图像分类是深度学习里最典型、也最容易做出成就感的方向之一。李哥这节课把之前讲过的张量、卷积、池化、反向传播、交叉熵这些概念全部串起来,带着我们从头把一个分类模型跑通。这一节的核心目标很明确:给定一批图片,让模型自己学会区分不同类别,并且在没见过的图片上也能给出靠谱的判断。
这篇内容会解决三件事:环境与数据怎么准备、模型结构怎么定、训练完怎么评估和排查问题。不管你是刚看完理论课的初学者,还是已经做过回归任务、想往视觉方向转的开发者,只要跟着把流程走一遍,就能搭出属于自己的第一套图像分类实战流程。整个过程里我会把每个关键选择背后的理由也讲清楚,避免“照抄代码但不知道自己在干嘛”的状态。
1. 图像分类在学什么——先想清楚再动手
很多人第一次做图像分类,上来就找模型、调参数,结果跑通了也不知道为什么能通。李哥这节课有个好处,是先把“图像分类到底在干什么”这件事讲透了,再让代码落地。
1.1 图像分类的本质是条件概率建模
图像分类表面上看是“给一张图贴标签”,本质上是在做一个条件概率建模:给定输入图像,计算它属于每个类别的概率,最后选概率最大的那个类别作为输出。
这里有个容易忽略的点:分类模型输出的不是“是猫还是狗”这种硬判断,而是一个概率分布。比如输入一张猫图,模型可能输出猫的概率是0.92、狗是0.05、鸟是0.03,最终取argmax得到“猫”。在训练过程中,模型学习的目标就是让正确类别的概率尽量接近1,让错误类别的概率尽量接近0。
这个认知很重要,因为它直接影响两件事。第一,损失函数要用交叉熵而不是均方误差——交叉熵天然适合衡量两个概率分布之间的差异,而均方误差更适合回归问题。第二,模型最后一层通常要加Softmax,把网络输出的原始分数(logits)压缩成一组和为1的概率值。李哥反复强调过:CrossEntropyLoss在PyTorch里其实已经内置了Softmax,所以很多代码里不加Softmax也能正常训练,但推理阶段如果要给人看置信度,就得显式算一遍Softmax。
1.2 深度网络为什么能省掉手工特征
在深度学习普及之前,图像分类的常规做法是“特征工程加分类器”。先用SIFT、HOG这类手工设计的算子提取特征,再把特征喂给SVM之类的分类器。这套流程的痛点很明显:特征设计依赖大量经验,换一个数据集、换一种光照条件,可能就要重新设计特征。
深度学习把这两步合并成一步。卷积网络从原始像素出发,逐层自动学习特征:浅层学到边缘、纹理、颜色块,中层学到局部形状,深层学到更具语义性的部件。用人话解释就是:传统做法是拿一把固定尺寸的尺子去量各种物体,深度网络则是自己学会做尺子,还能根据不同任务调整尺子的形状。
这背后的关键支撑是反向传播和梯度下降。因为网络每一层的卷积核都是可学习参数,所以模型可以自动根据损失函数的反馈调整每个卷积核的权重。这一点也是理解后面所有调参工作的基础——你调学习率、加数据增强、加Dropout,本质上都是在影响模型“学做尺子”的过程。
2. 实战环境和数据集准备
动手写代码之前,先把环境理清楚。李哥这节课用的是PyTorch,理由很直接:动态计算图对新手友好,调试时能直观看到每一步的中间结果,而且torchvision自带常用数据集和预训练模型,做图像分类非常顺手。
2.1 工具链与版本搭配的选择思路
我的建议是不要追求最新版本,选一个稳定组合就够了。基本依赖是这么几样:Python环境、PyTorch框架、torchvision视觉库、Pillow图像处理库、matplotlib可视化库。如果涉及评估指标,还可以加上scikit-learn。
安装完成后第一步就是验证环境是否正常。
python -c "import torch; print(torch.__version__)" python -c "import torchvision; print(torchvision.__version__)"如果这两行能正常输出版本号,说明环境基本没问题。这里特别提醒一点:torchvision的版本需要和PyTorch版本匹配,否则import时可能报错,比如找不到某个动态库。遇到这类报错,大多数情况下不需要自己手动解决依赖,直接按照官方启用的安装命令重新安装,让包管理器自动选兼容版本,比手动折腾快得多。
2.2 一份能跑通的三分类小数据集
这节课用的数据集不是现成的经典数据集,而是李哥自己整理的一份小型三分类数据集,里面包含猫、狗、鸟三个类别的图片。我当时听到这里就觉得这个方法很适合教学:类别少,模型容易学;图片量不大,单张显卡也能快速跑完一个实验。
数据集目录结构是标准格式,直接用torchvision的ImageFolder就能读取。
mini_dataset/ ├── train/ │ ├── cat/ │ ├── dog/ │ └── bird/ └── val/ ├── cat/ ├── dog/ └── bird/这里有个容易被忽略的细节:ImageFolder会按照文件夹名称的字母顺序给类别编号。也就是说,bird、cat、dog三个文件夹会被自动映射成0、1、2。如果你的业务场景对类别顺序有要求,最好在读取数据后打印一下class_to_idx,确认映射关系,避免后面看混淆矩阵时认错类别。
实际训练时,每个类别的图片数量不需要太多。像我这次实践,每类大概放了140张训练图、40张验证图,模型就能学到很不错的区分能力。但数据量少的时候,类别覆盖度要尽量广,比如猫最好包含不同花色、不同姿态、不同背景的图片,否则模型很容易靠背景线索做判断,而不是真正学会识别猫本身。
2.3 预处理和加载的完整写法
图像预处理是图像分类里特别容易踩坑的一环。训练和验证要用同一套预处理逻辑,但验证集不需要数据增强。我这里直接给出一个能跑的配置:
from torchvision import datasets, transforms from torch.utils.data import DataLoader train_transform = transforms.Compose([ transforms.Resize((128, 128)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=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((128, 128)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_dataset = datasets.ImageFolder("mini_dataset/train", train_transform) val_dataset = datasets.ImageFolder("mini_dataset/val", val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=2) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=2)这里解释几个关键点。Resize((128, 128))把图片统一缩放到固定尺寸,因为全连接层要求输入特征维度固定;RandomHorizontalFlip是随机水平翻转,属于最基础的数据增强手段,相当于免费增大训练样本的多样性;ColorJitter随机调整亮度、对比度,让模型对光照变化更鲁棒。
Normalize的mean和std用的是ImageNet的统计值,这是非常常见的做法。归一化的意义在于让每个颜色通道的数值分布接近标准正态分布,有助于梯度下降更稳定。很多人会问“为什么我自己的小数据集也用它”,答案很简单:直接用这套经验值基本不会出错,它能让模型训练更快收敛,而且几乎不需要针对小数据集重新计算均值和方差。
3. 构建分类模型:把理论变成可训练的结构
网络结构不需要一开始就上ResNet这种大模型。李哥带我们先写了一个小型卷积网络,目的就是让大家直观看到每一层在做什么,而不是把模型当成黑盒。等流程跑通了,再换预训练模型也不迟。
3.1 一个能跑通的卷积网络长什么样
我这次用了一个三层卷积加两层全连接的小网络。结构设计遵循一个常见规律:随着网络加深,特征图的通道数逐渐增加,空间尺寸逐渐缩小,最后通过全连接层输出类别数。
import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=3): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 16, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2) ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 16 * 16, 256), nn.ReLU(inplace=True), nn.Linear(256, num_classes) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x结构本身不复杂,这里重点说两个设计理由。第一,卷积层kernel_size选择3并且padding=1,这样卷积操作不会改变特征图尺寸,尺寸收缩完全交给池化层来控制,计算和调试都更直观。第二,最后全连接层的输入维度是64乘16乘16,这个数字不是拍脑袋定的,而是由输入尺寸128经过三次池化后计算出来的:每经过一次MaxPool2d,尺寸减半,128经过三次减半变成16,通道数最终是64,所以展平后就是64乘以16乘以16。
如果后面换了大尺寸输入,比如256,那么这个维度就要重新计算。为了省事,可以先把网络forward跑一遍,用打印每层输出shape的方式确定Flatten后的维度,这比手算更不容易出错,也是李哥课上反复强调的调试习惯。
3.2 损失函数、优化器与评价指标选型
分类任务的损失函数几乎固定用交叉熵。在PyTorch里,nn.CrossEntropyLoss接收的是网络输出和真实标签,内部已经包含了Softmax操作。我前面提到过,这里再说一句:不要在网络输出层再手动加Softmax再算损失,那样会做两次Softmax,虽然不会直接报错,但在数值上会损失精度,梯度也不对。
优化器我选择Adam,初始学习率设为0.001。Adam的优势是自适应调整每个参数的学习步长,对新手来说不用太操心学习率衰减策略,默认参数就能跑出不错的效果。李哥的原话大意是:先用Adam快速看到一个模型能学到什么程度,如果后续想刷精度,再换SGD加momentum、配合学习率调度器去精细调。
评价指标这里容易犯一个错误:只盯着准确率看。准确率确实是最直观的指标,但在类别不均衡时会有误导性。比如三个类别占比是90%、5%、5%,模型全部预测成第一类,准确率也有90%,但模型并没有真正学会分类。所以我这次在训练过程中同时记录了每个类别的混淆矩阵,后面还会专门讲怎么分析。
3.3 训练循环的完整实现
训练循环是整个实战流程最核心的部分。李哥把训练循环拆成了四个动作:前向传播算损失、清空梯度、反向传播、更新参数。这四个动作顺序不能乱,尤其是清空梯度那一步,新手经常忘。
def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss = 0 correct = 0 total = 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) correct += (outputs.argmax(dim=1) == labels).sum().item() total += labels.size(0) avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy为什么要optimizer.zero_grad()?因为PyTorch的梯度默认是累积的,backward()会把新的梯度累加到已有的梯度上。如果在每个batch不清零,那么上一次batch的梯度会跟当前batch的梯度叠加,结果是参数更新方向完全混乱。这在显存受限、需要梯度累积的场景里反而是一个特性,但常规训练里一定要每个batch清零一次。
还有一个很容易被忽略的细节:model.train()和model.eval()的切换。train()模式会让Dropout生效、BatchNorm使用当前batch的统计量;eval()模式则关闭Dropout、BatchNorm改用训练阶段保存的全局统计量。很多人直接在验证阶段忘了调回eval(),导致验证结果忽高忽低,怎么排查都找不到原因。
4. 训练过程与调参实战
训练不是代码跑完就完事,关键是要能看懂训练过程中出现的各种信号。我这次完整跑了20个epoch,把训练曲线和中间出现的问题都记录了下来,这里挑几个最有价值的点分享。
4.1 学习率设置与收敛速度的关系
初始学习率直接决定前几个epoch的loss下降速度。我测试过三组学习率:0.01、0.001、0.0001。0.01的loss在第一个epoch就震荡得很厉害,损失值完全下不去;0.001的loss稳步下降,从初期1.0左右降到后面0.2左右;0.0001的loss下降非常慢,跑完10个epoch才勉强接近0.6。
这就说明一个问题:学习率太大,参数更新跨步太大,容易在损失曲面震荡;学习率太小,模型学得慢,需要更多轮数才能收敛。0.001这个经验值对大多数图像分类任务都适用,尤其是配合Adam优化器的时候,基本上从0.001开始都不会出大问题。
如果训练后期精度上不去,可以加入CosineAnnealingLR这类学习率调度器,让学习率在训练后期逐渐变小,帮助模型在最优解附近做更精细的调整。我后面加了这个调度器,验证集准确率大约提升了2到3个百分点。
4.2 过拟合的信号与应对手段
我这次的训练数据每类只有140张,模型到第12个epoch左右,训练集准确率已经接近99%,但验证集准确率停在82%左右,这就是典型的过拟合信号:模型把训练集的细节背下来了,但没有真正泛化到新数据。
应对手段优先级很明确。第一是加数据增强,RandomHorizontalFlip和ColorJitter实行起来几乎零成本,能显著缓解过拟合。第二是加入Dropout,我在全连接层之间加了一层nn.Dropout(p=0.5),验证准确率提升了3个百分点左右。第三是早停,监控验证集loss,当连续5个epoch验证loss不再下降时停止训练。
这三种手段可以叠加使用,但要注意不要过度正则化。如果训练集准确率和验证集准确率同时都很低,那就不是过拟合问题,而是欠拟合,这时候应该增加模型容量,而不是继续加正则化。
4.3 从训练曲线判断模型状态
我每次训练都会记录训练集和验证集两个维度的loss和准确率,画成四条曲线。这个习惯帮我避免了很多盲目调参。
正常情况下,训练loss和验证loss应该同步下降。如果训练loss还在降、验证loss反而开始回升,说明已经进入过拟合区间,模型应该在这之前就停止。如果训练loss和验证loss都居高不下,首先要看数据预处理是不是有问题,比如归一化参数没用对,图片被当成全黑或全白数值喂进网络。如果训练loss下降但验证准确率一直卡在某一水平不动,有可能是类别本身极易混淆,或者训练数据覆盖不足。
我在实验日志里看到过一次验证准确率突然跳升又跌落的情况,后来发现是验证集里某个batch恰好包含大量简单样本,导致准确率虚高。这种情况说明单次准确率波动很正常,判断模型好坏要看整体趋势,不要被单次epoch的数值牵着走。
5. 评估与推理:模型好不好,不能只看acc
训练结束之后,李哥专门留了一节课的时间讲评估和推理。这一部分的价值被很多人低估,因为“训练完模型”和“模型真正能用”之间还差着一大段距离。
5.1 评估指标怎么选
准确率是最基础的指标,但只看准确率会掩盖很多问题。我这次做了个对比实验,单独看猫这个类别时,精确率和召回率明显不均衡。精确率低,说明很多狗和鸟的图片被误判成猫;召回率低,说明很多猫图没有被认出来。
这里顺便整理一下几个指标的含义和使用场景。
| 指标 | 关心的问题 | 适用场景 |
|---|---|---|
| 准确率 | 所有预测中猜对的比例 | 各类别样本量均衡时适用 |
| 精确率 | 预测为某类的结果中有多少是对的 | 误报代价高时重点关注 |
| 召回率 | 某类真实样本中有多少被找回来 | 漏报代价高时重点关注 |
| F1值 | 精确率和召回率的综合平衡 | 类别不均衡时最有参考价值 |
对于猫狗鸟这种相对均衡的三分类问题,准确率和F1差别不大;但如果放到真实业务场景,比如工业缺陷检测,缺陷样本很少,这时就必须以召回率为主来判断模型是否可靠。
5.2 混淆矩阵定位模型弱点
混淆矩阵是分析分类错误的利器。把验证集所有预测结果和真实标签放在一起,生成一个三乘三的矩阵,就能一眼看出模型具体在哪些类别之间容易犯错。
from sklearn.metrics import confusion_matrix import numpy as np all_labels = [] all_preds = [] model.eval() with torch.no_grad(): for images, labels in val_loader: images = images.to(device) outputs = model(images) preds = outputs.argmax(dim=1).cpu().numpy() all_labels.extend(labels.numpy()) all_preds.extend(preds) cm = confusion_matrix(all_labels, all_preds) print(cm)我这次实验的混淆矩阵显示,主要错误集中在猫和狗之间互相混淆,鸟的误判相对较少。这其实很合理:猫和狗在体型、毛色、背景上都有重叠,而鸟在轮廓上和前两者差异更大。如果后续要提升模型效果,最有效的做法不是盲目换大模型,而是针对猫和狗的混淆样本专门收集更多姿态差异明显的训练数据。
分析混淆矩阵时还要注意一个细节:each row represents真实类别,each column represents预测类别。矩阵第0行第1列的值,表示真实为第0类但被预测成第1类的样本数量。很多人第一次看反了,分析结论就会完全跑偏。
5.3 模型保存与单张图片推理
模型训练好之后需要保存,PyTorch最推荐的方式是保存state_dict,而不是直接保存整个模型。这样做的优势是:模型结构变更后,老权重还能和新结构灵活匹配,迁移部署也更安全。
torch.save(model.state_dict(), "model_weights.pth")这套流程跑通后,我再也没用过直接保存整个模型的方式,因为吃过一次亏:模型结构类改名之后,直接加载旧模型文件直接报错,而state_dict永远不会受这种问题影响。
单张图片推理的核心代码不长,但有几个细节必须注意。第一,推理前要写model.eval();第二,要加上torch.no_grad(),让框架不要保存计算图,既省显存又加快速度;第三,输入图片的预处理要和训练时完全一致。
from PIL import Image import torchvision.transforms.functional as TF def predict_image(model, image_path, class_names, device): model.eval() image = Image.open(image_path).convert("RGB") image = val_transform(image).unsqueeze(0).to(device) with torch.no_grad(): outputs = model(image) probs = torch.softmax(outputs, dim=1) confidence, pred = torch.max(probs, dim=1) print(f"预测类别: {class_names[pred.item()]}") print(f"置信度: {confidence.item():.4f}")这里有一个新手常犯的错误:训练时用的是train_transform,推理时却直接复用同一个transform,结果随机翻转和颜色抖动在推理时还在生效,导致同一张图预测结果不稳定。推理必须用val_transform那一套,只做缩放、归一化,不做随机增强。这个坑我踩过一次,后来在代码里严格分开训练和验证的transform才彻底解决。
6. 踩坑记录与注意事项速查表
最后这部分是我这次实战过程中真实遇到的坑,每条都花过时间排查。整理成速查表,方便以后直接对照。
6.1 模型不收敛或收敛慢的检查顺序
遇到loss不降或者降得很慢,不要急着改网络结构,先按顺序排查下面几项。
| 排查项 | 具体检查内容 | 解决办法 |
|---|---|---|
| 数据是否正常 | 打印batch里的图片,确认不是全黑、全白或标签错位 | 修正预处理,检查标签映射 |
| 归一化是否一致 | 训练和验证是否用了同一套Normalize参数 | 统一mean和std |
| 学习率是否合适 | 观察前几个epoch的loss变化幅度 | 过大就调低,过小就调高 |
| 模型输出维度 | 最后一层输出是否是类别数 | 改成num_classes |
| 损失函数是否匹配 | 多分类任务是否用了CrossEntropyLoss | 换损失函数 |
我上次遇到过一种奇怪的情况:训练loss前几个epoch在下降,但下降到一定程度就完全不动了,验证准确率也卡住。后来发现是数据增强里的ColorJitter参数设置过大,图片被调得色彩失真,模型很难学到有效特征。把参数从0.5调回0.2之后,问题立刻缓解。
6.2 显存不足与训练速度慢的处理思路
图像分类对显存的需求主要取决于三件事:输入图片尺寸、batch size、模型复杂度。如果显存不足,首选方案不是换更大的显卡,而是调小batch size,或者把输入图片从128缩到96。batch size减半之后,训练速度会变慢,但通常还能稳定运行。
如果单卡显存很小,连64x64的输入都跑不动,那就要考虑把整个网络的通道数减半,比如把第一层从16通道改成8通道。这样模型参数会少很多,训练速度和显存占用都能得到明显改善。对于图像分类这种任务来说,小模型在小数据集上反而比大模型更稳定,大模型在数据量不足时更容易过拟合。
训练速度方面,最有效的做法是用GPU训练而不是CPU。如果本地没有独立显卡,就用在线GPU环境,跑这种小数据集几分钟就能完成一个实验。
6.3 数据和标签相关的隐藏问题
数据问题往往比模型问题更难排查。我这次遇到过标签映射错误、图片损坏、类别顺序混排这三个问题。
标签映射错误发生在我手动修改文件夹名之后。我把dog文件夹改成了dog_and_cat,结果ImageFolder的class_to_idx重新排列了顺序,后面训练时模型输出和真实标签完全错位,训练loss却还在下降。这个问题的排查方法是:训练前打印class_to_idx,并且抽样可视化一批图片和它对应的标签。
图片损坏也遇到过,有个别的图片文件其实已经损坏,Pillow读取时会直接抛异常。这个问题最简单的解决方法是,在构建Dataset之后先跑一次全量数据检查,能正常加载的图片才留下来。否则训练到一半突然报错,又要从头再来。
还有一个非常隐蔽的问题:训练集和验证集中的部分图片完全相同。因为数据是我从网上手动收集的,个别图片既出现在训练集也出现在验证集,这会让验证结果虚高。收集数据时应该尽量排除重复图片,或者用基于内容哈希的去重工具清理一遍。
整套流程跑完之后,我最深的体会是:图像分类实战的难点不在模型代码,而在数据质量和训练过程的观察能力。代码错了报错信息会告诉你,数据错了却常常表现为模型效果差,排查成本高得多。最后分享一个小习惯——每次训练开始前,把随机抽出的九张训练图和对应标签打印在一张画布上看一眼。这个操作只要十秒钟,却能在训练之前发现大量数据问题,省下的时间远比花费的时间多。