☰
Python二手房房价预测实战:从数据清洗到随机森林建模
2026/10/9 10:46:35 网站建设 项目流程

简介:一份基于Python的二手房房价数据分析与预测源码包,面向有Python基础、正在开展毕设或课设的计算机方向学习者。项目覆盖数据清洗、缺失值与异常值处理、Pandas探索性分析、Scikit-learn回归建模(线性回归、决策树、随机森林)、交叉验证调参与特征工程,可作为完整的数据科学实践案例。压缩包共17个文件,包含12个CSV数据文件、3个Jupyter Notebook、1个Word说明文档与1个Python脚本,整体约6.22MB;文件类型覆盖从数据爬取、分析建模到结果说明的完整链路,便于对照学习。目前已有118人学习下载。通过该项目可掌握真实房价数据的处理流程与模型评估方法,还能参考实现思路拓展到其他城市或特征场景。

1. 基于 Python 的二手房房价数据分析与预测,这个源码包到底能解决什么问题

“二手房房价预测”这个方向,最容易劝退人的不是模型选型,而是数据本身。你可能拿到过一份挂牌数据,面积字段里混着“建面”和“套内”,楼层写着“低层/中层/高层”,朝向既有“南”又有“南北通透”,同一套房子在不同平台价格能差出十万。这个基于 Python 的源码包把数据清洗、特征构造、模型训练和项目说明打包在一起,核心价值不是预测精度多高,而是给你一条完整、能照做的链路:拿到 CSV、洗干净、建模、评估、换城市再跑。适合刚啃完 Python 基础、想完整走一遍数据项目的学习者,也适合需要快速搭一个房价分析 Demo 的从业者。需要提前说清楚:模型输出反映的是特征与价格的经验关系,不是定价答案。

2. 拆开这个 zip:源码包的文件结构与运行顺序

拿到“基于 Python 的二手房房价数据分析与预测源码+项目说明.zip”之后,第一步不是打开代码文件,而是先看整个目录长什么样。一个能跑通的二手房项目绝大多数会按“数据、代码、文档”三层组织,和那种单文件脚本的区别就在这:数据清洗和建模分开,中间结果可以随时检查,出了问题能定位到具体是哪一步。

2.1 常见文件布局:先认清楚每个路径在干什么

用一个表格来看这种项目最常遇到的文件布局,拿到手之后你可以直接对照着找。

路径作用落地建议
data/raw/原始挂牌 CSV 存放处不要手工修改源文件,所有清洗逻辑都下沉到代码
data/processed/清洗与特征工程后的中间结果每跑一步都检查这个目录,确认字段口径是否符合预期
src/data_clean.py数据清洗入口重点看干净的数据长什么样,而不是看代码有多花哨
src/feature_engineer.py特征构造脚本朝向、楼层、区域编号等衍生字段在这里生成
src/train_model.py建模与评估脚本模型参数、训练集测试集划分、评估指标都在这里
project_readme.md运行步骤与依赖说明先读这个,再跑代码,避免凭感觉猜字段含义

这种组织方式最大的好处是“可审计”。data/raw 里放原始数据,data/processed 里放清洗后的结果,你在特征工程阶段发现某个字段不对,可以直接对比 raw 和 processed 两个目录,很快定位是清洗逻辑写错还是原始数据本身有问题。我一般会先跑一遍 data_clean.py,然后把 processed 目录打开看一眼,再决定下一步动哪个文件。

2.2 环境与运行顺序:三个脚本依次执行

这种源码包通常依赖 pandas、numpy、scikit-learn 三件套,可视化场景还会带 matplotlib。依赖一般写在 requirements.txt 里,装环境时不要直接在全局环境里装,我习惯先建一个干净的 Python 3.8+ 虚拟环境,再按 requirements 安装,避免和系统里旧版本的包打架。运行顺序固定为清洗、特征、建模三步。

# 在项目根目录下依次执行 python src/data_clean.py --input data/raw/house_list.csv --output data/processed/clean.csv python src/feature_engineer.py --input data/processed/clean.csv --output data/processed/feature.csv python src/train_model.py --input data/processed/feature.csv --output results/model.pkl

逻辑上每个脚本只做一件事:清洗脚本负责把脏数据变成干净表格,特征脚本负责在干净表格上添加模型可用的数值字段,训练脚本负责建模并输出评估结果。--input 指读入文件的位置,--output 指生成文件的位置,把输出路径改掉之后,下一个脚本的输入路径也要跟着改,否则会报文件找不到。如果你拿到手的包把三个脚本合并成了一个 train.py,运行方式通常是 python train.py,效果一样,但中间结果需要自己在代码里保存。

2.3 项目说明文档里最值得先看的三件事

第一件事是目标变量定义。这个项目到底在预测挂牌总价还是每平米单价,决定了后面所有特征和评估口径。预测总价是用户最常见的诉求,但总价受面积影响极大,单价的分布更集中,两者在特征重要性和误差指标上表现完全不同。第二件事是字段定义表。原始数据里 price 什么单位、build_year 是拿证年份还是建成年份、楼层文本有没有统一格式,这些信息你不看文档只能靠猜。第三件事是已知限制。大多数爬下来的二手房数据都不包含学区、地铁距离、装修实际状况,文档里如果写了这一点,模型的误差上限你心里就有数,不会被一两个离群样本带偏。

3. 二手房数据清洗:从 CSV 到能建模的表格

房价预测项目的建模部分其实不难,难的是数据清洗。原始挂牌数据基本都带着三个毛病:单位混乱、口径不一致、文本字段的写法天差地别。这一章的目标很直接:把一份随手拿到的 CSV 变成能让 sklearn 直接消费的干净表格。

3.1 二手房数据为什么脏:三个高频问题

第一个高频问题是单位不统一。面积字段可能同时出现“89.5㎡”“89.5平米”“89.5平”三种写法,不统一就会让 pandas 把整个列读成字符串。第二个高频问题是字段口径不一致,总价和单价同时存在,但不同房源的单价有的是“元/平米”有的是“万元/平米”,建模前必须定清楚最终保留哪个。第三个高频问题藏在文本里,楼层字段常见的是“总33层/位于12层”,但也有“中楼层”“低楼层”这种无法直接转数字的写法,朝向则可能有“南北通透”“南向”“朝南”等多种描述,实际上指同一个方向组合。

清洗的核心思路是“所有字段只保留一种口径”,而不是逐条对数据做人工修正。把面积统一成数字平米,把朝向合并成方向分组,把楼层文本里的数字抽出来变成楼层数,然后把明显异常的样本剔除。整个过程要沉淀成代码,这样换一份数据、换一个城市,跑一遍同一个函数就能复用。

3.2 清洗函数:面积、朝向、楼层的统一化处理

下面这个函数是我在这种项目里的常规写法,覆盖了上面三个高频问题,外加缺失值和异常范围的兜底。你可以把它当成一个模板,按自己数据的实际字段名调整。

import pandas as pd import numpy as np def clean_second_hand(df: pd.DataFrame) -> pd.DataFrame: """把常见的二手房挂牌 CSV 清洗成统一口径的表格""" df = df.copy() # 1. 面积:去掉单位后缀,转成 float,解析失败的变成 NaN df["area"] = ( df["area"] .astype(str) .str.replace("㎡", "", regex=False) .str.replace("平米", "", regex=False) .str.replace("平", "", regex=False) .str.strip() .pipe(pd.to_numeric, errors="coerce") ) # 2. 朝向:合并高频写法,减少 one-hot 之后的维度爆炸 direction_map = { "南": "south", "南向": "south", "朝南": "south", "南北": "south_north", "南北通透": "south_north", "东南": "southeast", "西南": "southwest", "东": "east", "西": "west", "北": "north", } df["direction_group"] = df["direction"].astype(str).map(direction_map).fillna("other") # 3. 楼层:从“总33层/位于12层”这类文本里抽出第一个数字 df["floor_num"] = df["floor_info"].astype(str).str.extract(r"(\d+)")[0].astype(float) # 4. 删除明显冗余或可能导致目标泄漏的列 drop_cols = [c for c in ["unit_price", "link", "description"] if c in df.columns] df.drop(columns=drop_cols, inplace=True) # 5. 面积 15-300 平米之外的样本视为异常,价格缺失的直接丢弃 df = df[(df["area"] > 15) & (df["area"] < 300)] df = df.dropna(subset=["price", "area"]) return df

逻辑上分五步走:先把面积从带单位字符串转成数值,再用映射表把朝向归成 7 个方向组,然后从楼层文本里抽取数字做后续建模特征,接下来删掉单价、链接这些会导致目标泄漏或无语义信息的列,最后按业务常识裁剪异常范围。参数上需要留意两个地方:一是 errors="coerce" 表示无法解析的字符串会变成 NaN,之后由 dropna 兜底;二是面积范围 15 到 300 是按普通住宅挂牌来设的,如果项目是公寓数据,这个区间要放宽到 10 到 500,否则会误删大量正常样本。

注意:如果你的原始数据里没有“总33层/位于12层”这种文本,只有“低楼层/中楼层/高楼层”三档,就不要硬抽数字,直接保留分类字段当特征,否则会抽出一堆 NaN。

3.3 清洗到什么程度算合格:肉眼抽检与字段统计

清洗函数跑完不算完,关键要验证输出是不是真的干净。我会先打印方向分组的频数和每列缺失值数量,再做一次人工抽检。前两步能快速发现映射表漏了哪些写法,抽检能发现像楼层文本里“半地下”这种正则表达式抓错了的数字。

clean_df = clean_second_hand(pd.read_csv("data/raw/house_list.csv")) print(clean_df["direction_group"].value_counts()) print(clean_df.isna().sum()) print(clean_df.head(50).to_string())

这段代码的意图不是跑通流程,而是建立一条验证基线。value_counts 的输出里,如果 other 这一类的占比超过 20%,说明方向映射表漏掉了本地的常见写法,需要回去补表;isna().sum() 的输出里,area、price、floor_num 这几列应该是 0 或接近 0;head(50) 则是给你自己看的,人工快速扫一遍面积、楼层、朝向,确认没有出现“面积 999”“楼层抽取成 3 位数”之类的明显错误。数据清洗做到这一步,后面建模才有意义,否则再好的模型也是在垃圾数据上做文章。

4. 特征工程与模型选择:先线性回归做基准,再用随机森林拿结果

清洗完之后进入建模阶段。这个项目的常规做法是先上线性回归,用它的稳定表现当基线,再上随机森林捕捉非线性关系。两个模型各有各的位置,不要上来就直接堆复杂模型。

4.1 为什么选这两个模型:定位对比

模型适合场景主要风险在这个项目里的定位
线性回归特征与目标近似线性关系对异常值敏感,容易预测出负价格基准线,用于判断特征是否合理
随机森林特征间存在非线性关系树过多时过拟合,预测波动大主力模型,输出稳定且可解释

线性回归在这里的价值不是拿最终结果,而是用来“验特征”。如果线性回归在某个特征组合上的误差低得离谱,那大概率是出现了数据泄漏,而不是特征真的这么强。随机森林因为能捕捉交互关系,通常在房价这种特征非线性明显的任务上表现更好,但要注意它的预测结果是训练样本的加权平均,极端组合可能被平滑掉。

4.2 构造模型直接可用的特征列

原始数据里的朝向、区域、楼龄都是文本或原始数值,需要转成模型能吃的形式。我一般会把朝向和区域各自编码成类别码,楼龄按当前年份减建成年份计算。

from datetime import datetime current_year = datetime.now().year def build_features(df: pd.DataFrame) -> pd.DataFrame: df = df.copy() # 楼龄:当前年份减建成年份,负值直接截成 0 df["age"] = (current_year - df["build_year"]).clip(lower=0) # 朝向类别编码:把字符串映射成整数 df["direction_code"] = df["direction_group"].astype("category").cat.codes # 区域类别编码:商圈或行政区名映射成整数 df["district_code"] = df["district"].astype("category").cat.codes return df

age 直接使用当前年份会让结果受时间影响,但这个项目做的是截面数据分析,不是时间序列预测,所以问题不大。direction_code 和 district_code 是用 pandas 的 category 类型自动映射,省去了手工维护字典的麻烦。要留意的是,如果后面换了新城市的数据,district 名字集合变了,旧模型就没有办法直接迁移,需要重新训练。

4.3 训练脚本:线性回归和随机森林的对照实验

下面这段代码把两个模型放在同一个数据切分上做对照,是这类源码包里最常见的主训练逻辑。评估指标用 MAE 和 RMSE 一起看。

from sklearn.model_selection import train_test_split from sklearn.linear_model import LinearRegression from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_absolute_error, mean_squared_error feature_cols = ["area", "age", "floor_num", "direction_code", "district_code"] X = df[feature_cols] y = df["price"] 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) lr_pred = lr.predict(X_test) rf = RandomForestRegressor( n_estimators=300, max_depth=12, min_samples_leaf=3, random_state=42, n_jobs=-1, ) rf.fit(X_train, y_train) rf_pred = rf.predict(X_test) for name, pred in [("linear", lr_pred), ("random_forest", rf_pred)]: print(name, "MAE:", mean_absolute_error(y_test, pred)) rmse = mean_squared_error(y_test, pred, squared=False) print(name, "RMSE:", rmse)

先运行线性回归,它能给出一个误差基线;再跑随机森林,如果随机森林的 MAE 没有明显低于线性回归,说明特征里没有足够的非线性信号,问题多半出在特征工程上而不是模型选择上。几个关键参数解释一下:random_state=42 固定了数据切分和模型随机性,保证不同人复现的结果一致;n_estimators=300 是随机森林的树数量,太少预测不稳定,太多训练时间增加但收益递减;max_depth=12 限制每棵树的深度,防止树记住单个样本;min_samples_leaf=3 保证叶子节点至少有三个样本,减少极端预测值;n_jobs=-1 让所有 CPU 核心并行训练。如果你的 sklearn 版本较新,跑上面的代码会提示 squared 参数弃用,把 mean_squared_error(..., squared=False) 换成 mean_squared_error(..., root=True) 即可,结果不受影响。

4.4 特征重要性:不要只看分数,还要看业务逻辑

随机森林训练完之后,我会顺手打印特征重要性,这一步在源码包里通常放在训练脚本末尾。它能快速帮我们发现特征构造是否合理。

import pandas as pd importance = pd.Series(rf.feature_importances_, index=feature_cols) print(importance.sort_values(ascending=False))

特征重要性的业务逻辑一般是:面积、楼龄、区域排在前三,楼层和朝向的权重依城市而定。如果出现 direction_code 排第一,或者 age 排第一而面积排到最后,那基本可以断定特征编码出了问题,比如 area 列在清洗时被错误转成了字符串,或者朝向编码泄露了什么额外信息。特征重要性适合当报警器用,不适合当精确解释工具。

5. 避坑手册:本地复现最容易翻车的 5 个场景

看完前面几章直接把代码跑起来,大概率会遇到几个经典问题。这一章按“现象、原因、解决”的方式写了 5 个我在类似项目里见过最多的情况,不是理论推演,都是实际翻过车才总结出来的。

5.1 测试集里混进同小区房源:MAE 好看,换城市就崩

现象:用 train_test_split 随机切分数据,MAE 看起来非常低,模型似乎很准,但把同样的代码套到另一个城市的数据上,误差立刻翻倍。原因:同一个小区的挂牌房源之间强相关,随机切分会让同一小区的一部分样本进训练集、另一部分进测试集,模型相当于“见过”这个小区的大致价格区间,测试分数虚高。解决:按小区做分组切分,把整个小区的房源全部放进同一侧。

from sklearn.model_selection import GroupShuffleSplit gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, test_idx = next(gss.split(X, y, groups=df["community"])) X_train, X_test = X.iloc[train_idx], X.iloc[test_idx] y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]

GroupShuffleSplit 的 groups 参数传小区名字列,它会保证同一个小区不会同时出现在训练集和测试集里。这样评估出的 MAE 才是模型面对“陌生小区”时的真实水平。group 切分的代价是训练集变小,但换来了评价结果可信,我一般优先选它。

5.2 预测结果显示的是单价却不是总价:目标变量口径错了

现象:模型跑完没有报错,输出结果一对比发现数值全是每平米单价,和项目说明里的目标变量对不上。原因:清洗阶段删列顺序写错,把 price 总价列删了,却把 unit_price 单价列留到了最后,目标变量实际建模时的口径是单价。解决:在清洗脚本里明确定义目标变量,单独抽出来存成一个 Series,再检查最终保留的列。

y = df["price"].copy() assert "price" in df.columns and "unit_price" not in df.columns print(y.head())

这个断言看起来很基础,但它能拦截掉一大批因为列名重复或字段名相似而导致的低级错误。我在第一次做类似项目时,就因为字段命名类似,花了半天才意识到模型拟合的是单价而不是总价。

5.3 朝向的 one-hot 维度爆炸:映射表不完整

现象:方向分组后的 value_counts 显示有 20 多个取值,训练速度明显变慢,特征重要性也分散得没有规律。原因:映射表只覆盖了最主流的写法,本地房源常见“西南北”“南北东”“其他朝向”等组合全部落进了 other 以外的零散类别。解决:把低频类别统一归并,频率低于阈值的全部改成 other。

freq = clean_df["direction_group"].map(clean_df["direction_group"].value_counts()) clean_df["direction_group"] = clean_df["direction_group"].where(freq > 50, "other")

阈值 50 按数据量调整,我习惯用“样本总量 * 1%”作为阈值,低于这个比例的方向组没有统计意义,保留只会增加噪声。

5.4 模型输出了负价格:异常值处理没做好

现象:预测结果里出现负值,比如 -12 万,完全不符合业务常识。原因:线性回归对极端样本敏感,价格分布右偏,少部分豪宅样本直接把回归超平面拉歪了。解决:对目标变量做 log1p 变换,再训练模型,评估时用 expm1 还原。

import numpy as np y_log = np.log1p(y) # 训练与预测全部使用 y_log,最后还原 y_pred_original = np.expm1(rf.predict(X_test))

log1p 会把价格压缩到线性可建模的范围,豪宅样本的影响力被大幅削弱。还原之后误差会略放大,但模型不会再产出负价格这种荒谬结果。如果在真实项目中看到负价格,别去调模型参数,先回清洗阶段看是不是有异常值没处理干净。

5.5 楼层字段里藏着总层数:特征泄漏的经典案例

现象:验证分数高得异常,模型上线后一检查,发现预测高度依赖楼层特征,但业务上楼层对房价影响没那么大。原因:楼层原始文本是“总 33 层/位于 12 层”,正则抽出数字时把“33”抽了出来,模型等于拿到了总层数这个建筑属性。解决:特征只保留所在楼层占总层数的比例,或者干脆转成“低、中、高”三档。

df["floor_ratio"] = df["floor_num"] / df["total_floor"] # 或者使用三档分类 df["floor_level"] = pd.cut(df["floor_ratio"], bins=[0, 0.33, 0.66, 1.0], labels=["low", "mid", "high"])

这样既保留楼层位置的横向对比价值,又避免了总层数这一信息的直接泄漏。楼层特征只在高楼层密集的小区里才有明显区分度,在多数城市它属于弱特征,不值得为它引入泄漏风险。

6. 验证与迁移:交叉验证、换城复跑与一个收尾习惯

项目跑通只是开始,真正有价值的是知道模型“到底有多可信”,以及换一份数据之后还能不能稳定复现。这一章给三个步骤:用交叉验证替代单次切分,把项目迁移到新城市,以及一个我坚持了很久的收尾习惯。

6.1 用交叉验证替代单次切分

单次切分的结果太依赖随机种子,换一个 random_state 误差可能差出好几万。五折交叉验证的做法是让每一条样本都有机会出现在测试集里,最终报告的误差是五轮结果的平均值,稳定得多。

from sklearn.model_selection import KFold, cross_validate def evaluate_model(model, X, y, cv=5): kf = KFold(n_splits=cv, shuffle=True, random_state=42) scoring = { "mae": "neg_mean_absolute_error", "rmse": "neg_root_mean_squared_error", } results = cross_validate(model, X, y, cv=kf, scoring=scoring) return { "MAE": -results["test_mae"].mean(), "RMSE": -results["test_rmse"].mean(), }

scoring 里 sklearn 的负号前缀表示“负值越大代表误差越小”,所以返回值要手动取负。如果你的 sklearn 版本低于 0.24,不支持 neg_root_mean_squared_error,就把它拆成两个单独 scoring 项,或者只用 neg_mean_absolute_error。

6.2 迁移到新城市的三个改动点

复制整个项目到一个新城市的数据上,不是换一个 CSV 路径就行。第一,区域编码的映射表要换成目标城市的商圈或行政区域列表,旧映射表里的 district_code 毫无意义;第二,朝向映射表大概率能保留,但建议重新跑一次清洗后的 value_counts,确认 other 占比没有因为城市差异暴涨;第三,目标变量和评估口径要重新审视,不同城市房价基数差异大,可以把 MAE 换成 MAPE(平均绝对百分比误差)来对比模型在不同城市间的相对表现。

6.3 收尾习惯:人工抽检 50 条,再信模型

模型评估再全面,也不如人工看一眼结果来得安心。我做完清洗和建模之后,一定会把测试集里的预测值和真实值并排打印 50 条,肉眼对比。这一眼能发现模型系统性低估或者高估的区间,比如大面积户型全部预测偏低,或者高层房源被随机森林过高估值。有一次我在某城市复跑项目,模型各项指标正常,唯独某一栋楼的房源全部预测错误,最后定位到楼层字段里的“半地下”被正则抓成了数字,导致面积和楼层完全不匹配。这类问题靠交叉验证发现不了,只有人眼扫过前几十行才看得出异常。看完 50 条,再决定要不要调特征、换模型或砍样本,别让评估指标成为唯一的判断依据,这是我在这类项目里最想强调的一个习惯。希望帮到你。

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

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

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

立即咨询