简介:这份基于MATLAB从0开始实现的多层感知机完整工程,定位清晰,面向机器学习初学者、高校学生及希望深入理解反向传播算法的工程师。网络由三个全连接层构成,代码覆盖数据生成、训练与评估的完整闭环,并分别实现了ReLU、Sigmoid两种激活函数以及Softmax、交叉熵等关键模块,训练过程会实时绘制损失函数变化曲线,评估阶段给出分类可视化效果。压缩包共11个文件,由9个m脚本和2个mat数据或权重文件组成,整体仅13KB,轻量干净,非常适合逐行阅读、断点调试与二次改造。该资源已有1621人学习浏览,学习者可借助它快速厘清前向传播、反向传播、权重更新、梯度下降等核心概念,并直接在MATLAB环境中运行验证,训练耗时通常只有数秒。对于想从零搭建神经网络并用实验验证理论的同学来说,这是一份难得的入门实践资料。
1. 为什么放着工具箱不用,偏要手写多层感知机
我接触到这个项目的原因挺直接——有阵子我在做一个二维平面的非线性分类任务,数据长成两个弯月形交错的样子,试了逻辑回归、试了决策树,效果都不太理想。朋友建议我直接用MATLAB的Deep Learning Toolbox,往fitcnet里塞几层全连接,训练完事。道理是这么个道理,可真当模型跑出来之后,我盯着训练曲线心里发虚:这个网络的权重在更新时到底经过了怎样的计算?学习率设多少算合理?隐藏层节点数为什么取64而不是32或者128?如果模型不收敛,我又该从哪里下手排查?
说我钻牛角尖也好,说我有代码洁癖也罢,那种把黑盒当工具用、出了问题只能干瞪眼的感觉,实在不太舒服。于是我决定脱开工具箱,完全用MATLAB脚本手写一个多层感知机——从数据预处理到前向传播、反向传播、参数更新,全部自己实现,矩阵运算直接基于原生MATLAB语法,连trainNetwork都不碰。这件事做完之后,后续再遇到底层网络定制、框架不支持的奇奇怪怪的loss、或者需要把MATLAB模型移植到C语言嵌入式设备等需求,我心里就都很有底了。
这篇博文就是把整个过程拆开揉碎,从数学原理讲到MATLAB每个核心函数的实现细节,再分享我踩过的坑和排查思路。如果你也是在MATLAB里做机器学习、不是特别想套工具箱、想彻底搞清楚多层感知机底层逻辑的,那这篇文章应该挺对你胃口。
2. 多层感知机的核心原理:先搞懂它到底在算什么
在直接上代码之前,我强烈建议先花几分钟把多层感知机的数学流程理清楚。这个网络听起来高大上,本质就是“一堆矩阵乘来乘去,然后再用梯度更新这些矩阵”。只要把维度关系和链式法则吃透,写代码就是照葫芦画瓢。
2.1 神经元与矩阵运算:把多个样本放在一起
单个神经元做的事情,其实就是两件事:线性加权求和,再过一个非线性激活函数。比如输入向量是$x \in R^{d}$,权重是$w \in R^{d}$,偏置是$b$,那么神经元的输出就是:
$$z = w^T x + b$$ $$a = f(z)$$
到了多层感知机,我们不会再一个神经元一个神经元地去算,而是把网络每一层都拍成矩阵运算。假设某一层有$n$个输入、$m$个输出,那么这一层有一个权重矩阵$W \in R^{m \times n}$,一个偏置向量$b \in R^{m}$,整层输出可以写成:
$$Z = W X + b$$ $$A = f(Z)$$
这里的$X$不只是一个样本,而是可以一次放进整个batch的所有样本——假如batch里有$B$个样本,那么$X$的形状就是$n \times B$,$Z$和$A$的形状都是$m \times B$。用矩阵形式一次性处理一批样本,这正是MATLAB这类矩阵运算工具最擅长的事。
2.2 真正关键的反向传播:误差怎么从输出层“倒流”回来
训练多层感知机的本质,是不断调整每一层的$W$和$b$,让网络的输出越来越接近真实标签。这个“调整”使用的方法就是梯度下降,而梯度的计算依赖反向传播算法。
我见过不少初学朋友一提到反向传播就紧张,其实它只用到两个核心知识点:链式法则和局部梯度。我拿一个三层网络(输入层、一个隐藏层、输出层)来推一遍你就明白了。
设网络前向传播为:
$$Z_1 = W_1 X + b_1, \quad A_1 = \text{ReLU}(Z_1)$$ $$Z_2 = W_2 A_1 + b_2, \quad \hat{y} = \text{sigmoid}(Z_2)$$
损失函数用交叉熵(二分类),单个样本的损失:
$$L = -\big[y \log \hat{y} + (1-y)\log(1-\hat{y})\big]$$
我们希望计算$\frac{\partial L}{\partial W_2}$和$\frac{\partial L}{\partial W_1}$。先从输出层开始:
$$\frac{\partial L}{\partial Z_2} = \hat{y} - y$$
这个公式是交叉熵+sigmoid组合带来的漂亮化简,很多教程里直接给出,但实际推导一下会更踏实。求得$\delta_2 = \hat{y} - y$之后,$W_2$的梯度就很好算了:
$$\frac{\partial L}{\partial W_2} = \delta_2 A_1^T$$
这就是我常说的“局部误差乘以输入激活”的规则。再往前传播到隐藏层:
$$\delta_1 = (W_2^T \delta_2) \circ \text{ReLU}'(Z_1)$$
其中$\circ$表示逐元素相乘,$\text{ReLU}'(Z_1)$在$Z_1$中大于0的位置取1,否则取0。最后:
$$\frac{\partial L}{\partial W_1} = \delta_1 X^T$$
整个过程看起来不长,但维度极易搞错。我自己在最开始实现的时候,就是因为没搞清楚“哪一项该转置、转置之后乘在左还是右”,反反复复对了好几遍维度才调通。
2.3 激活函数与损失函数的选择细节
我用的是ReLU作为隐藏层激活、sigmoid作为输出层激活,二分类交叉熵作为损失函数。ReLU在隐藏层的优势是梯度不容易饱和,计算量极小;sigmoid放在输出层是为了把输出压缩到0~1之间,直接当概率用。如果做多分类,输出层可以换成softmax,损失函数用多分类交叉熵,反向传播的输出层梯度形式会变成$\hat{y} - \text{onehot}(y)$,原理完全一致。
这里有个容易踩的坑:隐藏层如果用sigmoid,在网络层数稍深或者学习率偏大的时候,梯度很容易在反向传播过程中逐层衰减,导致靠近输入层的权重几乎得不到有效更新。所以我做了几轮对比之后,还是把隐藏层激活函数固定为ReLU,实测收敛速度快了不是一星半点。
3. MATLAB从0到1的完整实现:主循环、前向、反向
现在进入正题,怎么用MATLAB脚本把上面这些数学式子变成能跑的代码。我先说明整体设计思路:不用classdef定义复杂的类,全部用结构体保存网络参数,用普通函数封装前向和反向,保证每一行代码都直观透明,方便逐步打印调试。
3.1 数据准备:自己造一个非线性分类数据集
为了验证网络真的学到了非线性边界,我直接用MATLAB内置函数造了一个“同心圆环”数据集。内圈和外圈各400个样本,属于两类:
rng(42); numSamples = 400; % 内圈样本:半径0.8,加入噪声 theta = 2 * pi * rand(numSamples, 1); r_in = 0.4 + 0.15 * randn(numSamples, 1); X_in = [r_in .* cos(theta), r_in .* sin(theta)]; Y_in = zeros(numSamples, 1); % 外圈样本:半径1.5,加入噪声 theta = 2 * pi * rand(numSamples, 1); r_out = 1.5 + 0.15 * randn(numSamples, 1); X_out = [r_out .* cos(theta), r_out .* sin(theta)]; Y_out = ones(numSamples, 1); % 拼接并打乱 X = [X_in; X_out]; Y = [Y_in; Y_out]; idx = randperm(size(X, 1)); X = X(idx, :); Y = Y(idx);把数据随机打乱尤其重要,不然模型在训练时可能“偷看”到类别分布规律,导致验证集上表现虚高。这里我顺手把训练集和验证集按8:2做了划分,分界点用cvpartition实现更规范:
cv = cvpartition(size(X, 1), 'HoldOut', 0.2); X_train = X(training(cv), :); Y_train = Y(training(cv), :); X_val = X(test(cv), :); Y_val = Y(test(cv), :);3.2 参数初始化:权重怎么给才不炸
权重初始化是非常容易被轻视的一环。如果全部初始化为0,那么反向传播时同一层的所有神经元会收到完全相同的梯度,无论怎么训练,神经元之间都无法“分化”,网络退化成一个宽线性层,完全没有意义。
我采用的是随机初始化,并且让每一层权重的方差和输入维度相关,防止激活值随着层数加深越来越大或者越来越小:
function W = initWeights(fan_in, fan_out) % He初始化,适合ReLU激活 stddev = sqrt(2 / fan_in); W = randn(fan_out, fan_in) * stddev; end偏置直接初始化为0就行。这里我踩过一个坑:最开始用rand均匀分布初始化,方差大概只有$\frac{1}{12}$,隐藏层输出方差过小,ReLU的负半区经常一片死寂。后来换成He初始化,情况立刻好转。如果你用sigmoid或tanh,建议用Xavier初始化(方差为$\frac{2}{fan_in + fan_out}$),两者逻辑是相通的。
3.3 前向传播:一行行矩阵运算,带缓存方便反向
前向传播不仅要算输出,还要把中间变量cache保存下来,因为反向传播要用到。我的实现是每一层把A_prev、Z、A都存在一个结构体里:
function [A2, cache] = forward(X, W1, b1, W2, b2) % 隐藏层 Z1 = W1 * X + b1; A1 = max(0, Z1); % ReLU % 输出层 Z2 = W2 * A1 + b2; A2 = 1 ./ (1 + exp(-Z2)); % sigmoid cache = struct('Z1', Z1, 'A1', A1, 'Z2', Z2, 'A2', A2, 'X', X); end需要注意b1和b2的广播机制。在MATLAB R2016b及以后版本中,m \times n的矩阵加上一个m \times 1的向量会自动做隐含扩展,很方便。但为了保证代码在旧版也能运行,你也可以写成W1 * X + repmat(b1, 1, size(X, 2)),运行效率会高不少。
3.4 反向传播:按公式逐个求偏导
反向传播代码和前面的公式一一对应:
function [gradW1, gradb1, gradW2, gradb2] = backward(cache, Y, W2) X = cache.X; A1 = cache.A1; Z1 = cache.Z1; A2 = cache.A2; numSamples = size(X, 2); % 输出层梯度,交叉熵 + sigmoid 化简结果 dZ2 = A2 - Y'; % 注意 Y 是列向量,需要转置 % 隐藏层梯度 dZ1 = (W2' * dZ2) .* (Z1 > 0); % ReLU 的导数 gradW2 = dZ2 * A1' / numSamples; gradb2 = mean(dZ2, 2); gradW1 = dZ1 * X' / numSamples; gradb1 = mean(dZ1, 2); end拿到梯度之后就是标准的梯度下降更新:
W1 = W1 - learningRate * gradW1; b1 = b1 - learningRate * gradb1; W2 = W2 - learningRate * gradW2; b2 = b2 - learningRate * gradb2;完整的主循环代码大致骨架如下:
learningRate = 0.1; numEpochs = 3000; inputSize = 2; hiddenSize = 32; outputSize = 1; W1 = initWeights(inputSize, hiddenSize); b1 = zeros(hiddenSize, 1); W2 = initWeights(hiddenSize, outputSize); b2 = zeros(outputSize, 1); lossHistory = zeros(numEpochs, 1); for epoch = 1:numEpochs % 前向 [A2, cache] = forward(X_train', W1, b1, W2, b2); % 损失 lossHistory(epoch) = -mean(Y_train' .* log(A2 + 1e-8) + ... (1 - Y_train') .* log(1 - A2 + 1e-8)); % 反向 [gradW1, gradb1, gradW2, gradb2] = backward(cache, Y_train, W2); % 更新 W1 = W1 - learningRate * gradW1; b1 = b1 - learningRate * gradb1; W2 = W2 - learningRate * gradW2; b2 = b2 - learningRate * gradb2; end注意我在损失里加了1e-8,防止log(0)导致数值变成NaN。这是写损失函数时最不起眼但最容易让人崩溃的细节。
4. 训练调参与优化技巧:从“能跑通”到“效果好”
代码跑通只是第一步。事实上我写完第一版之后,损失函数下降得非常慢,分类准确率只有八成左右,与现在的效果差一大截。调参过程中我总结了一些心得,这里挑重点讲。
4.1 学习率到底怎么设
学习率可能是影响训练结果最敏感的超参数。设太大,损失函数会在最优解附近来回震荡甚至爆炸;设太小,网络要很久才收敛。我在这个数据集上做过一个简单实验,结果对比很清楚:
| 学习率 | 训练行为 | 最终分类准确率 |
|---|---|---|
| 0.5 | 前几步就发散,损失变成NaN | 50%左右,基本不可用 |
| 0.1 | 收敛平稳,约600轮后loss降到0.05以下 | 96.5% |
| 0.01 | 收敛速度明显变慢,需要2000轮以上才能达到类似精度 | 95.8% |
这个实验也说明了一点:学习率不是孤立取值的,它和网络结构、数据分布、batch大小都有关系。拿到一个新数据集时,我会先用0.01、0.1、0.3跑个几十轮,看损失的下降趋势,再在合适的区间精调。
4.2 隐藏层节点数怎么选
隐藏层节点数决定了网络表达能力。我试过4、16、32、128四种节点数,在这个简单的二维圆环分类问题上,节点数从4增加到16,准确率有明显提升;但16到128的提升其实很小,训练时间却翻了好几倍。原因是数据集本身的非线性复杂度有限,多余的节点只会增加过拟合风险。
一个常用的经验法则:隐藏层节点数可以取输入维度和输出维度平均值附近再往上加一点,后续根据训练/验证准确率差异来微调。如果训练准确率高但验证准确率低,说明网络过拟合了,应该考虑减小网络规模或者加入正则化手段。
4.3 数值梯度检查:手写反向传播的“质检员”
手写反向传播最怕的是梯度算错但代码又不报错——这种情况下损失可能在下降,但方向是错误的,导致模型卡在很差的局部最优点。我的解决办法是做一个“数值梯度检查”:用中心差分法近似计算梯度,和自己反向传播算出来的梯度对比。
function diff = checkGradient(W1, b1, W2, b2, X, Y, eps) if nargin < 7 eps = 1e-5; end % 首尾连接权重与偏置 theta = [W1(:); b1(:); W2(:); b2(:)]; numelTheta = numel(theta); numGrad = zeros(size(theta)); for i = 1:numelTheta thetaPlus = theta; thetaPlus(i) = thetaPlus(i) + eps; thetaMinus = theta; thetaMinus(i) = thetaMinus(i) - eps; numGrad(i) = (computeLoss(thetaPlus, size(W1), size(b1), size(W2), size(b2), X, Y) - ... computeLoss(thetaMinus, size(W1), size(b1), size(W2), size(b2), X, Y)) / (2 * eps); end % 解析梯度 [gradW1, gradb1, gradW2, gradb2] = backward(forward(X', W1, b1, W2, b2), Y, W2); analyticGrad = [gradW1(:); gradb1(:); gradW2(:); gradb2(:)]; diff = norm(numGrad - analyticGrad) / (norm(numGrad) + norm(analyticGrad)); fprintf('相对误差: %e\n', diff); end如果相对误差在$1e-7$量级,基本可以确认反向传播的实现是正确的。我那次检查跑出来的结果大概是$2.4e-7$,这才放心让模型继续训练。建议写任何自定义网络结构时都保留这个梯度检查函数,它会帮你节省大量排查时间。
5. 实战中的常见报错与排查记录
手写一个完整的MLP过程中,我遇到了很多稀奇古怪的问题,这里整理几个具有代表性的,以及对应的解决思路。
5.1 矩阵维度对不上,报错信息又长又飘
这是新手最容易遇到的问题。我印象最深的一次,是反向传播里写dA1 = W2' * dZ2,但当时我的W2存成的是outputSize x hiddenSize,而dZ2的形状是1 x B,乘完之后得到hiddenSize x B,看起来没啥问题,但后面和dZ1逐元素相乘时形状死活对不上。后来打印所有中间变量的size才发现,dZ1应该是hiddenSize x B,但我写成了B x hiddenSize。
我的排查方法简单粗暴:在出错的函数里加几行disp(size(...)),把每一层每一步的矩阵维度全部打印出来,对一次就找到了。耐心把维度打出来真的是最有效的排查手段。
5.2 损失一直不下降,准确率卡在50%
这种情况多半是梯度方向有问题,常见原因有三个:
- 权重初始化不当,ReLU频繁置零导致梯度传不回去。解决方法是检查
cache.Z1中负值占比,如果超过50%,需要调初始化方差。 - 学习率过小,梯度更新不足以让loss明显下降。尝试调大学习率到0.1或0.3,观察是否改善。
- 数据没有归一化。输入特征如果量纲差距过大,可能导致部分权重更新的梯度被“淹没”。虽然我这里造的二维数据天然在相近量纲内,但实际项目中归一化是必须的步骤。
5.3 训练过程中损失变成NaN
NaN问题几乎都是数值不稳定引起的,最常见三个原因:log(0)、学习率过大导致梯度爆炸、或者中间某个激活值变成了Inf。我的建议是:
- 损失函数中加上平滑项(比如
1e-8)。 - 动态调整学习率,必要时在训练到一半时进行学习率衰减。
- 给梯度做一个简单的裁剪:把超过某个阈值的梯度强行截断。在MATLAB里可以用
max(min(grad, clipValue), -clipValue)实现。
5.4 训练集准确率很高,验证集却一直上不去
这个现象很典型,不用慌,是过拟合。解决思路有几种:减少隐藏层节点数、增大训练数据量、加入L2正则化、早停法(当验证集loss连续多轮不下降时提前终止训练)。我这边因为是造数据的实验,所以简单用了早停和适度减小网络规模就解决了。
一个额外的小技巧:怎么把模型输出可视化
MATLAB在做这种二维分类实验时有个优势,就是可视化特别方便。训练完模型后,我习惯把整个区域按密集网格预测一遍,绘制出分类边界:
[x1Grid, x2Grid] = meshgrid(-2:0.02:2, -2:0.02:2); gridX = [x1Grid(:)'; x2Grid(:)']; [gridPred, ~] = forward(gridX, W1, b1, W2, b2); gridPred = reshape(gridPred, size(x1Grid)); imagesc(x1Grid(1,:), x2Grid(:,1), gridPred); colormap('cool'); hold on; scatter(X(Y==0,1), X(Y==0,2), 25, 'k', 'filled'); scatter(X(Y==1,1), X(Y==1,2), 25, 'y', 'filled'); set(gca, 'YDir', 'normal');这个可视化过程帮我快速判断网络到底学到了什么形状的决策边界。如果边界严重过拟合、弯弯曲曲,那十有八九网络容量过大;如果边界太直、分不开数据,那可能是网络容量不足。可视化永远是调试机器学习模型最直观的工具之一。
这个MATLAB多层感知机项目做完之后,我最大的体会是:调用工具箱花费十分钟,手写一遍可能要花一个下午,但“自己掌握底层逻辑”这个回报是任何工具箱都给不了的。后续再做网络结构改进、激活函数调整、正则化实验,甚至把训练好的权重导出到C语言做实时推理,我都觉得自己是在调自己家的系统,而不是在对外包的合同品做黑盒测试。如果你也在学习深度学习的路上,我强烈建议你哪怕只有一次,也把多层感知机的训练过程完完整整手写一遍。
本文还有配套的精品资源,点击获取