ResNet18迁移学习实现豆科叶片病害小样本分类——完整工程实战与深度原理解析(附完整代码)

前言

在农业智能化浪潮中,作物病害的自动识别对于精准施药、减少产量损失具有极高的实用价值。然而,真实的农业图像分类任务往往面临两大核心痛点:其一,标注数据获取成本高昂,训练样本极为有限;其二,不同病害的发生频率差异显著,导致数据集类别分布严重不均。这两大问题使得从头训练深度卷积神经网络几乎必然陷入过拟合,模型在验证集上的表现无法达到实际应用标准。

迁移学习正是破解这一困境的关键技术。借助在大规模通用数据集(如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])
])

训练集增强逐项深度解析:

数据增强是小样本场景下最为关键的技术手段之一。通过对原始图像施加一系列随机变换,可以在不增加真实标注成本的前提下,大幅扩充训练样本的多样性,有效抑制过拟合。

  1. Resize((224, 224)):将所有图像统一缩放到224×224,匹配ResNet18的输入规格。

  2. RandomHorizontalFlip / RandomVerticalFlip:随机水平翻转和垂直翻转,模拟叶片在不同拍摄角度下的镜像变化。翻转操作不改变语义类别,是成本最低且效果显著的数据增强方式。

  3. RandomRotation(15):在±15度范围内随机旋转图像,模拟手持设备拍摄时的倾斜角度。角度范围不宜过大,否则可能引入不自然的形变。

  4. ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2):随机调整亮度、对比度和饱和度,模拟户外不同光照条件(晴天、阴天、背光等)下的图像变化。

  5. GaussianBlur(kernel_size=3):施加轻微的高斯模糊,模拟对焦不准或运动模糊的真实拍摄场景,增强模型对图像质量的鲁棒性。

  6. RandomResizedCrop(224, scale=(0.8, 1.0)):从原图中随机裁剪80%至100%面积的区域,再缩放至224×224。这是最强大的数据增强手段之一------它迫使模型关注叶片的不同局部区域而非全局纹理,显著提升泛化能力。

  7. ToTensor():将PIL图像或NumPy数组转换为PyTorch张量,并将像素值从0,255归一化到0,1

  8. 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通过为每个样本分配不同的采样权重,从数据加载层面干预每个批次的样本组成。其核心工作机制如下:

  1. 首先统计每一类的样本总数:class_counts = np.bincount(targets)
  2. 计算类别权重:class_weights = 1.0 / class_counts,样本越少的类别权重越大
  3. 将类别权重映射到每个具体样本:samples_weights = class_weights[targets]
  4. 采样器根据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%的参数(高层卷积层和自定义分类头)参与梯度更新。这样做有三重好处:

  1. 大幅减少可训练参数量,降低过拟合风险
  2. 保留ImageNet上学到的通用视觉特征
  3. 让高层特征有足够的灵活性去适配叶片病害的专属模式

剥离原生分类头与自定义分类头:

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.CrossEntropyLossweight参数允许为不同类别分配不同的损失权重。在本代码中,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]

标签平滑的作用机理:

  1. 防止过拟合:模型不再被强制将正确类别的logits推向正无穷,输出概率更加平滑
  2. 增强泛化能力:模型对输入变化不再过度敏感,在验证集上表现更稳定
  3. 缓解标签噪声:即使部分训练样本的标签存在标注错误,平滑后的损失函数对其不那么敏感

AdamW优化器的优势:

AdamW是Adam的改进版本,由Loshchilov和Hutter在2017年提出。传统Adam在实现权重衰减时,将L2正则化项直接添加到损失函数中,再通过Adam的自适应学习率进行调整------这导致权重衰减的效果被学习率缩放,正则化强度与学习率耦合。AdamW将权重衰减与梯度更新解耦,在参数更新时独立地施加权重衰减,使得正则化效果更加稳定和可预测。

ReduceLROnPlateau自适应学习率调度:

ReduceLROnPlateau是一种基于指标表现的自适应学习率调度策略。与固定步长衰减(如每5轮衰减一次)不同,它监控验证集指标的实际表现------当指标连续patience轮没有提升时,才触发学习率衰减。

本代码中的配置参数含义如下:

  • mode='max':监控的指标越大越好(验证准确率)
  • factor=0.5:触发衰减时,学习率乘以0.5
  • patience=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层使用当前批次的均值和方差进行归一化------这两个行为在验证模式下是禁用的。

训练的核心流程是标准的四步循环:

  1. optimizer.zero_grad():清空上一批次累积的梯度
  2. loss.backward():反向传播计算梯度
  3. 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()上下文管理器禁用自动梯度计算,这在验证阶段有三重好处:

  1. 大幅减少内存占用(不需要保存中间激活值用于反向传播)
  2. 提升推理速度
  3. 避免意外地对验证集计算梯度

预测结果收集:

all_predall_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}")

训练循环结构解析:

  1. 固定30轮迭代:严格匹配赛题要求,每一轮依次执行训练和验证
  2. 学习率调度 :将验证准确率传入ReduceLROnPlateau,调度器内部判断是否触发学习率衰减
  3. 当前学习率获取optimizer.param_groups[0]['lr']获取优化器当前的学习率,用于日志记录
  4. 完整指标打印:每轮输出训练损失、训练准确率、验证损失、验证准确率、当前学习率,满足赛题输出规范

最优模型保存策略:

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在数据加载层实现类别均衡采样,确保每个批次中少数类样本有足够的出现频率
  • CrossEntropyLossweight参数在损失层为少数类分配更高的错误惩罚权重
  • 两者结合,从"模型看到什么"和"模型有多在意错误"两个维度解决类别不均衡问题

网络层面的迁移学习优化:

  • 加载ImageNet预训练权重,利用通用视觉特征
  • 冻结前80%底层参数,仅微调后20%高层特征,平衡特征复用与任务适配
  • 剥离原生1000类分类头,自定义包含Flatten、Dropout、Linear、GELU、BatchNorm1d的多层分类头,完整覆盖赛题所有强制要求

训练层面的正则化与自适应优化:

  • 标签平滑(label_smoothing=0.1)软化硬标签,防止模型过度自信,抑制过拟合
  • AdamW优化器实现解耦的权重衰减,正则化效果更稳定
  • ReduceLROnPlateau自适应学习率调度,在验证精度平台期自动降低学习率

工程层面的规范化实践:

  • 超参数集中管理,便于调参与实验对比
  • 训练集与验证集预处理逻辑分离,保证评估结果真实可信
  • 历史最优模型自动保存,避免过拟合导致的最优权重丢失
  • 完整的分类报告与混淆矩阵输出,实现模型性能的量化评估

本套代码框架具有良好的可迁移性,只需替换数据集路径和调整类别数量,即可快速适配其他小样本图像多分类任务------无论是医疗影像诊断、工业缺陷检测还是其他农业病害识别场景。