Монте-Карло, TD и Q-learning
Как учиться без модели среды: оценка ценности методом Монте-Карло и TD(0), бутстрэп и компромисс смещения и разброса, ε-жадное исследование, SARSA и Q-learning, on-policy против off-policy и DQN.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В прошлом уроке мы знали правила среды: вероятности переходов и награды . Тогда оптимальную политику можно вычислить, не сделав ни одного хода. В жизни правил обычно нет. Никто не выдаст таблицу вероятностей для пользователя чат-бота или для соперника в игре. Остаётся пробовать, ошибаться и делать выводы. Это обучение без модели средыобучение без модели среды (model-free RL)EN model-free reinforcement learningАгент не знает вероятностей переходов и наград и учится только на собственном опыте. (model-free RL).
Методы этого урока — классика, на которой построен «словарь» всего RL. В языковых моделях Q-learning почти не применяют, но слова бутстрэп, TD-ошибка, исследование, on-policy и off-policy встречаются в каждой статье про PPO и GRPO. TD-ошибка станет кирпичиком GAE в следующем уроке, а off-policy — причиной, по которой в формуле PPO появляется отношение вероятностей .
Монте-Карло: сыграть и усреднить
Заголовок раздела «Монте-Карло: сыграть и усреднить»Напомним: — средняя отдача из состояния . Раз среднее, его можно оценить выборкой — сыграть много эпизодов и усреднить фактические отдачи. Это метод Монте-Карлометод Монте-Карло (в RL)EN Monte Carlo methodОценка ценности усреднением фактических отдач законченных эпизодов. Без смещения, но с большим разбросом., тот же закон больших чисел, что в уроке 1.3.1.
Пример на коридоре из прошлого урока (политика 50/50, ). Три эпизода из :
| Эпизод | Путь | Награды | из |
|---|---|---|---|
| 1 | выход | ||
| 2 | выход | ||
| 3 | выход |
Оценка . Точное значение — 0.647: в этих трёх эпизодах нам повезло, агент быстро нашёл выход. С ростом числа эпизодов среднее приближается к истине.
Чтобы не хранить все отдачи, среднее считают «на ходу»: . При это точное среднее, при постоянном — среднее с бо́льшим весом у свежих эпизодов.
Плюс Монте-Карло: оценка честная, без смещения — она опирается только на то, что действительно произошло. Минус: нужно ждать конца эпизода, а отдача — сумма многих случайных наград, поэтому разброс большой.
TD: учиться на каждом шаге
Заголовок раздела «TD: учиться на каждом шаге»Представьте, что вы едете домой и прогнозируете время в пути: «30 минут». Через 10 минут вы встали в пробку и думаете: «ещё минут 25». Новый прогноз всего пути — 35 минут, и исправить первоначальный можно уже сейчас, не дожидаясь приезда. Это идея обучения на временных разностяхобучение на временных разностях (TD)EN temporal-difference learningОбновление оценки после каждого шага: V(s) ← V(s) + α[r + γV(s′) − V(s)]. (temporal difference, TD).
В уравнении Беллмана . Сделав один шаг, мы видим конкретные и и можем подвинуть к цели :
Как читать: — TD-ошибкаTD-ошибкаEN TD errorδ = r + γV(s′) − V(s): насколько после шага дела оказались лучше (или хуже), чем ожидалось., «сюрприз» этого шага: насколько «награда плюс ценность того, куда попали» больше или меньше того, что мы ожидали в . — шаг обучения, как learning rate в градиентном спуске (урок 1.4.1). Если конечное, будущего нет и цель — просто .
Пример. Текущие оценки , . Агент шагнул из в , награда 0, , . Ошибка — в оказалось лучше, чем мы думали про . Новая оценка .
Главное слово: цель содержит нашу же оценку . Обновлять оценку с опорой на другую оценку называют бутстрэпомбутстрэп (в RL)EN bootstrappingОбновление оценки с опорой на другую собственную оценку (V(s′) или max Q(s′, ·)), а не на полную фактическую отдачу. Не путать с бутстрэп-выборкой из статистики. (bootstrapping — «вытянуть себя за шнурки ботинок»). Не путайте с бутстрэп-выборкой из статистики (уроки 1.3.4 и 2.3.1): слово то же, смысл другой.
Смещение и разброс: Монте-Карло против TD
Заголовок раздела «Смещение и разброс: Монте-Карло против TD»Это знакомый компромисс из урока 2.1.2, только про оценки ценности:
| Монте-Карло | TD(0) | |
|---|---|---|
| Цель обновления | фактическая отдача | |
| Когда обновляем | в конце эпизода | после каждого шага |
| Смещениекомпромисс смещения и разбросаEN bias–variance tradeoff | нет: | есть, пока неточна |
| Разброс | большой: сумма многих случайных наград | маленький: одна случайная награда и один переход |
| Бесконечные задачи | не подходит | подходит |
Между ними — целое семейство: -шаговая цель берёт настоящих наград, а дальше доверяется оценке:
При это TD, при до конца эпизода — Монте-Карло. В следующем уроке GAE возьмёт взвешенное среднее всех -шаговых оценок с одним параметром — тем самым, что стоит в конфигах PPO для LLM.
Исследование и использование
Заголовок раздела «Исследование и использование»Оценивать политику мало, её нужно улучшать. И здесь появляется новая проблема. Вы в незнакомом городе нашли неплохое кафе. Ходить туда каждый день — использование (exploitation): надёжно, но вы никогда не узнаете, что за углом кафе лучше. Каждый день пробовать новое — исследование (exploration): узнаете много, но часто будете есть плохо. Это компромисс исследования и использованияисследование и использованиеEN exploration vs. exploitationКомпромисс между «пробовать новое, чтобы узнать больше» и «делать то, что сейчас кажется лучшим»..
В чистом виде он живёт в задаче многорукого бандитамногорукий бандитEN multi-armed banditПростейшая задача RL — K «ручек» с неизвестными средними наградами, один шаг на эпизод.: игровых автоматов с неизвестными средними выигрышами, на каждом шаге дёргаем одну ручку. Это MDP из одного состояния. Простейшее решение — ε-жадная стратегияε-жадная стратегияEN ε-greedy policyС вероятностью ε — случайное действие, иначе — действие с наибольшей оценкой Q.:
Лучшее действие выбирается с вероятностью : при и четырёх действиях это , каждое из остальных — 0.025. Часто уменьшают со временем: сначала много исследуем, потом в основном используем.
Проверим на трёх автоматах с вероятностями выигрыша 0.3, 0.5 и 0.7 (агент их не знает):
import numpy as np
means = np.array([0.3, 0.5, 0.7]) # вероятность выигрыша у трёх «автоматов» (агент её не знает)
def play(eps, steps=1000, seed=0): rng = np.random.default_rng(seed) Q, N = np.zeros(3), np.zeros(3) # оценки и счётчики нажатий total = 0.0 for t in range(steps): if rng.random() < eps: a = int(rng.integers(3)) # исследуем else: a = int(rng.choice(np.flatnonzero(Q == Q.max()))) # используем (ничьи — случайно) r = float(rng.random() < means[a]) # награда 1 или 0 N[a] += 1 Q[a] += (r - Q[a]) / N[a] # бегущее среднее наград ручки a total += r return total / steps
for eps in [0.0, 0.01, 0.1, 0.3]: avg = np.mean([play(eps, seed=s) for s in range(200)]) print(f"ε = {eps:<4} средняя награда за шаг {avg:.3f} (лучшее возможное 0.700)")ε = 0.0 средняя награда за шаг 0.555 (лучшее возможное 0.700)ε = 0.01 средняя награда за шаг 0.630 (лучшее возможное 0.700)ε = 0.1 средняя награда за шаг 0.666 (лучшее возможное 0.700)ε = 0.3 средняя награда за шаг 0.636 (лучшее возможное 0.700)Чисто жадный агент () часто «залипает» на первой ручке, которая повезла. Слишком любопытный () тратит много шагов впустую. Лучше всего — немного исследования.
SARSA и Q-learning: учимся действовать
Заголовок раздела «SARSA и Q-learning: учимся действовать»Для улучшения политики удобнее оценивать не , а : тогда лучшее действие — просто , модель среды не нужна. Применим TD-идею к .
SARSASARSAEN SARSA (state–action–reward–state–action)Как Q-learning, но в цели Q(s′, a′) действия, которое агент действительно выбрал. On-policy. (по буквам перехода: ):
где — действие, которое агент действительно выберет в своей ε-жадной политикой.
Q-learningQ-learningEN Q-learningQ(s,a) ← Q(s,a) + α[r + γ max_a′ Q(s′,a′) − Q(s,a)]. Off-policy: учит Q* при любом поведении. (Уоткинс, 1989) отличается одним символом — максимумом:
Как читать Q-learning: «награда плюс ценность лучшего действия в следующем состоянии» — это правая часть уравнения оптимальности Беллмана из прошлого урока, оценённая по одному переходу. Поэтому Q-learning сходится к .
Пример. , награда , , , . Цель Q-learning: , TD-ошибка 0.72, новое . Если ε-жадный агент случайно выбрал в первое действие, цель SARSA — , новое .
Алгоритм для обоих:
- Таблица из нулей. Для каждого эпизода: = старт, = ε-жадно по .
- Сделать шаг: получить и . Выбрать ε-жадно по .
- Обновить по формуле SARSA или Q-learning. Если конечное — цель равна .
- . Повторять до конца эпизода.
On-policy и off-policy
Заголовок раздела «On-policy и off-policy»SARSA учит ценность той самой политики, которой действует, — ε-жадной, со всеми её случайными шагами. Это on-policyon-policy (обучение на данных текущей политики)EN on-policyПротивоположность — off-policy, обучение на данных другой политики. обучение. Q-learning ведёт себя ε-жадно, а учит ценность другой политики — чисто жадной. Это off-policyoff-policy (обучение на данных другой политики)EN off-policyПолитика, которую учим, отличается от той, что собирала данные. Пример — Q-learning; в PPO и GRPO — поправка π/π_old.: данные собирает одна политика, учим другую.
Разницу видно на классическом примере 6.6 из Саттона и Барто — «прогулке по обрыву». Поле 4×12, старт и цель в нижних углах, между ними обрыв: шаг туда — награда −100 и конец эпизода; каждый обычный шаг −1. Мы прогнали оба алгоритма по 500 эпизодов (, , три разных seed):
| Q-learning | SARSA | |
|---|---|---|
| Жадный путь после обучения | 13 шагов по самому краю обрыва (оптимум) | 17 шагов, на два ряда выше |
| Средняя отдача за последние 100 эпизодов обучения | от −33 до −40 | от −22 до −26 |
Q-learning выучил кратчайший путь, но во время обучения его ε-жадные случайные шаги у края то и дело сбрасывают агента вниз. SARSA «знает», что сам иногда делает случайный шаг, и прокладывает путь подальше от края. Это вы повторите в домашней работе.
Почему это важно для LLM. В PPO и GRPO ответы генерирует политика , а потом по этому же батчу делают несколько шагов оптимизации — после первого шага модель уже немного другая, и данные становятся «чуть-чуть off-policy». Поправку на это даёт отношение вероятностей (урок 6.0.3). А в асинхронном RL для агентов (урок 8.3.4) данные бывают устаревшими на несколько версий политики — и off-policy-поправки становятся обязательными.
От таблицы к нейросети: DQN
Заголовок раздела «От таблицы к нейросети: DQN»Таблица работает, пока состояний немного. У игры Atari состояние — картинка экрана, вариантов астрономически много. Решение — приближать нейросетью : на вход состояние, на выход по числу на каждое действие. Учить её регрессией к TD-цели:
Наивная версия расходится, и DQNDQN (глубокая Q-сеть)EN deep Q-network (DQN)Mnih и др., 2015 — Q-learning с нейросетью, буфером воспроизведения и целевой сетью; играл в Atari по пикселям. (Mnih et al., Nature, 2015) добавил два приёма:
- Буфер воспроизведениябуфер воспроизведения (replay buffer)EN experience replay bufferХранилище прошлых переходов (s, a, r, s′), из которого сэмплируют случайные мини-батчи. (replay buffer) хранит десятки тысяч прошлых переходов , а сеть учится на случайных мини-батчах из него. Соседние кадры игры почти одинаковы, и без перемешивания градиенты сильно коррелированы. Заодно каждый переход используется много раз. Учиться на старых переходах можно, потому что Q-learning — off-policy.
- Целевая сетьцелевая сеть (target network)EN target networkЗамороженная копия Q-сети для вычисления цели r + γ max Q; её веса обновляют редко. — копия сети, которую обновляют редко (раз в несколько тысяч шагов). Иначе цель сдвигается при каждом шаге вместе с , и сеть гоняется за собственным хвостом.
По словам авторов, один и тот же алгоритм с одной архитектурой и одними гиперпараметрами играл в 49 игр Atari по пикселям экрана на уровне профессионального тестировщика игр. Это была первая громкая победа глубокого RL.
Практика
Заголовок раздела «Практика»Задание: Q-learning и SARSA на скользком озере
Заголовок раздела «Задание: Q-learning и SARSA на скользком озере»Среда — «скользкое озеро» 4×4 в стиле FrozenLake: S — старт, F — лёд, H — прорубь (эпизод кончается без награды),
G — цель (+1). С вероятностью 0.2 агент скользит вбок. Реализуйте ε-жадный выбор со случайным разрешением ничьих,
шаги Q-learning и SARSA, цикл обучения с убывающим и оценку жадной политики. Порог — дойти до цели
хотя бы в 45% эпизодов. Политика, оптимальная при (как в задании), доходит примерно в 74% случаев:
с дисконтом выгоднее идти коротким путём мимо прорубей и иногда соскальзывать, чем долго осторожничать. Политика,
которая думает только о доходе до цели, без спешки, дошла бы в ~94% эпизодов за 100 шагов — это снова роль .
import numpy as np
# ── Среда уже готова: «скользкое озеро» 4×4 ─────────────────────────────────
# S — старт, F — лёд, H — прорубь (эпизод кончается, награда 0), G — цель (+1, конец).
# С вероятностью slip агент скользит вбок от выбранного направления (по slip/2 в каждую сторону).
class SlipperyLake:
MOVES = [(-1, 0), (0, 1), (1, 0), (0, -1)] # действия: 0 ↑, 1 →, 2 ↓, 3 ←
def __init__(self, layout=("SFFF", "FHFF", "FFFH", "HFFG"), slip=0.2, max_steps=100, seed=0):
self.layout, self.slip, self.max_steps = layout, slip, max_steps
self.H, self.W = len(layout), len(layout[0])
self.n_states, self.n_actions = self.H * self.W, 4
self.rng = np.random.default_rng(seed)
def reset(self):
self.s, self.t = 0, 0 # старт — левый верхний угол
return self.s
def step(self, a):
u = self.rng.random()
if u < self.slip / 2:
a = (a + 1) % 4 # поскользнулись вправо от направления
elif u < self.slip:
a = (a + 3) % 4 # … или влево
r, c = divmod(self.s, self.W)
nr = min(max(r + self.MOVES[a][0], 0), self.H - 1)
nc = min(max(c + self.MOVES[a][1], 0), self.W - 1)
self.s, self.t = nr * self.W + nc, self.t + 1
cell = self.layout[nr][nc]
reward = 1.0 if cell == "G" else 0.0
done = cell in "GH" or self.t >= self.max_steps
return self.s, reward, done
# ── Ваша часть ──────────────────────────────────────────────────────────────
def epsilon_greedy(q_row, eps, rng):
"""ε-жадный выбор действия по строке Q[s].
С вероятностью eps (проверка rng.random() < eps) — случайное действие rng.integers(len(q_row)).
Иначе — действие с наибольшей оценкой; если таких несколько — случайное из них
(np.flatnonzero(q_row == q_row.max()) и rng.choice). Вернуть int.
"""
# TODO: ваш код
...
def q_learning_update(Q, s, a, r, s_next, done, alpha, gamma):
"""Шаг Q-learning, Q меняется НА МЕСТЕ: Q[s,a] += alpha * (target − Q[s,a]).
target = r, если done (у конечного состояния нет будущего),
иначе target = r + gamma * max_a' Q[s_next, a']. Вернуть TD-ошибку target − Q[s,a] (до обновления).
"""
# TODO: ваш код
...
def sarsa_update(Q, s, a, r, s_next, a_next, done, alpha, gamma):
"""То же, что Q-learning, но в цели Q[s_next, a_next] — оценка действия, которое агент
действительно выбрал следующим (а не максимум). Вернуть TD-ошибку."""
# TODO: ваш код
...
def train(env, method="q", episodes=1000, alpha=0.1, gamma=0.9, eps_start=1.0, eps_end=0.05, seed=0):
"""Обучить таблицу Q (нули [n_states, n_actions]). Вернуть (Q, wins), wins[i] — награда i-го эпизода.
rng = np.random.default_rng(seed). ε убывает линейно от eps_start до eps_end за первые 70% эпизодов:
eps = eps_end + (eps_start − eps_end) * max(0, 1 − ep / (0.7 * episodes))
Эпизод: s = env.reset(); a = epsilon_greedy(...); пока не done:
s_next, r, done = env.step(a); a_next = epsilon_greedy(Q[s_next], ...);
обновление (method "q" — q_learning_update, иначе sarsa_update); s, a = s_next, a_next.
"""
# TODO: ваш код
...
def greedy_success_rate(env, Q, episodes=300):
"""Доля эпизодов, дошедших до цели, если всегда брать int(np.argmax(Q[s])) (без исследования)."""
# TODO: ваш код
...
# Проверьте себя кнопкой «Запустить»
for method in ["q", "sarsa"]:
out = train(SlipperyLake(seed=0), method)
if out is not None:
Q, wins = out
rate = greedy_success_rate(SlipperyLake(seed=1), Q, episodes=200)
print(f"{method:5}: успехов за последние 200 эпизодов обучения {wins[-200:].mean():.2f}, жадная политика {rate:.2f}")
cells = "".join(SlipperyLake().layout)
print("\n".join(" ".join(ch if ch in "HG" else "↑→↓←"[Q[4 * r + c].argmax()]
for c, ch in enumerate(cells[4 * r:4 * r + 4])) for r in range(4)))
После решения нажмите «Запустить»: вы увидите долю успехов и стрелки обеих политик. Сравните, где SARSA обходит проруби осторожнее.
Локально: DQN на CartPole
Заголовок раздела «Локально: DQN на CartPole»CartPole — классическая задача: удержать шест на тележке, толкая её влево или вправо. Состояние — 4 числа (положение и
скорость тележки, угол и угловая скорость шеста), награда +1 за каждый шаг, эпизод обрывается на 500 шагах. Нужны
pip install gymnasium и PyTorch из ступени 3. Видеокарта не нужна: сеть крошечная, всё идёт на CPU.
import randomfrom collections import deque
import gymnasium as gymimport numpy as npimport torchimport torch.nn as nn
torch.manual_seed(0)random.seed(0)env = gym.make("CartPole-v1")n_obs, n_act = env.observation_space.shape[0], env.action_space.n # 4 числа состояния, 2 действия
def make_net(): return nn.Sequential(nn.Linear(n_obs, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, n_act))
q_net = make_net() # Q(s, ·; θ) — обучаемtarget_net = make_net() # Q(s, ·; θ⁻) — замороженная копия для целиtarget_net.load_state_dict(q_net.state_dict())opt = torch.optim.Adam(q_net.parameters(), lr=1e-3)buffer = deque(maxlen=50_000) # replay buffer: последние 50 тыс. переходовgamma, batch_size, total_steps = 0.99, 64, 0history = [] # отдачи всех эпизодов
for episode in range(1, 401): s, _ = env.reset(seed=episode) ep_return, done = 0.0, False while not done: eps = max(0.05, 1.0 - total_steps / 10_000) # ε-жадность: от 1.0 к 0.05 за 10 тыс. шагов if random.random() < eps: a = random.randrange(n_act) else: with torch.no_grad(): a = int(q_net(torch.as_tensor(s, dtype=torch.float32)).argmax()) s2, r, terminated, truncated, _ = env.step(a) done = terminated or truncated buffer.append((s, a, r, s2, float(terminated))) # обрыв по лимиту — не конец «по-настоящему» s, ep_return, total_steps = s2, ep_return + r, total_steps + 1
if len(buffer) >= 1000: # учимся на случайном мини-батче из прошлого опыта batch = random.sample(buffer, batch_size) S, A, R, S2, T = (torch.as_tensor(np.array(x), dtype=torch.float32) for x in zip(*batch)) q = q_net(S).gather(1, A.long()[:, None])[:, 0] # Q(s, a) with torch.no_grad(): # цель r + γ max Q_target(s′, ·) target = R + gamma * (1 - T) * target_net(S2).max(1).values loss = nn.functional.smooth_l1_loss(q, target) # Huber loss — устойчивее MSE opt.zero_grad() loss.backward() opt.step() if total_steps % 500 == 0: # раз в 500 шагов обновляем целевую сеть target_net.load_state_dict(q_net.state_dict()) history.append(ep_return) if episode % 50 == 0: print(f"эпизод {episode:3d} шагов всего {total_steps:6d} ε = {eps:.2f} " f"средняя отдача за 50 эпизодов {np.mean(history[-50:]):5.1f}")Вывод (PyTorch 2.x, gymnasium 1.x, один поток CPU; на другой версии числа будут другими, а у DQN — заметно):
эпизод 50 шагов всего 1169 ε = 0.88 средняя отдача за 50 эпизодов 23.4эпизод 100 шагов всего 2173 ε = 0.78 средняя отдача за 50 эпизодов 20.1эпизод 150 шагов всего 4282 ε = 0.57 средняя отдача за 50 эпизодов 42.2эпизод 200 шагов всего 8422 ε = 0.16 средняя отдача за 50 эпизодов 82.8эпизод 250 шагов всего 15982 ε = 0.05 средняя отдача за 50 эпизодов 151.2эпизод 300 шагов всего 25670 ε = 0.05 средняя отдача за 50 эпизодов 193.8эпизод 350 шагов всего 39752 ε = 0.05 средняя отдача за 50 эпизодов 281.6эпизод 400 шагов всего 44813 ε = 0.05 средняя отдача за 50 эпизодов 101.2Пока велико, агент действует почти случайно (отдача около 20). Потом начинается обучение, и к 350-му эпизоду средняя отдача достигает 280. А дальше — провал до 100. Это не ошибка в коде, а известная болезнь DQN: бутстрэп к собственным оценкам, off-policy-данные и нейросеть вместе делают обучение неустойчивым, и выученная политика может «забыться». Лечат меньшим шагом обучения, Double DQN, мягким обновлением целевой сети и сохранением лучшей модели. Сравните с PPO в следующем уроке: на той же задаче он растёт заметно ровнее. Прогон занимает несколько минут на одном ядре CPU.
Обратите внимание на две детали. В буфер пишется terminated, а не done: если эпизод оборван лимитом в 500 шагов,
у состояния есть будущее, и цель должна бутстрэпиться через — та же тонкость встретится в GAE. И (1 - T)
обнуляет будущее только у настоящих конечных состояний (шест упал).
Проверь себя
Заголовок раздела «Проверь себя»- Без модели среды ценность оценивают по опыту. Монте-Карло усредняет фактические отдачи: без смещения, но шумно и только после конца эпизода. TD(0) обновляет после каждого шага по TD-ошибке , опираясь на собственную оценку (бутстрэп): меньше разброс, есть смещение. Между ними — -шаговые оценки.
- Чтобы найти хорошую политику, агент должен исследовать; простейший способ — ε-жадность с убывающим .
- SARSA (on-policy) учит ценность своей ε-жадной политики и осторожнее; Q-learning (off-policy) учит при любом поведении. Off-policy — ключ к обучению на старых данных и причина поправки в PPO.
- DQN заменил таблицу нейросетью и стабилизировал обучение буфером воспроизведения и целевой сетью.
Дальше: методы, которые учат саму политику, а не , — градиент политики, actor-critic, GAE и PPO целиком, и как всё это переносится на языковую модель.