ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

BP神经网络回归预测:纯numpy实现与代码拆解

BP神经网络回归预测:纯numpy实现与代码拆解

简介:这份资源是一套基于BP神经网络的数据回归预测Python代码,面向希望掌握回归预测实现方法的Python学习者与数据分析人员。代码使用NumPy自行搭建BP网络,以波士顿房价数据集为示例,完整覆盖数据读取、模型训练、结果对比与可视化环节。数据文件为Excel格式,可直接替换成自己的数据集,运行dp_nn.py脚本即可完成预测,并通过matplotlib输出真实值与预测值的散点图及折线图。资源压缩包共6个文件,包括1个Python脚本、2个Excel数据文件、2张结果图片和1份说明文档,整体大小约208KB,结构清晰便于直接使用。已有2255人学习下载,适合希望通过简洁工程案例理解BP网络原理,并快速迁移到自定义回归场景的读者。获取后可获得可直接运行的代码、图表化评价方式与数据集组织范例,有效降低入门门槛。

1. 基于BP神经网络的数据回归预测:一份能跑通的numpy实现,值得你花半小时拆一遍

把一份Excel表格丢给BP神经网络做数据回归预测,很多人第一反应是上TensorFlow或PyTorch,但这套基于BP神经网络的数据回归预测Python代码用纯numpy就做到了——它只有一个核心脚本dp_nn.py,加载Boston房价的训练和测试Excel数据,训练一个隐藏层BP网络,迭代完成后直接画出预测值与真实值的散点对比图和折线对比图。代码没套框架,前向传播、反向传播、梯度更新全部裸写,适合不想被框架黑匣子困住的人。新手可以用它建立回归预测的完整直觉,熟手则能快速替换成自己的Excel数据集做基线验证。整个包从数据到图一步到位,拿到就能跑,下面我把每个环节拆开讲。

2. BP网络的前向与反向传播:13个特征如何被压缩成一个房价预测值

2.1 三层网络结构:输入层、隐藏层、输出层各自承担什么

Boston房价数据集的标准形态是前13列特征,最后一列是目标值MEDV。13个特征包括犯罪率、房间数、房龄、到高速公路距离、税收等,它们单位不同、量级差异大,所以进网络前必须先做归一化。这份代码的实现里,输入层节点数就是13,输出层节点数是1,隐藏层节点数通常取10左右。三层结构是BP网络最经典的形态,隐藏层负责把线性不可分的特征空间做非线性映射。

隐藏层的激活函数一般用sigmoid或tanh。sigmoid输出范围是(0,1),和归一化后的目标值在同一量级;tanh输出范围是(-1,1),收敛通常更快。这份资源没有在说明里指名激活函数,我按最常规的写法推断用的是sigmoid,你替换数据时如果发现收敛慢,可以改成tanh对比一下。输出层在回归任务里通常不加激活函数或直接用线性输出,这样预测值才能突破sigmoid的(0,1)区间限制。

2.2 反向传播的四个核心公式:权重更新到底在更新什么

很多入门者卡在反向传播,其实核心就四个公式。设输入层到隐藏层的权重矩阵为W1,隐藏层到输出层的权重矩阵为W2,激活函数为f。

第一,计算输出层误差:delta_out = (y_pred - y_true) 乘以激活函数在输出层的导数。第二,计算隐藏层误差:delta_hidden = W2转置乘以delta_out,再逐元素乘以隐藏层激活函数的导数。第三,计算梯度:W2的梯度是delta_out乘以隐藏层输出,W1的梯度是delta_hidden乘以输入。第四,更新权重:W = W - learning_rate乘以梯度。

# 核心反向传播逻辑示意 # 这段是numpy实现的BP网络最关键的权重更新部分 delta_out = (y_pred - y_batch) * sigmoid_derivative(z_out) # 输出层误差 delta_hidden = np.dot(delta_out, W2.T) * sigmoid_derivative(z_hidden) # 隐藏层误差 W2 -= learning_rate * np.dot(z_hidden.T, delta_out) # 更新隐藏层到输出层权重 W1 -= learning_rate * np.dot(X_batch.T, delta_hidden) # 更新输入层到隐藏层权重

这里有个容易被忽略的细节:sigmoid的导数是sigmoid(x)乘以(1 - sigmoid(x)),当网络输出接近0或1时,导数趋近于0,梯度近乎消失,权重更新速度骤降。这就是为什么数据归一化在BP网络里不是可选项而是必选项——特征不归一化,sigmoid的输入很容易落在饱和区,Loss卡住不动是常有的事。

2.3 训练循环里的关键参数:学习率、迭代次数、随机种子

这份代码的训练过程是把训练集按批次喂给网络,或者全量训练,两种方式在numpy实现里都很常见。学习率一般设在0.01到0.1之间,太大Loss震荡甚至爆炸,太小收敛慢到让人怀疑代码写错了。迭代次数通常设几千次,因为BP网络每轮迭代只做一次完整的前后向传播,不像现代框架里有batch和epoch的区分。

随机种子也是容易被忽略的参数。权重初始化用np.random.randn或np.random.uniform,如果不固定种子,每次运行时初始权重不同,最终Loss曲线和训练效果会略有差异。我建议你第一次跑通后记住当前seed,后面对比参数效果时固定它,否则不同次运行之间的噪声会让你误判调参方向。这个项目里如果没显式设置seed,你可以在初始化权重前加一行np.random.seed(42),让结果可复现。

3. 环境搭建与Excel数据准备:numpy、openpyxl与文件列序对齐

3.1 环境配置与numpy版本核对

这份资源的依赖非常轻,核心就是numpy和matplotlib,外加pandas用于读取Excel。安装命令如下,你最好在运行前先核对一下python和numpy的版本,网上常见的“numpy版本不匹配”报错,多半是多个python环境串了。

# 在命令行执行环境安装 pip install numpy pandas matplotlib openpyxl # 核对版本,确认当前解释器用的是哪个numpy python -c "import numpy; print(numpy.__version__)" # 如果环境中已经有旧版numpy,需要先升级 pip install --upgrade numpy

安装完成后建议跑第一行命令的验证逻辑,确认不是pip装到了一个解释器、运行时用的是另一个解释器。这种环境串台的坑在Windows机器上特别多,我在第5章会专门讲。openpyxl是pandas读取xlsx文件的后端引擎,如果只装了pandas没装openpyxl,pd.read_excel会直接抛ImportError。

3.2 dp_nn.py的启动方式与迭代轮数调整

运行方式很简单,在项目根目录下执行python dp_nn.py,脚本会按顺序完成数据加载、归一化、网络初始化、迭代训练、绘图和保存。第一次运行时建议打开dp_nn.py找到迭代次数的变量,比如epochs = 5000,先改成500跑一遍,确认数据读入和网络形状都对,再改回完整轮数。这样做的好处是,如果训练代码里有维度错误,500轮内就会暴露,不用等完整训练跑完才发现。

# 在项目根目录执行,观察是否成功打印数据形状和初始Loss python dp_nn.py

正常情况控制台会输出类似“训练集形状: (404, 13),测试集形状: (102, 13)”的信息,注意Boston房价标准数据集是404个训练样本和102个测试样本,如果数字对不上,检查Excel文件是否被改动过。

3.3 替换成自己的Excel数据集:列顺序与最后一列的坑

替换数据集是这个项目最有价值的地方。Excel文件必须是数值型矩阵,最后一列是目标值,前面所有列是特征。如果你自己的数据有字符串列、缺失值或单位符号,得先在Excel里清洗干净。常见做法是用pd.read_excel读取后,在numpy转换前用df.select_dtypes(include=[np.number])丢掉非数值列。

import pandas as pd import numpy as np # 读取自己的Excel文件 df = pd.read_excel("my_data.xlsx", engine="openpyxl") # 只保留数值列,避免字符串列破坏矩阵转换 df = df.select_dtypes(include=[np.number]) # 最后一列是目标值,前面所有列是特征 X = df.iloc[:, :-1].values y = df.iloc[:, -1].values.reshape(-1, 1) print("特征形状:", X.shape, "目标值形状:", y.shape)

这段代码里的iloc[:, :-1]表示取所有行、去掉最后一列的所有列,iloc[:, -1]单独取出最后一列。目标值reshape成(-1, 1)是因为numpy的矩阵运算里,一维数组和二维列向量行为不同,BP网络里隐藏层输出是二维矩阵,目标值如果不是二维列向量,y_pred - y_true会触发广播机制算出一个形状错误的结果,这是新手最容易踩的维度坑之一。

4. dp_nn.py核心代码逐段拆解:从数据加载到Loss曲线落地

4.1 数据加载与归一化:训练集和测试集共用同一组min和max

数据加载之后的第一件事是归一化。这里有一个非常关键的工程习惯:归一化的min和max必须从训练集上计算,然后拿这组参数去变换测试集。很多人图省事把训练集和测试集拼在一起求min和max,这会让测试数据的信息提前泄漏进训练过程,导致测试结果虚高。

# 归一化函数:将数据缩放到[0, 1]区间 def normalize(X, min_vals=None, max_vals=None): if min_vals is None or max_vals is None: min_vals = X.min(axis=0) # 每个特征列的最小值 max_vals = X.max(axis=0) # 每个特征列的最大值 X_norm = (X - min_vals) / (max_vals - min_vals + 1e-8) # 加极小值防除零 return X_norm, min_vals, max_vals # 训练集上计算min和max,测试集沿用同一组 X_train_norm, min_vals, max_vals = normalize(X_train) X_test_norm, _, _ = normalize(X_test, min_vals, max_vals)

axis=0的意思是沿着行的方向求每一列的极值,返回形状是(13,)的数组,和特征列数对齐。分母加上1e-8是为了防止某个特征在数据里恒定不变时出现除零。预测完成后如果想还原成真实房价数值,需要把预测结果乘以(max_vals[-1] - min_vals[-1])再加上min_vals[-1],这个还原逻辑在第6章展开。

4.2 网络初始化与前向传播:用均匀分布控制权重尺度

权重初始化的尺度直接影响收敛速度。常见做法是用np.random.uniform在(-0.5, 0.5)之间取随机数,或者用标准正态分布乘以一个较小的系数。大权重的后果是神经元输出容易落入sigmoid饱和区,梯度消失,从小权重起步是一种性价比最高的初始化策略。

# 网络参数初始化 input_size = X_train_norm.shape[1] # 特征数量 hidden_size = 10 # 隐藏层神经元数量 output_size = 1 # 输出层神经元数量 np.random.seed(42) # 固定随机种子,保证结果可复现 W1 = np.random.uniform(-0.5, 0.5, (input_size, hidden_size)) b1 = np.zeros((1, hidden_size)) W2 = np.random.uniform(-0.5, 0.5, (hidden_size, output_size)) b2 = np.zeros((1, output_size))

偏置b1和b2初始化为零是安全做法,权重用均匀分布是为了打破对称性。如果所有权重初始化为同一个数值,同一层的神经元会学到完全相同的特征,网络退化成只有一个神经元在干活。前向传播就是把输入逐层计算到输出,隐藏层经过sigmoid激活,输出层直接线性输出,这样预测值就不受sigmoid的值域限制。

4.3 损失计算、反向传播与参数更新:MSE的意义与梯度裁剪

回归任务的损失函数用均方误差MSE,含义是预测值与真实值差值的平方的平均。为什么用平方而不是绝对值?因为平方损失对大误差更敏感,梯度方向能更快地把大偏差拉回来。MSE的导数刚好是(y_pred - y_true)乘以2除以样本数,这个形式和反向传播的误差项天然衔接。

# 训练循环内的核心逻辑 for epoch in range(epochs): # 前向传播 z1 = np.dot(X_train_norm, W1) + b1 # 隐藏层线性组合 a1 = sigmoid(z1) # 隐藏层激活 z2 = np.dot(a1, W2) + b2 # 输出层线性组合 y_pred = z2 # 回归任务输出层不加激活 # 计算MSE损失 loss = np.mean((y_pred - y_train) ** 2) # 反向传播,推导过程对应第2章的四个公式 delta_out = (y_pred - y_train) * 1 # 线性输出的导数为1 delta_hidden = np.dot(delta_out, W2.T) * sigmoid_derivative(a1) # 梯度计算与权重更新 grad_W2 = np.dot(a1.T, delta_out) / n_samples grad_W1 = np.dot(X_train_norm.T, delta_hidden) / n_samples W2 -= learning_rate * grad_W2 W1 -= learning_rate * grad_W1 b2 -= learning_rate * np.mean(delta_out, axis=0, keepdims=True) b1 -= learning_rate * np.mean(delta_hidden, axis=0, keepdims=True)

注意delta_out里乘的“1”就是线性输出的导数,很多教程写回归任务时输出层没有激活函数,这个导数为1的细节容易被忽略。keepdims=True是为了保持偏置的维度是(1, hidden_size)而不是被压缩成一维数组。梯度更新除以n_samples是为了让损失和梯度的尺度不随样本量变化,这样当你从404个样本的数据换成4000个样本的数据时,学习率不用重新调。

4.4 matplotlib绘图:散点图看趋势,折线图看细节

代码里的两张图,Figure_2.png和Figure_1.png,分别对应真实值与预测值的散点对比和按样本序号排列的折线对比。散点图用来判断整体预测质量,理想情况是散点沿对角线分布;折线图用来观察局部波动,看预测值是否紧跟真实值的起伏。

import matplotlib.pyplot as plt # 散点图:观察预测值与真实值是否沿对角线分布 plt.figure(figsize=(8, 6)) plt.scatter(y_test, y_pred, alpha=0.7, edgecolors='k', linewidths=0.5) plt.plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], 'r--') plt.xlabel("真实值") plt.ylabel("预测值") plt.title("测试集真实值 vs 预测值") # 折线图:按样本序号对比预测与真实的走势 plt.figure(figsize=(12, 5)) plt.plot(y_test, label="真实值", marker='o', markersize=3) plt.plot(y_pred, label="预测值", marker='s', markersize=3) plt.xlabel("样本序号") plt.ylabel("房价") plt.legend()

alpha=0.7控制散点的透明度,样本重叠多时能看出密度差异。红色虚线是理想的完全预测线,散点离它越近说明模型越准。折线图的markersize设小一些是经验之谈,测试集100多个样本时大标记会让线条变成黑疙瘩,反而看不清走势。如果样本上千,折线图建议只画前50个样本,否则横坐标标签全部挤成一团,这也是热词里“python画图横坐标太密集”的高频来源。

5. 五个高频问题排查:预测值趋平、Loss不降、图表异常的翻车记录

5.1 现象:训练完预测值全都趋近于同一个常数

现象:测试集预测结果画出来是接近水平的一条线,无论输入特征怎么变化,输出都在某个值附近小幅波动。

原因:最常见的是学习率过大导致权重在最优解附近来回震荡,最终收敛到一个糟糕的局部点;其次是特征没有归一化或归一化参数用错,某个特征数值过大,它的梯度过大把其他特征的权重压没了。

解决:先把学习率调低一个数量级,比如从0.1改成0.01;再检查训练集和测试集是否用了同一组min和max归一化参数,代码里如果测试集单独调用了normalize函数重新计算min和max,赶紧改成沿用训练集参数。

5.2 现象:Loss一直不下降,甚至变大

现象:训练前几轮Loss从某个值开始,迭代几百轮后还在原地踏步,偶尔还会突然跳到更大值。

原因:权重初始值过大,所有sigmoid神经元都落在饱和区,梯度消失。另一种可能是数据里有缺失值被pandas读成了NaN,NaN参与矩阵运算后Loss直接变成nan。

解决:初始化权重前打印一下X_train_norm是否包含np.isnan和np.isinf,有就回到Excel清洗。权重初始化改成np.random.randn(input_size, hidden_size) * 0.1,把标准差从0.5压到0.1,sigmoid的输入范围收窄,梯度消失问题能明显缓解。

5.3 现象:替换数据集后报错“shape mismatch”

现象:把Boston数据换成自己的Excel后,运行报错ValueError: shapes (100,20) and (13,10) not aligned。

原因:这是维度不匹配,dp_nn.py里写死的input_size = 13和hidden_size = 10,你的数据特征数是20,和W1的第一维对不上。

解决:找到input_size = X_train_norm.shape[1]这行,确认代码是用数据的实际特征数动态取值的,而不是写死13。如果是写死的,改成动态获取;网络初始化部分的代码顺序一定要在数据加载之后。

5.4 现象:matplotlib画出来的折线图横坐标标签全部糊在一起

现象:测试集有1000个样本时,X轴刻度从0到999,每个刻度标签都叠在一起,看起来就像一条黑线。

原因:matplotlib默认把所有刻度标签都画出来,样本多时不自适应稀疏化。这个在热词里是高频问题,很多人在自己的数据上遇到过,不算代码bug。

解决:画图前用plt.xticks(np.arange(0, len(y_test), step=50))强制每50个样本显示一个刻度;或者更简单,只画前100个样本plt.plot(y_test[:100])。对于回归预测的结果观察,前100个样本的走势已经完全够用。

5.5 现象:pip安装numpy后运行仍然提示ModuleNotFoundError

现象:明明执行了pip install numpy,运行dp_nn.py还是报ModuleNotFoundError: No module named 'numpy'。

原因:本机装了多个Python环境,pip安装到了A环境,运行脚本用的是B环境。Windows上更常见的是命令行里输入python打开的是Microsoft Store的Python,和刚才pip对应的解释器不是同一个。

解决:在项目根目录执行python -m pip install numpy pandas matplotlib openpyxl,用python -m pip能保证安装到当前python解释器对应的环境里。装完再用python -c "import numpy; print(numpy.__file__)"确认模块路径,看到路径和你运行脚本的解释器一致才算彻底解决。

6. 把这份代码用到自己的数据集上:隐藏层神经元、归一化边界与早停判断

6.1 隐藏层神经元数量:从特征数的一半开始试

隐藏层神经元数量是BP网络里最需要调的参数,它控制模型的拟合能力。神经元太少,网络对非线性关系的表达能力不足,预测值会偏“钝”;太多则容易记住训练集的噪声,测试集效果反而变差,这就是过拟合。我一般会从特征数的一半开始试,比如20个特征就先用10个隐藏神经元跑一遍,记录测试集MSE;再分别试8、12、16个,对比哪个MSE最低。这份代码里如果写死了hidden_size = 10,你把它改成变量,就能批量对比了。注意神经元数量变化时,耗时和训练轮数也要相应调整。

6.2 归一化边界与预测值还原

归一化到[0,1]是最稳妥的选择,因为它和目标值的sigmoid输出范围一致。但还原预测值这步很多人会漏掉:模型输出的0.6和0.8之间的差距,对应的真实房价差距是多少?必须用训练集目标列的最大值和最小值换回去。

# 还原预测值为真实量纲 y_pred_real = y_pred * (max_vals[-1] - min_vals[-1]) + min_vals[-1] y_test_real = y_test * (max_vals[-1] - min_vals[-1]) + min_vals[-1]

这两行放在绘图之前执行,画出来的散点图坐标轴就是真实房价,而不是归一化后的抽象数字。max_vals[-1]和min_vals[-1]是第4章归一化时保存的训练集目标列的极值,如果你在替换数据集时没有保存返回值,推荐回到4.1节的代码实现,返回值里的min_vals和max_vals一定要接住,这是还原阶段唯一的钥匙。

6.3 从训练曲线判断早停时机

没有验证集时,很多人傻傻跑满迭代次数在等Loss降到最低,其实在训练脚本里加一个每100轮打印Loss的语句就够了:

if epoch % 100 == 0: print(f"Epoch {epoch}, Loss: {loss:.6f}")

观察Loss下降曲线,前500轮如果已经从初始值降了80%,后面继续跑收益往往很小,甚至可能出现Loss先降后升的反常情况,那是学习率过大导致权重在最优解附近震荡,此时与其增加迭代次数,不如调低学习率重新跑。从那以后我每次替换数据集,都强制走一遍固定seed、检查归一化参数、打印前500轮Loss、预测值还原对比这条流程,这套流程帮我挡下了无数个“看起来训得很好其实是数据泄漏”的假象。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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