
简介蝴蝶种类识别与分类是图像分类领域常见的实践课题项目以PaddlePaddle框架为基础结合ResNet残差网络实现了一套完整的方案适合作为高校Python课程设计或期末大作业。项目已获导师指导并得到97分高分代码结构清晰可直接运行使用。压缩包共包含14个文件以Python脚本、Markdown说明文档、TXT说明及多张训练与验证过程图像为主其中主脚本实现了数据预处理、模型构建、训练与评估流程配图展示了损失曲线、准确率变化及分类结果。资源整体仅3.29MB轻量便携便于课后复现与文档撰写参考。目前已有177人浏览学习说明其实用性得到一定认可。下载后无需额外修改即可体验从数据集加载到ResNet模型训练的完整流程无论是用于答辩演示还是作为深度学习入门练习均具备较高参考价值适合希望快速搭建图像识别项目的学生参考使用。1. 蝴蝶分类项目到底在解决什么问题为什么 Paddle ResNet 是首选组合一个蝴蝶识别与分类项目表面是图像分类实际考的是三件事数据组织能力、迁移学习能力和训练调参能力。蝴蝶种类多、翅膀斑纹相似度高属于典型的细粒度识别场景拿 VGG 硬训很容易过拟合用 ResNet 残差网络配合 Paddle 的预训练权重能把难度拉回可控范围。Paddle 的好处在于全流程闭环训练、量化、导出、部署一套接口对刚接触 Python 图像方向的同学尤其友好。这个方案适合做毕业设计、课程设计以及想从分类任务入手深度学习的初学者你只要准备好分好类的蝴蝶图片剩下的目录结构、预处理和训练参数按下面这套流程接上就行。2. 搭好蝴蝶数据集与预处理目录结构、标签映射和 Transform 参数数据是这类项目的第一个黑匣子。拿到源码包先不急着跑训练第一步应该是把数据集按 Paddle 的习惯理成 ImageNet 风格的目录train 下每个类别一个文件夹文件夹名就是类别标签。蝴蝶数据集常见的坑是各类别图片数量差距很大例如某种蝴蝶一两百张另一种只有二十张这会直接放大后面的过拟合问题所以划分时要固定随机种子划分结果也要存下来。2.1 训练/验证集怎么划分才不会标签泄露标签泄露听起来玄学实际上大多数是划分时手滑造成的。常见做法是先把所有图片按类别放到 train 和 val 两个目录再检查两边没有重复文件名如果你复用网上现成的蝴蝶数据集尤其要注意原发布者有没有把同一条蝴蝶的连拍照片同时塞进训练集和验证集。我一般会在划分脚本里先对文件名做 MD5 去重再把去重结果按 8:2 随机划分seed 固定保证任何人重跑都一样。划分脚本可以写成一个独立的 split_data.py内容就是遍历源目录、在目标目录里生成拷贝。软链在 Windows 上不一定可用跨平台就别用 os.symlink直接拷贝最稳。验证集比例不要低于 15%蝴蝶任务本身类别多、单类样本少验证集太小会导致调参时看到的 val acc 起伏很大根本没法定判断到底是模型问题还是数据划分问题。2.2 预处理流水线从 224 到归一化顺序错了模型不收敛预处理顺序对初学者是最容易翻车的地方。常见做法是训练阶段用 Resize((256,256)) 再接 RandomCrop((224,224))这样每次随机裁一小块相当于免费做了一次平移增强如果直接 Resize((224,224))随机裁剪的余地就没了。翻转也可以加蝴蝶左右对称RandomHorizontalFlip(0.5) 不会破坏语义算是对蝴蝶这种左右镜像仍然同类的天然适配。归一化的 mean 和 std 直接用 ImageNet 那组 [0.485, 0.456, 0.406] / [0.229, 0.224, 0.225]即使你的数据集不是 ImageNet迁移学习的惯例也比零均值归一化效果稳。要特别注意的是 Paddle 的 ToTensor 会把 HWC 转成 CHWNormalize 必须放在 ToTensor 之后且输入图片要用 RGB 格式读取如果某个第三方库用 OpenCV 读图OpenCV 给出的是 BGR 通道顺序忘记转换会让模型学出一堆和颜色语义无关的特征来。2.3 用 Paddle Dataset 读取蝴蝶图片的最小实现写 Dataset 时不要把所有图片一次性读进内存蝴蝶图库即便只有几千张一次性 resize 转矩阵也很耗内存。按需读取虽然慢一点但胜在稳定。下面这个类就是常见的最小实现直接放在 train.py 开头也能跑import os from PIL import Image import paddle from paddle.io import Dataset class ButterflyDataset(Dataset): def __init__(self, root, splittrain, transformNone): super().__init__() self.root os.path.join(root, split) self.classes sorted(os.listdir(self.root)) self.class_to_id {name: idx for idx, name in enumerate(self.classes)} self.samples [] for cls_name in self.classes: cls_dir os.path.join(self.root, cls_name) for fname in os.listdir(cls_dir): if fname.lower().endswith((.jpg, .jpeg, .png)): self.samples.append((os.path.join(cls_dir, fname), self.class_to_id[cls_name])) self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] # 统一转成 RGB避免灰度图或带 alpha 通道的图片在训练时崩掉 img Image.open(path).convert(RGB) if self.transform: img self.transform(img) # Paddle 的分类损失要求 label 是 int64 return img, paddle.to_tensor(label, dtypeint64)这段代码的逻辑核心是三件事遍历类别文件夹建立 label 映射、过滤掉非图片文件、在getitem里按路径读图而不是预加载。label 转成 int64 是配合 nn.CrossEntropyLoss 的固定要求漏掉这一步运行时不会立刻报错但 loss 会变成 nan这是很多人查了很久都找不到原因的地方。配套的 transform 是这样写的from paddle.vision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop((224, 224)), transforms.RandomHorizontalFlip(0.5), transforms.ToTensor(), transforms.RandomErasing(prob0.5, scale(0.02, 0.2)), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) eval_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ])RandomErasing 是 Paddle 里对图片块做随机遮挡的增强prob0.5 表示每张训练图有 50% 概率被随机挡住一块遮挡面积在 2% 到 20% 之间配合 ResNet 的强拟合能力能明显降低过拟合。eval 阶段不要用 RandomCrop 和 RandomErasing否则验证集准确率会被干扰得没参考价值每次测出来还不一样容易误判模型好坏。3. 用 Paddle 训练 ResNet50模型组装、优化器与关键超参数先说明为什么是 ResNet50 而不是 ResNet18 或 ResNet101蝴蝶分类是细粒度任务类别之间差异集中在翅膀纹路、颜色分布这些局部细节ResNet18 的容量在几百张图的小数据集上不够用ResNet101 又容易过拟合且训练明显更慢ResNet50 在容量和收敛速度之间最均衡。如果你在 VSCode 里用 CPU 跑ResNet50 也能接受只是每个 epoch 要耐心等。3.1 ResNet 残差结构为什么适合细粒度识别任务ResNet 的核心是残差块里的 shortcut 连接把输入直接加到卷积输出上恒等映射让梯度能跨层回传。对蝴蝶识别来说这个设计直接解决两个问题一是网络深了之后梯度消失导致前几层学不到翅膀边缘特征shortcut 让浅层信息一路保留到最后二是细粒度任务的噪声大恒等路径相当于给网络一个“什么都不改也能过”的保底选项模型不需要在一开始就承担巨大风险去拟合差异这降低了训练初期的震荡。预训练权重是另一个关键。Paddle 的 resnet50(pretrainedTrue) 默认加载的是 ImageNet 预训练模型虽然 ImageNet 里没有蝴蝶专属类别但它学到的纹理、边缘、颜色响应足够作为蝴蝶特征的底座。没有预训练直接从零训蝴蝶这种样本量小的数据集几乎不可能收敛到可用的准确率。这就是为什么这类高分项目的源码里几乎都会在模型定义处出现 pretrained 参数。3.2 加载 ResNet50 并改分类头注意最后一层输出维度直接调用 paddle.vision.models 里的 resnet50 是最省事的做法。关键改动在最后一个全连接层ResNet50 默认输出是 1000 维对应 ImageNet 的类数必须替换成蝴蝶类别数。很多翻车现场就是只改了输出维度忘了在推理时把 BN 层设成 eval 状态导致预测结果一团糟。import paddle import paddle.nn as nn from paddle.vision.models import resnet50 class ButterflyResNet(nn.Layer): def __init__(self, num_classes50): super().__init__() backbone resnet50(pretrainedTrue) in_features backbone.fc.in_features backbone.fc nn.Linear(in_features, num_classes) self.net backbone def forward(self, x): return self.net(x) model ButterflyResNet(num_classes50)这里 pretrainedTrue 会从 Paddle 的模型库下载权重第一次跑需要联网。如果你的网络环境下载慢可以先用下载工具手动把压缩包拖到 Paddle 的本地缓存目录下Paddle 检测到本地文件以后就不会再重复下载。替换全连接层时in_features 是从原 fc 层读取的这样即使以后换 ResNet101 做底座这段代码也不用改。3.3 训练配置学习率、batch、warmup 和 EMA 这样调训练配置我直接用迁移学习最常见的一套优化器选 SGD Momentum学习率 1e-3batch size 32。Adam 在 ResNet 上不是不能用但对这类小数据集 Adam 的收敛点往往不如 SGD 稳最终 val acc 会差两三个点。epochs 30 base_lr 0.001 batch_size 32 # T_max 按更新步数理解按 epoch 步进时要换算成 epochs * steps_per_epoch scheduler paddle.optimizer.lr.LinearWarmup( learning_ratepaddle.optimizer.lr.CosineAnnealingDecay( learning_ratebase_lr, T_maxepochs * steps_per_epoch, eta_min1e-6), warmup_steps1000, start_lr1e-4, end_lrbase_lr, ) optimizer paddle.optimizer.Momentum( parametersmodel.parameters(), learning_ratescheduler, momentum0.9, weight_decay5e-4, ) criterion nn.CrossEntropyLoss(label_smoothTrue)四个关键参数解释base_lr0.001 对微调场景是安全起点纯随机初始化用 0.01 会梯度爆炸warmup_steps1000 让前 1000 步学习率从 1e-4 慢慢爬到 1e-3防止一开始就给预训练权重太大的扰动CosineAnnealingDecay 把学习率按余弦曲线降到 1e-6后期小学习率让权重在最优解附近稳定下来weight_decay5e-4 是对权重的 L2 惩罚配合 RandomErasing 共同对抗过拟合。loss 函数加 label_smoothTrue 表示标签平滑它把硬标签从 0/1 改成 0.9/0.1 之类的软标签避免模型对训练集过于自信。蝴蝶任务类别数多模型很容易在某个歧义样本上输出极端概率标签平滑对这类问题收益很明显。EMA指数滑动平均在 Paddle 里是另一套 API训练过程中维护一份参数的滑动平均副本推理时用这份副本往往比最后一步的权重更稳。EMA 接口的写法在不同 Paddle 版本里差异比较大建议直接查你本机 paddle.version 对应的官方示例别照着旧博客抄。3.4 训练日志怎么看loss 不降先查这三个数训练时我习惯每个 epoch 打印五样东西train loss、train acc、val loss、val acc、当前 lr。for epoch in range(epochs): train_loss, train_acc run_epoch(model, train_loader, optimizer, criterion) val_acc, val_loss evaluate(model, val_loader, criterion) scheduler.step() print(fepoch {epoch:02d} | train_loss {train_loss:.4f} | train_acc {train_acc:.4f} | fval_loss {val_loss:.4f} | val_acc {val_acc:.4f} | lr {scheduler.get_lr():.6f})如果看到 loss 完全不降先查三处预处理里 Normalize 的 mean/std 是否生效ToTensor 是否漏了漏掉后模型拿 0-255 的整数喂进去BN 会把梯度放大到不可收敛模型是否调用了 model.train()Paddle 里没设 train 模式时 BN 的均值方差不更新训练 loss 会反复震荡label 分布是否正常如果某个类别只有一两张图而其他类有几百张CrossEntropy 会被多数类带偏loss 即便降下来 val acc 也上不去。4. 蝴蝶分类训练避坑指南五个典型翻车现场与排查这类项目真正让人花时间的不是写模型而是排错。下面五条都是实际遇到过的现象、原因、解决三步说清楚你照着排查就行。4.1 现象验证集准确率从第一步就异常高第一轮 epoch 跑完 val acc 直接 95% 以上看着很爽但完全是假象。最常见的原因是数据集划分时同一张图片在不同 split 里都出现了比如原数据集自带的多视图连拍照片同一只蝴蝶的正面和背面被分到训练和验证两个目录模型其实记住了图片本身而不是类别。解决方法是划分前按文件内容做一次 MD5 去重把 hash 相同的图片只保留一份同时固定随机种子重跑确认划分结果里没有重复文件名。另一个隐蔽原因是验证集太小比如只有 20 张图、里面恰好某类占了 18 张这时候准确率没有任何统计意义把验证集扩到 100 张以上再谈 val acc。4.2 现象loss 缓慢下降但 val acc 纹丝不动训练 loss 在降说明模型在学到东西但验证集上准确率没变化基本可以断定是过拟合或者分布不一致。先看验证集的预处理是否和训练一致如果训练用了 RandomErasing 但评估时忘了用 eval_transform验证结果会被随机遮挡干扰再看数据分布蝴蝶数据集的类别图片数量经常是长尾的头部类别占了大半样本模型只需要把少数几个高频类学好loss 就能降得很漂亮尾部类别一个都学不会。我一般会在训练阶段就统计一下每个类别的样本数把尾部类做复制增强或者用带权重的 CrossEntropy 把少数类的梯度权重提上来。4.3 现象训练速度越来越慢且内存占用持续上涨这通常是 DataLoader 的锅而不是模型。如果 num_workers 设得过大Windows 下子进程反复创建会拖慢速度而且在 Python 脚本里如果不加 ifname main: 保护多进程 DataLoader 会在训练脚本被重新导入时把训练逻辑再跑一遍出现内存只涨不降。解决方式很简单Windows 下把 num_workers 设为 0 或 1并把训练代码全部包在 ifname main: 里。还有一个常见做法是在 Dataset 的init里将图片统一转成 RGB 并缩放到 256虽然多花了一点预处理时间但省掉了训练循环里反复解码 JPEG 的抖动。4.4 现象预测结果总是同一个类别模型在验证集上有模有样拿去预测新图结果全是“某种蝴蝶”先检查类目映射。如果你在训练时用的是 ButterflyDataset 里的 class_to_id推理脚本里类名列表必须用同一份排序结果不能手写一个和训练时顺序不一致的列表否则 50 个类别错位之后预测全是第 17 类的现象就会冒出来。其次是数据不均衡训练集里某一类占比超过一半模型学习到“输出这一类损失最小”在新图上无脑偏向它。解决方法是按类别做重采样或者给少数类提高损失权重。如果这两项都排除了再检查全连接层是否真的被替换过没替换的话输出一直是 1000 维softmax 后的索引和蝴蝶类别对不上。4.5 现象Windows 下 DataLoader 反复报错Paddle 在 Windows 上最常见的问题是 num_workers 相关报错和多进程崩溃报错信息往往是一大段 RuntimeError 加 Traceback。原因在于 Windows 默认用 spawn 方式启动子进程脚本顶层的训练代码会被重新执行一次。解决方法是把训练、评估、保存全部放到 ifname main: 里面数据加载的 num_workers 先设 0 确保能跑通再逐步调高。此外Paddle 在 Windows 上偶尔会报缺少 DLL 的错误通常和 Visual C 运行库未安装有关装一下运行库就能解决。5. 从训练到部署checkpoint 保存恢复、推理脚本与模型导出模型训练完不是结束还要解决“下次怎么加载”“新图怎么预测”“怎么给别人用”这三个问题。5.1 模型保存与恢复.pdparams 里到底存了什么训练过程中每过几个 epoch 就要保存一次 checkpoint不能只保存模型权重。Paddle 里 paddle.save 可以把模型参数、优化器状态、当前 epoch、类别名打包成一个字典这样恢复训练时学习率和优化器动量都能接上。state { model: model.state_dict(), optimizer: optimizer.state_dict(), epoch: epoch, class_to_id: dataset.class_to_id, } paddle.save(state, foutput/butterfly_epoch{epoch:02d}.pdparams)保存时把 class_to_id 一起存下来是很重要的一步。推理脚本里你必须知道训练用的类别顺序最稳的做法是从 checkpoint 里读而不是自己重新排序。恢复训练的代码同样简单state paddle.load(output/butterfly_epoch30.pdparams) model.set_state_dict(state[model]) optimizer.set_state_dict(state[optimizer]) start_epoch state[epoch] 1注意 set_state_dict 之前必须已经把模型构造出来并且 num_classes 要和保存时一致。中途改了类别数再加载旧权重最后一层 fc 的 shape 对不上会直接报错。如果想只加载 backbone 的权重做迁移可以用 set_state_dict 的变体或自己过滤 key但那样要小心 BatchNorm 参数是否齐全。5.2 单张图片推理从加载模型到输出 Top-5 类别推理脚本比训练脚本简单但一个容易忽略的细节是模型一定要先 eval而且要包在 paddle.no_grad 里。不然 BN 层会用动量更新dropout如果有也会随机屏蔽神经元预测结果每次都不一样。import paddle from PIL import Image def predict(model, image_path, class_names, topk5): model.eval() img Image.open(image_path).convert(RGB) img eval_transform(img) # 采用训练验证时同一套 eval_transform img img.unsqueeze(0) # 变成 [1, 3, 224, 224] with paddle.no_grad(): logits model(img) probs paddle.nn.functional.softmax(logits, axis1) top_idx paddle.argsort(probs, descendingTrue)[0][:topk].numpy() for i in top_idx: print(f{class_names[i]}: {probs[0][i].item():.4f})这里 eval_transform 是验证阶段定义的那一条不能拿训练用的带 RandomCrop 的管线来推理。Top-5 输出比只看最大值有用得多蝴蝶种类相似度很高如果第二第三名的置信度也接近第一名说明模型确实在犹豫这时候可以再检查一下输入图片的裁剪区域是否包含蝴蝶主体。5.3 导出可用于部署的 inference 模型to_static 与 input_spec如果你要把模型交给别的同学跑或者部署到服务端最好导出成静态图模型。Paddle 的 paddle.jit.to_static 能把动态图模型转成静态图再配合 paddle.jit.save 输出 .pdmodel 和 .pdiparams 两个文件。model.eval() input_spec paddle.static.InputSpec( shape[None, 3, 224, 224], dtypefloat32, nameimage) model paddle.jit.to_static(model, input_spec[input_spec]) paddle.jit.save(model, output/butterfly_inference)转静态图有几个注意点。InputSpec 的 batch 维写成 None 表示支持任意 batch 输入dtype 必须是 float32导出前一定确认模型在 eval 状态否则导出结果里的 BN 参数会变成不可复现的中间值。导出成功后用 paddle.inference 加载模型做一次前向对比动态图的预测结果两者的 softmax 输出应该完全一致这是验证导出是否成功的最直接方法import paddle.inference as paddle_infer config paddle_infer.Config(output/butterfly_inference.pdmodel, output/butterfly_inference.pdiparams) predictor paddle_infer.create_predictor(config) input_names predictor.get_input_names() input_tensor predictor.get_input_handle(input_names[0]) input_tensor.copy_from_cpu(sample_input.numpy()) predictor.run() output_names predictor.get_output_names() output_tensor predictor.get_output_handle(output_names[0]) output_data output_tensor.copy_to_cpu()这段代码用的是 Paddle Inference 的典型流程先创建 Config 加载模型文件再通过输入输出句柄拷贝数据、执行前向。复制输入数据前记得把 numpy 数组转成 float32形状要和 InputSpec 对齐否则会报维度不匹配的错误。6. 精度卡在 90% 怎么继续提升冻结层微调与 CutMix 的实测组合当你把上面这套流程跑通后val acc 大概会停在 85% 到 93% 之间。这时候再做两步就能往回拿几个点。第一步是冻结 backbone 微调 fc 层。用预训练权重时一开始就把全部层放开前期学习率稍大就会把已经学好的底层特征冲掉正确做法是第一段训练只更新最后几层。常见做法是把网络拆成 backbone 和 fcbackbone 设 stop_gradient 后只训练分类头for param in model.net.parameters(): param.stop_gradient True model.net.fc.stop_gradient False # 先训 5 个 epochlr1e-3 # 再解开 backbone 后两个 stage 的 stop_gradientlr 降到 3e-4继续训 10 个 epoch我一般把 stop_gradient 按层名过滤来解冻只解冻 stage3 和 stage4。第二段微调时学习率刻意比第一段低因为此时所有主干参数都在小范围调整学习率过大会直接让 loss 反弹。第二步是 CutMix。Paddle 的 transforms 里没有直接的 CutMix但在训练循环里按比例混合两张图很容易做按 0.5 概率从 batch 里取另一批图把一块矩形区域裁剪贴上去同时把标签按面积比例混合成软标签。配合前面已经开着的 label_smooth模型对翅膀局部遮挡的抗性会明显提升。两个技巧加在一起通常能让 val acc 再涨 2 到 4 个点。我自己第一次做类似项目时就是贪快直接全部层微调结果已经收敛的权重被大学习率冲得七零八落回滚 checkpoint 才救回来。现在我的习惯是优先保底第一段冻结、第二段低学习率、每个 epoch 存一次 checkpoint宁可多跑几轮也不让模型状态回不去。希望帮到你。本文还有配套的精品资源点击获取