简介:这是一份基于PyTorch的CNN蝴蝶识别实战项目,包含完整的Python源码与图片数据集,适合正在学习深度学习图像分类、希望上手PyTorch完整流程的开发者。压缩包内共408个文件,核心为3个Python脚本(数据集文本生成、模型训练、PyQt5界面推理),3个txt文本(含环境依赖说明requirements.txt),以及402张jpg蝴蝶样本图,整体大小约32.77MB。项目提供了清晰的三步式运行逻辑:先通过01脚本读取各类别图片并生成标签文本,再运行02脚本完成训练集与验证集的加载和模型训练,最后用03脚本启动可视化界面进行识别。数据集预处理环节加入了短边灰边填充和随机旋转增强,能有效扩充样本、提升模型泛化能力。当前已有96人学习下载,适合需要快速搭建图像分类Demo、理解数据预处理与PyTorch训练流程的入门及进阶学习者。
1. 从“花分类”到“蝴蝶识别”:CNN在细粒度图像分类上的第一课
很多人第一次做图像分类会选择猫狗识别或花卉识别,但蝴蝶识别更贴近真实业务:种类差异往往只体现在翅膀纹理的走向、边缘缺口和颜色块分布上,姿态、光照、背景变化都会让同一种蝴蝶呈现完全不同的像素。所以标题强调“CNN识别”而不是目标检测,任务是对整张图打类别标签,让卷积层自动提取判别特征。这篇文章基于Python + CNN深度学习项目,数据以图片类别目录组织。你会看到数据划分、图像增强、卷积网络搭建、训练评估,最后用预训练模型微调提升准确率。代码使用PyTorch,新手能按步骤复现,老手可对照参数边界和踩坑点。
2. 蝴蝶识别要先管好数据:图片数据集的组织与增强
拿到“蝴蝶数据集.zip”后,先别急着写模型。解压后通常是一个个文件夹,文件夹名就是类别名,里面是该类别的图片。常见做法是先按8:1:1随机分成train/val/test三个目录,而不是直接全量训练。CNN需要验证集来监控训练过程并决定是否保存模型,也需要测试集来评估在没见过的图片上的真实泛化能力。
2.1 数据集目录结构:训练集、验证集、测试集怎么分
我一般把输出目录设成data/train/类别名/、data/val/类别名/、data/test/类别名/。这样不需要额外标注文件,PyTorch的ImageFolder可以直接读取类别。划分脚本如下:
import os import random import shutil root = 'butterfly_dataset' out = 'data' val_ratio = 0.1 test_ratio = 0.1 random.seed(42) for cls in os.listdir(root): src = os.path.join(root, cls) if not os.path.isdir(src): continue imgs = [f for f in os.listdir(src) if f.lower().endswith(('.jpg', '.jpeg', '.png'))] random.shuffle(imgs) n_test = int(len(imgs) * test_ratio) n_val = int(len(imgs) * val_ratio) n_train = len(imgs) - n_val - n_test for split in ('train', 'val', 'test'): os.makedirs(os.path.join(out, split, cls), exist_ok=True) for i, img in enumerate(imgs): if i < n_train: split = 'train' elif i < n_train + n_val: split = 'val' else: split = 'test' shutil.copy(os.path.join(src, img), os.path.join(out, split, cls, img))代码先把每类图片随机打乱,再按样本数切片给三个集合。这里用copy而不是move,避免原始数据被误删。如果你的原始数据结构不是文件夹,而是一张CSV表,也可以按文件名前缀做映射,但目录结构更简洁,后面直接用ImageFolder不会出错。
划分比例不是固定的。如果数据集只有几百张,10%的测试集就太小,可以改成train 85%、val 10%、test 5%。更极端的情况会砍掉测试集,直接用val当测试,但那个做法会让模型选择用掉评估机会,最后报告的准确率有偏乐观的风险。蝴蝶数据集通常包含几十类,每类几百到几千张,按8:1:1一般够用。
还要留意类别列表排序。ImageFolder在加载train目录时会按文件夹名的字母顺序生成类别索引,如果之后又手动给test目录新增了类,类别索引就会错位。所以训练和评估必须使用同一个datasets.ImageFolder实例,或先把类别列表取出来保存。
2.2 图像增强:让CNN看到更多“蝴蝶family”
CNN虽然有平移不变性作为归纳偏置,但真实图片里的蝴蝶会在画面中处于不同位置、姿态、光线和遮挡状态。如果只用原始图片训练,模型很容易学到捷径,比如根据背景颜色判断种类,而不是根据蝴蝶本身的纹理。图像增强通过实时制造合理变体,强迫CNN去关注蝴蝶本身的特征。
下表是我在蝴蝶识别任务里比较常用的增强初值:
| 增强操作 | 推荐参数 | 作用 |
|---|---|---|
| RandomResizedCrop | size=224, scale=(0.7, 1.0) | 模拟远近和构图变化 |
| RandomHorizontalFlip | p=0.5 | 避免左右偏置 |
| ColorJitter | brightness=0.3, contrast=0.3, saturation=0.3, hue=0.05 | 模拟晨昏光照和色彩漂移 |
| RandomRotation | 通常不用 | 蝴蝶朝向有语义,不宜强转 |
随机旋转看起来是万能增强,但蝴蝶的头部朝向在人类视觉中是有先验的,如果测试集都是正着拍的,训练时大规模旋转反而会引入分布偏差。同理,超大角度的RandomResizedCrop会把蝴蝶切掉一半。增强参数应保持“合理困难”,而不是让模型学会在拼图碎片里猜类别。
from torchvision import transforms normalize = transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.3, hue=0.05), transforms.ToTensor(), normalize]) val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), normalize])验证集和测试集只做固定Resize和CenterCrop,不做随机增强,否则每次评估结果都会因为随机性产生波动。这里的Normalize使用ImageNet统计值,因为很多预训练模型的权重都是在ImageNet上训练出来的,后续如果要用ResNet,这个参数就不要改。若从头训练CNN,也可以自己算数据集的mean和std,但差别通常不大。
2.3 数据加载器与归一化参数
目录结构和transform准备好后,用ImageFolder和DataLoader接起来。ImageFolder会自动把data/train下的子目录名当成类别名,按字母顺序映射为0,1,2。
from torchvision import datasets from torch.utils.data import DataLoader train_loader = DataLoader( datasets.ImageFolder('data/train', transform=train_transform), batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader( datasets.ImageFolder('data/val', transform=val_transform), batch_size=32, shuffle=False, num_workers=4) test_loader = DataLoader( datasets.ImageFolder('data/test', transform=val_transform), batch_size=32, shuffle=False, num_workers=4)batch_size取决于GPU显存,16或32都是常用值。shuffle只在训练集打开,验证和测试集关闭。num_workers在Windows上建议设0,否则可能因为多进程启动问题卡住;Linux服务器设4到8能明显提升加载速度。pin_memory=True在数据拷贝到GPU时更快,但会占用固定内存,机器内存紧张时可以不设。
数据读到这里,还差一个关键的类别数。在每个epoch之前打印len(train_loader.dataset.classes),确认它和你的蝴蝶种类数一致。以前遇到过zip包内多了一个隐藏的__MACOSX目录,被当成一个类别,导致模型输出维度错误。数据部分就绪后,下一章搭建卷积网络。
3. 用Python搭建CNN模型:从卷积核到全连接层
3.1 为什么蝴蝶识别用CNN而不是全连接网络
把图片拉成一维向量丢给全连接网络,空间结构会立刻被打乱。128x128的彩色图输入就有49152个值,第一层隐藏层哪怕只有256个神经元,参数量也超过1200万,训练慢且极容易过拟合。CNN通过局部连接和权重共享解决这个问题:一个卷积核在整张图上滑动,所有空间位置共享同一套权重,既减少参数,又保留“相邻像素才相关”的图像先验。
蝴蝶识别属于细粒度图像分类,需要区分的特征经常是局部的:翅脉的走向、斑点排列、外缘缺口。浅层卷积核负责边缘和色块,深层卷积核把局部组合成完整的翅膀纹样。因此设计卷积网络时,通道数通常逐层增加,空间尺寸逐层缩小,让语义一步步抽象。
3.2 一个可复现的卷积网络结构
下面这个SimpleCNN是三层卷积加三层池化,最后接两层全连接。输入是224x224的RGB图,输出是num_classes类别。这个模型在CPU上也能完成训练,适合先跑通整体流程。
import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2) ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 28 * 28, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))第一层卷积输入3个通道,输出32个通道,kernel_size=3且padding=1会让特征图宽高保持不变;之后每经过一次MaxPool2d宽高减半。224经过三次池化变成28,因此Flatten后是1282828。如果输入尺寸改为256,这里的线性层维度要同步改为1283232,或换成AdaptiveAvgPool2d固定输出。
卷积层后面接ReLU已经是标配,池化层可以选MaxPool2d或AvgPool2d。MaxPool保留最强响应,适合边缘和纹理检测;AvgPool更适合全局信息。这里交替使用没有严格规定,只要记住池化会丢失位置信息,而分类任务本来就不需要精确位置,所以很安全。
下表对比从头训练小网络和迁移学习常用配置,方便在不同阶段切换:
| 配置项 | 从头训练小网络 | 迁移学习微调 |
|---|---|---|
| 网络深度 | 3层卷积 | ResNet18 |
| 首层卷积通道 | 32 | 64 |
| Dropout | 0.5 | 0.2 |
| 初始学习率 | 1e-3 | 1e-3(仅fc)/1e-4(解冻) |
| weight_decay | 1e-4 | 1e-4 |
3.3 损失函数、优化器与学习率参数
多分类任务默认用CrossEntropyLoss。PyTorch的CrossEntropyLoss把LogSoftmax和NLLLoss合在一起,所以模型输出层直接输出类别logits就好,不需要手动加Softmax。训练时加Softmax不仅多余,还会让梯度在反向传播中出现数值不稳定。
优化器在小型CNN上我习惯用Adam,初始学习率1e-3。它对学习率的敏感度低于SGD,但并非完全免疫。如果loss迟迟不降,把lr除以10;如果loss震荡不收敛,用0.0003再试。weight_decay是L2正则的一种实现,给大权重加惩罚,抑制过拟合。
import torch num_classes = len(train_loader.dataset.classes) model = SimpleCNN(num_classes=num_classes) criterion = torch.nn.CrossEntropyLoss() optimizer = torch.optim.Adam( model.parameters(), lr=1e-3, weight_decay=1e-4)如果数据集中某些类别样本很少,可以在CrossEntropyLoss里传入torch.tensor的权重向量。计算权重时通常是样本数越多权重越低,例如每个类别的权重设为总样本数除以该类别样本数,这样小类别的Loss贡献会被放大。Adam配合weight_decay也能用,但适合SGD的weight_decay取值范围和Adam场景并不完全一致,试出来的经验值多是在1e-4到3e-4之间。
如果想快速确认模型规模,运行下面一行会打印总参数量:
print(sum(p.numel() for p in model.parameters()))SimpleCNN参数量大约在250万左右,大头集中在最后两个全连接层。如果希望更轻量,把中间的256改成128;如果训练准确率不足,把128改成256或增加一层卷积。卷积神经网络的调整不是一次到位的,你要结合下一个epoch的日志再决定方向。
4. 训练与评估:跑通训练循环,看懂准确率和loss
4.1 训练循环与模型保存
训练循环本身不复杂,但模式容易写错。每个epoch分成训练阶段和验证阶段。训练阶段需要model.train()打开Dropout和BatchNorm的随机行为,验证阶段需要model.eval()关闭它们。每次迭代的关键顺序是:清零梯度、前向传播、计算损失、反向传播、更新参数。
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) best_acc = 0.0 epochs = 50 for epoch in range(epochs): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) out = model(images) loss = criterion(out, labels) optimizer.zero_grad() loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) train_loss = running_loss / len(train_loader.dataset) model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) out = model(images) _, pred = torch.max(out, 1) total += labels.size(0) correct += (pred == labels).sum().item() val_acc = correct / total print(f'Epoch {epoch+1:3d} | train_loss {train_loss:.4f} | ' f'val_acc {val_acc:.4f}') if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pth')running_loss在累加时乘上batch大小,最后除以总样本数,得到每个样本的平均损失。torch.max(out, 1)的第二个返回值是预测类别索引,pred == labels得到布尔张量,sum()统计正确数。验证过程包在torch.no_grad()里,避免为验证数据建立计算图浪费显存。
判断是否保存模型看的是val_acc而不是train_loss,因为训练损失持续下降并不代表泛化能力变好,验证集准确率才说明模型对没见过图片的表现。实际训练中常见这种曲线:训练损失一路走低,验证准确率到达某个峰值后不再上涨,后续epoch只在拟合训练集噪声。保存峰值权重之后,即使后面训练中断,也可以从best_model.pth恢复。
如果想把训练过程可视化,简单做法是每个epoch记录数值后用matplotlib画两条曲线:train_loss和val_acc。train_loss持续下降但val_acc平滑不涨,说明模型容量不足或增强过度;两条曲线同步走低又上升,则可能是学习率太大。
4.2 评估指标:分类报告与混淆矩阵
准确率在蝴蝶识别里容易被多数类带偏。如果数据集中最常见的类别占30%,一个永远预测该类的模型也有30%的准确率。为了看清每个类别的表现,需要在测试集上生成classification_report和confusion_matrix。
from sklearn.metrics import classification_report, confusion_matrix model.load_state_dict(torch.load( 'best_model.pth', map_location=device)) all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for images, labels in test_loader: images = images.to(device) out = model(images) _, pred = torch.max(out, 1) all_preds.extend(pred.cpu().numpy()) all_labels.extend(labels.numpy()) names = test_loader.dataset.classes print(classification_report(all_labels, all_preds, target_names=names)) print(confusion_matrix(all_labels, all_preds))classification_report会逐类输出precision、recall、F1。recall低表示这种蝴蝶经常被漏判,原因通常是训练样本太少,或该类与另一个类外观太接近。confusion_matrix非对角线上数值大的位置,就是模型最常搞混的类别对。如果两个类反复互相误判,优先回去看数据:有没有标注错误、拍摄角度是否极端、两个类在数据集里的图片是否已经非常相似。
下表总结了训练中常见现象和优先处理方向:
| 现象 | 原因 | 先试操作 |
|---|---|---|
| train_acc高,val_acc低 | 过拟合 | 增大Dropout或weight_decay |
| train_loss一直不降 | 学习率过大/过小,容量不足 | 调低lr或增加通道数 |
| val_acc剧烈震荡 | 学习率偏大或batch太小 | 降低lr,尝试增大batch |
| train/val都很低 | 欠拟合 | 加深网络,减少增强 |
4.3 过拟合判断与调整策略
蝴蝶数据集通常几千张,SimpleCNN约250万参数,训练中过拟合几乎是必然的。最直接的信号是:训练集准确率接近100%,验证集准确率停住甚至回落。此时验证loss已经从低点反弹,训练loss还在降,两条曲线开始拉大距离。
我的处理顺序是:先把Dropout从0.5升到0.6,同时把weight_decay从1e-4提到3e-4;如果依旧过拟合,减小模型容量,例如把三个卷积层的通道数从32/64/128改为24/48/96;再不行,就降低增强强度,尤其检查RandomResizedCrop的scale下限是不是太小。假如以上都不理想,就不要再揪着小网络调,直接换预训练模型微调,通常更省时间。
5. 进阶:用预训练模型微调,把蝴蝶识别准确率提上去
5.1 替换ResNet18的全连接层
SimpleCNN在蝴蝶数据上能拿到80%左右的验证准确率已经不错。想再往上提,用PyTorch的预训练ResNet18是命令行选手和调参老手都会首选的路线。ResNet18在ImageNet上已经学到了边缘、纹理、形状这些通用特征,蝴蝶识别只是在这个底座上做一次小规模适配。
import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) model.fc = torch.nn.Linear(model.fc.in_features, num_classes)新版torchvision推荐weights=DEFAULT,比pretrained=True更显式。替换fc层时用model.fc.in_features取值,而不是写死512,这样可以无脑切换到ResNet50等模型。权重首次下载可能需要一段时间,缓存路径由TORCH_HOME环境变量指定,默认在用户目录下的.cache/torch/hub/checkpoints。
5.2 两段式冻结微调参数
迁移学习最稳妥的参数策略是“先冻结,再解冻”。第一段把所有卷积层参数requires_grad设为False,只训练fc层,学习率1e-3,跑10个epoch;第二段解冻layer4的参数,学习率降到1e-4,再跑10个epoch。layer4是ResNet最靠近输出的残差块,语义层次最高,解冻它能帮助模型适应蝴蝶的视觉分布,又不会让底层基础特征被破坏。
for p in model.parameters(): p.requires_grad = False for p in model.fc.parameters(): p.requires_grad = True optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3) # 第一段训练10个epoch后,再解冻 layer4 for p in model.layer4.parameters(): p.requires_grad = True optimizer = torch.optim.Adam( [p for p in model.parameters() if p.requires_grad], lr=1e-4)第二段的optimizer这一步很关键,如果只传model.parameters(),会把requires_grad=False的参数也放进优化器,虽然梯度为零不会更新,但浪费内存。用列表推导只挑requires_grad为真的参数,才是“解冻到哪就优化到哪”的正确写法。
5.3 用测试集验证提升幅度
跑第4.2节里同一套评估代码,把加载的权重换成微调后的best_model.pth,重新打印classification_report。对比双方的准确率、macro F1和个别难类的recall。提升一般是明显的,如果没提升,先检查两件事:第一,数据划分时随机种子是否一致,两次训练用的是不是同一份test集;第二,微调过程中是否不小心把训练集和验证集transform混用了。模型文件的命名加上时间戳,方便回滚到表现最好的版本。到这里,从zip数据集到可用的蝴蝶识别模型,已经形成完整闭环。
本文还有配套的精品资源,点击获取