3. 模型训练基础:数据集准备与预处理、模型定义与训练脚本编写、保存训练好的模型(checkpoint)

好,咱们进入实操环节。模型移植这件事,说白了就是让训练好的模型在目标设备上跑起来。但前提是——你得先有一个训练好的模型。这一章,我就带你走一遍模型训练的完整流程:从原始数据到 checkpoint 文件。

我个人习惯把训练流程拆成三个模块:数据流水线、模型与训练逻辑、保存与恢复。这三个模块串起来,就是一个可复用的训练脚本骨架。

核心思路:训练脚本不是一次性代码,而是后续部署验证的基准线。你后面做量化、剪枝、转换格式,都要回头跟这个基准线对比精度。

3.1 数据集准备与预处理

数据是模型的上限。我在项目中遇到过好几次,模型结构调了半天精度上不去,最后发现是数据预处理跟推理时对不上。嗯,这里要注意:训练时的预处理流水线,必须跟部署时的预处理完全一致。

3.1.1 数据集组织

我建议用文件夹结构来组织数据集,简单直观:

dataset/
├── train/
│   ├── cat/
│   │   ├── cat_001.jpg
│   │   └── cat_002.jpg
│   └── dog/
│       ├── dog_001.jpg
│       └── dog_002.jpg
└── val/
    ├── cat/
    └── dog/

这种结构的好处是:torchvision.datasets.ImageFolder 可以直接读取,省去写数据加载器的麻烦。

3.1.2 预处理流水线

常见的预处理步骤包括:

步骤 操作 说明
1 Resize 统一输入尺寸,常用 224×224 或 256×256
2 RandomCrop / CenterCrop 训练用随机裁剪做数据增强,验证用中心裁剪
3 RandomHorizontalFlip 随机水平翻转,增加数据多样性
4 ToTensor 将 PIL Image 或 numpy 数组转为 Tensor,并缩放到 [0,1]
5 Normalize 用 ImageNet 均值标准差标准化:mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]
我的经验:Normalize 的均值和标准差一定要记下来。部署时如果用了不同的标准化参数,模型输出会完全偏离预期。我曾经因为这个排查了整整两天。

代码实现如下:

from torchvision import transforms

train_transform = transforms.Compose([
    transforms.Resize(256),
    transforms.RandomCrop(224),
    transforms.RandomHorizontalFlip(),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406],
                         std=[0.229, 0.224, 0.225])
])

val_transform = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406],
                         std=[0.229, 0.224, 0.225])
])

3.1.3 DataLoader 配置

DataLoader 的参数直接影响训练速度和内存占用:

from torch.utils.data import DataLoader

train_loader = DataLoader(
    train_dataset,
    batch_size=64,
    shuffle=True,
    num_workers=4,      # 数据加载的并行进程数
    pin_memory=True,    # 锁页内存,加速 GPU 传输
    drop_last=True      # 丢弃最后一个不完整的 batch
)

val_loader = DataLoader(
    val_dataset,
    batch_size=64,
    shuffle=False,
    num_workers=4,
    pin_memory=True
)
注意:num_workers 不是越大越好。我遇到过设成 8 反而比 4 慢的情况,因为进程切换开销超过了并行收益。建议从 4 开始调。

3.2 模型定义与训练脚本编写

模型定义这块,我习惯用 PyTorch 的 nn.Module 来封装。你想想看,这样后续做量化、导出 ONNX 都方便。

3.2.1 模型定义

以 ResNet-18 为例:

import torch.nn as nn
import torchvision.models as models

class CustomModel(nn.Module):
    def __init__(self, num_classes=2):
        super().__init__()
        # 加载预训练权重
        self.backbone = models.resnet18(pretrained=True)
        # 替换最后一层全连接
        in_features = self.backbone.fc.in_features
        self.backbone.fc = nn.Linear(in_features, num_classes)

    def forward(self, x):
        return self.backbone(x)

这里有个细节:预训练权重。我建议在 ImageNet 上预训练的模型作为起点,尤其是当你的数据集比较小的时候。迁移学习能省很多时间。

3.2.2 训练脚本核心逻辑

训练脚本的骨架其实很固定。我一般这样组织:

import torch
import torch.optim as optim
from torch.optim.lr_scheduler import StepLR

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = CustomModel(num_classes=2).to(device)

criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.001, momentum=0.9)
scheduler = StepLR(optimizer, step_size=30, gamma=0.1)

def train_one_epoch(model, loader, optimizer, criterion, device):
    model.train()
    total_loss = 0.0
    correct = 0
    total = 0

    for images, labels in loader:
        images, labels = images.to(device), labels.to(device)

        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

        total_loss += loss.item()
        _, predicted = outputs.max(1)
        total += labels.size(0)
        correct += predicted.eq(labels).sum().item()

    avg_loss = total_loss / len(loader)
    accuracy = 100. * correct / total
    return avg_loss, accuracy

def validate(model, loader, criterion, device):
    model.eval()
    total_loss = 0.0
    correct = 0
    total = 0

    with torch.no_grad():
        for images, labels in loader:
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            loss = criterion(outputs, labels)

            total_loss += loss.item()
            _, predicted = outputs.max(1)
            total += labels.size(0)
            correct += predicted.eq(labels).sum().item()

    avg_loss = total_loss / len(loader)
    accuracy = 100. * correct / total
    return avg_loss, accuracy
关键点:训练时用 model.train() 启用 Dropout 和 BatchNorm 的统计更新;验证时用 model.eval() 固定这些层。搞反了的话,验证精度会忽高忽低。

3.2.3 训练循环

num_epochs = 100
best_acc = 0.0

for epoch in range(1, num_epochs + 1):
    train_loss, train_acc = train_one_epoch(
        model, train_loader, optimizer, criterion, device
    )
    val_loss, val_acc = validate(model, val_loader, criterion, device)
    scheduler.step()

    print(f'Epoch {epoch:3d} | '
          f'Train Loss: {train_loss:.4f} Acc: {train_acc:.2f}% | '
          f'Val Loss: {val_loss:.4f} Acc: {val_acc:.2f}%')

    # 保存最佳模型
    if val_acc > best_acc:
        best_acc = val_acc
        torch.save(model.state_dict(), 'best_model.pth')

为什么会用 state_dict() 而不是整个模型?因为 state_dict() 只保存参数,不保存模型结构。这样更轻量,也方便后续加载到不同设备上。

3.3 保存训练好的模型(Checkpoint)

保存 checkpoint 不只是存个权重文件。我建议保存更多信息,方便训练中断后恢复,或者对比不同 epoch 的效果。

3.3.1 保存完整 Checkpoint

checkpoint = {
    'epoch': epoch,
    'model_state_dict': model.state_dict(),
    'optimizer_state_dict': optimizer.state_dict(),
    'scheduler_state_dict': scheduler.state_dict(),
    'best_acc': best_acc,
    'train_loss': train_loss,
    'val_loss': val_loss
}
torch.save(checkpoint, f'checkpoint_epoch_{epoch}.pth')

3.3.2 加载 Checkpoint 恢复训练

checkpoint = torch.load('checkpoint_epoch_50.pth')
model.load_state_dict(checkpoint['model_state_dict'])
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
scheduler.load_state_dict(checkpoint['scheduler_state_dict'])
start_epoch = checkpoint['epoch'] + 1
best_acc = checkpoint['best_acc']

print(f'Resuming from epoch {start_epoch}, best accuracy: {best_acc:.2f}%')
我的习惯:每 10 个 epoch 保存一个完整 checkpoint,同时始终保留一个 best_model.pth。这样既不会丢失最佳结果,又能随时回退到中间状态。

3.3.3 保存为部署格式

训练完成后,我会额外保存一份 TorchScript 格式,方便后续部署:

model.eval()
example_input = torch.randn(1, 3, 224, 224).to(device)
traced_model = torch.jit.trace(model, example_input)
traced_model.save('model_script.pt')

TorchScript 的好处是:不依赖 Python 环境,可以在 C++ 中直接加载推理。后面做 ONNX 导出也是基于这个格式。

3.4 本章知识结构

我把整个训练流程画成了一张图,方便你对照理解:

模型训练全流程结构图 模型训练全流程结构图 原始数据集 预处理流水线 DataLoader 模型定义 损失函数 + 优化器 训练循环(Epoch) 验证评估 精度监控 保存 Checkpoint 导出 TorchScript / ONNX 关键注意事项 • 训练预处理必须与部署预处理一致 • 保存 state_dict 而非整个模型 • 始终保留最佳模型 + 完整 checkpoint • 记录 Normalize 参数供部署使用
避坑指南:我曾经在部署阶段发现模型精度掉了 5 个点,排查了三天才发现是训练时用了 transforms.RandomResizedCrop,但部署时用了 transforms.Resize + CenterCrop。预处理不一致,精度必掉。