最近这两年,看CNN入门教程的人明显变多了。很多人上来就问:“卷积神经网络到底是什么?为什么图像识别非要用CNN?LeNet-5和AlexNet又有什么区别?”这些问题其实并不难,只是很多资料一上来就堆公式、堆术语,把新手吓退了。我尽量用一次能讲清楚的方式,把CNN最核心的几条主线串起来:卷积层、池化层、激活函数、全连接层,再拆两个经典网络LeNet-5和AlexNet,最后给出一套可以在本地跑通的实现与排查思路。文章更适合刚开始接触深度学习和图像识别的读者,也适合那些已经跑过模型、但对网络结构细节仍然模糊的人。
我先把结论放在前面:CNN本质上是“用可学习的卷积核自动提取图像特征,再用下采样压缩信息,最后用全连接层做分类”的流程。只要你理解了卷积、池化、非线性激活这三件事,再看LeNet-5和AlexNet就会顺很多。后面所有代码和参数都只是验证思路,真正关键的是理解每一层为什么要存在。
1. CNN到底在解决什么问题
1.1 图像分类任务的核心难点
图像分类,本质上是一张图片映射到一个类别标签。比如给模型一张猫的图片,输出是“猫”;给一张狗的图片,输出是“狗”。听起来很简单,但图像本身是由成千上万个像素点组成的,每个像素点又有RGB三个通道。如果直接把所有像素值拉平,当成一个很长的向量,会带来两个问题。
第一个问题是参数爆炸。假设输入是 224x224 的彩色图片,那么拉平后就有 224x224x3 = 150528 个输入维度。如果第一层就用全连接层连接到 1024 个神经元,这一层就有 150528x1024 个权重,约 1.5 亿个参数。在普通电脑上,这种参数量根本没法训练,即使能训练,也很容易过拟合。
第二个问题是丢失空间结构。把像素拉平后,相邻像素的关系被破坏了。一张猫的图片,眼睛和鼻子之间的相对位置关系,是判断“这是猫”的重要信息。全连接层把所有像素一视同仁,不关心哪个像素和哪个像素相邻,因此很难学到局部的视觉模式。
CNN出现,就是因为这两个痛点。
1.2 CNN的三个核心思想
CNN不是把图像拉平处理,而是保留图像的二维结构,用三种机制解决问题。
- 局部感受野:每个卷积核只关注输入图像的一小块区域。比如一个 3x3 的卷积核,只看 3x3 范围里的像素。这样既能提取局部特征,又能大幅减少参数量。
- 权值共享:同一个卷积核会在整张图上滑动,卷积核的权重是共享的。也就是说,不管图像多大,一个卷积核只有 9 个权重(针对 3x3)。这进一步减少了参数。
- 下采样:池化层或步长卷积可以降低特征图尺寸,保留主要信息,减少计算量,也让模型对位置变化更鲁棒。
这三个思想,基本覆盖了CNN的核心哲学:用较少的参数,从局部到全局,逐层提取抽象特征。
1.3 CNN与Transformer的关系
很多人会问“为什么最后是Transformer”这类问题。其实CNN和Transformer不是替代关系,而是不同任务下的不同选择。CNN更适合处理强局部相关性的数据,比如图像边缘、纹理;Transformer更适合处理长距离依赖,比如文本、序列、大规模图像块之间的关系。现在很多视觉模型也用Transformer,但普通图像分类任务,CNN依然是一个性价比很高的起点。理解CNN,能帮你后面理解注意力机制时多一个对比对象。
2. 卷积层:自动提取图像特征的核心
2.1 卷积操作到底怎么算
卷积层是CNN最重要的组成部分。它做的事情是:用一个小的矩阵(卷积核)在输入图像上滑动,每次覆盖一个局部区域,把对应位置数值相乘后相加,得到输出特征图上的一个值。
假设输入是一张灰度图,尺寸是 5x5,卷积核是 3x3,步长为 1。那么卷积核从左上角开始,先覆盖第 1 到 3 行、第 1 到 3 列,计算这九个位置与卷积核九个权重的加权和,得到输出的第一个像素。然后向右移动一步,继续计算,直到滑完整张图。
这个过程不是一个黑魔法。它本质上是让网络学习一组滤波器,每个滤波器关注一种特征。比如有的滤波器学到水平边缘,有的学到垂直边缘,有的学到颜色渐变。随着层数加深,浅层卷积核提取边缘、纹理,深层卷积核组合出眼睛、鼻子等更复杂的模式。
2.2 卷积核、步长、填充和通道
理解卷积层,需要掌握四个参数。
卷积核大小(Kernel Size):一般是 3x3、5x5、7x7。3x3最常用,因为两层 3x3 卷积的感受野可以等价于一层 5x5 卷积,但参数量更少,非线性更强。
步长(Stride):卷积核每次滑动的距离。步长为 1,输出尺寸接近输入;步长为 2,输出尺寸大概减半,有下采样效果。
填充(Padding):在输入边缘补一圈或多圈 0,用来控制输出尺寸,同时保留边缘信息。常见的“same”填充就是让输出尺寸和输入尺寸保持一致。
通道数(Channels):输入如果是彩色图,第一层就是 3 个通道。卷积层的输出通道数由该层使用了多少个卷积核决定。每一个卷积核会生成一个通道的特征图。
import torch.nn as nn # 一个标准卷积层示例 conv_layer = nn.Conv2d( in_channels=3, # 输入通道数,RGB图就是3 out_channels=64, # 输出通道数,希望提取64种特征 kernel_size=3, # 卷积核大小 stride=1, # 步长 padding=1 # 填充,保持尺寸 )2.3 输出尺寸计算方法
卷积层输出尺寸有一个固定计算公式。假设输入尺寸为 H_in, padding 为 P,卷积核大小为 K,步长为 S,则输出尺寸 H_out 为:
H_out = (H_in + 2*P - K) / S + 1
如果结果不是整数,说明参数不合适,通常需要调整填充或步长。分类网络里,这个公式不止在卷积层用,池化层同样适用。
举一个例子:输入 32x32,卷积核 5x5,步长 1,填充 0,输出尺寸就是 (32 - 5) / 1 + 1 = 28。也就是 32x32 的图像经过 5x5 卷积后变成 28x28。
这个计算看起来简单,但很多模型报错都出在这里。尤其是自己设计网络时,算错一层尺寸,后面全对不上。我的建议是:先手算一遍每一层输出尺寸,再写代码。
2.4 为什么卷积层能减少参数量
和全连接层相比,卷积层的参数量极小。一个 3x3 卷积,假设输入通道是 64,输出通道是 128,那么权重数量是 64x128x3x3 = 73728,但它处理的输入特征图可能是 56x56x64,对应的全连接层参数会是天文数字。权值共享让卷积层在参数量和表达能力之间取得了很好的平衡。这也是为什么大模型时代,卷积仍然没有消失。
3. 激活函数:让网络不再是线性变换
3.1 为什么必须加激活函数
如果只有卷积和全连接,不管网络叠加多少层,本质上都是线性变换的组合,最终仍然是线性变换。线性模型无法处理图像中的非线性关系,比如“有猫眼睛 AND 有猫耳朵”这种判断,不能简单通过线性加权完成。激活函数引入了非线性,才让深层网络有了拟合复杂函数的能力。
3.2 常见激活函数对比
ReLU(Rectified Linear Unit)是CNN里最常见的激活函数。公式是:f(x) = max(0, x)。x大于0时输出x,小于等于0时输出0。优点:计算简单,收敛快,能缓解梯度消失。缺点:负数部分梯度为0,可能导致“死神经元”,尤其是学习率设太大时。
Leaky ReLU对ReLU做了改进,负数部分给一个很小的斜率,比如 f(x) = max(0.01x, x),避免神经元完全死亡。
Sigmoid函数在CNN的隐藏层中已经很少使用了,因为容易梯度消失,计算量也大。现在更多用于二分类输出层,把输出压缩到0到1之间。
Tanh函数将输出压缩到-1到1,常用于某些循环神经网络或回归任务,但CNN隐藏层常用ReLU。
import torch.nn as nn # CNN中常见的激活层写法 relu = nn.ReLU() leaky_relu = nn.LeakyReLU(negative_slope=0.01)3.3 实践中怎么选
对于CNN分类网络,优先选择ReLU。如果发现某些神经元始终输出0,也就是训练一段时间后梯度完全消失,可以换成LeakyReLU或调整学习率。Sigmoid和Tanh在隐藏层会拖慢收敛速度,不建议在深层CNN里大量使用。更稳妥的方案是:先试ReLU,如果训练不稳定,再调学习率,最后再考虑换激活函数。
4. 池化层:压缩信息,增强鲁棒性
4.1 池化层在做什么
池化层的作用是下采样,把特征图变小。和卷积层不同,池化层通常不需要学习参数,而是用一个固定窗口在特征图上滑动,取窗口内的最大值或平均值。
- 最大池化(Max Pooling):取窗口内最大值。
- 平均池化(Average Pooling):取窗口内平均值。
最常见的池化层是 2x2、步长2 的最大池化,它会把特征图尺寸减半。
import torch.nn as nn # 2x2最大池化 maxpool = nn.MaxPool2d(kernel_size=2, stride=2) # 2x2平均池化 avgpool = nn.AvgPool2d(kernel_size=2, stride=2)4.2 池化的实际作用
池化层有三个作用。
第一,降低计算量。特征图尺寸减半后,后续卷积层计算量会明显减少。
第二,增强平移鲁棒性。如果图像里猫的位置轻微移动,最大池化可能仍然提取到类似的特征,因此模型不太容易因为目标移位而错判。
第三,扩大感受野。经过多次池化后,后面的卷积层可以看到输入图像的更大区域,从而能够提取更全局的特征。
但池化也有代价:会丢失细节。现在很多网络也倾向用步长为2的卷积来替代池化,既能下采样,又能保持更多信息。对于入门来说,先理解池化就好。
4.3 池化层和卷积层怎么搭配
经典结构通常是:卷积层 -> 激活函数 -> 池化层。也就是说,先用卷积提取特征,再用激活函数增加非线性,然后用池化压缩尺寸。这样重复几次,特征图越来越小但通道数越来越多,最后用全连接层或全局平均池化输出分类结果。LeNet-5和AlexNet遵循的都是这个思路。
5. 经典网络结构:LeNet-5和AlexNet
5.1 LeNet-5结构
LeNet-5是1998年提出的经典CNN,最早用于手写数字识别,也就是MNIST数据集。这个网络结构不大,但在CNN发展史上很重要。它的结构可以概括为:
- 输入:32x32 灰度图。
- C1卷积层:6个 5x5 卷积核,输出 28x28x6。
- S2池化层:2x2平均池化(带权重),输出 14x14x6。
- C3卷积层:16个 5x5 卷积核,输出 10x10x16。
- S4池化层:2x2平均池化,输出 5x5x16。
- C5卷积层:120个 5x5 卷积核,输出 120。
- F6全连接层:84维。
- 输出层:10个类别。
这个结构的关键是:卷积层负责提取特征,池化层负责降低尺寸,全连接层负责分类。虽然在今天看来很简单,但在MNIST手写数字识别任务上,它的准确率已经很高。初学者在自己电脑上用CPU就能训练,很适合作为第一个真正跑通的CNN模型。
5.2 AlexNet结构
AlexNet是2012年ImageNet竞赛的冠军网络,直接把深度学习带火。它比LeNet-5更深、更宽,并且引入了ReLU、Dropout、数据增强等技术。对于当时的GPU来说,它用了两块GPU并行训练。现在简化后,我们通常把这个结构记为:
- 输入:227x227x3 彩色图。
- 卷积层:96个 11x11 卷积核,步长4,输出 55x55x96。
- 最大池化:3x3,步长2,输出 27x27x96。
- 卷积层:256个 5x5 卷积核,padding 2,输出 27x27x256。
- 最大池化:3x3,步长2,输出 13x13x256。
- 卷积层:384个 3x3 卷积核,padding 1,输出 13x13x384。
- 卷积层:384个 3x3 卷积核,padding 1,输出 13x13x384。
- 卷积层:256个 3x3 卷积核,padding 1,输出 13x13x256。
- 最大池化:3x3,步长2,输出 6x6x256。
- 全连接层:4096维。
- Dropout。
- 全连接层:4096维。
- 输出层:1000类。
AlexNet的意义在于:证明了更深的网络、更大的数据量、ReLU和Dropout组合在一起,可以大幅提升图像识别准确率。它和LeNet-5的核心差别不是某个神秘技巧,而是规模与训练技巧的组合。
5.3 LeNet-5和AlexNet对比
| 对比维度 | LeNet-5 | AlexNet |
|---|---|---|
| 提出时间 | 1998 | 2012 |
| 输入尺寸 | 32x32 灰度 | 227x227 彩色 |
| 深度 | 较浅 | 较深 |
| 卷积核大小 | 5x5 | 11x11、5x5、3x3 |
| 激活函数 | Sigmoid / Tanh | ReLU |
| 防过拟合手段 | 较少 | Dropout、数据增强 |
| 适用任务 | 手写数字、小图 | ImageNet大图分类 |
| 训练难度 | 低 | 较高 |
如果你现在学习CNN,我的建议是:先用LeNet-5跑通MNIST,理解全流程;再换AlexNet跑猫狗分类或CIFAR-10,体会“加深网络”带来的变化。
6. 动手实现:用PyTorch搭建一个CNN
6.1 环境准备
要动手跑通CNN,准备一个Python环境即可。推荐用Anaconda创建一个干净环境,避免依赖冲突。Windows、macOS、Linux都可以,CPU也能跑小模型,MNIST手写数字识别用CPU完全没问题。
conda create -n cnn-demo python=3.9 conda activate cnn-demo pip install torch torchvision matplotlib这里没有指定具体版本。实际安装时,建议先确认你的Python版本和CUDA版本,再选择对应的PyTorch版本。如果只是学习,用CPU版本也能跑。
6.2 准备数据
MNIST是最经典的入门数据集,包含0到9的手写数字灰度图,每张图28x28。PyTorch的torchvision可以直接下载。
import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_dataset = datasets.MNIST( root='./data', train=True, transform=transform, download=True ) test_dataset = datasets.MNIST( root='./data', train=False, transform=transform, download=True ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)这里把28x28的图像Resize成32x32,是为了直接套用LeNet-5的输入尺寸。实际上LeNet-5原始输入是32x32,所以保持不变更合适。
6.3 定义LeNet-5模型
下面是一个简化版LeNet-5的PyTorch实现。它保留了核心结构,但把S2平均池化换成了常规最大池化,这样更符合现在的主流写法。
import torch.nn as nn class LeNet5(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 6, kernel_size=5, stride=1, padding=0), # 32x32 -> 28x28 nn.ReLU(), nn.MaxPool2d(kernel_size=2, stride=2), # 28x28 -> 14x14 nn.Conv2d(6, 16, kernel_size=5, stride=1, padding=0), # 14x14 -> 10x10 nn.ReLU(), nn.MaxPool2d(kernel_size=2, stride=2), # 10x10 -> 5x5 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(16 * 5 * 5, 120), nn.ReLU(), nn.Linear(120, 84), nn.ReLU(), nn.Linear(84, num_classes) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x这个模型参数量很小,CPU上训练一轮也很快。你也可以先定义一个类似的AlexNet,但CIFAR-10图像尺寸是32x32,直接照搬AlexNet的227x227结构会不匹配。要么Resize成227x227,要么改卷积核和池化参数。对新手来说,先不要纠结,用LeNet-5跑通全流程更重要。
6.4 训练与验证
训练过程分三步:定义损失函数、定义优化器、循环训练。
import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LeNet5(num_classes=10).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss = 0.0 correct = 0 total = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy def evaluate(model, test_loader, device): 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() return correct / total epochs = 5 for epoch in range(epochs): train_loss, train_acc = train_one_epoch( model, train_loader, criterion, optimizer, device ) test_acc = evaluate(model, test_loader, device) print(f"Epoch {epoch+1}: loss={train_loss:.4f}, train_acc={train_acc:.4f}, test_acc={test_acc:.4f}")训练时你会看到损失逐渐下降,准确率逐渐上升。如果一切正常,MNIST上测试准确率可以轻松超过98%。如果使用CPU,大概几分钟就能跑完5轮。
6.5 结果判断标准
看到以下几点,说明网络基本是健康的:
- 训练损失持续下降。
- 训练准确率和测试准确率同步上升。
- 测试准确率和训练准确率差距不是特别大。
- 给定一张测试图片,输出最大的类别确实是数字本身。
如果训练准确率很高但测试准确率明显偏低,说明过拟合了。如果训练损失不下降,需要检查数据归一化、学习率、网络结构等。
7. 常见问题排查与优化思路
7.1 损失不下降
损失不下降是CNN入门最常见的坑。我一般按这个顺序排查:
- 先看数据。确认图片像素值是否归一化到0到1或-1到1。如果原始像素是0到255直接喂给网络,梯度更新很容易不稳定。
- 再看学习率。学习率太大,损失会震荡;学习率太小,收敛极慢。可以先试0.001,再视情况调低或调高。
- 检查标签。分类任务用CrossEntropyLoss时,标签必须从0开始,且不超过类别数。
- 检查网络输出。如果输出层没有接Softmax,CrossEntropyLoss内部会处理,不要手动再套一次Softmax。
- 最后检查模型是否真的前向传播到了输出。有时定义Sequential时漏了一层,不会报错,但参数量明显不对。
7.2 过拟合
当训练准确率很高、测试准确率低时,优先考虑过拟合。常见解决办法:
- 增加数据增强:随机裁剪、水平翻转、旋转等。
- 增加Dropout:在全连接层前加Dropout,通常0.5。
- 使用L2正则化:优化器weight_decay设为5e-4或1e-4。
- 减小模型容量:减少卷积核数量或全连接层神经元数。
- 使用更大的数据集或预训练模型。
LeNet-5在小数据集上很容易过拟合,所以加Dropout往往很有效。AlexNet原本就包含Dropout,这也是它比早期网络更稳的原因之一。
7.3 显存或内存不足
如果你想把LeNet-5换成更大的AlexNet,或者使用GPU训练大尺寸图像,显存不足很常见。解决方向:
- 减小batch_size。这是最直观的方式。
- 降低图像尺寸。比如把224x224改成112x112。
- 减少卷积核数量。比如把第一个卷积层从96改到48。
- 使用混合精度训练。如果显卡支持,可以明显降低显存占用。
- 避免在数据加载时一次性把所有图片加载到内存中。
低配机器也能跑CNN,关键是不要一上来就开最大模型和最大batch。先用一个小尺寸样例验证流程,再逐步增加。
7.4 输入输出尺寸不匹配
自定义网络时,最容易报“size mismatch”错误。原因是前面层的输出尺寸和你后面写的全连接层输入尺寸不一致。排查方法:
- 打印每一层输出尺寸,可以用一个随机输入前向传播一遍。
- 按公式手算卷积和池化后的尺寸。
- 把全连接层的输入写成动态计算,而不是写死。
# 查看每层输出尺寸的调试方法 x = torch.randn(1, 1, 32, 32) def print_feature_size(model, x): for name, layer in model.features.named_children(): x = layer(x) print(name, x.shape) return x这种调试方式最适合新手。看到实际尺寸后,再去改全连接层的输入参数,基本能一次解决。
7.5 如果只是想快速用CNN做图像分类,该从哪里开始
如果现阶段不是想搞研究,只是想快速验证CNN的效果,建议按这个路径:
- 先用LeNet-5跑MNIST,确认整个训练流程正确。
- 再用CIFAR-10跑一遍,体会更大数据集和彩色图的区别。
- 如果想做猫狗分类,优先用预训练的ResNet或AlexNet做迁移学习,不要从零训练一个随机初始化的大模型。
从零训练一个大模型需要大量数据和算力,普通机器很难有好的效果。迁移学习在这种场景下更实用。
8. 从CNN到更深层网络的扩展思路
8.1 卷积层的改进方向
理解了LeNet-5和AlexNet后,你会发现后续很多网络都是在这两条路线上做优化。VGG把卷积核统一成3x3,层数加深到16到19层。ResNet引入残差连接,解决了深层网络退化问题。GoogLeNet引入Inception模块,在同一层用不同尺寸的卷积核提取多尺度特征。这些改进都绕不开你前面学到的卷积、激活、池化和全连接。
学习时不要试图一天啃完所有模型。先把LeNet-5和AlexNet吃透,再看VGG和ResNet,会发现原来只是结构上的组合方式变了。
8.2 为什么现代分类网络常用全局平均池化
AlexNet后面用的是全连接层做分类,优点是分类能力强,缺点是参数量很大。后来的网络,比如ResNet,往往在最后用全局平均池化,把每个通道的特征图平均成一个数,再接全连接层。这样能显著减少参数,还让模型不受输入尺寸限制。
8.3 学习CNN最值得记住的一句话
CNN的每一层,本质上都是在做“特征变换”:卷积层提取局部特征,激活函数增加非线性,池化层压缩信息,全连接层把特征映射到分类空间。你不需要背住所有网络结构的每一个参数,但需要知道每一层为什么要存在,以及它们之间如何配合。
这个理解,会陪你用很久。后面无论接触目标检测、语义分割还是Transformer视觉模型,很多核心思想依然来自CNN时代的这些基础操作。把基础打牢,比追很多“最新”概念更能提升实际动手能力。