1. 从零拆解卷积神经网络:为什么它能成为深度学习的“视觉担当”
组队学习进行到第五章,终于碰上了深度学习里最经典也最让人头疼的一块硬骨头——卷积神经网络。说它经典,是因为几乎每一本深度学习教材、每一门入门课程都绕不开它;说它让人头疼,是因为卷积、池化、通道、步长、填充这些概念堆在一起,初学者很容易被绕晕。我当初第一次学的时候,盯着那个卷积核在特征图上滑来滑去的动图看了半天,脑子里只有一个念头:这到底在算什么?
这一章的核心目标很明确:搞清楚卷积神经网络到底是怎么工作的,为什么它在图像任务上比全连接网络强那么多,以及怎么用PyTorch把LeNet这样的经典模型亲手搭出来跑一遍。适合已经了解神经网络基础、知道反向传播大概是怎么回事、但还没系统接触过CNN的读者。如果你正在跟着《动手深度学习》或者吴恩达的课程推进,这一章的内容基本能帮你把知识点串成一条线。
我个人的习惯是,学CNN不能只背公式,得把“为什么这样设计”想明白。比如为什么卷积层能大幅减少参数量?为什么池化层能提升鲁棒性?这些问题想通了,后面看ResNet、YOLO这些复杂模型才不会觉得是在看天书。下面我就按照自己组队学习时的笔记和实操经验,把这一章的内容重新梳理一遍,尽量把每个关键点都讲透。
2. 卷积神经网络的核心设计逻辑与方案选型
2.1 为什么全连接网络处理图像会“崩掉”
先从一个最直观的问题入手:假设我们要处理一张224×224的RGB图片,把它拉平成一个向量,长度就是224×224×3,算下来是150528维。如果第一层隐藏层只放1000个神经元,光这一层的权重参数就是150528×1000,大约1.5亿个参数。这还只是一层,要是堆个五六层,参数量直接爆炸到几十亿,显存根本扛不住,训练速度也会慢到无法接受。
更关键的是,全连接层把图像的空间结构彻底打碎了。图片里相邻的像素点本来是有强相关性的,比如一只猫的耳朵,相邻像素大概率都是毛发纹理,但拉平之后,这些空间关系就丢了。网络得从零开始学习“哪些像素挨在一起”这种本该免费获得的信息,效率极低。
卷积神经网络的设计初衷就是解决这两个问题:一是通过局部连接和权值共享大幅减少参数量,二是通过卷积操作保留图像的空间结构。你可以把卷积核想象成一个小窗口,在图片上滑动,每次只看一小块区域,这样既抓住了局部特征,又不用为每个像素位置都单独学一套权重。
2.2 卷积操作的本质:一个小窗口的滑动计算
卷积这个词听起来很数学,但实际操作特别直观。假设输入是一张5×5的灰度图,我们用一个3×3的卷积核去卷它。卷积核从左上角开始,覆盖输入图上3×3的区域,对应位置相乘再求和,得到一个数,这就是输出特征图左上角的值。然后卷积核向右滑动一格,重复同样的计算,直到滑完整张图。
这里有几个关键参数需要搞清楚:
- 步长(Stride):卷积核每次滑动几格。步长为1就是逐格滑动,步长为2就是隔一格滑,输出特征图的尺寸会相应缩小。
- 填充(Padding):在输入图边缘补一圈或多圈零,目的是控制输出尺寸,或者让边缘信息被更充分地利用。常见的有valid卷积(不填充)和same卷积(填充后输出尺寸与输入相同)。
- 通道(Channel):输入如果是RGB图片,就有3个通道,卷积核的深度必须和输入通道数一致。每个卷积核卷完所有通道后求和,得到一个输出通道。想要输出多个通道,就用多个卷积核。
输出特征图尺寸的计算公式是:( H_{out} = \frac{H_{in} - K + 2P}{S} + 1 ),其中K是卷积核大小,P是填充量,S是步长。这个公式我建议自己动手推一遍,比死记硬背强得多。
2.3 池化层:降维与特征压缩的平衡术
池化层通常跟在卷积层后面,作用是降低特征图的空间尺寸,减少计算量,同时增强模型对微小位移的鲁棒性。最常见的两种池化方式是最大池化和平均池化。
最大池化就是在一个小窗口里取最大值,比如2×2窗口、步长2,就是把特征图分成不重叠的2×2小块,每块取最大的那个数。平均池化则是取平均值。最大池化更常用,因为它能保留最显著的特征响应,比如边缘、纹理这些关键信息。
平均池化的计算公式很简单:对于窗口内的n个值,输出就是这n个值的算术平均。虽然看起来简单,但在某些任务里,比如需要平滑特征的场景,平均池化反而效果更好。我自己的经验是,分类任务优先用最大池化,图像重建或风格迁移类任务可以试试平均池化。
池化层没有可学习的参数,它就是一个固定的下采样操作。这一点很重要,意味着它不会增加模型复杂度,但能有效控制特征图尺寸的增长。
2.4 LeNet:CNN的“Hello World”
LeNet是Yann LeCun在1998年提出的经典卷积网络,最初用于手写数字识别。它的结构非常简洁:两个卷积层、两个池化层、三个全连接层。输入是32×32的灰度图,经过第一层卷积(6个5×5卷积核)得到6个28×28的特征图,池化后变成6个14×14,再经过第二层卷积(16个5×5卷积核)得到16个10×10的特征图,池化后变成16个5×5,然后拉平送入全连接层,最终输出10个类别。
LeNet的参数量很小,总共大约6万个参数,放在今天简直微不足道,但它的设计思想——卷积、池化、全连接交替堆叠——至今仍是CNN的基本范式。学LeNet不是为了用它解决实际问题,而是为了理解CNN的基本组装方式。我建议每个初学者都亲手把LeNet搭一遍,跑通MNIST数据集,感受一下从输入图片到输出分类的完整流程。
3. 核心细节解析与实操要点
3.1 卷积核参数初始化与学习过程
卷积核的权重不是手工设计的,而是通过反向传播学出来的。初始化时通常用He初始化或Xavier初始化,具体选哪个取决于激活函数。ReLU激活函数配合He初始化效果比较好,因为ReLU会把负半轴置零,He初始化考虑了这一点,让方差保持得更合理。
学习过程中,卷积核会逐渐变成边缘检测器、纹理检测器、颜色斑块检测器等等。浅层卷积核学到的特征比较通用,深层卷积核学到的特征更抽象、更任务相关。这也是迁移学习能奏效的原因:浅层特征在大多数视觉任务里都通用,直接拿预训练模型的浅层参数来用就行。
注意:卷积核的深度必须与输入通道数一致,但卷积核的个数决定了输出通道数。这两个概念初学者很容易搞混,建议在代码里打印一下权重张量的形状,亲眼看一下就明白了。
3.2 感受野的计算与意义
感受野是指输出特征图上一个像素点对应输入图上的区域大小。感受野越大,说明这个像素点“看到”的原始信息越多。卷积层堆得越深,感受野越大,网络就能捕捉到更大范围的语义信息。
计算感受野的公式是递推的:( RF_{l} = RF_{l-1} + (K_l - 1) \times \prod_{i=1}^{l-1} S_i ),其中K是卷积核大小,S是步长。这个公式看起来复杂,但实际算一遍就清楚了。比如两层3×3卷积、步长都是1,第一层感受野是3,第二层感受野是3 + (3-1)×1 = 5。所以两层3×3卷积的感受野等价于一层5×5卷积,但参数量更少,非线性更强。
理解感受野对设计网络结构很有帮助。如果你发现模型对某个尺度的目标检测效果不好,可能就是感受野没覆盖到那个尺度。
3.3 通道数与特征图数量的关系
输入图片如果是RGB三通道,第一层卷积核的深度就是3。假设我们用6个5×5的卷积核,那么权重张量的形状就是6×3×5×5,输出特征图就是6个通道。第二层卷积如果输入是6通道,卷积核深度就得是6,假设用16个卷积核,权重形状就是16×6×5×5,输出就是16通道。
通道数可以理解为网络在不同抽象层次上提取的特征种类数。浅层通道数少,因为原始特征种类有限;深层通道数多,因为抽象特征组合方式更多。但通道数也不是越多越好,太多会导致计算量激增,而且容易过拟合。
3.4 激活函数的选择:ReLU为什么成为默认选项
卷积层后面通常接一个非线性激活函数。Sigmoid和Tanh在早期网络里用得多,但它们有个致命问题:当输入很大或很小时,梯度接近零,反向传播时梯度会消失,深层网络根本训不动。ReLU(Rectified Linear Unit)把负半轴直接置零,正半轴线性输出,梯度要么是0要么是1,有效缓解了梯度消失问题。
ReLU的计算也特别简单,就是max(0, x),没有指数运算,速度很快。不过ReLU也有缺点,负半轴梯度为零,可能导致某些神经元“死亡”,永远不被激活。后来出现了Leaky ReLU、ELU等变体,给负半轴一个小斜率,避免神经元死亡。实际用的时候,ReLU在大多数情况下够用,如果发现训练过程中损失不下降,可以试试换成Leaky ReLU。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
我用的环境是Ubuntu 20.04,Python 3.8,PyTorch 1.10,CUDA 11.3。如果你没有GPU,用CPU也能跑LeNet,只是速度慢一些。安装PyTorch的命令根据你的CUDA版本会有所不同,建议直接去PyTorch官网查对应的安装命令。
pip install torch torchvision pip install matplotlib numpy数据集用MNIST,torchvision里自带下载功能,不用手动去下。第一次运行会自动下载到本地,大概几十兆,很快。
4.2 LeNet模型定义与代码实现
下面是我自己写的LeNet实现,加了详细的注释,方便对照理解每一层的输入输出尺寸变化。
import torch import torch.nn as nn import torch.nn.functional as F class LeNet(nn.Module): def __init__(self): super(LeNet, self).__init__() # 第一层卷积:输入1通道,输出6通道,卷积核5x5 self.conv1 = nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5) # 第二层卷积:输入6通道,输出16通道,卷积核5x5 self.conv2 = nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5) # 全连接层:输入16*5*5=400,输出120 self.fc1 = nn.Linear(16 * 5 * 5, 120) # 全连接层:输入120,输出84 self.fc2 = nn.Linear(120, 84) # 输出层:输入84,输出10(对应10个数字类别) self.fc3 = nn.Linear(84, 10) def forward(self, x): # 第一层:卷积 -> ReLU -> 最大池化 x = F.max_pool2d(F.relu(self.conv1(x)), 2) # 第二层:卷积 -> ReLU -> 最大池化 x = F.max_pool2d(F.relu(self.conv2(x)), 2) # 拉平 x = x.view(-1, 16 * 5 * 5) # 全连接层 + ReLU x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) # 输出层不加激活函数,因为后面用CrossEntropyLoss x = self.fc3(x) return x输入是32×32的灰度图,经过conv1(5×5卷积核,无填充)变成28×28,池化后14×14;经过conv2变成10×10,池化后5×5;然后拉平成400维,送入全连接层。这个尺寸变化建议自己手算一遍,确保每一步都对得上。
4.3 训练循环与参数设置
训练循环的代码比较标准,关键是几个超参数的设置。我用的batch size是64,学习率0.001,优化器选Adam,损失函数用CrossEntropyLoss。训练10个epoch,在MNIST上准确率能到98%以上。
import torch.optim as optim from torchvision import datasets, transforms # 数据预处理 transform = transforms.Compose([ transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) # 加载数据集 train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform) test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform) train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=1000, shuffle=False) # 初始化模型、优化器、损失函数 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LeNet().to(device) optimizer = optim.Adam(model.parameters(), lr=0.001) criterion = nn.CrossEntropyLoss() # 训练循环 for epoch in range(10): model.train() for batch_idx, (data, target) in enumerate(train_loader): data, target = data.to(device), target.to(device) optimizer.zero_grad() output = model(data) loss = criterion(output, target) loss.backward() optimizer.step() print(f'Epoch {epoch+1}, Loss: {loss.item():.4f}')Normalize里的均值和方差是MNIST数据集的统计值,直接用就行。如果你换别的数据集,需要重新算一下。
4.4 模型评估与结果分析
训练完之后在测试集上跑一遍,看看准确率。我实测下来,10个epoch后测试准确率能到98.5%左右。如果达不到,可能是学习率太大或者batch size太小,可以调一调。
model.eval() correct = 0 total = 0 with torch.no_grad(): for data, target in test_loader: data, target = data.to(device), target.to(device) output = model(data) _, predicted = torch.max(output.data, 1) total += target.size(0) correct += (predicted == target).sum().item() print(f'Test Accuracy: {100 * correct / total:.2f}%')评估的时候记得加torch.no_grad(),不然会占用大量显存。另外model.eval()会把Dropout和BatchNorm切到推理模式,虽然LeNet里没用到这些层,但养成习惯是好的。
5. 常见问题与排查技巧实录
5.1 尺寸不匹配报错怎么排查
最常见的报错就是全连接层输入维度对不上。比如你把输入图片改成28×28,但全连接层还是按16×5×5=400来写,就会报错。解决办法很简单:在forward里打印一下拉平之前的张量形状,看看实际是多少,然后改全连接层的输入维度。
x = F.max_pool2d(F.relu(self.conv2(x)), 2) print(x.shape) # 打印一下看看 x = x.view(-1, 16 * 5 * 5)我自己的习惯是,每次改网络结构都先跑一个假数据,确认尺寸对得上再开始训练。
5.2 损失不下降的几种可能原因
损失不下降通常有几个原因:学习率太大导致震荡,学习率太小导致收敛慢,数据预处理有问题,或者模型结构有bug。我的排查顺序是:先把学习率调小一个数量级试试,如果还不行,检查数据归一化有没有做,最后检查模型forward有没有写错。
还有一种情况是标签和输出对不上,比如CrossEntropyLoss要求输出是未经过Softmax的logits,标签是类别索引。如果你在输出层加了Softmax,又用CrossEntropyLoss,就会导致损失计算错误。
5.3 过拟合与欠拟合的判断与处理
训练集准确率很高但测试集准确率低,就是过拟合。LeNet参数量小,在MNIST上一般不会过拟合,但如果你换成更大的数据集或更复杂的模型,就可能出现过拟合。处理方法包括加Dropout层、加L2正则化、数据增强、早停等。
欠拟合则是训练集和测试集准确率都低,说明模型容量不够或者训练不充分。可以增加卷积核数量、加深网络、延长训练时间。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 尺寸不匹配报错 | 全连接层输入维度算错 | 打印拉平前的张量形状 |
| 损失不下降 | 学习率不当或数据未归一化 | 调小学习率,检查Normalize |
| 训练准确率高但测试低 | 过拟合 | 加Dropout或L2正则化 |
| 训练速度慢 | 没用GPU或batch size太小 | 检查device,增大batch size |
| 梯度爆炸 | 学习率太大或初始化不当 | 降低学习率,换He初始化 |
提示:调试CNN的时候,先用小数据集(比如MNIST的前1000张)跑通流程,确认没问题再上全量数据。这样能快速定位是代码问题还是数据问题。
6. 从LeNet到现代CNN的扩展思路
LeNet跑通之后,可以试着往几个方向扩展。一是加深网络,比如把卷积层堆到五六层,看看效果有没有提升。二是换数据集,比如CIFAR-10,输入变成3通道32×32的彩色图,需要调整第一层卷积的输入通道数。三是尝试不同的卷积变体,比如深度可分离卷积、空洞卷积、转置卷积,理解它们各自适用的场景。
深度可分离卷积把标准卷积拆成深度卷积和逐点卷积两步,参数量和计算量都大幅减少,MobileNet就是基于这个思想设计的。空洞卷积通过在卷积核里插入空洞,在不增加参数量的情况下扩大感受野,常用于语义分割任务。转置卷积则是上采样操作,用于生成任务或分割任务中恢复空间分辨率。
我自己的体会是,学CNN不能只停留在LeNet,但也不能一上来就啃ResNet。LeNet是理解基本组件的最佳起点,把这些组件吃透了,后面看更复杂的网络就是组件的重新组合和优化。组队学习的好处是有人一起讨论,遇到卡住的地方可以互相排查,比自己一个人闷头看效率高很多。
最后分享一个小技巧:每次学完一个新网络,试着用代码把它搭出来,不用训练,就打印每一层的输出形状,确认自己真的理解了数据在网络里的流动过程。这个习惯帮我避免了很多“以为自己懂了其实没懂”的情况。