Session 1: Echo 之战

问一棵树:“你能把输入原封不动还给我吗?"——这是 SPR 的第一场战役。

1. 什么是 Echo Test

训练任何翻译模型之前,先问一个更简单的问题:你能不能表示"输入等于输出”?

如果连"我是我"都做不到,“我是你”(翻译)就无从谈起。

echo 就是自映射:f(x) = x。对 SPR 树而言,echo 的流程是:

句子 → 每个词过树路由 → 叶子存词的 ID → 重建时从叶子取回 → 输出原句子

BLEU 用来度量重建精度。BLEU=100 意味着原句和重建句完全一致——这就是"完美 echo"。

Phase 0 是 SameTime 专题的第一个实验(详见 WMT-003)。它用一个 DummyModel——nn.Embedding(vocab, 64) → nn.Linear(64, vocab)——7.3M 参数,在 IWSLT14 全部训练集上跑了 2 个 epoch,用 teacher forcing 做自重建,BLEU 达到 97.3。注意这是同一个 echo 任务:输入英语→输出英语,不是翻译。Session 1 的目标:让 SPR 树在没有任何训练、没有任何可学习参数的前提下,达到同等甚至更高的 echo 精度。

2. 树的哈希:循环位移 + 符号破缺

2.1 树的结构

SPR 树是一棵固定的二叉树。每个内部节点做一件事:把到达该节点的词分成左右两组,递归到叶子。

这次我们不用复杂的"训练路由权重"——我们用一个完全确定性的哈希函数来路由。同一个词每次都走同一条路,路径由词自身决定。

2.2 第一次尝试:纯循环位移

直觉:把词的 embedding 向右"滚"一格,和没滚的向量相加,就能编码"谁在左、谁在右"。

父节点 = 左子树 + torch.roll(右子树, shifts=1)

让我们用具体数字验证。假设三个词的 4 维嵌入:

"我" = [1, 2, 3, 4]
"打" = [0, 1, 0, 1]  
"你" = [5, 6, 7, 8]

“我打你”(我在左,你在右):

左(我) = [1, 2, 3, 4]
右(你) = [5,6,7,8] → roll→ [8, 5, 6, 7]
父节点 = [1+8, 2+5, 3+6, 4+7] = [9, 7, 9, 11]

“你打我”(你在左,我在右):

左(你) = [5, 6, 7, 8]
右(我) = [1,2,3,4] → roll→ [4, 1, 2, 3]
父节点 = [5+4, 6+1, 7+2, 8+3] = [9, 7, 9, 11]

撞车! 两个完全相反的句子,产生了完全相同的哈希值。这是由联盟中另一位船长发现的致命漏洞。

2.3 漏洞分析

为什么碰撞?因为 roll 只是把数字换了位置——第一维里 1+8=95+4=9,第三维里 3+6=97+2=9。加法是可交换的,纯位移不能打破这个对称性。

另一位船长的原话:“仅仅在空间上做单一方向的滚动位移,信息还是太容易在加法中发生对冲和坍缩。”

2.4 修复:符号交替破缺

在"滚"之后乘以一个交替正负号 [1, -1, 1, -1, ...]。奇偶位符号不同,加法就不再可交换:

父节点 = 左子树 + sign_alt(torch.roll(右子树, shifts))
# sign_alt(x) = x * [1, -1, 1, -1, ...]

重新算一遍:

“我打你”(修正好):

左(我) = [1, 2, 3, 4]
右(你) = [5,6,7,8] → roll→ [8,5,6,7] → sign_alt→ [8, -5, 6, -7]
父节点 = [1+8, 2-5, 3+6, 4-7] = [9, -3, 9, -3]

“你打我”(修正好):

左(你) = [5, 6, 7, 8]
右(我) = [1,2,3,4] → roll→ [4,1,2,3] → sign_alt→ [4, -1, 2, -3]
父节点 = [5+4, 6-1, 7+2, 8-3] = [9, 5, 9, 5]

分离! 第二位一个是 -3,一个是 5。解码器只需要看这一位,就能区分主宾。

2.5 自路由:词自身的演进

上述逻辑不仅用于"合并两个词",也用于"路由单个词"。SPR 自路由的核心:

每深一层,词向量做一次 roll+sign_alt,然后和原始向量做内积。
内积 > 0 → 向右;内积 ≤ 0 → 向左。
def route_chunk(chunk, depth):
    idx = 0
    current = chunk.clone()
    for dp in range(depth):
        current = torch.roll(current, shifts=dp+1) * SIGN_MASK  # 自我演进
        score = (chunk * current).sum()      # 和原始做内积
        if score > 0: idx = 2*idx + 2        # 右
        else:         idx = 2*idx + 1        # 左
    return idx - (2^depth - 1)               # 叶子号

每一层里,current 都在自我演进(roll 的偏移量逐层递增),所以 7 层的内积彼此独立——每一层都在看词的不同"侧面"。

3. 第一次挫折:单树不够

depth=14 的单棵树有 16384 片叶子。WMT14 训练集约 42K 个不同词——平均每叶 2.6 个词。

这意味着什么?当多个词共享一片叶子时,我们只能输出其中最频繁的词。假设叶子里有 {“the”, “cat”, “strategy”}——不管输入是 “cat” 还是 “strategy”,输出都是 “the”。

结果是 BLEU=62.88,独叶率 42.6%。离 97 还很远。

4. 突破:分解路由

直觉:一个词的一整条 64 维向量,能不能用 4 个独立的 16 维子向量来替代?每个子向量各自走一遍自路由,产生各自的叶子号,然后组合起来?

一个词 [64 维]
  ├─ chunk0 [0:16]  → 自路由 → leaf0 ∈ [0, 127]
  ├─ chunk1 [16:32] → 自路由 → leaf1 ∈ [0, 127]
  ├─ chunk2 [32:48] → 自路由 → leaf2 ∈ [0, 127]
  └─ chunk3 [48:64] → 自路由 → leaf3 ∈ [0, 127]

组合键 = leaf0 × 128³ + leaf1 × 128² + leaf2 × 128 + leaf3
       ∈ [0, 128⁴-1] = [0, 268,435,455]

128⁴ = 2.68 亿 —— 而词表只有 42K。42K 个词散进 2.68 亿个槽里,每个词几乎独享一个槽。

4.1 物理直觉

这不是简单的"拆分向量以求更多叶子"。这是让词的每一个侧面独立表达

一个 64 维向量可以理解为一句话:维 0-15 说的是语法功能,维 16-31 说的是语义类别,维 32-47 说的是情感色彩,维 48-63 说的是时态语态。每一组走独立的树——不是在追问"你是谁",而是在追问"你的每一个侧面各是什么"。

结果:独叶率从 42.6% 跃升到 99.7%。BLEU 从 62.88 跃升到 99.99

src: a republican strategy to counter the
hyp: a republican strategy to counter the       ← 完美重建

src: republican leaders justified their policy by
hyp: republican leaders justified their policy by  ← 完美重建

src: however, the brennan centre considers this
hyp: however, the brennan centre considers this    ← 完美重建

5. 代码解析

完整的 SPR Echo 证明只需 95 行代码。核心组件:

# 1. 词表构建
word2id = {"<pad>": 0, "<unk>": 1}        # 每词赋ID
for s in train_sents:
    for w in s:
        if w not in word2id: word2id[w] = len(word2id)

# 2. 随机 embedding(无训练)
E = torch.randn(V, d) / E.norm(dim=1)     # V×64,单位球面上

# 3. 自路由——每词通过自身的循环演进决定路径
def route_chunk(chunks, depth):
    current = chunks.clone()
    for dp in range(depth):
        current = torch.roll(current, shifts=dp+1) * [1,-1,...]
        score = (chunks * current).sum()
        idx = idx*2 + 1 + (1 if score > 0 else 0)
    return idx - (2^depth - 1)

# 4. 分解路由——K=4 片,各自独立走树,组合
for k in range(4):
    leaf_chunks[:, k] = route_chunk(E[:, k*16:(k+1)*16], depth=7)
leaf_combined = leaf_0*128³ + leaf_1*128² + leaf_2*128 + leaf_3

# 5. Echo 查表——每个组合叶存最频繁词
for wid in range(V):
    leaf_top[leaf_combined[wid]] = most_frequent_in_that_leaf

关键参数:

参数 含义
V 42,109 去重词数
d 64 嵌入维度
K 4 分解片数
depth 7 每片树的深度
叶子 128⁴ = 268M 有效组合叶子数
独叶率 99.7% 每个词几乎独占叶子

6. 三场败仗教会我们的事

回顾 Session 1 的全程,有三场关键败仗塑造了最终设计:

败仗 1:纯循环位移碰撞

torch.rollsum() 在加法中产生对称性——“我打你"和"你打我"算出同一个哈希。修复是符号交替——[1, -1, 1, -1] 零成本打破对称。

败仗 2:训练 E 导致坍缩

尝试让 embedding 可训练——所有词被凝聚损失推到同一点,BLEU 归零。冻结嵌入(不训练)才是正路。

败仗 3:残差级联的直觉陷阱

直觉说"先去掉大类特征,再切分细节”——但对随机嵌入不成立。随机嵌入没有"大类特征"可减。分解路由(拆维并行)才是正确答案。

7. Session 1 的定位

echo test 不是终点——是结构的压力测试。如果一棵树连"保存自身信息"都做不到,还谈什么"转化信息"(翻译)?

Session 1 证明了:SPR 的自路由树 + 分解路由,能在零参数、零训练的条件下,在 42K 词表上达到 99.99% 的 echo BLEU。树的结构本身有足够的表达能力。

7.1 切片路由的真正意义:从 Echo 到 Translation

“Echo 是一场容量的阅兵。Translation 将是一场转换的围猎。” —— Gemini

Session 1 里用 K=4 切片达到 2.68 亿叶子——但切片的物理意义远不只是"增加桶数"。4 个切片形成 4 个独立的特征子空间

"apple" → chunk0(词义)=42, chunk1(语法)=7, chunk2(语境)=15, chunk3(词形)=88
"apfel" → chunk0(词义)=42, chunk1(语法)=7, chunk2(语境)=15, chunk3(词形)=23
                                         ↑ 只有词形不同——局部一跳,全局稳健

如果单树,两个词在某深度差一分就永远走散(雪崩效应)。切片把变化锁在特定维度——局部敏感、全局免疫

Session 2 的翻译路径由此清晰:不是学 “apple→Apfel” 的全局映射,而是学每个 chunk 的跨语言码本——4 个可训练码本、4×128 个连续向量,从叶子号到连续特征到最近邻召回。参数量微乎其微,局部对齐能力极强。

代码:spr_echo_proof.py
联盟审查版:houming818.reviewed/spr_echo_proof.py


License: GPLv3 本文《SPR》系列采用 GPLv3 协议开源发布。