纯NumPy手写BP神经网络房价预测:原理、实现与避坑
简介:面向机器学习入门者与深度学习实践者的房价回归预测案例,围绕BP神经网络展开,使用Python完成前向传播、反向传播与权重更新,并以方差作为损失函数,配合房屋特征数据训练模型、评估预测效果。压缩包共2个文件,含1个Python脚本和1个CSV数据集,整体约8KB,脚本负责网络搭建与训练流程,数据文件提供建模所需的特征与目标值。已有21762人学习,说明该案例在回归预测入门中具有一定参考价值。读者可据此理解数据读取与预处理、网络结构定义、损失计算、迭代训练及测试评估的完整链路,也可将代码作为课程实验、作业或小型项目的基线,进一步调整隐藏层、学习率与评估指标,观察模型在房价预测任务上的表现变化。
1. 纯 NumPy 写一个房价预测 BP 神经网络:不用框架反而更值
接手一个房价预测的需求,多数人第一反应是import xgboost,三行代码出结果。可真到了要向别人解释「这套房为什么报 320 万」,或者线上突然出现一批明显偏低的预测需要定位原因时,黑盒就成了负担。这份 Python 代码走的是另一条路:用纯 NumPy 从零实现一个 BP 神经网络,前向传播、反向传播、梯度更新、学习率衰减全部摊开在几百行里,谁都能逐行改。它面向的不是刷榜刷精度的比赛场景,而是两类人——想把神经网络回归的每个环节真正弄明白的转行者和学生,以及需要在自有房源数据上快速搭一个可解释基线、再往上叠特征的业务开发。跑通一次,你对标准化、损失函数、梯度消失这几个词的理解,会比看十篇公式推导更实在。
2. 项目结构与技术栈:从 CSV 到一条预测的完整链路
这份代码的组织方式很朴素,没有setup.py、没有pyproject.toml、没有__init__.py导出的包级别 API,就是几个平铺的模块加一个训练入口。好处是新手克隆下来改两行就能跑,坏处是你得先搞清楚谁调谁。我一般会先花五分钟把目录和调用关系过一遍,再动手跑,比直接python train.py然后看着报错猜要快得多。
2.1 目录结构与文件分工
house_bp/ ├── data/ │ └── house.csv # 原始成交表,一行一套房,最后一列是成交价 ├── src/ │ ├── dataset.py # 清洗、类别编码、划分、标准化、批次构造 │ ├── model.py # BPNet:He 初始化 / 前向 / 反向 / 预测 │ ├── optim.py # Adam 更新器 + 学习率衰减 │ ├── metrics.py # RMSE / MAE / MAPE,全部还原到「万元」量纲 │ └── train.py # 训练入口:早停、日志、权重落盘 ├── artifacts/ │ ├── model.npz # 所有 W/b 打包存一个文件 │ └── scaler.json # 训练集的均值方差,必须跟模型同生共死 ├── requirements.txt └── README.md最容易被忽略的是artifacts/scaler.json。新手常常只存模型参数,线上推理时忘了做同样的标准化,输入特征量纲差了几十倍,输出自然离谱。我把 scaler 和模型放在同一个目录下,加载时强制要求两者都读出来,少一个就抛异常,相当于给自己上了一道保险。
| 文件 | 核心职责 | 二次开发时的改动频率 |
|---|---|---|
dataset.py | 特征工程、划分、标准化 | 高,换数据必改 |
model.py | 网络结构、前向反向 | 中,调结构才改 |
optim.py | 优化器与学习率策略 | 低 |
metrics.py | 业务指标 | 中,换口径必改 |
train.py | 训练编排 | 高 |
2.2 依赖与运行环境
环境刻意压到最小,只有四个第三方包,且scikit-learn完全不参与训练过程,只用来算指标和做 K 折,这样能保证「神经网络部分是自己写的」这件事不被稀释。
numpy>=1.24 pandas>=2.0 scikit-learn>=1.3 # 只做指标与 KFold,不做训练 matplotlib>=3.7 # 画 loss 曲线,可选装环境建议用虚拟环境而不是全局 pip,尤其是你机器上还跑着别的项目时:
python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt跑训练的命令行参数都收在argparse里,常用的就是数据路径、轮数、学习率和批次大小:
python -m src.train \ --data data/house.csv \ --epochs 300 \ --lr 1e-3 \ --batch-size 64 \ --hidden 64 32 \ --patience 30--hidden 64 32表示两个隐藏层,宽度分别是 64 和 32。想要更深就继续往后面加数字,但别指望层数越多越准——在几千条样本这个量级上,三层以上几乎必然过拟合,这是我踩过不止一次的坑。
提示:第一次跑建议先把
--epochs设成 5,确认数据能正常加载、loss 在下降,再放开跑全量。全量跑一次几十秒,但排查数据问题时那几十秒会很磨人。
2.3 一次训练的数据流:形状变化要心里有数
整个链路可以概括成一句话:CSV 进来是(N, 原始列数),编码标准化之后变成(N, D),模型内部的张量在(B, D) → (B, H1) → (B, H2) → (B, 1)之间流动,最后反标准化回到「万元」。
具体来说,dataset.py的build_features()负责把原始的十几列(面积、房龄、楼层、总层数、室厅数、朝向、区域等)拆成数值列加 one-hot 列,输出一个纯数值矩阵X和价格向量y。这里有个约定必须守住:特征列的先后顺序在整个项目里是固定的,训练存的顺序和推理时的顺序必须一模一样。我见过有人在推理脚本里重新pd.get_dummies了一遍,结果训练时有 13 个区,推理时只有 9 个区,列数对不上直接崩掉,或者更糟——列数碰巧对上了但顺序错了,模型不报错,只是安静地输出垃圾。
train.py拿到X, y之后做的第一件事是划分,第二件事是在训练集上fit 标准化器,第三件事才是分批送进模型。这个顺序不能反,第 5 章会细说反了会出什么事。
3. 数据预处理的三个关键环节:房价回归的成败八成在这里
做回归的人有个共识:模型结构调来调去,涨的那点分数,不如特征里补一列「距地铁站步行分钟数」来得实在。这份代码的dataset.py不算复杂,但它把回归任务里最容易翻车的三件事——缺失与异常、量纲、目标分布——都处理了,值得逐段看。
3.1 缺失值、异常值与价格的正数约束
房价数据最典型的脏法是:面积为 0、房龄是负数、成交价填了 0 或者空着。这些行要是直接进模型,梯度会被彻底带偏。
import numpy as np import pandas as pd NUM_COLS = ["area", "age", "floor", "total_floor", "room", "hall"] CAT_COLS = ["orientation", "district"] def load_and_clean(path: str) -> pd.DataFrame: df = pd.read_csv(path) # 数值列:先强转,转不动的变 NaN,再用中位数填 # 用中位数而不是均值,是因为面积、价格都是右偏分布,均值会被豪宅拉高 for c in NUM_COLS: df[c] = pd.to_numeric(df[c], errors="coerce") df[c] = df[c].fillna(df[c].median()) # 类别列:缺失单独成一类,不要直接 drop 掉整行 # 房源里「朝向未知」本身就是信息,删了反而丢样本 for c in CAT_COLS: df[c] = df[c].fillna("未知").astype(str) # 硬性业务约束:面积必须为正,价格必须为正 df = df[(df["area"] > 0) & (df["price"] > 0)].copy() # 面积做一次 1%/99% 分位截断,挡住录入错误的天价和 1 平米 lo, hi = df["area"].quantile([0.01, 0.99]) df["area"] = df["area"].clip(lo, hi) return df代码里的顺序是有讲究的:先强转再填缺失,不能反过来,否则字符串"--"会被当中位数算进去。分位截断放在最后,是因为截断的上下界本身要用清洗后的数据算。errors="coerce"这个参数是血泪经验,很多表里面积那一列混着"89.5㎡"这种带单位的字符串,不加它整列都会变成 object 类型,后面np.asarray直接报错。
价格必须为正这条约束看着废话,其实很关键——下一步要做log1p变换,价格 ≤ 0 会算出-inf,然后整个 loss 变 nan,你还得回头找是哪一行,非常浪费时间。
3.2 标准化与 log1p 目标变换:只在训练集上 fit
房价预测有两个量纲问题,一个是特征之间的,一个是目标本身的。
特征侧的坑在于面积动辄上百、房龄只有个位数、one-hot 全是 0/1,如果不标准化,面积这一维的梯度会比其他维度大两三个数量级,网络会几乎只学面积。常见做法是 z-score 标准化,让每维均值 0 方差 1。
目标侧的坑更隐蔽:房价是典型的右偏分布,大部分成交集中在两百万上下,但零星有几套两千万的豪宅。直接用 MSE 训练,这几套豪宅的残差平方会主导整个梯度,模型为了讨好它们把整体预测往上抬,结果就是普通房源系统性高估。取log1p之后分布接近正态,梯度贡献被压平,这是房价、租金、销量这类金额型目标的标准操作。
def split_and_scale(df, feature_cols, val_ratio=0.2, test_ratio=0.2, seed=42): rng = np.random.default_rng(seed) idx = rng.permutation(len(df)) n_test = int(len(df) * test_ratio) n_val = int(len(df) * val_ratio) test_idx = idx[:n_test] val_idx = idx[n_test:n_test + n_val] train_idx = idx[n_test + n_val:] X = df[feature_cols].to_numpy(dtype=np.float64) y = np.log1p(df["price"].to_numpy(dtype=np.float64)) # 目标取 log # 关键:统计量只从训练集算,验证/测试集只 transform x_mean, x_std = X[train_idx].mean(0), X[train_idx].std(0) + 1e-8 y_mean, y_std = y[train_idx].mean(), y[train_idx].std() + 1e-8 Xn = (X - x_mean) / x_std yn = (y - y_mean) / y_std return (Xn[train_idx], yn[train_idx], Xn[val_idx], yn[val_idx], Xn[test_idx], yn[test_idx], {"x_mean": x_mean, "x_std": x_std, "y_mean": y_mean, "y_std": y_std})+ 1e-8是防除零,某一列在训练集里全是同一个值时 std 会是 0,这种列本来就该在特征筛选阶段删掉,但加个保护能避免训练直接崩溃。
标准化器返回的字典要原样序列化进artifacts/scaler.json。还原价格时要老老实实按price = expm1(y_hat * y_std + y_mean)走一遍,顺序反了或者漏了expm1,你会发现预测值全是 1.x 这种鬼数字——那是标准化空间里的值,忘了反变换。
3.3 划分与批次构造
划分比例用的是 6:2:2,训练、验证、测试。验证集只用来做早停和选超参,测试集在整轮开发里最好只碰一次,碰多了它就不是测试集了,是另一个验证集。
分批的时候有个细节值得单独拎出来说:每轮开始前打乱索引,但用固定的种子序列,这样同一个 epoch 号跑两次结果完全一样,方便复现。
def make_batches(n, batch_size, epoch, seed=42): # 用 epoch 号派生种子:每轮顺序不同,但同一轮可复现 rng = np.random.default_rng(seed + epoch) idx = rng.permutation(n) for s in range(0, n, batch_size): yield idx[s:s + batch_size]最后一个 batch 可能不满batch_size,这时候不用补,因为反向传播里求的是2/n * (y_hat - y),除的是当前批次的实际样本数,批次大小不一致不会引入偏差。有人喜欢把不完整的批次丢掉,在样本量小的房价数据集上这是浪费数据,不推荐。
4. BP 神经网络核心实现:一个样本从进入到返回的完整链路
model.py是整个项目的重心,一共两百来行,分成初始化、前向、反向、预测四块。我建议读它的时候手边放张纸,把每层的形状写下来,比干看代码快得多。
4.1 网络结构与 He 初始化
结构是 13 → 64 → 32 → 1,两个隐藏层用 ReLU,输出层不加激活函数。为什么输出层不加?因为回归要的是任意实数,加了 sigmoid 输出就被压在 0~1 之间,反变换之后所有预测值会挤成一坨,这是新手最常犯的错,第 5 章会专门讲。
import numpy as np class BPNet: def __init__(self, sizes, l2=1e-4, seed=42): # sizes 例如 [13, 64, 32, 1] rng = np.random.default_rng(seed) self.sizes = sizes self.l2 = l2 self.W, self.b = [], [] for i in range(len(sizes) - 1): fan_in, fan_out = sizes[i], sizes[i + 1] # He 初始化:标准差 sqrt(2/fan_in),专为 ReLU 设计 # 用 0.01 这种小常数初始化,深层网络前向信号会指数衰减 self.W.append(rng.normal(0.0, np.sqrt(2.0 / fan_in), (fan_in, fan_out))) self.b.append(np.zeros((1, fan_out)))初始化的选择直接决定能不能训起来。如果用np.random.randn(...) * 0.01,第一层到第二层的信号方差会缩到原来的万分之一,反向传播时梯度同理,最后靠近输入的层几乎不动,看起来就像「网络学了个偏置」。He 初始化把方差按2/fan_in缩放,正好抵消 ReLU 把一半神经元置零带来的方差损失。
偏置初始化为 0 是安全的,因为对称性已经被随机的 W 打破了,不需要再给 b 加随机扰动。
4.2 前向传播:把每一层的中间结果存进 cache
前向传播除了算输出,还兼职干一件事:把每层的输入激活值缓存下来,反向传播要用。
def forward(self, X): acts = [X] # acts[i] 是第 i 层的输入 a = X for i in range(len(self.W) - 1): z = a @ self.W[i] + self.b[i] a = np.maximum(z, 0.0) # ReLU acts.append(a) out = a @ self.W[-1] + self.b[-1] # 输出层线性 acts.append(out) return out, actsacts的长度是层数 + 1。acts[0]是原始输入(B, 13),acts[1]是第一个隐藏层输出(B, 64),以此类推,最后一个是(B, 1)的预测值。
这里用np.maximum(z, 0.0)而不是z * (z > 0),前者在数值上更稳,也不会因为布尔乘法产生额外的临时数组。
注意:cache 会占内存,样本量上万、层数加深之后要留意。真要上大规模数据,改成只缓存必要层或者用梯度检查点,但在这个项目的量级上完全不用操心。
4.3 反向传播:一次链式法则的向量化展开
这是全篇最需要看懂的一段。核心是把「每个样本单独求导再求和」这件事,用矩阵乘法一次性算完。
def backward(self, acts, y): n = y.shape[0] L = len(self.W) gW = [None] * L gb = [None] * L # MSE 对输出层线性输入的梯度:2/n * (y_hat - y) delta = (2.0 / n) * (acts[-1] - y) # shape (n, 1) for i in range(L - 1, -1, -1): a_in = acts[i] # 第 i 层的输入 gW[i] = a_in.T @ delta + self.l2 * self.W[i] # L2 正则项 gb[i] = delta.sum(axis=0, keepdims=True) if i > 0: # 传回上一层:先乘权重转置,再过 ReLU 的 0/1 掩码 # acts[i] 就是第 i-1 层的 ReLU 输出,>0 的位置导数为 1 delta = (delta @ self.W[i].T) * (acts[i] > 0) return gW, gb逐点说明。delta一开始是 loss 对最后一层线性输出的偏导,形状(n, 1),n 是这批样本数。循环从最后一层往前,每一步做三件事:
a_in.T @ delta把(B, fan_in)和(B, fan_out)乘成(fan_in, fan_out),这就是权重梯度,本质是把 B 个样本的贡献累加;delta.sum(axis=0)把批次维度压掉,得到偏置梯度;- 把
delta传回上一层,先乘W[i].T完成「误差反传」,再乘 ReLU 掩码(acts[i] > 0),因为 ReLU 在负半轴导数为 0,那些被置零的神经元不参与梯度回传。
self.l2 * self.W[i]是权重衰减项,等价于在 loss 里加了l2/2 * ||W||²。房价这种特征少、样本少的数据集,加一点 L2 能明显缓解过拟合,1e-4是个比较温和的起点。
整段没有任何显式的 for 循环遍历样本,全靠矩阵乘法,这也是为什么纯 NumPy 的实现在几千条数据上已经跑得飞快。
4.4 Adam 更新与早停训练循环
优化器用的是 Adam,因为它对学习率的容忍度比 SGD 高得多,新手用1e-3基本不会炸。
class Adam: def __init__(self, params_W, params_b, lr=1e-3, beta1=0.9, beta2=0.999, eps=1e-8): self.W, self.b, self.lr = params_W, params_b, lr self.beta1, self.beta2, self.eps = beta1, beta2, eps self.mW = [np.zeros_like(w) for w in params_W] self.vW = [np.zeros_like(w) for w in params_W] self.mb = [np.zeros_like(x) for x in params_b] self.vb = [np.zeros_like(x) for x in params_b] self.t = 0 def step(self, gW, gb): self.t += 1 c1 = 1 - self.beta1 ** self.t # 一阶矩偏差修正 c2 = 1 - self.beta2 ** self.t # 二阶矩偏差修正 for i in range(len(self.W)): self.mW[i] = self.beta1 * self.mW[i] + (1 - self.beta1) * gW[i] self.vW[i] = self.beta2 * self.vW[i] + (1 - self.beta2) * (gW[i] ** 2) self.W[i] -= self.lr * (self.mW[i] / c1) / (np.sqrt(self.vW[i] / c2) + self.eps) self.mb[i] = self.beta1 * self.mb[i] + (1 - self.beta1) * gb[i] self.vb[i] = self.beta2 * self.vb[i] + (1 - self.beta2) * (gb[i] ** 2) self.b[i] -= self.lr * (self.mb[i] / c1) / (np.sqrt(self.vb[i] / c2) + self.eps)偏差修正那两个c1c2不能省。t=1 时一阶矩只有0.1 * g,不修正的话第一步几乎不动,前几十轮都在热身,看起来就像学习率设小了。
训练循环里最关键的是早停,而且必须回滚到验证集最优的那组权重:
def train(model, opt, Xtr, ytr, Xva, yva, epochs=300, bs=64, patience=30): best = {"loss": np.inf, "W": None, "b": None} bad = 0 n = Xtr.shape[0] for ep in range(1, epochs + 1): rng = np.random.default_rng(ep) idx = rng.permutation(n) for s in range(0, n, bs): bidx = idx[s:s + bs] out, acts = model.forward(Xtr[bidx]) gW, gb = model.backward(acts, ytr[bidx]) opt.step(gW, gb) val_loss = model.mse(Xva, yva) if val_loss < best["loss"] - 1e-6: best = {"loss": val_loss, "W": [w.copy() for w in model.W], "b": [x.copy() for x in model.b]} bad = 0 else: bad += 1 if bad >= patience: print(f"early stop @ epoch {ep}, best val {best['loss']:.5f}") break # 一定回滚,最后一个 epoch 的权重通常已经过拟合了 model.W, model.b = best["W"], best["b"] return model不回滚是个高频错误:很多人觉得「训练到最后应该最好」,但验证 loss 往往在第 80 轮就开始回升,跑到第 300 轮时模型已经把那几千条样本背下来了。w.copy()也不能省,直接存引用的话,后面每次更新都会把存档一起改掉,最后存下来的是最新权重而不是最优权重。
5. 避坑与排查:训练不收敛、预测全一样时该看哪里
下面五条是我在这个项目上真真切切翻车过或者帮别人定位过的问题,按出现频率排序。每条都按现象、原因、解决写,遇到时可以直接对号入座。
5.1 Loss 跑着跑着变成 nan
现象:前几十轮 loss 正常下降,突然某一轮开始打印nan,之后再也恢复不了。
原因:绝大多数是梯度爆炸。学习率给到0.1配 MSE,某个批次里恰好有几套高价房,梯度一下子冲到很大,权重更新过头,下一轮前向直接溢出成inf,inf - inf就是nan。还有一种可能是数据里残留了inf或者极大的异常值,标准化之后仍然是个天文数字。
解决:先降学习率,从1e-3试起,别一上来就0.1。其次在训练循环里加梯度裁剪,把每个参数的梯度范数压到 5 以内:
def clip_grads(grads, max_norm=5.0): for g in grads: norm = np.linalg.norm(g) if norm > max_norm: g *= max_norm / (norm + 1e-12) return grads再保险一点,在load_and_clean里加一句df = df.replace([np.inf, -np.inf], np.nan),把无穷值提前归到缺失值流程里去。
5.2 训练 loss 降得很好,但预测值几乎一模一样
现象:验证集上 RMSE 看着还行,可是随便挑十套房做预测,输出全在 210 万到 215 万之间,明显没学到东西。
原因:九成是输出层加了激活函数。有人在forward里给最后一层套了个sigmoid或者tanh,输出被压在 0~1 或者 -1~1,反标准化之后就是同一个数附近抖动。还有一种是目标没做反变换,直接拿标准化空间里的值当房价,看着也都是零点几。
解决:回归任务的输出层保持线性,什么激活都别加。检查forward最后一行是不是a @ self.W[-1] + self.b[-1],没有np.maximum、没有1/(1+exp(-x))。反变换那步写成函数单独测一遍,用一条已知数据手算对比:
def inverse_price(y_hat, y_mean, y_std): # 先还原标准化,再还原 log1p return np.expm1(y_hat * y_std + y_mean)拿训练集第一行试,inverse_price(yn[0], y_mean, y_std)应该约等于df["price"].iloc[train_idx[0]],对不上就说明变换链有问题。
5.3 验证集指标漂亮,上线后偏差离谱
现象:本地 RMSE 三万块,部署到实际业务里预测值系统性偏高或者偏低一大截。
原因:标准化的统计量用了全量数据。很多人图省事写成X = (X - X.mean(0)) / X.std(0),X里包含验证集和测试集,这就把未来信息泄漏进了训练过程。模型在本地评估时见过测试集的分布,分数自然虚高,真到线上面对全新数据就露馅。
解决:统计量一定只在训练集上fit,代码里体现为先切索引、再用X[train_idx].mean(0)。这个错误特别隐蔽,因为它不会报错也不会让 loss 变 nan,指标甚至更好看,属于典型的「看起来赚了其实亏了」。我后来养成一个习惯:任何涉及.mean()、.std()、.quantile()的地方,都要盯着看一眼括号里是不是train_idx。
5.4 用 accuracy 评估回归模型
现象:想给模型加个评估指标,顺手写了accuracy_score(y_true, y_pred),跑出来 0.02,以为模型废了。
原因:accuracy_score是分类指标,判断的是标签是否完全相等。房价是连续值,预测值哪怕只差一块钱也算错,所以准确率必然是接近 0 的。这不是模型的问题,是指标选错了。
解决:回归用 RMSE、MAE、MAPE,而且要在还原到「万元」量纲之后算,否则报出来的数字没人看得懂。
def regression_metrics(y_true_price, y_pred_price): err = y_pred_price - y_true_price rmse = np.sqrt(np.mean(err ** 2)) mae = np.mean(np.abs(err)) # MAPE 分母加保护,防止极端低价样本把指标炸掉 mape = np.mean(np.abs(err) / np.maximum(np.abs(y_true_price), 1e-6)) * 100 return {"rmse": rmse, "mae": mae, "mape": mape}业务上更关心 MAPE,因为它能回答「平均误差百分之几」。RMSE 对离群值敏感,用来看有没有个别样本预测崩了。
5.5 训练和推理的特征列对不上
现象:训练脚本跑得好好的,推理脚本一加载模型就报shapes not aligned,或者不报错但结果全是乱码。
原因:推理时重新对原始 DataFrame 做了一遍get_dummies。训练集里有 13 个区,线上这批房源只出现了 9 个区,编码出来的列数变成 9,跟模型的W[0]形状(13, 64)对不上。如果碰巧列数相等但顺序不同,就更麻烦,矩阵乘法能过,结果是错的。
解决:把训练时用到的feature_cols列表存进scaler.json,推理时先reindex(columns=feature_cols, fill_value=0)补齐再转数组。缺的列补 0(表示该类别不出现),多的列直接丢掉。这一招能彻底消灭维度不一致的问题,代价只是多存一个字符串列表。
6. 二次开发:把这份教学代码改成能跑的回归管线
原始代码适合学习,直接上生产还差几步。我一般按下面的顺序改造,每一步都能单独验证,改坏了好回退。
| 改造方向 | 具体改法 | 怎么验证没改坏 |
|---|---|---|
| 权重持久化 | np.savez存所有 W/b + scaler | 存完立刻加载,预测值必须逐位相同 |
| 交叉验证 | 用 5 折 KFold 跑 5 次,取指标均值 | 各折 RMSE 标准差应小于均值的 20% |
| 特征扩充 | 加「单价」「房龄×面积」等交叉特征 | 特征数变了要重训,不能拿旧模型套 |
| 服务化 | FastAPI 包一个/predict接口 | 用训练集前 10 行做请求,结果与离线一致 |
| 模型对比 | 同一份数据跑一遍 GBDT 当基线 | 神经网络不该比基线差太多,否则先查预处理 |
持久化这段代码最值得先做,因为它是后面所有改造的基础:
def save_artifacts(model, scaler, feature_cols, path_prefix="artifacts/model"): payload = {} for i, (w, b) in enumerate(zip(model.W, model.b)): payload[f"W{i}"] = w payload[f"b{i}"] = b np.savez(f"{path_prefix}.npz", **payload) import json with open("artifacts/scaler.json", "w", encoding="utf-8") as f: json.dump({"scaler": {k: v.tolist() for k, v in scaler.items()}, "feature_cols": feature_cols, "sizes": model.sizes}, f, ensure_ascii=False) def load_model(path_prefix="artifacts/model"): import json z = np.load(f"{path_prefix}.npz") meta = json.load(open("artifacts/scaler.json", encoding="utf-8")) model = BPNet(meta["sizes"]) for i in range(len(model.W)): model.W[i] = z[f"W{i}"] model.b[i] = z[f"b{i}"] return model, meta注意sizes也要一起存。有人只存了权重,加载时靠猜或者硬编码[13, 64, 32, 1],等特征从 13 维加到 16 维就全乱了。存sizes之后,加载逻辑自动适配新结构。
服务化那步我建议先用最简单的同步接口,别急着上批处理、缓存、模型热更新。房价预测的 QPS 通常很低,一个同步接口足够,把复杂度留给特征工程。
# 伪代码示意:请求进来先补列,再标准化,最后反变换 def predict_one(raw: dict) -> float: row = pd.DataFrame([raw]).reindex(columns=feature_cols, fill_value=0) x = (row.to_numpy(dtype=np.float64) - x_mean) / x_std y_hat, _ = model.forward(x) return float(np.expm1(y_hat.ravel()[0] * y_std + y_mean))上线前一定要做一次端到端对齐:拿训练集里的第 100 行原始数据(不是标准化后的),走一遍predict_one,结果应该和离线评估时那一行的预测值完全一致。差一点点都说明某处变换漏了或者重复了。这个检查我写成了脚本,每次改完特征都强制跑一遍,省下了不少排查时间。
最后给一条我踩过最深的路:别在第一次改造时同时动特征和网络结构。有一次我一边加特征一边把隐藏层从[64, 32]调成[128, 64, 32],结果指标掉了 8 个点,花了一下午才搞明白是新加的那列特征里有大量缺失被填成 0 导致的,跟网络结构一点关系没有。从那以后我每次改代码都强制走一遍「一次只动一个变量 + 留一份基线指标做对照」的流程,哪怕慢一点,也比返工强。希望帮到你。
本文还有配套的精品资源,点击获取