从公式到代码:用华为云码道实现一个最小版 Self-Attention
基于PyTorch的Self-Attention最小实现:基于 PyTorch 的 Self-Attention 学习项目 - AtomGit
深圳技术大学 - 开源代码托管,代码协作 - AtomGit
一、项目简介
最近在学习 Transformer 时,Self-Attention 是最需要真正动手理解的一部分。
公式本身并不复杂:
但真正开始写代码之后,很快就会遇到几个问题:
- Q、K、V 在代码中是怎么生成的?
- 为什么 K 要进行转置?
Q @ K^T的结果为什么是一个方阵?- Softmax 应该在哪个维度计算?
- 最终输出为什么又恢复成了和输入一样的维度?
与其只在纸上推公式,这次我直接在华为云码道 CodeArts中创建了一个小型 PyTorch 项目,从零实现最基本的 Self-Attention,并通过打印每一步的张量维度,把整个计算过程跑一遍。
项目只保留 Self-Attention 最核心的部分,不加入多头注意力、Mask、位置编码等结构,让代码尽可能简单。
项目名称:
基于PyTorch的Self-Attention最小实现项目路径:
self-attention-demo技术栈:
Python PyTorch 华为云码道 CodeArts AtomGit最终要实现的效果也很明确:
输入 X ↓ 生成 Q / K / V ↓ 计算 QKᵀ ↓ 缩放 ↓ Softmax ↓ 得到 Attention Weights ↓ 与 V 相乘 ↓ 得到 Self-Attention 输出二、创建 Self-Attention 项目
我先在 AtomGit 创建一个新的代码仓库。
项目名称填写:
基于PyTorch的Self-Attention最小实现项目路径填写:
self-attention-demo项目介绍使用:
本项目使用Python和PyTorch实现一个最小版Self-Attention,用于学习和理解Transformer中的注意力机制。项目不调用封装好的MultiheadAttention,而是从输入张量出发,手动完成Q、K、V生成、QK^T相似度计算、缩放、Softmax以及Value加权等核心步骤,并打印各阶段张量维度,直观展示Self-Attention的计算流程和矩阵维度变化。初始化时同时加入 README 和 Python.gitignore文件。
华为云官方文档中提供了码道 CodeArts 与 AtomGit 代码仓的协同方式。代码仓建立后,可以在码道中继续进行代码分析、编写和修改。
图1 创建基于 PyTorch 的 Self-Attention 学习项目
三、把需求直接交给华为云码道
代码仓准备好之后,下一步进入华为云码道。
这次我没有直接让它生成一个完整 Transformer,而是把任务限定得非常小:
只实现最基本的 Self-Attention。
这样生成出来的代码更容易阅读,也更适合逐步理解矩阵变化。
我给码道输入了下面这段需求:
请帮我完成一个最小版Self-Attention学习项目,使用Python和PyTorch实现。 具体要求: 1. 创建一个self_attention_demo.py文件。 2. 不使用torch.nn.MultiheadAttention,手动实现Self-Attention的核心计算过程。 3. 输入参数设置为: batch_size = 2 seq_len = 4 d_model = 8 4. 使用三个nn.Linear分别生成Q、K、V。 5. 按照下面的公式完成Scaled Dot-Product Attention: Q = XW_Q K = XW_K V = XW_V Attention(Q,K,V) = softmax(QK^T / sqrt(d_k))V 6. 程序运行时打印: Input X shape Q shape K shape V shape Attention Scores shape Attention Weights shape Output shape 7. 最后打印第一个样本的Attention Weights。 8. 在代码中加入简洁的中文注释,重点说明: 为什么需要Q、K、V; 为什么K需要转置; 为什么需要除以sqrt(d_k); 为什么Softmax使用dim=-1。 9. 代码尽量简洁,只实现最基本的Self-Attention,不加入Multi-Head Attention、Mask、Dropout和位置编码。 10. 完成代码后运行程序,检查矩阵维度以及程序运行结果。如果存在问题,请直接修改并重新运行。与传统的“先搜索示例,再复制代码,再逐行调整”相比,这种方式可以直接把开发目标告诉码道,然后围绕当前项目继续修改。
华为云目前将码道 CodeArts 定位为代码智能体,并提供代码仓协同、代码分析和编码修改等开发能力。网页端也可以直接用于快速开发和验证。
图2 在华为云码道中描述 Self-Attention 的实现需求
四、从输入 X 开始实现 Q、K、V
这次使用一个非常小的输入:
batch_size = 2seq_len = 4d_model = 8分别代表:
batch_size:一次输入2个样本 seq_len:每个样本包含4个Token d_model:每个Token使用8维向量表示所以输入张量 X 的维度为:
[2, 4, 8]统一写成:
其中:
B = Batch Size L = Sequence Length D = Model DimensionQ、K、V 都由 X 经过不同的线性层得到:
对应到 PyTorch 中:
self.W_q = nn.Linear(d_model, d_model, bias=False)self.W_k = nn.Linear(d_model, d_model, bias=False)self.W_v = nn.Linear(d_model, d_model, bias=False)计算时:
Q = self.W_q(x)K = self.W_k(x)V = self.W_v(x)虽然 Q、K、V 的 Shape 都是:
[2, 4, 8]但三个线性层拥有不同的参数,所以它们得到的是输入 X 在三个不同空间中的表示。
五、完整 PyTorch 实现
最后得到的核心代码如下:
import mathimport torchimport torch.nn as nnimport torch.nn.functional as Fclass SelfAttention(nn.Module): def __init__(self, d_model): super().__init__() # 将输入分别映射成 Query、Key 和 Value self.W_q = nn.Linear(d_model, d_model, bias=False) self.W_k = nn.Linear(d_model, d_model, bias=False) self.W_v = nn.Linear(d_model, d_model, bias=False) def forward(self, x): # 输入维度: # x -> [batch_size, seq_len, d_model] Q = self.W_q(x) K = self.W_k(x) V = self.W_v(x) print("Input X shape:", x.shape) print("Q shape:", Q.shape) print("K shape:", K.shape) print("V shape:", V.shape) # K原本为[B, L, D] # 转置最后两个维度后变成[B, D, L] # 这样Q和K^T才能进行矩阵乘法 scores = torch.matmul( Q, K.transpose(-2, -1) )整个程序实际上只有几十行。
但 Self-Attention 最核心的:
Q K V QKᵀ Scale Softmax Weighted Sum全部包含在里面。
图3 在码道中完成 Self-Attention 核心代码实现
六、Q、K、V 的维度为什么没有变化?
输入 X:
[2, 4, 8]经过三个线性层后:
Q:[2, 4, 8] K:[2, 4, 8] V:[2, 4, 8]这是因为我们这里设置的是:
nn.Linear(8, 8)也就是:
8维输入 ↓ 线性变换 ↓ 8维输出因此只改变每个 Token 内部的数据表示,并不会改变:
Batch Size以及:
Sequence Length所以:
经过线性映射后仍然是:
七、K 为什么必须转置?
代码中非常关键的一步是:
K.transpose(-2, -1)原来的 K:
[B,L,D]具体到当前实验:
[2,4,8]转置最后两个维度以后:
[B,D,L]也就是:
[2,8,4]这时再进行:
Q @ K.transpose(-2, -1)就变成:
[2,4,8] @ [2,8,4]忽略 Batch 维度,看最后两个矩阵:
中间两个 8 可以正常完成矩阵乘法。
所以最终得到:
对应完整 Tensor Shape:
[2,4,4]也就是:
八、为什么 Attention Scores 是 4×4?
这个4×4其实就是理解 Self-Attention 的关键。
我们的序列中一共有 4 个 Token:
Token 1 Token 2 Token 3 Token 4对于 Token 1,需要分别计算:
Token1 → Token1 Token1 → Token2 Token1 → Token3 Token1 → Token4对于 Token 2 同样需要计算:
Token2 → Token1 Token2 → Token2 Token2 → Token3 Token2 → Token4四个 Token 都要与四个 Token 进行关联计算,因此自然得到:
的 Attention Scores。
可以把它想象成:
| Query \ Key | Token1 | Token2 | Token3 | Token4 |
|---|---|---|---|---|
| Token1 | Score | Score | Score | Score |
| Token2 | Score | Score | Score | Score |
| Token3 | Score | Score | Score | Score |
| Token4 | Score | Score | Score | Score |
每一行代表一个 Query 对所有 Key 的相关程度。
九、为什么要除以 √dₖ?
公式中还有一步:
对应代码:
scores = scores / math.sqrt(K.size(-1))当前实验:
d_k = 8所以实际上就是:
如果向量维度越来越大,Q 和 K 的点积结果也容易随之变大。
而 Softmax 对数值大小非常敏感。
如果输入 Softmax 的值差距太大,结果可能很快变得非常集中。
所以在进入 Softmax 之前,将结果除以:
可以控制点积结果的数值尺度。
这也是:
Scaled Dot-Product Attention
中“Scaled”的来源。
十、Softmax 为什么使用 dim=-1?
接下来执行:
attention_weights = F.softmax( scores, dim=-1)此时 scores 的 Shape 是:
[2,4,4]其中最后一个4表示:
当前 Query 对 4 个 Key 的分数。
因此 Softmax 要沿最后一个维度计算。
例如某一行原始分数是:
[0.8, 1.2, 0.3, 2.0]Softmax 之后可能得到:
[0.16, 0.24, 0.10, 0.50]这些权重加起来约等于:
1这样就可以把它理解成:
当前Token应该把多少注意力分配给不同Token十一、最后为什么又回到 [B,L,D]?
得到 Attention Weights 后,它的 Shape 是:
[B,L,L]也就是:
[2,4,4]而 V 是:
[B,L,D]也就是:
[2,4,8]接下来:
output = torch.matmul( attention_weights, V)矩阵关系就是:
[2,4,4] @ [2,4,8]最终:
[2,4,8]所以输出恢复到了:
和最初输入 X 的维度完全相同。
区别在于:
原来的每个 Token 主要包含自身的表示。
而经过 Attention 以后,每个 Token 已经根据不同的 Attention Weight 融合了其他 Token 的信息。
十二、在码道中直接运行程序
代码完成以后,我继续让码道运行:
运行self_attention_demo.py,检查程序是否正常执行,并把每一步的Tensor Shape打印出来。如果发现矩阵维度错误,直接定位并修改。程序正常运行后可以看到:
Input X shape: torch.Size([2, 4, 8]) Q shape: torch.Size([2, 4, 8]) K shape: torch.Size([2, 4, 8]) V shape: torch.Size([2, 4, 8]) Attention Scores shape: torch.Size([2, 4, 4]) Attention Weights shape: torch.Size([2, 4, 4]) Output shape: torch.Size([2, 4, 8])整个维度变化正好对应:
X [2,4,8] ↓ Q / K / V [2,4,8] ↓ QKᵀ [2,4,4] ↓ Softmax [2,4,4] ↓ Attention × V [2,4,8] ↓ Output [2,4,8]这也是我觉得实际运行一遍特别有价值的地方。
只看公式时:
还是比较抽象。
一旦把每一步 Tensor Shape 打出来,整个计算过程马上就清楚很多。
图4 在码道中运行 Self-Attention 并查看各阶段 Tensor Shape
这是整篇文章最重要的一张结果图。
十三、看看 Attention Weights
程序最后还有:
print(attention_weights[0])由于固定了:
torch.manual_seed(42)在当前代码中可以得到类似下面的结果:
tensor([ [0.4007, 0.1787, 0.1869, 0.2338], [0.2372, 0.2397, 0.2912, 0.2320], [0.2026, 0.2724, 0.3226, 0.2025], [0.0956, 0.2436, 0.4014, 0.2594] ])可以观察第一行:
0.4007 + 0.1787 + 0.1869 + 0.2338 ≈ 1它表示第一个 Token 对 4 个 Token 分配的注意力权重。
第二行则表示第二个 Token 对整个序列分配的权重。
因为现在只是随机初始化的输入和线性层,并没有进行训练,所以不需要分析这些具体数值代表什么语义。
但它能够很好地帮助理解:
Attention 最终确实是在为不同 Token 分配权重。
十四、继续让码道解释当前代码
程序能运行之后,我又继续在当前项目里问码道:
请结合当前self_attention_demo.py中的实际代码,从Tensor维度变化的角度解释一次Self-Attention。 按照下面的顺序解释: X:[B,L,D] Q/K/V:[B,L,D] K.transpose(-2,-1):[B,D,L] Q @ K^T:[B,L,L] Attention Weights:[B,L,L] Output:[B,L,D] 要求结合当前代码中的变量进行说明,并解释每一次矩阵乘法为什么合法。这一步对我理解代码帮助很大。
因为问题不再是泛泛地问:
Self-Attention 是什么?
而是直接基于当前已经写出来的代码继续追问:
为什么这一行的 Shape 是这样?
例如还可以继续问:
为什么K需要transpose(-2,-1),而不是transpose(0,1)?或者:
为什么Softmax使用dim=-1?甚至可以直接让码道检查:
不要修改程序功能,只检查当前代码所有Tensor Shape和矩阵乘法是否正确。这种围绕当前代码不断追问、修改、再运行的方式,比单独复制一段现成实现更容易理解其中的逻辑。
图5 使用码道进一步分析 Self-Attention 中的矩阵维度变化
十五、顺手做一个小实验
理解完基本流程后,我又把:
seq_len = 4改成:
seq_len = 6其他参数保持不变:
batch_size = 2d_model = 8此时输入变成:
[2,6,8]Q、K、V:
[2,6,8]Attention Scores:
[2,6,6]最终 Output:
[2,6,8]也就是说,当:
时,Attention Matrix 就会变成:
这让我更加直观地看到:
Self-Attention 中注意力矩阵的大小与序列长度直接相关。
如果 Sequence Length 为:
Attention Scores 就是:
这也是后续学习长序列 Attention 时需要继续关注的问题。
十六、这次用码道开发后,我真正弄清楚了什么?
这次项目非常小,没有实现完整 Transformer。
甚至核心代码只有几十行。
但正因为功能足够小,整个 Self-Attention 的计算路径反而变得非常直观。
第一步:
X ↓ Q、K、V三者来自相同输入,但经过不同的线性投影。
第二步:
Q @ Kᵀ计算 Token 与 Token 之间的关系。
维度:
[B,L,D] @ [B,D,L]得到:
[B,L,L]第三步:
Scale + Softmax把相关性分数变成注意力权重。
第四步:
Attention Weights @ V根据不同权重重新组合 Value。
最后:
[B,L,L] @ [B,L,D]重新得到:
[B,L,D]到这里再回头看公式:
每一个符号就都可以对应到一行真实的 PyTorch 代码。
十七、使用码道开发这个小项目的体验
这个项目本身并不复杂,但它很适合用来体验一种新的学习和开发方式。
以前遇到不熟悉的算法,我通常会经历:
搜索教程 ↓ 寻找代码 ↓ 复制代码 ↓ 运行 ↓ 报错 ↓ 继续搜索这一次是在项目里直接描述自己的目标:
我要实现什么 ↓ 码道生成代码 ↓ 直接运行 ↓ 查看结果 ↓ 结合当前代码继续追问 ↓ 修改和验证尤其是在 Tensor Shape、矩阵乘法和代码解释这类问题上,可以一直围绕当前代码继续对话,而不需要在多个页面之间来回切换。
华为云官方文档也提供了码道与 AtomGit 的代码仓协同流程,支持围绕已有代码仓进行读取分析、代码修改以及后续研发协作。
对我来说,这次最有价值的地方并不是“少写了几十行代码”,而是把:
公式 → 代码 → 运行 → 结果 → 解释
真正连在了一起。
十八、总结
这次使用华为云码道完成了一个最小版本的 Self-Attention。
项目没有调用:
torch.nn.MultiheadAttention而是手动实现了:
输入X ↓ Q / K / V ↓ QKᵀ ↓ Scale ↓ Softmax ↓ Attention Weights ↓ 与V进行加权 ↓ Output最终最重要的维度变化是:
X [B,L,D] ↓ Q / K / V [B,L,D] ↓ QKᵀ [B,L,L] ↓ Attention Weights [B,L,L] ↓ Output [B,L,D]对于刚开始学习 Transformer 的我来说,这种几十行代码的小实验,比直接阅读一个完整 Transformer 工程更容易理解 Self-Attention 的核心。
接下来可以在这个项目的基础上继续加入:
Multi-Head Attention Mask Position Encoding再一步一步把完整的 Transformer 结构搭出来。