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

Улучшения DQN: Double и Dueling

О чём эта тема
Две точечные модификации DQN, ставшие стандартом: Double DQN разделяет выбор действия и оценку его ценности (лечит переоценку максимума из конспекта 5), Dueling DQN расщепляет сеть на ценность состояния и преимущества действий. Практика — гоночная машинка CarRacing по пикселям, с конвейером обёрток и современным replay buffer. По конспекту преподавателя и ноутбуку курса.
Аннотация
Конспект начинается со старого долга: в конспекте 5 было доказано, что максимум по шумным оценкам систематически завышен, а бутстрап DQN разносит это завышение по всей Q-функции. Тренажёр даёт прочувствовать масштаб смещения и показывает решение — двойную оценку, у которой выбор действия и его оценивание разнесены по независимым оценщикам. Double DQN встраивает эту идею в DQN почти бесплатно: роль второго оценщика играет уже имеющаяся целевая сеть, меняются две строки кода. Затем — Dueling DQN: тождество Q = V + A, неоднозначность разложения и её устранение вычитанием среднего преимущества, архитектура с двумя потоками после общего экстрактора признаков. Вторая половина конспекта — практика на CarRacing: пиксельное наблюдение, конвейер из четырёх обёрток (включая самодельную SkipFrame — прямое применение конспекта 6), сеть Dueling с Double-целью из ноутбука курса, готовый replay buffer из torchrl и функция потерь Хьюбера. Полный рабочий код — в раскрывающемся блоке.
Пререквизиты
Конспект 7 (DQN целиком: память переходов, целевая сеть, цель (7.7)), конспект 5 (переоценка максимума, формула (5.7)), конспект 6 (обёртки), траектория «Нейросети», конспект 4 (свёрточные сети).
Мотивация
DQN из конспекта 7 работает, но его Q-значения систематически оптимистичны — и этот оптимизм не безобиден: завышенные цели искажают политику и дестабилизируют обучение. Кроме того, DQN тратит опыт неэкономно: чтобы понять, что состояние «плохое», ему нужно попробовать в нём каждое действие. Обе беды лечатся малой кровью — суммарно десятком строк кода, — и обе модификации вошли в стандартный арсенал: почти любой современный «DQN» на деле Double Dueling DQN.

1. Долг конспекта 5: переоценка максимума

Начнём с наблюдения, которое рано или поздно делает каждый обучивший свой первый DQN: Q-значения сети почти всегда обещают больше награды, чем агент реально собирает за эпизод. Это не ошибка в коде — это встроенный оптимизм самого метода, и у него точная математическая причина.

Вспомним неравенство (5.7): для любых шумных оценок \(\langle \max_a \hat Q(s',a) \rangle \ge \max_a \langle \hat Q(s',a) \rangle\) — даже если каждая оценка несмещённа, максимум по ним смещён вверх. В табличном Q-обучении этот эффект ещё терпим, но в DQN он усиливается: во-первых, цель (7.7) содержит \(\max_{a'} Q_{\bar\theta}(s', a')\), и завышение через бутстрап накапливается по цепочке; во-вторых, ошибка аппроксимации нейросети — это дополнительный шум, коррелированный между соседними состояниями (общие параметры!), так что «удачно» завышенное действие завышено сразу в целой области.

Причина смещения — одна и та же оценка используется дважды: сначала выбирает лучшее действие (\(\arg\max\)), затем оценивает выбранное (значение максимума). Шум, поднявший оценку действия, одновременно и делает его «победителем», и попадает в цель. Эффект знаком и вне RL — это регрессия к среднему: чемпион шумного отбора в среднем выступает хуже своего отборочного результата, потому что среди равных побеждает тот, кому повезло с шумом. Разорвать связь просто: пусть выбирает один оценщик, а оценивает — другой, с независимым шумом. Тогда шум выбора не влияет на оценку: выбранное действие для второго оценщика — обычное, и его оценка несмещённа. Это двойная оценка (англ. double estimator) — идея Double Q-learning (ван Хасселт, 2010), упомянутого в конце конспекта 5.

2. Тренажёр: смещение максимума и двойная оценка

Модельная ситуация: в состоянии \(s'\) есть \(K\) действий, и все они на самом деле одинаково бесполезны — истинные значения \(Q(s',a) = 0\) для всех \(a\). Идеальная цель обновления равна нулю. Но алгоритм видит не истинные значения, а шумные оценки \(\hat Q(s',a) = Q(s',a) + \varepsilon_a\), где \(\varepsilon_a \sim \mathcal{N}(0, \sigma^2)\) — например, из-за малого числа посещений или ошибки сети. Сравните две стратегии: обычную — \(\max_a \hat Q_A(s',a)\) — и двойную — \(\hat Q_B(s', \arg\max_a \hat Q_A(s',a))\), где \(\hat Q_A, \hat Q_B\) — две независимые оценки.

Тренажёр: чем больше действий и шума — тем сильнее оптимизм
гистограммы 1000 оценок цели · истинное значение — 0 (пунктир) · обычный max · двойная оценка

Что видно. Обычный максимум смещён вправо от нуля целиком: при K = 4 и σ = 1 его среднее по стартовой тысяче — 1,079 (теоретическое смещение для четырёх действий — 1,03σ), то есть цель завышена на целую сигму шума; рост K или σ только усугубляет — при K = 10 смещение около 1,53σ. Двойная оценка сидит на нуле при любых K и σ (0,017 на той же тысяче): она шире разбросана, но несмещённа — в среднем по обучению ошибки взаимно гасятся, а не накапливаются. Ровно это неравенство (5.7) и его лекарство, увиденные глазами.

Осталось приложить идею к DQN. Двойной оценке нужен второй, независимый оценщик — неужели заводить и обучать ещё одну сеть?

3. Double DQN: две строки кода

Не нужно: вторая сеть в DQN уже есть — целевая. Она учится той же Q-функции, но отстаёт от основной на сотни шагов, и потому её ошибки — другие. Авторы Double DQN (ван Хасселт и др., 2015) распределили роли: основная сеть выбирает лучшее следующее действие, целевая — оценивает выбранное. Ошибка минимизируется та же, меняется только цель:

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

Сравните с (7.7): там \(\max_{a'} Q_{\bar\theta}\) — целевая сеть и выбирает, и оценивает; здесь выбор перешёл к основной. Шумы двух сетей не совпадают, поэтому связка «выбрал — оценил» разорвана — ровно как в тренажёре. В коде learn_model из конспекта 7 меняется только вычисление максимума:

# было (DQN, конспект 7): целевая сеть выбирает и оценивает
with torch.no_grad():
    y = self.target(s1).detach()
maxQ = torch.max(y, 1)[0].view(-1,1)

# стало (Double DQN): основная выбирает, целевая оценивает
y = self.model(s1)
a = torch.argmax(y, 1).view(-1,1)      # a = arg max Q(s1, a; theta)
with torch.no_grad():
    q = self.target(s1)
maxQ = q.gather(1, a)                  # Q(s1, a; theta с чертой)
Типичная ошибка Ставят torch.no_grad() и на выбор действия основной сетью. На корректность цели это не влияет (argmax недифференцируем), но легко перепутать и наоборот — забыть no_grad у целевой оценки: тогда градиент потечёт через \(Q_{\bar\theta}\), и «замороженная» сеть перестанет быть замороженной. Правило то же, что в конспекте 7: всё, что стоит в цели, — под no_grad.

4. Dueling DQN: ценность состояния отдельно

Вторая модификация отвечает на другой вопрос: на что сеть тратит собранный опыт. Посмотрите на гонку глазами водителя. На прямом участке почти безразлично, чуть довернуть влево или вправо, — исход определяет сама трасса; в повороте у края обрыва, наоборот, выбор действия — это всё. Выходит, \(Q(s,a)\) смешивает две разные вещи: «насколько хороша ситуация» и «насколько важно, что я сейчас сделаю». Обычная сеть DQN выучивает эту смесь для каждого действия отдельно — «хорошесть» состояния она узнаёт по каждому действию заново, как если бы водитель заново оценивал прямую для каждого положения руля. Разложим полезность явно:

\[ Q(s, a) = V(s) + A(s, a), \tag{8.2}\]

где \(V(s)\) — ценность состояния, а \(A(s,a)\) — преимущество (англ. advantage) действия: насколько действие \(a\) лучше «среднего по политике». По определению \(V(s) = \langle Q(s, \pi(s))\rangle\), значит \(\langle A(s, \pi(s))\rangle = 0\); для жадной оптимальной политики \(a^* = \arg\max_{a'} Q(s,a')\) выполняется \(Q(s, a^*) = V(s)\) и \(A(s, a^*) = 0\).

«Дуэльная» сеть вычисляет оба слагаемых отдельными потоками с общим началом:

\[ Q(s, a;\, \theta, \alpha, \beta) = V(s;\, \theta, \beta) + A(s, a;\, \theta, \alpha), \tag{8.3}\]

где \(\theta\) — общие параметры (у пиксельных задач — свёрточный экстрактор признаков), а \(\beta\) и \(\alpha\) — параметры двух голов: скалярной \(V\) и векторной \(A\) по действиям:

Схема Dueling-сети: свёрточные слои, затем два потока — скалярная ценность состояния и вектор преимуществ, сливающиеся в Q-значения
Обычная сеть DQN (сверху) и дуэльная (снизу): после общих свёрточных слоёв — два потока, V и A. Из статьи Wang et al., 2016

Есть тонкость: разложение (8.2) неоднозначно — к \(A\) можно прибавить константу и вычесть её из \(V\), сумма не изменится, и сеть может «гулять» между эквивалентными решениями. Для устранения неоднозначности (и повышения стабильности) из потока преимуществ вычитают его максимум:

\[ Q(s, a) = V(s) + \Bigl[ A(s, a) - \max_{a'} A(s, a') \Bigr], \tag{8.4}\]

тогда для лучшего действия скобка равна нулю и \(Q(s, a^*) = V(s)\) — семантика потоков зафиксирована. На практике, как выяснили авторы, лучше работает вычитание среднего по действиям:

\[ Q(s, a) = V(s) + \Bigl[ A(s, a) - \frac{1}{N_a}\sum_{a'} A(s, a') \Bigr]. \tag{8.5}\]

Жадная политика от этого не меняется (сдвиг общий для всех действий), а обучение стабильнее. Выигрыш дуэльной архитектуры: градиент от каждого перехода обновляет общий поток \(V\) — сеть узнаёт «хорошесть» состояния, не перебирая все действия, и это особенно заметно при большом числе действий и в состояниях, где действия почти равнозначны (у таких \(A \approx 0\), вся работа у \(V\)).

5. Практика: гоночная машинка по пикселям

Пора собрать обе модификации на задаче, ради которой они придумывались, — с большим пространством наблюдений и живой картинкой. Ноутбук курса объединяет всё выученное с конспекта 6 в одну задачу: CarRacing-v2 — управление гоночной машинкой на случайно сгенерированной трассе. Наблюдение — пиксели Box(0, 255, (96, 96, 3)), вид сверху; в дискретном режиме (continuous=False) пять действий: ничего, руль влево, руль вправо, газ, тормоз. Награда: −0,1 за кадр плюс премия за каждую новую посещённую плитку трассы — быстрая чистая езда выгодна:

Случайная политика в CarRacing: машинка крутится на старте и съезжает с трассы
Случайная политика (400 шагов, возврат −21,8): машинка топчется у старта. Снято живым запуском среды
Сырой кадр CarRacing 96 на 96: красная машинка на серой трассе среди зелёного поля
Сырое наблюдение 96×96×3: машинка, трасса, приборная панель снизу

5.1. Конвейер обёрток

Сырые кадры сети не подают — их готовит цепочка обёрток, три готовых и одна самодельная (всё — материал конспекта 6):

env = gym.make("CarRacing-v2", continuous=False)
env = SkipFrame(env, skip=4)                     # своя обёртка: повтор действия 4 кадра
env = gym_wrap.GrayScaleObservation(env)         # RGB -> оттенки серого
env = gym_wrap.ResizeObservation(env, shape=84)  # 96x96 -> 84x84
env = gym_wrap.FrameStack(env, num_stack=4)      # стопка 4 последних кадров
# итоговое наблюдение: (4, 84, 84) - вход сети из конспекта 7

SkipFrame — классический gym.Wrapper (конспект 6, раздел 4): одно действие агента удерживается четыре кадра подряд, награды суммируются. Смысл двойной: соседние кадры почти одинаковы — решения каждый кадр не нужны, а обучение ускоряется вчетверо; плюс политика получается «спокойнее». Серость и уменьшение экономят память (буфер хранит сотни тысяч кадров!), стопка из четырёх кадров возвращает скорости — знакомая борьба с неполным описанием:

class SkipFrame(gym.Wrapper):
    def __init__(self, env, skip):
        super().__init__(env)
        self._skip = skip

    def step(self, action):
        total_reward = 0.0
        for _ in range(self._skip):        # повторяем действие skip кадров
            state, reward, terminated, truncated, info = self.env.step(action)
            total_reward += reward
            if terminated:
                break
        return state, total_reward, terminated, truncated, info
Первый кадр стопки: серое изображение 84 на 84
кадр t−3
Второй кадр стопки
кадр t−2
Третий кадр стопки
кадр t−1
Четвёртый кадр стопки
кадр t

Наблюдение после конвейера: стопка (4, 84, 84). Снято тем же запуском среды, что и кадры выше

5.2. Дуэльная сеть с Double-целью

Сеть ноутбука — свёрточный экстрактор из конспекта 7 плюс два потока по формуле (8.5):

class DuelingDQN(nn.Module):
    def __init__(self, in_dim, out_dim):
        super().__init__()
        cannel_n, height, width = in_dim              # (4, 84, 84)
        self.feature_extractor = nn.Sequential(       # общие параметры theta
            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()
        )
        self.value_stream = nn.Sequential(            # голова V: один выход
            nn.Linear(256, 256), nn.ReLU(), nn.Linear(256, 1)
        )
        self.advantage_stream = nn.Sequential(        # голова A: по выходу на действие
            nn.Linear(256, 256), nn.ReLU(), nn.Linear(256, out_dim)
        )

    def forward(self, input):
        features   = self.feature_extractor(input)
        values     = self.value_stream(features)
        advantages = self.advantage_stream(features)
        # формула (8.5): вычитание среднего преимущества
        return values + advantages - advantages.mean(dim=1, keepdim=True)

Обучающий шаг агента — Double-цель (8.1) в тех же обозначениях, что и в разделе 3; флаг double_q переключает между DQN и Double DQN, так что оба варианта можно сравнить на одной задаче:

if self.double_q:
    # Double DQN: выбирает обучаемая сеть, оценивает замороженная
    with torch.no_grad():
        next_actions = torch.argmax(self.updating_net(new_states), axis=1)
        tar_action_values = self.frozen_net(new_states)
    td_tar = rewards + (1 - terminateds.float()) * self.gamma \
             * tar_action_values[np.arange(batch_size), next_actions]
else:
    # обычный DQN: замороженная сеть выбирает и оценивает
    with torch.no_grad():
        tar_action_values = self.frozen_net(new_states)
    td_tar = rewards + (1 - terminateds.float()) * self.gamma \
             * tar_action_values.max(1)[0]

Ещё три отличия от самодельного DQN конспекта 7 — приметы взрослого инструментария:

Обучение на пикселях — это часы: ноутбук играет 500 эпизодов, обучаясь каждые 4 такта батчами по 32 и синхронизируя замороженную сеть раз в 5000 тактов. Запуск, логирование и графики — в ноутбуке курса Dueling DQN_1.ipynb. Ниже — самодостаточная версия одним файлом: сеть, обёртки и Double-цель ноутбука без изменений, а буфер — класс Memory в духе конспекта 7 с хранением кадров в uint8 (torchrl не требуется; проверено запуском — код учится с первых эпизодов).

Полный код для проектов: Dueling Double DQN на CarRacing (PyTorch, один файл)
# dueling_carracing.py — Dueling Double DQN на CarRacing-v2, один файл.
# По ноутбуку курса (Dueling DQN_1.ipynb + Dueling_DQN_model.py); буфер переходов —
# класс Memory в духе конспекта 7 (в ноутбуке — TensorDictReplayBuffer из torchrl).
import numpy as np
import torch
import torch.nn as nn
import gymnasium as gym
import gymnasium.wrappers as gym_wrap

class SkipFrame(gym.Wrapper):
    """Одно действие агента удерживается skip кадров, награды суммируются."""
    def __init__(self, env, skip):
        super().__init__(env)
        self._skip = skip

    def step(self, action):
        total_reward = 0.0
        for _ in range(self._skip):
            state, reward, terminated, truncated, info = self.env.step(action)
            total_reward += reward
            if terminated:
                break
        return state, total_reward, terminated, truncated, info

class DuelingDQN(nn.Module):
    """Свёрточный экстрактор + два потока: V (скаляр) и A (по действию)."""
    def __init__(self, in_dim, out_dim):
        super().__init__()
        cannel_n, height, width = in_dim
        self.feature_extractor = 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()
        )
        self.value_stream = nn.Sequential(
            nn.Linear(256, 256), nn.ReLU(), nn.Linear(256, 1))
        self.advantage_stream = nn.Sequential(
            nn.Linear(256, 256), nn.ReLU(), nn.Linear(256, out_dim))

    def forward(self, input):
        features = self.feature_extractor(input)
        values = self.value_stream(features)
        advantages = self.advantage_stream(features)
        # формула (8.5): вычитание среднего преимущества
        return values + advantages - advantages.mean(dim=1, keepdim=True)

class Memory:
    """Кольцевая память переходов для кадров-стопок (uint8 - экономим память в 4 раза)."""
    def __init__(self, capacity, state_shape):
        self.capacity, self.count = capacity, 0
        self.S0 = torch.empty((capacity,) + state_shape, dtype=torch.uint8)
        self.S1 = torch.empty((capacity,) + state_shape, dtype=torch.uint8)
        self.A0 = torch.empty((capacity,), dtype=torch.int64)
        self.R1 = torch.empty((capacity,), dtype=torch.float32)
        self.Dn = torch.empty((capacity,), dtype=torch.float32)

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

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

class Agent:
    def __init__(self, state_shape, action_n, double_q=True, gamma=0.95,
                 epsilon=1.0, epsilon_decay=0.9999925, epsilon_min=0.05,
                 capacity=100000, lr=0.0002):
        self.gamma, self.epsilon = gamma, epsilon
        self.epsilon_decay, self.epsilon_min = epsilon_decay, epsilon_min
        self.action_n, self.double_q = action_n, double_q
        self.device = "cuda" if torch.cuda.is_available() else "cpu"
        self.updating_net = DuelingDQN(state_shape, action_n).float().to(self.device)
        self.frozen_net = DuelingDQN(state_shape, action_n).float().to(self.device)
        self.frozen_net.load_state_dict(self.updating_net.state_dict())
        self.optimizer = torch.optim.Adam(self.updating_net.parameters(), lr=lr)
        self.loss_fn = torch.nn.SmoothL1Loss()      # потери Хьюбера
        self.buffer = Memory(capacity, state_shape)

    def take_action(self, state):
        if np.random.rand() < self.epsilon:
            action_idx = np.random.randint(self.action_n)
        else:
            x = torch.tensor(np.asarray(state), dtype=torch.float32,
                             device=self.device).unsqueeze(0) / 255.
            with torch.no_grad():
                action_idx = torch.argmax(self.updating_net(x), axis=1).item()
        self.epsilon = max(self.epsilon * self.epsilon_decay, self.epsilon_min)
        return action_idx

    def update_net(self, batch_size):
        states, actions, rewards, new_states, terminateds = self.buffer.get(batch_size)
        states, new_states = states.to(self.device), new_states.to(self.device)
        actions, rewards = actions.to(self.device), rewards.to(self.device)
        terminateds = terminateds.to(self.device)
        td_est = self.updating_net(states)[np.arange(batch_size), actions]
        if self.double_q:
            # Double DQN: выбирает обучаемая сеть, оценивает замороженная
            with torch.no_grad():
                next_actions = torch.argmax(self.updating_net(new_states), axis=1)
                tar = self.frozen_net(new_states)
            td_tar = rewards + (1 - terminateds) * self.gamma \
                     * tar[np.arange(batch_size), next_actions]
        else:
            with torch.no_grad():
                tar = self.frozen_net(new_states)
            td_tar = rewards + (1 - terminateds) * self.gamma * tar.max(1)[0]
        loss = self.loss_fn(td_est, td_tar)
        self.optimizer.zero_grad()
        loss.backward()
        self.optimizer.step()
        return loss.item()

    def sync(self):
        self.frozen_net.load_state_dict(self.updating_net.state_dict())

if __name__ == "__main__":
    env = gym.make("CarRacing-v2", continuous=False)
    env = SkipFrame(env, skip=4)
    env = gym_wrap.GrayScaleObservation(env)
    env = gym_wrap.ResizeObservation(env, shape=84)
    env = gym_wrap.FrameStack(env, num_stack=4)
    state, info = env.reset()
    driver = Agent(state.shape, env.action_space.n, double_q=True)

    batch_n, when2learn, when2sync = 32, 4, 5000   # параметры ноутбука
    timestep_n = 0
    for episode in range(1, 501):                  # у источника - 500 эпизодов (часы!)
        state, info = env.reset()
        episode_reward, losses = 0.0, []
        while True:
            timestep_n += 1
            action = driver.take_action(state)
            new_state, reward, terminated, truncated, info = env.step(action)
            episode_reward += reward
            driver.buffer.add(state, action, reward, new_state, terminated)
            if driver.buffer.count >= batch_n and timestep_n % when2learn == 0:
                losses.append(driver.update_net(batch_n))
            if timestep_n % when2sync == 0:
                driver.sync()
            state = new_state
            if terminated or truncated:
                break
        print(f"эпизод {episode:4d}  возврат {episode_reward:8.1f}  "
              f"eps {driver.epsilon:.3f}  средний loss {np.mean(losses or [0]):.4f}")

6. Что применять и когда

МодификацияЧто меняетКогда особенно полезна
Double DQNцель обучения: выбор действия — основной сетью, оценка — целевой (8.1)всегда — почти бесплатна; критична при малом опыте и большом числе действий, где шум оценок велик
Dueling DQNархитектуру: общий экстрактор + потоки V и A (8.5) много действий; состояния, где выбор почти не важен; ускоряет обучение V по каждому переходу

Модификации независимы и складываются: ноутбук курса использует обе сразу. На этом value-семейство нашей траектории завершено; но у него остаётся встроенное ограничение — действий должно быть немного и они дискретны, иначе \(\arg\max_a Q\) сам по себе становится задачей оптимизации (уже у CarRacing в непрерывном режиме руль+газ+тормоз — вектор из \(\mathbb{R}^3\)). Q-функция была нашим посредником пять конспектов подряд: учили её, а политику снимали жадным правилом. Следующий конспект убирает посредника — политику можно оптимизировать напрямую.

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

Источники

  1. Van Hasselt, H. Deep Reinforcement Learning with Double Q-learning / H. van Hasselt, A. Guez, D. Silver // arXiv. — 2015. — URL: https://arxiv.org/abs/1509.06461 (дата обращения: 08.07.2026).
  2. Wang, Z. Dueling Network Architectures for Deep Reinforcement Learning / Z. Wang, T. Schaul, M. Hessel [et al.] // arXiv. — 2015. — URL: https://arxiv.org/abs/1511.06581 (дата обращения: 08.07.2026).