ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Python手写三层BP神经网络:反向传播与异或问题实战

用Python手写三层BP神经网络:反向传播与异或问题实战 简介Python实现的三层BP神经网络完整工程面向机器学习与人工智能方向的学生和开发者适合正在学习反向传播算法、希望结合代码理解神经网络训练原理的入门读者也适用于课程设计或毕业设计中的算法验证与二次开发。压缩包共12个文件约484KB以6个Python源代码文件为主体涵盖BP网络主程序、梯度下降算法实现、分类与回归测试脚本另含3个编译好的pyc模块、2个经典csv数据集以及1份说明文档。其中鸢尾花数据集用于分类任务、豆瓣评分数据用于回归任务配合对应的数据加载器脚本可直接运行验证省去自行准备数据的步骤。已有2461人学习下载代码结构清晰、模块划分明确便于对照源码逐步理解前向传播、误差反传、权重更新与迭代训练的完整流程。1. 用Python实现三层BP神经网络先跑通一个最小例子再谈进阶很多人以为用Python实现三层BP神经网络最难的卡点是反向传播那堆链式法则公式。真上手写过的人都知道最难的是让一个最小例子第一次就跑通损失不降、梯度爆炸、预测输出全是同一个值这些坑比公式本身折磨人。这篇文章把三层BP网络从结构、前向传播、反向传播到Python代码完整串一遍用纯numpy手写一个能学会异或问题的网络。适合三类人做课设的本科生、入门神经网络想搞懂内部机制的开发者、以及平时用PyTorch却想知道底层到底发生什么的人。文中代码都能直接复制运行参数给的是我踩坑后常用的默认值。2. 三层BP网络的前向传播结构、激活函数与隐藏层为什么必不可少2.1 一张图看懂bp神经网络结构图三层分别负责什么先抛开公式把三层BP网络的结构在脑子里立起来。输入层、隐藏层、输出层每层之间全连接。以最常见的异或问题为例输入是二维特征输出是一维结果隐藏层设3到4个神经元就够。权重矩阵的形状跟着层间连接走输入2个节点到隐藏3个节点w1就是2乘3的矩阵隐藏3个节点到输出1个节点w2就是3乘1的矩阵。偏置b1和b2分别跟着隐藏层和输出层走形状是1乘3和1乘1。层权重形状输出形状做的事输入层无(m, 2)原始特征不做运算隐藏层(2, 3)(m, 3)线性加权后过激活函数输出层(3, 1)(m, 1)再次加权得到最终预测前向传播就是从输入层开始一层一层往后算z1 X w1 b1a1 sigmoid(z1)z2 a1 w2 b2a2 sigmoid(z2)。这里的m是样本数一次前向把整批数据都过一遍。很多人看bp神经网络结构图只看层数其实更该关注的是每个矩阵的形状矩阵形状对了前向和反向的维度才不会打架。隐藏层在这里扮演的角色是把原始输入重新组合成一组新特征。原始输入可能线性不可分但经过隐藏层的加权和激活函数变换新特征在另一个空间里往往就变得线性可分了。这也是三层结构最有价值的地方——不需要手工设计特征网络自己学出一组更合适的表示。量化交易里拿BP做信号分类也是这个思路原始因子直接分类效果差先让隐藏层做一次非线性重组合再输出买卖信号。2.2 激活函数选型不是玄学sigmoid、tanh、ReLU的取舍标准三层BP网络里激活函数的选择直接影响能不能收敛。最常用的是sigmoid公式是σ(x)1/(1e^-x)输出落在0到1之间天然适合二分类的输出层。但sigmoid有个明显短板两端导数趋近于0输入绝对值一大梯度就消失。tanh输出范围是-1到1导数的最大值是1比sigmoid的0.25要好实践中隐藏层用tanh往往比sigmoid收敛更快。ReLU在深层网络里很流行但三层小网络里神经元数量少一旦某次更新把权重推到负区间ReLU输出就恒为0这个神经元直接“死”掉排查起来反而麻烦。激活函数输出范围导数特点常用位置sigmoid(0, 1)最大0.25两端趋近0二分类输出层tanh(-1, 1)最大1.0整体优于sigmoid隐藏层ReLU[0, ∞)负数区导数为0深层网络隐藏层我自己的默认做法输出层做二分类用sigmoid隐藏层优先用tanh。如果发现网络学不动再换成sigmoid对比一下损失曲线。你会在有些教程里看到隐藏层用sigmoid输出层也sigmoid那通常是教学演示需要图个公式统一。实际工程里三层BP网络隐藏层用tanh输出层按任务选sigmoid或线性激活是更稳妥的组合。2.3 单层感知机学不会异或三层BP为什么可以异或问题的真值表很简单输入00输出001输出110输出111输出0。单层感知机本质是在平面上画一条直线来分类但异或的四个点在平面上无论怎么画直线都分不开这是经典教科书结论。三层BP网络能解决它关键在于隐藏层把输入映射到了一个新的特征空间。比如隐藏层两个节点时四个样本会落在新的二维坐标里网络自己学出一组权重让这些点在新空间里变成线性可分的。这就是“三层”不是拍脑袋加出来的原因。没有隐藏层的单层网络只能处理线性问题加上一个隐藏层才能逼近非线性映射。所以做课设或者入门练习选异或问题来验证三层BP实现是否正确是一个很可靠的做法——问题足够简单答案可以手工验证。如果网络连异或都学不会那前向或反向的代码里大概率有bug先别急着上复杂数据。3. 反向传播的数学拆解梯度公式、学习率与权重初始化的设定逻辑3.1 用链式法则手推梯度输出层和隐藏层各自的更新公式反向传播的实质是链式法则一层一层把损失对权重的偏导算出来。以均方误差损失为例损失L对输出层第j个节点的加权输入z2_j求导结果是δ2_j (a2_j - y_j) * σ(z2_j)。这里a2是输出层预测值σ(z2)是sigmoid的导数对sigmoid来说σ(z2) a2 * (1 - a2)。然后权重w2的梯度就是隐藏层输出a1的转置乘以δ2dw2 a1.T δ2偏置b2的梯度是δ2按行求和再取平均。隐藏层的误差项要从输出层传回来公式是δ1 (δ2 w2.T) * σ(z1)。这里乘的是sigmoid在隐藏层的导数z1是隐藏层的加权输入。得到δ1之后w1的梯度就是输入X的转置乘以δ1dw1 X.T δ1。整个过程看起来绕但落实到代码里就是四次矩阵乘法和两次逐元素乘法。一个容易算错的地方是用均方误差时输出层的梯度是(a2 - y) * σ(z2)不是直接写(a2 - y)。后者是交叉熵损失配sigmoid时才会出现的简化结果。有些教程直接写output - y那是因为它们用了交叉熵。你自己实现时一定要确认损失函数是什么两套公式不能混用。我在这里踩过坑损失函数改来改去梯度公式没跟着改结果网络怎么训都不收敛。3.2 学习率不是拍脑袋定的看损失曲线的形状再决定下一步学习率控制的是每次权重更新的步长。太小网络学得慢需要跑几万轮才见效果太大权重一步跨过最优点损失在某个值附近震荡甚至直接发散到NaN。定了初始学习率之后观察损失曲线的形状比记住某个固定数值更有用。我常用的初始值是0.1然后看训练前几百轮的表现来调整。如果损失曲线一路平滑下降说明学习率合理可以继续跑。如果损失在下降过程中来回抖动幅度不大可以把学习率降到0.01再试。如果损失越训越高或者直接出现NaN几乎可以断定学习率过大降一个数量级先跑通再说。还有一种常见操作是加动量每次更新时把上一步的梯度方向也计入v beta * v gradw - lr * vbeta取0.9。动量能抑制震荡让损失曲线更平滑代价是引入一个超参数。三层小网络里固定学习率通常够用等后面换复杂数据再上动量也不迟。3.3 权重初始化全零为什么翻车随机范围怎么选把w1和w2全部初始化为0是新手最容易踩的坑。全零初始化下隐藏层每个神经元的加权输入z1完全一样激活值一样梯度也一样这就像一个神经元复制了好几份网络的有效容量直接塌缩成单层。无论训练多少轮隐藏层所有节点都在学同一个东西异或这类非线性问题根本学不会。解决方法是随机初始化常见做法是取[-0.5, 0.5]的均匀分布或者[-1/sqrt(n), 1/sqrt(n)]其中n是上一层节点数。后一种方式更科学它保证输入维度越大初始权重越往小里取避免加权和过大导致sigmoid饱和。我自己习惯用np.random.uniform(-0.5, 0.5, shape)简单直接。偏置b1和b2初始化为全0是安全的因为权重已经打破了对称性偏置不需要再额外随机。如果发现两个隐藏层神经元的输出始终一模一样优先检查初始化是不是用了全0或者同一个固定值。4. 利用Python实现三层BP神经网络numpy完整代码与参数解释4.1 环境准备先装好Python和numpy再动手写代码写代码之前先把环境确认好。Python从官网下载安装之后记得把安装目录加到环境变量里否则命令行里敲python会提示找不到命令。装完之后打开终端验证一下版本然后安装numpy这是整个实现里唯一的第三方依赖。别急着装TensorFlow或PyTorch三层BP网络用numpy手写完全够用装框架反而把底层逻辑遮住了。python --version pip install numpy装完numpy之后用import numpy as np试一下能不能正常导入。如果pip安装时报错多半是pip的版本和Python版本不匹配先在命令行执行python -m pip install --upgrade pip再装一次。这一步卡住的人不少但基本都是环境变量或pip源的问题。我建议优先用默认源安装国内网络慢可以临时换镜像源但别把改源写进全局配置免得以后装其他包出幺蛾子。4.2 BPNetwork类的完整实现初始化、前向、反向、训练一个都不少下面这份代码是完整的三层BP网络实现用类把权重管理、前向、反向和训练封装在一起方便复用。激活函数用sigmoid并同时实现了它的导数导数在反向传播里要用。所有矩阵操作都用numpy的运算符做矩阵乘法不用for循环逐节点算这样代码简洁也能避免Python循环带来的性能问题。import numpy as np def sigmoid(x): return 1.0 / (1.0 np.exp(-x)) def sigmoid_derivative(a): return a * (1 - a) class ThreeLayerBP: def __init__(self, n_input, n_hidden, n_output, learning_rate0.1): self.lr learning_rate # 随机初始化权重打破对称性范围取[-0.5, 0.5] self.w1 np.random.uniform(-0.5, 0.5, (n_input, n_hidden)) self.b1 np.zeros((1, n_hidden)) self.w2 np.random.uniform(-0.5, 0.5, (n_hidden, n_output)) self.b2 np.zeros((1, n_output)) def forward(self, X): # 输入层到隐藏层激活后作为输出层的输入 self.z1 X self.w1 self.b1 self.a1 sigmoid(self.z1) # 隐藏层到输出层得到最终预测 self.z2 self.a1 self.w2 self.b2 self.a2 sigmoid(self.z2) return self.a2 def backward(self, X, y, output): m X.shape[0] # 输出层误差均方误差和sigmoid组合后的梯度 d_z2 (output - y) * sigmoid_derivative(output) d_w2 self.a1.T d_z2 / m d_b2 np.sum(d_z2, axis0, keepdimsTrue) / m # 误差传回隐藏层链式法则 d_a1 d_z2 self.w2.T d_z1 d_a1 * sigmoid_derivative(self.a1) d_w1 X.T d_z1 / m d_b1 np.sum(d_z1, axis0, keepdimsTrue) / m # 梯度下降更新权重和偏置 self.w2 - self.lr * d_w2 self.b2 - self.lr * d_b2 self.w1 - self.lr * d_w1 self.b1 - self.lr * d_b1 def train(self, X, y, epochs): for epoch in range(epochs): output self.forward(X) self.backward(X, y, output) if epoch % 500 0: loss np.mean((output - y) ** 2) print(fepoch {epoch}, loss {loss:.6f}) def predict(self, X): return self.forward(X)代码逻辑分成四块。__init__里定义了网络结构和学习率权重用随机均匀分布初始化偏置用0。forward做前向传播按输入层到隐藏层再到输出层的顺序计算。backward做反向传播先算输出层误差再算隐藏层误差最后用梯度下降更新四个参数。train循环调用前向和反向每500轮打印一次损失方便观察收敛情况。几个参数值得细说。learning_rate0.1是初始学习率异或问题这个值够用复杂数据可能要降到0.01。epochs是训练轮数异或用10000轮能收敛到很低损失。n_hidden是隐藏层节点数设4个足够解决异或我试过3个也能收敛但4个更稳。当梯度下降的更新公式里除以样本数m的目的是把批量损失平均化这样学习率的含义不受批量大小影响以后换大数据时不用重新调学习率。4.3 用异或数据验证预测值有没有逼近真值表代码写完后用异或问题验证是第一步。把四个样本做成X和y输入网络训练10000轮看最终预测值是否接近真值表。训练初期损失大概在0.25附近因为四个样本的输出都接近0.5均方误差在这个水平。随着训练推进损失逐渐下降最终应该到0.001量级甚至更低。X np.array([[0, 0], [0, 1], [1, 0], [1, 1]]) y np.array([[0], [1], [1], [0]]) net ThreeLayerBP(n_input2, n_hidden4, n_output1, learning_rate0.1) net.train(X, y, epochs10000) print(预测结果:) print(net.predict(X))预测结果应该是接近0、1、1、0的一个小数序列比如0.005、0.993、0.992、0.007。注意不是精确的整数因为sigmoid输出永远在0到1之间只有无限接近0和1但到达不了。如果输出是0.5、0.5、0.5、0.5说明网络没有学到任何东西优先检查两个地方初始化是不是全零、反向传播里d_z2的公式对不对。如果输出是接近1、0、0、1这种完全反向的结果可能是标签顺序弄错了也可能是sigmoid输出没有配合合适的损失函数。4.4 观察训练过程损失下降的细微变化怎么解读训练过程中每500轮打印一次损失这个曲线是判断网络是否健康的首要依据。正常情况是前几百轮快速下降后面趋缓像一把滑梯的侧面。如果损失在前500轮快速降到0.05附近但后面几乎不动常见原因是sigmoid饱和——输出层的加权输入z2绝对值偏大导数趋近零梯度更新变得非常慢。这时候可以检查权重的量级如果w2的值普遍大于5说明前向传播的加权结果太极端可以考虑缩小初始权重范围或者降低学习率。想更直观地观察可以用matplotlib画出损失曲线横轴是epoch纵轴是损失。画图时注意横轴刻度别太密几千个epoch的横坐标挤在一起根本没法人眼读常见做法是每隔500或1000个epoch取一个刻度让曲线形状清晰可读。我自己习惯把预测值也一起打印出来方便对照真值表验证。损失数值永远只是手段网络有没有学到正确的映射关系最终要看预测值。5. 三层BP神经网络的五个避坑点从损失震荡到梯度消失的排查顺序5.1 输出全部是同一个值权重初始化的对称性陷阱现象网络训练几万轮四个样本的输出始终是0.5左右损失稳定在0.25附近不再下降预测结果像在猜硬币。原因权重矩阵初始化成了全0或者所有值完全相同。全零初始化下隐藏层每个神经元的输入、激活值和梯度完全一致不管训练多久这些神经元始终同步更新等于网络只有一个隐藏神经元在学。异或这种非线性映射单靠一个神经元根本表达不了所以损失卡死。这个问题隐蔽在于程序不报错矩阵运算正常就是学不进去。解决把初始化改成随机数。np.random.uniform(-0.5, 0.5, (n_input, n_hidden))是我常用的写法你也可以用np.random.randn配一个缩放系数。改完之后重新训练损失应该很快开始下降。如果改完还是老样子检查一下是不是不小心把权重赋成了同一个固定随机数比如np.full这类操作。5.2 损失越训越高学习率过大的发散信号现象损失没有下降反而随着epoch增加一路攀升从0.3涨到几十甚至几百控制台偶尔还能看到NaN。原因学习率太大。梯度下降每一步跨得太远权重更新后反而跳到了损失更高的位置下一次更新又从那里接着跳结果越跳越远最后数值溢出变成NaN。我在刚接触BP时犯过这个错觉得学习率大一点学得快结果几千轮训练白跑了损失曲线画出来像心电图。解决先降学习率从0.1降到0.01如果还发散就继续降直到前几轮损失能稳定下降。另一个快捷确认方法单步试跑只训练10到20轮观察损失变化趋势。如果一开始就涨几乎可以确定是学习率问题不用急着怀疑代码逻辑。顺手再检查一下数据有没有做归一化输入特征量级太大也会放大权重更新的幅度。5.3 输入不归一化梯度消失的隐藏推手现象输入特征的数值范围很大比如从几百到几千网络训练了几千轮损失下降像蜗牛爬远没有达到预期精度。原因sigmoid激活函数在输入绝对值较大时进入饱和区导数趋近0。输入特征不归一化z1的数值会被撑得很大隐藏层梯度在一开始就接近0反向传播的误差信号传不回来。用异或数据时看不出这个问题因为输入只有0和1但换成真实数据集温度、金额、百分比这些特征量级差异很大不处理就直接喂进网络sigmoid基本会卡死。解决做归一化或标准化。归一化把特征压到[0,1]区间x_norm (x - x.min()) / (x.max() - x.min())标准化把特征转成均值为0、方差为1x_std (x - x.mean()) / x.std()。两种都可以BP网络里我优先用归一化因为它能保证所有特征在同一量级sigmoid的输出范围也正好匹配。注意归一化参数必须用训练集计算测试集直接用同一套参数变换不能重新算。5.4 训练集很准、新数据不行过拟合的经典症状现象训练集上的损失降得很低预测值几乎和标签一致但换一批新数据测试准确率明显下滑泛化能力很差。原因训练轮次过多网络的隐藏层节点数又偏大时模型开始记住训练样本的细节而不是学习背后的规律。异或问题只有四个样本多跑一些轮次后过拟合很难观测但数据量稍大就能看出来。隐藏层节点数从4加到50网络容量大了过拟合风险也随之上升。解决最直接的手段是早停——把训练数据分出一部分做验证集每训练几百轮就验证一次发现验证损失不再下降时停止训练。另一个手段是减少隐藏层节点数三层BP网络隐藏层节点数不是越多越好先从小规模起步比如输入维度的两倍再逐步增加。正则化项也是一个方向给权重更新加上衰减系数让权重往小的方向走但手写代码时要额外实现新手先用早停更省事。5.5 层数一多就失效sigmoid的梯度消失效应现象把三层网络改成五层、七层隐藏层全用sigmoid训练时前面几层的权重几乎不更新损失降不下去。网络层数越多问题越严重。原因sigmoid导数的最大值是0.25误差每反向传播一层就要乘一次这个小于1的数多层叠加后梯度指数级衰减靠近输入层的权重根本拿不到有效的梯度信号。三层BP网络存在这个问题但还不严重因为只有一层隐藏层链式法则里只乘一次sigmoid导数层数多了之后这个效应被放大到不可忽视。解决如果是在三层BP基础上加深网络把隐藏层的sigmoid换成tanh或ReLU。tanh的导数最大值是1ReLU在正区间导数恒为1都能有效缓解梯度衰减。再进一步可以考虑加入Batch Normalization让每层输入保持稳定分布但这对纯numpy手写实现来说工作量大不少通常要引入框架才划算。三层BP本身只有一次隐藏层传播用sigmoid问题不大如果你坚持用三层结构不需要特别处理这一条。6. 验证手写网络对不对与sklearn的MLPClassifier做对拍手写BP网络最大的风险是代码里存在隐蔽的维度或公式错误自己反复看不一定查得出来。一个有效的验证方法是和sklearn的MLPClassifier对拍用同样的数据、同样的网络结构、同样的激活函数和训练参数对比两者的损失收敛趋势和最终预测。如果手写实现逻辑正确结果应该高度一致。from sklearn.neural_network import MLPClassifier clf MLPClassifier( hidden_layer_sizes(4,), activationlogistic, solversgd, learning_rate_init0.1, max_iter5000, random_state0 ) clf.fit(X, y.ravel()) print(clf.predict(X))这份代码里hidden_layer_sizes(4,)表示隐藏层一个4个节点和手写网络对齐。activationlogistic对应sigmoidsolversgd对应梯度下降learning_rate_init0.1和手写代码保持一致。运行结果是[0 1 1 0]和异或真值表完全一致。对拍时重点看两件事。第一件是预测类别是否一致手写网络的预测值按0.5阈值取整后应该和MLPClassifier输出相同。第二件是训练过程中的损失量级都在几百轮内从0.25附近降到0.01以下说明收敛速度大致相当。如果手写网络损失明显高于sklearn多半是梯度公式写错了优先检查输出层的d_z2是否乘了sigmoid导数。我自己写完网络后有个习惯先拿这类能被手算的玩具数据对拍确认无误之后再换真实数据集。这个过程帮我挡下了好几次维度弄错、损失公式不匹配的翻车现场。对拍通过不代表网络在所有数据上都表现优秀但至少说明核心逻辑是通的可以放心进入特征工程和调参阶段。希望这篇笔记里的方法和代码能帮你少走一点我当年走过的弯路。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进