从单整数输出到逐项拆解:在 Mac MPS 上用 GRPO 训练 Qwen3-0.6B 数偶数的完整复盘
这次实验最初设定的任务非常小:给本地部署的 Qwen3-0.6B 输入 10 个整数,让它数出其中有几个偶数。训练基于 TRL 的 GRPOTrainer,运行在 Mac 的 MPS 后端上,模型权重预先存放在本地 models/Qwen3-0.6B 目录中。
看似简单的计数任务,在实际强化学习训练中却接连踩坑:
- 最开始要求模型直接输出一个整数:dev 集上一道题都答不对(0/50);
- 调整数值精度与非法格式惩罚后:模型走上捷径,所有题目清一色输出
1; - 换用恢复配置(加入 KL 与熵正则、收紧奖励尺度):训练满 100 步后,dev 准确率依然停留在每次
1/50。
问题出在任务形式与奖励信号的粒度上。后来我将任务改为“先逐个复制数字并判断奇偶,最后再汇总输出偶数总数”,同时将奖励函数拆分为三部分:分别检查逐项奇偶判断、最终答案正确性以及最终答案与自身标注的 even 行数是否一致。在这套逐数方案下,经过 100 步训练,同一任务的 dev 最终答案准确率从 15/50(30%)提升到 33/50(66%),并在第 40 步和第 70 步达到峰值 36/50(72%)。
本文对应 countdown-recovery.ipynb 的逐数拆解版本。文中的核心代码直接从当前 notebook 按执行顺序摘录,依次运行即可复现完整训练流程;早期的单整数输出版本保存在 history/countdown-recovery-integer-only-20260930.ipynb,完整排查日志记录在 process.md。
实验结果概览:100 步训练跑出了什么?
本轮实验的输出目录为 out-qwen3-stepwise/20260930-150958-619444。从 notebook 保存的训练日志以及 checkpoint-100 的 trainer state 可以确认,训练共完成了 100 个 optimizer steps,对应 0.2 个 epoch;总耗时 2912.5354 秒(约 48 分 33 秒,包含训练期间 callback 的定期评估耗时)。
完整 dev 集共 50 道题,每题包含 10 个整数,评估时统一采用 greedy 解码。为了把“格式学会了”、“奇偶判断对了”和“最后数对了”区分开,这里同时跟踪五个指标:
| 评估指标 | 具体检查内容 |
|---|---|
| 最终答案正确率 | 最后一行输出的答案是否等于真实的偶数个数 |
| 逐项正确率 | 数字复制无误且奇偶标签正确的行数,占输入数字总数的比例 |
| 完整解答正确率 | 10 个输入数字按原序列齐、奇偶判断全部正确,且最终答案正确 |
| 格式合法率 | 所有输入数字按顺序列齐,末尾有且仅有一行合法答案,且答案在 0..10 范围内 |
| 自身计数一致率 | 输出结构合法,且最终答案等于模型自己在前面标出的 even 行数 |
随着训练推进,每隔 10 步的完整 dev 评估结果如下:
| Optimizer Step | 最终答案正确 | 逐项正确率 | 完整解答正确 | 格式合法率 | 自身计数一致率 |
|---|---|---|---|---|---|
| 0 | 15/50(30%) | 96.6% | 13/50(26%) | 76% | 34% |
| 10 | 26/50(52%) | 98.8% | 25/50(50%) | 96% | 54% |
| 20 | 34/50(68%) | 99.4% | 33/50(66%) | 98% | 68% |
| 30 | 33/50(66%) | 99.4% | 32/50(64%) | 98% | 66% |
| 40 | 36/50(72%) | 99.6% | 35/50(70%) | 96% | 72% |
| 50 | 35/50(70%) | 99.6% | 34/50(68%) | 96% | 70% |
| 60 | 33/50(66%) | 99.2% | 32/50(64%) | 96% | 68% |
| 70 | 36/50(72%) | 99.6% | 35/50(70%) | 96% | 72% |
| 80 | 35/50(70%) | 99.6% | 34/50(68%) | 96% | 70% |
| 90 | 34/50(68%) | 99.6% | 33/50(66%) | 96% | 68% |
| 100 | 33/50(66%) | 99.4% | 32/50(64%) | 96% | 68% |
从表中可以看到两个值得注意的现象:
- 准确率并非单调递增:最终答案准确率在第 40 步和第 70 步达到 72% 的高点,到第 100 步回落至 66%。继续训练并未带来持续上涨,因此挑选最终模型时不能盲目取最后一个 step,而应根据各保存点在完整 dev 集上的表现来选择 checkpoint。
- 瓶颈已从“判断奇偶”转移到“汇总计数”:到第 100 步时,逐项正确率已经高达 99.4%,但完整解答正确率只有 64%。这说明模型几乎已经能准确判断每个数字的奇偶性,但在把已经标出的
even行数加总时仍会数错。此外,偶尔会出现中间某一行奇偶判断写错、但最后一行答案碰巧猜对的情况,因此“最终答案正确率”(66%)会略高于“完整解答正确率”(64%)。
关于实验边界的说明:本轮结果是在固定超参配置与固定随机种子下取得的开发集(dev)表现。50 条 dev 样本在训练调参过程中被反复用于监控;1000 条独立的 test 集已经生成落盘,但当前 notebook 尚未跑完训练后的最终 test 评估。由于这次改进同时修改了输出提示格式与奖励函数,衡量训练增益时应以本轮 step 0 的 30% 作为同口径基线(此前一次独立短探测记录的逐数基线为 28%,同样保存在日志中),而早期单整数输出版 0/50 或 1/50 的表现属于另一套任务设定,不宜直接跨任务相减。
核心方案:先逐个数判断奇偶,再汇总计数
在新方案中,模型接收一组整数,并按固定格式逐行输出判断结果与最终汇总:
输入:[3, 8, 8]
输出:
3: odd
8: even
8: even
Answer: 2哪怕输入中存在重复数字(例如两个 8),模型也必须按原顺序逐一各占一行输出。每一行都需要同时满足“复制的数字与对应位置的输入一致”和“奇偶标签正确”;最后一行再给出总计数,由评测逻辑同时核对真实答案与模型自身标出的 even 行数。
为什么要做这样的拆解?在旧版只输出一个整数的设计下,一旦模型答错,我们只能看到一个错误的数字,很难给中间推理过程提供细粒度反馈。改成逐行输出后,即便最后总数算错,前面答对的奇偶行依然能拿到逐项奖励,而最终计数则由答案奖励和一致性奖励共同约束。整个过程依然由 GRPO 采样模型生成文本并做策略优化——Python 程序只负责解析输出并计算标量奖励,模型自己生成的逐行文本则全程参与策略梯度更新。
下面按执行顺序拆解完整代码实现。
1. 环境初始化与独立运行目录
训练在 Python (GRPO) kernel 中运行,每次实验前重启 kernel 从头执行。当前环境核实的依赖版本为:PyTorch 2.14.0、TRL 1.14.0、Transformers 5.17.0、Datasets 5.0.1、Accelerate 1.15.0。
import os
from pathlib import Path
from datetime import datetime
from zoneinfo import ZoneInfo
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TOKENIZERS_PARALLELISM"] = "false"
import torch
from collections import Counter
from datasets import Dataset
from trl import GRPOTrainer, GRPOConfig
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainerCallback
MODEL_DIR = Path("./models/Qwen3-0.6B").resolve()
DATA_DIR = Path("./data/countdown-stepwise").resolve()
RUN_ID = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S-%f")
OUTPUT_DIR = Path("./out-qwen3-stepwise", RUN_ID).resolve()
MAX_COMPLETION_LENGTH = 192
print("Output directory:", OUTPUT_DIR)这里通过 HF_HUB_OFFLINE="1" 配合后续的 local_files_only=True 强制全部从本地加载模型与分词器。每次从头初始化都会按时间戳生成唯一的 RUN_ID 和独立输出目录,防止不同实验的日志与 checkpoint 互相覆盖。为了容纳 10 行判断加 1 行汇总,最大生成长度 MAX_COMPLETION_LENGTH 设为 192 tokens。
2. 混合精度细节:主体保持 BF16,RMSNorm 转为 FP32
required_files = ["config.json", "tokenizer.json", "model.safetensors"]
missing = [name for name in required_files if not (MODEL_DIR / name).is_file()]
if missing:
raise FileNotFoundError(f"Incomplete local model: {missing}")
if not torch.backends.mps.is_available():
raise RuntimeError("This notebook requires MPS in the grpo kernel")
tokenizer = AutoTokenizer.from_pretrained(MODEL_DIR, local_files_only=True)
tokenizer.padding_side = "left"
model = AutoModelForCausalLM.from_pretrained(
MODEL_DIR, dtype=torch.bfloat16, local_files_only=True,
).to("mps")
def restore_norm_output_dtype(module, inputs, output):
return output.to(inputs[0].dtype)
for module in model.modules():
if module.__class__.__name__.endswith("RMSNorm"):
module.float()
module.register_forward_hook(restore_norm_output_dtype)
print("Model:", type(model).__name__, next(model.parameters()).device)
print("Base dtype:", next(model.parameters()).dtype)
print("Norm dtype:", model.model.layers[0].input_layernorm.weight.dtype)这段加载逻辑做了一个关键处理:模型主体权重以 BF16 加载,随后专门把所有 RMSNorm 层的参数转换为 FP32。Qwen3-0.6B 总共有 596,049,920 个参数,其中 RMSNorm 参数仅占 65,536 个(约 0.011%),转成 FP32 几乎不增加显存负担,却能解决小学习率下归一化层权重因精度舍入而完全不更新的问题(详见后文复盘)。
同时,restore_norm_output_dtype 这个前向 hook 必不可少:在当前 Qwen3 实现中,当 FP32 的 norm 权重参与乘法运算时,会将输出张量提升为 FP32。注册 hook 将输出转回输入激活的 BF16 类型,才能保证后续线性层继续以 BF16 运算;如果漏掉这一步,后续激活的精度与计算开销都会被连带改变。
另外需要注意,模型参数的实际存储精度取决于 from_pretrained(..., dtype=...) 和 .float() 等显式转换,而不能只看 GRPOConfig 里的 bf16=True 开关。一旦修改了模型精度,必须重新实例化模型与 trainer,确保优化器绑定的是最新精度的参数张量。
3. 数据集构造:先均匀抽答案,再反推生成数字
import random
TASK_VERSION = "stepwise-parity-v1"
SYSTEM_PROMPT = (
"Classify every number in the input list as even or odd, then count the even numbers. "
"Copy every input number once, in the same order. Keep repeated numbers. "
"Write one line per number using exactly 'number: even' or 'number: odd'. "
"Finish with exactly 'Answer: count'. Do not add any other text.\n"
"Example for [3, 8, 8]:\n3: odd\n8: even\n8: even\nAnswer: 2"
)
def make_dataset(n=1000, seed=42, num_numbers=10):
rng = random.Random(seed)
rows = []
for _ in range(n):
answer = rng.randint(0, num_numbers)
nums = ([rng.randrange(2, 100, 2) for _ in range(answer)]
+ [rng.randrange(1, 100, 2) for _ in range(num_numbers - answer)])
rng.shuffle(nums)
rows.append({
"prompt": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Numbers: {nums}\nClassify each number, then give the even count."},
],
"numbers": nums,
"answer": answer,
"task_version": TASK_VERSION,
})
return Dataset.from_list(rows)
def load_or_make(split, n, seed):
directory = DATA_DIR / split
if directory.exists():
dataset = Dataset.load_from_disk(str(directory))
if not {"prompt", "numbers", "answer", "task_version"}.issubset(dataset.column_names):
raise ValueError(f"Old dataset schema at {directory}; use a new data directory")
if len(dataset) != n or set(dataset["task_version"]) != {TASK_VERSION}:
raise ValueError(f"Unexpected dataset version/size: {directory}")
else:
dataset = make_dataset(n=n, seed=seed)
dataset.save_to_disk(str(directory))
for row in dataset:
assert len(row["numbers"]) == 10
assert sum(number % 2 == 0 for number in row["numbers"]) == row["answer"]
return dataset
dataset_train = load_or_make("train", 2000, 42)
dataset_test = load_or_make("test", 1000, 43)
dataset_dev = load_or_make("dev", 50, 44)
print("Rows:", len(dataset_train), len(dataset_dev), len(dataset_test))
print("Train answer distribution:", Counter(dataset_train["answer"]))如果直接独立随机抽取 10 个整数,二项分布会让偶数个数高度集中在 5 附近,模型只要无脑猜中间值就能混到不错的平均分。因此这里采用“先在 0..10 之间均匀抽取答案 $k$,再分别生成 $k$ 个偶数和 $10-k$ 个奇数并打乱顺序”的构造方式,使各个答案类别的分布近似均衡。
数据集按不同随机种子划分为 train(2000 条)、dev(50 条)和 test(1000 条),独立种子生成时未做显式跨集合去重。逐数版在数据列中新增了 numbers 字段,方便奖励函数直接读取原始数字列表;answer 字段仅供后台评分使用,不会出现在模型的输入提示中。
此外,代码通过 task_version 校验杜绝误读旧版数据目录,并在加载后对全部 3050 条样本做了一次断言自检,确保重算的偶数个数与标签 100% 吻合(新旧版本数据集底层的数字序列与真实答案也完全一致)。
4. 输出解析:严格按位置对齐,保留重复数字
import re
ITEM_PATTERN = re.compile(r"(-?\d+)\s*:\s*(even|odd)", re.IGNORECASE)
ANSWER_PATTERN = re.compile(r"Answer\s*:\s*(-?\d+)", re.IGNORECASE)
def completion_text(completion):
if isinstance(completion, str):
return completion.strip()
return completion[-1]["content"].strip()
def parse_completion(completion):
lines = [line.strip() for line in completion_text(completion).splitlines() if line.strip()]
answer_lines = [i for i, line in enumerate(lines) if ANSWER_PATTERN.fullmatch(line)]
final_match = ANSWER_PATTERN.fullmatch(lines[-1]) if lines else None
answer = int(final_match.group(1)) if final_match and len(answer_lines) == 1 else None
item_lines = lines[:-1] if final_match else lines
entries = []
for line in item_lines:
match = ITEM_PATTERN.fullmatch(line)
entries.append((int(match.group(1)), match.group(2).lower()) if match else None)
return entries, answer
def score_completion(completion, numbers, answer):
entries, pred = parse_completion(completion)
correct_items = 0
for i, number in enumerate(numbers):
label = "even" if number % 2 == 0 else "odd"
if i < len(entries) and entries[i] == (number, label):
correct_items += 1
complete = len(entries) == len(numbers) and all(
entry is not None and entry[0] == number
for entry, number in zip(entries, numbers)
)
format_ok = complete and pred is not None and 0 <= pred <= len(numbers)
own_even_count = sum(entry[1] == "even" for entry in entries) if complete else None
consistent = bool(format_ok and pred == own_even_count)
answer_correct = pred == int(answer)
item_score = correct_items / len(numbers)
return {
"prediction": pred,
"correct_items": correct_items,
"item_score": item_score,
"answer_correct": bool(answer_correct),
"consistent": consistent,
"format_ok": bool(format_ok),
"fully_correct": bool(format_ok and correct_items == len(numbers) and answer_correct),
}解析器在设计上有几个防钻空子的细节:
- 位置严格对齐:每行使用正则
fullmatch匹配。如果某一行格式非法而无法解析,会在entries列表中占位保留一个None,而不会直接丢弃该行——这样后续行依然和输入列表的原始下标一一对应,不会因为中间坏掉一行而把后面所有数字的对齐关系错位。 - 唯一末尾答案与完整性检查:
Answer: count必须恰好出现一次,且位于最后一个非空行。complete要求解析出的条目数与输入长度相等,且每个位置上的数字都与原输入完全一致。解析容忍大小写和首尾空白,但漏写重复数字、调换顺序或夹带多余行都会导致complete为False。 - 局部奖励与全局严苛指标分离:逐项得分
item_score与整体格式完整性是分开计算的。假如模型前 10 行完全写对,却在末尾多画蛇添足写了一行数字,它依然能拿到前 10 行的逐项分数(保留局部正向学习信号),但complete、format_ok和consistent都会判定失败,“完整解答正确率”也会严格将其排除。
5. 三项奖励函数如何加权合成?
def item_reward(completions, numbers, answer, **kwargs):
return [score_completion(c, nums, gold)["item_score"]
for c, nums, gold in zip(completions, numbers, answer, strict=True)]
def answer_reward(completions, numbers, answer, **kwargs):
return [float(score_completion(c, nums, gold)["answer_correct"])
for c, nums, gold in zip(completions, numbers, answer, strict=True)]
def consistency_reward(completions, numbers, answer, **kwargs):
return [float(score_completion(c, nums, gold)["consistent"])
for c, nums, gold in zip(completions, numbers, answer, strict=True)]
def expected_text(numbers):
lines = [f"{number}: {'even' if number % 2 == 0 else 'odd'}" for number in numbers]
return "\n".join(lines + [f"Answer: {sum(number % 2 == 0 for number in numbers)}"])这三个函数分别提取同一批生成结果的逐项准确率、最终答案正确性和自身计数一致性。TRL 会将数据集中的额外字段(numbers 和 answer)自动透传给各个 reward 函数,并按照 reward_weights 对多项奖励做加权求和(详见 TRL GRPO 多奖励函数文档)。
本轮配置的权重为 [1.0, 1.0, 0.5],单份回答的总奖励计算公式为:
$$\text{Reward} = \text{逐项正确率} \times 1.0 + \text{最终答案正确} \times 1.0 + \text{自身计数一致} \times 0.5$$
以输入 [2, 3, 2, 5](真实答案为 2)为例,不同回答拿到的分数拆解如下:
回答情况(以 [2, 3, 2, 5] 为例) | 逐项分数 | 答案奖励 | 一致性奖励 | 加权总分 |
|---|---|---|---|---|
全部判断正确,且 Answer: 2 | 1.00 | 1.0 | 1.0 | 2.50 |
全部判断正确,但 Answer: 3 | 1.00 | 0.0 | 0.0 | 1.00 |
把 3 错写成 even,但 Answer: 2 | 0.75 | 1.0 | 0.0 | 1.75 |
把 3 错写成 even,且 Answer: 3 | 0.75 | 0.0 | 1.0 | 1.25 |
跳过过程,只写一行 Answer: 2 | 0.00 | 1.0 | 0.0 | 1.00 |
这里有一个很关键的平衡:一致性奖励核对的是模型“最终答案是否等于它自己写出的 even 行数”。换句话说,如果模型把奇数错标成了 even,然后老老实实按错误的标签数出了总数(如表中的第 4 行),它也能拿到一致性奖励。正因如此,一致性奖励的权重被限制在 0.5,必须配合逐项奖励(检查真实奇偶)和答案奖励(检查真实总数)共同作用,只有三者全对才能拿到满分 2.5。同时,本方案把格式合法性内嵌为一致性奖励的前置条件,不再像早期失败实验那样设置巨额的负分惩罚。
6. 训练前自检:验证奖励逻辑与 Token 长度上限
在正式启动训练前,先用 10 个边界测试用例检验打分逻辑,并核对训练集标准答案的 token 长度:
# Position-based matching: duplicated input numbers must each appear.
nums = [2, 3, 2, 5]
perfect = expected_text(nums)
cases = [
("correct including duplicates", perfect, 1.0, True, True),
("one wrong parity", "2: even\n3: even\n2: even\n5: odd\nAnswer: 2", 0.75, True, False),
("wrong final answer", perfect.replace("Answer: 2", "Answer: 3"), 1.0, False, False),
("missing duplicate", "2: even\n3: odd\n5: odd\nAnswer: 2", 0.5, True, False),
("reordered", "3: odd\n2: even\n2: even\n5: odd\nAnswer: 2", 0.5, True, False),
("extra entry", perfect.replace("Answer:", "2: even\nAnswer:"), 1.0, True, False),
("answer only", "Answer: 2", 0.0, True, False),
("partial rows", "2: even\n3: odd", 0.5, False, False),
("wrong copied number", perfect.replace("2: even", "4: even", 1), 0.75, True, False),
("multiple answers", perfect + "\nAnswer: 2", 1.0, False, False),
]
for name, text, items, answer_ok, consistent in cases:
score = score_completion(text, nums, 2)
assert score["item_score"] == items, (name, score)
assert score["answer_correct"] == answer_ok, (name, score)
assert score["consistent"] == consistent, (name, score)
values = [item_reward([text], [nums], [2])[0],
answer_reward([text], [nums], [2])[0],
consistency_reward([text], [nums], [2])[0]]
total = values[0] + values[1] + 0.5 * values[2]
assert 0 <= total <= 2.5
assert (total == 2.5) == (name == "correct including duplicates")
print("Reward checks passed:", len(cases))
target_lengths = [len(tokenizer(expected_text(row["numbers"]), add_special_tokens=False)["input_ids"])
for row in dataset_train]
assert max(target_lengths) + 1 < MAX_COMPLETION_LENGTH
print("Longest target tokens:", max(target_lengths), "/", MAX_COMPLETION_LENGTH)10 项断言测试全部通过。同时可以看到,训练集标准输出最长为 55 tokens,将 MAX_COMPLETION_LENGTH 设为 192 为结束符与采样波动留出了充裕空间(旧版单整数任务设为 8 tokens,改成 10 行输出后必须同步放大,否则输出会被腰斩)。
7. 统一评估管线与 Callback 监控
def generate_replies(model, rows):
texts = [tokenizer.apply_chat_template(
row["prompt"], tokenize=False, add_generation_prompt=True, enable_thinking=False,
) for row in rows]
inputs = tokenizer(texts, padding=True, return_tensors="pt").to(next(model.parameters()).device)
with torch.inference_mode():
outputs = model.generate(
**inputs, max_new_tokens=MAX_COMPLETION_LENGTH, do_sample=False, use_cache=True,
)
return tokenizer.batch_decode(outputs[:, inputs["input_ids"].shape[1]:], skip_special_tokens=True)
def bench_all(model, dataset, verbose=False, batch_size=4):
was_training = model.training
model.eval()
scores = []
prediction_counts = Counter()
try:
for start in range(0, len(dataset), batch_size):
rows = [dataset[i] for i in range(start, min(start + batch_size, len(dataset)))]
replies = generate_replies(model, rows)
for row, reply in zip(rows, replies, strict=True):
score = score_completion(reply, row["numbers"], row["answer"])
scores.append(score)
prediction_counts[score["prediction"]] += 1
if verbose:
print(f"Expected: {row['answer']}, Got: {score['prediction']}, "
f"Items: {score['correct_items']}/{len(row['numbers'])}, "
f"Fully correct: {score['fully_correct']}\n{reply.strip()}\n")
finally:
model.train(was_training)
correct = sum(score["answer_correct"] for score in scores)
acc = correct / len(scores)
print(f"Total correct: {correct}/{len(scores)} ({acc:.1%})")
print(f"Item accuracy: {sum(s['item_score'] for s in scores) / len(scores):.1%}")
print(f"Fully correct: {sum(s['fully_correct'] for s in scores)}/{len(scores)}")
print(f"Format valid: {sum(s['format_ok'] for s in scores) / len(scores):.1%}; "
f"Count consistent: {sum(s['consistent'] for s in scores) / len(scores):.1%}")
print("Final answer distribution:", dict(prediction_counts))
return acc无论在训练采样还是 dev 评估中,都通过 enable_thinking=False 显式关闭了 Qwen3 的内置思考模式(参考 Qwen3-0.6B 官方模型卡说明),让模型直接在正式回复中输出逐行判断。dev 评估固定采用 do_sample=False(greedy 解码)以统一对比口径,而训练阶段则保持采样以生成同题的多个候选回答。
批量生成时配合 padding_side="left",解码阶段按 padding 后的输入张量宽度切掉左侧 prompt,只把新增的生成文本送入 score_completion。评估函数在进入时会记录 was_training 并切换至 model.eval(),在 finally 块中恢复原训练状态,确保训练中途调用 callback 不会意外改变模型的 train/eval 模式。
训练开始前,可以先抽 5 道题打印具体生成内容做直观检查:
bench_all(model, dataset_dev.select(range(5)), verbose=True)而在正式训练期间,完整的 50 题 dev 集由下面的 BenchmarkCallback 在 step 0 及之后每 10 步自动触发评估(注:传给 GRPOTrainer 的 eval_dataset=dataset_dev 仅作数据挂载,文中的所有 dev 指标均由该 callback 统一打印):
class BenchmarkCallback(TrainerCallback):
def __init__(self, dev_dataset, every_n_steps=10):
self.dev_dataset = dev_dataset
self.every_n_steps = every_n_steps
self.steps = []
self.accuracies = []
def run_benchmark(self, model, step):
acc = bench_all(model, self.dev_dataset, verbose=False)
self.steps.append(step)
self.accuracies.append(acc)
print(f"[DEV] step={step}, answer_accuracy={acc:.1%}")
def on_train_begin(self, args, state, control, model=None, **kwargs):
self.run_benchmark(model, step=0)
def on_step_end(self, args, state, control, model=None, **kwargs):
if state.global_step > 0 and state.global_step % self.every_n_steps == 0:
self.run_benchmark(model, step=state.global_step)8. GRPO 训练配置与 Batch 口径换算
config = GRPOConfig(
output_dir=str(OUTPUT_DIR),
max_steps=100,
per_device_train_batch_size=8,
num_generations=8,
gradient_accumulation_steps=4,
num_iterations=1,
max_completion_length=MAX_COMPLETION_LENGTH,
learning_rate=3e-6,
bf16=True,
chat_template_kwargs={"enable_thinking": False},
logging_steps=1,
report_to="none",
gradient_checkpointing=True,
torch_empty_cache_steps=1,
optim="adamw_torch",
beta=0.02,
entropy_coef=0.01,
reward_weights=[1.0, 1.0, 0.5],
dataloader_pin_memory=False,
save_steps=20,
seed=42,
)
trainer = GRPOTrainer(
model=model,
processing_class=tokenizer,
args=config,
train_dataset=dataset_train,
# Keep test data for evaluation after choosing the experiment configuration.
eval_dataset=dataset_dev,
reward_funcs=[item_reward, answer_reward, consistency_reward],
callbacks=[BenchmarkCallback(dataset_dev, every_n_steps=10)],
)
trainer.train()本轮训练使用 learning_rate=3e-6,配合 BF16 主体 + FP32 RMSNorm;同时启用 beta=0.02(参考策略 KL 约束)和 entropy_coef=0.01(熵正则项),与有界奖励一起维持探索稳定性。至于这组超参是否适合其他任务,仍需结合具体任务的训练日志与验证集表现来判断。
在使用 GRPOConfig 时,最容易产生误解的是 Batch Size 与实际覆盖题目数的换算关系(详见 TRL GRPOConfig 文档)。在当前单设备、num_iterations=1 及默认生成批次设置下:
每次 optimizer update 生成的 completion 总数 = per_device_train_batch_size (8) × gradient_accumulation_steps (4) = 32
每道题目采样生成的 completion 数 (num_generations) = 8
每次 optimizer update 实际覆盖的不同题目数 = 32 / 8 = 4 道题
训练 100 steps 累计覆盖的不同题目数 = 100 × 4 = 400 道题 = 2000 条训练集的 0.2 epoch同一道题生成的 8 份回答用来在组内横向比较奖励。早期版本设置 per_device_train_batch_size=8, num_generations=8, gradient_accumulation_steps=1 时,每一步更新其实只看了 1 道不同题目,哪怕跑满 500 步也只相当于 0.25 个 epoch。弄清这一换算关系,才不会把 step 数量误当成样本覆盖量。此外,gradient_checkpointing=True 和 torch_empty_cache_steps=1 用于在 MPS 上压低显存占用(会牺牲一部分运行速度),save_steps=20 则定期保存 checkpoint 以便回溯复测。
踩坑复盘:从 0/50 到 72% 路上到底发生了什么?
回顾整个实验过程,每一次失败其实都对应着一个具体的机制问题。下面按排查顺序复盘八个核心坑点。
坑点 1:dev 0/50 —— 误算训练轮数,且 BF16 下微小更新被精度吞没
- 现象:在最初的单整数输出任务中,原始模型在 dev 上得分为 0/50,训练过程中也依然是 0/50。观察生成结果,模型往往只是从输入列表里随便挑了一个偶数(如
2、4、8)输出。当时我误以为已经“训了两轮”,但事后核对保存下来的日志才发现,可确认的那次运行其实在第 106/500 步就中断了。 原因排查:
- 先对全部 3050 条数据标签做重算校验,排除了数据标错的可能。
- 检查参数更新幅度时发现:在纯
BF16参数 +1e-6学习率 + 当前adamw_torch配置下,由于没有维护FP32master weights,计算出的微小参数增量在写回BF16时绝大部分被直接舍入归零了。 - 同时,由于
batch=8, generations=8, accumulation=1每步只包含 1 道题,把运行次数和 epoch 概念混为一谈,导致实际见过的样本量极少。
下面是在 3 个真实 GRPO optimizer steps 后,对比纯 BF16 与纯 FP32 下部分参数层元素发生改变的比例(第一步梯度非零占比均为 100%,但落盘到参数时出现了巨大差异):
| 检查的参数位置 | BF16 三步累计改变占比 | FP32 三步累计改变占比 |
|---|---|---|
第 0 层 q_proj(前 65,536 个元素) | 1.495% | 99.998% |
第 0 层 input_layernorm(1,024 个元素) | 0% | 100% |
第 27 层 q_proj(前 65,536 个元素) | 1.181% | 99.991% |
- 应对措施:首先厘清不同题目覆盖量的换算公式并加上梯度累积;其次用全
FP32的三步探测验证了梯度更新链路畅通,随后为了兼顾显存采用“主体BF16+FP32RMSNorm”的混合方案。不过,打通数值更新只解决了“参数动不动”的问题,单整数任务本身的奖励设计依然无法教会模型计数。
坑点 2:保留 BF16 仅调高学习率,RMSNorm 依然纹丝不动
- 现象:尝试保持纯
BF16不变,单纯把学习率从1e-6提高到1e-5甚至3e-5。结果发现线性层矩阵的更新比例确实上去了,但input_layernorm在三步之后的改变占比依然是 0%。 - 原因排查:浮点数相邻可表示值之间的间距(ULP)与数值本身的绝对大小成正比。
q_proj权重数值接近 0,间距较小;而RMSNorm的初始权重集中在1.0附近,在BF16下1.0附近的最小可表示间距要大得多,微小的梯度步长根本跨不过去。实测在1e-5下两个q_proj切片约有 12.215% 和 9.949% 的元素发生改变,提到3e-5后增至 24.411% 和 20.447%,但RMSNorm依然原地踏步。 - 应对措施:专门将
RMSNorm层转为FP32并用 forward hook 恢复输出BF16。改进后在1e-5的三步探测中,受检 norm 参数的改变占比立刻达到 100%。正式的长程逐数训练则稳妥地选用了较小的3e-6学习率。需要注意,梯度累积只能扩大每步估计梯度的样本量,并不会自动变出FP32主权重;且FP32norm 只覆盖了 65,536 个参数,其余BF16矩阵的舍入效应依然客观存在。
坑点 3:把非法格式惩罚从 -11 改成 -114514,导致策略塌缩全输出 1
- 现象:在某轮使用全
FP32参数、1e-5学习率的实验中,为了严惩非法输出,我把格式错误惩罚设成了极端的-114514。结果训完 100 步后,模型在 dev 集上对所有题目全部输出1(dev 中真实答案为 1 的恰好有 6 道题,于是得分定格在 6/50,即 12%)。 原因排查:
- 在那之前,非法格式给
-11分,而合法整数按无界距离-abs(pred - gold)扣分。这导致一个荒诞的漏洞:假如真实答案是 6,模型瞎猜一个合法数字24会得-18分,还不如直接胡言乱语触发格式错误拿-11分划算。 - 为了堵住这个漏洞,我把非法格式惩罚一竿子捅到了
-114514,结果走向了另一个极端。GRPO 是在同一道题的采样组内对奖励做减均值除标准差来计算相对优势(Advantage,详见 TRL 优势计算说明)。我们可以通过一个机制演示看清问题:假设一组 8 个采样得到的奖励分别是[-114514, -6, -5, -4, -3, -2, -1, 0],做组内标准化后,那 7 个合法回答(无论答对还是差了 6)算出来的 Advantage 全部被压缩到约+0.3535!巨大的异常值撑爆了组内标准差 $\sigma$,使得“合法但答错”和“完全答对”之间的相对差异几乎被抹平(注:此数组为说明标准化缩放机制的数值演示,当时历史日志未保存每组原始 rollout)。 翻看那轮训练的真实日志,塌缩过程非常清晰:
- Step 10:entropy 为
0.12512,组内零方差比例(frac_reward_zero_std)已达0.875; - Step 20:entropy 跌至
0.02140,组内零方差比例升至0.975; - Step 30:entropy 仅剩
0.000387,组内零方差比例达到1.0; - Step 30 → 100:此后直到训练结束,
loss和grad_norm全程为0。由于当时配置中 KL 系数与熵系数也都是0,一旦模型收敛到对所有题都输出同一个合法数字1,同题采样的 8 份回答得分完全相同,组内方差为 0,Advantage 全为 0,模型彻底丧失了更新梯度。
- Step 10:entropy 为
- 在那之前,非法格式给
- 应对措施:从初始权重重新开始,将所有奖励函数限制在量级相近的有界区间内(如后来逐数版的
0..2.5),并设置learning_rate=3e-6、beta=0.02、entropy_coef=0.01。需要客观指出的是,当时那次失败同时改动了精度、学习率和惩罚值,虽然日志确凿无疑地记录了策略塌缩,但无法在事后把锅 100% 归咎于某一个单独的超参;这也提醒我们,跑通几步短测试远远不够,必须在较长步数下持续监控输出分布与组内奖励方差。
坑点 4:训练 100 步准确率始终停在 1/50 —— 权重其实在变,只是从一种错法换成了另一种错法
- 现象:换用恢复版配置(修好尺度与正则)后,每隔 10 步做一次 dev 评估,从 step 10 到 step 100 的准确率雷打不动全是 1/50(2%)。表面看起来,简直像是评估 callback 写出了 bug,一直拿着同一个旧模型在跑。
原因排查:
- 我把保存下来的
checkpoint-20和checkpoint-100重新加载出来单独做 greedy 复测,结果两者确实都是 1/50,且唯一答对的都是第 24 题(真实答案为 4)。但仔细比对 50 道题的具体输出文本后发现,有 7 道题的预测答案发生了变化——只不过全都是从一个错误答案变成了另一个错误答案!其中预测结果为4的题目数量从 34/50 进一步集中到了 40/50。 - 查看第 100 步的训练日志:
entropy=0.5989、grad_norm=92.44、组内零方差比例为0。这说明它和上一次“全输出 1、梯度归零”的死锁截然不同,模型依然在活跃地采样和更新参数。 - 真正的原因藏在奖励权重里:那一版传入了
reward_funcs=[reward, exact_match_reward, format_reward],但配置的权重却是reward_weights=[1, 0, 0]!也就是说,后面两个函数只用来打日志,真正驱动优化的只有第一个距离奖励。在训练集上,假如模型完全不看输入、无脑固定输出中间值5,它的精确匹配准确率只有 8.2%,但平均绝对距离只有2.773,折算成距离奖励高达-0.25209。单纯优化距离奖励,极易诱导模型学会“猜中间数(4 或 5)以最小化平均误差”的懒惰策略。我用[2]*k + [1]*(10-k)($k=0\dots 10$)构造了一组极简控制题去测保存下来的模型,结果 11 道题全军覆没,输出完全不随偶数个数 $k$ 变化。
- 我把保存下来的
- 应对措施:必须给精确匹配(答案正确性)赋予非零权重,并将任务拆解为按位置给分的逐数奇偶判断(即当前
[1.0, 1.0, 0.5]的组合),同时在 dev 评估中每次打印预测答案的分布字典(Counter),一眼看穿模型是否在“押宝中间值”。
坑点 5:训练日志里格式合法率接近 100%,但计数能力依然接近 0
- 现象:在旧恢复版的 100 个训练日志点中,模型输出的平均格式合法率高达
99.65625%,但采样回答的平均 exact-match 只有6.84375%,固定 dev 评估更是只有2%。 - 原因排查:“学会输出一个合法的阿拉伯数字”和“学会根据输入列表数出偶数个数”完全是两码事。当格式合法率迅速逼近 100% 后,格式奖励就不再提供区分度,剩下的纯距离奖励继续把模型往中间值上推。此外,训练日志里的采样分数与固定 dev 集上的 greedy 评估在数据分布和解码策略上都不相同,不能混为一谈。
- 应对措施:在评估面板中把“格式合法率”、“逐项正确率”、“自身计数一致率”和“最终答案正确率”拆开并列展示。格式合法率上升只代表输出结构趋于稳定,只有最终答案与完整解答指标上升,才代表模型真正学会了计数。
坑点 6:改为逐数输出后,生成长度与解析规则必须同步升级
- 现象:如果沿用单整数版的
max_completion_length=8,10 行奇偶判断还没写完就会被强行截断;如果解析器简单地把模型输出的{number: parity}放进字典或集合去重,输入中的重复数字就会被吞掉;如果只用正则抓取全文最后一个整数当答案,模型在末尾重复输出多行Answer:也能蒙混过关。 - 原因排查:任务形态从“单值输出”升级为“10 行有序序列 + 1 行汇总”后,原有的超参与解析器假设全部失效,必须在开训前做针对性的兼容性重构。
- 应对措施:将最大生成长度上调至 192(标准答案最长 55 tokens),采用按行下标严格对齐的列表解析,要求
Answer:行全局唯一且位于末尾,并用前文展示的 10 个单元测试用例锁死边界行为。本轮单步探测与 100 步完整训练日志均确认,生成截断比例(completions/clipped_ratio)全程为0。
坑点 7:Notebook 内存状态残留、配置与实际参数精度脱节
- 现象:翻查早期旧 notebook 时发现,模型加载 cell 与训练 cell 的执行序号不连续;最初跑出的 dev baseline 是 0/50,但后来启动 trainer 时 step 0 的评估却变成了 2/50;还曾出现过配置里写着
bf16=True,但实际加载进显存的模型参数却是FP32;另有部分历史 checkpoint 的评估输出与中断的运行记录对不上号。 - 原因排查:Jupyter Notebook 会跨执行保留内存引用。如果在没重启 kernel 的情况下直接重跑训练 cell,实际上是在已经更新过的模型权重上继续训练;修改代码后未重跑的旧输出也会留在页面上造成误导。此外,复测混合精度 checkpoint 时如果先按
BF16整体加载、再对RMSNorm调.float(),保存的FP32norm 权重在加载瞬间就已经被量化截断成BF16了,事后调.float()只是补零,无法恢复原有的高精度数值。 - 应对措施:每次修改方案后强制重启 kernel 并按顺序从头运行,显式打印模型参数的实际
dtype与 step 0 基线;每次运行生成带时间戳的独立输出目录,归档保存历史 notebook;在离线复测 checkpoint 时,确保直接读取保存的FP32norm 权重张量而不经过二次BF16量化。由于历史 notebook 的执行序号只能提示状态存在复用、无法还原当时的完整操作序列,排查文档中也如实标注了这一证据边界。
坑点 8:受限 Shell 环境探测失败,容易误判为硬件不支持 MPS
- 现象:排查期间,曾在受限子进程 shell 中运行命令检查环境,返回
MPS unavailable,尝试构建BF16训练配置也直接报错;另一次直接调用系统默认 Python 去加载模型权重检查,则因为缺少依赖包而失败。 - 原因排查:宿主机硬件以及 Jupyter 所用的
Python (GRPO)虚拟环境本身完全支持 MPS。报错是因为受限沙盒 shell 无法访问 Metal 设备接口,且系统默认 Python 与项目虚拟环境不是同一个解释器。 - 应对措施:区分“外部工具/子进程的环境限制”与“真实训练环境的硬件能力”,所有三步参数更新探测、checkpoint 复测以及最终的 100 步训练,均统一在挂载了
Python (GRPO)kernel 的真实 MPS 路径下执行验证。
实用排查清单:以后跑 GRPO 遇到异常先看什么?
这次折腾留下了一套非常实用的自检清单。当强化学习训练不涨分或出现诡异行为时,建议按以下 7 步逐一核对证据:
- 先验数据标签:写断言脚本把所有训练/验证数据的真值标签重算一遍,确保评估目标本身没有乌龙。
- 锁定初始基线与真实精度:重启 kernel 加载全新模型,打印参数张量的实际
dtype,并跑完完整的 step 0 dev 基线。 - 三步参数更新探测:跑 3 个真实 optimizer steps,对比更新前后的参数张量差异比例,同时检查梯度是否为有限非零值,确认微小更新没有被低精度舍入吞没。
- 换算真实题目覆盖量:把
per_device_train_batch_size × gradient_accumulation_steps / num_generations算清楚,明确每步到底看了几道不同题目,再谈训练了多少个 epoch。 - 单元测试你的 Reward 函数:在开训前手写覆盖“全对、部分对、漏重复项、乱序、多写一行、只写答案”的测试用例,确认奖励打分没有被钻空子的捷径。
- 多维指标联合诊断:不要只盯一个 loss 或准确率。同时观察预测答案分布(
Counter)、组内奖励零方差比例(frac_reward_zero_std)、梯度范数(grad_norm)以及固定 dev 集上的分项指标。 - 隔离数据与输出目录:每次调整任务格式或奖励定义时,使用新的数据集版本号与带时间戳的输出目录,保留完整的
trainer_state.json以便回溯。
特别补充:如何正确解读 Token Entropy?
看训练日志时,平均 token entropy 的高低必须结合任务的输出结构来解读。在本轮逐数输出任务中,每一行都有大量确定性的复制数字、冒号、换行符以及固定的 Answer: 前缀,这些结构性 token 的概率极高、熵极低,会把整句的平均熵拉得很低。例如本轮第 100 步时,平均 entropy 仅为 0.02389,但此时 grad_norm 为 7.062,组内零方差比例为 0,dev 最终答案准确率保持在 66%。因此,单凭一个很低的 entropy 数值并不能断定策略发生了塌缩;只有像早期失败实验那样,同时观察到“输出分布坍缩为单一值 1 + 组内零方差比例升至 1.0 + 梯度范数归零”,才能确认为策略塌缩。
当前遗留问题与下一步计划:
目前最清晰的短板在于最后一步的计数汇总:模型在第 100 步的逐项奇偶判断准确率已达 99.4%,但最终答案准确率停在 66%(峰值 72%)。接下来的工作分为两步:首先按照既定的 dev 选模规则挑出最佳保存点(如 checkpoint-40 或 checkpoint-80),在尚未触碰的 1000 条独立 test 集上完成最终评估;若要进一步改进模型表现,则应设计控制变量的消融实验,分别检验提示词设计、最终答案奖励权重与自身一致性奖励各自的独立贡献。
原理拆解:在这个实验里,Reward、Advantage 和 Loss 是怎么串起来的?
很多刚开始用 TRL 跑 GRPO 的同学容易混淆日志里的各项数值。结合本实验的具体配置(核对本地 TRL 源码实现:当前使用 scale_rewards="group"、loss_type="dapo",并开启 beta=0.02 与 entropy_coef=0.01),我们可以用一句话概括三者的关系:
Reward 给每份完整回答打绝对分数,Advantage 衡量这份回答在同题的 8 个采样本里比平均水平好多少,而 Loss 则把这份相对好坏转化为拉高或压低对应 token 概率的梯度方向。
整个前向与更新的数据流向如下:
同一道题采样生成 8 份回答 (num_generations = 8)
↓
Python 解析文本,为每份回答计算标量 Reward
↓
在这 8 份回答的组内做标准化,算出每份回答的 Advantage
↓
结合 Advantage 与模型对每个生成 token 的预测概率比,计算 Loss
↓
backward() 反向传播 + optimizer.step() 更新模型权重1. Reward:这份回答本身得几分?
实验定义了三个奖励函数,配置权重为 reward_weights = [1.0, 1.0, 0.5]:
reward_funcs = [
item_reward,
answer_reward,
consistency_reward,
]
reward_weights = [1.0, 1.0, 0.5]对于第 $i$ 份生成回答,其合成后的标量总奖励 $r_i$ 为:
$$r_i = \underbrace{\frac{\text{数字与奇偶均正确的行数}}{\text{输入数字个数}}}_{\text{item\_reward}} + \underbrace{\mathbf{1}[\text{最终答案正确}]}_{\text{answer\_reward}} + 0.5 \times \underbrace{\mathbf{1}[\text{结构合法且答案与自身 even 行数一致}]}_{\text{consistency\_reward}}$$
我们再用简化的 4 个数字 [2, 3, 2, 5](真实偶数为 2 个)直观走一遍计算。如果模型生成了如下满分回答:
2: even
3: odd
2: even
5: odd
Answer: 2它的三项得分分别是:item_reward = 4/4 = 1.0、answer_reward = 1.0、consistency_reward = 1.0,加权总分 $r_i = 1.0 + 1.0 + 0.5 \times 1.0 = 2.5$。
当回答出现不同类型的瑕疵时,得分会呈現清晰的梯度(如下表):
| 回答情况 | 逐项分数 | 答案分数 | 一致性分数 | 总 Reward ($r_i$) |
|---|---|---|---|---|
全部正确,最后答 2 | 1.00 | 1.0 | 1.0 | 2.50 |
每行奇偶都对,最后错答 3 | 1.00 | 0.0 | 0.0 | 1.00 |
把 3 错标成 even,最后答 2 | 0.75 | 1.0 | 0.0 | 1.75 |
把 3 错标成 even,最后答 3 | 0.75 | 0.0 | 1.0 | 1.25 |
没写逐行过程,只写 Answer: 2 | 0.00 | 1.0 | 0.0 | 1.00 |
注意看第 3 行和第 4 行的对比:“自身计数一致性”和“客观事实正确性”是正交的两项检查。第 4 行虽然把 3 错标成了 even,导致客观上有 3 行 even,但它最后诚实地写出了 Answer: 3,所以依然拿到了 1.0 的一致性分数(乘权重后为 0.5)。而纠正它“不要把 3 标成 even、不要答 3”的任务,则交给前两项客观奖励去完成。
需要牢记的是:这些 Reward 全部由外部 Python 正则解析算出,它们只是作为常数系数传给后续公式,Reward 计算过程本身是不可导的,不能直接向模型回传梯度。
2. Advantage:这份回答在同题组里排老几?
由于设置了 num_generations = 8,针对同一道输入题目,模型会一口气采样出 8 份回答组成一个 Group。TRL 在这个 8 份回答的小组内部计算均值 $\mu$ 与标准差 $\sigma$,进而求出第 $i$ 份回答的相对优势 $A_i$(参考 TRL 组内优势文档):
$$A_i = \frac{r_i - \mu}{\sigma + 10^{-4}}$$
假设针对某道题,模型生成的 8 份回答里有 4 份拿了满分(2.5),另外 4 份每行奇偶都判断对了、唯独最后汇总数错了(得 1.0 分):
rewards = [2.5, 2.5, 2.5, 2.5, 1.0, 1.0, 1.0, 1.0]此时组内平均分 $\mu = 1.75$,按本地实现的样本标准差计算 $\sigma \approx 0.802$,代入公式可得:
| 组内回答类型 | 原始 Reward ($r_i$) | 组内 Advantage ($A_i$) | 对模型参数的更新倾向 |
|---|---|---|---|
| 4 份完整正确的回答 | 2.5 | +0.935 | 提高生成这些回答中对应 token 的概率 |
| 4 份最后数错的回答 | 1.0 | −0.935 | 降低生成这些回答中对应 token 的概率 |
从这个例子可以直观理解两个核心机制:
- Reward 为正数,算出来的 Advantage 完全可能是负数:那 4 份最后数错的回答虽然拿到了
+1.0的正奖励,但因为落后于同组平均分1.75,它们的 Advantage 是-0.935,在更新时会被作为负样本压低概率。 - 当组内所有回答同分时,任务优势归零:假如 8 份回答全部拿到
1.0分(rewards = [1.0] * 8),那么每份回答的 $r_i - \mu = 0 \Rightarrow A_i = 0$。此时仅靠任务奖励完全无法区分谁好谁坏——这正是前文“全输出 1”塌缩实验中所发生的现象。
3. Loss:如何把相对优势转化为参数的梯度?
真正能对模型参数求导的,是模型前向传播算出的 token 预测概率。
对于第 $i$ 份回答中生成的第 $t$ 个 token $y_{i,t}$,定义新旧策略的概率比值 $\rho_{i,t}$:
$$\rho_{i,t} = \frac{\pi_\theta(y_{i,t} \mid x, y_{i,<t})}{\pi_{\mathrm{old}}(y_{i,t} \mid x, y_{i,<t})}$$
其中 $\pi_\theta$ 是当前待更新的模型,$\pi_{\mathrm{old}}$ 是采样生成该回答时的策略(在反向传播中作为固定常数,不传梯度)。忽略裁剪(clipping)和正则项时,单个生成 token 的策略损失可以直观写为:
$$\ell_{i,t} = -\rho_{i,t} A_i$$
因为优化器总是朝着减小 Loss 的方向更新参数:
- 当 $A_i > 0$(好回答)时,要让 $-\rho_{i,t} A_i$ 变得更小(更负),就必须增大 $\rho_{i,t}$,也就是提高当前模型生成该 token 的概率 $\pi_\theta$;
- 当 $A_i < 0$(差回答)时,要让 $-\rho_{i,t} A_i$ 变小,就必须减小 $\rho_{i,t}$,也就是压低当前模型生成该 token 的概率。
在实际代码中,为了防止单步更新幅度过大,还会加上 PPO 风格的裁剪机制:
$$\ell^{\text{policy}}_{i,t} = -\min\left(\rho_{i,t} A_i,\; \operatorname{clip}(\rho_{i,t}, 0.8, 1.2) A_i\right)$$
再加上本轮开启的 KL 惩罚项与熵正则项,完整的优化目标为:
$$L = L_{\text{policy}} + 0.02\, L_{\text{KL}} - 0.01\, H$$
- 策略项 $L_{\text{policy}}$:依据组内 Advantage 奖优罚劣(采用 DAPO reduction 对有效生成 token 汇总并做梯度累积归一化,prompt 与 padding 不计入损失);
- KL 约束项 $0.02\, L_{\text{KL}}$:限制当前模型 $\pi_\theta$ 偏离初始参考模型的幅度;
- 熵正则项 $-0.01\, H$:带负号意味着增大输出分布的熵 $H$ 可以降低总 Loss,从而鼓励模型保留一定的探索多样性。
一个容易被忽略的信用分配细节(Credit Assignment):
虽然我们在 Python 里写了细粒度的 item_reward(能精确知道第几行奇偶判断写错了),但在交给标准 GRPO 时,整份回答的所有奖励最终仍被加权合并成了唯一一个标量 $r_i$ 和唯一一个标量 $A_i$。例如下面这份回答:
2: even
3: even ← 只有这一行判断错了
2: even
5: odd
Answer: 2解析器知道它对了 3 行、错了 1 行,给出了 0.75 的逐项分和 1.75 的总分。但进入 Loss 计算后,这份回答里的每一个生成 token(包括前面写对的 2: even 和中间写错的 3: even)共享的是完全相同的 Advantage $A_i$。标准 GRPO 并没有单独给错误的那个 even token 分配一个负的 token-level advantage。逐项奖励的作用是让“只错一行”的回答比“全错”的回答拿到更高的总分,至于具体哪个位置的 token 应该对分数差异负责,仍然交由多样本采样下的策略梯度去统计归因。
4. 为什么日志里的 Loss 经常接近 0,模型却依然在正常学习?
在日常盯训练日志时,常常会看到 loss 在 0.000x 甚至正负 0 附近微小波动,这很容易让人产生“模型是不是没在学”的错觉。
原因在于本实验配置了 num_iterations = 1(每批采样只做一次梯度更新)。在当前的单次更新路径下,采样策略 $\pi_{\mathrm{old}}$ 就是当前模型自身 $\pi_\theta$,本地 TRL 实现中构造概率比的方式等价于:
ratio = torch.exp(logp - logp.detach())在前向计算数值时,由于 logp 和 logp.detach() 数值完全相等:
$$\text{logp} - \text{logp.detach()} = 0 \quad \Longrightarrow \quad \rho_{i,t} = \exp(0) = 1$$
而同一组内各份回答的 Advantage $A_i$ 是经过零均值标准化的(正负之和本身就接近 0)。当所有 $\rho_{i,t}$ 在前向计算中都恒等于 $1$ 时,把 $-\rho_{i,t} A_i$ 加总平均下来,前向计算出的 Loss 标量数值自然就会相互抵消、非常接近 0!
但是,数值接近 0 绝不等于梯度为 0。因为 detach() 只截断了减号后面那一项的梯度,减号前面的 logp 依然带着完整的计算图:
- 对于 $A_i > 0$ 的样本,梯度会推着它的
logp往上升; - 对于 $A_i < 0$ 的样本,梯度会推着它的
logp往下降。
因此,在排查训练状态时,必须严格区分以下两种“Loss 接近 0”的情况:
| 日志表面现象 | 底层真实状态 |
|---|---|
正负 Advantage 在均值上相互抵消导致 loss ≈ 0,但 grad_norm > 0 且组内零方差比例低 | 正常学习中:好回答与差回答正在向相反方向拉扯参数更新 |
| 组内所有回答得分完全相同,每个样本 $A_i = 0$,导致策略项梯度彻底消失 | 任务信号失效:模型已无法从当前采样组中获得任务层面的改进方向(仅剩 KL 与熵项提供梯度;若这两项也为 0,则 grad_norm = 0 彻底停滞) |
总结来说,在这类 GRPO 实验中,绝对不要把 Loss 的数值高低当成答题准确率的代理指标。真正决定训练成败的核心链路只有四条:Reward 能不能拉开不同质量回答的差距 $\rightarrow$ Advantage 有没有产生非零的正负分化 $\rightarrow$ Loss 有没有回传有限且非零的梯度(grad_norm) $\rightarrow$ 固定 dev 集上的真实准确率有没有稳步提升。