拓冰建站拓冰建站
首页 / 资讯中心 / 正文

PyTorch卷积神经网络实战:从环境搭建到训练首个CNN模型

深度学习入门这块PyTorch 和卷积神经网络CNN基本是绕不开的两座山。网上教程多到看花眼但要么只讲原理不写代码要么给一段能跑的代码却讲不清里面每一步在干嘛。这篇东西我不想写成文档就按我带新手跑实验的节奏来先搞定环境再把张量、卷积、池化这些概念用大白话讲透最后撸一个能跑的CNN训练代码顺便把最常见的报错和处理办法整理出来。不管你是刚装完 Anaconda 准备踩坑的小白还是已经跑过几个模型但总觉得理解不透的初学者这篇应该都能给你一点实在的东西。1. 环境搭建与工具选型1.1 用 Anaconda 管理环境省掉 80% 的麻烦我见过太多人直接在系统 Python 里pip install torch装完没过两周就被依赖冲突折磨到重装系统。做深度学习虚拟环境是第一步也是最划算的一步。Anaconda 不是必需品但用它来创建和管理虚拟环境确实省心尤其是你以后要在不同项目里用不同版本的 PyTorch 时一个环境一套依赖互不干扰。具体操作不复杂。装好 Anaconda 后打开终端Windows 上用 Anaconda Prompt执行conda create -n torch_env python3.9 conda activate torch_envPython 版本不用追最新3.9 或者 3.10 足够稳。跑 PyTorch 项目3.8 到 3.10 这几个版本是经过大量验证的区间。太新的 Python 版本往往要等 PyTorch 官方跟上没必要给自己找不痛快。1.2 CPU 版还是 GPU 版怎么选这个问题几乎每个新手都会问。我的建议很直接只要你的电脑有 NVIDIA 独立显卡显存不低于 4G就直接装 CUDA 版。CPU 版虽然能跑通代码但训练一个稍微像样的 CNN 都要等到天荒地老学习的耐心很容易被消磨掉。GPU 版安装命令在 PyTorch 官网有对应的生成器选好你的操作系统、包管理工具、CUDA 版本它会给你一条现成命令。这里只提醒几个坑驱动版本要新一点老驱动可能不支持新版 CUDA。装完后一定要验证是否真的调用了 GPU这一步能省下之后无数排查时间。验证代码很简单import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else CPU mode)如果第一行能打印出版本号第二行是True基本就是成了。如果第二行是False先别急着卸载重装去查一查显卡驱动版本和 CUDA 的对应关系大多数问题出在这。1.3 写代码用 VSCode 还是 PyCharm这两个我都用过说实话差别没有传说中那么大。VSCode 轻量、启动快配合 Jupyter 插件可以在 .ipynb 文件里逐格跑代码非常适合新手理解每一步的输出。PyCharm 的调试功能更完整项目大了以后看变量、断点、步进都更舒服。我的建议是刚开始用 VSCode Jupyter 插件跑通了整个流程之后再根据项目复杂度决定要不要换 PyCharm。另外一个很实用的配置是 Jupyter Lab它比 notebook 界面更好用多个文件切换方便变量区一目了然。CNN 学习阶段尤其是做那种改一个参数马上观察输出变化的尝试Jupyter 的交互性比脚本式文件友好太多。提示环境搭建阶段最容易死磕的点是网上教程跟我系统不一样。别硬套教程官网文档永远是最新的。装了哪个版本要看那个版本的官方文档。2. 搞懂张量PyTorch 才算真正上手2.1 张量就是带加速buff的多维数组PyTorch 的核心数据结构是 Tensor你可以暂时把它理解成 NumPy 数组的加强版它能在 GPU 上运算能自动记录计算梯度这是深度学习训练的基础。用过 NumPy 的人迁移过来成本很低基本的数据操作长得很像但有几个细节完全不同。第一次写 PyTorch 代码的人最常翻车的点之一是dtype。比如你用torch.zeros(5, 3)默认生成的是torch.float32但如果你从别的地方转了个torch.int64的张量过来网络前向传播可能直接报错。图像数据进模型前务必确认是浮点型数值范围在 [0, 1] 或 [-1, 1] 之间不是 [0, 255]。另一个关键概念是requires_grad。凡是需要梯度反传的参数都要把它设为True。PyTorch 里面nn.Parameter默认就是True而普通张量不是。新手最常犯的错是手动把输入数据也设成了requires_gradTrue结果 loss 反传时梯度图越来越大内存就被吃满了。2.2 CNN 里的数据长什么样N C H W卷积神经网络处理图像数据在框架内部不是一张张纸片而是一个四维张量。四个维度分别是 N、C、H、W。N 是 batch size即一次喂给网络几张图C 是通道数彩色图是三通道 RGB灰度图是一通道H 和 W 是图像的高和宽。不要小看这四个字母的顺序。PyTorch 的默认顺序就是 NCHW这一点跟 TensorFlow 老版本不一样很多从 TF 转过来的人在这里栽过跟头。你可以这样记PyTorch 里图像张量的形状是[批次, 通道, 高, 宽]比如[4, 3, 32, 32]表示 4 张 32×32 的彩色图而[4, 1, 28, 28]是 4 张 28×28 的灰度图。这种排列方式对卷积运算特别友好。卷积核在一个小窗口内同时处理 H 和 W 两个维度上的像素通道维度则整体参与运算N 是各样本独立运算。理解不了 NCHW后面所有网络结构的维度推导都是空中楼阁。2.3 常用的张量操作先记这几个tensor.shape或tensor.size()查看形状。tensor.reshape(...)改变形状但要确保元素总数不变。tensor.permute(...)交换维度顺序比如把[B, H, W, C]转成[B, C, H, W]。tensor.unsqueeze(dim)在指定位置增加一个维度比如把[28, 28]变成[1, 28, 28]。tensor.squeeze(dim)去掉长度为 1 的维度。我见过最大数量的困惑来自permute和reshape的混淆。如果是图像数据在 HWC 和 CHW 之间变必须用permute因为它只是重新排列轴的顺序不会乱动像素的对应关系。而reshape会把数据按内存顺序重新压扁再展开像素排布会变图像撕裂就是这么做出来的。3. 卷积层CNN 的核心也是最值得玩透的部分3.1 卷积在干什么模板匹配器卷积千万别想复杂了。拿一个 3×3 的卷积核在图像上滑来滑去每次覆盖一个相同大小的小区域做逐元素相乘再求和得到一个新值。这个新值描述的是这个小区域跟我的卷积核有多像。所以训练 CNN本质是在训练一堆卷积核的参数。每个卷积核就是一个特征模板有的负责检测横线有的负责检测竖线深层的卷积核负责把浅层特征组合成更抽象的模式。这也是为什么卷积核也叫滤波器它就像在图像上做特征过滤。局部连接和参数共享这两个特性是 CNN 减少参数量的关键。局部连接指的是每个输出只跟输入的局部区域有关而不是像全连接那样看全局。参数共享指的是同一个卷积核在整张图的不同位置用的是同一套权重这让网络的参数量大幅下降。比如一张 1000×1000 的图像全连接光一层就要 10^12 个参数而一个 3×3 卷积核只需要 9 个参数加 1 个偏置。3.2 为什么大家都爱用 3×3 卷积核你翻经典的 CNN 网络结构会发现 3×3 卷积核的出镜率高得惊人。原因很简单两个 3×3 堆叠的感受野跟一个 5×5 是相同的但参数量更少。3×3 是 9 个参数两个是 18 个5×5 是 25 个。在同样感受野的情况下堆叠小卷积核不仅省参数还多了中间层带来的非线性变换表达能力更强。新手设计网络时我建议就直接遵循一个经验公式分辨率减半通道数翻倍。图像尺寸每缩小一倍卷积核数量增加一倍这样能保持计算量相对稳定也是 VGG 系列验证过的有效模式。不用一上来就自己发明结构先照着成熟结构抄一遍理解了再改。3.3 Padding 和 Stride两个改变输出尺寸的旋钮Padding填充是在输入图像周围补一圈像素通常补 0。它解决的核心问题是卷积会让尺寸变小和边缘像素参与计算的次数太少。如果输入是 5×5卷积核是 3×3步长为 1不加 padding输出是 3×3。加了一圈 padding 之后输入变成 7×7输出恢复成 5×5。这种不改变尺寸的卷积用得很广叫 same 卷积。Stride步长是卷积核每次滑动的距离。步长为 1 意味着逐像素移动输出尺寸几乎不变步长为 2 则输出尺寸缩小约一半这是一种常见的下采样手段。步长和 padding 配合决定了特征图的大小走向。这里我建议把公式抄下来背熟后面调网络结构离不开它输出尺寸 输入尺寸 2 × padding - 卷积核尺寸 / 步长 1这个公式的运算结果如果不是整数说明你的参数组合有问题通常需要对 padding 做调整。3.4 拿一个具体例子把公式走一遍假设输入是 32×32 的单通道图像卷积核 5×5padding 为 0步长为 1输出尺寸是32 - 5/ 1 1 28即 28×28。如果加 padding2输出变成32 4 - 5/ 1 1 32尺寸不变。如果步长改成 2加 padding0输出是32 - 5/ 2 1 14.5不是整数这组参数在标准实现里会报错或者被压成 14你得调整 padding 或核大小。LeNet-5 的结构里就有这个经典例子第一层卷积用 5×5 核输入 32×32输出 28×28靠的就是深度学习的祖传参数组合。建议你每设计一层网络先在纸上算一遍输出尺寸再跑到代码里验证对照上了才算真正理解了这一层。4. 池化、激活函数和全连接层4.1 池化不是必需品但下采样很香池化的本质是下采样把特征图的尺寸缩小。最大池化是取窗口内最大值平均池化是取平均值。最典型的做法是 2×2 窗口、步长为 2这样特征图的高和宽各自减半整体面积缩小到四分之一。池化的好处有两层。第一层是减少计算量后面的层处理的数据量变小训练更快。第二层是增强平移不变性物体在图像里稍微移动几个像素池化结果大概率不变这对分类任务很有帮助。最大池化对于纹理、边缘这类特征更敏感平均池化则更适合保留整体背景信息。现在很多新网络用 stride2 的卷积替代池化层做下采样效果也可以但理解池化仍然是理解 CNN 结构演进的必要基础。4.2 激活函数给网络加一点非线性如果没有激活函数CNN 再深也就是一堆线性变换的堆叠乘以权重再加偏置无论多少层都等价于一层线性变换根本学不了复杂模式。激活函数的引入让网络具备了非线性拟合能力。CNN 里最常用的激活函数是 ReLUf(x) max(0, x)。它计算极快而且能有效缓解梯度消失问题。Sigmoid 和 tanh 在浅层网络里还能用但一旦网络层数深了梯度在反向传播时会被层层压缩到接近于零更新不动。ReLU 有个常见问题叫神经元死亡如果某个神经元学到的权重总是让输入落在负区间它的梯度永远是 0之后它就再也不更新了。应对方法包括调小学习率、换用 LeakyReLU 这类带小斜率负区间的族系或者给网络加 Batch Normalization 层。我经验是新手阶段优先保证结构正确遇到训练不收敛再回头折腾激活函数的变体也不迟。4.3 全连接层分类的最后一步卷积和池化负责提取特征全连接层负责把高维特征拍扁后做分类。以图像分类为例最后一层卷积输出的特征图先被展平成一维向量然后经过一层或多层全连接最后经过 softmax 输出每个类别的概率。对新手来说这里最容易出的问题就是维度匹配不上。全连接层的输入维度必须跟展平后的特征图维度完全一致。改过前面任何一层的卷积核数量或者步长后面全连接层的输入维度就全变了。这也是为什么我前面反复强调手动推导输出尺寸——到全连接这一层你会发现之前的每一笔推算都在给你省事。5. 手写一个 CNN 并跑通训练全流程5.1 数据集选择与网络结构设计纸上谈兵太久容易飘直接上手写代码。数据集我推荐 CIFAR-1010 个类别3 通道 32×32 图像规模适中CPU 也能跑但慢GPU 几分钟就能见到效果。你也可以先用 MNIST 或 Fashion-MNIST 这种单通道、尺寸更小的数据逻辑完全一样。网络结构我照搬一个简化版 LeNet-5 的变体刚好把前面讲的概念全用上import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes10): super().__init__() # 输入: (3, 32, 32) self.features nn.Sequential( nn.Conv2d(3, 16, kernel_size3, padding1), # (16, 32, 32) nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), # (16, 16, 16) nn.Conv2d(16, 32, kernel_size3, padding1), # (32, 16, 16) nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), # (32, 8, 8) nn.Conv2d(32, 64, kernel_size3, padding1), # (64, 8, 8) nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), # (64, 4, 4) ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 4 * 4, 256), nn.ReLU(inplaceTrue), nn.Linear(256, num_classes), ) def forward(self, x): x self.features(x) x self.classifier(x) return x注意每一层我都在注释里标了输出形状这就是前面算尺寸练习的产物。每次改动网络结构第一件事就是把注释里的形状更新一下不然到后面你会彻底忘记每个分支的维度。5.2 数据加载与预处理PyTorch 有现成的数据集接口用torchvision几行就能拿到 CIFAR-10。但有两个预处理细节一定要重视归一化和数据增强。import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms transform_train transforms.Compose([ transforms.RandomHorizontalFlip(), transforms.RandomCrop(32, padding4), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) transform_test transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) train_set datasets.CIFAR10(root./data, trainTrue, downloadTrue, transformtransform_train) test_set datasets.CIFAR10(root./data, trainFalse, downloadTrue, transformtransform_test) train_loader DataLoader(train_set, batch_size64, shuffleTrue, num_workers2) test_loader DataLoader(test_set, batch_size64, shuffleFalse, num_workers2)Normalize里面那三个 mean 和 std 是 CIFAR-10 数据集的官方统计值直接抄即可。归一化不是锦上添花它让每个通道的数值分布保持在零附近网络收敛快得多。很多人训练不收敛第一件事就该检查是不是忘了归一化输入还是 0 到 255 的大数值。数据增强里的随机水平翻转和随机裁剪是给训练集免费增加数据量的手段。它不能用在测试集上因为测试时必须保证输入是确定性的所以上面代码里 train 和 test 用了两套 transform。Batch size 选 64 或者 128 都行。它需要是 2 的幂次因为很多 GPU 底层优化对 2 的幂次的张量形状更友好。Batch size 太小梯度更新方向噪声大训练不稳定太大显存吃紧一个 epoch 内更新次数少也可能收敛变慢。5.3 训练循环每一行代码都要清楚它在干嘛训练代码的结构几乎是模板化的定义模型、定义损失函数、定义优化器、循环迭代数据、前向传播、算 loss、反向传播、更新参数。每一步都有讲究我逐行拆开讲。import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleCNN().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) epochs 20 best_acc 0.0 for epoch in range(epochs): model.train() # 训练模式启用 Dropout/BatchNorm 训练逻辑 running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) epoch_loss running_loss / len(train_set) print(fEpoch {epoch1}/{epochs}, Loss: {epoch_loss:.4f}) # 每个 epoch 结束后验证一下 model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() acc 100.0 * correct / total print(fTest Accuracy: {acc:.2f}%) if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pth)几个关键点第一optimizer.zero_grad()必须在每次反向传播之前清空上一轮累积的梯度。PyTorch 默认是累加梯度的不清空的话这一轮的梯度跟上一轮混在一起loss 曲线会变得诡异。忘了这一句是新手高频错误。第二loss.backward()之后一定要有optimizer.step()前者算梯度后者拿梯度更新权重。少写了就发现模型完全不动loss 永远不降。第三model.train()和model.eval()不能混用。BatchNorm 和 Dropout 在训练和推理时行为不同model.eval()会切换成推理模式。很多人用训练好的模型做预测却忘记切模式结果每次预测结果都不一样或者准确率明显偏低就是这两个模式在捣鬼。第四with torch.no_grad():块里的代码不需要梯度显式声明能省内存和计算。5.4 模型保存与加载的正确打开方式保存模型我推荐只存state_dict也就是参数的字典而不是直接torch.save(model, path)存整个模型对象。前者跨环境兼容性好后者对代码结构极度敏感换个文件目录可能就加载失败。加载模型必须先实例化一个相同结构的模型再把状态字典加载进去model SimpleCNN() model.load_state_dict(torch.load(best_model.pth, map_locationdevice)) model.to(device) model.eval()map_locationdevice很关键。在 GPU 上保存的模型到只有 CPU 的机器上加载时要手动指定map_locationcpu否则会报错找不到 CUDA 设备。如果要继续训练而不是推理加载后记得调用model.train()否则会一直处于 eval 模式。这个坑特别隐蔽因为代码不会报错只是训练准确率怎么都上不去。6. 常见报错与排查技巧实录6.1 维度对不上新手最大噩梦报错信息里通常带Expected input batch_size (64) to match target batch_size (32)或者mat1 and mat2 shapes cannot be multiplied。前者多为标签和输入不同步后者是矩阵乘法维度不匹配出现在全连接层附近。排查这类问题我自己的笨办法是在 forward 函数里每一层后面打印x.shape。跑一个 batch 的数据看哪个环节的维度跟你预期的对不上问题就定位了。加打印不丢人Debug 完再删就行比对着网络结构图干瞪眼快得多。6.2 显存不足CUDA out of memory原因无非三种batch size 太大、输入图像尺寸太大、网络结构过深。最简单的临时方案是把 batch size 减半。如果减到 16 还是爆检查输入图像的尺寸是不是没做处理。很多公开数据集原始图好几千像素宽直接往里塞显存必然炸。如果是在训练中途才爆显存排查有没有把requires_gradTrue的中间变量长期保留。这种情况每次迭代都会生成新的计算图显存逐步增长最终爆掉。6.3 Loss 不降或者降到 NaNLoss 不动先确认学习率是不是太小或者模型压根没在更新检查optimizer.step()有没有被写进循环。Loss 变成 NaN绝大多数是学习率太大或者输入数据里有 NaN。移位一下学习率从 0.001 降到 0.0001通常能救回来。过拟合的话训练准确率高但测试准确率低这是另一个经典话题。应对手段按有效程度排序加数据增强、加 Dropout、加正则项、换更小的网络。新手别一上来就想着上大模型先确认小网络能不能拟合你的数据这是排查过拟合的第一步。6.4 一张速查表把踩过的坑都记下来现象常见原因处理办法维度报错卷积或全连接层输出尺寸计算错误逐层打印 shape对照公式检查 padding/stride显存不足batch size 过大/输入尺寸过大减半 batch size限制输入尺寸Loss 一直是同一个值忘记 zero_grad/step数据未归一化补全训练循环检查输入数据分布Loss 变成 NaN学习率过大降低学习率到 0.0001 或更小训练好但测试差过拟合加数据增强、Dropout简化网络加载模型报错模型结构不一致或设备不匹配用 state_dict 加载指定 map_location预测结果每次不同忘切 eval 模式推理前调用 model.eval()显存持续增长计算图未释放使用 no_grad 推理减少不必要的保留这张表是我帮人排查问题时最常遇到的八种情况。你会发现大部分问题不是难而是忘了某个细节。深度学习调试没有玄学大多数坑都有确定性的原因按日志和排查思路一步步走基本都能解决。写到这里整条路就通了从装好环境到理解张量和卷积再到亲手写一个 CNN 并且训练出效果你已经有了一套能独立上手的基础工具箱。我自己带人入门时最有感触的一点是很多人第一次跑通模型时欣喜若狂但过两天问他每个参数是什么意思又有点说不上来。所以我的建议是跑通代码之后别急着往下一个模型冲回头把 batch size、学习率、卷积核个数各改一遍观察这些改动怎么影响 loss 收敛和测试准确率。这种故意改参数踩坑的过程比多跑十个模板代码更能帮你建立直觉。等你能在一张图上解释清楚我改了哪个参数导致哪一层输出变化最后准确率为什么涨了CNN 这关才算真正过了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门