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

Модуль 9. RLVR и GRPO: проверяемые награды, групповые оценки и устойчивость

Сначала попробуйте решить упражнение. Ответы, подробные решения и проектные ориентиры раскрываются отдельно и сохраняют своё состояние при переключении RU/EN.

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

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

RLVR или не RLVR

Для каждого случая укажите: RLVR, не RLVR или недостаточно данных.

  1. Модель порождает 16 программ, тесты выбирают лучшую, веса не меняются.

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

  3. Политика обучается DPO на заранее собранных парах математических решений.

  4. Ответы оценивает LLM-судья, затем выполняется PPO.

  5. Агент получает награду из симулятора за достигнутое состояние.

Кратко объясните каждую классификацию.

Показать ответ и решение
  1. Не RLVR-обучение. Проверяющая система используется только для Best-of-N; веса не меняются.

  2. RLVR. Проверяемый результат входит в обновление политики.

  3. Не RLVR на этой стадии. Это фиксированные пары и DPO; происхождение пар может быть связано с проверкой, но свежая проверяемая награда внутри обновления отсутствует.

  4. Недостаточно данных / обычно не RLVR. LLM-судья является модельным оценивателем, а не автоматически проверяемым правилом. Режим можно назвать RLHF или RLAIF в зависимости от источника.

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

Три политики

В запуске ответы собраны контрольной точкой step_1200; во время оптимизации веса уже находятся на step_1203; KL вычисляется относительно исходной SFT-модели. Назовите старую, текущую и базовую политики. Какая из них должна стоять в знаменателе отношения вероятностей?

Показать ответ и решение
  • старая политика: step_1200, породившая действия;
  • текущая политика: step_1203, для которой считается градиент;
  • базовая политика: исходная SFT-модель, задающая KL-якорь.

В знаменателе отношения вероятностей должна стоять фактическая политика поведения step_1200, включая преобразования декодирования. Базовая политика не заменяет этот знаменатель.

Два типа ошибок проверяющей системы

Приведите по одному примеру ложноположительной и ложноотрицательной награды для:

  1. числовой задачи;

  2. задачи по программированию.

Для каждого примера объясните, как многократная оптимизация может усилить ошибку.

Показать ответ и решение

Числа:

  • ложноположительная: парсер видит эталон 42 в тексте «42 неверно, ответ 41» и берёт первое число;
  • ложноотрицательная: правильная дробь 1/2 отклоняется, потому что разрешён только десятичный формат.

Код:

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

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

«Без критика»

Оцените утверждение:

«GRPO не использует критик, поэтому не использует опорный уровень и даёт несмещённое токеновое распределение заслуги».

Разделите ответ на три независимые части.

Показать ответ и решение
  1. Критик: GRPO действительно может обходиться без обучаемой модели ценности.

  2. Опорный уровень: он остаётся — это средняя награда группы или её вариант.

  3. Распределение заслуги: одна оценка ответа обычно распространяется на все его токены; она не определяет причинный вклад отдельных токенов и не является несмещённой токеновой оценкой преимущества.

Цена динамического добора

Почему удаление групп, в которых все награды одинаковы, может одновременно:

  • повысить эффективность одного обновления;
  • увеличить стоимость генерации;
  • изменить целевое распределение обучающих запросов?

Назовите минимум две метрики, которые следует сохранить для аудита.

Показать ответ и решение

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

Для аудита сохраняют как минимум:

  • долю отброшенных групп по причине;
  • число сгенерированных токенов на одну принятую группу;
  • исходную сложность и долю успешных ответов;
  • качество на полном, неотфильтрованном отложенном наборе.

Сопоставьте варианты

Сопоставьте метод и главное изменение.

  1. Dr. GRPO

  2. DAPO

  3. GSPO

  4. исходная GRPO

A. последовательностное геометрическое среднее отношений и клип на уровне ответа
B. групповая нормировка по стандартному отклонению и среднее по токенам каждого ответа
C. удаление нормировки по стандартному отклонению и индивидуального знаменателя длины
D. Clip-Higher, динамический добор, общая токеновая нормировка и обработка чрезмерной длины

Почему один реальный запуск может не совпасть полностью ни с одной строкой?

Показать ответ и решение

Соответствие: 1–C, 2–D, 3–A, 4–B.

Реальная реализация может взять центрирование Dr. GRPO, общую токеновую нормировку DAPO и асимметричный клип, но оставить токеновые отношения. Это гибрид; название одного исходного метода скрывает важные детали.

Энтропия и разнообразие

Объясните, почему следующие величины нельзя использовать как взаимозаменяемые:

  • средняя токеновая энтропия;
  • число различных строк в группе;
  • число различных стратегий решения;
  • pass@k.

Приведите пример, где одна величина велика, а другая мала.

Показать ответ и решение

Токеновая энтропия измеряет локальную неопределённость распределения словаря. Различные строки могут отличаться только пунктуацией. Семантические стратегии требуют отдельной кластеризации. pass@k зависит от вероятности хотя бы одного правильного ответа, а не непосредственно от энтропии.

Пример: модель выбирает один и тот же алгоритм, но варьирует формулировки — строковое разнообразие и токеновая энтропия высоки, стратегическое разнообразие мало. Обратный пример: два детерминированных шаблона решения выбираются с вероятностью 0,5 — локальная энтропия сосредоточена в одной точке выбора, но стратегий две.

Усечение

Ответ достиг лимита длины, не породив EOS. Сравните четыре решения:

  1. поставить награду 0 как за неверный ответ;

  2. исключить ответ из функции потерь;

  3. добавить мягкий штраф длины;

  4. увеличить лимит.

Для каждого назовите возможный полезный эффект и риск.

Показать ответ и решение
  1. Ноль как неверному: просто и даёт давление заканчивать; риск — наказывать полезное незавершённое рассуждение как содержательно неверное.

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

  3. Мягкий штраф: создаёт плавный сигнал у границы; риск — произвольная шкала и преждевременное сокращение трудных ответов.

  4. Увеличение лимита: позволяет увидеть завершение; риск — рост стоимости, памяти и возможность ещё более длинных повторов.

Вероятность информативной группы

Для бинарной награды, p=0.1p=0.1 и G=8G=8 вычислите

Pinfo=1−pG−(1−p)G.P_{\mathrm{info}}=1-p^G-(1-p)^G.

Сколько запросов в среднем нужно просмотреть, чтобы получить 32 информативные группы? Назовите предположение, без которого расчёт может быть слишком оптимистичным.

Показать ответ и решение
Pinfo=1−0.18−0.98=1−10−8−0.43046721≈0.56953278.P_{\mathrm{info}} =1-0.1^8-0.9^8 =1-10^{-8}-0.43046721 \approx0.56953278.

Для 32 информативных групп:

E[N]=320.56953278≈56.19.\mathbb E[N] =\frac{32}{0.56953278} \approx56.19.

Расчёт предполагает условно независимые ответы и примерно постоянный pp. Корреляция генераций или изменение политики делает его оптимистичным.

Один успех в группе

В группе из G=8G=8 ответов только один получил награду 1, остальные — 0. Найдите нормированные преимущества успешного и неуспешного ответов при популяционном стандартном отклонении. Проверьте, что сумма восьми преимуществ равна нулю.

Показать ответ и решение

Средняя награда равна 1/81/8. Популяционная дисперсия:

18(78)2+78(18)2=764.\frac18\left(\frac78\right)^2 + \frac78\left(\frac18\right)^2 = \frac7{64}.

Поэтому s=7/8s=\sqrt7/8.

Успех:

7/87/8=7≈2.6458.\frac{7/8}{\sqrt7/8}=\sqrt7\approx2.6458.

Неудача:

−1/87/8=−17≈−0.3780.\frac{-1/8}{\sqrt7/8}=-\frac1{\sqrt7}\approx-0.3780.

Сумма: 7−7/7=0\sqrt7-7/\sqrt7=0.

Вес токена и длина

Два ответа имеют награды [1,0][1,0] и длины [50,500][50,500]. Используется исходная GRPO: групповая нормировка по стандартному отклонению и коэффициент токена A^i/(G∣oi∣)\widehat A_i/(G|o_i|).

  1. Найдите преимущества.

  2. Найдите вес одного токена каждого ответа.

  3. Найдите суммарный вклад каждого ответа до учёта отношений вероятностей.

  4. Во сколько раз отличаются модули токеновых весов?

Показать ответ и решение

Для двух наград среднее 0.50.5, стандартное отклонение 0.50.5, поэтому преимущества равны [1,−1][1,-1].

Токеновые веса:

w1=12⋅50=0.01,w_1=\frac{1}{2\cdot50}=0.01, w2=−12⋅500=−0.001.w_2=\frac{-1}{2\cdot500}=-0.001.

Суммарные вклады:

50⋅0.01=0.5,500⋅(−0.001)=−0.5.50\cdot0.01=0.5, \qquad 500\cdot(-0.001)=-0.5.

Модули токеновых весов отличаются в 10 раз.

Мягкий штраф DAPO

Пусть Lmax⁡=1000L_{\max}=1000, Lcache=200L_{\mathrm{cache}}=200. Вычислите RlengthR_{\mathrm{length}} для длин 750, 900, 1000 и 1200. Объясните, почему значение для 900 не равно бинарному штрафу.

Показать ответ и решение

Граница без штрафа: Lmax⁡−Lcache=800L_{\max}-L_{\mathrm{cache}}=800.

  • 750: 00;
  • 900: (800−900)/200=−0.5(800-900)/200=-0.5;
  • 1000: (800−1000)/200=−1(800-1000)/200=-1;
  • 1200: −1-1.

Для 900 штраф лежит внутри линейного перехода. Он сообщает, насколько ответ приблизился к пределу, вместо одного бинарного решения.

Последовательностное отношение GSPO

Для четырёх активных токенов токеновые отношения равны

(1.01,0.99,1.02,1.00).(1.01, 0.99, 1.02, 1.00).

Вычислите

s=exp⁡(14∑tlog⁡ρt).s=\exp\left(\frac14\sum_t\log\rho_t\right).

Сравните его с арифметическим средним. Почему GSPO использует геометрическое?

Показать ответ и решение
s=(1.01⋅0.99⋅1.02⋅1.00)1/4≈1.0049.s=(1.01\cdot0.99\cdot1.02\cdot1.00)^{1/4} \approx1.0049.

Арифметическое среднее равно 1.0051.005. Здесь числа близки, но в общем случае различаются. Геометрическое среднее естественно возникает из произведения вероятностей последовательности и среднего логарифма отношения; оно не требует перемножать очень малые вероятности напрямую.

pass@k в независимой модели

При вероятности успеха одной попытки p=0.2p=0.2 вычислите вероятность хотя бы одного успеха для k=1k=1, 44 и 1616:

1−(1−p)k.1-(1-p)^k.

Почему эти значения нельзя без проверки переносить на группу ответов LLM?

Показать ответ и решение
pass@1⁡=0.2,\operatorname{pass@1}=0.2, pass@4⁡=1−0.84=0.5904,\operatorname{pass@4}=1-0.8^4=0.5904, pass@16⁡=1−0.816≈0.9719.\operatorname{pass@16}=1-0.8^{16}\approx0.9719.

Ответы LLM могут быть коррелированы, иметь разную вероятность успеха по запросам и зависеть от общих первых токенов. Кроме того, стандартная конечновыборочная оценка pass@k использует другой комбинаторный оцениватель.

Числовой верификатор

Реализуйте:

Python
def verify_numeric_answer(
    response: str,
    gold: str,
    rtol: float = 1e-6,
    atol: float = 1e-9,
) -> int:
    ...

Контракт:

  • последнее \\boxed{...} имеет приоритет;
  • иначе берётся последнее число или дробь;
  • поддерживаются разделители тысяч, десятичные числа, научная запись и дроби;
  • нулевой знаменатель, NaN, бесконечность и отсутствие ответа дают 0;
  • сравнение: abs(a-g) <= atol + rtol*max(1, abs(g)).

Добавьте минимум восемь тестов, включая два состязательных.

Открытые проверки:

Python
assert verify_numeric_answer(r"\\boxed{3/7}", "0.428571") == 1
assert verify_numeric_answer(r"wrong 42, final \\boxed{41}", "42") == 0
assert verify_numeric_answer("1,000", "1000") == 1
Показать ответ и решение
Python
from fractions import Fraction
import math
import re

NUMBER_PATTERN = re.compile(
    r"-?\d+\s*/\s*-?\d+|-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
)
BOXED_PATTERN = re.compile(r"\\boxed\{([^{}]*)\}")
THOUSANDS_SEPARATOR = re.compile(r"(?<=\d),(?=\d{3}(?:\D|$))")


def extract_numeric_answer(text: str) -> str | None:
    if not isinstance(text, str):
        raise TypeError("text must be a string")
    boxed = BOXED_PATTERN.findall(text)
    if boxed:
        candidate = boxed[-1].strip()
        return candidate or None
    normalized = THOUSANDS_SEPARATOR.sub("", text)
    matches = list(NUMBER_PATTERN.finditer(normalized))
    return matches[-1].group(0).strip() if matches else None


def parse_numeric(value: str) -> float:
    if not isinstance(value, str):
        raise TypeError("value must be a string")
    value = THOUSANDS_SEPARATOR.sub("", value).replace(" ", "").strip()
    if not value:
        raise ValueError("empty numeric value")
    if "/" in value:
        parts = value.split("/")
        if len(parts) != 2:
            raise ValueError("fraction must contain one slash")
        numerator, denominator = (int(part) for part in parts)
        if denominator == 0:
            raise ZeroDivisionError("fraction denominator must be non-zero")
        result = float(Fraction(numerator, denominator))
    else:
        result = float(value)
    if not math.isfinite(result):
        raise ValueError("numeric value must be finite")
    return result


def verify_numeric_answer(
    response: str,
    gold: str,
    rtol: float = 1e-6,
    atol: float = 1e-9,
) -> int:
    if rtol < 0 or atol < 0 or not math.isfinite(rtol) or not math.isfinite(atol):
        raise ValueError("rtol and atol must be finite and non-negative")
    answer = extract_numeric_answer(response)
    if answer is None:
        return 0
    try:
        observed = parse_numeric(answer)
        expected = parse_numeric(gold)
    except (TypeError, ValueError, ZeroDivisionError, OverflowError):
        return 0
    tolerance = atol + rtol * max(1.0, abs(expected))
    return int(abs(observed - expected) <= tolerance)

Проверки:

Python
cases = [
    (r"\\boxed{42}", "42", 1),
    ("answer: 1/2", "0.5", 1),
    (r"wrong 42, final \\boxed{41}", "42", 0),
    ("1,000", "1000", 1),
    ("1e3", "1000", 1),
    (r"\\boxed{1/0}", "0", 0),
    ("no answer", "1", 0),
    ("42 items minus 1 = 41", "42", 0),
]
for response, gold, expected in cases:
    assert verify_numeric_answer(response, gold) == expected

Дробь в регулярном выражении проверяется раньше обычного числа: иначе 1/2 можно было бы ошибочно разобрать как два независимых целых. Такой верификатор намеренно не решает задачу символьной эквивалентности; его контракт прозрачен и подходит только для оговорённого класса числовых ответов.

Групповые преимущества

Реализуйте:

Python
import numpy as np

def group_relative_advantages(
    rewards,
    scale: bool = True,
    eps: float = 1e-8,
) -> np.ndarray:
    ...

Требования:

  • популяционное стандартное отклонение;
  • при scale=False возвращается только центрирование;
  • вырожденная группа при scale=True возвращает нули;
  • вход не должен содержать NaN или бесконечность.

Проверьте группы [1,0,0,0], [0.9,1.0,0.8,0.9] и [1,1,1,1].

Открытые проверки:

Python
a = group_relative_advantages([1, 0, 0, 0])
expected = [np.sqrt(3), -1 / np.sqrt(3), -1 / np.sqrt(3), -1 / np.sqrt(3)]
assert np.allclose(a, expected, atol=1e-7)
assert np.array_equal(group_relative_advantages([1, 1, 1, 1]), np.zeros(4))
Показать ответ и решение
Python
import math
import numpy as np


def group_relative_advantages(
    rewards,
    scale: bool = True,
    eps: float = 1e-8,
) -> np.ndarray:
    r = np.asarray(rewards, dtype=float)
    if r.ndim != 1 or r.size == 0 or not np.all(np.isfinite(r)):
        raise ValueError("rewards must be a non-empty finite 1D array")
    if eps < 0 or not math.isfinite(eps):
        raise ValueError("eps must be finite and non-negative")
    centered = r - r.mean()
    if not scale:
        return centered
    std = float(r.std(ddof=0))
    if std == 0.0:
        return np.zeros_like(r)
    return centered / (std + eps)
Python
assert np.allclose(
    group_relative_advantages([1, 0, 0, 0]),
    [np.sqrt(3), -1/np.sqrt(3), -1/np.sqrt(3), -1/np.sqrt(3)],
    atol=1e-7,
)
assert np.allclose(
    group_relative_advantages([0.9, 1.0, 0.8, 0.9], scale=False),
    [0.0, 0.1, -0.1, 0.0],
)
assert np.array_equal(group_relative_advantages([1, 1, 1, 1]), np.zeros(4))

Нулевой результат вырожденной группы возникает из нулевого центрированного числителя. Мы не делим случайный численный шум на eps и не создаём искусственный относительный сигнал там, где все награды одинаковы.

Асимметричный клип

Реализуйте токеновые члены и маску активной клиппированной ветви:

Python
import numpy as np

def clipped_terms(
    ratios,
    advantages,
    eps_low: float = 0.2,
    eps_high: float | None = None,
) -> np.ndarray:
    ...


def clip_activity(
    ratios,
    advantages,
    eps_low: float = 0.2,
    eps_high: float | None = None,
) -> np.ndarray:
    ...

Сравните симметричный клип 0.2/0.2 и Clip-Higher 0.2/0.28 на

text
ratios     = [1.50, 1.25, 0.70, 1.10]
advantages = [1.00, 1.00,-1.00, 1.00]

Открытые проверки:

Python
out = clipped_terms([1.5, 0.7, 1.1], [1.0, -1.0, -1.0], eps_low=0.2)
assert np.allclose(out, [1.2, -0.8, -1.1])
active = clip_activity([1.5, 0.7, 1.1], [1.0, -1.0, -1.0], eps_low=0.2)
assert np.array_equal(active, [True, True, False])
Показать ответ и решение
Python
import math
import numpy as np


def _paired_vectors(ratios, advantages):
    rho = np.asarray(ratios, dtype=float)
    adv = np.asarray(advantages, dtype=float)
    if rho.ndim != 1 or adv.shape != rho.shape or rho.size == 0:
        raise ValueError("ratios and advantages must be equal non-empty vectors")
    if not np.all(np.isfinite(rho)) or not np.all(np.isfinite(adv)):
        raise ValueError("ratios and advantages must be finite")
    if np.any(rho <= 0):
        raise ValueError("ratios must be strictly positive")
    return rho, adv


def clipped_terms(ratios, advantages, eps_low=0.2, eps_high=None):
    rho, adv = _paired_vectors(ratios, advantages)
    if eps_high is None:
        eps_high = eps_low
    if eps_low < 0 or eps_high < 0 or not math.isfinite(eps_low) or not math.isfinite(eps_high):
        raise ValueError("clip widths must be finite and non-negative")
    clipped = np.clip(rho, 1.0 - eps_low, 1.0 + eps_high)
    return np.minimum(rho * adv, clipped * adv)


def clip_activity(ratios, advantages, eps_low=0.2, eps_high=None):
    rho, adv = _paired_vectors(ratios, advantages)
    if eps_high is None:
        eps_high = eps_low
    if eps_low < 0 or eps_high < 0:
        raise ValueError("clip widths must be non-negative")
    return ((adv > 0) & (rho > 1.0 + eps_high)) | ((adv < 0) & (rho < 1.0 - eps_low))
Python
ratios = np.array([1.50, 1.25, 0.70, 1.10])
advs = np.array([1.00, 1.00, -1.00, 1.00])
sym = clipped_terms(ratios, advs, eps_low=0.2)
asym = clipped_terms(ratios, advs, eps_low=0.2, eps_high=0.28)
assert np.allclose(sym, [1.2, 1.2, -0.8, 1.1])
assert np.allclose(asym, [1.28, 1.25, -0.8, 1.1])
assert np.array_equal(
    clip_activity(ratios, advs, eps_low=0.2, eps_high=0.28),
    [True, False, True, False],
)

Clip-Higher освобождает второй положительный член, но не меняет нижнюю границу для отрицательного преимущества. Маска отмечает места, где минимум выбрал постоянную клиппированную ветвь и локальный градиент по отношению равен нулю.

Три нормировки

Для одного набора наград и длин верните коэффициенты токенов и суммарные вклады ответов для:

  1. исходной GRPO;

  2. Dr. GRPO с фиксированной длиной LL;

  3. DAPO с общим числом активных токенов.

Проверьте rewards=[1,0], lengths=[10,1000], L=1000. Объясните, какая схема равняет ответы, а какая делает вклад пропорциональным длине.

Сигнатуры:

Python
import numpy as np

def original_grpo_token_weights(rewards, lengths, *, eps: float = 1e-8) -> list[np.ndarray]: ...
def dr_grpo_token_weights(rewards, lengths, *, fixed_length: int) -> list[np.ndarray]: ...
def dapo_token_weights(rewards, lengths, *, scale_rewards: bool = True) -> list[np.ndarray]: ...
def response_contributions(token_weights) -> np.ndarray: ...

Открытые проверки:

Python
rewards, lengths = [1.0, 0.0], [10, 1000]
grpo = response_contributions(original_grpo_token_weights(rewards, lengths))
dr = response_contributions(dr_grpo_token_weights(rewards, lengths, fixed_length=1000))
dapo = response_contributions(dapo_token_weights(rewards, lengths))
assert np.allclose(grpo, [0.5, -0.5])
assert abs(dr[0] - 0.0025) < 1e-9
assert abs(dapo[0] - 0.009901) < 1e-6
Показать ответ и решение
Python
import math
import numpy as np


def _rewards_and_lengths(rewards, lengths):
    r = np.asarray(rewards, dtype=float)
    lens = np.asarray(lengths, dtype=int)
    if r.ndim != 1 or r.size == 0 or not np.all(np.isfinite(r)):
        raise ValueError("rewards must be a non-empty finite vector")
    if lens.ndim != 1 or lens.shape != r.shape or np.any(lens <= 0):
        raise ValueError("lengths must be positive and match rewards")
    return r, lens


def _advantages(rewards, *, scale, eps=1e-8):
    centered = rewards - rewards.mean()
    if not scale:
        return centered
    if eps < 0 or not math.isfinite(eps):
        raise ValueError("eps must be finite and non-negative")
    std = float(rewards.std(ddof=0))
    return np.zeros_like(rewards) if std == 0.0 else centered / (std + eps)


def original_grpo_token_weights(rewards, lengths, *, eps=1e-8):
    r, lens = _rewards_and_lengths(rewards, lengths)
    adv = _advantages(r, scale=True, eps=eps)
    group_size = r.size
    return [
        np.full(int(length), a / (group_size * length), dtype=float)
        for a, length in zip(adv, lens)
    ]


def dr_grpo_token_weights(rewards, lengths, *, fixed_length):
    r, lens = _rewards_and_lengths(rewards, lengths)
    if fixed_length <= 0:
        raise ValueError("fixed_length must be positive")
    adv = _advantages(r, scale=False)
    group_size = r.size
    return [
        np.full(int(length), a / (group_size * fixed_length), dtype=float)
        for a, length in zip(adv, lens)
    ]


def dapo_token_weights(rewards, lengths, *, scale_rewards=True):
    r, lens = _rewards_and_lengths(rewards, lengths)
    adv = _advantages(r, scale=scale_rewards)
    total_active_tokens = int(lens.sum())
    return [
        np.full(int(length), a / total_active_tokens, dtype=float)
        for a, length in zip(adv, lens)
    ]


def response_contributions(token_weights):
    if not token_weights:
        raise ValueError("token_weights must be non-empty")
    return np.asarray([np.asarray(w, dtype=float).sum() for w in token_weights])
Python
rewards, lengths = [1.0, 0.0], [10, 1000]
assert np.allclose(
    response_contributions(original_grpo_token_weights(rewards, lengths)),
    [0.5, -0.5],
)
assert np.allclose(
    response_contributions(dr_grpo_token_weights(rewards, lengths, fixed_length=1000)),
    [0.0025, -0.25],
)
assert np.allclose(
    response_contributions(dapo_token_weights(rewards, lengths)),
    [10/1010, -1000/1010],
    atol=1e-8,
)

Исходная GRPO нормирует каждый ответ по его собственной длине и потому даёт равные по модулю суммарные вклады. В показанных вариантах Dr. GRPO и DAPO коэффициент токена внутри ответа не делится на длину этого ответа, поэтому суммарный вклад становится пропорционален числу активных токенов. Абсолютный масштаб у двух схем различается из-за разных общих знаменателей и разной нормировки наград.

Последовательностное отношение и клип GSPO

Реализуйте:

Python
import numpy as np

def sequence_importance_ratio(new_logp, old_logp, mask=None) -> float:
    ...


def gspo_surrogate_term(
    new_logp,
    old_logp,
    advantage: float,
    eps_low: float = 3e-4,
    eps_high: float = 4e-4,
    mask=None,
) -> float:
    ...

Первую функцию вычисляйте как экспоненту среднего активного логарифма отношения. Затем используйте это отношение в клиппированном последовательностном члене. Тесты должны включать заполнение с маской и очень длинную последовательность, чтобы не перемножать вероятности напрямую.

Открытые проверки:

Python
new = np.array([-0.4, -0.6])
old = np.array([-0.5, -0.5])
mask = np.array([1, 1])
ratio = sequence_importance_ratio(new, old, mask)
assert abs(ratio - 1.0) < 1e-12
assert abs(sequence_importance_ratio(old, old, mask) - 1.0) < 1e-12
assert abs(gspo_surrogate_term(new, old, 1.0, mask=mask) - 1.0) < 1e-12
Показать ответ и решение
Python
import math
import numpy as np


def _active_log_ratio(new_logp, old_logp, mask=None):
    new = np.asarray(new_logp, dtype=float)
    old = np.asarray(old_logp, dtype=float)
    if new.ndim != 1 or old.shape != new.shape or new.size == 0:
        raise ValueError("new_logp and old_logp must be equal non-empty vectors")
    if not np.all(np.isfinite(new)) or not np.all(np.isfinite(old)):
        raise ValueError("log-probabilities must be finite")
    active = np.ones_like(new, dtype=bool)
    if mask is not None:
        m = np.asarray(mask)
        if m.shape != new.shape or np.any((m != 0) & (m != 1)):
            raise ValueError("mask must match and contain only 0/1")
        active = m.astype(bool)
    if not active.any():
        raise ValueError("at least one token must be active")
    return (new - old)[active]


def sequence_importance_ratio(new_logp, old_logp, mask=None):
    mean_log_ratio = float(_active_log_ratio(new_logp, old_logp, mask).mean())
    return float(np.exp(mean_log_ratio))


def gspo_surrogate_term(
    new_logp,
    old_logp,
    advantage,
    eps_low=3e-4,
    eps_high=4e-4,
    mask=None,
):
    if not math.isfinite(advantage):
        raise ValueError("advantage must be finite")
    if eps_low < 0 or eps_high < 0:
        raise ValueError("clip widths must be non-negative")
    ratio = sequence_importance_ratio(new_logp, old_logp, mask)
    clipped = float(np.clip(ratio, 1.0 - eps_low, 1.0 + eps_high))
    return float(min(ratio * advantage, clipped * advantage))
Python
old = np.log([0.20, 0.40, 0.50, 0.30])
new = old + np.log([1.01, 0.99, 1.02, 10.0])
ratio = sequence_importance_ratio(new, old, mask=[1, 1, 1, 0])
expected = (1.01 * 0.99 * 1.02) ** (1 / 3)
assert np.isclose(ratio, expected)

# Длинная последовательность остаётся численно устойчивой: суммируются логи.
long_old = np.full(100_000, -20.0)
long_new = long_old + 1e-4
assert np.isclose(sequence_importance_ratio(long_new, long_old), np.exp(1e-4))

Расчёт выполняется через средний логарифм и одну экспоненту, поэтому не страдает от потери точности при прямом произведении тысяч малых вероятностей.

Минимальный пакет роллаута

Создайте структуру данных для одного ответа с полями:

  • prompt_id, group_id;
  • token_ids, action_mask, old_logprobs;
  • компоненты награды;
  • причина остановки;
  • версия генератора.

Напишите валидатор, который проверяет длины, конечность чисел, наличие активного действия и допустимую причину остановки. Добавьте тесты для корректного ответа, пустой маски, несовпадающих длин и NaN в логарифмах вероятностей.

Стартовый контракт:

Python
from dataclasses import dataclass

@dataclass(frozen=True)
class RolloutRecord:
    prompt_id: str
    group_id: str
    token_ids: tuple[int, ...]
    action_mask: tuple[int, ...]
    old_logprobs: tuple[float, ...]
    reward_components: tuple[float, ...]
    stop_reason: str
    generator_version: str


def validate_rollout_record(record: RolloutRecord) -> None:
    ...

Это расширение, а не новая работа. Возьмите валидатор пакета из задания C6 модуля 6 (validate_rollout_batch) и приспособьте его к одной записи вместо пакета с дополнением. Новыми являются только поля среды и причины остановки; проверки длин и конечности чисел переносятся как есть.

Открытые проверки:

Python
record = RolloutRecord(
    prompt_id="q1", group_id="g1", token_ids=(1, 2, 3), action_mask=(0, 1, 1),
    old_logprobs=(0.0, -0.2, -0.3), reward_components=(1.0,),
    stop_reason="eos", generator_version="v1",
)
assert validate_rollout_record(record) is None
empty_mask = RolloutRecord(
    prompt_id="q1", group_id="g1", token_ids=(1, 2, 3), action_mask=(0, 0, 0),
    old_logprobs=(0.0, -0.2, -0.3), reward_components=(1.0,),
    stop_reason="eos", generator_version="v1",
)
try:
    validate_rollout_record(empty_mask)
    raise AssertionError("пустая маска должна отклоняться")
except ValueError:
    pass
Показать ответ и решение
Python
from dataclasses import dataclass
import math


@dataclass(frozen=True)
class RolloutRecord:
    prompt_id: str
    group_id: str
    token_ids: tuple[int, ...]
    action_mask: tuple[int, ...]
    old_logprobs: tuple[float, ...]
    reward_components: tuple[float, ...]
    stop_reason: str
    generator_version: str


def validate_rollout_record(record: RolloutRecord) -> None:
    if not record.prompt_id or not record.group_id or not record.generator_version:
        raise ValueError("prompt_id, group_id, and generator_version must be non-empty")
    n = len(record.token_ids)
    if n == 0 or len(record.action_mask) != n or len(record.old_logprobs) != n:
        raise ValueError("token_ids, action_mask, and old_logprobs must align")
    if any(m not in (0, 1) for m in record.action_mask) or not any(record.action_mask):
        raise ValueError("action_mask must contain 0/1 and at least one active action")
    if not all(math.isfinite(x) for x in record.old_logprobs):
        raise ValueError("old_logprobs must be finite")
    if not record.reward_components or not all(math.isfinite(x) for x in record.reward_components):
        raise ValueError("reward_components must be a non-empty finite sequence")
    if record.stop_reason not in {"eos", "stop_string", "truncated", "environment", "error"}:
        raise ValueError("unsupported stop_reason")
Python
valid = RolloutRecord(
    prompt_id="p-17",
    group_id="g-4",
    token_ids=(101, 102, 103),
    action_mask=(0, 1, 1),
    old_logprobs=(-2.0, -0.4, -0.8),
    reward_components=(1.0, 0.1),
    stop_reason="eos",
    generator_version="checkpoint-1200",
)
validate_rollout_record(valid)

for invalid in (
    RolloutRecord("p", "g", (1, 2), (0, 0), (-1.0, -1.0), (1.0,), "eos", "v1"),
    RolloutRecord("p", "g", (1, 2), (1,), (-1.0, -1.0), (1.0,), "eos", "v1"),
    RolloutRecord("p", "g", (1,), (1,), (float("nan"),), (1.0,), "eos", "v1"),
):
    try:
        validate_rollout_record(invalid)
        raise AssertionError("invalid record was accepted")
    except ValueError:
        pass

Валидатор проверяет структурную целостность, но не доказывает, что old_logprobs действительно получены фактической политикой поведения. Для этого нужен воспроизводимый генерационный протокол и сверка версии политики.