简介:这是一份面向计算机相关专业学生的Python机器学习实战资源,以房价预测为完整案例,适用于课程设计、期末大作业及实战练习。项目曾获98分评价并经导师指导认可,能帮助读者从数据预处理、特征工程、模型建立到评估走通机器学习全流程。压缩包共26个文件,约1.28MB,包含15个py脚本、1个ipynb笔记本、2个csv数据集、1个html分析报告及若干jpg可视化图片,另附read、md说明与gitignore配置,其中spiders文件夹提供链家与安居客的爬虫源码,数据仅供学习研究。目前已有103人学习。读者可借助详细代码注释与使用说明,掌握NumPy、Pandas、Scikit-learn、Matplotlib等库的应用,理解数据清洗、交叉验证、超参数调整及均方误差等评估指标,并参考完整目录结构快速复现项目,为深入学习机器学习打下基础。
1. 房价预测为什么是机器学习入门最值得啃的第一个项目
如果你正在搜 Python 机器学习实战,大概率已经看过一堆鸢尾花分类和手写数字识别的教程,跑完准确率 95% 以上,关掉 notebook 却不知道下一步能干什么。房价预测不一样,它从第一天起就逼你面对真实数据的全部脏乱差:缺失值、类别特征、偏态分布、离群点、量纲差异,一个都跑不掉。波士顿房价预测是很多人接触回归的起点,但那个数据集只有 506 条、13 个特征,而且因为伦理问题在新版 scikit-learn 里已经被移除,继续用它练手会遇到版本兼容的坑。我建议直接上 Kaggle 的 Ames Housing 数据集或者国内链家/贝壳的公开成交数据,特征量在 80 个左右,样本两三千条,规模刚好卡在「单机跑得动、但不用点技巧就跑不好」的区间。这篇文章面向两类人:刚学完 Python 基础语法、想找一个能写进简历的完整项目的入门者,以及做过分类任务、想补齐回归建模和特征工程短板的从业者。读完之后你应该能独立完成从数据加载、清洗、特征编码、模型训练到结果解释的全流程,并且知道每一步为什么这么做、参数怎么调、哪里容易翻车。
2. 拿到房价数据集先别急着 fit:数据探查与清洗的完整路径
2.1 房价预测数据集长什么样,去哪里拿
Kaggle 上的 Ames Housing 数据集是最接近工业场景的选择。它包含 2930 条训练样本、79 个解释变量,目标变量是 SalePrice。相比波士顿房价预测的 506 条数据,Ames 的缺失值模式更真实——有些缺失是「真的没有这个设施」,比如没有地下室所以 BsmtQual 为空,有些缺失是「忘记填了」,比如 Electrical 只缺了一条。这两种缺失处理方式完全不同,前者应该填 "None" 或 0,后者应该删掉或插补。
国内场景可以用链家或贝壳的公开成交记录,字段通常包括面积、户型、楼层、朝向、建成年代、小区均价、地铁距离等。这类数据的难点在于中文类别字段多、数值字段量纲差异大、而且存在大量重复挂牌。我一般会先做一轮去重,按「小区名 + 面积 + 户型 + 成交时间」四个字段联合去重,能砍掉 10% 到 20% 的噪声。
import pandas as pd import numpy as np # 加载数据,注意编码问题 df = pd.read_csv("train.csv", encoding="utf-8") # 查看整体情况 print(f"样本数: {df.shape[0]}, 特征数: {df.shape[1]}") print(df["SalePrice"].describe()) # 按缺失比例排序,决定处理策略 missing = df.isnull().sum().sort_values(ascending=False) missing_pct = (missing / len(df) * 100).round(2) missing_df = pd.DataFrame({"缺失数": missing, "缺失比例%": missing_pct}) print(missing_df[missing_df["缺失数"] > 0].head(20))这段代码做了三件事:确认数据规模、看目标变量分布、按缺失比例排序。参数上注意encoding要根据实际文件调整,国内数据常用gbk或utf-8-sig。缺失比例超过 80% 的列我一般直接删,因为插补带来的噪声比信息量还大;缺失比例在 5% 到 80% 之间的列,需要结合字段含义判断是「真缺失」还是「假缺失」。
2.2 缺失值、离群点和偏态分布的处理顺序
处理顺序很重要,顺序错了后面全白做。我的习惯是:先处理「假缺失」(有业务含义的空值),再处理离群点,最后处理偏态和目标变量变换。
以 Ames 为例,PoolQC、Alley、Fence 这些字段的空值含义是「没有该设施」,应该填 "None"。而 Electrical、MasVnrArea 这种只缺个位数的,直接删掉缺失行或者用众数/中位数插补。GarageYrBlt 缺失意味着没有车库,填 0 比填中位数更合理。
离群点方面,SalePrice 有两个明显异常值:一个是部分销售(Partial),一个是异常低价。GrLivArea 超过 4000 但价格低于 30 万的样本也要删掉,这是 Kaggle 社区公认的噪声。我一般用 IQR 方法先筛一遍,再结合业务判断。
# 假缺失:有业务含义的空值填 None none_cols = ["PoolQC", "MiscFeature", "Alley", "Fence", "FireplaceQu", "GarageType", "GarageFinish", "GarageQual", "GarageCond", "BsmtQual", "BsmtCond", "BsmtExposure", "BsmtFinType1", "BsmtFinType2"] for col in none_cols: df[col] = df[col].fillna("None") # 数值型假缺失填 0 zero_cols = ["GarageYrBlt", "GarageArea", "GarageCars", "BsmtFinSF1", "BsmtFinSF2", "BsmtUnfSF", "TotalBsmtSF", "BsmtFullBath", "BsmtHalfBath", "MasVnrArea"] for col in zero_cols: df[col] = df[col].fillna(0) # 删除 GrLivArea 异常值 df = df[~((df["GrLivArea"] > 4000) & (df["SalePrice"] < 300000))] # 目标变量对数变换,缓解右偏 df["SalePrice"] = np.log1p(df["SalePrice"])np.log1p等价于log(1+x),比直接log更安全,因为房价可能为 0。变换后目标变量接近正态分布,线性模型的残差假设更容易满足。注意变换后评估指标要还原回去,用np.expm1做逆变换再算 RMSE,否则你看到的误差是对数尺度上的,没有实际意义。
2.3 类别特征编码:Label Encoding 和 One-Hot 怎么选
房价数据里类别特征占一多半,编码方式直接决定模型上限。核心判断标准是:这个类别有没有序关系。有序类别用 Label Encoding 或者自定义映射,无序类别用 One-Hot。但 One-Hot 在特征维度高的时候会爆炸,Ames 里 Neighborhood 有 25 个取值,One-Hot 之后多出 24 列,还能接受;但如果某个字段有上百个取值,就得考虑目标编码(Target Encoding)或者频率编码。
我一般会先区分「有序」和「无序」:
| 字段类型 | 示例 | 编码方式 | 理由 |
|---|---|---|---|
| 有序类别 | ExterQual (Po/Fa/TA/Gd/Ex) | 自定义映射 1-5 | 等级有明确顺序 |
| 无序类别 | Neighborhood | One-Hot | 小区之间无大小关系 |
| 高基数类别 | 小区名(上百个) | 目标编码 + 平滑 | 避免维度爆炸 |
| 二值类别 | CentralAir (Y/N) | 0/1 映射 | 只有两个取值 |
from sklearn.preprocessing import LabelEncoder # 有序类别自定义映射 qual_map = {"None": 0, "Po": 1, "Fa": 2, "TA": 3, "Gd": 4, "Ex": 5} for col in ["ExterQual", "ExterCond", "BsmtQual", "BsmtCond", "HeatingQC", "KitchenQual", "FireplaceQu", "GarageQual", "GarageCond", "PoolQC"]: df[col] = df[col].map(qual_map).fillna(0).astype(int) # 无序类别 One-Hot nominal_cols = ["Neighborhood", "BldgType", "HouseStyle", "RoofStyle", "Exterior1st", "Foundation", "SaleType", "SaleCondition"] df = pd.get_dummies(df, columns=nominal_cols, drop_first=True) # 剩余类别统一 Label Encoding le = LabelEncoder() remaining_cat = df.select_dtypes(include=["object"]).columns for col in remaining_cat: df[col] = le.fit_transform(df[col].astype(str))drop_first=True是为了避免虚拟变量陷阱,在 LinearRegression 里必须加,树模型里加不加影响不大。Label Encoding 对树模型没问题,但线性模型会误以为类别之间有大小关系,所以线性模型必须用 One-Hot。这一步做完,数据基本可以喂给模型了。
3. 从线性回归到 XGBoost:房价预测模型选型与训练参数
3.1 先用线性回归建立 baseline,别一上来就上 XGBoost
很多人跳过 baseline 直接上 XGBoost,结果调了半天参数不知道到底提升了多少。我的习惯是先跑一个 Ridge 回归,看特征和目标之间的线性关系有多强。Ames 数据集上 Ridge 的 RMSE 大概在 0.13 左右(对数尺度),XGBoost 能压到 0.11 以下,差距就是非线性关系和特征交互带来的。
from sklearn.linear_model import Ridge, Lasso from sklearn.model_selection import cross_val_score, KFold from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline # 分离特征和目标 X = df.drop("SalePrice", axis=1) y = df["SalePrice"] # 标准化 + Ridge,用交叉验证评估 pipe = Pipeline([ ("scaler", StandardScaler()), ("ridge", Ridge(alpha=10.0)) ]) kf = KFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(pipe, X, y, cv=kf, scoring="neg_mean_squared_error") rmse_scores = np.sqrt(-scores) print(f"Ridge RMSE: {rmse_scores.mean():.4f} (+/- {rmse_scores.std():.4f})")alpha=10.0是正则化强度,越大惩罚越重。先用默认值跑一遍,再在[0.1, 1, 10, 100, 1000]里网格搜索。neg_mean_squared_error返回的是负 MSE,因为 sklearn 的交叉验证默认「分数越大越好」,所以取负号再开方。这一步的意义是给你一个基准线,后面所有模型都要和它比。
3.2 XGBoost 的关键参数:学习率、树深、正则项怎么设
XGBoost 在房价预测这类表格数据上几乎是最优解。参数虽多,但真正影响大的就几个:learning_rate、max_depth、n_estimators、subsample、colsample_bytree、reg_alpha、reg_lambda。
我的经验值:learning_rate设 0.01 到 0.05,配合n_estimators1000 到 3000,用 early stopping 自动停;max_depth3 到 6,太深容易过拟合;subsample0.7 到 0.9,colsample_bytree0.6 到 0.8;reg_alpha和reg_lambda从 0 开始,如果验证集 loss 比训练集高很多再加。
import xgboost as xgb from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, random_state=42 ) model = xgb.XGBRegressor( n_estimators=3000, learning_rate=0.01, max_depth=4, subsample=0.8, colsample_bytree=0.7, reg_alpha=0.1, reg_lambda=1.0, early_stopping_rounds=100, eval_metric="rmse", random_state=42 ) model.fit( X_train, y_train, eval_set=[(X_train, y_train), (X_val, y_val)], verbose=100 ) print(f"最佳迭代轮数: {model.best_iteration}") print(f"验证集 RMSE: {model.best_score:.4f}")early_stopping_rounds=100表示验证集 loss 连续 100 轮不下降就停,这是防止过拟合最有效的手段。eval_set同时传训练集和验证集,方便观察两者 loss 的差距。如果训练集 RMSE 远低于验证集,说明过拟合,该降max_depth或加reg_lambda;如果两者都高,说明欠拟合,该加n_estimators或提高learning_rate。
3.3 用交叉验证和早停判断模型有没有过拟合
单次 train_test_split 的结果波动大,尤其是样本量两三千的时候。我一般用 5 折交叉验证跑三遍不同随机种子,看 RMSE 的均值和标准差。标准差超过 0.01 就说明模型不稳定,需要检查数据划分或者增加正则化。
from sklearn.model_selection import cross_val_score # 用交叉验证评估 XGBoost cv_scores = cross_val_score( model, X, y, cv=5, scoring="neg_mean_squared_error" ) cv_rmse = np.sqrt(-cv_scores) print(f"CV RMSE: {cv_rmse.mean():.4f} (+/- {cv_rmse.std():.4f})")注意交叉验证时不能直接用 early stopping,因为每折的验证集不同,best_iteration会变。常见做法是先固定n_estimators跑交叉验证,或者用sklearn的GridSearchCV配合early_stopping的自定义封装。我一般先用固定轮数跑 CV 确认参数范围,再用 early stopping 在最终模型上确定最佳轮数。
4. 特征工程做对了,RMSE 能再降一截
4.1 构造组合特征:面积、房龄、卫浴数量的交互
原始特征往往不够表达业务逻辑。房价里最影响价格的几个因素:总面积、房龄、卫浴数量、车库容量、小区均价。把这些做成交互项,树模型能更容易分裂出有效节点。
我一般会构造这几类:
TotalSF = TotalBsmtSF + 1stFlrSF + 2ndFlrSF,总面积HouseAge = YrSold - YearBuilt,房龄RemodAge = YrSold - YearRemodAdd,翻新后年数TotalBath = FullBath + 0.5 * HalfBath + BsmtFullBath + 0.5 * BsmtHalfBath,加权卫浴数TotalPorch = OpenPorchSF + EnclosedPorch + 3SsnPorch + ScreenPorch,门廊总面积
df["TotalSF"] = df["TotalBsmtSF"] + df["1stFlrSF"] + df["2ndFlrSF"] df["HouseAge"] = df["YrSold"] - df["YearBuilt"] df["RemodAge"] = df["YrSold"] - df["YearRemodAdd"] df["TotalBath"] = (df["FullBath"] + 0.5 * df["HalfBath"] + df["BsmtFullBath"] + 0.5 * df["BsmtHalfBath"]) df["TotalPorch"] = (df["OpenPorchSF"] + df["EnclosedPorch"] + df["3SsnPorch"] + df["ScreenPorch"]) # 面积和质量的交互 df["SF_Qual"] = df["TotalSF"] * df["OverallQual"]SF_Qual这个交互项在 Ames 上效果很明显,因为大面积 + 高质量的房子价格不是线性叠加的。注意构造完要检查有没有无穷值或极端值,HouseAge如果出现负数说明数据有问题。
4.2 目标编码处理高基数类别,注意平滑和泄漏
Neighborhood 这种字段 One-Hot 之后维度还能接受,但如果是「小区名」有几百个取值,One-Hot 直接爆炸。目标编码的思路是用类别对应的目标均值替换类别值,但必须做平滑和交叉验证,否则会泄漏目标信息。
from sklearn.model_selection import KFold def target_encode(train_df, test_df, col, target, n_splits=5, smoothing=10): """目标编码,带平滑和 K 折防泄漏""" global_mean = train_df[target].mean() train_encoded = np.zeros(len(train_df)) test_encoded = np.zeros(len(test_df)) kf = KFold(n_splits=n_splits, shuffle=True, random_state=42) for train_idx, val_idx in kf.split(train_df): X_tr = train_df.iloc[train_idx] X_val = train_df.iloc[val_idx] # 计算每类的均值和计数 agg = X_tr.groupby(col)[target].agg(["mean", "count"]) # 平滑公式 smooth = (agg["mean"] * agg["count"] + global_mean * smoothing) / (agg["count"] + smoothing) train_encoded[val_idx] = X_val[col].map(smooth).fillna(global_mean) # 测试集用全量训练数据计算 agg_full = train_df.groupby(col)[target].agg(["mean", "count"]) smooth_full = (agg_full["mean"] * agg_full["count"] + global_mean * smoothing) / (agg_full["count"] + smoothing) test_encoded = test_df[col].map(smooth_full).fillna(global_mean) return train_encoded, test_encodedsmoothing=10是平滑系数,越大越偏向全局均值,适合样本少的类别。K 折的目的是让训练集里每个样本的编码都不包含自己的目标值,避免泄漏。这个函数在类别数超过 50 的时候特别有用,类别数少的时候直接用 One-Hot 更省事。
4.3 特征选择:用 SHAP 值筛掉拖后腿的变量
特征不是越多越好,噪声特征会拖累模型。我一般用 SHAP 值看每个特征对预测的贡献,把贡献接近 0 或者方向不稳定的特征删掉。XGBoost 自带的feature_importances_也能用,但它是基于分裂次数的,不如 SHAP 稳定。
import shap explainer = shap.TreeExplainer(model) shap_values = explainer.shap_values(X_train) # 看全局重要性 shap.summary_plot(shap_values, X_train, plot_type="bar", max_display=20) # 看单个特征的方向 shap.summary_plot(shap_values, X_train, max_display=20)SHAP 的 bar 图看重要性排序,beeswarm 图看每个特征的高值和低值分别把预测推向哪个方向。如果某个特征的重要性排在前 20 之外,而且 beeswarm 图上分布很散,就可以考虑删掉。我一般会迭代两三轮:删特征、重训、再看 SHAP,直到验证集 RMSE 不再下降。
5. 房价预测项目最容易翻车的五个地方
5.1 数据泄漏:目标编码和标准化在交叉验证里的坑
现象:交叉验证 RMSE 很低,但提交到测试集或者上线后误差翻倍。
原因:标准化、目标编码、缺失值插补这些步骤如果在全量数据上做,验证集的信息会泄漏到训练集。比如标准化用了全量数据的均值和方差,验证集的分布信息就提前被模型知道了。
解决:所有预处理必须放进 Pipeline,在交叉验证的每一折内部单独 fit。目标编码必须用 K 折方式生成,不能直接用全量目标均值。sklearn的Pipeline能自动处理这个问题,但自定义的目标编码需要手动封装成 transformer。
5.2 对数变换后忘记还原,RMSE 看起来很美但没意义
现象:模型 RMSE 0.11,觉得效果很好,但实际预测价格和真实价格差了几十万。
原因:目标变量做了log1p变换,评估时算的是对数尺度的 RMSE,没有做expm1逆变换。
解决:评估前先逆变换。np.expm1(y_pred)还原到原始价格尺度,再算 RMSE 或 MAE。对数尺度的 0.11 对应原始尺度大概是 11% 左右的相对误差,具体取决于价格水平。我一般两个尺度都看,对数尺度用于调参,原始尺度用于业务解释。
5.3 类别特征编码顺序错了,线性模型直接崩
现象:Ridge 回归的 RMSE 比均值预测还差,系数出现极端值。
原因:无序类别用了 Label Encoding,线性模型误以为类别之间有大小关系,比如把 Neighborhood 编码成 0 到 24,模型会认为 24 比 0 贵很多。
解决:线性模型必须用 One-Hot,树模型可以用 Label Encoding。如果特征维度太高,用目标编码替代。判断标准很简单:这个类别能不能排序?能排序就用有序编码,不能就用 One-Hot 或目标编码。
5.4 离群点没处理,树模型把分裂点带偏
现象:模型在大部分样本上表现正常,但在高价房上预测严重偏低。
原因:少数极端高价房把树的分裂点拉高了,导致正常价格区间的样本被分到不合适的叶子节点。
解决:训练前用 IQR 或业务规则筛掉离群点。Ames 里 GrLivArea 超过 4000 但价格低于 30 万的样本是公认的噪声。国内数据里,单价明显偏离小区均价的挂牌也要删。注意删之前先确认不是数据录入错误,有时候异常值恰恰是真实的高端交易。
5.5 交叉验证的 K 折没打乱,时间序列数据不能用随机 K 折
现象:交叉验证 RMSE 很低,但用最近的数据测试时误差很大。
原因:房价数据有时间维度,随机 K 折会让未来数据出现在训练集里,预测过去数据,造成乐观偏差。
解决:按时间切分,用TimeSeriesSplit或者手动按年份切。比如用 2015 到 2018 年数据训练,2019 年数据验证。如果数据没有时间字段,至少要用shuffle=True打乱,避免原始排序带来的偏差。
6. 把模型推到能用的程度:调参策略与结果解释
调参这件事,我的习惯是分两步走:先粗调确定数量级,再细调逼近最优。粗调用GridSearchCV在几个关键参数上网格搜索,比如max_depth在[3, 4, 5, 6]、learning_rate在[0.01, 0.05, 0.1]、subsample在[0.7, 0.8, 0.9]。这一步不用太细,目的是找到参数的大致范围。细调可以用Optuna或者Hyperopt做贝叶斯优化,在粗调结果附近采样,通常能再降 0.002 到 0.005 的 RMSE。
import optuna def objective(trial): params = { "n_estimators": 3000, "learning_rate": trial.suggest_float("learning_rate", 0.005, 0.05, log=True), "max_depth": trial.suggest_int("max_depth", 3, 7), "subsample": trial.suggest_float("subsample", 0.6, 0.95), "colsample_bytree": trial.suggest_float("colsample_bytree", 0.5, 0.9), "reg_alpha": trial.suggest_float("reg_alpha", 1e-3, 10, log=True), "reg_lambda": trial.suggest_float("reg_lambda", 1e-3, 10, log=True), "early_stopping_rounds": 100, "eval_metric": "rmse", "random_state": 42 } model = xgb.XGBRegressor(**params) model.fit(X_train, y_train, eval_set=[(X_val, y_val)], verbose=False) return model.best_score study = optuna.create_study(direction="minimize") study.optimize(objective, n_trials=50) print(f"最佳参数: {study.best_params}") print(f"最佳 RMSE: {study.best_value:.4f}")log=True表示在对数尺度上采样,适合学习率、正则项这种跨数量级的参数。n_trials=50是试验次数,数据量小的时候 30 到 50 次就够,数据量大可以加到 100 次以上。Optuna 会自动记录每次试验的结果,跑完之后用study.best_params拿到最优组合。
调完参之后,别只看 RMSE 一个数字。我一般会做三件事:第一,画预测值和真实值的散点图,看有没有系统性偏差;第二,看残差分布,理想情况下残差应该围绕 0 正态分布;第三,用 SHAP 值解释几个具体样本,确认模型的决策逻辑符合业务常识。如果模型认为「房龄越大价格越高」,那肯定有问题,得回去检查特征编码或者数据本身。
最后说一个我踩过的坑:有一次调参调到最后,验证集 RMSE 降到了 0.105,但把模型拿去看具体预测,发现高价房的预测全部偏低,低价房全部偏高。原因是目标变量做了对数变换,模型在对数尺度上优化 RMSE,等价于在原始尺度上优化相对误差,所以低价房的绝对误差被放大了。后来我在损失函数里加了样本权重,给高价房更高的权重,业务方看了才满意。这件事让我养成了一个习惯:每次调完参,都要把预测结果按价格分位数分组,看每一组的误差是不是均衡。如果某一组明显差,说明模型在这个区间有系统性问题,光看整体 RMSE 是发现不了的。希望帮到你。
本文还有配套的精品资源,点击获取