Transformer 工作原理深度解析:用 RASPy 编程语言直观模拟与实操
本教程将带你深入掌握 RASPy 框架——一个基于 Thinking Like Transformers 论文的 Python 实现,它允许你用代码直接模拟 Transformer 的计算过程。通过语言构造(输入、前馈网络、注意力机制)和一系列挑战任务,你将直观理解 Transformer 如何解析序列、进行信息交互并完成复杂运算。无论你是深度学习初学者还是资深研究者,这份教程都能帮你把抽象的核心图表转化为可运行的代码。
第一部分:将 Transformers 视为代码
我们的目标是定义一套计算形式来最小化 Transformers 的表达能力。我们将通过类比,描述每个语言构造及其在 Transformers 中的对应关系。(正式语言规范请参考本文底部论文全文链接)。
这套语言的核心单元是将一个序列转换成相同长度另一个序列的 序列操作,下文将其称为 transforms。
输入
在 Transformer 中,基本层是模型的前馈输入。这个输入通常包含原始的 token 和位置信息。

在代码中,tokens 的特征表示最简单的 transform,它直接返回经过模型的 token 序列,默认输入序列是 "hello":
tokens

假如我们要改变 transform 中的输入,可以使用 input 方法传入新值。
tokens.input([5, 2, 4, 5, 2, 2])

作为 Transformers,我们无法直接接收这些序列的位置信息。但为了模拟位置嵌入,我们可以获取位置的索引:
indices

sop = indices
sop.input("goodbye")

小提示: RASPy 默认输入是 "hello",你可以通过 .input() 方法改变序列内容。索引 indices 从 0 开始,模拟 Transformer 中常见的位置编码。
常见问题:
- 问题:
tokens和indices有什么区别?
答案:tokens表示序列中的实际符号(如字符、数字),而indices表示每个位置对应的索引(0,1,2,…)。两者都是 transform,可以独立或组合使用。
前馈网络
经过输入层后,我们来到前馈网络层。在 Transformer 中,这一步可以对序列的每一个元素独立地应用数学运算。

在代码中,我们通过在 transforms 上执行计算来表示这一步。每个序列元素都会独立参与数学运算。
tokens == "l"

结果是一个新的 transform,一旦传入新的输入,就会按照定义的计算方式执行:
model = tokens * 2 - 1 model.input([1, 2, 3, 5, 2])

该运算可以组合多个 transforms。例如,以前述的 token 和 indices 为例,可以类比 Transformer 能同时跟踪多条信息:
model = tokens - 5 + indices model.input([1, 2, 3, 5, 2])

(tokens == "l") | (indices == 1)

我们提供了一些辅助函数来简化 transforms 的编写。例如,where 提供了一个类似 if 条件判断的功能。
where((tokens == "h") | (tokens == "l"), tokens, "q")

map 允许我们定义自定义操作,比如将字符串转换为整数(用户应谨慎使用可模拟简单神经网络计算的操作):
atoi = tokens.map(lambda x: ord(x) - ord('0'))
atoi.input("31234")

函数(functions)可以轻松描述这些 transforms 的级联。例如,下面展示了同时使用 where、atoi 和加 2 的操作:
def atoi(seq=tokens):
return seq.map(lambda x: ord(x) - ord('0'))
op = (atoi(where(tokens == "-", "0", tokens)) + 2)
op.input("02-13")

小提示: where 函数类似于条件语句:如果条件为真则取第一个值,否则取第二个值。map 可以自定义简单的数学或逻辑操作,直接模拟 Transformer 中的神经元计算。
常见问题:
- 问题: 在
map中能否使用复杂计算?
答案: 理论上可以,但map对应 Transformer 前馈网络中的逐元素操作,建议只使用简单函数(如加减、比较),以保持与真正 Transformer 的等价性。
注意力筛选器
应用注意力机制时,事情开始变得有趣。这将允许序列中不同元素之间交换信息。

我们首先定义 key 和 query 的概念。Keys 和 Queries 可以直接从上面的 transforms 创建。例如,要定义一个名为 key 的 key:
key(tokens)

对于 query 也一样:
query(tokens)

标量可以作为 key 或 query 使用,它们会广播到基础序列的长度。
query(1)

我们创建筛选器来应用 key 和 query 之间的操作。这对应于一个二进制矩阵,指示每个 query 要关注哪个 key。与标准 Transformer 不同,这个注意力矩阵没有权重。
eq = (key(tokens) == query(tokens)) eq

一些例子:
选择器的匹配位置偏移 1:
offset = (key(indices) == query(indices - 1)) offset

key 早于 query 的选择器:
before = key(indices) < query(indices) before

key 晚于 query 的选择器:
after = key(indices) > query(indices) after

选择器可以通过布尔操作合并。例如,这个选择器将 before 和 eq 组合,我们通过在矩阵中包含一对键和值来展示这一点。
before & eq

小提示: 筛选器矩阵中的 true 表示 query 可以关注对应的 key。你可以组合多个条件(如 & 和 |)来构建复杂的关注模式。
使用注意力机制
给定一个注意力选择器,我们可以提供一个序列值进行聚合操作。我们通过累加那些被选择器选中的真值来完成聚合。
(请注意:在原始论文中,他们使用平均聚合操作,并展示了一个巧妙的结构,其中平均聚合能够代表总和计算。RASPy 默认使用累加来简化实现并避免碎片化。实际上,这意味着 RASPy 可能低估了所需的层数。基于平均值的模型可能需要两倍的层数)
注意聚合操作使我们能够计算诸如直方图之类的功能。
(key(tokens) == query(tokens)).value(1)

视觉上我们遵循图表结构:Query 在左边,Key 在上边,Value 在下面,输出在右边。

有些注意力机制操作甚至不需要用到输入 token。例如,要计算序列长度,我们创建一个“全选”的注意力筛选器并给它赋值。
length = (key(1) == query(1)).value(1)
length = length.name("length")
length

下面是一个更复杂的示例,逐步展示(有点像做采访一样)。
我们想计算一个序列中相邻值的和。首先我们向前截断:
WINDOW=3 s1 = (key(indices) >= query(indices - WINDOW + 1)) s1

然后我们向后截断:
s2 = (key(indices) <= query(indices)) s2

两者相交:
sel = s1 & s2 sel

最终聚合:
sum2 = sel.value(tokens) sum2.input([1,3,2,2,2])

这里还有一个计算累积求和的例子,我们引入了给 transform 命名的能力以方便调试。
def cumsum(seq=tokens):
x = (before | (key(indices) == query(indices))).value(seq)
return x.name("cumsum")
cumsum().input([3, 1, -2, 3, 1])

重要说明: RASPy 默认使用累加(sum)而不是平均,这简化了实现,但可能导致层数低估。实际 Transformer 中的线性层需注意此差异。
层
这套语言支持编译更复杂的 transforms。同时,它会通过跟踪每个运算操作来计算层数。

下面是一个 2 层 transform 的例子,第一层对应长度计算,第二层对应累积总和。
x = cumsum(length - indices) x.input([3, 2, 3, 5])

小提示: 层数对应 Transformer 中注意力层与前馈层的顺序组合。你可以通过 .layers 属性(如果提供)查看当前 transform 使用的层数。
第二部分:用 Transformers 进行编程
使用这个函数库,我们可以编写程序完成复杂任务。Gail Weiss 曾给我一个极具挑战性的问题来打破步骤限制:能否加载一个可以执行任意长度数字加法的 Transformer?
例如:给定字符串 "19492+23919",我们能否加载正确的输出?
如果你想自己尝试,我们提供了一个版本供你试验:
https://colab.research.google.com/github/srush/raspy/blob/main/Blog.ipynb
挑战一:选择一个给定的索引
加载一个在索引 i 处所有元素都有值的序列。
def index(i, seq=tokens):
x = (key(indices) == query(i)).value(seq)
return x.name("index")
index(1)

小提示: 这里使用标量 i 作为 query,可以定位到固定位置。注意 i 必须小于序列长度,否则会聚焦到空白位置。
挑战二:移位
将所有 token 向右侧移动 i 个位置。
def shift(i=1, default="_", seq=tokens):
x = (key(indices) == query(indices-i)).value(seq, default)
return x.name("shift")
shift(2)

常见问题:
- 问题: 向右移动后,左侧空出的位置怎么处理?
答案: 通过default参数指定填充值,默认是"_"。
挑战三:最小值
计算序列的最小值。(这一步开始变得困难,我们的版本使用了 2 层注意力机制。)
def minimum(seq=tokens):
sel1 = before & (key(seq) == query(seq))
sel2 = key(seq) < query(seq)
less = (sel1 | sel2).value(1)
x = (key(less) == query(0)).value(seq)
return x.name("min")
minimum()([5,3,2,5,2])

为什么需要两层? 第一层标记哪些元素小于当前 query 的元素(或等于但位于之前);第二层选择标记累积值最小的那个位置(即有多少元素小于它)。这种模式在 Transformer 中很常见。
挑战四:第一个索引
计算 token q 首次出现时的索引(2 层)。
def first(q, seq=tokens):
return minimum(where(seq == q, indices, 99))
first("l")

常见问题:
- 问题: 为什么把不匹配的索引设为 99?
答案: 因为minimum会找到所有匹配位置中的最小值,99 是一个大于任何合理索引的大数,确保不匹配的位置不会影响结果。
挑战五:右对齐
右对齐一个填充序列。例如:ralign().inputs('xyz___') = '--xyz'(2 层)
def ralign(default="-", sop=tokens):
c = (key(sop) == query("_")).value(1)
x = (key(indices + c) == query(indices)).value(sop, default)
return x.name("ralign")
ralign()("xyz__")

小提示: 这里先统计填充符 "_" 的数量 c,再通过偏移索引实现对齐。注意示例输入是 "xyz__"(两个字符填充),输出应为 "--xyz"(默认填充符 "-")。
挑战六:分割
在 token "v" 处将序列分成两部分,然后右对齐(2 层):
def split(v, i, sop=tokens):
mid = (key(sop) == query(v)).value(indices)
if i == 0:
x = ralign("0", where(indices < mid, sop, "_"))
return x
else:
x = where(indices > mid, sop, "0")
return x
split("+", 1)("xyz+zyr")

split("+", 0)("xyz+zyr")

常见问题:
- 问题: 为什么分割后右对齐使用
"0"填充?
答案: 这是为后续数字加法做准备,"0"在数值运算中代表零,不影响加法结果。
挑战七:滑动
将特殊 token "<" 替换为最靠近左侧的非 "<" 值(2 层):
def slide(match, seq=tokens):
x = cumsum(match)
y = ((key(x) == query(x + 1)) & (key(match) == query(True))).value(seq)
seq = where(match, seq, y)
return seq.name("slide")
slide(tokens != "<").input("xxxh<<

小提示: 这里的 match 是布尔 transform,标记需要保留的字符。通过累积和 cumsum 将连续 < 分组,再找到最近的非 < 值。
挑战八:加法——完整示例
你将执行两个数字的加法。下面是步骤。
add().input("683+345")
分成两部分。转换为整数。相加。
“683+345” => [0, 0, 0, 9, 12, 8]
计算进位情况。三种可能性:1 表示进位,0 表示不进位,< 表示可能进位。
[0, 0, 0, 9, 12, 8] => “00<100”
滑动进位系数
“00<100” => 001100"
完成加法
这些都只有 1 行代码。完整的系统需要 6 层注意力机制(尽管 Gail 说,如果你足够细心,可以在 5 层内完成!)。
def add(sop=tokens):
# 0) 解析并相加
x = atoi(split("+", 0, sop)) + atoi(split("+", 1, sop))
# 1) 检查进位
carry = shift(-1, "0", where(x > 9, "1", where(x == 9, "<", "0")))
# 2) 并行地将进位滑动到对应列
carries = atoi(slide(carry != "<", carry))
# 3) 加上进位
return (x + carries) % 10
add()("683+345")

683 + 345
1028
总结: 这个加法程序展示了如何用 6 层(甚至 5 层)注意力模拟带进位竖式加法,每个步骤都对应一个注意力层或前馈层。这是用 RASPy 模拟 Transformer 复杂推理能力的绝佳示例。
常见问题汇总
- 问题: RASPy 能完全等价于真实 Transformer 吗?
答案: 不完全等价。RASPy 简化了权重学习(注意力矩阵无权重)、聚合方式(默认 sum 而非 mean)等,但它能捕捉 Transformer 的核心计算模式,是理解其机制的重要工具。
- 问题:答案: 安装命令:
!pip install git+https://github.com/srush/RASPy。然后在 Python 环境中导入即可。
- 问题: 所有挑战都需要多少层?
答案: 简单操作如索引只需 1 层;复杂如最小值需 2 层;加法需要 6 层(或者优化后 5 层)。层数可通过在代码中跟踪运算获得。
下一步:深入探索
本教程让你通过 RASPy 的代码直观地理解了 Transformer 的工作方式。你学会了如何用输入、前馈网络、注意力筛选器和聚合操作构建 transform,并通过 8 个挑战任务模拟了经典的序列操作和算术运算。如果你想进一步阅读原始论文,请访问 Thinking Like Transformers 的官方链接:
英文原文: https://srush.github.io/raspy/
现在,你可以打开 Colab 亲自尝试,用自己的序列和运算来测试这个框架,真正地“像 Transformer 一样思考”。
