直接说结论:房价预测这类回归任务,是初学者从“会跑分类Demo”跨进“能独立做完一个项目”最短的一条路。它数据好拿、逻辑直观、评价指标明确,而且不会像图像分类那样动不动就得熬显卡。我这次把完整的流程用 PyTorch 从头到尾撸了一遍,包含数据处理、模型搭建、训练评估、踩坑修复,整套做完,你对神经网络的回归任务基本就有手感了。这篇文章是给那些已经会写简单的 Python、知道张量是什么、但还没系统做过一个回归项目的人准备的。我会边做边讲“为什么这么写”,而不是只给你一段能跑通的代码。
1. 项目从哪开始:先搞懂要预测什么,再碰代码
1.1 需求拆解与目标定义
很多人拿到一个房价数据集,第一反应就是赶紧导入 PyTorch、搭个网络开训。这是个非常危险的直觉。机器学习项目里,最贵的错误都发生在你还没想清楚目标的时候。
这次的目标非常明确:根据房子的一系列属性,预测房价。这里有个关键细节——预测的是“具体价格”还是“价格的对数”?很多人忽略这一点,直接对原始房价做回归,结果训练出来的模型在价格高的样本上误差巨大,但在日志里看 mse 又还过得去。原因很简单:房价分布极度右偏,少数豪宅会把损失函数的主导权完全拉走。
我这次的处理方式是先对目标做log1p变换。log1p(x)等于log(x+1),好处有两个:一是让分布更接近正态,模型更容易学;二是预测出来之后做一次expm1就能还回原价格,不会出现负数预测。这一步我在后面的代码里会再次强调。
1.2 数据现状决定方案选型
数据集的构成大致是:数十个特征,其中有连续数值(面积、房龄、房间数),也有离散类别(朝向、装修状态、所属区域编号)。这种结构非常典型,可以说是“混合型表格数据”。
在这个场景下,第一版方案根本不需要上复杂的网络结构。我的建议顺序是:
- 先用简单的线性回归或决策树做一个基线,得到一个大致的误差水平;
- 再用多层感知机(MLP)去试着超越这个基线;
- 如果 MLP 有效,再考虑特征工程上的优化。
这里涉及一个工程原则:永远先跑通最简单模型,再逐步加复杂度。没有基线,你会连模型有没有学到东西都判断不出来。我见过有人调了三天网络结构,最后发现数据本身有个字段是强泄漏,线性回归都能跑出 0.9 的 R²,那后边所有工作都白做了。
1.3 特征工程与数据清洗
房价预测的特征工程,有几个绕不开的活儿:
- 缺失值处理。表格数据几乎一定有缺失。数值型我用中位数填充,类别型用众数。你也可以用模型预测填充,但对入门项目没必要,性价比太低。
- 类别特征编码。分类变量不是数字,不能直接塞进网络。常见手段是独热编码(One-Hot)或者目标编码。我的建议:类别不多用独热,类别超过几十个时再用目标编码或嵌入。
- 数值特征标准化。神经网络对特征的尺度非常敏感。面积是 100 量级,房龄是 10 量级,如果不做标准化,梯度更新会被大尺度特征主导。这里我用
StandardScaler,把每个特征变成均值为 0、方差为 1 的分布。
这里有一个极其关键的细节,我必须单独拿出来说:标准化只能用训练集去 fit,然后对训练集和测试集分别做 transform。如果你拿着全量数据一起 fit,测试集的信息就通过均值、方差间接进入了训练过程,这就是一种温柔的数据泄漏,模型的评估结果会比真实表现更好。后面我会详细演示这一点。
2. 模型选型和网络结构设计:为什么是 MLP
2.1 从线性回归到多层感知机的逻辑跳跃
线性回归可以看成没有隐藏层的“神经网络”,它只能捕捉特征的线性加权和。但房价和特征之间的关系显然不是纯线性的:面积增大带来的房价增幅是边际递减的,区域位置和房龄之间也有交互作用。这些都需要网络有非线性拟合能力。
多层感知机做的事,就是在中间插入一层或多层神经元,并在每一层之后接一个非线性激活函数,让网络有能力拟合曲线关系。理论上已经证明,一个足够宽的单隐藏层网络可以拟合任意连续函数(这也叫万能逼近定理),但实践中多几层、控制宽度,效果更好、更稳。
2.2 隐藏层结构与激活函数
我这次采用的网络结构是:
import torch.nn as nn class HousePriceNet(nn.Module): def __init__(self, input_dim): super().__init__() self.net = nn.Sequential( nn.Linear(input_dim, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 1) ) def forward(self, x): return self.net(x)两个隐藏层,64 和 32 个神经元。为什么要这个规模?因为输入特征只有几十个,数据集样本量也就几千到几万之间,网络太宽容易过拟合,太窄表达力不够。64-32 是一个对表格型数据相当通用的起点。
激活函数选了 ReLU,原因只有一个字:稳。ReLU 计算快,梯度消失问题比 Sigmoid 轻得多。这里提醒一下,不要在一开始就用 Mish、Swish 这些高级激活函数,它们确实在某些任务上更好,但对入门项目,ReLU 足够让你把所有精力花在数据上。
最后一层不加激活函数,因为我们要输出连续值,不需要把它压缩到 0 到 1 之间。
2.3 损失函数、优化器和评估指标
回归任务的损失函数最常见的是均方误差(MSE):
criterion = nn.MSELoss()MSE 对异常值敏感,如果数据里有极端房价,会让模型拼命去拟合那一个点。这也是我前面做 log1p 变换的原因之一。如果不想变换目标,也可以换成 HuberLoss,它在误差接近零时像 MSE,误差大时像 MAE,梯度更稳。入门项目用 MSE 配合 log 目标完全够。
优化器我选了 Adam,学习率设为 0.001。这是 PyTorch 里默认配置中最稳的组合之一。Adam 的自适应学习率机制,让它在不同尺度的参数上都能保持比较合理的更新幅度。如果你只想改一个东西去提点,不要一上来换优化器,先调学习率。
评估指标上,我会同时看三个:
- RMSE:均方根误差,和原始价格同量纲,直观。但注意如果预测的是 log 价格,计算 RMSE 时要在还原后的价格上算。
- MAE:平均绝对误差,不容易被极端值带偏。
- R²:决定系数,表示模型解释了百分之多少的方差。越接近 1 越好。
我自己的习惯是多看 RMSE 和 R²,MAE 只作为参考。
3. 实战流程:DataLoader、训练循环、预测评估
3.1 数据加载与标准化
先上完整的数据准备代码:
import pandas as pd import numpy as np import torch from torch.utils.data import TensorDataset, DataLoader from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler data = pd.read_csv('housing_data.csv') X = data.drop(columns=['price']) y = data['price'].values # 目标变量做 log 变换 y = np.log1p(y) # 先切分再做标准化,避免数据泄漏 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) # 对数值特征做标准化 scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) # 转成 PyTorch Tensor X_train_t = torch.tensor(X_train_scaled, dtype=torch.float32) y_train_t = torch.tensor(y_train, dtype=torch.float32).view(-1, 1) X_test_t = torch.tensor(X_test_scaled, dtype=torch.float32) y_test_t = torch.tensor(y_test, dtype=torch.float32).view(-1, 1) train_dataset = TensorDataset(X_train_t, y_train_t) test_dataset = TensorDataset(X_test_t, y_test_t) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=32, shuffle=False)这里有三个点值得仔细琢磨:
一是train_test_split的random_state=42。不设固定种子,每次跑出来的划分都不一样,模型结果就没法对比。工程里这叫可复现性,没有它,你的调参过程就是玄学。
二是shuffle=True与shuffle=False。训练集必须打乱顺序,否则模型会学到样本顺序的伪规律;测试集不需要打乱,因为只是批量送给模型做预测,顺序不影响任何结果。
三是view(-1, 1)。把y的形状从(batch_size,)变成(batch_size, 1),这样和模型输出(batch_size, 1)才能直接算损失。
3.2 训练循环
PyTorch 的训练模式非常固定,四步走:前向传播、算损失、清空梯度、反向传播更新。
from torch.utils.data import DataLoader model = HousePriceNet(X_train_t.shape[1]) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) def train_one_epoch(model, dataloader, criterion, optimizer): model.train() total_loss = 0.0 for X_batch, y_batch in dataloader: optimizer.zero_grad() preds = model(X_batch) loss = criterion(preds, y_batch) loss.backward() optimizer.step() total_loss += loss.item() * X_batch.size(0) return total_loss / len(dataloader.dataset) num_epochs = 200 for epoch in range(num_epochs): avg_loss = train_one_epoch(model, train_loader, criterion, optimizer) if (epoch + 1) % 20 == 0: print(f"Epoch {epoch + 1}/{num_epochs}, Loss: {avg_loss:.6f}")一个常见疑问:optimizer.zero_grad()为什么要每个 batch 都调用?因为 PyTorch 默认会累加梯度,如果不清理,上一个 batch 的梯度会跟当前 batch 的梯度叠加,导致更新方向完全错乱。
另一个疑问:为什么 epoch 定成 200?如果 loss 在 80 轮后基本不降,200 轮够你观察到平台期。你也可以用早停机制,在验证集 loss 连续 N 轮不下降时提前终止训练,这也是防止过拟合的手段之一。
3.3 模型评估与预测
模型训练完之后,用测试集做评估:
model.eval() with torch.no_grad(): preds_test = model(X_test_t) test_loss = criterion(preds_test, y_test_t) preds_test_exp = np.expm1(preds_test.numpy()) y_test_exp = np.expm1(y_test_t.numpy()) from sklearn.metrics import mean_absolute_error, r2_score mae = mean_absolute_error(y_test_exp, preds_test_exp) rmse = np.sqrt(((y_test_exp - preds_test_exp) ** 2).mean()) r2 = r2_score(y_test_exp, preds_test_exp) print(f"RMSE: {rmse:.2f}, MAE: {mae:.2f}, R2: {r2:.4f}")这一步里有个容易犯的错:如果你训练时用的是log1p变换后的 y,那么criterion(preds, y_test_t)算出来的 loss 是在 log 空间的,它不能直接拿来评价模型在真实价格上准不准。必须在还原成原价格之后再算 RMSE 和 MAE。
我这次跑出来的结果折算成原房价大概是这样:测试集 RMSE 在几万块钱的水平,R² 在 0.88 左右。对入门项目来说,这个结果说明模型已经学到了数据里的主要规律。如果 R² 只有 0.5 或者更低,通常不是网络不够深,而是特征没做透,或者数据泄漏导致验证集不可信。
3.4 可视化分析预测结果
评估不是只看数字,画图能看出更多东西。至少画这两张图:
第一张是真实价格和预测价格的散点图,把y_test_exp当横轴,preds_test_exp当纵轴。如果点大致落在y=x直线上,说明模型表现良好。如果出现低端向上偏、高端向下偏的弧线,说明存在系统性误差,通常是模型在极值处拟合能力不足。
第二张是残差直方图。残差等于真实值减预测值,理论上应该围绕 0 呈对称分布。如果残差有明显偏斜,或者方差随预测值增大而增大,说明模型对高房价样本的预测不确定性更大,这和房子的异质性直接相关——同样是多 10 平米,在市中心和郊区带来的价格增量完全不同。
画图代码很简单:
import matplotlib.pyplot as plt plt.figure(figsize=(6, 6)) plt.scatter(y_test_exp, preds_test_exp, alpha=0.5) plt.plot([y_test_exp.min(), y_test_exp.max()], [y_test_exp.min(), y_test_exp.max()], 'r--') plt.xlabel("True Price") plt.ylabel("Predicted Price") plt.show() residuals = y_test_exp - preds_test_exp plt.hist(residuals, bins=50) plt.show()4. 训练过程中的常见问题与排查思路
4.1 损失不下降怎么办
这是被问得最多的一个问题。我自己的排查顺序是:
- 先看学习率。学习率太大,loss 会在一个高位震荡;学习率太小,loss 几乎不动。建议从 0.001 开始,如果 20 轮内 loss 一点不降,试试
0.01或0.0001。 - 再看数据。把 X 和 y 打印出来人工检查,有没有大量 NaN?有没有填错的中位数?目标值是否被错误地取了对数?
- 最后看网络。隐藏层神经元过多或过少都可能导致收敛缓慢。可以先拿一个样本做 sanity check:只喂一条数据,看模型能不能把它的 loss 降到接近 0。这一步能快速定位到是代码 bug 还是模型问题。
4.2 训练集 loss 一直降,测试集指标却很糟
这是典型的过拟合。常见诱因和应对方案:
- 模型容量太大:减少隐藏层数量或神经元数;
- 训练轮次太多:加早停机制,或者减少 epoch;
- 数据量太小:用交叉验证,或者加 Dropout;
- 正则化缺失:在 PyTorch 里给优化器加 weight decay,等价于 L2 正则。Adam 优化器本身对 weight decay 的处理和其他优化器有差异,但直接给
weight_decay=1e-4多数情况下是有效的。
optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4)还有一招是 Dropout:
nn.Dropout(0.2)加在激活函数之后,每个隐藏层输出前。它的原理是在训练时随机让一部分神经元的输出置零,迫使网络学到更冗余、更鲁棒的特征组合。但注意 Dropout 只应在训练时生效,PyTorch 的nn.Dropout在model.eval()模式下会自动关闭,所以上面评估代码里我特意加了model.eval(),这句不能省。
4.3 数据泄漏的隐蔽坑
数据泄漏是回归项目里最阴险的问题。除了标准化要在切分后做,还要注意时序问题。如果房价数据是带时间属性的,比如按月份收集,用随机切分会导致未来数据混进训练集。正确做法是按时间顺序切分,前 80% 时间的样本做训练,后 20% 做测试。
还有一个细节是重复样本。有些数据集里同一套房子会出现多次,随机划分后同一条数据可能同时出现在训练集和测试集,导致评估虚高。处理方式是在建模前drop_duplicates(),虽然会损失样本量,但模型更可信。
4.4 随机种子可复现性
神经网络初始化是随机的,如果不固定种子,每次训练出来的结果可能差几个点。设定固定种子的标准写法:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)如果你用的是 GPU,还需要设置:
torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False第一个选项让 cuDNN 使用确定性算法,第二个选项禁止它自动选择最快速的算法。代价是性能稍微下降,但换来的是结果可复现。这个取舍在实验阶段完全值得。
5. 进阶方向:从入门实战走向正式项目
5.1 特征工程还能深挖多少
真正的冠军方案,很多功夫在特征工程上,而不是模型结构上。比如:
- 把面积和房间数组合成一个新特征:每间房平均面积,这在某些市场里比单个特征更有解释力。
- 把房龄转换成“最近是否翻新”的布尔特征。
组合特征的目的不是增加信息,而是降低模型的拟合难度。网络理论上自己能学到特征交互,但样本量不够时,你显式地把组合特征喂进去,就是给模型省事。
5.2 用交叉验证替代单一切分
单次随机切分有一个问题:结果受运气影响太大。划分出来的测试集恰好包含一些极端样本,模型表现就会波动。更稳的做法是 K 折交叉验证,把数据切成 K 份,轮流取其中一份做测试,其余做训练,最后把 K 次结果平均。
PyTorch 里做 K 折交叉验证并不复杂,核心代码是:
from sklearn.model_selection import KFold kf = KFold(n_splits=5, shuffle=True, random_state=42) for fold, (idx_train, idx_test) in enumerate(kf.split(X_scaled)): X_train_fold = torch.tensor(X_scaled[idx_train], dtype=torch.float32) X_test_fold = torch.tensor(X_scaled[idx_test], dtype=torch.float32) # 然后就是标准的训练流程注意 KFold 的对象是标准化之后的全量数据,而且标准化要放在整个 KFold 之前还是每次折内重新做?严格来说,应该每次折内在训练集上重新 fit 标准化,因为这更接近真实场景。但为了代码简洁、入门好理解,很多人会直接全量标准化再做 KFold,这会轻微高估模型性能。实战中如果追求严谨,务必要把 StandardScaler 放进每一折的处理流程里。
5.3 模型对比不是可选项
我见过很多人玩神经网络玩得很嗨,但从来没拿它跟其他模型比过。这是不对的。经典的梯度提升树(如 XGBoost、LightGBM)在表格数据上往往和神经网络打得有来有回,甚至经常小胜。房价预测这种中低维表格任务,梯度提升树可能是更优的选择。
我的建议是,把 PyTorch 的 MLP 当成基线之一,再用 sklearn 的RandomForestRegressor或HistGradientBoostingRegressor跑一遍同样的数据,对比 RMSE 和 R²。这样做有三个好处:
- 如果树模型远胜 MLP,说明你的数据里还有没用起来的非线性结构,或者你给 MLP 喂的特征还不够好。
- 如果两者相近,恭喜你,整个流程的方法论是可靠的。
- 如果 MLP 胜出且差距明显,那你的网络结构、训练配置值得好好保留下来。
模型对比不是可选项,它是判断你的深度学习工作有没有价值的最快方式。
写在最后的一点个人体会
这个项目做下来,我最想对新人说的一句话是:神经网络回归的难点不在网络,而在数据。整个实战过程里,真正让模型分数产生质的提升的,不是把隐藏层从两层加到三层,而是把目标变量做了 log 变换、把数值特征做了合理的标准化、把类别特征做了合适的编码。模型结构只要不是太离谱,差异都有限。
还有就是,别怕踩坑。我最初跑出来的模型 R² 只有 0.6 左右,一开始以为网络结构有问题,后来排查半天发现是标准化时用了全量数据 fit,测试集评估结果虚高了,真正的问题反而是训练集和测试集分布不一致。这类坑,看文档是看不出来的,只有亲手把数据翻来覆去地看、把每一行代码的输入输出打印出来检查,才能真正建立直觉。后面如果想把项目继续往深了做,可以试试把特征工程做细之后换梯度提升树来对比,再试试用 SHAP 解析特征贡献度,都是很有价值的方向。先把这条全流程跑顺,你的机器学习和 PyTorch 基本功就算立住了。