从0实现Transformer
框架基本知识



代码
1.实现的是解码器结构的Transformer而非原始论文的encode-decode
2.和原始论文不太一样,并且存在许多隐含错误
1 | ''' |
效果
1 | Step: 0 Training Loss: 11.705 Validation Loss: 11.714 |
Post-LayerNorm vs Pre-LayerNorm
后来的 Transformer 模型普遍从原始的 Post-LayerNorm 改为 Pre-LayerNorm
两种 LayerNorm 位置对比
原始 Transformer(Post-LayerNorm)
1 | # 原始论文的顺序:子层 → LayerNorm → 残差连接 |
现代 Transformer(Pre-LayerNorm)
1 | # 现代实现的顺序:LayerNorm → 子层 → 残差连接 |
为什么要改为 Pre-LayerNorm?
训练稳定性大幅提升
Post-LayerNorm 的问题:
1 | # 梯度流经的路径: |
Pre-LayerNorm 的优势:
1 | # 梯度流经的路径: |
收敛速度更快
实际效果对比:
- Post-LayerNorm:可能需要更多训练步数才能收敛
- Pre-LayerNorm:通常收敛更快,需要的训练步数更少
梯度传播更直接
1 | Post-LayerNorm 梯度路径: |
梯度计算对比
Post-LayerNorm(层归一化在残差连接后)
1 | x = LayerNorm(x + Sublayer(x)) |
反向传播的路径:
1 | 梯度流向:损失 → LayerNorm → (残差连接 + Sublayer) → 输入 |
- 当输入x+f(x)的方差σ很小时,1/σ很大 做完乘积→ 梯度爆炸
- 当方差σ很大时,1/σ很小 做完乘积→ 梯度消失
Pre-LayerNorm(层归一化在残差连接前)
1 | x = x + Sublayer(LayerNorm(x)) |
反向传播的路径:
1 | 梯度流向:损失 → (残差连接 + Sublayer) → LayerNorm → 输入 |
使用 Pre-LayerNorm 的模型
- GPT 系列(GPT-2, GPT-3, GPT-4)
- BERT 及其变体
- T5
- RoBERTa
- ALBERT
- 大部分现代 Transformer 变体
使用 Post-LayerNorm 的模型
- 原始 Transformer(2017)
- 早期实验性模型
- 现在基本不再使用
总结
从 Post-LayerNorm 改为 Pre-LayerNorm 的主要原因:
- 训练稳定性:Pre-LayerNorm 大大减少了梯度问题
- 收敛速度:训练更快,需要更少的迭代次数
- 调参友好:对初始化和学习率不那么敏感
- 扩展性:更容易训练深层和超大模型
- 实际效果:在几乎所有任务上都表现更好
改进
- 将post-LayerNorm改为pre-LayerNorm
- 由于原始论文中位置编码固定,因此改变位置编码的位置,避免重复计算位置编码
- 修改ScaledDotProductAttention类和MultiHeadAttention类的职责分配,合成一个类
- 在残差连接前增加Dropout层
1 | ''' |
1 | Step: 0 Training Loss: 11.68 Validation Loss: 11.693 |
可能是有点提升,但我不知道是哪个发挥了作用
LayerNorm
层归一化(Layer Normalization) 是一种广泛应用于深度学习模型(尤其是 Transformer 架构)的归一化技术。它的核心思想是对单个样本的所有特征维度进行归一化,使得每一层的输出具有稳定的分布,从而加速训练并提高模型性能。
什么是层归一化?
对于一个输入向量$ ( x \in \mathbb{R}^d )($即单个样本的 $ d $个特征,层归一化计算该样本自身的均值和方差:
$
\mu = \frac{1}{d} \sum_{i=1}^{d} x_i, \quad \sigma^2 = \frac{1}{d} \sum_{i=1}^{d} (x_i - \mu)^2
$
然后对每个特征进行归一化:
$
\hat{x}_i = \frac{x_i - \mu}{\sqrt{\sigma^2 + \epsilon}}
$
这里的 $\epsilon$是一个很小的常数,防止除零。
最后引入可学习的缩放参数 ($\gamma$) 和平移参数 ($\beta$)(与输入维度相同),恢复模型的表达能力:
$
y_i = \gamma_i \hat{x}_i + \beta_i
$
层归一化 vs. 批归一化(Batch Normalization)
| 特性 | 批归一化 (BN) | 层归一化 (LN) |
|---|---|---|
| 归一化维度 | 在 batch 维度上,对每个特征通道归一化 | 在特征维度上,对每个样本归一化 |
| 依赖 batch size | 依赖 batch 大小,小 batch 效果差 | 不依赖 batch 大小,单样本也可用 |
| 适用场景 | CNN 等固定尺寸输入 | RNN、Transformer 等变长序列 |
| 训练与测试差异 | 训练时使用 batch 统计量,测试时使用全局移动平均 | 训练和测试行为一致,均使用当前样本统计量 |
为什么 Transformer 偏爱层归一化?
因为 Transformer 处理的是变长的序列,且在不同任务中 batch size 可能很小,使用批归一化会导致统计量不稳定;而层归一化对每个样本独立计算,天然适用于变长输入,且实现简单、稳定。
层归一化的主要作用
稳定训练过程
- 缓解梯度消失/爆炸:归一化将输入拉回到一个合适的范围,使得反向传播的梯度不会过大或过小,从而避免梯度问题。
- 平滑损失曲面:使损失函数对参数的变化更不敏感,优化路径更平滑,训练更稳定。
加速收敛
- 归一化后的输出均值为 0、方差为 1,使得后续层的输入分布相对固定,允许使用更大的学习率,从而加快模型收敛速度。
减少对参数初始化的依赖
- 在没有归一化的情况下,模型对权重初始化非常敏感;层归一化降低了这种敏感性,使训练更鲁棒。
轻微的正则化效果
- 由于每个样本的归一化引入了少量噪声(因为统计量来自当前样本),有时可以起到类似 Dropout 的正则化作用,提高泛化能力。
在 Transformer 中的典型应用
Transformer 的每个子层(如多头自注意力、前馈神经网络)之后都包含一个残差连接和一个层归一化,通常有两种放置方式:
- Post-LN(原始 Transformer):
LayerNorm(x + Sublayer(x)) - Pre-LN(更常见于现代实现):
x + Sublayer(LayerNorm(x))
Pre-LN 的梯度流动更顺畅,训练更稳定,因此被 GPT、BERT 等主流模型广泛采用。
微调(Fine-tuning)
微调(Fine-tuning) 是深度学习和自然语言处理中非常核心的技术,尤其在预训练大模型时代,它让通用模型能够快速适配特定任务。
微调的原理
微调基于迁移学习的思想:一个在大规模、多样化数据上预训练好的模型,已经学习到了通用的特征表示和语言知识。我们只需要在目标任务的小规模数据集上对它进行“微小的调整”,就能让它适应新任务,而无需从头训练。
为什么微调有效?
- 知识复用:预训练模型(如BERT、GPT)在海量文本上学习到了语法、语义、上下文关系等通用知识。这些知识对绝大多数下游任务都有用。
- 良好的初始化:预训练模型提供了一个比随机初始化更好的起点,使得优化过程更快、更稳定,且不容易陷入差的局部最优。
- 数据效率高:目标任务通常只有少量标注数据,从头训练容易过拟合。微调利用预训练知识,相当于给模型一个强先验,只需少量数据就能学到任务特定的模式。
微调的数学本质
假设预训练模型的参数为$ \theta_{\text{pre}}$,目标任务损失函数为 $\mathcal{L}{\text{task}}$。微调的过程就是寻找一组参数 $\theta$,使得在目标任务上损失最小,同时希望 $\theta$不要偏离$ \theta{\text{pre}}$太远(防止灾难性遗忘)。因此,微调通常采用较低的初始学习率,并在少量迭代后收敛。
对于大语言模型,微调常分为:
- 全参数微调:更新所有预训练参数。
- 参数高效微调:只更新少量新增参数(如LoRA、Adapter、Prefix Tuning),冻结大部分预训练参数,以降低显存消耗和避免过拟合。
微调的步骤
微调的一般流程包括数据准备、模型加载、训练配置、执行训练和评估。下面以有监督微调(Supervised Fine-tuning)为例说明,指令微调(Instruction Tuning)本质类似,只是数据格式变为指令-回答对。
步骤1:准备任务数据集
- 收集数据:根据目标任务(如情感分类、文本摘要、对话生成)收集标注数据。
- 数据清洗:去除噪声、处理缺失值、统一格式。
- 划分数据集:通常分为训练集、验证集和测试集(如 80%:10%:10%)。
- 格式化:对于大语言模型,需要将数据组织成模型期望的格式。例如,对于分类任务,可构造为
[CLS] 句子 [SEP];对于生成任务,可构造为输入:[指令] 输出:[回答]。
步骤2:加载预训练模型和分词器
- 选择基础模型(如 LLaMA、ChatGLM、BERT),使用相应的库(HuggingFace Transformers)加载模型和分词器。
- 如果需要参数高效微调,还需准备对应的模块(如 LoRA 的配置)。
步骤3:配置微调参数
- 训练参数:
- 学习率:通常比从头训练小很多,例如 1e-5 ~ 5e-5。
- 批次大小(batch size):根据显存调整。
- 训练轮数(epochs):通常较少(1~5轮),避免过拟合。
- 优化器:常用 AdamW。
- 学习率调度器:线性衰减或余弦衰减。
- 是否冻结部分层:如果数据量少,可以只微调最后几层,冻结底层通用特征层。
- 参数高效微调配置(如果使用):如 LoRA 的秩、目标模块等。
步骤4:执行训练
- 将训练数据按批次喂给模型。
- 前向传播计算损失(如交叉熵损失)。
- 反向传播更新参数(如果是全参数微调,更新全部参数;如果是参数高效微调,只更新新增参数)。
- 每个 epoch 后在验证集上评估性能,防止过拟合,可保存最佳模型。
步骤5:评估与保存
- 在测试集上评估最终模型,使用任务相关指标(如准确率、BLEU、ROUGE)。
- 保存模型权重和配置文件,以便后续推理部署。
(可选)步骤6:部署
- 将微调后的模型集成到应用系统中,提供服务。
微调的注意事项
- 灾难性遗忘:微调过程中可能丢失部分通用知识,尤其是在小数据集上全参数微调。可通过混合预训练任务或使用参数高效微调缓解。
- 过拟合:由于目标任务数据量小,需使用早停、权重衰减等正则化技术。
- 计算资源:全参数微调大模型需要较多显存(如数十GB),可使用梯度累积、混合精度训练、DeepSpeed 等技术优化。
- 数据质量:微调效果高度依赖于标注数据的质量和多样性。
常见微调类型简介
| 类型 | 特点 | 适用场景 |
|---|---|---|
| 全参数微调 | 更新所有参数,表达能力强,但资源消耗大 | 数据量大、任务与预训练差异大 |
| LoRA | 在 Transformer 层添加低秩矩阵,只训练这些矩阵 | 资源有限、快速适配 |
| Adapter | 插入小型适配模块,训练时只更新适配器 | 多任务学习、模块化 |
| Prefix Tuning | 在输入前添加可训练的连续向量 | 生成任务、少样本场景 |
| Prompt Tuning | 类似 Prefix,但只添加输入层的前缀 | 极轻量级微调 |
知识拾遗
针对代码中不理解的位置进行学习
Python 装饰器
它允许你在不修改原函数代码的情况下,为函数或类添加额外的功能。
基本概念
装饰器本质上是一个接受函数作为参数并返回一个新函数的函数。它使用 @ 符号语法糖来应用。
1 | # 装饰器的定义 |
装饰器的基本用法
最简单的装饰器
1 | def my_decorator(func): |
装饰带参数的函数
1 | def decorator(func): |
装饰器的四种形式
形式1:函数装饰器(最常用)
1 | import time |
形式2:带参数的装饰器
1 | def repeat(num_times): |
形式3:类装饰器
1 | class CountCalls: |
形式4:多个装饰器堆叠
1 | def bold(func): |
装饰器在实际项目中的应用
日志记录
1 | import functools |
权限验证
1 | def require_login(role="user"): |
缓存/记忆化
1 | from functools import lru_cache |
重试机制
1 | import time |
使用 functools.wraps
为什么需要它?
装饰器会隐藏原函数的元信息(名字、文档字符串等),functools.wraps 可以解决这个问题。
1 | import functools |
装饰器的底层原理
装饰器的执行时机
1 | def decorator(func): |
在机器学习中的实际应用
模型训练装饰器
1 | import torch |
梯度检查装饰器
1 | def check_gradients(func): |
装饰器的常见问题
问题1:装饰器破坏了函数签名
解决方案:使用 functools.wraps 和 inspect.signature
1 | import functools |
问题2:装饰器不能装饰类方法
解决方案:正确处理 self 参数
1 | def method_decorator(func): |
问题3:装饰器影响性能
解决方案:避免在装饰器内部做复杂操作
1 | # 不好:每次调用都重新计算 |
装饰器的最佳实践
始终使用 functools.wraps
1 | import functools |
编写可重用的装饰器
1 | from typing import Callable, Any |
装饰器工厂模式
1 | class DecoratorFactory: |
总结
装饰器是Python的超级武器,它让你能够:
- 添加功能而不修改原代码
- 分离关注点(业务逻辑 vs 横切关注点)
- 提高代码复用(装饰器可重复使用)
- 保持代码简洁(避免重复代码)
关键要点:
- 装饰器在函数定义时执行,而不是调用时
- 使用
@functools.wraps保留原函数信息 - 装饰器可以嵌套,执行顺序从内到外
- 装饰器可以是函数,也可以是类(实现
__call__方法) - 装饰器参数需要额外包装一层
model.train() model.eval()
model.train() 和 model.eval() 是控制 PyTorch 模型行为的开关:
| 特性 | model.train() |
model.eval() |
|---|---|---|
| 用途 | 训练 | 评估/推理 |
| Dropout | 启用(随机丢弃) | 禁用(全参与) |
| BatchNorm | 更新统计量 | 使用累积统计量 |
| 结果 | 随机(训练需要) | 确定(评估需要) |
| 内存 | 较大(保存梯度) | 较小(无梯度) |
广播机制(Broadcasting)
unsqueeze() 经常与广播机制一起使用:
广播规则
两个张量运算时,PyTorch 会自动扩展维度使它们形状匹配:
规则1:维度对齐(从右向左)
比较两个张量的形状,从最后一个维度(最右边)开始,向左逐个维度比较。
1 | # 示例 |
规则2:兼容性判断
两个维度兼容的条件:
- 维度相等:如 5 和 5
- 其中一个为1:如 5 和 1
- 其中一个不存在(缺失):视为1
1 | # 兼容的例子 |
规则3:扩展执行
将形状为1的维度扩展为对应维度的大小。
1 | a = torch.randn(3, 4, 5) # 形状: (3, 4, 5) |
unsqueeze() squeeze()
unsqueeze() 的逆操作是 squeeze(),用于移除大小为1的维度:
1 | # 添加维度 |
view() reshape()
| 特性 | torch.view() |
torch.reshape() |
|---|---|---|
| 内存连续性要求 | 要求张量是连续的(contiguous) | 不要求,会自动处理非连续张量 |
| 数据复制 | 不复制数据,共享底层存储 | 必要时会复制数据(当张量不连续时) |
| 错误情况 | 如果张量不连续会报错 | 总是成功,但可能有性能损失 |
| 使用场景 | 已知张量连续时的快速形状调整 | 不确定张量是否连续时的安全形状调整 |
| 性能 | 更快(无数据复制) | 可能较慢(可能需要复制) |
| 返回值 | 新视图,共享数据 | 可能的新张量,可能不共享数据 |
其他改变形状的API
| API | 功能 | 是否改变存储 | 是否支持原地操作 | 示例 |
|---|---|---|---|---|
view() |
改变形状(需连续) | ❌ 共享存储 | ❌ | x.view(2, 3) |
reshape() |
改变形状(自动处理) | 可能复制 | ❌ | x.reshape(2, 3) |
resize_() |
原地调整大小 | ✅ 可能改变存储 | ✅ | x.resize_(2, 3) |
flatten() |
展平为1D | 可能复制 | ❌ | x.flatten() |
squeeze() |
移除维度为1的轴 | ❌ 共享存储 | 有原地版本 | x.squeeze() |
unsqueeze() |
添加维度为1的轴 | ❌ 共享存储 | 有原地版本 | x.unsqueeze(0) |
transpose() |
交换两个维度 | ❌ 共享存储 | ❌ | x.transpose(0, 1) |
permute() |
重新排列所有维度 | ❌ 共享存储 | ❌ | x.permute(1, 0, 2) |
contiguous() |
使张量连续 | ✅ 复制数据 | ❌ | x.contiguous() |
