4.2 采样与置信区间

样本量决定「看得见的最小差别」。本节把样本量落到整数 Wilson 区间上:对占比类可解率用 z=2 的整数近似(isqrt,非 1.96),对非占比类用块极差;六档种子数实跑显示半宽之和从 n=8 的 545‰ 收到 n=256 的 117‰,刚好跨过 Δ=150‰。

4.2 采样与置信区间

《小游戏开发实战》卷一 12.2 可玩性指标测试 那篇已经把占比类指标的区间算清楚了,本节接着 4.1 的假设往下走:把样本量真的落到区间上,并回答一个更硬的问题——两臂的区间要窄到什么程度,才谈得上「分辨出 Δ」。指标仍是机制量,区间口径是整数 Wilson(z=2)。

两种区间,两类指标

占比类指标(可解率 k/n)用整数 Wilson:标准公式里有平方根和除法,本卷禁浮点,但整数平方根是精确的,用 math.isqrt 就能把整个区间落在整数上:

center‰ = 1000·(k + z²/2) / (n + z²)      = 1000·(k+2)/(n+4)          (z=2)
half‰   = 1000·z·√(k(n−k)+n) / ((n+z²)·√n) = 2000·√((k(n−k)+n)/n)/(n+4)
落盘:center 用整数地板除;half 用 isqrt(num·SCALE²//n) 得 SCALE·√(num/n),再地板除

z=2 是整数口径下的取值,对应约 95% 水平,不等同教科书里的 1.96——这点必须写明,否则「95% 置信区间」就是没兑现的承诺。z=2 的区间比 z=1.96 略宽,是保守方向。

非占比类指标(支配缺失度、策略熵)不是伯努利比例,套不了二项区间,只能给块极差:把 n 个种子切成 blocks 块,每块单独算一遍,取块间 [min, max]。它不假设分布、对任何指标可用,代价是更粗。

分辨力判据

有了区间,判据就一句话:两臂整数 Wilson 半宽之和 ≤ Δ,才谈得上分辨。半宽之和是「采样噪声」的尺度,Δ 是「设计相关的差」的尺度;噪声比信号还大时,任何点差都不能进决策。

{ "scenario": { "enemyHp": 65, "turnLimit": 16, "energyMax": 3, "playerHp": 60 },
  "arms": { "control": { "enemyAttack": 20 }, "treat": { "enemyAttack": 22 } },
  "referenceStrategy": "maxdmg", "seedStart": 401, "seedCount": 256,
  "sampleLadder": [8, 16, 32, 64, 128, 256], "blocks": 4, "z": 2,
  "minDiscerniblePermille": 150 }

S 层:六档种子数 × 两臂

脚本对两臂各扫六档种子数,对可解率同时给整数 Wilson 区间与「半宽之和 ≤ Δ」的判据,对两个非占比指标给块极差。全部千分比整数、无浮点、无 random;末尾用纯 xml.etree 画一张区间图。

#!/usr/bin/env python3
# file: sim/gd_proto_04_sampling.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_04_sampling.py
"""卷三 4.2 采样与置信区间(S 层):样本量决定「看得见的最小差别」。

对两臂各扫六档种子数,量三种机制指标:
  solveRate          占比类 -> 整数 Wilson 区间(z=2,用 isqrt 求,整数近似而非 1.96)
  dominanceAbsence   非占比类 -> 块极差(切成 blocks 块,取块间 [min, max])
  strategyEntropy    非占比类 -> 块极差
把两臂 Wilson 半宽之和与**最小可分辨差 Δ** 对比:半宽之和 <= Δ 才谈得上分辨。
指标是代理指标(proxy),只做相对比较,禁绝对化;区间只刻画采样不确定性、不修正偏差。
产出 out/proto04_sampling.json + .csv + .svg。全整数、无浮点、无 random;纯 xml.etree 出 SVG。
"""
import json
import os
import sys
import time
import xml.etree.ElementTree as ET

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_proto_04_sampling.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
import gd_proto_04_common as C  # noqa: E402


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    strats = C.strategies(spec)
    ref = cfg["referenceStrategy"]
    seeds = list(range(cfg["seedStart"], cfg["seedStart"] + cfg["seedCount"]))
    delta, z, blocks = cfg["minDiscerniblePermille"], cfg["z"], cfg["blocks"]

    rows, csv = [], []
    for n in cfg["sampleLadder"]:
        pool = seeds[:n]
        row = {"n": n}
        for arm in ("control", "treat"):
            m = C.mechanism_metrics(spec, pool, {**cfg["scenario"], **cfg["arms"][arm]}, strats, ref, blocks)
            lo, hi, half = C.wilson(m["solveRate"] * n // 1000, n, z)
            row[arm] = {**m, "wilsonLo": lo, "wilsonHi": hi, "wilsonHalf": half}
        c, t = row["control"], row["treat"]
        row["diff"] = c["solveRate"] - t["solveRate"]
        row["halfSum"] = c["wilsonHalf"] + t["wilsonHalf"]
        row["resolvable"] = row["halfSum"] <= delta
        row["disjoint"] = c["wilsonLo"] > t["wilsonHi"]
        rows.append(row)
        csv.append([n, c["solveRate"], t["solveRate"], row["diff"], c["wilsonLo"], c["wilsonHi"],
                    t["wilsonLo"], t["wilsonHi"], c["wilsonHalf"], t["wilsonHalf"], row["halfSum"],
                    c["dominanceAbsence"], t["dominanceAbsence"], c["strategyEntropy"], t["strategyEntropy"]])

    m = ashlib.write_csv(os.path.join(OUT, "proto04_sampling.csv"),
                         ["n", "ctrlRate", "treatRate", "diff", "ctrlLo", "ctrlHi", "treatLo", "treatHi",
                          "ctrlHalf", "treatHalf", "halfSum", "ctrlDom", "treatDom", "ctrlEnt", "treatEnt"], csv)
    size = draw(rows, delta, os.path.join(OUT, "proto04_sampling.svg"))
    n_req = next((r["n"] for r in rows if r["resolvable"]), None)
    report = {"id": "gd_proto_04_sampling", "z": z, "blocks": blocks,
              "minDiscerniblePermille": delta, "nRequired": n_req, "rows": rows,
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "proto04_sampling.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"z={z}(整数近似,非 1.96)  Δ={delta}‰  blocks={blocks}")
    print(f"{'n':>5}{'ctrl‰':>7}{'treat‰':>8}{'差‰':>6}{'ctrlWilson':>14}{'treatWilson':>14}"
          f"{'半宽和‰':>9}{'<=Δ':>5}{'不相交':>7}")
    for r in rows:
        c, t = r["control"], r["treat"]
        cw = "[{},{}]".format(c["wilsonLo"], c["wilsonHi"])
        tw = "[{},{}]".format(t["wilsonLo"], t["wilsonHi"])
        print(f"{r['n']:>5}{c['solveRate']:>7}{t['solveRate']:>8}{r['diff']:>6}{cw:>14}{tw:>14}"
              f"{r['halfSum']:>9}{'yes' if r['resolvable'] else 'no':>5}{'yes' if r['disjoint'] else 'no':>7}")
    print(f"\n非占比类(块极差,blocks={blocks}):")
    print(f"{'n':>5}{'ctrlDom‰':>10}{'ctrlDom区间':>14}{'treatDom‰':>11}{'ctrlEnt‰':>10}{'ctrlEnt区间':>14}")
    for r in rows:
        c, t = r["control"], r["treat"]
        db = "[{},{}]".format(c["domBlockLo"], c["domBlockHi"])
        eb = "[{},{}]".format(c["entBlockLo"], c["entBlockHi"])
        print(f"{r['n']:>5}{c['dominanceAbsence']:>10}{db:>14}{t['dominanceAbsence']:>11}"
              f"{c['strategyEntropy']:>10}{eb:>14}")
    print(f"\ncsv={m} rows  svg={size} bytes  nRequired={n_req}  elapsed={report['elapsedMs']} ms")
    print("wrote out/proto04_sampling.csv out/proto04_sampling.svg out/proto04_sampling.json")
    ET.parse(os.path.join(OUT, "proto04_sampling.svg"))
    return 0


def draw(rows, delta, path):
    W, H, x0, y1, ytop = 720, 320, 70, 268, 48
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, 20, 26, "solveRate 与整数 Wilson 区间(千分比,两臂各六档种子数)", 15)
    ashlib.svg_text(root, 20, 44, "区间只刻画采样不确定性;指标是代理指标,禁绝对化", 11, "#94a3b8")
    cols = {"control": "#38bdf8", "treat": "#f59e0b"}
    hi = len(rows) - 1
    for arm in ("control", "treat"):
        pts = " ".join(f"{x0 + i * (W - 130) // hi},{y1 - r[arm]['solveRate'] * (y1 - ytop) // 1000}"
                       for i, r in enumerate(rows))
        ET.SubElement(root, "polyline", {"points": pts, "fill": "none", "stroke": cols[arm], "stroke-width": "3"})
        for i, r in enumerate(rows):
            x = x0 + i * (W - 130) // hi
            ET.SubElement(root, "line", {"x1": str(x), "y1": str(y1 - r[arm]['wilsonHi'] * (y1 - ytop) // 1000),
                                         "x2": str(x), "y2": str(y1 - r[arm]['wilsonLo'] * (y1 - ytop) // 1000),
                                         "stroke": cols[arm], "stroke-width": "1", "opacity": "0.5"})
            ashlib.svg_text(root, x, y1 + 16, str(r["n"]), 10, "#94a3b8", "middle")
    ashlib.svg_text(root, 20, ytop + 20, "control(蓝) / treat(橙):竖线 = 整数 Wilson 区间", 11, "#e2e8f0")
    ashlib.svg_text(root, 20, ytop + 40, f"Δ={delta}‰:半宽之和 <= Δ 才可分辨", 11, "#94a3b8")
    return ashlib.svg_save(root, path)


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_proto_04_sampling.py:

z=2(整数近似,非 1.96)  Δ=150‰  blocks=4
    n  ctrl‰  treat‰    差‰    ctrlWilson   treatWilson     半宽和‰  <=Δ    不相交
    8    625     250   375     [301,865]      [70,596]      545   no     no
   16    625     250   375     [383,817]     [100,500]      417   no     no
   32    500     218   282     [334,666]      [88,356]      300   no     no
   64    437     250   187     [307,545]     [158,370]      225   no     no
  128    523     281   242     [429,601]     [203,357]      163   no    yes
  256    535     300   235     [469,591]     [244,356]      117  yes    yes

非占比类(块极差,blocks=4):
    n  ctrlDom‰     ctrlDom区间  treatDom‰  ctrlEnt‰     ctrlEnt区间
    8       625      [0,1000]        500      1546      [0,1584]
   16       625    [250,1000]        500      1546   [1251,1584]
   32       532     [250,625]        469      1507   [1251,1546]
   64       454     [375,625]        469      1468   [1413,1546]
  128       532     [375,625]        407      1512   [1413,1546]
  256       543     [454,610]        418      1517   [1468,1542]

csv=6 rows  svg=3730 bytes  nRequired=256  elapsed=880 ms
wrote out/proto04_sampling.csv out/proto04_sampling.svg out/proto04_sampling.json

读表:三件事

一、宽度随种子数收缩,但不是每个 n 都单调。 两臂半宽之和从 n=8 的 545‰ 一路收到 n=256 的 117‰,跨过 Δ=150‰;可解率的点估计却不是单调的——control 臂在 n=64 掉到 437‰,到 n=256 又回到 535‰。这说明点估计会飘,区间才是可比较的量。

二、判据跨线在 n=256。 n=128 时半宽之和还有 163‰(> 150,判 no),但两臂区间已经不重叠([429,601] vs [203,357]);到 n=256 半宽之和降到 117‰,<=Δ 才为 yes。不重叠不等于够分辨:不重叠只说明「差不为零」,够分辨才说明「差大到值得动作」。

三、非占比指标只能块极差,且更粗。 支配缺失度在 n=8 的块区间是 [0,1000](几乎没有信息),到 n=256 才收到 [454,610];策略熵同理。块极差不假设分布、任何指标都能用,代价就是粗——要窄区间又确实是占比类,才用 Wilson。

两种区间的口径对照

口径适用指标假设分布粒度本节的读数
整数 Wilson(z=2)占比类(可解率 k/n)二项千分比整数n=256 时半宽 56–62‰
块极差(blocks=4)任何指标(支配缺失度、策略熵)无1000/块内种子数n=256 时块宽 156‰

两种口径不能混用:给非占比指标套 Wilson 是错的(它没有 k/n 结构),给占比指标只报块极差是浪费(块极差更粗)。选口径的判据只有一条——这个指标是不是伯努利比例。

判据跨线在哪一档

n半宽之和‰≤ Δ=150‰两臂区间不相交可分辨
8545否否否
16417否否否
32300否否否
64225否否否
128163否是否
256117是是是

区间不修正偏差

本节最重要的一句:区间只刻画采样不确定性,不修正偏差。它回答「换个种子池,这个估计会飘多远」,不回答「这个指标离真实体验有多远」。窄区间 ≠ 指标准——一个偏的指标可以有很窄的区间。要让「指标偏不偏」有答案,得引入仿真之外的证据:

  • 机制建模偏差:run_spec 是规格的忠实执行,规格本身是不是玩家真在玩的东西,区间管不着。
  • 真实玩家测试数据:需要真实业务环境,命令行不可复现——本节不提供这类数据,任何涉及真人测试的结论一律标 X。
  • 代理指标边界:本节所有指标都是 proxy,只做相对比较,禁绝对化。区间窄不改变这一点。

自检表

检查项合格标准
口径写清z 与 blocks 写进 config;z=2 声明为整数近似而非 1.96
全整数用 isqrt 求平方根,落盘千分比整数,无浮点
口径匹配占比类用 Wilson,非占比类只给块极差,不许混用
判据可执行半宽之和 ≤ Δ 才算可分辨,写死判据不事后调
不夸大声明区间只覆盖采样不确定性、不修正偏差
代理指标声明指标声明为 proxy,只做相对比较;真人数据标 X

区间有了、判据有了,剩下的就是把它变成决策。下一节把「半宽 ≤ Δ」与「点差 ≥ Δ」串成三态判据,并诚实处理小样本。

阅读导航:上一节:4.1 测试的目标与假设 · 下一节:4.3 从测试到决策 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡