北京二手房价格预测:Python数据分析与机器学习全流程实战

📅 2026/8/26 21:40:01
北京二手房价格预测:Python数据分析与机器学习全流程实战
简介在数据分析和机器学习领域房价预测一直是最具代表性的实战场景之一。它要求从业者完整掌握数据采集、清洗、可视化、特征工程与建模评估的每一项核心技能尤其适合用Python生态中的pandas、matplotlib和scikit-learn等工具来落地。通过真实且“脏乱”的二手房挂牌数据我们不仅能理解数据结构化与特征提取的基本原理还能直观感受不同机器学习模型在回归任务中的表现差异。从城区均价的可视化洞察到随机森林与梯度提升的对比调优每一步都体现了数据驱动决策的技术价值。这类分析可广泛应用于市场研究、房产估值辅助和投资决策支持。本文以北京二手房为对象完整梳理了从公开数据抓取到模型训练的全流程为想用Python做数据分析与预测的读者提供一套可复现的工程范式。 北京二手房价格数据分析预测这个题目在Python数据分析圈子里被点名的频率一直很高。原因很简单它把爬虫、数据清洗、可视化、特征工程、机器学习预测这条完整链路全串起来了而且要处理的数据足够“脏”、足够真实比任何教程里的Toy Dataset都更能锻炼人。我这次把整个项目整理成了一个源码集锦性质的东西基于公开市场的二手房挂牌数据从采集到建模预测完完整整跑了一遍。无论你是刚学完Pandas和Matplotlib基础、想找个真实项目练手的初学者还是已经能写爬虫但不太清楚“拿到数据之后怎么分析建模”的进阶玩家这套东西应该都能给你一些可以直接抄作业的思路。我会把过程中的选型逻辑、代码骨架、踩过的坑都写在下面你可以照着复现也可以基于自己的需求改。1. 项目整体设计与思路拆解1.1 为什么偏偏选北京二手房很多人做数据分析练手项目会选电影票房、电商评论之类的这些数据确实容易获得但有个致命的问题特征太浅分析到最后很难有真正的预测价值。房价不一样它天然具备“结构化特征丰富”的优势——面积、户型、朝向、楼层、装修、城区、总价、单价每一个字段都能直接影响目标值这就给建模提供了非常扎实的原料。为什么选二手房而不是新房因为二手房挂牌数据的文本信息更完整楼层、朝向、装修、建成年代这些字段基本都有而新房经常是“价格待定”“一房一价”数据口径不统一反而不好分析。再加上北京本身的房价梯度非常大海淀、西城和密云、平谷之间的价格差距能到好几倍这种强烈的城区效应在建模时会产生非常明显的正向作用——模型更容易学到规律特征重要性也更清晰。说白了这套项目的设计思路是拿一个“数据量大、特征可解释性强、目标值分布有层次”的真实场景把Python数据分析全流程跑通。不是为了预测某套房子到底值多少钱而是为了理解整个数据分析流程怎么串起来。1.2 技术选型一套朴素但完整的组合技术栈我选得比较常规没有上任何花哨的东西requests加BeautifulSoup做数据采集pandas做清洗和聚合matplotlib加seaborn做可视化scikit-learn做建模和评估。为什么不直接用现成的爬虫框架Scrapy因为项目体量不大抓几千条挂牌数据用requests就完全够了Scrapy对于学习项目来说多了一层调度器和中间件的理解成本。为什么不直接上深度学习模型因为结构化表格数据的预测任务里传统机器学习模型在样本量只有几千、特征维度不高的情况下效果并不比神经网络差而且可解释性要好太多。你可以清清楚楚地看到模型是依据哪些特征做出的判断这对理解问题本身更有帮助。如果非要用一个类比来描述这个项目的结构我会说它像做一顿饭爬虫是去菜市场买菜数据清洗是择菜洗菜切菜可视化是看看食材的成色建模预测才是下锅炒。前面任何一步做得不干净最后端上桌的菜都不会好吃。2. 数据获取与预处理先把“脏活儿”干利索2.1 数据采集别硬刚反爬先看公开页面数据源我选的是公开二手房挂牌页面只做学习用途。第一步是访问二手房列表页提取每一套房子的详情链接然后进入详情页拿到完整字段。列表页一般会展示小区名、总价、单价、面积、户型、楼层、朝向这几项详情页还会有装修情况、建筑年代、权属等补充信息。采集时的核心思路就一句话容错优先。目标网站的页面结构随时可能改解析代码不能写得太“硬”。我的做法是把每一条解析逻辑都包进try/except里失败就记录日志然后跳过绝不让单条数据的解析异常中断整个采集过程。import requests from bs4 import BeautifulSoup import time import random headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } def fetch_page(page_url): try: resp requests.get(page_url, headersheaders, timeout10) resp.encoding utf-8 if resp.status_code 200: return resp.text except Exception as e: print(f请求失败: {page_url}, 错误: {e}) return None def parse_list_page(html): soup BeautifulSoup(html, html.parser) items [] for li in soup.select(.houseList li): try: title_tag li.select_one(.title a) total_price_tag li.select_one(.totalPrice) unit_price_tag li.select_one(.unitPrice) if not title_tag or not total_price_tag: continue items.append({ title: title_tag.get_text(stripTrue), detail_url: title_tag.get(href), total_price: total_price_tag.get_text(stripTrue), unit_price: unit_price_tag.get_text(stripTrue) if unit_price_tag else None }) except Exception as e: print(单条解析失败:, e) continue return items有一点要提醒requests请求之间一定要加随机延时我实测是1到3秒随机sleep比较安全长时间高频请求很容易被目标网站临时限制访问。学习项目不需要追求抓完整个网站的数据抓几百条到几千条足够做后续的统计分析和建模了。2.2 清洗与特征工程让表格真正可用采集下来的原始数据是典型的“人看刚刚好机器看一片糟”的格式。“总价590万”“单价7.8万/平米”“面积89.3平米”这样的字符串必须全部转换成数值类型才能进入后续计算。import pandas as pd import re def split_price(price_str): if not isinstance(price_str, str): return None match re.search(r[\d.], price_str) return float(match.group()) if match else None df[total_price] df[total_price].apply(split_price) df[unit_price] df[unit_price].apply(split_price) def parse_area(text): match re.search(r(\d\.?\d*)平米, text) return float(match.group(1)) if match else None df[area] df[title].apply(parse_area)清洗过程中还要处理缺失值和异常值。缺失值处理不能一刀切面积和总价缺失的房源只能丢弃因为这是建模的核心输入朝向缺失可以填“未知”作为单独一个类别处理。异常值过滤也是一门学问总价特别低但面积特别大的记录很有可能是车位或者奇葩标的我在实操中会先把单价比0.5万元以下或20万元以上的记录全部筛掉再用四分位数法把明显偏离正常分布的极值剔除。这里要注意一个细节同一个小区、同样面积、不同楼层的房子价格差异很大清洗时千万别用“同小区同面积”作为去重条件否则会误删大量有效样本。我用的是详情页的唯一标识去重每套房子的链接在采集时就去掉了重复项。2.3 聚合到城区粒度减少噪声的第一步做完单套房的数据清洗我先做了一步聚合分析把数据提升到城区粒度看一眼整体格局。这一步看起来简单但对后续建模帮助很大——单套房源的单价波动很大但聚合后的城区均价能帮你建立“业务先验”比如你知道海淀价格高、房山价格低那在建模后如果发现模型对海淀的房源预测普遍偏低你就能判断是特征不足还是模型偏差。district_stats df.groupby(district)[unit_price].agg( [median, mean, count] ).sort_values(median, ascendingFalse) print(district_stats)从聚合结果往往能看出几个信息不同城区的中位数单价差距非常大头部城区和尾部城区之间能差出四五倍。同时即便在同一个城区内部单价的方差也相当大说明“板块效应”和“小区效应”比单纯的城市边界更明显。这些观察直接指导了后面的特征工程城区字段一定要进模型并且要用OneHot编码而不是整数编码。3. 数据可视化与洞察先让数据开口说话3.1 城区均价与总价分布北京房价的“板块差异”可视化这一步我建议用箱线图而不是简简单单的柱状图。柱状图只能展示均价这个点估计但箱线图能把你带进更真实的分布世界——每个城区的房源单价从最低到最高是怎么分布的25%分位到75%分位集中在哪个区间有没有异常值一目了然。import matplotlib.pyplot as plt import seaborn as sns plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False plt.figure(figsize(12, 6)) sns.boxplot(datadf, xdistrict, yunit_price) plt.xticks(rotation45) plt.title(北京各城区二手房单价分布) plt.tight_layout() plt.show()这里要特别提醒一个新手常踩的坑matplotlib默认字体不支持中文不设置中文字体会导致所有坐标轴标签变成方框。解决方法是设置plt.rcParams[font.sans-serif]为系统已安装的中文字体Windows下一般用SimHeimacOS下可以用Arial Unicode MS或者PingFang SC。可视化之后你会对数据产生更强烈的体感不同城区的中位数差距大例如头部城区可以达到尾部城区的数倍同一城区内部的价差也很夸张高档小区和老公房能拉开很大的距离。这些“体感”不是玄学它直接告诉你哪些特征该进模型、哪些数据不可靠。3.2 面积、户型与总价的关联找找特征间的关系接下来看特征之间的相关性。先画面积与总价的散点图能看到非常明显的正相关趋势但散点云不是一条直线而是一个扇形——面积越大总价的波动范围越大。这说明面积虽然是强特征但单纯靠面积远不足以解释总价差异。再画一张数值特征的相关系数热力图面积和总价的相关系数通常最高单价和总价也会有一定的相关性但不如面积那么直接。这提示了一个建模思路如果目标是预测总价面积是最重要的数值特征如果目标是预测单价那面积的作用就会被稀释反而城区和小区品质的影响更大。户型对比可以用箱线图按居室数量分组看看一居、两居、三居、四居的总价分布差异。这里会出现一个“反直觉”的发现三居室的单价往往低于一居室但总价显著更高。原因很简单小户型总价门槛低、单价高这是市场逻辑不是模型能自动推理出来的。所以在特征工程里户型和面积都要作为独立特征保留不能简单合并。4. 预测模型构建从统计模型到机器学习4.1 建模数据准备别忘了一件事——OneHot编码数据清洗之后离建模还差最关键的一步把文本分类特征转成模型能吃的形式。目标字段我选择了“总价”因为它比单价更贴近购房者的直观感受而且在特征上能用到面积、城区、户型、朝向、楼层等所有信息。分类特征包括城区、户型、朝向、楼层、装修这些必须做OneHot编码。千万不能用LabelEncoder直接编码成0、1、2、3因为它会给类别强加一个“大小顺序”比如海淀编码为0、朝阳为1、房山为2模型会误以为房山“大于”海淀这在业务上完全没有意义。OneHot编码的每一列代表一个类别是否存在模型就能正确处理这种无序分类关系。from sklearn.model_selection import train_test_split from sklearn.preprocessing import OneHotEncoder from sklearn.compose import ColumnTransformer categorical_features [district, house_type, orientation, floor] numeric_features [area, age] preprocessor ColumnTransformer( transformers[ (num, passthrough, numeric_features), (cat, OneHotEncoder(handle_unknownignore), categorical_features) ] ) X df[categorical_features numeric_features] y df[total_price] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 )这里random_state42不是随便写的。固定随机种子之后每次运行代码划分出来的训练集和测试集完全一致你的项目才能被自己和别人复现。我曾见过不固定随机种子导致两次训练结果差异巨大的案例调参的时候会特别痛苦。4.2 三个候选模型线性回归、随机森林、梯度提升回归建模阶段我选了三个递进的模型线性回归作为基线随机森林作为非线性主力梯度提升回归树作为进阶选择。评估指标用R2和RMSER2看整体拟合优度RMSE看预测值和真实值的平均偏差单位是万元解释起来很直观。线性回归的结果意料之中——R2通常只有0.6左右RMSE在一百多万左右。原因很好理解房价和面积、区位之间不是简单的线性关系城区的边界效应、户型与面积的交互效应都让线性模型捉襟见肘。但线性回归作为baseline的价值在于它提供了一个“及格线”后续模型如果能显著超过线性回归说明非线性的空间确实存在。随机森林和梯度提升的结果通常会好不少。这两个模型都能捕捉非线性关系随机森林跑得快、不容易过拟合梯度提升则在数据量适中时通常有更高的精度上限。我在本次数据上实测随机森林的R2能到0.8以上RMSE能降到80万到90万左右。from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor from sklearn.pipeline import Pipeline from sklearn.metrics import r2_score, mean_squared_error import numpy as np models { 随机森林: RandomForestRegressor(n_estimators300, max_depth12, random_state42), 梯度提升: GradientBoostingRegressor(n_estimators300, max_depth5, random_state42) } for name, model in models.items(): pipeline Pipeline(steps[(preprocessor, preprocessor), (model, model)]) pipeline.fit(X_train, y_train) y_pred pipeline.predict(X_test) r2 r2_score(y_test, y_pred) rmse np.sqrt(mean_squared_error(y_test, y_pred)) print(f{name}: R2{r2:.4f}, RMSE{rmse:.2f}万元)调参的时候我的习惯是先用默认参数跑一遍确认模型“能干活”再做一层简单的网格搜索。树模型的关键参数就三个n_estimators控制树的数量太少了欠拟合太多了训练慢收益低max_depth控制树的深度太深容易过拟合min_samples_leaf控制叶子节点的最少样本量适当调大能显著抑制过拟合。梯度提升还有一个learning_rate我一般先固定为0.05到0.1再用交叉验证微调。4.3 结果解读与特征重要性模型到底在看什么模型跑完不能只看指标还要看特征重要性。随机森林和梯度提升都直接提供feature_importances_属性可以看到模型到底依赖哪些特征做预测。在我的结果里面积和城区稳居前两位然后是户型、楼层和朝向其中“海淀”“西城”这些城区one-hot列对预测的贡献尤其突出。这里要特别强调一个误区特征重要性高不等于因果性强它只说明在这个数据集上模型为了拟合结果大量依赖了这些特征。房价预测场景里的面积对总价的影响是相对因果的但城区特征本质上是一个“综合代理变量”它吸纳了教育、交通、商业配套等一大堆隐含信息。来自实操的真实体会即便是最优的模型预测结果也只是“整体趋势准、个别房源偏差大”。有些房源的实际挂牌价比模型预测高出很多很可能是因为它自带学区属性、精装修、或者户型极其稀缺有些比预测低很多则可能因为临街、把边、顶层或者业主急售。这些都属于模型拿不到的“软信息”在真实市场里恰恰是最关键的定价因素。所以对这个项目的预期管理很重要它的价值在于掌握数据分析与建模的方法而不是练出一台精准的房价预测机器。5. 源码整理与模块化让项目可以一键复现5.1 源码文件结构一个小而完整的工程项目跑通之后我把代码整理成了一个可以随时复用的工程结构。模块化是必须的不然所有代码堆在一个notebook里改任何一段都要全局搜索效率太低。bj_house_price/ ├── config.py # 全局配置请求头、URL、字段名、随机种子 ├── crawl.py # 数据采集列表页抓取、详情页解析 ├── clean.py # 数据清洗字段提取、缺失值处理、异常值过滤 ├── eda.py # 可视化分析城区分布、相关性、户型对比 ├── train.py # 建模训练OneHot编码、模型对比、结果评估 ├── requirements.txt # 依赖固定pandas、requests、scikit-learn等 └── data/ ├── raw/ # 原始采集数据 └── processed/ # 清洗后的建模数据config.py单独拎出来是有讲究的。爬虫URL、User-Agent、要提取的字段名、随机种子这些信息集中放在一个文件里后续换个城市或者换个数据源只需要改配置不需要动核心逻辑。我在自己维护的项目里甚至会把常见的字段映射表也放进去方便清洗阶段直接调用。5.2 核心代码片段爬虫、清洗和训练的骨架这里把最核心的三个模块的骨架结构写出来。完整的源码量比较大下面每一段都是“最小可用”的骨架你可以按自己的需求扩展。爬虫模块的核心是列表页抓取和详情页解析分离。列表页的解析结果包含详情链接详情页请求逻辑在另一个函数里处理这样可以单独控制请求速率和错误重试。清洗模块的核心是“所有字段都走独立函数”不要把清洗逻辑散落在循环里后面加字段会很难受。训练模块的核心是Pipeline它能把预处理和模型打包成一步操作预测时就不会出现“训练时做了OneHot、预测时忘了做”的尴尬。# clean.py 片段核心清洗函数 def clean_raw_data(df): df df.copy() # 价格字段转数值 df[total_price] df[total_price].map(split_price) df[unit_price] df[unit_price].map(split_price) # 面积提取 df[area] df[title].map(parse_area) # 楼层、朝向标准化 df[floor] df[floor].map(normalize_floor) df[orientation] df[orientation].map(normalize_orientation) # 缺失值处理 df[orientation] df[orientation].fillna(未知) df df.dropna(subset[total_price, area, district]) # 异常值过滤 df df[(df[unit_price] 0.5) (df[unit_price] 20)] return df有一个工程上的经验值得分享训练完成之后模型文件用joblib保存同时把经过OneHot编码后的特征列名也保存下来。因为模型预测时输入的数据列名和顺序必须和训练时完全一致否则数值对不上预测结果就会错得离谱。很多人在测试集上评估没问题一到“拿一条新数据去预测”就翻车基本都是栽在特征顺序不一致这个坑上。5.3 环境与依赖可复现性比代码本身更敏感再好的代码如果依赖的库版本不一样跑出来的结果也可能不一样。pandas 2.x对部分groupby操作返回类型的处理方式有变化seaborn某些版本的图形默认风格也不一样这些都不会让项目崩掉但会让图表的输出和文档对不上。我的建议是建一个独立的Conda环境然后在requirements.txt里固定关键依赖的版本号。固定版本不是为了炫耀“专业”而是为了让你一个月之后重新打开这个项目的时候还能原样跑到相同的结果。这个看起来不太起眼的习惯在项目复盘的时候会帮你省下大量的定位时间。6. 常见问题与排查技巧实录6.1 数据采集阶段的典型问题采集阶段最常遇到的情况是页面结构变了。今天解析这个节点能拿到数据明天网站前端改版选择器就全部失效了。应对方法只有一个解析必须容错同时日志要足够详细每次运行至少能知道哪一步失败、失败了哪几条。我自己习惯在爬虫里写一个简单的错误输出将失败的URL和具体异常类型输出到日志文件方便复盘。请求被拒绝也是高频问题。如果你发现某段时间请求全部失败先别急着怀疑代码去看看是不是请求频率太高了。临时性的访问限制一般缓一缓就能恢复切记不要无限加重试这会让情况更糟。代码里务必设置timeout参数不然遇到网络异常requests可能卡在那里很久不返回。合规方面学习用途只抓少量页面的公开信息即可请尊重目标网站的使用条款和robots协议约定数据不要用于任何商业用途也不要大规模高频抓取。6.2 数据清洗阶段的典型问题清洗阶段最常见的报错是直接拿字符串做数值运算。比如total_price列里存的是“590万”如果忘了把单位去掉就直接df[total_price] * 2抛出的TypeError会让人一头雾水。这里没有捷径日志打印字段类型是排查的第一动作。去重的坑我前面也提到过必须用唯一标识而不是特征组合去重。举个例子同一小区同一户型同一面积可能有朝南和朝北两套房如果按“小区户型面积”去重就会误删掉一个有效样本直接影响模型对朝向维度的学习。还有一个容易被忽略的点单价字段在页面上的显示格式可能是“65387元/平”也可能直接是“6.5万/平”不同详情页的格式可能不一样。提取的时候最好用正则抓数字然后统一乘以对应的单位不要在清洗脚本里硬编码“去掉后三位”这种逻辑。6.3 建模阶段的易错点与指标陷阱建模阶段最隐蔽的问题竟然是数据泄漏。什么情况下会出现数据泄漏比如把挂牌时间、小区名直接作为特征不小心用了未来信息或者本来应该分开处理的数据在train_test_split之前就做了全局的标准化。后者是很典型的坑先对整个数据集做了离差标准化再划分训练集和测试集测试集的信息已经“泄漏”到训练过程中了。正确做法是先用训练集fit预处理器再transform测试集。我上面的代码用Pipeline就是为了天然避免这个问题。RMSE的解读也要小心。RMSE对预测误差做了平方所以个别偏离特别大的房源会把RMSE拉得很高。如果你的模型对大多数房源的预测偏差在50万以内但个别几套偏差了300万RMSE可能一下子就飙到100多万。这时候要结合MAE和预测残差分布一起看别只看RMSE一个指标就下结论。还有一个业务上的经验同一个小区里低楼层、中间楼层、高楼层之间的总价差通常能到15%以上。如果你的数据集里有一部分房源楼层字段缺失没有进模型那预测误差就会有天然的结构性偏大。这类误差不是模型代码能解决的它提醒我们要在特征工程阶段尽可能保留完整的字段信息。最后再分享一个小技巧。模型训练完成之后不要只盯着整体指标把预测值与真实值的残差算出来按残差从大到小排序取前20条看看。这些“模型很不理解”的房源往往能告诉你真实市场里被忽略的信息——比如顶层复式、临街噪音、特殊户型布局。把这些案例翻出来看一遍你会对数据质量和特征工程的不足有更直观的认知。这个习惯我每次做数据分析项目都会用它比任何调参技巧都更能提升你对数据的理解深度。本文还有配套的精品资源点击获取