波士顿房价预测大概是每一个接触机器学习和深度学习的开发者都绕不过去的入门题,我在不同阶段拿它练过好几次手。一开始用 Scikit-learn 跑线性回归,后来用 Keras 搭多层感知机,再用 PyTorch 重写一遍时,才发现这个看似简单的案例里其实藏着不少值得琢磨的细节。
这篇文章不打算讲那些教科书里已有的大道理,而是把我用 PyTorch 重新实现波士顿房价预测时的完整思路、关键代码、训练陷阱和排查经验一次性说清楚。无论你是刚转过来学 PyTorch 的新手,还是已经有一段时间没碰过回归任务的开发者,这篇文章应该都能让你少走一些弯路。
1. 为什么是波士顿房价:被低估的回归入门任务
很多初学者容易陷入一个误区,觉得回归任务比图像分类简单,不就是输入特征输出一个数值。实际上回归问题在工程落地中远比分类更常见,从销量预测、风控评分到工业参数估计,本质上都是回归。波士顿房价这个数据集最难得的地方在于它的尺寸刚好合适——506个样本、13个特征、1个连续目标值。
这个规模意味着训练很快,CPU上几十秒就能跑完一个完整的实验周期,非常适合用来理解框架的核心机制。我见过不少同学一上来就拿 ImageNet 级别的大模型练手,结果连数据加载的 API 都没搞清楚就被 GPU 显存和训练时长劝退了。波士顿房价这种小而美、五脏俱全的数据集,才是真正适合拆开揉碎去研究 PyTorch 工作机制的好素材。
PyTorch 里跑通这个案例,你至少能掌握以下几项核心能力:Dataset 和 DataLoader 的基本用法、数据标准化的标准流程、自定义模型网络结构、训练循环的手写方法、MSE 损失函数和优化器的搭配逻辑。这些能力迁移到其他任何项目都是通用的,框架可以换,但这一整套思维方式不会变。
还有个很容易忽视的问题:波士顿房价数据集的下载其实已经不那么“官方”了。原始数据集因为涉及一些敏感信息,已经从一个常用的公开数据集中被移除了。好在 UCI 机器学习库上仍然保留了原始版本,也可以通过一些第三方镜像获取。这也是我建议你在动手前先解决的一个前置问题,后面会详细展开。
2. 数据准备:决定模型上限的隐形环节
2.1 拿不到数据怎么办:数据集获取的几种可行方案
我先说一个实际操作中很多人会卡住的问题:波士顿房价数据集在常用的sklearn.datasets.load_boston接口里已经不可用了。如果你沿用老教程里的代码,程序会直接抛异常。网上能找到的解决方案五花八门,但真正可靠的无非这几种。
最稳妥的方法是从 UCI 机器学习的公开仓库下载原始文件,文件格式是 data,本质上就是一行一个样本的纯文本数据。第 6 列是目标值,也就是房价的中位数。也可以用一些第三方维护的镜像仓库,但这类镜像的更新频率和维护状态不一,建议下载后先用后续提到的检查方法验证一下数据的完整性。
我的建议是你把它当成新项目的第一步来对待:先确认数据来源,再写加载代码。很多项目的失败看起来很随机,其实从数据环节就已经埋下了隐患。
2.2 只做标准化够吗:数据预处理的标准流程
拿到数据以后,下一步就是预处理。波士顿房价数据集的 13 个特征量纲差异非常大,CRIM 这个特征表示犯罪率,数值可能只有个位数,而 TAX 表示房产税率,数值可以达到几百甚至上千。如果直接把原始数据丢进模型,数值大的特征会在梯度计算中占据主导地位,模型的收敛速度和最终精度都会受影响。
我这里的做法是标准化,也就是把每个特征减去均值再除以标准差,让它们都落在零均值、单位方差附近。这个操作本质上是对特征做了一次线性变换,并不会改变数据内部的分布关系,但对于基于梯度的优化算法来说,这一步几乎可以说是必需品。
具体到 PyTorch 的实现,很多人会犯一个错误:在 Dataset 里直接对全量数据调用均值方差计算。标准化参数应该在划分训练集和测试集之前,只基于训练集部分计算,然后再应用到训练集和测试集上。这里面的逻辑是,测试集是用来模拟未来未知数据的,如果你把测试集的信息也透露给了模型,那评估结果就会偏乐观,失去了真实的参考价值。
2.3 划分数据集时的四个注意事项
训练集和测试集的划分看似简单,往往也有讲究。我习惯用 8:2 的比例,也就是 80% 的数据用于训练,20% 用于测试。在这个数据集上,大致对应 404 个训练样本和 102 个测试样本,对一个单隐层的小网络来说完全够用。
划分时需要格外注意几点:一是必须设置随机种子,否则每次运行代码得到的数据划分都不一样,实验结果就无法复现;二是要打乱数据顺序,原始数据可能存在某种隐含的顺序结构,不打乱的话会把这种结构泄漏给模型;三是对验证集的使用要慎重,如果你想调参,最好从训练集里再切出一部分当验证集,不要拿测试集反复尝试;四是如果你的数据存在明显的不平衡分布,应该考虑按目标值分层抽样。
之前我在一个实际项目中遇到过这种问题:因为数据划分没有固定随机种子,前后两次训练虽然模型结构一样,但测试分数跳来跳去,折腾了大半天才发现是数据划分的问题。
3. 模型设计:回归任务的网络结构到底该怎么搭
3.1 为什么要用多层感知机而不是别的主流模型
波士顿房价数据集是典型的表格数据,特征之间没有像图像那样的空间结构,也不存在像文本那样的时序依赖,所以卷积神经网络、循环神经网络这些结构在这里并不适用。表格数据用多层感知机是完全合理的选择,用更复杂的结构纯属给自己找麻烦。
那层数和隐藏层神经元个数该怎么定呢。我的经验是从简单开始,不要一上来就堆复杂结构。对 13 个输入特征这样的规模,单隐藏层网络就已经足够拟合。一个常被人误解的原则是:深度并不天然比宽度好,尤其是在数据量有限的情况下,过深的网络反而更容易过拟合。
我最终设计的结构是输入层 13 个特征,映射到隐藏层 32 个神经元,激活函数用 ReLU,然后从 32 个神经元映射到输出层 1 个数值。这个结构只有两层线性变换加一次激活函数,参数量非常小,在 CPU 上训练也就是几秒钟的事,效果却相当稳定。
3.2 激活函数的选择:回归场景下的默认选项
隐藏层激活函数我用的 ReLU,这个选择基本没有任何悬念。ReLU 计算成本极低,没有指数或除法运算,而且在正区间梯度恒为 1,能有效避免梯度消失问题。市面上常见的替代品,比如 tanh 和 sigmoid,虽然在某些场景下也有用,但在隐藏层使用 tanh 会导致输出范围被限制在 -1 到 1 之间,对于潜在范围更大的数值输出场景,需要对网络结构和参数初始化做更多的配套调整,初学者不建议碰。
这里特别提醒一点:输出层的激活函数绝对不要加 ReLU,也绝对不要加 sigmoid。回归任务的输出层通常就是一个普通的线性层。加了额外约束之后,你的模型输出范围就被框死了,遇到超出范围的房价标签就只能眼睁睁看着模型学不出来。
我见过有些教程为了让“网络更复杂”而在输出层强行加激活函数,那本来能轻松收敛到不错精度的模型,最后的损失曲线直接变成一条水平线。这是个非常基础但是经常被忽略的细节。
3.3 损失函数和优化器:回归任务的黄金组合
回归任务的默认损失函数是均方误差,PyTorch 里对应nn.MSELoss()。MSE 的主体思想很直白:算预测值和真实值之差的平方,再取平均。平方操作会让误差大的样本在损失里占比更高,相当于对离群值更敏感,这也促使模型优先去拟合那些偏差大的样本。
有的开发者会问:为什么不直接用绝对误差 MAE。MAE 对离群值更鲁棒,但它的梯度在零点不可导,优化过程不如 MSE 平滑。在波士顿房价这个数据集上,目标值范围大概在 5 到 50 之间,并没有极端离谱的离群值,用 MSE 是最稳妥的选择。
优化器我用的是随机梯度下降。考虑到数据集只有 506 个样本,简单配置一个固定的学习率和冲量就完全足够。Adam 这类自适应学习率的优化器当然也能用,而且通常收敛更快,但如果你正在学习 PyTorch,我建议先用 SGD 把优化器和梯度更新的直觉建立起来,再用 Adam 对比效果。
4. 核心实现:从数据构建到模型训练
4.1 加载和解析数据的完整代码
结合前面提到的数据源,实际数据文件路径可以根据自己的目录调整。这里我直接对一个本地文件进行读取,并对数据做了解析。
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_path = "housing.data" raw_data = [] with open(data_path, "r") as f: for line in f: if line.strip() == "": continue elements = line.split() raw_data.append([float(item) for item in elements]) data = np.array(raw_data) X = data[:, :13] y = data[:, 13] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test)这里有个小坑需要说明:原文件的末尾可能有空行,逐行读取时如果不做空行过滤,后面转 float 就会报错。习惯上,我会在解析的每一步都加验证,数据量小,并不会影响效率。
这里面数据读取部分因为原始文件的格式原因,很多人第一次接触时容易一头雾水。我从实际解析逻辑来看,每一行包含 13 个特征和紧跟着的 1 个目标值,所以按空白字符切分并全部转成 float 就是一个干净的处理方式。
4.2 封装 Dataset 和 DataLoader
PyTorch 的数据加载流程和纯 NumPy 流程最大的差别在于,你需要把数据组织成框架认识的格式。对于这种直接读入内存的小数据集,用 TensorDataset 封装就够了。
你需要注意一个关键的细节:特征张量和标签张量的数据类型必须是torch.float32,而默认的 NumPy 浮点数可能是 float64。PyTorch 的模型参数默认是 float32,如果输入是 float64 而参数是 float32,类型不匹配会导致程序直接报错,或者在某些版本里静默地做出类型转换,造成意料之外的行为。
标签的形状也需要处理。如果你只传入一个一维数组,有些损失函数在某些版本的 PyTorch 里会报维度不匹配的错误。稳妥的做法是直接把它变成和输出形状一致的二维张量,也就是(样本数, 1)。
X_train_t = torch.tensor(X_train, dtype=torch.float32) y_train_t = torch.tensor(y_train, dtype=torch.float32).view(-1, 1) X_test_t = torch.tensor(X_test, 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) batch_size = 32 train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)shuffle 参数只对训练集打开,测试集保持顺序不变就足够。因为测试集不参与梯度计算,不需要打乱,只需要一遍遍前向传播算指标就行。
一个小经验:batch_size 的选择不要太大也不要太小。在这个数据量上,我用 32 比较合适。batch_size 太大会导致每个批次梯度的噪声很小,但迭代次数也变少,模型容易陷入局部最优。batch_size 太小则每个批次代表性不足,训练过程震荡会比较剧烈。
4.3 模型定义与初始化的细节
PyTorch 中定义网络有两种风格:一种是写一个继承nn.Module的类,另一种是直接用nn.Sequential组件堆叠。对于这种 13→32→1 的线性网络,我用nn.Sequential就够了,简洁直观。
model = torch.nn.Sequential( torch.nn.Linear(13, 32), torch.nn.ReLU(), torch.nn.Linear(32, 1), )有一点值得注意:PyTorch 的nn.Linear默认会自动初始化权重和偏置,权重初始化方式是均匀分布。对这个问题来说,默认初始化完全够用。自己手动做初始化容易把分布范围弄错,反而拖慢收敛速度。
如果想对模型的工作机制有更多好奇,可以在定义完模型后打印一下模型的结构和参数:
print(model)输出会清楚展示每一层的名称和输出形状,这对新手理解数据的流动非常有帮助。
4.4 训练循环:从零手写的关键环节
训练循环是整个 PyTorch 入门中最需要理解的部分,它也是与高层封装框架差异最大的地方之一。很多从 Keras 转过来的开发者,初接触 PyTorch 时最大的不适应点就在这里:一切都要自己控制。
一个训练周期大概有下面几个步骤:把优化器梯度清零、前向传播算预测值、用损失函数算误差、反向传播计算梯度、优化器执行一步更新参数。五个步骤,一步都不能少。
epochs = 100 learning_rate = 0.05 momentum = 0.9 criterion = torch.nn.MSELoss() optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate, momentum=momentum) train_losses = [] for epoch in range(epochs): model.train() running_loss = 0.0 for batch_X, batch_y in train_loader: optimizer.zero_grad() outputs = model(batch_X) loss = criterion(outputs, batch_y) loss.backward() optimizer.step() running_loss += loss.item() * batch_X.size(0) epoch_loss = running_loss / len(train_loader.dataset) train_losses.append(epoch_loss) if (epoch + 1) % 20 == 0: print(f"Epoch [{epoch + 1}/{epochs}], Loss: {epoch_loss:.4f}")这里有一个非常容易犯的错:忘记调用optimizer.zero_grad()。PyTorch 的梯度是累积的,如果不做清零,下一次反向传播会把这次的梯度加到上次的梯度上。累积到一定程度以后,梯度值会变得很大,训练过程直接发散。
你可以试验一下把zero_grad注释掉,观察损失曲线的变化,这会让你对“梯度从哪里来、到哪里去”有更直观的理解。
学习率的选择也值得多说一句。我建议从 0.05 起步,观察损失下降速度。如果损失震荡剧烈,就降一个量级,比如到 0.005。如果损失下降太慢,就适当加大。这个调参过程是深度学习训练中不可回避的基本功,一次一次试出来的经验最可靠。
4.5 验证与评估:看 MAE 比 MSE 更直观
模型训练完以后,我们要在测试集上评估它的表现。很多人直接打印测试集上的 MSE 就完事,但我更推荐同时计算平均绝对误差 MAE。MAE 的单位和房价本身的单位一致,可以直接理解为“平均预测偏差了 X 千美元”。
model.eval() with torch.no_grad(): y_pred = model(X_test_t) test_loss_mse = criterion(y_pred, y_test_t).item() test_mae = torch.mean(torch.abs(y_pred - y_test_t)).item() print(f"Test MSE: {test_loss_mse:.4f}") print(f"Test MAE: {test_mae:.4f}") # 随机挑几个样本对比一下 for i in range(5): true_val = y_test_t[i].item() pred_val = y_pred[i].item() print(f"真实值: {true_val:.2f}, 预测值: {pred_val:.2f}")评估模式model.eval()和torch.no_grad()这两个操作缺一不可。前者会影响 BatchNorm 或 Dropout 等层的行为,虽然当前模型里没有这些层,但要养成习惯。后者是告诉 PyTorch 不需要记录梯度,可以显著减少内存占用、加速前向传播。在这个小数据集上你可能感觉不明显,放到大模型上差异就非常明显。
我在这套配置下跑出来的测试 MSE 大概在 12 到 16 之间,测试 MAE 大概在 2.2 到 2.8 之间,也就是模型预测房价平均偏差两千多美元。对一个用 CPU 跑几十秒的小模型来说,这个精度已经相当可用了。
顺带说一句,也不要过分追求把训练误差压到极低。这是个 506 样本的小数据集,拟合到测试 MAE 低于 2 往往伴随着过拟合。当训练损失持续下降而验证或测试损失开始上升,就是过拟合的信号,后面会专门展开。
5. 训练过程可视化:用损失曲线说话
损失曲线是我在所有深度学习入门实验里最推荐画的图,它能让你一眼判断出模型是欠拟合、过拟合还是刚刚好。初学者最容易犯的错误是只盯着最终打印出来的数字,却忽略训练过程中发生了什么。
import matplotlib.pyplot as plt plt.plot(range(1, epochs + 1), train_losses) plt.xlabel("Epoch") plt.ylabel("Training Loss") plt.title("Training Loss Curve") plt.grid(True) plt.show()一个健康的损失曲线应该是前期快速下降,然后逐渐变平。如果你看到损失曲线在某个点之后开始反弹,那大概率是学习率设置过大,模型在最优解附近来回震荡,无法彻底收敛。这时候降低学习率往往立竿见影。
把训练过程和可视化结合起来,是我反复强调的一次完整建模闭环。没有可视化的训练过程就像闭着眼睛开车,最后撞没撞墙全靠运气,这不应该是工程中该有的状态。
如果你希望更方便地利用验证指标来调整学习率,可以考虑用 PyTorch 的学习率调度器。例如torch.optim.lr_scheduler.StepLR每多少轮把学习率乘以一个衰减系数。在实际项目中,这种“训练到后期自动降低学习率”的做法对提升最终精度很有效。
6. 常见问题与排查技巧实录
6.1 验证损失比训练损失还低,存在什么问题
模型在测试集上的表现比训练集更好,看起来是好事,但我要提醒你,这往往意味着训练过程有泄漏。最常见的泄漏渠道就是把标准化参数放在了整个数据集上计算,让模型在训练时就看到了测试集的信息。
另一个可能性是原始数据集内部有重复样本或高度相似的样本,导致测试集里的一些数据点与训练集中的样本几乎相同。遇到这种泄漏,模型的真实泛化能力会比测试结果更差。一旦投入真实环境,性能会明显下滑。
6.2 损失不下降或变成 NaN,学习率带来的灾难
损失值变成nan是训练过程中最恐怖的信号之一。它通常意味着数值溢出,这背后往往有两个原因:要么特征没有标准化,输入量级太大导致梯度爆炸;要么学习率过高,参数一步跳出了正常的数值范围。
排查思路一般是这样:先确认特征已标准化,再检查标签中是否有异常大的离群值,再手动调低学习率尝试。如果你用的 SGD 学习率默认就是 0.01,把它降到 0.001 试试,往往立刻就好转了。
6.3 模型对训练数据过拟合,怎么判断和处理
如果训练损失一路下降,而测试损失开始反弹,就说明网络开始把训练数据里的噪声也背下来了。在波士顿房价这个数据集上,样本数太少,过拟合是很容易出现的问题。
处理方式有几个选择:增加训练数据量,但这个数据集已经固定了,不现实;降低模型复杂度,比如把隐藏层神经元从 32 降到 16,或者干脆去掉隐藏层;加入 L2 正则化,也就是在损失函数后面加一个对权重平方的惩罚项;或者用 Dropout,训练时随机丢弃一部分神经元。
我推荐先走降低复杂度这条路,对小型数据集来说最直接有效。
6.4 固定随机种子,别让结果来回飘
你可能会遇到这种情况:代码明明一模一样,跑两次结果却略有差异。原因在于模型参数的随机初始化、数据集的随机划分都依赖于随机种子,未固定时每次运行都会产生不同的状态。
解决方案是在代码开头加一行:
torch.manual_seed(42) np.random.seed(42)这样每次实验从头到尾都接收同一组随机序列,结果可以复现。深度学习的实验本质上和科学实验没有区别,不可复现的结果在工程协作中没有任何价值。
6.5 标签形状报错,一个容易被忽略的细节
我在第一次用 MSELoss 时遇到过形状不匹配的报错。如果你的标签张量是一维的,预测输出是二维的,它们之间的广播逻辑在某些 PyTorch 版本里并不会自动进行,导致损失计算时报错。
解决方案就是前面提到的,在构造标签张量时加一个.view(-1, 1),把形状统一成(样本数, 1)。这一类问题往往是新手学 PyTorch 时最容易遇到的摩擦点,我特意列出来,希望你可以提前规避。
7. 从波士顿房价到真实项目的三个迁移心得
跑通波士顿房价预测只是第一步。我在几个实际项目中遇到过类似的回归任务,比如某公司希望根据历史数据预测未来业绩,某实验室想通过传感器数据预测设备寿命。这些项目表面上和波士顿房价毫无关系,但底层的建模逻辑几乎完全相同。
第一个迁移心得是:数据预处理永远比模型结构更值得花时间。波士顿房价这个案例中,标准化前后的训练速度差异非常明显。在真实项目中,缺失值和异常值处理、特征构造、业务规则融合,每一项都比选模型框架更影响最终效果。
第二个迁移心得是:先从最简单的模型起步,再慢慢增加复杂度。每次加一个环节,就要确认一次它对结果的影响是否值得。我在一个销售预测项目中,一开始就用 XGBoost 取得不错的效果,后来换成多层感知机反而过拟合严重。后来回过头用简单模型认真处理特征,效果才真正稳定下来。
第三个迁移心得是:一定要养成可视化和记录实验的习惯。每次改动参数,都要记下来改了哪一项、结果是什么变化。你在波士顿房价这种小数据集上建立的这套实验方法论,可以平移到任何一个深度学习项目里。
技术圈里常说“把一件事做透比做多件事更重要”,波士顿房价是这句话的绝佳载体。当你把一个最朴素的回归任务从头到尾跑通、跑透,你对 PyTorch 的理解将会有质的飞跃,再回头看网上那些复杂的项目代码时,也会觉得自己看到了骨架上的很多细节。