简介基于BP神经网络的数据回归预测源码面向机器学习初学者与需要快速验证回归模型的开发者使用numpy搭建网络并用matplotlib完成可视化。资源包共6个文件内含可运行脚本、两个Excel数据集训练/测试、两张结果对比图及一份README说明整体压缩后仅208KB轻量易读。已有2255人学习下载。代码以Boston房价数据为例运行脚本即可完成加载数据、训练网络、预测输出与绘制对比图全流程Excel数据集可替换为自己的数据通过散点图观察真实值与预测值的分布借助折线图查看变化趋势拟合效果一目了然。适合作为BP网络回归任务的项目模板、课程作业或入门练手参考也可基于现有结构二次开发。1. 基于BP神经网络的数据回归预测Python代码从Excel到预测值一次手写跑通手里有一批Excel数据特征是温度、湿度、风速要预测一个连续指标比如设备压力值。很多人第一反应是调sklearn三行代码出一个MLPRegressor。可一旦把BP神经网络换成手写实现要求只用numpy和Excel数据集完成回归预测事情就变成了另一件事你要亲手处理归一化、前向传播、反向传播和梯度更新。这篇笔记就是围绕这个标题展开的目标是让一个已经会跑Python、装好了numpy和pandas的读者不依赖PyTorch或TensorFlow用numpy手写一个能用的BP网络跑通从Excel读取到预测值还原的完整流程。课程设计、毕业设计或者单纯想弄懂BP内部机制的人都能直接照做。2. 回归预测为什么选BP网络结构、激活函数与损失函数要先定对2.1 回归任务的输出层为什么不用sigmoid线性层与MSE的匹配BP神经网络做分类时输出层常用sigmoid因为它能把数值压到0到1之间配合交叉熵损失正好对应概率。但回归预测要输出的是连续实数目标可能是温度、价格、压力这类没有上下界的值。如果输出层用sigmoid网络就被迫把所有预测值压缩在0到1之间即便后面做逆变换也会损失大量动态范围离群点更是直接让模型失衡。所以手写回归BP时输出层必须用线性激活也就是不加任何激活函数。前向传播到输出层时直接算y_hat a1 w2 b2其中a1是隐藏层输出w2是输出层权重b2是偏置。隐藏层负责非线性变换输出层只做线性组合这样网络的输出范围理论上不受限制才能匹配回归目标。对应损失函数通常选均方误差MSE。为了方便求导常写成loss 0.5 * np.mean((y_hat - y) ** 2)这里的0.5是故意加的因为对y_hat求导后平方项的2被约掉梯度表达式会干净很多。后续反向传播里所有导数都基于这个损失函数推导。如果换成MAE平均绝对误差梯度在误差为零处不可导处理起来反而麻烦第一版先用MSE跑通更稳妥。2.2 隐藏层用tanh还是relu手写BP时梯度计算的成本对比隐藏层激活函数有两个候选tanh和relu。深度学习框架里relu用得最多因为它计算快、稀疏激活能缓解梯度消失。但在手写BP且用全量梯度下降的场景下relu有个让人头疼的问题输入特征某个维度一旦落到负区间梯度直接变0之后这个神经元可能永远不再更新也就是常说的“神经元死亡”。一旦发生网络容量就悄悄缩水回归预测的精度也跟着下降。tanh输出范围是-1到1均值接近0数据经过它之后不会出现单方向偏移对后续层更友好。它的导数更简单1 - tanh(x)^2在numpy里写起来就一行delta1 (delta2 self.w2.T) * (1 - self.a1 ** 2)反向传播的公式直接从tanh导数推出来不需要分段判断。relu的导数虽然更简单但要处理小于等于0的情况而且一旦出现死亡神经元调试起来很难察觉。所以我的建议是手写BP回归预测第一版用tanh等跑通之后再尝试leaky relu对比效果。下面的表格是这两个激活函数在手写BP场景下的关键差异激活函数导数形式主要风险适合场景tanh1 - a²饱和区梯度略小手写BP回归梯度稳定推荐首版relu0或1神经元死亡梯度永久为零深网络、卷积网络手写回归不优先leaky relu正区间1负区间0.01需要额外调负斜率参数想避免死亡神经元时的替代选项2.3 网络容量与超参数初值隐藏层节点数和学习率的经验值BP网络做回归预测单隐层结构其实已经够用。理论上一个单隐层网络只要隐藏层节点足够多可以拟合任意连续函数这就是万能近似定理的大意。所以第一版不需要堆多层把单隐层调好比加深度更实际。关键问题是隐藏层节点数取多少。常见做法是先按输入特征数的1到2倍取一个初值比如特征数是5隐藏层节点先取8或10然后观察训练集和测试集的误差曲线。节点太少模型学不到特征组合预测值会贴在均值附近节点太多训练集拟合得很好测试集误差反而大也就是过拟合。不要指望一上来就找到最优值从特征数的1.5倍开始再用一个验证集去试是最省时间的路径。隐藏层节点数在工程里经常被当成玄学其实经验和边界是清楚的下限至少是输入特征数上限不超过样本数的十分之一超出这个范围基本就是过拟合区间。学习率建议从0.01起步。这个值对大多数归一化后的数据都算安全。如果loss下降太慢可以放大到0.05如果loss震荡或变成NaN立刻降到0.001。下面这张表是跑通一个回归BP的初始参数可以直接抄参数初始值调整方向隐藏层节点数输入特征数 × 1.5向上取整过拟合就减小欠拟合就增大学习率0.01loss震荡时降为0.001下降慢时升到0.05训练轮数epochs1000loss还在降就加到3000测试集回升则提前停止权重初始化正态分布标准差按Xavier缩放出现NaN时把标准差改小随机种子42固定住后续调参才可复现3. 用Excel数据集准备样本读取、清洗、归一化与划分3.1 读取excel数据集前的三个检查引擎、路径与表头用pandas读Excel第一步就藏着坑。pd.read_excel本身只是个壳实际解析依赖第三方引擎读取.xlsx文件需要安装openpyxl读取.xls文件需要xlrd。如果没装会直接报缺少依赖。环境里最好一次性装齐numpy、pandas和openpyxl这三件套缺一个都会让流程卡在前十分钟。import numpy as np import pandas as pd df pd.read_excel(data.xlsx, sheet_nameSheet1, engineopenpyxl) print(df.head()) print(df.dtypes)这段代码里sheet_name指定工作表如果文件只有一个Sheet可以不写engine显式指定openpyxl避免pandas版本之间默认引擎不一致。文件路径尽量用相对路径Windows下如果必须用绝对路径字符串前面加r写成rD:\project\data.xlsx防止反斜杠被当成转义字符。读进来之后先看df.dtypes。典型问题是有些列明明存的是数字读出来却是object类型这是因为Excel里该列存在个别文本值比如单位行、备注、空字符串。处理方法很简单选出数值列再做类型转换df df.select_dtypes(include[np.number]) df df.dropna() print(df.shape, df.columns.tolist())select_dtypes会把所有非数值列丢掉比如ID、时间、字符串备注。dropna()去掉包含缺失值的行。这一步是手写BP前最值得做的数据清理因为numpy的矩阵运算遇到NaN会直接传染一个NaN能让整个loss变成NaN。3.2 归一化为什么是回归预测的必经步骤BP网络对输入特征的尺度很敏感。假设一个特征是温度范围在0到40之间另一个特征是压力范围在1000到5000之间如果直接送进网络反向传播时大数值特征对应的梯度会大得多权重更新被压力主导温度特征几乎学不到东西。隐藏层用tanh时问题更明显大数值输入会让tanh进入饱和区梯度趋近0训练直接卡住。常见做法是MinMax归一化把所有特征压到0到1区间。这里要注意一个原则归一化统计量只能从训练集算测试集必须沿用同一套min和max。如果拿全部数据的min和max去归一化测试集的信息就提前泄漏到了训练过程里验证结果会虚高。下面的代码演示手写归一化x_cols [temperature, humidity, pressure] y_col target x_train_raw train_df[x_cols].to_numpy(dtypefloat) y_train_raw train_df[y_col].to_numpy(dtypefloat).reshape(-1, 1) x_mn, x_mx x_train_raw.min(axis0), x_train_raw.max(axis0) y_mn, y_mx y_train_raw.min(), y_train_raw.max() x_train (x_train_raw - x_mn) / (x_mx - x_mn) y_train (y_train_raw - y_mn) / (y_mx - y_mn) x_test (test_df[x_cols].to_numpy(dtypefloat) - x_mn) / (x_mx - x_mn) y_test_raw test_df[y_col].to_numpy(dtypefloat).reshape(-1, 1)注意x_train_raw.min(axis0)是对每一列分别求最小值返回一个一维数组y_train被reshape(-1, 1)转成列向量这一步后面反向传播矩阵运算要用。MinMax归一化之后如果测试集某个特征值超出了训练集的min或max归一化结果会变成负数或者大于1这不影响tanh网络运行但提醒你这个特征分布可能已经漂移预测结果需要警惕。提示归一化统计量必须只从训练集计算测试集沿用同一组min和max。这是数据泄漏里最容易被忽略的一条。3.3 训练集与测试集划分时间序列数据不能随手shuffle划分样本前先想清楚数据性质。如果是普通的截面数据样本之间互相独立可以直接用随机打乱划分numpy一行实现rng np.random.default_rng(42) idx rng.permutation(len(x_train_raw)) x_train, y_train x_train_raw[idx], y_train_raw[idx]但如果数据是按时间采集的比如每天的销量、连续采集的设备读数就不能随机打乱。时间序列数据相邻样本有强关联打乱等于把未来信息混入训练集测试集效果会虚高。正确做法是按时间顺序切分前80%当训练集后20%当测试集train_df df.iloc[:int(len(df) * 0.8)] test_df df.iloc[int(len(df) * 0.8):]这样切分后测试集在时间上严格晚于训练集模拟的是真实预测场景。敲定切分方式后回到3.2节做归一化再进入网络训练。数据准备阶段多花五分钟检查数据类型和切分逻辑后面能省下大量调参时间。4. 用numpy实现BP网络的前向与反向传播完整可运行的Python代码4.1 网络类的初始化随机种子、权重尺度与激活函数选择手写BP网络我习惯直接定义一个类把权重、偏置、前向、反向和训练循环都封装进去。初始化方法里做三件事固定随机种子、按Xavier方法初始化权重、把偏置初始化为0。class BPRegressor: def __init__(self, n_input, n_hidden, n_output1, lr0.01, seed42): self.rng np.random.default_rng(seed) scale1 np.sqrt(2.0 / (n_input n_hidden)) scale2 np.sqrt(2.0 / (n_hidden n_output)) self.w1 self.rng.normal(0, scale1, size(n_input, n_hidden)) self.b1 np.zeros((1, n_hidden)) self.w2 self.rng.normal(0, scale2, size(n_hidden, n_output)) self.b2 np.zeros((1, n_output)) self.lr lrrandom_state或者说seed是调参的后悔药。不固定随机种子每次训练权重初始值都不同同一次调参结果可能完全不可复现最后根本分不清是参数改对了还是运气好。固定seed之后每次运行结果完全一致后续对比实验才有效。权重初始化用了Xavier的思想标准差按sqrt(2 / (输入维度 输出维度))缩放。这样做的原因是让每一层的输入输出方差保持在相近量级避免前向传播时数值逐层放大。尤其tanh激活函数权重初始值过大输入直接进入饱和区反向传播梯度趋近0模型从一开始就学不动。b1和b2保持零初始化因为偏置不参与梯度消失问题零偏置在训练开始阶段是安全的。4.2 前向传播与反向传播的实现公式到矩阵运算的映射前向传播分两层计算。隐藏层先做线性变换再过tanh激活输出层直接做线性组合。反向传播则按MSE损失求导从输出层倒推回输入层。核心代码如下def forward(self, X): self.z1 X self.w1 self.b1 self.a1 np.tanh(self.z1) self.z2 self.a1 self.w2 self.b2 return self.z2 def backward(self, X, y): m X.shape[0] delta2 (self.z2 - y) / m d_w2 self.a1.T delta2 d_b2 np.sum(delta2, axis0, keepdimsTrue) delta1 (delta2 self.w2.T) * (1 - self.a1 ** 2) d_w1 X.T delta1 d_b1 np.sum(delta1, axis0, keepdimsTrue) self.w1 - self.lr * d_w1 self.b1 - self.lr * d_b1 self.w2 - self.lr * d_w2 self.b2 - self.lr * d_b2逐步解释这里的形状和含义。X是形状为(m, n_input)的矩阵m是样本数n_input是特征数。X self.w1得到(m, n_hidden)加上偏置b1后过tanh得到隐藏层输出a1。self.a1 self.w2得到(m, 1)这就是预测值。反向传播从损失函数的导数开始。MSE损失除以m是为了让梯度变成平均梯度这样学习率不会随着样本量变化而需要大幅调整。delta2是输出层误差d_w2通过矩阵转置乘法得到形状和w2一致。delta1是隐藏层误差核心是(1 - self.a1 ** 2)这是tanh的导数。因为tanh导数最大是1所以delta1不会爆炸这也是选tanh的隐藏优势。有一点必须强调y必须和self.z2保持相同形状。如果y是形状(m,)的一维数组而z2是(m, 1)numpy广播虽然能算出结果但减法后的形状会变成(m, m)梯度方向直接错了。所以y在传入前一定要reshape(-1, 1)。4.3 训练循环的参数设计epoch、学习率与轮次里调试printfit方法里写训练循环每一轮做一次前向、算loss、反向更新权重。训练过程中打印loss曲线是判断模型状态最直接的手段。def fit(self, X, y, epochs1000, verboseTrue): for i in range(epochs): y_hat self.forward(X) loss 0.5 * np.mean((y_hat - y) ** 2) self.backward(X, y) if verbose and (i % 100 0 or i epochs - 1): print(fepoch {i:4d}, loss {loss:.6f}) def predict(self, X): return self.forward(X)每次调用forward网络内部会保存a1和z2这两份中间结果所以backward不需要重新计算前向过程。逻辑上forward和backward是一对必须成对调用不能先调backward再调forward否则a1还是上一次迭代的旧值。epochs设多少合理答案取决于数据量和学习率。1000轮是常见起点如果3000轮之后loss还在稳步下降说明学习率偏保守可以调大或者继续增加轮数。如果loss在某个值附近震荡说明学习率可能偏大降到0.001再试。verbose打印频率用i % 100 0这样既能观察下降趋势又不会被日志刷屏。训练完成后predict直接复用forward逻辑返回的依然是归一化后的值使用前记得按3.2里的公式逆变换回去。5. BP网络回归预测的避坑记录现象、原因与改法5.1 训练loss变成NaN初始化过大与学习率过高的共同作用现象第一个epoch打印出来的loss就是nan或者训练几轮之后突然从正常值跳到nan。原因通常是两个叠加输入数据里含有NaN或极端值权重初始化尺度偏大学习率设置过高。三者共同作用下前向传播数值膨胀梯度更新步长过大权重直接发散到无穷loss计算遇到溢出变成nan。解决方法是按顺序排查。先确认数据集干净调用np.isnan(x_train).any()检查输入有NaN就删行或填充。再把学习率降到0.001把权重初始化标准差从Xavier改成固定0.1甚至0.01人为限制初始权重尺度。如果问题消失再逐步调回参数。还有一个实用技巧在训练循环里打印np.linalg.norm(self.w1)如果权重范数在前几轮就剧烈增长基本可以断定梯度爆炸优先降学习率。5.2 预测值贴在训练均值附近归一化范围与网络容量的问题现象训练完成后测试集预测值几乎都在目标均值附近小幅波动算出来的R2接近0甚至为负。模型没有崩溃但没有任何预测能力。原因有两类。第一类是隐藏层节点太少网络容量不够学不到特征和目标之间的非线性关系。第二类是目标值归一化出了问题如果y存在极端离群值MinMax归一化会把大部分正常样本压到0.01到0.05这种极窄区间网络随便输出一个中间值loss就已经很小模型自然停在“平均解”上。解决方法是先看目标列的分布y_train_raw.min()和y_train_raw.max()如果差距极大优先对y做Z-Score归一化也就是减去均值除以标准差这样目标值的分布更均匀。然后检查隐藏层节点数是否至少达到输入特征数的1.5倍。最后把epochs加到3000观察loss是否还在持续下降。这三步都试过还不行再考虑增加输入特征或者换激活函数。5.3 测试集误差远大于训练集隐层节点数过多的过拟合现象训练结束打印的loss降到0.001以下训练集预测效果非常好但在测试集上RMSE大得离谱。这是回归预测里最典型的过拟合表现。原因是网络容量超过了数据能支撑的范围。隐藏层节点太多训练样本里的随机噪声也被当成规律记了下来模型在训练集上表现完美一旦遇到新样本错误的“规律”就开始起作用。epochs过长是另一个诱因模型在训练后期偏离真实规律死记样本。解决方法是减少隐藏层节点数回到特征数的1到1.5倍区间。另外可以在反向传播里加L2正则项本质是让权重不要太大# 在 __init__ 中增加 self.lam 0.001 # backward 中更新 w 前加上正则梯度 d_w2 self.a1.T delta2 self.lam * self.w2 d_w1 X.T delta1 self.lam * self.w1正则项只作用于权重w不作用于偏置b因为偏置不参与特征的方差累积加正则没有必要。更系统的做法是从训练集再切出一小块当验证集训练过程中每隔100轮在验证集上算一次loss一旦验证集loss开始回升立即停止训练这就实现了early stopping。手写代码加这个逻辑并不复杂但效果立竿见影。5.4 excel数据集读进来报错或全是NaN引擎、合并单元格和列类型现象pd.read_excel报缺少依赖或者读进来整个DataFrame全是NaN稍后训练时loss立刻变NaN。原因有几个。缺依赖是最简单的环境里没有openpyxl或xlrd。合并单元格会让pandas读取后出现大量NaN。Excel表格第一行是标题第二行是单位第三行才是数据如果没设置skiprows单位行就会被当成数据总行列所有数值列变成object类型。解决方法是安装依赖后从头检查用pip install openpyxl补齐解析引擎。读Excel时显式指定skiprows和header去掉无用的占位行。代码上最稳妥的是读取后直接执行select_dtypes(include[np.number])和dropna()只保留干净的数字列再进入归一化。这一步属于数据工程但BP网络对输入质量非常敏感数据清洗的时间投入在回归预测项目里永远划算。6. 验证手写BP的方式拿sklearn对照再用R2看回归质量6.1 R2与RMSE怎么算回归预测不看准确率回归预测不能用准确率评价输出是连续值不是类别。核心指标是RMSE和R2。RMSE直接反映误差的绝对量级但要结合目标值范围看才有意义。R2消除了量纲影响越接近1表示模型解释力越强0表示模型差于“均值预测”。from sklearn.metrics import r2_score, mean_squared_error y_pred_norm model.predict(x_test) y_pred_real y_pred_norm * (y_mx - y_mn) y_mn rmse np.sqrt(mean_squared_error(y_test_raw, y_pred_real)) r2 r2_score(y_test_raw, y_pred_real) print(fRMSE{rmse:.4f}, R2{r2:.4f})逆变换时用的是训练集的y_mn和y_mx这和归一化时的原则保持一致。6.2 与sklearn MLPRegressor对照同数据同参数对比手写BP最怕梯度算错。一个简单可靠的验证方法用相同数据和相同结构把sklearn的MLPRegressor也训练一遍对比两者RMSE。如果手写实现正确RMSE应该在同一个量级。from sklearn.neural_network import MLPRegressor mlp MLPRegressor( hidden_layer_sizes(n_hidden,), activationtanh, solversgd, learning_rate_init0.01, max_iter1000, random_state42, ) mlp.fit(x_train, y_train.ravel())sklearn用的是Adam或随机梯度下降和手写的全量梯度下降不完全等价所以不用追求完全一致的loss曲线只看RMSE量级。如果手写版明显差一个数量级优先检查反向传播里的矩阵转置和形状而不是怀疑数据集。6.3 固定随机种子做调参每次只改一个变量调参的习惯比参数本身更重要。我的血泪经验是固定seed每次只改一个变量。把隐藏层节点数分别试4、8、16、32各自训练1000轮在测试集上计算RMSE找出拐点n_hidden 8 model BPRegressor(x_train.shape[1], n_hidden, lr0.01, seed42) model.fit(x_train, y_train, epochs1000, verboseFalse)如果同时改学习率和隐藏层节点数效果变好了你并不知道是哪个改动起了作用。改坏同理两个变量互相干扰排查起来毫无头绪。先固定一份基线参数再逐个变量扫描每次记录RMSE这才是手写BP网络最少的实验成本。手写网络的价值不在于比sklearn跑得好而在于每一步计算都摊开在眼前出问题时能定位到具体是权重初始化、学习率还是数据泄漏。希望这篇笔记帮你在自己的数据上跑通第一条手写BP回归链路。本文还有配套的精品资源点击获取