简介:这份资源是面向高校学生与Python初学者的二手房数据分析完整项目包,适用于毕业设计、期末大作业与课程设计等场景,可帮助读者快速完成从数据清洗到可视化呈现的全流程实践。包内共157个文件,涵盖18个py源码、18个csv数据集、15个html页面、11个js脚本及65张png图表,另附docx文档说明与pptx汇报资料,压缩包约48.05MB,目录结构清晰,便于按模块查阅。项目代码注释详尽,新手也能看懂,部署简单,下载后即可运行。数据集包含原始与清洗后的多版本CSV文件,覆盖编码转换与数据预处理环节,配合可视化页面可直观展示房价分布与影响因素。已有127人学习下载,适合需要高分项目参考、希望掌握数据分析实战流程的读者。
1. 从一份二手房数据到能讲清楚的结论:这套 Python 分析项目到底解决什么问题
二手房市场有个很反直觉的现象:同一小区、同样面积,挂牌价能差出 20% 以上,而中介给出的理由往往是"楼层不一样""装修不一样"。真去翻数据会发现,楼层和装修对价格的影响远没有想象中大,真正拉开差距的是房龄、学区、地铁距离和挂牌时间。这套基于 Python 的二手房数据分析项目,要解决的就是把这种"说不清"变成"算得清"——用爬虫拿到真实挂牌数据,用 pandas 做清洗和特征工程,用可视化把价格分布、区域差异、影响因素摆到台面上,最后配一份能直接讲给答辩老师或面试官听的文档和 PPT。
适合三类人:一是做课程设计或毕业设计的学生,需要一个结构完整、能跑通、有文档有 PPT 的项目;二是想转数据分析的从业者,需要一个真实场景练手,而不是拿鸢尾花数据集反复折腾;三是做房产相关业务的人,想快速搭一套自己的价格分析流程。整套东西的核心不是代码多复杂,而是流程完整、结论能落地、文档能讲清楚每一步为什么这么做。
2. 数据从哪来、怎么存:二手房数据采集与落库的完整链路
2.1 采集目标与字段设计
做二手房分析,第一步不是写爬虫,而是想清楚要哪些字段。字段设计错了,后面清洗和分析全是返工。我一般会先列一张目标表,把"分析需要什么"和"页面能拿到什么"对齐。
| 字段名 | 类型 | 来源 | 用途 |
|---|---|---|---|
| title | str | 列表页 | 辅助判断房源类型 |
| total_price | float | 列表页 | 总价分析 |
| unit_price | float | 列表页 | 单价分析,核心指标 |
| area | float | 列表页 | 计算单价、面积分布 |
| layout | str | 列表页 | 户型分析,如"3室1厅" |
| floor | str | 详情页 | 楼层影响分析 |
| build_year | int | 详情页 | 房龄计算 |
| district | str | 列表页 | 区域对比 |
| community | str | 列表页 | 小区聚合 |
| follow_count | int | 列表页 | 热度指标 |
| visit_count | int | 详情页 | 带看热度 |
| publish_days | int | 详情页 | 挂牌时长 |
这张表里,unit_price是最关键的,因为总价受面积影响太大,直接比总价没有意义。publish_days是很多人会漏掉的字段,但它对判断"房东急不急卖"非常有用,后面分析里会专门讲。
2.2 采集脚本与反爬处理
采集部分用 requests + BeautifulSoup 就够了,不需要上 Scrapy,因为数据量不大,单机跑几个小时能拿到几千条。关键是控制频率和做好异常处理。
import requests from bs4 import BeautifulSoup import pandas as pd 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", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_page(url, retry=3): """带重试的页面抓取,失败返回 None""" for i in range(retry): try: resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code == 200: return resp.text # 状态码异常时退避重试 time.sleep(2 ** i) except requests.RequestException: time.sleep(2 ** i) return None def parse_list(html): """解析列表页,返回房源字典列表""" soup = BeautifulSoup(html, "html.parser") items = [] for node in soup.select(".sellListContent li"): try: title = node.select_one(".title a").get_text(strip=True) total = node.select_one(".totalPrice span").get_text(strip=True) unit = node.select_one(".unitPrice span").get_text(strip=True) items.append({ "title": title, "total_price": float(total), # 单价文本带"元/平米",需要去掉单位 "unit_price": float(unit.replace("元/平米", "").replace(",", "")), }) except (AttributeError, ValueError): # 单条解析失败不影响整体,跳过 continue return items def crawl(pages=50): all_data = [] for p in range(1, pages + 1): url = f"https://example.com/sale/pg{p}/" html = fetch_page(url) if not html: print(f"page {p} failed, skip") continue all_data.extend(parse_list(html)) # 随机间隔,避免请求过于规律 time.sleep(random.uniform(1.5, 3.5)) return pd.DataFrame(all_data) if __name__ == "__main__": df = crawl(pages=50) df.to_csv("raw_ershoufang.csv", index=False, encoding="utf-8-sig") print(f"共采集 {len(df)} 条")这段代码有三个关键点。第一,fetch_page里的重试用了指数退避,第 i 次失败等 2 的 i 次方秒,避免连续快速重试被识别。第二,parse_list里每条房源单独 try,某一条字段缺失不会让整页数据丢掉,这是血泪经验——早期版本一页里有一条异常,整页白抓。第三,time.sleep用随机值而不是固定值,固定间隔的请求模式太规律,容易被判定为脚本。
参数上,pages根据目标城市房源量调整,一般 50 到 200 页能覆盖一个城市的主要挂牌。timeout设 10 秒,太短容易误判超时,太长卡住整个流程。encoding="utf-8-sig"是为了 Excel 打开 CSV 不乱码,这个细节在交付文档时很重要。
2.3 落库与增量更新
采集完直接存 CSV 能用,但做多次采集就会乱。常见做法是存 SQLite,轻量、不用装数据库服务、Python 原生支持。
import sqlite3 def save_to_db(df, db_path="ershoufang.db"): conn = sqlite3.connect(db_path) # 建表时给 unit_price 和 district 建索引,后面查询快很多 conn.execute(""" CREATE TABLE IF NOT EXISTS houses ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, total_price REAL, unit_price REAL, area REAL, layout TEXT, floor TEXT, build_year INTEGER, district TEXT, community TEXT, follow_count INTEGER, visit_count INTEGER, publish_days INTEGER, crawl_date TEXT ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_unit ON houses(unit_price)") conn.execute("CREATE INDEX IF NOT EXISTS idx_district ON houses(district)") df.to_sql("houses", conn, if_exists="append", index=False) conn.commit() conn.close()用append而不是replace,是为了保留历史采集记录,后面可以做价格随时间变化的分析。crawl_date字段在采集时补上,用datetime.now().strftime("%Y-%m-%d"),这样同一套房源不同时间点的挂牌价都能追溯。索引建在unit_price和district上,因为后面分析里这两个字段的查询最频繁。
3. 清洗与特征工程:把脏数据变成能算的字段
3.1 缺失值与异常值处理
真实采集的数据一定脏。常见问题有三类:面积缺失、单价异常(比如 1 元/平米或 100 万/平米)、楼层字段格式不统一("低楼层"和"1/6层"混在一起)。
import numpy as np import pandas as pd def clean(df): df = df.copy() # 面积缺失用同户型中位数填充,比全局均值合理 df["area"] = df.groupby("layout")["area"].transform( lambda x: x.fillna(x.median()) ) # 单价用 1% 和 99% 分位数截断,去掉极端异常 low, high = df["unit_price"].quantile([0.01, 0.99]) df = df[(df["unit_price"] >= low) & (df["unit_price"] <= high)] # 总价和单价对不上时,以单价为准重算总价 df["total_price"] = df["unit_price"] * df["area"] / 10000 # 房龄 = 当前年份 - 建成年份 df["house_age"] = 2024 - df["build_year"] df.loc[df["house_age"] < 0, "house_age"] = np.nan return dfgroupby("layout")填充面积是有讲究的:同样"3室1厅",面积中位数比全局均值更接近真实值。分位数截断用 1% 和 99% 而不是 3σ,因为房价分布是右偏的,用标准差会把正常的高价房也砍掉。总价重算这一步很多人不做,但采集时总价和单价经常因为页面更新不同步而对不上,以单价为准更可靠。
3.2 从文本字段里挖特征
layout、floor、title这些文本字段里藏着大量信息,直接丢掉太浪费。常见做法是拆成结构化特征。
import re def extract_features(df): df = df.copy() # 户型拆成室和厅 df["rooms"] = df["layout"].str.extract(r"(\d+)室").astype(float) df["halls"] = df["layout"].str.extract(r"(\d+)厅").astype(float) # 楼层拆成楼层位置和总层数 df["floor_level"] = df["floor"].str.extract(r"(低|中|高)楼层") df["total_floors"] = df["floor"].str.extract(r"共(\d+)层").astype(float) # 标题里出现"地铁"的标记为近地铁 df["near_subway"] = df["title"].str.contains("地铁").astype(int) # 标题里出现"精装""简装"的标记装修等级 df["decoration"] = df["title"].str.extract(r"(精装|简装|毛坯)") return dfrooms和halls用正则提取,比字符串切割稳。near_subway用标题关键词判断是权宜之计,准确率大概七成,但比没有强;如果详情页能拿到地铁距离,优先用距离字段。decoration提取出来是分类变量,后面做分析时要转成哑变量。
3.3 特征相关性初筛
特征做完不是全塞进模型,先看相关性,把冗余的去掉。
import seaborn as sns import matplotlib.pyplot as plt def plot_corr(df, cols): corr = df[cols].corr() plt.figure(figsize=(10, 8)) sns.heatmap(corr, annot=True, fmt=".2f", cmap="coolwarm", center=0) plt.title("特征相关性矩阵") plt.tight_layout() plt.savefig("corr.png", dpi=150) return corr cols = ["unit_price", "area", "house_age", "rooms", "total_floors", "near_subway"] corr = plot_corr(df, cols) print(corr["unit_price"].sort_values(ascending=False))看相关性有两个目的:一是确认哪些特征和单价强相关,二是发现特征之间的共线性。比如area和rooms相关性通常很高,做线性回归时同时放进去会导致系数不稳定,这时候保留area丢掉rooms更合理。热力图存成 PNG,直接放进文档和 PPT 里,比截图清晰。
4. 分析建模与可视化:从描述统计到价格预测
4.1 描述性分析:先看清数据长什么样
建模之前先做描述统计,这一步能发现很多后面会踩的坑。
def describe_by_district(df): """按区域统计单价分布""" result = df.groupby("district")["unit_price"].agg([ ("count", "size"), ("mean", "mean"), ("median", "median"), ("std", "std"), ("q25", lambda x: x.quantile(0.25)), ("q75", lambda x: x.quantile(0.75)), ]).round(0) # 按中位数排序,均值容易被极端值拉偏 return result.sort_values("median", ascending=False) stats = describe_by_district(df) print(stats.head(10))用中位数排序而不是均值,是因为每个区域都有几套天价房源,均值会被拉高,中位数更能反映"典型房源"的价格水平。q25和q75给出价格区间,比单一数字信息量大。这个表直接放进文档,就是"区域价格对比"那一节的核心内容。
4.2 可视化:三张图讲清价格分布
可视化不用多,三张图能讲清楚就行:单价分布直方图、区域单价箱线图、面积与单价散点图。
import matplotlib.pyplot as plt import matplotlib # 中文字体设置,不设会显示方块 matplotlib.rcParams["font.sans-serif"] = ["SimHei"] matplotlib.rcParams["axes.unicode_minus"] = False fig, axes = plt.subplots(1, 3, figsize=(18, 5)) # 图1:单价分布 axes[0].hist(df["unit_price"], bins=50, color="#4C72B0", edgecolor="white") axes[0].set_xlabel("单价(元/平米)") axes[0].set_ylabel("房源数量") axes[0].set_title("单价分布直方图") # 图2:区域箱线图,只取房源量前8的区域 top_districts = df["district"].value_counts().head(8).index data = [df[df["district"] == d]["unit_price"].values for d in top_districts] axes[1].boxplot(data, labels=top_districts, vert=False) axes[1].set_xlabel("单价(元/平米)") axes[1].set_title("各区域单价分布") # 图3:面积与单价散点 axes[2].scatter(df["area"], df["unit_price"], alpha=0.3, s=10) axes[2].set_xlabel("面积(平米)") axes[2].set_ylabel("单价(元/平米)") axes[2].set_title("面积与单价关系") plt.tight_layout() plt.savefig("overview.png", dpi=150)中文字体那两行是必须的,不设的话图上全是方块,这是新手最常翻车的地方。箱线图只取房源量前 8 的区域,区域太多图会挤成一团。散点图alpha=0.3让重叠的点颜色加深,能看出密度分布。这三张图放进 PPT,一页就能把数据概况讲完。
4.3 价格预测:随机森林比线性回归更稳
做价格预测,线性回归可解释性强但对非线性关系拟合差,随机森林精度高但不好解释。我的建议是两个都做,用随机森林出预测结果,用线性回归的系数讲影响因素。
from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestRegressor from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_absolute_error, r2_score from sklearn.preprocessing import OneHotEncoder from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline # 特征列 num_cols = ["area", "house_age", "rooms", "total_floors", "near_subway"] cat_cols = ["district", "floor_level", "decoration"] # 分类变量独热编码,数值变量直接过 preprocessor = ColumnTransformer([ ("num", "passthrough", num_cols), ("cat", OneHotEncoder(handle_unknown="ignore"), cat_cols), ]) X = df[num_cols + cat_cols] y = df["unit_price"] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) # 随机森林 rf = Pipeline([ ("prep", preprocessor), ("model", RandomForestRegressor(n_estimators=200, max_depth=12, random_state=42)), ]) rf.fit(X_train, y_train) pred_rf = rf.predict(X_test) print(f"RF MAE: {mean_absolute_error(y_test, pred_rf):.0f} R2: {r2_score(y_test, pred_rf):.3f}") # 线性回归做对照 lr = Pipeline([ ("prep", preprocessor), ("model", LinearRegression()), ]) lr.fit(X_train, y_train) pred_lr = lr.predict(X_test) print(f"LR MAE: {mean_absolute_error(y_test, pred_lr):.0f} R2: {r2_score(y_test, pred_lr):.3f}")ColumnTransformer把数值列和分类列分开处理,这是 sklearn 的标准做法,比手动编码干净。handle_unknown="ignore"保证测试集出现训练集没见过的区域时不会报错。随机森林的max_depth=12是防止过拟合,树太深会把训练集的噪声也学进去。n_estimators=200是精度和速度的平衡点,再往上提升有限但训练时间线性增长。
评估指标用 MAE 和 R2。MAE 直接告诉你预测单价平均差多少元,比 RMSE 好解释。R2 看模型解释了多大比例的方差,二手房数据一般能到 0.7 到 0.85,低于 0.6 说明特征不够或者数据质量问题。
4.4 特征重要性:哪些因素真正影响房价
随机森林能输出特征重要性,这是做分析报告最有价值的部分。
import numpy as np # 从 pipeline 里取出编码后的特征名 ohe = rf.named_steps["prep"].named_transformers_["cat"] cat_names = ohe.get_feature_names_out(cat_cols) feature_names = num_cols + list(cat_names) importances = rf.named_steps["model"].feature_importances_ idx = np.argsort(importances)[::-1][:15] for i in idx: print(f"{feature_names[i]:30s} {importances[i]:.4f}")输出结果里,area和district的某些区域通常排最前,house_age和near_subway紧随其后。这个排序直接回答了"什么因素最影响房价"这个问题,比任何定性描述都有说服力。把前 15 个特征画成条形图放进 PPT,就是"影响因素分析"那一页。
5. 避坑与排查:这套流程里最容易翻车的五个地方
5.1 采集被封:请求频率和请求头没配对
现象:跑了几十页之后开始返回 403 或者验证码页面,数据突然断掉。
原因:请求间隔太短,或者 User-Agent 一直是同一个。有些站点还会检查 Referer 和 Cookie。
解决:把time.sleep调到 2 到 5 秒随机,准备多个 User-Agent 轮换,必要时加上 Referer 头。如果还是被封,降低采集量,分多次跑,每次间隔几小时。不要试图用多线程加速,单机多线程只会更快触发封禁。
5.2 单价算错:总价单位是"万"不是"元"
现象:算出来的单价是几百万一平米,明显不对。
原因:页面上的总价是"320万",直接当 320 元用了。单价页面给的是"元/平米",但总价是"万"。
解决:统一单位。总价乘以 10000 转成元,再除以面积得到元/平米。或者反过来,单价除以 10000 得到万/平米。关键是全流程统一,不要一部分用万一部分用元。我在清洗函数里专门加了一行df["total_price"] = df["unit_price"] * df["area"] / 10000来强制对齐。
5.3 中文乱码:matplotlib 和 CSV 两个地方都要设
现象:图表上中文全是方块,或者 CSV 用 Excel 打开是乱码。
原因:matplotlib 默认字体不支持中文,CSV 默认编码是 utf-8 但 Excel 认的是 utf-8-sig。
解决:matplotlib 加rcParams["font.sans-serif"] = ["SimHei"],CSV 保存时用encoding="utf-8-sig"。这两个地方都要设,只设一个另一个还是会出问题。Linux 环境下 SimHei 可能没有,换成WenQuanYi Micro Hei或者把字体文件放到项目目录里手动指定路径。
5.4 模型过拟合:训练集 R2 很高但测试集很差
现象:训练集 R2 到 0.98,测试集只有 0.5。
原因:决策树深度太深,或者特征里有泄漏。比如把total_price也放进特征了,而total_price和unit_price是直接换算关系。
解决:先检查特征里有没有和标签直接相关的字段,total_price、unit_price只能留一个。然后限制树深度,max_depth从 8 开始试,逐步加到验证集 R2 不再提升为止。再加min_samples_leaf=5防止叶子节点样本太少。
5.5 区域字段不统一:同一个区有好几种写法
现象:groupby 之后发现"朝阳"和"朝阳区"分成两组,统计结果对不上。
原因:不同页面或者不同采集批次,区域字段的写法不一致。
解决:清洗时做一次标准化,用映射表把常见变体统一。比如df["district"] = df["district"].str.replace("区$", "")去掉末尾的"区",或者建一个字典手动映射。这个步骤放在清洗函数最前面,后面所有分析都依赖它。
6. 文档与 PPT 怎么写出高分感:把分析过程变成可讲的故事
文档和 PPT 是这套项目里最容易被低估的部分。代码跑通只是及格,能把"为什么这么做"讲清楚才是高分。我一般按"问题—数据—方法—结论—局限"五段式组织文档,每段对应 PPT 的两到三页。
文档部分,重点写三块。第一块是数据字典,把每个字段的来源、类型、处理方式列成表,答辩时老师最爱问字段定义。第二块是方法选择理由,比如为什么用中位数不用均值、为什么用随机森林不用线性回归,每个选择都要有对比。第三块是结论和局限,结论要具体到数字,比如"房龄每增加 5 年,单价中位数下降约 8%",局限要诚实,比如"标题关键词判断近地铁准确率约七成,未使用真实距离数据"。
PPT 部分,控制在 15 到 20 页。封面一页,背景和问题一页,数据来源和字段一页,清洗流程一页,可视化三页(分布、区域、散点),模型对比一页,特征重要性一页,结论一页,局限和展望一页,最后致谢一页。每页只放一张图或一张表,文字不超过 5 行。图表用之前存好的 PNG,分辨率 150 dpi 足够投影。
代码组织上,建议按crawl.py、clean.py、analyze.py、model.py四个文件拆分,每个文件有if __name__ == "__main__"入口,能单独跑。再加一个requirements.txt列出依赖版本,一个README.md写清楚运行顺序。这样别人拿到你的项目,照着 README 三步就能跑起来,这在答辩和面试里是加分项。
最后一个技巧:把分析结论做成一个可交互的查询脚本,输入区域和面积,输出预测单价和同区域对比。这个脚本不用多复杂,二十行代码就够,但演示效果很好,能让老师或面试官直观感受到你的项目"能用"。
我自己做这类项目最大的教训是:别一上来就追求模型精度,先把数据链路跑通、把文档写清楚。模型 R2 从 0.75 提到 0.80 要花三天,但文档从"能看"改到"能讲"只要三小时,后者对分数的贡献反而更大。希望帮到你。
本文还有配套的精品资源,点击获取