ARTICLE DETAIL

资讯详情

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

链家二手房数据抓取与Python可视化分析实战:从CSV到KMeans聚类

链家二手房数据抓取与Python可视化分析实战:从CSV到KMeans聚类 简介这套基于Python爬虫与Jupyter的链家二手房数据分析源码是一份面向爬虫入门者、数据分析学习者及房地产研究场景的完整项目。项目以链家二手房为目标通过requests与解析库抓取房源信息再在Jupyter Notebook中完成清洗、聚类与可视化形成从数据采集到结论输出的闭环流程。压缩包共20个文件约30.68MB核心部分包含爬虫采集脚本、用于分析建模的IPYNB笔记本、存储原始数据的CSV表格以及13张PNG图表如词云、面积与总价散点图、上海各区域平均单价热力图等配套PPTX与DOCX文档便于理解与展示。目前已有559人浏览学习。借助源码读者可快速掌握二手房数据获取、多维度特征分析和图表呈现的完整思路也可参考其目录结构与代码写法迁移用于其他城市或同类房源数据场景。1. 一个链家二手房数据项目为什么值得把抓取和分析拆开看把链家二手房抓取脚本和 Jupyter 分析源码放进同一个压缩包乍看是期末报告套件但拆开里面那 20 个文件真正有用的是抓取、清洗、可视化三件事的串联关系。抓取端生成的 CSV 是后面所有图表的唯一输入如果字段口径不一致比如单价带“万”、总价带“元”画出来的散点图和聚类图全是错的。所以这个项目最适合两类人看一是想把二手房数据完整跑通一遍的数据分析初学者二是在做课程设计或市场报告、需要快速产出区域均价热力图和聚类图的开发者。读源码时先把二手房抓取.py 和上海市二手房数据分析.ipynb 对照着看比单独看代码更容易理解为什么 CSV 里要有区域、面积、单价这些字段。2. 抓取端设计requests 解析链家二手房列表页与 CSV 落盘抓取是整个项目的数据源头。链家列表页信息密度高一个 li 标签里能同时拿到房源标题、区域、户型、面积、楼层、朝向、装修、总价和单价一次性抓全可以减少请求次数也避免后期补字段时被迫重跑。2.1 先拆页面结构再写解析函数拿到项目里二手房抓取.py 第一件事不是读代码而是用浏览器打开链家二手房列表页按 F12 看房源卡片的 DOM 结构。链家 PC 端列表页通常把每条房源放在ul.sellListContent下的li.clear里房源卡片内部分几个区块.title放标题.address .houseInfo放房屋信息.positionInfo放小区和板块.totalPrice放总价.unitPrice放单价.followInfo放关注人数。这样定位以后解析函数可以写得非常短。如果发现请求返回的 HTML 里没有li.clear大概率是页面改版或跳转到了验证页。常见做法是把响应文本前 500 个字符打印出来看里面有没有“总价”或sellListContent没有就换 UA 或带上 Cookie 再试不要在解析失败时反复请求同一个 URL。2.2 翻页与字段提取一个可以直接改的抓取框架下面这段是简化后的抓取核心对应项目里二手房抓取.py 的主体逻辑import time import requests from bs4 import BeautifulSoup BASE_URL https://{city}.lianjia.com/ershoufang/pg{page}/ HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, } def fetch_page(city: str, page: int) - str: url BASE_URL.format(citycity, pagepage) resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() return resp.text def parse_page(html: str) - list[dict]: soup BeautifulSoup(html, lxml) items [] for li in soup.select(ul.sellListContent li.clear): pos li.select_one(.positionInfo) info li.select_one(.address .houseInfo) total li.select_one(.totalPrice span) unit li.select_one(.unitPrice) items.append({ title: li.select_one(.title a).get_text(stripTrue), region: pos.get_text( , stripTrue).split()[0] if pos else None, info: info.get_text( , stripTrue) if info else None, total: total.get_text(stripTrue) if total else None, unit: unit.get_text(stripTrue) if unit else None, }) return items records [] for page in range(1, 6): html fetch_page(sh, page) records.extend(parse_page(html)) time.sleep(1) print(len(records), records[0])这段代码里fetch_page负责请求parse_page负责解析。region取.positionInfo文本的第一个词按上海链家的页面习惯第一个词通常是所在行政区比如黄浦、静安、浦东这个字段后面做热力图和聚类分析都会用到。info是户型面积等信息的组合串Jupyter 里会再拆分。total取.totalPrice span得到的是“760万”而不是计算出的数字保留原始文本解析口径统一放到分析阶段。参数调整上city 传拼音简称如sh、bjpage 从 1 开始链家列表页超过 100 页后不再展示真实房源所以范围写 1 到 100 内更稳妥。time.sleep(1)不是可有可无的装饰它决定了请求节奏顺序抓取时 1 秒间隔已经够用如果你把间隔改成 0.1往往会在抓第 7、8 页时连续收到空页面。2.3 CSV 落盘与增量去重列表页抓下来是内存里的 list of dict下一步就该落盘。项目里的 data/上海市二手房数据.csv 就是从这一步来的import pandas as pd df pd.DataFrame(records) df df.drop_duplicates(subset[title, info, total], keepfirst) df.to_csv(data/上海市二手房数据.csv, indexFalse, encodingutf-8-sig)去重维度选title info total是因为同一套房子如果被不同中介发布标题和总价会高度相似只按 title 去重会误删同小区同户型但不同楼层的真实房源。utf-8-sig是给 Excel 用户准备的不加 BOM 的话Windows 下直接双击 CSV 中文会乱码后续 pandas 读回时同样指定这个编码。抓取字段和 CSV 里最终保留字段的对应关系如下CSV 字段原始页面文本示例分析前需要处理的问题title静安寺 经典两房 满五可能混入“满五”等标签词region静安 静安寺只保留行政区去掉板块info2室1厅 | 88.55平米 | 高楼层(共18层) | 南 南 | 精装需要按|拆成 5 列total760万去掉“万”转成 intunit单价78001元/平米保留数字去掉千分位2.4 抓取稳定性UA、Sleep 与失败重试抓取脚本跑一半中断是常态比较常见的原因是连续请求后触发了页面校验或者是某个页面正好没有房源数据导致解析返回空。应对方式是在请求层加一次重试而不是在循环里包 try/except 后直接 continue否则会把空记录也写进 CSV。def fetch_page_with_retry(city: str, page: int, retries: int 3) - str: for attempt in range(retries): try: return fetch_page(city, page) except requests.RequestException: time.sleep(2 * (attempt 1)) raise RuntimeError(fpage {page} failed after {retries} attempts)重试的等待时间按 2、4、6 秒递增比固定等待更不容易撞上风控窗口。这里没有引入并发请求原因是单个课程设计或学期项目的数据量通常在几千条以内顺序抓取足够写多线程反而会让 CSV 里的记录顺序不稳定给后面的聚类复现带来麻烦。3. Jupyter 里的数据清洗从 CSV 到可分析的 pandas DataFrame项目里真正决定图表质量的是上海市二手房数据分析.ipynb 里的清洗步骤。普通演示项目只做了一次pd.read_csv就把字符串丢给 matplotlib结果是纵轴刻度出现“4.52e07”这种无法解释的单位。这节把字段拆解、类型转换和类别编码一步到位。3.1 pandas 读取与字段标准化清洗开始前先明确目标CSV 里那串info有户型、面积、楼层、朝向、装修 5 类信息全部挤在同一个字符串里。分析阶段需要把它们拆成独立列再转成数值类型。import pandas as pd df pd.read_csv(data/上海市二手房数据.csv, encodingutf-8-sig) print(df.shape) print(df[info].head(3))读取时编码必须和写入时一致否则会在info列看到一堆乱码。打印出来的info长这样0 2室1厅 | 88.55平米 | 高楼层(共18层) | 南 南 | 精装 1 3室2厅 | 121.75平米 | 低楼层(共11层) | 南 北 | 简装先用str.split把这串文本按|扩成多列再做列重命名。这里的expandTrue会把拆分结果变成 DataFrame不需要手动遍历行。parts df[info].str.split(|, expandTrue) for i, col_name in enumerate([layout, area_raw, floor, direction, decoration]): if i parts.shape[1]: df[col_name] parts[i].str.strip() else: df[col_name] None这段代码把 5 种信息分别放进 5 列。str.strip()去掉分隔符两侧空格如果某条房源没有装修字段parts.shape[1]会小于 5用if把缺失的列补成 None避免列数不一致导致赋值报错。3.2 面积、单价、总价的类型陷阱链家页面上的面积是“88.55平米”总价是“760万”单价是“单价78001元/平米”。这些看起来是数字实际上全是字符串直接pd.to_numeric会得到 NaN。这里用三条正则分别抽取import re def parse_area(info_text): m re.search(r(\d(\.\d)?)平米, str(info_text)) return float(m.group(1)) if m else None def parse_total(total_text): m re.search(r(\d), str(total_text)) return int(m.group(1)) if m else None def parse_unit(unit_text): m re.search(r(\d), str(unit_text).replace(,, )) return float(m.group(1)) if m else None df[area] df[area_raw].apply(parse_area) df[total_wan] df[total].apply(parse_total) # 总价单位万元 df[unit_price] df[unit].apply(parse_unit) # 单价单位元/平米parse_area里的正则把“88.55平米”中的小数也挖出来防止后面散点图横坐标全是整数。parse_total只取第一个数字对“760万”和“760万元”都适用。parse_unit先把逗号删掉再取数字兼容部分城市带千分位的写法。原始字段解析结果类型88.55平米88.55float760万760int单价78,001元/平米78001.0float3.3 户型、朝向、装修的类别编码聚类模型不能直接读“精装”这种中文需要把它转成数值。但要注意户型这种本身有顺序含义的字段不要随便编码成 1、2、3因为“2室1厅”和“3室2厅”之间没有严格的整数间隔更合理的做法是把户型保留为字符串用于分类统计把装修和朝向转成可计算的二值或有序值。df[decoration_code] df[decoration].map({毛坯: 0, 简装: 1, 精装: 2}) df[is_south] df[direction].fillna().apply( lambda x: 1 if 南 in x else 0 )map的好处是未列出的值自动变成 NaN后续建模时也不会被误当成分组fillna()先处理缺失朝向再判断字符串里是否含“南”这样把“南 南”“南 北”都统一成朝南房源。项目里那张“上海市不同区的装修类型分布图”就是按region分组后用decoration_code做的堆叠柱状图。3.4 缺失值和重复记录的取舍清洗到最后一步是过滤异常值。二手房数据里出现面积 3 平米或总价 20 万通常是页面信息缺失或解析错位混进聚类会把簇中心拉偏。df df.dropna(subset[area, total_wan, unit_price]) df df[df[area] 10] df df[df[total_wan] 50] print(df.shape)这里的阈值按上海市区二手房行情取的做其他城市时要改成当地逻辑否则会误删真实房源。如果只图省事把所有缺失行都删除户型字段大量为空的房源也会被删掉导致样本量不够画占比图。先只对参与数值分析的核心字段做dropna再按业务阈值过滤顺序不要反过来。4. KMeans 聚类与热力图还原上海各区域价格结构项目里的 13 张 PNG 里最值得看的是两张聚类图聚类结果能说明“上海二手房不是按总价均匀分布的而是存在明显的高价小面积簇和低价大面积簇”。这章复现这两张图并把它们和区域热力图串起来。4.1 为什么用 KMeans 而不只看均值直接对区域算平均单价会被少数千万级豪宅拉高直接画总价分布又被面积变量干扰。KMeans 在「面积-单价」或「面积-总价」二维空间里找稠密区域能更清楚地看到哪几类房源在市场上是成组出现的。比如单价 12 万加面积 50 平的小户型和单价 5 万加面积 100 平的大户型平均值接近但完全不是同类产品。4.2 标准化与聚类参数选择用 sklearn 做聚类前必须标准化。面积是几十到几百的量纲单价是几万的量纲不标准化的话聚类结果基本只由单价决定面积维度形同虚设。from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score data df.dropna(subset[area, unit_price]).copy() X data[[area, unit_price]] scaler StandardScaler() X_scaled scaler.fit_transform(X) for k in range(2, 7): km KMeans(n_clustersk, random_state42, n_init10) labels km.fit_predict(X_scaled) score silhouette_score(X_scaled, labels) print(fk{k}, silhouette{score:.3f})StandardScaler把每个特征变成均值为 0、方差为 1n_init10指定多次随机初始化避免一次收敛到局部最优。轮廓系数是每个样本到同簇样本平均距离和最近异簇样本平均距离的差值越大说明簇越紧凑。课程报告里不要只贴最终聚类图把这段循环的输出放上去能说明 k 是选出来的而不是拍脑袋。4.3 可视化输出散点图、热力图、占比图确定簇数后把标签回贴到 DataFrame画散点图并保存。这里的中文显示需要单独处理Windows 和 Linux 的默认字体差异很大直接画会在坐标轴标签上显示方框。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei] plt.rcParams[axes.unicode_minus] False km KMeans(n_clusters4, random_state42, n_init10) data[cluster] km.fit_predict(X_scaled) fig, ax plt.subplots(figsize(8, 6)) sc ax.scatter(data[area], data[unit_price], cdata[cluster], cmapviridis, s10, alpha0.7) ax.set_xlabel(面积平米) ax.set_ylabel(单价元/平米) plt.colorbar(sc) plt.tight_layout() plt.savefig(output/cluster_area_unit.png, dpi200) plt.show()alpha0.7是为了减少重叠点对颜色的干扰s10控制点大小样本到几千条时这个值画出来既清楚又不糊。热力图则按 region 聚合均价import seaborn as sns region_price data.groupby(region)[unit_price].mean().sort_values().reset_index() fig, ax plt.subplots(figsize(6, 8)) sns.heatmap(region_price[[unit_price]], annotTrue, fmt.0f, cmapOrRd, cbar_kws{label: 平均单价(元/平米)}, yticklabelsregion_price[region], axax) plt.tight_layout() plt.savefig(output/region_price_heatmap.png, dpi200)annotTrue会把单价均值直接写进每个格子里fmt.0f控制不显示小数yticklabels用 groupby 后的顺序保证格子位置和区域名一一对应。分类占比图用饼图或条形图都可以项目里那张“上海市二手房户型占比分布图”更适合饼图因为户型占比的分母是全体房源含义清楚。4.4 聚类结果如何对应项目里的 PNG 文件PNG 文件名可视化类型对应分析意图聚类图单价与面积.png散点 KMeans看面积与单价的组合分簇聚类图总价与面积.png散点 KMeans看总价与面积的组合分簇房源面积与总价的散点图.png普通散点检查离群房源比如小面积高总价上海市各区域平均单价热力图.png热力图按区域比均价的梯度上海市二手房户型占比分布图.png饼图分析户型结构上海市不同区的装修类型分布图.png堆叠柱状图装修档次与区域的关系复现时注意如果 CSV 里缺少区域字段热力图就画不出来这也是抓取阶段要把.positionInfo放进解析函数的原因。聚类图的横纵轴单位不同保存时最好在标题里注明避免看报告的人误以为两张图的横轴都是面积。5. 报告素材批量导出把 ipynb 的图表变成 PPTX 和 DOCX 能用的文件课程设计报告通常要把 matplotlib 画出来的图再塞进 Word 和 PPT。手动逐个保存容易漏掉tight_layout很多图的坐标轴标签被裁掉就是在这一步发生的。这章给一个可复用的导出技巧。5.1 统一图表风格并控制 DPI定义一个通用保存函数所有图都走同一个出口def save_fig(fig, name): fig.savefig(foutput/{name}.png, dpi200, bbox_inchestight) plt.close(fig)bbox_inchestight会自动扩大画布边界把被裁掉的 y 轴标签收回来plt.close(fig)及时释放内存避免在 Jupyter 里连续画几十张图后占用过高。DPI 固定为 200Word 里缩到半页宽依然清晰。5.2 PNG 批量归档与命名规范项目根目录直接堆了 13 张 PNG文件名还是中文全称和带时间戳的截图混在一起。做报告前先批量重命名成类型_维度.png后面插入 PPT 时顺序一眼就能看懂import os rename_map { 聚类图单价与面积.png: cluster_area_unit.png, 聚类图总价与面积.png: cluster_total_area.png, 房源面积与总价的散点图.png: scatter_area_total.png, 上海市各区域平均单价热力图.png: heatmap_region_price.png, } for old, new in rename_map.items(): if os.path.exists(old): os.rename(old, new)建议按「图类型前缀_主维度_次维度」命名聚类图以 cluster 开头散点图以 scatter 开头热力图以 heatmap 开头。生成新图时直接在save_fig的 name 参数里写英文避免后期混入中英文两种命名风格。5.3 用图表落盘后的一个验证技巧图表画完不要急着贴进报告先验证聚类结果和原始单位是否对得上。由于聚类是在标准化后的数据上做的簇中心直接打印是标准化值很难向老师解释。用inverse_transform把中心还原回去centers scaler.inverse_transform(km.cluster_centers_) center_df pd.DataFrame(centers, columns[area, unit_price]) center_df[cluster] range(len(center_df)) print(center_df)还原后如果某个簇中心显示“面积 48 平、单价 11 万”说明高价小户型簇是存在的。再按簇和区域做一次交叉表确认每个簇的区域构成看看是不是出现了一个簇几乎全是浦东和闵行的大面积低单价房源。最后用pd.crosstab(df[region], df[cluster])验算一遍报告里的每一条结论都能追到具体样本。pd.crosstab(df[region], df[cluster])本文还有配套的精品资源点击获取
返回列表