По следам примера использования генетических алгоритмов от eax.me

В этой статье мы расскажем про один из интересных на наш взгляд экспериментов, которые мы давно планировали, но не могли реализовать, поскольку оригинальный код написан был на 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:

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

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

На этом все, и если у вас найдутся идеи, где это применить или как это улучшить, то пишите их в комментариях.

Добавить комментарий