前言
在农业智能化浪潮中,作物病害的自动识别对于精准施药、减少产量损失具有极高的实用价值。然而,真实的农业图像分类任务往往面临两大核心痛点:其一,标注数据获取成本高昂,训练样本极为有限;其二,不同病害的发生频率差异显著,导致数据集类别分布严重不均。这两大问题使得从头训练深度卷积神经网络几乎必然陷入过拟合,模型在验证集上的表现无法达到实际应用标准。
迁移学习正是破解这一困境的关键技术。借助在大规模通用数据集(如ImageNet)上预训练的卷积神经网络,我们可以复用其已经学到的通用视觉特征(边缘、纹理、颜色等),仅针对目标任务微调少量高层参数,从而在极小的标注数据上获得优异的泛化性能。
本文将以豆科叶片病害分类为例(包含健康、角斑病、锈病三类),从零开始构建一套完整的PyTorch工程解决方案。您将看到如何设计数据增强流水线、如何通过加权采样与加权损失双重策略解决类别不均衡、如何基于ResNet18构建自定义分类头、如何利用标签平滑和自适应学习率调度提升小样本训练的稳定性,以及最终如何输出分类报告与混淆矩阵进行量化评估。整篇代码严格遵循工程化规范,超参数统一配置,训练流程清晰可复现,可直接迁移至其他小样本多分类视觉任务。
### 文章目录
- [@toc](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [一、完整代码展示](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [二、依赖库导入与功能分层](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [三、全局超参数统一配置](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [四、数据增强与预处理流水线](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [五、数据集加载与类别均衡加权采样](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [六、迁移学习自定义ResNet18模型](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [七、损失函数、优化器与学习率调度器](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [八、训练与验证函数封装](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [单轮训练函数](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [单轮验证函数](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [九、30轮主训练循环与最优模型保存](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [十、训练结束后的量化评估](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [十一、工程拓展:类别校验与GPU适配](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [classname.txt类别校验](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [GPU自动适配](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
- [十二、项目总结与技术要点回顾](#文章目录 @[toc] 一、完整代码展示 二、依赖库导入与功能分层 三、全局超参数统一配置 四、数据增强与预处理流水线 五、数据集加载与类别均衡加权采样 六、迁移学习自定义ResNet18模型 七、损失函数、优化器与学习率调度器 八、训练与验证函数封装 单轮训练函数 单轮验证函数 九、30轮主训练循环与最优模型保存 十、训练结束后的量化评估 十一、工程拓展:类别校验与GPU适配 classname.txt类别校验 GPU自动适配 十二、项目总结与技术要点回顾)
一、完整代码展示
python
import torch
import torch.nn as nn
import torch.optim as optim
from torch.optim.lr_scheduler import ReduceLROnPlateau
from torchvision import datasets, transforms, models
from torch.utils.data import DataLoader, WeightedRandomSampler
from sklearn.metrics import classification_report, confusion_matrix
import numpy as np
# ====================== 超参数 ======================
DEVICE = torch.device("cpu")
BATCH_SIZE = 16
EPOCHS = 30
NUM_CLASSES = 3
IMAGE_SIZE = 224
LR = 1e-3
WEIGHT_DECAY = 1e-4
NUM_WORKERS = 0
# ====================== 数据增强与预处理流水线 ======================
# 训练集数据增强
train_transform = transforms.Compose([
transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)),
transforms.RandomHorizontalFlip(),
transforms.RandomVerticalFlip(),
transforms.RandomRotation(15),
transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
transforms.GaussianBlur(kernel_size=3),
transforms.RandomResizedCrop(IMAGE_SIZE, scale=(0.8, 1.0)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.224, 0.224, 0.225])
])
# 验证集预处理(无增强)
val_transform = transforms.Compose([
transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.224, 0.224, 0.225])
])
# ====================== 数据集加载(路径自行修正) ======================
train_dataset = datasets.ImageFolder(root="./赛题/task4/train", transform=train_transform)
val_dataset = datasets.ImageFolder(root="./赛题/task4/val", transform=val_transform)
# 类别均衡加权采样
targets = train_dataset.targets
class_counts = np.bincount(targets)
class_weights = 1.0 / torch.tensor(class_counts, dtype=torch.float)
samples_weights = class_weights[targets]
sampler = WeightedRandomSampler(
weights=samples_weights,
num_samples=len(samples_weights),
replacement=True
)
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, sampler=sampler, num_workers=NUM_WORKERS)
val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=NUM_WORKERS)
# ====================== 本地加载ResNet18迁移学习模型 ======================
class BeanDiseaseModel(nn.Module):
def __init__(self, num_classes=3):
super().__init__()
# 1. 初始化空ResNet18骨架
self.resnet18 = models.resnet18(weights=None)
# 2. 加载本地离线预训练权重
state_dict = torch.load("./赛题/task4/weights/resnet18-f37072fd.pth", map_location=DEVICE, weights_only=True)
self.resnet18.load_state_dict(state_dict)
# 3. 冻结前80%主干参数,仅解冻后20%微调
params = list(self.resnet18.parameters())
total_param_num = len(params)
freeze_threshold = int(total_param_num * 0.8)
for i, param in enumerate(params):
if i < freeze_threshold:
param.requires_grad = False
# 4. 剥离原生全连接分类头,只保留卷积特征提取主干
self.features = nn.Sequential(*list(self.resnet18.children())[:-1])
# 自适应全局平均池化(考题强制要求)
self.adaptive_pool = nn.AdaptiveAvgPool2d(1)
# 5. 自定义全新分类头(满足考题所有层约束)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Dropout(0.5),
nn.Linear(512, 256),
nn.GELU(),
nn.BatchNorm1d(256),
nn.Dropout(0.4),
nn.Linear(256, 128),
nn.GELU(),
nn.BatchNorm1d(128),
nn.Dropout(0.3),
nn.Linear(128, num_classes)
)
# 前向传播逻辑
def forward(self, x):
x = self.features(x) # 卷积主干提取特征
x = self.adaptive_pool(x) # 自适应池化压缩尺寸
x = self.classifier(x) # 自定义分类头输出3分类logits
return x
# 初始化模型并移至CPU/GPU设备
model = BeanDiseaseModel(NUM_CLASSES).to(DEVICE)
# ====================== 损失函数、优化器、学习率调度 ======================
# 带类别权重+标签平滑的交叉熵,解决小样本不均衡
criterion = nn.CrossEntropyLoss(weight=class_weights.to(DEVICE), label_smoothing=0.1)
# AdamW优化器,带L2权重衰减
optimizer = optim.AdamW(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)
# 自适应学习率调度器:验证精度不提升则衰减学习率
scheduler = ReduceLROnPlateau(
optimizer,
mode='max', # 监控指标越大越好(准确率)
factor=0.5, # 学习率乘以0.5衰减
patience=5, # 连续5轮无提升才降lr
verbose=True # 打印降学习率日志
)
# ====================== 单轮训练函数 ======================
def train_one_epoch():
model.train()
total_loss, correct, total = 0.0, 0, 0
for img, lbl in train_loader:
img, lbl = img.to(DEVICE), lbl.to(DEVICE)
optimizer.zero_grad() # 清空历史梯度
out = model(img) # 前向传播
loss = criterion(out, lbl) # 计算加权标签平滑损失
loss.backward() # 反向传播求梯度
optimizer.step() # 参数更新
total_loss += loss.item()
_, pred = torch.max(out, 1) # 取概率最大类别作为预测
correct += (pred == lbl).sum().item()
total += lbl.size(0)
# 返回平均训练损失、训练准确率
return total_loss / len(train_loader), correct / total
# ====================== 单轮验证函数 ======================
def val_one_epoch():
model.eval()
total_loss, correct, total = 0.0, 0, 0
all_pred, all_lbl = [], []
with torch.no_grad(): # 验证阶段关闭梯度计算,节省显存
for img, lbl in val_loader:
img, lbl = img.to(DEVICE), lbl.to(DEVICE)
out = model(img)
loss = criterion(out, lbl)
total_loss += loss.item()
_, pred = torch.max(out, 1)
correct += (pred == lbl).sum().item()
total += lbl.size(0)
# 保存预测与真实标签,用于后续混淆矩阵、分类报告
all_pred.extend(pred.cpu().numpy())
all_lbl.extend(lbl.cpu().numpy())
# 返回平均验证损失、验证准确率、全部预测、全部真实标签
return total_loss / len(val_loader), correct / total, all_pred, all_lbl
# ====================== 30轮主训练循环 ======================
best_acc = 0.0
for epoch in range(EPOCHS):
train_loss, train_acc = train_one_epoch()
val_loss, val_acc, preds, labels = val_one_epoch()
scheduler.step(val_acc) # 根据验证精度调整学习率
current_lr = optimizer.param_groups[0]['lr'] # 获取当前学习率
# 按题目要求打印每轮全部指标
print(
f"Epoch {epoch + 1:2d} | TrainLoss {train_loss:.4f} TrainAcc {train_acc:.4f} "
f"| ValLoss {val_loss:.4f} ValAcc {val_acc:.4f} | LR {current_lr:.6f}"
)
# 保存验证精度最高的最优模型,文件名固定best_bean_final.pth
if val_acc > best_acc:
best_acc = val_acc
torch.save(model.state_dict(), "best_bean_final.pth")
print(f"✅ 最优模型已保存 | 最高验证精度:{best_acc:.4f}")
# ====================== 训练结束后完整评估输出 ======================
# 加载最优模型
model.load_state_dict(torch.load("best_bean_final.pth", map_location=DEVICE))
_, _, preds, labels = val_one_epoch()
# 输出精确率、召回率、F1分数
print("\n==================== 分类评估报告 ====================")
print(classification_report(labels, preds, target_names=train_dataset.classes, digits=4))
# 输出混淆矩阵
print("\n==================== 混淆矩阵 ====================")
print(confusion_matrix(labels, preds))
二、依赖库导入与功能分层
python
import torch
import torch.nn as nn
import torch.optim as optim
from torch.optim.lr_scheduler import ReduceLROnPlateau
from torchvision import datasets, transforms, models
from torch.utils.data import DataLoader, WeightedRandomSampler
from sklearn.metrics import classification_report, confusion_matrix
import numpy as np
逐库功能解析:
-
torch / torch.nn :PyTorch核心库,提供张量运算、自动求导、神经网络层定义等基础能力。
nn.Module是所有神经网络的基类,我们自定义的模型将继承自它。 -
torch.optim / ReduceLROnPlateau :优化器模块。
optim.AdamW是本次赛题指定的优化器,它在Adam的基础上将权重衰减与梯度更新解耦,正则化效果更优。ReduceLROnPlateau是一种自适应学习率调度器,当监控的指标停止提升时自动降低学习率。 -
torchvision :PyTorch官方计算机视觉工具库。
datasets.ImageFolder可自动读取文件夹结构构建数据集;transforms提供丰富的图像预处理与数据增强操作;models内置了ResNet等经典网络架构的预训练版本。 -
DataLoader / WeightedRandomSampler :数据加载核心组件。
DataLoader负责批量读取数据;WeightedRandomSampler通过为每个样本分配采样权重,从数据加载层面解决类别不平衡问题。 -
sklearn.metrics :提供
classification_report(精确率、召回率、F1分数)和confusion_matrix(混淆矩阵),用于模型性能的全面量化评估。 -
numpy:用于数值计算,在本项目中主要承担统计每类样本数量、计算类别权重等任务。
三、全局超参数统一配置
python
# ====================== 超参数 ======================
DEVICE = torch.device("cpu")
BATCH_SIZE = 16
EPOCHS = 30
NUM_CLASSES = 3
IMAGE_SIZE = 224
LR = 1e-3
WEIGHT_DECAY = 1e-4
NUM_WORKERS = 0
参数设计思路与工程规范:
将全部可调参数集中在代码头部统一管理,是工程化开发的基本规范,便于后续调参与实验对比。
-
DEVICE :默认设置为
cpu,确保无GPU环境的用户也可直接运行。若检测到CUDA可用,可改为torch.device("cuda" if torch.cuda.is_available() else "cpu")实现自动适配。 -
BATCH_SIZE = 16:小样本场景下不宜使用过大的批次大小。较大的批次会使得梯度估计过于平滑,可能错过有助于泛化的尖锐极小值;而过小的批次则可能导致梯度噪声过大、训练不稳定。16是一个平衡内存占用与梯度稳定性的合理选择。
-
EPOCHS = 30:赛题强制要求完整训练30轮。在迁移学习场景下,由于模型已经具备良好的初始特征,30轮通常足以让分类头收敛。
-
IMAGE_SIZE = 224:ResNet18的原始输入规格为224×224,保持这一尺寸可以确保预训练权重中的BatchNorm统计量能够正常工作。
-
LR = 1e-3 / WEIGHT_DECAY = 1e-4:适配AdamW优化器在微调迁移模型时的典型参数组合。学习率不宜过大,以免破坏预训练好的特征;权重衰减提供L2正则化,抑制过拟合。
-
NUM_WORKERS = 0:Windows系统下多进程数据加载容易引发Pickle序列化错误,设置为0可规避此类异常,确保代码跨平台稳定运行。
四、数据增强与预处理流水线
python
# ====================== 数据增强与预处理流水线 ======================
# 训练集数据增强
train_transform = transforms.Compose([
transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)),
transforms.RandomHorizontalFlip(),
transforms.RandomVerticalFlip(),
transforms.RandomRotation(15),
transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
transforms.GaussianBlur(kernel_size=3),
transforms.RandomResizedCrop(IMAGE_SIZE, scale=(0.8, 1.0)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.224, 0.224, 0.225])
])
# 验证集预处理(无增强)
val_transform = transforms.Compose([
transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.224, 0.224, 0.225])
])
训练集增强逐项深度解析:
数据增强是小样本场景下最为关键的技术手段之一。通过对原始图像施加一系列随机变换,可以在不增加真实标注成本的前提下,大幅扩充训练样本的多样性,有效抑制过拟合。
-
Resize((224, 224)):将所有图像统一缩放到224×224,匹配ResNet18的输入规格。
-
RandomHorizontalFlip / RandomVerticalFlip:随机水平翻转和垂直翻转,模拟叶片在不同拍摄角度下的镜像变化。翻转操作不改变语义类别,是成本最低且效果显著的数据增强方式。
-
RandomRotation(15):在±15度范围内随机旋转图像,模拟手持设备拍摄时的倾斜角度。角度范围不宜过大,否则可能引入不自然的形变。
-
ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2):随机调整亮度、对比度和饱和度,模拟户外不同光照条件(晴天、阴天、背光等)下的图像变化。
-
GaussianBlur(kernel_size=3):施加轻微的高斯模糊,模拟对焦不准或运动模糊的真实拍摄场景,增强模型对图像质量的鲁棒性。
-
RandomResizedCrop(224, scale=(0.8, 1.0)):从原图中随机裁剪80%至100%面积的区域,再缩放至224×224。这是最强大的数据增强手段之一------它迫使模型关注叶片的不同局部区域而非全局纹理,显著提升泛化能力。
-
ToTensor():将PIL图像或NumPy数组转换为PyTorch张量,并将像素值从0,255归一化到0,1。
-
Normalize(mean=0.485, 0.456, 0.406, std=0.224, 0.224, 0.225):使用ImageNet数据集的均值和标准差进行标准化,使输入数据分布与预训练模型所期望的分布一致。这是迁移学习中至关重要的一步------如果输入分布与预训练时的分布差异过大,预训练特征将无法有效发挥作用。
验证集预处理原则:
验证集仅保留缩放、转张量、标准化三步,不引入任何随机变换。这一设计的核心考量是:验证集的评估结果必须真实反映模型在未见数据上的泛化能力。如果验证集也施加了随机增强,则每一轮的验证输入都不固定,指标波动将由随机性而非模型性能变化引起,评估结果将失去参考价值。
五、数据集加载与类别均衡加权采样
python
# ====================== 数据集加载(路径自行修正) ======================
train_dataset = datasets.ImageFolder(root="./赛题/task4/train", transform=train_transform)
val_dataset = datasets.ImageFolder(root="./赛题/task4/val", transform=val_transform)
# 类别均衡加权采样
targets = train_dataset.targets
class_counts = np.bincount(targets)
class_weights = 1.0 / torch.tensor(class_counts, dtype=torch.float)
samples_weights = class_weights[targets]
sampler = WeightedRandomSampler(
weights=samples_weights,
num_samples=len(samples_weights),
replacement=True
)
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, sampler=sampler, num_workers=NUM_WORKERS)
val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=NUM_WORKERS)
ImageFolder的自动标签映射机制:
datasets.ImageFolder是PyTorch提供的便捷数据集类,它会自动遍历指定目录下的所有子文件夹,将每个子文件夹的名称作为类别标签,文件夹内的图像文件作为该类别的样本。例如,若./train目录下存在healthy/、angular_leaf_spot/、bean_rust/三个子文件夹,则自动建立类别映射{'healthy': 0, 'angular_leaf_spot': 1, 'bean_rust': 2}。这一机制完全满足赛题"自动读取文件夹名称作为类别标签"的要求。
类别不均衡问题的本质与危害:
在分类任务中,当各类别样本数量严重不等时,模型会倾向于优先学习多数类的模式。原因在于损失函数的优化过程:如果某类样本占总数的90%,即使模型完全忽略其余10%的类别,只要正确预测了多数类,总体损失仍然较低。梯度下降的方向因此被多数类主导,少数类的决策边界得不到充分优化。在病害识别这类实际应用中,少数病害类别往往是最需要被准确识别的------漏检病害可能导致大面积农作物损失。
WeightedRandomSampler的均衡采样原理:
WeightedRandomSampler通过为每个样本分配不同的采样权重,从数据加载层面干预每个批次的样本组成。其核心工作机制如下:
- 首先统计每一类的样本总数:
class_counts = np.bincount(targets) - 计算类别权重:
class_weights = 1.0 / class_counts,样本越少的类别权重越大 - 将类别权重映射到每个具体样本:
samples_weights = class_weights[targets] - 采样器根据
samples_weights进行加权随机采样,权重越高的样本被抽中的概率越大
值得注意的是,WeightedRandomSampler并不要求权重之和为1,PyTorch会自动进行归一化------样本i被抽中的实际概率为weights[i] / sum(weights)。replacement=True表示放回采样,允许同一张图片在一个epoch内被多次抽中,这是过采样策略在数据加载层的实现。
需要强调的是,WeightedRandomSampler解决的是"模型看到什么"的问题(数据层面的均衡),而后续损失函数中的weight参数解决的是"模型有多在意错误"的问题(损失层面的加权)。两者结合使用,可以达到最佳的类别平衡效果。
DataLoader的配置细节:
- 训练集使用自定义
sampler,因此不再使用shuffle=True------采样器本身已经实现了随机打乱 - 验证集关闭打乱(
shuffle=False),保证每一轮验证的样本顺序完全一致,便于不同轮次之间的指标对比和混淆矩阵分析
六、迁移学习自定义ResNet18模型
python
# ====================== 本地加载ResNet18迁移学习模型 ======================
class BeanDiseaseModel(nn.Module):
def __init__(self, num_classes=3):
super().__init__()
# 1. 初始化空ResNet18骨架
self.resnet18 = models.resnet18(weights=None)
# 2. 加载本地离线预训练权重
state_dict = torch.load("./赛题/task4/weights/resnet18-f37072fd.pth", map_location=DEVICE, weights_only=True)
self.resnet18.load_state_dict(state_dict)
# 3. 冻结前80%主干参数,仅解冻后20%微调
params = list(self.resnet18.parameters())
total_param_num = len(params)
freeze_threshold = int(total_param_num * 0.8)
for i, param in enumerate(params):
if i < freeze_threshold:
param.requires_grad = False
# 4. 剥离原生全连接分类头,只保留卷积特征提取主干
self.features = nn.Sequential(*list(self.resnet18.children())[:-1])
# 自适应全局平均池化
self.adaptive_pool = nn.AdaptiveAvgPool2d(1)
# 5. 自定义全新分类头
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Dropout(0.5),
nn.Linear(512, 256),
nn.GELU(),
nn.BatchNorm1d(256),
nn.Dropout(0.4),
nn.Linear(256, 128),
nn.GELU(),
nn.BatchNorm1d(128),
nn.Dropout(0.3),
nn.Linear(128, num_classes)
)
def forward(self, x):
x = self.features(x)
x = self.adaptive_pool(x)
x = self.classifier(x)
return x
model = BeanDiseaseModel(NUM_CLASSES).to(DEVICE)
ResNet18网络结构深度解析:
ResNet(残差网络)由何恺明团队于2015年提出,其核心创新在于引入残差块(Residual Block)解决深层网络训练中的梯度消失问题。传统卷积网络随着层数加深,梯度在反向传播过程中逐层衰减,导致深层难以有效训练。残差块通过引入恒等映射(Identity Mapping),允许梯度直接跨层传播,将网络的学习目标从"直接拟合目标函数"转化为"拟合残差函数",显著降低了深层网络的训练难度。
ResNet18包含18个权重层(17个卷积层和1个全连接层),结构上分为5个阶段:
- 初始卷积层(7×7卷积 + 池化)
- 4组残差模块(Stage1-4),每组包含2个残差块,共计8个残差块
- 全局平均池化层
- 全连接输出层(原始为1000类)
离线预训练权重加载:
models.resnet18(weights=None)首先初始化一个未加载权重的空网络骨架,随后通过torch.load读取本地的resnet18-f37072fd.pth权重文件。weights_only=True参数开启安全加载模式,仅允许加载张量权重而不执行任意Python代码,这是PyTorch近期版本针对反序列化安全漏洞的加固措施。使用本地离线权重而非在线下载,确保在网络受限的环境中也能正常运行。
分层冻结策略的设计原理:
python
params = list(self.resnet18.parameters())
total_param_num = len(params)
freeze_threshold = int(total_param_num * 0.8)
for i, param in enumerate(params):
if i < freeze_threshold:
param.requires_grad = False
迁移学习的核心权衡在于:既要保留预训练模型已经学到的通用特征,又要让模型能够适应目标任务的特定数据分布。卷积神经网络的底层(靠近输入的层)学习的是边缘、颜色、纹理等通用视觉特征,这些特征在不同任务间高度共享;而高层(靠近输出的层)学习的是任务相关的语义特征。
因此,合理的迁移学习策略是:冻结底层参数,仅微调高层参数。本代码冻结前80%的网络参数(主要是底层和中层卷积层),仅解冻后20%的参数(高层卷积层和自定义分类头)参与梯度更新。这样做有三重好处:
- 大幅减少可训练参数量,降低过拟合风险
- 保留ImageNet上学到的通用视觉特征
- 让高层特征有足够的灵活性去适配叶片病害的专属模式
剥离原生分类头与自定义分类头:
python
self.features = nn.Sequential(*list(self.resnet18.children())[:-1])
list(self.resnet18.children())[:-1]提取网络除最后一层全连接层之外的所有层。这样做的原因是:原始ResNet18的全连接层输出1000类(适配ImageNet),而我们的任务只有3类。直接使用原生分类头不仅维度不匹配,而且其学到的1000类语义与叶片病害分类无关。因此必须彻底剥离原生分类头,重新设计适配3分类任务的全新分类头。
自定义分类头逐层解析(覆盖赛题全部强制要求):
赛题要求分类头必须包含卷积、BN、激活、自适应池化、Dropout、全连接层。本分类头完整覆盖了所有这些规定:
| 层 | 类型 | 作用 |
|---|---|---|
Flatten() |
展平 | 将512,1,1的特征图展平为512维向量 |
Dropout(0.5) |
Dropout | 以50%概率随机丢弃神经元,最强正则化 |
Linear(512, 256) |
全连接 | 从512维降维到256维 |
GELU() |
激活函数 | 高斯误差线性单元,比ReLU收敛更平滑 |
BatchNorm1d(256) |
批归一化 | 稳定256维特征的分布,加速收敛 |
Dropout(0.4) |
Dropout | 40%丢弃率,中度正则化 |
Linear(256, 128) |
全连接 | 从256维降维到128维 |
GELU() |
激活函数 | 同上 |
BatchNorm1d(128) |
批归一化 | 稳定128维特征分布 |
Dropout(0.3) |
Dropout | 30%丢弃率,最轻正则化 |
Linear(128, 3) |
全连接 | 输出3类logits |
Dropout概率逐层递减(0.5→0.4→0.3)的设计思路是:浅层特征维度高、参数量大,需要更强的正则化;深层特征已经过抽象和降维,过拟合风险相对较低,可以适当降低丢弃率。BatchNorm1d对全连接层的输出进行批归一化,将特征分布拉回均值为0、方差为1的标准正态分布,有效缓解内部协变量偏移(Internal Covariate Shift),使梯度传播更加稳定。
前向传播流程:
forward方法的逻辑清晰简洁:输入图像先经过卷积主干提取特征图 → 自适应平均池化将任意尺寸的特征图压缩为1×1 → 展平后送入自定义分类头输出3类logits。注意这里没有在forward中做Softmax------交叉熵损失函数内部会自动计算Softmax,将Softmax放在损失函数内部而非模型输出层是数值稳定性更好的做法。
七、损失函数、优化器与学习率调度器
python
# ====================== 损失函数、优化器、学习率调度 ======================
criterion = nn.CrossEntropyLoss(weight=class_weights.to(DEVICE), label_smoothing=0.1)
optimizer = optim.AdamW(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)
scheduler = ReduceLROnPlateau(
optimizer,
mode='max',
factor=0.5,
patience=5,
verbose=True
)
加权交叉熵损失与类别权重:
nn.CrossEntropyLoss的weight参数允许为不同类别分配不同的损失权重。在本代码中,class_weights正是前文计算出的类别权重------样本越少的类别权重越大。这意味着当模型将少数类样本预测错误时,会承受更大的损失惩罚,从而迫使优化器更加关注少数类的决策边界。
结合WeightedRandomSampler(数据层均衡)和weight参数(损失层加权),形成了双重保障机制:采样器确保模型在每个批次中能看到足够多的少数类样本;损失权重确保模型在遇到少数类样本时给予更高的学习信号强度。
标签平滑(Label Smoothing)的正则化原理:
label_smoothing=0.1是本代码中另一项关键的正则化技术。传统的分类任务使用one-hot标签,即正确类别概率为1、其他类别为0。这迫使模型在正确类别上输出极高的置信度,容易导致过拟合------模型变得过度自信,对输入的微小变化过于敏感。
标签平滑将硬标签(hard target)转换为软标签(soft target):正确类别的目标概率不再是1,而是1 - ε;其他类别的目标概率不再是0,而是ε / (K-1),其中K为类别总数。以ε=0.1、3分类为例,标签从[1, 0, 0]变为[0.933, 0.033, 0.033]。
标签平滑的作用机理:
- 防止过拟合:模型不再被强制将正确类别的logits推向正无穷,输出概率更加平滑
- 增强泛化能力:模型对输入变化不再过度敏感,在验证集上表现更稳定
- 缓解标签噪声:即使部分训练样本的标签存在标注错误,平滑后的损失函数对其不那么敏感
AdamW优化器的优势:
AdamW是Adam的改进版本,由Loshchilov和Hutter在2017年提出。传统Adam在实现权重衰减时,将L2正则化项直接添加到损失函数中,再通过Adam的自适应学习率进行调整------这导致权重衰减的效果被学习率缩放,正则化强度与学习率耦合。AdamW将权重衰减与梯度更新解耦,在参数更新时独立地施加权重衰减,使得正则化效果更加稳定和可预测。
ReduceLROnPlateau自适应学习率调度:
ReduceLROnPlateau是一种基于指标表现的自适应学习率调度策略。与固定步长衰减(如每5轮衰减一次)不同,它监控验证集指标的实际表现------当指标连续patience轮没有提升时,才触发学习率衰减。
本代码中的配置参数含义如下:
mode='max':监控的指标越大越好(验证准确率)factor=0.5:触发衰减时,学习率乘以0.5patience=5:连续5轮验证准确率没有提升才衰减verbose=True:衰减时打印日志信息
这种调度方式的优势在于:它不会在模型还在快速学习阶段就盲目降低学习率,而是在模型陷入平台期时才介入。对于小样本迁移学习场景,模型的收敛曲线往往不够平滑,ReduceLROnPlateau的自适应特性比固定步长调度更加合适。
八、训练与验证函数封装
单轮训练函数
python
# ====================== 单轮训练函数 ======================
def train_one_epoch():
model.train()
total_loss, correct, total = 0.0, 0, 0
for img, lbl in train_loader:
img, lbl = img.to(DEVICE), lbl.to(DEVICE)
optimizer.zero_grad()
out = model(img)
loss = criterion(out, lbl)
loss.backward()
optimizer.step()
total_loss += loss.item()
_, pred = torch.max(out, 1)
correct += (pred == lbl).sum().item()
total += lbl.size(0)
return total_loss / len(train_loader), correct / total
训练模式与梯度更新流程:
model.train()将模型切换为训练模式,这一操作会启用Dropout层的随机丢弃和BatchNorm层使用当前批次的均值和方差进行归一化------这两个行为在验证模式下是禁用的。
训练的核心流程是标准的四步循环:
optimizer.zero_grad():清空上一批次累积的梯度loss.backward():反向传播计算梯度optimizer.step():根据梯度更新参数
torch.max(out, 1)沿类别维度(dim=1)取最大值的索引作为预测类别。注意这里out是未经过Softmax的logits,但取最大值与Softmax后取最大值在数学上是等价的------因为Softmax是单调递增函数,不会改变最大值的位置。
单轮验证函数
python
# ====================== 单轮验证函数 ======================
def val_one_epoch():
model.eval()
total_loss, correct, total = 0.0, 0, 0
all_pred, all_lbl = [], []
with torch.no_grad():
for img, lbl in val_loader:
img, lbl = img.to(DEVICE), lbl.to(DEVICE)
out = model(img)
loss = criterion(out, lbl)
total_loss += loss.item()
_, pred = torch.max(out, 1)
correct += (pred == lbl).sum().item()
total += lbl.size(0)
all_pred.extend(pred.cpu().numpy())
all_lbl.extend(lbl.cpu().numpy())
return total_loss / len(val_loader), correct / total, all_pred, all_lbl
验证模式与梯度禁用:
model.eval()将模型切换为验证模式:Dropout层停止随机丢弃(所有神经元都参与计算,相当于使用完整的网络),BatchNorm层改用训练阶段积累的全局均值和方差进行归一化。
torch.no_grad()上下文管理器禁用自动梯度计算,这在验证阶段有三重好处:
- 大幅减少内存占用(不需要保存中间激活值用于反向传播)
- 提升推理速度
- 避免意外地对验证集计算梯度
预测结果收集:
all_pred和all_lbl收集了整个验证集上所有样本的预测结果和真实标签。这些数据在训练结束后用于生成分类报告和混淆矩阵。注意这里使用.cpu().numpy()将张量从GPU(如果使用)移回CPU并转换为NumPy数组,以便与sklearn的评估函数兼容。
九、30轮主训练循环与最优模型保存
python
# ====================== 30轮主训练循环 ======================
best_acc = 0.0
for epoch in range(EPOCHS):
train_loss, train_acc = train_one_epoch()
val_loss, val_acc, preds, labels = val_one_epoch()
scheduler.step(val_acc)
current_lr = optimizer.param_groups[0]['lr']
print(
f"Epoch {epoch + 1:2d} | TrainLoss {train_loss:.4f} TrainAcc {train_acc:.4f} "
f"| ValLoss {val_loss:.4f} ValAcc {val_acc:.4f} | LR {current_lr:.6f}"
)
if val_acc > best_acc:
best_acc = val_acc
torch.save(model.state_dict(), "best_bean_final.pth")
print(f"最优模型已保存 | 最高验证精度:{best_acc:.4f}")
训练循环结构解析:
- 固定30轮迭代:严格匹配赛题要求,每一轮依次执行训练和验证
- 学习率调度 :将验证准确率传入
ReduceLROnPlateau,调度器内部判断是否触发学习率衰减 - 当前学习率获取 :
optimizer.param_groups[0]['lr']获取优化器当前的学习率,用于日志记录 - 完整指标打印:每轮输出训练损失、训练准确率、验证损失、验证准确率、当前学习率,满足赛题输出规范
最优模型保存策略:
python
if val_acc > best_acc:
best_acc = val_acc
torch.save(model.state_dict(), "best_bean_final.pth")
采用"验证精度刷新则保存"的策略,而非训练结束后保存最后一轮的模型。这是因为在小样本场景下,模型可能在训练中期达到最佳泛化性能,之后由于过拟合加剧而导致验证精度下降。保存历史最优模型可以确保我们始终持有泛化能力最强的权重版本。
model.state_dict()保存的是模型的参数字典(即各层的权重和偏置),而非整个模型对象。这种保存方式更加轻量且跨版本兼容性好。加载时使用model.load_state_dict(torch.load("best_bean_final.pth"))即可恢复。
十、训练结束后的量化评估
python
# ====================== 训练结束后完整评估输出 ======================
model.load_state_dict(torch.load("best_bean_final.pth", map_location=DEVICE))
_, _, preds, labels = val_one_epoch()
print("\n==================== 分类评估报告 ====================")
print(classification_report(labels, preds, target_names=train_dataset.classes, digits=4))
print("\n==================== 混淆矩阵 ====================")
print(confusion_matrix(labels, preds))
分类评估报告(Classification Report):
classification_report输出每一类别的四项核心指标:
- 精确率(Precision) :预测为该类别的样本中,实际属于该类别的比例。公式:TP / (TP + FP)
- 召回率(Recall) :实际属于该类别的样本中,被正确预测的比例。公式:TP / (TP + FN)
- F1分数(F1-Score) :精确率和召回率的调和平均数。公式:2 × (Precision × Recall) / (Precision + Recall)
- 支持度(Support) :该类别的实际样本数量
对于小样本不均衡场景,不能仅看总体准确率------如果健康叶片占90%,即使模型完全无法识别病害,总体准确率仍可达90%,但这显然是一个失败的模型。必须逐类查看精确率和召回率,确保每个类别(尤其是少数病害类别)都有可接受的识别精度。
混淆矩阵(Confusion Matrix):
混淆矩阵是一个K×K的方阵(K为类别数),行表示真实类别,列表示预测类别。对角线上的数值表示正确分类的样本数,非对角线元素表示错分情况。
通过混淆矩阵可以快速定位:
- 哪些病害类别之间容易混淆(非对角线上的高数值)
- 哪个类别的识别效果最差(对角线数值低)
- 模型是否存在系统性偏差(某一行或某一列的总和异常)
十一、工程拓展:类别校验与GPU适配
classname.txt类别校验
python
# 读取类别映射文件校验
with open("./赛题/task4/classname.txt", "r", encoding="utf-8") as f:
txt_classes = [line.strip() for line in f.readlines()]
folder_classes = train_dataset.classes
if txt_classes != folder_classes:
print("警告:文件夹类别与classname.txt类别不匹配!")
else:
print("类别校验通过")
这段代码实现了赛题"兼容classname.txt类别校验"的要求。train_dataset.classes是ImageFolder从文件夹名称自动读取的类别列表,classname.txt是赛题提供的标准类别名称文件。将两者进行比对,若不一致则发出警告,确保类别映射的准确性。
GPU自动适配
python
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
将全局超参数中的DEVICE修改为以上代码,即可实现GPU自动检测与适配。当环境中有可用CUDA显卡时自动使用GPU加速训练,否则回退到CPU。
十二、项目总结与技术要点回顾
本文完整实现了一个基于ResNet18迁移学习的豆科叶片病害小样本分类系统,从数据加载到模型评估覆盖了PyTorch图像分类的全链路工程流程。以下是核心技术要点总结:
数据层面的双重重平衡策略:
WeightedRandomSampler在数据加载层实现类别均衡采样,确保每个批次中少数类样本有足够的出现频率CrossEntropyLoss的weight参数在损失层为少数类分配更高的错误惩罚权重- 两者结合,从"模型看到什么"和"模型有多在意错误"两个维度解决类别不均衡问题
网络层面的迁移学习优化:
- 加载ImageNet预训练权重,利用通用视觉特征
- 冻结前80%底层参数,仅微调后20%高层特征,平衡特征复用与任务适配
- 剥离原生1000类分类头,自定义包含Flatten、Dropout、Linear、GELU、BatchNorm1d的多层分类头,完整覆盖赛题所有强制要求
训练层面的正则化与自适应优化:
- 标签平滑(
label_smoothing=0.1)软化硬标签,防止模型过度自信,抑制过拟合 - AdamW优化器实现解耦的权重衰减,正则化效果更稳定
- ReduceLROnPlateau自适应学习率调度,在验证精度平台期自动降低学习率
工程层面的规范化实践:
- 超参数集中管理,便于调参与实验对比
- 训练集与验证集预处理逻辑分离,保证评估结果真实可信
- 历史最优模型自动保存,避免过拟合导致的最优权重丢失
- 完整的分类报告与混淆矩阵输出,实现模型性能的量化评估
本套代码框架具有良好的可迁移性,只需替换数据集路径和调整类别数量,即可快速适配其他小样本图像多分类任务------无论是医疗影像诊断、工业缺陷检测还是其他农业病害识别场景。