李宏毅HW03图像分类实战:从CNN原理到PyTorch实现详解

发布时间:2026/8/23 20:49:04
李宏毅HW03图像分类实战:从CNN原理到PyTorch实现详解 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(p0.5), # 随机水平翻转 transforms.RandomRotation(degrees15), # 随机旋转 transforms.ColorJitter(brightness0.2, contrast0.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, transformtrain_transform) valid_dataset datasets.ImageFolder(root./data/valid, transformtest_transform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers4, pin_memoryTrue) valid_loader DataLoader(valid_dataset, batch_size64, shuffleFalse, num_workers4, pin_memoryTrue)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_classes11): super(MyCNN, self).__init__() # 卷积块1: 输入3通道输出16通道 self.conv1 nn.Conv2d(3, 16, kernel_size3, padding1) # padding1保持尺寸 self.bn1 nn.BatchNorm2d(16) # 批归一化加速收敛 self.pool nn.MaxPool2d(2, 2) # 2x2最大池化尺寸减半 # 卷积块2 self.conv2 nn.Conv2d(16, 32, kernel_size3, padding1) self.bn2 nn.BatchNorm2d(32) # 卷积块3 self.conv3 nn.Conv2d(32, 64, kernel_size3, padding1) 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(p0.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逐层增加让网络能够学习到从低级边缘、纹理到高级物体部件的越来越复杂的特征。Paddingpadding1配合kernel_size3可以保持特征图的空间尺寸不变output_size input_size这样在计算经过池化后的尺寸时更简单。批归一化BatchNorm这是现代深度网络的标配。它对每一批batch的数据进行归一化减均值、除标准差使得中间层的输出分布更加稳定。这带来了三大好处允许使用更大的学习率、减少对参数初始化的依赖、有一定的正则化效果。通常放在卷积层之后、激活函数之前。激活函数ReLURectified Linear Unit是最常用的因为它计算简单且能缓解梯度消失问题。池化层最大池化MaxPooling用于下采样逐步减少特征图尺寸增加感受野同时提供一定的平移不变性。2x2池化是最常见的。全连接层与Dropout将卷积学习到的空间特征映射到样本标记空间。在第一个全连接层后加入Dropout随机“丢弃”一部分神经元置零是防止过拟合的强大正则化手段。p0.5是一个常用值。展平操作在进入全连接层前必须将多维的特征图“拉平”成一维向量。x.view(x.size(0), -1)中的-1表示自动计算该维度的大小。实操心得计算全连接层输入维度是新手最容易出错的地方。一个可靠的方法是先写一个print(x.shape)在view操作之前运行一次前向传播用一个小批量数据查看展平前的x的形状例如可能是[batch_size, 64, 16, 16]那么展平后的维度就是64*16*1616384。将这个值填入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_classes11): super(PretrainedModel, self).__init__() # 加载预训练的ResNet18并获取其特征提取部分去掉最后的全连接层 backbone models.resnet18(pretrainedTrue) # 冻结所有卷积层的参数在初始阶段不更新它们 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_gradFalse这意味着在训练初期只有我们新替换的全连接层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(), lr0.001) # Adam优化器 scheduler optim.lr_scheduler.StepLR(optimizer, step_size5, gamma0.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, descfEpoch {epoch1}/{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(fEpoch [{epoch1}/{num_epochs}], fTrain Loss: {train_loss:.4f}, Train Acc: {train_acc:.2f}%, fValid 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, SGDAdam自适应学习率对初始学习率不敏感通常作为默认选择收敛快。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, descPredicting): 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, indexFalse) 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文件时那种亲手搭建的系统跑通了的成就感是只看理论无法比拟的。希望这份详细的解析和代码能帮你更顺畅地完成这次实战少走一些我走过的弯路。