[控场AI]
· 13 分钟阅读· 6,747 字

模型替换别只看平均分:用配对测试检测真实回归

模型替换别只看平均分:用配对测试检测真实回归

配对测试与McNemar检验揭示平均分掩盖的模型替换回归。

文章指出两个后端在冻结测试集上平均分相同,但逐例对比可能九个变差、九个变好,用户只会注意变差的部分。为此提出一套配对测试工具:先冻结黄金集并记录哈希,每个用例配确定性检查器与能力标签;再用双向金丝雀审计评分器,既拒绝已知错误也接受容差边界;然后双腿运行并缓存原始输出,用精确McNemar检验基于不一致对子计算p值和delta;最后将报告读成决策,按翻转标签定位局部问题。文章还说明配对设计的边界,如后端路由与采样温度会破坏假设,且不能替代负载测试或产品级追踪。

两个后端在同一个冻结测试集上都拿到71%的平均分,听起来可以安全切换,但逐例对比后会发现:九个用例变差,九个用例变好——用户只会注意到变差的那九个。这不是靠更大的基准能解决的问题,而是一套针对冻结黄金集的配对测试,配合经过审计的评分器。本文从头搭建这套测试工具,并说明如何正确解读输出。

模型替换配对测试示意图

为什么平均分掩盖了真正的回归

聚合指标会隐藏那些真正破坏产品的翻转。两个后端在同一个冻结集上平均分相同,但每个用例的通过情况可能完全相反。配对测试只关注不一致的对子(discordant pairs),因此即使两次运行的平均分一模一样,也能检测出统计上显著的回归。

这套方法的核心不是扩大基准规模,而是提高测试集的区分度:如果两个模型在所有用例上都通过,测试集再大也提供不了任何信息。筛选用例时,应优先包含那些位于决策边界附近、你怀疑更便宜或不同调优的模型会出错的样例。从未失败过的用例只是冒烟测试,不是回归测试,应该放在单独的文件里。

第一步:在接触模型之前冻结用例

黄金集只有在实验期间不可变才有价值。把用例写入磁盘一次,对文件做哈希,并在每一份报告中记录该哈希。每个用例需要三个字段:稳定标识符、将要发送的精确提示词、以及返回布尔值的确定性检查器。

检查器要刻意保持无趣——JSON Schema校验、带容差的数值比较、必需子串检查——这样当评判模型变化时,判定结果不会漂移。为每个用例打上能力标签(如extraction、refusal、format),以便后续定位性能下降的类别,而不是争论到底哪里出了问题。

首轮80个用例是合理下限:配对检验的效力来自不一致对子,全部通过的集合无论多大都没有用。

第二步:先用金丝雀审计评分器

在任何比较运行之前,给评分器喂几个你确知错误的输出,要求它们全部拒绝。这是针对评估工作中最丢脸失败模式的廉价保险:某人在放宽正则后,评分器悄悄开始通过一切。

金丝雀文件与黄金集放在一起并一起版本化。金丝雀在评分器变更后仍然存活,应视为停止推进的事件,而不是警告。

# graders.py — 确定性检查器加金丝雀审计
import json, re

def check_json_keys(keys):
    def _check(output: str) -> bool:
        try:
            obj = json.loads(output)
        except json.JSONDecodeError:
            return False
        return all(k in obj for k in keys)
    return _check

def check_number(expected: float, tol: float = 1e-6):
    def _check(output: str) -> bool:
        m = re.search(r"-?\d+(?:\.\d+)?", output)
        return bool(m) and abs(float(m.group()) - expected) <= tol
    return _check

CANARIES = [
    ("empty_json",  check_json_keys(["id", "total"]), "{}"),
    ("prose_only",  check_json_keys(["id", "total"]), "Sure! Here is the answer."),
    ("wrong_total", check_number(41.50), "The total is 14.50"),
    ("near_miss",   check_number(41.50), "41.5 apples"),  # 必须通过:容差用例
]

def audit(canaries):
    must_fail = [c for c in canaries if c[0] != "near_miss"]
    survived = [name for name, check, out in must_fail if check(out)]
    if survived:
        raise SystemExit(f"评分器审计失败,金丝雀通过:{survived}")
    for name, check, out in canaries:
        if name == "near_miss" and not check(out):
            raise SystemExit("评分器过严:容差用例被拒绝")

最后一个金丝雀和前三个同样重要:拒绝一切的评分器会愉快地为你制造一个回归故事。双向审计让检查保持诚实,每个项目写一次大约十分钟。

第三步:运行双腿并记录清单

测试工具需要把相同的冻结提示词发送到两个后端,并持久化原始输出——在分析中途重跑不稳定的端点会悄悄使配对设计失效。按用例标识符、提示词文本和后端标签的哈希缓存每个响应,并写入包含后端标识字符串、采样参数和黄金集哈希的清单。

如果你无法说出服务请求的确切后端版本,你就没有可复现的比较;你只有带时间戳的轶事。


**精确 McNemar 检验**用于比较两个相关样本的二分类结果,适合本场景中同一用例在两个后端上的配对通过/失败。它只利用不一致对子:新通过旧失败的 gain 数(b)与旧通过新失败的 loss 数(c)。在零假设“两后端通过率相同”下,b 和 c 服从成功概率 0.5 的二项分布。精确版本直接计算二项尾概率,而非使用大样本卡方近似,因此在 b+c 较小时仍能给出可靠 p 值。代码中的 `exact_mcnemar(b, c)` 取单侧尾概率的两倍作为双侧 p 值,并截断至 1.0。当 b+c 为零时返回 1.0,表示没有证据。
# swap_test.py — 带精确McNemar检验的配对比较
import json, hashlib, math, os, urllib.request
from collections import Counter

def sha(*parts: str) -> str:
    return hashlib.sha256("|".join(parts).encode()).hexdigest()[:16]

def call(endpoint: str, model_tag: str, prompt: str) -> str:
    body = json.dumps({"model": model_tag, "temperature": 0,
                       "messages": [{"role": "user", "content": prompt}]}).encode()
    req = urllib.request.Request(endpoint + "/chat/completions", data=body,
        headers={"Content-Type": "application/json",
                 "Authorization": f"Bearer {os.environ['API_KEY']}"})
    with urllib.request.urlopen(req, timeout=60) as r:
        return json.loads(r.read())["choices"][0]["message"]["content"]

def exact_mcnemar(b: int, c: int) -> float:
    n = b + c
    if n == 0:
        return 1.0
    tail = sum(math.comb(n, i) for i in range(0, min(b, c) + 1)) / 2 ** n
    return min(1.0, 2 * tail)

def run(cases, backends, cache_path="cache.jsonl"):
    cache = {}
    if os.path.exists(cache_path):
        for line in open(cache_path):
            row = json.loads(line)
            cache[row["key"]] = row
    for case in cases:
        for name, (endpoint, tag) in backends.items():
            key = sha(name, case["id"], case["prompt"], tag)
            if key not in cache:
                out = call(endpoint, tag, case["prompt"])
                cache[key] = {"key": key, "case": case["id"], "backend": name,
                              "tag": tag, "output": out,
                              "passed": bool(case["check"](out))}
                with open(cache_path, "a") as f:
                    f.write(json.dumps(cache[key]) + "\n")
    return cache

def report(cases, cache, old, new):
    pairs = []
    for case in cases:
        a = next(r for r in cache.values() if r["case"] == case["id"] and r["backend"] == old)
        b = next(r for r in cache.values() if r["case"] == case["id"] and r["backend"] == new)
        pairs.append((case["id"], case.get("tag", "untagged"), a["passed"], b["passed"]))
    b_only = sum(1 for _, _, p, q in pairs if q and not p)   # 新通过、旧失败
    c_only = sum(1 for _, _, p, q in pairs if p and not q)   # 旧通过、新失败
    delta = (b_only - c_only) / len(pairs)
    flips = [(cid, tag) for cid, tag, p, q in pairs if p and not q]
    return {"n": len(pairs), "old_pass": sum(p for _, _, p, _ in pairs),
            "new_pass": sum(q for _, _, _, q in pairs), "gain": b_only,
            "loss": c_only, "delta": round(delta, 4),
            "p_value": round(exact_mcnemar(b_only, c_only), 4),
            "flips_by_tag": Counter(t for _, t in flips),
            "flip_list": [cid for cid, _ in flips]}

注意delta仅由不一致计数计算,因此两次运行的平均分不变完全可以与真实回归共存。

第四步:把报告读成决策,而不是分数

输出是一个小字典,每个字段对应一个动作。示例形状如{"n": 96, "gain": 7, "loss": 11, "delta": -0.0417, "p_value": 0.48, "flips_by_tag": {"format": 6, "extraction": 5}}——这些数字是模板,不是任何人系统的结果。

观察解读动作
金丝雀审计失败评分器损坏停止;先修评分器
delta ≥ 0 且 p 值较高未检测到回归保留替换,保留黄金集
delta < 0,loss > gain,p < 0.05该集上真实回归阻止替换或缩小提示范围
delta < 0,p > 0.2功效不足,未必没问题决定前补充边界用例
翻转集中在一个标签局部损坏手动检查翻转列表

集中度这一列承载了大部分实用价值:十一次损失均匀分布在五个能力类别上看起来像噪声,而十一次损失集中在format通常指向一条缺失的指令。先读翻转列表,再读p值;先读原始输出,再读翻转列表。

统计功效指在真实存在回归时,检验能够检测出它的概率。配对 McNemar 检验的效力几乎完全来自不一致对子数量(b+c),而非总用例数 n。若 80 个用例中只有 5 个发生翻转,即使真实效应存在,p 值也可能远大于 0.05,因为信息量不足。p > 0.2 时更应解读为“证据不足,无法下结论”,而非“没有回归”。要提升功效,应增加位于决策边界、可能引发翻转的用例,而不是简单扩大全部通过的用例。

这套工具的边界在哪里

配对设计假设每个用例在固定后端下是伯努利试验,因此将连续请求路由到不同模型版本的后端会膨胀方差并悄悄破坏配对。共享免费层风险最高,这正是清单逐字记录返回标识符字符串而不是信任你请求的名称的原因。如果在任何高于零的温度下采样,每个用例运行k次重复,按多数决定用例级判定,然后对多数判定重新运行金丝雀审计。

这也不是负载测试——它不说明每秒令牌数、排队行为或规模化成本,这些需要单独的方法。需要监管级评估证据的团队,或输出完全没有确定性锚点的产品,应该把这项工作交给人工审查,而不是把布尔检查器拉伸到它无法支撑的程度。它也不能替代产品级追踪,因为黄金集只认识你已经想象到的失败。

如果想不支付两份完整后端账单就运行参考腿,MonkeyCode的免费模型访问和免费服务器选项足以在小集合上执行上述工具。披露:本文是MonkeyCode产品推广的一部分。从二十个边界用例、一个金丝雀文件、每条腿一个固定后端标签开始,让翻转列表告诉你这次替换是否值得花掉下午剩下的时间。

伯努利试验假设要求每个用例在固定后端、固定采样参数下通过与否是独立的二元结果。若后端将连续请求路由到不同模型版本,同一用例的多次调用可能得到不同结果,导致用例级判定不再稳定;配对设计假设同一用例在两个后端上的结果可配对比较,路由变化会引入额外方差。温度大于零时采样同样破坏该假设,因此文中建议每个用例运行 k 次重复并以多数决定用例级判定,再对多数判定重新审计金丝雀,以恢复可比较性。

#模型替换#配对测试#McNemar检验#黄金集#评分器审计#AI评估#回归测试
分享:

相关推荐