02_transformer_architecture_self_attention

5126 字
26 分钟
02_transformer_architecture_self_attention

我们在 0809 节中介绍过了 attention 的计算与 Transformer 的架构。这节,让我们站在当今迅速发展的 GPT 系列模型的基础上,重新回顾关于 Transformer 与 Self-attention 的一切。

本节我们首先整体介绍 Transformer 的架构,然后介绍 encoder 部分。

import torch
from torch import nn
import transformers
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib as mpl
mpl.rcParams['figure.dpi'] = 200
print(torch.__version__) # 2.6.0+cu124
print(transformers.__version__) # 5.9.0

1. summary#

  • attention mechanism
  • encoder vs. decoder
    • seq2seq
      • seq of tokens -> seq of tokens
    • tasks
      • machine translation
    • encoder
      • seq of tokens -> seq of embedding vectors (hidden state/context)
    • decoder
      • encoder’s hidden state -> seq of tokens
        • iteratively generate
          • until EOS (end of seq) or reach max length limit
        • one token at a time

简单来说,encoder 的流程是:

输入一串 token → 输出一串 embedding 向量(也叫 hidden state / context)
  • encoder 负责 理解输入:把输入的文本(token 序列)转换成一串稠密的向量表示(embedding),这些向量编码了输入文本的语义信息;
  • 这些向量也叫 hidden state(隐状态) 或 context(上下文向量)。

decoder 的流程则是:

利用编码器输出的隐状态 → 逐步生成输出序列,一次生成一个 token,直到遇到 EOS 或达到最大长度
  • decoder 负责 生成输出:利用编码器已经理解好的语义信息,逐个词地生成目标文本。
  • EOS(End of Sequence) 是序列结束标记。
  • 它是自回归(autoregressive) 的:每生成一个词,就把这个词作为下一步的输入,再生成下一个词,像”串珠子”一样。

Transformer 的架构示意图:

Encoder 与 Decoder:

  • encoder only: seq of text -> rich representation (bidirectional attention)
    • task
      • text classification
      • NER
    • models
      • BERT
      • RoBERTa
      • DistilBERT
    • 完形填空(bidirectional)
      • representation of a given token depends both on
        • left (before the token)
        • right (after the token)
  • decoder only (causal or autoregressive attention)
    • GPT
    • 词语接龙
    • representation of a given token depends only on the left context
  • encoder-decoder
    • tasks
      • machine translation
      • summarization
    • models
      • T5
      • BART

我们上面是列举了 Transformer 的三种变体:

1.1 Encoder-only#

输入文本 → 丰富的语义表示(双向注意力)
  • 注意力方式:双向,每个词在计算表示时,可以看到它 左边和右边 的所有词;
  • 典型任务:
    • 文本分类
    • NER(命名实体识别)
  • 代表模型:BERT, RoBERTa,DistilBERT

形象来说,encoder-only 的任务像是“完形填空”。比如句子 “I ____ you”,BERT 要预测 ____ 是什么词,它就会同时看:

  • 左边的词 I
  • 右边的词 you

两边都看,所以叫双向,这样能更好地理解整个句子的语义。

1.2 Decoder-only#

注意力只依赖于左边的上下文(因果注意力)
  • 注意力方式:因果 / 自回归(causal / autoregressive),每个词在生成时,只能看到它左边(已经生成的)词,看不到右边的(因为右边还没生成)。
  • 代表模型:GPT 系列

形象来说,decoder-only 的任务像是“词语接龙”,比如 GPT 要生成下一个词:“今天天气真__”.

它只能根据前面已经写出的 今天天气真 来预测下一个词是 还是 不错。它看不到后面还没写出来的词,所以叫单向的因果注意力。

1.3 Encoder-Decoder#

这就是我们上一段看到的经典 seq2seq 结构,最完整的 Transformer.

  • 工作方式:Encoder 先用双向注意力把输入理解好 → Decoder 再用单向注意力逐步生成输出。
  • 典型任务:
    • 机器翻译(英→中)
    • 文本摘要(长文→短摘要)
  • 代表模型:T5、BART

2. encoder (encoder layer stack)#

  • encoder layer: encoding the contextual information (CNN)
    • input: seq of embeddings
      • multi-head self attention
      • FFN (fully-connected)
    • output:
      • same shape as input
    • contextual information (contextualized embeddings)
      • apple: company-like or fruit-like ?
        • keynote
        • phone
      • files
        • time flies like an arrowL soars
        • fruit likes a banana: insect
  • skip connection (residual connection) & layer normalization
    • 高效训练深度神经网络的技巧。

Transformer 的 Encoder 并不是一个单独的大模块,而是由 N 层相同的 Encoder Layer 堆叠 而成,比如我们之前介绍的 BERT-base 模型就有 12 个 Transformer 层。

每一层 Encoder Layer 的结构如下:

输入:一串 embedding 向量
├─ ❶ Multi-Head Self-Attention(多头自注意力)
├─ ❷ Skip Connection + Layer Normalization(残差连接 + 层归一化)
├─ ❸ Feed-Forward Network(前馈神经网络 / FC 全连接层)
├─ ❹ Skip Connection + Layer Normalization
输出:与输入形状相同的向量(但包含了上下文信息)

它的特点是 输出形状与输入形状相同,所以可以一层接一层地堆叠下去。


关于上下文信息(contextualized embeddings)部分,我们上面举了两个例子:

apple 是"苹果公司"还是"水果"?看周围词:
- keynote, phone → 像公司
- 如果是 eat, red → 像水果

可以看到,同一个词 apple,在不同句子中含义不同。Self-Attention 的作用就是:让 apple 去看它周围的词,根据上下文来确定它的语义。所以输出的向量不再是固定的词向量,而是融入了上下文信息的动态向量,这就是 contextualized embeddings(上下文感知的词向量)。

在另一个例子中,我们引用了一句经典的 NLP 例子:

Time flies like an arrow; fruit flies like a banana.

“时光如箭”,“果蝇喜欢香蕉”。同样,同一个词 flies,在两句中含义完全不同。Self-Attention 通过看周围的词(Time vs Fruit),就能正确区分。


残差连接(Skip Connection)与层归一化(Layer Normalization)是两个高效训练深度神经网络的技巧,它们的具体作用是:

技巧作用通俗理解
残差连接让梯度能直接”跳过”某一层反向传播像给网络加了”快捷通道”,防止层数太深导致梯度消失/爆炸
层归一化把每层的输出拉回到标准范围内让训练更稳定,收敛更快

2.1 multi-head self-attention layer#

2.1.1 self-attention#

  • each token
    • 不是 fixed embeddings
    • 而是 weighted average of each embedding of the whole input sequence
  • a seq of token embedding: x1,x2,...,xnx_1, x_2, ..., x_n, 经过 self-attention 得到 a seq of updated embeddings: x1,x2,...,xnx_1^{'}, x_2^{'}, ..., x_n^{'}.
xi=j=1nwjixjx_i^{'} = \sum_{j=1}^n w_{ji}x_j
  • wjiw_{ji}
    • attention weights
    • jwji=1\sum_j w_{ji}=1
    • wRn×nw\in \mathcal{R}^{n\times n}

在 Self-Attention 之前,词向量(如 Word2Vec、GloVe)是固定的——“apple”永远只有一个向量,无论它在什么句子中。

但在 Self-Attention 中,每个词的新表示 = 句子中所有词的加权平均

也就是说,apple 的新向量会融合句子里其他词的信息。如果句子里有 keynote、phone,那 apple 会偏向”公司”的含义;如果有 eat、red,则偏向”水果”的含义。


关于上面的数学公式,它的意思是:

  • 我们输入词向量序列 x1,x2,...,xnx_1, x_2, ..., x_n
  • 经过 self-attention 后得到 x1,x2,...,xnx_1^{'}, x_2^{'}, ..., x_n^{'}

我们可以用一个句子来理解。假设句子是 “I love you”,有 3 个词:

  • x1,x2,x3x_1,x_2,x_3 :输入的词向量,即 “I”, “love” 和 “you” 的原始向量;
  • x1,x2,x3x_1^{'}, x_2^{'}, x_3^{'} :更新后的词向量,即 融入了上下文 后的新向量;
  • wjiw_{ji} :这是 attention weight,表示词 jj 对词 ii 的“重要性”。

我们以 x2x_2^{'} (“love” 的新向量)为例:

x2=w12x1+w22x2+w32x3x_2^{'} = w_{12}x_1 + w_{22}x_2 + w_{32}x_3

展开来就是:

xlove=wIlovexI+wlovelovexlove+wyoulovexyoux_{love}^{'} = w_{I→love}·x_I + w_{love→love}·x_{love} + w_{you→love}·x_{you}

也就是说:“love” 的新向量 = “I”的信息 × 权重 + “love”自身的信息 × 权重 + “you”的信息 × 权重

如果权重显示 “I” 和 “you” 与 “love” 关系很紧密(比如 w12=0.4,w32=0.4w_{12}=0.4, w_{32}=0.4),那么 xlovex_{love}^{'} 就会更多地融入”I”和”you”的信息。


关于权重 wjiw_{ji} 的性质:

性质含义
wjiw_{ji}jj 对词 ii 的注意力权重
jwji=1\sum_j w_{ji}=1所有词对词 ii 的权重之和为 1(像概率一样)
wRn×nw \in \mathcal{R}^{n \times n}全部权重构成一个 n×nn \times n 的矩阵

这个 n×nn \times n 的权重矩阵可以直观理解为:

I love you
I [ w₁₁ w₁₂ w₁₃ ] ← "I" 看各个词
love [ w₂₁ w₂₂ w₂₃ ] ← "love" 看各个词
you [ w₃₁ w₃₂ w₃₃ ] ← "you" 看各个词

每一列是”这个词关注其他词的程度”,且每列之和 = 1

比如第 2 列 [w12,w22,w32]T[w_{12}, w_{22}, w_{32}]^T 就是”love”对其他 3 个词的注意力分布。

2.1.2 scaled dor-product attention#

Attention(Q,K,V)=softmax(QKTdk)VAttention(Q,K,V)=softmax(\frac{QK^T}{\sqrt{d_k}})V
  • Project each token embedding into 3 vectors called Query, Key and Value.
  • Compute attention scores.
    • dot-product (Query, Key) -> attention scores;
    • a seq with nn input tokens there is a corresponding Rn×n\mathcal{R}^{n\times n} matrix of attention scores.
  • Compute attention weights from attention scores: wjiw_{ji}
    • Dot products 的结果可能是任意大的数,会让整个训练过程非常不稳定;
    • 将 attention scores 乘以一个 scaling factor
    • 然后 softmax 归一化:jwji=1\sum_j w_{ji}=1
  • update the final embedding of the token (Value)
    • xi=j=1nwjivjx_i^{'}=\sum_{j=1}^n w_{ji}v_j

如同我们在 09 节中介绍过的那样,Self-attention 中权重 wjiw_{ji} 的计算分为清晰的 3 步:

每个 token 的向量
第一步:投影出 3 个向量 → Query / Key / Value
第二步:Query 与 Key 做点积 → attention scores → scaling → softmax → attention weights
第三步:用 attention weights 对 Value 加权求和 → 得到最终输出

下面逐一展开。


① Query、Key、Value(查询、键、值)#

Project each token embedding into 3 vectors called Query, Key and Value.

对于每个输入的词向量 xix_i,我们通过三个不同的权重矩阵 WQ,WK,WVW^Q, W^K, W^V,把它分别投影成三个向量:

qi=WQxi(Query)q_i = W^Q x_i \quad \text{(Query)}ki=WKxi(Key)k_i = W^K x_i \quad \text{(Key)}vi=WVxi(Value)v_i = W^V x_i \quad \text{(Value)}

为什么要分成三个角色? 用一个生活类比:

角色类比作用
Query(查询)🧐 你(读者)在图书馆搜索一本书当前词”想找什么信息”
Key(键)📚 每本书的书名标签其他词”有什么信息可以给”
Value(值)📖 书的内容本身其他词”实际提供的信息内容”

以句子 “I love you” 为例,当处理 love 这个词时:

  • loveQuery 负责问:“谁跟我有关系?”
  • IyouKey 负责回答:“我在这里,我叫’I’ / ‘you’”
  • Query 和 Key 的匹配程度决定了 attention 权重
  • 最后用权重去加权 Value(实际信息内容),得到 love 的新向量

② 计算 Attention Scores → Attention Weights#

dot-product (Query, Key) → attention scores

句子有 nn 个词,我们会得到一个 n×nn \times n 的 attention scores 矩阵

对于词 jj 对词 ii 的得分(词 ii 查询词 jj):

scoreji=qjki\text{score}_{ji} = q_j \cdot k_i

点积越大 → Query 和 Key 越匹配 → 词 jj 越应该关注词 ii

为什么要除以 scaling factor?#

Dot products 的结果可能是任意大的数,会让整个训练过程非常不稳定

如果向量维度 dkd_k 很大,点积的结果会非常大(因为很多数相加)。极端数值经过 softmax 后,会让大的更大、小的几乎消失,梯度变得非常小,模型学不动。

所以除以 dk\sqrt{d_k}(scaling factor),把数值拉回合理范围:

scaled scoreji=qjkidk\text{scaled score}_{ji} = \frac{q_j \cdot k_i}{\sqrt{d_k}}
然后 softmax 归一化#
wji=exp(scaled scoreji)t=1nexp(scaled scorejt)w_{ji} = \frac{\exp(\text{scaled score}_{ji})}{\sum_{t=1}^n \exp(\text{scaled score}_{jt})}

经过 softmax 后,jwji=1\sum_j w_{ji} = 1,变成一个概率分布——这就是我们之前公式里的 attention weights


③ 用权重加权 Value,更新词向量#

xi=j=1nwjivjx_i^{'} = \sum_{j=1}^n w_{ji} v_j

最后一步,用刚刚算出的权重 wjiw_{ji}Value 向量 做加权求和:

xlove=wIlovevI+wlovelovevlove+wyoulovevyoux_{love}^{'} = w_{I→love}·v_I + w_{love→love}·v_{love} + w_{you→love}·v_{you}

注意这里用的是 vjv_j(Value) 而不是原来的 xjx_j。这是因为 Value 是经过投影的”信息内容”,比原始向量更适合参与加权。


完整流程一图流#

输入: x₁ (I) x₂ (love) x₃ (you)
│ │ │
─────┴──────────┴───────────┴────
│ W^Q W^K W^V │ ← 三个投影矩阵
─────┬──────────┬───────────┬────
│ │ │
v v v
qᵢ kᵢ vᵢ
Step 1: q₂ · k₁ q₂ · k₂ q₂ · k₃ ← Query 与所有 Key 做点积
Step 2: [s₁, s₂, s₃] ÷ √dₖ → softmax → [w₁, w₂, w₃] ← scaling + softmax
Step 3: x₂' = w₁·v₁ + w₂·v₂ + w₃·v₃ ← 加权求和 Value

2.1.3 visualization analysis#

from transformers import AutoTokenizer
from bertviz.transformers_neuron_view import BertModel
from bertviz.neuron_view import show
model_ckpt = 'bert-base-uncased'
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
model = BertModel.from_pretrained(model_ckpt)
model

输出为:

BertModel(
(embeddings): BertEmbeddings(
(word_embeddings): Embedding(30522, 768, padding_idx=0)
(position_embeddings): Embedding(512, 768)
(token_type_embeddings): Embedding(2, 768)
(LayerNorm): BertLayerNorm()
(dropout): Dropout(p=0.1, inplace=False)
)
(encoder): BertEncoder(
(layer): ModuleList(
(0-11): 12 x BertLayer(
(attention): BertAttention(
(self): BertSelfAttention(
(query): Linear(in_features=768, out_features=768, bias=True)
(key): Linear(in_features=768, out_features=768, bias=True)
(value): Linear(in_features=768, out_features=768, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
)
(output): BertSelfOutput(
(dense): Linear(in_features=768, out_features=768, bias=True)
(LayerNorm): BertLayerNorm()
(dropout): Dropout(p=0.1, inplace=False)
)
)
(intermediate): BertIntermediate(
(dense): Linear(in_features=768, out_features=3072, bias=True)
)
(output): BertOutput(
(dense): Linear(in_features=3072, out_features=768, bias=True)
(LayerNorm): BertLayerNorm()
(dropout): Dropout(p=0.1, inplace=False)
)
)
)
)
(pooler): BertPooler(
(dense): Linear(in_features=768, out_features=768, bias=True)
(activation): Tanh()
)
)

这是我们之前介绍过许多次的经典 BERT 模型架构。

sample_text = 'time flies like an arrow'
show(
model,
model_type='bert',
tokenizer=tokenizer,
sentence_a=sample_text,
display_mode='light',
layer=0,
head=8
)

这里输出的是一个可交互的页面,让我们可以直观地看到每个词关注了哪些词。我们来大致解释一下 show() 里面的参数:

  • model :加载的 BERT 模型,用来前向传播计算 attention 权重;
  • model_type='bert' :告诉 BertViz 你用的是哪种模型架构。因为 BertViz 支持多种模型(bert、gpt2、xlnet 等),每种模型的内部结构略有不同,需要指定类型才能正确解析 attention 矩阵;
  • tokenizer :分词器,用于把输入的句子转成 token IDs,同时知道每个 token 对应的文本是什么,方便在可视化中显示标签;
  • sentence_a :要可视化的句子。sentence_a 这个命名来自 BERT 的预训练任务——它本来可以接收两个句子(比如 NSP 任务),这里只需要一个句子所以只用 sentence_a
  • display_mode :可视化界面的主题风格。'light' 是浅色背景,还有 'dark' 深色模式可选。
  • layer=0 :选择哪一层的 attention 来可视化。BERT-base 有 12 层(0~11),layer=0 就是第 1 层(最靠近输入的那一层)。不同层关注的模式不同:
    • 浅层(layer 0~3):更多关注语法关系、相邻词
    • 中层(layer 4~7):开始捕捉一定的语义
    • 深层(layer 8~11):关注高层次的语义关联
  • head :选择第几个注意力头来可视化。Multi-Head Attention 有多个头(BERT-base 有 12 个头,编号 0~11),每个头关注不同的模式。比方说:
head 0 → 关注下一个词
head 5 → 关注句法依赖关系(如动词→名词)
head 8 → 关注句法关系(可能看到 "time" 关注 "flies" 的修饰关系)

不过实际上,选择 layerhead 只影响一开始展示的层和头。你可以在可交互页面的上端选择自由查看每一层和每个头。

2.1.4 computation of self-attention#

model_inputs = tokenizer(sample_text, return_tensors='pt', add_special_tokens=False)
model_inputs

输出为:

{'input_ids': tensor([[ 2051, 10029, 2066, 2019, 8612]]), 'token_type_ids': tensor([[0, 0, 0, 0, 0]]), 'attention_mask': tensor([[1, 1, 1, 1, 1]])}

注意这里我们没有加入 [CLS][SEP] 特殊 token.

from torch import nn
from transformers import AutoConfig
config = AutoConfig.from_pretrained(model_ckpt)
config

输出为:

BertConfig {
"add_cross_attention": false,
"architectures": [
"BertForMaskedLM"
],
"attention_probs_dropout_prob": 0.1,
"bos_token_id": null,
"classifier_dropout": null,
"eos_token_id": null,
"gradient_checkpointing": false,
"hidden_act": "gelu",
"hidden_dropout_prob": 0.1,
"hidden_size": 768,
"initializer_range": 0.02,
"intermediate_size": 3072,
"is_decoder": false,
"layer_norm_eps": 1e-12,
"max_position_embeddings": 512,
"model_type": "bert",
"num_attention_heads": 12,
"num_hidden_layers": 12,
"pad_token_id": 0,
"position_embedding_type": "absolute",
"tie_word_embeddings": true,
"transformers_version": "5.9.0",
"type_vocab_size": 2,
"use_cache": true,
"vocab_size": 30522
}

以上就是 BERT 模型中的超参数,它们定义了 BERT 的骨架。需要注意的是,这里并不是 BERT 模型全部的超参数,训练的超参数需要在训练脚本(例如 HuggingFace 的 Trainer 中设置),比如学习率、优化器、batch_size 等等。

下面我们简单介绍一下每个参数的含义:

核心结构参数(最重要的)

参数含义
hidden_size768每个 token 的隐藏层向量维度。即输入/输出的 embedding 长度
num_hidden_layers12Encoder Layer 堆叠的层数
num_attention_heads12Multi-Head Attention 的头数。每头的维度 = 768/12 = 64
intermediate_size3072FFN 中间层的维度(先放大到 3072,再缩小回 768 = 4× 关系)
vocab_size30522词汇表大小,即 word_embeddings 矩阵的行数
max_position_embeddings512最大输入序列长度

激活函数 & Dropout:

参数含义
hidden_actgeluFFN 中的激活函数,不是 ReLU,是 GELU(高斯误差线性单元)
hidden_dropout_prob0.1隐藏层的 Dropout 概率
attention_probs_dropout_prob0.1Attention 权重上的 Dropout 概率
classifier_dropoutnull分类头专用 Dropout(此处未单独设置)

特殊 Token & 位置编码:

参数含义
pad_token_id0padding token 的 ID
bos_token_idnull序列开始 token(BERT 不用,用 [CLS])
eos_token_idnull序列结束 token(BERT 不用)
position_embedding_typeabsolute位置编码方式:绝对位置编码
type_vocab_size2token type 数量(区分两个句子的 [0, 1])

其他配置:

参数含义
is_decoderfalse不是 decoder,是 encoder-only
layer_norm_eps1e-12Layer Norm 的分母微小值,防止除零
initializer_range0.02参数初始化的范围(截断正态分布)
gradient_checkpointingfalse是否用梯度检查点节省显存(慢但省显存)
tie_word_embeddingstrue是否绑定输入/输出 embedding 权重(BERT 用于 MLM)
add_cross_attentionfalse是否添加 cross-attention(encoder-decoder 需要)
use_cachetrue是否缓存 KV(decoder 生成时加速用)
# config.vocab_size: 30522
# config.hidden_size: 768=64*12
token_embedding = nn.Embedding(config.vocab_size, config.hidden_size)
token_embedding # Embedding(30522, 768)
# forward of embedding module
input_embeddings = token_embedding(model_inputs['input_ids'])
# batch_size, seq_len, hidden_size
input_embeddings.shape # torch.Size([1, 5, 768])
# 先不考虑 position encoding
# query: [1, 5, 768], key: [1, 5, 768], value: [1, 5, 768]
# W_q, W_k, W_v: nn.linear, 作用在 token_embedding 上,依然通过 learing 最终决定
query = key = value = input_embeddings
dim_k = key.size(-1) # 768
print(dim_k)
# /np.sqrt(dim_k) 是为了缩放,防止 dot product 过大导致 softmax 后梯度消失
attention_scores = torch.matmul(query, key.transpose(1, 2)) / np.sqrt(dim_k)
attention_scores.shape # torch.Size([1, 5, 5])
attention_scores

输出为:

tensor([[[27.1932, 1.1826, -0.4651, 0.3558, 0.2249],
[ 1.1826, 29.3970, -0.2585, 2.2841, 1.1054],
[-0.4651, -0.2585, 26.4902, -0.1578, -0.9823],
[ 0.3558, 2.2841, -0.1578, 28.4970, 1.3951],
[ 0.2249, 1.1054, -0.9823, 1.3951, 26.8615]]],
grad_fn=<DivBackward0>)

这里我们得到的就是注意力分数矩阵,也就是公式中 QKTdk\frac{QK^T}{\sqrt{d_k}} 这一步的结果。

可以看出,对角线的值远远大于非对角线。这是因为每个词语自己最相似,所以点积最大。

需要注意的是,我们没有用可学习的 WQ,WKW^Q, W^K 投影,而是直接 query = key = value = input_embeddings,相当于 XXT/dkX\cdot X^T/\sqrt{d_k},即每个 token 与其他所有 token 的点积。所以,这些分数只反映了 原始 token embedding 的余弦相似度,还不是真正经过学习的 attention.

如果采用 WQ,WKW^Q, W^K 投影,它们会这些分数”调教”成更有语义意义的注意力分布——比如让 flies 更关注 time(语法修饰关系),而不是全靠自身。

# from scores -> weights, softmax
import torch.nn.functional as F
attention_weights = F.softmax(attention_scores, dim=-1)
attention_weights.shape # torch.Size([1, 5, 5])
attention_weights

输出为:

tensor([[[1.0000e+00, 5.0553e-12, 9.7307e-13, 2.2112e-12, 1.9401e-12],
[5.5803e-13, 1.0000e+00, 1.3206e-13, 1.6789e-12, 5.1658e-13],
[1.9655e-12, 2.4165e-12, 1.0000e+00, 2.6727e-12, 1.1718e-12],
[6.0036e-13, 4.1294e-12, 3.5925e-13, 1.0000e+00, 1.6974e-12],
[2.7034e-12, 6.5209e-12, 8.0838e-13, 8.7118e-12, 1.0000e+00]]],
grad_fn=<SoftmaxBackward0>)

这里就是上一步的 attention_scores 经过 softmax 归一化后的结果,也就是我们之前提到过的 wjiw_{ji}.

不过这里我们会发现:

time flies like an arrow
time 1.00 ~0 ~0 ~0 ~0
flies ~0 1.00 ~0 ~0 ~0
like ~0 ~0 1.00 ~0 ~0
an ~0 ~0 ~0 1.00 ~0
arrow ~0 ~0 ~0 ~0 1.00

几乎每个词都 100% 关注自己,而不看其他任何词。

这是我们前面简化的结果,在这种简化下,每个 token 和自己一模一样,点积当然远远大于与其他 token 的点积。这样的 attention 是完全失效的——每个词都是”孤岛”,没有吸收任何上下文信息。

不过,这里我们只是演示计算的过程,只需要知道真实的 BERT 并不是这样的即可。

attention_weights.sum(dim=-1) # 每行加起来等于 1

输出为:

tensor([[1., 1., 1., 1., 1.]], grad_fn=<SumBackward1>)

可以看到,经过 softmax 归一化后,每行加起来都等于 1,也就对应前面我们提到过的 jwji=1\sum_j w_{ji}=1.

# 5*5, 5*768 -> 5*768
attention_output = torch.bmm(attention_weights, value)
attention_output.shape # torch.Size([1, 5, 768])
attention_output

输出为:

tensor([[[-0.3932, 0.1078, -1.3741, ..., -0.8332, 1.7649, -0.8743],
[-1.7124, -1.0425, -0.7992, ..., 0.6528, 0.5512, 1.1871],
[-1.1721, -1.1326, 0.3345, ..., -0.1074, 0.3040, -0.2598],
[-0.4535, -0.7321, -1.8562, ..., -0.1357, 1.2970, 0.2048],
[-0.6879, -1.6019, 0.6278, ..., 0.2712, 0.0040, -0.2760]]],
grad_fn=<BmmBackward0>)

这里我们就来到了公式中的最后一步,用 attention_weights 对 Value 进行加权求和,形状为 (1, 5, 768):

  • 1 个 batch
  • 5 个 token
  • 每个 768 维

同样,由于我们简化的结果:

x₁' = 1.0 · v₁ + 0 · v₂ + 0 · v₃ + 0 · v₄ + 0 · v₅ = v₁
x₂' = 0 · v₁ + 1.0 · v₂ + 0 · v₃ + 0 · v₄ + 0 · v₅ = v₂
...

输出的每一行,其实就等于对应 token 自己的 Value 向量,几乎没有从其他词吸收任何信息。

我们用一个完整的函数覆盖上述步骤:

# batch_size, seq_len, hidden_size
def scaled_dot_product_attention(query, key, value):
dim_k = key.size(-1)
attention_scores = torch.matmul(query, key.transpose(1, 2)) / np.sqrt(dim_k)
attention_weights = F.softmax(attention_scores, dim=-1)
attention_output = torch.bmm(attention_weights, value)
return attention_output
scaled_dot_product_attention(query, key, value)

输出为:

tensor([[[-0.3932, 0.1078, -1.3741, ..., -0.8332, 1.7649, -0.8743],
[-1.7124, -1.0425, -0.7992, ..., 0.6528, 0.5512, 1.1871],
[-1.1721, -1.1326, 0.3345, ..., -0.1074, 0.3040, -0.2598],
[-0.4535, -0.7321, -1.8562, ..., -0.1357, 1.2970, 0.2048],
[-0.6879, -1.6019, 0.6278, ..., 0.2712, 0.0040, -0.2760]]],
grad_fn=<BmmBackward0>)

最后,补充介绍一下 .matmul().bmm() 函数:

  • torch.matmul()通用矩阵乘法,支持多种维度的输入,会根据输入自动选择计算方式:
输入维度行为例子
两个 2D (m, n) × (n, p)标准矩阵乘法(m, p)
3D + 2D (b, m, n) × (n, p)批次广播(b, m, p)
两个 3D (b, m, n) × (b, n, p)批次矩阵乘法(b, m, p)
1D 向量点积

之前我们用的就是 3D 版本:

# query: [1, 5, 768], key.transpose: [1, 768, 5]
torch.matmul(query, key.transpose(1, 2)) # → [1, 5, 5]

  • torch.bmm()专门用于 3D 张量的批次矩阵乘法,要求输入必须恰好是 3D
torch.bmm(attention_weights, value)
# weights: [1, 5, 5], value: [1, 5, 768]
# → output: [1, 5, 768]

二者关键区别:

matmul()bmm()
输入维度灵活(1D~3D+)必须两个都是 3D
广播✅ 支持❌ 不支持(batch 维必须相等)
使用场景Q 与 K^T 相乘(维数可能不同)weights 与 V 相乘(都是明确 3D)

简单来说:matmul 通用灵活,bmm 专一高效。二者在 3D 输入且 batch 一致时的行为等价,但 bmm 更严格——如果你确定两个输入都是 (batch, n, m) 形状,用 bmm 可以提前捕捉维度错误。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

02_transformer_architecture_self_attention
https://github.com/chunhuizhang/bilibili_vlogs/
作者
HAC
发布于
2026-07-10
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
HAC
观之非易,行且克难
Greetings
欢迎来到我的博客!这里主要分享我的学习笔记与兴趣爱好。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
32
分类
5
标签
13
总字数
79,889
运行时长
0
最后活动
0 天前

文章目录