08_attention_01
import torchfrom torch import nnfrom bertviz.transformers_neuron_view import BertModel, BertConfigfrom transformers import BertTokenizer这里我们首次导入了 bertviz 库。
bertviz 是一个专门用来可视化 BERT 注意力机制的第三方库。它的核心功能就是把 BERT 模型每层每个注意力头的注意力权重画出来,让你”看到”模型在关注哪些词。
比如下面这种图就是 bertviz 生成的(注意力连线图):
[CLS] ────╮─────────────╮ After ╰──╮──────────╮│ stealing ╰─╮────────╮╮│ money ╰──╮─────╮╰╯ from ╰─╮────╯ the ╰─╮──╯ bank ←──←──←──╯ ← "bank" 和前面的词有很强的注意力BertModel 为什么从 bertviz 而不是 transformers 导入?
这是关键点。对比一下两条 import:
from transformers import BertModel, BertTokenizerfrom bertviz.transformers_neuron_view import BertModel, BertConfigfrom transformers import BertTokenizer原因:bertviz 对原版 BertModel 做了”偷梁换柱”
bertviz.transformers_neuron_view 里面的 BertModel 并不是 HuggingFace transformers 库的原始 BertModel,而是 bertviz 自己魔改过的版本。
你可以理解为:
transformers.BertModel → 标准的 BERT 模型(普通版)bertviz.BertModel → 被 bertviz 改造过的 BERT 模型(特制版✨)这个”特制版”在原始 BertModel 的基础上做了两件事:
- 注册了前向钩子(forward hooks)——在模型计算注意力时,自动把注意力权重矩阵截取并保存下来
- 整理成可视化友好的格式——把原始的注意力权重整理成 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, BertConfigfrom transformers import BertTokenizerBertModel和BertConfig→ 从 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() 时,它的内部自动帮我们做了两件事:
- 创建了一个默认的
BertConfig,并加载预训练模型的配置; - 创建模型并加载权重。
而在这里,我们是用 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 矩阵的形状变化是:
- 原始矩阵:
Q, K, V形状 =(batch, seq_len, 768) - 拆分为多头后重塑为:
(batch, seq_len, 12, 64),然后转置为(batch, 12, seq_len, 64) - 每个头的注意力分数计算:,其中
这里的 就是 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 层的架构。
整体结构
每个 BertLayer 由 3 大模块组成:
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,即
② 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(转置)!其中:
- 的形状:
(*, in_features),比如(batch, 768) - 的存储形状:
(out_features, in_features) = (768, 768) - 的形状:
(in_features, out_features) = (768, 768) - 结果 的形状:
(*, out_features)
如果存成 (in_features, out_features),计算就是 (不需要转置)。
用一张图对比
构造参数: 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=True,model_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] | attentions | tuple 包含 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.
这里的 attn、queries、keys 是 bertviz 魔改版 BertModel 通过前向钩子额外捕获的注意力计算中间产物,对应注意力公式的三个核心部分:
| key | 含义 | 对应公式中的 |
|---|---|---|
queries | 第 0 层的 Q 向量(每个 token 的 query 表示) | |
keys | 第 0 层的 K 向量(每个 token 的 key 表示) | |
attn | 第 0 层的注意力权重(softmax 后的概率分布) |
它们的关系:
queries 和 keys 做点积 → 缩放 → softmax → attn ↓ attn 再和 V 相乘 → 输出也就是说:
queries:当前 token “想关注别人” 的表示keys:当前 token “被关注” 的表示(别人来查它)attn: 经过 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) # 3model_output[0].shape # (1, 201, 768)三个维度的含义:
| 维度 | 值 | 来源 |
|---|---|---|
batch_size | 1 | 我们只传了 1 条文本 newsgroups_train.data[:1] |
seq_len | 201 | 这条文本被 tokenizer 分词后的长度(max_length=256,截断前为 201) |
hidden_size | 768 | config.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_state3.1.2 pooler_output — shape (1, 768)
model_output[1].shape # (1, 768)| 维度 | 值 | 来源 |
|---|---|---|
batch_size | 1 | 同上 |
hidden_size | 768 | 经过 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_output3.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_size | 1 | 同上 |
num_heads | 12 | config.num_attention_heads —— 每层 12 个注意力头 |
seq_len (query) | 201 | query 侧的 token 数量 |
seq_len (key) | 201 | key 侧的 token 数量 |
怎么来的: 这是每个头算出的注意力权重矩阵,形状来源是:
- 第 个头: 形状
(1, 201, 64), 形状(1, 201, 64) - →
(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) |
.weight | Q 的权重矩阵,形状 (768, 768) |
.weight[: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 200query 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_200Q_token_0 [ ↗ ↗ ↗ ]Q_token_1 [ ↗ ↗ ↗ ] ← 对每个 query 行 ... [ ]Q_token_200[ ↗ ↗ ↗ ] ↑ dim=-1:沿最后一维(列方向)做 softmaxdim=-1 的含义是:对每一行(每个 query token),把该行中所有列(所有 key token)的分数做 softmax,使得该行的值加起来等于 1。
为什么用 dim=-1 而不是 dim=0?
假设我们有一个 query 在关注 “bank” 这个词,它应该把注意力分配给句子中相关的词:
| the | bank | near | the | river | |
|---|---|---|---|---|---|
| bank (query) | 0.05 | 0.60 | 0.15 | 0.05 | 0.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 |
在注意力公式 中,我们希望对每个 query token 去关注所有 key token 的概率做归一化,所以用 dim=-1(即最后一个维度)——这是注意力机制的标准写法。
使用
dim=-1比dim=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_layerattention_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_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 - 头 11 的
attention_embeddings; - 然后拼接这 12 个注意力头 ->
(201,768); BertSelfOutput.dense会做一次 Linear 投影 ->(201,768);- 残差连接 + LayerNorm -> 得到注意力子层的正式输出;
- 继续经过 FFN (Intermediate + Output) -> 得到这一层的完整输出。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!