大模型评测结果波动:MMLU/HumanEval/GSM8K可复现性实战
发布日期: 2026/07/25 阅读总量: 0

一次评测翻车: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=068.3%1.3%2.5%
MMLU (5-shot)固定随机种子但去除system prompt70.5%0.8%1.7%
HumanEval pass@1temperature=0.2, num_samples=20,不同seed72.0%2.1%4.5%
HumanEval pass@1temperature=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没话说。