基于Python的链家二手房房价分析与预测实战
简介一份面向计算机专业毕业设计及数据分析实践的二手房房价分析与预测项目源码包经导师指导并获评98分可直接作为毕设、课程设计或期末大作业使用。项目以链家二手房数据为切入覆盖数据抓取、清洗、探索分析与房价预测建模的完整流程压缩包共17个文件包含12个CSV数据文件、3个Jupyter Notebook分析脚本、1个Python脚本及1份Word说明文档包体约6.23MB结构清晰便于按步骤复现。目前已有336人学习下载。读者可获得可运行的完整源码、爬虫与建模思路、可视化分析过程及说明文档适合需要快速搭建毕业设计框架或提升数据分析实战能力的学习者。1. 二手房房价分析与预测不是算法难是数据链路长一套能跑通的 Python 源码每到毕业季总有人卡在同一道坎上Python 二手房房价数据分析与预测题目定了链家数据却爬不全抓回来又是一堆文本和混用单位模型还没建洗数据先耗掉一周。这套源码把链家爬虫、数据清洗、可视化分析和房价预测建模串成可直接跑的完整链路Notebook 和 sol.py 都有适合做毕业设计、课程设计或期末大作业的计算机相关专业学生。它不靠高级模型取胜胜在每一步都有能落地的代码照着跑就能讲清数据来源、处理逻辑和预测结果答辩站得住。2. 链家二手房数据怎么爬列表页解析、请求间隔与断点落盘拿到压缩包先把目录结构过一遍spider 目录和「链家网数据爬取.ipynb」负责数据采集data 目录放爬回来的数据sol.ipynb 是主分析文件sol.py 是脚本版README.docx 写了运行顺序。我一般建议按 README 的顺序先跑爬虫再用 sol.ipynb 做分析这样数据和代码对得上答辩时能展示一条完整的数据流。环境上不用特殊配置Anaconda 装好、Jupyter 能开就行如果还没配过照 python 安装教程把环境变量指好VSCode 用户注意把右下角 Python 解释器选到 conda 环境避免 import pandas 时提示找不到模块。2.1 请求包装与列表页解析别用裸 requests 直接怼链家二手房列表页的 URL 规则很规律第一页是https://{城市}.lianjia.com/ershoufang/第二页起是/ershoufang/pg2/。解析用 requests 拿 HTML再用 BeautifulSoup 找房源卡片核心逻辑如下import requests from bs4 import BeautifulSoup import time import random HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Referer: https://sh.lianjia.com/ershoufang/, } def fetch_page(citysh, page1, max_retry3): # 第一页没有 pg 前缀第二页起才是 /pg2/ 这种路径 url fhttps://{city}.lianjia.com/ershoufang/pg{page}/ if page 1 \ else fhttps://{city}.lianjia.com/ershoufang/ for attempt in range(max_retry): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: return resp.text print(f第 {page} 页返回 {resp.status_code}稍后重试) except requests.RequestException as exc: print(f第 {attempt 1} 次请求异常: {exc}) time.sleep(random.uniform(2, 5)) return None这段代码做了三件事伪造 UA 和 Referer 让请求看起来来自浏览器设置 timeout 防止某个页面卡死整个爬虫失败后随机等 2 到 5 秒再重试。这里的关键参数是max_retry3链家风控偶尔会随机返回 521 或者 403重试三次基本能绕过偶发拦截。注意睡眠时间不能写死random.uniform(2, 5)的意义是让请求间隔不完全规律降低被识别为脚本的概率。拿到 HTML 之后解析房源链家列表页的每个房源在ul.sellListContent li里def parse_list_page(html): soup BeautifulSoup(html, html.parser) rows [] for li in soup.select(ul.sellListContent li): title_tag li.select_one(.title a) if title_tag is None: # 列表尾部经常混入非房源卡片 continue house {链接: title_tag.get(href), 标题: title_tag.get_text(stripTrue)} text li.select_one(.houseInfo) price_tag li.select_one(.totalPrice) if text is not None: house[房屋信息] text.get_text(stripTrue) if price_tag is not None: house[总价] price_tag.get_text(stripTrue) rows.append(house) return rowstitle_tag is None这个判断不能省链家页面底部经常混进推荐位或广告卡片没有这个过滤后面清洗时会时不时冒出空记录。房屋信息字段是后续特征工程的重点它把户型、面积、朝向、装修、楼层、建成年份全塞在一个字符串里第三章会集中拆解。选择器基于链家当前版本的列表页 DOM不同城市大体一致如果哪天改版导致解析不到数据打开浏览器 F12 看ul.sellListContent是否还存在不存在就退一步用li[class*sellList]这类模糊匹配兜底。2.2 每页一个 JSON 文件中断后只补跑没抓到的页很多人一上来就用一个列表把所有页的数据 append 在一起跑到一半被限流就只能从头再来。更稳的做法是每页存一个独立 JSON下次运行先检查文件是否存在import json, os OUT_DIR data/json_pages os.makedirs(OUT_DIR, exist_okTrue) for page in range(1, 101): # 链家单城市列表最多翻到 100 页 out_path os.path.join(OUT_DIR, fpage_{page:03d}.json) if os.path.exists(out_path): # 已抓过的页直接跳过 continue html fetch_page(sh, page) if html is None: continue items parse_list_page(html) with open(out_path, w, encodingutf-8) as f: json.dump(items, f, ensure_asciiFalse, indent2) time.sleep(random.uniform(1, 3))这里把页码固定为 100 是有原因的链家对未登录访问的列表页有页码上限超过阈值会跳转登录页具体细节放在第五章避坑部分展开。断点续爬的核心就是os.path.exists(out_path)这行爬虫中断后重新运行已抓的页直接跳过只补跑缺失的页码。如果只关心建模不想花时间在爬虫上data 目录里已经放了一份抓取结果可以直接进第三章但毕设场景下我建议至少完整跑一遍日志和中间文件都能作为工作量证据。列表页能拿到的字段虽然不多但足够建模用了字段设计大致如下。字段来源示例用途链接列表页标题 a 标签 href/sh/ershoufang/xxx.html去重、详情页扩展标题列表页 .title 文本阳光新村 2室1厅 南辅助校验房屋信息.houseInfo 文本2室1厅 | 86.21平米 | 南 北 | 精装 | 中楼层(共6层) | 2008年建特征工程主来源总价.totalPrice 文本499万预测目标3. 数据清洗与特征工程把「南 北」「中楼层(共6层)」变成模型特征爬下来只是第一步原始字段是给人看的模型只认数字。sol.ipynb 里最花时间的部分就是这一章。常见错误是急着建模结果模型报错说特征里有字符串或者把「总价」转 float 时抛异常。先做两件事统一单位、拆文本字段。3.1 合并 JSON 与去重同一套房源不重复进训练集上一章按页存了 JSON这里需要把所有页面合并成一份 DataFrame并按房源链接去重import pandas as pd import glob def load_json_pages(patterndata/json_pages/page_*.json): frames [] for path in glob.glob(pattern): with open(path, encodingutf-8) as f: rows pd.read_json(f) if not rows.empty: frames.append(rows) df pd.concat(frames, ignore_indexTrue) df.drop_duplicates(subset[链接], keepfirst, inplaceTrue) return df df load_json_pages() print(f合并后 {len(df)} 条去重后 {df.shape[0]} 条)去重为什么按「链接」而不是按价格或面积因为链家一套房源只有一个唯一链接同一套房子在不同时间段可能被重复抓取如果链接字段丢失退而求其次用「小区 户型 面积」三列组合去重。keepfirst表示保留第一次出现的记录这个参数在爬虫共跑了多天的情况下尤其重要后抓的数据很可能包含已被标记下架的页面。3.2 文本字段拆解与单位统一总价万元、面积平米、单价重算房屋信息字段的格式基本是2室1厅 | 86.21平米 | 南 北 | 精装 | 中楼层(共6层) | 2008年建用竖线 split 就能拆开def parse_house_info(raw): # 示例: 2室1厅 | 86.21平米 | 南 北 | 精装 | 中楼层(共6层) | 2008年建 parts [p.strip() for p in str(raw).split(|)] if len(parts) 5: return {} return { 户型: parts[0], 面积: float(parts[1].replace(平米, ).replace(㎡, ).strip()), 朝向: parts[2], 装修: parts[3], 楼层描述: parts[4], } def parse_total_price(raw): # 499万 - 499.0 return float(str(raw).replace(万, ).replace(万以下, ).strip()) df df.join(df[房屋信息].apply(parse_house_info).apply(pd.Series)) df[总价] df[总价].apply(parse_total_price) df[单价] df[总价] / df[面积] # 不用页面单价自己重算 print(df[[面积, 朝向, 装修, 楼层描述, 总价, 单价]].head())拆文本的坑主要在分隔符。链家不同城市的分隔符基本都是|但偶尔会用中文全角或空格保险做法是先replace(, |)再做 split。单价这里我特意没用页面上的单价字段而是用总价除以面积重算有两个原因一是页面单价单位是元/平米总价单位是万量纲不统一二是重算一遍等于交叉校验如果总价和面积解析有误单价会明显偏离合理区间方便后面过滤异常值。3.3 特征编码朝向拆四列、楼层分三段、房龄封顶清洗完的字段还是文本需要编码成数值。朝向是个典型的复合特征「南 北」代表南北通透如果只当成一个类别模型很难学到「朝南的房子更贵」这种规律所以拆成四列 0/1 特征def split_orientation(s): s str(s) return { 朝南: 1 if 南 in s else 0, 朝北: 1 if 北 in s else 0, 朝东: 1 if 东 in s else 0, 朝西: 1 if 西 in s else 0, } def level_of_floor(s): s str(s) if 低楼层 in s: return 0 if 高楼层 in s: return 2 return 1 df df.join(df[朝向].apply(split_orientation).apply(pd.Series)) df[楼层等级] df[楼层描述].apply(level_of_floor) df[装修等级] df[装修].map({毛坯: 0, 简装: 1, 精装: 2, 豪装: 3}).fillna(1).astype(int) df[房龄] 2025 - df[建成年份].astype(int) df[房龄] df[房龄].clip(0, 60)朝向拆四列而不是做 one-hot是因为「南 北」这类多朝向组合在 one-hot 里会变成一个新类别模型看不出它和「南」的关联拆开后「朝南1、朝北1」能同时保留两个信息。楼层分三段是二手房领域的经验做法低中高三个等级比直接用「共6层」这种原始文本更稳定。fillna(1)处理装修里的「暂无数据」给个中间等级不影响大局。房龄的年份解析如果失败会被 pandas 转成 NaN转 int 时直接报错所以先astype(int)再clip(0, 60)把解析异常导致的 0 房龄和录入错误导致的极端房龄都拦在模型外面。过滤异常值放在编码之后统一处理df df[(df[面积] 10) (df[面积] 300)] df df[(df[总价] 0) (df[单价] 0.5) (df[单价] 30)] df df.dropna(subset[户型, 面积, 总价]) print(df.shape)面积下限 10 平米是为了过滤车位和储藏室这类记录在链家列表里并不少见单价上限 30 万元/平米基本能拦下所有录入错误正常住宅单价不会到这个量级。注意过滤顺序先算单价再过滤否则单价列会有除零导致的 inf。4. 可视化分析与房价建模基线回归起步树模型提精度字段就绪以后先用图确认关系再进模型。直接拿全部特征去跑随机森林是常见误区——你不知道哪些特征是噪声也不知道总价分布是右偏还是正态模型报告会很难看。可视化在这里不是给答辩凑图是给建模指方向。4.1 先看分布与相关性总价右偏面积线性关系明显第一张图看总价分布第二张图看面积与总价的散点关系import matplotlib.pyplot as plt import seaborn as sns import numpy as np fig, axes plt.subplots(1, 2, figsize(12, 4)) sns.histplot(df[总价], bins50, axaxes[0]) axes[0].set_title(总价分布) sns.scatterplot(datadf.sample(500, random_state41), x面积, y总价, alpha0.4, axaxes[1]) axes[1].set_title(面积 vs 总价) plt.tight_layout() plt.show() # 训练时对总价取对数缓解右偏 y_raw df[总价] y_log np.log1p(y_raw)二手房总价是典型的右偏分布大部分房源集中在 200 到 800 万少数千万级豪宅拉出一条长尾。直接拿原始总价训练模型的误差会被高价房主导一个 5000 万的别墅预测偏差可能比 100 套普通住宅还大。np.log1p把右偏分布压回近似正态1是防止总价为 0 时取对数得负无穷。预测出来的对数结果要还原成万元用np.expm1反向变换。如果数据里区域字段齐全还可以加一张各区域均价柱状图这类图答辩时非常能撑场面。4.2 三组模型对比线性回归、随机森林、梯度提升建模阶段用三组模型做对比线性回归当基线随机森林和梯度提升树负责精度from sklearn.model_selection import train_test_split from sklearn.linear_model import LinearRegression from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor from sklearn.metrics import mean_squared_error, r2_score features [面积, 房龄, 朝南, 朝北, 朝东, 朝西, 楼层等级, 装修等级] X df[features].fillna(0) y y_log X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state41) models { LinearRegression: LinearRegression(), RandomForest: RandomForestRegressor( n_estimators300, max_depth8, random_state41, n_jobs-1), GBDT: GradientBoostingRegressor( n_estimators300, max_depth4, learning_rate0.05, random_state41), } for name, model in models.items(): model.fit(X_train, y_train) pred model.predict(X_test) rmse mean_squared_error(y_test, pred, squaredFalse) r2 r2_score(y_test, pred) print(f{name:16} RMSE(对数){rmse:.4f} R2{r2:.4f})先跑线性回归不是为了让它赢而是看基线。线性回归的系数有明确方向面积为正、房龄为负如果符号反了说明特征工程有硬伤。随机森林max_depth8是防止树太深把噪声背下来300 棵树在这个数据量级足够收敛。GBDT 用learning_rate0.05配 300 棵树是保守但可靠的组合学习率太小要加树学习率太大容易过拟合。random_state41必须写死不然后面重跑结果对不上。三组模型的定位可以简单理解成模型定位关注点参考 R² 区间线性回归基线系数方向是否符合常识0.45 ~ 0.60随机森林主力特征重要度排序0.70 ~ 0.82GBDT精度上限调参空间大0.74 ~ 0.86参考区间基于几千条城市二手房数据的常见结果以你自己抓到的数据量为准。数据量越小 R² 波动越大一两千条数据跑到 0.6 也算正常不用为了凑高分硬上复杂模型。4.3 特征重要性告诉答辩老师「房价由什么决定」模型跑完特征重要性是一张必出的图gbdt models[GBDT] importance pd.Series(gbdt.feature_importances_, indexfeatures) importance.sort_values(ascendingFalse).plot.barh(figsize(8, 4)) plt.xlabel(Feature Importance) plt.tight_layout() plt.show()特征重要度通常会把面积、房龄排在前两位区域经过 one-hot 编码后也可能挤进前列朝向贡献小但保留它有分析价值。答辩时可以说面积和房龄是影响二手房总价的核心因素朝向单独贡献有限但南北通透的多朝向组合在单价层面仍然有正收益。注意重要度是统计关联不是因果话不能说满。这套「清洗 → 建模 → 指标验收 → 解释特征」的流程和金融时序预测、用户消费预测的套路完全一致区别只在于这里不需要构造时间滞后项把房价这套跑通换数据集时只需要重写特征工程这一段。5. 避坑指南链家爬取与房价建模的五个翻车现场下面五个问题是我和身边同学在复现这类项目时最常撞上的每一条都是实测翻车换来的按「现象 → 原因 → 解决」记在这里遇到时可以少走弯路。5.1 翻车现场一翻到第 101 页被重定向到登录页现象前 100 页数据正常继续往下翻时解析不到任何房源页面内容变成登录引导。原因链家对未登录访问的二手房列表做了页码限制超过 100 页直接触发风控跳转不同城市阈值可能更低比如房源少的城市 50 页左右就拦。解决爬取时把页码上限写死或者按行政区拆成多个 URL 分别抓徐汇、浦东各跑一遍单区域页数少触发风控概率低。代码里加一道防线发现返回页异常直接停if login in html or 登录 in html: print(被重定向到登录页停止翻页) break出现跳转后先停 30 秒再换区域不要原地重试。5.2 翻车现场二总价「万」和单价「元/平米」单位没统一现象总价字段是「499万」单价字段是「43701元/平米」直接拿去做特征数值量级差了几十倍线性回归系数乱成一团。原因列表页两个字段单位本身不同解析时只做文本替换没有统一到同一量纲。解决只保留总价和面积两个原始量单价一律用总价除以面积重算单位统一成万元/平米。这样也顺带校验了页面单价有无录入错误。清洗阶段如果发现单价和总价对不上以总价和面积为准。5.3 翻车现场三爬虫中断后整页重跑前 30 页全丢现象跑到第 40 页被限流重启 notebook 后循环从第一页重新抓白白浪费一个多小时。原因数据一直存在内存列表里没有在抓取过程中落盘进程一断全没了。解决每页解析完就存一个独立 JSON 文件下次运行先检查文件是否存在存在就跳过。一次抓不完分三天抓也接得上这招对任何长任务爬虫都适用不止链家。5.4 翻车现场四训练集 R² 0.95测试集只有 0.6 左右现象随机森林在训练集上几乎完美换测试集指标骤降典型的过拟合。原因一类是特征泄漏比如把总价或带总价字样的派生列混进了特征另一类是树模型参数太激进max_depth 过大把噪声也背下来了。解决特征列里凡是名字带「总价」「单价」且与预测目标同源的列全部排除。每次跑完同时打印 train R² 和 test R²差值超过 0.2 就先查泄漏leak_cols [c for c in X.columns if 总价 in c or 单价 in c] print(疑似泄漏特征:, leak_cols)随机森林 max_depth 控制在 8 到 10GBDT 用小学习率配合足够多的树过拟合概率会明显下降。5.5 翻车现场五工程目录越来越大不知道哪些文件该进版本库现象项目传上去之后体积膨胀目录里出现.ipynb_checkpoints、__pycache__和一堆.pyc缓存文件。原因Jupyter 会自动保存检查点Python 运行脚本也会生成缓存目录项目里没有忽略配置。解决在项目根目录放一个.gitignore至少包含三项.ipynb_checkpoints/ __pycache__/ *.pycdata 目录下抓回的原始 JSON 如果体积大只保留清洗后的 CSV 进版本库原始 JSON 单独归档。别小看这个习惯提交到 GitHub 或者打包给导师时体积差好几倍。6. 把模型留到答辩后保存、最小推理与自检清单模型调完不代表项目做完。答辩前几天最容易出的状况是现场想演示预测发现 notebook 里的模型对象没了重跑一次结果又对不上——原因是随机种子没固定或者模型没落盘。我一般会做两件收尾事把模型和特征列名一起存成 joblib再写一个独立预测脚本。import joblib import numpy as np import pandas as pd joblib.dump({model: gbdt, features: features}, house_price_model.joblib) loaded joblib.load(house_price_model.joblib) model loaded[model] feat loaded[features] # 新样本的字段顺序必须和训练时完全一致 sample pd.DataFrame([{ 面积: 88.5, 房龄: 12, 朝南: 1, 朝北: 1, 朝东: 0, 朝西: 0, 楼层等级: 1, 装修等级: 2, }]) pred_log model.predict(sample[feat])[0] print(预测总价(万元):, round(float(np.expm1(pred_log)), 2))特征列名和模型一起存是关键推理时直接用loaded[features]给 DataFrame 选列能避免「训练时顺序和推理时不一致」这种低级错误。np.expm1是对训练时np.log1p的逆变换忘了还原的话预测结果会是一个在 0 到 1 之间的对数和真实房价对不上。模型落盘之后我每次跑完都会更新一张自检表把关键指标固定下来检查项目的通过标准固定 random_state保证任意一次重跑结果一致两次运行测试集 R² 差小于 0.01train/test R² 都记录判断是否过拟合两值差小于 0.2joblib 落盘答辩现场可独立推理脚本能直接输出预测值特征列名随模型保存避免推理时列顺序错位训练和推理列名完全一致我做毕设那会儿吃过一次亏模型调得不错但没固定随机种子答辩前一天重跑指标全变了差点在台上一句话讲不出来。从那以后我每次跑完都强制走一遍这套流程——固定种子、记录训练测试指标、joblib 落盘、自检表更新。花十分钟做收尾换来的是整个项目随时可复现。希望这份拆解能帮到你尤其是马上要提交毕设或课程设计的同学把数据链路跑通比纠结某个模型参数有意义得多。本文还有配套的精品资源点击获取