1. 项目背景与整体思路拆解
1.1 这个比赛到底在做什么
Dog Breed Identification 是 Kaggle 平台上非常经典的一个图像分类竞赛,核心任务给定一张狗的图片,判断它属于哪个品种。听起来好像不难,但真正上手之后你会发现两个很现实的问题:第一,狗的品种实在太多了,细粒度分类难度远超普通的猫狗二分类;第二,训练数据量对于从零训练一个深度网络来说远远不够。这个比赛的数据集包含 120 个犬种,训练集大约一万张出头,测试集数量更多,而且每张图片的标签是以 one-hot 编码形式给出的,因为有些图片可能同时包含多个品种或者标注存在一定模糊性。
我最初拿到这个任务的时候,第一反应是直接用预训练模型做迁移学习。原因很简单:从零训练一个卷积网络需要百万级别的标注数据,而这里只有一万多张,过拟合几乎是必然的。GoogleNet(也就是 Inception v1)作为一个在 ImageNet 上预训练好的经典骨干网络,特征提取能力足够强,参数量相对 VGG 又小很多,推理速度也快,非常适合这种中等规模数据集的微调任务。
但真正让我在这个项目里花时间最多的,其实不是模型结构本身,而是图片标签的预处理和 .csv 文件的保存。这一点很多刚接触 Kaggle 的朋友容易忽略,觉得模型跑起来就行了,结果提交的时候格式不对、标签对不上、ID 顺序错乱,白白浪费提交次数。所以这篇分享我会把重点放在数据处理这条线上,同时把 GoogleNet 微调的完整流程讲透。
1.2 为什么选 GoogleNet 而不是别的网络
选骨干网络这件事,我一般从三个维度去权衡:数据规模、计算资源、任务难度。这个比赛的数据规模属于中等偏小,计算资源如果只有单卡或者 Colab 免费额度,那就不能选太重的模型。GoogleNet 的几个特点正好契合:
- Inception 模块的多尺度卷积:在同一层里用 1x1、3x3、5x5 卷积和池化并行提取特征,再拼接起来。这种设计对细粒度分类很友好,因为狗的品种差异往往体现在局部纹理(耳朵形状、毛发质感)和整体轮廓两个尺度上。
- 参数量约 500 万左右:相比 VGG16 的一亿多参数,GoogleNet 轻量太多,微调时不容易过拟合,训练也快。
- 1x1 卷积降维:Inception 模块里大量使用 1x1 卷积做通道压缩,这是控制计算量的关键技巧,也是后来很多网络借鉴的思路。
当然,如果你有更强的算力,ResNet 或者 EfficientNet 系列效果可能更好,但作为教学和入门项目,GoogleNet 的性价比是最高的。我实测下来,在单张中端显卡上,冻结底层只训练分类头,一个 epoch 大概几分钟就能跑完,非常适合快速迭代。
1.3 整体流程鸟瞰
在动手写代码之前,我习惯先把整个 pipeline 画清楚,避免写到一半发现数据格式对不上。这个项目的完整流程大致是这样:
- 读取训练集标签文件(.csv),解析每张图片的品种标签。
- 把 one-hot 编码的标签转换成类别索引,建立品种名称到索引的映射字典。
- 用这个映射字典把标签重新编码,方便后续用交叉熵损失训练。
- 构建图片路径与标签的对应关系,划分训练集和验证集。
- 定义数据增强和预处理变换,加载预训练的 GoogleNet。
- 替换最后的全连接层,适配 120 类输出。
- 分阶段微调:先冻结特征层训练分类头,再解冻部分层做精细微调。
- 在验证集上评估,保存最佳模型。
- 对测试集做预测,生成符合提交格式的 .csv 文件。
这里面第 2、3、9 步就是标题里强调的“图片标签预处理及保存 .csv 文件”,也是我踩坑最多的地方。下面我会一步步拆开讲。
2. 图片标签预处理的核心细节
2.1 读懂标签文件的真实结构
第一次打开labels.csv的时候,很多人会愣一下,因为它的格式和常见的“一图一标签”不太一样。它长这样:
id,breed 000bec180eb18c7604dcecc8fe0dba07,boston_bull 001513dfcb2ffafc82cccf4d8bbaba97,dingo ...每一行是一个图片 ID 和一个品种名称。图片 ID 对应的是train文件夹下的文件名(不含扩展名)。这里第一个要注意的点是:ID 是十六进制字符串,不是数字,所以读取的时候一定要按字符串处理,否则 pandas 可能会把它当成数字或者科学计数法,导致后面匹配图片路径时找不到文件。
我一般用 pandas 读取:
import pandas as pd labels_df = pd.read_csv('labels.csv', dtype={'id': str}) print(labels_df.head()) print(labels_df['breed'].nunique())dtype={'id': str}这个参数是必须的,我见过太多人因为没加这个,ID 被解析成数字后精度丢失,最后匹配不上图片。这种坑一旦踩了,排查起来很费时间,因为报错信息往往只是“文件不存在”,不会告诉你是因为 ID 变了。
2.2 从品种名称到类别索引的映射
神经网络训练需要的是数字标签,不是字符串。所以第二步是建立品种名称到整数索引的映射。这里有个细节:映射的顺序必须固定下来,因为训练时的标签索引和最后生成提交文件时的列顺序必须一致,否则预测结果会整体错位。
我的做法是先把所有品种名称排序,再建立字典:
breeds = sorted(labels_df['breed'].unique()) breed_to_idx = {breed: idx for idx, breed in enumerate(breeds)} idx_to_breed = {idx: breed for breed, idx in breed_to_idx.items()} labels_df['label'] = labels_df['breed'].map(breed_to_idx)为什么要排序?因为unique()返回的顺序在不同环境下可能不一致,排序之后结果是确定的,方便复现。这个映射字典一定要保存下来,后面生成提交文件时要用它把预测的索引还原成品种名称。
提示:如果你在训练中途重新跑了数据预处理,一定要确保映射字典和之前一致。我建议把
breed_to_idx用 json 或者 pickle 存到磁盘,训练和推理时都从同一个文件加载,避免不一致。
2.3 处理 one-hot 编码与多标签情况
虽然这个比赛的labels.csv是单标签格式,但官方在评估时用的是多类对数损失(multi-class log loss),而且测试集的某些图片可能包含多个品种。这就引出一个问题:我们训练时用单标签交叉熵,预测时输出的是每个品种的概率,提交文件里每一列是一个品种的概率值,而不是硬分类结果。
所以标签预处理阶段,我们只需要把品种名称转成索引,用交叉熵损失训练即可。但在生成提交文件时,要输出 softmax 之后的概率,而不是 argmax 的类别。这一点很多人第一次做会搞错,直接提交了 0/1 的硬标签,分数会很难看。
如果你想让模型对模糊样本更鲁棒,可以考虑标签平滑(label smoothing),把目标从 one-hot 的[0,0,1,0]变成[0.001,0.001,0.997,0.001]这种形式。PyTorch 的CrossEntropyLoss自带label_smoothing参数,用起来很方便:
criterion = nn.CrossEntropyLoss(label_smoothing=0.1)这个技巧在细粒度分类里效果挺明显,我实测能提升一两个千分点。
2.4 图片路径与标签的绑定
标签处理完之后,要把每张图片的路径和标签对应起来。这里有个容易忽略的点:图片扩展名可能是 .jpg 也可能是 .png,不能想当然地写死。稳妥的做法是先扫描训练目录,建立 ID 到完整路径的映射:
import os train_dir = 'train' id_to_path = {} for fname in os.listdir(train_dir): img_id = os.path.splitext(fname)[0] id_to_path[img_id] = os.path.join(train_dir, fname) labels_df['path'] = labels_df['id'].map(id_to_path) print(labels_df['path'].isnull().sum()) # 检查有没有匹配不上的最后那行检查非常重要。如果输出不是 0,说明有图片 ID 在标签文件里但目录里找不到,或者反过来。这种情况在 Kaggle 数据集里偶尔会出现,必须提前发现并处理,否则训练时会在某个 batch 突然报错。
3. 数据加载与增强的实操要点
3.1 自定义 Dataset 的写法
PyTorch 里我习惯写一个自定义 Dataset,把路径和标签传进去,在__getitem__里读图、做变换、返回张量和标签。这样比用 ImageFolder 灵活,因为我们的标签不是按文件夹组织的。
from torch.utils.data import Dataset from PIL import Image class DogDataset(Dataset): def __init__(self, df, transform=None): self.df = df.reset_index(drop=True) self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] image = Image.open(row['path']).convert('RGB') label = row['label'] if self.transform: image = self.transform(image) return image, label.convert('RGB')这行别省。有些图片是灰度图或者带 alpha 通道的 PNG,不转换的话送进网络会报通道数不匹配的错误。我一开始就因为这个卡了半小时,后来养成习惯,读图之后一律转 RGB。
3.2 训练集和验证集的划分策略
因为测试集没有标签,我们必须从训练集里切一部分出来做验证。这里有个讲究:要按品种分层采样,保证每个品种在验证集里都有一定数量的样本。如果随机切,某些稀有品种可能验证集里一张都没有,评估结果就不可信了。
用 scikit-learn 的train_test_split可以很方便地做分层:
from sklearn.model_selection import train_test_split train_df, val_df = train_test_split( labels_df, test_size=0.2, stratify=labels_df['label'], random_state=42 )stratify参数就是按标签分层,random_state固定随机种子保证可复现。验证集比例我一般取 15% 到 20%,数据少的时候取 15%,数据多的时候可以到 20%。
3.3 数据增强的取舍
图像分类任务里,数据增强是防止过拟合的利器。但增强手段不能乱加,得看任务特点。狗品种识别里,水平翻转是安全的,因为狗朝左朝右不影响品种判断。但垂直翻转就要谨慎,因为倒过来的狗在现实中几乎不会出现,可能引入噪声。
我常用的增强组合是:
- RandomResizedCrop:随机裁剪并缩放到固定尺寸,模拟不同拍摄距离。
- RandomHorizontalFlip:水平翻转,概率 0.5。
- ColorJitter:轻微调整亮度、对比度、饱和度,模拟不同光照。
- RandomRotation:小角度旋转,一般 ±15 度以内。
验证集和测试集只做 Resize 和 CenterCrop,不做随机增强,保证评估结果稳定。
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2, 0.05), transforms.RandomRotation(15), 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]) ])那个 Normalize 的均值和标准差是 ImageNet 的统计值,因为 GoogleNet 是在 ImageNet 上预训练的,输入分布保持一致才能发挥预训练权重的效果。这个细节新手很容易忽略,直接用 0.5 的均值方差,结果收敛慢很多。
4. GoogleNet 微调的关键步骤
4.1 加载预训练模型并替换分类头
torchvision 里自带 GoogleNet,加载预训练权重很方便:
import torchvision.models as models import torch.nn as nn model = models.googlenet(pretrained=True) num_features = model.fc.in_features model.fc = nn.Linear(num_features, 120)GoogleNet 最后的分类层叫fc,输入维度是 1024,我们把它换成输出 120 类的新全连接层。新加的层是随机初始化的,所以一开始 loss 会比较高,这是正常的。
有个细节:GoogleNet 还有两个辅助分类器(auxiliary classifiers),在训练时会额外产生两个输出。如果你直接用model(x),返回的是一个元组,包含主输出和两个辅助输出。训练时要把这三个损失的加权和作为总损失,权重一般是 1.0、0.3、0.3。如果嫌麻烦,也可以在加载时设置aux_logits=False,但那样就浪费了辅助分类器的正则化效果。我一般保留辅助分类器,训练更稳。
4.2 分阶段微调策略
微调不是一上来就把所有层都解冻,那样容易把预训练学到的通用特征破坏掉。我的做法分两个阶段:
第一阶段:冻结特征层,只训练分类头。把 backbone 的所有参数requires_grad设为 False,只让新的fc层更新。学习率可以设大一点,比如 1e-3,因为只训练一层,收敛很快。这个阶段大概跑 3 到 5 个 epoch,让分类头先适应新的任务。
第二阶段:解冻部分层,做精细微调。把后面的 Inception 模块解冻,学习率调小到 1e-4 甚至 1e-5。前面的底层特征(边缘、颜色)是通用的,不需要动;后面的高层特征(语义、纹理)需要针对狗品种调整。这个阶段跑 10 到 15 个 epoch,配合学习率衰减。
# 第一阶段 for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True # 第二阶段解冻 for param in model.inception5b.parameters(): param.requires_grad = True优化器我用 AdamW,权重衰减设 1e-4。学习率调度用 CosineAnnealingLR,比 StepLR 更平滑,实测效果更好。
4.3 训练循环与模型保存
训练循环本身不复杂,但有几个点要注意。第一,每个 epoch 结束后在验证集上评估,记录验证损失和准确率。第二,保存验证损失最低的模型权重,而不是最后一个 epoch 的,因为过拟合之后验证损失会回升。第三,用torch.save保存 state_dict 而不是整个模型,文件更小,加载也更灵活。
best_val_loss = float('inf') for epoch in range(num_epochs): model.train() for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) # 处理辅助分类器的输出 if isinstance(outputs, tuple): main_out, aux1, aux2 = outputs loss = criterion(main_out, labels) + \ 0.3 * criterion(aux1, labels) + \ 0.3 * criterion(aux2, labels) else: loss = criterion(outputs, labels) loss.backward() optimizer.step() # 验证 model.eval() val_loss = 0.0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) if isinstance(outputs, tuple): outputs = outputs[0] val_loss += criterion(outputs, labels).item() val_loss /= len(val_loader) if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), 'best_model.pth')辅助分类器在验证和推理时只取主输出,这个要记得处理,否则会报错。
5. 生成提交文件与 .csv 保存的完整流程
5.1 测试集预测的正确姿势
测试集的图片在test文件夹下,没有标签文件。我们需要遍历所有测试图片,逐张预测,输出每个品种的概率。这里的关键是测试图片的顺序要和提交文件的 ID 顺序一致,但更重要的是,提交文件的列顺序要和品种映射一致。
model.load_state_dict(torch.load('best_model.pth')) model.eval() test_dir = 'test' test_ids = sorted([os.path.splitext(f)[0] for f in os.listdir(test_dir)]) all_probs = [] with torch.no_grad(): for img_id in test_ids: img_path = os.path.join(test_dir, img_id + '.jpg') image = Image.open(img_path).convert('RGB') image = val_transform(image).unsqueeze(0).to(device) outputs = model(image) if isinstance(outputs, tuple): outputs = outputs[0] probs = torch.softmax(outputs, dim=1).cpu().numpy().flatten() all_probs.append(probs)注意这里用了val_transform,不是训练时的增强变换。测试时不能做随机增强,否则同一张图每次预测结果都不一样。
5.2 构建提交 DataFrame 并保存
拿到所有概率之后,构建一个 DataFrame,第一列是图片 ID,后面 120 列是各品种的概率。列名就是品种名称,顺序必须和breeds列表一致。
import numpy as np submission = pd.DataFrame(all_probs, columns=breeds) submission.insert(0, 'id', test_ids) submission.to_csv('submission.csv', index=False)index=False这个参数一定要加,否则会多出一列索引,提交格式就不对了。我见过有人因为这个被平台拒绝,白跑一次。
保存之前最好做几个检查:
- 行数是否等于测试集图片数量。
- 列数是否是 121(1 个 ID 列 + 120 个品种列)。
- 每行的概率和是否接近 1。
- 有没有 NaN 值。
assert len(submission) == len(test_ids) assert submission.shape[1] == 121 assert not submission.isnull().any().any() print(submission.iloc[:, 1:].sum(axis=1).head())这几行检查花不了几秒钟,但能避免很多低级错误。
5.3 保存标签映射供后续复用
前面提到过,breed_to_idx和idx_to_breed这两个映射一定要保存下来。我一般用 json:
import json with open('breed_to_idx.json', 'w') as f: json.dump(breed_to_idx, f) with open('idx_to_breed.json', 'w') as f: json.dump(idx_to_breed, f)这样下次做推理或者换模型对比时,直接加载映射文件,不用重新跑一遍数据预处理。而且能保证不同模型生成的提交文件列顺序一致,方便做模型融合。
6. 常见问题与排查技巧实录
6.1 标签预处理阶段的典型坑
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 图片路径匹配不上,大量 NaN | ID 被解析成数字,精度丢失 | 读取 csv 时加dtype={'id': str} |
| 品种索引和提交列顺序不一致 | 映射字典未固定或未保存 | 排序后建字典,保存到 json |
| 训练 loss 一直不降 | 标签和图片错位 | 检查 DataFrame 的 path 和 label 是否对应 |
| 验证集某些品种没有样本 | 随机划分未分层 | 用stratify参数分层采样 |
标签错位这个问题特别隐蔽,因为训练照样能跑,loss 也会慢慢降,只是准确率上不去。排查方法是随机抽几张图可视化,看标签对不对。我一般会在训练前抽 9 张图拼成 3x3 的网格看一眼,确认图片和标签匹配。
6.2 训练过程中的疑难杂症
显存不够怎么办?减小 batch size 是最直接的,但太小会影响 BatchNorm 的统计。可以配合梯度累积,比如 batch size 设为 16,累积 4 次,等效 batch size 64。另外把图片尺寸从 224 降到 192 也能省不少显存,精度损失有限。
过拟合太严重怎么办?除了数据增强,还可以加 Dropout。GoogleNet 本身在 fc 层前有 Dropout,可以把概率调高一点。另外早停(early stopping)也很有效,验证损失连续几个 epoch 不降就停。
训练速度太慢怎么办?确保用了 GPU,并且pin_memory=True、num_workers设为 CPU 核心数。如果数据加载是瓶颈,可以把图片预先 resize 到固定尺寸存到磁盘,减少每次读图的开销。
6.3 提交文件相关的坑
最常见的就是列顺序问题。Kaggle 的评估是按列名匹配的,理论上列顺序不影响,但有些比赛确实要求严格顺序。保险起见,我一般按照 sample_submission 的列顺序来排。另外概率和不为 1 也可能出问题,虽然评估时用的是 log loss,但归一化一下更稳妥。
还有一个坑是 ID 的格式。测试集的 ID 是文件名去掉扩展名,但有些图片可能是 .png 而不是 .jpg。如果写死了扩展名,会漏掉一部分图片,提交文件行数就不对了。稳妥的做法是扫描目录建立 ID 到路径的映射,和训练集的处理方式一致。
7. 一些提升效果的经验技巧
7.1 学习率的选择与调度
微调任务里学习率是最重要的超参数。我的经验是:只训练分类头时用 1e-3,解冻后整体微调用 1e-4,如果再解冻更多层就降到 1e-5。配合 CosineAnnealingLR,从初始值平滑降到接近 0,比固定学习率效果好不少。
另外可以用差分学习率,给底层设更小的学习率,给分类头设更大的。PyTorch 里通过参数组实现:
optimizer = torch.optim.AdamW([ {'params': model.fc.parameters(), 'lr': 1e-3}, {'params': model.inception5b.parameters(), 'lr': 1e-4}, ], weight_decay=1e-4)这样底层特征不会被大幅改动,分类头又能快速适应新任务。
7.2 测试时增强(TTA)
TTA 是提升比赛分数的常用技巧。做法是对每张测试图做多种变换(原图、水平翻转、不同裁剪),分别预测后取平均。这样能降低单次预测的方差,提升稳定性。代价是推理时间成倍增加,但比赛提交前跑一次还是值得的。
def predict_tta(model, image, transforms_list): probs = [] for t in transforms_list: inp = t(image).unsqueeze(0).to(device) with torch.no_grad(): out = model(inp) if isinstance(out, tuple): out = out[0] probs.append(torch.softmax(out, dim=1).cpu().numpy().flatten()) return np.mean(probs, axis=0)我一般用原图加水平翻转两种,简单有效,推理时间只翻倍。
7.3 模型融合的思路
如果时间充裕,可以训练几个不同的骨干网络(比如 GoogleNet、ResNet、DenseNet),把它们的预测概率加权平均。不同网络的特征提取方式不同,融合后能互补。权重可以按验证集上的表现来定,表现好的权重大一些。这个技巧在 Kaggle 比赛里几乎是标配,单模型很难冲到前排。
不过模型融合的前提是每个单模型都训练到位,而且提交文件的列顺序完全一致。这就是为什么前面强调要保存映射字典,融合时直接按列名对齐就行。
8. 关于这个项目我个人的一些体会
做这个比赛最大的收获,其实不是模型调参,而是把数据处理这条链路走通了。图片标签预处理看起来简单,但里面藏着很多细节:ID 的字符串类型、品种映射的固定顺序、路径匹配的完整性检查、提交文件的格式校验。这些环节任何一个出问题,模型训得再好也白搭。
我现在做任何图像分类项目,都会先把数据处理脚本单独写成一个模块,跑完之后打印几个关键检查点:样本总数、类别数、每类样本数分布、路径匹配率。这几项确认无误了,再开始训练。这个习惯帮我省了很多返工的时间。
另外一点体会是,微调预训练模型时不要贪心,一上来就解冻所有层。分阶段微调虽然多花点时间,但结果更稳,也更容易定位问题。如果第一阶段分类头都训不好,那说明数据或者标签有问题,这时候去调模型结构是南辕北辙。
最后分享一个小技巧:训练前先用一个很小的子集(比如每个品种 5 张图)跑一遍完整流程,包括生成提交文件。这样能在几分钟内验证整条 pipeline 是否通畅,避免在全量数据上跑了几小时才发现提交格式不对。这个“小步快跑”的思路,在 Kaggle 比赛里特别实用。