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

Trust Region и PPO

О чём эта тема
Финал траектории — алгоритм, на котором сегодня держится значительная часть практического RL, включая дообучение языковых моделей: Proximal Policy Optimization. Путь к нему: переиспользование чужих данных через importance ratio, опасность больших шагов, доверительный регион — и знаменитый клип, заменяющий всю эту машинерию тремя строками кода. По лекции 6 курса «RL: от бандитов до RLHF» (мехмат МГУ).
Аннотация
Конспект начинается с главной неэффективности policy-методов: после каждого шага оптимизатора собранные траектории устаревают. Суррогатная функция с importance ratio позволяет несколько раз учиться на одном батче — но у неё, как доказывается, короткий поводок: она приближает истинную цель лишь пока новая политика близка к старой, и граница Шульмана превращает «близка» в строгий KL-термин. TRPO решает задачу с KL-ограничением честно и дорого; PPO заменяет ограничение клипированием importance ratio, и теорема о трёх случаях показывает, почему это работает: у выгодных действий срезается потолок, у невыгодных — пол, а в окрестности старой политики градиент не искажается. Тренажёр рисует клип-цель как функцию ratio при разных знаках преимущества. Дальше собирается полный функционал PPO — клип-цель, потеря критика, энтропийный бонус — и обобщённая оценка преимущества GAE, интерполирующая между TD и Монте-Карло. Практика — наш PPO на CartPole: три поколения policy-методов этой траектории на одном графике. Финал — мостик во вторую ступень: как PPO дообучает языковые модели.
Пререквизиты
Конспект 10 (актёр-критик, преимущество, TD-ошибка как его оценка), конспект 9 (градиент политики, on-policy), конспект 4 (важностная выборка — она возвращается!, TD против Монте-Карло), конспект 2 (разведка).
Мотивация
Посчитаем, куда уходит время актёра-критика: сыграть эпизод — тысячи прогонов сети, один градиентный шаг — и всё выбросить, потому что политика изменилась и данные «протухли». Обидно до невозможности: в DQN каждый переход работал сотни раз. Нельзя ли и здесь учиться на батче несколько эпох? Можно — если честно пересчитывать вклад чужих траекторий (важностная выборка из конспекта 4) и не уходить далеко от политики, которая их собирала. Из этих двух «если» выросли TRPO и PPO — второй стал алгоритмом по умолчанию везде, от игр до обучения ассистентов вроде того, с которым вы, возможно, сейчас разговариваете.

1. Чужие данные: importance ratio

Пусть батч траекторий собран политикой \(\pi_{\theta_{\text{old}}}\), а обновлять мы хотим уже новую \(\pi_\theta\). Знакомая по конспекту 4 ситуация off-policy оценивания, и лекарство то же — важностная выборка. Определим importance ratio (отношение правдоподобий) для каждого шага:

\[ r_t(\theta) = \frac{\pi_\theta(A_t \,|\, S_t)} {\pi_{\theta_{\text{old}}}(A_t \,|\, S_t)}. \tag{11.1}\]

Число прозрачное: \(r_t > 1\) — новая политика делает это действие охотнее старой, \(r_t < 1\) — реже; при \(\theta = \theta_{\text{old}}\) все \(r_t = 1\). Из него строится суррогатная функция — замена настоящей цели \(J(\theta)\), вычислимая по старым данным:

\[ L(\theta) = \mathbb{E}\bigl[ r_t(\theta)\, \hat A_t \bigr], \tag{11.2}\]

где \(\hat A_t\) — оценка преимущества из конспекта 10. Почему это честная замена? В точке \(\theta = \theta_{\text{old}}\) градиенты совпадают: продифференцировав (11.1), получаем \(\nabla_\theta r_t \big|_{\theta_{\text{old}}} = \nabla_\theta \log \pi_\theta\), и \(\nabla L\) превращается в знакомый градиент политики с весом-преимуществом (конспект 10, формула (10.3)). Значит, первый шаг по суррогату — это в точности шаг актёра-критика; вопрос в том, что происходит на втором, третьем, десятом шаге по тем же данным.

1.1. Проблема больших шагов

А происходит вот что: суррогат приближает цель, лишь пока распределение посещаемых состояний новой политики похоже на старое. Теория монотонного улучшения политик (Шульман и др., 2015) даёт точную нижнюю границу:

\[ J(\theta) \;\ge\; L(\theta) - C \cdot \max_s \mathrm{KL}\bigl( \pi_\theta(\cdot|s) \,\|\, \pi_{\theta_{\text{old}}}(\cdot|s) \bigr), \tag{11.3}\]

где \(C > 0\) зависит от \(\gamma\) и максимума награды, а KL — дивергенция Кульбака–Лейблера, мера «непохожести» распределений действий (не метрика: несимметрична и без неравенства треугольника, но нулевая тогда и только тогда, когда политики совпадают). Читается граница так: пока новая политика близка к старой (KL мал), рост суррогата гарантирует рост настоящей цели; стоит уйти далеко — граница становится тривиальной, и суррогат может расти, пока \(J\) падает. Окрестность старой политики, внутри которой суррогату можно верить, называется доверительным регионом (англ. trust region).

Алгоритм TRPO так и поступает: максимизирует \(L(\theta)\) при явном ограничении \(\mathrm{KL} \le \delta\). Честно — и дорого: нужен гессиан и условная оптимизация, что для сетей с миллионами параметров мучительно. PPO предлагает аппроксимацию, умещающуюся в три строки.

2. Клип: доверительный регион за три строки

Идея Proximal Policy Optimization: не ограничивать KL явно, а обрезать сам ratio — лишить оптимизатор стимула уводить политику далеко. Обозначив \(\operatorname{clip}(x, a, b)\) — прижатие числа \(x\) к отрезку \([a, b]\), — запишем клипированную цель:

\[ L^{\text{CLIP}}(\theta) = \mathbb{E}\Bigl[ \min\Bigl( r_t(\theta)\, \hat A_t,\; \operatorname{clip}\bigl(r_t(\theta),\, 1{-}\varepsilon,\, 1{+}\varepsilon\bigr)\, \hat A_t \Bigr) \Bigr], \tag{11.4}\]

где \(\varepsilon\) — гиперпараметр, обычно от 0,1 до 0,3.

Теорема 11.1 (клип по случаям). Для каждого слагаемого (11.4):

  1. при \(\hat A_t > 0\) цель перестаёт расти, как только \(r_t > 1 + \varepsilon\);
  2. при \(\hat A_t < 0\) цель перестаёт убывать, как только \(r_t < 1 - \varepsilon\);
  3. при \(\theta = \theta_{\text{old}}\) (то есть \(r_t = 1\)) градиент \(L^{\text{CLIP}}\) совпадает с градиентом суррогата (11.2).

Доказательство. Обозначим \(r = r_t\) и сравним две функции под минимумом: \(f_1(r) = r \hat A_t\) и \(f_2(r) = \operatorname{clip}(r, 1{-}\varepsilon, 1{+}\varepsilon)\, \hat A_t\). Случай \(\hat A_t > 0\): \(f_1\) растёт, а \(f_2\) при \(r > 1{+}\varepsilon\) — константа \((1{+}\varepsilon)\hat A_t\); минимум при \(r \le 1{+}\varepsilon\) равен обеим, дальше — константе, и градиент по \(r\) зануляется: наращивать вероятность выгодного действия сверх порога незачем. Случай \(\hat A_t < 0\): \(f_1\) убывает, \(f_2\) при \(r < 1{-}\varepsilon\) — константа \((1{-}\varepsilon)\hat A_t\); минимум совпадает с \(f_1\) при \(r \ge 1{-}\varepsilon\) и с константой ниже порога — градиент зануляется: душить невыгодное действие сильнее порога тоже незачем. Случай \(r = 1\): точка лежит строго внутри \([1{-}\varepsilon, 1{+}\varepsilon]\), клип не активен, цели и градиенты совпадают. \(\blacksquare\)

Геометрически \(L^{\text{CLIP}}\) — кусочно-линейная функция от \(r\): у выгодных действий срезан потолок, у невыгодных — пол; за счёт минимума это всегда пессимистическая нижняя оценка суррогата, отсюда консервативность обновления. Убедитесь в каждом случае теоремы сами:

Тренажёр: клип-цель глазами — все случаи теоремы 11.1
серая прямая — суррогат r·Â без клипа · цветная ломаная — LCLIP(r) · точка — текущий r · штриховка — зона нулевого градиента

Подвигайте ползунок при \(\hat A = +1\): внутри коридора \([1{-}\varepsilon, 1{+}\varepsilon]\) клип-цель повторяет прямую, за правым порогом — полка: расти дальше оптимизатору незачем, вероятность действия за один цикл не вырастет более чем в \(1{+}\varepsilon\) раз. Переключите на \(\hat A = -1\): полка возникает слева — то же ограничение на подавление. Заметьте асимметрию, которую часто упускают: при \(\hat A > 0\) движение влево (ухудшение) не клипируется — минимум оставляет честную прямую, пессимизм работает только против чрезмерного оптимизма.

3. Полный функционал и GAE

Клип-цель отвечает только за актёра. Рабочий PPO оптимизирует сумму трёх слагаемых — по общим параметрам, если актёр и критик делят ствол сети (как ModelA2C из конспекта 10):

\[ L(\theta, \varphi) = -L^{\text{CLIP}}(\theta) + c_v \,\mathbb{E}\bigl[ (V_\varphi(S_t) - \hat V_t)^2 \bigr] - c_H \,\mathbb{E}\bigl[ H(\pi_\theta(\cdot|S_t)) \bigr] \;\to\; \min. \tag{11.5}\]

Второе слагаемое — критик, приближающий цель \(\hat V_t\). Третье — энтропийный бонус: энтропия \(H(\pi(\cdot|s)) = -\sum_a \pi(a|s) \log \pi(a|s)\) измеряет «размазанность» распределения действий, и её поощрение мешает политике преждевременно схлопнуться в детерминированную — та же забота о разведке, что ε-жадность в конспекте 2 и обучаемая дисперсия в конспекте 10, только третьим способом.

Осталось сказать, откуда берутся \(\hat A_t\) и \(\hat V_t\). Стандарт — обобщённая оценка преимущества (Generalized Advantage Estimation, GAE): экспоненциально взвешенная сумма TD-ошибок критика

\[ \hat A_t = \sum_{l=0}^{T-t-1} (\gamma \lambda)^l\, \delta_{t+l}, \qquad \delta_t = R_{t+1} + \gamma V_\varphi(S_{t+1}) - V_\varphi(S_t), \tag{11.6}\]

а цель критика — \(\hat V_t = \hat A_t + V_\varphi(S_t)\). Параметр \(\lambda\) — старый знакомый: при \(\lambda = 0\) остаётся одна TD-ошибка (минимум дисперсии, максимум смещения — актёр-критик конспекта 10), при \(\lambda = 1\) сумма телескопируется в монте-карловский возврат минус \(V\) (несмещённо и шумно — REINFORCE с baseline). Это в третий раз спектр «TD против Монте-Карло» из конспекта 4 — теперь в виде ручки, которую крутят: на практике \(\lambda = 0{,}9\)–\(0{,}97\).

PPO: один цикл
  1. собрать батч траекторий политикой \(\pi_\theta\); зафиксировать \(\theta_{\text{old}} \leftarrow \theta\);
  2. по каждому переходу вычислить \(\hat A_t\) (GAE) и \(\hat V_t\);
  3. нормализовать \(\hat A_t\) по батчу (вычесть среднее, поделить на разброс);
  4. K эпох градиентного спуска по (11.5), пересчитывая \(r_t(\theta)\) на каждом шаге;
  5. вернуться к шагу 1 со свежими данными.

Шаг 4 — то, ради чего всё затевалось: одни и те же данные прокручиваются K раз, и клип страхует от ухода из доверительного региона. Из каждого собранного эпизода PPO извлекает в K раз больше обучения, чем актёр-критик с его одним проходом, — а по простоте несравним с TRPO.

Типичная ошибка Пересобирают log_prob старой политики на каждой эпохе заново — от текущей сети. Тогда \(r_t \equiv 1\), клип никогда не активен, и «PPO» тихо вырождается в обычный актёр-критик с K-кратным переобучением на батче. Логарифмы вероятностей старой политики фиксируются в момент сбора данных (detach/no_grad) и в эпохах не пересчитываются.

4. Практика: три поколения на одной задаче

Замкнём линию, начатую в конспекте 9. Тот же CartPole, те же 128 нейронов; PPO собран по псевдокоду выше — общий ствол, две головы, GAE, батч из четырёх эпизодов, четыре эпохи на батч. Все три запуска — наши, при подготовке конспектов:

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

Результат стоит прочесть внимательно, потому что он честнее рекламного. Кривая PPO — самая гладкая из трёх, и, выйдя на максимум, он держит его железно: с трёхсотой итерации каждый батч — ровно 500 шагов, без единого срыва (у REINFORCE и актёра-критика провалы случаются до самого конца). Но по числу эпизодов на игрушечном CartPole PPO не выигрывает — он тратит их батчами. Его экономия — в другом: каждый собранный батч прокручивается четыре эпохи, так что на один сбор данных приходится вчетверо больше обучения; на задачах, где эпизод дорог — робот, симулятор посложнее, генерация текста, — эта арифметика решает. И главное его достоинство — не скорость, а предсказуемость: клип не даёт политике разрушить саму себя одним неудачным шагом (вспомните коллапс актёра-критика из конспекта 10 — с PPO такой сценарий устроить трудно).

А теперь обещанный должок конспекта 10 — маятник, который ванильный актёр-критик так и не поднял. Тот же PPO, только политика гауссова (конспект 10, формула (10.7)) с обучаемым разбросом:

Кривая обучения PPO на Pendulum: возврат растёт от -1400 к области около -400
PPO на Pendulum-v1: средний возврат батча по итерациям (наш запуск)
Обученный PPO маятник раскачивается и удерживается около вертикали
Обученная политика: раскачка и вертикаль. Снято живым запуском среды

Итог нашего запуска (400 итераций и ещё 400 со сниженным до 10⁻⁴ шагом — тот же приём «дожать аккуратнее», что у машинки в конспекте 7): средний возврат стохастической политики вырос с −1450 до −500 с лишним; детерминированная (по средним μ) версия набирает −724 в среднем по 20 эпизодам, а из верхней полусферы держит вертикаль почти идеально — лучший эпизод −1,5. Это не «решённая» задача (эталон около −150: подъём снизу удаётся не из каждого положения), но контраст с актёром-критиком из конспекта 10, который за 3000 эпизодов не сдвинулся с −1450, — наглядный. Желающим дожать маятник до конца — увеличить батч, добавить нормализацию наблюдений или взять готовую реализацию из библиотек (Stable-Baselines3, AgileRL): архитектурно там тот же код, что перед вами.

Полный код для проектов: PPO с гауссовой политикой на Pendulum-v1
# ppo_pendulum.py — PPO с гауссовой политикой на Pendulum-v1.
# Та же схема, что ppo_cartpole.py, но политика непрерывная (10.7)-(10.8).
import numpy as np
import torch
import torch.nn as nn
from torch.distributions import Normal
import gymnasium as gym

gamma, lam, clip_eps = 0.9, 0.95, 0.2
c_v, c_h, K_epochs, batch_episodes = 0.5, 0.01, 10, 8

env = gym.make("Pendulum-v1")
torch.manual_seed(3); np.random.seed(3)
nS, nA = env.observation_space.shape[0], env.action_space.shape[0]
A_MAX = float(env.action_space.high[0])

class ActorCritic(nn.Module):
    def __init__(self, h=128):
        super().__init__()
        self.base = nn.Sequential(nn.Linear(nS, h), nn.ReLU())
        self.mu = nn.Sequential(nn.Linear(h, nA), nn.Tanh())
        self.logstd = nn.Parameter(torch.zeros(nA))       # обучаемый лог-разброс
        self.v = nn.Linear(h, 1)

    def dist_v(self, x):
        y = self.base(x)
        return Normal(self.mu(y) * A_MAX, torch.exp(self.logstd)), self.v(y).squeeze(-1)

model = ActorCritic()
optimizer = torch.optim.Adam(model.parameters(), lr=0.0003)

def collect_batch():
    S, A, LOGP, ADV, VTARG, totals = [], [], [], [], [], []
    for _ in range(batch_episodes):
        state, _ = env.reset()
        states, actions, logps, rewards, values = [], [], [], [], []
        total = 0.0
        for t in range(200):
            s = torch.from_numpy(state).float()
            with torch.no_grad():
                dist, v = model.dist_v(s)
            a = dist.sample()
            states.append(s); actions.append(a)
            logps.append(dist.log_prob(a).sum()); values.append(v)
            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
        totals.append(total)
        values = torch.stack(values)
        adv, gae = torch.zeros(len(rewards)), 0.0
        for t in reversed(range(len(rewards))):
            v_next = values[t + 1] if t + 1 < len(rewards) else 0.0
            delta = rewards[t] + gamma * v_next - values[t]
            gae = delta + gamma * lam * gae
            adv[t] = gae
        S += states; A += actions; LOGP += logps
        ADV.append(adv); VTARG.append(adv + values)
    ADV = torch.cat(ADV); VTARG = torch.cat(VTARG)
    ADV = (ADV - ADV.mean()) / (ADV.std() + 1e-8)
    return (torch.stack(S), torch.stack(A), torch.stack(LOGP), ADV, VTARG,
            float(np.mean(totals)))

history = []
for it in range(1, 401):
    S, A, LOGP_OLD, ADV, VTARG, mean_ret = collect_batch()
    for _ in range(K_epochs):
        dist, v = model.dist_v(S)
        logp = dist.log_prob(A).sum(-1)
        ratio = torch.exp(logp - LOGP_OLD)
        surr1 = ratio * ADV
        surr2 = torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) * ADV
        loss = (-torch.min(surr1, surr2).mean()
                + c_v * ((v - VTARG) ** 2).mean()
                - c_h * dist.entropy().sum(-1).mean())
        optimizer.zero_grad(); loss.backward(); optimizer.step()
    history.append(mean_ret)
    if it % 20 == 0:
        print(f"итерация {it:4d}  средний возврат батча {mean_ret:8.1f}  "
              f"за 10 итераций {np.mean(history[-10:]):8.1f}", flush=True)

np.save(r"C:\Users\Evgenie\AppData\Local\Temp\ppo_pend_history.npy", np.array(history))
torch.save(model.state_dict(), r"C:\Users\Evgenie\AppData\Local\Temp\ppo_pend_model.pt")
Полный код для проектов: PPO на CartPole (PyTorch + Gymnasium, один файл)
# ppo_cartpole.py — PPO с клипированной целью на CartPole-v1, один файл.
# Батч из нескольких эпизодов -> GAE -> K эпох оптимизации функционала (11.6).
import numpy as np
import torch
import torch.nn as nn
from torch.distributions import Categorical
import gymnasium as gym

gamma, lam = 0.99, 0.95        # дисконт и параметр GAE
clip_eps = 0.2                 # epsilon клипа
c_v, c_h = 0.5, 0.01           # веса потерь критика и энтропийного бонуса
K_epochs = 4                   # эпох оптимизации на одном батче
batch_episodes = 4             # эпизодов в батче

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

class ActorCritic(nn.Module):
    """Общий ствол, две головы: логиты политики и ценность."""
    def __init__(self):
        super().__init__()
        self.base = nn.Sequential(nn.Linear(nS, 128), nn.ReLU())
        self.pi = nn.Linear(128, nA)
        self.v = nn.Linear(128, 1)

    def forward(self, x):
        y = self.base(x)
        return self.pi(y), self.v(y).squeeze(-1)

model = ActorCritic()
optimizer = torch.optim.Adam(model.parameters(), lr=0.003)

def collect_batch():
    """Играем batch_episodes эпизодов текущей политикой, считаем GAE."""
    S, A, LOGP, ADV, VTARG, lengths = [], [], [], [], [], []
    for _ in range(batch_episodes):
        state, _ = env.reset()
        states, actions, logps, rewards, values = [], [], [], [], []
        for t in range(1000):
            s = torch.from_numpy(state).float()
            with torch.no_grad():
                logits, v = model(s)
            c = Categorical(logits=logits)
            a = c.sample()
            states.append(s); actions.append(a)
            logps.append(c.log_prob(a)); values.append(v)
            state, r, term, trunc, _ = env.step(int(a))
            rewards.append(r)
            if term or trunc: break
        lengths.append(len(rewards))
        # GAE (11.4): A_t = sum (gamma*lam)^l * delta_{t+l}; V-цель = A + V
        values = torch.stack(values)
        adv, gae = torch.zeros(len(rewards)), 0.0
        for t in reversed(range(len(rewards))):
            v_next = values[t + 1] if t + 1 < len(rewards) else 0.0  # term: V=0
            delta = rewards[t] + gamma * v_next - values[t]
            gae = delta + gamma * lam * gae
            adv[t] = gae
        S += states; A += actions; LOGP += logps
        ADV.append(adv); VTARG.append(adv + values)
    ADV = torch.cat(ADV); VTARG = torch.cat(VTARG)
    ADV = (ADV - ADV.mean()) / (ADV.std() + 1e-8)   # нормировка advantage
    return (torch.stack(S), torch.stack(A), torch.stack(LOGP), ADV, VTARG,
            float(np.mean(lengths)))

running, history = 10.0, []
for it in range(1, 501):
    S, A, LOGP_OLD, ADV, VTARG, mean_len = collect_batch()
    for _ in range(K_epochs):                       # K эпох на одном батче
        logits, v = model(S)
        c = Categorical(logits=logits)
        ratio = torch.exp(c.log_prob(A) - LOGP_OLD)         # r_t(theta)
        surr1 = ratio * ADV
        surr2 = torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) * ADV
        loss = (-torch.min(surr1, surr2).mean()             # -L^CLIP
                + c_v * ((v - VTARG) ** 2).mean()           # критик
                - c_h * c.entropy().mean())                 # энтропийный бонус
        optimizer.zero_grad(); loss.backward(); optimizer.step()
    history += [mean_len] * batch_episodes
    running = running * 0.9 + mean_len * 0.1
    if it % 10 == 0:
        print(f"итерация {it:4d}  средняя длина батча {mean_len:6.1f}  скользящая {running:6.1f}", flush=True)
    if running > 475:
        print(f"Решено на итерации {it} (~{it * batch_episodes} эпизодов): скользящая {running:.1f}")
        break

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

Для промышленных задач писать PPO руками не обязательно: готовые реализации есть в библиотеках экосистемы Gymnasium (Stable-Baselines3, AgileRL — туториал «On-Policy Training» из материалов курса показывает обучение PPO в несколько строк). Но однажды собранный своими руками клип запомнится лучше любой документации.

5. Куда это ведёт: PPO и языковые модели

Закончим тем, с чего траектория начиналась, — мотивацией из конспекта 1 про системы, которые «учатся у результата». Самое громкое применение PPO сегодня — дообучение больших языковых моделей на человеческих предпочтениях (RLHF). Приглядитесь: генерация текста — это MDP, в котором состояние — запрос плюс уже написанные токены, действие — следующий токен из словаря в десятки тысяч слов, эпизод — один ответ, а награду в конце выставляет отдельная модель-оценщик, обученная на сравнениях ответов людьми. Всё, чем мы занимались одиннадцать конспектов, переносится сюда дословно: value-методы отпадают из-за чудовищного пространства действий (конспект 9), политика — трансформер с softmax по словарю (тот же розыгрыш из Categorical), критик — value-голова поверх той же сети (общий ствол, конспект 10), преимущество считается GAE, шаг ограничивается клипом — плюс один новый ингредиент, KL-штраф к исходной модели, чтобы в погоне за наградой не разучиться говорить по-человечески. Разбор этой кухни — RLHF, reward-модели, DPO и GRPO — тема второй ступени курса; фундамент для неё у вас теперь есть полностью.

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

Источники

  1. Schulman, J. Proximal Policy Optimization Algorithms / J. Schulman, F. Wolski, P. Dhariwal [et al.] // arXiv. — 2017. — URL: https://arxiv.org/abs/1707.06347 (дата обращения: 08.07.2026).
  2. Schulman, J. Trust Region Policy Optimization / J. Schulman, S. Levine, P. Moritz [et al.] // arXiv. — 2015. — URL: https://arxiv.org/abs/1502.05477 (дата обращения: 08.07.2026).
  3. Schulman, J. High-Dimensional Continuous Control Using Generalized Advantage Estimation / J. Schulman, P. Moritz, S. Levine [et al.] // arXiv. — 2015. — URL: https://arxiv.org/abs/1506.02438 (дата обращения: 08.07.2026).