
从全连接网络一路走到这里应该已经有不少读者和我当初一样心里冒出一个问题前面两篇里我们把反向传播、梯度下降和训练循环都跑通了拿个手写数字识别的小例子也挺像那么回事可一旦把输入换成稍微大一点的图片、类别稍微多一点的数据集网络就变得又慢又差甚至直接跑不起来。这篇我打算把最关键的缺口补上——从全连接走向卷积讲清楚深度神经网络在面对图像这类真实任务时到底是怎么工作的然后带大家用PyTorch从零搭一个能用的图像分类项目。适合已经了解Python基础、看得懂反向传播但还没有真正做过图像任务的读者。1. 图像任务里的参数灾难为什么全连接网络走不远先说一个我当年踩过的坑。拿到图像任务时我第一反应是把图片拉直成一维向量然后塞进前面做好用的全连接网络里。思路听起来没什么问题但算了一笔账之后我整个人都愣住了。1.1 一张小图片压垮全连接层假设输入是一张 224×224 的彩色图片通道数是3那么拉直后输入维度就是 224×224×3150528。如果我们希望第一层有100个神经元那么这一层的参数量就是 150528×10015052800约1500万个参数。这才仅仅是一层后面再接几层参数轻松过亿。亿级参数是什么概念它意味着你需要海量数据去喂给网络否则它记住训练集绰绰有余但对没见过的图片基本没有任何判断力这就是典型的过拟合。它同时还意味着你的显存、内存、训练时间全部跟着爆炸。我在实验室的普通电脑上试过一次单次前向传播都慢得让人怀疑人生更不要说反向传播了。所以结论很明确在处理图像时把每个像素都平等地连接到每个神经元是一条走不通的路。这里要理解一个关键事实自然图像具有强烈的局部相关性。一张猫的图片里耳朵的边缘只和附近几十个像素有关它和图片角落里的背景像素几乎没有什么直接联系。全连接网络完全无视这种结构让每个神经元去关心所有像素这不仅是浪费甚至是灾难。1.2 卷积带来的两个直觉局部连接与参数共享卷积网络做的事本质上就是针对图像这种二维结构做两个减法。第一个是局部连接。每个神经元不再连接整张图片而是只看一个小窗口。比如一个3×3的窗口一次只看9个像素。这就像你认识一个人的时候不需要先了解他全家族的几百号人先看看他的五官、衣着就够了。小窗口里的信息足够判断出边缘、颜色变化这些底层特征。第二个是参数共享。同一个窗口规则我们叫卷积核会滑过整张图片的所有位置用同一组参数去扫描整幅图。这背后的假设是一个能识别竖直边缘的滤波器在图片左上角好用在右下角也应该同样好用。就像一把尺子你不会因为量的是桌子左边就换一把完全不同的尺子。参数共享让参数量急剧下降比如一个3×3的卷积核只有9个权重彩色输入再乘通道数和前面1500万比几乎可以忽略。2. 卷积层内部到底在算什么一个像素级拆解理解了为什么需要卷积之后就得弄清楚卷积层具体是怎么运算的。只说提取特征是不够的得能亲手算出每个输出像素的值才算真正掌握了。2.1 卷积核、滑动窗口与特征图卷积核就是一个小的权重矩阵常见尺寸是3×3或5×5。计算时卷积核从输入图像的左上角开始盖住同样大小的区域然后把对应位置的像素值和核里的权重逐个相乘再求和最后加上一个偏置项得到输出特征图上的一个值。接着核向右滑动一个步长重复同样的操作直到扫完整个输入。打个比方卷积核就像一个放大镜它一次只看一小块但每看一块都会从那一块里提炼出一个数字。放大镜在整个画布上扫一圈就得到了一张新画布也就是特征图。这一张特征图对应的是原图每个局部位置经过同一个观察规则后的响应值。为了看得更直观用一个非常简单的边缘检测核举例import numpy as np kernel np.array([ [-1, -1, -1], [-1, 8, -1], [-1, -1, -1] ])这个核的核心思想是如果窗口中心像素和周围8个像素差别很大求和结果就会很大说明这里存在突变很可能是一条边缘如果一片区域颜色均匀那么正负相互抵消输出接近0。这就是卷积层提取特征的最基本逻辑——每一层都在问这个局部区域里有没有某种模式。2.2 步长、填充与感受野计算实际拼接网络时还有两个绕不开的参数步长stride和填充padding。步长控制卷积核每次移动多远。步长为1时核每次挪一个像素输出特征图和输入尺寸基本一致步长为2时每两下才输出一个值特征图分辨率直接减半计算量也随之下降。填充则是在输入边缘补一圈0目的是控制输出尺寸防止图像越缩越小同时也能让边缘像素被卷积核覆盖更多次。边缘像素在无填充时被看到的次数远少于中间像素这会导致图片边缘信息天然被弱化填充可以部分缓解这个问题。输出尺寸的计算公式是out (W - F 2P) / S 1其中W是输入尺寸F是卷积核尺寸P是paddingS是stride。这个公式非常实用调模型时几乎每次都要用它来推算特征图尺寸直到网络输出的特征图能顺利对接全连接层。关于感受野我当初理解了好久。简单说第一个卷积层的神经元只看原图的3×3区域但第二个卷积层再看第一层输出的特征图时它覆盖的其实是第二层特征图上的3×3而第二层特征图上的每一个点又对应原图更大的一块区域。所以第二层的神经元实际感受到原图的区域比3×3大得多。层数越深每个神经元对应的原始输入范围越大这就是网络能从边缘到轮廓再到完整物体逐层抽象的原因。2.3 池化到底保留了什么丢掉了什么池化层没有参数常见的有最大池化和平均池化。最大池化在窗口内取最大值平均池化取平均值然后同样滑动扫描。池化的价值在于降维。图像经过多层卷积后特征图数量在增加但每张特征图的分辨率可以通过池化不断缩小。这带来两个好处一是后续层的计算量显著减少二是扩大感受野让网络在更深的层用更少的计算覆盖更大的输入区域。但池化是有代价的。以最大池化为例它只保留窗口内最强烈的响应相当于把其他信息丢掉。这意味着网络对某个特征出现了很敏感但对这个特征精确出现在哪里没那么在意了。这种精度换来的好处是平移不变性物体稍微挪动几个像素最大池化后的结果基本不变分类时就不容易因为物体位置偏移而误判。所以池化层舍弃的是精确位置换来的是对位置变化的鲁棒性和计算效率我认为这笔买卖通常是划算的。下面用表格对比一下全连接层和卷积层在图像任务上的核心差异维度全连接层卷积层连接方式每个神经元连接所有输入每个神经元只连接局部窗口参数量随输入尺寸爆炸式增长由卷积核尺寸和通道数决定与输入尺寸无关空间结构输入拉直丢失二维结构保留图像空间结构平移不变性不具备通过滑动窗口自然具备适用场景向量输入、小型特征图像、时序等带结构的数据3. 深度网络训练三座山激活饱和、梯度消逝、过拟合把卷积层、池化层堆出一个像样的网络之后接下来真正折磨人的不是网络结构而是训练过程。很多读者可能和我当年一样网络结构照搬下来训练就是loss不降或者验证集准确率差到离谱。这里面的根本原因集中在三个方面。3.1 激活函数怎么选不要迷信Sigmoid早期神经网络喜欢用Sigmoid做激活函数但在深层网络里它有一个致命问题饱和区的梯度几乎为0。Sigmoid在输入很大或很小时曲线极其平缓反向传播时梯度经过它后会被乘一个接近0的数。算一笔账就明白了假设每一层梯度都被Sigmoid衰减到原来的0.5倍10层以后就是0.5的10次方约等于千分之一20层以后就是约百万分之一。深层的参数几乎收不到有效的更新信号网络就停在那里不动了这就是梯度消失。深度神经网络在2012年之前迟迟打不开局面很大程度就是卡在激活函数的饱和问题上。ReLU的出现很直白负数一律截断为0正数原样输出梯度在正区间恒为1深层信号的传递能力大幅改善。所以新项目里我默认先用ReLU。它也有自己的坑——如果学习率设置太大或初始化不当大量神经元可能全部输出0称为死亡ReLU这时模型会失去表达能力。碰到这种情况可以先试试LeakyReLU它给负区间留了一个小斜率让神经元即使落在负半区也能有一点点梯度恢复的机会。3.2 权重初始化和批归一化让梯度流动起来激活函数选对了如果权重初始化不讲究深层网络照样训不动。如果所有神经元用同一个值初始化反向传播时同一层的所有神经元会收到完全相同的梯度它们会一直保持同步更新这等于这一层只有一个神经元在干活网络的表达能力被砍掉一大截。如果初始化权重过大经过多层相乘后激活值会爆炸权重过小信号又会逐层衰减到消失。Xavier初始化和Kaiming初始化的核心思路就是让每层输出的方差保持在一个合理范围内。Kaiming初始化专门针对ReLU设计所以现在PyTorch里用ReLU时默认的初始化方式基本就是Kaiming思路大多数情况下不需要手动干预。这里想提醒的是没事别用全零初始化结果会非常糟糕。BatchNorm则是另一个里程碑式的设计。它的做法是每个批次内先把这一层神经元的激活值减去均值除以标准差再做一次可学习的缩放和平移。你可以把网络想象成一条生产线上游工位出来的零件尺寸总是忽大忽小工人加工起来很费劲BatchNorm就是在每个工位前加了一台标准化设备把零件尺寸统一了再送进去。BatchNorm带来的实际收益很直接可以使用更大的学习率训练收敛更快同时对初始化质量的敏感度也下降不少。但有一个细节特别容易踩坑训练时BatchNorm用的是当前batch的统计数据推理时用的却是训练阶段累积的滑动平均。这决定了模型在训练和推理两种模式下行为不同所以做验证或推理时必须调用model.eval()否则指标会莫名其妙地变差。3.3 正则化实操Dropout与L2 Weight Decay的PyTorch写法网络结构深、参数多过拟合几乎是必然出现的。正则化就是一边把模型容量降下来一边逼模型学更通用的规律。Dropout的做法非常直观训练时以一定概率随机把一部分神经元的输出置为0相当于每次迭代都让不同的小团队去完成预测最终模型不能过度依赖某一个神经元必须学会冗余表达。类比一下就是小组做项目时老师每次随机抽走几个人大家被逼得每个人都能独当一面。推理时Dropout要关闭否则随机丢弃会让预测结果抖动。PyTorch的nn.Dropout在train和eval模式下会自动切换行为前提是你记得切换模式。L2正则化在PyTorch里的实现通常就是优化器里的weight_decay参数。它的思想是在损失函数里加上所有权重平方和的惩罚项让权重保持较小值防止少数参数取值过大、主导整个模型的输出。写成优化器代码只有一行optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4)如果手动实现L2的梯度项效果等价于每次更新时把权重往0方向拉一点点w w - lr * (grad lambda * w)。lambda就是weight_decay我通常从1e-4开始尝试。设太小等于没有设太大模型会严重欠拟合验证集准确率会先升后降这个拐点附近往往就是合适的位置。4. 一个可复现的实战项目用CNN做图像分类理论部分铺垫完了下面动手做一个完整的图像分类项目。整个项目采用PyTorch搭建输入是任意一个按类别分目录摆放的图片文件夹不需要你手动修改大量代码只需要把数据按照约定放好训练和预测都可以直接跑通。4.1 项目结构与数据准备推荐的项目目录结构如下image_cls/ ├── data/ │ ├── train/ │ │ ├── class_a/ │ │ │ ├── a_001.jpg │ │ │ └── ... │ │ └── class_b/ │ │ ├── b_001.jpg │ │ └── ... │ └── val/ │ ├── class_a/ │ └── class_b/ ├── train.py ├── predict.py └── model.pyclass_a、class_b这些文件夹的名字就是类别名想分成几类就建几个文件夹。我建议训练和验证数据从一开始就严格分开千万不要混在一起。验证集的图片不需要太多但来源最好和训练集有一定独立性不然验证曲线是虚的后期上线会很难看。数据加载部分用PyTorch自带的torchvision.datasets.ImageFolder它天然支持这种按目录分类的结构看到目录名就能自动推断标签。配合transforms做必要的预处理import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader transform_train transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) transform_val transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_dataset datasets.ImageFolder(data/train, transformtransform_train) val_dataset datasets.ImageFolder(data/val, transformtransform_val) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue)这里Normalize所用的均值和标准差是常见预设值。做图像分类时获取每个通道的均值和标准差然后做标准化是很重要的一步它能避免输入数值过大或过小让网络训练从一开始就处于稳定状态。4.2 模型定义从LeNet式结构到自己的小网络我设计了一个结构简单但足够完成任务的小卷积网络放在model.py里import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes2): super(SimpleCNN, self).__init__() self.features nn.Sequential( nn.Conv2d(3, 16, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 28 * 28, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): x self.features(x) x self.classifier(x) return x输入图片resize到224×224后经过三次MaxPool2d分辨率依次变成112、56、28所以最后一层卷积输出的特征图是64×28×28全连接层的输入维度就是64×28×28。这个尺寸是我按公式算出来的不是拍脑袋写的。如果你换用其他输入尺寸需要按照第二节的公式重新推算线性层的输入维度这是新手最容易报错的地方。选择三层卷积加池化而不是直接堆单个大卷积核是有原因的。三个3×3卷积堆叠感受野可以覆盖7×7区域但参数量只有3×9×通道数比一个7×7卷积核少得多而且中间多了两层非线性表达能力反而更强。这就是深度网络胜过浅层大卷积核的关键之一。4.3 训练循环、验证与模型保存训练逻辑写在train.py里。核心训练循环如下import torch import torch.nn as nn from torch.optim import Adam from model import SimpleCNN def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss 0.0 correct 0 total 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) _, preds torch.max(outputs, dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total def evaluate(model, loader, criterion, device): model.eval() total_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) _, preds torch.max(outputs, dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total训练过程要注意几个细节每次优化前调用optimizer.zero_grad()清空梯度验证阶段用torch.no_grad()关闭梯度计算既省显存又避免误改参数验证必须包在model.eval()里。完整训练入口如下def main(): device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleCNN(num_classes2).to(device) criterion nn.CrossEntropyLoss() optimizer Adam(model.parameters(), lr1e-3, weight_decay1e-4) epochs 30 for epoch in range(epochs): train_loss, train_acc train_one_epoch( model, train_loader, criterion, optimizer, device) val_loss, val_acc evaluate(model, val_loader, criterion, device) print(fEpoch {epoch1}/{epochs} | ftrain_loss {train_loss:.4f} | train_acc {train_acc:.4f} | fval_loss {val_loss:.4f} | val_acc {val_acc:.4f}) torch.save(model.state_dict(), checkpoint.pth) if __name__ __main__: main()这里每轮结束都保存一次模型权重实际项目中更推荐的做法是只在验证准确率提升时保存防止最后一轮恰好不是最优状态。预测时加载权重后调用model.eval()再对新图片做同样预处理后前向传播即可。5. 训练过程中我反复踩过的坑和最终方案最后这部分把我自己反复遇到、也经常在社区里看到的问题集中整理一下。每个问题背后都是一段真实的调试经历照单排查能省下不少时间。5.1 损失不下降或震荡先查数据再查超参损失不下降时不要先怀疑网络结构而是按顺序排查。第一看数据预处理和归一化是否一致训练集和验证集若用了不同预处理指标会异常第二看标签是否对得上类别文件夹放错图片是常见低级事故第三看学习率学习率太大loss会震荡太小loss纹丝不动可以从1e-4到1e-2之间按数量级试几轮最后再看模型本身。对于图片数量很少的样本先把batch size适当调大让每个batch的统计更稳定。loss如果在某个位置开始震荡一个有效操作是引入学习率衰减比如每训练若干轮把学习率乘以0.1或0.5。PyTorch里的ReduceLROnPlateau可以直接监听验证loss长时间不降就自动调低学习率我几乎每个项目都会挂上这个调度器。5.2 验证集指标和训练集差距过大过拟合的典型信号训练准确率一路冲到95%以上验证集却卡在70%上下这就是过拟合。对策优先级按我自己的经验排列先加数据增强再加大Dropout比例或Weight Decay最后才考虑缩小模型。数据增强代码如下transform_train transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])增强相当于用有限的图片制造出更丰富的训练分布让模型不能死记某个具体像素组合。加增强之后训练集准确率通常会掉一点这很正常验证集准确率反而会往上走我们要的就是这个效果。5.3 多个Loss加权时的比例调整经验如果你的任务是目标检测、多任务学习这类需要同时优化多个损失的情况经常会面对如何调整多个loss间的比例这个问题。我踩过几次坑之后总结出一套比较靠谱的流程。第一步先固定主干单独训练各个任务的分支分别拿到每个loss的大致量级。多任务的总损失通常是这样的形式total_loss w1 * L1 w2 * L2如果L1本身就是0.01量级L2是10量级权重等权设置为1的话L2就会完全主导梯度L1对应的任务分支基本学不到东西。所以第二步是按量级的反比设定初始权重比如w1100w21让两个loss对总梯度的贡献一开始大致相当。第三步才是根据训练过程微调。一个实用的观察指标是各任务分支的梯度范数梯度范数大的任务在主导优化方向可以适当降低它的权重梯度范数太小的任务要调高权重。下面是我常用的调参方向参考表现象原因调整方向某个任务准确率一直不涨对应loss量级太小被主导任务压制提高该任务的loss权重总loss震荡剧烈某个任务梯度过大降低该任务的loss权重两个任务此消彼长权重过于极端回到等量级起步缩小两者差距某任务收敛极快其余跟不上任务难度差异大适当降低简单任务权重保留网络容量给难任务还有一个容易忽略的细节不要在中途频繁修改权重。每改一次权重优化目标就变一次模型会反复震荡。最好是先在某个权重组合下让它训练20~30轮等曲线稳定下来再做下一轮调整而不是每3轮就动一次旋钮。我在实际使用中发现训练深度神经网络这件事真正拉开差距的往往不是用了多新奇的网络结构而是对数据预处理、激活函数、正则化和loss权重的理解深度。上面这套流程本质上也是在模拟我自己每个项目的排查轨迹从参数灾难到卷积原理再到训练技巧和实战细节每一步都是被真实项目逼着补上的。如果你也正在啃深度学习建议先别急着追新模型把我这个小网络在不同数据集上反复训练几次把loss曲线的变化和调参动作对应起来效果会比囫囵吞枣地刷十个教程来得更扎实。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。