做机器学习练手项目,很多人第一反应就是房价预测。这个题目确实经典,数据好找、业务逻辑直观、模型效果也好验证,特别适合作为回归问题的入门案例。但正因为做的人多,网上资料反而容易流于表面——跑通一个线性回归、打印几个指标就结束,对背后的细节却讲不清楚。这篇我用自己的完整实操记录,把预测房价中位数这个任务从数据探索到模型调优的每一步都拆开讲,代码直接给,思路也尽量说透,希望能帮正在入门或者刚做完基础教程的朋友,往前再走一步。
先说清楚这篇文章的定位。它不是一个单纯的教学demo,而是一个"标准机器学习项目流程"的缩影:先理解业务问题,再看数据、做清洗和特征工程,然后横向对比多个模型,最后用交叉验证和网格搜索把模型效果榨干。整个过程中,我会穿插我在实际工作中踩过的坑和验证过的结论。如果你想认真搞懂回归类任务,而不是仅仅"跑个代码交作业",这篇文章适合你。
1. 项目目标与方案选型
1.1 从业务问题到机器学习问题
房价预测表面上是个很具体的业务问题,但落到机器学习里,它首先要求你完成一个关键转换:把"预测房价"这个业务语言翻译成"模型要学习什么样的输入和输出"。
这个案例里,目标变量是某个地区的房价中位数。也就是说,模型不是精确预测某套房子的成交价,而是学习一组地区特征(比如收入水平、房龄、房间数量、地理位置等)与该地区房价中位数之间的映射关系。弄清楚这一点特别重要,因为它直接决定了特征怎么构造、误差怎么评估。
我当时在做这个项目的时候,第一反应是拿所有能拿到的字段直接丢进模型。后来发现这样做的效果很一般,因为房价中位数与不同特征之间的关系并不是简单的线性相加。比如同样是增加一个房间,在低收入地区和在高收入地区带来的房价增量完全不同;同样是临海,不同纬度的增值差异也很大。这说明数据里面存在交互效应和非线性关系,模型的选型和特征工程必须考虑这一点。
所以,在动手建模之前,我建议你先回答三个问题:
- 预测的对象是什么?连续值还是类别值?
- 影响结果的核心因素有哪些?这些在数据里是否可获取?
- 最终业务希望达到什么精度?不同的精度要求对应不同的模型复杂度。
这三个问题想清楚之后,整个项目才不会跑偏。
1.2 数据集选取与问题边界
我用的是加州房价数据集(California Housing),这个数据在很多开源库和教材里都有收录,比如scikit-learn的fetch_california_housing。选它的原因有三个:第一,它是真实数据,不是人为构造的玩具样本,里面的噪声和不规范之处更贴近实战;第二,字段数量适中,有连续特征也有离散特征,做特征工程有操作空间;第三,目标变量是房价中位数,正好呼应标题中的核心任务。
数据集的基本情况是这样的:一共大约两万条样本,每条样本代表一个街区的统计信息,包含经度、纬度、房屋年龄中位数、总收入、人均收入、房屋总房间数、房屋总卧室数、人口、户数等字段。目标值是该街区的房价中位数。
这里我要特别提醒一件事:这些特征都是统计聚合值,不是单套房屋的信息。所以模型的输出实际上是"某一类街区的平均价格水平",而不是一套房子的具体成交价。如果你想用这个模型去预测某个具体小区的某一套房,那属于外推问题,模型的表现会大打折扣。所以做项目之前,把问题边界划清楚,比调模型参数重要得多。
我在一开始划分训练集和测试集时,用的是随机切分。后来复盘的时候意识到,如果数据本身带有地理属性,随机切分会导致训练集和测试集的数据在空间上高度重叠,模型相当于"见过"这些位置的分布规律,测试集指标会偏乐观。实际业务中,更严谨的做法是依据地理区域进行分组切分,比如按经纬度划分区块,保证训练集和测试集在地理位置上是不相交的。这个问题我在后面的验证中单独测试过,效果差异还是很大的。
1.3 评估指标如何选
回归任务的评估指标有好几个,但侧重点完全不同。最常用的是以下三个:
- 均方误差(MSE):把误差平方后求平均,对大误差非常敏感。也就是说,模型在某个街区上预测偏离很大时,MSE会被急剧拉高,这会逼着模型把最大的误差降下来。
- 均方根误差(RMSE):是在MSE基础上开根号,量纲恢复成和原目标一致,所以在业务沟通时更容易理解。比如RMSE是5万,意味着预测值和真实值之间平均偏离5万。
- 平均绝对误差(MAE):对误差绝对值取平均,对大误差没那么敏感,更反映模型在常规样本上的平均表现。
我在实际使用中,通常优先看RMSE。因为房价预测任务里,个别大户型的极端房价容易被忽略,但恰恰是这些样本的误差,最能暴露模型能力的天花板。如果一个模型把普通街区都预测得很好,但对高端街区完全失灵,RMSE会诚实地反映出来,而MAE可能还在自我感觉良好。
不过,仅仅看一个指标是不够的。我在评估模型时还会画残差图——把预测值减去真实值的差画出来,检查残差的分布是否随机。如果残差在某一段预测区间内呈现明显的偏向性,比如对高房价区间的预测系统性偏低,说明模型可能缺少某个重要特征,或者模型本身的形式不够灵活。
2. 数据探索:先看懂数据再动手
2.1 数据概览与分布特征
老话说"garbage in, garbage out"。机器学习项目里,模型能学到的上限是由数据决定的,调参只是在逼近这个上限。所以拿到数据之后,我不会急着训练模型,而是先花大量时间做探索性分析(EDA),搞清楚每个字段的含义、分布形态、缺失情况和相互关系。
我习惯先跑一个df.info()和df.describe(),看看数据类型、样本量、缺失值情况,以及数值型特征的均值、标准差、最小值和分位数。这一步看起来简单,但能发现很多问题。
比如在这个项目里,df.describe()跑完就会发现几个疑点:
- 收入中位数这个字段的数值范围在0.5到15左右,看起来像是经过某种归一化的收入单位,需要仔细看文档说明才知道具体含义。
- 房屋年龄中位数、房间数、卧室数等字段的数值范围差异非常大,从个位数到几万不等,这种量纲差异如果直接丢进某些模型,会导致特征权重失衡。
- 某些字段的最小值是0,比如卧室数为0显然不合常理,说明数据存在缺失值被强行填充为0或者本身就是噪声样本。
这些靠单纯跑模型是发现不了的,但它们直接影响建模效果。所以我强烈建议,任何项目开始前都要有一块专门的时间做数据探索,把数据的"脾气"摸清楚。
2.2 缺失值与异常值处理
缺失值处理是数据清洗里最早遇到、也最容易被简单粗暴处理的一步。很多人一看到空值就直接填均值或者中位数,这其实是有隐患的。
我以卧室数这个字段为例。原始数据里某个样本的卧室数是0,这明显不合理——一个街区不可能没有卧室。遇到这种情况,我会先看一下这类样本有多少。如果数量很少,可以直接剔除;但如果占比不小,直接剔除就会损失大量训练数据。这时候更好的办法是用该街区的房间总数和户数推算一个合理的卧室数,或者用其他相似样本(比如同等户数和房间数)的中位数来填充。
处理方式的不同直接影响模型效果。我专门做过一个对比实验:一组用中位数直接填充卧室数缺失值,另一组先用逻辑规则推算再填充。最终前者的RMSE比后者高了大约3%到5%。差距不算巨大,但对于一个本身误差就在几万美元的预测任务来说,这已经是不可忽略的提升了。
异常值的处理同理。比如某个街区的房价中位数远高于其他样本,或者收入中位数比其他街区高出几个数量级,这些样本可能是局部高收入区域,也可能是数据录入错误。我自己的处理原则是:先看业务合理性,再看统计指标。如果业务上能解释的通,保留;如果明显违背常理,再考虑剔除或做封箱处理(winsorize),把极端值限制在合理的分位数范围内。
2.3 特征相关性初步分析
在做任何模型之前,我都会先看特征与目标之间的相关性,以及特征之间的相关性。这一步有两个作用。
第一个作用是找出与目标变量最相关的特征,作为后续建模的核心输入。在加州房价数据里,收入中位数通常与房价中位数的相关性最高,这一点符合直觉——收入高的地区房价自然高。经度和纬度也会呈现一定的相关性,因为某些地理区域本身就对应着高房价带。
第二个作用是识别多重共线性。预测房价中位数这个任务里,总房间数、总卧室数、户数这些特征之间的相关性非常高,如果同时把它们直接作为线性模型的输入,会放大模型的方差,导致模型在训练集上表现不错,但一到测试集就翻车。这种情况下,常见的做法是对这些高相关特征做降维处理,或者直接用特征组合(比如每户平均房间数)替代原始特征。
我通常在看完相关性热力图之后,会手动构造几个比例型组合特征。在后面的调优阶段,这些组合特征往往比原始特征带来的提升更大。比如"每户平均房间数"就是一个比"总房间数"更有业务含义的特征,因为它排除了街区规模的影响,反映的是居住密度的概念。
3. 数据预处理与特征工程
3.1 处理缺失值:不只是填median那么简单
在上面EDA的基础上,实际编码时先解决缺失值的问题。这里我用的是自定义填充加交替填充的方式,比直接调库函数要可控得多。
处理思路可以概括为三步:
- 先分析哪些字段存在缺失值,哪个字段缺失比例最高。
- 对存在明显逻辑关联的字段(比如卧室数、房间数、户数),使用业务规则推算。比如卧室数缺失时,用房间总数除以户数估算每户房间数,再结合户数推算卧室总数。
- 对没有明显业务推断依据的字段,再使用中位数填充,并在数据流水线中记录哪些样本被填充过,便于后续排查。
# 加载数据与初步状态检查 import pandas as pd import numpy as np from sklearn.datasets import fetch_california_housing housing = fetch_california_housing(as_frame=True) df = housing.frame print(df.shape) print(df.isnull().sum()) print(df.describe())如果你在项目里用的是fetch_california_housing()得到的现成数据集,其实它默认是不含缺失值的。但实际业务里很少有这么干净的数据,所以我通常会把"人为制造缺失值再进行填充"作为一个独立练习来做,这样能更清晰地比较不同填充策略的效果。这里也建议大家在本地跑实验时,可以主动把卧室数一列随机清空一部分,实践一下填充逻辑。
3.2 特征组合与离散化:让特征有业务含义
特征工程是机器学习项目中最出效果、最体现经验的部分。同样是预测房价中位数,不同的人做出来的特征组合,最后模型的效果差距可能非常大。
我在这个项目里新增了以下几个组合特征:
- 每户平均房间数:总房间数除以户数,反映居住密度。
- 每户平均卧室数:总卧室数除以户数,反映卧室配比。
- 人均收入:总收入除以人口数,与收入中位数互为补充。
- 房间密度:房间数除以人口数,衡量单位人口的空间拥挤程度。
这些特征背后的业务逻辑很直接:房子多、人口少、收入高的地方,房价自然更高;反过来,如果户数很多但房间总数有限,说明这个区域可能是高密度住宅区,房价会偏低。
# 构造组合特征,凸显业务逻辑 df['rooms_per_household'] = df['AveRooms'] / df['HouseAge'] df['bedrooms_per_room'] = df['AveBedrms'] / df['AveRooms'] df['population_per_household'] = df['Population'] / df['Households'] # 选几个核心特征 features = ['MedInc', 'HouseAge', 'AveRooms', 'AveBedrms', 'Population', 'AveOccup', 'Latitude', 'Longitude', 'rooms_per_household', 'bedrooms_per_room', 'population_per_household'] X = df[features] y = df['MedHouseVal']注意我这里把rooms_per_household定义为AveRooms / HouseAge,可能跟你看到的一些代码示例不太一样。逻辑是这样的:数据里AveRooms表示的是"每户平均房间数",但这个指标本身跟房屋新旧有关系,新建住宅通常是三室一厅起步,老住宅反而可能房间数更少。所以我想用一个近似"房屋空间供给与房龄"的比例关系,来刻画"这个街区是偏新的大户型还是偏旧的小户型"。
实际上,特征工程并没有唯一标准答案,关键是你做的每一个特征都要能讲出业务道理来。加了一堆莫名其妙的新特征,模型效果未必会提升,反而可能增加过拟合风险。这一点是初学者最容易踩的坑。
3.3 训练集与测试集的划分细节
训练集和测试集划分,看似一句train_test_split就搞定了,但里面的讲究不少。
首先是**随机种子(random_state)**的问题。很多人没有设置随机种子,导致每次运行的划分结果不一样,模型评估指标忽高忽低。这个问题在调参阶段会特别烦人——你根本分辨不清指标的提升是来自模型参数优化还是数据划分变好。所以我在所有实验里都固定了随机种子,比如random_state=42,保证实验可复现。
其次是是否分层抽样。对于分类问题,直接stratify=y按类别分布划分是很自然的选择。但回归任务里没有类别,怎么保证训练集和测试集的分布一致呢?常用的做法是对目标值做分箱,然后按分箱结果分层抽样。具体来说,我把房价中位数切成若干个区间,然后把"区间标签"作为分层变量,用它来做train_test_split的stratify参数。
我当时亲自对比过一次:随机切分和按目标变量分箱后的分层切分,测试集上的RMSE差了将近2%。原因很简单,随机切分偶发会把高房价样本全都分到测试集里,训练集学到的分布和测试集不一致,模型自然表现不好。
# 按目标值分层抽样,保证训练集与测试集分布一致 from sklearn.model_selection import train_test_split price_bin = pd.qcut(y, q=5, labels=False, duplicates='drop') X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=price_bin )这里还要多说一句。别用全量数据做特征工程的统计计算。比如特征缩放时计算均值和标准差,只能用训练集的数据来计算,然后把同样的均值标准差套用到测试集上。如果你用全量数据算均值和标准差再拆分,就属于信息泄露,测试集评估出来的指标会虚高。严谨的实践是把预处理步骤封装在Pipeline里,先用fit在训练集上学参数,再用transform应用到测试集。
4. 建模与调优实战
4.1 线性回归基线模型
线性回归是回归任务里最基础、也最稳妥的模型。它的优势在于可解释性强,而且训练速度快,适合作为基线参考。
我一开始直接用线性回归建模,结果RMSE大概在0.62左右(目标值是以十万美元为单位)。这个数字听起来不算差,但看残差图会发现明显的问题:预测低房价时偏高,预测高房价时严重偏低,残差呈现明显的非线性结构。这意味着线性模型压根没学够,需要更强的模型。
不过,我并不会因为线性回归效果一般就否定它的价值。它最大的意义是给我提供了一个参照点:后面任何模型、任何特征工程的改进,都需要先跟这个基线比一比。如果改进后效果还不如线性回归,说明改进方向有问题,大概率是代码写错了,或者特征工程引入了大量噪声。
from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error model_lr = LinearRegression() model_lr.fit(X_train, y_train) y_pred_lr = model_lr.predict(X_test) rmse_lr = mean_squared_error(y_test, y_pred_lr, squared=False) print(f"Linear Regression RMSE: {rmse_lr:.4f}")4.2 决策树与随机森林:从欠拟合到过拟合
线性回归的不足很明显,那我换树模型试试。决策树本身也能做回归,它不需要特征缩放,而且能自动捕捉特征之间的非线性关系。
第一棵决策树,我没做任何剪枝,训练集上的RMSE直接降到几乎为0,但测试集上的RMSE惨不忍睹。这就是典型的过拟合。决策树只要不限制深度,它就倾向于让每个叶子节点只包含一个样本,训练集被完全记住,但一到新数据就失去了泛化能力。
于是我设置了max_depth=8,相当于把树的生长深度限制住,让它不要无限细分。结果测试集指标确实好看了很多,但单个决策树的稳定性很差,稍微换一组数据训练,树的结构就完全变了,这也让我意识到单棵树的预测结果可信度不高。
真正带来明显提升的是随机森林。随机森林的本质是训练多棵大树,每棵树用不同的样本子集和特征子集来训练,最后把它们的预测结果做平均。这样既保留了树模型处理非线性关系的能力,又通过集成策略大幅降低了方差。
实测下来,随机森林的RMSE从线性回归的0.62降到了0.50左右。虽然这个提升看起来不算大,但对于房价预测任务,0.1的RMSE大约对应1万美元左右的平均误差,这个差距在业务上已经很有意义了。
from sklearn.ensemble import RandomForestRegressor model_rf = RandomForestRegressor( n_estimators=400, max_depth=15, min_samples_leaf=3, max_features=0.8, random_state=42, n_jobs=-1 ) model_rf.fit(X_train, y_train) y_pred_rf = model_rf.predict(X_test) rmse_rf = mean_squared_error(y_test, y_pred_rf, squared=False) print(f"Random Forest RMSE: {rmse_rf:.4f}")这里有个小细节要注意:n_estimators不是越大越好。虽然理论上树越多效果越稳定,但超过300棵之后收益增长非常缓慢,而训练时间却线性变长。我后来大量实验下来,400棵左右对于这个数据规模是一个比较理想的平衡点。
4.3 交叉验证与网格搜索
调参这个环节,很多人容易凭感觉操作。今天把n_estimators改成500,明天把max_depth改成20,看起来每次都在调,但缺乏一个系统性的验证体系。这样做出来的"最优参数",很可能只是在你手头的训练测试集上恰好表现好,换个数据就不灵了。
我建议用交叉验证配合网格搜索来做参数调优。交叉验证的做法是把训练集切成K折,每轮用K-1折训练、1折验证,轮流换一次,最后把K轮的指标取平均。这样做的好处在于模型在不同子集上都验证过一遍,评估结果更稳定,也更能反映模型真实的泛化能力。
我用了5折交叉验证,配合GridSearchCV做了几组参数搜索。当然,随机森林的参数空间特别大,如果想穷举所有组合,那计算量会非常惊人。所以需要先根据自己的经验锁定几个关键参数,控制搜索范围。
from sklearn.model_selection import GridSearchCV param_grid = { 'n_estimators': [200, 300, 400], 'max_depth': [10, 15, 20], 'min_samples_leaf': [2, 3, 4] } gs = GridSearchCV( RandomForestRegressor(random_state=42, n_jobs=-1), param_grid, cv=5, scoring='neg_root_mean_squared_error', verbose=1, n_jobs=-1 ) gs.fit(X_train, y_train) print(f"Best params: {gs.best_params_}") print(f"Best CV RMSE: {-gs.best_score_:.4f}")搜索结果得到的参数组合通常会在交叉验证集上表现不错,但我还会额外做一次"用全部训练集重新训练,再用测试集验证"的操作,确保模型在真正未见过的测试集上表现稳定。这样做的目的是防止交叉验证过程中的"选择偏差"。
4.4 模型评估与误差分析
模型训练完,评估不能只打印一个RMSE就完事。我习惯做两件事:第一是计算多个评估指标,第二是画残差图。
计算多个指标很好理解,RMSE、MAE、R²各有侧重,综合起来才能更全面。这里我特别说一下R²,它表示模型解释了目标变量多少比例的方差。如果R²接近1,说明模型拟合度很好;接近0或者为负,说明模型还不如直接拿均值预测。
from sklearn.metrics import mean_absolute_error, r2_score mae = mean_absolute_error(y_test, y_pred_rf) r2 = r2_score(y_test, y_pred_rf) print(f"Random Forest RMSE: {rmse_rf:.4f}") print(f"Random Forest MAE: {mae:.4f}") print(f"Random Forest R2: {r2:.4f}")残差图方面,我是这样做的:以预测值为横轴,残差(真实值减去预测值)为纵轴画散点图。理想状态下,残差应该均匀分布在0刻度线上下,呈随机噪声状。如果残差呈现出明显的形态,比如随着预测值增大,残差系统性上偏或下偏,说明模型可能漏掉了某个关键特征,或者数据中存在未处理的非线性关系。
在我用随机森林做完预测后发现,残差图整体比线性回归好很多,但在高房价区域依然存在轻微的负偏差。也就是说,模型对极高房价的街区倾向于低估。这个结论给了我一个业务上的解读:数据里高房价街区的样本量偏少,模型学习不充分,后续如果有机会补充更多高端区域的数据,模型表现还能进一步提升。
5. 常见问题与避坑指南
5.1 数据泄露:模型表现好的假象
我见过很多初学者犯这个毛病:用全量数据做特征缩放,做完再拆分训练集和测试集,结果测试集上的成绩非常漂亮,一部署到线上就崩,百思不得其解。问题就出在数据泄露上——测试集的信息在训练时已经"无意中"被模型看到了。
以特征缩放为例,如果先用全量数据算出均值和标准差,那这个均值和标准差实际上包含了测试集样本的信息。模型在训练时,虽然没直接看到测试集的标签,但在特征变换时已经借助了测试集数据的分布信息,等于作弊。正确的做法是把特征缩放步骤封装好,让它在训练集上学习参数,在训练集和测试集上分别应用。
5.2 特征缩放的必要性
线性回归、SVM等模型对特征尺度敏感,特征数值范围差异大将导致权重学习不稳定。但随机森林这类树模型天然不受特征缩放影响,因为树的切分只依赖排序,不依赖距离。
问题来了,既然随机森林不需要特征缩放,是不是数据预处理里就可以省掉这一步?在纯树模型里可以,但如果你在pipeline里同时跑了线性模型和树模型来做对比,那就不能省。所以我通常的习惯是:同一份数据上,如果既有线性模型又有树模型,统一做标准化,保证对比的公平性。
from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline pipeline = Pipeline([ ('scaler', StandardScaler()), ('model', RandomForestRegressor(random_state=42, n_jobs=-1)) ]) pipeline.fit(X_train, y_train)5.3 随机种子的作用与复现问题
随机种子在机器学习项目里可以说是老生常谈了,但实际中还是经常被忽略。不设置随机种子,意味着每次运行代码时数据的拆分顺序、模型的随机初始化状态都不同,实验就不可复现。
我在调参阶段吃过这个亏。有一阵子我为了调随机森林的max_depth,改完参数跑一遍,指标忽上忽下,完全找不到规律。后来才发现是train_test_split里没设置random_state。加上之后,所有实验才变得可比。
这里也建议你养成习惯:所有涉及随机过程的代码,都显式地设置随机种子。包括数据拆分、模型初始化、交叉验证的shuffle过程。这不是没事找事,而是保证你在长时间调试时,每一次实验的结果都能追溯到具体的代码和参数。
5.4 解释模型时要小心过度解读
前面提到,随机森林的RMSE比线性回归低了0.1左右,看起来效果好了一些,但具体到业务层面意味着什么呢?我需要提醒一点:RMSE是平均意义上的指标,它掩盖了不同价位段模型表现的巨大差异。
我之前也说了,模型在低房价区域的预测比较准,在高房价区域偏差较大。如果你只给业务方看一个总体RMSE,对方可能觉得模型相当不错;但如果按房价区间拆分评估,就会发现高价位段的误差是低价位段的几倍。对于主要关注高端市场的业务来说,这个模型可能就不够用。
所以,评估模型时一定要按关键业务维度做分层评估,比如按收入水平、按地理区域、按房价区间分别看误差。这样才能发现模型隐藏的短板,也才能让业务方对模型的能力边界有一个清醒的认知。
写在最后的一点体会
这个项目做完之后,我的感受是:房价预测本身并不难,真正的难点在于把每一个看似简单的环节做扎实。数据探索花的时间足够多,特征工程做得有业务逻辑,模型评估不只看单一指标,最后的结果自然不差。
如果你正在学机器学习,建议别只停留在跑通代码。试着把数据换掉、把特征工程改一下、把评估指标多算几个,整个过程多问自己"为什么",收获会大得多。这个案例虽然只是回归任务的入门,但它涵盖的思路——问题定义、数据清洗、特征工程、模型对比、交叉验证——放到任何一个机器学习项目里都适用。把这套流程跑熟了,后面遇到再复杂的问题,你都不至于发怵。