ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

BP神经网络实现房价预测:反向传播原理与Python手写实践

BP神经网络实现房价预测:反向传播原理与Python手写实践

简介:面向机器学习初学者与回归预测实践者的 BP 神经网络 Python 项目,基于波士顿房价预测这一经典数据科学案例,从 housing.csv 读取数据,完成特征预处理、网络结构定义、前向传播计算、方差损失函数反向传播更新,直至模型评估与预测,适合课程设计或入门练习。压缩包共 2 个文件,整体仅 8KB,包含 pred_nn.py 训练脚本与 housing.csv 数据文件;数据集列出犯罪率、房间数量等十四个特征及中位房价目标值,便于直接开展回归实验。目前已有 21731 人学习下载。脚本代码注释清晰、结构紧凑,完整呈现随机权重初始化、标准化处理、多轮迭代训练与均方误差、R 方指标计算,读者可对照代码理解误差反向传播与梯度下降机制,也可直接将数据集替换为自有数据,迁移应用到其他回归预测场景,实现举一反三。

1. 房价预测的BP神经网络:为什么不用线性回归而选它

当你准备用Python拆解房价预测时,第一个念头往往是先跑一个线性回归看看。可房价和面积、位置、房龄的关系并不是一条直线:刚需房和豪宅的每平米单价差很远,线性模型很难表达这种拐弯的变化。BP神经网络(反向传播神经网络)靠隐藏层里的非线性激活函数,可以把特征重新组合成高维映射,所以它是这类回归问题里比线性回归更常用的起点。下面这篇笔记从零手写BP网络,用加州房价数据跑通训练和预测,把网络结构、学习率、归一化这些关键点的坑一并讲清。适合正在学BP的初学者,也适合想脱离框架理解反向传播的工程师;全篇只需要numpy、scikit-learn和matplotlib,不需要先装任何深度学习框架。

2. BP神经网络核心:从神经元到反向传播,给房价预测搭骨架

2.1 神经元与网络结构:输入层、隐藏层、输出层的数量怎么定

一个神经元做的事情很朴素:把输入加权求和,加上偏置,再过激活函数。单个神经元解决不了非线性问题,把多个神经元并排放成一层,再把层串起来,就是神经网络。以加州房价为例,每个样本有8个特征,那么输入层就有8个节点;输出层要预测一个连续房价,所以是1个节点。中间的隐藏层负责学习特征之间的组合关系,比如“房龄大但面积大”这种交互效应。万能逼近定理告诉我们,一个隐藏层就能以任意精度逼近连续函数,所以手写BP通常从单隐藏层起步。

隐藏层节点数没有定论。常见经验值是取输入特征数的1.5到2倍,或者取(输入+输出)/2向上取整。我一般会先用8个特征、16个隐藏节点的配置跑通,再逐次翻倍对比测试集误差。隐藏节点少了,模型欠拟合,训练集loss都降不下去;隐藏节点多了,模型开始背诵训练数据,测试集误差反而上涨。房价数据集一般只有几万条样本,盲目加深网络不如把单层调好,深网络在这里更容易过拟合,收敛也更慢。

2.2 激活函数与损失函数:回归任务不能照搬分类的ReLU+交叉熵

分类任务里,隐藏层常用ReLU,输出层用softmax,损失用交叉熵。这套组合搬到回归任务上会出事:输出层若是sigmoid,输出被限制在0到1之间,而标准化后的房价有正有负,模型连负值都输出不了;若是ReLU,输出永远非负,同样会限制表达范围。所以回归问题输出层必须用线性激活,也就是不套任何激活函数,让神经元加权和直接作为预测值。

隐藏层激活函数可以在sigmoid、tanh和ReLU里选。手写BP时sigmoid公式最简单,但它的导数最大只有0.25,多层叠加会出现梯度衰减;单隐藏层时这个影响不大,因此教学实现常选sigmoid。损失用均方误差MSE,公式是loss = mean((pred - y)^2)。为什么不用平均绝对误差?MSE可导且对大误差更敏感,梯度形式简单,配合线性输出层时,对输出的梯度正好是pred - y,这让反向传播写起来很直观。

2.3 数据预处理:房价特征量纲差异大,归一化是第一步

房价数据的特征量纲差异极大:平均收入只有个位数,房间总数可能到几十,人口从几百到几万,而房价中位数更是动辄几十万美元。如果不处理,数值大的特征会在加权和里占绝对主导,权重更新时梯度方向也被它们带偏,模型很难学到合理的权重组合。常见做法是标准化,让每个特征的均值变成0、标准差变成1,公式是x' = (x - mean) / std。

用一个StandardScaler就能完成。但要注意,scaler只能从训练集计算均值和标准差,然后再用这组参数去transform训练集和测试集。缩小数据集做演示,正确代码如下:

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() # 只对训练集 fit,统计训练集的均值和标准差 scaler.fit(X_train) X_train_scaled = scaler.transform(X_train) # 测试集复用同一组参数,不能重新 fit X_test_scaled = scaler.transform(X_test)

如果写成scaler.fit(X_all)之后再transform,测试集的分布信息已经混进了均值与标准差,相当于把测试集的“答案”提前告诉了训练流程,后续评估指标会虚高。这是整个BP流程里最容易踩、也最容易被忽视的一步。还有,目标变量房价y同样建议标准化,但评估时要还原成原始房价,这个细节后面第3章和第5章会再展开。

2.4 反向传播的四行核心公式:从输出误差倒推权重更新

BP的训练就是不断做两件事:前向传播算预测,反向传播算梯度。梯度下降更新权重W = W - lr * dW,只要求出每个权重的梯度dW,训练就有了方向。求dW依赖链式法则:输出层的误差先算出来,再逐层乘以激活函数的导数,把误差“传”回前一层。记隐藏层加权和为z1,激活后为a1;输出层加权和为z2,也就是预测值。反向传播的四个核心公式写成代码就是下面几行:

# 反向传播四行核心,m为当前样本数 d_z2 = (a2 - y) / m # 输出层误差,对应 dL/dz2 d_W2 = a1.T.dot(d_z2) # 隐藏层到输出层的权重梯度 d_z1 = d_z2.dot(W2.T) * a1 * (1 - a1) # 误差传回隐藏层,a1*(1-a1)是sigmoid导数 d_W1 = X.T.dot(d_z1) # 输入层到隐藏层的权重梯度

这里a1*(1-a1)是把链式法则接在sigmoid导数的结果上。如果你把隐藏层换成tanh或ReLU,这一行就要换成对应的导数:tanh的导数是1 - a^2,ReLU的导数是a>0时为1否则为0。理解了这四行,后面写BP类就只剩工程化封装了。

3. 用Python实现BP神经网络:数据准备、网络定义与训练循环

3.1 准备数据集:读取、划分与标准化

加州房价数据集在sklearn里一行就能加载,返回8个特征和连续房价。先切分再标准化,顺序不能反。下面是数据准备代码:

import numpy as np from sklearn.datasets import fetch_california_housing from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler data = fetch_california_housing() X = data.data # 8个特征:收入、房龄、房间数等 y = data.target.reshape(-1, 1) # 房价,单位是十万美元,2.0代表20万美元 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) scaler_x = StandardScaler().fit(X_train) X_train_s = scaler_x.transform(X_train) X_test_s = scaler_x.transform(X_test) scaler_y = StandardScaler().fit(y_train) y_train_s = scaler_y.transform(y_train) y_test_s = scaler_y.transform(y_test) print(X_train_s.shape, X_test_s.shape) # (16512, 8) (4128, 8)

注意fetch_california_housing返回的target是(20640,),reshape(-1,1)是为了让y作为列向量参与矩阵运算。y也做标准化的原因和X一样:房价数值在数十万美元级别,直接当输出会让梯度量纲偏大,收敛不稳。训练完成后用scaler_y.inverse_transform把预测还原到原始房价单位。print形状可以确认数据份数:训练集16512条,测试集4128条。

3.2 定义BPNetwork类:前向传播与反向传播

手写BP不需要框架,一个类就够。类里保存权重和偏置,实现sigmoid、前向、反向和训练。代码不长,但每一步都对应前一章的公式:

class BPNetwork: def __init__(self, n_input, n_hidden, n_output, lr=0.01): # 权重初始化为小随机数,不能太大 self.W1 = np.random.randn(n_input, n_hidden) * 0.1 self.b1 = np.zeros((1, n_hidden)) self.W2 = np.random.randn(n_hidden, n_output) * 0.1 self.b2 = np.zeros((1, n_output)) self.lr = lr def sigmoid(self, x): return 1 / (1 + np.exp(-x)) def sigmoid_derivative(self, a): return a * (1 - a) def forward(self, X): self.z1 = np.dot(X, self.W1) + self.b1 self.a1 = self.sigmoid(self.z1) self.z2 = np.dot(self.a1, self.W2) + self.b2 self.a2 = self.z2 # 输出层线性激活 return self.a2 def backward(self, X, y, output): m = X.shape[0] d_z2 = (output - y) / m d_W2 = np.dot(self.a1.T, d_z2) d_b2 = np.sum(d_z2, axis=0, keepdims=True) # 误差从输出层传回隐藏层 d_a1 = np.dot(d_z2, self.W2.T) d_z1 = d_a1 * self.sigmoid_derivative(self.a1) d_W1 = np.dot(X.T, d_z1) d_b1 = np.sum(d_z1, axis=0, keepdims=True) self.W2 -= self.lr * d_W2 self.b2 -= self.lr * d_b2 self.W1 -= self.lr * d_W1 self.b1 -= self.lr * d_b1 def train(self, X, y, epochs): loss_history = [] for epoch in range(epochs): output = self.forward(X) loss = np.mean((output - y) ** 2) self.backward(X, y, output) loss_history.append(loss) if epoch % 100 == 0: print(f"epoch {epoch}, loss {loss:.4f}") return loss_history def predict(self, X): return self.forward(X)

权重用np.random.randn * 0.1,目的是让初始权重落在比较小的范围,避免输入经过加权和后太大,使sigmoid进入饱和区。偏置初始化为0没问题。forward里把中间量self.z1、self.a1、self.z2缓存下来,因为backward要用。backward里的d_z2没有乘2,是把MSE求导的系数2吸收进学习率了,效果相同。train方法走的是全量梯度下降:每个epoch都拿全部训练样本算一次梯度再更新,对一万多条的房价数据足够用。

3.3 训练循环与损失曲线

创建网络前先固定随机种子,保证结果可复现。然后训练500轮,画出loss曲线:

np.random.seed(42) network = BPNetwork( n_input=X_train_s.shape[1], n_hidden=16, n_output=1, lr=0.01 ) loss_hist = network.train(X_train_s, y_train_s, epochs=500) import matplotlib.pyplot as plt plt.plot(loss_hist) plt.xlabel('epoch') plt.ylabel('MSE') plt.title('Training Loss') plt.show()

如果训练过程正常,loss会从接近1的数值往下走,前200轮下降明显,后面变缓。全量梯度下降在数据量不大时很稳定,但注意你的机器如果跑500轮偏慢,可以把epochs调成200,不影响后面评估。欠拟合时loss曲线会一直高,过拟合时训练loss很低但测试误差高,这里先看训练曲线找感觉。

3.4 评估模型:反归一化与MAE/RMSE

训练完必须把预测值从标准化空间还原成真实房价,再计算误差。直接在标准化空间算MAE会得到很夸张的数字,没有业务意义。正确评估代码:

y_pred_s = network.predict(X_test_s) y_pred = scaler_y.inverse_transform(y_pred_s) y_test_orig = scaler_y.inverse_transform(y_test_s) mae = np.mean(np.abs(y_pred - y_test_orig)) rmse = np.sqrt(np.mean((y_pred - y_test_orig) ** 2)) print(f"MAE: {mae:.4f} (十万美元)") print(f"RMSE: {rmse:.4f} (十万美元)")

注意y_test_s是标准化后的测试标签,inverse_transform还原后才是真实房价。套用这个流程,手写单隐藏层BP在加州房价上的MAE通常在0.5左右,也就是5万美元上下。这个结果比线性回归略好,但跟梯度提升树还有差距,毕竟BP用这么简单的结构能做到这个程度已经很不错。想要更低误差,后面第6章的Mini-batch和特征组合可以试试。

4. 训练过程监控与调参:学习率、隐藏层神经元数、过拟合

4.1 学习率对收敛的影响:先跑0.1、0.01、0.001

学习率是手写BP里第一个要调的超参数。它决定每一步权重更新的步伐大小,太大震荡甚至发散,太小则慢得让人失去耐心。可以在同一份数据上分别用0.1、0.01、0.001训练,观察loss曲线:

for lr in [0.1, 0.01, 0.001]: np.random.seed(42) net = BPNetwork(X_train_s.shape[1], 16, 1, lr=lr) losses = net.train(X_train_s, y_train_s, epochs=300) plt.plot(losses, label=f'lr={lr}') plt.legend() plt.xlabel('epoch') plt.ylabel('MSE') plt.show()

实际跑下来:lr=0.1时loss会在早期冲高,出现明显震荡,有时甚至nan;lr=0.01下降平稳,是稳妥起点;lr=0.001下降很慢,300轮还停在较高的loss。调参时我习惯先固定epochs=300,用0.01跑一遍,如果loss曲线抖动就降到0.005或0.003,如果下降太慢就试0.02。手写BP没有自适应学习率机制,所以这个参数的影响比用Keras时更直接。

4.2 隐藏层神经元数与过拟合:从4到64的验证

隐藏层把网络容量撑起来,但容量不是免费的。分别试试4、16、32、64个隐藏节点,训练后同时看训练集和测试集MAE:

def evaluate_mae(net): pred_train = scaler_y.inverse_transform(net.predict(X_train_s)) pred_test = scaler_y.inverse_transform(net.predict(X_test_s)) train_mae = np.mean(np.abs(pred_train - y_train)) test_mae = np.mean(np.abs(pred_test - y_test)) return train_mae, test_mae for h in [4, 16, 32, 64]: np.random.seed(42) net = BPNetwork(X_train_s.shape[1], h, 1, lr=0.01) net.train(X_train_s, y_train_s, epochs=300) train_mae, test_mae = evaluate_mae(net) print(f"hidden={h:2d}, train_MAE={train_mae:.4f}, test_MAE={test_mae:.4f}")

通常hidden=4时训练和测试误差都偏高,欠拟合。hidden=16时测试误差开始下降。hidden=64时训练误差继续下降,但测试误差可能不再降甚至回升,这就是过拟合。识别过拟合的简单标尺:训练MAE明显低于测试MAE,且两者差距随节点数增大而拉大。遇到这种情况,减少隐藏节点、增加L2正则化、或者用早停都比硬调epochs管用。

4.3 评估指标的意义:MAE和RMSE怎么看

MAE是平均绝对值误差,单位就是房价单位,最直观。RMSE对大误差更敏感,一个样本差10万会把RMSE拉高很多,所以RMSE通常比MAE大。如果两者接近,说明各样本误差较均匀;如果RMSE比MAE大很多,说明少数样本预测得很差,比如豪宅样本。对于加州房价,target中位数在2左右,也就是20万美元,MAE=0.5意味着平均判断偏差5万美元,这对粗略估算是合理的,但离精确估值还差得远。

在实际项目中,我会同时打印MAE和RMSE,再看一下预测值与真实值的散点图:点越贴近45度线说明模型越准。如果散点图在高端房价区域散开,说明高房价样本没有学好,需要补充特征或提高高价值样本的权重。

5. 房价预测BP神经网络的避坑指南:五个常见翻车现场

5.1 特征缩放的数据泄漏:测试集凭空变好

现象:对全量X做StandardScaler().fit_transform再切分,跑出来的测试MAE比训练还低,但把模型拿到新数据上预测时效果差很多。原因是scaler在切分前看到了测试集的均值和标准差,测试集的分布信息混进了训练流程,这叫数据泄漏。解决:先切分,再只对训练集fit,测试集用同一个scaler.transform,代码就是第3章的写法。这个坑在Kaggle比赛里是红线,在自己调参时也容易犯,因为管道写起来更顺手,但评估结果会骗人。

5.2 输出层用了sigmoid,预测结果全在0~1

现象:训练loss下降正常,但反归一化后预测房价几乎都集中在同一个值,而且明显偏离真实房价。原因是输出层backward里的self.a2被包了一层sigmoid,把输出限制在(0,1);标准化后的房价有正有负,模型输出永远大于0且小于1,无法表达负值,最后只能学一个折中常数。解决:让输出层线性激活,forward里直接self.a2 = self.z2。这也是为什么回归任务不能把分类任务的输出层激活函数照搬过来的最典型例子。

5.3 初始化权重太大,loss卡住不动

现象:把np.random.randn直接赋给W1,不乘0.1,训练开始loss就在高位,几百轮几乎不降。原因是输入特征标准差为1,但特征数量多,加权和后数值很容易超过3,sigmoid陷入饱和区,导数趋近于0,梯度消失,权重更新量极小。解决:权重初始化乘以0.1或0.01,或者把sigmoid换成tanh,tanh零中心且导数在0附近是1,饱和问题会缓解。手写BP最稳的组合是“标准化输入 + 小随机权重 + sigmoid”。

5.4 不固定随机种子,同一份代码两次结果完全不同

现象:两次运行代码,得到不同的loss曲线和MAE,有时差距超过20%。原因有两个:train_test_split不传random_state,每次切分的数据不同;权重初始化是随机数,每次网络起点不同。解决:在import后固定np.random.seed(42),train_test_split里也要写random_state=42。注意先设置seed再创建网络,因为BPNetwork的__init__里会调用randn。固定种子后,同一环境跑出来的结果完全可复现,这对调参和写文章都重要。

5.5 忘记对y反归一化,评估指标高得离谱

现象:训练的时候loss降得很漂亮,但打印MAE得到1.8甚至更大,比直接用训练集均值预测还差。原因是拿标准化后的y_pred_s和y_test_s直接算MAE,标准化后的房价分布是均值为0、方差为1,误差1.0已经是大错,换算回原尺度就是十万美元级别。解决:评估和业务汇报必须用真实房价单位,用scaler_y.inverse_transform把预测和标签都还原。注意inverse_transform要求输入是二维数组,predict返回的形状通常是(n,1),不需要再reshape成(n,)。

6. 进阶:把BP用于更大房价数据的三个技巧

6.1 用Mini-batch梯度下降替代全量

全量梯度下降每次更新要算完所有样本,数据量上万时还能接受,但特征变多、网络变宽后就会明显变慢。Mini-batch把训练集按batch_size切成小份,每份算一次梯度并更新权重。这样更新频率高,收敛更快,也有一定正则效果。修改train方法很直接:在每轮epoch里先打乱索引,再分batch迭代。

def train_minibatch(self, X, y, epochs, batch_size=32): m = X.shape[0] for epoch in range(epochs): idx = np.random.permutation(m) X_shuffle, y_shuffle = X[idx], y[idx] for i in range(0, m, batch_size): Xb = X_shuffle[i:i+batch_size] yb = y_shuffle[i:i+batch_size] output = self.forward(Xb) loss = np.mean((output - yb) ** 2) self.backward(Xb, yb, output)

用batch_size=32时,backward里的m就是32,梯度方向会有噪声,loss曲线不像全量那么平滑,但整体下降更快。我一般把batch_size设为32或64,再配合下面这个早停技巧,能省不少调参时间。

6.2 早停:用验证集决定何时停

训练轮次是另一个容易拍脑袋的超参数。轮次太少欠拟合,太多过拟合。常见做法是从训练集再切一部分做验证集,每个epoch后在验证集上算损失,连续多个epoch不下降就停止训练。比如验证集loss连续20轮没有低于历史最优,就break并恢复历史最优参数。这个思路叫early stopping,用手写BP实现时要额外维护一份训练集和验证集的划分,但代码不复杂。对房价这种小数据集,早停通常能让你用“足够多”的初始化epoch把模型跑到最优,而不用反复试次数。

6.3 手工加特征组合

BP虽然能自动学交互,但给模型喂正确的信号永远比让它硬找省力。我在做房价预测时,会把手头特征先做相关性粗筛,再构造几个有业务含义的组合特征,比如“房龄 × 房间数”能反映老房子的空间拥挤度,“人均房间数 = 房间数 / 人口”比单人房间总数更有解释力。这些新特征先拼进X,再一起标准化。特征维度从8涨到10,隐藏节点可以适当从16涨到24,测试MAE往往比纯用原始特征要低一些。做这些尝试时,记得每次都在训练集上重新fit scaler。

我自己跑这个项目最深的教训是:先检查输出层激活函数和数据泄漏,再去调学习率和隐藏层大小——方向错了,后面所有调参都是白费。希望帮到你,愿你跑出来的房价预测曲线比自己家小区的涨幅更稳。

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

RELATED READING

延伸阅读

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