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‰ | 两臂区间不相交 | 可分辨 |
|---|---|---|---|---|
| 8 | 545 | 否 | 否 | 否 |
| 16 | 417 | 否 | 否 | 否 |
| 32 | 300 | 否 | 否 | 否 |
| 64 | 225 | 否 | 否 | 否 |
| 128 | 163 | 否 | 是 | 否 |
| 256 | 117 | 是 | 是 | 是 |
区间不修正偏差
本节最重要的一句:区间只刻画采样不确定性,不修正偏差。它回答「换个种子池,这个估计会飘多远」,不回答「这个指标离真实体验有多远」。窄区间 ≠ 指标准——一个偏的指标可以有很窄的区间。要让「指标偏不偏」有答案,得引入仿真之外的证据:
- 机制建模偏差:
run_spec是规格的忠实执行,规格本身是不是玩家真在玩的东西,区间管不着。 - 真实玩家测试数据:需要真实业务环境,命令行不可复现——本节不提供这类数据,任何涉及真人测试的结论一律标 X。
- 代理指标边界:本节所有指标都是 proxy,只做相对比较,禁绝对化。区间窄不改变这一点。
自检表
| 检查项 | 合格标准 |
|---|---|
| 口径写清 | z 与 blocks 写进 config;z=2 声明为整数近似而非 1.96 |
| 全整数 | 用 isqrt 求平方根,落盘千分比整数,无浮点 |
| 口径匹配 | 占比类用 Wilson,非占比类只给块极差,不许混用 |
| 判据可执行 | 半宽之和 ≤ Δ 才算可分辨,写死判据不事后调 |
| 不夸大 | 声明区间只覆盖采样不确定性、不修正偏差 |
| 代理指标声明 | 指标声明为 proxy,只做相对比较;真人数据标 X |
区间有了、判据有了,剩下的就是把它变成决策。下一节把「半宽 ≤ Δ」与「点差 ≥ Δ」串成三态判据,并诚实处理小样本。
阅读导航:上一节:4.1 测试的目标与假设 · 下一节:4.3 从测试到决策 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。