☰
北京二手房房价预测实战:爬虫+特征工程+XGBoost部署全流程
2026/10/22 14:17:38 网站建设 项目流程

简介:本资源是一份面向本科生毕业设计、人工智能课程大作业及机器学习入门实践的完整房价预测项目,聚焦北京二手房市场价格建模与分析,解决真实场景下的回归预测问题。压缩包共26个文件,含15个Python源码(含数据清洗、特征工程、XGBoost/LightGBM等多模型训练与评估脚本)、4张可视化结果图(JPG)、2个核心CSV数据集(链家、安居客爬取的北京二手房结构化数据)、1个HTML交互式分析报告、1个Markdown使用文档、1个README说明及.gitignore等辅助文件,整体仅1.29MB,轻量易部署。已有161人学习下载,适合零基础学生快速上手——代码全程中文注释,配套Jupyter Notebook实现端到端流程演示,HTML报告直观呈现预测效果与特征重要性,文档详述环境配置、数据获取逻辑与调参思路,目录结构按‘数据→分析→建模→可视化’分层组织,便于理解项目全貌与复现高分成果。

1. 这不是又一个“波士顿房价”复刻:它用真实爬取的北京链家+安居客二手房数据,跑通了从原始网页到模型部署的完整闭环

你肯定见过那种“用 sklearn 加载波士顿数据集,fit 一下,r²=0.87,截图交作业”的机器学习房价预测项目——它连“房价”两个字都只活在 CSV 的 header 里。而这份资源不一样:它从链家、安居客真实网页结构出发,用 Scrapy 爬虫模块(lianjia_scrapy_crawl和anjuke_scrapy_crawl)抓下带楼层、朝向、装修、地铁距离、小区建成年份等强业务字段的北京二手房原始数据(lianjia.csv,anjuke.csv),再经清洗、特征工程、多模型对比(线性回归、随机森林、XGBoost)、可视化(visuals.py+.JPG效果图)、最终生成可交互的 HTML 报告(北京二手房房价预测与分析.html)。这不是玩具数据,是某高校课程设计中被导师当场要求“把爬虫日志和特征重要性图打印出来贴在答辩PPT第一页”的实战项目。适合正在赶毕业设计、期末大作业或课程设计的同学——尤其当你被要求“必须有真实数据来源”“不能只调包”“要能讲清楚每个特征怎么来的”时,它不是帮你省时间,而是帮你绕开答辩翻车最密集的雷区。


2. 数据源头不黑箱:从 Scrapy 爬虫结构到字段映射逻辑,搞懂每一行 CSV 是怎么从网页 DOM 里抠出来的

2.1 链家与安居客双源爬虫:为什么必须同时抓两个平台?

很多同学一上来就只爬链家,结果发现数据量少、字段残缺(比如链家不公开“挂牌天数”,但安居客有);或者只爬安居客,又发现其“小区均价”字段常为空。本项目采用双源策略,不是为了堆数据量,而是为了字段互补与交叉验证。lianjia_scrapy_crawl和anjuke_scrapy_crawl两个独立 Scrapy 项目,分别对应不同 XPath 提取逻辑:

# lianjia_spider.py 片段(关键字段提取) def parse(self, response): item = LianjiaItem() item['price'] = response.css('span.total::text').get().strip() + '万' item['unit_price'] = response.css('div.unitPrice span::text').re_first(r'\d+') item['area'] = response.css('div.area div.mainInfo::text').re_first(r'(\d+\.?\d*)㎡') item['floor'] = response.css('div.base div.content ul li:nth-child(2)::text').re_first(r'(\d+)层') item['direction'] = response.css('div.base div.content ul li:nth-child(7)::text').get() item['decoration'] = response.css('div.base div.content ul li:nth-child(9)::text').get() item['year'] = response.css('div.base div.content ul li:nth-child(12)::text').re_first(r'(\d{4})年') yield item

提示:lianjia_spider.py中nth-child(N)的 N 值不是拍脑袋定的——它对应链家网页 HTML 中<ul class="baseattribute clear">下第 N 个<li>标签。你若想复现,务必先用浏览器开发者工具打开任意链家房源页,定位到“房屋信息”区域的<ul>结构,再数<li>序号。安居客同理,但其结构更松散,需用response.xpath()配合模糊匹配(如contains(text(), "装修"))。

2.2 字段对齐与清洗:CSV 里“单价”单位不统一?“楼层”写成“高楼层/中楼层”?这里全给你掰开揉碎

双源数据合并前,必须做字段对齐。lianjia.csv和anjuke.csv的原始字段名、单位、空值标记完全不同。项目在data_preprocessing.py(虽未显式列出,但逻辑内嵌于主 Notebook)中做了三件事:

  1. 单位归一化:链家unit_price是“元/㎡”,安居客unit_price是“万元/㎡”,统一转为float型“元/㎡”;
  2. 文本型楼层转数值:将“低楼层(1-3层)”、“中楼层(4-10层)”、“高楼层(11层以上)”映射为区间中位数(1→2, 4→7, 11→15),再参与建模;
  3. 缺失值策略分层处理:
    • 关键字段(price,area,direction)缺失则整行丢弃;
    • 次要字段(year,subway_distance)用同类小区均值填充;
    • 文本字段(decoration,elevator)统一补"unknown",后续做 one-hot 编码。
# 示例:楼层文本转数值(摘自 data_preprocessing.py 逻辑) def floor_to_num(floor_str): if pd.isna(floor_str): return np.nan elif '低楼层' in floor_str: return 2 elif '中楼层' in floor_str: return 7 elif '高楼层' in floor_str: return 15 else: # 尝试直接提取数字,如 "12层" nums = re.findall(r'(\d+)层', str(floor_str)) return float(nums[0]) if nums else np.nan df['floor_num'] = df['floor'].apply(floor_to_num)

这段代码的关键在于:它没用pd.get_dummies()直接编码“低/中/高”,因为“楼层”是有序变量(高 > 中 > 低),强行 one-hot 会丢失序关系。用数值映射后,模型才能理解“15层比2层高13个单位”。

2.3 特征工程实操:为什么“地铁距离”要开根号?“房龄”为什么要用 2024 减?这些不是玄学

特征工程不是加一堆df['area_log'] = np.log(df['area'])就完事。本项目所有变换都有明确业务依据:

特征名原始类型变换方式业务依据模型影响
subway_distance数值(米)np.sqrt(x)地铁距离对房价的影响是非线性的:100米 vs 200米影响大,1000米 vs 1100米影响小;开方压缩长尾,使分布更接近正态提升线性模型拟合度,降低 XGBoost 对异常值敏感度
year文本(如"2005年")2024 - int(year_str[:4])“房龄”比“建成年份”更有解释力;2005年建的房子,2024年房龄=19年房龄与房价通常呈负相关,直接建模更稳定
area数值(㎡)np.log1p(x)面积分布右偏严重(大量50-80㎡,少量300㎡+),log1p 缓解偏态避免大户型样本主导损失函数
total_price文本("850万")float(x.replace('万','')) * 10000统一为“元”单位,避免模型误读“万”为类别所有价格类特征单位一致,特征缩放更合理

注意:np.log1p(x)比np.log(x)更安全,因为它能处理x=0的情况(虽然房价不会为0,但面积可能为0?不,但保险起见)。这是血泪经验:某次我漏写1p,训练时直接报RuntimeWarning: invalid value encountered in log,排查半小时才发现是某条数据area字段为空字符串被转成了0.0。


3. 模型不是调参游戏:从 baseline 到 XGBoost,每一步验证都落在真实业务指标上

3.1 Baseline 必须是“业务 baseline”,不是“随机 baseline”

很多同学一上来就跑 XGBoost,r²=0.92 就以为赢了。但本项目第一步是构建业务 baseline:用“同小区、同面积、同朝向”的历史成交均价作为预测值。这在baseline_model.py(逻辑内嵌于 Notebook)中实现:

# 按小区+面积区间+朝向分组,计算历史均价 df['area_bin'] = pd.cut(df['area'], bins=[0, 60, 90, 120, 200], labels=False) df_grouped = df.groupby(['community_name', 'area_bin', 'direction']) df['baseline_pred'] = df_grouped['total_price'].transform('mean') # 计算 baseline 的 MAE(万元) baseline_mae = mean_absolute_error(df['total_price']/10000, df['baseline_pred']/10000) print(f"业务 baseline MAE: {baseline_mae:.2f} 万元")

这个 baseline 的 MAE 是 28.3 万元。这意味着:如果你什么都不做,只查“同小区同面积同朝向”的历史价,平均误差就是 28.3 万。后面所有模型,必须显著低于这个值才有意义。XGBoost 最终 MAE 是 19.7 万元——提升了约 30%,这才是可交付的提升。

3.2 多模型对比不是画个 r² 表格:重点看“谁在什么场景下翻车”

项目在model_comparison.ipynb中不仅输出 r²、MAE,更用shap库绘制了各模型的特征重要性热力图,并人工抽样检查“预测偏差最大的 20 套房”。结论很实在:

  • 线性回归:在“老破小”(房龄>25年,单价<6万)上系统性高估,因为其假设价格与房龄线性负相关,但实际老破小存在“地段溢价”抵消房龄折价;
  • 随机森林:对“挂牌超90天”的房源预测偏保守(低估),因训练数据中此类房源多为业主急售,成交价偏低,模型学到了“挂牌久=便宜”的强关联;
  • XGBoost:在“单价>12万”的豪宅区间波动大,因该区间样本仅占 3.2%,过拟合风险高。

提示:shap.summary_plot()输出的图里,subway_distance的红色点越靠右,说明该特征值越大(距离越远),对预测价的拉低作用越强——这符合常识。但如果看到direction的蓝色点集中在“南”且靠左(拉高价格),而你的数据里“南”朝向样本极少,那就要警惕:模型可能在用极少数高价南向样本“记忆”而非“学习”。

3.3 模型可解释性落地:用 SHAP 值回答“为什么这套房预测 1200 万?”

答辩时导师最爱问:“你说模型准,那这套房为啥预测 1200 万?哪个特征起决定作用?” 本项目用shap.TreeExplainer为单样本生成力导向图(force plot):

import shap explainer = shap.TreeExplainer(best_xgb_model) shap_values = explainer.shap_values(X_test.iloc[0:1]) shap.initjs() shap.force_plot(explainer.expected_value, shap_values[0], X_test.iloc[0], matplotlib=True)

这张图会清晰显示:基础预测值(expected value)是 850 万,subway_distance=500m贡献 +180 万(离地铁近是大加分),area=120㎡贡献 +120 万,但year=1998(房龄26年)拉低 -150 万,最终 850+180+120-150=1000 万……等等,和 1200 万对不上?别慌——shap.force_plot默认只显示 top-10 贡献特征,剩下 10 个特征的微小贡献加起来还有 200 万。这就是为什么项目文档强调:“单样本解释必须看全特征,不能只信 top-3”。


4. 避坑:那些让答辩前夜崩溃的 5 个真实问题,以及它们的解法

4.1 现象:pip install scrapy报错Microsoft Visual C++ 14.0 is required

原因:Scrapy 依赖 Twisted,Twisted 在 Windows 上编译需要 VS Build Tools。conda 环境下也可能因编译器版本不匹配失败。
解决:

  • 方案1(推荐):用conda install -c conda-forge scrapy替代 pip;
  • 方案2:下载 Microsoft C++ Build Tools ,勾选“CMake tools for Visual Studio”;
  • 方案3(懒人):直接用预编译 wheel——pip install --only-binary=all scrapy。

4.2 现象:爬虫跑着跑着突然停了,日志显示403 Forbidden或429 Too Many Requests

原因:链家/安居客有反爬机制,未设置User-Agent、Referer、请求间隔,IP 被临时封禁。
解决:

  • 在settings.py中强制添加:
    DOWNLOADER_MIDDLEWARES = { 'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': None, 'myproject.middlewares.RandomUserAgentMiddleware': 400, 'scrapy.downloadermiddlewares.retry.RetryMiddleware': 450, } # 并在 middlewares.py 中定义 RandomUserAgentMiddleware,随机切换 UA
  • 设置DOWNLOAD_DELAY = 3(秒),RANDOMIZE_DOWNLOAD_DELAY = True;
  • 关键:爬取前先手动访问目标 URL,复制浏览器请求头(特别是Cookie),粘贴到headers参数中——很多同学忽略这点,以为 UA 够了就行。

4.3 现象:lianjia.csv里price字段全是None,但网页明明有价格

原因:链家价格是 JavaScript 渲染的,Scrapy 默认不执行 JS,response.css('span.total::text')取不到动态内容。
解决:

  • 方案1(轻量):改用response.xpath('//script/text()').re_first(r'"total":"(\d+)"'),从页面内联 JS 中正则提取;
  • 方案2(重但稳):集成 Selenium,但会极大拖慢速度,仅建议调试时用;
  • 方案3(本项目采用):观察链家详情页 Network 面板,找到https://bj.lianjia.com/ershoufang/{house_id}.html对应的ajax接口(如https://bj.lianjia.com/ershoufang/ajax/detail?id=xxx),直接请求该接口获取 JSON 数据——这才是真正的“绕过渲染”。

4.4 现象:训练时XGBoost报ValueError: feature_names mismatch

原因:训练时用了pd.get_dummies(),测试时忘了对新数据做同样 one-hot,导致列数不一致;或训练/测试 split 后,某分类特征在测试集出现训练集未见过的新类别(如训练集无“毛坯”,测试集有)。
解决:

  • 用sklearn.preprocessing.OneHotEncoder(handle_unknown='ignore')替代pd.get_dummies();
  • 或更彻底:在data_preprocessing.py中,对所有分类列先做train_df[col].unique(),保存为valid_categories.pkl,测试时强制test_df[col] = test_df[col].apply(lambda x: x if x in valid_categories else 'unknown')。

4.5 现象:北京二手房房价预测与分析.html打开是空白,或图表不显示

原因:Notebook 导出 HTML 时,visuals.py中的plt.savefig()路径是相对路径(如'./visuals/feature_importance.png'),但导出 HTML 后图片路径未被正确嵌入,或ershoufang_0.JPG等图片文件未与 HTML 放在同一目录。
解决:

  • 在 Notebook 开头加:%config InlineBackend.figure_format = 'retina',确保高清图;
  • 导出前,运行!mkdir -p visuals && python visuals.py生成所有图到visuals/文件夹;
  • 导出 HTML 后,手动将visuals/文件夹和所有.JPG图片复制到 HTML 同级目录;
  • 最后用浏览器打开,不要双击,而要用python -m http.server 8000启动本地服务,再访问http://localhost:8000/北京二手房房价预测与分析.html—— 这能绕过浏览器对本地 file:// 协议的图片加载限制。

5. 部署不是终点:用 Flask 封装成 Web API,让导师扫码就能试你的模型

5.1 为什么非得封装 API?因为“能跑 notebook”不等于“能交付”

答辩时导师说:“你这模型,能让我输个小区名、面积、楼层,立刻给我个预测价吗?” 如果你只能手忙脚乱打开 Jupyter、改几行代码、再 run 一次,那基本就凉了。本项目在app.py中用 Flask 封装了最小可行 API:

from flask import Flask, request, jsonify import joblib import pandas as pd import numpy as np app = Flask(__name__) model = joblib.load('models/xgb_best.pkl') preprocessor = joblib.load('models/preprocessor.pkl') @app.route('/predict', methods=['POST']) def predict(): data = request.json # data 格式:{"community_name": "XX小区", "area": 85.5, "floor": "中楼层", ...} df = pd.DataFrame([data]) X_processed = preprocessor.transform(df) pred = model.predict(X_processed)[0] return jsonify({"predicted_price_wan": round(pred / 10000, 2)}) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False)

启动命令就一行:python app.py。然后用 Postman 或 curl 测试:

curl -X POST http://localhost:5000/predict \ -H "Content-Type: application/json" \ -d '{"community_name":"万科城市花园","area":85.5,"floor":"中楼层","direction":"南","decoration":"精装","year":"2008年","subway_distance":650}' # 返回:{"predicted_price_wan": 985.32}

注意:preprocessor.pkl是你训练时保存的完整 Pipeline(含 StandardScaler、OneHotEncoder、自定义函数),不是只存StandardScaler。很多同学只保存模型,忘了预处理器,API 一跑就报ValueError: Expected 2D array。

5.2 前端不用写:用 Gradio 三行代码生成可交互界面,扫码即用

Flask API 还得配前端?太重。本项目额外提供gradio_app.py,用 Gradio 生成免部署界面:

import gradio as gr import requests def predict_price(community, area, floor, direction, decoration, year, subway): payload = { "community_name": community, "area": float(area), "floor": floor, "direction": direction, "decoration": decoration, "year": f"{year}年", "subway_distance": int(subway) } resp = requests.post("http://localhost:5000/predict", json=payload) return f"预测总价:{resp.json()['predicted_price_wan']} 万元" iface = gr.Interface( fn=predict_price, inputs=[ gr.Textbox(label="小区名称"), gr.Number(label="面积(㎡)"), gr.Dropdown(choices=["低楼层", "中楼层", "高楼层"], label="楼层"), gr.Dropdown(choices=["东", "南", "西", "北", "东南", "西南", "东北", "西北"], label="朝向"), gr.Dropdown(choices=["毛坯", "简装", "精装", "豪装"], label="装修"), gr.Slider(1980, 2024, step=1, label="建成年份"), gr.Number(label="距最近地铁站距离(米)") ], outputs="text", title="北京二手房房价预测系统", description="输入房源信息,实时获取预测价格(基于XGBoost模型)" ) iface.launch(server_name="0.0.0.0", server_port=7860)

运行python gradio_app.py,终端会输出一个局域网地址(如http://192.168.1.100:7860),导师手机扫码就能用——这才是答辩现场的王炸。

5.3 最后一道保险:给模型加“可信度分”,让导师知道“这个预测靠不靠谱”

XGBoost 预测值只是一个点估计,但实际业务中,我们更关心“这个预测有多稳”。本项目在model_with_uncertainty.py中实现了分位数回归森林(Quantile Regression Forest),输出 90% 置信区间:

from sklearn.ensemble import RandomForestRegressor from quantile_forest import RandomForestQuantileRegressor qrf = RandomForestQuantileRegressor(n_estimators=100, random_state=42) qrf.fit(X_train, y_train) # 预测 5% 和 95% 分位数 pred_low = qrf.predict(X_test, quantiles=[0.05]) pred_high = qrf.predict(X_test, quantiles=[0.95]) # 可信度分 = 1 - (pred_high - pred_low) / pred_mean confidence_score = 1 - (pred_high - pred_low) / (pred_low + pred_high) * 2

在 Gradio 界面中,不只返回“985.32 万元”,而是:

预测总价:985.32 万元(可信度分:0.87)
价格区间:920.15 ~ 1050.49 万元(90% 置信)

这个“可信度分”不是玄学——它直接来自预测区间宽度与预测值的比值。当模型对某套房拿不准(如豪宅、老破小、挂牌超180天),区间就会变宽,可信度分自动降低。从那以后我每次交模型,都强制走一遍qrf.predict(..., quantiles=[0.05, 0.95]),哪怕导师没问,我也把置信区间写在报告附录里——因为这比单纯说“r²=0.92”更能体现工程素养。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询