Фронтендер пишет нейронки. Уровень сложности «мартышка и уравнение Беллмана»

Привет.

Количество плюсов под последней статьей говорит о том, что моя подача материала про нейронные сети не вызвала сильного отторжения, поэтому решение — «прочитать, посмотреть что-то новое и сделать новую статью» не заставило себя ждать. Хочется сделать оговорку, что нисколько не претендую на звание того, кто будет учить чему-то и говорить о чем-то серьезном в своей статье. Наоборот, нахожу данный формат — написание статьи или выступление на конференции, способом, когда самому можно чему-нибудь научиться. Ты делаешь что-то, собираешь обратную связь, делаешь что-то лучше. Также это происходит и в нейронных сетях. Кстати о них. 

В комментариях к прошлой статье поднялся вопрос про reinforcement learning. Почему бы и нет. Давайте подробнее рассмотрим что это такое. 

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

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

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

Наш условный лабиринтНаш условный лабиринт

Представляя эту картину, мы можем увидеть все основные составляющие любого проекта с использованием обучения с подкреплением. Во-первых, у нас есть крыса — Агент (agent), наша нейронная сеть, которая мыслит и принимает решения. Во-вторых, у нас есть Окружение или среда (environment) агента — лабиринт, который имеет свое Состояние (state), расположение проходов, мест с котами, финальный островок и так далее. Крыса может принимать решения и совершать определенные Действия (actions), которые могут приводить к разным последствиям. Крыса либо получает Вознаграждение (reward), либо Санкции (penalty or -reward) за свои действия.

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

Но, говоря, «мы решили эту проблему при помощи обучения с подкреплением», мы не сообщаем никакой информации. Данная группа весьма обширна и в данной статье мы познакомимся с самым популярным методом — Q-learning.  Идея та же самая, жестоко бить током нашу нейронную сеть, когда та косячит и одаривать всеми благами мира, когда делает то, что нам нужно. Давайте рассмотрим детали. 

Семейство методов обучения с подкреплениемСемейство методов обучения с подкреплением

Вообще, пусть меня поправят эксперты, Q-learning может не иметь ничего общего с нейронными сетями. Вся цель этого алгоритма — максимизировать значение Q (вознаграждение, полученное за проход от начального состояния до конечного), используя конечные автоматы.

Мы имеем некоторое кол-во состояний (s1, s2 …), наш агент может находится в одном из этих состояний в каждый момент времени. Цель агента достичь финального состояния.

Пример конечного автоматаПример конечного автомата

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

---

s1

s2

s3

s4

s5

s6

s7

s1

0

1

-1

---

---

---

---

s2

---

0

---

-10

1

---

---

s3

---

---

0

---

---

100

1

Но здесь появляется проблема, как только мы начнем изменять кол-во состояний, наша таблица становится бесполезной и мы должны заполнять ее заново. Здесь нам на помощь приходят нейронные сети. Зачем заполнять таблицы? Давайте просто предсказывать значение перехода. 

В чем идея? Как нейронные сети нам помогут?  

В прошлый раз мы условились, что будем воспринимать нейронные сети как черный ящик для простоты понимания. Давайте не будем нарушать эти условности.

Итак, мы имеем некоторые входные данные и знаем как должны выглядеть обработанные данные. Например, у нас есть картинка котика и мы точно знаем, что это котик. Допустим, у нас 1 000 разных картинок котиков. Мы отдаем это все в наш черный ящик и он возвращает нам некоторый алгоритм того, как определить что на картинке котик. 

Получение алгоритма распознавания котиковПолучение алгоритма распознавания котиков

Далее, если мы достанем еще несколько картинок котиков, которые отличны от тех 1000 штук, возьмем наш алгоритм, и все это отправим в черный ящик, он вернет нам правильное название для этих картинок, хотя этих картинок нейронная сеть еще не видела. Это «нейронные сети 101» (базовый курс).

Распознавание новых котиковРаспознавание новых котиков

Так вот, с Q-обучением тоже самое. Зная только часть переходов между состояниями таблицы, используя нейронные сети, мы можем предсказывать новые состояния для новой таблицы с максимальным вознаграждением. Поэтому на свет появился Deep Q-learning алгоритм. Deep потому что deep neural networks, глубокие нейронные сети, глубокие — потому что много слоев.

Наверняка появились вопросы или я что-то упустил. Поэтому давайте перейдем к практической части. 

Реализация окружения и агента

В этот раз мы также будем использовать р5 редактор, поскольку снова будем делать аркадный проект, поэтому р5 будет незаменим в этом.

Начнем мы с того, что определим наши объектные модели. Самыми главными классами для нас будут класс Environment и класс Agent.

class Agent {
  constructor(b, r, s, w, h) {
    this.network = b;

    this.rect = r;
    this.speed = s;
    this.width = w;
    this.height = h;
  }
}

class Environment {
  constructor(w, h, r, c, es, as) {
    this.width = w;
    this.height = h;
    this.rows = r;
    this.columns = c;
    this.enemySpeed = es;
    this.agentSpeed = as;

    this.agent = this.resetAgent();
    this.eps = Environment.MAX_EPS;
    this.discount = Environment.DISCOUNT;
  }
}

Пока некоторые поля могут вызывать вопросы, мы разберемся со всем немного далее. Давайте поговорим про то, что мы собираемся сделать. 

Это будет некое подобие лабиринта. Наш агент-крыса каждую игру начинает в левом верхнем углу и его цель — добраться до сыра в нижнем правом. На поле игры в рандомном месте генерится несколько котов. Задача агента состоит в том, чтобы добраться до сыра невредимым и миновать всех котов. 

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

Итак, давайте обозначим, что является состоянием. Я решил выбрать 9 параметров, которые могут изменяться в течение времени:  

  • положение агента по оси Х

  • положение агента по оси У

  • наличие врага впереди на оси Х

  • наличие врага впереди на оси У

  • дистанция до врага по оси Х

  • дистанция до врага по оси У

  • наличие цели на оси Х

  • наличие цели на оси У

  • дистанция до цели

Параметры состояния окружения агентаПараметры состояния окружения агента

Таким образом, мы имитируем зрение агента, как если бы он двигался по настоящему лабиринту, то есть он может видеть что у него спереди и как близко он у цели. Собственно, чтобы двигаться агент может совершать 4 действия (шага) — вправо, вниз, влево, вверх.

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

const ACTIONS = [MOVE_RIGHT, MOVE_DOWN, MOVE_LEFT, MOVE_UP];

chooseAction(state, eps) {
  if (random(0, 1) < eps) {
    return ACTIONS[random([0, 1, 2, 3])]; // рандомный шаг
  } else {
    return tf.tidy(() => {
      // сеть возвращает массив из 4 значений
      const probs = this.network.predict(state).dataSync();

			// шаг с максимальным значением
      return ACTIONS[probs.indexOf(Math.max(...probs))]; 
    });
  }
}

Агент получает текущее состояние и выдает ответ в какую сторону стоит сделать шаг. Здесь мы уже можем ответить на возможный вопрос, что за eps и зачем он нужен.

Это, так называемый, коэффициент исследования. 

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

Почему? Это называется проблемой компромисса исследования и использования (не знаю как правильно перевести exploration-exploition trade-off).

Давайте рассмотрим на примере. 

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

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

Вернемся обратно к агенту.

Получив ответ на вопрос — «куда сделать следующий шаг» нам нужно реализовать метод, который будет делать этот шаг. 

update(action) {
  switch (action) {
    case MOVE_UP:
    this.rect.top = this.rect.top - this.speed;
    break;
    case MOVE_DOWN:
    this.rect.top = this.rect.top + this.speed;
    break;
    case MOVE_RIGHT:
    this.rect.left = this.rect.left + this.speed;
    break;
    case MOVE_LEFT:
    this.rect.left = this.rect.left - this.speed;
    break;
  }
}

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

createModel(inputShape) {
  const model = tf.sequential();
  model.add(tf.layers.dense({ inputShape: [inputShape], units: 36, activation: 'relu' }));
  model.add(tf.layers.dense({ units: 36, activation: 'relu' }));
  model.add(tf.layers.dropout({ rate: 0.20 }));
  model.add(tf.layers.dense({ units: Agent.ACTIONS.length }));

  model.compile({ optimizer: 'adam', loss: 'meanSquaredError' });

  return model;
}

У нас появился необычный слой (5 строка), который называется dropout. Его советуют использовать, если есть возможность того, что нейронка может перетренероваться (явление, когда сеть не предсказывает выходные данные, а просто запоминает связки инпут-аутпут из тренировочных данных). Но также нашел на хабре статью, в комментариях которой говорят, что у этого слоя куда больше применений, хотя их автор не упомянул. Не суть. Что делает этот слой? Он просто игнорирует некоторые нейроны с заданной вероятностью, то есть обнуляет их веса, чтобы те не влияли на ответ. 

И вторая строчка, которая нас будет интересовать чуть позже (8 строка), метод компиляции модели. Здесь мы указываем то, как нейронка будет обрабатывать свои ошибки (разницы между предсказанными значениями и теми, которые должны быть). Оставим настройку этих параметров интернету и будем воспринимать их как черный ящик.

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

Обучение агента

Каков наш алгоритм обучения? На самом деле, в интеренете очень много примеров и реализаций DQN алгоритма, в частности на js, но, чтобы понять этот алгоритм мне пришлось потратить пару дней непрерывного чтения различных статей и обрывков книг, чтобы просто прочитать код. Я даже отчаялся и пошел просить помощи на stackoverflow. В итоге я не уверен, что полностью понимаю, что я сделал. Наверное, поэтому и пишу эту статью, но эй! Мы за этим здесь и собрались —  учиться на ошибках. Поэтому буду очень рад обратной связи. 

Итак, как же мы научим агента искать сыр?

Во-первых, мы разобьем наше обучение на несколько игр, 60–100 должно хватить. Установим кол-во шагов в каждой игре, пусть будет 1000, чтобы игра не шла вечно и агент не крутился на месте. Если агент израсходует свои шаги, игра начинается заново.  Если агент натыкается на кота или находит сыр, игра начинается заново. Чтобы мотивировать агента избегать котов и искать сыр введем метод подсчета его вознаграждения и штрафов. За каждый шаг будем бить агента током, мотивируя его быстрее добраться до цели, причем, чем ближе агент к цели, тем меньше он получит разряд (в числах это от -0.2 до 0). Если агент натыкается на кота то умирает и получает -10. Если находит сыр, то его награда +100. 

calcReward() {
  // находим нормализованную дистанцию до цели (от 0 до 1) и умножаем на -0.2
  let reward = distance(
  	this.agent.rect.left,
  	this.width,
  	this.agent.rect.top,
  	this.height) / distance(0, this.width, 0, this.height) * -0.2;

  const agentRect = toRect(this.agent.rect);
  const enemiesRects = this.enemies.map(e => toRect(e));
  const goalRect = toRect(this.goal);

  const intersected = enemiesRects.filter(e => rectsIntersected(e, agentRect));
  reward += intersected.length && -10;

  if (rectsIntersected(agentRect, goalRect)) reward = 100;

  return reward;
}

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

В-третьих, соберем все вместе.  

Мы инициализируем игру и наше хранилище.

function setup() {
  mem = new ReplayMemory();
  env = new Environment(450, 300, 4, 6, 4, 2);

  createCanvas(...env.dims);
}

Реализуем отрисовку всех составляющих игры на каждой итерации игрового цикла. 

async function draw() {
  CURRENT_STEP++;

  background(220);

  drawGoal(env.goal);
  drawNet(env.net);
  drawEnemies(env.enemies);
  drawAgent(env.agent.rect);

  ...

Отдаем агенту текущее состояние и в ответ получаем шаг, который он сделал, новое состояние, которое наступило после его шага и флаг, который нам говорит закончилась игра или нет. 

// environment

updateAgent(STATE = this.getStateTensor()) {
  const action = this.agent.chooseAction(STATE, this.eps);

  this.agent.update(action);

  const nextState = this.getStateTensor();

  return [nextState, action, this.isDone()];
}

Далее мы считаем награду за шаг агента и добавляем все эти данные в нашу память. Они нам потом понадобятся.

// draw in sketch file

const [nextState, action, done] = env.updateAgent(STATE);

const reward = env.calcReward();

mem.append([STATE, action, reward, nextState, done]);

STATE = nextState;

...

После всего этого мы проверяем, не погиб ли наш агент и не закончились ли шаги для текущей игры, если да, то наступает время воспоминаний перед сном.  

1f07275a0876373f5777b9565f27be60

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

Теперь к самой реализации. Думаю, я довольно потомил вас в ожидании, вот эта формула — уравнение Беллмана. Что же она нам говорит?

0c543803070b2a69a5b93e5741062259

Если попробовать прочитать что тут написано, то получится нечно следующее: одно кольцо, чтоб править всеми максимально возможное вознаграждение (Q) агента в состоянии s равно сумме моментального вознаграждения rза его шаг а и максимально возможноного вознаграждения агента из состояния помноженное на коэффициент понижения gamma. На слух — это точно эльфийский. 

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

b6aaef3d53c85bc874b4aa03ec3d1b5c

Допустим, у нас есть начальное состояние агента s1, далее агент имеет возможность перейти в состояние s2 и s3 при помощи действий (шагов) а1 и а2, после этого вариации выбора еще раз расширяются. И из состояния s2, все еще при помощи а1 и а2, агент может попасть в состояния s4 и s5, а из состояния s3 в состояния s6 и s7.  За каждый переход агент будет получать моментальное вознаграждение, ну или штраф, а чтобы посчитать долгосрочное вознаграждение из состояния s1 нам нужно проверить все ветви нашего автомата. Собственно, становится понятно, что max Q для состояния s1 == 99 (-1 + 100), а для состояния s3 == 100 (100 — финальный переход) и логично отсюда вывести, что max Q для s1 равно вознаграждение за переход a2 плюс max Q из состояния, в которое мы попали (s3)

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

async function replay() {
  let miniBatch = mem.sample(500);

  const filtered = miniBatch.filter(Boolean);
	
  // фильтруем если очень мало шагов сделали
  if (!filtered.length) return;

  let currentStates = filtered.map((dp) => { return dp[0].dataSync() });
  // предсказываем Q для каждого текущего состояния s в памяти
  let currentQs = await env.agent.network.predict(tf.tensor(currentStates)).array();
  
  let newCurrentStates = filtered.map((dp) => { return dp[3].dataSync() });
  // предсказываем Q для каждого состояния s', в которое мы попали из s
  let futureQs = await env.agent.network.predict(tf.tensor(newCurrentStates)).array();

  let X = [];
  let Y = [];

  for (let index = 0; index < filtered.length; index++) {
    // берем один слайс
    const [state, action, reward, newState, done] = filtered[index];
    let newQ;
    let currentQ;
	
    // уравнение Беллмана
    if (!done) {
      let maxFutureQ = Math.max(...futureQs[index]);
      // находим максимальный Q для следующего состояния (s') 
      // и складываем с моментальным вознаграждением (r)
      newQ = reward + (env.discount * maxFutureQ);
    }
    // если финальный переход, просто учитываем сам переход
    else { newQ = reward }

    currentQ = currentQs[index];
    // корректируем текущее значение Q на то, которое посчитали
    currentQ[action] = newQ;

    X.push(state.dataSync()); // 9 параметров нашего состояния
    Y.push(currentQ); // массив из 4 значений Q для наших шагов (вправо, вниз, влево, вверх)
  }
	
  // учим нашу сеть скорректированными данными
  await env.agent.network.fit(tf.tensor(X), tf.tensor(Y), { verbose: 0 });
}

Когда мы попадаем в реплай игры мы берем небольшой слайс записей наших шагов. 

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

Делаем то же самое для всех состояний, в которых агент оказался после своего шага. 

Теперь мы считаем разницу между тем, что нам подсказала нейронка и тем, что мы сами посчитали.

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

После этого вызываем специальный метод fit, который помогает нейронке переосмыслить свои шаги с нашими корректировками. 

И чуть не забыл, зачем нам вообще нужен коэффициент понижения? Нам нужен еще один пример.   

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

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

9bf03326bbc001b4122d6cede90006b4

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

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

Давайте уже запускать симуляцию.

Первые несколько игр Джери тупит в углу либо суицидиться об котов.

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

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

PS. если есть желание поконтрибьютить, welcome to PR«s или напишите мне в твиттер: v_hadoocken

© Habrahabr.ru