
AI课程实验4做完已经好几天了但直到现在群里还有同学在问环境怎么配、CNN的输入维度到底怎么算、为什么训练了好几个epoch准确率还在原地踏步。我原本以为这只是一个简单的“跑通MNIST”任务真正动手才发现“深度学习算法及应用”这个实验考验的从来不是能不能跑通而是你是否真正理解了整套流程数据怎么喂给网络、模型结构怎么设计、损失函数和优化器为什么这么选、训练完了怎么评估和保存。这篇文章我就拿自己这次的实验过程为例把从环境搭建到模型训练再到问题排查的完整链路重新捋一遍。如果你正在做类似的深度学习入门实验或者刚接触PyTorch没多久这篇文章应该能帮你少走不少弯路。我不会只贴代码还会讲清楚每个关键步骤背后的逻辑以及我踩过的坑。毕竟“深度学习”这四个字听起来高大上落到底层就是张量运算和梯度更新把这套逻辑理顺了实验怎么写都不会慌。1. 实验拆解先搞清楚要做什么再动手1.1 这次实验到底在考什么实验4的题目叫“深度学习算法及应用”听上去范围很广但课程实验一般都有明确的载体。我这次选的是最经典、也是最稳妥的任务手写数字识别数据集用MNIST。为什么用它因为它足够小、足够干净、训练速度快对初学者极度友好。一张图28x28像素灰度图总共10个类别数字0到9训练集6万张测试集1万张。无论你用全连接网络还是卷积神经网络都能在几分钟内得到可接受的结果。但这个实验真正的考点并不只是“识别准确率有多高”。从实验报告的角度看老师更想看到的是你能不能把一件事讲明白数据怎么预处理、模型结构为什么这么设计、训练过程中的loss和accuracy变化是否合理、遇到过什么问题、又是怎么解决的。说白了深度学习实验考察的是“完整闭环”能力而不是某个孤立的知识点。我的建议是拿到实验题先别急着敲代码。花十分钟在纸上画一下整个流程数据加载 - 预处理 - 模型定义 - 训练循环 - 测试评估 - 结果可视化。把这些模块列清楚后面写代码就是按图索骥。这一步看起来简单但能帮你避免一大半“代码写一半不知道自己在干嘛”的尴尬。1.2 环境与工具选型别把时间浪费在配环境上深度学习实验最常见的问题不是模型写不出来而是环境装到一半崩了。PyTorch和TensorFlow是目前最主流的两个框架我这次选的是PyTorch原因是它对初学者更友好调试体验更好报错信息也相对容易理解。而且PyTorch的写法更接近“Python原生风格”你在定义模型、写训练循环的时候思路会比较连贯。硬件方面MNIST这种小数据集其实不需要GPUCPU也能在几分钟内跑完一个简单的CNN。如果你手头有NVIDIA显卡并且已经装好了CUDA那当然更好。没有GPU也不用焦虑完全可以用CPU先跑通流程。我这次是在实验室的GPU服务器上跑的但代码里做了设备自适应判断也就是说同一套代码放到没有GPU的机器上也能正常运行。安装环境的时候我建议用conda新建一个独立环境不要直接装在base环境里否则后面装其他包很容易产生版本冲突。我这次用的核心版本是Python 3.10 PyTorch 2.0CUDA 11.8。如果你是CPU环境直接执行pip install torch torchvision就行PyTorch从2.0开始默认的PyPI包已经能很好支持CPU推理和训练。提示如果你是在校学生可以考虑用Google Colab注册后就能免费使用GPU训练MNIST这种级别的小模型非常宽裕。唯一要注意的是每次会话结束以后环境会重置模型文件记得及时下载到本地。2. 数据准备模型吃进去的不是图片是张量2.1 MNIST数据集与数据加载很多人第一次接触深度学习时会对“图片是怎么输入到网络里的”这个问题感到困惑。其实在计算机眼里一张灰度图片就是一个二维矩阵尺寸是28x28每个元素的值在0到255之间代表像素亮度。MNIST里的图片已经被预处理成了这种规整的格式所以连裁剪和缩放都省了。PyTorch里加载MNIST非常简单使用torchvision.datasets.MNIST这个接口它会自动下载数据集并封装成Dataset对象。为了让数据能够被批量、随机地读取还需要把它传给DataLoader。这就像去食堂打饭数据集的角色是“后厨”DataLoader是“打饭窗口”每次按固定的人数batch size把饭菜端出来还可以随机打乱顺序shuffle。from torch.utils.data import DataLoader from torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST( root./data, trainTrue, downloadTrue, transformtransform ) test_dataset datasets.MNIST( root./data, trainFalse, downloadTrue, transformtransform ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size64, shuffleFalse)这里有一个细节很容易忽略ToTensor()会把原始像素值从0到255缩放到0到1之间同时把数据的shape从(28, 28)变成(1, 28, 28)这个“1”是通道数。因为MNIST是灰度图所以只有1个通道如果是RGB彩色图就会有3个通道。PyTorch中图像张量的标准排列方式是(batch_size, channels, height, width)记住这个顺序后面定义卷积层和全连接层的时候会频繁用到。2.2 归一化、批次与DataLoader数据归一化在MNIST实验里看起来不起眼但影响很大。上面代码里的Normalize((0.1307,), (0.3081,))这两个数是MNIST数据集的全局均值和标准差。归一化之后数据的分布就变成了近似标准正态分布均值接近0标准差接近1。这样做的好处是梯度更新的方向更稳定模型收敛速度明显变快也减少了对初始权重的敏感度。你可以做一个简单的对比实验不归一化直接训练loss下降会很磨叽甚至可能出现震荡归一化之后同样的参数设置loss下降就顺滑得多。这不是玄学而是因为未经归一化的特征值波动范围太大会让损失函数在某些方向上的梯度特别陡另一些方向上又特别平缓优化器找起路来很费劲。Batch size的选择也值得说两句。我这次用的是64这是图像分类里比较常用的默认值。batch size太小比如1梯度噪声大训练不稳定太大比如512梯度更平滑但内存占用高而且在某些情况下泛化能力反而变差。对MNIST这种小数据集64到128之间都挺合适。注意DataLoader在训练时设置shuffleTrue是为了让每个epoch内的数据顺序都不一样避免模型学到样本顺序里的虚假规律。测试时不需要打乱所以设置shuffleFalse。3. 模型设计从全连接网络到卷积神经网络3.1 先搭一个MLP基线我建议不要一上来就写CNN先搭一个最简单的多层感知机MLP作为基线。这样做有两个好处一是可以验证数据加载和训练流程是否正确二是通过对比MLP和CNN的效果能更直观地理解卷积神经网络到底“强在哪里”。MLP的结构很直白把28x28的图片展平成一维向量长度是784然后依次经过全连接层、激活函数、全连接层、激活函数最后输出10维的向量对应10个数字的得分。每个数字的得分越高模型就越倾向于把这张图识别为对应的数字。import torch.nn as nn class MLP(nn.Module): def __init__(self): super().__init__() self.net nn.Sequential( nn.Flatten(), nn.Linear(28 * 28, 256), nn.ReLU(), nn.Linear(256, 128), nn.ReLU(), nn.Linear(128, 10) ) def forward(self, x): return self.net(x)这段代码里nn.Flatten()的作用是把shape为(batch_size, 1, 28, 28)的输入压平成(batch_size, 784)。nn.Linear(784, 256)表示一个全连接层输入784个特征输出256个神经元。中间的ReLU激活函数给网络引入非线性否则叠加再多全连接层本质上还是线性变换无法拟合复杂的函数。计算一下参数数量感受一下这个“简单”网络的体量第一层784x256 256偏置 200960第二层256x128 128 32896第三层128x10 10 1290。合计约23.5万个参数。在MNIST上好好训练MLP的准确率可以达到97%到98%已经不算差但离CNN还是有差距。3.2 用CNN提取图像特征CNN和MLP最本质的区别在于CNN通过卷积操作去提取图像的局部特征。你可以把卷积核想象成一个小型放大镜它每次只看图片的一小块区域然后在整张图上滑动扫描。这种方式能够捕捉到边缘、纹理、形状等层次化特征而且因为卷积核是共享的参数数量大大减少。我这里参考了经典的LeNet-5结构但做了一点简化调整让它在MNIST上表现更好。结构是卷积层1 - 池化 - 卷积层2 - 池化 - 展平 - 全连接层1 - 全连接层2 - 输出层。class CNN(nn.Module): def __init__(self): super().__init__() self.conv_layers nn.Sequential( nn.Conv2d(1, 6, kernel_size5, padding0), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(6, 16, kernel_size5), nn.ReLU(), nn.MaxPool2d(2, 2) ) self.fc_layers nn.Sequential( nn.Linear(16 * 4 * 4, 120), nn.ReLU(), nn.Linear(120, 84), nn.ReLU(), nn.Linear(84, 10) ) def forward(self, x): x self.conv_layers(x) x torch.flatten(x, 1) x self.fc_layers(x) return x我估计刚接触CNN的人最头疼的就是维度计算。我们一步步推输入是(1, 28, 28)。第一个卷积层用了6个5x5的卷积核padding0stride1输出尺寸是(28-5)/1 1 24所以变成(6, 24, 24)。接着ReLU和2x2最大池化尺寸减半变成(6, 12, 12)。第二个卷积层用16个5x5卷积核输出尺寸是(12-5)/1 1 8变成(16, 8, 8)。再池化后变成(16, 4, 4)。最后展平成一维长度是16x4x4 256。这个维度计算一定要自己手算一遍因为我见过太多人代码报错就出在维度对不上。你可以把每层输出的shape用print(x.shape)打到屏幕上逐层核对这是最快的方法。参数总量方面CNN要少得多。conv11x6x5x5 6 156conv26x16x5x5 16 2416fc1256x120 120 30840fc2120x84 84 10164fc384x10 10 850。合计约4.4万个参数只有MLP的六分之一左右但准确率反而更高。这就是卷积和参数共享的威力。3.3 损失函数与优化器怎么选模型定义好之后还要确定两个东西损失函数和优化器。我用的是nn.CrossEntropyLoss()也就是交叉熵损失。这里有一个容易困惑的点交叉熵损失函数内部已经包含了Softmax操作所以我们不需要在模型的最后一层外面再手动套一个Softmax。模型输出的10维原始得分logits直接丢给损失函数就行PyTorch会帮你处理好。优化器我用的Adam学习率设0.001。Adam的最大优势是自适应调整每个参数的学习率对初学者的容错率很高。你不需要像SGD那样反复调momentum和学习率衰减大部分情况下默认参数就能得到不错的结果。import torch.optim as optim model CNN() criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001)如果你想让模型泛化能力更强一点可以在实验中后期换用SGD momentum试试但需要把学习率适当调大比如0.01到0.1之间同时要更仔细地观察训练曲线。对课程实验来说Adam CrossEntropyLoss是最稳的组合先把流程跑通再去做变体实验也不迟。4. 训练闭环与结果分析4.1 训练与测试代码训练过程的核心就是“前向传播 - 计算损失 - 反向传播 - 更新参数”这个循环。PyTorch里的写法非常固定新手容易忘的是三行代码optimizer.zero_grad()清空上一轮梯度、loss.backward()反传梯度、optimizer.step()更新参数。这三行的顺序不能乱原因也很简单不清空梯度的话梯度会累加不反传就没有梯度可用不更新参数梯度算了也是白算。import torch device torch.device(cuda if torch.cuda.is_available() else cpu) model CNN().to(device) def train_one_epoch(loader): model.train() total_loss 0.0 correct 0 total 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() pred outputs.argmax(dim1) correct (pred labels).sum().item() total labels.size(0) return total_loss / len(loader), correct / total def evaluate(loader): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) pred outputs.argmax(dim1) correct (pred labels).sum().item() total labels.size(0) return correct / total测试时务必加上model.eval()和torch.no_grad()。前者会把模型切换到评估模式影响Dropout和BatchNorm这类层的行为后者告诉PyTorch不需要计算梯度既省内存又能加速推理。这两个设置虽然对MNIST小模型影响不明显但等你以后跑大模型就会知道它们有多重要了。4.2 记录训练过程别只看最终准确率训练epoch设5就够了MNIST上收敛很快跑多了容易过拟合。下面是我记录的训练日志格式是“epoch: train_loss, train_acc, test_acc”。你可以按照这个思路自己打印日志观察变化趋势比单看最终数字有意义得多。epochtrain_losstrain_acctest_acc10.182594.35%97.12%20.064398.18%98.25%30.042098.69%98.73%40.029699.16%98.90%50.021699.43%99.08%CNN在第一个epoch结束就能到97%以上的测试准确率之后稳步提升。注意第四到第五个epoch训练准确率还在涨但测试准确率涨幅已经明显变小这说明模型已经趋近饱和再继续训练意义不大。如果训练到后期发现训练准确率很高、测试准确率反而下降那就是过拟合的信号需要考虑加Dropout或数据增强。分析的时候我还会抽几张测试图片出来看模型预测错误的都是哪些样本。大多数错误集中在笔迹潦草的图像上比如把“4”看成“9”把人写的“7”看成“2”。这种人工检查非常值得做它能让你直观理解模型的失败模式写实验报告的时候也有素材。4.3 模型保存与加载训练完之后把模型保存下来是实验的收尾工作。PyTorch保存模型有两种常用方式一种是用torch.save(model, model.pth)保存整个模型对象另一种是只保存model.state_dict()。我强烈推荐后者因为state_dict本质上是Python字典只包含模型的参数和缓存不依赖模型类的源代码路径跨环境复用更安全。torch.save(model.state_dict(), cnn_mnist.pth) # 加载时需要先实例化一个相同结构的模型 model_loaded CNN() model_loaded.load_state_dict(torch.load(cnn_mnist.pth)) model_loaded.to(device)加载完之后如果要继续评估或者部署别忘了调用model_loaded.eval()。我之前有一次加载完直接拿去推理结果因为没切eval模式得到的预测结果和训练时不一致排查了半天才发现是这个问题。5. 踩坑实录与调参建议5.1 常见问题速查表我把这次实验以及帮同学调试过程中遇到的问题整理成了一张速查表基本覆盖了新手最常碰到的几类情况现象可能原因解决方案loss一直不下降学习率太大或太小数据没归一化学习率从0.001试起检查transform配置准确率卡在90%上下不去模型太简单或者训练不充分换CNN增加训练轮数或调整batch size报错size mismatch全连接层输入维度和卷积输出维度对不上print每层输出shape重新计算维度报错input and target batch size不一致数据标签没对齐或某个barch被drop用len(images)和len(labels)检查DataLoaderCUDA out of memorybatch size太大或模型太大减小batch size释放无用变量确认没有在测试时开梯度加载模型时key名字对不上state_dict和模型结构不一致检查模型类定义不要用旧版本state_dict加载新结构这些问题大多不是算法问题而是工程习惯问题。我的经验是出错了先看张量shape再看设备和数据类型最后才怀疑算法的锅。把这三个地方检查一遍能解决80%的报错。5.2 调参的几个方向深度学习调参看起来没有标准答案但有一些通用原则。学习率是最敏感的超参数我建议按指数间隔去试比如0.1、0.01、0.001、0.0001先跑一两个epoch看loss趋势而不是一上来就完整训练。loss剧烈震荡多半是学习率偏大loss下降特别慢可以稍微调大学习率或者换优化器。Batch size的影响也很微妙。同一个模型batch size从64调到256收敛速度会变快但最终准确率不一定更高。这是因为小batch的梯度带有更多噪声有时候反而能帮模型跳出局部最优点。对MNIST而言我的经验是64到128之间比较平衡不需要额外调。隐藏层数量和宽度也不是越大越好。MNIST数据集本身简单隐藏层太宽太深容易过拟合训练时间还长。MLP用256 - 128这样的小结构就够了CNN用LeNet级别的结构也完全够用。把模型弄大很简单但把模型设计得恰到好处才是这个实验想训练的能力。5.3 一次让我折腾到半夜的bug这里必须分享一个我真实遇到过的bug。当时我用CNN训练第一个epoch刚跑完就报了RuntimeError: Expected input batch_size to match target batch_size。我第一反应是数据出问题了于是去打印images.shape和labels.shape发现一个是(64, 1, 28, 28)另一个是(64,)明明对得上。后来我重新看代码发现是模型里的全连接层维度写错了。我把16 * 4 * 4算成了16 * 5 * 5导致最后一个卷积层池化后的展平维度是256但全连接层期望的输入是400于是PyTorch在forward时直接报维度不匹配。为什么报错信息里写的是“batch size mismatch”而不是“dimension mismatch”我看了一下问题出在我展平后的张量形状被误当成了batch维才出现了这种误导性很强的报错。这个bug困了我很久但也让我养成一个好习惯写完模型先喂一个随机张量做“冒烟测试”。dummy torch.randn(2, 1, 28, 28) out model(dummy) print(out.shape) # 期望 torch.Size([2, 10])如果这一步能通过就说明前向传播的网络结构没问题如果报错直接在堆栈信息里找到具体是哪一层出了问题。这个习惯帮我省了无数时间推荐你也养成。最后再说两句这次实验做完我最深的体会是深度学习实验真正难的不是“深度学习算法”这几个字而是把数据流和维度算清楚。很多同学代码复制下来能跑但换个数据集、改个层数就废了原因就是没有把每一层输入输出的shape刻在脑子里。我后来习惯在做实验前拿张纸把网络结构每一层的尺寸写出来训练时心里非常有底。如果你时间充裕还可以在这个实验基础上继续扩展给CNN加Dropout和数据增强试试能不能稳定超过99.5%的准确率或者换一个更难的数据集比如Fashion-MNIST看看模型结构对结果的影响有多大。深度学习就是这样越折腾越有感觉。希望这篇记录能帮你少踩几个坑做得比我更顺。