14.3 距离的读法与边界

把 6×6 距离矩阵读出来,并老实交代三条边界:距离依赖维度选择(留一维度敏感性实测 Δ 在 33~63‰,最近一对在 4/10 次留一下改判)、整数地板除的量化误差上界(归一化传播到 L1 每对 ≤20、千分比每格 <1),以及「距离只是代理指标、只做相对比较、禁绝对化」,并给出与风格距离矩阵的分工。

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)。它量的不是「品类像不像」,而是「在这十个维度上,两个品类的归一化读数差多少」。这两件事不等价,所以有三条纪律:

  1. 只做相对比较:能说「A 比 B 离 C 近」,不能说「A 与 C 的相似度是 X%」。
  2. 禁绝对化:268‰ 不是「相似度 73.2%」,也不是「差 26.8%」。它只是一个排在 15 对里第 1 位的距离读数。
  3. 结论要带条件:任何读数都要带上前提——「在本特征集下」「在整数 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.pyout/gd_proto_14_read.json留一敏感性 Δ 33~63‰
同上out/gd_proto_14_sensitivity.svg4547 bytes,ET.parse 回读通过
最近一对稳健性留一改判计数10 次留一中 4 次改判,6 次不变

三节到此收束:14.1 把品类变成向量,14.2 把向量变成距离矩阵,14.3 把矩阵读成结论并划清边界。这套工具只做一件事——把「这两个品类像不像」从一句形容词,变成一串带前提的整数。它给的从来不是答案,而是一把可复现、可证伪、也知道自己局限在哪的尺子。下一章把六个变体拉回同一张回归表,看它们在全部口径上是否仍然自洽。

阅读导航:上一节:14.2 距离矩阵与可区分性 · 下一节:15.1 六品类变体的回归 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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