模型A/B测试的科学方法论:让两个AI题解“赛跑“的量化框架 模型A/B测试的科学方法论让两个AI题解赛跑的量化框架一、A/B测试不是看谁分高就留谁组内有两个题解生成模型想上线v2.3 比 v2.2 多了一层 attention 剪枝效果看起来好不少。Leader 说拿生产数据跑个对比确认没问题再上线。我想都没想掏出 1000 道题跑了两遍算出平均分 v2.2 3.8 vs v2.3 4.1结论v2.3 比 v2.2 高 0.3 分上Leader 看了一眼我的分析你算这 0.3 分的 p 值了吗这 1000 题是随机抽的吗有没有控制题目的难度分布我问号。Leader 丢给我一本书《Trustworthy Online Controlled Experiments》。于是我知道A/B 测试不只是跑个对比取平均而是一整套统计推断流程抽样策略、效应量计算、假设检验、置信区间、多重比较校正——缺了任何一个环节上线这个动作都是拍脑袋。flowchart TB A[选择评测数据集br/分层抽样 难度均匀分布] -- B[定义核心指标br/主指标 护栏指标] B -- C[设定假设与效应量阈值br/H0: μ_new ≤ μ_old] C -- D[随机分流实验br/用户层 vs 题目层] D -- E[收集实验数据br/最小样本量校验] E -- F[统计检验br/t检验 / Mann-Whitney] F -- G{p 0.05?} G --|✅ 显著| H[计算效应量br/Cohens d / 相对提升率] G --|❌ 不显著| I[增加样本量br/或放弃实验] H -- J[护栏指标检查br/延迟、成本、稳定性] J -- K{护栏通过?} K --|✅| L[发布决策发布] K --|❌| M[拒绝上线br/记录决策原因] L -- N[长期效果监控br/AA回溯验证]A/B测试不是跑完收工而是一整套从设计到决策的闭环。其中最容易忽略的是统计显著性和效应量的区别——显著只说明差异不太可能是偶然效应量才能回答差异到底有多大。二、效应量为什么均值差 0.3 分不算完先定义几个关键概念。假设实验组新模型得分为 X对照组旧模型得分为 Y各有 n 个样本。我们关心的不是均值的绝对差而是标准化均值差也即 Cohens dimport numpy as np from scipy import stats from typing import Tuple, Optional, List from dataclasses import dataclass dataclass class ABTestResult: A/B测试的完整统计结果 group_name: str # 实验组名称 mean: float # 样本均值 std: float # 样本标准差 n: int # 样本量 improvement: float # 相对提升% cohens_d: float # Cohens d 效应量 p_value: float # p值 ci_lower: float # 95%置信区间下界 ci_upper: float # 95%置信区间上界 significant: bool # 是否显著 practical: bool # 是否具实际意义效应量≥0.2 class ABTestEngine: A/B测试的统计引擎 核心流程 1. 计算两组样本的均值和标准差 2. Welch t-test不假设方差齐性 3. 计算 Cohens d 效应量 4. 计算 95% 置信区间 5. 综合判断统计显著 实际效应量 staticmethod def cohens_d(scores_a: np.ndarray, scores_b: np.ndarray) - float: 计算Cohens d效应量 Cohens d (mean_a - mean_b) / pooled_std 效应量解释 - 0.2: 小效应需要大样本才能检测 - 0.5: 中等效应肉眼可见差异 - 0.8: 大效应明显效果 使用pooled standard deviation而非任一组的std 是为了消除样本量不均衡带来的偏差。 n_a, n_b len(scores_a), len(scores_b) mean_a, mean_b np.mean(scores_a), np.mean(scores_b) var_a, var_b np.var(scores_a, ddof1), np.var(scores_b, ddof1) # 合并标准差pooled standard deviation pooled_std np.sqrt( ((n_a - 1) * var_a (n_b - 1) * var_b) / (n_a n_b - 2) ) if pooled_std 0: return 0.0 return (mean_a - mean_b) / pooled_std staticmethod def welch_ttest(scores_a: np.ndarray, scores_b: np.ndarray) - Tuple[float, float]: Welchs t-test不假设两组方差相同 相比标准 Students t-test - Welchs 版本不要求方差齐性 - 对不均衡样本量也更稳健 - 自由度使用 Satterthwaite 近似 H0: μ_a ≤ μ_b实验组不优于对照组 H1: μ_a μ_b实验组显著优于对照组 使用右侧单尾检验因为我们只关心新模型是否更好。 n_a, n_b len(scores_a), len(scores_b) mean_a, mean_b np.mean(scores_a), np.mean(scores_b) var_a, var_b np.var(scores_a, ddof1), np.var(scores_b, ddof1) # 均值差的标准误 se np.sqrt(var_a / n_a var_b / n_b) if se 0: return 0.0, 1.0 t_stat (mean_a - mean_b) / se # Satterthwaite 自由度近似 # 修正分母使 t 分布更准确地逼近真实分布 num (var_a / n_a var_b / n_b) ** 2 denom ((var_a / n_a) ** 2) / (n_a - 1) ((var_b / n_b) ** 2) / (n_b - 1) df num / denom if denom 0 else min(n_a, n_b) - 1 # 单侧检验survival function 1 - CDF p_value stats.t.sf(t_stat, df) return t_stat, p_value staticmethod def confidence_interval( scores_a: np.ndarray, scores_b: np.ndarray, alpha: float 0.05 ) - Tuple[float, float]: 计算两组均值差的 95% 置信区间 均值差的置信区间 (mean_a - mean_b) ± t_crit * SE_diff 如果区间完全在 0 右侧即下界 0说明提升是显著的。 如果区间跨越 0说明无法排除无差异的可能性。 n_a, n_b len(scores_a), len(scores_b) mean_diff np.mean(scores_a) - np.mean(scores_b) var_a, var_b np.var(scores_a, ddof1), np.var(scores_b, ddof1) se_diff np.sqrt(var_a / n_a var_b / n_b) # Satterthwaite 自由度 num (var_a / n_a var_b / n_b) ** 2 denom ((var_a / n_a) ** 2) / (n_a - 1) ((var_b / n_b) ** 2) / (n_b - 1) df num / denom if denom 0 else min(n_a, n_b) - 1 t_crit stats.t.ppf(1 - alpha / 2, df) margin t_crit * se_diff return (mean_diff - margin, mean_diff margin) classmethod def run( cls, scores_a: List[float], # 实验组新模型 scores_b: List[float], # 对照组旧模型 group_name: str experimental, alpha: float 0.05, min_effect_size: float 0.2 # 最小实际效应量阈值 ) - ABTestResult: 执行完整的A/B测试统计分析 Args: scores_a: 实验组评分列表 scores_b: 对照组评分列表 group_name: 实验组名称 alpha: 显著性水平默认0.05 min_effect_size: 最小实际效应量默认0.2 Returns: ABTestResult: 包含所有统计指标的结果对象 a np.array(scores_a, dtypefloat) b np.array(scores_b, dtypefloat) mean_a, mean_b np.mean(a), np.mean(b) std_a, std_b np.std(a, ddof1), np.std(b, ddof1) # 相对提升率 improvement ((mean_a - mean_b) / mean_b) * 100 if mean_b ! 0 else 0.0 # Cohens d 效应量 d cls.cohens_d(a, b) # Welchs t-test _, p_value cls.welch_ttest(a, b) # 95% 置信区间 ci_lower, ci_upper cls.confidence_interval(a, b, alpha) # 综合判断 significant p_value alpha practical abs(d) min_effect_size return ABTestResult( group_namegroup_name, meanmean_a, stdstd_a, nlen(a), improvementround(improvement, 2), cohens_dround(d, 4), p_valueround(p_value, 4), ci_lowerround(ci_lower, 4), ci_upperround(ci_upper, 4), significantsignificant, practicalpractical )上面的代码实现了 A/B 测试的核心统计引擎。重点在于两个判断维度的同时使用统计显著p 0.05说明差异不太可能是随机噪声实际效应量|d| 0.2说明差异在业务上有价值。只靠其中一个做决策都是危险的。三、分层抽样为什么随机采 1000 道题不够一个常被忽视的问题是题目难度分布不均。如果你的实验样本里简单题占比过高两个模型得分都会偏高但谁更好的信号可能被噪声淹没。科学的做法是分层抽样class StratifiedSampler: 分层抽样器按难度均匀抽取评测题目 为什么需要分层 - 简单题两个模型都能答对区分度低 - 中等题新模型可能更好这是检测效应的关键区间 - 困难题两个模型可能都答不好方差大 def __init__(self, problem_pool: List[dict]): 初始化抽样子 Args: problem_pool: 题目池每条包含 {id, difficulty, score} self.pool problem_pool def sample_stratified( self, strata_key: str difficulty, total_n: int 1000 ) - List[dict]: 按层等比例或等量抽样 等量抽样每层抽取相同数量的样本 等比例抽样按层在总体中的比例抽取 这里使用等量抽样因为我们需要每层都有足够的统计功效。 import random random.seed(42) # 按层级分组 strata: dict {} for item in self.pool: level item[strata_key] strata.setdefault(level, []).append(item) n_strata len(strata) n_per_stratum total_n // n_strata sampled [] for level, items in strata.items(): if len(items) n_per_stratum: # 该层题目不够全取 sampled.extend(items) else: sampled.extend(random.sample(items, n_per_stratum)) return sampled staticmethod def compute_min_sample_size( baseline_mean: float, baseline_std: float, minimum_detectable_effect: float, alpha: float 0.05, power: float 0.8 ) - int: 计算最小样本量 公式n 2 * (z_α z_β)² * σ² / δ² 其中 - δ minimum_detectable_effect最小可检测效应 - σ 标准差 - z_α 显著性水平的 z 分数 - z_β 统计功效的 z 分数 这是A/B测试中最容易忽略的一步。 如果样本量不够即使新模型确实更好你也检测不出来。 from scipy.stats import norm z_alpha norm.ppf(1 - alpha / 2) # 双侧1 - α/2 z_beta norm.ppf(power) # 功效1 - β delta minimum_detectable_effect sigma baseline_std n 2 * ((z_alpha z_beta) ** 2) * (sigma ** 2) / (delta ** 2) return int(np.ceil(n)) def simulate_ab_test(): 模拟一次完整的A/B测试 场景题解质量评分1-5分我们怀疑新模型更好。 先生成两组有差异的模拟数据跑一遍完整流程。 np.random.seed(42) # 对照组旧模型均分3.8标准差0.7 scores_old np.random.normal(3.8, 0.7, 500) scores_old np.clip(scores_old, 1.0, 5.0) # 实验组新模型均分4.0标准差0.7真实提升0.2分 scores_new np.random.normal(4.0, 0.7, 500) scores_new np.clip(scores_new, 1.0, 5.0) # 运行A/B测试 result ABTestEngine.run( scores_alist(scores_new), scores_blist(scores_old), group_namev2.3_attention_pruned ) # 输出报告 print( * 60) print(fA/B 测试报告{result.group_name}) print( * 60) print(f实验组均值: {result.mean:.2f} ± {result.std:.2f}) print(f相对提升: {result.improvement:.1f}%) print(fCohens d: {result.cohens_d:.4f}) print(fp-value: {result.p_value:.4f}) print(f95% CI: [{result.ci_lower:.4f}, {result.ci_upper:.4f}]) print(f统计显著: {✅ 是 if result.significant else ❌ 否}) print(f实际意义: {✅ 有 if result.practical else ❌ 无}) print( * 60) # 决策建议 if result.significant and result.practical: print( 建议可以上线新模型) elif result.significant and not result.practical: print(⚠️ 建议差异显著但效应量太小投入产出比不高) elif not result.significant and result.practical: print( 建议效应量达标但不显著需要更多样本) else: print(❌ 建议效果不显著也不具实际意义回退旧模型) return result # 运行模拟 if __name__ __main__: simulate_ab_test()分层抽样的核心价值在于控制变量。当两个模型在中等难度题上差距很大但在简单题上差距可以忽略时如果样本中简单题占比 80%整体均值差会被严重稀释从而得出新模型没有提升的错误结论。四、多重比较与AA回溯验证两个高阶陷阱多重比较问题如果你同时对比了 5 个指标正确性、完整性、可读性、延迟、token 消耗使用 α 0.05 做 5 次独立检验那么至少有一个假阳性的概率是 1 - (1 - 0.05)^5 ≈ 0.226。也就是说你有 22.6% 的几率发现一个不存在的提升。解决方案是Bonferroni 校正将显著性水平调整为 α / mm 为比较次数。或者使用更温和的Holm-Bonferroni方法def holm_bonferroni(p_values: List[float], alpha: float 0.05) - List[bool]: Holm-Bonferroni 多重比较校正 与 Bonferroni 相比Holm 方法更不容易犯第二类错误假阴性。 步骤 1. 将 p 值从小到大排序 2. 第 i 个 p 值与 α / (m - i 1) 比较 3. 一旦某个 p 值不显著其后所有都不显著 m len(p_values) ranked sorted(enumerate(p_values), keylambda x: x[1]) rejected [False] * m for rank, (idx, p_val) in enumerate(ranked, start1): adjusted_alpha alpha / (m - rank 1) if p_val adjusted_alpha: rejected[idx] True else: # 一旦停止拒绝后续全部不拒绝 break return rejectedAA 回溯验证上线后不能不管。你需要定期跑AA 测试——将同一版本的流量随机分成两组假装一个是实验组、一个是对照组。如果 AA 测试的 p 值频繁跌破 0.05说明你的分流机制或统计方法有问题。def aa_backtest(scores: List[float], n_trials: int 1000) - dict: AA回溯验证检查统计方法的假阳性率 将同一批数据随机分成两组跑 t-test重复 n_trials 次。 假阳性率应该接近 alpha0.05。 如果假阳性率远大于 0.05说明你的测试框架有系统性偏差。 false_positives 0 results [] for _ in range(n_trials): np.random.shuffle(scores) mid len(scores) // 2 # 随机分成两组本质上是同一分布 group_a scores[:mid] group_b scores[mid:] _, p_val ABTestEngine.welch_ttest( np.array(group_a), np.array(group_b) ) if p_val 0.05: false_positives 1 results.append(p_val) fpr false_positives / n_trials print(fAA 回溯验证{n_trials} 次试验) print(f假阳性率: {fpr:.4f} 期望值: 0.05) print(f偏差判断: {⚠️ 存在偏差 if abs(fpr - 0.05) 0.02 else ✅ 正常}) return { trials: n_trials, false_positive_rate: fpr, expected: 0.05, biased: abs(fpr - 0.05) 0.02 }五、总结做A/B测试的三个必须这套 A/B 测试框架的核心不是代码量而是科学决策的流程约束必须计算样本量。样本量不够即使新模型真的好 20%你也检测不出来。使用compute_min_sample_size方法在实验开始前就确定你需要多少数据。必须同时看显著性和效应量。p 0.05 只告诉你差异不是噪声Cohens d 告诉你差异有多大。如果一个改动统计显著但效应量只有 0.05上线带来的复杂度远大于收益。必须做 AA 回溯验证。你的测试框架是否过于灵敏是否经常把随机波动误判为显著差异AA 测试能暴露这个问题。假阳性率稳定在 0.05 附近才说明你的统计流程是可靠的。影响 A/B 测试结论可靠性的三大因素样本量不足最隐蔽的坑表面上有显著结果实则统计功效太低未做分层抽样简单题占比过高稀释了中等题上的真实差异忽略多重比较对比 5 个指标时不对 α 做校正假阳性率飙升至 22.6%这些检查项都不需要复杂的代码却比任何高级统计模型都更直接影响你上线 vs 回退的决策质量。