03_transformer_architecture_multi_head_attention
1. Multi-head (Self/Cross) Attention
本节我们继续介绍 Transformer 架构的多头(自/交叉)注意力。下面是 Multi-head Attention 的架构图:

多个头的 Scaled dot product attention 拼接起来,送给 concat,然后交给 Linear 就得到了 multi-head attention 的输出。
我们首先承接上一节的内容:
import torchfrom torch import nnimport transformersimport numpy as npimport pandas as pdimport matplotlib.pyplot as pltimport matplotlib as mpl
mpl.rcParams['figure.dpi'] = 200print(torch.__version__) # 2.6.0+cu124print(transformers.__version__) # 5.9.0from transformers import AutoTokenizerfrom bertviz.transformers_neuron_view import BertModelfrom bertviz.neuron_view import showmodel_ckpt = 'bert-base-uncased'tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
model = BertModel.from_pretrained(model_ckpt)modelsample_text = 'time flies like an arrow'
show( model, model_type='bert', tokenizer=tokenizer, sentence_a=sample_text, display_mode='light', layer=0, head=8 )model_inputs = tokenizer(sample_text, return_tensors='pt', add_special_tokens=False)model_inputsfrom torch import nnfrom transformers import AutoConfigconfig = AutoConfig.from_pretrained(model_ckpt)config# config.vocab_size: 30522# config.hidden_size: 768=64*12token_embedding = nn.Embedding(config.vocab_size, config.hidden_size)token_embedding # Embedding(30522, 768)# forward of embedding moduleinput_embeddings = token_embedding(model_inputs['input_ids'])
# batch_size, seq_len, hidden_sizeinput_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_embeddingsdim_k = key.size(-1) # 768print(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# from scores -> weights, softmaximport torch.nn.functional as F
attention_weights = F.softmax(attention_scores, dim=-1)attention_weights.shape # torch.Size([1, 5, 5])attention_weightsattention_weights.sum(dim=-1) # 每行加起来等于 1# 5*5, 5*768 -> 5*768attention_output = torch.bmm(attention_weights, value)attention_output.shape # torch.Size([1, 5, 768])attention_output# batch_size, seq_len, hidden_sizedef 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_outputscaled_dot_product_attention(query, key, value)以上就是上一节的内容,这里我们已经得到了 Scaled dot product attention,也就是之前示意图中的红色部分。
下面正式进入本节的部分。
class AttentionHead(nn.Module): def __init__(self, embed_dim, head_dim): super().__init__() # 三个线性层,用 W 把输入投影成 query, key, value self.W_q = nn.Linear(embed_dim, head_dim) self.W_k = nn.Linear(embed_dim, head_dim) self.W_v = nn.Linear(embed_dim, head_dim)
def forward(self, hidden_states): q = self.W_q(hidden_states) k = self.W_k(hidden_states) v = self.W_v(hidden_states) attention_output = scaled_dot_product_attention(q, k, v) return attention_output我们这里简单定义了一个注意力头从输入到输出的完整 pipline. 这里的 W_q, W_k, W_v 是 nn.Linear 模块,每个模块内部存储着:
- 一个可学习的权重矩阵 weight,形状为 (
head_dim,embed_dim) - 一个可学习的 bias,形状为 (
head_dim),默认开启
那么我们在上一节中提到的,数学上的 对应的就是 self.W_q.weight,再加上可选的 bias.
关于具体得到 的过程,我们知道 hidden_states 的形状为 [batch_size, seq_len, embed_dim]. 对于每个 token 位置 ,它的向量为 (长度 768),那么 nn.Linear 对每个 做的就是:
在 Pytorch 中,nn.Linear(in_features, out_features) 的 forward 本质是
其中 的形状是 (out_features, in_features),也就是 (head_dim, embed_dim),差别主要是 PyTorch 把权重存成 (out, in),关于这一点我们在 08 节中探讨过。
对整段序列,PyTorch 会在 batch 和 seq_len 维上批量做同样的线性变换,所以一次调用就得到所有位置的 :
hidden_states: [batch, seq_len, embed_dim] ↓ W_q(对每个 token 的向量做同一线性变换)q: [batch, seq_len, head_dim]同理。
class MultiHeadAttention(nn.Module): # config 见前文,存储 BERT 的超参数 def __init__(self, config): super().__init__() embed_dim = config.hidden_size # 例如 BERT-base: 768 num_heads = config.num_attention_heads # 例如 12 head_dim = embed_dim // num_heads # 768 // 12 = 64 self.heads = nn.ModuleList([ AttentionHead(embed_dim, head_dim) for _ in range(num_heads) ]) self.output_layer = nn.Linear(embed_dim, embed_dim)
# 定义前向计算过程 def forward(self, hidden_states): # hidden_states: [batch_size, seq_len, embed_dim] # 按头分割 # 每个头得到的结果是 [batch_size, seq_len, head_dim] x = torch.cat([head(hidden_states) for head in self.heads], dim=-1) x = self.output_layer(x) return x这里我们就定义了多头注意力,它的核心之前也介绍过:不要只算一次注意力,而是并行算多个独立的注意力头,每个头用自己的 ,从不同子空间看同一段序列;再把各头结果拼起来,最后过一个线性层得到输出。
nn.ModuleList:把多个子模块放进列表,PyTorch 能正确注册参数。- 列表里有 12 个
AttentionHead,彼此参数不共享。 - 每个
AttentionHead内部有 3 个nn.Linear(embed_dim, head_dim),即各自的 .
- 列表里有 12 个
torch.cat:对得到的 12 个 tensors 进行拼接,dim=-1表示在 特征维 上拼接:
head_0: [B, L, 64]head_1: [B, L, 64]...head_11: [B, L, 64] ↓ concatx: [B, L, 768] # 64 × 12 = 768x = self.output_layer(x):最后输出线性层,对每个 token 的 768 维向量做 ,最终返回的x就是 Multi-head Attention 的输出。
整体的数据流:
hidden_states [B, L, 768] │ ├─ AttentionHead 0 ──→ [B, L, 64] ├─ AttentionHead 1 ──→ [B, L, 64] ├─ ... └─ AttentionHead 11 ─→ [B, L, 64] │ ▼ torch.cat(dim=-1) x [B, L, 768] │ ▼ output_layer (W^O) 输出 [B, L, 768]from torch import nnfrom transformers import AutoConfig, AutoTokenizer, AutoModel
model_ckpt = 'bert-base-uncased'tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
# hyperparametersconfig = AutoConfig.from_pretrained(model_ckpt)configmha = MultiHeadAttention(config)# input# config.vocab_size: 30522# config.hidden_state: 768=64*12# lookup-table, learnabletoken_embedding = nn.Embedding(config.vocab_size, config.hidden_size)# token_embeddingsample_text = 'time flies like an arrow'model_inputs = tokenizer(sample_text, return_tensors='pt', add_special_tokens=False)# foward of embedding moduleinput_embeddings = token_embedding(model_inputs['input_ids'])# batch_size, seq_len, hidden_sizeinput_embeddings.shape # torch.Size([1, 5, 768])mha(input_embeddings)输出为:
tensor([[[ 0.3316, -0.1109, -0.2945, ..., 0.2417, -0.0272, 0.0901], [ 0.3491, -0.1779, -0.2362, ..., 0.1970, 0.1125, 0.0460], [ 0.2233, -0.0979, -0.1581, ..., 0.1129, 0.1000, 0.0995], [ 0.3722, -0.1765, -0.2382, ..., 0.1719, -0.0061, 0.0895], [ 0.3137, -0.2047, -0.2799, ..., 0.2113, 0.0330, -0.0451]]], grad_fn=<ViewBackward0>)from bertviz import head_viewfrom transformers import AutoModel
model = AutoModel.from_pretrained(model_ckpt, output_attentions=True)sentence_a = 'time flies like an arrow'sentence_b = 'fruit flies like a banana'viz_inputs = tokenizer(sentence_a, sentence_b, return_tensors='pt')
# nsp (next sentence prediction)print(viz_inputs)attention = model(**viz_inputs).attentionssentence_b_start = (viz_inputs.token_type_ids == 0).sum(dim=1)print(sentence_b_start)
tokens = tokenizer.convert_ids_to_tokens(viz_inputs.input_ids[0]) # tensor([...])head_view(attention, tokens, sentence_b_start, heads=[8])部分输出为:
{'input_ids': tensor([[ 101, 2051, 10029, 2066, 2019, 8612, 102, 5909, 10029, 2066, 1037, 15212, 102]]), 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]])}tensor([7])可以看出,我们只有一个 tensor,也就是说两句话实际上被展平了,我们识别第一句话和第二句话的方法就是看 token_type_ids,为 0 的就是第一句话,为 1 的就是第二句话。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!