上个月接了个自动化需求,客户给了一堆产品照片,要求按颜色和款式自动分类,不然靠人工一天得翻几千张图。我第一时间想到的就是用 PyTorch 做图片识别。“pytorch 识别图片”听起来像要搭一套很吓人的神经网络,但拆开看,核心就四件事:把图变成数字,把数字丢进网络,拿预测结果跟真实标签算损失,再更新参数让下一轮更准。这篇文章就把这条完整链路从数据准备写到单张图片推理,代码都是可以直接跑的,适合有 Python 基础、想把图片分类真正落地的人。
1. 先想清楚:识别图片到底在算什么
1.1 识别不是“看”,是“算”
很多新手第一次接触图像识别,会下意识以为模型像人一样“看”图片,然后理解内容。实际上 PyTorch 里的图片识别本质上是一次非常机械的数学计算:输入是一堆数字,中间经过很多层矩阵运算,最后输出每个类别的得分。得分高的那个类别就是模型认为图片的内容。
我习惯用一个类比解释给同事听:这就像小时候看图认字,第一眼看到的是线条,然后才是线条组成的部首,最后才是整个字。卷积神经网络也是这个思路,第一层提取边缘和颜色块,中间层组合成纹理和局部形状,深层再组合成“眼睛”“轮子”“把手”这类有语义的部件。不同之处在于,这些特征不需要我们人工设计,模型会从训练数据里自己学。
所以“识别图片”这件事落地到工程上,其实就是图像分类:给定一张图,模型输出它属于哪个类别。理解了这一点,后面很多操作就顺了。
1.2 一张图进网络之前被做了什么
在 PyTorch 里,图片不是以“文件”的形式参与计算的,而是先转换成张量(Tensor)。一张普通的 RGB 彩色图片,会被拆成三个通道(红、绿、蓝),每个通道是一张单色的像素矩阵。假设原图是 224 宽、224 高,那么它在内存里的形状就是 (3, 224, 224),注意不是 (224, 224, 3),PyTorch 默认使用“通道在前”的排列方式。
接下来还要归一化。把像素值从 0 到 255 的范围缩放、平移到某个合适的范围,这是为了让模型训练更稳定。最后,如果一次要处理一批图片,DataLoader 会在最前面再加一个 batch 维度,变成 (B, 3, 224, 224),B 就是这批图片的张数。
这个过程中最容易被忽略的是顺序:不管后面用什么模型,你训练时做的预处理和在预测时做的预处理必须完全一致。我在实操中见过太多次“训练用的归一化是 A,推理脚本里却写了 B”的情况,看起来代码没问题,实际效果崩得厉害。
1.3 一次完整训练的最小闭环
用 PyTorch 训练一个图片分类模型,再简化也有这几个环节:准备数据、构建网络、定义损失函数、选优化器、循环训练、评估效果、保存模型、推理新图。整个闭环跑通以后,后续的优化都是在这个基础上调参数、换网络、清理数据。
这篇文章后面的章节就按这个闭环来展开。我先说一个原则:第一版不要追求复杂的模型和花哨的技巧,先用手头数据把一个最简单流程跑通,再逐步加东西。因为很多问题只有在你真正跑起来以后才会暴露,纸上谈兵没有意义。
2. 数据和预处理:把图片目录变成 DataLoader
2.1 先用文件夹目录当分类标签
准备训练数据时,最省事的方式是让文件夹结构直接告诉我们标签。PyTorch 的torchvision.datasets.ImageFolder就是专门为这种结构设计的,它要求目录这样组织:
data/ ├── train/ │ ├── 类别A/ │ │ ├── 0001.jpg │ │ └── 0002.jpg │ └── 类别B/ │ ├── 0001.jpg │ └── 0002.jpg └── val/ ├── 类别A/ └── 类别B/ImageFolder会自动把子文件夹名称解析成标签,并维护一个classes属性,从 0 开始编号。这个好处在于,你新增类别或者换数据集时,不需要改标注文件,只要把图片放进对应目录就行。
我通常还会写一个小脚本,把原始图片按大约 8:2 的比例随机分到 train 和 val。但是注意,这个划分不能“太随机”。如果同一批连拍的照片被同时分进训练集和验证集,那验证结果是虚高的,因为模型等于提前见过几乎一样的图。更好的做法是按拍摄场景、按个体或者按批次来分,保证验证集里的内容相对独立。这一点我在后面第 6 章还会专门讲。
2.2 处理尺寸、归一化和批量化
接下来是写 transform,把图片统一尺寸、转张量、归一化。一个我能直接用的版本长这样:
from torch.utils.data import DataLoader from torchvision import datasets, transforms data_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_dataset = datasets.ImageFolder("data/train", transform=data_transform) val_dataset = datasets.ImageFolder("data/val", transform=data_transform) 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)这里涉及的三个操作各有讲究。Resize 把图片统一到 224x224,是因为后面的模型输入尺寸是固定的;ToTensor 把 PIL 图片转成张量,并把像素值缩放到 [0, 1];Normalize 再用均值和标准差做标准化,让数据分布更接近模型训练时的分布。
2.3 Normalize 参数不能乱填
上面代码里的 mean 和 std 写成 0.485、0.456、0.406 这几个固定值,很多人只知道照抄,不知道为什么。如果数据集的尺度、内容与预训练模型的训练分布相差很远,直接套用可能效果不好。如果你是自己从零训练模型,最稳妥的做法是统计自己训练集的均值和标准差,再填进去。
import torch from torchvision import transforms tmp = transforms.Compose([transforms.Resize((224, 224)), transforms.ToTensor()]) loader_tmp = DataLoader(datasets.ImageFolder("data/train", transform=tmp), batch_size=64, shuffle=False, num_workers=4) mean = torch.zeros(3) std = torch.zeros(3) count = 0 for images, _ in loader_tmp: b = images.size(0) images = images.view(b, 3, -1) mean += images.mean(dim=[0, 2]) * b std += images.std(dim=[0, 2]) * b count += b mean /= count std /= count用这个 mean 和 std 去替换前面的固定值,会更加贴合自己的数据。如果你用的是预训练模型,通常会更倾向于保留预训练权重对应的归一化参数。等会儿讲迁移学习的时候我会给一个更省心的玩法:直接用权重自带的预处理方法。
3. 模型:从最简 CNN 到迁移学习
3.1 为什么全连接网络做不了图像识别
有些从传统机器学习转过来的同学,第一反应是把图片拉成一维向量,然后接一个全连接网络。假设输入是 224x224x3,拉平以后有 15 万个维度,第一层如果输出 128 个神经元,那这一层就有将近两千万个参数。不仅训练慢,还特别容易过拟合,更关键的是这种方式完全打乱了图片的二维空间结构。
卷积神经网络(CNN)解决的就是这个问题。它用卷积核在图片局部滑动,同一组权重被复用在不同位置,参数数量大幅下降,同时保留了“局部特征”和“平移不变性”。简单说,猫头在图片左上角还是右下角,CNN 都能通过同一组卷积核提取到相似的特征,全连接网络则很难做到这一点。
3.2 一个够用的小型 CNN 模板
如果你手里的数据量不大,比如每类只有几百张图,可以先从一个小型 CNN 开始。下面这个结构不算复杂,但作为入门基线已经够用:三层卷积加池化,最后接全连接。
import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 16, kernel_size=3, padding=1), nn.BatchNorm2d(16), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 28 * 28, num_classes), ) def forward(self, x): return self.classifier(self.features(x))这里为什么要加 BatchNorm?它能让每一层的输入分布不那么发散,训练收敛更快,还能在一定程度上缓解梯度问题。为什么输入 224x224,最后全连接输入是 642828?因为经过三次 MaxPool2d,224 被除 2 三次,变成 28,而通道数最终是 64。
这个网络在小数据集上可以作为基准,但它很容易过拟合。我一般会配合数据增强一起用,后面会专门说。
3.3 更省事的选择:迁移学习
大多数情况下,我更推荐直接用预训练模型做迁移学习,尤其是数据量不大但有真实业务需求的时候。预训练模型已经在一个大规模通用图像数据集上学过很久,学会了识别边缘、纹理、物体部件这些通用特征,我们只需要在它基础上微调后面几层,让它适配当前分类任务。
用 PyTorch 加载一个 ResNet18 预训练版本,改动非常小:
import torchvision.models as models from torchvision.models import ResNet18_Weights import torch.nn as nn weights = ResNet18_Weights.DEFAULT model = models.resnet18(weights=weights) num_classes = 2 model.fc = nn.Linear(model.fc.in_features, num_classes)weights = ResNet18_Weights.DEFAULT的作用是自动加载预训练权重。而且它带来了一个额外的好处:可以直接用weights.transforms()拿到和这个权重配套的图像预处理方法,不需要自己瞎猜 mean 和 std。这对新手尤其友好。
data_transform = weights.transforms() train_dataset = datasets.ImageFolder("data/train", transform=data_transform)ResNet 的残差结构让网络可以更深而不至于梯度消失,所以它在实际任务里表现通常远好于自己搭的小网络。第一次跑图像识别,直接用预训练 ResNet18 是最不容易翻车的选择。等流程跑通了,再回头看要不要换成更轻量或更强的结构。
4. 训练评估与调参:让识别准确率从瞎猜到可用
4.1 损失函数与优化器怎么选
多分类任务里,损失函数一般用CrossEntropyLoss。很多人以为要先做 softmax 再算交叉熵,实际上 PyTorch 的CrossEntropyLoss内部已经包含了 softmax,你只需要把网络最后一层的原始输出(logits)直接丢进去就行。如果先手动 softmax 再算,反而可能因为数值问题得到错误结果。
优化器我习惯先用 Adam,学习率从 1e-4 起步。Adam 对学习率没那么敏感,适合快速看到效果。如果你想追求最终的精度和收敛稳定性,后面可以换回带动量的 SGD,配合学习率衰减。但第一版不要在这些选择上纠结太久。
4.2 训练主循环:一个函数搞定 train 和 val
我平时会写一个通用函数来跑一个完整的 epoch,训练和验证都复用同一个函数,只是传入不同的 loader 和 optimizer。这样代码干净,也好排查问题。
import torch import torch.nn.functional as F device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) def run_epoch(model, loader, optimizer=None, device="cpu"): is_train = optimizer is not None model.train(is_train) total_loss, correct, total = 0.0, 0, 0 with torch.set_grad_enabled(is_train): for images, labels in loader: images, labels = images.to(device), labels.to(device) logits = model(images) loss = criterion(logits, labels) if is_train: optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) correct += (logits.argmax(1) == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total for epoch in range(20): train_loss, train_acc = run_epoch(model, train_loader, optimizer, device) val_loss, val_acc = run_epoch(model, val_loader, None, device) print(f"[{epoch+1}/20] train_loss={train_loss:.4f} " f"train_acc={train_acc:.4f} val_acc={val_acc:.4f}")有几个细节容易忽略。第一,optimizer.zero_grad()必须在backward()之前调用,否则梯度会累积。第二,验证阶段要用model.eval()或model.train(False),并加上torch.no_grad(),这样能关闭 BatchNorm 和 Dropout 的训练状态,也省内存。我在上面的run_epoch里用model.train(is_train)实现了这两者的切换。
4.3 看指标别只看整体准确率
训练过程中,我一般盯两个指标:训练损失和验证准确率。训练损失持续下降说明模型在学,验证准确率才说明泛化能力。输出里如果 train_acc 已经 0.98,val_acc 只有 0.7,那基本可以断定过拟合了,不是 epoch 还没跑够。
整体准确率之外,类别不均衡时还要看混淆矩阵。比如两类图片里 A 类占 90%,那模型只要全猜 A,准确率也有 90%,但这显然不是一个合格的识别系统。
from sklearn.metrics import confusion_matrix all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for images, labels in val_loader: images = images.to(device) preds = model(images).argmax(1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) print(cm)混淆矩阵能告诉我哪些类别在互相混淆。比如把“蓝色”产品误判成“紫色”,多半是光照条件导致的;如果某个类别整体被压得很低,就要考虑给这个类别增加数据或者调权重。
4.4 调参优先级和判定方法
很多人调参是拍脑袋,我习惯按下面的优先级排查:
| 现象 | 优先检查方向 |
|---|---|
| 训练和验证 loss 都不降 | 学习率过大或过小、数据预处理错误、标签错位、网络输出层与类别数不匹配 |
| 训练 loss 下降但验证 acc 不涨 | 过拟合,先减 epoch、加数据增强、降低模型容量 |
| 训练过程剧烈震荡 | 学习率太大、batch_size 太小、Adam 参数不合适 |
| 验证 acc 在两个值之间反复跳 | 验证集太小、单次评估噪声大,增大验证集或取多次平均 |
这些小问题几乎每个人都会遇到。不要一上来就换网络结构,先把学习率、batch_size、epoch 这三颗按钮拧对,很多问题自然就消失了。
5. 模型落地:单张图片推理与导出
5.1 输入一张图片,输出一个类别
模型训练完,最终还是要面对一张张真实图片。推理代码和训练代码最大的区别是:输入只有一张图,要自己加一个 batch 维度,因为网络是按批次设计的。完整推理函数我这样写:
from PIL import Image from torchvision import transforms as T def predict_image(img_path, model, class_names, device="cpu", transform=None): img = Image.open(img_path).convert("RGB") if transform is None: transform = T.Compose([ T.Resize((224, 224)), T.ToTensor(), T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) tensor = transform(img).unsqueeze(0).to(device) model.eval() with torch.no_grad(): probs = F.softmax(model(tensor), dim=1)[0] prob, idx = torch.topk(probs, 1) return class_names[idx.item()], prob.item()调用时,class_names直接用train_dataset.classes就能拿到,顺序和训练时的标签编号一一对应。这里有几个容易忽略的地方:
- 推理前一定
model.eval(),否则模型里的 BatchNorm 统计量可能不一致,结果会抖动。 - 图片打开后最好
.convert("RGB"),避免遇到灰度图或者带透明通道的 PNG。 torch.no_grad()不是为了省一两秒,而是防止推理时把中间张量记下来导致内存溢出。
5.2 置信度不达标就交给人工
模型输出 softmax 概率以后,我们通常取最大概率对应的类别作为结果。但概率不是万能的,比如一张完全没见过的东西,模型可能也会硬给一个 0.7 的概率。所以在实际项目里,我会加一道阈值判断:如果最高概率低于某个值,比如 0.6,就返回“无法判断”,让这条样本进入人工处理通道。
这个策略虽然简单,但在真实业务里非常管用。它不改变模型本身,只是改变了决策方式,效果可能比单纯调模型还要直接。你要明白,模型不是为了“看起来聪明”,而是为了在业务里稳定可用。
5.3 模型保存、加载和导出 ONNX
训练完成后保存模型,我通常只保存权重而不是整个模型对象,这样后续换网络结构、升级依赖版本都更灵活:
torch.save(model.state_dict(), "image_classifier.pth") # 新环境里加载 model = models.resnet18(weights=None) model.fc = nn.Linear(model.fc.in_features, num_classes) model.load_state_dict(torch.load("image_classifier.pth", map_location="cpu"))如果要把模型接到其他服务里,我更建议导出成 ONNX 格式。ONNX 的好处是跨平台、对部署环境要求更低,不一定要装完整版 PyTorch:
model.eval() dummy = torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy, "image_classifier.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}} )dynamic_axes是让导出的模型允许运行时改变 batch_size,不然它会被固定成 1。后面用 ONNX Runtime 推理时,可以生成一个 Session 对象,输入格式保持 (B, 3, 224, 224),输出和 PyTorch 里基本一致。
6. 我踩过的一些坑
6.1 验证集划分太随机,结果虚高
第一次做图片分类时,我直接对全部图片做了随机切分。训练时发现验证准确率异常高,接近 0.99,当时还以为是模型太强。后来发现同一批连拍的产品图,角度几乎一样,一张进了训练集,另一张就进了验证集。模型本质上相当于“见过”验证集的答案。
所以从那以后,我先按拍摄对象分组,再按组划分。比如每个花瓣样本拍 10 张图,那么这 10 张要么全进训练集,要么全进验证集,不能两边同时出现。这样得到的准确率才有参考价值。
6.2 数据增强不是越多越好
数据增强常见的操作有水平翻转、随机旋转、颜色抖动、随机裁剪。它能明显缓解过拟合,但要考虑实际业务场景。比如识别带数字的图片,如果将图片水平翻转,数字就反了,模型学到的特征就是错的。再比如产品颜色识别,过分调色相的增强会让模型以为“红色”和“蓝色”可能一样。
我的原则是:增强操作必须符合业务上的合理变化。目录图片不会翻转,那训练时就不要开水平翻转;光照变化大,可以适当加亮度和对比度抖动。增强是给模型创造合理的变化,而不是无脑加噪声。
6.3 类别不均衡会让模型变“懒”
如果一类图片很多、另一类很少,模型很容易偏向学大类。比如 95% 是“正常品”,5% 是“瑕疵品”,模型只要全都预测成“正常品”,准确率就能到 95%,但一个瑕疵都没分出来。这种情况下只看准确率是骗自己的。
对策有两个:一个是给损失函数设置权重,CrossEntropyLoss(weight=torch.tensor([...])),给少数类更大的权重;另一个是用WeightedRandomSampler让每个 batch 里少数类出现的频率更高。我一般先试前者,简单直接,不改变数据分布。
6.4 GPU 相关的低级错误
最典型的是“明明调了.to(device),却说张量不在同一个设备上”。记住一句话:模型、输入图片、标签都要在同一个 device。很多人只给模型和输入调了 device,忘了给 labels 调,结果报错。还有,BatchNorm 对 batch_size 比较敏感,太小了统计值不稳,训练时尽量让 batch_size 不低于 16。
另外pin_memory=True配合 GPU 训练能减少数据传输等待,但要搭配num_workers使用,否则效果不明显。如果你的机器内存不大,num_workers可以调小到 2,避免加载数据时把内存吃满。
6.5 随机种子不固定,结果没法复现
深度学习里到处都有随机性:权重初始化、DataLoader 里的 shuffle、数据增强的随机操作。如果不在代码里固定随机种子,今天跑出 0.91,明天可能变 0.89,排查问题时就分不清是自己改动导致的,还是单纯随机波动。
固定种子一般这样写:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)把set_seed(42)放在训练脚本的最前面,再配合DataLoader(shuffle=True)里的 shuffle 参数,大部分情况下就能稳定复现。真正的完全复现还需要关闭某些 GPU 加速特性,但对一个图片分类项目来说,固定到这一步已经足够用了。
我这套流程跑下来之后,又用同样的思路处理过另外几个不同场景的数据,基本只需要换数据集路径和类别数量就能复用。PyTorch 识别图片这件事,真正难的不是写模型,而是你愿不愿意先把数据、预处理、训练循环、评测指标这些基本功扎扎实实过一遍。把这些环节做对,模型本身反而不是瓶颈。