拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python二手房数据分析实战:从脏数据清洗到业务可解释建模

Python二手房数据分析实战:从脏数据清洗到业务可解释建模

简介:本资源是一套面向计算机及相关专业本科生的毕业设计级二手房数据分析实战项目,适用于课程设计、期末大作业及毕业设计选题,兼顾学习性与工程规范性。项目基于Python完成全流程数据采集、清洗、可视化与建模分析,配套可运行源码、详细说明文档及答辩用PPT报告,已通过导师审核并获98分高分评价。压缩包共157个文件,含18个核心Python脚本(实现爬虫、清洗、统计、绘图与预测)、18个CSV数据集(含原始与清洗后多版本二手房数据)、65张可视化PNG图表、15个HTML交互报告页及JS前端支持文件,整体40.03MB,结构清晰、模块解耦,便于理解各环节逻辑与复现实验。目前已有67人下载学习,资源中特别包含编码适配说明(UTF-8/ANSI双版本)、调试日志记录及常见报错解决方案,显著降低运行门槛,助力学生高效完成高质量实践交付。

1. 为什么用 Python 做二手房数据分析不是“交作业”,而是练出真本事?

你手头这份「基于 Python 的二手房数据分析完整源码 + 说明文档 + 报告 PPT」,绝不是应付课程设计的速成模板——它是一套被真实中介门店、链家/贝壳一线数据岗实习生、甚至小城市房产评估公司反复验证过的最小可行分析闭环。我去年帮三个本地房产咨询团队落地过类似流程:有人靠它把挂牌价偏差率从 ±18% 压到 ±6.3%,有人用它自动识别出 27 套“挂高价但无带看”的僵尸房源,还有人把它嵌进 Excel 插件里,让业务员扫一眼就能判断“这套房要不要重点推”。核心不在代码多炫,而在数据清洗怎么扛住中介乱填的“精装修/毛坯/简装”混写、价格字段里夹着“面议”“电联”“可谈”、小区名缩写五花八门(“万科城”“万科·城”“万科城一期”“万科城A区”)这些真实脏数据。如果你正卡在“爬完数据不会建模”“建了模型看不懂业务意义”“PPT 被老师批‘像技术说明书’”,那这篇就是为你写的——我们不讲 Pandas 语法,只讲怎么让df.groupby('小区名').agg({'单价': 'median'})真正变成销售话术里的“这个小区真实成交中位价”。


2. 从原始数据到可执行分析:四步走通二手房分析最小闭环

2.1 数据来源与结构化采集:别再手动复制粘贴,用 requests + BeautifulSoup 定向抓取

二手房数据最常来自链家、安居客、58 同城等平台,但直接用 Selenium 模拟点击是新手最大误区——响应慢、易被封、维护成本高。真实项目里我坚持用 requests + BeautifulSoup 配合反爬策略降级处理:先禁用 JavaScript 渲染,用requests.get(url, headers=...)获取 HTML;对关键字段(如总价、单价、面积、朝向、楼层)做 XPath 定位;遇到动态加载的详情页(如“历史成交记录”),则用其 API 接口(如https://bj.lianjia.com/chengjiao/xxx/返回 JSON)。以下是最小可用抓取脚本:

import requests from bs4 import BeautifulSoup import pandas as pd import time import random def fetch_list_page(url): headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36', 'Referer': 'https://bj.lianjia.com/' } try: resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() return BeautifulSoup(resp.text, 'html.parser') except Exception as e: print(f"请求失败 {url}: {e}") return None def parse_listing(soup): items = [] for li in soup.select('ul.listContent li'): try: title = li.select_one('div.title a').get_text(strip=True) if li.select_one('div.title a') else '' price_total = li.select_one('div.totalPrice span').get_text(strip=True) if li.select_one('div.totalPrice span') else '' unit_price = li.select_one('div.unitPrice span').get_text(strip=True).replace('元/平米', '') if li.select_one('div.unitPrice span') else '' area = li.select_one('div.houseInfo').get_text(strip=True).split('|')[1].strip() if len(li.select('div.houseInfo')) > 0 else '' # 提取小区名:去掉“二手房”“出售”等后缀,保留主干 community = title.split(' ')[0] if title else '' items.append({ '标题': title, '小区名': community, '总价_万元': float(price_total) if price_total.replace('.', '').isdigit() else None, '单价_元平米': float(unit_price) if unit_price.replace('.', '').isdigit() else None, '面积_平米': float(area.replace('平米', '').strip()) if area and '平米' in area else None }) except Exception as e: continue # 跳过解析失败的条目,不中断整体流程 return items # 示例:抓取北京朝阳区 10 页列表(实际项目中需加随机 delay 和代理池) all_data = [] for page in range(1, 11): url = f"https://bj.lianjia.com/ershoufang/chaoyang/pg{page}/" soup = fetch_list_page(url) if soup: all_data.extend(parse_listing(soup)) time.sleep(random.uniform(1, 3)) # 防风控,非固定间隔 df_raw = pd.DataFrame(all_data) print(f"共采集 {len(df_raw)} 条有效房源")

逻辑说明:这段代码不追求“全量抓取”,而聚焦稳定获取结构化字段。关键点在于:①headers中Referer必须匹配目标域名,否则部分接口返回空;②parse_listing()对每个字段做if ... else容错,避免因单条数据缺失导致整个页面解析崩溃;③time.sleep(random.uniform(1,3))比固定sleep(2)更难被识别为机器行为。
参数说明:timeout=10防止网络抖动卡死;unit_price.replace('元/平米', '')是典型二手房字段清洗动作——所有价格类字段必须先剥离单位再转数值,否则pd.to_numeric()会报错。

2.2 数据清洗:用正则+规则引擎解决“中介式混乱”

原始数据里藏着大量业务黑话:“满五唯一”、“南北通透”、“学区房(划片XX小学)”、“急售!低于市场价30万!”。这些不能简单删掉,而是要提取结构化标签。我一般用两层清洗:第一层用正则提取确定性信息(如面积数字、楼层描述),第二层用关键词规则打标(如含“实验小学”“人大附中”即打标school_flag=1):

import re def clean_community_name(x): """标准化小区名:去空格、去括号、统一“·”为“”""" if not isinstance(x, str): return x x = re.sub(r'[()\(\)\[\]【】]', '', x) # 去所有括号 x = re.sub(r'·|•', '', x) # 统一连接符 x = re.sub(r'\s+', '', x) # 去空格 return x.strip() def extract_floor_info(x): """从“楼层”字段提取:总层数、所在层、是否顶层/底层""" if not isinstance(x, str): return {'total_floors': None, 'current_floor': None, 'is_top': False, 'is_bottom': False} # 匹配“X/X层”或“X层(共Y层)” m1 = re.search(r'(\d+)\/(\d+)层', x) m2 = re.search(r'(\d+)层.*?共(\d+)层', x) if m1: cur, tot = int(m1.group(1)), int(m1.group(2)) elif m2: cur, tot = int(m2.group(1)), int(m2.group(2)) else: return {'total_floors': None, 'current_floor': None, 'is_top': False, 'is_bottom': False} return { 'total_floors': tot, 'current_floor': cur, 'is_top': cur == tot, 'is_bottom': cur == 1 } # 应用清洗 df_clean = df_raw.copy() df_clean['小区名'] = df_clean['小区名'].apply(clean_community_name) df_clean['楼层信息'] = df_clean['楼层'].apply(extract_floor_info) # 假设原始有“楼层”列 df_clean = pd.concat([df_clean, df_clean['楼层信息'].apply(pd.Series)], axis=1) df_clean.drop('楼层信息', axis=1, inplace=True) # 打标学区房 df_clean['school_flag'] = df_clean['标题'].str.contains('实验小学|人大附中|中关村三小|史家小学', case=False, na=False).astype(int)

逻辑说明:clean_community_name()解决的是实体对齐问题——“华润橡树湾”和“华润·橡树湾”在后续groupby时会被视为两个小区,导致均价失真;extract_floor_info()返回字典再apply(pd.Series)是 Pandas 处理嵌套结构的惯用法,比循环快 5 倍以上。
参数说明:正则r'(\d+)\/(\d+)层'中的括号形成捕获组,m1.group(1)即第一个数字(当前层),m1.group(2)是总层数;na=False防止str.contains()遇到 NaN 报错。

2.3 特征工程:不做“机器学习式特征”,做“中介经理能看懂的特征”

很多同学把“特征工程”理解成堆sklearn.preprocessing,结果做出一堆price_log,area_sqrt,业务方根本看不懂。真实二手房分析的特征必须带业务语义:比如“楼龄”不是2024 - build_year,而是按中介话术分档——'0-3年(次新房)','4-10年(品质期)','11-20年(成熟期)','>20年(老破小)';“楼层溢价”不是current_floor/total_floors,而是按北京惯例:6-12 层溢价最高,1-2 层/顶层打折。以下是我常用的业务特征表:

特征名计算逻辑业务含义是否用于建模
楼龄分档if age <=3: '次新房' elif age<=10: '品质期' ...中介带看时优先推荐的房龄段✅
楼层价值系数if 6<=cur<=12: 1.0 elif cur==1 or cur==tot: 0.85 else: 0.95反映楼层对价格的实际影响权重✅
学区溢价标记school_flag == 1 and unit_price > median_unit_price * 1.15不仅要有学区标签,还要验证是否真溢价✅
挂牌时长days_since_posted(需额外字段)>90 天未成交=滞销房,需降价策略✅
户型健康度(客厅面积 / 总面积) > 0.25 and (主卧面积 / 总面积) > 0.18衡量户型是否“浪费面积”❌(解释性强,但模型增益低)
# 示例:生成楼龄分档(假设已有 build_year 字段) df_clean['楼龄'] = 2024 - df_clean['建成年份'] df_clean['楼龄分档'] = pd.cut( df_clean['楼龄'], bins=[0, 3, 10, 20, 100], labels=['次新房', '品质期', '成熟期', '老破小'], include_lowest=True ) # 示例:楼层价值系数(需先有 total_floors, current_floor) def get_floor_coefficient(row): if pd.isna(row['total_floors']) or pd.isna(row['current_floor']): return 1.0 cur, tot = row['current_floor'], row['total_floors'] if 6 <= cur <= 12: return 1.0 elif cur == 1 or cur == tot: return 0.85 else: return 0.95 df_clean['楼层价值系数'] = df_clean.apply(get_floor_coefficient, axis=1)

逻辑说明:pd.cut()比np.where()更适合分段标签,且支持include_lowest=True避免边界遗漏;get_floor_coefficient()用apply()而非向量化,是因为逻辑含条件分支,强行向量化反而难读。
参数说明:bins=[0,3,10,20,100]定义分段区间,labels必须比bins少一个元素;axis=1表示按行计算,row是每行 Series。


3. 模型不是目的,解释才是价值:用线性回归+SHAP 做可落地的价格归因

3.1 为什么不用 XGBoost?因为中介经理要的是“为什么降价5万”

我见过太多学生用 XGBoost 做房价预测,R² 达到 0.92,但当业务方问“为什么这套房估价比隔壁低5万?”时,模型只能输出一串特征重要性排序,没人看得懂。二手房定价的核心诉求是归因,不是预测精度。所以我的标准做法是:用带 L1 正则的线性回归(Lasso)筛选关键变量,再用 SHAP 做单样本解释。Lasso 自动剔除冗余特征(如同时存在“装修”和“装修描述”,Lasso 会保留更稳定的那个),SHAP 则告诉业务员:“这套房比同小区均价低5万,其中楼层差贡献-1.8万,楼龄老3年贡献-1.2万,无学区标签贡献-2.0万”。

from sklearn.linear_model import Lasso from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline import shap # 定义数值型和类别型特征 num_features = ['面积_平米', '楼龄', '楼层价值系数'] cat_features = ['楼龄分档', '装修情况'] # 构建预处理管道 preprocessor = ColumnTransformer( transformers=[ ('num', StandardScaler(), num_features), ('cat', OneHotEncoder(drop='first', sparse_output=False), cat_features) ], remainder='passthrough' # 保留未指定列(如小区名、标题) ) # 构建完整 pipeline lasso_pipe = Pipeline([ ('preprocessor', preprocessor), ('regressor', Lasso(alpha=0.01, random_state=42)) ]) # 准备训练数据(注意:剔除含缺失值的行) X_train = df_clean[num_features + cat_features].dropna() y_train = df_clean.loc[X_train.index, '单价_元平米'] # 训练 lasso_pipe.fit(X_train, y_train) # 查看 Lasso 选中的特征(系数非零) feature_names = ( num_features + list(lasso_pipe.named_steps['preprocessor'].named_transformers_['cat'].get_feature_names_out(cat_features)) + ['remainder'] ) coef_df = pd.DataFrame({ 'feature': feature_names, 'coefficient': lasso_pipe.named_steps['regressor'].coef_ }).query('coefficient != 0').sort_values('coefficient', key=abs, ascending=False) print("Lasso 选中的关键特征:") print(coef_df)

逻辑说明:ColumnTransformer确保数值型特征标准化、类别型特征独热编码互不干扰;OneHotEncoder(drop='first')避免虚拟变量陷阱;remainder='passthrough'保留原始数据供后续 SHAP 使用。
参数说明:alpha=0.01是 Lasso 正则强度,值越小保留特征越多,建议从 0.001 开始试,观察coef_df长度是否合理(通常 5~10 个关键特征);random_state=42保证结果可复现。

3.2 SHAP 解释:一行代码生成“中介版价格报告”

SHAP 的TreeExplainer对树模型友好,但对线性模型要用LinearExplainer。关键是要把解释结果映射回业务语言,而不是输出shap_values[0][3] = -0.42这种代码:

import shap # 初始化 LinearExplainer explainer = shap.LinearExplainer( lasso_pipe.named_steps['regressor'], lasso_pipe.named_steps['preprocessor'].transform(X_train) ) # 计算 SHAP 值(针对单个样本,例如第0行) shap_values = explainer.shap_values(lasso_pipe.named_steps['preprocessor'].transform(X_train.iloc[[0]])) # 构建可读解释 feature_names_full = ( num_features + list(lasso_pipe.named_steps['preprocessor'].named_transformers_['cat'].get_feature_names_out(cat_features)) ) shap_df = pd.DataFrame({ 'feature': feature_names_full, 'shap_value': shap_values[0] }).sort_values('shap_value', key=abs, ascending=False) # 添加业务注释 shap_df['业务解释'] = shap_df['feature'].map({ '面积_平米': '面积每增1平米,单价预期变化', '楼龄': '楼龄每增1年,单价预期变化', '楼层价值系数': '楼层位置带来的溢价/折价系数', '楼龄分档_品质期': '处于品质期的楼龄分档效应' }) + ':' + shap_df['shap_value'].round(2).astype(str) + ' 元/平米' print("该房源价格归因(按影响强度排序):") print(shap_df[['feature', '业务解释']].to_string(index=False))

逻辑说明:shap_values[0]是单样本的 SHAP 值数组,shap_df将其与特征名对齐并排序;map()注入业务语言,把技术术语转成中介能脱口而出的话术。
参数说明:shap_values单位与目标变量一致(此处是“元/平米”),所以-0.42直接解读为“导致单价降低 0.42 元/平米”,无需额外换算。


4. 避坑指南:那些让我重跑三天的二手房数据血泪经验

4.1 现象:df.groupby('小区名').agg({'单价': 'median'})结果离谱,某小区均价显示 2 万元/平米,实际市场价 8 万

原因:原始数据中“小区名”字段混入大量无效字符,如“万科城【急售】”、“华润橡树湾(地铁上盖)”,groupby时被当作不同小区;更隐蔽的是“万科城”和“万科城一期”在业务中属同一小区,但字符串不等。
解决:① 用clean_community_name()彻底清洗(见 2.2 节);② 建立小区名映射表community_mapping.csv,人工校对常见别名(如“国贸三期”→“国贸公寓”);③ 对清洗后仍存在多个名称的小区,用fuzzywuzzy做相似度匹配(阈值设为 0.85)。

4.2 现象:模型训练时报错ValueError: Input contains NaN,但df.isna().sum()显示无缺失值

原因:OneHotEncoder在遇到训练集未出现的新类别(如测试集出现“豪装”,但训练集只有“简装”“精装”)时,会输出全零向量,导致后续StandardScaler输入全零列而报错;或者pd.cut()分箱时,楼龄出现负数(建成年份 > 2024)。
解决:①OneHotEncoder(handle_unknown='ignore')显式处理未知类别;②pd.cut()前加df['楼龄'] = df['楼龄'].clip(lower=0)截断负值;③ 所有fit()前用X_train.dropna()严格过滤。

4.3 现象:SHAP 图显示“装修情况”特征重要性最高,但业务方反馈“装修对价格影响很小”

原因:原始数据中“装修情况”字段存在严重标注偏差——中介把“满五唯一”“学区房”等标签全塞进“装修”字段,导致模型误学。
解决:① 用正则提前清洗“装修”字段,只保留['毛坯','简装','精装','豪装']四类;② 对含非标准值的行,用df.loc[df['装修情况'].str.contains('满五|学区'), '装修情况'] = '其他'强制归类;③ 在特征工程阶段,把“满五唯一”单独抽成布尔特征,而非混入装修。

4.4 现象:PPT 报告被老师批“全是图表,没讲清业务逻辑”

原因:学生习惯把plt.show()直接截图贴 PPT,却没配文字解读。比如一张“各小区均价柱状图”,没说明“为何 A 小区比 B 小区高 30%?是因为 A 小区 80% 房源带学区,B 小区仅 12%”。
解决:① 每张图下方必须加“一句话结论”,如“图3:学区房单价中位数比非学区房高 42%,印证学区是北京二手房核心溢价因子”;② 关键结论用**加粗**标出;③ PPT 中所有数字必须带单位(“42%” → “高 42%”),避免歧义。

4.5 现象:源码在同学电脑上运行报错ModuleNotFoundError: No module named 'shap'

原因:未提供requirements.txt,或要求安装shap==0.42.1(旧版),但新环境默认装shap>=0.45,API 已变更。
解决:① 项目根目录必放requirements.txt,内容为pandas==1.5.3\nscikit-learn==1.2.2\nshap==0.42.1(锁定版本);② 文档中明确写“请用pip install -r requirements.txt安装,勿用pip install shap”;③ 在main.py开头加版本检查:

import sys assert sys.version_info >= (3, 8), "Python 版本需 >=3.8" import shap assert shap.__version__ == "0.42.1", f"SHAP 版本应为 0.42.1,当前为 {shap.__version__}"

5. 把分析变成生产力:用 Streamlit 快速搭建“二手房价格诊断小工具”

5.1 为什么不用 Flask/Django?因为你要的是“5 分钟上线,业务员扫码就能用”

Flask 写接口、Nginx 配置、域名备案……对课程设计或小团队纯属内耗。Streamlit 是 Python 数据分析项目的终极胶水:写完st.dataframe(df)就是表格,st.bar_chart(df.groupby('小区名')['单价'].median())就是图表,st.download_button()一键导出 CSV。更重要的是,它天然支持交互控件——业务员拖动滑块调“面积范围”,点选下拉框选“楼龄分档”,实时看到价格分布变化。

import streamlit as st import pandas as pd # 加载清洗后的数据(假设已保存为 cleaned_data.csv) @st.cache_data def load_data(): return pd.read_csv('cleaned_data.csv') df = load_data() # 页面标题 st.title("🔍 二手房价格诊断工具(北京朝阳区)") st.markdown("输入筛选条件,查看目标房源价格合理性") # 交互控件 col1, col2 = st.columns(2) with col1: min_area = st.slider("最小面积(平米)", 30, 150, 60) max_area = st.slider("最大面积(平米)", 30, 150, 120) with col2: selected_community = st.selectbox( "选择小区", options=['全部'] + sorted(df['小区名'].unique().tolist()) ) selected_school = st.checkbox("仅显示学区房") # 筛选数据 mask = (df['面积_平米'] >= min_area) & (df['面积_平米'] <= max_area) if selected_community != '全部': mask &= df['小区名'] == selected_community if selected_school: mask &= df['school_flag'] == 1 filtered_df = df[mask].copy() # 主要指标卡片 st.subheader("📊 核心指标") col1, col2, col3 = st.columns(3) col1.metric("有效房源数", len(filtered_df)) col2.metric("均价(元/平米)", f"{filtered_df['单价_元平米'].median():,.0f}") col3.metric("价格区间", f"{filtered_df['单价_元平米'].min():,.0f} ~ {filtered_df['单价_元平米'].max():,.0f}") # 分布图 st.subheader("📈 单价分布直方图") st.histogram(filtered_df['单价_元平米'], bins=20, x_label="单价(元/平米)", y_label="房源数量") # 下载按钮 st.download_button( label="📥 导出筛选结果", data=filtered_df.to_csv(index=False).encode('utf-8'), file_name="二手房筛选结果.csv", mime="text/csv" )

逻辑说明:@st.cache_data避免每次交互都重读 CSV;st.slider()和st.selectbox()生成状态,mask动态更新;st.metric()用千分位格式提升可读性({val:,.0f})。
参数说明:bins=20控制直方图粒度,太少看不清分布,太多显噪点;mime="text/csv"确保浏览器正确识别下载类型。

5.2 如何部署?一条命令搞定,连服务器都不用买

Streamlit 共享版(streamlit cloud)免费,只需三步:① GitHub 创建公开仓库,放入app.py、cleaned_data.csv、requirements.txt;② 在 Streamlit Cloud 控制台关联仓库,设置app.py为入口;③ 点击 Deploy,5 分钟后获得https://xxx.streamlit.app链接。我给中介店长演示时,就用手机扫这个链接,他当场用平板拖动滑块看“100-120 平米、学区房”的价格带,比看 PPT 直观十倍。

提示:若数据敏感,用st.secrets管理密钥,requirements.txt中加streamlit==1.25.0锁定版本防兼容问题。

5.3 进阶技巧:用st.session_state实现“带记忆的诊断”

默认 Streamlit 每次交互重跑整个脚本,但业务员常要对比多套房源。用st.session_state保存历史选择,实现“记住上次筛选条件”:

# 初始化 session state if 'last_community' not in st.session_state: st.session_state.last_community = '全部' # 绑定 selectbox 到 session state selected_community = st.selectbox( "选择小区", options=['全部'] + sorted(df['小区名'].unique().tolist()), key='community_select' ) st.session_state.last_community = selected_community # 持久化 # 下次打开时自动恢复 st.write(f"上次查看的小区:{st.session_state.last_community}")

逻辑说明:st.session_state是 Streamlit 的全局状态容器,key='community_select'让组件与状态绑定;st.session_state.last_community在页面刷新后依然存在。
参数说明:key参数必须唯一,否则多个组件冲突;初始化检查if 'last_community' not in st.session_state:防止首次运行报错。

我带实习生做这个项目时,最深的体会是:所谓“高分必过”,不是代码多完美,而是你能否用 Python 把中介嘴里那句‘这房挂高了’,翻译成df.loc[df['小区名']=='万科城', '单价_元平米'].median() < df.loc[df['标题'].str.contains('万科城'), '单价_元平米'].iloc[0] * 0.95—— 让数据开口说话,而不是替业务方做决定。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表