☰
二手房房价预测全流程实战:从爬虫清洗到随机森林调优
2026/9/26 14:41:28 网站建设 项目流程

简介:这是一份基于Python的二手房房价数据分析与预测项目源码与说明文档,定位于高校毕设/课设、数据科学入门与进阶自学,特别适合有基础编程能力、希望走通真实数据处理全流程的学习者。压缩包共17个文件,包含12个CSV数据集、3个便于跟随操作的Jupyter Notebook教学文档、1个Python脚本和1份Word版项目说明书,整体大小仅6.22MB,目录精简,便于快速上手。项目以链家二手房为切入点,覆盖缺失值处理、异常值检测、Pandas探索性分析、可视化呈现特征关系,并基于Scikit-learn构建线性回归、决策树回归和随机森林等模型,通过交叉验证调参和特征工程提升预测表现;此外还引入时间序列分析,尝试对未来房价走势做出判断。当前已有118人学习下载,适合需要完整项目参考的毕设课设用户。配套说明文档和脚本完整保留了主要实现思路,拿到后可以边读边练,也便于替换城市或数据集进行二次扩展。

1. 二手房房价预测项目:一条值得完整跑通的数据分析链路

在数据分析和预测这条路上,很多项目要么只讲算法原理,要么直接丢给你一份清洗好的数据,想自己撑起“拿数据 → 清洗 → 建模 → 出结论”整条链路,往往要东拼西凑。这份二手房房价数据分析与预测源码包是少见的全链路项目:爬虫、清洗、EDA、线性回归 / 决策树 / 随机森林建模、交叉验证,一步不缺。拆完最大的感受是:它不追求模型上限,而是把流程的完整性放在第一位,特别适合想让 Pandas 和 Scikit-learn 落地的人。无论你是刚学完 Python 基础,还是正在为课设、毕设找一份 baseline,这份资源都能直接拿来跑通一条真实链路。

按说明把两个 notebook 跑通,你就能看到数据从网页变成预测结果的全过程。下面我按自己拆解的顺序,把爬虫、清洗、建模和踩坑点逐段展开,中间会贴出可执行的替代代码,方便你复现时对照。

2. 从链家爬虫到结构化数据:把页面信息落成 DataFrame

2.1 爬虫脚本里到底做了三件事

资源里的“链家网数据爬取.ipynb”职责很清楚:把目标城市的二手房列表页抓下来,解析出字段,再交给后续分析使用。很多新手以为爬虫的核心是“请求页面”,其实真正花时间的部分是从 HTML 里稳定地提取字段。按我的拆解,这个 notebook 做了三件事:

  1. 请求列表页,拿到 HTML;
  2. 从每套房子的卡片里抽总价、单价、面积、户型、朝向、楼层、区域等字段;
  3. 翻页拼接 URL,加上延时循环抓取。

下面这段是我按同样思路整理的最小版本。注意这里把“请求”“解析”“翻页调度”拆成了三个函数,这个习惯很值得保留。

import time import requests from bs4 import BeautifulSoup HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } def fetch_listing_page(page: int) -> str: # 实际使用时把 example 域名替换成项目里配置的目标地址 url = f"https://xx.example.com/loupan/pg{page}/" resp = requests.get(url, headers=HEADERS, timeout=15) resp.raise_for_status() return resp.text def parse_listing(html: str) -> list[dict]: soup = BeautifulSoup(html, "html.parser") items = [] # 下面的选择器以实际页面结构为准,项目 notebook 里已经调好 for card in soup.select(".sellListContent li"): total = card.select_one(".totalPrice span") unit = card.select_one(".unitPrice") info = card.select_one(".houseInfo") if total and unit and info: items.append({ "总价": total.text.strip(), "单价": unit.text.strip(), "房源信息": info.text.strip().split("|"), }) return items def crawl(max_page: int = 10, sleep_secs: float = 1.5): all_rows = [] for page in range(1, max_page + 1): html = fetch_listing_page(page) rows = parse_listing(html) print(f"page {page}: {len(rows)} rows") if not rows: break all_rows.extend(rows) time.sleep(sleep_secs) return all_rows if __name__ == "__main__": data = crawl(max_page=10) print(len(data))

逻辑说明:fetch_listing_page 只负责拿页面,parse_listing 只负责从页面里抠字段,crawl 负责控制翻页和节奏。这样目标站点某个字段的标签变了,你只需要改 parse_listing,不用动整个链路。

参数上,max_page 控制抓取深度,sleep_secs 控制请求间隔。1.5 秒只是一个保守节奏。实际按目标网站的反爬强度和你自己的网速调整。项目里用的是静态请求,没有引入 selenium,因为列表页大多不需要复杂渲染,静态解析更轻量。

2.2 跑通爬虫的最短路径:依赖、请求头与延时

先把环境配齐。README.docx 和 notebook 的 import 区已经写明了依赖,我通常会一次性装完整:

pip install requests beautifulsoup4 pandas jupyter matplotlib seaborn scikit-learn

装完后先在 notebook 里做一次“单页测试”,不要一上来就开 100 页。字段对上了再放开翻页。常见做法是单独拉一个 cell 做冒烟测试:

html = fetch_listing_page(1) rows = parse_listing(html) print(len(rows), rows[0] if rows else "EMPTY")

如果 rows 是空,说明选择器已经失效,加再多延时也抓不回来。解决方式很直接:在浏览器开发者工具里重新看目标字段的 CSS 选择器或 xpath,回填到 parse_listing 里。我把这一步叫前置检查,能避掉 70% 的爬虫问题。

请求头里至少要有 User-Agent。有的站点还会校验 Referer 和 Cookie,遇到 403 就在 HEADERS 里补上浏览器里复制到的 Cookie。不过要注意,Cookie 会过期,更好的做法是先正常访问一轮,把必要的 Cookie 参数化,而不是硬编码一个十天后的临时值。

2.3 先落 CSV 再进模型:data 目录是中间层

爬虫拿到的原始数据是一堆 list[dict],直接交给 pandas 转成 DataFrame,再存成 CSV。这一步不经过模型,却能让后面的调试效率提高一个量级。项目里专门有 data 目录,这就是给 CSV 留的中间层。

import pandas as pd rows = crawl(max_page=10) df = pd.DataFrame(rows) df.to_csv("data/raw_house.csv", index=False, encoding="utf-8-sig") print(df.shape)

这里用 utf-8-sig 是为了让 Excel 打开 CSV 时不乱码。如果你在 Jupyter 里继续做分析,建议从这里开始单独开一个 cell,只做 read_csv,不要再重新抓取。数据一旦固化,后面每一步都不需要重新访问目标网站,也降低被封风险。sol.ipynb 里后面的操作,基本就是基于这份 CSV 展开的。

3. 清洗与 EDA:房价分析里最容易翻车的两个环节

3.1 缺失值与异常值:先看分布再动手

把 CSV 读进来后,第一件事不是建模,而是看 df.info() 和 df.isnull().sum()。二手房字段里,最容易缺的是“小区”“户型”“楼层”这类信息型字段,而价格、面积一般比较完整。

import pandas as pd df = pd.read_csv("data/raw_house.csv", encoding="utf-8-sig") print(df.info()) print(df.isnull().sum())

逐字段看缺失比例。我的处理原则是:缺失率低于 5% 的字段直接删除对应行;5% 到 20% 的字段用中位数或众数填充;超过 20% 的字段要重新评估,它可能不适合进模型。这一点在二手房场景里尤其关键,因为“满五”“配套”这类信息缺失并不代表不存在,强行填充反而会引入噪音。

缺失值处理完,就要看异常值。价格、面积这类连续变量最容易出现录入错误,比如总价 1 万以下、面积 1 平方米。先转成数值类型:

import pandas as pd import numpy as np df["总价"] = pd.to_numeric(df["总价"].astype(str).str.replace(",", ""), errors="coerce") df["单价"] = pd.to_numeric(df["单价"].astype(str).str.replace("元/平", ""), errors="coerce") df["面积"] = pd.to_numeric(df["面积"].astype(str).str.replace("平米", ""), errors="coerce") print(df.describe())

只看 describe 还不够。我一般先看 1% 和 99% 分位数,再过滤掉明显不合理的区间。下面的边界不是固定的,不同城市、不同房源类型的差异很大:

df = df[(df["面积"] > 5) & (df["面积"] < 300)] df = df[(df["单价"] > 2000) & (df["单价"] < 200_000)]

如果你用的是链家一线城市数据,5000 到 15 万每平是比较常见的区间。建议先跑一下 df["单价"].quantile([0.01, 0.99]),再决定上下界,不要直接套死数字。

3.2 EDA 可视化:价格和哪些字段同涨同跌

数据清洗完,进入探索性数据分析。项目里用 pandas 和可视化库把数据特征铺开来看,这一步的价值是帮你找到“哪些特征值得进模型”。先看总价的分布:

import matplotlib.pyplot as plt import seaborn as sns plt.figure(figsize=(8, 4)) sns.histplot(df["总价"], bins=50) plt.yscale("log") plt.show() numeric_cols = df.select_dtypes(include=[np.number]).columns plt.figure(figsize=(10, 8)) sns.heatmap(df[numeric_cols].corr(), annot=True, cmap="coolwarm", fmt=".2f") plt.show()

对数 y 轴是为了让右偏的分布看得更清楚。热力图则能一眼看到“面积”和“总价”高度正相关,而“楼层”这类字段与价格的相关性往往弱得多。EDA 不是看图就结束了,更有效的做法是每发现一个相关性,就把它变成候选特征。比如“总价 / 面积”就是单价,“区域 + 户型”的组合可以放进后续模型。项目里后面用到的特征工程,很多思路就是从这几张图里来的。

3.3 时间维度上的房价走势:简单的时间序列观察

摘要有提到时间序列分析,这一点在二手房场景里通常落地为“挂牌时间 / 成交时间”的月度均价走势。如果你的数据里有挂牌时间字段,直接提取月份并按月份聚合。

df["挂牌月份"] = pd.to_datetime(df["挂牌时间"]).dt.to_period("M") monthly = df.groupby("挂牌月份")["单价"].mean().reset_index() print(monthly.head())

数据量够的话,可以用 three-period moving average 去掉单月波动:

monthly["单价_ma3"] = monthly["单价"].rolling(3).mean()

这里做的只是趋势观察,不是严格意义上的时间序列预测模型。对课设和毕设来说,能把月份趋势画出来已经足够体现时间序列分析这部分了。更重要的是,在后面建模时,你最好意识到时间字段不是普通数值,这一点第 5 章会专门说。

4. 线性回归 vs 决策树 vs 随机森林:用交叉验证选出能用的预测模型

4.1 特征编码与训练集构造

模型训练之前,要把文本类特征变成数值。常见做法是使用 pandas 的 get_dummies 做 one-hot 编码,或者对有序字段用 LabelEncoder。Scikit-learn 本身不接受字符串,所以这一步躲不掉。

from sklearn.model_selection import train_test_split feature_cols = ["面积", "户型", "朝向", "楼层", "区域", "挂牌月份"] df_model = df.dropna(subset=feature_cols + ["总价"]) X = df_model[feature_cols] y = df_model["总价"] X_encoded = pd.get_dummies(X, columns=["户型", "朝向", "楼层", "区域"], drop_first=True) X_train, X_test, y_train, y_test = train_test_split( X_encoded, y, test_size=0.2, random_state=42 ) print(X_train.shape, y_train.shape)

这里的 drop_first=True 是为了去除 one-hot 后的冗余列,避免线性回归出现多重共线性。random_state 固定成 42,保证你每次跑出来的结果可复现,这也是做课设时最容易忽略的细节。

严格说,“挂牌月份”不应该直接当数值特征,因为它是有循环周期的月份编号。稳妥的做法是把它转成“一年中的第几个月”,或者单独做 one-hot。第 5 章我会把这个问题当重点踩一遍。

4.2 三个回归模型的一次对齐

项目里同时使用了线性回归、决策树回归和随机森林回归。先各自用默认参数跑一轮,用 RMSE 和 R² 对比,这一步叫基线对齐。

from sklearn.linear_model import LinearRegression from sklearn.tree import DecisionTreeRegressor from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_squared_error, r2_score models = { "LinearRegression": LinearRegression(), "DecisionTree": DecisionTreeRegressor(random_state=42), "RandomForest": RandomForestRegressor(n_estimators=100, random_state=42), } results = [] for name, model in models.items(): model.fit(X_train, y_train) pred = model.predict(X_test) rmse = mean_squared_error(y_test, pred) ** 0.5 r2 = r2_score(y_test, pred) results.append((name, rmse, r2)) for name, rmse, r2 in results: print(f"{name:18s} RMSE={rmse:.2f} R2={r2:.3f}")

逻辑说明:把三个模型塞进同一个循环,输出统一格式的评估指标,这是项目里很实用的做法。RMSE 反映误差的实际量级,R² 反映模型解释了多少房价方差。对二手房这个场景,RMSE 比 R² 更有参考价值,因为 R² 很高不代表你预测的单价误差小。

随机森林默认用 100 棵树,稳定性明显优于单棵决策树。决策树如果不限制深度,几乎一定会过拟合;线性回归又太简单,对非线性关系无能为力。三者对比的价值就在这里:你能直观看到“复杂度上来了,误差降了多少”。

4.3 GridSearchCV 调参:把随机森林的潜力挤出来

项目摘要里提到了交叉验证优化模型参数。最常见的是 GridSearchCV,把随机森林的主要参数做一个小范围搜索。

from sklearn.model_selection import GridSearchCV param_grid = { "n_estimators": [100, 200], "max_depth": [None, 10, 20], "min_samples_split": [2, 5], "max_features": ["sqrt", "log2"], } grid = GridSearchCV( RandomForestRegressor(random_state=42), param_grid, scoring="neg_root_mean_squared_error", cv=5, n_jobs=-1, verbose=1, ) grid.fit(X_train, y_train) print(grid.best_params_) print(-grid.best_score_)

参数说明:n_estimators 从 100 起步就够用,1000 会让组合数爆炸;max_depth 用一个有限值列表去试,None 表示不限制;min_samples_split 控制分裂所需的最小样本数,越大越不容易过拟合;max_features 控制每次分裂看几个特征,常用 "sqrt" 和 "log2"。

不要一上来就跑全网格。以小组合先找到大方向,再在最优值附近做第二轮。如果你在 Jupyter 里同时打开了很多 cell,n_jobs 可以调成 4,别把 CPU 全占满。GridSearchCV 的 scoring 参数我习惯用负 RMSE,因为在 sklearn 里“分数越高越好”,负 RMSE 越大代表误差越小。

5. 避坑指南:跑二手房预测项目常见的五个翻车点

5.1 爬虫抓回来全是空数据

现象:爬虫跑完,DataFrame 是空的,或者字段全是 NaN。

原因:最常见的是 CSS 选择器已经失效,其次是没有带 User-Agent,被目标网站拒绝。

解决:先做单页调试,打印页面前 1000 个字符,确认返回的是 HTML 而不是验证码或错误页。然后重新打开开发者工具,核对选择器。选择器没调对,增加延时也救不回来。项目里链家爬虫的选择器是基于当时页面结构写的,等你下载使用时,很可能需要自己修正一次。

5.2 中文列名让 sklearn 直接报错

现象:fit 的时候报ValueError: could not convert string to float: '东城'。

原因:不是中文列名本身的问题,而是列里还有未编码的字符串值。你只处理了数字列,忘记把“区域”“户型”等文本列做编码。

解决:把所有非数值特征统一收集起来,一次性用 pd.get_dummies 处理。处理完检查dtypes,确保全部是数值类型。还有个隐蔽点:X_train 和 X_test 的列顺序必须一致,如果编码时用了两次 get_dummies,列顺序很容易错位。

5.3 模型 R² 高得离谱,先查数据泄露

现象:R² 能到 0.98,看起来特别完美,但换一条新的数据来预测,结果一塌糊涂。

原因:特征里混进了目标变量的直接派生字段。最常见的是预测“总价”,却把“单价”也放进特征里;或者在 train_test_split 之前用全量数据做标准化,把测试集的统计信息提前泄漏给了模型。

解决:检查特征列里有没有和 y 线性相关的派生列。标准化的正确顺序是:先切分训练集和测试集,再在训练集上 fit,用同一个 transform 处理测试集。Scikit-learn 的 Pipeline 能帮你固定这个顺序,建议直接用它。

5.4 GridSearchCV 慢到怀疑人生

现象:一个参数组合跑了十几分钟,notebook 卡到没法继续。

原因:参数网格太大,数据量又没控制。比如 n_estimators 给到 [300, 500],max_depth 再给四五个值,组合数直接翻了几倍。

解决:先用 5000 到 10000 条样本做一轮粗筛,把 n_estimators 控制在 100 和 200,max_depth 控制在两三个值。如果搜索空间还是很大,就用 RandomizedSearchCV,在参数分布里随机采样,效率高得多。项目里用的是完整网格搜索,适合小数据集,你复现时如果数据量大,这一步一定要缩。

5.5 把挂牌时间当成普通数值特征

现象:训练集误差很小,但按时间顺序验证时,误差突然变大甚至出现跳跃。

原因:月份是循环周期变量,12 和 1 在数值上相差 11,但实际只差一个月。直接把“挂牌月份”塞进线性模型,模型会学到一段并不存在的线性趋势。

解决:把月份拆成“一年中的第几个月”的多列 one-hot,或者把年份和月份分开处理。如果数据带成交时间,还必须有时间顺序意识:训练集应该是时间靠前的数据,测试集是时间靠后的数据,不能用 train_test_split 随机打乱。这个坑最隐蔽,也是摘要有提时间序列分析的原因。

6. 从预测到结论:用特征重要性和模型保存收好整条链路

6.1 老规矩,先看训练与测试误差的差距

调完参,不要直接拿测试集 R² 写结论。先把训练集和测试集 RMSE 放在一起对比。如果训练集远低于测试集,说明模型过拟合了。

pred_train = grid.best_estimator_.predict(X_train) pred_test = grid.best_estimator_.predict(X_test) train_rmse = mean_squared_error(y_train, pred_train) ** 0.5 test_rmse = mean_squared_error(y_test, pred_test) ** 0.5 print(f"train RMSE={train_rmse:.2f}, test RMSE={test_rmse:.2f}")

经验值:两者差距控制在 10% 到 20% 以内。如果超过 30%,先回退 max_depth,随机森林深度太大是过拟合的主要来源。这个检查我每次都会做,比单看某个指标靠谱得多。

6.2 把模型存下来,给下一次预测留后门

模型验证完,最容易被忽略的是保存。课设答辩或项目展示时,现场重新训练十几分钟不是好体验,提前保存模型是更专业的方式。

import joblib joblib.dump(grid.best_estimator_, "models/house_price_rf.pkl") joblib.dump(X_train.columns.tolist(), "models/feature_columns.pkl") loaded_model = joblib.load("models/house_price_rf.pkl") loaded_columns = joblib.load("models/feature_columns.pkl")

这里要连列名一起保存。原因很实际:预测新数据时,你必须保证输入的特征列顺序和训练时完全一致。保存列名之后,遇到缺列可以手动补零,遇到多出来的列就删掉。

6.3 用特征重要性给业务结果一个解释

最后,随机森林自带 feature_importances_,这一步是给整个项目加分的关键。

import pandas as pd importance = pd.Series( grid.best_estimator_.feature_importances_, index=X_train.columns ).sort_values(ascending=False) print(importance.head(10))

对二手房这个场景,面积、区域通常会排在前面,朝向和装修的重要性一般靠后。如果前几名里出现了不合逻辑的特征,回头检查是否清洗不干净。这一步能让你的分析报告从“我调了一个模型”变成“我知道了什么在真正拉动房价”。

拆这份项目的时候,我把模型重复训练了三次,每次换一个 random_state,最终误差没有明显波动才算放心。从那以后,我每次做预测类项目都会强制走一遍:训练/测试误差对比、模型保存、特征重要性检查,缺一不可。这份源码包把链路搭得很稳,希望你也能顺着它跑通自己的第一个房价预测项目。

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

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

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

立即咨询