简介:图像分类是深度学习中基础且高频的应用场景,其核心在于将原始像素转化为有效特征并完成类别映射。实际工程中,数据集的整理与标注解析往往比模型结构更影响效果。以鸟类图像识别任务为例,借助迁移学习加载ResNet预训练权重,通过数据增强、分层采样和微调策略,能够在小规模数据集上获得稳定准确率。该方案不仅适用于野生动物监测、生态调查等场景,也是高校人工智能课程中理解图像分类全流程的典型实践。本文围绕70类鸟类数据集,从标注文件解析、模型训练配置到测试结果输出,系统梳理了图像分类实验的完整链路与常见避坑要点。
1. 鸟类图像分类这个实验:不是刷榜,是先把数据链路跑通
如果你正在做图像分类方面的课程实验,最怕的不是模型跑不起来,而是拿到一份数据集却不知道怎么把它喂进网络。这份基于深度学习的鸟类图像分类资源,本质是一个完整的 70 类鸟类识别实验包:6500 张训练图片、1666 张测试图片,附带类别映射文件和带目标框坐标的标注文件。它解决的不是“用什么模型刷最高分”的问题,而是让你在有限算力下把数据集处理、模型训练、结果输出这一整条链路跑通。适合高校人工智能相关专业的本科生和研究生,也适合刚入门深度学习、想用真实数据练手图像分类的新手——快速走完一遍,比看十篇教程都管用。
2. 解析数据集:70 类鸟的目录结构、标注文件与加载方式
2.1 数据集三件套:images、classes.txt、trainDataSet_mixed.txt
这份资源里最有价值的是它的标注设计,它不是简单的 ImageFolder 分类目录,而是额外给了每个训练样本的检测框坐标。也就是说,你在做分类的同时,还能顺手观察目标检测框和分类标签之间的关系,这在课程实验里很少见。目录结构如下:
--images文件夹(6500张图片) --001.Black_footed_Albatross --002.Laysan_Albatross ... --070.Green_Violetear --classes.txt --trainDataSet_mixed.txtclasses.txt记录类别 ID 和类别名称的对应关系,trainDataSet_mixed.txt则是训练集的索引文件。注意一个细节:训练集图片按子文件夹存放,子文件夹名既是类别名又是图片路径的一部分,而trainDataSet_mixed.txt里给出了每个样本的四个 bbox 坐标。这意味着你可以做两种训练策略——纯分类不看框,或者把框裁出来再送进网络。常见做法是先把数据读成 DataFrame,便于后续按需取用。
2.2 解析 trainDataSet_mixed.txt:把标注变成可用数据
这个文件的格式是<image_id> <class_name/image_name> <bbox_x1> <bbox_y1> <bbox_x2> <bbox_y2>,第一列是图片编号,第二列是带类别名的图片路径,后四列是左上角和右下角的像素坐标。我用 pandas 直接读:
import pandas as pd train_df = pd.read_csv( 'trainDataSet_mixed.txt', sep=' ', header=None, names=['image_id', 'image_path', 'bbox_x1', 'bbox_y1', 'bbox_x2', 'bbox_y2'] ) # 提取类别名和纯文件名 train_df['class_name'] = train_df['image_path'].apply(lambda x: x.split('/')[0]) train_df['file_name'] = train_df['image_path'].apply(lambda x: x.split('/')[1]) # 把类名映射成类别序号 class_list = train_df['class_name'].unique() class_to_id = {name: idx for idx, name in enumerate(sorted(class_list))} train_df['class_id'] = train_df['class_name'].map(class_to_id) print(train_df.head()) print(train_df['class_id'].value_counts())这里的关键点是sep=' '用空格分割,因为文件名里没有空格,所以能安全拆成六列。class_to_id的映射方式我建议按字母排序生成,因为测试集输出结果时类别 ID 是整数,如果映射不一致,后续评估会全部错位。value_counts()可以快速看一眼类别分布,后面用来判断是否存在严重类别不均衡。
2.3 验证集划分:按类别分层采样,别乱 shuffle
课程实验通常是给定训练集,自己留一部分做验证。6500 张图分 70 类,每类平均不到 100 张,如果直接随机划分,某些类别在验证集里可能只有 1 张甚至没有。我一般用分层采样,确保每个类别在训练集和验证集里的比例一致:
from sklearn.model_selection import train_test_split train_idx, val_idx = train_test_split( train_df.index, test_size=0.2, stratify=train_df['class_id'], random_state=42 ) train_split = train_df.loc[train_idx].reset_index(drop=True) val_split = train_df.loc[val_idx].reset_index(drop=True) print(f'Train: {len(train_split)}, Val: {len(val_split)}')stratify=train_df['class_id']是核心参数,它保证每个类别都被按比例抽到验证集里。random_state=42固定随机种子,确保每次重跑实验结果可比。这里有个容易被忽略的坑:验证集图片和训练集图片必须来自同一分布,本数据集所有图都是真实拍摄的鸟类照片,不用做复杂的域适配。
2.4 数据增强:这个数据集大小下,增强比换模型更重要
6500 张图对 70 类分类来说刚过及格线,纯靠 ResNet 预训练权重硬训也能到 85% 左右,但想上 90% 就需要数据增强来凑。鸟类图像的特点是主体在画面中间但大小差异大,有些图鸟很小、背景占比很高。我用组合增强策略:
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.5, 1.0)), transforms.RandomHorizontalFlip(p=0.5), 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_transform = 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]) ])RandomResizedCrop的scale=(0.5, 1.0)意味着裁剪区域占原图 50% 到 100%,这个区间很适合鸟类这种主体占比不固定的场景。验证集只用Resize(256) + CenterCrop(224),不做随机增强,因为验证指标要反映真实分布下的表现。Normalize 用的是 ImageNet 的均值和标准差,配合预训练权重使用效果最好——如果你自己从零训练,这两个参数就是错的。
3. 模型选型与训练配置:从 ResNet 到微调,参数怎么定才稳
3.1 为什么课程实验优先选 ResNet50,而不是 ViT
70 类分类、每类不到 100 张训练图,这个数据规模决定了模型选择的方向。Vision Transformer 在小数据集上需要较强的正则化手段和大 batch 才能收敛,显存占用也高;而 ResNet50 作为 CNN 里的经典结构,配合 ImageNet 预训练权重做微调,是这个实验里性价比最高的方案。它的fc层是 2048 维输入,替换成 70 维输出只需要改一行代码。
如果你显卡显存不够 8G,还可以降级用 ResNet18,准确率大概会掉 3 到 5 个点,但训练时间能缩短一半。我自己的判断标准是:训练集总样本数除以类别数小于 200 时,优先考虑 ResNet 系;大于 500 时再考虑 EfficientNet 或 Swin Transformer,因为那时数据量才撑得起更复杂的结构。
3.2 加载预训练权重并替换分类头
从 torchvision 里直接加载预训练 ResNet50,关键点是确认权重版本和替换分类头的维度:
import torch import torch.nn as nn import torchvision.models as models model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) num_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(num_features, 70) ) model = model.cuda() if torch.cuda.is_available() else model print(f'Replace fc layer: {num_features} -> 70')IMAGENET1K_V2是官方最新版本预训练权重,比 V1 的 top-1 准确率高约 1.5 个百分点,微调收敛也更快。model.fc.in_features这行代码很关键,它自动读取原分类头的输入维度,不用硬编码 2048,以后换 ResNet18 或 ResNet101 也不用改这里。Dropout(0.3)是防止小数据集过拟合的常见手段,类别数多但每类样本少,dropout 比 weight decay 更直接有效。
3.3 训练参数:学习率、batch size、epoch 的搭配逻辑
这个实验的训练参数,我建议按以下方式设置:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| batch size | 32 | 8G 显存下 ResNet50 刚好能跑,梯度更新也稳定 |
| 初始学习率 | 0.001 | 微调阶段用,比从头训练低一到两个数量级 |
| 学习率衰减 | 每 20 epoch 乘 0.1 | 能直观看到验证集准确率在衰减点附近跳升 |
| epoch | 60 | 70 类、6000 张训练图,60 轮足够收敛 |
| 优化器 | AdamW | 比 Adam 多了正确实现的权重衰减,泛化更好 |
| weight decay | 0.01 | AdamW 默认推荐值,配合预训练权重足够 |
这里有个经验:微调时主干网络的学习率可以比分类头低 10 倍,因为预训练特征已经很好了,不需要大步幅改动。常见做法是把模型参数分成两个组,fc层用 0.001,主干网络用 0.0001。如果只用一个学习率,训练前期 loss 下降快,但后期容易在验证集上震荡,因为分类头已经收敛而主干还在大幅更新。
3.4 训练循环主体与验证逻辑
训练循环直接写进一个脚本里,关键部分如下:
from torch.optim import AdamW from torch.cuda.amp import autocast, GradScaler optimizer = AdamW(model.parameters(), lr=0.001, weight_decay=0.01) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.1) criterion = nn.CrossEntropyLoss() scaler = GradScaler() for epoch in range(60): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() optimizer.zero_grad() with autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() running_loss += loss.item() * images.size(0) # 验证 model.eval() val_correct = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.cuda(), labels.cuda() outputs = model(images) _, preds = torch.max(outputs, 1) val_correct += (preds == labels).sum().item() val_acc = val_correct / len(val_split) * 100 print(f'Epoch {epoch+1:02d} | Loss: {running_loss/len(train_split):.4f} | Val Acc: {val_acc:.2f}%') scheduler.step() torch.save(model.state_dict(), f'checkpoints/bird_epoch{epoch+1:02d}.pth')autocast()混合精度训练在 1080Ti 以上都能提速约 30%,且不影响最终准确率。GradScaler是配套操作,防止 fp16 下梯度下溢。我在代码里每个 epoch 都保存一次权重,这样训练到第 40 轮如果发现验证集开始过拟合,可以直接回退到第 30 轮的权重,不用从头重跑——这是训练曲线不稳定时最好的后悔药。
4. 测试集推理与结果输出:按学号姓名格式生成提交文件
4.1 测试集读取顺序:image_id 从 1 到 1666
这个实验的测试集是 1666 张图,TestImage_id从 1 开始编号,推理时最忌用文件名排序后直接遍历,因为文件名的数字可能不是从 1 开始连续的。稳妥做法是显式构造一个 id 到路径的映射:
import os test_dir = 'test_DataSet' test_paths = {} for i in range(1, 1667): candidate = os.path.join(test_dir, f'{i}.jpg') if not os.path.exists(candidate): candidate = os.path.join(test_dir, f'{i}.png') test_paths[i] = candidate print(f'Total test images: {len(test_paths)}')这段代码强制用 id 去查文件而不是扫描目录,能避免目录里有无关文件被混入。另外我顺手做了一次格式兜底:找不到.jpg就尝试.png,因为原始数据集在不同站点转存后后缀可能不一致。
4.2 推理脚本:加载最优权重并输出预测结果
推理阶段加载验证准确率最高的那个 epoch 权重,不做任何数据增强,只用验证集同样的一套预处理:
import torch from torchvision import transforms device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.load_state_dict(torch.load('best.pth', map_location=device)) model.to(device).eval() test_transform = 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]) ]) results = [] with torch.no_grad(): for img_id in range(1, 1667): from PIL import Image img = Image.open(test_paths[img_id]).convert('RGB') tensor = test_transform(img).unsqueeze(0).to(device) output = model(tensor) pred_class = torch.argmax(output, dim=1).item() results.append((img_id, pred_class)) print(f'Predictions: {len(results)}, first 5: {results[:5]}')torch.argmax取的是模型输出的最大值索引,这个索引就是 classes.txt 里约定的类别序号吗?不一定——取决于你第 2 章里怎么建class_to_id的映射。如果按字母排序映射的数字是从 0 开始的,而 classes.txt 里的序号是从 1 到 70,那这里必须做偏移:pred_class = class_list[pred_class]输出真正的 class_id。我建议直接用 classes.txt 构建映射,不要自己重新排序。
4.3 输出文件格式:第一行学号姓名,第二行起是预测
评测程序读取文件时只认两段内容,第一行学号姓名中间无空格,第二行起每行是<TestImage_id><空格><class_id>。写文件的时候注意编码和换行符,Windows 下容易把\r\n混进去,我统一用显式换行:
with open('09118000李飞飞.txt', 'w', encoding='utf-8') as f: f.write('09118000李飞飞\n') for img_id, class_id in results: f.write(f'{img_id} {class_id}\n') # 校验行数:1 + 1666 = 1667 line_count = sum(1 for _ in open('09118000李飞飞.txt', encoding='utf-8')) assert line_count == 1667, f'Line count mismatch: {line_count}' print('Result file verified.')这个校验逻辑很笨但有用——行数不对就说明输出有问题,不用等评测系统反馈。加encoding='utf-8'是防中文系统默认 ANSI 编码带来的乱码问题。文件命名也不能马虎,评测程序按文件名找人,写错一个字就是零分。
5. 避坑指南:鸟类分类实验里常见的几个翻车点
5.1 现象:验证集准确率 95%,评测成绩却不到 80%
原因:验证集和测试集分布不一致。最常见是你做数据增强时用了RandomHorizontalFlip,但鸟类有左右朝向的语义特征(比如鸟嘴方向),翻转后训练集把左右对称样本都教给了模型,验证集你也翻转了所以没问题,但测试集不翻转,模型对朝向敏感的特征就失效了。
解决:验证和测试阶段统一不做水平翻转,只在训练阶段用。另外检查一下训练集和测试集图片是否来自不同的拍摄设备或背景,如果是,加一些背景增强,比如RandomApply随机替换背景色,能缓解这个分布偏移。
5.2 现象:用预训练权重微调,loss 却在前 5 个 epoch 不下降
原因:学习率设置过大,导致预训练权重被破坏。我见过有人直接用 0.01 微调,主干网络的特征提取层被大步幅更新,ImageNet 学到的特征全被覆盖,loss 不仅不降还可能涨。
解决:主干网络分组学习率,fc层 0.001,主干网络 0.0001。如果已经跑乱了,重新加载原始预训练权重再训。另外确认一下model.train()和model.eval()切换正确,BatchNorm 层在 eval 模式下用全局统计量,如果漏切会看到验证指标异常波动。
5.3 现象:训练时把 bbox 坐标当成了特征输入
原因:这个数据集的 txt 里有 bbox,很多同学想“我多了检测框信息是不是能帮分类”,于是把四个坐标 concat 到特征向量里。结果分类头变成了坐标回归器,模型记住的是框的位置而不是鸟的视觉特征,测试时 bbox 变化直接导致预测乱掉。
解决:分类任务里 bbox 只用来做前置裁剪——要么按框裁出鸟体再训练,要么完全忽略。这份数据集里 bbox 的意义在于可以观察框质量,如果你想用框,就把每张图按[x1, y1, x2, y2]裁出来缩放到 224 再训练。
5.4 现象:输出结果文件第一行忘了写学号姓名,被判零分
原因:评测程序读文件时第一行必须是学号姓名,且中间不能有空格。有人写完预测结果才发现没写第一行,重新写又改了换行符导致格式混乱。
解决:写文件时先写第一行再写预测,顺序不要反。写完用wc -l或脚本校验行数。文件名里的学号姓名必须和第一行内容一致,我用同一个变量student_id = '09118000李飞飞'同时控制文件名和第一行,从根本上避免不一致。
5.5 现象:训练时突然报图片解码错误,程序中断
原因:数据集中混入了损坏图片或非 RGB 三通道图片,PIL 默认打开后可能是 RGBA 四通道,直接送进模型会报错。
解决:加载图片时统一convert('RGB'),并做一次完整性检查:
from PIL import Image valid_images = [] for path in train_df['image_path']: try: img = Image.open(path).convert('RGB') img.load() valid_images.append(path) except Exception as e: print(f'Broken image: {path}, error: {e}')img.load()会强制读取像素数据,能提前暴露文件损坏问题,比等到训练时才崩溃要省时间得多。我每次都把这一步放到数据预处理阶段,筛完再建 Dataset,整个训练过程一次崩溃都不出。
6. 进阶操作:不换大模型的前提下把准确率再往上提一截
当 ResNet50 微调跑到瓶颈,比如验证集准确率卡在 88% 左右,我通常会先试 Test Time Augmentation 和标签平滑,这两招几乎不用改模型结构就能稳定涨 1 到 2 个点。TTA 的做法是推理时对同一张图做多次增强变换,把预测概率取平均再取 argmax:
tta_transforms = [ transforms.CenterCrop(224), transforms.Compose([transforms.RandomHorizontalFlip(p=1.0)]), transforms.Compose([transforms.RandomResizedCrop(224, scale=(0.9, 1.0))]) ] with torch.no_grad(): for img_id in range(1, 1667): img = Image.open(test_paths[img_id]).convert('RGB') prob_sum = None for t in tta_transforms: tensor = t(img).unsqueeze(0).to(device) prob = torch.softmax(model(tensor), dim=1) prob_sum = prob if prob_sum is None else prob_sum + prob pred = prob_sum.argmax(dim=1).item()这里注意RandomHorizontalFlip(p=1.0)是强制翻转,RandomResizedCrop在推理时是固定随机种子还是每次不同?torchvision 里这两个 transform 不接收随机种子参数,但你在同一进程里多次调用它,第二次会重新生成随机参数。测试时反而需要这种随机性,因为多张图的随机裁切概率会逼近真实分布。
另一个容易涨点的操作是 EMA,指数移动平均权重。训练时维护一份模型权重的滑动平均,推理时用这份平均权重而不是最后一步的权重,能有效平滑训练后期的震荡:
ema_model = torch.optim.swa_utils.AveragedModel(model) # 每个 epoch 结束后更新 ema_model.update_parameters(model)AveragedModel默认衰减系数是avg_fn的指数形式,PyTorch 1.6 以上内置支持,使用成本几乎为零。我在自己的实验里,EMA 加 TTA 组合起来,在 70 类鸟分类任务上比单模型推理多拿了 1.8 个点。从那以后我每次做分类任务,训练都顺手维护一份 EMA 权重,推理时先试 EMA 再对比原始权重的差别,哪个高用哪个,已经成了我的固定习惯。希望帮到你。
本文还有配套的精品资源,点击获取