1.2 策略空间枚举与 Shannon 熵

策略空间是全部合法动作序列的集合。本节先用 |A|^D 给出规模粗上界,再用广度优先把深度 8 以内的合法前缀树真的枚举出来——1679616 条粗上界里只有 181166 条合法,剪枝率 107‰;再在枚举出的空间上算四个整数定点 Shannon 熵,并解释为何深度 8 时结果熵会塌成 0。

《小游戏开发实战》卷一 6.1 核心循环拆解 用 S 层数的是核心循环的时长占比——一局的秒数怎么被切开,是时间轴上的量;本节数的是策略空间——把动作序列当对象,先量它的规模,再量它的形状,是状态空间上的量。两者的底座是同一份 config/play_spec.json,但一个数秒、一个数条,口径不重叠。上一节说明了策略有两种写法,本节把其中的动作序列这一侧做成可数的对象。

1.2 策略空间枚举与 Shannon 熵

**策略空间(strategy space)**就是全部合法动作序列的集合。它的规模由两个数决定:字母表大小 |A| 和序列长度 L。如果不加任何约束,长度恰为 L 的序列一共有 |A|^L 条。对《灰烬牌库》来说 |A| = 6、baseline 长度 L = 25,于是:

|A|^L = 6^25 = 28430288029929701376 ≈ 2.8 × 10^19

这个数字大得没有意义——它把「打一张不存在的牌」「能量不够还硬打」「游戏结束后继续按键」这些根本不可能发生的序列也算进去了。所以「策略空间有多大」这个问题,必须先把「合法」两个字补上。

先剪枝:只有合法的序列才算数

一条指令在某个状态下合不合法,由规格决定:p<i> 要求 0 ≤ i < len(hand) 且 card.cost ≤ energy,e 恒合法,游戏结束后扇出为 0。把非法指令在生成时就丢弃,剩下的就是合法序列。这个过滤有多强,可以在一个能完整枚举的深度上量出来:深度 8 的粗上界是 6^8 = 1679616,合法序列只有 181166 条——剪枝率 107‰,也就是砍掉了将近九成。

深度 d该层合法序列累计合法粗上界 6^d
1666
2313736
3123160216
44646241296
5197525997776
682851088446656
73374644630279936
81365351811651679616

注意深度 2 那一行:合法序列 31 条,而粗上界只有 36 条——几乎全部合法。到了深度 8,合法只剩粗上界的 107‰。剪枝的力量随深度增加,因为序列越长,越容易撞上「能量不够」或「游戏已结束」的边界。

四个 Shannon 熵

规模只是一个数,形状要用熵来刻画。本节在枚举出来的合法空间上算四个熵,全部取 ‰(千分比),全部用整数定点计算(避免浮点):

记号定义它在问什么
H0 先验熵`log2(A
Hact 动作熵6 个符号在枚举树上的出现分布之熵空间在动作维度上摊得有多开
Hbr 分支熵每个节点的合法动作个数(扇出)分布之熵分支有多不均匀
Hout 结果熵深度 D 前沿上 won / lost / ongoing 三类分布之熵到这个深度分出胜负了吗

熵的定义是 H = −Σ pᵢ·log2(pᵢ)。为了不引入浮点,脚本用整数平方逐位逼近 log2:L(n) = floor(2^20·log2(n)),于是 H ≈ [N·L(N) − Σ nᵢ·L(nᵢ)] / (N·2^20),再乘 1000 取整。

S 层:枚举与四个熵

sim/gd_play_01_entropy.py 读规格与 config/gd_play_01_space.json,广度优先展开合法前缀树(按序列计数,不是按状态),同时统计扇出直方图、符号直方图与终局直方图,最后算出四个熵。全整数,无浮点,无随机。

{
  "id": "gd_play_01_space",
  "note": "卷二 1.2/1.3 策略空间:枚举深度、枚举上限、种子与采样数(全整数,禁浮点)",
  "seeds": [201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216],
  "enumDepth": 8,
  "maxNodes": 200000,
  "maxBranch": 6,
  "sampleCount": 4096,
  "sampleDepth": 12
}
#!/usr/bin/env python3
# file: sim/gd_play_01_entropy.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_01_entropy.py
"""卷二 1.2 策略空间枚举与 Shannon 熵(S 层)。

策略空间 = 全部**合法动作序列**的集合。本节先给出规模(|A|^D 的粗上界),再用广度
优先把深度 D 以内的合法前缀树真的枚举出来(非法指令在生成时就被剪掉,游戏结束后
扇出为 0),最后在这个枚举出来的空间上算四个熵:

  H0 先验熵   = log2(|A|)            均匀随机按键时的最大熵(上界,与机制无关)
  H_act 动作熵 = 枚举树上 6 个符号的出现分布之熵(策略空间有多「摊得开」)
  H_br 分支熵  = 每个节点的扇出数(合法动作个数)分布之熵(分支有多不均匀)
  H_out 结果熵 = 深度 D 前沿上 won / lost / ongoing 三类终局分布之熵

熵全部用**整数定点**算(BITS 位小数),输出取 ‰,避免浮点:
  H = log2(N) − (1/N)·Σ nᵢ·log2(nᵢ) ≈ [N·L(N) − Σ nᵢ·L(nᵢ)] / (N·2^BITS)
其中 L(n) = floor(2^BITS·log2(n)) 由整数平方逼近得到。

**这些熵都是代理指标(proxy)**:它们度量策略空间的形状,不等于「好玩」,只用于
相对比较,禁止写成「熵越高越好」这类绝对结论。产出 out/play01_entropy.{json,csv,svg}。
"""
from __future__ import annotations

import json
import os
import sys
import xml.etree.ElementTree as ET

import ashlib

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_01_space.json")
BITS = 20


def alphabet(spec: dict) -> list[str]:
    return ["p" + str(i) for i in range(spec["params"]["handSize"])] + ["e"]


def log2_scaled(n: int, bits: int = BITS) -> int:
    """floor(2^bits · log2(n)),纯整数(整数平方逐位逼近小数部分),n ≥ 1。"""
    if n < 1:
        raise ValueError("log2 只接受 n>=1")
    k = n.bit_length() - 1
    res = k << bits
    y = (n << bits) >> k            # y = n/2^k · 2^bits ∈ [2^bits, 2^(bits+1))
    for i in range(bits):
        y = (y * y) >> bits
        if y >= (1 << (bits + 1)):
            res |= 1 << (bits - 1 - i)
            y >>= 1
    return res


def entropy_permille(counts: list[int]) -> int:
    """Shannon 熵 ×1000 取整(整数定点)。counts 全 0 时返回 0。"""
    total = sum(counts)
    if total <= 0:
        return 0
    acc = total * log2_scaled(total)
    for c in counts:
        if c:
            acc -= c * log2_scaled(c)
    return (1000 * acc) // (total << BITS)


def cost(spec: dict, cid: str) -> int:
    return next((c["cost"] for c in spec["cards"] if c["id"] == cid), 999999)


def legal(spec: dict, s: dict) -> list[str]:
    """可负担的出牌 p<i> + 恒合法的 e;游戏结束则为空(与 solvability 同口径)。"""
    if s["over"]:
        return []
    a = ["p" + str(i) for i, cid in enumerate(s["hand"]) if cost(spec, cid) <= s["energy"]]
    a.append("e")
    return a


def bfs(spec: dict, depth: int, cap: int) -> tuple:
    """按**序列**枚举合法前缀树:返回 (边数, 逐深度行, 扇出直方图, 符号直方图,
    终局直方图, 是否截断)。"""
    frontier: list[list[str]] = [[]]
    edges, fanout, symbols = 0, {}, {}
    rows, trunc = [], False
    last_snaps: list[dict] = []
    for d in range(1, depth + 1):
        nxt, snaps = [], []
        for seq in frontier:
            s = ashlib.run_spec(spec, seq)
            acts = legal(spec, s)
            fanout[len(acts)] = fanout.get(len(acts), 0) + 1
            for a in acts:
                if edges >= cap:
                    trunc = True
                    break
                edges += 1
                symbols[a] = symbols.get(a, 0) + 1
                nxt.append(seq + [a])
                snaps.append(None)      # 占位,终局在下一轮算
            if trunc:
                break
        rows.append((d, len(nxt), edges))
        if trunc or not nxt:
            last_snaps = [ashlib.run_spec(spec, seq) for seq in frontier] if not nxt else []
            break
        frontier = nxt
        last_snaps = [ashlib.run_spec(spec, seq) for seq in frontier]
    return edges, rows, fanout, symbols, last_snaps, trunc


def outcome_hist(snaps: list[dict]) -> dict:
    h = {"won": 0, "lost": 0, "ongoing": 0}
    for s in snaps:
        h["won" if s["won"] else ("lost" if s["over"] else "ongoing")] += 1
    return h


def entropy_svg(path: str, bars: list[tuple]) -> int:
    root = ashlib.svg_begin(620, 240)
    ashlib.svg_text(root, 20, 30, "1.2 策略空间的四个熵(‰,上限 = log2|A| = 2585‰)", 14)
    for i, (label, val, den) in enumerate(bars):
        y = 52 + i * 42
        ashlib.svg_text(root, 20, y + 16, label, 12)
        ashlib.svg_bar(root, 170, y, 380, 22, val, den, "#38bdf8", f"{val}‰")
    ashlib.svg_text(root, 20, 228, "熵是代理指标:只做相对比较,不判定「好玩」", 11, "#94a3b8")
    return ashlib.svg_save(root, path)


def main() -> int:
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    alpha = alphabet(spec)
    D, cap = cfg["enumDepth"], cfg["maxNodes"]

    edges, rows, fanout, symbols, last_snaps, trunc = bfs(spec, D, cap)
    raw = len(alpha) ** D

    h0 = entropy_permille([1] * len(alpha))                      # 先验:均匀
    h_act = entropy_permille([symbols.get(a, 0) for a in alpha])
    h_br = entropy_permille([fanout.get(b, 0) for b in range(0, len(alpha) + 1)])
    hist = outcome_hist(last_snaps)
    h_out = entropy_permille([hist[k] for k in ("won", "lost", "ongoing")])
    prune = (edges + 1) * 1000 // raw

    n = ashlib.write_csv(os.path.join(OUT, "play01_entropy.csv"),
                         ["depth", "atDepth", "cumulative"], [list(r) for r in rows])
    bars = [("H0 先验熵", h0, h0), ("H_act 动作熵", h_act, h0),
            ("H_br 分支熵", h_br, h0), ("H_out 结果熵", h_out, h0)]
    b = entropy_svg(os.path.join(OUT, "play01_entropy.svg"), bars)

    report = {"id": "gd_play_01_entropy", "alphabet": alpha, "alphabetSize": len(alpha),
              "enumDepth": D, "maxNodes": cap, "rawSpace": raw, "legalSpace": edges + 1,
              "truncated": trunc, "prunePermille": prune,
              "entropyPermille": {"H0": h0, "Hact": h_act, "Hbr": h_br, "Hout": h_out},
              "fanout": fanout, "symbols": symbols, "outcome": hist, "rows": rows}
    with open(os.path.join(OUT, "play01_entropy.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"alphabet |A|={len(alpha)} : {','.join(alpha)}")
    print(f"enumDepth={D} maxNodes={cap}")
    print(f"rawSpace |A|^D={raw}  legalSpace={edges + 1}  truncated={trunc}")
    print("depth atDepth cumulative")
    for r in rows:
        print(" ".join(str(x) for x in r))
    print(f"prunePermille={prune}‰(合法空间 / 粗上界)")
    print(f"fanout histogram (合法动作个数: 节点数) = "
          f"{dict(sorted(fanout.items()))}")
    print(f"symbol histogram (符号: 出现次数) = {dict(sorted(symbols.items()))}")
    print(f"outcome histogram = {hist}")
    print(f"H0={h0}‰  Hact={h_act}‰  Hbr={h_br}‰  Hout={h_out}‰")
    print(f"wrote out/play01_entropy.csv rows={n}")
    print(f"wrote out/play01_entropy.svg bytes={b}")
    ET.parse(os.path.join(OUT, "play01_entropy.svg"))
    return 0


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

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

alphabet |A|=6 : p0,p1,p2,p3,p4,e
enumDepth=8 maxNodes=200000
rawSpace |A|^D=1679616  legalSpace=181166  truncated=False
depth atDepth cumulative
1 6 6
2 31 37
3 123 160
4 464 624
5 1975 2599
6 8285 10884
7 33746 44630
8 136535 181165
prunePermille=107‰(合法空间 / 粗上界)
fanout histogram (合法动作个数: 节点数) = {1: 6856, 2: 3204, 3: 4344, 4: 7152, 5: 12189, 6: 10886}
symbol histogram (符号: 出现次数) = {'e': 44631, 'p0': 34031, 'p1': 34679, 'p2': 33455, 'p3': 23483, 'p4': 10886}
outcome histogram = {'won': 8, 'lost': 0, 'ongoing': 136527}
H0=2584‰  Hact=2483‰  Hbr=2446‰  Hout=0‰
wrote out/play01_entropy.csv rows=8
wrote out/play01_entropy.svg bytes=2168

判读:三个熵都接近上界,唯独结果熵塌了

H0 = 2584‰ 是理论上限(log2(6) = 2.585)。它是「玩家闭着眼均匀按键」时的熵,跟机制无关,放在这里只是为了给另外三个熵一把尺子。

Hact = 2483‰ 接近上界。枚举出来的合法空间里,6 个符号的出现次数是 e: 44631、p0: 34031、p1: 34679、p2: 33455、p3: 23483、p4: 10886——分布相当均匀(p4 偏少是因为它是手牌第 5 张,只有手牌满时才存在)。这说明合法空间在动作维度上摊得很开,没有哪条指令被边缘化。

Hbr = 2446‰ 也接近上界。扇出直方图是 {1: 6856, 2: 3204, 3: 4344, 4: 7152, 5: 12189, 6: 10886}——大部分节点的扇出是 5 或 6,只有少数是 1。分支分布同样均匀,说明这个机制在深度 8 内几乎处处都留了选择。

Hout = 0‰ 则是本节最该停下来读的一行。深度 8 的前沿上,136535 个状态里只有 8 个已经分出胜负,其余 136527 个全是 ongoing——结果分布几乎是一个点,熵自然塌成 0。这不是「空间没结果」,而是深度不够:一局《灰烬牌库》要打 4 个回合、约 25 步才能赢,深度 8 连一半都到不了。要把结果熵算准,需要枚举到 20 步以上,而 6^20 ≈ 3.6 × 10^15 条序列根本枚举不完。

这条落差正是下一节的入口:枚举能算的深度和结果需要的深度之间隔着一道墙。

熵是代理指标

四个熵都只是代理指标(proxy),不是「好玩」的度量。它们描述的是策略空间的形状——摊得多开、分支多均匀、结果多分散——只用于相对比较:换一组参数,Hact 从 2483‰ 掉到 1500‰,可以说「这个改动让策略空间在动作维度上变窄了」;但不能说「熵越高越好玩」,因为「好玩」从来没有被定义过一个单位。这一点在全卷通用,后面每一节的机制量指标都要照此声明。

检查项合格标准
规模有上界给出 `
合法有定义非法指令的判据写清(下标、费用、结束态)
枚举可复现同一深度同一上限,两次枚举的计数逐位相同
熵是整数用定点 log2,输出 ‰,禁浮点
代理显式正文声明熵是代理指标,只做相对比较、禁绝对化
诚实标注结果熵为 0 时说明是深度不足,不假装它「没结果」

下一节把这道墙拆成可操作的工程问题:当枚举撞上上限时,怎么用剪枝、去重与采样把空间压回可算,并给出「何时改用采样」的整数判据。

阅读导航:上一节:1.1 策略的表示:动作序列与决策树 · 下一节:1.3 策略空间的规模控制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡