☰
动物识别系统实战:基于PyTorch ResNet18的迁移学习与Web部署
2026/10/16 7:30:27 网站建设 项目流程

做人工智能实验的时候选了“动物识别系统”这个题目,说实话一开始觉得挺简单的——不就是图像分类嘛,把猫狗图片扔进卷积网络里训练一下完事。真做起来才发现,从数据集清洗到模型选型,从训练调到Web界面上线,每一步都有不少坑。这篇就从头到尾聊一下我的完整做法,包括踩过的坑、调参的经验、最后部署成网页端应用的方案,希望能给同样在做图像识别类项目的同学一个可以直接抄作业的参考。

1. 项目思路与整体结构设计

1.1 需求拆解:这个系统到底要干什么

动物识别系统,表面上是“上传一张图片,输出它是哪种动物”。但拆分一下,真正的需求其实是三层:

  • 第一层:能对常见动物图片做分类,并且有可接受的准确率。基本要求是日常常见物种不能认错。
  • 第二层:不能只对训练集里的图片有效,要在光线、角度、背景都有变化的情况下也能稳定识别。这直接影响数据增强和模型泛化能力的设计。
  • 第三层:要有一个让人能用的入口,不管是命令行还是网页界面,总归不能每次都跑Python脚本传参数。

所以我最终把项目拆成了四个模块:数据准备与清洗、模型训练与验证、模型导出、Web端推理展示。这四个模块顺序依赖,前面做好了,后面才顺。

1.2 技术选型背后的几条逻辑

选型的时候我给自己定了几个约束条件:机器只有一块普通消费级显卡,显存不大;实验周期有限,不能从头训练一个超大网络;项目要能演示,界面的交互体验得说得过去。

所以整套方案的技术栈是这样定的:

模块选型理由
语言Python 3.9+AI生态最完善,torch和transform的工具链齐全
深度学习框架PyTorch调试方便,动态图机制适合实验阶段频繁改结构
模型ResNet18(迁移学习)网络规模适中,预训练权重易获取,适合小数据集微调
训练加速CUDA卷积运算量大,GPU训练效率远超CPU
Web界面Streamlit纯Python就能写交互页面,快速出demo首选
图像处理Pillow + OpenCV处理格式转换、尺寸调整、增强操作

这个组合最大的好处是每一层都有成熟的轮子,不需要重复造。你如果非要用TensorFlow或者Keras也能做,但PyTorch在实验场景下改起来最顺手,故而我后面所有代码示例都以PyTorch为例。

1.3 目录结构与工程规范

工程不是把几个py文件堆在一起就行,一个好的目录结构能在后面调试的时候省下大量时间。我用的结构是这样:

animal_recognition/ ├── data/ │ ├── train/ │ │ ├── cat/ │ │ ├── dog/ │ │ └── ... │ └── val/ ├── checkpoints/ ├── src/ │ ├── dataset.py │ ├── model.py │ ├── train.py │ └── predict.py ├── app/ │ └── webapp.py └── requirements.txt

数据目录按类别分子文件夹,这是PyTorch的ImageFolder直接能吃的格式,省去写一堆自定义数据类的功夫。训练脚本和推理脚本分开,避免把训练逻辑和预测逻辑搅在一起。

经验之谈:从一开始就保持“数据、模型、代码、Web”四个目录分离。实验做到中期你一定会需要对比不同模型的权重文件,如果checkpoint到处乱放,很快就会乱成一锅粥。

2. 数据集准备与预处理

2.1 数据从哪里来、怎么筛

动物识别必须有训练数据。如果是课程实验,通常有两种途径:网上公开数据集和自行爬取。公开数据集的好处是标注整齐,缺点是类别和你想做的可能不完全一致;自行爬取数量可控但质量参差。

我最后选了公开数据集为主、补充少量自采样本的方式。类别最终定了十种:猫、狗、马、牛、羊、猪、鸡、鸭、大象、熊。选这十种有讲究——它们涵盖了家养动物、农场动物和野生动物,类间差异有大有小,既有容易混淆的类别(比如猫和狗),也有特征明显的类别(比如大象和熊),能把模型的真实能力试出来。

数据清洗这一步特别容易被忽略,但它直接影响模型上限。我洗数据的时候主要处理了三类问题:

  • 图片损坏或格式不对:小部分图虽然能打开但内容损坏,直接用PIL读取验证,打不开的扔掉。
  • 标注明显错误的:数据集中偶尔混入了卡通图、雕像图,甚至完全是别的动物,这类样本对训练的负面影响很大,删掉。
  • 重复图片:很多搜索引擎来源的图是同一张多次搬运,不查重会让模型在训练时反复看到同一张图,造成“假性拟合”。

清洗完每个类别的样本量保持在400到800张之间。数量上不用追求绝对平衡,但差距不能超过一倍,否则后面要处理类别不均衡问题,会很麻烦。

2.2 图像预处理流程

预处理的目的很简单:让图片统一尺寸、统一数值范围,同时尽可能保留有效信息。我的预处理Pipeline是:

from torchvision import transforms train_transforms = transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transforms = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

尺寸全部统一为224×224,这是ResNet系列网络的默认输入尺寸。归一化的均值和标准差用的ImageNet的统计值,因为迁移学习要用别人预训练好的权重,输入侧的分布保持一致才能让权重发挥出应有的效果。

训练集和验证集用了不同的预处理策略。训练集加随机裁剪、水平翻转和颜色抖动,相当于免费扩充数据量,让模型看到更多样化的样本;验证集只用缩放和中心裁剪,因为你验证时追求的是稳定、可复现的结果,不是引入随机性。

2.3 数据增强的边界

数据增强能提升模型泛化能力,但也不是越多越好,过强的增强反而会让模型学不到有效特征。我的经验是:

  • 水平翻转几乎总是安全的,动物的左右翻转不影响类别判断。
  • 随机裁剪对场景图有效,但如果数据集里很多图主体本身就不居中,裁剪可能把主体切掉。
  • 颜色抖动幅度要控制住,动物毛色是特征的组成部分,如果饱和度调得太夸张,模型可能会把“棕色的马”学成“颜色深的马”,反而损害识别能力。

我实际测试下来,只用随机裁剪+水平翻转+轻微颜色抖动这组组合,比加了更多花哨增强(比如随机擦除、混合增强)的效果要稳定。对于实验项目来说,简单可靠的方案永远比复杂花哨的方案划算。

3. 模型选型:为什么是ResNet18而不是自己搭CNN

3.1 自建卷积网络的问题

很多实验报告喜欢让学生从零搭一个几层的CNN。做个简单的MNIST手写数字识别没问题,但放到动物识别这种真实图像任务上,自己搭轻量级CNN基本打不过预训练模型,原因有两个:

第一,数据量不够。十类动物,每类几百张图,数据规模撑不起从零训练一个深网络的参数量。即便硬训出来也只是记住了训练集,换一张新图就露馅。

第二,特征提取能力差异巨大。预训练模型在千万级图片上学过通用视觉特征——边缘、纹理、形状组合,这些底层特征和具体任务关系不大,完全可以迁移过来复用。而自己从零训练的浅层网络,卷积核还没学到有效的边缘纹理特征就陷入过拟合了。

3.2 不同预训练模型的对比

我在实验中对几个主流模型都做了快速验证:

模型参数量训练时间(10轮)验证准确率备注
ResNet18约1100万约18分钟94.6%速度和精度均衡
ResNet50约2400万约40分钟95.2%精度略高但训练慢一倍
MobileNetV3约250万约12分钟91.8%速度最快,精度略低
EfficientNetB0约500万约25分钟93.1%精度不错但训练不稳定

最终选了ResNet18。原因很朴素:在小型数据集上,模型规模超过一定阈值后精度提升有限,但训练时间和显存占用会明显增加。ResNet18在1080Ti上训练一轮只需一两分钟,迭代试错成本完全在可控范围内,ResNet50那种精度差距在实际识别体验中几乎感知不到。

同理,为什么不选更强的EfficientNet?虽然理论上它有更好的精度-效率平衡,但它的训练对超参数更敏感,学习率稍高就容易震荡,实验阶段我不想在调参上耗费太多时间。回答“为什么选这个模型”有时候比“这个模型最好”更重要。

3.3 迁移学习的具体做法

迁移学习不是简单把模型的最后全连接层换掉就行,实际做的时候有几个关键选择要处理好。

PyTorch里加载预训练模型然后替换分类头的代码很简单:

import torchvision.models as models import torch.nn as nn model = models.resnet18(pretrained=True) num_features = model.fc.in_features num_classes = 10 model.fc = nn.Linear(num_features, num_classes)

但这里有个值得注意的点:替换分类头之后,训练策略有两种。一种是把前面所有层冻住只训练新的全连接层,另一种是全部层一起微调(fine-tune)。

我测试下来,在数百张级别的数据量上,全量微调效果明显更好。只有几百张数据时凍结骨干网络,相当于只让模型学“输出层如何组合已有特征”,学出来的分类边界比较粗糙;全量微调让模型能够针对动物数据微调中层的特征响应,准确率大概能再提升两到三个百分点。

全量微调只有一个额外要求:学习率必须要低,一般用1e-4甚至更低,不然预训练权重会被快速破坏。

如果GPU显存紧张,可以使用gradient checkpointing或者把batch size调小。我的经验是batch size 32在8G显存下刚好,低于16的话BatchNorm统计会不稳,影响精度。

4. 核心细节解析与训练实操

4.1 关键参数与选择依据

训练中涉及到的核心参数,我直接给出最终使用的一组,以及每个参数为什么这么定:

  • 输入尺寸:224×224,ResNet的标准尺寸,大了显存吃不消,小了会丢失细节。
  • 损失函数:交叉熵损失(CrossEntropyLoss)。分类任务的标准选择,它本身包含了Softmax和负对数似然,对多分类任务而言简单直接。
  • 优化器:Adam,初始学习率1e-4,权重衰减1e-4。Adam收敛速度快、对学习率不敏感;权重衰减用于抑制过拟合。
  • 学习率调度:CosineAnnealingLR,将学习率从初始值逐步余弦衰减到极低值。相比阶梯式下降,余弦退火在训练后期更平滑,能省去手动找学习率下降节点的麻烦。
  • Batch大小:32。受限于显存,同时实测32比64能更稳地收敛。
  • 训练轮数:25到30轮。第15轮左右验证精度基本趋于平台,后续是精细调整阶段。

固定随机种子这件事很多人会忽略,但对实验的可复现性至关重要。别人跑同样的代码得到不同结果,往往就是没有固定种子:

import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False

benchmark=False可能会损失一点训练速度,但换来的是每次运行结果一致,做对比实验时才有说服力。

4.2 训练循环与验证策略

训练代码的主体结构比较标准,但我在中间加了一些实验后才摸索出来的细节。核心逻辑如下:

from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder import torch.optim as optim train_dataset = ImageFolder('./data/train', transform=train_transforms) val_dataset = ImageFolder('./data/val', transform=val_transforms) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) best_acc = 0.0 for epoch in range(30): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() scheduler.step() model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = 100.0 * correct / total print(f'Epoch {epoch+1:02d} | Loss {running_loss:.4f} | Val Acc {val_acc:.2f}%') if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), './checkpoints/best_model.pth')

这里有几个关键点值得展开:

验证时一定要加torch.no_grad()和model.eval()。model.eval()会关掉Dropout和BatchNorm的训练模式,让BatchNorm使用运行均值而非当前batch的统计量,否则验证集的准确率会被虚高估计。torch.no_grad()则关闭梯度计算,节省显存并加速推理。

保存模型时不保存整个model对象,只保存state_dict。这样模型权重文件更小,加载时也不受代码版本变动影响。加载方式固定是:

model = models.resnet18(pretrained=False) model.fc = nn.Linear(num_features, num_classes) model.load_state_dict(torch.load('./checkpoints/best_model.pth'))

有个容易踩的坑是加载时忘记先重新定义模型结构,直接想load到一个裸模型上,报错说键值不匹配。记住这个顺序:先定义结构,再load权重。

4.3 评估指标与结果分析

准确率是主要指标,但不能只盯着准确率看。我每次训练完还会生成混淆矩阵和分类报告,这两样东西能告诉你模型为什么出错,而不是仅仅告诉你错了。

混淆矩阵里的典型错误模式很有意思,我的模型在几轮迭代后主要出现两类混淆:

  • 牛和马的混淆:两者轮廓相似、颜色单一,预训练模型的中层特征本身就区分不好。
  • 羊和狗的混淆:部分羊的图片毛发卷曲、拍摄角度是正面特写,视觉上和大型犬过于接近。

针对第一类混淆,最好的改进手段是增加牛和马的侧身、全身图,减少只看局部的特写图;针对第二类,则需要更细的纹理特征。实验做到这一步就不只是完成任务,而是在真正理解模型的行为。

分类报告方面,整体F1-score在0.93左右,精确率和召回率比较均衡,说明模型没有偏向哪一类。如果出现某个类别精确率高、召回率低,就说明模型对这类动物过于“保守”,宁愿不认也不认错,这种时候往往需要补充该类别多样性强的样本。

5. Web端部署与系统演示

5.1 为什么用Streamlit而不是Flask

模型训练完,光在终端里跑predict.py一遍遍敲路径体验太差了,也不利于最后展示。做Web界面我第一反应是Flask,但写了几行代码后放弃了。Flask需要自己写HTML模板、处理POST请求、写静态文件,做一个小demo耗时太长。

换成Streamlit之后整个思路就变了:不用写HTML、不用管请求响应,专注在“页面长什么样、逻辑做什么”。如果整个项目面向的是算法验证和实验报告,Streamlit的效率高得多。

5.2 界面逻辑与推理流程

页面逻辑设计了三块内容:

  • 左侧边栏:上传图片、选择可用的模型权重。
  • 主区域:显示识别结果(预测类别和置信度),同时展示Top3的概率分布条形图。
  • 附带区域:显示当前上传图片的缩略预览。

推理部分的代码:

import streamlit as st from PIL import Image import torch import torchvision.models as models from torchvision import transforms class_dict = { 0: '猫', 1: '狗', 2: '马', 3: '牛', 4: '羊', 5: '猪', 6: '鸡', 7: '鸭', 8: '大象', 9: '熊' } def load_model(model_path): model = models.resnet18(pretrained=False) num_features = model.fc.in_features model.fc = torch.nn.Linear(num_features, len(class_dict)) model.load_state_dict(torch.load(model_path, map_location='cpu')) model.eval() return model @st.cache_resource def get_model(): return load_model('./checkpoints/best_model.pth') def predict(model, image): preprocess = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) input_tensor = preprocess(image).unsqueeze(0) with torch.no_grad(): outputs = model(input_tensor) probs = torch.softmax(outputs, dim=1) top3 = torch.topk(probs, k=3) results = [] for i in range(3): idx = top3.indices[0][i].item() prob = top3.values[0][i].item() results.append((class_dict[idx], prob)) return results st.title('动物识别系统') uploaded = st.file_uploader('上传动物图片', type=['jpg', 'jpeg', 'png']) if uploaded is not None: image = Image.open(uploaded).convert('RGB') st.image(image, caption='上传图片', width=300) model = get_model() results = predict(model, image) st.subheader('识别结果') for name, prob in results: st.write(f'{name}: {prob*100:.1f}%')

有没有注意到我加了@st.cache_resource装饰器?不加的话每次点击按钮或页面刷新,Streamlit都会重新加载模型,加载一次要好几百毫秒,体验非常差。加了之后模型只加载一次,后续所有交互都走缓存,识别单张图片在CPU上也能做到几百毫秒内出结果。

transform里的预处理必须和训练阶段验证集保持一致。这一点特别容易出错,如果你在训练时用了某种Normalize均值和标准差,推理时忘了用或者用错,出来的置信度分布会变得很奇怪,准确率也会掉一截。我调试的时候发现这个问题,在代码里把训练和推理的预处理函数抽成了同一个工具文件,保证两边不会不一致。

5.3 部署与演示的实际效果

Web端跑起来之后,我拿了训练集之外的“野生”图片测试。用手机随手拍的路边的狗、从视频里截取的大象画面,识别表现都不错,置信度普遍在85%以上。还专门试了一张画质较差的远距离马匹图,Top1置信度降到了68%,Top3里包含了马和牛,说明模型在低质量输入下会犹豫,但大方向仍然是正确的。

![示意图]这里放一张你运行后的截图

运行方式很简单:

pip install -r requirements.txt streamlit run app/webapp.py

浏览器自动打开本地地址,整个系统就可以演示了。数据、模型、Web三者打通之后,“动物识别系统”才算真正闭环。

6. 常见问题与排查技巧实录

做项目过程中踩了不少坑,有些问题极具代表性,整理成速查表供参考:

现象可能原因解决方案
训练Loss下降但验证集准确率始终不变数据标签错位或预处理不一致检查train/val的transform,确认验证集没有使用随机增强
验证准确率忽高忽低、震荡剧烈学习率过高或batch太小降低初始学习率到5e-5或增大batch到64
图片一加载就报错图片通道数异常或文件损坏用PIL.Image.open检查,加上convert('RGB')统一通道
CPU推理太慢模型未切换到eval模式或没关梯度加model.eval()和torch.no_grad(),实测可提速3到5倍
混淆矩阵显示某一类准确率极低该类训练样本种类单一扩充该类别不同角度、不同环境的图片
部署时模型加载报错键不匹配模型结构和state_dict不一致确认加载前已重新定义模型结构
用了预训练权重但效果还不如随机初始化学习率太高导致预训练权重被破坏全量微调时学习率不要超过1e-4

有几个排查经验单独说一下。

Loss不降但准确率在涨,这种情况我在实验前期遇到过。原因是训练数据里类别不均衡明显,模型学到的是把多数类全判对、放弃少数类,整体Loss高但准确率看起来还行。解法是看混淆矩阵而不是只看整体准确率,然后针对少数类做数据补充或采用加权采样。

明明训练了很长时间但最后保存的模型不是最优的,原因是只在所有epoch结束后保存了一次模型。改成每轮验证后都在验证集上跑一遍、并保存验证指标最好的那个checkpoint,这一点我在代码里已经示范了,务必坚持。

BatchNorm在加载权重后影响性能,有时是因为推理时忘了调model.eval()。BatchNorm在训练模式和推理模式下行为完全不同,这种错误隐蔽且难定位,报错不会出现,但结果就是不准。遇到预测结果离谱的情况,先检查这一条。

另外想分享一个数据层面的技巧:如果有类别间表现差距过大,与其花大力气调模型,不如先去补充数据。我在实验中发现,给混淆程度较高的两个类别各补充两三百张边界情况下的图片后,整体准确率涨了将近三个百分点,这比更换任何模型结构都来得明显。

7. 写在最后的几点个人体会

整个项目做下来,最大的感悟是:图像识别系统的难点往往不在“训练模型”这一步,而在数据的整理和全流程的串联上。数据清洗占了整个项目大概四成时间,但数据干净的收益会体现在后面的每一步里。模型只用了标准ResNet18加迁移学习,单看任何一块技术都是成熟方案,但把它们有序组织起来,才让这个系统真正可用。

如果要在这个项目基础上继续扩展,有几个方向值得一试:一是把当前十类扩充到更多物种,但需要先把数据管线做得更自动化;二是加入检测模型,让系统支持一张图中多个动物的同时识别;三是把模型转换为ONNX格式再用推理引擎加速,这样Web端响应能比现在快不少。但这些都是后话了,先把基础的图像分类吃透,后面的路会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询