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

📅 发布时间:2026/10/17 20:53:27
从感知机到多层神经网络:手写实现与反向传播实战
我看过太多人学机器学习一上来就抱着深度学习框架敲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, lr0.1, epochs10): 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 才输出 1XOR 的规则是两个输入不同才输出 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_features2, lr0.1, epochs10) 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, axis0) dW1 X^T δ1 / m db1 mean(δ1, axis0)这里除以 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, lr0.1, seed42): 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, axis0) / m delta_hidden (delta_out self.W2.T) * self.sigmoid_deriv(self.hidden) dW1 X.T delta_hidden / m db1 np.sum(delta_hidden, axis0) / 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, epochs1000, verboseTrue): 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(fepoch {epoch:4d} loss {loss:.6f}) def predict(self, X, threshold0.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]], dtypefloat) y np.array([[0], [1], [1], [0]], dtypefloat) mlp MLP(2, 4, 1, lr0.5, seed42) mlp.fit(X, y, epochs2000) print(mlp.predict(X))只要反向传播没有写错这个网络在几百轮之后 loss 会明显下降最终预测结果能完全拟合 XOR。在第一次跑通时看到这样的结果其实是很有成就感的因为这意味着你亲手写出了一台能够解决感知机“不可能任务”的小机器。这里有个值得解释的经验为什么学习率取 0.5因为我们就四个样本数据量非常小学习率小的话收敛会特别慢。如果你取 0.012000 轮之后 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(weps) - L(w-eps)) / (2*eps)近似梯度再和你手写的反向传播梯度对比。示例代码如下def numerical_gradient(loss_fn, params, eps1e-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 背后的动作都变得透明。你不需要成为反向传播的推导机器但至少要清楚梯度从输出流向输入的路径。这种看着网络不慌的底气很值得花时间去换。