资讯动态

Kaggle狗品种识别:GoogleNet微调与标签预处理实战

发布时间:2026/10/15 5:01:11 来源:尧图企业网站定制
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_smoothing0.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, transformNone): self.df df.reset_index(dropTrue) 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_size0.2, stratifylabels_df[label], random_state42 )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(pretrainedTrue) 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_logitsFalse但那样就浪费了辅助分类器的正则化效果。我一般保留辅助分类器训练更稳。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, dim1).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, columnsbreeds) submission.insert(0, id, test_ids) submission.to_csv(submission.csv, indexFalse)indexFalse这个参数一定要加否则会多出一列索引提交格式就不对了。我见过有人因为这个被平台拒绝白跑一次。保存之前最好做几个检查行数是否等于测试集图片数量。列数是否是 1211 个 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(axis1).head())这几行检查花不了几秒钟但能避免很多低级错误。5.3 保存标签映射供后续复用前面提到过breed_to_idx和idx_to_breed这两个映射一定要保存下来。我一般用 jsonimport 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 标签预处理阶段的典型坑问题现象可能原因解决方法图片路径匹配不上大量 NaNID 被解析成数字精度丢失读取 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_memoryTrue、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_decay1e-4)这样底层特征不会被大幅改动分类头又能快速适应新任务。7.2 测试时增强TTATTA 是提升比赛分数的常用技巧。做法是对每张测试图做多种变换原图、水平翻转、不同裁剪分别预测后取平均。这样能降低单次预测的方差提升稳定性。代价是推理时间成倍增加但比赛提交前跑一次还是值得的。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, dim1).cpu().numpy().flatten()) return np.mean(probs, axis0)我一般用原图加水平翻转两种简单有效推理时间只翻倍。7.3 模型融合的思路如果时间充裕可以训练几个不同的骨干网络比如 GoogleNet、ResNet、DenseNet把它们的预测概率加权平均。不同网络的特征提取方式不同融合后能互补。权重可以按验证集上的表现来定表现好的权重大一些。这个技巧在 Kaggle 比赛里几乎是标配单模型很难冲到前排。不过模型融合的前提是每个单模型都训练到位而且提交文件的列顺序完全一致。这就是为什么前面强调要保存映射字典融合时直接按列名对齐就行。8. 关于这个项目我个人的一些体会做这个比赛最大的收获其实不是模型调参而是把数据处理这条链路走通了。图片标签预处理看起来简单但里面藏着很多细节ID 的字符串类型、品种映射的固定顺序、路径匹配的完整性检查、提交文件的格式校验。这些环节任何一个出问题模型训得再好也白搭。我现在做任何图像分类项目都会先把数据处理脚本单独写成一个模块跑完之后打印几个关键检查点样本总数、类别数、每类样本数分布、路径匹配率。这几项确认无误了再开始训练。这个习惯帮我省了很多返工的时间。另外一点体会是微调预训练模型时不要贪心一上来就解冻所有层。分阶段微调虽然多花点时间但结果更稳也更容易定位问题。如果第一阶段分类头都训不好那说明数据或者标签有问题这时候去调模型结构是南辕北辙。最后分享一个小技巧训练前先用一个很小的子集比如每个品种 5 张图跑一遍完整流程包括生成提交文件。这样能在几分钟内验证整条 pipeline 是否通畅避免在全量数据上跑了几小时才发现提交格式不对。这个“小步快跑”的思路在 Kaggle 比赛里特别实用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑