13.1 实验设计与分流

A/B 实验的第一步是把用户稳定地分到对照组与实验组。本节用确定性 PRNG mulberry32 与 user_id 的 FNV-1a 哈希实现可复现的稳定分流,本机跑出 10 万用户的分桶比例、偏差与稳定性,并量化「每次曝光都重抽」的不稳定分流会让多少用户同时看到多个变体,说明为什么分流必须先于显著性。

前面十二章都在讲「怎么把一个数值算清楚」:属性、成长、战斗、掉落、抽卡、匹配、赛季、付费、定价、LTV、留存。但从第 9 章开始,很多结论是假设——折扣的需求响应是假设、流失预警的召回率是假设、价格弹性是假设。要判断一个改动到底有没有用,只有一条路:做实验。本章讲的就是这条路的工程实现。它和 数据分析埋点方案 是分工关系:那篇是埋点与指标口径清单(事件怎么埋、指标怎么定义),本章是实验设计、显著性与决策的可跑流程(怎么分、怎么判、怎么拍板)。没有前者,实验没有数据;没有后者,数据只是数字。

实验最容易出错的地方,不是算显著性,而是分流。如果同一个玩家今天在对照组、明天在实验组,那么「两组差异」里混进了「同一个人反复横跳」的噪声,后面再精致的统计也救不回来。所以本章从分流讲起。

一次实验必须定义的三件事

做实验之前,有三件事必须先写下来,缺一件就不是实验,是「改了就上线」:

  • 假设(hypothesis):改了什么、预期哪个指标往哪个方向动。本套的主实验假设是「把次日付费引导位提前,D1 付费转化率从 80‰ 升到 96‰」。
  • 指标(metric):一个主指标决定成败,若干护栏指标防止改坏别处。主指标是 D1 付费转化率;护栏是 D7 留存、单次会话时长——主指标涨了但留存崩了,同样不能上。
  • 分流(traffic split):哪些玩家进对照组、哪些进实验组,各占多少。本套按 50/50 分,参数写在 config/n13_experiment.json。

前两件是产品决策,第三件是工程实现。本节只讲第三件,因为它最容易被随手写错。

分流的两种写法

分桶看起来只是「随机扔一半人进实验组」,但「随机」有两种完全不同的写法:

  • 不稳定分流:每次曝光时现抽一个随机数决定看哪个变体。实现最简单,但同一个玩家每次曝光都可能换组。
  • 稳定分桶:用 user_id 的哈希当种子算桶号,同一个玩家永远分到同一桶。

两者的区别不是精度,是语义。不稳定分流下,一个玩家可能先看到 A 再看到 B,实验测到的就不是「A 与 B 谁更好」,而是「A 与 B 来回切换的效果」。真正可用的分流必须是稳定的,而且要可复现——同一份用户池、同一个种子,任何人重跑都得到同一份分桶结果。

本套用确定性 PRNG mulberry32(Python 与 TypeScript 同算法同种子)配 user_id 的 FNV-1a 哈希来实现:桶号 = floor(mulberry32(fnv1a(user_id) ^ seed)() × 桶数)。哈希保证稳定,PRNG 保证均匀。

仿真:10 万用户的分桶

下面这段是 S 层仿真脚本,读 config/n13_experiment.json,对 10 万个用户做稳定分桶,统计各桶人数与目标比例的偏差,并对照「不稳定分流」的代价:

# file: sim/n13_split.py
# tier: S
# verify: python3 sim/n13_split.py --config config/ --out out/
"""实验分流仿真(13.1):用确定性 PRNG mulberry32 + user_id 的 FNV-1a 哈希做稳定分桶。

三件事:
1. 稳定分桶:同一 user_id 每次分到的桶不变(hash 做种子,不依赖运行时随机)。
2. 分桶比例:各桶人数与目标千分比的偏差,以及 running share 是否收敛到目标。
3. 不稳定分流的代价:若每次曝光都重抽随机数,一个用户会在多个变体之间来回跳。

产出 out/n13_split.json + out/n13_split.csv + out/n13_split.svg。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from svg_line import render  # noqa: E402


def mulberry32(seed):
    """确定性 PRNG:与 proto/src/n13_split.ts 逐位一致。"""
    state = seed & 0xFFFFFFFF

    def nxt():
        nonlocal state
        state = (state + 0x6D2B79F5) & 0xFFFFFFFF
        t = ((state ^ (state >> 15)) * (state | 1)) & 0xFFFFFFFF
        t = (t ^ (t + (((t ^ (t >> 7)) * (t | 61)) & 0xFFFFFFFF))) & 0xFFFFFFFF
        return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0

    return nxt


def fnv1a(s: str) -> int:
    """32 位 FNV-1a 哈希:把 user_id 变成一个稳定的整数种子。"""
    h = 0x811C9DC5
    for ch in s.encode("utf-8"):
        h ^= ch
        h = (h * 0x01000193) & 0xFFFFFFFF
    return h


def bucket_of(user_id: str, num_buckets: int, seed: int) -> int:
    """稳定分桶:bucket = floor(mulberry32(fnv1a(user_id) ^ seed)() * num_buckets)。"""
    r = mulberry32((fnv1a(user_id) ^ seed) & 0xFFFFFFFF)()
    return min(num_buckets - 1, int(r * num_buckets))


def load(path: str) -> dict:
    with open(path, encoding="utf-8") as fh:
        return json.load(fh)


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)

    cfg = load(os.path.join(args.config, "n13_experiment.json"))
    seed = cfg["seed"]
    sp = cfg["split"]
    buckets = sp["buckets"]
    nb = len(buckets)
    users = sp["users"]
    target = sp["targetPermille"]

    counts = [0] * nb
    running = []          # 每 1000 人采样一次 control 桶的占比(千分比)
    for i in range(users):
        uid = f"u{i:06d}"
        b = bucket_of(uid, nb, seed)
        counts[b] += 1
        if (i + 1) % 1000 == 0:
            running.append(counts[0] * 1000 // (i + 1))

    shares = [c * 1000 // users for c in counts]
    dev = [shares[i] - target[i] for i in range(nb)]

    # 稳定分桶自检:同一 id 连算 3 次必须一致
    stable_ok = all(
        len({bucket_of(f"u{i:06d}", nb, seed) for _ in range(3)}) == 1
        for i in range(0, 2000, 7)
    )

    # 不稳定分流:每次曝光重抽(无稳定 key),统计跨多次曝光看到 >1 个变体的用户比例
    redraws = sp["unstableRedraws"]
    rnd = mulberry32((seed ^ 0x5A17) & 0xFFFFFFFF)
    multi_variant = 0
    for _ in range(users):
        seen = set()
        for _ in range(redraws):
            seen.add(min(nb - 1, int(rnd() * nb)))
        if len(seen) > 1:
            multi_variant += 1
    multi_permille = multi_variant * 1000 // users

    rows = [{"bucket": buckets[i], "count": counts[i], "sharePermille": shares[i],
             "targetPermille": target[i], "devPermille": dev[i]} for i in range(nb)]
    with open(os.path.join(args.out, "n13_split.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["bucket", "count", "sharePermille", "targetPermille", "devPermille"])
        for r in rows:
            w.writerow([r["bucket"], r["count"], r["sharePermille"], r["targetPermille"], r["devPermille"]])

    render(running, os.path.join(args.out, "n13_split.svg"), "control share permille (running)")

    report = {
        "version": 1, "seed": seed, "users": users, "buckets": buckets,
        "rows": rows, "maxAbsDevPermille": max(abs(d) for d in dev),
        "stableBucket": stable_ok,
        "unstableRedraws": redraws, "multiVariantPermille": multi_permille,
        "runningSampleN": len(running), "runningTail": running[-1],
    }
    with open(os.path.join(args.out, "n13_split.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_split.csv(种子 20261012,10 万用户,目标 500/500‰):

桶人数占比(‰)目标(‰)偏差(‰)
control50232502500+2
treat49768497500−3

脚本还输出 maxAbsDevPermille = 3、stableBucket = true、multiVariantPermille = 872。三个数各有含义:

  • maxAbsDevPermille = 3:两个桶的占比与目标最多差 3‰。10 万用户下,50/50 的理想波动约 ±1.6‰(1000 分之 500 的标准差约为 500/√100000 ≈ 1.6‰),3‰ 在正常范围内。偏差不是零——随机分流天然有波动,只要落在预期内就不算问题。
  • stableBucket = true:抽查的 286 个用户里,每个连算 3 次都落在同一桶。这正是稳定分桶的意义:同一个玩家在实验期内始终看同一个变体。
  • multiVariantPermille = 872:这是不稳定分流的代价。如果每次曝光都重抽(4 次曝光),872‰ 的用户至少会看到两个不同的变体。两桶、4 次独立重抽下,「4 次全在同一桶」的概率是 2×(1/2)^4 = 125‰,所以「看到超过一个变体」的理论值是 875‰——实测 872‰,吻合。

这一行是整个 13.1 的核心:分流不稳,实验就废。872‰ 的用户被两组「同时影响」,测出来的任何差异都无法归因。

原型:跨语言一致的分桶

分流算法必须跨语言一致——线上是客户端/服务端某种语言,分析是另一种语言,两边分桶结果必须逐位相同,否则「实验组」在两边是两拨人。下面这段是 P 层原型,用 TypeScript 实现同一套 mulberry32 + FNV-1a:

// file: proto/src/n13_split.ts
// tier: P
// verify: node --test proto/test/n13.test.ts
// 实验分流原型(13.1):与 sim/n13_split.py 同一套 mulberry32 + FNV-1a,
// 对 user_id 做稳定分桶,逐位一致。

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const HERE = dirname(fileURLToPath(import.meta.url));
export const CONFIG_DIR = join(HERE, "..", "..", "config");

export interface SplitConfig {
  seed: number;
  split: { users: number; buckets: string[]; targetPermille: number[]; unstableRedraws: number };
}
export interface SplitReport {
  counts: number[]; sharesPermille: number[]; maxAbsDevPermille: number;
}

export function loadJson<T>(name: string): T {
  return JSON.parse(readFileSync(join(CONFIG_DIR, `${name}.json`), "utf-8")) as T;
}

/** 确定性 PRNG:与 Python 侧 mulberry32 逐位一致(32 位无符号掩码 + imul)。 */
export function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return function (): number {
    a = (a + 0x6D2B79F5) >>> 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a) >>> 0;
    const t0 = t;
    t = (t0 + Math.imul(t0 ^ (t0 >>> 7), 61 | t0)) >>> 0;
    t = (t ^ t0) >>> 0;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** 32 位 FNV-1a:把 user_id 变成稳定整数种子(与 Python 侧逐位一致)。 */
export function fnv1a(s: string): number {
  let h = 0x811C9DC5;
  const bytes = new TextEncoder().encode(s);
  for (const b of bytes) {
    h ^= b;
    h = Math.imul(h, 0x01000193) >>> 0;
  }
  return h >>> 0;
}

export function bucketOf(userId: string, numBuckets: number, seed: number): number {
  const r = mulberry32(((fnv1a(userId) ^ seed) >>> 0))();
  return Math.min(numBuckets - 1, Math.floor(r * numBuckets));
}

export function assignAll(users: number, numBuckets: number, seed: number): number[] {
  const counts = new Array<number>(numBuckets).fill(0);
  for (let i = 0; i < users; i++) {
    counts[bucketOf(`u${String(i).padStart(6, "0")}`, numBuckets, seed)]++;
  }
  return counts;
}

export function analyze(cfg: SplitConfig): SplitReport {
  const nb = cfg.split.buckets.length;
  const counts = assignAll(cfg.split.users, nb, cfg.seed);
  const shares = counts.map((c) => Math.floor((c * 1000) / cfg.split.users));
  const dev = shares.map((s, i) => s - cfg.split.targetPermille[i]);
  return { counts, sharesPermille: shares, maxAbsDevPermille: Math.max(...dev.map((d) => Math.abs(d))) };
}

配套测试 proto/test/n13.test.ts 断言 TS 侧分桶与 Python 实跑逐位一致:计数 [50232, 49768]、占比 [502, 497]、最大偏差 3、fnv1a("u000000") = 1764898608,以及同一 user_id 多次分桶不变。本机 node --test proto/test/n13.test.ts 五个用例全过。

灰度放量与实验的区别

分流解决「分得对」,灰度解决「放得稳」。两者常被混为一谈,但目标不同:

  • A/B 实验:为了测量。两组必须同时在线、同时受同样的大盘环境影响,否则差异无法归因。
  • 灰度放量(canary / ramp):为了安全。先放 1% 看有没有崩溃、有没有指标异常,再逐步放到 100%。它测的是「会不会出事」,不是「哪个更好」。

灰度放量的版本列车与放量节奏,卷一 7.3 灰度、放量与回滚 已给出工程流程,本节不重复。这里只强调一点:一次「灰度放量」如果顺手拿去比指标,它不是实验——因为放量时两组不同时在线、样本量也不等,直接比会得出错的结论。

分层与互斥

真实项目不会一次只跑一个实验。同时在线的多个实验如果不加约束,会互相污染:一个改付费引导、一个改新手教程,同一个用户同时进两个实验,就分不清是谁的功劳。工程上有两种隔离方式:

  • 互斥层(mutual exclusion):把实验分到不同「层」,同层内互斥,一个用户只能进一个。
  • 分层正交(layered / orthogonal):不同层的分桶用不同的哈希盐,让「进不进实验 A」与「进不进实验 B」统计独立,从而可以叠加。

本套的分流函数用 fnv1a(user_id) ^ seed 做种子,seed 就是那层盐——换层就换 seed,同一个用户在不同层会落到不同的桶。这为后续做分层正交留好了接口。

分流做对了,才轮到显著性

本节把分流讲透了:稳定分桶(同一用户永远同组)、可复现(同种子同结果)、跨语言一致(Python/TS 逐位相同)、以及不稳定分流的真实代价(872‰ 的用户看到多个变体)。但分流只是第一步——分得再好,如果样本量不够或偷看太多次,结论依然是错的。下一节进入实验的核心:显著性、样本量,以及四个最常见的陷阱。

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

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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