简介:一份面向计算机专业毕业设计及数据分析实践的二手房房价分析与预测项目源码包,经导师指导并获评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(city="sh", page=1, max_retry=3): # 第一页没有 pg 前缀,第二页起才是 /pg2/ 这种路径 url = f"https://{city}.lianjia.com/ershoufang/pg{page}/" if page > 1 \ else f"https://{city}.lianjia.com/ershoufang/" for attempt in range(max_retry): try: resp = requests.get(url, headers=HEADERS, timeout=10) 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_retry=3,链家风控偶尔会随机返回 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(strip=True)} text = li.select_one(".houseInfo") price_tag = li.select_one(".totalPrice") if text is not None: house["房屋信息"] = text.get_text(strip=True) if price_tag is not None: house["总价"] = price_tag.get_text(strip=True) 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_ok=True) for page in range(1, 101): # 链家单城市列表最多翻到 100 页 out_path = os.path.join(OUT_DIR, f"page_{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", encoding="utf-8") as f: json.dump(items, f, ensure_ascii=False, indent=2) 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(pattern="data/json_pages/page_*.json"): frames = [] for path in glob.glob(pattern): with open(path, encoding="utf-8") as f: rows = pd.read_json(f) if not rows.empty: frames.append(rows) df = pd.concat(frames, ignore_index=True) df.drop_duplicates(subset=["链接"], keep="first", inplace=True) return df df = load_json_pages() print(f"合并后 {len(df)} 条,去重后 {df.shape[0]} 条")去重为什么按「链接」而不是按价格或面积?因为链家一套房源只有一个唯一链接,同一套房子在不同时间段可能被重复抓取;如果链接字段丢失,退而求其次用「小区 + 户型 + 面积」三列组合去重。keep="first"表示保留第一次出现的记录,这个参数在爬虫共跑了多天的情况下尤其重要,后抓的数据很可能包含已被标记下架的页面。
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["总价"], bins=50, ax=axes[0]) axes[0].set_title("总价分布") sns.scatterplot(data=df.sample(500, random_state=41), x="面积", y="总价", alpha=0.4, ax=axes[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_size=0.2, random_state=41) models = { "LinearRegression": LinearRegression(), "RandomForest": RandomForestRegressor( n_estimators=300, max_depth=8, random_state=41, n_jobs=-1), "GBDT": GradientBoostingRegressor( n_estimators=300, max_depth=4, learning_rate=0.05, random_state=41), } for name, model in models.items(): model.fit(X_train, y_train) pred = model.predict(X_test) rmse = mean_squared_error(y_test, pred, squared=False) r2 = r2_score(y_test, pred) print(f"{name:<16} RMSE(对数)={rmse:.4f} R2={r2:.4f}")先跑线性回归不是为了让它赢,而是看基线。线性回归的系数有明确方向:面积为正、房龄为负,如果符号反了,说明特征工程有硬伤。随机森林max_depth=8是防止树太深把噪声背下来,300 棵树在这个数据量级足够收敛。GBDT 用learning_rate=0.05配 300 棵树,是保守但可靠的组合,学习率太小要加树,学习率太大容易过拟合。random_state=41必须写死,不然后面重跑结果对不上。
三组模型的定位可以简单理解成:
| 模型 | 定位 | 关注点 | 参考 R² 区间 |
|---|---|---|---|
| 线性回归 | 基线 | 系数方向是否符合常识 | 0.45 ~ 0.60 |
| 随机森林 | 主力 | 特征重要度排序 | 0.70 ~ 0.82 |
| GBDT | 精度上限 | 调参空间大 | 0.74 ~ 0.86 |
参考区间基于几千条城市二手房数据的常见结果,以你自己抓到的数据量为准。数据量越小 R² 波动越大,一两千条数据跑到 0.6 也算正常,不用为了凑高分硬上复杂模型。
4.3 特征重要性:告诉答辩老师「房价由什么决定」
模型跑完,特征重要性是一张必出的图:
gbdt = models["GBDT"] importance = pd.Series(gbdt.feature_importances_, index=features) importance.sort_values(ascending=False).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 到 10,GBDT 用小学习率配合足够多的树,过拟合概率会明显下降。
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.01 |
| train/test R² 都记录 | 判断是否过拟合 | 两值差小于 0.2 |
| joblib 落盘 | 答辩现场可独立推理 | 脚本能直接输出预测值 |
| 特征列名随模型保存 | 避免推理时列顺序错位 | 训练和推理列名完全一致 |
我做毕设那会儿吃过一次亏:模型调得不错,但没固定随机种子,答辩前一天重跑,指标全变了,差点在台上一句话讲不出来。从那以后我每次跑完都强制走一遍这套流程——固定种子、记录训练测试指标、joblib 落盘、自检表更新。花十分钟做收尾,换来的是整个项目随时可复现。希望这份拆解能帮到你,尤其是马上要提交毕设或课程设计的同学,把数据链路跑通,比纠结某个模型参数有意义得多。
本文还有配套的精品资源,点击获取