В этой статье мы расскажем про один из интересных на наш взгляд экспериментов, которые мы давно планировали, но не могли реализовать, поскольку оригинальный код написан был на Perl, а разбираться с алгоритмом не было времени.
Автор оригинального кода довольно известен и для его блога eax.me, мы как-то делали гостевую статью про FPGA, и именно его идею мы и хотели воспроизвести в D.
Эксперимент, а именно так можно это назвать, который будет описываться здесь связан с генетическими алгоритмами…
Про генетические алгоритмы, мы уже рассказывали в статье и там все было завязано на уравнениях.
Эта статья не будет исключением, однако, мы займемся несколько иной задачей: будем аппроксимировать функции полиномами некоторой степени. Если на этом моменте, вы решили статью закрыть, то не спешите: все описанное в этой статье не выходит за пределы обычной школьной математики и не требует от вас каких-либо познаний в области высшей математики.
Давайте сразу поясним, что мы будем сегодня делать: в этой статье, мы будем искать некую формулу, которая будет описывать некоторую зависимость между одним набором данных и другим набором. Эта формула будет представлять собой полином, который принимает произвольную величину из первого набора и дает значение, которое почти совпадает с некоторым другим значением из второго набора. Если вам непонятно, то мы будем пытаться построить зависимость между набором точек X и точек Y c помощью полинома произвольный степени, которую можно выбрать.
Полином — это многочлен, то есть выражение, следующего вида:
x + a0 * x + a1 * (x ^^ 2) + a2 * (x ^^ 3) + ... + an * (x ^^ n - 1)
где a0,…,an — произвольные коэффициенты, а n — это максимальная степень полинома (многочлена).
Т.е. по сути, мы будем искать приближение некоторой функции Y = F(X) с помощью многочлена степени n.
Поскольку, степени меняются последовательно и есть даже доказательства того, что любую функциональную зависимость можно приблизить полиномом некоторой степени, то наша задача состоит лишь в том, чтобы для выбранной степени n полинома подобрать коэффициенты.
Существует множество разных методов, как найти эти коэффициенты, но в основном, как мы поняли все сводится к решению некоторой системы линейных уравнений, с учетом того, что данных может быть очень много, то такие подходы — это ресурсозатратные вычисления. Кроме того, порой не требуется высокая точность приближения, но важно получить хотя бы примерный результат.
И вот тут, можно найти своего рода компромиссное решение — использовать методы поиска, которые не ориентированы на высокую точность, и по сути своей являются эвристическими. Одним из таких методов является генетический алгоритм. Оригинальную идею, как уже говорилось ранее, реализовал автор блога eax.me Александр, который предложил подбирать эти коэффициенты с помощью генетического алгоритма.
Алгоритм, который предложил он, нам показался довольно простым и логичным, однако, как говорилось ранее, у нас были трудности понимания некоторых деталей (поскольку скрипт был написан на Perl, который никто из авторов LightHouse Software никогда не использовал) да и просто хотелось бы иметь скрипт, который работает также. Насчет последнего стоит объяснить: несмотря на казалось бы узкую специализацию такого алгоритма, он удивительно полезен во многих областях от просто оптимизации до поиска разных зависимостей между полученными с приборов данными.
Алгоритм, в общих чертах, работает следующим образом: считывается некоторый CSV-файл, в котором записано множество точек в виде x;y и загружается в некоторую табличку, которая будет применяться далее в обработке. Далее, на основании предварительно заданных констант, таких как степень полинома, максимальное отклонение полинома от аппроксимируемых значений и некоторых других, создается начальное поколение особей, к которому далее применяются методы генетического алгоритма. Особь представляет собой небольшую структуру, которая содержит в себе массив, длины равной максимальной степени многочлена и значение некоторой функции приспособленности. Функция приспособленности, в данном случае, это максимальное отклонение значения полинома (коэффициенты которого и содержит особь) от табличных значений, и это обычная разница между ходом «графиков» полинома и исходной функциональной зависимости. Далее, после формирования первоначального поколения, применяется основной алгоритм, который включает в себя скрещивание и мутацию. Скрещивание происходит просто, как взятие двух некоторых особей и генерации новой с помощью простого попарного усреднения всех значений полинома, хранимых обеими особями. Мутация осуществляется на основании взятия коэффициентов, которые хранит особь, и умножения их на некое число с последующим сложением с текущими значениями. Внутри алгоритма происходит постоянное применение скрещивания и мутаций, после чего происходит подсчет функций приспособленности для получившейся популяции (т.е набора особей) и сортировка всей популяции на основании функции приспособленности (именно для ее вычисления и нужна таблица значений). Чем функция приспособленности меньше, тем меньше отклонение от исходной функции, и следовательно, лучше результат. Для улучшения результатов некоторая часть популяции отмирает и эта та часть, у которой значение функции приспособленности больше всего. После проведения всех этих манипуляций цикл повторяется, и как правило один такой цикл называется поколением.
В оригинальном алгоритме, цикл повторяется до тех пор, пока не будет достигнуто минимальное возможное отклонение, которое устанавливается отдельной константой, однако, мы внесли в алгоритм ряд своих улучшений. Мы добавили ряд констант, которые управляют тем, в каких пределах будут генерироваться коэффициенты, а также сделали возможность установки необходимого количества поколений, которые должен пройти алгоритм в дополнение к изначальному условию минимизации отклонения от графика. Еще мы добавили возможность в алгоритме указать долю мутаций, а также ряд случайных перемешиваний самой популяции, чтобы улучшить разнообразие особей и увеличить сходимость алгоритма.
Несмотря на ряд изменений, сам алгоритм по сути остался без изменений и в нем по прежнему можно многое улучшить.
А сама реализация на D выглядит так:
import std.algorithm; import std.conv; import std.file; import std.math; import std.random; import std.stdio; import std.string; enum MAXIMUM_POLYNOMIAL_POWER = 5; enum NUMBER_OF_BEST_INDIVIDUALS = 32; enum POINT_SELECTION_FREQUENCY = 1; enum double MINIMAL_POLYNOMIAL_DEVIATION = 0.0000005; enum MINIMUM_X = -4.0; enum MAXIMUM_X = 4.0; auto loadCSV(string filename) { double[double] points; File file = File(filename); string line; // счетчик точек int pointsCounter; while ((line = file.readln.strip) != "") { if (++pointsCounter == POINT_SELECTION_FREQUENCY) { pointsCounter = 0; auto xy = line.split(";"); if (xy.length == 2) { auto x = to!double(xy[0]); auto y = to!double(xy[1]); points[x] = y; } } } file.close; return points; } struct Individual { double[] data; double fitness; } /// Сгенерировать нулевое поколение auto firstGeneration() { Individual[] population; auto rng = Random(unpredictableSeed); foreach (e; 0..NUMBER_OF_BEST_INDIVIDUALS) { double[] data; foreach (_; 0..MAXIMUM_POLYNOMIAL_POWER+1) { auto p = uniform(MINIMUM_X, MAXIMUM_X, rng); if (p.isNaN) { p = 0.0; } data ~= p; } population ~= Individual(data, double.max); } return population; } /// Вычислить функцию приспособленности double calculateFitness(ref const double[] data, ref double[double] table) { double maxDelta = 0.0f; foreach (x, y; table) { double delta = abs(y - calculatePolynomial(data, x)); maxDelta = delta > maxDelta ? delta : maxDelta; } return maxDelta; } /// Вычислить значение полинома в некоторой точке double calculatePolynomial(ref const double[] data, double x) { double result = 0.0; foreach (i, e; data) { result += e * (x ^^ i); } return result; } /// Запустить генетический алгоритм auto runGeneticAlgorithm(ref double[double] pointsTable, double mutationRate = 0.5, size_t maximumGenerations = 150) { assert(mutationRate < 1.0 && mutationRate > 0.0); // начальная популяция Individual[] population = firstGeneration(); // номер поколения size_t generationNumber = 0; auto rng = Random(unpredictableSeed); while (population[0].fitness >= MINIMAL_POLYNOMIAL_DEVIATION) { for (size_t i = 0; i < NUMBER_OF_BEST_INDIVIDUALS - 1; ++i) { for (size_t j = i + 1; j < NUMBER_OF_BEST_INDIVIDUALS; ++j) { double[] tmp; // скрещивание for (size_t k = 0; k < MAXIMUM_POLYNOMIAL_POWER + 1; k++) { tmp ~= (population[i].data[k] + population[j].data[k]) * 0.5; } // мутация if (rng.dice(1.0 - mutationRate, mutationRate)) { foreach (ref e; tmp) { e += (e + uniform(MINIMUM_X, MAXIMUM_X, rng)); } } population ~= Individual(tmp); } } // вымирание предков population = population[NUMBER_OF_BEST_INDIVIDUALS..$]; // перемешивание population.randomShuffle(rng); // вычисляем функции приспособленности foreach (ref e; population) { e.fitness = calculateFitness(e.data, pointsTable); } // сортировка по значению функции приспособленности sort!((a, b) => a.fitness < b.fitness)(population); // отбираем лучших population = population[0..NUMBER_OF_BEST_INDIVIDUALS]; // перемешивание population.randomShuffle(rng); // выводим номер поколения и значение целевой функции у "лучшего" потомка writef("%d;%f;%f;", generationNumber, population[0].fitness, population[$-1].fitness); // выводим формулу для D string formula = ""; foreach (i, e; population[0].data) { if (i == 0) { formula ~= format(`%.05f`, e); } else { auto w = format(`%.05f * (x ^^ %d)`, e, i); formula ~= (e >= 0) ? (" + " ~ w) : w.replace("-", " - "); } } // пишем формулу write(formula); writeln; // if (generationNumber == maximumGenerations ) { return population[0]; } // увеличиваем номер поколения generationNumber++; } return population[0]; } void main() { auto w = loadCSV(`test.csv`); auto p = runGeneticAlgorithm(w, 0.76, 2048); }
Как видите, сам алгоритм несложен и состоит из нескольких довольно простых функций, почти каждая из которых прокомментирована. Сам алгоритм требует для своей работы заранее подготовленный CSV-файл, который мы делали следующим скриптом:
import std.math; import std.stdio; void main() { File file; file.open(`test.csv`, `w`); for (float i = 0.0f; i < 2 * PI; i += 0.000001) { file.writeln(i, `;`, sin(i)); } file.close; }
Здесь дан пример для синуса, но вы можете заменить его вычисление на свою функцию, а также можете изменить шаги вычисления и диапазон начальных значений (также, можно просто отказаться от вычислений и подать свой, заранее подготовленный файл).
В функцию runGeneticAlgorithm подаются заранее подготовленная таблица значений (делается функцией loadCSV), количество поколений и доля мутации в популяции, а результатом выполнения ее является набор формул, которые последовательно выводятся в стандартный вывод вместе с номером поколения и значением функции приспособленности.
Сам формат вывода данного алгоритма выглядит следующим образом:
номер поколения;функция приспособленности лучшего потомка;функция приспособленности худшего потомка;формула полинома для лучшего потомка
А вот пример формулы полинома, который был нами найден для синуса на промежутке от 0 до 2 * PI:
auto d = 0.00994 + 0.86994 * (x ^^ 1) + 0.28182 * (x ^^ 2) - 0.39955 * (x ^^ 3) + 0.08809 * (x ^^ 4) - 0.00559 * (x ^^ 5);
И для более наглядного представления того, что получилось можно сравнить два графика, которые мы сделали через онлайн-сервис Mathway:

Красный график — это исходная функция синуса, а синий график — это график того полинома, чтио мы нашли. Чем ближе график полинома проходит от графика самой функциональной зависимости, тем точнее значение полученной формулы для значений из файла!
Это не единственный пример и вы можете сами испытать приведенный в статье код и найти свои примеры аппроксимации известных функций — им все это с помощью всего лишь сложений и умножений. Конечно, можно многое можно улучшить, к примеру, найти способы выхода алгоритма из стационарного состояния или улучшить вычисление полинома через схему Горнера.
На этом все, и если у вас найдутся идеи, где это применить или как это улучшить, то пишите их в комментариях.