前置知识:Python 基础 / 第02篇的激活函数和归一化概念 / 第01篇的数学基础
引言:从"能跑"到"能训练"
前两篇我们搭了数学基础,理解了激活函数、归一化层的设计哲学。但知识停留在脑子里和写在纸上是不够的------代码才是工程能力的试金石。
这一篇的目标很直接:手把手搭一个完整的 MiniBERT 训练循环,包含:
- 数据加载(DataLoader)
- 模型定义(nn.Module 最佳实践)
- 训练循环(前向→损失→反向→优化器步)
- 工程化技巧(梯度裁剪、学习率调度、checkpoint)
过程中顺便把 Python 高级特性和 PyTorch 底层机制讲清楚。这不是一篇基础教程------我们直接进入"写生产级训练代码"的状态。
一、Python 高级特性:深度学习中的"工程甜点"
先来三个 Python 特性,它们在 DL 代码中频繁出现,但新手往往忽略。
1.1 @dataclass:超参数管理的最佳拍档
写模型时你需要管理几十个超参数。最原始的方式是手写 __init__:
python
class Config:
def __init__(self, vocab_size=10000, d_model=128, lr=5e-4):
self.vocab_size = vocab_size
self.d_model = d_model
self.lr = lr
Python 3.7 引入的 @dataclass 自动帮你生成这些样板代码:
python
from dataclasses import dataclass
@dataclass
class MiniBERTConfig:
vocab_size: int = 10000
d_model: int = 128
learning_rate: float = 5e-4
num_epochs: int = 10
它不仅少了代码,还免费获得了 __repr__(打印友好)、__eq__(比较两个配置是否相同)。这对于实验管理非常重要------你可以在日志中直接 print(config) 看到所有参数。
踩坑:dataclass 默认值不要用可变类型:
python
# ❌ 错误:所有实例共享同一个 list
@dataclass
class BadConfig:
layer_sizes: list = [128, 256, 512]
# ✅ 正确:每次创建一个新 list
@dataclass
class GoodConfig:
layer_sizes: list = field(default_factory=lambda: [128, 256, 512])
1.2 上下文管理器:model.train() 不再需要成对出现
python
@contextmanager
def training_mode(model, mode=True):
original = model.training
try:
model.train(mode)
yield
finally:
model.train(original)
# 使用
with training_mode(model):
output = model(inputs)
loss.backward()
即使中间抛出异常,finally 块也能保证恢复模式。这是异常安全的工程实践。
1.3 生成器:无限数据流
python
def infinite_batches(dataset, batch_size):
while True:
indices = torch.randperm(len(dataset))
for start in range(0, len(dataset), batch_size):
yield [dataset[i] for i in indices[start:start+batch_size]]
生成器(yield)在"永远产生数据"的场景下非常自然------比如分布式训练中每个进程需要独立的、无限的数据流。
二、PyTorch 核心三剑客:Tensor · Autograd · Module
2.1 Tensor:PyTorch 的"原子"
PyTorch 的 Tensor 可以理解为一个支持 GPU 加速、自动求导的 NumPy 数组。
python
# 创建
x = torch.randn(3, 4, device='cuda') # GPU tensor
y = torch.ones(3, 4, requires_grad=True) # 需要计算梯度
# 形状操作
x.view(-1) # reshape(需要 contiguous)
x.transpose(0, 1) # 转置(返回视图)
x.permute(2, 0, 1) # 任意维度置换
Tensor 最容易被忽视的特性是内存布局 。一个 Tensor 在内存中是一段连续的 float 数组加上元数据(shape、stride、dtype)。transpose 等操作不复制数据,只是修改了 stride 元信息------这称为"视图"(View)。
2.2 Autograd:自动求导引擎
当你调用 loss.backward() 时,PyTorch 做了什么?
- 遍历计算图(从 loss 节点开始反向遍历)
- 对每个中间节点应用链式法则
- 把所有需要梯度的
.grad属性填上计算结果
三个关键操作:
| 操作 | 作用 | 何时使用 |
|---|---|---|
.detach() |
从计算图中分离 | 在验证/推理时使用 |
.item() |
提取 Python 标量 | 记录 loss 日志(仅 0-dim) |
.requires_grad_() |
开启/关闭梯度追踪 | 冻结某层参数 |
踩坑:.item() vs .detach().cpu()
python
loss = tensor([3.1415]) # 0-dim tensor
# ✅ 正确:提取单个标量
val = loss.item()
# ❌ 错误:loss 是 GPU tensor 时需要先移到 CPU
# numpy_array = loss.numpy() # RuntimeError!
# ✅ 正确:先 detach,再 cpu,再 numpy
numpy_array = loss.detach().cpu().numpy()
# 两步还是三步?
# .detach(): 断开梯度追踪
# .cpu(): 如果 tensor 在 GPU 上,拷贝到 CPU
# .numpy(): 转换为 numpy 数组
记一个顺序口诀:"断梯→拉回→转换"(detach → cpu → numpy)。
2.3 nn.Module:一切模型的基类
nn.Module 提供了:
- 参数注册 :
__init__中创建的nn.Parameter自动被注册 - 子模块管理 :
nn.ModuleList、nn.Sequential自动注册子模块 - 设备管理 :
.to(device)递归移动所有参数 - 训练/评估切换 :
.train()/.eval()
重要提醒 :不要直接用 Python list 存放模块!
python
# ❌ 错误:list 不会注册子模块,参数不会被优化器管理
self.layers = [TransformerBlock(i) for i in range(4)]
# ✅ 正确:用 nn.ModuleList
self.layers = nn.ModuleList([TransformerBlock(i) for i in range(4)])
三、自定义 nn.Module 的最佳实践
3.1 __init__ 只建模块,不写计算
python
class MiniBERT(nn.Module):
def __init__(self, config):
super().__init__()
self.token_embedding = nn.Embedding(config.vocab_size, config.d_model)
self.layers = nn.ModuleList([TransformerBlock(config) for _ in range(config.n_layers)])
__init__ 的职责只是搭建"骨架",所有与数据相关的操作放在 forward 中。
3.2 forward 只描述计算图
python
def forward(self, input_ids):
x = self.token_embedding(input_ids)
for layer in self.layers:
x = layer(x)
return x
forward 不应该:
- ❌ 修改模型参数(如更新 buffer)
- ❌ 调用
.item()或.numpy() - ❌ 从计算图中分离张量
3.3 参数初始化
不初始化直接训练的后果:前几步 loss 变成 nan。PyTorch 各层的默认初始化不一定适合你的模型,显式初始化是成熟工程项目的标配。
python
def _init_weights(self):
for module in self.modules():
if isinstance(module, nn.Linear):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.bias is not None:
nn.init.zeros_(module.bias)
elif isinstance(module, nn.LayerNorm):
nn.init.ones_(module.weight)
nn.init.zeros_(module.bias)
0.02 这个值来自 BERT/OpenAI GPT 的初始化实践。
3.4 权重绑定(Weight Tying)
BERT 等模型的 Token Embedding 和输出预测头共享同一个权重矩阵:
python
self.token_embedding = nn.Embedding(vocab_size, d_model)
self.mlm_head = nn.Linear(d_model, vocab_size, bias=False)
self.mlm_head.weight = self.token_embedding.weight # 共享!
这可以减少约 1/3 的参数量(因为 vocab_size × d_model 通常很大)。
四、张量操作优化
4.1 contiguous():什么时候需要?
transpose、permute、view 等操作返回的是"视图"------它们共享原始数据的内存。
python
x = torch.randn(2, 3, 4)
x_t = x.transpose(0, 1) # 视图,非连续内存
print(x_t.is_contiguous()) # False
# view() 要求输入是 contiguous 的
x_t.view(-1) # RuntimeError!
# 解决方案:先复制为连续内存
x_c = x_t.contiguous()
x_c.view(-1) # 成功
经验法则 :在 transpose/permute 之后、view 之前,加一个 .contiguous()。
4.2 广播机制
广播是 PyTorch 隐式扩展张量形状的机制。理解它可以让代码更简洁、更高效:
python
a = torch.randn(32, 1, 64) # [B, 1, d]
b = torch.randn(1, 10, 64) # [1, T, d]
c = a + b # 自动广播 → [32, 10, 64]
规则简单一句话:从后往前对齐维度,缺失或为 1 的维度复制。
4.3 向量化替代循环
python
# ❌ 循环方式(Python 循环在 GPU 上极度低效)
for i in range(n):
y[i] = torch.dot(w[i], x)
# ✅ 向量化方式(GPU 并行执行)
y = w @ x
在 GPU 上,向量化比循环快 10~100 倍 (取决于数据量)。如果你的训练代码中出现了 Python for 循环且内部涉及 Tensor 操作,大概率可以向量化。
五、调试技巧
5.1 torchinfo:一键查看模型结构
python
from torchinfo import summary
summary(model, input_size=(32, 64), dtypes=[torch.long])
输出包含:每层的名称、输出形状、参数量、可训练参数量。这是调试模型结构是否正确的第一道防线。
5.2 TensorBoard
python
from torch.utils.tensorboard import SummaryWriter
writer = SummaryWriter('runs/experiment_001')
writer.add_scalar('Loss/train', loss, epoch)
writer.add_scalar('LR', learning_rate, epoch)
writer.add_histogram('weights', model.fc1.weight, epoch)
在终端运行 tensorboard --logdir runs 即可在浏览器中查看。可视化训练曲线能帮你快速发现过拟合、欠拟合、学习率异常等问题。
5.3 梯度检查
当 loss 出现 nan 时,第一个排查步骤:
python
# 在 backward() 之后检查梯度
for name, param in model.named_parameters():
if param.grad is not None and torch.isnan(param.grad).any():
print(f"梯度 NaN 出现在: {name}")
break
六、代码实战:MiniBERT 训练循环
完整代码见 code/03-python-pytorch-practice/。
6.1 模型结构
scss
MiniBERT(
(token_embedding): Embedding(10000, 128)
(position_embedding): Embedding(64, 128)
(layers): ModuleList(
(0-3): 4 x TransformerBlock(
(attention): MultiHeadSelfAttention(n_heads=4, d_head=32)
(ffn): FeedForward(SwiGLU, d_ff=512)
)
)
(mlm_head): Linear(128 → 10000)
)
总参数量: ~2M(BERT-base 的 1/50)
6.2 训练循环模板
一个标准的训练 step:
python
model.train() # 1. 训练模式
optimizer.zero_grad() # 2. 清零梯度
logits = model(input_ids) # 3. 前向
loss = loss_fn(logits, labels) # 4. 计算损失
loss.backward() # 5. 反向传播
nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 6. 梯度裁剪
optimizer.step() # 7. 参数更新
scheduler.step() # 8. 学习率更新
每行代码都不多余 ,顺序也不能换------比如梯度裁剪必须在 backward() 之后、step() 之前。
6.3 踩坑记录

左图:训练 Loss(蓝)和验证 Loss(橙)在 300 步内的下降过程。右图:余弦退火 + Warmup 学习率调度,前期线性上升后余弦衰减。
踩坑一:.item() vs .detach().cpu()
python
# ❌ GPU tensor 直接调用 .item() 的隐患
total_loss += loss.item() # 正确,loss 通常是 0-dim
# ❌ 需要 numpy 数组时的错误方式
# grad_np = loss.grad.numpy() # RuntimeError!
# ✅ 正确方式
grad_np = loss.grad.detach().cpu().numpy()
踩坑二:评估时忘了 torch.no_grad()
python
# ❌ 评估时仍跟踪梯度------浪费显存,可能修改参数
for batch in val_loader:
output = model(batch)
val_loss += loss_fn(output, labels)
# ✅ 正确:用装饰器或上下文管理器
@torch.no_grad()
def evaluate(model, loader):
for batch in loader:
output = model(batch)
...
踩坑三:DataLoader 的 num_workers
在 Windows 上,num_workers > 0 可能导致多进程序列化错误。建议 Windows 用户设为 0,Linux 用户可根据 CPU 核数设置为 2/4/8。
踩坑四:显存泄漏
常见原因:
- 在循环中创建了新的 Tensor 但未释放
- 保留了不再需要的计算图(未
detach()) - TensorBoard 或 matplotlib 累积了历史数据
诊断方法:
python
import gc
gc.collect() # 手动触发垃圾回收
torch.cuda.empty_cache() # 清空缓存
print(torch.cuda.memory_summary()) # 详细显存报告
要点总结
| 概念 | 一句话记住 | 工程意义 |
|---|---|---|
| @dataclass | 自动生成 init/repr/eq | 整洁的超参数管理 |
| contiguous | 转置后记得 contiguous() | 避免 view 报错 |
| detach → cpu → numpy | 先断梯再拉回最后转换 | GPU tensor 转 numpy 的标准流程 |
| 梯度裁剪 | backward 后、step 前 | 防止梯度爆炸 |
| nn.ModuleList | 不要用 list 放子模块 | 参数自动注册 |
| torch.no_grad() | 评估必须用 | 省显存防副作用 |
思考题
- 在大模型分布式训练中,为什么
zero_grad()最好用optimizer.zero_grad(set_to_none=True)而不是默认行为?后者有什么性能优势? - 权重绑定(Weight Tying)在什么场景下不适合使用?为什么有些模型选择不绑定?
- 以下代码有什么问题?如何修复?
python
class MyModel(nn.Module):
def __init__(self):
super().__init__()
self.layers = [nn.Linear(64, 64) for _ in range(4)]
def forward(self, x):
for layer in self.layers:
x = layer(x)
return x
下一篇预告
下一篇《开发工具链与部署环境》将跳出 PyTorch 代码,聚焦工程基础设施:
- Docker 容器化 PyTorch 环境
- Git 工作流与 commit 规范
- GPU 监控与进程管理
- 集群调度基础