显著只是第一道门
10.2 的四组实验都算出了「处理组更便宜」。但便宜是否可信、是否值得发布,是三个不同的问题:统计上是否显著、效应是否大到值得迁移、质量与延迟是否仍在护栏内。
先看显著性。批处理的成本差只有 17.54 微元,但 10,000 条配对样本把标准误压到 0.025,t 值达到 −695.013,p 小于 0.001。显著只说明「差值不太可能恰好来自抽样噪声」,不说明差值有业务价值。样本足够大时,任何微小差异都会显著;这时要看置信区间与相对变化,而不是只看星号。
置信区间比 p 值更有用
| 实验 | 成本差(95% CI,微元) | 质量差(95% CI) | p |
|---|---|---|---|
| 提示压缩 | −178.00 [−195.80, −160.20] | −0.1333 [−0.1646, −0.1021] | <0.001 |
| 结果缓存 | −81.26 [−88.36, −74.16] | −0.0117 [−0.0203, −0.0031] | 0.0078 |
| 小模型替代 | −217.50 [−238.69, −196.32] | −0.0320 [−0.0364, −0.0276] | <0.001 |
| 批处理 | −17.54 [−17.59, −17.49] | 0.0000 [0.0000, 0.0000] | 1.000 |
置信区间同时给出方向、幅度与不确定性。缓存的质量差上界是 −0.0031,说明「质量完全没变」不太可能;但下界只有 −0.0203,最坏情况接近 2 个百分点。评审要决定的是「最坏情况能否接受」,而不是「均值看起来还行」。
批处理的成本区间窄到几乎是一条线,因为每条条目的成本由固定公式决定,方差只来自 200 条在线与 9,800 条离线之间的分配。区间窄不代表结论强,只代表这个成本模型本身很确定;模型之外的延迟风险没有被区间覆盖。
多重比较:12 次检验里的一次显著
四组实验、三个指标,一共 12 次检验。即使所有处理都没有效果,按 α=0.05 也期望出现 0.6 次假阳性。把族错误率控制在 0.05,单次门槛应降到 0.05/12≈0.0042。
按这个门槛,缓存的质量回退 p=0.0078 不再显著。这不是说质量一定没退,而是说「在 12 次比较的家庭里,这次回退还不足以排除偶然」。结论应该写成「观察到 1.17 个百分点的质量下降,但未通过多重比较校正」,而不是「缓存显著损害质量」。压缩与小模型的质量 p 远小于校正门槛,结论不受影响。
质量回退的三种形态
第一种是均值整体下移。提示压缩的平均质量分从 1.0000 降到 0.8667,13.33 个百分点的差距在任何分段里都存在。这种回退最容易发现,也最容易被均值掩盖掉尾部:某个会话可能丢的是关键单号,而不是一条无关描述。
第二种是低概率、高代价的误命中。结果缓存 600 条请求里只有 7 次误命中,质量均值只降 1.17 个百分点;但每次误命中都是一次错误交付,代价 900 到 1,500 微元,合计 7,500 微元。均值把「7 次严重错误」摊成了「一点点质量损失」。
第三种是能力间不均衡。小模型总体保留率 96.41%,但 code.review 只有 94.15%,比 search.rerank 的 98.15% 低 4 个百分点。总体通过验收,不代表每个能力都能替换;如果生产流量里代码评审占比更高,总体保留率会继续下滑。
结论能不能外推
外推风险由两件事决定:实验样本的段构成与生产流量差多少,以及实验是否漏掉了处理组更差的段。示例检查给出:段构成差异 30.0%,受损段「批量任务」在生产中占 40%、在实验中只占 10%,综合判定为高风险。
四组实验的外推条件各不相同:
- 提示压缩的 36 个会话是合成的,生产上下文更长、工具结果更多,压缩比与事实丢失率都会变。
- 结果缓存只覆盖三个能力与精确命中,语义缓存、跨租户复用与失效传播都没有进入样本。
- 小模型替代的 40 条样本按能力均分,生产流量不会均分;
code.review占比上升会拉低总体保留率。 - 批处理的 2 小时窗口与 6 小时容忍度是设定值,生产的新鲜度要求更紧时,离线比例会下降。
因此「实验通过」只能写成「在实验覆盖的段与窗口内通过」。发布前要补的是生产段构成、最差段的表现,以及窗口收紧后的成本曲线,而不是再跑一遍同样的样本。
四组复核结论
| 实验 | 主指标 | 质量护栏(≤1pp) | 综合判定 |
|---|---|---|---|
| 提示压缩 | 成本 −19.72%,显著 | −13.33pp,突破 | 不通过 |
| 结果缓存 | 成本 −52.99%,显著 | −1.17pp,略超门槛 | 边界,需重设护栏 |
| 小模型替代 | 成本 −75.00%,显著 | −3.59pp,突破 | 条件通过,需保护弱能力 |
| 批处理 | 成本 −8.60%,显著 | 0,通过 | 仅延后消费可用 |
「边界」不是模糊词。缓存的质量差 1.17 个百分点,与 1 个百分点的护栏只差 0.17;要么把护栏改成 2 个百分点并说明依据,要么缩小缓存范围,把高风险能力排除。把边界写成「基本通过」,下一次评审就会把它当成通过。
实现
experiment-analysis.ts 提供配对分析、辛普森检测、护栏判定、发布决策与外推风险五组函数。它不读取实验数据,只接收 PairedSample 与分段汇总,因此同一套判定可以复用到下一章的质量-成本-延迟权衡。
// file: src/perf/experiment-analysis.ts
// tier: L2
// verify: node --test test/perf/experiment-analysis.test.ts
import { AiError } from "../core/errors.ts";
import { normalCdf, tCritical95 } from "./experiment.ts";
import type { MetricSpec, PairedSample } from "./experiment.ts";
export interface ArmSummary {
n: number;
mean: number;
sd: number;
se: number;
}
function fail(message: string): never {
throw new AiError("invalid-request", `experiment-analysis: ${message}`);
}
function mean(values: readonly number[]): number {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function sampleSd(values: readonly number[]): number {
if (values.length < 2) return 0;
const center = mean(values);
const variance = values.reduce((sum, value) => sum + (value - center) ** 2, 0) / (values.length - 1);
return Math.sqrt(variance);
}
export function summarizeArm(values: readonly number[]): ArmSummary {
if (values.length === 0) fail("汇总至少需要 1 个值");
const sd = sampleSd(values);
return { n: values.length, mean: mean(values), sd, se: sd / Math.sqrt(values.length) };
}
export interface PairedAnalysis {
metric: string;
n: number;
baseline: ArmSummary;
treatment: ArmSummary;
meanDiff: number;
sdDiff: number;
ci95: [number, number];
tStat: number;
pValue: number;
relativeChange: number;
cohensDz: number;
/** 按指标方向归一后的改善量:正数表示处理组更好。 */
improvement: number;
significant: boolean;
}
/** 逐条配对后再做差,而不是拿两个组的均值直接相减。配对消掉样本难度差异。 */
export function analyzePaired(samples: readonly PairedSample[], spec: MetricSpec, alpha = 0.05): PairedAnalysis {
if (samples.length < 2) fail("配对分析至少需要 2 条样本");
for (const sample of samples) {
if (!Number.isFinite(sample.baseline) || !Number.isFinite(sample.treatment)) fail(`样本 ${sample.id} 的数值必须是有限数`);
}
const baseline = summarizeArm(samples.map((sample) => sample.baseline));
const treatment = summarizeArm(samples.map((sample) => sample.treatment));
const diffs = samples.map((sample) => sample.treatment - sample.baseline);
const meanDiff = mean(diffs);
const sdDiff = sampleSd(diffs);
const seDiff = sdDiff / Math.sqrt(samples.length);
const crit = tCritical95(samples.length - 1);
const ci95: [number, number] = seDiff === 0 ? [meanDiff, meanDiff] : [meanDiff - crit * seDiff, meanDiff + crit * seDiff];
const tStat = seDiff === 0 ? (meanDiff === 0 ? 0 : Number.POSITIVE_INFINITY * Math.sign(meanDiff)) : meanDiff / seDiff;
const pValue = seDiff === 0 ? (meanDiff === 0 ? 1 : 0) : 2 * (1 - normalCdf(Math.abs(tStat)));
const improvement = spec.direction === "lower-is-better" ? -meanDiff : meanDiff;
return {
metric: spec.name, n: samples.length, baseline, treatment, meanDiff, sdDiff, ci95, tStat, pValue,
relativeChange: baseline.mean === 0 ? 0 : meanDiff / Math.abs(baseline.mean),
cohensDz: sdDiff === 0 ? 0 : meanDiff / sdDiff,
improvement,
significant: pValue < alpha && (ci95[0] > 0 || ci95[1] < 0),
};
}
export interface SegmentObservation {
segment: string;
baselineN: number;
baselineMean: number;
treatmentN: number;
treatmentMean: number;
}
export interface SegmentDelta {
segment: string;
baselineN: number;
treatmentN: number;
delta: number;
weight: number;
}
export interface SimpsonReport {
aggregateBaseline: number;
aggregateTreatment: number;
aggregateDelta: number;
withinSegmentDelta: number;
signFlip: boolean;
flippedSegments: string[];
segments: SegmentDelta[];
}
function weightedMean(values: readonly number[], weights: readonly number[]): number {
const total = weights.reduce((sum, weight) => sum + weight, 0);
if (total === 0) return 0;
return values.reduce((sum, value, index) => sum + value * weights[index], 0) / total;
}
/** 整体差值用两组各自样本量加权,段内差值用每段总样本量加权;反号即构成悖论。 */
export function detectSimpson(segments: readonly SegmentObservation[]): SimpsonReport {
if (segments.length === 0) fail("辛普森检查至少需要 1 个分段");
for (const item of segments) {
if (item.baselineN < 0 || item.treatmentN < 0) fail(`${item.segment}: 样本量不能为负`);
if (!Number.isFinite(item.baselineMean) || !Number.isFinite(item.treatmentMean)) fail(`${item.segment}: 均值必须是有限数`);
}
const aggregateBaseline = weightedMean(segments.map((item) => item.baselineMean), segments.map((item) => item.baselineN));
const aggregateTreatment = weightedMean(segments.map((item) => item.treatmentMean), segments.map((item) => item.treatmentN));
const totalN = segments.reduce((sum, item) => sum + item.baselineN + item.treatmentN, 0);
const details: SegmentDelta[] = segments.map((item) => ({
segment: item.segment, baselineN: item.baselineN, treatmentN: item.treatmentN,
delta: item.treatmentMean - item.baselineMean,
weight: totalN === 0 ? 0 : (item.baselineN + item.treatmentN) / totalN,
}));
const withinSegmentDelta = details.reduce((sum, item) => sum + item.delta * item.weight, 0);
const aggregateDelta = aggregateTreatment - aggregateBaseline;
const signFlip = aggregateDelta * withinSegmentDelta < 0 && Math.abs(aggregateDelta) > 1e-9 && Math.abs(withinSegmentDelta) > 1e-9;
return {
aggregateBaseline, aggregateTreatment, aggregateDelta, withinSegmentDelta, signFlip,
flippedSegments: signFlip
? details.filter((item) => item.delta * aggregateDelta < 0 && item.weight >= 0.05).map((item) => item.segment)
: [],
segments: details,
};
}
export interface GuardrailResult {
metric: string;
/** 处理组相对对照组的原始差值。 */
delta: number;
allowed: number;
passed: boolean;
}
/** 护栏按“回退量”判断:成本看是否涨,质量看是否跌,方向不能共用一套符号。 */
export function checkGuardrail(analysis: PairedAnalysis, spec: MetricSpec): GuardrailResult {
const regression = spec.direction === "higher-is-better" ? -analysis.meanDiff : analysis.meanDiff;
return { metric: spec.name, delta: analysis.meanDiff, allowed: spec.nonInferiorityMargin, passed: regression <= spec.nonInferiorityMargin };
}
export interface DecisionPolicy {
alpha: number;
/** 相对改善低于该比例时,不值得为迁移与回滚风险买单。 */
minRelativeEffect: number;
}
export interface ExperimentDecision {
ship: boolean;
reasons: string[];
}
export function decideExperiment(primary: PairedAnalysis, guardrails: readonly GuardrailResult[], policy: DecisionPolicy): ExperimentDecision {
const reasons: string[] = [];
if (!primary.significant) reasons.push("主指标未达到统计显著");
if (primary.improvement <= 0) reasons.push("主指标没有改善");
if (Math.abs(primary.relativeChange) < policy.minRelativeEffect) {
reasons.push(`相对变化 ${(primary.relativeChange * 100).toFixed(2)}% 低于门槛 ${(policy.minRelativeEffect * 100).toFixed(2)}%`);
}
for (const guardrail of guardrails) {
if (!guardrail.passed) reasons.push(`护栏 ${guardrail.metric} 回退 ${guardrail.delta.toFixed(4)} 超过允许 ${guardrail.allowed}`);
}
return { ship: reasons.length === 0, reasons };
}
export interface ShareObservation {
segment: string;
/** 实验样本中该段占比。 */
experimentShare: number;
/** 生产流量中该段占比。 */
productionShare: number;
/** 处理组减对照组;负数表示处理组更差。 */
delta: number;
}
export interface ExtrapolationReport {
/** 两组占比的总变差距离,0 表示构成完全一致。 */
shareGap: number;
risk: "low" | "medium" | "high";
underRepresentedHarmful: string[];
reasons: string[];
}
/** 外推风险 = 段构成差多少 + 是否漏掉受损段;只报“代表性良好”无法指导发布。 */
export function extrapolationRisk(items: readonly ShareObservation[]): ExtrapolationReport {
if (items.length === 0) fail("外推检查至少需要 1 个分段");
for (const item of items) {
if (item.experimentShare < 0 || item.productionShare < 0) fail(`${item.segment}: 占比不能为负`);
}
const shareGap = 0.5 * items.reduce((sum, item) => sum + Math.abs(item.experimentShare - item.productionShare), 0);
const underRepresentedHarmful = items
.filter((item) => item.delta < 0 && item.productionShare - item.experimentShare > 0.05)
.map((item) => item.segment);
const harmfulProduction = items.filter((item) => item.delta < 0).reduce((sum, item) => sum + item.productionShare, 0);
const reasons: string[] = [];
if (shareGap > 0.15) reasons.push(`段构成差异 ${(shareGap * 100).toFixed(1)}% 超过 15%`);
if (underRepresentedHarmful.length > 0) reasons.push(`受损段被低采样:${underRepresentedHarmful.join("、")}`);
if (harmfulProduction > 0.2) reasons.push(`生产流量中受损段占 ${(harmfulProduction * 100).toFixed(1)}%`);
return { shareGap, risk: reasons.length >= 2 ? "high" : reasons.length === 1 ? "medium" : "low", underRepresentedHarmful, reasons };
}
analyzePaired 同时返回原始差值、相对变化、Cohen’s dz 与置信区间,是为了让评审能区分「统计显著」与「效应够大」。decideExperiment 把显著性、最小相对效应与全部护栏放在一次判断里,任何一项不满足都会返回具体原因,而不是一个布尔值。
extrapolationRisk 不判断实验本身好不好,只回答「这份样本能否代表生产」。当受损段被低采样时,它直接点名该段;这比一个「代表性良好」的形容词更能阻止错误发布。
测试与运行结果
测试覆盖配对区间、护栏方向、发布决策、不显著情形、辛普森悖论、外推风险与非法输入。执行:
node --test test/perf/experiment-analysis.test.ts
本机一次运行的真实输出摘要如下,耗时随机器变化:
ℹ tests 7
ℹ pass 7
ℹ fail 0
ℹ duration_ms 154.132792
示例中辛普森检测返回整体差值 +46、段内差值 −10、signFlip=true;外推检查返回段构成差异 30.0%、风险 high、受损段 ["批量任务"]。这些结果与 10.1 的设计门禁、10.2 的配对样本使用同一套口径,可以逐项复算。
小结
结果解读要同时回答四个问题:差值是否超出抽样噪声、幅度是否值得迁移、质量与延迟是否仍在护栏内、样本能否代表生产。四组实验里,只有批处理在质量上无回退,但它的延迟语义已经改变;其余三组都需要重新谈护栏或缩小范围。显著性只是入场券,不是发布许可。
下一章把成本、延迟与质量放到同一个权衡面上,讨论如何为不同能力选择不同的最优点。
阅读导航:上一节:10.2 四组对照实验 · 下一节:11.1 质量-成本-延迟的权衡面 。
常见错误与边界
- 把 p<0.05 当成「可以发布」。它只排除抽样噪声,不回答幅度与护栏。
- 样本足够大时追逐微小效应。批处理每条省 17.54 微元,但延迟代价没有被算进去。
- 只报均值不报区间。缓存质量最坏可能降 2 个百分点,均值只显示 1.17。
- 忽略多重比较。12 次检验里的一次 p=0.0078,校正后不再显著。
- 用总体保留率掩盖弱能力。
code.review的 94.15% 会被其他能力平均掉。 - 把误命中当成「少量误差」。7 次错误交付的代价不会因为占比低而消失。
- 把实验结论直接外推到生产。段构成、窗口与流量比例都可能不同。
- 护栏被突破后改成「基本通过」。下一次评审会把边界当成既成事实。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。