Python二手房房价预测实战:从爬虫到机器学习模型全流程 简介这份资源是一套面向毕业设计、期末大作业与课程设计的完整实战项目围绕二手房房价预测展开从数据采集到机器学习建模全流程覆盖适合具备Python基础、希望快速完成高分项目的学生与初学者。压缩包共26个文件约1.28MB包含15个py脚本、1个ipynb笔记本、2个csv数据集、1个html报告及若干jpg可视化图表另有read、md说明与gitignore配置代码注释详尽新手也能看懂。项目以链家、安居客等二手房数据为对象涵盖爬虫抓取、数据清洗、特征工程与模型训练预测等环节并配有可视化图表与完整分析报告下载后简单部署即可运行。目前已有416人学习下载作者自述为手打98分项目导师认可度高。读者可直接获得一套结构清晰、可复用的房价预测分析方案便于对照修改、快速落地到自己的课题中。1. 从链家到模型一套二手房房价预测系统到底长什么样很多人第一次听到「基于python实现二手房房价预测分析 从爬虫到机器学习预测源码」脑子里浮现的是两段互不相干的代码一段爬数据一段跑模型。真做过一遍就知道这两段之间隔着一整条数据流水线任何一环偷懒最后模型给出的价格都是玄学。我在实际项目里踩过的最大坑不是模型调参而是爬下来的房源里混着车位、商铺、公寓特征全乱训练出来的模型对住宅报价离谱到没法看。这套方案要解决的事情很具体给定一个城市把二手房挂牌信息抓下来清洗成结构化表格再用机器学习模型拟合出「面积、户型、楼层、地段」这些特征和总价之间的关系最后能对一条新房源给出估价并告诉你哪些特征在推高价格。它适合两类人一类是想把 python爬虫、机器学习 串起来做完整项目的入门者另一类是手里有房源数据、想快速搭个估价原型的从业者。下面按「抓什么 → 怎么洗 → 怎么建模 → 怎么避坑」的顺序讲透源码结构可以直接照着搭。2. 爬虫层把二手房挂牌页拆成结构化字段2.1 先定字段再写爬虫别反过来新手最容易犯的错是先写 requests 请求抓到什么算什么最后发现缺字段又回头改。正确顺序是先确定建模需要哪些列再倒推页面里哪些节点能取到。二手房估价常用的字段我一般固定成这几类字段类型来源建模用途总价数值万元列表页价格块预测目标单价数值元/平列表页或详情页辅助校验建筑面积数值平米详情页户型信息核心特征户型字符串如 3室2厅详情页拆成室/厅两个数值楼层字符串低/中/高详情页序数编码建成年代数值详情页房龄特征小区名字符串列表页后续做地段聚合区域/板块字符串列表页类别特征字段定完爬虫的目标就清晰了列表页负责翻页和拿详情链接详情页负责补齐面积、年代这些细粒度信息。列表页和详情页分开抓是为了让翻页逻辑和字段解析逻辑解耦改一个不影响另一个。2.2 requests xpath 抓列表页的最小可跑代码下面这段是列表页抓取的核心负责翻页、拿详情链接和列表页就能看到的字段。用 requests 发请求lxml 的 xpath 解析比 BeautifulSoup 在批量场景下快一些。import requests from lxml import etree import time import random HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 } def fetch_list_page(city_url, page): 抓取某一页列表返回该页所有房源的详情链接和列表页字段 url f{city_url}/pg{page}/ resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 html etree.HTML(resp.text) items [] # 每条房源卡片class 名以实际页面为准这里用占位说明结构 for card in html.xpath(//div[contains(class,sellListContent)]/li): link card.xpath(.//a/href) title card.xpath(.//a/text()) total card.xpath(.//div[classtotalPrice]/span/text()) unit card.xpath(.//div[classunitPrice]/span/text()) if not link: continue items.append({ detail_url: link[0], title: title[0].strip() if title else , total_price: total[0] if total else None, unit_price: unit[0] if unit else None, }) # 随机休眠降低被封概率 time.sleep(random.uniform(1.5, 3.5)) return items逻辑说明fetch_list_page只做一件事——把一页里的房源卡片遍历出来抽出详情链接和列表页字段。参数上city_url是城市入口地址page是页码两者拼成实际请求地址。timeout10防止某个请求卡死拖垮整个循环。random.uniform(1.5, 3.5)的随机休眠是血泪经验固定间隔的请求节奏太规律很容易触发风控。参数怎么改如果目标站点列表页字段 class 名不同改 xpath 里的contains(class,...)部分即可不要动整体结构。翻页上限建议先手动翻到最后一页确认别用 while True 硬跑。2.3 详情页补齐字段与断点续爬列表页拿到的字段不够建模面积、户型、年代都在详情页。详情页抓取要解决两个问题一是字段解析二是中断后不从头再来。import csv import os def parse_detail(html_text): 解析详情页返回面积、户型、楼层、年代等字段 html etree.HTML(html_text) def pick(xp): r html.xpath(xp) return r[0].strip() if r else None return { area: pick(//div[classhouseInfo]/text()), # 需二次正则提取数字 layout: pick(//div[classroom]/text()), floor: pick(//div[classfloor]/text()), year: pick(//div[classsubInfo]/text()), } def crawl_details(items, out_pathhouses.csv, done_pathdone.txt): 带断点续爬的详情抓取 done set() if os.path.exists(done_path): with open(done_path, encodingutf-8) as f: done set(line.strip() for line in f) write_header not os.path.exists(out_path) with open(out_path, a, newline, encodingutf-8-sig) as fout, \ open(done_path, a, encodingutf-8) as fdone: writer csv.DictWriter(fout, fieldnames[ detail_url, title, total_price, unit_price, area, layout, floor, year]) if write_header: writer.writeheader() for it in items: if it[detail_url] in done: continue try: resp requests.get(it[detail_url], headersHEADERS, timeout10) resp.encoding utf-8 it.update(parse_detail(resp.text)) writer.writerow(it) fdone.write(it[detail_url] \n) fout.flush() except Exception as e: print(fail, it[detail_url], e) time.sleep(random.uniform(1.5, 3.5))逻辑说明done.txt记录已抓链接重跑时自动跳过这是长任务必备的后悔药。encodingutf-8-sig是为了 Excel 打开 CSV 不乱码。fout.flush()保证每写一行就落盘进程被杀也不丢已抓数据。参数说明out_path和done_path建议按城市分开命名比如sh_houses.csv避免多城市数据混在一起。parse_detail里返回的area是原始文本后面清洗阶段再用正则抽数字解析和清洗分两步做方便单独调试。3. 数据清洗把脏字符串变成模型能吃的数值3.1 正则抽取与异常值处理爬下来的area字段长这样「89.5平米」「120.34㎡」「暂无数据」。total_price是「320万」。这些都得转成 float。清洗阶段我一般写一个统一的转换函数把「万」「平米」「㎡」这些单位剥掉转不了的置为 NaN。import re import pandas as pd import numpy as np def to_float(text, unit_pattern): 从字符串里抽数字抽不到返回 np.nan if not isinstance(text, str): return np.nan text re.sub(unit_pattern, , text) m re.search(r\d\.?\d*, text) return float(m.group()) if m else np.nan df pd.read_csv(sh_houses.csv) df[area] df[area].apply(lambda x: to_float(x, r[平㎡米])) df[total_price] df[total_price].apply(lambda x: to_float(x, r[万])) df[unit_price] df[unit_price].apply(lambda x: to_float(x, r[元/平])) # 单价和总价/面积对不上的说明抓错了直接丢 df[calc_unit] df[total_price] * 10000 / df[area] df df[(df[calc_unit] - df[unit_price]).abs() / df[unit_price] 0.1]逻辑说明to_float用正则统一处理单位避免为每种格式写一个分支。最后一步用「总价×10000÷面积」反算单价和页面单价对比偏差超过 10% 的整行丢掉——这类行通常是抓到了车位或商铺留着会污染模型。参数说明偏差阈值 0.1 是我在几个城市数据上试出来的经验值太松过滤不掉异常太严会误杀正常房源。如果数据量小可以放宽到 0.15。3.2 户型、楼层、地段的特征工程layout是「3室2厅」得拆成rooms和halls两个数值列。floor是「中楼层」转成序数。地段用区域做 one-hot 或目标编码。# 户型拆分 df[rooms] df[layout].str.extract(r(\d)室).astype(float) df[halls] df[layout].str.extract(r(\d)厅).astype(float) # 楼层序数 floor_map {低楼层: 0, 中楼层: 1, 高楼层: 2} df[floor_level] df[floor].map(floor_map) # 房龄 df[age] 2024 - df[year] # 区域类别编码用目标编码避免维度爆炸 region_mean df.groupby(region)[total_price].mean() df[region_encoded] df[region].map(region_mean) # 丢掉缺失关键特征的样本 df df.dropna(subset[area, total_price, rooms, age])逻辑说明户型用str.extract直接抽数字比 split 稳。楼层映射成 0/1/2 是因为楼层高低对价格的影响是单调的序数编码比 one-hot 更省维度。区域用目标编码该区域均价而不是 one-hot是因为城市区域动辄上百个one-hot 会让特征维度爆炸树模型虽然能扛但训练慢且容易过拟合。参数说明age里的 2024 是当前年份实际用的时候换成抓取年份。目标编码有个坑——必须用训练集的均值映射到测试集不能全量算否则数据泄漏这点在第 5 章会细说。4. 建模从线性回归到梯度提升的选型与调参4.1 为什么二手房估价首选树模型房价和特征的关系高度非线性面积和总价不是简单线性地段的影响是阶跃式的房龄超过某个阈值后贬值速度变缓。线性回归在这种场景下欠拟合严重我早期用线性回归跑R² 只有 0.6 出头换成梯度提升直接到 0.85 以上。常见做法是先用随机森林或梯度提升树GBDT打底再看要不要上 XGBoost、LightGBM。数据量在几万条这个级别LightGBM 训练快、内存省是我一般会选的。下面用 sklearn 的GradientBoostingRegressor做基线依赖少跑通再换。from sklearn.model_selection import train_test_split from sklearn.ensemble import GradientBoostingRegressor from sklearn.metrics import mean_absolute_error, r2_score features [area, rooms, halls, floor_level, age, region_encoded] X df[features] y df[total_price] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42) model GradientBoostingRegressor( n_estimators300, # 树的数量 learning_rate0.05, # 学习率 max_depth4, # 单棵树深度 subsample0.8, # 行采样比例 random_state42) model.fit(X_train, y_train) pred model.predict(X_test) print(MAE:, mean_absolute_error(y_test, pred)) print(R2:, r2_score(y_test, pred))逻辑说明train_test_split固定random_state保证每次划分一致方便对比调参效果。GradientBoostingRegressor的四个参数是核心n_estimators和learning_rate是一对学习率小就要更多树max_depth控制单棵树复杂度房价数据一般 3 到 5 就够再深容易过拟合subsample小于 1 引入随机性能提升泛化。参数说明MAE 是平均绝对误差单位是万元比 R² 更直观——MAE 15 意味着平均估错 15 万。R² 看整体拟合度0.85 以上算可用。调参顺序建议先定max_depth再调n_estimators和learning_rate最后动subsample。4.2 特征重要性与估价解释模型跑通后得知道哪些特征在起作用否则没法向业务解释。import pandas as pd imp pd.Series(model.feature_importances_, indexfeatures).sort_values(ascendingFalse) print(imp)逻辑说明feature_importances_给出每个特征对降低不纯度的贡献占比。二手房场景里面积和地段编码通常排前两位房龄次之。如果发现某个特征重要性异常高要警惕数据泄漏比如把unit_price误当特征喂进去了——单价和总价强相关等于提前告诉模型答案。参数说明重要性只反映「这个特征在模型里被用了多少」不代表因果。地段编码重要性高是因为它本身是均价的映射属于合理但需注意的循环特征正式项目里建议用外部数据如地铁距离、学区替代。5. 避坑与排查那些让模型翻车的细节5.1 数据泄漏目标编码用错地方现象离线 R² 高达 0.95上线后估价偏差巨大。原因目标编码用了全量数据的区域均价测试集的信息泄漏进了训练。解决目标编码必须在训练集上算均值再映射到测试集或者用 K 折目标编码每折用其他折的均值。5.2 重复房源同一套房抓了多次现象模型对某些户型预测异常准实际是同一房源在训练集和测试集都出现。原因列表页翻页时排序变化导致重复抓取或详情页链接带参数不同但指向同一房源。解决按detail_url去重不够要按「小区名面积户型」组合去重抓取阶段就用 set 记录指纹。5.3 单位不统一总价混了「万」和「元」现象部分房源总价是 320部分是 3200000模型被拉偏。原因不同页面价格单位不同清洗时没统一。解决清洗阶段强制统一到万元用calc_unit反算校验偏差大的行直接丢。5.4 时间穿越用了未来才有的信息现象模型在历史数据上表现好预测新挂牌房源时偏差大。原因训练数据里混入了抓取时间晚于挂牌时间的房源或者用了「成交价」这类预测时拿不到的特征。解决只用挂牌时点可见的特征成交相关字段一律不进特征集。5.5 类别特征维度爆炸现象加了小区名做 one-hot 后特征维度上万训练极慢且过拟合。原因小区数量太多one-hot 稀疏。解决小区名改用目标编码或频次编码低频小区合并成「其他」。6. 让估价更可信交叉验证与残差检查的实操模型跑出一个 R² 就收工是新手和熟手的分水岭。我现在的习惯是任何房价模型上线前必须过两道检查K 折交叉验证和残差分布。K 折交叉验证比单次 train_test_split 稳能看出模型在不同数据子集上的波动。用cross_val_score跑 5 折看 R² 的均值和标准差标准差大说明模型不稳定可能是某些区域样本太少。from sklearn.model_selection import cross_val_score scores cross_val_score(model, X, y, cv5, scoringr2) print(R2 mean:, scores.mean(), std:, scores.std())残差检查更关键。把测试集的预测值和真实值做差画残差分布或者按价格区间分组看 MAE。如果发现高价房源残差系统性偏大说明模型对高价段拟合不足可能是高价样本太少或者缺少区分高价的特征如学区、景观。residual y_test - pred # 按真实价格分箱看残差 bins pd.cut(y_test, bins[0, 200, 400, 600, 10000]) print(pd.DataFrame({residual: residual, bin: bins}).groupby(bin)[residual].agg([mean, std]))逻辑说明残差按价格分箱后如果某箱的 mean 明显偏离 0说明模型在该价格段有系统偏差。std 大说明该段预测不稳定。这两个指标比单一 R² 更能暴露问题。参数说明分箱边界按目标城市的价格分布定一线城市可以设 300/600/1000二三线设 100/200/400。分箱后每箱样本太少少于 30就别看了统计意义不够。一个具体技巧如果高价段残差偏大别急着加特征先检查是不是高价房源本身异质性太强——同样是 800 万老破小学区和次新大平层是完全不同的定价逻辑。这时候按价格段分别建模或者加一个「是否学区房」的标记往往比调参有效。我自己踩过最深的坑是早期太迷信 R²模型在测试集上 0.9 就以为成了结果拿几条真实房源一试全偏。后来养成习惯任何模型跑完先手动挑 10 条不同价位、不同区域的房源把预测值和挂牌价并排看一遍比任何指标都直观。这个习惯帮我省了无数次返工。希望帮到你。本文还有配套的精品资源点击获取