14.3 距离的读法与边界
14.2 算出了一张 6×6 的距离矩阵,但它到底能读出什么、不能读出什么,还没交代。本节做两件事:先把矩阵读一遍(谁离谁近、为什么),再把它的三条边界如实摆出来——① 距离依赖维度选择,② 整数地板除带来量化误差,③ 距离只是代理指标,只能相对比较、禁绝对化。这是机制距离矩阵,与 《风格与品类美术实战》的风格距离矩阵 分工明确:它量视觉(色相/明度/饱和度/笔触/纹理),本节量机制(规则/状态/策略空间/相变宽度),口径对照、不重造。读法与边界两边通用,但两把尺子量的不是同一个空间。
怎么读这张矩阵
把 14.2 的千分比矩阵按距离重排,能看出三层结构:
- 放置是孤岛:它到其余五个品类的距离是
695‰~1000‰,整行整列都最远。原因是它在 ⑩平均步数(顶格)、⑥状态空间、⑦决策深度三处都是极值——放置的一局是几万个 tick,其余品类是几步到上百步,量级差了两个数量级。 - 塔防 / 自走棋 / 三消是一簇:三对距离
268‰、278‰、461‰,是全矩阵最近的一角。它们共享「短回合 + 经济积累 + 强反馈」的骨架。 - 卡牌构筑居中、平台跳跃偏外:卡牌构筑到塔防最近(
362‰),到放置最远(847‰);平台跳跃离谁都偏远(538‰起),因为它带一个别处没有的维度——⑧随机结构计数是0(平台跳跃完全确定),⑨相变宽度也是0(一刀切),这两维把它推向矩阵外缘。
注意这里的措辞:只能说「塔防与自走棋在本特征集下 L1 距离 1743、归一化千分比 268,是 6×6 中最近的一对」,绝不能说「塔防和自走棋相似度 73%」。268‰ 不是「相似度」,它是「这一对的距离,在所有 15 对里排第 1 近」——只此一个含义。
边界一:距离依赖维度选择
同一批品类,换一组维度就会得到另一张矩阵。这不是 bug,是任何特征距离都逃不掉的。量化它最直接的办法是留一法:把第 k 维置 0 后重算整张矩阵,看它相对基准变了多少。sim/gd_proto_14_read.py 对十个维度各做一次,报出平均变化 Δ_k(千分比)与「最近的一对」是否改判。
结果(下面实跑输出):最关键的维度是 ⑩平均步数(Δ=63),最不关键的是 ④结果熵(Δ=33)——但注意差距很小,33 到 63,说明没有哪一维能单独决定整张矩阵。更值得警惕的是「最近的一对」在 10 次留一里有 4 次改判:删掉 ⑧随机结构、③策略空间、⑨相变宽度会改判成塔防↔三消,删掉 ④结果熵会改判成卡牌构筑↔塔防。也就是说,「塔防↔自走棋最近」这个结论并不稳健——它依赖当前这十维的选择,换一维就可能易主。
边界二:整数地板除的量化误差
整条流水线有两级地板除,各自有上界:
- 归一化:
u = floor(1000 × (v − lo) / (hi − lo)),每格误差< 1。它传播到 L1:每一维的|u_ik − u_jk|最多带2的误差(两端各< 1),十维加起来,每一对 L1 的误差 ≤ 2 × 10 = 20。 - 千分比:
D = floor(1000 × d / maxd),每格截断掉的余数是(1000 × d mod maxd) / maxd,恒< 1。
把两级合起来:D 的误差上界约 1000 × 20 / 6496 + 1 ≈ 4‰。这意味着矩阵里相差 4‰ 以内的两对,不能断言谁更近。反过来看,最远那对是 1000‰、最近那对是 268‰,跨度远大于 4‰,所以矩阵的大结构(谁近谁远)是稳的,不稳定的只是小数点后的名次。实跑里千分比那一级的最大截断余数是 6152/6496 ≈ 0.947,15 对合计 45256/6496 ≈ 6.97——都远小于各自的上界。
边界三:代理指标纪律
距离是代理指标(proxy)。它量的不是「品类像不像」,而是「在这十个维度上,两个品类的归一化读数差多少」。这两件事不等价,所以有三条纪律:
- 只做相对比较:能说「A 比 B 离 C 近」,不能说「A 与 C 的相似度是 X%」。
- 禁绝对化:
268‰不是「相似度 73.2%」,也不是「差 26.8%」。它只是一个排在 15 对里第 1 位的距离读数。 - 结论要带条件:任何读数都要带上前提——「在本特征集下」「在整数 L1 与归一化千分比口径下」。14.2 的「最近」「最远」全部是这个口径下的相对名次,不是品类的内在属性。
与风格距离矩阵的分工
本章的机制距离矩阵和《风格与品类美术实战》的风格距离矩阵是两把不同的尺子,各量各的,不能相加、不能相乘:
| 对照项 | 风格距离矩阵 | 机制距离矩阵(本章) |
|---|---|---|
| 特征集 | 色相 / 明度 / 饱和度 / 笔触 / 纹理 | 规则条数 / 状态字段 / 策略空间 / 相变宽度 等十维 |
| 量的对象 | 画面像不像 | 玩法像不像 |
| 出处 | 《风格与品类美术实战》 | 本章 14.1~14.3 |
| 口径 | 整数 L1 + 归一化千分比 | 同(口径对照,不重造) |
两者合起来用,得到的是两个独立的答案:一个品类可能在视觉上离 A 近、在机制上离 B 近,这并不矛盾——它恰恰说明「品类距离」不是一个数,而是一组按特征集分开的距离。把两个矩阵的读数混着比(比如「风格距离 300 比机制距离 400 更远」),是口径错误。
S 层:读法、留一敏感性与量化误差
sim/gd_proto_14_read.py 读 14.2 的矩阵,做留一维度敏感性、算量化误差上界,并画一张敏感性条形图(纯 xml.etree,无第三方库)。
#!/usr/bin/env python3
# file: sim/gd_proto_14_read.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_14_read.py
"""卷三 14.3 距离的读法与边界(S 层):把 14.2 的距离矩阵「读」出三条边界。
· 维度敏感性:逐维留一(把第 k 维置 0 后重算归一化 L1 与千分比),报出每个维度对矩阵的
平均影响 Δ_k(千分比),以及「最近的一对」在该维度被删后是否换人——直接量化「距离依赖
维度选择」。
· 量化误差:两级地板除(归一化 0..1000、千分比 1000×d/maxd)各自给出上界,并报出实测的
截断损失。
· 代理指标纪律:距离只做相对比较,禁绝对化;本脚本只报相对读数,不产出任何「相似度 %」。
距离是**代理指标(proxy)**,只做相对比较,禁绝对化。产出 out/gd_proto_14_read.json +
out/gd_proto_14_sensitivity.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, drop: int = -1) -> list:
rows, cols = len(vecs), len(vecs[0])
out = [[0] * cols for _ in range(rows)]
for k in range(cols):
if k == drop:
continue
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 permille_matrix(norm: list) -> tuple:
n = len(norm)
d = [[sum(abs(a - b) for a, b in zip(norm[i], norm[j])) for j in range(n)] for i in range(n)]
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)]
return d, dist, maxd
def bars(rows, path) -> int:
W, H, x0, y0, rh = 720, 60 + 30 * len(rows), 240, 72, 30
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 16, 28, "留一维度敏感性:删掉第 k 维后矩阵的平均变化 Δ_k(‰)", 15)
ashlib.svg_text(root, 16, 48, "条越长 = 这一维对「像不像」的影响越大;代理指标,仅相对比较", 11, "#94a3b8")
hi = max(v for _, v in rows) or 1
for i, (name, v) in enumerate(rows):
y = y0 + i * rh
ashlib.svg_text(root, 16, y + 18, name, 12)
ashlib.svg_bar(root, x0, y + 4, W - x0 - 90, rh - 12, v, hi, "#38bdf8", str(v))
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)
with open(os.path.join(OUT, "gd_proto_14_matrix.json"), encoding="utf-8") as f:
base = json.load(f)
vecs, labels, dims = feat["vectors"], feat["labels"], feat["dims"]
n, cols = len(vecs), len(dims)
norm = normalize(vecs)
d, dist, maxd = permille_matrix(norm)
off = [(i, j) for i in range(n) for j in range(i + 1, n)]
closest = min(off, key=lambda p: d[p[0]][p[1]])
sens = []
for k in range(cols):
nk = normalize(vecs, k)
_, dk, _ = permille_matrix(nk)
delta = sum(abs(dk[i][j] - dist[i][j]) for i, j in off) // len(off)
ck = min(off, key=lambda p: sum(abs(a - b) for a, b in zip(nk[p[0]], nk[p[1]])))
sens.append({"dim": dims[k], "delta": delta,
"closest": [labels[ck[0]], labels[ck[1]]],
"sameClosest": ck == closest})
sens_sorted = sorted(sens, key=lambda s: -s["delta"])
# 量化误差:两级地板除。
# ① 归一化 u = floor(1000(v−lo)/(hi−lo)),每格误差 < 1;传播到 L1 每对 ≤ 2×10 = 20。
# ② 千分比 D = floor(1000·d/maxd),每格截断掉的余数 = (1000·d mod maxd)/maxd < 1。
norm_err_bound = cols * 2
perm_err_bound = 1
rem = {(i, j): (1000 * d[i][j]) % maxd for i, j in off}
perm_trunc_num = sum(rem.values())
max_trunc_num = max(rem.values())
perm_err_from_l1 = (1000 * norm_err_bound) // maxd + 1
report = {
"id": "gd_proto_14_read", "labels": labels, "dims": dims,
"sensitivity": sens_sorted,
"mostInfluential": sens_sorted[0]["dim"], "leastInfluential": sens_sorted[-1]["dim"],
"closestPair": [labels[closest[0]], labels[closest[1]]],
"closestPermille": dist[closest[0]][closest[1]],
"closestStableUnderDrop": sum(1 for s in sens if s["sameClosest"]),
"quantization": {"l1ErrBoundPerPair": norm_err_bound, "permErrBoundPerCell": perm_err_bound,
"permErrFromL1": perm_err_from_l1,
"permTruncNumerSum": perm_trunc_num, "permTruncNumerMax": max_trunc_num,
"maxd": maxd, "pairs": len(off)},
"discipline": "距离是代理指标,只做相对比较;不产出相似度百分比。",
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "gd_proto_14_read.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
f.write("\n")
size = bars([(s["dim"], s["delta"]) for s in sens_sorted],
os.path.join(OUT, "gd_proto_14_sensitivity.svg"))
print(f"基准最近一对:{labels[closest[0]]}↔{labels[closest[1]]} = {dist[closest[0]][closest[1]]}‰")
print("\n留一维度敏感性 Δ_k(千分比,越大越关键):")
for s in sens_sorted:
mark = "(最近对不变)" if s["sameClosest"] else f"(最近对改判为 {s['closest'][0]}↔{s['closest'][1]})"
print(f" {s['dim']:<16} Δ={str(s['delta']).rjust(4)} {mark}")
print(f"\n最关键的维度={report['mostInfluential']} 最不关键={report['leastInfluential']}"
f" 最近对在 {report['closestStableUnderDrop']}/{cols} 次留一下不变")
print(f"量化误差:归一化每格 < 1(传播到 L1 每对 ≤ {norm_err_bound},再传到千分比 ≤ {perm_err_from_l1}‰)"
f";千分比每格截断余数 < 1(最大 {max_trunc_num}/{maxd},{len(off)} 对合计 {perm_trunc_num}/{maxd})")
print(f"svg={size} bytes")
print("wrote out/gd_proto_14_read.json out/gd_proto_14_sensitivity.svg")
ET.parse(os.path.join(OUT, "gd_proto_14_sensitivity.svg"))
return 0
if __name__ == "__main__":
sys.exit(main())
# snippet: 跑本节 S 层
/opt/homebrew/bin/python3 sim/gd_proto_14_read.py
基准最近一对:塔防↔自走棋 = 268‰
留一维度敏感性 Δ_k(千分比,越大越关键):
avgStepsMilli Δ= 63 (最近对不变)
decisionDepth Δ= 59 (最近对不变)
randomBits Δ= 51 (最近对改判为 塔防↔三消)
paretoPermille Δ= 46 (最近对不变)
rules Δ= 45 (最近对不变)
stateFields Δ= 44 (最近对不变)
stateBits Δ= 42 (最近对不变)
strategyBits Δ= 39 (最近对改判为 塔防↔三消)
phaseWidth Δ= 34 (最近对改判为 塔防↔三消)
entropyMilli Δ= 33 (最近对改判为 卡牌构筑↔塔防)
最关键的维度=avgStepsMilli 最不关键=entropyMilli 最近对在 6/10 次留一下不变
量化误差:归一化每格 < 1(传播到 L1 每对 ≤ 20,再传到千分比 ≤ 4‰);千分比每格截断余数 < 1(最大 6152/6496,15 对合计 45256/6496)
svg=4547 bytes
wrote out/gd_proto_14_read.json out/gd_proto_14_sensitivity.svg
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
/opt/homebrew/bin/python3 sim/gd_proto_14_read.py | out/gd_proto_14_read.json | 留一敏感性 Δ 33~63‰ |
| 同上 | out/gd_proto_14_sensitivity.svg | 4547 bytes,ET.parse 回读通过 |
| 最近一对稳健性 | 留一改判计数 | 10 次留一中 4 次改判,6 次不变 |
三节到此收束:14.1 把品类变成向量,14.2 把向量变成距离矩阵,14.3 把矩阵读成结论并划清边界。这套工具只做一件事——把「这两个品类像不像」从一句形容词,变成一串带前提的整数。它给的从来不是答案,而是一把可复现、可证伪、也知道自己局限在哪的尺子。下一章把六个变体拉回同一张回归表,看它们在全部口径上是否仍然自洽。
阅读导航:上一节:14.2 距离矩阵与可区分性 · 下一节:15.1 六品类变体的回归 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。