上一节把用户确定性地分成了两组,但分组只是手段。真正要回答的是:实验组比对照组好 9‰,这个差异是真的,还是碰巧抽到了两组运气不同的用户?这一节把「显著」变成一个可计算的数字:自实现两比例 z 检验,再按基线率、最小可检测效应(MDE)、显著性水平 α 与统计功效算出「要多少样本才够」,最后用一个布朗运动仿真揭穿最常见、也最致命的陷阱——边跑边看 p 值。
D 层:把统计口径落成配置
下面这份是 D 层(静态层)产物,全整数。它把检验要用的每一个口径都写成参数:基线率、MDE、α、功效、每日每臂新增用户,以及一组假设的观测数据 observed 和偷看次数列表 peekLooks。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "显著性、样本量与偷看陷阱:两比例 z 检验(自实现 erf,不依赖 scipy)判「新频次是否显著改变 D7 留存」;按基线率 / 最小可检测效应(MDE) / 显著性水平 α / 统计功效算每臂最小样本量;再用布朗运动(信息时间)模拟「每来一批数据就看一次 p 值」如何把假阳性率抬高。observed 是一组假设的观测数据,真实转化率需真实业务环境,命令行不可复现(X)。全整数,禁浮点。",
"metric": "d7RetentionPermille",
"baselineRatePermille": 120,
"mdePermille": 12,
"alphaPermille": 50,
"powerPermille": 800,
"dailyUsersPerArm": 4000,
"observed": {
"control": { "n": 40000, "conv": 4800 },
"treat": { "n": 40000, "conv": 5160 }
},
"peekLooks": [1, 2, 3, 5, 10, 20],
"peekTrials": 20000
}
这里的口径要逐字读:baselineRatePermille 是现状的 D7 留存 120‰(12%),mdePermille 是你愿意接受被检验的最小变化 12‰(相对提升 10%),alphaPermille 50‰ 是双侧显著性水平 0.05,powerPermille 800‰ 是功效 0.8(即真实有效时,有 80% 的概率检出)。四个数一凑,样本量就唯一确定了。
S 层:z 检验、样本量与偷看陷阱
下面这段是 S 层仿真脚本,自实现 erf 与逆正态 probit(不依赖 scipy),做两比例 z 检验、算每臂最小样本量,再用布朗运动模拟「每看 k 次」的假阳性率,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/l08_significance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l08_significance.py --config config/ --out out/
"""显著性、样本量与偷看陷阱:两比例 z 检验(自实现 erf,不依赖 scipy)、
按基线率 / MDE / α / 功效算每臂最小样本量、用布朗运动模拟「每看一次 p 值」抬高假阳性率。
产出 JSON + CSV + SVG。真实转化率需真实业务环境,命令行不可复现(X)。"""
from __future__ import annotations
import argparse
import csv
import json
import math
import os
import random
import xml.etree.ElementTree as ET
SQRT2 = math.sqrt(2.0)
# Acklam 逆正态近似系数
_A = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00]
_B = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
6.680131188771972e+01, -1.328068155288572e+01]
_C = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
-2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00]
_D = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, 3.754408661907416e+00]
def load(d: str) -> dict:
with open(os.path.join(d, "l08_significance.json"), encoding="utf-8") as f:
return json.load(f)
def erf(x: float) -> float:
"""A&S 7.1.26,误差 <1.5e-7。与 TS 内核同一公式,逐位对齐。"""
sign = -1.0 if x < 0 else 1.0
t = 1.0 / (1.0 + 0.3275911 * abs(x))
y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t
- 0.284496736) * t + 0.254829592) * t * math.exp(-x * x)
return sign * y
def normal_cdf(z: float) -> float:
return 0.5 * (1.0 + erf(z / SQRT2))
def probit(p: float) -> float:
"""Acklam 逆正态近似。"""
if p < 0.02425 or p > 0.97575:
lo = p < 0.02425
q = math.sqrt(-2.0 * math.log(p if lo else 1.0 - p))
num = ((((_C[0] * q + _C[1]) * q + _C[2]) * q + _C[3]) * q + _C[4]) * q + _C[5]
den = (((_D[0] * q + _D[1]) * q + _D[2]) * q + _D[3]) * q + 1.0
return (num / den) if lo else -(num / den)
q = p - 0.5
r = q * q
return (((((_A[0] * r + _A[1]) * r + _A[2]) * r + _A[3]) * r + _A[4]) * r + _A[5]) * q / \
(((((_B[0] * r + _B[1]) * r + _B[2]) * r + _B[3]) * r + _B[4]) * r + 1.0)
def two_proportion_z(n1: int, c1: int, n2: int, c2: int) -> dict:
"""两比例 z 检验(合并比例),效应与 95% 置信区间以‰表示(绝对差)。"""
p1, p2 = c1 / n1, c2 / n2
p = (c1 + c2) / (n1 + n2)
se_pool = math.sqrt(p * (1.0 - p) * (1.0 / n1 + 1.0 / n2))
z = (p2 - p1) / se_pool if se_pool else 0.0
se_diff = math.sqrt(p1 * (1.0 - p1) / n1 + p2 * (1.0 - p2) / n2)
zc = probit(0.975)
return {"z": z, "pValue": 2.0 * (1.0 - normal_cdf(abs(z))),
"effectPermille": (p2 - p1) * 1000.0,
"ciLowPermille": (p2 - p1 - zc * se_diff) * 1000.0,
"ciHighPermille": (p2 - p1 + zc * se_diff) * 1000.0}
def sample_size_per_arm(p_permille: int, mde_permille: int, alpha_permille: int,
power_permille: int) -> int:
"""每臂最小样本量(两比例、双侧):ceil((z_{α/2}+z_β)² · 2p(1-p) / MDE²)。"""
p, mde = p_permille / 1000.0, mde_permille / 1000.0
za = probit(1.0 - (alpha_permille / 1000.0) / 2.0)
zb = probit(power_permille / 1000.0)
return math.ceil((za + zb) ** 2 * 2.0 * p * (1.0 - p) / (mde * mde))
def peek_false_positive(alpha_permille: int, looks: int, trials: int, rng) -> int:
"""z 在信息时间上是布朗运动:看 k 次就有 k 个相关检验,任一超临界即假阳性。"""
crit = probit(1.0 - (alpha_permille / 1000.0) / 2.0)
fp = 0
for _ in range(trials):
z, prev, hit = 0.0, 0.0, False
for i in range(1, looks + 1):
t = i / looks
z += rng.gauss(0.0, math.sqrt(t - prev))
prev = t
if abs(z) > crit:
hit = True
break
fp += 1 if hit else 0
return fp * 1000 // trials
def draw_svg(path: str, rows: list) -> int:
W, H, pl, pt, pb = 720, 320, 64, 34, 46
iw, ih = W - pl - 40, H - pt - pb
hi = max((r["falsePositivePermille"] for r in rows), default=1) or 1
n = len(rows)
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": str(pl), "y": "22", "font-size": "14",
"fill": "#111827"}).text = "偷看次数 vs 假阳性率(名义 α=50‰)"
for x1, y1, x2, y2 in [(pl, pt + ih, pl + iw, pt + ih), (pl, pt, pl, pt + ih)]:
ET.SubElement(svg, "line", {"x1": str(x1), "y1": str(y1), "x2": str(x2),
"y2": str(y2), "stroke": "#9ca3af"})
pts = [f'{pl + iw * i // (n - 1)},{pt + ih - ih * r["falsePositivePermille"] // hi}'
for i, r in enumerate(rows)]
ET.SubElement(svg, "polyline", {"points": " ".join(pts), "fill": "none",
"stroke": "#2563eb", "stroke-width": "2"})
for i, r in enumerate(rows):
x = pl + iw * i // (n - 1)
y = pt + ih - ih * r["falsePositivePermille"] // hi
ET.SubElement(svg, "circle", {"cx": str(x), "cy": str(y), "r": "3", "fill": "#2563eb"})
ET.SubElement(svg, "text", {"x": str(x), "y": str(pt + ih + 18), "font-size": "11",
"fill": "#374151", "text-anchor": "middle"}).text = f'{r["looks"]}次'
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
cfg = load(a.config)
obs = cfg["observed"]
test = two_proportion_z(obs["control"]["n"], obs["control"]["conv"],
obs["treat"]["n"], obs["treat"]["conv"])
need = sample_size_per_arm(cfg["baselineRatePermille"], cfg["mdePermille"],
cfg["alphaPermille"], cfg["powerPermille"])
rng = random.Random(cfg["seed"])
peek = [{"looks": k, "falsePositivePermille":
peek_false_positive(cfg["alphaPermille"], k, cfg["peekTrials"], rng)}
for k in cfg["peekLooks"]]
days = (need + cfg["dailyUsersPerArm"] - 1) // cfg["dailyUsersPerArm"]
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "l08_significance.svg"), peek)
with open(os.path.join(a.out, "l08_significance.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["looks", "falsePositivePermille"])
for r in peek:
w.writerow([r["looks"], r["falsePositivePermille"]])
rep = {"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"], "metric": cfg["metric"],
"baselineRatePermille": cfg["baselineRatePermille"], "mdePermille": cfg["mdePermille"],
"alphaPermille": cfg["alphaPermille"], "powerPermille": cfg["powerPermille"],
"sampleSizePerArm": need, "dailyUsersPerArm": cfg["dailyUsersPerArm"], "daysNeeded": days,
"observed": {"z": round(test["z"], 4), "pValue": round(test["pValue"], 6),
"effectPermille": round(test["effectPermille"], 2),
"ciLowPermille": round(test["ciLowPermille"], 2),
"ciHighPermille": round(test["ciHighPermille"], 2),
"significant": test["pValue"] < cfg["alphaPermille"] / 1000.0},
"peeking": peek, "csv": os.path.join(a.out, "l08_significance.csv"),
"svg": os.path.join(a.out, "l08_significance.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "l08_significance.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"sampleSizePerArm": need, "daysNeeded": days, "observedZ": rep["observed"]["z"],
"observedP": rep["observed"]["pValue"], "effectPermille": rep["observed"]["effectPermille"],
"significant": rep["observed"]["significant"],
"peeking": [(r["looks"], r["falsePositivePermille"]) for r in peek]},
ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出如下(仿真数值为同机相对量级,绝对数值不可移植):
{"sampleSizePerArm": 11512, "daysNeeded": 3, "observedZ": 3.8552, "observedP": 0.000116, "effectPermille": 9.0, "significant": true, "peeking": [[1, 49], [2, 53], [3, 54], [5, 59], [10, 68], [20, 73]]}
读这三组数字。样本量:基线 120‰、MDE 12‰、α 50‰、功效 800‰ → 每臂 11512 人;按每天每臂 4000 人,需 3 天。观测:对照 4800/40000 = 120‰、实验 5160/40000 = 129‰,差 9‰,z ≈ 3.855,p ≈ 0.000116 < 0.05 → 显著。偷看:只 1 次看,假阳性率 49‰(约等于名义 α 50‰);但看 20 次,假阳性率涨到 73‰——明明没有真实效应,也有近 1.5 倍于名义值的概率误报「显著」。
这就是本节最重要的陷阱:p 值不是一个「随时可读」的仪表盘。每次看都做一次检验,看 k 次就有 k 个彼此相关的检验,任一次越过临界值就宣布「成功」,等于把 α 偷偷放大了。正确做法是事先锁定样本量或实验时长,到点才看一次;如果确实要中途监控,就要用序贯检验或 alpha-spending 之类的修正方法,而不是盯着 p 值看它什么时候跌破 0.05。
P 层:同口径的显著性内核
仿真验证了口径,但服务端/看板要实时算这些量,就需要一段无依赖的内核。下面这段是 P 层内核 core/src/l08/stats.ts,与 Python 脚本同一套 erf / probit / z 检验公式、同一个样本量公式,还顺带给出 8.3 要用的决策函数 decide。
// file: core/src/l08/stats.ts
// tier: P
// verify: node --test 'core/test/l08_*.test.ts'
// 《合成小岛》显著性内核:两比例 z 检验、每臂最小样本量、决策规则。
// 自实现 erf(A&S 7.1.26)与 probit(Acklam),不依赖 scipy;与 sim/l08_significance.py 同公式。
// 浮点内核(统计量本就需要 sqrt/log),无 Math.random()、只用可擦除语法。
const SQRT2 = Math.SQRT2;
/** Abramowitz-Stegun 7.1.26 erf 近似,误差 <1.5e-7。 */
export function erf(x: number): number {
const sign = x < 0 ? -1 : 1;
const ax = Math.abs(x);
const t = 1 / (1 + 0.3275911 * ax);
const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t
- 0.284496736) * t + 0.254829592) * t * Math.exp(-ax * ax);
return sign * y;
}
export const normalCdf = (z: number): number => 0.5 * (1 + erf(z / SQRT2));
/** Acklam 逆正态近似(probit)。 */
export function probit(p: number): number {
const a = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00];
const b = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, 6.680131188771972e+01, -1.328068155288572e+01];
const c = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00];
const d = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, 3.754408661907416e+00];
if (p < 0.02425) {
const q = Math.sqrt(-2 * Math.log(p));
return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
}
if (p > 1 - 0.02425) {
const q = Math.sqrt(-2 * Math.log(1 - p));
return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
}
const q = p - 0.5, r = q * q;
return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q
/ (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
}
export interface ZResult {
z: number; pValue: number; effectPermille: number; ciLowPermille: number; ciHighPermille: number;
}
/** 两比例 z 检验(合并比例);效应与置信区间以‰表示(绝对差)。 */
export function twoProportionZ(n1: number, c1: number, n2: number, c2: number, zCrit: number): ZResult {
const p1 = c1 / n1, p2 = c2 / n2, p = (c1 + c2) / (n1 + n2);
const sePool = Math.sqrt(p * (1 - p) * (1 / n1 + 1 / n2));
const z = sePool === 0 ? 0 : (p2 - p1) / sePool;
const seDiff = Math.sqrt(p1 * (1 - p1) / n1 + p2 * (1 - p2) / n2);
return {
z, pValue: 2 * (1 - normalCdf(Math.abs(z))), effectPermille: (p2 - p1) * 1000,
ciLowPermille: (p2 - p1 - zCrit * seDiff) * 1000,
ciHighPermille: (p2 - p1 + zCrit * seDiff) * 1000,
};
}
/** 每臂最小样本量(两比例、双侧):ceil((z_{α/2}+z_β)² · 2p(1-p) / MDE²)。 */
export function sampleSizePerArm(pPermille: number, mdePermille: number, alphaPermille: number, powerPermille: number): number {
const p = pPermille / 1000, mde = mdePermille / 1000;
const zA = probit(1 - (alphaPermille / 1000) / 2), zB = probit(powerPermille / 1000);
return Math.ceil(Math.pow(zA + zB, 2) * 2 * p * (1 - p) / (mde * mde));
}
export type Decision = "ship" | "iterate" | "keep_running" | "stop";
/** 决策规则:护栏优先 → 显著性 → 方向 → 效应量。 */
export function decide(pValue: number, effectPermille: number, minEffectPermille: number,
alphaPermille: number, guardrailBreached: boolean): Decision {
if (guardrailBreached) return "stop";
if (pValue >= alphaPermille / 1000) return "keep_running";
if (effectPermille <= 0) return "stop";
if (effectPermille < minEffectPermille) return "iterate";
return "ship";
}
注意 sampleSizePerArm 里的 Math.pow(zA + zB, 2) 与 Python 的 (za + zb) ** 2 是同一个式子:z_{α/2} 由 probit(1 - α/2) 给出、z_β 由 probit(功效) 给出。本机跑 node --test 'core/test/l08_*.test.ts',sampleSizePerArm(120, 12, 50, 800) 断言为 11512,twoProportionZ(40000, 4800, 40000, 5160, 1.96) 断言 z≈3.855、p≈0.000116、效应 9‰,与 Python 同口径。
小结与下一步
这一节把「显著」拆成三个可计算的量:检验(z 与 p)、样本量(由基线率、MDE、α、功效唯一确定)、陷阱(偷看把假阳性率从 50‰ 抬到 73‰)。有了这三个量,「这个差异是真的吗」就不再靠感觉。
但显著性只回答「是不是真的」,不回答「值不值得上」。一个显著但只提升 2‰ 的改动,可能连开发成本都收不回。下一节把显著性、效应量与护栏合并成一张决策规则表,并用一个可交互的 W 层看板把它跑起来。
阅读导航:上一节:实验设计与分流 · 下一节:从实验到决策 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。