Глубокое 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\) означает «не доехала».
Обе анимации сняты живым запуском среды; вторая — политика, которую мы сейчас получим.
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 по индексированным состояниям. Две детали из источника: действий достаточно двух (толкать влево и вправо: «не толкать» оптимальной политике не нужно, и метод это потом сам обнаружит), а ε-распад для этой задачи не критичен — обучение идёт и при быстром распаде, и без него:
Эпизодов нужны десятки тысяч — но каждый шаг дешёвый, и по времени обучение занимает минуты. Мы воспроизвели этот эксперимент при подготовке конспекта: после 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. Но полное решение обеих проблем — заменить таблицу функцией, которая обобщает по построению.
3. Аппроксимация функции ценности
Идея: вместо таблицы с \(|S| \cdot |A|\) независимыми числами — параметрическая функция
у которой параметров \(\theta\) существенно меньше, чем состояний, и одни и те же параметры обслуживают сразу много состояний — это и есть обобщение. Простейший вариант — линейная аппроксимация по вектору признаков \(\phi(s) \in \mathbb{R}^d\):
Пример из лекции: коридор из 1000 позиций, ценность меняется вдоль него плавно — признаки \(\phi(s) = (1, s, s^2)\) сжимают тысячу параметров до трёх, ценой невозможности представить произвольную функцию. Нейросеть — та же идея с нелинейной параметризацией и признаками, которые сеть строит сама.
Перенесём TD(0) из конспекта 4 на параметрическое семейство. Для перехода \((S_t, R_{t+1}, S_{t+1})\) TD-ошибка выглядит как раньше (конспект 4, формула (4.9)), только ценности берутся из \(V_w\):
а обновление сдвигает параметры по градиенту:
Обновление (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\). Итог — проекционное уравнение Беллмана:
Алгоритм больше не ищет точное решение уравнения Беллмана — только его «лучшую тень» внутри доступного семейства \(\mathcal{F}\). Для линейной аппроксимации и on-policy данных это уравнение имеет решение, и TD к нему сходится; дальше начинаются проблемы.
3.1. Смертельная триада
Термином deadly triad («смертельная триада») называют одновременное присутствие трёх факторов:
- аппроксимация функций — общие параметры вместо независимой таблицы;
- бутстрап — текущая оценка внутри цели обновления, как в (7.3);
- off-policy обучение — данные собраны не той политикой, которую оцениваем (а Q-learning из конспекта 5 — принципиально off-policy).
Каждый фактор по отдельности безобиден; вместе они способны разрушить сходимость — классический контрпример Байрда демонстрирует расходимость off-policy TD уже при линейной аппроксимации: норма весов растёт без остановки. Интуитивно порочный круг выглядит так: параметры формируют цель → цель тянет параметры → новые параметры сдвигают цель — и если данные приходят из «неподходящих» областей пространства состояний, ошибка в одной части функции через общие параметры усиливает ошибку в другой, цикл самоподдерживается. Табличная интуиция «TD надёжен» на параметрический случай не переносится.
4. DQN: два механизма стабилизации
Наивный перенос Q-learning на нейросеть даёт цель
в которой и оцениваемая функция, и цель зависят от одних и тех же параметров \(\theta\) — та самая погоня за собственным хвостом, плюс обучение по сильно коррелированным последовательным состояниям. Обучение получается чрезвычайно нестабильным. Метод Deep Q-Network (DQN, DeepMind: Atari по пикселям в 2013-м, публикация в Nature в 2015-м) добавил два механизма, разорвавших оба порочных круга:
- Память переходов (англ. replay buffer) \(\mathcal{D}\): четвёрки \((s, a, r', s')\) складываются в большой кольцевой буфер, а обучение идёт по случайным батчам из него. Соседние по времени состояния почти одинаковы — случайная выборка перемешивает опыт и приближает данные к независимой выборке (именно эта корреляция мешала глубокому обучению до DeepMind). Фактически буфер — неустаревающая выборочная модель среды; конечность буфера при этом важна: он должен «забывать» области вдали от оптимальной стратегии.
- Целевая сеть (англ. target network) \(Q_{\bar\theta}\): копия основной сети с замороженными параметрами \(\bar\theta\), которые лишь изредка (раз в сотни шагов) обновляются из основной. Цель строится по целевой сети — на коротком промежутке она почти фиксирована, и хвост перестаёт убегать.
Минимизируется средний квадрат отклонения от уравнения Беллмана с жадным выбором действия — по батчу из \(B\) четвёрок:
Основная сеть оценивает ценность текущей пары (s, a), целевая — ценность следующего состояния. Схема целиком:
Оба механизма — инженерные: они не отменяют смертельную триаду теоретически (обучение остаётся off-policy бутстрапом с аппроксимацией), но резко улучшают практическую устойчивость.
5. Тренажёр: зачем нужна память переходов
Посмотрим на главный аргумент DQN своими глазами. Ниже — фазовое пространство машинки (горизонталь — позиция x, вертикаль — скорость v; динамика среды воспроизведена точно). Кнопка «сыграть эпизод» добавляет в память переходы одного эпизода раскачивающей политики с ε-шумом. Дальше сравните два способа собрать обучающий батч из 100 переходов: последние подряд — так учился бы алгоритм без памяти, по свежему хвосту опыта — и случайные из памяти, как в DQN. Метрика покрытия — доля клеток фазового пространства 10×10, задетых батчем.
Типичная картина: «последние подряд» лежат ниткой вдоль куска одной-единственной траектории — весь батч приходит из одного эпизода, состояния идут почти дублями (после стартовых двух эпизодов: покрытие 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 решается за сотни эпизодов, но менее устойчиво:
Гистограммы памяти — диагностика в духе раздела 4: в начале обучения память забита состояниями с малыми скоростями (машинка в ложбине), к концу распределение размывается — агент увидел всё фазовое пространство; распределение действий показывает, что «ничего не делать» метод из памяти практически вытеснил.
7.1. Борьба с нестабильностью
Рекомендации источника — чеклист на случай «кривая обучения дёргается или падает»:
- реже обновлять целевую сеть (мягкое обновление против жёсткого — сразу или после каждого батча);
- увеличивать память — это очень важный фактор — и следить за разнообразием примеров в ней (гистограммы выше);
- умеренная скорость обучения; Adam — по умолчанию, но для маленьких сетей простой SGD иногда справляется лучше;
- не экономить на нейронах скрытых слоёв: поверхность Q бывает очень сложной (ниже — её вид для машинки); ужимать сеть — после того, как обучение стало устойчивым;
- масштабировать вход к \([-1, 1]\), но осторожно: декларированные средой границы могут не соответствовать реальности (CartPole объявляет скорости бесконечными — тогда масштабирование отключают или задают границы вручную по статистике).
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.
Контрольные вопросы
-
Состояние непрерывно (пара x, v) — таблица по всем состояниям не определена. Дискретизация: разбить диапазон каждой компоненты на интервалы (в источнике 101×101) и работать с индексами ячеек. Работает, но таблица растёт экспоненциально с размерностью и не обобщает между соседними ячейками — политика получается шумной.
-
В TD-цели R + γV_w(S′) тоже сидят параметры w, но при дифференцировании цель замораживается: градиент берётся только по V_w(S_t). Так устроены все TD-методы с аппроксимацией (7.3)–(7.4). Расплата — теряются табличные гарантии сходимости: процесс сходится не к V^π, а в лучшем случае к решению проекционного уравнения Беллмана (7.5).
-
Аппроксимация функций (общие параметры), бутстрап (текущая оценка в цели), off-policy обучение (данные от другой политики). Порочный круг: параметры формируют цель, цель тянет параметры, ошибка в одной области через общие параметры усиливает ошибку в другой. Контрпример Байрда: off-policy TD расходится даже при линейной аппроксимации.
-
Память переходов: обучение случайными батчами из большого буфера разрывает корреляцию последовательных состояний и приближает данные к независимой выборке. Целевая сеть: цель (7.7) строится по замороженной копии параметров, обновляемой изредка, — цель перестаёт двигаться вслед за каждым шагом обучения.
-
dn = 1 зануляет бутстрап: у терминального состояния нет будущего. Конец эпизода по лимиту — усечение (конспект 6), формально не терминальное. Но в MountainCar награда всюду −1 и единственный сигнал — конец эпизода: при timeout=False необученный агент до флага не доезжает, терминалов в данных нет, все ценности стягиваются к −1/(1−γ) = −100, и обучение не идёт. timeout=True делает усечение «псевдотерминалом» и даёт сигнал конечности времени — рабочий компромисс ценой марковости (время не в наблюдении). В CartPole сигнала хватает без этого (упасть можно всегда).
-
Целевая сеть — точная копия основной с отстающими параметрами: только так θ̄ ← θ выполняется прямым копированием состояния (load_state_dict(model.state_dict())). Основная сеть оценивает Q(s, a) и обучается каждым шагом; целевая даёт max Q(s′, ·) в цели и обновляется раз в update шагов.
-
Частоту обновления целевой сети (обновлять реже), ёмкость и разнообразие памяти (гистограммы состояний и действий), скорость обучения и оптимизатор (Adam, для простых сетей — иногда SGD), ширину скрытых слоёв (не экономить до стабилизации), масштабирование входа к [-1,1] с реальными, а не декларированными границами. Плюс контроль среднего и дисперсии Q — на предмет расходимости.
-
Меняется только «голова восприятия»: вместо полносвязной сети от пары чисел — свёрточная сеть от четырёх кадров 84×84 (несколько кадров восстанавливают скорости — борьба с неполным описанием). Память переходов, целевая сеть, цель (7.7), ε-жадная политика — без изменений.
Источники
- 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).
- 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.