这次实验最初设定的任务非常小:给本地部署的 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最终答案正确逐项正确率完整解答正确格式合法率自身计数一致率
015/50(30%)96.6%13/50(26%)76%34%
1026/50(52%)98.8%25/50(50%)96%54%
2034/50(68%)99.4%33/50(66%)98%68%
3033/50(66%)99.4%32/50(64%)98%66%
4036/50(72%)99.6%35/50(70%)96%72%
5035/50(70%)99.6%34/50(68%)96%70%
6033/50(66%)99.2%32/50(64%)96%68%
7036/50(72%)99.6%35/50(70%)96%72%
8035/50(70%)99.6%34/50(68%)96%70%
9034/50(68%)99.6%33/50(66%)96%68%
10033/50(66%)99.4%32/50(64%)96%68%

从表中可以看到两个值得注意的现象:

  1. 准确率并非单调递增:最终答案准确率在第 40 步和第 70 步达到 72% 的高点,到第 100 步回落至 66%。继续训练并未带来持续上涨,因此挑选最终模型时不能盲目取最后一个 step,而应根据各保存点在完整 dev 集上的表现来选择 checkpoint。
  2. 瓶颈已从“判断奇偶”转移到“汇总计数”:到第 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: 21.001.01.02.50
全部判断正确,但 Answer: 31.000.00.01.00
把 3 错写成 even,但 Answer: 20.751.00.01.75
把 3 错写成 even,且 Answer: 30.750.01.01.25
跳过过程,只写一行 Answer: 20.001.00.01.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 步就中断了。
  • 原因排查:

    1. 先对全部 3050 条数据标签做重算校验,排除了数据标错的可能。
    2. 检查参数更新幅度时发现:在纯 BF16 参数 + 1e-6 学习率 + 当前 adamw_torch 配置下,由于没有维护 FP32 master weights,计算出的微小参数增量在写回 BF16 时绝大部分被直接舍入归零了。
    3. 同时,由于 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 + FP32 RMSNorm”的混合方案。不过,打通数值更新只解决了“参数动不动”的问题,单整数任务本身的奖励设计依然无法教会模型计数。

坑点 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 主权重;且 FP32 norm 只覆盖了 65,536 个参数,其余 BF16 矩阵的舍入效应依然客观存在。

坑点 3:把非法格式惩罚从 -11 改成 -114514,导致策略塌缩全输出 1

  • 现象:在某轮使用全 FP32 参数、1e-5 学习率的实验中,为了严惩非法输出,我把格式错误惩罚设成了极端的 -114514。结果训完 100 步后,模型在 dev 集上对所有题目全部输出 1(dev 中真实答案为 1 的恰好有 6 道题,于是得分定格在 6/50,即 12%)。
  • 原因排查:

    1. 在那之前,非法格式给 -11 分,而合法整数按无界距离 -abs(pred - gold) 扣分。这导致一个荒诞的漏洞:假如真实答案是 6,模型瞎猜一个合法数字 24 会得 -18 分,还不如直接胡言乱语触发格式错误拿 -11 分划算。
    2. 为了堵住这个漏洞,我把非法格式惩罚一竿子捅到了 -114514,结果走向了另一个极端。GRPO 是在同一道题的采样组内对奖励做减均值除标准差来计算相对优势(Advantage,详见 TRL 优势计算说明)。我们可以通过一个机制演示看清问题:假设一组 8 个采样得到的奖励分别是 [-114514, -6, -5, -4, -3, -2, -1, 0],做组内标准化后,那 7 个合法回答(无论答对还是差了 6)算出来的 Advantage 全部被压缩到约 +0.3535!巨大的异常值撑爆了组内标准差 $\sigma$,使得“合法但答错”和“完全答对”之间的相对差异几乎被抹平(注:此数组为说明标准化缩放机制的数值演示,当时历史日志未保存每组原始 rollout)。
    3. 翻看那轮训练的真实日志,塌缩过程非常清晰:

      • 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,模型彻底丧失了更新梯度。
  • 应对措施:从初始权重重新开始,将所有奖励函数限制在量级相近的有界区间内(如后来逐数版的 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,一直拿着同一个旧模型在跑。
  • 原因排查:

    1. 我把保存下来的 checkpoint-20 和 checkpoint-100 重新加载出来单独做 greedy 复测,结果两者确实都是 1/50,且唯一答对的都是第 24 题(真实答案为 4)。但仔细比对 50 道题的具体输出文本后发现,有 7 道题的预测答案发生了变化——只不过全都是从一个错误答案变成了另一个错误答案!其中预测结果为 4 的题目数量从 34/50 进一步集中到了 40/50。
    2. 查看第 100 步的训练日志:entropy=0.5989、grad_norm=92.44、组内零方差比例为 0。这说明它和上一次“全输出 1、梯度归零”的死锁截然不同,模型依然在活跃地采样和更新参数。
    3. 真正的原因藏在奖励权重里:那一版传入了 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(),保存的 FP32 norm 权重在加载瞬间就已经被量化截断成 BF16 了,事后调 .float() 只是补零,无法恢复原有的高精度数值。
  • 应对措施:每次修改方案后强制重启 kernel 并按顺序从头运行,显式打印模型参数的实际 dtype 与 step 0 基线;每次运行生成带时间戳的独立输出目录,归档保存历史 notebook;在离线复测 checkpoint 时,确保直接读取保存的 FP32 norm 权重张量而不经过二次 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 步逐一核对证据:

  1. 先验数据标签:写断言脚本把所有训练/验证数据的真值标签重算一遍,确保评估目标本身没有乌龙。
  2. 锁定初始基线与真实精度:重启 kernel 加载全新模型,打印参数张量的实际 dtype,并跑完完整的 step 0 dev 基线。
  3. 三步参数更新探测:跑 3 个真实 optimizer steps,对比更新前后的参数张量差异比例,同时检查梯度是否为有限非零值,确认微小更新没有被低精度舍入吞没。
  4. 换算真实题目覆盖量:把 per_device_train_batch_size × gradient_accumulation_steps / num_generations 算清楚,明确每步到底看了几道不同题目,再谈训练了多少个 epoch。
  5. 单元测试你的 Reward 函数:在开训前手写覆盖“全对、部分对、漏重复项、乱序、多写一行、只写答案”的测试用例,确认奖励打分没有被钻空子的捷径。
  6. 多维指标联合诊断:不要只盯一个 loss 或准确率。同时观察预测答案分布(Counter)、组内奖励零方差比例(frac_reward_zero_std)、梯度范数(grad_norm)以及固定 dev 集上的分项指标。
  7. 隔离数据与输出目录:每次调整任务格式或奖励定义时,使用新的数据集版本号与带时间戳的输出目录,保留完整的 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$)
全部正确,最后答 21.001.01.02.50
每行奇偶都对,最后错答 31.000.00.01.00
把 3 错标成 even,最后答 20.751.00.01.75
把 3 错标成 even,最后答 30.750.01.01.25
没写逐行过程,只写 Answer: 20.001.00.01.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 的概率

从这个例子可以直观理解两个核心机制:

  1. Reward 为正数,算出来的 Advantage 完全可能是负数:那 4 份最后数错的回答虽然拿到了 +1.0 的正奖励,但因为落后于同组平均分 1.75,它们的 Advantage 是 -0.935,在更新时会被作为负样本压低概率。
  2. 当组内所有回答同分时,任务优势归零:假如 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 集上的真实准确率有没有稳步提升。