Модуль 9. RLVR и GRPO: проверяемые награды, групповые оценки и устойчивость
Сначала попробуйте решить упражнение. Ответы, подробные решения и проектные ориентиры раскрываются отдельно и сохраняют своё состояние при переключении RU/EN.
Скачать файлы курса (.zip) 382,1 КБАрхив содержит файлы на обоих языках и вспомогательные материалы с сохранением структуры каталогов.
RLVR или не RLVR
Для каждого случая укажите: RLVR, не RLVR или недостаточно данных.
-
Модель порождает 16 программ, тесты выбирают лучшую, веса не меняются.
-
Модель порождает программы, результаты тестов входят в градиентное обновление политики.
-
Политика обучается DPO на заранее собранных парах математических решений.
-
Ответы оценивает LLM-судья, затем выполняется PPO.
-
Агент получает награду из симулятора за достигнутое состояние.
Кратко объясните каждую классификацию.
Показать ответ и решение
-
Не RLVR-обучение. Проверяющая система используется только для Best-of-N; веса не меняются.
-
RLVR. Проверяемый результат входит в обновление политики.
-
Не RLVR на этой стадии. Это фиксированные пары и DPO; происхождение пар может быть связано с проверкой, но свежая проверяемая награда внутри обновления отсутствует.
-
Недостаточно данных / обычно не RLVR. LLM-судья является модельным оценивателем, а не автоматически проверяемым правилом. Режим можно назвать RLHF или RLAIF в зависимости от источника.
-
RLVR в широком операциональном смысле, если переход и награда среды воспроизводимы и входят в обновление. Для стохастической среды нужно сохранять состояние и начальное значение генератора.
Три политики
В запуске ответы собраны контрольной точкой step_1200; во время оптимизации веса уже находятся на step_1203; KL вычисляется относительно исходной SFT-модели. Назовите старую, текущую и базовую политики. Какая из них должна стоять в знаменателе отношения вероятностей?
Показать ответ и решение
- старая политика:
step_1200, породившая действия; - текущая политика:
step_1203, для которой считается градиент; - базовая политика: исходная SFT-модель, задающая KL-якорь.
В знаменателе отношения вероятностей должна стоять фактическая политика поведения step_1200, включая преобразования декодирования. Базовая политика не заменяет этот знаменатель.
Два типа ошибок проверяющей системы
Приведите по одному примеру ложноположительной и ложноотрицательной награды для:
-
числовой задачи;
-
задачи по программированию.
Для каждого примера объясните, как многократная оптимизация может усилить ошибку.
Показать ответ и решение
Числа:
- ложноположительная: парсер видит эталон
42в тексте «42 неверно, ответ 41» и берёт первое число; - ложноотрицательная: правильная дробь
1/2отклоняется, потому что разрешён только десятичный формат.
Код:
- ложноположительная: тесты не проверяют побочный эффект или граничный случай, и программа подбирает константный ответ;
- ложноотрицательная: корректная программа завершается по тайм-ауту из-за нестабильного окружения.
Оптимизация повышает вероятность шаблонов, которые систематически проходят, и подавляет те, которые систематически отклоняются. Поэтому малая ошибка проверки может стать доминирующей стратегией.
«Без критика»
Оцените утверждение:
«GRPO не использует критик, поэтому не использует опорный уровень и даёт несмещённое токеновое распределение заслуги».
Разделите ответ на три независимые части.
Показать ответ и решение
-
Критик: GRPO действительно может обходиться без обучаемой модели ценности.
-
Опорный уровень: он остаётся — это средняя награда группы или её вариант.
-
Распределение заслуги: одна оценка ответа обычно распространяется на все его токены; она не определяет причинный вклад отдельных токенов и не является несмещённой токеновой оценкой преимущества.
Цена динамического добора
Почему удаление групп, в которых все награды одинаковы, может одновременно:
- повысить эффективность одного обновления;
- увеличить стоимость генерации;
- изменить целевое распределение обучающих запросов?
Назовите минимум две метрики, которые следует сохранить для аудита.
Показать ответ и решение
Удаление вырожденных групп повышает долю запросов с ненулевыми преимуществами, но требует дополнительных генераций. Оно также переобучает распределение к задачам средней текущей сложности.
Для аудита сохраняют как минимум:
- долю отброшенных групп по причине;
- число сгенерированных токенов на одну принятую группу;
- исходную сложность и долю успешных ответов;
- качество на полном, неотфильтрованном отложенном наборе.
Сопоставьте варианты
Сопоставьте метод и главное изменение.
-
Dr. GRPO
-
DAPO
-
GSPO
-
исходная 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. Сравните четыре решения:
-
поставить награду 0 как за неверный ответ;
-
исключить ответ из функции потерь;
-
добавить мягкий штраф длины;
-
увеличить лимит.
Для каждого назовите возможный полезный эффект и риск.
Показать ответ и решение
-
Ноль как неверному: просто и даёт давление заканчивать; риск — наказывать полезное незавершённое рассуждение как содержательно неверное.
-
Исключение: убирает шум неизвестного знака; риск — политика может уходить от отрицательной награды в бесконечное продолжение.
-
Мягкий штраф: создаёт плавный сигнал у границы; риск — произвольная шкала и преждевременное сокращение трудных ответов.
-
Увеличение лимита: позволяет увидеть завершение; риск — рост стоимости, памяти и возможность ещё более длинных повторов.
Вероятность информативной группы
Для бинарной награды, и вычислите
Сколько запросов в среднем нужно просмотреть, чтобы получить 32 информативные группы? Назовите предположение, без которого расчёт может быть слишком оптимистичным.
Показать ответ и решение
Для 32 информативных групп:
Расчёт предполагает условно независимые ответы и примерно постоянный . Корреляция генераций или изменение политики делает его оптимистичным.
Один успех в группе
В группе из ответов только один получил награду 1, остальные — 0. Найдите нормированные преимущества успешного и неуспешного ответов при популяционном стандартном отклонении. Проверьте, что сумма восьми преимуществ равна нулю.
Показать ответ и решение
Средняя награда равна . Популяционная дисперсия:
Поэтому .
Успех:
Неудача:
Сумма: .
Вес токена и длина
Два ответа имеют награды и длины . Используется исходная GRPO: групповая нормировка по стандартному отклонению и коэффициент токена .
-
Найдите преимущества.
-
Найдите вес одного токена каждого ответа.
-
Найдите суммарный вклад каждого ответа до учёта отношений вероятностей.
-
Во сколько раз отличаются модули токеновых весов?
Показать ответ и решение
Для двух наград среднее , стандартное отклонение , поэтому преимущества равны .
Токеновые веса:
Суммарные вклады:
Модули токеновых весов отличаются в 10 раз.
Мягкий штраф DAPO
Пусть , . Вычислите для длин 750, 900, 1000 и 1200. Объясните, почему значение для 900 не равно бинарному штрафу.
Показать ответ и решение
Граница без штрафа: .
- 750: ;
- 900: ;
- 1000: ;
- 1200: .
Для 900 штраф лежит внутри линейного перехода. Он сообщает, насколько ответ приблизился к пределу, вместо одного бинарного решения.
Последовательностное отношение GSPO
Для четырёх активных токенов токеновые отношения равны
Вычислите
Сравните его с арифметическим средним. Почему GSPO использует геометрическое?
Показать ответ и решение
Арифметическое среднее равно . Здесь числа близки, но в общем случае различаются. Геометрическое среднее естественно возникает из произведения вероятностей последовательности и среднего логарифма отношения; оно не требует перемножать очень малые вероятности напрямую.
pass@k в независимой модели
При вероятности успеха одной попытки вычислите вероятность хотя бы одного успеха для , и :
Почему эти значения нельзя без проверки переносить на группу ответов LLM?
Показать ответ и решение
Ответы LLM могут быть коррелированы, иметь разную вероятность успеха по запросам и зависеть от общих первых токенов. Кроме того, стандартная конечновыборочная оценка pass@k использует другой комбинаторный оцениватель.
Числовой верификатор
Реализуйте:
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)).
Добавьте минимум восемь тестов, включая два состязательных.
Открытые проверки:
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Показать ответ и решение
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)Проверки:
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 можно было бы ошибочно разобрать как два независимых целых. Такой верификатор намеренно не решает задачу символьной эквивалентности; его контракт прозрачен и подходит только для оговорённого класса числовых ответов.
Групповые преимущества
Реализуйте:
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].
Открытые проверки:
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))Показать ответ и решение
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)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 и не создаём искусственный относительный сигнал там, где все награды одинаковы.
Асимметричный клип
Реализуйте токеновые члены и маску активной клиппированной ветви:
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 на
ratios = [1.50, 1.25, 0.70, 1.10]
advantages = [1.00, 1.00,-1.00, 1.00]Открытые проверки:
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])Показать ответ и решение
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))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 освобождает второй положительный член, но не меняет нижнюю границу для отрицательного преимущества. Маска отмечает места, где минимум выбрал постоянную клиппированную ветвь и локальный градиент по отношению равен нулю.
Три нормировки
Для одного набора наград и длин верните коэффициенты токенов и суммарные вклады ответов для:
-
исходной GRPO;
-
Dr. GRPO с фиксированной длиной ;
-
DAPO с общим числом активных токенов.
Проверьте rewards=[1,0], lengths=[10,1000], L=1000. Объясните, какая схема равняет ответы, а какая делает вклад пропорциональным длине.
Сигнатуры:
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: ...Открытые проверки:
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Показать ответ и решение
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])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
Реализуйте:
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:
...Первую функцию вычисляйте как экспоненту среднего активного логарифма отношения. Затем используйте это отношение в клиппированном последовательностном члене. Тесты должны включать заполнение с маской и очень длинную последовательность, чтобы не перемножать вероятности напрямую.
Открытые проверки:
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Показать ответ и решение
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))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 в логарифмах вероятностей.
Стартовый контракт:
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) и приспособьте его к одной записи вместо пакета с дополнением. Новыми являются только поля среды и причины остановки; проверки длин и конечности чисел переносятся как есть.
Открытые проверки:
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Показать ответ и решение
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")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 действительно получены фактической политикой поведения. Для этого нужен воспроизводимый генерационный протокол и сверка версии политики.