04_ffn_layer_norm_skip_conn
本节的大部分内容都在 08 和 09 节中介绍得比较具体了,但那是放在 BERT 的环境下讨论的。本节我们将从 Transformer 架构的角度,重新审视 FFN 层归一化与残差连接。
import torchfrom torch import nnimport torch.nn.functional as Fimport transformersfrom transformers import AutoModel, AutoTokenizer, AutoConfigimport numpy as npimport pandas as pdimport matplotlib.pyplot as pltimport matplotlib as mpl
mpl.rcParams['figure.dpi'] = 1501. summary
- 两层的 MLP
- 两个细节
- h -> 4h -> h
- 768 -> 3072 -> 768
- Gelu
- h -> 4h -> h
- 模型的 capacity 以及 memorization (world knowledge)
- Layer Normalization & skip connections
- LN 在整个模型拓扑结构中的位置
1.1 多层感知机
在 Transformer 的每个 encoder 层中,除了 Multi-Head Attention 之外,还有一个前馈神经网络(FFN, Feed-Forward Network)。它本质上就是一个两层的全连接网络(MLP):
输入 → 线性层1 → 激活函数 → 线性层2 → 输出两个细节:
- 768 -> 3072 -> 768:这是 FFN 的维度变化规律:
- h 是模型的隐藏层维度,在 BERT-base 模型中,
h=768; - 第一层将维度扩大到 4 倍,即 768 -> 3072;
- 第二层再压缩回原维度,即 3072 -> 768;
- h 是模型的隐藏层维度,在 BERT-base 模型中,
这样设计的好处是,先”膨胀”再”压缩”给了模型更大的表达能力:中间层有 4 倍的空间来学习更丰富的特征组合,然后再把学到的信息压缩回标准维度,方便和下一层对接。
- GELU(Gaussian Error Linear Unit)是 FFN 使用的激活函数。相比早期的 ReLU,GELU 更平滑,在 Transformer/BERT 中效果更好。简单理解:它就是一个”升级版的 ReLU”。
1.2 Capacity & Memorization
- Capacity:指模型的容量。FN 的参数量非常大(总参数为 ,注意这里只算了权重没有算 bias),占了 Transformer 总参数的大部分。这让模型有足够的”脑容量”去学习复杂模式。
- Memorization:研究表明,Transformer 中的 FFN 层主要负责存储事实性知识(比如”巴黎是法国的首都”这类世界知识),而 Attention 层负责从上下文中提取关系。可以粗略理解为:Attention 负责”推理”,FFN 负责”记忆”。
1.3 Layer Normalization & Skip Connections
- Layer Normalization:层归一化。训练深层网络时,各层的输出数值分布可能越来越不稳定(梯度消失/爆炸)。Layer Normalization 对每个样本的特征维度做标准化,让数值保持稳定,训练更顺畅。
- Skip Connections:就是把输入直接”短路”加到输出上:
输出 = 子层(x) + x. 这样做的好处是:即使子层(如 FFN 或 Attention)学得不好,梯度也能通过”短路路径”直接传回去,有效防止深层网络的梯度消失问题。
关于 LN 在整个模型拓扑结构中的位置,这里指的是 Post-LN 与 Pre-LN 的区别:
| 类型 | 结构 | 说明 |
|---|---|---|
| Post-LN(原始 Transformer) | x → 子层 → LN(x + 子层(x)) | LN 放在残差连接之后 |
| Pre-LN(现代主流) | x → LN(x) → 子层 → x + 子层(LN(x)) | LN 放在子层之前 |
现代大多数模型(包括 GPT 系列)都采用 Pre-LN,因为训练更稳定。
2. review multihead attention
model_skpt = 'bert-base-uncased'model = AutoModel.from_pretrained(model_skpt)
print(model)print(model.embeddings)print(model.encoder)
# 只显示了 layer 的定义,至于层与层之间的拓扑连接关系,是在 module 的 forward 中定义print(model.encoder.layer[0])tokenizer = AutoTokenizer.from_pretrained(model_skpt)config = AutoConfig.from_pretrained(model_skpt)configdef scaled_dot_product_attention(query, key, value, mask=None): """ 计算缩放点积注意力 query: 查询张量,形状为 (..., seq_len_q, depth) key: 键张量,形状为 (..., seq_len_k, depth) value: 值张量,形状为 (..., seq_len_v, depth_v) mask: 可选的掩码张量,形状为 (..., seq_len_q, seq_len_k) """ # hidden size dim_k = key.size(-1) # batch_size, seq_len_q, seq_len_k attn_scores = torch.bmm(query, key.transpose(1, 2)) / np.sqrt(dim_k) attn_weights = F.softmax(attn_scores, dim=-1) return torch.bmm(attn_weights, value)class AttentionHead(nn.Module): def __init__(self, embedding_dim, head_dim): super().__init__() self.query = nn.Linear(embedding_dim, head_dim) self.key = nn.Linear(embedding_dim, head_dim) self.value = nn.Linear(embedding_dim, head_dim)
def forward(self, hidden_states): q = self.query(hidden_states) k = self.key(hidden_states) v = self.value(hidden_states) attn_output = scaled_dot_product_attention(q, k, v) return attn_outputclass MultiHeadAttention(nn.Module): def __init__(self, config): super().__init__() embedding_dim = config.hidden_size num_heads = config.num_attention_heads # 768 // 12 = 64 head_dim = embedding_dim // num_heads
self.heads = nn.ModuleList([AttentionHead(embedding_dim, head_dim) for _ in range(num_heads)]) self.output_layer = nn.Linear(embedding_dim, embedding_dim)
def forward(self, hidden_states): print(f"input hidden_states shape: {hidden_states.shape}") print(f"head (hidden_states) shape: {self.heads[11](hidden_states).shape}") x = torch.cat([head(hidden_states) for head in self.heads], dim=-1) print(f"concat head shape: {x.shape}") x = self.output_layer(x) return xmha = MultiHeadAttention(config)# input# config.vocab_size = 30522# config.hidden_size = 768 = 12 * 64# lookup-table, learnabletoken_embeddings = nn.Embedding(config.vocab_size, config.hidden_size)# token_embeddingssample_text = 'time flies like an arrow'model_inputs = tokenizer(sample_text, return_tensors='pt', add_special_tokens=False)# forward of embedding moduleinput_embeddings = token_embeddings(model_inputs['input_ids'])# batch_size, seq_len, hidden_sizeinput_embeddings.shape # torch.Size([1, 5, 768])attention_output = mha(input_embeddings)输出为:
input hidden_states shape: torch.Size([1, 5, 768])head (hidden_states) shape: torch.Size([1, 5, 64])concat head shape: torch.Size([1, 5, 768])3. FFN
以上是对之前的简单 review,下面正式进入本节的正题——FFN.
- RELU:
- GELU:
3.1 ReLU
ReLU (Rectified Linear Unit),公式为:
含义非常简单:如果输入是正数,原样输出;如果是负数,直接输出 0.
- 优点:计算极快,没有复杂的指数运算;正区间梯度恒为 1,缓解了梯度消失。
- 缺点:负区间梯度为 0,导致某些神经元可能永远”死掉”(dead ReLU),再也无法激活。
3.2 LeakyRELU
LeakyRELU 是 ReLU 的一个改进版本,公式为:
其中 是一个很小的常数,例如 0.01 或 0.1.
ReLU 对负数直接抹零,而 LeakyReLU 对负数留一条缝——负值不是变成 0,而是乘上一个很小的系数 后”漏”一点过去。所以,即使 为负,它也依然有斜率。
LeakyRELU 主要是为了解决ReLU 的死神经元问题:一旦某个神经元输出的加权和始终为负,它的梯度恒为 0,这个神经元就永远无法更新了。
因此,LeakyReLU 的解决思路是:在负区间给一个极小的非零梯度 ,这样即使神经元落到负区间,仍有微弱的梯度信号传回去,神经元就有机会”复活”。
3.3 GELU
GELU (Gaussian Error Linear Unit),公式为:
其中 是标准正态分布的累积分布函数:
ReLU 用硬性开关(正/负一刀切),而 GELU 用概率来决定要不要输出这个值:
-
输入的 越大,越有可能被保留;
-
越小,越可能被抑制。
-
优点:处处可导、曲线平滑,对小负值不是直接抹零而是”打折”输出,保留了更多信息。在 Transformer/BERT 等大模型中效果明显优于 ReLU。
-
缺点:计算量比 ReLU 大(涉及 erf 误差函数或 tanh 近似),但在实际工程中用近似公式已经足够快。
x = np.arange(-5, 5, 0.01)plt.plot(x, nn.ReLU()(torch.from_numpy(x)))plt.plot(x, nn.LeakyReLU(negative_slope=0.1)(torch.from_numpy(x)))plt.plot(x, nn.GELU()(torch.from_numpy(x)))plt.legend(['ReLU', 'LeakyReLU', 'GELU'])plt.show()输出为:

class FeedForward(nn.Module): def __init__(self, config): super().__init__() self.linear1 = nn.Linear(config.hidden_size, config.intermediate_size) self.activation = nn.GELU() self.linear2 = nn.Linear(config.intermediate_size, config.hidden_size) self.dropout = nn.Dropout(config.hidden_dropout_prob)
def forward(self, hidden_states): x = self.linear1(hidden_states) x = self.activation(x) x = self.linear2(x) x = self.dropout(x) return xffn = FeedForward(config)ffn(attention_output).size() # torch.Size([1, 5, 768])4. layer norm & skip connection
- layer norm (
config, "layer_norm_eps": 1e-12, )- zero mean & one variant
- 与 batch norm 相对,是样本层面
- 对于 NLP 问题,一般是每一个 token 对应的 embedding(即 768 维的 hidden states 进行 0 均值 1 标准差的归一化)
- Transformer 中主要是两处用到了 layer norm:
- embedding:对于最终的 token_embedding
- 构建 BERT layer(即 Transformer encoder layer)
4.1 layer norm
Layer Norm 的核心操作很简单,就是对一组数值做两件事:
- 减均值 :让数据中心平移到 0(零均值);
- 除以标准差 :让数据缩放成标准差为 1(单位方差);
除此之外,还有一个可学习的缩放和偏移参数 和 :,让模型自己决定要不要“还原”一些信息。
"layer_norm_eps": 1e-12 就是为了防止除以 0 而加的一个极小值,在之前的 config 中可以看到。
与 Batch Norm 的区别:layer norm 是”样本层面”的:
| Batch Norm | Layer Norm | |
|---|---|---|
| 归一化的维度 | 同一个特征,跨样本(跨 batch) | 同一个样本,跨特征(跨 hidden dims) |
| 依赖 batch 大小 | ✅ 依赖,batch 太小就不稳定 | ❌ 不依赖,batch=1 也能用 |
| 适用场景 | CNN(图像) | RNN / Transformer(NLP) |
用一个例子来直观理解:
假设一个 batch 有 2 个句子,各 5 个 token,hidden_size=768张量形状: [batch=2, seq=5, hidden=768]
Batch Norm: 对每个 hidden 维度,跨 batch×seq 求均值(跨样本)Layer Norm: 对每个 token,跨 768 维求均值(样本内部)所以,Layer Norm 是每个样本自己跟自己比,不依赖 batch 里其他样本。
对 NLP 问题的具体做法:
每一个 token 对应的 embedding(即 768 维的 hidden states)进行 0 均值 1 标准差的归一化
具体来说,对于形状为 [batch, seq_len, 768] 的张量:
- 归一化对象:每个 token 的 768 维向量
- 统计范围:这 768 个数(不是跨 token,也不是跨 batch)
- 结果:每个 token 的 768 维向量变成均值 0、标准差 1
Token "time" 的 768 维向量 → 均值 0, 标准差 1(独立归一化)Token "flies" 的 768 维向量 → 均值 0, 标准差 1(独立归一化)...每个 token 独立归一化,互不影响。这天然适合 NLP——因为句子长度不固定,batch norm 很难处理变长序列。
Transformer 中使用 Layer Norm 的两处位置:
第一处:Embedding 层之后
token → Embedding → Layer Norm → 进入 encoder对最终的 token embedding 先做一次归一化,让输入数值稳定后再送入 encoder 层。
第二处:每个 Encoder Layer 内部
每个 Transformer encoder layer 内部有两个子层,各配一个 Layer Norm:
x → Attention → Skip Connection → Layer Norm → FFN → Skip Connection → Layer Norm → 输出即我们之前讨论的 Pre-LN 或 Post-LN 的位置。BERT 用的是 Post-LN(LN 在残差连接之后),示意图:

5. EncoderLayer

class TransformerEncoderLayer(nn.Module): def __init__(self, config): super().__init__() self.self_attention = MultiHeadAttention(config) self.layer_norm1 = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps) self.feed_forward = FeedForward(config) self.layer_norm2 = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
def forward(self, hidden_states): # Self-Attention attention_output = self.self_attention(hidden_states) # Skip Connection + Layer Norm hidden_states = self.layer_norm1(hidden_states + attention_output)
# Feed Forward ffn_output = self.feed_forward(hidden_states) # Skip Connection + Layer Norm hidden_states = self.layer_norm2(hidden_states + ffn_output)
return hidden_statesencoder_layer = TransformerEncoderLayer(config)encoder_layer(input_embeddings).shape # torch.Size([1, 5, 768])输出为:
input hidden_states shape: torch.Size([1, 5, 768])head (hidden_states) shape: torch.Size([1, 5, 64])concat head shape: torch.Size([1, 5, 768])文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!