作者:互联网 时间: 2026-07-14 09:03:59
前两篇我们搭了数学基础,理解了激活函数、归一化层的设计哲学。但知识停留在脑子里和写在纸上是不够的——代码才是工程能力的试金石。
这一篇的目标很直接:手把手搭一个完整的 MiniBERT 训练循环,包含:
过程中顺便把 Python 高级特性和 PyTorch 底层机制讲清楚。这不是一篇基础教程——我们直接进入"写生产级训练代码"的状态。
先来三个 Python 特性,它们在 DL 代码中频繁出现,但新手往往忽略。
@dataclass:超参数管理的最佳拍档写模型时你需要管理几十个超参数。最原始的方式是手写 __init__:
class Config:def __init__(self, vocab_size=10000, d_model=128, lr=5e-4):self.vocab_size = vocab_sizeself.d_model = d_modelself.lr = lr
Python 3.7 引入的 @dataclass 自动帮你生成这些样板代码:
from dataclasses import dataclassclass MiniBERTConfig:vocab_size: int = 10000d_model: int = 128learning_rate: float = 5e-4num_epochs: int = 10
它不仅少了代码,还免费获得了 __repr__(打印友好)、__eq__(比较两个配置是否相同)。这对于实验管理非常重要——你可以在日志中直接 print(config) 看到所有参数。
踩坑:dataclass 默认值不要用可变类型:
# ❌ 错误:所有实例共享同一个 listclass BadConfig:layer_sizes: list = [128, 256, 512]# ✅ 正确:每次创建一个新 listclass GoodConfig:layer_sizes: list = field(default_factory=lambda: [128, 256, 512])
model.train() 不再需要成对出现def training_mode(model, mode=True):original = model.trainingtry:model.train(mode)yieldfinally:model.train(original)# 使用with training_mode(model):output = model(inputs)loss.backward()
即使中间抛出异常,finally 块也能保证恢复模式。这是异常安全的工程实践。
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 可以理解为一个支持 GPU 加速、自动求导的 NumPy 数组。
# 创建x = torch.randn(3, 4, device='cuda') # GPU tensory = 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)。
当你调用 loss.backward() 时,PyTorch 做了什么?
.grad 属性填上计算结果三个关键操作:
| 操作 | 作用 | 何时使用 |
|---|---|---|
.detach() | 从计算图中分离 | 在验证/推理时使用 |
.item() | 提取 Python 标量 | 记录 loss 日志(仅 0-dim) |
.requires_grad_() | 开启/关闭梯度追踪 | 冻结某层参数 |
踩坑:.item() vs .detach().cpu()
loss = tensor([3.1415]) # 0-dim tensor# ✅ 正确:提取单个标量val = loss.item()# ❌ 错误:loss 是 GPU tensor 时需要先移到 CPU# numpy_array = loss.numpy() # RuntimeError!# ✅ 正确:先 detach,再 cpu,再 numpynumpy_array = loss.detach().cpu().numpy()# 两步还是三步?# .detach(): 断开梯度追踪# .cpu():如果 tensor 在 GPU 上,拷贝到 CPU# .numpy():转换为 numpy 数组
记一个顺序口诀:"断梯→拉回→转换"(detach → cpu → numpy)。
nn.Module 提供了:
__init__ 中创建的 nn.Parameter 自动被注册nn.ModuleList、nn.Sequential 自动注册子模块.to(device) 递归移动所有参数.train() / .eval()重要提醒:不要直接用 Python list 存放模块!
# ❌ 错误:list 不会注册子模块,参数不会被优化器管理self.layers = [TransformerBlock(i) for i in range(4)]# ✅ 正确:用 nn.ModuleListself.layers = nn.ModuleList([TransformerBlock(i) for i in range(4)])
__init__ 只建模块,不写计算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 中。
forward 只描述计算图def forward(self, input_ids):x = self.token_embedding(input_ids)for layer in self.layers:x = layer(x)return x
forward 不应该:
.item() 或 .numpy()不初始化直接训练的后果:前几步 loss 变成 nan。PyTorch 各层的默认初始化不一定适合你的模型,显式初始化是成熟工程项目的标配。
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 的初始化实践。
BERT 等模型的 Token Embedding 和输出预测头共享同一个权重矩阵:
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 通常很大)。
transpose、permute、view 等操作返回的是"视图"——它们共享原始数据的内存。
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()。
广播是 PyTorch 隐式扩展张量形状的机制。理解它可以让代码更简洁、更高效:
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 的维度复制。
# ❌ 循环方式(Python 循环在 GPU 上极度低效)for i in range(n):y[i] = torch.dot(w[i], x)# ✅ 向量化方式(GPU 并行执行)y = w @ x
在 GPU 上,向量化比循环快 10~100 倍(取决于数据量)。如果你的训练代码中出现了 Python for 循环且内部涉及 Tensor 操作,大概率可以向量化。
from torchinfo import summarysummary(model, input_size=(32, 64), dtypes=[torch.long])
输出包含:每层的名称、输出形状、参数量、可训练参数量。这是调试模型结构是否正确的第一道防线。
from torch.utils.tensorboard import SummaryWriterwriter = 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 即可在浏览器中查看。可视化训练曲线能帮你快速发现过拟合、欠拟合、学习率异常等问题。
当 loss 出现 nan 时,第一个排查步骤:
# 在 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
完整代码见 code/03-python-pytorch-practice/。
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)
一个标准的训练 step:
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() 之前。
左图:训练 Loss(蓝)和验证 Loss(橙)在 300 步内的下降过程。右图:余弦退火 + Warmup 学习率调度,前期线性上升后余弦衰减。
踩坑一:.item() vs .detach().cpu()
# ❌ 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()
# ❌ 评估时仍跟踪梯度——浪费显存,可能修改参数for batch in val_loader:output = model(batch)val_loss += loss_fn(output, labels)# ✅ 正确:用装饰器或上下文管理器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。
踩坑四:显存泄漏
常见原因:
detach())诊断方法:
import gcgc.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) 而不是默认行为?后者有什么性能优势?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 代码,聚焦工程基础设施:
本文代码示例:code/03-python-pytorch-practice/