13.3 从实验到决策

统计显著不等于值得上线。本节把一次实验结果压成可执行的决策:算提升点估计、两比例 z 检验的 p 值、差值的 95% 置信区间与当前样本量下的最小可检测效应,再用业务门槛与护栏指标给出「回滚 / 继续观察 / 全量」的判定,并算出要越过门槛还差多少样本。

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 的 MDE15.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:

每组 nMDE(‰)
100035.53
200025.12
500015.89
1000011.24
200007.94
500005.02

n 从 5000 翻到 20000(4 倍),MDE 从 15.89‰ 降到 7.94‰(正好一半)——与 1/√n 完全吻合。这张表在排期时非常有用:想知道「这个实验大概能看见多小的差异」,查表即可;反过来,想检出 8‰ 的差异,至少每组 20000 人。

护栏:主指标之外的否决权

判定逻辑里,护栏有一票否决权:只要护栏越界,无论主指标多好,一律回滚。本套的两个护栏:

护栏对照组(‰)实验组(‰)差值容忍带是否越界
D7 留存300292−8±10否
单次会话时长10001010+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 赛季结构与节奏 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引