8.3 从实验到决策

显著性只回答「差异是不是真的」,不回答「值不值得上」。这一节把护栏指标(ARPDAU、崩溃率)、显著性、效应量合成一张决策规则表:护栏破了一票否决,不显著继续观察,显著但变差就停,显著但效应不足则迭代,达标才 ship;给出可跑的 Python 决策脚本与一个可交互的 W 层看板。

前两节解决了「怎么分」和「怎么判」,但一个显著的结果不等于该上线。这一节把三样东西合成一张决策规则表:护栏(不能变坏的底线指标)、显著性(差异是不是真的)、效应量(值不值得做),让「这次改动上不上」有据可依。

D 层:把决策规则落成配置

下面这份是 D 层(静态层)产物,全整数。它定义主指标、最小效应量、显著性水平、护栏清单(ARPDAU 允许下跌幅度、崩溃率允许上涨幅度),以及四个待决策实验的观测数据与护栏实测值。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "从实验到决策:把一组实验结果喂进一张决策规则表——先看护栏(guardrail)有没有破,再看显著性,再看效应是否达到最小可检测效应,最后才判 ship/iterate/keep_running/stop。护栏含 ARPDAU(分,允许下跌 maxDropPermille‰)与崩溃率(‰,允许上涨 maxRisePermille‰)。每个实验的观测数、转化数、护栏实测值都是假设数据,真实值需真实业务环境,命令行不可复现(X)。全整数,禁浮点。",
  "primaryMetric": "d7RetentionPermille",
  "minEffectPermille": 5,
  "alphaPermille": 50,
  "zCritMilli": 1960,
  "guardrails": [
    { "id": "arpdauCent", "name": "ARPDAU(分)", "baseline": 24, "maxDropPermille": 50 },
    { "id": "crashPermille", "name": "崩溃率‰", "baseline": 3, "maxRisePermille": 1000 }
  ],
  "experiments": [
    { "id": "exp1", "name": "插屏降频次", "n1": 40000, "conv1": 4800, "n2": 40000, "conv2": 5160, "guardrailMeasured": { "arpdauCent": 23, "crashPermille": 3 } },
    { "id": "exp2", "name": "首屏奖励翻倍", "n1": 40000, "conv1": 4800, "n2": 40000, "conv2": 4850, "guardrailMeasured": { "arpdauCent": 24, "crashPermille": 3 } },
    { "id": "exp3", "name": "强弹窗召回", "n1": 40000, "conv1": 4800, "n2": 40000, "conv2": 4980, "guardrailMeasured": { "arpdauCent": 19, "crashPermille": 3 } },
    { "id": "exp4", "name": "新手指引重写", "n1": 40000, "conv1": 4800, "n2": 40000, "conv2": 4620, "guardrailMeasured": { "arpdauCent": 24, "crashPermille": 4 } }
  ]
}

S 层:一张决策规则表

下面这段是 S 层脚本,读 config/l08_decision.json,对每个实验先查护栏、再做 z 检验、最后按「护栏 → 显著 → 方向 → 效应量」的顺序给结论,产出 JSON + CSV + SVG;在本机跑它,四个实验正好给出四种结论(仿真数值为同机相对量级,绝对数值不可移植):

#!/usr/bin/env python3
# file: sim/l08_decision.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l08_decision.py --config config/ --out out/
"""从实验到决策:读 config/l08_decision.json,把一组实验结果喂进一张决策规则表。
顺序:① 护栏(ARPDAU 下跌 / 崩溃率上涨)破了 → stop;② 主指标不显著 → keep_running;
③ 显著但变差 → stop;④ 显著但效应不足 minEffectPermille → iterate;⑤ 否则 ship。
产出 JSON + CSV + SVG。所有观测数据均为假设值,真实值需真实业务环境,命令行不可复现(X)。"""
from __future__ import annotations
import argparse
import csv
import json
import math
import os
import xml.etree.ElementTree as ET

from l08_significance import two_proportion_z

COLOR = {"ship": "#16a34a", "iterate": "#f59e0b", "keep_running": "#94a3b8", "stop": "#dc2626"}

def load(d: str) -> dict:
    with open(os.path.join(d, "l08_decision.json"), encoding="utf-8") as f:
        return json.load(f)

def guardrail_breach(cfg: dict, measured: dict) -> tuple:
    """逐个护栏判断是否越界;返回 (是否破护栏, 越界明细)。"""
    hits = []
    for g in cfg["guardrails"]:
        val = measured.get(g["id"], g["baseline"])
        if "maxDropPermille" in g and val < g["baseline"] * (1000 - g["maxDropPermille"]) // 1000:
            hits.append({"id": g["id"], "value": val, "kind": "drop"})
        if "maxRisePermille" in g and val > g["baseline"] * (1000 + g["maxRisePermille"]) // 1000:
            hits.append({"id": g["id"], "value": val, "kind": "rise"})
    return bool(hits), hits

def decide(cfg: dict, z: dict, breached: bool) -> str:
    if breached:
        return "stop"
    if z["pValue"] >= cfg["alphaPermille"] / 1000.0:
        return "keep_running"
    if z["effectPermille"] <= 0:
        return "stop"
    if z["effectPermille"] < cfg["minEffectPermille"]:
        return "iterate"
    return "ship"

def draw_svg(path: str, rows: list, min_effect: int) -> int:
    W, PAD, ROW = 720, 210, 46
    H, span = 70 + ROW * len(rows), 720 - 210 - 60
    peak = max((abs(r["effectPermille"]) for r in rows), default=1) or 1
    zero = PAD + span // 2
    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": "16", "y": "28", "font-size": "15", "fill": "#111827"}).text = \
        f"各实验的效应(‰)与决策(虚线 = 最小效应 {min_effect}‰)"
    for x, color, dash in ((zero, "#9ca3af", None), (zero + span * min_effect // (2 * peak), "#f59e0b", "4 3")):
        attr = {"x1": str(x), "y1": "44", "x2": str(x), "y2": str(50 + ROW * len(rows)),
                "stroke": color}
        if dash:
            attr["stroke-dasharray"] = dash
        ET.SubElement(svg, "line", attr)
    for i, r in enumerate(rows):
        y = 54 + i * ROW
        w = span * abs(r["effectPermille"]) // (2 * peak)
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
                                    "fill": "#374151"}).text = r["name"]
        ET.SubElement(svg, "rect", {"x": str(zero if r["effectPermille"] >= 0 else zero - w),
                                    "y": str(y + 6), "width": str(w), "height": "22",
                                    "rx": "4", "fill": COLOR[r["decision"]]})
        ET.SubElement(svg, "text", {"x": str(zero + span // 2 + 6), "y": str(y + 22), "font-size": "12",
                                    "fill": "#6b7280"}).text = f'{r["effectPermille"]}‰ · {r["decision"]}'
    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)
    zc = cfg["zCritMilli"] / 1000.0
    rows = []
    for e in cfg["experiments"]:
        z = two_proportion_z(e["n1"], e["conv1"], e["n2"], e["conv2"])
        p1, p2 = e["conv1"] / e["n1"], e["conv2"] / e["n2"]
        se = math.sqrt(p1 * (1 - p1) / e["n1"] + p2 * (1 - p2) / e["n2"])
        eff = z["effectPermille"] / 1000.0
        breached, hits = guardrail_breach(cfg, e["guardrailMeasured"])
        rows.append({"id": e["id"], "name": e["name"], "effectPermille": round(z["effectPermille"], 2),
                     "pValue": round(z["pValue"], 6),
                     "ciLowPermille": round((eff - zc * se) * 1000, 2),
                     "ciHighPermille": round((eff + zc * se) * 1000, 2),
                     "guardrailBreach": breached, "guardrailHits": hits,
                     "decision": decide(cfg, z, breached)})
    summary = {d: sum(1 for r in rows if r["decision"] == d)
               for d in ("ship", "iterate", "keep_running", "stop")}
    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "l08_decision.svg"), rows, cfg["minEffectPermille"])
    with open(os.path.join(a.out, "l08_decision.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "effectPermille", "pValue", "ciLowPermille",
                    "ciHighPermille", "guardrailBreach", "decision"])
        for r in rows:
            w.writerow([r["id"], r["name"], r["effectPermille"], r["pValue"],
                        r["ciLowPermille"], r["ciHighPermille"], r["guardrailBreach"], r["decision"]])
    rep = {"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"],
           "primaryMetric": cfg["primaryMetric"], "minEffectPermille": cfg["minEffectPermille"],
           "alphaPermille": cfg["alphaPermille"], "experiments": rows, "summary": summary,
           "csv": os.path.join(a.out, "l08_decision.csv"),
           "svg": os.path.join(a.out, "l08_decision.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "l08_decision.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"summary": summary,
                      "decisions": [(r["id"], r["effectPermille"], r["pValue"],
                                     r["guardrailBreach"], r["decision"]) for r in rows]},
                     ensure_ascii=False))
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

在本机跑它,四个实验给出四种结论(仿真数值为同机相对量级,绝对数值不可移植):

{"summary": {"ship": 1, "iterate": 0, "keep_running": 1, "stop": 2}, "decisions": [["exp1", 9.0, 0.000116, false, "ship"], ["exp2", 1.25, 0.587285, false, "keep_running"], ["exp3", 4.5, 0.052046, true, "stop"], ["exp4", -4.5, 0.048328, false, "stop"]]}

逐条读:exp1 效应 +9‰、p≈0.000116、护栏没破 → ship;exp2 效应仅 +1.25‰、p≈0.587 不显著 → keep_running(样本不够,继续跑);exp3 效应 +4.5‰ 且护栏破了(ARPDAU 从 24 跌到 19,超过允许的 5% 跌幅)→ stop,哪怕主指标是正的也一票否决;exp4 效应 -4.5‰ 显著为负 → stop。四种结论各对应一条规则,这正是决策表的用处:把「该不该上」变成一张查表。

W 层:可交互的决策看板

把上面的内核打包成一个能在浏览器里拖动的看板,能直观看到「样本量、p 值、决策」怎么联动。下面两段是 W 层构建脚本与 Playwright 无头验收脚本。

#!/usr/bin/env node
// file: web/build_l08_experiment.mjs
// tier: W
// verify: node web/build_l08_experiment.mjs
// 把 core/src/l08/split.ts + core/src/l08/stats.ts 打成单文件 HTML(A/B 实验决策看板)。复用 pack.mjs 的擦除逻辑。

import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const experiment = JSON.parse(readFileSync(join(ROOT, "config", "l08_experiment.json"), "utf8"));
const significance = JSON.parse(readFileSync(join(ROOT, "config", "l08_significance.json"), "utf8"));
const cfg = { experiment, significance };
const js = bundle(ROOT, ["core/src/l08/split.ts", "core/src/l08/stats.ts"]);
const script = `const cfg = ${inlineConfig(cfg)};
${js}
const expCfg = cfg.experiment, sigCfg = cfg.significance;
const cv = document.getElementById("stage"), g = cv.getContext("2d");
let state = { baselinePermille: sigCfg.baselineRatePermille, mdePermille: sigCfg.mdePermille,
  alphaPermille: sigCfg.alphaPermille, powerPermille: sigCfg.powerPermille,
  nPerArm: sigCfg.observed.control.n, controlPermille: 120, treatPermille: 129 };
let d = null;
function compute() {
  const requiredPerArm = sampleSizePerArm(state.baselinePermille, state.mdePermille, state.alphaPermille, state.powerPermille);
  const daysNeeded = Math.ceil(requiredPerArm / sigCfg.dailyUsersPerArm);
  const c1 = Math.floor(state.nPerArm * state.controlPermille / 1000);
  const c2 = Math.floor(state.nPerArm * state.treatPermille / 1000);
  const zr = twoProportionZ(state.nPerArm, c1, state.nPerArm, c2, 1.96);
  const decision = decide(zr.pValue, zr.effectPermille, 5, state.alphaPermille, false);
  const t = tallyArms(expCfg, expCfg.populationUsers);
  const srm = srmTally(expCfg, t.assigned, t.logged);
  const maxDev = Math.max(...srm.map((r) => r.deviationPermille));
  d = { requiredPerArm, daysNeeded, c1, c2, z: zr.z, pValue: zr.pValue, effectPermille: zr.effectPermille,
    ciLow: zr.ciLowPermille, ciHigh: zr.ciHighPermille, decision, srm, maxDev,
    srmPass: maxDev <= expCfg.srmTolerancePermille, assigned: t.assigned, logged: t.logged };
  draw();
}
function draw() {
  const W = cv.width, H = cv.height;
  g.clearRect(0, 0, W, H);
  g.font = "13px system-ui";
  const maxN = Math.max(state.nPerArm, d.requiredPerArm, 1), bw = W - 210;
  g.fillStyle = "#e2e8f0"; g.fillText("样本进度", 8, 18); g.fillStyle = "#94a3b8"; g.fillText("已有 " + state.nPerArm, 8, 46);
  g.fillStyle = "#38bdf8"; g.fillRect(96, 30, Math.floor(bw * state.nPerArm / maxN), 20);
  g.fillStyle = "#94a3b8"; g.fillText("所需 " + d.requiredPerArm, 8, 78); g.fillStyle = "#334155"; g.fillRect(96, 62, Math.floor(bw * d.requiredPerArm / maxN), 20);
  g.fillStyle = "#e2e8f0"; g.fillText("效应 95% CI (‰)", 8, 112);
  const span = Math.max(Math.abs(d.ciHigh), Math.abs(d.ciLow), 1) * 1.2, cx = W / 2, sc = (W - 80) / (2 * span);
  g.strokeStyle = "#475569"; g.beginPath(); g.moveTo(cx - span * sc, 130); g.lineTo(cx + span * sc, 130); g.stroke();
  g.strokeStyle = "#9ca3af"; g.beginPath(); g.moveTo(cx, 122); g.lineTo(cx, 138); g.stroke();
  g.strokeStyle = d.decision === "ship" ? "#16a34a" : d.decision === "stop" ? "#dc2626" : "#f59e0b";
  g.lineWidth = 3; g.beginPath(); g.moveTo(cx + d.ciLow * sc, 130); g.lineTo(cx + d.ciHigh * sc, 130); g.stroke();
  g.lineWidth = 1; g.fillStyle = "#38bdf8";
  g.beginPath(); g.arc(cx + d.effectPermille * sc, 130, 4, 0, 6.3); g.fill();
  g.fillStyle = "#94a3b8"; g.fillText("0", cx - 4, 152);
  document.getElementById("required").textContent = String(d.requiredPerArm);
  document.getElementById("days").textContent = String(d.daysNeeded);
  document.getElementById("pval").textContent = d.pValue.toFixed(4);
  document.getElementById("decision").textContent = d.decision;
  document.getElementById("srmdev").textContent = d.maxDev + "‰" + (d.srmPass ? " (通过)" : " (SRM 未通过)");
  globalThis.__pebble = { requiredPerArm: d.requiredPerArm, daysNeeded: d.daysNeeded, z: d.z, pValue: d.pValue,
    effectPermille: d.effectPermille, decision: d.decision, maxDev: d.maxDev, srmPass: d.srmPass,
    assigned: d.assigned, logged: d.logged, canvasDrawn: true, api };
}
const api = {
  stats(s) { Object.assign(state, s); compute(); return { requiredPerArm: d.requiredPerArm, daysNeeded: d.daysNeeded }; },
  evaluate(s) { Object.assign(state, s); compute(); return { z: d.z, pValue: d.pValue, effectPermille: d.effectPermille, decision: d.decision }; },
  split() { return { assigned: d.assigned, logged: d.logged, maxDev: d.maxDev, srmPass: d.srmPass }; },
  get() { return { requiredPerArm: d.requiredPerArm, pValue: d.pValue, effectPermille: d.effectPermille, decision: d.decision, maxDev: d.maxDev }; },
};
compute();
`;
const body = `  <h1>《合成小岛》· A/B 实验决策看板</h1>
  <div class="sub">分流内核 core/src/l08/split.ts + 显著性内核 core/src/l08/stats.ts:全整数哈希 + 自实现 z 检验,无 scipy、无 Math.random</div>
  <div class="card row"><div><div class="k">每臂最小样本量</div><div class="big" id="required">0</div></div><div><div class="k">预计天数</div><div class="big" id="days">0</div></div><div><div class="k">p 值</div><div class="big" id="pval">0</div></div><div><div class="k">决策</div><div class="big" id="decision">-</div></div></div>
  <div class="card"><canvas id="stage" width="480" height="170"></canvas></div>
  <div class="sub">SRM 偏差:<span id="srmdev">-</span>(分配均衡,但 treat 臂日志丢失 30‰ 后样本比例失衡)</div>`;
const bytes = writePage(join(ROOT, "web/dist/l08.html"), page({ title: "《合成小岛》A/B 实验决策看板 · l08", body, script, css: "#stage { width: 480px; height: 170px; }" }));
console.log(JSON.stringify({ html: "web/dist/l08.html", htmlBytes: bytes, bundleLines: js.split("\n").length }));
#!/usr/bin/env node
// file: web/verify_l08_experiment.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l08_experiment.mjs web/dist/l08.html
// W 层验收:Playwright 无头打开单文件 HTML,调 api 改样本量/转化率,读回断言 + 截图。
// playwright 用 createRequire 加载——NODE_PATH 对 ESM 的 import 无效。

import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";

const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/l08.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
const errors = [];
const p = await browser.newPage({ viewport: { width: 800, height: 700 } });
p.on("pageerror", (e) => errors.push(String(e)));
await p.goto(url);
await p.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const base = await p.evaluate(() => globalThis.__pebble.api.get());
const keep = await p.evaluate(() => globalThis.__pebble.api.evaluate({ treatPermille: 121 }));
const stop = await p.evaluate(() => globalThis.__pebble.api.evaluate({ treatPermille: 108 }));
const split = await p.evaluate(() => globalThis.__pebble.api.split());
const stats = await p.evaluate(() => globalThis.__pebble.api.stats({ mdePermille: 8 }));
const domDecision = (await p.textContent("#decision")).trim();
const domSrm = (await p.textContent("#srmdev")).trim();
const painted = await p.evaluate(() => {
  const d = document.getElementById("stage").getContext("2d").getImageData(0, 0, 480, 170).data;
  let n = 0; for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0 && d[i] < 250) n += 1; return n;
});
await p.screenshot({ path: join(ROOT, "out/shots/l08_experiment.png"), fullPage: true });
await p.close();
const p2 = await browser.newPage();
await p2.goto(url);
await p2.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const rerun = await p2.evaluate(() => globalThis.__pebble.api.get());
await p2.close();
await browser.close();

assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(base.requiredPerArm, 11512); assert.equal(base.decision, "ship");
assert.ok(Math.abs(base.pValue - 0.000116) < 1e-5, "默认 p 值应≈0.000116");
assert.equal(keep.decision, "keep_running"); assert.equal(stop.decision, "stop");
assert.equal(split.assigned.control, 100071); assert.equal(split.assigned.treat, 99929);
assert.equal(split.logged.treat, 96892); assert.equal(split.maxDev, 16); assert.equal(split.srmPass, false);
assert.equal(stats.requiredPerArm, 25902); assert.equal(domDecision, "stop");
assert.ok(domSrm.includes("SRM 未通过")); assert.ok(painted > 300, `canvas 绘制 ${painted}px`);
assert.deepEqual(rerun.requiredPerArm, base.requiredPerArm, "同一次加载应可复现");

console.log(JSON.stringify({
  page: "web/dist/l08.html", base, keep, stop, split, stats, domDecision, domSrm,
  canvasPaintedPx: painted, rerunMatches: true, screenshot: "out/shots/l08_experiment.png",
}));
console.log("SMOKE-W8: PASS");

本机跑 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l08_experiment.mjs web/dist/l08.html,最后打印 SMOKE-W8: PASS:默认每臂样本量 11512、默认判 ship、p 值≈0.000116;改 treatPermille 为 121 判 keep_running、为 108 判 stop;split 读回 100071 / 99929 / 96892 与 16‰,stats({ mdePermille: 8 }) 得 25902——与 8.1、8.2 的 S 层逐位一致。warm 那一行是必需的预热:冷启动首个 page 读 canvas 像素恒为空白,必须先开一个页面丢弃。

小结与下一步

这一节把实验闭环补齐了:分流(8.1)保证可比、显著性(8.2)保证结论可信、决策表(8.3)保证结论可执行。一条规则值得记牢——护栏优先:主指标再好,只要底线指标(ARPDAU、崩溃率)破了,直接 stop;实验的意义不是「证明我对」,而是「在可控风险下把决策交给数据」。实验跑通、结论落定,下一步就是把它真正推上线——上线不是「点一下发布」,要走审核、灰度、回滚,还要有一份能照着走的清单,这是下一章的主题。

阅读导航:上一节:显著性、样本量与常见陷阱 · 下一节:上线评审与发布流程 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制