Глубокое Q-обучение для игры Breakout в Tensorflow и Theano

Глубокое Q-обучение для игры Breakout в Tensorflow

Здравствуйте и вновь добро пожаловать на занятия по теме «Глубокое обучение с подкреплением на языке Python».

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

Соответствующий этой лекции файл называется dqn_tf.py.

# https://deeplearningcourses.com/c/deep-reinforcement-learning-in-python

# https://www.udemy.com/deep-reinforcement-learning-in-python

from __future__ import print_function, division

from builtins import range

# Note: you may need to update your version of future

# sudo pip install -U future

import copy

import gym

import os

import sys

import random

import numpy as np

import tensorflow as tf

import matplotlib.pyplot as plt

from gym import wrappers

from datetime import datetime

from scipy.misc import imresize

Прежде всего мы определяем ряд констант. Вначале идут MAX_EXPERIENCES и MIN_EXPERIENCES. MAX_EXPERIENCES – это общий размер нашего буфера воспроизведения, а MIN_EXPERIENCES – размер буфера, при котором мы начинаем обучение нашей нейронной сети. TARGET_UPDATE_PERIOD – это количество этапов обучения, которое будет проходить между моментами копирования главной сети в целевую. IM_SIZE – размер каждого кадра, когда он становится входными данными для нейронной сети. Напомню, что настоящий размер кадра больше и это вовсе не квадрат, но так было в оригинальной статье. И наконец, K – количество исходящих узлов нашей нейронной сети, а заодно и количество возможных действий. На самом деле среда игры Breakout возвращает большее количество – 6, если точно, хотя в последних двух действиях ничего не происходит. Поэтому проще установить количество действий, равное 4, – именно столько действий на самом деле являются значимыми – вместо использования env.action_space.n.

##### testing only

# MAX_EXPERIENCES = 10000

# MIN_EXPERIENCES = 1000

MAX_EXPERIENCES = 500000

MIN_EXPERIENCES = 50000

TARGET_UPDATE_PERIOD = 10000

IM_SIZE = 84

K = 4 #env.action_space.n

Далее идёт класс ImageTransformer. В отличие от Theano, Tensorflow идёт с множеством замечательных функций для обработки изображений, всеми преимуществами которых мы можем воспользоваться; будем надеяться, что они работают и с GPU, чтобы ускорит работу. Итак, в конструкторе мы начинаем с создания заполнителя для входных данных. Это изображение из игры. Изображение цветное, поэтому последняя размерность равна 3. Высота же и ширина изображения равны 210×160. Затем мы преобразуем изображение в чёрно-белое, используя функцию rgb_to_grayscale. После этого можно обрезать изображение. Это значит, что мы пропускаем первые 34 строки, получив в результате квадрат размером 160×160. Следом за этим мы изменяем размер изображения до 84×84. Можно оставить 84, а можно и 80 – это не особо важно. И наконец, мы сжимаем данные, чтобы убрать все посторонние размерности. Не забывайте, что поскольку мы преобразовали изображение в чёрно-белое, последняя размерность равна 1, но это избыточная информация.

# Transform raw images for input into neural network

# 1) Convert to grayscale

# 2) Resize

# 3) Crop

class ImageTransformer:

  def __init__(self):

    with tf.variable_scope(“image_transformer”):

      self.input_state = tf.placeholder(shape=[210, 160, 3], dtype=tf.uint8)

      self.output = tf.image.rgb_to_grayscale(self.input_state)

      self.output = tf.image.crop_to_bounding_box(self.output, 34, 0, 160, 160)

      self.output = tf.image.resize_images(

        self.output,

        [IM_SIZE, IM_SIZE],

        method=tf.image.ResizeMethod.NEAREST_NEIGHBOR)

      self.output = tf.squeeze(self.output)

Далее идёт функция transform. Она принимает изображение в виде массива Numpy и выполняет вышеуказанные операции во время сессии Tensorflow. Выходом же, разумеется, будет преобразованное изображение в виде массива Numpy.

  def transform(self, state, sess=None):

    sess = sess or tf.get_default_session()

    return sess.run(self.output, { self.input_state: state })

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

def update_state(state, obs_small):

  return np.append(state[:,:,1:], np.expand_dims(obs_small, 2), axis=2)

Следующим мы определяем класс ReplayMemory. Не забывайте, что основная идея тут заключается в том, чтобы предварительно выделить все кадры, которые собираемся сохранять, чтобы позже можно было выбрать состояния из отдельных кадров. Конструктор в основном предназначен для настройки и инициации переменных: аргументами служат размер буфера size, высота кадра frame_height и его ширина frame_width, длина истории agent_history_length, которая является количеством кадров, составляющих одно состояние, и размер пакета batch_size для позднейшего взятия образца.

После сохранения всех этих входных данных мы создаём ряд новых атрибутов. Вначале идут count и current, в которых отслеживается момент вставки в нашем буфере воспроизведения. Затем мы предопределяем массивы для хранения кадров, действий, вознаграждений и флагов done. После этого предопределяются другие массивы, в которых будут храниться каждый из выбранных пакетов. Поскольку все пакеты одинакового размера, нам не нужно выделять новый массив при каждом получении пакета.

class ReplayMemory:

  def __init__(self, size=MAX_EXPERIENCES, frame_height=IM_SIZE, frame_width=IM_SIZE,

               agent_history_length=4, batch_size=32):

    “””

    Args:

        size: Integer, Number of stored transitions

        frame_height: Integer, Height of a frame of an Atari game

        frame_width: Integer, Width of a frame of an Atari game

        agent_history_length: Integer, Number of frames stacked together to create a state

        batch_size: Integer, Number of transitions returned in a minibatch

    “””

    self.size = size

    self.frame_height = frame_height

    self.frame_width = frame_width

    self.agent_history_length = agent_history_length

    self.batch_size = batch_size

    self.count = 0

    self.current = 0

    # Pre-allocate memory

    self.actions = np.empty(self.size, dtype=np.int32)

    self.rewards = np.empty(self.size, dtype=np.float32)

    self.frames = np.empty((self.size, self.frame_height, self.frame_width), dtype=np.uint8)

    self.terminal_flags = np.empty(self.size, dtype=np.bool)

    # Pre-allocate memory for the states and new_states in a minibatch

    self.states = np.empty((self.batch_size, self.agent_history_length,

                            self.frame_height, self.frame_width), dtype=np.uint8)

    self.new_states = np.empty((self.batch_size, self.agent_history_length,

                                self.frame_height, self.frame_width), dtype=np.uint8)

    self.indices = np.empty(self.batch_size, dtype=np.int32)

Следующей идёт функция add_experience, которая добавляет кадр с соответствующим действием, вознаграждением и флагом done. Вначале мы проверяем, имеет ли кадр правильный размер, выдавая ошибку, если это не так. Затем сохраняем входные данные в наших буферах массивов. Как видите, тут используется self.current. Как я указывал ранее, он отслеживает текущий индекс вставки, который повторяется по кругу – то есть, достигнув конца, мы вновь начинаем с нуля. Именно поэтому мы обновляем значение self.current в виде self.current + 1 mod self.size. Обратите также внимание, что переменная self.count достигает максимального значения при величине, равной размеру буфера воспроизведения.

  def add_experience(self, action, frame, reward, terminal):

    “””

    Args:

        action: An integer-encoded action

        frame: One grayscale frame of the game

        reward: reward the agend received for performing an action

        terminal: A bool stating whether the episode terminated

    “””

    if frame.shape != (self.frame_height, self.frame_width):

      raise ValueError(‘Dimension of frame is wrong!’)

    self.actions[self.current] = action

    self.frames[self.current, …] = frame

    self.rewards[self.current] = reward

    self.terminal_flags[self.current] = terminal

    self.count = max(self.count, self.current+1)

    self.current = (self.current + 1) % self.size

Далее идёт функция _get_state, которая в качестве аргумента принимает индекс, указывающий на последний кадр в состоянии, и возвращает полное состояние, содержащее всю последовательность кадров. Вначале мы проверяем, не равна ли величина нулю и что индекс равен как минимум трём, ведь он представляет последний кадр состояния, которое имеет четыре кадра. Затем мы индексируем массив кадров. Как вы можете видеть, если рассматривать индекс t, то это будут кадры с индексами от t – 3 до t + 1.

  def _get_state(self, index):

    if self.count is 0:

      raise ValueError(“The replay memory is empty!”)

    if index < self.agent_history_length – 1:

      raise ValueError(“Index must be min 3”)

    return self.frames[index-self.agent_history_length+1:index+1, …]

Следующей идёт функция, которая называется _get_valid_indices. Это вспомогательная функция для выбора пакета, так что может показаться странным, что она ничего не возвращает. Вместо этого она присваивает индексы, которые будут соответствовать примерам из пакета, и сохраняет их в переменной экземпляра self.indices. Именно здесь проверяются все крайние случаи, о которых мы говорили ранее. Сначала мы случайным образом выбираем индекс. Это может быть любое целое число от 4 до значения переменной self.count. Мы ещё не знаем, корректный ли это индекс, поэтому должны сделать ряд проверок. Первым делом мы проверяем тривиальный случай, когда индекс меньше agent_history_length – этого не может случиться по чисто техническим причинам. Затем мы проверяем, что четыре последовательных кадра не пересекают границу self.current, поскольку – не забывайте об этом! – self.current представляет точку, в которой мы вставляем новый кадр. Также не забывайте, что буфер воспроизведения цикличен, поэтому может получиться, что self.current может оказаться где-то посередине после self.current, представляющего старый кадр, и до self.current, представляющего самый последний кадр. Если мы выберем индекс, который несколько опережает self.current, однако четыре кадра после несколько отстают от self.current, то эти четыре кадра не представляют собой действительное состояние. Следующий случай, нуждающийся в проверке, – не находимся ли мы на границе эпизода. Логика тут аналогичная: если мы находимся где-то в середине буфера воспроизведения и индекс немного опережает флаг done, но четыре кадра позади немного отстают от флага done, то в этом случае у нас не будет корректного состояния. Во всех таких случаях мы просто продолжаем работу и выбираем новый индекс. Обнаружив же индекс, соответствующий действительному состоянию, мы прерываем цикл и присваиваем его атрибуты self.indices.

  def _get_valid_indices(self):

    for i in range(self.batch_size):

      while True:

        index = random.randint(self.agent_history_length, self.count – 1)

        if index < self.agent_history_length:

          continue

        if index >= self.current and index – self.agent_history_length <= self.current:

          continue

        if self.terminal_flags[index – self.agent_history_length:index].any():

          continue

        break

      self.indices[i] = index

Далее идёт функция get_minibatch. Прежде всего выдаётся ошибка, если недостаточно заполнен буфер воспроизведения. После этого вызывается только что описанная вспомогательная функция _get_valid_indices. Затем мы перебираем все найденные индексы и получаем состояния для каждого индекса. Не забывайте, для каждого индекса нам нужно состояние s и следующее состояние s’, поэтому мы присваиваем их self.states и self.new_states. После этого возвращается пакет, включающий в себя состояния s, действия a, вознаграждения r, следующие состояния s’ и флаг done. Следует отметить, что в связи с тем, как индексируются массивы, более удобно, если первым идёт временная размерность, поэтому перед выходом из функции массивы состояний и следующих состояний имеют вид N x T x высота x ширина. Однако в TensorFlow высота и ширина должны идти первыми, поэтому мы преобразуем эти массивы с помощью функции transpose библиотеки Numpy таким образом, чтобы временная размерность шла в конце, причём это делается как для массива self.states, так и для массива self.new_states.

  def get_minibatch(self):

    “””

    Returns a minibatch of self.batch_size transitions

    “””

    if self.count < self.agent_history_length:

      raise ValueError(‘Not enough memories to get a minibatch’)

    self._get_valid_indices()

    for i, idx in enumerate(self.indices):

      self.states[i] = self._get_state(idx – 1)

      self.new_states[i] = self._get_state(idx)

    return np.transpose(self.states, axes=(0, 2, 3, 1)), self.actions[self.indices], self.rewards[self.indices], np.transpose(self.new_states, axes=(0, 2, 3, 1)), self.terminal_flags[self.indices]

Затем идёт класс DQN. Основная часть работы выполняется в конструкторе, поэтому на данную функцию надо обратить особое внимание. В качестве входных данных берётся число выходов K, размеры свёрточного и скрытого слоёв, а также область видимости. Последняя здесь необходима, поскольку и основная, и целевая сети будут создаваться с использованием одних и тех же функций слоёв. Вначале мы присваиваем значения K и области видимости переменным экземпляра. Далее с помощью команды with tf.variable_scope(scope) мы указываем TensorFlow, что каждая переменная, созданная в этом блоке, должна находиться в заданной области видимости.

Вначале создаётся заполнитель для входных состояний с называнием X. После этого у нас идут заполнители для целевых переменных, в частности, предполагаемой отдачи G, и выбранных действий. Далее мы пропускаем данные через свёрточные слои. Мы перебираем каждый из параметров в conv_layer_sizes и вызываем функцию conv2d с этими параметрами. Как видите, мы подставляем кортежи из переменных num_output_filters, filtersz и poolsz. В виде примечания отметим, что формально мы выполняем не агрегирование, а, скорее, этап свёртки.

class DQN:

  def __init__(self, K, conv_layer_sizes, hidden_layer_sizes, scope):

    self.K = K

    self.scope = scope

    with tf.variable_scope(scope):

      # inputs and targets

      self.X = tf.placeholder(tf.float32, shape=(None, IM_SIZE, IM_SIZE, 4), name=’X’)

      # tensorflow convolution needs the order to be:

      # (num_samples, height, width, “color”)

      self.G = tf.placeholder(tf.float32, shape=(None,), name=’G’)

      self.actions = tf.placeholder(tf.int32, shape=(None,), name=’actions’)

      # calculate output and cost

      # convolutional layers

      # these built-in layers are faster and don’t require us to

      # calculate the size of the output of the final conv layer!

      Z = self.X / 255.0

      for num_output_filters, filtersz, poolsz in conv_layer_sizes:

        Z = tf.contrib.layers.conv2d(

          Z,

          num_output_filters,

          filtersz,

          poolsz,

          activation_fn=tf.nn.relu

        )

Далее мы передаём данные в полносвязные слои. Они определяются только одним числом – размером скрытых слоёв M. На этом месте мы проводим данные через конечный полносвязный слой, чтобы получить K выходов. Тут можно уже создать функцию затрат и минимизировать её. Прежде всего мы должны убедиться, что на выходе учитываются только те из выходов, которые соответствуют предпринятым действиям. Простейший способ – использовать для действий метод прямого кодирования, а затем перемножить их с выходами нейронной сети, что эффективно установит значение для любого выхода, чьё действие мы не предпринимали, равным нулю. Затем можно суммировать по действиям, чтобы получить пакет Q-прогнозов.

      # fully connected layers

      Z = tf.contrib.layers.flatten(Z)

      for M in hidden_layer_sizes:

        Z = tf.contrib.layers.fully_connected(Z, M)

      # final output layer

      self.predict_op = tf.contrib.layers.fully_connected(Z, K)

      selected_action_values = tf.reduce_sum(

        self.predict_op * tf.one_hot(self.actions, K),

        reduction_indices=[1]

      )

После этого мы вычисляем функцию потерь. Как вы можете видеть, здесь используется функция huber_loss, однако квадрат ошибок тоже неплох. Функция huber_loss очень похожа, но после определённой точки, ограничивающей величину градиента, становится линейной. Это хорошо для стабилизации обучения и избежания слишком больших шагов. Тут же создаётся функция train_op, использующая Adam-оптимизацию. Это всё, что касается конструктора.

      # cost = tf.reduce_mean(tf.square(self.G – selected_action_values))

      cost = tf.reduce_mean(tf.losses.huber_loss(self.G, selected_action_values))

      self.train_op = tf.train.AdamOptimizer(1e-5).minimize(cost)

      # self.train_op = tf.train.AdagradOptimizer(1e-2).minimize(cost)

      # self.train_op = tf.train.RMSPropOptimizer(2.5e-4, decay=0.99, epsilon=1e-3).minimize(cost)

      # self.train_op = tf.train.RMSPropOptimizer(0.00025, 0.99, 0.0, 1e-6).minimize(cost)

      # self.train_op = tf.train.MomentumOptimizer(1e-3, momentum=0.9).minimize(cost)

      # self.train_op = tf.train.GradientDescentOptimizer(1e-4).minimize(cost)

      self.cost = cost

Далее идёт ещё несколько функций класса DQN, но они куда меньше только что рассмотренных.

Так, следующей идёт функция copy_from. Она принимает в виде аргумента глубокую сеть и копирует её весовые коэффициенты. Тут мы первым делом собираем все весовые коэффициенты в области видимости каждой сети. К счастью, использование областей видимости позволяет легко проводить поиск по имени. Затем мы сортируем переменные, чтобы убедиться, что в обеих сетях они будут появляться в одном и том же порядке. Далее мы по циклу перебираем каждую переменную в соответствующем порядке и вызываем функцию assign, чтобы создать операцию, которую можно запустить в сессии. И наконец, мы используем сессию, чтобы запустить все только что собранные операции.

  def copy_from(self, other):

    mine = [t for t in tf.trainable_variables() if t.name.startswith(self.scope)]

    mine = sorted(mine, key=lambda v: v.name)

    theirs = [t for t in tf.trainable_variables() if t.name.startswith(other.scope)]

    theirs = sorted(theirs, key=lambda v: v.name)

    ops = []

    for p, q in zip(mine, theirs):

      op = p.assign(q)

      ops.append(op)

    self.session.run(ops)

Затем идёт функция save. Она позволяет сохранить весовые коэффициенты для позднейшего использования. Как и в случае функции copy, мы собираем все переменные, используя область видимости, после чего запускаем все переменные в сессии, чтобы получить их значения. Последним идёт использование функции np.savez, чтобы сохранить все массивы в одном файле.

  def save(self):

    params = [t for t in tf.trainable_variables() if t.name.startswith(self.scope)]

    params = self.session.run(params)

    np.savez(‘tf_dqn_weights.npz’, *params)

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

  def load(self):

    params = [t for t in tf.trainable_variables() if t.name.startswith(self.scope)]

    npz = np.load(‘tf_dqn_weights.npz’)

    ops = []

    for p, (_, v) in zip(params, npz.iteritems()):

      ops.append(p.assign(v))

    self.session.run(ops)

Далее идёт функция set_session, которая присваивает сесиию переменной экземпляра session.

  def set_session(self, session):

    self.session = session

Затем идёт функция predict, запускающая функцию predict_op для заданного состояния. Она должна возвращать ценность Q по всем действиям.

  def predict(self, states):

    return self.session.run(self.predict_op, feed_dict={self.X: states})

Следом идёт функция update, запускающая одну итерацию train_op, а также возвращает значение функции затрат. Обратите внимание, что в качестве аргументов она принимает состояния, действия и целевые переменные, соответствующие ранее созданным нами заполнителям.

  def update(self, states, actions, targets):

    c, _ = self.session.run(

      [self.cost, self.train_op],

      feed_dict={

        self.X: states,

        self.G: targets,

        self.actions: actions

      }

    )

    return c

И наконец, у нас есть функция sample_action, использующая эпсилон-жадный алгоритм для выбора действия. Она принимает два аргумента: x, которое является состоянием, и eps, которое является вероятностью выполнения случайно выбранного действия. Как вы видите, здесь используется написанная нами ранее функция predict. Взяв аргумент максимизации argmax, мы получаем действия, дающие наибольшую ценность Q.

  def sample_action(self, x, eps):

    if np.random.random() < eps:

      return np.random.choice(self.K)

    else:

      return np.argmax(self.predict([x])[0])

Следующей идёт функция learn. Очень хорошо инкапсулировать всё в одну функцию, поскольку настройка обновлений является нетривиальной задачей: мы должны взять пакет данных из памяти воспроизведения, рассчитать целевые переменные, используя целевую сеть, а затем подставить эти целевые переменные в главную сеть. Итак, первым делом мы используем наш буфер воспроизведения, чтобы получить случайным образом выбранный пакет данных с помощью функции get_minibatch. Затем нам нужно вычислить целевые переменные. Вначале мы вычисляем ценности Q для следующего состояния s’. После этого выбираются максимальные значения Q для каждого состояния по всем действиям. В конце мы обесцениваем ценности Q при помощи gamma, умноженного на инвертированный флаг done, и с добавлением вознаграждения.

Самое непонятное тут – почему используется инвертированный флаг done, поэтому объясним это подробнее. Как вы знаете, ценность конечного состояния равна нулю, а логические значения рассматриваются как нули и единицы – значение «истина» соответствует единице, а «ложь» – нулю. Следовательно, если флаг done имеет значение «истина», то он равен единице, а инвертирование этого значения даёт нам нуль. Умножение же на нуль даёт нам нуль – именно ту ценность конечного состояния, которая и должна быть. Если же флаг done имеет значение «ложь», то инвертирование даёт нам единицу, что даёт обычную целевую TD-переменную.

В конце мы вызываем функцию update, определённую ранее, и возвращаем значение потерь. Обратите внимание, что в данном случае нас интересуют не столько потери, сколько вознаграждения с течением времени, поэтому мы не будем их рассматривать, хотя вы при желании можете построить их график.

def learn(model, target_model, experience_replay_buffer, gamma, batch_size):

  # Sample experiences

  states, actions, rewards, next_states, dones = experience_replay_buffer.get_minibatch()

  # Calculate targets

  next_Qs = target_model.predict(next_states)

  next_Q = np.amax(next_Qs, axis=1)

  targets = rewards + np.invert(dones).astype(np.float32) * gamma * next_Q

  # Update model

  loss = model.update(states, actions, targets)

  return loss

Далее идёт функция play_one, отыгрывающая один эпизод игры. В качестве аргументов она принимает env – объект среды, sess – объект сессии, total_t – общее количество до сих пор сыгранных этапов, experience_replay_buffer – объект памяти воспроизведения, model – главную нейронную сеть, target_model – целевую нейронную сеть, image_transformer – объект преобразования изображения, gamma – коэффициент обесценивания, batch_size – количество примеров, которые нужно взять для каждой итерации обучения, epsilon – текущее значение ε, epsilon_change – величина, на которую уменьшается ε на каждом этапе, и epsilon_min – наименьшее значение ε, которого мы хотим достичь. Всё это выглядит сложным, однако на самом деле эта функция просто проходит по циклу каждый этап эпизода, пока не появится флаг done.

Для начала мы записываем текущее время и присваиваем его переменной t0. Затем мы сбрасываем среду, чтобы получить первое наблюдение, преобразуем наблюдение и затем складываем четыре наблюдения, чтобы получить первые данные. Далее мы создаём несколько переменных, чтобы отслеживать значения нужных нам величин: total_time_training – для отслеживания общего количества времени, потраченного на обучение нашей нейронной сети, num_steps_in_episode – для отслеживания количества этапов в эпизоде, и episode_reward – для отслеживания суммы всех вознаграждений.

def play_one(

  env,

  sess,

  total_t,

  experience_replay_buffer,

  model,

  target_model,

  image_transformer,

  gamma,

  batch_size,

  epsilon,

  epsilon_change,

  epsilon_min):

  t0 = datetime.now()

  # Reset the environment

  obs = env.reset()

  obs_small = image_transformer.transform(obs, sess)

  state = np.stack([obs_small] * 4, axis=2)

  loss = None

  total_time_training = 0

  num_steps_in_episode = 0

  episode_reward = 0

После этого мы входим в цикл, который завершается при появлении флага done. Внутри цикла мы проверяем, не пора ли обновить нашу целевую сеть, и если да, то вызываем функцию target_model.copy_from для главной сети. Далее мы выбираем действие из модели при помощи функции sample_action, после чего выполняем это действие в среде, обратно получая следующее наблюдение, вознаграждение и значение флага done. Следующим делом мы преобразуем это наблюдение, то есть уменьшаем его и преобразуем в чёрно-белое, после чего применяем функцию update_state для создания нового состояния. Затем мы обновляем переменную episode_reward с учётом только что полученного вознаграждения, а после этого – добавляем наблюдение, действие, вознаграждение и флаг done в буфер  воспроизведения опыта.

  done = False

  while not done:

    # Update target network

    if total_t % TARGET_UPDATE_PERIOD == 0:

      target_model.copy_from(model)

      print(“Copied model parameters to target network. total_t = %s, period = %s” % (total_t, TARGET_UPDATE_PERIOD))

    # Take action

    action = model.sample_action(state, epsilon)

    obs, reward, done, _ = env.step(action)

    obs_small = image_transformer.transform(obs, sess)

    next_state = update_state(state, obs_small)

    # Compute total reward

    episode_reward += reward

    # Save the latest experience

    experience_replay_buffer.add_experience(action, obs_small, reward, done)   

Затем запускается одна итерация обучения главной сети. Обратите внимание, как это связывается с функцией datetime, чтобы можно было отслеживать количество времени, потраченного на функцию learn. Длительность этого времени присваивается переменной dt, после чего мы добавляем количество секунд dt к нашему общему времени обучения, а также добавляем 1 к количеству этапов в эпизоде. После этого переменной state присваивается значение переменной next_state, поскольку на следующей итерации цикла это уже будет текущим состоянием. Кроме того, к переменной total_t добавляется единица, поскольку эта переменная характеризует общее количество пройденных на данный момент этапов. Наконец, мы обновляем значение переменной epsilon путём вычитания значения epsilon_change из epsilon, но не позволяя ему опуститься ниже значения epsilon_min.

В конце мы выходим из функции, возвращая собранную информацию.

    # Train the model, keep track of time

    t0_2 = datetime.now()

    loss = learn(model, target_model, experience_replay_buffer, gamma, batch_size)

    dt = datetime.now() – t0_2

    # More debugging info

    total_time_training += dt.total_seconds()

    num_steps_in_episode += 1

    state = next_state

    total_t += 1

    epsilon = max(epsilon – epsilon_change, epsilon_min)

  return total_t, episode_reward, (datetime.now() – t0), num_steps_in_episode, total_time_training/num_steps_in_episode, epsilon

Далее идёт функция smooth. Это простенькая функция, которая позволяет нам отслеживать среднюю отдачу за последние 100 эпизодов. Это полезно, поскольку отдачи будут сильно различаться, а функция даст нам более сглаженно выглядящий график отдач во времени.

def smooth(x):

  # last 100

  n = len(x)

  y = np.zeros(n)

  for i in range(n):

    start = max(0, i – 99)

    y[i] = float(x[start:(i+1)].sum()) / (i – start + 1)

  return y

И наконец, у нас идёт раздел main. Сначала мы определяем параметры нашей нейронной сети. Если помните, переменная conv_layer_sizes содержит кортежи из количества фильтров, размера ядра и шага. Переменная hidden_layer_sizes содержит размер каждого из скрытых слоёв, следующих за свёрточными слоями. Далее идёт gamma – коэффициент обесценивания, batch_sz – размер пакета, и num_episodes – количество эпизодов для обучения. Кроме того, переменная total_t приравнивается к нулю. Также мы создаём наш объект памяти воспроизведения и распределяем вознаграждения за эпизоды. Затем мы определяем ε-переменные. Начальное значение переменной epsilon равно 1, то есть мы всегда только исследуем, а конечное значение равно 0,1. Переход от 1 к 0,1 будет происходить на протяжении 500 000 этапов, поэтому мы делим (1 – 0,1) на 500 000.

if __name__ == ‘__main__’:

  # hyperparams and initialize stuff

  conv_layer_sizes = [(32, 8, 4), (64, 4, 2), (64, 3, 1)]

  hidden_layer_sizes = [512]

  gamma = 0.99

  batch_sz = 32

  num_episodes = 3500

  total_t = 0

  experience_replay_buffer = ReplayMemory()

  episode_rewards = np.zeros(num_episodes)

  # epsilon

  # decays linearly until 0.1

  epsilon = 1.0

  epsilon_min = 0.1

  epsilon_change = (epsilon – epsilon_min) / 500000

После всего этого мы создаём объект окружения, нашу главную сеть, целевую сеть и преобразователь изображений:

  # Create environment

  env = gym.envs.make(“Breakout-v0”)

  # Create models

  model = DQN(

    K=K,

    conv_layer_sizes=conv_layer_sizes,

    hidden_layer_sizes=hidden_layer_sizes,

    scope=”model”)

  target_model = DQN(

    K=K,

    conv_layer_sizes=conv_layer_sizes,

    hidden_layer_sizes=hidden_layer_sizes,

    scope=”target_model”

  )

  image_transformer = ImageTransformer()

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

  with tf.Session() as sess:

    model.set_session(sess)

    target_model.set_session(sess)

    sess.run(tf.global_variables_initializer())

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

    print(“Populating experience replay buffer…”)

    obs = env.reset()

    for i in range(MIN_EXPERIENCES):

        action = np.random.choice(K)

        obs, reward, done, _ = env.step(action)

        obs_small = image_transformer.transform(obs, sess) # not used anymore

        experience_replay_buffer.add_experience(action, obs_small, reward, done)

        if done:

            obs = env.reset()

    # Play a number of episodes and learn!

    t0 = datetime.now()

    for i in range(num_episodes):

      total_t, episode_reward, duration, num_steps_in_episode, time_per_step, epsilon = play_one(

        env,

        sess,

        total_t,

        experience_replay_buffer,

        model,

        target_model,

        image_transformer,

        gamma,

        batch_sz,

        epsilon,

        epsilon_change,

        epsilon_min,

      )

      episode_rewards[i] = episode_reward

      last_100_avg = episode_rewards[max(0, i – 100):i + 1].mean()

      print(“Episode:”, i,

        “Duration:”, duration,

        “Num steps:”, num_steps_in_episode,

        “Reward:”, episode_reward,

        “Training time per step:”, “%.3f” % time_per_step,

        “Avg Reward (Last 100):”, “%.3f” % last_100_avg,

        “Epsilon:”, “%.3f” % epsilon

      )

      sys.stdout.flush()

    print(“Total duration:”, datetime.now() – t0)

    model.save()

    # Plot the smoothed returns

    y = smooth(episode_rewards)

    plt.plot(episode_rewards, label=’orig’)

    plt.plot(y, label=’smoothed’)

    plt.legend()

    plt.show()

Это всё, что касается данного файла. Запустим его и посмотрим, что у нас получится.

Итак, похоже, что всё очень неплохо. Как видите, модель в конце концов научилась играть мастерски.

Глубокое Q-обучение для игры Breakout в Theano

В этой лекции мы рассмотрим код для глубокого Q-обучения в библиотеке Theano. Поскольку мы уже знакомы с планом кода, то в данной лекции сосредоточимся на деталях программы. Соответствующий этой лекции файл называется dqn_theano.py.

import copy

import gym

import os

import sys

import random

import numpy as np

import theano

import theano.tensor as T

from theano.tensor.nnet import conv2d

import matplotlib.pyplot as plt

from gym import wrappers

from datetime import datetime

from scipy.misc import imresize

Прежде всего мы определяем ряд констант. Вначале идут MAX_EXPERIENCES и MIN_EXPERIENCES. MAX_EXPERIENCES – это общий размер нашего буфера воспроизведения, а MIN_EXPERIENCES – размер буфера, при котором мы начинаем обучение нашей нейронной сети. TARGET_UPDATE_PERIOD – это количество этапов обучения, которое будет проходить между моментами копирования главной сети в целевую. IM_SIZE – размер каждого кадра, когда он становится входными данными для нейронной сети. Напомню, что настоящий размер кадра больше и это вовсе не квадрат, но так было в оригинальной статье. И наконец, K – количество исходящих узлов нашей нейронной сети, а заодно и количество возможных действий. На самом деле среда игры Breakout возвращает большее количество – 6, если точно, хотя в последних двух действиях ничего не происходит. Поэтому проще установить количество действий, равное 4, – именно столько действий на самом деле являются значимыми – вместо использования env.action_space.n.

##### testing only

# MAX_EXPERIENCES = 10000

# MIN_EXPERIENCES = 1000

MAX_EXPERIENCES = 500000

MIN_EXPERIENCES = 50000

TARGET_UPDATE_PERIOD = 10000

IM_SIZE = 84

K = 4 #env.action_space.n

Далее идёт функция для преобразования игрового изображения в чёрно-белое. У вас может возникнуть вопрос, что это за случайные числа и почему бы просто не взять среднее RGB-каналов для получения чёрно-белого изображения. Дело в том, что эти значения используются в программах вроде MATLAB, а также в Tensorflow. Если хотите проверить исходный код библиотеки Tensorflow, что убедиться, что так и есть, – пожалуйста. По сути это всё сводится к учению о цвете, если вам интересно.

def rgb2gray(rgb):

  r, g, b = rgb[:,:,0], rgb[:,:,1], rgb[:,:,2]

  gray = 0.2989 * r + 0.5870 * g + 0.1140 * b

  return gray.astype(np.uint8)

Затем идёт функция downsample_image, которая выполняет ряд вещей. Прежде всего она обрезает изображение, так что мы получаем квадрат 160×160 соответствующей части экрана. После этого изображение преобразуется в чёрно-белое с помощью только что описанной функции rgb2gray. И наконец, мы преобразуем размер изображения с помощью функции imresize из библиотеки Scipy. Она преобразует изображение в квадрат размером 84×84. Можно оставить 84, а можно и 80 – это не особо важно.

# TODO: can this be converted into a Theano function?

def downsample_image(A):

  B = A[34:194] # select the important parts of the image

  B = rgb2gray(B) # convert to grayscale

  # downsample image

  # changing aspect ratio doesn’t significantly distort the image

  # nearest neighbor interpolation produces a much sharper image

  # than default bilinear

  B = imresize(B, size=(IM_SIZE, IM_SIZE), interp=’nearest’)

  return B

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

def update_state(state, obs):

  obs_small = downsample_image(obs)

  return np.append(state[1:], np.expand_dims(obs_small, 0), axis=0)

Следующим мы определяем класс ReplayMemory. Не забывайте, что основная идея тут заключается в том, чтобы предварительно выделить все кадры, которые собираемся сохранять, чтобы позже можно было выбрать состояния из отдельных кадров. Конструктор в основном предназначен для настройки и инициации переменных: аргументами служат размер буфера size, высота кадра frame_height и его ширина frame_width, длина истории agent_history_length, которая является количеством кадров, составляющих одно состояние, и размер пакета batch_size для позднейшего взятия образца.

После сохранения всех этих входных данных мы создаём ряд новых атрибутов. Вначале идут count и current, в которых отслеживается момент вставки в нашем буфере воспроизведения. Затем мы предопределяем массивы для хранения кадров, действий, вознаграждений и флагов done. После этого предопределяются другие массивы, в которых будут храниться каждый из выбранных пакетов. Поскольку все пакеты одинакового размера, нам не нужно выделять новый массив при каждом получении пакета.

class ReplayMemory:

  def __init__(self, size=MAX_EXPERIENCES, frame_height=IM_SIZE, frame_width=IM_SIZE,

               agent_history_length=4, batch_size=32):

    “””

    Args:

        size: Integer, Number of stored transitions

        frame_height: Integer, Height of a frame of an Atari game

        frame_width: Integer, Width of a frame of an Atari game

        agent_history_length: Integer, Number of frames stacked together to create a state

        batch_size: Integer, Number of transitions returned in a minibatch

    “””

    self.size = size

    self.frame_height = frame_height

    self.frame_width = frame_width

    self.agent_history_length = agent_history_length

    self.batch_size = batch_size

    self.count = 0

    self.current = 0

    # Pre-allocate memory

    self.actions = np.empty(self.size, dtype=np.int32)

    self.rewards = np.empty(self.size, dtype=np.float32)

    self.frames = np.empty((self.size, self.frame_height, self.frame_width), dtype=np.uint8)

    self.terminal_flags = np.empty(self.size, dtype=np.bool)

    # Pre-allocate memory for the states and new_states in a minibatch

    self.states = np.empty((self.batch_size, self.agent_history_length,

                            self.frame_height, self.frame_width), dtype=np.uint8)

    self.new_states = np.empty((self.batch_size, self.agent_history_length,

                                self.frame_height, self.frame_width), dtype=np.uint8)

    self.indices = np.empty(self.batch_size, dtype=np.int32)

Следующей идёт функция add_experience, которая добавляет кадр с соответствующим действием, вознаграждением и флагом done. Вначале мы проверяем, имеет ли кадр правильный размер, выдавая ошибку, если это не так. Затем сохраняем входные данные в наших буферах массивов. Как видите, тут используется self.current. Как я указывал ранее, он отслеживает текущий индекс вставки, который повторяется по кругу – то есть, достигнув конца, мы вновь начинаем с нуля. Именно поэтому мы обновляем значение self.current в виде self.current + 1 mod self.size. Обратите также внимание, что переменная self.count достигает максимального значения при величине, равной размеру буфера воспроизведения.

  def add_experience(self, action, frame, reward, terminal):

    “””

    Args:

        action: An integer-encoded action

        frame: One grayscale frame of the game

        reward: reward the agend received for performing an action

        terminal: A bool stating whether the episode terminated

    “””

    if frame.shape != (self.frame_height, self.frame_width):

      raise ValueError(‘Dimension of frame is wrong!’)

    self.actions[self.current] = action

    self.frames[self.current, …] = frame

    self.rewards[self.current] = reward

    self.terminal_flags[self.current] = terminal

    self.count = max(self.count, self.current+1)

    self.current = (self.current + 1) % self.size

Далее идёт функция _get_state, которая в качестве аргумента принимает индекс, указывающий на последний кадр в состоянии, и возвращает полное состояние, содержащее всю последовательность кадров. Вначале мы проверяем, не равна ли величина нулю и что индекс равен как минимум трём, ведь он представляет последний кадр состояния, которое имеет четыре кадра. Затем мы индексируем массив кадров. Как вы можете видеть, если рассматривать индекс t, то это будут кадры с индексами от t – 3 до t + 1.

  def _get_state(self, index):

    if self.count is 0:

      raise ValueError(“The replay memory is empty!”)

    if index < self.agent_history_length – 1:

      raise ValueError(“Index must be min 3”)

    return self.frames[index-self.agent_history_length+1:index+1, …]

Следующей идёт функция, которая называется _get_valid_indices. Это вспомогательная функция для выбора пакета, так что может показаться странным, что она ничего не возвращает. Вместо этого она присваивает индексы, которые будут соответствовать примерам из пакета, и сохраняет их в переменной экземпляра self.indices. Именно здесь проверяются все крайние случаи, о которых мы говорили ранее. Сначала мы случайным образом выбираем индекс. Это может быть любое целое число от 4 до значения переменной self.count. Мы ещё не знаем, корректный ли это индекс, поэтому должны сделать ряд проверок. Первым делом мы проверяем тривиальный случай, когда индекс меньше agent_history_length – этого не может случиться по чисто техническим причинам. Затем мы проверяем, что четыре последовательных кадра не пересекают границу self.current, поскольку – не забывайте об этом! – self.current представляет точку, в которой мы вставляем новый кадр. Также не забывайте, что буфер воспроизведения цикличен, поэтому может получиться, что self.current может оказаться где-то посередине после self.current, представляющего старый кадр, и до self.current, представляющего самый последний кадр. Если мы выберем индекс, который несколько опережает self.current, однако четыре кадра после несколько отстают от self.current, то эти четыре кадра не представляют собой действительное состояние. Следующий случай, нуждающийся в проверке, – не находимся ли мы на границе эпизода. Логика тут аналогичная: если мы находимся где-то в середине буфера воспроизведения и индекс немного опережает флаг done, но четыре кадра позади немного отстают от флага done, то в этом случае у нас не будет корректного состояния. Во всех таких случаях мы просто продолжаем работу и выбираем новый индекс. Обнаружив же индекс, соответствующий действительному состоянию, мы прерываем цикл и присваиваем его атрибуты self.indices.

  def _get_valid_indices(self):

    for i in range(self.batch_size):

      while True:

        index = random.randint(self.agent_history_length, self.count – 1)

        if index < self.agent_history_length:

          continue

        if index >= self.current and index – self.agent_history_length <= self.current:

          continue

        if self.terminal_flags[index – self.agent_history_length:index].any():

          continue

        break

      self.indices[i] = index

Далее идёт функция get_minibatch. Прежде всего выдаётся ошибка, если недостаточно заполнен буфер воспроизведения. После этого вызывается только что описанная вспомогательная функция _get_valid_indices. Затем мы перебираем все найденные индексы и получаем состояния для каждого индекса. Не забывайте, для каждого индекса нам нужно состояние s и следующее состояние s, поэтому мы присваиваем их self.states и self.new_states. После этого возвращается пакет, включающий в себя состояния s, действия a, вознаграждения r, следующие состояния s и флаг done.

  def get_minibatch(self):

    “””

    Returns a minibatch of self.batch_size transitions

    “””

    if self.count < self.agent_history_length:

      raise ValueError(‘Not enough memories to get a minibatch’)

    self._get_valid_indices()

    for i, idx in enumerate(self.indices):

      self.states[i] = self._get_state(idx – 1)

      self.new_states[i] = self._get_state(idx)

    return self.states, self.actions[self.indices], self.rewards[self.indices], self.new_states, self.terminal_flags[self.indices]

Далее идёт функция init_filter. Она будет использоваться для свёрточных слоёв, чтобы получить начальные значения весовых коэффициентов. Как обычно, нам нужно, чтобы весовые коэффициенты имели дисперсию, равную двойке, делённой на размер входных данных.

def init_filter(shape):

  w = np.random.randn(*shape) * np.sqrt(2.0 / np.prod(shape[1:]))

  return w.astype(np.float32)

Затем идёт функия, вычисляющая Adam-обновления при заданных потерях и списке параметров модели. Я не буду вдаваться в подробности, поскольку всё уже сделано, однако не стесняйтесь задавать вопросы в разделе вопросов и ответов, если не понимаете, что тут происходит.

def adam(cost, params, lr0=1e-5, beta1=0.9, beta2=0.999, eps=1e-8):

  # cast

  lr0 = np.float32(lr0)

  beta1 = np.float32(beta1)

  beta2 = np.float32(beta2)

  eps = np.float32(eps)

  one = np.float32(1)

  zero = np.float32(0)

  grads = T.grad(cost, params)

  updates = []

  time = theano.shared(zero)

  new_time = time + one

  updates.append((time, new_time))

  lr = lr0*T.sqrt(one – beta2**new_time) / (one – beta1**new_time)

  for p, g in zip(params, grads):

    m = theano.shared(p.get_value() * zero)

    v = theano.shared(p.get_value() * zero)

    new_m = beta1*m + (one – beta1)*g

    new_v = beta2*v + (one – beta2)*g*g

    new_p = p – lr*new_m / (T.sqrt(new_v) + eps)

    updates.append((m, new_m))

    updates.append((v, new_v))

    updates.append((p, new_p))

  return updates

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

class ConvLayer(object):

  def __init__(self, mi, mo, filtsz=5, stride=2, f=T.nnet.relu):

    # mi = input feature map size

    # mo = output feature map size

    sz = (mo, mi, filtsz, filtsz)

    W0 = init_filter(sz)

    self.W = theano.shared(W0)

    b0 = np.zeros(mo, dtype=np.float32)

    self.b = theano.shared(b0)

    self.stride = (stride, stride)

    self.params = [self.W, self.b]

    self.f = f

    # self.cut = cut

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

  def forward(self, X):

    conv_out = conv2d(

      input=X,

      filters=self.W,

      subsample=self.stride,

      # border_mode=’half’,

      border_mode=’valid’,

    )

    # cut off 1 pixel from each edge

    # to make the output the same size as input

    # like tensorflow

    # if self.cut:

    #   conv_out = conv_out[:, : ,:self.cut ,:self.cut]

    return self.f(conv_out + self.b.dimshuffle(‘x’, 0, ‘x’, ‘x’))

Далее идёт HiddenLayer, в котором, по сути, следуем той же схеме. В конструкторе мы инициируем весовые коэффициенты и свободные члены и сохраняем их в виде переменных экземляра. В функции forward выполняется пересылка данных в слой нейронной сети.

class HiddenLayer:

  def __init__(self, M1, M2, f=T.nnet.relu):

    W = np.random.randn(M1, M2) * np.sqrt(2 / M1)

    self.W = theano.shared(W.astype(np.float32))

    self.b = theano.shared(np.zeros(M2).astype(np.float32))

    self.params = [self.W, self.b]

    self.f = f

  def forward(self, X):

    a = X.dot(self.W) + self.b

    return self.f(a)

Затем идёт класс DQN. Основная часть работы выполняется в конструкторе, поэтому на данную функцию надо обратить особое внимание. В качестве входных данных берётся число выходов K, а также размеры свёрточного и скрытого слоёв. Вначале мы присваиваем значения K переменным экземпляра. Затем мы создаём переменные Theano для входных данных и целевых переменных. Входные данные – это X, которое является четырёхмерным тензором, а целевые переменные – это G, которое является вектором прогнозируемых отдач. Кроме того, нам нужны действия, которые будут определять, какой из выходных узлов будет использоваться для прогноза. Это тоже одномерный массив, но состоящий из целых, а не вещественных чисел.

class DQN:

  def __init__(self, K, conv_layer_sizes, hidden_layer_sizes):

    self.K = K

    # inputs and targets

    X = T.ftensor4(‘X’)

    G = T.fvector(‘G’)

    actions = T.ivector(‘actions’)

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

    # create the graph

    self.conv_layers = []

    num_input_filters = 4 # number of filters / color channels

    current_size = IM_SIZE

    for num_output_filters, filtersz, stride in conv_layer_sizes:

      ### not using this currently, it didn’t make a difference ###

      # cut = None

      # if filtersz % 2 == 0: # if even

      #   cut = (current_size + stride – 1) // stride

      layer = ConvLayer(num_input_filters, num_output_filters, filtersz, stride)

      current_size = (current_size + stride – 1) // stride

      # print(“current_size:”, current_size)

      self.conv_layers.append(layer)

      num_input_filters = num_output_filters

Следующее, что мы делаем, – напрямую получаем выходной размер. Это куда более явный способ получения выходного размера и является наиболее предпочтительным, поскольку свёртки в разных библиотеках не совсем совпадают. Так, например, один и тот же код в Theano и Tensorflow будет выдавать разные размеры выходов. Ввиду этого проверка размера напрямую является наиболее верным способом получения правильного ответа. Для этого мы создаём функцию Theano, которая просто выдаёт последний выход функций forward из свёрточных слоёв. Тогда мы можем просто ввести фиктивные входные данные и проверить размер выходных, что покажет нам размер входных данных в первом полносвязном слое. Как вы можете видеть, я сохраняю эти данные в переменной flattened_ouput_size.

    # get conv output size

    Z = X / 255.0

    for layer in self.conv_layers:

      Z = layer.forward(Z)

    conv_out = Z.flatten(ndim=2)

    conv_out_op = theano.function(inputs=[X], outputs=conv_out, allow_input_downcast=True)

    test = conv_out_op(np.random.randn(1, 4, IM_SIZE, IM_SIZE))

    flattened_ouput_size = test.shape[1]

Далее мы передаём данные в полносвязные слои. Они определяются только одним числом – размером скрытых слоёв M. И наконец, мы проводим данные через конечный полносвязный слой, чтобы получить K выходов.

    # build fully connected layers

    self.layers = []

    M1 = flattened_ouput_size

    print(“flattened_ouput_size:”, flattened_ouput_size)

    for M2 in hidden_layer_sizes:

      layer = HiddenLayer(M1, M2)

      self.layers.append(layer)

      M1 = M2

    # final layer

    layer = HiddenLayer(M1, K, lambda x: x)

    self.layers.append(layer)

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

    # collect params for copy

    self.params = []

    for layer in (self.conv_layers + self.layers):

      self.params += layer.params

После этого мы можем передать выход свёртки в наши конечные полносвязные слои. Это даёт нам переменную Y_hat – выходной прогноз. Получив его, можно строить функцию затрат. Для этого нужно выбрать только те Y_hat, которые соответствуют выбранным действиям. К счастью, в Theano это сделать легко, поскольку мы можем проиндексировать тензоры наподобие массивов Numpy. Это невозможно в других библиотеках вроде Tensorlow – во всяком случае, пока что. После этого мы вычисляем значение переменной cost, которое равно среднему значению квадрата ошибки между G и ценностями выбранных действий. Затем мы можем подставить переменную cost и параметры в функцию adam и получить обновления для модели. Используя этот список обновлений, мы можем скомпилировать функцию Theano для обучения. Она принимает входные данные, целевые переменные и действия, в результате выдавая прогноз. Это всё, что касается конструктора.

    # calculate final output and cost

    Z = conv_out

    for layer in self.layers:

      Z = layer.forward(Z)

    Y_hat = Z

    selected_action_values = Y_hat[T.arange(actions.shape[0]), actions]

    cost = T.mean((G – selected_action_values)**2)

    # create train function

    updates = adam(cost, self.params)

    # compile functions

    self.train_op = theano.function(

      inputs=[X, G, actions],

      outputs=cost,

      updates=updates,

      allow_input_downcast=True

    )

    self.predict_op = theano.function(

      inputs=[X],

      outputs=Y_hat,

      allow_input_downcast=True

    )

Далее идёт ещё несколько функций класса DQN, но они куда меньше только что рассмотренных.

Так, следующей идёт функция copy_from. Она принимает в виде аргумента глубокую сеть и копирует её весовые коэффициенты. В Theano это делается легко, поскольку мы собрали все параметры модели в атрибуте params, а потому всё, что нам нужно, – это перебрать по циклу все эти параметры в обеих сетях в соответствующем порядке и и спользовать функции get_value и set_value для копирования каждого параметра.

  def copy_from(self, other):

    my_params = self.params

    other_params = other.params

    for p, q in zip(my_params, other_params):

      actual = q.get_value()

      p.set_value(actual)

Затем идёт функция predict, запускающая функцию predict_op для заданного состояния. Она должна возвращать ценность Q по всем действиям.

  def predict(self, X):

    return self.predict_op(X)

Следом идёт функция update, запускающая одну итерацию train_op, а также возвращает значение функции затрат. Обратите внимание, что в качестве аргументов она принимает состояния, действия и целевые переменные, соответствующие ранее созданным нами заполнителям.

  def update(self, states, actions, targets):

    return self.train_op(states, targets, actions)

И наконец, у нас есть функция sample_action, использующая эпсилон-жадный алгоритм для выбора действия. Она принимает два аргумента: x, которое является состоянием, и eps, которое является вероятностью выполнения случайно выбранного действия. Как вы видите, здесь используется написанная нами ранее функция predict. Взяв аргумент максимизации argmax, мы получаем действия, дающие наибольшую ценность Q.

  def sample_action(self, x, eps):

    if np.random.random() < eps:

      return np.random.choice(self.K)

    else:

      return np.argmax(self.predict([x])[0])

Следующей идёт функция learn. Очень хорошо инкапсулировать всё в одну функцию, поскольку настройка обновлений является нетривиальной задачей: мы должны взять пакет данных из памяти воспроизведения, рассчитать целевые переменные, используя целевую сеть, а затем подставить эти целевые переменные в главную сеть. Итак, первым делом мы используем наш буфер воспроизведения, чтобы получить случайным образом выбранный пакет данных с помощью функции get_minibatch. Затем нам нужно вычислить целевые переменные. Вначале мы вычисляем ценности Q для следующего состояния s’. После этого выбираются максимальные значения Q для каждого состояния по всем действиям. В конце мы обесцениваем ценности Q при помощи gamma, умноженного на инвертированный флаг done, и с добавлением вознаграждения.

Самое непонятное тут – почему используется инвертированный флаг done, поэтому объясним это подробнее. Как вы знаете, ценность конечного состояния равна нулю, а логические значения рассматриваются как нули и единицы – значение «истина» соответствует единице, а «ложь» – нулю. Следовательно, если флаг done имеет значение «истина», то он равен единице, а инвертирование этого значения даёт нам нуль. Умножение же на нуль даёт нам нуль – именно ту ценность конечного состояния, которая и должна быть. Если же флаг done имеет значение «ложь», то инвертирование даёт нам единицу, что даёт обычную целевую TD-переменную.

В конце мы вызываем функцию update, определённую ранее, и возвращаем зн