章节导语

“上一章我们用全连接网络(MLP)粗暴地把图片拉成了一条直线,虽然识别出了简单的手写数字,但这样做丢失了图片最重要的东西——空间结构。”

试想一下,如果把一只猫的照片倒过来放,或者把猫从图片的左上角移到右下角,普通的全连接网络可能就立刻“瞎”了。因为它死记硬背的是像素的位置,而不是“猫耳朵”或“猫胡须”这样的特征。

人类看东西不是一个像素一个像素看的,而是先看局部特征(边缘、形状、纹理),然后把这些特征组合起来。

本章我们将学习模仿人类视觉系统的神器——卷积神经网络(CNN)。它是计算机视觉(Computer Vision)的绝对统治者。更重要的是,我们将学习一种名为“迁移学习”的工程化技巧,让你不需要几百万张图片,也能训练出专业级的图像识别模型。


8.1 学习目标

在学完本章后,你将能够:

  1. 理解CNN原理:用直观的图解掌握卷积(Convolution)池化(Pooling)的作用。

  2. 数据增强工程:掌握 torchvision.transforms,通过旋转、裁剪等手段让有限的数据集“变大”。

  3. 掌握迁移学习:学会“站在巨人的肩膀上”,加载预训练模型(如 ResNet),并针对自己的任务进行微调(Fine-tuning)。

  4. 处理自定义数据:熟练使用 ImageFolder 加载你自己收集的图片文件。

  5. 实战落地:构建一个智能垃圾分类系统,能识别玻璃、纸张、金属和塑料。


8.2 为什么全连接网络(MLP)看图不行?

在第7章的MNIST案例中,我们的输入层有  个神经元。 如果我们要识别一张普通的手机照片(假设 像素,3个颜色通道),输入层就需要  万个神经元! 如果第一层隐藏层只有1000个神经元,那么这就需要  亿个权重参数。这会让任何一台计算机的内存瞬间爆炸。

更糟糕的是,把图片拉平成一条线,就破坏了像素之间的邻域关系。CNN 的出现,就是为了解决参数爆炸和保留空间特征。


8.3 CNN的核心零件:卷积与池化

别被数学名词吓到,CNN 的逻辑其实非常贴近生活。

8.3.1 卷积层(Convolution):特征扫描仪

想象你在玩“找茬”游戏,你拿著一个小手电筒(比如  的方框)在图片上从左到右、从上到下滑动。这个小方框就叫卷积核(Kernel)滤波器(Filter)

  • 卷积核里存的是什么?是一些特定的模式

  • 有的核专门找“竖线”,有的找“横线”,有的找“圆弧”。

  • 当卷积核滑动到和它长得很像的区域时,它会激活,输出一个很大的数值。

本质:卷积就是在做特征提取。第一层卷积提取边缘,第二层提取形状(眼睛、鼻子),第三层提取物体(猫脸)。

8.3.2 池化层(Pooling):信息压缩机

提取完特征后,数据量还是很大。我们需要“挑重点”。 最大池化(Max Pooling) 是最常用的方法。它在一个  的区域内,只保留数值最大的那个点。

作用

  1. 减小尺寸:数据量减少75%,计算更快。

  2. 抗干扰:不管猫稍微往左偏一点还是往右偏一点,只要那个区域里有猫耳朵,最大值还是能被提取出来。这就是平移不变性


8.4 工程化神器:迁移学习(Transfer Learning)

这是本章最重要的实战技巧。

在工业界,很少有人会从零开始(From Scratch)训练一个CNN。因为训练一个像样的CNN需要海量数据(百万级)和昂贵的显卡集群(跑好几天)。

我们会使用预训练模型(Pre-trained Model)。 比如 Google 或 Facebook 已经用 1400 万张图片(ImageNet数据集)训练好了一个超级模型(如 ResNet)。这个模型已经“见过世面”了,它知道什么是线条,什么是纹理,什么是狗眼。

我们只需要把它的最后一层(分类层)砍掉,换成我们自己的“垃圾分类层”,然后稍微训练一下。这就叫微调(Fine-tuning)

【比喻】 预训练模型就像一位博学的大学教授。 你的任务是让他去教幼儿园(识别垃圾)。 你不需要教他怎么识字、怎么看书(基础特征提取),你只需要告诉他:“教授,这个圆的是塑料瓶,那个方的是纸盒子”就行了。


8.5 实战案例:智能垃圾分类系统

我们将构建一个模型,能够区分四类垃圾:Cardboard(纸板)、Glass(玻璃)、Metal(金属)、Plastic(塑料)

8.5.1 第一步:准备数据与目录结构

PyTorch 提供了一个非常符合直觉的数据加载方式:ImageFolder。你只需要按照文件夹分类存放图片即可。

请确保你的项目文件夹结构如下(可以从 Kaggle 下载 "Garbage Classification" 数据集的部分数据):

data/
    train/
        cardboard/
            001.jpg
            002.jpg
        glass/
            ...
        metal/
            ...
        plastic/
            ...
    val/   <-- 验证集结构同上
        cardboard/
        ...

8.5.2 第二步:数据增强(Data Augmentation)

我们的图片可能不多。为了防止模型死记硬背(过拟合),我们要对图片进行“折磨”:随机旋转、随机裁剪、水平翻转。这样一张图能当十张用。

import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, models, transforms
​
# 定义数据预处理
# 这里的 mean 和 std 是 ImageNet 数据集的统计值,这是迁移学习的标准操作
data_transforms = {
    'train': transforms.Compose([
        transforms.RandomResizedCrop(224), # 随机裁剪并缩放到 224x224
        transforms.RandomHorizontalFlip(), # 随机水平翻转
        transforms.ToTensor(),             # 转为 Tensor
        transforms.Normalize([0.485, 0.456, 0.406], 
                             [0.229, 0.224, 0.225])
    ]),
    'val': transforms.Compose([
        transforms.Resize(256),
        transforms.CenterCrop(224),        # 验证集不需要随机,只取中间
        transforms.ToTensor(),
        transforms.Normalize([0.485, 0.456, 0.406], 
                             [0.229, 0.224, 0.225])
    ]),
}
​
# 加载数据
data_dir = './data' # 指向你的数据文件夹
image_datasets = {x: datasets.ImageFolder(os.path.join(data_dir, x),
                                          data_transforms[x])
                  for x in ['train', 'val']}
​
dataloaders = {x: torch.utils.data.DataLoader(image_datasets[x], batch_size=32,
                                             shuffle=True, num_workers=4)
               for x in ['train', 'val']}
​
class_names = image_datasets['train'].classes
print(f"检测到的分类: {class_names}") # 应该是 ['cardboard', 'glass', 'metal', 'plastic']

【小白避坑】为什么是 224x224? 绝大多数经典的 CNN 模型(如 ResNet, VGG)在设计时都默认输入尺寸是 224x224。为了配合预训练模型,我们最好遵守这个标准。

8.5.3 第三步:加载“巨人”模型 (ResNet18)

我们将使用 ResNet18,它是一个轻量级但非常强大的卷积网络。

# 1. 下载预训练模型
# pretrained=True 表示我们要下载人家训练好的参数,而不是一个空壳
model = models.resnet18(pretrained=True)

# 2. 修改全连接层 (Fully Connected Layer)
# ResNet18 原始的最后一层叫 fc,输出是 1000 (ImageNet有1000类)
# 我们需要把它改成 4 (我们的垃圾只有4类)

num_ftrs = model.fc.in_features # 获取倒数第二层的输入特征数
model.fc = nn.Linear(num_ftrs, 4) # 替换为新的层,输出为4

# 3. 搬运到 GPU
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)

print("模型加载完毕,已替换输出层。")

8.5.4 第四步:训练循环 (复用与升级)

这个循环逻辑与第7章基本一致,但我们加入验证集的计算,以便实时监控模型是否过拟合。

def train_model(model, criterion, optimizer, num_epochs=5):
    for epoch in range(num_epochs):
        print(f'Epoch {epoch+1}/{num_epochs}')
        print('-' * 10)
​
        # 每个 Epoch 都要跑一遍训练集和验证集
        for phase in ['train', 'val']:
            if phase == 'train':
                model.train()  # 训练模式 (启用 Dropout, BatchNorm)
            else:
                model.eval()   # 评估模式 (冻结 Dropout, BatchNorm)
​
            running_loss = 0.0
            running_corrects = 0
​
            for inputs, labels in dataloaders[phase]:
                inputs = inputs.to(device)
                labels = labels.to(device)
​
                optimizer.zero_grad()
​
                # 只有在训练阶段才追踪梯度
                with torch.set_grad_enabled(phase == 'train'):
                    outputs = model(inputs)
                    _, preds = torch.max(outputs, 1)
                    loss = criterion(outputs, labels)
​
                    if phase == 'train':
                        loss.backward()
                        optimizer.step()
​
                running_loss += loss.item() * inputs.size(0)
                running_corrects += torch.sum(preds == labels.data)
​
            epoch_loss = running_loss / len(image_datasets[phase])
            epoch_acc = running_corrects.double() / len(image_datasets[phase])
​
            print(f'{phase} Loss: {epoch_loss:.4f} Acc: {epoch_acc:.4f}')
​
    return model
​
# --- 开始训练 ---
criterion = nn.CrossEntropyLoss()
# 这里的学习率设为 0.001,比从头训练要小,因为只需要微调
optimizer = optim.SGD(model.parameters(), lr=0.001, momentum=0.9)
​
model_ft = train_model(model, criterion, optimizer, num_epochs=5)

预期结果: 你会发现,仅仅训练 1 个 Epoch,验证集的准确率可能就高达 80% 以上!到了第 5 个 Epoch,可能达到 90%~95%。这就是迁移学习的威力——它学得飞快。

8.5.5 第五步:预测单张图片

模型训练好了,怎么用它来识别一张新图片?

from PIL import Image
​
def predict_image(image_path):
    # 1. 加载并处理图片
    img = Image.open(image_path)
    # 必须使用和验证集一样的预处理步骤
    preprocess = data_transforms['val']
    img_tensor = preprocess(img).unsqueeze(0) # 增加 Batch 维度 [1, 3, 224, 224]
    
    # 2. 搬到 GPU
    img_tensor = img_tensor.to(device)
    
    # 3. 预测
    model.eval()
    with torch.no_grad():
        outputs = model(img_tensor)
        # Softmax 将输出转换为概率
        probs = torch.nn.functional.softmax(outputs, dim=1)
        _, preds = torch.max(outputs, 1)
    
    # 4. 结果解析
    class_name = class_names[preds.item()]
    probability = probs[0][preds.item()].item()
    
    print(f"这是一张: {class_name}, 置信度: {probability:.2%}")
    
    # 显示图片
    import matplotlib.pyplot as plt
    plt.imshow(img)
    plt.title(f"{class_name} ({probability:.1%})")
    plt.axis('off')
    plt.show()
​
# 找一张网上的图试试
# predict_image('my_coke_bottle.jpg')

8.6 章节小结

本章我们跨越了从“识别数字”到“看懂世界”的鸿沟。

  1. CNN 原理:卷积提取特征,池化压缩信息,保留了空间结构。

  2. ImageFolder:PyTorch 处理图像数据的标准姿势,只要整理好文件夹,代码自动搞定。

  3. 数据增强:通过随机裁剪和翻转,让有限的数据集发挥了无限的价值。

  4. 迁移学习:这是本章的核心工程思想。不要重复造轮子,使用 pretrained=True,站在巨人的肩膀上,你可以用极少的数据、极短的时间训练出工业级的模型。

在下一章,我们将把目光从“图像”转向“文本”。如何让机器读懂莎士比亚?如何判断一句话是赞美还是讽刺?我们将进入 NLP(自然语言处理) 的世界。


8.7 思考与扩展练习

  1. 冻结层(Freezing Layers): 在上面的代码中,我们训练了整个 ResNet 的所有参数。其实前面的层(提取线条、颜色)不需要重新训练。尝试在定义优化器之前,将前面层的参数锁定:

    for param in model.parameters():
        param.requires_grad = False
    # 然后解冻最后一层
    model.fc.weight.requires_grad = True
    model.fc.bias.requires_grad = True

    比较一下,锁定参数后,训练速度(每个Epoch的时间)是不是变快了?

  2. 混淆矩阵分析: 使用第5章学过的混淆矩阵,分析一下你的垃圾分类模型。它最容易把哪两类垃圾搞混?(比如是不是经常把“玻璃”看成“塑料”?因为它们都是透明的)。

  3. 自定义挑战: 拿起你的手机,拍20张你家里的猫和狗的照片,分别建立 catsdogs 文件夹。利用本章的代码,训练一个你专属的“猫狗分类器”。

 

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐