ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MATLAB实现CNN手写数字识别:卷积、池化、Dropout与动量详解

MATLAB实现CNN手写数字识别:卷积、池化、Dropout与动量详解 简介这是一套基于MATLAB实现的CNN手写数字识别完整工程面向深度学习初学者或数字图像识别实验者解决从MNIST数据加载、模型搭建到训练评估的一体化实践需求。压缩包共14个文件以13个MATLAB脚本为主体另含1份PDF说明文档整体仅1.27MB脚本覆盖卷积层、池化层、ReLU、Softmax、Dropout正则化等关键模块并配有不同版本的CNN主程序便于对照理解模型改进思路。该方法以MNIST数据集为基础对09手写数字进行分类识别展示卷积神经网络在图像特征自动提取与分类任务中的应用流程。已有437人学习下载适合想通过可运行代码快速上手CNN原理、观察不同结构对识别效果影响的读者包内文件划分清晰从底层卷积操作到完整训练验证均有对应实现可作为课程设计或入门实践的参考资料。1. CNN手写数字识别这份MATLAB代码包把“从零到能跑”的路一次走通如果你在MATLAB里从零搭过一个CNN做手写数字识别大概率经历过这种窘境教程看了十几篇卷积、池化层的概念都懂真到写代码时光是特征图尺寸的推导就能卡一下午。这份代码包的价值在于它把卷积神经网络里每一层都拆成了独立的.m文件——Conv.m做卷积、Pool.m做池化、ReLU.m做激活、Softmax.m做输出还附了三个主程序入口和带Dropout、带动量项的两个变体。你不需要先读懂每行矩阵运算才能把它跑起来但想改参数、换结构、加正则化时每一层的边界都划得很清楚。适合正在学深度学习、需要复现MNIST实验或者想把手写体识别迁移到自己数据集上的MATLAB用户。2. 读懂网络结构卷积-池化-全连接链路与Conv.m、Pool.m、ReLU.m的实现细节2.1 从28×28到10个分类分数一张图走完前向传播MNIST里每张图是28×28的灰度图。CNN处理它的思路和全连接网络完全不同全连接层把784个像素拉成一根长向量像素之间的空间位置关系直接丢失卷积层则用一个小卷积核在图上滑动每次覆盖一个局部区域把局部像素组合成特征。这个差别在手写数字上非常直观——数字“7”的斜线、“0”的圈圈都是由相邻像素构成的局部纹理卷积核捕捉的正是这些局部模式。代码包里Conv.m实现的是valid卷积步长1不补零。假设输入尺寸是h×w卷积核是k×k输出尺寸就是(h-k1)×(w-k1)。28×28的图像过一遍5×5卷积核得到24×24的特征图再过一遍2×2池化变成12×12继续卷第二层的话就变成8×8再池化得到4×4。这条尺寸链必须自己在草稿纸上推一遍因为后面全连接层的输入维度全靠它决定。全连接层把这些特征拉平输出10个分数最后过Softmax转成10类数字的概率。ReLU.m的实现是三行里最简单的核心就一句max(0, input)。为什么不用sigmoid因为sigmoid在两端梯度趋近于0网络稍微深一点梯度就传不回去ReLU在正区间梯度恒为1虽然负区间会把神经元直接杀死但在MNIST这种规模的小网络上ReLU的稳定性和收敛速度都明显更好。Softmax.m负责把全连接层输出的10个分数转成概率。这里有一个MATLAB新手很容易踩的坑直接对原始分数做exp当某个分数偏大时exp(x)会溢出成Inf最后整个输出变成NaN。标准做法是先减去这组分数的最大值再做exp因为概率比的是相对大小减掉同一个常数不改变最终结果function p Softmax(x) % x是1×10的全连接层输出分数 x x - max(x); % 数值稳定防止exp溢出成Inf ex exp(x); p ex / sum(ex); end这段代码的逻辑是先找到10个分数里的最大值并减掉保证exp的输入不超过0然后求指数、归一化成概率。参数只有一个输入向量x输出p是10个概率之和为1的行向量。在训练阶段这个p会和one-hot标签一起算交叉熵损失在测试阶段直接取p里最大值的下标就是预测的数字。2.2 Conv.m与Pool.m的关键实现步长、边界和特征图尺寸的算法Conv.m的核心逻辑是拿卷积核在输入图像上逐位置滑动每次取k×k的局部块做逐元素乘法和求和。单通道输入下的核心实现长这样function output Conv(input, W, b) % input: h×w 单通道灰度图 % W: k×k 卷积核b: 偏置标量 % 输出: (h-k1)×(w-k1) 特征图 [h, w] size(input); [k, ~] size(W); out_h h - k 1; out_w w - k 1; output zeros(out_h, out_w); for i 1:out_h for j 1:out_w patch input(i:ik-1, j:jk-1); output(i, j) sum(sum(patch .* W)) b; end end end这里out_h和out_w直接由输入尺寸减卷积核尺寸加1得到因为步长是1且不补零。每个位置取出的patch尺寸和卷积核一致逐元素相乘后求和再加偏置就得到特征图上一个点的值。如果想在MNIST上提升准确率最常见的改动是把5×5卷积核换成3×3或者给输入补一圈零让卷积输出保持原尺寸。补零的好处是边缘像素也能被卷积核完整覆盖否则图像四周的像素只参与很少几次卷积计算信息容易被丢掉。MATLAB里补零的做法是input_padded zeros(h2, w2); input_padded(2:end-1, 2:end-1) input;Pool.m做的是最大池化默认窗口2×2、步长2实现如下function output Pool(input, pool_size, stride) % 最大池化取窗口内最大值实现降采样 [h, w] size(input); out_h floor((h - pool_size) / stride) 1; out_w floor((w - pool_size) / stride) 1; output zeros(out_h, out_w); for i 1:out_h for j 1:out_w row_start (i-1)*stride 1; col_start (j-1)*stride 1; patch input(row_start:row_startpool_size-1, ... col_start:col_startpool_size-1); output(i, j) max(patch(:)); end end end池化有两个作用一是把特征图尺寸降下来减少后续全连接层的参数数量防止过拟合二是让特征对小幅平移更鲁棒——数字整体偏移几个像素池化窗口内的最大值变化很小。这里用floor计算输出尺寸意味着当输入尺寸不能被stride整除时末尾几行几列会被直接丢弃这是后面避坑章节要专门讲的问题。需要特别提醒的是这些层文件本身不保存任何状态全部依赖调用者把权重参数传入传出。这种“层是纯函数”的设计很适合学习——想打印中间特征图直接在这几个调用语句后面加一行figure和imshow就能看到卷积层到底提取了什么。相比封装好的深度学习工具箱这种手写实现虽然慢一点但每一步都在你眼前黑匣子的感觉会小很多。3. 跑通训练主程序CNNmain.m的数据加载、批大小与损失统计3.1 MNIST数据格式与加载ubyte二进制的大端字节序怎么读MNIST数据集原始文件是ubyte格式的二进制不是常见的图片文件。每个文件前面有一段头部信息图像文件是16字节标签文件是8字节后面才是真正的数据。MATLAB的fread默认按小端字节序读而MNIST头部是按大端字节序存的所以必须显式声明ieee-be否则读出来的magic number、样本数量全是反的整个文件解析直接错位。读图像文件的函数骨架如下function images loadMNISTImages(filename) % 读取MNIST图像文件返回 28×28×N 的数组 fid fopen(filename, rb); magic fread(fid, 1, int32, 0, ieee-be); % 应为2051 numImages fread(fid, 1, int32, 0, ieee-be); rows fread(fid, 1, int32, 0, ieee-be); cols fread(fid, 1, int32, 0, ieee-be); data fread(fid, inf, uint8); images reshape(data, cols, rows, numImages); images permute(images, [2 1 3]); % 交换前两维得到正常方向的图像 images images / 255.0; % 归一化到 [0,1] fclose(fid); end这里有一个值得注意的细节MNIST二进制里像素按列优先存储如果直接reshape成rows×cols图像是横躺着的必须用permute交换前两维才能看到正常的数字方向。标签文件更简单读掉8字节头后直接fread剩余字节每个字节的值就是0到9的数字。很多人在这一步翻车不是因为代码写错而是没意识到MNIST本身就是尺寸统一、灰度统一的干净数据集不像处理日常图片那样需要缩放、灰度化、去背景。这份代码包能顺利跑通很大程度归功于数据预处理被简化到了极致。加载完成后通常还要把训练集切成两份一份用于训练一份作为验证集用来观察模型在没见过数据上的表现。3.2 训练循环里的超参数学习率、批大小和epoch的配合方式主程序里的训练循环骨架是两层for外层遍历epoch内层按batch遍历训练集每个batch内做一次前向传播、一次反向传播、一次参数更新。一个典型的训练循环长这样epochs 5; learning_rate 0.01; batch_size 100; numBatches floor(size(Xtrain, 3) / batch_size); for epoch 1:epochs totalLoss 0; % 每个epoch打乱训练顺序避免模型记住固定排列 order randperm(size(Xtrain, 3)); for b 1:numBatches idx order((b-1)*batch_size1 : b*batch_size); Xbatch Xtrain(:, :, idx); Ybatch Ytrain(:, idx); % 前向传播得到预测和中间缓存 [pred, cache] forward(Xbatch, W1, b1, W2, b2); loss crossEntropy(pred, Ybatch); % 反向传播计算梯度 grad backward(pred, Ybatch, cache, W2); % 参数更新 W1 W1 - learning_rate * grad.dW1; b1 b1 - learning_rate * grad.db1; W2 W2 - learning_rate * grad.dW2; b2 b2 - learning_rate * grad.db2; totalLoss totalLoss loss; end fprintf(epoch %d, avg loss %.4f\n, epoch, totalLoss / numBatches); end这里的核心参数有三个。learning_rate取0.01在MNIST这种小规模数据上是安全的起点loss下降太慢就往0.03、0.05试一旦出现NaN立刻除以10回退batch_size取100影响的是梯度估计的噪声程度和内存占用对MATLAB来说100张图完全不构成压力epochs先设5看趋势如果最后一个epoch的loss还在明显下降继续往上加到10或15。容易被忽略的一步是每个epoch开始前的randperm打乱。如果一直按原始顺序训练模型会记住样本的排列顺序验证集准确率会忽高忽低。这个打乱操作不是锦上添花是训练过程稳定性的基本保障。代码包里出现了CNNmain.m和CNNmain2.m两个主程序。从命名看后者应该是换了一种网络结构的对比版本比如加深了卷积层或调整了全连接层维度。我建议把两个主程序里网络结构定义的部分并排diff一下训练曲线的差异就能直接反映出结构改动的影响——这是理解CNN结构最直观的对照实验。4. 防止过拟合和加速收敛Dropout与动量项的代码级对照4.1 Dropout层训练时随机屏蔽神经元测试时必须完全关闭MNIST训练集有6万张图但类别只有10种。网络参数一旦多起来模型很容易把训练集“背下来”在测试集上表现反而差。Dropout是应对这个问题最常用的正则化手段思路非常朴素训练时让每个神经元以一定概率临时失效迫使网络不能过度依赖任何一个神经元从而学会更冗余、更鲁棒的特征表达。Dropout.m的核心实现就三行function output Dropout(input, ratio) % input: 输入特征图或向量 % ratio: 神经元保留概率训练时通常取0.5测试时必须取1 mask rand(size(input)) ratio; % 按概率生成0/1掩码 output input .* mask / ratio; % 除以ratio保持输出期望不变 endmask这一步生成一个与input同尺寸的0/1矩阵rand产生0到1的均匀随机数小于ratio的位置为1其余为0。把input和mask逐元素相乘后再除以ratio目的是让训练时的输出期望与测试时保持一致——如果训练时一半神经元被关掉却不缩放全连接层输出的数量级会被直接砍半测试时神经元全部恢复预测分数就全乱了。代码包里CNNdropout.m和CNN2dropout.m的区别从命名看一个是在第一套基础网络结构上加了Dropout另一个是在第二套结构上加的。Dropout加的位置也很有讲究加在卷积层后面正则化的是特征图本身加在全连接层前面正则化的是分类器。就MNIST的经验而言参数主要集中在全连接层所以加在全连接前收益最明显卷积层因为参数共享、过拟合风险相对较低没必要每个卷积层后面都接Dropout。4.2 动量项让参数更新带惯性摆脱局部极小平稳收敛纯梯度下降的更新是 W W - lr * grad每一步只看当前batch的梯度方向batch之间的梯度波动大、收敛路径绕。加了动量之后更新量不仅包含当前梯度还保留上一轮的“速度”相当于给参数更新加了惯性方向抖动被平滑收敛速度明显加快。带动量的更新逻辑一般长这样% 训练循环外初始化速度项维度与权重一致 vW1 zeros(size(W1)); vW2 zeros(size(W2)); alpha 0.9; % 动量系数保留上一轮速度的比例 % 每次反向传播得到梯度后执行 vW1 alpha * vW1 - learning_rate * grad.dW1; W1 W1 vW1; vW2 alpha * vW2 - learning_rate * grad.dW2; W2 W2 vW2;动量系数alpha取0.9是常见配置含义是上一轮的速度保留90%。这个值越大更新路径越平滑但设到0.99这种极端值时参数会在最优点附近来回震荡停不下来。CNNmain2mmt.m这个文件名里的“mmt”指的就是在第二套网络结构上加了动量训练时配合CNNmmt.m一起用。这里有一个几乎所有人第一次都会踩的坑只改了CNNmmt.m里的参数更新代码忘了在CNNmain2mmt.m里把速度项和权重一起初始化结果第一轮迭代就报“Unrecognized function or variable vW1”。速度项必须在训练循环外、和权重同维度初始化这个细节不是语法问题而是对动量机制理解不到位——动量项是跨batch存在的状态不是某个batch内的临时变量。为什么要专门维护一个带动量的版本因为MNIST上的实际经验是纯SGD要达到95%以上的测试准确率通常需要更多epoch加动量后同样的epoch数就能明显快不少。如果你还想叠加学习率衰减把learning_rate改成每轮epoch乘0.95这样的递减因子即可与动量并不冲突。5. 避坑指南MNIST识别里最容易翻车的五个位置5.1 标签没转one-hot全连接层维度直接报错现象前向传播走到全连接层时MATLAB报“Matrix dimensions must agree”或者损失值在几步之后变成NaN。 原因MNIST标签文件里存的是0到9的整数。如果直接拿这个整数向量和网络输出的10维概率做损失计算矩阵维度不匹配即便维度恰好对上了语义也是错的。交叉熵损失要求标签是10×n的one-hot矩阵每一列只在对应类别位置为1。 解决在训练开始前把整数标签一次性转成one-hot矩阵不需要每个batch都转换。function Y onehot(labels, numClasses) % labels: 1×n 整数标签取值范围0-9 % numClasses: 类别数这里是10 n numel(labels); Y zeros(numClasses, n); idx sub2ind([numClasses, n], labels(:) 1, 1:n); Y(idx) 1; end这里labels要加1是因为MNIST的标签是0到9而MATLAB矩阵索引从1开始。这个函数放在数据加载之后调用一次后面训练循环里直接用one-hot矩阵能省掉不少麻烦。5.2 池化边界丢弃特征图尺寸越算越对不上现象手动推导时明明算好了特征图尺寸跑到某一层却发现矩阵索引超出范围或者全连接层的输入维度报错。 原因Pool.m里用的是floor((h - pool_size)/stride)1。当输入尺寸是奇数或者不能被stride整除时边缘像素会被直接丢弃。比如13×13的特征图做2×2池化输出尺寸是6×6而不是7×7最底下一行和最右一列永远不会进入池化窗口。 解决要么接受边缘丢弃计算全连接层输入维度时严格按实际输出尺寸来要么在池化前给特征图补零。实际调试时我建议在每层后面打印一次尺寸逐层核对别靠心算。fprintf(after conv: %d x %d\n, size(F1)); fprintf(after pool: %d x %d\n, size(P1));5.3 Dropout被带进测试阶段验证准确率断崖式下跌现象训练集上准确率一路涨到97%测试集却只有70%上下差距大得不正常。 原因训练时用了Dropout测试阶段的前向传播却忘了把ratio设回1。测试阶段模型必须是确定性的任何随机屏蔽都会让预测结果不稳定多次对同一张图预测还可能得到不同结果。 解决在主程序里用is_training开关区分训练段和测试段。训练阶段保留Dropout测试阶段跳过或把ratio设1。if is_training H Dropout(H, 0.5); else H H; % 测试阶段不屏蔽任何神经元 end5.4 学习率过大loss在中途变成NaN现象训练前几步loss正常下降某一步突然变成NaN之后无论如何都回不到正常值。 原因学习率太大某次梯度更新幅度超过参数合理范围前向传播里的exp或log遇到了无效输入数值直接崩掉。 解决把learning_rate从0.01降到0.001或者检测到loss为NaN时把学习率除以10重新来。快速判断梯度是否爆炸的方法是打印第一层权重的最大绝对值如果数值已经到几十上百基本可以实锤。fprintf(max |W1| %.4f\n, max(abs(W1(:))));5.5 输入未归一化训练半天loss纹丝不动现象模型能跑通但loss下降极慢几十个epoch后准确率还在60%附近徘徊。 原因像素值0到255直接进网络而权重初始化通常在0附近的小值。大范围的输入会让梯度的尺度过大参数更新忽大忽小训练不稳定。 解决把像素除以255将范围压到[0,1]。更讲究的做法是计算整个训练集的均值和标准差做标准化让输入分布更接近标准正态。loadMNISTImages函数最后一步images images / 255.0做的就是这个事。6. 把这份代码改造成你自己的手写识别器三步验证与迁移技巧代码包在MNIST上跑通只是第一步多数人拿到它是要解决自己的问题——识别课程作业里扫描的手写公式、票据上的数字或者单纯想把网络结构改成自己的设计。这时候直接换数据是不行的我一般强制自己走完三步验证再动网络结构。第一步在训练集里留出5000张做验证集不要用测试集调参。MNIST测试集是最终衡量标准用它调参等于考试前偷看答案准确率虚高。把训练集切出5000张当验证集每个epoch结束后在验证集上算一次准确率画成曲线训练loss和验证准确率一起看才能判断是欠拟合、过拟合还是学习率不合适。第二步可视化第一层卷积核和中间特征图。CNN的手写实现最大的好处就是中间状态可以随便打出来。在Conv调用后面加一行figure; imshow(output, []);你会看到第一层卷积核学出来的是边缘检测器、斜线响应和局部斑块这些就是数字的笔画特征。如果卷积核看起来全是一片噪声说明训练没收敛别急着调网络结构先回去查学习率和数据预处理。第三步换自己的数据时把所有输入统一成28×28灰度图、single类型、归一化到[0,1]。你扫描的手写图通常有几百万像素直接resize到28×28会糊成一团。先做二值化、居中裁剪、按重心对齐再缩放。MNIST本身是居中的你的图如果字体偏一边池化层的平移鲁棒性也救不回来。这一步偷懒准确率直接从95%掉到70%。我自己就栽在过这个坑上。有次拿这套代码跑课程设计的扫描件忘了做二值化和居中裁剪准确率卡在70%多。当时不知道去看卷积核像个黑匣子一样盲调了两天最后发现根本不是网络的问题是预处理的问题。从那以后我每次跑这套代码都会强制走一遍“打印每层尺寸、看第一层卷积核、用验证集挑epoch”的流程这三步走完网络结构基本不用大改。希望帮到你。本文还有配套的精品资源点击获取
返回列表