一次评测翻车:MMLU分数凭空涨了2%
两周前,我向CTO汇报模型改进效果。用同样的Llama3-70B,同样的评测脚本,MMLU得分从68.3%变成了70.5%。CTO当场质疑:“你改了什么?”我翻遍代码,最后发现是一行缺失的system prompt——之前版本加了“You are a helpful assistant”,新版本没加。这2%的差异让整个对比失效。更深处的问题是:哪怕代码完全一致,每次跑MMLU结果也可能不一样。HumanEval的pass@1波动更大,GSM8K的chain-of-thought温度设置直接改变答案分布。这文章记录我踩过的坑和解决方案。
问题本质:评测结果为什么不可复现?
- 温度随机性:大多数推理时设temperature=0?但HuggingFace默认是0.0吗?不一定。很多框架默认temperature=1.0,导致每次生成不同。
- few-shot示例顺序:MMLU是5-shot,示例从训练集随机选。不同随机种子导致不同示例组合,得分差1-2%很常见。
- HumanEval的pass@k:计算时需多次采样,不同随机种子下的样本不同。
- GSM8K的chain-of-thought prompt:如果使用“Let's think step by step”,模型输出格式变化会影响提取最终答案。
- batch size和padding行为:不同batch size可能改变softmax数值精度。
- 评测框架版本:lm-evaluation-harness 0.3.x vs 0.4.x 对MMLU的定义有差异。
方案对比:单次评测 vs 多次评测+统计分析
方案A:单次评测(传统做法)
直接跑一次,拿一个分数。快,但风险高。你无法知道这个分数是否偶然。
方案B:多次评测+置信区间(本文推荐)
用固定种子跑5次,每次不同的few-shot采样(如果构造允许),统计均值±标准差。对于HumanEval,用pass@k计算时跑10次取平均值。GSM8K用temperature=0.2跑5次,取多数投票。这样结果可靠,但耗时增加5-10倍。折衷:跑3次取中位数。
完整代码实现:基于lm-evaluation-harness的稳定性评测
依赖:Python 3.10, lm_eval 0.4.3, transformers 4.44.0, torch 2.3.0。硬件:2×A100 80GB。
# 安装
pip install lm_eval==0.4.3 transformers==4.44.0 torch==2.3.0
1. 多种子评测MMLU
import subprocess
import json
import numpy as np
model = "hf-causal-experimental" # 本地模型路径
model_args = "pretrained=/data/models/llama3-70b,dtype=bfloat16,trust_remote_code=True"
tasks = "mmlu"
num_fewshot = 5
results = []
for seed in [0, 42, 123, 666, 999]:
cmd = f"lm_eval --model {model} --model_args {model_args} --tasks {tasks} --num_fewshot {num_fewshot} --device cuda:0 --seed {seed} --output_path ./results_seed{seed}"
subprocess.run(cmd, shell=True, check=True)
with open(f"./results_seed{seed}/results.json", "r") as f:
data = json.load(f)
results.append(data["results"]["mmlu"]["acc,none"])
print("MMLU scores:", results)
print("Mean:", np.mean(results), "Std:", np.std(results))
2. HumanEval pass@k 多次评测
HumanEval需要生成代码并测试。lm-eval内置了humaneval任务。但注意它默认生成一次,我们需要改参数采样多次。
# 使用lm_eval的API进行多次采样
from lm_eval import evaluator
from lm_eval.models.huggingface import HFLM
model = HFLM(pretrained="/data/models/llama3-70b", dtype="bfloat16", device="cuda:0")
tasks = ["humaneval"]
results_list = []
for _ in range(5):
results = evaluator.simple_evaluate(
model=model,
tasks=tasks,
num_fewshot=0, # HumanEval是0-shot
batch_size=8,
max_length=2048,
temperature=0.2, # 非零温度多次采样
num_samples=20, # 每个题生成20个样本,计算pass@k
random_seed=np.random.randint(0, 10000),
)["results"]["humaneval"]
results_list.append(results["pass@1"])
print("HumanEval pass@1 scores:", results_list)
print("Mean:", np.mean(results_list), "Std:", np.std(results_list))
3. GSM8K 多数投票稳定性测试
GSM8K用chain-of-thought,输出需要提取数字。
import json
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
model_path = "/data/models/llama3-70b"
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype=torch.bfloat16, device_map="auto")
with open("gsm8k_test.json", "r") as f:
test_data = json.load(f) # 格式:[{"question":"...","answer":"..."}]
def extract_answer(text):
# 找最后出现的数字
import re
nums = re.findall(r"\d+\.?\d*", text)
return nums[-1] if nums else ""
def evaluate_gsm8k(temperature, num_runs=5):
scores = []
for run in range(num_runs):
correct = 0
for item in test_data[:100]: # 可全量跑
prompt = f"Question: {item['question']}\nLet's think step by step.\n"
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
outputs = model.generate(**inputs, max_new_tokens=256, temperature=temperature, do_sample=True)
output_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
pred = extract_answer(output_text)
gt = extract_answer(item["answer"])
if pred == gt:
correct += 1
scores.append(correct / len(test_data[:100]))
return np.mean(scores), np.std(scores)
for temp in [0.0, 0.2, 0.5, 1.0]:
mean, std = evaluate_gsm8k(temp, num_runs=5)
print(f"Temperature={temp}: Mean={mean:.3f}, Std={std:.3f}")
4. 保存稳定评测配置的YAML
# stable_eval_config.yaml
model: hf-causal-experimental
model_args: pretrained=/data/models/llama3-70b,dtype=bfloat16,trust_remote_code=True
tasks:
- mmlu
- humaneval
- gsm8k
num_fewshot:
mmlu: 5
humaneval: 0
gsm8k: 8
device: cuda:0
seed: 42
batch_size: 8
gen_kwargs:
mmlu: {temperature: 0.0, do_sample: false, max_length: 2048}
humaneval: {temperature: 0.2, num_samples: 20, max_length: 2048}
gsm8k: {temperature: 0.0, do_sample: false, max_length: 1024}
5. 生成报告脚本(JSON格式输出)
// 示例输出:results_summary.json
{
"mmlu": { "mean": 0.683, "std": 0.013, "runs": [0.67,0.69,0.685,0.678,0.692] },
"humaneval": { "pass@1_mean": 0.72, "pass@1_std": 0.021, "runs": [0.71,0.73,0.695,0.725,0.74] },
"gsm8k": { "acc_mean": 0.801, "acc_std": 0.015, "runs": [0.81,0.79,0.805,0.798,0.812] }
}
效果数据:不同温度、种子下的波动
测试模型:Llama3-70B(本地部署,batch_size=8,A100×2)
| 任务 | 条件 | 5次均值 | 标准差 | 最大-最小 |
|---|---|---|---|---|
| MMLU (5-shot) | seed=0,42,123,666,999,temperature=0 | 68.3% | 1.3% | 2.5% |
| MMLU (5-shot) | 固定随机种子但去除system prompt | 70.5% | 0.8% | 1.7% |
| HumanEval pass@1 | temperature=0.2, num_samples=20,不同seed | 72.0% | 2.1% | 4.5% |
| HumanEval pass@1 | temperature=0.0,重复10次 | 70.5% | 0.0% | 0.0% |
| GSM8K (8-shot) | temperature=0.0,不同few-shot顺序 | 80.1% | 1.5% | 3.0% |
| GSM8K (8-shot) | temperature=0.5,5次平均 | 79.2% | 2.8% | 6.2% |
关键结论:temperature=0时,HumanEval输出确定,但MMLU因为few-shot采样不同仍有1.3%波动。GSM8K的prompt格式(加不加“Let's think”)影响更大。最佳实践:固定随机种子、固定few-shot示例(从某个预定义文件读取)、temperature=0。如果必须用采样(HumanEval),则跑10次取中位数。
避坑指南:我在这三个评测上踩过的坑
1. MMLU的few-shot示例泄露
坑:lm-eval默认从训练集随机选5个示例,但训练集是分布外样本?实际MMLU训练集是公开的,但如果你使用了验证集做更广泛的测试,默认行为可能把验证集样本当作few-shot,导致结果虚高。解决:显式指定fewshot_source: "train"。另外,不同语言模型的tokenizer对示例长度影响不同,导致截断不一致。检查num_fewshot参数是否被截断。
2. HumanEval的pass@k计算:你的K选对了吗?
坑:官方论文pass@k是指首先生成k个样本,然后看是否有至少一个通过。但很多实现生成n个样本然后计算C(n,k),这完全错误。lm-eval 0.4.3已经修正为正确方法,但早期版本有bug。验证:如果模型输出为空,pass@1可能被算作0,但pass@k可能非零。一定要检查生成的样本数是否等于设置的num_samples。
3. GSM8K的答案提取:正则表达式要兜底
坑:模型输出可能包含“Final answer: 123”,也可能只有“123”。我的extract_answer函数用正则找最后一个数字,但如果模型输出“The answer is $123$”,就会漏掉。更好的做法是先用关键词“Final answer”切分,再提取数字。另外,GSM8K的答案严格是整数(如“5”),但模型可能输出“5.0”。需要字符串比较前去除".0"。实测因提取错误导致0.5%-1%的准确率下降。
4. 评测框架版本差异
坑:lm-eval 0.3.x里的MMLU用了不同的few-shot构建方式(随机采样),而0.4.x改为固定顺序?不,0.4.3仍是随机。但社区有人提交过用相同种子的争议。始终记录lm_eval版本号。我曾从0.3.5升级到0.4.3,MMLU得分从67%跳到69%,全是因为few-shot样例变了。
5. 温度=0真的确定吗?
坑:理论上temperature=0且do_sample=False时输出确定。但HuggingFace的generate函数在beam search时可能引入随机性(如重复惩罚)。实测发现,即使temperature=0,不同GPU或不同batch size下softmax浮点误差可能导致少数样本不同。保险做法:使用do_sample=False, num_beams=1,并固定种子。
总结:稳定评测的三条铁律
- 固定所有随机种子:model seed、numpy seed、python seed。
- 统一few-shot示例:从固定文件读取,不动态采样。
- 温度=0,do_sample=false(除非必须评测采样能力)。
你踩过的坑我都替你踩了。下次汇报时,带上5次运行的均值和标准差,CTO没话说。