简介面向数据分析与爬虫学习者的成都二手房价格分析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/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.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(stripTrue) if title else None, 链接: title.get(href) if title else None, 位置: address.get_text( , stripTrue) if address else None, 总价: total_price.get_text(stripTrue) 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( , stripTrue) 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(ascendingFalse))数值字段的处理思路面积、总价、单价这些核心回归目标或特征出现缺失优先考虑剔除记录而不是填充。原因很简单这类字段是房价建模的根基缺失占比通常不高链家数据里核心字段缺失率一般低于 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_q0.01, upper_q0.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[单价_元每平], bins60, kdeTrue, axaxes[0]) axes[0].set_title(单价分布 - 原始) sns.histplot(np.log1p(df_filtered[单价_元每平]), bins60, kdeTrue, axaxes[1]) axes[1].set_title(单价分布 - 对数变换) plt.show()图形解读左侧原始分布会呈现明显右偏峰值集中在某个低价区间右侧对数变换后分布更接近正态。对数变换的意义不只是让图更好看——线性回归和某些树模型对偏态目标变量的拟合都会受到长尾影响对数变换后再建模误差评估时再指数变换回来是房价预测项目里的标准操作。np.log1p是log(1x)专门用于处理可能为 0 的数值避免log(0)报错。4.2 区域价格对比锦江、青羊、高新谁最贵分组聚合是探索区域差异最快的方式region_price df_filtered.groupby(区域)[单价_元每平].agg([median, mean, count]) region_price region_price.sort_values(median, ascendingFalse) print(region_price)业务解读成都主城区的价格梯队通常是锦江、高新在头部青羊、武侯居中郫都、新都相对偏低。但要注意均值和中位数的差异——如果某个区域均值远高于中位数说明该区域有少量高价房源拉高了均值此时用中位数代表该区域的“典型价格”更准确。4.3 特征编码与数据集划分get_dummies 和 train_test_splitfeature_cols [ 面积_平米, 室, 厅, 卫, 总楼层, 楼层类型, 朝向分组, 装修情况, 建筑类型, 梯户比例, 房屋年限 ] df_model df_filtered.copy() df_model pd.get_dummies(df_model, columns[ 楼层类型, 朝向分组, 装修情况, 建筑类型, 房屋年限 ], drop_firstTrue) 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_firstTrue会去掉每个分类的第一列防止完全共线性。注意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_size0.2, random_state42 )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_estimators200, max_depth12, random_state42), XGBoost: XGBRegressor(n_estimators300, learning_rate0.05, max_depth6, random_state42), } 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_estimators200、max_depth12限制深度以防止过拟合XGBoost 设置n_estimators300、learning_rate0.05、max_depth6较低学习率配合较多迭代轮数通常比高学习率少轮数效果更稳定。random_state42保证实验可复现。评估指标用 RMSE、MAE、R² 三个一起看不要只看 R²——R² 高不代表预测误差小尤其在房价这种数值跨度大的场景里。实际上在这个项目里线形回归的 R² 通常在 0.6 到 0.7 之间随机森林能到 0.7 到 0.8XGBoost 则取决于特征工程质量。注意这是在 log 空间下计算的指标真实价格的 RMSE 需要做np.expm1还原。4.5 特征重要性分析哪些变量在驱动预测XGBoost 和随机森林都内置了特征重要性属性直接输出排序结果import numpy as np importance pd.Series( models[XGBoost].feature_importances_, indexX_train.columns ).sort_values(ascendingFalse) 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, encodingutf-8) for f in csv_files] df_all pd.concat(df_list, ignore_indexTrue) # 去重同一套房源可能出现多次 df_all df_all.drop_duplicates(subset[链接], keepfirst) print(f合并后总记录数: {len(df_all)})合并说明glob.glob(二手房-*.csv)会把所有区域文件命中pd.concat按行拼接ignore_indexTrue重新生成索引。去重字段选择了“链接”——同一套房源只有唯一的详情页 URL这是最可靠的去重键。如果用标题或地址去重容易因为文本细微差异导致漏删。爬虫和清洗串行执行的常见做法是把所有步骤封装成函数main 函数按顺序调用。考虑到不同区域的房源特征可能有差异比如锦江的高层房源占比远高于郫都还可以做一个分层建模按区域训练独立的 XGBoost 模型而不是全局一个模型。对比全局模型和各区域模型在对应区域测试集上的 RMSE通常会得到另一个有价值的观察。最后一件事是注意报告落地的形式。项目里附带report.docx说明最终成果不只是模型还包括数据分析报告。报告中建议放三类图表各区域单价中位数箱线图、特征重要性 Top10 柱状图、真实单价与预测单价散点图。这三类图能完整呈现“数据概览 → 模型解读 → 预测效果”的逻辑链。本文还有配套的精品资源点击获取