3.2 分支的平衡与偏斜

20000 条路径逐边计数,把「分支被走得均不均匀」变成整数:分支覆盖率 1000‰、进入次数中位 4173、偏斜度 gini 373‰、头部集中度 29‰。再用偏置强度 b 从 0 扫到 1000 看相变——覆盖先掉、偏斜先抬、稀有结局先消失,软告警恒 exit 0。

3.2 分支的平衡与偏斜

《游戏设计》卷二《策略涌现与体验度量》 里的平衡是机制层的分布与平衡——一局战斗的动力学,哪种策略更强、资源怎么守恒;本节量的是叙事层的结局分布与分支平衡——玩家路径的形状,哪条分支被走得更多、整体偏不偏。上一节量的是结局的分布,这一节量的是分支(对话边)的分布:每条边被走过的次数,均不均匀。

覆盖率给总数,偏斜给形状

上一节(2.2)的覆盖率是一个总数:115 条对话边里走过了多少条。但覆盖率 1000‰ 也可能藏着两种截然不同的图:

  • 一种均匀:每条边都被走过差不多一样多次,玩家在哪都是近似随机;
  • 一种偏斜:少数几条主干边被走了上万次,绝大多数边只被擦过一次。

两者覆盖率都是 1000‰,形状却完全相反。把「形状」变成整数,需要三个量:进入次数分布(每条边被走过多少次)、偏斜度(Gini 系数 ×1000,0 = 完全均匀,接近 1000 = 极端偏斜)、头部集中度(走得最多的边占总进入次数的千分比)。下面这段 S 层脚本一次把它们算出来,并顺手做一次口径对照。

S 层:把偏斜算成整数

sim/nm_03_2_branch_balance.py 从 20000 条均匀随机路径统计每条对话边的进入次数(全规格 115 条选项),给出分布、偏斜与口径对照。它复用 sim/nm_03_common.py 的 walk / gini_permille / entropy_milli_bits。

#!/usr/bin/env python3
# file: sim/nm_03_2_branch_balance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_03_2_branch_balance.py
"""卷三 3.2 分支的平衡与偏斜(S 层):把「各分支被走得均不均匀」变成整数。

从 20000 条均匀随机路径统计**每条对话边的进入次数**(全规格 115 条选项),给出:
  1) 分支覆盖率(走过的边 / 全部边,千分比);
  2) 进入次数分布(最大 / 中位 / 最小 / 只走过一次的边数);
  3) 偏斜度 gini —— Gini 系数 ×1000 取整,0 = 完全均匀,接近 1000 = 极端偏斜;
  4) 头部集中度 top1_share —— 走得最多的边占总进入次数的千分比;
  5) 口径对照 —— 同一条确定性路径在「选项 id(115)」与「(node,goto) 对(56)」两个
     口径下覆盖率不同,说明「覆盖率」必须先声明口径。
公共内核见 sim/nm_03_common.py。口径(proxy):只做相对比较,不声称平衡更好。
产出 out/nm_03_2_branch_balance.json + .csv。
"""
from __future__ import annotations

import csv
import json
import math
import os
import sys

from nm_03_common import OUT, Rng, SEED, entropy_milli_bits, gini_permille, load_spec, walk

SAMPLE_N = 20000


def main() -> int:
    spec = load_spec()
    edge_ids = [c["id"] for n in spec["dialogue"] for c in (n.get("choices") or [])]
    edge_of = {c["id"]: (n["id"], c["goto"]) for n in spec["dialogue"] for c in (n.get("choices") or [])}
    all_pairs = set(edge_of.values())

    counts = {eid: 0 for eid in edge_ids}
    rng = Rng(SEED)
    for _ in range(SAMPLE_N):
        _, trail = walk(spec, rng)
        for cid in trail:
            counts[cid] += 1

    vals = list(counts.values())
    total_edges, total_entries = len(vals), sum(vals)
    covered = [v for v in vals if v > 0]
    coverage = len(covered) * 1000 // total_edges
    top1 = max(vals) * 1000 // total_entries
    med = sorted(vals)[total_edges // 2]
    gini_all = gini_permille(vals)
    gini_cov = gini_permille(covered)
    ent = entropy_milli_bits(covered, total_entries)
    ent_upper = int(math.log2(len(covered)) * 1000)

    # 口径对照:确定性首选项策略走过的一条路径
    _, single = walk(spec)
    single_cid_cov = len(set(single)) * 1000 // total_edges
    single_pair_cov = len({edge_of[c] for c in single}) * 1000 // len(all_pairs)

    os.makedirs(OUT, exist_ok=True)
    rows = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
    with open(os.path.join(OUT, "nm_03_2_branch_balance.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["choice_id", "entries"])
        w.writerows(rows)
    report = {
        "sample_n": SAMPLE_N, "seed": SEED,
        "total_edges": total_edges, "covered_edges": len(covered), "coverage_permille": coverage,
        "entry_counts": {"max": max(vals), "median": med, "min": min(vals),
                         "once": sum(1 for v in vals if v == 1)},
        "total_entries": total_entries,
        "gini_permille_all": gini_all, "gini_permille_covered": gini_cov,
        "top1_share_permille": top1,
        "branch_entropy_milli_bits": ent, "branch_entropy_upper_milli_bits": ent_upper,
        "single_path_choice_ids": len(set(single)), "single_path_pair_edges": len({edge_of[c] for c in single}),
        "total_pair_edges": len(all_pairs),
        "single_path_coverage_choice_id_permille": single_cid_cov,
        "single_path_coverage_pair_permille": single_pair_cov,
        "top10": rows[:10],
    }
    with open(os.path.join(OUT, "nm_03_2_branch_balance.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    print(f"采样          {SAMPLE_N} 条路径,共 {total_entries} 次分支进入")
    print(f"分支覆盖      {len(covered)} / {total_edges} 条边({coverage} 千分)")
    print(f"进入次数分布  最大 {max(vals)} · 中位 {med} · 最小 {min(vals)} · 只走过 1 次的边 {sum(1 for v in vals if v == 1)} 条")
    print(f"偏斜度 gini   全量 {gini_all} 千分 / 只算走过的边 {gini_cov} 千分")
    print(f"头部集中度    走得最多的边占 {top1} 千分")
    print(f"分支分布熵    {ent} 毫比特(上界 {ent_upper},按 {len(covered)} 条走过的边均匀算)")
    print(f"口径对照      单条确定性路径:选项 id {len(set(single))} / {total_edges} = {single_cid_cov} 千分;"
          f"(node,goto) 对 {len({edge_of[c] for c in single})} / {len(all_pairs)} = {single_pair_cov} 千分")
    print("Top5 分支      " + " · ".join(f"{cid} {cnt}" for cid, cnt in rows[:5]))
    uncovered = [cid for cid, v in counts.items() if v == 0]
    if uncovered:
        print(f"WARN {len(uncovered)} 条边在 {SAMPLE_N} 条采样中一次都没被走到(软告警,不硬失败)")
    print(f"wrote out/nm_03_2_branch_balance.json / .csv")
    print(f"OK nm_03_2_branch_balance:覆盖 {coverage} 千分,偏斜度 {gini_all} 千分")
    return 0


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

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

采样          20000 条路径,共 508271 次分支进入
分支覆盖      115 / 115 条边(1000 千分)
进入次数分布  最大 14760 · 中位 4173 · 最小 92 · 只走过 1 次的边 0 条
偏斜度 gini   全量 373 千分 / 只算走过的边 373 千分
头部集中度    走得最多的边占 29 千分
分支分布熵    6504 毫比特(上界 6845,按 115 条走过的边均匀算)
口径对照      单条确定性路径:选项 id 25 / 115 = 217 千分;(node,goto) 对 25 / 56 = 446 千分
Top5 分支      c_dq_1c 14760 · c_ho_3a 11784 · c_dq_2e 10074 · c_hub_a1a 10058 · c_hub_a2a 10050
wrote out/nm_03_2_branch_balance.json / .csv
OK nm_03_2_branch_balance:覆盖 1000 千分,偏斜度 373 千分

判读:偏斜是「主干 vs 支线」的形状

第一,覆盖率 1000‰ 不等于均匀。 115 条边全被走到,但进入次数从 92 到 14760 差了 160 倍,偏斜度 gini = 373‰。这是一张主干清晰、支线纤细的图:少数枢纽节点的选项(c_dq_1c 14760 次、c_ho_3a 11784 次)在每条路径上都被经过,而深处的支线只有少数路径会拐进去。「全被覆盖」和「走得均匀」是两件事。

第二,头部集中度只有 29‰,说明没有单点垄断。 走得最多的那条边也只占总进入次数的 2.9%——没有任何一条边吃掉大部分流量。这解释了为什么 gini 只有 373‰ 而不是接近 1000:偏斜来自「主干边被反复经过、支线边偶尔经过」的结构性差异,不是「一条边吸走一切」的病态差异。区分这两种偏斜,是看 gini 之外还要看头部集中度的理由。

第三,口径对照把「覆盖率必须先声明口径」摆上台面。 同一条确定性路径,按选项 id 算是 25 / 115 = 217‰,按 (node,goto) 对算是 25 / 56 = 446‰——同一条路,两个覆盖率。差别在于:规格里有 115 条选项,但很多选项指向同一个 (起点,终点) 对,去重后只剩 56 条。报覆盖率不写口径,这个数就没有意义。 目录篇里「单条路径覆盖 446‰」用的正是 (node,goto) 口径。

第四,分支分布熵 6504 毫比特,离上界 6845 不远。 按 115 条走过的边均匀分布,上界是 log2(115)*1000 = 6845;实测 6504,达上界的 95%。熵高但 gini 也高——这不矛盾:熵看的是「整体分布有多接近均匀」,gini 对尾部更敏感。两者一起看,才能说清「这张图偏斜但不病态」。

B 层:偏置强度扫出相变

均匀随机只是「怎么选」的一种参数。下面这段 B 层脚本 sim/nm_03_2_balance_sweep.py 把「选第一个可用选项」的偏置强度 b 当作可扰动参数,从 0(纯均匀)扫到 1000(总是选第一个),看覆盖、偏斜、熵怎么随 b 相变。B 层只产出软告警,恒 exit 0。

#!/usr/bin/env python3
# file: sim/nm_03_2_balance_sweep.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/nm_03_2_balance_sweep.py
"""卷三 3.2 分支平衡的相变扫描(B 层):参数扰动 → 覆盖与分布的相变,**软告警不硬失败**。

均匀随机采样只是「怎么选」的一种参数。本节把「选第一个可用选项」的**偏置强度** b 当作
可扰动参数,从 0(纯均匀随机)扫到 1000(总是选第一个):每步以 b/1000 的概率选第一个
可用选项,否则在可用选项里均匀随机。

每个 b 报四个结构量:分支覆盖率(千分)、偏斜度 gini(千分)、结局分布熵(毫比特)、
未被采样到的结局数。看的是**相变**:偏置一强,覆盖先掉、偏斜度先抬、稀有结局先消失。

口径(proxy):全部是结构量,只做相对比较。**B 层只产出软告警,一律 exit 0**。
公共内核见 sim/nm_03_common.py。产出 out/nm_03_2_balance_sweep.json。
"""
from __future__ import annotations

import json
import os
import sys

from nm_03_common import OUT, Rng, SEED, entropy_milli_bits, gini_permille, load_spec, walk

N_PER = 5000
BIASES = [0, 200, 400, 600, 800, 1000]


def run_bias(spec: dict, bias: int, n: int, seed: int) -> dict:
    edge_ids = [c["id"] for x in spec["dialogue"] for c in (x.get("choices") or [])]
    counts = {eid: 0 for eid in edge_ids}
    dist = {e["name"]: 0 for e in spec["endings"]}
    rng = Rng(seed)
    for _ in range(n):
        name, trail = walk(spec, rng, bias=bias)
        for cid in trail:
            counts[cid] += 1
        if name:
            dist[name] += 1
    covered = sum(1 for v in counts.values() if v > 0)
    return {
        "bias_permille": bias,
        "coverage_permille": covered * 1000 // len(counts),
        "gini_permille": gini_permille(list(counts.values())),
        "ending_entropy_milli_bits": entropy_milli_bits(list(dist.values()), n),
        "unsampled_endings": sum(1 for v in dist.values() if v == 0),
        "distribution": dist,
    }


def main() -> int:
    spec = load_spec()
    target = spec["metrics"]["coverage_target_permille"]
    thr = spec["metrics"]["ending_entropy_min_milli_bits"]
    rows = [run_bias(spec, b, N_PER, SEED) for b in BIASES]

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "nm_03_2_balance_sweep.json"), "w", encoding="utf-8") as fh:
        json.dump({"n_per": N_PER, "seed": SEED, "target_coverage_permille": target, "rows": rows},
                  fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    print(f"{'bias':>6}{'覆盖‰':>8}{'gini‰':>8}{'熵毫比特':>10}{'未采样结局':>12}")
    for r in rows:
        print(f"{r['bias_permille']:>6}{r['coverage_permille']:>8}{r['gini_permille']:>8}"
              f"{r['ending_entropy_milli_bits']:>10}{r['unsampled_endings']:>12}")
    warns = 0
    for r in rows:
        if r["coverage_permille"] < target:
            print(f"WARN bias={r['bias_permille']} 覆盖 {r['coverage_permille']} 千分低于目标 {target}")
            warns += 1
        if r["ending_entropy_milli_bits"] < thr:
            print(f"WARN bias={r['bias_permille']} 结局熵 {r['ending_entropy_milli_bits']} 毫比特低于阈值 {thr}")
            warns += 1
        if r["unsampled_endings"] > 0:
            print(f"WARN bias={r['bias_permille']} 有 {r['unsampled_endings']} 个结局未被采样到")
            warns += 1
    print(f"wrote out/nm_03_2_balance_sweep.json rows={len(rows)}")
    print(f"OK nm_03_2_balance_sweep:{len(rows)} 档偏置已扫描,告警 {warns} 条已报告(B 层不硬失败)")
    return 0


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

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

  bias     覆盖‰   gini‰      熵毫比特       未采样结局
     0    1000     375      1730           0
   200    1000     404      1898           0
   400     991     489      1985           0
   600     991     588      1854           0
   800     947     689      1411           1
  1000     217     783         0           4
WARN bias=800 结局熵 1411 毫比特低于阈值 1500
WARN bias=800 有 1 个结局未被采样到
WARN bias=1000 覆盖 217 千分低于目标 850
WARN bias=1000 结局熵 0 毫比特低于阈值 1500
WARN bias=1000 有 4 个结局未被采样到
wrote out/nm_03_2_balance_sweep.json rows=6
OK nm_03_2_balance_sweep:6 档偏置已扫描,告警 5 条已报告(B 层不硬失败)

判读:偏置一强,先掉覆盖、再抬偏斜

第一,相变是分阶段的。 b 从 0 抬到 400,覆盖率还挂在 1000‰→991‰,gini 却已经从 375 爬到 489——偏斜先动,覆盖后动。到 b=800,覆盖掉到 947‰、出现第 1 个未被采样的结局;到 b=1000(总是选第一个),覆盖塌到 217‰、gini 顶到 783、4 个结局消失、熵归 0。偏斜度是比覆盖率更早的预警信号。

第二,熵在 b=200 处反而最高(1898)。 纯均匀(b=0)熵 1730,偏置到 200 时升到 1898,之后才单调下滑。原因是轻微的「选第一个」偏置打散了纯均匀下的某些集中,让结局分布更接近均匀——这就是为什么不能只看「越均匀越好」的直觉:熵的峰值不在参数区间的端点。

第三,5 条告警全是软告警,脚本仍 exit 0。 b=800 的熵 1411 低于阈值 1500、b=1000 的覆盖 217‰ 低于目标 850‰,都只记 WARN。如果改成硬失败,那么「偏置策略」这个参数本身就无法被扫描——而它恰恰是要被观察的对象。断言写的是「告警条数已报告 = 5」,不是「必须无告警」。

分支平衡与偏斜都是代理指标:它们度量「这张图的分支流量有多集中」,不声称偏斜越小越好,也不声称某个 gini 值对应某种体验。所有结论都只在相对比较(不同偏置、不同采样规模、不同口径)里成立。

自检表

检查项合格标准
分布可算输出进入次数最大 / 中位 / 最小与只走过 1 次的边数
偏斜可比gini ×1000 整数;区分「结构性偏斜」与「病态垄断」(配头部集中度)
口径对照同一条路径在选项 id(115)与 (node,goto)(56)两口径下覆盖率不同
软告警口径B 层低于目标只记 WARN,恒 exit 0;断言写「告警条数已报告」

偏斜度把「分支流量怎么分布」量清楚了,但偏斜是当前这张图的静态形状。如果规格改一处、某条边从此走不到,偏斜会变——结局会不会因为一次改动而变得不可达,是下一节要做的回归。

阅读导航:上一节:3.1 结局分布的熵 · 下一节:3.3 结局可达性的回归 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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