- BrainTools - https://www.braintools.ru -

А если агенту не платить? Альтернативная механика обучения с подкреплением

В машинном обучении [1] есть такой метод – обучение с подкреплением [2] (reinforcement learning, RL), который используется для решения задач последовательного принятия решений. В этом методе агент на каждом шаге взаимодействует со средой, изменяя её. Обратной связью для него является некая искусственно сконструированная награда, которая выдаётся на каждой итерации взаимодействия. Основная проблема в том, что действие и награда напрямую не коррелируют. Часто, награда назначается за какое-то финальное достижение, которого можно достичь только выполнив определенную последовательность действий с нулевым или даже отрицательным вознаграждением. Существуют различные способы “протянуть” награду вдоль всей траектории, чтобы в конце концов агент осваивал более-менее приемлемую стратегию поведения [3].

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

Чтобы предложить алгоритм обучения агента, представим себя на месте существа, которое ничего не знает и ничего не умеет (что довольно сложно сделать имея сформированный интеллект [4]). А теперь попытаемся понять, как такое существо обучается выполнению какой-либо задачи, прикинем последовательность стадий обучения:

  1. Кажется, что сначала мы должны пробовать и ощупывать окружающую среду, чтобы набрать статистику как мир реагирует на наши действия. Запоминаем, что изменится, когда сделаешь что-либо. Если потянешь руку – можешь дотянутся до игрушки. Сожмёшь пальцы – сможешь удержать и т.д.

  2. Когда мы изучили элементарные действия, пытаемся выполнить задуманное, выбирая те действия, которые способны привести к результату. Дети, которым уже несколько месяцев, тянутся к игрушке руками, а не перебирают все доступные организму движения. Наконец у нас что-то получилось. Получилось раз, получилось два, пока случайно. Набираем успешные траектории, выбираем из них кратчайшие.

  3. Финальная стадия – обобщение (доведение до автоматизма). Когда сто раз собрали пирамидку, уже не раздумывая делаем её из любого положения.

Вот такую схему и попробуем собрать на модельном примере: движение агента на дискретной сетке с использованием ограниченного набора действий. Агент должен “сам” вывести последовательность действий, необходимую для достижения цели.

Окружение

Пускай объект в нашем мире характеризуется всего тремя величинами: x, y и направление взгляда, назовём их “характеристиками”. Первые две характеристики – любые целые числа, а последняя пускай принимает значения от 0 до 3 (условно: направо, вниз, налево, вверх). Эти характеристики зададим списком, т.е. обращаться к ним будем по индексам. Это неудобно для человеческого восприятия [5], но позволяет делать обобщения. Заодно объявим некоторые функции для работы с характеристиками и с “целью” (словарь, хранящий пары {индекс характеристики: целевое значение}):

Код
NCHARS := 3      # x, y, dir
def create_rand_chars():
    chars := [0] * NCHARS
    chars[0] := random.randrange(5, 50)
    chars[1] := random.randrange(5, 50)
    chars[2] := random.randrange(0, 4)
    return chars

# изменяет хар-ки на заданные приращения
def update_chars(chars, deltas):
    for i, d in enumerate(deltas):
        chars[i] += d
        if i = 2:
            chars[i] := (chars[i] + 4) % 4  # periodic condition

# проверяет рядом ли коордианты с целью
def is_near(chars, aim):
    delta := abs(chars[0] - aim[0]) + abs(chars[1] - aim[1])
    return delta < 2

# генерирует цель от текущего положения агента (его характеристик)
def gen_aim(chars, radius):
    x := random.randrange(-radius, radius+1)
    y := random.randrange(-radius, radius+1)
    if x = 0 and y = 0:
        r := random.random()
        if r < 0.25:
            x += random.randrange(2, radius+1)
        elif r < 0.5:
            y += random.randrange(2, radius+1)
        elif r < 0.75:
            x -= random.randrange(2, radius+1)
        else:
            y -= random.randrange(2, radius+1)
    return {0: chars[0] + x, 1: chars[1] + y}

# делает туплу, как разницу координат до цели и направление - нужно для сохранения результатов движения
def aimed_chars_to_tuple(chars: list, aim: dict):
    dx = aim[0] - chars[0]
    dy = aim[1] - chars[1]
    return dx, dy, chars[2]
А если агенту не платить? Альтернативная механика обучения с подкреплением - 1 [6]

Создадим класс “Окружение”, который будет определять приращения к характеристикам объекта на основании его действия:

Код
ACTIONS := {'left': 0, 'right': 1, 'go': 2}
IDX_TO_ACT := {idx: action for action, idx in ACTIONS.items()}
ACT_LIST := list(ACTIONS.keys())

DX := [1, 0, -1, 0]
DY := [0, 1, 0, -1]

class Env:
    def apply_action(self, action, chars):
        affected = set()       # to save affected chars
        deltas = [0] * NCHARS
        if action = 'left':
            deltas[2] = -1
            affected.add(2)
        elif action = 'right':
            deltas[2] = 1
            affected.add(2)
        elif action = 'go':
            deltas[0] = DX[chars[2]]
            deltas[1] = DY[chars[2]]
            if deltas[0] <> 0:
                affected.add(0)
            if deltas[1] <> 0:
                affected.add(1)
        return deltas, affected
А если агенту не платить? Альтернативная механика обучения с подкреплением - 2 [6]

Кроме приращений характеристик метод класса возвращает множество индексов характеристик, которые изменились (с этим просто удобнее обучать агента).

Агент

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

Агент (начало)
class Agent:
    def __init__(self):
        self.chars = create_rand_chars()

        self.act_effects = {}      
        self.char_to_act = {}       
        self.opposite_actions = {}
А если агенту не платить? Альтернативная механика обучения с подкреплением - 3 [6]

Стадия 1. Изучение окружающего мира

Первое, что, кажется, нужно сделать, это определить какое действие на какую характеристику влияет:

Код
    def lstage1a(self, env: Env) -> dict:
        affected_chars := {}
        for _ in range(20):
            action := random.choice(ACT_LIST)
            deltas, changed := env.apply_action(action, self.chars)
            update_chars(self.chars, deltas)
            if changed:
                if action in affected_chars:
                    affected_chars[action] |= changed
                else:
                    affected_chars[action] := changed
        return affected_chars
А если агенту не платить? Альтернативная механика обучения с подкреплением - 4 [6]

Мы случайно выбираем действие и смотрим какая характеристика изменилась. И так много раз. Как младенец тыкаем наугад во все стороны, пробуем на зуб всё до чего дотянулись. Метод возвращает словарь {действие: множество индексов характеристик, которые это действие может изменить}.

Следующий этап – больше конкретики, мы уже знаем, на что влияют действия, теперь бы ещё понять как:

Код
    def lstage1b(self, env: Env, affected_chars: dict):
        for action_of_interest, dims in affected_chars.items():
            data := []
            uniques := [set() for _ in range(NCHARS * 2)]
            i := 0
            while i < 30 * len(dims):
                action := random.choice(ACT_LIST)
                deltas, _ := env.apply_action(action, self.chars)
                if action = action_of_interest:
                    data.append(self.chars.copy() + deltas)
                    for j in range(NCHARS):
                        uniques[j].add(self.chars[j])
                        if deltas[j] <> 0:
                            uniques[j + NCHARS].add(deltas[j])
                    i += 1
                update_chars(self.chars, deltas)

            # describe effect: (condition, dimension, value)
            for dimension in dims:
                if len(uniques[NCHARS + dimension]) = 1:  # только одно значение => результат - константа
                    if action_of_interest in self.act_effects:
                        self.act_effects[action_of_interest].append((None, dimension, data[0][NCHARS + dimension]))
                    else:
                        self.act_effects[action_of_interest] = [(None, dimension, data[0][NCHARS + dimension])]

                # более сложная ситуация:
                #   есть несколько значений => вероятно есть какое-то условие, по которому они выбираются
                #   предположим, что это условие это определенное значение одного из параметров
                elif len(uniques[NCHARS + dimension]) > 1:
                    for val in uniques[NCHARS + dimension]:

                        # простая эвристика, считаем, что то измерение, где меньше уникальных - это и есть условие
                        subdata := [row[:NCHARS] for row in data if row[NCHARS + dimension] = val]
                        subuniques := [set() for _ in range(NCHARS)]

                        min_un := 1000
                        min_un_index := None
                        for j in range(NCHARS):
                            for row in subdata:
                                subuniques[j].add(row[j])
                            if len(subuniques[j]) < min_un:
                                min_un := len(subuniques[j])
                                min_un_index := j
                        for unique in subuniques[min_un_index]:
                            if action_of_interest in self.act_effects:
                                self.act_effects[action_of_interest].append(((min_un_index, unique), dimension, val))
                            else:
                                self.act_effects[action_of_interest] = [((min_un_index, unique), dimension, val)]

        self.cultivate_act_effects()
А если агенту не платить? Альтернативная механика обучения с подкреплением - 5 [6]

Метод использует словарь, полученный на прошлом этапе. Идём по этому словарю и смотрим, как конкретно действия влияют на характеристики. Мы также выбираем случайные действия, но логируем только те, которые в данный момент изучаем. Кроме того, запоминаем в каких условиях эти действия были выполнены. Самое простое что мы можем детектировать, это случай, когда действие всегда изменяет характеристику на одну и ту же величину – константный эффект. Чуть сложнее ситуация, когда эффект зависит от текущего значения другой характеристики.

Данный метод сохраняет в self.act_effects словарь {действие: список[ (условие, индекс изменяемой характеристики, величина изменения) ] }. Как видим словарь поддерживает множество эффектов на одно действие. Условие может быть None, а может быть туплой (индекс характеристики, её значение). Пока мы поддерживаем только один тип условий, равенство одной из характеристик некой константе. Хорошо бы придумать как можно задавать больше условий.

В конце вызывается другой метод, вычисляющий вспомогательный словарь из self.act_effects:

Код
    def cultivate_act_effects(self):
        # обращаем словарь действий: {action: effect} -> {affected_characteristic: action}
        for act, effects in self.act_effects.items():
            for eff in effects:
                char := eff[1]  # affected characteristic
                if char in self.char_to_act:
                    self.char_to_act[char].append((act, eff[2], eff[0]))
                else:
                    self.char_to_act[char] := [(act, eff[2], eff[0])]  # (action, value, condition)
А если агенту не платить? Альтернативная механика обучения с подкреплением - 6 [6]

Этот метод “выворачивает” словарь self.act_effects, чтобы получить словарь, где ключом будет индекс изменяемой характеристики, а значением – список тупл из действий, условий и величины изменения. Т.е. новый словарь должен нам сказать, какое действие выполнить, чтобы изменить заданную характеристику. Ещё нам понадобится словарь “противоположных действий”, чтобы не делать подряд действия, которые друг друга нивелируют.

Код
    def find_opp_actions(self):
        act_list := list(self.act_effects)
        for i, action in enumerate(act_list):
            if len(self.act_effects[action]) = 1:
                for j in range(i + 1, len(act_list)):
                    act2 := act_list[j]
                    if len(self.act_effects[act2]) = 1:
                        if self.act_effects[action][0][1] = self.act_effects[act2][0][1] and self.act_effects[action][0][2] = -self.act_effects[act2][0][2]:
                            self.opposite_actions[action] := act2
                            self.opposite_actions[act2] := action
А если агенту не платить? Альтернативная механика обучения с подкреплением - 7 [6]

Теперь пишем метод find_solution, который должен предложить действие, чтобы достичь цели:

Код метода find_solution
    def find_solution(self, aim, prev_action):
        actions = []

        for char, val in aim.items():
            delta := calc_delta(val, self.chars[char], char)
            actions.extend([list(data) for data in self.char_to_act[char] if (delta * data[1]) > 0])      # has the same sign

        for i, action in enumerate(actions):
            if action[2]:       # there is a condition
                char := action[2][0]
                val := action[2][1]
                if self.chars[char] <> val:     
                    delta := calc_delta(val, self.chars[char], char)
                    replace_actions := [list(data) for data in self.char_to_act[char] if (delta * data[1]) > 0]
                    if replace_actions:
                        actions[i] := replace_actions[0]     # the first approximation

        if prev_action in self.opposite_actions:
            actions := [action for action in actions if action[0] <> self.opposite_actions[prev_action]]

        action := random.choice(actions)[0] if actions else None
        return action
А если агенту не платить? Альтернативная механика обучения с подкреплением - 8 [6]

Метод работает просто, сначала перебирает по словарю все действия, которые приближают целевые характеристики. Потом проверяются условия этих действий, если они не выполняются, то эти действия заменяются на другие, которые приближают выполнение условия. По-хорошему, это надо делать рекурсивно, но в данном примере достаточно только одного уровня иерархии. Из полученного списка действий выбираем одно случайно.

Стадия 2. Сбор траекторий

До этого мы просто исследовали среду, тыкались наугад. Пора научится решать поставленную задачу, достигать цели. Для этого мы тоже должны набрать достаточно опыта [7]. Соберем несколько траекторий, в которых агент пытается дойти до цели используя метод find_solution:

Код
    RADIUS := 5              # radius around agent coordinates to place aim
    N_EPISODES := 10000
    MAX_STEP := RADIUS * 3 + 5       # maximal number of steps to solve episode

    agent := Agent()
    env := Env()

    affected_chars := agent.lstage1a(env)
    agent.lstage1b(env, affected_chars)
    agent.find_opp_actions()

    memory := {}   
    n_success := 0
    traj_len := 0
    for ep in range(N_EPISODES):
        aim = gen_aim(agent.chars, RADIUS)
        nearest_step := None
        traj := []   # list of tuples (agent.chars, action)
        j_add := 0
        prev_action := None
        for step in range(MAX_STEP):
            if not nearest_step and is_near(agent.chars, aim):
                nearest_step := step
                j_add := 1
            action := agent.find_solution(aim, prev_action)
            traj.append((agent.chars.copy(), action))
            if action:
                deltas, _ := env.apply_action(action, agent.chars)
                update_chars(agent.chars, deltas)
            else:
                n_success += 1
                traj_len += step
                nearest_step := step
                break
            prev_action := action

        # only successful trajectories
        if nearest_step:
            i := nearest_step - 1
            j := 1 + j_add
            while i > 0:
                inds := aimed_chars_to_tuple(traj[i][0], aim)
                if inds not in memory:
                    memory[inds] := [j, traj[i][1]]
                elif memory[inds][0] = j:     # the same number of steps => add action if not presented
                    if traj[i][1] not in memory[inds]:
                        memory[inds].append(traj[i][1])
                elif memory[inds][0] > j:      # new variant is faster, replace old
                    memory[inds] = [j, traj[i][1]]
                else:       # old variant is better, skip current
                    break
                j += 1
                i -= 1

    print("n_success: {:.1f}%, mean length of traj: {:.1f}".format(100 * n_success / N_EPISODES, traj_len / n_success if n_success else 0))
    print("memory size:", len(memory))
    # теперь оставим только одно действие в словаре и уберем число шагов
    memory := {inds: actions[1] for inds, actions in memory.items()}
А если агенту не платить? Альтернативная механика обучения с подкреплением - 9 [6]

С помощью описанного выше метода агент пытается достичь цели в большом количестве эпизодов. Это уже не совсем наугад, тут мы сильно сужаем пространство поиска. Успешные траектории обрабатываются: создается словарь, где ключи – это тупла значений характеристик по отношению к цели (dx, dy, direction), а значение список: [количество шагов до цели, действие1, действие2, …]. В ходе сбора словаря минимизируется длина траекторий: хранятся только те действия, которые в конечном итоге привели к скорейшему достижению цели. После сбора, заменяем список на единственное действие.

Обобщение

Наконец финальная стадия обучения – обобщение материала. Здесь мы должны вывести некое правило поведения [8], на основе собранного опыта и обобщить его на те ситуации, которые нам не встретились. Я долго думал, каким это методом сделать, рассматривал решающие деревья и inductive logic programming, но в конечном счете остановился на нейронных сетях. Небольшая сетка-классификатор, на вход dx, dy и direction (в one-hot представлении), на выходе – классификатор на количество действий. Обучаем на полученных выше данных, конвертированных в pytorch тензора. Сам цикл обучения градиентным спуском писать не буду, он тривиальный, приведу только структуру нейронной сети (вход это батч из 6 чисел: dx, dy, one_hot_direction):

Код нейронки
class ExpNet(nn.Module):
    def __init__(self, input_dim, group_dim, n_actions):
        super().__init__()
        self.input_dim := input_dim
        self.group_dim := group_dim

        fact_inp_dim := input_dim + 4
        self.net := nn.Sequential(
            nn.Linear(fact_inp_dim * group_dim, n_actions)
        )

    def forward(self, comb):
        x := torch.sign(comb[:, :2])
        delt := torch.sign(comb[:, 0:1] - comb[:, 1:2] )
        sm := torch.sqrt( torch.sum(comb[:, :2]**2, dim=1))  + 0.1
        r := comb[:, :2] / sm.unsqueeze(1)
        rad := torch.log( sm.unsqueeze(1) + 1 )
        x := torch.cat((x, delt, r, rad), dim=1)
        g := comb[:, 2:]

        B := x.size(0)

        x_expanded := torch.einsum('bi,bj->bij', g, x)  
        x_expanded := x_expanded.reshape(B, -1)         
        return self.net(x_expanded)

model := ExpNet(2, 4, len(ACTIONS))
А если агенту не платить? Альтернативная механика обучения с подкреплением - 10 [6]

Разумеется, я экспериментировал с разными архитектурами. В конечном счете остановился на такой, здесь даже нет скрытых слоёв в классификаторе. С последними получается точность на обучающем датасете выше, но в боевых условиях результат хуже.

Проверка

Цикл проверки расписывать не буду, там всё просто, мы просто конвертируем ситуации в торч-тензор, подаем в нейронку, аргмаксом выбираем действие. Для проверки использован намного больший радиус (20 вместо 5). Кроме того, в качестве baseline используем следующий алгоритм: если текущая ситуация есть в “памяти” (словарь, полученный при сборе траекторий), то тупо выбираем действие оттуда, иначе вызываем уже описанный find_solution:

Код
    def solution_by_mem(self, aim, prev_action, memory):
        inds := aimed_chars_to_tuple(self.chars, aim)
        if inds in memory:
            return memory[inds]
        else:
            return self.find_solution(aim, prev_action)
А если агенту не платить? Альтернативная механика обучения с подкреплением - 11 [6]

В начальном коде действие “go” перемещает агента на единичку вправо/вниз/влево/верх в зависимости от последний характеристики, которую можно интерпретировать как ориентация агента. Но нас интересует общность алгоритма, поэтому интересно, что будет, если изменить эффект действий. Для этого достаточно поменять всего две строчки в коде окружения, например следующим образом:

# bishop:
DX := [1, -1, -1, 1]
DY := [1, 1, -1, -1]

# knight
DX := [2, -1, -2, 1]
DY := [1, 2, -1, -2]
А если агенту не платить? Альтернативная механика обучения с подкреплением - 12 [6]

В первом варианте агент двигается только по диагонали (ориентированный аналог шахматного слона, bishop), во втором – ориентированный конь (knight). Результаты экспериментов приведены в таблице ниже. От запуска к запуску результаты немного отличаются, но в целом плюс-минус стабильны, я не стал проводить полноценный статистический анализ.

движение агента

сбор траекторий: % достижения цели, средняя длина траектории, размер словаря “памяти”

результат обучения нейронной сети после 6000 эпох, loss (accuracy, %)

baseline с использованием памяти [9]: % достижения (средняя длина траектории)

решение принимает нейронная сеть: % достижения (средняя длина траектории)

tower (базовый вариант)

99%, 8.5, 337

0.06 (98%)

75% (24.9)

100% (22.5)

bishop

42%, 9.2, 534

0.24 (93%)

30% (25.3)

50% (16.0)

knight

18%, 6.9, 679

0.31 (88%)

19% (20.3)

20% (11.2)

Напомню, что для сбора обучающих траекторий мы генерировали цель в пределах квадрата 5×5 от положения агента, а для проверки – в пределах 20×20. Для первого варианта действий агент на основе нейросети справляется в 100% случаев. Для геометрии “слон” – в 50%, но мы помним, что слон ходит только по клеткам одного цвета, т.е. 50% это максимально достижимый результат. Для “коня” можно сказать, что базовая линия даёт тот же процент успешности, что и алгоритм с нейронкой. Для коня и слона наблюдается сильное уменьшение средней длины траектории, т.е. с использованием нейросети агент движется более целенаправленно. Сравним занимаемую память: веса нейросетевой модели (файл .pth) занимают 2 Кб, а словарь памяти (файл .pkl) для случая “конь” – 9 Кб. В целом считаю эксперимент успешным.

Возможно, для коня, который прыгает только в одну сторону, цель тоже не всегда достижима, не проверял. Кстати, для обычного коня на бесконечной доске, насколько мне известно, не существует аналитического алгоритма достижения цели. Может быть стоит ввести два действия: левый прыжок и правый прыжок (+2 в одном направлении и +1/-1 в другом) и проверить, найдёт ли алгоритм решение в этом случае.

Заключение

Структура обучения получилась модульной, мы легко можем разбить её на стадии. Обратите внимание [10], в простых случаях первую стадию можно опустить. Мы можешь написать словарь действий сами, имитируя “врожденные знания” для последующих этапов.

Что ещё можно сделать? Можно попробовать добавить блок мотивации [11] (сейчас его нет, цель задается извне). Обратная связь используется только на начальных этапах. В принципе, можно для каждого действия сопоставлять ожидаемый результат с фактическим. Добавить удивление, если наблюдается расхождение и наворотить какие-то механизмы поверх этого. Добавить каким-либо образом операционную память и планирование.

В результате мы уходим от монолитного RL-алгоритма к конструктору из модулей и, возможно, это путь к построению интеллектуальных систем.

Автор: azTotMD

Источник [12]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/26476

URLs in this post:

[1] обучении: http://www.braintools.ru/article/5125

[2] подкреплением: http://www.braintools.ru/article/5528

[3] поведения: http://www.braintools.ru/article/9372

[4] интеллект: http://www.braintools.ru/article/7605

[5] восприятия: http://www.braintools.ru/article/7534

[6] Image: https://sourcecraft.dev/

[7] опыта: http://www.braintools.ru/article/6952

[8] поведения: http://www.braintools.ru/article/5593

[9] памяти: http://www.braintools.ru/article/4140

[10] внимание: http://www.braintools.ru/article/7595

[11] мотивации: http://www.braintools.ru/article/9537

[12] Источник: https://habr.com/ru/articles/1005768/?utm_campaign=1005768&utm_source=habrahabr&utm_medium=rss

www.BrainTools.ru

Rambler's Top100