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

Policy Gradient и REINFORCE

О чём эта тема
Новая ветка методов: политика перестаёт быть побочным продуктом Q-функции и становится самостоятельной нейросетью, которую мы оптимизируем напрямую — градиентным подъёмом по ожидаемому возврату. Полный вывод градиента политики с доказательствами, метод REINFORCE с работающим кодом на CartPole и честный разбор того, где метод ломается. По конспекту преподавателя и лекции 5 курса «RL: от бандитов до RLHF» (мехмат МГУ).
Аннотация
Конспект начинается с вопроса, зачем оптимизировать политику напрямую, когда пять конспектов подряд хватало Q-функции: жадный максимум ломается на непрерывных и огромных дискретных пространствах действий. Затем строится сеть политики — softmax-вероятности действий и способы сэмплирования из них — и почти без математики угадывается функция ошибки: кросс-энтропия, взвешенная возвратом. Дальше догадка превращается в теорему: вероятность траектории, likelihood-ratio приём, лемма о том, что прошлые награды не зависят от текущего действия (отсюда reward-to-go), теорема о градиенте политики через Q-функцию и распределение посещений, наконец baseline — вычитание опоры, не смещающее градиент, но снижающее дисперсию. Всё это собирается в алгоритм REINFORCE, который на глазах обучается в тренажёре (softmax-бандит) и в коде ноутбука курса решает CartPole. Финал — пределы метода: высокая дисперсия, прожорливость к эпизодам и неспособность решить MountainCar — мостик к актёру-критику следующего конспекта.
Пререквизиты
Конспект 1 (траектория, возврат), конспект 3 (V и Q, марковость), конспект 4 (Монте-Карло оценка, дисперсия против смещения), конспект 8 (пределы value-методов). Из «Нейросетей»: конспект 1 (softmax), конспект 2 (градиентный спуск, кросс-энтропия).
Мотивация
Всю траекторию политика была тенью Q-функции: выучи ценности — и бери максимум. Но продолжите мысль конспекта 8: у CarRacing с непрерывным рулём \(\arg\max_a Q(s,a)\) — отдельная задача оптимизации на каждый шаг; у языковой модели «действие» — следующий токен из словаря в десятки тысяч слов, и надёжно оценить Q каждого токена немыслимо. Естественнее выучить само распределение «что делать»: сеть, которая по состоянию выдаёт вероятности действий. Осталось понять, по чему брать градиент — награда ведь приходит от среды, через которую продифференцироваться нельзя. Оказывается, можно обойтись и без этого — в том и главный фокус конспекта.

1. Сеть политики

Пусть состояние — вектор из \(n\) вещественных чисел, действий — конечное множество из \(m\) штук. Политику \(\pi_\theta(a\,|\,s)\) — условную вероятность действия — аппроксимируем нейросетью с параметрами \(\theta\): \(n\) входов, \(m\) выходов-вероятностей. Знакомая по «Нейросетям» конструкция многоклассовой классификации, только «класс» — это действие, а разметки нет: правильных действий нам никто не скажет.

model = nn.Sequential(
    nn.Linear(nS, nH),        # nS - размерность состояния
    nn.ReLU(),
    nn.Linear(nH, nA),        # nA - число действий; выходы - логиты
)

def run_model(state):          # одно наблюдение или батч
    return model( torch.Tensor(state).view(-1, nS) )

Действие не выбирается жадно — оно разыгрывается по вероятностям: политика сама стохастическая, и разведка встроена в неё (ε-жадность больше не нужна). Конспект преподавателя приводит несколько равносильных способов розыгрыша; самый удобный — распределение Categorical, принимающее сырые логиты:

from torch.distributions.categorical import Categorical

def policy(state):
    return Categorical( logits=run_model(state) ).sample()

# эквивалентно: softmax + multinomial
def policy(state):
    probs = torch.softmax( run_model(state), 1)
    return torch.multinomial(probs, 1)

# а если действий меньше, чем в среде (MountainCar: action_space=[0,2]):
def policy(state):
    probs = torch.softmax( run_model(state), 1).numpy()
    return np.random.choice(action_space, p=probs)
Типичная ошибка Ставят на выход сети сигмоиду и подают её в multinomial. Работать будет (multinomial сам нормирует положительные числа), но это не softmax: вероятности искажаются, а на больших логитах сигмоида насыщается и градиент затухает. Правильно — логиты без активации и Categorical(logits=...) либо явный softmax.

2. Идея: кросс-энтропия, взвешенная возвратом

Чему учить такую сеть, если правильных действий нет? Есть кое-что взамен — итог игры. Сыграем текущей политикой \(M\) эпизодов, запишем каждую тройку «состояние, сделанное действие, суммарная награда эпизода» \((s^{(k)}, a^{(k)}, R^{(k)})\) и минимизируем

\[ L = -\sum_k R^{(k)} \log p^{(k)}, \qquad p^{(k)} = \pi_\theta\bigl(a^{(k)} \,|\, s^{(k)}\bigr). \tag{9.1}\]

Сравните с кросс-энтропией многоклассовой классификации («Нейросети», конспект 3): там максимизировался логарифм вероятности правильного класса, здесь — логарифм вероятности сделанного действия, взятый с весом \(R^{(k)}\). Действия из удачных эпизодов усиливаются сильнее, из провальных — слабее (а при отрицательных наградах — подавляются). Это и есть семейство методов градиента политики (англ. policy gradient). Почему в (9.1) стоит именно логарифм — пока принято на веру; сейчас выведем это честно, заодно найдя у наивной формулы два изъяна.

3. Вывод: градиент, который не трогает среду

Зафиксируем эпизодическую задачу конечной длины \(T\). Траектория — \(\tau = (S_0, A_0, R_1, S_1, A_1, R_2, \ldots, S_{T-1}, A_{T-1}, R_T, S_T)\); её вероятность при политике \(\pi_\theta\) складывается из распределения старта, политики и динамики среды:

\[ p_\theta(\tau) = \rho_0(S_0) \prod_{t=0}^{T-1} \pi_\theta(A_t | S_t)\, P(S_{t+1}, R_{t+1} \,|\, S_t, A_t). \tag{9.2}\]

Максимизируем ожидаемый дисконтированный возврат:

\[ J(\theta) = \mathbb{E}_{\tau \sim p_\theta} \Bigl[ \sum_{t=0}^{T-1} \gamma^t R_{t+1} \Bigr] \;\to\; \max_\theta. \tag{9.3}\]

3.1. Приём likelihood-ratio

Теорема 9.1 (градиент через траектории). Пусть \(\pi_\theta\) дифференцируема по \(\theta\) и её носитель не зависит от \(\theta\). Тогда

\[ \nabla_\theta J(\theta) = \mathbb{E}_{\tau \sim p_\theta} \Bigl[ \Bigl( \sum_{t=0}^{T-1} \gamma^t R_{t+1} \Bigr) \sum_{t=0}^{T-1} \nabla_\theta \log \pi_\theta(A_t | S_t) \Bigr]. \tag{9.4}\]

Доказательство. Горизонт конечен — дифференцируем под знаком суммы: \(\nabla_\theta J = \sum_\tau \nabla_\theta p_\theta(\tau) \cdot R(\tau)\). Применяем тождество \(\nabla_\theta p_\theta = p_\theta \nabla_\theta \log p_\theta\) (верное всюду, где \(p_\theta > 0\)) — сумма снова становится ожиданием по \(p_\theta\). Осталось взять логарифм (9.2):

\[ \log p_\theta(\tau) = \log \rho_0(S_0) + \sum_{t=0}^{T-1} \log \pi_\theta(A_t|S_t) + \sum_{t=0}^{T-1} \log P(S_{t+1}, R_{t+1}|S_t, A_t), \]

где первый и третий члены от \(\theta\) не зависят и при дифференцировании исчезают. \(\blacksquare\)

Вот и обещанный фокус: динамика среды \(P\) выпала из градиента до того, как нам понадобилось её знать. Среда может оставаться чёрным ящиком — достаточно уметь в ней играть и дифференцировать собственную политику. Заодно легализован логарифм из (9.1): он пришёл из \(\nabla p = p\,\nabla\log p\), а не из аналогии с кросс-энтропией.

3.2. Причинность: прошлое не зависит от текущего действия

Формула (9.4) неэкономична: полный возврат эпизода умножается на градиенты всех действий — в том числе ранних, которые никак не могли повлиять на уже полученные награды. Интуиция подсказывает, что награды до момента \(t\) из веса действия \(A_t\) можно убрать. Докажем.

Лемма 9.1. Для любого \(t\):

\[ \mathbb{E}_{\tau \sim p_\theta} \Bigl[ \nabla_\theta \log \pi_\theta(A_t|S_t) \sum_{k=0}^{t-1} \gamma^k R_{k+1} \Bigr] = 0. \tag{9.5}\]

Доказательство. Прошлая сумма \(C_t = \sum_{k<t} \gamma^k R_{k+1}\) определяется историей до момента \(t\) и от \(A_t\) не зависит. По формуле повторного ожидания достаточно показать, что \(\mathbb{E}[\nabla_\theta \log \pi_\theta(A_t|S_t) \,|\, S_t = s] = 0\):

\[ \sum_a \pi_\theta(a|s) \nabla_\theta \log \pi_\theta(a|s) = \sum_a \nabla_\theta \pi_\theta(a|s) = \nabla_\theta \sum_a \pi_\theta(a|s) = \nabla_\theta 1 = 0. \tag{9.6}\]

\(\blacksquare\) Тождество (9.6) — «сумма вероятностей равна единице, градиент единицы — ноль» — ещё дважды сработает ниже; запомните его.

Теорема 9.2 (форма reward-to-go). Обозначив возврат с момента \(t\) как \(G_t = \sum_{k \ge t} \gamma^{k-t} R_{k+1}\) (конспект 1, формула (1.2)), имеем

\[ \nabla_\theta J(\theta) = \mathbb{E}_{\tau \sim p_\theta} \Bigl[ \sum_{t=0}^{T-1} \gamma^t\, \nabla_\theta \log \pi_\theta(A_t | S_t)\, G_t \Bigr]. \tag{9.7}\]

Доказательство. В (9.4) разбиваем полный возврат при каждом \(t\) на прошлое \(C_t\) и будущее \(\gamma^t G_t\); слагаемые с \(C_t\) зануляет лемма 9.1. \(\blacksquare\)

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

3.3. Теорема о градиенте политики

Последний шаг — заменить в (9.7) случайный \(G_t\) его условным ожиданием: по определению (конспект 3, формула (3.5)) \(\mathbb{E}[G_t | S_t, A_t] = Q^{\pi_\theta}(S_t, A_t)\). Собирая слагаемые по состояниям, получаем классическую формулировку.

Теорема 9.3 (Policy Gradient Theorem). Пусть \(d^\gamma_{\pi_\theta}(s) = \sum_t \gamma^t \Pr(S_t = s)\) — дисконтированная мера посещения состояний. Тогда

\[ \nabla_\theta J(\theta) = \sum_{s} d^\gamma_{\pi_\theta}(s) \sum_{a} \nabla_\theta \pi_\theta(a|s)\, Q^{\pi_\theta}(s, a). \tag{9.8}\]

Концептуальный итог: градиент качества политики выражен через локальные величины — «куда сдвинуть вероятности в состоянии \(s\)» и «сколько стоит действие», — и нигде не дифференцирует среду. Постоянно посещаемые состояния влияют на градиент сильнее редких: политика улучшается прежде всего там, где живёт.

3.4. Baseline: сдвиг, который ничего не смещает

Оценки (9.7) корректны, но дисперсия их велика. Классическое средство — вычесть из возврата опору (англ. baseline) \(b(s)\), зависящую только от состояния:

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

Математическое ожидание не изменилось: добавка \(\mathbb{E}[\nabla \log \pi_\theta \cdot b(s)]\) равна нулю — \(b\) выносится за ожидание по действию, и остаётся тождество (9.6). А вот дисперсия от \(b\) зависит, и удачный выбор (среднее \(R\) по батчу, а лучше всего — оценка ценности состояния \(V(S_t)\), тогда в скобках возникает знакомое по конспекту 8 преимущество \(G_t - V(S_t)\)) снижает её в разы. Из этой скобки в следующем конспекте вырастет актёр-критик.

4. REINFORCE

Алгоритм REINFORCE (Уильямс, 1992) — это формула (9.9), исполненная по Монте-Карло, как в конспекте 4: играем эпизод до конца, считаем реальные \(G_t\), делаем градиентный шаг.

REINFORCE: один цикл
  1. сыграть полный эпизод текущей стохастической политикой \(\pi_\theta\);
  2. для каждого шага \(t\) вычислить reward-to-go \(G_t = \sum_{k \ge t} \gamma^{k-t} R_{k+1}\);
  3. обновить \(\theta \leftarrow \theta + \alpha\, \gamma^t (G_t - b)\, \nabla_\theta \log \pi_\theta(A_t|S_t)\) по всем \(t\);
  4. повторять с шага 1 — данные каждый раз собираются заново (on-policy!).

Обратите внимание на слово on-policy: формулы раздела 3 верны для траекторий, разыгранных текущей политикой. После каждого шага обучения старые эпизоды становятся чужими — память переходов из DQN здесь неприменима, опыт потребляется один раз. Это честная цена за прямую оптимизацию политики.

5. Тренажёр: REINFORCE на двуруком бандите

Чтобы увидеть механику голыми глазами, возьмём наименьшую задачу, где она работает, — softmax-бандита из лекции: одно состояние, два действия, политика \(\pi_\theta(a{=}i) = e^{\theta_i} / (e^{\theta_1} + e^{\theta_2})\). Градиент логарифма считается вручную: \(\partial \log \pi_\theta(a{=}i) / \partial \theta_j = \mathbf{1}[i{=}j] - \pi_\theta(a{=}j)\). Средние награды рычагов задаются ползунками (агент их не знает и видит только зашумлённые выплаты); эпизод — один ход, поэтому \(G_t\) — это просто полученная награда.

Тренажёр: политика на глазах сдвигается к удачным действиям
кривая: вероятность π(рычаг 2) по шагам · пунктир — 0,5 · шум наград — гауссов, σ = 1

Три опыта, которые стоит проделать. Первый: при наградах 1 против 2 вероятность лучшего рычага растёт к единице — политика «подталкивается» к удачным выборам, в точности по формуле шага 3. Второй: сделайте награды равными — вероятности блуждают вокруг 0,5, градиент в среднем нулевой, но шаг за шагом политика может надолго «залипать» у краёв: это и есть дисперсия Монте-Карло оценки. Третий: включите baseline при наградах 1 и 2 — кривая становится заметно глаже: вычитание среднего убирает из веса общий уровень наград, оставляя только их разницу.

6. Практика: CartPole решается напрямую

Теперь настоящая среда. В CartPole (конспект 1) REINFORCE особенно удачлив, и понятно почему: чем дольше эпизод, тем больше возврат, — уже сама длина эпизода отделяет хорошие розыгрыши от плохих; к тому же оптимальная политика в этой задаче почти линейна по состоянию, и маленькой сети достаточно. Ноутбук курса собирает REINFORCE в полсотни строк:

class Policy(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(nS, 128, bias=False),
            nn.Dropout(p=0.6),          # dropout заметно улучшает политику
            nn.ReLU(),
            nn.Linear(128, nA, bias=False),
            nn.Softmax(dim=-1)          # выход - вероятности действий
        )

Выбор действия — розыгрыш из Categorical с запоминанием логарифма вероятности (он понадобится в градиенте):

def select_action(state, log_probs):
    c = Categorical( policy(torch.from_numpy(state).float()) )
    action = c.sample()
    log_probs.append( c.log_prob(action) )   # log pi(a|s) - в историю эпизода
    return int(action)

Сердце метода — обновление по концу эпизода. Reward-to-go считается одним проходом с конца (тот же приём, что в MC-оценке конспекта 4), а нормировка возвратов по батчу — это baseline «среднее по эпизоду» плюс масштабирование к единичной дисперсии:

def update_policy(log_probs, rewards_episode):
    R, returns = 0.0, []
    for r in rewards_episode[::-1]:        # дисконтируем с конца эпизода
        R = r + gamma * R
        returns.insert(0, R)               # G_t для каждого шага t
    returns = torch.tensor(returns)
    returns = (returns - returns.mean()) / (returns.std() + 1e-8)  # baseline
    loss = -(torch.stack(log_probs) * returns).sum()   # формула (9.9)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

Мы прогнали этот код при подготовке конспекта: задача решается — средняя длина эпизода превышает порог 475 из 500 возможных. Слева кривая обучения этого запуска, справа — обученная политика в деле:

Кривая обучения REINFORCE на CartPole: длина эпизода растёт с сильными колебаниями от 20 до 500
Длина эпизода по ходу обучения (наш запуск кода ноутбука). Колебания — фирменный почерк REINFORCE
Обученная политика удерживает стержень вертикально, тележка мелко подруливает
Обученная политика: стержень стоит. Снято живым запуском среды

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

Тренажёр: обученная политика против вас и случайности
политика:
ручное управление:
физика среды CartPole-v1 воспроизведена точно (шаг 0,02 с); пунктир — границы |x| ≤ 2,4; эпизод — до 500 шагов; стрелки клавиатуры тоже работают

Поиграйте за все три политики. Случайная падает за пару десятков шагов — вы, скорее всего, продержитесь дольше, но 500 шагов руками не выстоять: тележка уползает за границу быстрее, чем кажется. Обученная сеть держит стержень все 500 шагов мелкими симметричными подруливаниями — и обратите внимание на её вероятности: вдали от беды они близки к 50/50 (какая разница, куда шагнуть, когда всё стабильно), но стоит стержню накрениться — распределение резко перекашивается в сторону спасающего действия. Это выученная стохастическая политика из раздела 1, работающая у вас на глазах.

Полный код для проектов: REINFORCE на CartPole (PyTorch + Gymnasium, один файл)
# reinforce_cartpole.py — REINFORCE (Monte-Carlo policy gradient) на CartPole-v1.
# По ноутбуку курса; API Gymnasium. Один файл: обучение, кривая, сохранение весов.
import numpy as np
import torch
import torch.nn as nn
from torch.distributions import Categorical
import gymnasium as gym

learning_rate = 0.01
gamma = 0.99
env = gym.make("CartPole-v1")
torch.manual_seed(1); np.random.seed(1)

class Policy(nn.Module):
    def __init__(self):
        super().__init__()
        nS = env.observation_space.shape[0]      # 4 компоненты состояния
        nA = env.action_space.n                  # 2 действия: влево/вправо
        self.net = nn.Sequential(
            nn.Linear(nS, 128, bias=False),
            nn.Dropout(p=0.6),                   # dropout заметно улучшает политику
            nn.ReLU(),
            nn.Linear(128, nA, bias=False),
            nn.Softmax(dim=-1)                   # выход - вероятности действий
        )

    def forward(self, x):
        return self.net(x)

policy = Policy()
optimizer = torch.optim.Adam(policy.parameters(), lr=learning_rate)

def select_action(state, log_probs):
    """Выбор действия по вероятностям политики; логарифм вероятности - в историю."""
    state = torch.from_numpy(state).float()
    c = Categorical(policy(state))
    action = c.sample()
    log_probs.append(c.log_prob(action))
    return int(action)

def update_policy(log_probs, rewards_episode):
    """Шаг REINFORCE по формуле (9.9): reward-to-go + нормировка (baseline)."""
    R, returns = 0.0, []
    for r in rewards_episode[::-1]:              # дисконтируем с конца эпизода
        R = r + gamma * R
        returns.insert(0, R)                     # G_t для каждого шага
    returns = torch.tensor(returns)
    returns = (returns - returns.mean()) / (returns.std() + 1e-8)  # baseline+масштаб
    loss = -(torch.stack(log_probs) * returns).sum()
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

running, history = 10.0, []
for episode in range(1, 1001):
    state, _ = env.reset(seed=episode)
    log_probs, rewards_episode = [], []
    for t in range(1, 1001):
        action = select_action(state, log_probs)
        state, reward, terminated, truncated, _ = env.step(action)
        rewards_episode.append(reward)
        if terminated or truncated:
            break
    update_policy(log_probs, rewards_episode)
    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:                            # порог "решённости" CartPole-v1
        print(f"Решено на эпизоде {episode}: средняя длина {running:.1f}")
        break

torch.save(policy.state_dict(), r"C:\Users\Evgenie\AppData\Local\Temp\reinforce_policy.pt")
np.save(r"C:\Users\Evgenie\AppData\Local\Temp\reinforce_history.npy", np.array(history))
Типичная ошибка Переиспользуют старые эпизоды «для экономии» — например, докладывают их в батч вместе со свежими. REINFORCE on-policy: формулы раздела 3 предполагают, что траектории разыграны текущей политикой. Эпизоды, сыгранные до шага оптимизатора, принадлежат другой политике, и их вклад в градиент смещён — обучение тихо портится, чем дальше, тем сильнее.

7. Где REINFORCE ломается

Успех на CartPole не должен обманывать. У метода два родовых недостатка, и оба видны уже в формулах.

Дисперсия. Возможных траекторий чудовищно много, а оцениваем мы ожидание по ним счётным числом эпизодов — оценка градиента шумит (тренажёр показывал это даже на бандите с двумя действиями). Шумный градиент вынуждает малые шаги, малые шаги — множество эпизодов; а поскольку метод on-policy, каждый эпизод используется один раз и выбрасывается. REINFORCE прожорлив к данным по построению.

Одинаковые возвраты. Веса при логарифмах различают действия, только если возвраты эпизодов различаются. Конспект преподавателя разбирает MountainCar: случайная политика в начале обучения никогда не доезжает до флага, все эпизоды длятся 200 шагов, и все возвраты равны −200 — алгоритму не за что зацепиться, «хорошие» действия неотличимы от «плохих». Переход к reward-to-go мало что меняет: награды на каждом шаге одинаковы (−1), и веса просто больше у шагов ближе к концу эпизода — независимо от того, вела машинка к цели или нет. Сравните с DQN из конспекта 7, который решает эту задачу: value-методам хватает редкого сигнала терминальности, растекающегося по бутстрапу.

Оба лекарства уже названы в этом конспекте. Дисперсию лечит baseline, а лучший baseline — ценность состояния \(V(S_t)\): в весе остаётся преимущество \(G_t - V(S_t)\), «насколько действие лучше обычного для этого состояния». Но \(V\) неоткуда взять — её надо учить параллельно с политикой, второй сетью. Политика-актёр и оценщик-критик, обучаемые вместе, — это и есть актёр-критик, герой следующего конспекта.

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

Источники

  1. Williams, R. J. Simple statistical gradient-following algorithms for connectionist reinforcement learning / R. J. Williams // Machine Learning. — 1992. — Vol. 8. — P. 229–256. — DOI: 10.1007/BF00992696.
  2. Sutton, R. S. Policy gradient methods for reinforcement learning with function approximation / R. S. Sutton, D. McAllester, S. Singh, Y. Mansour // Advances in Neural Information Processing Systems. — 2000. — Vol. 12. — P. 1057–1063.