加州房价预测实战:机器学习项目从零到一完整流程

加州房价预测实战:机器学习项目从零到一完整流程 掐指一算这个项目在机器学习入门圈子里几乎快被写烂了但为什么我今天还是要再写一遍因为越经典的题目越能讲清楚机器学习项目从零到一的全流程。很多朋友看完理论课学完线性回归、决策树、随机森林参数意思都懂但打开一个真正的数据集直接傻眼先干嘛数据要不要清洗用什么指标评估为什么我跑出来的结果和教程差那么多这篇就用加州房价中位数预测这个例子把完整的项目流程走一遍从数据探索、数据切分、特征工程到模型训练、超参数调优最后在测试集上验证结果。代码用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 OCEANmedian_house_value数值房价中位数目标变量单位美元注意一下这个数据集的采集年份虽然是1990年价格数字放到今天没什么现实参考意义但它的特征结构和数据质量非常适合用来练手包含数值型特征、分类特征、缺失值、异常值非常全面。1.2 环境准备与依赖安装写这份代码时我用的是Python 3.9如果你的环境版本比我低问题也不大但建议至少在3.7以上。需要装的库有这些pip install pandas numpy matplotlib seaborn scikit-learnpandas处理表格数据numpy数值计算matplotlib seaborn画图做数据可视化探索scikit-learn机器学习模型库这里说一个我的习惯建项目时先单独开一个虚拟环境不要一股脑装到全局环境。python -m venv housing_env然后source housing_env/bin/activateWindows下是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], bins50) 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, annotTrue, fmt.2f, cmapcoolwarm) 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档。然后调用StratifiedShuffleSplitfrom sklearn.model_selection import StratifiedShuffleSplit split StratifiedShuffleSplit(n_splits1, test_size0.2, random_state42) 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, axis1, inplaceTrue)注意整个探索阶段要养成分离训练集和测试集的习惯。特征工程、数据清洗、模型选择都只能在训练集上进行测试集要留到最后才碰。否则你看着测试集调了半天参数测试集就失去了未知数据的意义最终评估结果会偏乐观这在真实项目里是极其严重的错误。4. 特征工程与数据清洗这件事可能决定你模型的上限特征工程是机器学习里最依赖经验的部分同样的数据特征处理方式不同模型效果可能天差地别。Garbage in, garbage out这句话一点不夸张。4.1 创造组合特征原始数据里有total_rooms、total_bedrooms、population、households这四个总量型特征但它们直接扔进模型其实不太合理。你想一下同样是600间房间放在500人的街区里意味着人均房间很多放在5000人的街区里就是人均资源紧张模型的预测逻辑完全不一样。所以需要把总量转成更合理的比率特征data strat_train_set.drop(median_house_value, axis1) 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的缺失值用SimpleImputerfrom sklearn.impute import SimpleImputer imputer SimpleImputer(strategymedian) # 先处理数值列 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, columnsnum_cols, indexdata.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 OCEAN4会被模型理解为比INLAND2大2倍这显然没道理。所以要用One-Hot编码把每个类别变成一个独立的0/1列from sklearn.preprocessing import OneHotEncoder cat_encoder OneHotEncoder(handle_unknownignore) 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和transformfrom sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer num_pipeline Pipeline([ (imputer, SimpleImputer(strategymedian)), (std_scaler, StandardScaler()), ]) num_attribs list(data_num.columns) cat_attribs [ocean_proximity] full_pipeline ColumnTransformer([ (num, num_pipeline, num_attribs), (cat, OneHotEncoder(handle_unknownignore), 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(fLinear Regression RMSE: {lin_rmse})我跑完第一轮的RMSE大概在7万美元左右。RMSERoot Mean Squared Error是回归任务最常用的评估指标之一它相当于给了大误差更高的惩罚。为什么用RMSE而不是MAE对于房价预测这种场景误差5万和误差10万不是简单的两倍关系大误差在RMSE中被平方放大能倒逼模型减少大偏差。看到7万多的RMSE说实话一眼就知道线性回归不够用——目标变量的平均值才20万出头误差7万意味着中位水平的预测偏差高达三分之一这个精度远远达不到实用要求。线性回归模型对这种数据的主要问题在于真实世界特征和目标变量之间几乎不可能是严格的线性关系。5.2 决策树回归小心过拟合接下来很多人会想那试试决策树吧它能捕捉非线性关系不是更牛吗试一下from sklearn.tree import DecisionTreeRegressor tree_reg DecisionTreeRegressor(random_state42) 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(fDecision 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, scoringneg_mean_squared_error, cv10) 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_estimators100, random_state42) forest_reg.fit(housing_prepared, data_labels) forest_scores cross_val_score(forest_reg, housing_prepared, data_labels, scoringneg_mean_squared_error, cv10) 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里对应的是RandomizedSearchCVfrom sklearn.model_selection import RandomizedSearchCV from scipy.stats import randint param_distribs { n_estimators: randint(low100, high300), max_depth: randint(low3, high15), min_samples_split: randint(low2, high10), max_features: [sqrt, log2, None], } forest_reg RandomForestRegressor(random_state42) rnd_search RandomizedSearchCV( forest_reg, param_distributionsparam_distribs, n_iter50, cv5, scoringneg_mean_squared_error, random_state42 ) rnd_search.fit(housing_prepared, data_labels) best_estimator rnd_search.best_estimator_ print(best_estimator)n_iter50意味着只尝试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, axis1) 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(fFinal RMSE on test set: {final_rmse})如果前面的工作没有偷懒测试集上的RMSE应该和交叉验证结果接近。我跑完大约是4.6万美元左右。如果测试集误差明显比交叉验证高一大截说明模型在新数据上泛化能力不行需要回去调整。再看一眼误差分布画出预测值和真实值的散点图。plt.figure(figsize(8, 8)) plt.scatter(y_test, final_predictions, alpha0.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保存下来方便之后做预测或对比实验。你会发现记录和复盘带来的提升比单纯堆模型要快得多。