☰
纯NumPy实现BP神经网络:房价预测回归任务从原理到代码
2026/10/17 11:49:07 网站建设 项目流程

简介:一份基于BP神经网络完成波士顿房价预测的Python实现,面向机器学习初学者、需要完成回归预测任务的学生,以及希望理解前向与反向传播原理的开发者。压缩包共2个文件,包含pred_nn.py主代码和housing.csv数据集,整体仅8KB,代码精简,适合直接阅读与调试。已有21743人学习下载,是入门神经网络回归的常用参考。实现涵盖数据读取与预处理、网络结构定义、前向传播、方差损失计算及反向传播权重更新等关键环节,并给出训练后评估模型的方式。代码使用pandas与numpy加载CSV数据,可帮助读者理清BP神经网络的完整训练流程,掌握将房屋特征映射到中位价格的回归建模思路,也能为后续扩展隐藏层、调整学习率或尝试其他数据集提供清晰范本。

1. 项目定位:房价预测的BP神经网络,一份能跑通的纯NumPy回归实现

某房产数据分析小组想预测二手房成交价,先用线性回归,R2卡在0.6上下不去。换成BP神经网络后,同一份数据跑到了0.85以上。这个项目就是那套方案的完整Python实现:CSV数据清洗、特征归一化、BP网络训练、回归预测,全流程可复现。项目用纯NumPy手写BP算法,不依赖tensorflow、pytorch之类的深度学习框架,前向传播和反向传播的每一步都摊在源码里。适合两类人:想搞懂BP原理、把反向传播推导落到代码上的初学者,以及在不引入重型框架的前提下快速做回归预测的开发者。按下面的步骤跑一遍,就能得到一个可训练的房价预测模型,也可以直接套到自己的回归任务上。

2. 项目结构与技术选型:为什么是BP神经网络而不是线性回归

2.1 技术选型分析:BP神经网络在回归任务里的优势

房价预测本质是一个回归问题,特征与目标之间往往不是简单的线性关系。比如面积对房价的影响,小户型区间可能是每平米加一万,到了大平层区间加价幅度会放缓,这种“非线性饱和”效应是线性回归很难表达的。决策树回归也能处理非线性,但外推能力弱,数据分布稍微偏移就很容易跑偏。在特征维度不高、数据量几千到几万条这个量级上,BP神经网络的拟合上限更高,调参路径也更直观。

BP神经网络的核心优势来自它的万能逼近能力:一个包含足够多隐藏节点的前馈网络,配合合理的激活函数,可以逼近任意连续函数。这个项目的目标不是做工业级估价系统,而是用一份干净可读的代码把BP算法的完整链路打通。所以没有用tensorflow、pytorch这些框架,而是纯NumPy手写。框架一旦引入,前向传播和反向传播就变成了黑匣子,真出问题时定位成本反而更高。NumPy实现的另一个好处是与数据科学栈无缝衔接:数据清洗用pandas、归一化用sklearn,矩阵运算在numpy内部完成,整条链路不需要切换环境。

方案非线性拟合能力实现成本适用场景
线性回归弱,只能拟合线性关系极低,sklearn几行特征与目标近似线性
BP神经网络强,可逼近任意连续函数中等,纯NumPy约200行非线性关系明显,数据量几千以上
决策树回归较强,但外推能力弱低特征含义清晰、追求可解释性

对房价这种数据,非线性是常态,所以BP是最合适的落点。项目把权重初始化、前向传播、反向传播、训练循环全部封装在一个类里,读代码的过程就是把《神经网络与深度学习》教材里的公式一笔一笔重算的过程。

2.2 项目目录结构与依赖清单

项目解压后的目录结构如下,每个文件职责单一,按数据流顺序组织:

bp_house_price/ ├── data/ │ └── house_data.csv # 原始房价数据:特征列 + price目标列 ├── src/ │ ├── data_preprocess.py # 数据加载、缺失值处理、归一化、切分 │ ├── bp_network.py # BP神经网络核心类:前向/反向/训练/预测 │ ├── train.py # 训练入口脚本,加载数据并启动训练 │ └── evaluate.py # 评估脚本:R2、MAE指标计算 ├── output/ │ ├── model_params.npz # 训练好的网络权重 │ └── loss_curve.png # 训练和验证loss曲线 └── requirements.txt

依赖写在requirements.txt里,只有四个库:

numpy pandas scikit-learn matplotlib

numpy承担矩阵运算,pandas负责CSV读取和缺失值填充,scikit-learn提供train_test_split、MinMaxScaler和r2_score评估指标,matplotlib画loss曲线。没有引入任何深度学习框架,装完这四个库就能跑。

2.3 快速跑通:从安装依赖到看到训练曲线

拿到项目后的第一步,先装依赖:

cd bp_house_price pip install -r requirements.txt

Python版本3.8以上基本都行,不用刻意配置虚拟环境。装完直接启动训练:

python src/train.py

train.py内部自动完成“读数据→预处理→切分→初始化网络→训练→保存模型→画loss曲线”整条链路。一切正常的话,终端会打印每一轮的训练loss,最后输出测试集上的R2和MAE,同时在output目录生成model_params.npz和loss_curve.png。

第一次跑通后,再回头去看bp_network.py里的各段代码,理解起来会顺畅很多。还没有跑通的话,先别往下读,优先解决环境问题,否则后面讲的核心逻辑都成了空中楼阁。

3. 数据预处理与特征工程:训练效果的头号胜负手

3.1 数据加载与缺失值处理

房价数据一般是一个CSV文件,每行一条房源记录,列包含面积、房龄、卧室数量、距市中心距离等特征,最后一列是成交价。第一步先把数据读进来,看看字段类型和缺失情况:

import pandas as pd df = pd.read_csv('data/house_data.csv') print(df.info()) print(df.describe())

df.info()能直观看到每列的数据类型和非空数量。如果某列非空数量明显少于总行数,说明存在缺失值。处理缺失值最常见的做法是按列填充中位数。为什么不填均值?因为房价这类数据往往有少数极高价的房源,均值会被这些离群值拉偏,中位数更稳健。

for col in df.columns: if df[col].isnull().any(): df[col].fillna(df[col].median(), inplace=True)

填充完再跑一次df.isnull().sum(),确认结果清零。这一步看似简单,但填充策略直接决定后续模型的上限。还要留意字符串类型的列,比如“朝向”“装修”这类文字字段,BP网络只能吃数值。要么用pd.get_dummies做one-hot编码,要么在特征相关性检查后直接丢弃。字符串特征如果硬编码成1、2、3这种顺序数字,会引入虚假的大小关系,模型会以为“装修=3比装修=1好三倍”,这是新手最容易踩的坑。

3.2 特征相关性检查:先砍掉没用的列

原始数据集里不是所有特征都对房价有预测价值。先算每个特征与目标价格price的相关系数绝对值:

corr = df.corr()['price'].abs().sort_values(ascending=False) print(corr) # 保留与房价相关性高于0.1的特征 keep_cols = corr[corr > 0.1].index.tolist() keep_cols.remove('price')

过滤掉相关性低于0.1的列,能压缩输入维度,减小网络参数量,让训练更快,也能降低过拟合风险。如果两个输入特征之间相关性极高,比如“建筑面积”和“套内面积”往往高度共线,保留其中一个即可。共线特征会让特征矩阵接近奇异,反向传播时梯度互相抵消,模型学起来非常吃力。

这一步筛选出来的keep_cols会在后续切分、归一化、训练中反复用到。每次跑完这步,打印一下keep_cols,看看哪些特征被留下了,是一个值得养成的好习惯。

3.3 归一化与数据集划分:顺序错了全盘皆输

BP神经网络对输入特征的量纲非常敏感。以tanh激活函数为例,输入绝对值大于2就进入饱和区,梯度趋近于0,网络基本学不动。MinMax归一化把特征压到[0,1]区间,能显著改善收敛速度:

from sklearn.model_selection import train_test_split from sklearn.preprocessing import MinMaxScaler X = df[keep_cols].values y = df['price'].values X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42) scaler = MinMaxScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test)

这段代码里最容易翻车的点在于:先切分,后拟合。scaler只能fit在训练集上,测试集用同一个scaler做transform。如果有人偷懒先对整个数据集fit再切分,测试集的分布信息会被提前混进训练流程,评估结果虚高,上线就现原形。

注意:先切分,再归一化。这一步顺序错了,后面所有结果都不可信。

y(房价)要不要归一化?我的习惯是不归一化,因为输出层用的是线性激活,MSE损失直接在原始量纲上计算,评估指标更直观。如果y的分布严重右偏,少数豪宅把均值拉得极高,可以考虑对y取log变换后再训练,预测时再exp还原。这个项目的默认配置不启用log变换,保持原始量纲。

从数据处理的角度看,整条链路就是“读数据、补缺失、砍特征、切分、归一化”五步。数据预处理做到位,后面BP网络本身的调参压力会小很多,这也是我在项目里反复强调这部分的直接原因。

4. 核心模块拆解:BP神经网络的前向传播与反向传播

4.1 网络结构与权重初始化

网络设计为三层结构:输入层大小等于特征数,两个隐藏层分别32和16个节点,输出层1个节点。隐藏层用tanh激活,输出层不加激活(线性)。为什么隐藏层不用sigmoid?sigmoid输出范围是(0,1),均值不为0,下一层收到的输入整体偏正,梯度更新时容易呈锯齿状震荡。tanh输出均值为0,收敛更稳。

import numpy as np class BPRegressor: def __init__(self, layer_dims, lr=0.01, epochs=500, seed=42): self.layer_dims = layer_dims self.lr = lr self.epochs = epochs self.params = {} self.loss_history = [] self.val_loss_history = [] np.random.seed(seed) self._init_params() def _init_params(self): for i in range(1, len(self.layer_dims)): scale = np.sqrt(2.0 / self.layer_dims[i - 1]) self.params[f'W{i}'] = np.random.randn( self.layer_dims[i - 1], self.layer_dims[i]) * scale self.params[f'b{i}'] = np.zeros((1, self.layer_dims[i]))

权重初始化方式直接影响训练能否起跑。随机初始化范围过大,前向传播时各层的加权和直接落到tanh饱和区,梯度消失;全部初始化为0,所有神经元退化成同一个节点,网络失去表达能力。这里用的是Xavier的简化形式,scale = sqrt(2/n_in),让权重方差随输入维度自动调整,保证每一层输出的数值范围大致稳定。偏置全部初始化为0即可,梯度下降会自己调整到合适位置。

layer_dims参数是核心配置项,构造时传入一个列表,例如[13, 32, 16, 1]表示输入13个特征、隐藏层32和16个节点、输出1个值。想改网络深度,直接改这个列表长度,不用动任何逻辑代码。

4.2 前向传播与损失计算

前向传播就是把输入一层一层往上推,每一层先做线性变换Z = A_prev @ W + b,再过激活函数得到A:

def forward(self, X): caches = {'A0': X} L = len(self.layer_dims) - 1 for i in range(1, L + 1): Z = caches[f'A{i-1}'] @ self.params[f'W{i}'] + self.params[f'b{i}'] if i == L: A = Z # 输出层线性激活 else: A = np.tanh(Z) # 隐藏层tanh caches[f'Z{i}'] = Z caches[f'A{i}'] = A return caches

caches这个字典把每一层的Z和A都存下来,目的是给反向传播复用,避免重复计算。Z是线性变换结果,A是激活输出。注意@操作符是numpy的矩阵乘法,等价于np.dot。输入X的形状是(m, n_features),W1形状是(n_features, 32),A1变成(m, 32),到最后输出层A_L形状是(m, 1),正好和房价标签的形状对上。

模型用均方误差MSE作为损失函数:

def compute_loss(self, X, y): y_pred = self.predict(X).reshape(-1, 1) return np.mean((y_pred - y.reshape(-1, 1)) ** 2)

MSE对每个样本的误差做平方再平均。平方操作让大误差的惩罚成倍放大,促使模型优先修正偏差最大的样本,回归任务里MSE是最常用的损失,梯度推导也最简洁。loss曲线上如果看到数值持续收敛,说明前向传播的链路没有问题。

4.3 反向传播:链式法则落到矩阵运算

反向传播是BP神经网络的核心。关键在于抓住一个中间量:每一层的dZ,也就是损失函数对该层线性输出Z的偏导。有了dZ,权重梯度和偏置梯度就能直接算出来。

def backward(self, X, y, caches): grads = {} m = X.shape[0] L = len(self.layer_dims) - 1 y = y.reshape(-1, 1) dZ = caches[f'A{L}'] - y # 输出层:线性激活,dZ = A - y for i in range(L, 0, -1): A_prev = caches[f'A{i-1}'] grads[f'dW{i}'] = A_prev.T @ dZ / m grads[f'db{i}'] = np.sum(dZ, axis=0, keepdims=True) / m if i > 1: dZ = dZ @ self.params[f'W{i}'].T * (1 - np.tanh(caches[f'Z{i-1}']) ** 2) return grads

输出层因为用的是线性激活,dZ等于预测值减真实值,这和线性回归的梯度形式完全一致。从最后一层往前推时,每层的dW都是A_prev的转置乘dZ再除以样本数m,除以m是为了取平均梯度,避免梯度随batch大小变化而抖动。

梯度往上一层传播时,先乘当前层权重的转置,再乘激活函数的导数。tanh的导数是1减tanh的平方,在Z接近0时接近1,在Z远离0时趋近0。这也解释了为什么归一化格外重要:如果Z的绝对值普遍偏大,tanh导数趋近0,链式法则里每一项梯度都被压得极小,多层连乘后梯度直接消失。

4.4 训练循环与模型保存

训练过程就是不断重复“打乱数据→前向传播→反向传播→更新参数”:

def train(self, X, y, X_val=None, y_val=None): for epoch in range(self.epochs): idx = np.random.permutation(X.shape[0]) X_batch, y_batch = X[idx], y[idx] caches = self.forward(X_batch) grads = self.backward(X_batch, y_batch, caches) for i in range(1, len(self.layer_dims)): self.params[f'W{i}'] -= self.lr * grads[f'dW{i}'] self.params[f'b{i}'] -= self.lr * grads[f'db{i}'] train_loss = self.compute_loss(X, y) self.loss_history.append(train_loss) if X_val is not None: val_loss = self.compute_loss(X_val, y_val) self.val_loss_history.append(val_loss) def save_model(self, path): np.savez(path, **self.params)

每个epoch先用permutation打乱样本顺序。如果不打乱,模型会学到样本顺序带来的虚假规律,尤其是数据集中存在按区域分组排列时,不打乱等于每轮都在学同一个区域的局部特征。当前实现用全量数据计算梯度,属于batch gradient descent,数据量几万条以内完全够用;数据量再大,可以把这里改成mini-batch。

参数更新直接拿学习率乘梯度做减法。lr默认0.01,如果发现loss下降太慢,可以尝试0.03;如果loss震荡,降到0.003或0.001。学习率是整个项目最值得手动去试的超参数,没有之一。params通过np.savez保存,把多个矩阵打包进一个文件,预测时用np.load读取即可继续使用。

训练入口的调用方式:

python src/train.py

train.py里会先调data_preprocess加载并切分数据,然后构造BPRegressor实例,传入layer_dims=[特征数, 32, 16, 1],最后调用train并保存模型。到这里,一个纯NumPy的BP回归网络已经可以完整地跑完训练和预测流程。

5. 常见问题与避坑:训练不收敛、过拟合、数据泄漏

5.1 问题一:loss在一个平台期震荡,怎么降都降不下来

现象:训练loss初始就很高,几百轮之后在一个平台期来回震荡,曲线像一条毛刺很多的横线。

原因:最常见的是学习率过大。梯度下降每一步跨度过大,参数在最优解两侧来回摆动,落不进收敛区。其次是特征未归一化,输入量纲差异大导致梯度不均衡。

解决:把学习率从0.01调到0.003或0.001重新跑。如果还在震荡,检查训练输入X是否做了归一化,以及隐藏层的加权和Z是否大面积落在tanh饱和区。

5.2 问题二:训练集R2有0.9,测试集只有0.3

现象:训练阶段表现亮眼,一上测试集就崩,典型的“背题选手”。

原因:过拟合。网络容量过大,隐藏层节点太多或者层数太深,数据量又不大,模型把训练集的噪声也背了下来。房价数据集通常就几千条,两个隐藏层各32个节点已经偏大。

解决:优先把网络结构改成[特征数, 16, 8, 1]试试;其次在损失函数里加L2正则化,第6章有具体改造代码;最后用早停,当验证集loss连续多轮不降时停止训练。项目代码里留了val_loss_history,就是为了配合早停用的。

5.3 问题三:测试集结果好得离谱,一查是归一化泄漏

现象:测试集R2比训练集还高,好到不真实,甚至出现0.95以上的数值。

原因:归一化时对整个数据集fit了MinMaxScaler,测试集的最小值、最大值已经参与了scaler的计算,模型在训练阶段就间接见过测试集信息。这不叫模型强,这叫作弊。

解决:严格先train_test_split,再对训练集fit_transform,对测试集只transform。这句话值得写三遍:先切分,再归一化。先切分,再归一化。测试集在整个训练流程里,除了最后一次评估,一个字节都不该提前碰到。

5.4 问题四:换个随机种子,结果从0.75跳到0.88

现象:代码一行没改,只改random_state,评估指标大幅波动。

原因:数据量不大时,不同随机划分出来的测试集构成差异明显;再加上权重初始化是随机的,两次训练可能收敛到不同的局部最优。

解决:固定随机种子做基准对比;评估真实水平用K-fold交叉验证跑5轮,看平均R2和标准差。手工随机初始化的网络,单次结果本来就有波动,只看一次运行结果就下结论,大概率被随机性带偏。

5.5 问题五:训练中途出现NaN,之后全部是NaN

现象:loss在某一步突然变成nan,后续全部nan,曲线直接断掉。

原因:梯度爆炸。学习率过大、某个特征数值极端,或者数据里混入了inf值,权重更新一步就溢出成nan。运气好是那一步跳飞,运气不好整个loss曲线直接报废。

解决:先检查数据里有没有inf和nan,用np.isinf()和np.isnan()过一遍;再确认归一化已应用到所有特征;最后把学习率调低一个数量级重跑。如果还出NaN,给梯度加clip,np.clip(grad, -1, 1),这是最野蛮但最有效的止血办法。

5.6 通用排查顺序

遇到训练效果差,我一般按这个顺序排查:先看数据,有没有nan和异常值;再看预处理,确认先切分后归一化;再看网络结构,是不是容量过大;最后才调训练参数。这个顺序是血泪换来的,大部分翻车都发生在第一个和第二个环节,网络代码本身反而很少出问题。把数据预处理做扎实,80%的训练问题会在起跑前被消灭。

6. 二次开发:把BP神经网络改造成通用的回归预测工具

这套代码的改造空间很大。最刚需的改造有两个:加L2正则化,以及把网络结构参数化。L2正则化的实现代码量非常小,只需在backward的梯度计算末尾,给每个权重梯度加上lambda乘以权重本身:

def backward(self, X, y, caches): # 原有梯度计算...每个dW已经除以m for i in range(1, len(self.layer_dims)): grads[f'dW{i}'] += self.reg_lambda * self.params[f'W{i}']

构造参数里多一个reg_lambda,默认值1e-4。Loss从MSE变成MSE + lambda * sum(W^2),训练时权重会被持续往零方向拉,过拟合就会有明显缓解。网络结构参数化已经在layer_dims这个配置上实现了:换成[特征数, 64, 32, 1]就是加深加宽,换成[特征数, 16, 1]就是简化结构,不需要改动任何逻辑代码。

换数据集时,只需要保证CSV格式一致:特征列在前,目标列名为price。目标列名不同的,去data_preprocess.py里改一行。我一般拿到新数据,会先跑一遍预处理脚本,打印出keep_cols看看哪些特征被保留了,再决定要不要手动补充特征。验证改动是否有效的标准方法是:固定随机种子,改动前后各跑5次,对比平均R2和标准差。只跑一次就说改好了属于玄学范畴,多跑几次,真实差异才会浮出水面。

从最初翻开这份代码到彻底搞懂,我最深的感受是:BP神经网络并没有想象中那么高不可攀,核心就是链式法则加上矩阵乘法。但越是简单的原理,越容易被数据预处理、学习率这些外围环节绊倒。从那以后,我每次拿到新的回归数据集,都强制走一遍先切分再归一化的流程,把随机种子写死在配置里,跑完先看loss曲线再下结论。希望帮到你。

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

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

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

立即咨询