08_attention_01

6841 字
34 分钟
08_attention_01
Attention(Q,K,V)=softmax(QKTdk)VAttention(Q,K,V)=softmax(\frac{QK^T}{\sqrt{d_k}})V
import torch
from torch import nn
from bertviz.transformers_neuron_view import BertModel, BertConfig
from transformers import BertTokenizer

这里我们首次导入了 bertviz 库。

bertviz 是一个专门用来可视化 BERT 注意力机制的第三方库。它的核心功能就是把 BERT 模型每层每个注意力头的注意力权重画出来,让你”看到”模型在关注哪些词。

比如下面这种图就是 bertviz 生成的(注意力连线图):

[CLS] ────╮─────────────╮
After ╰──╮──────────╮│
stealing ╰─╮────────╮╮│
money ╰──╮─────╮╰╯
from ╰─╮────╯
the ╰─╮──╯
bank ←──←──←──╯ ← "bank" 和前面的词有很强的注意力

BertModel 为什么从 bertviz 而不是 transformers 导入

这是关键点。对比一下两条 import

from transformers import BertModel, BertTokenizer
from bertviz.transformers_neuron_view import BertModel, BertConfig
from transformers import BertTokenizer

原因:bertviz 对原版 BertModel 做了”偷梁换柱

bertviz.transformers_neuron_view 里面的 BertModel 并不是 HuggingFace transformers 库的原始 BertModel,而是 bertviz 自己魔改过的版本

你可以理解为:

transformers.BertModel → 标准的 BERT 模型(普通版)
bertviz.BertModel → 被 bertviz 改造过的 BERT 模型(特制版✨)

这个”特制版”在原始 BertModel 的基础上做了两件事:

  1. 注册了前向钩子(forward hooks)——在模型计算注意力时,自动把注意力权重矩阵截取并保存下来
  2. 整理成可视化友好的格式——把原始的注意力权重整理成 bertviz 画图函数可以识别的数据结构

用一张图说明:

原始 transformers 库
┌─────────────────────────┐
│ BertModel │ ← 标准的 BERT,注意力权重藏在内部
│ forward() → 输出各种 │ 不主动暴露细节
│ hidden states │
└─────────────────────────┘
bertviz 做了"包装"
┌─────────────────────────┐
│ bertviz.transformers_ │
│ neuron_view.BertModel │ ← 继承/包装了标准 BertModel
│ │
│ forward() → 除了原有 │
│ 输出,还自动捕获了 │
│ attention weights ✨ │
└─────────────────────────┘

为什么 bertviz 不直接用原版

因为原版 transformers.BertModel 虽然可以设置 output_attentions=True 输出注意力权重,但输出的格式是嵌套的 tensor(12层 × 12头 × 序列长度 × 序列长度),直接拿来可视化需要额外处理。

bertviz 为了方便用户一键可视化,直接把自己包装过的 BertModel 放在 bertviz.transformers_neuron_view 模块下,让你导入它的模型 → 正常 forward → 直接调用可视化函数,一气呵成。

一个有趣的细节

from bertviz.transformers_neuron_view import BertModel, BertConfig
from transformers import BertTokenizer
  • BertModelBertConfig → 从 bertviz 导入(因为要捕获注意力权重)
  • BertTokenizer → 从 transformers 导入(分词器不需要捕获注意力,用原版即可)

所以简单说:bertviz 是一个可视化工具包,它为了让你能方便地看到注意力权重,自己包装了一个”特制版”的 BertModel,你需要用它的版本来代替原版。

1. model config and load#

1.1 配置模型#

max_length = 256 # 设置最大输入长度
model_name = 'bert-base-uncased'
config = BertConfig.from_pretrained(
model_name,
output_attentions=True, # 让模型输出注意力权重(后面可视化用)
output_hidden_states=True, # 让模型输出隐藏状态(后面可视化用)
return_dict=True # 以字典形式返回输出,更方便使用
)
tokenizer = BertTokenizer.from_pretrained(model_name)
config.max_position_embeddings = max_length
model = BertModel(config).from_pretrained(model_name)
model = model.eval()

我们首先配置模型,为理解注意力机制计算做准备:

1.1.1 BertConfig#

  • BertConfig.from_pretrained() :从 HuggingFace 下载/加载模型的配置文件(不是模型本身)。

在之前的笔记中,我们直接使用类似 model=BertModel.from_pretrained() 创建模型并加载权重。实际上,当我们调用 BertModel.from_pretrained() 时,它的内部自动帮我们做了两件事:

  1. 创建了一个默认的 BertConfig,并加载预训练模型的配置;
  2. 创建模型并加载权重。

而在这里,我们是用 config=BertConfig.from_pretrained() 先手动创建了一个 BertConfig,再通过 model=BertModel(config).from_pretrained() 来初始化模型骨架,最后加载权重。

print(config)

输出为:

{
"architectures": [ # 模型架构名称(用于自动加载对应模型类)
"BertForMaskedLM"
],
"attention_probs_dropout_prob": 0.1, # 注意力层的 Dropout 概率(防止过拟合)
"finetuning_task": null, # 微调任务类型(null 表示预训练阶段,未指定下游任务)
"hidden_act": "gelu", # 隐藏层的激活函数(GELU:高斯误差线性单元)
"hidden_dropout_prob": 0.1, # 全连接层的 Dropout 概率
"hidden_size": 768, # 隐藏层维度(每个 token 被编码成 768 维向量)
"initializer_range": 0.02, # 参数初始化时权重的随机范围(±0.02 内均匀分布)
"intermediate_size": 3072, # FFN 中间层维度(768 → 3072 → 768,先放大再缩小)
"layer_norm_eps": 1e-12, # LayerNorm 的分母防零小常数
"max_position_embeddings": 256, # 最大序列长度(此处被我们从 512 改为了 256)
"model_type": "bert", # 模型类型标识(用于 HuggingFace 自动路由)
"num_attention_heads": 12, # 每层注意力头数(每个头负责关注不同的特征)
"num_hidden_layers": 12, # Transformer 编码器层数
"num_labels": 2, # 分类任务标签数(预训练默认为 2,用于 NSP 任务)
"output_attentions": true, # 是否输出注意力权重(此处设为 true,用于可视化)
"output_hidden_states": true, # 是否输出所有隐藏层状态
"pad_token_id": 0, # Padding 填充符的 token ID([PAD] = 0)
"torchscript": false, # 是否启用 TorchScript(用于模型部署优化)
"type_vocab_size": 2, # 句子类型数量(单句=0,句对=1,用于 token_type_ids)
"vocab_size": 30522 # 词表大小(BERT 共能识别 30522 个不同的词/子词)
}

可以看到,BertConfig 其实就是一个配置类,里面存着 BERT 模型的所有超参数

1.1.2 什么时候必须用 BertConfig#

当你需要修改默认配置时,手动创建 Config 就很有必要了。

之前的代码拆分来看就是:

BertModel(config) → 拿配置单造一个"空壳模型"(参数随机初始化)
.from_pretrained(model_name) → 把预训练好的"参数"填进去

1.1.3 那 BertModel.from_pretrained() 能修改配置吗?#

可以! model=BertModel.from_pretrained() 也支持传参数,但只能改部分参数

# 也能修改部分配置
model = BertModel.from_pretrained(
model_name,
output_hidden_states=True, # ✅ 可以
output_attentions=True, # ✅ 可以
max_position_embeddings=256 # ❌ 这个就不行!
)

max_position_embeddings 这种结构性的参数(会影响模型内部张量形状的),你必须在 BertConfig 里先改好,再传给 BertModel,否则会报错。


1.2 Transformer 层#

att_head_size = int(model.config.hidden_size / model.config.num_attention_heads)
att_head_size # 64

这里其实就是每个注意力头的维度大小,也就是:

输入 (768维)
┌───── 拆分成 12 个注意力头 ────┐
│ │ │ │
头1(64维) 头2(64维) 头3(64维) ... 头12(64维)
│ │ │ │
每个头独立计算注意力(关注不同位置的特征)
│ │ │ │
└───── 拼接回去 (64×12 = 768) ──┘
输出 (768维)

从数学上来看,在 Self-Attention 中,Q、K、V 矩阵的形状变化是:

  1. 原始矩阵Q, K, V 形状 = (batch, seq_len, 768)
  2. 拆分为多头后重塑为:(batch, seq_len, 12, 64),然后转置为 (batch, 12, seq_len, 64)
  3. 每个头的注意力分数计算:Attention(Qi,Ki)=softmax(QiKiTdk)Vi`\text{Attention}(Q_i, K_i) = \text{softmax}\left(\frac{Q_i K_i^T}{\sqrt{d_k}}\right)V_i`,其中 dk=att_head_size=64d_k = \text{att\_head\_size} = 64

这里的 dk=64=8\sqrt{d_k} = \sqrt{64} = 8 就是 scaling factor(缩放因子),用于防止 softmax 后的梯度太小。

model.encoder
model.encoder.layer
model.encoder.layer[0]

输出为:

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)
)
)
)
)
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)
)
)
)
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)
)
)

输出解析:

  • model.encoder :还记得我们在 03 中看过 model 本身的输出吗?encoder 就是其中的一个属性,包含了所有的 Transformer 层(也就是我们在 07 中看到的那些层)。我们可以通过 model.encoder.layer 来访问这些层。
  • model.encoder.layer :当我们用 model.encoder.layer 获取了所有 Transformer 层后,我们就可以索引这些层了。
  • model.encoder.layer[0] :比如,我们可以取出第 0 层。

1.2.1 每个 Transformer 层的架构#

从第 0 层的输出中,我们可以管窥每个 Transformer 层的架构。

整体结构#

每个 BertLayer3 大模块组成:

BertLayer
├── attention ← 多头注意力层
├── intermediate ← FFN 中间层(升维)
└── output ← FFN 输出层(降维 + 残差连接 + LayerNorm)

这和经典 Transformer 论文中的架构完全一致:

┌─────────────────────┐
│ Add & Norm │ ← output (LayerNorm + 残差)
├─────────────────────┤
│ Feed Forward │ ← intermediate (3072) → output (768)
├─────────────────────┤
│ Add & Norm │ ← BertSelfOutput (LayerNorm + 残差)
├─────────────────────┤
│ Multi-Head Self-Attention │ ← BertSelfAttention
└─────────────────────┘

attention — 注意力子层#

(attention): BertAttention(
(self): BertSelfAttention( ← 核心:多头自注意力
(query): Linear(768 → 768) ← Q 映射
(key): Linear(768 → 768) ← K 映射
(value): Linear(768 → 768) ← V 映射
(dropout): Dropout(p=0.1)
)
(output): BertSelfOutput( ← Norm + 残差连接
(dense): Linear(768 → 768) ← 多头输出拼接后的投影
(LayerNorm): BertLayerNorm() ← 层归一化
(dropout): Dropout(p=0.1)
)
)

关键点:

  • Q/K/V 都是 Linear(768→768):注意,这里输出是 768 维,即 12×64=768 . 实际上这 768 维的 Linear 层内部被”隐式拆成”了 12 个头——权重矩阵形状是 (768, 768),但计算时会被 reshape 成 (12, 64) 做并行计算。
  • BertSelfOutput 做了一件重要的事:残差连接 + LayerNorm,即 LayerNorm(x+Attention(x))\text{LayerNorm}(x + \text{Attention}(x))

intermediate — FFN 升维层#

(intermediate): BertIntermediate(
(dense): Linear(768 → 3072) ← 把 768 维放大到 3072 维
)

这对应 Transformer 论文中的 Feed Forward 的第一半:先 4 倍升维(768 → 3072),激活函数为 GELU(在代码中不显示,在 config.hidden_act 里)。

output — FFN 降维 + 残差#

(output): BertOutput(
(dense): Linear(3072 → 768) ← 从 3072 维降回 768 维
(LayerNorm): BertLayerNorm()
(dropout): Dropout(p=0.1)
)

这是 FFN 的第二半:降维回 768,然后再次做 残差连接 + LayerNorm


一张图总结单个 Transformer 层中完整的数据流:

输入 x (768维) (上一层的输出)
├──→ BertSelfAttention (Q/K/V 各 768→768, 再拆12头)
│ ┌─────────────────────────────────────┐
│ │ 768 → 12个头×64维 → 注意力计算 → 拼接 → 768 │
│ └─────────────────────────────────────┘
│ 输出 att_out (768维)
├──→ BertSelfOutput (残差连接 + LayerNorm)
│ output = LayerNorm(x + att_out)
├──→ BertIntermediate
│ Linear(768 → 3072) + GELU
│ 输出 3072维
├──→ BertOutput
│ Linear(3072 → 768) + 残差连接 + LayerNorm
│ output = LayerNorm(intermediate_out + self_output)
└──→ 输出 (768维) → 进入下一层

1.2.2 “多头”在哪?—— 看 reshape 拆解过程#

上面我们已经拆解了 encoder 中每一个 Transformer 层的结构。但这里我们发现,上述结构中似乎并没有体现“多头注意力”中的“多头”。即使我们进一步索引:

model.encoder.layer[0].attention
model.encoder.layer[0].attention.self
model.encoder.layer[0].attention.self.query

输出为:

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)
)
)
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)
)
Linear(in_features=768, out_features=768, bias=True)

从表面上看,query 是一个 Linear(768→768),完全看不出”12 个头”的存在。秘密藏在实际计算时的 reshape 操作中:

原理#

模型不是创建 12 个独立的 Linear 层,而是用一个 Linear(768→768) 一次性算出所有头的 Q,然后通过 tensor reshape 拆成 12 份:

Linear(768→768) 的输出: (batch, seq_len, 768)
reshape 成 (batch, seq_len, 12, 64)
transpose 成 (batch, 12, seq_len, 64)
┌──── 头0 ── 头1 ── ... ── 头11 ──┐
│ 64维 64维 64维 │
└──────────────────────────────────┘

下面用代码演示这个拆解过程:

model.encoder.layer[0].attention.self.query.weight.shape # torch.Size([768, 768])
model.encoder.layer[0].attention.self.query.weight[:64, :] # 第一个头的权重矩阵
model.encoder.layer[0].attention.self.query.weight[:64, :].shape # torch.Size([64, 768])
model.encoder.layer[0].attention.self.query.weight[64:128, :] # 第二个头的权重矩阵

输出为:

torch.Size([768, 768])
tensor([[-0.0164, 0.0261, -0.0263, ..., -0.0545, 0.0607, 0.0398],
[-0.0326, 0.0346, -0.0423, ..., 0.0586, -0.0306, -0.0412],
[ 0.0105, 0.0334, 0.0109, ..., -0.0573, -0.0118, -0.0147],
...,
[-0.0085, 0.0514, 0.0555, ..., -0.0607, 0.0823, 0.0531],
[-0.0198, 0.0944, 0.0617, ..., 0.0934, -0.0044, -0.0301],
[ 0.0015, -0.0952, 0.0099, ..., 0.0719, 0.0701, 0.0175]],
grad_fn=<SliceBackward0>)
torch.Size([64, 768])
tensor([[-0.0112, 0.0260, -0.0169, ..., 0.1083, -0.0436, -0.0611],
[-0.0324, -0.0067, 0.0232, ..., 0.0056, -0.1032, 0.0224],
[-0.0615, -0.0616, 0.0068, ..., 0.0968, -0.1035, -0.0320],
...,
[-0.0383, 0.1097, 0.0124, ..., 0.0188, 0.0138, 0.0376],
[ 0.0031, 0.0029, -0.0168, ..., -0.0171, -0.0488, 0.0186],
[ 0.0059, -0.0540, 0.0301, ..., 0.0141, -0.0453, -0.0482]],
grad_fn=<SliceBackward0>)

输出解析:

  • .weight.shape :这里我们取出的是第 0 层的 query 权重矩阵形状。
  • [:64, :] :这里我们切出的是第一个头的 query 矩阵。

在 Pytorch 中,nn.Linear 构造函数的参数顺序是 (in_features, out_features),但要注意在这里我们内部存储的权重矩阵形状是 (out_features, in_features)=(768,768),即:行 = 输出维度,列 = 输入维度

用一个简单的图示说明:

in_features=768 (列)
┌────────────────────────────┐
头0 │ ███████████████████████████ │ ← [:64, :] 这 64 行 = 头 0
64行 │ ███████████████████████████ │
├────────────────────────────┤
头1 │ ·························· │ ← 下一个 64 行 = 头 1
│ ·························· │
├────────────────────────────┤
... │ ... │
├────────────────────────────┤
头11 │ ·························· │ ← 最后 64 行 = 头 11
│ ·························· │
└────────────────────────────┘
out_features=768 (行)

这里并不是 Pytorch 一定要反着来,而是由它的数学实现方式决定的。PyTorch 的 Linear 实际做的计算是:

y = x @ W.T + b # ← 注意这里有个 .T(转置)!

其中:

  • xx 的形状:(*, in_features),比如 (batch, 768)
  • WW存储形状(out_features, in_features) = (768, 768)
  • WTW^T 的形状:(in_features, out_features) = (768, 768)
  • 结果 yy 的形状:(*, out_features)

如果存成 (in_features, out_features),计算就是 y=xWy = xW(不需要转置)。

用一张图对比

构造参数: Linear(in_features=768, out_features=768)
存储的 W: shape = (768, 768) = (out, in)
计算时: y = x @ W.T
x: (*, 768) W.T: (768, 768) (in, out)
结果: (*, 768)
  • [64:128, :] :这里再往后 64 维就是顺理成章地第二个头的 query 矩阵。

2. data#

from sklearn.datasets import fetch_20newsgroups
newsgroups_train = fetch_20newsgroups(subset='train')
inputs_tests = tokenizer(
newsgroups_train.data[:1], # 取前一条文本数据进行测试
padding=True,
truncation=True, # 超过最大长度则截断
max_length=max_length, # 最大长度
return_tensors='pt' # 返回PyTorch张量
)
inputs_tests.keys() # dict_keys(['input_ids', 'token_type_ids', 'attention_mask'])
inputs_tests['input_ids'].shape # torch.Size([1, 201])

输出为:

dict_keys(['input_ids', 'token_type_ids', 'attention_mask'])
torch.Size([1, 201])

3. model output#

model_output = model(**inputs_tests)

下面是 BERT 模型 forward 时可能返回的 4 种输出:

  • last_hidden_state(batch_size, seq_length, hidden_size): the last hidden state where outputted from the last BERT layer.
  • pooler_output(batch_size, hidden_size): output of the Pooler layer.
  • hidden_states(batch_size, seq_length, hidden_size): hidden states of the model at the output of each BERT layer + the initial embedding.
  • attentions(batch_size, num_heads, seq_length, seq_length): one for each BERT layer. Attentions weights after the attention softmax.
输出形状含义在我们的输出中?
last_hidden_state(1, 201, 768)所有 201 个 token 经过 最后一层 BERT 层 后的隐藏状态[0]
pooler_output(1, 768)[CLS] token 经过一个 Linear + Tanh 后的句子级表示[1]
hidden_states元组,13 个 (1, 201, 768)每一层 的输出 + 最开始的 embedding 层输出(共 13 个)❌ 未开启
attentions元组,12 个 (1, 12, 201, 201)每一层 的注意力权重(softmax 之后)[2]

如果开启 output_hidden_states=Truemodel_output 就会变成 4 个:

索引名称
[0]last_hidden_state
[1]pooler_output
[2]hidden_states(新增)
[3]attentions

hidden_states 是什么

hidden_states每一层 BERT 层的输出 + 最开始的 embedding 输出,共 13 个:

hidden_states = (
embedding_output, # [0] 初始词嵌入 + 位置嵌入 + 句子类型嵌入
layer_0_output, # [1] 第 0 层输出
layer_1_output, # [2] 第 1 层输出
...
layer_11_output, # [12] 第 11 层输出 = last_hidden_state
)

它可以用来做中间层特征提取分析模型各层学到了什么

len(model_output) # 3
len(model_output[-1]) # 12
model_output[-1][0].keys() # dict_keys(['attn', 'queries', 'keys'])
  • model_output :长度是 3,它对应 BERT 模型返回的 3 个输出组件
索引名称形状含义
[0]last_hidden_state(1, 201, 768)所有 token 经过 12 层编码后的最终隐藏状态
[1]pooler_output(1, 768)[CLS] token 再经过一个 Linear+Tanh 后的句子级表示
[2]attentionstuple 包含 12 层每层的注意力权重(因设置了 output_attentions=True

具体到每一层的注意力形状为 (batch, 12 heads, seq_len, seq_len)

当然,如果你没有在 config 中设置 output_attentions=True,那么 model_output 的长度就只有 2(没有 attentions)。

  • model_output[-1] :也就是 attentions 的部分(最后一部分),总共 12 层,所以长度为 12.
  • model_output[-1][0].keys() :我们可以看到 attention 部分值的 keys.

这里的 attnquerieskeysbertviz 魔改版 BertModel 通过前向钩子额外捕获的注意力计算中间产物,对应注意力公式的三个核心部分:

key含义对应公式中的
queries第 0 层的 Q 向量(每个 token 的 query 表示)QQ
keys第 0 层的 K 向量(每个 token 的 key 表示)KK
attn第 0 层的注意力权重(softmax 后的概率分布)softmax(QKTdk)softmax(\frac{QK^T}{\sqrt{d_k}})

它们的关系:

queries 和 keys 做点积 → 缩放 → softmax → attn
attn 再和 V 相乘 → 输出

也就是说:

  1. queries:当前 token “想关注别人” 的表示
  2. keys:当前 token “被关注” 的表示(别人来查它)
  3. attnQKTQ \cdot K^T 经过 softmax 后的结果,即每个 token 对其他所有 token 的注意力分布

为什么只存了 queries 和 keys,没有 values

因为 bertviz 的可视化只需要看”谁在关注谁”,而注意力权重由 Q 和 K 的点积 决定,不需要 V。所以它只在钩子里捕获了 Q 和 K 以及算好的 attn,V 没有被保存——这也解释了为什么字典里只有三个键,没有 values

model_output[-1][0]['attn'].shape # torch.Size([1, 12, 201, 201])

正如我们之前所说,attentions 的形状是 (batch_size, num_heads, seq_length, seq_length). 现在,让我们好好拆解一下每个输出,每个维度的来源。

3.1 维度是怎么来的?#

3.1.1 last_hidden_state — shape (1, 201, 768)#

len(model_output) # 3
model_output[0].shape # (1, 201, 768)

三个维度的含义:

维度来源
batch_size1我们只传了 1 条文本 newsgroups_train.data[:1]
seq_len201这条文本被 tokenizer 分词后的长度(max_length=256,截断前为 201)
hidden_size768config.hidden_size —— BERT 把每个 token 编码成 768 维向量

怎么来的: 输入 (1, 201) 的 token IDs 经过 embedding 层变成 (1, 201, 768),再经过 12 层 Transformer 编码器,每层输入输出形状不变,最后输出仍然是 (1, 201, 768)

token IDs embedding 12层 Transformer
(1, 201) ──→ (1, 201, 768) ──→ (1, 201, 768)
last_hidden_state

3.1.2 pooler_output — shape (1, 768)#

model_output[1].shape # (1, 768)
维度来源
batch_size1同上
hidden_size768经过 Linear + Tanh 后的向量维度

怎么来的:last_hidden_state 中取出 [CLS] 位置(第 0 个 token)的向量,再过一个 Linear(768→768) + Tanh:

last_hidden_state 取出 [CLS] 位置 Linear + Tanh
(1, 201, 768) ──→ (1, 768) ──→ (1, 768)
↑ ↑
位置 [:, 0, :] pooler_output

3.1.3 attentions — tuple of 12, each shape (1, 12, 201, 201)#

len(model_output[-1]) # 12(12 层)
model_output[-1][0].shape # 等一下,这里是 dict,我们来查
model_output[-1][0]['attn'].shape # (1, 12, 201, 201)
维度来源
batch_size1同上
num_heads12config.num_attention_heads —— 每层 12 个注意力头
seq_len (query)201query 侧的 token 数量
seq_len (key)201key 侧的 token 数量

怎么来的: 这是每个头算出的注意力权重矩阵,形状来源是:

Attention(Qi,Ki)=softmax(QiKiTdk)Attention(Q_i, K_i) = softmax\left(\frac{Q_i K_i^T}{\sqrt{d_k}}\right)

  • ii 个头:QiQ_i 形状 (1, 201, 64)KiK_i 形状 (1, 201, 64)
  • QiKiTQ_i \cdot K_i^T(1, 201, 201) — 每个 query token 对所有 key token 的注意力分数
  • 12 个头堆叠在一起 → (1, 12, 201, 201)
Q 拆成 12 个头 K 拆成 12 个头
(1, 12, 201, 64) (1, 12, 201, 64)
\ /
╲ ╱ Q @ K^T
╲ ╱
(1, 12, 201, 201) ← 注意力权重
softmax
(1, 12, 201, 201) ← attn(保存下来的值)

注意:bertviz 魔改版保存的 attn 已经是 softmax 之后的值,形状正是 (1, 12, 201, 201)

这里 12 个头具体是如何堆叠的#

这里既不是按行拼接,也不是按列拼接,而是通过 reshape(view)+ transpose(转置) 在张量的”维度”层面实现的。

整个过程分三步走:

第 1 步:Linear 输出 → 所有头”首尾相连”排成一个长向量#

Linear(768→768) 的输出是 (1, 201, 768)。对于每个位置的 token,它的 768 维向量是这样排列的:

每个位置的 768 维向量:
┌─────────────────────────────────────────────────────────┐
│ 头0的64维 │ 头1的64维 │ 头2的64维 │ ... │ 头11的64维 │
│ 0~63 │ 64~127 │ 128~191 │ │ 704~767 │
└─────────────────────────────────────────────────────────┘

这 12 个 64 维向量是首尾相连、连续存储在 768 维中的。

第 2 步:view(reshape)→ 把”连着的”拆成单独一维#
# (1, 201, 768) → (1, 201, 12, 64)
q = q.view(1, 201, 12, 64)

view 不会移动任何数据,它只是重新解释这 768 个数——把每连续的 64 个数看作一个”头”:

原始内存排列(一维视角):
[ 头0_0, 头0_1, ..., 头0_63, 头1_0, ..., 头1_63, ..., 头11_63 ]
▲ ▲ ▲
连续的64个数 下一个连续的64个数 最后64个数
view(1, 201, 12, 64) 后:
(1, 201, 12, 64)
dim=2 是"头编号"(0~11)
第 3 步:transpose → 把头编号提到前面#
# (1, 201, 12, 64) → (1, 12, 201, 64)
q = q.transpose(1, 2)

交换 dim=1(seq_len)和 dim=2(num_heads),这样头的维度就变成 dim=1,方便后续做批量的矩阵乘法:

view 后: (1, 201, 12, 64) ← 头在 dim=2
↑ ↑ ↑
batch seq head
transpose: (1, 12, 201, 64) ← 头提到 dim=1
↑ ↑ ↑
batch head seq
第 4 步:Q @ K^T → 12 个头并行计算#
# Q: (1, 12, 201, 64), K: (1, 12, 201, 64)
# Q @ K.transpose(-2, -1) → (1, 12, 201, 64) @ (1, 12, 64, 201)
# ↓
attn = Q @ K.transpose(-2, -1) # (1, 12, 201, 201)

PyTorch 把 dim=1 的 12 当作 batch 维度,一次性做了 12 个并行的 (201, 64) @ (64, 201) → (201, 201) 矩阵乘法。


一张图总结整个过程#
原始输出: (1, 201, 768)
view(1, 201, 12, 64) ← 把 768 重新解释为 12×64(不移动数据)
(1, 201, 12, 64)
transpose(1, 2) ← 交换 seq 和 head 维度
(1, 12, 201, 64)
Q @ K^T ← 12 个头并行矩阵乘法
(1, 12, 201, 201)

核心理解:这不是”把 12 个矩阵拼起来”,而是一个4 维张量——第 1 维(dim=1)的 12 代表 12 个”通道”,每个通道里存着一个 (201, 201) 的注意力矩阵。PyTorch 把这 12 个通道当成独立批次一次性算完。


3.1.4 总结#

输出形状通俗理解
last_hidden_state(1, 201, 768)每个 token 的最终表示向量
pooler_output(1, 768)整个句子的浓缩特征向量
attentions[0]['attn'](1, 12, 201, 201)第 0 层每个头中,“谁在看谁”的注意力分布
model_output[-1][0]['attn'][0, 0, :, :]

4. from scratch#

这一节,我们来从零完成一次 Q, K, V 的计算。

embeddings_output = model.embeddings(inputs_tests['input_ids'], inputs_tests['token_type_ids'])
embeddings_output

输出为:

tensor([[[ 0.1686, -0.2858, -0.3261, ..., -0.0276, 0.0383, 0.1640],
[-0.1172, 0.6055, 0.0487, ..., 0.5867, 0.8167, 0.4067],
[-0.7412, 0.3854, -0.7550, ..., 0.5425, 0.5629, 0.6106],
...,
[ 0.0679, 0.2560, 0.3443, ..., 0.5042, 0.4860, 0.3145],
[ 0.1079, 0.0740, 0.4233, ..., 0.2864, 0.5379, 0.1220],
[-0.0594, -0.0563, 0.2673, ..., -0.7952, -0.0813, -0.6690]]],
grad_fn=<AddBackward0>)

这一步我们拿到的是 BERT 的 embedding 层输出,即:词嵌入 + 位置嵌入 + 句子类型嵌入三者相加的结果。在下面我们可以看到,它的形状是 (1,201,768)

  • 1 = batch size(1 条文本)
  • 201 = 序列长度
  • 768 = 隐藏层维度
embeddings_output.shape # torch.Size([1, 201, 768])
Q_first_head_first_layer = embeddings_output[0] @ model.encoder.layer[0].attention.self.query.weight[:att_head_size, :].T + model.encoder.layer[0].attention.self.query.bias[:att_head_size]
Q_first_head_first_layer.shape # torch.Size([201, 64])
Q_first_head_first_layer

输出为:

tensor([[ 0.7090, -0.1532, -0.0324, ..., -0.1861, -1.1897, -0.3917],
[ 0.9676, 0.0748, 0.2025, ..., 0.7521, 0.4138, 0.1224],
[ 0.8280, -0.0809, 0.5322, ..., 0.1444, 0.3582, -0.0987],
...,
[ 0.9873, -0.7626, 0.3701, ..., -0.7065, 0.7049, -0.3984],
[ 1.0845, -0.9601, 0.4284, ..., -0.8086, 0.8632, -0.7072],
[ 0.3943, -0.4631, -0.3239, ..., -0.1489, -0.8662, -0.1141]],
grad_fn=<AddBackward0>)

这里我们就手动计算出了第一个注意力头的 Query 向量,各部分含义如下:

部分含义
embeddings_output[0]形状 (201, 768),去掉 batch 维度,拿到这 201 个 token 的嵌入向量
model.encoder.layer[0]0 层(第一层)Transformer
.attention.self.query该层的 Q 映射层,一个 Linear(768 → 768)
.weightQ 的权重矩阵,形状 (768, 768)
.weight[:att_head_size, :]前 64 行,即第一个注意力头的权重(att_head_size=64\text{att\_head\_size} = 64
.T转置,形状变为 (768, 64),为了满足矩阵乘法 x @ W.T
.bias[:att_head_size]偏置的前 64 个元素(第一个头的偏置)

计算流程如图:

embeddings_output[0] query.weight[:64, :].T
(201, 768) @ (768, 64) + bias[:64]
(64,)
Q_first_head_first_layer
(201, 64)

所以最终得到的是:201 个 token,每个 token 用 64 维向量表示——这正是第一个注意力头的 Query 矩阵。

这样,我们就直观验证了”多头注意力中的多头是通过权重矩阵的行切片实现的”这一核心概念。

K_first_head_first_layer = embeddings_output[0] @ model.encoder.layer[0].attention.self.key.weight[:att_head_size, :].T + model.encoder.layer[0].attention.self.key.bias[:att_head_size]
K_first_head_first_layer.shape # torch.Size([201, 64])
K_first_head_first_layer

输出为:

tensor([[ 1.2797, 0.2204, 0.2408, ..., 0.5843, -0.4191, 0.9194],
[-0.5851, -0.5868, -0.1607, ..., 1.0473, 0.0725, -0.3103],
[-0.5147, -0.5780, 0.0156, ..., 0.4617, 0.3585, -0.3786],
...,
[-0.6989, 1.2598, 0.1314, ..., 0.0398, 0.6206, -1.6981],
[-1.0770, 1.2705, -0.0528, ..., -0.2308, 0.5101, -1.7183],
[-1.3563, 1.7390, -0.0489, ..., -0.6310, 0.2154, 0.9688]],
grad_fn=<AddBackward0>)

Key 矩阵与 Query 矩阵同理,唯一不同的就是我们把 query 换成了 key.

接着我们来看注意力分数:

# (201, 64) @ (64, 201) -> (201, 201)
attention_scores = torch.nn.Softmax(dim=-1)(Q_first_head_first_layer @ K_first_head_first_layer.T / (att_head_size ** 0.5))
attention_scores.shape # torch.Size([201, 201])
attention_scores

输出为:

tensor([[0.0053, 0.0109, 0.0052, ..., 0.0039, 0.0036, 0.0144],
[0.0086, 0.0041, 0.0125, ..., 0.0045, 0.0041, 0.0071],
[0.0051, 0.0043, 0.0046, ..., 0.0043, 0.0045, 0.0031],
...,
[0.0010, 0.0023, 0.0055, ..., 0.0012, 0.0018, 0.0011],
[0.0010, 0.0023, 0.0057, ..., 0.0012, 0.0017, 0.0007],
[0.0022, 0.0056, 0.0063, ..., 0.0045, 0.0048, 0.0015]],
grad_fn=<SoftmaxBackward0>)

可以看到,这与我们之前 model_output[-1][0]['attn'][0, 0, :, :] 的输出是一模一样的。

此外,要注意这里的 dim=-1 ,它是在 PyTorch 里指定 Softmax 沿着哪一个维度做归一化的参数。

Q @ K.T 的形状是 (201, 201),它是个 2 维矩阵

key token 0 key token 1 ... key token 200
query 0 [ 0.3 0.1 ... 0.05 ]
query 1 [ 0.05 0.4 ... 0.2 ]
...
query 200 [ 0.1 0.15 ... 0.6 ]

在 PyTorch 中,dim 可以用正数负数指定:

  • dim=0 = 第 0 维(行)
  • dim=1 = 第 1 维(列)
  • dim=-1 = 最后一个维度——对于 2 维矩阵就是 dim=1(列)
形状 (201, 201)
↑ ↑
dim=0 dim=1
dim=-1 👈 就是这个

在注意力分数矩阵中:

K_token_0 K_token_1 ... K_token_200
Q_token_0 [ ↗ ↗ ↗ ]
Q_token_1 [ ↗ ↗ ↗ ] ← 对每个 query 行
... [ ]
Q_token_200[ ↗ ↗ ↗ ]
dim=-1:沿最后一维(列方向)做 softmax

dim=-1 的含义是:对每一行(每个 query token),把该行中所有列(所有 key token)的分数做 softmax,使得该行的值加起来等于 1。

为什么用 dim=-1 而不是 dim=0

假设我们有一个 query 在关注 “bank” 这个词,它应该把注意力分配给句子中相关的词:

thebankneartheriver
bank (query)0.050.600.150.050.15

沿 dim=-1(行) softmax → 对 “bank”这一行归一化,所有列加起为 1,表示 “bank”这个词把注意力分散到了哪些词上 ✅ 合理

如果错误地用了 dim=0(列) softmax → 对 “bank”这一列归一化,表示 “哪些词在关注 bank”——这就不是 Self-Attention 的标准语义了。

总结一下:

写法等价的效果
Softmax(dim=-1)Softmax(dim=1)每一行变成概率分布,总和为 1
Softmax(dim=0)每一列变成概率分布,总和为 1

在注意力公式 softmax(QKTdk)softmax(\frac{QK^T}{\sqrt{d_k}}) 中,我们希望对每个 query token 去关注所有 key token 的概率做归一化,所以用 dim=-1(即最后一个维度)——这是注意力机制的标准写法。

使用 dim=-1dim=1 的好处是:如果你的张量变成 3 维、4 维(比如加了 batch 维度和 head 维度),dim=-1 始终指向”最后一个维度”(序列长度所在的维度),代码更具通用性。

最后我们来看 Value 矩阵:

V_first_head_first_layer = embeddings_output[0] @ model.encoder.layer[0].attention.self.value.weight[:att_head_size, :].T + model.encoder.layer[0].attention.self.value.bias[:att_head_size]
V_first_head_first_layer.shape # torch.Size([201, 64])
V_first_head_first_layer

输出为:

tensor([[ 2.3786e+00, -9.7945e-02, -2.8436e-01, ..., 9.6968e-02,
-1.8537e-01, 2.1132e-01],
[ 9.8886e-02, 2.2942e-01, -3.9613e-01, ..., 2.0846e-01,
-1.1224e-01, -2.1420e-01],
[-6.3356e-01, 1.1083e+00, -5.2857e-04, ..., -3.1870e-01,
7.2633e-02, -1.0239e-01],
...,
[-7.9659e-01, -4.9400e-01, -4.9216e-02, ..., -3.6446e-01,
3.1565e-01, -7.1713e-01],
[-9.2551e-01, -5.0348e-01, -1.0398e-01, ..., -2.1418e-01,
1.6604e-01, -5.9637e-01],
[ 1.2915e-01, -7.5899e-03, 1.8397e-01, ..., 2.6980e-01,
1.3651e-01, 1.9180e-01]], grad_fn=<AddBackward0>)
attention_embeddings = attention_scores @ V_first_head_first_layer
attention_embeddings.shape # torch.Size([201, 64])
attention_embeddings

输出为:

tensor([[-4.5640e-01, 4.6211e-02, 4.3913e-02, ..., -2.0099e-02,
-1.2756e-02, 6.4255e-03],
[-4.5674e-01, 3.4322e-02, 3.2707e-02, ..., -4.9206e-02,
1.4976e-02, -3.0628e-02],
[-4.9474e-01, -2.9539e-04, -7.5374e-04, ..., -2.0035e-02,
1.7146e-02, -3.0126e-02],
...,
[-3.7991e-01, 5.2831e-02, 2.2534e-02, ..., -1.8338e-02,
-6.9508e-02, 2.1317e-02],
[-3.8071e-01, 4.0900e-02, 2.8770e-02, ..., -2.1192e-02,
-5.2893e-02, 1.9734e-02],
[-4.7131e-01, 1.0947e-01, 1.1631e-02, ..., -3.4542e-02,
-2.3752e-02, -5.0506e-03]], grad_fn=<MmBackward0>)

这里我们就完成了注意力公式的最后一步

Attention(Q,K,V)=softmax(QKTdk)VAttention(Q,K,V) = softmax(\frac{QK^T}{\sqrt{d_k}}) \cdot V

具体来说:

部分形状含义
attention_scores(201, 201)softmax 后的注意力权重——每个 query token 关注所有 key token 的概率分布
V_first_head_first_layer(201, 64)每个 token 的 Value 向量——每个 token 带有的”内容信息”
attention_embeddings(201, 64)加权求和后的结果——每个 token 的注意力输出

本质上,它其实就是加权求和——对于每个 query token,用它的注意力权重去加权聚合所有 token 的 Value 信息:

对第 i 个 query token:
attention_embeddings[i] = Σ_j attention_scores[i, j] × V[j]
↑ ↑
权重(概率) Value 向量

举个例子,如果 “bank” 这个 token 的注意力分布是:

  • bank → the: 0.05
  • bank → near: 0.15
  • bank → river: 0.15
  • bank → bank: 0.60
  • …其他 ≈ 0.05

那么 “bank” 的输出向量 = 0.05×V(the) + 0.15×V(near) + 0.15×V(river) + 0.60×V(bank) + ...

总结一下:

**attention_embeddings 就是第 0 层中第 1 个注意力头的原始输出,我们总的下一步应该是:

  1. 重复同样的操作,计算出头 1 - 头 11 的 attention_embeddings
  2. 然后拼接这 12 个注意力头 -> (201,768)
  3. BertSelfOutput.dense 会做一次 Linear 投影 -> (201,768)
  4. 残差连接 + LayerNorm -> 得到注意力子层的正式输出;
  5. 继续经过 FFN (Intermediate + Output) -> 得到这一层的完整输出。

文章分享

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

08_attention_01
https://github.com/chunhuizhang/bilibili_vlogs/
作者
HAC
发布于
2026-06-20
许可协议
CC BY-NC-SA 4.0

评论区

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

音乐

暂未播放

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

文章目录