简介:这是一份面向计算机相关专业学生的Python机器学习实战资源,以房价预测为完整案例,适用于课程设计、期末大作业与实战练习。项目曾获98分并获导师认可,数据由爬虫从链家、安居客获取,仅供学习研究。压缩包共26个文件,约1.28MB,包含15个py脚本、1个ipynb笔记本、2个csv数据集、1个html报告及4张jpg可视化图,另有read、md、gitignore等说明文件,覆盖数据采集、预处理、建模到评估的全流程。已有103人学习。读者可参照代码注释与使用说明,掌握数据清洗、特征工程、模型选择、交叉验证与超参数调整等技能,并借助NumPy、Pandas、Scikit-learn、Matplotlib完成回归分析与可视化,理解均方误差等评估指标,快速搭建可复现的房价预测项目。
1. 从一份 98 分课设说起:这套房价预测资源到底能跑出什么
如果你正在为机器学习课程设计或者期末大作业发愁,大概率会刷到各种「房价预测」的题目。但真正拿到手能跑通、能讲清楚、还能扛住导师追问的项目其实不多。这份资源是一套完整的北京二手房房价预测案例,包含 Jupyter Notebook 主分析文件、爬虫源码、可视化脚本和导出的 HTML 报告,数据来自链家和安居客两个平台。它不是那种只丢一个 CSV 让你自己猜的「半成品」,而是从数据采集、清洗、特征工程到建模评估全流程都铺开了。适合谁?计算机相关专业做课设的学生、刚入门机器学习想找一个完整项目练手的开发者,以及需要一份可复现案例来对照自己流程的从业者。下面我按实际拆包的顺序,把这份资源的技术细节和落地路径讲清楚。
2. 拆开压缩包先看什么:文件结构与数据管线的真实面貌
拿到一个资源包,我习惯先不急着跑代码,而是把目录结构过一遍。这份资源的文件组织方式其实透露了作者的设计思路——它不是一个单文件脚本,而是一条从爬虫到分析再到可视化的完整链路。理解这个结构,后面调参和排错才不会迷路。
2.1 核心文件清单与各自职责
解压后你会看到这些内容:北京二手房房价预测与分析.ipynb是主分析文件,所有建模逻辑都在里面;visuals.py是独立出来的可视化脚本,说明作者把绘图逻辑做了模块化;lianjia.csv和anjuke.csv是两个平台的原始数据;spiders文件夹下放着lianjia_scrapy_crawl和anjuke_scrapy_crawl两套爬虫代码;read文件夹可能是数据读取的辅助脚本;三张 JPG 图片是分析结果的截图;还有一个导出的 HTML 版本方便直接浏览。
| 文件/文件夹 | 类型 | 作用 |
|---|---|---|
| 北京二手房房价预测与分析.ipynb | Notebook | 主流程:清洗、特征、建模、评估 |
| visuals.py | Python 脚本 | 封装可视化函数,供 Notebook 调用 |
| lianjia.csv / anjuke.csv | 数据文件 | 两个平台的原始二手房数据 |
| spiders/ | 爬虫目录 | 链家和安居客的采集代码 |
| read/ | 辅助目录 | 数据读取与预处理脚本 |
| 北京二手房房价预测与分析.html | 导出报告 | Notebook 的静态 HTML 版本 |
这个结构的好处是职责分离:爬虫归爬虫,分析归分析,可视化单独抽出来。你改模型的时候不用碰爬虫代码,换数据源的时候也不用动 Notebook 里的建模逻辑。常见做法是把所有东西塞一个.py里,跑起来是快,但一旦报错就得从头翻到尾,这种模块化的组织在课设答辩时也更好讲清楚「你做了什么」。
2.2 数据从哪来:链家与安居客的字段差异
两个 CSV 的字段并不完全一致。链家的数据通常包含小区名、户型、面积、朝向、楼层、建造年份、总价和单价;安居客的字段命名习惯不同,可能用「房屋总价」而不是「总价」,面积单位也可能有差异。这意味着你在合并或对比两个数据源之前,必须先做字段对齐。
我一般会先用 pandas 快速看一眼两个表的列名和数据类型:
import pandas as pd # 分别读取两个平台的数据,先看结构 lianjia = pd.read_csv('lianjia.csv') anjuke = pd.read_csv('anjuke.csv') # 查看列名、数据类型、缺失情况 print("链家数据形状:", lianjia.shape) print("安居客数据形状:", anjuke.shape) print("\n链家列名:", lianjia.columns.tolist()) print("\n安居客列名:", anjuke.columns.tolist()) print("\n链家缺失值:\n", lianjia.isnull().sum()) print("\n安居客缺失值:\n", anjuke.isnull().sum())这段代码不涉及任何建模,但它是整个项目的地基。shape告诉你数据量够不够,columns告诉你字段能不能直接对齐,isnull().sum()告诉你哪些列需要填充或丢弃。很多人拿到数据直接df.dropna()一了百了,结果发现训练集少了一半——这就是没先看缺失分布就动手的后果。
参数方面,pd.read_csv()默认用逗号分隔、UTF-8 编码。如果链家数据是 GBK 编码(早期爬虫常见),你会遇到UnicodeDecodeError,这时候加encoding='gbk'或encoding='utf-8-sig'就能解决。另外注意anjuke.csv如果有 BOM 头,用utf-8-sig读进来列名才不会带隐藏字符。
2.3 爬虫代码的角色:为什么它只是参考
spiders文件夹里的两套爬虫代码用的是 Scrapy 框架。链家的页面结构相对规整,分页逻辑清晰;安居客的反爬策略更激进,可能需要处理动态加载和请求头校验。但这份资源的核心价值不在爬虫本身——数据已经给你了,爬虫代码更多是让你理解数据是怎么来的,以及在答辩时能说清楚「数据采集环节我做了哪些工作」。
如果你打算自己重新跑爬虫,有几个现实问题要面对:链家的列表页有翻页限制,通常只能看到前 100 页;安居客对同一 IP 的请求频率有阈值,跑太快会被临时限制。常见做法是设置DOWNLOAD_DELAY参数控制请求间隔,在settings.py里加上:
# Scrapy settings.py 中的关键配置 DOWNLOAD_DELAY = 3 # 每次请求间隔3秒,降低被限制的概率 RANDOMIZE_DOWNLOAD_DELAY = True # 在0.5到1.5倍之间随机浮动 USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...' # 模拟浏览器DOWNLOAD_DELAY设为 3 秒意味着爬 1000 条数据大约需要 50 分钟,这是速度和稳定之间的折中。RANDOMIZE_DOWNLOAD_DELAY让间隔不固定,避免被识别出机器规律。但说实话,如果你只是做课设,直接用现成的 CSV 就够了,爬虫代码理解逻辑即可,不必花大量时间在采集上——导师看的是你的分析和建模能力,不是你爬了多少条数据。
3. 从原始 CSV 到特征矩阵:数据清洗与特征工程的实操细节
数据清洗和特征工程是房价预测项目里最耗时也最能拉开差距的环节。模型选得再花哨,特征没处理好,结果就是欠拟合或者过拟合。这一章我把 Notebook 里的关键步骤拆开讲,包括缺失值处理、类别特征编码和衍生特征的构造。
3.1 缺失值与异常值的处理策略
北京二手房数据里,缺失值主要集中在建造年份、楼层和朝向这几列。建造年份缺失可能是因为老小区没有明确记录,楼层缺失可能是爬虫没抓到,朝向缺失则可能是房源描述里没写。处理方式不能一刀切。
import numpy as np # 建造年份:用中位数填充,同时标记是否缺失 df['建造年份_缺失'] = df['建造年份'].isnull().astype(int) df['建造年份'].fillna(df['建造年份'].median(), inplace=True) # 楼层:提取数字部分,转为数值型 df['楼层数值'] = df['楼层'].str.extract(r'(\d+)').astype(float) df['楼层数值'].fillna(df['楼层数值'].median(), inplace=True) # 面积:过滤掉极端异常值 df = df[(df['面积'] > 10) & (df['面积'] < 1000)] # 总价:去掉明显录入错误的记录 df = df[df['总价'] > 0]这里有几个设计决策值得说。建造年份用中位数而不是均值,是因为年份分布可能偏斜,均值会被极端值拉偏。加一个「是否缺失」的标记列,是让模型知道这个值原本是空的,有时候「缺失」本身就是信息——比如某些高端小区就是不公开建造年份。面积过滤掉小于 10 平米和大于 1000 平米的记录,是因为这些大概率是录入错误或者特殊物业类型,留在训练集里会干扰模型。
异常值检测还可以用 IQR 方法:
# 用四分位距检测总价异常值 Q1 = df['总价'].quantile(0.25) Q3 = df['总价'].quantile(0.75) IQR = Q3 - Q1 lower_bound = Q1 - 1.5 * IQR upper_bound = Q3 + 1.5 * IQR # 查看异常值占比,决定是否剔除 outliers = df[(df['总价'] < lower_bound) | (df['总价'] > upper_bound)] print(f"异常值占比: {len(outliers) / len(df) * 100:.2f}%")如果异常值占比在 5% 以内,可以考虑剔除;如果超过 10%,就要谨慎了——可能是数据分布本身就这样,强行剔除会丢失重要信息。我一般会先画个箱线图看看分布形态再决定。
3.2 类别特征编码:独热编码还是目标编码
户型、朝向、装修情况这些类别特征不能直接喂给模型。最常见的做法是独热编码(One-Hot Encoding),但它有个问题:如果某个特征的类别很多(比如小区名可能有几千个),独热编码会让特征维度爆炸。
# 对低基数类别特征做独热编码 low_card_cols = ['朝向', '装修情况', '楼层类型'] df = pd.get_dummies(df, columns=low_card_cols, drop_first=True) # 对高基数特征(如小区名)用目标编码 # 目标编码:用该类别对应的目标变量均值替换类别值 target_mean = df.groupby('小区名')['单价'].mean() df['小区名_目标编码'] = df['小区名'].map(target_mean) df.drop('小区名', axis=1, inplace=True)drop_first=True是为了避免虚拟变量陷阱——如果朝向有 4 个类别,独热编码会产生 4 列,但其中一列可以由其他三列推导出来,造成多重共线性。目标编码则用小区对应的平均单价来替换小区名,这样既保留了小区信息,又不会让维度膨胀。但目标编码有个坑:如果某个小区只有一两条记录,它的均值就不可靠,容易导致过拟合。常见做法是加平滑处理:
# 带平滑的目标编码 global_mean = df['单价'].mean() smoothing = 10 # 平滑参数,越大越保守 target_mean = df.groupby('小区名')['单价'].agg(['mean', 'count']) target_mean['smoothed'] = ( (target_mean['mean'] * target_mean['count'] + global_mean * smoothing) / (target_mean['count'] + smoothing) ) df['小区名_目标编码'] = df['小区名'].map(target_mean['smoothed'])平滑参数smoothing=10的意思是:如果一个小区只有 2 条记录,它的编码值会更靠近全局均值;如果有 100 条记录,就更靠近自己的均值。这样既保留了信息,又避免了小样本的噪声。
3.3 衍生特征:从面积和总价里挖出更多信号
原始特征往往不够用,衍生特征能显著提升模型表现。房价预测里最有效的衍生特征包括:单价(总价除以面积)、房间数(从户型字符串里提取)、房龄(当前年份减去建造年份)、楼层占比(当前楼层除以总楼层)。
# 单价:最直接的价格信号 df['单价'] = df['总价'] / df['面积'] # 房间数:从"2室1厅"中提取数字 df['房间数'] = df['户型'].str.extract(r'(\d+)室').astype(float) df['厅数'] = df['户型'].str.extract(r'(\d+)厅').astype(float) # 房龄:假设当前年份为2024 df['房龄'] = 2024 - df['建造年份'] # 楼层占比:低楼层和高楼层的价格差异可能非线性 df['楼层占比'] = df['楼层数值'] / df['总楼层'] # 面积分段:把连续面积离散化,捕捉非线性关系 df['面积分段'] = pd.cut(df['面积'], bins=[0, 50, 90, 140, 300, 1000], labels=['小户型', '刚需', '改善', '大平层', '豪宅']) df = pd.get_dummies(df, columns=['面积分段'], drop_first=True)单价是最重要的衍生特征,因为总价受面积影响太大,单价才能反映地段和品质。房龄捕捉了新房和二手房的溢价差异。楼层占比比单纯的楼层数值更有意义——6 层楼的第 3 层和 30 层楼的第 3 层,居住体验完全不同。面积分段则是把连续变量离散化,让线性模型也能捕捉非线性关系。
做完这些,你可以用df.info()看一下最终的特征矩阵有多少列、有没有残留的缺失值。一般来说,经过这一轮处理,特征数会从原始的十几个扩展到三四十个,训练集和测试集的划分建议用train_test_split按 8:2 切分,随机种子固定为 42 保证可复现。
4. 模型训练与评估:线性回归、随机森林和 XGBoost 的对比
特征准备好之后,就进入建模环节。这份 Notebook 里大概率用了线性回归做基线,然后上随机森林或梯度提升树做提升。我按实际跑下来的经验,把三种模型的训练、调参和评估讲清楚。
4.1 基线模型:线性回归能告诉你什么
线性回归不是用来拿高分的,它的价值在于给你一个可解释的基线。如果线性回归的 R² 只有 0.5,而随机森林到了 0.85,说明特征和目标之间存在大量非线性关系;如果两者差距不大,说明线性模型已经够用了。
from sklearn.linear_model import LinearRegression from sklearn.model_selection import train_test_split from sklearn.metrics import mean_squared_error, r2_score import numpy as np # 准备特征矩阵和目标变量 X = df.drop(['总价', '单价'], axis=1) # 注意:单价不能作为特征,否则数据泄露 y = df['总价'] # 划分训练集和测试集 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) # 训练线性回归 lr = LinearRegression() lr.fit(X_train, y_train) # 预测与评估 y_pred_lr = lr.predict(X_test) rmse_lr = np.sqrt(mean_squared_error(y_test, y_pred_lr)) r2_lr = r2_score(y_test, y_pred_lr) print(f"线性回归 RMSE: {rmse_lr:.2f} 万元") print(f"线性回归 R²: {r2_lr:.4f}")这里有个关键点:单价必须从特征里去掉。因为单价 = 总价 / 面积,如果你把单价作为特征去预测总价,模型会直接学到这个公式,R² 会虚高到 0.99,但这是数据泄露,答辩时被问到就露馅了。random_state=42是固定随机种子,保证每次划分结果一致,方便对比不同模型。
线性回归的系数还能告诉你哪些特征影响最大:
# 查看特征系数 coef_df = pd.DataFrame({ '特征': X.columns, '系数': lr.coef_ }).sort_values('系数', key=abs, ascending=False) print(coef_df.head(10))如果面积的系数是正的且绝对值最大,说明面积是影响总价的最主要因素,这符合直觉。如果某个特征的系数符号和常识相反(比如房龄越大价格越高),那就要检查是不是有多重共线性或者数据有问题。
4.2 随机森林与 XGBoost:调参的重点在哪
随机森林和 XGBoost 是这类项目里最常用的两个模型。随机森林靠 bagging 降低方差,XGBoost 靠 boosting 降低偏差。两者的调参重点不同。
from sklearn.ensemble import RandomForestRegressor from xgboost import XGBRegressor # 随机森林:重点调 n_estimators 和 max_depth rf = RandomForestRegressor( n_estimators=200, # 树的数量,越多越稳定但计算越慢 max_depth=15, # 树的最大深度,控制过拟合 min_samples_split=5, # 节点分裂所需最小样本数 min_samples_leaf=2, # 叶节点最小样本数 random_state=42, n_jobs=-1 # 并行计算 ) rf.fit(X_train, y_train) y_pred_rf = rf.predict(X_test) rmse_rf = np.sqrt(mean_squared_error(y_test, y_pred_rf)) r2_rf = r2_score(y_test, y_pred_rf) print(f"随机森林 RMSE: {rmse_rf:.2f} 万元, R²: {r2_rf:.4f}") # XGBoost:重点调 learning_rate 和 n_estimators xgb = XGBRegressor( n_estimators=300, learning_rate=0.05, # 学习率,越小需要越多树 max_depth=6, # XGBoost 的树通常更浅 subsample=0.8, # 行采样比例 colsample_bytree=0.8, # 列采样比例 reg_alpha=0.1, # L1 正则化 reg_lambda=1.0, # L2 正则化 random_state=42 ) xgb.fit(X_train, y_train) y_pred_xgb = xgb.predict(X_test) rmse_xgb = np.sqrt(mean_squared_error(y_test, y_pred_xgb)) r2_xgb = r2_score(y_test, y_pred_xgb) print(f"XGBoost RMSE: {rmse_xgb:.2f} 万元, R²: {r2_xgb:.4f}")随机森林的n_estimators设到 200 通常够用,再往上收益递减。max_depth=15是防止每棵树长得太深导致过拟合。XGBoost 的learning_rate=0.05配合n_estimators=300是常见组合,学习率低就需要更多树来补偿,但泛化能力更好。subsample和colsample_bytree都是 0.8,意思是每棵树随机用 80% 的样本和 80% 的特征,这能增加模型的多样性,降低过拟合风险。
调参不要盲目网格搜索,先用手动调参找到大致范围,再用GridSearchCV精细搜索。比如先试max_depth在 5、10、15、20 上的表现,找到拐点后再细化。
4.3 评估指标:RMSE、MAE 和 R² 各自说明什么
RMSE(均方根误差)对大误差敏感,适合你关心「最差能差多少」的场景。MAE(平均绝对误差)对所有误差一视同仁,适合你关心「平均差多少」的场景。R² 告诉你模型解释了目标变量多少比例的方差。
from sklearn.metrics import mean_absolute_error # 对比三个模型的指标 models = {'线性回归': y_pred_lr, '随机森林': y_pred_rf, 'XGBoost': y_pred_xgb} for name, pred in models.items(): rmse = np.sqrt(mean_squared_error(y_test, pred)) mae = mean_absolute_error(y_test, pred) r2 = r2_score(y_test, pred) print(f"{name}: RMSE={rmse:.2f}, MAE={mae:.2f}, R²={r2:.4f}")如果 XGBoost 的 RMSE 是 45 万,意思是预测总价和实际总价平均差了 45 万左右。对于北京二手房来说,这个误差算合理——毕竟同一小区不同楼层的价格都能差几十万。如果 RMSE 超过 100 万,那就要检查特征里是不是漏了关键信息,或者数据里有大量异常值没处理。
还有一个容易被忽略的点:交叉验证。单次 train_test_split 的结果可能受划分方式影响,用 5 折交叉验证能得到更稳定的评估:
from sklearn.model_selection import cross_val_score # 5折交叉验证,评估模型稳定性 cv_scores = cross_val_score(xgb, X, y, cv=5, scoring='r2') print(f"XGBoost 5折交叉验证 R²: {cv_scores.mean():.4f} (+/- {cv_scores.std():.4f})")如果交叉验证的均值比单次测试低很多,说明模型对数据划分敏感,可能需要更多数据或者更简单的模型。
5. 避坑与排查:跑这份代码时最容易翻车的五个地方
这一章是我实际跑下来踩过的坑,以及帮别人排查时反复遇到的问题。每个都按「现象 → 原因 → 解决」的结构写,你遇到类似情况可以直接对照。
5.1 中文列名导致的 KeyError
现象:运行df['总价']时报KeyError: '总价',但明明 CSV 里就有这一列。
原因:CSV 文件的编码不是 UTF-8,pandas 读进来后中文列名变成了乱码,比如'鎬讳环'。这种情况在 Windows 上用 Excel 保存的 CSV 里特别常见。
解决:先检查编码,用chardet检测或者直接试几种编码:
# 尝试不同编码读取 for enc in ['utf-8', 'gbk', 'utf-8-sig', 'gb18030']: try: df = pd.read_csv('lianjia.csv', encoding=enc) print(f"编码 {enc} 读取成功,列名: {df.columns.tolist()[:5]}") break except UnicodeDecodeError: continue如果列名还是乱码,可以在读取后手动重命名:df.columns = ['小区名', '户型', '面积', ...]。最稳妥的做法是先用utf-8-sig试,不行再换gbk。
5.2 特征矩阵里混入了目标变量
现象:模型 R² 高达 0.99,RMSE 只有几万,但实际预测新数据时误差巨大。
原因:特征矩阵里包含了单价或者总价的衍生列。比如你构造了单价特征,又用总价做目标,模型直接学到了总价 = 单价 × 面积这个恒等式。
解决:在train_test_split之前,明确列出要丢弃的列:
# 明确指定哪些列不能作为特征 drop_cols = ['总价', '单价', '总价_分段'] # 所有和目标直接相关的列 X = df.drop(columns=[c for c in drop_cols if c in df.columns]) y = df['总价']养成习惯:每次构造新特征后,检查一下它和目标变量的相关系数。如果某个特征和目标的相关性超过 0.95,大概率是泄露了。
5.3 XGBoost 安装失败或版本不兼容
现象:import xgboost报ModuleNotFoundError,或者训练时报AttributeError: 'XGBRegressor' object has no attribute 'fit'。
原因:XGBoost 没有安装,或者安装的版本和 scikit-learn 版本不兼容。在 Windows 上还可能缺少 Visual C++ 运行库。
解决:用 pip 安装指定版本,避免最新版和旧版 sklearn 冲突:
# 安装 XGBoost,指定兼容版本 pip install xgboost==1.7.6 scikit-learn==1.2.2 # 如果安装报错,先升级 pip pip install --upgrade pip setuptools wheel如果还是不行,可以退而求其次用GradientBoostingRegressor(sklearn 自带),效果接近但不需要额外安装:
from sklearn.ensemble import GradientBoostingRegressor gbr = GradientBoostingRegressor(n_estimators=300, learning_rate=0.05, max_depth=6)5.4 可视化脚本报字体错误
现象:运行visuals.py时中文显示为方框,或者报RuntimeWarning: Glyph missing from current font。
原因:matplotlib 默认字体不支持中文,需要手动指定中文字体。
解决:在绘图前设置字体参数:
import matplotlib.pyplot as plt # 设置中文字体,Windows 用 SimHei,Mac 用 Arial Unicode MS plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'DejaVu Sans'] plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题如果SimHei不存在,可以用matplotlib.font_manager查找系统可用字体:
from matplotlib.font_manager import FontManager fm = FontManager() chinese_fonts = [f.name for f in fm.ttflist if 'Hei' in f.name or 'Song' in f.name] print("可用中文字体:", chinese_fonts)5.5 Notebook 内存溢出导致内核崩溃
现象:运行到特征工程或模型训练时,Jupyter 内核突然重启,之前的变量全部丢失。
原因:数据量太大或者特征维度太高,内存不够用。特别是做独热编码后,如果小区名有几千个类别,特征矩阵会瞬间膨胀到几万列。
解决:分步处理,及时释放不用的变量:
import gc # 删除中间变量,释放内存 del df_raw, temp_df gc.collect() # 对高基数特征用目标编码代替独热编码 # 如果必须用独热编码,先限制类别数量 top_categories = df['小区名'].value_counts().head(50).index df['小区名_简化'] = df['小区名'].where(df['小区名'].isin(top_categories), '其他') df = pd.get_dummies(df, columns=['小区名_简化'], drop_first=True)另外,把数据类型从float64降为float32也能省一半内存:
# 降低数值精度 for col in df.select_dtypes(include=['float64']).columns: df[col] = df[col].astype('float32')6. 进阶技巧:用特征重要性和残差图验证模型是否真的学到了东西
模型跑出 R² 0.85 不算完,你得知道它为什么能到 0.85,以及它在哪些样本上翻车。这一章讲两个我每次做完回归都会走的验证步骤,以及一个能直接提升答辩表现的小技巧。
6.1 特征重要性排序:确认模型没走捷径
随机森林和 XGBoost 都提供feature_importances_属性,但直接看数值不够直观,我一般会画成横向条形图:
import matplotlib.pyplot as plt import pandas as pd # 获取特征重要性 importance = pd.DataFrame({ '特征': X.columns, '重要性': xgb.feature_importances_ }).sort_values('重要性', ascending=True) # 只显示前15个重要特征 top_features = importance.tail(15) plt.figure(figsize=(10, 8)) plt.barh(top_features['特征'], top_features['重要性'], color='steelblue') plt.xlabel('重要性得分') plt.title('XGBoost 特征重要性 Top 15') plt.tight_layout() plt.show()如果面积、单价(注意这里指的是作为衍生特征但没泄露的那个版本)、房龄、楼层占比排在前列,说明模型学到了符合常识的规律。如果某个你随手构造的无关特征排到了前三,那就要警惕了——可能是数据泄露,也可能是这个特征和某个关键特征高度相关。
我还会做一件事:把重要性最低的 10 个特征删掉,重新训练一次,看 R² 有没有明显下降。如果没有下降,说明这些特征确实是噪声,删掉能让模型更简洁。
6.2 残差分析:找出模型在哪些房子上预测不准
残差图是回归模型最诚实的体检报告。横轴是预测值,纵轴是残差(实际值减预测值),理想情况下残差应该随机分布在 0 附近,没有明显规律。
# 计算残差 residuals = y_test - y_pred_xgb plt.figure(figsize=(10, 6)) plt.scatter(y_pred_xgb, residuals, alpha=0.5, s=10) plt.axhline(y=0, color='red', linestyle='--', linewidth=1) plt.xlabel('预测总价(万元)') plt.ylabel('残差(万元)') plt.title('XGBoost 残差分布') plt.tight_layout() plt.show() # 统计残差超过100万的样本占比 large_residual = (abs(residuals) > 100).sum() print(f"残差超过100万的样本数: {large_residual}, 占比: {large_residual/len(residuals)*100:.2f}%")如果残差图呈现喇叭形(预测值越大残差越大),说明模型对高价房的预测能力不足,可能需要单独对高价房建模,或者对目标变量做对数变换。如果残差在某个区间集中偏离 0,说明模型在这个价格段有系统性偏差。
我一般还会把残差最大的 10 个样本单独拎出来看看:
# 找出预测误差最大的样本 error_df = pd.DataFrame({ '实际总价': y_test, '预测总价': y_pred_xgb, '残差': residuals }) worst_predictions = error_df.reindex(error_df['残差'].abs().sort_values(ascending=False).index) print(worst_predictions.head(10))这些样本往往是特殊房源——比如学区房、别墅、或者数据录入有误的记录。分析它们能帮你判断是模型问题还是数据问题。
6.3 一个答辩加分项:用 SHAP 值解释单个预测
SHAP(SHapley Additive exPlanations)能告诉你每个特征对某一条预测结果的贡献是多少。这在答辩时非常有用——导师问「为什么这套房子预测 500 万」,你可以直接展示 SHAP 图说明每个特征的加减分。
import shap # 创建 SHAP 解释器 explainer = shap.TreeExplainer(xgb) shap_values = explainer.shap_values(X_test) # 全局特征重要性(SHAP 版本) shap.summary_plot(shap_values, X_test, plot_type="bar", show=False) plt.tight_layout() plt.show() # 单个样本的解释 sample_idx = 0 shap.force_plot( explainer.expected_value, shap_values[sample_idx], X_test.iloc[sample_idx], matplotlib=True, show=False ) plt.tight_layout() plt.show()SHAP 的安装用pip install shap即可。如果报错,检查 numpy 版本是否兼容,常见做法是pip install shap --upgrade配合numpy>=1.20。
从那以后我每次做完回归项目,都会强制走一遍「特征重要性 → 残差图 → SHAP 单样本解释」这三步。它不仅能帮你发现模型的问题,还能让你在答辩时面对「你这个模型可解释性怎么样」的提问时,有实打实的东西可以展示。希望帮到你。
本文还有配套的精品资源,点击获取