12.3 指标的置信区间
《小游戏开发实战》卷一 12.2 可玩性指标测试 那篇已经把「占比类指标 + 置信区间」这条路走通了,指标偏胜率、完成率。本节做的是同一件事的机制量版本:上一节那些从字段加工出来的指标(平均回合、输出完成度、剩余血量)大多不是伯努利比例,不能直接套二项区间,得另想办法。本节给两个全整数口径,并诚实标出它们的边界:区间只覆盖采样不确定性,不修正偏差。
点估计是错觉
上一节表里每个数字都长得像一个确定值:played.min=583‰。但它其实是「用这 16 个种子跑出来的一个数」。换 16 个种子,它会变。本节的开场实验直接把这件事摆出来:对同一个场景,种子数从 8 一路加到 256,看同一个指标怎么动。
结论先说:它会动,而且动得不少。临界场景下胜率的点估计在 n=8 时是 375‰,加到 n=256 时落到 222‰——同一个机制、同一批种子的前缀,只是看得更多,数就降了 153‰。所以「指标 = 一个数」是错觉;能进决策的表述是「指标 = 一个数 ± 一个区间」。
两个整数区间口径
块极差:把 n 个种子切成 B 块,每块单独算一遍指标,取块间的 [min, max]。它不假设任何分布,对任何指标都适用,但很粗——块数少时区间宽,且只能给出 1000/B 的整数粒度。
整数 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 略宽,是保守方向。
{ "seedCounts": [8, 16, 32, 64, 128, 256], "blocks": 4, "z": 2, "maxSteps": 40,
"scenarios": [ { "id": "mid", "enemyHp": 70, "enemyAttack": 20, "label": "临界附近" },
{ "id": "sat", "enemyHp": 70, "enemyAttack": 18, "label": "饱和" } ] }
S 层:六档种子数 × 两个场景
脚本对两个场景各扫六档种子数,对胜率同时给块极差与整数 Wilson,对输出完成度只给块极差(它不是占比类)。全部千分比整数、无浮点、无 random。
#!/usr/bin/env python3
# file: sim/gd_play_12_interval.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_12_interval.py
"""卷二 12.3 指标的置信区间(S 层):点估计有多准——用整数区间回答。
同一指标换一批种子就会变,所以「指标 = 一个数」是错觉。本节给两个**全整数**区间口径:
① 块极差:把 n 个种子切成 B 块,各块算一遍指标,取块间 [min, max];
② 整数 Wilson(z=2):对占比类指标,用整数平方根 isqrt 求
center = (k+2)/(n+4)、half = 2000·√((k(n−k)+n)/n)/(n+4),落盘为千分比整数。
对胜率(占比类)同时给两种区间,对策略胜率熵(非占比类)只给块极差;并量出区间宽度随
种子数 n 的收缩(≈1/√n,用整数比表达)。区间只刻画**采样不确定性**,不修正偏差——
指标仍是代理指标(proxy),只做相对比较,禁绝对化。真实玩家测试数据一律标 X。
产出 out/play12_interval.json + out/play12_interval.csv + out/play12_interval.svg。
全整数、无浮点、无 random;纯 xml.etree 出 SVG。
"""
import copy
import json
import os
import sys
import time
import xml.etree.ElementTree as ET
from math import isqrt
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_play_12_interval.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
SCALE = 1000000
KEYS = ("enemyHp", "turnLimit", "enemyAttack")
def choose(s, cost, dmg):
"""参考策略:最大伤害优先。"""
hand, en = s["hand"], s["energy"]
aff = [i for i, c in enumerate(hand) if cost[c] <= en]
if not aff:
return "e"
return f"p{max(aff, key=lambda i: (dmg[hand[i]], -cost[hand[i]]))}"
def play_snap(spec, seed, scen, max_steps, cost, dmg):
s = copy.deepcopy(spec)
s["seed"] = seed
s["params"].update({k: scen[k] for k in KEYS})
seq = []
for _ in range(max_steps):
sn = ashlib.run_spec(s, seq)
if sn["over"]:
break
seq.append(choose(sn, cost, dmg))
return ashlib.run_spec(s, seq)
def wilson(k, n, z):
"""整数 Wilson 区间(z 为整数),返回千分比 [lo, hi] 与半宽。"""
num = k * (n - k) + n
s_scaled = isqrt(num * SCALE * SCALE // n) # ≈ SCALE·√(num/n)
center = 1000 * (k + z * z // 2) // (n + z * z)
half = (1000 * z * s_scaled) // (SCALE * (n + z * z))
return max(0, center - half), min(1000, center + half), half
def main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
pool, counts = cfg["seedPool"], cfg["seedCounts"]
z, blocks = cfg["z"], cfg["blocks"]
cost = {c["id"]: c["cost"] for c in spec["cards"]}
dmg = {c["id"]: c["damage"] for c in spec["cards"]}
rows, csv = [], []
for scen in cfg["scenarios"]:
for n in counts:
seeds = pool[:n]
won = [play_snap(spec, sd, scen, cfg["maxSteps"], cost, dmg)["won"] for sd in seeds]
k = sum(1 for w in won if w)
lo, hi, half = wilson(k, n, z)
bs = max(1, n // blocks)
block_rates = [1000 * sum(1 for w in won[i * bs:(i + 1) * bs] if w) // bs
for i in range(blocks)]
# 输出完成度是非占比类指标(千分比),只给块极差:每块的 mean(dealt)÷enemyHp
dealt_blocks = []
for b in range(blocks):
blk = seeds[b * bs:(b + 1) * bs]
ds = [play_snap(spec, sd, scen, cfg["maxSteps"], cost, dmg)["dealt"] for sd in blk]
dealt_blocks.append(1000 * (sum(ds) // len(ds)) // scen["enemyHp"])
rows.append({"scenario": scen["id"], "label": scen["label"], "n": n, "k": k,
"win": 1000 * k // n, "wilsonLo": lo, "wilsonHi": hi, "wilsonWidth": hi - lo,
"blockLo": min(block_rates), "blockHi": max(block_rates),
"blockWidth": max(block_rates) - min(block_rates),
"dealtLo": min(dealt_blocks), "dealtHi": max(dealt_blocks),
"dealtWidth": max(dealt_blocks) - min(dealt_blocks)})
csv.append([scen["id"], n, k, 1000 * k // n, lo, hi, hi - lo,
min(block_rates), max(block_rates), max(block_rates) - min(block_rates),
min(dealt_blocks), max(dealt_blocks), max(dealt_blocks) - min(dealt_blocks)])
m = ashlib.write_csv(os.path.join(OUT, "play12_interval.csv"),
["scenario", "n", "k", "win", "wilsonLo", "wilsonHi", "wilsonWidth",
"blockLo", "blockHi", "blockWidth",
"dealtLo", "dealtHi", "dealtWidth"], csv)
size = draw(rows, cfg["scenarios"], os.path.join(OUT, "play12_interval.svg"))
ratios = {}
for s in cfg["scenarios"]:
w_first = next(r for r in rows if r["scenario"] == s["id"] and r["n"] == counts[0])["wilsonWidth"]
w_last = next(r for r in rows if r["scenario"] == s["id"] and r["n"] == counts[-1])["wilsonWidth"]
ratios[s["id"]] = 1000 * w_first // (w_last or 1)
report = {"id": "gd_play_12_interval", "z": z, "blocks": blocks, "scenarios": cfg["scenarios"],
"seedCounts": counts, "rows": rows, "widthRatioFirstLast": ratios,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play12_interval.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
for scen in cfg["scenarios"]:
print(f"== 场景 {scen['id']}({scen['label']}):enemyHp={scen['enemyHp']} "
f"enemyAttack={scen['enemyAttack']} turnLimit={scen['turnLimit']}")
print(f"{'n':>5}{'k':>5}{'胜率‰':>7}{'Wilson下':>9}{'Wilson上':>9}{'宽':>5}"
f"{'块下':>6}{'块上':>6}{'块宽':>5}{'输完下':>7}{'输完上':>7}{'输完宽':>7}")
for r in (x for x in rows if x["scenario"] == scen["id"]):
print(f"{r['n']:>5}{r['k']:>5}{r['win']:>7}{r['wilsonLo']:>9}{r['wilsonHi']:>9}"
f"{r['wilsonWidth']:>5}{r['blockLo']:>6}{r['blockHi']:>6}{r['blockWidth']:>5}"
f"{r['dealtLo']:>7}{r['dealtHi']:>7}{r['dealtWidth']:>7}")
print(f" Wilson 宽度 n={counts[0]}→n={counts[-1]} 收窄到 1/{ratios[scen['id']] // 1000 + 1}"
f"(比值 {ratios[scen['id']]}‰;√{counts[-1] // counts[0]}≈{isqrt(counts[-1] // counts[0])} 仅作参考)")
print(f"csv={m} rows svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/play12_interval.csv out/play12_interval.svg out/play12_interval.json")
ET.parse(os.path.join(OUT, "play12_interval.svg"))
return 0
def draw(rows, scenarios, path):
W, H, x0, y0, y1 = 700, 320, 70, 48, 240
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 20, 26, "Wilson 区间宽度随种子数收缩(千分比,越小越准)", 15)
ashlib.svg_text(root, 20, 46, "区间只刻画采样不确定性;指标仍是代理指标,禁绝对化", 11, "#94a3b8")
series = [("wilsonWidth", "Wilson", "#38bdf8"), ("blockWidth", "block", "#f59e0b"),
("dealtWidth", "dealt", "#22c55e")]
for sid, scen in enumerate(scenarios):
rs = [r for r in rows if r["scenario"] == scen["id"]]
for k, (field, nm, col) in enumerate(series):
mx = max(r[field] for r in rows) or 1
hi = len(rs) - 1
pts = " ".join(f"{x0 + i * (W - 130) // hi},{y1 - r[field] * (y1 - y0) // mx}"
for i, r in enumerate(rs))
ET.SubElement(root, "polyline", {"points": pts, "fill": "none", "stroke": col,
"stroke-width": "3" if sid == 0 else "1",
"stroke-dasharray": "0" if sid == 0 else "4 3"})
ashlib.svg_text(root, 20, y0 + 20 + 20 * sid, f"{scen['id']}: Wilson/block/entropy 宽度", 11,
"#e2e8f0" if sid == 0 else "#94a3b8")
return ashlib.svg_save(root, path)
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_12_interval.py:
== 场景 mid(临界附近(胜率约两成)):enemyHp=70 enemyAttack=20 turnLimit=20
n k 胜率‰ Wilson下 Wilson上 宽 块下 块上 块宽 输完下 输完上 输完宽
8 3 375 134 698 564 0 500 500 900 971 71
16 5 312 140 560 420 0 500 500 928 942 14
32 7 218 109 391 282 0 375 375 900 942 42
64 17 265 171 387 216 125 312 187 914 942 28
128 28 218 155 299 144 156 312 156 914 942 28
256 57 222 175 277 102 171 265 94 928 928 0
Wilson 宽度 n=8→n=256 收窄到 1/6(比值 5529‰;√32≈5 仅作参考)
== 场景 sat(饱和(几乎全胜)):enemyHp=70 enemyAttack=18 turnLimit=20
n k 胜率‰ Wilson下 Wilson上 宽 块下 块上 块宽 输完下 输完上 输完宽
8 7 875 522 978 456 500 1000 500 957 1000 43
16 15 937 711 989 278 750 1000 250 971 1000 29
32 31 968 839 993 154 875 1000 125 985 1000 15
64 63 984 914 996 82 937 1000 63 985 1000 15
128 127 992 956 998 42 968 1000 32 985 1000 15
256 252 984 959 993 34 953 1000 47 985 1000 15
Wilson 宽度 n=8→n=256 收窄到 1/14(比值 13411‰;√32≈5 仅作参考)
csv=12 rows svg=1692 bytes elapsed=230 ms
wrote out/play12_interval.csv out/play12_interval.svg out/play12_interval.json
读表:三件事
一、宽度随种子数收缩,但不总是 1/√n。 临界场景的 Wilson 宽度从 564‰ 收到 102‰,比值 5.5 倍,和 √(256/8)=5.66 很接近;饱和场景却收了 13.4 倍,远超 √32。原因是近边界效应:胜率 984‰ 已经贴着 1000‰,Wilson 区间被上界压住变成单侧,宽度掉得比 1/√n 快。所以「1/√n 定律」只在指标离边界远时成立——把它当成通用公式会低估近边界处的精度。
二、块极差比 Wilson 宽,且更粗。 两个场景里块宽都明显大于 Wilson 宽(如 n=256:94‰ vs 102‰、47‰ vs 34‰),且块宽只能取 1000/块内种子数 的整数倍。块极差不假设分布、对任何指标可用,代价就是更粗。要窄区间、又确实是占比类,用 Wilson;是机制量,就只能用块极差。
三、小样本区间不保证盖住大样本值。 临界场景 n=8 的区间是 [134, 698],n=256 的 222‰ 落在里面——盖住了。但饱和场景 n=8 的区间是 [522, 978],n=256 的 984‰ 落在区间外。区间不是保证,它是一个模型内的不确定性刻画;种子池本身若是有偏的(比如前 8 个种子恰好都偏顺),区间再宽也救不回来。
区间不修正偏差
这是本节最重要的一句:区间只刻画采样不确定性,不修正偏差。它回答的是「换个种子池,这个估计会飘多远」,不回答「这个指标离真实体验有多远」。窄区间 ≠ 指标准——一个偏的指标可以有很窄的区间。要让「指标偏不偏」有答案,得引入仿真之外的证据:
- 机制建模偏差:
run_spec是规格的忠实执行,规格本身是不是玩家真在玩的东西,区间管不着。 - 真实玩家测试数据:需要真实业务环境,命令行不可复现——本节不提供这类数据,任何涉及真人测试的结论一律标 X。
- 代理指标边界:本节所有指标都是 proxy,只做相对比较,禁绝对化。区间窄不改变这一点。
所以正确的用法是:先固定种子规模与区间口径,再比较配置。同一口径下的两个区间不重叠,才谈得上「这两个配置的差别超过了采样噪声」;区间重叠时,别把噪声当信号。
自检表
| 检查项 | 合格标准 |
|---|---|
| 口径写清 | 块数与 z 写进 config;z=2 声明为整数近似而非 1.96 |
| 全整数 | 用 isqrt 求平方根,落盘千分比整数,无浮点 |
| 口径匹配 | 占比类用 Wilson,非占比类只给块极差,不许混用 |
| 边界诚实 | 近 0 / 1000 时说明单侧效应与 1/√n 失效 |
| 不夸大 | 声明区间只覆盖采样不确定性、不修正偏差 |
| 代理指标声明 | 指标声明为 proxy,只做相对比较;真人数据标 X |
三节下来,体验度量这条线闭环了:选哪些量(12.1)、怎么把字段加工成量(12.2)、这个量有多准(12.3)。三节共用同一批种子与同一套整数纪律,所以三节的读数可以互相引用、直接对照。下一章把这些量组装成「可玩性」的操作化定义。
阅读导航:上一节:12.2 从机制仿真到体验指标 · 下一节:13.1 可玩性的操作化定义 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。