1. 从一张图说起:为什么图像任务绕不开CNN
很多人第一次接触深度学习,都是从一张手写数字或者一只猫的图片开始的。你把手里的图片喂给一个普通的全连接网络,它也能跑,甚至小数据集上效果还行。但一旦图片分辨率上去了,参数量就像脱缰的野马,训练慢、显存炸、还特别容易过拟合。这个问题困扰了早期做图像研究的人很久,直到卷积神经网络(CNN)这套思路被真正用起来,局面才彻底改变。
CNN卷积神经网络,说白了就是一种专门为处理网格状数据(图像、音频频谱、时序信号都算)设计的网络结构。它的核心卖点有两个:一是局部连接,二是权值共享。这两个词听起来抽象,但你只要理解了它们想解决什么,后面所有细节就都能顺下来。这篇文章我打算从头讲清楚CNN到底在干什么,配上一套能直接跑的手写数字实操,再把参数计算、感受野、常见坑都捋一遍。不管你是刚入门的学生,还是从传统机器学习转过来的工程师,看完应该都能自己搭一个小网络出来。
我个人的习惯是,学任何结构之前先问一句:它为什么长这样?CNN的答案就藏在图像本身的特性里。一张图里,相邻像素高度相关,远处的像素基本没关系;一只猫挪到图片左上角还是右下角,它还是猫。这两个朴素的观察,恰好对应了CNN的局部连接和权值共享——前者减少了无效连接,后者让网络对位置不那么敏感。理解了这层动机,再去啃卷积核、通道、步长这些名词,就不会觉得是在背天书了。
这篇文章适合三类人:正在学CNN但被各种术语绕晕的新手、想复习底层原理准备面试的同学、以及需要给团队做技术分享但缺一份通俗讲义的工程师。我会尽量用生活化的例子代替公式堆砌,但关键的数学和代码一个不落,尤其是卷积输出尺寸、参数量这些面试和工作里真会用到的东西。
2. CNN的核心部件拆解:每个零件到底在干什么
2.1 卷积层:一块滑动的小窗口如何扫出特征
卷积层是CNN的心脏。你可以把卷积核想象成一个放大镜,它只有巴掌大,比如3x3或者5x5,但它会在整张图上从左到右、从上到下滑动。每滑到一个位置,就把覆盖区域的像素值和卷积核里的权重做一次逐元素相乘再求和,得到一个数。滑完整张图,这些数就拼成了一张新的图,我们叫它特征图(feature map)。
这里的直觉是这样的:卷积核里的权重就是它想找的"模式"。如果某个3x3的卷积核学出来的权重是中间亮、四周暗,那它就特别擅长在图上找到"亮点"出现的位置。训练的过程,其实就是让这些卷积核慢慢学出对分类最有用的模式——有的核负责找边缘,有的找角点,有的找纹理,越往后越抽象。
有几个参数必须搞明白:
- 卷积核大小(kernel size):常见3x3、5x5。3x3是绝对的主流,因为两个3x3堆叠的感受野等于一个5x5,但参数更少、非线性更强。
- 步长(stride):滑动一次走几格。stride=1是逐像素滑,stride=2会把特征图尺寸砍掉大约一半,常用于下采样。
- 填充(padding):在图片边缘补一圈0,目的是让输出尺寸可控。不做padding的话,每卷一层图就小一圈,几层之后边缘信息全丢光了。
- 通道数(channels):一个卷积层通常有多个卷积核,比如64个,那它就输出64张特征图。每个核独立学习一种模式。
注意:卷积核的深度必须和输入的通道数一致。输入是3通道RGB图,那你的3x3卷积核实际形状是3x3x3,算的时候是把三个通道的结果加起来再输出一个值。新手最容易在这里犯迷糊,以为3x3核只管一个通道。
举个具体例子帮你建立手感。假设输入是一张5x5的单通道图,卷积核是3x3,stride=1,padding=0,那么输出就是3x3。计算过程是:把3x3的核盖在左上角,对应位置相乘求和,得到输出左上角那个值;然后右移一格,再算一次;滑完一行下移一格。总共滑了(5-3+1) x (5-3+1) = 3x3次。
2.2 池化层:浓缩信息而不是简单丢弃
池化层常被人误解成"降采样就是丢信息",其实它更像是在做摘要。最常用的是最大池化(max pooling),拿一个2x2的窗口在特征图上滑,每次取窗口里最大的那个数保留下来。这样特征图边长减半,但保留下来的都是每个小区域里最强的响应。
为什么取最大而不是平均?因为对图像来说,一个特征"有没有出现"比"出现得多强"更重要。最大值池化相当于在告诉后面的层:这个2x2的格子里,我关注的这个模式确实存在。平均池化(average pooling)则更平滑,早期LeNet-5用的就是平均池化,现在主流网络更偏爱最大池化。
池化的好处有三个:一是显著减少参数量和计算量;二是扩大感受野;三是提供一定的平移不变性——就算特征在2x2窗口内挪了一格,最大值大概率还是它,输出基本不变。这个性质对分类任务特别友好,因为我们不关心猫在图片的哪个精确坐标。
提示:池化层没有可学习的参数。它就是个固定的下采样规则,所以你会发现网络的参数量基本都集中在卷积层和全连接层。
2.3 激活函数:给网络装上非线性开关
如果只有卷积和池化,整个网络本质上是线性的,无论堆多深,都能被一个线性变换等价替换掉。这对复杂任务来说是灾难。激活函数的作用就是往里面注入非线性,让网络能拟合任意复杂的函数。
ReLU(Rectified Linear Unit)是目前卷积网络里的标配,公式简单到不能再简单:f(x) = max(0, x)。小于0的变0,大于0的原样输出。它的优点是计算极快,而且梯度在正区间恒为1,能有效缓解深层网络的梯度消失问题。相比之下,早期的Sigmoid和Tanh在两端梯度趋近于0,网络一深就训不动了。
ReLU也有缺点,就是"神经元死亡"——如果某个神经元的输入长期为负,它的梯度永远是0,权重再也不更新,这个神经元就废了。为了解决这个,后来出现了Leaky ReLU、ELU、GELU等变体。Leaky ReLU给负区间留了一个很小的斜率(比如0.01),保证梯度不会完全断掉。实际项目中,ReLU通常够用,遇到大量神经元死亡再考虑换Leaky ReLU。
2.4 全连接层与输出层:从特征到最终判定
经过若干轮卷积和池化之后,我们得到的是一个个三维的特征图(宽 x 高 x 通道)。全连接层要做的事,就是把这些特征图压平成一维向量,然后通过一到两层普通神经网络,映射到最终的类别分数上。
比如做一个10类的手写数字识别,最后全连接层输出10个数,分别代表这张图属于0到9的"原始得分"。这些得分通常要经过Softmax函数转成概率,训练时用交叉熵损失来衡量预测和真实标签的差距。
现在很多新式网络(比如ResNet后期版本)会用全局平均池化(GAP)替代全连接层,直接把每个通道的特征图取平均得到一个数,这样参数量几乎为零,还能减少过拟合。这是后话,新手阶段先把全连接层用熟没问题。
3. 关键参数与计算过程:手把手算一遍
3.1 卷积输出尺寸的计算公式与推导
这个公式必须背下来,工作和面试都会用到:
输出尺寸 = (输入尺寸 - 卷积核大小 + 2 * padding) / stride + 1当结果不是整数时,说明你选的参数组合不合法,需要调整padding或stride让它整除。
我们拿几个真实配置验算一下。假设输入是32x32的单通道图:
- 配置一:kernel=5, padding=0, stride=1。输出 = (32-5+0)/1+1 = 28。所以得到28x28。
- 配置二:kernel=3, padding=1, stride=1。输出 = (32-3+2)/1+1 = 32。尺寸不变,这叫"same padding",是保持尺寸最常用的组合。
- 配置三:kernel=3, padding=1, stride=2。输出 = (32-3+2)/2+1 = 16.5,不合法。改成padding=1、kernel=3、stride=2,输入得是偶数才好算。
为什么padding=1配kernel=3能保持尺寸?因为卷积核在边缘覆盖不到的地方补了一圈0,相当于把输入从32扩到了34,34-3+1=32,正好回到原尺寸。这个组合在ResNet等网络里到处都是,记住它。
再补一个多通道的例子。输入是224x224x3,卷积层有64个3x3的核,padding=1,stride=1:
- 输出空间尺寸 = (224-3+2)/1+1 = 224,不变。
- 输出通道数 = 卷积核个数 = 64。
- 所以输出是224x224x64。
3.2 参数量怎么算:CNN为什么比全连接省这么多
卷积层的参数量公式是:
参数量 = 卷积核个数 * (卷积核大小 * 卷积核大小 * 输入通道数 + 1)那个"+1"是每个卷积核对应的偏置项(bias)。
还拿上面那个例子:64个3x3的核,输入3通道。
参数量 = 64 * (3 * 3 * 3 + 1) = 64 * 28 = 1792不到两千个参数。
现在换成全连接层做同样的事:输入224x224x3 = 150528个像素,输出假设也是150528维(极端情况),参数量就是150528 * 150528,大约226亿。就算输出只到1000维,也有1.5亿参数,是卷积方案的8万倍。
这就是CNN能在图像任务上碾压全连接网络的根本原因。权值共享意味着同一个卷积核在整张图上用同一套权重,参数量只跟核大小和通道数有关,跟图片尺寸无关。图片从224涨到448,卷积层参数量一点不变,全连接层直接涨4倍。
实操心得:很多新手设计网络时喜欢一上来就堆全连接层,结果模型动辄几亿参数。我的建议是,卷积层负责提特征,全连接层只在最后收尾,一般不超过两层,而且维度别设太大。
3.3 感受野:网络"看"到的范围是怎么长大的
感受野(receptive field)指的是特征图上一个点,对应原始输入图上的区域大小。这个概念很重要,因为网络能不能识别一个大物体,取决于深层特征的感受野够不够大。
感受野是逐层累积的,计算公式稍微绕一点,但记住思路就行:越深的层,感受野越大。一个粗略的近似是,连续堆n个3x3卷积(stride=1),感受野大约是2n+1。比如堆3层3x3,感受野约7x7;堆5层,约11x11。
精确计算可以用迭代公式:
RF_new = RF_old + (kernel_size - 1) * 前面所有层的stride乘积举个例子,输入图,第一层3x3,stride=1,感受野=3。第二层3x3,stride=1,感受野 = 3 + (3-1)*1 = 5。第三层加一个stride=2的3x3,感受野 = 5 + (3-1)*1 = 7(这一步前面stride乘积还是1)。如果我们想让感受野快速变大,可以在深层用带stride的卷积或者池化,它们会把有效步长放大。
理解了感受野,你就能明白为什么目标检测任务(要定位大物体)需要很深的网络,而简单分类(只看整体类别)浅一点也够用。
4. 一个完整实操:从零训练手写数字识别
4.1 数据准备与预处理
讲了一堆原理,不动手永远学不会。我们拿MNIST手写数字数据集做例子,这是深度学习的"Hello World",几乎零门槛,CPU上几分钟就能跑完。
MNIST包含60000张训练图和10000张测试图,每张是28x28的灰度图,标签是0到9。数据预处理主要做三件事:
import torch from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), # 转成张量,并把像素归一化到[0,1] transforms.Normalize((0.1307,), (0.3081,)) # 减均值除标准差,加速收敛 ]) train_set = datasets.MNIST(root='./data', train=True, download=True, transform=transform) test_set = datasets.MNIST(root='./data', train=False, download=True, transform=transform) train_loader = torch.utils.data.DataLoader(train_set, batch_size=64, shuffle=True) test_loader = torch.utils.data.DataLoader(test_set, batch_size=1000, shuffle=False)那几个均值0.1307和标准差0.3081是MNIST全局统计出来的经验值。归一化的目的是让输入分布集中在0附近、方差为1,这样梯度更新更稳定,收敛更快。新手常忽略这一步,结果训练前期loss震荡得厉害。
注意:归一化用的均值方差必须和训练时一致,测试和上线时不能各算各的。这是数据预处理最容易出大坑的地方。
4.2 网络结构设计与逐层解析
我们先搭一个经典的LeNet-5风格的小网络。LeNet-5是1998年提出的,是CNN的开山之作,结构看着简单,但吃透了受益无穷。
import torch.nn as nn import torch.nn.functional as F class LeNet5(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 6, kernel_size=5, padding=2) # 28x28 -> 28x28 self.conv2 = nn.Conv2d(6, 16, kernel_size=5) # 14x14 -> 10x10 self.fc1 = nn.Linear(16 * 5 * 5, 120) self.fc2 = nn.Linear(120, 84) self.fc3 = nn.Linear(84, 10) def forward(self, x): x = F.max_pool2d(F.relu(self.conv1(x)), 2) # 28x28 -> 14x14 x = F.max_pool2d(F.relu(self.conv2(x)), 2) # 10x10 -> 5x5 x = torch.flatten(x, 1) # 展平成 16*5*5 = 400 x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) x = self.fc3(x) return x逐层过一遍,算清楚尺寸变化:
- 输入:1x28x28(单通道灰度图)。
- conv1:6个5x5核,padding=2。输出尺寸 = (28-5+4)/1+1 = 28,通道变6,得到6x28x28。
- 池化:2x2最大池化,尺寸减半,得到6x14x14。
- conv2:16个5x5核,无padding。输出 = (14-5)/1+1 = 10,通道变16,得到16x10x10。
- 池化:再减半,得到16x5x5。
- 展平:1655 = 400维向量。
- fc1:400 -> 120,加ReLU。
- fc2:120 -> 84,加ReLU。
- fc3:84 -> 10,输出10类得分。
为什么conv1要padding=2?因为28x28的图如果用5x5核不padding,会变成24x24,再池化更小,边缘信息损失快。padding=2正好保持尺寸,让第一层尽量多吃点原始信息。
4.3 训练配置与调参记录
训练循环的模板基本固定:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LeNet5().to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() for epoch in range(10): model.train() for data, target in train_loader: data, target = data.to(device), target.to(device) optimizer.zero_grad() output = model(data) loss = criterion(output, target) loss.backward() optimizer.step() # 每个epoch结束后在测试集上评估 model.eval() correct = 0 with torch.no_grad(): for data, target in test_loader: data, target = data.to(device), target.to(device) pred = model(data).argmax(dim=1) correct += pred.eq(target).sum().item() print(f'Epoch {epoch}: acc = {correct / 10000:.4f}')几个关键选择的理由:
- 优化器选Adam:自适应学习率,对超参不敏感,新手友好。SGD收敛更平滑但需要仔细调lr和momentum,入门阶段先不折腾。
- 学习率1e-3:Adam的经典起点。训练loss不降就往下调到1e-4,震荡就调到5e-4。
- 交叉熵损失:多分类标配,内部已经包含了Softmax,所以网络最后不要手动加Softmax,否则会重复。
这套配置在MNIST上10个epoch能跑到99%以上准确率。如果只有97%左右,多半是归一化漏了或者学习率太大。
实操心得:训练时务必先跑一两个batch看loss有没有在动。如果loss一直不动或者变成nan,八成是学习率太大、输入没归一化、或者标签对错了。这个"先小样本验证"的习惯能帮你省下大量无谓的等待时间。
4.4 参数量与显存占用核对
训练前我很喜欢先算一遍参数量,确认模型大小合理。LeNet-5的参数:
| 层 | 计算方式 | 参数量 |
|---|---|---|
| conv1 | 6*(551+1) | 156 |
| conv2 | 16*(556+1) | 2416 |
| fc1 | 120*(400+1) | 48120 |
| fc2 | 84*(120+1) | 10164 |
| fc3 | 10*(84+1) | 850 |
| 合计 | 61706 |
才6万多个参数,模型体积不到300KB,推理速度飞快。这也解释了为什么LeNet能在早期算力有限的设备上落地。
对比一下,如果我们把conv1换成256个核、conv2换成512个核,参数量和显存会迅速上涨,但MNIST这种简单任务并不需要。模型容量要匹配任务难度,这是设计网络时最重要的原则之一。任务简单就上大网络,只会过拟合。
5. 典型场景与结构变体:CNN不只会认猫
5.1 图像分类、目标检测与语义分割
CNN最基础的应用是图像分类:给一张图,输出一个类别。但现实需求远不止于此。
目标检测要在图上画出每个物体的框,并标出类别。经典的两阶段方法(如Faster R-CNN)先找出候选区域,再对每个区域分类;单阶段方法(如YOLO、SSD)直接一步预测框和类别,速度更快。它们都是在CNN骨干网络提特征的基础上加检测头。
语义分割要做到像素级分类,给每个像素打上类别标签。FCN(全卷积网络)是里程碑,它把全连接层换成卷积层,让网络能输出和输入同尺寸的分割图。后来的U-Net用编码器-解码器加跳跃连接,在医学影像分割上非常流行。
这三种任务的共同点是:底层都在用卷积提特征,区别只在输出头的设计。理解了CNN的骨架,转向这些任务只是换个"尾巴"的事。
5.2 3D卷积与视频、医学影像
普通卷积处理的是二维图像(高x宽),3D卷积则多了一个深度维度,处理的是体数据,比如视频(时间维)或者CT/MRI(空间深度)。
3D卷积核的形状是k x k x k,它在立方体上滑动。参数量比2D卷积大,计算也更贵,但换来的是对时空信息的联合建模。视频动作识别、肺结节检测这类任务,3D CNN是标配。代价也明显,同样尺寸下3D卷积的参数量是2D的k倍,显存很容易吃紧,所以通常配合更激进的下采样。
提示:数据量不大的时候慎用3D CNN。三维数据标注成本高,参数量又大,很容易过拟合。可以考虑先用2D卷积逐帧处理,再在时间维度上做融合。
5.3 从LeNet到现代骨干:结构演进的主线
CNN的演进有一条清晰的主线:越堆越深,同时解决深层带来的退化问题。
- LeNet-5(1998):最早的成功实践,证明卷积+池化能提特征。
- AlexNet(2012):加深到8层,用ReLU和Dropout,在ImageNet上一战成名。
- VGG(2014):全部用3x3小核堆叠,结构规整,证明深度是关键。
- GoogLeNet(2014):提出Inception模块,多尺度并行卷积,兼顾宽度和深度。
- ResNet(2015):引入残差连接,x + F(x),让梯度能直接回传,一举把网络做到上百层。
- 后续的DenseNet、EfficientNet等继续在连接方式和缩放策略上做文章。
对新手来说,不必每个都实现一遍,但ResNet的残差思想必须理解。它解决了"网络越深反而越差"的退化问题,是后面几乎所有深层网络的基础。
6. 常见问题与排查技巧实录
6.1 训练不收敛怎么一步步排查
训练loss不动或者乱跳,按下面顺序查:
- 输入是否归一化:这是最高频的坑。像素值没归一化到合理范围,梯度会爆炸。
- 学习率是否过大:loss变nan先砍lr,从1e-3降到1e-4甚至1e-5试试。
- 标签是否正确:分类任务里标签越界、顺序错乱很常见,确认类别数和标签范围对得上。
- 网络最后有没有多加Softmax:用CrossEntropyLoss时多加一层Softmax会导致梯度异常。
- 参数初始化:默认初始化一般够用,但全零初始化会让所有神经元学一样的东西,绝对不能用。
6.2 过拟合与欠拟合的判断和应对
看训练集和验证集的准确率差距:
| 现象 | 训练准确率 | 验证准确率 | 判断 | 应对 |
|---|---|---|---|---|
| 欠拟合 | 低 | 低 | 模型能力不足 | 加深网络、加训练轮数、调大学习率 |
| 良好 | 高 | 高且接近 | 正常 | 保持 |
| 过拟合 | 很高 | 明显低 | 记住了训练集 | 加数据、加Dropout、加正则、早停 |
过拟合的应对手段里,数据增强性价比最高。对图像做随机裁剪、翻转、旋转、颜色扰动,能让模型看到更多样的样本,泛化能力显著提升。Dropout则是在训练时随机"关掉"一部分神经元,逼网络学出更鲁棒的特征。
实操心得:别一上来就上数据增强。先把模型跑通、确认能过拟合到100%(哪怕在小样本上),再逐步加正则。如果连过拟合都做不到,说明模型或数据有问题,加正则只会掩盖问题。
6.3 常见问题速查表
| 问题 | 可能原因 | 排查动作 |
|---|---|---|
| loss变nan | lr过大、输入未归一化 | 降lr、检查预处理 |
| 准确率卡在10%(10类) | 标签对错、输出层维度错 | 打印标签分布、核对输出维度 |
| 训练快验证慢 | 过拟合 | 加数据增强、加Dropout |
| 显存爆了 | batch太大、模型太大 | 减小batch、加梯度累积 |
| 卷积输出尺寸算不对 | padding/stride组合不合法 | 用公式重算,保证整除 |
| 验证准确率忽高忽低 | batch太小、没shuffle | 增大batch、开启shuffle |
6.4 卷积核里到底学出了什么
很多人好奇训练完的卷积核长什么样。第一层的卷积核通常能看出明显的边缘、条纹、颜色斑块,因为它在处理最原始的像素。越往后的层,特征图越抽象,人眼已经看不懂了。可视化特征图(用matplotlib把中间层输出画出来)是理解网络行为的好方法,也常用来排查模型是不是真的学到了有用的东西。如果所有特征图都长一个样,说明网络退化了,多半是学习率或初始化出了问题。
7. 几个踩过坑才明白的经验
关于"图像处理为啥用CNN不用前馈神经网络",最直白的答案已经摆在参数量的对比里了。我见过太多人拿着全连接网络硬套图片,模型几亿参数,训练半天还跑不过一个6万参数的LeNet。这不是网络不行,是结构跟数据特性不匹配。CNN的局部连接和权值共享,正是为图像的"局部相关"和"平移不变"量身定做的。
再补一个小提醒:batch size、学习率、网络深度这几个超参在初期不用追求最优。先把pipeline跑通,拿到一个能用的baseline,再一个个调。我早期最浪费时间的事,就是模型还没跑通就在纠结用3x3还是5x5、用Adam还是SGD。后来养成"先跑通、再优化"的习惯,效率高了不止一倍。
如果你已经能熟练搭CNN,下一步可以往两个方向走:一是深入现代骨干网络,重点啃ResNet的残差连接和BatchNorm;二是转向具体任务,比如目标检测或分割,把backbone换成预训练权重做迁移学习。迁移学习是你实际工作中最常用的技能,比从零训练划算得多。这些内容后面有机会我再单独展开聊。