第 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 | |
|---|---|---|
| 数据 | 冻结的用例集,工具响应可以回放 | 真实用户的会话 |
| 设计 | 配对:同一条用例两个版本都跑 | 不配对:每个会话只进一个组 |
| 检验 | 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 = 271 | b = 25(回归) | 296 |
| A 挂 | c = 30(修好) | d = 74 | 104 |
| 合计 | 301 | 99 | 400 |
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 → B | b(回归) | c(修好) | McNemar 精确 p |
|---|---|---|---|---|
| 表单校验 | 61 → 66(+6.25 个点) | 2 | 7 | 0.180 |
| 权限 | 62 → 65(+3.75) | 2 | 5 | 0.453 |
| 状态同步 | 58 → 63(+6.25) | 3 | 8 | 0.227 |
| 并发竞态 | 57 → 50(−8.75) | 13 | 6 | 0.167 |
| 页面渲染 | 58 → 57(−1.25) | 5 | 4 | 1.000 |
并发竞态掉了 8.75 个点,但每类只有 80 条,单看这一类 p = 0.167,也不显著。整体的提升把它盖住了。
这时要用第 1 周讲过的回归清单:b 里的 25 条用例,两个模型各重跑 5 次,按重跑结果归类(阈值是笔者定的经验值:A 至少 4/5 过、B 至多 1/5 过算真回归):
| 归类 | 条数 | 其中并发竞态 |
|---|---|---|
| 真回归(A 稳定过、B 稳定挂) | 8 | 8 |
| flaky / 待查 | 15 | 5 |
| 偶发(重跑后 A、B 都稳定通过) | 2 | 0 |
合成数据里没有"用例 / 判定有问题"这一类(第 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 | 门禁放行的概率 |
|---|---|
| 200 | 20.3% |
| 400 | 35.6% |
| 800 | 61.8% |
| 1600 | 89.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, --model | codex 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, --profile | codex 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
),但:
config.toml里写profile = "cand"会直接报错(core/src/config/mod.rs:3385-3392)。- 用
-p cand时,如果config.toml里还有[profiles.cand]表,也报错(loader/mod.rs:302-322)。 - app-server 的配置写接口拒绝给这两个键写入值,删除仍允许(判断条件是
if !value.is_null(),app-server/src/config_manager_service.rs:282-295)。
源码的报错信息把 [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] 表两种旧写法都会触发它。
两点值得测开注意:
- profile 名字拼错不会报错。
-p cnad指向的cnad.config.toml不存在,加载函数把不存在的文件当成空表(loader/mod.rs:547-551的注释:“If the file does not exist, uses an emptyTable"),于是静默地跑了基线配置。A/B 的两组就变成了 A/A,实验结论是"没差别”,而且看上去很合理。 - doctor 不接受
-p(本机报错:"–profile only applies to runtime commands andcodex 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,查看等间隔):
| 查看次数 K | 1 | 2 | 5 | 10 | 20 |
|---|---|---|---|---|---|
| 每次按 \(\lvert Z_k\rvert \ge 1.96\) 判,总一类错误率 | 0.050 | 0.083 | 0.142 | 0.193 | 0.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 测试平台的场景下也指出,持续监控会让误报概率膨胀到名义水平的数倍。
三种修法
- 固定样本量:事先算好 n,只在最后看一次结果。中间可以看,但不做决定。
- 分组序贯边界:事先定好看几次,每次用更严的临界值,让总的一类错误率仍是 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。
- 任意时刻都有效的 p 值(always-valid p-values):Johari 等人(2017)的方法,允许随时看、随时停,同时控制误报率。实现比前两种复杂,本章不展开。
两种边界的 c 都没有闭式解,本文用和上面同样的数值积分求解:
| K | Pocock c | O’Brien-Fleming:第 1 次 / 最后一次 |
|---|---|---|
| 2 | 2.178 | 2.797 / 1.977 |
| 3 | 2.289 | 3.471 / 2.004 |
| 5 | 2.413 | 4.562 / 2.040 |
| 10 | 2.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-Fleming | 5.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 文件被当成空表,静默跑基线配置。