4.3 从测试到决策

区间要变成决策,判据必须是三态而不是两态。本节用整数判据输出通过 / 不通过 / 样本不足:先看半宽之和能否分辨 Δ,再看点差够不够。实跑三个案例恰好各出一态,其中小样本案例点差高达 375‰ 却被诚实地判为「样本不足」,而参数微调案例点差为 0 判不通过。

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 卡牌构筑的机制解剖 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡