在计算机视觉领域,图像分类是最基础也最核心的任务之一。PyTorch作为当前最流行的深度学习框架,提供了完整的工具链来实现各类图像分类模型。本文将手把手带你实现两个经典场景:多分类交叉熵实现手机品牌识别(苹果/华为/小米三分类)和二分类交叉熵实现苹果手机检测任务。
为什么选择这两个案例?多分类是图像分类的典型场景,而二分类在工业质检、缺陷检测等实际应用中更为常见。通过对比学习,你能掌握PyTorch中两种最重要的分类任务实现方式。我将分享在实际项目中验证过的代码架构,这个轻量级CNN模型仅6.5M参数,在CPU上也能流畅运行,非常适合作为工业落地的基准模型。
我们的SmallPhoneCNN采用经典的卷积神经网络结构,包含3个卷积层和2个全连接层。这种"浅而宽"的设计在小型数据集上表现优异,避免了过拟合风险。模型输入为224×224的RGB图像,经过三次卷积池化后,最终输出对应三个手机类别的logits。
python复制class SmallPhoneCNN(nn.Module):
def __init__(self, num_classes=3):
super(SmallPhoneCNN, self).__init__()
# 卷积层定义
self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1)
self.pool1 = nn.MaxPool2d(2, 2)
self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1)
self.pool2 = nn.MaxPool2d(2, 2)
self.conv3 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.pool3 = nn.MaxPool2d(2, 2)
# 全连接层
self.fc1 = nn.Linear(64 * 28 * 28, 128)
self.fc2 = nn.Linear(128, num_classes)
# 权重初始化
self._initialize_weights()
关键设计选择:使用3×3小卷积核配合padding=1保持特征图尺寸,这种设计在VGG网络中被验证有效。每层卷积后接2×2最大池化,逐步下采样提取高级特征。
PyTorch的Dataset类让我们可以方便地组织图像数据。数据目录应按类别组织,例如:
code复制phone_data/
├── train/
│ ├── apple/
│ ├── huawei/
│ └── xiaomi/
└── test/
├── apple/
├── huawei/
└── xiaomi/
预处理流程包含resize、随机水平翻转(数据增强)、归一化等标准操作:
python复制train_transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.RandomHorizontalFlip(), # 数据增强
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])
注意事项:ImageNet的均值和标准差是经过大量实验得出的通用参数,在大多数图像任务中都表现良好,除非你的数据分布与ImageNet差异极大,否则建议直接使用这些值。
训练循环采用标准的PyTorch模式,但有几点需要特别注意:
python复制criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
for epoch in range(EPOCHS):
model.train()
for inputs, labels in train_loader:
# 前向传播
outputs = model(inputs)
loss = criterion(outputs, labels)
# 反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()
# 测试集评估
model.eval()
with torch.no_grad():
correct = 0
for inputs, labels in test_loader:
outputs = model(inputs)
_, preds = torch.max(outputs, 1)
correct += (preds == labels).sum().item()
acc = correct / len(test_dataset)
经验分享:在小型数据集上,15-20个epoch通常就能达到不错的效果。如果发现训练损失下降但测试准确率不升,可能是过拟合的信号,可以尝试增加数据增强或添加Dropout层。
训练完成后,我们可以用以下函数进行单张图片预测:
python复制def predict_single_image(img_path):
model.eval()
img = Image.open(img_path).convert('RGB')
img_tensor = test_transform(img).unsqueeze(0).to(device)
with torch.no_grad():
outputs = model(img_tensor)
probs = F.softmax(outputs, dim=1)
top_prob, top_idx = torch.max(probs, dim=1)
pred_cls = class_names[top_idx[0].item()]
confidence = top_prob[0].item()
return pred_cls, confidence
避坑指南:务必使用model.eval()将模型切换到评估模式,这会影响Dropout和BatchNorm等层的表现。忘记这个调用可能导致推理结果不一致。
二分类虽然可以看作多分类的特例,但在实现上有几个关键区别:
python复制class BinaryAppleCNN(nn.Module):
def __init__(self):
super(BinaryAppleCNN, self).__init__()
# 卷积部分与多分类相同
self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1)
# ...其他卷积层...
# 全连接层最终输出1个值
self.fc1 = nn.Linear(64 * 28 * 28, 128)
self.fc2 = nn.Linear(128, 1) # 输出单个logits
为什么使用BCEWithLogitsLoss?它将sigmoid和BCELoss组合在一起,数值计算更稳定,能有效避免log(0)导致的数值问题。
二分类的数据集需要将标签转换为浮点数。假设我们任务是检测是否为苹果手机:
python复制class BinaryAppleDataset(Dataset):
def __init__(self, data_root, transform=None):
self.class_to_idx = {cls: 1.0 if cls == "apple" else 0.0
for cls in os.listdir(data_root)}
# ...其他初始化代码...
def __getitem__(self, idx):
img_path, label = self.img_paths[idx]
img = Image.open(img_path).convert('RGB')
if self.transform:
img = self.transform(img)
return img, torch.tensor([label], dtype=torch.float32)
二分类的训练循环需要做相应调整:
python复制criterion = nn.BCEWithLogitsLoss() # 使用带sigmoid的二元交叉熵
# 训练循环
outputs = model(inputs)
loss = criterion(outputs, labels) # 直接使用logits
# 评估时手动计算sigmoid
probs = torch.sigmoid(outputs)
preds = (probs > 0.5).float() # 以0.5为阈值
阈值选择技巧:0.5是默认阈值,但在正负样本不平衡时可能需要调整。可以通过绘制PR曲线或ROC曲线来寻找最佳阈值。
二分类的预测函数需要返回概率和分类结果:
python复制def predict_single_image(img_path, threshold=0.5):
model.eval()
img_tensor = test_transform(Image.open(img_path).convert('RGB')).unsqueeze(0)
with torch.no_grad():
logit = model(img_tensor)
prob = torch.sigmoid(logit).item()
pred = "apple" if prob > threshold else "not_apple"
return pred, prob if pred == "apple" else 1 - prob
卷积层的参数数量计算公式为:
code复制参数数量 = (kernel_width × kernel_height × in_channels + 1) × out_channels
以第一层卷积为例:
计算得:(3×3×3 + 1)×16 = 448个参数
为什么+1?每个输出通道有一个偏置项。可以通过conv.bias = None来禁用偏置。
输入图像224×224经过三次池化(每次缩小一半):
最终特征图尺寸为28×28,通道数为64,因此全连接层输入维度为64×28×28=50176。
使用torchinfo库可以自动打印每层的尺寸变化,强烈推荐在调试模型时使用。
我们采用Kaiming初始化,这是ReLU激活函数的推荐初始化方式:
python复制nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
对于没有激活函数的层(如最后的全连接层),可以考虑使用Xavier初始化。
| 特性 | 多分类实现 | 二分类实现 |
|---|---|---|
| 最后一层输出 | num_classes个值 | 1个值 |
| 损失函数 | CrossEntropyLoss | BCEWithLogitsLoss |
| 标签格式 | 类别索引(0,1,2...) | 浮点数(0.0或1.0) |
| 预测处理 | torch.max取最大值 | sigmoid后阈值判断 |
| 适用场景 | 多个互斥类别 | 是/否判断 |
损失不下降:
过拟合:
GPU内存不足:
调试技巧:在训练初期,先在小批量数据(如20张图)上过拟合,确保模型有能力达到100%训练准确率,这可以验证模型实现是否正确。
架构改进:
训练技巧:
python复制model = torch.quantization.quantize_dynamic(
model, {nn.Linear, nn.Conv2d}, dtype=torch.qint8)
python复制torch.onnx.export(model, dummy_input, "model.onnx")
数据层面:
模型层面:
实际项目中,模型架构通常不是瓶颈,数据质量和数量才是关键。建议将70%的精力放在数据上,30%放在模型上。