13.2 解决的是「差异是不是真的」。但真不代表该上——统计显著(p < 0.05)和业务显著(提升大到值得全量)是两回事。一个把付费转化率从 80‰ 抬到 80.5‰ 的改动,样本够大时也能 p < 0.05,可它值得为它改一版客户端、承担上线风险吗?多半不值得。本节把 13.2 的统计输出,接上一个决策层:把结果压成「回滚 / 继续观察 / 全量」三选一。
决策不能靠感觉。本套的做法是把一次实验的观测结果,连同业务门槛和护栏指标一起喂进一个脚本,让它吐出判定。
决策需要的五个量
除了 p 值,决策还依赖四个量:
- 点估计(提升大小):实验组比对照组高多少。这是「值不值得」的基础。
- 置信区间(CI):提升的不确定范围。点估计是 15.6‰,但真值可能在 4‰ 到 27‰ 之间——决策要看下界,不看点估计。
- 最小可检测效应(MDE):当前样本量能可靠检出的最小差异。它告诉你「这次实验的分辨率有多细」。
- 业务门槛:多小的提升算「不值得做」。本套设 10‰(提升不足 10‰ 就不值得全量)。
- 护栏指标:主指标之外、不能被改坏的指标(留存、时长)。护栏越界,主指标再好也要回滚。
仿真:把一次实验结果压成判定
下面这段是 S 层决策脚本,读 config/n13_experiment.json 的观测结果(对照组 402/5000、实验组 480/5000)与业务门槛,算出上面五个量,并给出判定:
# file: sim/n13_decision.py
# tier: S
# verify: python3 sim/n13_decision.py --config config/ --out out/
"""从实验到决策(13.3):把一次实验结果压成一个可执行的决策。
对一次实验的观测结果,算:
- 提升点估计、两比例 z 检验的 p 值、差值的 95% 置信区间(不合并方差)。
- 当前样本量下的最小可检测效应 MDE。
- 「让置信区间下界越过业务门槛」所需的每组样本量。
- 护栏指标是否越界。
- 决策判定:回滚 / 继续观察 / 全量。
产出 out/n13_decision.json + out/n13_decision.csv + out/n13_decision.svg。
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from n13_stats import norm_cdf, norm_ppf, two_prop_z # noqa: E402
from svg_line import render # noqa: E402
def mde_permille(p: float, n: int, alpha: float, power: float) -> float:
"""等样本量下、当前 n 能检出的最小比例差(千分比)。"""
za = norm_ppf(1.0 - alpha / 2.0)
zb = norm_ppf(power)
return (za + zb) * math.sqrt(2.0 * p * (1.0 - p) / n) * 1000.0
def n_for_ci_lower(diff: float, p: float, threshold: float, alpha: float) -> int:
"""让差值置信区间下界 >= threshold 所需的每组样本量(不合并方差近似)。"""
z = norm_ppf(1.0 - alpha / 2.0)
slack = diff - threshold
if slack <= 0:
return -1 # 点估计都没过门槛,加样本也没用
return math.ceil(2.0 * p * (1.0 - p) * z * z / (slack * slack))
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.config, "n13_experiment.json"), encoding="utf-8") as fh:
cfg = json.load(fh)
ex = cfg["experiment"]
dc = cfg["decision"]
alpha = ex["alphaPermille"] / 1000.0
power = ex["powerPermille"] / 1000.0
o = dc["observed"]
c1, n1, c2, n2 = o["controlConv"], o["controlN"], o["treatConv"], o["treatN"]
p1, p2 = c1 / n1, c2 / n2
diff = p2 - p1
diff_permille = round(diff * 1000, 2)
rel_lift_permille = round(diff / p1 * 1000, 1)
z, pval = two_prop_z(c1, n1, c2, n2)
z = float(z)
pval = float(pval)
# 差值的 95% CI(不合并方差)
se = math.sqrt(p1 * (1 - p1) / n1 + p2 * (1 - p2) / n2)
zc = norm_ppf(1.0 - alpha / 2.0)
ci_lo = (diff - zc * se) * 1000.0
ci_hi = (diff + zc * se) * 1000.0
p_pool = (c1 + c2) / (n1 + n2)
mde = mde_permille(p_pool, min(n1, n2), alpha, power)
threshold = dc["minLiftPermille"] / 1000.0
n_needed = n_for_ci_lower(diff, p_pool, threshold, alpha)
# 护栏
guardrails = []
gr_ok = True
for g in dc["guardrails"]:
delta = g["treatPermille"] - g["controlPermille"]
breach = abs(delta) > g["tolerancePermille"]
gr_ok = gr_ok and not breach
guardrails.append({"id": g["id"], "deltaPermille": delta,
"tolerancePermille": g["tolerancePermille"], "breach": breach})
significant = pval < alpha
if not significant:
verdict = "hold" # 不显著:继续观察 / 延长实验
elif diff < 0:
verdict = "rollback" # 显著为负:回滚
elif not gr_ok:
verdict = "rollback" # 护栏越界:回滚
elif ci_lo / 1000.0 >= threshold:
verdict = "ship" # 显著为正、护栏正常、CI 下界过门槛:全量
else:
verdict = "hold" # 显著为正,但还不能确信超过业务门槛
# 样本量 vs MDE 曲线(用于 SVG 与 CSV)
curve_n = [1000, 2000, 5000, 10000, 20000, 50000]
curve = [{"nPerArm": n, "mdePermille": round(mde_permille(p_pool, n, alpha, power), 2)} for n in curve_n]
with open(os.path.join(args.out, "n13_decision.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["nPerArm", "mdePermille"])
for r in curve:
w.writerow([r["nPerArm"], r["mdePermille"]])
render([r["mdePermille"] for r in curve], os.path.join(args.out, "n13_decision.svg"),
"MDE permille vs n per arm")
report = {
"version": 1,
"controlRatePermille": round(p1 * 1000, 2), "treatRatePermille": round(p2 * 1000, 2),
"diffPermille": diff_permille, "relativeLiftPermille": rel_lift_permille,
"z": round(z, 4), "pValue": round(pval, 6), "significant": significant,
"ciLowPermille": round(ci_lo, 2), "ciHighPermille": round(ci_hi, 2),
"mdePermille": round(mde, 2), "minLiftPermille": dc["minLiftPermille"],
"nPerArm": min(n1, n2), "nPerArmForThreshold": n_needed,
"guardrails": guardrails, "guardrailsOk": gr_ok,
"verdict": verdict, "curve": curve,
}
with open(os.path.join(args.out, "n13_decision.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 out/n13_decision.json(观测:对照 402/5000、实验 480/5000):
| 量 | 值 |
|---|---|
| 对照组转化率 | 80.4‰ |
| 实验组转化率 | 96.0‰ |
| 提升(绝对 / 相对) | +15.6‰ / +194.0‰ |
| z / p 值 | −2.7505 / 0.005951 |
| 差值 95% CI | [4.49‰, 26.71‰] |
| 当前 n 的 MDE | 15.89‰ |
| 业务门槛 | 10‰ |
| 判定 | hold(继续观察) |
为什么是 hold,不是 ship
这张表里有一组耐人寻味的对照:
- p = 0.006 < 0.05,统计上显著。 提升是真的,不是噪声。
- 但 CI 下界只有 4.49‰,低于业务门槛 10‰。 也就是说,真值有相当概率落在「不值得做」的区间里。
- 当前 n 的 MDE 是 15.89‰,而观测到的提升是 15.6‰。 两者几乎相等——这次实验的分辨率,刚好卡在它能检出的边界上。观测效应比 MDE 还略小,意味着这个实验勉强能看见它,但看不清它到底多大。
三个数合起来,判定就是 hold:显著,但还不足以确信提升越过业务门槛。这时候正确动作不是「显著了就上」,而是「再收样本,把 CI 收窄到下界越过 10‰」。
要多少样本才能拍板
脚本还算了 nPerArmForThreshold:要让 CI 下界越过 10‰ 门槛,每组需要 19703 人——是当前 5000 人的约 4 倍。这个 4 倍从哪来?CI 宽度正比于 1/√n,要把下界从 4.49‰ 抬到 10‰,需要把 CI 半宽从约 11.1‰ 压到约 5.6‰,即压到一半,√n 要翻倍,n 要翻 4 倍。样本量对结论精度的贡献是平方根级的——想看清一半的差异,要付出 4 倍的成本。
脚本同时输出 MDE 随 n 的变化曲线 out/n13_decision.svg:
| 每组 n | MDE(‰) |
|---|---|
| 1000 | 35.53 |
| 2000 | 25.12 |
| 5000 | 15.89 |
| 10000 | 11.24 |
| 20000 | 7.94 |
| 50000 | 5.02 |
n 从 5000 翻到 20000(4 倍),MDE 从 15.89‰ 降到 7.94‰(正好一半)——与 1/√n 完全吻合。这张表在排期时非常有用:想知道「这个实验大概能看见多小的差异」,查表即可;反过来,想检出 8‰ 的差异,至少每组 20000 人。
护栏:主指标之外的否决权
判定逻辑里,护栏有一票否决权:只要护栏越界,无论主指标多好,一律回滚。本套的两个护栏:
| 护栏 | 对照组(‰) | 实验组(‰) | 差值 | 容忍带 | 是否越界 |
|---|---|---|---|---|---|
| D7 留存 | 300 | 292 | −8 | ±10 | 否 |
| 单次会话时长 | 1000 | 1010 | +10 | ±10 | 否 |
两个护栏都在容忍带内,所以没有触发回滚。注意 session_min 的差值正好是 +10‰、容忍带也是 ±10‰——边界是闭区间(|10| > 10 为假),判为不越界。护栏的容忍带要在实验前定死,不能事后看着数据调整——那又变成了一种 peeking。
决策矩阵
把上面的逻辑整理成一张决策矩阵,就是 13.3 的核心:
| 统计显著 | 业务显著(CI 下界过门槛) | 护栏 | 判定 |
|---|---|---|---|
| 否 | — | — | hold:继续观察 / 延长实验 |
| 是(负向) | — | — | rollback:回滚 |
| 是(正向) | 否 | 正常 | hold:再收样本(本例) |
| 是(正向) | 是 | 正常 | ship:全量 |
| — | — | 越界 | rollback:回滚 |
关键在第三行:「显著但业务不显著」既不是 ship 也不是 rollback,而是 hold。这是最容易被运营跳过的一格——他们要么「显著就上」(把 4‰ 的微弱提升全量),要么「不确定就撤」(把真有价值的改动砍掉)。hold 的价值在于:它把「需要更多证据」变成一个明确的、有量化目标(n=19703)的待办,而不是一个含糊的「再看看」。
小结
本节把 13.2 的统计输出接上了决策层:用点估计、置信区间、MDE、业务门槛和护栏,把一次实验压成「回滚 / 继续观察 / 全量」三选一。本例的结论是一个典型且反直觉的 hold——p = 0.006 显著,但 CI 下界 4.49‰ 没过 10‰ 门槛,需要约 4 倍样本(19703/组)才能拍板。到这里,「怎么证明改对了」这条链路——分流(13.1)、显著性(13.2)、决策(13.3)——就闭环了。
决策落地之后,下一个问题是节奏:什么时候做实验、什么时候做活动、一个赛季该怎么排布。这是第 14 章的主题。
阅读导航:上一节:13.2 显著性、样本量与常见陷阱 · 下一节:14.1 赛季结构与节奏 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。