简介:基于Python的二手房数据分析完整项目,专为毕业设计、期末大作业和课程设计场景打造。项目从数据采集清洗到可视化展示形成闭环,18个Python源码文件覆盖数据读取、清洗、建模和可视化等环节,18个CSV数据集包含原始房源表与多版本清洗结果,15个HTML页面和11个JS脚本搭建交互式展示界面,另配文档说明和PPT汇报材料,适合不同基础的学习者快速上手。源码关键位置附有详细注释,整体逻辑清晰,简单部署即可运行;65张PNG截图和可视化图表直观呈现分析过程与结论,便于答辩演示与成果汇报。整个资源包共157个文件,压缩后48.05MB,结构紧凑、分类明确;CSV文件还兼顾不同编码格式,便于在多种环境下直接读取。已有127人学习/下载,对于需要快速完成高质量数据分析类课程设计、期末大作业或毕业设计的同学,是一份经过调试、可直接参考和复用的高分范本。
1. 二手房照样能做出让人眼前一亮的数据分析项目
同学毕设选题,十个里有六七个卡在“不知道做什么数据、跑完怎么讲”。但 Python 二手房数据分析这题,反而是最不容易翻车的一类:数据量大、字段结构稳定、结论可解释性天然强。标题里的“完整源码+文档说明+PPT资料”指向的是一份能直接复现、能应对答辩追问的交付物,而这条链路并不复杂——爬虫抓列表页、清洗字段、做特征、画图、建模、最后把结果整理成文档和PPT。对刚入门 Python、想完整走一遍数据分析流程的人来说,它比纯练习题更能建立全局观。接下来我就按这个顺序,把每一步的代码、参数和坑摊开讲,新手能照做,熟手能直接抄边界条件。
2. 房源数据从哪来:爬虫采集与字段设计
2.1 结构化列表页为什么是数据源首选
二手房数据分析项目,最容易被低估的一步是数据获取。有人一上来就想到平台开放 API,但这几年开放接口收紧,个人申请基本拿不到;也有人想通过搜索引擎聚合网页再解析,解析成本高、字段还不对齐。我一般直接选链家、贝壳这类结构化列表页,原因很朴素:一个小区列表页里的每套房,标题、户型、面积、朝向、楼层、总价、单价都在固定的 HTML 节点里,只需要一个解析函数就能稳定抽出几百条记录。
这类页面的另一个好处是翻页规则非常规整,ershoufang/pg1、pg2一路往后翻就行,不用处理复杂的异步加载和签名参数。爬下来的是城市维度的挂牌数据,不是成交数据,用来研究房价分布和影响因素足够了;如果论文或课设需要成交价,再去找对应城市的存量成交明细,但那种数据通常需要额外授权,建议在项目文档里写清楚用的是挂牌数据。
提示:爬虫只做学习用途,控制频率、不要并发抓取,也不要抓取需要登录才能看的访客数据。这个项目重在链路完整,不是抓得多才高分,把前几页数据跑通,胜过后台挂着抓三天。
2.2 一个最小可运行爬虫:请求头、解析与字段提取
常见做法是用 requests 拉页面,BeautifulSoup 配合 lxml 解析。下面这段代码是完整的抓取保存逻辑,运行在 Python 3.8+ 环境,依赖 requests、beautifulsoup4、lxml 三个库,装好就能直接跑:
import requests from bs4 import BeautifulSoup import csv import time 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(city_spider: str, page: int) -> list: url = f"https://{city_spider}.lianjia.com/ershoufang/pg{page}/" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() soup = BeautifulSoup(resp.text, "lxml") items = [] for li in soup.select(".sellListContent li .info"): item = {} a = li.select_one(".title a") item["title"] = a.text.strip() if a else "" info = li.select_one(".houseInfo") item["house_info"] = info.text.strip() if info else "" p = li.select_one(".totalPrice span") item["total_price"] = p.text.strip() if p else "" u = li.select_one(".unitPrice span") item["unit_price"] = u.text.strip() if u else "" items.append(item) return items if __name__ == "__main__": all_data = [] for pg in range(1, 6): # 前5页,验证流程够用 all_data.extend(fetch_page("bj", pg)) time.sleep(1) # 控制抓取频率 with open("houses.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["title", "house_info", "total_price", "unit_price"]) writer.writeheader() writer.writerows(all_data) print("saved", len(all_data), "rows")这段代码的逻辑分三层:requests.get负责把页面拉回来,BeautifulSoup负责解析 DOM,最后的csv.DictWriter把结果落盘。需要注意 HEADERS 里最关键的是User-Agent,不带或不标准很容易触发拦截;Accept-Language保证返回的中文页面,避免解析到英文或乱码节点,这在国内站点上时常出现。
几个参数的实用取法:fetch_page里每次只解析当前页,因为sleep(1)必须保留,把 5 页变成 50 页还不加 sleep,基本几十页后就会被限流。CSV 保存用utf-8-sig是为了让 Excel 打开不出现中文乱码,普通utf-8在 Windows 上打开会乱。跑完后确认保存条数是否约等于 5 页乘每页 30 条,如果差太多,先检查select选择器是否匹配当前页面结构。
2.3 数据落库与增量更新:CSV 和 SQLite 怎么选
项目前期用 CSV 完全够用,结构简单、肉眼可查、Excel 能直接打开。但如果你打算抓多个城市、连续多天抓取,或者后面要按天对比挂牌价变化,我建议早点换成 SQLite。理由有两条:一是重复运行脚本不会产生重复数据,二是后面做可视化查询时写 SQL 比在 DataFrame 里反复去重省事得多。
import sqlite3 import pandas as pd df = pd.read_csv("houses.csv") conn = sqlite3.connect("houses.db") df.to_sql("house", conn, if_exists="append", index=False) # 幂等去重,避免下次重复导入产生脏行 conn.execute(""" DELETE FROM house WHERE rowid NOT IN ( SELECT MIN(rowid) FROM house GROUP BY title, house_info, total_price, unit_price ) """) conn.commit() print(conn.execute("SELECT COUNT(*) FROM house").fetchone()[0])逻辑说明:先把 CSV 追加导入 SQLite,再用GROUP BY找出重复组合里最早的那条,删掉其余重复行。title、house_info、total_price、unit_price四个字段组合起来,基本能唯一识别一套房;后面如果加了小区和街道字段,组合可以更精确。参数上注意if_exists="append"是追加语义,不是覆盖,重复跑脚本时配合去重 SQL 才能保持数据干净。
这套流程跑通后,手里就有一张结构化的房源表。接下来进入整个项目最花时间的环节:把原始字段变成能算、能画、能建模的干净数据。
3. 数据清洗与特征工程:房价分析的底气都在这里
3.1 四类脏数据:先做标准清洗
原始数据长什么样,上一章的输出已经能看出来——house_info是一个包含小区、户型、面积、朝向、装修、楼层、楼龄的拼接字符串,total_price是整数,unit_price是62345元/平这种带单位的文本。先处理最常见的四类脏数据:空值、单位、拼接文本、异常值。只要 Python 基础语法过关,这部分处理起来其实很机械,但漏掉任何一类,后面建模都会出问题。
import pandas as pd df = pd.read_csv("houses.csv") df = df.dropna(subset=["title", "total_price", "unit_price"]) # 拆 house_info:示例格式 "小区名 2室1厅 89.5平米 南 简装 中楼层 2012年建" parts = df["house_info"].str.split(" ", expand=True) df["layout"] = parts[1].fillna("未知") df["area"] = pd.to_numeric(parts[2].str.replace("平米", ""), errors="coerce") df["orientation"] = parts[3].fillna("未知") df["floor"] = parts[4].fillna("未知") df["build_year"] = parts[5].str.replace("年建", "") df["total_price"] = df["total_price"].astype(float) # 单位:万 df["unit_price_clean"] = ( df["unit_price"].str.replace("元/平", "").str.replace(",", "").astype(float) ) df["price_checked"] = df["total_price"] * 10000 / df["area"] # 核单价 df = df[(df["area"] > 5) & (df["area"] < 500)] df = df[(df["total_price"] > 5) & (df["total_price"] < 5000)] df.to_csv("houses_clean.csv", index=False, encoding="utf-8-sig")参数说明:split(" ", expand=True)的展开列数取决于页面字段,如果小区名带空格,整体列位置会后移,建议先打印parts.head()确认列位置再继续。area字段如果是89.5㎡这种写法,replace后直接astype(float)会抛错,用pd.to_numeric(..., errors="coerce")更稳,无法转换的会变成 NaN,后续统一处理。price_checked是核单价,用来校对页面给的单价是否合理,两边差距超过 20% 的行标记为可疑,不急着删。
很多初学项目在第一步就翻车,不是代码不会写,而是没意识到爬下来的东西根本不是表。上面这套清洗做完,得到的是可以进模型的 DataFrame,后面所有分析都建立在它上面,所以这一步宁可多花半小时核对,也别急着往后跑。
3.2 特征工程:把字符串变成模型能用的数字
清洗只是把数据变干净,特征工程才是提分的关键。二手房分析里最常用的几个衍生特征:房龄(当前年份减建成年份)、单价(总价乘 10000 除以面积)、朝向分组、楼层分组、商圈编码。
import numpy as np from sklearn.preprocessing import LabelEncoder df = pd.read_csv("houses_clean.csv") now_year = 2024 df["house_age"] = now_year - pd.to_numeric(df["build_year"], errors="coerce") def group_orientation(ori): if ori in ["南", "东南", "西南", "南北", "南东", "南西"]: return "south" if ori in ["东", "西", "东西"]: return "ew" if ori in ["北", "东北", "西北"]: return "north" return "unknown" df["ori_group"] = df["orientation"].apply(group_orientation) df["is_south"] = (df["ori_group"] == "south").astype(int) le = LabelEncoder() df["floor_code"] = le.fit_transform(df["floor"].fillna("未知"))说明:house_age在build_year缺失时相减会变成 NaN,建模前要么填中位数,要么直接删,不要留着让 sklearn 报错。朝向分组里“南、东南、西南、南北”归为south是基于北方城市“朝南最好卖”的经验;如果是南方城市,西晒问题会让西南朝向口碑分化,这个阈值要按城市调整。LabelEncoder的编码结果是 0、1、2……本身有大小关系,这在树模型里问题不大,但线性回归里会诱导模型以为“编码越大的楼层对价格影响越大”,如果坚持用线性回归,建议用 one-hot 替代。
3.3 切分数据前的质量检查:三个硬指标
特征做完了,先别急着建模,做一次“三查”。第一查空值率,逐列打印缺失比例;第二查重复率,按核心字段集合统计重复行数;第三查离群,看面积、单价、总价的分布有没有明显异常。
print(df.isnull().mean().sort_values(ascending=False)) print("dup:", df.duplicated(subset=["title", "area", "total_price"]).sum()) print(df[["area", "total_price", "unit_price_clean"]].describe(percentiles=[.01, .05, .95, .99]))这三个指标决定后面模型的天花板:空值率超过 20% 的列建议直接砍,重复率过高说明爬虫去重逻辑有问题,离群值不一定删但要标记。percentiles是这段代码里唯一需要调的参数,数据量小的时候看 1% 和 99% 分位就够,数据量大时加 0.5% 和 99.5% 能更早发现极端值。把这些检查结果写进项目文档,答辩时是很加分的“数据质量说明”部分——它证明你不是拿到数据就直接开跑,而是对数据做过系统的体检。
4. 可视化分析与模型验证:把数据变成能讲结论的图
4.1 静态结论图:seaborn 一把梭出核心洞察
数据干净了,先出最直观的结论图。二手房分析里永远三个图最有用:单价分布直方图、面积-总价散点图、商圈均价 Top 条形图。这三个图覆盖了“整体行情、一房一价关系、区域差异”三个问题,也是文档和 PPT 里最常被引用的素材。
import matplotlib.pyplot as plt import seaborn as sns sns.set_theme(style="whitegrid") df = pd.read_csv("houses_clean.csv") fig, axes = plt.subplots(1, 3, figsize=(16, 4)) sns.histplot(df["unit_price_clean"] / 10000, bins=40, ax=axes[0]) axes[0].set_title("单价分布(万元/平)") sns.scatterplot(data=df, x="area", y="total_price", alpha=0.6, ax=axes[1]) axes[1].set_title("面积-总价关系") top = df.groupby("district")["unit_price_clean"].mean().sort_values(ascending=False).head(10) sns.barplot(x=top.values, y=top.index, ax=axes[2]) axes[2].set_title("商圈均价Top10") plt.tight_layout() plt.savefig("insights.png", dpi=150)参数上,bins=40是直方图分箱数,样本量在 300 条以内时 40 箱会显得碎,改 30 更干净;dpi=150是导出清晰度,进 PPT 够用,要打印再提到 200。groupby("district")依赖数据里有商圈字段,如果爬虫里没抓,用小区名前缀做粗粒度分组也可以。这里去掉 hue 参数,样本几百条时按朝向分色块区分不明显,反而干扰“面积涨总价涨”这个主结论。图保存用savefig而不是plt.show(),避免在无图形界面环境里卡住,远程跑项目时这个习惯很省事。
4.2 可视化界面:FastAPI + ECharts 做一个能交互的报告
静态图只够放在文档和 PPT 里。如果你想做一个能现场点选的“python 爬虫可视化界面”,最省事的方案是 FastAPI 做后端、ECharts 做前端图表。后端提供一个 JSON 接口,前端一个下拉框切换区域,图表跟着变,演示效果比静态图强一个档次。
# app.py from fastapi import FastAPI from fastapi.responses import HTMLResponse import pandas as pd app = FastAPI() df = pd.read_csv("houses_clean.csv") @app.get("/api/price/{district}") def price_dist(district: str): sub = df[df["district"] == district] bins = pd.cut(sub["unit_price_clean"], 20) hist = sub.groupby(bins, observed=False).size() return {"district": district, "bins": [str(b) for b in hist.index], "counts": hist.tolist()} @app.get("/", response_class=HTMLResponse) def index(): return """<!DOCTYPE html> <html><head><meta charset="utf-8"><title>二手房单价分析</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script></head> <body> <h3>选择区域</h3> <select id="sel"> <option>朝阳</option><option>海淀</option><option>丰台</option><option>通州</option> </select> <div id="chart" style="width:800px;height:400px;"></div> <script> const chart = echarts.init(document.getElementById('chart')); async function load(){ const d = document.getElementById('sel').value; const res = await fetch('/api/price/' + d); const data = await res.json(); chart.setOption({xAxis:{data:data.bins}, yAxis:{}, series:[{type:'bar', data:data.counts}]}); } document.getElementById('sel').onchange = load; load(); </script></body></html>"""逻辑说明:/api/price/{district}是核心接口,district决定过滤哪个区域,返回单价分箱后的频数分布交给 ECharts 画柱状图;前端就一个下拉框加一个 div,复杂度很低。pd.cut的分箱数 20 可以按数据量调,只有 200 条数据时 20 个箱子会很碎,减到 10 更合适。跑起来用uvicorn app:app --port 8000,浏览器打开127.0.0.1:8000就能看。
这个界面的价值不只是好看,它让项目从“我写了分析代码”变成“我做了一个可交互的数据分析工具”。答辩现场换一个区域让图表变化,比放十张静态图更有说服力,也是标题里“高分项目”最容易出彩的地方。
4.3 模型验证:回归跑出来不是终点,能解释才是
可视化回答了“什么因素和房价相关”,模型用来回答“这些因素组合起来能预测到什么程度”。二手房价格预测最常见的做法是 log1p 变换后做回归。总价分布一般右偏严重,直接回归会让模型把注意力全放在高价房上,取对数后分布接近正态,误差评估也更合理。
from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestRegressor from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error, r2_score import numpy as np features = ["area", "house_age", "is_south", "floor_code"] X = df[features].copy() X["total_price_log"] = np.log1p(df["total_price"]) y = X.pop("total_price_log") X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) lr = LinearRegression() lr.fit(X_train, y_train) y_pred_lr = lr.predict(X_test) print("Linear RMSE:", np.sqrt(mean_squared_error(y_test, y_pred_lr)), "R2:", r2_score(y_test, y_pred_lr)) rf = RandomForestRegressor(n_estimators=200, max_depth=8, random_state=42) rf.fit(X_train, y_train) y_pred_rf = rf.predict(X_test) print("RF RMSE:", np.sqrt(mean_squared_error(y_test, y_pred_rf)), "R2:", r2_score(y_test, y_pred_rf))参数说明:random_state=42是为了让结果可复现,答辩时两次跑的结果不一样会很尴尬;test_size=0.2在几百条数据下合理,数据更少时改 0.3;随机森林的max_depth=8是为了控制过拟合,小样本上树太深时 R² 会虚高。评估指标统一看 log 尺度下的 RMSE,不要只报 R²,R² 在样本量小时参考意义有限,RMSE 才能反映“预测一套 400 万的房,平均误差多少万”。
模型部分到这里足够支撑一个高分项目:有基准模型(线性回归)、有提升模型(随机森林)、有指标比较。真正让项目区分度变高的,是把“哪些特征重要、为什么重要”讲清楚——这部分放进文档比放进代码更值钱。
5. 避坑与常见问题:爬虫、清洗、评估三个环节最容易翻车的 5 个点
避坑这一章我单独拎出来写,原因很简单:这个项目里代码本身不难,真正消耗时间的是各种环境、结构和数据上的意外。我见过不少做 Python 数据分析课设的同学,翻车点高度集中在这五个地方,每个都能让人白折腾一个晚上。
5.1 反爬:连续抓取几十页后全部超时
现象:脚本前几十页抓得很快,到一定页数后 requests 开始抛超时异常,或者返回的 HTML 里找不到任何.sellListContent节点,看起来像页面改版了。
原因:列表页有基于 IP 的访问频控,短时间内请求数超过阈值就进入临时限制。很多脚本没加 sleep,甚至用了多线程,通常几百个请求就会触发。
解决:每次请求之间至少time.sleep(1),数据量少时 2 秒更稳;分页循环里加一个简单的重试逻辑,连续失败三次就退出而不是无限重试;一次只抓一个城市的前 20 到 30 页,完全够分析用了。不推荐也不需要用任何绕过限流的方案,学习项目里把频率降下来,比什么技巧都管用。
5.2 页面结构一改,字段全错位
现象:昨天跑得好好的脚本,今天抓下来的house_info里面积变成了朝向,朝向变成了楼龄。
原因:目标页面的 DOM 结构调整了,字段顺序或 class 名称变了。这是爬虫类项目最常见的“结构漂移”问题,平台改版频率比想象中高。
解决:把页面解析逻辑收敛到一个函数里,页面变化时只改一处;每次跑完检查字段取值分布,面积列如果出现非数值,立刻停下来;抓取时间精确到天记录在数据文件里,方便追溯。还有一个实用习惯:把原始 HTML 存一份到本地,字段错乱时打开看结构,比反复请求页面高效得多。
5.3 假房源与离群点,比缺数据更麻烦
现象:清洗后画散点图,发现左下角有一批总价 3 万、面积 200 平的点,模型拟合时 R² 一直上不去。
原因:挂牌平台存在中介用低价噱头吸引点击的虚假房源,这部分数据不是真实市场规律,属于被刻意制造出来的离群点。
解决:先按分位数做标记而不是直接删。比如总价低于 1% 分位或单价低于同商圈均价 50% 的行,加一列suspicious=1;建模时跑一个不含噪声的版本做对比,如果 R² 明显上升,就在文档的数据质量说明里写明处理方式。答辩时主动交代这批数据怎么处理的,比被提问时支支吾吾强得多。
5.4 总价不取对数直接回归,结果惨不忍睹
现象:线性回归跑完,RMSE 大得离谱,预测出来甚至出现负的总价。
原因:总价分布严重右偏,不满足线性回归对误差正态性的假设,模型被少部分高价房主导,普通房源的误差反而很大。
解决:目标值从一开始就用np.log1p取对数,误差评估都在对数空间做,展示结果时再用np.expm1还原成真实价格单位。另一个细节是评估指标不要只报 R²,log 尺度下的 RMSE 要一起报,两个指标结合才能看出模型在普通价位段表现如何。这不算奇技淫巧,几乎所有房价预测项目都是标准操作。
5.5 Windows 下中文路径和编码问题,环境一换就全崩
现象:在 Mac 上跑得好好的项目,拷到 Windows 一运行就报UnicodeDecodeError,CSV 用 Excel 打开一片乱码。
原因:Windows 默认编码是 GBK,读取没指定 encoding 的 utf-8 文件直接报错;保存时用了 utf-8 而不是 utf-8-sig,Excel 不认缺失 BOM 的 utf-8。
解决:所有pd.read_csv和open都显式传encoding="utf-8-sig"或"utf-8",保存统一用utf-8-sig;项目根目录准备requirements.txt并写清 Python 版本;用 PyCharm 或 VSCode 打开项目时把文件编码统一设成 UTF-8,避免中文注释变成乱码。Python 安装本身不难,难的是装完之后这套环境和编码的坑。还有一个最容易忽略的:项目路径不要带中文和空格,Windows 下一旦路径里有中文,某些库会报一些玄学级错误。
这五条不是每个项目都会全遇到,但反爬和页面结构两条出现的概率最高。遇到时先别急着改代码,把现象截图、把报错信息贴到文档里,再对照原因逐条排查,大多数都能在半小时内定位。
6. 文档、PPT 与答辩:把分析结果做成一份高分交付
项目代码跑通只是完成了一半,另一半是文档和 PPT。README 第一屏要写清楚三件事:数据来源和抓取时间、运行环境与依赖、一键复现的步骤。数据字典单独用表格列出字段说明和类型,这是评审最想看的部分——它证明你知道每个字段在讲什么,而不是只会调库。PPT 的思路是一页讲一个洞察:第一页放单价分布,讲这个城市挂牌价的集中区间;第二页放面积-总价散点,讲刚需面积段和价格弹性;第三页放商圈对比,讲区域差异;第四页放模型结果,讲哪些特征最影响价格、误差多大。每页配一句话结论就够,别堆代码截图。
答辩演示有个我自己的血泪习惯:先准备好“已知缺陷”的表述。比如数据是挂牌价不是成交价、样本量只有几百条、模型没有做时序验证。主动讲出这些边界,比被评委问出来要体面得多,也说明你是真的理解项目,而不是只会跑代码。文档里再加一节“改进方向”,写未来可以接入爬虫定时更新、加入经纬度做空间聚类、用成交数据替换挂牌数据——这些不一定要实现,但展示了分析的延伸思考能力。
这个方向值不值得投入?如果你需要的是一个能讲清完整数据流、能现场演示交互界面的 Python 数据分析项目,二手房这个选题的性价比确实高:数据获取门槛低、字段容易解释、可视化效果好,模型也不用很复杂就能出结论。希望这些经验能帮你在复现这套项目的过程中少走几步弯路,把时间花在真正提分的文档和答辩准备上。
本文还有配套的精品资源,点击获取