云计算百科
云计算领域专业知识百科平台

26-不会还有人拿「感觉变好了」当改进吧?基线不在,改进不算 | 效果评测与回归门槛

上一篇:不会还有人红灯只会重跑和甩锅吧?假绿自动修是自杀 | 红灯喂回闭环

不会还有人拿「感觉变好了」当改进吧?基线不在,改进不算 | 效果评测与回归门槛

系列:《从 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。

步骤总览(下面这段代码在干什么,一口气讲清):

  • macro_f1(pairs):从 (预测, 真实) 对列表算 Top-1、宏 F1 与各类 F1——每类分别算 P/R 再合成 F1,最后无权重平均。
  • check():当前值低于基线超过 REGRESSION_TOLERANCE = 0.02 即打 FAIL——容忍带之外无商量。
  • 检索侧 recall_at_5() 与 rr() 算总量,再按 project_type 切分层:任何一组低于 0.70,总量再好看也视同回归。
  • 基线 diff 检查:拿「PR 里的基线」对「版本化基线」,发现下调且 pr_approval 里没有审批记录 → 直接 reject。
  • 汇总对照:三个刹车各拦哪类自欺。
  • 完整可跑: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 拦「让测试变绿的捷径」
    代码测试证明「没弄坏」,评测门槛度量「变好了」。缺一个,红绿灯都只是装饰。

    输出你看什么

  • 头两行是全篇最重要的一课:同一次改动,top1 从 0.88 掉到 0.84「看着还行」,宏 F1 从 0.86 掉到 0.80 直接红。看分类 F1 那一列:explain 0.80 → 0.67——小类被 query 吃掉了一条样本,大类几乎不动。如果只盯 accuracy,这次劣化会无声合入;宏 F1 对小类一视同仁,拽不住的就是拽不住。
  • 基线复核那两行不是废话:先用改动前的数据验证「基线本身站得住」(0.88/0.86 都过),再对改动后重评。跳过复核直接重评,你可能拿一套本来就达不了的基线否决所有人。
  • FAIL 行末尾的「混淆矩阵见 reports/」:报告归档到带时间戳的 JSON,失败断言直接指向现场——排障不用重跑。这和第 24 篇失败归档是同一个哲学:报错信息是给用户看的导航,不是给用户看的墙。
  • 检索段先绿后红:总量 recall@5 0.82、MRR 0.68 都过线,代码也体贴地提醒你「先别急着庆祝」——切分层后 iot 0.58 现出原形。三条 iot query 里两条把相关文档排到 5 名开外,全局十条一平均,什么都看不出来。分层门槛比全局门槛紧一截,就是防平均数骗术的。
  • 最后一段刹车防的是人:改动没过门槛,有人动了基线的脑筋——top1 0.85 → 0.80 这种 diff 一眼假,但「顺手微调基线 0.01」混在大 PR 里就很难说了。所以降基线不看幅度、看审批:pr_approval 里没有记录,一律 reject。门槛防的从来不是恶意,是每周三下午五点半的疲惫。
  • 读到这里,不妨立刻做一个五分钟动作:打开你们仓库,找到与本篇对应的那个目录(没有就新建),把文中最小示意落成真实文件名。文件在,讨论才有坐标;文件不在,下周还会从「我们觉得」开始。第 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 工程化实践》

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 26-不会还有人拿「感觉变好了」当改进吧?基线不在,改进不算 | 效果评测与回归门槛
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!