简介:resnet152_plant.zip 是一份面向深度学习初学者与计算机视觉实践者的植物病害识别迁移学习方案,围绕 ResNet152 预训练模型展开,帮助读者解决 38 类植物叶片病害的精细分类问题,可用于课程设计、农业 AI 项目复现与算法练手。压缩包共 2000 个文件、约 503.25MB,以 5412 张 jpg 叶片图像构成数据集主体,辅以 6 个 py 脚本承载训练与推理流程、2 个 pth 权重文件供直接加载或继续微调,另有 json 与 txt 记录类别映射和说明信息。资源已有 1433 人学习下载,具备一定参考基础。其价值在于把数据准备、增强策略、输出层改造、优化器与学习率设置、评估指标到部署落地串成完整链路,读者可据此复现 99.6% 识别准确率的训练思路,理解预训练特征迁移与全连接层替换的关键细节,并对照权重与脚本快速验证改造效果,减少从零搭建的试错成本。
1. 从压缩包到可用模型:resnet152_plant.zip 到底装了什么
拿到 resnet152_plant.zip 的人,十有八九第一反应是解压后找 train.py 直接跑;真正决定它能不能用的,不是 ResNet152 这串名字,而是压缩包里的植物图像目录、标签顺序和预处理是否与权重对齐。这份资源的核心价值在于用 ResNet152 做迁移学习,把通用视觉特征迁到植物叶片或整株图像分类上,适合已经有一批带类别目录的图像、想快速跑通训练到推理链路的从业者。它不解决采集问题,也不替你做标签清洗;如果你的数据只有零散图片没有类别结构,先补目录,再谈模型。下面按“拆包核对—训练—推理—排查”的顺序拆。
2. 先立数据契约:植物图像目录、标签映射与 ResNet152 输入管线
2.1 目录审计:先数类别,再看每类样本
resnet152_plant.zip 这类资源最常见的翻车点不是模型结构,而是数据目录和标签顺序。解压后先别改代码,先做一次目录审计:不管包内是 train/val/test 三级目录,还是 images 加分类型标签文件,先把类别名、每类样本数、图片后缀分布打印出来。很多植物分类数据会有“同一类别多个别名”“文件名带采集批次”“某几类只有十几张图”的情况,这些信息直接决定后面能不能用默认的 ImageFolder 管线。
常见的 ImageFolder 目录约定是每个类别一个文件夹,文件夹名就是类别名。比如data/plant/train/类别A/*.jpg、data/plant/val/类别A/*.jpg。如果你拿到的是平铺图片加 CSV 标签,不要硬套 ImageFolder,先写一个小脚本转成目录结构,或者自定义 Dataset 读 CSV。目录审计这一步做完,你至少要知道:类别数是否等于模型输出数、训练集和验证集的类别是否完全一致、有没有空文件夹、有没有非图片文件混在类别目录里。
# 在解压后的数据根目录执行,先看两层目录结构 find data/plant -maxdepth 3 -type d | sort | head -80 # 统计每个类别目录下的图片数量,后缀按常见格式过滤 find data/plant/train -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.webp" \) \ | awk -F/ '{print $(NF-1)}' | sort | uniq -c | sort -nr上面第一条命令只做结构观察,-maxdepth 3防止在深层缓存目录里刷屏;第二条命令用awk取文件所在目录名,也就是类别名,再按数量倒序。执行后如果发现某个类别明显偏少,先记下来,后面要么补样本,要么在训练时用类别权重,别等到混淆矩阵里看到它被全部分错才回头查。图片后缀统计也有用:有些包内混了.gif或.bmp,PIL 能打开,但训练管线默认只读 RGB,统一转成三通道更稳。
图片完整性检查不要省。植物图像采集现场常见半截文件、零字节文件、扩展名和内容不一致的文件,这些样本在 DataLoader 里会直接抛异常,训练跑到一半断掉。我一般会在正式训练前跑一遍PIL.Image.verify(),把坏图路径写进bad_images.txt,再决定删除还是重新导出。
from pathlib import Path from PIL import Image, UnidentifiedImageError data_root = Path("data/plant/train") bad = [] suffixes = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} for p in data_root.rglob("*"): if p.is_file() and p.suffix.lower() in suffixes: try: with Image.open(p) as im: im.verify() # 只校验文件头,不保证像素完整解码 except (UnidentifiedImageError, OSError) as e: bad.append((str(p), repr(e))) Path("bad_images.txt").write_text( "\n".join(f"{p}\t{e}" for p, e in bad), encoding="utf-8" ) print("bad images:", len(bad))这段脚本的data_root指向训练集根目录,suffixes按你实际数据扩展;verify()之后图片对象不能继续读像素,所以只用于校验。坏图清单落盘后,人工抽查前二十条,确认是传输损坏还是格式不受支持。注意不要一上来就全删:有些.png只是带透明通道,转成 RGB 后仍可用;真正零字节或截断的文件才需要剔除。
2.2 ResNet152 的 224 输入不是随便定的
ResNet152 在通用视觉预训练里通常按 224×224 的输入训练,迁移到植物分类时,最稳的做法是让输入尺寸、归一化参数和预训练权重保持一致。训练阶段可以用RandomResizedCrop(224, scale=(0.7, 1.0))做尺度扰动,验证和推理阶段用Resize(256)加CenterCrop(224)。这个组合不是玄学,它保留了比 224 稍大的视野,再裁中心区域,减少推理时因缩放方式不同带来的分布偏移。
归一化参数建议直接沿用预训练权重的默认值:mean 为[0.485, 0.456, 0.406],std 为[0.229, 0.224, 0.225]。你可以因为植物叶片颜色重要而减少颜色抖动,但不要轻易改归一化均值方差。改了就相当于让预训练卷积核看到另一种色彩分布,冻结训练时还能勉强收敛,解冻后很容易出现验证指标先升后崩。常见做法是训练增强里加轻微ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.02),色相幅度不要大;水平翻转对叶片通常安全,垂直翻转要谨慎,因为叶脉方向和病斑位置可能带语义。
| 环节 | 输入尺寸 | 关键参数 | 说明 |
|---|---|---|---|
| 训练 | RandomResizedCrop 224 | scale=(0.7,1.0) | 保留主体,避免过度裁掉病斑 |
| 验证 | Resize 256 + CenterCrop 224 | 插值默认 | 与推理严格一致 |
| 推理 | Resize 256 + CenterCrop 224 | 不随机 | 单图和批量共用同一套 transform |
| 归一化 | 224×224×3 | 预训练 mean/std | 不要随意替换 |
如果显存吃紧,可以把训练输入降到 192 或 160,但验证和推理也要同步降,不要训练 224、推理 160。ResNet152 对输入尺寸敏感,尤其是小病斑、小叶脉这类细粒度特征,降太多会直接损失判别力。显存不够优先降 batch size 或用混合精度,不要先砍输入分辨率。
2.3 用 ImageFolder 固定 class_to_idx 并保存 classes.json
ImageFolder 会按文件夹名排序生成class_to_idx,这个映射在训练集和验证集之间必须一致。问题在于:如果你后来往训练目录里加了一个新类别文件夹,或者验证集目录名大小写不同,映射就可能变。训练时标签 0 是类别 A,推理时标签 0 变成类别 B,模型输出看着正常,实际全错。这类问题在植物分类项目里很常见,因为类别名经常带品种、批次、采集日期,人工改名后忘了同步。
import json from pathlib import Path from torchvision import datasets, transforms from torch.utils.data import DataLoader train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.02), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) val_tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) train_ds = datasets.ImageFolder("data/plant/train", transform=train_tf) val_ds = datasets.ImageFolder("data/plant/val", transform=val_tf) assert train_ds.class_to_idx == val_ds.class_to_idx, "训练/验证类别映射不一致" classes = train_ds.classes Path("classes.json").write_text( json.dumps({"classes": classes, "class_to_idx": train_ds.class_to_idx}, ensure_ascii=False, indent=2), encoding="utf-8", ) train_loader = DataLoader(train_ds, batch_size=16, shuffle=True, num_workers=4, pin_memory=True, drop_last=True) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4, pin_memory=True)这段代码先构造两套 transform,训练集带随机裁剪、翻转和轻微颜色扰动,验证集只做确定性的缩放裁剪。assert那行是数据契约的保险丝:训练集和验证集的class_to_idx不一致就直接停,不要带着错位标签继续跑。classes.json保存类别列表和映射,后面推理脚本只认这个文件,不再依赖目录当前状态。batch_size=16是 ResNet152 在 224 输入下的保守起点,显存足够再往上加;num_workers按 CPU 核数调整,Windows 环境下如果 DataLoader 卡住,先把 workers 设成 0 排查。
类别不平衡时,不要只在损失函数里拍一个权重。可以用WeightedRandomSampler让每类被采样的概率接近,也可以给 CrossEntropyLoss 传 class weights。两者选一个即可,同时用容易让训练节奏变得难以判断。验证集必须保持原始分布,不要对验证集做重采样,否则指标会虚高。
3. 迁移学习怎么接:ResNet152 分类头替换、冻结策略与分层学习率
3.1 ResNet152 深但小数据更该迁移
ResNet152 的层数深,直接从小规模植物图像从零训练,常见结果是训练集 loss 很快降下去,验证集准确率卡住甚至倒退。不是模型不够强,而是数据量撑不起这么多参数。迁移学习的思路是保留预训练卷积层提取边缘、纹理、颜色块的能力,只把最后的分类头换成本地类别数,再根据数据量决定解冻多少层。植物叶片识别和通用物体识别在低层特征上有大量重叠,纹理、叶缘、病斑形态都能复用,所以 ResNet152 在这里的优势是特征表达够深,代价是显存和训练时间更高。
选 ResNet152 而不是更浅的骨干,通常是因为类别细粒度较高、类间差异小,比如不同品种的叶片、不同病害阶段的斑块。如果数据只有几百张、类别差异又很明显,ResNet50 甚至更小的模型可能更稳。资源包给的是 ResNet152,不代表所有场景都要硬上;先看验证集规模,再决定是否换骨干。换骨干时分类头替换、冻结策略、输入管线都可以沿用,这也是把工程写成配置的好处。
3.2 替换 fc 之后先打印可训练参数
ResNet152 的最后一层全连接层默认输出 1000 类,迁移到植物分类要替换成自己的类别数。替换时不要只写model.fc = nn.Linear(2048, num_classes)就结束,先把可训练参数名和形状打印出来,确认没有漏掉旧分类头,也确认新分类头初始化正常。常见错误是加载权重时strict=True,结果因为 fc 形状不匹配直接报错;或者先加载权重再替换 fc,把刚训练好的头又覆盖掉。
import torch import torch.nn as nn from torchvision import models def build_resnet152(num_classes, pretrained=True): try: weights = models.ResNet152_Weights.DEFAULT if pretrained else None model = models.resnet152(weights=weights) except AttributeError: # 兼容旧接口:没有 weights 参数时退回 pretrained model = models.resnet152(pretrained=pretrained) in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) return model num_classes = 12 # 改成你的类别数 model = build_resnet152(num_classes, pretrained=True) for name, p in model.named_parameters(): if p.requires_grad: print(name, tuple(p.shape))这段代码先按当前 torchvision 的weights接口取预训练权重,取不到再退回旧pretrained参数,避免换环境就报错。in_features从原模型读,不硬编码,ResNet152 这里是 2048,但写死没有好处。替换model.fc后,新全连接层默认requires_grad=True;打印可训练参数时你应该看到fc.weight和fc.bias,形状分别是(num_classes, 2048)和(num_classes,)。如果还看到旧分类头的参数名,说明替换顺序有问题。
加载自己保存的 checkpoint 时,先构建同类别数的模型,再load_state_dict。如果只是加载预训练骨干,可以用strict=False并检查缺失键和多余键,不要看到没报错就以为加载成功。missing_keys里出现大量layer开头,通常意味着权重根本没对上。
3.3 冻结 backbone 与解冻 layer4 的两段式训练
小数据迁移学习我一般分两段:第一段冻结除 fc 外的所有参数,只训练分类头,让新头先适应特征分布;第二段解冻layer4和 fc,用更小的学习率微调高层特征。第一段 epoch 不用多,3 到 5 轮足够,验证集 macro F1 不再明显上升就进入第二段。第二段不要一上来解冻全部层,ResNet152 全部解冻对小数据很容易过拟合,显存也会明显增加。
冻结时有一个容易忽略的细节:把参数requires_grad设为 False 后,BatchNorm 层在model.train()模式下仍会更新 running mean 和 running var。这会让冻结阶段的高层统计量发生漂移,后面解冻时出现指标突然崩。常见做法是在每个训练 epoch 开始前,把所有 BatchNorm 层设成 eval 模式,只保留 Dropout 的训练行为;ResNet152 里 Dropout 很少,主要就是 BN。
import torch.nn as nn def set_freeze(model, freeze): for name, p in model.named_parameters(): if name.startswith("fc."): p.requires_grad = True else: p.requires_grad = not freeze def bn_eval_when_frozen(model, frozen=True): if not frozen: return for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.eval() def make_param_groups(model, head_lr=3e-4, backbone_lr=3e-5, wd=1e-4): head, backbone = [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue if name.startswith("fc."): head.append(p) else: backbone.append(p) groups = [{"params": head, "lr": head_lr, "weight_decay": wd}] if backbone: groups.append({"params": backbone, "lr": backbone_lr, "weight_decay": wd}) return groupsset_freeze(model, True)只放开 fc,set_freeze(model, False)全部放开;实际两段式训练里第二段可以只解冻layer4,把layer1到layer3继续冻结,这样显存和过拟合风险都更可控。bn_eval_when_frozen要在每个 epoch 的model.train()之后调用,否则 BN 又回到训练模式。make_param_groups把 head 和 backbone 分成不同学习率组,head 用3e-4,backbone 用3e-5,这是 AdamW 下比较稳的起点;如果你用 SGD,学习率要换一个量级重新试,不要把 AdamW 的值直接套过去。
第二段解冻时,学习率调度建议带一点 warmup,尤其是从冻结切到解冻的那几个 step。没有 warmup 时,layer4的梯度第一次回传可能很大,训练 loss 会突然抖一下。简单做法是前 200 个 step 线性从 0 升到设定学习率,再接 cosine。这个细节不写进模型结构,但很影响可复现性。
4. 训练脚本落地:损失函数、优化器、早停和可恢复的检查点
4.1 一个能跑完的训练循环骨架
训练循环不要写成“只要 loss 降就保存”。植物分类常见的类别不平衡会让 accuracy 看起来不错,但少数类全错。验证指标至少同时记录 accuracy 和 macro F1,保存模型时按 macro F1 选最优。下面这个骨架包含混合精度、梯度裁剪、训练/验证切换和早停计数,代码不依赖特定版本号,按你环境里的 torch 接口跑。
import copy import torch import torch.nn as nn from sklearn.metrics import f1_score, accuracy_score def train_one_epoch(model, loader, criterion, optimizer, scaler, device, frozen=True, grad_clip=5.0): model.train() if frozen: for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.eval() total_loss, all_preds, all_targets = 0.0, [], [] for images, targets in loader: images = images.to(device, non_blocking=True) targets = targets.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) with torch.cuda.amp.autocast(enabled=scaler.is_enabled()): logits = model(images) loss = criterion(logits, targets) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) scaler.step(optimizer) scaler.update() total_loss += loss.item() * images.size(0) preds = logits.argmax(dim=1) all_preds.extend(preds.detach().cpu().tolist()) all_targets.extend(targets.detach().cpu().tolist()) return (total_loss / len(loader.dataset), accuracy_score(all_targets, all_preds), f1_score(all_targets, all_preds, average="macro")) @torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, all_preds, all_targets = 0.0, [], [] for images, targets in loader: images = images.to(device, non_blocking=True) targets = targets.to(device, non_blocking=True) logits = model(images) loss = criterion(logits, targets) total_loss += loss.item() * images.size(0) all_preds.extend(logits.argmax(dim=1).cpu().tolist()) all_targets.extend(targets.cpu().tolist()) return (total_loss / len(loader.dataset), accuracy_score(all_targets, all_preds), f1_score(all_targets, all_preds, average="macro"))train_one_epoch里frozen=True时把 BN 设 eval,解决冻结阶段统计量漂移;scaler在 CPU 环境下可以传torch.cuda.amp.GradScaler(enabled=False),代码不会因为没 GPU 直接崩。clip_grad_norm_的grad_clip=5.0是防止解冻初期梯度爆炸的保险,不是越多越好。evaluate用@torch.no_grad()关掉梯度,验证集 batch size 可以比训练大,因为不存激活。
损失函数先用nn.CrossEntropyLoss(label_smoothing=0.1)。标签平滑对植物分类这种可能有人工标注噪声的数据有帮助,尤其是病斑边界模糊、类别间有过渡状态时。如果类别严重不平衡,再给weight传类别权重,但不要把 label smoothing 和过大的类别权重一起叠,训练会变得很钝。优化器优先 AdamW,weight_decay=1e-4;数据量很大、调参经验足时再试 SGD momentum。
4.2 batch size、学习率和 weight decay 怎么改
ResNet152 在 224 输入下显存占用不低,batch size 16 是保守值。显存够就试 32,训练更稳但每轮迭代次数减少;显存不够不要硬扛,用梯度累积把accum_steps=2或 4 加上去。梯度累积时 loss 要除以累积步数,optimizer.step()和scaler.update()只在累积边界执行,否则等效学习率会变大。
| 参数 | 保守起点 | 可调范围 | 调整依据 |
|---|---|---|---|
| batch size | 16 | 8~32 | 显存和梯度噪声 |
| head lr | 3e-4 | 1e-4~1e-3 | 新分类头收敛速度 |
| backbone lr | 3e-5 | 1e-5~1e-4 | 解冻后验证指标是否抖 |
| weight decay | 1e-4 | 1e-5~1e-3 | 过拟合程度 |
| epochs | 30 | 20~60 | 早停触发位置 |
| patience | 6 | 4~10 | 验证集规模 |
学习率不要只看训练 loss。head 学习率太大时,训练 loss 降得很快,验证 macro F1 来回摆;backbone 学习率太大时,解冻后的第一轮验证指标经常直接掉。我的习惯是第二段先跑 2 个 epoch 看验证曲线,如果比第一段末尾还低,就把 backbone lr 再降一半。weight decay 对 ResNet152 这种深模型有用,但不要用 weight decay 去救标签错误,标签错的时候正则只会让模型更保守。
drop_last=True在训练集样本数不能被 batch size 整除时丢掉最后一批,避免 BatchNorm 在极小 batch 上统计量不稳。验证集不要 drop_last,否则每轮指标会随丢掉的样本波动。pin_memory=True在 GPU 环境能加快主机到设备拷贝,CPU 环境可以关掉。
4.3 验证指标与 checkpoint 里该存什么
checkpoint 不要只存model.state_dict()。至少存model_state、optimizer_state、scheduler_state、epoch、best_f1、classes、input_size、mean、std。植物分类项目换人接手时,最容易缺的是类别顺序和预处理参数;只给一个权重文件,推理脚本只能靠猜,猜错就是全盘错。把classes.json的内容一起塞进 checkpoint,推理时先读 checkpoint 再构建模型,能省很多后悔药。
def save_checkpoint(path, model, optimizer, scheduler, epoch, best_f1, classes, input_size=224): torch.save({ "model_state": model.state_dict(), "optimizer_state": optimizer.state_dict(), "scheduler_state": scheduler.state_dict() if scheduler else None, "epoch": epoch, "best_f1": best_f1, "classes": classes, "input_size": input_size, "mean": [0.485, 0.456, 0.406], "std": [0.229, 0.224, 0.225], }, path) # 训练循环里的保存逻辑示意 best_f1, patience_counter = -1.0, 0 for epoch in range(start_epoch, epochs): tr_loss, tr_acc, tr_f1 = train_one_epoch( model, train_loader, criterion, optimizer, scaler, device, frozen=is_frozen ) va_loss, va_acc, va_f1 = evaluate(model, val_loader, criterion, device) if va_f1 > best_f1 + 1e-4: best_f1 = va_f1 patience_counter = 0 save_checkpoint("best.pt", model, optimizer, scheduler, epoch, best_f1, train_ds.classes) else: patience_counter += 1 if patience_counter >= 6: break保存逻辑按 macro F1 判断,1e-4是防止浮点噪声触发保存。patience_counter到 6 就停,验证集很小的时候可以放宽到 8,但不要无限等。断点恢复时先加载 checkpoint,再恢复 optimizer 和 scheduler,最后从epoch + 1继续。只恢复模型权重不恢复优化器状态,学习率调度会从第一步重新开始,训练曲线会对不上。
训练日志至少每轮打印train_loss/train_f1/val_loss/val_f1/lr。如果 val_loss 持续升、val_f1 不升,说明过拟合或学习率过大;如果 train_f1 和 val_f1 都低,先查标签和预处理,不要先加 epoch。深度学习训练曲线有时很玄学,但数据契约、类别映射、预处理对齐这三件事做完,大部分“玄学”都会变成可解释的排查项。
5. 推理与排查:单图预测、批量导出和五个高频翻车点
5.1 单图推理:预处理必须复用验证变换
推理脚本最常见的错误是“训练用了归一化,推理只做了 ToTensor”。模型看到的输入分布变了,输出置信度会整体偏移,表现就是所有图片都预测成同一类,或者 top1 分数很低但 top2 看起来合理。单图推理必须复用验证集的Resize(256) + CenterCrop(224) + Normalize,并且从 checkpoint 里读classes,不要从当前目录重新扫。
import torch from PIL import Image from torchvision import transforms def load_model_from_ckpt(ckpt_path, device="cuda"): ckpt = torch.load(ckpt_path, map_location="cpu") classes = ckpt["classes"] model = build_resnet152(len(classes), pretrained=False) model.load_state_dict(ckpt["model_state"]) model.to(device).eval() return model, classes, ckpt def predict_one(model, classes, image_path, device="cuda", topk=3): tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) image = Image.open(image_path).convert("RGB") x = tf(image).unsqueeze(0).to(device) with torch.no_grad(): probs = torch.softmax(model(x), dim=1)[0] scores, idx = probs.topk(min(topk, len(classes))) return [(classes[i], float(s)) for s, i in zip(scores, idx)] model, classes, ckpt = load_model_from_ckpt("best.pt", device="cpu") print(predict_one(model, classes, "data/plant/test/sample.jpg", device="cpu"))load_model_from_ckpt用pretrained=False构建同类别数模型,再加载训练权重,避免推理时又去下载预训练权重。map_location="cpu"让脚本在没有 GPU 的机器上也能加载,再决定是否.to(device)。predict_one里convert("RGB")处理带 alpha 通道的 PNG,unsqueeze(0)补 batch 维,softmax后取 topk。输出的分数是模型置信度,不是校准后的概率,阈值判断要谨慎。
5.2 批量预测与混淆矩阵导出
批量预测不要用for循环一张张走模型,DataLoader 能批处理就批处理。需要保留原始路径,方便人工复核。ImageFolder 的samples里有路径和标签,但推理目录如果没有子文件夹,就自己写一个只读图片的 Dataset,返回路径和 tensor。预测结果输出 CSV,字段至少包含path,pred,score,label,有标签时顺便算混淆矩阵。
import csv from pathlib import Path from torch.utils.data import Dataset, DataLoader from sklearn.metrics import confusion_matrix, classification_report class ImagePathDataset(Dataset): def __init__(self, paths, tf): self.paths = [str(p) for p in paths] self.tf = tf def __len__(self): return len(self.paths) def __getitem__(self, i): from PIL import Image img = Image.open(self.paths[i]).convert("RGB") return self.tf(img), self.paths[i] val_tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) paths = sorted(Path("data/plant/test").rglob("*.jpg")) ds = ImagePathDataset(paths, val_tf) dl = DataLoader(ds, batch_size=32, shuffle=False, num_workers=0) rows, y_true, y_pred = [], [], [] model.eval() with torch.no_grad(): for images, batch_paths in dl: probs = torch.softmax(model(images.to("cpu")), dim=1) scores, idx = probs.max(dim=1) for p, i, s in zip(batch_paths, idx.tolist(), scores.tolist()): rows.append({"path": p, "pred": classes[i], "score": s}) with open("predictions.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["path", "pred", "score"]) writer.writeheader() writer.writerows(rows)这段批量脚本只做预测,不依赖测试目录的类别结构;num_workers=0是为了避免某些环境下多进程读图卡住,数据量大再往上加。utf-8-sig让 CSV 在常见表格软件里打开不乱码。要算混淆矩阵时,要求测试目录本身有类别子文件夹,用 ImageFolder 或从路径里解析真实标签,再调confusion_matrix(y_true, y_pred, labels=classes)。混淆矩阵不要只看图,导出 CSV 后按行看召回率,少数类被错分到哪个类一目了然。
5.3 五个高频翻车点:现象、原因、解决
现象一:训练 loss 一直降,验证准确率不动,预测全是一个类。
原因通常是训练集和验证集类别映射不一致,或者推理时用了另一套预处理。ImageFolder 按目录名排序,训练和验证目录只要有一个类别名不同,标签就错位;推理时如果只做 ToTensor 没做 Normalize,模型输入分布直接变。解决是把classes.json作为唯一标签源,训练、验证、推理都读同一份;推理脚本里打印 tensor 的 min/max/mean,和训练时的 batch 统计对比,差太多就查 transform。
现象二:冻结阶段正常,解冻 layer4 后第一轮验证指标突然掉。
原因多半是 backbone 学习率太大,或者冻结阶段 BatchNorm 的 running stats 被训练集小 batch 带偏。解决是解冻后 backbone lr 先降到 head 的十分之一,前 200 个 step 加 warmup;冻结阶段每个 epoch 把 BN 设 eval。还有一个常见原因是解冻时把layer1到layer3也放开了,小数据下高层特征被快速覆盖。先只解冻layer4,验证稳定后再考虑全部解冻。
现象三:训练刚开始就显存爆炸,batch size 降到 1 才能跑。
ResNet152 在 224 输入下激活占用高,batch 32 很容易超。先确认没有在验证阶段保留梯度,evaluate必须加torch.no_grad();再开混合精度,把 batch 降到 8 或 16;还不够就用梯度累积,把accum_steps=4等效成更大的 batch。不要为了省显存把输入降到 96 还指望小病斑识别,分辨率损失不是调参能补回来的。
现象四:验证集指标高得离谱,上线后一塌糊涂。
植物图像经常同一植株、同一叶片拍多张,随机划分会把相似图片分到训练和验证两边,模型其实在背样本。解决是按植株 ID、采集批次或拍摄时间做分组划分,同一组只进训练或验证。没有分组信息时,至少按文件名前缀粗分,并保留一个完全独立的测试集。测试集只在最后跑一次,不要拿测试集调早停。
现象五:换一台机器推理,类别名全乱或路径读不到。
原因通常是推理脚本重新扫目录生成类别列表,而目录里多了临时文件夹;或者路径里带中文、空格,脚本用字符串拼接读文件失败。解决是推理只读 checkpoint 里的classes,路径统一用pathlib.Path,CSV 用 UTF-8 写出。换机器前把best.pt、classes.json和一张样例图放在同一目录,先跑单图预测,确认输出类别名和训练时一致,再跑批量。
6. 把模型交付出去:TTA、拒识阈值和一次可复现的验收
训练到best.pt只是半成品,交付前我一般做三件事:轻量 TTA、拒识阈值、固定验收集。TTA 不要堆太多,植物分类用原图和水平翻转两张平均就够了,垂直翻转、多尺度裁剪容易把不确定性放大。代码上就是推理时对同一张图做两次 transform,softmax 后取平均,再 argmax。收益通常小于数据清洗,但比单纯调阈值稳。
拒识阈值解决“模型必须给一个答案”的问题。业务里遇到不属于训练类别的植物,模型仍会输出高置信度错误类。做法是设一个max_prob阈值,低于阈值输出unknown,阈值在验证集上按目标拒识率扫,不要拍脑袋定 0.9。表格里这三个动作的边界要清楚:
| 动作 | 目的 | 关键参数 | 适用边界 |
|---|---|---|---|
| 水平翻转 TTA | 降低单次裁剪随机性 | 2 视图平均 | 叶片方向不敏感时 |
| 拒识阈值 | 拦截分布外样本 | max_prob 阈值 | 验证集有 unknown 样本时 |
| TorchScript 导出 |
本文还有配套的精品资源,点击获取