ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyTorch 快速搭建神经网络:从数据加载到模型训练完整实战

PyTorch 快速搭建神经网络:从数据加载到模型训练完整实战

1. 为什么选择 PyTorch 快速搭建神经网络

1.1 从一次实际需求说起

去年帮一个做工业质检的朋友处理一批图像分类任务,需求很明确:给一批产品表面缺陷图打标签,分成划痕、凹坑、污渍和正常四类。数据量不大,每类大概八百来张,标注质量还行。朋友问我用啥框架,我几乎没犹豫就说了 PyTorch。原因很简单——从零到跑通第一个 baseline,我用不到四十分钟,其中还包括写数据加载和训练循环的时间。

这不是吹嘘 PyTorch 有多神,而是它的设计哲学确实对开发者友好。动态计算图意味着你可以像写普通 Python 代码一样写网络结构,print调试、条件分支、循环展开全都天然支持。相比之下,静态图框架在调试时那种“先编译再运行、报错信息隔层山”的体验,做过的人都懂。

这篇文章面向的读者很明确:有基本 Python 基础、了解神经网络概念、但还没亲手搭过一个完整模型的人。我会从张量操作讲到模型训练、评估、保存,每一步都给出可直接运行的代码和详细注释。你不需要 GPU 也能跟着跑,CPU 上小规模数据完全够用。

1.2 PyTorch 的核心优势到底在哪

很多人说 PyTorch 好用,但“好用”具体体现在哪,值得掰开说。

第一是动态图机制。每次前向传播时计算图是即时构建的,这意味着你可以在forward函数里写if判断、写for循环,甚至根据输入数据的形状动态调整网络行为。我做过一个变长序列处理的任务,用静态图框架时得各种 padding 和 mask,换成 PyTorch 后直接在 forward 里按实际长度切片,代码量少了三分之一。

第二是生态完整。torchvision提供了主流视觉模型和数据集接口,torchaudio管音频,torchtext管文本。你要做迁移学习,一行代码就能加载预训练的 ResNet;要做数据增强,transforms模块里几十种增强方法随取随用。这种“开箱即用”的程度,在快速验证想法阶段能省下大量时间。

第三是社区活跃。遇到问题搜一下,大概率能找到解决方案。论文复现代码十有八九也是 PyTorch 写的,这对跟进前沿工作非常重要。

1.3 一个完整的神经网络项目包含哪些环节

很多人以为“搭模型”就是定义网络结构,其实那只是冰山一角。一个能跑通、能复现、能迭代的项目,至少包含以下环节:

  • 数据准备:读取、清洗、划分训练验证测试集、构建 Dataset 和 DataLoader
  • 模型定义:设计网络层、确定激活函数、初始化参数
  • 训练配置:选择损失函数、优化器、学习率策略
  • 训练循环:前向传播、计算损失、反向传播、参数更新
  • 验证与调优:监控指标、早停、超参数调整
  • 模型保存与加载:持久化权重、恢复训练、推理部署

下面我会按这个流程,用一个完整的图像分类例子串起来讲。代码基于 PyTorch 2.x 版本,但核心 API 在 1.x 上也通用。

2. 环境准备与数据加载的实操细节

2.1 安装 PyTorch 的正确姿势

安装本身不复杂,但有几个坑得提前说。

首先确认你的 CUDA 版本(如果有 NVIDIA 显卡)。命令行输入nvidia-smi,右上角会显示 CUDA Version。然后去 PyTorch 官网的安装页面,选择对应版本。比如 CUDA 11.8 就选cu118,CUDA 12.1 就选cu121。没有显卡就选 CPU 版本。

# CPU 版本(最稳妥,适合学习和调试) pip install torch torchvision torchaudio # CUDA 11.8 版本 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118

注意:不要直接pip install torch就完事,那样装的可能是不带 CUDA 支持的版本。装完后用torch.cuda.is_available()验证一下,返回True才算 GPU 可用。

我个人的习惯是永远先装 CPU 版本把代码逻辑跑通,确认没问题后再换 GPU 版本加速。这样能避免很多“代码还没写对就折腾环境”的时间浪费。

2.2 用 Dataset 和 DataLoader 构建数据管道

PyTorch 的数据加载核心是两个类:Dataset和DataLoader。Dataset负责“一条数据怎么读”,DataLoader负责“一批数据怎么组装”。

先看自定义 Dataset 的写法:

import torch from torch.utils.data import Dataset, DataLoader from PIL import Image import os class DefectDataset(Dataset): """自定义缺陷分类数据集""" def __init__(self, root_dir, transform=None): """ root_dir: 数据根目录,结构为 root_dir/类别名/图片文件 transform: 数据增强和预处理操作 """ self.root_dir = root_dir self.transform = transform self.samples = [] # 存储 (图片路径, 标签) 元组 self.class_to_idx = {} # 类别名到数字标签的映射 # 遍历目录,自动构建类别映射 classes = sorted(os.listdir(root_dir)) for idx, cls_name in enumerate(classes): self.class_to_idx[cls_name] = idx cls_dir = os.path.join(root_dir, cls_name) for img_name in os.listdir(cls_dir): img_path = os.path.join(cls_dir, img_name) self.samples.append((img_path, idx)) def __len__(self): """返回数据集总样本数,DataLoader 靠这个确定 epoch 长度""" return len(self.samples) def __getitem__(self, idx): """返回单条数据:图像张量和标签""" img_path, label = self.samples[idx] image = Image.open(img_path).convert('RGB') # 统一转 RGB,防止灰度图报错 if self.transform: image = self.transform(image) return image, label

这里有几个细节值得展开。

__len__和__getitem__是必须实现的。前者告诉 DataLoader 数据集有多大,后者定义按索引取数据的行为。PyTorch 的采样器会基于__len__生成索引序列,然后逐个调用__getitem__。

convert('RGB')这行别省。实际项目中经常混入灰度图或 RGBA 图,不统一通道数的话,后面 batch 组装时会报错。我踩过这个坑,排查了半小时才发现是几张 PNG 带透明通道。

类别映射用sorted保证顺序稳定。如果不排序,os.listdir返回的顺序在不同系统上可能不一致,导致标签错乱。这个坑很隐蔽,模型训练时 loss 正常下降但准确率死活上不去,最后发现是标签和类别对不上。

接下来定义预处理和数据增强:

from torchvision import transforms # 训练集增强:随机裁剪、翻转、颜色抖动 train_transform = transforms.Compose([ transforms.Resize((256, 256)), # 统一尺寸 transforms.RandomCrop(224), # 随机裁剪到 224x224 transforms.RandomHorizontalFlip(p=0.5), # 50% 概率水平翻转 transforms.ColorJitter(brightness=0.2, contrast=0.2), # 颜色扰动 transforms.ToTensor(), # 转张量,像素值归一化到 [0,1] transforms.Normalize(mean=[0.485, 0.456, 0.406], # ImageNet 均值 std=[0.229, 0.224, 0.225]) # ImageNet 标准差 ]) # 验证集只做必要预处理,不做随机增强 val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

提示:Normalize的均值和标准差用的是 ImageNet 的统计值。如果你做迁移学习,保持和预训练模型一致;如果从零训练,可以换成自己数据集的统计值,但用 ImageNet 的值通常也没大问题。

ToTensor()会自动把 PIL 图像的像素值从[0, 255]缩放到[0.0, 1.0],同时把通道维度从最后移到最前(HWC 转 CHW)。这是 PyTorch 的标准输入格式,别搞反了。

然后用 DataLoader 包装:

# 假设数据已按 train/val 分好目录 train_dataset = DefectDataset('data/train', transform=train_transform) val_dataset = DefectDataset('data/val', transform=val_transform) train_loader = DataLoader( train_dataset, batch_size=32, # 批大小,根据显存调整 shuffle=True, # 训练集打乱顺序 num_workers=4, # 并行加载的进程数 pin_memory=True # 锁页内存,加速 GPU 传输 ) val_loader = DataLoader( val_dataset, batch_size=32, shuffle=False, # 验证集不需要打乱 num_workers=4, pin_memory=True )

num_workers设多少合适?一般设为 CPU 核心数或略少。设太大反而会因为进程切换开销拖慢速度。Windows 上有时设大于 0 会报错,那就设 0 用主进程加载,慢一点但稳定。

pin_memory=True只在用 GPU 时有意义。它把数据锁在页锁定内存中,从 CPU 传到 GPU 时更快。CPU 训练时设了也没坏处,但没收益。

3. 网络结构设计与前向传播实现

3.1 用 nn.Module 组织网络层

PyTorch 定义网络的标准方式是继承nn.Module,在__init__里声明层,在forward里定义数据流向。

import torch.nn as nn import torch.nn.functional as F class DefectNet(nn.Module): """一个用于缺陷分类的卷积神经网络""" def __init__(self, num_classes=4): super().__init__() # 必须调用父类初始化 # 卷积特征提取部分 self.conv_block1 = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), # 输入3通道,输出32通道 nn.BatchNorm2d(32), # 批归一化,加速收敛 nn.ReLU(inplace=True), # 激活函数 nn.MaxPool2d(2) # 2x2 最大池化,尺寸减半 ) self.conv_block2 = nn.Sequential( nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2) ) self.conv_block3 = nn.Sequential( nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2) ) # 全局平均池化,把任意空间尺寸压成 1x1 self.global_pool = nn.AdaptiveAvgPool2d(1) # 分类头 self.classifier = nn.Sequential( nn.Dropout(0.5), # 随机丢弃,防止过拟合 nn.Linear(128, num_classes) # 全连接层,输出类别数 ) def forward(self, x): """定义前向传播:输入张量 -> 输出 logits""" x = self.conv_block1(x) # [B, 3, 224, 224] -> [B, 32, 112, 112] x = self.conv_block2(x) # -> [B, 64, 56, 56] x = self.conv_block3(x) # -> [B, 128, 28, 28] x = self.global_pool(x) # -> [B, 128, 1, 1] x = x.flatten(1) # -> [B, 128] x = self.classifier(x) # -> [B, num_classes] return x

这段代码有几个设计决策值得解释。

为什么用nn.Sequential?把“卷积-归一化-激活-池化”这个固定组合打包,代码更简洁,也方便复用。当然你也可以一层层写,效果一样。

BatchNorm 放在卷积之后、激活之前。这是标准做法。BatchNorm 对卷积输出做归一化,让激活函数工作在敏感区间。注意BatchNorm2d的参数是通道数,别写成 batch size。

inplace=True是什么意思?ReLU 的原地操作,直接在输入张量上修改,省一点内存。大多数情况没问题,但如果你的网络有分支结构,同一个张量被多处引用,原地操作可能导致意外。不确定就设False。

AdaptiveAvgPool2d(1)的好处。它把任意 H×W 的特征图压成 1×1,这样网络就能接受任意尺寸的输入,不用固定死 224×224。对部署时处理不同分辨率图片很友好。

Dropout 放在全连接之前。0.5 的丢弃率是经验值,小数据集上可以调高到 0.6 甚至 0.7,大数据集上可以降到 0.2~0.3。

3.2 参数初始化与权重加载

PyTorch 默认用 Kaiming 初始化卷积层、均匀初始化全连接层,大多数情况够用。但如果你想手动控制:

def init_weights(m): """自定义初始化策略""" if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') if m.bias is not None: nn.init.zeros_(m.bias) elif isinstance(m, nn.BatchNorm2d): nn.init.ones_(m.weight) nn.init.zeros_(m.bias) elif isinstance(m, nn.Linear): nn.init.normal_(m.weight, 0, 0.01) nn.init.zeros_(m.bias) model = DefectNet(num_classes=4) model.apply(init_weights) # 递归应用到所有子模块

model.apply会遍历所有子模块并调用传入的函数,非常方便。Kaiming 初始化适合 ReLU 激活,如果是 LeakyReLU 或 ELU,nonlinearity参数要相应调整。

如果要加载预训练权重做迁移学习:

from torchvision import models # 加载预训练 ResNet18 model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) # 替换最后的全连接层,适配自己的类别数 num_features = model.fc.in_features model.fc = nn.Linear(num_features, 4) # 只训练最后的分类头,冻结前面的特征提取层 for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True

注意:weights参数在新版 PyTorch 中替代了旧的pretrained=True。如果版本较老,用pretrained=True也行,但会有弃用警告。

冻结层这个操作在小数据集上特别有用。我那个质检项目只有三千多张图,从零训练很容易过拟合,用预训练模型冻结特征层后,验证集准确率直接从 72% 跳到 89%。

4. 训练循环与验证流程的完整实现

4.1 损失函数与优化器的选择逻辑

分类任务最常用的是交叉熵损失:

criterion = nn.CrossEntropyLoss()

注意CrossEntropyLoss内部已经包含了 Softmax 操作,所以网络的输出应该是原始 logits,不要在 forward 里再加 Softmax。这个坑很常见,加了 Softmax 再算交叉熵会导致梯度异常,loss 下降极慢。

优化器选 Adam 还是 SGD?我的经验是:

  • Adam:默认学习率 1e-3,收敛快,适合快速验证想法。缺点是最终精度可能略低于调好的 SGD。
  • SGD + Momentum:默认学习率 1e-2,需要调学习率策略,但泛化性能通常更好。
# Adam 方案 optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) # SGD 方案 optimizer = torch.optim.SGD(model.parameters(), lr=1e-2, momentum=0.9, weight_decay=1e-4)

weight_decay是 L2 正则化系数,1e-4 是个安全的起点。如果发现训练 loss 远低于验证 loss,可以适当加大。

学习率调度器也值得配上:

# 每 10 个 epoch 学习率乘以 0.1 scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1) # 或者用余弦退火,更平滑 scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)

4.2 训练一个 epoch 的标准写法

def train_one_epoch(model, loader, criterion, optimizer, device): """训练一个 epoch,返回平均损失和准确率""" model.train() # 切换到训练模式,启用 Dropout 和 BatchNorm 更新 running_loss = 0.0 correct = 0 total = 0 for images, labels in loader: images = images.to(device) # 移到 GPU 或保持 CPU labels = labels.to(device) optimizer.zero_grad() # 清空上一轮梯度 outputs = model(images) # 前向传播 loss = criterion(outputs, labels) # 计算损失 loss.backward() # 反向传播,计算梯度 optimizer.step() # 更新参数 # 统计指标 running_loss += loss.item() * images.size(0) _, predicted = outputs.max(1) # 取最大 logit 对应的类别 correct += predicted.eq(labels).sum().item() total += labels.size(0) avg_loss = running_loss / total accuracy = correct / total return avg_loss, accuracy

model.train()和model.eval()千万别忘。训练时 BatchNorm 用当前 batch 的统计量并更新滑动平均,Dropout 随机丢弃神经元;验证时 BatchNorm 用滑动平均的统计量,Dropout 不生效。忘了切换会导致验证结果波动大、不准确。

optimizer.zero_grad()的位置。放在前向传播之前或之后都行,但必须在loss.backward()之前。我习惯放在最前面,逻辑清晰。

loss.item()和loss的区别。loss是带计算图的张量,loss.item()取出 Python 浮点数。累加统计量时用.item(),避免计算图不断累积导致内存爆炸。

4.3 验证集评估与指标监控

@torch.no_grad() # 验证不需要计算梯度,省显存 def evaluate(model, loader, criterion, device): """在验证集上评估模型""" model.eval() # 切换到评估模式 running_loss = 0.0 correct = 0 total = 0 for images, labels in loader: images = images.to(device) labels = labels.to(device) outputs = model(images) loss = criterion(outputs, labels) running_loss += loss.item() * images.size(0) _, predicted = outputs.max(1) correct += predicted.eq(labels).sum().item() total += labels.size(0) avg_loss = running_loss / total accuracy = correct / total return avg_loss, accuracy

@torch.no_grad()装饰器关闭梯度计算,能显著减少显存占用、加快速度。验证阶段不需要反向传播,所以这是必须的优化。

4.4 完整训练流程与早停策略

def train_model(model, train_loader, val_loader, criterion, optimizer, scheduler, device, num_epochs=50, patience=7): """完整训练流程,带早停""" best_val_acc = 0.0 epochs_no_improve = 0 for epoch in range(num_epochs): train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc = evaluate(model, val_loader, criterion, device) scheduler.step() # 更新学习率 print(f"Epoch {epoch+1:02d} | " f"Train Loss: {train_loss:.4f} Acc: {train_acc:.4f} | " f"Val Loss: {val_loss:.4f} Acc: {val_acc:.4f} | " f"LR: {optimizer.param_groups[0]['lr']:.6f}") # 保存最佳模型 if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), 'best_model.pth') epochs_no_improve = 0 print(f" -> 保存最佳模型,验证准确率: {val_acc:.4f}") else: epochs_no_improve += 1 if epochs_no_improve >= patience: print(f"早停触发,验证准确率 {patience} 个 epoch 未提升") break return best_val_acc

早停的patience设多少?小数据集 5~7 比较合适,大数据集可以放宽到 10~15。设太小可能错过后续的提升,设太大浪费时间。我一般先设 10 跑一轮,观察 loss 曲线后再调整。

5. 常见问题排查与实战避坑指南

5.1 训练不收敛的排查思路

这是新手最常遇到的问题。按以下顺序排查:

第一步:检查数据。把几张图可视化出来看看,标签对不对,图像有没有损坏。我遇到过一批图全是纯黑的情况,模型当然学不到东西。另外确认Normalize的均值和标准差是否匹配数据分布。

第二步:检查损失函数。分类任务用CrossEntropyLoss,标签必须是LongTensor类型,取值范围[0, num_classes-1]。如果标签从 1 开始编号,需要减 1。

第三步:检查学习率。学习率太大会导致 loss 震荡甚至发散,太小则收敛极慢。可以做个学习率扫描:从 1e-5 到 1e-1 各跑几个 batch,看哪个 loss 下降最快。

第四步:检查网络输出。在训练前用随机输入跑一次前向传播,打印输出形状和数值范围。输出全是 NaN 说明初始化有问题或存在除零操作。

5.2 过拟合与欠拟合的判断与应对

现象训练 loss验证 loss原因对策
过拟合持续下降先降后升模型太复杂/数据太少加 Dropout、加正则、数据增强、早停
欠拟合下降很慢下降很慢模型太简单/学习率太小加层、加宽、调大学习率、检查数据
正常下降同步下降健康继续训练

过拟合的经典信号是训练准确率 99% 但验证准确率只有 70%。我一般先加数据增强,再考虑加 Dropout,最后才动网络结构。数据增强的收益通常比调结构大。

5.3 显存不足的优化手段

GPU 显存不够时,按以下优先级处理:

  1. 减小 batch size:最直接,从 32 降到 16 甚至 8
  2. 用混合精度训练:torch.cuda.amp能省约一半显存
  3. 梯度累积:小 batch 多次前向后累积梯度再更新,等效大 batch
  4. 及时释放中间变量:用del删除不用的张量,torch.cuda.empty_cache()清缓存

混合精度训练的写法:

scaler = torch.cuda.amp.GradScaler() for images, labels in loader: with torch.cuda.amp.autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() optimizer.zero_grad()

5.4 模型保存与加载的两种方式

方式一:只保存参数(推荐)

# 保存 torch.save(model.state_dict(), 'model_weights.pth') # 加载 model = DefectNet(num_classes=4) # 先实例化相同结构 model.load_state_dict(torch.load('model_weights.pth')) model.eval()

这种方式文件小、灵活,但加载时需要知道网络结构。

方式二:保存整个模型

# 保存 torch.save(model, 'full_model.pth') # 加载 model = torch.load('full_model.pth')

文件大,且依赖具体的类定义路径,换项目目录可能加载失败。除非有特殊需求,否则用方式一。

注意:加载 GPU 上训练的模型到 CPU 环境时,用torch.load('model.pth', map_location='cpu'),否则会报 CUDA 相关错误。

5.5 常见报错速查表

报错信息原因解决方法
RuntimeError: expected scalar type Long but found Float标签类型不对labels.long()或检查 Dataset 返回类型
RuntimeError: size mismatch全连接层输入维度不对打印 flatten 后的形状,调整nn.Linear输入
CUDA out of memory显存不足减小 batch、用混合精度、清缓存
Expected 4D input卷积层输入维度不对确认输入是[B, C, H, W]格式
loss is nan学习率太大或数据有 NaN降低学习率、检查数据、加梯度裁剪

梯度裁剪的写法:

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

放在loss.backward()之后、optimizer.step()之前。对 RNN 和 Transformer 特别有用,能防止梯度爆炸。

6. 从训练到推理的完整落地

6.1 单张图片推理的完整代码

训练完模型最终是要用的。下面是一个完整的推理函数:

from PIL import Image def predict_single(model, image_path, transform, class_names, device): """对单张图片进行预测""" model.eval() # 加载并预处理图片 image = Image.open(image_path).convert('RGB') input_tensor = transform(image).unsqueeze(0) # 增加 batch 维度 input_tensor = input_tensor.to(device) with torch.no_grad(): outputs = model(input_tensor) probabilities = F.softmax(outputs, dim=1) # 转成概率 confidence, predicted = probabilities.max(1) pred_class = class_names[predicted.item()] conf = confidence.item() return pred_class, conf # 使用示例 class_names = ['凹坑', '划痕', '正常', '污渍'] pred_class, conf = predict_single(model, 'test.jpg', val_transform, class_names, device) print(f"预测类别: {pred_class}, 置信度: {conf:.4f}")

unsqueeze(0)这步很关键。模型训练时输入是[B, C, H, W],单张图片预处理后是[C, H, W],必须补上 batch 维度才能喂给模型。

6.2 批量推理与结果导出

实际生产中往往是批量处理:

import pandas as pd def batch_predict(model, image_dir, transform, class_names, device): """批量预测目录下所有图片""" model.eval() results = [] for img_name in os.listdir(image_dir): if not img_name.lower().endswith(('.jpg', '.png', '.jpeg')): continue img_path = os.path.join(image_dir, img_name) pred_class, conf = predict_single(model, img_path, transform, class_names, device) results.append({ '文件名': img_name, '预测类别': pred_class, '置信度': round(conf, 4) }) df = pd.DataFrame(results) df.to_csv('predictions.csv', index=False, encoding='utf-8-sig') return df

encoding='utf-8-sig'是为了 Excel 打开 CSV 时中文不乱码,这个小细节在实际交付时很加分。

6.3 模型性能优化的几个实用技巧

技巧一:用torch.jit.trace导出 TorchScript。脱离 Python 环境运行,速度也有提升。

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

技巧二:推理时用torch.inference_mode()替代no_grad()。前者更快,因为它连版本计数都不跟踪。

技巧三:合并 BatchNorm 和卷积层。推理时 BatchNorm 是线性变换,可以和前面的卷积融合成一个卷积,减少计算量。torch.quantization.fuse_modules可以自动完成。

技巧四:INT8 量化。对精度要求不极致的场景,量化能把模型体积压到四分之一,速度提升两三倍。

quantized_model = torch.quantization.quantize_dynamic( model, {nn.Linear}, dtype=torch.qint8 )

6.4 我踩过的几个真实坑

坑一:验证集准确率比训练集高。一开始以为代码写错了,后来发现是验证集太小且太简单。解决办法是增大验证集或做交叉验证。别看到这种反常现象就慌,先检查数据划分。

坑二:DataLoader 的shuffle忘了设。训练集不打乱,模型学到的顺序信息会干扰泛化。这个错误很隐蔽,因为 loss 照样下降,只是最终精度差几个点。

坑三:保存模型时保存了优化器状态。如果只是想推理,不需要保存优化器,文件会大两三倍。只有需要恢复训练时才保存optimizer.state_dict()。

坑四:在__getitem__里做了太重的操作。比如实时做复杂的图像变换,导致数据加载成为瓶颈。解决办法是预处理阶段把增强后的图存到磁盘,或者用更轻量的增强。

坑五:忘了设随机种子。每次跑结果都不一样,无法复现。在代码开头加上:

import random import numpy as np def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False set_seed(42)

cudnn.deterministic=True会牺牲一点速度换取可复现性。如果追求极致速度,可以设benchmark=True,但结果会有微小波动。

这套流程我在多个项目里反复用过,从图像分类到简单的序列预测,改的主要是网络结构和数据加载部分,训练循环和评估逻辑基本可以复用。建议你把这篇文章里的代码整理成一个模板文件,下次新项目直接改 Dataset 和模型定义就行,能省下大量重复劳动。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进