上一节把平衡拆成四层,四层却给出互相打架的结论——「平衡」要能比较,就得先压成数。站内经济平衡的概念与示意代码 算的是经济系统的收支水位(产出、消耗、通胀),那是资源口径;本节算的是策略胜率分布的形状,是博弈口径。两者都在说「平衡」,量的却不是同一样东西。
5.2 平衡的度量
把平衡做成一个数,要先想清楚「什么算好」。一条自然的要求是:没有哪条打法明显更强。这句话可以拆成几个互相独立的量,每个都能从上一节的胜率矩阵里直接数出来。
| 分量 | 定义 | 越平衡的方向 |
|---|---|---|
band 带宽 | 策略行均值的极差(‰) | 越小越好 |
entropy 熵 | 行均值归一化后的 Shannon 熵 ×1000 | 越大越好 |
domFree 支配缺失度 | 没被任何策略支配的策略占比(‰) | 越大越好 |
viableCount 可用策略数 | 行均值 ≥ 最高 − 容差 的策略数 | 越多越好 |
mutualPairs 互有胜负对数 | 互有胜负的策略对 | 越多越好 |
balanceScore 复合分 | (1000 − band + entropy) // 2 | 越大越好 |
熵用行均值归一化后再除以 log2(策略数),所以它的满分是 1000‰——六条打法胜率完全相等时取满。所有分量都是代理指标:只做配置之间的相对比较,禁绝对化。复合分也不是唯一写法,这里让带宽与熵各占一半权重;换权重排序会变,所以比较两套配置必须用同一个公式与同一个容差。
{ "seeds": [401, "…", 416], "seedCounts": [4, 8, 12, 16],
"tolerances": { "viableTol": 200, "bandBalanced": 200 } }
S 层:把五个分量数出来
脚本把上一节的胜率矩阵重算一遍,再逐项数出分量。熵只在脚本内用 math.log2 求,落盘一律取整——配置与产物里不允许出现浮点。支配检测的完整算法见 2.2,这里只读它的结果计数。
#!/usr/bin/env python3
# file: sim/gd_play_05_metric.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_05_metric.py
"""卷二 5.2 平衡的度量(S 层):把 5.1 的四个层次压成一组可比的整数指标。
五个分量 + 一个复合分(全部千分比整数,禁浮点):band 胜率带宽 · entropy 策略胜率熵
(行均值归一化后的 Shannon 熵 ÷ log2(策略数) ×1000)· domFree 支配缺失度 · viableCount
可用策略数(行均值 ≥ 最高 − 容差且 > 0)· mutualPairs 互有胜负对数 ·
balanceScore = (1000 − band + entropy) // 2。熵只在脚本内用 math.log2 求,落盘一律取整。
支配检测的完整算法见 2.2,本节只读它的结果计数。产出 out/play05_metric.json + .svg。
所有分量都是**代理指标**:只用于相对比较,禁绝对化。
"""
import copy, json, math, os, sys, 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_05_metric.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
KEYS = ("enemyHp", "turnLimit", "enemyAttack")
def choose(rule, s, cost, dmg, blk, ea):
"""确定性策略规则:读当前快照,返回下一步命令(pK 出第 K 张,e 结束回合)。"""
hand, en = s["hand"], s["energy"]
aff = [i for i, c in enumerate(hand) if cost[c] <= en]
if not aff:
return "e"
if rule == "max_damage":
i = max(aff, key=lambda i: (dmg[hand[i]], -cost[hand[i]]))
elif rule == "block_first":
g = [i for i in aff if blk[hand[i]] > 0]
i = g[0] if g and s["block"] < ea else max(aff, key=lambda i: dmg[hand[i]])
elif rule == "min_cost":
i = min(aff, key=lambda i: (cost[hand[i]], -dmg[hand[i]]))
elif rule == "even_turn_big":
big = [i for i in aff if cost[hand[i]] >= 2]
if s["turn"] % 2 == 0:
return f"p{big[0]}" if big else "e"
i = min(aff, key=lambda i: (cost[hand[i]], -dmg[hand[i]]))
elif rule == "first_slot":
i = aff[0]
else:
i = aff[-1]
return f"p{i}"
def matrix(spec, cfg, seeds):
"""W[policy][encounter] = 胜场千分比(整数地板除)。"""
cost = {c["id"]: c["cost"] for c in spec["cards"]}
dmg = {c["id"]: c["damage"] for c in spec["cards"]}
blk = {c["id"]: c["block"] for c in spec["cards"]}
W = {}
for pol in cfg["policies"]:
row = []
for enc in cfg["encounters"]:
wins = 0
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"].update({k: enc[k] for k in KEYS})
seq = []
for _ in range(300):
sn = ashlib.run_spec(s, seq)
if sn["over"]:
break
seq.append(choose(pol["rule"], sn, cost, dmg, blk, enc["enemyAttack"]))
wins += 1 if ashlib.run_spec(s, seq)["won"] else 0
row.append(1000 * wins // len(seeds))
W[pol["id"]] = row
return W
def metrics(W, ids, tol):
means = {k: sum(W[k]) // len(W[k]) for k in ids}
band = max(means.values()) - min(means.values())
tot = sum(means.values())
ent = 0
if tot > 0:
h = sum(-(v / tot) * math.log2(v / tot) for v in means.values() if v > 0)
ent = int(round(1000 * h / math.log2(len(ids))))
dom = lambda a, b: all(W[a][i] >= W[b][i] for i in range(len(W[a]))) \
and any(W[a][i] > W[b][i] for i in range(len(W[a])))
dominant = sorted(a for a in ids if any(dom(a, b) for b in ids if b != a))
dominated = sorted(b for b in ids if any(dom(a, b) for a in ids if a != b))
best = max(means.values())
viable = sum(1 for v in means.values() if v > 0 and v >= best - tol)
mutual = sum(1 for i in range(len(ids)) for j in range(i + 1, len(ids))
if any(W[ids[i]][k] > W[ids[j]][k] for k in range(len(W[ids[i]])))
and any(W[ids[i]][k] < W[ids[j]][k] for k in range(len(W[ids[i]]))))
return {"means": means, "band": band, "entropy": ent,
"domFree": 1000 * (len(ids) - len(dominant)) // len(ids),
"dominant": dominant, "dominated": dominated, "viableCount": viable,
"mutualPairs": mutual, "balanceScore": (1000 - band + ent) // 2}
def draw(m, ids, path):
root = ashlib.svg_begin(640, 320)
ashlib.svg_text(root, 22, 28, "《灰烬牌库》平衡的度量:分量与复合分", 15)
ashlib.svg_text(root, 22, 48, "全部为代理指标:只做相对比较,禁绝对化", 11, "#94a3b8")
mx = max(m["means"].values()) or 1
for i, k in enumerate(ids):
y = 66 + i * 22
ashlib.svg_text(root, 22, y + 12, k, 12)
ashlib.svg_bar(root, 110, y, 300, 14, m["means"][k], mx, fill="#38bdf8", label=f"{m['means'][k]}‰")
comps = [("band", m["band"]), ("entropy", m["entropy"]), ("domFree", m["domFree"]),
("viableCount", m["viableCount"] * 1000 // len(ids)), ("balanceScore", m["balanceScore"])]
ashlib.svg_text(root, 22, 216, "分量(千分比)", 13)
for i, (k, v) in enumerate(comps):
ashlib.svg_text(root, 22, 241 + i * 16, k, 11)
ashlib.svg_text(root, 170, 241 + i * 16, f"{v}‰", 11, "#cbd5e1")
ashlib.svg_text(root, 380, 250, f"balanceScore={m['balanceScore']}‰", 16, "#22c55e")
return ashlib.svg_save(root, path)
def main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
ids, seeds = [p["id"] for p in cfg["policies"]], cfg["seeds"]
tol = cfg["tolerances"]["viableTol"]
W = matrix(spec, cfg, seeds)
m = metrics(W, ids, tol)
sweep = [{**{"seeds": c}, **{k: metrics(matrix(spec, cfg, seeds[:c]), ids, tol)[k]
for k in ("band", "entropy", "viableCount", "balanceScore")}}
for c in cfg["seedCounts"]]
size = draw(m, ids, os.path.join(OUT, "play05_metric.svg"))
report = {"id": "gd_play_05_metric", "seeds": len(seeds), "policies": ids, "matrix": W,
"metrics": m, "seedSweep": sweep, "tolerances": cfg["tolerances"],
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
with open(os.path.join(OUT, "play05_metric.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"{'策略':<9}{'行均值':>7} 遭遇胜率")
for k in ids:
print(f"{k:<9}{m['means'][k]:>7} " + " ".join(f"{v:4d}" for v in W[k]))
print(f"band={m['band']}‰ entropy={m['entropy']}‰ domFree={m['domFree']}‰ "
f"viable={m['viableCount']}/{len(ids)} mutual={m['mutualPairs']}")
print(f"支配者={m['dominant']} 被支配者={m['dominated']}")
print(f"balanceScore=({1000 - m['band']}+{m['entropy']})//2={m['balanceScore']}‰")
for r in sweep:
print(f" seeds={r['seeds']:>3} band={r['band']:>4} entropy={r['entropy']:>4} "
f"viable={r['viableCount']} score={r['balanceScore']}")
print(f"svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/play05_metric.svg out/play05_metric.json")
ET.parse(os.path.join(OUT, "play05_metric.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_05_metric.py:
策略 行均值 遭遇胜率
greedy 640 1000 687 1000 1000 375 375 687 0
tank 796 875 125 1000 1000 1000 1000 750 625
thrifty 226 500 0 0 687 0 0 625 0
mixed 93 125 0 0 625 0 0 0 0
first 538 875 0 562 1000 562 562 500 250
last 515 750 250 562 937 312 312 812 187
band=703‰ entropy=914‰ domFree=166‰ viable=2/6 mutual=6
支配者=['first', 'greedy', 'last', 'tank', 'thrifty'] 被支配者=['first', 'mixed', 'thrifty']
balanceScore=(297+914)//2=605‰
seeds= 4 band= 688 entropy= 907 viable=3 score=609
seeds= 8 band= 703 entropy= 915 viable=3 score=606
seeds= 12 band= 708 entropy= 910 viable=3 score=601
seeds= 16 band= 703 entropy= 914 viable=2 score=605
svg=4420 bytes elapsed=892 ms
wrote out/play05_metric.svg out/play05_metric.json
判读:一个复合分,五个分量
| 分量 | 实跑值 | 读法 |
|---|---|---|
band | 703‰ | 最弱 93‰ 到最强 796‰,差距大 |
entropy | 914‰ | 六条打法的胜率分布接近均匀(满分 1000) |
domFree | 166‰ | 只有 1/6 的策略没被支配 |
viableCount | 2/6 | 容差 200‰ 下只有 tank、greedy 够格 |
mutualPairs | 6 | 六对策略互有胜负 |
balanceScore | 605‰ | (297 + 914) // 2 |
注意 band 与 entropy 在这里是同向的(带宽越小、熵越大越平衡),所以复合分把两者相加是合理的。domFree 却给出了相反的信号:支配者是 first、greedy、last、tank、thrifty,被支配者里同时出现了 first 和 thrifty——也就是说支配关系不是全序,存在互相支配的对。一个策略可以既支配别人、又被别人支配。
B 层:容差与采样对度量的影响
复合分 605‰ 看起来是个确定的数,但它的两个分量分别依赖两个可调的旋钮:容差决定「可用策略数」,种子数决定采样。B 层把这两个依赖扫出来。
#!/usr/bin/env python3
# file: sim/gd_play_05_metric_robustness.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_05_metric_robustness.py
"""卷二 5.2 平衡度量的稳健性(B 层):**永远 exit 0**,只提示不使构建失败。
读 out/play05_metric.json(5.2 S 层产物):① 把「可用策略」容差从 50 扫到 400‰,看可用数
怎么变、在哪一格跨过半;② 摆出四种种子规模下的 balanceScore 极差;③ domFree 偏低时提醒
「无支配」这一分量实际不成立。每条含 severity=soft 与 hardFail=false。
产出 out/play05_metric_robustness.json。
"""
import json
import os
import sys
OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "out")
def main():
with open(os.path.join(OUT, "play05_metric.json"), encoding="utf-8") as f:
rep = json.load(f)
m, ids = rep["metrics"], rep["policies"]
n = len(ids)
warn = []
sweep = [{"tol": t, "viableCount": sum(1 for v in m["means"].values()
if v > 0 and v >= max(m["means"].values()) - t)}
for t in (50, 100, 150, 200, 250, 300, 400)]
half = next((s for s in sweep if s["viableCount"] * 2 >= n), None)
warn.append({"code": "TOLERANCE_SWEEP", "severity": "soft", "sweep": sweep,
"halfAt": half["tol"] if half else None,
"message": f"容差从 50 扫到 400‰,可用策略数在 {sweep[0]['viableCount']}→"
f"{sweep[-1]['viableCount']} 之间移动;容差 "
f"{half['tol'] if half else '—'}‰ 时刚好过半。「可用」是阈值定义的,不是自然事实"})
scores = [r["balanceScore"] for r in rep["seedSweep"]]
spread = max(scores) - min(scores)
warn.append({"code": "SAMPLING_SPREAD", "severity": "soft", "scores": scores, "spread": spread,
"message": f"四种种子规模下 balanceScore 为 {scores},极差 {spread}‰:"
+ ("超过 10‰,比较不同配置时应固定种子数" if spread > 10
else "很小,复合分对采样规模不敏感")})
if m["domFree"] < 500:
warn.append({"code": "DOMINANCE_PRESENT", "severity": "soft", "domFree": m["domFree"],
"dominant": m["dominant"],
"message": f"domFree={m['domFree']}‰:{len(m['dominant'])}/{n} 个策略能严格支配别人,"
f"「无支配」这一分量实际不成立——复合分被带宽与熵撑住了"})
warn.append({"code": "PROXY_ONLY", "severity": "soft",
"message": "band / entropy / domFree / viableCount 全部来自仿真胜率,是代理指标:"
"只做配置之间的相对比较,禁写「平衡度提升 N 倍」"})
report = {"id": "gd_play_05_metric_robustness", "tier": "B", "hardFail": False,
"note": "B 档软告警:只提示容差与采样对平衡度量的影响,不使任何构建失败",
"balanceScore": m["balanceScore"], "domFree": m["domFree"], "toleranceSweep": sweep,
"samplingScores": scores, "warnings": warn}
with open(os.path.join(OUT, "play05_metric_robustness.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
for w in warn:
print(f"SOFT WARN {w['code']}: {w['message']}")
print(f"warnings={len(warn)} hardFail=false → exit 0")
print("wrote out/play05_metric_robustness.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_05_metric_robustness.py:
SOFT WARN TOLERANCE_SWEEP: 容差从 50 扫到 400‰,可用策略数在 1→4 之间移动;容差 300‰ 时刚好过半。「可用」是阈值定义的,不是自然事实
SOFT WARN SAMPLING_SPREAD: 四种种子规模下 balanceScore 为 [609, 606, 601, 605],极差 8‰:很小,复合分对采样规模不敏感
SOFT WARN DOMINANCE_PRESENT: domFree=166‰:5/6 个策略能严格支配别人,「无支配」这一分量实际不成立——复合分被带宽与熵撑住了
SOFT WARN PROXY_ONLY: band / entropy / domFree / viableCount 全部来自仿真胜率,是代理指标:只做配置之间的相对比较,禁写「平衡度提升 N 倍」
warnings=4 hardFail=false → exit 0
wrote out/play05_metric_robustness.json
三条提示合起来说明:复合分 605‰ 这个数本身不脆弱(采样极差只有 8‰),但它的分量分别脆弱——「可用策略数」随容差从 1 挪到 4,「无支配」几乎不成立(5/6 的策略都在支配别人)。换句话说,复合分把内部分歧平均掉了;要做决策,得回去看分量。
自检表
| 检查项 | 合格标准 |
|---|---|
| 分量可数 | 每个分量都能从胜率矩阵直接数出来 |
| 全整数 | 落盘无浮点,熵在脚本内取整 |
| 复合分同口径 | 比较不同配置必须用同一公式与同一容差 |
| 代理指标显式 | 分量与复合分声明为代理指标,禁绝对化 |
度量做完了,还剩一个问题:如果「平衡」和「多样性」不是一回事,那能不能只追平衡?下一节用一个旋钮把两者拆开。
阅读导航:上一节:5.1 平衡的定义与层次 · 下一节:5.3 平衡与多样性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。