简介:面向机器学习与计算机视觉初学者及课程实践者,这份基于BP神经网络的手写数字识别资料包以MATLAB为实现环境,围绕图像预处理、特征向量构建、网络训练与识别评估展开,可用于课程设计、实验复现和算法入门。压缩包共收录5027个文件,主体为5000张bmp手写数字图像,配合20个ini配置、5个m脚本、1份docx实验报告及1个txt说明,整体约6.93MB;脚本覆盖网络结构定义、训练参数设置与测试流程,报告则汇总方法、实验设计和结果分析,便于对照理解误差反向传播与权重更新过程。已有2173人学习下载,适合希望从数据准备到模型评估完整走通流程的读者参考,也能为调整隐藏层规模、学习率、动量项及正则化策略提供实践对照,并帮助梳理准确率、召回率等指标的分析思路。
1. 一份能跑通的 BP 神经网络数字识别 MATLAB 工程,值不值得拆
带课设的常见场景是这样:MATLAB 里网络跑起来了,损失曲线平得像一条直线,识别准确率停在 10% 上下——十类里蒙对一类。改学习率、改隐藏层节点、换十遍随机种子,曲线还是那条线。问题从来不在“BP 神经网络”这五个字,而在数据进网络之前被怎么摆、梯度往回传的时候有没有传对。这份基于 BP 神经网络的手写数字识别 MATLAB 工程,把 784 维输入、单隐层、softmax 输出的整条链路拆成可以单步调试的函数:数据读取、标签编码、前向、损失、反向、参数更新各自独立,不是一坨从头跑到尾的脚本。适合正在做数字识别课设、想搞清楚每一次W = W - lr*dW里那个dW到底从哪来的人,也适合拿它当讲稿骨架去拆解每一层的角色。
2. 网络结构定稿:784-128-10 的三层骨架与数据管线
动手写第一行代码之前,有三件事必须先定死:输入张量怎么展平、标签怎么编码、参数怎么初始化。这三件事任何一件错了,后面调参调三天都是白费。这一章把这些前置动作逐个落地,每个都配可运行的函数,跑完能直接拿到两个矩阵:一个784×N的输入、一个10×N的标签。
2.1 从 28×28 到 784:展平顺序与标签编码
一张手写数字图是 28 行 28 列的灰度矩阵,像素值 0 到 255。全连接层只认向量,所以要展平成 784 维。MATLAB 的reshape是列优先的,reshape(img, 784, 1)拿到的是“先竖着扫完第一列,再扫第二列”,而人眼看图是按行扫的。这两种顺序本身都能训练出可用的网络,因为权重是随机学的,网络会自己适应;真正致命的是训练时用一种顺序、预测时用另一种,权重对不上号,准确率立刻掉到随机水平。
我的做法是固定一个flatten_batch函数,训练、验证、测试、单张推理全部走它,杜绝手写reshape各写各的:
function X = flatten_batch(imgs) %FLATTEN_BATCH 把 [28 x 28 x N] 的灰度图批量展平成 [784 x N] % imgs 允许是 uint8 或 double,统一转 double 并归一化到 [0,1] imgs = double(imgs); % 必须先转 double,uint8 运算会截断 [R, C, N] = size(imgs); X = reshape(imgs, R*C, N) / 255; % 列优先展平,除以 255 做归一化 assert(size(X,1) == 784, '展平后维度不是 784,检查输入尺寸'); enddouble(imgs)这一行不是可选项。MATLAB 里uint8类型的减法会饱和——uint8(3) - uint8(5)结果是 0 而不是 -2。后面做零均值化X - mean(X,2)的时候,如果矩阵还是uint8,所有负值全被砍成 0,特征直接废掉一半。这个坑我在不同人的代码里见过不止五次。
标签侧要转成独热编码。原始标签是 0 到 9 的整数,而 MATLAB 数组下标从 1 开始,所以映射关系是“标签 0 落在第 1 行”:
function Y = onehot(y, numClass) %ONEHOT 整数标签 [1 x N] 转独热矩阵 [numClass x N] y = double(y(:)'); % 统一成行向量 N = numel(y); Y = zeros(numClass, N); idx = sub2ind([numClass, N], y + 1, 1:N); % 关键:+1 把 0-9 平移到 1-10 Y(idx) = 1; end写完立刻加三条断言,比事后 debug 省几小时:size(Y,2) == size(X,2)保证样本数对齐,all(sum(Y,1) == 1)保证每列恰好一个 1,max(y) <= 9 && min(y) >= 0保证标签范围没越界。这三条断言一旦有一条不过,后面所有训练结果都没有参考价值。
2.2 idx 文件的读取、切分与断言检查
公开的 28×28 手写数字灰度集通常以 idx 格式存放:图像文件前 16 字节是四个大端序 int32(魔数、样本数、行数、列数),后面紧跟像素数据;标签文件前 8 字节是两个 int32,后面是每样本一字节的类别。MATLAB 默认小端序读,这里必须显式指定'ieee-be',否则读出来的样本数会是个天文数字。
function [X, y] = load_idx(imgPath, lblPath) %LOAD_IDX 读取 idx 格式图像与标签,返回 [784 x N] 与 [1 x N] fid = fopen(imgPath, 'rb', 'ieee-be'); % 大端序,不能省 magic = fread(fid, 1, 'int32'); % 魔数,前两字节固定为 0 N = fread(fid, 1, 'int32'); R = fread(fid, 1, 'int32'); C = fread(fid, 1, 'int32'); buf = fread(fid, N*R*C, 'uint8=>double'); % 直接转 double,避免后续溢出 fclose(fid); % fread 按列填充,先还原成 [C R N] 再置换回 [R C N] tmp = reshape(buf, C, R, N); tmp = permute(tmp, [2 1 3]); X = reshape(tmp, R*C, N) / 255; fid = fopen(lblPath, 'rb', 'ieee-be'); fread(fid, 1, 'int32'); M = fread(fid, 1, 'int32'); y = fread(fid, M, 'uint8')'; fclose(fid); assert(N == M, '图像数与标签数不一致:%d vs %d', N, M); assert(R == 28 && C == 28, '图像尺寸异常:%dx%d', R, C); endpermute(tmp, [2 1 3])这一步是整段代码里最容易写错的地方。reshape(buf, C, R, N)拆出来的是“列数在前”,要换回“行数在前”才是正常图像方向。写反了不会报错,网络照样能训,只是所有数字都躺倒 90 度——准确率会掉,但不会掉到 10%,于是很容易被误判成“模型不行”。
切训练/测试集时,我一般按 8:2 或者直接使用数据集自带的划分。切完之后再切一刀验证集出来,比例 10% 到 15%,专门用来判断什么时候停。验证集不能参与任何参数更新,也不能参与归一化统计量的计算,否则它就不是验证集了。
2.3 权重初始化:全零为什么必然失败
三层网络的参数一共两组权重两个偏置:
| 层 | 权重矩阵 | 偏置 | 参数量 |
|---|---|---|---|
| 输入 → 隐藏 | 128×784 | 128×1 | 100480 |
| 隐藏 → 输出 | 10×128 | 10×1 | 1290 |
| 合计 | 101770 |
十万量级的参数,初始化方式直接决定前几轮能不能动起来。全零初始化是新手最常踩的雷:隐藏层所有神经元的输入、输出、梯度完全一致,反向传播时每一列更新的数值也完全一样,等于 128 个神经元被绑成一个,网络容量白扔掉。正确做法是让初始权重的尺度跟这一层的 fan-in 挂钩:
function [W1, b1, W2, b2] = init_params(nIn, nHid, nOut, useReLU) %INIT_PARAMS 按 fan-in 缩放的高斯初始化 rng(42); % 固定种子,保证实验可复现 if nargin < 4, useReLU = false; end scale1 = sqrt(2 / nIn); if ~useReLU, scale1 = sqrt(1 / nIn); end scale2 = sqrt(2 / nHid); if ~useReLU, scale2 = sqrt(1 / nHid); end W1 = randn(nHid, nIn) * scale1; b1 = zeros(nHid, 1); % 偏置可以从零起步 W2 = randn(nOut, nHid) * scale2; b2 = zeros(nOut, 1); end乘sqrt(1/fan_in)对应 sigmoid 激活,乘sqrt(2/fan_in)对应 ReLU,区别在于两者的方差特性不同。尺度太大,sigmoid 会立刻饱和,梯度趋近于零,训练前几轮损失纹丝不动;尺度太小,信号逐层衰减,输出层几乎恒等于 0.1,损失卡在 2.3 附近——正好是-log(1/10),十类均匀分布的理论值。看到损失精确地停在 2.30 不动,先回头查初始化尺度。
rng(42)这一行看起来无关紧要,实际上是可复现的后悔药。同一份代码换个人跑,结果差两个百分点,追查起来极其痛苦。种子固定之后,任何一次精度变化都能明确归因到代码改动上。
3. 前向与反向:一次迭代里梯度到底经过了哪几层
网络结构定了之后,剩下的核心就是四件事:前向算出预测,损失量化偏差,反向把偏差摊回每个参数,优化器按梯度挪一步。这一段代码总量不到 60 行,但每一行的维度都必须咬得死死的,差一个转置就全盘崩。这一章把前向、损失、反向、训练循环拆开写,每个函数单独可测。
3.1 前向传播的矩阵化写法
单样本前向是z = W*x + b,批量则是把x拼成矩阵,一次矩阵乘法算完整个 mini-batch。批量化的意义不只是快,更重要的是梯度是 batch 内所有样本的平均,本身带一点平滑效果。
function [A1, A2, cache] = forward(X, W1, b1, W2, b2) %FORWARD 单隐层前向:sigmoid 激活 + softmax 输出 Z1 = W1 * X + b1; % [nHid x B] A1 = 1 ./ (1 + exp(-Z1)); % sigmoid,输出 (0,1) Z2 = W2 * A1 + b2; % [nOut x B] Z2 = Z2 - max(Z2, [], 1); % 逐列减最大值,防止 exp 溢出 E = exp(Z2); A2 = E ./ sum(E, 1); % softmax,每列和为 1 cache = struct('X', X, 'A1', A1, 'A2', A2); endmax(Z2, [], 1)沿第一维取最大值,得到1×B的行向量,再用隐式扩展逐列广播。这一步不改变 softmax 的数学结果——分子分母同乘一个常数——但把exp的输入压到 0 以下,彻底避免exp(800)变成Inf之后再除出NaN。手写数字任务的 logit 一般不会大到溢出,但在训练初期权重尺度没调好的时候,我确实见过Z2冲到三位数。
sigmoid的隐藏层在手写数字上是够用的,代价是收敛比 ReLU 慢,好处是梯度形式简单、不需要担心死亡神经元。做课设演示用 sigmoid 更容易讲清楚链式法则,追求收敛速度就把A1换成max(0, Z1),反向传播里对应的导数从A1.*(1-A1)换成Z1 > 0。
维度上要养成习惯:权重矩阵的行数是“这一层有多少个神经元”,列数是“上一层有多少个神经元”,所以W1是nHid × nIn,W2是nOut × nHid。写成反的也能乘,但结果含义完全不同,而且不会报错。
3.2 交叉熵损失与 softmax 的数值稳定
多分类的标准损失是交叉熵:
function L = cross_entropy(A2, Y) %CROSS_ENTROPY 平均交叉熵,A2 为预测概率,Y 为独热标签 m = size(A2, 2); L = -sum(sum(Y .* log(A2 + 1e-12))) / m; end1e-12是保险丝。理论上 softmax 的输出严格大于 0,但浮点数在极端情况下会下溢成 0,log(0)得到-Inf,-Inf乘上标签 1 再加权平均,整个损失变NaN,然后所有梯度跟着变NaN,训练彻底停摆。加上一个极小值之后,最坏情况是损失被抬到 27 左右,模型还能继续往下走,不会一步死掉。
为什么不用均方误差?因为 softmax 加 MSE 的组合会让输出层梯度里多出一项A2.*(1-A2),预测接近真值时这一项趋近于零,梯度消失,训练后期慢得肉眼可见。交叉熵和 softmax 的导数正好约掉这一项,合并后只剩A2 - Y,干净利落。这不是理论洁癖,是实打实的收敛速度差别,同样 20 轮,交叉熵版本能比 MSE 版本高出三到四个百分点。
损失函数还承担一个诊断职责:训练集损失和验证集损失都要一轮一记。只看准确率是看不出过拟合的,准确率会一直缓慢爬升,把已经发生的过拟合盖过去。
3.3 反向传播:四行梯度公式的落点
反向传播的本质是链式法则,落到代码上就是四行矩阵乘法加两行逐元素乘:
function [dW1, db1, dW2, db2] = backward(Y, W2, cache) %BACKWARD 反向传播,返回四个梯度,均已按 batch 大小取平均 m = size(cache.X, 2); dZ2 = cache.A2 - Y; % softmax+交叉熵的合并梯度 dW2 = dZ2 * cache.A1' / m; % [nOut x nHid] db2 = mean(dZ2, 2); % [nOut x 1] dA1 = W2' * dZ2; % 误差回传到隐藏层 dZ1 = dA1 .* cache.A1 .* (1 - cache.A1); % sigmoid 导数 dW1 = dZ1 * cache.X' / m; % [nHid x nIn] db1 = mean(dZ1, 2); enddZ2 = A2 - Y这一行是整个反向传播里最漂亮的地方:softmax 的雅可比矩阵和交叉熵的导数相乘之后,非对角线项全部抵消,只剩预测减真值。不理解推导也能用,但务必记住这个形式只对“softmax + 交叉熵”成立,换成 MSE 就必须老老实实乘上 softmax 的雅可比。
db2 = mean(dZ2, 2)沿第二维求平均,把1×B的误差压成nOut×1。偏置在 broadcast 的时候是被复制了 B 份的,所以梯度也得把 B 份加起来——求和之后除以 m 和求平均是一回事。这里写成sum(...,2)/m或者直接mean(...,2)都行,唯一不能做的是忘了除:偏置的梯度会比权重的大 m 倍,更新一步直接飞出去。
sigmoid的导数A1.*(1-A1)用激活值本身表达,不必重新计算Z1,这是缓存A1的意义。第一次写这段建议拿单个样本做数值梯度检验:把某个参数加减1e-4,看损失变化量与解析梯度的比值是否接近 1,误差在1e-7量级就算对上了。
3.4 训练主循环与 mini-batch 切分
把前面几个函数串起来就是训练循环。这里有两个细节值得单独说:打乱和评估时机。
function [W1, b1, W2, b2, hist] = train_bp(X, Y, opt) %TRAIN_BP 小批量随机梯度下降主循环 [W1, b1, W2, b2] = init_params(size(X,1), opt.nHid, size(Y,1), opt.useReLU); m = size(X, 2); hist = zeros(opt.epochs, 2); % [loss, acc] for ep = 1:opt.epochs perm = randperm(m); % 每轮重新打乱,别只在开头打一次 for s = 1:opt.batchSize:m idx = perm(s : min(s + opt.batchSize - 1, m)); Xb = X(:, idx); Yb = Y(:, idx); [~, ~, cache] = forward(Xb, W1, b1, W2, b2); [dW1, db1, dW2, db2] = backward(Yb, W2, cache); W1 = W1 - opt.lr * dW1; b1 = b1 - opt.lr * db1; W2 = W2 - opt.lr * dW2; b2 = b2 - opt.lr * db2; end [~, A2full] = forward(X, W1, b1, W2, b2); hist(ep, 1) = cross_entropy(A2full, Y); [~, pred] = max(A2full, [], 1); [~, truth] = max(Y, [], 1); hist(ep, 2) = mean(pred == truth); end endrandperm(m)写在 epoch 循环内部。如果只在训练开始前打乱一次,每个 epoch 的 mini-batch 组成完全相同,梯度下降的路径会呈现出周期性震荡,损失曲线出现规律的锯齿。更糟的情况是原始数据按类别排序存储,不打乱就切批,某个 batch 里全是数字 3,梯度只往那个方向拽,训练几轮之后模型直接塌陷成只输出一个类。
min(s + batchSize - 1, m)处理最后一个不满的 batch。不处理的话索引超界,MATLAB 会直接报错。最后一个 batch 小一些没有问题,梯度本来就是估计值,不必强求每批等大——除非用了 BatchNorm 这类对批大小敏感的层,这份单隐层网络用不到。
评估放在每个 epoch 结束后、用全量训练集算一次。全量前向一次的开销在一万样本量级上是毫秒级的,换来一条平滑可读的曲线,很划算。训练过程中不要每 10 个 batch 就全量评估一次,那会把训练时间拖长好几倍,而且曲线抖动得没法看。
4. 训练参数怎么定:学习率、隐藏层宽度与 epoch 上限
参数没有全局最优解,但有明确的失效区间。这一章给的是区间边界和判断依据,拿到之后可以按自己的数据规模做等比缩放,不是一组只能照抄的数字。
4.1 学习率:三个量级的实测差别
学习率是唯一一个能让训练从“完全不动”直接跳到“发散成 NaN”的超参数。在 128 隐藏节点、batch size 64、交叉熵损失这套配置下,我这边跑出来的大致分档是这样:
| 学习率 | 前 5 轮表现 | 20 轮后训练准确率 | 判断 |
|---|---|---|---|
| 0.5 | 损失剧烈震荡,偶发 NaN | 不可用 | 步子太大,越过谷底 |
| 0.1 | 损失快速下降,曲线平滑 | 96% 上下 | 可用的上限 |
| 0.01 | 下降平稳但偏慢 | 94% 上下 | 稳妥,适合长训练 |
| 0.001 | 20 轮几乎没动 | 30% 上下 | 学习率不足 |
判断学习率是否过大,最直观的信号不是损失值大小,而是损失曲线的形状:正常收敛是单调下降带一点小抖动,学习率过大会出现“降两步、弹三步”的锯齿,甚至在某轮突然跳到 2.3 以上再重新往下走。判断过小则看斜率,前五轮损失下降不到 0.3,基本可以判定步子太碎。
学习率跟 batch size 是耦合的。batch 从 64 加到 256,梯度的方差变小,方向更稳,此时学习率可以放大到 0.2 到 0.3;反过来把 batch 降到 16,梯度噪声变大,学习率得压到 0.03 以下才稳。凭经验给一个起点:lr ≈ 0.1 * batchSize / 64,然后在这个值上下各试一个数量级。
4.2 隐藏层节点数:64、128、256 的取舍
隐藏层宽度决定模型容量。太少欠拟合,太多过拟合且训练变慢。手写数字这个任务的复杂度不高,单隐层 128 个节点已经能到 97% 左右,往上加收益递减很明显:
| 隐藏层节点 | 参数量 | 训练 20 轮准确率 | 单轮耗时(相对值) |
|---|---|---|---|
| 64 | 约 5.1 万 | 96% 上下 | 0.7 |
| 128 | 约 10.2 万 | 97% 上下 | 1.0 |
| 256 | 约 20.3 万 | 97.3% 上下 | 1.9 |
| 512 | 约 40.6 万 | 97.5% 上下,验证集开始回落 | 3.6 |
从 128 到 256,参数量翻倍,准确率提升不到半个百分点,训练时间接近翻倍。从 256 到 512,训练集还在涨,验证集已经开始掉头向下,这就是典型的过拟合信号。课设场景下 128 是最舒服的点:跑得快、指标够看、还有余量做对比实验。
节点数不是孤立调的。隐藏层加到 256 而学习率还停在 0.1,参数量变大之后梯度尺度也变了,可能出现震荡;这时候把学习率降到 0.05 会更稳。换宽度的时候顺手把学习率一起看,比死守一个值强。
sigmoid 还有个隐性约束:隐藏层太宽时,输出层的输入是几百个(0,1)区间的数加权求和,如果不做初始化缩放,Z2的方差会随宽度线性增长,softmax 直接饱和。这也是 2.3 里那个sqrt(1/fan_in)的意义所在,宽度越大越不能省。
4.3 用验证集曲线决定什么时候停
epoch 数不该拍脑袋定,该由曲线决定。做法是每轮记录三条数:训练损失、验证损失、验证准确率。
正常形态是训练损失和验证损失同步下降,验证损失略微高于训练损失,两条线的间距稳定。一旦验证损失开始掉头向上、训练损失还在往下走,间距持续拉大,那就是过拟合开始的时刻,往后训的每一轮都在做负功。这时候有两个选择:停在这个 epoch,或者加正则化继续训。
% 早停:连续 patience 轮验证损失没有改善就退出 bestLoss = inf; patience = 5; wait = 0; for ep = 1:maxEpoch % ... 一轮训练 ... valLoss = cross_entropy(forward(Xval, W1, b1, W2, b2), Yval); if valLoss < bestLoss - 1e-4 bestLoss = valLoss; wait = 0; bestW1 = W1; bestb1 = b1; bestW2 = W2; bestb2 = b2; % 存快照 else wait = wait + 1; if wait >= patience, break; end end endbestW1 = W1这几行存的是参数快照,训练结束之后回滚到验证损失最低的那一轮,而不是用最后一轮的参数。这个细节能让最终指标稳定高出零点几个百分点,代价只是四份矩阵的内存。patience设 5 是给曲线留出越过局部小凸起的机会,设 1 会因为一次抖动误杀,设 20 又等于没早停。
验证集比例我一般在 10% 到 15% 之间。太少,验证损失噪声大,早停信号不可靠;太多,训练样本不够,模型本身欠拟合。切分要用分层抽样,保证验证集里十个类别大致均衡,否则某一类样本太少,验证准确率的方差会很大。
5. 避坑与排查:五个把准确率锁死在 10% 的翻车现场
下面五条都是实际调试中反复出现的故障,共同点是:代码不报错,训练能跑完,指标烂得离谱。每一条按“现象 → 原因 → 解决”写,对照着排查比从头读代码快得多。
5.1 损失几乎不动,预测结果全是同一个类
现象:训练损失从 2.30 缓慢降到 2.28 就不再变化,20 轮之后预测输出全是 0 或者全是 1。
原因:标签编码和预测索引对不上。onehot里忘了y + 1,标签 0 被写到了索引 0,MATLAB 会直接报错或者静默丢弃;另一种更隐蔽的情况是onehot返回的是N×10,而网络输出是10×N,两个矩阵在A2 - Y时被隐式扩展成了10×10,损失值看起来正常但梯度完全是错的。
解决:在数据准备阶段就把断言钉死。
Y = onehot(y, 10); assert(isequal(size(Y), [10, size(X,2)]), '独热矩阵方向不对'); assert(all(sum(Y,1) == 1), '存在没有标签或重复标签的样本'); assert(abs(sum(A2(:,1)) - 1) < 1e-9, 'softmax 输出列和不为 1');最后一条断言检查的是前向传播的正确性,拿第一个样本跑一次forward就能验证。三条全过,说明数据到网络的接口是通的。
5.2 第一轮损失直接变成 NaN
现象:第一次迭代算出来的损失就是NaN,之后所有参数变成NaN,训练彻底中断。
原因:三个高频来源。第一,原始像素没做归一化,Z1的数值冲到几百,exp(-Z1)下溢成 0,A1全 0,log(0)出-Inf。第二,输入矩阵还是uint8类型,W1 * X时发生整数截断和饱和。第三,softmax 里没做减最大值,exp溢出成Inf,Inf/Inf得到NaN。
解决:按顺序检查这三处。class(X)必须是double;max(X(:))应该不超过 1;forward里必须有Z2 = Z2 - max(Z2, [], 1)这一行。损失函数里的log(A2 + 1e-12)是最后一道保险,不能因为“理论上不会出 0”就省掉。
5.3 训练集 99%,测试集 62%
现象:训练集准确率一路涨到 99%,测试集卡在 62% 上不去,验证损失从第 8 轮开始持续上升。
原因:信息泄漏或者过拟合。信息泄漏最常见的形式是在切分之前就对全体数据做了零均值化:X = X - mean(X,2)这句如果作用在包含测试集的完整矩阵上,测试集的均值和方差信息已经渗进训练过程,测试指标会虚高;反过来的情形是切分之后单独看测试集,指标又虚低。另一种是拿测试集当验证集反复调参,调了二十次之后那 62% 已经不代表泛化能力了。
解决:把归一化的统计量固定在训练集上,然后原样套用到验证集和测试集。
mu = mean(Xtrain, 2); % 只从训练集算 sig = std(Xtrain, 0, 2) + 1e-8; Xtrain = (Xtrain - mu) ./ sig; Xtest = (Xtest - mu) ./ sig; % 用同一组 mu/sig,不许重算1e-8是为了防止某些像素在所有训练样本里恒为 0 导致除零。这套统计量要和模型参数一起保存,预测新样本时必须复用,重新计算就等于换了输入分布。另外,从训练集里再切一份验证集出来专门调参,测试集只在前面的所有决策做完之后用一次。
5.4 损失上下震荡,越训越差
现象:损失曲线像心电图,某一轮突然从 0.8 跳到 3.5,再慢慢降回来,反复循环,最终指标比训练 5 轮时还差。
原因:参数更新写反了方向或倍数。W1 = W1 + lr * dW1是往梯度上升方向走,损失当然越来越大;dW在backward里已经除以 m,主循环里又除了一次,梯度被缩小到 1/m² 量级,表现为“几乎不动”;反过来漏除 m,梯度放大 m 倍,一步就跨出有效区间,表现为“飞出去再爬回来”。
解决:做一次数值梯度检验,这是唯一能一次性定位这类问题的办法。
% 取一个小 batch,逐参数核对解析梯度与数值梯度 i = 7; eps_ = 1e-4; [f1, ~, ~] = forward(Xb, W1, b1, W2, b2); L1 = cross_entropy(f1, Yb); W1p = W1; W1p(i) = W1p(i) + eps_; [f2, ~, ~] = forward(Xb, W1p, b1, W2, b2); L2 = cross_entropy(f2, Yb); numGrad = (L2 - L1) / eps_; relErr = abs(numGrad - dW1(i)) / max(1e-12, abs(numGrad) + abs(dW1(i))); fprintf('相对误差 = %.3e\n', relErr); % 应小于 1e-6相对误差在1e-7量级说明反向传播写对了,在1e-2量级说明某处符号或系数有问题。这个方法一次只能查一个参数,但查三五个就能判断是全局性的比例错误还是局部公式错误。
5.5 每轮准确率波动五个点以上
现象:相邻两个 epoch 的训练准确率相差 5 个百分点,曲线完全无法判断趋势。
原因:数据没有打乱,或者只在训练开始前打乱了一次。如果原始数据是按类别顺序存的(前一千个全是 0,接着一千个全是 1),不打乱直接切 mini-batch,每个 batch 里的样本类别高度单一。用全 0 的 batch 更新完,模型往“全预测 0”偏;下一个 batch 全是 1,又往“全预测 1”拽,参数在两类之间来回横跳。
解决:把randperm放进 epoch 循环内部,每轮重新洗牌。
for ep = 1:opt.epochs perm = randperm(m); % 必须在这里,不能提到循环外 for s = 1:opt.batchSize:m idx = perm(s : min(s + opt.batchSize - 1, m)); % ... end end洗完之后顺手做个抽查:histcounts(y(idx))看第一个 batch 的类别分布,十个桶大致均匀就说明打乱生效了。如果某个桶是空的,说明样本总量太少,得把 batch size 调小或者补样本。
6. 从 92% 到 97%:混淆矩阵定位与动量项
准确率卡在 92% 左右的时候,别急着加层加节点,先看混淆矩阵。C = confusionmat(trueLabels, predLabels)出来的 10×10 矩阵,对角线是分对的,非对角线全是错分。手写数字上最典型的两组混淆是 4 和 9、3 和 8——笔画的拓扑结构接近,784 维像素里那点差别在单隐层里很难被线性切出来。看清楚了是哪几类在互相拖后腿,再决定是加宽隐藏层还是对这几类做数据增强(轻微旋转、平移一两个像素),比盲目调参有效得多。
第二个开关是动量项。纯 SGD 在损失曲面的狭长谷地里会来回震荡,加一个动量能沿着一致方向累积速度:
vW1 = zeros(size(W1)); vW2 = zeros(size(W2)); % 前向声明,循环外初始化 beta = 0.9; % 循环内: vW1 = beta * vW1 - opt.lr * dW1; W1 = W1 + vW1; vW2 = beta * vW2 - opt.lr * dW2; W2 = W2 + vW2;注意动量缓存必须在循环外初始化,写在循环里每次都清零就等于没加。beta取 0.9 是通用起点,取 0.99 收敛更平滑但前期启动慢。配合学习率衰减——比如每 10 轮把lr乘 0.5——通常能在同样 20 轮里把准确率再抬一到两个百分点,代价只是多两个超参数要记。
有个习惯是从那以后养成的:每次开训之前,先打印一遍标签分布的直方图、检查sum(A2,1)是否全为 1、再抽一个样本手工核对一遍输入像素和标签是否对应。这三步加起来不到一分钟,但能挡掉后面百分之八十的玄学问题。希望帮到你。
本文还有配套的精品资源,点击获取