news 2026/8/23 20:48:58

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
李宏毅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(用于图像处理和预训练模型)、pandasnumpymatplotlibtqdm(用于进度条)。使用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]) ])

这里有几个重要的设计考量:

  1. 数据增强(Data Augmentation):仅对训练集使用。通过随机翻转、旋转、颜色调整等,可以人为增加数据的多样性,相当于让模型看到了更多可能的图片变体,能有效防止过拟合,提升模型的泛化能力。这是提升小数据集性能的必备技巧。
  2. 归一化参数:为什么使用ImageNet的均值和标准差?因为许多预训练模型是在ImageNet上训练的,其卷积核已经适应了这种数据分布。即使我们不使用预训练模型,使用这个通用的统计值也是一个不错的起点,它有助于稳定训练过程。如果你的数据集与ImageNet差异极大,可以计算自己数据集的均值和标准差,但作业数据通常接近自然图像,直接用问题不大。
  3. 输入尺寸(128, 128)是一个示例,你可以根据计算资源调整。更大的尺寸(如224, 256)可能带来更好的性能,但也会显著增加显存消耗和训练时间。需要在性能和效率间权衡。

使用ImageFolderDataLoader加载数据:

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

关键设计点解析:

  1. 卷积核与通道数:卷积核大小常用3x3,这是VGG网络推广的高效选择。通道数(如16, 32, 64)逐层增加,让网络能够学习到从低级(边缘、纹理)到高级(物体部件)的越来越复杂的特征。
  2. Paddingpadding=1配合kernel_size=3可以保持特征图的空间尺寸不变(output_size = input_size),这样在计算经过池化后的尺寸时更简单。
  3. 批归一化(BatchNorm):这是现代深度网络的标配。它对每一批(batch)的数据进行归一化(减均值、除标准差),使得中间层的输出分布更加稳定。这带来了三大好处:允许使用更大的学习率减少对参数初始化的依赖有一定的正则化效果。通常放在卷积层之后、激活函数之前。
  4. 激活函数:ReLU(Rectified Linear Unit)是最常用的,因为它计算简单且能缓解梯度消失问题。
  5. 池化层:最大池化(MaxPooling)用于下采样,逐步减少特征图尺寸,增加感受野,同时提供一定的平移不变性。2x2池化是最常见的。
  6. 全连接层与Dropout:将卷积学习到的空间特征映射到样本标记空间。在第一个全连接层后加入Dropout,随机“丢弃”一部分神经元(置零),是防止过拟合的强大正则化手段。p=0.5是一个常用值。
  7. 展平操作:在进入全连接层前,必须将多维的特征图“拉平”成一维向量。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)

微调策略详解:

  1. 冻结(Freeze)与解冻(Unfreeze):一开始,我们冻结了预训练模型的所有层(requires_grad=False),这意味着在训练初期,只有我们新替换的全连接层(fc)的参数会被更新。这是为了让模型先适应新任务的新“头部”。训练几个epoch后,可以解冻所有层或部分深层卷积层,用较小的学习率进行整体微调。这种分阶段训练策略非常有效。
  2. 学习率设置:对于新添加的层(fc),可以使用一个相对较大的学习率(如0.01);对于预训练层,如果解冻了,应该使用一个非常小的学习率(如0.001的十分之一),以免破坏已经学到的宝贵特征。
  3. 模型选择: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}%')

核心环节拆解:

  1. .train().eval()模式model.train()会启用Dropout和BatchNorm的训练行为(如用当前batch的统计量进行归一化)。model.eval()则会关闭这些行为,使用训练阶段累积的移动平均统计量进行归一化,这对验证和测试的一致性至关重要。
  2. optimizer.zero_grad():PyTorch的梯度是累加的。如果在每次backward()前不清零梯度,梯度会不断累积,导致更新方向错误。这是最常见的错误之一。
  3. with torch.no_grad():在验证和测试时,我们不需要计算梯度。这个上下文管理器可以禁用自动求导,大幅减少内存消耗并加速计算。
  4. 损失和准确率计算:损失是每个样本损失的平均。注意在累加总损失时,我们乘以了images.size(0)(即当前batch的大小),最后再除以数据集总大小,这是为了得到整个epoch的平均损失,即使最后一个batch可能不满。
  5. 模型保存:我们保存验证集上性能最好的模型(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, 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 结果分析与模型诊断

提交后,你会得到一个在测试集(或公开验证集)上的分数。如果分数不理想,如何排查?

  1. 过拟合(Overfitting):训练准确率远高于验证准确率。
    • 对策:增加数据增强的强度;加大Dropout比率;添加更多的正则化(如权重衰减);使用更简单的模型;收集更多数据。
  2. 欠拟合(Underfitting):训练和验证准确率都很低。
    • 对策:增加模型复杂度(更多层、更多通道);减少正则化;延长训练时间;检查数据预处理是否有误(如归一化参数错了);尝试使用预训练模型。
  3. 训练不稳定(Loss震荡或NaN)
    • 检查学习率:学习率可能太高,尝试降低一个数量级。
    • 检查数据:数据中是否有损坏的图片或异常的标签?归一化后数据值是否在合理范围(如-3到3之间)?
    • 检查梯度:可以添加梯度裁剪(torch.nn.utils.clip_grad_norm_)防止梯度爆炸。
  4. 验证集性能停滞
    • 尝试学习率调度:使用ReduceLROnPlateau在验证损失停滞时自动降低学习率。
    • 解冻预训练层:如果使用迁移学习且还冻结着,尝试解冻后面几层进行微调。
    • 集成(Ensemble):训练多个不同初始化或不同结构的模型,对它们的预测结果进行平均或投票,这是提升分数的“大杀器”,但会增加计算成本。

5.3 进阶优化思路

如果基础模型已经跑通,想冲击更高分数,可以尝试以下方向:

  1. 更强大的数据增强:除了基本的翻转旋转,可以尝试RandAugmentAutoAugment这类自动搜索或预设的增强策略包,它们组合了多种增强方式,效果显著。
  2. 模型集成:训练多个模型(如ResNet18, ResNet50, EfficientNet-B0),在预测时取它们输出的概率平均,能有效提升鲁棒性和准确率。
  3. 测试时增强(Test Time Augmentation, TTA):对一张测试图片,进行多种增强(如原图、水平翻转、垂直翻转等),分别预测,然后对结果取平均。这相当于给了模型多次“观察”的机会,通常能提升一点性能。
  4. 标签平滑(Label Smoothing):在计算损失时,不直接使用硬标签(如[0,0,1,0]),而是使用平滑后的软标签(如[0.01, 0.01, 0.96, 0.01]),这可以减轻模型对标签的过度自信,有正则化效果,可能提升泛化能力。
  5. 混合精度训练:使用torch.cuda.amp进行自动混合精度训练,可以在几乎不损失精度的情况下,大幅减少显存占用,从而允许使用更大的batch_size或更大的模型,同时还能加速训练。

完成HW03的整个过程,远比单纯实现一个CNN类要丰富。它涵盖了数据管道构建、模型设计、训练调试、结果分析这一完整机器学习闭环。最大的收获不是调出了一个高分的模型,而是学会了如何系统地诊断和解决训练中遇到的各种问题。当你看到自己的模型在验证集上的准确率一点点爬升,最终生成那个可以提交的CSV文件时,那种亲手搭建的系统跑通了的成就感,是只看理论无法比拟的。希望这份详细的解析和代码,能帮你更顺畅地完成这次实战,少走一些我走过的弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/23 20:48:33

C++函数重载与模板:告别重复代码,实现泛型编程

1. 项目概述:从“重复造轮子”到“一劳永逸”的思维跃迁 干了这么多年C,我见过太多新手,甚至一些工作了几年的朋友,还在用最原始的方式写代码。比如,要写一个比较两个数大小的函数,他们可能会为 int 写一…

作者头像 李华
网站建设 2026/8/23 20:46:56

群晖Docker部署HomeAssistant:打造本地化智能家居中枢

1. 项目概述与核心价值如果你手头有一台群晖NAS,并且对智能家居自动化感兴趣,但又不想额外购买树莓派或NUC这类硬件,那么直接在群晖的Docker里部署HomeAssistant(简称HA)绝对是一个高性价比且优雅的解决方案。我自己的…

作者头像 李华
网站建设 2026/8/23 20:40:29

MFC入门指南:从Windows GUI开发基础到消息映射与文档视图架构实践

1. 项目概述:为什么今天还要聊MFC?如果你是一个刚接触Windows桌面开发的C新手,打开搜索引擎,扑面而来的可能是Qt、WPF、WinUI 3甚至是Electron。这时你可能会疑惑:MFC是什么?一个听起来有点“古老”的名字&…

作者头像 李华
网站建设 2026/8/23 20:40:18

Git规范全解析:从提交信息到分支管理,打造高效团队协作流程

1. 项目概述:为什么我们需要Git规范? 干了这么多年开发,我见过太多因为版本控制混乱而引发的“血案”。一个团队里,有人提交信息写“fix bug”,有人写“update”,还有人干脆什么都不写。几个月后&#xff…

作者头像 李华
网站建设 2026/8/23 20:39:47

爱思控无刷电机驱动器实战:从硬件接线到RS485/CAN通讯调试全解析

1. 项目概述:从零上手爱思控无刷电机驱动器最近在做一个自动化设备改造的项目,核心需求是要把几台老旧的步进电机换成性能更强的无刷电机。选型的时候,我盯上了爱思控(iCON)的AQMDBLS系列驱动器。这个系列型号后缀挺多…

作者头像 李华