经典指标:exact-match、F1、accuracy、BLEU-4、ROUGE-L 本节摘要:BLEU、ROUGE-L、F1、exact-match、accuracy——五个至今仍占已发表 LLM 评估数字大多数的指标。本节从第一性原理实现每一个,让你知道那个数字意味着什么。标准库加 numpy 就够:exact-match 与 accuracy 各一行,F1 是 token 集合求交,BLEU-4 是计数加钳制加几何平均,ROUGE-L 是最长公共子序列的动态规划。难点只有一个:选定一个分词器并承诺不变。读完本节,你能指着「分词器在哪决定、平滑在哪施加」的那两行代码,跨论文比数字变成读指标设置而非争论库。 对应原课程:Phase 19 · Lesson 71 · (原英文 )。
本节摘要:BLEU、ROUGE-L、F1、exact-match、accuracy——五个至今仍占已发表 LLM 评估数字大多数的指标。本节从第一性原理实现每一个,让你知道那个数字意味着什么。标准库加 numpy 就够:exact-match 与 accuracy 各一行,F1 是 token 集合求交,BLEU-4 是计数加钳制加几何平均,ROUGE-L 是最长公共子序列的动态规划。难点只有一个:选定一个分词器并承诺不变。读完本节,你能指着「分词器在哪决定、平滑在哪施加」的那两行代码,跨论文比数字变成读指标设置而非争论库。
对应原课程:Phase 19 · Lesson 71 ·
classical-metrics(原英文phases/19-capstone-projects/71-classical-metrics/docs/en.md)。本节属第 20 章「毕业项目」的评估赛道。
阅读完本节,你应当能够:
metric_name 字段上分派,让运行器对指标一无所知。你会读到一篇论文报 BLEU 28.3,另一篇报 BLEU 0.283;你会发现两个库的 ROUGE-L 差 10 分,只因为一个转小写、一个不转。停止困惑最快的方法是自己写指标,然后指着分词器在哪决定、平滑在哪施加的那行代码。之后,跨论文比数字就是读指标设置,而非争论库。
标准库加 numpy 足矣:BLEU 是计数加钳制,ROUGE-L 是动态规划,F1 是 token 求交,最难的部分是选分词器并承诺不变。
分词器是 re.findall(r"\w+", text.lower())——小写、字母数字连续串、丢标点。本节每个指标都用这个确定的分词器,运行器无权选择。换分词器就是跑另一个基准。
TOKEN_RE = re.compile(r"\w+", re.UNICODE) def tokenize(text): return TOKEN_RE.findall(text.lower())
⚠️ 这是有意的简化。生产要在意 CJK、缩写、代码标识符。本节的要点是:分词器是契约,不是旋钮。
def exact_match(pred, targets): return float(any(pred.strip() == t.strip() for t in targets))
每任务返回 1.0 或 0.0,数据集聚合是均值。这是算术、MCQ、短分类的主力。
为预测与 target 各建 token 多重集。精度 = 多重集交 / 预测多重集;召回 = 同一交集 / target 多重集;F1 = 调和平均。实现处理空预测、空 target 的边界。多 target 任务取 target 列表上最佳 F1,匹配 SQuAD 风格的广泛行为。
def f1_score(pred, target): p, t = tokenize(pred), tokenize(target) common = Counter(p) & Counter(t) inter = sum(common.values()) if inter == 0: return 0.0 prec, rec = inter / len(p), inter / len(t) return 2 * prec * rec / (prec + rec)
BLEU 是经典机器翻译指标,至今出现在摘要工作里。我们用语料级 BLEU-4,带标准长度惩罚与修正 n-gram 计数上的加一平滑(避免单个缺失 4-gram 把分数压到零)。对每个候选-参考对,计 n=1..4 的修正 n-gram 精度:修正精度把候选 n-gram 计数钳制到任意参考里该 n-gram 的最大计数,使候选不能靠重复一个短语膨胀。四精度的几何平均外包长度惩罚。
平滑规则是 Lin 与 Och 称为「方法 1」的:对每个 n-gram 精度的分子分母各加一再取 log。这避免了参考无匹配 4-gram 时的 log 0,并在长候选上接近未平滑值。
def bleu4(pred, target): c, r = tokenize(pred), tokenize(target) if not c: return 0.0 precs = [] for n in (1, 2, 3, 4): cand = Counter(zip(*[c[i:] for i in range(n)])) ref = Counter(zip(*[r[i:] for i in range(n)])) clipped = sum(min(cnt, ref.get(ng, 0)) for ng, cnt in cand.items()) total = max(len(c) - n + 1, 0) precs.append((clipped + 1) / (total + 1)) # 加一平滑 geo = math.exp(sum(math.log(p) for p in precs) / 4) bp = 1.0 if len(c) >= len(r) else math.exp(1 - len(r) / len(c)) return bp * geo
ROUGE-L 比较候选与参考 token 序列的最长公共子序列。LCS 捕捉词序却不强制连续,这正是它成为默认摘要指标的原因。用标准动态规划表算 LCS 长度,再派生召回 = lcs / 参考长度、精度 = lcs / 候选长度,用 beta=1 的 F-beta 组合成对称 F1 形。
def lcs_length(a, b): n, m = len(a), len(b) dp = numpy.zeros((n + 1, m + 1), dtype=int) for i in range(n): for j in range(m): if a[i] == b[j]: dp[i+1, j+1] = dp[i, j] + 1 else: dp[i+1, j+1] = max(dp[i+1, j], dp[i, j+1]) return int(dp[n, m]) def rouge_l(pred, target): p, t = tokenize(pred), tokenize(target) if not p or not t: return 0.0 lcs = lcs_length(p, t) prec, rec = lcs / len(p), lcs / len(t) return 2 * prec * rec / (prec + rec)
numpy 表让实现清晰;纯 Python 列表也行。选 ROUGE-L 的任务付每任务 O(nm) 代价,典型摘要长度下保持亚毫秒。
多 target 分类任务的 accuracy 归约为对单一归一 target 的 exact-match。单独暴露函数,让分派器按 metric_name 分派而无需运行器内做字符串比较。
唯一入口是 score(metric_name, prediction, targets),返回 [0,1] 的 float。运行器不对指标名分支,只转手并写结果。这是 75 节将粘到 70 节任务规格的接口。
def score(metric_name, pred, targets): if metric_name == "exact_match": return exact_match(pred, targets) if metric_name == "f1": return max(f1_score(pred, t) for t in targets) if metric_name == "bleu_4": return max(bleu4(pred, t) for t in targets) if metric_name == "rouge_l": return max(rouge_l(pred, t) for t in targets) if metric_name == "accuracy": return accuracy(pred, targets) raise ValueError(f"未知 metric_name: {metric_name}")
code_exec 在 72 节处理并在那里插入分派器。
| 指标 | 度量什么 | 复杂度 | 来源论文 |
|---|---|---|---|
| exact_match | 去空白后任一 target 等值 | O(1) | — |
| F1 | token 多重集交的调和平均 | O(n) | SQuAD |
| accuracy | 归一后单 target 等值 | O(1) | — |
| BLEU-4 | 修正 n-gram 精度的几何平均 × BP | O(n) | Papineni 2002 |
| ROUGE-L | LCS 的 F-beta | O(nm) | Lin 2004 |
业界对比:sacreBLEU、rouge-score、HuggingFace evaluate、nltk.translate.bleu_score 实现同一公式,差异主要在分词(是否区分大小写、是否保留标点、CJK 是否切字)与平滑方法。它们的共识与本节一致:分词器是契约,平滑规则要明示。sacreBLEU 的全部理由就是「可复现、不分词器歧义」——与本节「分词器是契约不是旋钮」同源。
本节不调模型、不在 70 节后处理规则之外归一化生成、不算置信区间、不算 BLEURT 或 BERTScore(那些需要模型,属另一课)。要点是地基:五个指标、一个分词器、一张分派表。main.py 把每个指标定义成自由函数加分派器;参考向量在文件底部 _reference_examples 块;demo 对八个样例跑分派器打印每指标分;code/tests/test_metrics.py 钉死参考向量并压测每个边界(空预测、空参考、无共享 token、精确匹配、重复短语钳制)。
score(metric_name, pred, targets) 分派器:本节是 70 节任务规格的执行端,也是 75 节运行器调用的唯一指标入口。新增指标只需在此表加一行。re.findall(r"\w+", text.lower()),换分词器即换基准。score(metric_name, pred, targets),运行器对指标名一无所知。下一节,我们将进入「代码执行指标」——把
code_exec这个 metric_name 落地为「在沙箱里真跑代码、按通过测试打分」,并补进分派器。