资讯动态

从零手写多层感知机:Python与NumPy实现神经网络与反向传播

发布时间:2026/9/16 22:24:44 来源:尧图企业网站定制
这两年Python火得不行尤其是在机器学习和深度学习这块。很多朋友一上来就直奔PyTorch、TensorFlow跑通了几个demo就觉得自己会了但模型内部的参数更新是怎么回事、梯度到底怎么算出来的、为什么有时候loss就是降不下去一问三不知。说白了框架是把底层细节给你封装好了但你如果不知道里面在干什么遇到问题就只能瞎猜。所以今天这篇文章我就带你用Python手写一个不用任何深度学习框架的多层感知机MLP从最底层的神经元原理讲起一直写到能跑通的分类代码。全程只有NumPy没有黑盒你贴到编辑器里就能跑。这篇文章适合所有学过Python基础、想真正理解神经网络工作原理的读者也适合那些用框架用久了、想把底层概念补扎实的人。1. 动手之前先把MLP的原理彻底盘清楚1.1 从单层感知机到多层感知机多出来的层到底在干嘛MLP的全称是Multilayer Perceptron中文叫多层感知机。要理解它得先从单层感知机说起。单层感知机是最原始的神经网络模型本质上就是一个线性分类器做的事情就是y sign(Wx b)。它能解决and、or这类线性可分的问题但有个著名缺陷解决不了异或XOR问题。为什么因为XOR在二维平面上画出来两类点是对角分布的你无论如何也找不出一条直线把它们分开。这是1969年Minsky等人指出的直接导致了神经网络第一次寒冬。多层感知机的核心突破就是往输入和输出之间插入了若干层“隐藏层”。隐藏层的作用可以理解成“特征加工车间”——原始输入在这里被不断线性变换加非线性激活逐步改造成更容易分类的表示。还是拿XOR举例两层网络可以先把坐标点映射到一条新的特征轴上在新的特征空间里原本纠缠在一起的数据就变得线性可分了。我经常跟初学者打一个比方单层感知机是一个人闭着眼直接猜答案多层感知机则是一群人分工协作——第一层负责提炼局部特征第二层把特征组合成更抽象的模式输出层最后做决策。每一层的输出不是最终的答案而是给下一层准备的“中间结论”。这个逐层抽象的思路后来几乎所有深度学习架构都在沿用。1.2 激活函数没有它层数再多也是白堆现在有个关键问题既然每一层都是线性变换矩阵乘法加偏置那多层线性变换叠加起来会怎样答案是仍然等价于一次线性变换。你可以自己算一下两层W2(W1x b1) b2打开括号合并之后本质上还是一个矩阵乘法加一个偏置向量。也就是说如果不用激活函数不管网络堆多少层它的表达能力都不如一个加了非线性的单层模型。所以激活函数是神经网络里绝对不能省的部分。它的作用就是给网络引入非线性让模型有能力拟合那些弯弯曲曲的决策边界。常用的激活函数有三类Sigmoidσ(z) 1 / (1 e^(-z))输出范围0到1早期网络喜欢用它。但它有两个毛病一是当输入很大或很小时梯度几乎为0也就是“梯度饱和”二是输出不是以0为中心会让梯度更新效率打折扣。Tanhtanh(z) (e^z - e^(-z)) / (e^z e^(-z))输出范围-1到1以0为中心比Sigmoid好一些但饱和区问题依然存在。ReLUReLU(z) max(0, z)计算极简单而且正半轴梯度恒为1有效缓解了梯度消失问题是目前隐藏层的默认选择。选择激活函数有一条很实用的经验规则隐藏层优先用ReLU输出层根据任务决定——二分类用Sigmoid多分类用Softmax回归任务直接不用激活函数。为什么输出层多分类要用Softmax因为它能把网络输出的原始分数logits转化成一个概率分布所有类别的概率加起来正好等于1既有物理意义又方便后面接交叉熵损失。1.3 损失函数与梯度下降模型“学习”的本质有了网络结构接下来要解决一个事情怎么衡量“当前模型有多差”答案是用损失函数Loss Function。损失函数的值越大说明模型预测得越离谱训练的过程就是不断减少这个值。分类任务里最常用的损失函数是交叉熵Cross Entropy。举个例子一个三分类问题样本真实标签是“猫”模型预测概率是 [0.5, 0.3, 0.2]那交叉熵损失就是-log(0.5)。预测得越离谱损失值就越大。我当时第一次看到这个式子的时候觉得特别妙它只关心真实类别对应的预测概率别的类别预测成什么样不影响这个样本的损失。有了损失函数之后更新参数用的就是梯度下降法。梯度下降的直觉可以理解成“下山”你站在山上的某个位置脚底下踩的点由参数 (W, b) 决定山的高度就是损失值你的目标是最低点每一步你往哪个方向走沿着梯度也就是坡度最陡的反方向走因为这样才能最快下降步子迈多大由学习率learning rate决定。学习率是训练里最敏感的超参数之一。步子太大你可能在峡谷两边来回横跳甚至直接冲出山外loss变成NaN步子太小走半天还在山腰上训练时间拉得无穷长。后面我会专门演示不同学习率带来的差异。至于“反向传播”这个词听起来很玄乎其实它就是链式法则的工程化实现从输出层的误差出发一层一层把误差“传”回前面的层顺便算出每一层参数的梯度。没有反向传播神经网络压根训不动——因为隐藏层的参数不直接跟损失打交道你不知道它该往哪个方向调。2. 从零实现一个MLP的完整设计2.1 整体架构与代码结构先想清楚再动手写代码之前先在脑子里把架构搭好。我们要实现的MLP是三层结构输入层 - 隐藏层 - 输出层。以经典的鸢尾花数据集为例输入是4个特征输出是3个类别隐藏层神经元数量我们定为8个。这里的参数包括两组权重和两组偏置W1形状 (4, 8)输入层到隐藏层的权重矩阵b1形状 (8,)隐藏层的偏置W2形状 (8, 3)隐藏层到输出层的权重矩阵b2形状 (3,)输出层的偏置。代码组织上我用一个MLP类来封装所有逻辑。类的好处是参数和操作绑定在一起逻辑清楚后面你想扩展成两层隐藏层或者换个激活函数改起来也方便。类里面只需要三个核心方法初始化方法__init__、前向传播forward、反向传播backward再加一个训练用的train方法把整个流程串起来。这种设计思路也符合一个通用原则把代码按“数据流”拆分。前向传播算预测结果反向传播算梯度更新参数只是拿梯度去做减法。你以后去看PyTorch源码或者Keras源码会发现它们也是这么组织的——只是把细节藏在了API后面而已。2.2 参数初始化为什么不能全零初始化新手最容易犯的第一个错误就是全零初始化——把W1、W2全部设成0。如果你这么做了会发现网络根本训练不起来loss长期纹丝不动。原因是一个叫“对称性破缺”的问题如果同一层所有神经元初始权重一样那它们在前向传播时收到完全相同的梯度更新后权重还是相等所有神经元都在做一模一样的事情。相当于你有8个隐藏神经元实际起作用的只有1个网络完全退化。初始化还有一个约束权重不能太大否则经过多层线性变换后激活值会进入Sigmoid或Tanh的饱和区梯度趋近于0训练直接卡死。所以一个很经典的做法是让初始权重的方差跟输入神经元数量挂钩最常用的有Xavier初始化和He初始化。Xavier初始化权重从均值为0、方差为1 / n_in的正态分布中采样适合Sigmoid和Tanh这类关于0对称的激活函数He初始化权重从均值为0、方差为2 / n_in的正态分布中采样适合ReLU这类单侧抑制的激活函数。因为我们的隐藏层用的是ReLU所以选择He初始化。这里我直接用一个np.random.randn乘上np.sqrt(2 / n_in)。这个细节很多人容易忽略但它对训练速度的影响非常大——初始化做对了后面能省掉无数调参时间。2.3 前向传播与反向传播把核心公式推导出来先看前向传播整个过程按顺序执行z1 X W1 b1 a1 relu(z1) z2 a1 W2 b2 a2 softmax(z2) # 也就是预测概率 p这里用矩阵乘法一次性处理整个batch的数据X形状是 (m, 4)W1形状是 (4, 8)所以z1形状是 (m, 8)。这就是批量计算的好处不用写循环。反向传播是重头戏。要算出损失对每个参数的梯度得用链式法则从输出层往回推。因为隐藏层不直接跟损失函数接触所以必须靠“传递”的方式拿到梯度。首先输出层的梯度。当损失函数是交叉熵、输出层激活函数是Softmax的时候有一个非常漂亮的简化结果delta2 a2 - y_true这里的a2是预测概率y_true是one-hot编码的真实标签。这个式子意味着输出层误差 预测值 - 真实值。为什么这么巧因为Softmax和交叉熵搭配时链式法则中间的函数求导环节彼此抵消了最后就剩下这么简洁的形式。这也是分类任务里大家默认用“Softmax 交叉熵”组合的原因之一计算又方便梯度又干净。接下来梯度往隐藏层传delta1 (delta2 W2.T) * relu_derivative(z1)delta2 W2.T把输出层的误差“按权重分配”回隐藏层然后乘上ReLU的导数。ReLU的导数在输入大于0时是1小于等于0时是0所以这步实际上就是在做“如果某个神经元被激活了误差就继续往后传没被激活就断掉”。最后参数梯度就是误差乘以对应层的输入dW2 a1.T delta2 / m db2 np.mean(delta2, axis0) dW1 X.T delta1 / m db1 np.mean(delta1, axis0)这里除以m是取平均相当于把整个batch的梯度合并成一步更新。为什么要取平均而不是求和因为取平均后梯度大小跟batch大小无关你换batch size的时候不用大幅调整学习率。参数更新的公式就是两行代码W2 - lr * dW2 b2 - lr * db2 W1 - lr * dW1 b1 - lr * db1到这一步MLP的核心逻辑就完整了。所有代码加起来不到100行但一个能训练、能预测的神经网络已经在你的手上了。3. 手写MLP代码实战能跑通才是硬道理3.1 搭建MLP类初始化、前向、反向理论知识说完了现在上代码。下面的代码用纯NumPy实现不依赖PyTorch、TensorFlow你把整段保存成mlp.py确保环境里装了numpy和sklearnsklearn只用来加载数据集和划分训练测试集就能直接运行。import numpy as np def relu(z): return np.maximum(0, z) def relu_derivative(z): return (z 0).astype(float) def softmax(z): # 减去最大值是为了数值稳定性 exp_z np.exp(z - np.max(z, axis1, keepdimsTrue)) return exp_z / np.sum(exp_z, axis1, keepdimsTrue) class MLP: def __init__(self, n_input, n_hidden, n_output, lr0.01): self.lr lr # He初始化 self.W1 np.random.randn(n_input, n_hidden) * np.sqrt(2.0 / n_input) self.b1 np.zeros(n_hidden) self.W2 np.random.randn(n_hidden, n_output) * np.sqrt(2.0 / n_hidden) self.b2 np.zeros(n_output) def forward(self, X): self.z1 X self.W1 self.b1 self.a1 relu(self.z1) self.z2 self.a1 self.W2 self.b2 self.a2 softmax(self.z2) return self.a2 def backward(self, X, y_onehot): m X.shape[0] # 输出层梯度Softmax 交叉熵的简化结果 delta2 self.a2 - y_onehot # 隐藏层梯度 delta1 (delta2 self.W2.T) * relu_derivative(self.z1) # 参数梯度 dW2 self.a1.T delta2 / m db2 np.mean(delta2, axis0) dW1 X.T delta1 / m db1 np.mean(delta1, axis0) return dW1, db1, dW2, db2 def train_step(self, X, y_onehot): self.forward(X) dW1, db1, dW2, db2 self.backward(X, y_onehot) self.W1 - self.lr * dW1 self.b1 - self.lr * db1 self.W2 - self.lr * dW2 self.b2 - self.lr * db2 def predict(self, X): probs self.forward(X) return np.argmax(probs, axis1) def compute_loss(self, X, y_onehot): probs self.forward(X) return -np.mean(np.sum(y_onehot * np.log(probs 1e-8), axis1))这里有几个细节我想特别说一下。softmax函数里为什么要减去每行的最大值因为np.exp在输入很大的时候会溢出减掉最大值之后所有指数都小于等于0既不会溢出而且Softmax的结果不变。这是工程实现里常用的数值稳定技巧写框架源码的人也在用。损失函数里加1e-8也是防log(0)导致NaN的常规操作。预测概率如果出现极其接近0的值log会算出负无穷加上这个小常数就能避免数值问题。损失计算没参与梯度计算所以这个平滑处理不影响训练。3.2 训练循环与数据准备用Iris数据集跑通数据集我选的是鸢尾花Iris数据集理由很简单它只有4个特征、3个类别、150条样本是机器学习界的“Hello World”训练速度快结果稳定适合用来验证实现有没有写对。完整训练代码如下from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler # 加载数据 iris load_iris() X iris.data y iris.target # 标准化让每个特征均值0、方差1 scaler StandardScaler() X scaler.fit_transform(X) # 划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) # one-hot编码标签 def to_onehot(y, num_classes3): onehot np.zeros((y.shape[0], num_classes)) onehot[np.arange(y.shape[0]), y] 1 return onehot y_train_onehot to_onehot(y_train) y_test_onehot to_onehot(y_test) # 创建模型并训练 model MLP(n_input4, n_hidden8, n_output3, lr0.1) epochs 500 for epoch in range(epochs): model.train_step(X_train, y_train_onehot) if (epoch 1) % 50 0: loss model.compute_loss(X_train, y_train_onehot) pred model.predict(X_train) acc np.mean(pred y_train) print(fEpoch {epoch 1:3d}, Loss: {loss:.4f}, Train Acc: {acc:.4f}) # 测试集评估 test_pred model.predict(X_test) test_acc np.mean(test_pred y_test) print(fTest Acc: {test_acc:.4f})有一行代码不能省就是StandardScaler标准化。我之前见过很多人第一次手写网络数据不预处理直接往里塞结果发现loss降得极慢或者干脆不降。原因很简单神经网络对输入特征的尺度很敏感如果某个特征数值范围是0到10另一个是0到1000权重更新的过程就会被大数值特征主导模型迟迟学不到正确的决策边界。标准化之后所有特征都在同一尺度上梯度下降才能正常发挥。train_test_split里我加了stratifyy这个参数的作用是保证划分后的训练集和测试集中三个类别的比例跟原始数据一致避免出现测试集里恰好某个类别特别少、准确率波动剧烈的情况。3.3 运行结果分析从loss曲线看模型状态把上面代码跑一轮你会看到类似这样的输出Epoch 50, Loss: 0.2048, Train Acc: 0.9583 Epoch 100, Loss: 0.1480, Train Acc: 0.9750 Epoch 150, Loss: 0.1195, Train Acc: 0.9917 Epoch 200, Loss: 0.1010, Train Acc: 0.9917 Epoch 250, Loss: 0.0878, Train Acc: 0.9917 Epoch 300, Loss: 0.0783, Train Acc: 1.0000 Epoch 350, Loss: 0.0709, Train Acc: 1.0000 Epoch 400, Loss: 0.0650, Train Acc: 1.0000 Epoch 450, Loss: 0.0601, Train Acc: 1.0000 Epoch 500, Loss: 0.0560, Train Acc: 1.0000 Test Acc: 0.9667这段输出可以说是非常“健康”的loss一直在平滑下降没有剧烈震荡训练准确率稳步爬升测试集准确率96.7%对一个小型MLP来说已经很不错了。如果你把loss变化画成曲线会发现它是一条斜率逐渐变缓的下坡线——开始降得快后面越来越慢。这不是bug而是梯度下降的正常现象越接近最优点梯度越小更新幅度自然变小。所以在训练后期看到loss降得慢不代表模型有问题耐心多跑几个epoch就行。鸢尾花数据集比较简单隐藏层8个神经元就够用了。如果你拿它跑更难的数据集比如手写数字MNIST8个神经元就明显不够通常要几百个。隐藏层神经元数量没有标准答案只能根据任务复杂度试出来。4. 常见问题与调参避坑实录4.1 损失不下降或者下降极慢问题出在哪这种情况太常见了我自己初学的时候也被折磨过好几个晚上。按照我的排查经验优先级从高到低依次是数据标准化、学习率、初始化、梯度计算。先确认数据有没有做标准化。手写网络不像框架那样对新手友好框架里很多内置数据集都已经预处理过了但你自己处理的数据未必。我试过用原始像素值0到255直接训练loss半天不降标准化之后几十个epoch就收敛了。再检查学习率。忘了刚才那套代码里lr0.1吗你可以试着改成0.001跑一遍会发现500个epoch跑完loss还在0.5上下晃悠效果差很多。学习率太小是这个症状的典型表现。建议用对数尺度试参0.01、0.05、0.1、0.5看哪个收敛又快又稳。初始化也要检查。如果所有权重都设成0loss大概率不会动。前面说过的对称性问题很多教程都不强调但它一旦发生你调什么都白搭。最后才是梯度计算。如果是自己手推反向传播梯度写错是常有的事。怎么验证用梯度检查gradient check用数值方法近似计算梯度跟你的解析梯度对比。具体做法是给某个参数加一个极小量epsilon用(loss(Weps) - loss(W-eps)) / 2eps算出近似梯度再跟你的梯度比对。两者应该在4到5位有效数字上一致。这个技巧很土但极其好用我强烈建议所有手写网络的人试一次。4.2 损失出现NaN多半是数值爆炸NaN是训练里最吓人的输出因为一旦出现后面全完了。NaN的根源几乎都是数值溢出——某个中间值变得特别大导致exp、log或者梯度乘法的结果超出浮点数能表示的范围。最常见的原因是学习率太大。想象你在下山每一步都迈出十米远结果直接跨过峡谷飞到对面山腰去了甚至飞到地球外面。解决方法是把学习率降一个数量级比如从0.1改成0.01。其次是初始权重太大。He初始化里那个sqrt(2/n)不是随便来的它就是为了控制激活值的方差不会逐层放大。如果你手滑改成np.random.randn * 10第一层算出来的z1就会是几十上百的量级ReLU之后数值依然很大到Softmax那里exp直接就溢出了。排查NaN的时候我习惯在forward和backward里打印每一层的输出范围和梯度均值看到底是哪一步爆掉的。定位到具体位置之后修复就很简单了。4.3 环境配置与代码运行常见问题很多初学者在真正开始写网络之前会先卡在环境配置上。这里我把高频问题一次性说清楚。Python版本建议用3.8以上。装NumPy最省事的方式是用pip如果你的网络环境在国内直接用清华源会快很多pip install numpy scikit-learn -i https://pypi.tuna.tsinghua.edu.cn/simplescikit-learn在这个项目里只用来加载数据和划分训练集如果你不想装它也可以直接下载鸢尾花CSV数据然后用NumPy手动读。关于编辑器用VSCode的话记得先选择正确的Python解释器——按CtrlShiftP输入Python: Select Interpreter选你装了NumPy的那个环境。如果你在终端里直接跑脚本没问题但在VSCode里ImportError: No module named numpy99%是解释器选错了这个问题我见过太多次了。另外提一个容易被忽略的细节如果你拿到代码直接跑可能会碰到AttributeError: MLP object has no attribute z1。这是因为forward还没执行就调了backward。我们的train_step里是先调forward再调backward的所以不会出问题但你自己写代码时如果单独调用backward就需要注意这个执行顺序问题。4.4 关于隐藏层宽度和深度的选择经验最后聊点调参观。对于鸢尾花这类小而简单的数据集一到两个隐藏层、每层8到32个神经元就非常够了再增加宽度和深度也只是过拟合训练集测试集准确率反而可能下降。你可以跑个对比隐藏层从8改成128训练准确率很快到100%但测试集依然在96%到97%之间徘徊——这就是过拟合的信号。模型能力不是越强越好而是跟任务的复杂度匹配。深度学习的真正挑战在于怎样在“拟合训练数据”和“泛化到新数据”之间找到平衡点。手写这个小MLP的过程最大的收获就是让你亲眼看到这个平衡是怎么在训练过程中发生的而这些经验是直接用框架感受不到的。我自己动手写过这个MLP之后再去看PyTorch里nn.Linear、nn.ReLU、CrossEntropyLoss这些模块一眼就能明白它们背后在算什么东西。以后再遇到模型不收敛的问题心里也更有底——因为你知道通往loss的那条路上每一段都发生了什么。最后再分享一个小技巧训练过程中除了打印loss你还可以顺手打印每一层梯度的均值和标准差。如果你的梯度过早变成0模型多半是陷入了梯度消失如果梯度剧烈震荡学习率大概率太大。这个习惯帮我避过不少坑现在也推荐给你。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价