14.2 距离矩阵与可区分性

先逐维 min-max 归一化到 0..1000,再用整数 L1 定义距离、用 floor(1000×d/maxd) 归一化成千分比,落成 6×6 对称矩阵,并验证对称性、三角不等式与「最小非零距离 > 0」的可区分性。实测塔防↔自走棋最近(268‰)、自走棋↔放置最远(1000‰),全部整数、无浮点。

14.2 距离矩阵与可区分性

14.1 造出了 6×10 的整数特征矩阵,但那张表还不能直接相减:各维量纲差了四个数量级(①机制计数是两位数,⑥状态空间对数上万,⑩平均步数能到 80 万)。直接算 L1,距离会被最大的那几维完全吃掉,得到一张「只反映状态空间和步数」的假矩阵。本节先把量纲抹平,再定义距离,最后验证这张矩阵真的能区分六个品类。这是机制距离矩阵,与 《风格与品类美术实战》的风格距离矩阵 是口径对照:同样的「整数 L1 + 归一化千分比」写法,但它量的是色相/明度/饱和度/笔触/纹理,本节量的是规则/状态/策略空间/相变宽度,特征集完全不同,不重造。

距离是代理指标(proxy):它只回答「谁离谁近」,不回答「像不像」。所以本节只报相对读数,绝不写「相似度 87%」这种绝对化表述。

距离的定义:整数 L1 + 归一化千分比

三步,全是整数地板除、无浮点:

  1. 逐维 min-max 归一化到 0..1000:u_ik = floor(1000 × (v_ik − lo_k) / (hi_k − lo_k)),lo_k/hi_k 是第 k 维在六个品类上的最小/最大值。量程为 0 的维度整列置 0(本项目六个品类没有这种情况,但判据要留)。
  2. L1 距离:d(i,j) = Σ_k |u_ik − u_jk|,取值范围 [0, 10000](10 维 × 1000)。
  3. 归一化千分比距离:D(i,j) = floor(1000 × d(i,j) / max d),对角线置 0。

归一化这一步是有代价的:它把「绝对值大小」丢掉了,只留相对位置。好处是六个品类在同一把尺子上比;代价是 14.3 要交代的——换个维度集,归一化后的矩阵就会变。为什么用 L1 而不是欧氏距离?因为 L1 是可加的:d(i,j) 就是十维差的和,能逐维拆开看是谁贡献了这段距离;欧氏距离要开方,拆不出整数,也不利于后面做维度敏感性分析。

为什么先归一化:量纲差四个数量级

看 14.1 的原始矩阵:⑥状态空间对数最小 17975、最大 46458,⑩平均步数最小 4971、最大 800000。如果跳过归一化直接算 L1,任何一对的距离都会被这两维主导——⑩单独就能贡献几十万的差值,其余八维加起来不过几百。归一化把每一维拉到同一量程,让十个维度等权参与。这个「等权」本身也是一个选择,不是真理;14.3 会把它列为边界之一。

S 层:归一化、L1 与三条验证

sim/gd_proto_14_matrix.py 读 14.1 的 out/gd_proto_14_features.json,做上面三步,再验证三条性质,落成 out/gd_proto_14_matrix.json 与热力图 out/gd_proto_14_heatmap.svg。

#!/usr/bin/env python3
# file: sim/gd_proto_14_matrix.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_14_matrix.py
"""卷三 14.2 距离矩阵与可区分性(S 层):把 6×10 特征向量变成 6×6 整数距离矩阵。

三步,全是整数地板除、无浮点:
  1) 逐维 min-max 归一化到 0..1000(10 个维度量纲差 4 个数量级,不归一化就被 ⑥ 主导);
  2) L1 距离 d(i,j) = Σ_k |u_ik − u_jk| ∈ [0, 10000];
  3) 归一化千分比距离 D(i,j) = floor(1000 × d(i,j) / max d),对角线置 0。

验证:对称性 d(i,j)=d(j,i)、三角不等式 d(i,k) ≤ d(i,j)+d(j,k)、最小非零距离 > 0(可区分性)。
距离是**代理指标(proxy)**,只做相对比较,禁绝对化。产出 out/gd_proto_14_matrix.json +
out/gd_proto_14_heatmap.svg。
"""
from __future__ import annotations

import json
import os
import sys
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")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402


def normalize(vecs: list) -> list:
    """逐维 min-max 归一化到 0..1000(整数地板除);量程为 0 的维度整列置 0。"""
    rows = len(vecs)
    cols = len(vecs[0])
    out = [[0] * cols for _ in range(rows)]
    for k in range(cols):
        col = [vecs[i][k] for i in range(rows)]
        lo, hi = min(col), max(col)
        if hi == lo:
            continue
        for i in range(rows):
            out[i][k] = (1000 * (vecs[i][k] - lo)) // (hi - lo)
    return out


def l1(a: list, b: list) -> int:
    return sum(abs(x - y) for x, y in zip(a, b))


def heatmap(labels, dist, path) -> int:
    n = len(labels)
    cw, ch, x0, y0 = 92, 54, 128, 92
    root = ashlib.svg_begin(x0 + cw * n + 40, y0 + ch * n + 56)
    ashlib.svg_text(root, 16, 28, "品类机制距离矩阵:归一化千分比距离 D(对角线 0)", 15)
    ashlib.svg_text(root, 16, 50, "色越深 = 越远;代理指标,只做相对比较", 11, "#94a3b8")
    for j, lb in enumerate(labels):
        ashlib.svg_text(root, x0 + j * cw + cw // 2, y0 - 12, lb, 12, anchor="middle")
    for i, lb in enumerate(labels):
        y = y0 + i * ch
        ashlib.svg_text(root, x0 - 12, y + ch // 2 + 4, lb, 12, anchor="end")
        for j in range(n):
            v = dist[i][j]
            t = 255 - v * 210 // 1000  # 越远越深
            ET.SubElement(root, "rect", {"x": str(x0 + j * cw + 2), "y": str(y + 2),
                "width": str(cw - 4), "height": str(ch - 4),
                "fill": f"#{t:02x}{(t + 24 if t + 24 < 256 else 255):02x}ff", "rx": "4"})
            ashlib.svg_text(root, x0 + j * cw + cw // 2, y + ch // 2 + 4, str(v), 12,
                            "#0b1220", "middle")
    return ashlib.svg_save(root, path)


def main() -> int:
    with open(os.path.join(OUT, "gd_proto_14_features.json"), encoding="utf-8") as f:
        feat = json.load(f)
    vecs, labels = feat["vectors"], feat["labels"]
    n = len(vecs)
    norm = normalize(vecs)

    d = [[0] * n for _ in range(n)]
    for i in range(n):
        for j in range(i + 1, n):
            d[i][j] = d[j][i] = l1(norm[i], norm[j])
    maxd = max(d[i][j] for i in range(n) for j in range(n))
    dist = [[(0 if i == j else (1000 * d[i][j]) // maxd) for j in range(n)] for i in range(n)]

    raw = [[0] * n for _ in range(n)]
    for i in range(n):
        for j in range(i + 1, n):
            raw[i][j] = raw[j][i] = l1(vecs[i], vecs[j])

    sym = all(d[i][j] == d[j][i] for i in range(n) for j in range(n))
    tri = all(d[i][k] <= d[i][j] + d[j][k]
              for i in range(n) for j in range(n) for k in range(n))
    tri_dist = all(dist[i][k] <= dist[i][j] + dist[j][k]
                   for i in range(n) for j in range(n) for k in range(n))
    off = [d[i][j] for i in range(n) for j in range(n) if i != j]
    min_nonzero = min(off)
    pairs = sorted(((d[i][j], labels[i], labels[j]) for i in range(n) for j in range(i + 1, n)))
    raw_pairs = sorted(((raw[i][j], labels[i], labels[j]) for i in range(n) for j in range(i + 1, n)))

    report = {
        "id": "gd_proto_14_matrix", "genres": feat["genres"], "labels": labels,
        "dims": feat["dims"], "normalized": norm, "l1": d, "distPermille": dist,
        "rawL1": raw, "maxL1": maxd,
        "checks": {"symmetric": sym, "triangle": tri, "triangleOnPermille": tri_dist,
                   "minNonZero": min_nonzero, "distinct": min_nonzero > 0},
        "closest": [{"l1": p[0], "pair": [p[1], p[2]],
                     "permille": dist[labels.index(p[1])][labels.index(p[2])]} for p in pairs[:4]],
        "farthest": [{"l1": p[0], "pair": [p[1], p[2]],
                      "permille": dist[labels.index(p[1])][labels.index(p[2])]} for p in pairs[-3:]],
        "rawFarthest": [{"l1": p[0], "pair": [p[1], p[2]]} for p in raw_pairs[-3:]],
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "gd_proto_14_matrix.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
        f.write("\n")
    size = heatmap(labels, dist, os.path.join(OUT, "gd_proto_14_heatmap.svg"))

    print("归一化特征(0..1000):")
    for i, lb in enumerate(labels):
        print(f"  {lb:<6} " + " ".join(str(x).rjust(4) for x in norm[i]))
    print("\nL1 距离矩阵(归一化特征上):")
    print("        " + "".join(lb.rjust(7) for lb in labels))
    for i, lb in enumerate(labels):
        print(f"  {lb:<6}" + "".join(str(d[i][j]).rjust(7) for j in range(n)))
    print("\n归一化千分比距离 D = floor(1000×d/maxd):")
    print("        " + "".join(lb.rjust(7) for lb in labels))
    for i, lb in enumerate(labels):
        print(f"  {lb:<6}" + "".join(str(dist[i][j]).rjust(7) for j in range(n)))
    print(f"\nmaxd={maxd}  对称={sym}  三角不等式={tri}(千分比上={tri_dist})"
          f"  最小非零距离={min_nonzero}  可区分={min_nonzero > 0}")
    print("最近 4 对: " + "  ".join(f"{p['pair'][0]}↔{p['pair'][1]}:{p['l1']}/{p['permille']}‰" for p in report["closest"]))
    print("最远 3 对: " + "  ".join(f"{p['pair'][0]}↔{p['pair'][1]}:{p['l1']}/{p['permille']}‰" for p in report["farthest"]))
    print(f"未归一化的原始 L1 最远 3 对: " + "  ".join(f"{p['pair'][0]}↔{p['pair'][1]}:{p['l1']}" for p in report["rawFarthest"]))
    print(f"svg={size} bytes")
    print("wrote out/gd_proto_14_matrix.json out/gd_proto_14_heatmap.svg")
    ET.parse(os.path.join(OUT, "gd_proto_14_heatmap.svg"))
    return 0


if __name__ == "__main__":
    sys.exit(main())
# snippet: 跑本节 S 层
/opt/homebrew/bin/python3 sim/gd_proto_14_matrix.py
归一化特征(0..1000):
  卡牌构筑    477    0   37    0  750  119   17  500 1000    0
  塔防      477  700    0  809  500   75    0  500  500    0
  三消        0  700  174  610  375  116   22 1000  750   23
  平台跳跃     68  600  502  557  500 1000  193    0    0  120
  自走棋    1000 1000   14 1000    0    0    7  500  625    8
  放置      318  700 1000  327 1000  245 1000  500    0 1000

L1 距离矩阵(归一化特征上):
           卡牌构筑     塔防     三消   平台跳跃    自走棋     放置
  卡牌构筑        0   2357   3080   4958   3808   5508
  塔防       2357      0   1811   3501   1743   4811
  三消       3080   1811      0   3576   2996   5386
  平台跳跃     4958   3501   3576      0   5186   4520
  自走棋      3808   1743   2996   5186      0   6496
  放置       5508   4811   5386   4520   6496      0

归一化千分比距离 D = floor(1000×d/maxd):
           卡牌构筑     塔防     三消   平台跳跃    自走棋     放置
  卡牌构筑        0    362    474    763    586    847
  塔防        362      0    278    538    268    740
  三消        474    278      0    550    461    829
  平台跳跃      763    538    550      0    798    695
  自走棋       586    268    461    798      0   1000
  放置        847    740    829    695   1000      0

maxd=6496  对称=True  三角不等式=True(千分比上=True)  最小非零距离=1743  可区分=True
最近 4 对: 塔防↔自走棋:1743/268‰  塔防↔三消:1811/278‰  卡牌构筑↔塔防:2357/362‰  三消↔自走棋:2996/461‰
最远 3 对: 三消↔放置:5386/829‰  卡牌构筑↔放置:5508/847‰  自走棋↔放置:6496/1000‰
未归一化的原始 L1 最远 3 对: 自走棋↔放置:798568  卡牌构筑↔放置:800941  塔防↔放置:802477
svg=9026 bytes
wrote out/gd_proto_14_matrix.json out/gd_proto_14_heatmap.svg

三条验证

一张距离矩阵要能用,至少得满足三条性质,脚本逐条断言:

  • 对称性:d(i,j) = d(j,i)。L1 逐维取绝对值、求和,天然对称。实测 对称=True。
  • 三角不等式:d(i,k) ≤ d(i,j) + d(j,k)。L1 作为度量必满足,脚本对全部 6³ = 216 个三元组穷举,三角不等式=True,且在千分比矩阵上也成立(千分比上=True)。这条保证「绕道不会更近」——若某个品类是另外两个的「桥」,矩阵不会给出矛盾读数。
  • 可区分性:最小非零距离 > 0。这是最要紧的一条:如果两个品类距离为 0,它们在这个特征集下就无法区分,矩阵退化成一张有重复行的表。实测最小非零距离是 1743(塔防↔自走棋),可区分=True——六个品类两两可分,没有一行是另一行的复制品。

maxd = 6496 是矩阵里的最大 L1(自走棋↔放置),所有千分比都拿它当分母,所以最远的那一对必然被归一化成 1000。

6×6 矩阵怎么读

千分比矩阵是本节的主产物,先看最近与最远两端:

  • 最近的一对是塔防↔自走棋(L1 1743,千分比 268‰)。它们都围绕「布阵 + 逐回合结算 + 经济积累」展开,状态字段数(19 vs 22)、规则条数(39 vs 62)都在同一档,L1 只差在随机结构与相变宽度上。
  • 第二近是塔防↔三消(1811/278‰)——都是短回合、强反馈的节奏型品类。
  • 最远的三对全部涉及放置:自走棋↔放置 6496/1000‰、卡牌构筑↔放置 5508/847‰、三消↔放置 5386/829‰。放置的单局平均步数是 800000(归一化后顶格 1000),状态空间与决策深度也都拉满,几乎在每一维上都是极值,所以它离谁都远。

这里有一个必须点破的陷阱:如果不做归一化,原始 L1 的「最远三对」是自走棋↔放置 798568、卡牌构筑↔放置 800941、塔防↔放置 802477——三对全部涉及放置,且数值差了十几倍。这正是量纲主导的证据:原始 L1 里放置的⑩平均步数(800000)一项就压过其余九维之和。归一化后三对的距离被拉回 1000‰/847‰/829‰,不再差十几倍,十个维度才真正等权。

热力图 out/gd_proto_14_heatmap.svg 用纯 xml.etree 生成(无第三方库),色越深表示越远,对角线的 0 最浅。它一眼能看出的结构是:放置那一行/列整体最深,塔防/自走棋/三消三者的交叉块最浅。

热力图:纯 xml.etree,不引第三方库

heatmap() 只用 xml.etree.ElementTree 拼 SVG:每个格子是一个 <rect>,灰度按 t = 255 − v × 210 / 1000 从浅到深线性映射(v 是千分比距离,0 最浅、1000 最深),格子中心叠一个 <text> 写上数值。没有引入 matplotlib、numpy 或任何绘图库——这是本套书稿的纪律:可视化也必须是可复现的纯标准库产物,能被 ET.parse 回读、能在 diff 里逐字节比较。矩阵落成 JSON(out/gd_proto_14_matrix.json)而不仅是图,正是为了让后续章节(15/16 与附录 A)能直接读数值,而不是去「看」一张图。

本节实跑清单

命令产物实测
/opt/homebrew/bin/python3 sim/gd_proto_14_matrix.pyout/gd_proto_14_matrix.json6×6 对称矩阵,maxd=6496
同上out/gd_proto_14_heatmap.svg9026 bytes,ET.parse 回读通过
三条验证布尔断言对称 / 三角 / 可区分 全为 True

矩阵到手了,但它到底是「真相」还是「一把尺子量出来的一个视角」?14.3 把这张矩阵读一遍,并老老实实交代它的三条边界:换维度会变、地板除有量化误差、它只是代理指标不能绝对化。

阅读导航:上一节:14.1 品类的特征向量 · 下一节:14.3 距离的读法与边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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