手把手教你用PyTorch做图像分类:5种服装识别,代码全中文注释

0 阅读14分钟

别再被“深度学习”吓跑啦!这篇文章像教小朋友认卡片一样,让电脑学会区分“上衣、鞋、包、下衣、手表”

很多新手看到 model .fit()、backward()这些术语就头疼。今天我不堆砌术语,而是用最直白的语言 + 完整可运行的代码,带你从头实现一个真正的图像分类器。你不需要是数学天才,只要会一点Python基础,就能跟着敲出一个能用的 AI 模型。

我们最终要做成的事:
给电脑一张衣服/鞋/包的照片,它能告诉你“这是上身衣服”、“这是鞋”还是“其他”。

一、先别写代码——搞懂“卷积神经网络”在干嘛

很多人学CNN死在第一步:概念太多。我们换个比喻。

1.1 把CNN想象成一个“找特征小组”

  • 卷积层

    (Conv):小组成员拿着不同的“模板”(比如边缘模板、颜色模板)在图片上滑来滑去,记录每个位置匹配的程度。这些模板就是“卷积核”。

  • 池化层

    (Pooling):小组长说:“每2x2的格子只保留最突出的那个,其他扔掉”,图片瞬间缩小一半,但关键特征还在。

  • 全连接层

    (FC):最后所有特征汇总到一个“决策官”那里,他根据各种特征的组合,拍板决定“这是上衣”。

1.2 我们的网络结构(超级简单,但有效)

为什么尺寸变成16x16?
64 → 卷积(padding=1) → 64 → 池化 → 32 → 卷积 → 32 → 池化 → 16。
16x16x16 = 4096,这就是全连接层的输入大小。

现在你心里有个地图了,下面我们开始搭积木。

二、准备工作:项目文件夹结构

建议你新建一个文件夹,按下面结构创建文件和子文件夹:

my_fashion_classifier/
│
├── common/                     # 公共资源(放数据和工具)
│   ├── dataset/                # 所有图片放这里,例如 0.jpg, 1.jpg, ...
│   └── fashion-labels.csv      # 标签文件(告诉程序每张图属于哪类)
│
├── image_classification/       # 分类模块的所有代码
│   ├── __init__.py             # 空文件,标识这是一个Python包
│   ├── config.py               # 配置参数(就像游戏设置)
│   ├── dataset.py              # 读取图片和标签的代码
│   ├── model.py                # CNN模型定义
│   ├── engine.py               # 训练和验证的核心步骤
│   └── train.py                # 主程序:运行训练
│
└── classifier.pt               # 训练完后会生成的模型文件

💡 所有代码我都会给出完整版本,你可以直接复制保存。

数据集下载:pan.baidu.com/s/1yvtFiQMf…

三、配置文件:集中管理所有“可调旋钮”

创建 image_classification/config.py:

# ------------------- 路径配置 -------------------
IMAGE_DIR = "../common/dataset/"          # 图片文件夹路径
LABEL_CSV_PATH = "../common/fashion-labels.csv"  # 标签CSV文件
 
# ------------------- 图像预处理 -------------------
IMAGE_SIZE = 64           # 统一缩放成 64x64 像素
 
# ------------------- 数据划分 -------------------
TRAIN_RATIO = 0.75        # 75%数据用于训练,25%用于验证
RANDOM_SEED = 42          # 随机种子,保证结果可复现
 
# ------------------- 训练超参数 -------------------
BATCH_SIZE = 32           # 每次喂给模型32张图(显存不够可以改小,比如16)
EPOCHS = 30               # 把所有数据完整看30遍
LEARNING_RATE = 0.001     # 学习率:每次调参的步长
 
# ------------------- 类别映射 -------------------
CLASS_NAMES = {
    0: "上身衣服",
    1: "鞋",
    2: "包",
    3: "下身衣服",
    4: "手表"
}
NUM_CLASSES = len(CLASS_NAMES)   # 自动计算类别数 = 5

为什么把这些单独放?因为后期调参(比如想训练50轮、改学习率)只需改这一个文件,不用满代码翻找。

四、读取数据:让程序认识你的图片和标签

4.1 标签文件长什么样?

fashion-labels.csv 有两列:

id

target

0

0

1

1

2

2

3

3

4

4

...

...

  • id 对应图片文件名(不含扩展名),比如 0.jpg 对应 id=0

  • target 就是类别编号(0=上身衣服,1=鞋,2=包,3=下身衣服,4=手表)

4.2 写一个“数据集类”

创建 image_classification/dataset.py,内容如下(关键地方都有注释):

import os
import re
import pandas as pd
from PIL import Image
from torch.utils.data import Dataset
 
def natural_sort_key(filename):
    """
    让文件名按数字顺序排序,而不是字符串顺序。
    例如:['1.jpg','2.jpg','10.jpg'] 会变成 1,2,10 而不是 1,10,2
    """
    # 把文件名中的数字部分转换成整数,非数字部分转小写
    convert = lambda text: int(text) if text.isdigit() else text.lower()
    # 用正则把数字和非数字拆开,例如 'img10.jpg' -> ['img','10','.jpg']
    alphanum_key = lambda key: [convert(c) for c in re.split('([0-9]+)', key)]
    return alphanum_key(filename)
 
class FashionDataset(Dataset):
    """
    自定义数据集:读取图片和对应的标签
    """
    def __init__(self, image_dir, label_csv_path, transform=None):
        """
        参数:
            image_dir (str): 存放图片的文件夹路径
            label_csv_path (str): 标签CSV文件路径
            transform (callable, optional): 对图片的预处理操作
        """
        self.image_dir = image_dir
        self.transform = transform
 
        # 获取文件夹下所有图片文件名,并按数字顺序排序
        self.image_names = sorted(os.listdir(image_dir), key=natural_sort_key)
 
        # 读取标签CSV文件
        labels_df = pd.read_csv(label_csv_path)
        # 将id->target映射为字典,方便快速查找
        self.label_dict = dict(zip(labels_df['id'], labels_df['target']))
 
    def __len__(self):
        """返回数据集总共有多少张图片"""
        return len(self.image_names)
 
    def __getitem__(self, idx):
        """
        根据索引 idx 返回一个样本 (image_tensor, label)
        """
        # 1. 加载图片
        img_path = os.path.join(self.image_dir, self.image_names[idx])
        image = Image.open(img_path).convert("RGB")   # 转为RGB三通道
 
        # 2. 获取标签
        # 注意:CSV中的id与文件名索引是对应的(假设id从0开始顺序排列)
        label = self.label_dict[idx]
 
        # 3. 预处理(缩放、转张量等)
        if self.transform is not None:
            image = self.transform(image)
        else:
            raise RuntimeError("请提供transform预处理函数")
 
        return image, label

4.3 为什么要用自然排序?

os.listdir() 返回的文件名顺序可能是 1.jpg, 10.jpg, 2.jpg。如果不排序,索引0对应 1.jpg,索引1对应 10.jpg,但你的CSV里id=1对应的是第二张图吗?不是。所以必须按数字顺序排序,让第0个文件名是 0.jpg(如果图片命名从0开始),这样索引才能和id对应上。

如果你的图片命名不是从0开始的连续数字,那么你需要根据文件名中的数字去匹配CSV中的id。但这里为了简化,我们约定图片名和id一一对应且按顺序排列。

4.4 创建DataLoader(数据投喂器)

在主程序(后面会写)中,我们会这样使用:

from torch.utils.data import DataLoader, random_split
import torchvision.transforms as T
from image_classification.dataset import FashionDataset
from image_classification.config import *
 
# 定义预处理:缩放 + 转张量
transform = T.Compose([
    T.Resize((IMAGE_SIZE, IMAGE_SIZE)),
    T.ToTensor()   # 将PIL图片转为 [0,1] 范围的张量,形状 (C, H, W)
])
 
# 加载全部数据
full_dataset = FashionDataset(IMAGE_DIR, LABEL_CSV_PATH, transform=transform)
 
# 划分训练集(75%)和验证集(25%)
train_size = int(TRAIN_RATIO * len(full_dataset))
val_size = len(full_dataset) - train_size
train_dataset, val_dataset = random_split(full_dataset, [train_size, val_size])
 
# 创建DataLoader
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, drop_last=True)
val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False)
 
# 测试一下:取一个批次看看形状
for images, labels in train_loader:
    print("一个批次图片的形状:", images.shape)   # 期望 [32, 3, 64, 64]
    print("一个批次标签的形状:", labels.shape)   # 期望 [32]
    break

输出示例:

一个批次图片的形状: torch.Size([32, 3, 64, 64])
一个批次标签的形状: torch.Size([32])

解释:[32, 3, 64, 64] = 32张图 × 3通道(RGB) × 高64 像素 × 宽64像素。

五、搭建CNN模型:积木块详解

创建 image_classification/model.py:

import torch.nn as nn
import torch.nn.functional as F
 
class FashionClassifier(nn.Module):
    """
    一个简单的CNN分类器:
    两个卷积层 + 两个池化层 + 一个全连接层
    """
    def __init__(self, num_classes=5):
        super(FashionClassifier, self).__init__()
        # 第一个卷积层: 输入3通道(RGB) -> 输出8个特征图
        self.conv1 = nn.Conv2d(in_channels=3, out_channels=8, 
                               kernel_size=3, stride=1, padding=1)
        # 最大池化层: 2x2窗口, 步长2 -> 尺寸减半
        self.pool = nn.MaxPool2d(kernel_size=2, stride=2)
        # 第二个卷积层: 输入8通道 -> 输出16个特征图
        self.conv2 = nn.Conv2d(in_channels=8, out_channels=16,
                               kernel_size=3, stride=1, padding=1)
        # 全连接层: 输入特征数 = 16 * 16 * 16 = 4096, 输出 num_classes
        self.fc = nn.Linear(16 * 16 * 16, num_classes)
 
    def forward(self, x):
        """
        前向传播: 输入 x 的形状 (batch_size, 3, 64, 64)
        """
        # 第一块: 卷积 + ReLU + 池化
        x = F.relu(self.conv1(x))      # 形状: (batch, 8, 64, 64)
        x = self.pool(x)               # 形状: (batch, 8, 32, 32)
 
        # 第二块: 卷积 + ReLU + 池化
        x = F.relu(self.conv2(x))      # 形状: (batch, 16, 32, 32)
        x = self.pool(x)               # 形状: (batch, 16, 16, 16)
 
        # 展平: 把每个样本的特征图拉直成一维向量
        x = x.view(x.size(0), -1)      # 形状: (batch, 4096)
 
        # 全连接层输出5个类别的原始分数(logits)
        x = self.fc(x)                 # 形状: (batch, 5)
 
        # 输出 log_softmax (数值稳定的对数概率)
        # 注意: 训练时通常用 CrossEntropyLoss, 它内部已经包含 softmax,
        # 但这里为了演示, 我们直接输出 log_softmax 也可以配合 NLLLoss。
        # 后面训练时我们使用 CrossEntropyLoss, 所以不需要在这里做 softmax。
        return x   # 返回原始 logits

为什么全连接层的输入是 4096?
经过两次池化,特征图尺寸从 64x64 → 32x32 → 16x16。
通道数最后是16,所以总特征数 = 16 × 16 × 16 = 4096。

如果你不想手动计算,可以用 nn.AdaptiveAvgPool2d((1,1)) 自适应全局池化,那样全连接层输入就只是通道数16,更简单。但这里为了让你理解尺寸变化,我们保留了显式计算。

六、训练引擎:核心训练/验证循环

创建 image_classification/engine.py:

import torch
 
def train_one_epoch(model, train_loader, loss_fn, optimizer, device):
    """
    在训练集上训练一个epoch(一轮)
    返回:
        平均训练损失
    """
    model.train()   # 设置为训练模式(对Dropout/BatchNorm有影响)
    total_loss = 0.0
    num_batches = 0
 
    for images, labels in train_loader:
        # 把数据搬到GPU/CPU上
        images = images.to(device)
        labels = labels.to(device)
 
        # 清空梯度(否则梯度会累积)
        optimizer.zero_grad()
 
        # 前向传播:模型预测
        outputs = model(images)
 
        # 计算损失
        loss = loss_fn(outputs, labels)
 
        # 反向传播:计算梯度
        loss.backward()
 
        # 更新模型参数
        optimizer.step()
 
        total_loss += loss.item() * images.size(0)   # 累加该批次的总损失
        num_batches += 1
 
    avg_loss = total_loss / len(train_loader.dataset)   # 平均每个样本的损失
    return avg_loss
 
def validate(model, val_loader, loss_fn, device):
    """
    在验证集上评估模型(不更新参数)
    返回:
        平均验证损失
    """
    model.eval()    # 设置为评估模式
    total_loss = 0.0
    num_batches = 0
 
    # 不计算梯度,节省内存和计算
    with torch.no_grad():
        for images, labels in val_loader:
            images = images.to(device)
            labels = labels.to(device)
 
            outputs = model(images)
            loss = loss_fn(outputs, labels)
 
            total_loss += loss.item() * images.size(0)
            num_batches += 1
 
    avg_loss = total_loss / len(val_loader.dataset)
    return avg_loss

为什么要区分 model.train() 和 model.eval()?
因为某些层(如Dropout、BatchNorm)在训练和推理时的行为不同。训练时需要随机丢弃神经元(Dropout)或计算批次统计量(BatchNorm),而推理时要用固定的统计量。

七、主训练脚本:把所有零件组装起来

创建 image_classification/train.py:

import torch
import torch.nn as nn
import torch.optim as optim
import torchvision.transforms as T
from torch.utils.data import DataLoader, random_split
 
# 导入自定义模块
from config import *
from dataset import FashionDataset
from model import FashionClassifier
from engine import train_one_epoch, validate
 
def set_seed(seed):
    """设置随机种子,保证结果可复现"""
    torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed_all(seed)
 
def main():
    # 1. 检查是否有GPU
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    print(f"使用设备: {device}")
 
    # 2. 设置随机种子
    set_seed(RANDOM_SEED)
 
    # 3. 数据预处理
    transform = T.Compose([
        T.Resize((IMAGE_SIZE, IMAGE_SIZE)),
        T.ToTensor()
    ])
 
    # 4. 加载数据集
    print("正在加载数据集...")
    full_dataset = FashionDataset(IMAGE_DIR, LABEL_CSV_PATH, transform=transform)
 
    train_size = int(TRAIN_RATIO * len(full_dataset))
    val_size = len(full_dataset) - train_size
    train_dataset, val_dataset = random_split(full_dataset, [train_size, val_size])
 
    train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, 
                              shuffle=True, drop_last=True)
    val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False)
    print(f"训练集大小: {len(train_dataset)}, 验证集大小: {len(val_dataset)}")
 
    # 5. 创建模型、损失函数、优化器
    model = FashionClassifier(num_classes=NUM_CLASSES).to(device)
    loss_fn = nn.CrossEntropyLoss()   # 交叉熵损失(内部包含softmax)
    optimizer = optim.AdamW(model.parameters(), lr=LEARNING_RATE)
 
    # 6. 训练循环
    best_val_loss = float('inf')
    print("开始训练...")
 
    for epoch in range(1, EPOCHS + 1):
        train_loss = train_one_epoch(model, train_loader, loss_fn, optimizer, device)
        val_loss = validate(model, val_loader, loss_fn, device)
 
        print(f"Epoch {epoch:2d}/{EPOCHS} | Train Loss: {train_loss:.6f} | Val Loss: {val_loss:.6f}")
 
        # 保存验证损失最低的模型
        if val_loss < best_val_loss:
            best_val_loss = val_loss
            torch.save(model.state_dict(), "classifier.pt")
            print(f"  -> 保存新最佳模型 (val_loss={val_loss:.6f})")
 
    print("训练完成!最佳模型已保存为 classifier.pt")
 
if __name__ == "__main__":
    main()

训练输出(损失逐渐下降)

使用设备: cuda
正在加载数据集...
训练集大小: 18639, 验证集大小: 6214
开始训练...
Epoch  1/30 | Train Loss: 0.213366 | Val Loss: 0.077724
  -> 保存新最佳模型 (val_loss=0.077724)
Epoch  2/30 | Train Loss: 0.076589 | Val Loss: 0.098107
Epoch  3/30 | Train Loss: 0.062420 | Val Loss: 0.049305
  -> 保存新最佳模型 (val_loss=0.049305)
Epoch  4/30 | Train Loss: 0.050954 | Val Loss: 0.047376
  -> 保存新最佳模型 (val_loss=0.047376)
Epoch  5/30 | Train Loss: 0.042681 | Val Loss: 0.043416
  -> 保存新最佳模型 (val_loss=0.043416)
Epoch  6/30 | Train Loss: 0.039183 | Val Loss: 0.048987
Epoch  7/30 | Train Loss: 0.033624 | Val Loss: 0.037801
  -> 保存新最佳模型 (val_loss=0.037801)
Epoch  8/30 | Train Loss: 0.029378 | Val Loss: 0.043007
Epoch  9/30 | Train Loss: 0.027791 | Val Loss: 0.039153
Epoch 10/30 | Train Loss: 0.023849 | Val Loss: 0.042653
Epoch 11/30 | Train Loss: 0.021031 | Val Loss: 0.040328
Epoch 12/30 | Train Loss: 0.019363 | Val Loss: 0.039726
Epoch 13/30 | Train Loss: 0.013487 | Val Loss: 0.061417
Epoch 14/30 | Train Loss: 0.014416 | Val Loss: 0.051996
Epoch 15/30 | Train Loss: 0.011395 | Val Loss: 0.046527
Epoch 16/30 | Train Loss: 0.012894 | Val Loss: 0.053243
Epoch 17/30 | Train Loss: 0.009627 | Val Loss: 0.049918
Epoch 18/30 | Train Loss: 0.011956 | Val Loss: 0.055744
Epoch 19/30 | Train Loss: 0.008906 | Val Loss: 0.047444
Epoch 20/30 | Train Loss: 0.007415 | Val Loss: 0.055333
Epoch 21/30 | Train Loss: 0.004162 | Val Loss: 0.052728
Epoch 22/30 | Train Loss: 0.007470 | Val Loss: 0.052018
Epoch 23/30 | Train Loss: 0.006289 | Val Loss: 0.058021
Epoch 24/30 | Train Loss: 0.007052 | Val Loss: 0.062240
Epoch 25/30 | Train Loss: 0.006248 | Val Loss: 0.054409
Epoch 26/30 | Train Loss: 0.004461 | Val Loss: 0.060665
Epoch 27/30 | Train Loss: 0.003012 | Val Loss: 0.068889
Epoch 28/30 | Train Loss: 0.007218 | Val Loss: 0.068075
Epoch 29/30 | Train Loss: 0.003673 | Val Loss: 0.053514
Epoch 30/30 | Train Loss: 0.007567 | Val Loss: 0.058273
训练完成!最佳模型已保存为 classifier.pt

注意:实际损失数值取决于你的数据量和难度,但趋势应该是不断下降并趋于平稳。

八、用训练好的模型做预测

训练完成后,我们可以单独写一个脚本来测试模型效果。

创建 image_classification/predict.py:

import torch
import torchvision.transforms as T
from PIL import Image
from model import FashionClassifier
from config import CLASS_NAMES, IMAGE_SIZE
 
def predict_image(model, image_path, device):
    """对单张图片进行分类预测"""
    # 预处理
    transform = T.Compose([
        T.Resize((IMAGE_SIZE, IMAGE_SIZE)),
        T.ToTensor()
    ])
    image = Image.open(image_path).convert("RGB")
    input_tensor = transform(image).unsqueeze(0)  # 增加batch维度 -> (1,3,64,64)
    input_tensor = input_tensor.to(device)
 
    # 推理
    model.eval()
    with torch.no_grad():
        output = model(input_tensor)          # 输出 logits
        probabilities = torch.softmax(output, dim=1)  # 转为概率
        predicted_class = torch.argmax(probabilities, dim=1).item()
 
    return predicted_class, probabilities.cpu().numpy()[0]
 
if __name__ == "__main__":
    # 加载训练好的模型
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    model = FashionClassifier(num_classes=len(CLASS_NAMES))
    model.load_state_dict(torch.load("classifier.pt", map_location=device))
    model.to(device)
 
    # 测试一张图片(修改路径)
    test_image = "../common/dataset/123.jpg"   # 替换成你的图片路径
    class_id, probs = predict_image(model, test_image, device)
 
    print(f"预测类别: {CLASS_NAMES[class_id]}")
    print("各类别概率:")
    for i, name in CLASS_NAMES.items():
        print(f"  {name}: {probs[i]:.4f}")

运行示例输出:

预测类别: 包
各类别概率:
  上身衣服: 0.0000
  鞋: 0.0000
  包: 1.0000
  下身衣服: 0.0000
  手表: 0.0000

完整代码下载(包含数据集):pan.baidu.com/s/15iajG1KB…

九、常见问题与解决方法(Q&A)

Q1:运行时报错FileNotFoundError: [Errno 2] No such file or directory: '../common/dataset/'

解决:检查你的路径设置。建议使用绝对路径或在代码中动态获取当前文件所在目录。可以这样修改 config.py:

import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
IMAGE_DIR = os.path.join(BASE_DIR, "common", "dataset")
LABEL_CSV_PATH = os.path.join(BASE_DIR, "common", "fashion-labels.csv")

Q2:显存不足(CUDA out of memory)

解决:减小 BATCH_SIZE,比如从32改成16或8。

Q3:训练损失下降很慢或不下降

可能原因

  • 学习率太大或太小 → 尝试 0.01、0.001、0.0001

  • 数据没有归一化(但我们用了 ToTensor() 已归一化到[0,1])

  • 模型太简单,数据太难 → 考虑加深网络或使用预训练模型

Q4:验证损失一直比训练损失高很多

可能过拟合:增加数据增强、减小模型复杂度、增加Dropout层、增加正则化权重衰减。

十、总结与下一步

恭喜你!你已经完整实现了一个能用的图像分类系统。回顾一下我们做了哪些事:

  1. 理解了CNN的基本模块:卷积、池化、全连接
  2. 搭建了项目结构:配置、数据、模型、训练分开管理
  3. 写了一个自定义数据集类:读取图片和CSV标签
  4. 构建了一个小型CNN模型:两卷积+两池化+一全连接
  5. 实现了训练和验证循环:学会了用PyTorch训练模型
  6. 保存并加载模型:用于后续预测

你现在可以尝试的改进方向

改进点

如何做

预期效果

增加数据增强

transform

中加入

T.RandomHorizontalFlip()

,

T.ColorJitter()

提升泛化能力,减少过拟合

加深网络

再加一组

Conv2d + Pool

可能提高准确率(但也可能过拟合)

使用预训练模型

torchvision.models.resnet18(pretrained=True)

替换自己的模型

大幅提升准确率,尤其小数据集

添加准确率指标

在验证函数中计算

accuracy = (pred == labels).float().mean()

更直观地评估模型

绘制混淆矩阵

sklearn.metrics.confusion_matrix

看清哪几类容易混淆

最后送你一句话:

看懂这篇文章只需要耐心,但真正学会需要你亲手敲一遍代码。不要复制粘贴,逐行打出来,你会发现自己不知不觉就入门了深度学习的实战。

如果你在运行中遇到任何问题,欢迎在评论区留言。祝你在AI之路上一帆风顺!