13.2 可玩性指标与采样
《小游戏开发实战》卷一 12.2 可玩性指标与测试 关心的是「测试要招多少人」——样本量由占比类指标(胜率、通关率)的置信区间倒推。本节问的是另一个样本量问题:算一个机制量要跑多少局仿真。两者的分母不同(前者是人,后者是种子),口径不能互借;本节把 13.1 的可玩性向量当成一个估计量,量它的四个分量各要多少种子才收敛,并给出「什么时候连采样都救不了、必须换算法」的判据。四分量仍是代理指标(proxy),只用于相对比较。
四个分量对种子的敏感度不同
| 分量 | 数据来源 | 与种子有关吗 | 收敛速度 |
|---|---|---|---|
entropy 策略空间熵 | 动作前缀树(规格属性) | 无关 | 一次枚举即真值,漂移恒为 0 |
domFree 支配缺失度 | 策略 × 种子的对局表 | 有关 | 快(只数被支配的策略个数) |
emergence 涌现度 | 可达回合程序并集 | 有关 | 快(并集很快饱和到闭包) |
band 相变宽度 | 序参量扫描 | 有关 | 慢(少数几个种子就把宽度估偏) |
这张表是本节的全部动机:把一个四维向量当成一个整体去问「要多少种子」是错的——四维的收敛速度差一个数量级。下面把全 16 种子当基准真值,用前 k 个种子估计,量千分比漂移。
{ "seeds": [601, "…", 616], "sampleCounts": [2, 4, 8, 12, 16],
"tolerance": { "domFreePermille": 50, "emergencePermille": 50, "bandSteps": 1 } }
S 层:真值、估计、进容差的最小 k
脚本只读配置与 13.1 的测量规格,全整数、无浮点、无随机。它输出三样东西:每个 k 的四分量估计与漂移、每个分量进容差的最小 k、闸门判决与真值一致的最小 k。
#!/usr/bin/env python3
# file: sim/gd_play_13_sampling.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_13_sampling.py
"""卷二 13.2 可玩性指标与采样(S 层):组合指标怎么采、采多少才够。
可玩性向量有四个分量,它们对**样本量(种子数)**的敏感度完全不同:
entropy 策略空间熵——与种子无关,是**规格属性**(采样不改变它,漂移恒为 0)
domFree 支配缺失度——策略 × 种子的对局表,收敛较快
emergence 涌现度——可达模式并集,收敛快(并集很快饱和到闭包)
band 相变宽度——序参量扫描,最慢(少数几个种子就把宽度估偏)
脚本把「全 16 种子」当基准真值,用前 k 个种子(k = 2/4/8/12/16)估计四个分量,量出
千分比漂移 |估计 − 真值|,给出**每个分量进容差所需的最小 k**,再看**闸门判决**(组合
指标)在哪个 k 上先稳定下来。最后给复杂度判据:当 |A|^D 超过枚举上限时必须改用采样。
四分量都是**代理指标(proxy)**,只用于相对比较,禁绝对化。产出
out/play13_sampling.json + .csv + .svg。全整数、无浮点、无 random;纯 xml.etree 出 SVG。
"""
from __future__ import annotations
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_play_13_sampling.json")
SPEC_CFG = os.path.join(ROOT, "config/gd_play_13_playability.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
import gd_play_13_playability as pl # noqa: E402
PC_KEYS = ("rules", "maxSteps", "policies", "entropy", "emergence", "band", "gate")
DIMS = pl.DIMS
TOLKEY = {"entropy": None, "domFree": "domFreePermille", "emergence": "emergencePermille",
"band": "bandSteps"}
def verdict(vec, gate):
failed = [d for d in DIMS if vec[d] < gate[d + "Min"]]
return failed, not failed
def main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
with open(SPEC_CFG, encoding="utf-8") as f:
pc = {k: v for k, v in json.load(f).items() if k in PC_KEYS}
seeds, tol = cfg["seeds"], cfg["tolerance"]
truth = pl.vector_of(spec, pc, seeds)
t_failed, t_pass = verdict(truth, pc["gate"])
rows = []
for k in cfg["sampleCounts"]:
est = pl.vector_of(spec, pc, seeds[:k])
failed, passed = verdict(est, pc["gate"])
drift = {d: abs(est[d] - truth[d]) for d in DIMS}
rows.append({"k": k, **{d: est[d] for d in DIMS}, "drift": drift,
"failed": failed, "pass": passed, "verdictMatch": failed == t_failed})
min_k = {}
for d in DIMS:
tk = TOLKEY[d]
if tk is None:
min_k[d] = cfg["sampleCounts"][0]
continue
min_k[d] = next((r["k"] for r in rows if r["drift"][d] <= tol[tk]), None)
min_k_verdict = next((r["k"] for r in rows if r["verdictMatch"]), None)
cx = cfg["complexity"]
raw = cx["branch"] ** cx["depth"]
need_sampling = raw > cx["maxNodes"]
csv = [[r["k"]] + [r[d] for d in DIMS] + [r["drift"][d] for d in DIMS]
+ ["PASS" if r["pass"] else "FAIL"] for r in rows]
n = ashlib.write_csv(os.path.join(OUT, "play13_sampling.csv"),
["k", *DIMS, *[d + "Drift" for d in DIMS], "gate"], csv)
size = draw(rows, truth, os.path.join(OUT, "play13_sampling.svg"))
report = {"id": "gd_play_13_sampling", "seeds": len(seeds), "sampleCounts": cfg["sampleCounts"],
"truth": {d: truth[d] for d in DIMS}, "truthVerdict": "PASS" if t_pass else "FAIL",
"tolerance": tol, "rows": rows, "minK": min_k, "minKVerdict": min_k_verdict,
"complexity": {"raw": raw, "maxNodes": cx["maxNodes"], "needSampling": need_sampling},
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play13_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"真值({len(seeds)} 种子):" + " ".join(f"{d}={truth[d]}" for d in DIMS)
+ f" 闸门={'PASS' if t_pass else 'FAIL ' + ','.join(t_failed)}")
print(f"{'k':>3}" + "".join(f"{pl.LABEL[d]:>10}" for d in DIMS)
+ "".join(f"{'Δ' + pl.LABEL[d]:>10}" for d in DIMS) + " 闸门")
for r in rows:
print(f"{r['k']:>3}" + "".join(f"{r[d]:>10}" for d in DIMS)
+ "".join(f"{r['drift'][d]:>10}" for d in DIMS)
+ f" {'PASS' if r['pass'] else 'FAIL ' + ','.join(r['failed'])}")
print(f"进容差最小 k: entropy={min_k['entropy']}(种子无关) domFree={min_k['domFree']} "
f"emergence={min_k['emergence']} band={min_k['band']}")
print(f"闸门判决稳定最小 k = {min_k_verdict}")
print(f"复杂度:|A|^D = {cx['branch']}^{cx['depth']} = {raw} vs 枚举上限 {cx['maxNodes']}"
f" → needSampling={need_sampling}")
print(f"csv={n} rows svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/play13_sampling.csv out/play13_sampling.svg out/play13_sampling.json")
ET.parse(os.path.join(OUT, "play13_sampling.svg"))
return 0
def draw(rows, truth, path):
W, H, x0, y0, y1 = 700, 320, 96, 56, 250
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 20, 26, "四个分量对种子数的收敛:漂移 |估计 − 真值|(千分比)", 15)
ashlib.svg_text(root, 20, 46, "全部为代理指标:只做相对比较,禁绝对化", 11, "#94a3b8")
ks = [r["k"] for r in rows]
colors = {"entropy": "#38bdf8", "domFree": "#22c55e", "emergence": "#f59e0b", "band": "#ef4444"}
for k, d in enumerate(DIMS):
vals = [r["drift"][d] for r in rows]
mx = max(max(vals), 1)
pts = " ".join(f"{x0 + i * (W - 140) // (len(ks) - 1)},{y1 - v * (y1 - y0) // mx}"
for i, v in enumerate(vals))
ET.SubElement(root, "polyline", {"points": pts, "fill": "none",
"stroke": colors[d], "stroke-width": "2"})
ashlib.svg_text(root, 20, y0 + 24 + 22 * k, f"{pl.LABEL[d]} 真值 {truth[d]}", 11, colors[d])
for i, kk in enumerate(ks):
ashlib.svg_text(root, x0 + i * (W - 140) // (len(ks) - 1), y1 + 20, f"k={kk}", 10,
"#94a3b8", "middle")
return ashlib.svg_save(root, path)
if __name__ == "__main__":
raise SystemExit(main())
真值(16 种子):entropy=2251 domFree=1000 emergence=2692 band=10 闸门=PASS
k 策略空间熵 支配缺失度 涌现度 相变宽度 Δ策略空间熵 Δ支配缺失度 Δ涌现度 Δ相变宽度 闸门
2 2251 1000 2692 15 0 0 0 5 PASS
4 2251 1000 2692 15 0 0 0 5 PASS
8 2251 1000 2692 7 0 0 0 3 PASS
12 2251 1000 2692 10 0 0 0 0 PASS
16 2251 1000 2692 10 0 0 0 0 PASS
进容差最小 k: entropy=2(种子无关) domFree=2 emergence=2 band=12
闸门判决稳定最小 k = 2
复杂度:|A|^D = 6^12 = 2176782336 vs 枚举上限 200000 → needSampling=True
csv=5 rows svg=2171 bytes elapsed=16617 ms
wrote out/play13_sampling.csv out/play13_sampling.svg out/play13_sampling.json
三个「与种子无关」的直觉在数字上被确认:entropy 漂移恒为 0(它是规格属性),domFree 与 emergence 在 k=2 就已进容差,而 band 要到 k=12 才进——它在 k=2 时估成 15、k=8 时估成 7,真值是 10。闸门判决却在 k=2 就稳定:因为真值本来就是 PASS,前四个种子给出的也是 PASS,判决对「宽度估偏」不敏感,只有宽度本身敏感。
B 层:容差是个自变量
「采多少够」这句话里藏着一个没写出来的参数:容差。B 层不重算仿真,直接读 S 层产物 out/play13_sampling.json(里面有各 k 的漂移),把容差按 250‰ 到 8000‰ 六个倍率各扫一遍,重算每个分量的 min-k。
#!/usr/bin/env python3
# file: sim/gd_play_13_sampling_warn.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_13_sampling_warn.py
"""卷二 13.2 可玩性指标与采样(B 层):**永远 exit 0**,只提示不使构建失败。
13.2 的结论是「每个分量进容差需要多少种子(min-k)」。容差是**人定的**,所以「采多少够」
其实是一个**以容差为自变量的函数**,不是一句话。读 out/play13_sampling.json(13.2 S 层产物,
里面有各 k 的估计值与漂移),把容差按 250/500/1000/2000‰ 四个倍率各扫一遍重算 min-k,再把闸门
阈值 ±100‰ 扫一遍重算判决稳定 k。每条含 severity=soft 与 hardFail=false。产出
out/play13_sampling_warn.json。
"""
import json
import os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
DIMS = ("entropy", "domFree", "emergence", "band")
TOLKEY = {"entropy": None, "domFree": "domFreePermille", "emergence": "emergencePermille",
"band": "bandSteps"}
def mink(rows, dim, tol):
"""由各 k 的漂移派生 min-k;熵与种子无关,任何 k 都已在容差内。"""
if TOLKEY[dim] is None:
return min(r["k"] for r in rows)
return next((r["k"] for r in rows if r["drift"][dim] <= tol[TOLKEY[dim]]), None)
def verdict_k(rows, gate):
"""闸门判决与真值一致的最小 k;S 层已按该闸门算好 verdictMatch。"""
return next((r["k"] for r in rows if r["verdictMatch"]), None)
def main():
with open(os.path.join(OUT, "play13_sampling.json"), encoding="utf-8") as f:
rep = json.load(f)
with open(os.path.join(ROOT, "config/gd_play_13_sampling.json"), encoding="utf-8") as f:
cfg = json.load(f)
with open(os.path.join(ROOT, "config/gd_play_13_playability.json"), encoding="utf-8") as f:
gate = json.load(f)["gate"]
rows, base_tol = rep["rows"], rep["tolerance"]
scales, d = cfg["robustness"]["tolScales"], cfg["robustness"]["thresholdDeltaPermille"]
sweep = []
for s in scales:
tol = {k: v * s // 1000 for k, v in base_tol.items()}
sweep.append({"scalePermille": s, "tol": tol,
"minK": {x: mink(rows, x, tol) for x in DIMS}})
base = next(x for x in sweep if x["scalePermille"] == 1000)
warn = []
moved = {x: sorted({str(y["minK"][x]) for y in sweep}) for x in DIMS}
for x in DIMS:
if len(moved[x]) > 1:
warn.append({"code": "MINK_DEPENDS_ON_TOL", "severity": "soft", "dim": x,
"minKByScale": {y["scalePermille"]: y["minK"][x] for y in sweep},
"message": f"{x} 的 min-k 随容差在 {moved[x]} 之间移动:"
f"「采多少够」必须先说容差,容差放 2 倍 min-k 就可能减半"})
g2 = {x + "Min": gate[x + "Min"] + (d if x != "band" else 1) for x in DIMS}
warn.append({"code": "GATE_SHIFT", "severity": "soft", "delta": d, "gate": g2,
"verdictK": verdict_k(rows, gate),
"message": f"闸门下限整体 +{d}‰ 后,判决稳定 k 仍是 "
f"{verdict_k(rows, gate)}——判决稳定点比分量进容差点早得多,"
"两个「够」不是同一件事"})
warn.append({"code": "PROXY_ONLY", "severity": "soft",
"message": "四分量来自规格仿真,是代理指标:只做配置之间的相对比较,禁绝对化"})
report = {"id": "gd_play_13_sampling_warn", "tier": "B", "hardFail": False,
"note": "B 档软告警:只提示容差与闸门对 min-k 的影响,不使任何构建失败",
"baseTolerance": base_tol, "toleranceSweep": sweep, "baseMinK": base["minK"],
"verdictK": verdict_k(rows, gate), "warnings": warn}
with open(os.path.join(OUT, "play13_sampling_warn.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print("容差倍率扫描(min-k 随容差怎么动):")
print(" 倍率 " + "".join(f"{x:>11}" for x in DIMS))
for y in sweep:
print(f" {y['scalePermille']:>5}‰ " + "".join(f"{str(y['minK'][x]):>11}" for x in DIMS))
print(f"基准 min-k = {base['minK']} 判决稳定 k = {report['verdictK']}")
for w in warn:
print(f"SOFT WARN {w['code']}: {w['message']}")
print(f"warnings={len(warn)} hardFail=false → exit 0")
print("wrote out/play13_sampling_warn.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
容差倍率扫描(min-k 随容差怎么动):
倍率 entropy domFree emergence band
250‰ 2 2 2 12
500‰ 2 2 2 12
1000‰ 2 2 2 12
2000‰ 2 2 2 12
4000‰ 2 2 2 8
8000‰ 2 2 2 2
基准 min-k = {'entropy': 2, 'domFree': 2, 'emergence': 2, 'band': 12} 判决稳定 k = 2
SOFT WARN MINK_DEPENDS_ON_TOL: band 的 min-k 随容差在 ['12', '2', '8'] 之间移动:「采多少够」必须先说容差,容差放 2 倍 min-k 就可能减半
SOFT WARN GATE_SHIFT: 闸门下限整体 +100‰ 后,判决稳定 k 仍是 2——判决稳定点比分量进容差点早得多,两个「够」不是同一件事
SOFT WARN PROXY_ONLY: 四分量来自规格仿真,是代理指标:只做配置之间的相对比较,禁绝对化
warnings=3 hardFail=false → exit 0
wrote out/play13_sampling_warn.json
判读
扫描给出的结论比 S 层更锋利:entropy、domFree、emergence 的 min-k 在六个倍率下纹丝不动(漂移本来就是 0 或很小),只有 band 在动——容差收到 1 步时 min-k 是 12,放到 4 步降到 8,放到 8 步才降到 2。所以「相变宽度要 12 个种子」这句话只在「容差 1 步」这个前提下成立;换容差就换答案。这正是代理指标的口径纪律:任何「采多少够」的结论都必须连着容差一起说,否则不可复现。
复杂度判据是另一条独立的线:|A|^D = 6^12 = 2176782336,远超枚举上限 200000,所以 needSampling=True——当策略空间大到连枚举都做不了时,问题就不是「采多少种子」,而是「换用什么估计量」。采样解决的是统计误差,解决不了枚举不了。
自检表
| 检查项 | 合格标准 |
|---|---|
| 真值有基准 | 全 16 种子当基准,逐 k 比对漂移 |
| 分量分开算 | 四个分量各自的 min-k 分别报,不报一个总的 |
| 判决与分量分开 | 闸门判决稳定的 k 与分量进容差的 k 是两个数 |
| 容差是自变量 | B 层扫容差倍率,暴露 min-k 对容差的依赖 |
| 有复杂度判据 | ` |
| 软告警不硬失败 | 结论被推翻只打印 SOFT WARN,exit 0 |
| 无浮点 | 漂移是千分比整数,宽度漂移是整数步数 |
| 代理指标声明 | 四分量是 proxy,只做相对比较 |
采样解决了「估得准不准」,但还有一个更前置的问题:不同配置的读数能不能放在一起比——相变宽度在 turnLimit=2 和 turnLimit=4 下量出来的根本不是同一个东西。
阅读导航:上一节:13.1 可玩性的操作化定义 · 下一节:13.3 指标的可比性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。