13.3 指标的可比性

跨配置到底能不能比:四分量单位不同,比较前必须先归一化,而换一种归一化口径排序就会翻盘。S 层算 6 个配置的可玩性矩阵,量出两种归一化口径下的同序比例 concordance,并暴露相变宽度对测量 regime 的依赖;B 层再扰动闸门阈值、测量 regime 与种子池,数出判决翻盘的次数。

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 曲线与谱 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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