4.3 从测试到决策
《小游戏开发实战》卷一 12.2 可玩性指标测试 那篇停在「区间有多准」。本节再往前一步:区间怎么变成决策。判据必须给出三态——通过 / 不通过 / 样本不足,而不是二态的「显著 / 不显著」。把「样本不足」单列,是为了让「没测出来」和「测出来没有」分开:前者该补样本,后者该放弃改动。
三态判据
全部整数,三个分支,顺序不能反:
INSUFFICIENT:两臂整数 Wilson 半宽之和 > Δ —— 连 Δ 大小的真效果都分辨不出,样本不足。PASS:可分辨 且 点差 ≥ Δ —— 效果达到设计相关的最小可分辨差。FAIL:可分辨 且 点差 < Δ —— 没有超出阈值的证据,不上。
关键在先看半宽、再看点差。反过来先看点差,小样本里的大点差会骗人——本节 T3 就是活例:16 个种子下两臂可解率差到 375‰,看着比 T1 还大,但半宽之和 417‰ 远大于 Δ,判据诚实地给出「样本不足」。
{ "scenario": { "enemyHp": 65, "turnLimit": 16, "energyMax": 3, "playerHp": 60 },
"referenceStrategy": "maxdmg", "seedStart": 401, "seedCount": 256,
"z": 2, "minDiscerniblePermille": 150,
"cases": [
{ "id": "T1-难度上调", "control": { "enemyAttack": 20 }, "treat": { "enemyAttack": 22 }, "n": 256, "expect": "PASS" },
{ "id": "T2-参数微调", "control": { "enemyAttack": 20 }, "treat": { "enemyAttack": 21 }, "n": 256, "expect": "FAIL" },
{ "id": "T3-小样本", "control": { "enemyAttack": 20 }, "treat": { "enemyAttack": 22 }, "n": 16, "expect": "INSUFFICIENT" } ] }
S 层:三个案例
脚本对三个案例各跑一遍:T1 难度上调(20→22)、T2 参数微调(20→21)、T3 小样本(20→22 但只采 16 个)。每个案例算可解率、整数 Wilson 区间、半宽之和,再套三态判据。
#!/usr/bin/env python3
# file: sim/gd_proto_04_decision.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_04_decision.py
"""卷三 4.3 从测试到决策(S 层):区间 + 阈值 -> 通过 / 不通过 / 样本不足 三态。
三态判据(全部整数):
INSUFFICIENT 两臂整数 Wilson 半宽之和 > Δ —— 连 Δ 大小的真效果都分辨不出,样本不足
PASS 可分辨 且 点差 >= Δ —— 效果达到设计相关的最小可分辨差
FAIL 可分辨 且 点差 < Δ —— 没有超出阈值的证据,不上
本脚本真跑三个案例,其中 T3 是小样本案例,**诚实输出「样本不足」**而不是硬判通过。
指标是代理指标(proxy),只做相对比较,禁绝对化;区间只刻画采样不确定性、不修正偏差。
产出 out/proto04_decision.json。全整数、无浮点、无 random。
"""
import json
import os
import sys
import time
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_decision.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
import gd_proto_04_common as C # noqa: E402
def verdict(diff: int, half_sum: int, delta: int) -> str:
if half_sum > delta:
return "INSUFFICIENT"
return "PASS" if diff >= delta else "FAIL"
def main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
fn = dict(C.strategies(spec))[cfg["referenceStrategy"]]
pool = list(range(cfg["seedStart"], cfg["seedStart"] + cfg["seedCount"]))
z, delta = cfg["z"], cfg["minDiscerniblePermille"]
print("判据:半宽和<=Δ 且 点差>=Δ -> PASS;半宽和<=Δ 且 点差<Δ -> FAIL;半宽和>Δ -> INSUFFICIENT")
print(f"z={z} Δ={delta}‰ 参考策略={cfg['referenceStrategy']}\n")
print(f"{'case':<14}{'n':>5}{'ctrl‰':>7}{'treat‰':>8}{'差‰':>6}{'半宽和‰':>9}{'判定':>14}{'期望':>14}{'一致':>5}")
rows, ok_all = [], True
for case in cfg["cases"]:
n = case["n"]
seeds = pool[:n]
c = C.solve_rate(spec, seeds, {**cfg["scenario"], **case["control"]}, fn)
t = C.solve_rate(spec, seeds, {**cfg["scenario"], **case["treat"]}, fn)
lo_c, hi_c, half_c = C.wilson(c * n // 1000, n, z)
lo_t, hi_t, half_t = C.wilson(t * n // 1000, n, z)
diff, half_sum = c - t, half_c + half_t
v = verdict(diff, half_sum, delta)
same = v == case["expect"]
ok_all = ok_all and same
rows.append({"id": case["id"], "n": n, "control": c, "treat": t, "diff": diff,
"halfSum": half_sum, "ctrlWilson": [lo_c, hi_c], "treatWilson": [lo_t, hi_t],
"verdict": v, "expect": case["expect"], "match": same})
print(f"{case['id']:<14}{n:>5}{c:>7}{t:>8}{diff:>6}{half_sum:>9}{v:>14}"
f"{case['expect']:>14}{'ok' if same else 'MISMATCH':>5}")
print(f"\n三态分布:PASS={sum(1 for r in rows if r['verdict'] == 'PASS')} "
f"FAIL={sum(1 for r in rows if r['verdict'] == 'FAIL')} "
f"INSUFFICIENT={sum(1 for r in rows if r['verdict'] == 'INSUFFICIENT')}")
print(f"与期望一致:{'全部一致' if ok_all else '存在不一致'}(T3 诚实地判为样本不足,不硬判通过)")
report = {"id": "gd_proto_04_decision", "z": z, "minDiscerniblePermille": delta,
"allMatch": ok_all, "cases": rows, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "proto04_decision.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"\nwrote out/proto04_decision.json elapsed={report['elapsedMs']} ms")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_proto_04_decision.py:
判据:半宽和<=Δ 且 点差>=Δ -> PASS;半宽和<=Δ 且 点差<Δ -> FAIL;半宽和>Δ -> INSUFFICIENT
z=2 Δ=150‰ 参考策略=maxdmg
case n ctrl‰ treat‰ 差‰ 半宽和‰ 判定 期望 一致
T1-难度上调 256 535 300 235 117 PASS PASS ok
T2-参数微调 256 535 535 0 122 FAIL FAIL ok
T3-小样本 16 625 250 375 417 INSUFFICIENT INSUFFICIENT ok
三态分布:PASS=1 FAIL=1 INSUFFICIENT=1
与期望一致:全部一致(T3 诚实地判为样本不足,不硬判通过)
wrote out/proto04_decision.json elapsed=106 ms
为什么 T3 判「样本不足」
T3 的点差 375‰ 是三个案例里最大的,却判 INSUFFICIENT——因为它的半宽之和 417‰ 比 Δ=150‰ 还大。噪声比信号大时,点差再大也不可信:16 个种子下,可解率 625‰ 与 250‰ 各自都可能是 400‰ 或 800‰,两臂的真实差也许只有 50‰。补到 256 个种子后(就是 T1),同样的两臂差回落到 235‰,才进入「可分辨」的区间。
T2 是另一种诚实的失败:参数从 20 微调到 21,两臂可解率完全相同(都是 535‰),点差 0,判 FAIL。不是「测不出」,是「确实没有」——这条改动不值得为它承担任何风险。三态的价值就在这里:把「再测测」和「别改了」分成两句不同的话。
B 层:三态对 Δ 与 z 的稳健性
判据里有两个人为选定的数:最小可分辨差 Δ 与区间口径 z。它们不是数据算出来的,是测试前定的。B 层把 Δ 在 {100,150,200}‰、z 在 {1,2,3} 上扰动,看三态怎么动。脚本软告警不硬失败:翻转只打印 WARN,永远 exit 0。
#!/usr/bin/env python3
# file: sim/gd_proto_04_robustness.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_proto_04_robustness.py
"""卷三 4.3 的 B 层:决策三态对阈值 Δ 与 z 的稳健性。
同一批实测比例,把 Δ 在 {100,150,200}‰、z 在 {1,2,3} 上扰动,重算三态判据:
- 判定**翻转**的格子打印 WARN(软告警,不硬失败);
- 脚本**永远 exit 0**——稳健性是「报告」不是「断言」;
- 所有数字来自本机实跑,无浮点、无 random。
指标是代理指标(proxy),只做相对比较,禁绝对化。
产出 out/proto04_robustness.json。
"""
import json
import os
import sys
import time
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_decision.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
import gd_proto_04_common as C # noqa: E402
DELTAS = [100, 150, 200]
ZGRID = [1, 2, 3]
def verdict(diff: int, half_sum: int, delta: int) -> str:
if half_sum > delta:
return "INSUFFICIENT"
return "PASS" if diff >= delta else "FAIL"
def main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
fn = dict(C.strategies(spec))[cfg["referenceStrategy"]]
pool = list(range(cfg["seedStart"], cfg["seedStart"] + cfg["seedCount"]))
base_delta, base_z = cfg["minDiscerniblePermille"], cfg["z"]
rates = []
for case in cfg["cases"]:
n = case["n"]
seeds = pool[:n]
c = C.solve_rate(spec, seeds, {**cfg["scenario"], **case["control"]}, fn)
t = C.solve_rate(spec, seeds, {**cfg["scenario"], **case["treat"]}, fn)
rates.append({"id": case["id"], "n": n, "diff": c - t, "kc": c * n // 1000, "kt": t * n // 1000})
base = {}
for r in rates:
hs = C.wilson_half(r["kc"], r["n"], base_z) + C.wilson_half(r["kt"], r["n"], base_z)
base[r["id"]] = verdict(r["diff"], hs, base_delta)
print(f"基线:Δ={base_delta}‰ z={base_z} -> " + " ".join(f"{k}={v}" for k, v in base.items()))
print(f"\n{'Δ‰':>5}{'z':>3} " + "".join(f"{r['id']:<16}" for r in rates) + "翻转数")
grid, flips = [], 0
for delta in DELTAS:
for z in ZGRID:
cells, flip = [], 0
for r in rates:
hs = C.wilson_half(r["kc"], r["n"], z) + C.wilson_half(r["kt"], r["n"], z)
v = verdict(r["diff"], hs, delta)
cells.append((r["id"], v, v != base[r["id"]]))
flip += 1 if v != base[r["id"]] else 0
flips += flip
grid.append({"delta": delta, "z": z,
"cells": [{"id": i, "verdict": v, "flip": f} for i, v, f in cells], "flips": flip})
tag = "" if flip == 0 else f" <- WARN {flip} 格翻转"
print(f"{delta:>5}{z:>3} " + "".join(f"{v:<16}" for _, v, _ in cells) + f"{flip}{tag}")
print(f"\n共 {len(grid)} 个 (Δ,z) 格子,{flips} 格判定相对基线翻转;软告警不硬失败,脚本 exit 0。")
print("稳健性读法:判定对 z 敏感(区间口径),对 Δ 敏感(设计相关阈值)——两者都必须在测试前写死。")
report = {"id": "gd_proto_04_robustness", "baseDelta": base_delta, "baseZ": base_z,
"base": base, "grid": grid, "flips": flips,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "proto04_robustness.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"wrote out/proto04_robustness.json elapsed={report['elapsedMs']} ms")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_proto_04_robustness.py(exit 0):
基线:Δ=150‰ z=2 -> T1-难度上调=PASS T2-参数微调=FAIL T3-小样本=INSUFFICIENT
Δ‰ z T1-难度上调 T2-参数微调 T3-小样本 翻转数
100 1 PASS FAIL INSUFFICIENT 0
100 2 INSUFFICIENT INSUFFICIENT INSUFFICIENT 2 <- WARN 2 格翻转
100 3 INSUFFICIENT INSUFFICIENT INSUFFICIENT 2 <- WARN 2 格翻转
150 1 PASS FAIL INSUFFICIENT 0
150 2 PASS FAIL INSUFFICIENT 0
150 3 INSUFFICIENT INSUFFICIENT INSUFFICIENT 2 <- WARN 2 格翻转
200 1 PASS FAIL INSUFFICIENT 0
200 2 PASS FAIL INSUFFICIENT 0
200 3 PASS FAIL INSUFFICIENT 0
共 9 个 (Δ,z) 格子,6 格判定相对基线翻转;软告警不硬失败,脚本 exit 0。
稳健性读法:判定对 z 敏感(区间口径),对 Δ 敏感(设计相关阈值)——两者都必须在测试前写死。
wrote out/proto04_robustness.json elapsed=103 ms
稳健性读法
9 个格子里 6 格判定相对基线翻转,全部集中在判据变严的方向:Δ 收紧到 100‰ 或 z 放大到 3,半宽之和就超过 Δ,T1、T2 一起从 PASS/FAIL 掉成 INSUFFICIENT。而放宽(Δ=200‰ 或 z=1)时三态完全不变。这说明判定对「口径」比对接「数据」更敏感——Δ 和 z 必须在测试前写死并公开,否则同一批数据能被口径调出三种结论。
自检表
| 检查项 | 合格标准 |
|---|---|
| 三态齐全 | 判据输出通过 / 不通过 / 样本不足,缺一即不完整 |
| 顺序正确 | 先判半宽和是否 ≤ Δ,再判点差是否 ≥ Δ |
| 小样本诚实 | 小样本大点差判「样本不足」,不硬判通过 |
| 口径前置 | Δ 与 z 测试前写死;稳健性扰动只报告不硬失败 |
| 代理指标声明 | 指标声明为 proxy,只做相对比较;真人数据标 X |
三节下来,测试设计这条线闭环了:测什么(4.1 假设与样本量)、怎么算够(4.2 采样与区间)、怎么决策(4.3 三态)。三节共用同一批种子、同一套整数纪律与同一个 Δ,所以结论可以互相引用。下一章把这份测试设计套到第一个品类变体上——卡牌构筑 Roguelike。
阅读导航:上一节:4.2 采样与置信区间 · 下一节:5.1 卡牌构筑的机制解剖 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。