为了搞清楚某座城市二手房市场的真实价格水平,我一开始做了个很原始的尝试:手动把几个热门板块的房源信息一条条填进Excel。记到三百多条的时候,手指先投降了,我决定结束这种纯体力劳动。于是就有了这个项目——一套基于Python+爬虫+随机森林算法的房价数据分析与预测系统。它的工作流程很清晰:爬虫负责自动采集挂牌房源,数据清洗和特征工程把脏乱字段变成模型能吃的结构化特征,随机森林负责从历史样本中学习价格规律并对未知房源做预测。这篇文章会把我的设计思路、实现细节、跑出来的结果,以及踩过的坑完整讲一遍,适合想做一个全流程Python数据项目的朋友参考,也适合正在做类似练手项目或课程设计的同学直接抄作业。
1. 系统设计:先想清楚数据怎么流动,再动手写代码
1.1 模块划分与数据流
很多新手拿到这种题目就急着去pip install各种库,结果爬到一半发现字段对不上,清洗时又把数据搞乱了。我的习惯是先画数据流。
整个系统拆下来就是四个环节:
- 采集层:从房源信息平台抓取列表页和详情页数据,包括户型、面积、总价、所在区域、楼层、朝向、装修情况等原始字段。
- 存储层:把清洗前的原始数据先落到SQLite数据库里,备份一份原始脏数据,防止后面处理失误时还能回头查。
- 处理层:用pandas做数据清洗、字段拆分、特征构造,最终生成建模用的宽表。
- 模型层:使用随机森林回归模型训练、调参、评估,并输出新样本的预测价格。
数据流大概是:目标页面 → 爬虫请求 → 解析结果 → SQLite原始表 → 清洗脚本 → 特征宽表(CSV或数据表) → 训练脚本 → 模型文件和评估报告。
这套流程我用的全是Python生态最基础的工具:requests加BeautifulSoup做采集,sqlite3做存储,pandas做处理,scikit-learn做建模。没有引入重型框架,好处是每一层都能单独调试,坏处是代码量会稍微多些,但作为个人项目完全够用。
1.2 技术选型为什么是这套组合
先解释几个关键选型的理由,因为后面所有代码都围绕这些决定展开。
爬虫为什么用requests+BeautifulSoup,不用Scrapy和Selenium?
这个项目的数据量是千级到万级,不是百万级。Scrapy的并发和调度能力很强,但对应的学习成本和框架约束也高。requests写起来直观,哪里断了就debug哪里,对单机小项目来说性价比最高。BeautifulSoup的解析语法简单,find、select一用就上手。Selenium能用,但它会启动浏览器,性能和稳定性都吃亏,除非目标网站大量依赖JavaScript动态渲染,否则没必要上。实际测试中,我遇到的大多数房源字段都直接嵌在HTML源码里,用requests就够了。
预测为什么选随机森林,而不是线性回归或XGBoost?
这个是我反复对比后的结论。房价和特征之间基本不是线性关系——面积对总价的影响在豪宅和刚需盘上完全不一样,房子所在板块和楼层对价格的影响也有复杂的交互作用。线性回归对这种关系拟合能力不足。XGBoost在调参得当的情况下精度确实更高,但样本量只有几千条时优势并不明显,而且超参数空间更大,调起来更痛苦。随机森林属于集成学习里的Bagging家族,方差小、抗过拟合能力强,对异常值也比XGBoost更稳。更关键的是它自带特征重要性输出,能直接告诉我们哪些因素在影响房价,这对一个分析型项目来说价值很大。
存储为什么用SQLite?
CSV当然能存,但房源数据有字段更新和去重的需求,SQLite支持简单的SQL语句,处理重复数据和增量更新都更方便。这个体量单文件数据库完全能扛住,也不需要单独维护数据库服务。
1.3 数据字典先定好,后面少返工
我在写爬虫之前,先列了一份目标字段表。这一步极其重要,因为页面解析、数据库建表和特征工程全都依赖这份表。最终定的核心字段如下:
| 字段名 | 含义 | 原始页面来源 | 数据类型 |
|---|---|---|---|
| house_id | 房源唯一标识 | 详情页ID | 文本 |
| district | 所在区域 | 详情页 | 文本 |
| block | 所属板块 | 详情页 | 文本 |
| community | 小区名称 | 列表页 | 文本 |
| house_type | 户型(如3室2厅1卫) | 列表页 | 文本 |
| area | 建筑面积 | 列表页 | 文本 |
| floor_info | 楼层信息(如低楼层/共18层) | 详情页 | 文本 |
| orientation | 朝向 | 详情页 | 文本 |
| decoration | 装修情况 | 详情页 | 文本 |
| year_info | 建筑年代 | 详情页 | 文本 |
| total_price | 挂牌总价(万元) | 列表页 | 数值 |
| unit_price | 挂牌单价(元/平) | 列表页 | 数值 |
这里要提醒一句:unit_price虽然在页面里直接给了,但我前期做预测时是把它当作待预测目标来处理的,而不是特征。如果把它当特征放进模型去预测unit_price本身,就构成典型的特征泄漏,模型效果会好到不真实,一上线就露馅。这个坑我在后面第五章还会专门展开。
2. 房源数据采集:在反爬限制和数据完整之间找平衡
2.1 页面结构解析与字段抽取
我选了一个某生活服务平台下的二手房产频道作为数据源,完全是为了练手,个人学习用途、控制请求频率,没有对目标服务造成压力。进入列表页后,每页大概展示20到30套房源。我需要的字段一部分在列表页就能拿到,比如总价、单价、户型、面积、小区名;另一部分像楼层、朝向、装修、建筑年代,则要进详情页才能取全。
所以爬虫分了两个阶段。第一阶段抓列表页,把所有房源的基础字段和详情页链接解析出来,存到数据库的house_list表。第二阶段遍历这些链接,逐个进入详情页补齐剩余字段。两阶段分开跑的另一个好处是:如果详情页抓取中途被封,只需要从列表表里找出还没抓完的链接断点续跑,不用从头再来。
列表页解析的代码大致长这样,我用的是非常直白的定位方式:
import requests import time from bs4 import BeautifulSoup HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } def parse_list_page(html): soup = BeautifulSoup(html, "html.parser") items = [] for li in soup.select("li.house-item"): item = {} title_tag = li.select_one(".title a") item["title"] = title_tag.get_text(strip=True) if title_tag else None item["detail_url"] = title_tag.get("href") if title_tag else None room_info = li.select_one(".room") item["house_type"] = room_info.get_text(strip=True) if room_info else None area_info = li.select_one(".area") item["area"] = area_info.get_text(strip=True) if area_info else None total_tag = li.select_one(".total-price") item["total_price"] = total_tag.get_text(strip=True) if total_tag else None unit_tag = li.select_one(".unit-price") item["unit_price"] = unit_tag.get_text(strip=True) if unit_tag else None items.append(item) return items这段代码里,选择器是当时根据页面实际结构写的,你换到别的网站时一定要先用浏览器开发者工具确认目标元素对应的class或结构,直接照搬大概率取不到内容。用select_one而不是select是因为每个房源卡片里这些字段只有一个。
2.2 请求频率控制与反爬应对
写爬虫最怕的不是代码报错,而是请求发得太频繁触发网站的反爬机制。我在这里踩过一回:第一次全量抓详情页时没有做任何限速,开了个循环一口气猛跑,结果大概跑到两百多条的时候,返回的状态码开始大量出现403,顺手做了个计数,连续十条全是403,很明显是被识别成异常流量了。
后来的处理比较保守,核心原则是“慢就是快”:
- 每请求一个详情页,强制
time.sleep(1.5)到time.sleep(3)随机等待。 - 同一个页面失败时,不立刻重试,先等更长时间,比如第1次重试等5秒、第2次等10秒、第3次等20秒,逐次退避。
- requests的Session复用连接池,同时也把最初的Headers带全,减少被服务端校验的风险。
- 每天控制在几百条请求量以内,分批跑,不搞一次性全量。
def fetch_with_retry(url, max_retries=3): session = requests.Session() session.headers.update(HEADERS) for attempt in range(max_retries): try: resp = session.get(url, timeout=10) if resp.status_code == 200: return resp.text elif resp.status_code in (403, 429): wait_time = 5 * (attempt + 1) time.sleep(wait_time) except requests.RequestException: time.sleep(3 * (attempt + 1)) return None这个重试函数虽然是示例,但逻辑比较贴近实际项目——把403和429这类“请求太频繁”的响应单独拎出来,用渐进式等待时间处理。另外,我没有用任何代理节点,因为打乱了数据来源反而会让后续分析失去地域连贯性,而且合规风险也更高。这种个人学习级别的爬虫,靠降低频率和调整请求头就够了,根本不需要走高级对抗路线。
2.3 建表与去重设计
采集数据马上要落库,我用sqlite3建了两张表。house_raw保存每个详情页完整抓取结果,house_list保存列表页的基础数据。两表通过house_id关联。
CREATE TABLE IF NOT EXISTS house_raw ( house_id TEXT PRIMARY KEY, title TEXT, district TEXT, block TEXT, community TEXT, house_type TEXT, area TEXT, floor_info TEXT, orientation TEXT, decoration TEXT, year_info TEXT, total_price TEXT, unit_price TEXT, detail_url TEXT, fetched_at TEXT DEFAULT CURRENT_TIMESTAMP );house_id我直接取详情页链接里尾部的ID部分,因为它是网站层面的房源唯一标识。这是天然的主键,去重完全依赖它。如果一条数据已经存在,就用INSERT OR REPLACE覆盖,保证同一套房源的信息随抓取时间更新。
由于原始字段很多是文本,我在这个阶段并不急着转成数值。清洗是下一个步骤的事,不要采集的时候顺手做,万一转换逻辑写错,原始数据已经被污染了,想恢复就得重新抓。
3. 数据清洗与特征工程:模型精度的分水岭就在这里
3.1 字符串字段的规则化拆分
爬下来的原始数据有多脏,不亲眼看一遍根本想象不到。面积字段长这样:“89.5平”“建面89.5㎡”“106平米”;户型字段长这样:“3室2厅1卫”“2室1厅”“4室2厅2卫,带车位”。这些字符串给模型是没法用的,必须全部转成结构化数值。
我用的清洗规则很简单直接,按字段逐个处理。
面积字段用正则提取数字部分:
import re def parse_area(text): if not text: return None match = re.search(r"([\d.]+)\s*(平|㎡|平米|平方米)", text) return float(match.group(1)) if match else None户型字段拆成室、厅、卫三个数字:
def parse_house_type(text): if not text: return None, None, None nums = re.findall(r"\d+", text) if len(nums) >= 3: return int(nums[0]), int(nums[1]), int(nums[2]) return None, None, None楼层字段处理稍微复杂一些。原始数据是“低楼层/共18层”或“23层/共32层”这种格式,我把它拆成两个特征:一个是floor(所在楼层数值),一个是total_floors(建筑总层数)。如果“低楼层”这类描述没有具体楼层数字,就按低中高三档映射成0、1、2,再配合总层数使用。
def parse_floor_info(text): if not text: return None, None floors = re.findall(r"\d+", text) if len(floors) == 2: return int(floors[0]), int(floors[1]) level_map = {"低楼层": 0, "中楼层": 1, "高楼层": 2} for key, val in level_map.items(): if key in text: total = floors[-1] if floors else None return val, total return None, None有了楼层层数和所在楼层,我还可以构造一个更有解释力的特征:floor_ratio = floor / total_floors,也就是楼层相对高度。因为“10楼”是高层还是低层,取决于整栋楼有多高,这个相对值比绝对楼层数更有意义。我自己的建模结果里,这个特征的贡献度排名比朝向还要靠前。
3.2 数值字段的清洗与目标变量选择
总价、单价、面积这三个关键数值字段,原始数据里带着单位,我直接用相同思路统一解析。
真正需要说清楚的是目标变量究竟选哪一个。大多数讲房价预测的教程会直接拿总价当预测目标,但这里有个问题:总价和面积高度线性相关,模型很容易学到“面积越大总价越高”这条规律,预测误差虽然好看,实际参考意义有限。我更推荐预测单价。单价是控制面积影响之后的“单位价值”,它更直接反映区域、楼层、装修这些因素带来的溢价,也更能体现模型真正学到的市场逻辑。
最终建表时,我保留了total_price作为辅助字段,但建模目标明确用unit_price。
数据清洗阶段还要处理缺失值和异常值。我当时的处理原则可以列成一份规则表:
| 问题类型 | 处理方式 |
|---|---|
| 面积缺失或解析失败 | 删除该条记录,约占总样本的1% |
| 总价或单价缺失 | 删除该条记录,无法可靠推算 |
| 面积小于5平方米 | 删除,视为车位或商铺混入 |
| 单价明显偏离同板块均价3倍以上 | 人工抽查后删除,多为特殊产权房 |
| 朝向缺失 | 用“未知”类别填充,让模型自己判断,不主观乱填 |
这里要特别说明一下单价异常值的处理。我当时发现某板块有几套挂牌单价特别低的房源,点进去看才发现是“已购公房”,产权性质和普通商品房完全不同,没有电梯也没有小区环境。这种样本如果直接放进训练集,会严重干扰模型对正常商品房的判断。我最终把这些记录单独剔除,并在备注里标注原因,而不是简单当成离群点删了。
3.3 类别特征的编码策略
剩下的类别特征包括:所在区域、板块、朝向、装修情况。这些文本特征需要转成数值。我的做法分了两种:
- 高层级特征如“区域”“板块”,用LabelEncoder,因为它的取值可能有三四十个,用独热编码会让特征矩阵变得稀疏且维度爆炸。
- 低层级特征如“朝向”“装修”,用独热编码,因为取值通常就四五个,独热之后模型可解释性更好。
朝向的例子比较直观。中文朝向字段有“南”“南北”“东南”“西北”“东西”等组合。独热编码前,我先把“南北”拆成“南”和“北”两个布尔特征,而不是保留一个“南北”类别。这样一来,模型就能学到“朝南加分、朝北减分”这种更细的规律。
装修情况我映射成了有序数值:毛坯=0、简装=1、精装=2、豪华装修=3。装修是有明确优劣顺序的,用有序编码比独热更合理。
最后把所有特征合并成一张宽表。我用的特征列表是这个样子:
| 特征名 | 类型 | 含义 |
|---|---|---|
| area | 数值 | 建筑面积 |
| room_cnt | 数值 | 室的数量 |
| hall_cnt | 数值 | 厅的数量 |
| floor | 数值 | 所在楼层 |
| total_floors | 数值 | 建筑总层数 |
| floor_ratio | 数值 | 楼层相对高度 |
| building_age | 数值 | 当前年份减去建筑年代 |
| district_encoded | 类别编码 | 所在区域 |
| block_encoded | 类别编码 | 板块 |
| o_south | 0/1 | 是否朝南 |
| o_north | 0/1 | 是否朝北 |
| ... | 0/1 | 其他朝向标志 |
| decoration_level | 有序数值 | 装修程度 |
这张表的作用不只是喂给模型,它本身就是一份“房价影响因素”的体检报告。后面随机森林输出的特征重要性,就是基于这个宽表算出来的。
4. 随机森林建模:从默认参数到能解释市场规律的模型
4.1 随机森林处理房价问题的原理优势
随机森林这个名字听起来玄乎,本质上是一个“专家委员会预测机制”。它训练出几百棵决策树,每棵树在训练时只随机看到样本的一部分、特征的一部分,然后各自给出预测,最后把所有树的结果取平均作为最终预测值。
这个机制对房价数据有天然的适配性。想想看:如果让一个专家评估一套房子,他可能会因为个人偏好高估或者忽略某些因素。但让一百个有不同盲区的专家共同评估,然后取平均,结果通常比任何一个单独专家都稳。随机森林正是用这种“集体决策”来降低单棵决策树的预测方差,从而减少过拟合。
和线性回归相比,它的优势非常明显:
- 不需要预设特征和目标之间的函数形式。房价和面积、楼层的关系是复杂曲线,决策树能自动分段拟合。
- 能自动捕捉特征之间的交互。比如“面积大且位于高楼层”和“面积大但位于低楼层”在二手房市场的价值差异,线性模型很难表达,树模型天然处理得了。
- 对量纲不敏感,不需要做标准化。面积是平方米、年代是年份、区域是编码,直接进模型就行。
和XGBoost相比,它的优势在于调参压力小。个人项目阶段,随机森林只要把树的数量和深度调到合理范围,效果就已经相当好了,不必去和梯度提升那一套学习率、正则化权重纠缠。
4.2 训练脚本与评估框架
建模代码用pandas加载清洗好的宽表,划分训练集和测试集,然后训练随机森林回归模型:
import pandas as pd from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score import numpy as np df = pd.read_csv("house_features.csv") feature_cols = [ "area", "room_cnt", "hall_cnt", "floor", "total_floors", "floor_ratio", "building_age", "district_encoded", "block_encoded", "o_south", "o_north", "o_east", "o_west", "decoration_level" ] X = df[feature_cols] y = df["unit_price"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) model = RandomForestRegressor( n_estimators=300, max_depth=18, min_samples_leaf=2, n_jobs=-1, random_state=42 ) model.fit(X_train, y_train) y_pred = model.predict(X_test) mae = mean_absolute_error(y_test, y_pred) rmse = np.sqrt(mean_squared_error(y_test, y_pred)) r2 = r2_score(y_test, y_pred) print(f"MAE: {mae:.2f}元/平") print(f"RMSE: {rmse:.2f}元/平") print(f"R2: {r2:.4f}")关于测试集划分,我特别把random_state固定下来,这是为了保证每次跑出来的结果可复现。我在前期没有固定随机种子,导致两次训练结果差异很大,还以为代码写错了,后来才发现是数据划分每次都不同造成的。
我第一次跑默认参数的结果是:MAE在2300元/平左右,R2在0.85左右。这个成绩看着还行,但还达不到我的预期。后来发现两个问题:一是max_depth太深,树把很多训练集的噪声也学进去了;二是板块编码这个特征太强,有些板块样本量很少,模型几乎是死记硬背这些板块的价格,对新样本泛化能力差。
4.3 参数调优的完整过程
我调参没有直接用GridSearchCV跑全组合,因为数据量加上参数网格会让搜索时间很长。我是先手动控制变量,逐一观察每个参数的影响。
| 参数 | 试过的值 | 最终选择 | 调参依据 |
|---|---|---|---|
| n_estimators | 100, 200, 300, 500 | 300 | 超过300后MAE下降不明显,训练时间却涨了一截 |
| max_depth | None, 10, 15, 18, 20 | 18 | 太深过拟合,太浅欠拟合,18在测试集上表现最好 |
| min_samples_leaf | 1, 2, 3, 5 | 2 | 值为1时噪声太多,5时精度下降 |
| max_features | 0.3, 0.5, 0.7, 1.0 | 0.7 | 默认auto效果已在稳定区,0.7略有提升 |
调参建议不要追求极致精度。个人项目的核心是从模型结果里解读市场逻辑,而不是和谁比赛刷分数。模型训练稳定后,我把调参轮次控制在七八次以内,再多就是浪费时间了。
调完参后,最终测试集上的表现是:MAE约1850元/平,RMSE约2500元/平,R2约0.91。考虑到房价本身的波动区间很大,这个误差已经具有操作层面的参考价值——比如一套挂牌价两万五每平的房子,模型给两万三千多到两万六千多的区间,基本能反映真实行情。
4.4 特征重要性的解读
随机森林训练完成后,我用feature_importances_输出了特征排序:
importance = pd.Series( model.feature_importances_, index=feature_cols ).sort_values(ascending=False) print(importance)结果排序大致是:area、district_encoded、block_encoded、building_age、floor_ratio排在前五位,装修、朝向紧随其后。
这个排序本身就是一个很有价值的市场结论:面积和板块位置仍然是决定房价的最核心因素;房龄的重要性排在楼层之前,说明“次新小区”的溢价在目标市场中非常明显;朝向的重要性没有想象中高,可能是因为当地气候温和,人们对朝北的抗性没那么强。
如果只用线性回归,我很难得到这种程度的洞察。这也是我坚持在项目里使用随机森林的另一个原因——它不只是一个预测器,还是一个市场特征的探针。
5. 实测中的翻车现场与工具化改造心得
5.1 三个典型翻车现场
这个项目不是一次跑通的,中途踩过的坑大致分成三类,写出来大家少走弯路。
翻车一:请求速度过快触发限制
前面说过,第一次大规模抓详情页时连续收到403。当时我的第一反应是检查Headers是不是没带全,折腾了半个多小时才发现是频率问题。这个教训让我明白,遇到被限制时不要急着加什么伪装技术,先反思自己的请求间隔是不是不够。爬虫的礼貌性是底线,不是可选项。
翻车二:漏掉产权类型的清洗规则
某个板块的“已购公房”房源混进训练集之后,模型在那个板块的预测明显整体偏低。当时我盯着残差图看了很久,发现模型对普通商品房的预测普遍偏高,反过来推这时候才意识到是特殊产权房拉低了样本均值。清洗规则里加一条“产权类型”的判断后,模型的系统性偏差立刻消失了。
翻车三:特征泄漏让我虚惊一场
有一次我不小心把unit_price同时放进了特征列表和目标变量,结果R2飙到0.99,我一度以为自己做出了神级模型。后来检查特征重要性才发现,模型根本就是拿答案解题。这种看似“效果爆表”的模型,放到真实预测场景会立刻失效。每次建模前检查特征列表和目标变量是否有重合,应该成为肌肉记忆。
5.2 让项目从一次性脚本变成可复用工具
做完第一版之后,我发现整个流程如果要换城市、换数据源重新跑,还得改一堆脚本。于是我做了一次工具化改造,中心思想是“配置与代码分离”。
改造之后,爬虫的目标页面地址、请求间隔、最大重试次数、要抓的字段列表全部写进一个配置文件,清洗脚本不再写死任何业务规则——比如哪些板块属于哪个区、户型的最大值是多少——而是从配置表读取。训练脚本也改成从命令行接收参数,用来指定特征列表和模型超参数。
加上logging基础日志模块之后,每个环节的产出、报错、耗时都有记录,排查问题至少不用到处print了。这个改造花了一天时间,但后面换到另一个城市的数据重新跑时,只花了不到一个小时就完成了整个流程。
5.3 后续可以继续扩展的方向
这个项目做完虽然告一段落,但可扩展的空间还很大,我列几个自己考虑过、也建议读者去尝试的方向:
- 加入地理信息特征。比如小区到最近地铁站的距离、到市中心的直线距离,这类位置特征对房价的解释力很强,目前我只用了板块编码,颗粒度比较粗。
- 引入时间维度。把挂牌时间、成交周期作为特征,可以看到不同季节、不同市场周期下房价的波动规律,从“静态预测”升级成“趋势感知”。
- 换成多模型对比框架。在随机森林旁边加一个XGBoost、一个LightGBM,跑同一份数据,比较误差和特征重要性,可以更全面地理解模型差异。
- 做简单的Web展示层。用Flask或Streamlit把训练好的模型包成一个页面,输入户型、面积、板块,直接返回预测价格区间,这个系统就会从个人工具变成可以给朋友演示的小产品。
最后说点实际体会。整个项目做下来,我最大的收获不是那个预测模型,而是彻底理解了“数据从哪来、怎么处理、怎么变成决策依据”的完整链路。随机森林的预测值本身有误差,但特征重要性排序教会我看市场:面积和板块是硬通货,房龄和楼层是调节项,装修和朝向是加分项。如果你也想做类似的项目,我的建议是先跑通一个最小闭环——抓一百条数据、清洗干净、训练一个简单模型,再慢慢加功能和数据量。不要一上来就追求架构完美,先让数据流动起来,你才会知道真正的瓶颈在哪里。