
简介这是一份面向计算机相关专业学生的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, encodingutf-8) # 查看整体情况 print(f样本数: {df.shape[0]}, 特征数: {df.shape[1]}) print(df[SalePrice].describe()) # 按缺失比例排序决定处理策略 missing df.isnull().sum().sort_values(ascendingFalse) 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(1x)比直接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等级有明确顺序无序类别NeighborhoodOne-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, columnsnominal_cols, drop_firstTrue) # 剩余类别统一 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_firstTrue是为了避免虚拟变量陷阱在 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, axis1) y df[SalePrice] # 标准化 Ridge用交叉验证评估 pipe Pipeline([ (scaler, StandardScaler()), (ridge, Ridge(alpha10.0)) ]) kf KFold(n_splits5, shuffleTrue, random_state42) scores cross_val_score(pipe, X, y, cvkf, scoringneg_mean_squared_error) rmse_scores np.sqrt(-scores) print(fRidge RMSE: {rmse_scores.mean():.4f} (/- {rmse_scores.std():.4f}))alpha10.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.9colsample_bytree0.6 到 0.8reg_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_size0.2, random_state42 ) model xgb.XGBRegressor( n_estimators3000, learning_rate0.01, max_depth4, subsample0.8, colsample_bytree0.7, reg_alpha0.1, reg_lambda1.0, early_stopping_rounds100, eval_metricrmse, random_state42 ) model.fit( X_train, y_train, eval_set[(X_train, y_train), (X_val, y_val)], verbose100 ) print(f最佳迭代轮数: {model.best_iteration}) print(f验证集 RMSE: {model.best_score:.4f})early_stopping_rounds100表示验证集 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, cv5, scoringneg_mean_squared_error ) cv_rmse np.sqrt(-cv_scores) print(fCV 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_splits5, smoothing10): 目标编码带平滑和 K 折防泄漏 global_mean train_df[target].mean() train_encoded np.zeros(len(train_df)) test_encoded np.zeros(len(test_df)) kf KFold(n_splitsn_splits, shuffleTrue, random_state42) 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_encodedsmoothing10是平滑系数越大越偏向全局均值适合样本少的类别。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_typebar, max_display20) # 看单个特征的方向 shap.summary_plot(shap_values, X_train, max_display20)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 年数据验证。如果数据没有时间字段至少要用shuffleTrue打乱避免原始排序带来的偏差。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, logTrue), 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, logTrue), reg_lambda: trial.suggest_float(reg_lambda, 1e-3, 10, logTrue), 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)], verboseFalse) return model.best_score study optuna.create_study(directionminimize) study.optimize(objective, n_trials50) print(f最佳参数: {study.best_params}) print(f最佳 RMSE: {study.best_value:.4f})logTrue表示在对数尺度上采样适合学习率、正则项这种跨数量级的参数。n_trials50是试验次数数据量小的时候 30 到 50 次就够数据量大可以加到 100 次以上。Optuna 会自动记录每次试验的结果跑完之后用study.best_params拿到最优组合。调完参之后别只看 RMSE 一个数字。我一般会做三件事第一画预测值和真实值的散点图看有没有系统性偏差第二看残差分布理想情况下残差应该围绕 0 正态分布第三用 SHAP 值解释几个具体样本确认模型的决策逻辑符合业务常识。如果模型认为「房龄越大价格越高」那肯定有问题得回去检查特征编码或者数据本身。最后说一个我踩过的坑有一次调参调到最后验证集 RMSE 降到了 0.105但把模型拿去看具体预测发现高价房的预测全部偏低低价房全部偏高。原因是目标变量做了对数变换模型在对数尺度上优化 RMSE等价于在原始尺度上优化相对误差所以低价房的绝对误差被放大了。后来我在损失函数里加了样本权重给高价房更高的权重业务方看了才满意。这件事让我养成了一个习惯每次调完参都要把预测结果按价格分位数分组看每一组的误差是不是均衡。如果某一组明显差说明模型在这个区间有系统性问题光看整体 RMSE 是发现不了的。希望帮到你。本文还有配套的精品资源点击获取