从零手写多层感知机:Python与NumPy实现神经网络与反向传播
2026/9/16 22:24:30 网站建设 项目流程

这两年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为中心,会让梯度更新效率打折扣。
  • Tanh:tanh(z) = (e^z - e^(-z)) / (e^z + e^(-z)),输出范围-1到1,以0为中心,比Sigmoid好一些,但饱和区问题依然存在。
  • ReLU:ReLU(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, axis=0) dW1 = X.T @ delta1 / m db1 = np.mean(delta1, axis=0)

这里除以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,确保环境里装了numpysklearn(sklearn只用来加载数据集和划分训练测试集)就能直接运行。

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, axis=1, keepdims=True)) return exp_z / np.sum(exp_z, axis=1, keepdims=True) class MLP: def __init__(self, n_input, n_hidden, n_output, lr=0.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, axis=0) dW1 = X.T @ delta1 / m db1 = np.mean(delta1, axis=0) 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, axis=1) def compute_loss(self, X, y_onehot): probs = self.forward(X) return -np.mean(np.sum(y_onehot * np.log(probs + 1e-8), axis=1))

这里有几个细节我想特别说一下。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_size=0.2, random_state=42, stratify=y ) # one-hot编码标签 def to_onehot(y, num_classes=3): 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_input=4, n_hidden=8, n_output=3, lr=0.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(f"Epoch {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(f"Test Acc: {test_acc:.4f}")

有一行代码不能省,就是StandardScaler标准化。我之前见过很多人第一次手写网络,数据不预处理直接往里塞,结果发现loss降得极慢或者干脆不降。原因很简单:神经网络对输入特征的尺度很敏感,如果某个特征数值范围是0到10,另一个是0到1000,权重更新的过程就会被大数值特征主导,模型迟迟学不到正确的决策边界。标准化之后所有特征都在同一尺度上,梯度下降才能正常发挥。

train_test_split里我加了stratify=y,这个参数的作用是保证划分后的训练集和测试集中三个类别的比例跟原始数据一致,避免出现测试集里恰好某个类别特别少、准确率波动剧烈的情况。

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个神经元就够用了。如果你拿它跑更难的数据集,比如手写数字MNIST,8个神经元就明显不够,通常要几百个。隐藏层神经元数量没有标准答案,只能根据任务复杂度试出来。

4. 常见问题与调参避坑实录

4.1 损失不下降或者下降极慢,问题出在哪

这种情况太常见了,我自己初学的时候也被折磨过好几个晚上。按照我的排查经验,优先级从高到低依次是:数据标准化、学习率、初始化、梯度计算。

先确认数据有没有做标准化。手写网络不像框架那样对新手友好,框架里很多内置数据集都已经预处理过了,但你自己处理的数据未必。我试过用原始像素值(0到255)直接训练,loss半天不降,标准化之后几十个epoch就收敛了。

再检查学习率。忘了刚才那套代码里lr=0.1吗?你可以试着改成0.001跑一遍,会发现500个epoch跑完loss还在0.5上下晃悠,效果差很多。学习率太小是这个症状的典型表现。建议用对数尺度试参:0.01、0.05、0.1、0.5,看哪个收敛又快又稳。

初始化也要检查。如果所有权重都设成0,loss大概率不会动。前面说过的对称性问题,很多教程都不强调,但它一旦发生,你调什么都白搭。

最后才是梯度计算。如果是自己手推反向传播,梯度写错是常有的事。怎么验证?用梯度检查(gradient check):用数值方法近似计算梯度,跟你的解析梯度对比。具体做法是给某个参数加一个极小量epsilon,用(loss(W+eps) - loss(W-eps)) / 2eps算出近似梯度,再跟你的梯度比对。两者应该在4到5位有效数字上一致。这个技巧很土但极其好用,我强烈建议所有手写网络的人试一次。

4.2 损失出现NaN,多半是数值爆炸

NaN是训练里最吓人的输出,因为一旦出现,后面全完了。NaN的根源几乎都是数值溢出——某个中间值变得特别大,导致explog或者梯度乘法的结果超出浮点数能表示的范围。

最常见的原因是学习率太大。想象你在下山,每一步都迈出十米远,结果直接跨过峡谷飞到对面山腰去了,甚至飞到地球外面。解决方法是把学习率降一个数量级,比如从0.1改成0.01

其次是初始权重太大。He初始化里那个sqrt(2/n)不是随便来的,它就是为了控制激活值的方差不会逐层放大。如果你手滑改成np.random.randn * 10,第一层算出来的z1就会是几十上百的量级,ReLU之后数值依然很大,到Softmax那里exp直接就溢出了。

排查NaN的时候,我习惯在forwardbackward里打印每一层的输出范围和梯度均值,看到底是哪一步爆掉的。定位到具体位置之后,修复就很简单了。

4.3 环境配置与代码运行常见问题

很多初学者在真正开始写网络之前,会先卡在环境配置上。这里我把高频问题一次性说清楚。

Python版本建议用3.8以上。装NumPy最省事的方式是用pip,如果你的网络环境在国内,直接用清华源会快很多:

pip install numpy scikit-learn -i https://pypi.tuna.tsinghua.edu.cn/simple

scikit-learn在这个项目里只用来加载数据和划分训练集,如果你不想装它,也可以直接下载鸢尾花CSV数据然后用NumPy手动读。

关于编辑器,用VSCode的话,记得先选择正确的Python解释器——按Ctrl+Shift+P,输入Python: Select Interpreter,选你装了NumPy的那个环境。如果你在终端里直接跑脚本没问题,但在VSCode里ImportError: No module named numpy,99%是解释器选错了,这个问题我见过太多次了。

另外提一个容易被忽略的细节:如果你拿到代码直接跑,可能会碰到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.Linearnn.ReLUCrossEntropyLoss这些模块,一眼就能明白它们背后在算什么东西。以后再遇到模型不收敛的问题,心里也更有底——因为你知道通往loss的那条路上,每一段都发生了什么。

最后再分享一个小技巧:训练过程中,除了打印loss,你还可以顺手打印每一层梯度的均值和标准差。如果你的梯度过早变成0,模型多半是陷入了梯度消失;如果梯度剧烈震荡,学习率大概率太大。这个习惯帮我避过不少坑,现在也推荐给你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询