8.1 实验设计与分流

把「做实验」从拍脑袋变成可复现的工程:用 FNV-1a 哈希把用户确定性地分到对照与实验臂,再用带盐哈希模拟日志丢失、用样本比例失衡(SRM)检查把「分流坏了」变成可断言的数字,给出可跑的 Python 仿真、同口径的 TypeScript 内核与 5 项单元测试。

站内已有一篇广告变现设计清单 ,那篇是清单式短文,把「该不该加广告、加在哪、频次多少」列成一张检查表;本章给的是可跑的实验与显著性模型——同样一句「新频次更赚钱」,怎么用分流、样本量和显著性把它验成一个数字。区别在写法:那篇回答「广告该怎么设计」,本章回答「一个改动到底该不该上线,让数据说了算」。

前面几章把变现、增长、留存各自算了一遍,但它们都是模型:模型告诉你「新频次理论上更好」,不告诉你「这次改动在真实用户身上是否真的更好」。要把一个改动从「我觉得」变成「数据支持」,唯一的办法是做实验:把用户随机分成两组,一组用旧方案、一组用新方案,比两组的指标差异。这一章三节分别解决三件事:怎么分(8.1)、怎么判(8.2)、怎么决策(8.3)。分流最直觉的做法是「用户 ID 对 2 取模」,但它有两个致命问题:一是不均衡(ID 分布有偏时两臂人数会差出一大截),二是不可复现(每次请求重新随机,同一用户会在两臂间漂移)。正确做法是确定性哈希:把用户 ID 哈希成桶号,同一个用户永远落同一个桶、同一个臂。

D 层:把实验定义落成配置

下面这份是 D 层(静态层)产物,全整数。它定义实验 ID、哈希桶数、各臂权重‰,以及一个容易被忽略的参数 logDropPermille——某臂的日志丢失率;SRM 检查的期望取「分析样本总数 × 权重‰」,偏离超过 srmTolerancePermille 即判失衡。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "A/B 实验分流:用 FNV-1a 32 位哈希把用户 ID 确定性映射到 [0, hashModulo) 的一个桶,再按各臂 weightPermille 把桶区间依次切给各臂。同一用户永远落同一臂(可复现、无会话漂移)。分配本身应当均衡,但真正的 SRM 常来自「日志丢失」:logDropPermille 给某臂一个丢失率(用第二条独立哈希 logHashSalt 决定谁被丢),于是被分析到的样本比例失衡。SRM 用「分析样本总数 × 权重‰」作期望,偏离超过 srmTolerancePermille 即判失衡。真实丢失率需真实业务环境,命令行不可复现(X)。全整数,禁浮点。",
  "experimentId": "exp-interstitial-on-loss",
  "unit": "userIdHash",
  "hashModulo": 10000,
  "logHashSalt": ":log",
  "populationUsers": 200000,
  "srmTolerancePermille": 10,
  "logDropPermille": { "control": 0, "treat": 30 },
  "arms": [
    { "id": "control", "name": "对照(原频次)", "weightPermille": 500 },
    { "id": "treat", "name": "实验(降频次)", "weightPermille": 500 }
  ]
}

logDropPermille 是这一节的关键。分配本身(哈希)永远是均衡的,但被分析到的样本未必均衡:如果实验臂因为埋点 bug、崩溃、或客户端版本差异丢了一部分日志,进入分析的两臂人数就会失衡——这就是 SRM(Sample Ratio Mismatch,样本比例失衡)。SRM 不是「实验做错了」,而是**「实验数据不可信」的红灯**:一旦 SRM 报警,任何显著性结论都作废。

S 层:确定性哈希分流 + SRM 检查

下面这段是 S 层仿真脚本,读 config/l08_experiment.json,把 20 万个合成用户按哈希分到两臂,再按丢失率丢掉一部分日志,最后做 SRM 检查,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/l08_split.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l08_split.py --config config/ --out out/
"""A/B 分流仿真:FNV-1a 32 位哈希把用户 ID 映射到桶,再按各臂权重‰切桶区间。
同一用户永远落同一臂;再用带盐哈希模拟日志丢失,做 SRM(样本比例失衡)检查。
产出 JSON + CSV + SVG。真实丢失率需真实业务环境,命令行不可复现(X)。全整数。"""
from __future__ import annotations
import argparse, csv, json, os
import xml.etree.ElementTree as ET

FNV_OFFSET, FNV_PRIME, MASK32 = 0x811C9DC5, 0x01000193, 0xFFFFFFFF

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

def fnv1a32(s: str) -> int:
    """FNV-1a 32 位哈希(ASCII 输入逐字符即逐字节)。"""
    h = FNV_OFFSET
    for ch in s:
        h = ((h ^ ord(ch)) * FNV_PRIME) & MASK32
    return h

def bucket_of(uid: str, modulo: int) -> int:
    return fnv1a32(uid) % modulo

def assign_arm(cfg: dict, uid: str) -> str:
    """按累计权重把桶区间分给各臂;同一用户永远落同一臂。"""
    b, cum = bucket_of(uid, cfg["hashModulo"]), 0
    for arm in cfg["arms"]:
        cum += arm["weightPermille"]
        if b < cum * cfg["hashModulo"] // 1000:
            return arm["id"]
    return cfg["arms"][-1]["id"]

def is_logged(cfg: dict, uid: str, arm: str) -> bool:
    """日志是否落库:带盐第二哈希决定,按该臂丢失率‰丢弃。"""
    drop = cfg["logDropPermille"].get(arm, 0)
    return True if drop <= 0 else fnv1a32(uid + cfg["logHashSalt"]) % 1000 >= drop

def tally(cfg: dict, population: int) -> tuple:
    assigned = {a["id"]: 0 for a in cfg["arms"]}
    logged = {a["id"]: 0 for a in cfg["arms"]}
    for i in range(population):
        uid = "u%d" % i
        arm = assign_arm(cfg, uid)
        assigned[arm] += 1
        if is_logged(cfg, uid, arm):
            logged[arm] += 1
    return assigned, logged

def srm_rows(cfg: dict, logged: dict) -> tuple:
    """期望 = 分析样本总数 × 权重‰;偏差‰ 与卡方(×1000,整数)。"""
    total = sum(logged.values())
    rows, chi2, max_dev = [], 0, 0
    for arm in cfg["arms"]:
        exp = total * arm["weightPermille"] // 1000
        obs = logged[arm["id"]]
        dev = abs(obs - exp) * 1000 // exp if exp else 0
        chi2 += (obs - exp) ** 2 * 1000 // exp if exp else 0
        max_dev = max(max_dev, dev)
        rows.append({"id": arm["id"], "name": arm["name"], "expected": exp,
                     "observed": obs, "deviationPermille": dev})
    return rows, max_dev, chi2

def draw_svg(path: str, rows: list, tol: int) -> int:
    W, PAD, ROW = 720, 190, 46
    H = 70 + ROW * len(rows)
    peak = max((r["observed"] for r in rows), default=1) or 1
    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"分析样本数 vs 期望(SRM 容差 ±{tol}‰,红线 = 期望)"
    for i, r in enumerate(rows):
        y = 54 + i * ROW
        w_obs = (W - PAD - 60) * r["observed"] // peak
        w_exp = (W - PAD - 60) * r["expected"] // 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(PAD), "y": str(y + 6), "width": str(w_obs),
                                    "height": "22", "rx": "4", "fill": "#2563eb"})
        ET.SubElement(svg, "line", {"x1": str(PAD + w_exp), "y1": str(y + 2),
                                    "x2": str(PAD + w_exp), "y2": str(y + 32),
                                    "stroke": "#dc2626", "stroke-width": "2"})
        ET.SubElement(svg, "text", {"x": str(PAD + w_obs + 8), "y": str(y + 22), "font-size": "12",
                                    "fill": "#6b7280"}).text = f'实测 {r["observed"]} · 偏差 {r["deviationPermille"]}‰'
    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)
    pop = cfg["populationUsers"]
    assigned, logged = tally(cfg, pop)
    rows, max_dev, chi2 = srm_rows(cfg, logged)
    for r in rows:
        r["assigned"] = assigned[r["id"]]
    ok = max_dev <= cfg["srmTolerancePermille"]
    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "l08_split.svg"), rows, cfg["srmTolerancePermille"])
    with open(os.path.join(a.out, "l08_split.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["armId", "name", "assigned", "expected", "observed", "deviationPermille"])
        for r in rows:
            w.writerow([r["id"], r["name"], r["assigned"], r["expected"], r["observed"], r["deviationPermille"]])
    rep = {"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"],
           "experimentId": cfg["experimentId"], "hashModulo": cfg["hashModulo"], "populationUsers": pop,
           "srmTolerancePermille": cfg["srmTolerancePermille"], "logDropPermille": cfg["logDropPermille"],
           "arms": rows, "assigned": assigned, "logged": logged, "maxDeviationPermille": max_dev,
           "chi2Milli": chi2, "srmPass": ok,
           "deterministic": assign_arm(cfg, "u12345") == assign_arm(cfg, "u12345"),
           "csv": os.path.join(a.out, "l08_split.csv"),
           "svg": os.path.join(a.out, "l08_split.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "l08_split.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"assigned": assigned, "logged": logged, "maxDeviationPermille": max_dev,
                      "chi2Milli": chi2, "srmPass": ok, "deterministic": rep["deterministic"]},
                     ensure_ascii=False))
    return 0

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

脚本只有四步:fnv1a32 把 ID 哈希成整数;bucket_of 取模得到桶号;assign_arm 按累计权重把桶分给各臂;srm_rows 用「分析样本总数 × 权重‰」作期望、算各臂偏差‰。在本机跑它,输出如下(同机相对量级,绝对数值不可移植):

{"assigned": {"control": 100071, "treat": 99929}, "logged": {"control": 100071, "treat": 96892}, "maxDeviationPermille": 16, "chi2Milli": 51308, "srmPass": false, "deterministic": true}

读这三个数字:分配是 100071 : 99929,几乎完美均衡(偏差不足 1‰);但日志落库后变成 100071 : 96892——treat 臂丢了约 3% 的日志,SRM 偏差冲到 16‰,超过容差 10‰,SRM 未通过。这正是 SRM 的价值:它先于任何指标结论,告诉你「这组数据的两臂不可比」。

P 层:同一份 config 的分流内核

仿真跑通了,但分流逻辑最终要跑在服务端(或客户端 SDK)里。下面这段是 P 层内核 core/src/l08/split.ts,与 Python 脚本同一份 config、同一套整数运算,因此逐位一致。注意 Math.imul(h, FNV_PRIME) >>> 0——JS 的 * 在超过 2^53 时会丢精度,必须用 Math.imul 做 32 位整数乘法、>>> 0 转无符号。

// file: core/src/l08/split.ts
// tier: P
// verify: node --test 'core/test/l08_*.test.ts'
// 《合成小岛》A/B 分流内核:FNV-1a 32 位哈希把用户 ID 确定性映射到桶,
// 再按各臂权重‰把桶区间分给各臂;另用带盐的第二条哈希模拟日志丢失,做 SRM 检查。
// 与 sim/l08_split.py 同一份 config、同一套整数运算,逐位一致。全整数、无 Math.random()。

export interface Arm { id: string; name: string; weightPermille: number }
export interface SplitConfig {
  arms: Arm[]; hashModulo: number; logHashSalt: string; logDropPermille: Record<string, number>;
}
export interface ArmTally {
  id: string; name: string; assigned: number; expected: number; observed: number; deviationPermille: number;
}

const FNV_OFFSET = 0x811c9dc5;
const FNV_PRIME = 0x01000193;

/** FNV-1a 32 位哈希(ASCII 输入逐字符即逐字节)。 */
export function fnv1a32(s: string): number {
  let h = FNV_OFFSET;
  for (let i = 0; i < s.length; i += 1) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, FNV_PRIME) >>> 0;
  }
  return h >>> 0;
}

export const bucketOf = (uid: string, modulo: number): number => fnv1a32(uid) % modulo;

/** 按累计权重把桶区间分给各臂;同一用户永远落同一臂。 */
export function assignArm(cfg: SplitConfig, uid: string): string {
  const b = bucketOf(uid, cfg.hashModulo);
  let cum = 0;
  for (const arm of cfg.arms) {
    cum += arm.weightPermille;
    if (b < Math.floor(cum * cfg.hashModulo / 1000)) return arm.id;
  }
  return cfg.arms[cfg.arms.length - 1].id;
}

/** 日志是否落库:用带盐的第二条哈希决定,按该臂丢失率‰丢弃。 */
export function isLogged(cfg: SplitConfig, uid: string, arm: string): boolean {
  const drop = cfg.logDropPermille[arm] ?? 0;
  if (drop <= 0) return true;
  return fnv1a32(uid + cfg.logHashSalt) % 1000 >= drop;
}

/** 统计 population 个合成用户(u0..u{N-1})的分配数与日志落库数。 */
export function tallyArms(cfg: SplitConfig, population: number): { assigned: Record<string, number>; logged: Record<string, number> } {
  const assigned: Record<string, number> = {};
  const logged: Record<string, number> = {};
  for (const arm of cfg.arms) { assigned[arm.id] = 0; logged[arm.id] = 0; }
  for (let i = 0; i < population; i += 1) {
    const uid = "u" + i;
    const arm = assignArm(cfg, uid);
    assigned[arm] += 1;
    if (isLogged(cfg, uid, arm)) logged[arm] += 1;
  }
  return { assigned, logged };
}

/** SRM 检查:期望 = 分析样本总数 × 权重‰,返回各臂偏差‰。 */
export function srmTally(cfg: SplitConfig, assigned: Record<string, number>, logged: Record<string, number>): ArmTally[] {
  const total = cfg.arms.reduce((s, a) => s + logged[a.id], 0);
  return cfg.arms.map((arm) => {
    const expected = Math.floor(total * arm.weightPermille / 1000);
    const observed = logged[arm.id];
    return {
      id: arm.id, name: arm.name, assigned: assigned[arm.id], expected, observed,
      deviationPermille: expected ? Math.floor(Math.abs(observed - expected) * 1000 / expected) : 0,
    };
  });
}

P 层:把分流钉进单元测试

// file: core/test/l08_split.test.ts
// tier: P
// verify: node --test 'core/test/l08_*.test.ts'
// 断言:哈希确定、同一用户永远落同一臂;分配均衡但日志丢失造成 SRM;
// 关键计数与 sim/l08_split.py 逐位一致。

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { fnv1a32, bucketOf, assignArm, isLogged, tallyArms, srmTally } from "../src/l08/split.ts";

const cfg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "config", "l08_experiment.json"), "utf-8"));

test("fnv1a32:确定且与 Python 逐位一致", () => {
  assert.equal(fnv1a32("u0"), 54699616);
  assert.equal(fnv1a32("u2"), 88254854);
  assert.equal(fnv1a32("u12345"), 192589931);
  assert.equal(fnv1a32("u0"), fnv1a32("u0"));
});

test("assignArm:同一用户永远落同一臂,且落在 [0, hashModulo)", () => {
  assert.equal(bucketOf("u0", cfg.hashModulo), 9616);
  assert.equal(assignArm(cfg, "u0"), "treat");
  assert.equal(assignArm(cfg, "u2"), "control");
  assert.equal(assignArm(cfg, "u12345"), "treat");
  assert.equal(assignArm(cfg, "u12345"), assignArm(cfg, "u12345"));
  for (let i = 0; i < 500; i += 1) {
    const b = bucketOf("u" + i, cfg.hashModulo);
    assert.ok(b >= 0 && b < cfg.hashModulo);
  }
});

test("分配本身均衡,日志丢失让 treat 臂样本比例失衡(SRM)", () => {
  const { assigned, logged } = tallyArms(cfg, cfg.populationUsers);
  assert.deepEqual(assigned, { control: 100071, treat: 99929 });
  assert.deepEqual(logged, { control: 100071, treat: 96892 });
  assert.equal(assigned.control - assigned.treat, 142); // 分配只差 142
  assert.equal(logged.control - logged.treat, 3179); // 日志落库后差 3179
});

test("isLogged:带盐哈希按丢失率丢弃,control 丢失率为 0 时不丢", () => {
  assert.equal(isLogged(cfg, "u0", "control"), true);
  assert.equal(isLogged(cfg, "u0", "treat"), fnv1a32("u0:log") % 1000 >= 30);
});

test("srmTally:期望按分析样本总数算,偏差 16‰ 超容差 10‰ → SRM 未通过", () => {
  const { assigned, logged } = tallyArms(cfg, cfg.populationUsers);
  const rows = srmTally(cfg, assigned, logged);
  const maxDev = Math.max(...rows.map((r) => r.deviationPermille));
  assert.equal(maxDev, 16);
  assert.ok(maxDev > cfg.srmTolerancePermille);
  assert.equal(rows[0].assigned, 100071);
  assert.equal(rows[1].observed, 96892);
});

本机跑 node --test 'core/test/l08_*.test.ts',5 项全过;其中「分配本身均衡,日志丢失让 treat 臂样本比例失衡」一条同时断言了 assigned 与 logged 两组计数,与 Python 仿真逐位一致(100071 / 99929 / 96892 / 16‰)。

小结与下一步

这一节把「分流」做成了一个可复现、可断言的工程件:确定性哈希保证同臂、权重切桶保证比例、带盐哈希模拟丢失、SRM 检查保证数据可信。分流分得对,实验才有资格谈结论——但分好组只是第一步,怎么判断两组的差异是「真的」还是「碰巧」、需要多少样本才够、看到一半就想下结论会出什么错,是下一节的主题。

阅读导航:上一节:目录 · 下一节:显著性、样本量与常见陷阱 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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