从全连接到卷积:CNN核心原理与图像分类实战解析
2026/9/8 14:24:36 网站建设 项目流程

1. 从全连接到卷积:神经网络模型的核心思路

1.1 神经网络模型到底在做什么

这篇是系列笔记的第2篇,偏重把神经网络模型这件事说透。第1篇里我把机器学习的整体思路盘了一遍:数据进、特征出、模型学、预测用,本质上是一个从样本中归纳规律的函数拟合过程。到了神经网络模型这里,事情变得更有意思——它不再靠人手工设计特征,而是让网络自己在数据里找模式。你给它几万张猫图狗图,它自己学会看耳朵、看眼睛、看纹理,最后告诉你“这张更可能是猫”。

神经网络模型这个词听起来很高深,拆开看其实很朴素。它模拟的是神经元之间传递信号的过程:每个神经元接收输入,乘上权重,加个偏置,再经过一个非线性激活函数,然后把结果传给下一层。无数个这样的节点堆叠起来,就构成了一个可以逼近任意复杂函数的模型。负责连接相邻两层的是“全连接层”,意思是前一层的每个节点都和下一层的每个节点相连。这种结构做小规模任务没问题,一旦碰到图像这种高维数据,问题立刻暴露。

我自己第一次用全连接网络跑图像分类时,印象太深了。输入一张28乘28像素的灰度图,展开后是784个像素值,第一层隐层设128个节点,这层的参数量就是784乘以128,接近十万个。如果换成彩色图片,比如32乘32乘3,再叠加几层全连接,参数量直接奔着几十万上百万去。模型倒是能跑,但收敛慢、容易过拟合,而且在CPU上要等很久。更关键的问题是,全连接层把每个像素当成独立特征,完全忽略了像素之间的空间关系——图像里相邻的像素往往高度相关,这种结构信息是图像识别里最有价值的线索,全连接网络却白白浪费了。

1.2 卷积神经网络模型解决了什么问题

卷积神经网络模型(Convolutional Neural Network,CNN)就是冲着上面两个痛点来的。它做两件核心的事:局部连接和权值共享。局部连接的意思是,每个神经元只看输入的一小块区域,不需要看全图。权值共享的意思是,同一组权重(也就是卷积核)会在整张图上滑动复用,不同位置共用同一个模板。

打个比方你就明白了。全连接网络像是你在一个班里,每个同学都要和全班所有同学都打过招呼才作数,累且低效;卷积神经网络则像是你手里拿了一个放大镜,在照片上按顺序扫过每一块区域,看到兔子耳朵就标记“疑似耳朵”,看到圆眼睛就标记“疑似眼睛”。放大镜是同一个,扫的位置不同,但判断标准一致。这样参数量骤降,还能自动提取局部特征。

用数字说话:一张32乘32的彩色图,如果第一层用6个大小为5乘5的卷积核,每个卷积核的参数量是5乘5乘3,加1个偏置,也就是76个参数,6个卷积核总共才456个参数。同样的输入,如果用全连接层连到100个神经元,光这一层就是30多万个参数。差了三个数量级。参数少,模型就更容易训练,也更容易泛化,这是卷积神经网络模型能横扫图像领域最根本的原因。

2. 卷积神经网络模型的三大核心组成

2.1 卷积层:特征提取的“扫描仪”

卷积层是整个CNN的地基,干的事情用一句话概括:用一个小的权重矩阵(卷积核)在输入上滑动,对每个位置做内积运算,得到一个响应值,响应值越大,说明这个位置的局部模式和卷积核越匹配。所有位置滑完,就生成一张特征图(feature map)。

这里有几个必设参数,每个都直接影响特征图的尺寸和提取效果。

第一个是卷积核大小(kernel size)。最常用的是3乘3,为什么不用更大的?两个3乘3卷积核堆叠,感受野相当于一个5乘5卷积核,但参数量只有后者的18/25,而且中间多了一次非线性变换,表达能力反而更强。所以现在的网络结构里,大卷积核基本被小卷积核堆叠取代了。

第二个是步长(stride),也就是卷积核每次滑动几个像素。步长越大,特征图越小,计算量越小,但可能会漏掉细节。通常第一层用步长1或2,再往后可以逐渐增大。

第三个是填充(padding)。padding有“valid”(不填充)和“same”(填充后保持尺寸不变)两种。最常用的是“same”,因为多次卷积后特征图如果缩得太快,深层网络的尺寸很快就会变成负数。补一圈0,尺寸就保住了。补0不会引入额外信息,反而能让边缘像素也能被卷积核覆盖到,不至于被边缘化。

卷积层的输出尺寸公式是:输出尺寸 = (输入尺寸 - 卷积核大小 + 2*padding) / stride + 1。我之前搭网络时经常要用这个公式手算尺寸,后来发现干脆用padding="same"加stride=1的组合,让每层卷积都不改变特征图的宽高,只在通道数上做变化,省心很多。

卷积完之后通常会接一个激活函数,最常用的是ReLU(Rectified Linear Unit),公式是max(0, x)。ReLU的好处有三点:计算快,只需要做个阈值判断;能缓解梯度消失,正数区间的梯度恒为1,不会越传越小;能制造稀疏性,让一部分神经元输出为0,减少过拟合风险。早期用的sigmoid和tanh在深层网络里容易让梯度消失,训练根本跑不动,所以除非输出层做概率映射,隐藏层还是老老实实用ReLU系列。

2.2 池化层:压缩信息的“下采样”

池化层的任务不是提取特征,而是压缩。它会在一个小窗口内取最大值或平均值,作为这个窗口的“代表”。常用的是窗口2乘2、步长2的最大池化(max pooling),效果是特征图的宽高各缩一半,总的像素量缩小为原来的四分之一。

为什么需要压缩?第一,降低计算量,后面的层处理起来更快。第二,扩大感受野,池化之后每个输出点对应的原始输入区域变大了,网络能看到更全局的信息。第三,也是最重要的一点,能提供一定的平移不变性。图像里物体稍微偏移几个像素,经过池化之后特征基本不变,模型鲁棒性就上来了。

最大池化和平均池化怎么选?主流做法是最大池化占优,因为它保留的是最强烈的响应,相当于把“这个位置有没有某种特征”这个信号放大,噪声和弱响应会被过滤掉。平均池化则会把弱信号也平均进来,信息会被稀释。不过在全局平均池化(Global Average Pooling)这个场景下,平均池化倒是很有用,它把整个特征图压成一个值,直接作为分类器的输入,能大幅减少全连接层的参数量,现在很多经典网络都用这种方式收尾。

池化层没有需要学习的参数,所以不会增加模型体积,但它对整体性能的影响很大。我遇到过一次把池化窗口从2乘2改成3乘3(步长2)后精度下降的情况,原因是重叠区域引入了噪声。所以除非有特殊需要,老老实实用2乘2无重叠池化最稳妥。

2.3 全连接层与输出层:把特征变成结论

卷积和池化层干了半天,输出是一堆特征图,它们还得有人“翻译”成最终的分类结果。这个翻译官就是全连接层和输出层。

全连接层做的事情和我在第一节里说的传统神经网络一样,只不过输入不再是原始像素,而是经过卷积池化提炼后的高级特征。实际操作中,会先把最后一个特征图“拍平”(flatten),变成一个一维向量,然后接一层或几层全连接,最后接一个输出层。

输出层的设计取决于任务类型。如果是二分类,用sigmoid,输出一个0到1之间的概率。如果是多分类,用softmax,输出一组和为1的概率分布,每个位置代表一个类别。softmax的好处是能让概率之间的差距更明显,比如原始输出是[2.0, 1.0, 0.1],经过softmax后变成[0.65, 0.24, 0.11],类别间的区分更清晰。

损失函数用交叉熵(cross-entropy loss)。为什么不用均方误差(MSE)?因为分类问题本质是概率分布的比较,交叉熵衡量的是两个分布的差异,梯度更平稳,收敛更快。MSE在配合softmax时梯度容易饱和,训练速度明显慢。在我自己的项目里,从MSE换成交叉熵后,同样的网络结构,收敛epoch数大约少了三分之一,这个差距是很直观的。

3. 亲手搭一个CNN模型:从数据到训练的完整实操

3.1 数据准备:没有好数据,模型就是空中楼阁

实操环节我用PyTorch来演示,因为它的动态图和自动求导机制对初学者特别友好,而且生态完善,很多经典模型都能直接调用预训练权重。我先用一个大家都很熟悉的数据集:CIFAR-10。共6万张32乘32的彩色图片,10个类别,每个类别6000张,训练集50000张,测试集10000张。尺寸小、类别清晰、加载方便,非常适合拿来练手。

数据准备阶段有个特别容易忽略的环节:归一化。图像像素值范围是0到255,直接喂给网络会导致数值范围过大,梯度更新不稳定。我的做法是先计算数据集的均值和标准差,然后把每个通道的像素值按“(像素值 - 均值) / 标准差”做标准化。CIFAR-10最常见的标准化参数是:

  • 均值:(0.4914, 0.4822, 0.4465)
  • 标准差:(0.2470, 0.2435, 0.2616)

这个参数网上到处都能找到,自己算一遍也可以,结果差不多。

数据增强是另一个拉升精度的“免费午餐”。训练时对图像做随机水平翻转、随机裁剪、随机亮度调整,相当于在用同样一批数据制造“新样本”,模型见过的变化越多,泛化能力越强。在CIFAR-10上,光加一个随机翻转和裁剪,准确率就能提升2到3个百分点。测试时不用增强,只用标准化。

import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), 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_dataset = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train) test_dataset = datasets.CIFAR10(root='./data', train=False, download=True, transform=transform_test) train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=4) test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False, num_workers=4)

有一点要注意,数据增强只加在训练集上,测试集必须保持原始分布,否则评估结果会失真。我第一次就是不小心把增强用在了测试集上,导致验证准确率忽高忽低,排查了很久才发现是这个原因。

3.2 网络结构设计与参数考量

接下来搭一个经典但不复杂的CNN结构。我的设计是“卷积块-池化-卷积块-池化-全连接-输出”这个经典范式,一共两层卷积、两层池化、两层全连接。为什么选这个结构而不是更深的网络?CIFAR-10图像只有32乘32,分辨率不高,太深的网络容易在这么小的图上过拟合,而且训练时间会成倍增长。这个结构在入门阶段已经足够说明问题。

每一层的参数设定和理由如下:

  • 第一个卷积层:输入通道3(RGB),输出通道32,卷积核3乘3,padding为1,接ReLU。为什么输出通道设32而不是16?通道数越多,能提取的特征类型越多,但计算量和参数量也随之增大。32是一个在表达能力和效率之间比较平衡的起点。
  • 第一个池化层:2乘2最大池化,步长2,把32乘32的特征图压到16乘16。
  • 第二个卷积层:输入通道32,输出通道64,卷积核3乘3,padding为1,接ReLU。这一层在上一层基础上继续提取更高阶的特征,比如“带条纹的物体”“有车轮的物体”。
  • 第二个池化层:2乘2最大池化,把16乘16压到8乘8。
  • 第一个全连接层:把64通道的8乘8特征图展开,得到64乘8乘8 = 4096个特征值,连接到256个神经元节点,接ReLU。
  • 输出层:256个节点连接到10个类别,接softmax。

可以算一下参数量:第一个卷积层是3乘32乘3乘3再加32个偏置,一共896个参数;第二个卷积层是32乘64乘3乘3加64,共18496个参数;第一个全连接层是4096乘256再加256,约100万个参数;输出层是256乘10加10,共2570个参数。看出来了吧,大头全在全连接层上。

这也是为什么后来出现了“全局平均池化”这个设计,它可以把全连接层的参数量几乎砍掉。但对于这个入门项目,保留全连接层更容易直观理解整个流程。

import torch.nn as nn import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super(SimpleCNN, self).__init__() self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.pool = nn.MaxPool2d(kernel_size=2, stride=2) self.fc1 = nn.Linear(64 * 8 * 8, 256) self.fc2 = nn.Linear(256, num_classes) self.dropout = nn.Dropout(0.5) def forward(self, x): x = F.relu(self.conv1(x)) x = self.pool(x) x = F.relu(self.conv2(x)) x = self.pool(x) x = x.view(x.size(0), -1) x = F.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) return x

上面的代码里我加了一个Dropout层,概率设为0.5,放在第一个全连接层之后。Dropout的作用是训练时随机让一半神经元失活,迫使网络不依赖某几个特定神经元,从而提升泛化能力。这个操作在CIFAR-10这种中等规模数据集上效果非常明显,不加的话验证集准确率大约跌2到3个点。

3.3 训练策略与超参数调整

网络结构定好之后,训练环节是决定最终效果的关键。这里面的超参数选择,每一条都是实践中积累出来的经验。

优化器我用Adam,初始学习率设为0.001。Adam的优势在于它结合了Momentum和RMSProp的优点,能自适应调整每个参数的学习率,对初始学习率的敏感度比SGD低很多,适合快速上手。如果你想追求更高的精度,可以换用SGD加动量(momentum=0.9),配合学习率余弦衰减或阶梯式下降,在CIFAR-10这类数据集上往往能比Adam高出1到2个点。但对初学者来说,先用Adam把流程跑通,再逐步替换优化器,是更务实的路径。

Batch size我设为128。为什么不是更大或更小?Batch size太大会导致显存占用高、收敛变慢;太小则梯度估计的噪声太大,训练不稳定。128是一个很常用的中间值,在主流显卡上也比较友好。如果显存紧张可以降到64。

训练的epoch数先设30轮。每轮结束我会在测试集上计算一次准确率,把最好的模型参数保存下来,而不是简单用最后一轮的参数。因为训练后期模型可能已经过拟合,测试准确率反而下降,保存最优模型能确保最终拿到的是效果最好的那一份权重。

import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SimpleCNN(num_classes=10).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) best_acc = 0.0 for epoch in range(30): model.train() running_loss = 0.0 for inputs, labels in train_loader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() model.eval() correct, total = 0, 0 with torch.no_grad(): for inputs, labels in test_loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() acc = 100.0 * correct / total print(f'Epoch {epoch+1}/30, Loss: {running_loss/len(train_loader):.4f}, Acc: {acc:.2f}%') if acc > best_acc: best_acc = acc torch.save(model.state_dict(), 'best_model.pth') print(f'Best accuracy: {best_acc:.2f}%')

这里有一个细节必须强调:model.eval()不能漏。PyTorch里Dropout和BatchNorm在训练和推理两种模式下的行为不同,eval模式会关闭Dropout、使用固定的BatchNorm统计数据。如果不切换模式,推理结果会不稳定,我之前因为这个问题在验证时吃了不少亏。同理,训练前要调用model.train()确保Dropout生效。

按这个配置训练一轮大约40到60秒(视GPU型号而定),30轮下来,测试集准确率大概能到72%到78%。这个成绩在CIFAR-10这个难度不算高,但过程完整,能很清晰地看到模型的收敛趋势和各个环节的作用。

4. 常见问题与排查技巧实录

4.1 损失不下降,训练半天没动静怎么办

这是所有新手都会撞上的第一堵墙。模型跑起来了,loss值却像一条平直线,或者只降了一点点就再也不动了。遇到这种问题,不要慌,按下面的顺序排查。

先检查学习率。学习率设太大,loss会在一个范围内震荡,跳过了最优点;设太小,loss几乎不下降。0.001是Adam的通用起点,如果一开始就把学习率设成了0.1,loss很容易原地爆炸。我见过不少同学把这个点搞反,总想让模型“学快一点”,结果反而学不动。

接着检查数据预处理。输入值如果没做归一化,比如直接拿0到255的像素值喂进去,梯度会非常大,优化过程极其不稳定。另一个容易踩的坑是:数据标签没对齐。比如label从1开始而不是从0开始,而模型的输出是10个类别、下标从0开始,这样分类任务永远学不对。

再检查网络结构里有没有“致命失误”。比如在最后的输出层误加了ReLU,导致输出永远非负,softmax根本没法形成正确的概率分布。这类结构性错误有时并不会让loss变成NaN,它只是让loss停在某个高位不动,排查起来更隐蔽。

有一套很傻但屡试不爽的方法:先用一个极小的数据集(比如1个batch、16张图)训练,看loss能不能降到非常低,比如0.01以下。如果这个小数据集上loss都降不下去,说明代码或网络结构有问题;如果小数据集能过拟合、大数据集不行,那问题就出在数据或超参数上。这个排查思路帮我节省了大量时间。

4.2 验证集准确率停滞在低水平,是欠拟合还是过拟合

先分清概念:欠拟合是训练集和验证集准确率都低,说明模型容量不够,学不动;过拟合是训练集准确率很高、验证集准确率上不去,说明模型把训练数据背下来了,没有学到通用规律。

欠拟合的解决办法有这几个方向:加深网络(加卷积层或全连接层)、加宽网络(增加通道数)、减少正则化强度(降低Dropout)、增加训练轮数。如果用的是SGD,可以考虑换Adam或调大学习率。

过拟合的处理则相反:增加数据增强(随机裁剪、翻转、颜色抖动),加大Dropout比例,加入权重衰减(weight decay,也叫L2正则化),提前停止训练(早停法,即验证集准确率连续几个epoch不涨就停),减少网络容量。在实际项目里,数据增强和Dropout是性价比最高的两个手段。

我做一个项目时曾经在CIFAR-10上试过只加数据增强、不加Dropout,验证精度从70%左右涨到74%;后来又加了Dropout 0.5,再涨到76%。说明这两者的作用方向一致,可以叠加使用。但注意别加过头,正则化太强模型也会欠拟合。

4.3 显存不足,模型根本放不下怎么办

最后说一个非常现实的问题:工程环境下的显存限制。我的笔记本显卡只有8GB显存,训练稍大一点的模型就容易爆显存。解决办法按优先级排序。

第一,减小batch size。这是最直接的手段,从128减到64甚至32。batch size小一点,梯度噪声大一点,但配合Adam一般没问题。如果32还不够,可以试试“梯度累积”:累计多个batch的梯度后再做一次参数更新,相当于“分身”出更大的batch size,显存占用却不变。

第二,降低输入分辨率。有的任务允许缩放图像尺寸,比如把输入从224缩到160,显存占用能减少一半。但要注意,这个操作可能损失细节,具体还得看任务本身的要求。

第三,用混合精度训练。PyTorch里可以用torch.cuda.amp自动混合精度,把部分计算从FP32降到FP16,显存占用约能减少一半,速度还能提升。在支持Tensor Core的GPU上效果尤其明显。开启AMP之后要注意损失缩放,PyTorch的GradScaler已经帮你处理好了,照抄官方示例即可。

第四,检查是否有显存泄漏。每个训练循环里如果忘记把梯度清零(optimizer.zero_grad()),或者反向传播后没有释放计算图,显存占用会持续增长直到爆掉。用nvidia-smi实时监控显存变化,如果每个epoch结束后占用只增不减,基本就是代码的问题。

4.4 一个容易被忽视的小细节:评估指标到底该看什么

训练过程中最常看的指标是loss和accuracy,但这两个指标各有局限。Loss衡量的是当前预测和真实标签的差异,它可以下降,但accuracy不一定同步上升,特别是在类别不均衡的数据集上。比如99%的样本都是类别A,模型全预测成A也有99%准确率,但这个模型毫无用处。

所以当任务里类别不均衡时,建议顺便计算每个类别的精确率(precision)、召回率(recall)和F1分数。PyTorch里可以借用sklearn的classification_report快速生成。我之前做一个瑕疵品检测项目时,正样本不足5%,靠accuracy完全看不出问题,转头看recall才发现模型漏检了一大半真正的瑕疵品。

最后再说说怎么判断模型训练的“健康程度”。最直观的手段是把训练loss和验证loss画在同一张图里。训练loss持续下降、验证loss降到某个点后开始回升,说明过拟合来了;两个loss都居高不下,说明欠拟合或者学习率不对。绘制曲线只需要在每个epoch记录一次loss值,训练结束后用matplotlib画出来,信息量远比堆在终端里看数字大得多。

5. 从CNN到更多模型:你接下来可以往哪走

把上面这个完整的流程跑通之后,你手里已经有了一套可以迁移到很多图像任务上的基础能力。但神经网络模型的版图远不止CNN这个起点。在我实际做项目的过程中,有几个方向值得在打完基础后作为自然的下一步去探索。

一个方向是网络结构深挖。你试过两层卷积,那可以试试现在主流的残差网络(ResNet)。它引入了一个“短路连接”(skip connection),把输入直接加到输出上,解决了深层网络梯度消失的难题。我第一次用ResNet替换自己的两层CNN时,同样在CIFAR-10上,准确率从76%直接跳到85%以上。这个提升不是因为单个组件有多神奇,而是“把网络加深”这条路被真正打通了。

另一个方向是从图像分类走向目标检测和语义分割。CNN提取特征的能力,不只是能用来判断“这是什么类别”,同样适用于“物体在哪里”“哪些像素属于这个物体”。像目标检测领域的YOLO系列、语义分割领域的U-Net,这些模型的骨干网络全都是CNN。你掌握的卷积、池化、激活函数这些概念,到了这些任务里依然成立,只是网络结构上多了一些分支和特殊设计。

还有一个值得关注的方向是轻量化模型。现在的手机和嵌入式设备上跑不了动不动几亿参数的模型,所以有了MobileNet、ShuffleNet这类为移动端设计的CNN变体。它们用深度可分离卷积替代标准卷积,把计算量降了一个量级。我在部署一个边缘端图像识别项目时,用MobileNetV3替换VGG16,模型大小从500MB缩到20MB,推理速度提升了近10倍,精度只掉了两个点。这个方向在工程实际中的应用非常广。

神经网络模型的学习路径本质上是“先窄后宽”的,先用一个小而完整的项目把全链路跑通,再沿着自己感兴趣的方向逐步扩展技术版图,这比一开始就想着吃透所有模型要高效得多。你会发现,后面学的大多数模型,底层逻辑都跑不出你在CNN里看到的那些核心思想:特征提取、信息压缩、非线性变换、梯度优化。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询