Практикум — GRPO своими руками
Мини-R1-Zero за пару минут на CPU — крошечная модель учится рассуждать через GRPO, и вы видите рост награды и длины ответа; затем Qwen2.5-0.5B на задаче Countdown через TRL и GSM8K через verl на GPU, с разбором метрик и типичных поломок.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Четыре урока теории позади: GRPO, R1-Zero, варианты, дистилляция. Пора своими руками увидеть то, что в статьях показывают графиками: награда растёт, ответы удлиняются, модель «начинает рассуждать». Практикум устроен по принципу «сначала руками, потом фреймворк»:
- CPU, 2–3 минуты. Мини-R1-Zero: крошечная рекуррентная модель, правиловая награда, шаг GRPO примерно в 30 строк. Вы увидите полный цикл и проведёте эксперименты, на которые на настоящей LLM ушли бы дни.
- GPU, TRL. Qwen2.5-0.5B-Instruct учится решать Countdown — головоломку, на которой TinyZero воспроизводил R1-Zero. Свои функции награды, настоящий GRPOTrainer.
- 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
Часть 1. Мини-R1-Zero на CPU
Заголовок раздела «Часть 1. Мини-R1-Zero на CPU»Постановка
Заголовок раздела «Постановка»Задача: промпт — четыре цифры и знак «=», например 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 важно одно — что это авторегрессионная модель, которая сэмплирует токены и умеет считать каждого.
Сохраните как grpo_toy.py и запустите python grpo_toy.py --plot. Нужны только torch и (для графика) matplotlib.
"""GRPO своими руками на CPU: крошечная «языковая модель» учится складывать цифры.
Задача: промпт «3527=», правильный ответ — сумма цифр 17.Модель может ответить сразу («17.») или «порассуждать» — выписать частичные суммы («8,10,17.»)."""import argparseimport copyimport randomimport re
import torchimport torch.nn as nnimport 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: политика ушла от базы, но не даёт ей уйти бесконечно.
На графике grpo_toy.png три кривые — награда, длина и доля ответов с рассуждением. Они очень похожи на рисунки
из статей о reasoning-моделях: быстрый рост в первые 25–50 шагов, затем плато с шумом.
Эксперименты
Заголовок раздела «Эксперименты»Запускайте с разными параметрами и сравнивайте. Вот что получилось у нас:
| Запуск | База: pass@1 / CoT | После: pass@1 / pass@8 / CoT | Вывод |
|---|---|---|---|
по умолчанию (--p-cot 0.3) | 0.19 / 0.27 | 0.77 / 0.98 / 0.98 | рассуждение усилилось, награда выросла в 4 раза |
--p-cot 0.05 | 0.03 / 0.05 | 0.30 / 0.87 / 0.90 | даже 5% «зачатка» RL превратил в 90%, но качество шагов слабое: база видела мало примеров |
--p-cot 0 | 0.02 / 0.00 | 0.10 / 0.51 / 0.00 | рассуждать модель не начала: RL не изобретает формат, которого нет в базе; немного выучились прямые ответы |
--G 2 | 0.19 / 0.27 | 0.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. При каком весе модель снова начинает «отвечать с потолка»?
Часть 2. Qwen на Countdown через TRL
Заголовок раздела «Часть 2. Qwen на Countdown через TRL»Задача и награды
Заголовок раздела «Задача и награды»Countdown: даны числа, например , и цель 31. Нужно составить выражение, где каждое число использовано ровно один раз: . Задача хороша для RLVR (RL с проверяемой наградой, урок 6.4.2): проверка — одна строка кода, а решение требует перебора, то есть рассуждения. Данные сгенерируем сами — никаких скачиваний.
Функция награды — это ваш код, который выполняется на тексте модели. Модель будет активно искать в нём дыры,
поэтому проверщик пишут параноидально: белый список символов, запрет ** (выражение 9**9**9 повесит процесс),
eval без доступа к встроенным функциям, проверка, что числа использованы ровно по разу.
import randomimport 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 года, в старых версиях
часть называется иначе.
from datasets import Datasetfrom 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 | энтропия политики | снижается медленно, без обвала к нулю |
kl | KL к референсу (при beta больше 0) | растёт плавно |
clip_ratio/region_mean | доля клиппированных токенов | единицы процентов, не десятки |
loss | значение loss | около нуля — это нормально (урок 6.4.1) |
Часть 3. verl на нескольких GPU
Заголовок раздела «Часть 3. verl на нескольких GPU»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) удалён, включены группа () и 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.
Диагностика: что ломается в RL
Заголовок раздела «Диагностика: что ломается в RL»| Симптом | Вероятная причина | Что делать |
|---|---|---|
| Награда с первого шага около 0 и не растёт | задачи слишком трудны или формат никогда не совпадает | посмотрите на сами ответы; упростите задачи; возьмите Instruct-модель или модель побольше |
frac_reward_zero_std близко к 1, reward_std около 0 | вырожденные группывырожденная группаEN degenerate group (zero-variance group)Все ответы группы получили одинаковую награду — все преимущества нулевые, градиента нет.: всё решено или всё безнадёжно | подберите трудность, увеличьте , выбрасывайте вырожденные группы (динамический сэмплинг) |
| Длина растёт до лимита, много обрезанных ответов | смещение по длинесмещение по длине (в 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, проверка точности логитов |
Проверь себя
Заголовок раздела «Проверь себя»- Шаг GRPO в коде: роллаут ответов → правиловая награда → преимущества по группе → политики и референса → loss → шаг. На CPU это 30 строк и пара минут.
- Мини-R1-Zero повторяет кривые больших моделей: награда 0.19 → 0.77, длина 4 → 8, доля рассуждений 27% → 98%. Без
«зачатка» рассуждений в базе (
p_cot = 0) роста нет. - Функция награды — часть среды: тестируйте её на ручных примерах и делайте безопасной.
- TRL — быстрый старт на одной карте; verl — масштаб на нескольких картах с генерацией через vLLM.
- Смотрите на награду, её разброс, длину, энтропию и сами ответы — не на loss.
Дальше: ступень 7 — как запускать модели быстро и дёшево и как честно измерять их качество.