☰
从感知机到多层神经网络:手写实现与反向传播实战
2026/10/17 20:53:17 网站建设 项目流程

我看过太多人学机器学习,一上来就抱着深度学习框架敲model.fit,调参调到怀疑人生,却连 forward 和 backward 到底在干嘛都说不清。这篇文章我换个路子:自己动手实现神经网络,从最原始的感知机(perceptron)开始,一步一步把网络变宽变深,最终写到多层神经网络。先把基础打透,你后面看框架源码、调 bug、设计模型都会顺手很多。内容适合刚接触机器学习、对神经网络只有模糊概念的读者,也适合想彻底搞懂反向传播细节的开发者。

很多人觉得手写神经网络是一个“早就过时的练习”,但我的实际感受恰好相反。感知机虽然简单,它却是理解一切神经网络的地基。只要你亲手把这个地基从零搭起来,后面看任何复杂模型的源码,都会有一种“不过如此”的底气。这篇文章没有任何隐藏依赖,只需要 Python 和 NumPy。我不会只贴代码,还会把每一步设计背后的原因讲清楚,尤其是那些容易踩坑、但常规文档里不会写的地方。

1. 为什么推荐从感知机开始手写神经网络

1.1 感知机不是玩具,而是神经网络的骨架

感知机是 1958 年就提出的经典模型,别因为它古老就轻视。它的结构用一句话就能概括:先把输入特征加权求和,再加上一个偏置项,最后丢进一个阈值函数做判断。写成数学形式就是 y = step(w·x + b)。你可以把它想象成一套“加权打分系统”:每个维度先给出自己的分数,然后乘以权重、累加,超过分数线就录取,没超过就不录取。权重代表这个维度有多重要,偏置代表整体门槛定在哪里。

这个结构为什么是骨架?因为现代神经网络中的每一个线性层,本质上做的依然是“线性变换 + 非线性激活”。只不过我们把阶跃函数换成了平滑可导的 sigmoid 或 ReLU,把单个打分员换成了一整层打分员。你剥开现在的深度学习框架,最里面那一行也就是矩阵乘法加激活函数。别被各种花哨的名字吓到,多层感知机这个名字已经说明了一切:多层感知机就是感知机堆起来的。

我见过不少初学者花大量时间研究“什么是神经元”“什么是激活函数”,其实不如直接写一遍感知机。写一遍之后你会发现,神经元不是什么仿生学黑魔法,它就是一组参数加一个判断规则。理解了这一点,你再看反向传播、卷积、Transformer,至少底层语言是通的。

1.2 手写实现和调库学习到底差在哪

直接用框架最大的问题是:梯度是自动算的,参数是自动更新的,中间过程被封装得严严实实。模型跑通了当然很好,可一旦不收敛,你根本不知道问题出在前向传播、反向传播还是数据预处理。手写实现就没这个困扰,因为每一步都是你自己写的,你可以随时随地打印每一层输出的形状、每一个梯度的均值,快速定位问题。

我有个很深的体会:调库能让你一天跑出十个模型,但手写一遍能让你在模型出问题时不用到处问人。你想想,框架帮你把model.fit变成一行代码,背后是前向、损失、反向、优化四个大模块。如果你对这些模块没有实感,遇到 loss 为 NaN、精度死活不动这类问题,基本只能靠猜。

所以我一直建议,入门前至少完整手写一次从感知机到多层神经网络的过程。这个过程不需要太长,却能把“参数初始化、前向传播、损失计算、反向传播、参数更新”这条主线彻底印在脑子里。等你回到框架,看见model.add、model.compile时,就不会觉得是在调用一个魔法盒子。这篇文章后面的内容,就是带你走完这条主线。

2. 环境准备与代码骨架

2.1 依赖选择:只要 Python 和 NumPy

手写神经网络最大的优点之一是依赖极轻。你不需要 GPU,不需要任何深度学习框架,只需要一个能跑 Python 的环境和 NumPy。NumPy 提供多维数组、矩阵乘法、广播机制,这些都是神经网络的基础工具。如果你后面想可视化 loss 曲线,可以再装一个 matplotlib,但不装也完全不影响核心流程。

环境搭建并不复杂。建议新建一个虚拟环境,避免和系统自带 Python 环境互相污染。进入项目目录后执行:

python -m venv venv source venv/bin/activate pip install numpy matplotlib

如果你用的是 Windows,激活虚拟环境的命令是venv\Scripts\activate。装好之后可以顺手验证一下版本:

import numpy as np print(np.__version__)

还有一个必须养成的习惯:设置随机种子。因为后面我们要用随机数初始化权重,如果不固定种子,每次运行结果都不一样,排查问题时很容易把“随机波动”误判成代码 bug。在脚本开头写上np.random.seed(42),保证实验可复现。这个习惯看起来小,实际价值非常大,尤其是当你调试到深夜、反复对比两次运行结果的时候。

2.2 搭建基础框架:前向传播的最小闭环

我们先不急着写完整网络,而是写一个最小前向传播的类。这个类只有一个线性层,输入特征经过加权求和和偏置,输出一个结果。代码很短:

import numpy as np class NeuralNetwork: def __init__(self, n_input, n_output): self.w = np.random.randn(n_input, n_output) * 0.01 self.b = np.zeros(n_output) def forward(self, x): return x @ self.w + self.b

这里@是矩阵乘法。假设输入 x 的形状是(3, 2),表示 3 个样本、每个样本 2 个特征;w 的形状是(2, 1);那么输出就是(3, 1),也就是每个样本得到一个标量。NumPy 的广播机制会把偏置 b 自动加到每一行上,省去显式循环。

这个类还没有任何学习能力,但它已经包含了一个“神经网络层”的全部要素。后面要做的事情无非是:给这个类加上损失函数、反向传播、参数更新,再把它扩展成两层甚至三层。所以不要小看这两行代码,它们是后续所有实现的起点。

这里还要强调一个初始化细节:权重不能全零。如果 w 初始化为全零,输入乘以零之后所有输出都一样,反向传播时每个神经元的梯度也会完全对称,网络永远学不出差异化特征。这就是后面会细说的“对称性问题”。我现在只用randn乘一个小系数,比如 0.01,先保证前向传播不饱和。

3. 感知机的完整实现

3.1 单层感知机的数学原理与代码实现

单层感知机的决策过程可以拆成两步:先计算加权和 z = w1·x1 + w2·x2 + b,再用阶跃函数做判断:如果 z 大于 0,输出 1,否则输出 0。这个模型只能输出 0 或 1,所以天然适合做二分类。它无法输出“概率”或者置信度,这也正是它后来被更完整模型替代的原因之一。

训练感知机用的是感知机学习规则。核心思想是:拿当前样本的特征和预测结果去比较,如果预测错了,就朝正确的方向调整权重。数学形式如下:

w += lr * (y_true - y_pred) * x b += lr * (y_true - y_pred)

其中 lr 是学习率,(y_true - y_pred)是误差。如果误差为正,说明模型把样本判成了 0,但真实标签是 1,我们要把权重往 x 的方向推,让加权和 z 变大;如果误差为负,则反过来。这个规则虽然简单,却是“误差驱动参数修正”的最原始版本,梯度下降的思想已经在这里出现了。

对应代码我写成一个完整的类:

import numpy as np class Perceptron: def __init__(self, n_features, lr=0.1, epochs=10): self.lr = lr self.epochs = epochs self.w = np.random.randn(n_features) * 0.1 self.b = 0.0 def predict(self, x): return np.where(x @ self.w + self.b > 0, 1, 0) def fit(self, X, y): for _ in range(self.epochs): for xi, yi in zip(X, y): pred = self.predict(xi) err = yi - pred self.w += self.lr * err * xi self.b += self.lr * err

注意这里的 fit 过程是“在线更新”,也就是每看到一个样本就立刻更新参数,而不是等所有样本算完再统一更新。对于感知机来说,这种逐样本更新的收敛行为更直观,也更接近经典学习规则的描述。

3.2 用真实数据检验感知机的边界

写完感知机,最好立刻拿真实小数据集来测试。我最喜欢用的是 AND 和 XOR 这两组二分类数据。AND 的输入输出规则是:两个输入都是 1 才输出 1;XOR 的规则是:两个输入不同才输出 1。数据只有四行,一眼能看透。

X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]]) y_and = np.array([0, 0, 0, 1]) y_xor = np.array([0, 1, 1, 0]) p = Perceptron(n_features=2, lr=0.1, epochs=10) p.fit(X, y_and) print(p.predict(X)) p.fit(X, y_xor) print(p.predict(X))

如果代码正确,AND 大概很快学会,输出结果接近[0 0 0 1]。但 XOR 永远学不会,无论你怎么调学习率、怎么加大迭代次数,输出都不可能完全等于[0 1 1 0]。原因说穿了也很简单:单层感知机只能画一条直线来分割数据。AND 这个类别分布能被一条直线分开,而 XOR 在二维平面上天然需要两条直线组合才能分开。

这个实验对理解神经网络至关重要。它逼着你接受一个现实:单个神经元的能力非常有限。正因为感知机在 XOR 上失败,才催生了“把多个感知机组合起来”的想法。第一层先学出若干条中间直线,第二层再把它们组合成更复杂的决策边界。顺着这个思路往前走,你就自然走到了多层神经网络。

4. 从感知机到多层网络的关键跃迁

4.1 非线性激活函数为什么不可少

你可能想,既然一个感知机不够,那就多堆几个线性层,总能把问题解决吧?但这里有个数学上的坎:多个线性变换叠在一起,仍然等价于一个线性变换。假设第一层输出 h = W1x + b1,第二层输出 o = W2h + b2,合并后就是 o = W2W1x + (W2b1 + b2)。这仍然是一条直线方程。也就是说,如果你只做线性运算,堆一百层和堆一层没有本质区别,模型容量没有增加。

要让网络真正“弯”起来,必须在层与层之间插入非线性激活函数。最简单的理解是这样的:线性变换只能对数据做平移和拉伸,就像你在一个有网格的纸上移动、放缩图形,网线最后还是直线;非线性函数则像把纸揉皱再摊开,原本一条直线可以被折叠成任意复杂的形状。sigmoid、ReLU、tanh 就是用来“揉纸”的工具。

sigmoid 是入门阶段最好的选择,因为它数学形式简单、处处可导,公式是 σ(z) = 1 / (1 + e^(-z))。缺点是输出不是零中心,而且两端饱和,导数会趋近于零,容易造成深层网络的梯度消失。工程上更常用 ReLU,也就是 max(0, z)。不过我们做手写实现时,先用 sigmoid 把反向传播的数学逻辑吃透,再换成 ReLU 就会发现只是改一个函数的问题。

4.2 反向传播的链式法则拆解

反向传播听起来很高深,本质就是链式法则的工程化应用。我的理解方式是这样的:网络前向计算得到一个预测值,然后算损失,也就是预测和真实标签之间的距离。误差产生之后,需要“怪罪”到每一层的参数头上。这个怪罪的过程,就是梯度从输出层往输入层逐层传递。可以想象成一条瀑布从山顶流下,每一层都是一块分水岭,水流的“责任”会按比例分给每一层。

具体到两层网络,我们用符号表示:输入 X,第一层的权重 W1、偏置 b1,隐藏层输出 h = σ(XW1 + b1);第二层的权重 W2、偏置 b2,输出层 o = hW2 + b2。损失函数用均方误差 L = 0.5 * mean((o - y)²)。这里的 0.5 是为了求导时消掉平方项的系数,纯粹是计算方便。

反向传播要先算输出层误差 δ2 = o - y。由于输出层后面没有接激活函数,这个误差就是残差本身。接着把误差往隐藏层传:δ1 = (δ2 W2^T) * σ'(h),其中 σ'(h) = h * (1 - h),这是 sigmoid 的导数。最后得到梯度:

dW2 = h^T δ2 / m db2 = mean(δ2, axis=0) dW1 = X^T δ1 / m db1 = mean(δ1, axis=0)

这里除以 m 是因为我们用整个批量来算平均梯度,m 是样本数量,这样更新方向的噪声更小,loss 曲线更平滑。

我最想提醒的是维度问题。如果 X 形状是(m, 2),W1 是(2, hidden),那么 h 是(m, hidden);W2 是(hidden, 1),δ2 是(m, 1);δ2 @ W2.T 得到(m, hidden),再和 σ'(h) 逐元素相乘,形状完全吻合。很多人在这一步写错,是因为手动实现时没有先标注清楚每个变量的 shape。我的习惯是写代码前先在草稿纸上标一遍形状,能避免一半以上的低级错误。

5. 多层神经网络的实现与训练

5.1 网络结构与初始化策略

现在我们把感知机升级成两层网络,用刚才推好的反向传播公式写代码。为了先在小数据上验证正确性,我用 XOR 数据做实验,网络结构设计成 2-4-1,也就是输入层 2 个特征,隐藏层 4 个神经元,输出层 1 个神经元。为什么要 4 个隐藏神经元?因为 XOR 至少需要两条分割线组合,4 个神经元足够产生多样化的中间特征。

完整代码:

import numpy as np class MLP: def __init__(self, n_in, n_hidden, n_out, lr=0.1, seed=42): np.random.seed(seed) self.lr = lr self.W1 = np.random.randn(n_in, n_hidden) * 0.1 self.b1 = np.zeros(n_hidden) self.W2 = np.random.randn(n_hidden, n_out) * 0.1 self.b2 = np.zeros(n_out) def sigmoid(self, x): return 1 / (1 + np.exp(-x)) def sigmoid_deriv(self, a): return a * (1 - a) def forward(self, X): self.hidden = self.sigmoid(X @ self.W1 + self.b1) self.output = self.hidden @ self.W2 + self.b2 return self.output def backward(self, X, y): m = X.shape[0] delta_out = self.output - y dW2 = self.hidden.T @ delta_out / m db2 = np.sum(delta_out, axis=0) / m delta_hidden = (delta_out @ self.W2.T) * self.sigmoid_deriv(self.hidden) dW1 = X.T @ delta_hidden / m db1 = np.sum(delta_hidden, axis=0) / m self.W2 -= self.lr * dW2 self.b2 -= self.lr * db2 self.W1 -= self.lr * dW1 self.b1 -= self.lr * db1 def fit(self, X, y, epochs=1000, verbose=True): for epoch in range(epochs): out = self.forward(X) loss = np.mean((out - y) ** 2) self.backward(X, y) if verbose and epoch % 100 == 0: print(f"epoch {epoch:4d} loss {loss:.6f}") def predict(self, X, threshold=0.5): return np.where(self.forward(X) > threshold, 1, 0)

初始化策略在这里很关键。权重必须用随机小值,而不能全零。全零初始化会让所有隐藏神经元完全对称,训练中更新量也相同,最后每个神经元学到的东西一模一样,相当于白白浪费了网络宽度。标准差也不能太大,我取 0.1。如果取 0.5,加权和很容易落到 sigmoid 的饱和区,梯度接近零,训练基本瘫痪。更严格的初始化方法如 Xavier、He 等,是在网络规模变大之后才需要重点考虑的,这里先用 0.1 跑通即可。

5.2 训练循环与损失变化观察

现在用 XOR 数据来训练这个两层网络:

X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float) y = np.array([[0], [1], [1], [0]], dtype=float) mlp = MLP(2, 4, 1, lr=0.5, seed=42) mlp.fit(X, y, epochs=2000) print(mlp.predict(X))

只要反向传播没有写错,这个网络在几百轮之后 loss 会明显下降,最终预测结果能完全拟合 XOR。在第一次跑通时,看到这样的结果其实是很有成就感的,因为这意味着你亲手写出了一台能够解决感知机“不可能任务”的小机器。

这里有个值得解释的经验:为什么学习率取 0.5?因为我们就四个样本,数据量非常小,学习率小的话收敛会特别慢。如果你取 0.01,2000 轮之后 loss 可能还很可观;但取大到 1.0 以上,loss 又容易在后期来回振荡,甚至发散到 NaN。每个数据集都有自己合适的学习率区间,我的习惯是先让代码跑通,然后画 loss 曲线,观察它是平稳下降还是在震荡,再手动调整数量级。

训练结束后,你可以顺手打印一下 W2 的值。你会看到不同隐藏神经元对应的权重有明显差异,这说明每个神经元各自学到了不同的中间特征。这种检查虽然简单,却能直观地告诉你“随机初始化 + 梯度更新”到底做了什么,比只看准确率要有意思得多。

真正上手更真实的任务时,建议先找一个手写数字识别数据集的子集来玩。把每个 8x8 的图片拉成 64 维向量,输入层就是 64,隐藏层设 16,输出层设 10。注意一定要先做归一化,把像素值从 0 到 255 缩放到 0 到 1。如果不缩放,输入数值太大,加权和很容易进入 sigmoid 饱和区,梯度极小,训练过程会非常痛苦。我在初学阶段就吃过这个亏,当时还在傻乎乎地调学习率,其实根源是数据没归一化。

6. 我踩过的坑与排查实录

6.1 梯度消失和激活函数选择的坑

手写神经网络的第一大坑就是梯度消失。sigmoid 单层网络问题不大,但当你把网络堆到三层以上,sigmoid 的饱和问题就变得非常明显。症状是:输出层还在缓慢更新,靠近输入的几层权重几乎一动不动,loss 卡在高位下不去。这背后的原因是 sigmoid 在输入绝对值较大时,导数趋近于零,误差信号在反向传播时被一层层“吃掉”,传到前面时已经微乎其微。

我当时的做法是打印每一层的梯度均值,发现第一层权重的梯度均值只有 1e-6 级别,而输出层还在 1e-2 级别,立刻就知道是梯度消失。解决办法有三板斧:把激活换成 ReLU,对输入做归一化,把权重初始化标准差调小。ReLU 在正区间导数恒为 1,梯度传递效率比 sigmoid 高得多,所以在现代网络里几乎是标配。

遇到 loss 长时间不变时,不要盲目加大学习率。先打印梯度均值、梯度标准差,确认梯度还在流动。如果梯度已经消失,加大学习率只会让前面一两层抖动,后面几层照样不动。

6.2 初始化不当导致的训练失败

初始化问题没有经验的人最容易忽略。第一种坑是全零初始化。表面上看网络能训练,loss 也在下降,但你把 W1 打印出来,会发现每一列数值几乎一模一样,隐藏神经元全部退化成同一个特征提取器。这本质上是因为反向传播时每个隐藏神经元的梯度也完全相同,更新步调一致,永远跳不出对称性。

第二种坑是权重初始化系数过大。标准的np.random.randn生成的随机数大致在 -3 到 3 之间,直接乘 1 会给 sigmoid 送进一个很大的加权和,导致激活值全部落在 0 或 1 附近,梯度接近零。这个 bug 表面上和梯度消失长得一样,但本质是初始化。解决办法是把随机数乘 0.01 或 0.1,让激活值落在 sigmoid 比较敏感的区域,梯度才能有效流动。

第三种坑是学习率过大导致的 NaN。loss 在某个 epoch 突然变成nan,不要急着改网络结构,先看学习率和数据里有没有极端值。如果你用的是真实数据集,某些特征取值特别大的话,梯度更新很容易产生剧烈波动,进而让权重飞掉。先做特征缩放、降低学习率,问题大概率就解决了。

6.3 维度错误与广播陷阱排查

手写神经网络最常见的报错就是矩阵形状不匹配。初学者最容易在反向传播里写反矩阵乘法的顺序。我的排查口诀是:shape 不对先打印,梯度异常先看均值,loss 不动先查饱和。

下面这个表格基本可以覆盖绝大部分入门问题:

现象可能原因处理方式
报错 matmul shape mismatch权重矩阵维度没对齐打印每层输入、权重、输出的 shape,按(样本数, 特征数)逐层核对
输出永远是同一个值权重全零初始化或激活饱和改用随机初始化,缩小权重标准差
loss 不下降数据未归一化、学习率过小、梯度消失先归一化,再调学习率,最后打印梯度均值
loss 变成 NaN学习率过大、数据含极端值降低学习率,检查数据是否有缺失或离群点
隐藏层输出全部相同初始化破坏对称性确认没有把权重设成全零或常数
训练很快但精度低模型容量太小或特征表达不足增加隐藏神经元数量,检查标签格式

还有一个很实用的小技巧:在 forward 和 backward 里加assert,检查矩阵形状是否符合预期。比如assert self.hidden.shape == (X.shape[0], self.W1.shape[1])。这样一旦代码出错,程序会在出问题的第一时间停下,而不是等你观察几千轮 loss 才发现结果不对。

7. 后续扩展方向与我的建议

7.1 值得尝试的几个扩展方向

你现在已经有了一个能跑的两层神经网络,接下来往哪个方向走,取决于你想补齐哪块短板。如果觉得梯度消失是当前最大的困惑,可以试着把 sigmoid 换成 ReLU,对比收敛速度;如果觉得训练速度太慢,可以把“全量更新”改成“mini-batch 更新”,按小批量样本计算梯度;如果要做多分类,可以继续把输出层的均方误差换成 softmax + 交叉熵;如果想做更深的网络,可以把 L2 正则和 Dropout 加进来,观察过拟合变化。

我建议不要一次加太多东西。每次只改一个环节。比如这周只做“sigmoid 换 ReLU”,下周再尝试 mini-batch。这样定位问题会非常容易,哪块改坏了,注意力就锁定在哪一块。把所有变量同时改掉,出了问题你根本不知道怪谁。

7.2 梯度检查:验证反向传播的保险丝

在继续扩展之前,强烈建议先做一次梯度检查。数值梯度是验证反向传播正确性的黄金标准。原理很简单:导数的定义就是极限,我们可以用有限差分近似它。对某个参数 w,把 w 分别加上和减去一个很小的 eps,算两次损失,用(L(w+eps) - L(w-eps)) / (2*eps)近似梯度,再和你手写的反向传播梯度对比。

示例代码如下:

def numerical_gradient(loss_fn, params, eps=1e-5): grads = [] for p in params: grad = np.zeros_like(p) it = np.nditer(p, flags=['multi_index']) while not it.finished: idx = it.multi_index old = p[idx] p[idx] = old + eps plus = loss_fn() p[idx] = old - eps minus = loss_fn() p[idx] = old grad[idx] = (plus - minus) / (2 * eps) it.iternext() grads.append(grad) return grads

这段代码很笨,要遍历每一个参数,但确实能救命。你只要跑一次对比,如果相对误差大于 1e-4,就说明反向传播里的某个公式写错了。我在加新层、改激活函数、改损失函数之后,都会先跑一遍梯度检查再训练,这已经成了固定习惯。

最后分享一个真实体会:我一开始也嫌手写太慢,总想直接冲框架,后来发现基本功不扎实,遇到问题根本无从下手。花几天把这个从感知机到多层神经网络的最小实现写完,再回头用框架,所有 API 背后的动作都变得透明。你不需要成为反向传播的推导机器,但至少要清楚梯度从输出流向输入的路径。这种看着网络不慌的底气,很值得花时间去换。

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

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

立即咨询