揭示了 lm-eval-harness 中 acc 与 acc_norm 对不同长度候选项的结构性偏好差异,及其对模型卡报告的影响。
你的微调在 HellaSwag 上 acc_norm 涨了 3 分,acc 却跌了 2 分。同一个 checkpoint,同一个 harness,同一个 seed。模型的常识推理不可能同时往两个方向动——你改变的是答案字符串的平均每 token 熵,而这两个指标中有一个部分是在测量答案字符串的长度。
这就是 acc 与 acc_norm 的问题,它决定了你的 model card 上填哪个数字。
在 lm-eval-harness 的多选任务中,acc 选取累计对数似然最高的候选;acc_norm 在选取前先把该和除以续写字符串的字节长度。
累计对数似然大致等于 -(长度) × (平均每 token 熵),因此它在结构上就偏好更短的候选。在候选长度有差异的基准(HellaSwag、ARC、PIQA)上,原始 acc 同时也在测量长度而非仅仅是知识。
归一化是按字节而非 token 计数,所以不同 tokenizer 词形密度的模型之间分数仍然可比。每 token 归一化奖励那些切分成许多可预测子词的答案。
MMLU 没有差异,因为它的续写都是单个字母——每个分母都是 1,所以 acc 和 acc_norm 产生的排名完全相同。
SFT 和 RLHF 不均匀地降低了每 token 熵,这缩小了长度惩罚,可能导致 acc 和 acc_norm 朝相反方向移动而能力毫无变化。报告两个指标,在训练开始前为每个任务家族选定一个指标。
两者都是在候选续写上做 argmax。给定一个上下文 x 和候选 y_1 ... y_k,harness 对每个候选跑一次前向传播,计算续写的累计 token 对数概率:
score_raw(y) = sum_t log p(y_t | x, y_<t)
acc 是 argmax_y score_raw(y)。acc_norm 是 argmax_y score_raw(y) / len(y.encode("utf-8"))。
这就是全部区别:一个分母。它之所以至关重要,是因为分子是一个广延量——它随长度增长——而你想测量的东西并不是。
因为每多一个 token 就多一个负项。对于长度为 L 的 token 序列,在模型自身分布下的期望累计对数概率是 -L × H,其中 H 是模型沿该路径赋予的平均每 token 熵。一个 8B 模型下校准良好的英文文本,每个 token 处于低个位数的 nats 附近。给一个候选加上十个 token 要付出大约十到二十 nats 的代价——远超过区分一个可信结尾和一个不可信结尾所需的几 nats 信号。
所以在正确答案不系统性更短的任何任务上,acc 都是在白白牺牲准确率。它的形态是这样的,用的是说明性的数字(不是实测的——重点是排序,不是量级):
原始求和选 A。按字节选 B。按 token 选 C。三个看起来都合理的评分规则,三个不同的答案,一次前向传播各出一个。
因为 token 计数是 tokenizer 的属性而非文本的属性。上面那个候选 C 的失败模式:一个生僻词炸成许多子词碎片,而每个碎片在前一个碎片给定的情况下高度可预测。defenestr -> ates 几乎不花什么代价。所以求和被分散到一个大分母的廉价 token 上,而按 token 归一化把一个错误的稀有答案送上了不该有的胜利。
字节长度与 tokenizer 无关。这是它真正的价值:它让你可以把一个用 Llama 分词的模型和一个用 Gemma 分词的模型放在同一张表里,而不会让词形密度差异渗入指标。它不是一个原则性的信息论校正——bits-per-byte 是一个合适的跨-tokenizer 度量,但 acc_norm 是把它作为候选上的启发式平局决胜器来用,而不是作为似然。
实际后果:分母是在数据集里出现的原始字符串上计算的。尾部空格、标注者漏掉的句号、你 prompt 模板吃或不吃的开头空格——每一个都会让分母偏移一两个字节。对于一个五个字符的候选,一个字节就是归一化分数 20% 的变化。把你的选项字符串清理干净。
一次前向传播出三种评分:
import torch, torch.nn.functional as F
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "meta-llama/Llama-3.1-8B"
tok = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
model_id, torch_dtype=torch.bfloat16, device_map="cuda"
).eval()
@torch.no_grad()
def score(context: str, continuation: str) -> dict:
ctx_ids = tok(context, add_special_tokens=True).input_ids
full_ids = tok(context + continuation, add_special_tokens=True).input_ids
n_cont = len(full_ids) - len(ctx_ids)
inp = torch.tensor([full_ids], device=model.device)
logits = model(inp).logits[0].float()
logprobs = F.log_softmax(logits[:-1], dim=-1) # position i predicts token i+1
tok_ll = logprobs.gather(-1, inp[0, 1:].unsqueeze(-1)).squeeze(-1)
cont_ll = tok_ll[len(ctx_ids) - 1:] # logprobs of continuation tokens
total = cont_ll.sum().item()
return {
"acc": total, # raw sum
"per_token": total / n_cont,
"acc_norm": total / len(continuation.encode("utf-8")), # byte-normalized
}
def predict(context, choices, metric="acc_norm"):
return max(range(len(choices)),
key=lambda i: score(context, choices[i])[metric])
自己写 harness 时会咬人的一个注意事项:以 len(ctx_ids) 切分的前提是 tokenizing 上下文 + 续写能精确复现上下文 token。当续写以空格开头且上下文以非空格结尾时通常如此,而当边界落在一次合并内部时它会静默出错。Harness 通过编码配对后对齐来处理这个问题;如果你是自己实现的,要 assert full_ids[:len(ctx_ids)] == ctx_ids 并在不符时大声失败。
因为在 harness 中计分时 MMLU 使用单字母续写——doc_to_choice: ["A", "B", "C", "D"]。每个候选都是一个字符,所以每个分母都是 1,归一化是恒等变换,argmax 也相同。两者报告的数字是同一个数字。
这就是为什么「MMLU 对长度稳健」和「HellaSwag 需要 acc_norm」可以同时为真且互不矛盾。差异在于答案格式,而非基准的难度。任何用字母键答题的任务都天然免疫;任何评分完整自然语言续写的任务都会受到影响。
这也意味着你不能拿字母键 MMLU 的运行结果和完整文本续写版 MMLU 变体做比较。它们是共享一个名字的不同测量。
因为 SFT 和偏好优化改变了每 token 熵,而原始 acc 中的长度惩罚随熵缩放。指令微调模型比基座模型对流畅的、分布内续写更锐利。越锐利意味着每个 token 花费的 nats 越少,这意味着长候选的每 token 惩罚缩小,因此原始 acc 在答案较长的任务上机械地上升——而模型的知识毫无变化。
当你的 SFT 数据把模型推向 eval 候选不匹配的格式时,同样的效应反方向运行。此时候选是分布外的,熵上升,长答案被猛击,acc 崩溃,而 acc_norm 几乎不动,因为分母吸收了一部分。
经验法则:如果 acc 和 acc_norm 朝同一方向移动,你大概改变了能力。如果它们分叉,你大概改变了输出分布的锐度或格式。用同一批题目的字母键版本做一次小规模运行来检验——如果那个数字走平,那就从来不是能力问题。
当候选答案的原始频率差异大于长度差异时。字节归一化对那些在预训练数据中 simplesmente 更常见的字符串候选毫无作用。GPT-3 时代的修复方案是无条件(逐点互信息风格)归一化:用空上下文计分续写然后相减。
score_pmi(y) = log p(y | x) - log p(y | "Answer:")
这需要每个候选跑第二次前向传播——通常可以缓存,因为空上下文是共享的——而且它针对的是一种真正不同的偏置。把它用在干扰项是高频短语的任务(许多 ARC-Easy 题目)上,而且长度差异较小的时候。不要盲目叠加在字节归一化之上;你会双重校正,排名变得难以分析。
对于自定义多选任务,output_type: multiple_choice 会计算两个指标;你选择上报哪个:
task: my_mc_task
dataset_path: json
dataset_kwargs:
data_files:
test: data/mc.jsonl
test_split: test
output_type: multiple_choice
doc_to_text: "Question: {{question}}\nAnswer:"
doc_to_choice: "{{choices}}" # e.g. [" Paris", " Lyon", " Marseille"]
doc_to_target: "{{label}}" # integer index
metric_list:
- metric: acc
aggregation: mean
higher_is_better: true
- metric: acc_norm
aggregation: mean
higher_is_better: true
有两个细节在审查时值得强制执行。第一,doc_to_text 以 Answer: 结尾,每个选项以空格开头——把空格留在选项一侧,保持一致,否则你的分母和 tokenization 都会因每个样本而晃动。第二,如果你的选项是字母键,从列表里完全去掉 acc_norm;报告一个被证明与另一个完全相同的指标只会让人挑更高的那个引用。
第三个不相关的「归一化」含义出现在 leaderboard 层面:把一个任务的分数相对于其随机基线重新缩放,使得一个 4 选 1 的任务不会从 25% 开始。那发生在聚合之后,与 acc_norm 无关。不要在结果表中把它们混为一谈。
acc 和 acc_norm 不一致,是因为累计对数似然是一个随续写长度增长的广延量,所以原始 acc 系统性地偏好短候选,而 acc_norm 除以续写的字节长度来消除大部分长度偏差。在任何评分变长自然语言续写的任务(HellaSwag、ARC、PIQA)上使用 acc_norm,它是对模型所知更诚实的度量;在 MMLU 这样的字母键格式上用普通 acc,反正两者数值上相同;当偏置来源是候选频率而非候选长度时,用 PMI 风格的无条件归一化。在训练开始前为每个任务家族选定指标,上报两个数字,并把两者之间的分歧视为模型输出分布形状发生变化的信号——而不是它变聪明了。