从零搭建简单卷积网络:CNN核心概念与PyTorch实战
卷积基础知识三简单卷积网络CNN、卷积核、特征图、池化……这些词对学深度学习的人来说几乎天天见但真正让人卡住的往往不是某个名词而是“这些东西拼在一起到底怎么跑起来”。这篇是这个系列的基础篇之三前两篇我们把卷积的数学原理想清楚了今天换个姿势直接搭一个简单卷积网络用代码把每个概念焊死在真实工程里。目标很明确让读者看完能自己动手复现一个能跑通、能训练、能出准确率的模型同时把卷积层的尺寸计算、参数量估算、池化和激活函数的作用全部对号入座。这篇文章适合两种人一种是刚学完卷积原理、正处于“懂了但不会用”阶段的初学者另一种是已经在用全连接网络做分类、想看看卷积到底比全连接好在哪的新手。我会按我实际带队踩坑的经验来写尽量不绕弯子。你不需要提前掌握多深的数学会基本的求导就能看懂代码部分我会给出完整的可直接运行版本并解释每一行在做什么。1. 为什么先动手搭一个简单卷积网络1.1 从全连接到卷积一次思路上的硬转折很多初学者一开始接触的是全连接网络。输入一张图片比如 28x28 的灰度图代码里先把它展平成一维的 784 个像素点然后喂给一个个 Dense 层。这样做有个很要命的问题图片的二维空间结构——相邻像素之间的关系、物体边缘的方向、局部纹理的排列——全部被折腾成线性顺序之后信息就乱了。你等于把一张完整的照片剪成一串碎纸条再让网络去猜原图是猫还是狗难度翻了好几倍。卷积层解决的就是这个问题。它不是把图片展平而是保留二维排布用一个很小的窗口比如 3x3在图片上从左到右、从上到下地滑动每滑到一个位置就对窗口里的像素做一次加权求和。这个窗口就是卷积核滑动的间距叫步长。你可以把它理解成拿一个放大镜扫过整幅图放大镜看到的每一个局部区域都浓缩成一个数值最后得到一张“特征图”。这个转折对新手来说真的需要一点时间适应。我见过不少人在这个点上卡住全连接网络里每一个输入节点都连着下一层的所有节点好理解但卷积层里一个小滑块扫来扫去到底学到了什么答案藏在权值共享里——同一个卷积核扫遍全图意味着同一种局部模式比如横边、竖边、某个角点不管出现在图的哪个位置都会被同一个参数检测出来。这在图像里特别合理因为猫的耳朵可能出现在左上角也可能出现在右下角但检测耳朵边缘的那个“滤波器”不需要换成两套。1.2 简单CNN的三大核心机制要真正理解简单卷积网络绕不开三个机制局部连接、权值共享、层次化特征提取。局部连接说的是每个输出神经元只看输入的一小块区域而不是全部像素这既符合视觉系统的特性又让计算量大幅下降。权值共享就是前面说的一个卷积核的参数被整张特征图共用这是卷积网络参数远少于全连接网络的根本原因。层次化特征提取则是堆叠多个卷积层带来的效果浅层卷积核学会识别边缘、颜色、纹理这类低级特征深层卷积核把这些低级特征组合成眼睛、轮子、门窗这类更抽象的部件再往上就能认出整个物体。这三个机制不是分开独立的而是连环配合。举个生活化的例子你要在一群人中找戴红帽子的那位。全连接的做法是把每个人的整身照片都从头到脚看一遍再对比卷积的做法是先学一个“红色检测器”在整张照片上滑动扫描扫到红色区域就点亮再把所有亮起来的红色区域组合起来判断是不是帽子。后者对位置不敏感也省计算这就是卷积的天生优势。所以做一个简单卷积网络重点不是先背一堆专有名词而是先把这三个机制在脑海里转成画面一个滑块在图上滑动每滑一次算一个数得到一张新的小图多放几个滑块就得到一组小图再往下堆卷积和池化小图越来越小、通道越来越多、语义越来越抽象。后面所有代码和参数都是在为这个画面填空。2. 卷积层特征图尺寸与参数怎么算2.1 输出尺寸公式与亲手算一遍卷积层最基础的一个问题输入一张 HxW 的图经过一个卷积核之后输出尺寸变成多少公式是固定的我建议直接记下来H_out (H_in 2*P - K) / S 1其中 H_in 是输入高K 是卷积核大小假设方形P 是填充padding的像素数S 是步长。宽度方向同理把 H 换成 W 就行。这个公式看起来简单但很多人第一次写模型报维度错误就是在这里栽的。我们亲手算一遍。假设输入是 28x28 的灰度图用一个 3x3 卷积核步长 S1不填充 P0代入公式(28 0 - 3) / 1 1 26输出就是 26x26。如果加上填充 P1则 (28 2 - 3) / 1 1 28尺寸不变。这就是“same padding”的效果——输出尺寸和输入一样。为什么这个公式值得亲手算因为在搭网络的时候最后一个卷积层的特征图尺寸直接决定了后面全连接层的输入维度。如果你在第4层卷积之后发现尺寸算错了、Flatten 之后维度对不上整个模型都起不来。我的习惯是每一层卷积后面都在注释里写上输出尺寸比如“# 28x28x1 - 26x26x16”调试起来一目了然。2.2 卷积核、步长、填充的选型逻辑新手面对的第一个选择就是卷积核到底用多大步长用几填充加不加。这三个参数其实是互相耦合的我分开说。卷积核大小最常用的就是 3x3。原因有两个一是奇数卷积核有中心像素方便对齐做 padding 的时候上下左右填充量相等不需要写一堆边界判断二是两层 3x3 卷积堆叠感受野等价于一层 5x5 卷积但参数只有后者的 18/25非线性表达能力还更强。能小则小不是越大越好。当然1x1 卷积也经常用来做通道升降维但它基本不提取空间特征属于另一个话题。步长决定了卷积核每次滑多远。S1 是最常用的因为输出尺寸变化小空间信息保留充分S2 可以把特征图尺寸直接减半起到类似降采样的作用但信息丢失比池化更激进。我个人的经验是简单卷积网络里先用 S1配合池化层做降采样避免过早压缩空间信息。填充的选择更直接P0valid padding会让特征图越变越小P1 配合 3x3 核可以保持尺寸不变。如果你不想每个卷积层都去手算输出尺寸直接用 P1、S1 的 3x3 卷积组合再在固定间隔插入池化网络维度就非常好预测。2.3 参数量到底少在哪很多人听说“卷积网络参数比全连接少”但不清楚少到什么程度我算给你看。一个卷积层的参数量公式是Params (K_h * K_w * C_in 1) * C_outC_in 是输入通道数C_out 是输出通道数加的那个 1 是偏置项。比如输入是 3 通道彩色图用 16 个 3x3 卷积核参数就是 (333 1) * 16 448 个。相比如同一个全连接层假设输入是 32x32x33072 个像素输出是 16 个节点全连接的参数是 3072*16 16 49168 个相差一百多倍。这个差距在较大的图片上只会更悬殊。关键原因还是权值共享。全连接里每一个输出节点都拥有一套独立的输入权重而卷积里 16 个输出通道对应的就是 16 个共享卷积核每个核在图上滑动产生的所有输出位置都共用同一套权重。这就好比全连接是给每个岗位单独雇一个看门人卷积是雇一个巡逻队用同样的标准检查所有房间。参数少了带来的直接好处是更不容易过拟合、训练更快、对小数据集更友好。这也是为什么简单卷积网络在几万张图片的数据集上就能训出可用的结果而同样规模的全连接网络早早就开始死记硬背。3. 激活函数与池化层3.1 卷积之后为什么必须接激活函数卷积本质上是一种线性运算——乘加、乘加、再乘加。如果你只堆卷积层不做别的处理哪怕堆一百层数学上等价于一层线性变换网络根本没有能力拟合非线性关系。所以每一层卷积后面几乎都要接一个非线性激活函数给网络注入“弯折”的能力。现在最常用的激活函数就是 ReLU公式是 f(x) max(0, x)。它的好处有三个计算极快导数恒为 1 或者 0不会像 sigmoid 那样在两端导数趋近于 0 导致梯度消失对正区间无饱和收敛速度快负区间直接置零带来稀疏性让特征图更聚焦。对比 sigmoid它在小数据上虽然也能用但深层网络里很容易出现梯度衰减训练半天不收敛。实际使用的时候有个小坑负输入全部变成 0有时候会导致某些神经元“死掉”也就是永远输出 0梯度传不过去。这个问题的专业名词叫“死 ReLU”实操中可以通过合理的学习率设置、偏置初始化或者改用 Leaky ReLU 来缓解。简单卷积网络这个层面我的建议是直接用 ReLU如果发现训练过程中大量神经元的输出恒为 0再换成 Leaky ReLU斜率设成 0.01 就够。3.2 池化层不是可有可无池化层的作用是降采样也就是把特征图变小。最常见的是最大池化和平均池化。最大池化在窗口里取最大值保留的是最强烈的响应平均池化取平均值保留整体信息。以 2x2 池化、步长 2 为例特征图的宽高都减半通道数不变。为什么要池化第一计算量成倍下降后面层的运算负担轻很多。第二平移不变性目标在图片里偏移几个像素池化后的特征基本还是那个样子网络更鲁棒。第三有效缓解过拟合因为特征图的维度变小了需要学习的参数总量也随之减少。不过我不建议把池化层当成必需品到处乱插。现代网络里也有用步长为 2 的卷积来替代池化的这种设计能保留更多空间细节但同时引入了更多参数。对于“简单卷积网络”这个场景老老实实用 2x2 最大池化是最稳的选择我来回试过多次小数据集上最大池化几乎总是比平均池化效果好因为分类任务更在乎“这个特征有没有出现”而不是“这个特征的平均强度是多少”。4. 从零搭建一个简单卷积网络实操4.1 网络结构设计与理由接下来我们从零搭一个能用的简单卷积网络用在手写数字识别这种经典任务上。输入图片统一缩放为 28x28 灰度图所以输入形状是 (1, 28, 28)。网络结构我设计成四段式的经典套路卷积-激活-池化再卷积-激活-池化然后展平接全连接最后输出分类。具体结构如下输入层1x28x28第一个卷积层16 个 3x3 卷积核填充 1输出 16x28x28ReLU 激活第一个最大池化层2x2步长 2输出 16x14x14第二个卷积层32 个 3x3 卷积核填充 1输出 32x14x14ReLU 激活第二个最大池化层2x2步长 2输出 32x7x7Flatten 展平32x7x7 1568 维向量全连接层128 个神经元 ReLU输出层10 个节点对应 10 个类别这个结构为什么值得抄因为它把卷积层通道数从 16 扩到 32空间尺寸从 28 降到 7完美演示了“空间变小、通道变深”的通用设计哲学。全连接层只留了一层隐藏层避免参数量膨胀非常适合小数据集。如果你要改成彩色图片只需把第一层输入通道从 1 改成 3其他基本不用动。4.2 完整代码与逐行说明我用 PyTorch 来写完整实现代码保留必要的训练循环可以直接跑通。先说结论这个代码在小数据集上训练 10 个 epoch准确率能到 98% 以上足够验证结构正确性。import torch import torch.nn as nn import torch.optim as optim # 定义简单卷积网络 class SimpleCNN(nn.Module): def __init__(self, num_classes10): super(SimpleCNN, self).__init__() # 第一个卷积块1 - 16 通道3x3 卷积填充 1尺寸不变 self.conv1 nn.Conv2d(in_channels1, out_channels16, kernel_size3, stride1, padding1) # 第一个池化2x2步长 2尺寸减半 self.pool1 nn.MaxPool2d(kernel_size2, stride2) # 第二个卷积块16 - 32 通道 self.conv2 nn.Conv2d(in_channels16, out_channels32, kernel_size3, stride1, padding1) # 第二个池化 self.pool2 nn.MaxPool2d(kernel_size2, stride2) # 激活函数 self.relu nn.ReLU() # 展平后接全连接32*7*7 - 128 - num_classes self.fc1 nn.Linear(32 * 7 * 7, 128) self.fc2 nn.Linear(128, num_classes) def forward(self, x): x self.pool1(self.relu(self.conv1(x))) x self.pool2(self.relu(self.conv2(x))) # 展平 x x.view(x.size(0), -1) x self.relu(self.fc1(x)) x self.fc2(x) return x # 初始化模型 model SimpleCNN(num_classes10) print(model)代码不算长但每一行都有讲究。卷积层的 padding1 保证特征图尺寸不变所以两次池化后28 - 14 - 7正好得到 32x7x7 的特征图。这里我要特别强调self.fc1 nn.Linear(32 * 7 * 7, 128)里的 7 就是你用第一节公式手算出来的尺寸不是猜的。如果你把 padding 去掉写成 padding0输出尺寸就会变成 26 - 13 - 6这里的 3277 就要改成 3266差一点点整个网络就跑不起来。训练部分也一并给出损失函数用交叉熵优化器用 Adam学习率 0.001批量大小 64跑 10 个 epochimport torchvision.datasets as datasets import torchvision.transforms as transforms from torch.utils.data import DataLoader # 数据加载与预处理 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_data datasets.MNIST(root./data, trainTrue, transformtransform, downloadTrue) train_loader DataLoader(train_data, batch_size64, shuffleTrue) # 损失函数、优化器、学习率 criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) # 训练循环 for epoch in range(10): 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() avg_loss running_loss / len(train_loader) print(fEpoch [{epoch 1}/10], Loss: {avg_loss:.4f})关于代码我想额外提一点MNIST 数据里的像素范围是 0 到 255ToTensor 会把它缩放到 [0, 1]加一层 Normalize 是为了把均值变成 0、范围变成 [-1, 1]。这一步很多人容易忽略但其实对收敛速度影响很大。如果发现训练 loss 下降非常慢先去检查数据是不是真的一 一归一化了。4.3 训练配置与调参训练简单卷积网络有四个超参数最常需要调学习率、批量大小、epoch 数量、优化器选择。学习率我建议一开始就用 0.001这是 Adam 优化器的默认推荐值对小网络非常稳。如果 loss 震荡得厉害降到 0.0003如果 loss 下降太慢提高到 0.003 但要注意观察是否发散。批量大小在 32 和 64 之间选数据量小的话 32 更好梯度噪声大一点反而能跳出局部最优显存不够就 16但训练会慢一些。epoch 不是越多越好我会结合验证集 loss 做早停验证 loss 连续三到五个 epoch 不再下降就停止训练。优化器选 Adam 还是 SGD 一直有争论。我的经验是简单卷积网络用 Adam 上手最快几乎不需要怎么调参但如果你想追求极致表现SGD 加动量momentum0.9配合合适的学习率调度最终结果常常略胜一筹。新手先用 Adam跑通之后再换 SGD 做对比体会一下两种优化器的差异是值得花时间做的实验。5. 训练常见问题与排查技巧实录5.1 维度不匹配与特征图尺寸错误我在带新手实验时报错数量最多的就是维度不匹配。典型场景是卷积层加了 padding0自己没意识到Flatten 之后维度跟全连接层的输入对不上报错显示尺寸不匹配。排查方法很简单设计网络时每层后面标注理论输出尺寸然后用一行代码打印实际输出的 shape 来验证。PyTorch 里可以用打印形状的方式快速检查不要等到训练报错再回头看。此外在定义全连接层输入维度的时候宁可先用一个临时变量把特征图跑一遍看 shape也别凭感觉写数字。毕竟手动计算容易漏掉池化后的尺寸变化代码先验证一遍最稳。如果尺寸一直对不上可以做一个通用技巧在展平之前插入一个自适应池化层比如nn.AdaptiveAvgPool2d((1, 1))把任意尺寸的特征图都压成 1x1这样全连接层的输入就永远固定了。这招在很多现成的网络里很常见能让结构适应不同尺寸的输入图片。5.2 训练不收敛或过拟合的排查路径训练不收敛通常是三件事数据没有归一化、学习率太大、或者网络结构本身对任务来说太浅。手写数字识别这种简单任务用我们的结构通常不会不收敛如果你遇到 loss 不降先去看数据预处理再去降学习率这两步能解决大部分问题。过拟合的表现是训练 loss 持续下降验证准确率却上不去甚至下降。处理手段按优先级排第一加数据增强旋转、平移、缩放这些都能有效扩大数据分布第二加 Dropout通常加在全连接层之前比率设 0.5第三减少模型容量比如把卷积通道数从 32 降到 16全连接层从 128 降到 64。别一上来就用大招先把最简单有效的手段加上。这里有个从工程里挖出来的经验观察训练曲线比盯着准确率数字有用得多。准确率是一个比较粗糙的指标它不变的时候loss 可能还在稳步下降说明模型仍在学习只是还没越过分类边界反之准确率短暂上升而 loss 反弹往往是过拟合前兆。养成把每个 epoch 的 loss 记录下来的习惯画一条曲线远比看最终准确率更能指导调参方向。5.3 特征图可视化看懂网络到底在学什么最后分享一个我特别推荐的操作把第一层卷积的特征图可视化出来。做法很简单训练完成后随便取一张输入图片把它过到第一个卷积层把 16 个输出通道以灰度图形式打印出来。你会惊讶地发现有些通道在检测数字的横边有些在检测竖边有些对左上角的弧线特别敏感。这就是卷积网络“层次化特征提取”最直观的证明看完之后对卷积的理解会上一个台阶相信我这个体验比读十篇文章都有效。具体实现可以用 PyTorch 的 hook 机制或者直接把model.conv1(image).detach()拿出来用 matplotlib 的 subplot 排成 4x4 显示。这个过程完全不别动模型结构只是读取中间结果非常安全。如果你在动手时把这一步做出来不仅验证了自己的模型确实在学习有效特征在向别人讲解 CNN 原理时这些图也比任何文字都有说服力。6. 简单CNN还可以怎么扩展搭建并训练出一个能用的简单卷积网络之后不要急着收工。我建议按下面三条路继续延伸每一条都不难但能把基础打得更牢。第一条路是替换激活函数和池化方式。把 ReLU 换成 Leaky ReLU、把最大池化换成平均池化或者步长为 2 的卷积观察准确率和收敛速度有什么变化。亲手做一两次对比实验你对每个组件的作用就会从“听说过”变成“亲测过”。第二条路是加深网络。在现有结构里再加一个卷积层比如 16 - 32 - 64 通道或者把卷积核从 3x3 换成 5x5看看参数量和效果怎么权衡。你会亲身体验到“网络越深越需要正则化”这句话的具体含义。第三条路是换一个更有挑战性的数据。彩色图、多类别、带背景干扰的图片会催着你学会数据增强、批次归一化这些更进阶的手段。简单卷积网络是起点不是终点把它跑熟之后再去接触 ResNet、MobileNet 这些现代结构会顺畅得多。我个人在实际操作中的体会是学卷积网络最大的门槛不是数学也不在代码而在于你有没有亲手把一个模型从零训到能用。这个过程中出现的每一个报错、每一次 loss 不降都是在帮你补课。所以如果你现在手边有环境建议别光看文章把上面的代码敲一遍改几个参数看看会发生什么。跑通之后你对整个系列前两篇里那些理论的感受将完全不同。