用Numpy手写BP神经网络,搞定回归预测全流程
2026/9/23 19:27:18 网站建设 项目流程

简介:基于BP神经网络实现数据回归预测,面向需要快速上手神经网络的Python开发者和数据分析初学者,使用numpy完成网络搭建,以Boston房价数据集为示例,演示从Excel数据读取、网络训练到结果可视化的完整流程。压缩包共6个文件,包含可直接运行的Python脚本、两个Excel数据集、Markdown说明文档以及效果对比图,整体仅208KB,轻量易用。已有2255人学习浏览,适合作为课程设计或入门实践参考。除了dp_nn.py,还提供训练集与测试集Excel文件,替换数据即可迁移到自己的场景;matplotlib绘制的散点图与折线图能直观对比真实值与预测值,帮助理解BP网络回归拟合效果与误差分布。README对运行方式做了说明,配合两张结果图可快速复现并拓展到其他回归任务。

1. 用numpy从零实现BP神经网络做回归预测,反而比先装框架更省事

绝大多数讲BP神经网络的教程,第一步是让你装TensorFlow或PyTorch,但当你手里只有一份Excel数据集、任务只是回归预测时,用numpy手写BP网络反而更快。因为单隐层的BP网络用不到自动求导,用不到GPU加速,框架带来的便捷远小于环境配置的麻烦。这篇文章就是把标题里这条路径完整走一遍:读取Excel数据集、特征归一化、用numpy搭建BP神经网络、训练到收敛、输出预测结果并评估误差。整个过程只依赖numpy和pandas,你甚至不需要理解什么叫计算图。适合刚学完Python基础、想搞懂神经网络内部机制的人,也适合做数据分析但不想引入重型框架的工程师。

2. 数据准备:Excel数据集读取、归一化与训练测试划分

2.1 用pandas读取Excel数据集:三种常见写法

读Excel最方便的库是pandas,底层依赖openpyxl或xlrd。如果pandas没装,就用pip install pandas openpyxl一次性装齐。下面三种写法覆盖了大多数场景:

import pandas as pd # 方式1:读取默认第一个sheet df = pd.read_excel('data.xlsx') # 方式2:指定sheet名,只读取需要的列 df = pd.read_excel('data.xlsx', sheet_name='Sheet1', usecols=['feature1', 'feature2', 'target']) # 方式3:sheet也可以传序号,从0开始 df = pd.read_excel('data.xlsx', sheet_name=0)

usecols参数在特征很多时很实用,只加载模型需要的列,省内存也省时间。读取后最好先看一眼数据规模和缺失值情况:

print(df.shape) # (样本数, 列数) print(df.isnull().sum()) # 每列缺失值计数

缺失值建议用列均值填充,不能直接删行,因为回归预测的样本量本来就有限。

接下来把DataFrame拆成特征矩阵和目标向量,这一步是后面所有矩阵运算的前提:

X = df[['feature1', 'feature2']].values # shape = (m, 2) y = df['target'].values.reshape(-1, 1) # shape = (m, 1)

reshape(-1, 1)是把一维数组变成列向量,如果不做这一步,后面np.dot(a1.T, dloss)的维度会直接报错或者算出错误结果。

2.2 归一化方法选择:min-max与z-score

BP神经网络对输入特征的数值范围极其敏感。一个特征范围是0到1,另一个是0到100000,梯度更新会被大数值的特征主导,训练过程振荡甚至不收敛。回归任务里最常用的两种归一化处理,各有各的适用场景:

方法公式适用场景
min-max(x - min) / (max - min)特征分布均匀,无极端离群值
z-score(x - mean) / std特征存在离群值,分布偏态明显
import numpy as np def minmax_normalize(data): min_val = data.min(axis=0) max_val = data.max(axis=0) norm = (data - min_val) / (max_val - min_val) return norm, min_val, max_val def zscore_normalize(data): mean = data.mean(axis=0) std = data.std(axis=0) norm = (data - mean) / std return norm, mean, std

无论是目标值y还是特征X,都要做归一化。y归一化的目的是让输出层的误差量级与隐藏层激活函数的工作区间匹配,用sigmoid做隐藏层激活时尤其重要。

注意:函数返回的min_val、max_val(或mean、std)必须保存下来。预测阶段要用训练集上的同一套参数对新数据做变换,不能重新计算,否则归一化尺度不一致,预测结果直接失真。

2.3 划分训练集与测试集

归一化之后划分训练集和测试集,顺序不能反。如果用全量数据计算min和max再划分,测试集的分布信息已经泄漏到归一化参数里了:

def train_test_split(X, y, test_ratio=0.2, seed=42): np.random.seed(seed) m = X.shape[0] indices = np.random.permutation(m) test_size = int(m * test_ratio) test_idx, train_idx = indices[:test_size], indices[test_size:] X_train, X_test = X[train_idx], X[test_idx] y_train, y_test = y[train_idx], y[test_idx] return X_train, X_test, y_train, y_test

这里用np.random.permutation(m)生成打乱后的索引,比直接np.random.shuffle更安全,因为x和y可以共用同一组索引,保证特征和标签的对应关系不被拆散。seed设为固定值,保证每次运行划分结果一致,方便排查问题。

3. BP神经网络结构设计与numpy前向/反向传播实现

3.1 网络结构怎么定:输入层、隐藏层、输出层

BP神经网络做回归预测时,输入层节点数等于特征数量,输出层节点数等于目标值个数,回归任务一般就是1个节点。真正需要拍板的是隐藏层节点数。

隐藏层节点太少,模型拟合能力不足,预测曲线会相当粗糙;节点太多,模型把训练集的噪声也记下来了,测试误差反而变大。经验法则有几种:

# 三种常见估算方式 hidden_1 = int(input_dim + 1) # 最小下限 hidden_2 = int((input_dim + output_dim) / 2) # 均值法 hidden_3 = int((input_dim * output_dim) ** 0.5) # 开方法

我一般按(输入维度 + 输出维度) / 2取整作为起点,特征数不超过10个时,5个隐藏层节点通常已经足够,再按训练误差和测试误差的差距微调。

3.2 激活函数怎么选:回归任务和分类任务完全不同

分类任务输出层必须用sigmoid或softmax把结果压成概率,回归任务则要求输出任意范围的连续数值,所以输出层用线性激活,也就是不激活。隐藏层的激活函数负责引入非线性,让网络能拟合曲线。常用的有两个:

激活函数公式输出范围导数
sigmoid1 / (1 + exp(-x))(0, 1)x * (1 - x)
tanh(exp(x) - exp(-x)) / (exp(x) + exp(-x))(-1, 1)1 - x²

sigmoid的梯度存在一个典型问题:输入绝对值较大时导数趋近于0,导致梯度消失。tanh输出区间包含负值,收敛速度一般比sigmoid快。单隐层回归任务两者都可以,但要注意sigmoid导数写法:

def sigmoid(x): return 1 / (1 + np.exp(-x)) def sigmoid_derivative(x): return x * (1 - x) # 这里的 x 是激活后的输出,不是激活前的 z

很多人在这里搞反,把sigmoid_derivative当成对z求导。反向传播计算时,传进去的应该是隐藏层输出a1,不是z1,这一点直接决定梯度是否正确。

3.3 前向传播:输入如何变成预测值

以下代码演示了BP网络的前向传播过程:

def forward(self, X): self.z1 = np.dot(X, self.W1) + self.b1 self.a1 = sigmoid(self.z1) self.z2 = np.dot(self.a1, self.W2) + self.b2 self.y = self.z2 # 回归任务:线性输出 return self.y

X是归一化后的特征矩阵,形状(m, input_dim),W1形状(input_dim, hidden_dim)np.dot(X, W1)得到形状(m, hidden_dim)的中间结果,加偏置b1后经过sigmoid非线性映射,得到隐藏层输出a1。接着a1与W2相乘,W2形状(hidden_dim, output_dim),最终输出预测值y。如果输出层也用sigmoid,预测值会被限制在0到1之间,遇到测试集目标值超出训练集范围时会封顶,这是回归任务的大忌。

3.4 反向传播:误差链式回传的numpy实现

反向传播是BP网络的灵魂。回归任务用均方误差(MSE)作为损失函数,MSE对预测值的导数是y_pred - y_true,这个结果直接当作输出层的误差信号传播回去:

def backward(self, X, y, lr): m = X.shape[0] # 输出层梯度 dloss = self.y - y dW2 = np.dot(self.a1.T, dloss) / m db2 = np.sum(dloss, axis=0) / m # 隐藏层梯度 dhidden = np.dot(dloss, self.W2.T) * sigmoid_derivative(self.a1) dW1 = np.dot(X.T, dhidden) / m db1 = np.sum(dhidden, axis=0) / m # 参数更新 self.W2 -= lr * dW2 self.b2 -= lr * db2 self.W1 -= lr * dW1 self.b1 -= lr * db1

这里所有梯度都除以了m,即使用了批量梯度下降的平均梯度,避免样本量影响学习率的选择。dloss(m, 1)a1.T(hidden_dim, m),两者相乘得到(hidden_dim, 1)的W2梯度,维度恰好和W2对齐。dhidden把输出层误差通过W2映射回隐藏层,再乘上sigmoid导数完成链式法则,这就是"误差反向传播"的核心。

4. 完整训练循环与参数调优:learning rate、epochs、hidden_dim

4.1 能直接跑通的完整训练代码

前面拆解了各个模块,现在组装成完整代码。以下代码可以直接粘贴运行,只依赖numpy和pandas:

import numpy as np import pandas as pd def minmax_normalize(data): min_val = data.min(axis=0) max_val = data.max(axis=0) return (data - min_val) / (max_val - min_val), min_val, max_val def sigmoid(x): return 1 / (1 + np.exp(-x)) def sigmoid_derivative(x): return x * (1 - x) class BPNN: def __init__(self, input_dim, hidden_dim, output_dim): self.W1 = np.random.randn(input_dim, hidden_dim) * 0.5 self.b1 = np.zeros((1, hidden_dim)) self.W2 = np.random.randn(hidden_dim, output_dim) * 0.5 self.b2 = np.zeros((1, output_dim)) def forward(self, X): self.z1 = np.dot(X, self.W1) + self.b1 self.a1 = sigmoid(self.z1) self.z2 = np.dot(self.a1, self.W2) + self.b2 self.y = self.z2 return self.y def backward(self, X, y, lr): m = X.shape[0] dloss = self.y - y dW2 = np.dot(self.a1.T, dloss) / m db2 = np.sum(dloss, axis=0) / m dhidden = np.dot(dloss, self.W2.T) * sigmoid_derivative(self.a1) dW1 = np.dot(X.T, dhidden) / m db1 = np.sum(dhidden, axis=0) / m self.W2 -= lr * dW2 self.b2 -= lr * db2 self.W1 -= lr * dW1 self.b1 -= lr * db1 def train(self, X, y, epochs, lr): for epoch in range(epochs): self.forward(X) self.backward(X, y, lr) if epoch % 100 == 0: loss = np.mean((self.y - y) ** 2) print(f"epoch {epoch}, loss = {loss:.6f}") # 读取Excel数据集 df = pd.read_excel('data.xlsx') X = df[['feature1', 'feature2', 'feature3']].values y = df['target'].values.reshape(-1, 1) # 归一化 X_norm, x_min, x_max = minmax_normalize(X) y_norm, y_min, y_max = minmax_normalize(y) # 训练 model = BPNN(input_dim=3, hidden_dim=5, output_dim=1) model.train(X_norm, y_norm, epochs=2000, lr=0.1)

这段代码里,权重用np.random.randn(...) * 0.5初始化,randn生成标准正态分布随机数,乘0.5把初始权重压到[-1, 1]附近,防止激活值一开始就落在sigmoid饱和区。训练循环里每100轮打印一次MSE,用来观察loss的下降趋势。

4.2 三个必调参数:learning rate、epochs、hidden_dim

BP神经网络调参,核心就是下面三个:

参数常见取值范围出了问题怎么调
learning rate0.01 ~ 0.1loss震荡不下降就调小,下降太慢就适当调大
epochs1000 ~ 50001000轮内loss还在明显下降就继续加
hidden_dim4 ~ 16训练误差大就增加,测试误差大就减少

learning rate是BP神经网络里最敏感的参数。取0.1只是常见起点,实际训练中如果发现loss在某个值附近反复横跳,大概率是学习率偏大,改成0.03或0.01通常就稳定了。

epochs不是越大越好。我习惯先跑1000轮看loss曲线,如果曲线最后一段还在明显下行,就继续训练;如果已经走平甚至反弹,说明训练过头了。

hidden_dim的选择可以做一个简单实验:分别用3、5、8、12个节点跑同一份数据,比较测试集误差。注意节点数翻倍,训练时间会相应增加,在回归预测这类小数据任务里,没必要追求巨大网络。

4.3 用matplotlib绘制损失下降曲线

训练过程是否正常,光看打印的数字不够直观,把loss画成曲线更清楚:

import matplotlib.pyplot as plt loss_history = [] for epoch in range(epochs): model.forward(X_norm) model.backward(X_norm, y_norm, lr) if epoch % 50 == 0: loss_history.append(np.mean((model.y - y_norm) ** 2)) plt.plot(range(0, epochs, 50), loss_history) plt.xlabel('epoch') plt.ylabel('MSE loss') plt.title('BPNN Training Loss Curve') plt.show()

如果loss曲线是平滑下降后趋于水平,说明训练正常。如果曲线出现锯齿状上下跳动,检查learning rate是否过大。如果loss从一开始就几乎不变,仔细检查归一化是否漏了、sigmoid_derivative传入的到底是a1还是z1

5. 回归预测评估:MSE、MAE、R²与拟合曲线可视化

5.1 测试集预测与反归一化

训练完成后,要用测试集数据验证模型泛化能力。预测时新数据必须用训练集保存的归一化参数做变换:

def predict(model, X_new, x_min, x_max, y_min, y_max): X_norm = (X_new - x_min) / (x_max - x_min) y_norm = model.forward(X_norm) y_real = y_norm * (y_max - y_min) + y_min return y_real # 假设X_test是划分好的测试特征 y_pred = predict(model, X_test, x_min, x_max, y_min, y_max)

反归一化的公式是归一化的逆运算:(max - min)乘回去再加min。这一步漏了的话,预测值和真实值完全不在一个量级上,后续所有评估指标都会失去意义。

5.2 回归指标:MSE、MAE、R²的计算与解读

回归预测看三个指标就够,各有侧重:

指标公式含义
MSEmean((y_true - y_pred)²)误差平方的平均,对大误差敏感
MAEmean(abs(y_true - y_pred))误差绝对值的平均,更直观
1 - SS_res / SS_tot模型解释了多少比例的数据波动
def evaluate(y_true, y_pred): mse = np.mean((y_true - y_pred) ** 2) mae = np.mean(np.abs(y_true - y_pred)) ss_res = np.sum((y_true - y_pred) ** 2) ss_tot = np.sum((y_true - np.mean(y_true)) ** 2) r2 = 1 - ss_res / ss_tot return {'MSE': mse, 'MAE': mae, 'R²': r2} print(evaluate(y_test, y_pred))

R²接近1说明模型解释能力强,接近0说明模型和直接用均值预测差不多,出现负数说明模型比均值预测还差。如果R²为负,优先检查是不是训练集和测试集的分布差异过大,或者归一化参数在预测时用错了。MSE和MAE的量级跟业务数据本身有关,跨数据集比较没有意义,但在同一个数据集上,MSE明显大于MAE说明存在少数大误差样本,值得检查是否有离群值。

5.3 拟合曲线可视化与误差分布

单特征回归任务直接画拟合曲线,多特征则画真实值与预测值的散点对比:

plt.figure(figsize=(10, 4)) plt.subplot(1, 2, 1) plt.scatter(y_test, y_pred, alpha=0.6) plt.plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], 'r--') plt.xlabel('True Value') plt.ylabel('Predicted Value') plt.title('Prediction vs True') plt.subplot(1, 2, 2) residual = y_test - y_pred plt.scatter(y_pred, residual, alpha=0.6) plt.axhline(y=0, color='r', linestyle='--') plt.xlabel('Predicted Value') plt.ylabel('Residual') plt.title('Residual Plot') plt.tight_layout() plt.show()

残差图中如果红色虚线附近随机散布,说明模型没有系统性偏差。如果残差呈现出漏斗形状,比如预测值越大残差越大,说明数据存在异方差性,可以考虑对目标值取对数后再训练,效果往往更好。

6. 训练不稳定时的三个技巧:权重初始化、学习率衰减与早停

6.1 权重初始化范围的选择

前面用了np.random.randn(...) * 0.5,这个0.5是经验值。权重初始化范围影响训练初期的梯度大小,范围太大导致隐藏层输出进入sigmoid饱和区,梯度消失;范围太小导致各层信号逐层衰减,收敛缓慢。常见的做法是根据输入节点数调整缩放系数:np.random.randn(input_dim, hidden_dim) * np.sqrt(2 / input_dim),这个量级可以保证每层输出的方差不会逐层放大或缩小。

6.2 学习率衰减:跑得更稳的常用手段

训练前期需要大学习率快速下降,后期需要小学习率精细收敛。最简单的方式是按epoch线性衰减:

def train_with_decay(self, X, y, epochs, lr0, decay): for epoch in range(epochs): lr = lr0 / (1 + decay * epoch) self.forward(X) self.backward(X, y, lr)

decay取0.001到0.01之间时,学习率从0.1逐渐降到0.02左右,比固定学习率更容易在后期逼近最优解。这种方法没有额外计算开销,在numpy实现里只需要多算一个lr,非常实用。

6.3 早停法:防止过拟合的实用技巧

训练过程中监控测试集误差,一旦发现测试误差连续多轮不再下降甚至上升,立即停止训练。实现上只需在训练循环里保存一份测试集Loss:

best_loss = float('inf') patience = 0 for epoch in range(epochs): model.forward(X_train) model.backward(X_train, y_train, lr) y_pred_test = model.forward(X_test) test_loss = np.mean((y_pred_test - y_test) ** 2) if test_loss < best_loss: best_loss = test_loss patience = 0 else: patience += 1 if patience > 200: print(f"early stop at epoch {epoch}") break

patience设为200的含义是允许测试误差连续200轮不创新低,超过这个阈值就认为训练已经过拟合。需要注意的是,早停时model里保存的是触发停止那一刻的参数,而不是历史最优参数,如果想保留最优模型,需要另外把best_loss对应的W1、W2、b1、b2复制一份。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询