尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

卷积神经网络核心原理与LeNet-5实战:从理论到代码实现

卷积神经网络核心原理与LeNet-5实战:从理论到代码实现 最近在头歌Educoder上把“神经网络学习之卷积神经网络”这一章完整刷了下来。头歌的实训关卡设计很有意思它不让你一口气读大段理论而是把知识点拆成若干关卡每关写代码、跑测试通过了才放行下一关。我在这一章里断断续续卡了两三天一边补前馈神经网络和BP反向传播的旧账一边啃卷积、池化、LeNet-5这些内容。说实话以前看论文和课件时很多一笔带过的概念都是在这一章里真正动手之后才想明白的。这篇就把我在头歌上做卷积神经网络实训的过程和心得整理出来内容包括CNN为什么适合图像处理、卷积层和池化层的核心原理、LeNet-5的网络结构逐层拆解以及我当时在平台上踩过的坑和排查思路。适合两类人看一类是正在学神经网络基础、想搞懂CNN原理的初学者另一类是和我一样在头歌上刷题需要把实验关卡跑通又不想只抄答案的同学。1. 图像处理为什么非用卷积神经网络不可1.1 全连接网络处理图像的三个硬伤先说一个最容易困惑的问题图像明明可以用前馈神经网络处理为什么实际做图像识别时大家几乎都用CNN我在头歌这一章的“任务描述”里看到这个疑问被反复提及很多同学第一反应是“前馈神经网络不也能做分类吗”但真正推一遍就发现行不通。全连接网络处理图像第一个硬伤是参数爆炸。一张256x256的彩色图片展平后就是196608维的输入向量如果第一层隐藏层设1000个神经元仅这一层就有大约1.97亿个参数。这样规模的模型在普通显卡上根本没法训练更别说部署到手机或嵌入式设备上。第二个硬伤是空间结构被破坏。图片本来有上下左右的局部关系相邻像素之间的纹理、边缘、颜色过渡才是图像信息的关键。前馈网络把像素展平成一个大向量相当于把二维空间关系打碎成一维列表局部特征完全丢失。第三个硬伤是平移不变性差。同一只猫出现在图片左上角和右下角展平后的向量完全不同全连接网络会把它当成两个不同的输入需要大量增强数据才能勉强学到一些“位移容忍”。邱锡鹏老师的《神经网络与深度学习》里提到过图像识别任务对人造神经网络提出了三个要求局部性、权重共享、以及某种形式的平移/形变鲁棒性。前馈网络三条都不满足所以处理图像天然吃亏。1.2 卷积神经网络靠什么解决这些问题卷积神经网络用三个核心策略解决了上面的问题局部连接、权值共享、下采样。局部连接很好理解每个卷积核只感受输入的一个小窗口比如5x5或者3x3的区域。这就像一群人各管自己门前的一亩三分地而不是每个人都去看完整幅画面。这样既保留了像素之间的局部关系又大幅减少了参数。权值共享更巧妙同一个卷积核会在整张图上滑动用同一组参数去扫描所有位置。比如一个5x5的卷积核只有25个权重加1个偏置却能作用于整张图。这带来的好处不仅仅是参数少还让网络自动获得了“在哪个位置都能认出同一种特征”的能力。下采样也就是池化层负责降低特征图尺寸同时带来一定的平移鲁棒性。最大池化取窗口内的最大值平均池化取平均值无论目标物体在窗口里移动了一两个像素池化输出不会发生剧烈变化。这种“差不多就行”的性质正好匹配图像识别中物体位置不固定的特点。当时我把这三个策略和“AI神经网络滤镜”里的效果联系起来其实手机相册里各种风格迁移、人像分割、超分辨率滤镜底层基本都是卷积神经网络在做特征提取。理解了局部连接和池化再看这类应用的实现思路就清晰多了。1.3 和其他主流网络结构的边界划分这一章里还会顺带对比其他神经网络我在头歌的“知识拓展”部分也看过相关题。简单梳理一下CNN卷积神经网络适合处理网格状数据比如图像、视频帧主要操作是卷积和池化。RNN循环神经网络适合处理序列数据比如文本、语音、时间序列靠循环单元记忆历史信息。GNN图神经网络适合处理图结构数据比如社交网络、分子结构通过消息传递聚合邻居信息。3D CNN是CNN在视频或医学体数据上的延伸卷积核多了一个时间或深度维度。明白了这些边界就不会再问“为什么不拿RNN识别图片”这类问题了。数据是什么结构就选匹配这种结构的网络CNN正是为“二维网格结构”设计的专用武器。2. 卷积神经网络的核心组件逐个拆解2.1 卷积层到底在算什么卷积层的本质是特征提取。用一句话概括拿一个小窗口卷积核在输入图上每次滑动一定步长在窗口内做逐元素乘法再求和把结果填到输出特征图的对应位置。假设输入是单通道图像尺寸为5x5卷积核是3x3步长为1不填充那么输出尺寸的计算公式为output_size (input_size - kernel_size) / stride 1代入就是(5 - 3) / 1 1 3所以输出是3x3的特征图。如果输入是多通道卷积核也要有对应通道数每个通道独立做卷积再相加最后加上偏置。在头歌的关卡里我写过一段朴素的numpy卷积前向实现虽然效率不如框架自带的卷积函数但对理解原理非常有帮助import numpy as np def conv2d_forward(image, kernel, stride1, padding0): h, w image.shape kh, kw kernel.shape oh (h 2 * padding - kh) // stride 1 ow (w 2 * padding - kw) // stride 1 if padding 0: image_pad np.zeros((h 2 * padding, w 2 * padding)) image_pad[padding:padding h, padding:padding w] image else: image_pad image output np.zeros((oh, ow)) for i in range(oh): for j in range(ow): region image_pad[i * stride:i * stride kh, j * stride:j * stride kw] output[i, j] np.sum(region * kernel) return output这段代码里最容易被忽略的是padding的处理。padding的作用是保持边缘像素也能被卷积核覆盖到避免特征图尺寸迅速缩小。常见的padding方式有两种valid不填充和same填充后输出尺寸和输入相同。当时我第一版没写padding分支结果输出尺寸和预期始终对不上后来才意识到边缘像素被丢了多少。卷积核里的权重是网络自己学出来的不是人工设计。训练初始可以随机初始化之后通过反向传播不断调整。理解了这一点再看“为什么卷积核能识别边缘”这类问题答案就很明确因为训练数据中边缘特征反复出现梯度更新让网络自发把响应边缘的滤波器放大。2.2 池化层为什么能减小计算量又不丢关键信息池化层夹在卷积层之间主要干两件事压缩特征图尺寸增强特征的平移鲁棒性。最常见的最大池化是在每个2x2窗口里取最大值输出。这样特征图的高度和宽度都减半参数和计算量跟着降同时保留了窗口内最强烈的响应。有人会问池化不会丢信息吗确实会丢但丢的往往是“具体位置”信息保留的是“大概哪里有强响应”。比如一张人脸图池化后网络仍然知道眼睛区域有强边缘响应但不需要知道边缘具体在第几行第几列。这种模糊性反而让网络对轻微位移不敏感。在头歌关卡里池化层通常不会让人手写太复杂的实现更多是让你回答“池化的作用”或者“输出尺寸是多少”。我的建议是看到卷积层后跟池化层先算卷积输出尺寸再除以池化窗口大小一步步推下去不要跳步。比如输入32x32经过5x5卷积变成28x28再经过2x2池化变成14x14这个推导过程在LeNet-5的题目里经常出现。2.3 激活函数和全连接层在CNN里的分工卷积层和池化层负责“提取特征”全连接层负责“整合特征并分类”。这个过程很多人忽略了一点如果卷积层后面不接激活函数那么整个网络无论有多少层本质上都是线性变换的堆叠表达能力非常有限。所以每个卷积层后一般会接ReLU之类的非线性激活函数。ReLU就是max(0, x)计算简单梯度在正区间恒为1有效缓解梯度消失。我刚开始接触时不太理解为什么不用sigmoid后来在头歌的习题里对比过sigmoid在输入绝对值较大时梯度接近0深层网络反向传播时梯度一乘就消失ReLU要好得多。当然ReLU也有问题比如负区间梯度为0会让部分神经元“死亡”所以后来才有LeakyReLU、PReLU这些改进版本。全连接层放在网络尾部把前面卷积池化得到的特征图展平成一维向量通过一系列线性变换和激活函数最后经过Softmax输出每个类别的概率。卷积层像是一层层筛选简历把原始像素逐步提炼成“有没有横边缘”“有没有圆形轮廓”“看起来像不像眼睛”这类高层语义特征全连接层则像个面试官依据这些特征打总分、下结论。3. 从LeNet-5看经典CNN架构的设计逻辑3.1 网络结构逐层拆解与参数计算LeNet-5是卷积神经网络最经典的入门模型最早用于手写数字识别。我在头歌这一章里正好用了它做实验虽然结构相比现在的ResNet、EfficientNet已经很老了但它的设计思路至今仍值得琢磨。LeNet-5的输入是32x32的灰度图网络结构可以分成两个部分特征提取部分包含两个卷积层和两个池化层分类部分包含三个全连接层。原始论文里还有一个特殊的连接表用来控制C3层的卷积连接关系现代实现大多简化成全连接方式。我整理了一张逐层结构表层名类型核大小/步长输出尺寸可训练参数数量输入层灰度图-32x32x10C1卷积5x5 / 128x28x6(5x51)x6156S2平均池化2x2 / 214x14x612C3卷积5x5 / 110x10x16(5x5x61)x162416简化全连接版S4平均池化2x2 / 25x5x1632C5卷积5x5 / 11x1x120(5x5x161)x12048120F6全连接-84(1201)x8410164输出层全连接-10(841)x10850C1层输出为什么是28x28因为(32 - 5) / 1 1 28。S2层是对C1输出做2x2池化14x14。这个“卷积-池化-卷积-池化”的节奏非常有代表性每经过一次卷积和池化特征图尺寸变小通道数变多。高层特征图能覆盖更大的原始感受野所以能看到更宏观的图案结构。C3层输出10x10的特征图也是通过(14 - 5) / 1 1算出来的。C5层比较特殊输入是S4的5x5x16卷积核也是5x5相当于对整张特征图做了一次全连接操作输出1x1x120。到这一步空间信息已经被压榨完毕后面F6层和输出层就是普通的全连接网络。LeNet-5的总参数量大约6万个放在今天来看极其轻量。对比一下全连接网络处理同样输入所需的参数规模就能直观感受到局部连接和权值共享带来的压缩效果。3.2 从LeNet-5能学到的通用设计原则LeNet-5虽然简单但它奠定了CNN设计的几个重要原则。第一是特征图尺寸逐层递减、通道数逐层递增。尺寸递减是靠池化完成的通道数递增是靠增加卷积核数量实现的。这样做的好处是低层用少量卷积核提取简单边缘高层用大量卷积核组合复杂语义。第二是卷积层和池化层交替出现。单纯的卷积层堆叠虽然也能扩大感受野但计算量很大。加一个池化层虽然丢了一些空间细节但换来的是更小的特征图后续卷积就能在更大范围上建立联系。第三是分类头放在特征提取之后。网络前段是通用的特征提取器后段是专用的分类器。在迁移学习里大家经常“冻结”前段卷积层的权重只微调后段全连接层这就是利用了这个设计特点。我当时看邱锡鹏老师的教材时发现他在讲全连接网络到卷积网络的过渡时用了“稀疏交互”“参数共享”“等变表示”三个概念来概括CNN的本质配合LeNet-5骨架去理解这三个概念比直接啃论文舒服得多。如果你手上也有这本书建议把这一节和头歌实验对照着看。4. 头歌平台上卷积神经网络实训的实操要点4.1 关卡结构与测试脚本的阅读方法头歌的关卡通常由“任务描述”“相关知识”“测试说明”三部分组成。很多同学看到长长的“相关知识”就头大其实这里面的文字比课程课件要精简得多更多是给你一个代码框架让你补全关键函数。我的经验是先看“测试说明”弄清楚平台到底用什么数据测你、调用哪个函数再去读“相关知识”里对应的部分。以“神经网络学习之卷积神经网络”这一章为例它会围绕LeNet-5的构建和训练来出题。平台内部一般会准备好MNIST之类的手写数字数据集测试脚本会import你写的函数然后跑训练或推理最后比对输出结果。所以代码里的函数名、参数顺序、返回值类型必须严格和关卡要求一致不能自己改接口。头歌的实验环境是网页端预配置好的通常已经装好了PyTorch、TensorFlow或者Keras。你不需要自己搭环境但要注意平台对文件路径有严格限制训练数据、预训练权重一般放在指定目录别自己手动上传或者改路径否则提交后评测环境找不到文件就会白白报错。我那次就是因为自作聪明改了数据集缓存目录结果本地跑得好好的一提交就失败。4.2 用PyTorch从零搭建一个简化版LeNet头歌在CNN这块比较常见的任务是让学习者自己搭一个卷积神经网络模型。下面这个简化版LeNet是我在实训过程中使用的完整代码输入是MNIST的28x28图片相比原始LeNet的32x32输入做了一个小调整第一个卷积层用了padding2保证卷积后尺寸仍是28x28。先定义一个辅助函数用来计算卷积后的特征图尺寸方便验证每一步的输出形状import torch import torch.nn as nn import torch.optim as optim from torchvision import datasets, transforms from torch.utils.data import DataLoader def conv_out_size(in_size, kernel_size, padding0, stride1): return (in_size - kernel_size 2 * padding) // stride 1接下来定义模型主体。我把卷积池化部分放在features里全连接分类部分放在classifier里class SimpleLeNet(nn.Module): def __init__(self, num_classes10): super(SimpleLeNet, self).__init__() self.features nn.Sequential( nn.Conv2d(1, 6, kernel_size5, padding2), nn.ReLU(), nn.MaxPool2d(kernel_size2, stride2), nn.Conv2d(6, 16, kernel_size5), nn.ReLU(), nn.MaxPool2d(kernel_size2, stride2) ) self.classifier nn.Sequential( 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 x.view(x.size(0), -1) x self.classifier(x) return x这里最需要注意的是全连接层输入维度的计算。MNIST输入是28x28第一个卷积层padding2后输出还是28x28第一次池化后变成14x14第二个卷积层核大小5无padding输出变成(14-5)/1110第二次池化后变成5x5。所以features部分输出是5x5x16展平后是16x5x5400对应Linear层的输入维度就是400。如果写错这个数字模型初始化就会直接报维度不匹配错误。数据集加载和训练部分我习惯先对图像做归一化。MNIST的全局均值和标准差是固定的可以直接用0.1307和0.3081transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue)训练循环保持简洁用交叉熵损失和Adam优化器跑三个epoch一般就能看到损失明显下降model SimpleLeNet() criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) def train(epochs3): model.train() for epoch in range(epochs): running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() avg_loss running_loss / len(train_loader) print(fEpoch {epoch 1}, loss: {avg_loss:.4f})这个简化版LeNet的参数量只有6.2万左右在CPU上训练MNIST完全没压力。我在头歌本地环境跑的时候三个epoch只要一两分钟准确率能到98%以上。而且因为结构足够简单即使出现维度错误或者过拟合问题也容易排查。4.3 训练过程细节与超参数选择的思考训练CNN时学习率、batch size、epoch这几个超参数需要配合调整。我最初用0.01的学习率结果损失值震荡得很厉害后来改成0.001才稳定下来。原因是LeNet结构较浅Adam优化器本身适合较小的学习率过大的初始值会让权重更新幅度过大导致训练不收敛。batch size也是常见坑点。如果平台环境内存不大batch size设得过大容易直接内存溢出。28x28的灰度图还好但换到彩色大图时就要注意内存占用。我的习惯是先在64或者128这个小档位起步再根据显存情况调整。还有一个被很多人忽略的细节随机种子。头歌的评测环境如果要求结果复现最好在代码开头固定seed否则每次跑出来的模型初始化不同最终准确率会有轻微浮动。虽然没有随机种子也能过评测但固定seed能让调试过程更可控import random import numpy as np random.seed(42) np.random.seed(42) torch.manual_seed(42) if torch.cuda.is_available(): torch.cuda.manual_seed_all(42)注意不是所有头歌关卡都需要完全复现结果但固定随机种子是一个好习惯。在本地实验和平台评测结果出现差异时这能排除“随机性”这个变量。5. 常见问题速查与避坑经验5.1 典型报错与解决办法对照表我在刷完这一章和后来在本地训练CNN时积累了一些高频问题。整理成一张速查表遇到问题时可以直接对照问题现象常见原因解决办法报错size mismatch维度对不上全连接层输入维度算错用conv_out_size辅助函数逐步推导每层输出尺寸训练损失不下降准确率停留在10%左右学习率过大或过小数据没有归一化检查学习率建议从0.001开始确保输入归一化到0-1或标准化损失变为NaN学习率过大导致梯度爆炸降低学习率检查输入是否有NaN值训练集正确率很高测试集很低模型过拟合增加数据增强、正则化如Dropout、减小模型容量本地跑得好好的提交到平台就报错文件路径不一致或依赖版本不同检查是否使用了平台指定路径不要在代码里依赖本地绝对路径准确率每次跑都不一样没有固定随机种子在代码开头固定torch、numpy、random的随机种子平台提示“输出格式错误”函数返回类型或顺序与测试脚本不一致仔细比对关卡给出的代码框架不要自己改函数签名5.2 让我印象深刻的三个调试经历第一个经历是关于维度不对的。我在写SimpleLeNet时把第二个卷积层后的池化尺寸算成了6x6原因是忘了第一个卷积层加了padding2导致尺寸没缩小。当时一运行就报Linear层维度不匹配我对着报错信息花了几分钟才意识到问题出在前面而不是后面。从那以后我每次都会在模型forward里加打印语句或者用torchsummary库看每层的输出形状再也不靠心算。第二个经历是损失值明明在降但平台评测就是不通过。后来发现是数据集加载方式的问题。头歌平台里给的MNIST数据可能是已经处理好的.npy文件而不是torchvision能直接download的格式。本地跑torchvision下载没问题平台评测环境不一定允许联网下载数据所以必须按关卡提示的数据加载方式走。这也是为什么我一直强调“先看测试说明和代码框架再开始写模型”。第三个经历是卷积核可视化。为了搞清楚网络到底学到了什么我把第一个卷积层的6个卷积核打印出来看了看。训练前它们看起来像随机噪声训练后有些核能看出明显的边缘响应模式有的偏横向有的偏纵向。看到那张图的时候我才真正理解了“卷积核自动学习特征”这句话的含义。如果你也在做CNN实验强烈建议试试可视化卷积核比干看代码直观得多。5.3 从实训延伸到真实项目的建议头歌这一章做完基本就算是把CNN的入门路走通了。我自己的体会是平台实验只是一个起点要想真正用起来需要往这几个方向再做延伸。第一是学会用数据增强。真实场景的图片不像MNIST那么规整会有旋转、裁剪、亮度变化。用PyTorch的transforms模块可以轻松实现随机翻转、随机裁剪、色彩抖动这些操作能显著提升模型泛化能力。第二是关注更现代的CNN结构。LeNet-5是1998年的模型现在常用的ResNet引入了残差连接解决深层网络退化问题EfficientNet用复合缩放统一调整深度、宽度和分辨率MobileNet用深度可分离卷积大幅减少参数适合移动端部署。理解了LeNet-5再看这些结构会发现核心思想一脉相承只是组合方式更复杂了。第三是可以关注CNN在专用硬件上的部署趋势。现在很多竞赛和实际项目都会涉及通用神经网络处理器下的算子调度、内存优化核心就是把卷积计算映射到硬件上。这些内容虽然底层但能把模型训练和工程部署串起来对找工作或者做落地项目都很有帮助。搞懂卷积的原理之后你会发现无论是纯软件框架还是专用硬件加速核心都是“如何高效地完成乘加运算”。这一点想通了以后看到各种加速方案就不会觉得玄乎。我个人练这一章时最大的体会是代码能跑通只是第一步真正吃透还是要把每层的输入输出尺寸、参数量、感受野变化一步步手推一遍。头歌这个平台的好处是它逼着你动手坏处是很多人动手前没想清楚写错了就反复试浪费时间。建议你每写一个模块先做几分钟纸面推导再动手敲代码表面上慢了实际反而快很多。如果做实验时遇到具体问题欢迎按着上面那张速查表先自查一遍大部分坑都是可以自己排查解决的。
返回列表