- BrainTools - https://www.braintools.ru -
В машинном обучении [1] есть такой метод – обучение с подкреплением [2] (reinforcement learning, RL), который используется для решения задач последовательного принятия решений. В этом методе агент на каждом шаге взаимодействует со средой, изменяя её. Обратной связью для него является некая искусственно сконструированная награда, которая выдаётся на каждой итерации взаимодействия. Основная проблема в том, что действие и награда напрямую не коррелируют. Часто, награда назначается за какое-то финальное достижение, которого можно достичь только выполнив определенную последовательность действий с нулевым или даже отрицательным вознаграждением. Существуют различные способы “протянуть” награду вдоль всей траектории, чтобы в конце концов агент осваивал более-менее приемлемую стратегию поведения [3].
Удивительно, но обучение с подкреплением никак не использует информацию о том, какие изменения происходят в среде в результате выбранного агентом действия, а только скалярную величину награды. В этом небольшом эксперименте, мы хотим проверить, может ли эта информация как-то быть обработана и использована для построения стратегии агента.
Чтобы предложить алгоритм обучения агента, представим себя на месте существа, которое ничего не знает и ничего не умеет (что довольно сложно сделать имея сформированный интеллект [4]). А теперь попытаемся понять, как такое существо обучается выполнению какой-либо задачи, прикинем последовательность стадий обучения:
Кажется, что сначала мы должны пробовать и ощупывать окружающую среду, чтобы набрать статистику как мир реагирует на наши действия. Запоминаем, что изменится, когда сделаешь что-либо. Если потянешь руку – можешь дотянутся до игрушки. Сожмёшь пальцы – сможешь удержать и т.д.
Когда мы изучили элементарные действия, пытаемся выполнить задуманное, выбирая те действия, которые способны привести к результату. Дети, которым уже несколько месяцев, тянутся к игрушке руками, а не перебирают все доступные организму движения. Наконец у нас что-то получилось. Получилось раз, получилось два, пока случайно. Набираем успешные траектории, выбираем из них кратчайшие.
Финальная стадия – обобщение (доведение до автоматизма). Когда сто раз собрали пирамидку, уже не раздумывая делаем её из любого положения.
Вот такую схему и попробуем собрать на модельном примере: движение агента на дискретной сетке с использованием ограниченного набора действий. Агент должен “сам” вывести последовательность действий, необходимую для достижения цели.
Пускай объект в нашем мире характеризуется всего тремя величинами: 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]
Создадим класс “Окружение”, который будет определять приращения к характеристикам объекта на основании его действия:
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
Кроме приращений характеристик метод класса возвращает множество индексов характеристик, которые изменились (с этим просто удобнее обучать агента).
Со свойствами мира мы определились, теперь надо написать агента, который, взаимодействуя с миром, должен сообразить, как достичь цели. Сначала перечислю поля, а методы распишем ниже.
Первое, что, кажется, нужно сделать, это определить какое действие на какую характеристику влияет:
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
Мы случайно выбираем действие и смотрим какая характеристика изменилась. И так много раз. Как младенец тыкаем наугад во все стороны, пробуем на зуб всё до чего дотянулись. Метод возвращает словарь {действие: множество индексов характеристик, которые это действие может изменить}.
Следующий этап – больше конкретики, мы уже знаем, на что влияют действия, теперь бы ещё понять как:
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()
Метод использует словарь, полученный на прошлом этапе. Идём по этому словарю и смотрим, как конкретно действия влияют на характеристики. Мы также выбираем случайные действия, но логируем только те, которые в данный момент изучаем. Кроме того, запоминаем в каких условиях эти действия были выполнены. Самое простое что мы можем детектировать, это случай, когда действие всегда изменяет характеристику на одну и ту же величину – константный эффект. Чуть сложнее ситуация, когда эффект зависит от текущего значения другой характеристики.
Данный метод сохраняет в 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)
Этот метод “выворачивает” словарь 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
Теперь пишем метод 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
Метод работает просто, сначала перебирает по словарю все действия, которые приближают целевые характеристики. Потом проверяются условия этих действий, если они не выполняются, то эти действия заменяются на другие, которые приближают выполнение условия. По-хорошему, это надо делать рекурсивно, но в данном примере достаточно только одного уровня иерархии. Из полученного списка действий выбираем одно случайно.
До этого мы просто исследовали среду, тыкались наугад. Пора научится решать поставленную задачу, достигать цели. Для этого мы тоже должны набрать достаточно опыта [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()}
С помощью описанного выше метода агент пытается достичь цели в большом количестве эпизодов. Это уже не совсем наугад, тут мы сильно сужаем пространство поиска. Успешные траектории обрабатываются: создается словарь, где ключи – это тупла значений характеристик по отношению к цели (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))
Разумеется, я экспериментировал с разными архитектурами. В конечном счете остановился на такой, здесь даже нет скрытых слоёв в классификаторе. С последними получается точность на обучающем датасете выше, но в боевых условиях результат хуже.
Цикл проверки расписывать не буду, там всё просто, мы просто конвертируем ситуации в торч-тензор, подаем в нейронку, аргмаксом выбираем действие. Для проверки использован намного больший радиус (20 вместо 5). Кроме того, в качестве baseline используем следующий алгоритм: если текущая ситуация есть в “памяти” (словарь, полученный при сборе траекторий), то тупо выбираем действие оттуда, иначе вызываем уже описанный find_solution:
В начальном коде действие “go” перемещает агента на единичку вправо/вниз/влево/верх в зависимости от последний характеристики, которую можно интерпретировать как ориентация агента. Но нас интересует общность алгоритма, поэтому интересно, что будет, если изменить эффект действий. Для этого достаточно поменять всего две строчки в коде окружения, например следующим образом:
# bishop:
DX := [1, -1, -1, 1]
DY := [1, 1, -1, -1]
# knight
DX := [2, -1, -2, 1]
DY := [1, 2, -1, -2]
В первом варианте агент двигается только по диагонали (ориентированный аналог шахматного слона, 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
Нажмите здесь для печати.