04_ffn_layer_norm_skip_conn

2409 字
12 分钟
04_ffn_layer_norm_skip_conn

本节的大部分内容都在 08 和 09 节中介绍得比较具体了,但那是放在 BERT 的环境下讨论的。本节我们将从 Transformer 架构的角度,重新审视 FFN 层归一化与残差连接。

import torch
from torch import nn
import torch.nn.functional as F
import transformers
from transformers import AutoModel, AutoTokenizer, AutoConfig
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib as mpl
mpl.rcParams['figure.dpi'] = 150

1. summary#

  • 两层的 MLP
  • 两个细节
    • h -> 4h -> h
      • 768 -> 3072 -> 768
    • Gelu
  • 模型的 capacity 以及 memorization (world knowledge)
  • Layer Normalization & skip connections
    • LN 在整个模型拓扑结构中的位置

1.1 多层感知机#

在 Transformer 的每个 encoder 层中,除了 Multi-Head Attention 之外,还有一个前馈神经网络(FFN, Feed-Forward Network)。它本质上就是一个两层的全连接网络(MLP):

输入 → 线性层1 → 激活函数 → 线性层2 → 输出

两个细节:

  1. 768 -> 3072 -> 768:这是 FFN 的维度变化规律:
    • h 是模型的隐藏层维度,在 BERT-base 模型中,h=768
    • 第一层将维度扩大到 4 倍,即 768 -> 3072;
    • 第二层再压缩回原维度,即 3072 -> 768;

这样设计的好处是,先”膨胀”再”压缩”给了模型更大的表达能力:中间层有 4 倍的空间来学习更丰富的特征组合,然后再把学到的信息压缩回标准维度,方便和下一层对接。

  1. GELU(Gaussian Error Linear Unit)是 FFN 使用的激活函数。相比早期的 ReLU,GELU 更平滑,在 Transformer/BERT 中效果更好。简单理解:它就是一个”升级版的 ReLU”。

1.2 Capacity & Memorization#

  • Capacity:指模型的容量。FN 的参数量非常大(总参数为 768×3072+3072×7684.7M768\times 3072+3072\times 768\approx 4.7M,注意这里只算了权重没有算 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)
config
def 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_output
class 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 x
mha = MultiHeadAttention(config)
# input
# config.vocab_size = 30522
# config.hidden_size = 768 = 12 * 64
# lookup-table, learnable
token_embeddings = nn.Embedding(config.vocab_size, config.hidden_size)
# token_embeddings
sample_text = 'time flies like an arrow'
model_inputs = tokenizer(sample_text, return_tensors='pt', add_special_tokens=False)
# forward of embedding module
input_embeddings = token_embeddings(model_inputs['input_ids'])
# batch_size, seq_len, hidden_size
input_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: x1x>0x\cdot\mathbf{1}_{x>0}
  • GELU: xΦ(x)x\mathbf{\Phi}(x)
Φ(x)=1+Erf(x/2)2\mathbf{\Phi}(x)=\frac{1+\text{Erf}(x/\sqrt{2})}{2}

3.1 ReLU#

ReLU (Rectified Linear Unit),公式为:

ReLU(x)=x1x>0=max(0,x)\text{ReLU}(x) = x\cdot\mathbf{1}_{x>0} = \max(0,x)

含义非常简单:如果输入是正数,原样输出;如果是负数,直接输出 0.

  • 优点:计算极快,没有复杂的指数运算;正区间梯度恒为 1,缓解了梯度消失。
  • 缺点:负区间梯度为 0,导致某些神经元可能永远”死掉”(dead ReLU),再也无法激活。

3.2 LeakyRELU#

LeakyRELU 是 ReLU 的一个改进版本,公式为:

LeakyRELU(x)=max(0,x)+αmin(0,x)\text{LeakyRELU}(x)=\max(0,x)+\alpha\cdot \min(0,x)

其中 α\alpha 是一个很小的常数,例如 0.01 或 0.1.

ReLU 对负数直接抹零,而 LeakyReLU 对负数留一条缝——负值不是变成 0,而是乘上一个很小的系数 α\alpha 后”漏”一点过去。所以,即使 xx 为负,它也依然有斜率。

LeakyRELU 主要是为了解决ReLU 的死神经元问题:一旦某个神经元输出的加权和始终为负,它的梯度恒为 0,这个神经元就永远无法更新了。

因此,LeakyReLU 的解决思路是:在负区间给一个极小的非零梯度 α\alpha,这样即使神经元落到负区间,仍有微弱的梯度信号传回去,神经元就有机会”复活”。

3.3 GELU#

GELU (Gaussian Error Linear Unit),公式为:

GELU(x)=xΦ(x)\text{GELU}(x) = x\cdot\mathbf{\Phi}(x)

其中 Φ(x)\mathbf{\Phi}(x) 是标准正态分布的累积分布函数:

Φ(x)=1+Erf(x/2)2\mathbf{\Phi}(x)=\frac{1+\text{Erf}(x/\sqrt{2})}{2}

ReLU 用硬性开关(正/负一刀切),而 GELU 用概率来决定要不要输出这个值:

  • 输入的 xx 越大,越有可能被保留;

  • xx 越小,越可能被抑制。

  • 优点:处处可导、曲线平滑,对小负值不是直接抹零而是”打折”输出,保留了更多信息。在 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 x
ffn = 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 的核心操作很简单,就是对一组数值做两件事:

x^i=xiμσ\hat{x}_i=\frac{x_i-\mu}{\sigma}
  • 减均值 μ\mu:让数据中心平移到 0(零均值);
  • 除以标准差 σ\sigma:让数据缩放成标准差为 1(单位方差);

除此之外,还有一个可学习的缩放和偏移参数 γ\gammaβ\betayi=γx^i+βy_i=\gamma \hat{x}_i+\beta,让模型自己决定要不要“还原”一些信息。

"layer_norm_eps": 1e-12 就是为了防止除以 0 而加的一个极小值,在之前的 config 中可以看到。


与 Batch Norm 的区别:layer norm 是”样本层面”的:

Batch NormLayer 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_states
encoder_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])

文章分享

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

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

评论区

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

音乐

暂未播放

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

文章目录