Глубокое Q-обучение и псевдокод для игр Atari

Дополнительные детали реализации для Atari

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

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

Прежде всего, мы уже говорили о том, насколько входные данные теперь больше того, к чему мы привыкли. Напомню, что мы собираем четыре кадра в виде одного состояния, размер кадра составляет 210x160x3, но 3 мы можем отбросить, сделав изображение чёрно-белым. Итого мы получаем размер 210x160x4 для каждого состояния, что равно 134 400 входам, а это много. Но что хорошо во всех играх Atari – так это то, что их графика весьма проста, так что можно уменьшить изображение, вовсе не теряя никакой информации. В действительности есть части изображения, которые на нам вообще не нужны для принятия решения, так что изображение можно ещё и обрезать.

Давайте изучим кадр, что вы понимали, что я имею в виду. Вначале необходимо импортировать Gym и получить среду, которая, если вы ещё не знаете, для рассматриваемого примера называется Breakout, а затем получить первое состояние с помощью команды env.reset:

import gym

env = gym.make(‘Breakout-v0’)

A = env.reset

Не забывайте, что команда env.observation_space.sample() даст лишь равномерную выборку пикселей, что нам никак не поможет.

Далее необходимо импортировать matplotlib, чтобы вывести изображение:

import matplotlib.pyplot as plt

plt.imshow(A)

plt.show()

Сейчас вы видите первый кадр игры Breakout (см. слайд). Как видим, верхняя часть кадра, а также нижняя, которая под ракеткой, на самом деле нам не нужны. Я советую выполнить описанные команды и увеличить изображение, чтобы вы поняли, какие части кадра можно обрезать. Я выбрал пиксели 31 и 195 вдоль размерности высоты:

B = A[31:195]

plt.imshow(B)

plt.show()

Обрежем и посмотрим, как это будет выглядеть. Как видите, после выполнения этой операции у нас по-прежнему есть вся необходимая для игры информация.

Далее мы упоминали об уменьшении размерности. Попробуем её следующей и выполнить. К счастью, в библиотеке Scipy есть функция специально для уменьшения размерности изображений, которая называется imresize:

from scipy.misc import imresize

C = imresize(B, size=(105, 80, 3))

Заметьте, что 105×80 – это половина исходного размера. На слайде вид не очень чёткий, но вовсе не от того, о чём вы подумали. Причина в том, что уменьшение размерности изображения – это не просто «прореживание» исходного изображения, тут используется довольно заковыристая математика.

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

C = imresize(B, size=(105, 80, 3), interp=’nearest’)

plt.imshow(C)

plt.show()

Получается куда лучше.

И наконец, нам будет удобнее обрабатывать изображения как квадраты, где ширина равна высоте:

C = imresize(B, size=(80, 80, 3), interp=’nearest’)

plt.imshow(C)

plt.show()

Может возникнуть вопрос, не приведёт ли такое искажение изображения к потере информации, однако всё выглядит нормально.

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

Следующее изменение, о котором надо поговорить, – это использование встроенных слоёв Tensorflow. Напомню курс по свёрточным нейронным сетям: чтобы соединить свёрточные и полносвязные слои, нам необходимо сгладить конечный выход свёртки. Но чтобы весовые коэффициенты первого скрытого слоя соответствовали нужному размеру, необходимо знать, каким будет размер конечного выхода свёртки. В общем случае, это нетривиальная задача; в действительности мы потратили на неё целую лекцию. Использование же встроенных слоёв делает это менее болезненной процедурой; кроме того, они являются частью официальной библиотеки Tensorflow, так что будут работать быстрее, чем что-либо созданное нами. Скорость тут действительно имеет значение, поэтому необходимо как можно больше пользоваться встроенными инструментами. Библиотека Tensorflow имеет функции как для свёрточных, так и для полносвязных слоёв:

tf.contrib.layers.conv2d

tf.contrib.layers.fully_connected

Далее, поскольку мы используем встроенные слои Tensorflow, это значит, что у нас будет меньше контроля над их действием. В частности, перед этим мы использовали переменные экземпляра параметров, что помогало нам скопировать главную сеть в целевую. Однако встроенные слои Tensorflow не имеют таких переменных. Для решения этой проблемы нам нужно воспользоваться так называемой областью видимости. Присвоив каждой сети свою область видимости, все переменные, которые мы создадим в сети, будут иметь имена с префиксом имени этой сферы видимости. Это поможет нам находить переменные для каждой сети, когда настанет время для их копирования. Следующая тонкость небольшая, однако всё же стоящая упоминания. В оригинальной статье о Q-обучении ε обновлялось линейно с уменьшением от 1 до 0,1 на протяжении некоторого количества этапов, после чего значение ε сохранялось равным 0,1. Мы также воспользуемся этим подходом. Если вы хотите узнать точные значения использовавшихся гиперпараметров, предлагаю прочитать эту статью, но, конечно, их можно и перенастроить. Так что имейте в виду: то, что вы видите в сегодняшних видео по написанию кода, может не совпадать с тем, что будет официально размещено в репозитарии в дальнейшем. Именно поэтому вы должны всегда использовать команду git pull, чтобы иметь последнюю версию.

Псевдокод и память воспроизведения

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

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

Рассмотрим подробнее, что влечёт за собой игра в каждом эпизоде. Как вы уже видели, это значит, что мы запускаем цикл, заканчивающийся при получении флага done. Внутри цикла мы предпринимаем действие, основываясь на текущем состоянии. Конечно, мы используем нейронную сеть, чтобы определить, какое предпринять действие, и применяя эпсилон-жадную стратегию. Это даёт нам следующий кадр, вознаграждение и флаг done. Используя следующий кадр, мы можем добавить его к нашему состоянию, чтобы получить следующее состояния для очередной итерации цикла. Помимо того, нам нужно хранить кортеж (s, a, r, s’) в памяти воспроизведения опыта. И наконец, на каждом этапе вызывается функция обучения. Кроме этого, нам нужно быть уверенными в периодическом копировании весовых коэффициентов из главной сети в целевую.

Итак, что же происходит в функции обучения? Это место, где мы в действительности обучаем нашу модель. В этой функции мы собираем случайный пакет примеров из нашей памяти воспроизведения опыта – это пакет кортежей (s, a, r, s’, done). Напомню, что состояния формируют входы в нейронную сеть, а целевые переменные вычисляются при помощи целевой сети. Точнее говоря, целевая переменная для заданных r и s равна

Важно не забывать, что если флаг done имеет значение «истина», то это значит, что s является конечным состоянием; в этом случае целевая переменная равна просто r – вознаграждению.

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

Важно не забывать, что если флаг done имеет значение «истина», то это значит, что s является конечным состоянием; в этом случае целевая переменная равна просто r – вознаграждению.

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

По сути, это всё.

Главный фокус в следующем – как эффективно организовать память воспроизведения. Представьте, что мы просто и безыскусно сохраняем каждый кортеж (s, a, r, s’, done). Если помните, каждое состояние – хоть s, хоть s – является массивом 4x80x80, а это весьма большой массив. Более того, у нас будет множество таких массивов! Предположим, мы будем хранить их в виде списка. Когда список заполнится, нам потребуется выполнить две операции: во-первых, добавить последнее состояние и потому вызвать функцию append; а во-вторых, также убрать самое старое состояние и потому вызвать функцию pop с аргументом 0. Может показаться, что это будет занимать постоянный объём памяти, однако на самом деле нет. К сожалению, в Python это приведёт к потерям в памяти.

Есть и ещё одна проблема в хранении состояний таким способом. Рассмотрим s и s. Так как s – это состояние после s, последние три кадра s идентичны первым трём кадрам s. Другими словами, у нас есть только 5 уникальных кадров, а хранить придётся 8, то есть почти в два раза больше, чем нужно.

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

Решение в следующем. Мы будем просто сохранять последовательные кадры. Предположим, пришло время взять пример, то есть кортеж (s, a, r, s’, done). У нас есть массивы кадров, действий, вознаграждений и флагов done. Предположим далее, мы выбрали случайный индекс t. Тогда состояние хранится в массиве кадров с индексами от t – 4 до t. Следующее состояние хранится в массиве кадров с индексами от t – 3 до t + 1. Действие же, вознаграждение и флаг done хранятся с индексом t. Можем видеть, как можно неявно хранить все нужные состояния путём хранения одних лишь кадров.

Конечно же, есть и все виды предельных случаев, с которыми придётся иметь дело. К примеру, что будет на границе между эпизодами? Мы не можем рассматривать кадры с индексами от t – 4 до t, если индекс t – 2 является концом эпизода. К тому же нам нужно обновлять массивы по кругу, так как массив предопределён и имеет фиксированную длину. А значит, когда массив заполнится, мы вернёмся к его началу.

Ещё одно, что надо предусмотреть, – это упорядочение размерностей для используемой библиотеки. Если помните, в Theano используется порядок цвет – высота – ширина, тогда как в Tensorflow – порядок высота – ширина – цвет. Отдельный кадр, поскольку он чёрно-белый, является двухмерным массивом – ширина x высота. Таким образом, наши объекты типа буфера воспроизведения должны будут создавать пакеты изображений таким образом, чтобы размерности шли в правильном порядке.

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

Андрей Никитенко
Андрей Никитенко
Задать вопрос эксперту
Понравилась статья? Поделить с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: