news 2026/10/21 8:18:24

BP神经网络房价预测实战:NumPy从零实现与调优避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
BP神经网络房价预测实战:NumPy从零实现与调优避坑指南

简介:这份资源面向机器学习入门者与需要完成回归预测实战的开发者,围绕经典波士顿房价数据集,用Python从零实现BP神经网络完成房价预测。压缩包共2个文件,含1个py脚本与1个csv数据文件,整体约8KB,其中csv提供14维特征与中位房价目标变量,py脚本承载网络搭建与训练全流程。代码覆盖数据读取与预处理、输入层到输出层的结构定义、前向传播计算、以方差为损失函数的反向传播权重更新,以及迭代训练与测试集评估等环节,并给出均方误差、决定系数等指标,帮助读者理解神经网络回归的完整链路。已有21746人学习下载,适合希望脱离框架、亲手推导梯度并调试训练过程的读者,可作为课程作业或项目练手的参考实现。

1. 从一份房价预测代码说起:BP神经网络做回归到底靠不靠谱

前阵子帮一个做房产数据的朋友看模型,他手里有一批二手房成交记录,想用机器学习估个价。试了线性回归,误差大得离谱;换了梯度提升树,效果还行但调参调到怀疑人生。后来翻出一份用 BP 神经网络做房价预测的 Python 代码,跑了一遍发现——在特征维度不高、样本量几千条的场景下,一个三层 BP 网络居然能打平调了半天参的树模型。这让我重新审视了 BP 网络在回归任务里的定位:它不是什么过时的老古董,而是小规模结构化数据上性价比极高的方案。

这份代码要解决的问题很明确:输入房屋面积、房间数、楼层、地段评分等特征,输出预测房价。适合两类人——刚接触神经网络、想找一个完整可跑通的回归项目练手的新手;以及手头有表格数据、想快速搭一个基线模型做对比的开发者。代码不依赖深度学习框架,用 NumPy 从零实现前向传播和反向传播,这意味着你能看清每一层权重是怎么更新的,而不是调一个model.fit()就完事。下面我从项目结构开始拆,一路讲到怎么改网络结构、换损失函数、加正则化。

2. 项目结构与数据流:从 CSV 到预测值的完整链路

2.1 目录组织与模块职责

拿到代码包后,先别急着跑。花两分钟看清楚文件是怎么分的,后面改起来才不迷路。典型的目录结构长这样:

house_price_bp/ ├── data/ │ ├── raw/ │ │ └── house_prices.csv # 原始数据,含特征列和价格列 │ └── processed/ │ ├── train.npz # 归一化后的训练集 │ └── test.npz # 归一化后的测试集 ├── src/ │ ├── config.py # 超参数、路径、特征列名配置 │ ├── data_loader.py # 读取CSV、缺失值处理、归一化、划分数据集 │ ├── network.py # BP网络类:初始化、前向、反向、更新 │ ├── train.py # 训练循环、损失记录、模型保存 │ ├── evaluate.py # 加载模型、计算MAE/RMSE/R²、画预测对比图 │ └── utils.py # 归一化工具、指标计算、随机种子设置 ├── models/ │ └── bp_model.npz # 训练好的权重和偏置 ├── notebooks/ │ └── exploration.ipynb # 数据探索,看分布、相关性 ├── requirements.txt └── README.md

这个分法把「数据」「模型」「训练」「评估」四件事拆开了。config.py单独拎出来很关键——学习率、隐藏层神经元数、训练轮数、批大小全放这里,改参数不用翻遍所有文件。network.py是核心,只负责网络本身的计算逻辑,不掺和数据加载和训练循环。这种职责分离在后续做实验对比时特别省事:想换激活函数?只动network.py;想换数据划分比例?只动data_loader.py。

2.2 数据预处理:归一化为什么不能省

房价数据里,面积可能是 80~200,房间数 1~5,地段评分 1~10。如果直接喂给网络,面积这个特征因为数值大,梯度更新时会主导权重调整,房间数几乎被淹没。所以输入特征和标签都要做归一化。代码里用的是 Min-Max 归一化:

# src/utils.py import numpy as np class MinMaxScaler: def __init__(self): self.min_ = None self.max_ = None def fit(self, X): """计算每列的最小值和最大值,用于后续变换""" self.min_ = X.min(axis=0) self.max_ = X.max(axis=0) def transform(self, X): """将数据缩放到 [0, 1] 区间,分母加 1e-8 防止除零""" return (X - self.min_) / (self.max_ - self.min_ + 1e-8) def fit_transform(self, X): self.fit(X) return self.transform(X) def inverse_transform(self, X_scaled): """把归一化后的预测值还原回原始房价量纲""" return X_scaled * (self.max_ - self.min_ + 1e-8) + self.min_

这里有个容易翻车的点:归一化参数必须用训练集算,然后应用到测试集。如果先把全量数据一起归一化再划分,测试集的极值会「泄露」到训练过程里,评估结果虚高。代码里data_loader.py的做法是先在训练集上fit,拿到min_和max_,再用同一组参数去transform测试集。预测完之后,用inverse_transform把输出还原成真实房价,否则你算出来的 MAE 是归一化尺度下的,没有实际意义。

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

房价预测是回归任务,输出层就一个神经元,不加激活函数(或者用线性激活)。隐藏层数量和每层神经元数属于超参数,代码默认给的是「输入特征数 → 10 → 1」的两层结构。为什么是 10?这是个小数据集上的经验值,隐藏层神经元太少欠拟合,太多过拟合且训练慢。config.py里可以改:

# src/config.py INPUT_DIM = 8 # 特征列数量,根据实际CSV调整 HIDDEN_DIM = 10 # 隐藏层神经元数 OUTPUT_DIM = 1 # 回归任务输出一个值 LEARNING_RATE = 0.01 EPOCHS = 2000 BATCH_SIZE = 32

network.py里初始化权重用的是 He 初始化(针对 ReLU 激活)或 Xavier 初始化(针对 tanh)。代码默认用 ReLU,因为它在隐藏层能缓解梯度消失,训练更快:

# src/network.py import numpy as np class BPNetwork: def __init__(self, input_dim, hidden_dim, output_dim): # He初始化:标准差 sqrt(2 / 输入维度),适合ReLU self.W1 = np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim) self.b1 = np.zeros((1, hidden_dim)) self.W2 = np.random.randn(hidden_dim, output_dim) * np.sqrt(2.0 / hidden_dim) self.b2 = np.zeros((1, output_dim)) def forward(self, X): """前向传播:线性变换 + ReLU,输出层不加激活""" self.z1 = X @ self.W1 + self.b1 self.a1 = np.maximum(0, self.z1) # ReLU self.z2 = self.a1 @ self.W2 + self.b2 return self.z2 def backward(self, X, y_true, y_pred): """反向传播:MSE损失对各参数的梯度""" m = X.shape[0] # 输出层梯度:dL/dz2 = (y_pred - y_true) * 2 / m dz2 = (y_pred - y_true) * 2 / m self.dW2 = self.a1.T @ dz2 self.db2 = np.sum(dz2, axis=0, keepdims=True) # 隐藏层梯度:ReLU导数在z1>0处为1,否则为0 da1 = dz2 @ self.W2.T dz1 = da1 * (self.z1 > 0) self.dW1 = X.T @ dz1 self.db1 = np.sum(dz1, axis=0, keepdims=True) def update(self, lr): """梯度下降更新参数""" self.W1 -= lr * self.dW1 self.b1 -= lr * self.db1 self.W2 -= lr * self.dW2 self.b2 -= lr * self.db2

逐段看:forward里z1是隐藏层线性输出,a1是 ReLU 激活后的结果,z2直接作为预测值。backward里dz2是损失函数对输出层输入的梯度,MSE 的导数是2*(y_pred - y_true)/m,除以m是做批平均。da1是把输出层梯度回传到隐藏层,再乘 ReLU 的导数(z1 > 0时为 1)。update就是最朴素的 SGD,没有动量、没有自适应学习率。这套实现虽然简单,但每一步都能对应到公式,适合拿来理解 BP 的本质。

2.4 训练循环:批大小、轮数、损失记录

train.py把数据按BATCH_SIZE切分,每个 epoch 跑完所有批次,记录平均损失。关键代码:

# src/train.py from src.network import BPNetwork from src.data_loader import load_and_preprocess from src.config import INPUT_DIM, HIDDEN_DIM, OUTPUT_DIM, LEARNING_RATE, EPOCHS, BATCH_SIZE import numpy as np X_train, y_train, X_test, y_test, scaler = load_and_preprocess() model = BPNetwork(INPUT_DIM, HIDDEN_DIM, OUTPUT_DIM) loss_history = [] for epoch in range(EPOCHS): # 每个epoch打乱顺序,避免批次间相关性过强 indices = np.random.permutation(len(X_train)) X_shuffled, y_shuffled = X_train[indices], y_train[indices] epoch_loss = 0 for i in range(0, len(X_train), BATCH_SIZE): X_batch = X_shuffled[i:i+BATCH_SIZE] y_batch = y_shuffled[i:i+BATCH_SIZE] y_pred = model.forward(X_batch) loss = np.mean((y_pred - y_batch) ** 2) epoch_loss += loss * len(X_batch) model.backward(X_batch, y_batch, y_pred) model.update(LEARNING_RATE) avg_loss = epoch_loss / len(X_train) loss_history.append(avg_loss) if epoch % 200 == 0: print(f"Epoch {epoch}, Loss: {avg_loss:.6f}") # 保存权重 np.savez("models/bp_model.npz", W1=model.W1, b1=model.b1, W2=model.W2, b2=model.b2)

每个 epoch 先np.random.permutation打乱索引,这是为了防止同一批数据总是以相同顺序出现,导致梯度更新有偏。epoch_loss按样本数加权累加,最后除以总样本数得到平均损失。每 200 轮打印一次,方便观察收敛趋势。训练完把四个参数矩阵存成.npz,评估时直接加载,不用重新训练。

2.5 评估与可视化:MAE、RMSE、R² 怎么看

evaluate.py加载模型后,在测试集上预测,再反归一化,计算三个指标:

# src/evaluate.py import numpy as np from src.network import BPNetwork from src.data_loader import load_and_preprocess from src.config import INPUT_DIM, HIDDEN_DIM, OUTPUT_DIM X_train, y_train, X_test, y_test, scaler = load_and_preprocess() params = np.load("models/bp_model.npz") model = BPNetwork(INPUT_DIM, HIDDEN_DIM, OUTPUT_DIM) model.W1, model.b1 = params["W1"], params["b1"] model.W2, model.b2 = params["W2"], params["b2"] y_pred_scaled = model.forward(X_test) y_pred = scaler.inverse_transform(y_pred_scaled) y_true = scaler.inverse_transform(y_test) mae = np.mean(np.abs(y_pred - y_true)) rmse = np.sqrt(np.mean((y_pred - y_true) ** 2)) 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 print(f"MAE: {mae:.2f} 万元") print(f"RMSE: {rmse:.2f} 万元") print(f"R²: {r2:.4f}")

MAE 告诉你平均预测偏差多少万,RMSE 对大误差更敏感,R² 看模型解释了房价多少比例的方差。房价数据里 R² 能到 0.8 以上就算不错,因为影响房价的因素太多,户型、装修、学区这些没进模型的特征会拉低上限。如果 R² 低于 0.6,先别急着调网络,回去检查特征工程和异常值。

3. 避坑与排查:训练不收敛、预测全是一个值怎么办

3.1 损失震荡不下降

现象:训练日志里 loss 一会儿大一会儿小,整体趋势平缓甚至上升。原因:学习率太大,梯度更新步子迈过头,在最优解附近来回跳。解决:把LEARNING_RATE从 0.01 降到 0.001 或 0.0005,观察 loss 是否平滑下降。如果降得太慢,可以试 0.005。另一个可能是批大小太小(比如 4 或 8),梯度噪声大,调到 32 或 64 试试。

3.2 预测值全部接近均值

现象:测试集上不管输入什么特征,输出都在房价均值附近浮动,R² 接近 0。原因:网络没学到东西,权重初始化后几乎没更新,或者隐藏层神经元太少导致欠拟合。解决:先检查归一化是不是漏了——如果输入特征没归一化,梯度会被大数值特征主导,小数值特征对应的权重几乎不动。再检查隐藏层维度,8 个特征配 10 个隐藏神经元偏少,可以加到 32 或 64。最后看训练轮数,2000 轮不够就加到 5000。

3.3 训练集表现好、测试集崩了

现象:训练集 MAE 很低,测试集 MAE 是训练集的三四倍。原因:过拟合。网络把训练样本的噪声也记住了。解决:三个方向——减少隐藏层神经元数(比如从 64 降到 16)、加 L2 正则化(在损失里加lambda * (||W1||² + ||W2||²))、或者加 Dropout(训练时随机丢弃一部分隐藏层输出)。代码里没内置这些,但改起来不难,下一章讲怎么加。

3.4 梯度爆炸导致 NaN

现象:训练几轮后 loss 变成nan,参数矩阵里出现inf。原因:学习率过大加上权重初始化不当,梯度在反向传播时指数级放大。解决:先降学习率到 0.0001,如果还不行就加梯度裁剪——在update之前把梯度范数限制在一个阈值内:

# 在model.update之前加 for grad in [model.dW1, model.db1, model.dW2, model.db2]: np.clip(grad, -1.0, 1.0, out=grad)

3.5 数据泄露导致评估虚高

现象:R² 高得离谱(0.98 以上),但实际用新数据预测时偏差很大。原因:归一化时用了全量数据的 min/max,或者特征里混入了跟标签强相关的「未来信息」。解决:确认MinMaxScaler只在训练集上fit,测试集用transform。再检查特征列里有没有不该出现的字段,比如「成交总价」这种直接泄露标签的列。

4. 二次开发:换损失函数、加正则化、调网络深度

4.1 把 MSE 换成 Huber Loss

房价数据里常有极端值——豪宅成交价可能是均价的十倍。MSE 对极端值敏感,一个异常样本就能把梯度拉偏。Huber Loss 在误差小的时候用平方,误差大的时候用线性,对异常值更鲁棒。改network.py的backward:

def backward_huber(self, X, y_true, y_pred, delta=1.0): """Huber损失反向传播,delta控制平方/线性的切换阈值""" m = X.shape[0] error = y_pred - y_true # 误差绝对值小于delta时导数为error,否则为delta*sign(error) dz2 = np.where(np.abs(error) <= delta, error, delta * np.sign(error)) * 2 / m self.dW2 = self.a1.T @ dz2 self.db2 = np.sum(dz2, axis=0, keepdims=True) da1 = dz2 @ self.W2.T dz1 = da1 * (self.z1 > 0) self.dW1 = X.T @ dz1 self.db1 = np.sum(dz1, axis=0, keepdims=True)

delta默认 1.0,对应归一化后的误差尺度。如果归一化到 [0,1],delta 取 0.1 到 0.2 更合适。改完之后训练损失用 Huber 公式算,评估指标还是 MAE/RMSE,方便跟 MSE 版本对比。

4.2 加 L2 正则化抑制过拟合

在损失函数里加权重平方和,反向传播时梯度也要加上对应的正则项导数。改backward:

def backward_with_l2(self, X, y_true, y_pred, l2_lambda=0.001): m = X.shape[0] dz2 = (y_pred - y_true) * 2 / m self.dW2 = self.a1.T @ dz2 + l2_lambda * self.W2 / m self.db2 = np.sum(dz2, axis=0, keepdims=True) da1 = dz2 @ self.W2.T dz1 = da1 * (self.z1 > 0) self.dW1 = X.T @ dz1 + l2_lambda * self.W1 / m self.db1 = np.sum(dz1, axis=0, keepdims=True)

l2_lambda从 0.0001 开始试,太大模型会欠拟合。判断标准:训练集和测试集的 MAE 差距缩小了,但测试集 MAE 没有明显上升。

4.3 加深网络:从两层到四层

两层网络表达能力有限,特征交互复杂时可以加到四层。改network.py的初始化和前向传播:

class DeepBPNetwork: def __init__(self, dims): """dims = [input_dim, h1, h2, h3, output_dim]""" self.params = {} for i in range(len(dims) - 1): self.params[f"W{i+1}"] = np.random.randn(dims[i], dims[i+1]) * np.sqrt(2.0 / dims[i]) self.params[f"b{i+1}"] = np.zeros((1, dims[i+1])) self.dims = dims def forward(self, X): self.activations = [X] self.zs = [] a = X for i in range(len(self.dims) - 2): z = a @ self.params[f"W{i+1}"] + self.params[f"b{i+1}"] self.zs.append(z) a = np.maximum(0, z) self.activations.append(a) # 输出层不加激活 z_out = a @ self.params[f"W{len(self.dims)-1}"] + self.params[f"b{len(self.dims)-1}"] self.zs.append(z_out) return z_out

层数加深后,学习率要相应调小(比如 0.001),否则容易梯度爆炸。另外每层神经元数建议逐层递减,比如[8, 64, 32, 16, 1],这样网络先提取高维特征再逐步压缩。

4.4 用早停法省训练时间

训练轮数设 5000 轮,但模型可能 1500 轮就收敛了,后面全是白跑。加一个早停逻辑:每 50 轮检查验证集损失,如果连续 5 次没下降就停。

best_loss = float("inf") patience = 5 wait = 0 for epoch in range(EPOCHS): # ... 训练代码 ... val_loss = compute_val_loss(model, X_val, y_val) if val_loss < best_loss: best_loss = val_loss wait = 0 np.savez("models/best_model.npz", **model.params) else: wait += 1 if wait >= patience: print(f"Early stop at epoch {epoch}") break

验证集从训练集里再切 10% 出来,不参与梯度更新。早停保存的是验证集上最好的模型,不是最后一轮的模型,这点很关键——最后一轮可能已经过拟合了。

4.5 一个我踩过的坑

有次改完网络结构,训练 loss 降得特别漂亮,测试集 R² 也有 0.92,我差点直接交差。后来不放心,把预测值按房价从低到高排序画了散点图,发现低价房预测偏高、高价房预测偏低——典型的回归均值现象。原因是隐藏层用了 ReLU,输出层没加激活,但训练数据里高价样本太少,网络倾向于输出中间值来降低平均损失。解决办法是在损失函数里给高价样本加权,或者对房价取对数再预测。从那以后我每次跑完回归模型,都强制画一遍「预测值 vs 真实值」的散点图,光看指标数字真的会骗人。希望这份拆解能帮你少走点弯路,代码跑起来之后,先从改学习率和隐藏层维度开始试,感受一下超参数对结果的影响,再往深了改结构。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 8:18:15

Stable Diffusion WebUI远程访问实战:异地组网方案与踩坑记录

最近有几个朋友问我同一个问题&#xff1a;自己电脑上装好了 Stable Diffusion WebUI&#xff0c;生成效果也不错&#xff0c;但人一离开工位就啥都干不了&#xff0c;手机上也看不到进度、没法远程跑图。尤其是上班摸鱼或者出差的时候&#xff0c;想临时改个参数、追加几张图&…

作者头像 李华
网站建设 2026/10/21 8:17:49

俄罗斯方块C语言程序设计报告:从控制台到图形界面的完整实现路径

简介&#xff1a;这份C语言课程设计报告以经典俄罗斯方块游戏为案例&#xff0c;面向高校计算机相关专业学生及C语言初学者&#xff0c;帮助读者完成从需求分析到代码实现的完整课程设计任务。报告围绕随机方块生成、键盘控制、消行计分、等级加速等核心功能展开&#xff0c;并…

作者头像 李华
网站建设 2026/10/21 8:16:44

H3C综合网络实验:SR-MPLS TE Policy设计及故障切换实战

简介&#xff1a;一份面向毕业设计场景的H3C综合网络实验资料&#xff0c;通过多种路由交换协议组网模拟中大型企业网络的转发流程&#xff0c;适合网络工程、计算机等相关专业学生完成综合性实验或毕设方案。内容围绕核心层、汇聚层、接入层展开&#xff0c;覆盖M-LAG、VRRP高…

作者头像 李华
网站建设 2026/10/21 8:16:25

汽车电子整流检波电路设计:从原理到EMC整改实践

简介&#xff1a;面向汽车电子电器与电磁兼容&#xff08;EMC&#xff09;领域的整流检波电路专业参考文档《几种典型的整流检波电路.pdf》&#xff0c;系统梳理半波整流、全波整流、桥式整流三种典型整流电路&#xff0c;以及包络检波、同步检波等常用检波电路的原理、特点与适…

作者头像 李华
网站建设 2026/10/21 8:15:34

老Flash联机Demo拆解:TCP长连接与select模型实战

简介&#xff1a;这一压缩包汇集了Flash游戏客户端与配套服务器源码&#xff0c;并以PPT讲解双方交互的数据包格式&#xff0c;适合正在学习网络编程、TCP通信及select I/O多路复用模型的开发者参考&#xff0c;尤其对想要理解传统浏览器端游戏如何与后端服务交互的读者十分实用…

作者头像 李华
网站建设 2026/10/21 8:15:06

STM32 的 I2C 卡死在 BUSY:从机按住 SDA 的成因与恢复时序

去年给实验室的环境监测节点加了一块 0.96 寸 OLED&#xff0c;和温湿度传感器共用 I2C1。台面上调试一切正常&#xff0c;放到窗边连续跑了几天后&#xff0c;早上来发现串口没输出了&#xff0c;程序停在 OLED 刷新的 HAL_I2C_Master_Transmit 里面。看门狗复位之后又能跑&am…

作者头像 李华