Перейти к содержимому
Ступень 66.4 Рассуждения и RL — путь DeepSeek-R1

Практикум — GRPO своими руками

Мини-R1-Zero за пару минут на CPU — крошечная модель учится рассуждать через GRPO, и вы видите рост награды и длины ответа; затем Qwen2.5-0.5B на задаче Countdown через TRL и GSM8K через verl на GPU, с разбором метрик и типичных поломок.

УРОКstage-6/6-4-reasoning/05-grpo-practice.mdx3 чУрок 5 из 5
После урока вы сможете
  • Собрать полный цикл GRPO на PyTorch — генерация группы, правиловая награда, групповые преимущества, KL, обновление — и обучить им крошечную модель на CPU
  • Воспроизвести в миниатюре кривые R1-Zero (награда и длина растут) и проверить, при каком условии этого не происходит
  • Запустить GRPO для Qwen2.5-0.5B через TRL с собственными функциями награды и через verl на нескольких GPU
  • Читать логи RL-обучения и диагностировать типичные поломки — вырожденные группы, взрыв длины, коллапс энтропии, взлом награды

Четыре урока теории позади: GRPO, R1-Zero, варианты, дистилляция. Пора своими руками увидеть то, что в статьях показывают графиками: награда растёт, ответы удлиняются, модель «начинает рассуждать». Практикум устроен по принципу «сначала руками, потом фреймворк»:

  1. CPU, 2–3 минуты. Мини-R1-Zero: крошечная рекуррентная модель, правиловая награда, шаг GRPO примерно в 30 строк. Вы увидите полный цикл и проведёте эксперименты, на которые на настоящей LLM ушли бы дни.
  2. GPU, TRL. Qwen2.5-0.5B-Instruct учится решать Countdown — головоломку, на которой TinyZero воспроизводил R1-Zero. Свои функции награды, настоящий GRPOTrainer.
  3. GPU, verl. Промышленный фреймворк ByteDance на всех ваших видеокартах: GSM8K, генерация через vLLM.
flowchart LR
A["Промпты"] --> B["Роллаут: G ответов на промпт"]
B --> C["Награда правилами"]
C --> D["Преимущества по группе"]
B --> E["log π политики и референса"]
D --> F["Loss GRPO"]
E --> F
F -->|"шаг оптимизатора"| B
Цикл, который мы соберём трижды: на CPU руками, в TRL и в verl

Задача: промпт — четыре цифры и знак «=», например 3527=. Верный ответ — сумма цифр, 17. Модель может ответить сразу (17.) или «порассуждать» — выписать частичные суммы 8,10,17. (3+5, затем +2, затем +7). Награда — правиловаяправиловая наградаEN rule-based rewardНаграду считает программа (сверка ответа, запуск тестов, проверка формата), а не нейросеть. Её трудно «взломать»., как у R1-Zero: 1, если последнее число верное и формат правильный, иначе 0. За длину и за рассуждение не платим.

Базовая модель учится на «интернете», где 30% решений написаны аккуратно по шагам, а 70% — ответы «с потолка» (случайное число). Так бывает и в настоящих корпусах: рядом с разобранными решениями лежат голые ответы без выкладок. Модель — эмбеддинг, однослойный GRUGRU (управляемый рекуррентный блок)EN gated recurrent unit (GRU)Упрощённая LSTM с двумя гейтами и без отдельной ячейки памяти (Cho и соавт., 2014). (рекуррентная сеть из урока 3.4.3) и линейная голова: 77 тысяч параметров. Это не трансформер, но для GRPO важно одно — что это авторегрессионная модель, которая сэмплирует токены и умеет считать log⁡π\log \pi каждого.

Сохраните как grpo_toy.py и запустите python grpo_toy.py --plot. Нужны только torch и (для графика) matplotlib.

"""GRPO своими руками на CPU: крошечная «языковая модель» учится складывать цифры.
Задача: промпт «3527=», правильный ответ — сумма цифр 17.
Модель может ответить сразу («17.») или «порассуждать» — выписать частичные суммы («8,10,17.»).
"""
import argparse
import copy
import random
import re
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.set_num_threads(1) # крошечной модели потоки только мешают: синхронизация дороже самой работы
VOCAB = list("0123456789,.=") + ["<bos>"]
STOI = {c: i for i, c in enumerate(VOCAB)}
BOS, DOT = STOI["<bos>"], STOI["."]
V_IN, V_OUT = len(VOCAB), len(VOCAB) - 1 # <bos> модель читает, но никогда не генерирует
MAX_NEW = 10 # лимит длины ответа в токенах
# ── 1. Задача и «интернет», на котором учится базовая модель ───────────────────
def random_digits():
return [random.randint(0, 9) for _ in range(4)]
def make_prompt(ds):
return "".join(map(str, ds)) + "="
def cot_answer(ds): # «рассуждение»: частичные суммы 3+5=8, 8+2=10, 10+7=17
s, parts = ds[0], []
for d in ds[1:]:
s += d
parts.append(str(s))
return ",".join(parts) + "."
def corpus_line(p_cot):
ds = random_digits()
if random.random() < p_cot:
return make_prompt(ds) + cot_answer(ds) # аккуратное решение по шагам
return make_prompt(ds) + str(random.randint(0, 36)) + "." # ответ «с потолка»
def encode(s):
return [BOS] + [STOI[c] for c in s]
def decode(ids):
return "".join(VOCAB[i] for i in ids)
# ── 2. Модель: эмбеддинг → GRU → логиты следующего токена ─────────────────────
class TinyLM(nn.Module):
def __init__(self, d=64, h=128):
super().__init__()
self.emb = nn.Embedding(V_IN, d)
self.rnn = nn.GRU(d, h, batch_first=True)
self.head = nn.Linear(h, V_OUT)
def forward(self, x, hidden=None):
out, hidden = self.rnn(self.emb(x), hidden)
return self.head(out), hidden
def pad(seqs):
"""Дополняет последовательности точкой до одной длины; лишние позиции потом закрываются маской."""
x = torch.full((len(seqs), max(map(len, seqs))), DOT)
for i, s in enumerate(seqs):
x[i, : len(s)] = torch.tensor(s)
return x
def pretrain(model, p_cot, steps, bs=128, lr=3e-3):
"""Обычное обучение языковой модели (next-token prediction) на «интернете»."""
opt = torch.optim.Adam(model.parameters(), lr=lr)
for _ in range(steps):
seqs = [encode(corpus_line(p_cot)) for _ in range(bs)]
x = pad(seqs)
# 1 — настоящий следующий токен, 0 — паддинг (их длина len(s) − 1, потому что предсказываем со сдвигом)
mask = torch.tensor([[1.0] * (len(s) - 1) + [0.0] * (x.shape[1] - len(s)) for s in seqs])
logits, _ = model(x[:, :-1]) # вход — всё, кроме последнего токена
# cross_entropy ждёт классы во 2-й оси: [N, V, T]; reduction="none" — loss на каждой позиции отдельно
ce = F.cross_entropy(logits.transpose(1, 2), x[:, 1:], reduction="none")
loss = (ce * mask).sum() / mask.sum()
opt.zero_grad()
loss.backward()
opt.step()
# ── 3. Генерация, награда, лог-вероятности ─────────────────────────────────────
@torch.no_grad()
def generate(model, prompts):
"""Сэмплирует ответы (temperature = 1). Возвращает списки id токенов ответа."""
x = torch.tensor([encode(p) for p in prompts]) # все промпты одной длины (4 цифры и «=») — паддинг не нужен
logits, h = model(x) # h — скрытое состояние GRU после прочтения промпта
outs, done = [[] for _ in prompts], torch.zeros(len(prompts), dtype=torch.bool)
for _ in range(MAX_NEW):
tok = torch.multinomial(F.softmax(logits[:, -1], -1), 1)[:, 0]
for i in range(len(prompts)):
if not done[i]:
outs[i].append(tok[i].item())
done |= tok == DOT # ответ закончен, когда модель поставила точку
if done.all():
break
logits, h = model(tok[:, None], h) # подаём только новый токен: прошлое уже «сжато» в h
return outs
# формат: «17.» или «8,10,17.»: (?:\d+,)* — сколько угодно групп «число,»; (\d+) — последнее число запоминаем;
# \.$ — в конце точка. ^ и $ — начало и конец строки, лишних символов нет
ANSWER = re.compile(r"^(?:\d+,)*(\d+)\.$")
def reward(prompt, completion):
"""Правиловая награда в духе R1-Zero: 1 за верный ответ в верном формате, иначе 0."""
m = ANSWER.match(completion)
return float(m is not None and int(m.group(1)) == sum(int(c) for c in prompt[:4]))
def token_logprobs(model, prompts, outs):
"""log π(токен) для каждого токена ОТВЕТА: матрица [N, T] и маска [N, T]."""
seqs = [encode(p) + o for p, o in zip(prompts, outs)]
x = pad(seqs)
logits, _ = model(x[:, :-1])
# log_softmax даёт log π для всех токенов словаря; gather выбирает тот, что реально стоит следующим
lp = torch.log_softmax(logits, -1).gather(2, x[:, 1:, None])[..., 0]
mask = torch.zeros_like(lp)
for i, o in enumerate(outs):
start = len(encode(prompts[i])) - 1 # позиция, предсказывающая первый токен ответа
mask[i, start : start + len(o)] = 1
return lp, mask
# ── 4. Один шаг GRPO ───────────────────────────────────────────────────────────
def grpo_step(model, ref, opt, B, G, beta, eps=0.2):
prompts = [make_prompt(random_digits()) for _ in range(B)]
rep = [p for p in prompts for _ in range(G)] # каждый промпт G раз подряд
outs = generate(model, rep)
texts = [decode(o) for o in outs]
r = torch.tensor([reward(p, t) for p, t in zip(rep, texts)]).view(B, G)
# Групповые преимущества: (r - среднее группы) / (std группы + eps)
adv = ((r - r.mean(1, keepdim=True)) / (r.std(1, keepdim=True, unbiased=False) + 1e-4)).view(-1, 1)
lp, mask = token_logprobs(model, rep, outs)
with torch.no_grad():
lp_old = lp.detach() # одно обновление на батч (μ = 1): π_old = π_θ, ratio = 1
lp_ref, _ = token_logprobs(ref, rep, outs)
ratio = torch.exp(lp - lp_old)
surrogate = torch.min(ratio * adv, ratio.clamp(1 - eps, 1 + eps) * adv)
kl = torch.exp(lp_ref - lp) - (lp_ref - lp) - 1 # оценка KL «k3», всегда ≥ 0
per_token = surrogate - beta * kl
objective = ((per_token * mask).sum(1) / mask.sum(1)).mean() # 1/|o_i| внутри ответа, 1/G по группе
opt.zero_grad()
(-objective).backward()
opt.step()
seq_kl = ((kl * mask).sum(1) / mask.sum(1)).mean().item()
lengths = mask.sum(1).mean().item()
cot = sum("," in t for t in texts) / len(texts)
return r.mean().item(), lengths, cot, seq_kl
# ── 5. Оценка: pass@1, pass@8, длина, доля ответов с рассуждением ───────────────
def evaluate(model, n=400, k=8, seed=123):
random.seed(seed)
torch.manual_seed(seed)
prompts = [make_prompt(random_digits()) for _ in range(n)]
rep = [p for p in prompts for _ in range(k)]
texts = [decode(o) for o in generate(model, rep)]
r = torch.tensor([reward(p, t) for p, t in zip(rep, texts)]).view(n, k)
length = sum(len(t) for t in texts) / len(texts)
cot = sum("," in t for t in texts) / len(texts)
# pass@1 — средняя доля верных; pass@k — доля задач, где верен хотя бы один из k ответов (максимум по строке)
return r.mean().item(), r.max(1).values.mean().item(), length, cot
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--p-cot", type=float, default=0.3, help="доля решений по шагам в корпусе претрейна")
ap.add_argument("--pretrain-steps", type=int, default=2500)
ap.add_argument("--steps", type=int, default=150, help="шагов GRPO")
ap.add_argument("--B", type=int, default=32, help="промптов на шаг")
ap.add_argument("--G", type=int, default=8, help="ответов на промпт (размер группы)")
ap.add_argument("--beta", type=float, default=0.02, help="коэффициент KL")
ap.add_argument("--lr", type=float, default=1e-3)
ap.add_argument("--plot", action="store_true", help="сохранить графики в grpo_toy.png")
args = ap.parse_args()
random.seed(0)
torch.manual_seed(0)
model = TinyLM()
pretrain(model, args.p_cot, args.pretrain_steps)
print("база: pass@1 %.2f pass@8 %.2f длина %.1f доля CoT %.2f" % evaluate(model))
ref = copy.deepcopy(model).requires_grad_(False) # замороженная референсная модель
opt = torch.optim.Adam(model.parameters(), lr=args.lr)
random.seed(1)
torch.manual_seed(1)
history = []
for step in range(args.steps + 1):
rew, length, cot, kl = grpo_step(model, ref, opt, args.B, args.G, args.beta)
history.append((rew, length, cot, kl))
if step % 25 == 0:
print(f"шаг {step:3d}: награда {rew:.2f} длина {length:.1f} доля CoT {cot:.2f} KL {kl:.2f}")
print("после: pass@1 %.2f pass@8 %.2f длина %.1f доля CoT %.2f" % evaluate(model))
if args.plot:
import matplotlib.pyplot as plt
fig, ax = plt.subplots(1, 3, figsize=(12, 3.2))
for a, j, name in zip(ax, [0, 1, 2], ["награда", "длина ответа, токенов", "доля ответов с CoT"]):
a.plot([h[j] for h in history])
a.set_title(name)
a.set_xlabel("шаг GRPO")
plt.tight_layout()
plt.savefig("grpo_toy.png", dpi=120)
print("графики сохранены в grpo_toy.png")
if __name__ == "__main__":
main()

Ожидаемый вывод (PyTorch 2.x на CPU; в другой версии числа могут немного отличаться, а у нас на загруженной машине всё заняло около полутора минут):

база: pass@1 0.19 pass@8 0.77 длина 4.2 доля CoT 0.27
шаг 0: награда 0.23 длина 4.4 доля CoT 0.30 KL 0.00
шаг 25: награда 0.50 длина 7.1 доля CoT 0.78 KL 0.34
шаг 50: награда 0.61 длина 7.7 доля CoT 0.89 KL 0.68
шаг 75: награда 0.75 длина 7.9 доля CoT 0.96 KL 0.54
шаг 100: награда 0.71 длина 8.2 доля CoT 0.96 KL 0.51
шаг 125: награда 0.70 длина 8.0 доля CoT 0.97 KL 0.50
шаг 150: награда 0.77 длина 8.0 доля CoT 0.99 KL 0.66
после: pass@1 0.77 pass@8 0.98 длина 8.0 доля CoT 0.98
графики сохранены в grpo_toy.png

Перед вами R1-Zero в миниатюре:

  • Награда выросла с 0.19 до 0.77 — без единого примера «как надо», только по сигналу «верно/неверно».
  • Длина ответа выросла с 4 до 8 токенов, доля рассуждений — с 27% до 98%. За длину не платили: просто ответы по шагам верны гораздо чаще ответов «с потолка», получают положительное преимущество в своих группах, и их вероятность растёт. Ровно механизм из урока 6.4.2.
  • pass@8 вырос куда меньше, чем pass@1: с 0.77 до 0.98. База и раньше умела найти ответ за 8 попыток — RL сделал верный путь основным. Это «затачивание», о котором писали DeepSeekMath и LeapLab.
  • KL держится в районе 0.5–0.7: политика ушла от базы, но β=0.02\beta = 0.02 не даёт ей уйти бесконечно.

На графике grpo_toy.png три кривые — награда, длина и доля ответов с рассуждением. Они очень похожи на рисунки из статей о reasoning-моделях: быстрый рост в первые 25–50 шагов, затем плато с шумом.

Запускайте с разными параметрами и сравнивайте. Вот что получилось у нас:

ЗапускБаза: pass@1 / CoTПосле: pass@1 / pass@8 / CoTВывод
по умолчанию (--p-cot 0.3)0.19 / 0.270.77 / 0.98 / 0.98рассуждение усилилось, награда выросла в 4 раза
--p-cot 0.050.03 / 0.050.30 / 0.87 / 0.90даже 5% «зачатка» RL превратил в 90%, но качество шагов слабое: база видела мало примеров
--p-cot 00.02 / 0.000.10 / 0.51 / 0.00рассуждать модель не начала: RL не изобретает формат, которого нет в базе; немного выучились прямые ответы
--G 20.19 / 0.270.53 / 0.91 / 0.93группа из двух: много вырожденных групп и вчетверо меньше ответов на шаг — учится медленнее

Строка с --p-cot 0 — самая важная. Это игрушечная версия наблюдения TinyZero про 0.5B-модель и вывода LeapLab: сила RL ограничена силой базы. Если поведения нет даже в зачатке, группа ответов никогда его не покажет, и преимущество нечему присвоить.

Ещё идеи для самостоятельных опытов:

  • --beta 0 и --beta 0.2: как меняются KL, скорость роста награды и разнообразие ответов?
  • Замените в grpo_step усреднение на token-level (DAPO): objective = (per_token * mask).sum() / mask.sum(). Длинные ответы теперь весят больше — как это сказалось на длине?
  • Добавьте в reward штраф за длину в духе Kimi k1.5. При каком весе модель снова начинает «отвечать с потолка»?

Countdown: даны числа, например [3,7,10][3, 7, 10], и цель 31. Нужно составить выражение, где каждое число использовано ровно один раз: 3⋅7+10=313 \cdot 7 + 10 = 31. Задача хороша для RLVR (RL с проверяемой наградой, урок 6.4.2): проверка — одна строка кода, а решение требует перебора, то есть рассуждения. Данные сгенерируем сами — никаких скачиваний.

Функция награды — это ваш код, который выполняется на тексте модели. Модель будет активно искать в нём дыры, поэтому проверщик пишут параноидально: белый список символов, запрет ** (выражение 9**9**9 повесит процесс), eval без доступа к встроенным функциям, проверка, что числа использованы ровно по разу.

import random
import re
PROMPT = (
"Используя числа {nums} ровно по одному разу и операции + - * / со скобками, получи число {target}. "
"Сначала порассуждай внутри <think> </think>, затем запиши только выражение внутри <answer> </answer>."
)
def make_countdown(n_tasks, seed=0):
"""Задачи Countdown: три числа и цель, которая точно достижима."""
rng = random.Random(seed)
tasks = []
while len(tasks) < n_tasks:
nums = [rng.randint(1, 20) for _ in range(3)]
a, b, c = rng.sample(nums, 3) # случайный порядок
op1, op2 = rng.choice("+-*"), rng.choice("+-*")
target = eval(f"({a} {op1} {b}) {op2} {c}") # здесь eval безопасен: выражение строим мы сами
if 1 <= target <= 100:
tasks.append({"nums": nums, "target": target})
return tasks
# re.S: точка «.» совпадает и с переводом строки (рассуждение многострочное); .*? — «как можно меньше символов»
FORMAT = re.compile(r"^\s*<think>.*?</think>\s*<answer>.*?</answer>\s*$", re.S)
ANSWER = re.compile(r"<answer>(.*?)</answer>", re.S)
SAFE = re.compile(r"^[\d+\-*/() .]+$") # только цифры, операции, скобки, пробелы
def text_of(completion):
"""В TRL ответ — строка или (для диалоговых данных) список сообщений."""
return completion[0]["content"] if isinstance(completion, list) else completion
# TRL вызывает функцию награды со списком ответов и передаёт колонки датасета (nums, target…) именованными
# аргументами; ненужные функции собирает **kwargs (урок 0.2.2)
def format_reward(completions, **kwargs):
return [0.2 if FORMAT.match(text_of(c)) else 0.0 for c in completions]
def check_expression(expr, nums, target):
if not expr or not SAFE.match(expr) or "**" in expr: # «**» запрещаем: 9**9**9 повесит процесс
return False
if sorted(int(t) for t in re.findall(r"\d+", expr)) != sorted(nums):
return False # каждое число — ровно один раз
try:
# eval считает строку как выражение Python; {"__builtins__": {}} отнимает доступ к open, import и прочему
return abs(eval(expr, {"__builtins__": {}}) - target) < 1e-6
except (SyntaxError, ZeroDivisionError, TypeError):
return False
def correctness_reward(completions, nums, target, **kwargs):
rewards = []
for c, ns, t in zip(completions, nums, target):
m = ANSWER.search(text_of(c))
rewards.append(1.0 if m and check_expression(m.group(1).strip(), ns, t) else 0.0)
return rewards
if __name__ == "__main__":
tasks = make_countdown(3)
print(tasks)
nums, target = [3, 7, 10], 31
tests = [
"<think>3*7=21, 21+10=31</think><answer>3*7+10</answer>", # верно
"<think>...</think><answer>7*3+10+0</answer>", # лишнее число 0
"<think>...</think><answer>3*7+7+3</answer>", # числа повторяются
"<answer>(3*7)+10</answer>", # верно, но без рассуждения
"<think>хм</think><answer>9**9**9</answer>", # попытка «повесить» проверщик
[{"role": "assistant", "content": "<think>ок</think> <answer>10 + 3*7</answer>"}],
]
print(format_reward(tests))
print(correctness_reward(tests, [nums] * len(tests), [target] * len(tests)))

Сохраните как countdown_rewards.py и запустите — это работает и без GPU:

[{'nums': [12, 19, 7], 'target': 38}, {'nums': [3, 11, 16], 'target': 2}, {'nums': [1, 3, 13], 'target': 15}]
[0.2, 0.2, 0.2, 0.0, 0.2, 0.2]
[1.0, 0.0, 0.0, 1.0, 0.0, 1.0]

Всегда тестируйте награду на руками написанных примерах до запуска RL — это самый дешёвый способ поймать reward hacking. Награда за формат намеренно маленькая (0.2): иначе модель быстро научится выдавать красивые теги с пустым содержимым.

Установите зависимости курса для LLM-ступеней (pip install -r homework/requirements-llm.txt) и обновите TRL до свежей версии (pip install -U trl): параметры ниже сверены с документацией GRPOTrainer 2026 года, в старых версиях часть называется иначе.

train_grpo_countdown.py
from datasets import Dataset
from trl import GRPOConfig, GRPOTrainer
from countdown_rewards import PROMPT, correctness_reward, format_reward, make_countdown
tasks = make_countdown(4000)
dataset = Dataset.from_list([
{
"prompt": [{"role": "user", "content": PROMPT.format(nums=t["nums"], target=t["target"])}],
"nums": t["nums"], # дополнительные колонки TRL передаст в функции награды
"target": t["target"],
}
for t in tasks
])
config = GRPOConfig(
output_dir="qwen-countdown-grpo",
num_generations=8, # G — ответов на промпт
per_device_train_batch_size=8, # ответов на устройство за микрошаг
gradient_accumulation_steps=4, # итого 8 * 4 / 8 = 4 промпта на шаг оптимизатора (на одну GPU)
max_completion_length=256,
learning_rate=1e-6,
max_steps=300,
beta=0.0, # без KL и без референсной модели (по умолчанию в TRL)
loss_type="dapo", # token-level loss (по умолчанию в свежих версиях)
epsilon_high=0.28, # Clip-Higher из DAPO
logging_steps=5,
log_completions=True, # печатать примеры ответов — смотрите на них!
report_to="none",
)
trainer = GRPOTrainer(
model="Qwen/Qwen2.5-0.5B-Instruct",
reward_funcs=[format_reward, correctness_reward],
args=config,
train_dataset=dataset,
)
trainer.train()

Запуск на одной видеокарте — python train_grpo_countdown.py, на четырёх — accelerate launch --num_processes 4 train_grpo_countdown.py (данные делятся между картами, шаг оптимизатора видит в 4 раза больше промптов). accelerate — утилита Hugging Face, которая запускает по процессу на каждую карту и связывает их, как torchrun из урока 5.4.3 (сравнение способов запуска — в уроке 4.6.5).

Почему Instruct, а не базовая модель: авторы TinyZero пишут, что Qwen2.5-0.5B-Base на Countdown рассуждать не учится, а 3B-модель — учится. Instruct-версия уже следует формату, и у неё ненулевая начальная точность — без этого все группы будут вырожденными. Как только цикл заработает, попробуйте Qwen/Qwen2.5-1.5B-Instruct, Qwen/Qwen3-1.7B или Qwen/Qwen2.5-3B — на 3B уже можно ловить свой маленький «момент озарения» (сколько памяти это потребует — в блоке ниже).

Метрика TRLЧто значитЗдоровая динамика
rewardсредняя суммарная наградарастёт, затем плато
reward_stdразброс наградне падает к нулю
frac_reward_zero_stdдоля вырожденных групп (все награды равны)не растёт к единице
completions/mean_lengthсредняя длина ответарастёт умеренно, не упирается в max_completion_length
completions/clipped_ratioдоля ответов, обрезанных по лимиту длинымаленькая
entropyэнтропия политикиснижается медленно, без обвала к нулю
klKL к референсу (при beta больше 0)растёт плавно
clip_ratio/region_meanдоля клиппированных токеновединицы процентов, не десятки
lossзначение lossоколо нуля — это нормально (урок 6.4.1)

verl — RL-фреймворк ByteDance, на котором написаны DAPO, TinyZero и многие воспроизведения R1. Генерацию делает vLLM или SGLang (движки быстрого инференса, о них — урок 7.1.2), обучение — FSDP (урок 5.4.3) или Megatron (фреймворк NVIDIA для тензорного и конвейерного параллелизма), всё связано через Ray — библиотеку, которая раздаёт задачи процессам на разных картах и машинах. Сначала данные (скрипт из репозитория verl скачивает GSM8K — школьные текстовые задачи по математике — и превращает в parquet, компактный табличный формат файлов, с полем эталонного ответа):

Окно терминала
python3 examples/data_preprocess/gsm8k.py --local_save_dir ~/data/gsm8k

Затем GRPO на Qwen2.5-0.5B-Instruct на четырёх картах. Это команда из официального quickstart, переделанная под GRPO по документации docs/algo/grpo.md: критик PPO (урок 6.0.3) удалён, включены группа (n=8n = 8) и KL в loss оценкой k3.

Окно терминала
PYTHONUNBUFFERED=1 python3 -m verl.trainer.main_ppo \
algorithm.adv_estimator=grpo \
algorithm.use_kl_in_reward=False \
data.train_files=$HOME/data/gsm8k/train.parquet \
data.val_files=$HOME/data/gsm8k/test.parquet \
data.train_batch_size=128 \
data.max_prompt_length=512 \
data.max_response_length=512 \
actor_rollout_ref.model.path=Qwen/Qwen2.5-0.5B-Instruct \
actor_rollout_ref.actor.optim.lr=1e-6 \
actor_rollout_ref.actor.ppo_mini_batch_size=64 \
actor_rollout_ref.actor.ppo_micro_batch_size_per_gpu=8 \
actor_rollout_ref.actor.use_kl_loss=True \
actor_rollout_ref.actor.kl_loss_coef=0.001 \
actor_rollout_ref.actor.kl_loss_type=low_var_kl \
actor_rollout_ref.rollout.name=vllm \
actor_rollout_ref.rollout.n=8 \
actor_rollout_ref.rollout.tensor_model_parallel_size=1 \
actor_rollout_ref.rollout.gpu_memory_utilization=0.5 \
actor_rollout_ref.rollout.log_prob_micro_batch_size_per_gpu=8 \
actor_rollout_ref.ref.log_prob_micro_batch_size_per_gpu=8 \
trainer.logger=console \
trainer.n_gpus_per_node=4 \
trainer.nnodes=1 \
trainer.test_freq=10 \
trainer.save_freq=50 \
trainer.total_epochs=3

Расшифровка: на шаг берётся 128 задач × 8 ответов = 1024 ответа; они делятся на мини-батчи по 64 задачи, то есть два шага оптимизатора на одну генерацию — здесь клиппинг уже работает. В логах ищите critic/score/mean (награда, несмотря на слово critic), response_length/mean, actor/pg_clipfrac (доля клиппированных токенов) и val/test_score/openai/gsm8k — точность на тесте. Ключи конфигурации verl меняются между версиями: если что-то не запускается, сверьтесь со скриптами в examples/grpo_trainer своей версии. Чтобы попробовать DAPO, достаточно actor_rollout_ref.actor.clip_ratio_high=0.28 и actor_rollout_ref.actor.loss_agg_mode=token-mean (последнее — значение по умолчанию); полный рецепт — в разделе DAPO документации verl.

СимптомВероятная причинаЧто делать
Награда с первого шага около 0 и не растётзадачи слишком трудны или формат никогда не совпадаетпосмотрите на сами ответы; упростите задачи; возьмите Instruct-модель или модель побольше
frac_reward_zero_std близко к 1, reward_std около 0вырожденные группывырожденная группаEN degenerate group (zero-variance group)Все ответы группы получили одинаковую награду — все преимущества нулевые, градиента нет.: всё решено или всё безнадёжноподберите трудность, увеличьте GG, выбрасывайте вырожденные группы (динамический сэмплинг)
Длина растёт до лимита, много обрезанных ответовсмещение по длинесмещение по длине (в GRPO)EN response-level length biasИз-за множителя 1/|o_i| короткие верные ответы поощряются сильнее, а длинные неверные наказываются слабее. Исправления — token-level loss (DAPO) и Dr. GRPO., «передумывание»«передумывание» (overthinking)EN overthinkingМодель тратит тысячи токенов там, где хватило бы сотни. Лечат штрафом за длину, long2short, бюджетом мышления.loss_type="dapo" или "dr_grpo", mask_truncated_completions=True, мягкий штраф за длину
Энтропия быстро падает к нулю, ответы одинаковыеколлапс энтропииколлапс энтропииEN entropy collapseЭнтропия политики быстро падает при RL: ответы в группе становятся почти одинаковыми, исследование прекращается, рост награды упирается в потолок.epsilon_high=0.28, ниже learning rate, бонус за энтропию, разнообразнее задачи
Награда растёт, а качество на тесте нетвзлом наградывзлом награды (reward hacking)EN reward hackingПолитика находит способ получить высокую награду, не делая того, что задумали люди.читать ответы (log_completions), чинить проверщик, отдельный честный eval
KL и loss внезапно взрываются, текст превращается в мусорслишком большой шаг; для MoE — нестабильность маршрутизацииниже learning rate, клиппинг градиента, GSPO для MoE, проверка точности логитов
Проверь себя5 вопросов
1 В игрушечном эксперименте базовая модель обучалась на корпусе без единого решения «по шагам» (--p-cot 0). Что сделал GRPO?
2 TRL на одной GPU: per_device_train_batch_size=8, gradient_accumulation_steps=4, num_generations=8. Сколько разных промптов приходится на один шаг оптимизатора?
3 Какие признаки говорят о здоровом ходе GRPO?Можно выбрать несколько ответов
4 Зачем проверщик Countdown запрещает ** и вызывает eval без встроенных функций?
5 В логах reward_std почти всё время около нуля, а награда около 0.95. Что происходит?
  • Шаг GRPO в коде: роллаут GG ответов → правиловая награда → преимущества по группе → log⁡π\log \pi политики и референса → loss → шаг. На CPU это 30 строк и пара минут.
  • Мини-R1-Zero повторяет кривые больших моделей: награда 0.19 → 0.77, длина 4 → 8, доля рассуждений 27% → 98%. Без «зачатка» рассуждений в базе (p_cot = 0) роста нет.
  • Функция награды — часть среды: тестируйте её на ручных примерах и делайте безопасной.
  • TRL — быстрый старт на одной карте; verl — масштаб на нескольких картах с генерацией через vLLM.
  • Смотрите на награду, её разброс, длину, энтропию и сами ответы — не на loss.

Дальше: ступень 7 — как запускать модели быстро и дёшево и как честно измерять их качество.

Закончили урок?Урок пройден

Отметьте его — и 5 карточек из урока попадут в колоду повторения. · 5 карточек — в колоде повторения

Первоисточники 5

  • EN
    Hugging Face TRL — GRPO Trainer
    быстрый старт, формат функций награды, параметры GRPOConfig (loss_type, beta, scale_rewards, epsilon_high) и список логируемых метрикадаптация · Apache-2.0
  • EN
    verl (ByteDance) — Quickstart: GSM8K на Qwen2.5-0.5B-Instruct
    подготовка данных examples/data_preprocess/gsm8k.py, запуск verl.trainer.main_ppo, формат логов; для GRPO — docs/algo/grpo.mdадаптация · Apache-2.0
  • EN
    TinyZero — R1-Zero на Countdown (на базе verl)
    постановка Countdown; наблюдение авторов: 3B-модель учится самопроверке, 0.5B — нетссылка
  • ZH
    MiniMind — trainer/train_grpo.py
    GRPO и CISPO на чистом PyTorch для модели на 64M параметров; README, раздел 7.2 — графики награды и комментарии авторассылка · Apache-2.0
  • ZH
    Datawhale · happy-llm, глава 8 — Agentic RL (GRPO)
    китайский учебник: GRPO от формулы до кода (8.1), on-policy дистилляция (8.2), агентный RL — Search-R1 и ReToolссылка · CC-BY-NC-SA-4.0

Лицензия текста урока: CC-BY-SA-4.0 · обновлено 2026-10-03