加州房价预测实战:机器学习项目从零到一完整流程
2026/9/16 6:27:58 网站建设 项目流程

掐指一算,这个项目在机器学习入门圈子里几乎快被写烂了,但为什么我今天还是要再写一遍?因为越经典的题目,越能讲清楚机器学习项目从零到一的全流程。很多朋友看完理论课,学完线性回归、决策树、随机森林,参数意思都懂,但打开一个真正的数据集直接傻眼:先干嘛?数据要不要清洗?用什么指标评估?为什么我跑出来的结果和教程差那么多?

这篇就用加州房价中位数预测这个例子,把完整的项目流程走一遍:从数据探索、数据切分、特征工程,到模型训练、超参数调优,最后在测试集上验证结果。代码用Python写,基于scikit-learn实现,每一段我都会说清楚为什么要这样做,而不是机械地"抄步骤"。我自己当年入门时因为没有项目经验,花了一个星期踩各种坑,如果当时有人把每一步的"为什么"讲透,至少能省一半时间。

1. 项目背景与数据集:为什么选这个题目练手

先说结论:预测房价中位数这个题目,覆盖了机器学习监督学习中的回归任务、数据清洗、特征工程、模型对比、超参数调优五大核心环节,同时数据量不大(两万条左右),普通笔记本跑随机森林也就几十秒,非常适合作为第一个完整项目。

数据集用的是经典的加州房价数据集(California Housing)。这是1990年美国加州人口普查的数据,包含了加州各街区(block group)级别的房价统计信息。每个街区大约有600到3000人,数据集中一共有20640个街区样本。目标变量是某街区内所有房屋的价格中位数,单位是美元。所谓中位数,就是把这个街区的房子按价格排序后取最中间那个的值,这样不容易被极端高价房带偏。

1.1 数据集字段说明

加载数据之后,你会看到表里有这9个字段:

字段名类型含义
longitude数值街区中心的经度
latitude数值街区中心的纬度
housing_median_age数值街区内房屋房龄中位数
total_rooms数值街区内房间总数
total_bedrooms数值街区内卧室总数
population数值街区内人口数
households数值街区内家庭户数
median_income数值街区内居民收入中位数(单位:万美元)
ocean_proximity分类街区与海洋的距离类别(<1H OCEAN, INLAND, ISLAND, NEAR BAY, NEAR OCEAN)
median_house_value数值房价中位数(目标变量,单位:美元)

注意一下,这个数据集的采集年份虽然是1990年,价格数字放到今天没什么现实参考意义,但它的特征结构和数据质量非常适合用来练手,包含数值型特征、分类特征、缺失值、异常值,非常全面。

1.2 环境准备与依赖安装

写这份代码时我用的是Python 3.9,如果你的环境版本比我低,问题也不大,但建议至少在3.7以上。需要装的库有这些:

pip install pandas numpy matplotlib seaborn scikit-learn
  • pandas:处理表格数据
  • numpy:数值计算
  • matplotlib + seaborn:画图,做数据可视化探索
  • scikit-learn:机器学习模型库

这里说一个我的习惯:建项目时先单独开一个虚拟环境,不要一股脑装到全局环境。python -m venv housing_env,然后source housing_env/bin/activate(Windows下是housing_env\Scripts\activate)。后面装包、升级包都不会污染其他项目,尤其是scikit-learn版本升级频繁,不同版本的API偶尔有微调,隔离环境省心很多。

提示:如果你用的是Jupyter Notebook,把代码按cell拆开跑完全没问题。如果是PyCharm或VS Code跑.py脚本,建议养成"写完一段跑一段"的习惯,别一次性写几百行再运行,排查错误会非常痛苦,这是我第一次写项目时最痛的血泪教训。

数据加载的代码很简单:

import pandas as pd data = pd.read_csv("housing.csv") print(data.shape) print(data.head()) print(data.info())

输出会显示(20640, 10),正好对应9个特征加1个目标变量。data.info()会告诉你每一列的非空值数量、数据类型,这一步就能初步发现缺失值问题。

2. 数据探索:建模前先跟数据混个脸熟

很多新手上来就分特征、跑模型,这是最忌讳的。你自己都不知道数据长什么样,跑出来的模型出了问题也完全无从排查。探索阶段的核心目的有三个:了解分布、发现异常、寻找规律。

先看目标变量median_house_value的分布:

import matplotlib.pyplot as plt import seaborn as sns sns.histplot(data['median_house_value'], bins=50) plt.show()

这个直方图一看就会发现问题:几乎所有的房价都在20万美元以下,但在50万美元这个位置有一根极高的柱子。这是因为该数据集的capping操作——1990年的人口普查把超过50万的房价都记录为50万,所以大量被截断的数据堆积在顶部。这会直接导致模型学到"到50万就封顶"的假规律,如果想让模型对高价位街区预测更准,后续可以考虑把这些被截断的样本单独处理,或者至少要知道有这个限制。

2.1 特征相关性分析

连续特征之间有没有关系,最直观的方法就是看相关系数矩阵。我一般用seaborn的热力图来看:

numeric_cols = data.select_dtypes(include=['float64', 'int64']).columns corr_matrix = data[numeric_cols].corr() plt.figure(figsize=(10, 8)) sns.heatmap(corr_matrix, annot=True, fmt=".2f", cmap='coolwarm') plt.show()

相关系数的取值范围在-1到1之间,绝对值越接近1说明相关性越强。从热力图上能清楚看到,median_income与median_house_value的相关系数大约在0.69左右,是所有特征里和目标变量相关性最强的,这也很符合直觉——一个街区的居民收入水平高,房价中位数大概率也高。total_rooms和total_bedrooms之间的相关系数更是高达0.93,这不奇怪,房间多的房子卧室也自然多,但这种强相关往往意味着信息冗余,后面特征工程时要留意。

2.2 分类特征的取值分布

ocean_proximity这个分类特征有5个取值,用value_counts()查看一下分布:

print(data['ocean_proximity'].value_counts())

你会看到<1H OCEAN最多,INLAND次之,ISLAND最少只有5个样本。ISLAND样本量太少,在训练集和测试集划分时如果不注意,测试集里可能一个ISLAND都没有,模型见都没见过这个类别。对于这种极小类别的处理,一般可以考虑合并到近似类别或干脆删除,这个后面会细说。

2.3 缺失值与异常值检查

pandas 里用 isna() 就能扫描出缺失情况:

print(data.isna().sum())

跑出来的结果只有total_bedrooms一列有207个缺失值,占总样本的1%左右。缺失量不大,处理方案比较灵活:可以用中位数填充,也可以直接删除这些行。我个人的偏好是用中位数填充,这样不浪费样本。为什么要用中位数而不是均值?因为total_bedrooms的分布是右偏的,个别超大值会把均值拉高,用中位数更稳健。

异常值方面,结合scatter plot看经纬度和房价的关系,会发现有些街区明明在偏远地带却标着50万美元的封顶价,这就是前面提到的capping问题。这类异常值在建模时不用急着删,先保留观察,后面模型效果不理想再回头审视。

3. 数据切分:分层抽样为什么比随机抽样靠谱

这一步看起来最不起眼,但我觉得它是整个项目里最容易被低估的环节。很多教程就一行train_test_split完事,但如果你想成为合格的从业者,这一步背后大有讲究。

3.1 随机抽样的问题在哪里

按数据量20640条来看,随机抽样本来也不至于出大问题,但数据集小的时候,随机切分会有翻车风险。比如我们只有5个ISLAND样本,随机切分时这5个可能全进了训练集,测试集里完全没有ISLAND这种类别;更隐蔽的问题是,如果目标变量的分布本来就不均匀,随机切分可能让训练集和测试集中的价格分布不一致,导致测试集的评估结果失真。

3.2 按收入类别做分层抽样

sklearn 提供了StratifiedShuffleSplit,可以按某个类别进行分层抽样。因为我们看到median_income是预测房价最重要的特征,所以最好是按收入水平分层。光有理论不行,具体操作是把连续值离散化成类别:

import numpy as np # 将收入中位数除以1.5并取整,相当于分成了多个区间 data["income_cat"] = pd.cut(data["median_income"], bins=[0., 1.5, 3.0, 4.5, 6., np.inf], labels=[1, 2, 3, 4, 5])

这样就把收入分成了5档。然后调用StratifiedShuffleSplit:

from sklearn.model_selection import StratifiedShuffleSplit split = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42) for train_index, test_index in split.split(data, data["income_cat"]): strat_train_set = data.loc[train_index] strat_test_set = data.loc[test_index]

切完之后,可以验证一下每个收入档次在训练集和测试集里的占比是否和原来的全量数据集基本一致:

print(data["income_cat"].value_counts() / len(data)) print(strat_train_set["income_cat"].value_counts() / len(strat_train_set)) print(strat_test_set["income_cat"].value_counts() / len(strat_test_set))

看到三个比例几乎一样,说明切分是成功的。切完之后,把临时用于分层的income_cat列删除,避免它混进特征:

for set_ in (strat_train_set, strat_test_set): set_.drop("income_cat", axis=1, inplace=True)

注意:整个探索阶段要养成分离训练集和测试集的习惯。特征工程、数据清洗、模型选择都只能在训练集上进行,测试集要留到最后才碰。否则你看着测试集调了半天参数,测试集就失去了"未知数据"的意义,最终评估结果会偏乐观,这在真实项目里是极其严重的错误。

4. 特征工程与数据清洗:这件事可能决定你模型的上限

特征工程是机器学习里最依赖经验的部分,同样的数据,特征处理方式不同,模型效果可能天差地别。Garbage in, garbage out,这句话一点不夸张。

4.1 创造组合特征

原始数据里有total_rooms、total_bedrooms、population、households这四个"总量型"特征,但它们直接扔进模型其实不太合理。你想一下,同样是600间房间,放在500人的街区里意味着人均房间很多,放在5000人的街区里就是人均资源紧张,模型的预测逻辑完全不一样。所以需要把总量转成更合理的比率特征:

data = strat_train_set.drop("median_house_value", axis=1) data_labels = strat_train_set["median_house_value"].copy() # 组合特征:每户房间数、每户卧室数、每户人数 data["rooms_per_household"] = data["total_rooms"] / data["households"] data["bedrooms_per_room"] = data["total_bedrooms"] / data["total_rooms"] data["population_per_household"] = data["population"] / data["households"]

这几个比率特征背后都有业务含义:rooms_per_household反映居住空间大小,bedrooms_per_room反映卧室占比(卧室占比过高可能意味着小户型密集或合租现象),population_per_household反映家庭规模。实际跑下来,这些组合特征往往比原始特征对模型的贡献更大。

4.2 缺失值填充与特征缩放

处理total_bedrooms的缺失值,用SimpleImputer:

from sklearn.impute import SimpleImputer imputer = SimpleImputer(strategy="median") # 先处理数值列 num_cols = data.select_dtypes(include=[np.number]).columns data_num = data[num_cols] imputer.fit(data_num) data_imputed = imputer.transform(data_num) data_imputed = pd.DataFrame(data_imputed, columns=num_cols, index=data.index)

特征缩放这一步,很多人会问为什么有的模型需要、有的不需要。答案是:涉及距离或梯度的模型(线性回归、SVM、神经网络)对特征尺度敏感,不标准化的话,量纲大的特征会在损失函数里占据主导地位;而基于树的模型(决策树、随机森林)是按特征值做分割的,缩放不影响树的结构,所以可做可不做。但为了维护pipeline的统一和后续替换模型方便,我往往会统一做标准化:

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() data_num_scaled = scaler.fit_transform(data_imputed)

StandardScaler的原理是每个特征减去均值再除以标准差,让所有特征都变成均值0、方差1的分布。

4.3 分类特征的编码:One-Hot 编码

ocean_proximity是文本类别,模型不认识,要转成数值。直接编码成1、2、3、4、5行不行?原则上不行,因为这样隐含了大小关系——NEAR OCEAN(4)会被模型理解为比INLAND(2)大2倍,这显然没道理。所以要用One-Hot编码,把每个类别变成一个独立的0/1列:

from sklearn.preprocessing import OneHotEncoder cat_encoder = OneHotEncoder(handle_unknown='ignore') data_cat = data[['ocean_proximity']] data_cat_1hot = cat_encoder.fit_transform(data_cat)

转换后原来是1列,现在变成了5列,每行只在对应的类别位置是1,其余是0。ISLAND样本极少,5列里有一列几乎全0,但也别急着删,树模型在划分特征时自然会处理这种低信息量特征。

我之前踩过一个坑:sklearn版本更新后,OneHotEncoder返回的是一个稀疏矩阵,直接用pd.concat合并数据时报维度对不上。所以合并前最好toarray()转成普通数组,或者直接用hstack保留稀疏格式也行,看后续怎么用。

4.4 用Pipeline把预处理串起来

预处理步骤多,如果每一步都手动操作,代码会又长又乱。sklearn的Pipeline可以把缩放、编码等步骤按顺序组合成一个整体,统一fit和transform:

from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer num_pipeline = Pipeline([ ('imputer', SimpleImputer(strategy='median')), ('std_scaler', StandardScaler()), ]) num_attribs = list(data_num.columns) cat_attribs = ['ocean_proximity'] full_pipeline = ColumnTransformer([ ('num', num_pipeline, num_attribs), ('cat', OneHotEncoder(handle_unknown='ignore'), cat_attribs), ]) housing_prepared = full_pipeline.fit_transform(data)

用Pipeline的好处不只是整洁,更大的优势是:模型上线时,这个pipeline可以被整体保存下来,新数据进来直接走同样的处理流程,不会出现"训练时做了标准化、预测时忘做"这种低级错误。

5. 模型训练:从baseline开始,逐步对比

模型不是一个一个"试过去",而是有策略地对比。我的习惯是先跑一个最简单、训练最快的模型做baseline(基准线),心里有底之后再上复杂模型。

5.1 线性回归:做一个性能参照

from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error lin_reg = LinearRegression() lin_reg.fit(housing_prepared, data_labels) housing_predictions = lin_reg.predict(housing_prepared) lin_mse = mean_squared_error(data_labels, housing_predictions) lin_rmse = np.sqrt(lin_mse) print(f"Linear Regression RMSE: {lin_rmse}")

我跑完第一轮的RMSE大概在7万美元左右。RMSE(Root Mean Squared Error)是回归任务最常用的评估指标之一,它相当于给了大误差更高的惩罚。为什么用RMSE而不是MAE?对于房价预测这种场景,误差5万和误差10万不是简单的两倍关系,大误差在RMSE中被平方放大,能倒逼模型减少大偏差。

看到7万多的RMSE,说实话一眼就知道线性回归不够用——目标变量的平均值才20万出头,误差7万意味着中位水平的预测偏差高达三分之一,这个精度远远达不到实用要求。线性回归模型对这种数据的主要问题在于,真实世界特征和目标变量之间几乎不可能是严格的线性关系。

5.2 决策树回归:小心过拟合

接下来很多人会想:那试试决策树吧,它能捕捉非线性关系,不是更牛吗?试一下:

from sklearn.tree import DecisionTreeRegressor tree_reg = DecisionTreeRegressor(random_state=42) tree_reg.fit(housing_prepared, data_labels) housing_predictions = tree_reg.predict(housing_prepared) tree_mse = mean_squared_error(data_labels, housing_predictions) tree_rmse = np.sqrt(tree_mse) print(f"Decision Tree RMSE (training): {tree_rmse}")

如果你在训练集上算RMSE,会发现结果接近于0——几乎完美。但千万不要高兴,这是在训练集上跑出来的结果,决策树如果不对深度做限制,它可以把每个训练样本都单独记下来,形成一套无比复杂的规则,这在训练集上当然准,但拿到新数据上就会全面崩盘。正确做法是用交叉验证评估:

from sklearn.model_selection import cross_val_score def display_scores(scores): print("Scores:", scores) print("Mean:", scores.mean()) print("Standard deviation:", scores.std()) tree_scores = cross_val_score(tree_reg, housing_prepared, data_labels, scoring="neg_mean_squared_error", cv=10) tree_rmse_scores = np.sqrt(-tree_scores) display_scores(tree_rmse_scores)

交叉验证的RMSE大概在7万美元左右,和线性回归差不多。这就说明决策树过拟合了——训练集上几乎0误差,交叉验证却一下涨到7万,这是典型的方差过大,模型泛化能力不行。

5.3 随机森林:集成学习的威力

随机森林的思路是训练多棵决策树,每棵树只在随机抽样的数据和随机选择的特征子集上学习,最后把它们的预测结果平均起来。单棵树容易过拟合,但很多棵树平均之后,个别树的偏差会被其他树"投票"纠正,整体方差显著下降。

from sklearn.ensemble import RandomForestRegressor forest_reg = RandomForestRegressor(n_estimators=100, random_state=42) forest_reg.fit(housing_prepared, data_labels) forest_scores = cross_val_score(forest_reg, housing_prepared, data_labels, scoring="neg_mean_squared_error", cv=10) forest_rmse_scores = np.sqrt(-forest_scores) display_scores(forest_rmse_scores)

跑出来的交叉验证RMSE降到了5万美元左右,明显比线性回归和单棵决策树好了一个档次,而且分数的标准差也小,说明模型在不同数据子集上表现稳定。随机森林默认参数就能达到这个效果,这也印证了集成学习在这类中等规模表格数据上的优势。

6. 超参数调优:随机搜索比网格搜索让你更省心

默认参数的随机森林已经不错了,但5万美元的RMSE还有很大优化空间。随机森林有几个关键超参数可以调:n_estimators(树的数量)、max_features(每个节点随机选择的特征数)、max_depth(树的最大深度)、min_samples_split(节点分裂所需的最小样本数)。

传统的网格搜索是把所有参数组合都跑一遍,但参数一多就完蛋——假设3个参数各有10种取值,就是1000次训练,跑起来非常慢。随机搜索不是穷举所有组合,而是从参数空间中随机采样固定数量的组合,实践证明对于高维参数空间,随机搜索往往能以更少的训练次数找到接近最优的参数。sklearn里对应的是RandomizedSearchCV:

from sklearn.model_selection import RandomizedSearchCV from scipy.stats import randint param_distribs = { 'n_estimators': randint(low=100, high=300), 'max_depth': randint(low=3, high=15), 'min_samples_split': randint(low=2, high=10), 'max_features': ['sqrt', 'log2', None], } forest_reg = RandomForestRegressor(random_state=42) rnd_search = RandomizedSearchCV( forest_reg, param_distributions=param_distribs, n_iter=50, cv=5, scoring='neg_mean_squared_error', random_state=42 ) rnd_search.fit(housing_prepared, data_labels) best_estimator = rnd_search.best_estimator_ print(best_estimator)

n_iter=50意味着只尝试50组随机组合,配合5折交叉验证,总共训练250次。如果你的机器性能一般,可以把n_estimators范围调小一点,或者把cv从5降到3,会块很多。

跑完随机搜索之后,如果你想进一步精细调整,可以在最优参数附近再跑一次小范围的网格搜索。另外,我习惯查看特征重要性,随机森林自带feature_importances_属性:

feature_importances = best_estimator.feature_importances_ sorted_idx = np.argsort(feature_importances)[::-1] for idx in sorted_idx[:10]: print(f"{housing_prepared.shape[1] - idx}: {feature_importances[idx]:.4f}")

通常你会发现median_income仍然是最重要的特征,其次是组合特征rooms_per_household、bedrooms_per_room、ocean_proximity的相关列。这个信息在真实的业务项目里非常值钱,它告诉你数据里到底什么因素真正驱动预测结果。

7. 最终在测试集上验证:模型的真实水平

调参调得再开心,也别忘记:真正的考验是测试集。前面所有工作都避开了测试集,这时候才把它拿出来,通过之前训练好的pipeline做同样的转换,然后评估最终模型。

# 从分层抽样保存的测试集中分离特征和标签 X_test = strat_test_set.drop("median_house_value", axis=1) y_test = strat_test_set["median_house_value"].copy() # 用之前训练好的pipeline转换测试数据 X_test_prepared = full_pipeline.transform(X_test) # 用调优后的模型预测 final_predictions = best_estimator.predict(X_test_prepared) final_mse = mean_squared_error(y_test, final_predictions) final_rmse = np.sqrt(final_mse) print(f"Final RMSE on test set: {final_rmse}")

如果前面的工作没有偷懒,测试集上的RMSE应该和交叉验证结果接近。我跑完大约是4.6万美元左右。如果测试集误差明显比交叉验证高一大截,说明模型在新数据上泛化能力不行,需要回去调整。

再看一眼误差分布:画出预测值和真实值的散点图。

plt.figure(figsize=(8, 8)) plt.scatter(y_test, final_predictions, alpha=0.5) plt.plot([0, 500000], [0, 500000], 'r--') plt.xlabel("True Median House Value") plt.ylabel("Predicted Median House Value") plt.show()

散点图会清楚显示:大部分点集中在对角线附近,说明预测还算准;但右上角区域点明显稀疏且偏离对角线——因为封顶50万的存在,真实值为50万的样本,模型只能预测到50万以下。这说明数据本身的截断问题仍是模型误差的重要来源之一。

这个项目到这一步就算完整跑通了。回看整个流程,核心的收获不是"我会用随机森林预测房价"这个结果,而是完整掌握了机器学习项目的标准套路:先理解数据和业务,再做合理的切分与特征工程,然后从baseline开始逐步迭代,用交叉验证控制过拟合,最后在测试集上验证。这套流程在任何回归或分类项目上都能直接复用。

最后再分享一个小习惯:每跑完一个版本的模型,就把代码、数据pipeline和结果记录下来,甚至可以用joblib把训练好的模型和pipeline保存下来,方便之后做预测或对比实验。你会发现,记录和复盘带来的提升,比单纯堆模型要快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询