从零手写神经网络:用NumPy实现OR门理解反向传播原理

📅 2026/6/29 5:24:13
从零手写神经网络:用NumPy实现OR门理解反向传播原理
1. 项目概述为什么“从零开始”写神经网络比调用一行model.fit()更重要你有没有过这种感觉在 Kaggle 上跑通一个 ResNet50准确率刷到 98%但被问到“反向传播里链式求导的第三项怎么来的”瞬间卡壳或者调试模型时发现 loss 不下降第一反应是去 Stack Overflow 搜报错而不是翻开草稿纸推一遍梯度更新公式这恰恰暴露了我们这一代实践者最危险的盲区——把深度学习框架当成了黑箱里的神龛只敢供奉不敢拆解。这篇内容不是教你怎么用 PyTorch 搭积木而是带你亲手锻造一把刻刀用纯 Python 和 NumPy从零实现一个能跑通“OR 逻辑门”的神经网络。它不追求炫技只解决三个最根本的问题权重到底怎么更新误差到底怎么反传为什么非得加个 bias这些问题的答案藏在每一行代码背后的数学推导里也藏在你调试失败十次后突然顿悟的凌晨三点。我带过十几期 AI 工作坊发现一个铁律能手写反向传播的人调参时看 loss 曲线的眼神和只会调 learning_rate 的人完全是两种物种。他们知道某个陡降是梯度爆炸的前兆某个平台期是激活函数饱和的信号而不是盲目地改 batch_size。这种直觉没法从文档里 copy-paste只能从矩阵乘法、链式法则、偏导数的笔尖上长出来。关键词“Mathematics”在这里不是装饰——它是整座大厦的地基。你看不到钢筋但每一块砖都靠它咬合。比如 sigmoid 函数的导数为什么是sigmoid(x) * (1 - sigmoid(x))不是因为它“看起来很美”而是因为(1/(1e^{-x}))经过商法则和链式法则推导后必然坍缩成这个形式。再比如权重更新公式w w - lr * ∂E/∂w那个∂E/∂w为什么必须拆成∂E/∂out * ∂out/∂in * ∂in/∂w三段相乘因为这是复合函数求导的唯一路径跳过任何一环你的梯度就断了。这些推导不是为了考试而是为了让你在模型不 work 时能像老中医搭脉一样精准定位是“输入层气血不通”数据没归一化还是“隐藏层经络淤堵”梯度消失。所以别把它当成一门课当成一次外科手术你将亲手切开神经网络的皮肤暴露它的血管数据流、神经梯度流和肌肉参数更新然后一针一线把它缝合起来。当你第一次看到自己写的weights - lr * deriv_final让0,0的预测从 0.5 坠落到 0.02那种掌控感是任何预训练模型都给不了的。2. 核心设计思路为什么选择“单层感知机”作为起点它不是过时的玩具很多人看到“从零实现神经网络”第一反应是“现在谁还手写 BP直接上 Transformer 吧” 这种想法就像学游泳前先研究流体力学方程却拒绝下水扑腾。我们的设计核心非常明确用最小的、可手工验证的系统承载最完整的神经网络原理。这就是为什么我们死磕“单层感知机”Perceptron而不是一上来就堆 LSTM 或 Attention。它不是历史的尘埃而是理解一切的钥匙。首先它足够小小到你能用计算器复现每一步。拿 OR 门的四组输入[0,0], [0,1], [1,0], [1,1]来说整个前向传播就是四次dot(input, weights) bias再套一次sigmoid。你可以拿出一张纸把weights[[0.1],[0.2]],bias0.3代进去手动算出in_o和out_o结果和代码输出分毫不差。这种“所见即所得”的确定性是建立信任的第一步。当你确信自己的sigmoid函数没写错才能放心地怀疑是梯度计算出了问题。其次它足够完整完整到囊括了所有核心范式。它有输入层input features有输出层output有激活函数sigmoid有损失计算error有优化器gradient descent甚至有超参数learning rate。它只是没有“隐藏层”但这恰恰是优势——没有隐藏层意味着梯度路径是线性的、唯一的、不可绕行的。你无法把∂E/∂w1的错误甩锅给w2的梯度干扰。每一个变量的生死都由你亲手定义的公式决定。这就像学开车先在空旷停车场练直角转弯和倒车入库而不是一上手就挑战秋名山五连发卡弯。最后它足够真实真实到能解决实际问题。OR 门看似简单但它代表了所有二分类任务的本质在高维空间中寻找一个超平面把两类样本分开。[0,0]是一类输出 0[0,1], [1,0], [1,1]是另一类输出 1。你的网络要做的就是通过不断调整weights和bias让这个超平面在这里是二维平面上的一条直线恰好穿过[0,0]和其他点的分界。这和你用 CNN 分辨猫狗本质没有任何区别只是维度从 224x224x3 降到了 2。我试过把这里的input_features换成一组真实的、经过 PCA 降维到 2D 的鸢尾花数据同样的代码稍作修改就能画出清晰的决策边界。这证明我们搭建的不是玩具而是一台可伸缩的原理引擎。提示选择单层而非多层并非技术妥协而是认知策略。多层网络的梯度是“树状分叉”的你永远不知道是哪一层的权重在捣鬼单层网络的梯度是“线性串联”的你一眼就能看出∂E/∂w的每个因子来自哪里。这是为大脑减负不是为代码减负。3. 数学原理深挖链式法则不是魔法是三把精密的手术刀所有关于“反向传播很难”的抱怨根源都在于把链式法则Chain Rule当成了一个需要背诵的咒语而不是一套可以拆解、可以触摸的工具。在我们的单层网络里它被具象化为三把手术刀每一把负责切开一个函数接口。我们以更新权重w1为例目标是求出∂E/∂w1其中E是误差这里用out_o - target_output简化表示。整个过程就是这三把刀的接力切割。3.1 第一把刀误差对输出的敏感度∂E/∂out_o这是最直观的一刀。误差E直接由网络的预测输出out_o决定。如果out_o是 0.69而目标是 1那么E 0.69 - 1 -0.31。所以∂E/∂out_o就是1因为E out_o - targettarget是常数。这把刀很简单但它定义了“方向”out_o需要变大还是变小答案是变大因为误差是负的增大out_o能让E向 0 靠拢。这把刀的锋利之处在于它把抽象的“优化目标”转化成了具体的“数值信号”。3.2 第二把刀输出对输入的敏感度∂out_o/∂in_oout_o并不是凭空产生的它是由in_o加权求和后的输入经过sigmoid函数变换而来out_o sigmoid(in_o)。所以out_o对in_o的变化有多敏感这正是sigmoid函数的导数。我们来亲手推导它破除神秘感sigmoid(in_o) 1 / (1 e^(-in_o)) 令 u 1 e^(-in_o), 则 sigmoid u^(-1) 根据链式法则d(sigmoid)/d(in_o) d(u^(-1))/du * du/d(in_o) d(u^(-1))/du -u^(-2) -1/u² du/d(in_o) d(1)/d(in_o) d(e^(-in_o))/d(in_o) 0 e^(-in_o) * (-1) -e^(-in_o) 所以 d(sigmoid)/d(in_o) (-1/u²) * (-e^(-in_o)) e^(-in_o) / u² 但 u 1 e^(-in_o), 所以 u² (1 e^(-in_o))² 而 sigmoid 1/u, 所以 1 - sigmoid 1 - 1/u (u-1)/u e^(-in_o)/u 因此e^(-in_o)/u² (1/u) * (e^(-in_o)/u) sigmoid * (1 - sigmoid)看sigmoid(in_o) sigmoid(in_o) * (1 - sigmoid(in_o))这个经典公式不是天上掉下来的而是商法则和指数函数求导的必然产物。它的物理意义极其清晰当in_o很大正时sigmoid接近 1其导数接近1*00说明输出已经“饱和”再怎么加大in_oout_o也几乎不变当in_o很小负时sigmoid接近 0导数也接近0*10同样饱和只有当in_o在 0 附近时导数最大0.25此时out_o对in_o的变化最敏感。这把刀切开了非线性激活函数的“弹性系数”。3.3 第三把刀输入对权重的敏感度∂in_o/∂w1in_o是什么是input_features * weights bias的点积。对于第一个权重w1它只和第一个输入特征x1相乘。所以in_o x1*w1 x2*w2 bias。对w1求偏导x1是常数x2*w2和bias都是常数所以∂in_o/∂w1 x1。这把刀最朴实无华却至关重要它把梯度从“抽象的数学空间”拉回了“具体的输入数据”。x1的值有多大w1就该被“拉动”多大。如果x10比如输入是[0,1]那么无论误差多大w1的梯度都是 0——它在这次更新中完全“失声”因为它的输入是 0它对当前预测毫无贡献。这解释了为什么数据归一化如此重要如果x1动辄上万w1的梯度就会爆炸如果x1总是 0.001w1的梯度就会消失。3.4 三刀合一梯度的最终形态与矩阵实现现在把三把刀的结果乘起来∂E/∂w1 (∂E/∂out_o) * (∂out_o/∂in_o) * (∂in_o/∂w1)。代入上面的推导就是1 * sigmoid(in_o)*(1-sigmoid(in_o)) * x1。注意这是一个标量。但在我们的代码里input_features是一个(4,2)的矩阵4 个样本2 个特征weights是(2,1)所以∂E/∂weights必须是一个(2,1)的矩阵。如何把 4 个样本的梯度“汇总”到一个权重上答案是矩阵转置与点积。input_features.T是(2,4)deriv即∂E/∂out_o * ∂out_o/∂in_o是(4,1)它们的点积np.dot(input_features.T, deriv)结果是(2,1)完美匹配weights的形状。这个操作的数学本质是对每个权重wi计算它在所有样本上的梯度贡献之和。比如w1的总梯度 x1_sample1 * error1 x1_sample2 * error2 x1_sample3 * error3 x1_sample4 * error4。这不再是纸上谈兵而是内存里实实在在的矩阵运算。我实测过把np.dot拆成 for 循环手动累加结果完全一致只是慢了 100 倍。这证明矩阵运算是链式法则在工程世界的优雅投影。4. 实操全流程从零开始一行一行写出能跑通的神经网络现在让我们把前面所有的数学浇铸成可执行的 Python 代码。这不是复制粘贴而是每一步都要理解“为什么这样写”。我会用最直白的语言解释每一行代码背后的设计意图和潜在陷阱。4.1 数据准备为什么 OR 门的数据是完美的教学样本import numpy as np # 定义输入特征四组布尔值构成一个 (4,2) 矩阵 input_features np.array([[0,0], [0,1], [1,0], [1,1]]) print(输入特征形状:, input_features.shape) # (4, 2) print(输入特征内容:\n, input_features) # 定义目标输出OR 门的真值表reshape 成 (4,1) 列向量 target_output np.array([[0,1,1,1]]) target_output target_output.reshape(4,1) print(目标输出形状:, target_output.shape) # (4, 1) print(目标输出内容:\n, target_output)这段代码的精妙之处在于它的“可穷举性”。只有 4 个样本你可以把它们全部列在纸上。[0,0]应该输出 0[0,1]和[1,0]应该输出 1[1,1]也应该输出 1。这让你在调试时能立刻判断“我的网络在0,0这个点上预测是 0.7那肯定是错了”。如果换成 MNIST 的 60000 张图片你根本无法定位是哪一张导致了问题。另外reshape(4,1)是强制要求。因为np.dot(input_features, weights)的结果是(4,1)target_output必须是相同形状才能进行error out_o - target_output的逐元素减法。我踩过的坑是忘了 reshape导致target_output是(1,4)减法变成了广播broadcasting结果完全错误debug 了半小时才意识到是形状问题。4.2 初始化随机权重与 bias 的哲学# 初始化权重两个输入特征对应两个权重初始化为小的随机数 weights np.array([[0.1], [0.2]]) print(初始权重形状:, weights.shape) # (2, 1) print(初始权重内容:\n, weights) # 初始化偏置一个标量设为 0.3 bias 0.3 # 学习率一个超参数控制每次更新的步长通常很小 lr 0.05为什么权重不能全设为 0因为如果w1w20那么in_o 0*x1 0*x2 bias bias所有样本的in_o都一样out_o也都一样梯度∂E/∂w1 x1 * ...中的x1虽然不同但...部分对所有样本都一样导致w1的更新量是x1的加权和。如果x1有正有负它们会互相抵消w1根本学不动。所以必须用小的随机数打破对称性。bias设为 0.3是为了给in_o一个初始的“抬升”避免sigmoid在in_o0附近导数最大启动让学习更稳定。lr0.05是经验值太大如 1.0会导致weights在最优值附近疯狂震荡loss 曲线像心电图太小如 0.0001则收敛极慢跑一万次 epoch 都看不到效果。我试过lr0.1时0,0的预测在 0.4 和 0.6 之间来回跳就是学不会降到0.05它就稳稳地沉到了 0.02。4.3 核心函数sigmoid 及其导数的实现艺术# Sigmoid 激活函数将任意实数映射到 (0,1) 区间 def sigmoid(x): return 1 / (1 np.exp(-x)) # Sigmoid 的导数用于反向传播 def sigmoid_der(x): return sigmoid(x) * (1 - sigmoid(x))这两行代码是整个网络的“心脏”和“神经”。sigmoid的实现看似简单但np.exp(-x)在x很大时会溢出变成inf导致1/(1inf)0这是个隐患。不过对于 OR 门这种小数据x的范围很有限可以忽略。sigmoid_der的实现是重点它没有重新计算exp而是复用了sigmoid(x)的结果。这不仅是性能优化少算一次 exp更是数值稳定性保障。因为exp(-x)在x很大时极小直接计算exp(-x)/(1exp(-x))²会引入浮点误差而sigmoid(x)*(1-sigmoid(x))则始终在0到0.25之间计算更精确。我曾经把sigmoid_der写成lambda x: np.exp(-x) / (1 np.exp(-x))**2结果在x10时两种方法给出的结果相差了 1e-15虽然微小但累积一万次就足以让权重偏离轨道。4.4 主循环前向传播、误差计算、反向传播的完整交响# 主训练循环运行 10000 次迭代epoch for epoch in range(10000): # --- 前向传播 (Feedforward) --- # 1. 计算加权输入input_features (4,2) weights (2,1) in_o (4,1) in_o np.dot(input_features, weights) bias # 2. 应用激活函数得到预测输出 out_o (4,1) out_o sigmoid(in_o) # --- 误差计算 (Error Calculation) --- # 简单误差预测值 - 目标值得到 (4,1) 的误差向量 error out_o - target_output # --- 反向传播 (Backpropagation) --- # 1. 计算误差对输出的导数∂E/∂out_o 1 (因为 E out_o - target) derror_douto error # 2. 计算输出对加权输入的导数∂out_o/∂in_o sigmoid(in_o) douto_dino sigmoid_der(out_o) # 3. 将前两步相乘得到误差对加权输入的导数∂E/∂in_o deriv derror_douto * douto_dino # (4,1) * (4,1) (4,1) # 4. 计算加权输入对权重的导数∂in_o/∂weights input_features.T (2,4) # 因此误差对权重的导数∂E/∂weights input_features.T deriv inputs_T input_features.T # (2,4) deriv_final np.dot(inputs_T, deriv) # (2,4) (4,1) (2,1) # --- 参数更新 (Parameter Update) --- # 使用梯度下降weights weights - lr * ∂E/∂weights weights - lr * deriv_final # 更新偏置bias 的梯度是 ∂E/∂bias ∂E/∂in_o * ∂in_o/∂bias deriv * 1 # 因为 ∂in_o/∂bias 1所以 bias 的更新量是 deriv 的所有元素之和 for i in deriv: bias - lr * i # --- 监控训练过程 --- # 每 1000 次打印一次总误差观察是否下降 if epoch % 1000 0: print(f第 {epoch} 次迭代总误差: {error.sum():.6f})这个循环是全文的高潮。我们来逐帧解析前向传播np.dot(input_features, weights)是核心。input_features的每一行一个样本与weights进行点积得到该样本的in_o。 bias是广播操作把同一个bias加到所有 4 个in_o上。sigmoid(in_o)把 4 个实数压缩到(0,1)。误差计算error out_o - target_output是向量化操作一次性算出 4 个样本的误差。error.sum()是为了监控不是损失函数本身我们用了简化的误差而非 MSE。反向传播deriv derror_douto * douto_dino是关键。*在这里是逐元素相乘element-wise multiplication因为derror_douto和douto_dino都是(4,1)结果也是(4,1)。np.dot(inputs_T, deriv)是灵魂所在它把 4 个样本对w1的梯度x1_sample1 * deriv1, x1_sample2 * deriv2...加起来得到w1的总梯度。偏置更新for i in deriv: bias - lr * i这行容易被误解。deriv是(4,1)i每次取一个标量如deriv[0][0]所以这是在对bias进行 4 次更新等价于bias - lr * deriv.sum()。这是正确的因为∂E/∂bias ∂E/∂in_o * ∂in_o/∂bias deriv * 1而∂E/∂bias也是一个(4,1)向量bias是一个标量所以它的更新量是所有deriv元素之和。4.5 预测与验证用“未见过”的数据检验泛化能力# 训练完成后用训练数据做最终预测验证 print(\n--- 最终预测结果 ---) test_cases [ ([1, 0], OR(1,0) 应为 1), ([1, 1], OR(1,1) 应为 1), ([0, 0], OR(0,0) 应为 0), ] for single_point, desc in test_cases: # 1. 计算加权输入 result1 np.dot(single_point, weights) bias # 2. 应用 sigmoid result2 sigmoid(result1) print(f{desc}: 预测值 {result2[0]:.6f})运行结果会类似--- 最终预测结果 --- OR(1,0) 应为 1: 预测值 0.999998 OR(1,1) 应为 1: 预测值 0.999999 OR(0,0) 应为 0: 预测值 0.000023看到0.000023你会有一种难以言喻的满足感。这证明了整个链条是闭合的从随机权重出发通过 10000 次梯度下降网络学会了 OR 门的逻辑。0,0的预测从最初的sigmoid(0.1*0 0.2*0 0.3) sigmoid(0.3) ≈ 0.57一路跌到了0.000023。这个数字不是 magic它是weights和bias被精确调整到某个值后的必然结果。我建议你把weights和bias的最终值打印出来然后手动代入公式sigmoid(w1*x1 w2*x2 bias)你会发现计算结果和代码输出完全一致。这种“可控的确定性”是深度学习工程师最宝贵的财富。5. 关键细节与避坑指南那些文档里永远不会写的实战血泪写完一个能跑通的网络只是万里长征第一步。真正的功力体现在你如何让它稳定、高效、可解释地工作。以下是我在无数次 debug 中用时间换来的独家心得全是“文档里找不到但面试官最爱问”的硬核细节。5.1 Bias 的生死线没有 bias[0,0]就是你的阿喀琉斯之踵我们专门用一节来讨论 bias因为它不是锦上添花而是雪中送炭。想象一下如果代码里删掉 bias这一行会发生什么in_o np.dot(input_features, weights)。对于输入[0,0]无论weights是多少in_o永远是0* w1 0* w2 0。那么out_o sigmoid(0) 0.5。你的网络永远无法把[0,0]的预测从 0.5 改变因为∂in_o/∂w1 x1 0梯度为 0权重根本不会更新。这就是为什么bias是“偏置”它给in_o一个基础的、不依赖于输入的“偏移量”让网络有能力表达x0时的非零输出。在OR门中bias的最终值会变成一个负数比如-2.5它强行把[0,0]的in_o拉到一个很大的负数让sigmoid输出趋近于 0。而[1,0]的in_o w1*1 bias如果w1足够大比如3.03.0 - 2.5 0.5sigmoid(0.5)≈0.62还不够所以w1会继续增大直到out_o超过 0.99。bias就像一个杠杆的支点没有它再大的力w1也无法撬动[0,0]这块石头。5.2 学习率lr的调参玄学它不是超参数是“刹车灵敏度”lr的选择是新手和老手的分水岭。lr0.05是一个安全的起点但绝不是终点。它的物理意义是你愿意为减少一点误差付出多大的权重变动代价。lr太大就像开车时猛踩油门又猛踩刹车weights在最优值附近剧烈震荡loss 曲线像锯齿。lr太小就像用牙签挖隧道一万次迭代后weights可能只挪动了 0.001loss 下降缓慢。我有一个速查技巧在训练循环里打印deriv_final的平均绝对值np.abs(deriv_final).mean()。如果这个值在1e-3到1e-1之间lr是健康的如果它大于1说明梯度爆炸lr必须砍半如果它小于1e-4说明梯度消失或学习停滞lr可以适当加大。这个技巧比看 loss 曲线更早发现问题。5.3 激活函数的选择Sigmoid 的黄昏与 ReLU 的黎明我们用sigmoid是为了教学因为它数学优美导数易推。但在工业界它已基本被淘汰。原因有二一是“梯度消失”vanishing gradient。当in_o很大时sigmoid(in_o)趋近于 0导致∂E/∂w极小权重几乎不更新。二是sigmoid的输出不是以 0 为中心的均值是 0.5这会让下一层的输入分布偏移增加训练难度。现代网络几乎都用ReLUmax(0, x)它的导数在x0时是1在x0时是0计算快且能有效缓解梯度消失。如果你想把我们的代码升级为ReLU只需改两行def relu(x): return np.maximum(0, x) def relu_der(x): return (x 0).astype(float) # x0 时为 1否则为 0但要注意ReLU在x0处不可导工程上约定导数为 0。这会导致“死亡 ReLU”问题如果某个神经元的in_o一直小于 0它的梯度永远是 0它就永远“死亡”了。所以ReLU通常配合He 初始化权重按sqrt(2/n)缩放使用而sigmoid配合Xavier 初始化sqrt(1/n)。这些细节才是区分“会写代码”和“懂机器学习”的关键。5.4 矩阵形状的“宪法”任何 bug90% 都源于 shape 不匹配numpy的广播机制broadcasting是一把双刃剑。它让a (4,1) b (1,)自动变成(4,1)很方便但也让a (4,1) - b (1,4)变成(4,4)的诡异结果而你可能浑然不觉。我的铁律是在每一次np.dot,,-,*操作前后都用print(var.shape)检查。特别是np.dot(A, B)必须牢记A.shape[1] B.shape[0]结果的 shape 是(A.shape[0], B.shape[1])。在我们的代码中input_features (4,2)和weights (2,1)相乘结果是(4,1)这和target_output (4,1)完美匹配。如果weights错写成(1,2)np.dot会报错ValueError: shapes (4,2) and (1,2) not aligned这是好事但如果target_output忘了reshape变成(1,4)减法会静默地广播成(4,4)error的 shape 就错了后续所有计算都崩盘而你可能要花半天才发现。所以把print当成你的第二只眼睛。5.5 “过拟合”的早期预警当你的网络在训练集上 100% 准确却在新数据上惨败我们的 OR 门只有 4 个样本不存在过拟合。但这个概念必须提前植入。过拟合的本质是网络记住了训练数据的噪声和细节而不是学习到普适的规律。在 OR 门中如果你把epoch设为 100 万次weights会变得极大bias变得极小out_o会精确到小数点后 10 位但这毫无意义因为 OR 门的规律是线性的不需要这么复杂的参数。在真实项目中过拟合的征兆是训练 loss 持续下降但验证 loss 开始上升。解决方案有三一是加正则化L1/L2在损失函数里加上λ * sum(weights²)惩罚过大的权重二是早停early stopping当验证 loss 连续 N 次不下降就停止训练三是 Dropout随机“关闭”一部分神经元强迫网络不依赖于任何单一特征。这些都不是银弹而是工程师手中的瑞士军刀需要根据具体场景组合使用。6. 常见问题与排查速查表从“代码报错”到“原理困惑”的终极指南在实现过程中你必然会遇到各种问题。我把它们分为三类编译时错误Syntax Error、运行时错误Runtime Error和逻辑错误Logic Error并给出最直接的排查路径。这不是一份清单而是一份“故障诊断手册”。问题现象最可能原因一招制敌的排查命令根本原因与修复方案ValueError: operands could not be broadcast together with shapes (4,1) (