1. 项目概述:一次从理论到实践的CNN图像分类实战
李宏毅老师的机器学习课程,在圈内一直以理论扎实、作业硬核著称。2023年的HW03作业,聚焦于卷积神经网络(CNN)进行图像分类,这不仅是课程的一个关键里程碑,也是很多同学从“看懂公式”到“跑通模型”的第一次深度实战。我花了大约一周的时间,从理解题目、数据预处理、模型搭建、调参优化到最终提交,整个过程踩了不少坑,也积累了一些心得。这篇内容就来详细拆解HW03的每一个环节,分享可复现的代码和那些在官方文档里找不到的“实战经验”。无论你是正在苦战这份作业的同学,还是想通过一个具体项目来巩固CNN和PyTorch的初学者,相信都能从中找到直接的参考和启发。
这份作业的核心任务是利用CNN对食物图片进行分类,共11个类别。它模拟了一个真实的机器学习项目流程:数据加载与探索、模型架构设计、训练策略制定、结果分析与提交。难点不在于模型的复杂性(通常一个几层的CNN即可),而在于如何正确地处理数据、组织代码、调试模型以及理解训练过程中的各种现象。接下来,我会按照实际操作的顺序,逐一拆解。
2. 作业整体思路与核心设计解析
2.1 任务目标与环境搭建
作业的目标非常明确:给定一个食物图片数据集(训练集、验证集和测试集),你需要构建一个CNN模型,在验证集上获得尽可能高的准确率,并对测试集进行预测生成提交文件。数据集的图片尺寸统一,但可能存在光照、角度、背景等差异,这正是一个典型的图像分类任务。
首先,环境是基石。我强烈建议使用Python 3.8+和PyTorch 1.12+的环境。Anaconda虚拟环境管理是首选,它能避免包版本冲突。除了PyTorch,你还需要安装torchvision(用于图像处理和预训练模型)、pandas、numpy、matplotlib和tqdm(用于进度条)。使用CUDA版本的PyTorch可以极大加速训练过程,只要你的显卡支持。
注意:在Windows系统上安装PyTorch的CUDA版本时,务必通过PyTorch官网提供的命令进行安装,明确指定CUDA版本(如
cu117对应CUDA 11.7),并提前在系统中安装对应版本的NVIDIA驱动和CUDA Toolkit。版本不匹配是新手最常见的环境报错原因。
2.2 数据加载与预处理策略
数据是模型的“粮食”,处理得好坏直接决定模型的上限。作业提供的数据通常以文件夹形式组织,每个子文件夹代表一个类别,里面存放着该类别的图片。torchvision.datasets.ImageFolder是处理这种结构的神器,它能自动根据文件夹结构生成标签。
预处理(transforms)是关键一步。我们需要将图片转换为模型能处理的张量(Tensor),并进行归一化。常见的操作组合如下:
from torchvision import transforms # 训练集的预处理:通常包含数据增强 train_transform = transforms.Compose([ transforms.RandomHorizontalFlip(p=0.5), # 随机水平翻转 transforms.RandomRotation(degrees=15), # 随机旋转 transforms.ColorJitter(brightness=0.2, contrast=0.2), # 颜色抖动 transforms.Resize((128, 128)), # 调整大小,根据你的输入尺寸定 transforms.ToTensor(), # 转换为Tensor,并归一化到[0,1] transforms.Normalize(mean=[0.485, 0.456, 0.406], # ImageNet的均值 std=[0.229, 0.224, 0.225]) # ImageNet的标准差 ]) # 验证集和测试集的预处理:不进行数据增强,只需Resize和归一化 test_transform = transforms.Compose([ transforms.Resize((128, 128)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这里有几个重要的设计考量:
- 数据增强(Data Augmentation):仅对训练集使用。通过随机翻转、旋转、颜色调整等,可以人为增加数据的多样性,相当于让模型看到了更多可能的图片变体,能有效防止过拟合,提升模型的泛化能力。这是提升小数据集性能的必备技巧。
- 归一化参数:为什么使用ImageNet的均值和标准差?因为许多预训练模型是在ImageNet上训练的,其卷积核已经适应了这种数据分布。即使我们不使用预训练模型,使用这个通用的统计值也是一个不错的起点,它有助于稳定训练过程。如果你的数据集与ImageNet差异极大,可以计算自己数据集的均值和标准差,但作业数据通常接近自然图像,直接用问题不大。
- 输入尺寸:
(128, 128)是一个示例,你可以根据计算资源调整。更大的尺寸(如224, 256)可能带来更好的性能,但也会显著增加显存消耗和训练时间。需要在性能和效率间权衡。
使用ImageFolder和DataLoader加载数据:
from torchvision import datasets from torch.utils.data import DataLoader train_dataset = datasets.ImageFolder(root='./data/train', transform=train_transform) valid_dataset = datasets.ImageFolder(root='./data/valid', transform=test_transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True) valid_loader = DataLoader(valid_dataset, batch_size=64, shuffle=False, num_workers=4, pin_memory=True)batch_size:一次训练所选取的样本数。太大可能导致显存不足,太小则梯度更新噪声大、训练慢。64或32是常见的起点。shuffle:训练集必须打乱,防止模型学习到数据顺序。num_workers:用于数据加载的子进程数,可以加快数据读取速度。在Windows上有时设为0可避免问题。pin_memory:当使用GPU时,设置为True可以将数据锁页内存,加速数据从CPU到GPU的传输。
3. CNN模型架构设计与实现细节
3.1 从零搭建一个基础CNN模型
对于HW03,我们完全可以自己搭建一个轻量级的CNN。一个典型的模式是多个“卷积层 -> 激活层 -> 池化层”的堆叠,最后接全连接层进行分类。下面是一个示例模型:
import torch.nn as nn import torch.nn.functional as F class MyCNN(nn.Module): def __init__(self, num_classes=11): super(MyCNN, self).__init__() # 卷积块1: 输入3通道,输出16通道 self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1) # padding=1保持尺寸 self.bn1 = nn.BatchNorm2d(16) # 批归一化,加速收敛 self.pool = nn.MaxPool2d(2, 2) # 2x2最大池化,尺寸减半 # 卷积块2 self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(32) # 卷积块3 self.conv3 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.bn3 = nn.BatchNorm2d(64) # 全连接层 # 假设输入图片是128x128,经过3次pooling后是16x16 (128 -> 64 -> 32 -> 16) self.fc1 = nn.Linear(64 * 16 * 16, 512) # 需要根据实际尺寸计算 self.dropout = nn.Dropout(p=0.5) # Dropout防止过拟合 self.fc2 = nn.Linear(512, num_classes) def forward(self, x): x = self.pool(F.relu(self.bn1(self.conv1(x)))) x = self.pool(F.relu(self.bn2(self.conv2(x)))) x = self.pool(F.relu(self.bn3(self.conv3(x)))) # 将特征图展平成一维向量 x = x.view(x.size(0), -1) x = F.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) return x关键设计点解析:
- 卷积核与通道数:卷积核大小常用3x3,这是VGG网络推广的高效选择。通道数(如16, 32, 64)逐层增加,让网络能够学习到从低级(边缘、纹理)到高级(物体部件)的越来越复杂的特征。
- Padding:
padding=1配合kernel_size=3可以保持特征图的空间尺寸不变(output_size = input_size),这样在计算经过池化后的尺寸时更简单。 - 批归一化(BatchNorm):这是现代深度网络的标配。它对每一批(batch)的数据进行归一化(减均值、除标准差),使得中间层的输出分布更加稳定。这带来了三大好处:允许使用更大的学习率、减少对参数初始化的依赖、有一定的正则化效果。通常放在卷积层之后、激活函数之前。
- 激活函数:ReLU(Rectified Linear Unit)是最常用的,因为它计算简单且能缓解梯度消失问题。
- 池化层:最大池化(MaxPooling)用于下采样,逐步减少特征图尺寸,增加感受野,同时提供一定的平移不变性。2x2池化是最常见的。
- 全连接层与Dropout:将卷积学习到的空间特征映射到样本标记空间。在第一个全连接层后加入Dropout,随机“丢弃”一部分神经元(置零),是防止过拟合的强大正则化手段。
p=0.5是一个常用值。 - 展平操作:在进入全连接层前,必须将多维的特征图“拉平”成一维向量。
x.view(x.size(0), -1)中的-1表示自动计算该维度的大小。
实操心得:计算全连接层输入维度是新手最容易出错的地方。一个可靠的方法是先写一个
print(x.shape)在view操作之前,运行一次前向传播(用一个小批量数据),查看展平前的x的形状,例如可能是[batch_size, 64, 16, 16],那么展平后的维度就是64*16*16=16384。将这个值填入nn.Linear的第一个参数。
3.2 使用预训练模型进行迁移学习
如果你的目标是获得更高的分数,迁移学习几乎是必选项。其思想是:利用在超大规模数据集(如ImageNet)上预训练好的模型权重,作为我们模型的起点,然后针对我们的食物分类任务进行微调(Fine-tuning)。
torchvision.models提供了丰富的预训练模型,如ResNet, VGG, EfficientNet等。以ResNet18为例:
import torchvision.models as models class PretrainedModel(nn.Module): def __init__(self, num_classes=11): super(PretrainedModel, self).__init__() # 加载预训练的ResNet18,并获取其特征提取部分(去掉最后的全连接层) backbone = models.resnet18(pretrained=True) # 冻结所有卷积层的参数,在初始阶段不更新它们 for param in backbone.parameters(): param.requires_grad = False # 替换最后的全连接层,以适应我们的11分类任务 num_features = backbone.fc.in_features backbone.fc = nn.Linear(num_features, num_classes) self.model = backbone def forward(self, x): return self.model(x)微调策略详解:
- 冻结(Freeze)与解冻(Unfreeze):一开始,我们冻结了预训练模型的所有层(
requires_grad=False),这意味着在训练初期,只有我们新替换的全连接层(fc)的参数会被更新。这是为了让模型先适应新任务的新“头部”。训练几个epoch后,可以解冻所有层或部分深层卷积层,用较小的学习率进行整体微调。这种分阶段训练策略非常有效。 - 学习率设置:对于新添加的层(
fc),可以使用一个相对较大的学习率(如0.01);对于预训练层,如果解冻了,应该使用一个非常小的学习率(如0.001的十分之一),以免破坏已经学到的宝贵特征。 - 模型选择:ResNet18/34比较轻量,训练快;ResNet50/101性能更强但更耗资源。EfficientNet系列在精度和效率上平衡得更好。根据你的硬件和时间选择。
4. 训练流程的完整实现与调参技巧
4.1 训练循环的构建
有了模型和数据,接下来就是编写训练循环。这是PyTorch训练的标准模板,但细节决定成败。
import torch import torch.optim as optim from tqdm import tqdm device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = MyCNN().to(device) # 或 PretrainedModel().to(device) criterion = nn.CrossEntropyLoss() # 多分类任务使用交叉熵损失 optimizer = optim.Adam(model.parameters(), lr=0.001) # Adam优化器 scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1) # 学习率调度器 num_epochs = 30 best_acc = 0.0 for epoch in range(num_epochs): # 训练阶段 model.train() train_loss = 0.0 train_correct = 0 train_total = 0 # 使用tqdm包装数据加载器,显示进度条 pbar = tqdm(train_loader, desc=f'Epoch {epoch+1}/{num_epochs} [Train]') for images, labels in pbar: images, labels = images.to(device), labels.to(device) # 前向传播 outputs = model(images) loss = criterion(outputs, labels) # 反向传播与优化 optimizer.zero_grad() # 清空过往梯度,至关重要! loss.backward() # 反向传播计算梯度 optimizer.step() # 更新参数 # 统计 train_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs.data, 1) train_total += labels.size(0) train_correct += (predicted == labels).sum().item() # 更新进度条信息 pbar.set_postfix({'Loss': loss.item()}) train_loss = train_loss / len(train_dataset) train_acc = 100.0 * train_correct / train_total # 验证阶段 model.eval() valid_loss = 0.0 valid_correct = 0 valid_total = 0 with torch.no_grad(): # 关闭梯度计算,节省内存和计算 for images, labels in valid_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) valid_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs.data, 1) valid_total += labels.size(0) valid_correct += (predicted == labels).sum().item() valid_loss = valid_loss / len(valid_dataset) valid_acc = 100.0 * valid_correct / valid_total # 学习率调度 scheduler.step() # 打印日志 print(f'Epoch [{epoch+1}/{num_epochs}], ' f'Train Loss: {train_loss:.4f}, Train Acc: {train_acc:.2f}%, ' f'Valid Loss: {valid_loss:.4f}, Valid Acc: {valid_acc:.2f}%') # 保存最佳模型 if valid_acc > best_acc: best_acc = valid_acc torch.save(model.state_dict(), 'best_model.pth') print(f' -> Best model saved with acc: {best_acc:.2f}%')核心环节拆解:
.train()和.eval()模式:model.train()会启用Dropout和BatchNorm的训练行为(如用当前batch的统计量进行归一化)。model.eval()则会关闭这些行为,使用训练阶段累积的移动平均统计量进行归一化,这对验证和测试的一致性至关重要。optimizer.zero_grad():PyTorch的梯度是累加的。如果在每次backward()前不清零梯度,梯度会不断累积,导致更新方向错误。这是最常见的错误之一。with torch.no_grad():在验证和测试时,我们不需要计算梯度。这个上下文管理器可以禁用自动求导,大幅减少内存消耗并加速计算。- 损失和准确率计算:损失是每个样本损失的平均。注意在累加总损失时,我们乘以了
images.size(0)(即当前batch的大小),最后再除以数据集总大小,这是为了得到整个epoch的平均损失,即使最后一个batch可能不满。 - 模型保存:我们保存验证集上性能最好的模型(
state_dict),而不是最后一个epoch的模型。这可以防止模型在训练后期过拟合导致验证集性能下降。
4.2 超参数调优与训练监控
训练深度学习模型很大程度上是在调参。以下是一些核心超参数和经验值:
| 超参数 | 常见范围/选择 | 作用与调参心得 |
|---|---|---|
| 学习率 (lr) | 1e-4 到 1e-2 | 最重要的参数。太大导致震荡不收敛,太小则收敛慢。Adam优化器下,从3e-4开始尝试是个好选择。使用学习率调度器(如StepLR, ReduceLROnPlateau)在训练中后期降低学习率,有助于模型收敛到更优的局部最优点。 |
| 批大小 (batch_size) | 32, 64, 128 | 受限于GPU显存。更大的batch_size使梯度估计更准确,训练更稳定,但可能降低泛化能力。通常设为能占满显存的最大2的幂次。 |
| 优化器 (Optimizer) | Adam, AdamW, SGD | Adam:自适应学习率,对初始学习率不敏感,通常作为默认选择,收敛快。SGD with momentum:配合学习率衰减,最终性能可能更好,但需要更多调参。AdamW:解决了Adam的权重衰减问题,现在被认为是更优的选择,尤其是配合预训练模型。 |
| 权重衰减 (Weight Decay) | 1e-4, 1e-5 | 一种L2正则化,防止模型权重过大,缓解过拟合。对于Adam,使用AdamW并设置weight_decay参数。 |
| Epoch数 | 20-100+ | 观察训练/验证损失曲线。当验证损失连续多个epoch不再下降(甚至上升)时,应提前停止(Early Stopping),防止过拟合。 |
训练监控技巧:
- 绘制损失/准确率曲线:这是诊断训练过程最直观的工具。理想情况是训练损失稳步下降,验证损失先降后升(过拟合拐点)。如果训练损失都不降,可能是学习率太小、模型能力不足或数据有问题。
- 使用TensorBoard或Weights & Biases:这些工具可以实时可视化损失、准确率、权重分布、梯度直方图等,对于复杂调参和实验管理非常有帮助。
5. 测试集预测、结果分析与常见问题排查
5.1 生成提交文件
训练出最佳模型后,我们需要在测试集上运行,生成符合Kaggle或课程平台要求的提交文件(通常是CSV格式)。
import pandas as pd from PIL import Image import os # 加载最佳模型 model.load_state_dict(torch.load('best_model.pth')) model.eval() test_data_path = './data/test' submission = [] # 注意:测试集可能没有标签,需要按文件名顺序读取 test_image_names = sorted(os.listdir(test_data_path)) # 确保顺序一致 with torch.no_grad(): for img_name in tqdm(test_image_names, desc='Predicting'): img_path = os.path.join(test_data_path, img_name) # 用PIL打开图片,并应用与验证集相同的预处理 image = Image.open(img_path).convert('RGB') image = test_transform(image).unsqueeze(0).to(device) # 增加batch维度 output = model(image) _, predicted = torch.max(output, 1) # 假设文件名就是ID,或者从文件名中提取ID submission.append([img_name.split('.')[0], predicted.item()]) # 保存ID和预测类别 # 创建DataFrame并保存为CSV df = pd.DataFrame(submission, columns=['Id', 'Category']) df.to_csv('submission.csv', index=False) print("Submission file saved to submission.csv")重要提示:测试集的预处理必须与验证集完全一致(相同的
Resize尺寸、相同的归一化均值标准差)。任何不一致都会导致模型性能的不可预测下降。
5.2 结果分析与模型诊断
提交后,你会得到一个在测试集(或公开验证集)上的分数。如果分数不理想,如何排查?
- 过拟合(Overfitting):训练准确率远高于验证准确率。
- 对策:增加数据增强的强度;加大Dropout比率;添加更多的正则化(如权重衰减);使用更简单的模型;收集更多数据。
- 欠拟合(Underfitting):训练和验证准确率都很低。
- 对策:增加模型复杂度(更多层、更多通道);减少正则化;延长训练时间;检查数据预处理是否有误(如归一化参数错了);尝试使用预训练模型。
- 训练不稳定(Loss震荡或NaN):
- 检查学习率:学习率可能太高,尝试降低一个数量级。
- 检查数据:数据中是否有损坏的图片或异常的标签?归一化后数据值是否在合理范围(如-3到3之间)?
- 检查梯度:可以添加梯度裁剪(
torch.nn.utils.clip_grad_norm_)防止梯度爆炸。
- 验证集性能停滞:
- 尝试学习率调度:使用
ReduceLROnPlateau在验证损失停滞时自动降低学习率。 - 解冻预训练层:如果使用迁移学习且还冻结着,尝试解冻后面几层进行微调。
- 集成(Ensemble):训练多个不同初始化或不同结构的模型,对它们的预测结果进行平均或投票,这是提升分数的“大杀器”,但会增加计算成本。
- 尝试学习率调度:使用
5.3 进阶优化思路
如果基础模型已经跑通,想冲击更高分数,可以尝试以下方向:
- 更强大的数据增强:除了基本的翻转旋转,可以尝试
RandAugment或AutoAugment这类自动搜索或预设的增强策略包,它们组合了多种增强方式,效果显著。 - 模型集成:训练多个模型(如ResNet18, ResNet50, EfficientNet-B0),在预测时取它们输出的概率平均,能有效提升鲁棒性和准确率。
- 测试时增强(Test Time Augmentation, TTA):对一张测试图片,进行多种增强(如原图、水平翻转、垂直翻转等),分别预测,然后对结果取平均。这相当于给了模型多次“观察”的机会,通常能提升一点性能。
- 标签平滑(Label Smoothing):在计算损失时,不直接使用硬标签(如[0,0,1,0]),而是使用平滑后的软标签(如[0.01, 0.01, 0.96, 0.01]),这可以减轻模型对标签的过度自信,有正则化效果,可能提升泛化能力。
- 混合精度训练:使用
torch.cuda.amp进行自动混合精度训练,可以在几乎不损失精度的情况下,大幅减少显存占用,从而允许使用更大的batch_size或更大的模型,同时还能加速训练。
完成HW03的整个过程,远比单纯实现一个CNN类要丰富。它涵盖了数据管道构建、模型设计、训练调试、结果分析这一完整机器学习闭环。最大的收获不是调出了一个高分的模型,而是学会了如何系统地诊断和解决训练中遇到的各种问题。当你看到自己的模型在验证集上的准确率一点点爬升,最终生成那个可以提交的CSV文件时,那种亲手搭建的系统跑通了的成就感,是只看理论无法比拟的。希望这份详细的解析和代码,能帮你更顺畅地完成这次实战,少走一些我走过的弯路。