简介:基于MATLAB实现卷积神经网络(CNN)的手写数字识别,这份资源面向深度学习初学者、MATLAB用户及图像识别入门者,解决从数据准备到模型训练的核心流程落地问题。脚本以MNIST手写数字库为对象,覆盖60000个训练样本与10000个测试样本的导入、像素归一化等预处理,并按卷积层、池化层、全连接层结构搭建CNN;利用卷积层提取边缘、线条等局部特征,池化层降低数据维度,全连接层完成分类,同时涉及ReLU激活、交叉熵损失、SGD或Adam优化器以及dropout、早停等调优手段,训练完成后可对测试集预测并计算准确率。压缩包共1个文件,为M格式MATLAB脚本,大小约2KB,代码精简集中,适合作为课程设计、实验复现或算法练手的基础参考。该资源已有323人学习浏览,对正在入门CNN或希望在MATLAB中快速实现数字识别模型的读者有直接借鉴价值。
1. 在 MATLAB 里跑通 CNN 手写数字识别,先卡的是数据链路
真正把 CNN 手写数字识别在 MATLAB 里跑通的人都知道,最折磨人的不是网络结构怎么搭,而是 MNIST 数据怎么进来。torchvision 那边偶尔会遇到下载 404,MATLAB 这边也从来没有开箱即用的 downloadMNIST();网上打包好的 .mat 文件版本又杂,标签对不上、灰度范围不统一,训出来的模型准确率连 90% 都不到。下面这套流程要解决的就是从原始 IDX 字节流到训练再到部署的完整链路,适合第一次用 Deep Learning Toolbox 做图像分类的工程师,也适合想把 LeNet-5 这类经典 CNN 结构在 MATLAB 里完整复现一遍的熟手。只需要 Deep Learning Toolbox,不需要额外的硬件,R2021a 之后任意版本都能跟着做,最后拿到一个能上 99% 的手写数字分类器。
2. MNIST 数据读入 MATLAB:解析 IDX 原始文件并做归一化
2.1 自己解析 MNIST 原始文件,别依赖来路不明的 .mat
MNIST 官方发布的是四个 IDX 格式文件:train-images.idx3-ubyte、train-labels.idx1-ubyte、t10k-images.idx3-ubyte、t10k-labels.idx1-ubyte。IDX 格式本身很简单:开头 4 字节是魔数,接着按维度各 4 字节描述张量形状,然后就是裸数据。训练图像文件里,魔数 2051 后面跟着样本数 60000、行数 28、列数 28,之后是 60000 张 28×28 的 uint8 像素;标签文件的魔数是 2049,后面只有样本数,剩下的是 0 到 9 的标签字节。
常见做法是直接写一个 readMNIST 解析函数,把这两个文件读出来。注意 MNIST 的字节流是大端序,MATLAB 的 fopen 默认按本机字节序读,这里必须显式指定'ieee-be',否则魔数会变成 0x08030000 这种被翻转的值,读出来的数值就全错了。
还有一点需要留神:网上流传的 .rar 压缩包里经常混着 CSV 版本的 MNIST,配套博客会让你用 csvread 读像素列。CSV 版当然也能用,但解析速度比二进制慢一个数量级,而且你还要自己确认列顺序是不是「标签在前、784 像素在后」。我一般只认官方 IDX 二进制文件。
function [images, labels] = readMNIST(imageFile, labelFile) % 读图像文件,注意 'ieee-be' 大端 fid = fopen(imageFile, 'rb', 'ieee-be'); magic = fread(fid, 1, 'uint32'); numImages = fread(fid, 1, 'uint32'); rows = fread(fid, 1, 'uint32'); cols = fread(fid, 1, 'uint32'); raw = fread(fid, inf, 'uint8=>uint8'); fclose(fid); % MNIST 按行扫描存储,MATLAB reshape 按列填充,要先反置再转置 images = reshape(raw, [cols, rows, numImages]); images = permute(images, [2 1 3]); % 补上 channel 维和 batch 维:高、宽、通道、样本 images = reshape(images, [rows, cols, 1, numImages]); % 读标签文件 fid = fopen(labelFile, 'rb', 'ieee-be'); magic = fread(fid, 1, 'uint32'); numLabels = fread(fid, 1, 'uint32'); labels = fread(fid, inf, 'uint8'); fclose(fid); % 直接转 categorical,分类层要求标签是 categorical 类型 labels = categorical(labels); end这段代码的关键在 reshape 那两行。MNIST 文件里每个像素是按「第一行从左到右、再第二行」的顺序连续存放的,而 MATLAB 的 reshape 是按列填充的,直接 reshape 成 28×28 会把整张图转置 90 度并镜像。所以先撑成[cols, rows, numImages],再用 permute 换成[rows, cols, numImages],最后 reshape 成[rows, cols, 1, numImages],这四个维度正好对应 imageInputLayer 要求的 HWC 加 batch 维。
调用时这样写:
[trainImages, trainLabels] = readMNIST('train-images.idx3-ubyte', 'train-labels.idx1-ubyte'); [testImages, testLabels] = readMNIST('t10k-images.idx3-ubyte', 't10k-labels.idx1-ubyte');MNIST 的四个文件头部信息可以按下表核对,解析前先用它确认文件没被截断:
| 文件 | 魔数 | 后续维度字段 | 数据量 |
|---|---|---|---|
| train-images.idx3-ubyte | 2051 | 样本数 60000、行 28、列 28 | 60000×28×28 字节 |
| train-labels.idx1-ubyte | 2049 | 样本数 60000 | 60000 字节 |
| t10k-images.idx3-ubyte | 2051 | 样本数 10000、行 28、列 28 | 10000×28×28 字节 |
| t10k-labels.idx1-ubyte | 2049 | 样本数 10000 | 10000 字节 |
2.2 像素归一化的三种方式对比
读进来的 trainImages 是 uint8 类型,范围 0 到 255。直接喂给网络不是不行,但初始梯度会被放大,早期训练不稳定。常见处理是把值域压到[0,1]或[-1,1],MATLAB 里三种做法:
single(trainImages) / 255:最简单,占用内存最小,网络收敛速度足够。
rescale(trainImages):默认把每个样本独立缩放到[0,1],注意它是按单张图的最小最大值算的,MNIST 背景是 0、笔画最亮 255,结果和除以 255 一样,但如果你后面接自定义图像,行为可能不同。
标准化到[0,1]后再减均值:比如减 0.1307、除 0.3081,这是 PyTorch 里常用的 MNIST 统计量。在 MATLAB 里对 28×28 的灰度图做全局均值减法收益不大,因为 MNIST 图像的亮度和对比度非常稳定,不需要复杂归一化。
我一般直接用single(trainImages) / 255,这一行就够。另外 trainImages 是 uint8 时 trainNetwork 会自己转 single,但显式转换能省掉训练过程中的类型转换开销。还有一点:不要用 mat2gray,它在归一化同时会把类型也转成 double,训练数据从 60000×28×28×1 的 double 数组会直接吃掉 1.3 GB 内存,而 single 只有一半。
2.3 划分验证集并保存为 mat 文件
官方训练集 60000 张,测试集 10000 张。常见做法是在训练集里再切出一部分做验证集,比如最后 5000 张,用于观察过拟合。不需要自己写随机切分,直接索引切片即可:
valImages = trainImages(:,:,:,55001:60000); valLabels = trainLabels(55001:60000); trainImages = trainImages(:,:,:,1:55000); trainLabels = trainLabels(1:55000);切分后执行save('mnist_prepared.mat', 'trainImages', 'trainLabels', 'valImages', 'valLabels', 'testImages', 'testLabels'),后续训练脚本直接 load。注意变量名要保留这六个名字,否则下次加载时还得重新赋值。这也是处理 .rar 下载包里.mat文件最稳妥的方式:自己解析一次,后面所有实验都复用同一份干净数据。
提示:用验证集而不是测试集来调参。测试集只在最终评估时碰一次,否则你调出来的数字会虚高,部署后用户的真实手写字体一进来准确率立刻掉。
3. CNN 网络结构:按 LeNet-5 变体搭建卷积层与全连接层
3.1 为什么手写数字场景用 5×5 卷积核
MNIST 图像只有 28×28,数字的笔画宽度通常在 1 到 3 个像素。5×5 卷积核的感受野是 5×5,覆盖笔画交叉、断点和边缘拐角的典型尺度,既不会像 3×3 那样需要堆叠更多层才能看到大范围结构,也不会像 7×7 那样在 28×28 输入上过早丢失分辨率。经典 LeNet-5 就是 5×5 卷积加 2×2 最大池化的组合,MNIST 在 LeNet 结构上能稳定跑到 99% 以上,这个结论几十年没变过。
在 MATLAB 里搭 CNN(卷积神经网络)有两种方式:layers 数组适合这种线性的 LeNet-5 结构;layerGraph 适合有残差连接或并行分支的网络。手写数字识别用不到分支,直接给 trainNetwork 传一个 layers 数组就够了。相比用底层语言手搓 BP 神经网络要自己实现反向传播,MATLAB 里声明完 layers 数组,梯度更新由工具箱内部完成,这也是新手能最快拿到结果的原因。
3.2 layers 数组的最小完整定义
layers = [ imageInputLayer([28 28 1], 'Normalization', 'none', 'Name', 'input') convolution2dLayer(5, 6, 'Padding', 0, 'Name', 'conv1') reluLayer('Name', 'relu1') maxPooling2dLayer(2, 'Stride', 2, 'Name', 'pool1') convolution2dLayer(5, 16, 'Padding', 0, 'Name', 'conv2') reluLayer('Name', 'relu2') maxPooling2dLayer(2, 'Stride', 2, 'Name', 'pool2') fullyConnectedLayer(120, 'Name', 'fc1') reluLayer('Name', 'relu3') fullyConnectedLayer(84, 'Name', 'fc2') reluLayer('Name', 'relu4') fullyConnectedLayer(10, 'Name', 'fc3') softmaxLayer('Name', 'softmax') classificationLayer('Name', 'output') ];各层选择理由逐条说:
- imageInputLayer 的 Normalization 设成
'none',因为数据已经在第 2 章归一化到[0,1],工具箱不需要再重复处理。 - conv1 输出通道 6,模型容量小,正则化压力小,训练稳定。conv2 输出通道 16,是 LeNet-5 的原始配置。在 MATLAB 中加大到 32/64 确实能涨点,但训练时间和过拟合风险一起涨,对 28×28 灰度图没有必要。
- 每层卷积后跟 ReLU,不使用 sigmoid 或 tanh。ReLU 的梯度在正区间恒为 1,配合 Adam 优化器不容易出现梯度消失。
- 全连接层拆成 120 和 84 两段,中间插 ReLU。直接一步从 4×4×16 降到 10 也能收敛,但中间层保留高维特征能让网络学习更丰富的笔画组合。
- fc3 输出 10 对应数字 0 到 9,categorical 标签自动按类别排序,不需要手动做 one-hot。
网络层的输出尺寸变化可以用 analyzeNetwork 验证。训练前先跑一下:
analyzeNetwork(layers);analyzeNetwork 会把这段 layers 渲染成一张交互式 CNN 结构图,按层显示激活尺寸和参数数量。看到 conv1 输出 24×24×6、pool1 输出 12×12×6、conv2 输出 8×8×16、pool2 输出 4×4×16,fc1 的输入正好是 4×4×16=256,这条链路就完全对得上。如果哪两层维度接不上,analyzeNetwork 会直接报错并指出问题位置。
3.3 层参数速查表
| 层 | 关键参数 | 默认值 | 手写数字场景推荐值 |
|---|---|---|---|
| convolution2dLayer | FilterSize, NumFilters | 无默认 | 5, 6 / 5, 16 |
| convolution2dLayer | Padding | 0 | 0 |
| reluLayer | 无 | - | - |
| maxPooling2dLayer | PoolSize, Stride | PoolSize=2, Stride=1 | 2, 2 |
| fullyConnectedLayer | OutputSize | 无默认 | 120 / 84 / 10 |
| softmaxLayer | 无 | - | - |
注意 maxPooling2dLayer 的 Stride 默认是 1,不是 2。如果不显式写'Stride', 2,28×28 的图像经过池化之后尺寸不会减半,后面的 4×4 特征图会变成 7×7,fc 层输入维度就变了。这个坑很多从 PyTorch 迁移过来的同事容易踩,PyTorch 的 MaxPool2d 默认 stride 等于 kernel size,MATLAB 不是。
4. 训练选项调参:学习率、批次大小与验证策略
4.1 trainingOptions 的核心参数
网络结构定下来后,trainNetwork 的训练行为完全由 trainingOptions 控制。同样一个网络,学习率差一个数量级,结果可能从 99% 掉到 90%。我常用的配置是这样:
options = trainingOptions('adam', ... 'InitialLearnRate', 0.001, ... 'MaxEpochs', 10, ... 'MiniBatchSize', 128, ... 'ValidationData', {valImages, valLabels}, ... 'ValidationFrequency', 30, ... 'Shuffle', 'every-epoch', ... 'Plots', 'training-progress', ... 'ExecutionEnvironment', 'auto', ... 'VerboseFrequency', 50); net = trainNetwork(trainImages, trainLabels, layers, options);参数说明:
- 优化器用 adam。sgdm 对学习率更敏感,momentum 默认 0.9,初始学习率要压到 0.01 以下才稳;adam 自带一阶二阶矩估计,0.001 起步基本不会炸。
- MiniBatchSize 128:batch 太小梯度噪声大、训练慢;太大内存占用高。对 28×28 小图来说 128 到 256 都是合理区间,显存紧张就降到 64。
- ValidationFrequency 30:每 30 个 iteration 跑一次验证集。太频繁会拖慢训练,太少则验证曲线只有几个点看不清楚。10 个 epoch 对应大约 4300 个 iteration,验证点大概 140 个,够了。
- Shuffle
'every-epoch':每个 epoch 开始前打乱训练顺序,避免网络记住样本排列顺序。如果设置成'never',某些 epoch 的梯度方向会周期性重复,收敛曲线会出现明显波动。 - ExecutionEnvironment
'auto':有 GPU 用 GPU,没有就回退 CPU。可以用 gpuDevice 确认 GPU 是否可用。
trainNetwork 的返回值 net 是 SeriesNetwork 对象,后续 classify 和 exportONNXNetwork 都靠它。
4.2 训练曲线怎么看
Plots 设成'training-progress'后,训练过程中会实时画出准确率和损失两条曲线,同时给出学习率。这里要注意:训练损失曲线的下降通常比验证准确率曲线平滑,训练开始几轮里验证准确率出现 80% 到 95% 的跳变是正常的,因为初始权重随机,前几个 iteration 的分类边界变化剧烈。
如果训练损失下降但验证损失先降后升,就是过拟合。此时三个调整方向:降低 fc 层的输出维度(比如 120 改成 64)、加大 L2Regularization 到 0.0005、或者增加 DropoutLayer(0.5) 放在 fc3 之前。手写数字样本量大,过拟合在 10 个 epoch 内不太会出现,但如果你把 MaxEpochs 加到 30,最好把验证集曲线盯紧。
如果损失曲线完全不下降,先检查数据链路而不是网络:用montage(trainImages(:,:,:,1:64))看一眼切出来的图是不是 0 到 255 的原始灰度,标签是不是 categorical 类型。我不止一次见过把归一化忘掉、输入全 0 导致梯度为零的情况,这种情况调任何训练参数都没用。
4.3 一个快速调参基线
给一个我常用的四组参数对照表,新手可以直接当起点。数据集不变,网络结构不变,只改 trainingOptions 里的三个值:
| 方案 | InitialLearnRate | MiniBatchSize | MaxEpochs | 预期验证准确率 |
|---|---|---|---|---|
| 保守基线 | 0.0005 | 256 | 15 | 98.8%~99.2% |
| 快速验证 | 0.001 | 128 | 8 | 98.5%~99.0% |
| 追逐精度 | 0.0005 | 64 | 20 | 99.1%~99.4% |
| 过拟合演示 | 0.01 | 32 | 30 | 训练 99.9%,验证 98% 以下 |
这个表的目标是让第一次跑的人有个心理预期。MNIST 在 LeNet 变体上 99% 出头就是合理区间,任何声称到 99.9% 的结果基本都用了测试集调参或者数据增强,不用太当真。
最后注意 trainNetwork 不是唯一选择。R2021a 之后可以用 dlnetwork 加自定义训练循环,能自由控制前向传播、梯度累积和分布式训练,但对 MNIST 这种任务完全没有必要,trainNetwork 已经把数据加载、打乱、梯度更新、验证评估全包了。
5. 部署前先做三件事:混淆矩阵、错误样本对照与 ONNX 导出
5.1 用混淆矩阵定位最容易混的类别
训练完成后先不要急着导模型,用 classify 在测试集上做一次完整预测:
YPred = classify(net, testImages); acc = mean(YPred == testLabels); figure; confusionchart(testLabels, YPred);手写数字场景里,混淆矩阵上 4 和 9、3 和 8、7 和 2 这几对数字的单元格通常比对角线周边的其他位置亮,因为笔画结构相似。如果某个对角线格子明显偏暗,优先去检查那一类样本的标注质量和笔画分布,而不是盲目加大网络。这一步配合准确率能快速判断当前结果是不是网络结构的天花板。
5.2 把预测错误的样本挑出来看
10000 张测试图里 1% 的错误就是 100 张图,用 montage 一次性看:
wrongIdx = find(YPred ~= testLabels); montage(testImages(:,:,:,wrongIdx(1:min(16, numel(wrongIdx)))), 'Size', [4 4]);如果错误样本大部分是「人眼也说不清」的潦草写法,说明模型容量到顶;如果错误样本里存在大量正常书写,回去改第 4 章的训练参数。
5.3 固定输入尺寸再导出 ONNX
导出 ONNX 用exportONNXNetwork(net, 'mnist_cnn.onnx'),跨框架推理就靠这个文件。导出前确认 imageInputLayer 里没写 MinibatchSize,否则 ONNX 模型会把 batch 维固定,部署端一次只能推理固定张数。另外,网络里有自定义层时 exportONNXNetwork 会报错,遇到这种情况只能把自定义层替换成标准层再用 dlnetwork 导出。实际落地时,入口函数里先 imresize 到 28×28、转灰度、归一化到[0,1],顺序固定下来,否则分类准确率会掉 1 到 2 个百分点。
本文还有配套的精品资源,点击获取