上一篇:不会还有人红灯只会重跑和甩锅吧?假绿自动修是自杀 | 红灯喂回闭环
不会还有人拿「感觉变好了」当改进吧?基线不在,改进不算 | 效果评测与回归门槛
系列:《从 Rule 到落地:AI 工程化实践》第 26 篇 标签:评测 · 回归基线 · 宏 F1 · Recall@K · MRR 专栏:从小白到AI落地大师
序言
测试弧走完三站:第 23 篇把验收写进 YAML,第 24 篇归档失败现场,第 25 篇把红灯喂回记忆库。这一篇回答一个更刁钻的问题:你怎么证明这次改动让系统变好了,而不是只是没变坏?测试断言管「对」,指标基线管「好」——两码事。读完你要能给自己系统里任何一个「算法味」组件(分类规则、检索排序、召回策略)立起一套能进 CI 的评测门槛。
说句自嘲的:我干过最漂亮的一次「优化」,是改了检索的同义词表,跑完自己手写的三条 case,全中,兴冲冲合了码。两周后才发现一个冷门意图类别的召回掉了一半——那类 case 我一条都没写。感觉良好不是证据,是盲区。
记住一句话:「感觉变好了」不是指标;基线不在,改进不算——掉出 2pp 容忍带就否决,想降基线先走 PR。下面这份 eval_gate.py 纯标准库、复制即跑,同屏踩下三个刹车:宏 F1 拦「小类被吃掉」、分层门槛拦「平均数骗术」、基线 diff 拦「让测试变绿的捷径」。
概念
三个维度、三组指标,分工要明确:
意图分类看 Top-1 准确率与宏 F1。为什么不用 accuracy 当门禁?意图集天然不均衡——「文档问答」类输入五花八门占大头,小类一个数据集里只有十几条。accuracy 会被大类主导:大类全对,小类全军覆没,数字照样好看。宏 F1 对每个类别分别算 F1 再无权重平均,小类错了直接把平均值拽下来。混淆矩阵是排障入口:Top-1 掉了,先看是谁和谁互相认错。
检索看 Recall@K 与 MRR。Recall@K = 前 K 个结果命中的相关文档数 / 全部相关文档数,衡量「捞到没有」——RAG 场景里漏掉的文档后面 rerank 救不回来,所以它是优先优化项。MRR = 各 query「第一条正确结果的排名倒数」的平均,衡量「捞到了排第几」——适合「只要一条正确答案」的任务,比如把意图路由到那条对的记忆。相关文档若分级,该换 nDCG,但别为用不上的形态上重指标。
基线是版本化的 JSON(或 dict),元数据写明每个数字的出处:提升基线需 PR 说明依据,禁止悄降。当前指标低于基线 2 个百分点(2pp)即 fail。2pp 是刻意留的容差——数据集有随机成分,不给容差会把 flaky test 变成日常;但 2pp 也足够小,真实的算法劣化拦得住。
可以理解为:体检看的是「和去年代比」,不是「今天感觉」。基线就是那张存档的报告;没有存档,你拿什么证明瘦了两斤?
问题在哪
没有回归门槛的团队,改动的默认姿态是「我觉得更好就合」。单次都合理,每季度掉 1pp 没有任何一次看起来严重,一年后回头看指标已经烂了——温水煮青蛙式的「顺手优化」。这与 A/B 测试思想同源:线上实验拿对照组拦住「感觉良好」的产品改动,版本化基线拿固定数据集拦住「感觉良好」的算法改动。
还有两种更隐蔽的自欺,本文 demo 各演一次:其一,总量指标好看、某个分层全烂——平均数骗术;解法是分层门槛(最差 project_type 不得低于线)。其二,测试红了不去修改动,去改基线——把 0.85 改成 0.80 就「绿」了;解法是把基线 diff 本身纳入门禁,降基线视同回归,必须走显式审批。
最后提醒一句工程账:评测不是免费的。建库、灌数据、跑仿真都花时间,全塞进每次 PR 会拖垮开发节奏。通行解法是分级——PR 快速门槛跑确定性轻评测,nightly 兜住重的全量(含仿真、含真 API 的对照评测)。代价是 nightly 才能发现的问题最多滞后一天,自己权衡着买。
举例
场景:一次「意图规则微调」合码前后,跑 tests/eval/ 里两套固定数据集:32 条意图标注对、10 条分项目类型的检索 query。基线 intent_rule: top1 0.85 / macro_f1 0.85,retrieval: recall@5 0.80 / mrr 0.60,分层下限 0.70。
步骤总览(下面这段代码在干什么,一口气讲清):
完整可跑:eval_gate.py
# eval_gate.py —— 第26篇完整案例(虚构效果评测与回归门槛,仅依赖标准库)
# 纯标准库,复制即跑。用法:python eval_gate.py
import copy
REGRESSION_TOLERANCE = 0.02 # 回归门槛 2pp:低于基线超 2 个百分点即否决
MIN_STRATUM_RECALL = 0.70 # 分层门槛:最差 project_type 的 Recall@5 下限
# 基线是版本化 JSON(这里用 dict 示意),元数据写明出处与纪律
BASELINES = {
"_meta": "基线数值版本化:提升基线需 PR 说明依据,禁止悄降。回归门槛=低于基线 2pp 即否决。",
"intent_rule": {"top1": 0.85, "macro_f1": 0.85},
"retrieval": {"recall@5": 0.80, "mrr": 0.60},
}
def macro_f1(pairs):
"""(预测, 真实) 列表 -> (top1, 宏F1, 各类 F1)。宏 F1 = 每类各算 F1 再无权重平均。"""
labels = sorted({g for _, g in pairs} | {p for p, _ in pairs})
f1s = {}
for lb in labels:
tp = sum(1 for p, g in pairs if p == lb and g == lb)
fp = sum(1 for p, g in pairs if p == lb and g != lb)
fn = sum(1 for p, g in pairs if p != lb and g == lb)
prec = tp / (tp + fp) if tp + fp else 0.0
rec = tp / (tp + fn) if tp + fn else 0.0
f1s[lb] = 2 * prec * rec / (prec + rec) if prec + rec else 0.0
top1 = sum(1 for p, g in pairs if p == g) / len(pairs)
return top1, sum(f1s.values()) / len(f1s), f1s
def check(name, cur, base):
if base – cur > REGRESSION_TOLERANCE:
print(f"[FAIL] {name}: {cur:.2f} < 基线 {base:.2f} – 2pp(差 {base – cur:.2f})——混淆矩阵见 reports/")
return False
print(f"[PASS] {name}: {cur:.2f}(基线 {base:.2f})")
return True
def pairs(counts):
return [(p, g) for (p, g), n in counts.items() for _ in range(n)]
# —— 意图分类:改动前 32 条、改动后只错得更「偏小类」——
V1 = pairs({("query", "query"): 14, ("refactor", "refactor"): 10, ("explain", "explain"): 4,
("query", "refactor"): 1, ("refactor", "query"): 1,
("explain", "refactor"): 1, ("refactor", "explain"): 1})
V2 = pairs({("query", "query"): 14, ("refactor", "refactor"): 10, ("explain", "explain"): 3,
("query", "refactor"): 1, ("refactor", "query"): 1,
("explain", "refactor"): 1, ("refactor", "explain"): 1,
("query", "explain"): 1}) # 意图规则微调:一条 explain 被 query 吃掉
print("==== 意图分类:accuracy 会骗人,宏 F1 不会 ====")
for tag, data in [("改动前 ", V1), ("规则微调后", V2)]:
top1, mf, per = macro_f1(data)
detail = " ".join(f"{k} {v:.2f}" for k, v in per.items())
print(f"{tag}: top1 {top1:.2f} | macro_f1 {mf:.2f} | 分类 F1: {detail}")
print()
t1, m1, _ = macro_f1(V1)
t2, m2, _ = macro_f1(V2)
check("基线复核 intent_rule.top1", t1, BASELINES["intent_rule"]["top1"])
check("基线复核 intent_rule.macro_f1", m1, BASELINES["intent_rule"]["macro_f1"])
check("重评 intent_rule.top1", t2, BASELINES["intent_rule"]["top1"])
check("重评 intent_rule.macro_f1", m2, BASELINES["intent_rule"]["macro_f1"])
# —— 检索:Recall@5 管「捞到没」,MRR 管「捞到排第几」;还按 project_type 分层 ——
QUERIES = [ # (project_type, 相关文档集, 排序后的检索结果)
("web", {"d1"}, ["d1", "x1", "x2"]),
("web", {"d2"}, ["x1", "d2", "x2"]),
("web", {"d3", "d4"}, ["d3", "x1", "x2", "d4", "x3"]),
("web", {"d5"}, ["x1", "x2", "x3", "d5", "x4"]),
("data", {"d6"}, ["x1", "x2", "d6"]),
("data", {"d7", "d8"}, ["d7", "x1", "x2", "x3", "x4", "x5", "x6", "d8"]),
("data", {"d9"}, ["d9", "x1"]),
("iot", {"d10", "d11"}, ["x1", "d10", "x2"]),
("iot", {"d12"}, ["d12"]),
("iot", {"d13", "d14", "d15", "d16"}, ["x1", "x2", "x3", "x4", "d13", "x5"]),
]
def recall_at_5(rel, ranked, k=5):
return len(rel & set(ranked[:k])) / len(rel)
def rr(rel, ranked):
for i, doc in enumerate(ranked, 1):
if doc in rel:
return 1 / i
return 0.0
print()
print("==== 检索:总量与分层,一个都不能少看 ====")
recs = [recall_at_5(rel, ranked) for _, rel, ranked in QUERIES]
rrs = [rr(rel, ranked) for _, rel, ranked in QUERIES]
overall_rec, overall_mrr = sum(recs) / len(recs), sum(rrs) / len(rrs)
b = BASELINES["retrieval"]
ok = check("retrieval.recall@5", overall_rec, b["recall@5"]) and check("retrieval.mrr", overall_mrr, b["mrr"])
if ok:
print("总量达标——先别急着庆祝,切一刀分层:")
types = sorted({pt for pt, _, _ in QUERIES})
line = " | ".join(
f"{pt} {sum(r for (p, _, _), r in zip(QUERIES, recs) if p == pt) / sum(1 for q in QUERIES if q[0] == pt):.2f}"
f"({sum(1 for q in QUERIES if q[0] == pt)}条)" for pt in types)
print(f"按 project_type:{line}")
for pt in types:
sub = [r for (p, _, _), r in zip(QUERIES, recs) if p == pt]
avg = sum(sub) / len(sub)
if avg < MIN_STRATUM_RECALL:
print(f"[FAIL] 分层门槛:{pt} 组 recall@5 {avg:.2f} < {MIN_STRATUM_RECALL:.2f}——总量达标但某类全烂,视同回归")
# —— 偷懒 diff:改基线让测试变绿?——
print()
print('==== 偷懒 diff:把基线 0.85 改到 0.80 让 top1 "变绿"? ====')
pr = copy.deepcopy(BASELINES)
pr["intent_rule"]["top1"] = 0.80
pr_approval = {} # 本次 PR 描述里没有引用任何基线变更依据
for comp, ms in pr.items():
if not isinstance(ms, dict):
continue
for m, v in ms.items():
if v < BASELINES[comp][m] and f"{comp}.{m}" not in pr_approval:
print(f"[reject] {comp}.{m} 基线被下调 {BASELINES[comp][m]:.2f} -> {v:.2f},PR 无审批记录")
print("降基线视同回归:每一次退让都必须变成显式决策,而不是 diff 里顺手的两行数字。")
print()
print("==== 汇总:同屏多刹车,各拦一类自欺 ====")
print("macro_f1 拦「小类被吃掉」 | 分层门槛拦「平均数骗术」 | 基线 diff 拦「让测试变绿的捷径」")
print("代码测试证明「没弄坏」,评测门槛度量「变好了」。缺一个,红绿灯都只是装饰。")
跑起来长这样
python eval_gate.py,真实输出原样贴上:
==== 意图分类:accuracy 会骗人,宏 F1 不会 ====
改动前 : top1 0.88 | macro_f1 0.86 | 分类 F1: explain 0.80 query 0.93 refactor 0.83
规则微调后: top1 0.84 | macro_f1 0.80 | 分类 F1: explain 0.67 query 0.90 refactor 0.83
[PASS] 基线复核 intent_rule.top1: 0.88(基线 0.85)
[PASS] 基线复核 intent_rule.macro_f1: 0.86(基线 0.85)
[PASS] 重评 intent_rule.top1: 0.84(基线 0.85)
[FAIL] 重评 intent_rule.macro_f1: 0.80 < 基线 0.85 – 2pp(差 0.05)——混淆矩阵见 reports/
==== 检索:总量与分层,一个都不能少看 ====
[PASS] retrieval.recall@5: 0.82(基线 0.80)
[PASS] retrieval.mrr: 0.68(基线 0.60)
总量达标——先别急着庆祝,切一刀分层:
按 project_type:data 0.83(3条) | iot 0.58(3条) | web 1.00(4条)
[FAIL] 分层门槛:iot 组 recall@5 0.58 < 0.70——总量达标但某类全烂,视同回归
==== 偷懒 diff:把基线 0.85 改到 0.80 让 top1 "变绿"? ====
[reject] intent_rule.top1 基线被下调 0.85 -> 0.80,PR 无审批记录
降基线视同回归:每一次退让都必须变成显式决策,而不是 diff 里顺手的两行数字。
==== 汇总:同屏多刹车,各拦一类自欺 ====
macro_f1 拦「小类被吃掉」 | 分层门槛拦「平均数骗术」 | 基线 diff 拦「让测试变绿的捷径」
代码测试证明「没弄坏」,评测门槛度量「变好了」。缺一个,红绿灯都只是装饰。
输出你看什么
读到这里,不妨立刻做一个五分钟动作:打开你们仓库,找到与本篇对应的那个目录(没有就新建),把文中最小示意落成真实文件名。文件在,讨论才有坐标;文件不在,下周还会从「我们觉得」开始。第 26 篇的验收不是「读完」,是「仓库里多了一份可指出的东西」。
从最小可用版起步:tests/eval/baselines.json 先只锁一个你最有把握的指标(比如意图分类 macro_f1),datasets/ 放一份 30 条以内、人工标注得起的 jsonl,门槛脚本进 CI。别一上来铺四个维度——基线的第一敌不是设错,是设完没人维护。
自检
# 评测门槛自检
– [ ] 「改进」有没有改动前锁定的版本化基线?
– [ ] 重评之前,基线本身先复核过吗?
– [ ] 门禁指标对小类公平吗(宏 F1 / 逐类)?
– [ ] 分层至少切过一刀——总量达标某类全烂算回归吗?
– [ ] 降基线在 CI 里会触发红灯(须走 PR 审批)吗?
合上本文自问四个。一,你的「改进」有改动前锁定的基线吗,还是每次都现场找一组好看的数据?二,你的门禁指标对小类别公平吗——accuracy 主导的报表里,最小的那个类多久没被单独看过了?三,分层切过一刀吗?按项目类型、按意图、按客户规模,任选一刀。四,改基线这个动作,在你的 CI 里会触发红灯吗?四问有一个答不上,你的「变好了」就还是感觉。
思考
小结三句。指标选择跟着任务形态走:只要一条正确答案就用 MRR,类别不均衡就用宏 F1,防平均数骗术就加分层统计。基线必须版本化且只升不降,降要走显式流程。评测分级(PR 轻门槛 + nightly 全量)是纪律和速度之间的通用平衡解。
这套体系真正的产出不是那几个数字,是开发姿态的转变:动手改检索之前先想「哪个指标该动、哪个不能动」,改完看断言而不是看感觉。当「不能悄悄变差」从团队默契变成机器执行的事实,你才配谈持续改进。
可以理解为:第 25 篇的红灯喂回保证了「错一次学一次」,本篇的门槛保证了「学完真变好」。代码测试保证对,评测门槛度量好——对而不好,是僵化;好而不对,是玄学。终章要把整条路收拢成一张清单:勾完它,才算落地。
下篇预告:《不会还有人学完还觉得「感觉用上了 AI」就算落地吧?清单打分不认感觉 | 工程化终章》——第 27 篇把 26 站的钉子收拢成可季度重勾的清单,红线漏一条,总分清零。
觉得有用的话,欢迎点赞、留言晒一晒你被「平均数骗术」坑过的现场;有「基线被同事顺手改小」故事的,更欢迎展开讲讲。
专栏:从小白到AI落地大师|《从 Rule 到落地:AI 工程化实践》
网硕互联帮助中心





评论前必须登录!
注册