简介:面向机器学习与深度学习入门者的BP神经网络回归实战素材,聚焦用反向传播网络完成波士顿房价预测这一经典案例。压缩包仅8KB,共2个文件:一份CSV格式的housing.csv提供包含犯罪率、房间数等特征与中位房价标签的训练数据,一份pred_nn.py则用Python完整实现网络搭建、前向传播、基于方差(均方误差)的损失计算与反向传播权重更新,并给出训练迭代与测试集评估流程。读者可据此跑通从数据读取、预处理、权重初始化到预测输出的全链路,理解多层前馈网络的梯度计算细节,也可在此基础上调整隐藏层结构、学习率与迭代次数做对比实验。已有21749人学习,适合作为课程作业、毕设选题或神经网络原理自学的配套练手代码。
1. 先取对数再进网络:房价预测里最反直觉的一步
把 BP 神经网络拿来做房价回归预测,第一件该做的事往往不是搭网络,而是先把房价取个对数。这个结论听起来不像深度学习,倒像数据清洗的边角料,但只要你手写一遍 Python 实现,看梯度在原始房价尺度上怎么被大数值带偏,就会明白这不是玄学,是误差反向传播的数学后果。回归预测和分类最大的区别在于输出空间是连续的,BP 神经网络在这里干的活,本质是用链式法则把均方误差逐层归因到每个权重;网络结构只是外壳,真正决定能不能收敛的是标签分布、特征尺度和梯度路径。
2. 前向传播:线性回归为什么只是没有隐藏层的特例
很多人第一次用 BP 做回归,会把隐藏层当成“越多越准”的旋钮,结果在小样本房价数据上越加越差。问题不在隐藏层数量,而在没搞清楚前向传播到底在算什么。前向计算不是黑匣子,它是一串矩阵乘法和激活函数,每一层的输出维度、每个参数的形状都能写得清清楚楚;把这一步拆开,后面反向传播的梯度才不会对不上号。
2.1 单层线性模型拟合不了的,是特征之间的交互
先看最朴素的房价模型:预测值等于面积乘一个权重,加房龄乘一个权重,再加一个偏置。写成矩阵形式就是y = Xw + b,这正是一个没有隐藏层、没有激活函数的神经网络。它的局限不是参数少,而是假设“面积每增加一平米,房价涨固定的钱”在整个取值范围内都成立。真实房价里,面积对总价的边际贡献会随地段变化,房龄对价格的影响也不是直线衰减,这类特征交互和分段非线性,线性模型只能靠人工构造“面积×地段”“房龄的平方”这类交叉项去补。
更关键的一个事实是:如果堆叠多层但不加激活函数,多层线性变换的复合仍然是线性变换。设第一层Z1 = XW1 + b1,第二层Y = Z1W2 + b2,代入后得到Y = X(W1W2) + (b1W2 + b2),等价于一个单层线性模型。这意味着隐藏层本身不带来表达能力,真正让网络能拟合非线性房价曲线的是激活函数。常见的实现思路是在隐藏层用 ReLU 或 tanh,把线性输出掰弯,输出层再保持线性,让预测值落在实数域。
2.2 隐藏层在做什么:把原始特征映射成一组可组合的基
把隐藏层每个神经元看成一个基函数,会比“模拟人脑”这种说法有用得多。输入特征先被W1投影到隐藏空间,再经过 ReLU 截断负值,每个隐藏单元相当于在输入空间里划出一片区域,区域内输出斜平面、区域外输出零。输出层把这些分段线性的基加权求和,就能拼出弯曲的房价曲面。宽度决定基的数量,也就是能切出多少段;深度决定基的复合层数,能表达更复杂的嵌套关系。
这解释了一个工程上的取舍:房价数据通常只有几千到几万条,特征维度也不高,加宽隐藏层比加深更划算。宽度增加的是可组合的基函数数量,模型容量线性增长;深度增加的是复合函数层级,容量指数增长,但小样本下更容易记住噪声。我在模拟项目里见过把隐藏层堆到五六层、每层几百个单元的配置,训练损失一路降,验证集上的房价误差反而比单隐藏层更大,这就是容量和样本量不匹配的典型翻车。
2.3 用 numpy 写一遍前向计算
下面这段代码只做前向,目的是把每个张量的形状钉死。输入X是(N, d),N是样本数,d是特征数;隐藏层宽度记为H;回归输出一个值,所以输出维度是 1。
import numpy as np def init_params(n_in, n_hidden, n_out, seed=42): rng = np.random.default_rng(seed) # He 初始化:适合 ReLU,方差取 2/n_in,避免深层梯度一开始就缩得太小 W1 = rng.normal(0, np.sqrt(2.0 / n_in), size=(n_in, n_hidden)) b1 = np.zeros(n_hidden) # 输出层是线性回归头,方差取 1/n_hidden,初始预测接近 0 W2 = rng.normal(0, np.sqrt(1.0 / n_hidden), size=(n_hidden, n_out)) b2 = np.zeros(n_out) return W1, b1, W2, b2 def forward(X, params): W1, b1, W2, b2 = params Z1 = X @ W1 + b1 # (N, H),线性投影 A1 = np.maximum(Z1, 0.0) # (N, H),ReLU 激活 Y_hat = A1 @ W2 + b2 # (N, 1),回归输出保持线性 cache = (X, Z1, A1) return Y_hat, cacheZ1 = X @ W1 + b1里的@是矩阵乘法,X的每一行是一个样本的特征向量,乘完得到该样本在隐藏空间的坐标。np.maximum(Z1, 0.0)是 ReLU,它把负的预激活值压成零,保留正的部分,求导时只需要判断Z1 > 0的真假。输出层没有再套 sigmoid 或 tanh,因为房价是连续实数,套了激活反而把预测范围锁死,还会在饱和区把梯度压没。cache存下前向的中间结果,反向传播时不用重算,这是手写实现里省计算量的常见做法。
3. 反向传播:误差如何逐层归因到每个权重
前向算完得到预测值,接下来要回答一个更具体的问题:预测偏了 10 万,这个误差该由第一层的哪个权重、第二层的哪个偏置承担多少责任。BP 神经网络的“学习”就是把这个问题用链式法则拆成局部梯度,再从输出层往回乘。理解这一章,调参就不再是试运气,而是看梯度有没有传对、尺度有没有失衡。
3.1 均方误差与输出层:回归为什么常用恒等映射
房价回归最常用的损失是均方误差,写作L = (1/N) * Σ(y_hat - y)^2。它对预测值的导数是dL/dy_hat = 2(y_hat - y)/N,预测偏低时梯度为正,梯度下降会把输出往上推;预测偏高时梯度为负,把输出往下拉。这个梯度的大小和误差成正比,误差越大修正越猛,这是 MSE 在回归里好用的原因,也是它对异常房价敏感的原因。
输出层用恒等映射,是为了让dL/dy_hat不被激活函数的导数再乘一次。如果输出层套 sigmoid,预测值被压在 0 到 1 之间,房价必须先缩放才能当目标;更麻烦的是 sigmoid 在两端导数接近零,大房价样本产生的误差梯度会被饱和区吃掉,网络学不动。分类任务用 softmax 是因为输出要解释成概率分布,回归任务没有这个约束,线性输出让误差信号原样传回隐藏层。
3.2 链式法则逐层拆:从损失对输出的梯度到第一层
反向传播的核心是四个矩阵乘法和一个掩码。记dY = dL/dY_hat,形状(N, 1);第二层权重W2形状(H, 1);隐藏层激活A1形状(N, H)。对W2求导时,每个样本的贡献是A1_i乘上该样本的dY_i,累加所有样本就是dW2 = A1.T @ dY,形状(H, N) @ (N, 1) = (H, 1),和W2对得上。对偏置b2求导则是把dY按样本维度求和,因为偏置对每个样本都加同一个值。
误差继续往回流,先经过输出层权重:dA1 = dY @ W2.T,形状(N, 1) @ (1, H) = (N, H),表示隐藏层每个激活值对最终损失的敏感度。然后穿过 ReLU:ReLU 的导数是分段常数,Z1 > 0的位置导数为 1,否则为 0,所以dZ1 = dA1 * (Z1 > 0)。这里的*是逐元素乘法,掩码把被 ReLU 截断的单元的梯度直接置零,这也是 ReLU 网络里“死神经元”的来源之一。最后对第一层:dW1 = X.T @ dZ1,db1 = dZ1.sum(axis=0),形状分别回到(d, H)和(H,)。
矩阵形状对不上的时候,不要硬调,先拿纸把(N, d)、(N, H)、(H, 1)写出来,看哪一步转置反了。血泪经验是:dW = 输入.T @ 上层梯度这个模式在每一层都成立,记住它比背公式可靠。
3.3 手写 backward 并用数值梯度校验
下面补上损失和反向传播的实现。代码里的注释标了每个梯度的形状,方便和参数对照。
def mse_loss(Y_hat, Y): diff = Y_hat - Y loss = np.mean(diff ** 2) dY = 2.0 * diff / Y.size # (N, 1),对 Y_hat 的梯度 return loss, dY def backward(dY, cache, params): X, Z1, A1 = cache W1, b1, W2, b2 = params dW2 = A1.T @ dY # (H, N) @ (N, 1) -> (H, 1) db2 = dY.sum(axis=0) # (1,) dA1 = dY @ W2.T # (N, 1) @ (1, H) -> (N, H) dZ1 = dA1 * (Z1 > 0) # ReLU 导数,逐元素掩码 dW1 = X.T @ dZ1 # (d, N) @ (N, H) -> (d, H) db1 = dZ1.sum(axis=0) # (H,) return dW1, db1, dW2, db2mse_loss里的Y.size是N * 1,除以样本数得到平均损失,梯度也按同样比例缩放,这样学习率不会随 batch 大小剧烈变化。backward返回的顺序和params一致,更新时逐个相减即可。写完反向传播,别急着跑训练,先做数值梯度校验:把某个权重扰动一个很小的eps,用中心差分(f(w+eps) - f(w-eps)) / (2*eps)算近似梯度,和反向传播的结果比相对误差。误差在1e-6量级说明推导和转置都没问题,误差到1e-2以上基本可以确定有层写反了。
提示:梯度校验要关掉随机性,用固定的小样本和固定初始化,否则每次前向结果不同,数值梯度没有可比性。
4. 数据工程:为什么标准化和对数变换要先于调参
同一个网络结构,换一套预处理,验证集误差能差出一大截,这在房价预测里尤其明显。原因不神秘:BP 的梯度是逐层乘出来的,输入特征的尺度、标签的分布形状,都会改变梯度的量级和方向。先处理数据和先调超参数,差别就像先修路再开车和先踩油门再找路。
4.1 房价标签的右偏分布与对数变换
真实房价分布通常右偏:大部分房子集中在中间价位,少数高价房把尾巴拉得很长。直接对原始房价做 MSE,高价样本的误差平方会主导总损失,网络会优先把高价房拟好,中低价房的相对误差反而很大。对标签取对数后,分布更接近对称,MSE 在对数空间里近似等价于原始空间的相对误差,高价房和低价房的误差被拉到同一量级,梯度不会被少数大数值绑架。
还原预测值时要用exp逆变换。这里有个容易忽略的细节:对数空间的预测是无偏的,但exp之后在原始空间会有偏差,严格的实现会加一个方差修正项;工程上样本量不大时,直接exp也能用,但评估指标要在原始房价上算,不能拿对数空间的损失直接当业务误差汇报。
import numpy as np def log_transform(y, eps=1.0): # eps 防止零房价或负值;真实数据要先确认最小值再定 eps return np.log(y + eps) def inverse_log(y_log, eps=1.0): return np.exp(y_log) - epslog_transform只应该拟合在训练集上,验证集和测试集用同一个eps变换,保证空间一致。inverse_log把模型输出拉回房价量纲,之后的 MAE、RMSE 才有业务含义。
4.2 特征标准化如何改变梯度的尺度
房价特征里,面积可能是几十到几百,房龄是个位数,距离市中心的距离可能是几公里,量纲差两三个数量级。不做标准化时,第一层梯度dW1 = X.T @ dZ1会被大尺度特征主导,等高线被拉成狭长椭圆,同一个学习率对大特征合适、对小特征太小,训练要么震荡要么爬得极慢。标准化把每个特征减去均值、除以标准差,让各维尺度接近,梯度的条件数改善,学习率才有统一的参考值。
标准化的均值和标准差只能从训练集算,再应用到验证集。用全量数据算均值方差,等于把验证集的分布信息泄漏进训练,验证误差会偏乐观,这是很隐蔽的踩坑点。
def fit_scaler(X): mu = X.mean(axis=0) sigma = X.std(axis=0) sigma[sigma < 1e-8] = 1.0 # 常数列不参与缩放,避免除零 return mu, sigma def transform(X, mu, sigma): return (X - mu) / sigmafit_scaler返回训练集的均值和标准差,transform对任意数据集用同一组参数做变换。常数列的标准差接近零,把它置为 1,等价于只减均值不缩放,避免出现inf。
4.3 类别编码与训练/验证划分里的泄漏
地段等级这类类别特征不能直接当数字喂进网络,否则模型会误以为“等级 3 是等级 1 的三倍”。常见做法是独热编码,但类别表必须来自训练集:验证集里出现训练集没见过的类别时,整行编码为全零,而不是临时扩充维度。每次划分后重新建类别表,会导致训练和验证的特征维度不一致,预测时报形状错误。
数据泄漏还有几个来源:先对全量数据做标准化再划分、用全量标签算目标编码、用未来数据做特征。排查方法很简单,把预处理写进一个只在训练集上fit的管道,验证集只transform,如果验证误差突然比训练误差低很多,先怀疑泄漏,而不是庆祝模型泛化好。
5. 排查清单:loss 在降、房价却全偏同一个值的几个原因
训练日志里损失曲线往下走,不代表模型学到了东西。房价回归里有一类失败很典型:损失在降,但预测值挤在同一个数附近,或者验证集完全崩掉。下面几条是模拟项目里反复出现的现象,每条按“看到什么、为什么、怎么改”拆开。
5.1 现象:所有样本预测值几乎相同
原因:输出层被套了 sigmoid 或 tanh,预测被压在窄区间;或者输入特征没标准化,第一层梯度被某个大尺度特征主导,隐藏层激活大面积死掉;也可能是学习率太小,训练几百轮权重几乎没动。
解决:输出层改回线性;对特征做标准化;打印每层梯度的范数,如果第一层梯度比第二层大几个数量级,基本就是尺度问题。再检查 ReLU 的死亡比例,某层激活全零说明初始化或学习率把神经元推到了负区。
5.2 现象:训练集误差很低,验证集误差翻倍
原因:隐藏层太宽、样本太少,模型把训练集的噪声记住了;或者预处理阶段用了全量数据拟合均值和方差,验证集信息泄漏进训练;目标编码用了验证集标签也是常见来源。
解决:先缩小隐藏层宽度,从 64 降到 16 试起;把标准化、类别编码全部改成只在训练集fit;加 L2 正则或早停。验证误差突然低于训练误差时,不要先调模型,先查泄漏。
5.3 现象:预测出负房价
原因:输出层是线性映射,值域是整个实数轴,遇到分布外的样本或异常特征,外推就会出负值。取对数训练的目标经exp还原后天然非负,但原始空间直接回归没有这个约束。
解决:要么对标签取对数再训练,要么在输出后做非负截断,但截断只是遮住症状,根因通常是异常特征或外推。检查验证集里有没有超出训练分布的特征取值,必要时加特征裁剪。
5.4 现象:损失震荡,几轮高几轮低
原因:学习率相对特征尺度偏大;batch 太小,梯度噪声大;数据没有 shuffle,连续样本的分布偏差被放大;特征尺度差异大,等高线太扁。
解决:学习率降一个数量级再试;batch 增大到 64 或 128;每个 epoch 前打乱索引;标准化特征。震荡和发散是两回事,发散看损失是否越来越大,震荡看损失是否在一个区间里来回。
5.5 现象:深层网络梯度消失或爆炸
原因:隐藏层用了 sigmoid 或 tanh,导数最大只有 1 或 0.25,逐层相乘后梯度指数衰减;初始化方差太大,前向激活值爆炸,反向梯度跟着爆炸。
解决:隐藏层换 ReLU,初始化用 He 初始化;层数控制在两三层以内;必要时加梯度裁剪。房价数据量小,深网络本来就不划算,先把宽度和预处理调好,再考虑加深。
6. 容量、正则与评估:小样本房价回归的取舍
房价数据通常不大,特征也就几十维,BP 网络的容量选择要克制。宽度和深度之间,我倾向于先加宽到 32 或 64,观察验证误差,再决定要不要加第二层隐藏层;小样本下加深带来的复合表达能力,往往抵不过过拟合的风险。正则化方面,L2 权重衰减比 dropout 更适合这种回归场景,dropout 在小数据上会把有效样本进一步打散,训练不稳定。早停是性价比最高的正则:每轮在验证集上算一次原始房价的 MAE,连续若干轮不降就停,保留验证误差最低的权重。
评估指标不要只看一个。下面这张表是房价回归里常用的四个指标,各自的侧重点不同:
| 指标 | 计算方式 | 适合回答的问题 | 注意点 |
|---|---|---|---|
| MAE | 预测误差绝对值的平均 | 平均每套房价偏多少钱 | 和大房价的绝对误差挂钩,可解释性强 |
| RMSE | 误差平方平均后开方 | 大误差有多严重 | 对异常房价敏感,少数高价房会拉高 |
| MAPE | 绝对百分比误差的平均 | 相对误差水平 | 真实房价接近零时数值爆炸,要过滤 |
| R² | 1 减去残差平方和比总平方和 | 模型解释了多大比例的方差 | 样本少时容易虚高,不能单独看 |
我一般会同时看原始房价的 MAE 和 R²:MAE 告诉业务方平均偏多少,R² 看模型有没有学到超过均值预测的东西。对数空间训练的模型,评估前一定先exp还原,否则指标量纲对不上,汇报时很容易闹乌龙。
从那以后我每次跑房价回归,都强制先做三件事:画一眼标签分布决定要不要取对数,确认标准化参数只在训练集上拟合,跑完训练先看验证集预测值的散点而不是只看损失曲线。这套习惯不能保证模型一定准,但能挡掉大部分“损失在降、结果不能用”的坑。希望帮到你。
本文还有配套的精品资源,点击获取