15.2 指标的可比性与局限

同一张指标表里的数不能随便比:量纲不同的指标不能相加,七条指标合成不出一个总分;同一份规格加删一条选项,覆盖率就从 446 漂到 438 / 454;熵对样本量敏感,n=50 缺最稀有结局反而算出更高的熵。本节用三个可复现实验把可比性的边界钉死。

《游戏设计》卷一《玩法机制与可执行规格》 那一套给的是玩法规格的度量口径;本章给的是叙事规格的度量口径——指标集不同(那边是一局的动力学,这边是整张故事图的路径形状),但「整数化 + 确定性 + 只做相对比较」的纪律同源。15.1 把七条指标收进一张表,紧接着的问题就是:这张表里的数,能跟谁比?

15.2 指标的可比性与局限

15.1 那张表有一个危险的读法:把七个数并排一看,人就会忍不住问「哪个更重要」「总分多少」「能不能跨作品比」。本节的任务是把这三条路堵死——而且不靠声明堵,靠实跑堵:用三个可复现的实验,证明七条指标只在同一规格、同一口径、同一样本量下才可比。

三条局限

  • 量纲不可相加:七条指标合成不出一个总分(3 / 3 组跨量纲求和被拒);
  • 跨规格不可比:换一份规格,同一个数含义就变(同一路径 446 → 438 / 454);
  • 样本量约束:熵只在固定 n 下可比(n=50 熵 1756 > n=20000 熵 1714)。

局限一:量纲不可相加

七条指标落在四个量纲上——千分(覆盖率、密度偏差)、毫比特(熵)、毫(两条 MAE)、计数(两条残差)。脚本里有一个 combine() 守卫:量纲不同就 raise ValueError。本节抽三组跨量纲求和来试,3 / 3 全被拒:permille vs milli、milli_bits vs permille、count vs milli_bits。

结论很硬:七条指标没有合法的求和口径。想要一个「叙事质量总分」,就得先给千分、毫比特、毫、计数定权重——而这个权重没有任何真源支撑,纯属拍脑袋。所以本卷不给总分。

局限二:跨规格不可比

第二条更隐蔽:同一个数,换一份规格,含义就变了。 分支覆盖率的分母是「全部去重对话边」,而这个分母是规格自己写的。本节做一个最小扰动实验:保持那条 25 条边的确定性路径一步不动,只在规格里加一条选项(分母 56→57)、再删一条选项(分母 56→55):

规格变体去重边路径边覆盖率
基准5625446 千分
加一条选项5725438 千分
删一条选项5525454 千分

路径没变,数变了。 这条路径一步都没多走,覆盖率却从 446 漂到 438 / 454。同理,目标覆盖率 850 千分、熵上界 log2(5) = 2321 毫比特,都是这份规格自己写死的字段,不是放之四海皆准的标准。所以「跨规格比覆盖率」,比的是两份规格的分母,不是两条路径的丰富度。

局限三:熵与样本量

第三条专门说熵。熵对稀有类目和样本量都敏感。本节把采样量从 50 扫到 20000,盯住最稀有的结局「真相」:

n看到的结局熵(毫比特)
504 / 51756
2005 / 51687
10005 / 51713
50005 / 51730
200005 / 51714

看第一行和最后一行:n=50 时「真相」一次都没出现,熵 1756 反而高于 n=20000 的 1714。小样本的熵方向不定——少数几个结局占比一抖,熵就飘。跨 5 个种子再验一遍:n=50 的熵极差 172,n=1000 的极差收窄到 59。而最稀有的「真相」占比只有 8 千分,期望出现一次需要 n ≈ 112。所以熵只在固定样本量下可比;要跨样本量比,先把 n 钉死。

S 层:三个实验一次跑完

脚本 sim/nm_15_2_comparability.py 把三个实验串起来:量纲守卫逐组试拒、规格加删一条选项各重算一次覆盖率、采样量从 50 扫到 20000 看熵:

#!/usr/bin/env python3
# file: sim/nm_15_2_comparability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_15_2_comparability.py
"""卷三 15.2 指标的可比性与局限(S 层):这张表里的数能跟谁比。

三个实验:一、四个量纲(千分 / 毫比特 / 毫 / 计数)两两求和一律拒绝,七条指标不能
合成一个「叙事质量分」;二、同一条确定性路径(25 条边),规格加 / 删一条选项使分母
56→57 / 55,千分比立刻 446→438 / 454——路径没变数变了,目标覆盖率与熵上界都是这份
规格自己写死的字段;三、熵对样本量与稀有类目敏感,n=50 缺最稀有结局「真相」而熵反
高于 n=20000,故熵只在固定样本量下可比。全部整数、全部 proxy。产出
out/nm_15_2_comparability.json。
"""
from __future__ import annotations

import json
import math
import os
import sys

import nm_15_common as c

OBSERVED = {"branch_coverage": 446, "ending_entropy": 1714, "tension_curve_mae": 0,
            "affinity_curve_mae": 5739, "consistency_residual": 8,
            "density_deviation": 5, "invariant_residual": 0}


def main() -> int:
    spec = c.load_json("config/story_spec.json")
    by = {n["id"]: n for n in spec["dialogue"]}
    table = c.load_json("config/nm_15_1_metric_definition_table.json")["metrics"]
    units = {e["metric"]: e["unit"] for e in table}

    # ---- 一 量纲不可相加 ----
    pairs = [("branch_coverage", "tension_curve_mae"),
             ("ending_entropy", "branch_coverage"),
             ("consistency_residual", "ending_entropy")]
    refused = 0
    print("== 一 量纲不可相加 ==")
    for k in OBSERVED:
        print(f"   {k:<22} {OBSERVED[k]:>6} {units[k]}")
    for a, b in pairs:
        try:
            c.combine(units[a], OBSERVED[a], units[b], OBSERVED[b])
            print(f"   相加 {a} + {b} 意外通过")
        except ValueError as exc:
            refused += 1
            print(f"   相加 {a} + {b} → 拒绝({exc})")
    print(f"   跨量纲相加 {refused} / {len(pairs)} 被拒绝:七条指标没有合法的求和口径")

    # ---- 二 跨规格不可比 ----
    base_edges = c.edges_of(spec)
    det = c.det_edges(spec)
    base_permille = len(det) * 1000 // len(base_edges)

    add_spec = json.loads(json.dumps(spec))
    by_id = {n["id"]: n for n in add_spec["dialogue"]}
    add_node, add_goto = None, None
    for n in add_spec["dialogue"]:
        for m in add_spec["dialogue"]:
            if n.get("choices") and m["id"] != n["id"] and (n["id"], m["id"]) not in base_edges:
                add_node, add_goto = n["id"], m["id"]
                break
        if add_node:
            break
    by_id[add_node]["choices"].append({"id": "c_mut_add", "text": "(扰动新增)", "goto": add_goto})
    add_edges = c.edges_of(add_spec)
    add_permille = len(c.det_edges(add_spec)) * 1000 // len(add_edges)

    del_spec = json.loads(json.dumps(spec))
    det_nodes = set(c.walk(spec, None)[1])
    del_node, del_idx = None, None
    for n in del_spec["dialogue"]:
        if n["id"] in det_nodes or len(n.get("choices") or []) < 2:
            continue
        seen = {}
        for ch in n["choices"]:
            seen[ch["goto"]] = seen.get(ch["goto"], 0) + 1
        for i, ch in enumerate(n["choices"]):
            if seen[ch["goto"]] == 1:
                del_node, del_idx = n["id"], i
                break
        if del_node:
            break
    removed = {n["id"]: n for n in del_spec["dialogue"]}[del_node]["choices"].pop(del_idx)["goto"]
    del_edges = c.edges_of(del_spec)
    del_permille = len(c.det_edges(del_spec)) * 1000 // len(del_edges)

    print("== 二 跨规格不可比(同一条 25 条边的确定性路径) ==")
    print(f"   基准        去重边 {len(base_edges)}  路径边 {len(det)} → {base_permille} 千分")
    print(f"   加一条选项  去重边 {len(add_edges)}  路径边 {len(det)} → {add_permille} 千分"
          f"({add_node} → {add_goto})")
    print(f"   删一条选项  去重边 {len(del_edges)}  路径边 {len(det)} → {del_permille} 千分"
          f"({del_node} → {removed})")
    print(f"   同一条路径、同一个策略:分母一变,千分比 {base_permille} → {add_permille} / {del_permille}")
    print(f"   目标覆盖率 {spec['metrics']['coverage_target_permille']} 千分是规格字段(非通用标准);"
          f"熵上界 log2(5) = {int(math.log2(5) * 1000)} 毫比特同理")

    # ---- 三 样本量约束 ----
    print("== 三 样本量对熵的影响 ==")
    ent_rows = []
    for n in (50, 200, 1000, 5000, 20000):
        rng, dist = c.SEED, {}
        for _ in range(n):
            end, _, _, _, rng, _ = c.walk(spec, rng, 0)
            key = by[end].get("ending", "(未终局)")
            dist[key] = dist.get(key, 0) + 1
        e = c.entropy_milli_bits(list(dist.values()), n)
        ent_rows.append({"n": n, "entropy_milli_bits": e, "distribution": dist, "endings_seen": len(dist)})
        print(f"   n={n:<6} 结局 {len(dist)}/5  熵 {e} 毫比特  {dist}")
    spread = []
    for n in (50, 1000):
        es = []
        for s_i in range(5):
            rng, dist = c.SEED + s_i * 7919, {}
            for _ in range(n):
                end, _, _, _, rng, _ = c.walk(spec, rng, 0)
                key = by[end].get("ending", "(未终局)")
                dist[key] = dist.get(key, 0) + 1
            es.append(c.entropy_milli_bits(list(dist.values()), n))
        spread.append({"n": n, "seeds": es, "min": min(es), "max": max(es), "range": max(es) - min(es)})
        print(f"   n={n:<6} 跨 5 个种子 熵 {min(es)}~{max(es)}  极差 {max(es) - min(es)}")
    rare = ent_rows[-1]["distribution"].get("真相", 0)
    print(f"   最稀有结局「真相」占比 {rare}/{ent_rows[-1]['n']} = "
          f"{rare * 1000 // ent_rows[-1]['n']} 千分;期望出现 1 次需 n ≈ {ent_rows[-1]['n'] // max(rare, 1)}")
    print(f"   n=50 时「真相」出现 0 次,熵 {ent_rows[0]['entropy_milli_bits']} 反而高于 "
          f"n=20000 的 {ent_rows[-1]['entropy_milli_bits']}:小样本的熵方向不定")
    print("   结论:熵只在固定样本量下可比;跨样本量比必须先固定 n,跨规格比无意义")

    # ---- 规则表 lint(D 层,config/nm_15_2_comparability_rules.json) ----
    rules = c.load_json("config/nm_15_2_comparability_rules.json")
    rule_ids = [r["id"] for r in rules["rules"]]
    no_ev = [r["id"] for r in rules["rules"] if r["allowed"] is False and not r.get("evidence")]
    covered = {p["metric"] for p in rules["per_metric"]}
    missing = sorted(set(units) - covered)
    print("== 规则表 lint ==")
    print(f"   规则 {len(rule_ids)} 条({' '.join(rule_ids)})  允许 "
          f"{sum(1 for r in rules['rules'] if r['allowed'])} 条 / 禁止 "
          f"{sum(1 for r in rules['rules'] if not r['allowed'])} 条")
    print(f"   逐指标覆盖 {len(covered)} / {len(units)}  未覆盖 {missing}")
    print(f"   禁止规则缺证据 {len(no_ev)} 条")
    assert not missing and not no_ev and len(rule_ids) == 6, "规则表 lint 失败"
    assert rules["per_metric"][6]["across_spec"] is True, "唯一可跨规格引用的指标应为整数不变量残差"
    assert base_permille == 446 and refused == len(pairs)

    os.makedirs(c.OUT, exist_ok=True)
    with open(os.path.join(c.OUT, "nm_15_2_comparability.json"), "w", encoding="utf-8") as fh:
        json.dump({"seed": c.SEED, "units": units, "values": OBSERVED, "dimension_refused": refused,
                   "spec_variants": [
                       {"name": "base", "edges": len(base_edges), "path_edges": len(det),
                        "permille": base_permille},
                       {"name": "add_one_choice", "edges": len(add_edges), "path_edges": len(det),
                        "permille": add_permille, "at": f"{add_node}->{add_goto}"},
                       {"name": "drop_one_choice", "edges": len(del_edges), "path_edges": len(det),
                        "permille": del_permille, "at": f"{del_node}->{removed}"}],
                   "entropy_by_n": ent_rows, "entropy_seed_spread": spread,
                   "uniform_upper_milli_bits": int(math.log2(len(spec["endings"])) * 1000),
                   "proxy": True}, fh, ensure_ascii=False, indent=1)
        fh.write("\n")
    print("wrote out/nm_15_2_comparability.json")
    print("OK nm_15_2_comparability:量纲不可加、跨规格不可比、样本量约束三条局限已实测")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/nm_15_2_comparability.py:

== 一 量纲不可相加 ==
   branch_coverage           446 permille
   ending_entropy           1714 milli_bits
   tension_curve_mae           0 milli
   affinity_curve_mae       5739 milli
   consistency_residual        8 count
   density_deviation           5 permille
   invariant_residual          0 count
   相加 branch_coverage + tension_curve_mae → 拒绝(量纲不同不可相加:permille vs milli)
   相加 ending_entropy + branch_coverage → 拒绝(量纲不同不可相加:milli_bits vs permille)
   相加 consistency_residual + ending_entropy → 拒绝(量纲不同不可相加:count vs milli_bits)
   跨量纲相加 3 / 3 被拒绝:七条指标没有合法的求和口径
== 二 跨规格不可比(同一条 25 条边的确定性路径) ==
   基准        去重边 56  路径边 25 → 446 千分
   加一条选项  去重边 57  路径边 25 → 438 千分(d_pier_1 → d_tavern_1)
   删一条选项  去重边 55  路径边 25 → 454 千分(d_mr_3 → d_ho_1)
   同一条路径、同一个策略:分母一变,千分比 446 → 438 / 454
   目标覆盖率 850 千分是规格字段(非通用标准);熵上界 log2(5) = 2321 毫比特同理
== 三 样本量对熵的影响 ==
   n=50     结局 4/5  熵 1756 毫比特  {'离港': 24, '沉没': 14, '缄默': 6, '灯守': 6}
   n=200    结局 5/5  熵 1687 毫比特  {'离港': 104, '沉没': 60, '缄默': 21, '灯守': 12, '真相': 3}
   n=1000   结局 5/5  熵 1713 毫比特  {'离港': 508, '沉没': 278, '缄默': 149, '灯守': 56, '真相': 9}
   n=5000   结局 5/5  熵 1730 毫比特  {'离港': 2502, '沉没': 1364, '缄默': 815, '灯守': 270, '真相': 49}
   n=20000  结局 5/5  熵 1714 毫比特  {'离港': 10074, '沉没': 5507, '缄默': 3223, '灯守': 1018, '真相': 178}
   n=50     跨 5 个种子 熵 1662~1834  极差 172
   n=1000   跨 5 个种子 熵 1687~1746  极差 59
   最稀有结局「真相」占比 178/20000 = 8 千分;期望出现 1 次需 n ≈ 112
   n=50 时「真相」出现 0 次,熵 1756 反而高于 n=20000 的 1714:小样本的熵方向不定
   结论:熵只在固定样本量下可比;跨样本量比必须先固定 n,跨规格比无意义
== 规则表 lint ==
   规则 6 条(R1 R2 R3 R4 R5 R6)  允许 2 条 / 禁止 4 条
   逐指标覆盖 7 / 7  未覆盖 []
   禁止规则缺证据 0 条
wrote out/nm_15_2_comparability.json
OK nm_15_2_comparability:量纲不可加、跨规格不可比、样本量约束三条局限已实测

三个实验各自的 assert 都钉住了方向:跨量纲相加必须 3 / 3 被拒、基准覆盖率必须是 446 千分、规则表必须 6 条且逐指标覆盖 7 / 7。实验不是「演示」,是「门禁」——这三条局限一旦被破坏,脚本直接 FAIL。

D 层:可比性规则表

规则落 config/nm_15_2_comparability_rules.json:6 条规则(允许 2 条 / 禁止 4 条)+ 逐指标的可比性标注:

{
  "section": "15.2",
  "tier": "D",
  "note": "指标可比性规则表。allowed=false 的规则都在 sim/nm_15_2_comparability.py 里有实跑证据;本节只做相对比较,不做绝对判断。",
  "units": {"permille": "千分比(整数地板除)", "milli_bits": "毫比特(Shannon 熵 × 1000 取整)",
            "milli": "毫(1 点 = 1000 毫)", "count": "计数(整数)"},
  "rules": [
    {"id": "R1", "rule": "量纲不同的两个指标相加", "allowed": false,
     "evidence": "3 / 3 组跨量纲求和被 combine() 拒绝(permille vs milli / milli_bits vs permille / count vs milli_bits)"},
    {"id": "R2", "rule": "同一规格、同一口径、同一采样规模下的前后对照", "allowed": true,
     "evidence": "这是 6.3 声明里 allowed_use 的第一条:同一规格内两条曲线的相对偏离"},
    {"id": "R3", "rule": "跨规格比较(分母 / 目标 / 结局数不同)", "allowed": false,
     "evidence": "同一条 25 条边的确定性路径:去重边 56 → 446‰、57 → 438‰、55 → 454‰;路径没变,数变了"},
    {"id": "R4", "rule": "熵跨样本量比较", "allowed": false,
     "evidence": "n=50 熵 1756 毫比特 > n=20000 熵 1714 毫比特,而 n=50 时最稀有结局一次都没出现"},
    {"id": "R5", "rule": "整数不变量残差跨规格引用", "allowed": true,
     "evidence": "恒等判据(必须为 0),不依赖任何分母、目标或采样规模"},
    {"id": "R6", "rule": "把七条指标合成一个「叙事质量总分」", "allowed": false,
     "evidence": "四个量纲之间没有合法权重,合成即 R1 的越界"}
  ],
  "per_metric": [
    {"metric": "branch_coverage", "within_spec_before_after": true, "across_spec": false, "needs_fixed_n": true,
     "note": "分母随规格变(56 / 57 / 55),故只在同一规格同一分母下可比"},
    {"metric": "ending_entropy", "within_spec_before_after": true, "across_spec": false, "needs_fixed_n": true,
     "note": "上界 log2(结局数) 随规格变;小样本的熵方向不定"},
    {"metric": "tension_curve_mae", "within_spec_before_after": true, "across_spec": false, "needs_fixed_n": false,
     "note": "目标曲线是规格字段,换规格即换目标;无采样随机性"},
    {"metric": "affinity_curve_mae", "within_spec_before_after": true, "across_spec": false, "needs_fixed_n": true,
     "note": "基线曲线由采样算出,n 必须固定;真源里没有 target_affinity_curve"},
    {"metric": "consistency_residual", "within_spec_before_after": true, "across_spec": false, "needs_fixed_n": false,
     "note": "静态计数,无采样随机性;跨规格比的是一份规格自己的失配条数"},
    {"metric": "density_deviation", "within_spec_before_after": true, "across_spec": false, "needs_fixed_n": true,
     "note": "可读带 [250, 750] 千分是 7.2 的设计参数;窗口密度依赖采样"},
    {"metric": "invariant_residual", "within_spec_before_after": true, "across_spec": true, "needs_fixed_n": false,
     "note": "恒等判据,唯一可跨规格引用的指标"}
  ]
}

表里最关键的一行是 invariant_residual 的 across_spec: true——七条指标里只有整数不变量残差可以跨规格引用,因为它是恒等判据(必须为 0),不依赖任何分母、目标或采样规模。其余六条都被标成 across_spec: false,needs_fixed_n 视指标而定。

判读

第一,可比性是「口径的一部分」,不是附加说明。 一个数能跟谁比、不能跟谁比,和它怎么算一样重要。15.1 定义了「怎么算」,本节定义了「跟谁比」,两节合起来才是一条指标的完整口径。第二,跨规格比是本卷最常被误用的操作。 覆盖率 446 和覆盖率 1000 摆在一起,看起来是「一条路径 vs 全部路径」,其实分母一旦不同,「446」这个词本身就不指同一样东西。本节用加删一条选项把它做成可见的:路径没动,数动了——这就是跨规格比较失真的最小例子。

第三,样本量是熵的隐藏参数。 报熵的时候不报 n,等于报了一个没有刻度的数。n=50 的 1756 和 n=20000 的 1714 谁大谁小,离开 n 就没有意义。这一条会被 16.1 的装配检查表收进去,作为「指标必须带口径」的一部分。

自检表

检查项实测合格标准
量纲守卫3 / 3 被拒跨量纲相加必拒
跨规格漂移446 → 438 / 454路径不变、分母变
熵样本量表5 档 n 全跑至少覆盖 50 与 20000
规则表6 条,覆盖 7 / 7无未覆盖指标
跨规格可引用仅 1 条只有整数不变量残差

阅读导航:上一节:15.1 指标的定义与口径 · 下一节:15.3 指标不能声称什么 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长