\documentclass{article}

\input{../common-preamble}
\input{../bmstu-preamble}
\input{../fancy-listings-preamble}

\numerationTop

\begin{document}
\fontsize{14pt}{14pt}\selectfont
\pagestyle{empty}
\makeBMSTUHeader

\makeReportTitle{лабораторной}{2}{Разработка программы «Глубокое Q-обучение игрового агента Atari»}{Мультиагентные интеллектуальные системы}{}{Большаков В.Э.}
\newpage
\pagestyle{fancy}
\tableofcontents
\newpage
\sloppy
\section{Цель работы}

Использовать глубокое Q-обучение игрового агента Atari для игры Bowling. Оценить влияние гиперпараметров на результат действий агента.

\section{Задание}

\begin{enumerate}
\item В среде Python (3.6 и старше) создать проект и подключить библиотеку PyTorch.
\item Получить вариант игры Atari у преподавателя.
\item Дать описание игры, среды, состояний и действий агента Atari своей игры по аналогии с документом Exploring the Gym and its Features (таблицы на стр. 16-18 обязательны).
\item Прочитать, перевести, включить в отчет и повторить эксперимент из документа Deep Q-Learning Agent для своей игры Atari.
\item Прочитать, перевести, включить в отчет и повторить эксперимент из документа Model dynamics.
\item Продемонстрировать один из эпизодов динамики игры видеофайлом.
\item Исследовать влияние и взаимовлияние девяти параметров \code{GAMMA}, \code{BATCH_SIZE}, \code{REPLAY_SIZE}, \code{LEARNING_RATE}, \code{SYNC_TARGET_FRAMES}, \code{REPLAY_START_SIZE}, \code{EPSILON_DECAY_LAST_FRAME}, \code{EPSILON_START}, \code{EPSILON_FINAL} на исход игры. Обосновать выбор лучших параметров.
\item Провести обоснованное экспериментальное сравнение обучения своей игры Atari с помощью Deep Q-Learning и случайно действующего агента Random.
\item Подготовить и защитить отчет (титульный лист, задание, теоретическая часть, графики экспериментов, диаграмма структуры программы, принтскрины основных шагов работы программы, листинг программы с комментариями, список использованной литературы) + видеофайл.
\end{enumerate}

\section{Работа и результат}
Для лаборатрной работы по заданному индивидуальному варианту требовалось разработать глубокое Q-обучение игрового агента Atari для игры Bowling.

Цель игры -- набрать как можно больше очков в игре в боулинг. Игра состоит из 10 фреймов, и у вас есть две попытки на каждый фрейм. Сбивание всех кеглей с первой попытки называется «страйком». Сбивание всех кеглей во время второго броска называется «лонжероном». В противном случае рамка называется «открытой».

\begin{figure}[H]
    \centering
    \includegraphics[width=40mm]{03-mas-02-lab-bowling.png}
    \caption{Скриншот игры Bowling}
    \label{fig:bowling}
\end{figure}

Доступные действия для агента выражаются в следующей таблице:

\begin{table}[H]
\begin{center}
\begin{tabular}{cc}
\hline
\textbf{Num} & \textbf{Action} \\ \hline
0            & NOOP            \\
1            & FIRE            \\
2            & UP              \\
3            & DOWN            \\
4            & UPFIRE          \\
5            & DOWNFIRE        \\ \hline
\end{tabular}
\label{Tab:action}
\caption{Основные действия агента для игры Bowling}
\end{center}
\end{table}

Агент получает очки за сбивание кеглей. Точный счет зависит от того, удастся ли агенту провести «страйк», «запасной» или «открытый» фрейм. Более того, очки, которые агент набирает за один кадр, могут зависеть от следующих кадров. Агент может набрать до 300 очков за одну игру (если ему удастся нанести 12 ударов).

\subsection{Результаты тестирования агента для различных гиперпараметров}
Так как результат игры зависит от принятых действий агента на протяжении всех итераций игры, то до момента полного обучения агента его действия представляют собой череду случайных действий, иными словами агент «анализирует окружащую среду» для поиска действий, ведущих к достижению цели (набор максимального числа очков). Таким образом, результаты игры представляют собой некоторое случайно распределённое значение. Полученные результаты было решено аппроксимировать по методу наименьших квадратов (МНК), так как в данной работе необходимо оценить качественное влияние гиперпараметров на результат действий агента. Таким образом при помощи МНК можно описать линию тренда, которая выражает оценку действий агента в окружающей среде. Так же была посчитана медиана, как более наглядный собирательный параметр всех итераций.

\foreach \nameFile/\name/\namePar in 
{GAMMA_epsilon/epsilon/GAMMA,
GAMMA_speed/speed/GAMMA,
GAMMA_m_reward/m reward/GAMMA,
GAMMA_reward/reward/GAMMA,
BATCH_SIZE_epsilon/epsilon/BATCH SIZE,
BATCH_SIZE_speed/speed/BATCH SIZE,
BATCH_SIZE_m_reward/m reward/BATCH SIZE,
BATCH_SIZE_reward/reward/BATCH SIZE,
REPLAY_SIZE_epsilon/epsilon/REPLAY SIZE,
REPLAY_SIZE_speed/speed/REPLAY SIZE,
REPLAY_SIZE_m_reward/m reward/REPLAY SIZE,
REPLAY_SIZE_reward/reward/REPLAY SIZE,
LEARNING_RATE_epsilon/epsilon/LEARNING RATE,
LEARNING_RATE_speed/speed/LEARNING RATE,
LEARNING_RATE_m_reward/m reward/LEARNING RATE,
LEARNING_RATE_reward/reward/LEARNING RATE,
SYNC_TARGET_FRAMES_epsilon/epsilon/SYNC TARGET FRAMES,
SYNC_TARGET_FRAMES_speed/speed/SYNC TARGET FRAMES,
SYNC_TARGET_FRAMES_m_reward/m reward/SYNC TARGET FRAMES,
SYNC_TARGET_FRAMES_reward/reward/SYNC TARGET FRAMES,
REPLAY_START_SIZE_epsilon/epsilon/REPLAY START SIZE,
REPLAY_START_SIZE_speed/speed/REPLAY START SIZE,
REPLAY_START_SIZE_m_reward/m reward/REPLAY START SIZE,
REPLAY_START_SIZE_reward/reward/REPLAY START SIZE,
EPSILON_DECAY_LAST_FRAME_epsilon/epsilon/EPSILON DECAY LAST FRAME,
EPSILON_DECAY_LAST_FRAME_speed/speed/EPSILON DECAY LAST FRAME,
EPSILON_DECAY_LAST_FRAME_m_reward/m reward/EPSILON DECAY LAST FRAME,
EPSILON_DECAY_LAST_FRAME_reward/reward/EPSILON DECAY LAST FRAME,
EPSILON_START_epsilon/epsilon/EPSILON START,
EPSILON_START_speed/speed/EPSILON START,
EPSILON_START_m_reward/m reward/EPSILON START,
EPSILON_START_reward/reward/EPSILON START,
EPSILON_FINAL_epsilon/epsilon/EPSILON FINAL,
EPSILON_FINAL_speed/speed/EPSILON FINAL,
EPSILON_FINAL_m_reward/m reward/EPSILON FINAL,
EPSILON_FINAL_reward/reward/EPSILON FINAL}
{
   \begin{figure}[H]
        \begin{center}
            \resizebox{0.7\textwidth}{!}{\input{pics/03-mas-02-lab-\nameFile.pgf}}
        \end{center}
        \caption{Влияние \namePar\thinspace на значение \name}
        \label{fig:\nameFile}
    \end{figure}
}

После анализа всех приведённых графиков можно выделить следующий набор оптимальных в плане максимума награды гиперпараметров:

\begin{table}[H]
\begin{center}
    \begin{tabular}{cc}
    Гиперпараметр                                     & Значение                     \\ \hline
    \multicolumn{1}{|c|}{GAMMA}                       & \multicolumn{1}{c|}{0.93}    \\ \hline
    \multicolumn{1}{|c|}{BATCH\_SIZE}                 & \multicolumn{1}{c|}{64.0}    \\ \hline
    \multicolumn{1}{|c|}{REPLAY\_SIZE}                & \multicolumn{1}{c|}{12000.0} \\ \hline
    \multicolumn{1}{|c|}{LEARNING\_RATE}              & \multicolumn{1}{c|}{1e-06}   \\ \hline
    \multicolumn{1}{|c|}{SYNC\_TARGET\_FRAMES}        & \multicolumn{1}{c|}{800.0}   \\ \hline
    \multicolumn{1}{|c|}{REPLAY\_START\_SIZE}         & \multicolumn{1}{c|}{8000.0}  \\ \hline
    \multicolumn{1}{|c|}{EPSILON\_DECAY\_LAST\_FRAME} & \multicolumn{1}{c|}{80000.0} \\ \hline
    \multicolumn{1}{|c|}{EPSILON\_START}              & \multicolumn{1}{c|}{0.9}     \\ \hline
    \multicolumn{1}{|c|}{EPSILON\_FINAL}              & \multicolumn{1}{c|}{0.1}     \\ \hline
    \end{tabular}
\end{center}
\caption{Таблица гиперпараметров, обеспечивающих максимум награды}
\label{Tab:reward}
\end{table}

\section{Выводы}
В результате лабораторной работы были найдены оптимальные гиперпараметры для обучения агента. Указанные параметры приведены в таблице \ref{Tab:reward}. В ходе обучения агента была выбрана черта в 400 тысяч кадров, при таком подходе, макисмальное среднее число очков, которые удалось набрать в ходе всех проведённых игр составляет 49,93. Дальнейший расчёт затруднён тем, что для полноценного обучения агента может потребоваться неколько миллионов кадров, чтобы достичь коэффициента побед хотя бы около 70\%. Такой долгий процесс обучения связан в первую очередь с большим количеством операций при работе сверточной неройнной сети, представляющей «зрение» агента, в свою очередь необученный агент совершает большое количество лишних операций с целью исследования окружающей среды, что в пустую расходует ресурсы GPU.

\newpage
\appendix
\setcounter{secnumdepth}{3}
\section*{Приложения}
\addcontentsline{toc}{section}{Приложения}
\renewcommand{\thesubsection}{\Asbuk{subsection}}

\subsection{Перевод оригинальной статьи Model dynamics}
Чтобы сделать Ваше ожидание не таким скучным, наш код сохраняет лучшие веса модели. В файле \code{/03_dqn_play.py} у нас есть программа, которая может загрузить этот файл модели и сыграть один эпизод, отображая динамику модели. Код очень простой, но наблюдение за тем, как несколько матриц с миллионом параметров играет в пинг-понг с нечеловеческой точностью, глядя только на пиксели, может показаться магией.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
import gym
import time
import argparse
import numpy as np
import torch
from lib import wrappers
from lib import dqn_model

DEFAULT_ENV_NAME = 'PongNoFrameskip-v4'
FPS = 25
\end{lstlisting}

В самом начале мы импортируем уже знакомые PyTorch и Gym модули. Параметр FPS устанавливает примерную скорость демонстрируемых фреймов.

\begin{lstlisting}[language=C,style=CCodeStyle]
if name == 'main':
  parser = argparse.ArgumentParser()
  parser.add_argument('-m', '--model', required=True, help='Model file to load')
  parser.add_argument(
      '-e', '--env', default=DEFAULT_ENV_NAME,
      help=f'Environment name to use, default={DEFAULT_ENV_NAME}')
  parser.add_argument(
      '-r', '--record', help='Directory to store video recording')
  parser.add_argument(
      '--no-visualize', default=True, action='store_false', dest='visualize',
      help='Disable visualization of the game play')
  args = parser.parse_args()
\end{lstlisting}

Скрипт принимает имя файла сохранённой модели и позволяет уточнить Gym-окружение (конечно, модель и окружение, должны соответствовать). В дополнение, возможно передать ключ \code{-r} с именем несуществующей директории, которая будет использована для сохранения видео Вашей игры (используя обёртку монитора). По умолчанию, скрипт просто показывает кадры, но если, например, нужно загрузить видео игры на YouTube, ключ \code{-r} может оказаться полезен.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
env = wrappers.make_env(args.env)
if args.record:
  env = gym.wrappers.Monitor(env, args.record)
net = dqn_model.DQN(env.observation_space.shape, env.action_space.n)
net.load_state_dict(
  torch.load(args.model, map_location=lambda storage, loc: storage))
\end{lstlisting}

Код выше должен быть понятен без комментариев: мы создаём окружение и модель, затем загружаем веса из файла, путь до которого передан в аргументах.

\begin{lstlisting}[language=C,style=CCodeStyle]
state = env.reset()
total_reward = 0.0
  
while True:
  start_ts = time.time()
  if args.visualize:
    env.render()
  state_v = torch.tensor(np.array([state], copy=False))
  q_vals = net(state_v).data.numpy()[0]
  action = np.argmax(q_vals)
\end{lstlisting}

Это почти точная копия метода \code{play_step()} класса \code{Agent} из тренировочного кода без выбора эпсилон-жадной стратегии. Мы просто передаём наши наблюдения агенту и выбираем действие с максимальной наградой. Единственное, что здесь есть нового, это метод \code{render()} в окружении, который является стандартным способом в \code{Gym}, чтобы показать текущие наблюдения (для этого понадобится GUI).

\begin{lstlisting}[language=C,style=CCodeStyle]
  state, reward, done, _ = env.step(action)
  total_reward += reward
  if done:
    break
  if args.visualize:
    delta = 1/FPS - (time.time() - start_ts)
    if delta > 0:
      time.sleep(delta)
print(f'Total reward: {total_reward:.2f}')
\end{lstlisting}

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

\subsection{Перевод оригинальной статьи Deep Q-Learning Agent}
\subsubsection*{Deep Q-Learning Agent}
Исследователи обнаружили много советов и приемов для того, чтобы сделать DQN обучение более стабильным и эффективным. Однако основа, позволяющая \code{DeepMind} успешно обучить DQN на наборе из 49 игр Atari и продемонстрировать эффективность этот подход, применяется к сложным средам. Оригинальная статья (без целевой сети) была опубликована в конце 2013 года (Playing Atari with Deep Reinforcement Learning 1312.5602v1, Mnih and others), а для тестирования использовали семь игр. Позднее, в начале 2015 года была опубликована исправленная версия статьи с 49 различными играми в Nature (Human-Level Control Through Deep Reinforcement Learning doi:10.1038/nature14236, Mnih and others.) Алгоритм для DQN из предыдущей статьи имеет следующие этапы:
\begin{enumerate}
    \item Инициализировать параметры для $Q(s, a)$ и $\hat{Q}(s, a)$ со случайными весами, $\varepsilon \gets 1.0$, и пустым буфером воспроизведения;
    \item с вероятностью $\varepsilon$, выбрать случайное действие $a$, в противном случае $a = arg \max_a Q_{s, a}$;
    \item выполнить действие $a$ в эмуляторе, наблюдать за наградой $r$ и следующим состоянием $s'$;
    \item сохранить переход $(s,a,r,s')$ в буфере воспроизведения;
    \item сделать случайную мини-выборку переходов из буфера воспроизведения;
    \item для каждого перехода в буфере вычислить цель
      \begin{itemize}
        \item [] если эпизод закончился на этом шаге, $y=r$
        \item [] в противном случае $y=r+\gamma*\underset{a'\in A}{\max}\hat{Q}_{s',a'}$;
      \end{itemize}
    \item вычислить потери $L=(Q_{s,a} - y)^2$
    \item обновить $Q(s, a)$ с использованием SGD алгоритма, минимизируя потери, согласно параметров модели;
    \item каждые $N$ шагов копировать вес из $Q$ в $\hat{Q}$;
    \item повторять с шага 2, пока не сойдется.
\end{enumerate}

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

Еще одна вещь, которую следует отметить, это производительность. Наши предыдущие примеры для FrozenLake или Q-обучения Atari не требовательны с точки зрения производительности, наблюдения были маленькими, параметры нейронной сети были крошечными, и несколько лишних миллисекунд в тренировочный цикл не имели значения. Однако отныне это уже не так. Одно единственное наблюдение из среды Atari — это 100 тысяч значений, которые должны быть масштабированы, преобразованы в числа с плавающей запятой и сохранены в буфере воспроизведения. Одна дополнительная копия этих данных может стоить вам скорости обучения, которая будет измеряться не секундами и минутами, но часов даже на самом быстром графическом процессоре. Цикл обучения нейронной сети также может быть узким местом. Конечно RL-модели не такие огромные монстры как ультрасовременные ImageNet, но даже модель DQN 2015 года имеет более 1,5 млн параметров, что очень много для графического процессора. Итак, если коротко: производительность имеет значение, особенно когда вы экспериментируете с гиперпараметрами и вам нужно ждать пока обучится не одна модель, а десяток.

PyTorch достаточно выразителен, поэтому более-менее эффективный код обработки может выглядеть гораздо менее загадочным, чем оптимизированные графики TensorFlow, но все же есть много возможностей, чтобы делать что-то медленно и делать ошибки. Например, наивная версия DQN вычисления потерь, которая перебирает каждую выборку партии, примерно в два раза медленнее, чем параллельная версия. Однако одна дополнительная копия пакета данных может сделать скорость
того же кода в 13 раз медленнее, что весьма существенно. Этот пример разбит на три модуля из-за его длины, логической структуры и возможности повторного использования. Модули:
\begin{enumerate}
  \item \code{Deep Q Learning Agent/lib/wrappers.py}: это Atari оболочки среды, в основном взятые из проекта OpenAI Baselines;
  \item \code{Deep Q Learning Agent/lib/dqn_model.py}: это DQN слой нейронной сети с той же архитектурой, что и DeepMind DQN из оригинальной статьи;
  \item \code{Deep Q Learning Agent/02_dqn_pong.py}: это основной модуль с тренировочным циклом, расчетом функции потерь и буфером воспроизведения опыта.
\end{enumerate}

\subsubsection{Обертки}
Работа с играми Atari с помощью RL довольно требовательна с точки зрения ресурсов. Чтобы ускорить работу, к взаимодействию с платформой Atari применено несколько преобразований, которые описаны в статье DeepMind. Некоторые из этих преобразований влияют только на производительность, но некоторые функции платформы Atari делают обучение долгим, трудоемким и нестабильным. Преобразования обычно реализуются в виде оболочек OpenAI Gym различного вида. Полный список довольно длинный, и существует несколько реализаций одной и той же обертки в различных источниках. Мой\footnote{автора статьи (прим. переводчика)} личный фаворит находится в репозитории OpenAI под названием
baselines, который представляет собой набор методов и алгоритмов RL, реализованных в TensorFlow и
применяется к популярным эталонным тестам, чтобы установить общую основу для сравнения методов\footnote{Репозиторий доступен по адресу \href{https://github.com/openai/baselines}{/openai/baselines}, а обертки доступны в файле: \href{https://github.com/openai/baselines/blob/master/baselines/common/atari_wrappers.py}{atari\_wrappers.py}}. Полный список преобразований Atari, используемых исследователями RL, включает:
\begin{itemize}
    \item Преобразование отдельных жизней в игре в отдельные эпизоды. В общем, эпизод содержит все этапы от начала игры до появления экрана «Игра окончена», которое может длиться тысячи игровых шагов (наблюдений и действий). Обычно, в аркадных играх игроку дается несколько жизней, дающих несколько попыток в игре. Это преобразование разбивает полный эпизод на отдельные небольшие эпизоды для каждой жизни, которая есть у игрока. Не все игры поддерживают эту функцию (например, Pong не поддерживает), но для поддерживаемых сред это обычно помогает ускорить конвергенцию, поскольку наши эпизоды становятся короче.
    \item В начале игры, выполняя случайное количество (до 30) бесполезных действия. Это должно стабилизировать тренировку, но нет правильного объяснения, почему это так.
    \item Принятие решения о действии каждые K шагов, где K обычно равно 4 или 3. На промежуточных кадрах, выбранное действие просто повторяется. Это позволяет значительно ускорить обучение, так как обработка каждого кадра с помощью нейронной сети является довольно сложной задачей, но разница между последовательными кадрами обычно незначительна.
    \item Максимум каждого пикселя в последних двух кадрах и использование его в качестве наблюдения. Некоторые игры Atari имеют эффект мерцания, что связано с особенностями и ограничениями платформы (Atari имеет ограниченное количество спрайтов, которые могут отображаться на одном кадре). Человеческому глазу такие быстрые изменения не видны, но могут сбить с толку нейронные сети.
    \item Нажатие FIRE в начале игры. Некоторые игры (в том числе понг и Breakout) требуют, чтобы пользователь нажал кнопку FIRE, чтобы начать игру. Теоретически возможно чтобы нейросеть научилась нажимать FIRE сама, но для этого потребуется гораздо больше эпизодов игры. Поэтому, нажимаем FIRE в обертке.
    \item Масштабирование каждого кадра с 210×160 с тремя цветными кадрами до одноцветного изображения 84×84. Возможны разные подходы. Например, статья DeepMind описывает это преобразование как получение канала цвета Y из цветового пространства YCbCr, а затем масштабирование полного изображения до разрешения 84×84. Некоторые другие исследователи делают преобразование оттенков серого, обрезку ненужных частей изображения и последующее масштабирование. В репозитории Baselines (и в следующем примере кода) используется последний подход.
    \item Объединение нескольких (обычно четырех) последовательных кадров вместе, чтобы дать сети информацию о динамике игровых объектов.
    \item Обрезание вознаграждения до значений −1, 0 и 1. Полученная оценка может сильно различаться от игры к игре. Например, в игре Pong вы получаете 1 очко за каждый мяч, который вы смогли забить противнику. Однако в некоторых играх, таких как кунг-фу, вы получаете вознаграждение в размере 100 за каждого убитого врага. Этот разброс значений вознаграждения делает наши потери полностью разного масштаба между играми, что затрудняет поиск общего гиперпараметра для набора игр. Чтобы исправить это, награда просто обрезается до диапазона $[−1...1]$.
    \item Преобразование наблюдений из байтов без знака в значения с плавающей запятой. Экран полученный из эмулятора кодируется как тензор байт со значениями от 0 до 255, что не является лучшим представлением для нейронной сети. Итак, нам нужно преобразовать изображение в числа с плавающей запятой и изменить масштаб значений в диапазоне $[0,0…1,0]$.
    \item В нашем примере с Pong нам не нужны некоторые из вышеперечисленных оболочек, например преобразование жизней в отдельные эпизоды и вырезание наград, поэтому, эти обертки не включены в пример кода. Тем не менее, вы должны знать о них, на всякий случай, если решите поэкспериментировать с другими играми. Иногда, когда DQN не сходится, проблема не в коде, а в неправильно завернутом окружении. Я\footnote{автор (прим. переводчика} провел несколько дней отладки проблем конвергенции, вызванных отсутствием нажатия кнопки FIRE на начало игры.
\end{itemize}

Рассмотрим реализацию индивидуальных обёрток из \code{Deep Q Learning Agent /lib/wrappers.py}:
\begin{lstlisting}[language=Python,style=PyCodeStyle]
import cv2
import gym
import gym.spaces
import numpy as np
import collections

class FireResetEnv(gym.Wrapper):
  def __init__(self, env=None):
    super(FireResetEnv, self).__init__(env)
    assert env.unwrapped.get_action_meanings()[1] == 'FIRE'
    assert len(env.unwrapped.get_action_meanings()) >= 3
 
 def step(self, action):
    return self.env.step.action()
 
 def reset(self):
    self.env.reset()
    obs, _, done, _ = self.env.step(1)
    if done:
      self.env.reset()
    obs, _, done, _ = self.env.step(2)
    if done:
      self.env.reset()
    return obs
\end{lstlisting}

Предыдущая оболочка нажимает кнопку FIRE в средах, которые требуют этого, чтобы игра началась. Помимо нажатия FIRE, эта обертка проверяет несколько угловых случаев, которые присутствуют в некоторых играх.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
class MaxAndSkipEnv(gym.Wrapper):
  def __init__(self, env=None, skip=4):
    """Return only every 'skip'-th frame"""
    super(MaxAndSkipEnv, self).__init__(env)
    # most recent raw observations (for max pooling across time steps)
    self._obs_buffer = collections.deque(maxlen=2)
    self._skip = skip
 def step(self, action):
   total_reward = 0.0
   done = None
   for _ in range(self._skip):
     obs, reward, done, info = self.env.step(action)
     self._obs_buffer.append(obs)
     total_reward += reward
   if done:
     break
  max_frame = np.max(np.stack(self._obs_buffer), axis=0)
  return max_frame, total_reward, done, info
 
 def _reset(self):
   """Clear past frame buffer and init. to first obs. from inner env."""
   self._obs_buffer.clear()
   obs = self.env.reset()
  self._obs_buffer.append(obs)
   return obs  
\end{lstlisting}

Эта оболочка объединяет повторение действий в течение K кадров и пикселей из двух последовательных кадров.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
class ProcessFrame84(gym.ObservationWrapper):
  def __init__(self, env=None):
    super(ProcessFrame84, self).__init__(env)
    self.observation_space = gym.spaces.Box(low=0, high=255,
    shape=(84, 84, 1), dtype=np.uint8)
 
  def observation(self, obs):
    return ProcessFrame84.process(obs)
 
  @staticmethod
  def process(frame):
    if frame.size == 210 * 160 * 3:
      img = np.reshape(frame, [210, 160, 3]).astype(np.float32)
    elif frame.size == 250 * 160 * 3:
      img = np.reshape(frame, [250, 160, 3]).astype(np.float32)
    else:
      assert False, "Unknown resolution."
 img = img[:, :, 0] * 0.299 + img[:, :, 1] * 0.587 + img[:, :, 2] * 0.114
 resized_screen = cv2.resize(img, (84, 110), interpolation=cv2.INTER_AREA)
 x_t = resized_screen[18:102, :]
 x_t = np.reshape(x_t, [84, 84, 1])
 return x_t.astype(np.uint8)
\end{lstlisting}

Целью этой оболочки является преобразование входных наблюдений от эмулятора, который обычно имеет разрешение 210×160 пикселей с цветовыми каналами RGB, в изображение в градациях серого 84×84. Он делает это с помощью колориметрического преобразования оттенков серого (что ближе к человеческому восприятию цвета, чем простое усреднение цветовых каналов), изменения размера изображения и обрезки верхней и нижней частей результата.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
class BufferWrapper(gym.ObservationWrapper):
  def __init__(self, env, n_steps, dtype=np.float32):
    super(BufferWrapper, self).__init__(env)
    self.dtype = dtype
    old_space = env.observation_space
    self.observation_space = gym.spaces.Box(
      old_space.low.repeat(n_steps, axis=0),
      old_space.high.repeat(n_steps, axis=0),
      dtype=dtype)
  def reset(self):
    self.buffer = np.zeros_like(self.observation_space.low, dtype=self.dtype)
    return self.observation(self.env.reset())
  def observation(self, observation):
    self.buffer[:-1] = self.buffer[1:]
    self.buffer[-1] = observation
    return self.buffer
\end{lstlisting}

Этот класс создает стек последующих кадров по первому измерению и возвращает их как наблюдение. Цель состоит в том, чтобы дать сети представление о динамике объектов, например о скорости и направлении мяча в понге или о том, как двигаются враги. Это очень важная информация, которую невозможно получить из одного изображения.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
class ImageToPyTorch(gym.ObservationWrapper):
  def __init__(self, env):
    super(ImageToPyTorch, self).__init__(env)
    old_shape = self.observation_space.shape
    self.observation_space = gym.spaces.Box(
      low=0.0, high=1.0, shape=(old_shape[-1], old_shape[0], old_shape[1]),
      dtype=np.float32)
  def observation(self, observation):
    return np.moveaxis(observation, 2, 0)  
\end{lstlisting}

Эта простая оболочка меняет форму наблюдения с HWC на формат CHW, требуемый PyTorch. Входная форма тензора имеет цветовой канал в качестве последнего измерения, но слои свертки PyTorch предполагают, что цветовой канал является первым измерением.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
class ScaledFloatFrame(gym.ObservationWrapper):
  def observation(self, obs):
    return np.array(obs).astype(np.float32) / 255.0  
\end{lstlisting}

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

\begin{lstlisting}[language=Python,style=PyCodeStyle]
def make_env(env_name):
  env = gym.make(env_name)
  env = MaxAndSkipEnv(env)
  env = FireResetEnv(env)
  env = ProcessFrame84(env)
  env = ImageToPyTorch(env)
  env = BufferWrapper(env, 4)
  return ScaledFloatFrame(env)  
\end{lstlisting}

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

\subsubsection{DQN модель}
Модель, опубликованная в Nature, имеет три сверточных слоя, за которыми следуют два полносвязных слоя. Все слои разделены нелинейностями ReLU. Результатом модели являются значения Q для каждого действия, доступного в среде, без применения нелинейности (поскольку значения Q могут иметь любое значение). Подход, при котором все значения Q вычисляются за один проход через сеть, помогает нам значительно увеличить скорость по сравнению с буквальной обработкой $Q(s,a)$ передачей наблюдений и действий в сеть для получения значения действия. Код модели находится в \code{Deep Q Learning Agent/lib/dqn_model.py}:

\begin{lstlisting}[language=Python,style=PyCodeStyle]
import torch
import torch.nn as nn
import numpy as np

class DQN(nn.Module):
  def __init__(self, input_shape, n_actions):
    super(DQN, self).__init__()

    self.conv = nn.Sequential(
      nn.Conv2d(input_shape[0], 32, kernel_size=8, stride=4),
      nn.ReLU(),
      nn.Conv2d(32, 64, kernel_size=4, stride=2),
      nn.ReLU(),
      nn.Conv2d(64, 64, kernel_size=3, stride=1),
      nn.ReLU()
    )
    
    conv_out_size = self._get_conv_out(input_shape)
    self.fc = nn.Sequential(
      nn.Linear(conv_out_size, 512),
      nn.ReLU(),
      nn.Linear(512, n_actions)
    )
\end{lstlisting}

Чтобы иметь возможность написать нашу сеть в общем виде, она была реализована в двух частях: сверточной и последовательной. PyTorch не имеет «более плоского» слоя, который мог бы преобразовывать трехмерный тензор в одномерный вектор чисел, необходимый для подачи вывода свертки на полностью связанный слой. Эта проблема решена в функции \code{forward()}, где мы можем преобразовать наш пакет 3D-тензоров в пакет 1D-векторов.

Еще одна небольшая проблема заключается в том, что мы не знаем точного количества значений на выходе слоя свертки, полученного с входом данной формы. Однако нам нужно передать это число первому полносвязному конструктору слоя. Одним из возможных решений было бы жестко закодировать это число, которое является функцией формы входных данных (для входных данных 84×84 выходные данные слоя свертки будут иметь 3136 значений), но это не лучший способ, так как наш код становится менее устойчив к изменению входной формы. Лучшим решением было бы иметь простую функцию (\code{get_conv_out()}), которая принимает входную форму и применяет слой свертки к фальшивому тензору такой формы. Результат функции будет равен количеству параметров, возвращаемых этим приложением. Это будет быстро, так как этот вызов будет выполнен один раз при создании модели, но позволит иметь общий код:

\begin{lstlisting}[language=Python,style=PyCodeStyle]
def _get_conv_out(self, shape):
  o = self.conv(torch.zeros(1, *shape))
  return int(np.prod(o.size()))

def forward(self, x):
  conv_out = self.conv(x).view(x.size()[0], -1)
  return self.fc(conv_out)
\end{lstlisting}

Последней частью модели является функция \code{forward()}, которая принимает входной тензор 4D (первое измерение — \textit{размер пакета}, второе — \textit{цветовой канал}, представляющий собой стек последующих кадров, а третье и четвертое — \textit{размеры изображения}). Применение преобразований выполняется в два этапа: сначала мы применяем сверточный слой к входным данным, а затем получаем на выходе четырехмерный тензор. Этот результат выравнивается, чтобы иметь два измерения: размер пакета и все параметры, возвращаемые сверткой для этого элемента пакета, в виде одного длинного вектора чисел. Это делается с помощью функции view() тензоров, которая позволяет одному единственному измерению быть аргументом -1 в качестве подстановочного знака для остальных параметров.

Например, если у нас есть тензор T формы (2, 3, 4), который представляет собой трехмерный тензор из 24 элементов, мы можем преобразовать его в двумерный тензор с шестью строками и четырьмя столбцами, используя \code{T.view(6, 4)}. Эта операция не создает новый объект памяти и не перемещает данные в памяти, она просто изменяет форму тензора более высокого уровня. Тот же результат можно получить с помощью \code{T.view(-1, 4)} или \code{T.view(6, -1)}, что очень удобно, когда ваш тензор имеет пакетный размер в первом измерении. Наконец, мы передаем этот плоский 2D-тензор нашим полносвязным слоям, чтобы получить Q-значения для каждого пакетного ввода.

\subsubsection{Тренировка}
Третий модуль содержит буфер воспроизведения опыта, агента, расчет функции потерь и сам цикл обучения. Прежде чем перейти к коду, необходимо сказать о гиперпараметрах обучения. Документ DeepMind Nature содержал таблицу со всеми подробностями о гиперпараметрах, используемых для обучения его модели на всех 49 играх Atari, использованных для оценки. DeepMind сохранил все эти параметры одинаковыми для всех игр (но обучал отдельные модели для каждой игры), и целью команды было показать, что метод достаточно надежен для решения множества игр с различной сложностью, пространством действий, структурой вознаграждения и другими деталями с использованием единой архитектуры модели и гиперпараметров. Однако наша цель здесь намного скромнее: мы хотим решить только игру Pong.

Pong довольно прост и понятен по сравнению с другими играми в тестовом наборе Atari, поэтому гиперпараметры в документе являются излишними для нашей задачи. Например, чтобы получить наилучший результат во всех 49 играх, DeepMind использовала буфер воспроизведения с миллионом наблюдений, для хранения которого требуется около 20 ГБ ОЗУ и большое количество выборок из среды для заполнения. Используемый график затухания эпсилон также не является лучшим для одной игры в понг. При обучении DeepMind линейно затухал эпсилон с 1,0 до 0,1 в течение первого миллиона кадров, полученных из окружающей среды. Однако эксперименты показали, что для Pong достаточно затухать эпсилон в течение первых 100 тыс. кадров, а затем сохранять его стабильным. Буфер воспроизведения также может быть намного меньше: 10k переходов будет достаточно.

В следующем примере я\footnote{автор статьи (прим. переводчика)} использовал свои параметры. Они отличаются от параметров в статье, но позволяют нам решать Pong примерно в десять раз быстрее. На GeForce GTX 1080 Ti следующая версия достигает среднего балла 19,5 за один-два часа, но с гиперпараметрами DeepMind на это потребуется как минимум день. Это ускорение, конечно, является тонкой настройкой для одной конкретной среды и может нарушить конвергенцию в других играх. Вы можете свободно играть с опциями и другими играми из набора Atari.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
from lib import wrappers
from lib import dqn_model
import argparse
import time
import numpy as np
import collections
import torch
import torch.nn as nn
import torch.optim as optim
from tensorboardX import SummaryWriter

DEFAULT_ENV_NAME = ‘PongNoFrameskip-v4’
MEAN_REWARD_BOUND = 19.5
\end{lstlisting}

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

\begin{lstlisting}[language=Python,style=PyCodeStyle]
GAMMA = 0.99
BATCH_SIZE = 32
REPLAY_SIZE = 10000
REPLAY_START_SIZE = 10000
LEARNING_RATE = 1e-4
SYNC_TARGET_FRAMES = 1000
\end{lstlisting}

Эти параметры определяют следующее:
\begin{itemize}
\item значение гаммы, используемое для аппроксимации Беллмана;
\item размер пакета, выбираемый из буфера воспроизведения (\code{BATCH_SIZE});
\item максимальная емкость буфера (\code{REPLAY_SIZE});
\item количество кадров, перед началом обучения для заполнения буфера воспроизведения (\code{REPLAY_START_SIZE});
\item скорость обучения, используемая в оптимизаторе Adam, который используется в этом примере;
\item как часто мы синхронизируем веса модели из обучающей модели с целевой моделью, которая используется для получения значения следующего состояния в приближении Беллмана.
\end{itemize}

\begin{lstlisting}[language=Python,style=PyCodeStyle]
EPSILON_DECAY_LAST_FRAME = 10**5
EPSILON_START = 1.0
EPSILON_FINAL = 0.02
\end{lstlisting}

Последняя партия гиперпараметров связана с графиком затухания эпсилон. Чтобы добиться правильного исследования, на ранних этапах обучения мы начинаем с $\varepsilon=1.0$, что приводит к тому, что все действия выбираются случайным образом. Затем, в течение первых 100 000 кадров, эпсилон линейно уменьшается до $0,02$, что соответствует случайному действию, предпринимаемому в $2\%$ шагов. Похожая схема использовалась в оригинальной статье DeepMind, но продолжительность затухания была в $10$ раз больше (так, $\varepsilon = 0,02$ достигается после миллиона кадров).

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

\begin{lstlisting}[language=Python,style=PyCodeStyle]
Experience = collections.namedtuple(
  'Experience', field_names=['state', 'action', 'reward', 'done', 'new_state'])

class ExperienceBuffer:
  def __init__(self, capacity):
    self.buffer = collections.deque(maxlen=capacity)
  def __len__(self):
    return len(self.buffer)

  def append(self, experience):
    self.buffer.append(experience)

  def sample(self, batch_size):
    indices = np.random.choice(len(self.buffer), batch_size, replace=False)
    states, actions, rewards, dones, next_states = zip(
      *[self.buffer[idx] for idx in indices])
    return (np.array(states), np.array(actions),
      np.array(rewards, dtype=np.float32),
      np.array(dones, dtype=np.uint8), np.array(next_states))  
\end{lstlisting}

Большая часть кода буфера воспроизведения опыта довольно проста: он в основном использует возможности класса \code{deque} для поддержания заданного количества записей в буфере. В методе \code{sample()} мы создаем список случайных индексов, а затем перепаковываем выборочные записи в массивы NumPy для более удобного расчета потерь. Следующий класс, который нужен, — это агент, который взаимодействует с окружающей средой и сохраняет результат взаимодействия в буфере воспроизведения опыта, который мы только что видели:

\begin{lstlisting}[language=Python,style=PyCodeStyle]
class Agent:
  def __init__(self, env, exp_buffer):
    self.env = env
    self.exp_buffer = exp_buffer
    self._reset()

  def _reset(self):
    self.state = env.reset()
    self.total_reward = 0.0
\end{lstlisting}

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

\begin{lstlisting}[language=Python,style=PyCodeStyle]
def play_step(self, net, epsilon=0.0, device="cpu"):
  done_reward = None
  if np.random.random() < epsilon:
    action = env.action_space.sample()
  else:
    state_a = np.array([self.state], copy=False)
    state_v = torch.tensor(state_a).to(device)
    q_vals_v = net(state_v)
    _, act_v = torch.max(q_vals_v, dim=1)
    action = int(act_v.item())
\end{lstlisting}

Основной метод агента — выполнить шаг в среде и сохранить его результат в буфере. Для этого нам нужно сначала выбрать действие. С вероятностью эпсилон (передается в качестве аргумента) мы выполняем случайное действие, в противном случае мы используем прошлую модель, чтобы получить Q-значения для всех возможных действий и выбрать лучшее.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
new_state, reward, is_done, _ = self.env.step(action)
self.total_reward += reward
new_state = new_state

exp = Experience(self.state, action, reward, is_done, new_state)
self.exp_buffer.append(exp)
self.state = new_state
if is_done:
  done_reward = self.total_reward
  self._reset()
return done_reward  
\end{lstlisting}

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

Теперь пришло время для последней функции в обучающем модуле, которая вычисляет потери для выбранной партии. Эта функция написана таким образом, чтобы максимально использовать параллелизм графического процессора путем обработки всех выборок пакетов с помощью векторных операций, что затрудняет понимание по сравнению с простым циклом над пакетом. Тем не менее, эта оптимизация окупается: параллельная версия более чем в два раза быстрее, чем явный цикл над пакетом. Напоминаем, вот выражение потерь, которое нам нужно рассчитать:
\[ L=(Q_{s,a} - y)^2 \]
для шагов, которые не находятся в конце эпизода, или
\[ L=(Q_{s,a} - r)^2\]
для заключительных шагов.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
def calc_loss(batch, net, tgt_net, device="cpu"):
  states, actions, rewards, dones, next_states = batch  
\end{lstlisting}

В аргументах мы передаем наш пакет в виде кортежа массивов (перепакованных методом \code{sample()} в буфере опыта), нашей обучаемой сети и целевой сети, которая периодически синхронизируется с обученной. Первая модель (передается как сеть аргумента) используется для расчета градиентов, а вторая модель в аргументе \code{tgt_net} используется для расчета значений для следующих состояний, и этот расчет не должен влиять на градиенты. Для этого мы используем функцию \code{detach()} тензора PyTorch, чтобы предотвратить попадание градиентов в график целевой сети.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
states_v = torch.tensor(states).to(device)
next_states_v = torch.tensor(next_states).to(device)
actions_v = torch.tensor(actions).to(device)
rewards_v = torch.tensor(rewards).to(device)
done_mask = torch.ByteTensor(dones).to(device)  
\end{lstlisting}

Предыдущий код прост: мы оборачиваем отдельные массивы NumPy с пакетными данными в тензоры PyTorch и копируем их на GPU, если в аргументах указано устройство CUDA.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
state_action_values = net(states_v).gather(1, actions_v.unsqueeze(-1)).squeeze(-1)
\end{lstlisting}

В строке выше мы передаем наблюдения в первую модель и извлекаем конкретные Q-значения для предпринятых действий с помощью тензорной операции \code{Gather()}. Первый аргумент вызова \code{Gather()} — это индекс измерения, по которому мы хотим произвести сбор (в нашем случае он равен 1, что соответствует действиям). Второй аргумент — это тензор индексов элементов, которые необходимо выбрать. Дополнительные вызовы \code{unsqueeze()} и \code{squeeze()} необходимы для выполнения требований функций сбора к аргументу индекса и для избавления от дополнительных измерений, которые мы создали (индекс должен иметь то же количество измерений, что и данные, которые мы обрабатываем). На следующем изображении вы можете увидеть иллюстрацию того, что делает сбор в примере с пакетом из шести записей и четырех действий.

\begin{figure}[H]
    \centering
    \includegraphics[width=170mm]{03-mas-lab-02-transform.png}
    \caption{Преобразование тензоров во время вычисления потерь}
    \label{fig:transform}
\end{figure}

Имейте в виду, что результат применения функции \code{collect()} к тензорам является дифференцируемой операцией, которая сохранит все градиенты по отношению к конечному значению потерь.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
next_state_values = tgt_net(next_states_v).max(1)[0]
\end{lstlisting}

В приведенной выше строке мы применяем целевую сеть к наблюдениям за нашим следующим состоянием и вычисляем максимальное значение Q по тому же измерению действия 1. Функция \code{max()} возвращает как максимальные значения, так и индексы этих значений (поэтому она вычисляет как максимальное, так и argmax), что очень удобно. Однако в данном случае нас интересуют только значения, поэтому мы берем первую запись результата.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
next_state_values[done_mask] = 0.0
\end{lstlisting}

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

\begin{lstlisting}[language=Python,style=PyCodeStyle]
next_state_values = next_state_value.detach()
\end{lstlisting}

В этой строке мы отделяем значение от его графа вычислений, чтобы предотвратить попадание градиентов в нейронную сеть, используемую для вычисления аппроксимации Q для следующих состояний. Это важно, так как без этого наше обратное распространение потери начнет влиять как на прогнозы для текущего состояния, так и на следующее состояние. Однако мы не хотим касаться прогнозов для следующего состояния, поскольку они используются в уравнении Беллмана для вычисления эталонных значений Q. Чтобы предотвратить попадание градиентов в эту ветвь графика, мы используем метод \code{detach()} тензора, который возвращает тензор без привязки к его истории вычислений. В предыдущих версиях PyTorch мы использовали \code{volatile} атрибут класса \code{Variable}, который устарел с выпуском 0.4.0.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
expected_state_action_values = next_state_values * GAMMA + rewards_v
return nn.MSELoss()(state_action_values, expected_state_action_values)
\end{lstlisting}

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

\begin{lstlisting}[language=Python,style=PyCodeStyle]
if __name__ == '__main__':
  parser = argparse.ArgumentParser()
  parser.add_argument(
    '--cuda', default=False, action='store_true', help='Enable cuda')
  parser.add_argument(
    '--env', default=DEFAULT_ENV_NAME,
    help=f'Name of the environment, default={DEFAULT_ENV_NAME}')
  parser.add_argument(
    '--reward', type=float, default=MEAN_REWARD_BOUND,
    help=f'Mean reward boundary for stop of training, default={MEAN_REWARD_BOUND:.2f}')
  args = parser.parse_args()
  # device = torch.device('cuda' if args.cuda else 'cpu')
  device = device_available  
\end{lstlisting}

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

\begin{lstlisting}[language=Python,style=PyCodeStyle]
env = wrappers.make_env(args.env)
net = dqn_model.DQN(env.observation_space.shape, env.action_space.n).to(device)
tgt_net = dqn_model.DQN(env.observation_space.shape, env.action_space.n).to(device)
\end{lstlisting}

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

\begin{lstlisting}[language=Python,style=PyCodeStyle]
writer = SummaryWriter(comment=f'-{args.env}')
print(net)

buffer = ExperienceBuffer(REPLAY_SIZE)
agent = Agent(env, buffer)
epsilon = EPSILON_START
\end{lstlisting}

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

\begin{lstlisting}[language=Python,style=PyCodeStyle]
optimizer = optim.Adam(net.parameters(), lr=LEARNING_RATE)
total_rewards = []
frame_idx = 0
ts_frame = 0
ts = time.time()
best_mean_reward = None
\end{lstlisting}

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

\begin{lstlisting}[language=Python,style=PyCodeStyle]
while True:
  frame_idx += 1
  epsilon = max(
    EPSILON_FINAL, EPSILON_START - frame_idx / EPSILON_DECAY_LAST_FRAME))  
\end{lstlisting}

В начале цикла обучения мы подсчитываем количество выполненных итераций и уменьшаем эпсилон в соответствии с нашим графиком. Эпсилон будет снижаться линейно в течение заданного количества кадров (\code{EPSILON_DECAY_LAST_FRAME}=100k), а затем останется на том же уровне \code{EPSILON_FINAL}=0,02.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
reward = agent.play_step(net, epsilon, device=device)
if reward is not None:
  total_rewards.append(reward)
  speed = (frame_idx - ts_frame) / (time.time() - ts)
  ts_frame = frame_idx
  ts = time.time()
  mean_reward = np.mean(total_rewards[-100:])
  print(
    f'{frame_idx}: done {len(total_rewards)} games,'
    f'mean reward {mean_reward:.3f},'
    f'eps {epsilon:.2f}, speed {speed:.2f} f/s')
  writer.add_scalar('EPSILON', epsilon, frame_idx)
  writer.add_scalar('speed', speed, frame_idx)
  writer.add_scalar('reward_100', mean_reward, frame_idx)
  writer.add_scalar('reward', reward, frame_idx)
\end{lstlisting}

В этом блоке кода мы просим нашего агента сделать один шаг в среде (используя нашу текущую сеть и значение эпсилон). Эта функция возвращает результат, отличный от \code{None}, только если этот шаг является последним в эпизоде. В этом случае мы сообщаем о нашем прогрессе. В частности, мы вычисляем и показываем как в консоли, так и в TensorBoard следующие значения:
\begin{itemize}
\item Скорость в виде количества обработанных кадров в секунду
\item Количество сыгранных серий
\item Среднее вознаграждение за последние 100 эпизодов
\item Текущее значение эпсилон
\end{itemize}

\begin{lstlisting}[language=Python,style=PyCodeStyle]
if best_mean_reward is None or best_mean_reward < mean_reward:
  torch.save(net.state_dict(), f'{args.env}-best.dat')
  if best_mean_reward is not None:
    print(
      f'Best mean reward updated {best_mean_reward:.3f}'
      f' -> {mean_reward:.3f}, model saved')
  best_mean_reward = mean_reward
if mean_reward > args.reward:
  print(f'Solved in {frame_idx} frames!')
  break
\end{lstlisting}

Каждый раз, когда наша средняя награда за последние 100 эпизодов достигает максимума, мы сообщаем об этом и сохраняем параметры модели. Если наше среднее вознаграждение превышает указанную границу, мы прекращаем обучение. Для Pong граница составляет 19,5, что означает победу более чем в 19 играх из 21 возможной игры.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
if len(buffer) < REPLAY_START_SIZE:
  continue

if frame_idx % SYNC_TARGET_FRAMES == 0:
  tgt_net.load_state_dict(net.state_dict())  
\end{lstlisting}

Здесь мы проверяем, достаточно ли велик наш буфер для обучения. В начале мы должны дождаться запуска достаточного количества данных, что в нашем случае составляет 10 тысяч переходов. Следующее условие синхронизирует параметры из нашей основной сети в целевую сеть каждые \code{SYNC_TARGET_FRAMES}, по умолчанию, 1000.

\begin{lstlisting}[language=Python,style=PyCodeStyle]
optimizer.zero_grad()
batch = buffer.sample(BATCH_SIZE)
loss_t = calc_loss(batch, net, tgt_net, device=device)
loss_t.backward()
optimizer.step()
\end{lstlisting}

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

\subsubsection*{Запуск и производительность}
Этот пример требователен к ресурсам. В Pong требуется около 400 тысяч кадров, чтобы достичь среднего вознаграждения в 17 (что означает победу более чем в 80\% игр). Аналогичное количество кадров потребуется, чтобы получить от 17 до 19,5, поскольку наш прогресс в обучении насыщается, и модели трудно улучшить оценку. Так что в среднем для его полного обучения требуется миллион кадров. На GTX 1080Ti скорость около 150 кадров в секунду, это около двух часов тренировки. На CPU скорость намного ниже: около девяти кадров в секунду, что займет примерно полтора дня. Помните, что это для Pong, который относительно легко решить. Другие игры требуют сотни миллионов кадров и буфер воспроизведения в 100 раз больше. Тем не менее, для Atari вам понадобятся ресурсы и терпение! На следующем изображении показан скриншот TensorBoard с динамикой обучения:

\begin{figure}[H]
    \centering
    \includegraphics[width=170mm]{03-mas-lab-02-characteristics.png}
    \caption{Характеристики тренировки (по оси Х номер итерации)}
    \label{fig:transform}
\end{figure}

\subsection{Листинги}
\subsubsection{Код wrappers.py}
\lstinputlisting[language=Python,style=PyCodeStyle]{src/03-mas-02-wrap.py}

\subsubsection{Код dqn\_model.py}
\lstinputlisting[language=Python,style=PyCodeStyle]{src/03-mas-02-dqn.py}

\subsubsection{Код main.py (jupyter notebook)}
\lstinputlisting[language=Python,style=PyCodeStyle]{src/03-mas-02-main.py}

\end{document}

