简介:这是一份面向计算机相关专业学生与项目实战学习者的Python数据分析资源,对应课程设计与期末大作业场景,围绕二手房数据完成采集、清洗、统计分析与可视化全流程,可作为高分作业参考模板。压缩包共190个文件,约48.21MB,包含18个py源码文件、18个csv数据文件(含原始与清洗版本)、15个html图表页面、65个png可视化结果,以及docx分析报告、pptx展示文稿和说明文档,覆盖代码、数据、报告与演示材料。已有65人学习下载。读者可直接获得可运行的完整项目源码与多版本数据集,对照分析报告理解指标解读与结论撰写思路,并借助图表与展示文件快速梳理汇报结构,适合需要快速上手数据分析实战、查漏补缺的学习者。
1. 二手房数据分析项目:从爬虫到报告,一套能跑通的 Python 全流程
二手房市场有个特点,同一套房源在不同平台挂的价格能差出十几万,挂牌价和成交价之间还藏着大量水分。想搞清楚一个城市的真实行情,靠人工刷页面不现实,用 Excel 手动整理几千条数据也迟早翻车。这个项目要解决的就是这件事:用 Python 把房源数据抓下来、清洗干净、算出各区域的真实均价和议价空间,最后生成一份能直接看的分析报告。整套流程包含源码、数据集和报告模板,适合会一点 Python 基础、想拿一个完整项目练手的人,也适合做房产中介、投资分析的朋友拿来改改就能用。核心链路是四步:采集、清洗、分析、出报告,每一步都有具体的坑要绕。
2. 数据采集:用 requests + BeautifulSoup 抓房源列表
2.1 为什么选静态页面抓取而不是上 Selenium
二手房数据采集最常见的做法有两种:一种是直接请求页面 HTML 然后解析,另一种是用浏览器自动化工具模拟点击翻页。我一般优先选前者,原因很直接——速度快、资源占用低、代码好维护。Selenium 启动一个浏览器实例就要几百兆内存,抓几千条数据下来,光是等待页面渲染的时间就够你喝杯咖啡了。
但这里有个前提:目标页面的房源列表是服务端渲染的,也就是你右键查看网页源代码能看到房源标题和价格。如果页面是前端 JS 动态加载的,那 requests 拿到的 HTML 里就是空的,这时候才需要考虑 Selenium 或者直接找后端 API。
判断方法很简单,用下面这段代码试一下:
import requests url = "https://example.com/ershoufang/pg1/" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" } resp = requests.get(url, headers=headers, timeout=10) print(resp.status_code) print(len(resp.text)) # 在返回的文本里搜索一个你知道肯定存在的房源标题关键词 print("房源关键词" in resp.text)如果最后一行输出True,说明数据在 HTML 里,可以走静态解析路线。如果输出False,要么换思路,要么去找页面背后的数据接口。
2.2 房源列表页的解析脚本与字段提取
确认页面结构后,用 BeautifulSoup 提取房源链接、标题、总价、单价、区域、楼层、朝向等字段。下面是一个可复用的解析函数:
from bs4 import BeautifulSoup import re def parse_list_page(html): soup = BeautifulSoup(html, "html.parser") items = [] for li in soup.select(".sellListContent li"): try: title = li.select_one(".title a").get_text(strip=True) link = li.select_one(".title a")["href"] total_price = li.select_one(".totalPrice span").get_text(strip=True) unit_price = li.select_one(".unitPrice span").get_text(strip=True) # 单价字段通常带"元/平米"后缀,用正则去掉 unit_price = re.sub(r"[^\d]", "", unit_price) region = li.select_one(".positionInfo a").get_text(strip=True) items.append({ "title": title, "link": link, "total_price": float(total_price), "unit_price": int(unit_price), "region": region }) except Exception as e: # 单条解析失败不影响整体,记录后跳过 print(f"解析跳过一条: {e}") continue return items这段代码的关键点有三个。第一,select用的 CSS 选择器要跟目标页面实际结构对齐,不同平台类名不一样,需要自己打开开发者工具确认。第二,单价字段里的“元/平米”必须去掉,否则后面转 int 会报错。第三,用 try-except 包住单条解析,因为列表页里偶尔会混入广告位或格式异常的条目,一条出错不能让整个采集中断。
2.3 翻页策略与请求频率控制
翻页本身不难,把页码拼进 URL 循环请求就行。真正需要注意的是请求频率。我见过太多人写个 for 循环不加延时,几十页请求发出去,轻则被封 IP,重则收到警告。稳妥的做法是每次请求之间随机等待 2 到 5 秒,并且每抓完一页就把数据追加写入 CSV,避免程序中途挂掉导致前面抓的全丢。
import time import random import csv def crawl_pages(base_url, max_page=50): all_items = [] for page in range(1, max_page + 1): url = base_url.format(page=page) try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code != 200: print(f"第{page}页返回{resp.status_code},停止") break items = parse_list_page(resp.text) if not items: print(f"第{page}页无数据,可能已到末尾") break all_items.extend(items) # 每页抓完立即落盘 with open("houses.csv", "a", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=items[0].keys()) writer.writerows(items) print(f"第{page}页完成,累计{len(all_items)}条") except requests.RequestException as e: print(f"第{page}页请求异常: {e}") time.sleep(random.uniform(2, 5)) return all_items参数方面,max_page根据目标城市实际页数设,一般二线城市在 50 到 100 页之间。timeout设 10 秒足够,太短容易误判超时,太长卡住浪费时间。随机延时范围 2 到 5 秒是经验值,再短就有风险,再长效率太低。
3. 数据清洗:把脏数据变成能算的表格
3.1 二手房数据里最常见的五类脏数据
抓下来的原始数据几乎不可能直接用来分析。我整理过十几个城市的房源数据,以下五类问题几乎每次都会遇到:
第一,面积字段缺失或格式混乱。有的写“89.5平米”,有的写“89.5㎡”,还有的干脆是空字符串。第二,楼层信息五花八门,“中楼层/共18层”“低楼层(共6层)”“地下室”混在一起,需要拆出所在楼层和总楼层两个字段。第三,朝向字段有“南”“南北”“东南”“东西”等多种组合,分析时通常只关心是否朝南。第四,同一套房源可能被重复抓取,需要按链接或标题去重。第五,总价和单价之间存在不一致,比如总价 200 万、面积 89 平,算出来单价应该是 22472 元/平,但页面上写的单价可能是 22000,这种偏差需要标记出来。
3.2 用 pandas 做清洗的完整流程
清洗环节用 pandas 最顺手。下面这段代码覆盖了去重、缺失值处理、字段拆分和类型转换:
import pandas as pd import numpy as np df = pd.read_csv("houses.csv") # 1. 按链接去重 df = df.drop_duplicates(subset=["link"]) # 2. 面积字段清洗:提取数字 df["area"] = df["area"].astype(str).str.extract(r"(\d+\.?\d*)") df["area"] = pd.to_numeric(df["area"], errors="coerce") # 3. 楼层拆分 df["floor_info"] = df["floor"].astype(str) df["total_floors"] = df["floor_info"].str.extract(r"共(\d+)层") df["total_floors"] = pd.to_numeric(df["total_floors"], errors="coerce") df["is_low"] = df["floor_info"].str.contains("低楼层").astype(int) df["is_high"] = df["floor_info"].str.contains("高楼层").astype(int) # 4. 朝向简化:是否朝南 df["facing_south"] = df["orientation"].astype(str).str.contains("南").astype(int) # 5. 删除关键字段缺失的行 df = df.dropna(subset=["area", "total_price", "unit_price"]) df = df[df["area"] > 10] # 面积小于10平的通常是车位或异常数据 # 6. 重新计算单价,和页面单价对比 df["calc_unit_price"] = (df["total_price"] * 10000 / df["area"]).round(0) df["price_diff"] = abs(df["calc_unit_price"] - df["unit_price"]) df["price_abnormal"] = (df["price_diff"] > df["unit_price"] * 0.1).astype(int) print(f"清洗后剩余{len(df)}条,异常单价{df['price_abnormal'].sum()}条") df.to_csv("houses_clean.csv", index=False)几个参数需要说明。面积提取的正则(\d+\.?\d*)能匹配整数和小数,但遇到“暂无数据”这类文本会返回 NaN,后面用dropna处理。单价偏差阈值设 10%,超过这个比例就标记为异常,分析时可以单独看这批数据是不是录入错误。面积下限设 10 平米,是因为二手房列表里经常混入车位、储藏室,这些数据会严重拉低区域均价。
3.3 清洗后的数据质量校验
清洗完不能直接开算,得先做一轮校验。我一般会检查四个指标:总记录数、各区域记录数分布、单价的中位数和四分位距、异常单价占比。如果某个区域只有个位数记录,那这个区域的均价就没有代表性,分析报告里要标注出来。单价四分位距过大,说明数据里混入了豪宅或老破小,需要分区域分价位段来看。
# 区域分布检查 region_counts = df["region"].value_counts() print(region_counts[region_counts < 10]) # 少于10条的区域 # 单价分布 print(df["unit_price"].describe(percentiles=[0.25, 0.5, 0.75])) # 按区域看均价 region_price = df.groupby("region")["unit_price"].agg(["median", "count"]) region_price = region_price[region_price["count"] >= 10] print(region_price.sort_values("median", ascending=False))这段校验代码输出的结果直接决定了后面分析报告的可信度。记录数不足的区域要么合并到相邻区域,要么在报告中注明“样本量不足,仅供参考”。
4. 分析建模:区域均价、议价空间与可视化
4.1 算清楚三个核心指标
二手房分析最常被问到的三个问题是:这个区域均价多少?挂牌价和成交价差多少?什么样的房子单价最高?对应到数据上就是三个指标:区域单价中位数、议价空间比例、单价影响因素排序。
区域单价中位数比均值更靠谱,因为均价容易被少数高价房源拉偏。议价空间需要成交价数据,如果只有挂牌价,可以用同一小区不同房源的挂牌价差异来近似。单价影响因素可以用简单的相关性分析,看面积、楼层、朝向、区域分别和单价的相关程度。
# 区域单价中位数 region_median = df.groupby("region")["unit_price"].median().sort_values(ascending=False) # 议价空间近似:同一区域内的单价标准差除以中位数 region_cv = df.groupby("region")["unit_price"].agg( lambda x: x.std() / x.median() ).sort_values(ascending=False) # 相关性分析 corr_cols = ["area", "total_floors", "is_low", "is_high", "facing_south", "unit_price"] corr_matrix = df[corr_cols].corr() print(corr_matrix["unit_price"].sort_values(ascending=False))region_cv越大,说明这个区域房源价格越分散,议价空间可能越大。相关性分析里,如果area和unit_price呈负相关,说明小户型单价更高,这在核心城区很常见。
4.2 用 matplotlib 出三张关键图表
报告里至少要有三张图:区域均价柱状图、单价分布箱线图、面积与单价散点图。代码不复杂,关键是图要能说明问题。
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False # 图1:区域均价 fig, ax = plt.subplots(figsize=(10, 6)) region_median.plot(kind="bar", ax=ax, color="#4C72B0") ax.set_title("各区域二手房单价中位数(元/平米)") ax.set_ylabel("单价") plt.tight_layout() plt.savefig("region_price.png", dpi=150) # 图2:单价分布箱线图 fig, ax = plt.subplots(figsize=(10, 6)) df.boxplot(column="unit_price", by="region", ax=ax, rot=45) ax.set_title("各区域单价分布") ax.set_ylabel("单价") plt.suptitle("") plt.tight_layout() plt.savefig("price_box.png", dpi=150) # 图3:面积与单价散点 fig, ax = plt.subplots(figsize=(8, 6)) ax.scatter(df["area"], df["unit_price"], alpha=0.3, s=10) ax.set_xlabel("面积(平米)") ax.set_ylabel("单价(元/平米)") ax.set_title("面积与单价关系") plt.tight_layout() plt.savefig("area_price.png", dpi=150)中文字体那两行必须加,否则图表里的中文会变成方块。alpha=0.3让散点半透明,数据量大时能看出密度分布。dpi=150保证图片清晰度够放进报告。
4.3 把分析结果落成一份可读的报告
报告不需要花哨,用 Markdown 或者 Word 都行,结构建议固定为:数据概览、区域对比、价格影响因素、异常数据说明、结论。数据概览写清楚采集时间、样本量、覆盖区域。区域对比放柱状图和表格。价格影响因素放相关性结果和散点图。异常数据说明列出被标记的异常单价条目数量和占比。结论部分用两三句话总结哪个区域性价比高、什么户型单价偏高。
如果想让报告自动生成,可以用jinja2模板把分析结果填进去,再转成 HTML 或 PDF。这一步不是必须的,但做一次之后,以后换城市换数据,改几个参数就能重新出一份报告,省事很多。
5. 避坑指南:数据采集与分析中的五个血泪教训
5.1 页面结构一变,选择器全部失效
现象:昨天还能正常跑的爬虫,今天返回的房源数量变成零,或者解析出来的字段全是空值。
原因:目标网站调整了页面 HTML 结构,类名或层级变了。这是最常见也最让人头疼的问题,没有之一。
解决:不要把 CSS 选择器写死在代码里,抽出来放到配置文件或常量区。每次运行前先手动请求一页,检查关键选择器是否还能匹配到元素。更稳妥的做法是同时用多个备选选择器,按优先级依次尝试。
5.2 单价字段带单位导致类型转换报错
现象:ValueError: invalid literal for int() with base 10: '45000元/平米'。
原因:页面上的单价文本包含“元/平米”后缀,直接转 int 或 float 会失败。
解决:转换前先用正则去掉所有非数字字符。注意有些页面用“万”做单位,比如“4.5万/平米”,这种情况要先判断是否含“万”,再乘以 10000。
5.3 重复数据导致区域均价被拉偏
现象:某个小区的房源数量异常多,算出来的区域均价明显偏离市场感知。
原因:同一套房源在列表页出现多次,或者翻页时因为页面排序变化导致重复抓取。
解决:按房源链接去重是最可靠的方式。如果链接也重复,可以组合标题加总价加面积做联合去重。去重后要重新检查各区域样本量,样本太少的区域在报告中要标注。
5.4 中文字体缺失导致图表全是方块
现象:matplotlib 生成的图表里,标题和坐标轴的中文显示为一个个小方块。
原因:matplotlib 默认字体不支持中文,需要手动指定系统中已有的中文字体。
解决:在绘图前设置plt.rcParams["font.sans-serif"] = ["SimHei"],Windows 系统一般都有 SimHei。如果是 Mac 或 Linux,换成["Arial Unicode MS"]或["WenQuanYi Micro Hei"]。设置完记得加plt.rcParams["axes.unicode_minus"] = False,否则负号也会显示异常。
5.5 异常值不处理导致结论失真
现象:报告里某个区域的均价明显偏高,点进去看发现是几套别墅或学区房拉高了均值。
原因:没有对极端值做处理,少数高价房源对均值影响很大。
解决:用中位数代替均值作为区域均价的主要指标。同时可以按单价做分位数过滤,比如只保留 5% 到 95% 分位之间的数据。如果确实要分析高端市场,就单独分一个价位段来看,不要和普通住宅混在一起。
6. 进阶技巧:把项目变成可复用的分析模板
项目跑通一次不难,难的是换一个城市、换一个平台还能快速复用。我的习惯是把整个流程拆成配置驱动:城市名、目标 URL 模板、页面选择器、清洗规则、报告模板全部放到一个config.yaml里,主程序只负责按配置执行。这样下次想分析另一个城市,改配置文件就行,不用动代码。
import yaml with open("config.yaml", "r", encoding="utf-8") as f: config = yaml.safe_load(f) city = config["city"] base_url = config["base_url"] selectors = config["selectors"] # 后续采集和解析都用 config 里的值对应的config.yaml大概长这样:
city: "成都" base_url: "https://example.com/ershoufang/pg{page}/" selectors: list_item: ".sellListContent li" title: ".title a" total_price: ".totalPrice span" unit_price: ".unitPrice span" region: ".positionInfo a"另一个值得做的进阶方向是加一层简单的验证。每次分析完,自动检查几个关键指标是否在合理范围内,比如区域均价不能低于 3000 也不能高于 100000,样本量不能少于 50 条。如果触发异常,就发个提醒或者直接在报告里标红。这个习惯帮我省过好几次事,有一次数据源改版导致抓到的全是车位信息,均价算出来只有几千块,验证层直接拦住了,不然那份报告发出去就闹笑话了。
最后说一个我自己的教训:不要等到所有功能都写完再跑完整流程。我刚开始做这类项目时,习惯先把采集、清洗、分析、报告全部写完再一次性运行,结果每次都在某个环节卡住,回头改代码又要重新跑一遍。后来改成每写完一个模块就用小批量数据跑通,确认输出没问题再往下写,整体效率反而高了很多。希望帮到你。
本文还有配套的精品资源,点击获取