简介:这是一个基于Python和ResNet的动物图像分类系统项目,面向刚接触深度学习或希望完成图像识别小项目的开发者。系统使用PyTorch框架实现,包含数据生成、模型训练、单张图片预测和Web端交互展示等完整流程,既可用于课程设计,也适合作为学习参考。压缩包共26个文件,包含8个Python源代码文件(如train.py、predict.py、spider.py等)、11张PNG截图或界面图、1个训练好的ResNet18权重文件(.pth)、1个HTML模板以及运行日志、模型配置文件等,整体大小约41.74MB。资源中附带的resnet18_e_best.pth可直接加载进行推理,减少了重新训练的时间成本;目录中的output、logs、templates等模块也清晰展示了项目结构。目前已有118人学习浏览,对于想快速跑通基于ResNet的动物分类流程并了解PyTorch项目组织方式的开发者来说,是一份小而完整的参考资料。
1. ResNet加Python的动物分类系统,迁移学习是默认起点
“基于ResNet和Python的动物图像分类系统”这句话,本质上说的是一个常见的视觉项目组合:数据集是动物照片,模型用ResNet,工程语言选Python。很多人拿到这个标题会默认动手写一个卷积网络然后从头训练,这是最容易走的弯路。真实做法是先跑通ResNet的预训练权重,在你自己的动物数据上微调,再用封装好的推理脚本单张预测或批量评估。不同动物类别差异大、背景变化多,ResNet这类残差网络的迁移效果往往比从零训练小网络更好,这也是它在动物分类项目里被反复选中的原因。下文按实际落地顺序,把原理、数据、训练、调试讲清楚。
2. ResNet结构在动物图像分类中的选型依据:从残差块到预训练
2.1 残差块解决了网络退化,也改变了小数据集的训练方式
ResNet的核心改动是引入残差块,让某一层学习的内容从完整映射变为残差映射。假设期望输出是H(x),残差块让堆叠层去拟合F(x)=H(x)-x,输出写作H(x)=F(x)+x。这样做带来了两个直接结果:梯度能够通过恒等捷径更顺畅地回传,网络深度加深时训练误差不会像VGG那样迅速退化;这等于给了网络一个“什么都不学也能保底”的通道,对数据量有限的动物分类场景格外有用。
从零训练一个33层的ResNet,在几千张数据上通常不如微调18层的效果好。差距不在网络容量上,而在低层特征能不能在有限迭代里学会边缘、纹理、局部形状。预训练权重把ImageNet上学到的通用视觉特征带过来,动物数据集只负责调整与类别强相关的深层语义。这个思路下,ResNet不再是“一个结构”那么简单,而是变成一个特征抽取器加可替换分类头的组合,后面微调时也只改尾部。
2.2 动物分类项目里ResNet18、50、101怎么权衡
torchvision里常见的三个版本是ResNet18、ResNet50、ResNet101。它们的主要差异在层数、Bottleneck结构和计算量。ResNet18用BasicBlock,层数少,前向快,一张224×224的图片推理大约在几十毫秒级;ResNet50开始使用Bottleneck,先降维再升维,实际通道数比18深很多,适合类别多、形态差异小的数据集。
| 模型 | 参数量 | 224输入下的相对耗时 | 典型适用场景 |
|---|---|---|---|
| ResNet18 | 约11.7M | 低 | 动物类别少、数据量小、推理设备一般 |
| ResNet50 | 约25.6M | 中 | 动物类别多、需要更高准确率、单卡可训练 |
| ResNet101 | 约44.5M | 高 | 数据充足、类别细粒度、对延迟不敏感 |
多数动物分类系统会选ResNet50。它的特征表达能力比18强,训练成本又远低于101,在10到50个动物类别之间是性价比比较稳的选择。如果项目的部署目标是树莓派或CPU推理,ResNet18优先,原始输入可以适当缩小到160或128。若理解不了选型差异,记住一个经验:拿不准就先用预训练ResNet50跑一版基线,再根据准确率和推理耗时回退或升级,不要一上来堆到ResNet152。
2.3 预训练权重为什么几乎必用
动物数据集里经常出现类别不均衡和背景干扰,单靠有限标注很难训练深层的底层特征。预训练权重的价值在于把前几层卷积固定成可靠的边缘检测器与纹理提取器,微调时这些层基本只需轻微更新。torchvision的ResNet接口提供了weights参数,支持加载ImageNet预训练权重,代码上最简单的方式是:
import torchvision.models as models model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) print(model.fc.in_features) # 2048V2权重比V1训练更久、输入归一化参数略有调整,使用时要配套对应的Transforms。这里直接把模型载入,然后观察最后一个全连接层的输入维度,后续替换分类头需要知道这个数字。预训练权重并非越高越好,但它显著缩短了训练收敛时间,这是动物分类系统能在一个小时左右跑出可用模型的主要原因。
3. 构建动物图像数据集与预处理:从文件目录到ResNet输入张量
3.1 按类别建目录,让Dataset自动索引标签
最常见的数据组织方式是按类建目录,每类动物一个文件夹,图片文件名随意。这种方式可以直接被torchvision的ImageFolder读取,标签顺序按文件夹名的字典序自动生成,不需要手工维护CSV标签。
data/ train/ cat/ 00001.jpg 00002.jpg dog/ 10001.jpg 10002.jpg valid/ cat/ 00003.jpg dog/ 10003.jpg使用ImageFolder构造数据集时,下面这两行代码就完成了训练集与验证集的声明:
from torchvision.datasets import ImageFolder from torchvision import transforms train_ts = transforms.Compose([...]) train_ds = ImageFolder("data/train", transform=train_ts) valid_ds = ImageFolder("data/valid", transform=valid_ts)ImageFolder会自动把cat、dog映射为0、1或按字典序相关的索引。注意每次增删类别文件夹后,映射关系会变化,正式项目里应该把class_to_idx保存出来,推理阶段按同一个映射还原类别名。
3.2 坏图清洗与类别不均衡的应对
动物图片来源混杂,经常出现损坏文件、灰度图、RGBA四通道图。PIL在读取部分损坏JPEG时会直接抛异常,训练时就地崩溃,所以要提前做一轮清洗,不必复杂,能读且尺寸合法就保留。
from PIL import Image from pathlib import Path for p in Path("data/train").rglob("*.jpg"): try: img = Image.open(p) img.load() if img.width < 32 or img.height < 32: p.unlink() except Exception: p.unlink()这段代码把不能正常加载以及尺寸过小的图片直接删除。类别不均衡方面,比较实用的做法是给每个类单独设置采样权重,让每个batch里小类别出现的次数更多。用WeightedRandomSampler可以做到:
from torch.utils.data import WeightedRandomSampler targets = train_ds.targets class_counts = torch.bincount(torch.tensor(targets)) weights = 1.0 / class_counts[targets].float() sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True)每个样本的权重是该类样本数量的倒数。权重小的类别在采样时被抽中的概率被抬高,batch组成从“按文件顺序均匀采”变为“按类别概率均衡采”。采样权重与数据增强配合,比单纯删除多数类图片更能保留原始信息。
3.3 训练集的增强策略与验证集的固定变换
ResNet要求输入固定尺寸,torchvision预训练权重的推荐输入是224×224。训练集和验证集的变换不能完全一样:训练集需要随机性防止过拟合,验证集只做尺寸调整和归一化,保证评估结果稳定。
mean = [0.485, 0.456, 0.406] std = [0.229, 0.224, 0.225] train_ts = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.3, 0.3, 0.3), transforms.ToTensor(), transforms.Normalize(mean, std) ]) valid_ts = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean, std) ])随机裁剪的比例设到0.7到1.0,尺度更保守一些,避免中心主体被切掉;水平翻转默认可用;颜色抖动按0.3的强度处理,适合光照不稳的野外动物图片。验证集先缩放到256再做中心裁剪。这些参数与ImageNet预训练时的设置保持一致,目的就是让输入数据分布尽量贴近训练预训练权重的分布。
3.4 DataLoader参数中影响训练速度的三个选项
from torch.utils.data import DataLoader train_loader = DataLoader( train_ds, batch_size=32, shuffle=True, num_workers=4, pin_memory=True, drop_last=True ) valid_loader = DataLoader( valid_ds, batch_size=64, shuffle=False, num_workers=4, pin_memory=True )batch_size决定单个step里的样本数量,同时受显存与训练稳定性约束;num_workers是CPU预取进程数,Linux下常设为CPU核心数的一半,Windows下建议从2开始,设太高反而报错;pin_memory=True让数据先放在锁页内存,再拷到GPU会更快。drop_last只在训练集用,防止最后一个batch过小影响BatchNorm统计量。验证集不shuffle,保证评估顺序一致。
4. PyTorch下搭建ResNet训练流程:模型、损失与检查点
4.1 载入预训练模型并替换最后全连接层
训练脚本的第一步是把ResNet50的全连接层替换成适应动物分类类别的结构。以10类动物为例,最后的全连接层输出改为10。为了增强分类头的表现,在池化后接一个带Dropout的中间层是常见做法:
num_classes = 10 model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) in_features = model.fc.in_features model.fc = torch.nn.Sequential( torch.nn.Dropout(0.3), torch.nn.Linear(in_features, 512), torch.nn.ReLU(inplace=True), torch.nn.Linear(512, num_classes) )替换时保留了原模型的卷积部分。Dropout加在主干特征进入分类头之前,抑制过拟合;中间层从2048维降到512维,再接输出层,避免直接2048到10的映射出现欠拟合。改造后训练参数量仍是原来的绝大部分,分类头之外的参数都会参与反向传播。
4.2 训练循环、评估循环与模型保存的完整代码
训练部分采用AdamW优化器配合交叉熵损失:
import torch import torch.nn as nn device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20)AdamW与SGD的选择取决于已有的调参习惯。AdamW对学习率不那么敏感,配1e-4起步比较安全;SGD+Momentum通常需要配合更高学习率,但对超参数更敏感,数据量不足时容易震荡。CosineAnnealingLR让学习率在20个epoch内从1e-4平滑下降,比固定学习率更容易在后期收敛。
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0, 0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total每个step先清零梯度,再前向算出loss,反向传播后更新参数。损失乘以该batch样本数,最后除以总样本数,得到按样本加权的epoch平均损失。准确率的计算用torch.max取预测类别,再与标签做比较。
验证循环与训练循环的最大区别是关闭梯度记录:
@torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0, 0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() * images.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / total, correct / totalno_grad()减少中间变量缓存,大幅降低显存占用,同时避免验证阶段意外更新BatchNorm的统计量。模型必须调用eval(),因为训练时BatchNorm基于当前batch统计,验证时要使用训练累积的running_mean与running_var。
检查点保存不仅要存模型权重,还要存优化器状态、当前epoch和验证准确率:
best_acc = 0 for epoch in range(1, 21): train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc = evaluate(model, valid_loader, criterion, device) scheduler.step() if val_acc > best_acc: best_acc = val_acc torch.save({ "epoch": epoch, "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "best_acc": best_acc }, "best_model.pth")只保存权重文件会遇到一个问题:中断训练后想恢复学习率、迭代轮次就没有依据。以字典形式保存优化器状态,恢复时通过load_state_dict把训练上下文一并载入。
4.3 检查点里存什么:epoch、优化器与best精度
验证集准确率更新的那个epoch对应的模型,是未来做推理的首选版本。best_model.pth同时包含epoch信息,方便回溯“最优结果出现在哪一轮”。若以单GPU训练配置保存了state_dict,推理时即使换到CPU机器,也可以用map_location参数加载。需要继续训练时,从断点恢复优化的方式如下:
checkpoint = torch.load("best_model.pth", map_location=device) model.load_state_dict(checkpoint["model_state_dict"]) optimizer.load_state_dict(checkpoint["optimizer_state_dict"]) start_epoch = checkpoint["epoch"] + 1恢复优化器状态后无需重设学习率,因为scheduler的步数会从当前epoch继续。注意如果checkpoint是在多卡训练下保存的state_dict,键名会带module前缀,此时要先去掉前缀再load。
5. 微调ResNet的常规参数与训练过程排查
5.1 冻结特征层的前几个epoch怎么设定
迁移学习常见的策略是先冻结backbone,只训练新替换的分类头,然后再解冻整体微调。冻结通过requires_grad控制参数是否计算梯度:
for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True optimizer = torch.optim.AdamW(model.fc.parameters(), lr=3e-4)这一段先让backbone在反向传播时不产生梯度,只有分类头参与更新。因为分类头是从随机初始化开始的,直接让backbone跟它一起更新,前期梯度会把已经学好的特征搅乱。常见安排是前5个epoch只训练分类头,第6个epoch开始解冻backbone,并把所有参数纳入优化器:
for param in model.parameters(): param.requires_grad = True optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4)解冻后的初始学习率比冻结阶段更小,backbone在已有特征基础上做微调。如果数据量很少,小于等于每类50张,推荐把backbone的浅层继续冻结只解冻后两个stage;如果每类超过几百张,整体微调更合适。
5.2 学习率、batch size与weight decay的配合关系
| 参数 | 常见范围 | 调整依据 |
|---|---|---|
| 初始学习率 | 分类头3e-4,解冻后1e-4 | 训练损失发散时调低 |
| batch size | 16-64 | 显存为上限,偏小则损失震荡 |
| weight decay | 1e-4 | 动物类别少时降低到1e-5 |
| Dropout | 0.2-0.5 | 训练准确率明显高于验证准确率时调大 |
batch size与学习率有联动关系。batch size加倍时,梯度估计更平滑,学习率也可以适度翻倍;但从32直接改成128,学习率不动也会导致收敛变慢或震荡。weight decay值太大,预训练特征会被过度压缩,典型表现是训练损失掉了但验证损失不降,此时优先把weight decay降到1e-5再看。
5.3 训练过程不收敛时的检查序列
第一步看loss变化曲线,前5个epoch若loss不降反升,先排除学习率过大;第二步打印每个batch里标签的分布,确认是否真的按相等的概率采集到了每一个类;第三步检查验证集的归一化参数。使用ImageNet预训练模型时,验证集一定要用官方同款的mean和std,训练用错归一化参数会导致输入分布偏移,特征完全无法发挥作用。
验证集准确率在附近几个epoch里时高时低,先看是不是验证集太小,再考虑调整学习率策略。CosineAnnealing末尾阶段学习率降得太低时,验证准确率经常出现小幅波动,这不是模型坏了,而是处处在最优解附近抖动。若模型始终不收敛,用tensorboard记录每个epoch的梯度范数,梯度范数趋近于零说明梯度消失,趋近于极大值说明梯度爆炸,两者对预览模型都意味着前几层不可信。
6. 用ResNet做动物分类推理与三个频发坑
6.1 单张图片推理的标准化接口
推理阶段需要把训练时定义的transforms原样搬过来,任何一步变换不一致都会导致预测结果偏差。一个合适的推理函数大致如上:
def predict_image(image_path, model, class_names, device): image = Image.open(image_path).convert("RGB") tensor = valid_ts(image).unsqueeze(0).to(device) model.eval() with torch.no_grad(): logits = model(tensor) prob = torch.softmax(logits, dim=1) top_p, top_idx = torch.topk(prob, 3) results = [(class_names[i], float(p)) for i, p in zip(top_idx[0], top_p[0])] return resultsun squeeze(0)把单张图片的张量补成维度为1的batch。softmax把logits转成概率分布,topk取概率最高的三个类别。实际调用时,先加载checkpoint,再按保存的class_to_idx把预测索引映射成真实类别。
6.2 三个频发坑:通道数、归一化与类别对齐
第一个坑是输入图像是RGBA四通道。Image.open读出的图可能是四通道,直接送入ResNet会报维度不匹配。统一在入口调用convert("RGB"),比在Dataset里反复处理更省事。第二个坑是归一化参数不一致。训练时用了官方mean和std,推理时自己写了另一套mean和std,模型精度会掉得非常明显,这个问题的排查难度很大,因为模型本身没有报错。第三个坑是类别错位。训练时ImageFolder按文件夹名字典序生成类别索引,推理端如果手动写class_names列表而不是从训练端保存的class_to_idx读取,很容易出现“预测猫实际是狗”的错位。
提示:在保存best_model.pth时,把class_to_idx一并写入checkpoint字典,恢复推理时用同一个映射还原类别名,能彻底避免第三个坑。
最后再补一个验证技巧:把所有验证集图片跑一遍,把概率低于0.6的样本打印出来看,绝大多数是背景复杂、遮挡严重或类别相近的图像。这类样本暴露的问题不是模型不收敛,而是数据本身的标注噪声或类别定义不清晰。把这类图片整理成难例集,后续做数据清洗与二次标注,比盲目加深网络更容易提升准确率。
本文还有配套的精品资源,点击获取