Траектория «Обучение с подкреплением» · конспект 7 из 12

Глубокое Q-обучение: от дискретизации к DQN

О чём эта тема
Q-обучение выходит из таблицы в непрерывный мир — на примере машинки MountainCar. Сначала честная попытка остаться в таблице (дискретизация), затем нейросеть вместо таблицы и два изобретения DQN, сделавших это возможным: память переходов и целевая сеть. По конспекту преподавателя и лекции 5 курса «RL: от бандитов до RLHF» (мехмат МГУ); код — PyTorch, с мостиком для знающих Keras.
Аннотация
Конспект начинается с задачи MountainCar-v0 — машинки с непрерывным состоянием, к которой табличный Q-learning из конспекта 5 напрямую неприменим. Первое решение — дискретизация: разбить каждую компоненту состояния на интервалы; метод работает, но обнажает две проблемы — экспоненциальный рост таблицы и шумность политики без обобщения между соседними состояниями. Отсюда — аппроксимация функции ценности: semi-gradient TD, проекционное уравнение Беллмана и строгий разбор того, почему сочетание аппроксимации, бутстрапа и off-policy обучения (deadly triad) расшатывает сходимость. DQN предъявляется как инженерный ответ: память переходов разрывает корреляции соседних состояний (это показывает тренажёр), целевая сеть останавливает погоню за собственным хвостом. Дальше — полная реализация на PyTorch из конспекта преподавателя, с параллельным словариком PyTorch ↔ Keras, тонкостью про timeout из конспекта 6 и рекомендациями по борьбе с нестабильностью. Завершает конспект возвращение к Space Invaders: та же схема, но со свёрточной сетью по пикселям.
Пререквизиты
Конспект 5 (Q-learning, цель обновления, ε-распад), конспект 6 (устройство сред, завершение против усечения), конспект 4 (бутстрап, on/off-policy). Из траектории «Нейросети»: конспекты 1–3 (нейрон, градиентный спуск, полносвязные слои), конспект 4 (свёрточные сети — для раздела про Atari).
Мотивация
Состояние FrozenLake — номер клетки от 0 до 15; состояние машинки на горке — пара вещественных чисел, и «таблица» по всем таким парам не определена. Это не экзотика, а норма: у тележки CartPole четыре непрерывных компоненты, у экрана Atari — 210×160×3 пикселей, у робота — десятки координат и скоростей. Всё, что мы построили в конспектах 4–5, упирается в таблицу. Замена таблицы нейросетью выглядит очевидным ходом — но наивная замена не сходится, и понимание того, почему (и как это починили в 2013–2015 годах), — центральный сюжет этого конспекта.

1. Машинка на горке: непрерывное состояние

Среда MountainCar-v0: машинка стоит в ложбине между двумя горками, цель — флаг на правой вершине. Двигатель слаб — в лоб на горку не заехать; нужно раскачаться, разгоняясь попеременно в обе стороны. Наблюдение — два вещественных числа: позиция \(x \in [-1{,}2;\, 0{,}6]\) и скорость \(v \in [-0{,}07;\, 0{,}07]\). Действия дискретны: толкать влево, не толкать, толкать вправо. Награда — \(-1\) на каждом шаге (вспомните конспект 1: максимизация такого возврата означает минимизацию времени до цели), лимит эпизода — 200 шагов, то есть возврат \(-200\) означает «не доехала».

Машинка под случайной политикой болтается в ложбине и не достигает флага
Случайная политика: 200 шагов болтания в ложбине, возврат −200
Обученная машинка раскачивается и заезжает на правую горку к флагу
Политика после табличного Q-обучения (раздел 2): раскачка и флаг

Обе анимации сняты живым запуском среды; вторая — политика, которую мы сейчас получим.

2. Попытка №1: дискретизация

Пока компонент состояния мало, непрерывность можно «сломать» дискретизацией: разбить диапазон каждой компоненты на интервалы и работать с номерами интервалов как с дискретным состоянием. В конспекте преподавателя каждая ось разбивается на 101 интервал — получается таблица \(101 \times 101 = 10\,201\) состояний:

bins = (101, 101)                    # число интервалов по каждой оси
low  = np.array([-1.2, -0.07])       # минимальные значения наблюдения
high = np.array([ 0.6,  0.07])       # максимальные значения наблюдения

Q = np.zeros( bins + (len(actions),) )  # таблица (101, 101, 2)

def index(state):                       # непрерывное состояние -> кортеж индексов
    ix = ((state - low)/(high - low) * (np.array(bins) - 1)).astype(int)
    return tuple(ix)

def policy(state):
    if np.random.random() < epsilon:     # случайное действие
        return np.random.randint(len(actions))
    s = index(state)                     # (ix, iv) - кортеж целых чисел
    return np.argmax(Q[s])               # Q[s] - строка значений по действиям

Дальше — обычный Q-learning из конспекта 5 по индексированным состояниям. Две детали из источника: действий достаточно двух (толкать влево и вправо: «не толкать» оптимальной политике не нужно, и метод это потом сам обнаружит), а ε-распад для этой задачи не критичен — обучение идёт и при быстром распаде, и без него:

Кривая обучения табличного Q-learning на MountainCar: средняя награда растёт от -200 к -120 за десятки тысяч эпизодов
Кривая обучения дискретизированного Q-learning (γ = 0,99, λ = 0,1, ε-распад за 10 000 эпизодов). Из конспекта преподавателя

Эпизодов нужны десятки тысяч — но каждый шаг дешёвый, и по времени обучение занимает минуты. Мы воспроизвели этот эксперимент при подготовке конспекта: после 25 000 эпизодов с параметрами источника средний возврат составил около −171, ещё 75 000 жадных эпизодов довели его до −112 — этой политикой снята анимация из раздела 1.

Полный код для проектов: табличное Q-обучение машинки (numpy, один файл)
# qlearn_mountaincar.py — табличное Q-обучение с дискретизацией состояния
# Параметры — из конспекта преподавателя: bins (101,101), два действия [0, 2],
# gamma 0.99, lm 0.1, epsilon 1.0 -> 0.001 за 10000 эпизодов.
import numpy as np
import gymnasium as gym

env = gym.make("MountainCar-v0")
bins = (101, 101)                      # число интервалов по каждой оси
low  = np.array([-1.2, -0.07])         # минимальные значения наблюдения
high = np.array([ 0.6,  0.07])         # максимальные значения наблюдения
actions = [0, 2]                       # толкать влево / толкать вправо

def index(state):
    """Непрерывное состояние -> кортеж индексов ячейки таблицы."""
    ix = ((state - low) / (high - low) * (np.array(bins) - 1)).astype(int)
    return tuple(np.clip(ix, 0, np.array(bins) - 1))

Q = np.zeros(bins + (len(actions),))   # таблица (101, 101, 2)
gamma, lm = 0.99, 0.1
eps1, eps2, decays = 1.0, 0.001, 10000
epsilon, decay = eps1, np.exp(np.log(eps2 / eps1) / decays)

rng = np.random.default_rng(0)
rews = []
for episode in range(1, 25001):
    s, _ = env.reset(seed=int(rng.integers(1e9)))
    si, total = index(s), 0.0
    for t in range(200):
        if rng.random() < epsilon:                  # epsilon-жадная политика
            a = int(rng.integers(len(actions)))
        else:
            a = int(np.argmax(Q[si]))
        s1, r, term, trunc, _ = env.step(actions[a])
        s1i = index(s1)
        # цель Q-обучения; терминальность зануляет бутстрап (конспект 6!)
        target = r + gamma * np.max(Q[s1i]) * (0.0 if term else 1.0)
        Q[si + (a,)] += lm * (target - Q[si + (a,)])
        si, total = s1i, total + r
        if term or trunc:
            break
    rews.append(total)
    epsilon = 0.0 if epsilon < eps2 else epsilon * decay
    if episode % 2500 == 0:
        print(f"{episode:6d}  eps={epsilon:.3f}  средний возврат = {np.mean(rews[-500:]):.1f}")

np.save("mcar_Q.npy", Q)               # таблица пригодится для записи GIF

# --- запись GIF обученной политики (нужен pillow) ---
from PIL import Image
env = gym.make("MountainCar-v0", render_mode="rgb_array")
s, _ = env.reset(seed=3)
frames = [env.render()]
for t in range(200):
    s, r, term, trunc, _ = env.step(actions[int(np.argmax(Q[index(s)]))])
    if t % 2 == 0:
        frames.append(env.render())
    if term or trunc:
        break
imgs = [Image.fromarray(f).resize((300, 200)).quantize(colors=64) for f in frames]
imgs[0].save("mountaincar.gif", save_all=True, append_images=imgs[1:],
             duration=40, loop=0, optimize=True)
print("готово: mountaincar.gif,", t + 1, "шагов")

2.1. Чем дискретизация плоха

Проблема первая — проклятие размерности. Уже у CartPole четыре компоненты: сетка \(101^4 \approx 10^8\) ячеек. У экрана Atari (конспект 6) наблюдение 84×84×4 кадра даже при грубых уровнях яркости даёт порядка \(256^{84 \cdot 84 \cdot 4}\) состояний — таблица бессмысленна уже на уровне памяти. Проблема вторая тоньше: таблица не обобщает. Соседние ячейки для неё — независимые строки; выучив ценность одной, она ничего не узнаёт о соседней. Посмотрите, как выглядит выученная дискретизацией политика рядом с «точной»:

Карта действий табличной политики в координатах позиция-скорость: шумная, с рваными границами и серыми непосещёнными областями
Политика таблицы: шум, рваные границы; серое — состояния, где агент не бывал. Из конспекта преподавателя
Точная политика той же задачи: гладкая изогнутая граница между областями двух действий
«Точная» политика той же задачи: границы гладкие, области действий непрерывны. Из конспекта преподавателя

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

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

3. Аппроксимация функции ценности

Идея: вместо таблицы с \(|S| \cdot |A|\) независимыми числами — параметрическая функция

\[ Q_\theta(s, a) \approx Q^\pi(s, a), \tag{7.1}\]

у которой параметров \(\theta\) существенно меньше, чем состояний, и одни и те же параметры обслуживают сразу много состояний — это и есть обобщение. Простейший вариант — линейная аппроксимация по вектору признаков \(\phi(s) \in \mathbb{R}^d\):

\[ V_w(s) = \langle w, \phi(s)\rangle = w^\top \phi(s). \tag{7.2}\]

Пример из лекции: коридор из 1000 позиций, ценность меняется вдоль него плавно — признаки \(\phi(s) = (1, s, s^2)\) сжимают тысячу параметров до трёх, ценой невозможности представить произвольную функцию. Нейросеть — та же идея с нелинейной параметризацией и признаками, которые сеть строит сама.

Перенесём TD(0) из конспекта 4 на параметрическое семейство. Для перехода \((S_t, R_{t+1}, S_{t+1})\) TD-ошибка выглядит как раньше (конспект 4, формула (4.9)), только ценности берутся из \(V_w\):

\[ \delta_t = R_{t+1} + \gamma V_w(S_{t+1}) - V_w(S_t), \tag{7.3}\]

а обновление сдвигает параметры по градиенту:

\[ w \leftarrow w + \alpha\, \delta_t\, \nabla_w V_w(S_t). \tag{7.4}\]

Обновление (7.4) называется semi-gradient («полуградиентным»): цель в (7.3) тоже зависит от \(w\) — через \(V_w(S_{t+1})\), — но при дифференцировании она замораживается как константа. Это не небрежность, а конструкция всех TD-алгоритмов с аппроксимацией; расплата за неё — потеря табличных гарантий сходимости, о чём ниже.

К чему сходится такой процесс, когда сходится? В табличном случае \(V^\pi\) — единственная неподвижная точка оператора Беллмана \(T^\pi\) (конспект 3, γ-сжатие). Но семейство \(\{V_w\}\) — лишь малое подпространство всех функций, и \(T^\pi V_w\) обычно из него выпадает. Приходится после каждого применения оператора проектировать результат обратно — брать ближайшую функцию семейства по норме \(\|\cdot\|_\mu\), взвешенной распределением посещения состояний \(\mu\). Итог — проекционное уравнение Беллмана:

\[ \hat V = \Pi_\mu T^\pi \hat V, \qquad \hat V \in \mathcal{F}. \tag{7.5}\]

Алгоритм больше не ищет точное решение уравнения Беллмана — только его «лучшую тень» внутри доступного семейства \(\mathcal{F}\). Для линейной аппроксимации и on-policy данных это уравнение имеет решение, и TD к нему сходится; дальше начинаются проблемы.

3.1. Смертельная триада

Термином deadly triad («смертельная триада») называют одновременное присутствие трёх факторов:

Каждый фактор по отдельности безобиден; вместе они способны разрушить сходимость — классический контрпример Байрда демонстрирует расходимость off-policy TD уже при линейной аппроксимации: норма весов растёт без остановки. Интуитивно порочный круг выглядит так: параметры формируют цель → цель тянет параметры → новые параметры сдвигают цель — и если данные приходят из «неподходящих» областей пространства состояний, ошибка в одной части функции через общие параметры усиливает ошибку в другой, цикл самоподдерживается. Табличная интуиция «TD надёжен» на параметрический случай не переносится.

4. DQN: два механизма стабилизации

Наивный перенос Q-learning на нейросеть даёт цель

\[ y_t = R_{t+1} + \gamma \max_{a'} Q_\theta(S_{t+1}, a'), \tag{7.6}\]

в которой и оцениваемая функция, и цель зависят от одних и тех же параметров \(\theta\) — та самая погоня за собственным хвостом, плюс обучение по сильно коррелированным последовательным состояниям. Обучение получается чрезвычайно нестабильным. Метод Deep Q-Network (DQN, DeepMind: Atari по пикселям в 2013-м, публикация в Nature в 2015-м) добавил два механизма, разорвавших оба порочных круга:

  1. Память переходов (англ. replay buffer) \(\mathcal{D}\): четвёрки \((s, a, r', s')\) складываются в большой кольцевой буфер, а обучение идёт по случайным батчам из него. Соседние по времени состояния почти одинаковы — случайная выборка перемешивает опыт и приближает данные к независимой выборке (именно эта корреляция мешала глубокому обучению до DeepMind). Фактически буфер — неустаревающая выборочная модель среды; конечность буфера при этом важна: он должен «забывать» области вдали от оптимальной стратегии.
  2. Целевая сеть (англ. target network) \(Q_{\bar\theta}\): копия основной сети с замороженными параметрами \(\bar\theta\), которые лишь изредка (раз в сотни шагов) обновляются из основной. Цель строится по целевой сети — на коротком промежутке она почти фиксирована, и хвост перестаёт убегать.

Минимизируется средний квадрат отклонения от уравнения Беллмана с жадным выбором действия — по батчу из \(B\) четвёрок:

\[ L(\theta) = \frac{1}{B}\sum_{i=1}^{B} \Bigl[ r'_i + \gamma \max_{a'} Q_{\bar\theta}(s'_i, a') - Q_\theta(s_i, a_i) \Bigr]^2. \tag{7.7}\]

Основная сеть оценивает ценность текущей пары (s, a), целевая — ценность следующего состояния. Схема целиком:

Оба механизма — инженерные: они не отменяют смертельную триаду теоретически (обучение остаётся off-policy бутстрапом с аппроксимацией), но резко улучшают практическую устойчивость.

5. Тренажёр: зачем нужна память переходов

Посмотрим на главный аргумент DQN своими глазами. Ниже — фазовое пространство машинки (горизонталь — позиция x, вертикаль — скорость v; динамика среды воспроизведена точно). Кнопка «сыграть эпизод» добавляет в память переходы одного эпизода раскачивающей политики с ε-шумом. Дальше сравните два способа собрать обучающий батч из 100 переходов: последние подряд — так учился бы алгоритм без памяти, по свежему хвосту опыта — и случайные из памяти, как в DQN. Метрика покрытия — доля клеток фазового пространства 10×10, задетых батчем.

Тренажёр: память переходов против свежего хвоста
серое — вся память · цветное — выбранный батч · пунктир — цель x ≥ 0,5

Типичная картина: «последние подряд» лежат ниткой вдоль куска одной-единственной траектории — весь батч приходит из одного эпизода, состояния идут почти дублями (после стартовых двух эпизодов: покрытие 31 клетка, опыт из 1 эпизода); случайный батч рассыпан по всей исследованной области и смешивает сыгранные эпизоды (41 клетка, оба эпизода). Разрыв растёт с числом эпизодов: сыграйте ещё несколько — хвост так и останется одной свежей ниткой, а случайный батч будет покрывать накопленный опыт целиком. Градиентный шаг по коррелированной нитке тянет всю аппроксимацию в одну сторону (вспомните триаду: общие параметры!), шаг по рассыпанному батчу усредняет опыт — это и есть эффект памяти переходов.

6. PyTorch для знающих Keras

Код глубокого RL в этой траектории — на PyTorch: так написаны материалы курса и большинство примеров экосистемы Gymnasium. Для читателя траектории «Нейросети» (там — Keras) вот словарик соответствий; он понадобится и в конспектах 8–11:

Keras («Нейросети», к. 3)PyTorchКомментарий
Sequential([...])nn.Sequential(...) то же: стопка слоёв
Dense(n, activation)nn.Linear(in, out) + nn.ReLU()активация — отдельный слой; размер входа указывается явно
model.compile(loss, optimizer) nn.MSELoss(), torch.optim.Adam(model.parameters()) ошибка и оптимизатор — самостоятельные объекты
model.fit(X, y) цикл: loss.backward(); optimizer.step() обучающего цикла «в коробке» нет — мы пишем его сами (для RL это плюс: цикл и так нестандартный)
model.predict(X) with torch.no_grad(): model(x) без no_grad PyTorch строит граф для градиентов
model.get_weights() / set_weights() state_dict() / load_state_dict() ровно так DQN копирует основную сеть в целевую

Главное культурное отличие — PyTorch не прячет обучающий цикл. В DQN это удобно: обучение происходит после каждого шага среды, батч приходит не из датасета, а из памяти переходов, цель пересчитывается на лету — в Keras такой цикл тоже пишется, но против шерсти фреймворка.

7. Реализация: DQN построчно

Разберём реализацию DQN целиком — она компактна, и каждая строка отвечает на вопрос «зачем» из разделов выше. Сначала память переходов: пять предвыделенных тензоров-колец и случайная выборка индексов:

class Memory:
    def __init__(self, capacity, nS):
        self.capacity = capacity  # вместимость памяти
        self.count    = 0         # число помещённых примеров
        self.S0 = torch.empty( (capacity, nS), dtype=torch.float32)
        self.S1 = torch.empty( (capacity, nS), dtype=torch.float32)
        self.A0 = torch.empty( (capacity, 1),  dtype=torch.int64)
        self.R1 = torch.empty( (capacity, 1),  dtype=torch.float32)
        self.Dn = torch.empty( (capacity, 1),  dtype=torch.float32)

    def add(self, s0, a0, s1, r1, done):
        idx = self.count % self.capacity          # кольцо: старое затирается
        self.S0[idx] = torch.tensor(s0, dtype=torch.float32)
        self.S1[idx] = torch.tensor(s1, dtype=torch.float32)
        self.A0[idx] = a0;  self.R1[idx] = r1;  self.Dn[idx] = done
        self.count += 1

    def get(self, count):
        high = min(self.count, self.capacity)
        num  = min(count, high)
        ids  = torch.randint(high=high, size=(num,))  # случайный батч
        return self.S0[ids], self.A0[ids], self.S1[ids], self.R1[ids], self.Dn[ids]

Метод собран в класс DQN; гиперпараметры — словарём (их у DQN много, и это само по себе предупреждение):

class DQN:
    def __init__(self, env):
        self.env  = env
        self.low  = env.observation_space.low    # границы наблюдения
        self.high = env.observation_space.high
        self.nA   = env.action_space.n           # число действий
        self.nS   = env.observation_space.shape[0]
        self.params = {
            'ticks'    : 200,       # длительность эпизода
            'timeout'  : True,      # истечение времени терминально?
            'gamma'    : 0.99,      # фактор дисконтирования
            'eps1'     : 1.0,       # начальный epsilon
            'eps2'     : 0.001,     # конечный epsilon
            'decays'   : 500,       # эпизодов на распад eps1 -> eps2
            'update'   : 100,       # частота обновления целевой сети (в шагах)
            'batch'    : 100,       # размер батча
            'capacity' : 100000,    # ёмкость памяти
            'hiddens'  : [256,128], # нейронов в скрытых слоях
            'scale'    : True,      # масштабировать состояние к [-1, 1]?
            'lm'       : 0.001,     # скорость обучения
        }

Сеть строится по списку слоёв — сравните с Keras-версией из «Нейросетей», конспект 3; в init создаются две одинаковые сети:

    def create_model(self, sizes, hidden=nn.ReLU, output=nn.Identity):
        layers = []
        for i in range(len(sizes) - 1):
            activation = hidden if i < len(sizes) - 2 else output
            layers += [ nn.Linear(sizes[i], sizes[i+1]), activation() ]
        return nn.Sequential(*layers)

    def init(self):
        self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
        sizes = [self.nS] + self.params['hiddens'] + [self.nA]  # [2, 256, 128, 3]
        self.model  = self.create_model(sizes).to(self.device)  # основная  Q
        self.target = self.create_model(sizes).to(self.device)  # целевая   Q
        self.loss      = nn.MSELoss()
        self.optimizer = torch.optim.Adam(self.model.parameters(), lr=self.params['lm'])
        self.memo = Memory(self.params['capacity'], self.nS)
        self.epsilon    = self.params['eps1']
        self.decay_rate = np.exp(np.log(self.params['eps2']/self.params['eps1'])
                                 / self.params['decays'])

Вход сети — состояние (у машинки два числа), выходов столько, сколько действий: одна прогонка сети даёт Q сразу для всех действий, и ε-жадная политика из конспекта 5 почти не меняется. Состояние приводится к \([-1, 1]\) — сетям так удобнее (вспомните нормировку входов из «Нейросетей»):

    def policy(self, state):
        if np.random.random() < self.epsilon:
            return np.random.randint(self.nA)     # случайное действие
        x = torch.tensor(state, dtype=torch.float32).to(self.device)
        with torch.no_grad():
            y = self.model(x).detach().cpu().numpy()
        return np.argmax(y)                       # лучшее действие

    def scale(self, obs):
        if self.params['scale']:
            return -1. + 2.*(obs - self.low)/(self.high - self.low)
        return obs

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

    def run_episode(self, ticks):
        rew = 0
        s0 = self.scale(self.env.reset()[0])
        a0 = self.policy(s0)
        for t in range(1, ticks+1):
            s1, r1, done, _, _ = self.env.step(a0)
            s1 = self.scale(s1)
            a1 = self.policy(s1)
            dn = done and (self.params['timeout'] or t < ticks)  # см. ниже!
            self.memo.add(s0, a0, s1, r1, float(dn))
            if self.frame % self.params['update'] == 0:
                self.target.load_state_dict( self.model.state_dict() )
            if self.memo.count >= self.params['batch']:
                self.learn_model()
            rew += r1;  self.frame += 1
            if done: break
            s0, a0 = s1, a1
        return rew

Строка с dn — знакомая по конспекту 6 тонкость завершения и усечения, решённая параметром: признак dn обнуляет бутстрап в цели, и вопрос в том, считать ли конец эпизода по времени терминальным. Строго говоря — нельзя: состояние на 200-м шаге не терминально (машинка с хорошей скоростью недалеко от цели продолжила бы зарабатывать). Но источник отмечает, что польза параметра определяется задачей, и MountainCar — поучительное исключение. Награда здесь всюду −1, единственный источник различий между состояниями — конец эпизода; при timeout=False ε-жадный агент с плоской начальной Q до флага не доезжает, терминалов в данных нет, и все ценности стягиваются к константе \(-1/(1-\gamma) = -100\) — обучение вообще не происходит. С timeout=True усечение становится «псевдотерминалом», даёт сигнал конечности времени — и метод учится (ценой формального нарушения марковости из конспекта 6: время в наблюдение не входит; это рабочий компромисс). В CartPole сигнала хватает и так — упасть можно в любой момент, — и на кривой источника стоит timeout=False; включить его полезно у потолка награды, где агент стабильно доживает до лимита. Одна булева константа решает, учится агент или нет.

Само обучение — формула (7.7) построчно; обратите внимание на gather, выбирающий из матрицы Q значения совершённых действий:

    def learn_model(self):
        s0, a0, s1, r1, dn = self.memo.get(self.params['batch'])
        with torch.no_grad():
            y = self.target(s1).detach()          # целевая сеть: Q(s1, ·)
        maxQ = torch.max(y, 1)[0].view(-1,1)      # max по действиям
        yb = r1 + self.params['gamma']*maxQ*(1.-dn)  # цель (7.7); dn=1 - нет будущего
        y = self.model(s0)                        # основная сеть: Q(s0, ·)
        y = y.gather(1, a0)                       # Q совершённых действий
        L = self.loss(y, yb)
        self.optimizer.zero_grad()                # обнулить градиенты
        L.backward()                              # обратное распространение
        self.optimizer.step()                     # шаг оптимизатора

Результаты источника: MountainCar (сеть [256, 128], SGD, 5000 эпизодов) выходит на средний возврат около −110; CartPole решается за сотни эпизодов, но менее устойчиво:

Слева гистограммы содержимого памяти по длине состояния и по действиям, справа кривая обучения DQN на MountainCar до среднего возврата около -110
DQN на MountainCar: слева — гистограммы памяти (длины векторов состояния и действия), справа — кривая обучения. Из конспекта преподавателя
Кривая обучения DQN на CartPole: быстрый выход на максимум с провалами нестабильности
DQN на CartPole-v0 (сеть [64, 32], Adam): быстрый, но менее устойчивый рост. Из конспекта преподавателя

Гистограммы памяти — диагностика в духе раздела 4: в начале обучения память забита состояниями с малыми скоростями (машинка в ложбине), к концу распределение размывается — агент увидел всё фазовое пространство; распределение действий показывает, что «ничего не делать» метод из памяти практически вытеснил.

7.1. Борьба с нестабильностью

Рекомендации источника — чеклист на случай «кривая обучения дёргается или падает»:

Трёхмерная поверхность V-функции MountainCar: спиральный обрыв вокруг ложбины
Поверхность V-функции машинки: спиральный обрыв — раскачка. Именно такую форму сеть должна суметь представить. Из конспекта преподавателя
Типичная ошибка Обучающий цикл на PyTorch без optimizer.zero_grad() или цель без torch.no_grad(). В первом случае градиенты батчей суммируются, во втором — градиент течёт и через целевую сеть, и «замороженная» цель перестаёт быть замороженной. Обе ошибки не падают с исключением: обучение просто тихо не работает — в Keras эти детали прятал fit, здесь они наши.
Полный код для проектов: DQN одним файлом (PyTorch + Gymnasium)
# dqn_full.py — полный DQN по конспекту преподавателя, API Gymnasium
import numpy as np
import torch
import torch.nn as nn
import gymnasium as gym

class Memory:
    """Кольцевая память переходов (s0, a0, s1, r1, dn)."""
    def __init__(self, capacity, nS):
        self.capacity = capacity
        self.count = 0
        self.S0 = torch.empty((capacity, nS), dtype=torch.float32)
        self.S1 = torch.empty((capacity, nS), dtype=torch.float32)
        self.A0 = torch.empty((capacity, 1), dtype=torch.int64)
        self.R1 = torch.empty((capacity, 1), dtype=torch.float32)
        self.Dn = torch.empty((capacity, 1), dtype=torch.float32)

    def add(self, s0, a0, s1, r1, done):
        idx = self.count % self.capacity
        self.S0[idx] = torch.tensor(s0, dtype=torch.float32)
        self.S1[idx] = torch.tensor(s1, dtype=torch.float32)
        self.A0[idx] = a0; self.R1[idx] = r1; self.Dn[idx] = done
        self.count += 1

    def get(self, count):
        high = min(self.count, self.capacity)
        num = min(count, high)
        ids = torch.randint(high=high, size=(num,))
        return self.S0[ids], self.A0[ids], self.S1[ids], self.R1[ids], self.Dn[ids]

class DQN:
    """DQN для дискретных действий."""
    def __init__(self, env):
        self.env = env
        self.low = env.observation_space.low
        self.high = env.observation_space.high
        self.nA = env.action_space.n
        self.nS = env.observation_space.shape[0]
        self.params = {
            'ticks': 200, 'timeout': True, 'gamma': 0.99,
            'eps1': 1.0, 'eps2': 0.001, 'decays': 500,
            'update': 100, 'batch': 100, 'capacity': 100000,
            'hiddens': [256, 128], 'scale': True, 'lm': 0.001,
        }

    def create_model(self, sizes, hidden=nn.ReLU, output=nn.Identity):
        layers = []
        for i in range(len(sizes) - 1):
            activation = hidden if i < len(sizes) - 2 else output
            layers += [nn.Linear(sizes[i], sizes[i + 1]), activation()]
        return nn.Sequential(*layers)

    def init(self):
        self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
        sizes = [self.nS] + self.params['hiddens'] + [self.nA]
        self.model = self.create_model(sizes).to(self.device)
        self.target = self.create_model(sizes).to(self.device)
        self.target.load_state_dict(self.model.state_dict())
        self.loss = nn.MSELoss()
        self.optimizer = torch.optim.Adam(self.model.parameters(), lr=self.params['lm'])
        self.memo = Memory(self.params['capacity'], self.nS)
        self.epsilon = self.params['eps1']
        self.decay_rate = np.exp(np.log(self.params['eps2'] / self.params['eps1'])
                                 / self.params['decays'])

    def policy(self, state):
        if np.random.random() < self.epsilon:
            return np.random.randint(self.nA)
        x = torch.tensor(state, dtype=torch.float32).to(self.device)
        with torch.no_grad():
            y = self.model(x).detach().cpu().numpy()
        return int(np.argmax(y))

    def scale(self, obs):
        if self.params['scale']:
            return -1. + 2. * (obs - self.low) / (self.high - self.low)
        return obs

    def run_episode(self, ticks):
        rew = 0
        s0 = self.scale(self.env.reset()[0])
        a0 = self.policy(s0)
        for t in range(1, ticks + 1):
            s1, r1, term, trunc, _ = self.env.step(a0)
            s1 = self.scale(s1)
            a1 = self.policy(s1)
            done = term or trunc or t == ticks
            # терминальность для цели: усечение по времени - по выбору timeout
            dn = term or (done and not term and self.params['timeout'])
            self.memo.add(s0, a0, s1, r1, float(dn))
            if self.frame % self.params['update'] == 0:
                self.target.load_state_dict(self.model.state_dict())
            if self.memo.count >= self.params['batch']:
                self.learn_model()
            rew += r1; self.frame += 1
            if done: break
            s0, a0 = s1, a1
        return rew

    def learn_model(self):
        s0, a0, s1, r1, dn = self.memo.get(self.params['batch'])
        s0 = s0.to(self.device); s1 = s1.to(self.device); a0 = a0.to(self.device)
        r1 = r1.to(self.device); dn = dn.to(self.device)
        with torch.no_grad():
            y = self.target(s1).detach()
        maxQ = torch.max(y, 1)[0].view(-1, 1)
        yb = r1 + self.params['gamma'] * maxQ * (1. - dn)
        y = self.model(s0)
        y = y.gather(1, a0)
        L = self.loss(y, yb)
        self.optimizer.zero_grad()
        L.backward()
        self.optimizer.step()

    def learn(self, episodes=100000, stat=100):
        self.frame = 1
        rews = []
        for episode in range(1, episodes + 1):
            rew = self.run_episode(self.params['ticks'])
            rews.append(rew)
            self.epsilon *= self.decay_rate
            if self.epsilon < self.params['eps2']:
                self.epsilon = 0.
            if episode % stat == 0:
                mean, std = np.mean(rews[-stat:]), np.std(rews[-stat:])
                print(f"{episode:6d} rew: {mean:7.2f} ± {std/stat**0.5:4.2f}  eps: {self.epsilon:.3f}")
        return rews

if __name__ == "__main__":
    env = gym.make("CartPole-v1")
    dqn = DQN(env)
    dqn.params.update({'timeout': False, 'hiddens': [64, 32],
                       'capacity': 1000, 'lm': 0.0001, 'scale': False})
    dqn.init()
    dqn.learn(episodes=500)

Оба файла самодостаточны: код выше собран из фрагментов конспекта преподавателя и адаптирован к пятёрке значений step() современного Gymnasium (обработка завершения и усечения — по правилам конспекта 6). Скрипты проверены запуском при подготовке конспекта: табличный доучивается до возврата −112 и пишет GIF, DQN на CartPole за 500 эпизодов выходит с ~20 на ~92 и продолжает расти. Для MountainCar в DQN верните параметры по умолчанию (hiddens [256, 128], capacity 100000, scale True, timeout True — почему это критично, объяснено в разделе 7) и запаситесь терпением: источнику потребовалось 5000 эпизодов.

8. Та же схема на пикселях: Atari

Всё в этом конспекте переносится на игры Atari заменой первого слоя восприятия: вместо двух чисел машинки — четыре последних кадра экрана 84×84, вместо полносвязной сети — свёрточная (траектория «Нейросети», конспект 4), выход — по-прежнему Q для каждого действия джойстика. Сеть из презентации курса:

class DQN(nn.Module):
    def __init__(self, in_dim, out_dim):
        super().__init__()
        cannel_n, height, width = in_dim          # (4, 84, 84): 4 кадра-канала
        self.net = nn.Sequential(
            nn.Conv2d(in_channels=cannel_n, out_channels=16,
                      kernel_size=8, stride=4),
            nn.ReLU(),
            nn.Conv2d(in_channels=16, out_channels=32,
                      kernel_size=4, stride=2),
            nn.ReLU(),
            nn.Flatten(),
            nn.Linear(2592, 256),
            nn.ReLU(),
            nn.Linear(256, out_dim),               # Q для каждого действия
        )

    def forward(self, input):
        return self.net(input)

Несколько кадров на входе — знакомый по конспекту 1 приём против неполного описания: по одному кадру не определить скорости объектов. Сеть должна не запоминать пиксельные кадры, а извлекать устойчивые признаки — положение объектов, направления движения; именно здесь нейросетевое обобщение из раздела 3 становится незаменимым. Память переходов, целевая сеть, цель (7.7) — без изменений; так DQN играл в Space Invaders из конспекта 6 и ещё в полсотни игр по одному и тому же рецепту. Обучение на пикселях занимает часы на GPU — практический разбор (запуск, replay на изображениях, приёмы ускорения) вынесен в ноутбук курса space_invaders.ipynb.

Контрольные вопросы

Источники

  1. Mnih, V. Playing Atari with Deep Reinforcement Learning / V. Mnih, K. Kavukcuoglu, D. Silver [et al.] // arXiv. — 2013. — URL: https://arxiv.org/abs/1312.5602 (дата обращения: 08.07.2026).
  2. Mnih, V. Human-level control through deep reinforcement learning / V. Mnih, K. Kavukcuoglu, D. Silver [et al.] // Nature. — 2015. — Vol. 518. — P. 529–533. — DOI: 10.1038/nature14236.