Что такое грамматическая эволюция + легкая реализация

Совсем недавно я написал статью, в которой без объяснений показал то, на что способен метод грамматической эволюции. Я полностью, согласен, что так делать нельзя, но как хотелось показать результаты интересного метода. Я думал «что будет лучше: перевести первоисточник или дать свое собственное объяснение». Лень взяла верх.

Если кому-то интересны эволюционные методы и задача символьной регрессии (и не только), то прошу к прочтению.

Форма Бакуса-Наура

Сначала нужно сказать про то, что такое контекстно-независимая грамматика в форме Бакуса-Наура (сокращенно БНФ). Про формальные языки и грамматики на Хабре уже есть довольно интересная статья. Очень рекомендую к прочтению. Но наша цель заключается в том, чтобы понять, что такое БНФ и научиться этой формой пользоваться.

Википедия дает вполне адекватное определение:

БНФ — формальная система описания синтаксиса, в которой одни синтаксические категории последовательно определяются через другие категории. БНФ используется для описания контекстно-свободных формальных грамматик.


БНФ имеет терминальные и нетерминальные символы. Терминалы — константы. Мы их подставили и все тут, а вот с нетерминальными символами все гораздо интересней: их можно подставлять друг в друга по правилами.

Рассмотрим пример. У нас есть следующая формальная система описания синтаксиса контекстно-свободной грамматики:

<sntnce> :: = <sntnce> | <noun><verb> | <adverb><verb>


<noun> ::= Peter \, | \, ball


<verb> ::= ran \,|\, fell


<adverb> ::= quickly


В нашем примере множество нетерминальных символов

N =\{<sntnce>,<noun>,<verb>,<adverb>\}.


А множество терминальных символов представлено так

T= \{Peter, \, ball, \, quickly, \, ran, \, fell \}


Множество S будет содержать один элемент.

S = \{<sntnce>\}


Этот элемент будет входной точкой для постройки и работы с правилами.

Теперь, имея один нетерминальный элемент, мы можем составлять конструкции, которые будут соответствовать нашей грамматике.

Следим за цепочками

1) => => quickly => quickly ran

2) => => Peter => Peter fell

Возникает вопрос: на каком основании происходят данные подстановки? На этот вопрос я отвечу чуть позже.

Генетический алгоритм

Мне кажется, что этот алгоритм является каноничным в эволюционных кругах. Он прост в реализации и хорошо работает. Рассмотрение данного алгоритма необходимо для того, чтобы понять, что за механизм будет в качестве «движка» у метода грамматической эволюции. Но (!) на его месте может быть любой другой, удобный для вас, эволюционный алгоритм.

Итак, ГА использует поведение природы. На самом деле ничего нового придумано не было. Этот алгоритм работает уже миллионы лет. Спасибо ему. Ведь если б не он, то нас бы не было.

ГА состоит из нескольких этапов.

(1) Создание начальной популяции (создаем хромосому для каждой особи)

(2) Высчитывание у каждого индивидуума фитнес-функции (именно она показывает кто приспособлен в данной популяции лучше всего)

(3) Отбор лучший представителей для образования дальнейшего потомства

(4) Кроссовер

(5) Мутация

(6) После (4) получаем детей, часть которых проходит через (5). На выходе получаем потомство

(7) Отбор отцов и детей в новое поколение

(8) возврат к шагу (2), если значения, которые выдают дети нас не устраивает

Хромосома — закодированное представление нужной нам информации. В первоисточниках используется бинарное представление. Т.е. если нам нужно найти 4 параметра (каждый из них в интервале от 0 до 15), то на каждый параметр понадобится 4 бита (ноль или единица). А сама хромосома будет длиной 16. Все довольно примитивно.

Важно: можно использовать и десятичное представление. Я так и буду делать для грамматической эволюции.

Теперь немного про операторы в ГА и всякие фитнес функции.

Фитнес-функция — функционал, который мы должны оптимизировать. Он варьируется от задачи к задаче. Если стоит вопрос в минимизации функционала, то для селекции нужны родители, которые обладают как можно меньшей фитнес-функцией.

Кроссовер — классная штука. В генетическом программировании, кстати, этот оператор является чуть ли не единственным для получения потомства с лучшими качествами. Суть заключается в том, что мы берем двух родителей (а точнее их генотип). Делим его пополам. И меняем местами. Сейчас покажу.

Есть два списка:

first \, parent = [1,2,3,4,-5,-6,-7,-8,]


Second \, parent = [-1,-2,-3,-4,5,6,7,8]


Result:


Child \, 1 = [1,2,3,4,5,6,7,8]


Child \, 2 = [-1,-2,-3,-4,-5,-6,-7,-8]


Это был пример точечного кроссовера. Есть другие вариации на эту тему, но о них не будем.

Мутация — процесс в случайно замене того или иного гена.

was = [1,2,3,4]


be = [1,-5,3,4]


Часто употребимый метод отбора в новое поколение — элитарный. Мы просто берем n лучших особей из списка дети+ родители. А потом дополняем популяцию случайным образом до нужного количества.

Важно: размер хромосомы может быть как фиксированным, так и произвольным. Тоже самое касается и размера популяции.

Грамматическая эволюция

А вот теперь о самом главном. Что ж это за метод такой и с чем его едят.
Сама суть заключается в том, что у вас есть задача, которую надо решить. Вы строите грамматику в форме Бакуса-Наура. Создаете исходную популяцию, в которой у каждого индивидуума будет своя хромосома, описывающая какие правила когда, куда, подставлять. Важность заключается в том, что благодаря этим правилам вы получаете функцию, которую можете использовать для вычислений. Значение этой функции с подставленными в нее заранее заданными (или нет) параметрами может выступать в качестве функционала (фитнес-функции). Чем лучше функционал, тем лучше функция, а следовательно и индивидуум со своей хромосомой.

Подробней о хромосоме.

Пусть имеем следующую грамматику

::= |

::= x | 3×2

::= + | — | * | /

Дальше у нас есть такая хромосома

chromo = [2,1,6,4,5,1]


Изначально символьное представление функции содержит один нетерминальный символ: H = (как правило).

Берем первый элемент из chromo: 2. Считаем сколько правил в преобразования в : 2. Делим 2% 2 (по модулю!) = 0. Значит вместо подставляем . Таким образом Н = . Двойку из chromo выкидываем. Она больше не нужна.

На очереди единица. и снова смотрим на . 1% 2(число правил подстановки) = 1. Значит вместо подставляем . Получаем H = .

Если проделывать эти нехитрые манипуляции дальше, то получится такая цепочка

<val><op><e> (6\%3 = 0) -> x <op><e>


x <op> <e> (4 \% 4 = 0) -> x + <e>


x + <e> (5 \% 2 = 1) -> x + <val>


x + <val> (1 \% 3 = 1) -> x + 3


H = x + 3.


Дальше используем эту функцию, чтобы посчитать функционал и определить насколько нам подходит индивидуум с данным фенотипном (так функция зовется) и генотипом (хромосомой).

Это все. Да, есть несколько вариантов подстановки: в глубину (рассмотренный), в ширину, так называемая \pi — подстановка. Но это уже материал на отдельную статью.

А теперь пример.

Есть интервал времени t \in [-5,5]. Есть функция y(x) = 1+x+x^2+x^3. Нужно найти функцию, которая давала бы минимальную квадратичную ошибку — функционал данной задачи.

Посмотрим на код

модуль main

import GE
import time

def foo(x):
    return 1+x+x**2+x**3

interval = [-5,5]
values =[foo(elem) for elem in range(interval[0],interval[1])]

if __name__ == "__main__" :

    time_begin = time.time()
    result = GE.GA(
        dim=15,
        lengthPopulation=50,
        count=150
    )[0]

    print(result)
    print()
    print("time = {0}".format(time.time() - time_begin))


Функция foo генерирует значения, которые мы и будем сравнивать с теми, которые будут получаться у нас из функций, созданных каждым индивидуумом.

Длина хромосомы (dim) = 15;

Длина популяции = 50;

Число итераций (эволюций) = 150;

Модуль parser

import math
import random
import re

def rand(num):
    return int(math.trunc(random.random()*num))

def parse(chromo):
    length = len(chromo)
    j=0
    H = ""
    grammar = {
        "":[
                "",
                ""
        ],
        "":["+", "-", "*", "/"],

        "": [ "x" , "1"]
    }

    s = r"<+[expr|op|val]+>"
    pattern = re.compile(s)

    while(j" :
                    elem = ""
                c = len(grammar[elem])
                randd = rand(c)
                n = grammar[elem][randd]
                elem = elems[i]
                H = H.replace(elem,n,1)
        else:
            break

    return H


Словарь grammar содержит правила для грамматики. Далее алгоритм подстановки, который я описывал выше. После блок While нужен для завершения функции. Бывают случаи, когда хромосома закончена, а нетерминальные символы остались. Вот последний цикл и нужен для того, чтобы заменить все нетерминалы терминалами.
Важно: не факт, что конечная функция получится валидной с точки зрения семантики (она может и на ноль делить и все такое).

модуль GE

import random
import math
from parser import parse

def rand(num):
    return int(math.trunc(random.random() * num))

class Individ:
    def __init__(self, genotype=None):
        self.genotype = genotype
        self.phenotype = self.getPhenotype()
        self.fitness = self.getMistake()

    def __str__(self):
        return "Chromosome : {0}\nPhenotype = {2}\nFitness = {1}\n".format(self.genotype, self.fitness, self.phenotype)

    def getPhenotype(self):
        return parse(self.genotype)

    def getMistake(self):
        import main
        intr = main.interval
        vals = main.values
        f = eval("lambda x: {0}".format(self.phenotype))
        f_vals = []
        for i in range(intr[0], intr[1]):
            try:
                val = f(i)
                f_vals.append(val)
            except:
                return 10000
        try:
            return sum(list(map(lambda elems: (elems[0] - elems[1]) ** 2, list(zip(vals, f_vals)))))
        except:
            return 10000


def GA(dim, lengthPopulation, count):
    population = [inst for inst in getPopulation(lengthPopulation, dim)]
    while count > 0:
        if count % 50 == 0:
            print("count = {0}".format(count))
            print(population[0])
        childrnChromos = getChildrenChromose(population)
        mutation(childrnChromos, rand(0.3 * lengthPopulation))
        children = [child for child in getChildren(childrnChromos)]
        population = getNewPopulation(population, children)
        count -= 1
    return population

def getGenotype(gen_length=0):
    return [rand(200) for i in range(gen_length)]

def getPopulation(length, chromo_len):
    for i in range(0, length):
        yield Individ(genotype=getGenotype(chromo_len))

def getChildrenChromose(parents):
    children_chromo = []
    buf = parents[:]
    random.shuffle(buf)
    length = int(len(buf) / 2)
    for i in range(length):
        children_chromo += crossover(parents[i], parents[i + 1])
    return children_chromo

def getChildren(childrnChromos):
    l = len(childrnChromos)
    for i in range(l):
        yield Individ(childrnChromos[i])

def crossover(p1, p2):
    l = len(p1.genotype)
    d = rand(l - 1)
    return [p1.genotype[:d] + p2.genotype[d:], p2.genotype[:d] + p1.genotype[d:]]

def mutation(chldrnChromo, howMuch):
    l = len(chldrnChromo[0])
    for j in range(0, howMuch):
        chromo = chldrnChromo[j]
        chromo[rand(l - 1)] = rand(200)
        chldrnChromo[j] = chromo
    return chldrnChromo

def getNewPopulation(population, children):
    l_need = len(population)
    buf = (population + children)[:]
    buf.sort(key=lambda elem: elem.fitness)
    count = rand(0.2 * len(buf))
    result = buf[:count]
    another = buf[count:]
    i = count
    while i < l_need:
        r = rand(l_need - count)
        while another[r] in result:
            r = rand(l_need - count)
        result.append(another[r])
        i += 1
    return result


Обычная реализация генетического алгоритма.

Функция GA является входной точкой в цикл эволюций.

В общем то функции говорят сами за себя, и реализация каждой функции не очень длинная. Замечу, что селекция для кроссовера не стандартная. Я просто мешаю родителей и выбираю первую половину перемешанной кучи. Для данной задачи это не сильно вредно, но лучше так не делать. Есть десяток (или даже больше) методов, которые позволяют отобрать лучших кандидатов для кроссовера.

Каждый индивидуум имеет три поля: генотип, фенотип, значение фитнес-функции.

В итоге, за время работы алгоритма

time= 1.2367351ms


получаю функцию

x+x/1*x+x*x*1*x+1/1


что очень сильно напоминает

1+ x +x^2 +x^3


Как вы можете видеть, грамматика очень важна.

Надеюсь, теперь стало ясно что за метод такой грамматическая эволюция и как его использовать для решения задач. Интересен тот факт, что символьная регрессия применима не только для функций. Мы можем создавать инструкции для роботов, а также строить структуру нейронной сети и настраивать в ней параметры. Нам становится не нужен метод обратного распространения ошибки, для обучения сети. Вместе с ним можно отказаться из от функций активаций (обязательной) первой производной. А также мы больше не нуждаемся в обучающей выборке. Это интересный подход, который требует размышления и исследований. Но могу сказать, что он работает.

Еще раз даю ссылку на первоисточник. Если кто хочет глубже понять метод.

© Habrahabr.ru