14.2 策略空间的热力图
上一节把图分成了「曲线读趋势、谱读分布」两族,横轴都只有一维。/posts/game/industry/
那一栏目的配图也基本停在这一层——把一段结论画成一张直观的图。但设计里更常见的问题是两个自变量同时变:换一套打法(策略族)、换一档难度(参数),结果怎么变?画成两条曲线会乱成一团。答案是热力图:行是一维、列是另一维,颜色承载第三个量。它不引入任何新指标,只是把两张一维表压进一张图。
读热力图先记住它的定位:它是二维切片,不是新量。行、列都来自你已经能算的整数,格值就是上一节的同一个代理指标(这里是胜率千分比)。本机没有 matplotlib,所以颜色、色阶、色标、网格线、行列表头全部由 xml.etree 手绘;每一格 rect 都带 data-row / data-col / data-value,画完立刻回读重建,逐格比对源矩阵。
颜色是第三维:整数色阶
热力图的全部难点在把 0–1000 的数映射成一个颜色。本节用三段整数色阶:0‰ 是深底 #0f172a,500‰ 是蓝 #2563eb,1000‰ 是橙 #f59e0b。落在两个色标之间的值,在 RGB 三个通道上各自线性插值,t 是千分比,全程地板除:
r = a_r + (b_r − a_r) × t // 1000 (g、b 通道同理,t = 1000 × (v − v0) // (v1 − v0))
全程无浮点,是因为浮点在跨机器重跑时会带来末位差异,而整数色阶在任何机器上都产出逐字节相同的 SVG。色标图例不由图像库生成,而是拿 legendSteps(10)段小 rect 拼出来,刻度值 0 / 500 / 1000 手写。
{ "seeds": [401, "…", 432], "maxSteps": 40,
"rows": ["spec", "max_damage", "max_block", "cheap_first"],
"cols": [6, 10, 14, 18, 22, 26], "cellPx": 62,
"ramp": [[0, "#0f172a"], [500, "#2563eb"], [1000, "#f59e0b"]],
"legendSteps": 10, "thresholdPermille": 500 }
S 层:四种策略族 × 六档难度
脚本对 4 种策略族各跑 6 档 enemyAttack × 32 个种子,得到 4×6 的胜率矩阵,再画成热力图并回读重建。四种策略族是:spec(照规格序列打)、max_damage(每步选伤害最高的可用牌)、max_block(每步选格挡最高的)、cheap_first(每步选费用最低的)。它们共用同一个 ashlib.run_spec 引擎,只是每步看快照后决定下一个命令——同一套机制,四种打法。
#!/usr/bin/env python3
# file: sim/gd_play_14_heatmap.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_14_heatmap.py
"""卷二 14.2 策略空间的热力图(S 层):行=策略族、列=参数档,格值=代理指标。
热力图是**二维切片**,不是新指标:它把「策略 × 参数」两张一维表压进一张图,用**颜色**承载
第三个量。本机无 matplotlib,所以颜色、色阶、色标、网格线、行列表头全部由 `xml.etree` 手绘:
- 颜色用**整数线性插值**在三个色标之间取(0‰ → 深、500‰ → 蓝、1000‰ → 橙),全程地板除;
- 每格 rect 带 `data-row` / `data-col` / `data-value`,画完立刻**回读重建**,逐格比对源矩阵;
- 色标图例由 `legendSteps` 段 rect 拼成,刻度值手写。
格值胜率是**代理指标(proxy)**,只做相对比较,禁绝对化:热力图能告诉你「哪一族在哪一档领先」,
不能告诉你「这一族更好玩」。全整数、无浮点、无 random。产出 out/play14_heatmap.svg + .csv + .json。
"""
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_14_heatmap.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
def hexrgb(h: str) -> tuple:
h = h.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def ramp(v: int, stops: list) -> str:
"""整数色阶:在 stops 的相邻色标之间线性插值(t 为千分比,全程地板除)。"""
for i in range(len(stops) - 1):
v0, c0 = stops[i]
v1, c1 = stops[i + 1]
if v <= v1:
t = 0 if v1 == v0 else 1000 * (v - v0) // (v1 - v0)
a, b = hexrgb(c0), hexrgb(c1)
return "#%02x%02x%02x" % (a[0] + (b[0] - a[0]) * t // 1000,
a[1] + (b[1] - a[1]) * t // 1000,
a[2] + (b[2] - a[2]) * t // 1000)
return stops[-1][1]
def play_policy(spec: dict, over: dict, seed: int, policy: str, max_steps: int) -> dict:
"""跑一条策略族:每步看快照再决定下一个命令;返回终局快照。"""
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"]}
s = copy.deepcopy(spec)
s["seed"] = seed
s["params"].update(over)
seq: list = []
for _ in range(max_steps):
sn = ashlib.run_spec(s, seq)
if sn["over"]:
break
if policy == "spec":
seq.append(s["strategy"][len(seq)] if len(seq) < len(s["strategy"]) else "e")
continue
hand, en = sn["hand"], sn["energy"]
aff = [i for i, cid in enumerate(hand) if cost[cid] <= en]
if not aff:
seq.append("e")
elif policy == "max_damage":
seq.append(f"p{max(aff, key=lambda i: (dmg[hand[i]], -cost[hand[i]]))}")
elif policy == "max_block":
seq.append(f"p{max(aff, key=lambda i: (blk[hand[i]], dmg[hand[i]]))}")
else: # cheap_first
seq.append(f"p{min(aff, key=lambda i: (cost[hand[i]], -dmg[hand[i]]))}")
return ashlib.run_spec(s, seq)
def win_permille(spec: dict, over: dict, seeds: list, policy: str, max_steps: int) -> int:
w = 0
for sd in seeds:
w += 1 if play_policy(spec, over, sd, policy, max_steps)["won"] else 0
return 1000 * w // len(seeds)
def draw(matrix: dict, rows: list, rowlabels: list, cols: list, cfg: dict, path: str) -> int:
"""手绘热力图:逐格 rect + 行列表头 + 格内数值 + 底部色标图例。"""
cp, pad, left, top = cfg["cellPx"], 6, 92, 88
gw, gh = len(cols) * cp, len(rows) * cp
W, H = left + gw + 96, top + gh + 74
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 20, 28, "策略空间热力图:行=策略族 · 列=enemyAttack · 格=胜率(代理指标 ‰)", 15)
ashlib.svg_text(root, 20, 46, "颜色承载第三维 · 纯 xml.etree 逐格 rect · 仅作相对比较", 11, "#94a3b8")
for j, c in enumerate(cols):
ashlib.svg_text(root, left + cp * j + cp // 2, top - 8, str(c), 11, "#cbd5e1", "middle")
ashlib.svg_text(root, left + gw // 2, top - 26, "enemyAttack", 11, "#64748b", "middle")
for i, r in enumerate(rows):
ashlib.svg_text(root, left - 8, top + cp * i + cp // 2 + 4, rowlabels[i], 11, "#cbd5e1", "end")
for j, c in enumerate(cols):
v = matrix[r][c]
fill = ramp(v, cfg["ramp"])
ET.SubElement(root, "rect", {"x": str(left + cp * j + 1), "y": str(top + cp * i + 1),
"width": str(cp - 2), "height": str(cp - 2), "fill": fill,
"data-row": r, "data-col": str(c), "data-value": str(v)})
ashlib.svg_text(root, left + cp * j + cp // 2, top + cp * i + cp // 2 + 4, str(v),
11, "#0f172a" if v >= cfg["thresholdPermille"] else "#e2e8f0", "middle")
lg_x, lg_y, sw = left, top + gh + 34, (gw - 40) // cfg["legendSteps"]
for k in range(cfg["legendSteps"]):
v = 1000 * k // (cfg["legendSteps"] - 1)
ET.SubElement(root, "rect", {"x": str(lg_x + sw * k), "y": str(lg_y), "width": str(sw),
"height": "12", "fill": ramp(v, cfg["ramp"]),
"data-legend": str(v)})
for v in (0, 500, 1000):
ashlib.svg_text(root, lg_x + sw * cfg["legendSteps"] * v // 1000, lg_y + 26, str(v),
9, "#94a3b8", "middle")
return ashlib.svg_save(root, path)
def main() -> int:
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
seeds, rows, cols = cfg["seedPool"][: cfg["cellsPerScan"]], cfg["rows"], cfg["cols"]
matrix = {r: {c: win_permille(spec, {"enemyAttack": c}, seeds, r, cfg["maxSteps"])
for c in cols} for r in rows}
best = {c: max(matrix[r][c] for r in rows) for c in cols}
colmax = sum(1 for c in cols for r in rows if matrix[r][c] == best[c])
total = len(rows) * len(cols)
dominated = 1000 - 1000 * colmax // total
header = ["policy"] + [f"ea{ c }" for c in cols]
n = ashlib.write_csv(os.path.join(OUT, "play14_heatmap.csv"), header,
[[r] + [matrix[r][c] for c in cols] for r in rows])
print(f"seeds={len(seeds)} 行={len(rows)} 族 列={len(cols)} 档 格值=胜率‰(代理指标)")
print(f"{'策略族':>12}" + "".join(f"{'ea' + str(c):>7}" for c in cols) + f"{'行均值':>8}")
for r in rows:
vals = [matrix[r][c] for c in cols]
print(f"{cfg['rowLabels'][rows.index(r)]:>12}" + "".join(f"{v:>7}" for v in vals)
+ f"{sum(vals) // len(vals):>8}")
print(f"{'列最大':>12}" + "".join(f"{best[c]:>7}" for c in cols))
print(f"\n支配格数(=列最大值的格)={colmax}/{total} "
f"支配缺失度={dominated}‰(=1000 − 1000×{colmax}//{total})")
size = draw(matrix, rows, cfg["rowLabels"], cols, cfg, os.path.join(OUT, "play14_heatmap.svg"))
text = open(os.path.join(OUT, "play14_heatmap.svg"), encoding="utf-8").read()
root = ET.fromstring(text)
tags: dict = {}
for e in root.iter():
t = e.tag.split("}")[-1]
tags[t] = tags.get(t, 0) + 1
cells = [e for e in root.iter() if e.tag.endswith("rect") and e.get("data-value")]
legend = [e for e in root.iter() if e.tag.endswith("rect") and e.get("data-legend")]
recon = {r: {} for r in rows}
for e in cells:
recon[e.get("data-row")][int(e.get("data-col"))] = int(e.get("data-value"))
mism = sum(1 for r in rows for c in cols if recon[r][c] != matrix[r][c])
print(f"\n回读 heatmap : ET.fromstring ok bytes={size} tags={tags}")
print(f"格 rect(带 data-value)={len(cells)}(应为 {total}) 色标 rect={len(legend)}"
f"(应为 {cfg['legendSteps']})")
print(f"回读重建 vs 源矩阵:逐格不符={mism}")
report = {"id": "gd_play_14_heatmap", "seeds": len(seeds), "rows": rows, "cols": cols,
"matrix": matrix, "colBest": best, "colMaxCells": colmax, "cells": total,
"dominatedPermille": dominated, "svgBytes": size, "svgTags": tags,
"reconMismatch": mism, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play14_heatmap.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"\ncsv={n} rows elapsed={report['elapsedMs']} ms")
print("wrote out/play14_heatmap.svg out/play14_heatmap.csv out/play14_heatmap.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_14_heatmap.py:
seeds=32 行=4 族 列=6 档 格值=胜率‰(代理指标)
策略族 ea6 ea10 ea14 ea18 ea22 ea26 行均值
规格序列 1000 1000 1000 968 750 531 874
最大伤害优先 1000 1000 1000 1000 1000 1000 1000
最大格挡优先 1000 1000 1000 1000 1000 843 973
低费优先 1000 1000 1000 968 937 375 880
列最大 1000 1000 1000 1000 1000 1000
支配格数(=列最大值的格)=17/24 支配缺失度=292‰(=1000 − 1000×17//24)
回读 heatmap : ET.fromstring ok bytes=8893 tags={'svg': 1, 'rect': 35, 'text': 40}
格 rect(带 data-value)=24(应为 24) 色标 rect=10(应为 10)
回读重建 vs 源矩阵:逐格不符=0
csv=4 rows elapsed=95 ms
wrote out/play14_heatmap.svg out/play14_heatmap.csv out/play14_heatmap.json
读数
第一件事看哪一族先掉。max_damage 一整行都是 1000——它在 6 档里全部赢满。spec 在 18 档就掉到 968、22 档 750、26 档只剩 531,是四族里最先崩的。max_block 撑到 22 档还是 1000、26 档 843。cheap_first 介于两者之间。同一套机制、同一批种子,只换每步的选牌规则,26 档的胜率就从 1000 差到 375——这就是热力图想让你一眼看见的东西。
第二件事看支配缺失度。把「列最大值」圈出来数一数:24 格里只有 17 格等于所在列的最大值,于是支配缺失度 = 1000 − 1000×17//24 = 292‰。这个数不是新指标,只是上一章「支配策略」概念在二维矩阵上的记法:值越高,说明越多的格被别的格支配(同一档下总有别的族更强)。292‰ 意味着大约三成的格是「被支配」的。
第三件事看回读。SVG 被 ET.fromstring 解析成功(8893 字节,35 个 rect、40 个 text),24 格逐格重建回源矩阵后不符 = 0,色标 10 段也对得上。热力图的正确性同样是三个可数的零,不是「看起来颜色对」。
自检表
| 检查项 | 合格标准 |
|---|---|
| 回读与逐格比对 | ET.fromstring 解析成功,24 格重建回源矩阵后不符数为 0 |
| 颜色可复现 | 色阶全程整数地板除,任何机器重跑产出逐字节相同的 SVG;色标图例手绘 |
| 整数纪律 | 坐标、色值、胜率全由整数运算得到,无浮点、无 random |
| 代理指标声明 | 格值胜率是 proxy,只作相对比较,禁绝对化 |
热力图的行列都是离散的:策略族不可排序、参数档也只是几档采样。要真正读出「临界」,就得让控制参数连续变化、把序参量的跃迁找出来——那是下一节相变图的读法。
阅读导航:上一节:14.1 曲线与谱 · 下一节:14.3 相变图的读法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。