☰
PyTorch实战:GoogleNet微调与多分类图像任务全流程
2026/10/15 5:01:10 网站建设 项目流程

1. 从零拆解一个多分类图像任务:为什么选这个比赛练手

图像分类的入门项目很多,但真正能把「数据预处理 → 模型微调 → 结果落盘」这条链路完整跑通、并且逼着你处理真实脏数据的,其实没几个。Dog Breed Identification 这个比赛就是其中一个典型:训练集和测试集都是真实场景下的狗照片,光照、姿态、背景、遮挡全都不一样,而且类别数多达 120 种。更关键的是,它的评估指标是 Multi-Class Log Loss,不是简单的准确率,这意味着你输出的不能只是「预测成哪一类」,而必须是每一类的概率分布。

我当初挑这个项目,核心目的不是刷榜,而是想借它把三件事彻底搞明白:第一,图片标签的预处理到底有多少坑;第二,微调一个预训练网络时,哪些层该冻、哪些层该放、学习率怎么设;第三,怎么把预测结果按比赛要求的格式写成.csv文件,并且保证行数、列名、顺序一个都不错。这三件事看起来简单,但真动手做的时候,每一步都能卡住人。

这篇文章适合谁看?如果你已经会用 Python 读写文件、知道什么是张量、跑过最简单的全连接网络,但还没完整做过一个图像多分类项目,那这篇就是写给你的。我会把整个流程拆成可复现的步骤,参数怎么算、为什么这么设、哪里容易翻车,全部讲清楚。代码基于 PyTorch 生态,预训练模型用 GoogleNet(也就是 Inception v1),因为它的参数量适中、结构清晰,特别适合拿来理解「微调」这件事的本质。

先说结论性的判断:这个任务的核心难点不在模型结构,而在数据管道。120 类、每类样本数量不均、图片尺寸各异、标签是字符串而不是数字——这些才是真正消耗你时间的地方。模型部分反而是最标准化的,torchvision 里几行就能搭起来。所以下面的内容,我会把大量篇幅放在标签编码、数据集划分、以及结果文件的组织上。

2. 数据管道的设计与标签预处理的核心逻辑

2.1 为什么标签预处理是这类比赛的第一个拦路虎

很多人拿到数据第一反应是直接ImageFolder一把梭,但 Dog Breed 这个比赛的数据组织方式没那么友好。它的标签不是按文件夹分好的,而是集中在一个labels.csv里,两列:id和breed。图片则统一放在train/目录下,文件名就是{id}.jpg。这就意味着你不能直接用ImageFolder,必须自己写一个Dataset子类,把「文件名 → 标签」这个映射关系手动建立起来。

这里第一个坑就来了:标签是字符串,比如scottish_deerhound、bluetick,模型只认从 0 开始的整数索引。所以你需要做标签编码(Label Encoding)。做法是取出所有唯一 breed,排序后建立breed → index的字典。为什么要排序?因为如果你不排序,字典的键顺序在不同运行环境下可能不一致,导致你训练时的标签索引和推理时的索引对不上,最后提交的 csv 里类别全错位。这个坑我踩过,当时排查了半天才发现是字典顺序问题。

import pandas as pd labels_df = pd.read_csv('labels.csv') breeds = sorted(labels_df['breed'].unique()) breed_to_idx = {b: i for i, b in enumerate(breeds)} idx_to_breed = {i: b for b, i in breed_to_idx.items()} labels_df['label'] = labels_df['breed'].map(breed_to_idx)

排序这一步看似多余,实则是保证可复现性的关键。你还要把idx_to_breed保存下来,因为最后生成提交文件时,需要把模型输出的索引再映射回 breed 字符串。

2.2 训练集与验证集的划分策略

这个比赛的训练集大约有 10000 张出头,120 类,平均每类 80 多张,但分布并不均匀,有的类可能只有 60 多张,有的能到 100 多张。如果你直接随机按 8:2 划分,某些类别可能在验证集里只有十几张,评估波动会很大。

我的做法是分层抽样(Stratified Split),保证每个类别在训练集和验证集中的比例一致。用sklearn的train_test_split加stratify参数就行:

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 )

random_state固定住,保证每次划分结果一样。分层抽样的意义在于:如果某个类别样本特别少,随机划分可能让验证集里一张该类图片都没有,那验证 loss 就会失真,你根本不知道模型在这个类上到底学得怎么样。

提示:划分完之后,建议打印一下训练集和验证集的类别分布,确认每个类在两个集合里都有样本。如果发现某个类在验证集里少于 5 张,可以考虑调整划分比例或者做数据增强来补充。

2.3 图像读取与增强的取舍

图像增强在训练阶段几乎是必须的,但增强方式要贴合任务特点。狗的照片,水平翻转是安全的,因为狗不会因为左右翻转就变成另一个品种。但垂直翻转就要谨慎,因为狗正常不会倒立出现,垂直翻转可能引入不自然的样本。颜色抖动(Color Jitter)可以适度用,但幅度不能太大,否则毛色特征被破坏,而毛色恰恰是区分品种的重要线索。

验证集和测试集的预处理必须和训练集保持一致,但不能加随机增强,只能做确定性的 resize 和归一化。这一点很多人会忽略,导致验证指标虚高或虚低。

from torchvision import transforms train_tfms = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), 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_tfms = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

归一化用的均值和方差是 ImageNet 的统计值,因为 GoogleNet 是在 ImageNet 上预训练的,保持输入分布一致才能最大程度利用预训练权重。如果你自己算一遍数据集的均值和方差,理论上更贴合,但实测差别不大,直接用 ImageNet 的值最省事。

3. GoogleNet 微调:冻结策略与学习率设置

3.1 为什么选 GoogleNet 而不是 ResNet

GoogleNet 和 ResNet 都是经典骨干网络,但在这个任务里我选 GoogleNet 有几个实际理由。第一,它的参数量大约 600 多万,比 ResNet50 的 2500 万小得多,在单卡显存有限的情况下更友好。第二,它的 Inception 模块结构清晰,辅助分类器(auxiliary classifier)的设计对理解「多尺度特征融合」很有帮助。第三,对于 120 类、1 万张图这个规模,GoogleNet 的容量已经足够,用更大的网络反而容易过拟合。

当然,如果你追求更高的准确率,ResNet 或 EfficientNet 系列确实更强。但作为学习微调流程的项目,GoogleNet 的性价比最高。

3.2 冻结哪些层,放哪些层

微调的核心思想是:底层特征(边缘、纹理)是通用的,不需要重新学;高层特征(语义、部件)和分类头是任务相关的,需要重新训练。所以标准做法是冻结前面的卷积层,只训练后面的层和新的分类头。

GoogleNet 在 torchvision 里的结构,特征提取部分叫features,分类器叫fc。原始fc输出是 1000 类(ImageNet),我们要换成 120 类。做法是:

import torch.nn as nn from torchvision import models model = models.googlenet(pretrained=True) # 冻结所有特征提取层 for param in model.parameters(): param.requires_grad = False # 替换分类头 model.fc = nn.Linear(model.fc.in_features, 120) # 新分类头默认 requires_grad=True,会被训练

但这里有个细节:GoogleNet 还有两个辅助分类器aux1和aux2,它们在训练时也会输出预测并参与 loss 计算。如果你只替换了fc,辅助分类器的输出还是 1000 类,会导致维度不匹配报错。所以要么把辅助分类器也替换掉,要么在 forward 时忽略它们。torchvision 的实现里,aux_logits参数可以控制是否启用辅助分类器:

model = models.googlenet(pretrained=True, aux_logits=False)

关掉辅助分类器最简单,代价是失去了一点正则化效果。如果你想保留,就需要把aux1.fc和aux2.fc也替换成 120 类,并且确保它们的参数可训练。

3.3 学习率的分组设置

微调时,不同层应该用不同的学习率。新加的分类头是随机初始化的,需要较大的学习率快速收敛;而预训练的卷积层即使被解冻,也应该用很小的学习率,避免破坏已经学好的特征。

我的做法是分两组:分类头用1e-3,卷积层用1e-4。如果显存和时间允许,可以先只训练分类头几个 epoch,再解冻后面的卷积块一起微调。

optimizer = torch.optim.Adam([ {'params': model.fc.parameters(), 'lr': 1e-3}, {'params': model.features.parameters(), 'lr': 1e-4} ])

注意,这里model.features.parameters()只有在解冻后才有梯度。如果你前面冻结了,这一步其实只训练fc。解冻的时机一般是:先冻结训练 3-5 个 epoch,等分类头 loss 降下来,再解冻最后几个 Inception 块。

注意:解冻后一定要重新创建 optimizer,因为 PyTorch 的 optimizer 在创建时就绑定了参数列表,动态改变requires_grad不会自动更新 optimizer 的参数组。

3.4 损失函数与评估指标

比赛用的是 Multi-Class Log Loss,所以训练时直接用CrossEntropyLoss就行,它内部就是 softmax + NLLLoss,和 log loss 等价。评估时,你需要把模型输出的 logits 过一遍 softmax 得到概率,再按 log loss 公式计算。

criterion = nn.CrossEntropyLoss() # 评估时 model.eval() with torch.no_grad(): logits = model(images) probs = torch.softmax(logits, dim=1)

Log Loss 对概率的校准很敏感。如果你模型输出的概率过于自信(比如某类给 0.99 但实际错了),惩罚会非常大。所以训练后期可以观察验证集的 log loss,如果准确率还在涨但 log loss 开始升,说明模型过拟合了,需要加正则化或早停。

4. 完整实操流程:从数据加载到模型训练

4.1 自定义 Dataset 的写法

前面说了标签在 csv 里,图片在文件夹里,所以需要自定义 Dataset。核心是__getitem__返回(image_tensor, label_index)。

from torch.utils.data import Dataset from PIL import Image import os class DogDataset(Dataset): def __init__(self, df, img_dir, transform=None): self.df = df.reset_index(drop=True) self.img_dir = img_dir self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img_path = os.path.join(self.img_dir, row['id'] + '.jpg') image = Image.open(img_path).convert('RGB') label = row['label'] if self.transform: image = self.transform(image) return image, label

convert('RGB')这一步不能省,因为有些图片可能是灰度图或带 alpha 通道,不统一转成 RGB 会导致后续归一化报错。reset_index(drop=True)也很重要,否则iloc可能因为索引不连续而出问题。

4.2 DataLoader 的参数设置

from torch.utils.data import DataLoader train_dataset = DogDataset(train_df, 'train', transform=train_tfms) val_dataset = DogDataset(val_df, 'train', transform=val_tfms) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True)

batch_size=32是个稳妥的起点,显存不够就降到 16。num_workers设成 CPU 核心数的一半左右比较合适,太多反而会因为进程切换开销变慢。pin_memory=True在 GPU 训练时能加速数据传输。

4.3 训练循环与验证

训练循环的标准结构是:前向 → 计算 loss → 反向 → 更新。每个 epoch 结束后在验证集上评估。

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) for epoch in range(num_epochs): 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() * images.size(0) train_loss = running_loss / len(train_dataset) model.eval() val_loss = 0.0 correct = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) val_loss += loss.item() * images.size(0) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() val_loss /= len(val_dataset) val_acc = correct / len(val_dataset) print(f'Epoch {epoch+1}: train_loss={train_loss:.4f}, ' f'val_loss={val_loss:.4f}, val_acc={val_acc:.4f}')

这里记录的是loss.item() * images.size(0),最后除以数据集大小,得到的是按样本加权的平均 loss,比直接平均每个 batch 的 loss 更准确,因为最后一个 batch 可能不满。

4.4 学习率调度与早停

微调时学习率调度很关键。我一般用ReduceLROnPlateau,监控验证 loss,如果连续 2 个 epoch 不降就把学习率乘以 0.5。

scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=2 ) # 每个 epoch 结束后 scheduler.step(val_loss)

早停则是防止过拟合的最后一道防线。如果验证 loss 连续 5 个 epoch 不降,就停止训练,保存验证 loss 最低的那个模型权重。

best_val_loss = float('inf') patience_counter = 0 if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), 'best_model.pth') patience_counter = 0 else: patience_counter += 1 if patience_counter >= 5: print('Early stopping') break

5. 预测与生成提交 CSV 的完整细节

5.1 测试集的加载与推理

测试集的图片在test/目录下,文件名是{id}.jpg,但没有标签。你需要写一个只返回图片和 id 的 Dataset。

class TestDataset(Dataset): def __init__(self, img_dir, transform=None): self.img_dir = img_dir self.transform = transform self.img_ids = [f[:-4] for f in os.listdir(img_dir) if f.endswith('.jpg')] def __len__(self): return len(self.img_ids) def __getitem__(self, idx): img_id = self.img_ids[idx] img_path = os.path.join(self.img_dir, img_id + '.jpg') image = Image.open(img_path).convert('RGB') if self.transform: image = self.transform(image) return image, img_id

推理时用torch.no_grad()关闭梯度,并且用model.eval()切换到评估模式(影响 BatchNorm 和 Dropout 的行为)。

model.eval() all_probs = [] all_ids = [] with torch.no_grad(): for images, ids in test_loader: images = images.to(device) outputs = model(images) probs = torch.softmax(outputs, dim=1) all_probs.append(probs.cpu().numpy()) all_ids.extend(ids)

5.2 概率矩阵的拼接与列名映射

把所有 batch 的概率拼成一个大的 numpy 数组,形状是(样本数, 120)。列的顺序必须和breed_to_idx一致,因为模型输出的第 i 列对应的是索引 i 的 breed。

import numpy as np all_probs = np.concatenate(all_probs, axis=0)

然后构造 DataFrame,列名用 breed 字符串,第一列是 id。

submission = pd.DataFrame(all_probs, columns=breeds) submission.insert(0, 'id', all_ids)

这里breeds是排序后的列表,和breed_to_idx的键顺序一致。如果你前面没排序,这里就会错位。

5.3 保存 CSV 的注意事项

保存时有两个细节:一是index=False,否则会多出一列行号;二是确认行数等于测试集图片数。

submission.to_csv('submission.csv', index=False) print(submission.shape) # 应该是 (测试集数量, 121)

121 是因为 120 个类别列加上 1 个 id 列。如果行数不对,说明有图片没被读到,或者 id 提取有问题。

提示:提交前一定要打开 csv 检查前几行,确认 id 和概率值看起来正常。概率应该在 0 到 1 之间,每行的概率和应该接近 1。如果某行和明显偏离 1,说明 softmax 那步出了问题。

5.4 常见格式错误速查

问题现象可能原因解决方法
提交报错列数不对列名或列数不匹配确认是 121 列,列名与 sample_submission 一致
概率和不为 1忘记 softmax 或用了 logits推理时加torch.softmax
id 顺序错乱用了 shuffle=True 的 DataLoader测试集推理时 shuffle 必须为 False
部分 id 缺失文件名提取逻辑有误检查os.listdir是否过滤了非 jpg 文件
概率全为 0 或 1模型输出异常或未训练好检查模型是否加载了正确权重

6. 实操中踩过的坑与排查经验

6.1 标签编码顺序不一致导致的灾难

这是我踩过最隐蔽的坑。第一次做的时候,我用set()取唯一 breed,然后直接转 list 建字典。set的顺序是不确定的,每次运行可能不一样。结果训练时标签索引和生成提交时的列顺序对不上,提交上去的 log loss 高得离谱,但验证集准确率看着还行。排查了很久才意识到是字典顺序问题。从那以后,所有涉及类别映射的地方,我都强制sorted()。

6.2 验证集 loss 震荡大怎么办

如果验证 loss 波动很大,通常是 batch size 太小或者学习率太高。可以先把 batch size 翻倍,或者把学习率降一个数量级。另外,如果验证集样本少,可以改用 K 折交叉验证,取平均结果,稳定性会好很多。

6.3 显存不够的几种解法

显存不够时,按优先级依次尝试:降低 batch size、减小输入尺寸(224 降到 192)、冻结更多层、用混合精度训练(torch.cuda.amp)。混合精度能省将近一半显存,而且速度更快,但要注意 loss scaling 的设置。

6.4 模型不收敛的排查顺序

如果训练几个 epoch 后 loss 几乎不降,按这个顺序查:先确认标签编码没问题,再确认数据加载时图片和标签是否对应,然后检查学习率是不是太小或太大,最后看预训练权重是否加载成功。我遇到过一次是pretrained=True写成了pretrained=False,等于从随机初始化开始训,当然收敛慢。

6.5 保存和加载模型权重的最佳实践

保存时用state_dict()而不是整个模型,这样文件小、加载灵活。加载时先实例化同样结构的模型,再load_state_dict。

torch.save(model.state_dict(), 'model.pth') # 加载 model = models.googlenet(aux_logits=False) model.fc = nn.Linear(model.fc.in_features, 120) model.load_state_dict(torch.load('model.pth')) model.eval()

注意加载时也要关掉aux_logits,否则结构不匹配会报错。

7. 几个能明显提升效果的小技巧

第一个技巧是测试时增强(TTA)。对同一张测试图做多次不同的变换(比如原图、水平翻转、不同裁剪),分别推理后把概率平均。这个操作几乎不增加训练成本,但通常能降 0.01 到 0.02 的 log loss。

第二个技巧是类别权重。如果某些类别样本特别少,可以在CrossEntropyLoss里传weight参数,给少样本类别更高的权重,缓解不平衡问题。

第三个技巧是余弦退火学习率。相比ReduceLROnPlateau,余弦退火在训练后期能让学习率平滑降到接近 0,有助于模型收敛到更平坦的极小值,泛化更好。

scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=num_epochs, eta_min=1e-6 )

第四个技巧是梯度裁剪。微调时偶尔会遇到梯度爆炸,加一行torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)就能稳住。

我个人在实际操作中的体会是,这个项目最大的价值不在于最后刷到多少分,而在于它逼着你把「标签 → 数据 → 模型 → 结果文件」这条链路每一环都亲手走一遍。尤其是 csv 生成那一步,看起来最简单,但格式、顺序、列名任何一个细节错了,整个提交就废了。把这条链路跑通之后,再换任何图像分类任务,你都能快速迁移过去。

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

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

立即咨询