ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

二手房房价预测实战:Python数据清洗、特征工程与LightGBM建模全流程

二手房房价预测实战:Python数据清洗、特征工程与LightGBM建模全流程 简介一套基于Python的二手房房价数据分析与预测完整项目面向具备一定编程基础的数据科学学习者既可作毕设/课设参考也可用于实战练习。项目覆盖数据处理全流程先完成缺失值处理、异常值检测等清洗步骤再借助Pandas进行探索性分析并通过可视化工具体现特征影响随后使用Scikit-learn构建线性回归、决策树与随机森林等模型结合交叉验证调优还涉及特征工程与时间序列分析。压缩包共17个文件含12个CSV数据文件、3个ipynb分析笔记、1个py脚本及1份docx项目说明整体大小6.22MB结构清晰便于按步骤学习。该资源已被118人下载学习适合希望掌握房价预测建模与模型评估方法的学习者。1. 二手房房价预测项目这套 Python 源码到底解决什么问题很多刚接触数据分析的开发者拿到一份二手房房价数据集之后第一反应是“跑个线性回归看看哪些特征影响房价”。真做起来才会发现90% 的时间根本不在建模上而是耗在数据清洗和特征工程里。链家、贝壳这类平台上的房源信息缺值、异常值、文本杂讯一抓一把户型写“3室2厅1厨1卫”的还好遇到“送车位、满五唯一、临地铁”这种备注你根本不知道该怎么塞进 DataFrame。这套基于 Python 的二手房房价数据分析与预测源码做的就是把这个过程完整串起来从数据获取与清洗到特征工程再到模型训练、调参和结果解释最后用可视化把规律展示出来。适合谁看想拿完整项目练手的数据分析新人以及已经在做房屋估值、租金预测、市场分析这类方向、需要一份能复现的 Baseline 的从业者。源码包里最有价值的不是某个模型跑出来的分数而是整套特征处理的思路和后面对模型做解释的方法。2. 二手房数据从哪来爬虫落库与字段规整2.1 数据获取的常见做法不是所有字段都能直接进模型做房价预测的第一步不是建模是把房源数据拿到手并且保证它能被 pandas 正常读取。公开数据集比如 Kaggle 上的 House Prices虽然干净但和国内二手房市场的真实情况差距太大——户型叫法、面积单位、楼层描述都对不上。自己爬一套数据通常走链家或者贝壳这里以链家为例说明常见抓取逻辑注意链家页面结构经常改反爬策略也在升级代码大概率需要你按着当天页面的实际情况微调。先用 requests 抓列表页再用 BeautifulSoup 或 lxml 解析详情。列表页拿到的是房源 ID、标题、总价、单价这些外层信息详情页才有建筑面积、朝向、楼层、建筑年代、小区名这些关键字段。抓下来的数据直接进 MySQL 还是存 CSV看你要跑多少量。建议落 MySQL中间断点续爬好处理后面用 pandas 读的时候也更接近真实工作流。import requests from bs4 import BeautifulSoup import pandas as pd import time # 列表页URLp2是页码你也可以在前面加行政区参数做区域筛选 url https://bj.lianjia.com/ershoufang/pg{}/ headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } data [] for page in range(1, 6): # 先跑5页验证解析逻辑通了再放量 resp requests.get(url.format(page), headersheaders, timeout10) soup BeautifulSoup(resp.text, html.parser) # 每个房源卡片都在 li[classclear] 里 for li in soup.select(li.clear): title_el li.select_one(.title a) # 标题里带有小区和户型描述 total_el li.select_one(.totalPrice) # 总价 unit_el li.select_one(.unitPrice) # 单价 info_el li.select_one(.houseInfo) # 面积/朝向/户型/楼层 if title_el is None: continue try: total float(total_el.text.strip().replace(万, )) unit float(unit_el.text.strip().replace(元/平, ).replace(,, )) except Exception: continue # 解析失败的直接跳过后面清洗再细处理 info info_el.text.strip().split(|) if info_el else [] # info 的典型结构[3室2厅, 116.8平米, 南 北, 低楼层/28层, 精装] data.append({ title: title_el.text.strip(), total_price: total, unit_price: unit, info_raw: |.join(info) }) time.sleep(1) # 别把对方服务器打挂了爬太快容易封IP df pd.DataFrame(data) df.to_csv(ershoufang_raw.csv, indexFalse, encodingutf-8-sig)这段代码里有几个点在实际做的时候要特别留意。unitPrice解析出来是“单价xx元/平”的文本直接 replace 掉单位再转 float比正则简单可靠houseInfo用|分隔成列表后再合并回一个字符串是为了保留原始信息等清洗阶段再逐个字段拆开——不要在爬取阶段做太重的解析先原样落下来再说。爬虫只是数据来源的一种。如果你不想爬或者爬下来样本量不够也可以把链家网页上搜索到的房源信息整理成 CSV只要保证字段包括总价、单价、面积、户型、朝向、楼层、建筑年代、小区名这套源码剩下的流程都能跑通。这个项目的核心价值不在爬虫而是爬完之后那套清洗和建模流程。2.2 字段规整把“3室2厅116.8平米”拆成模型能用的数字特征原始数据里的info_raw是一个长字符串模型没法直接消费。这里要做一个拆字段的操作把户型、面积、朝向、楼层、装修情况分别拆出来变成独立列。户型还需要进一步拆成室和厅两个数值列面积是连续变量直接转 float朝向是类别变量后面做编码。# 从 info_raw 中拆出结构化字段 import re def parse_info(raw): parts [p.strip() for p in raw.split(|)] if isinstance(raw, str) else [] # 典型结构[3室2厅, 116.8平米, 南 北, 低楼层/28层, 精装] house_type area None toward floor_info decoration for p in parts: if 室 in p: house_type p elif 平米 in p: match re.search(r([\d.])平米, p) if match: area float(match.group(1)) elif 层 in p and / in p: floor_info p elif 精装 in p or 简装 in p or 毛坯 in p: decoration p else: toward p bedroom None living_room None if house_type: m re.match(r(\d)室(\d)厅, house_type) if m: bedroom int(m.group(1)) living_room int(m.group(2)) return pd.Series({ bedroom: bedroom, living_room: living_room, area: area, toward: toward, floor_info: floor_info, decoration: decoration }) df pd.read_csv(ershoufang_raw.csv) parsed df[info_raw].apply(parse_info) df pd.concat([df, parsed], axis1)拆字段的逻辑看着简单实际坑很多。house_type匹配“3室2厅”时有的房源写的是“3室2厅1厨1卫”正则要兼容有的写“2室0厅”也就是开间这时候living_room0不能当异常值删。面积字段建议保留小数精度不要 round后面算单价和归一化的时候精度差异会体现出来。朝向字段尤其混乱——有“南 北”这种带空格的有“东南”这种合并的有“东西向”这种带“向”字的清洗的时候要统一映射规则。3. 特征工程与数据探索房价规律藏在哪个维度里3.1 位置特征与文本特征的提取小区名、商圈名怎么变成模型输入位置是房价预测里最重要的信息之一。同一个城市环线内外价差可能翻倍同一个小区不同楼栋价差也在两位数百分比。对爬下来的数据位置特征可以从两个层面提取小区名层面的映射比如小区均价、小区房龄中位数以及文本里的关键词提取比如“近地铁”“满五唯一”这些话术。先说小区名。链家的房源标题里通常包含小区名“融泽嘉园 3室2厅 南北通透”可以通过字符串匹配把小区名提出来。有了小区名之后可以按小区做 groupby计算小区房源的平均单价、平均面积构造“小区均价”“小区在售套数”这类聚合特征——这些特征的预测力通常比面积本身还高因为它们隐含了地段和社区品质的信息。# 构造小区聚合特征 df[community] df[title].str.extract(r(.?)(?\d室|\d厅|$)) # 注意这条正则在真实数据上不一定准小区名里可能含有数字如万科城市之光2期 # 所以更稳妥的做法是维护一份小区名字典做最大匹配 community_stats df.groupby(community).agg( community_avg_price(total_price, mean), community_unit_price(unit_price, mean), community_count(total_price, count) ).reset_index() df df.merge(community_stats, oncommunity, howleft)这里有个重要的细节聚合特征是从训练集算出来的话会引入目标泄露Leakage。如果拿整个数据集 groupby 算community_avg_price而这个均值实际上包含了某套房源自身的价格那模型在训练时直接记住“这个小区均价高所以我也高”测试集上结果虚高。正确做法是先划分训练集和测试集只在训练集上计算聚合特征再映射到测试集。朝向的处理相对机械。常见朝向可以映射成三类纯南、南北通透、其他。太多类别会让树模型拆分困难太少又丢失信息三类是一个经验性的平衡点。至于“近地铁”“满五唯一”这种话术可以用关键词命中的方式做 0/1 标记但要注意词库覆盖不全的问题漏掉的话特征全是 0等于没加。3.2 价格分布与特征相关性用可视化先给模型“把把脉”在进入建模之前一定要花时间看价格分布和特征之间的关系。二手房总价数据是典型的右偏分布几百万的房源是主体偶尔有上亿的豪宅直接用原始数值建模异常值会把损失函数拉偏。常见的处理是对总价取对数让分布接近正态。import matplotlib.pyplot as plt import seaborn as sns df[log_price] np.log(df[total_price]) df[unit_price] df[total_price] * 10000 / df[area] fig, axes plt.subplots(1, 3, figsize(15, 4)) # 原始总价分布 sns.histplot(df[total_price], bins50, axaxes[0]) axes[0].set_title(Total Price Distribution) # 取对数后的总价分布 sns.histplot(df[log_price], bins50, axaxes[1]) axes[1].set_title(Log Price Distribution) # 单价与面积的关系通常面积越大单价越低但要注意太大面积会反转 sns.scatterplot(datadf, xarea, yunit_price, alpha0.3, axaxes[2]) axes[2].set_title(Area vs Unit Price) plt.tight_layout() plt.savefig(eda_price.png, dpi150)这组可视化有实际指导意义。如果取对数后仍然有两个峰说明数据里混着两类完全不同的房产——比如普通住宅和别墅/商住两用房这时候建议做数据分层或者干脆分开建模。面积和单价的散点图如果出现“面积越大单价越贵”的正相关先别急着信很多是豪宅拉动的关系把面积超过 300 平的样本剔除后再看相关性和斜率结论可能相反。特征相关性矩阵也要看但别只看数值。面积和总价相关性高是必然的朝向、装修这些类别特征和价格的相关系数本来就低这是类别特征的统计特性不代表它们没预测力。树模型里朝向依然会被选中做分裂只是线性相关系数看不出来而已。我的习惯是相关性分析只看连续变量之间类别变量留到模型特征重要性阶段再判断。3.3 缺失值与异常值处理哪些样本该删、哪些该留真实爬下来的数据不缺值是幸运缺值是常态。朝向缺失、面积缺失、楼层缺失各有各的处理逻辑。面积缺失是致命的——总价、单价、后续所有派生特征都依赖面积面积缺失的样本直接删。朝向缺失占比不高时删掉也问题不大。楼层缺失如果样本量够就删不够就单独标记一个“未知楼层”类别让模型自己学。异常值方面单价明显偏离小区均值的样本要小心。一套房挂 5 万每平小区均价 4 万可能是真有原因精装修、临地铁、景观房也可能是数据录入错误。处理原则是不轻易删、先标记# 用IQR方法标记异常单价但不直接删除而是新增异常标记列 Q1 df[unit_price].quantile(0.25) Q3 df[unit_price].quantile(0.75) IQR Q3 - Q1 lower, upper Q1 - 3 * IQR, Q3 3 * IQR # 用3倍IQR而不是1.5倍二手房价格本来离散度就大 df[unit_price_outlier] ((df[unit_price] lower) | (df[unit_price] upper)).astype(int) print(df[unit_price_outlier].value_counts())3 倍 IQR 比 1.5 倍合理因为房价受地段、装修、楼层影响极大1.5 倍会把大量真房源误杀。标记出来而不是直接删除是保留了一种可能性——如果后面模型对异常值敏感线性回归再把标记过的样本剔除如果模型不敏感树模型留着影响也不大。这个思路在房价这类强异质性数据上比较实用。4. 房价预测模型选型与参数调优从线性回归到集成模型4.1 三种建模路径的对比用哪类模型心里要有底二手房房价预测建模路线基本就三条。线性回归可解释性强但需要手动做大量特征变换对数化、交互项、非线性项否则拟合能力不够随机森林不需要特征缩放能自动捕捉非线性关系但外推能力差样本外的极端值预测不准XGBoost / LightGBM综合表现最好训练快且支持自定义损失函数但对参数敏感调不好容易过拟合。从实际项目角度我建议直接以 LightGBM 作为主力模型随机森林做 Baseline 对照线性回归留作解释性分析。原因很简单LightGBM 对特征工程的要求相对低对缺失值的容忍度高训练速度快迭代试错成本低。下面代码以 LightGBM 为例。import lightgbm as lgb from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score # 模型特征列 feature_cols [area, bedroom, living_room, toward, decoration, total_floor, community_avg_price, community_unit_price, is_near_subway, building_age] df[toward] df[toward].map({南: 0, 南北: 1, 东南: 2, 东: 3, 西南: 4, 西北: 5, 西: 6, 北: 7, 东西: 8}) df[decoration] df[decoration].map({精装: 2, 简装: 1, 毛坯: 0}) df[total_floor] df[floor_info].str.extract(r/(\d)层).astype(float) for col in [decoration, toward]: df[col] df[col].fillna(-1) # 缺失编码为-1让树模型单独分一支 df df.dropna(subset[area, total_price]) X df[feature_cols] y df[log_price] # 注意对 log_price 建模预测完再 exp 还原 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) model lgb.LGBMRegressor( n_estimators500, learning_rate0.05, num_leaves31, max_depth-1, subsample0.8, colsample_bytree0.8, random_state42, n_jobs-1 ) model.fit( X_train, y_train, eval_set[(X_test, y_test)], eval_metricrmse, callbacks[lgb.early_stopping(50), lgb.log_evaluation(50)] ) y_pred_log model.predict(X_test) y_pred np.exp(y_pred_log) y_true np.exp(y_test) print(fMAE: {mean_absolute_error(y_true, y_pred):.2f} 万元) print(fRMSE: {mean_squared_error(y_true, y_pred, squaredFalse):.2f} 万元) print(fR2: {r2_score(y_true, y_pred):.4f})代码里有一个关键细节对log_price建模预测完再exp还原。如果你直接对总价建模模型会牺牲多数普通房源的精度去拟合少数高总价房源误差会被豪宅样本放大。取对数之后误差在相对尺度上更均匀实际预测结果也更符合需求——大家关心的是“总价差多少”而是“价格偏离了几个点”。4.2 损失函数的选择MAE 还是 RMSE 要看业务场景模型评估指标不能只看 R2。R2 高不代表预测准它只说明模型解释了数据方差的比例。对房价预测业务上更关心的通常是两个指标MAE 反映平均绝对误差单位是“万元”直观RMSE 对大误差样本惩罚更重如果模型偶尔把一套 500 万的房子预测成 300 万RMSE 会很难看但这个错误对实际业务可能也能接受。优化目标上的建议是LightGBM 默认用 L2 损失如果你希望模型少出现极端大误差就保持 L2如果你希望模型在普通房源上预测得更稳、不追求极端样本的表现可以把objective改成regression_l1。下面是改法注意改目标之后学习率可能要调低一半L1 损失梯度更陡。model lgb.LGBMRegressor( objectiveregression_l1, # 改为MAE优化对大误差样本更宽容 n_estimators800, learning_rate0.03, # L1损失下建议缩小学习率 num_leaves63, subsample0.9, colsample_bytree0.9 )真实落地的时候最好把 MAE、RMSE、以及“误差在 10% 内的样本占比”都打出来。很多业务方不关心 RMSE他们只想知道“你这套模型预测 100 套房子多少套误差在 10% 以内”。Percentage within 10% 这个指标更贴近业务沟通建议加上。4.3 网格搜索与随机搜索调参不要满大街乱试LightGBM 的参数多来来回回就那么关键的几个。num_leaves控制模型复杂度值越大模型越容易过拟合数据量小的时候 31 以内是安全的min_child_samples控制叶子节点最少样本数调大能明显抑制过拟合learning_rate和学习率配合n_estimators学习率越小需要越多树训练时间变长但精度通常更好。搜索策略上网格搜索在小规模数据上可行但参数组合多了之后指数爆炸建议先用随机搜索找到大概范围再在小范围内精调。from sklearn.model_selection import RandomizedSearchCV from scipy.stats import randint, uniform param_dist { num_leaves: randint(15, 127), min_child_samples: randint(5, 50), subsample: uniform(0.6, 0.35), colsample_bytree: uniform(0.6, 0.35), reg_alpha: uniform(0, 2), reg_lambda: uniform(0, 2), learning_rate: uniform(0.02, 0.15) } clf lgb.LGBMRegressor(n_estimators300, n_jobs-1, random_state42) search RandomizedSearchCV( clf, param_dist, n_iter50, cv5, scoringneg_root_mean_squared_error, verbose1, random_state42 ) search.fit(X_train, y_train) print(Best params:, search.best_params_)随机搜索的n_iter50是经验值。二手房数据集通常几千到几万条50 组参数大约跑十几分钟可以接受。数据量大到十万级以上时建议降到 20 组先用粗参数跑通 Baseline再用业务直觉锁死一部分参数。5. 房价预测避坑与常见问题排查模型翻车往往不在模型本身5.1 现象模型在训练集上 R2 接近 0.95测试集上掉到 0.6原因特征里出现了目标泄露。最常见的就是直接拿整个数据集做了聚合特征按小区算均价、按区域算均价这些特征本身包含了目标变量的信息。训练时模型只要记住“这个小区均价高”预测自然准换到新数据上新小区的均价特征没有历史支撑效果立刻土崩瓦解。解决划分训练集和测试集之后再算聚合特征。训练集内部做 groupby 生成小区均价然后分别映射到训练集和测试集没有历史数据的测试集小区用全局均值或者是所在大区的均值兜底。实际下调幅非常明显测试集 R2 从 0.9 以上回到 0.7 左右才是真实水平不要舍不得那个虚高分数。5.2 现象预测结果出现负房价或极端高价原因大多数情况下是忘了把log_price还原回来模型预测的是对数价格直接当成总价输出当然离谱。另一种可能是对价格没有做任何变换模型预测豪宅样本时外推过度。解决检查预测管线确认np.exp()是否已经应用。如果已经还原但还是出现离谱值看特征中是否有极端面积或极端楼层之类的样本这类样本预测误差大是模型本身的局限可以加一个上下限截断比如把预测值限制在训练集价格最小值和最大值的 1.2 倍范围内输出结果更可控。5.3 现象网格搜索调完参分数反而比默认参数差原因随机搜索或者网格搜索选的参数组合在验证集上表现好可能是过拟合了验证集。二手房数据量小5 折交叉验证仍然有较高的方差某一种参数组合恰好在前几折表现好不代表泛化能力更强。解决搜索完之后用工整的参数组合多跑几次不同的random_state划分看均值和方差。另外把n_estimators固定在一个合理范围不要让它在搜索时和learning_rate同时自由浮动否则搜索到的是“高学习率少树”或者“低学习率多树”的巧合匹配不是真实规律。节省时间的做法是网格搜索时固定n_estimators300只对num_leaves和min_child_samples两个关键参数做细搜。5.4 现象爬下来的数据里有大量重复房源原因链家房源列表带分页同一套房源在不同页面出现或者解析时同一个卡片被选中两次导致样本重复。重复样本在训练集中会让模型对某些房子“记性好”对整体泛化能力没有帮助。解决用房源 ID 做去重。即使爬虫代码里没有显式保存房源 ID也可以用title total_price area拼接作为去重键重复的直接drop_duplicates掉。数据量小的时候看不出影响几千条数据里混几十条重复对验证集的评估会造成虚假乐观。5.5 现象增加更多特征之后模型效果反而变差原因加的特征不是信息量不够而是噪声太多。比如把“装修情况”拆成五个虚拟变量或者加入大量关键词命中特征树模型虽然理论上不受无关特征影响但特征过多时colsample_bytree随机采样更容易选到噪声特征分叉质量下降。解决每加一批特征跑一次特征重要性排序把重要性为 0 的特征直接删掉。LightGBM 的feature_importance输出很方便定期做一轮特征筛选比盲目堆特征有效得多。经验值是最终特征数控制在 20 个以内多数项目里 10 到 15 个特征已经够用。6. 用 SHAP 解释单套房源预测落地时最有用的一个技巧模型训完业务方一定会问一个具体问题“为什么这套房子你预测 620 万”R2、MAE 这些指标回答不了这个问题SHAP 可以。SHAP 把每个特征对预测结果的贡献拆开正值代表推高房价负值代表拉低房价输出的可解释性和直观程度远超特征重要性。import shap explainer shap.TreeExplainer(model) shap_values explainer.shap_values(X_test) # 看单个样本的解释以测试集第一套房为例 shap.force_plot(explainer.expected_value, shap_values[0,:], X_test.iloc[0,:], matplotlibTrue)一个典型的输出里你会看到某个小区均价特征推高了 80 万面积特征推高了 30 万朝向是南北通透又加了 15 万但装修情况是“简装”把价格拉低了 20 万。这套解释对于和业务方对齐预期非常有价值。顺带说SHAP 值还能帮你验证特征有没有“学歪”——比如如果“朝向北”的 SHAP 值反而为正大概率是数据里北方位样本太少模型学到了不可靠的模式这时候可以增加数据样本而不是调整模型参数。我的个人习惯是把 SHAP 分析放进每次模型迭代里不是一个可选步骤。模型从 0.72 调到 0.78 的 R2到底是靠真实规律还是靠特征泄露SHAP 的分布图会告诉你答案。如果你手里的数据量不大、特征处理也是常规路线预测误差稳定在 8% 到 15% 这个区间已经算不错了不要迷信单个模型的 R2 数字。完整的项目流程走一遍之后你手里有爬虫脚本、清洗代码、特征工程逻辑、训练调参管线、SHAP 解释模块这套东西换一个城市、换一个平台、甚至换一个预测目标比如租金都能复用。真正的入门到熟练不是学会调包而是知道哪些环节会翻车、为什么翻、怎么提前规避。希望这个项目能帮你少走一段弯路。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进