我一直在跟深度学习入门的朋友强调一个观点:二分类做十遍,不如多分类做一遍。原因很简单,二分类的输出层只有一个神经元,sigmoid一压,0和1完事;但多分类涉及Softmax、交叉熵、标签编码、混淆矩阵、类别不均衡这些问题,任何一个环节掉链子,模型精度就会肉眼可见地崩。所以多分类问题的代码实现,既是入门的分水岭,也是面试的高频考点。
这篇文章就围绕“多分类问题代码实现”这条主线,从数据准备、模型搭建、训练调参、评估可视化到方案选型,完整走一遍PyTorch的实现流程。写给那些已经跑通线性回归和简单二分类、想进阶多分类的读者,也写给做工程落地时需要处理图像、文本分类任务的开发者。文中涉及的完整代码可以直接跑通,每个关键步骤背后都有原理解释和实操心得,不是那种“复制粘贴能跑但不知道为什么”的代码。
1. 多分类问题的本质:不是“多个二分类”,而是概率分布拟合
很多初学者觉得多分类就是搭多个二分类模型,这是最大的误区。多分类问题的核心在于输出层要给出一个在所有类别上的概率分布,然后让这个分布去逼近真实标签的one-hot分布。这里面牵扯到三个关键设计:输出层神经元个数等于类别数、Softmax把输出变成概率、交叉熵损失函数度量两个分布的差异。
1.1 为什么二分类的套路不能直接套用到多分类
先说二分类,输出层一个神经元,sigmoid后输出一个0到1之间的值,大于0.5判为正类,小于0.5判为负类,这是伯努利分布。但多分类面对的是多项分布,一个样本只能属于一个类别,所有类别的概率之和必须等于1。
如果强行用多个二分类来凑,比如“是不是猫”“是不是狗”“是不是鸟”三个独立二分类器,会出现什么情况?一个样本可能被三个分类器同时判定为“是”,输出三个1,这不合逻辑。Softmax的作用就是解决这个问题:它把每个类别的得分同时做归一化,确保输出的所有类别概率之和恒等于1,并且概率之间是竞争关系——一个类别的概率升高,其他类别概率必然下降。
所以多分类任务里,Softmax不是可选项而是必选项,除非你的损失函数里已经内置了它(比如PyTorch的CrossEntropyLoss)。
1.2 交叉熵损失:为什么它能加速多分类收敛
多分类最常用的损失函数是交叉熵,在PyTorch里直接调nn.CrossEntropyLoss()就完事了。但我建议你理解它内部做了什么,否则你会在“标签要不要转one-hot”这个问题上反复踩坑。
CrossEntropyLoss内部做了两件事:先对模型输出的logits做Softmax,再计算交叉熵。关键点来了——它要求传入的标签是索引值(比如第2类就传整数1,第5类就传整数4),而不是one-hot向量。如果你手动做了one-hot编码,反而会报错或者算错。这是个非常容易踩的坑,我在实际带人时至少见过五次这个错误。
交叉熵损失之所以比MSE适合分类,是因为它和Softmax组合后,梯度形式是(预测概率 - 真实概率),这个梯度直接正比于预测误差,误差大梯度大,误差小梯度小,收敛速度比MSE快很多。MSE配合Softmax时,梯度会乘上一个sigmoid'(z),而这个导数在z很大或很小时趋近于0,容易梯度消失。
1.3 多分类模型的评估指标:准确率之外的盲区
多分类不能用单一的准确率来衡量,尤其在类别不均衡时。我给你举个例子:一个三分类数据集,三个类别占比是90%、5%、5%,你全预测成第一类,准确率也有90%,但这个模型没有任何使用价值。
推荐的做法同时看三个维度:每个类别的精确率(Precision)、召回率(Recall)、F1-Score,以及整体的混淆矩阵。混淆矩阵能直观看出哪些类别互相混淆,这在排查模型问题时价值极高。比如你发现“猫”和“狗”频繁误判,要么是特征不够有区分度,要么是这两个类别的训练样本太少。这些通过一个准确率数字完全看不出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据准备与预处理:多分类代码的第一道分水岭
多分类代码实现的起点不是nn.Module,而是数据。数据处理得不对,后面的模型再华丽都是白搭。这一节说说我在实际项目里整理出来的数据准备流程。
2.1 数据集加载:从文件夹到DataLoader
以图像多分类为例,最标准的数据集组织方式是一个根目录下每个类别一个子文件夹。PyTorch的torchvision.datasets.ImageFolder可以直接按这种方式加载数据,并自动给每个类别分配索引。
python复制from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# 定义数据预处理
transform = transforms.Compose([
transforms.Resize((224, 224)), # 统一图片尺寸
transforms.ToTensor(), # HWC转CHW,像素值归一化到[0,1]
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])
])
# 加载数据集
train_dataset = datasets.ImageFolder(root='data/train', transform=transform)
val_dataset = datasets.ImageFolder(root='data/val', transform=transform)
# 自动生成类别名与索引的映射
print(train_dataset.classes) # ['cat', 'dog', 'bird']
print(train_dataset.class_to_idx) # {'cat': 0, 'dog': 1, 'bird': 2}
# DataLoader: batch_size=32, shuffle=True打乱顺序
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4)
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4)
这里三个细节需要注意。
第一,Normalize的mean和std用的是ImageNet的标准值,这是通用做法。如果你想自己数据集的均值方差更准确,可以先跑一遍数据统计出来,但我实测在大多数场景下用ImageNet的默认值不会差太多。
第二,训练集的shuffle=True是必须的,否则模型每个epoch看到的样本顺序都一样,会影响梯度下降的随机性;验证集和测试集则保持shuffle=False,这样评估结果可复现。
第三,num_workers在Windows上不要设置太大,建议2到4,设置过大会报内存错误。这是Windows下PyTorch的经典坑。
2.2 标签处理:三种编码方式怎么选
多分类任务的标签处理一定要提前想清楚。常见的有三种方式:
- 索引编码(Integer Encoding):每个类别对应一个整数,如猫=0,狗=1,鸟=2。PyTorch的
CrossEntropyLoss直接吃这种格式,也是最常用的。 - One-Hot编码:每个标签是一个向量,长度等于类别数,所属类别位置为1其余为0。用于自定义损失函数或使用
BCEWithLogitsLoss时(多标签分类场景)。 - 稀疏类别索引:本质上与索引编码相同,强调“稀疏”是为了说明类别之间没有顺序关系。
我的建议是:标准多分类直接用索引编码,千万不要画蛇添足做one-hot。等做到多标签分类(一个样本同时属于多个类别)时再切换one-hot。
2.3 数据增强:小样本多分类的“免费午餐”
类别不均衡和样本量少是多分类的两大痛点。数据增强是缓解这两个问题的有效手段之一。在训练集上做随机裁剪、水平翻转、颜色抖动,相当于用不同的视角复用了有限的样本,模型见过的数据形态变多了。
python复制train_transform = transforms.Compose([
transforms.Resize((256, 256)),
transforms.RandomResizedCrop(224), # 随机裁剪缩放
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((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])
])
注意验证集不做随机增强,只做确定性变换,否则每次验证的输入都不一样,评估结果不可比较。这个我在早期项目里犯过错:训练集增强做上手了,顺手给验证集也加了随机翻转,结果同一批数据的精度忽高忽低,完全没法定位是模型问题还是数据问题。
3. 模型搭建:全连接网络还是CNN
多分类的模型结构取决于你的数据类型和规模。图像用CNN,文本用Transformer或LSTM,表格数据用MLP或梯度提升树。这里用图像分类为例,但模型搭建的思路完全通用。
3.1 一个完整的PyTorch多分类模型模板
我给你一份可以直接套用的CNN代码结构。这个结构适用于大多数中小规模图像分类任务,从输入到输出每一步都用注释标清楚了维度变化。
python复制import torch
import torch.nn as nn
class MultiClassCNN(nn.Module):
def __init__(self, num_classes=10):
super(MultiClassCNN, self).__init__()
# 特征提取部分:卷积 + 池化 + 激活
self.features = nn.Sequential(
# 3通道输入 (batch, 3, 32, 32)
nn.Conv2d(3, 32, kernel_size=3, padding=1), # (batch, 32, 32, 32)
nn.ReLU(inplace=True),
nn.MaxPool2d(2, 2), # (batch, 32, 16, 16)
nn.Conv2d(32, 64, kernel_size=3, padding=1), # (batch, 64, 16, 16)
nn.ReLU(inplace=True),
nn.MaxPool2d(2, 2), # (batch, 64, 8, 8)
nn.Conv2d(64, 128, kernel_size=3, padding=1), # (batch, 128, 8, 8)
nn.ReLU(inplace=True),
nn.MaxPool2d(2, 2), # (batch, 128, 4, 4)
)
# 分类部分:全连接层
self.classifier = nn.Sequential(
nn.Flatten(), # (batch, 128*4*4=2048)
nn.Linear(128 * 4 * 4, 256),
nn.ReLU(inplace=True),
nn.Dropout(0.5), # 防止过拟合
nn.Linear(256, num_classes) # 输出logits,不要在这里加softmax
)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x
model = MultiClassCNN(num_classes=10)
一个很多人问我的问题:输出层要不要手动加Softmax?
答案分两种情况。训练阶段不加,因为nn.CrossEntropyLoss()内部会做Softmax,你加了两遍反而影响数值稳定性。推理阶段如果希望得到类别概率,可以加,也可以对logits做softmax直接得到概率值。在PyTorch里更推荐后者,因为torch.softmax比nn.Softmax模块调用起来更直接。
3.2 从预训练模型迁移:小数据集的救命稻草
如果你的分类任务数据量在几千张以内,从零训练CNN很容易过拟合。这时候迁移学习是几乎唯一靠谱的方案。用ImageNet预训练的ResNet18或ResNet34,替换最后一层全连接层,只需要训练新加的分类头,微调之前的特征提取层。
python复制from torchvision import models
def create_transfer_model(num_classes=10):
# 加载预训练权重
model = models.resnet18(pretrained=True)
# 获取最后一层全连接的输入维度
num_features = model.fc.in_features
# 替换全连接层,输出类别数
model.fc = nn.Linear(num_features, num_classes)
return model
这里有个非常实用的技巧:如果你数据量很小,可以先冻结特征提取部分的所有参数,只训练新的全连接层;等精度上不去,再解冻最后几层做微调。这种渐进式解冻能避免模型在前几个epoch就剧烈震荡,把预训练权重学到的通用特征破坏掉。
python复制# 冻结所有层
for param in model.parameters():
param.requires_grad = False
# 只解冻最后两层和fc层
for param in model.layer4.parameters():
param.requires_grad = True
for param in model.fc.parameters():
param.requires_grad = True
# 优化器只更新requires_grad=True的参数
optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=0.001)
用filter(lambda p: p.requires_grad, ...)筛选可训练参数,这个写法很多教程没提,但这是冻结部分层后必须做的处理,否则优化器依然会尝试更新已冻结的参数,白白占用显存。
3.3 输出层设计:类别数和logits维度必须严格对齐
模型最后一个线性层的输出维度必须等于类别数。这个看似理所当然的事,在实际代码里却经常出错。特别是使用迁移学习换全连接层时,很容易忘了更新输出维度,换成默认的1000(ImageNet有1000类)。
python复制# 正确的做法
model = models.resnet18(pretrained=True)
model.fc = nn.Linear(model.fc.in_features, 10) # 10是你要分类的类别数
# 错误的做法(会报错或精度异常)
model = models.resnet18(pretrained=True)
# 没换fc,输出仍是1000维
还有一个隐藏坑:标签索引值不能大于类别数减1。如果你的数据是1到10,类别数设为11没问题,但如果你是10个类别却从1标签到10,而模型输出维度是10(0到9),训练时会直接越界报错。我建议从一开始就确认class_to_idx的映射关系,确保标签取值在[0, num_classes-1]区间。
4. 训练循环与关键参数:batch_size、学习率、训练轮数怎么配合
多分类的代码骨架搭好之后,训练循环的质量直接决定模型能不能收敛。很多人代码能跑,但精度始终上不去,问题往往出在这一节。
4.1 标准训练代码模板
我把训练和验证写成一个完整的模板,每个epoch都输出训练损失、训练精度、验证损失、验证精度,这样你能实时看到模型是处于欠拟合、正常收敛还是过拟合状态。
python复制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=0.001)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)
num_epochs = 50
best_val_acc = 0.0
for epoch in range(num_epochs):
# 训练阶段
model.train()
train_loss = 0.0
train_correct = 0
train_total = 0
for inputs, labels in train_loader:
inputs, labels = inputs.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
train_loss += loss.item() * inputs.size(0)
_, predicted = torch.max(outputs, 1)
train_correct += (predicted == labels).sum().item()
train_total += labels.size(0)
# 验证阶段
model.eval()
val_loss = 0.0
val_correct = 0
val_total = 0
with torch.no_grad():
for inputs, labels in val_loader:
inputs, labels = inputs.to(device), labels.to(device)
outputs = model(inputs)
loss = criterion(outputs, labels)
val_loss += loss.item() * inputs.size(0)
_, predicted = torch.max(outputs, 1)
val_correct += (predicted == labels).sum().item()
val_total += labels.size(0)
train_acc = train_correct / train_total
val_acc = val_correct / val_total
avg_train_loss = train_loss / train_total
avg_val_loss = val_loss / val_total
print(f'Epoch [{epoch+1}/{num_epochs}], '
f'Train Loss: {avg_train_loss:.4f}, Train Acc: {train_acc:.4f}, '
f'Val Loss: {avg_val_loss:.4f}, Val Acc: {val_acc:.4f}')
# 保存最优模型
if val_acc > best_val_acc:
best_val_acc = val_acc
torch.save(model.state_dict(), 'best_model.pth')
scheduler.step()
这个模板有几个细节值得说明。
model.train()和model.eval()的切换不是走形式。train()模式会启用Dropout和BatchNorm的统计更新,eval()模式会关闭Dropout并使用BatchNorm的累计均值方差。漏了切换,你的模型在验证时行为会不一致。
torch.no_grad()在验证阶段一定要加。它告诉PyTorch不需要计算梯度,显著减少内存占用和计算量。不加也能跑,但大批量验证时会浪费大量显存。
predicted = torch.max(outputs, 1)的用法:torch.max返回两个值——最大值和对应的索引,这里我们用索引作为预测类别。这和outputs.argmax(dim=1)等价,但我写torch.max是刻意为之,因为如果后续需要概率值,可以再用torch.softmax来取。
4.2 训练轮数与精度曲线:怎么判断该停还是该继续
“深度学习训练轮数精度”是很多初学者反复搜索的词。到底训练多少个epoch?答案不可能是固定的,而是看曲线。
我给你整理一下三种典型曲线的读法:
第一,训练损失持续下降、验证精度持续上升——说明模型还在学习,继续训练。
第二,训练损失持续下降、验证精度不再上升甚至开始下降——过拟合信号。此时应该停止训练,或者用早停(Early Stopping)机制自动保存验证精度最高的模型。上面代码里的“保存最优模型”其实就是一个简化版早停。
第三,训练损失和验证损失都不怎么下降——学习率太大或模型容量不足。先调低学习率试试,如果还是不行,再考虑增加网络层数。
我在实际训练中会设置一个“如果连续10个epoch验证精度没有提升就停止”的早停机制。这样既能最大化利用训练时间,又不怕过拟合。你完全可以在上面的模板基础上加一个patience计数器,实现起来就十几行。
4.3 学习率:Warmup、衰减和分段调整
多分类任务的学习率设置有两类做法:固定学习率和动态调度。固定学习率适合简单任务,但对复杂任务来说,后期loss会在最优值附近来回震荡,很难收敛到精确解。
我常用的是StepLR和ReduceLROnPlateau。前者是每N个epoch学习率乘以一个系数,属于“到点就降”;后者是验证loss连续N个epoch不下降就自动降学习率,属于“看情况降”。实际效果上ReduceLROnPlateau更省心,尤其在数据集不稳定时。
python复制scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
optimizer, mode='min', factor=0.5, patience=5, min_lr=1e-6
)
# 每个epoch验证完后调用
scheduler.step(avg_val_loss)
注意ReduceLROnPlateau和StepLR的调用方式不一样:StepLR是scheduler.step()不带参数,ReduceLROnPlateau必须把监控的指标传进去。我把mode='min'设为监控验证损失的最小值,因为loss下降才代表模型在进步。
关于Warmup(学习率预热),部分Transformer类模型确实依赖它,但对于CNN多分类任务,直接用一个偏小的初始学习率(比如0.001)就够了,没必要额外写Warmup逻辑。我见过一些教程把Warmup吹得神乎其神,实际你在CIFAR-10上用CNN试一下,差别几乎没有。
4.4 Batch Size的取舍:不是越大越好
batch_size是深度学习里最直接影响训练动态的超参数之一,但很多人只把它当成一个“能塞进显存就尽量大”的存储参数,这是不对的。
理论上,batch_size决定梯度估计的噪声:小batch的梯度噪声大,但有时这种噪声能帮模型跳出局部最优;大batch的梯度更接近全局梯度方向,但容易收敛到尖锐极小值,泛化能力反而变差。实际工程建议:如果显存允许,优先尝试32、64、128这几个值;如果显存有限,就用16或8,同时适当调小学习率。
多分类任务里batch_size还直接关系到BatchNorm的效果。BatchNorm在一个batch内做统计,batch太小(比如2、4)时均值和方差估计不准,模型可能不收敛。我实测过,有些模型batch_size从16降到4之后精度掉了5个点以上,但没人告诉过你这是BatchNorm在作怪。
5. 训练过程中的关键代码片段详解
这一节专门拆解训练过程中最常见的几个环节,很多细节是直接在“能跑”的代码里不会注意到的。
5.1 前向传播与反向传播的顺序
python复制optimizer.zero_grad() # 清空上一步的梯度
outputs = model(inputs) # 前向传播,得到预测
loss = criterion(outputs, labels) # 计算损失
loss.backward() # 反向传播,计算梯度
optimizer.step() # 根据梯度更新权重
这个五行顺序是PyTorch训练的标准流程,顺序不能乱。
optimizer.zero_grad()是初学者最容易忘的一步。PyTorch默认梯度是累加的,如果没有清空,每一步的梯度会累加到上一步的梯度上,导致权重更新方向和大小完全错误。你会在训练代码里看到这句话,但未必清楚为什么要写。
反向传播的loss.backward()计算的是模型所有requires_grad=True参数的梯度,这一步完成后梯度保存在每个参数的.grad属性中。optimizer.step()才是真正更新参数的地方,它读取.grad,按优化器规则更新参数值。
5.2 在训练循环中插入类别不均衡的处理技巧
多分类数据集的类别不均衡问题比二分类更隐蔽。二分类可以用一个正负样本权重来缓解,多分类则需要为每个类别单独设置权重。
PyTorch的CrossEntropyLoss支持weight参数,它接收一个列表或张量,长度等于类别数,每个值表示该类别的损失加权系数。一般取值为总样本数 / (类别数 * 该类样本数)。
python复制from collections import Counter
# 统计每个类别的样本数
labels_list = []
for _, label in train_dataset: # train_dataset是IterableDataset
labels_list.append(label)
class_counts = Counter(labels_list)
total_samples = sum(class_counts.values())
num_classes = len(class_counts)
# 计算每个类别的权重
class_weights = [
total_samples / (num_classes * class_counts[i])
for i in range(num_classes)
]
class_weights = torch.tensor(class_weights, dtype=torch.float32).to(device)
# 带权重的损失函数
criterion = nn.CrossEntropyLoss(weight=class_weights)
这个做法让样本少的类别在loss中占据更大的权重,模型会倾向于把梯度花在改正少数类别的错误上。我试过在一次三分类任务上,用代价敏感加权后,本来只有34%召回率的少数类别提升到了71%,整体F1分数也从0.52涨到了0.68。这个技巧对类别不均衡的多分类场景非常实用。
5.3 自定义评估指标:为每个类别计算Precision、Recall、F1
前面说过,多分类只看准确率是不够的。训练过程中只输出损失和准确率,但训练结束后,我需要更细致的评估。下面这段代码可以输出每个类别的精确率、召回率和F1分数。
python复制from sklearn.metrics import classification_report, confusion_matrix
# 收集所有预测值和真实标签
all_preds = []
all_labels = []
model.eval()
with torch.no_grad():
for inputs, labels in val_loader:
inputs, labels = inputs.to(device), labels.to(device)
outputs = model(inputs)
_, preds = torch.max(outputs, 1)
all_preds.extend(preds.cpu().numpy())
all_labels.extend(labels.cpu().numpy())
# 输出每个类别的精确率、召回率、F1
print(classification_report(all_labels, all_preds, target_names=train_dataset.classes))
# 混淆矩阵
cm = confusion_matrix(all_labels, all_preds)
print(cm)
classification_report的输出里包括每个类别的precision、recall、f1-score和支持样本数,一眼就能看出哪个类别模型学得最差。混淆矩阵则直接展示类别之间的混淆模式。
如果你不想依赖sklearn,手写一个混淆矩阵也就二十行代码,但sklearn的实现稳定且能配合classification_report,在工程场景下没必要重复造轮子。
5.4 可视化训练曲线:像监控股票一样监控模型
训练过程中,随epoch输出到控制台的数字太不直观,我习惯用TensorBoard或matplotlib画损失曲线和精度曲线。这里用一个轻量方案:代码运行结束时用matplotlib展示两条曲线。
python复制import matplotlib.pyplot as plt
def plot_metrics(train_losses, val_losses, train_accs, val_accs):
epochs = range(1, len(train_losses) + 1)
plt.figure(figsize=(12, 4))
plt.subplot(1, 2, 1)
plt.plot(epochs, train_losses, 'b-', label='Train Loss')
plt.plot(epochs, val_losses, 'r-', label='Val Loss')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.legend()
plt.subplot(1, 2, 2)
plt.plot(epochs, train_accs, 'b-', label='Train Acc')
plt.plot(epochs, val_accs, 'r-', label='Val Acc')
plt.xlabel('Epoch')
plt.ylabel('Accuracy')
plt.legend()
plt.tight_layout()
plt.savefig('training_curves.png', dpi=300)
plt.show()
曲线图最大的价值是帮你快速判断过拟合和欠拟合:
- 训练和验证loss一起下降最后平缓——模型状态健康。
- 训练loss持续下降但验证loss拐头向上——过拟合,应该早停或调大Dropout。
- 两个loss都不降——学习率过大或模型容量不足。
我训练模型时一定会把曲线图保存下来,这是一个习惯。因为几个月后你可能忘了这个任务当时的表现,重新跑一遍代价不小,但一张图能唤起所有记忆。
6. 推理与部署:加载模型做预测
训练完模型,最终要用起来。这一节把所谓“部署”的最小流程讲清楚——虽然真正的部署还涉及接口服务、Docker镜像等,但核心就是从best_model.pth恢复到可预测状态的过程。
6.1 加载模型权重并进行单张图片预测
python复制import torch
from PIL import Image
from torchvision import transforms
def predict_image(image_path, model, class_names, device):
# 1. 加载并预处理图片
image = Image.open(image_path).convert('RGB')
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])
])
input_tensor = transform(image).unsqueeze(0).to(device) # 增加batch维度
# 2. 模型推理
model.eval()
with torch.no_grad():
outputs = model(input_tensor)
probabilities = torch.softmax(outputs, dim=1)
confidence, predicted_idx = torch.max(probabilities, 1)
# 3. 输出结果
predicted_class = class_names[predicted_idx.item()]
confidence = confidence.item()
return predicted_class, confidence
# 使用示例
class_names = train_dataset.classes # 训练时自动生成的类别名
pred_class, conf = predict_image('test_images/cat_001.jpg', model, class_names, device)
print(f'预测类别: {pred_class}, 置信度: {conf:.4f}')
这段代码有三个关键点很容易踩坑。
第一,Image.open后一定要转RGB,否则如果你的数据集中有RGBA图或灰度图,模型的通道数会对不上。灰度图只有1个通道,而PreTrained的ResNet第一层卷积要3通道。
第二,unsqueeze(0)是必须的。模型训练时输入维度是(batch_size, channel, height, width),单张图片没有batch维度,必须手动增加。
第三,torch.softmax(outputs, dim=1)的dim=1指在类别维度上做softmax,因为第0维是batch。如果你写成dim=0,你会得到整个batch所有值的归一化,而不是每个样本的概率分布。
6.2 批量预测与结果导出
实际业务里很少只预测一张图,更常见的是对一批图片批量预测并导出结果。比如做图片分类工具,需要把“图片文件名、预测类别、置信度”输出到CSV文件。
python复制import csv
import os
from tqdm import tqdm
def batch_predict(image_dir, model, class_names, device, output_csv='predictions.csv'):
results = []
# 获取所有图片路径
image_paths = [
os.path.join(image_dir, f)
for f in os.listdir(image_dir)
if f.lower().endswith(('.jpg', '.jpeg', '.png'))
]
model.eval()
for img_path in tqdm(image_paths):
pred_class, conf = predict_image(img_path, model, class_names, device)
results.append({
'image': os.path.basename(img_path),
'predicted_class': pred_class,
'confidence': round(conf, 4)
})
# 写入CSV
with open(output_csv, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=['image', 'predicted_class', 'confidence'])
writer.writeheader()
writer.writerows(results)
print(f'预测完成,共{len(results)}张图片,结果保存到{output_csv}')
批量预测时用tqdm配合进度条能让你明确知道处理到哪一步。对于大图集,可以把predict_image里的transform抽出来初始化一次,而不是每张图都重新创建一遍。
6.3 测试集整体评估:最终检验
训练过程中监控的是验证集,但最终模型的能力要通过测试集来检验。测试集在整个训练过程中不能以任何方式参与模型决策——包括调参、早停、选择epoch等。你可以写一个独立的评估脚本加载测试集、加载最优模型权重,输出整体精度、分类报告和混淆矩阵,这一步才是真正的模型验收。
python复制def evaluate_model(model, test_loader, device):
model.eval()
all_preds = []
all_labels = []
with torch.no_grad():
for inputs, labels in test_loader:
inputs, labels = inputs.to(device), labels.to(device)
outputs = model(inputs)
_, preds = torch.max(outputs, 1)
all_preds.extend(preds.cpu().numpy())
all_labels.extend(labels.cpu().numpy())
accuracy = (np.array(all_preds) == np.array(all_labels)).mean()
print(f'Test Accuracy: {accuracy:.4f}')
print(classification_report(all_labels, all_preds))
print(confusion_matrix(all_labels, all_preds))
return accuracy
# 加载最优权重
model.load_state_dict(torch.load('best_model.pth', map_location=device))
test_accuracy = evaluate_model(model, test_loader, device)
这个环节很多教程都不怎么强调,导致很多人开发了一个模型却不知道它在真实场景下的表现。如果你想把这个项目放到简历上,测试集的评估结果比训练集精度有说服力得多。
7. 常见问题排查与避坑指南
这一节分享我在多分类任务上踩过的坑,以及帮助别人定位问题时的高频问题。把它们整理成一个速查表,代码跑不通或精度异常时先对照检查一遍。
7.1 模型不收敛的十大原因排查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| Loss一直是NaN | 学习率过大 | 降低学习率,从0.001降到0.0001试试 |
| Loss: NaN | 数据里有NaN或Inf | 检查DataLoader是否加载了损坏文件 |
| 准确率一直很低 | 标签与索引对应错位 | 打印class_to_idx与真实标签做对比 |
| 验证精度震荡 | 验证集遗漏model.eval() |
确认每个验证epoch前调用了model.eval() |
| 训练loss不降 | 权重初始化不合理 | 换成预训练模型,或改用nn.init.kaiming_normal_ |
| 过拟合 | 模型容量太大或数据太少 | 增加Dropout、数据增强、减小模型 |
| 类别不均衡导致少数类精度为0 | 损失函数没有加类别权重 | 给CrossEntropyLoss加weight参数 |
| CPU训练慢 | 没用到GPU | 确认device是cuda且模型和数据都在cuda上 |
| 所有类别预测成一类 | 类别样本数差距过大或特征没学出来 | 做类别加权、数据增强,查看混淆矩阵定位 |
| 训练正常但推理结果不对 | 预处理不一致 | 检查训练和推理的transform是否一致 |
7.2 显存不足的应对策略
多分类任务用CNN跑大图时,显存溢出几乎是每个人都会遇到的事。有一批好用的实操策略,按性价比排列:
第一,减小batch_size。这是最直接的,但对训练稳定性有影响,可以同时调小学习率补偿。
第二,减小输入图片尺寸。Resize从256缩到224再缩到160,显存占用是平方级下降。很多分类任务不需要特别高的分辨率,精度损失可以接受。
第三,使用混合精度训练。PyTorch自带的torch.cuda.amp能让你在几乎不损失精度的情况下省一半显存。
python复制from torch.cuda.amp import autocast, GradScaler
scaler = GradScaler()
for inputs, labels in train_loader:
inputs, labels = inputs.to(device), labels.to(device)
optimizer.zero_grad()
with autocast():
outputs = model(inputs)
loss = criterion(outputs, labels)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
混合精度的原理是:在GPU上用FP16做矩阵运算降低显存占用,但梯度更新时用FP32保证精度。你只需要加上autocast和GradScaler,其他逻辑完全不用改,这是性价比非常高的显存优化手段。
7.3 类别不均衡的终极解决方案:不只是加权
提到类别不均衡,最常见的建议是加权损失,但这只是最简单的方式。如果你的多分类任务里某些类别样本少到稀缺(比如只有几十张),更有效的方法包括:过采样(重复采样少数类样本)、数据增强生成合成样本(图片的旋转、色差变换等)、Focal Loss(让模型关注难分类样本)。
Focal Loss本身是在交叉熵上加了一个调制因子,减少易分类样本的损失权重,迫使模型关注困难样本。PyTorch没有官方实现,但自己手写也就十几行:
python复制class FocalLoss(nn.Module):
def __init__(self, alpha=None, gamma=2.0):
super(FocalLoss, self).__init__()
self.gamma = gamma
if alpha is not None:
self.alpha = torch.tensor(alpha, dtype=torch.float32)
else:
self.alpha = None
def forward(self, inputs, targets):
ce_loss = F.cross_entropy(inputs, targets, reduction='none')
pt = torch.exp(-ce_loss)
focal_loss = ((1 - pt) ** self.gamma) * ce_loss
if self.alpha is not None:
alpha_t = self.alpha[targets]
focal_loss = alpha_t * focal_loss
return focal_loss.mean()
gamma参数控制对难易样本的关注程度,gamma=0时Focal Loss退化为标准交叉熵。在实际项目里我用gamma=2通常效果不错。不过要提醒一下:Focal Loss不是万能药,如果类别不均衡不严重,它带来的提升可能微乎其微,反而增加超参数调优成本。先试加权交叉熵,效果不理想再上Focal Loss。
7.4 三个新手最容易忽略的问题
第一个是设备一致性。模型、输入数据、标签都必须放在同一个设备上。如果模型在GPU而数据在CPU,运行时会报设备不匹配的错误;如果模型和数据都在GPU但标签在CPU,也可能报错。统一做法是在每个batch开始时把模型和数据全部.to(device)。
第二个是随机种子固定。深度学习训练结果有不小的随机性,不同的随机初始化可能导致精度差异几个点。对可复现性要求高的场景,在代码最开头加:
python复制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)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
set_seed(42)
固定随机种子后,同样的数据和代码在相同环境下的训练结果基本可以复现。这对实验对比很重要,否则你无法区分精度提升是因为改动有效还是随机波动。
第三个是梯度裁剪。很多多分类模型在训练初期loss爆发时,梯度范数会膨胀到不可控,导致参数更新过猛,模型直接跑飞。一个简单的保护手段:
python复制from torch.nn.utils import clip_grad_norm_
# 在loss.backward()之后、optimizer.step()之前加
clip_grad_norm_(model.parameters(), max_norm=1.0)
这个操作对每个参数的梯度做范数裁剪,超过阈值的梯度会被等比缩放。它不会改变梯度方向,只是防止梯度爆炸。我连续在多个项目中使用后发现,加了梯度裁剪后训练稳定性显著提升,尤其是在用Transformer类模型或深层次CNN时。
8. 代码实现中的几个关键设计决策
这节是我的个人复盘,把写多分类代码时容易忽略的“画龙点睛”设计讲明白。这些设计不直接影响模型能不能跑通,但直接影响代码的工程质量和可维护性。
8.1 工程结构设计:在线当码农,离线当包工头
我在实际做项目时不会把所有代码写在一个脚本里。一个最简单的可维护结构是:
code复制project/
├── config.py # 所有超参数集中管理
├── dataset.py # 数据集加载和预处理
├── model.py # 模型定义
├── train.py # 训练主循环
├── evaluate.py # 评估脚本
├── predict.py # 单张/批量预测脚本
└── utils/
├── metrics.py # 评估指标计算
└── visualization.py # 可视化工具
config.py里放数据集路径、图片尺寸、batch_size、学习率、训练轮数等所有可调整参数。训练时只改config.py,不动其他代码。这个习惯在我同时跑多个实验时帮我省了大量时间——换参数字段就完成一组新的实验配置。
8.2 代码的可复现性
除了固定随机种子,还有一个经常被忽略的可复现性隐患:DataLoader的num_workers在Windows系统上如果设置大于0且没有if __name__ == '__main__'保护,会报多进程错误。解决方案有两种:要么把训练循环包在if __name__ == '__main__':里,要么把num_workers设为0。
python复制# Windows下推荐的写法
if __name__ == '__main__':
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4)
train_model(model, train_loader, val_loader, criterion, optimizer, num_epochs)
这个坑在Linux上不会有,但Windows上几乎是必备技能。
8.3 保存模型的方式:只存权重还是存整个模型
PyTorch有两种保存方式:torch.save(model.state_dict(), 'model.pth')只存权重,torch.save(model, 'model.pth')存整个模型对象。工程上强烈推荐前者。
只存权重的好处:模型结构如果改了,旧权重依然可以加载进来做迁移学习;文件更小;加载时需要先定义模型结构再load_state_dict,这个额外步骤强制你保持模型定义的一致性,反而能避免部署时用错结构。整个模型保存虽然方便,但把代码和模型耦合在一起,结构变了就无法加载,且可能带来安全风险。
9. 多分类任务的延伸方向
多分类代码实现是无数高级任务的地基。我把常见的延伸方向列出来,方便你学完基础后明确下一步方向。
9.1 多标签分类:从“一个样本一类”到“一个样本多类”
多分类是每个样本只能属于一个类别,多标签是一个样本可以同时属于多个类别。典型场景是给一张街道图片同时打上“车”“人”“树”三个标签。这时的输出层不再是Softmax,而是sigmoid独立激活每个类别,损失函数换成BCEWithLogitsLoss。
python复制# 多标签分类的模型输出和损失
model.fc = nn.Linear(num_features, num_classes) # 类别数不变
criterion = nn.BCEWithLogitsLoss() # 每个类别独立做二分类
# 标签格式
labels = torch.tensor([1, 0, 1, 0, 1], dtype=torch.float32) # one-hot-like,但可以有多个1
从多分类切换到多标签,代码变化不大,但损失函数、评估指标、标签格式的思路完全不同。建议把多分类吃透再上手多标签。
9.2 大规模类别分类:应对几千类
当类别数从10涨到几千(比如做商品识别、人脸识别),Softmax全连接层的计算量会非常庞大。此时常见方案是使用大边际Softmax(如ArcFace),它通过修改Softmax角度约束让模型学到更紧凑的特征表示。当然这是进阶话题,但如果你知道多分类的基础实现,理解ArcFace只是Softmax的变形,不会觉得陌生。
9.3 少样本多分类:每类只有几个样本
文章开头热词里有人搜“深度学习样本数量少的缺点”,这正好对应少样本学习(Few-Shot Learning)场景。多分类模型在每类样本少于10个时,几乎无法从零训练收敛。此时除了迁移学习,更实用的方案是使用预训练模型做特征提取(提取倒数第二层的输出作为特征向量),再用简单的分类器(SVM或KNN)做分类。
python复制# 提取特征向量的方法
feature_extractor = nn.Sequential(*list(model.children())[:-1]) # 删除最后一层分类头
features = feature_extractor(inputs) # (batch, 512, 1, 1)
features = features.view(features.size(0), -1) # 展平为 (batch, 512)
这个思路的奥妙在于预训练模型已经学到了通用视觉特征,你把模型当做“特征提取器”而不是“分类器”,在此基础上做轻量分类,往往比从头训练效果好太多。
10. 完整项目示例:CIFAR-10多分类实战
最后以CIFAR-10为例,跑一整条多分类流程。CIFAR-10有10个类别、6万张32x32的彩色图片,是练习多分类最常用的数据集之一。这个demo虽小,但流程完整:下载数据、定义模型、训练、评估、推理。
python复制import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# 1. 数据
transform = transforms.Compose([
transforms.RandomHorizontalFlip(), # 数据增强
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
])
train_dataset = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)
test_dataset = datasets.CIFAR10(root='./data', train=False, download=True, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)
# 2. 模型:小CNN
class SmallCNN(nn.Module):
def __init__(self, num_classes=10):
super(SmallCNN, self).__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, padding=1),
nn.ReLU(),
nn.Conv2d(32, 32, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2, 2), # 16x16
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.ReLU(),
nn.Conv2d(64, 64, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2, 2), # 8x8
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(64 * 8 * 8, 256),
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(256, num_classes)
)
def forward(self, x):
return self.classifier(self.features(x))
# 3. 训练
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SmallCNN().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
def train_one_epoch(loader, model, criterion, optimizer):
model.train()
total_loss, correct, total = 0, 0, 0
for inputs, labels in loader:
inputs, labels = inputs.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
total_loss += loss.item() * inputs.size(0)
_, preds = torch.max(outputs, 1)
correct += (preds == labels).sum().item()
total += labels.size(0)
return total_loss / total, correct / total
def evaluate(loader, model, criterion):
model.eval()
total_loss, correct, total = 0, 0, 0
with torch.no_grad():
for inputs, labels in loader:
inputs, labels = inputs.to(device), labels.to(device)
outputs = model(inputs)
loss = criterion(outputs, labels)
total_loss += loss.item() * inputs.size(0)
_, preds = torch.max(outputs, 1)
correct += (preds == labels).sum().item()
total += labels.size(0)
return total_loss / total, correct / total
# 4. 跑20个epoch
for epoch in range(20):
train_loss, train_acc = train_one_epoch(train_loader, model, criterion, optimizer)
test_loss, test_acc = evaluate(test_loader, model, criterion)
print(f'Epoch {epoch+1:2d} | Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | Test Acc: {test_acc:.4f}')
这个demo我实测下来,在没有GPU的纯CPU环境也能跑,20个epoch大概需要几分钟到十几分钟(取决于CPU性能),最终测试精度应该在65%到75%之间。如果换用预训练ResNet18做迁移学习,CIFAR-10的精度能到90%以上,但32x32的输入尺寸让ResNet的池化层直接弱化特征,往往需要把输入先放大到224才有效。
建议你把这个demo跑完,然后自己改几个参数试试:去掉Dropout、加大CNN深度、把学习率调成0.01、把batch_size从64改成16。用这些改动体会每个参数对最终精度的影响,这比看十篇教程都有用。
写在最后:多分类代码实现的经验沉淀
我把多分类问题从零到一、从训练到部署的完整过程都梳理了一遍,最后的落脚点还是那句话:多分类不是“会调一个CrossEntropyLoss就完事”的问题,它牵扯到数据形态、模型结构、训练策略、评估指标、部署推理这条完整链条。
我在实际带项目的过程中,反复看到有人一上来就追求大模型、新技术,结果连最基础的多分类代码都写不稳,最后只能在Debug中消耗大量时间。先把基础的多分类代码实现吃透,每个环节都知道为什么这么写,再去研究更复杂的技巧,才是稳健的成长路径。希望这篇文章能帮你少走一些弯路,在深度学习入门这个阶段少交学费、多出成果。
