ResNet-152迁移学习植物叶片病害分类实战

发布时间:2026/11/2 11:27:11
ResNet-152迁移学习植物叶片病害分类实战

简介:面向计算机视觉学习者与农业智能化实践者的 ResNet152 植物病害识别迁移学习项目包,围绕 38 类植物叶片病害分类任务展开,可用于课程设计、竞赛复现与模型微调练习。压缩包共 2000 个文件、约 503.25MB,主体为 jpg 叶片图像,并配有 py 训练与推理脚本、pth 模型权重、json 类别映射及 txt 说明,数据、代码与模型三部分齐全。已有 1433 人学习下载。读者可据此走通数据预处理与增强、替换 ResNet152 输出层、选择优化器与学习率策略、用准确率与 F1 等指标评估的完整流程,并参考高准确率的实现思路,体会迁移学习在农业场景中的落地方式,适合具备一定深度学习基础、希望快速搭建可运行基线的读者。

1. 现场出结果:一个 zip 包解决的植物叶片病害分类

前阵子看一个温室巡检的小活,需求很朴素:工人拿手机对着叶片拍一张,现场就要告诉他是哪类病害,不能等照片传回服务器再算。这种活最省事的路径不是从零训一个卷积网络,而是找个足够深、在 ImageNet 上吃过大数据的骨干做迁移学习微调。resnet152_plant.zip 就是为这个场景整理的一套完整工程:Torchvision 的 ResNet-152 骨干、按 ImageFolder 约定组织的数据目录、两段式冻结的训练脚本、单图与批量推理入口,还有一份微调完的权重。

它适合两类人:手里有几百到几万张自己拍的作物叶片图、想快速跑出一个能用的计算机视觉分类基线的人;以及想照着现成工程把深度学习训练链路(数据增强、冻层、分层学习率、断点续训)完整走一遍的人。152 层不算轻,它对细粒度病斑的辨识比浅骨干稳,代价是显存和推理耗时,这笔账后面算清楚。

2. 解压之后先别急着跑 train.py:目录、依赖与权重三件事

压缩包解开,第一反应通常是找一个入口脚本直接敲命令。这套工程里入口脚本不止一个,训练、评估、推理是分开的,跑错一个只会得到一句语焉不详的报错。先花十分钟把目录、依赖版本、预训练权重这三件事对齐,后面能省掉反复调整的时间和精力。

2.1 目录结构:每个文件负责哪一段链路

工程按职能切分,数据、代码、权重、输出四块是分开的,这个划分方式决定了你改代码时该动哪个文件。

resnet152_plant/ ├── data/ │ ├── raw/ # 原始图片,一个子目录一个类别 │ │ ├── healthy/ │ │ ├── leaf_spot/ │ │ └── rust/ │ └── split/ # 划分脚本产出,ImageFolder 直接吃这层 │ ├── train/ │ ├── val/ │ └── test/ ├── src/ │ ├── config.py # 路径、超参、类别数集中在这里 │ ├── split_data.py # 按类别分层划分 train/val/test │ ├── dataset.py # transforms + DataLoader 组装 │ ├── model.py # 构建 ResNet-152、换头、冻层 │ ├── train.py # 训练主循环 │ ├── evaluate.py # 混淆矩阵 + 每类召回 │ └── predict.py # 单图/批量推理 ├── weights/ │ ├── resnet152_imagenet.pth # 骨干预训练权重,离线时用 │ └── best_plant.pth # 微调后的成品 ├── runs/ # 每个实验一个时间戳子目录 └── requirements.txt

data/raw这一层是唯一需要你手动准备的目录,其余全由脚本生成。src/config.py是整套工程的配置集中处,类别数、图片尺寸、batch size、学习率、权重路径都放这里,改参数时不要散落到各个脚本里改,这是后来接手的人最容易抱怨的地方。

runs/下按时间戳分实验目录,里面放 TensorBoard 事件文件、每个 epoch 的 checkpoint 和最终的混淆矩阵图。这个约定看着不起眼,等你同时跑五组超参做对比的时候就知道好处了——没有它,你会在五个终端窗口里翻找 log。

weights/里的两个文件性质完全不同:resnet152_imagenet.pth是 ImageNet 上预训练的骨干,是迁移学习的起点;best_plant.pth是训练完的成品,含改过的fc层。这两个权重不能互换加载,弄混了会得到一个维度对不上的报错,或者更糟——加载成功但输出全是噪声。

2.2 依赖版本:torch 和 torchvision 必须成对

这套代码用了torchvision.models.ResNet152_Weights这个枚举对象,它在 torchvision 0.13 之后才有。装了个 0.9 的 torchvision,代码会在 import 阶段直接挂掉,报一句“module has no attribute”。

组件建议版本踩点提示
torch2.0 ~ 2.32.x 的torch.autocast必须显式传device_type
torchvision与 torch 严格对应0.15↔2.0、0.16↔2.1、0.17↔2.2,版本错配编译期就崩
Pillow≥ 9.0低于 9.0 读不了 webp,手机导出的图有一半是 webp
numpy< 2.02.x 和老版本 opencv、matplotlib 冲突较多
onnx / onnxruntime1.14+只在导出模型时需要,训练阶段不用装

requirements.txt里只锁大版本、不锁小版本,装的时候按上限就近匹配:

# 先看本机 CUDA 版本,别照抄网上的 cu118 命令 nvidia-smi | head -n 4 # torch 与 torchvision 一起装,让 pip 自己配对 pip install torch==2.1.2 torchvision==0.16.2 --index-url https://download.pytorch.org/whl/cu121 # 其余依赖 pip install -r requirements.txt # 装完立刻验一遍,重点是 cuda 是否可用、权重枚举是否存在 python -c "import torch, torchvision; print(torch.__version__, torch.cuda.is_available()); \ from torchvision.models import ResNet152_Weights; print(list(ResNet152_Weights))"

最后那行验证很值。torch.cuda.is_available()返回 False 的情况下训练脚本不会报错,它会安静地用 CPU 跑,一个 epoch 从 3 分钟变成 2 小时,很多人是在这里浪费了半天。而ResNet152_Weights能打印出来,说明 torchvision 版本没问题。

2.3 预训练权重:联网下载、离线加载,以及“到底加载上了没”

model.py默认走联网下载,models.resnet152(weights=...)会先去~/.cache/torch/hub/checkpoints/找缓存,找不到才拉。完全断网的环境(比如某些内网机器)会卡在下载重试上,表现为脚本启动后长时间没输出。

常见做法是给一个开关:本地有weights/resnet152_imagenet.pth就从文件读,没有才走 torchvision 的下载逻辑。

# src/model.py import os import torch import torch.nn as nn from torchvision import models def build_backbone(offline_ckpt=None, num_classes=3, freeze=True): """构建 ResNet-152 骨干并替换分类头。 offline_ckpt: 本地预训练权重路径;None 则联网加载 ImageNet 权重 num_classes: 你的类别数,替换掉 ImageNet 的 1000 freeze: True 表示冻结除 fc 外的所有层(第一阶段训练用) """ if offline_ckpt and os.path.isfile(offline_ckpt): # weights=None 只建结构,不触发任何下载 model = models.resnet152(weights=None) state = torch.load(offline_ckpt, map_location="cpu", weights_only=True) state = state.get("model", state) if isinstance(state, dict) else state missing, unexpected = model.load_state_dict(state, strict=False) # 这里必须看一眼 missing:全 miss 说明权重文件根本不是这套骨干 print(f"[backbone] missing={len(missing)} unexpected={len(unexpected)}") else: model = models.resnet152(weights=models.ResNet152_Weights.IMAGENET1K_V1) in_features = model.fc.in_features # ResNet-152 这里是 2048 model.fc = nn.Sequential( nn.Dropout(p=0.3), nn.Linear(in_features, num_classes), ) if freeze: for name, p in model.named_parameters(): if not name.startswith("fc."): p.requires_grad = False return model

几个参数的含义值得单独说一句。weights_only=True是 torch 2.6 之后torch.load的默认行为,显式写上能让代码在新旧版本下行为一致,也避免加载到含自定义类的 checkpoint 时报UnpicklingError。strict=False加上后打印 missing/unexpected 数量,是判断“权重到底有没有加载上”最直接的依据:missing=0且unexpected=0说明完全匹配;missing有几百个 key 而unexpected也为几百,基本就是骨干结构对不上,比如拿 resnet50 的权重往 152 里塞。

model.fc换成Sequential(Dropout, Linear)而不是单个Linear,是因为 152 层参数量大、样本又常常只有几千张,加一层 0.3 的 dropout 在小数据集上能压住过拟合。这一步不是必须的,但你如果发现训练集准确率冲到 99% 而验证集停在 70%,先把 dropout 提到 0.5 试试,比调学习率见效快。

3. 数据管线:从一堆手机照片到 224×224 的张量

模型再好,喂进去的数据组织错了也白搭。这套工程的数据链路就三段:分层划分、两套 transforms、DataLoader 组装。每一段都有容易出问题的地方,尤其是划分那一步,做得糙会直接导致后面所有指标都不可信。

3.1 按类别分层的划分脚本

data/raw/下每个子目录是一个类别,这是ImageFolder的硬约定。划分脚本要保证两件事:每个类别都按同样比例切,且切分结果可复现(固定随机种子)。

# src/split_data.py import random import shutil from pathlib import Path IMG_EXT = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} def split_dataset(src, dst, ratios=(0.7, 0.15, 0.15), seed=42): """按类别分层划分,避免某个小类别被整类划进训练集。 ratios 顺序为 (train, val, test),三段之和应为 1 """ random.seed(seed) src, dst = Path(src), Path(dst) for cls_dir in sorted(p for p in src.iterdir() if p.is_dir()): imgs = [f for f in cls_dir.iterdir() if f.is_file() and f.suffix.lower() in IMG_EXT] random.shuffle(imgs) # 每个类别内部独立打乱 n = len(imgs) n_train = int(n * ratios[0]) n_val = int(n * ratios[1]) buckets = { "train": imgs[:n_train], "val": imgs[n_train:n_train + n_val], "test": imgs[n_train + n_val:], } for split, files in buckets.items(): out = dst / split / cls_dir.name out.mkdir(parents=True, exist_ok=True) for f in files: shutil.copy2(f, out / f.name) print(f"{cls_dir.name}: total={n} " + " ".join(f"{k}={len(v)}" for k, v in buckets.items())) if __name__ == "__main__": split_dataset("data/raw", "data/split", seed=42)

random.seed(seed)放在循环外面、random.shuffle放在类别循环里面,这个顺序不能反。放外面保证整次运行可复现;每个类别单独 shuffle 保证分层。如果只写一次全局 shuffle 再按比例切,类别多的会挤占类别少的样本,遇到样本量差十倍的数据集,小类别可能一个验证样本都不剩。

ratios里的 0.7/0.15/0.15 是常见起点,样本少于 500 张的类别建议改成 0.6/0.2/0.2,验证集太小的话评估结果的抖动会大到没法判断模型到底改好没好。跑完之后终端会打印每个类别的分配情况,这个输出要留着,后面算每类召回时对着看。

3.2 训练与验证各一套 transforms

训练用带随机性的增强,验证和测试必须完全确定性,否则同一个模型跑两次评估得到两个数。工程里在dataset.py中写了两套:

# src/dataset.py from torchvision import transforms # ImageNet 的均值和方差,用预训练权重就必须照这个来,别自己算 MEAN, STD = [0.485, 0.456, 0.406], [0.229, 0.224, 0.225] train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0), ratio=(0.85, 1.15)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=15), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2, hue=0.02), transforms.ToTensor(), transforms.Normalize(MEAN, STD), ]) val_tf = transforms.Compose([ transforms.Resize(256), # 短边缩到 256,再中心裁 224 transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(MEAN, STD), ])

RandomResizedCrop的scale=(0.6, 1.0)表示每次从原图裁出 60%~100% 面积的区域再缩放,这个范围对叶片病害图合适——病斑常常只占叶面一小块,裁得太狠(比如 0.3)会把病斑整个裁掉,模型学到的是叶脉纹理。ratio=(0.85, 1.15)限制长宽比不要拉太歪,手机竖拍横拍混在一起时能减少畸变。

验证侧用Resize(256) + CenterCrop(224)而不是直接Resize(224),是业内通行做法:直接拉伸会改变长宽比,病斑形状这类特征就被扭曲了。两边必须用同一组 MEAN/STD,训练归一化了、验证忘了,模型看到的输入分布直接错位,准确率能掉二十个点,而且从 loss 曲线上一眼看不出原因。

3.3 DataLoader、类别不均衡与采样器

组装 DataLoader 的时候,几个参数的默认值在 Windows 和容器环境里都是坑。

# src/dataset.py 续 import os import torch from torch.utils.data import DataLoader, WeightedRandomSampler from torchvision import datasets def build_loaders(root="data/split", batch_size=16, num_workers=None, balanced=False): train_ds = datasets.ImageFolder(os.path.join(root, "train"), transform=train_tf) val_ds = datasets.ImageFolder(os.path.join(root, "val"), transform=val_tf) # ImageFolder 按文件夹名排序生成类别索引,务必和训练时一致 print("class_to_idx:", train_ds.class_to_idx) assert train_ds.class_to_idx == val_ds.class_to_idx sampler = None if balanced: # 按类别频次的倒数给权重,让稀有类别被多采 counts = [0] * len(train_ds.classes) for _, label in train_ds.samples: counts[label] += 1 weights = [1.0 / counts[label] for _, label in train_ds.samples] sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True) # Windows / 某些容器里多进程 dataloader 会卡死,默认给 0 最稳 if num_workers is None: num_workers = 0 if os.name == "nt" else 4 train_loader = DataLoader(train_ds, batch_size=batch_size, sampler=sampler, shuffle=(sampler is None), num_workers=num_workers, pin_memory=True, drop_last=True, persistent_workers=num_workers > 0) val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True) return train_loader, val_loader, train_ds.classes

shuffle和sampler是互斥的,同时传会直接抛异常,所以写成shuffle=(sampler is None)。drop_last=True只在训练侧开,是为了让每个 batch 尺寸一致,配合 BatchNorm 训练更稳;验证侧不能丢,丢了最后几个样本不参与评估,指标会偏。

class_to_idx那行打印和 assert 是保命的。ImageFolder的索引来自文件夹名的字典序,data/split/train和data/split/val的类目录名一样就没问题,但如果你后来在某一侧删了一个类、或者大小写不一致,索引就会错位——训练时标签 0 是 healthy,推理时标签 0 变成了 leaf_spot,模型输出的概率看着很合理,结论全错。

batch_size=16是 152 层在 12GB 显存上的保守值。不均衡时用balanced=True打开WeightedRandomSampler,替代“过采样小类别文件”的做法,避免磁盘上多出一份重复数据。

4. 迁移学习主体:ResNet-152 换头、冻层与两段式解冻

前面都是铺垫,真正的核心就这一段:怎么把一个 1000 类的 ImageNet 分类器改造成你的叶片病害分类器。工程里采用的是“先冻后放”的两段式策略,这也是小样本场景下最稳的做法。

4.1 换头:2048 维接到你的类别数

ResNet-152 的最后是一个Linear(2048, 1000),2048 来自全局平均池化后的通道数。换掉它有两行代码,但有两件事必须同时做对:新层的初始化,以及旧层的加载顺序。

# src/model.py 续 import torch.nn.init as init def replace_head(model, num_classes): """在 frozen 状态下替换 fc 层,并手动初始化。 顺序很关键:先加载预训练权重,再换头。 反过来会把刚初始化的 fc 权重覆盖掉。 """ in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) # 默认初始化对 2048->N 这种宽输入偏小,用 kaiming 重新初始化 init.kaiming_normal_(model.fc.weight, mode="fan_out", nonlinearity="relu") init.constant_(model.fc.bias, 0.0) return model

“先加载预训练权重、再换头”这个顺序是血泪经验。反过来的话,load_state_dict会因为fc.weight形状不匹配报错;如果为了消错加了strict=False,更隐蔽——你刚初始化好的新分类头被 ImageNet 的 1000 类权重覆盖(能覆盖的维度覆盖,剩下的保持随机),模型前几个 epoch 的输出会非常诡异。

kaiming_normal_里mode="fan_out"表示按输出维度缩放,配 ReLU 系激活是标准组合。nn.Linear的默认初始化是 U(-1/√fan_in, 1/√fan_in),在 2048 维输入下初始值偏小,前期梯度信号弱,收敛会慢两三个 epoch。这个差异在 152 层上更明显,因为梯度的计算路径更长。

4.2 冻结与解冻:为什么先只训 fc

第一阶段冻结骨干只训fc,本质是把这个网络当成一个固定的特征提取器,只学一个线性分类面。好处是收敛极快、显存占用小(不用存骨干的梯度),在小数据集上不容易把预训练学到的通用特征冲掉。

第二阶段解冻最后几个 stage,用很小的学习率做微调。

# src/model.py 续 def unfreeze_stages(model, n_stages=2): """解冻最后 n 个 stage,n=1 只放 layer4,n=2 再放 layer3。 先解冻 layer4:它对应的是高层语义特征,和具体任务最相关, 改动它风险最小、收益最大。 """ order = ["layer4", "layer3", "layer2", "layer1"] for stage in order[:n_stages]: for name, p in model.named_parameters(): if name.startswith(stage + "."): p.requires_grad = True # 别忘了 BN 层:解冻后如果继续冻着 BN 的统计量,特征分布对不上 for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.momentum = 0.05 # 从默认 0.1 调小,微调阶段统计量变化要慢 return model

解冻顺序从layer4往前推,是因为 ResNet 的层级语义是递进的:layer1学的是边缘和纹理,跟 ImageNet 上的通用视觉特征差别不大,冻结它几乎没损失;layer4学的是物体部件级别的组合,和“病斑长什么样”强相关,放开它收益最大。凭经验,样本量五千以下解冻 1 个 stage 就够,硬放 4 个 stage 一定会过拟合。

BatchNorm2d那几行容易被忽略。解冻之后如果 BN 层还处于eval状态(model.eval()之后没切回来),它会一直用 ImageNet 的 running mean/var,而你新数据的分布和 ImageNet 差得远,特征归一化就错位了,表现为解冻后 loss 反而往上跳。把 momentum 从 0.1 调到 0.05,是让统计量更新得慢一点,别被小 batch 的噪声带偏。

4.3 优化器、调度器与混合精度训练循环

分层学习率是这套工程里最值得抄的一段。骨干用 1e-4,新分类头用 1e-3,差一个数量级——头是随机初始化的,需要大步走;骨干是预训练好的,只需要小步微调。

# src/train.py import torch from torch.optim.lr_scheduler import CosineAnnealingLR def make_param_groups(model, lr_head=1e-3, lr_backbone=1e-4, weight_decay=1e-4): """按参数名分组,只收集 requires_grad=True 的参数。""" head, backbone = [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue (head if name.startswith("fc.") else backbone).append(p) groups = [{"params": head, "lr": lr_head, "weight_decay": weight_decay}] if backbone: groups.append({"params": backbone, "lr": lr_backbone, "weight_decay": weight_decay}) print(f"[optim] head={len(head)} backbone={len(backbone)}") return groups def build_optim(model, epochs, lr_head=1e-3, lr_backbone=1e-4): optim = torch.optim.AdamW(make_param_groups(model, lr_head, lr_backbone)) sched = CosineAnnealingLR(optim, T_max=epochs, eta_min=1e-6) return optim, sched

用AdamW而不是SGD,是因为第二阶段解冻的参数很少,SGD 在这种稀疏更新下收敛太慢;weight_decay=1e-4做解耦权重衰减,对抑制 152 层的过拟合有实际作用。CosineAnnealingLR的T_max必须等于总 epoch 数,写成别的值会导致学习率在训练中途就降到eta_min,后面几十个 epoch 白跑。

训练循环用 AMP 混合精度,这是 152 层能在 12GB 卡上跑起来的前提:

# src/train.py 续 def train_one_epoch(model, loader, criterion, optim, scaler, device, use_amp=True): model.train() # 切回训练模式,BN 和 Dropout 才会生效 total_loss, correct, seen = 0.0, 0, 0 for x, y in loader: x = x.to(device, non_blocking=True) y = y.to(device, non_blocking=True) optim.zero_grad(set_to_none=True) # 比 zero_grad() 省一次显存写 with torch.autocast(device_type=device.type, dtype=torch.float16, enabled=use_amp): logits = model(x) loss = criterion(logits, y) scaler.scale(loss).backward() # 梯度先放大再反传,避免 fp16 下溢 scaler.step(optim) scaler.update() total_loss += loss.item() * y.size(0) correct += (logits.argmax(1) == y).sum().item() seen += y.size(0) return total_loss / seen, correct / seen

scaler.scale(loss).backward()这三行的作用很多人只是照抄。fp16 能表示的数值范围比 fp32 窄,梯度里那些 1e-9 量级的小值会直接变成 0,网络就学不动。GradScaler在反传前把 loss 乘一个大数,反传后再除回来,等于给梯度做了一次动态量程调整。optim.step()之前必须走scaler.step(),否则梯度既没被还原也没做 inf/nan 检查,训练会莫名其妙发散。

zero_grad(set_to_none=True)是把梯度置成None而不是填 0,PyTorch 在反传时会重新分配,省一次全量显存写。152 层有 6000 万参数,这个优化在显存吃紧时能挤出几百 MB。

4.4 保存策略与断点续训

runs/下的 checkpoint 分两类:last.pth每个 epoch 覆盖,best.pth只在验证准确率提升时写。前者用来续训,后者用来推理,功能不能混。

# src/train.py 续 def save_ckpt(path, model, optim, sched, epoch, best_acc, classes): torch.save({ "model": model.state_dict(), "optim": optim.state_dict(), # 续训必须存优化器状态,AdamW 有二阶动量 "sched": sched.state_dict(), "epoch": epoch, "best_acc": best_acc, "classes": classes, # 类别顺序一起存,推理时不用再猜 }, path)

classes一起存进去是很实用的一步。后面predict.py加载权重时直接从 ckpt 里读类别列表,不用再去data/split/train里扫目录。数据目录被改了、被删了、被换了一套类名,推理端的标签映射依然是训练时那一套,不会出现“模型预测第 2 类,你把它显示成第 3 类的名字”这种低级但难查的问题。

续训时optim.load_state_dict和sched.load_state_dict都不能漏。只恢复模型权重的话,AdamW 的二阶动量从零开始,前几个 epoch 的学习率实际效果会突变,loss 曲线会出现一个明显的台阶。

5. 排查避坑:五个让准确率原地打转的操作

调模型最耗时的不是写代码,是判断“现在这个结果到底是模型不行还是流程有问题”。下面五条都是在实际调试里反复出现的,每条都按现象、原因、解决写,对号入座比盲调超参快得多。

5.1 准确率死死卡在 1/类别数附近

现象:训练 loss 缓慢下降但基本不动,验证准确率稳定在 33% 左右(三分类),怎么看都像随机猜。

原因通常是三个之一。一是训练和验证的归一化不一致——训练用了Normalize,验证的val_tf里漏了,模型看到的输入分布完全不同,等于在拿另一套数据评估。二是学习率给到了 1e-2 这个量级,第一阶段只训fc的时候还没事,一解冻就把预训练权重整个冲掉,152 层的特征全毁了。三是class_to_idx在训练和评估两侧不一致,标签整体错位,模型学得再好也全判错。

解决:先跑一个 20 张图的小批量过拟合测试——固定同一批数据,不加任何增强,训 100 个 step,看能不能把训练准确率干到 100%。干不到,问题在数据或标签,不在模型容量;能干到,问题在泛化侧,回去查归一化和学习率。

# 过拟合小批量的自检,20 张图跑 100 step,10 秒出结果 x, y = next(iter(train_loader)) x, y = x[:20].to(device), y[:20].to(device) model.train() for step in range(100): optim.zero_grad(set_to_none=True) loss = criterion(model(x), y) loss.backward(); optim.step() if step % 20 == 0: acc = (model(x).argmax(1) == y).float().mean().item() print(f"step={step} loss={loss.item():.4f} acc={acc:.3f}")

这段自检代码我建议每换一次数据都跑一遍。它能在十秒内把“数据管线坏了”和“模型欠拟合”这两类问题彻底分开,比盯着 loss 曲线猜快得多。

5.2 验证集 99%,现场一测全错

现象:验证集准确率 0.99,混淆矩阵几乎是对角阵,但拿手机新拍的照片一测,结果乱七八糟。

原因基本可以锁定为数据泄漏,而且是最隐蔽的一种:同一片叶子、同一个背景板被拍了好几张,划分的时候按文件随机分,同一株的样本同时进了训练集和验证集。模型记住的是背景和叶片的整体外观,不是病斑特征。在受控环境(比如实验室白背景)下拍的图,这个现象尤其严重。

解决:划分单位从“图片”改成“植株”或“拍摄批次”。每张图命名时带上植株编号,划分脚本按编号分组,同一编号的所有图片只能落在同一个 split 里。改完之后验证准确率通常会掉十个点以上——那个掉下去的数字才是真实水平。另外补一个测试集,用完全不同的时间段、不同设备拍的图,这个数字才敢拿去汇报。

5.3 batch_size 上不去,显存一碰就爆

现象:batch_size=32直接 CUDA OOM,改成 8 能跑但 BatchNorm 效果很差,训练不稳定。

原因:ResNet-152 的前向激活本身就大,224×224 输入下layer1输出的特征图是 64×56×56,一个 batch 32 张的激活量轻松超过 8GB。第一阶段冻结骨干时问题不明显(不存梯度),一解冻显存需求翻倍。

解决:三件事叠加。开 AMP,激活占一半;用梯度累积把有效 batch 拉到 32 而实际只放 8 张;冻结阶段把torch.no_grad()用在骨干上,requires_grad=False只省梯度不省激活,真正省显存得靠no_grad,或者用torch.utils.checkpoint做激活重算。梯度累积的写法很简单:

accum_steps = 4 # 等效 batch = 8 * 4 = 32 for i, (x, y) in enumerate(loader): with torch.autocast(device_type="cuda", dtype=torch.float16): loss = criterion(model(x.to(device)), y.to(device)) / accum_steps scaler.scale(loss).backward() if (i + 1) % accum_steps == 0: scaler.step(optim); scaler.update() optim.zero_grad(set_to_none=True)

注意 loss 要除以accum_steps,否则梯度是累加后的总和,等效学习率翻四倍。这个除法漏掉的话,训练前几十步看着正常,之后 loss 突然起飞,很难联想到是累积步数的问题。

5.4 权重加载报错,或者加载成功但输出全是同一个类

现象一:torch.load抛UnpicklingError,提示需要weights_only=False。现象二:加载成功,strict=False也不报错,但推理时所有图片都判成同一类。

原因:前者是 torch 2.6 起torch.load默认weights_only=True,老 checkpoint 里塞了argparse.Namespace或自定义类,反序列化被拦下。后者多半是 key 前缀问题——用DataParallel或 DDP 训练保存的权重带module.前缀,直接load_state_dict会全部 miss,模型还是随机初始化的那一套,输出自然退化成常数。

解决:加载函数里统一做前缀清洗,并且一定要打印 missing/unexpected 的数量,不要静默吞掉。数量不为 0 就别往下走。

def load_weights(model, ckpt_path, device="cpu"): ckpt = torch.load(ckpt_path, map_location=device, weights_only=False) state = ckpt.get("model", ckpt) if isinstance(ckpt, dict) else ckpt # 去掉 DataParallel / DDP 留下的 module. 前缀 state = {k[len("module."):] if k.startswith("module.") else k: v for k, v in state.items()} missing, unexpected = model.load_state_dict(state, strict=False) assert len(missing) == 0, f"缺少 {len(missing)} 个 key,检查骨干结构是否一致" assert len(unexpected) == 0, f"多出 {len(unexpected)} 个 key,检查是否加载了错误文件" return model

weights_only=False只在完全信任文件来源时用,这点要有意识。工程自带的 ckpt 没问题,网上随手下的权重就别这么干了。

5.5 同一张图推理两次结果不一样

现象:predict.py对同一张图片连跑两次,输出的概率不一样,差距还不小。

原因:忘了切model.eval()。训练模式下 Dropout 是打开的,每次前向都在随机丢 30% 的神经元;BatchNorm 也会用当前 batch 的统计量而不是 running 统计量,单张图推理时 batch 统计量就是它自己,结果完全变形。

解决:推理入口第一行model.eval(),外面套torch.no_grad()。这两件事要一起做,只加no_grad不加eval依然会随机。

@torch.no_grad() def predict(model, img_path, class_names, device, tf, topk=3): model.eval() # 少了这行,两次结果必然不同 img = Image.open(img_path).convert("RGB") # 灰度图、四通道 PNG 必须转 RGB x = tf(img).unsqueeze(0).to(device) prob = model(x).softmax(dim=1)[0] conf, idx = prob.topk(min(topk, len(class_names))) return [(class_names[i], round(c, 4)) for c, i in zip(conf.tolist(), idx.tolist())]

convert("RGB")也是必需的。手机拍的 PNG 常带 alpha 通道,ToTensor()之后是 4 通道,喂进只接受 3 通道的骨干会直接报维度错;灰度图则是 1 通道,报错信息更绕。一行convert把这两个分支都堵住。

6. 二次开发:换作物、换骨干,以及怎么验证没改坏

这套工程的改造成本主要集中在三个点上,按改动量从小到大排:换类别、换骨干、换部署形态。

换作物类别是最常见的需求。流程是:把新数据按类别放进data/raw、跑split_data.py、改config.py里的num_classes和class_names、重新训。注意num_classes改了之后旧权重不能直接用,fc层形状对不上,但骨干部分仍然可以复用——加载旧 ckpt 时把fc.*从 state 里过滤掉,其余照常加载,这样能省掉从 ImageNet 重新收敛的那几十个 epoch。

换骨干要拿数据说话,别拍脑袋。152 层适合病斑细小、样本充足的场景;样本只有一两千张的时候,换小骨干往往更划算:

骨干参数量12GB 卡上 batch适用场景
resnet50约 25M64样本 < 3000,追求推理速度
resnet152约 60M16(开 AMP)细粒度、样本充足
efficientnet_b0约 5M96边缘设备,精度要求中等

换骨干只改model.py里建模型那几行,但有个坑要绕:不同骨干的分类头属性名不一样,resnet*是fc,efficientnet*是classifier[1],mobilenet_v3也是classifier。冻层逻辑里用name.startswith("fc.")判断的话,换骨干后会一个参数都匹配不上,等于全放开训练。稳妥写法是按named_children()拿最后一个子模块的名字,别硬编码。

验证改动没坏这件事,比改代码本身更重要。我现在的固定动作是三步:先在 20 张图的子集上跑过拟合自检,确认新代码能把训练准确率推到 100%;再跑完整评估出混淆矩阵,重点看每类的召回而不是总体准确率,因为不均衡数据里总体准确率会被大类别拉高;最后导出 ONNX 用 onnxruntime 跑一遍,和 PyTorch 的输出逐个比对,最大绝对误差超过 1e-3 就说明导出有问题(常见于动态 shape 没设对)。

# 导出后的一致性校验,PyTorch 与 onnxruntime 各跑一次 import numpy as np, onnxruntime as ort x = torch.randn(4, 3, 224, 224) with torch.no_grad(): ref = model.eval().cpu()(x).numpy() sess = ort.InferenceSession("resnet152_plant.onnx", providers=["CPUExecutionProvider"]) got = sess.run(None, {"input": x.numpy()})[0] print("max_abs_diff:", np.abs(ref - got).max()) # 应小于 1e-3

另外一个容易被忘的后悔药:每次改完训练脚本,把torch.manual_seed、np.random.seed、random.seed三个都固定住,然后用同一个 seed 跑两遍,两次的最终验证准确率应该完全一致。如果对不上,说明流程里还有没被控制的随机源(比如num_workers>0时的 worker seed),这种情况下你后面所有的 A/B 对比都是噪声,白忙活。

从那以后我每次动完数据管线或模型结构,都会强制走一遍「小批量过拟合 + 混淆矩阵 + ONNX 一致性」这三关,哪怕只改了一行 normalize,也不敢跳。希望帮到你。

本文还有配套的精品资源,点击获取