4. 模型训练实战
本节把前面准备好的数据真正“喂”进网络,完成一个可复现的训练闭环:定义作物病害分类模型、编写训练循环、用学习率调度控制优化节奏、按验证集指标保存最优权重,并在训练结束后用验证集评估模型。示例以“番茄叶片三分类(健康、早疫病、晚疫病)”为假设场景,输入为 224×224 的 RGB 叶片图像,训练集约 4800 张、验证集约 1200 张,类别分布大致均衡。这些数字属于教学示例,实际项目应按自己的数据集替换。
一、模型结构:从骨干网络到分类头
农业病害图像的特点是病斑区域小、纹理细、背景(土壤、支架、光照)干扰大,因此常用迁移学习:加载在 ImageNet 上预训练的 ResNet18 或 MobileNetV3,替换最后的全连接层为与类别数一致的输出。下面用 ResNet18 构建分类器,并冻结骨干的前若干层以减少小数据集上的过拟合。
import torch
import torch.nn as nn
from torchvision import models
def build_model(num_classes=3, freeze_backbone=True):
model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)
if freeze_backbone:
for name, p in model.named_parameters():
if not name.startswith("fc"):
p.requires_grad = False
in_features = model.fc.in_features
model.fc = nn.Sequential(
nn.Dropout(0.3),
nn.Linear(in_features, num_classes)
)
return model
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = build_model(num_classes=3).to(device)
冻结骨干后,只有分类头参与反向传播,第一轮训练相当于“线性探针”,收敛快且不易破坏预训练特征。待验证集准确率稳定后,可解冻最后一个 stage 做小学习率微调,这是农业小样本场景中较稳妥的两段式策略。
二、训练循环:前向、损失、反向、更新
训练循环的核心是四个动作的顺序与作用域:optimizer.zero_grad() 清空上一轮梯度,前向得到 logits,交叉熵计算损失,loss.backward() 累积梯度,optimizer.step() 更新参数。同时必须显式切换 model.train() 与 model.eval(),因为 Dropout 和 BatchNorm 在两种模式下的行为不同,漏掉 eval() 会让验证指标虚高或抖动。
import torch.optim as optim
from torch.optim.lr_scheduler import CosineAnnealingLR
criterion = nn.CrossEntropyLoss()
optimizer = optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()),
lr=3e-4, weight_decay=1e-4)
scheduler = CosineAnnealingLR(optimizer, T_max=15, eta_min=1e-6)
def train_one_epoch(model, loader):
model.train()
total_loss, correct, total = 0.0, 0, 0
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
logits = model(images)
loss = criterion(logits, labels)
loss.backward()
optimizer.step()
total_loss += loss.item() * labels.size(0)
correct += (logits.argmax(1) == labels).sum().item()
total += labels.size(0)
return total_loss / total, correct / total
这里用 loss.item() * labels.size(0) 累加再除以样本总数,得到的是按样本加权的平均损失,比逐 batch 求平均再平均更准确,尤其在最后一个 batch 不满时差别明显。准确率同理按样本累计。
三、学习率调度:让优化节奏随训练阶段变化
学习率是训练中最敏感的超参数。固定学习率在初期可能震荡,在后期又难以精细收敛。余弦退火让学习率从初始值平滑下降到接近零,适合总轮数已知的短周期训练;若训练轮数不确定,可用 ReduceLROnPlateau,在验证损失连续若干轮不下降时衰减。注意调度器的调用位置:按 epoch 更新的调度器应在每个 epoch 结束后调用 scheduler.step(),而不是每个 batch。
def evaluate(model, loader):
model.eval()
total_loss, correct, total = 0.0, 0, 0
with torch.no_grad():
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
logits = model(images)
loss = criterion(logits, labels)
total_loss += loss.item() * labels.size(0)
correct += (logits.argmax(1) == labels).sum().item()
total += labels.size(0)
return total_loss / total, correct / total
验证阶段必须包在 torch.no_grad() 中,既关闭梯度计算节省显存,也避免误把验证数据用于参数更新。评估函数与训练函数结构对称,便于对比两者的损失与准确率。
四、保存最优权重与验证集评估
训练过程中不能只看最后一轮的权重,而应以验证集准确率为准保存“最优检查点”。下面的主循环把训练损失、验证损失、验证准确率和当前学习率记录下来,并在验证准确率提升时覆盖保存 best_crop_disease.pth。保存内容建议包含 state_dict、类别名映射和输入尺寸,便于部署时对齐预处理。
best_acc = 0.0
history = []
for epoch in range(15):
tr_loss, tr_acc = train_one_epoch(model, train_loader)
val_loss, val_acc = evaluate(model, val_loader)
lr_now = optimizer.param_groups[0]["lr"]
scheduler.step()
history.append((epoch + 1, tr_loss, val_loss, val_acc, lr_now))
print(f"Epoch {epoch+1:02d} | train_loss={tr_loss:.4f} "
f"train_acc={tr_acc:.3f} | val_loss={val_loss:.4f} "
f"val_acc={val_acc:.3f} | lr={lr_now:.2e}")
if val_acc > best_acc:
best_acc = val_acc
torch.save({
"state_dict": model.state_dict(),
"classes": ["健康", "早疫病", "晚疫病"],
"input_size": 224,
"val_acc": best_acc
}, "best_crop_disease.pth")
print("最优验证准确率:", round(best_acc, 4))
如何判断训练是否健康?可观察三条曲线:训练损失持续下降而验证损失开始上升,说明过拟合,应加强数据增强、提高 Dropout 或提前停止;训练与验证损失都居高不下,说明欠拟合或学习率过大,可解冻更多层或降低初始学习率;验证准确率在若干轮内反复横跳,往往是学习率偏大或 batch 过小。示例中若第 8 轮验证准确率达到 0.94 后不再提升,而训练准确率已到 0.99,就应保留第 8 轮权重作为最终模型。
训练结束后,用保存的最优权重重新加载并做一次完整验证,确认指标与训练日志一致,同时输出混淆矩阵,重点看“早疫病”与“晚疫病”是否互相误判——这两类病斑形态接近,是农业分类任务中最常见的混淆对。若某一类召回率明显偏低,应回到数据层检查该类样本数量与标注质量,而不是盲目增加训练轮数。至此,一个可复现、可评估、可保存的作物病害分类器训练流程就完成了,导出的权重文件可直接用于后续的 ONNX 转换与部署。