VKВлад КуликовОткрытые лекции
RL для LLM4 / 13

Модуль 4. Градиенты политики: от REINFORCE к PPO

Здесь можно прочитать пояснения и код имеющихся ноутбуков или скачать файлы. Код на сайте не запускается.

Скачать файлы курса (.zip) 382,1 КБ

Архив содержит файлы на обоих языках и вспомогательные материалы с сохранением структуры каталогов.

Модуль 4 — практикум: градиенты политики, GAE и PPO

Основной ноутбук модуля

Скачать .ipynb module-04-policy-gradients-ppo.ru.ipynb · 25,6 КБ

Показать текст ноутбука

Модуль 4 — практикум: градиенты политики, GAE и PPO

Этот ноутбук собирает полный вычислительный маршрут модуля: от свойств опорного уровня до реалистичного цикла PPO и токеновой маски для авторегрессионных ответов. Среда CartPole реализована внутри курса, поэтому ноутбук не зависит от версии Gymnasium. Код различает истинное завершение, внешнее усечение и границу роллаут-буфера.

Профиль smoke выполняет быстрый сквозной тест на CPU. Профиль full увеличивает число обновлений и начальных значений генератора. Числа и графики ниже — результаты кода курса при указанном протоколе, а не внешний benchmark.

Python
from __future__ import annotations

from dataclasses import replace
from pathlib import Path
import os
import sys
import time

import matplotlib.pyplot as plt
import numpy as np

ROOT = Path.cwd()
if not (ROOT / "modules").is_dir():
    for parent in [ROOT, *ROOT.parents]:
        if (parent / "modules" / "module-04").is_dir():
            ROOT = parent
            break
MODULE_DIR = ROOT / "modules" / "module-04"
if str(MODULE_DIR) not in sys.path:
    sys.path.insert(0, str(MODULE_DIR))

from module_4_reference import (
    gae_advantages,
    masked_token_ppo_objective,
    masked_token_ppo_terms,
    response_token_mask,
    sequence_log_probability_ratio,
    sequence_probability_ratio,
    simulate_group_baseline_bias,
)
from module_4_ppo_training import (
    aggregate_training_runs,
    profile_config,
    train_ppo,
)

RUN_MODE = os.environ.get("RL_LLM_M4_PROFILE", "smoke").strip().lower()
if RUN_MODE not in {"smoke", "full"}:
    raise ValueError("RL_LLM_M4_PROFILE должен быть 'smoke' или 'full'")

SEEDS = [11, 37] if RUN_MODE == "smoke" else [11, 37, 71, 101, 131]
print("корень репозитория:", ROOT)
print("профиль:", RUN_MODE, "| начальные значения генератора:", SEEDS)

from module_4_llm_sequence import (
    decode_sequence,
    evaluate_sequence_policy,
    sequence_profile_config,
    train_tiny_sequence_policy,
)

1. Опорный уровень: уменьшение шума и конечновыборочный масштаб

Сравним оцениватель без baseline, центрирование по среднему той же группы и RLOO. Эксперимент не заменяет алгебру: он проверяет предсказание о множителе (G−1)/G(G-1)/G и показывает разброс групповых оценок.

Python
baseline_result = simulate_group_baseline_bias(
    group_size=8,
    groups=80_000 if RUN_MODE == "smoke" else 400_000,
    seed=2026,
)
display_names = {
    "exact": "точное ожидание",
    "plain_mean": "среднее без опоры",
    "same_group_mean": "среднее с общей опорой",
    "rloo_mean": "среднее RLOO",
    "predicted_same_group": "теоретическое среднее группы",
    "plain_std": "ст. откл. без опоры",
    "same_group_std": "ст. откл. общей опоры",
    "rloo_std": "ст. откл. RLOO",
}
for key, value in baseline_result.items():
    print(f"{display_names[key]:>32s}: {value: .6f}")

labels = ["без опорного уровня", "среднее той же группы", "RLOO"]
stds = [
    baseline_result["plain_std"],
    baseline_result["same_group_std"],
    baseline_result["rloo_std"],
]
fig, ax = plt.subplots(figsize=(8.2, 4.8))
ax.bar(labels, stds)
ax.set_ylabel("стандартное отклонение групповой оценки градиента")
ax.set_title("Опорный уровень меняет дисперсию; среднее той же группы — ещё и масштаб")
ax.grid(axis="y", alpha=0.25)
plt.xticks(rotation=8)
plt.show()

2. GAE: две маски отвечают на два разных вопроса

terminated решает, разрешён ли bootstrap из следующего состояния. trace_cuts решает, можно ли продолжить рекурсию GAE к следующей записи массива. На границе роллаут-буфера bootstrap может быть нужен, но перенос рекурсии через несвязанную запись запрещён.

Python
rewards = np.array([1.0, 100.0])
values = np.array([2.0, 0.0])
next_values = np.array([10.0, 0.0])
terminated = np.array([False, True])

# Запись 0 обрезана границей буфера роллаутов, а не терминалом задачи.
# Она использует бутстрэппинг из V(next_state)=10, но рекурсия GAE не должна перейти в запись 1.
trace_cuts = np.array([True, True])
advantages = gae_advantages(
    rewards,
    values,
    next_values,
    terminated,
    trace_cuts,
    gamma=0.9,
    gae_lambda=1.0,
)
print("преимущества при раздельных масках бутстрэппинга и рекурсии:", advantages)
print("цель ценности для первой записи:", values[0] + advantages[0])
assert np.allclose(advantages, [8.0, 100.0])

3. Полный PPO на CartPole

Реализация включает актор–критик, фиксированный роллаут, сохранённые старые логарифмические вероятности и значения функции ценности, GAE, несколько эпох со случайными мини-пакетами, клиппированные цели политики и функции ценности, энтропийный бонус, ограничение нормы градиента, диагностику KL и отдельную детерминированную оценку политики.

Python
runs = []
started = time.perf_counter()
for seed in SEEDS:
    config = profile_config(RUN_MODE, seed=seed)
    # В профиле smoke оцениваем политику через одно обновление, чтобы запуск оставался быстрым.
    if RUN_MODE == "smoke":
        config = replace(config, evaluation_every=2)
    result = train_ppo(config)
    runs.append(result)
    print(
        f"начальное значение={seed:3d} | итоговое среднее={result['final_evaluation_mean']:7.2f} "
        f"± {result['final_evaluation_std']:6.2f}"
    )
elapsed = time.perf_counter() - started
curve = aggregate_training_runs(runs)
print(f"время обучения: {elapsed:.2f} s")
Python
steps = curve["environment_steps"]
finite = np.isfinite(curve["median"])
fig, ax = plt.subplots(figsize=(9.0, 5.0))
ax.fill_between(
    steps[finite], curve["q25"][finite], curve["q75"][finite],
    alpha=0.25, label="межквартильный диапазон",
)
ax.plot(steps[finite], curve["median"][finite], marker="o", label="медиана оценочного возврата")
for run in curve["all_curves"]:
    ax.plot(steps[finite], run[finite], alpha=0.25, linewidth=0.9)
ax.axhline(500.0, linestyle="--", linewidth=1.0, label="потолок по лимиту времени")
ax.set_xlabel("переходы среды")
ax.set_ylabel("возврат при детерминированной оценке")
ax.set_title(f"Самодостаточный PPO на CartPole — профиль {RUN_MODE}")
ax.grid(alpha=0.25)
ax.legend()
plt.show()

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

Python
# Диагностики для первого начального значения. Это локальные величины пакета, а не универсальные пороги.
history = runs[0]["history"]
updates = np.asarray(history["update"])
fig, ax = plt.subplots(figsize=(9.0, 4.8))
ax.plot(updates, history["approx_kl"], marker="o", label="неотрицательная приближённая KL")
ax.plot(updates, history["active_clip_fraction"], marker="s", label="доля активного клиппирования")
ax.plot(updates, history["ratio_outside_fraction"], marker="^", label="доля отношений вне интервала")
ax.set_xlabel("обновление PPO")
ax.set_title("Локальная диагностика оптимизации для одного начального значения")
ax.grid(alpha=0.25)
ax.legend()
plt.show()

4. Авторегрессионный ответ: сдвиг, маска и единица усреднения

Следующий пример намеренно помещает огромные значения в позиции запроса и заполнения (padding). Корректная маска должна полностью исключить их. Затем сравниваются локальные токеновые отношения и точное произведение для ответа, а также три разные редукции токеновой PPO-суррогатной цели.

Для более полного контекста токенизации см. Modern LLMs, Модуль 2 «Токенизация и эмбеддинги» (modern_llms.module_02_tokenization). Архитектура RLHF/RLOO/GRPO рассматривается в Модуле 14 «Обучение по обратной связи: RLHF, DPO, RLOO и GRPO» (modern_llms.module_12_preference_optimization). Текущий ноутбук остаётся самодостаточным.

Python
# Исходные позиции токенов: BOS, prompt_1, prompt_2, response_1, response_2, EOS, PAD.
input_ids = np.array([
    [1, 10, 11, 20, 21, 2, 0],
    [1, 12, 30, 2, 0, 0, 0],
])
prompt_lengths = np.array([3, 2])
mask = response_token_mask(
    input_ids,
    prompt_lengths,
    pad_token_id=0,
    eos_token_id=2,
    include_eos=True,
)

# Столбцы выровнены с метками input_ids[:, 1:]. Значения запроса и заполнения ниже
# намеренно экстремальны, чтобы контракт маски было легко проверить.
old_log_probs = np.array([
    [-9.0, -8.0, -1.0, -1.2, -0.8, -20.0],
    [-7.0, -0.7, -0.9, -30.0, -40.0, -50.0],
])
new_log_probs = np.array([
    [ 9.0,  8.0, -0.7, -1.5, -0.9,  20.0],
    [ 7.0, -0.5, -1.2,  30.0,  40.0,  50.0],
])
advantages = np.array([1.5, -0.8])

ratio, unclipped, clipped, pessimistic = masked_token_ppo_terms(
    new_log_probs,
    old_log_probs,
    advantages,
    mask,
    clip_epsilon=0.2,
)
print("маска ответа, выровненная с input_ids[:, 1:]:\n", mask)
print("локальные отношения (в маскированных позициях стоит единица):\n", np.round(ratio, 4))
print("точные логарифмы последовательностных отношений:", np.round(sequence_log_probability_ratio(new_log_probs, old_log_probs, mask), 4))
print("точные последовательностные отношения:", np.round(sequence_probability_ratio(new_log_probs, old_log_probs, mask), 4))
reduction_names = {
    "token_mean": "среднее по токенам",
    "sequence_mean": "среднее по ответам",
    "sequence_sum_mean": "сумма по токенам, затем среднее",
}
for reduction in ["token_mean", "sequence_mean", "sequence_sum_mean"]:
    value = masked_token_ppo_objective(
        new_log_probs, old_log_probs, advantages, mask,
        clip_epsilon=0.2, reduction=reduction,
    )
    print(f"{reduction_names[reduction]:>34s} ({reduction}): {value: .6f}")

assert np.all(pessimistic[mask == 0] == 0.0)

5. Минимальное авторегрессионное обучение RLOO/PPO

До сих пор авторегрессионная часть показывала только тензорный контракт. Теперь запустим его целиком. Маленькая GRU получает один из трёх запросов и должна породить соответствующую пару символов, а затем сгенерировать EOS. Проверяющая функция выдаёт плотную, но недифференцируемую награду. Для каждого запроса собирается группа ответов, строятся преимущества с исключением текущего ответа, замораживаются старые токеновые логарифмические вероятности, после чего выполняются несколько эпох PPO.

Это не модель качества языка и не имитация промышленного RLHF. Эксперимент нужен, чтобы в одном исполняемом пути увидеть переменную длину ответа, причинный сдвиг, маску запроса и заполнения (padding), включённый сгенерированный EOS, групповой опорный уровень и токеновое клиппирование.

Python
sequence_config = sequence_profile_config(RUN_MODE)
print("Профиль авторегрессионного эксперимента:", sequence_config)
sequence_run = train_tiny_sequence_policy(seed=5, **sequence_config)
first_batch = sequence_run["first_batch"]
assert first_batch is not None

print("Первые строки исходного роллаута:")
for i in range(min(6, len(first_batch.prompt_classes))):
    print(
        f"  запрос={first_batch.prompt_classes[i]} | "
        f"{decode_sequence(first_batch.input_ids[i].cpu().numpy())} | "
        f"награда={first_batch.rewards[i]:.2f} | "
        f"преимущество={first_batch.advantages[i]:+.3f} | "
        f"маска={first_batch.response_mask[i].cpu().numpy().astype(int).tolist()}"
    )
for prompt_class in range(3):
    idx = np.flatnonzero(first_batch.prompt_classes == prompt_class)
    np.testing.assert_allclose(first_batch.advantages[idx].sum(), 0.0, atol=1e-12)
print("Сумма RLOO-преимуществ в каждой группе равна нулю.")
Python
sequence_history = sequence_run["history"]
iterations = np.asarray(sequence_history["iteration"])
mean_reward = np.asarray(sequence_history["evaluation_mean_reward"])
plt.figure(figsize=(9, 5))
plt.plot(iterations, mean_reward)
plt.xlabel("Итерация")
plt.ylabel("Средняя награда на проверке")
plt.title("Маленькая авторегрессионная политика учится по проверяемой награде")
plt.tight_layout()
plt.show()
print("начальная средняя награда на проверке:", float(mean_reward[0]))
print("итоговая средняя награда на проверке:", sequence_run["final_evaluation"]["mean_reward"])
Python
success = np.asarray(sequence_history["success_rate"])
plt.figure(figsize=(9, 5))
plt.plot(iterations, success)
plt.ylim(-0.02, 1.02)
plt.xlabel("Итерация")
plt.ylabel("Доля полностью правильных ответов")
plt.title("Успешность полного ответа во время обучения")
plt.tight_layout()
plt.show()

print("Жадные ответы после обучения:")
greedy = evaluate_sequence_policy(
    sequence_run["model"], samples_per_prompt=1, deterministic=True
)
for prompt_class, row, reward in zip(
    greedy["prompt_classes"], greedy["input_ids"], greedy["rewards"]
):
    print(f"  запрос={prompt_class} | {decode_sequence(row)} | награда={reward:.2f}")

Что этот прогон показывает — и чего не показывает

Кривая демонстрирует согласованность реализации: награда действительно влияет на вероятность породить нужный ответ, а маски не дают запросу, заполнению и позициям после EOS попасть в потерю политики. Она не доказывает устойчивость PPO на больших языковых моделях. В реальном постобучении добавляются опорная политика (reference policy), распределённый сбор роллаутов, намного более сложная модель награды или проверяющая система (verifier), управление длиной, смешанные цели, задержки между политикой сбора и обновления (off-policy-сдвиг) и инфраструктурные ограничения.

Для более широкой архитектурной карты посмотрите Modern LLMs, Модуль 14 «Обучение по обратной связи: RLHF, DPO, RLOO и GRPO» (modern_llms.module_12_preference_optimization). Вариационная логика энтропии и KL-регуляризации подробно разобрана в Information Theory for ML, Модуле 8 «Максимальная энтропия, экспоненциальные модели и KL-регуляризация» (it_ml.module_08_maxent_kl_policy). Но текущий ноутбук самодостаточен: обе ссылки — необязательное углубление.

6. Ключевые выводы практикума

  1. REINFORCE не требует дифференцируемой награды, но его дисперсия зависит от параметризации и опорного уровня.

  2. Преимущества для потери актора должны иметь явный контракт остановки градиента (detach).

  3. GAE нужны как минимум две логические маски: для бутстрэппинга и для обратной рекурсии.

  4. Клиппирование PPO ограничивает локальную суррогатную выгоду, но не гарантирует доверительную область.

  5. Полноценная реализация PPO включает сбор роллаутов, функцию ценности, несколько эпох обновления по мини-пакетам, диагностику и многозапусковый протокол.

  6. Перенос на LLM требует аккуратного причинного сдвига, маски ответа, обработки EOS и явного правила токеновой агрегации.

  7. Групповое преимущество с исключением текущего ответа можно встроить в реальный авторегрессионный цикл, но общий коэффициент ответа всё ещё не решает локальное распределение вклада между токенами.

  8. Любая учебная кривая должна сопровождаться границами вывода: успешный прогон профиля smoke проверяет кодовый контракт, а не обещает масштабируемость метода.