资讯动态

基于Python的二手房房价数据分析与预测实战:从数据清洗到LightGBM

发布时间:2026/10/9 23:01:17 来源:尧图企业网站定制
简介这套压缩包是完整的基于Python的二手房房价数据分析与预测项目面向有编程基础、正在完成毕设或课设的学习者。项目以链家二手房数据为切入点覆盖数据爬取、缺失值与异常值处理、Pandas探索性分析与可视化以及使用Scikit-learn构建线性回归、决策树、随机森林等模型通过交叉验证调参与评估并涉及特征工程和时间序列分析等进阶应用。压缩包共17个文件以12个CSV数据文件为主体另有3个Notebook脚本、1个Python脚本和1份Word项目说明文档整体约6.22MBNotebook按采集、探索、建模等阶段拆分便于逐模块复现。已有118人浏览下载。通过该案例可学习数据清洗、特征选择、模型对比与优化流程并获得可运行的源码和配套说明适合作为毕业设计、课程设计或数据分析实战的参考。1. 为什么说“基于Python的二手房房价数据分析与预测”这门手艺最难的不是模型拿到一类名为“基于Python的二手房房价数据分析与预测源码项目说明.zip”的项目包时大多数人的第一反应是去看模型代码但我的经验是真正决定这个项目成败的是数据入口那一段。二手房数据和公开数据集不一样字段看着不多坑却一个接一个——总价里带面积均价、朝向写法五花八门、同一个小区的房源在不同时间被反复挂牌。这些脏数据不被处理干净后面用多先进的模型都救不回来。这篇文章的目标很直接帮你把“二手房价分析预测”的完整链路走通从字段清洗到特征工程从线性回归到LightGBM再到最容易翻车的验证环节每步都给可复现代码和参数经验。适合刚想系统跑完一个数据项目的初学者也适合拿到某个压缩包后想快速判断它值不值得细读的从业者。2. 数据探查与清洗先把二手房字段的口径全部捋顺2.1 字段先分清总价、单价、面积到底谁才是预测目标二手房数据里最常见的字段组合是“所在区域、小区名称、户型、面积、总价、单价、朝向、装修、楼层、建成年份”。这些字段看似简单但每一列的背后都藏着一套自己的口径。先看一张我常用的字段体检表字段典型取值常见脏数据问题所在区域某区某板块同板块叫法不统一区与板块层级混淆小区名称文字同一小区出现多种写法或附带推广词户型2室1厅分隔符混乱或缺厅缺卫信息面积71.5个别为0极端小或极端大单位可能是平方英尺总价320与单价混用部分记录填的是“每平米均价”单价45000可能由总价除以面积得到也可能来自挂牌参考价朝向南北“南 北”“南北通透”“南向”并存装修精装精装、精装修、豪装混用甚至有空值楼层低楼层高层、低层、中层、顶层描述不一致建成年份2008有的记录填楼龄有的填建成年份这里需要先做一道选择题目标变量用总价还是单价。常见做法是预测总价因为总价才是购房者真正关心的支付金额如果转成单价虽然消除了面积差异但同时给模型塞进了一个几乎能直接解释价格的信息——同一个小区的单价高度趋同模型很容易退化成“查表”而不是“预测”。我一般会把总价作为目标变量而把面积单独作为特征并且明确检查数据里有没有“均价”一类的派生字段有就坚决剔除否则后续模型会学到总价等于面积乘单价的恒等映射R²虚高到没有参考价值。2.2 缺失值处理先算缺失率再决定填还是删拿到数据后第一步不是建模而是计算每一列的缺失率。二手房数据的缺失通常集中在“朝向”和“装修”这两个文本字段以及“建成年份”这个数值字段。处理时我一般遵循三个原则第一面积缺失的样本直接删除。面积是价格建模的核心输入填充几套房子还可以但缺失面积意味着总价和单价的关系也无法验证硬填只会引入噪声。第二建成年份缺失时用该区域的中位数填充不能用全局中位数。不同板块的开发年代差异非常大全局中位数会把老城区样本强行拉到新城区水平等于把板块信息洗掉了。第三朝向和装修这类低缺失率文本字段统一填充为“未知”即可。如果缺失率超过10%就说明这个字段本身不稳定不建议作为强特征使用后续特征工程里做频次编码时也要做缺失分组。还要提醒一句不要看到重复行就去重。二手房数据里同一个小区、同一个户型、同一个面积出现多行很可能只是不同时间点的挂牌快照这些时间维度的重复记录在后续时间切分验证里反而很有价值。2.3 数据质量体检脚本拿任何数据集都先跑这一遍我习惯在动手做任何处理之前先写一个几十行的体检脚本把所有潜在问题一次性暴露出来。下面是核心部分import pandas as pd import numpy as np def data_health_check(df: pd.DataFrame) - None: # 缺失率概览只看有缺失的列节省输出 missing pd.DataFrame({ missing_rate: df.isna().mean().round(4), dtype: df.dtypes.astype(str) }) print(missing[missing[missing_rate] 0]) # 同小区同户型同面积重复判断是快照记录还是真实重复 dup_rows df.duplicated( subset[小区, 户型, 面积], keepFalse ).sum() print(同小区同户型同面积的重复记录数:, dup_rows) # 校验总价、面积、单价三者是否自洽 if 单价 in df.columns: mismatch_mask ~np.isclose( df[总价] / df[面积], df[单价], rtol0.05 ) print(总价/面积与单价不一致的记录数:, mismatch_mask.sum())这段脚本的逻辑很简单但非常有效缺失率能告诉你哪些字段可以信任重复记录数能帮你判断数据是“一房一挂”还是“多次挂牌快照”总价、面积与单价的三方自洽校验是全局最关键的一步如果超过5%的记录出现不一致说明单价字段可能直接来自于挂牌参考价必须用“总价/面积”重算单价同时排查是否已有派生特征防止后期从源头上发生数据泄漏。3. 特征工程从面积、朝向、小区名称里挖出真正的价格信号3.1 连续特征处理面积做对数楼龄做分段二手房数据里的面积和建成年份是最基础的两个连续特征但直接丢进模型并不是最优做法。面积分布天然右偏——大量60到90平方米的中小户型集中少量200平方米以上的大平层和别墅把分布向右拖。这种偏态分布对线性模型不友好一个超大面积的样本就可能把回归系数带偏。常见做法是使用log1p对数变换至于建成年份很多项目里会先转换成楼龄再做分段处理。import numpy as np import pandas as pd df[面积_log] np.log1p(df[面积]) # 楼龄计算建成年份转楼龄注意边界值 df[楼龄] df[建成年份].apply( lambda x: 2025 - x if 1900 x 2025 else np.nan ) # 楼龄分段分段能减少异常年份带来的过拟合风险 df[楼龄段] pd.cut( df[楼龄], bins[0, 5, 10, 20, 50], labels[次新, 五年内, 十年内, 二十年以上] )需要说明的是log1p与标准化是两个不同维度的事情。标准化解决的是量纲问题但对数变换解决的是分布形态问题。对树模型来说面积做不做对数影响不大因为树模型只关心切分点但对线性回归和后续的模型解释性来说对数变换能让误差呈现乘法关系也就是误差比例化而不是绝对化。楼龄分段我一般切四段就够5年内次新房、5到10年、10到20年、20年以上。段数太多会让某些年龄段样本过少段数太少又会把“房龄对价格的负向影响”这种梯度信息磨平。当然分段边界可以结合你手中数据的分布微调不要照抄。3.2 文本特征编码朝向与装修的最优解不是 one-hot朝向字段的写法在各数据源里差异极大。“南北通透”可能被写成“南北”“南 北”“南向”“主卧朝南”等。如果直接按空格切词再编码会产生十几个维度的碎片特征并且丢失最重要的组合信息——南北通透这个语义。我更推荐用str.contains把方位信息拆成多个布尔列然后额外构造“是否南北通透”的特征。这样既不会丢失原始文本里的组合信息又控制了特征维度。orientation_cols [东, 南, 西, 北] for c in orientation_cols: df[f朝向_{c}] df[朝向].astype(str).str.contains(c).astype(int) # 南北通透是强信号单独拆出来 df[南北通透] ( df[朝向].astype(str).str.contains(南) df[朝向].astype(str).str.contains(北) ).astype(int)装修字段的处理方式和朝向不太一样。装修标准很难用一个条目概括更常见的做法是先把同义词做一次人工映射比如“精装修”“精装”“豪装”归为一档然后做频次编码。频次编码的意思是某个装修档位在数据里出现的次数越多说明它越主流用出现次数代替类别本身参与建模。freq_map df[装修].value_counts() df[装修_freq] df[装修].map(freq_map)频次编码对树模型和线性模型都兼容而且不会像 one-hot 那样让“毛坯”“简装”“精装”变成互斥的三列。要注意的是如果某个装修档位只出现一两次频次编码会给它一个很小的值这相当于给它打上了“小众”标记如果后续业务场景集中在主流房源上这种标记反而合理。3.3 区域与小区特征均值编码的正确做法区域和小区名称是二手房价格预估中解释力最强的特征但也是最容易做错的部分。直接 one-hot 一个几百个小区名的字段会让矩阵稀疏到失去建模意义直接 LabelEncoder 则会让树模型错误地理解编号数字存在大小关系这都没有实际业务含义。业界最常见的做法是目标编码Target Encoding也就是用“该小区历史样本的平均价格”作为特征值。但是如果直接对整个数据集做均值再映射会产生严重的过拟合验证集里那些只出现过一次的小区它们的编码值来自本小区的真实价格模型分数会虚高。正确的做法是用 K 折交叉把编码过程嵌到每一折之内from sklearn.model_selection import KFold import numpy as np import pandas as pd def target_encoding_with_cv(s: pd.Series, y: pd.Series, n_splits: int 5, seed: int 42) - np.ndarray: s s.reset_index(dropTrue) y y.reset_index(dropTrue) kf KFold(n_splitsn_splits, shuffleTrue, random_stateseed) out np.zeros(len(s)) for tr_idx, va_idx in kf.split(s): # 只用训练折统计均值验证折完全不可见 tmp pd.DataFrame({key: s.iloc[tr_idx], target: y.iloc[tr_idx]}) mean_map tmp.groupby(key)[target].mean() out[va_idx] s.iloc[va_idx].map(mean_map).fillna(y.mean()).values return out df[小区_target] target_encoding_with_cv(df[小区], np.log1p(df[总价])) df[板块_target] target_encoding_with_cv(df[板块], np.log1p(df[总价]))代码中每个验证折的小区均值完全来自训练折不会把验证样本的价格信息偷走。但还要再进一步小体量小区在训练折里可能只出现一次它算出来的均值会异常极端。更稳妥的写法是加一个平滑系数让均值向全局均值收缩def smoothed_target_encoding(s: pd.Series, y: pd.Series, alpha: int 10) - pd.Series: agg pd.DataFrame({ count: s.value_counts(), mean: y.groupby(s).mean() }) global_mean y.mean() score (agg[count] * agg[mean] alpha * global_mean) / (agg[count] alpha) return s.map(score).fillna(global_mean)平滑参数 alpha 越大编码值越向全局均值靠拢。对于只有一两条记录的小区alpha 能防止它被编码成极端值。这个写法在线上推理时也更容易复现只需要保存每个小区的平滑编码结果遇到新小区就直接填全局均值不需要重新训练。4. 从线性回归到LightGBM房价预测建模链路怎么排顺序4.1 先跑基线模型全特征多元线性回归很多初学者一上来就调 XGBoost 的参数结果跑完了既不知道模型学的是什么也没有一点业务解释能力。我建议任何房价预测项目都先跑一个全特征线性回归因为这个环节能暴露大量数据问题某个特征的系数符号与业务常识相反说明特征方向有误系数绝对值异常大说明存在单位不一致或者共线性。而且线性回归给了全流程一个分数下限后续模型如果还没有线性回归好说明前面的特征处理顺序就有问题。from sklearn.linear_model import LinearRegression from sklearn.model_selection import train_test_split from sklearn.metrics import r2_score, mean_absolute_error feature_cols [ 面积_log, 楼龄, 室, 厅, 装修_freq, 小区_target, 板块_target ] X df[feature_cols] y np.log1p(df[总价]) X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, random_state42 ) model_lr LinearRegression() model_lr.fit(X_train, y_train) pred_lr model_lr.predict(X_val) print(线性回归 R2:, round(r2_score(y_val, pred_lr), 4)) print(线性回归 MAE(log):, round(mean_absolute_error(y_val, pred_lr), 4))需要注意两个点。第一random_state必须固定否则后续每次对比模型时训练集都不一样比较结果没有意义。第二这里的评估指标全是在 log 空间里算出如果你想让人看懂误差必须用np.expm1把它还原成原始价格再做计算这一点在 4.3 还会再强调。4.2 用 LightGBM 做主力模型关键参数按数据量来调线性回归跑通之后主力模型我一般用 LightGBM。它不是所有场景都最佳但在几万条二手房数据上表现通常比 XGBoost 更容易调训练速度快对类别型和高基数特征也有不错的容忍度。对房价预测这类存在明显交互效应的场景树模型天然有能力发现“大面积老小区特定板块”的组合关系。import lightgbm as lgb params { objective: regression, metric: rmse, learning_rate: 0.05, num_leaves: 31, max_depth: 8, min_data_in_leaf: 20, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, lambda_l2: 1.0, random_state: 42, } d_train lgb.Dataset(X_train, y_train) d_val lgb.Dataset(X_val, y_val, referenced_train) model_lgb lgb.train( params, d_train, num_boost_round2000, valid_sets[d_val], callbacks[lgb.early_stopping(100), lgb.log_evaluation(100)] )这几个参数是二手房数据上最常用的起点配置调参时按优先级来先调min_data_in_leaf再调num_leaves最后动feature_fraction和bagging_fraction。learning_rate0.05对几千到几万条数据已经够用数据量超过五万可以考虑提到 0.1减少训练轮数。num_leaves31是默认经验值样本量不大时不要盲目增大叶子节点过多模型容易记住个别小区的噪声。min_data_in_leaf20是关键的防过拟合参数如果发现训练集R²远高于验证集优先把它从20提到50甚至100。feature_fraction0.8和bagging_fraction0.8是两棵开关同时开启会让模型更稳健但这在几十万条大样本上才收益明显小样本上防止它们反而让模型训练不足。不要一上来就套网格搜索。房价数据量通常不大特征维度有限网格搜索选出的参数往往只在这份数据上最优换一份数据立刻失效。先固定上面这组参数跑通再逐次只动一个参数观察验证集分数才是可靠的流程。4.3 评估指标R²、RMSE、MAE 和相对误差各自看什么模型跑完后很多人只看 R²这是最大的误区。R² 衡量的是模型相对均值改善了多大的方差它极度依赖数据本身的价格离散程度。二手房数据里如果包含多个城市或多个极端豪宅样本R²天然会很高但这不代表模型预测得准。我的评估习惯是同时看四个维度指标计算方式房价场景下的解读R²1 - SS_res/SS_tot只看拟合优度不反映误差绝对值RMSEsqrt(mean((y - y_pred)^2))大误差被放大适合发现离群预测MAEmean(abs(y - y_pred))直观反映平均偏差几万元中位相对误差median(abs(y - y_pred)/y)最能反映购房者实际感知还原到原始价格的计算方式同样重要。因为训练时目标变量使用了log1p验证时如果不还原RMSE 为 0.15 在 log 空间里看起来是个小数字但换算成原始价格的误差大约是涨跌 16%——完全不是一个量级。import numpy as np from sklearn.metrics import mean_squared_error pred_log model_lgb.predict(X_val, num_iterationmodel_lgb.best_iteration) pred_price np.expm1(pred_log) y_price np.expm1(y_val) print(RMSE万元:, round(float(np.sqrt(mean_squared_error(y_price, pred_price))), 1)) print(MAE万元:, round(float(np.mean(np.abs(y_price - pred_price))), 1)) print(中位相对误差%:, round( float(np.median(np.abs(y_price - pred_price) / y_price)) * 100, 1 ))这套输出能直接对标业务感受。举一个实际常见的结果300万的房子预测偏差中位数在15万以内也就是5%左右已经算一个能用的模型如果中位相对误差超过10%改善的重点通常不是换模型而是回去补区域特征和房源文本特征。5. 二手房房价预测避坑清单5个最容易让结果失真的坑5.1 把“均价”混进特征模型学成了恒等映射现象模型R²高达0.97以上线性回归几乎不训练就能达到但把验证集换成另一批房源后误差立刻变大。原因特征里混入了“单价”或“每平方米均价”字段。总价等于面积乘以单价模型只需学到这个乘除关系就能拿满分本质上是在做算术不是在预测。解决拿到任何数据源和源码包第一件事就是扫描特征清单里是否存在价格派生字段。总价除以面积生成的字段也算。我在 2.3 的数据体检脚本里专门加了自洽性校验就是用来提前定位这一类的。判断方法很简单如果某个特征本身和价格几乎线性相关它在模型里的重要性排名必然第一此时要果断删除。5.2 同小区房源同时出现在训练集和验证集R²虚高现象随机切分后验证集 R² 有0.91但把模型放到一个完全没有历史记录的新小区时预测误差远大于验证集表现。原因同一个小区在不同时间挂牌的房源天然价格高度相似。随机train_test_split会让同一个小区的部分样本出现在训练集部分出现在验证集模型等于已经见过这个小区的价格水平验证结果自然失真。解决按小区分组切分使用GroupKFold而不是默认的随机切分。from sklearn.model_selection import GroupKFold gkf GroupKFold(n_splits5) groups df[小区] for tr_idx, va_idx in gkf.split(X, y, groupsgroups): X_tr, X_va X.iloc[tr_idx], X.iloc[va_idx] y_tr, y_va y.iloc[tr_idx], y.iloc[va_idx]如果数据带挂牌时间字段更严格的做法是把时间纳入切分依据按时间排序后前80%作训练、后20%作验证。这样才能真实模拟“用历史预测未来”的业务场景。房价存在明显的惯性和周期随机切分会让时间信号也被偷看。5.3 不做对数变换直接预测输出负房价现象模型预测结果里出现负值比如 -20 万元明显不符合业务认知。原因线性回归的预测值是特征的线性组合当某些特征取值落在训练分布边缘时预测结果很容易外推到负数区间。树模型一般不会输出负值但线性模型非常常见。解决训练目标统一使用np.log1p预测后再np.expm1还原并且在接口层加一道np.clip(..., 0, None)防御。同时要检查整个流程里有没有遗漏还原步骤——训练集做了对数变换验证集却直接拿原始值对比指标这是新手最容易出现的不对称错误。5.4 朝向字段切词后维度爆炸南北通透信息反而丢了现象朝向特征做 one-hot 后出现十几个维度模型训练没有变慢但特征重要性分散朝向对价格的解释力不如预期。原因把“南北通透”按空格切成了“南”和“北”两个单独特征再 one-hot 后“南北通透”变成两个互相独立的列组合语义完全丢失。而现实中朝南和朝北同时存在时通风采光带来的溢价远大于单独朝南。解决用str.contains拆布尔列并显式构造“是否南北通透”特征。这个做法在 3.2 里给过代码不需要修改分词逻辑只把原始字符串看成整体做包含判断信息量保留得最完整。5.5 忽略时间维度模型“穿越”训练现象某一月份的数据预测效果很好换到下个月误差明显增大而且误差方向基本一致比如普遍低估。原因市场整体价格在变化而模型里没有时间特征。二手房市场有强烈的价格惯性整体涨跌会直接平移所有房源的价位水平。如果训练集中完全没有“挂牌年月”这个维度模型学到的只是一个静止市场的截距。解决在特征中加入“挂牌年月”或“统计月份”同时在最后上线阶段采用滚动窗口重训每次用最近12个月的数据训练预测下一个月而不是训练一次长期使用。如果只是做一次性离线分析至少要按时间排序切分训练验证集让模型见过“时间”的存在。6. 进阶用残差图和SHAP给预测模型“验货”6.1 残差图先看形状再信分数模型评估不能只看指标残差图能暴露指标掩盖的系统性问题。对房价预测我习惯画出验证集里预测值与残差的关系import matplotlib.pyplot as plt pred_log model_lgb.predict(X_val, num_iterationmodel_lgb.best_iteration) residual y_val - pred_log plt.scatter(pred_log, residual, s4, alpha0.4) plt.axhline(y0, colorred, linewidth1) plt.xlabel(Prediction (log space)) plt.ylabel(Residual (log space)) plt.show()好的残差图应当是围绕0水平线均匀分布的一条带状。如果残差随着预测值增大而呈现漏斗形扩散说明模型在高价格区间的不确定性更大此时RMSE对离群值敏感业务汇报时应该强调中位相对误差如果散点呈现出明显的“左低右高”或抛物线弯曲说明对数变换没有完全消除模型的非线性需要把目标变量改为Box-Cox变换或者增加高阶交互特征。这个图在每次调参后重跑一次比对比一长串指标更能说明问题。6.2 SHAP摘要图看特征排序是否符合业务常识树模型全家桶里的最终解释工具我一般用 SHAP。它给出每个样本上每个特征对预测值的贡献方向与大小汇总成摘要图后可以直观回答问题哪些特征在真正驱动这套模型的预测结果import shap explainer shap.TreeExplainer(model_lgb) shap_values explainer.shap_values(X_val) shap.summary_plot(shap_values, X_val, max_display15)读完 SHAP 图后我会按业务常识做三件事。第一确认面积和区域类特征排在前三这在二手房定价逻辑里几乎不会例外第二确认“朝向_南”这类特征的贡献方向为正朝向为南的房源比朝北更贵如果符号反了说明数据处理阶段有字段错位第三警惕“小区_target”排在第一位且贡献值远大于其他特征。这种情况虽然模型性能好但本质上退化成同一小区价格求平均对新小区完全失效部署价值很低。这套“残差图先看形、SHAP再看因、最后才是R²”的流程是我反反复复踩了几次坑之后养成的习惯。早年间我一看到 R² 0.93 就急着对外说模型效果好结果换了时间窗口立刻打回原形。现在拿到任何二手房房价分析项目第一件事永远是跑这两张图先看预测误差的形态合理不合理再看特征重要性符合不符合定价逻辑两者都过关才回头精读模型参数。这个习惯也推荐给你希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑