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 结局可达性的回归 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。