首页 / 资讯中心 / 文章详情

2020 cs231n卷积神经网络笔记:从底层原理到工程实现完整链路

2020 cs231n卷积神经网络笔记:从底层原理到工程实现完整链路 ★ FEATURED ARTICLE
1. 为什么2020年的cs231n笔记至今仍值得翻出来看卷积神经网络这个方向每年都有新东西冒出来但真正把CNN的来龙去脉讲透的课程其实不多。2020年那版cs231n的课程设计有一个很明显的特征它把卷积神经网络从“怎么用”到“为什么这样设计”这条线拉得很完整。很多后来出的教程直接跳到Transformer或者大模型微调反而把CNN的基础环节压缩得很厉害导致不少人在做视觉任务时遇到问题只能靠试参数不知道问题出在哪一层。这份学习笔记的核心价值在于它覆盖了卷积神经网络从底层原理到工程实现的完整链路。具体来说它涉及的核心技术点包括卷积操作的数学本质、感受野与特征图尺寸的推导、池化层的设计动机、批归一化的训练与推理差异、残差连接解决梯度问题的机制、以及经典网络结构如VGG、ResNet的设计取舍。这些内容不是孤立的而是相互关联的——你理解了卷积的数学形式才能算清楚特征图尺寸算清楚了尺寸才能理解为什么ResNet的降采样策略要那样设计。适合看这份笔记的人其实比想象中广。如果你是刚入门深度学习的同学它能帮你建立一套从输入到输出的完整计算直觉而不是只会调库如果你已经做过一些视觉项目它能帮你把之前“凭感觉”调参的经验对应到具体的原理上下次遇到问题知道往哪个方向排查如果你在准备面试或者做技术分享这份笔记里的推导过程和参数计算可以直接作为素材。我自己的习惯是每隔一段时间就把这份笔记翻出来看一遍每次都能发现之前忽略的细节。比如第一次看的时候只记住了“卷积可以提取特征”后来再看才注意到步长和填充对特征图尺寸的影响其实有严格的公式约束再后来才意识到这些约束会直接影响网络能堆多深。这种层层递进的理解是单纯看API文档得不到的。2. 卷积神经网络的核心设计思路拆解2.1 从全连接到卷积为什么参数共享是关键如果只用全连接层处理图像第一个问题就是参数量爆炸。假设输入是一张224×224×3的彩色图展平后是150528个像素。如果第一个隐藏层有1000个神经元光这一层就有1.5亿个参数。这还只是一层堆几层之后参数量会大到无法训练。更麻烦的是全连接层把每个像素都当作独立特征完全忽略了图像的空间结构——相邻像素之间的关系、局部纹理的重复性这些信息全丢了。卷积层解决这个问题的思路很直接既然图像的特征在空间上是局部重复的那就不要让每个神经元都连接全部像素而是让一组权重在整张图上滑动。这就是参数共享。一个3×3的卷积核只有9个权重加上偏置是10个但它可以在整张图上反复使用。参数量从1.5亿直接降到个位数而且滑动的过程天然保留了空间位置信息。这里有一个容易被忽略的细节参数共享的前提是“同一个特征在不同位置出现时应该被同样地检测”。这个假设对图像成立因为一只猫出现在左上角还是右下角检测它的卷积核应该是一样的。但对某些任务比如人脸识别中的关键点定位不同位置的特征可能需要不同的处理方式这时候就会在网络的后面几层用全连接层来整合全局信息。2.2 卷积核尺寸的选择逻辑为什么3×3成了主流在2020年的课程里卷积核尺寸的选择是一个重点讨论的话题。早期的一些网络用过7×7甚至11×11的卷积核但后来3×3逐渐成为主流。这个变化背后有明确的数学理由。两个3×3卷积层的感受野等价于一个5×5卷积层三个3×3卷积层的感受野等价于一个7×7卷积层。但参数量上三个3×3卷积层的参数量是3×927忽略偏置而一个7×7卷积层是49。也就是说用更小的卷积核堆叠可以用更少的参数获得相同的感受野。同时每层之间都加了非线性激活函数三个3×3层意味着三次非线性变换表达能力比单个7×7层更强。还有一个工程上的考虑3×3的卷积在GPU上的计算效率很高。大多数深度学习框架对3×3卷积都有专门的优化内存访问模式也更友好。7×7的卷积核在计算时会有更多的内存跳转实际训练速度反而不如堆叠的小卷积核。注意卷积核尺寸的选择不是绝对的。在网络的第一个卷积层有时候会用7×7的大核来快速降低空间分辨率减少后续层的计算量。这种设计在计算资源有限的情况下仍然有效。2.3 池化层的取舍最大池化与平均池化的实际差异池化层的作用是降低特征图的空间尺寸同时保留主要信息。最大池化取局部区域的最大值平均池化取平均值。课程里对这两种池化的对比分析很值得细看。最大池化在视觉任务中更常用原因是它保留了局部区域中最强的激活响应。在图像中边缘、角点这些特征往往表现为局部区域的极值最大池化能把这些极值传递到下一层。平均池化则会把极值平滑掉对于纹理识别这类需要统计信息的任务可能更合适但在分类任务中通常不如最大池化。池化层的另一个作用是提供一定程度的平移不变性。输入图像稍微平移几个像素经过池化后特征图的变化会被压缩。这种不变性对于分类任务很重要因为同一类物体在图像中的位置是不固定的。但池化也有代价它丢失了精确的空间位置信息。所以在目标检测和语义分割任务中池化层用得越来越少取而代之的是步长卷积或者空洞卷积。2.4 批归一化的训练与推理差异一个容易踩坑的地方批归一化在训练时对每个mini-batch计算均值和方差然后归一化。但在推理时如果还依赖当前batch的统计量结果就会不稳定——单张图片推理时batch size为1均值和方差没有意义。所以推理时必须使用训练过程中累积的全局均值和方差。这个差异在实际部署时经常出问题。我见过不少人在训练时一切正常导出模型后推理结果完全不对排查半天才发现是批归一化的模式没有切换。大多数框架在调用model.eval()之后会自动切换到推理模式但如果你自己实现了批归一化层或者用了某些自定义层就需要手动处理。训练时累积全局统计量的方式通常是移动平均。具体来说维护一个running_mean和running_var每次训练时用当前batch的统计量去更新它们更新公式是running_mean momentum * running_mean (1 - momentum) * batch_mean。momentum通常取0.1或0.01。这个值的选择会影响推理时的稳定性momentum太小全局统计量更新太慢训练初期推理效果差momentum太大全局统计量波动大推理结果不稳定。3. 核心细节解析与实操要点3.1 特征图尺寸的推导一个必须练到条件反射的计算特征图尺寸的计算公式是输出尺寸 (输入尺寸 - 卷积核尺寸 2 * 填充) / 步长 1。这个公式看起来简单但在设计网络结构时每一步都要算清楚否则到了后面几层特征图尺寸可能变成负数或者非整数。举个例子输入是32×32卷积核是5×5填充是0步长是1。输出尺寸是(32-50)/1128。如果填充是2输出尺寸是(32-54)/1132保持了输入尺寸不变。如果步长是2输出尺寸是(32-50)/2114.5这不是整数说明这个配置有问题——要么调整填充要么调整步长。在实际操作中我习惯在纸上画一个表格每一层记录输入尺寸、卷积核尺寸、填充、步长、输出尺寸。这样做虽然看起来笨但能避免很多低级错误。特别是当网络很深的时候某一层的尺寸算错后面全错而且报错信息往往不会直接告诉你哪一层出了问题。提示当输出尺寸不是整数时大多数框架会向下取整但这意味着有一部分输入信息被丢弃了。在设计网络时应该尽量避免这种情况因为它会让特征图的信息利用不充分。3.2 感受野的计算理解网络“看到”了什么感受野是指输出特征图上的一个像素对应输入图像上的区域大小。感受野的计算是逐层累积的第一层的感受野就是卷积核尺寸第二层的感受野是第一层感受野 (第二层卷积核尺寸 - 1) * 第一层步长以此类推。假设网络结构是3×3卷积步长1→ 3×3卷积步长1→ 2×2最大池化步长2→ 3×3卷积步长1。第一层感受野是3第二层是3(3-1)*15池化层不改变感受野但把步长累积为2第三层感受野是5(3-1)*29。也就是说第三层的一个像素对应输入图像上9×9的区域。理解感受野的意义在于如果感受野太小网络只能看到局部细节无法捕捉全局结构如果感受野太大可能会引入无关的背景信息。对于分类任务通常希望最后一层的感受野覆盖整个输入图像这样每个输出神经元都能“看到”全图。对于检测任务感受野需要和目标尺寸匹配太小会漏检大目标太大会把多个目标混在一起。3.3 权重初始化的选择为什么不能全零初始化权重初始化是训练能否收敛的关键因素之一。如果所有权重都初始化为零那么同一层的所有神经元在前向传播时输出相同反向传播时梯度也相同它们会一直保持相同的值相当于只有一个神经元在工作。这就是所谓的对称性问题。常用的初始化方法有Xavier初始化和He初始化。Xavier初始化适用于Sigmoid和Tanh激活函数它让每一层的输出方差保持一致。He初始化适用于ReLU激活函数因为ReLU会把一半的输入置零所以方差需要乘以2来补偿。具体来说He初始化的标准差是sqrt(2 / 输入神经元数量)。假设一个卷积层的输入通道数是64卷积核是3×3那么输入神经元数量是64×3×3576标准差是sqrt(2/576)≈0.059。这个值看起来很小但经过多层累积后能保持激活值的方差稳定。在实际操作中大多数框架的默认初始化已经足够好不需要手动调整。但如果你在训练一个很深的网络或者用了非标准的激活函数就需要检查一下初始化是否合适。一个简单的判断方法是训练开始后观察每一层的激活值分布如果某一层的激活值几乎全为零或者方差急剧增大说明初始化可能有问题。3.4 学习率与批大小的配合一个被低估的调参维度学习率和批大小是联合起作用的。批大小决定了梯度的噪声水平批越大梯度估计越准确但计算成本越高批越小梯度噪声越大但可能有助于跳出局部最优。学习率决定了参数更新的步长。经验规则是批大小翻倍时学习率也可以翻倍。这个规则的背后逻辑是批大小翻倍后梯度的方差减半所以可以用更大的步长而不至于发散。但这个规则不是绝对的特别是在使用批归一化时批大小对训练的影响会更复杂。我自己的做法是先用一个较小的批大小比如32和一个适中的学习率比如0.01跑几百个iteration观察loss曲线。如果loss下降太慢增大学习率如果loss震荡或者发散减小学习率。确定学习率之后再尝试增大批大小来加速训练。如果增大批大小后效果变差可能需要同步调整学习率或者换用其他的优化器。4. 实操过程与核心环节实现4.1 从零实现一个卷积层的前向传播理解卷积层最好的方式是自己实现一遍前向传播。下面是一个简化的实现假设输入是单通道卷积核也是单通道步长为1填充为0。import numpy as np def conv_forward(inputs, kernel): # inputs: (H, W) # kernel: (K, K) H, W inputs.shape K kernel.shape[0] out_H H - K 1 out_W W - K 1 output np.zeros((out_H, out_W)) for i in range(out_H): for j in range(out_W): output[i, j] np.sum(inputs[i:iK, j:jK] * kernel) return output这段代码虽然效率不高但把卷积的本质说清楚了卷积就是在输入上滑动一个窗口每个位置做逐元素乘法再求和。实际框架里会用im2col或者FFT来加速但计算逻辑是一样的。如果要处理多通道输入比如输入是3通道卷积核也必须是3通道每个通道分别做卷积然后求和。如果要处理多个卷积核每个卷积核产生一个输出通道最终输出的通道数等于卷积核的数量。4.2 用PyTorch搭建一个完整的CNN分类器下面是一个完整的CNN分类器实现用于32×32的彩色图像分类类别数为10。import torch import torch.nn as nn import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self, num_classes10): super(SimpleCNN, self).__init__() self.conv1 nn.Conv2d(3, 32, kernel_size3, padding1) self.bn1 nn.BatchNorm2d(32) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.bn2 nn.BatchNorm2d(64) self.conv3 nn.Conv2d(64, 128, kernel_size3, padding1) self.bn3 nn.BatchNorm2d(128) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(128 * 4 * 4, 256) self.fc2 nn.Linear(256, num_classes) self.dropout nn.Dropout(0.5) def forward(self, x): x self.pool(F.relu(self.bn1(self.conv1(x)))) x self.pool(F.relu(self.bn2(self.conv2(x)))) x self.pool(F.relu(self.bn3(self.conv3(x)))) x x.view(-1, 128 * 4 * 4) x F.relu(self.fc1(x)) x self.dropout(x) x self.fc2(x) return x这个网络的结构是三个卷积块每个卷积块包含卷积、批归一化、ReLU激活和最大池化。经过三次池化后32×32的输入变成4×4通道数从3增加到128。然后展平后接两个全连接层中间加了dropout来防止过拟合。训练这个网络的典型配置是交叉熵损失Adam优化器学习率0.001批大小64训练50个epoch。如果数据集比较小可以适当增加dropout的比例或者加权重衰减。如果训练集准确率很高但验证集准确率低说明过拟合了需要增加正则化如果训练集准确率也上不去说明模型容量不够或者训练不充分。4.3 数据增强的实际操作与参数选择数据增强是提升CNN泛化能力最直接的手段之一。常用的增强方式包括随机裁剪、随机翻转、颜色抖动、旋转等。在2020年的课程里随机裁剪和随机翻转是标配颜色抖动和旋转则根据任务特点选择。随机裁剪的参数是裁剪后的尺寸和裁剪位置的范围。比如从32×32的图像中随机裁剪28×28的区域然后缩放到32×32。这样做的好处是让网络看到物体在不同位置和不同尺度下的样子。但裁剪的幅度不能太大否则可能把物体裁掉或者引入太多背景。随机翻转通常是水平翻转概率设为0.5。垂直翻转在自然图像中不常用因为上下颠倒的物体不符合物理规律。但在医学图像或者卫星图像中垂直翻转可能是合理的。颜色抖动包括调整亮度、对比度、饱和度和色调。参数范围需要根据数据集的特点来定。比如对于光照变化大的数据集亮度调整的范围可以大一些对于颜色区分度高的数据集色调调整的范围要小一些否则可能把不同类别的颜色特征混淆。注意数据增强只在训练时使用验证和测试时不要用。另外增强的强度需要根据数据集大小来调整。数据集越小增强可以越强数据集越大增强可以越弱因为过强的增强可能引入太多噪声。4.4 训练过程中的监控与调试训练CNN时有几个关键指标需要持续监控训练loss、验证loss、训练准确率、验证准确率。这四个指标的组合能告诉你很多信息。如果训练loss下降但验证loss上升说明过拟合了。解决办法包括增加数据增强、增加dropout、增加权重衰减、减小模型容量。如果训练loss和验证loss都下降但验证loss始终比训练loss高很多说明模型欠拟合或者数据分布不一致。如果训练loss震荡很厉害说明学习率太大或者批大小太小。除了这些常规指标我还会监控每一层的激活值分布和梯度范数。激活值分布可以帮助判断批归一化是否正常工作梯度范数可以帮助判断是否出现了梯度消失或梯度爆炸。如果某一层的梯度范数接近零说明这一层几乎没有学习如果梯度范数非常大说明可能需要梯度裁剪。还有一个实用的技巧是可视化第一层的卷积核。第一层的卷积核通常学习到的是边缘、颜色斑块等低级特征。如果第一层的卷积核看起来像随机噪声说明训练可能没有正常进行。如果第一层的卷积核有明显的结构说明网络至少在底层学到了东西。5. 常见问题与排查技巧实录5.1 训练不收敛的常见原因与排查顺序训练不收敛是新手最常遇到的问题。排查的时候不要东试一下西试一下按照固定的顺序来效率最高。第一步检查数据。把输入数据可视化出来确认没有标签错误、数据损坏、归一化不当等问题。我见过有人把标签和图像搞混了训练了几十个epoch才发现。也见过有人忘了做归一化输入像素值在0到255之间导致梯度爆炸。第二步检查损失函数。分类任务用交叉熵回归任务用均方误差不要搞混。另外要注意输出的维度是否匹配比如10分类任务的输出应该是10维如果写成1维就会出问题。第三步检查学习率。学习率太大loss会震荡或者变成NaN学习率太小loss下降极慢。可以先试几个数量级的学习率比如0.1、0.01、0.001、0.0001看哪个下降最快。第四步检查网络结构。确认每一层的输入输出维度匹配确认激活函数没有用错确认没有忘记加非线性激活。如果网络很深检查是否有梯度消失的问题可以尝试加残差连接或者用批归一化。5.2 过拟合的判断与应对策略过拟合的判断标准是训练集表现很好验证集表现明显差于训练集。比如训练准确率99%验证准确率只有70%这就是典型的过拟合。应对过拟合的策略按优先级排序增加数据增强、增加dropout、增加权重衰减、减小模型容量、早停。数据增强是最有效的手段因为它直接增加了训练数据的多样性。dropout和权重衰减是正则化手段通过限制模型的复杂度来防止过拟合。减小模型容量是最后的手段因为可能会欠拟合。早停是一个简单但有效的策略在验证集loss不再下降时停止训练。具体操作是每个epoch结束后计算验证集loss如果连续几个epoch验证集loss都没有下降就停止训练并保存验证集loss最低时的模型。早停的耐心值通常设为5到10个epoch。5.3 批归一化层的常见坑批归一化虽然好用但有几个坑需要注意。第一个坑是批大小太小。批归一化依赖batch内的统计量如果批大小只有1或2统计量噪声很大训练效果会变差。如果显存有限无法增大批大小可以考虑用Group Normalization或者Layer Normalization替代。第二个坑是训练和推理模式切换。前面提到过推理时必须用全局统计量。如果忘了切换推理结果会不稳定。大多数框架在model.eval()时会自动切换但自定义层需要手动处理。第三个坑是批归一化层的位置。通常放在卷积层之后、激活函数之前。如果放在激活函数之后效果可能会变差因为激活函数后的分布已经被改变了。但这个也不是绝对的有些网络会尝试不同的位置需要根据具体任务来定。5.4 学习率调度器的选择与配置学习率调度器的作用是在训练过程中动态调整学习率。常用的调度器包括StepLR、MultiStepLR、CosineAnnealingLR、ReduceLROnPlateau。StepLR每隔固定的epoch数把学习率乘以一个衰减因子。比如每30个epoch学习率乘以0.1。这种调度器简单直接适合训练周期固定的场景。CosineAnnealingLR让学习率按照余弦曲线从初始值降到零。这种调度器在训练后期学习率很小有助于模型收敛到更优的局部最优。ReduceLROnPlateau在验证集loss不再下降时降低学习率。这种调度器自适应性强不需要手动设置衰减时间点但需要指定耐心值和衰减因子。我自己的习惯是如果训练周期比较短比如50个epoch以内用CosineAnnealingLR如果训练周期比较长用StepLR或者MultiStepLR如果不确定训练多久用ReduceLROnPlateau。问题现象可能原因排查方法解决方案loss变成NaN学习率太大检查学习率数量级减小学习率loss不下降学习率太小或数据有问题可视化数据试不同学习率调整学习率或修复数据训练准确率高但验证准确率低过拟合对比训练和验证曲线增加数据增强或正则化训练准确率也低欠拟合检查模型容量和训练时长增大模型或延长训练推理结果不稳定批归一化模式未切换检查model.eval()是否调用切换到推理模式显存不足批大小太大或模型太大检查显存占用减小批大小或模型6. 从经典网络结构中学到的设计经验6.1 VGG的贡献与局限VGG的核心贡献是证明了用3×3的小卷积核堆叠可以构建很深的网络并且效果比大卷积核更好。VGG16有16层VGG19有19层在当年的ImageNet上取得了很好的成绩。VGG的设计很规整每个卷积块包含两个或三个3×3卷积层然后接一个2×2最大池化。通道数从64开始每经过一个池化层翻倍直到512。最后接三个全连接层输出1000类。但VGG的局限也很明显参数量太大大部分参数集中在全连接层。VGG16的参数量大约是1.38亿其中全连接层占了1.2亿以上。这意味着VGG的推理速度慢显存占用高部署到移动端几乎不可能。从VGG学到的经验是小卷积核堆叠是有效的但全连接层是参数量的瓶颈。后来的网络设计都尽量避免使用全连接层改用全局平均池化来替代。6.2 ResNet的残差连接为什么它能让网络变得更深ResNet的核心创新是残差连接。传统的网络是y F(x)ResNet是y F(x) x。这个加法的意义在于如果F(x)学习不到有用的东西网络至少可以保持恒等映射不会比浅层网络更差。残差连接解决了深层网络的退化问题。在ResNet之前人们发现网络加深到一定程度后训练误差反而上升了。这不是过拟合因为训练误差也上升了。原因是梯度在反向传播时经过太多层后变得非常小导致前面的层几乎无法更新。残差连接提供了一条梯度的“高速公路”梯度可以直接通过加法操作传回前面的层不需要经过所有的中间层。ResNet的另一个设计是瓶颈结构对于深层ResNet如ResNet50每个残差块用1×1、3×3、1×1三个卷积层。第一个1×1卷积降低通道数第二个3×3卷积在低维空间做卷积第三个1×1卷积恢复通道数。这样做减少了计算量同时保持了表达能力。6.3 全局平均池化的作用与适用场景全局平均池化是把每个通道的特征图取平均值得到一个标量。如果有512个通道全局平均池化后得到一个512维的向量可以直接接全连接层或者softmax。全局平均池化的好处是参数量为零不像全连接层那样需要大量的权重。同时它对空间位置不敏感适合分类任务。但它的缺点是丢失了空间信息不适合需要精确定位的任务。在实际操作中全局平均池化通常用在分类网络的最后一层。如果任务需要保留空间信息比如分割就不要用全局平均池化而是用卷积层或者上采样来恢复空间分辨率。6.4 网络深度与性能的权衡网络越深表达能力越强但训练难度也越大。ResNet证明了在残差连接的帮助下网络可以深到100层甚至1000层。但并不是越深越好因为深层网络的推理速度慢显存占用高而且性能提升会逐渐饱和。选择网络深度时需要考虑任务复杂度、数据集大小、计算资源。对于简单任务和小数据集浅层网络如10层以内可能就够了。对于复杂任务和大数据集深层网络如50层以上可能更合适。如果计算资源有限可以考虑用轻量级网络如MobileNet或者模型压缩技术。我自己的经验是先用一个中等深度的网络比如ResNet18或ResNet34跑一个baseline如果效果不够好再尝试更深的网络。不要一上来就用最深的网络因为训练时间和调试成本都很高。7. 一些实操中的个人体会卷积神经网络的学习曲线其实挺陡的因为涉及的东西很多数学推导、工程实现、调参经验缺一不可。我刚开始学的时候花了很多时间在推导公式上但真正动手写代码时才发现很多问题不是公式能解决的。比如数据归一化没做好公式推导再清楚也没用。后来我养成了一个习惯每学一个概念就写一段代码验证一下。比如学完卷积的尺寸计算公式就写一个函数随机生成输入和卷积核验证输出尺寸是否符合预期。学完感受野的计算就手动算一遍再和代码对比。这种“手算代码验证”的方式比单纯看笔记有效得多。还有一个体会是不要怕犯错。训练不收敛、过拟合、推理结果不对这些问题我都遇到过。每次排查问题的过程其实都是对原理理解加深的过程。比如有一次推理结果完全不对排查后发现是批归一化的模式没有切换从此以后我对训练和推理的差异印象特别深。最后分享一个小技巧如果你在复现一个网络结构先把每一层的输入输出尺寸列一个表格然后逐层核对。这个表格不仅能帮你发现尺寸计算错误还能帮你理解每一层的作用。我到现在还保留着这个习惯每次设计新网络都会先画表格再写代码。
阅读完成 · 觉得有帮助?
咨询建站