03_transformer_architecture_multi_head_attention

1367 字
7 分钟
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 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
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
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
)
model_inputs = tokenizer(sample_text, return_tensors='pt', add_special_tokens=False)
model_inputs
from torch import nn
from transformers import AutoConfig
config = AutoConfig.from_pretrained(model_ckpt)
config
# 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
# 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
attention_weights.sum(dim=-1) # 每行加起来等于 1
# 5*5, 5*768 -> 5*768
attention_output = torch.bmm(attention_weights, value)
attention_output.shape # torch.Size([1, 5, 768])
attention_output
# 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)

以上就是上一节的内容,这里我们已经得到了 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_vnn.Linear 模块,每个模块内部存储着:

  • 一个可学习的权重矩阵 weight,形状为 (head_dim, embed_dim)
  • 一个可学习的 bias,形状为 (head_dim),默认开启

那么我们在上一节中提到的,数学上的 WQW^Q 对应的就是 self.W_q.weight,再加上可选的 bias.


关于具体得到 Q,K,VQ,K,V 的过程,我们知道 hidden_states 的形状为 [batch_size, seq_len, embed_dim]. 对于每个 token 位置 ii,它的向量为 xix_i(长度 768),那么 nn.Linear 对每个 xix_i 做的就是:

qi=WQxi+bQq_i=W^Qx_i+b^Q

在 Pytorch 中,nn.Linear(in_features, out_features)forward 本质是

y=xWT+by=xW^T+b

其中 WW 的形状是 (out_features, in_features),也就是 (head_dim, embed_dim),差别主要是 PyTorch 把权重存成 (out, in),关于这一点我们在 08 节中探讨过。

对整段序列,PyTorch 会在 batchseq_len 维上批量做同样的线性变换,所以一次调用就得到所有位置的 QQ:

hidden_states: [batch, seq_len, embed_dim]
↓ W_q(对每个 token 的向量做同一线性变换)
q: [batch, seq_len, head_dim]

K,VK,V 同理。

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

这里我们就定义了多头注意力,它的核心之前也介绍过:不要只算一次注意力,而是并行算多个独立的注意力头,每个头用自己的 WQ,WK,WVW^Q, W^K, W^V,从不同子空间看同一段序列;再把各头结果拼起来,最后过一个线性层得到输出。

  • nn.ModuleList :把多个子模块放进列表,PyTorch 能正确注册参数。
    • 列表里有 12 个 AttentionHead,彼此参数不共享。
    • 每个 AttentionHead 内部有 3 个 nn.Linear(embed_dim, head_dim),即各自的 WQ,WK,WVW^Q, W^K, W^V.
  • torch.cat :对得到的 12 个 tensors 进行拼接,dim=-1 表示在 特征维 上拼接:
head_0: [B, L, 64]
head_1: [B, L, 64]
...
head_11: [B, L, 64]
↓ concat
x: [B, L, 768] # 64 × 12 = 768
  • x = self.output_layer(x) :最后输出线性层,对每个 token 的 768 维向量做 y=xWO+by=xW^O+b,最终返回的 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 nn
from transformers import AutoConfig, AutoTokenizer, AutoModel
model_ckpt = 'bert-base-uncased'
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
# hyperparameters
config = AutoConfig.from_pretrained(model_ckpt)
config
mha = MultiHeadAttention(config)
# input
# config.vocab_size: 30522
# config.hidden_state: 768=64*12
# lookup-table, learnable
token_embedding = nn.Embedding(config.vocab_size, config.hidden_size)
# token_embedding
sample_text = 'time flies like an arrow'
model_inputs = tokenizer(sample_text, return_tensors='pt', add_special_tokens=False)
# foward 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])
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_view
from 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).attentions
sentence_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 的就是第二句话。

文章分享

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

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

评论区

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

音乐

暂未播放

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

文章目录