Глава 4: Консервативное Q-обучение (CQL)

«Не доверяй значениям, которых не видел. А если не видел — занижай их.»


Проблема, переформулированная

В главе 2 мы увидели, что стандартное Q-обучение на офлайн данных порождает катастрофически оптимистичные Q-значения для действий вне распределения (OOD). Жадная политика эксплуатирует эти завышенные значения, выбирая действия, которых в датасете никогда не было — и терпит неудачу при развёртывании.

Корень проблемы: обновление Беллмана использует $\max_{a'} Q(s', a')$, перебирая все действия, включая ненаблюдавшиеся. Для таких действий Q-функция обобщается оптимистично.

Conservative Q-Learning (CQL) — Kumar et al., NeurIPS 2020 — исправляет это одной элегантной идеей: добавить регуляризационный член, явно штрафующий Q-значения для действий вне датасета.

Результат: Q-функция, которая по построению пессимистична для OOD-действий. Жадная политика, видя более низкие значения вне датасета, естественным образом остаётся близко к поведенческой политике — без явного ограничения.


Идея

Стандартное TD-обучение минимизирует:

$$\mathcal{L}_{TD}(\theta) = \mathbb{E}_{(s,a,r,s') \sim \mathcal{D}} \left[ \left( r + \gamma \max_{a'} Q_{\bar\theta}(s', a') - Q_\theta(s, a) \right)^2 \right]$$

CQL добавляет к этой цели два члена:

$$\mathcal{L}_{CQL}(\theta) = \mathcal{L}_{TD}(\theta) + \alpha \cdot \underbrace{\left( \mathbb{E}_{s \sim \mathcal{D},\, a \sim \mu} \left[ Q_\theta(s, a) \right] - \mathbb{E}_{(s,a) \sim \mathcal{D}} \left[ Q_\theta(s, a) \right] \right)}_{\text{консервативный штраф}}$$

где: - $\mu$ — некоторое распределение над действиями (как правило, равномерное или текущая политика) - $\alpha > 0$ — гиперпараметр, управляющий силой консерватизма - Первое математическое ожидание занижает Q-значения для действий из $\mu$ - Второе математическое ожидание завышает Q-значения для действий из датасета

Иными словами: минимизировать Q-значения везде, но максимизировать их в точках датасета. Разрыв между двумя членами — то, что минимизирует CQL: действия из датасета выглядят лучше, чем OOD-действия.


Формализация

Целевая функция CQL

Точнее, CQL минимизирует следующую регуляризованную ошибку Беллмана:

$$\min_\theta \, \alpha \left( \mathbb{E}_{s \sim \mathcal{D}} \left[ \log \sum_a \exp Q_\theta(s, a) \right] - \mathbb{E}_{(s,a) \sim \mathcal{D}} \left[ Q_\theta(s, a) \right] \right) + \frac{1}{2} \mathcal{L}_{TD}(\theta)$$

Первый член — log-sum-exp по всем действиям — гладкая аппроксимация $\max_a Q(s,a)$. Он опускает всю Q-поверхность вниз. Второй член поднимает Q-значения именно в точках датасета.

Определим:

$$\mathcal{R}_{CQL}(\theta) = \mathbb{E}_{s \sim \mathcal{D}} \left[ \log \sum_a \exp Q_\theta(s, a) \right] - \mathbb{E}_{(s,a) \sim \mathcal{D}} \left[ Q_\theta(s, a) \right]$$

Тогда: $\mathcal{L}_{CQL}(\theta) = \mathcal{L}_{TD}(\theta) + \alpha \cdot \mathcal{R}_{CQL}(\theta)$

Замечание о множителе $\frac{1}{2}$. В оригинальной статье CQL написано $\frac{1}{2}\mathcal{L}_{TD}$ — это соглашение о нормировке в рамках их вывода. На практике множитель поглощается скоростью обучения и $\alpha$, поэтому реализации (включая нашу) его опускают.

Почему Log-Sum-Exp?

Член logsumexp — это аппроксимация максимума через softmax:

$$\log \sum_a \exp Q(s, a) = \max_a Q(s, a) + \log \sum_a \exp(Q(s,a) - \max_a Q(s,a))$$

При температуре → 0 это сходится к $\max_a Q(s,a)$. При конечной температуре выражение дифференцируемо и штрафует всё распределение Q-значений, а не только максимум.

Для непрерывных пространств действий перебрать все $a$ невозможно. CQL аппроксимирует logsumexp через importance sampling:

$$\log \sum_a \exp Q(s, a) \approx \log \mathbb{E}_{a \sim \mu(a|s)} \left[ \frac{\exp Q(s,a)}{\mu(a|s)} \right]$$

где $\mu$ — предложенное распределение. На практике $\mu$ — либо равномерное над пространством действий, либо текущая политика $\pi_\theta$.

Зачем вычитать $\log \mu(a|s)$ в коде? При $\mu = \pi_\theta$ importance-sampling оценка принимает вид:

$$\log \mathbb{E}_{a \sim \pi_\theta} \left[ \frac{\exp Q(s,a)}{\pi_\theta(a|s)} \right] \approx \log \frac{1}{N} \sum_{i=1}^N \frac{\exp Q(s, a_i)}{\pi_\theta(a_i|s)} = \text{logsumexp}_i \left[ Q(s, a_i) - \log \pi_\theta(a_i|s) \right] + \text{const}$$

Именно поэтому в коде вычисляется q_policy - policy_log_probs перед logsumexp: вычитание $\log \pi_\theta(a|s)$ реализует коррекцию importance weights. Без неё мы бы аппроксимировали $\mathbb{E}_{\pi_\theta}[Q]$ (простое среднее Монте-Карло), а не $\log \sum_a \exp Q$ (мягкий максимум, который требует CQL). Для случайных равномерных действий эта коррекция не нужна: их лог-плотность константна и сокращается внутри logsumexp.

Теоретическая гарантия

Теорема (Kumar et al., 2020). Пусть $\hat{Q}^\pi$ — Q-функция, обученная CQL, а $Q^\pi$ — истинная Q-функция политики $\pi$. Тогда:

$$\hat{Q}^\pi(s, a) \leq Q^\pi(s, a) \quad \forall (s, a) \in \mathcal{D}$$

при подходящих условиях на $\alpha$.

Иными словами: при предположениях анализа CQL и достаточной оптимизации метод строит консервативную оценку, математическое ожидание которой даёт нижнюю границу истинной ценности политики в точках датасета. Это снижает стимул эксплуатировать OOD-переоценки, но не является абсолютной гарантией безопасности или отсутствия переоценки в произвольной нейросетевой реализации.

Более практично (при тех же предположениях) ожидаемая производительность политики удовлетворяет:

$$J(\hat\pi) \geq J(\pi_\beta) - \frac{\alpha}{1-\gamma} \cdot \mathbb{E}_{s \sim d^{\pi_\beta}} \left[ D_{CQL}(\hat\pi, \pi_\beta)(s) \right]$$

Граница (при предположениях теоремы) связывает производительность CQL с BC плюс штраф, пропорциональный отклонению политики от поведенческой — это не безусловная гарантия в каждой практической настройке.


Реализация

📄 Полный код: cql.py

Сети

CQL обычно использует архитектуру в стиле SAC: два Q-networks (для снижения переоценки через double-Q), стохастический актор и регуляризацию энтропией.

import math
import torch
import torch.nn as nn
import torch.nn.functional as F
from copy import deepcopy

class QNetwork(nn.Module):
    def __init__(self, state_dim, action_dim, hidden_dim=256):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(state_dim + action_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),              nn.ReLU(),
            nn.Linear(hidden_dim, 1),
        )
    def forward(self, state, action):
        return self.net(torch.cat([state, action], dim=-1)).squeeze(-1)


class GaussianPolicy(nn.Module):
    def __init__(self, state_dim, action_dim, hidden_dim=256):
        super().__init__()
        self.trunk = nn.Sequential(
            nn.Linear(state_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),
        )
        self.mean_head    = nn.Linear(hidden_dim, action_dim)
        self.log_std_head = nn.Linear(hidden_dim, action_dim)

    def sample(self, state):
        h       = self.trunk(state)
        mean    = self.mean_head(h)
        log_std = self.log_std_head(h).clamp(-4, 2)
        std     = log_std.exp()
        eps     = torch.randn_like(mean)
        raw     = mean + std * eps
        action  = torch.tanh(raw)
        log_prob = (
            torch.distributions.Normal(mean, std).log_prob(raw)
            - torch.log(1 - action.pow(2) + 1e-6)
        ).sum(-1)
        return action, log_prob

Потеря CQL

def cql_loss(Q1, Q2, Q1_target, Q2_target, policy,
             states, actions, rewards, next_states, dones,
             alpha_cql=1.0, gamma=0.99, n_action_samples=10):
    """
    CQL loss = стандартная TD-потеря + консервативный штраф.
    alpha_cql: сила консерватизма. Больше = более пессимистично.
    """
    batch_size = states.shape[0]

    # ── 1. Стандартная TD-цель ────────────────────────────────────────────
    with torch.no_grad():
        next_actions, next_log_probs = policy.sample(next_states)
        q_next  = torch.min(Q1_target(next_states, next_actions),
                            Q2_target(next_states, next_actions))
        q_next -= 0.1 * next_log_probs    # SAC: энтропийный бонус
        # ↑ 0.1 — фиксированный коэффициент энтропии. Автонастройку см. ниже.
        td_target = rewards + gamma * (1 - dones) * q_next

    td_loss = F.mse_loss(Q1(states, actions), td_target)

    # ── 2. CQL штраф ──────────────────────────────────────────────────────
    # Случайные OOD-действия из равномерного распределения
    random_actions = torch.FloatTensor(
        batch_size * n_action_samples, actions.shape[-1]
    ).uniform_(-1, 1).to(states.device)

    # Действия текущей политики
    states_rep = states.unsqueeze(1).repeat(1, n_action_samples, 1).view(
        batch_size * n_action_samples, -1)
    policy_actions, policy_log_probs = policy.sample(states_rep)

    # Q-значения для случайных OOD-действий
    q_rand   = Q1(states.unsqueeze(1).repeat(1, n_action_samples, 1)
                  .view(batch_size * n_action_samples, -1),
                  random_actions).view(batch_size, n_action_samples)

    # Q-значения для policy-действий — с коррекцией importance weights.
    # Вычитаем log π(a|s): сэмплируем a ~ π, но нужен E_{uniform}[exp Q].
    q_policy_raw = Q1(states_rep, policy_actions).view(batch_size, n_action_samples)
    lp = policy_log_probs.detach().view(batch_size, n_action_samples)
    q_policy = q_policy_raw - lp     # importance weight: exp(Q)/π → exp(Q - log π)

    # Q-значения для действий из датасета
    q_data = Q1(states, actions)

    # Усредняем два предложения отдельно, затем объединяем (не смешивать в одном logsumexp)
    # log E[exp Q] ≈ log( (1/2) E_rand[exp Q] + (1/2) E_π[exp Q] )
    term_rand = torch.logsumexp(q_rand, dim=1) - math.log(n_action_samples)
    term_pi   = torch.logsumexp(q_policy, dim=1) - math.log(n_action_samples)
    logsumexp = torch.logsumexp(torch.stack([term_rand, term_pi], dim=1), dim=1) - math.log(2)
    cql_penalty = (logsumexp - q_data).mean()

    return td_loss + alpha_cql * cql_penalty, td_loss.item(), cql_penalty.item()

Автоматическая настройка alpha

# Настройка alpha через двойственный градиентный спуск
log_alpha_cql = torch.zeros(1, requires_grad=True, device=device)
alpha_opt     = optim.Adam([log_alpha_cql], lr=1e-4)
target_gap = 10.0   # τ: целевой CQL-штраф (разрыв logsumexp, ≥ 0)

# В шаге обновления:
alpha_cql  = log_alpha_cql.exp().item()
alpha_loss = -log_alpha_cql * (cql_penalty - target_gap)
alpha_opt.zero_grad()
alpha_loss.backward()
alpha_opt.step()

Замечание об энтропийном коэффициенте. В коде используется фиксированное 0.1 для SAC-члена энтропии. В полноценном CQL этот коэффициент тоже настраивается автоматически — с целью поддерживать энтропию политики на уровне $\mathcal{H}^* = -\dim(\mathcal{A})$ (одна нат на измерение действия):

# Автонастройка коэффициента энтропии (SAC-стиль)
log_alpha_ent = torch.zeros(1, requires_grad=True, device=device)
ent_opt       = optim.Adam([log_alpha_ent], lr=3e-4)
target_entropy = -action_dim   # H* = -dim(A)

# В шаге обновления политики:
alpha_ent  = log_alpha_ent.exp().item()
loss_pi    = (alpha_ent * log_probs - q_pi).mean()
# ...
ent_loss   = -(log_alpha_ent * (log_probs + target_entropy).detach()).mean()
ent_opt.zero_grad()
ent_loss.backward()
ent_opt.step()

Фиксированное 0.1 работает для задачи управления тепловым процессом, но для других сред может потребоваться корректировка. При неопределённости рекомендуется автонастройка.

$\alpha$ — важнейший гиперпараметр CQL. Он управляет компромиссом между консерватизмом и производительностью:

$\alpha$ Поведение
$\alpha = 0$ Стандартный SAC — нет консерватизма, эксплуатация OOD
$\alpha$ мало (0.1–1.0) Мягкий консерватизм — допускает некоторое улучшение политики
$\alpha$ велико (5–10) Сильный консерватизм — политика близка к $\pi_\beta$
$\alpha \to \infty$ Эквивалентно поведенческому клонированию

Интуиция: ландшафт Q-функции

Представьте Q-функцию как ландшафт над пространством состояние-действие. Стандартное Q-обучение формирует этот ландшафт только через точки данных — между ними ландшафт не ограничен и стремится расти из-за оптимистичного обобщения.

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

Политика, действующая жадно по этому ландшафту, предпочитает действия из датасета — не потому что её явно ограничили, а потому что ландшафт естественно направляет её туда.

Стандартное Q-обучение:      CQL:

  Q  ↑   *     *            Q  ↑   *     *
     |  / \   / \              |  * \   / *
     | /   \ /   \             | /   \ /   \
     |/     *     \            |/     *     \
     +----------→ a            +----------→ a
     ↑ точки датасета           ↑ точки датасета (закреплены высоко)
     ↑↑ OOD оптимизм            OOD занижены

(В HTML-версии эта диаграмма отображается как SVG-рисунок.)


Как выбрать α

$\alpha$ — важнейший гиперпараметр CQL. Правильное значение зависит от качества датасета, масштаба наград и доверия к поведенческой политике.

Шаг 1 — Проверьте знак CQL-штрафа

Перед настройкой убедитесь, что штраф положителен: $\mathbb{E}_\mu[Q] > \mathbb{E}_D[Q]$ должно выполняться после нескольких сотен обновлений. Если штраф отрицателен с самого начала — сэмплирование OOD-действий сломано.

Шаг 2 — Грид-поиск по логарифмической шкале

for alpha in [0.1, 0.5, 1.0, 5.0, 10.0]:
    agent = CQLAgent(..., alpha_cql=alpha)
    train_agent(agent, loader, n_epochs=80)
    res = evaluate(agent, env, ...)
    print(f"alpha={alpha:.1f}  reward={res['reward_mean']:.2f}")

Пять прогонов дают грубую карту компромисса награда–консерватизм.

Шаг 3 — Читайте диагностику

Симптом Диагноз Действие
Политика ≈ BC, нет улучшения $\alpha$ слишком велик — Q подавлен везде Уменьшить в 5–10×
Q-значения расходятся $\alpha$ слишком мал — OOD не контролируется Увеличить в 2–5×
CQL-штраф уходит в минус $\alpha$ слишком мал Увеличить $\alpha$
CQL-штраф >> TD-потеря $\alpha$ слишком велик или проблема масштаба наград Нормализовать награды; уменьшить $\alpha$

Типичные значения по типу данных

Тип датасета Стартовый $\alpha$ Обоснование
Только экспертные (почти оптимальные) 0.1 – 0.5 Поведенческая политика хороша; сильный консерватизм вредит
Смешанное качество (эксперт + случайный) 1.0 – 2.0 Безопасный дефолт
Случайные / шумные операторские данные 2.0 – 5.0 Высокий шум → больше OOD-риска → больше консерватизма
Промышленные логи с несколькими режимами 1.0 + авто-$\alpha$ Лагранжиан адаптируется к разным режимам

Когда использовать автонастройку

Используйте auto_alpha=True если: (а) политика будет развёрнута в реальной системе и безопасность важнее производительности, или (б) датасет охватывает несколько режимов работы с разными масштабами наград. Задайте target_gap положительным значением в шкале logsumexp-разрыва (например, 10.0).

Используйте фиксированный $\alpha$ если: проводите воспроизводимые офлайн-эксперименты, или уже нашли хорошее значение грид-поиском.


CQL vs TD3+BC

TD3+BC (Fujimoto & Gu, 2021) — более простая альтернатива, добавляющая BC-член прямо в потерю политики:

$$\pi^* = \arg\max_\pi \mathbb{E}_{(s,a) \sim \mathcal{D}} \left[ \lambda Q(s, \pi(s)) - (\pi(s) - a)^2 \right]$$

CQL TD3+BC
Где живёт пессимизм Q-функция Целевая функция политики
OOD Q-значения Явно занижаются Не изменяются
Теоретическая гарантия Да (нижняя оценка) Нет
Гиперпараметры $\alpha$ $\lambda$
Сложность реализации Средняя Низкая

TD3+BC — хорошая отправная точка для детерминированных политик. CQL более строго обоснован и обычно сильнее на сложных задачах.


Практические советы

Нормализуйте наблюдения. CQL чувствителен к масштабу. Всегда нормализуйте состояния по статистикам датасета.

Начинайте с $\alpha = 1.0$. Безопасное значение по умолчанию. Если политика слишком консервативна — уменьшайте. Если Q-значения расходятся — увеличивайте.

Используйте Double-Q. Всегда используйте две Q-сети и берите минимум. Это снижает переоценку независимо от CQL — два механизма дополняют друг друга.

Мониторьте CQL-штраф. Логируйте E_μ[Q] - E_D[Q] во время обучения. Он должен быть положительным и стабильным. Если уходит в минус — $\alpha$ слишком мало.

Совет для промышленных данных. Если датасет содержит несколько режимов работы (остановленный/медленный/быстрый), рассмотрите обучение отдельных агентов CQL для каждого режима или добавление режима как признака состояния.


Ограничения

Требует разметки наград. В отличие от BC, CQL нужны кортежи $(s, a, r, s')$. Если датасет содержит только $(s, a, s')$ — необходимо определить функцию вознаграждения.

Чувствителен к масштабу наград. Баланс между TD-потерей и CQL-штрафом зависит от величины наград. Если награды большие (например, в тысячах), TD-потеря доминирует и CQL теряет эффект. Нормализуйте награды в $[-1, 1]$ или $[0, 1]$.

Консервативен по построению. CQL не превзойдёт поведенческую политику значительно в регионах с редкими данными. Он создан для безопасности, а не для агрессивной экстраполяции. Для задач, требующих существенного выхода за пределы датасета, более уместны модельные методы (Глава 8).

Непрерывные пространства действий требуют сэмплирования. Аппроксимация logsumexp требует сэмплирования — как правило, 10 случайных + 10 политиковых действий на состояние. В коде два предложения усредняются отдельно (log E_rand[exp Q] и log E_π[exp Q] с importance weights для π), затем объединяются как log((1/2)(E_rand + E_π)), а не один logsumexp по всем 20 сэмплам, чтобы не искажать веса важности. Это увеличивает вычислительные затраты по сравнению с BC или TD3+BC.


Итоги

Свойство CQL
Необходимые данные Переходы $(s, a, r, s')$ с наградами
Целевая функция TD-потеря + CQL штраф (logsumexp − Q датасета)
Обработка OOD Явная: Q-значения занижаются для OOD-действий
Теоретическая гарантия Нижняя оценка истинной Q-функции в датасете
Ключевой гиперпараметр $\alpha$ (сила консерватизма)

CQL закрывает разрыв между поведенческим клонированием и полным офлайн RL. Он использует информацию о награде для улучшения над поведенческой политикой, а пессимизм относительно OOD-действий предотвращает катастрофические отказы стандартного Q-обучения.

Остающееся ограничение: CQL является безмодельным. Глава 5 (IQL) уточняет идею пессимизма по значениям. Глава 8 (MOPO) показывает, как обучение модели мира позволяет генерировать синтетические данные — расширяя эффективный датасет за пределы собранного.


Литература