14.2 距离矩阵与可区分性
14.1 造出了 6×10 的整数特征矩阵,但那张表还不能直接相减:各维量纲差了四个数量级(①机制计数是两位数,⑥状态空间对数上万,⑩平均步数能到 80 万)。直接算 L1,距离会被最大的那几维完全吃掉,得到一张「只反映状态空间和步数」的假矩阵。本节先把量纲抹平,再定义距离,最后验证这张矩阵真的能区分六个品类。这是机制距离矩阵,与 《风格与品类美术实战》的风格距离矩阵 是口径对照:同样的「整数 L1 + 归一化千分比」写法,但它量的是色相/明度/饱和度/笔触/纹理,本节量的是规则/状态/策略空间/相变宽度,特征集完全不同,不重造。
距离是代理指标(proxy):它只回答「谁离谁近」,不回答「像不像」。所以本节只报相对读数,绝不写「相似度 87%」这种绝对化表述。
距离的定义:整数 L1 + 归一化千分比
三步,全是整数地板除、无浮点:
- 逐维 min-max 归一化到
0..1000:u_ik = floor(1000 × (v_ik − lo_k) / (hi_k − lo_k)),lo_k/hi_k是第 k 维在六个品类上的最小/最大值。量程为 0 的维度整列置 0(本项目六个品类没有这种情况,但判据要留)。 - L1 距离:
d(i,j) = Σ_k |u_ik − u_jk|,取值范围[0, 10000](10 维 × 1000)。 - 归一化千分比距离:
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.py | out/gd_proto_14_matrix.json | 6×6 对称矩阵,maxd=6496 |
| 同上 | out/gd_proto_14_heatmap.svg | 9026 bytes,ET.parse 回读通过 |
| 三条验证 | 布尔断言 | 对称 / 三角 / 可区分 全为 True |
矩阵到手了,但它到底是「真相」还是「一把尺子量出来的一个视角」?14.3 把这张矩阵读一遍,并老老实实交代它的三条边界:换维度会变、地板除有量化误差、它只是代理指标不能绝对化。
阅读导航:上一节:14.1 品类的特征向量 · 下一节:14.3 距离的读法与边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。