Transformer代码深入理解

全文主要由AI生成,是根据哈弗Annotated Transformer代码实现进行的代码解释
关于Transformer的内容理解见Transformer From Scratch
单元格 6:导入所有需要的库
是什么: 系统交互工具
import os
from os.path import exists
os 模块的本质:
os
是”操作系统”的缩写,是 Python 标准库的一部分- 这个模块提供了一套与操作系统交互的接口函数
exists
函数专门用来检查文件或目录是否存在,返回布尔值
为什么需要: 文件管理的必要性
在深度学习项目中,我们经常需要:
- 检查模型文件是否存在:避免重复下载或训练
- 创建保存目录:为训练结果和模型权重创建文件夹
- 路径处理:在不同操作系统上正确处理文件路径
- 环境检测:确认运行环境的配置
怎么做: 具体应用场景
# 典型使用示例
if not exists("model_weights.pt"):
print("模型文件不存在,开始训练...")
else:
print("发现已保存的模型,加载中...")
实际用途: 在这个Transformer项目中,主要用于检查预训练模型、数据集文件是否存在,以及创建输出目录。
是什么: 深度学习核心框架
import torch
import torch.nn as nn
from torch.nn.functional import log_softmax, pad
PyTorch 框架体系:
torch
- 核心计算引擎:
- PyTorch 是目前最流行的深度学习框架之一(由 Meta/Facebook 开发)
- 核心数据结构是 Tensor(张量):多维数组,类似 numpy,但专为深度学习优化
- 提供自动微分:自动计算梯度,这是神经网络训练的核心
- 支持 GPU 加速:利用 CUDA 实现几十倍的计算加速
torch.nn
- 神经网络构建工具包:
nn
是 “Neural Networks” 的缩写- 包含构建神经网络的所有基本组件:
- 层(Layers):Linear(全连接)、Conv2d(卷积)、Embedding(嵌入)等
- 激活函数:ReLU、Tanh、Sigmoid 等
- 损失函数:CrossEntropyLoss、MSELoss 等
- 容器:Sequential(顺序堆叠)、ModuleList(模块列表)等
torch.nn.functional
- 函数式操作:
- 包含无状态的函数版本,不需要创建层对象
log_softmax
:对数softmax函数,用于多分类概率计算pad
:张量填充函数,为序列添加padding
为什么选择这些: 设计哲学与优势
为什么选择 PyTorch:
- 动态计算图:更灵活,便于调试和实验
- Python原生:与Python生态无缝集成
- 研究友好:易于实现复杂的研究想法
- 生产就绪:TorchScript可以部署到生产环境
为什么需要 nn 模块:
- 面向对象设计:将神经网络组件封装成类,便于管理
- 参数自动管理:自动跟踪和更新可学习参数
- 设备无关:一行代码即可在CPU/GPU间切换
- 状态管理:自动处理训练/评估模式切换
为什么使用 functional:
- 计算效率:某些操作的函数版本更高效
- 代码简洁:不需要预先定义层对象
- 灵活性:可以动态调整参数和行为
怎么做: 在Transformer中的具体应用
# 使用 nn 构建层
self.linear = nn.Linear(512, 30000) # 全连接层:512维 -> 30000维(词汇表大小)
# 使用 functional 进行计算
output_probs = log_softmax(logits, dim=-1) # 计算对数概率
# 使用 pad 处理变长序列
padded_sequences = pad(sequences, (0, max_len - seq_len)) # 右侧填充到最大长度
为什么这样设计: Transformer 模型需要处理变长文本序列、进行大规模矩阵运算、支持并行计算,PyTorch 的这些模块完美满足了这些需求。
是什么: Python标准库工具集
import math
import copy
import time
数学与工具模块详解:
math
- 数学运算库:
- Python内置的数学函数库
- 提供基础数学函数:
sqrt()
、log()
、sin()
、cos()
、exp()
等 - 常数:
math.pi
、math.e
等
copy
- 对象复制工具:
- 处理Python对象的复制操作
copy.copy()
:浅复制(shallow copy)copy.deepcopy()
:深复制(deep copy),创建完全独立的副本
time
- 时间处理模块:
- 时间相关的功能:获取当前时间、计算时间差、休眠等
- 主要函数:
time.time()
、time.sleep()
为什么需要这些: 在Transformer中的关键作用
为什么需要 math:
- 注意力缩放:使用
math.sqrt(d_k)
缩放注意力分数 - 位置编码:使用三角函数
sin
、cos
生成位置信息 - 初始化:某些权重初始化方法需要数学计算
为什么需要 copy:
- 层复制:Transformer有多层相同结构,需要创建独立的副本
- 参数隔离:确保不同层的参数不会互相影响
- 模型构建:避免多个层共享同一组参数
为什么需要 time:
- 性能监控:测量训练和推理的时间消耗
- 进度跟踪:计算剩余训练时间
- 性能优化:识别计算瓶颈
怎么做: 实际应用示例
# math 的使用
attention_scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k)
# 为什么除以 sqrt(d_k)?防止 softmax 饱和,保持梯度流动
# copy 的使用
def clones(module, N):
return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
# 创建N个相同但独立的层
# time 的使用
start_time = time.time()
# 训练代码...
training_time = time.time() - start_time
print(f"训练耗时: {training_time:.2f} 秒")
设计原理: 这些工具看似简单,但在大规模神经网络中起到精确控制的作用:数学函数确保数值稳定性,深复制保证模型结构的正确性,时间监控帮助优化性能。
是什么: 学习率优化工具
from torch.optim.lr_scheduler import LambdaLR
学习率调度器本质:
- 学习率调度器是控制模型训练过程中学习率动态变化的工具
LambdaLR
允许使用自定义函数来调整学习率- 属于 PyTorch 优化模块的一部分
为什么至关重要: 训练稳定性的核心
学习率的重要性:
-
训练稳定性:
- 太大:梯度爆炸,训练发散,loss震荡
- 太小:收敛极慢,容易陷入局部最优
- 需要找到”甜蜜点”
-
Transformer特殊需求:
- 大模型敏感性:参数量巨大,对学习率极其敏感
- 注意力机制:需要特殊的预热策略
- 梯度特性:初期梯度不稳定,需要小心处理
为什么选择 LambdaLR:
- 高度自定义:可以实现任意复杂的调度策略
- 数学精确:直接使用数学函数描述学习率变化
- 研究重现:能精确复现论文中的学习率策略
怎么做: Transformer的学习率策略
原论文的学习率公式:
def rate(step, model_size, factor, warmup):
# lr = factor * (model_size^(-0.5) * min(step^(-0.5), step * warmup^(-1.5)))
if step == 0:
step = 1
return factor * (
model_size ** (-0.5) * min(step ** (-0.5), step * warmup ** (-1.5))
)
# 实际使用
scheduler = LambdaLR(optimizer, lr_lambda=lambda step: rate(step, 512, 1, 4000))
策略分解:
- Warmup阶段(前4000步):学习率线性增长
- 原因:让模型逐渐适应数据分布,避免初期的不稳定
- 衰减阶段:学习率按步数的平方根衰减
- 原因:后期需要更精细的调整,避免过冲
为什么这样设计: 这种策略结合了稳定性(warmup)和收敛性(衰减),被证明对大型Transformer模型特别有效。
import pandas as pd
import altair as alt
数据分析和可视化工具:
pandas
:
- 强大的数据分析库
- 提供 DataFrame 数据结构,类似于 Excel 表格
- 可以方便地读取、处理、分析数据
altair
:
- 数据可视化库
- 可以创建漂亮的交互式图表
- 在这个项目中用于可视化注意力权重、训练进度等
from torchtext.data.functional import to_map_style_dataset
from torch.utils.data import DataLoader
from torchtext.vocab import build_vocab_from_iterator
import torchtext.datasets as datasets
文本处理和数据加载工具:
torchtext
:
- PyTorch 的官方文本处理库
- 专门用于处理自然语言数据
to_map_style_dataset
:
- 将数据转换为 PyTorch 能够理解的数据集格式
DataLoader
:
- 非常重要的工具,用于:
- 将数据分成小批次(batches)
- 打乱数据顺序
- 并行加载数据,提高效率
build_vocab_from_iterator
:
- 从文本数据中构建词汇表
- 词汇表是单词到数字的映射,因为计算机只能处理数字
datasets
:
- 包含了一些标准的数据集(如翻译数据集)
import spacy
自然语言处理工具:
- spaCy 是另一个强大的 NLP 库
- 主要用于分词(将句子拆分成单词或子词)
- 支持多种语言
- 在这个项目中用于预处理文本数据
import GPUtil
GPU 监控工具:
- 用于监控 GPU 的使用情况
- 可以查看 GPU 内存使用量、温度等
- 在训练大型模型时,监控 GPU 状态很重要
import warnings
from torch.utils.data.distributed import DistributedSampler
import torch.distributed as dist
import torch.multiprocessing as mp
from torch.nn.parallel import DistributedDataParallel as DDP
高级功能模块:
warnings
:
- 控制 Python 警告信息的显示
- 可以忽略不重要的警告,让输出更清洁
分布式训练模块:
DistributedSampler
:在多GPU或多机器训练时分配数据torch.distributed
:分布式训练的核心模块torch.multiprocessing
:多进程处理DistributedDataParallel
:将模型分布到多个GPU上并行训练
为什么需要分布式训练: Transformer 模型通常很大,单个 GPU 可能无法承载。分布式训练可以将模型和数据分布到多个 GPU 或机器上,大大加快训练速度。
# Set to False to skip notebook execution (e.g. for debugging)
warnings.filterwarnings("ignore")
RUN_EXAMPLES = True
配置设置:
warnings.filterwarnings("ignore")
:
- 告诉 Python 忽略所有警告信息
- 这让输出更清洁,但在调试时可能需要注意
RUN_EXAMPLES = True
:
- 这是一个自定义的全局变量(布尔值,True/False)
- 用于控制是否运行示例代码
- 当设为 False 时,可以跳过耗时的示例,只查看代码结构
单元格 7:辅助函数和虚拟类
是什么: 代码管理和测试工具
def is_interactive_notebook():
return __name__ == "__main__"
函数定义详解:
- 函数语法:
def
关键字定义函数,()
内为参数列表,:
后为函数体 - 返回机制:
return
指定函数的输出值 - 特殊变量:
__name__
是Python内置变量,存储模块名称
__name__ == "__main__"
的深层含义:
- 当Python文件直接运行时,
__name__
被设为"__main__"
- 当文件被导入时,
__name__
是文件名 - 在Jupyter Notebook中,每个单元格都被视为”直接运行”
为什么需要环境检测: 代码适应性
不同运行环境的挑战:
- Jupyter Notebook:交互式环境,单元格独立运行
- Python脚本:整体运行,可能被其他模块导入
- 模块导入:作为库使用,不应执行示例代码
环境检测的价值:
- 防止意外执行:避免导入时运行示例代码
- 条件控制:只在合适的环境下运行测试代码
- 代码复用:同一份代码在不同环境下表现不同
怎么做: 智能的示例管理
def show_example(fn, args=[]):
if __name__ == "__main__" and RUN_EXAMPLES:
return fn(*args)
def execute_example(fn, args=[]):
if __name__ == "__main__" and RUN_EXAMPLES:
fn(*args)
高阶函数设计:
- 函数作为参数:
fn
是一个函数对象,体现了Python的函数是”一等公民” - 参数解包:
*args
将列表元素展开为独立参数 - 默认参数:
args=[]
提供空列表作为默认值
双重条件检查:
- 环境检查:
__name__ == "__main__"
确保在正确环境 - 开关控制:
RUN_EXAMPLES
提供手动控制
两个函数的差异:
show_example
:有返回值,适用于需要显示结果的演示execute_example
:无返回值,适用于执行操作的演示
使用示例:
# 使用 show_example 显示结果
result = show_example(my_calculation_function, [arg1, arg2])
# 使用 execute_example 执行操作
execute_example(my_visualization_function, [data])
是什么: 测试用虚拟对象
class DummyOptimizer(torch.optim.Optimizer):
def __init__(self):
self.param_groups = [{"lr": 0}]
None
def step(self):
None
def zero_grad(self, set_to_none=False):
None
class DummyScheduler:
def step(self):
None
面向对象编程基础:
- 类定义:
class
关键字创建新的对象类型 - 继承机制:
(torch.optim.Optimizer)
表示继承父类功能 - 方法重写:子类重新定义父类的方法
- 构造函数:
__init__
方法在对象创建时自动调用
虚拟对象模式(Stub Pattern):
- 接口保持:提供与真实对象相同的方法接口
- 功能禁用:方法体为空或返回None,不执行实际操作
- 占位作用:在测试或演示中代替真实对象
为什么需要虚拟对象: 开发和测试的需要
软件开发中的常见需求:
- 单元测试:测试代码逻辑而不执行实际训练
- 快速原型:验证架构而不消耗计算资源
- 代码演示:展示代码结构而不等待训练完成
- 调试分析:隔离问题而不受训练过程干扰
优化器的复杂性:
- 真实优化器需要计算梯度、更新参数、管理状态
- 虚拟优化器避免了这些重计算操作
- 保持了代码的接口一致性
为什么继承 torch.optim.Optimizer:
- 类型检查:确保对象类型正确
- 接口兼容:其他代码期望optimizer对象有特定方法
- 属性继承:自动获得必需的属性结构
怎么做: 实际应用场景
必需属性的最小实现:
self.param_groups = [{"lr": 0}] # PyTorch优化器必须有的属性
param_groups
:存储参数组和学习率信息- 即使是虚拟对象,也必须提供这个属性来保持兼容性
方法的空实现:
def step(self): # 通常用于参数更新
None # 什么都不做
def zero_grad(self, set_to_none=False): # 通常用于梯度清零
None # 什么都不做
使用场景示例:
# 在快速测试中使用
if TESTING_MODE:
optimizer = DummyOptimizer()
scheduler = DummyScheduler()
else:
optimizer = torch.optim.Adam(model.parameters())
scheduler = torch.optim.lr_scheduler.StepLR(optimizer)
# 代码的其余部分保持不变
optimizer.step() # 测试时不执行,训练时正常执行
scheduler.step() # 同样的逻辑
设计哲学: 这种设计体现了依赖注入和接口隔离的原则,让代码在不同场景下具有高度的灵活性和可测试性。
总结第一部分
到目前为止,我们已经看到了:
- 环境准备:安装依赖包的命令
- 工具导入:导入了构建 Transformer 模型所需的所有工具
- 辅助函数:定义了一些帮助管理和测试代码的工具
这些准备工作为后面的核心内容奠定了基础。在下一部分,我们将开始看到 Transformer 模型的实际实现。
每个导入的库都有其特定用途:
- torch 系列:构建和训练神经网络
- 数据处理:处理文本数据、创建词汇表
- 可视化:创建图表、监控训练进度
- 系统工具:文件操作、时间计算、GPU 监控
这种模块化的设计让复杂的深度学习项目变得可管理和可维护。
单元格 8-13:背景介绍和目录
这些单元格都是 Markdown 格式,包含了关于 Transformer 模型的背景介绍。它们解释了为什么需要 Transformer,以及它相比之前的模型有什么优势。这些都是文字说明,不是代码,所以我们直接跳到核心的模型实现部分。
单元格 14:EncoderDecoder 类 - Transformer 的整体架构
是什么: Transformer的顶层控制器
class EncoderDecoder(nn.Module):
"""
A standard Encoder-Decoder architecture. Base for this and many
other models.
"""
def __init__(self, encoder, decoder, src_embed, tgt_embed, generator):
super(EncoderDecoder, self).__init__()
self.encoder = encoder
self.decoder = decoder
self.src_embed = src_embed
self.tgt_embed = tgt_embed
self.generator = generator
def forward(self, src, tgt, src_mask, tgt_mask):
"Take in and process masked src and target sequences."
return self.decode(self.encode(src, src_mask), src_mask, tgt, tgt_mask)
def encode(self, src, src_mask):
return self.encoder(self.src_embed(src), src_mask)
def decode(self, memory, src_mask, tgt, tgt_mask):
return self.decoder(self.tgt_embed(tgt), memory, src_mask, tgt_mask)
面向对象编程的核心概念:
- 类定义:
class
创建新的数据类型,定义对象的结构和行为 - 继承机制:
(nn.Module)
从PyTorch基类继承功能 - 组合模式:将多个组件组合成一个完整的系统
继承 nn.Module 的深层原因:
- 参数自动注册:所有神经网络参数被自动跟踪
- 设备管理:一键在CPU/GPU间移动模型
- 状态管理:train()/eval()模式自动切换
- 序列化支持:模型保存/加载功能
- 钩子系统:支持前向/后向传播的钩子函数
为什么这样设计: 编码器-解码器的架构优势
序列到序列任务的挑战:
- 变长输入输出:输入和输出序列长度可能不同
- 语义理解:需要理解输入的完整含义
- 生成控制:输出需要逐步生成,保持连贯性
- 注意力机制:需要在生成时关注输入的不同部分
编码器-解码器分离的好处:
- 职责明确:编码器专注理解,解码器专注生成
- 可扩展性:两部分可以独立改进
- 通用性:这种架构适用于多种seq2seq任务
- 并行化:编码器可以并行处理整个输入序列
组件设计哲学:
encoder
:理解模块 - 将输入序列编码为语义表示decoder
:生成模块 - 基于编码表示生成输出序列src_embed
:输入映射 - 将源语言词汇转为向量空间tgt_embed
:输出映射 - 将目标语言词汇转为向量空间generator
:概率输出 - 将隐藏状态转为词汇概率
怎么做: 数据流和处理流程
前向传播的完整流程:
def forward(self, src, tgt, src_mask, tgt_mask):
return self.decode(self.encode(src, src_mask), src_mask, tgt, tgt_mask)
步骤分解:
-
编码阶段:
self.encode(src, src_mask)
- 源序列经过嵌入层转为向量
- 编码器处理整个序列,产生语义表示
-
解码阶段:
self.decode(memory, src_mask, tgt, tgt_mask)
- 目标序列经过嵌入层
- 解码器基于编码结果生成输出
掩码机制的重要性:
src_mask
:隐藏源序列的填充部分tgt_mask
:隐藏目标序列的填充部分和未来信息(因果掩码)
方法设计的解耦原则:
def encode(self, src, src_mask):
return self.encoder(self.src_embed(src), src_mask)
def decode(self, memory, src_mask, tgt, tgt_mask):
return self.decoder(self.tgt_embed(tgt), memory, src_mask, tgt_mask)
- 单一职责:每个方法只负责一个明确的功能
- 可测试性:可以单独测试编码或解码过程
- 可复用性:推理时只需要调用encode一次,decode多次
实际使用场景:
# 训练时:teacher forcing,已知完整目标序列
output = model(src_tokens, tgt_tokens, src_mask, tgt_mask)
# 推理时:逐步生成
memory = model.encode(src_tokens, src_mask)
for step in range(max_length):
output = model.decode(memory, src_mask, generated_so_far, causal_mask)
next_token = select_next_token(output)
generated_so_far = append(generated_so_far, next_token)
单元格 15:Generator 类 - 将隐藏状态转换为词汇概率
是什么: 概率分布生成器
class Generator(nn.Module):
"Define standard linear + softmax generation step."
def __init__(self, d_model, vocab):
super(Generator, self).__init__()
self.proj = nn.Linear(d_model, vocab)
def forward(self, x):
return log_softmax(self.proj(x), dim=-1)
神经网络输出层的本质:
- 线性投影:将高维隐藏状态映射到词汇空间
- 概率归一化:将实数分数转换为概率分布
- 对数空间:在对数域进行计算,提高数值稳定性
为什么需要Generator: 从向量到词汇的转换
高维向量的语义问题:
- 解码器输出:每个位置产生d_model维向量(如512维)
- 词汇表映射:需要判断这个向量对应哪个词汇
- 概率解释:需要为每个可能的词汇分配概率
- 可微分性:整个过程必须支持梯度传播
线性投影的数学原理:
self.proj = nn.Linear(d_model, vocab)
# 数学表示:output = input × W + b
# W: (d_model, vocab) 权重矩阵
# b: (vocab,) 偏置向量
为什么使用线性层:
- 计算效率:矩阵乘法可以高度并行化
- 表达能力:线性变换足以学习向量到词汇的映射
- 梯度友好:线性函数的梯度计算简单稳定
- 内存效率:相比非线性层,参数量相对可控
怎么做: 概率计算的技术细节
Softmax函数的数学含义:
softmax(x_i) = exp(x_i) / Σ(exp(x_j))
- 将任意实数转换为概率(0-1之间,和为1)
- 较大的输入值得到较高的概率
Log-Softmax的优势:
return log_softmax(self.proj(x), dim=-1)
为什么使用对数概率:
- 数值稳定性:避免exp()函数的上溢/下溢
- 计算精度:在对数域计算更精确
- 损失函数:交叉熵损失天然使用对数概率
- 梯度计算:对数函数的梯度更稳定
维度处理的详细说明:
dim=-1
:在最后一个维度应用softmax- 输入形状:
(batch_size, seq_length, d_model)
- 输出形状:
(batch_size, seq_length, vocab_size)
- 每个位置都有一个完整的词汇概率分布
实际计算流程:
# 假设 d_model=512, vocab_size=30000
hidden_state = torch.randn(32, 20, 512) # (batch, length, hidden)
generator = Generator(512, 30000)
# 1. 线性投影
logits = generator.proj(hidden_state) # (32, 20, 30000)
# 2. 对数概率计算
log_probs = log_softmax(logits, dim=-1) # (32, 20, 30000)
# 3. 每个位置的概率分布
# log_probs[0, 0, :] 是第一个样本第一个位置的词汇对数概率
与训练损失的连接:
# Generator输出对数概率
log_probs = generator(decoder_output)
# 交叉熵损失直接使用对数概率
loss = F.nll_loss(log_probs.view(-1, vocab_size), target_tokens.view(-1))
这种设计确保了从语义向量到词汇选择的平滑过渡,是整个生成过程的关键最后一步。
单元格 16-18:图片和说明
这些单元格包含了 Transformer 架构的图片说明,帮助理解模型的整体结构。
单元格 19:clones 函数 - 创建多个相同的层
是什么: 神经网络层的复制工厂
def clones(module, N):
"Produce N identical layers."
return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
函数的核心功能:
- 输入:一个神经网络模块和要复制的次数
- 输出:N个结构相同但参数独立的模块列表
- 目的:批量创建具有相同架构的神经网络层
为什么需要这个函数: Transformer的结构重复性
Transformer的架构特点:
- 编码器堆叠:通常包含6个相同的编码器层
- 解码器堆叠:通常包含6个相同的解码器层
- 多头注意力:每层包含多个相同结构的注意力头
- 层的一致性:保持每层架构相同,但参数独立
为什么不能简单复制引用:
# 错误的做法
layer = SomeLayer()
layers = [layer] * 6 # 所有元素指向同一个对象!
# 正确的做法
layers = clones(layer, 6) # 每个都是独立的对象
参数独立性的重要性:
- 学习差异化:每层需要学习不同的特征表示
- 梯度更新:参数必须能够独立更新
- 功能分工:不同层可能专注于不同的语言现象
怎么做: 深度复制与容器管理
深度复制的技术细节:
copy.deepcopy(module)
深拷贝 vs 浅拷贝的区别:
-
浅拷贝:只复制对象的第一层引用
shallow_copy = copy.copy(module) # 参数仍然共享!
-
深拷贝:递归复制所有层级的对象
deep_copy = copy.deepcopy(module) # 完全独立的副本
列表推导式的解析:
[copy.deepcopy(module) for _ in range(N)]
range(N)
:生成0到N-1的序列for _ in range(N)
:下划线表示不使用循环变量copy.deepcopy(module)
:每次循环创建一个独立副本
nn.ModuleList的特殊作用:
return nn.ModuleList([...])
为什么不用普通的Python列表:
- 参数注册:PyTorch自动识别和注册其中的所有参数
- 设备管理:
.to(device)
会自动移动所有子模块 - 状态管理:
.train()
和.eval()
会递归应用到所有子模块 - 序列化支持:模型保存时会包含所有子模块
实际使用示例:
# 创建一个注意力层模板
attention_template = MultiHeadedAttention(8, 512)
# 创建6个独立的编码器层
encoder_layers = clones(EncoderLayer(512, attention_template, ff_layer, 0.1), 6)
# 验证参数独立性
print(id(encoder_layers[0].self_attn)) # 不同的内存地址
print(id(encoder_layers[1].self_attn)) # 证明是独立对象
内存和计算考虑:
- 内存开销:每个副本都占用独立的内存空间
- 初始化一致性:所有副本从相同的初始状态开始
- 训练动态:通过梯度下降,各层参数会逐渐分化
单元格 20:Encoder 类 - 编码器的实现
是什么: 多层编码器的堆叠容器
class Encoder(nn.Module):
"Core encoder is a stack of N layers"
def __init__(self, layer, N):
super(Encoder, self).__init__()
self.layers = clones(layer, N)
self.norm = LayerNorm(layer.size)
def forward(self, x, mask):
"Pass the input (and mask) through each layer in turn."
for layer in self.layers:
x = layer(x, mask)
return self.norm(x)
编码器的架构设计:
- 层次结构:由N个相同结构的编码器层堆叠而成
- 顺序处理:输入依次通过每一层进行处理
- 最终规范化:在输出前进行层归一化
为什么采用堆叠设计: 深度学习的表示能力
深度网络的优势:
-
层次特征学习:
- 浅层:学习局部特征(如词汇、短语)
- 深层:学习全局特征(如语法、语义)
-
非线性表达能力:
- 每层增加模型的非线性变换能力
- 更深的网络可以表示更复杂的函数
-
渐进式抽象:
- 第1层:词汇级别的表示
- 第2-3层:短语级别的表示
- 第4-6层:句子级别的语义表示
为什么是6层:
- 经验最优:原论文实验发现6层在性能和计算成本间的平衡点
- 梯度传播:足够深以学习复杂特征,又不至于梯度消失
- 计算效率:训练时间和性能的权衡
怎么做: 前向传播的具体实现
顺序处理的实现:
def forward(self, x, mask):
for layer in self.layers:
x = layer(x, mask)
return self.norm(x)
数据流分析:
- 输入:
x
是嵌入后的序列,mask
是注意力掩码 - 逐层处理:每层接收前一层的输出作为输入
- 掩码传递:所有层共享相同的掩码信息
- 最终归一化:输出前进行层归一化处理
为什么在最后进行层归一化: 确保编码器的最终输出具有稳定的数值分布,为后续的解码器或其他组件提供良好的输入。 经过6层的残差连接后,数值可能会发生累积偏移,最后的归一化起到校正作用。
return self.norm(x)
单元格 21-22:LayerNorm 类 - 层归一化
是什么: 神经网络中的数据标准化技术
class LayerNorm(nn.Module):
"Construct a layernorm module (See citation for details)."
def __init__(self, features, eps=1e-6):
super(LayerNorm, self).__init__()
self.a_2 = nn.Parameter(torch.ones(features))
self.b_2 = nn.Parameter(torch.zeros(features))
self.eps = eps
def forward(self, x):
mean = x.mean(-1, keepdim=True)
std = x.std(-1, keepdim=True)
return self.a_2 * (x - mean) / (std + self.eps) + self.b_2
层归一化的数学本质:
- 标准化操作:将数据转换为均值为0、标准差为1的分布
- 仿射变换:通过可学习参数进行缩放和平移
- 数值稳定性:防止数值计算中的溢出和下溢问题
为什么需要层归一化: 深度网络训练的挑战
深度网络的训练问题:
- 内部协变量偏移:随着网络层数增加,每层的输入分布不断变化
- 梯度爆炸/消失:深层网络中梯度可能变得极大或极小
- 学习率敏感性:不同的初始化和学习率对性能影响巨大
- 收敛缓慢:训练过程可能非常缓慢或不稳定
层归一化的解决方案:
- 稳定分布:每层的输入保持相似的数值范围
- 加速收敛:标准化的数据更容易优化
- 减少依赖:对权重初始化和学习率不那么敏感
- 提升性能:通常能获得更好的最终效果
层归一化 vs 批归一化的区别:
- 批归一化:在批次维度上计算统计量,适用于CNN
- 层归一化:在特征维度上计算统计量,适用于RNN和Transformer
- 独立性:层归一化不依赖批次大小,更适合变长序列
怎么做: 数学公式和实现细节
数学公式详解:
μ = mean(x) # 计算均值
σ = std(x) # 计算标准差
x_norm = (x - μ) / (σ + ε) # 标准化
y = γ * x_norm + β # 仿射变换
参数的含义和作用:
self.a_2 = nn.Parameter(torch.ones(features)) # γ (gamma) 缩放参数
self.b_2 = nn.Parameter(torch.zeros(features)) # β (beta) 偏移参数
self.eps = eps # ε (epsilon) 数值稳定项
nn.Parameter的特殊性:
- 自动注册:PyTorch自动将其识别为模型参数
- 梯度计算:参与反向传播,可以被优化器更新
- 设备管理:跟随模型在GPU/CPU间移动
- 序列化:模型保存时包含这些参数的值
前向传播的具体实现:
def forward(self, x):
mean = x.mean(-1, keepdim=True) # 沿最后一维计算均值
std = x.std(-1, keepdim=True) # 沿最后一维计算标准差
return self.a_2 * (x - mean) / (std + self.eps) + self.b_2
维度处理的技术细节:
dim=-1
:在最后一个维度(特征维度)上计算统计量keepdim=True
:保持维度,便于广播运算- 输入shape:
[batch_size, seq_length, d_model]
- 统计量shape:
[batch_size, seq_length, 1]
为什么在特征维度归一化:
# 假设输入形状为 [2, 3, 4] (batch=2, seq=3, features=4)
x = torch.randn(2, 3, 4)
mean = x.mean(-1, keepdim=True) # shape: [2, 3, 1]
# 每个位置的4个特征被独立归一化
- 语义一致性:每个位置的特征向量代表语义信息
- 尺度统一:不同特征维度的数值范围得到统一
- 位置独立:每个序列位置的归一化是独立的
初始化策略的原理:
torch.ones(features)
:γ初始化为1,保持标准化后的方差torch.zeros(features)
:β初始化为0,保持标准化后的均值- 这样初始状态下,层归一化相当于标准的z-score标准化
数值稳定性考虑:
std + self.eps # 防止除零错误
- 当标准差接近0时,加上小常数防止数值不稳定
eps=1e-6
是经验值,平衡精度和稳定性
实际效果演示:
# 未归一化的数据可能范围很大
before = torch.tensor([[-100.0, 50.0, 200.0], [0.1, 0.2, 0.3]])
layer_norm = LayerNorm(3)
after = layer_norm(before)
# 归一化后每行的均值接近0,标准差接近1
print(after.mean(-1)) # 接近 [0, 0]
print(after.std(-1)) # 接近 [1, 1]
这种标准化确保了神经网络中每一层都能接收到数值稳定、分布一致的输入,是Transformer稳定训练的重要基础。
class Encoder(nn.Module):
"Core encoder is a stack of N layers"
def __init__(self, layer, N):
super(Encoder, self).__init__()
self.layers = clones(layer, N)
self.norm = LayerNorm(layer.size)
def forward(self, x, mask):
"Pass the input (and mask) through each layer in turn."
for layer in self.layers:
x = layer(x, mask)
return self.norm(x)
编码器的作用: 编码器的任务是理解输入序列(比如一个德语句子),并将其转换为一系列向量表示,这些向量包含了句子的语义信息。
构造函数分析:
def __init__(self, layer, N):
super(Encoder, self).__init__()
self.layers = clones(layer, N)
self.norm = LayerNorm(layer.size)
layer
:单个编码器层的模板N
:编码器层的数量(论文中是 6)self.layers = clones(layer, N)
:创建 N 个相同的编码器层self.norm = LayerNorm(layer.size)
:最后的层归一化
forward 方法分析:
def forward(self, x, mask):
"Pass the input (and mask) through each layer in turn."
for layer in self.layers:
x = layer(x, mask)
return self.norm(x)
数据流动过程:
- 输入
x
是词汇的向量表示 mask
告诉模型哪些位置是真实的词汇,哪些是填充- 依次通过每个编码器层,每一层都会更新
x
- 最后应用层归一化,得到最终的编码表示
为什么要逐层处理:
- 每一层都会从不同角度分析输入
- 浅层关注局部特征(如词汇和短语)
- 深层关注全局特征(如句子的整体含义)
- 堆叠多层可以获得更丰富的表示
mask 的重要性: 在批处理时,不同的句子长度不同,短句子会用特殊符号填充。mask 告诉模型忽略这些填充位置,只关注真实的内容。
单元格 21-22:LayerNorm 类 - 层归一化
class LayerNorm(nn.Module):
"Construct a layernorm module (See citation for details)."
def __init__(self, features, eps=1e-6):
super(LayerNorm, self).__init__()
self.a_2 = nn.Parameter(torch.ones(features))
self.b_2 = nn.Parameter(torch.zeros(features))
self.eps = eps
def forward(self, x):
mean = x.mean(-1, keepdim=True)
std = x.std(-1, keepdim=True)
return self.a_2 * (x - mean) / (std + self.eps) + self.b_2
层归一化的作用: 层归一化是深度学习中的重要技术,它可以:
- 稳定训练过程
- 加快收敛速度
- 减少对初始化的依赖
- 提高模型性能
参数解释:
features
:输入特征的维度eps
:很小的数值(1e-6),防止除零错误self.a_2
:可学习的缩放参数,初始化为 1self.b_2
:可学习的偏移参数,初始化为 0
nn.Parameter 的含义:
nn.Parameter
将普通张量转换为模型参数,这意味着:
- 优化器会自动更新这些参数
- 模型保存时会包含这些参数
- 可以通过
model.parameters()
访问
归一化过程:
mean = x.mean(-1, keepdim=True) # 计算均值
std = x.std(-1, keepdim=True) # 计算标准差
return self.a_2 * (x - mean) / (std + self.eps) + self.b_2
- 计算输入的均值和标准差
- 将输入标准化:
(x - mean) / (std + eps)
- 应用可学习的缩放和偏移:
a_2 * 标准化值 + b_2
为什么在最后一个维度归一化:
-1
表示最后一个维度- 对于形状为
[batch_size, sequence_length, features]
的张量 - 我们在
features
维度上进行归一化 - 这意味着每个位置的特征向量都被独立归一化
单元格 23-24:SublayerConnection 类 - 残差连接和层归一化
是什么: 深度网络的连接模式
class SublayerConnection(nn.Module):
"""
A residual connection followed by a layer norm.
Note for code simplicity the norm is first as opposed to last.
"""
def __init__(self, size, dropout):
super(SublayerConnection, self).__init__()
self.norm = LayerNorm(size)
self.dropout = nn.Dropout(dropout)
def forward(self, x, sublayer):
"Apply residual connection to any sublayer with the same size."
return x + self.dropout(sublayer(self.norm(x)))
残差连接的核心概念:
- 跳跃连接:直接将输入加到输出上,创建”高速公路”
- 恒等映射:当子层学习为零函数时,输出等于输入
- 梯度流动:为梯度提供直接的反向传播路径
为什么需要残差连接: 解决深度网络的根本问题
深度网络的退化问题:
- 梯度消失:在深层网络中,梯度在反向传播时逐层衰减
- 训练困难:单纯堆叠层数并不能保证性能提升
- 表示学习:网络难以学习恒等映射,即”什么都不做”
残差连接的革命性解决方案:
- 梯度高速公路:梯度可以直接从输出传播到输入
- 学习简化:网络只需学习”残差”(调整量)而非完整映射
- 深度可扩展:理论上可以训练非常深的网络
数学表达的深层含义:
传统网络:H(x) = F(x)
残差网络:H(x) = F(x) + x
等价于: F(x) = H(x) - x (学习残差)
为什么学习残差更容易:
- 如果最优映射就是恒等映射,F(x)只需学习为0
- 如果需要微调,F(x)只需学习小的调整量
- 比从头学习完整映射H(x)要简单得多
怎么做: 技术实现和设计选择
前向传播的执行顺序:
return x + self.dropout(sublayer(self.norm(x)))
步骤分解:
self.norm(x)
:输入先经过层归一化sublayer(...)
:标准化后的输入通过子层(如注意力层)self.dropout(...)
:对子层输出应用dropout正则化x + ...
:原始输入与处理后输出相加
层归一化前置的设计选择:
# Pre-LN (本实现):先归一化再处理
output = x + dropout(sublayer(norm(x)))
# Post-LN (原论文):先处理再归一化
output = norm(x + dropout(sublayer(x)))
Pre-LN的优势:
- 训练稳定性:归一化的输入让子层更容易训练
- 梯度流动:更好的梯度传播特性
- 收敛速度:通常收敛更快,需要更少的warmup
Dropout正则化的作用机制:
self.dropout = nn.Dropout(dropout)
Dropout的工作原理:
- 训练时:随机将部分神经元输出设为0
- 推理时:使用所有神经元,但按比例缩放
- 正则化:防止过拟合,提高泛化能力
为什么对残差应用Dropout:
- 噪声注入:为学习过程增加随机性
- 鲁棒性:让模型不过度依赖特定的连接
- 泛化能力:提高在新数据上的表现
子层接口的通用性:
def forward(self, x, sublayer):
设计哲学:
sublayer
可以是任何接受相同输入输出维度的层- 注意力层、前馈网络等都可以作为子层
- 实现了高度的模块化和可复用性
实际使用示例:
# 在编码器层中使用
class EncoderLayer(nn.Module):
def __init__(self, size, self_attn, feed_forward, dropout):
super(EncoderLayer, self).__init__()
self.self_attn = self_attn
self.feed_forward = feed_forward
self.sublayer = clones(SublayerConnection(size, dropout), 2)
self.size = size
def forward(self, x, mask):
# 第一个残差连接:自注意力
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
# 第二个残差连接:前馈网络
return self.sublayer[1](x, self.feed_forward)
lambda函数的巧妙使用:
lambda x: self.self_attn(x, x, x, mask)
- 将多参数函数包装成单参数函数
- 满足SublayerConnection的接口要求
- 闭包捕获了mask变量
残差连接的数值效果:
# 假设输入和子层输出
x = torch.randn(2, 10, 512) # 原始输入
sublayer_output = torch.randn(2, 10, 512) # 子层输出
# 没有残差连接:只有子层输出
without_residual = sublayer_output
# 有残差连接:原始输入+子层输出
with_residual = x + sublayer_output
# 梯度流动:
# without_residual对x的梯度 = 0
# with_residual对x的梯度 = 1 + 子层梯度 (至少保证梯度为1)
这种设计确保了即使在非常深的网络中,梯度也能有效地传播到早期层,是Transformer能够训练深层网络的关键技术。
单元格 25-26:EncoderLayer 类 - 编码器的单个层
是什么: 编码器的基本构建块
class EncoderLayer(nn.Module):
"Encoder is made up of self-attn and feed forward (defined below)"
def __init__(self, size, self_attn, feed_forward, dropout):
super(EncoderLayer, self).__init__()
self.self_attn = self_attn
self.feed_forward = feed_forward
self.sublayer = clones(SublayerConnection(size, dropout), 2)
self.size = size
def forward(self, x, mask):
"Follow Figure 1 (left) for connections."
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
return self.sublayer[1](x, self.feed_forward)
编码器层的双重结构:
- 自注意力机制:让序列中的每个位置关注其他位置
- 前馈网络:对每个位置独立进行非线性变换
- 残差连接:确保信息流动和梯度传播
为什么这样设计: Transformer的核心创新
自注意力的革命性意义:
- 并行计算:不像RNN需要顺序处理,可以并行处理所有位置
- 长距离依赖:直接建立任意两个位置之间的连接
- 动态权重:注意力权重根据输入内容动态调整
- 位置无关:不受固定窗口大小限制
前馈网络的补充作用:
- 非线性变换:注意力机制本身是线性的,需要非线性增强表达能力
- 位置独立处理:对每个位置进行相同的变换
- 特征混合:在更高维空间中混合特征
- 模式识别:学习复杂的语言模式
两阶段处理的协同效应:
- 注意力阶段:关注什么(Where to look)
- 前馈阶段:处理什么(What to do)
怎么做: 具体实现和技术细节
初始化中的组件管理:
def __init__(self, size, self_attn, feed_forward, dropout):
self.self_attn = self_attn
self.feed_forward = feed_forward
self.sublayer = clones(SublayerConnection(size, dropout), 2)
self.size = size
组件的职责分工:
self_attn
:自注意力层,处理序列间的关系feed_forward
:前馈网络,进行位置级的特征变换sublayer
:两个残差连接层,包装上述两个组件size
:特征维度,确保所有组件的维度一致性
前向传播的两阶段处理:
def forward(self, x, mask):
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
return self.sublayer[1](x, self.feed_forward)
第一阶段:自注意力处理
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
自注意力的三个输入:
- 第一个
x
:Query(查询),“我想要什么信息” - 第二个
x
:Key(键),“我有什么信息” - 第三个
x
:Value(值),“具体的信息内容” mask
:掩码,控制注意力的范围
为什么自注意力需要Q、K、V都是同一个输入:
- 自注意力:序列与自身的注意力
- 位置关系:每个位置都可以作为查询者和被查询者
- 信息整合:允许每个位置收集来自所有位置的信息
第二阶段:前馈网络处理
return self.sublayer[1](x, self.feed_forward)
前馈网络的作用:
- 接收注意力处理后的表示
- 进行独立的非线性变换
- 输出增强的特征表示
lambda函数的封装技巧:
lambda x: self.self_attn(x, x, x, mask)
为什么需要lambda封装:
- 接口适配:SublayerConnection期望单参数函数
- 参数绑定:将4参数的注意力函数转为1参数函数
- 闭包捕获:自动捕获mask变量
- 代码简洁:避免定义额外的辅助函数
数据流动的完整路径:
# 输入:[batch_size, seq_length, d_model]
input_x = x
# 第一个残差连接:self-attention
# 1. 层归一化
norm_x = layer_norm(input_x)
# 2. 自注意力
attn_output = self_attention(norm_x, norm_x, norm_x, mask)
# 3. dropout + 残差连接
x = input_x + dropout(attn_output)
# 第二个残差连接:feed-forward
# 1. 层归一化
norm_x = layer_norm(x)
# 2. 前馈网络
ff_output = feed_forward(norm_x)
# 3. dropout + 残差连接
output = x + dropout(ff_output)
# 输出:[batch_size, seq_length, d_model]
性能考虑:
- 内存效率:残差连接避免了额外的内存分配
- 计算并行:自注意力可以高度并行化
- 梯度稳定:残差连接保证梯度流动
这种双阶段设计使得编码器层既能捕获序列内的关系(通过自注意力),又能进行深度的特征变换(通过前馈网络),是Transformer强大表示能力的核心。
def forward(self, x, mask):
"Follow Figure 1 (left) for connections."
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
return self.sublayer[1](x, self.feed_forward)
编码器层的组成: 每个编码器层包含两个主要组件:
- 自注意力机制 (self-attention)
- 前馈神经网络 (feed-forward network)
构造函数分析:
size
:层的维度大小self_attn
:自注意力模块feed_forward
:前馈网络模块dropout
:dropout 概率self.sublayer = clones(SublayerConnection(size, dropout), 2)
:创建两个残差连接
forward 方法详解:
第一个子层(自注意力):
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
lambda x: self.self_attn(x, x, x, mask)
是一个匿名函数- 将相同的输入
x
作为 query、key、value 传给自注意力 - 这就是”自”注意力的含义:序列关注自己
第二个子层(前馈网络):
return self.sublayer[1](x, self.feed_forward)
- 将自注意力的输出传给前馈网络
- 前馈网络对每个位置独立地进行非线性变换
lambda 函数解释:
lambda x: self.self_attn(x, x, x, mask)
lambda
是 Python 中创建匿名函数的关键字- 等价于:
def temp_function(x):
return self.self_attn(x, x, x, mask)
- 用 lambda 更简洁,适合简单的一行函数
数据流动过程:
输入 x
→ 层归一化
→ 自注意力
→ dropout
→ 残差连接
→ 层归一化
→ 前馈网络
→ dropout
→ 残差连接
→ 输出
单元格 27-29:Decoder 相关类 - 解码器实现
是什么: 序列生成的核心引擎
class Decoder(nn.Module):
"Generic N layer decoder with masking."
def __init__(self, layer, N):
super(Decoder, self).__init__()
self.layers = clones(layer, N)
self.norm = LayerNorm(layer.size)
def forward(self, x, memory, src_mask, tgt_mask):
for layer in self.layers:
x = layer(x, memory, src_mask, tgt_mask)
return self.norm(x)
解码器的根本使命:
- 序列生成:逐步生成目标序列的每个位置
- 条件生成:基于源序列(编码器输出)生成目标序列
- 自回归特性:每个位置的生成依赖于之前已生成的位置
为什么解码器更复杂: 生成任务的挑战
编码器 vs 解码器的根本差异:
- 编码器:理解任务,可以看到完整输入,并行处理
- 解码器:生成任务,只能看到已生成部分,顺序依赖
解码器面临的三重挑战:
- 自回归约束:不能”偷看”未来的词汇
- 条件依赖:必须基于源序列信息生成
- 长序列生成:保持生成过程的一致性和连贯性
为什么需要memory参数:
def forward(self, x, memory, src_mask, tgt_mask):
memory
:编码器的输出,包含源序列的完整语义信息- 解码器必须”记住”源序列的内容才能正确翻译
怎么做: 解码器的具体实现机制
多层堆叠的生成策略:
for layer in self.layers:
x = layer(x, memory, src_mask, tgt_mask)
逐层抽象的生成过程:
- 浅层:局部语法结构生成
- 中层:短语级别的语义转换
- 深层:句子级别的语义一致性
四种掩码的协同作用:
src_mask
:隐藏源序列的填充部分tgt_mask
:防止解码器看到未来位置- 确保生成过程的正确性和有效性
是什么: 解码器的基本构建单元
class DecoderLayer(nn.Module):
"Decoder is made of self-attn, src-attn, and feed forward (defined below)"
def __init__(self, size, self_attn, src_attn, feed_forward, dropout):
super(DecoderLayer, self).__init__()
self.size = size
self.self_attn = self_attn
self.src_attn = src_attn
self.feed_forward = feed_forward
self.sublayer = clones(SublayerConnection(size, dropout), 3)
def forward(self, x, memory, src_mask, tgt_mask):
"Follow Figure 1 (right) for connections."
m = memory
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, tgt_mask))
x = self.sublayer[1](x, lambda x: self.src_attn(x, m, m, src_mask))
return self.sublayer[2](x, self.feed_forward)
解码器层的三阶段处理架构:
- 阶段1:自注意力 - 整合已生成的目标序列信息
- 阶段2:交叉注意力 - 结合源序列的语义信息
- 阶段3:前馈网络 - 进行最终的特征变换
为什么需要三个组件: 生成任务的复杂性
自注意力的必要性:
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, tgt_mask))
自注意力解决的问题:
- 内部一致性:确保已生成部分的语法和语义一致
- 长距离依赖:让当前位置关注远程的已生成内容
- 上下文整合:整合目标序列的局部上下文
交叉注意力的关键作用:
x = self.sublayer[1](x, lambda x: self.src_attn(x, m, m, src_mask))
Query-Key-Value的分工:
- Query (x):当前目标位置的”问题” - “我需要什么信息?”
- Key (m):源序列的”索引” - “我有什么信息?”
- Value (m):源序列的”内容” - “具体信息是什么?”
交叉注意力的核心作用:
- 信息检索:从源序列中检索相关信息
- 对齐学习:学习源语言和目标语言的对应关系
- 条件生成:基于源序列内容生成合适的目标词汇
前馈网络的补强作用:
return self.sublayer[2](x, self.feed_forward)
- 在整合了目标和源序列信息后,进行最终的非线性变换
- 将复杂的语义表示转换为可用于下一层的格式
怎么做: 三阶段协同的技术实现
注意力机制参数模式的深层含义:
自注意力模式:
self.self_attn(x, x, x, tgt_mask)
# Q=目标序列, K=目标序列, V=目标序列
# 目标序列关注自身,但不能看到未来
交叉注意力模式:
self.src_attn(x, m, m, src_mask)
# Q=目标序列, K=源序列, V=源序列
# 目标序列查询源序列信息
残差连接的三重保护:
self.sublayer = clones(SublayerConnection(size, dropout), 3)
- 每个阶段都有独立的残差连接
- 确保梯度能够顺利传播到所有层
- 防止信息在多阶段处理中丢失
lambda函数的参数绑定技巧:
lambda x: self.src_attn(x, m, m, src_mask)
- 将4参数函数适配为SublayerConnection要求的1参数接口
m
和src_mask
通过闭包机制被捕获- 体现了函数式编程的优雅
数据流的完整路径:
# 输入:部分生成的目标序列 + 完整源序列编码
target_partial = x # [batch, target_len, d_model]
source_memory = memory # [batch, source_len, d_model]
# 阶段1:目标序列自注意力
self_attended = self_attention(target_partial, target_partial, target_partial, tgt_mask)
x = x + dropout(layernorm(self_attended))
# 阶段2:交叉注意力(目标查询源)
cross_attended = cross_attention(x, source_memory, source_memory, src_mask)
x = x + dropout(layernorm(cross_attended))
# 阶段3:前馈网络
ff_output = feed_forward(x)
output = x + dropout(layernorm(ff_output))
性能优化的设计考虑:
- 并行化:自注意力和交叉注意力都可以并行计算
- 缓存重用:推理时可以缓存已计算的key和value
- 内存效率:残差连接避免额外的内存分配
这种三阶段设计使得解码器既能维护生成序列的内部一致性,又能有效利用源序列信息,还能进行复杂的语义变换,是序列到序列生成任务的核心架构。
单元格 30-32:subsequent_mask 函数 - 防止看到未来
是什么: 自回归生成的守护机制
def subsequent_mask(size):
"Mask out subsequent positions."
attn_shape = (1, size, size)
subsequent_mask = torch.triu(torch.ones(attn_shape), diagonal=1).type(
torch.uint8
)
return subsequent_mask == 0
掩码的数学本质:
- 上三角矩阵:只保留下三角和对角线的信息
- 因果关系:确保时间步t只能看到时间步≤t的信息
- 自回归约束:维护生成过程的单向性
为什么需要这个掩码: 自回归生成的根本要求
自回归模型的基本原理:
- 在生成序列时,每个位置只能依赖于前面已经生成的位置
- 这模拟了真实的生成过程:写文章时,下一句依赖于前面已写的内容
“作弊”问题的严重性:
- 训练与推理不一致:训练时如果能看到未来,推理时却不能
- 模型能力虚高:模型可能学会”抄答案”而非真正理解
- 泛化能力差:在真实场景中性能会大幅下降
因果掩码的哲学意义:
- 时间的不可逆性:模拟现实世界中时间的单向流动
- 信息的渐进性:知识和理解是逐步积累的过程
- 决策的顺序性:每个决策基于当前可用的信息
怎么做: 掩码的技术实现细节
逐步构建掩码的过程:
def subsequent_mask(size):
attn_shape = (1, size, size) # 步骤1:定义形状
subsequent_mask = torch.triu( # 步骤2:创建上三角
torch.ones(attn_shape),
diagonal=1
).type(torch.uint8) # 步骤3:类型转换
return subsequent_mask == 0 # 步骤4:逻辑反转
关键函数详解:
torch.triu的工作原理:
torch.triu(torch.ones(3, 3), diagonal=1)
# 输出:
# [[0, 1, 1],
# [0, 0, 1],
# [0, 0, 0]]
triu
:“triangular upper”的缩写diagonal=1
:从主对角线上方一位开始- 保留上三角部分,其余置为0
逻辑反转的必要性:
subsequent_mask == 0
- PyTorch的注意力机制中:True表示可以关注,False表示屏蔽
- 我们要屏蔽上三角(未来位置),所以需要反转逻辑
形状设计的考虑:
attn_shape = (1, size, size) # (batch_dim, seq_len, seq_len)
- 第一维为1,便于广播到任意batch_size
- 后两维构成注意力矩阵的形状
实际掩码效果演示:
# 对于长度为4的序列
mask = subsequent_mask(4)
print(mask.squeeze())
# 输出:
# [[True, False, False, False],
# [True, True, False, False],
# [True, True, True, False],
# [True, True, True, True ]]
掩码的语义解释:
- 位置0:只能看到自己(冷启动状态)
- 位置1:可以看到位置0和1(基于前一个词生成)
- 位置2:可以看到位置0,1,2(基于前两个词生成)
- 位置3:可以看到所有位置(基于完整上下文生成)
在注意力计算中的应用:
# 注意力分数计算
scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k)
# 应用掩码
scores.masked_fill_(mask == 0, -1e9) # 将False位置设为很大的负数
# Softmax后,-1e9变为接近0的概率
attention_weights = torch.softmax(scores, dim=-1)
数值技巧的重要性:
- 使用
-1e9
而不是-inf
:避免数值不稳定 - 经过softmax后,大负数变为接近0的正数
- 实现了”软掩码”而非”硬截断”
掩码与训练效率:
# Teacher Forcing + 掩码的组合
# 训练时:已知完整目标序列,但用掩码防止看到未来
target_input = target_sequence[:, :-1] # 去掉最后一个
target_output = target_sequence[:, 1:] # 去掉第一个
# 一次性计算所有位置,但每个位置只能看到合法的历史
decoder_output = decoder(target_input, encoder_memory, src_mask, tgt_mask)
推理时的逐步生成:
# 推理时的自回归生成
generated = [START_TOKEN]
for step in range(max_length):
# 当前掩码只需要考虑已生成的部分
current_mask = subsequent_mask(len(generated))
# 生成下一个token
output = decoder(generated, encoder_memory, src_mask, current_mask)
next_token = output[:, -1, :].argmax(dim=-1)
generated.append(next_token)
if next_token == END_TOKEN:
break
这种掩码机制确保了模型在训练时学会正确的依赖关系,在推理时能够逐步生成合理的序列,是自回归语言模型的核心技术基础。
单元格 33-34:example_mask 函数 - 可视化掩码
这个函数创建了一个可视化图表,展示掩码的效果。它使用 Altair 库创建热力图,帮助理解哪些位置可以相互关注。
单元格 35-37:注意力机制 - Transformer 的核心
是什么: 序列建模的革命性机制
def attention(query, key, value, mask=None, dropout=None):
"Compute 'Scaled Dot Product Attention'"
d_k = query.size(-1)
scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
p_attn = scores.softmax(dim=-1)
if dropout is not None:
p_attn = dropout(p_attn)
return torch.matmul(p_attn, value), p_attn
注意力机制的本质:
- 信息检索系统:从大量信息中找到与当前任务最相关的部分
- 动态权重分配:根据内容自适应地分配注意力权重
- 关系建模工具:建立序列中任意两个位置之间的联系
为什么注意力机制如此重要: 解决序列建模的根本问题
传统RNN的局限性:
- 顺序依赖:必须逐步处理,无法并行计算
- 长距离问题:远距离信息容易丢失(梯度消失)
- 固定容量:隐藏状态容量有限,长序列信息压缩困难
- 位置偏见:更多关注近期信息,忽略远程依赖
注意力机制的革命性解决:
- 直接连接:任意两个位置可以直接交互,路径长度为1
- 并行计算:所有位置可以同时计算注意力
- 动态容量:根据需要分配注意力,没有固定瓶颈
- 全局视野:平等对待所有位置,无位置偏见
Query-Key-Value的哲学意义:
- Query:“我想要什么?” - 表达信息需求
- Key:“我是什么?” - 表达信息标识
- Value:“我有什么?” - 表达信息内容
怎么做: 注意力的计算细节和技术实现
第一步:相似度计算
d_k = query.size(-1)
scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
点积注意力的数学原理:
Attention(Q,K,V) = softmax(QK^T/√d_k)V
为什么使用点积:
- 计算效率:矩阵乘法高度优化,GPU友好
- 语义直觉:点积衡量向量相似度的自然选择
- 可微分性:完全可微,支持端到端训练
- 缩放性:通过批次矩阵操作实现高效并行
缩放因子的深层作用:
/ math.sqrt(d_k)
为什么需要缩放:
- 数值稳定性:防止点积过大导致softmax饱和
- 梯度保持:保持梯度在合理范围,避免梯度消失
- 维度无关:使注意力机制对特征维度不敏感
- 理论基础:保持点积的方差为1(假设输入是标准正态分布)
数学推导:
假设 q_i, k_j 是独立的标准正态分布
E[q_i * k_j] = 0
Var(q_i * k_j) = 1
对于 d_k 维向量:Var(q * k) = d_k
因此需要除以 sqrt(d_k) 来保持方差为1
第二步:掩码应用
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
掩码的精确机制:
mask == 0
:找到需要屏蔽的位置-1e9
:使用大负数而非负无穷,避免数值问题masked_fill
:就地替换,内存效率高
为什么使用-1e9:
- softmax(-1e9) ≈ 0,但仍是有限数值
- 避免NaN和Inf的传播
- 保持数值计算的稳定性
第三步:概率归一化
p_attn = scores.softmax(dim=-1)
Softmax的核心作用:
- 概率解释:将任意实数转为概率分布
- 竞争机制:突出重要信息,抑制不重要信息
- 可微分性:平滑函数,梯度传播友好
- 归一化:确保所有注意力权重和为1
第四步:正则化
if dropout is not None:
p_attn = dropout(p_attn)
注意力Dropout的特殊意义:
- 连接稀疏化:随机断开一些注意力连接
- 鲁棒性增强:防止过度依赖特定位置
- 泛化能力:提高模型的泛化性能
第五步:信息聚合
return torch.matmul(p_attn, value), p_attn
加权聚合的数学含义:
output_i = Σ(attention_weights_ij * value_j)
- 每个输出位置是所有Value的加权平均
- 权重由注意力分数决定
- 实现了内容基础的信息选择
形状变换的追踪:
# 假设:batch_size=32, seq_len=20, d_model=512
query.shape # [32, 20, 512]
key.shape # [32, 20, 512]
value.shape # [32, 20, 512]
# 计算注意力分数
scores.shape # [32, 20, 20] # (batch, seq_len, seq_len)
# 注意力权重
p_attn.shape # [32, 20, 20] # 每行是一个概率分布
# 最终输出
output.shape # [32, 20, 512] # 与输入相同形状
注意力矩阵的解释:
p_attn[b, i, j]
:在批次b中,位置i对位置j的注意力权重- 每行代表一个query对所有key的注意力分布
- 每列代表所有query对某个key的关注程度
实际应用示例:
# 机器翻译场景
# 德语:"Das ist ein Buch"
# 英语:"This is a book"
# 生成"book"时的注意力权重可能是:
# Das: 0.1, ist: 0.1, ein: 0.2, Buch: 0.6
# 模型学会了"Buch"对应"book"
这种设计让模型能够自动发现重要信息,建立长距离依赖,实现并行计算,是Transformer架构成功的核心基础。
单元格 38-40:多头注意力机制 - 让模型从多个角度看问题
是什么: 并行的多视角信息处理系统
class MultiHeadedAttention(nn.Module):
def __init__(self, h, d_model, dropout=0.1):
"Take in model size and number of heads."
super(MultiHeadedAttention, self).__init__()
assert d_model % h == 0
# We assume d_v always equals d_k
self.d_k = d_model // h
self.h = h
self.linears = clones(nn.Linear(d_model, d_model), 4)
self.attn = None
self.dropout = nn.Dropout(p=dropout)
def forward(self, query, key, value, mask=None):
"Implements Figure 2"
if mask is not None:
# Same mask applied to all h heads.
mask = mask.unsqueeze(1)
nbatches = query.size(0)
# 1) Do all the linear projections in batch from d_model => h x d_k
query, key, value = [
lin(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
for lin, x in zip(self.linears, (query, key, value))
]
# 2) Apply attention on all the projected vectors in batch.
x, self.attn = attention(
query, key, value, mask=mask, dropout=self.dropout
)
# 3) "Concat" using a view and apply a final linear.
x = (
x.transpose(1, 2)
.contiguous()
.view(nbatches, -1, self.h * self.d_k)
)
del query
del key
del value
return self.linears[-1](x)
多头注意力的核心理念:
- 认知多样性:不同的”注意力头”关注不同的语言现象
- 并行处理:多个头同时工作,提高计算效率
- 表示丰富性:组合多个视角得到更全面的理解
为什么需要多头: 单头注意力的局限性
单头注意力的问题:
- 视角单一:只能从一个角度理解信息
- 容量限制:单一表示空间可能无法捕获所有模式
- 注意力冲突:不同类型的信息竞争同一个注意力通道
- 表达瓶颈:复杂的语言现象需要多维度表示
多头的优势解析:
-
专业化分工:
- 头1:关注句法结构(主谓宾关系)
- 头2:关注语义相似性
- 头3:关注词汇对应关系
- 头4:关注上下文信息
-
并行效率:
- 所有头同时计算,没有顺序依赖
- 充分利用现代GPU的并行计算能力
-
鲁棒性增强:
- 即使部分头失效,其他头仍能维持功能
- 不同头之间形成互补关系
怎么做: 多头机制的精密实现
初始化的设计思考:
def __init__(self, h, d_model, dropout=0.1):
assert d_model % h == 0
self.d_k = d_model // h
self.h = h
self.linears = clones(nn.Linear(d_model, d_model), 4)
维度分配的数学逻辑:
- 约束条件:
d_model % h == 0
确保均匀分割 - 单头维度:
d_k = d_model // h
每头处理的维度 - 总维度保持:
h * d_k = d_model
确保信息不丢失
线性层的四重作用:
self.linears = clones(nn.Linear(d_model, d_model), 4)
- W_Q:Query投影矩阵
- W_K:Key投影矩阵
- W_V:Value投影矩阵
- W_O:输出投影矩阵
前向传播的三阶段处理:
阶段1:并行投影和重塑
query, key, value = [
lin(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
for lin, x in zip(self.linears, (query, key, value))
]
张量变换的详细追踪:
# 输入形状
input_shape = [batch_size, seq_len, d_model] # [32, 20, 512]
# 线性投影
projected = linear(input) # [32, 20, 512] → [32, 20, 512]
# 重塑为多头
reshaped = projected.view(32, 20, 8, 64) # [batch, seq, heads, head_dim]
# 转置以便并行处理
transposed = reshaped.transpose(1, 2) # [32, 8, 20, 64] [batch, heads, seq, head_dim]
为什么这样重塑:
- 并行计算:将不同头放在不同维度,便于并行处理
- 内存布局:优化内存访问模式,提高计算效率
- 批次处理:所有头可以用一次矩阵乘法完成
阶段2:并行注意力计算
x, self.attn = attention(query, key, value, mask=mask, dropout=self.dropout)
并行处理的效率分析:
- 输入:每个头获得独立的Q、K、V投影
- 计算:8个头的注意力同时计算
- 输出:每个头产生独立的表示
阶段3:头融合和输出投影
x = (
x.transpose(1, 2)
.contiguous()
.view(nbatches, -1, self.h * self.d_k)
)
return self.linears[-1](x)
融合过程的技术细节:
形状恢复:
x.transpose(1, 2) # [32, 8, 20, 64] → [32, 20, 8, 64]
内存连续性:
.contiguous()
- PyTorch中transpose可能导致内存不连续
- contiguous()确保内存布局适合后续操作
- 这是view()操作的前提条件
拼接操作:
.view(nbatches, -1, self.h * self.d_k) # [32, 20, 8, 64] → [32, 20, 512]
- 将多个头的输出拼接成一个向量
- 相当于concat操作,但更高效
最终投影:
self.linears[-1](x) # 通过W_O矩阵进行最终变换
掩码处理的技巧:
if mask is not None:
mask = mask.unsqueeze(1) # 为头维度添加广播维度
- 原始掩码形状:[batch, seq_len, seq_len]
- 扩展后形状:[batch, 1, seq_len, seq_len]
- 广播到所有头:[batch, heads, seq_len, seq_len]
内存优化的考虑:
del query
del key
del value
- 显式删除中间变量释放GPU内存
- 在大模型训练中,内存管理至关重要
多头注意力的数学公式:
MultiHead(Q,K,V) = Concat(head_1, ..., head_h)W^O
where head_i = Attention(QW_i^Q, KW_i^K, VW_i^V)
实际效果分析:
# 单头注意力容量:512维表示空间
# 多头注意力容量:8个64维子空间 = 8倍的表示多样性
# 每个头专注不同方面:
# head_1: 句法依赖关系
# head_2: 语义相似性
# head_3: 共指消解
# head_4: 词汇对应
# ...
计算复杂度分析:
- 时间复杂度:O(n²d) (n=序列长度,d=模型维度)
- 空间复杂度:O(n²h) (h=头数)
- 并行度:头数量和序列长度都可以并行
这种设计让Transformer能够同时处理多种语言现象,高效利用计算资源,提供丰富的表示能力,是其强大性能的关键技术基础。
class MultiHeadedAttention(nn.Module):
def __init__(self, h, d_model, dropout=0.1):
"Take in model size and number of heads."
super(MultiHeadedAttention, self).__init__()
assert d_model % h == 0
# We assume d_v always equals d_k
self.d_k = d_model // h
self.h = h
self.linears = clones(nn.Linear(d_model, d_model), 4)
self.attn = None
self.dropout = nn.Dropout(p=dropout)
def forward(self, query, key, value, mask=None):
"Implements Figure 2"
if mask is not None:
# Same mask applied to all h heads.
mask = mask.unsqueeze(1)
nbatches = query.size(0)
# 1) Do all the linear projections in batch from d_model => h x d_k
query, key, value = [
lin(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
for lin, x in zip(self.linears, (query, key, value))
]
# 2) Apply attention on all the projected vectors in batch.
x, self.attn = attention(
query, key, value, mask=mask, dropout=self.dropout
)
# 3) "Concat" using a view and apply a final linear.
x = (
x.transpose(1, 2)
.contiguous()
.view(nbatches, -1, self.h * self.d_k)
)
del query
del key
del value
return self.linears[-1](x)
多头注意力的核心思想: 多头注意力就像是让模型从多个不同的角度来观察同一件事情。想象一下:
- 如果你只用一只眼睛看东西,你只能得到一个视角
- 用两只眼睛看,你可以感知深度
- 多头注意力让模型用”多只眼睛”来理解语言
构造函数详解:
def __init__(self, h, d_model, dropout=0.1):
super(MultiHeadedAttention, self).__init__()
assert d_model % h == 0
self.d_k = d_model // h
self.h = h
self.linears = clones(nn.Linear(d_model, d_model), 4)
self.attn = None
self.dropout = nn.Dropout(p=dropout)
参数解释:
h
:注意力头的数量(论文中是 8)d_model
:模型的维度(论文中是 512)dropout
:dropout 概率
assert 语句:
assert d_model % h == 0
assert
是 Python 的断言语句,用于检查条件是否为真- 这里检查
d_model
是否能被h
整除 - 如果不能整除,程序会报错并停止
- 这确保每个头都有相同的维度
关键计算:
self.d_k = d_model // h
//
是整数除法运算符- 如果
d_model=512
,h=8
,那么d_k=64
- 这意味着每个注意力头处理 64 维的特征
线性变换层:
self.linears = clones(nn.Linear(d_model, d_model), 4)
- 创建 4 个线性变换层
- 前 3 个用于 Query、Key、Value 的变换
- 第 4 个用于最后的输出变换
forward 方法详解:
步骤 1:处理掩码
if mask is not None:
mask = mask.unsqueeze(1)
unsqueeze(1)
在第 1 个维度插入一个新维度- 这是为了让掩码适配多头的结构
步骤 2:线性变换和重塑
query, key, value = [
lin(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
for lin, x in zip(self.linears, (query, key, value))
]
这是一个复杂的列表推导式,让我们分解它:
-
zip(self.linears, (query, key, value))
:- 将线性层和输入配对
- 第一个线性层配对 query
- 第二个线性层配对 key
- 第三个线性层配对 value
-
lin(x)
:- 对输入应用线性变换
- 输入形状:
[batch_size, seq_len, d_model]
- 输出形状:
[batch_size, seq_len, d_model]
-
.view(nbatches, -1, self.h, self.d_k)
:- 重塑张量形状
-1
表示自动计算这个维度的大小- 新形状:
[batch_size, seq_len, h, d_k]
- 这将
d_model
维度分割成h
个d_k
维的头
-
.transpose(1, 2)
:- 交换第 1 和第 2 个维度
- 最终形状:
[batch_size, h, seq_len, d_k]
- 这样每个头就可以独立处理了
view() 和 transpose() 详解:
view()
方法:
- 用于改变张量的形状,但不改变数据
- 类似于 numpy 的
reshape()
- 新形状的元素总数必须与原形状相同
transpose()
方法:
- 交换张量的两个维度
.transpose(1, 2)
交换第 1 和第 2 个维度
为什么需要这些操作:
原始输入是 [batch_size, seq_len, d_model]
,我们需要将其转换为 [batch_size, h, seq_len, d_k]
,这样就可以对每个头独立地计算注意力。
步骤 3:计算注意力
x, self.attn = attention(
query, key, value, mask=mask, dropout=self.dropout
)
- 使用之前定义的
attention
函数 - 对所有头同时计算注意力
- 由于张量形状的设计,每个头都会独立计算
步骤 4:合并头并输出
x = (
x.transpose(1, 2)
.contiguous()
.view(nbatches, -1, self.h * self.d_k)
)
del query
del key
del value
return self.linears[-1](x)
合并过程:
.transpose(1, 2)
:将形状从[batch, h, seq_len, d_k]
变为[batch, seq_len, h, d_k]
.contiguous()
:确保内存布局是连续的,这对view()
操作是必需的.view(nbatches, -1, self.h * self.d_k)
:将形状变为[batch, seq_len, d_model]
self.linears[-1](x)
:通过最后一个线性层进行最终变换
内存管理:
del query
del key
del value
- 显式删除不再需要的变量
- 释放 GPU 内存,在处理大型模型时很重要
多头注意力的优势:
- 多样性:每个头可以关注不同类型的模式
- 并行性:所有头可以同时计算
- 表达能力:多个头的组合比单个头更强大
- 稳定性:即使某些头失效,其他头仍能工作
实际例子: 在翻译任务中,不同的头可能关注:
- 头1:语法结构
- 头2:语义关系
- 头3:词汇对应
- 头4:上下文信息
单元格 41:前馈神经网络 - 非线性变换
是什么: 位置级的深度特征变换器
class PositionwiseFeedForward(nn.Module):
"Implements FFN equation."
def __init__(self, d_model, d_ff, dropout=0.1):
super(PositionwiseFeedForward, self).__init__()
self.w_1 = nn.Linear(d_model, d_ff)
self.w_2 = nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
return self.w_2(self.dropout(self.w_1(x).relu()))
前馈网络的本质定位:
- 非线性引入器:为线性的注意力机制添加非线性变换能力
- 特征混合器:在高维空间中重新组合和变换特征
- 位置处理器:对每个序列位置独立进行深度变换
为什么需要前馈网络: 弥补注意力机制的不足
注意力机制的局限性:
- 线性约束:注意力本质上是加权平均,缺乏非线性变换
- 交互单一:主要处理位置间关系,缺乏位置内的深度处理
- 表达瓶颈:单纯的注意力无法学习复杂的函数映射
- 模式识别:难以捕获需要非线性组合的复杂语言模式
前馈网络的补强作用:
- 非线性注入:ReLU激活函数提供强大的非线性建模能力
- 容量扩张:中间层维度扩大4倍,提供充足的表示空间
- 独立处理:每个位置获得深度的、个性化的特征变换
- 模式学习:能够学习复杂的语言模式和规则
与注意力的互补关系:
- 注意力:负责”关注什么”(What to attend)
- 前馈网络:负责”如何处理”(How to process)
怎么做: 前馈网络的精妙设计
网络架构的数学表达:
FFN(x) = max(0, xW₁ + b₁)W₂ + b₂
两层结构的设计逻辑:
self.w_1 = nn.Linear(d_model, d_ff) # 512 → 2048 (扩张)
self.w_2 = nn.Linear(d_ff, d_model) # 2048 → 512 (压缩)
维度变化的战略意图:
-
扩张阶段:
d_model → d_ff
(512 → 2048)- 提供4倍的表示容量
- 允许学习更复杂的特征组合
- 为非线性变换提供充足空间
-
压缩阶段:
d_ff → d_model
(2048 → 512)- 将丰富信息压缩回原始维度
- 强制网络学习最重要的特征
- 保持与其他组件的维度兼容性
前向传播的四阶段处理:
def forward(self, x):
return self.w_2(self.dropout(self.w_1(x).relu()))
阶段1:线性扩张
expanded = self.w_1(x) # [batch, seq_len, 512] → [batch, seq_len, 2048]
- 通过权重矩阵W₁进行线性变换
- 将每个位置的特征向量扩展到高维空间
阶段2:非线性激活
activated = expanded.relu() # 应用ReLU激活函数
ReLU函数的关键作用:
ReLU(x) = max(0, x) = {x if x > 0; 0 if x ≤ 0}
- 稀疏性:约50%的神经元被激活,产生稀疏表示
- 非饱和:正值区域梯度为1,避免梯度消失
- 计算效率:实现简单,计算速度快
- 生物启发:模拟神经元的激活模式
阶段3:正则化
regularized = self.dropout(activated)
- 训练时随机将部分神经元置零
- 防止过拟合,提高泛化能力
- 增强模型鲁棒性
阶段4:线性压缩
output = self.w_2(regularized) # [batch, seq_len, 2048] → [batch, seq_len, 512]
- 将高维表示压缩回原始维度
- 提取最重要的特征信息
位置独立性的重要含义:
# 前馈网络对每个位置独立处理
for i in range(seq_len):
output[i] = FFN(input[i]) # 位置i的处理不依赖其他位置
为什么设计为位置独立:
- 并行效率:所有位置可以同时处理
- 职责分离:注意力负责位置交互,FFN负责位置内变换
- 计算优化:可以高效地批量处理
- 模块化:保持架构的清晰和可理解性
容量分析:
# 参数量计算
w1_params = d_model * d_ff = 512 * 2048 = 1,048,576
w2_params = d_ff * d_model = 2048 * 512 = 1,048,576
total_params = 2,097,152 # 约200万参数
# 这占了Transformer单层参数的很大比例
为什么选择4倍扩张:
- 经验最优:实验证明4倍是性能和计算的最佳平衡
- 足够容量:提供充分的非线性变换能力
- 计算可行:在现有硬件上可以高效训练
与传统MLP的区别:
- 应用方式:每个位置独立应用,而非整个序列
- 维度设计:扩张-压缩模式,而非单纯增加层数
- 集成方式:与注意力机制紧密结合,形成完整架构
这种设计使得Transformer在保持注意力机制优势的同时,获得强大的非线性建模能力,是架构成功的关键组成部分。
单元格 42-43:嵌入层 - 将词汇转换为向量
是什么: 离散符号到连续向量的桥梁
class Embeddings(nn.Module):
def __init__(self, d_model, vocab):
super(Embeddings, self).__init__()
self.lut = nn.Embedding(vocab, d_model)
self.d_model = d_model
def forward(self, x):
return self.lut(x) * math.sqrt(self.d_model)
嵌入层的根本使命:
- 符号化解器:将离散的词汇ID转换为连续的向量表示
- 语义编码器:学习词汇的分布式语义表示
- 维度统一器:为后续处理提供统一的向量维度
为什么需要嵌入层: 计算机理解语言的基础
离散vs连续的根本差异:
- 计算机的限制:只能处理数字,无法直接理解文字符号
- 语义距离:离散符号无法表达语义相似性
- 梯度传播:离散表示无法进行梯度优化
- 维度一致:神经网络需要固定维度的输入
词汇嵌入的革命性意义:
- 语义空间:在高维空间中建立词汇的语义关系
- 相似性度量:语义相近的词在向量空间中距离更近
- 可学习性:嵌入向量可以通过训练不断优化
- 泛化能力:学到的语义表示可以迁移到新任务
怎么做: 嵌入机制的技术实现
nn.Embedding的工作原理:
self.lut = nn.Embedding(vocab, d_model)
查找表(Look-Up Table)的本质:
# 本质上是一个大的权重矩阵
embedding_matrix.shape = [vocab_size, d_model] # [30000, 512]
# 对于输入的词汇ID
word_id = 1234
word_vector = embedding_matrix[word_id] # 取出对应行作为词向量
为什么叫”查找表”:
- 给定词汇ID,直接查找对应的向量
- 比传统的one-hot编码+矩阵乘法更高效
- 避免了稀疏矩阵的计算开销
缩放因子的深层含义:
def forward(self, x):
return self.lut(x) * math.sqrt(self.d_model)
为什么乘以√d_model:
- 数值平衡:与位置编码的量级保持一致
- 理论基础:保持方差的数学期望
- 训练稳定:避免不同组件贡献的不平衡
- 论文一致:与原始Transformer论文保持一致
数学推导:
假设嵌入向量的每个维度都是独立的标准正态分布 N(0,1)
则 d_model 维向量的L2范数期望为 √d_model
通过乘以 √d_model,使嵌入向量与位置编码具有相似的量级
嵌入层的学习过程:
初始化阶段:
# 随机初始化
embedding_matrix = torch.randn(vocab_size, d_model) * 0.1
训练过程:
# 前向传播:根据词汇ID获取向量
word_vectors = embedding(input_ids)
# 反向传播:根据损失更新对应的嵌入向量
loss.backward() # 只有被使用的词汇嵌入会被更新
共享权重的考虑: 在某些实现中,输入嵌入和输出投影共享权重:
# 共享权重可以减少参数量
generator.proj.weight = embeddings.lut.weight.transpose()
嵌入维度的选择:
- d_model=512:在表达能力和计算效率间的平衡
- 更大维度:更强的表达能力,但计算成本更高
- 更小维度:计算效率高,但可能损失表达能力
词汇表大小的影响:
# 参数量计算
embedding_params = vocab_size * d_model
# 例如:30000 * 512 = 15,360,000 参数
# 这通常是模型参数的很大一部分
实际应用中的技巧:
- 预训练嵌入:使用Word2Vec、GloVe等预训练向量初始化
- 子词嵌入:使用BPE、SentencePiece等处理未登录词
- 层次嵌入:对于超大词汇表,使用层次化的嵌入策略
嵌入质量的评估:
# 语义相似性测试
similar_words = find_similar(embedding_matrix, "king")
# 期望结果:["queen", "prince", "royal", ...]
# 类比关系测试
# king - man + woman ≈ queen
这种设计将离散的语言符号转换为连续的数值表示,为神经网络处理自然语言奠定了基础,是连接符号智能和连接主义的关键桥梁。
嵌入层的作用: 嵌入层是深度学习处理离散符号(如单词)的关键技术:
- 符号到向量:将单词 ID 转换为稠密向量
- 语义表示:相似的单词在向量空间中距离较近
- 可训练:嵌入向量在训练过程中会不断优化
nn.Embedding 详解:
self.lut = nn.Embedding(vocab, d_model)
lut
代表 “Look-Up Table”(查找表)vocab
:词汇表大小(比如 30000 个单词)d_model
:每个单词的向量维度(512)- 本质上是一个大小为
[vocab, d_model]
的矩阵
嵌入过程:
- 输入:单词的 ID(整数),比如 [1, 5, 3, 8]
- 查找:从嵌入矩阵中取出对应行
- 输出:每个 ID 对应一个 d_model 维的向量
缩放因子:
return self.lut(x) * math.sqrt(self.d_model)
为什么要乘以 sqrt(d_model):
- 数值稳定性:确保嵌入向量的数值范围合理
- 与位置编码匹配:位置编码的数值范围也是这个量级
- 论文建议:原始 Transformer 论文中的做法
嵌入学习的直觉:
- 初始时:嵌入向量是随机的
- 训练过程中:相似含义的词会逐渐靠近
- 最终:语义相近的词在向量空间中聚集
单元格 44-46:位置编码 - 为序列注入位置信息
是什么: 序列顺序信息的编码器
class PositionalEncoding(nn.Module):
"Implement the PE function."
def __init__(self, d_model, dropout, max_len=5000):
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(p=dropout)
# Compute the positional encodings once in log space.
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1)
div_term = torch.exp(
torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model)
)
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
pe = pe.unsqueeze(0)
self.register_buffer("pe", pe)
def forward(self, x):
x = x + self.pe[:, : x.size(1)].requires_grad_(False)
return self.dropout(x)
位置编码的核心使命:
- 位置感知器:为无序的注意力机制注入序列顺序信息
- 几何编码器:使用数学函数将位置信息映射到向量空间
- 永恒记忆:提供不依赖参数学习的固定位置表示
为什么需要位置编码: 解决注意力机制的位置盲区
Transformer架构的根本问题:
- 置换不变性:注意力机制对输入顺序不敏感
- 位置无关:相同的词在不同位置得到相同的处理
- 语义缺失:丢失了语言中至关重要的位置信息
- 结构失效:无法理解句法结构和语序规则
举例说明位置的重要性:
"猫咬了狗" vs "狗咬了猫" # 相同的词,不同的语义
"我很高兴" vs "很我高兴" # 语序决定合法性
为什么不用位置嵌入:
- 固定长度限制:学习的位置嵌入受训练序列长度限制
- 泛化能力差:对超出训练长度的序列效果差
- 参数开销:需要额外的参数存储
- 数学优雅性:函数式编码更简洁和理论化
怎么做: 正弦位置编码的数学艺术
核心数学公式:
PE(pos, 2i) = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
其中:
pos
:序列中的位置 (0, 1, 2, …)i
:维度索引 (0, 1, 2, …, d_model/2-1)2i
和2i+1
:偶数和奇数维度
初始化过程的逐步分析:
步骤1:创建位置张量
pe = torch.zeros(max_len, d_model) # [5000, 512]
position = torch.arange(0, max_len).unsqueeze(1) # [5000, 1]
# position 的内容:
# [[0], [1], [2], [3], ..., [4999]]
步骤2:计算频率项
div_term = torch.exp(
torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model)
)
频率项的数学含义:
# 对于维度 i (i = 0, 1, 2, ...)
div_term[i] = exp(-log(10000) * 2i / d_model)
= exp(log(10000^(-2i/d_model)))
= 10000^(-2i/d_model)
= 1 / 10000^(2i/d_model)
为什么使用这种频率设计:
- 多尺度表示:不同维度具有不同的频率
- 几何级数:频率按几何级数递减,覆盖多个时间尺度
- 唯一性保证:每个位置都有唯一的编码向量
- 相对位置:能够表达相对位置关系
步骤3:应用三角函数
pe[:, 0::2] = torch.sin(position * div_term) # 偶数维度使用sin
pe[:, 1::2] = torch.cos(position * div_term) # 奇数维度使用cos
三角函数编码的优势:
1. 周期性特征:
# sin和cos函数具有周期性,能够处理长序列
# 即使超出训练长度,也能产生有意义的位置编码
2. 相对位置信息:
# 利用三角恒等式:
# sin(a + b) = sin(a)cos(b) + cos(a)sin(b)
# cos(a + b) = cos(a)cos(b) - sin(a)sin(b)
# 位置 pos+k 的编码可以表示为位置 pos 编码的线性组合
3. 有界性:
# sin和cos的值域为[-1, 1],编码值稳定
# 避免了位置编码随位置增长而发散
步骤4:添加批次维度
pe = pe.unsqueeze(0) # [1, 5000, 512]
步骤5:注册为缓冲区
self.register_buffer("pe", pe)
register_buffer的作用:
- 非参数存储:不作为模型参数,不参与梯度更新
- 设备同步:自动跟随模型移动到GPU/CPU
- 状态保存:在模型保存/加载时自动处理
前向传播的应用:
def forward(self, x):
x = x + self.pe[:, : x.size(1)].requires_grad_(False)
return self.dropout(x)
关键操作解析:
1. 长度匹配:
self.pe[:, : x.size(1)] # 截取与输入序列长度相同的位置编码
2. 梯度控制:
.requires_grad_(False) # 确保位置编码不参与梯度计算
3. 相加融合:
x = x + self.pe[...] # 词嵌入 + 位置编码
为什么是相加而不是拼接:
- 维度保持:不改变特征维度,保持架构简洁
- 线性叠加:允许注意力机制同时关注内容和位置
- 计算效率:避免维度增长带来的计算开销
- 理论支持:线性叠加能够被后续的线性变换有效处理
可视化理解:
# 不同位置的编码模式:
# 位置0: [sin(0/1), cos(0/1), sin(0/100), cos(0/100), ...]
# 位置1: [sin(1/1), cos(1/1), sin(1/100), cos(1/100), ...]
# 位置2: [sin(2/1), cos(2/1), sin(2/100), cos(2/100), ...]
频率谱分析:
- 低频成分:编码长期位置模式
- 高频成分:编码短期位置变化
- 多频融合:提供丰富的位置表示
实际效果验证:
# 相邻位置编码的相似性较高
similarity = cosine_similarity(pe[pos], pe[pos+1])
# 远距离位置编码的相似性较低
distance_similarity = cosine_similarity(pe[pos], pe[pos+100])
这种设计巧妙地将数学的优雅性、计算的高效性和表示的丰富性完美结合,为Transformer提供了强大而灵活的位置感知能力。
pe = pe.unsqueeze(0)
self.register_buffer("pe", pe)
unsqueeze(0)
添加批次维度register_buffer
将pe
注册为模型的一部分,但不是参数- 缓冲区会随模型移动(CPU/GPU),但不会被优化器更新
forward 方法:
def forward(self, x):
x = x + self.pe[:, : x.size(1)].requires_grad_(False)
return self.dropout(x)
- 将位置编码加到嵌入向量上
: x.size(1)
只取序列长度对应的位置编码requires_grad_(False)
确保位置编码不参与梯度计算
正弦函数的优势:
- 周期性:可以表示相对位置关系
- 外推性:可以处理比训练时更长的序列
- 数值稳定:值域在 [-1, 1] 之间
- 几何性质:可以通过线性变换表示相对位置
单元格 47:可视化位置编码
这个单元格创建了一个图表,展示不同维度的位置编码是如何随位置变化的。你可以看到正弦和余弦波的周期性模式。
单元格 48-49:完整模型构建 - 组装所有组件
是什么: 完整Transformer模型的工厂函数
def make_model(
src_vocab, tgt_vocab, N=6, d_model=512, d_ff=2048, h=8, dropout=0.1
):
"Helper: Construct a model from hyperparameters."
c = copy.deepcopy
attn = MultiHeadedAttention(h, d_model)
ff = PositionwiseFeedForward(d_model, d_ff, dropout)
position = PositionalEncoding(d_model, dropout)
model = EncoderDecoder(
Encoder(EncoderLayer(d_model, c(attn), c(ff), dropout), N),
Decoder(DecoderLayer(d_model, c(attn), c(attn), c(ff), dropout), N),
nn.Sequential(Embeddings(d_model, src_vocab), c(position)),
nn.Sequential(Embeddings(d_model, tgt_vocab), c(position)),
Generator(d_model, tgt_vocab),
)
# This was important from their code.
# Initialize parameters with Glorot / fan_avg.
for p in model.parameters():
if p.dim() > 1:
nn.init.xavier_uniform_(p)
return model
工厂函数的核心职责:
- 组件制造器:批量创建所有必需的神经网络模块
- 架构装配器:按照正确的拓扑结构组装完整模型
- 参数初始化器:使用最佳实践初始化所有可训练参数
为什么需要工厂函数: 模块化设计的智慧
复杂系统的组装挑战:
- 组件繁多:编码器、解码器、注意力、前馈网络等众多模块
- 依赖关系:各组件间有复杂的参数和结构依赖
- 配置管理:需要统一管理超参数和配置
- 重复使用:需要创建相同结构的多个实例
工厂模式的优势:
- 封装复杂性:隐藏组装细节,提供简洁接口
- 保证一致性:确保所有组件使用相同的超参数
- 便于实验:通过修改参数快速创建不同配置的模型
- 减少错误:避免手动组装过程中的配置错误
怎么做: 精密的模型装配流程
超参数配置解析:
def make_model(
src_vocab, tgt_vocab, N=6, d_model=512, d_ff=2048, h=8, dropout=0.1
):
参数含义与典型值:
src_vocab
:源语言词汇表大小(如英语:30,000)tgt_vocab
:目标语言词汇表大小(如中文:50,000)N=6
:编码器和解码器层数(原论文标准)d_model=512
:模型隐藏维度(平衡性能与计算)d_ff=2048
:前馈网络维度(4倍扩张)h=8
:多头注意力头数(并行处理)dropout=0.1
:正则化强度(防止过拟合)
深拷贝策略:
c = copy.deepcopy
为什么使用深拷贝:
- 独立实例:每个层都需要独立的参数,不能共享
- 避免别名:防止多个引用指向同一个对象
- 梯度隔离:确保各层的梯度更新相互独立
- 内存安全:避免意外的参数共享导致的训练问题
组件模板创建:
attn = MultiHeadedAttention(h, d_model) # 注意力模板
ff = PositionwiseFeedForward(d_model, d_ff, dropout) # 前馈网络模板
position = PositionalEncoding(d_model, dropout) # 位置编码模板
模板的作用:
- 作为”蓝图”创建多个独立实例
- 保证所有实例使用相同的架构配置
- 通过深拷贝确保参数独立性
模型主体组装:
model = EncoderDecoder(
Encoder(EncoderLayer(d_model, c(attn), c(ff), dropout), N),
Decoder(DecoderLayer(d_model, c(attn), c(attn), c(ff), dropout), N),
nn.Sequential(Embeddings(d_model, src_vocab), c(position)),
nn.Sequential(Embeddings(d_model, tgt_vocab), c(position)),
Generator(d_model, tgt_vocab),
)
组装逻辑深度分析:
编码器构建:
Encoder(EncoderLayer(d_model, c(attn), c(ff), dropout), N)
- 创建编码器层模板:
EncoderLayer(...)
- 复制N层:每层都是独立的深拷贝
- 堆叠形成完整编码器
解码器构建:
Decoder(DecoderLayer(d_model, c(attn), c(attn), c(ff), dropout), N)
- 注意双重注意力:
c(attn), c(attn)
- 第一个:自注意力(self-attention)
- 第二个:编码器-解码器注意力(cross-attention)
- 每个解码器层需要两个独立的注意力模块
嵌入层构建:
nn.Sequential(Embeddings(d_model, src_vocab), c(position)) # 源语言
nn.Sequential(Embeddings(d_model, tgt_vocab), c(position)) # 目标语言
Sequential的设计意图:
- 流水线处理:词嵌入 → 位置编码
- 模块化封装:将两个步骤打包为一个模块
- 复用性:编码器和解码器都需要相同的流程
- 扩展性:便于添加其他预处理步骤
输出层配置:
Generator(d_model, tgt_vocab)
- 将隐藏表示映射到目标词汇表
- 产生最终的词汇概率分布
Xavier权重初始化:
for p in model.parameters():
if p.dim() > 1:
nn.init.xavier_uniform_(p)
Xavier初始化的深层原理:
为什么需要合适的初始化:
- 梯度流动:不当初始化导致梯度消失或爆炸
- 激活分布:保持各层激活值的合理分布
- 收敛速度:好的初始化加速训练收敛
- 性能上限:影响最终模型的性能表现
Xavier公式:
# 对于权重矩阵 W ∈ R^(m×n)
std = sqrt(2.0 / (fan_in + fan_out))
W ~ Uniform(-std * sqrt(3), std * sqrt(3))
其中:
fan_in
:输入维度fan_out
:输出维度- 保持前向和反向传播的方差稳定
维度判断逻辑:
if p.dim() > 1:
- 多维参数:权重矩阵(需要Xavier初始化)
- 一维参数:偏置项(通常初始化为0)
模型规模分析:
# 参数量估算(N=6, d_model=512, h=8, d_ff=2048)
encoder_params = N * (
3 * d_model * d_model + # 注意力的Q、K、V投影
d_model * d_model + # 注意力输出投影
2 * d_model * d_ff + # 前馈网络
4 * d_model # 层归一化参数
)
decoder_params = N * (
6 * d_model * d_model + # 两个注意力模块
2 * d_model * d_ff + # 前馈网络
6 * d_model # 层归一化参数
)
embedding_params = (src_vocab + tgt_vocab) * d_model
total_params ≈ 65M # 约6500万参数
实际使用示例:
# 创建标准Transformer模型
model = make_model(
src_vocab=30000, # 英语词汇表
tgt_vocab=50000, # 中文词汇表
N=6, # 6层编码器和解码器
d_model=512, # 512维隐藏状态
d_ff=2048, # 2048维前馈网络
h=8, # 8个注意力头
dropout=0.1 # 10%的dropout
)
print(f"模型参数量: {sum(p.numel() for p in model.parameters()):,}")
这个工厂函数优雅地封装了Transformer的复杂性,使得研究者和工程师能够专注于模型的使用和改进,而不必纠结于繁琐的组装细节。
model = EncoderDecoder(
Encoder(EncoderLayer(d_model, c(attn), c(ff), dropout), N),
Decoder(DecoderLayer(d_model, c(attn), c(attn), c(ff), dropout), N),
nn.Sequential(Embeddings(d_model, src_vocab), c(position)),
nn.Sequential(Embeddings(d_model, tgt_vocab), c(position)),
Generator(d_model, tgt_vocab),
)
组装过程分析:
-
编码器:
Encoder(EncoderLayer(...), N)
- 创建一个编码器层的模板
- 复制 N 次创建完整编码器
-
解码器:
Decoder(DecoderLayer(...), N)
- 注意解码器层有两个注意力:
c(attn), c(attn)
- 第一个是自注意力,第二个是编码器-解码器注意力
- 注意解码器层有两个注意力:
-
源语言嵌入:
nn.Sequential(Embeddings(...), c(position))
- 将嵌入层和位置编码串联
- 输入先经过嵌入,再加上位置编码
-
目标语言嵌入:同源语言嵌入
-
生成器:
Generator(d_model, tgt_vocab)
- 将解码器输出转换为词汇概率
参数初始化:
for p in model.parameters():
if p.dim() > 1:
nn.init.xavier_uniform_(p)
Xavier 初始化:
- 也叫 Glorot 初始化
- 根据层的输入和输出维度来设置初始权重
- 目标是保持梯度在合理范围内
p.dim() > 1
确保只初始化矩阵参数,不初始化偏置
为什么需要好的初始化:
- 梯度流动:好的初始化帮助梯度正常传播
- 收敛速度:影响训练的收敛速度
- 避免饱和:防止激活函数进入饱和区域
这样,我们就有了一个完整的 Transformer 模型!
单元格 50-54:推理测试和简单示例
是什么: 模型推理能力的验证器
这个函数展示了如何使用未训练的模型进行推理。虽然模型还没有训练,但可以看到推理的完整流程。
推理过程的本质:
- 前向传播验证器:确保模型架构正确无误
- 数据流测试器:验证输入输出的形状和类型匹配
- 接口展示器:演示模型的使用方法
为什么需要推理测试: 验证架构完整性
未训练模型的测试价值:
- 架构验证:确保所有组件正确连接
- 维度检查:验证数据在各层间的形状变换
- 接口测试:确认模型的输入输出接口设计合理
- 调试基础:为后续训练提供基础的错误检查
早期测试的重要性:
- 快速发现问题:在投入大量训练资源前发现架构问题
- 节省时间:避免长时间训练后才发现基础错误
- 建立信心:确认模型基本功能正常
怎么做: 推理测试的实现策略
虽然模型未训练,但输出应该具有合理的形状和数值特征。这为后续的完整训练和评估奠定了基础。
单元格 55-58:训练准备 - 批次和掩码
是什么: 训练数据的智能包装器
class Batch:
"""Object for holding a batch of data with mask during training."""
def __init__(self, src, tgt=None, pad=2): # 2 = <blank>
self.src = src
self.src_mask = (src != pad).unsqueeze(-2)
if tgt is not None:
self.tgt = tgt[:, :-1]
self.tgt_y = tgt[:, 1:]
self.tgt_mask = self.make_std_mask(self.tgt, pad)
self.ntokens = (self.tgt_y != pad).data.sum()
@staticmethod
def make_std_mask(tgt, pad):
"Create a mask to hide padding and future words."
tgt_mask = (tgt != pad).unsqueeze(-2)
tgt_mask = tgt_mask & subsequent_mask(tgt.size(-1)).type_as(
tgt_mask.data
)
return tgt_mask
Batch类的核心使命:
- 数据组织器:将原始序列数据转换为模型可处理的批次格式
- 掩码生成器:为注意力机制创建必要的掩码矩阵
- 训练适配器:为teacher forcing训练模式准备输入输出对
为什么需要Batch类: 解决训练中的实际问题
批次处理的必要性:
- 计算效率:GPU并行处理多个样本比逐一处理快得多
- 内存优化:批次处理充分利用GPU的并行计算能力
- 梯度稳定:批次内梯度的平均化提高训练稳定性
- 序列对齐:处理不等长序列需要填充和掩码机制
掩码的关键作用:
- 填充掩码:区分真实内容和填充内容
- 因果掩码:防止解码器看到未来信息
- 注意力控制:精确控制模型的注意力分布
怎么做: 精密的数据预处理流程
源序列处理的细节:
self.src = src # 源序列,如英语句子
self.src_mask = (src != pad).unsqueeze(-2) # 创建填充掩码
掩码创建的数学逻辑:
# 假设源序列:[1, 15, 234, 5, 2, 2, 2] # 2是填充符
# 掩码结果: [T, T, T, T, F, F, F] # T=True(真实), F=False(填充)
unsqueeze(-2)的作用:
# 原始掩码形状:[batch_size, seq_len]
# 处理后形状: [batch_size, 1, seq_len]
# 目的:为多头注意力的广播机制做准备
Teacher Forcing的巧妙实现:
if tgt is not None:
self.tgt = tgt[:, :-1] # 解码器输入:去掉最后一个词
self.tgt_y = tgt[:, 1:] # 期望输出:去掉第一个词
具体示例:
# 原始目标序列:[<start>, I, am, happy, <end>]
# 解码器输入: [<start>, I, am, happy] (self.tgt)
# 期望输出: [I, am, happy, <end>] (self.tgt_y)
这种设计的深层原理:
- 自回归性质:每个位置的输出只依赖前面的输入
- 并行训练:可以同时计算所有位置的损失
- 稳定性:避免了推理时的错误累积
目标掩码的双重保护:
@staticmethod
def make_std_mask(tgt, pad):
tgt_mask = (tgt != pad).unsqueeze(-2) # 填充掩码
tgt_mask = tgt_mask & subsequent_mask(tgt.size(-1)).type_as(tgt_mask.data)
return tgt_mask
双重掩码的逻辑运算:
# 填充掩码: [T, T, T, F, F]
# 因果掩码: 下三角矩阵
# 最终掩码: 填充掩码 AND 因果掩码
静态方法的设计考虑:
@staticmethod # 不依赖实例状态的纯函数
- 功能独立:可以独立测试和使用
- 内存效率:不需要访问实例变量
- 设计清晰:明确表示这是一个工具函数
词元统计的实用价值:
self.ntokens = (self.tgt_y != pad).data.sum()
- 训练监控:跟踪处理的有效词元数量
- 性能评估:计算每秒处理的词元数
- 损失归一化:按有效词元数归一化损失
这种精心设计的批次处理机制,确保了训练过程的高效性和正确性,是深度学习训练管道中的关键环节。
单元格 59-61:训练循环核心 - 模型学习的引擎
是什么: 深度学习训练的执行引擎
class TrainState:
"""Track number of steps, examples, and tokens processed"""
step: int = 0 # Steps in the current epoch
accum_step: int = 0 # Number of gradient accumulation steps
samples: int = 0 # total # of examples used
tokens: int = 0 # total # of tokens processed
def run_epoch(
data_iter, model, loss_compute, optimizer, scheduler,
mode="train", accum_iter=1, train_state=TrainState(),
):
"""Train a single epoch"""
start = time.time()
total_tokens = 0
total_loss = 0
tokens = 0
n_accum = 0
for i, batch in enumerate(data_iter):
out = model.forward(batch.src, batch.tgt, batch.src_mask, batch.tgt_mask)
loss, loss_node = loss_compute(out, batch.tgt_y, batch.ntokens)
if mode == "train" or mode == "train+log":
loss_node.backward()
train_state.step += 1
train_state.samples += batch.src.shape[0]
train_state.tokens += batch.ntokens
if i % accum_iter == 0:
optimizer.step()
optimizer.zero_grad(set_to_none=True)
n_accum += 1
train_state.accum_step += 1
scheduler.step()
total_loss += loss
total_tokens += batch.ntokens
tokens += batch.ntokens
if i % 40 == 1 and (mode == "train" or mode == "train+log"):
lr = optimizer.param_groups[0]["lr"]
elapsed = time.time() - start
print(f"Epoch Step: {i:6d} | Accumulation Step: {n_accum:3d} | "
f"Loss: {loss / batch.ntokens:6.2f} | "
f"Tokens / Sec: {tokens / elapsed:7.1f} | "
f"Learning Rate: {lr:6.1e}")
start = time.time()
tokens = 0
del loss
del loss_node
return total_loss / total_tokens, train_state
训练引擎的核心职责:
- 状态跟踪器:监控训练进度和统计信息
- 梯度协调器:管理前向传播、反向传播和参数更新
- 性能监视器:实时展示训练效率和学习进度
为什么需要训练循环: 深度学习的核心机制
训练循环的根本意义:
- 参数优化:通过梯度下降不断调整模型参数
- 知识积累:每个批次都为模型提供学习机会
- 收敛控制:监控和引导模型向最优解收敛
- 资源管理:高效利用计算资源和内存
TrainState的设计哲学:
- 全局视角:跟踪整个训练过程的宏观统计
- 细粒度监控:从步骤、样本到词元的多层次计数
- 性能评估:为训练效率分析提供数据支撑
- 状态恢复:为训练中断恢复提供状态快照
怎么做: 训练循环的精密编排
TrainState的状态管理:
step: int = 0 # 当前epoch内的训练步数
accum_step: int = 0 # 梯度累积步数(实际参数更新次数)
samples: int = 0 # 处理的样本总数
tokens: int = 0 # 处理的词元总数
状态字段的具体含义:
- step vs accum_step:区分批次处理和参数更新频率
- samples:用于计算平均每个样本的处理时间
- tokens:更精确的处理量度量,因为序列长度不同
run_epoch的核心循环:
阶段1:前向传播
out = model.forward(batch.src, batch.tgt, batch.src_mask, batch.tgt_mask)
- 输入源序列和目标序列(带掩码)
- 获得模型对每个位置的词汇概率预测
阶段2:损失计算
loss, loss_node = loss_compute(out, batch.tgt_y, batch.ntokens)
loss
:标量损失值,用于统计loss_node
:保留计算图的张量,用于反向传播batch.tgt_y
:真实的目标标签batch.ntokens
:用于损失归一化
阶段3:反向传播与优化
if mode == "train" or mode == "train+log":
loss_node.backward() # 计算梯度
train_state.step += 1 # 更新步数
train_state.samples += batch.src.shape[0] # 累积样本数
train_state.tokens += batch.ntokens # 累积词元数
if i % accum_iter == 0: # 梯度累积条件
optimizer.step() # 应用梯度更新
optimizer.zero_grad(set_to_none=True) # 清空梯度
n_accum += 1 # 累积步数递增
train_state.accum_step += 1
scheduler.step() # 学习率调度
梯度累积的巧妙机制:
if i % accum_iter == 0:
optimizer.step()
optimizer.zero_grad(set_to_none=True)
梯度累积的作用原理:
- 有效批次大小扩大:
effective_batch_size = batch_size * accum_iter
- 内存限制应对:在GPU内存不足时模拟大批次训练
- 梯度稳定性:更大的有效批次提供更稳定的梯度估计
- 训练等价性:与真实大批次训练数学上等价
set_to_none=True的优化:
optimizer.zero_grad(set_to_none=True)
- 内存效率:释放梯度张量的内存,而不是置零
- 性能提升:避免不必要的内存操作
- 现代最佳实践:PyTorch 1.7+推荐的梯度清理方式
学习率调度的时机:
scheduler.step() # 每个批次后调用,而非每次参数更新后
- 精细控制:提供更细粒度的学习率调整
- warmup支持:支持复杂的学习率预热策略
- 收敛优化:根据训练进度动态调整学习率
阶段4:实时监控与报告
if i % 40 == 1 and (mode == "train" or mode == "train+log"):
lr = optimizer.param_groups[0]["lr"] # 获取当前学习率
elapsed = time.time() - start # 计算时间间隔
print(f"Epoch Step: {i:6d} | Accumulation Step: {n_accum:3d} | "
f"Loss: {loss / batch.ntokens:6.2f} | "
f"Tokens / Sec: {tokens / elapsed:7.1f} | "
f"Learning Rate: {lr:6.1e}")
start = time.time() # 重置计时器
tokens = 0 # 重置词元计数
监控报告的设计考虑:
- 适度频率:每40步报告一次,平衡信息量和性能
- 关键指标:损失、吞吐量、学习率三大核心指标
- 归一化损失:按词元数归一化,提供可比较的损失值
- 吞吐量监控:Tokens/Sec是衡量训练效率的重要指标
内存管理的细节:
del loss
del loss_node
- 显式删除:及时释放不再需要的张量
- 内存优化:防止循环中的内存累积
- PyTorch最佳实践:在长循环中及时清理计算图
返回值的设计:
return total_loss / total_tokens, train_state
- 平均损失:按词元数归一化的epoch平均损失
- 状态传递:更新后的训练状态,支持多epoch训练
实际使用场景:
# 训练模式
train_loss, train_state = run_epoch(
train_dataloader, model, loss_compute,
optimizer, scheduler, mode="train", accum_iter=4
)
# 验证模式
val_loss, _ = run_epoch(
val_dataloader, model, loss_compute,
None, None, mode="eval"
)
这种精心设计的训练循环,提供了高效、稳定、可监控的模型训练框架,是深度学习项目成功的关键基础设施。
训练循环详解:
1. 前向传播:
out = model.forward(
batch.src, batch.tgt, batch.src_mask, batch.tgt_mask
)
- 将批次数据传入模型
- 获得模型的预测输出
2. 损失计算:
loss, loss_node = loss_compute(out, batch.tgt_y, batch.ntokens)
- 比较模型预测和真实标签
- 返回损失值和可计算梯度的损失节点
3. 反向传播(仅在训练模式):
if mode == "train" or mode == "train+log":
loss_node.backward()
backward()
计算梯度- 梯度存储在模型参数的
.grad
属性中
4. 梯度累积:
if i % accum_iter == 0:
optimizer.step()
optimizer.zero_grad(set_to_none=True)
单元格 62-65:学习率调度 - 训练的节奏掌控者
是什么: 动态学习率控制策略
def rate(step, model_size, factor, warmup):
"""
we have to default the step to 1 for LambdaLR function
to avoid zero raising to negative power.
"""
if step == 0:
step = 1
return factor * (
model_size ** (-0.5) * min(step ** (-0.5), step * warmup ** (-1.5))
)
学习率调度器的核心使命:
- 训练节奏掌控者:在训练过程中动态调整学习步长
- 收敛优化器:平衡探索能力和收敛稳定性
- 性能提升器:通过精心设计的策略提升最终性能
为什么需要学习率调度: 优化过程的智慧
固定学习率的局限性:
- 探索vs利用矛盾:高学习率利于探索,低学习率利于收敛
- 训练阶段差异:早期需要大步探索,后期需要细致调优
- 损失景观复杂性:不同阶段的损失函数特性不同
- 泛化性能优化:动态调整有助于找到更好的泛化解
Transformer特有的挑战:
- 参数规模庞大:需要更谨慎的优化策略
- 梯度传播复杂:深层网络需要稳定的初始训练
- 注意力机制敏感性:对学习率变化较为敏感
- 收敛困难:大型模型容易陷入局部最优
怎么做: Transformer的学习率艺术
核心公式深度解析:
lr = factor * d_model^(-0.5) * min(step^(-0.5), step * warmup^(-1.5))
公式的三个关键组成部分:
1. 基础缩放因子:
factor * model_size ** (-0.5)
- factor:全局缩放参数,通常设为1.0或2.0
- d_model^(-0.5):根据模型维度自适应缩放
- 数学直觉:更大的模型需要更小的学习率
为什么使用d_model^(-0.5):
# 对于d_model=512: 1/√512 ≈ 0.044
# 对于d_model=1024: 1/√1024 ≈ 0.031
# 模型越大,基础学习率越小
2. Warmup阶段(step < warmup):
step * warmup ** (-1.5)
Warmup的数学特性:
# 当step < warmup时:
lr = factor * d_model^(-0.5) * step * warmup^(-1.5)
= factor * d_model^(-0.5) * (step / warmup^1.5)
# 这实际上是线性增长,直到warmup步数
Warmup阶段的渐进过程:
# 假设warmup=4000
# step=1000: lr ∝ 1000/4000^1.5 = 很小的值
# step=2000: lr ∝ 2000/4000^1.5 = 稍大的值
# step=4000: lr ∝ 4000/4000^1.5 = 达到峰值
3. 衰减阶段(step >= warmup):
step ** (-0.5)
衰减的数学特性:
# 当step >= warmup时:
lr = factor * d_model^(-0.5) * step^(-0.5)
= factor * d_model^(-0.5) / √step
# 这是平方根衰减,比指数衰减更温和
两阶段的平滑过渡:
# 在warmup点,两个表达式相等:
step^(-0.5) = step * warmup^(-1.5)
# 解得:step = warmup(过渡点)
特殊处理的边界情况:
if step == 0:
step = 1
- 数学稳定性:避免0的负数次幂导致的数值问题
- 实现细节:LambdaLR要求学习率函数在step=0时有定义
- 逻辑合理性:第一步应该有一个合理的小学习率
可视化理解学习率曲线:
# Warmup阶段:线性上升
# 峰值点:在warmup步数达到最大值
# 衰减阶段:平方根衰减,缓慢下降
不同参数的影响:
factor参数的作用:
factor = 1.0 # 标准设置
factor = 2.0 # 更激进的学习率
factor = 0.5 # 更保守的学习率
warmup参数的影响:
warmup = 4000 # 标准设置,适中的预热期
warmup = 8000 # 更长的预热期,更稳定
warmup = 2000 # 更短的预热期,更快进入衰减
model_size的自适应性:
d_model = 512 # Base模型
d_model = 1024 # Large模型,自动使用更小的学习率
实际应用中的优势:
1. 训练稳定性:
- 避免初期的梯度爆炸
- 为复杂的注意力机制提供平滑的优化路径
2. 收敛质量:
- Warmup确保良好的初始化
- 平方根衰减保持持续的学习能力
3. 实验可重复性:
- 数学公式明确,减少超参数调优的主观性
- 在不同规模的模型间具有良好的迁移性
与其他调度策略的比较:
# 指数衰减:lr *= gamma^step (衰减太快)
# 线性衰减:lr -= alpha*step (缺乏理论基础)
# 余弦衰减:lr = base * cos(π*step/max_steps) (缺乏warmup)
# Transformer调度:兼具warmup和适度衰减的优势
单元格 66-70:标签平滑技术 - 对抗过拟合的智慧
是什么: 概率分布的平滑化器
class LabelSmoothing(nn.Module):
"Implement label smoothing."
def __init__(self, size, padding_idx, smoothing=0.0):
super(LabelSmoothing, self).__init__()
self.criterion = nn.KLDivLoss(reduction="sum")
self.padding_idx = padding_idx
self.confidence = 1.0 - smoothing
self.smoothing = smoothing
self.size = size
self.true_dist = None
def forward(self, x, target):
assert x.size(1) == self.size
true_dist = x.data.clone()
true_dist.fill_(self.smoothing / (self.size - 2))
true_dist.scatter_(1, target.data.unsqueeze(1), self.confidence)
true_dist[:, self.padding_idx] = 0
mask = torch.nonzero(target.data == self.padding_idx)
if mask.dim() > 0:
true_dist.index_fill_(0, mask.squeeze(), 0.0)
self.true_dist = true_dist
return self.criterion(x, true_dist.clone().detach())
标签平滑的核心使命:
- 概率软化器:将硬性的one-hot标签转换为平滑的概率分布
- 过拟合抑制器:防止模型过度自信于训练数据
- 泛化增强器:提高模型在未见数据上的表现
为什么需要标签平滑: 对抗过度自信的陷阱
传统硬标签的问题:
- 过度自信:模型被训练为对正确答案给出100%的置信度
- 泛化差:在训练数据上表现很好,但在新数据上表现差
- 脆弱性:对输入的小变化过于敏感
- 校准问题:预测概率与实际准确率不匹配
硬标签的数学表示:
# 传统one-hot编码,假设正确答案是词汇3
hard_label = [0, 0, 0, 1, 0, 0, ...] # 只有位置3是1,其他都是0
平滑标签的改进思路:
# 标签平滑后,假设smoothing=0.1
smooth_label = [0.02, 0.02, 0.02, 0.86, 0.02, 0.02, ...]
# 正确答案仍然概率最高,但其他位置也有小概率
深层原理解析:
- 不确定性建模:承认训练标签可能不是唯一正确答案
- 正则化效应:阻止模型在训练集上过度拟合
- 概率校准:使预测概率更接近真实的置信度
- 鲁棒性提升:提高对噪声和变化的抗干扰能力
怎么做: 标签平滑的精确实现
初始化参数的深度含义:
def __init__(self, size, padding_idx, smoothing=0.0):
self.criterion = nn.KLDivLoss(reduction="sum") # KL散度损失
self.padding_idx = padding_idx # 填充符索引
self.confidence = 1.0 - smoothing # 正确答案的概率
self.smoothing = smoothing # 平滑参数
self.size = size # 词汇表大小
KL散度损失的选择:
nn.KLDivLoss(reduction="sum")
- KL散度定义:D_KL(P||Q) = Σ P(x) * log(P(x)/Q(x))
- 适用性:自然地处理概率分布间的差异
- 数学性质:非对称,惩罚模型与目标分布的偏差
平滑分布的构建过程:
步骤1:创建模板分布
true_dist = x.data.clone() # 复制输入张量的形状
- 获得与模型输出相同的形状 [batch_size, vocab_size]
- 为每个样本创建概率分布模板
步骤2:填充均匀基础概率
true_dist.fill_(self.smoothing / (self.size - 2))
为什么除以(size-2)而不是size:
# size - 2 的原因:
# 1. 排除正确答案位置(它会被单独设置)
# 2. 排除padding位置(它会被设为0)
# 实际参与平滑分配的词汇数 = total_vocab - 1 - 1 = size - 2
数学推导:
# 设词汇表大小为V,平滑参数为ε
# 正确答案概率:1 - ε
# 错误答案总概率:ε
# 每个错误答案概率:ε / (V - 2)
步骤3:设置正确答案的高概率
true_dist.scatter_(1, target.data.unsqueeze(1), self.confidence)
scatter_操作详解:
# scatter_(dim, index, src)
# dim=1: 在词汇维度上操作
# index: 正确答案的位置索引
# src: 要填入的值(confidence = 1.0 - smoothing)
具体示例:
# 假设target=[3], confidence=0.9, smoothing=0.1, vocab_size=6
# 初始: [0.025, 0.025, 0.025, 0.025, 0.025, 0.025] # (除了padding)
# scatter后: [0.025, 0.025, 0.025, 0.9, 0.025, 0.025]
步骤4:处理填充位置
true_dist[:, self.padding_idx] = 0 # 填充符概率设为0
填充位置的特殊处理:
- 填充符不是真实词汇,不应参与概率分布
- 设为0确保这些位置不影响损失计算
步骤5:处理填充样本
mask = torch.nonzero(target.data == self.padding_idx)
if mask.dim() > 0:
true_dist.index_fill_(0, mask.squeeze(), 0.0)
边界情况处理:
- 当目标本身就是填充符时,整行概率都设为0
- 这种情况在序列末尾的填充位置会出现
概率分布的数学验证:
# 对于非填充位置,概率分布应该和为1:
# P(correct) + Σ P(incorrect) = confidence + (size-2) * smoothing/(size-2)
# = (1-smoothing) + smoothing = 1 ✓
实际效果对比:
传统硬标签:
# 目标: "I love you",词汇表: ["I", "love", "you", "hate", "like"]
hard_labels = [
[1.0, 0.0, 0.0, 0.0, 0.0], # "I"
[0.0, 1.0, 0.0, 0.0, 0.0], # "love"
[0.0, 0.0, 1.0, 0.0, 0.0], # "you"
]
标签平滑后:
# smoothing = 0.1
smooth_labels = [
[0.9, 0.025, 0.025, 0.025, 0.025], # "I" 更多选择可能性
[0.025, 0.9, 0.025, 0.025, 0.025], # "love"
[0.025, 0.025, 0.9, 0.025, 0.025], # "you"
]
性能提升的量化分析:
- 训练损失:稍微增加(因为目标概率降低)
- 验证损失:通常降低(因为泛化能力提升)
- BLEU分数:在机器翻译任务上通常提升1-2分
- 概率校准:预测概率与实际准确率更匹配
超参数选择指导:
# 常用smoothing值:
smoothing = 0.0 # 无平滑(传统训练)
smoothing = 0.1 # 轻度平滑(推荐起点)
smoothing = 0.2 # 中度平滑(某些任务有效)
smoothing = 0.3 # 重度平滑(可能过度)
平滑参数的影响分析:
- 过小(<0.05):效果有限,接近硬标签
- 适中(0.1-0.2):平衡性能和泛化,多数情况最优
- 过大(>0.3):可能损害学习效率,正确答案置信度过低
单元格 71-77:简单复制任务示例 - 模型能力的初步验证
是什么: 模型学习能力的基础测试
def data_gen(V, batch_size, nbatches):
"Generate random data for a src-tgt copy task."
for i in range(nbatches):
data = torch.randint(1, V, size=(batch_size, 10))
data[:, 0] = 1
src = data.requires_grad_(False).clone().detach()
tgt = data.requires_grad_(False).clone().detach()
yield Batch(src, tgt, 0)
class SimpleLossCompute:
"A simple loss compute and train function."
def __init__(self, generator, criterion):
self.generator = generator
self.criterion = criterion
def __call__(self, x, y, norm):
x = self.generator(x)
sloss = (
self.criterion(
x.contiguous().view(-1, x.size(-1)), y.contiguous().view(-1)
)
/ norm
)
return sloss.data * norm, sloss
def greedy_decode(model, src, src_mask, max_len, start_symbol):
memory = model.encode(src, src_mask)
ys = torch.zeros(1, 1).fill_(start_symbol).type_as(src.data)
for i in range(max_len - 1):
out = model.decode(
memory, src_mask, ys, subsequent_mask(ys.size(1)).type_as(src.data)
)
prob = model.generator(out[:, -1])
_, next_word = torch.max(prob, dim=1)
next_word = next_word.data[0]
ys = torch.cat(
[ys, torch.zeros(1, 1).type_as(src.data).fill_(next_word)], dim=1
)
return ys
复制任务的核心价值:
- 原理验证器:测试模型是否具备基本的序列学习能力
- 架构调试器:快速发现模型实现中的问题
- 性能基准:为更复杂任务建立性能底线
为什么从复制任务开始: 循序渐进的学习策略
复制任务的独特优势:
- 明确性:输入输出关系完全确定,容易验证正确性
- 简洁性:排除语言学复杂性,专注于模型架构测试
- 快速性:训练时间短,可以快速迭代和调试
- 必要性:连复制都学不会,更复杂任务必然失败
从复制到翻译的能力阶梯:
# 能力层次:复制 < 重排 < 转换 < 翻译
# 复制:[1,2,3] → [1,2,3] (完全记忆)
# 重排:[1,2,3] → [3,1,2] (位置变换)
# 转换:[1,2,3] → [4,5,6] (符号映射)
# 翻译:["I","love"] → ["ich","liebe"] (语言转换)
怎么做: 复制任务的精确实现
数据生成的巧妙设计:
def data_gen(V, batch_size, nbatches):
for i in range(nbatches):
data = torch.randint(1, V, size=(batch_size, 10)) # 随机序列
data[:, 0] = 1 # 固定起始符
src = data.requires_grad_(False).clone().detach() # 源序列
tgt = data.requires_grad_(False).clone().detach() # 目标序列
yield Batch(src, tgt, 0) # 返回批次
设计细节的深层考虑:
1. 词汇范围控制:
torch.randint(1, V, size=(batch_size, 10)) # 从1到V-1随机选择
- 避免0索引:通常保留给特殊符号(如填充)
- 固定长度:简化实验,专注于学习机制验证
- 随机性:确保模型学习通用模式,而非记忆特定序列
2. 起始符的统一设置:
data[:, 0] = 1 # 所有序列都以符号1开始
- 一致性:为解码器提供统一的起始点
- 可预测性:简化解码过程的初始状态
- 实用性:模拟真实场景中的句子开始标记
3. 梯度管理:
src = data.requires_grad_(False).clone().detach()
- 内存优化:数据不需要梯度,节省计算资源
- 安全性:避免意外的梯度传播到数据生成过程
SimpleLossCompute的封装智慧:
class SimpleLossCompute:
def __call__(self, x, y, norm):
x = self.generator(x) # 生成词汇概率
sloss = self.criterion( # 计算损失
x.contiguous().view(-1, x.size(-1)), # 展平为2D
y.contiguous().view(-1) # 展平为1D
) / norm # 归一化
return sloss.data * norm, sloss # 返回值和节点
形状变换的必要性:
# 原始形状:
# x: [batch_size, seq_len, vocab_size]
# y: [batch_size, seq_len]
# 展平后:
# x: [batch_size * seq_len, vocab_size] # 每行是一个词的概率分布
# y: [batch_size * seq_len] # 每个元素是正确的词汇ID
返回双值的设计:
return sloss.data * norm, sloss # (标量损失, 张量损失)
- 标量损失:用于监控和日志记录
- 张量损失:保留计算图,用于反向传播
贪婪解码的逐步实现:
def greedy_decode(model, src, src_mask, max_len, start_symbol):
解码的四个关键阶段:
阶段1:编码源序列
memory = model.encode(src, src_mask) # 获得源序列的表示
- 一次编码:编码器只需运行一次
- 记忆存储:存储源序列的完整信息
阶段2:初始化解码
ys = torch.zeros(1, 1).fill_(start_symbol).type_as(src.data)
- 起始状态:从特殊的开始符号启动
- 类型匹配:确保设备和数据类型一致
阶段3:逐步生成
for i in range(max_len - 1):
out = model.decode(memory, src_mask, ys, subsequent_mask(ys.size(1)).type_as(src.data))
prob = model.generator(out[:, -1]) # 只看最后位置的输出
_, next_word = torch.max(prob, dim=1) # 选择概率最大的词
next_word = next_word.data[0] # 提取标量值
ys = torch.cat([ys, torch.zeros(1, 1).type_as(src.data).fill_(next_word)], dim=1)
贪婪策略的实现细节:
torch.max(prob, dim=1) # 返回 (最大值, 最大值索引)
- 贪婪选择:总是选择当前步骤概率最大的词
- 简单高效:无需复杂的搜索算法
- 确定性:相同输入总是产生相同输出
序列拼接的动态过程:
ys = torch.cat([ys, new_word_tensor], dim=1) # 在序列维度拼接
- 动态扩展:序列长度逐步增长
- 历史保持:保留之前生成的所有词汇
复制任务的成功标准:
# 输入:[1, 3, 7, 2, 9]
# 期望输出:[1, 3, 7, 2, 9]
# 成功指标:100%的词汇级别准确率
训练进度的典型模式:
- 初期混乱:随机输出,准确率接近1/vocab_size
- 起始符学习:快速学会复制第一个符号
- 模式识别:逐渐理解复制任务的本质
- 完美复制:最终实现100%准确率
这种精心设计的复制任务,为模型的复杂应用奠定了坚实的基础,体现了**“大道至简”**的深度学习训练哲学。
单元格 78-85:实际机器翻译任务 - 真实应用的挑战
是什么: 从人工数据到真实语言的跨越
# Load the dataset using datasets library
from datasets import load_dataset
# Load IWSLT 2017 German-English dataset
raw_datasets = load_dataset("iwslt2017", "iwslt2017-de-en")
# Tokenization using spaCy
import spacy
spacy_de = spacy.load("de_core_news_sm")
spacy_en = spacy.load("en_core_web_sm")
def tokenize_de(text):
return [tok.text for tok in spacy_de.tokenizer(text)]
def tokenize_en(text):
return [tok.text for tok in spacy_en.tokenizer(text)]
机器翻译的核心挑战:
- 语言复杂性:处理真实语言的语法、语义、语用等多层面复杂性
- 知识迁移:将在复制任务上验证的架构应用到复杂的双语映射
- 评估标准:使用BLEU等标准指标衡量翻译质量
为什么选择机器翻译: Transformer的经典应用场景
机器翻译的代表性意义:
- 序列到序列本质:体现Transformer encoder-decoder架构的完整能力
- 历史意义:Transformer最初就是为机器翻译而设计
- 实用价值:具有直接的商业和社会应用价值
- 评估完善:有成熟的评估体系和基准数据集
IWSLT数据集的优势:
- 适中规模:数据量足够训练,但不会过度消耗计算资源
- 高质量:人工标注,质量可靠
- 标准基准:研究社区广泛使用,便于对比
- 语言对经典:德英翻译是NLP研究的经典语言对
怎么做: 真实数据处理的工程挑战
分词处理的深层考虑:
spacy_de = spacy.load("de_core_news_sm") # 德语分词模型
spacy_en = spacy.load("en_core_web_sm") # 英语分词模型
为什么使用spaCy:
- 语言特异性:针对不同语言的专门优化
- 工业级质量:经过大规模数据训练和验证
- 一致性:确保分词结果的可重复性
- 便利性:与深度学习框架良好集成
分词策略的影响:
# 例如德语复合词:
# "Bundesrepublik" → ["Bundes", "republik"] 或 ["Bundesrepublik"]
# 不同策略影响词汇表大小和翻译质量
这种从简单复制任务到复杂机器翻译的渐进式设计,体现了深度学习项目中**“从简到繁、步步为营”**的工程智慧。
**分词的重要性:**
1. **语言理解**:将文本分解成有意义的单元
2. **标准化**:处理标点、大小写等
3. **多语言支持**:不同语言有不同的分词规则
**spaCy 工具:**
- 业界标准的自然语言处理库
- 提供多语言支持
- 包含预训练的语言模型
### 词汇表构建:
```python
from torchtext.data.utils import get_tokenizer
from collections import Counter
from torchtext.vocab import vocab
def build_vocabulary(spacy_tokenizer, data_iter):
def yield_tokens(data_iter):
for data_sample in data_iter:
yield spacy_tokenizer(data_sample)
counter = Counter()
for tokens in yield_tokens(data_iter):
counter.update(tokens)
return vocab(counter, specials=["<unk>", "<pad>", "<bos>", "<eos>"])
词汇表的作用:
- 词汇映射:将词汇转换为数字ID
- 处理未知词:使用
<unk>
标记 - 特殊标记:
<pad>
:填充短句子<bos>
:句子开始<eos>
:句子结束<unk>
:未知词汇
数据加载器:
def collate_batch(batch, src_pipeline, tgt_pipeline, src_vocab, tgt_vocab, device, max_padding=128, pad_id=2):
bs_id = torch.tensor([0], device=device) # <bos> token id
eos_id = torch.tensor([1], device=device) # <eos> token id
src_list, tgt_list = [], []
for (_src, _tgt) in batch:
processed_src = torch.cat([bs_id, torch.tensor(src_vocab(src_pipeline(_src)), dtype=torch.int64, device=device), eos_id], 0)
processed_tgt = torch.cat([bs_id, torch.tensor(tgt_vocab(tgt_pipeline(_tgt)), dtype=torch.int64, device=device), eos_id], 0)
src_list.append(processed_src[:max_padding])
tgt_list.append(processed_tgt[:max_padding])
return pad_sequence(src_list, padding_value=pad_id), pad_sequence(tgt_list, padding_value=pad_id)
批次处理的挑战:
- 变长序列:不同句子长度不同
- 填充对齐:需要填充到相同长度
- 特殊标记:添加句子边界标记
- 内存管理:限制最大长度
单元格 86-92:模型训练和评估
训练配置:
def create_model(src_vocab_size, tgt_vocab_size, N=6, d_model=512, d_ff=2048, h=8, dropout=0.1):
"Helper: Construct a model from hyperparameters."
c = copy.deepcopy
attn = MultiHeadedAttention(h, d_model)
ff = PositionwiseFeedForward(d_model, d_ff, dropout)
position = PositionalEncoding(d_model, dropout)
model = EncoderDecoder(
Encoder(EncoderLayer(d_model, c(attn), c(ff), dropout), N),
Decoder(DecoderLayer(d_model, c(attn), c(attn), c(ff), dropout), N),
nn.Sequential(Embeddings(d_model, src_vocab_size), c(position)),
nn.Sequential(Embeddings(d_model, tgt_vocab_size), c(position)),
Generator(d_model, tgt_vocab_size))
# Initialize parameters with Glorot / fan_avg.
for p in model.parameters():
if p.dim() > 1:
nn.init.xavier_uniform_(p)
return model
超参数的选择:
N=6
:编码器和解码器各6层(原论文配置)d_model=512
:模型维度d_ff=2048
:前馈网络维度h=8
:注意力头数dropout=0.1
:防止过拟合
参数初始化:
nn.init.xavier_uniform_(p)
- Xavier初始化保持激活值的方差
- 有助于训练稳定性
- 避免梯度爆炸或消失
训练循环:
def train_worker(gpu, ngpus_per_node, config, model, criterion, opt):
torch.distributed.init_process_group(
backend="nccl", init_method="env://", rank=gpu, world_size=ngpus_per_node
)
torch.cuda.set_device(gpu)
device = torch.device("cuda:{}".format(gpu))
model = model.to(device)
model = DDP(model, device_ids=[gpu])
train_dataloader, valid_dataloader = create_dataloaders(device, config["vocab_src"], config["vocab_tgt"], config["spacy_de"], config["spacy_en"], config["batch_size"], config["max_padding"], config["is_distributed"])
optimizer = torch.optim.Adam(model.parameters(), lr=config["base_lr"], betas=(0.9, 0.98), eps=1e-9)
lr_scheduler = LambdaLR(optimizer=optimizer, lr_lambda=lambda step: rate(step, config["d_model"], factor=1, warmup=config["warmup"]))
train_state = TrainState()
for epoch in range(config["num_epochs"]):
if config["is_distributed"]:
train_dataloader.sampler.set_epoch(epoch)
model.train()
print(f"[GPU{gpu}] Epoch {epoch} Training ====", flush=True)
_, train_state = run_epoch(
(Batch(b[0], b[1], config["pad_id"]) for b in train_dataloader),
model,
SimpleLossCompute(model.module.generator, criterion),
optimizer,
lr_scheduler,
mode="train+log",
accum_iter=config["accum_iter"],
train_state=train_state,
)
分布式训练:
- 使用多个GPU并行训练
DDP
(DistributedDataParallel):PyTorch的分布式训练- 提高训练速度和处理能力
优化器配置:
- Adam:自适应学习率优化器
- betas=(0.9, 0.98):动量参数
- eps=1e-9:数值稳定性
BLEU评估:
def check_outputs(valid_dataloader, model, vocab_src, vocab_tgt, n_examples=15, pad_idx=2, eos_string="</s>"):
results = [()] * n_examples
for idx in range(n_examples):
print("\nExample %d ========\n" % idx)
b = next(iter(valid_dataloader))
rb = Batch(b[0], b[1], pad_idx)
greedy_decode_result, _ = greedy_decode(model, rb.src, rb.src_mask, 64, 0)
src_tokens = [vocab_src.get_itos()[x] for x in rb.src[0] if x != pad_idx]
tgt_tokens = [vocab_tgt.get_itos()[x] for x in rb.tgt[0] if x != pad_idx]
print("Source Text (Input) : " + " ".join(src_tokens).replace("\n", ""))
print("Target Text (Ground Truth) : " + " ".join(tgt_tokens).replace("\n", ""))
print("Model Output : " + " ".join([vocab_tgt.get_itos()[x] for x in greedy_decode_result[0] if x != pad_idx]).replace("\n", ""))
results[idx] = (rb, greedy_decode_result)
return results
模型评估的重要性:
- 定量评估:BLEU分数等指标
- 定性分析:人工检查翻译质量
- 错误分析:理解模型的局限性
BLEU分数:
- 比较机器翻译和参考翻译的n-gram重叠
- 分数范围0-100,越高越好
- 业界标准的机器翻译评估指标
单元格 93-100:注意力可视化
注意力权重提取:
def draw_attention(data, layer, head, row, col, ax):
"Draw attention weights"
attention = data[layer][0, head].data
ax.matshow(attention, cmap='Blues')
ax.set_xticks(range(len(col)))
ax.set_yticks(range(len(row)))
ax.set_xticklabels(col, rotation=90)
ax.set_yticklabels(row)
注意力可视化的价值:
- 模型解释性:理解模型在关注什么
- 调试工具:发现模型的问题
- 语言学洞察:揭示语言现象
多头注意力分析:
def visualize_attention(model, vocab_src, vocab_tgt, sentence_idx=0):
# Set model to evaluation mode
model.eval()
with torch.no_grad():
# Get a batch of data
example_data = check_outputs(valid_dataloader, model, vocab_src, vocab_tgt, n_examples=1)
example_data_idx = sentence_idx
# Get the source and target sentences
src_sent = example_data[example_data_idx][0].src[0]
tgt_sent = example_data[example_data_idx][1][0]
# Convert to tokens
src_tokens = [vocab_src.get_itos()[x] for x in src_sent if x != 2] # Remove padding
tgt_tokens = [vocab_tgt.get_itos()[x] for x in tgt_sent if x != 2] # Remove padding
print("Source:", " ".join(src_tokens))
print("Target:", " ".join(tgt_tokens))
# Get attention weights for each layer and head
attns = run_model_extract_attentions(example_data[example_data_idx][0], model)
# Visualize different attention heads
for layer in range(6): # 6 layers
fig, axes = plt.subplots(2, 4, figsize=(16, 8))
for head in range(8): # 8 heads
draw_attention(attns, layer, head, tgt_tokens, src_tokens, axes[head//4, head%4])
axes[head//4, head%4].set_title(f"Layer {layer+1} Head {head+1}")
不同注意力头的专门化: 研究发现不同的注意力头学会了不同的语言现象:
- 语法关系:主谓关系、修饰关系
- 长距离依赖:跨越多个词的关系
- 位置信息:相邻词的关系
编码器-解码器注意力:
编码器-解码器注意力显示了:
- 对齐关系:源语言和目标语言词汇的对应
- 翻译策略:模型如何处理不同的语言结构
- 语言差异:德语和英语的语序差异
总结:完整的Transformer实现
我们学到了什么:
1. 模型架构:
- 注意力机制:Self-attention和Cross-attention
- 多头注意力:并行处理不同类型的关系
- 位置编码:为模型提供位置信息
- 残差连接和层归一化:稳定训练
2. 训练技术:
- Teacher Forcing:训练时使用真实标签
- 学习率调度:Warmup和衰减策略
- 标签平滑:提高泛化能力
- 梯度累积:模拟大批次训练
3. 实际应用:
- 数据处理:分词、词汇表构建
- 批次处理:填充和掩码
- 分布式训练:多GPU并行
- 模型评估:BLEU分数和可视化
为什么Transformer如此重要:
1. 并行化:
- 不像RNN需要顺序处理
- 可以并行计算所有位置
- 训练和推理都更快
2. 长距离依赖:
- 直接连接任意两个位置
- 避免了RNN的梯度问题
- 更好地处理长序列
3. 可解释性:
- 注意力权重提供了解释
- 可以看到模型关注什么
- 有助于调试和理解
4. 可扩展性:
- 架构简单但功能强大
- 可以轻松调整层数和维度
- 为GPT、BERT等奠定基础
后续发展:
这个基础的Transformer架构启发了:
- BERT:双向编码器表示
- GPT:生成式预训练Transformer
- T5:Text-to-Text Transfer Transformer
- 更多变体:优化效率和性能
通过这个详细的解释,你应该对Transformer有了深入的理解。从基础的Python语法到复杂的注意力机制,每一个组件都有其重要的作用。这为理解现代自然语言处理的基础打下了坚实的基础!
建议的学习路径:
- 先理解概念:每个组件的作用和原理
- 动手实践:运行代码,修改参数
- 可视化分析:观察注意力权重
- 尝试改进:调整架构或训练策略
- 应用扩展:尝试其他任务和数据集
记住,深度学习是一个实践性很强的领域,理论理解和动手实践同样重要!