Python爬虫+机器学习实战:房天下二手房数据采集与房价预测全流程
简介这份源码包面向数据科学与机器学习入门者及高校实训学生提供基于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 后面形如?price100-200表示总价 100 到 200 万。做房价预测时我一般不加价格筛选因为加了之后样本分布会被截断模型学到的只是某个价格带内的规律外推能力很差。区域筛选倒是可以加但要注意每个区域的房源量差异很大后期建模时得做分层采样。请求头是第一个必须处理的地方。房天下对User-Agent有基础校验空 UA 或者明显的脚本 UA 会直接返回 403。我一般准备一个 UA 池随机轮换同时带上Referer指向列表页本身Accept-Language设成zh-CN,zh;q0.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;q0.9, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, } def fetch_list_page(city, page, timeout10): url fhttps://{city}.fang.com/house/i3{page}/ headers build_headers(fhttps://{city}.fang.com/house/i31/) resp requests.get(url, headersheaders, timeouttimeout) resp.encoding utf-8 # 房天下部分页面返回 gbk需按实际调整 resp.raise_for_status() time.sleep(random.uniform(1.5, 3.5)) # 随机间隔别用固定 sleep return resp.text这段代码里三个参数最关键。timeout10是防止某个请求卡死拖垮整个任务实测房天下响应通常在 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(stringre.compile(r^\d(\.\d)?万$)) data[total_price] float(price_node.replace(万, )) if price_node else None # 单价找包含元/平米的文本 unit_node soup.find(stringre.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(stringre.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_keyTrue) # 房源ID city Column(String(16), nullableFalse) 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, defaultdatetime.now) __table_args__ ( Index(idx_city_price, city, total_price), Index(idx_community, community), ) engine create_engine(sqlite:///fang.db, echoFalse) Base.metadata.create_all(engine) Session sessionmaker(bindengine)索引这块idx_city_price是为了按城市和价格区间查询时走索引idx_community是为了按小区聚合分析。SQLite 做本地开发够用数据量上到百万级建议换 PostgreSQLcreate_engine的 URL 改成postgresql://user:passlocalhost/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_countsession.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 dfquantile([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_unknownignore), cat_cols), (num, StandardScaler(), num_cols), ]) pipe Pipeline([ (prep, preprocessor), (model, RandomForestRegressor(n_estimators200, max_depth12, random_state42)), ])handle_unknownignore是关键参数。预测时如果遇到训练集没见过的朝向类别不加这个参数会直接报错加了之后该类别全编码为 0模型仍能给出预测。n_estimators200是经验值再往上提升有限但训练时间线性增长max_depth12是防止过拟合房价数据特征维度不高树太深容易记住噪声。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_size0.2, shuffleFalse # 关键不打乱 ) pipe.fit(X_train, y_train) print(测试集 R²:, pipe.score(X_test, y_test)) tscv TimeSeriesSplit(n_splits5) scores cross_val_score(pipe, X, y, cvtscv, scoringr2) print(时序交叉验证 R²:, scores.mean(), scores.std())shuffleFalse是这条链路里最容易被忽略的参数。默认shuffleTrue随机打乱后训练集里混进了未来数据R² 能虚高 0.1 以上。TimeSeriesSplit的n_splits5表示把数据切成 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但预测新数据完全不准。原因是数据泄漏最常见的是特征里混了单价或者训练测试集随机划分导致未来数据泄漏。解决方法是检查特征列表删掉和标签有计算关系的字段并且用shuffleFalse或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²多看残差、多看特征重要性、多留实验记录模型才真的能用。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →