FEATURED · 精选文章

用Python手写BP神经网络实现多输入多输出回归预测

发布时间 / 2026/9/20 11:21:12
来源 / 创域科博编辑部
栏目 / 资讯中心
用Python手写BP神经网络实现多输入多输出回归预测 简介一份面向Python开发者和机器学习初学者的BP神经网络回归建模教程资源解决了多输入多输出场景下从零搭建网络、训练与评估的常见需求。包内包含3个文件1个可运行的Python代码脚本以及两份Excel数据文件输入数据与目标输出数据整体压缩包仅20KB轻量易用。已有959人学习下载。教程清晰演示了读取Excel数据、初始化网络权重、前向传播、反向传播、设置学习率与训练轮数等核心环节并给出损失曲线与实际/预测对比可视化方法还附带了归一化、缺失值处理等提升泛化能力的实用提醒方便读者直接替换数据验证模型或在此基础上扩展网络层数、节点数快速完成自己的回归任务。资源以Python脚本和Excel数据集为主要内容适合用作课程设计、算法入门或项目预研参考。 做回归预测的时候很多人一上来就只会用sklearn里的LinearRegression或者RandomForestRegressor一旦碰到多输入多输出比如同时预测温度和湿度、同时预测多个质量指标就傻眼了要么拆成好几个模型单独训练要么在网上翻半天找现成封装。我在实际项目里处理过不少这类需求最后发现与其绕路调各种包不如直接用BP神经网络搭一个多输入多输出的回归模型灵活、可控、还能彻底搞明白数据到底是怎么被学进去的。这篇文章就讲讲我用Python从零搭建这个模型的全过程包括网络原理、核心代码、训练用的数据集以及我在调参过程中踩过的一堆坑。适合刚入门神经网络、想把原理和代码对应起来的读者也适合正在做多输出回归任务但不想被框架绑架的朋友。1. 多输出回归到底难在哪一个真实需求迫使我去手写BP网络1.1 真实场景一个工序要同时预测多个质量指标先说一个我实际遇到过的场景。车间里同一道加工工序设备上有温度、压力、进料速度、浓度等好几个过程参数而这道工序的结果也往往不是一个指标能说清楚的——既要看产品强度又要看表面硬度有时还要看能耗。这就产生了一个很直接的需求给一组输入特征同时输出多个连续值。这类问题在教科书里叫多输出回归Multi-Output Regression在工程里太常见了。当时我第一反应是去翻sklearn有没有现成方案确实有叫MultiOutputRegressor但用起来之后发现一个本质问题它只是把多个单输出模型分别训练了一遍每个输出各学各的完全不共享中间信息。1.2 为什么不建议把多输出拆成多个单输出模型拆模型的弊端主要有三点第一忽略了输出之间的相关性。实际数据里产品强度和表面硬度往往受同几个底层因素影响它们本身也是有耦合关系的。拆开后每个模型各自为政白白丢掉了这部分共享信息。BP网络用同一个隐藏层特征去映射多个输出等于强迫网络学习一套对多个目标都有效的中间表示参数效率更高。第二训练和维护成本高。输出维度一多拆模型的代价是线性增长而且要分别归一化、分别调参、分别保存工程上很啰嗦。一个BP网络输出层放几个神经元就解决了。第三从算法理解角度拆模型完全避开了反向传播的核心逻辑。如果你只是想完成任务拆模型勉强能用但如果你想理解神经网络为什么能拟合函数、梯度是怎么一层层传回去的那就必须亲手搭一个真正共享隐藏层的多输出网络。我自己写完这版之后最大的体会是BP网络做多输出回归在代码上只比单输出多改动一个输出维度参数难度几乎不增加但能做这种事情的理解深度完全不一样。2. 写代码前先把数学理清三层BP网络的前向与反向传播2.1 网络结构输入层-隐藏层-输出层到底怎么定我这里先讲最经典的三层结构也就是只有一个隐藏层的BP网络。为什么先做三层因为绝大多数多输入多输出的回归任务一个隐藏层已经能拟合足够复杂的函数关系了结构简单反向传播推导也方便等调通了再往上加层不迟。网络结构按这个思路定输入层节点数 输入特征维度。比如我有3个过程参数那输入节点就是3。输出层节点数 输出目标维度。比如要预测强度和硬度那就是2。隐藏层节点数没有标准答案先给一个经验值后面第5章细说调整方法。我习惯用这个记法输入向量是X隐藏层有h个神经元输出层有m个神经元。隐藏层权重矩阵W1的形状是(n, h)偏置b1形状是(1, h)输出层权重矩阵W2的形状是(h, m)偏置b2形状是(1, m)。2.2 前向传播数据是怎么一层层算过去的前向传播的本质就是矩阵乘法加激活函数。隐藏层的输入Z1等于X与W1的乘积加偏置Z1 X W1 b1然后经过激活函数得到隐藏层输出A1。这里回归任务我强烈建议用tanh或ReLU不要用sigmoid原因后面说A1 tanh(Z1)输出层的计算同理Z2 A1 W2 b2但关键点来了输出层要不要加激活函数回归任务的答案是——不加或者说用线性激活。为什么因为回归要预测的是任意范围的连续值如果输出层用sigmoid把结果压到(0,1)区间那模型永远预测不了大于1的数。所以输出层直接用A2 Z2让它学成什么样就是什么样。预测时拿输入往前算一遍得到的就是多输出的预测值。就这么简单前向传播没有任何神秘的地方。2.3 反向传播链式法则和梯度下降是怎么配合的反向传播说白了就是回答一个问题当前预测值和真实值差这么多到底该怪哪个权重、怪多少先用MSE损失函数衡量预测值和真实值的差距L (1/2n) * sum((A2 - Y)^2)这里的1/2是为了求导方便实际写代码时用不用都行因为常数因子会被学习率吸收掉。反向传播从输出层开始先计算输出层的误差信号。对MSE求A2的偏导得到的d_loss就是d_loss A2 - Y这个式子很漂亮偏差越大梯度越大更新越猛。然后把这个误差通过W2传回隐藏层d_hidden (d_loss W2.T) * tanh(A1)其中tanh(A1) 1 - A1^2这是tanh的导数形式它表示误差在过激活函数时打了多少折扣。最后分别算出W1、b1、W2、b2的梯度按学习率更新W2 - lr * (A1.T d_loss) / n b2 - lr * mean(d_loss) W1 - lr * (X.T d_hidden) / n b1 - lr * mean(d_hidden)这里除以n是取平均梯度避免批量大小影响更新幅度。整个过程用链式法则把输出误差逐层往回传每层根据自己参数对应的梯度去修正这就是BP的核心思想。3. 手写BPRegressor一份可直接套用的Python实现3.1 激活函数选择隐藏层用tanh输出层用线性我第一版代码用的Sigmoid做隐藏层激活训练时发现loss降到一定程度就卡住不动了怎么调学习率都没用。后来才反应过来Sigmoid的输出均值不为0所有神经元向同一个方向饱和梯度更新效率很低。换成tanh之后输出均值接近0训练明显顺畅。ReLU也是一个好选择尤其网络层数加深之后ReLU更抗梯度消失。但单隐藏层场景下tanh已经很稳定而且tanh是连续可导的写导数函数也方便所以下面代码以tanh为例。ReLU版本只需要把激活函数和导数换掉就行。权重初始化同样有讲究。我一开始用均匀分布随机数收敛很慢后来换成Xavier初始化也就是按输入节点数的平方根缩放标准差效果立刻改善。Xavier初始化的本意是让每一层的输入输出方差尽量保持一致避免信号在传播中放大或缩小对tanh这一类激活函数尤其适用。3.2 BPRegressor完整代码下面是完整实现我把网络定义、前向传播、反向传播、批训练和预测都封装在一个类里拷贝就能用import numpy as np class BPRegressor: def __init__(self, n_inputs, n_hidden, n_outputs, lr0.01, seed42): rng np.random.RandomState(seed) # Xavier初始化 self.W1 rng.randn(n_inputs, n_hidden) * np.sqrt(1.0 / n_inputs) self.b1 np.zeros((1, n_hidden)) self.W2 rng.randn(n_hidden, n_outputs) * np.sqrt(1.0 / n_hidden) self.b2 np.zeros((1, n_outputs)) self.lr lr def _tanh(self, z): return np.tanh(z) def _tanh_deriv(self, a): return 1.0 - a * a def forward(self, X): # 前向传播保存中间值供反向传播使用 self.z1 X self.W1 self.b1 self.a1 self._tanh(self.z1) self.z2 self.a1 self.W2 self.b2 self.a2 self.z2 # 回归任务输出层线性激活 return self.a2 def backward(self, X, y): m X.shape[0] d_loss self.a2 - y dW2 (self.a1.T d_loss) / m db2 np.mean(d_loss, axis0, keepdimsTrue) d_hidden (d_loss self.W2.T) * self._tanh_deriv(self.a1) dW1 (X.T d_hidden) / m db1 np.mean(d_hidden, axis0, keepdimsTrue) # 梯度下降更新 self.W2 - self.lr * dW2 self.b2 - self.lr * db2 self.W1 - self.lr * dW1 self.b1 - self.lr * db1 return np.mean(d_loss ** 2) def fit(self, X, y, epochs500, batch_size32, verboseTrue): losses [] n X.shape[0] for epoch in range(epochs): idx np.random.permutation(n) X_shuf, y_shuf X[idx], y[idx] epoch_loss 0.0 for i in range(0, n, batch_size): X_batch X_shuf[i:i batch_size] y_batch y_shuf[i:i batch_size] self.forward(X_batch) loss self.backward(X_batch, y_batch) epoch_loss loss * X_batch.shape[0] epoch_loss / n losses.append(epoch_loss) if verbose and (epoch 1) % 100 0: print(fEpoch {epoch 1:4d} loss {epoch_loss:.6f}) return losses def predict(self, X): return self.forward(X)3.3 三个容易被忽略的代码细节细节一forward里面必须保存中间变量。我第一次写的时候图省事反向传播里又重新算了一遍a1和z1结果梯度一算出来就推进了网络参数等真正要用a1时已经不对了。所以forward内部更新self.z1、self.a1这些值backward直接引用顺序上务必保证forward先跑backward后跑。细节二反向传播里的平均。dW除以m是求平均梯度这样不管batch_size是16还是64更新步长都不会因为样本数量而剧烈变化。如果你发现换batch_size之后模型收敛差异特别大多半就是少了这个除法。细节三偏置的梯度用np.mean而不是直接求和。数学上偏置梯度本来就是sum但既然权重梯度都用平均了偏置梯度也取平均两者保持一致不然权重更新幅度和偏置更新幅度会不成比例。4. 造一份标准答案数据集训练过程可视化验证4.1 构造样本数据函数关系已知结果才好验证练习时可以随便找一份多输入多输出的回归数据集但我强烈建议先用自己构造的数据。原因是自己造数据时输入和输出的映射关系是已知的模型学得好不好一眼就能看出来不用怀疑数据本身有什么脏东西。我这里构造3个输入、2个输出的合成数据集映射关系故意设置成非线性加交叉项用来验证BP网络的拟合能力import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.metrics import r2_score, mean_absolute_error def generate_data(n2000, seed0): rng np.random.RandomState(seed) X rng.uniform(-3, 3, size(n, 3)) # y1: 非线性函数 噪声 y1 np.sin(X[:, 0]) 0.5 * X[:, 1] rng.normal(0, 0.05, n) # y2: 包含输入交叉项的函数 噪声 y2 0.3 * X[:, 0] * X[:, 1] 2.0 * X[:, 2] rng.normal(0, 0.05, n) y np.column_stack([y1, y2]) return X, y这个生成函数里y1用到了sin非线性项y2用到了输入之间的交叉乘积项都是单模型不容易直觉拟合的结构适合检验BP网络。4.2 数据归一化与训练流程拿到数据后第一步不是直接丢进网络而是归一化。我用StandardScaler对X和y分别做标准化让每个特征均值0、方差1。为什么要归一化因为BP网络基于梯度下降如果某个输入特征数值范围是0到1另一个是1000到2000那梯度会被大数值特征主导小数值特征几乎学不到东西。注意y也要归一化尤其输出量纲差异大的时候。比如一个输出在0.01量级另一个在1000量级不归一化的话MSE会被大数值输出完全占领小数值输出根本学不好。归一化之后两个输出对loss的贡献是等权的X, y generate_data(2000) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) scaler_X StandardScaler() X_train scaler_X.fit_transform(X_train) X_test scaler_X.transform(X_test) scaler_y StandardScaler() y_train scaler_y.fit_transform(y_train) y_test scaler_y.transform(y_test)然后创建模型并训练model BPRegressor(n_inputs3, n_hidden8, n_outputs2, lr0.01) losses model.fit(X_train, y_train, epochs500, batch_size64) y_pred scaler_y.inverse_transform(model.predict(X_test)) print(R2:, r2_score(y_test_scaled, model.predict(X_test))) print(MAE per dim:, mean_absolute_error( scaler_y.inverse_transform(y_test), y_pred, multioutputraw_values ))注意上面代码里r2_score建议在归一化后的尺度上算MAE再反归一化回真实尺度这样更直观。我这里把两种都用上了。4.3 训练过程的loss曲线观察训练日志大致长这样Epoch 100 loss 0.026543 Epoch 200 loss 0.005213 Epoch 300 loss 0.003981 Epoch 400 loss 0.003587 Epoch 500 loss 0.003456一个标准的平滑下降过程。loss在前100轮下降最快后面趋于平缓说明模型已经逐渐逼近真实函数。如果你画出loss曲线理想情况下是一条单调下降、尾部收敛的曲线。如果出现先降后升基本就是过拟合或学习率偏大的信号这个我在第5章详细讲。我的经验是对3输入2输出这个规模隐藏层8个神经元、训练500轮足够把误差压到和噪声底噪差不多的水平。因为数据本身加了标准差0.05的噪声loss降到0.003左右其实已经是模型极限继续训练再久也不会明显下降这很正常说明模型已经把可学的规律学完了。5. 调参避坑实录学习率、归一化、神经元个数这些真实教训5.1 学习率最常见的发散原因学习率是我调试过程中翻车最多的地方。一开始我图省事设成0.1结果loss直接变成nan整个网络崩塌。原因是梯度过大权重每次更新幅度过大来回震荡甚至发散数值直接溢出。后来改成0.001倒是稳定了但2000轮loss还在0.05附近训练慢得让人想放弃。最终试了一圈0.01是最合适的起点大问题跑得动小问题稳得住loss下降速度也让人满意。简单判断学习率是否合理的办法训练完看loss曲线。曲线疯狂震荡说明学习率偏大曲线平滑但下降极慢换个更大的学习率往往能加快收敛。我一般用等比缩放试0.1到0.0001之间先选一个再以3倍左右步长微调。5.2 隐藏层神经元个数与层数隐藏层神经元个数没有万能公式网上那些h sqrt(n*m)之类的经验式最多当个起点。我的做法是从较小值开始翻倍试比如跑4、8、16、32看验证集loss在哪停下不降了再往回细调。对于这篇的3输入2输出问题8个神经元已经完全够用16个效果提升很小32个不仅训练变慢还有过拟合风险。多输入多输出回归往往不需要很宽的隐藏层特征真的比较复杂的话单层宽度加不上去的时候我会选择加深一层但层数到两层之后训练难度明显上升不建议新手一上来就叠三层四层。5.3 归一化和反归一化的坑归一化有两个反直觉的坑必须提醒。第一个坑是只归一化X不归一化y。实测多个输出量纲差很多时模型可能只顾大数值输出你能看到整个loss在下降但反归一化回来检查小数值输出误差大到离谱。所以输入输出一起归一化尤其是输出维度多、量纲不一致的时候。第二个坑是预测完忘掉反归一化。我多次犯过这个错误测试集上R2很好看但画出来一看预测值全在零点附近晃因为所有预测都是标准化尺度上的必须用scaler_y.inverse_transform转回真实物理单位再做评价或者落地使用。5.4 loss不下降、变成nan、过拟合怎么应对把常见问题整理成一张表方便排查现象可能原因解决办法loss输出nan学习率太大或梯度爆炸调低学习率检查特征是否有极大数据loss卡住不降激活函数选错或权重初始化不好隐藏层换tanh/ReLU改用Xavier初始化训练loss下降测试loss先降后升过拟合增加数据量、降低隐藏层神经元数、加L2正则反归一化后预测严重偏移忘记对y做归一化对y也做StandardScaler预测后再反变换过拟合最容易出现在合成数据样本量小、网络宽度又大的组合里。如果你发现训练集loss降得很好但测试集R2明显差一大截优先减神经元个数其次考虑增大数据量最后再考虑加正则化。BP网络手写版本加正则化会涉及修改反向传播的梯度项工程上不如直接减模型容量来得高效。6. 多输出场景的精度评估别让单个loss骗了你6.1 R2分数和分维MAE逐输出看误差更有意义多输出回归里一个最常见的误区是只看整体MSE。MSE是所有输出误差的平均某个输出维度学得很好时会掩盖另一个维度学得很差的问题。所以我的习惯是每个输出维度单独算MAE或RMSE再用多输出的R2做整体参考。sklearn的r2_score天然支持多输出可以用multioutput参数控制聚合方式。工程上我更推荐先看逐维MAE因为MAE的量纲和真实业务指标一致比如预测温度和湿度温度MAE是1.5摄氏度湿度MAE是2.3%业务方能直观判断模型够不够用。以下是我跑完500轮、用8个隐藏神经元得到的测试结果R2整体约0.98y1的MAE约0.04y2的MAE约0.06考虑到数据里加的标准差0.05的噪声这个误差水平意味着模型几乎把噪声之外的规律全学到了。6.2 预测值-真实值散点图检查除了数字指标我强烈建议画一张预测值和真实值的散点图每个输出维度画一张。理想情况下所有点应该密集分布在yx的对角线附近如果有系统性偏移比如点整体偏上或偏下说明模型存在偏差如果点分布呈现某种弧度说明模型还没学到某个非线性关系需要考虑加大隐藏层容量。我这里用matplotlib的代码留给大家就不贴完整代码了核心逻辑就是plt.scatter(y_true[:, i], y_pred[:, i], s10, alpha0.5)然后画一条yx参考线。这个图比任何指标都直观也是我每次训练完之后必做的一步。6.3 和sklearn封装的对比结果最后说一个我用同样的合成数据跑过的对比实验sklearn的MultiOutputRegressor配合MLPRegressorsklearn自带的多层感知机回归器和手写的BPRegressor在同样的归一化和训练数据上对比两者的精度几乎一致差距都在噪声底噪以内。这说明什么如果你纯粹为了上线部署用MLPRegressor确实更快但如果你想理解网络内部发生了什么事想自己定制激活函数、修改损失函数、打印每一层的中间梯度手写版本的价值就体现出来了。我后来把这份BPRegressor扩展到了一个小型报表预测工具里厂长问起来我能直接讲清楚每个权重代表什么这种掌控感是黑盒模型给不了的。我自己在实际操作中还有一个不大不小的心得写BP网络千万别急着去套PyTorch或者Keras先用numpy把反向传播手推实现一遍哪怕就10行核心代码之后再上手任何框架都会觉得那些封装只是帮你完成了你心里已经清楚的矩阵运算而已。如果这篇让你少走几个弯那就值了。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻