深度学习入门:PyTorch CNN手写数字识别代码逐行拆解
如果你是个完全没碰过深度学习的人第一次打开一个项目源码那种感觉大概跟看天书差不多。到处都是self.xxx、forward()、tensor.view()每一行都认识连起来就不知道在干什么。我当年入门的时候就是这样对着一个图像分类的代码来回看了三天才勉强把“数据怎么进网络”“网络怎么算出结果”“结果怎么变成损失”这条链路串起来。那会儿最缺的就是有人把每一行代码掰开揉碎告诉我“这一句到底做了什么、为什么要这么写、执行完数据变成了什么形状”。所以这篇博文我打算用一个最经典也最完整的图像分类项目——基于 CNN 的手写数字识别带着你把整份代码从头到尾过一遍。不浮在概念层面讲什么“卷积神经网络是一种前馈神经网络”而是直接落到代码行每一行在干嘛数据在这一行之前是什么形状、在这一行之后变成了什么形状每一处报错最可能是什么原因。不管你之后是做目标检测、语义分割、还是 NLP这套“数据管道 模型定义 训练循环 评估流程”的骨架都是通用的。把这份骨架吃透后面换任何任务都是在这个框架里填内容。1. 项目整体长什么样先看骨架再抠细节1.1 一个深度学习项目最基本的四大块任何深度学习项目哪怕公司里跑了几千万美金训练成本的工业级系统拆到最底层也就是四件事第一把数据准备好让数据能一批一批地喂给模型。这就是数据管道。包括下载、预处理、划分训练集和测试集、打包成批次。第二把模型搭出来也就是定义一堆带参数的运算层让数据在这些层之间流动并输出结果。这是模型定义。第三用数据去训练模型让模型输出的结果一点点逼近正确答案。这是训练循环。第四在没见过的数据上验证模型学得好不好。这是评估。手写数字识别这个任务恰好把这四块都占全了而且每一块都不复杂。数据是现成的 MNIST模型用一个两三层的 CNN 就够用训练也快——CPU 上几分钟就能跑完。正因为它足够小你才有精力去关注“每一行代码的作用”而不是被训练成本、分布式、调参这些外围因素干扰。1.2 我们最终要实现的完整流程我先用大白话把整个流程讲一遍心里有个地图后面看代码才不会迷路。我们有 6 万张图片每张图片是一个 28×28 的灰度手写数字标签是 0 到 9。模型要做的事情是输入一张图片输出一个长度为 10 的向量向量第几位最大就预测图片是几。模型最初是随机参数的输出完全不准。我们把图片喂进去拿到预测结果和真实标签算一个“差距”损失函数然后根据这个差距反向传播用梯度下降法把模型的参数往“让差距变小”的方向调一点点。重复这个过程几万次模型输出的结果就越来越准。最后在没参加训练的测试集上评估一下准确率基本能到 99% 左右。下面这份代码就是完整可运行的版本用的是 PyTorch。建议你先别急着复制跑跟着文章一步步把每一行的逻辑看懂然后再动手。import torch import torch.nn as nn import torch.optim as optim import torchvision import torchvision.transforms as transforms import matplotlib.pyplot as plt import numpy as np # 1. 数据准备 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset torchvision.datasets.MNIST( root./data, trainTrue, transformtransform, downloadTrue) test_dataset torchvision.datasets.MNIST( root./data, trainFalse, transformtransform, downloadTrue) train_loader torch.utils.data.DataLoader( train_dataset, batch_size64, shuffleTrue) test_loader torch.utils.data.DataLoader( test_dataset, batch_size64, shuffleFalse) # 2. 模型定义 class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3) self.conv2 nn.Conv2d(32, 64, kernel_size3) self.fc1 nn.Linear(64 * 12 * 12, 128) self.fc2 nn.Linear(128, 10) def forward(self, x): x torch.relu(self.conv1(x)) x torch.max_pool2d(x, 2) x torch.relu(self.conv2(x)) x torch.max_pool2d(x, 2) x x.view(x.size(0), -1) x torch.relu(self.fc1(x)) x self.fc2(x) return x model CNN() # 3. 训练配置 criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) # 4. 训练循环 for epoch in range(5): running_loss 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() print(fEpoch {epoch1}, Loss: {running_loss / len(train_loader):.4f}) # 5. 评估 model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: outputs model(images) _, predicted torch.max(outputs.data, 1) total labels.size(0) correct (predicted labels).sum().item() print(fTest Accuracy: {100 * correct / total:.2f}%)就这么 60 多行代码训练出一个 99% 准确率的数字识别模型。下面我一段一段拆。2. 数据准备部分深度学习的地基2.1 导入库的次序也有讲究import torch import torch.nn as nn import torch.optim as optim第一行导入 PyTorch 主库用它来操作张量。第二行的nn是神经网络模块里面封装好了各种网络层比如卷积层nn.Conv2d、全连接层nn.Linear。第三行的optim是优化器模块里面实现了各种梯度下降算法。这里有一个新手容易忽略的点torch本身只负责张量运算就像一个高级版的 NumPy。你需要别的功能时再从子模块里导入。这种设计是有意为之的——把核心的张量库做小做稳外围功能按需加载。import torchvision import torchvision.transforms as transformstorchvision是专门处理视觉数据的工具库里面帮我们把常见数据集MNIST、CIFAR-10、ImageNet 的接口都写好了。transforms是数据预处理的工具箱。import matplotlib.pyplot as plt import numpy as np这两个是画图和数学计算的库后面做可视化时会用到。plt用来显示图片numpy用来做数据转换和形状查看。为什么不直接用torch里的张量画图因为matplotlib更擅长展示图片而且很多老工具只认 NumPy 数组。转换一下也就是一行代码的事。2.2 数据变换为什么一定要转张量还要做归一化transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])Compose就是把多个预处理操作打包成一个管道图片进来会按顺序依次经过里面每一个操作。第一个操作ToTensor()做了两件事一件是把图片从 PIL 格式或 NumPy 数组格式转换成 PyTorch 的张量格式另一件是把像素值从 0 到 255 压缩到 0 到 1——它直接把每个像素值除以了 255。这个缩放不是可选项是必选项。如果直接用 0 到 255 的数值喂给网络数值太大容易让梯度计算不稳定模型很难收敛。第二个操作Normalize((0.1307,), (0.3081,))是标准化每个像素值减去均值 0.1307 再除以标准差 0.3081。这两个数值不是随便写的它们就是 MNIST 数据集全部图片像素的均值和标准差。标准化之后数据的分布会变成均值接近 0、标准差接近 1 的状态。这里有个疑问很常见为什么已经缩放到 0 到 1 了还要再标准化因为 0 到 1 只是把范围统一了但数据的分布形状没有变。标准化是让数据分布变成一个标准的钟形曲线这样网络在初始化时的参数尺度才能跟数据尺度匹配上梯度下降走起来更顺。在实践中我见过很多人跳过这步模型也能跑但收敛速度明显变慢准确率上限也会低一点。在 MNIST 这种简单任务上影响不大一换到真实数据集差别就很明显了。2.3 数据集对象是怎么回事train_dataset torchvision.datasets.MNIST( root./data, trainTrue, transformtransform, downloadTrue) test_dataset torchvision.datasets.MNIST( root./data, trainFalse, transformtransform, downloadTrue)这两行做的事情是去网上下载 MNIST 数据集到本地./data目录然后把每一张图片都过一遍上面定义的transform管道最后组装成一个标准的“数据集对象”。trainTrue表示加载训练集合有 6 万张图片trainFalse表示加载测试集合有 1 万张图片。需要注意的是dataset对象本身还不是我们喂给模型的数据。它更像一个“仓库”我们可以通过下标train_dataset[0]拿到第一张图。但这个仓库拿数据的方式是一次拿一个样本而训练神经网络需要一次拿一批所以还需要接下来这个步骤。2.4 DataLoader把零散数据打包成批次train_loader torch.utils.data.DataLoader( train_dataset, batch_size64, shuffleTrue) test_loader torch.utils.data.DataLoader( test_dataset, batch_size64, shuffleFalse)DataLoader做的事情就是按照我们指定的规则从数据集中一批一批地取数据。batch_size64表示每次取 64 张图片打包成一个批次。这里我多说一句batch_size是最重要的超参数之一它决定了每次参数更新前要看多少张图。64 这个值不是拍脑袋定的。太小的批次比如 1会让梯度方向噪声很大训练过程震荡严重太大的批次比如 512 或 1024虽然梯度方向更稳定但每个批次计算量太大内存压力也大而且有研究表明特别大的批次需要调大学习率才能匹配上。64 和 128 对 MNIST 这种小图来说是个既稳妥又高效的区间。shuffleTrue表示每个 epoch 开始前会先把数据打乱。为什么训练集要打乱因为如果数据里 0 到 9 是按顺序排的模型在一个批次里看到的全是同一个数字连续几个批次下来参数会往偏向那个数字的方向猛调然后再遇到下一个数字又猛调回来。训练过程就会忽左忽右不稳定。打乱之后每个批次的数字分布是随机的梯度方向更接近真实的全局方向。shuffleFalse用于测试集。测试时我们不需要调整模型参数只是评估效果不需要打乱。还有一点你可能没注意DataLoader默认开启了多进程数据加载在 Windows 上可能默认是 0它会提前把下一批数据加载好这样 GPU 在计算时不用干等 CPU 取数据训练速度能提升不少。3. 模型定义部分手写 CNN 的每一层3.1 为什么选 CNN 而不是全连接网络在手写数字识别这件事上理论上用简单的全连接网络也能跑出 90% 以上的准确率。那为什么我要用 CNN因为 CNN 把“图片相邻像素之间有空间关系”这个先验知识直接做进了网络结构里。全连接网络把每个像素当成独立的输入特征排列顺序对它来说没有意义——你把图片像素随机打乱重新排列全连接网络的表现不会变差因为它在结构上根本没利用空间信息。而 CNN 的卷积核在图片上滑动天然就是在一个局部区域内做特征提取左上角和右下角的像素距离很远它们之间的关联就被结构性地忽略了。对图像任务来说这是巨大的归纳偏置优势。3.2 类定义和super那行代码class CNN(nn.Module): def __init__(self): super(CNN, self).__init__()在 PyTorch 里定义一个模型标准做法是继承nn.Module这个基类。super(CNN, self).__init__()这行是在调用父类的构造函数。这部分代码的作用是让 PyTorch 知道你这个类是一个正经的模型它会自动帮你注册好里面所有的网络层和参数。如果你漏了这行后面会出现类似CNN object has no attribute conv1这种错误。3.3 定义网络层每一层的参数和输出形状self.conv1 nn.Conv2d(1, 32, kernel_size3) self.conv2 nn.Conv2d(32, 64, kernel_size3) self.fc1 nn.Linear(64 * 12 * 12, 128) self.fc2 nn.Linear(128, 10)这是整个项目里最核心的定义部分我一行一行拆。nn.Conv2d(1, 32, kernel_size3)定义了第一个卷积层。第一个参数1是输入通道数因为我们的图片是灰度图只有 1 个通道。如果是彩色 RGB 图片这里就得写 3。第二个参数32是输出通道数意味着这层卷积会用 32 个不同的卷积核去扫描图片每个卷积核提取一种特征。kernel_size3表示每个卷积核是 3×3 的小窗口。这一层的计算过程你可以理解成这样一个 3×3 的小窗口从图片左上角开始每次滑动 1 个像素默认步长是 1每到一个位置就把窗口里的 9 个像素值跟卷积核的 9 个权重做加权求和加上偏置得到一个输出值。窗口把整张 28×28 的图片扫完就得到一张新的“特征图”。因为有 32 个卷积核所以输出是 32 张特征图。这里要提醒你记住一个公式输出尺寸 (输入尺寸 - 卷积核大小 2×填充) / 步长 1。默认填充是 0、步长是 1所以 28×28 的图经过 3×3 卷积之后变成 26×26。这张特征图从始至终都是 26×26只是“厚度”通道数从 1 变成了 32。nn.Conv2d(32, 64, kernel_size3)的输入是上一层输出的 32 通道特征图输出是 64 通道。注意这里的特征图尺寸经过 relu 和池化之后会变我先卖个关子下一节算给你看。nn.Linear(64 * 12 * 12, 128)是全连接层。第一个参数是输入特征数第二个参数是输出特征数。为什么第一个参数是 64×12×12这需要一步步算卷积池化之后的特征图尺寸第一层卷积28×28 → 26×26卷积核 3步长 1无填充 第一次池化26×26 → 13×13池化核 2步长 2 第二层卷积13×13 → 11×11还是 3×3 卷积 第二次池化11×11 → 5×5等一下这里算出来是 5×5那64 * 12 * 12是哪里来的这是个非常容易踩坑的地方我直接说一下常见的设置差异。很多人喜欢在卷积层里加padding1保持尺寸不变28×28 → 28×28卷积池化后 14×14第二层卷积后还是 14×14再池化变 7×7这样全连接层的输入就是 64×7×7。而我这份代码为了演示基础版本没有加 padding所以按上面的公式算出来经过两次“卷积 池化”之后应该是 64 通道、5×5 大小全连接输入应该是 64×5×5。所以实际使用时这一行的 64×12×12 应该改成 64×5×5。这种维度不一致是新手最常见的第一类报错PyTorch 会提示你形状对不上你照着公式重新算一遍就行。3.4 forward 函数数据在层之间的流动def forward(self, x): x torch.relu(self.conv1(x)) x torch.max_pool2d(x, 2) x torch.relu(self.conv2(x)) x torch.max_pool2d(x, 2) x x.view(x.size(0), -1) x torch.relu(self.fc1(x)) x self.fc2(x) return xforward定义的是前向传播过程也就是数据从输入到输出的流动路径。这个函数的名字是固定的不能随便改。PyTorch 在调用model(images)时会自动调用你写的forward方法。我一步一步跟着数据走一遍。进来的时候x的形状是(64, 1, 28, 28)64 是批次大小1 是通道数28×28 是图片尺寸。第一步torch.relu(self.conv1(x))数据经过第一层卷积输出 64×32×26×26然后经过 ReLU 激活函数尺寸不变此时是(64, 32, 26, 26)。ReLU 做的事情很简单把负的数值全部变成 0正的保持不变。为什么要这样做因为卷积操作本质是线性加权求和如果不加激活函数网络再怎么叠多层本质上跟一层没什么区别只是在做线性变换的叠加而线性变换的叠加还是线性变换表达能力会很有限。ReLU 这样的非线性激活函数就是给网络注入非线性能力的。第二步torch.max_pool2d(x, 2)最大池化核大小为 2。它在每个 2×2 的窗口里取最大值作为输出这一步把特征图的长宽各缩小一半。26×26 变成了 13×13。为什么用最大值而不是平均值因为卷积层提取的特征往往体现在“有没有”上而不是“有多强”取最大值就是在问“这个局部区域有没有这个特征”对位置微小的偏移也不敏感。这种不敏感是好事数字图片里的笔画稍微偏一个像素不应该影响识别结果。此时形状是(64, 32, 13, 13)。第三步torch.relu(self.conv2(x))经过第二层卷积输出(64, 64, 11, 11)再经过 ReLU尺寸不变。第四步torch.max_pool2d(x, 2)再池化一次11×11 变成了 5×5。此时是(64, 64, 5, 5)。第五步x.view(x.size(0), -1)这是把多维特征图压平成二维矩阵的操作。x.size(0)是批次大小 64-1的意思是“根据总数自动推断”。64 张特征图每张是 64×5×5 1600 个数值压平之后变成(64, 1600)。这一步很关键因为全连接层只能接收二维输入——形状是“批次大小 × 特征数”它并不知道自己处理的是图片、是音频还是别的什么结构它只认一长串数值。第六步torch.relu(self.fc1(x))经过第一个全连接层从 1600 维降到 128 维形状变成(64, 128)。再经过 ReLU。第七步self.fc2(x)经过第二个全连接层从 128 维降到 10 维输出(64, 10)。注意最后一层没有接 ReLU原因在讲损失函数的时候细说。最后返回的x就是模型对每个样本的预测分数64 个样本每个样本 10 个类别的得分。3.5 为什么最后一层不加激活函数我把最后一层跟损失函数放一块讲因为它们俩是配合着设计的。nn.CrossEntropyLoss()这个损失函数在 PyTorch 里做了两件事先把模型的输出做一次 softmax把原始分数变成概率分布然后在概率分布上算交叉熵损失。如果我们在最后一层提前加了个softmax那CrossEntropyLoss内部还会再做一次 softmax两次 softmax 就会导致数值不稳定、梯度信号变弱。所以标准做法是模型最后一层输出原始分数logits损失函数内部处理 softmax不要在模型里自己加。我早期犯过这个错误在最后一层加了个 softmax训练 loss 一直下不去查了半天才意识到是重复 softmax 的问题。4. 训练循环模型真正“学习”的地方4.1 损失函数和优化器的选择criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001)criterion是损失函数它用来衡量模型输出的结果和真实标签之间的差距。对于分类问题交叉熵损失是最标准的选择。我还是用大白话解释一下所谓“差距”。模型输出一个 10 维向量假如真实标签是 3那么交叉熵损失会在模型输出的第 3 位数值上做惩罚如果第 3 位的分数很低损失值就很大如果第 3 位分数很高损失值就很小。直观来说就是让模型把正确类别对应的分数尽量抬高觉得错误的类别分数越低越好。optim.Adam(model.parameters(), lr0.001)是创建优化器。model.parameters()自动收集了模型里所有可以训练的权重和偏置。Adam 是一种改进的梯度下降算法它给每个参数自适应的学习率收敛速度快对学习率不那么敏感。lr0.001是学习率也就是每次参数更新的步长。为什么用 Adam 而不是最原始的 SGD因为在这个项目里Adam 更省心。SGD 需要仔细调学习率稍微大一点就容易震荡小一点收敛又很慢。Adam 自带调整机制0.001 基本可以匹配大多数中小型模型。等你经验更丰富了再去玩 SGD 的动量调整也不迟入门阶段先用稳妥的方案把流程跑通。4.2 最核心的循环结构for epoch in range(5): running_loss 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() print(fEpoch {epoch1}, Loss: {running_loss / len(train_loader):.4f})外层for epoch in range(5)表示把整个数据集完整训练 5 遍。一个 epoch 就是完整过一遍全部训练数据。为什么是 5 不是 500MNIST 比较简单5 个 epoch 已经够收敛了。实际训练时你应该盯着 loss 曲线来决定要不要继续而不是拍脑袋定次数。running_loss 0.0在每个 epoch 开始时把累计损失归零。内层循环for images, labels in train_loader每取一次得到一个批次的图片和标签。images的形状是(64, 1, 28, 28)labels的形状是(64)64 个值每个值就是这张图片的真实数字标签。循环体里这五行是整个深度学习最核心的五步一秒钟都不能忘optimizer.zero_grad()把模型所有参数的梯度清零。如果不做这一步PyTorch 默认会自动累加梯度——这样一个批次累积下来的梯度会把前面批次的信息混在一起参数更新的方向就错乱了。每处理一个批次前清零保证前后批次之间互不影响。outputs model(images)前向传播。把这一批图片送进模型得到预测输出。这个调用会自动执行我们前面写的forward函数。loss criterion(outputs, labels)计算损失。拿模型的预测结果和真实标签比较得出损失值。loss.backward()反向传播。这是最魔法的一步。PyTorch 的自动求导机制会从损失值出发沿着计算图的每个环节反向回推自动计算每个参数关于损失的梯度。此时参数本身的数值还没变只是每个参数上挂了一个“要往哪个方向调多少”的梯度值。optimizer.step()优化器拿着刚算好的梯度按照预设的学习率更新所有参数。这一步做完模型的参数才真正发生改变。running_loss loss.item()把这一步的损失值累加到总损失里后面用来打印观察。最后一个print语句输出的是本 epoch 的平均损失。正常情况下随着训练推进损失应该是逐步下降的比如从最初的 0.4 左右降到 0.05 以下。如果损失不降反升那大概率是学习率设太大了。4.3 关于“为什么梯度要清零”再补一刀上面提到zero_grad()是用来防止梯度累积的。有一个细节可以让你理解得更透loss.backward()算出来的梯度是“累加”进参数梯度中的不是“覆盖”。如果你连续两次调用backward()不清零第一次的梯度和第二次的梯度会加在一起。其实在少数场景下梯度累积是有意为之的。比如你的 GPU 显存不够一个批次只能放 16 张图但你想要等效于 batch_size64 的效果就可以每次喂 16 张、累计 4 次梯度再更新一次参数。但这是高级玩法入门阶段老老实实每批清零就好了。5. 测试评估部分验证学到了什么5.1 模型的两种模式model.eval()在训练循环里模型默认处于“训练模式”。而评估之前需要先调用model.eval()切换到“评估模式”。其实这个模型结构相对简单没有用到 Dropout 或 BatchNorm这两种模式的区别不会太明显。但你以后做更复杂的网络时这个调用就非常重要了。model.eval()会关闭 Dropout 的随机失活行为也会让 BatchNorm 使用训练阶段保存的全局统计量而不是当前批次的统计数据。我用一个不那么严格但容易记住的类比训练模式是“边做边改”模型会根据每个批次数据随时调整行为评估模式是“上考场”模型必须使用固定规则并且在没有标准答案的情况下做出预测。有一个很常见的坑是训练完模型直接拿去评估忘了调用model.eval()。在有 Dropout 的网络里这会导致测试结果忽高忽低因为每次预测时随机删掉一些神经元结果当然不稳定。我建议把model.eval()和后面的 no_grad 环节当成固定套路记下来测试评估前必写。5.2 为什么用 no_gradwith torch.no_grad():torch.no_grad()是一个上下文管理器告诉 PyTorch这段代码里所有计算都不要跟踪梯度也不要构建计算图。为什么能关因为评估阶段我们只需要前向传播拿到预测结果不需要反向传播自然也不需要保存中间计算信息。关掉之后有两个好处一是能省大量内存训练时计算图会占用很多显存评估时没有这些负担二是速度会快不少因为省去了自动求导的额外计算开销。我实测过在同一批测试数据上开启和关闭 no_grad速度能差 1.5 到 2 倍内存占用差距更大。这在大模型上非常可观。请养成评估时写no_grad的习惯。5.3 计算准确率的代码逐行看correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: outputs model(images) _, predicted torch.max(outputs.data, 1) total labels.size(0) correct (predicted labels).sum().item()correct 0和total 0是初始化计数器一个记为对的样本数一个记总样本数。for images, labels in test_loader跟训练时的循环结构一样一批一批取测试数据。outputs model(images)前向传播拿模型对这批测试图片的预测结果形状是(64, 10)。torch.max(outputs.data, 1)这一行新手很容易卡住。torch.max有两个版本如果你传两个张量它做元素级比较如果你传一个张量并指定维度参数它返回这个维度上的最大值和最大值所在位置的索引。这里用的是第二个版本。outputs.data是一个(64, 10)的张量第二个参数1表示在第 1 维也就是 10 个类别那一维上找最大值。返回的两个值分别是最大分数值和最大分数所在的索引。比如某张图片在 10 个类别的分数里索引 7 的分数最高predicted里的对应值就是 7。前面用下划线_接收最大分数值用predicted接收索引。predicted的形状是(64)正好对应每张图预测的数字。total labels.size(0)labels.size(0)就是批次大小 64累加得到已经测试的总样本数。(predicted labels)做了逐元素比较相同位置相等则为 True不等则为 False。.sum()把 True 的数量加起来True 被当成 1False 被当成 0得到本批次预测正确的数量。.item()把只有一个数值的张量转成普通的 Python 数字。最后print(fTest Accuracy: {100 * correct / total:.2f}%)输出最终准确率。MNIST 上跑完这 5 个 epoch准确率一般在 98% 以上好的情况能到 99% 以上。5.4 拿几张图实际验证一下训练完只输出一个准确率数字感觉不够直观。我一般会再加一小段代码随机挑几张测试图片把模型预测的结果标在图片下面打包成一张图看一下。dataiter iter(test_loader) images, labels next(dataiter) model.eval() with torch.no_grad(): outputs model(images) _, predicted torch.max(outputs, 1) fig, axes plt.subplots(2, 4, figsize(8, 4)) for i in range(8): ax axes[i // 4][i % 4] ax.imshow(images[i].squeeze(), cmapgray) ax.set_title(fTrue: {labels[i].item()}, Pred: {predicted[i].item()}) ax.axis(off) plt.tight_layout() plt.show()这段代码很好理解。从中取一批图片让模型预测然后把前 8 张画出来。这里有一个细节值得注意images[i]的形状是(1, 28, 28)因为它还是带通道维度的张量画图前要用.squeeze()把多余的通道维度去掉变成(28, 28)matplotlib才能正常显示为灰度图。如果你去看结果图会发现模型的预测绝大多数跟真实标签是一致的。如果有个别预测错的也可以看看是哪个数字被误认成了哪个数字——比如手写的 4 被认成了 9人眼看着确实也挺像的这时候你就知道模型不是“乱猜”而是确实学到了一些特征只是某个样本长得太模糊了。这里的.squeeze()也是一个值得记住的细节很多时候你发现图片画出来是一团黑或者报错说维度不对多半就是多了一个1的维度。6. 踩坑实录与关键经验6.1 维度不对的排查套路“size mismatch”这类报错霸占了新手报错排行榜第一位。遇到这种报错不要慌我给你的排查顺序是这样的先看报错前一步的数据形状再看模型定义里对应的层会输出什么形状比对一下就能定位。PyTorch 的报错其实很友好它会把期望形状和实际形状都打出来。比如RuntimeError: mat1 and mat2 shapes cannot be multiplied (64x1600 and 1600x128)这里面信息量很足前面的形状是你数据当前的实际形状后面的形状是你模型里nn.Linear定义的形状。如果 1600 是你全连接层的输入那说明数据到这里没问题如果对不上说明前面卷积池化的输出尺寸跟你定义全连接层时用的尺寸不一致。解决这个问题最简单的办法就是在forward里临时加一行print(x.shape)在关键步骤之间打印形状跑一次看输出马上就知道数据在哪一步变形成了什么样。调通了再删掉。6.2 归一化的均值标准差不能随便抄很多人做迁移学习看到别人代码里Normalize((0.5,), (0.5,))就直接照抄。这两个数对大多数灰度图来说勉强能用但如果你在 MNIST 上用了(0.5, 0.5)而不是(0.1307, 0.3081)训练效果会有轻微下降——模型需要额外花力气去适应一个不太合适的初始化分布。不同数据集有自己的均值标准差。你用 ImageNet 预训练权重做迁移学习就得用 ImageNet 的归一化参数因为预训练模型就是在那些参数下训练的。用 CIFAR-10 就查 CIFAR-10 的。这个细节看似小实际上对模型收敛速度有实实在在的影响。6.3 关于“明明代码一样为什么我的准确率低了”跟你分享一个我早期踩过的坑model.train()和model.eval()没说清楚会造成测试指标差异。假如一个项目里既有 Dropout 又有 BatchNorm你评估前忘了切模式那么每一轮测试结果都可能不同。而且如果你是在模型已经训练到 90% 以上准确率时才发现这个问题前期的验证数字基本都不可信了。养成习惯训练循环开始前写model.train()评估循环开始前写model.eval()一次也不要省。6.4 学习率设得太大会怎样经常有朋友问我“我的 loss 变成 nan 了怎么办”。绝大多数情况下要么是学习率太大要么是数据里有异常值。拿我们这份代码为例你把lr从 0.001 改成 0.1大概率跑不了几步 loss 就飞了甚至变成 nan。原因是学习率决定了参数更新的步长。0.1 这个步长对我们这个模型来说太大了参数一步跨过头直接跳到损失函数极其陡峭的区域梯度变得非常大再下一步直接爆炸。当你遇到 loss 异常时第一件事就是把学习率调小两个数量级试试。这是一个成本最低、效果最快的排查手段。7. 结束前的最后一点心里话整个项目走下来你会发现深度学习项目其实没有你想象中那么玄。它不像传统编程那样“给一个输入按规则计算就得到输出”而是“给一堆带答案的样本让模型自己摸索出一套规则”。我当初最大的感悟就是不要急着去追那些花哨的新模型新架构把这份手写数字识别的代码吃透每一行都弄明白为什么这么写比什么都重要。因为无论你以后是搞目标检测、语音识别、还是推荐系统整个框架都是这四步准备数据、搭模型、训练、评估。区别只在数据长什么样、模型结构多复杂、训练技巧多花哨而已。如果你能把这份代码完全吃透并且在没有任何参考的情况下自己默写出来那深度学习入门的第一道坎你已经迈过去了。后面遇到任何项目不管是 YOLO 还是 Transformer你都能在源码里快速定位数据管道在哪、模型定义在哪、训练循环在哪、评估代码在哪——然后用你现在已经掌握的这套框架逻辑把它们一个个拆开、段段看懂。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →