4.1 测试的目标与假设
《小游戏开发实战》卷一 12.2 可玩性指标测试 那篇把「占比类指标 + 置信区间」这条路走通了,指标偏胜率、完成率,落点在「这个比例有多准」。本节做的是测试设计本身:在跑任何采样之前,先把「这次到底想验证什么」写成一条可证伪的假设,再据此定样本量与判据。指标也换了一类——本节量的是机制量(可解率、支配缺失度、策略熵),回答的是「机制有没有被改动影响」,而不是「玩家喜不喜欢」。
假设不是愿望
「我想验证难度会不会太高」不是假设,是愿望。它没法被判伪:无论测出什么,都能说「再测测看」。可证伪的假设必须写死三件事——量哪个指标、哪个方向的多少算效果、采多少样本才够。三件事都写死,测试才有「结论」可言,否则只剩「数据」。
把这三件事连同两个互斥命题凑成一个五元组,就是本节要的形式:metric(量什么)、direction(哪个方向算效果)、thresholdPermille(多大算效果)、sampleSize(计划采多少)、h0 / h1(一对互斥命题)。五元组里任何一项缺失,假设就不可证伪,脚本会直接判不合格。
一条合格的假设长什么样
以本章的贯穿测试为例:候选改动是把敌人攻击从 20 提到 22(难度上调),要回答的是「可解率会不会被击穿」。写成假设:
- H0:改动后两臂可解率之差 < 150‰(难度与可解率无关)。
- H1:改动后两臂可解率之差 ≥ 150‰(难度上调击穿底线)。
150‰ 就是最小可分辨差 Δ——它是设计相关的门槛,不是统计量:低于这个差,改动在体验上等于没改,不值得为它承担风险。Δ 必须在测试前写死,事后调 Δ 就是挑数据。
假设的机器可读形式
假设写进 config/gd_proto_04_hypothesis.json,一台机器就能检:五元组齐不齐、Δ 和样本量是多少、两臂差落在哪个参数上。三个指标各登记一条假设,主指标 solveRate 是占比类,另两个是非占比类。
{ "testId": "gd_proto_04_difficulty_ab", "referenceStrategy": "maxdmg",
"scenario": { "enemyHp": 65, "turnLimit": 16, "energyMax": 3, "playerHp": 60 },
"arms": { "control": { "enemyAttack": 20 }, "treat": { "enemyAttack": 22 } },
"seedStart": 401, "seedCount": 256, "pilotSeeds": 32, "sampleLadder": [8, 16, 32, 64, 128, 256],
"minDiscerniblePermille": 150, "z": 2, "zNote": "整数近似,对应约 95% 水平,不等同教科书 1.96",
"hypotheses": [ { "id": "H-solve", "metric": "solveRate", "direction": "decrease",
"thresholdPermille": 150, "sampleSize": 256,
"h0": "可解率下降 < 150‰(难度与可解率无关)",
"h1": "可解率下降 >= 150‰(难度上调击穿底线)" } ] }
S 层:可证伪性自检与试点
脚本分两步。第一步自检:逐条检查五元组是否齐备,不齐备直接标 NO。第二步试点:用 32 个种子跑两臂,拿到三个指标的基线值,再由试点比例反推所需样本量 nRequired——在样本阶梯里找最小的 n,使两臂整数 Wilson 半宽之和 ≤ Δ。公共库 sim/gd_proto_04_common.py 提供四个确定性策略与整数区间,本章三节共用。
#!/usr/bin/env python3
# file: sim/gd_proto_04_common.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_04_common.py
"""卷三第 4 章公共库:四个确定性参考策略 + 三个机制指标 + 整数 Wilson 区间。
strategies(spec) 最大伤害 / 优先格挡 / 最省费用 / 每费伤害 四个确定性策略
play / solve_rate 按策略序列跑一局;参考策略在种子池上的可解率(千分比)
mechanism_metrics(...) 可解率 + 支配缺失度 + 策略熵(后两者带块极差)
wilson / wilson_half 整数 Wilson 区间(z 为整数,用 isqrt 求,整数近似而非 1.96)
全部整数、无浮点、无 random。指标是代理指标(proxy),只做相对比较,禁绝对化。
"""
import copy
from math import isqrt
import ashlib
SCALE = 1000000
PREC = 16
ONE = 1 << PREC
def log2_fixed(x: int) -> int:
"""floor(log2(x) * 2^PREC) 的整数实现(纯移位与平方,无浮点)。"""
b = x.bit_length() - 1
y = x << (PREC - b) if b <= PREC else x >> (b - PREC)
res = b << PREC
for i in range(PREC):
y = (y * y) >> PREC
if y >= (ONE << 1):
res += 1 << (PREC - 1 - i)
y >>= 1
return res
def entropy_permille(counts: list) -> int:
"""策略胜出计数的 Shannon 熵 ×1000(底 2)。"""
w = sum(counts)
if w == 0:
return 0
acc = sum(c * (log2_fixed(c) - log2_fixed(w)) for c in counts if c > 0)
return (-acc * 1000) // (w * ONE)
def strategies(spec: dict) -> list:
cost = {c["id"]: c["cost"] for c in spec["cards"]}
dmg = {c["id"]: c["damage"] for c in spec["cards"]}
blk = {c["id"]: c["block"] for c in spec["cards"]}
def argmax(s, key, pred=None):
idx = [i for i, c in enumerate(s["hand"])
if cost[c] <= s["energy"] and (pred is None or pred(c))]
return None if not idx else f"p{max(idx, key=lambda i: key(s['hand'][i]))}"
def maxdmg(s):
return argmax(s, lambda c: (dmg[c], -cost[c])) or "e"
def maxblk(s):
return argmax(s, lambda c: (blk[c], -cost[c]), lambda c: blk[c] > 0) or maxdmg(s)
def cheapest(s):
return argmax(s, lambda c: (-cost[c], dmg[c])) or "e"
def dmgper(s):
return argmax(s, lambda c: (dmg[c] * 1000 // max(1, cost[c]), dmg[c])) or "e"
return [("maxdmg", maxdmg), ("maxblk", maxblk), ("cheapest", cheapest), ("dmgper", dmgper)]
def play(spec, seed, params, fn, max_steps=80):
s = copy.deepcopy(spec)
s["seed"] = seed
s["params"].update(params)
seq = []
for _ in range(max_steps):
sn = ashlib.run_spec(s, seq)
if sn["over"]:
break
seq.append(fn(sn))
return ashlib.run_spec(s, seq)
def solve_rate(spec, seeds, params, fn) -> int:
won = sum(1 for sd in seeds if play(spec, sd, params, fn)["won"])
return 1000 * won // len(seeds)
def mechanism_metrics(spec, seeds, params, strats, ref, blocks) -> dict:
wins = {name: 0 for name, _ in strats}
unique, ref_won = 0, 0
for sd in seeds:
res = {name: play(spec, sd, params, fn)["won"] for name, fn in strats}
for name in wins:
wins[name] += 1 if res[name] else 0
ref_won += 1 if res[ref] else 0
if len([name for name in wins if res[name]]) == 1:
unique += 1
n = len(seeds)
bs = max(1, n // blocks)
dom, ent = [], []
for b in range(blocks):
blk = seeds[b * bs:(b + 1) * bs]
w2 = {name: 0 for name, _ in strats}
u2 = 0
for sd in blk:
res = {name: play(spec, sd, params, fn)["won"] for name, fn in strats}
for name in w2:
w2[name] += 1 if res[name] else 0
if len([name for name in w2 if res[name]]) == 1:
u2 += 1
dom.append(1000 - 1000 * u2 // len(blk))
ent.append(entropy_permille([w2[name] for name, _ in strats]))
return {"solveRate": 1000 * ref_won // n, "dominanceAbsence": 1000 - 1000 * unique // n,
"strategyEntropy": entropy_permille([wins[name] for name, _ in strats]),
"domBlockLo": min(dom), "domBlockHi": max(dom),
"entBlockLo": min(ent), "entBlockHi": max(ent)}
def wilson(k: int, n: int, z: int) -> tuple:
"""整数 Wilson 区间(z 为整数),返回千分比 [lo, hi] 与半宽。"""
num = k * (n - k) + n
s_scaled = isqrt(num * SCALE * SCALE // 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 wilson_half(k: int, n: int, z: int) -> int:
return wilson(k, n, z)[2]
if __name__ == "__main__":
_spec = ashlib.load_spec()
print("strategies:", " ".join(n for n, _ in strategies(_spec)),
"| wilson(50,100,2)=", wilson(50, 100, 2),
"| entropy([1,1,1,1])=", entropy_permille([1, 1, 1, 1]))
#!/usr/bin/env python3
# file: sim/gd_proto_04_hypothesis.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_04_hypothesis.py
"""卷三 4.1 测试的目标与假设(S 层):把「这次测试想验证什么」写成可证伪的假设。
假设不是一句「我想验证难度会不会太高」,而是可机检的五元组:
metric(量哪个指标)/ direction(哪个方向算效果)/ threshold(多大算效果)
/ sampleSize(计划采多少样本)/ h0 + h1(一对互斥命题)。
本脚本先对登记表做**可证伪性自检**(五元组齐备才算合格),再跑一次**试点**
(pilotSeeds 个种子)拿到两臂的机制指标基线,最后由试点比例反推**所需样本量**
nRequired:在样本阶梯里找最小的 n,使两臂整数 Wilson 半宽之和 <= Δ。
指标是代理指标(proxy),只做相对比较,禁绝对化;区间只刻画采样不确定性、不修正偏差。
产出 out/proto04_hypothesis.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_hypothesis.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
import gd_proto_04_common as C # noqa: E402
REQUIRED = ("id", "metric", "direction", "thresholdPermille", "sampleSize", "h0", "h1")
METRICS = ("solveRate", "dominanceAbsence", "strategyEntropy")
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 = cfg["minDiscerniblePermille"], cfg["z"]
print("== 假设登记表:可证伪性自检(五元组 + h0/h1 齐备)")
print(f"{'id':<10}{'metric':<20}{'dir':<10}{'thr‰':>6}{'n':>6} {'齐备':<4}")
checklist = []
for h in cfg["hypotheses"]:
ok = all(h.get(k) not in (None, "") for k in REQUIRED)
checklist.append({"id": h["id"], "falsifiable": ok})
print(f"{h['id']:<10}{h['metric']:<20}{h['direction']:<10}"
f"{h['thresholdPermille']:>6}{h['sampleSize']:>6} {'yes' if ok else 'NO':<4}")
print(f"\n== 试点(pilotSeeds={cfg['pilotSeeds']}):两臂机制指标基线")
pilot = seeds[:cfg["pilotSeeds"]]
arms = {a: C.mechanism_metrics(spec, pilot, {**cfg["scenario"], **cfg["arms"][a]}, strats, ref, 4)
for a in ("control", "treat")}
print(f"{'arm':<10}{'solveRate‰':>12}{'domAbs‰':>10}{'entropy‰':>11}")
for a in ("control", "treat"):
m = arms[a]
print(f"{a:<10}{m['solveRate']:>12}{m['dominanceAbsence']:>10}{m['strategyEntropy']:>11}")
diffs = {k: arms["control"][k] - arms["treat"][k] for k in METRICS}
print(f"{'diff(降)':<10}{diffs['solveRate']:>12}{diffs['dominanceAbsence']:>10}"
f"{diffs['strategyEntropy']:>11}")
pa, pb = arms["control"]["solveRate"], arms["treat"]["solveRate"]
print(f"\n== 由试点比例反推样本量(Δ={delta}‰,z={z},主指标 solveRate)")
print(f"{'n':>5}{'hA‰':>6}{'hB‰':>6}{'hA+hB‰':>9} {'<=Δ':<5}")
n_req = None
for n in cfg["sampleLadder"]:
ha = C.wilson_half(pa * n // 1000, n, z)
hb = C.wilson_half(pb * n // 1000, n, z)
ok = (ha + hb) <= delta
if ok and n_req is None:
n_req = n
print(f"{n:>5}{ha:>6}{hb:>6}{ha + hb:>9} {'yes' if ok else 'no':<5}")
print(f"nRequired = {n_req}(阶梯内最小可分辨样本量;"
f"计划 sampleSize={cfg['hypotheses'][0]['sampleSize']})")
report = {"id": "gd_proto_04_hypothesis", "testId": cfg["testId"], "reference": ref,
"pilotSeeds": cfg["pilotSeeds"], "minDiscerniblePermille": delta, "z": z,
"falsifiability": checklist, "pilot": arms, "pilotDiff": diffs, "nRequired": n_req,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "proto04_hypothesis.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_hypothesis.json elapsed={report['elapsedMs']} ms")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_proto_04_hypothesis.py:
== 假设登记表:可证伪性自检(五元组 + h0/h1 齐备)
id metric dir thr‰ n 齐备
H-solve solveRate decrease 150 256 yes
H-dom dominanceAbsence decrease 100 256 yes
H-ent strategyEntropy decrease 60 256 yes
== 试点(pilotSeeds=32):两臂机制指标基线
arm solveRate‰ domAbs‰ entropy‰
control 500 532 1507
treat 218 469 1352
diff(降) 282 63 155
== 由试点比例反推样本量(Δ=150‰,z=2,主指标 solveRate)
n hA‰ hB‰ hA+hB‰ <=Δ
8 288 228 516 no
16 223 185 408 no
32 166 134 300 no
64 121 99 220 no
128 87 71 158 no
256 62 51 113 yes
nRequired = 256(阶梯内最小可分辨样本量;计划 sampleSize=256)
wrote out/proto04_hypothesis.json elapsed=58 ms
试点读出了什么
三件事。一、主指标的差远大于 Δ:试点 32 个种子里,两臂可解率是 500‰ 与 218‰,差 282‰,比 Δ=150‰ 大一截——这条假设有戏。二、非占比指标在试点尺度上还不显著:支配缺失度差 63‰(< 其阈值 100‰),策略熵差 155(×1000,> 其阈值 60)——一个够一个不够,说明不同指标的样本量需求不一样,不能一刀切。三、nRequired = 256:试点比例代进整数 Wilson,n=128 时半宽之和还有 158‰(> 150),到 n=256 才降到 113‰——看得见的差有多大,取决于看了多少。
自检表
| 检查项 | 合格标准 |
|---|---|
| 可证伪 | 五元组 + h0/h1 齐备,缺一即不合格 |
| Δ 前置 | 最小可分辨差在测试前写死,事后不改 |
| 方向明确 | 每个指标写清「哪个方向算效果」 |
| 样本量有据 | nRequired 由试点比例反推,不是拍脑袋 |
| 代理指标声明 | 指标声明为 proxy,只做相对比较;真人数据标 X |
本节只回答「测什么、怎么算够」。样本量怎么落到区间上、区间怎么变成决策,是后两节的事——三节共用同一批种子与同一套整数纪律,所以读数可以互相引用。
阅读导航:上一节:3.3 原型的可分享与可回放 · 下一节:4.2 采样与置信区间 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。