Learning AI Quality 返回 KuthorX Blog II博客首页

第 34 章

第 7 周:换个模型上线,离线回归和线上 A/B 各管什么?

换模型、改 prompt 分两道关:先在冻结用例上做配对的离线回归(McNemar、Newcombe 配对区间、非劣效、回归清单逐条重跑),再做不配对的线上 A/B(样本量、偷看问题、Pocock 与 O'Brien-Fleming 边界)。切换开关对照 Codex 的 -m、-c key=value 和 --profile,用临时 CODEX_HOME 实测优先级。全部用合成数据在本地跑。一段讲解视频,三个互动演示。

BugHunt-Bench 的测试 Agent 现在用模型 A,想换成便宜一些的模型 B;或者模型不变,把 prompt 从 v1 改成 v2。两件事是同一个问题:换完以后,有没有哪里变差了? 这一章把它拆成两道关。第一道是离线回归:在冻结的用例集上,两个版本跑同一批用例,配对比较,回答"会不会变差、哪里变差"。第二道是线上 A/B:把真实流量随机分给两个版本,回答"在真实用户那里怎么样"。两道关的统计结构不一样:离线是配对的,线上不配对,样本量差一个数量级;线上还多了一个离线没有的坑,就是边跑边看结果(偷看)。

切换本身也要工程化:两组除了一个配置键,其他都要一样。这里对照 Codex 的三个开关 -m、-c key=value 和 --profile,在临时的 CODEX_HOME 里实测它们的优先级。本章的实验全部用合成数据,用固定种子在本地跑,不调用任何模型,所以"真实差值"是已知的,可以检验每一步的结论对不对。

讲解视频

互动演示

三个演示:改离线 2×2 表的四个格子和非劣效 margin,实时算 McNemar 精确 p、Newcombe 配对区间和门禁结论;调基线、最小可检测差、功效和每天流量,看线上 A/B 每组要多少会话、要跑几天,和离线配对要多少用例对比;设定查看次数、停止规则和真实差值,模拟几千次线上实验,看偷看会把误报率推到多高、Pocock 和 O’Brien-Fleming 边界怎么把它压回 5%。页面底部有自动判分的练习。

互动演示:离线回归与线上 A/B 在新标签页打开

两道关,各管一件事

离线回归线上 A/B
数据冻结的用例集,工具响应可以回放真实用户的会话
设计配对:同一条用例两个版本都跑不配对:每个会话只进一个组
检验McNemar、配对区间(第 1 周已讲)两比例 z 检验,或分组序贯检验
需要的量几百到几千条用例每组几千到几万个会话
速度、成本快,不影响用户慢,坏版本会伤到一部分用户
能发现具体哪几条用例回归了用例集没覆盖到的分布、真实的接受率、时延和成本
不能发现用例集外的问题;用例集过期、泄漏某一类小众用例上的回归(被平均掉)

顺序是先离线、后线上:离线便宜,又能定位到具体用例,先把明显的回归拦住;线上贵,只给离线已经过关的版本。两者不能互相替代。用例集外的问题只有线上能看到;反过来,线上的平均指标会把一小类用例上的回归平均掉,下面的实验里就有这种情况。本章只看质量指标;换模型时成本和延迟怎么一起权衡,见本周「七个配置选哪个?成本、延迟、质量的帕累托前沿」。

第一关:离线回归

冻结 400 条 BugHunt 用例:每条是一个注入了 Bug 的被测页面加一份标准答案,工具响应按本周「把工具响应录下来回放,评测就公平了吗?」一章的回放方式固定下来。5 个类别各 80 条:表单校验、权限、状态同步、并发竞态、页面渲染。两个模型在同一批用例上各跑一次,“通过"指找到了注入的 Bug 且报告有效。

合成设定:每条用例有一个共同的难度,两个模型都受它影响,所以结果正相关;B 在大多数用例上略好,但在并发竞态类里约 30% 的用例上明显更差。真实通过率 A 73.6%、B 73.9%,整体只差 +0.34 个点。

2×2 表与整体检验

B 过B 挂合计
A 过a = 271b = 25(回归)296
A 挂c = 30(修好)d = 74104
合计30199400

A 296/400 = 74.00% → B 301/400 = 75.25%,差 \((c-b)/N = +1.25\) 个点,分歧率 \(\psi = (b+c)/N = 13.8\%\)。只有 55 条结果不一样的用例带信息,这一点和怎么做 McNemar 检验,见第 1 周「新 prompt 从 82% 涨到 86%,是真的变好了吗?」,这里不再推导。McNemar 精确 p = 0.590:没有证据说两者不一样。

换模型要证明的是"没有明显变差”,所以看差值区间的下界,也就是非劣效。事先定 margin 为 3 个点,要求 95% 双侧区间的下界 > −3 个点(等价于单侧 α = 0.025,第 1 周同一章讲过)。

区间:Newcombe 的配对方法

第 1 周用配对 bootstrap 给区间。这里再给一个不用重抽的解析区间,方便放进门禁脚本和互动演示:Newcombe(1998,Improved confidence intervals for the difference between binomial proportions based on paired data,Statistics in Medicine 17:2635–2650)比较了 10 种配对比例差的区间,指出常用的渐近(Wald)法可能给出超出 [−1, 1] 的界、覆盖率也差。他的方法 10:两个版本的通过率各算一个 Wilson 区间,再用两者的相关系数合成。记 \(\hat\theta = (c-b)/N\),\(p_B = (a+c)/N\)、\(p_A = (a+b)/N\),它们的 Wilson 区间是 \((l_B, u_B)\)、\((l_A, u_A)\):

$$ L = \hat\theta - \sqrt{(p_B-l_B)^2 - 2\hat\phi\,(p_B-l_B)(u_A-p_A) + (u_A-p_A)^2} $$$$ U = \hat\theta + \sqrt{(u_B-p_B)^2 - 2\hat\phi\,(u_B-p_B)(p_A-l_A) + (p_A-l_A)^2} $$$$ \hat\phi = \frac{\max(ad - bc - N/2,\ 0)}{\sqrt{(a+b)(c+d)(a+c)(b+d)}}\quad(ad > bc\ \text{时};\text{分母为 0 时}\ \hat\phi = 0) $$

分子里减 \(N/2\) 是方法 10 对 \(\hat\phi\) 的连续性校正(方法 8 不减)。论文的四格记号是 e、f、g、h,对应这里的 a、c、b、d。实现和论文 Table III 的方法 10 逐个对照,四组例子都对到小数点后 4 位,写在单元测试里。

区间95% 区间(个百分点)非劣效(margin 3)
Newcombe 方法 10[−2.43, +4.93]下界 > −3,通过
配对 bootstrap(10000 次,按用例重抽)[−2.50, +4.75]通过

两种方法在这份数据上只差不到 0.2 个点。整体看,B 过了非劣效门禁。

门禁过了,不等于可以切

按类别拆开:

类别通过条数(每类 80 条)A → Bb(回归)c(修好)McNemar 精确 p
表单校验61 → 66(+6.25 个点)270.180
权限62 → 65(+3.75)250.453
状态同步58 → 63(+6.25)380.227
并发竞态57 → 50(−8.75)1360.167
页面渲染58 → 57(−1.25)541.000

并发竞态掉了 8.75 个点,但每类只有 80 条,单看这一类 p = 0.167,也不显著。整体的提升把它盖住了。

这时要用第 1 周讲过的回归清单:b 里的 25 条用例,两个模型各重跑 5 次,按重跑结果归类(阈值是笔者定的经验值:A 至少 4/5 过、B 至多 1/5 过算真回归):

归类条数其中并发竞态
真回归(A 稳定过、B 稳定挂)88
flaky / 待查155
偶发(重跑后 A、B 都稳定通过)20

合成数据里没有"用例 / 判定有问题"这一类(第 1 周的第三类),这里多出的"偶发"是 A、B 重跑都至少 4/5 通过的用例。

8 条真回归全在并发竞态一类。在这个合成设定下,整体非劣效通过、单类检验不显著,只有逐条重跑把问题集中暴露出来。线上 A/B 也很难发现它(笔者分析):如果并发竞态类任务只占真实流量的 15%,这一类掉 8.75 个点,摊到整体约 1.3 个点,低于下文线上实验按 2 个点设计的最小可检测差。所以处理方式应该在离线解决:针对这一类修 prompt 或保留旧模型路由,把这 8 条加进回归集,下次切换时单独设门禁。

400 条够吗

样本量也要事先算。第 1 周「要跑多少次才够?」给了配对设计检测差距的公式(Connor, 1987),但非劣效门禁问的是另一件事:“两个版本真的一样时,门禁有多大概率放行”。这里直接模拟(本文模拟:真实差 0,A 通过率 75%,分歧率 14%,margin 3 个点,每个 N 模拟 100000 次):

用例数 N门禁放行的概率
20020.3%
40035.6%
80061.8%
160089.2%

分歧率 14% 时,400 条用例连一个完全没变差的版本都只有约 36% 的机会证明自己非劣效。上面的 B 能过,有运气成分:它的真实差只有 +0.34 个点(按合成设定重抽 20000 次,放行概率约 40%),这一批样本观测到的 +1.25 偏高。门禁不放行时,结论是"证据不够",不是"B 更差"。

切换开关:两组只差一个配置键

离线回归和线上 A/B 都要求:两组除了被比较的那一项,其他全部一样。工程上最稳的做法是不改代码,只改配置,并且把每次运行实际生效的配置记下来。Codex 的配置覆盖是一个现成的参照。以下源码引用基于 commit 7993248(/tmp/codex-src),CLI 行为在本机 codex-cli 0.159.2 上实测,两者一致。

三个开关

开关写法落到哪里
-m, --modelcodex exec -m model-b ...直接进 ConfigOverrides.model( exec/src/lib.rs:560-572 ),加载完配置后 let model = model.or(cfg.model);( core/src/config/mod.rs:3970 ),所以比所有配置层都优先
-c key=value-c model=model-b、-c developer_instructions="..."任意配置键,组成一个 SessionFlags 配置层
-p, --profilecodex exec -p cand ...把 $CODEX_HOME/cand.config.toml 作为第二个用户层叠在 config.toml 上

-c 的解析在 utils/cli/src/config_override.rs:49-84 ,核心几行:

                // Only split on the *first* '=' so values are free to contain
                // the character.
                let mut parts = s.splitn(2, '=');
                // ...
                let value: Value = match parse_toml_value(value_str) {
                    Ok(v) => v,
                    Err(_) => {
                        // Strip leading/trailing quotes if present
                        let trimmed = value_str.trim().trim_matches(|c| c == '"' || c == '\'');
                        Value::String(trimmed.to_string())
                    }
                };

逐行看:

  • splitn(2, '='):只按第一个 = 切,值里可以再出现 =。
  • parse_toml_value:先把右边当 TOML 值解析(实现是拼成 _x_ = <值> 再解析, :95-102 )。
  • 解析失败就退回成字符串。所以 -c model=gpt-5.1 不加引号也行:gpt-5.1 不是合法的 TOML 值,按字符串处理。反过来,看起来像数字的值会被解析成数字:本机实测 -c model=1.5,doctor 报配置加载失败(invalid data)。

键是带点的路径,逐段写进一个 TOML 表( config/src/overrides.rs:9-15 )。根命令上的 -c 会被插到子命令的 -c 前面,“so they have lower precedence than command-specific flags parsed after a subcommand”( config_override.rs:40-45 ),同一个键后写的覆盖先写的。

层的优先级

每个配置层有一个优先级数值,大的覆盖小的( config/src/config_layer_source.rs:33-51 ):

    pub fn precedence(&self) -> i16 {
        match self {
            ConfigLayerSource::PackagedDefaults { .. } => -10,
            ConfigLayerSource::Mdm { .. } => 0,
            ConfigLayerSource::System { .. } => 10,
            ConfigLayerSource::EnterpriseManaged { .. } => 15,
            ConfigLayerSource::User { profile, .. } => {
                if profile.is_some() {
                    21
                } else {
                    20
                }
            }
            ConfigLayerSource::Project { .. } => 25,
            ConfigLayerSource::SessionFlags => 30,
            ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
            ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50,
        }
    }

和做实验相关的几条:

  • config.toml 是 20,--profile 选中的文件是 21,所以 profile 文件里"只需要写要改的键"( config/src/loader/mod.rs:289-291 的注释原话是 “the profile only needs to contain overrides”),加载见 :326-337 ;文件名是 <name>.config.toml( core/src/config/mod.rs:2030-2038 )。
  • 项目里的 .codex/config.toml 是 25,高于 profile。按源码注释,目录不受信任时这一层"loaded but disabled"( loader/mod.rs:131-133 )。笔者分析:如果被测仓库受信任、又带了自己的 .codex/config.toml 并写了 model,它会盖过 --profile 里的 model。
  • -c 是 30,高于用户层和项目层;只有两种旧式的托管配置(40、50)比它高。

旧写法已经不能用了

这个 commit 里,--profile 是"profile-v2":一个独立文件,不再是 config.toml 里的 [profiles.<name>] 表。ConfigToml 里还保留着 profile 和 profiles 字段( config/src/config_toml.rs:356-361 ),但:

源码的报错信息把 [profiles.x] 称为 legacy 写法;按这种写法配置的旧教程,在这个版本上会报错。

实测:临时 CODEX_HOME

脚本 codex_config_demo.sh 新建一个临时目录当 CODEX_HOME(不碰 ~/.codex),写一个基础 config.toml(model = "model-a"、developer_instructions = "PROMPT_V1")和一个 cand.config.toml(model = "model-b"、developer_instructions = "PROMPT_V2"),然后只跑两个不调模型的命令:codex doctor 打印生效的 model;codex debug prompt-input 打印发给模型的输入,从里面找生效的 developer 指令。doctor 会做连通性探测,脚本把 model provider 指到 http://127.0.0.1:9/v1,探测只连本机并失败。本机 0.159.2 的输出:

codex --version: codex-cli 0.159.2
--- 生效的 model
[基础配置]                           model                    model-a · local_stub
[-c model=model-c]                   model                    model-c · local_stub
[-m model-d -c model=model-c]        model                    model-d · local_stub
[根 -c model=x,子命令 -c model=y]   model                    y · local_stub
--- 生效的提示(developer_instructions)
[基础配置]                           PROMPT_V1
[-p cand]                            PROMPT_V2
[-p cand -c developer_instructions=PROMPT_CLI] PROMPT_CLI
[-p cnad(拼错)]                    PROMPT_V1
--- 旧写法
[config.toml 里还有 [profiles.cand],再用 -p cand] Error: --profile `cand` cannot be used while /private/tmp/laq_codex_home.rEftZh/config.toml contains legacy `profile = "cand"` or `[profiles.cand]` config; move those settings into /private/tmp/laq_codex_home.rEftZh/cand.config.toml and remove the legacy profile selector/table. See https://developer

最后一行被脚本截到 300 个字符;完整报错文本见 config/src/loader/mod.rs:317 ,profile = "cand" 选择器和 [profiles.cand] 表两种旧写法都会触发它。

两点值得测开注意:

  1. profile 名字拼错不会报错。-p cnad 指向的 cnad.config.toml 不存在,加载函数把不存在的文件当成空表( loader/mod.rs:547-551 的注释:“If the file does not exist, uses an empty Table"),于是静默地跑了基线配置。A/B 的两组就变成了 A/A,实验结论是"没差别”,而且看上去很合理。
  2. doctor 不接受 -p(本机报错:"–profile only applies to runtime commands and codex mcp",列表里有 codex debug prompt-input),所以没法用 doctor 直接看 profile 里的 model。笔者的做法:model 用 -c model=... 切(doctor 能核对),prompt 用 profile 或 -c developer_instructions=... 切(debug prompt-input 能核对);每批运行前把这两个命令的输出存进 trace,跑完后断言"实验组生效的值确实是候选值"。

第二关:线上 A/B

离线过关、并发竞态的问题处理完以后,才上线做 A/B。线上指标定为"会话里至少有一条 Bug 报告被开发者确认"的比例,基线约 40%(合成设定)。

线上不再配对:每个会话只进一个组,同一条输入不会被两个版本各跑一次,第 1 周的 McNemar 用不上,回到两个独立比例的比较。随机分组的单位要选会话或用户,不要选单次请求:同一个用户的多次请求结果相关,按请求分组再按请求算样本量,会犯「跑了 300 次,就是 300 个样本吗?」那一章说的错误(笔者分析)。另外两组的实际分流比例要和设计一致,例如设计 50/50,结果明显偏离,先查分流有没有 bug,再看指标。

每组要多少会话

Kohavi 等人(2009,Controlled experiments on the web: survey and practical guide,Data Mining and Knowledge Discovery 18:140–181)的公式 (2),置信度 95%、功效 80%:

$$ n = \frac{16\,\sigma^2}{\Delta^2} $$

\(n\) 是每组的用户数(两组等大),\(\sigma^2\) 是指标的方差,\(\Delta\) 是想检测的变化。原文注明出自 van Belle(2002)第 31 页,并说把 16 换成 21 就是 90% 功效。二值指标 \(\sigma^2 = p(1-p)\)。本文推导:16 是 \(2(z_{0.975}+z_{0.8})^2 = 15.70\) 取整的结果,21 对应 \(2(z_{0.975}+z_{0.9})^2 = 21.01\)。

基线 40%,想检测 2 个点的变化(双侧 α = 0.05):

算法每组会话数
Kohavi 16σ²/Δ²9600
系数用 15.70 不取整9419
第 1 周「要跑多少次才够?」的两组公式9336
Kohavi,90% 功效(21σ²/Δ²)12600

每组每天 960 个会话,要跑 10 天。对比离线:同样检测 2 个点,配对设计(第 1 周的 Connor 公式)在分歧率 5%、10%、20% 时要 979、1960、3923 条用例。线上贵出好几倍,原因就是不配对:每个会话只有一个版本的结果,用例难度的差异全算进了噪声。

偷看:每天看一眼,误报率翻几倍

10 天的实验,产品经理每天都会看一眼看板。最自然的做法是"哪天 p < 0.05 就宣布结果、停下实验"。问题是每看一次都是一次检验,多看几次,碰上一次"显著"的概率就上去了。Armitage、McPherson、Rowe(1969,Repeated significance tests on accumulating data,JRSS Series A 132:235–244)最早算出了这个膨胀:等间隔看 5 次、每次按 0.05 判,总的一类错误率约 0.142(数值转引自 Lakens《Improving Your Statistical Inferences》第 10 章)。

本文用同样的思路数值计算(\(Z_k\) 是第 k 次查看时累计数据的 z 统计量,两组差为 0,查看等间隔):

查看次数 K1251020
每次按 \(\lvert Z_k\rvert \ge 1.96\) 判,总一类错误率0.0500.0830.1420.1930.248

K = 5 的 0.142 和文献一致(单元测试里对照)。每天看一次、看 10 天,A/A 实验(两组完全一样)有约 19% 的概率被宣布"显著"。Johari 等人(2017,Peeking at A/B Tests: Why it matters, and what to do about it,KDD 2017)在 A/B 测试平台的场景下也指出,持续监控会让误报概率膨胀到名义水平的数倍。

三种修法

  1. 固定样本量:事先算好 n,只在最后看一次结果。中间可以看,但不做决定。
  2. 分组序贯边界:事先定好看几次,每次用更严的临界值,让总的一类错误率仍是 5%。
    • Pocock(1977,Group sequential methods in the design and analysis of clinical trials,Biometrika 64(2):191–199):每次用同一个临界值 c。
    • O’Brien & Fleming(1979,A multiple testing procedure for clinical trials,Biometrics 35(3):549–556):第 k 次的临界值是 \(c\sqrt{K/k}\),前期极严,最后一次接近 1.96。
  3. 任意时刻都有效的 p 值(always-valid p-values):Johari 等人(2017)的方法,允许随时看、随时停,同时控制误报率。实现比前两种复杂,本章不展开。

两种边界的 c 都没有闭式解,本文用和上面同样的数值积分求解:

KPocock cO’Brien-Fleming:第 1 次 / 最后一次
22.1782.797 / 1.977
32.2893.471 / 2.004
52.4134.562 / 2.040
102.555(每次名义 p < 0.0106)6.598 / ≈ 2.0865

K = 2、3 的 Pocock 值和 K = 3 的 O’Brien-Fleming 三个值,与 Lakens 教材第 10 章给出的数值一致(2.178、2.289;3.471、2.454、2.004),写进了单元测试。

实验:10 天、每天看一次

合成设定:基线 40%,每组每天 960 个会话,第 1 天到第 10 天每天看一次累计数据,用合并方差的两比例 z 检验。先用正态近似算理论值,再用二值数据模拟 20000 次核对:

停止规则A/A:宣布显著的比例(理论 / 模拟)真实差 −2 个点:检出率(理论 / 模拟)真实差 −2 个点:平均停在第几天(理论 / 模拟)
固定样本量,只在第 10 天看5.0% / 5.1%80.7% / 80.9%10 / 10
每天看,按 1.96 判(偷看)19.3% / 19.5%87.3% / 87.4%4.9 / 5.0
每天看,Pocock(c = 2.555)5.0% / 5.1%68.9% / 69.5%6.8 / 6.8
每天看,O’Brien-Fleming5.0% / 5.2%79.2% / 79.4%7.7 / 7.6

在这个合成设定下:

  • 偷看的"检出率高、停得早"是假象,代价是 A/A 时近 1/5 的误报。
  • Pocock 和 O’Brien-Fleming 都把误报压回 5%,而且 B 真的更差时,平均能提前 2~3 天停下,少让一部分用户用坏版本。
  • 最大样本量不变时,两种边界都会损失功效,Pocock 损失更多(80.7% → 68.9%)。本文数值计算:要保住 80% 功效,Pocock 要把最大样本量放大到固定设计的 1.30 倍,O’Brien-Fleming 只要 1.04 倍;真实差等于 2 个点时,两者的期望样本量都约为固定设计的 0.79 倍。

O’Brien-Fleming 前几天的临界值极高(第 1 天 6.598),几乎只会在第 10 天前后做出决定,和"中间不做决定"的固定设计很接近,又留了一个出大问题时提前停的口子。对"换模型"这种主要担心变差的实验,这通常是更顺手的选择(笔者观点)。

代码

代码在学习目录的 week07_回放评测与模型对比/code/model_switch_ab/,只依赖 numpy 和 scipy:

  • stats.py:2×2 表、McNemar、Newcombe 方法 10、配对 bootstrap、非劣效判定、回归清单归类、样本量(Kohavi、第 1 周的两组公式和 Connor)、偷看的数值积分和 Pocock / O’Brien-Fleming 边界。
  • synthetic.py:生成离线用例集和线上逐日数据。
  • run_experiment.py:打印本章所有数字。
  • test_stats.py:18 个单元测试。
  • codex_config_demo.sh:临时 CODEX_HOME 里的 Codex 配置实验。

偷看问题的核心是下面这个递推:在"还没停"的区域上,把 \(S_k\) 的密度和正态核卷积一步,同时累加这一步越界的概率。

def crossing_probs(bounds: np.ndarray, drift: float = 0.0, h: float = 0.02) -> np.ndarray:
    """K 次等间隔查看、第 k 次在 |Z_k| >= bounds[k] 时停止,返回每次查看"首次越界"的概率。

    模型:S_k = X_1 + ... + X_k,X_i 独立 N(drift, 1),Z_k = S_k / sqrt(k)。
    这是 Armitage, McPherson & Rowe (1969) 的递推思路:在继续区域上把密度逐步和正态核卷积。
    本文数值实现:网格步长 h,梯形积分。"""
    bounds = np.asarray(bounds, float)
    probs = []
    s = None
    f = None
    for k, c in enumerate(bounds, start=1):
        b = c * sqrt(k)                                  # S_k 的边界
        if k == 1:
            probs.append(norm.sf(b - drift) + norm.cdf(-b - drift))
            s = _grid(b, h)
            f = norm.pdf(s - drift)
            continue
        step = s[1] - s[0]
        w = np.full(s.size, step)                        # 梯形权重
        w[0] = w[-1] = step / 2
        mass = f * w
        probs.append(float(np.sum(mass * (norm.sf(b - s - drift) + norm.cdf(-b - s - drift)))))
        s_new = _grid(b, h)
        f = norm.pdf(s_new[:, None] - s[None, :] - drift) @ mass
        s = s_new
    return np.array(probs)

\(S_k = X_1 + \dots + X_k\),\(X_i\) 独立同分布 \(N(\text{drift}, 1)\),\(Z_k = S_k/\sqrt{k}\)。naive_peeking_alpha(K) 就是 crossing_probs 在每次都用 1.96 时的总和;pocock_c、obf_bounds 用 brentq 求出让总和等于 0.05 的 c。单元测试用 20 万条模拟路径核对了三件事:偷看的误报率、两种边界的误报率,以及 O’Brien-Fleming 的功效和期望停止时间,误差都在 0.004 以内。

python3 -m unittest -v test_stats.py 的 18 个测试全部通过(Python 3.9、numpy 1.22、scipy 1.8)。

面试怎么答

“能不能把模型换成便宜的?你怎么验证?”

分两道关。先离线:冻结的用例集、工具响应回放,两个版本跑同一批用例,配对比较。事先定一个能接受的下降幅度,比如 3 个点,要求配对差值区间的下界高于 −3,并且旧过新挂的用例逐条重跑、归类,按类别看有没有集中回归。离线过了再上线 A/B:按会话随机分流,事先按最小可检测差算样本量,基线 40%、检测 2 个点每组约 9600 个会话;要中途看结果就用分组序贯边界,比如 O’Brien-Fleming,不能每天按 0.05 看。切换本身只改配置,比如 Codex 的 -c model=... 或 profile 文件,每次运行把生效配置打进 trace,防止 profile 拼错静默跑成 A/A。

追问:“离线已经过了,为什么还要 A/B?” 用例集只覆盖了你想到的情况,真实分布、开发者是否接受报告、真实时延和成本只有线上能看到。反过来,A/B 的平均值会把小类别上的回归平均掉,所以离线的逐条回归清单不能省。

追问:“A/B 每天看结果有什么问题?” 每看一次就是一次检验。等间隔看 5 次、每次按 0.05 判,总误报率约 14%;10 次约 19%。要么只在最后决定,要么用 Pocock / O’Brien-Fleming 这种事先定好的边界,要么用 always-valid 的方法。

常见错误说法

  • “离线 p = 0.59,说明 B 和 A 一样好,可以换”:不显著不等于一样,要看差值区间下界和非劣效 margin。
  • “整体非劣效通过了,就可以直接切”:本章的合成数据里,整体通过、单类不显著,8 条真回归全集中在一个类别里,只有逐条重跑才看得出来。
  • “线上 A/B 也用 McNemar”:线上每个会话只进一个组,没有配对,用两比例检验;McNemar 要求同一条用例两个版本都有结果。
  • “线上要的样本量和离线差不多”:离线配对时分歧率 10% 检测 2 个点约 1960 条用例,线上不配对每组约 9600 个会话。
  • “每天看一眼,p < 0.05 就停,反正也是 5% 的显著性”:每天看 10 天,A/A 的误报率约 19%。
  • “用 Pocock 边界就能随便提前停,不吃亏”:本章设定(看 10 次、按 2 个点设计)下,最大样本量不变时功效从约 81% 掉到约 69%,要放大最大样本量来补。
  • “Codex 的 profile 写在 config.toml 的 [profiles.x] 里”:在 commit 7993248 和本机 0.159.2 上,这种写法配合 -p x 会报错;profile 是 $CODEX_HOME/x.config.toml。
  • “profile 名字写错了会报错”:不会,不存在的 profile 文件被当成空表,静默跑基线配置。