Улучшения 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\) — две независимые оценки.
Что видно. Обычный максимум смещён вправо от нуля целиком: при 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) распределили роли: основная сеть выбирает лучшее следующее действие, целевая — оценивает выбранное. Ошибка минимизируется та же, меняется только цель:
Сравните с (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 выучивает эту смесь для каждого действия отдельно — «хорошесть» состояния она узнаёт по каждому действию заново, как если бы водитель заново оценивал прямую для каждого положения руля. Разложим полезность явно:
где \(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\).
«Дуэльная» сеть вычисляет оба слагаемых отдельными потоками с общим началом:
где \(\theta\) — общие параметры (у пиксельных задач — свёрточный экстрактор признаков), а \(\beta\) и \(\alpha\) — параметры двух голов: скалярной \(V\) и векторной \(A\) по действиям:
Есть тонкость: разложение (8.2) неоднозначно — к \(A\) можно прибавить константу и вычесть её из \(V\), сумма не изменится, и сеть может «гулять» между эквивалентными решениями. Для устранения неоднозначности (и повышения стабильности) из потока преимуществ вычитают его максимум:
тогда для лучшего действия скобка равна нулю и \(Q(s, a^*) = V(s)\) — семантика потоков зафиксирована. На практике, как выяснили авторы, лучше работает вычитание среднего по действиям:
Жадная политика от этого не меняется (сдвиг общий для всех действий), а обучение стабильнее. Выигрыш дуэльной архитектуры: градиент от каждого перехода обновляет общий поток \(V\) — сеть узнаёт «хорошесть» состояния, не перебирая все действия, и это особенно заметно при большом числе действий и в состояниях, где действия почти равнозначны (у таких \(A \approx 0\), вся работа у \(V\)).
5. Практика: гоночная машинка по пикселям
Пора собрать обе модификации на задаче, ради которой они придумывались, — с большим
пространством наблюдений и живой картинкой. Ноутбук курса объединяет всё выученное с
конспекта 6 в одну задачу: CarRacing-v2
— управление гоночной машинкой на случайно сгенерированной трассе. Наблюдение — пиксели
Box(0, 255, (96, 96, 3)), вид сверху; в дискретном режиме
(continuous=False) пять действий: ничего, руль влево, руль вправо, газ, тормоз.
Награда: −0,1 за кадр плюс премия за каждую новую посещённую плитку трассы — быстрая чистая
езда выгодна:
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
Наблюдение после конвейера: стопка (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 — приметы взрослого инструментария:
- Готовый буфер: вместо самодельного класса
Memory—TensorDictReplayBufferсLazyMemmapStorage(300000)из библиотеки torchrl: буфер на 300 тысяч стопок кадров живёт на диске (memmap), а не в оперативной памяти — для пиксельных задач это необходимость. - Потери Хьюбера (
SmoothL1Loss) вместо MSE: квадратичны у нуля, линейны на больших ошибках — редкие аномальные цели (а на ранних стадиях их много) не взрывают градиент. - ε-распад по шагам, а не по эпизодам (
epsilon_decay = 0.9999925за такт): эпизоды CarRacing длинные и разной длины, распад по шагам равномернее.
Обучение на пикселях — это часы: ноутбук играет 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-функция была нашим посредником пять конспектов подряд: учили её, а политику снимали жадным правилом. Следующий конспект убирает посредника — политику можно оптимизировать напрямую.
Контрольные вопросы
-
В max одна оценка и выбирает действие, и оценивает его: шум, поднявший оценку, делает действие «победителем» и попадает в цель — E[max Q̂] ≥ max E[Q̂] (5.7). В двойной оценке выбирает Q̂_A, а оценивает независимая Q̂_B: для неё выбранное действие обычное, её шум с выбором не коррелирован, оценка несмещённа (хотя и с большей дисперсией).
-
Роль второго оценщика играет уже имеющаяся целевая сеть: она отстаёт от основной на сотни шагов обучения, её ошибки с ошибками основной не совпадают. Основная сеть выбирает argmax по s′, целевая оценивает выбранное действие — цель (8.1). В коде меняются две строки вычисления maxQ.
-
Q(s,a) = V(s) + A(s,a) (8.2). V(s) — ценность состояния (среднее Q по политике), A(s,a) — преимущество: насколько действие лучше среднего; ⟨A(s,π(s))⟩ = 0, для жадного действия A(s,a*) = 0 и Q(s,a*) = V(s).
-
Разложение Q = V + A неоднозначно: константу можно перекидывать между V и A. Сеть без фиксации «гуляет» между эквивалентными решениями. Вычитание max A фиксирует семантику (Q(s,a*) = V), но на практике стабильнее вычитание среднего (8.5); жадная политика не меняется — сдвиг одинаков для всех действий.
-
Каждый переход обновляет общий поток V — «хорошесть» состояния выучивается без перебора всех действий. Обычной сети для того же нужно посетить каждую пару (s,a). Выигрыш растёт с числом действий и в состояниях, где действия почти равнозначны (там A ≈ 0 и вся информация в V).
-
SkipFrame(4) — самодельный Wrapper: действие удерживается 4 кадра, награды суммируются (соседние кадры избыточны, обучение ×4 быстрее); GrayScaleObservation — цвет не нужен, память ×3 меньше; ResizeObservation(84) — стандартный вход DQN-свёртки; FrameStack(4) — стопка кадров возвращает скорости (борьба с неполным описанием, конспект 1).
-
Хьюбер (SmoothL1) квадратичен у нуля и линеен на больших отклонениях: аномальные цели ранних стадий обучения не дают взрывных градиентов. Буфер на 300 000 стопок (4×84×84) — гигабайты: LazyMemmapStorage держит их на диске, подгружая батчами, иначе оперативной памяти не хватит.
Источники
- 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).
- 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).