13.3 指标的可比性
《小游戏开发实战》卷一 12.2 可玩性指标与测试 里的占比类指标(胜率、通关率)都是同一个分母上的比例,天然可比:两个配置的胜率都是「胜局 ÷ 总局」,直接相减就有意义。13.1 的可玩性向量没有这个性质——四个分量单位不同(三个是千分比、一个是步数),量纲不统一就没法比较。本节把「可比性」当成一个要证明的前提:先归一化,再看排序稳不稳,最后看测量 regime 换了之后读数还成不成立。四分量仍是代理指标(proxy),只用于相对比较。
三种「不可比」
| 不可比的来源 | 具体表现 | 本节怎么量 |
|---|---|---|
| 量纲不同 | 千分比与步数不能直接加权 | 两种归一化口径各算一次排序 |
| 归一化口径不同 | 除以最大值 vs min-max 拉伸,排序可能翻盘 | 同序比例 concordance(千分比) |
| 测量 regime 不同 | 相变宽度随 turnLimit 变化 | 同一配置在四个 regime 下的读数 |
{ "grid": [ { "id": "c0" }, "…", { "id": "c5" } ], "regimeSweep": { "turnLimits": [2, 3, 4, 5] }, "robustness": { "altRegimeTurnLimits": [2, 4, 5] } }
S 层:两种归一化口径 + 一个 regime 依赖
脚本对 6 个配置各算一次向量,再分别用「除以网格最大值」与「min-max 拉伸」归一化后排序,量出同序比例;最后把本体配置的相变宽度在 turnLimit = 2/3/4/5 下各量一次。全整数、无浮点、无随机。
#!/usr/bin/env python3
# file: sim/gd_play_13_comparability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_13_comparability.py
"""卷二 13.3 指标的可比性(S 层):跨配置到底能不能比。
四分量单位不同(熵/缺失度/涌现度是千分比,相变宽度是整数步数),跨配置比较必须先
**归一化**。本节做三件事:① 6 个配置各算一次可玩性向量;② 同一矩阵分别用「除以网格最大
值」与「min-max 拉伸」两种口径归一化,各得一个排序,量出同序比例 concordance——< 1000 就
说明**排序依赖归一化口径**;③ 相变宽度在 turnLimit = 2/3/4/5 四个测量 regime 下的读数完全
不同,说明**换 regime 的读数不可直接比**。四分量都是**代理指标(proxy)**,只用于相对比较,
禁绝对化。产出 out/play13_comparability.{json,csv,svg}。全整数、无浮点、无 random。
"""
from __future__ import annotations
import copy
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_comparability.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
DIMS = pl.DIMS
PC_KEYS = ("rules", "maxSteps", "policies", "entropy", "emergence", "band", "gate")
def normalize(m, ids, mode):
"""两种归一化口径:'max' 除以该分量在网格上的最大值,'minmax' 拉伸到 [0,1000]。"""
out = {}
for d in DIMS:
lo, hi = min(m[c][d] for c in ids), max(m[c][d] for c in ids)
out[d] = {c: ((1000 * m[c][d] // hi if hi else 0) if mode == "max"
else (1000 if hi == lo else 1000 * (m[c][d] - lo) // (hi - lo)))
for c in ids}
return out
def score_order(m, ids, mode):
"""按归一化后的四分量均值排序:返回 (顺序, 分数, 归一化矩阵)。"""
nm = normalize(m, ids, mode)
score = {c: sum(nm[d][c] for d in DIMS) // len(DIMS) for c in ids}
return sorted(ids, key=lambda c: (-score[c], c)), score, nm
def concordance(a, b):
"""两个排序的同序比例(千分比):1000 = 完全同序。"""
pos = {c: i for i, c in enumerate(b)}
pairs = [(a[i], a[j]) for i in range(len(a)) for j in range(i + 1, len(a))]
return 1000 * sum(1 for x, y in pairs if pos[x] < pos[y]) // len(pairs)
def draw(nm, ids, path):
W, x0, y0, y1 = 720, 104, 62, 240
root = ashlib.svg_begin(W, 320)
ashlib.svg_text(root, 20, 26, "跨配置可玩性向量:两种归一化口径(千分比)", 15)
ashlib.svg_text(root, 20, 46, "全部为代理指标:只做相对比较,禁绝对化", 11, "#94a3b8")
colors = {"entropy": "#38bdf8", "domFree": "#22c55e", "emergence": "#f59e0b", "band": "#ef4444"}
hi = len(ids) - 1
for k, d in enumerate(DIMS):
ET.SubElement(root, "polyline", {"fill": "none", "stroke": colors[d], "stroke-width": "2",
"points": " ".join(f"{x0 + i * (W - 150) // hi},"
f"{y1 - nm[d][c] * (y1 - y0) // 1000}"
for i, c in enumerate(ids))})
ashlib.svg_text(root, 20, y0 + 20 + 22 * k, pl.LABEL[d], 11, colors[d])
for i, c in enumerate(ids):
ashlib.svg_text(root, x0 + i * (W - 150) // hi, y1 + 20, c, 10, "#94a3b8", "middle")
return ashlib.svg_save(root, path)
def main():
t0 = time.perf_counter_ns()
base = 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, ids = cfg["seeds"], [g["id"] for g in cfg["grid"]]
m = {g["id"]: pl.vector_of({**copy.deepcopy(base),
"params": {**base["params"], **g["params"]}}, pc, seeds)
for g in cfg["grid"]}
order_a, score_a, nm_a = score_order(m, ids, "max")
order_b, score_b, _ = score_order(m, ids, "minmax")
conc = concordance(order_a, order_b)
rs = cfg["regimeSweep"]
reg = next(g["params"] for g in cfg["grid"] if g["id"] == rs["config"])
reg_spec = {**copy.deepcopy(base), "params": {**base["params"], **reg}}
regime = []
for tl in rs["turnLimits"]:
band, hi_e, lo_e = pl.band_width(reg_spec, seeds, {**pc["band"], "regime": {"turnLimit": tl}})
regime.append({"turnLimit": tl, "band": band, "hiEdge": hi_e, "loEdge": lo_e})
n = ashlib.write_csv(os.path.join(OUT, "play13_comparability.csv"),
["config", *DIMS, "scoreMax", "scoreMinmax"],
[[c] + [m[c][d] for d in DIMS] + [score_a[c], score_b[c]] for c in ids])
size = draw(nm_a, ids, os.path.join(OUT, "play13_comparability.svg"))
report = {"id": "gd_play_13_comparability", "seeds": len(seeds), "configs": ids, "matrix": m,
"normalizedMax": nm_a, "orderMax": order_a, "orderMinmax": order_b,
"scoreMax": score_a, "scoreMinmax": score_b, "concordancePermille": conc,
"regimeSweep": regime, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play13_comparability.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print("配置 " + "".join(f"{pl.LABEL[d]:>10}" for d in DIMS) + f"{'归一化分A':>10}{'归一化分B':>10}")
for c in ids:
print(f"{c:<6}" + "".join(f"{m[c][d]:>10}" for d in DIMS) + f"{score_a[c]:>10}{score_b[c]:>10}")
print(f"排序A(max 归一化) = {order_a}")
print(f"排序B(min-max 归一化)= {order_b}")
print(f"concordance={conc}‰" + ("(完全同序)" if conc == 1000 else "(排序翻盘,不可比)"))
print(f"regime 依赖:配置 {rs['config']} 的相变宽度随 turnLimit")
for r in regime:
print(f" turnLimit={r['turnLimit']} band={r['band']} band=[{r['hiEdge']},{r['loEdge']}]")
print(f"csv={n} rows svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/play13_comparability.csv out/play13_comparability.svg out/play13_comparability.json")
ET.parse(os.path.join(OUT, "play13_comparability.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
配置 策略空间熵 支配缺失度 涌现度 相变宽度 归一化分A 归一化分B
c0 2251 1000 2692 10 898 650
c1 2251 1000 2692 10 898 650
c2 2321 1000 1538 6 739 625
c3 2235 1000 4307 2 790 500
c4 2235 1000 4307 2 790 500
c5 2321 1000 1538 6 739 625
排序A(max 归一化) = ['c0', 'c1', 'c3', 'c4', 'c2', 'c5']
排序B(min-max 归一化)= ['c0', 'c1', 'c2', 'c5', 'c3', 'c4']
concordance=733‰(排序翻盘,不可比)
regime 依赖:配置 c0 的相变宽度随 turnLimit
turnLimit=2 band=10 band=[35,44]
turnLimit=3 band=10 band=[58,67]
turnLimit=4 band=2 band=[78,79]
turnLimit=5 band=2 band=[78,79]
csv=6 rows svg=2248 bytes elapsed=45463 ms
wrote out/play13_comparability.csv out/play13_comparability.svg out/play13_comparability.json
733‰ 是关键读数:两种归一化口径下有 26.7% 的配置对给出了相反的排序。而 regime 依赖更硬——同一个配置 c0,turnLimit=2 时相变宽度是 10,turnLimit=4 时是 2,差了 5 倍,但两者都不是错的,只是量的不是同一件事。
B 层:判决对扰动敏感
判决建立在三样可扰动的东西上:闸门阈值(人定的)、测量 regime、种子池。B 层把这三项各扰动一遍,看 6 个配置里哪些翻盘。
#!/usr/bin/env python3
# file: sim/gd_play_13_comparability_warn.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_13_comparability_warn.py
"""卷二 13.3 指标的可比性(B 层):闸门判决对参数扰动的稳健性——软告警,不硬失败。
闸门判决建立在**可扰动**的三样东西上:① 闸门阈值(人为定的下限);② 相变宽度的**测量
regime**(turnLimit);③ 相变宽度用的**种子池**。本脚本对 6 个配置各算一次基准判决,再逐个
扰动这三项,报出翻盘的配置与方向。熵/缺失度/涌现度在 13.2 已验证对种子稳定,故 B 层只扰动
相变宽度这一维。**永远 exit 0**。产出 out/play13_comparability_warn.json。
"""
from __future__ import annotations
import copy
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_play_13_comparability.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
DIMS = pl.DIMS
PC_KEYS = ("rules", "maxSteps", "policies", "entropy", "emergence", "band", "gate")
def verdict(vec, gate):
return [d for d in DIMS if vec[d] < gate[d + "Min"]]
def shift(gate, delta):
"""闸门阈值整体平移;相变宽度是步数不是千分比,只 ±1 步。"""
return {d + "Min": gate[d + "Min"] + (delta if d != "band" else (1 if delta > 0 else -1))
for d in DIMS}
def main():
t0 = time.perf_counter_ns()
base = 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}
rb, gate = cfg["robustness"], pc["gate"]
seeds = cfg["seeds"][: rb["poolSeeds"]]
ids = [g["id"] for g in cfg["grid"]]
specs = {g["id"]: {**copy.deepcopy(base), "params": {**base["params"], **g["params"]}}
for g in cfg["grid"]}
vectors = {c: pl.vector_of(specs[c], pc, seeds) for c in ids}
baseline = {c: verdict(vectors[c], gate) for c in ids}
checks = []
for delta in (rb["thresholdDeltaPermille"], -rb["thresholdDeltaPermille"]):
g2 = shift(gate, delta)
checks.append({"kind": "threshold", "delta": delta, "gate": g2,
"flips": [c for c in ids if verdict(vectors[c], g2) != baseline[c]]})
def band_check(kind, key, value, seeds2, bd2):
flips, vals = [], {}
for c in ids:
band = pl.band_width(specs[c], seeds2, bd2)[0]
vals[c] = band
if verdict({**vectors[c], "band": band}, gate) != baseline[c]:
flips.append(c)
checks.append({"kind": kind, key: value, "band": vals, "flips": flips})
for tl in rb["altRegimeTurnLimits"]:
bd2 = copy.deepcopy(pc["band"])
bd2["regime"] = {"turnLimit": tl}
band_check("regime", "turnLimit", tl, seeds, bd2)
for off in rb["altPoolOffsets"]:
band_check("seedPool", "offset", off, [sd + off for sd in seeds], pc["band"])
flips_total = sum(len(c["flips"]) for c in checks)
report = {"id": "gd_play_13_comparability_warn", "seeds": len(seeds), "gate": gate,
"baseline": baseline, "checks": checks, "flipsTotal": flips_total,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play13_comparability_warn.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"{c}={'PASS' if not baseline[c] else 'FAIL'}" for c in ids))
for c in checks:
tag = (f"阈值 {c['delta']:+d}‰" if c["kind"] == "threshold"
else (f"regime turnLimit={c['turnLimit']}" if c["kind"] == "regime"
else f"种子池 +{c['offset']}"))
print(f"{c['kind']:<9}{tag:<22}flips={c['flips']}")
print(f"总翻盘次数 = {flips_total}")
if flips_total == 0:
print("SOFT OK: 三项扰动下闸门判决全部稳定——判决可复现(在本网格上)")
else:
print(f"SOFT WARN VERDICT_FLIP: {flips_total} 次判决翻盘——判决对可扰动参数敏感,须声明口径")
print(f"SOFT: hardFail=false(B 档软告警不硬失败) elapsed={report['elapsedMs']} ms")
print("wrote out/play13_comparability_warn.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
基准判决(8 种子):c0=PASS c1=PASS c2=PASS c3=FAIL c4=FAIL c5=PASS
threshold阈值 +100‰ flips=[]
threshold阈值 -100‰ flips=[]
regime regime turnLimit=2 flips=['c3', 'c4']
regime regime turnLimit=4 flips=['c0', 'c1']
regime regime turnLimit=5 flips=['c0', 'c1']
seedPool 种子池 +100 flips=[]
seedPool 种子池 +200 flips=[]
seedPool 种子池 +400 flips=[]
总翻盘次数 = 6
SOFT WARN VERDICT_FLIP: 6 次判决翻盘——判决对可扰动参数敏感,须声明口径
SOFT: hardFail=false(B 档软告警不硬失败) elapsed=22518 ms
wrote out/play13_comparability_warn.json
判读
六次翻盘里,阈值扰动一次都没翻(±100‰ 下判决全稳),种子池扰动也没翻,翻盘全部来自 regime 扰动:turnLimit 从 3 换成 2,c3/c4 翻盘;换成 4 或 5,c0/c1 翻盘。这个分布本身就是结论——阈值和种子池不是可比性的瓶颈,测量 regime 才是。所以在报告里写「配置 A 的相变宽度比 B 宽」时,必须同时写清 regime;不写,这个比较就是无效的。
自检表
| 检查项 | 合格标准 |
|---|---|
| 口径依赖被量化 | 同序比例 < 1000 就说明排序依赖口径,不假装唯一 |
| regime 写进结论 | 相变宽度的读数必须带 turnLimit |
| 扰动有分类 | 阈值 / regime / 种子池三类分别报,不混成一个总数 |
| 软告警不硬失败 | 翻盘只打印 SOFT WARN,exit 0 |
| 代理指标声明 | 四分量是 proxy,只做相对比较 |
可比性立住了,才轮到最后一章要做的事:反过来用指标去推参数——给定想要的可玩性读数,机制参数该往哪调。
阅读导航:上一节:13.2 可玩性指标与采样 · 下一节:14.1 曲线与谱 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。