BP神经网络回归预测实战:房源房价估价与工程避坑
简介:面向机器学习入门者与回归预测实践者的波士顿房价预测案例包,围绕BP神经网络的前向传播、反向传播与方差损失展开,帮助读者理解多层前馈网络如何从房屋特征中拟合中位房价。包内共2个文件,以1个Python脚本和1个CSV数据集为主,压缩后仅8KB,脚本承载网络结构定义、权重更新、训练迭代与预测评估流程,CSV则提供含多列特征的训练样本,便于直接复现实验。已有21762人学习下载。读者可据此梳理输入层、隐藏层、输出层的搭建思路,掌握损失计算、梯度回传、均方误差与决定系数等评估环节,并借助小体量数据快速跑通回归预测全流程,适合课程实验、自学练手与神经网络基础巩固。
1. 一份房源表丢进BP神经网络:回归预测能替你省掉多少试错
BP神经网络做回归预测最典型的落地场景就是房价估价:手上一张从门店导出的房源表,字段无非面积、室数、楼层、房龄、区域编码,想知道某个特征组合大概能卖多少钱。规则硬编码撑不了几天,线性回归又压不住「面积 × 区域」这种交互项,而一个两三层的小网络配 Python 就能在半天内跑出第一版 baseline,用来判断这套特征到底有没有预测力。它适合手上有几千到几万条结构化表格、想快速验证特征有效性的开发者,不适合指望它直接接管报价系统的团队。下面按我拆过的实际结构,从目录、预处理、网络、训练一路讲到怎么改、改完怎么验。
2. 项目骨架与技术选型:数据、网络、训练脚本怎么分层
一个能反复跑的回归项目,最怕把所有逻辑塞进一个main.py。训练脚本里混着读 CSV、算标准化、搭网络、画图,第二次想换特征就得从头翻。我拆的这个项目按「数据入口 → 预处理 → 模型 → 训练 → 评估 → 预测」切成六个文件,依赖全走pip,不引入深度学习框架,numpy 手写反向传播,目的是让每一层的梯度都能打印出来看。
选型上比较克制:pandas 负责表格清洗,scikit-learn 只借它的划分、标准化和指标,网络本体自己写。这么做不是为了炫技,而是房价这种几百到几千样本的小任务,用大框架反而容易掩盖数据问题——loss 不降的时候,你分不清是网络写错了还是特征没处理干净。手写版本里每个矩阵形状都明确,出错位置一眼能定位。
2.1 目录树与每个文件的职责
house_price_bp/ ├── data/ │ ├── raw/house_sample.csv # 原始房源表,只读,不在这里改任何东西 │ └── processed/ # 预处理产物,可随时删掉重建 ├── src/ │ ├── config.py # 超参、路径、随机种子集中管理 │ ├── data_loader.py # 读 CSV、切分训练/验证/测试 │ ├── preprocess.py # 缺失值、异常值、编码、标准化 │ ├── model.py # BPNN 类:初始化、前向、反向 │ ├── train.py # 训练循环、早停、保存权重 │ ├── evaluate.py # RMSE / MAE / R² / MAPE 与残差图 │ └── predict.py # 加载权重做单条或批量预测 ├── outputs/ │ ├── model.npz # 网络权重 │ ├── scaler.pkl # 标准化参数,必须跟权重同生命周期 │ └── metrics.json # 每次训练的评估结果 ├── requirements.txt └── README.md关键点在于raw只读。清理规则改一次就要能重跑一次,原始文件被就地改写,后面所有对比都失去参照。processed目录是缓存,删掉重建的成本不过几秒,别把它当数据源。scaler.pkl单独存这件事后面第 6 章还会专门讲,它是二次开发里最容易翻车的一环。
2.2 依赖清单与运行环境
numpy==1.26.4 pandas==2.2.2 scikit-learn==1.4.2 matplotlib==3.8.4 joblib==1.4.2版本钉死是血泪经验。numpy 2.x 之后部分np.float_之类的别名被移除,老代码直接报AttributeError;pandas 2.x 对get_dummies的 dtype 默认值也调过,类别编码结果从 bool 变成 uint8,如果你的代码里有X.sum(axis=1) == 1这种断言,升级当天就得改。Python 用 3.10 及以上即可,虚拟环境建好再装,别往系统解释器里灌。
python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt python -m src.train --config src/config.pytrain.py用python -m起而不是python src/train.py,原因是脚本内部会from src import model,直接跑脚本时sys.path只包含src/目录,包导入会失败。这个坑每个新手至少踩一次,报错信息是ModuleNotFoundError: No module named 'src',看着像环境没装好,其实是启动方式问题。
2.3 配置集中管理:所有超参只在一个文件里改
# src/config.py from pathlib import Path ROOT = Path(__file__).resolve().parent.parent DATA_PATH = ROOT / "data/raw/house_sample.csv" OUTPUT_DIR = ROOT / "outputs" # 网络结构:输入维度由特征决定,这里的 hidden 是隐藏层宽度 HIDDEN = (32, 16) LR = 1e-3 # Adam 的初始学习率,1e-3 是回归任务的稳妥起点 BATCH_SIZE = 64 EPOCHS = 500 PATIENCE = 30 # 验证集连续 30 轮没改善就停 L2 = 1e-4 # 权重衰减,小样本时防止权重爆掉 VAL_RATIO = 0.15 TEST_RATIO = 0.15 SEED = 42参数说明:HIDDEN写成元组而不是单个整数,是为了能一行改网络深度;PATIENCE配EPOCHS=500属于「上限给足、实际靠早停」,硬编码 100 轮经常在验证损失还在往下走的时候被切断;L2作用在权重上不作用在偏置上,这点在backward里要写清楚,否则偏置被无谓压缩会让输出层收敛变慢。
SEED只固定一次不够,numpy 的default_rng(SEED)每次调用都是新流,训练时打乱顺序用的生成器要单独再建一个,或者直接从 config 里派生SEED + 1。不固定种子最直接的症状是:同一份数据跑三遍,R² 分别是 0.71、0.68、0.74,你以为模型不稳,其实只是初始化不同。
3. 数据预处理模块:标准化、缺失值与特征编码的落地写法
回归任务里,预处理对最终指标的影响往往比网络结构大。我做过对比,同样一个 32-16-1 的网络,特征处理好坏能把测试集 R² 从 0.55 拉到 0.78,而把隐藏层从 32 加到 128 只带来 0.01 的提升。这一章按数据流动的顺序写:先清洗,再切分,最后编码和缩放。
3.1 读数据、缺失值与异常值处理
import pandas as pd import numpy as np def load_raw(path): df = pd.read_csv(path) # 总价受面积影响过大,改成单价作为建模目标,方差更稳定 df["unit_price"] = df["total_price"] / df["area"] return df def clean(df): # 面积是硬指标,缺失或明显录错的直接丢,不做插补 df = df[df["area"].between(20, 500)].copy() # 房龄缺失按中位数补,并留一个缺失标记,让网络自己学 df["age_missing"] = df["age"].isna().astype(int) df["age"] = df["age"].fillna(df["age"].median()) # 目标做 1% / 99% 分位截断,压掉挂牌价录错一位的极端样本 lo, hi = df["unit_price"].quantile([0.01, 0.99]) df = df[df["unit_price"].between(lo, hi)] return df.reset_index(drop=True)逻辑说明:area用区间过滤而不是插补,因为面积缺失的样本往往整行信息都不全,补一个中位数等于凭空造样本。age走中位数插补但额外加age_missing标记列,是为了让网络能区分「房龄真的是 15 年」和「房龄不知道、被填成 15 年」,这个标记列在后面的特征重要性分析里经常排前几名。
参数说明:20和500这两个边界来自业务直觉,小户型 loft 和别墅都可能有,但低于 20 平的基本是车位或仓储,高于 500 平的多为整栋挂牌,都会把单价分布拉歪。分位数截断用 1% 和 99% 而不是 5% 和 95%,是因为砍太狠会丢掉真实的高价房源信号,模型对高端盘反而完全无感。
3.2 标准化必须放在划分之后
这是整份代码里最容易写反的一步。
from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X = build_features(df) y = np.log1p(df["unit_price"].values) # 价格右偏,取对数后再回归 # 第一步切分,切完再 fit 任何预处理器 X_tr, X_tmp, y_tr, y_tmp = train_test_split( X, y, test_size=0.3, random_state=42) X_val, X_te, y_val, y_te = train_test_split( X_tmp, y_tmp, test_size=0.5, random_state=42) scaler = StandardScaler().fit(X_tr) # 只用训练集统计均值方差 X_tr_s, X_val_s, X_te_s = (scaler.transform(X_tr), scaler.transform(X_val), scaler.transform(X_te))逻辑说明:fit只能看到训练集,验证集和测试集一律走transform。如果先对全量数据fit_transform再切分,训练集里就混进了测试集的均值和方差,这叫数据泄露。它的隐蔽之处在于指标不会变差,反而更好看——测试集 R² 虚高 0.03 到 0.05,等你上线拿真实新样本一跑,指标掉一个台阶,查半天查不出原因。
参数说明:test_size=0.3再对半切成验证和测试,最终是 70/15/15。验证集用于早停和调参,测试集只在最后评估一次,中途反复看测试集指标等于在测试集上调参,结论同样不可信。np.log1p处理单价右偏,预测时对应要np.expm1还原,这一对函数必须成对出现,漏掉任何一个都会让预测值量纲错位,第 5 章会详细说这个故障。
3.3 类别特征编码与特征构造
NUM_COLS = ["area", "rooms", "floor", "total_floor", "age", "distance", "age_missing"] CAT_COLS = ["district_code", "decoration", "orientation"] def build_features(df): # 构造两个比值特征,比让网络自己乘出来更省事 df = df.copy() df["floor_ratio"] = df["floor"] / df["total_floor"].clip(lower=1) df["area_per_room"] = df["area"] / df["rooms"].clip(lower=1) X = pd.get_dummies( df[NUM_COLS + CAT_COLS + ["floor_ratio", "area_per_room"]], columns=CAT_COLS, drop_first=True, # 去掉基准类,避免共线 dtype=np.float32, ) return X逻辑说明:get_dummies前先构造floor_ratio和area_per_room,是因为这两个比值对价格的影响是非线性的——中间楼层最贵,两头递减,直接喂原始楼层号让网络去拟合这个 V 形曲线,需要更多隐藏单元。手动给出比值相当于把先验知识塞进去,小样本场景下收益明显。
参数说明:drop_first=True去掉每个类别变量的第一个水平作为基准,避免独热编码后的完全共线。共线本身不会让梯度下降跑不动,但会让 L2 正则的惩罚分配变得奇怪,权重被摊到多个等价维度上,调参时你看到的「某个特征权重很小」其实是假象。dtype=np.float32是为了和后面的 numpy 矩阵统一,pandas 2.x 默认输出uint8或bool,不转的话矩阵乘法会悄悄升到 float64,内存翻倍不说,某些 BLAS 后端还会走慢路径。类别基数太大的字段(比如小区 ID 上千个)不要直接独热,常见做法是按均价做目标编码或者干脆聚成十几个片区,否则特征维度爆炸,训练集每个类别只有几条样本,网络记住的是 ID 不是规律。
4. BP网络核心实现:从手写反向传播到训练循环
网络本体不复杂:输入维度跟着特征走,两个隐藏层用 ReLU,输出层线性不加激活,损失用 MSE。写这部分的目标不是复现论文,而是让每一次前向和反向的形状、系数都对得上,出问题时能靠打印中间变量定位。
4.1 参数初始化与前向传播
import numpy as np class BPNN: def __init__(self, n_in, hidden=(32, 16), seed=42): rng = np.random.default_rng(seed) sizes = (n_in,) + tuple(hidden) + (1,) self.n_layers = len(sizes) - 1 self.params = {} for i in range(self.n_layers): fan_in, fan_out = sizes[i], sizes[i + 1] # He 初始化:适配 ReLU,方差取 2/fan_in,避免深层激活值塌缩 self.params[f"W{i+1}"] = rng.normal(0, np.sqrt(2.0 / fan_in), (fan_in, fan_out)) self.params[f"b{i+1}"] = np.zeros(fan_out) def forward(self, X): cache = {"A0": X} A = X for i in range(1, self.n_layers + 1): Z = A @ self.params[f"W{i}"] + self.params[f"b{i}"] # 隐藏层 ReLU,输出层线性——回归不能加激活,否则值域被卡死 A = np.maximum(0.0, Z) if i < self.n_layers else Z cache[f"Z{i}"], cache[f"A{i}"] = Z, A return A.ravel(), cache逻辑说明:sizes把输入、隐藏、输出串成一条链,循环按层建参数,加层只改hidden元组。cache保存每层的Z和A,反向传播要用Z > 0算 ReLU 导数、用A算权重梯度,不缓存就得重算一遍前向。
参数说明:初始化用np.sqrt(2.0 / fan_in)而不是 0.01 这类固定小值。固定小值在输入维度几十的时候还能跑,维度上百后每层输出方差逐层缩小,到第二层激活值几乎全在 0 附近,ReLU 一卡就死一片,梯度恒为 0,loss 停在那儿一动不动。如果哪天把 ReLU 换成 tanh,方差要改成1.0 / fan_in(Xavier),用错初始化同样会训不动,只是症状没这么极端。
4.2 损失函数与反向传播
def backward(self, cache, y, l2=1e-4): m = y.shape[0] grads = {} # MSE 对 y_hat 求导:dL/dy_hat = 2*(y_hat - y)/m,m 是 batch 平均的系数 dA = 2.0 * (cache[f"A{self.n_layers}"].ravel() - y) / m for i in range(self.n_layers, 0, -1): # 输出层线性,dZ 就是 dA;隐藏层乘 ReLU 导数 dZ = dA.reshape(-1, 1) if i == self.n_layers else dA * (cache[f"Z{i}"] > 0) grads[f"W{i}"] = cache[f"A{i-1}"].T @ dZ + l2 * self.params[f"W{i}"] grads[f"b{i}"] = dZ.sum(axis=0) # 偏置不加正则 dA = dZ @ self.params[f"W{i}"].T # 传给上一层的梯度 return grads逻辑说明:dA一开始是形状(m,)的一维数组,进循环后立刻reshape(-1, 1)变成列向量,后面所有矩阵乘法才是标准的(n, m) @ (m, k)。这一步漏了会得到形状对不上的广播错误,或者更糟——numpy 悄悄广播成(m, m)矩阵,代码能跑但梯度全错,loss 缓慢下降到一个很高的平台。
参数说明:2.0 / m这个系数只在 MSE 里出现一次,不要在dW里再除一次m,否则梯度小了两个数量级,学习率得开到 1e-1 才动,一到调参就被带偏。l2只加到W上不加到b上,偏置参与正则没有理论依据,还会让输出层基线偏移收敛变慢。(cache[f"Z{i}"] > 0)返回布尔数组,和dA相乘时自动转成 0/1,等价于 ReLU 的导数,注意在Z == 0这个点上取 0,实际训练中影响可以忽略。
4.3 训练循环、mini-batch 与早停
def train(model, X_tr, y_tr, X_val, y_val, cfg): m_t = {k: np.zeros_like(v) for k, v in model.params.items()} v_t = {k: np.zeros_like(v) for k, v in model.params.items()} beta1, beta2, eps, step = 0.9, 0.999, 1e-8, 0 best_val, best_params, wait = np.inf, None, 0 history = {"train": [], "val": []} for epoch in range(cfg.EPOCHS): idx = np.random.permutation(len(X_tr)) for s in range(0, len(idx), cfg.BATCH_SIZE): b = idx[s:s + cfg.BATCH_SIZE] y_hat, cache = model.forward(X_tr[b]) grads = model.backward(cache, y_tr[b], l2=cfg.L2) step += 1 for k in model.params: m_t[k] = beta1 * m_t[k] + (1 - beta1) * grads[k] v_t[k] = beta2 * v_t[k] + (1 - beta2) * grads[k] ** 2 # 偏差修正,前几十步 m_t/v_t 偏向 0,不修会白跑一段 m_hat = m_t[k] / (1 - beta1 ** step) v_hat = v_t[k] / (1 - beta2 ** step) model.params[k] -= cfg.LR * m_hat / (np.sqrt(v_hat) + eps) train_loss = mse(model, X_tr, y_tr) val_loss = mse(model, X_val, y_val) history["train"].append(train_loss) history["val"].append(val_loss) # 早停:验证集连续 PATIENCE 轮无改善就回滚到最优权重 if val_loss < best_val - 1e-6: best_val, wait = val_loss, 0 best_params = {k: v.copy() for k, v in model.params.items()} else: wait += 1 if wait >= cfg.PATIENCE: break if best_params is not None: model.params = best_params return history逻辑说明:每个 epoch 先打乱索引再按BATCH_SIZE切块,保证每个 batch 的样本组合不固定。Adam 的偏差修正在step很小时影响最大,前几十步不修的话等效学习率会偏小一半左右,表现为前几个 epoch loss 下降特别慢,容易误判成网络没学到东西。
参数说明:beta1=0.9、beta2=0.999是默认值,回归任务基本不用动;eps=1e-8防止v_hat在梯度极小时除零。早停存的是best_params的副本而不是引用,{k: v.copy()}里的copy不能省,少了这个函数会在后续更新中继续改同一块内存,最后回滚的其实是最新一轮的权重,早停形同虚设。1e-6这个阈值是为了避免浮点噪声让val_loss反复「改善」,样本量小的时候尤其明显。
5. 避坑与排查:损失不降、预测全平、过拟合的现场记录
这一章按现象归类,都是我在跑这套代码时真实遇到过的。每条给出表现、原因和改法,照着对号入座比盲调学习率快得多。
5.1 训练阶段:损失不降、NaN 与反复震荡
现象一:loss 从第一轮开始就卡在 0.6 左右,几百轮不降。原因通常是标准化没做或做错,输入特征量纲差几百倍(面积 80、距离 12000),梯度被大数值特征主导,小特征几乎不更新;另一个可能是学习率给到 1e-1 起步,Adam 在初期就把权重推到 ReLU 死区。 解决:先打印X_tr_s.mean(axis=0)和X_tr_s.std(axis=0),确认均值接近 0、标准差接近 1;再降学习率到 1e-3 跑 50 轮看趋势。
现象二:loss 跑着跑着变成nan。多数是学习率过大导致权重爆炸,或者y里有 0 而你在评估阶段算了 MAPE 时除了 0;还有一种隐蔽情况是log1p之后又手动减均值,遇到极端值时把-inf带进来了。 解决:训练前加np.isfinite(X).all()和np.isfinite(y).all()断言;学习率减半再跑;MAPE 计算前把y_true为 0 的样本剔掉或用1e-8兜底。
现象三:验证损失上下横跳,早停在第 60 轮就触发,但训练损失还在降。原因是 batch 太小(比如 8),每个 batch 的梯度噪声大;或者PATIENCE设得太小(10 轮),验证集本身样本少,噪声就有这么大波动。 解决:BATCH_SIZE提到 64 以上,PATIENCE给到 30,同时把验证集比例从 0.1 提到 0.15,让早停判断有统计意义。
现象四:训练集 R² 0.95,测试集 0.3,差距大得离谱。典型过拟合。特征维度被独热编码撑到几百维而样本只有一两千条,网络容量相对数据太大。 解决:加大L2到 1e-2,隐藏层从(32, 16)收到(16, 8),独热字段做基数合并,把小区 ID 换成区域均价。三项一起上,测试集 R² 往往能拉回 0.6 以上。
5.2 预测阶段:全平、量纲错位与结果复现不了
现象五:所有样本的预测值几乎一样,都贴在均值附近。网络实际上没学到东西,只是输出了训练集的目标均值。常见原因有三个:特征里绝大多数列是常数(独热后某类别占 99%)、学习率太小加上轮数不够、输出层前面误加了 ReLU 把负值截断。 解决:先看X_tr_s.std()有没有接近 0 的列并删掉;确认输出层是线性;把EPOCHS拉到 1000 观察验证损失是否还在下降。
现象六:预测值全是 0.003 到 0.006,真实单价是 3 万到 6 万。量纲错位,y做了log1p但预测时忘了np.expm1,或者scaler当初是对y做的标准化而预测时没做逆变换。 解决:把「目标变换」和「逆变换」写成一个函数对,encode_target/decode_target,全项目只允许通过这两个函数进出,其他地方禁止直接写np.log1p。这条规矩立下来之后,同类问题再没出现过。
现象七:同一份数据同一次训练,换台机器结果差很多。没固定种子,或者固定了np.random.seed但用的是default_rng两套体系。另外get_dummies的列顺序依赖 pandas 版本,类别出现顺序变了,特征列顺序跟着变,权重就对不上了。 解决:训练开始处统一np.random.default_rng(cfg.SEED),打乱索引用同一个生成器;把get_dummies后的columns列表存进outputs/feature_columns.json,预测时按这个列表reindex,缺列补 0、多列丢弃。这一条在二次开发里是硬性要求,否则线上预测和离线训练的特征永远对不齐。
6. 二次开发:把训练脚本改造成能对外提供预测的接口
训练脚本跑通只是起点,真正要拿去用,第一步是把「权重 + 预处理器 + 特征列顺序」打成一个不可分割的包。三者缺一,预测结果就是错的,而且错得很安静。
6.1 权重、scaler 与特征列一起入库
import json, joblib, numpy as np def save_bundle(model, scaler, feature_cols, out_dir): np.savez(out_dir / "model.npz", **model.params) # 权重 joblib.dump(scaler, out_dir / "scaler.pkl") # 标准化参数 (out_dir / "feature_columns.json").write_text( json.dumps(feature_cols, ensure_ascii=False), encoding="utf-8")加载时先reindex(columns=feature_cols, fill_value=0),再做scaler.transform,顺序反了就会因为列数不匹配直接报错。
6.2 单条预测接口
def predict_one(raw: dict) -> float: row = pd.DataFrame([raw]) row["unit_price"] = 1.0 # 占位,保证 clean 流程不炸 row = clean(row) # 复用训练时的清洗规则 X = build_features(row).reindex(columns=FEATURE_COLS, fill_value=0) Xs = SCALER.transform(X).astype(np.float32) y_log, _ = MODEL.forward(Xs) return float(np.expm1(y_log)[0]) # 逆变换还原成单价注意clean里的分位数截断在单条预测时不能用训练集的分位值,要换成固定的上下界常量,否则一条正常房源的单价会被「训练集 99 分位」错误截断。这类「训练期统计量必须持久化」的地方,是二次开发里最密集的坑。
6.3 改造完怎么验
改完接口不要只看它跑不报错,跑三个检查:拿训练集前 20 条样本走一遍predict_one,结果和evaluate.py算出的预测值误差应小于 1e-4;人为把某个特征置为NaN,确认清洗流程按预期插补而不是抛异常;把feature_columns.json删掉两列再加载,确认能通过reindex补 0 而不是静默错位。这三条过了,接口才算可用。
我现在的习惯是:任何一次改动,先跑一遍「训练 → 存包 → 重启进程 → 加载 → 单条预测」的完整链路,再去看指标。因为踩过太多次「训练时一切正常,加载后预测全歪」的坑,问题都出在包不完整上。从那以后我每次交付前都强制走一遍这个链路,宁可多花十分钟,也不想半夜被叫起来查为什么线上预测值全是 0.003。希望帮到你。
本文还有配套的精品资源,点击获取