☰
Python爬虫+机器学习实战:房天下二手房数据采集与房价预测全流程
2026/9/28 17:25:27 网站建设 项目流程

简介:这份源码包面向数据科学与机器学习入门者及高校实训学生,提供基于Python的房天下二手房爬虫与房价预测完整实现,可用于课程设计、毕业项目或数据分析练手。包内共93个文件,以42个Python脚本为核心,覆盖爬虫请求头伪装、数据清洗、数据理解、可视化分析与模型预测等环节;另有31张PNG图表、6个CSV数据文件、5个XML配置及Markdown文档、xlsx表格等辅助材料,压缩包约13.71MB,目录按功能模块划分,便于按流程阅读。项目源自2024年夏季小学期实训,从随机请求头爬取房源,到探索年份、面积、楼层、朝向等特征与总价关系,再到训练预测模型,形成一条可复用的分析链路。目前已有175人学习下载,适合想了解爬虫与房价预测如何衔接的读者参考借鉴。

1. 从房天下二手房页面到房价预测模型:一条能跑通的链路长什么样

很多人第一次做房价预测,卡住的地方不是模型,而是数据。网上能找到的波士顿房价预测数据集只有 506 条、13 个特征,跑出来的 R² 好看得离谱,但换成真实在售房源就完全不是一回事。房天下这类二手房平台上的数据,才是真正带业务噪声的原料:同一小区不同楼层差价几十万、挂牌价和成交价能差 15%、户型描述里混着"满五唯一""南北通透"这种非结构化文本。用 Python 把这条链路走通——requests 抓列表页、解析详情页、SQLAlchemy 落库、pandas 清洗、sklearn 建模——才算真正摸到房价预测的门槛。

这篇东西面向两类人:一类是刚学完 Python 语法、想找一个完整爬虫案例练手的入门者;另一类是想把爬虫数据接到预测模型上、但一直没跑通全流程的从业者。我会按"先立住原理、再动手复现"的顺序讲,重点放在参数怎么设、翻车点在哪、模型评估怎么不骗自己。整套方案用 requests + BeautifulSoup + SQLAlchemy + scikit-learn,不依赖任何需要额外授权的接口,本地就能跑。

2. 房天下二手房页面结构拆解与请求参数设计

2.1 列表页 URL 规律与分页参数

房天下二手房频道的城市列表页 URL 结构比较规整,常见形式是https://城市拼音.fang.com/house/i3{页码}/,其中i3是二手房列表的固定前缀,页码从 1 开始递增。比如北京是bj.fang.com,上海是sh.fang.com,成都cd.fang.com。这个规律不是官方文档写的,是抓包看出来的——打开浏览器开发者工具的 Network 面板,翻到第 2 页、第 3 页,对比 URL 就能确认。

真正需要留意的是筛选参数。房天下支持按区域、价格区间、户型、面积段筛选,这些参数拼在 URL 后面,形如?price=100-200表示总价 100 到 200 万。做房价预测时,我一般不加价格筛选,因为加了之后样本分布会被截断,模型学到的只是某个价格带内的规律,外推能力很差。区域筛选倒是可以加,但要注意每个区域的房源量差异很大,后期建模时得做分层采样。

请求头是第一个必须处理的地方。房天下对User-Agent有基础校验,空 UA 或者明显的脚本 UA 会直接返回 403。我一般准备一个 UA 池,随机轮换,同时带上Referer指向列表页本身,Accept-Language设成zh-CN,zh;q=0.9。Cookie 不是必须的,但带上一个从浏览器复制的匿名 Cookie 能显著降低被拦概率。

import requests import random import time UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 " "(KHTML, like Gecko) Version/17.0 Safari/605.1.15", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) " "Gecko/20100101 Firefox/121.0", ] def build_headers(city_referer): return { "User-Agent": random.choice(UA_POOL), "Referer": city_referer, "Accept-Language": "zh-CN,zh;q=0.9", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", } def fetch_list_page(city, page, timeout=10): url = f"https://{city}.fang.com/house/i3{page}/" headers = build_headers(f"https://{city}.fang.com/house/i31/") resp = requests.get(url, headers=headers, timeout=timeout) resp.encoding = "utf-8" # 房天下部分页面返回 gbk,需按实际调整 resp.raise_for_status() time.sleep(random.uniform(1.5, 3.5)) # 随机间隔,别用固定 sleep return resp.text

这段代码里三个参数最关键。timeout=10是防止某个请求卡死拖垮整个任务,实测房天下响应通常在 300ms 到 2s 之间,10s 足够。resp.encoding必须显式设置,requests 自动推断经常猜成 ISO-8859-1,导致中文全变乱码,这是新手最常见的翻车点。time.sleep用random.uniform而不是固定值,是因为固定间隔的请求节奏太规律,反而容易被识别成脚本。

2.2 详情页字段定位与解析策略

列表页只给出标题、总价、单价、小区名、户型、面积这些摘要信息,真正建模需要的楼层、朝向、装修、房龄、梯户比都在详情页。详情页 URL 在列表页的<a>标签里,形如https://{city}.fang.com/house/{房源ID}.html。

解析我优先用 BeautifulSoup 的lxml解析器,比html.parser快 3 到 5 倍。字段定位不要依赖 class 名,房天下的 class 名带随机后缀,今天叫price_num,明天可能叫price_num_abc。稳妥的做法是用文本特征定位:找包含"总价""单价""建筑面积"这些关键词的节点,再取相邻节点或父节点的文本。

from bs4 import BeautifulSoup import re def parse_detail(html): soup = BeautifulSoup(html, "lxml") data = {} # 总价:找包含"万"的文本节点 price_node = soup.find(string=re.compile(r"^\d+(\.\d+)?万$")) data["total_price"] = float(price_node.replace("万", "")) if price_node else None # 单价:找包含"元/平米"的文本 unit_node = soup.find(string=re.compile(r"\d+元/平米")) if unit_node: data["unit_price"] = int(re.search(r"(\d+)", unit_node).group(1)) else: data["unit_price"] = None # 户型:从标题里正则提取,如"3室2厅" title = soup.find("h1") if title: m = re.search(r"(\d+)室(\d+)厅", title.get_text()) data["rooms"] = int(m.group(1)) if m else None data["halls"] = int(m.group(2)) if m else None # 建筑面积 area_node = soup.find(string=re.compile(r"建筑面积.*?(\d+(\.\d+)?)平米")) data["area"] = float(re.search(r"(\d+(\.\d+)?)", area_node).group(1)) if area_node else None return data

这里有个血泪经验:soup.find(string=...)返回的是 NavigableString,不是 Tag,不能直接.get_text(),得先str()或者直接当字符串用。另外正则里的.*?非贪婪匹配在跨标签时经常失效,因为 BeautifulSoup 的 string 搜索是逐节点匹配的,跨节点文本得用soup.get_text()整体处理再正则。我一般先用get_text()拿到全文,再用正则批量提取,比逐节点找稳得多。

字段缺失是常态。有的房源不写楼层,有的不写朝向,这些缺失值不能简单填 0,得在清洗阶段单独处理。我的做法是:缺失率低于 5% 的字段用中位数填充,高于 30% 的直接丢弃该字段,介于两者之间的用"未知"作为独立类别。

3. 用 SQLAlchemy 落库:表结构设计与增量抓取

3.1 房源表字段设计与索引

抓下来的数据必须落库,否则重跑一次就丢一次。我用 SQLAlchemy 的 ORM 定义表结构,字段分三类:标识字段(房源 ID、城市、抓取时间)、特征字段(面积、户型、楼层、朝向、装修、房龄)、标签字段(总价、单价)。房源 ID 做主键,天然去重。

from sqlalchemy import create_engine, Column, Integer, Float, String, DateTime, Index from sqlalchemy.orm import declarative_base, sessionmaker from datetime import datetime Base = declarative_base() class House(Base): __tablename__ = "house" id = Column(String(32), primary_key=True) # 房源ID city = Column(String(16), nullable=False) community = Column(String(64)) # 小区名 area = Column(Float) # 建筑面积 rooms = Column(Integer) halls = Column(Integer) floor = Column(String(32)) # 楼层描述 orientation = Column(String(16)) # 朝向 decoration = Column(String(16)) # 装修 age = Column(Integer) # 房龄 total_price = Column(Float) # 总价(万) unit_price = Column(Integer) # 单价(元/平) crawl_time = Column(DateTime, default=datetime.now) __table_args__ = ( Index("idx_city_price", "city", "total_price"), Index("idx_community", "community"), ) engine = create_engine("sqlite:///fang.db", echo=False) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine)

索引这块,idx_city_price是为了按城市和价格区间查询时走索引,idx_community是为了按小区聚合分析。SQLite 做本地开发够用,数据量上到百万级建议换 PostgreSQL,create_engine的 URL 改成postgresql://user:pass@localhost/fang即可,ORM 层不用动。

3.2 增量抓取与去重逻辑

全量重抓既慢又容易被封,增量抓取是必须的。我的做法是:每次抓列表页时,先查库里该城市已有的房源 ID 集合,解析列表页时跳过已存在的 ID,只抓新出现的详情页。这样第二次跑的时候,如果首页 30 条里有 25 条是旧的,实际只发 5 个详情请求,效率提升非常明显。

def save_houses(session, houses): existing = {row[0] for row in session.query(House.id).all()} new_count = 0 for h in houses: if h["id"] in existing: continue session.add(House(**h)) new_count += 1 session.commit() return new_count

session.query(House.id).all()在数据量大时会一次性拉回所有 ID,内存吃不消。数据超过 10 万条时改成session.query(House.id).filter(House.city == city).all(),按城市分批查。另外session.commit()不要每条都提交,攒 50 到 100 条提交一次,事务开销能降一个数量级。

提示:SQLite 在并发写入时会锁库,如果你开了多线程抓取,写入必须串行化,或者换 PostgreSQL。我一般抓取用多线程、写库用单线程队列,两边解耦。

4. 从原始数据到建模特征:清洗、编码与特征工程

4.1 缺失值、异常值与文本字段处理

原始数据里最脏的是三类:价格异常、面积异常、文本字段不统一。价格异常比如总价 1 万或者 9999 万,明显是测试数据或者录入错误,用 IQR 方法剔除:计算总价的 Q1 和 Q3,把超出Q1 - 1.5*IQR和Q3 + 1.5*IQR的记录删掉。面积同理,小于 10 平或大于 1000 平的直接丢。

文本字段里,朝向有"南""南北""东南""南北通透"等十几种写法,装修有"精装""简装""毛坯""豪华装修"。这些不能直接 one-hot,类别太多会导致维度爆炸。我的做法是先归并:朝向归成"南向""北向""东西向""南北通透""其他"五类,装修归成"毛坯""简装""精装"三类。归并规则用字典映射,写死在代码里,比让模型自己学靠谱。

import pandas as pd import numpy as np def clean(df): # 价格异常剔除 q1, q3 = df["total_price"].quantile([0.25, 0.75]) iqr = q3 - q1 df = df[(df["total_price"] >= q1 - 1.5*iqr) & (df["total_price"] <= q3 + 1.5*iqr)] df = df[(df["area"] >= 10) & (df["area"] <= 1000)] # 朝向归并 orient_map = { "南": "南向", "南北": "南北通透", "东南": "南向", "西南": "南向", "东": "东西向", "西": "东西向", "北": "北向", } df["orientation"] = df["orientation"].map(orient_map).fillna("其他") # 装修归并 deco_map = {"精装": "精装", "简装": "简装", "毛坯": "毛坯", "豪华装修": "精装", "中装": "简装"} df["decoration"] = df["decoration"].map(deco_map).fillna("简装") # 房龄缺失用中位数填 df["age"] = df["age"].fillna(df["age"].median()) return df

quantile([0.25, 0.75])返回的是 Series,索引是 0.25 和 0.75,解包顺序别搞反。map之后没匹配上的会变 NaN,必须fillna,否则后面建模会报错。这几步看着简单,但实际数据里"南北通透"和"南北"是两回事,前者通常指板楼通透户型,后者可能只是朝向描述,归并时得看业务含义,不能纯按字面。

4.2 类别编码与数值特征标准化

归并后的类别字段用 one-hot 编码,pd.get_dummies一行搞定。数值字段里,面积、房龄、房间数的量纲差异大,线性模型必须标准化,树模型(随机森林、XGBoost)不需要。我一般两套都准备,用ColumnTransformer把预处理和模型串成 Pipeline,避免训练和预测时预处理不一致。

from sklearn.compose import ColumnTransformer from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.pipeline import Pipeline from sklearn.ensemble import RandomForestRegressor cat_cols = ["orientation", "decoration", "city"] num_cols = ["area", "rooms", "halls", "age"] preprocessor = ColumnTransformer([ ("cat", OneHotEncoder(handle_unknown="ignore"), cat_cols), ("num", StandardScaler(), num_cols), ]) pipe = Pipeline([ ("prep", preprocessor), ("model", RandomForestRegressor(n_estimators=200, max_depth=12, random_state=42)), ])

handle_unknown="ignore"是关键参数。预测时如果遇到训练集没见过的朝向类别,不加这个参数会直接报错,加了之后该类别全编码为 0,模型仍能给出预测。n_estimators=200是经验值,再往上提升有限但训练时间线性增长;max_depth=12是防止过拟合,房价数据特征维度不高,树太深容易记住噪声。

5. 房价预测模型训练与评估:别被 R² 骗了

5.1 训练集测试集划分与交叉验证

房价数据有时间维度,随机划分训练测试集会导致数据泄漏——比如用 2024 年 3 月的房源训练、用 2024 年 2 月的房源测试,模型"见过未来"。正确做法是按抓取时间排序,前 80% 做训练、后 20% 做测试。如果数据量够,用TimeSeriesSplit做交叉验证更稳。

from sklearn.model_selection import train_test_split, TimeSeriesSplit, cross_val_score df = df.sort_values("crawl_time") X = df[cat_cols + num_cols] y = df["total_price"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, shuffle=False # 关键:不打乱 ) pipe.fit(X_train, y_train) print("测试集 R²:", pipe.score(X_test, y_test)) tscv = TimeSeriesSplit(n_splits=5) scores = cross_val_score(pipe, X, y, cv=tscv, scoring="r2") print("时序交叉验证 R²:", scores.mean(), scores.std())

shuffle=False是这条链路里最容易被忽略的参数。默认shuffle=True,随机打乱后训练集里混进了未来数据,R² 能虚高 0.1 以上。TimeSeriesSplit的n_splits=5表示把数据切成 5 折,每折用前面的数据训练、后面的数据验证,更贴近真实预测场景。

5.2 特征重要性与误差分析

R² 只能告诉你模型整体拟合得怎么样,不能告诉你哪里错了。我习惯看两个东西:特征重要性和残差分布。随机森林的feature_importances_能直接给出每个特征的重要性,通常面积、单价(如果没泄漏的话)、小区均价排前三。残差分布如果集中在 0 附近说明模型无偏,如果残差随预测值增大而增大,说明存在异方差,得对目标做对数变换。

import numpy as np import matplotlib.pyplot as plt # 特征重要性 feat_names = pipe.named_steps["prep"].get_feature_names_out() importances = pipe.named_steps["model"].feature_importances_ top_idx = np.argsort(importances)[-10:] for i in top_idx: print(f"{feat_names[i]}: {importances[i]:.4f}") # 残差分析 y_pred = pipe.predict(X_test) residuals = y_test - y_pred print("残差均值:", residuals.mean()) print("残差标准差:", residuals.std())

get_feature_names_out()返回的是预处理后的特征名,one-hot 后的名字形如cat__orientation_南向,能直接对应回原始字段。残差均值接近 0 说明模型没有系统性高估或低估;标准差反映预测波动,如果标准差是总价均值的 20% 以上,说明模型精度还不够,得加特征或者换模型。

注意:如果特征里混进了"单价"字段,R² 会高得离谱,因为总价 = 单价 × 面积,这是典型的目标泄漏。建模前务必检查特征列表,把和标签有直接计算关系的字段删掉。

6. 避坑与排查:这条链路上最容易翻车的 5 个地方

现象一:请求返回 403,换 UA 也没用。原因通常是请求频率太高,触发了 IP 级别的限流。解决方法是把time.sleep的间隔拉长到 3 到 8 秒,并且每抓 50 页换一次 UA 和 Referer。如果还是不行,说明该 IP 被临时封了,等 30 分钟再试,别硬刚。

现象二:中文全是乱码,resp.text出来是问号。原因是 requests 自动推断编码错误,房天下部分页面返回 GBK 但没在 header 里声明。解决方法是手动resp.encoding = "gbk"或者用resp.apparent_encoding检测。我一般先试 utf-8,出现乱码再切 gbk,两个都不行就用chardet检测。

现象三:SQLAlchemy 写入报UNIQUE constraint failed。原因是主键重复,通常是同一房源被抓了两次。解决方法是在save_houses里先查已有 ID 再插入,或者用session.merge()代替session.add(),merge 会自动处理主键冲突,存在则更新、不存在则插入。

现象四:模型 R² 高达 0.98,但预测新数据完全不准。原因是数据泄漏,最常见的是特征里混了单价,或者训练测试集随机划分导致未来数据泄漏。解决方法是检查特征列表,删掉和标签有计算关系的字段,并且用shuffle=False或TimeSeriesSplit划分。

现象五:预测结果全是负数或者大得离谱。原因是标准化和反标准化不一致,或者 one-hot 编码时训练集和预测集类别对不上。解决方法是把预处理和模型串成 Pipeline,预测时直接调pipe.predict(),不要手动做预处理。如果必须手动,确保StandardScaler用的是训练集拟合的mean_和scale_。

7. 让模型真正可用:把预测封装成可复用的推理脚本

模型训练完只是半成品,真正要用起来得封装成推理脚本:输入一个房源的原始字段,输出预测总价。我一般用joblib把整个 Pipeline 序列化,推理时加载回来,避免每次重新训练。

import joblib # 训练完保存 joblib.dump(pipe, "house_price_pipe.pkl") # 推理时加载 pipe = joblib.load("house_price_pipe.pkl") def predict_price(area, rooms, halls, age, orientation, decoration, city): sample = pd.DataFrame([{ "area": area, "rooms": rooms, "halls": halls, "age": age, "orientation": orientation, "decoration": decoration, "city": city, }]) return pipe.predict(sample)[0] # 示例:预测一套成都 89 平、3室2厅、房龄 5 年、南北通透、精装的房子 price = predict_price(89, 3, 2, 5, "南北通透", "精装", "cd") print(f"预测总价: {price:.1f} 万")

joblib.dump保存的是整个 Pipeline,包括预处理器的拟合参数和模型的树结构,文件通常几 MB 到几十 MB。推理时输入必须是 DataFrame 而不是 dict,因为ColumnTransformer按列名取数据,传 dict 会报 KeyError。字段顺序不重要,但字段名必须和训练时完全一致,少一个字段或者多一个字段都会报错。

验证推理脚本是否可靠,我有个笨办法但很管用:从测试集里随机抽 10 条,用推理脚本重新预测一遍,和pipe.predict(X_test)的结果对比,如果完全一致说明封装没问题。如果对不上,八成是字段名或者字段类型在封装时变了。

最后说个我自己的习惯。每次跑完一轮抓取和训练,我会把当次的 R²、残差标准差、特征重要性 Top5 记到一个experiment_log.md里,下次调参或者加特征时翻出来对比。房价数据受政策、季节影响大,同一个模型隔三个月再跑,R² 掉 0.05 到 0.1 是常事,有历史记录才知道是模型退化了还是数据分布变了。这套链路我前后迭代了七八版,最大的教训就是别迷信单次 R²,多看残差、多看特征重要性、多留实验记录,模型才真的能用。希望帮到你。

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

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

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

立即咨询