简介:面向数据分析与爬虫学习者的成都二手房价格分析Python实战资源,基于链家网真实交易数据,围绕二手房市场背景与房价影响因素,串联数据爬取、清洗、可视化和价格预测的完整流程。资源包共17个文件,含12个按成都主城区划分的CSV数据文件、3个Jupyter Notebook、1份Word报告和1个Python脚本,压缩包大小约6.2MB,其中数据量超过三万条,涵盖总价、单价、户型、面积、朝向、装修、电梯等多维字段。已有1227人学习/下载,适合希望用Python实战数据分析、完成课程设计或入门房价预测的读者。压缩包内data目录按锦江、双流、龙泉驿、武侯等分区存放CSV,链家网数据爬取Notebook与Python脚本演示采集过程,分析Notebook提供探索与建模流程,Word文档输出数据分析报告,对照学习可掌握数据预处理、特征探索及预测模型构建的关键技巧,对实际项目和毕设均有参考价值。
1. 从链家三万余条数据里,看成都二手房到底被什么因素定价
做房产数据分析最容易犯的错,是把“价格”当成一个孤立变量去回归、去拟合,最后模型 R² 看着还行,一到真实挂牌场景就失效。原因很简单:链家网上的二手房挂牌价是业主、中介、市场情绪三方博弈的结果,它不是纯物理属性定价,而是“房子本身”加“信息不对称”加“挂牌策略”的混合体。这次拿到的成都主城区二手房价数据集,共三万余条记录,字段覆盖了总价、单价、户型、朝向、楼层、装修、梯户比、建筑类型、交易权属、抵押信息等二十多个维度。数据量不算大,但维度足够杂,正好用来验证一个核心问题:在真实挂牌数据里,哪些变量对房价的解释力最强,哪些变量只是噪声,以及当我们用机器学习建模时,应该如何处理这些脏的、缺的、带异常值的字段。
这篇文章走完整个流程:从链家网的爬虫设计与反爬应对,到 CSV 数据的清洗与特征工程,再到价格分布的探索性可视化和多模型对比预测。每一步都会给出可复现的代码和参数说明,最后落在特征重要性和误差分析上。适合正在做数据分析课程设计、房价预测项目,或者想了解真实二手房数据长什么样的人。
2. 链家网二手房数据爬虫:请求构造、解析链路与反爬策略
2.1 爬虫的整体设计:先确定 URL 规律,再写解析逻辑
链家二手房列表页的 URL 结构相对固定。以成都为例,主城区各区域的二手房列表页遵循这种模式:
https://cd.lianjia.com/ershoufang/jinjiang/pg{n}/ https://cd.lianjia.com/ershoufang/qingyang/pg{n}/其中jinjiang、qingyang是区域拼音,pg{n}是页码。爬虫的核心思路并不复杂:遍历区域拼音列表,对每个区域从第 1 页爬到最后一页或设定上限,解析列表页中的房源卡片,提取详情页链接和基础字段,再决定是否需要进入详情页补全字段。
本次项目里,spider 链家网数据爬取.ipynb用的就是这种策略。爬虫把每个区域的房源数据单独存成一个 CSV 文件,文件名格式为“二手房-{区域}-第{批次}次下载.csv”。这样做的好处是:如果某个区域请求失败,或者被反爬中断,不需要重爬全部数据,只需要补爬对应区域即可。实际项目中我也是这么处理的——按区域分文件存储,天然支持断点续爬。
2.2 请求头伪装与 Cookie 策略:不能只改 User-Agent
写链家爬虫,最基础的反爬应对就是设置 User-Agent。但只改 UA 远远不够。链家的反爬会综合校验请求频率、Cookie、请求头顺序,甚至浏览器指纹。常见做法是:
import requests from fake_useragent import UserAgent ua = UserAgent() headers = { "User-Agent": ua.random, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Encoding": "gzip, deflate, br", "Connection": "keep-alive", "Referer": "https://cd.lianjia.com/ershoufang/", } session = requests.Session() session.headers.update(headers)代码说明:这里用fake_useragent随机生成 UA,避免每次请求都用同一个标识;Referer设置为链家二手房首页,模拟从首页跳转过来的浏览行为;使用Session对象保持连接,复用 Cookie。这里有一个容易被忽略的细节:Accept-Encoding不要手动改成空值或随意删掉,链家的服务器会根据这个字段决定是否返回压缩内容,如果协商失败可能导致响应体解析异常。
在请求频率上,我习惯加一个 1 到 3 秒的随机延时,而不是固定延时。固定延时容易被识别为机器行为,随机延时更接近真人浏览节奏:
import time import random time.sleep(random.uniform(1, 3))此外,链家对单 IP 高频访问会有封禁策略。如果只是跑几千条数据,这个频率通常够用;如果要爬全城主城区全量数据,建议准备代理池,或者分布在不同时间段分段爬取。这个项目里三万余条数据分区域存储,实际上也是在为“分段爬取”铺路。
2.3 列表页解析:用 BeautifulSoup 提取字段的锚点
列表页中每套房源卡片是一个div元素,class 包含info,内部有标题、位置、价格等结构化信息。用 BeautifulSoup 解析的核心思路是:先锁定房源卡片容器,再从中提取文本和链接。
from bs4 import BeautifulSoup def parse_list_page(html): soup = BeautifulSoup(html, "html.parser") items = [] for li in soup.select("ul.sellListContent li"): info = li.select_one("div.info") if not info: continue title = info.select_one("div.title a") address = info.select_one("div.flood") total_price = info.select_one("div.totalPrice") unit_price = info.select_one("div.unitPrice") items.append({ "标题": title.get_text(strip=True) if title else None, "链接": title.get("href") if title else None, "位置": address.get_text(" ", strip=True) if address else None, "总价": total_price.get_text(strip=True) if total_price else None, "单价": unit_price.get("data-price") if unit_price else None, }) return items解析逻辑说明:ul.sellListContent li是链家二手房列表页的房源卡片选择器,基本没有变化;div.title a里是房源标题和详情页链接;div.totalPrice的文本是总价文本,带“万”字,后面清洗时要去掉;div.unitPrice的>def parse_detail_page(html): soup = BeautifulSoup(html, "html.parser") result = {} intro = soup.select_one("div.introContent") if intro: items = intro.select("ul li") for li in items: text = li.get_text(" ", strip=True) if ":" in text: key, value = text.split(":", 1) result[key.strip()] = value.strip() return result
字段提取的说明:链家详情页的div.introContent里是房屋基本信息列表,每项是li标签,格式为“字段名:值”。用split(":", 1)按中文冒号切分,能拿到完整字段映射。但注意不同房源详情页的字段数量不完全一致,有的有“抵押信息”,有的没有;有的有“上次交易”,有的显示“暂无数据”。这种情况不要强行补默认值,保留原样即可,清洗阶段再处理。
3. 数据预处理与特征工程:从三万余条杂乱记录中提炼可建模字段
3.1 原始数据的常见脏形态:特殊符号、缺失值和单位混用
爬虫落盘的 CSV 文件并不等于能直接进模型的数据。摘要中明确提到“原始数据中含有特殊符号或者数字后跟等单位或特殊符号”,这是二手房数据的常态。打开 CSV 后通常会看到这些问题:户型字段出现“3室2厅1厨2卫”这样带厨卫信息的混合文本;面积字段尾部带“平米”字样;总价字段带“万”字;单价字段可能是整数也可能是带小数点的字符串;楼层字段混杂“低楼层/共32层”这类复合文本;挂牌时间有的是“2024-03-15”,有的只有“2024-03”。
这些脏数据不能靠一个一个改,必须写清洗函数批量处理。清洗的目标是生成可以直接参与建模的数值特征,同时保留必要的原始文本供后续特征提取。
3.2 数值字段清洗:正则提取、单位剥离与类型强制转换
import pandas as pd import re def clean_price(text): """从带单位的字符串中提取数值""" if pd.isna(text): return None match = re.search(r"(\d+\.?\d*)", str(text)) return float(match.group(1)) if match else None def clean_area(text): """提取面积数值,去掉'平米'等后缀""" if pd.isna(text): return None match = re.search(r"(\d+\.?\d*)", str(text)) return float(match.group(1)) if match else None df["总价_万"] = df["总价格"].apply(clean_price) df["面积_平米"] = df["建筑面积"].apply(clean_area) df["单价_元每平"] = df["每平方单价"].apply(clean_price)清洗逻辑说明:clean_price和clean_area的本质上一样——从任意字符串中提取第一个数字,兼容整数和小数。这样设计的好处是,无论原始字段是“185万”“185.6万”还是“单价 18564 元/平”,都能被正确提取。这里有一个细节:正则表达式(\d+\.?\d*)可以匹配“123”和“123.45”两种格式,但无法匹配“1,234.56”这种带千分位逗号的写法。如果数据里出现千分位格式,需要先去掉逗号再提取。从链家爬下来的数据一般不涉及千分位,所以这个正则够用。
3.3 文本特征抽取:从“3室2厅1厨2卫”中拆出结构化特征
户型字段是典型的复合文本,但拆解逻辑非常固定。常见格式是“X室Y厅Z厨W卫”,也可能有“X室Y厅Z卫”这种缺厨的变体。处理方式是用正则分别提取每个维度的数值:
def extract_room_features(text): """解析户型文本为室、厅、厨、卫四个字段""" result = {"室": None, "厅": None, "厨": None, "卫": None} if pd.isna(text): return result text = str(text) result["室"] = extract_num(text, "室") result["厅"] = extract_num(text, "厅") result["厨"] = extract_num(text, "厨") result["卫"] = extract_num(text, "卫") return result def extract_num(text, unit): pattern = rf"(\d+){unit}" match = re.search(pattern, text) return int(match.group(1)) if match else None这段代码的关键在于extract_num把“室、厅、厨、卫”当作单位去匹配前面的数字。比如“3室2厅1厨2卫”,会依次提取出 3、2、1、2。这种写法的好处是即使字段顺序变化(比如“2卫3室”),也能正确提取。注意rf"(\d+){unit}"是 Python 的 raw f-string 用法,其中{unit}是变量,(\d+)作为捕获组。运行时不要把{unit}误写成{unit}s之类。
提取完户型字段后,可以做一步校验:室、厅、卫任意一个为None的记录,可能户型字段本身缺失或格式异常,建议单独查看原始文本,不要直接丢数据。
3.4 楼层信息拆分:把“低楼层/共32层”变成楼层数值和楼层类型
楼层字段在链家数据里的格式是“低楼层/共32层”或“中楼层/共18层”。这个字段对房价有显著影响——顶层和底层通常折价,中间楼层溢价。处理方式分两步:先提取总楼层数和所在楼层,再映射楼层类型。
def split_floor(text): result = {"楼层类型": None, "总楼层": None} if pd.isna(text): return result text = str(text) match_total = re.search(r"共(\d+)层", text) result["总楼层"] = int(match_total.group(1)) if match_total else None if "低楼层" in text: result["楼层类型"] = "low" elif "中楼层" in text: result["楼层类型"] = "mid" elif "高楼层" in text: result["楼层类型"] = "high" elif "顶层" in text: result["楼层类型"] = "top" return result处理逻辑说明:楼层类型直接映射为英文标签,方便后续做 one-hot 编码;总楼层保留为数值特征。为什么不直接用“低/中/高”中文?因为后续 sklearn 的编码器处理中文标签也可以,但英文标签更通用,绘图时再映射回中文即可。如果你拿到的数据里楼层字段是“1/32层”这种格式,需要改用正则提取斜杠前后的两个数字。
3.5 缺失值处理策略:哪些字段该填充,哪些字段该丢弃
二手房数据不像实验数据那样规整,缺失是常态。处理缺失值要分字段讨论,不能一个方法打天下。
# 查看整体缺失情况 missing_summary = df.isnull().sum() missing_ratio = missing_summary[missing_summary > 0] / len(df) print(missing_ratio.sort_values(ascending=False))数值字段的处理思路:面积、总价、单价这些核心回归目标或特征出现缺失,优先考虑剔除记录,而不是填充。原因很简单,这类字段是房价建模的根基,缺失占比通常不高(链家数据里核心字段缺失率一般低于 2%),直接丢弃不会对样本量造成显著影响。而梯户比例、建筑结构这类分类特征,缺失占比可能达到 10% 到 20%,用众数填充或单独标记一个“未知”类别更稳妥。
3.6 特征工程:构造朝向、装修、建筑类型的分组变量
原始数据中的朝向字段是“南北”“东南”“西南”等组合文本,不能直接进入模型。常见的处理方式是把朝向转换为单一朝向向量,或者做分组映射:
def map_orientation(text): if pd.isna(text): return "unknown" text = str(text) if "南" in text and "北" in text: return "南北" if "南" in text: return "南" if "东" in text and "西" in text: return "东西" if "东" in text: return "东" if "西" in text: return "西" if "北" in text: return "北" return "其他" df["朝向分组"] = df["房屋朝向"].apply(map_orientation)分组说明:带南朝向的房子按“南北 > 南 > 东南/西南 > 其他”的顺序天然形成价格梯度,这样分组既保留了这个排序逻辑,又把几十种组合压缩到六个类别,避免 one-hot 后维度爆炸。装修字段同理,把“精装”“简装”“毛坯”保留为三分类,“其他”归为未知。建筑类型可以按“板楼”和“塔楼”二分,板塔结合归为板楼——在成都二手房市场,板楼的单价普遍高于塔楼,这个归类方式更贴近实际成交逻辑。
3.7 异常值过滤:让模型不被极端挂牌价带偏
二手房源里总有几套“天价房”或“超低价房”,比如总价 9999 万的豪宅,或者总价 5 万的车位混进住宅列表。这些极端值会对回归模型产生不成比例的影响。常用的过滤逻辑基于分位数:
def filter_outliers(df, column, lower_q=0.01, upper_q=0.99): lower = df[column].quantile(lower_q) upper = df[column].quantile(upper_q) return df[(df[column] >= lower) & (df[column] <= upper)] df_filtered = filter_outliers(df, "单价_元每平") df_filtered = filter_outliers(df_filtered, "面积_平米")参数说明:这里按 1% 和 99% 分位数做截断,把落在两端的极值剔除。column参数可以传入“单价_元每平”或“面积_平米”,调用两次实现多字段过滤。注意过滤顺序有讲究——先过滤单价再过滤面积,还是反过来,结果会有细微差别。如果数据本身噪声很大,可以尝试两种顺序对比过滤后的样本量和模型效果。
4. 房价分布可视化与建模:从 EDA 到多模型对比
4.1 单价分布直方图:观察右偏形态并决定是否做对数变换
二手房单价数据几乎必然是右偏分布——大部分房源集中在某个价格区间,少量豪宅把长尾拖到右边。画直方图可以直接验证这一点:
import matplotlib.pyplot as plt import seaborn as sns plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False fig, axes = plt.subplots(1, 2, figsize=(14, 5)) sns.histplot(df_filtered["单价_元每平"], bins=60, kde=True, ax=axes[0]) axes[0].set_title("单价分布 - 原始") sns.histplot(np.log1p(df_filtered["单价_元每平"]), bins=60, kde=True, ax=axes[1]) axes[1].set_title("单价分布 - 对数变换") plt.show()图形解读:左侧原始分布会呈现明显右偏,峰值集中在某个低价区间;右侧对数变换后分布更接近正态。对数变换的意义不只是让图更好看——线性回归和某些树模型对偏态目标变量的拟合都会受到长尾影响,对数变换后再建模,误差评估时再指数变换回来,是房价预测项目里的标准操作。np.log1p是log(1+x),专门用于处理可能为 0 的数值,避免log(0)报错。
4.2 区域价格对比:锦江、青羊、高新谁最贵
分组聚合是探索区域差异最快的方式:
region_price = df_filtered.groupby("区域")["单价_元每平"].agg(["median", "mean", "count"]) region_price = region_price.sort_values("median", ascending=False) print(region_price)业务解读:成都主城区的价格梯队通常是锦江、高新在头部,青羊、武侯居中,郫都、新都相对偏低。但要注意均值和中位数的差异——如果某个区域均值远高于中位数,说明该区域有少量高价房源拉高了均值,此时用中位数代表该区域的“典型价格”更准确。
4.3 特征编码与数据集划分:get_dummies 和 train_test_split
feature_cols = [ "面积_平米", "室", "厅", "卫", "总楼层", "楼层类型", "朝向分组", "装修情况", "建筑类型", "梯户比例", "房屋年限" ] df_model = df_filtered.copy() df_model = pd.get_dummies(df_model, columns=[ "楼层类型", "朝向分组", "装修情况", "建筑类型", "房屋年限" ], drop_first=True) X = df_model[feature_cols + [c for c in df_model.columns if c.startswith(("楼层类型_", "朝向分组_", "装修情况_", "建筑类型_", "房屋年限_"))]] y = np.log1p(df_filtered["单价_元每平"])编码说明:get_dummies会把分类字段展开为多列 0/1 特征,drop_first=True会去掉每个分类的第一列,防止完全共线性。注意feature_cols里包含了楼层类型等原始分类列,而get_dummies之后这些列会被替换成展开后的多列,所以后面构造X时用startswith重新匹配了所有展开列。
这里有一个常见错误:如果直接拿feature_cols作为X的列名,会因为楼层类型已经被删除而报 KeyError。所以代码里用了一个更稳健的写法——保留原始数值列,再动态拼接所有展开列。跑代码时如果遇到“列名不存在”的报错,检查一下get_dummies之后的列名列表即可。
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 )4.4 模型训练:线性回归、随机森林与 XGBoost 对比
房价预测项目至少要比三种模型:线性回归(简单基线)、随机森林(树模型基线)、XGBoost(强化基线)。只跑一个模型就下结论没有任何说服力。
from sklearn.linear_model import LinearRegression from sklearn.ensemble import RandomForestRegressor from xgboost import XGBRegressor from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score models = { "LinearRegression": LinearRegression(), "RandomForest": RandomForestRegressor(n_estimators=200, max_depth=12, random_state=42), "XGBoost": XGBRegressor(n_estimators=300, learning_rate=0.05, max_depth=6, random_state=42), } for name, model in models.items(): model.fit(X_train, y_train) pred = model.predict(X_test) rmse = np.sqrt(mean_squared_error(y_test, pred)) mae = mean_absolute_error(y_test, pred) r2 = r2_score(y_test, pred) print(f"{name} | RMSE(log): {rmse:.4f} | MAE(log): {mae:.4f} | R2: {r2:.4f}")参数说明:随机森林设置n_estimators=200、max_depth=12,限制深度以防止过拟合;XGBoost 设置n_estimators=300、learning_rate=0.05、max_depth=6,较低学习率配合较多迭代轮数通常比高学习率少轮数效果更稳定。random_state=42保证实验可复现。评估指标用 RMSE、MAE、R² 三个一起看,不要只看 R²——R² 高不代表预测误差小,尤其在房价这种数值跨度大的场景里。
实际上在这个项目里,线形回归的 R² 通常在 0.6 到 0.7 之间,随机森林能到 0.7 到 0.8,XGBoost 则取决于特征工程质量。注意这是在 log 空间下计算的指标,真实价格的 RMSE 需要做np.expm1还原。
4.5 特征重要性分析:哪些变量在驱动预测
XGBoost 和随机森林都内置了特征重要性属性,直接输出排序结果:
import numpy as np importance = pd.Series( models["XGBoost"].feature_importances_, index=X_train.columns ).sort_values(ascending=False) print(importance.head(15))预期结果分析:面积_平米通常排在第一位,这是符合预期的——面积直接决定了总价规模,单价和面积之间的关系在区域差异被控制后依然显著。总楼层和楼层类型往往排在前列,说明高层、中层与低层的单价差异在链家数据里足够明显。朝向分组中的“南北”特征也会进入前十,这与成都购房者对通透户型偏好的市场认知一致。
室、厅、卫的重要度反而可能不高,这是因为这些变量与面积高度相关——面积大的房子通常房间多,解释信息被面积吸收了。这不代表户型不重要,而是说明在多变量模型中,信息冗余会让部分特征权重被重新分配。如果项目报告中需要强调户型的价值,可以删除面积_平米后重新训练对比模型表现。
4.6 预测误差分析:在 log 空间评估,在真实空间解读
pred_log = models["XGBoost"].predict(X_test) pred_price = np.expm1(pred_log) y_test_price = np.expm1(y_test) error = y_test_price - pred_price error_pct = error / y_test_price * 100 error_df = pd.DataFrame({ "实际单价": y_test_price, "预测单价": pred_price, "误差绝对值": np.abs(error), "误差百分比": error_pct.abs() }) print(error_df.describe()) print("中位数误差百分比: {:.2f}%".format(error_df["误差百分比"].median()))误差解读:np.expm1是对np.log1p的逆操作,把对数空间的预测值还原为真实单价。误差百分比的分布通常比误差绝对值更有分析价值——绝对值相同,在不同价位段的意义完全不同。比如误差 1000 元/平,在 5000 元/平的房源上是 20% 的偏差,在 30000 元/平的房源上只有 3.3%。观察误差百分比的高分位数值,如果 75% 分位在 15% 以上,说明模型对约四分之一房源的单价预测偏差超过 15%,这类房源往往是户型异常、装修特殊或有额外赠送面积的案例。
5. 从单区域模型到串行爬虫:房价分析项目的工程化补强
前四章把数据爬取、清洗、建模全部跑通了,但只做一遍是不够的。真实项目里会遇到一个实际问题:三万余条数据分区域存放,每次都要手动合并、清洗、建模,流程冗长且容易出错。这一章给出两个提升效率的补强:一是批量合并多个区域 CSV 的脚本化处理,二是把爬虫、清洗、建模串成一条可重复执行的流水线。
import glob csv_files = glob.glob("二手房-*.csv") df_list = [pd.read_csv(f, encoding="utf-8") for f in csv_files] df_all = pd.concat(df_list, ignore_index=True) # 去重:同一套房源可能出现多次 df_all = df_all.drop_duplicates(subset=["链接"], keep="first") print(f"合并后总记录数: {len(df_all)}")合并说明:glob.glob("二手房-*.csv")会把所有区域文件命中,pd.concat按行拼接,ignore_index=True重新生成索引。去重字段选择了“链接”——同一套房源只有唯一的详情页 URL,这是最可靠的去重键。如果用标题或地址去重,容易因为文本细微差异导致漏删。
爬虫和清洗串行执行的常见做法是把所有步骤封装成函数,main 函数按顺序调用。考虑到不同区域的房源特征可能有差异(比如锦江的高层房源占比远高于郫都),还可以做一个分层建模:按区域训练独立的 XGBoost 模型,而不是全局一个模型。对比全局模型和各区域模型在对应区域测试集上的 RMSE,通常会得到另一个有价值的观察。
最后一件事是注意报告落地的形式。项目里附带report.docx,说明最终成果不只是模型,还包括数据分析报告。报告中建议放三类图表:各区域单价中位数箱线图、特征重要性 Top10 柱状图、真实单价与预测单价散点图。这三类图能完整呈现“数据概览 → 模型解读 → 预测效果”的逻辑链。
本文还有配套的精品资源,点击获取