「Transformer核心必读」编码器深度解析:架构拆解、数据流动方向、PyTorch代码实现
1、编码器介绍
我们现在将视野拉高,系统、全面、深入地讲解 Transformer 编码器(Encoder) 的完整结构、设计动机、工作机制、数学细节、工程实现要点,以及它在不同模型中的演化与应用。
从 7 个核心维度 展开,力求做到: ✅ 概念清晰|✅ 技术严谨|✅ 覆盖全面|✅ 层次分明
一、整体架构:编码器是什么?
Transformer 编码器 是一个端到端的神经网络模块,其目标是:
将一个离散的 token 序列 x = ( x 1 , x 2 , . . . , x T ) x = (x_1, x_2, ..., x_T) x=(x1,x2,...,xT) 映射为一组上下文感知的连续向量表示 H = ( h 1 , h 2 , . . . , h T ) ∈ R T × d model H = (h_1, h_2, ..., h_T) \in \mathbb{R}^{T \times d_{\text{model}}} H=(h1,h2,...,hT)∈RT×dmodel,其中每个 h i h_i hi 都融合了整个输入序列的语义信息。
📐 标准结构(以原始 Transformer 为基础)
Input: [x₁, x₂, ..., x_T] ← Token IDs
Step 1: Embedding + Positional Encoding
↓
X ∈ ℝ^{T × d_model} ← 初始表示(可训练嵌入 + 位置编码)
Step 2: Stack of N Encoder Layers
↓
Layer 1 → Layer 2 → ... → Layer N
Step 3: Final Output
↓
H ∈ ℝ^{T × d_model} ← 上下文化表示(Contextualized Embeddings)
-
典型层数 N:
- 原始 Transformer(机器翻译):6 层
- BERT-base:12 层, d model = 768 d_{\text{model}} = 768 dmodel=768
- BERT-large:24 层, d model = 1024 d_{\text{model}} = 1024 dmodel=1024
- T5-small:6 层;T5-XXL:24 层
-
所有编码器层结构相同,但参数不共享(每层有独立的 MHSA 和 FFN 权重)。
二、输入处理:从 Token 到向量(Embedding + Positional Encoding)
编码器的输入不是原始字符串,而是经过两步转换的稠密向量:
- Token Embedding
- 将每个 token ID x i ∈ { 1 , 2 , . . . , V } x_i \in \{1, 2, ..., V\} xi∈{1,2,...,V}(V 为词表大小)映射为 d model d_{\text{model}} dmodel 维向量。
- 实现方式:
nn.Embedding(V, d_model) - 这是一个可学习的查找表,在预训练中与模型其他部分联合优化。
- 注意:这不同于 Word2Vec/GloVe 等静态嵌入,它是任务自适应、上下文无关的初始表示(后续由编码器层赋予上下文)。
- Positional Encoding(位置编码)
- 问题:自注意力机制对输入顺序不敏感(即 permutation-invariant),无法区分 “猫追狗” 和 “狗追猫”。
- 解决方案:显式注入位置信息。
▶ 原始 Transformer 使用固定正弦位置编码:
P
E
(
p
o
s
,
2
i
)
=
sin
(
p
o
s
10000
2
i
/
d
model
)
,
P
E
(
p
o
s
,
2
i
+
1
)
=
cos
(
p
o
s
10000
2
i
/
d
model
)
PE_{(pos, 2i)} = \sin\left(\frac{pos}{10000^{2i/d_{\text{model}}}}\right), \quad PE_{(pos, 2i+1)} = \cos\left(\frac{pos}{10000^{2i/d_{\text{model}}}}\right)
PE(pos,2i)=sin(100002i/dmodelpos),PE(pos,2i+1)=cos(100002i/dmodelpos)
- 优点:可泛化到训练时未见的序列长度。
- 缺点:无法学习位置模式。
▶ 现代模型(BERT、RoBERTa、T5)普遍使用可学习位置嵌入:
- 创建一个
nn.Embedding(max_seq_len, d_model),位置索引作为输入。 - 优点:更灵活,能捕捉特定位置的语义(如句首/句尾特殊性)。
- 缺点:最大长度受限(如 BERT 限制为 512)。
- 最终输入表示
X = E token + E position ∈ R T × d model X = E_{\text{token}} + E_{\text{position}} \in \mathbb{R}^{T \times d_{\text{model}}} X=Etoken+Eposition∈RT×dmodel
- 两者直接相加(而非拼接),保持维度一致。
- 有些模型(如 ALBERT)会在输入第一层前加 LayerNorm,但非主流。
三、核心处理:N 层编码器层的级联精炼
这是编码器的“心脏”。每一层都执行相同的计算流程,但逐层深化语义。
🔁 单层计算流程(注意:原始为 Post-LN,现代多用 Pre-LN)
▶ 原始 Transformer(Post-LN):
# 子层 1
attn_out = self_attn(x, x, x, mask=src_mask)
x = LayerNorm(x + Dropout(attn_out))
# 子层 2
ffn_out = ffn(x)
x = LayerNorm(x + Dropout(ffn_out))
▶ 现代主流(Pre-LN,如 T5、DeBERTa):
# 子层 1
x_norm = LayerNorm(x)
attn_out = self_attn(x_norm, x_norm, x_norm, mask=src_mask)
x = x + Dropout(attn_out)
# 子层 2
x_norm = LayerNorm(x)
ffn_out = ffn(x_norm)
x = x + Dropout(ffn_out)
⚠️ 重要澄清:
- BERT 实际使用的是 Post-LN(配合学习率 warmup)
- Pre-LN 因训练更稳定,已成为新模型首选(无需 warmup)
🌐 信息流分析
| 步骤 | 作用 | 是否跨 token |
|---|---|---|
| MHSA | 计算 token 间相关性,聚合全局信息 | ✅ 是(唯一跨 token 通信机制) |
| FFN | 对每个 token 独立进行非线性变换 | ❌ 否(但输入已是上下文感知的) |
| 残差连接 | 保留原始信息,防止梯度消失 | — |
| LayerNorm | 稳定激活分布,加速收敛 | — |
🧠 为什么需要多层堆叠?
单层只能捕获浅层交互。堆叠 N 层形成层次化表征:
| 层级 | 学习内容 | 实证证据 |
|---|---|---|
| 底层(1–3 层) | 词法、局部搭配、基本语法(如 n-gram) | 注意力头聚焦邻近 token |
| 中层(4–8 层) | 句法结构(主谓一致、依存关系、成分边界) | 出现“句法头”(syntactic heads) |
| 高层(9–12+ 层) | 语义角色、指代消解、任务意图、情感 | 注意力分布稀疏且语义导向 |
📊 实验支持:
- BERTology 研究(Tenney et al., 2019)通过探针实验验证了这种分层特性。
- 注意力可视化显示底层关注局部,高层关注语义相关词(如动词-宾语)。
四、输出表示:上下文化嵌入的性质与用途
编码器的最终输出 H = [ h 1 , . . . , h T ] H = [h_1, ..., h_T] H=[h1,...,hT] 具有以下关键特性:
✅ 核心性质
- 上下文感知(Contextualized):同一词在不同句子中向量不同。
- 全局依赖(Global Dependency):每个 h i h_i hi 理论上可访问所有 x j x_j xj(通过 MHSA)。
- 向量分布集中(Low Anisotropy):向量倾向于聚集在低维锥体中(可通过后处理改善)。
- 任务通用(Task-Agnostic):预训练编码器可迁移到多种下游任务。
🛠️ 下游任务如何使用这些表示?
| 任务类型 | 使用方式 | 示例 |
|---|---|---|
| 句子级分类 | 取特殊 token(如 BERT 的 [CLS])的输出向量 | 情感分析、NLI |
| Token 级标注 | 每个 token 的输出接分类头 | NER、POS tagging |
| 问答(抽取式) | 用两个向量分别预测答案起止位置 | SQuAD |
| 语义相似度 | 对两个句子的 [CLS] 向量计算余弦相似度 | STS-B |
| 作为解码器输入 | 在 Seq2Seq 中,H 作为 cross-attention 的 Key/Value | T5、BART |
💡 注意:
[CLS]是 BERT 引入的特殊 token,原始 Transformer 编码器没有此设计。它之所以有效,是因为在每层都能通过自注意力看到所有 token。
五、关键技术细节与工程考量
- Masking 策略
- Padding Mask:屏蔽
<pad>token,避免无效信息干扰。- 形状:
(B, T)→ 扩展为(B, 1, T)用于注意力 - 实现:在 softmax 前将 mask 位置设为
-inf
- 形状:
- 无因果掩码(Causal Mask):编码器不需要像解码器那样遮盖未来信息,因为它是双向建模。
- 参数规模估算
以 BERT-base 为例(12 层):
- Embedding 层: V × d model ≈ 30 k × 768 ≈ 23 M V \times d_{\text{model}} ≈ 30k \times 768 ≈ 23M V×dmodel≈30k×768≈23M
- 每层 MHSA: 4 × d model 2 ≈ 4 × 768 2 ≈ 2.4 M 4 \times d_{\text{model}}^2 ≈ 4 \times 768^2 ≈ 2.4M 4×dmodel2≈4×7682≈2.4M(Q/K/V/O 投影)
- 每层 FFN: 2 × d model × d ff ≈ 2 × 768 × 3072 ≈ 4.7 M 2 \times d_{\text{model}} \times d_{\text{ff}} ≈ 2 \times 768 \times 3072 ≈ 4.7M 2×dmodel×dff≈2×768×3072≈4.7M
- 总参数 ≈ 110M
📌 编码器是 Transformer 中参数最密集的部分(尤其 FFN 占比超 60%)。
- 计算复杂度
- MHSA: O ( T 2 ⋅ d model ) O(T^2 \cdot d_{\text{model}}) O(T2⋅dmodel)(因注意力矩阵为 T × T T \times T T×T)
- FFN: O ( T ⋅ d model ⋅ d ff ) O(T \cdot d_{\text{model}} \cdot d_{\text{ff}}) O(T⋅dmodel⋅dff)
- 总复杂度:随序列长度平方增长 → 长文本瓶颈
🔜 这也是 Longformer、BigBird 等模型引入稀疏注意力的原因。
六、在不同模型中的演化与变体
| 模型 | 编码器特点 | 关键差异 |
|---|---|---|
| 原始 Transformer | 6 层,Post-LN,正弦位置编码 | 用于机器翻译,需配合解码器 |
| BERT | 12/24 层,Post-LN + warmup,可学习位置嵌入,含 [CLS]/[SEP] | 首个大规模预训练纯编码器 |
| RoBERTa | 同 BERT 架构,移除 NSP,更大 batch/数据 | 更强的 MLM 预训练 |
| ALBERT | 参数共享(所有层用同一组权重) | 显著减少参数量 |
| DeBERTa | 内容与位置向量分离,引入相对位置偏置 | 更精准的位置建模 |
| T5 | Pre-LN,可学习位置嵌入,统一文本到文本框架 | 编码器仅用于理解输入 |
🌟 趋势:从 Post-LN → Pre-LN;从固定位置编码 → 可学习/相对位置;从绝对位置 → 相对位置增强。
七、常见误解澄清
| 误解 | 正确理解 |
|---|---|
| “编码器输出的是词向量” | ❌ 它输出的是上下文向量,同一词在不同句子中不同 |
| “FFN 也能让 token 互相通信” | ❌ FFN 是 position-wise 的,只有 MHSA 能跨 token 通信 |
| “位置编码只是加噪声” | ❌ 它是结构性信号,对模型理解顺序至关重要 |
| “更多层一定更好” | ⚠️ 存在收益递减;深层模型需配合 deepnorm 等技巧 |
| “编码器能生成文本” | ❌ 编码器是理解模块;生成需解码器(或仅用解码器如 GPT) |
✅ 终极总结:编码器的本质
Transformer 编码器是一个由可学习嵌入、位置编码和 N 个精心设计的编码器层组成的深度神经网络。它通过多头自注意力机制建立 token 间的全局依赖,并通过前馈网络进行非线性特征提炼,在残差连接和层归一化的保障下,逐层构建从词法到语义的层次化上下文表示。其输出是一组动态、上下文感知的向量,构成了现代自然语言理解系统的语义基石。
你可以将其视为:
- 语义蒸馏器:把原始符号转化为富含意义的向量
- 信息整合引擎:将局部观察升华为全局理解
- 预训练知识容器:在海量文本中学习语言的统计规律与结构
正是这个看似简单的“编码器”,支撑了从 BERT 到 T5 再到今天大模型的绝大多数 NLP 突破。理解它,就理解了现代 NLP 的半壁江山。
2、编码器数据流向
🧭 总览:数据流向的宏观路径
Raw Tokens
↓ (Tokenization)
Token IDs: [w₁, w₂, ..., w_T] ∈ ℤ^T
↓ (Embedding Lookup)
Token Embeddings: E_tok ∈ ℝ^{T × d_model}
↓ (+ Positional Encoding)
Input Representation: X⁰ ∈ ℝ^{T × d_model}
↓ (Layer 1)
X¹ ∈ ℝ^{T × d_model}
↓ (Layer 2)
X² ∈ ℝ^{T × d_model}
⋮
↓ (Layer N)
Xᴺ ∈ ℝ^{T × d_model}
↓ (Final LayerNorm) ← 新增这一步!
H ∈ ℝ^{T × d_model} ← Final Contextualized Output
✅ 关键性质:
- 所有中间表示 保持相同形状:
(T, d_model)- 每一层都是 等维映射(isometric transformation)
- 信息通过 自注意力(跨 token) + FFN(单 token 非线性) 不断精炼
- 最终输出 H 经过顶层 LayerNorm,确保表示分布稳定
下面我们从 输入 → 嵌入 → 位置编码 → 第1层 → … → 第N层 → 输出 逐步展开。
🔹 阶段 1:原始输入 → Token IDs
-
输入:字符串序列,如
"The cat sat on the mat." -
处理:经 tokenizer(如 WordPiece、BPE)切分为 subword tokens:
tokens = ["[CLS]", "the", "cat", "sat", "on", "the", "mat", ".", "[SEP]"] -
输出:token ID 序列
w = [ w 1 , w 2 , . . . , w T ] ∈ { 0 , 1 , . . . , V − 1 } T \mathbf{w} = [w_1, w_2, ..., w_T] \in \{0, 1, ..., V-1\}^T w=[w1,w2,...,wT]∈{0,1,...,V−1}T- T T T:序列长度(含特殊 token)
- V V V:词表大小(如 BERT: 30522)
📌 注意:此阶段无数值计算,仅为符号到整数的映射。
🔹 阶段 2:Token Embedding(词嵌入)
-
操作:查表(embedding lookup)
-
模块:
nn.Embedding(V, d_model) -
数学:
E tok = Embed ( w ) = [ e w 1 , e w 2 , . . . , e w T ] ⊤ ∈ R T × d model \mathbf{E}_{\text{tok}} = \text{Embed}(\mathbf{w}) = [\mathbf{e}_{w_1}, \mathbf{e}_{w_2}, ..., \mathbf{e}_{w_T}]^\top \in \mathbb{R}^{T \times d_{\text{model}}} Etok=Embed(w)=[ew1,ew2,...,ewT]⊤∈RT×dmodel- 每个 e w i ∈ R d model \mathbf{e}_{w_i} \in \mathbb{R}^{d_{\text{model}}} ewi∈Rdmodel 是可学习向量
-
形状变化:
- 输入:
(T,)(整数索引) - 输出:
(T, d_model)(浮点向量)
- 输入:
💡 此时每个 token 向量彼此独立,无上下文、无顺序信息。
🔹 阶段 3:Positional Encoding(位置编码)
-
目的:注入序列顺序信息(因 MHSA 本身对顺序不敏感)
-
方式:向量加法(非拼接)
-
数学:
X ( 0 ) = E tok + E pos ∈ R T × d model \mathbf{X}^{(0)} = \mathbf{E}_{\text{tok}} + \mathbf{E}_{\text{pos}} \in \mathbb{R}^{T \times d_{\text{model}}} X(0)=Etok+Epos∈RT×dmodel- E pos = [ p e 1 , p e 2 , . . . , p e T ] ⊤ \mathbf{E}_{\text{pos}} = [pe_1, pe_2, ..., pe_T]^\top Epos=[pe1,pe2,...,peT]⊤,其中 p e t ∈ R d model pe_t \in \mathbb{R}^{d_{\text{model}}} pet∈Rdmodel
▶ 两种主流实现:
| 类型 | 公式/实现 | 特点 |
|---|---|---|
| 正弦编码(原始 Transformer) | p e ( t , 2 i ) = sin ( t / 10000 2 i / d ) pe_{(t,2i)} = \sin(t / 10000^{2i/d}) pe(t,2i)=sin(t/100002i/d), p e ( t , 2 i + 1 ) = cos ( ⋯ ) pe_{(t,2i+1)} = \cos(\cdots) pe(t,2i+1)=cos(⋯) | 固定、可外推、不可学习 |
| 可学习位置嵌入(BERT/T5) | nn.Embedding(max_len, d_model),输入位置索引 t | 可训练、更灵活、长度受限 |
- 形状不变:仍为
(T, d_model) - 语义意义:现在每个向量同时包含 “是什么词” + “在什么位置”
⚠️ 注意:没有 LayerNorm 在此阶段(除非像 ALBERT 那样显式添加,但非常规)
🔹 阶段 4:进入第 1 个编码器层(以 Pre-LN 架构为例)
我们以 现代主流 Pre-LN 架构 为例(如 T5、DeBERTa),因其训练更稳定。
(若用 Post-LN,仅 LayerNorm 位置不同,数据流本质一致)
设当前输入为 X ( l − 1 ) ∈ R T × d model \mathbf{X}^{(l-1)} \in \mathbb{R}^{T \times d_{\text{model}}} X(l−1)∈RT×dmodel,输出为 X ( l ) \mathbf{X}^{(l)} X(l)
🔄 子步骤 4.1:LayerNorm(MHSA 前)
-
操作:对每个 token 向量独立做 Layer Normalization
-
数学(对每个位置 t t t):
x ~ t = LayerNorm ( x t ( l − 1 ) ) = γ ⋅ x t ( l − 1 ) − μ t σ t 2 + ϵ + β \tilde{\mathbf{x}}_t = \text{LayerNorm}(\mathbf{x}_t^{(l-1)}) = \gamma \cdot \frac{\mathbf{x}_t^{(l-1)} - \mu_t}{\sqrt{\sigma_t^2 + \epsilon}} + \beta x~t=LayerNorm(xt(l−1))=γ⋅σt2+ϵxt(l−1)−μt+β- μ t , σ t 2 \mu_t, \sigma_t^2 μt,σt2:在 d model d_{\text{model}} dmodel 维上计算均值/方差
- γ , β ∈ R d model \gamma, \beta \in \mathbb{R}^{d_{\text{model}}} γ,β∈Rdmodel:可学习缩放/偏移
-
形状:仍为
(T, d_model) -
作用:稳定激活分布,加速训练
📌 注意:LayerNorm 是 position-wise 的,不跨 token。
🔄 子步骤 4.2:多头自注意力(MHSA)
-
输入: X ~ = [ x ~ 1 , . . . , x ~ T ] ⊤ ∈ R T × d model \tilde{\mathbf{X}} = [\tilde{\mathbf{x}}_1, ..., \tilde{\mathbf{x}}_T]^\top \in \mathbb{R}^{T \times d_{\text{model}}} X~=[x~1,...,x~T]⊤∈RT×dmodel
-
投影(线性变换):
Q = X ~ W Q ∈ R T × d k K = X ~ W K ∈ R T × d k V = X ~ W V ∈ R T × d v \begin{aligned} \mathbf{Q} &= \tilde{\mathbf{X}} \mathbf{W}^Q \in \mathbb{R}^{T \times d_k} \\ \mathbf{K} &= \tilde{\mathbf{X}} \mathbf{W}^K \in \mathbb{R}^{T \times d_k} \\ \mathbf{V} &= \tilde{\mathbf{X}} \mathbf{W}^V \in \mathbb{R}^{T \times d_v} \end{aligned} QKV=X~WQ∈RT×dk=X~WK∈RT×dk=X~WV∈RT×dv- 通常 d k = d v = d model / h d_k = d_v = d_{\text{model}} / h dk=dv=dmodel/h, h h h 为头数(如 h=12, d_k=64)
- W Q , W K , W V ∈ R d model × d k \mathbf{W}^Q, \mathbf{W}^K, \mathbf{W}^V \in \mathbb{R}^{d_{\text{model}} \times d_k} WQ,WK,WV∈Rdmodel×dk:可学习权重
-
多头注意力计算(对每个头 i i i):
head i = softmax ( Q i K i ⊤ d k + M ) V i \text{head}_i = \text{softmax}\left( \frac{\mathbf{Q}_i \mathbf{K}_i^\top}{\sqrt{d_k}} + \mathbf{M} \right) \mathbf{V}_i headi=softmax(dkQiKi⊤+M)Vi-
M
∈
R
T
×
T
\mathbf{M} \in \mathbb{R}^{T \times T}
M∈RT×T:实际实现中,padding mask 通常为
(B, 1, T),广播后作用于(B, h, T, T)的注意力分数- 有效位置:0,屏蔽位置:-∞
- softmax 沿 最后一维(key 维度) 归一化 → 得到注意力权重
-
M
∈
R
T
×
T
\mathbf{M} \in \mathbb{R}^{T \times T}
M∈RT×T:实际实现中,padding mask 通常为
-
拼接与输出投影:
MultiHead = Concat ( head 1 , . . . , head h ) W O ∈ R T × d model \text{MultiHead} = \text{Concat}(\text{head}_1, ..., \text{head}_h) \mathbf{W}^O \in \mathbb{R}^{T \times d_{\text{model}}} MultiHead=Concat(head1,...,headh)WO∈RT×dmodel- W O ∈ R d model × d model \mathbf{W}^O \in \mathbb{R}^{d_{\text{model}} \times d_{\text{model}}} WO∈Rdmodel×dmodel
-
Dropout:
A = Dropout ( MultiHead ) ∈ R T × d model \mathbf{A} = \text{Dropout}(\text{MultiHead}) \in \mathbb{R}^{T \times d_{\text{model}}} A=Dropout(MultiHead)∈RT×dmodel -
残差连接:
X temp ( l ) = X ( l − 1 ) + A \mathbf{X}^{(l)}_{\text{temp}} = \mathbf{X}^{(l-1)} + \mathbf{A} Xtemp(l)=X(l−1)+A
✅ 关键信息流:
- 跨 token 通信发生在此:每个输出 token 是所有输入 token 的加权和
- mask 决定可见范围:编码器使用 padding mask,所有有效 token 互相可见(双向)
- 输出形状不变:
(T, d_model)
🔄 子步骤 4.3:LayerNorm(FFN 前)
-
对 X temp ( l ) \mathbf{X}^{(l)}_{\text{temp}} Xtemp(l) 做 LayerNorm(为 FFN 准备):
X ~ ffn = LayerNorm ( X temp ( l ) ) \tilde{\mathbf{X}}_{\text{ffn}} = \text{LayerNorm}(\mathbf{X}^{(l)}_{\text{temp}}) X~ffn=LayerNorm(Xtemp(l)) -
形状:
(T, d_model)
📌 注意:Pre-LN 架构中,每层仅有两个 LayerNorm,分别位于 MHSA 和 FFN 之前。
🔄 子步骤 4.4:前馈网络(FFN)
-
结构:两层 MLP,中间扩维
FFN ( x ) = W 2 ⋅ GELU ( W 1 x + b 1 ) + b 2 \text{FFN}(\mathbf{x}) = \mathbf{W}_2 \cdot \text{GELU}(\mathbf{W}_1 \mathbf{x} + \mathbf{b}_1) + \mathbf{b}_2 FFN(x)=W2⋅GELU(W1x+b1)+b2- W 1 ∈ R d ff × d model \mathbf{W}_1 \in \mathbb{R}^{d_{\text{ff}} \times d_{\text{model}}} W1∈Rdff×dmodel,通常 d ff = 4 × d model d_{\text{ff}} = 4 \times d_{\text{model}} dff=4×dmodel
- W 2 ∈ R d model × d ff \mathbf{W}_2 \in \mathbb{R}^{d_{\text{model}} \times d_{\text{ff}}} W2∈Rdmodel×dff
- GELU / ReLU 引入非线性
- 在第一层之后其实还有个 Dropout,但 Dropout 不属于模型组件,也不是核心内容,所以公式中并未体现出来
-
计算方式:对每个 token 独立应用(position-wise)
F = FFN ( X ~ ffn ) ∈ R T × d model \mathbf{F} = \text{FFN}(\tilde{\mathbf{X}}_{\text{ffn}}) \in \mathbb{R}^{T \times d_{\text{model}}} F=FFN(X~ffn)∈RT×dmodel -
Dropout + 残差:
X ( l ) = X temp ( l ) + Dropout ( F ) \mathbf{X}^{(l)} = \mathbf{X}^{(l)}_{\text{temp}} + \text{Dropout}(\mathbf{F}) X(l)=Xtemp(l)+Dropout(F)
✅ 关键信息流:
- 无跨 token 通信:FFN 是“单兵作战”
- 但输入已是上下文感知的,所以 FFN 是在“已有共识”基础上做个人决策
- 非线性能力主要来自此处(MHSA 是线性加权)
🔁 阶段 5:重复 N 次 → 层间信息演化
上述过程在 每一层重复,但参数不共享,因此每层学习不同的表示模式。
📈 信息演化示例(以句子 “The cat sat on the mat.” 为例)
| 层 | “cat” 的表示变化 | “mat” 的表示变化 |
|---|---|---|
| X⁰(输入) | 仅含 “cat” 词义 + 位置2 | 仅含 “mat” 词义 + 位置6 |
| X¹(第1层后) | 知道邻近词 “The”, “sat” | 知道邻近词 “the”, “.” |
| X³(第3层后) | 知道主语是 “cat”,谓语是 “sat” | 知道 “mat” 是宾语,被 “on” 修饰 |
| X⁶(第6层后) | 理解 “cat” 是动作发起者 | 理解 “mat” 是地点载体 |
| X¹²(最终) | 向量可用于判断情感/实体类型 | 向量可用于问答/指代消解 |
🔬 实证支持:通过 probing classifier 或 attention rollout 可观测到这种层次化。
🔚 阶段 6:最终输出 H \mathbf{H} H
-
计算:
H = LayerNorm ( X ( N ) ) \mathbf{H} = \text{LayerNorm}(\mathbf{X}^{(N)}) H=LayerNorm(X(N))
其中 X ( N ) \mathbf{X}^{(N)} X(N) 是第 N N N 个编码器层的输出。 -
形状: H ∈ R T × d model \mathbf{H} \in \mathbb{R}^{T \times d_{\text{model}}} H∈RT×dmodel
-
性质:
- 每个
h
t
\mathbf{h}_t
ht 都融合了整句话的信息
→ 不再是孤立的词向量,而是根据整个输入序列动态生成的上下文表示。
(例如:“bank” 在 “river bank” 和 “bank account” 中会得到不同的 h t \mathbf{h}_t ht) - 向量空间具有 语义连续性:相似语境下的 token 会产生相近的向量
- 已通过最终 LayerNorm 归一化,数值分布稳定,可直接用于下游任务(如分类、问答、解码器输入等)
- 每个
h
t
\mathbf{h}_t
ht 都融合了整句话的信息
💡 注:虽然某些简化实现可能省略最终 LayerNorm,但 原版 Transformer 及绝大多数标准库(如 PyTorch
nn.TransformerEncoder)均包含此步骤。它有助于稳定后续模块(如池化或解码器)的输入分布。
✅ 补充说明
📌 关于最终 LayerNorm 的澄清:
在 Post-LN 架构中,每个EncoderLayer内部以 LayerNorm 结尾,因此第 N 层输出Xᴺ已是归一化的,此时“最终 LayerNorm”就是第 N 层的一部分。
在 Pre-LN 架构中,每个层以残差连接结尾,因此需在所有层后显式添加一个 LayerNorm。
无论哪种架构,最终交付给下游的表示 H 都应是经过 LayerNorm 的——这是实践中的共识。💡 如果采用的是 Post-LN 架构(即每个 EncoderLayer 内部以 LayerNorm 结尾),那么“Final LayerNorm”本质上就是第 N 层的一部分,但为了流程完整性和与 Pre-LN 架构统一表述,显式列出仍是最佳实践。
🧪 补充:Batch 维度与实际代码中的数据流
在实际训练中,输入是 batched 的:
- 输入 token IDs:
(B, T) - Token Embedding:
(B, T, d_model) - Positional Encoding:可学习位置嵌入为
(max_len, d_model),通过索引选择前 T 个并广播加到(B, T, d_model);正弦编码可直接生成(T, d_model)并广播 - 每层计算:所有操作支持 batch 并行
- MHSA:
(B, T, d_model)→ Q/K/V →(B, h, T, d_k)→ attn →(B, T, d_model) - FFN:
(B, T, d_model)→(B, T, d_ff)→(B, T, d_model)
- MHSA:
✅ 关键:batch 维度 不参与任何交互,各样本独立处理。
🧩 特殊情况:Padding 与 Mask 处理细节
-
Padding token(如 ID=0)在 embedding 后仍为向量,但会被 mask 屏蔽
-
Padding Mask 生成:
# input_ids: (B, T) src_key_padding_mask = (input_ids == pad_token_id) # (B, T), True for padding -
在 MHSA 中使用:
- 通常扩展为
(B, 1, T)(用于 key mask,在注意力分数(B, h, T, T)上广播) - 在 softmax 前:
attn_scores.masked_fill(mask.unsqueeze(1).unsqueeze(2), -inf)(PyTorch 风格)
- 通常扩展为
✅ 结果:padding 位置的输出向量理论上应接近输入(因无有效信息流入),但实践中仍会受残差影响。
📊 数据流向总结表
| 阶段 | 输入形状 | 操作 | 输出形状 | 是否跨 token | 是否可学习 |
|---|---|---|---|---|---|
| Tokenization | str | 分词 | (T,) | — | — |
| Token Embedding | (T,) | 查表 | (T, d_model) | ❌ | ✅ |
| Positional Encoding | (T, d_model) | 加法 | (T, d_model) | ❌ | ✅(若可学习) |
| LayerNorm (Pre-MHSA) | (T, d_model) | 归一化 | (T, d_model) | ❌ | ✅(γ, β) |
| MHSA | (T, d_model) | QKV + attn + proj | (T, d_model) | ✅ | ✅(W^Q/K/V/O) |
| Residual + Dropout | (T, d_model) | 加法 + dropout | (T, d_model) | — | ✅(dropout 随机) |
| LayerNorm (Pre-FFN) | (T, d_model) | 归一化 | (T, d_model) | ❌ | ✅(γ, β) |
| FFN | (T, d_model) | 两层 MLP | (T, d_model) | ❌ | ✅(W₁, W₂) |
| Residual + Dropout | (T, d_model) | 加法 + dropout | (T, d_model) | — | ✅ |
| (重复 N 次) | … | … | … | … | … |
| Final LayerNorm | (T, d_model) | 归一化 | (T, d_model) | ❌ | ✅(γ, β) |
| Final Output | — | — | (T, d_model) | — | — |
✅ 终极结论:数据在编码器中的本质流动
数据以
(T, d_model)的张量形式贯穿整个编码器。它首先被赋予词义和位置身份,然后在每一层中经历两次精炼:第一次通过自注意力机制“倾听整个句子”,实现全局信息融合;第二次通过前馈网络“独立思考”,进行非线性特征转换。残差连接确保原始信息不丢失,层归一化保障训练稳定性。经过 N 次这样的“集体讨论 + 个人内化”循环,初始的孤立符号最终升华为富含上下文、句法与语义的动态向量表示。
这不仅是数据的流动,更是意义的生成过程——而 Transformer 编码器,正是这一过程的自动化引擎。
3、nn.ModuleList
📘 一、nn.ModuleList 是什么?
nn.ModuleList 是 PyTorch 提供的一个 特殊容器类(container class),继承自 torch.nn.Module,用于存储多个 nn.Module 子模块的有序列表。
它在功能上类似于 Python 内置的 list,但具有深度集成到 PyTorch 模块系统的能力:
- 自动注册子模块(submodules)
- 自动收集参数(parameters)和缓冲区(buffers)
- 支持设备迁移(
.to(device))、训练/评估模式切换(.train()/.eval()) - 可被
torch.save()/torch.load()正确序列化
✅ 核心定位:
nn.ModuleList是 PyTorch 中实现可变数量层堆叠(如 N 层 Transformer 编码器)的标准工具。注意:这里的“动态”指结构在初始化时确定但层数可配置,而非训练过程中动态改变网络拓扑。
🎯 二、为什么需要 nn.ModuleList?普通 list 不行吗?
❌ 使用普通 Python list 的致命问题
class BadNet(nn.Module):
def __init__(self):
super().__init__()
self.layers = [] # 普通 list
for _ in range(3):
self.layers.append(nn.Linear(10, 10))
def forward(self, x):
for layer in self.layers:
x = layer(x)
return x
问题分析:
| 问题 | 后果 |
|---|---|
| 参数未注册 | model.parameters() 返回空迭代器 → 优化器无法更新权重 |
| 设备不迁移 | model.to('cuda') 不会将 Linear 层移到 GPU |
| 状态丢失 | torch.save(model.state_dict()) 不包含这些层的参数 |
| 训练模式失效 | model.train() 不会递归设置子模块为训练模式 |
🔥 根本原因:PyTorch 的模块注册机制依赖于
__setattr__魔术方法。只有通过self.xxx = module或使用nn.ModuleList/nn.ModuleDict等注册容器,才能触发子模块注册。
✅ 使用 nn.ModuleList 的正确方式
class GoodNet(nn.Module):
def __init__(self):
super().__init__()
self.layers = nn.ModuleList([
nn.Linear(10, 10) for _ in range(3)
])
def forward(self, x):
for layer in self.layers:
x = layer(x)
return x
此时:
- 所有
Linear层被自动注册为GoodNet的子模块 list(model.named_modules())包含它们- 参数、设备、模式等全部正常工作
📜 三、函数签名与初始化参数详解
torch.nn.ModuleList(modules=None)
参数说明:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
modules | iterable of nn.Module or None | None | 一个可迭代对象(如 list, tuple),其中每个元素应为 nn.Module 或其子类。若为 None,则创建空的 ModuleList。 |
⚠️ 重要约束:
-
所有非
None元素必须是nn.Module。尝试放入非模块对象会立即报错:nn.ModuleList([1, 2, 3]) # RuntimeError: module must be a Module subclass -
None值是否允许?
在 PyTorch ≥ 1.8 中,ModuleList允许包含None(例如用于占位),但强烈不推荐,因为:None不是nn.Module,无参数、无 forward 方法;- 在
forward中需额外判空,易出错; - 序列化/反序列化可能引发问题。
# 技术上可行(PyTorch ≥ 1.8),但不推荐 mlist = nn.ModuleList([nn.Linear(10, 10), None])
🔧 四、常用操作(方法与行为)
nn.ModuleList 实现了 Python list 的大部分接口,但仅限于模块操作。
- 创建
# 空列表
mlist = nn.ModuleList()
# 从可迭代对象初始化
mlist = nn.ModuleList([nn.Linear(10,5), nn.ReLU()])
- 添加模块
| 方法 | 说明 | 示例 |
|---|---|---|
.append(module) | 在末尾添加一个模块 | mlist.append(nn.Dropout(0.5)) |
.extend(modules) | 添加多个模块(接受可迭代对象) | mlist.extend([nn.BatchNorm1d(10), nn.Linear(10,2)]) |
.insert(i, module) | 在索引 i 处插入模块 | mlist.insert(0, nn.Embedding(100, 10)) |
💡 注意:
.extend()内部会遍历输入并逐个调用.append(),确保每个模块都被正确注册。
- 访问与遍历
| 操作 | 说明 |
|---|---|
mlist[i] | 获取索引为 i 的模块(支持负索引) |
len(mlist) | 返回模块数量 |
for m in mlist: | 遍历所有模块(顺序保持) |
mlist.index(module) | 查找模块首次出现的索引(基于 is 比较) |
- 修改与删除
| 方法 | 说明 | 示例 |
|---|---|---|
mlist[i] = new_module | 替换索引 i 处的模块 | mlist[0] = nn.Conv1d(1, 16, 3) |
del mlist[i] | 删除索引 i 处的模块 | del mlist[1] |
mlist.pop(i=-1) | 弹出并返回索引 i 的模块 | last_layer = mlist.pop() |
mlist.clear() | 清空所有模块(PyTorch ≥ 1.4 即支持,非 1.8) | mlist.clear() |
⚠️ 注意:替换或删除模块后,原模块将不再被父模块管理(参数不会出现在
state_dict中)。
- 查询与检查
| 方法 | 说明 |
|---|---|
mlist.count(module) | 统计某模块出现次数(基于 is) |
module in mlist | 判断模块是否在列表中(基于 is) |
🧠 五、内部机制:PyTorch 如何管理 ModuleList?
理解这一点至关重要。
- 子模块注册原理
当你执行:
self.layers = nn.ModuleList([layer1, layer2])
PyTorch 会:
-
调用
nn.Module.__setattr__('layers', module_list) -
触发子模块注册逻辑
-
将
module_list本身注册为子模块 -
同时,
ModuleList的__init__和append等方法会递归注册其内部每个非None模块 -
参数收集流程
调用 model.parameters() 时:
- 遍历所有直接子模块(包括
ModuleList) - 对
ModuleList,递归遍历其每个非None元素 - 收集所有
Parameter对象
- 设备迁移
model.to('cuda') 会:
- 递归调用所有子模块的
.to('cuda') ModuleList会对其每个非None元素调用.to('cuda')
- 序列化
torch.save(model.state_dict()) 包含:
layers.0.weight,layers.0.biaslayers.1.running_mean, etc.
命名规则:{parent_name}.{list_name}.{index}.{param_name}
🆚 六、与其他容器的详细对比
| 特性 | nn.ModuleList | nn.ModuleDict | nn.Sequential | 普通 list |
|---|---|---|---|---|
是否继承 nn.Module | ✅ | ✅ | ✅ | ❌ |
| 自动注册子模块 | ✅ | ✅ | ✅ | ❌ |
| 保留顺序 | ✅(索引访问) | ✅(Python 3.7+ 插入顺序) | ✅ | ✅ |
| 支持任意控制流 | ✅(需手动调用) | ✅ | ❌(固定顺序执行) | ✅(但参数不注册) |
| 命名访问 | ❌(仅索引) | ✅(字符串 key) | ❌ | ❌ |
| 自动 forward | ❌ | ❌ | ✅ | ❌ |
| 典型用途 | Transformer 层数组、可配置层数 | 多任务头、命名分支 | 简单链式结构 | 禁止用于可训练模块 |
何时选择哪个?
ModuleList:需要有序、可索引、自定义调用逻辑的模块集合(如 N 层相同结构)ModuleDict:需要按名称访问不同功能模块(如heads['cls'],heads['reg'])Sequential:简单串行,无分支、无跳连、无条件逻辑- 普通
list:仅用于存储非参数对象(如 loss 函数、配置字典)
🧪 七、典型应用场景(附代码)
场景 1:Transformer 编码器堆叠
encoder_layers = nn.ModuleList([
EncoderLayer(d_model, num_heads) for _ in range(num_layers)
])
场景 2:多尺度特征提取(U-Net)
down_layers = nn.ModuleList([
nn.Conv2d(in_ch, out_ch, 3),
nn.MaxPool2d(2),
...
])
场景 3:动态深度网络(根据超参数调整)
class ConfigurableNet(nn.Module):
def __init__(self, num_layers):
super().__init__()
self.layers = nn.ModuleList([
nn.Linear(10, 10) for _ in range(num_layers)
])
def forward(self, x):
for layer in self.layers:
x = layer(x)
return x
⚠️ 注意:这里“动态”指初始化时根据
num_layers构建,而非在forward中改变结构。
场景 4:残差连接中的跳跃点
class ResNetBlock(nn.Module):
def __init__(self, num_blocks):
super().__init__()
self.blocks = nn.ModuleList([
BasicBlock() for _ in range(num_blocks)
])
def forward(self, x):
for block in self.blocks:
x = x + block(x) # 残差
return x
⚠️ 八、常见误区与陷阱
误区 1:以为 ModuleList 会自动 forward
# 错误!
model = nn.ModuleList([nn.Linear(10,1), nn.ReLU()])
output = model(x) # ❌ ModuleList 没有 forward 方法!
✅ 必须手动遍历:
for layer in model:
x = layer(x)
误区 2:用 += 或 list 拼接
layers = nn.ModuleList([nn.Linear(10,5)])
layers += [nn.Linear(5,1)] # ❌ 这会创建新 list,破坏注册!
✅ 正确做法:
layers.extend([nn.Linear(5,1)])
误区 3:在 forward 中动态创建 ModuleList 或层
def forward(self, x):
layers = nn.ModuleList([nn.Linear(10,10)]) # ❌ 每次 forward 都新建!
return layers[0](x)
→ 参数不会被优化器跟踪!所有模块必须在 __init__ 中创建。
误区 4:放入非 Module 对象
nn.ModuleList([lambda x: x * 2]) # ❌ 报错
→ 若需存储函数,用普通 list 或 dict。
🧩 九、高级技巧
from copy import deepcopy
base_layer = EncoderLayer(...)
layers = nn.ModuleList([deepcopy(base_layer) for _ in range(N)])
✅ 确保每层参数独立(原始论文做法)
技巧 2:与 torch.jit.script 兼容
ModuleList 支持 TorchScript,但需注意:
- 不能在
forward中修改ModuleList(如append) - 索引必须是常量或张量(不能是动态 Python 变量在 script 模式下)
技巧 3:自定义命名(通过父模块)
虽然 ModuleList 自身不支持命名,但可通过父模块间接实现:
class MyModel(nn.Module):
def __init__(self):
super().__init__()
self.layer_0 = nn.Linear(10, 10)
self.layer_1 = nn.Linear(10, 10)
# 但这样失去了“列表”的便利性
→ 更推荐用 ModuleDict 实现命名。
📊 十、性能与内存考量
- 内存开销:
ModuleList本身开销极小(只是一个 Python list + 注册逻辑) - 速度:遍历
ModuleList与普通list几乎无差别(因为只是引用) - GPU 利用率:无影响,因为计算发生在子模块内部
✅ 结论:
ModuleList在性能上几乎零成本,放心使用。
✅ 十一、总结:最佳实践清单
| 场景 | 推荐做法 |
|---|---|
存储多个相同/不同 nn.Module | ✅ 使用 nn.ModuleList |
需要在 forward 中自定义调用顺序 | ✅ 使用 nn.ModuleList |
| 模块数量由超参数决定 | ✅ 在 __init__ 中根据参数构建 ModuleList |
| 需要命名访问 | ➡️ 改用 nn.ModuleDict |
| 简单串行无分支 | ➡️ 优先考虑 nn.Sequential |
存储非 Module 对象 | ❌ 不要用 ModuleList,用普通 list/dict |
| 训练中动态增删层 | ⚠️ 避免! PyTorch 不支持训练时改变计算图结构(除非使用 torch.fx 等高级工具) |
🌟 最后一句话
nn.ModuleList是 PyTorch 中实现“可编程神经网络结构”的基石。它不是简单的列表,而是 PyTorch 模块系统的“第一公民”,让你既能享受 Python 的灵活性,又不丢失深度学习框架的自动化能力。
4、代码
class Encoder(nn.Module):
def __init__(self, encoder_layer: nn.Module, num_layers: int):
"""
:param encoder_layer: 已构建好的编码器层
:param num_layers: 编码器的层数
"""
super().__init__()
# num_layer 个编码器
self.layers = nn.ModuleList([copy.deepcopy(encoder_layer) for _ in range(num_layers)])
# 最终输出需通过规范化层
self.norm = LayerNorm(d_model=encoder_layer.d_model)
def forward(self, x, mask=None):
# 经过多个编码器层
for layer in self.layers:
x = layer(x, mask)
# 最终输出经过规范化层
output = self.norm(x)
return output
经典代码:
import copy # 必须显式导入 copy 模块
class Encoder(nn.Module):
"""
Transformer 编码器:由多个 EncoderLayer 堆叠而成。
输入应为已经过词嵌入(Embedding)和位置编码(Positional Encoding)的张量,
形状为 (batch_size, seq_len, d_model)。
"""
def __init__(self, encoder_layer, num_layers):
"""
Args:
encoder_layer (EncoderLayer): 一个配置好的编码器层实例(将被深拷贝 num_layers 次)
num_layers (int): 编码器层数
"""
super().__init__()
# 使用 ModuleList 存储多层,确保参数被正确注册
self.layers = nn.ModuleList([
copy.deepcopy(encoder_layer) for _ in range(num_layers)
])
# 最终的 LayerNorm(使用你自定义的 LayerNorm 类)
self.norm = LayerNorm(d_model=encoder_layer.d_model)
def forward(self, x, mask=None):
"""
Args:
x (Tensor): 输入张量,形状 (B, T, d_model)
mask (Tensor, optional): 注意力掩码,形状通常为 (B, 1, T) 或 (B, T, T)
Returns:
Tensor: 编码后的输出,形状 (B, T, d_model)
"""
for layer in self.layers:
x = layer(x, mask)
# 应用最终的 LayerNorm
x = self.norm(x)
return x
✅ 关键点说明:
- 必须导入
copy模块:copy.deepcopy不是内置函数,需显式import copy,否则会报错;- 使用深拷贝而非引用:确保每一层拥有独立的参数,避免所有层共享同一组权重;
- 依赖已有的
LayerNorm和EncoderLayer:假设EncoderLayer实例具有.d_model属性(已在__init__中保存);- 输入假设明确:
x应已完成词嵌入和位置编码,本模块仅负责上下文建模;- 结构符合标准 Transformer:N 个编码器层 + 顶层 LayerNorm(与《Attention Is All You Need》一致)。
更多推荐


所有评论(0)