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

Actor-Critic и непрерывные действия

О чём эта тема
Две ветки траектории соединяются: политика-актёр учится по градиенту из конспекта 9, а веса её шагам выставляет критик — функция ценности из конспектов 3–7. TD-ошибка оказывается несмещённой оценкой преимущества, обучение перестаёт ждать конца эпизода, а гауссова политика открывает дорогу к непрерывным действиям — вплоть до DDPG. По конспекту преподавателя и лекции 6 курса «RL: от бандитов до RLHF» (мехмат МГУ).
Аннотация
Конспект начинается с двух хронических болезней REINFORCE — обновление только по концу эпизода и шумный вес \(G_t\) — и лечит обе одним персонажем: критиком. Сначала формализуется преимущество \(A = Q - V\) и доказывается, что его среднее по политике равно нулю; затем — центральная теорема: TD-ошибка одного перехода является несмещённой оценкой преимущества, так что учить нужно одну лишь \(V_\varphi\). Отсюда собирается on-policy алгоритм актёр–критик с обновлением на каждом шаге, обсуждаются его смещение против дисперсии и условия сходимости. Практика — CartPole: критик как обучаемый baseline заметно ускоряет и разглаживает наш собственный REINFORCE из конспекта 9, кривые обоих запусков — на одном графике (включая честную историю о том, как первый запуск коллапсировал). Тренажёр показывает актёра и критика в четыре клетки коридора: видно, как растёт V, как TD-ошибка гонит вероятности к цели. Финал — непрерывные действия: гауссова политика с тремя головами (среднее, дисперсия, ценность), векторные среды для ускорения A2C и DDPG — детерминированный актёр с критикой Q, перенявший у DQN память переходов и целевые сети.
Пререквизиты
Конспект 9 (градиент политики, baseline, REINFORCE), конспект 4 (TD(0), TD-ошибка, смещение против дисперсии), конспект 7 (semi-gradient, проекционное уравнение Беллмана, replay buffer и целевая сеть), конспект 8 (преимущество A = Q − V из Dueling).
Мотивация
REINFORCE узнаёт о качестве действий только с финальным свистком: сыграй эпизод до конца — потом учись. Для CartPole это терпимо, для часовой игры или робота — нет. Вторая беда глубже: вес \(G_t\) шумит всей случайностью будущего, и мы уже знаем лучший успокоитель — вычесть \(V(S_t)\), — но взять её неоткуда. Выход напрашивается сам: у нас полконспекта траектории посвящено тому, как учить функции ценности. Пусть вторая сеть учит \(V\) на лету и подсказывает первой, насколько действие оказалось лучше ожидаемого. Сеть-политика действует — «актёр»; сеть-ценность оценивает — «критик».

1. Преимущество действия

Начнём с величины, которая уже дважды мелькала в траектории — в Dueling-сети (конспект 8, формула (8.2)) и в baseline конспекта 9. Преимущество (англ. advantage) действия \(a\) в состоянии \(s\) при политике \(\pi\):

\[ A^\pi(s, a) = Q^\pi(s, a) - V^\pi(s). \tag{10.1}\]

Это «качество действия за вычетом качества места»: положительное преимущество — действие лучше среднего для этого состояния, отрицательное — хуже. Среднее преимущество по самой политике равно нулю:

\[ \mathbb{E}_{A \sim \pi(\cdot|s)}\bigl[ A^\pi(s, A) \bigr] = \sum_a \pi(a|s) Q^\pi(s,a) - V^\pi(s) = V^\pi(s) - V^\pi(s) = 0, \tag{10.2}\]

где посередине — связь \(V = \sum_a \pi Q\) из конспекта 3 (формула (3.7)). Теперь возьмём градиент политики с baseline (конспект 9, формула (9.9)) и подставим лучший baseline \(b(s) = V^{\pi_\theta}(s)\) — в скобках возникает ровно преимущество:

\[ \nabla_\theta J(\theta) = \mathbb{E} \Bigl[ \sum_{t=0}^{T-1} \gamma^t\, \nabla_\theta \log \pi_\theta(A_t|S_t)\, A^{\pi_\theta}(S_t, A_t) \Bigr]. \tag{10.3}\]

Формула красива и бесполезна в лоб: чтобы знать \(A\), нужно знать и \(Q\), и \(V\) — две неизвестные функции вместо одной. Следующий раздел сводит их к одной, причём к самой дешёвой.

2. TD-ошибка — это и есть преимущество

Вспомним TD-ошибку из конспекта 4 (формула (4.10)), записанную для параметрического критика \(V_\varphi\):

\[ \delta_t = R_{t+1} + \gamma V_\varphi(S_{t+1}) - V_\varphi(S_t). \tag{10.4}\]

Теорема 10.1 (TD-ошибка как оценка преимущества). Пусть критик идеален: \(V_\varphi = V^{\pi_\theta}\). Тогда для любого перехода

\[ \mathbb{E}\bigl[ \delta_t \,\big|\, S_t = s,\, A_t = a \bigr] = A^{\pi_\theta}(s, a). \tag{10.5}\]

Доказательство. Условное ожидание первого и второго слагаемых (10.4) — это \(\mathbb{E}[R_{t+1} + \gamma V^{\pi}(S_{t+1}) \,|\, s, a]\). По определению \(Q^\pi(s,a) = \mathbb{E}[R_{t+1} + \gamma G_{t+1} | s, a]\), а по марковости \(\mathbb{E}[G_{t+1} | s, a] = \mathbb{E}[V^\pi(S_{t+1}) | s, a]\) (усреднение сначала по будущему при данном \(S_{t+1}\), затем по \(S_{t+1}\) — формула повторного ожидания). Значит, первое слагаемое равно \(Q^\pi(s, a)\), и \(\mathbb{E}[\delta_t | s, a] = Q^\pi(s,a) - V^\pi(s) = A^\pi(s,a)\). \(\blacksquare\)

Смысл теоремы трудно переоценить: однопереходная величина — награда плюс перепад оценок критика — в среднем равна преимуществу, для которого формально нужны две полные функции. Достаточно учить одну сеть \(V_\varphi\), а вместо \(A\) в (10.3) подставлять \(\delta_t\):

\[ \nabla_\theta J(\theta) \approx \mathbb{E} \Bigl[ \sum_{t} \gamma^t\, \nabla_\theta \log \pi_\theta(A_t|S_t)\, \delta_t \Bigr]. \tag{10.6}\]

Честная оговорка: критик никогда не идеален, и при \(V_\varphi \ne V^{\pi}\) оценка (10.6) смещена. Это знакомая по конспекту 4 сделка «смещение против дисперсии» — та же, что между TD и Монте-Карло: REINFORCE несмещён и шумен, актёр-критик чуть смещён и спокоен. На практике даже грубый критик снижает дисперсию настолько, что небольшое смещение окупается с лихвой.

3. Алгоритм: актёр и критик учатся вместе

Собираем конструкцию. Актёр — политика \(\pi_\theta(a|s)\), обновляется в сторону роста \(J(\theta)\); критик — функция ценности \(V_\varphi(s)\), приближает \(V^{\pi_\theta}\). Один шаг среды — и оба получают по обновлению:

On-policy Actor-Critic (TD(0)): один такт
  1. разыграть действие \(A_t \sim \pi_\theta(\cdot | S_t)\), получить \(R_{t+1}, S_{t+1}\);
  2. вычислить TD-ошибку \(\delta_t = R_{t+1} + \gamma V_\varphi(S_{t+1}) - V_\varphi(S_t)\);
  3. обновить критика (TD(0), конспект 4): \(\varphi \leftarrow \varphi + \alpha_\varphi\, \delta_t \nabla_\varphi V_\varphi(S_t)\);
  4. обновить актёра (градиент политики): \(\theta \leftarrow \theta + \alpha_\theta\, \gamma^t \delta_t \nabla_\theta \log \pi_\theta(A_t | S_t)\).

Сравните с REINFORCE: там цикл начинался словами «сыграть полный эпизод», здесь обучение идёт на каждом шаге — как в TD-методах, и это не совпадение: шаг 3 — дословно полуградиентный TD(0) из конспекта 7, со всеми его свойствами. При фиксированном актёре критик — стохастическая аппроксимация оператора Беллмана и при линейной аппроксимации сходится к решению проекционного уравнения (конспект 7, формула (7.5)).

Слова «при фиксированном актёре» — не мелкий шрифт. На практике оба обновляются одновременно, и строгие гарантии требуют разделения масштабов времени: критик должен подстраиваться существенно быстрее, чем меняется актёр, — иначе актёр учится по оценкам вчерашней политики. Отсюда практическое правило: скорость обучения критика берут больше, чем актёра. Для нейросетей точных теорем нет, но конструкция устойчиво работает.

Типичная ошибка Пропускают градиент через критика в весе актёра: пишут advantage = returns - values без values.detach() и суммируют потери актёра и критика. Тогда «вес» при \(\log \pi\) сам получает градиент, и актёрская часть ошибки начинает подгонять критика под действия вместо оценки политики. Вес — константа для градиента актёра: только detach() (или no_grad).

4. Практика: критик ускоряет наш REINFORCE

Проверим обещание «дисперсия упадёт — обучение ускорится» на собственном опыте. Возьмём REINFORCE с CartPole из конспекта 9 и добавим критика — вторую сеть, обучаемую приближать возврат; вес шага актёра сменится с нормированного \(G_t\) на преимущество \(G_t - V_\varphi(S_t)\) (вариант конспекта преподавателя: в роли \(Q\) — реальный возврат эпизода). Ядро кода:

actor  = nn.Sequential(nn.Linear(nS, 128), nn.ReLU(), nn.Linear(128, nA))
critic = nn.Sequential(nn.Linear(nS, 128), nn.ReLU(), nn.Linear(128, 1))

# ... эпизод сыгран: log_probs, values, returns посчитаны ...
advantage   = returns - values.detach()          # A = G - V; detach обязателен!
actor_loss  = -(torch.stack(log_probs) * advantage).sum()
critic_loss = ((returns - values) ** 2).sum()     # критик приближает возврат

Оба запуска — из этой траектории, одинаковые сети 128 нейронов, одинаковый порог «решено» (средняя длина 475):

Две кривые обучения на CartPole: актёр-критик достигает порога заметно раньше REINFORCE, и его кривая менее шумная
CartPole: REINFORCE (конспект 9) против актёра-критика — наши запуски при подготовке конспектов

Актёр-критик решил задачу на 543-м эпизоде против 625-го у REINFORCE, и по сглаженным кривым видно главное: он раньше выходит из «плато случайности» и растёт ровнее (разброс длин эпизодов на среднем участке обучения — 156 против 174 шагов). Ложка дёгтя из собственного опыта: первый запуск актёра-критика с равными скоростями обучения обеих сетей (0,01) к 850-му эпизоду коллапсировал — политика выродилась, длина упала до девяти шагов. Причина — нарушение правила из раздела 3: актёр менялся быстрее, чем критик успевал его оценивать. Лечение — снизить скорость актёра впятеро (0,002 против 0,01 у критика) и нормировать преимущество по эпизоду; с этими поправками кривая выше. Правило масштабов времени — не теоретическая вежливость, а условие выживания.

Полный код для проектов: актёр-критик на CartPole (PyTorch + Gymnasium, один файл)
# a2c_cartpole.py — актёр-критик с преимуществом (G - V) на CartPole-v1.
# Актёр и критик — отдельные сети; advantage по конспекту: вес log pi = G_t - V(s_t).
import numpy as np
import torch
import torch.nn as nn
from torch.distributions import Categorical
import gymnasium as gym

gamma = 0.99
env = gym.make("CartPole-v1")
torch.manual_seed(1); np.random.seed(1)
nS, nA = env.observation_space.shape[0], env.action_space.n

actor = nn.Sequential(                      # политика: логиты действий
    nn.Linear(nS, 128), nn.ReLU(), nn.Linear(128, nA))
critic = nn.Sequential(                     # критик: скалярная ценность V(s)
    nn.Linear(nS, 128), nn.ReLU(), nn.Linear(128, 1))
opt_actor = torch.optim.Adam(actor.parameters(), lr=0.002)   # актёр медленнее критика!
opt_critic = torch.optim.Adam(critic.parameters(), lr=0.01)

running, history = 10.0, []
for episode in range(1, 1001):
    state, _ = env.reset(seed=episode)
    log_probs, values, rewards = [], [], []
    for t in range(1, 1001):
        s = torch.from_numpy(state).float()
        c = Categorical(logits=actor(s))
        a = c.sample()
        log_probs.append(c.log_prob(a))
        values.append(critic(s).squeeze())
        state, r, term, trunc, _ = env.step(int(a))
        rewards.append(r)
        if term or trunc: break
    # возвраты G_t
    R, returns = 0.0, []
    for r in rewards[::-1]:
        R = r + gamma * R
        returns.insert(0, R)
    returns = torch.tensor(returns)
    values = torch.stack(values)
    advantage = returns - values.detach()          # A = G - V (критик как baseline)
    advantage = (advantage - advantage.mean()) / (advantage.std() + 1e-8)
    actor_loss = -(torch.stack(log_probs) * advantage).sum()
    critic_loss = ((returns - values) ** 2).sum()  # критик учится приближать G
    opt_actor.zero_grad(); actor_loss.backward(); opt_actor.step()
    opt_critic.zero_grad(); critic_loss.backward(); opt_critic.step()
    history.append(t)
    running = running * 0.99 + t * 0.01
    if episode % 50 == 0:
        print(f"эпизод {episode:4d}  длина {t:4d}  средняя {running:6.1f}", flush=True)
    if running > 475:
        print(f"Решено на эпизоде {episode}: средняя длина {running:.1f}")
        break

np.save(r"C:\Users\Evgenie\AppData\Local\Temp\a2c_history.npy", np.array(history))

5. Тренажёр: актёр и критик в четыре клетки

Вся механика видна на задаче размером с ладонь: коридор из пяти клеток, старт слева, выход справа, каждый шаг стоит −1 — чем быстрее к выходу, тем лучше. Актёр — по логиту на клетку (вероятность шага вправо через сигмоиду), критик — по числу \(V\) на клетку. Обе таблички обновляются по алгоритму раздела 3, и всё происходящее печатается в журнал: \(\delta_t\) каждого шага, рост \(V\), сдвиг вероятностей.

Тренажёр: TD-ошибка учит обоих
● — агент (ходит в анимации) · стрелка — π(вправо) актёра · красный столбик — V критика (растёт вниз), чёрная риска на нём — эталон \(-(1-\gamma^d)/(1-\gamma)\) · вспышка клетки — знак δ: зелёная лучше ожиданий, красная хуже · γ = 0,95, α актёра 0,2, критика 0,3

За чем смотреть. Запустите анимированный эпизод: синий агент бродит по коридору, и каждая посещённая клетка вспыхивает цветом TD-ошибки — красным, когда переход хуже ожиданий критика, зелёным, когда лучше. Первые эпизоды почти сплошь красные: критик наивно ждёт нуля, а каждый шаг стоит −1, — столбики V растут вниз, к чёрным рискам-эталонам \(-(1-\gamma^{d})/(1-\gamma)\) точного решения. Нажмите «+10 мгновенно» пару раз: столбики доросли до рисок, вспышки позеленели у выхода, и стрелки политики вытянулись вправо — сначала в клетке 3, потом всё глубже. Это и есть механика раздела 3 в замедленной съёмке: критик проявляет структуру задачи, которой REINFORCE с его «одним возвратом на весь эпизод» не видит, — и передаёт её актёру через δ каждого шага.

6. Непрерывные действия: гауссова политика

До сих пор действий было конечное число, и сеть актёра заканчивалась softmax. Пусть теперь действие — вектор из \(m\) вещественных чисел: моменты на суставах робота, угол руля, крутящий момент маятника. Softmax по континууму не построишь, но политике и не нужно перечислять действия — достаточно уметь разыгрывать их и вычислять \(\log \pi\). Классический выбор — нормальное распределение по каждой компоненте:

\[ \pi(a\,|\,s) = \prod_{k=1}^{m} \frac{1}{\sqrt{2\pi D_k}} \exp\Bigl[ -\frac{(a_k - \mu_k)^2}{2 D_k} \Bigr], \tag{10.7}\]

где средние \(\mu_k(s)\) и дисперсии \(D_k(s)\) выдаёт сеть. Логарифм политики — в градиент он входит напрямую — считается в одну строку:

\[ \log \pi(a\,|\,s) = -\sum_{k=1}^{m} \Bigl[ \frac{(a_k - \mu_k)^2}{2 D_k} + \log \sqrt{2 \pi D_k} \Bigr]. \tag{10.8}\]

Сеть из конспекта преподавателя — общий ствол признаков и три головы: средние, дисперсии и ценность (актёр и критик в одном теле, как экстрактор Dueling в конспекте 8):

class ModelA2C(nn.Module):
    def __init__(self, n, m, h):       # n - состояние, m - действие, h - скрытых
        super().__init__()
        self.base = nn.Sequential(     # общие признаки состояния
            nn.Linear(n, h),
            nn.ReLU() )
        self.mu = nn.Sequential(       # средние действий: симметричный диапазон
            nn.Linear(h, m),
            nn.Tanh() )
        self.var = nn.Sequential(      # дисперсии: положительные -> Softplus
            nn.Linear(h, m),
            nn.Softplus() )
        self.value = nn.Linear(h, 1)   # ценность: любая -> без активации

    def forward(self, x):
        y = self.base(x)
        return self.mu(y), self.var(y), self.value(y)

Выбор активаций — маленький урок проектирования голов: \(\tanh\) на средних, потому что компоненты действий живут в симметричных пределах; Softplus («гладкий ReLU») на дисперсиях, потому что они обязаны быть положительными; ценность — без активации, ей можно всё. Заметьте, что дисперсия — обучаемая: политика сама решает, где действовать уверенно, а где продолжать разведку — стохастичность политики опять работает вместо ε-жадности.

Маятник Pendulum крутится под случайными моментами, не в силах встать вертикально
Pendulum-v1 — простейшая среда с непрерывным действием: момент \(a \in [-2, 2]\). Случайная политика; снято живым запуском среды
Полный код для проектов: актёр-критик с гауссовой политикой на Pendulum-v1
# a2c_pendulum.py — актёр-критик с гауссовой политикой на Pendulum-v1.
# Сеть ModelA2C по конспекту: общий ствол, головы mu (Tanh), var (Softplus), value.
import numpy as np
import torch
import torch.nn as nn
from torch.distributions import Normal
import gymnasium as gym

gamma = 0.9                       # короткий горизонт Pendulum
env = gym.make("Pendulum-v1")
torch.manual_seed(2); np.random.seed(2)
nS = env.observation_space.shape[0]     # 3: cos, sin, скорость
nA = env.action_space.shape[0]          # 1: крутящий момент
A_MAX = float(env.action_space.high[0]) # 2.0

class ModelA2C(nn.Module):
    def __init__(self, n, m, h=128):
        super().__init__()
        self.base = nn.Sequential(nn.Linear(n, h), nn.ReLU())
        self.mu = nn.Sequential(nn.Linear(h, m), nn.Tanh())      # средние: [-1,1]
        self.var = nn.Sequential(nn.Linear(h, m), nn.Softplus()) # дисперсии > 0
        self.value = nn.Linear(h, 1)

    def forward(self, x):
        y = self.base(x)
        return self.mu(y), self.var(y), self.value(y)

model = ModelA2C(nS, nA)
optimizer = torch.optim.Adam(model.parameters(), lr=0.0005)

history = []
for episode in range(1, 3001):
    state, _ = env.reset(seed=episode)
    log_probs, values, rewards, entropies = [], [], [], []
    total = 0.0
    for t in range(200):
        s = torch.from_numpy(state).float()
        mu, var, v = model(s)
        dist = Normal(mu * A_MAX, torch.sqrt(var) + 1e-5)   # политика (10.7)
        a = dist.sample()
        log_probs.append(dist.log_prob(a).sum())            # log pi (10.8)
        entropies.append(dist.entropy().sum())
        values.append(v.squeeze())
        state, r, term, trunc, _ = env.step(np.clip(a.numpy(), -A_MAX, A_MAX))
        rewards.append(r / 8.0)                             # масштаб награды
        total += r
        if term or trunc: break
    R, returns = 0.0, []
    for r in rewards[::-1]:
        R = r + gamma * R
        returns.insert(0, R)
    returns = torch.tensor(returns, dtype=torch.float32)
    values = torch.stack(values)
    adv = returns - values.detach()
    adv = (adv - adv.mean()) / (adv.std() + 1e-8)
    loss = (-(torch.stack(log_probs) * adv).sum()
            + 0.5 * ((returns - values) ** 2).sum()
            - 0.001 * torch.stack(entropies).sum())          # бонус разведки
    optimizer.zero_grad(); loss.backward()
    nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    optimizer.step()
    history.append(total)
    if episode % 200 == 0:
        print(f"эпизод {episode:5d}  средний возврат за 100: {np.mean(history[-100:]):8.1f}", flush=True)

np.save(r"C:\Users\Evgenie\AppData\Local\Temp\pend_history.npy", np.array(history))
torch.save(model.state_dict(), r"C:\Users\Evgenie\AppData\Local\Temp\pend_model.pt")

Код честный, и результат его — тоже честный: за 3000 эпизодов средний возврат остаётся около −1450 при случайных −1400…−1600, то есть ванильный актёр-критик Pendulum не решает (мы гоняли его при подготовке конспекта — и это известное свойство, а не опечатка в коде). Причины уже разобраны в этой траектории: награда густая, но информативный сигнал — редкие удачные раскачки; on-policy метод видит каждый опыт один раз; дисперсия гауссовой политики схлопывается раньше, чем найдена вертикаль. Именно на таких задачах и окупаются DDPG (раздел 7) с его памятью переходов — и PPO, который в следующем конспекте доведёт этот самый маятник до вертикали.

Практическая деталь из туториала Gymnasium «Speeding up A2C»: on-policy методы любят векторные среды — несколько копий среды шагают параллельно (gym.make_vec("LunarLander-v3", num_envs=3)), актёр получает батч состояний и выдаёт батч действий. Больше независимых переходов на обновление — ниже дисперсия градиента, та же идея, что случайный батч из памяти DQN, но без нарушения on-policy: все переходы свежие. Там же показана доменная рандомизация — параллельные копии среды с разной гравитацией и ветром дают более живучую политику.

7. DDPG: детерминированный актёр и критик Q

Гауссов актёр — не единственный путь в непрерывность. Конспект преподавателя предлагает взглянуть на проблему с другой стороны: дискретизировать непрерывное действие нельзя (10 степеней свободы по 4 уровня — уже \(4^{10} = 1\,048\,576\) действий, сходимости не дождаться), но можно сделать политику детерминированной функцией \(a = \pi_\theta(s)\) — сеть просто выдаёт вектор действия. Так устроен DDPG (Deep Deterministic Policy Gradient):

Детерминированность разом лишает метод встроенной разведки — политика всегда выдаёт одно и то же. Лекарство из конспекта преподавателя: добавлять шум — в действия или прямо в параметры сети. А поскольку детерминированная политика с шумом фактически off-policy, DDPG свободно заимствует у DQN оба стабилизатора из конспекта 7: память переходов и целевые сети (по паре на актёра и критика). Получается гибрид двух веток траектории: скелет обучения — от DQN, градиент политики — от актёра-критика. Развитие этой линии — TD3 (твин-критики против переоценки — привет от Double DQN) — можно попробовать в туториале «Lunar Lander with TD3» из материалов курса.

A2C (разделы 1–6)DDPG
политикастохастическая \(\pi_\theta(a|s)\) детерминированная \(a = \pi_\theta(s)\)
критик\(V_\varphi(s)\)\(Q_\varphi(s, a)\)
режим данныхon-policy (свежие эпизоды) off-policy (память переходов)
разведкавстроена в розыгрышвнешний шум
наследство DQNreplay buffer, целевые сети

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

Источники

  1. Mnih, V. Asynchronous Methods for Deep Reinforcement Learning / V. Mnih, A. P. Badia, M. Mirza [et al.] // arXiv. — 2016. — URL: https://arxiv.org/abs/1602.01783 (дата обращения: 08.07.2026).
  2. Lillicrap, T. P. Continuous control with deep reinforcement learning / T. P. Lillicrap, J. J. Hunt, A. Pritzel [et al.] // arXiv. — 2015. — URL: https://arxiv.org/abs/1509.02971 (дата обращения: 08.07.2026).
  3. Speeding Up A2C Training with Vector Envs — Gymnasium Documentation : [сайт]. — URL: https://gymnasium.farama.org/tutorials/training_agents/vector_a2c.html (дата обращения: 08.07.2026).