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=9 和 5+4=9,第三维里 3+6=9 和 7+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. 通向上半场:词级成功 ≠ 句级成功
6.1 词级哈希的成功与边界
词级 echo 验证的是:
42K 个词 → 268M 个组合桶 → 99.7% 独叶 → BLEU=99.99
它没有验证的是:
句子 → 编码器(roll+merge) → 根哈希 → 解码器(反向拆分) → 叶子词
树的循环位移、递归二分、符号破缺——这些 SPR 真正的核心机制,在词级测试里一个都没被调用。词级测试只验证了"4 个独立哈希函数能把 42K 个词塞进 268M 个槽里不碰撞"。
6.2 句级 Echo 试水——根哈希信息瓶颈
最初的句级方案:把整句话所有词通过递归树 merge 成一个 128 维根哈希,再用 W_split decoder 反向拆解。
结果:BLEU=0,token 准确率停在 17% 不动。
原因:信息瓶颈。128 维连续向量(512 字节)在没有 Attention 的动态支持下,想通过浅层 MLP 并行寻址榨出整句话的离散信息——香农信息熵根本不够装。单根静态哈希是一颗"死弹",它装不下句子的流体语义。
7. 下半场:固定拓扑模板——语法作为确定性状态机
7.1 转折点:语法是有限卡槽
关键洞察:人类语言的语法规则从来不是无限连续的。 在拓扑上,它就是极其有限的、死死固定住的几种几何卡槽。一句话进来,要么符合卡槽 A,要么符合卡槽 B——如果都不符合就是病句。它根本不需要用深度神经网络去"猜"概率。
我们直接写出 4 棵固定死的语法拓扑模板,不训练、不算距离、不需要句法标注:
TEMPLATES = {
'Left_Heavy': lambda n, d: 1, # 一直从左劈 1 个词
'Right_Heavy': lambda n, d: max(1, n-1), # 一直从右劈 1 个词
'Balanced': lambda n, d: max(1, n // 2), # 对半劈
'Spec_Head': lambda n, d: 3 if n >= 6 else n // 2, # 主语-谓语分界
}
每一棵树是确定性的:给 T 个词,路径是固定的 L/R 序列。不需要 syntactic_distances 动态算距离,不需要 argmax 找切分点。
7.2 “说不通就换另一个”——几何共振选择
对于一句输入的句子,4 棵树都试着走一遍。哪棵树的 root_hash 模长最大,就选哪棵。
物理原理:当句子完美符合一棵树的结构时,向量在层层 roll + SIGN_MASK 的自底向上合并中发生几何共振(模长最大);而错误的语法组合会导致向量方向相消,发生几何对冲(模长坍塌)。
best_score_val = -1e9; best_root = None
for tname in TEMPLATES:
root = compute_root_hash(ids, tname) # 所有操作可微,梯度流向 E
score_val = root.norm().item() # 纯标量比较,解耦落选者的计算图
if score_val > best_score_val:
best_score_val, best_root = score_val, root # 只保留胜者
关键设计:root.norm() 的梯度保留(去掉了之前的 torch.no_grad()),让词表 E 可以在反向传播中主动调整——让它认得的词在正确的模板下爆出更高的 norm,在错误的模板下发生对冲坍塌。落选的模板用 .item() 解耦,不会在内存中挂载多余计算图。
7.3 解码器:Per-Token 叶子 + GRU + 计划采样
句子 "republican leaders justified their"
│
├─ per-token 叶子: E["republican"] + pos[0] → leaf_0
│ E["leaders"] + pos[1] → leaf_1
│ E["justified"] + pos[2] → leaf_2
│ E["their"] + pos[3] → leaf_3
│
├─ 句级模板树: 4 棵死树 → argmax root_hash (句法结构签名)
│
└─ GRU 解码:
t=0: leaf_0 + root_context → h_0 → "republican"
t=1: leaf_1 + root_context + prev(自己猜的) → h_1 → "leaders"
t=2: leaf_2 + root_context + prev → h_2 → "justified"
t=3: ...
“per-token” 只描述输入编码方式(每个词独立算 leaf 向量),解码是句级的——GRU 隐态跨 token 传递信息、root_hash 提供全局上下文。同一个词在不同句子里会因 GRU 隐态不同而输出不同结果——这是真正的句级重建。
计划采样(Scheduled Sampling):训练中 p_teacher 从 1.0(100% 吃真题)线性退火到 0.2(80% 吃自己猜的),强迫 GRU 在野生环境中学会自己走。关键隔离:
with torch.no_grad():
pred_id = logits.argmax(dim=-1)
prev_pred_emb = E.weight[pred_id].detach() # .detach(): 自预测不污染词表梯度
7.4 实验结果
| 指标 | 数值 |
|---|---|
| BLEU-4 | 60.5 |
| Token 准确率 | 79.1% |
| 高频词 (freq>50) 准确率 | 99.5% |
| 暴露偏差 (位置 0→9) | 84%→77%(仅掉 7%) |
| 全句长 BLEU 稳定性 | 50-75%(所有长度,无坍塌) |
4 棵模板的实际选择分布(argmax root_hash norm):
| 模板 | 选择率 |
|---|---|
| Left_Heavy | 57% |
| Right_Heavy | 36% |
| Balanced | 7% |
| Spec_Head | 0%(仅对长句有意义) |
根哈希唯一性:2000 句不同句子的 root_hash 碰撞率为 0——句法距离树编码的句子签名完全唯一。
8. 为什么 79% 不是 99.99%
词级 echo 达到 BLEU=99.99 是因为纯查表——42K 词散进 268M 桶,几乎零碰撞。句级 echo 的 79% 是真正重建——同一个词在不同句子里会因为 GRU 上下文不同而走不同的解码路径。差异来自:
- 稀词(freq≤5):仅 39.4% 准确率——训练中见得少
- 0-shot 词(val 独有、训练从未出现):0% 准确率
- 信息压缩损失:整句结构通过 128 维 root_hash + GRU 隐态编码,必然有损失
这套指标的物理意义和词级查表完全不同——这是真正的句级重建质量。
9. Session 1 结束,翻译桥接已就绪
Session 1 证明了两件事:
1. 固定语法模板是生效的。 4 棵写死的树 + argmax norm + 计划采样,在 20K 训练句上跑出了 BLEU=60.5。不需要 Attention、不需要逐步概率生成、不需要几亿参数。
2. 根哈希是可靠的句子签名。 不同句子的 root_hash 碰撞率为 0,固定模板是跨语言通用的(SVO 在德语和英语中共享相同的树拓扑)。这为 Session 2 的翻译架好了桥:
[德语句子] → E_de + 4棵死树 → root_hash_de
│
W_bridge (Linear)
│
▼
[英语句子] ← GRU解码 ← root_hash_en ←┘ (同一棵树的路径)
下一场战役:Bridge 训练——一个极轻量的 Linear(d→d) 层,将德语根签名映射到英语特征流形。解码端直接用 S1 训好的 GRU 自回归出英文。
句级代码:spr_s1_eval.py
词级代码:spr_echo_proof.py
碰撞分析:spr_collision_analysis.py
固定模板:spr_fixed_templates.py
License: GPLv3 本文《SPR》系列采用 GPLv3 协议开源发布。