【Agentic RL #1】在 Mac 上用 GRPO 教 Qwen 数偶数:从 0/50、全输出 1,到逐数判断后的 66%
这次实验的任务很小:给 Qwen3-0.6B 十个整数,让它回答里面有几个偶数。训练使用 TRL 的 GRPOTrainer,在 Mac 的 MPS 上运行,本地模型提前放在 models/Qwen3-0.6B。
最初的提示要求模型只输出一个整数。实际训练接连出现了几种情况:dev 一个都答不对;修改精度和奖励之后全部输出 1;换成恢复配置,训练 100 步还是每次 1/50。
后来把任务改成逐个复制数字并判断奇偶,最后再输出总数。奖励也跟着拆开,分别检查逐项判断、最终答案和自身计数是否一致。本轮 100 步训练中,同一任务的 dev 最终答案准确率从 15/50(30%)提高到 33/50(66%),最高到过 36/50(72%)。
本文对应 countdown-recovery.ipynb 的逐数版本。下面的核心代码从当前 notebook 分段摘取,按顺序运行即可组成训练流程;旧整数版保存在 history/countdown-recovery-integer-only-20260930.ipynb,排查记录在 process.md。
这轮跑出了什么结果
本轮运行目录是 out-qwen3-stepwise/20260930-150958-619444。notebook 保存的训练输出和 checkpoint-100 的 trainer state 都显示完成了 100 optimizer steps,epoch 为 0.2。记录的 runtime 为 2912.5354 秒,约 48 分 33 秒,包含训练期间的 callback 评估时间。
完整 dev 有 50 道题,每题 10 个数,采用 greedy 解码。这里同时报告五个指标。
| 指标 | 检查什么 |
|---|---|
| 最终答案正确率 | 最后一行的答案是否等于真实偶数个数 |
| 逐项正确率 | 数字复制及奇偶标签都正确的行数,占输入数字总数的比例 |
| 完整解答正确率 | 每个数按顺序列齐、奇偶全部正确,且最终答案正确 |
| 格式合法率 | 所有输入数字按顺序列齐,答案唯一且位于末尾,答案范围为 0..10 |
| 自身计数一致率 | 结构合法,最终答案等于模型自己标注的 even 行数 |
| 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%。后面继续训练没有保持单调上升,选择 checkpoint 时应比较保存点的完整 dev 结果。
第 100 步逐项正确率已经达到 99.4%,完整解答正确率为 64%。模型基本能判断每个数的奇偶,但仍会把已经标出的 even 行数算错。最后一行偶然答对、某行判断错误的回答,也会造成最终答案正确率高于完整解答正确率。
实验范围。 这是一次固定配置、固定种子的开发集结果。50 条 dev 在训练中反复检查,1000 条 test 已准备好,当前 notebook 尚无训练后的 test 结果。此次改动同时改变了输出提示和奖励;训练增益使用本轮 step 0 的 30% 作比较,旧整数任务的 0/50 或 1/50 属于另一种任务设置。前一次独立短探测的逐数基线为 28%,也单独保留在日志中。
当前方法:先判断每个数,再汇总答案
输入和规定输出的关系如下。
输入:[3, 8, 8]
输出:
3: odd
8: even
8: even
Answer: 2两个 8 分别占一行。每一行都要同时满足“数字与对应输入相同”和“奇偶标签正确”。最后检查真实答案,再检查答案与模型自己的 even 行数是否一致。
原先只看到一个错误整数时,很难给出细分信号。逐数输出让部分正确的回答也能获得逐项分数,最后的计数继续由答案奖励约束。这里仍由 GRPO 采样模型回答并优化策略;程序计算标签和奖励,模型生成的逐项文字参与训练。
环境与独立运行目录
选择 Python (GRPO) 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 和后面的 local_files_only=True 让加载使用本地文件。每次从头初始化会生成新的 RUN_ID,训练输出分开保存,方便对应配置与日志。输出上限现在是 192 tokens。
主体保留 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。本模型共有 596,049,920 个参数,其中这些 FP32 norm 参数为 65,536 个,约占 0.011%。
restore_norm_output_dtype 有明确作用:这套 Qwen3 实现中,FP32 norm 权重参与乘法会使 norm 输出提升到 FP32。hook 把输出转回输入的 dtype,让后续主要计算继续使用 BF16。遗漏这一步会改变后续激活的精度和运行开销。
参数存储精度由 from_pretrained(..., dtype=...) 和 .float() 等操作决定,不能只看训练配置中的 bf16=True。修改精度后要重新创建模型和 trainer,保证优化器也对应当前参数。
数据生成:先选答案,再生成对应奇偶的数字
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"]))先从 0..10 均匀抽取偶数个数,再生成对应数量的偶数和奇数并打乱。这样设计的是答案类别近似均衡的数据。若只独立随机抽十个整数,偶数个数通常集中在 5 附近,模型更容易靠中间答案获得不错的平均分。
train、dev、test 分别有 2000、50、1000 条,使用不同随机种子。独立种子划分没有显式做跨集合去重。逐数版新增 numbers 字段,奖励函数可以直接拿到原数字列表;answer 仅供程序计算奖励,用户提示里没有真实答案。
task_version 用来拒绝加载旧格式的数据。全量检查过 3050 条标签,重新计算的偶数个数全部一致;新旧数据里的数字列表和答案也一致。此次使用新数据目录保存新提示和元数据。
解析输出:按位置匹配,保留重复数字
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 检查整行。遇到无法解析的行会保留 None,使后面的数字仍对应原来的位置;直接删除错误行会把位置对齐关系改掉。
答案需要恰好出现一次,且是最后一个非空行。complete 要求数字数量相同,每个位置复制的数字也相同。大小写和行尾空白被容忍,但漏掉重复数字、交换顺序或增加一行都无法通过完整性检查。
逐项分数与完整性分开计算。前十行全部正确却又多写一行,仍可得到逐项分数,完整性和一致性奖励会失败。这个选择保留部分学习信号;“完整解答正确”指标仍严格检查整份回答。
三个 reward 函数怎样合成一个分数
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)}"])三个函数检查同一批 completion 的不同部分。numbers 和 answer 来自数据列,函数返回与 completion 一一对应的分数列表,strict=True 也会检查列表长度。TRL 支持多个 reward 函数,并按 reward_weights 加权求和。TRL GRPO 文档
本次权重是 [1.0, 1.0, 0.5],每份回答的总奖励为:
R = 逐项正确率 + 最终答案正确 × 1 + 自身计数一致 × 0.5| 回答情况,以 [2, 3, 2, 5] 为例 | 逐项分数 | 答案奖励 | 一致性奖励 | 加权总分 |
|---|---|---|---|---|
| 全部判断正确,Answer: 2 | 1 | 1 | 1 | 2.5 |
| 全部判断正确,Answer: 3 | 1 | 0 | 0 | 1 |
| 把 3 写成 even,Answer: 2 | 0.75 | 1 | 0 | 1.75 |
| 把 3 写成 even,Answer: 3 | 0.75 | 0 | 1 | 1.25 |
| 只写 Answer: 2 | 0 | 1 | 0 | 1 |
一致性奖励核对的是模型自己写出的 even 行数,错误标签也可能得到一致性分数,所以它只占 0.5。逐项奖励检查真实奇偶,答案奖励检查真实总数,三项共同限制这种情况。
完整正确回答能拿到 2.5。只写一个正确答案也能拿到 1 分,这是当前奖励保留的部分信号。格式检查包含在一致性奖励的条件中,未设置独立的大额惩罚。
训练前先检查奖励和输出长度
下面是 notebook 中完整的奖励自检 cell。它实际检查了十种情况。
# 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)十个检查全部通过。训练集的标准输出最长为 55 tokens,192 的上限给结束符和生成变化留出了空间。旧版本的 8 tokens 适合一个整数;改成十行判断后,生成长度也要同步更新。
用同一套解析器评估 dev
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 关闭 Qwen 的 thinking 模式,逐数判断直接写在正常回答中。这个模板开关来自 Qwen3-0.6B 官方模型卡。dev 用 greedy 解码固定比较口径,训练保留采样来产生同题的多个回答。
padding_side="left" 适用于这里的批量生成。解码时按 padding 后实际输入宽度切掉 prompt,只解析新增文本。评估前切换到 eval,结束后恢复原来的训练状态,避免 callback 改变后续训练模式。
训练前的预览只展示五题。
bench_all(model, dataset_dev.select(range(5)), verbose=True)完整的五十题 dev 由 callback 在 step 0 和每 10 步运行。eval_dataset=dataset_dev 仅把数据交给 trainer,本文实际打印的 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)GRPO 配置与启动训练
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()这轮学习率为 3e-6,使用 BF16 主体加 FP32 norm。beta=0.02 加入参考策略的 KL 约束,entropy_coef=0.01 加入熵项。它们与有界奖励一起用于恢复探索;是否适合别的任务,要看对应日志和评估。
最容易误解的是 batch 数字。在本轮单设备、num_iterations=1 和默认生成批次设置下:
每次 optimizer update 的 completion 数 = 8 × 4 = 32
每个题目生成 8 个 completion
每次 update 的不同题目数 = 32 / 8 = 4
100 steps 覆盖约 400 个题目 = 2000 条 train 的 0.2 epoch同题的八份回答用于比较奖励。最初 batch=8, generations=8, accumulation=1,每步只有一个不同题目,500 步约为 0.25 epoch。num_iterations 表示一批生成结果的优化复用次数。生成批次的默认关系可查 TRL 的 GRPOConfig。
gradient_checkpointing 用来减少激活内存,torch_empty_cache_steps=1 控制缓存清理频率,二者都有运行开销。这里保留的是已经跑完这轮的配置。save_steps=20 留下了可追溯的 checkpoint,便于重新评估。
踩坑复盘:每次失败具体发生了什么
dev 0/50:先核对数据,再检查实际更新
症状。 整数输出任务的原模型 dev 为 0/50,训练期间也出现 0/50。很多回答像是在挑一个输入里的偶数,常见数字为 2、4、8。最初以为已经“训了两轮”,保存输出中能确认的那次运行实际在 106/500 中断。
原因。 全量 3050 条标签检查通过。参数更新探测却发现,BF16 参数配合 1e-6 学习率和当前 AdamW,没有 FP32 主权重来保存微小增量,检查位置中的多数更新被舍入。同时,batch=8、generations=8 的那版每步只覆盖一个不同题目,运行次数与 epoch 数被混淆。
| 检查位置 | 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% |
这些比例来自三个真实 GRPO optimizer steps,只对应表中参数或参数片段。检查到的梯度有限,第一步非零占比为 100%,参数存储仍会丢失部分更新。
修正。 先计算不同题目的覆盖量,再比较更新前后的参数。全 FP32 三步探测验证了更新路径,随后为了保留 BF16 主体,改成 FP32 norm,并增加梯度累积。这些修改解决了部分数值和训练量问题;整数任务后来仍然没有学会计数,任务奖励也需要继续调整。
保留 BF16 后只提高学习率,norm 仍然不更新
症状。 把学习率从 1e-6 提到 1e-5 或 3e-5 后,检查的矩阵更新比例增加,但 input_layernorm 的三步改变占比仍为 0%。
原因。 BF16 相邻可表示数的间距与数值大小有关。靠近 1 的 norm 权重,微小更新尤其容易低于可表示间距。1e-5 下两个 q_proj 片段约有 12.215%、9.949% 改变,3e-5 下增加到 24.411%、20.447%,norm 仍保持不变。
修正。 把 RMSNorm 参数转成 FP32,再用 hook 恢复输出 dtype。1e-5 的三步探测中,检查的 norm 参数改变占比达到 100%。最终完整逐数训练使用更小的 3e-6。提高学习率的短探测只说明参数能更新,长训练中的策略稳定性还要单独观察。
梯度累积增加的是每次更新参考的样本量,当前实现不会因此得到 FP32 主权重。FP32 norm 也只覆盖 65,536 个参数,BF16 矩阵的更新舍入仍然存在。
格式惩罚从 -11 改到 -114514,模型全部输出 1
症状。 有一轮使用实际 FP32 参数、1e-5 学习率和巨大非法格式惩罚,100 步后 dev 全部输出 1。dev 中答案为 1 的题目有六道,最终得到 6/50(12%)。
原因。 原来非法输出得 -11,合法整数使用无界的 -abs(pred-gold)。例如真实答案为 6,回复 24 得 -18,文字却得 -11,这让部分非法格式比越界数字更划算。后来把非法惩罚改成 -114514,尺度又走向另一个极端。
GRPO 按同题组内奖励计算相对优势。在机制演示中,奖励 [-114514, -6, -5, -4, -3, -2, -1, 0] 标准化后,七个合法回答的优势都约为 +0.3535,正确答案与错误合法答案的差别被压得很小。这个演示解释了巨大惩罚的风险,历史日志没有保存每组原始 rollout。
那次运行在 step 10 的 entropy 为 0.12512,组内零方差比例为 0.875;step 20 分别为 0.02140 和 0.975;step 30 达到 entropy=0.000387、零方差比例=1,之后直到 step 100,loss 和 grad_norm 都为 0。实际配置中的 KL 与熵系数也为 0,同题回答没有奖励差异后,任务优势无法继续提供优化方向。TRL 的优势计算与日志定义
修正。 从原始模型重启,把奖励限制在接近的尺度,并以 3e-6 学习率、KL 0.02 和熵系数 0.01 做恢复探测。后来的逐数方案使用 0..2.5 的总奖励。负奖励本身可以用于 GRPO,比较同题回答的差异才是这里要检查的机制。
当时同时改变了精度、学习率和奖励,日志能确认策略塌缩,无法把它全部归因于单独一个参数。小步运行测试通过后,也需要拉长观察输出分布和组内奖励方差。
100 步始终 1/50:权重在变,错误答案也在变
症状。 恢复版每十步评估一次,step 10 到 100 总是 1/50(2%)。这很容易让人怀疑 callback 一直用了同一个模型。
原因。 实际重新加载 checkpoint-20 和 checkpoint-100 做 greedy 复测,两份权重都得到 1/50,唯一答对的都是第 24 题,真实答案为 4。两次评估有七个回复改变,它们都没有变正确。预测 4 的数量从 34/50 增加到了 40/50。
第 100 步 entropy=0.5989、grad_norm=92.44、组内零方差比例为 0。它与“全输出 1、梯度归零”的失败有不同日志,模型仍然在采样和更新。
那版使用 reward_funcs=[reward, exact_match_reward, format_reward],对应权重却为 [1, 0, 0]。exact-match 和格式只进入日志,实际优化目标仍是距离奖励。固定输出 5 在 train 上准确率为 8.2%,平均距离为 2.773,距离奖励约为 -0.25209;这种忽略输入的中间答案也有不错的平均距离分数。另做 [2]*k+[1]*(10-k)、k=0..10 的简单控制题,保存模型全错,输出没有表现出随偶数数量变化的规律。
修正。 把真实答案正确性加入非零权重,并按输入位置奖励每个数的奇偶判断。当前的三项权重 [1, 1, 0.5] 都参与训练;dev 也打印答案分布和逐项指标。后面的 30%→66% 结果来自这套逐数版本。
输出格式已经接近 100%,任务仍然没有学会
症状。 旧恢复版 100 个训练日志点的平均格式率为 99.65625%,平均采样 exact-match 只有 6.84375%。整数都能解析,dev 仍是 2%。
原因。 “会写合法整数”与“会根据输入数偶数”检查的是不同能力。格式通过之后,纯距离奖励仍然给中间答案分数。训练采样分数和固定 dev greedy 结果也来自不同数据与解码方式。
修正。 明确记录格式、答案和中间步骤的指标。当前逐数版还加了完整解答正确率与自身计数一致率。格式率升高说明回答结构更稳定,答案指标才能判断计数是否改善。
改成逐数输出后,长度和解析也要一起改
症状。 沿用原来的 8 tokens 上限,十行输出会被截断。若把复制的数字放进集合,重复数字会丢失;若只从文本里找最后一个整数,多余答案也可能被接受。
原因。 旧的长度和解析针对一个整数,新的任务需要检查十个有序位置及最后一行答案。这是改版时预先检查的兼容风险。
修正。 上限改为 192,标准答案最长 55 tokens。位置匹配保留重复数字,答案必须唯一且位于末尾,用十个奖励测试检查漏项和乱序等情况。逐数版的实际单步与第 100 步训练日志都显示截断比例为 0。
notebook 的运行顺序、精度和 checkpoint 会混在一起
症状。 旧 notebook 的模型加载与训练 cell 执行序号不连续,最初 dev baseline 是 0/50,后来 trainer 的 step 0 变成 2/50;曾出现 bf16=True,但实际加载的参数是 FP32。另有历史 checkpoint 加载输出与中断运行无法对应。
原因。 notebook 保留内存状态,单独重复训练 cell 可能沿用已经更新过的模型;旧输出也会在源码修改后继续显示。训练配置的 BF16 开关与参数实际 dtype 是两项检查。
修正。 每次更改方法后重启 kernel,从原始模型开始,打印实际 dtype 和 step 0 baseline。每轮独立输出目录,保留 trainer state 和正式历史 notebook。checkpoint 复测还要保留 FP32 norm 的保存值:先用 BF16 加载再把 norm 转成 FP32,已经发生的量化无法恢复,应重新读取保存的 FP32 norm 张量。
历史序号只能提示状态可能被复用,完整 kernel 操作没有被保存;排查记录因此保留了这项证据的范围。
受限 shell 的 MPS 探测失败,容易误判成硬件不支持
症状。 一次受限 shell 返回 MPS unavailable,构建 BF16 配置也失败。另一次系统 Python 缺少所需包,无法直接加载权重检查。
原因。 实际宿主机和 Python (GRPO) 环境的 MPS 可以使用。受限执行环境及系统 Python 与训练 kernel 有差异。
修正。 用实际项目环境检查 MPS 和依赖,区分环境访问失败与硬件能力。后续三步更新探测、checkpoint 复测和本轮完整训练都在实际 MPS 路径上完成。
以后遇到类似现象,先看哪些证据
这次排查留下了几项可直接复用的检查。
- 重新计算数据标签,先确认评估目标正确。
- 记录 fresh model 的完整 dev 基线,以及模型实际 dtype。
- 用三步探测比较参数前后差异,同时观察梯度是否有限。
- 把 batch、生成数和梯度累积换算成不同题目数,再报告 epoch。
- 用手写回答测试 reward,覆盖正确、部分正确和非法格式。
- 同时查看答案分布、组内零方差比例、梯度及固定 dev 结果。
- 保存每次配置和 checkpoint 来源,修改任务时使用新的数据与输出目录。
平均 token entropy 还要结合输出结构解释。逐数输出中,复制数字和固定标点占了不少 token,本轮第 100 步 entropy 约为 0.02389,grad_norm 为 7.062,组内零方差比例为 0,dev 仍有 66% 的最终答案准确率。单个较低的 entropy 数字不足以判断策略塌缩;旧失败里同时出现全输出 1、组内零方差为 1、梯度为 0,这些证据需要一起看。
当前最明确的剩余问题是计数汇总。逐项正确率 99.4%,最终答案正确率 66%,两者之间还有差距。下一步可先按既定 dev 规则选择已保存的 checkpoint,再做一次独立 test 评估;需要进一步改方法时,单独比较提示、答案奖励与一致性奖励的作用,并记录重新运行的基线。
Reward, Advantage, Loss?
在这个数奇偶的 GRPO 实验里,reward 给整份回答打分,advantage 表示它比同题其他回答好多少,loss 把这个比较结果转成模型参数的更新方向。
它们依次连接起来:
同一道题生成 8 份回答
↓
程序给每份回答计算 reward
↓
在这 8 份回答内部计算 advantage
↓
用 advantage 和模型的 token 概率计算 loss
↓
backward + optimizer.step 更新模型下面按你当前 notebook 的配置解释。我也核对了本地 TRL 实现:当前使用的是 scale_rewards="group"、loss_type="dapo",加上 beta=0.02 和 entropy_coef=0.01。
1. Reward:这份回答得几分?
你有三个 reward 函数,权重为:
reward_funcs = [
item_reward,
answer_reward,
consistency_reward,
]
reward_weights = [1.0, 1.0, 0.5]对第 (i) 份回答,最终合成的 reward 是:
[
r_i
=
\underbrace{\frac{\text{数字与奇偶均正确的行数}}{\text{输入数字个数}}}_{\text{item reward}}
+
\underbrace{\mathbf{1}[\text{最终答案正确}]}_{\text{answer reward}}
+
0.5\underbrace{\mathbf{1}[\text{结构合法且答案与自身 even 行数一致}]}_{\text{consistency reward}}
]
用四个数字举例,实际十个数字的计算方式相同:
输入:[2, 3, 2, 5]
正确答案:2完整正确的回答:
2: even
3: odd
2: even
5: odd
Answer: 2它的 reward 是:
item_reward = 4/4 = 1
answer_reward = 1
consistency_reward = 1
总 reward = 1 + 1 + 0.5 × 1 = 2.5不同错误会得到不同分数:
| 回答情况 | 逐项分数 | 答案分数 | 一致性分数 | 总 reward |
|---|---|---|---|---|
| 全部正确,最后答 2 | 1 | 1 | 1 | 2.5 |
| 每行正确,最后答 3 | 1 | 0 | 0 | 1.0 |
| 把 3 标成 even,最后答 2 | 0.75 | 1 | 0 | 1.75 |
| 把 3 标成 even,最后答 3 | 0.75 | 0 | 1 | 1.25 |
只写 Answer: 2 | 0 | 1 | 0 | 1.0 |
第三、四行可以看出:自身一致性和真实正确性是两种检查。 模型可以把奇偶判断写错,但最后忠实地数了自己写出的 even 行,所以仍得到一致性分数。另两项奖励负责约束真实正确性。
这些分数由 Python 解析生成文本后算出来。它们是训练信号,本身没有能回传到模型的梯度。
2. Advantage:这份回答在同题组里好多少?
你设置了:
num_generations = 8同一道题采样八份回答,组成一个组。TRL 先合成各自的 reward,再计算:
[
A_i=\frac{r_i-\mu}{\sigma+10^{-4}}
]
其中:
- (\mu):这道题八份回答的平均 reward。
- (\sigma):这道题八份回答的 reward 标准差。
- (A_i):第 (i) 份回答的 advantage。
这个计算在同一道题内部进行。TRL 的组内优势说明
假设学生生成了四份完整正确的回答,以及四份“每行正确、最后计数错误”的回答:
rewards = [2.5, 2.5, 2.5, 2.5, 1.0, 1.0, 1.0, 1.0]这组的平均分是:
[
\mu=1.75
]
按本地实现的样本标准差计算,(\sigma) 约为 (0.802),因此:
| 回答 | Reward | Advantage,约值 | 更新倾向 |
|---|---|---|---|
| 完整正确的四份 | 2.5 | +0.935 | 提高这些回答中已生成 token 的概率 |
| 最后计数错误的四份 | 1.0 | −0.935 | 降低这些回答中已生成 token 的概率 |
reward 为正,advantage 仍然可以为负。 这里 1 分回答得到了部分奖励,但低于组内平均水平,所以训练倾向于减少这种回答。
还有一种关键情况:
rewards = [1.0] * 8八份回答都同分,所有回答都有:
[
r_i-\mu=0 \quad\Rightarrow\quad A_i=0
]
这个组无法通过任务奖励区分哪份回答更值得增加概率。你之前“全输出 1”的失败,就出现了这种组内奖励没有差异的现象。
3. Loss:怎样把比较结果变成梯度?
模型能参与反向传播的是它计算出的 token 概率。
对回答 (i) 中已经生成的第 (t) 个 token,定义:
[
\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}}):采样时的策略;在计算中作为固定量。
- (\rho_{i,t}):这个 token 的概率相对于采样时改变了多少。
先忽略 clipping 和正则,单个 token 的策略 loss 可以理解为:
[
\ell_{i,t}=-\rho_{i,t}A_i
]
结合上面的正负 advantage:
| Advantage | 降低 loss 的方向 |
|---|---|
| (A_i>0) | 增大该 token 的概率,使 (-\rho A_i) 更小 |
| (A_i<0) | 减小该 token 的概率,使 loss 更小 |
实际实现还使用 clipping 限制更新:
[
\ell^{\text{policy}}_{i,t}
=
-\min\left(
\rho_{i,t}A_i,\;
\operatorname{clip}(\rho_{i,t},0.8,1.2)A_i
\right)
]
你的当前完整目标,还加入了两个项:
[
L
=
L_{\text{policy}}
+
0.02\,L_{\text{KL}}
-
0.01\,H
]
- 策略项:根据 advantage,提高相对好的回答概率,降低相对差的回答概率。
- KL 项:约束当前模型偏离固定参考模型的程度。
- 熵项 (H):鼓励保留一定的输出多样性,负号使增加熵能够降低 loss。
当前默认的 DAPO reduction 会对有效生成 token 汇总,并处理梯度累积的归一化;prompt 和 padding 不作为回答 token 参与这部分损失。
这里还有一个容易漏掉的细节:你的逐项奖励最终仍被合成了一份回答的标量分数。
例如某份回答:
2: even
3: even ← 这一行错了
2: even
5: odd
Answer: 2程序知道有一行错,因此逐项分数是 0.75。但进入当前 GRPO 后,这份回答得到一个总 reward、一个 advantage,整份回答的有效 token 共用这个 advantage。代码目前没有给错误的 even 单独分配一个负 advantage。逐项检查让评分更细,具体哪些 token 应负责仍由策略梯度学习。
4. 为什么 loss 接近 0,仍可能在学习?
你配置了 num_iterations=1。在当前普通生成路径下,本地 TRL 可以这样构造概率比:
ratio = torch.exp(logp - logp.detach())前向计算时:
logp - logp.detach() = 0
ratio = 1但 detach() 只阻断后一项的梯度,前面的 logp 仍然可微。于是:
loss = -ratio * advantage即使组内正负 advantage 的平均值相互抵消,使 loss 数值接近 0,梯度也可能非零。不同回答会分别得到增加或减少概率的方向。
需要区分两种情况:
| 现象 | 含义 |
|---|---|
| 正负 advantage 相互抵消,loss 接近 0,但梯度非零 | 仍可能正常学习 |
| 每份回答的 advantage 都为 0 | 任务奖励对应的策略梯度消失 |
后一种情况下,你当前的 KL 和熵项仍可能提供梯度。之前全输出 1 那轮同时没有启用这两个项,才出现组内零方差、loss 为 0、梯度也为 0 的组合。
所以,这个实验中要看的是:reward 是否能区分回答、advantage 是否有正负差异、loss 是否产生有效梯度,以及固定 dev 是否改善。 loss 的绝对大小不能直接当成答题准确率。