ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python数据分析实战:Scrapy爬虫与Django可视化租房平台设计

Python数据分析实战:Scrapy爬虫与Django可视化租房平台设计 作为计算机毕业设计很多同学喜欢挑管理系统、商城网站这类“增删改查”项目说实话这类题目做完对找工作帮助很有限。今天要分享的这个项目——基于Python的全流程租房数据分析平台走的是“爬虫采集 数据清洗 算法分析 可视化展示”这条完整技术链路含金量完全不一样。它的核心价值在于用Scrapy爬取真实的房源数据配合Django做Web展示再用K-means聚类和线性回归分别解决“城市租金分层”和“房价预测”这两个实际问题。整个项目融合了数据采集、存储、清洗、建模、可视化五个环节无论你是准备毕业答辩还是想往数据分析方向求职这套技术栈都能实打实地写进简历里。标题虽然打着“租房”的旗号实际上这套架构可以平移到二手房分析、招聘薪资分析、商品价格监测等任何数据类场景可扩展性非常强。文章后面我会把完整的设计思路、算法选择原因、核心代码逻辑、以及我在实际开发中踩过的各种坑一次性梳理清楚。1. 项目整体设计与技术选型思路1.1 为什么要用Scrapy而不是requests很多同学写爬虫习惯于用requests加BeautifulSoup简单页面没问题一旦到了全流程项目里就会暴露三个痛点单线程请求速度慢、断点续爬需要自己造轮子、数据解析和存储逻辑全部混在一起难以维护。Scrapy作为专业爬虫框架自带异步并发机制同一时间可以并发请求多个页面采集速度快几个量级。更重要的是它的架构天然就是分层的Spider负责发起请求和解析数据Pipeline负责数据存储Middleware负责请求拦截和代理切换。这种解耦设计在项目后期维护时会非常舒服——比如想换存储方式只需要改Pipeline层Spider完全不用动。我在这个项目里还用到Scrapy的深度优先爬取策略先抓城市列表页拿到每个城区的URL再进入城区页面获取房源详情链接最后逐个抓取房源明细。三个层级对应三个解析回调函数逻辑清晰出错了也容易定位。1.2 Django在项目里扮演什么角色这个项目的定位是“数据分析平台”所以Django的作用不是简单搭个网页展示数据而是把整个数据流程串起来启动爬虫任务、管理采集到的数据集、调用算法模块做分析、将分析结果通过图表渲染到前端。用Django而不是Flask的原因主要有两点。第一Django自带ORM可以直接把爬虫采集到的房源数据映射成Model对象查询、筛选、聚合都特别方便不需要额外写SQL。第二Django的Admin后台是现成的数据管理界面爬虫采集过程中如果发现数据质量问题我可以直接在后台查看和修正对调试非常有帮助。另外Django的MTV模式把模板渲染和业务逻辑分开前端页面只需要专注于图表展示和数据交互后端专心处理算法调度这种分离在开发效率上优势明显。1.3 K-means和线性回归分别解决什么问题这两个算法在项目里不是堆砌技术点而是对应两个真实需求场景。K-means聚类用来做“城市租金分层分析”。同一座城市的租金分布并不均匀市中心、近郊、远郊的租金差异很大而这三者之间并不存在明确的划分阈值。K-means正好适合做这种无监督分类任务——把房源按地理位置和租金水平聚成若干类每类代表一个租金层次然后再统计每个层次的房源数量和平均租金就能直观看出城市的租金结构。线性回归用来做“房价预测”。输入特征是面积、户型、楼层、朝向、所在区域等输出是租金价格。虽然真实房价和这些因素之间并非严格的线性关系但线性回归作为入门模型胜在可解释性强——每个特征对应的系数能直接告诉我们“面积每增加一平米租金大概上涨多少”这在毕业设计的演示和答辩中非常好讲。K值和特征工程的细节下面专门讲实现的时候再展开。2. 数据采集与处理环节的实操拆解2.1 爬虫模块的核心代码架构Spider部分我设计了一个继承自scrapy.Spider的房源爬虫类核心逻辑分为三个层级import scrapy from housing_scrapy.items import HouseItem class RentHouseSpider(scrapy.Spider): name rent_house allowed_domains [examplecity.com] start_urls [https://www.examplecity.com/zufang/] # 第一层解析城区入口页面 def parse(self, response): # 提取各城区链接 district_links response.css(div.districtList a::attr(href)).getall() district_names response.css(div.districtList a::text()).getall() for name, link in zip(district_names, district_links): yield scrapy.Request( urlresponse.urljoin(link), callbackself.parse_house_list, meta{district: name.strip()} ) # 第二层解析房源列表页 def parse_house_list(self, response): district response.meta[district] house_links response.css(div.houseList a::attr(href)).getall() for link in house_links: yield scrapy.Request( urlresponse.urljoin(link), callbackself.parse_house_detail, meta{district: district} ) # 翻页处理拿到了下一页按钮的链接就继续跟进 next_page response.css(a.next::attr(href)).get() if next_page: yield scrapy.Request( urlresponse.urljoin(next_page), callbackself.parse_house_list, meta{district: district} ) # 第三层解析房源详情页 def parse_house_detail(self, response): item HouseItem() item[district] response.meta[district] item[title] response.css(h1.house-title::text).get() item[price] response.css(span.price::text).get() item[area] response.css(span.area::text).get() item[bedrooms] response.css(span.bedrooms::text).get() item[floor] response.css(span.floor::text).get() item[location] response.css(span.location::text).get() item[publish_time] response.css(span.time::text).get() yield itemSelector语法用得很基础这块需要提醒几个细节。房源列表页如果数量大很容易触发频率控制我实际测试下来最容易出问题的是翻页间隔和单页请求数量。后来在中间件里做了个简单的限速控制保证每个页面提取出来后延时0.5到1秒再请求下一个。这里有个反爬处理的技巧值得说明不要只在DownloaderMiddleware里换User-Agent更要关注请求头里其他字段的顺序。有些站点对Header顺序有要求把Accept、Accept-Language、Referer这些字段静默默地加到默认Header里比单纯换UA效果要好得多。实际写代码时我把这些配置放到了settings.py里DEFAULT_REQUEST_HEADERS { Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.8,en-US;q0.5,en;q0.3, Accept-Encoding: gzip, deflate, Referer: https://www.examplecity.com/, }连接到Pipeline的数据存储环节我写了MongoDB和MySQL双写通道的Pipeline。为什么这么设计MongoDB适合存原始数据字段结构灵活采集过程中可能出现某些房源缺少户型或者面积的字段这种半结构化数据存进来不需要做严格的Schema校验。MySQL则负责存清洗后的干净数据供后面的算法和Web平台使用。两个存储结合使用开发和调试都方便。class DuplicatePipeline: def process_item(self, item, spider): # 根据房源链接去重 if self.redis_client.sismember(rent_house_urls, item[url]): raise DropItem(Duplicate item found) return item class MongoPipeline: def process_item(self, item, spider): self.mongo_collection.insert_one(dict(item)) return item class MysqlPipeline: def process_item(self, item, spider): data dict(item) self.cursor.execute( INSERT INTO house_info (district,title,price,area,bedrooms,floor,location,publish_time) VALUES (%s,%s,%s,%s,%s,%s,%s,%s), (data[district], data[title], data[price], data[area], data[bedrooms], data[floor], data[location], data[publish_time]) ) self.conn.commit() return itemPipeline的执行顺序是在settings里通过ITEM_PIPELINES配置控制的我先做了去重再存Mongo最后落MySQL这个顺序中间两个换一下也没有大影响。实际开发中我建议把清洗步骤独立成一个Pipeline放在最前面方便后面统一处理脏数据。2.2 数据清洗把“能用”变成“好用”的关键一环很多同学爬完数据直接灌进数据库就开始跑算法结果K-means聚类出来的结果一塌糊涂——根源就在于数据质量不行。房源数据常见的问题有以下几类。房间面积的字段爬下来是字符串“85平方米”算法没法直接处理需要正则提取数字进行类型转换。价格字段有时候是“押一付三”这种描述真正的月租金隐藏在描述条件里这种特殊case需要在清洗阶段单独处理。同一套房源可能在多个时间点被重复采集需要根据URL或者房源编号去重。还有缺失值的处理楼层为空、朝向为空这样的记录要么剔除要么填充阈值。以面积字段的清洗为例我当时写了一个专门的清洗函数import re def parse_area(raw_area): if not raw_area: return None match re.search(r(\d(\.\d)?), raw_area) if match: return float(match.group(1)) return None如果面积字段的数值明显异常比如超过200平米的“普通住宅”或者低于10平米的“整套房源”我基本会标为异常值剔除掉。说到缺失值对于价格这种关键字段我采用策略是直接丢弃整条数据而对于楼层、朝向这类辅助特征则是按“未知”类别填充。数据清洗对后面的模型效果影响有多大我实测下来清洗后K-means的轮廓系数从0.35提升到了0.62线性回归的R²从0.51升到了0.78提升幅度非常大。这一部分在毕业论文里很好写也比较好出图展示清洗前后的数据对比。3. 数据分析算法实现与参数调优3.1 K-means聚类城市租金层次租房价格会受到区域位置的强烈影响但到底有哪些“租金层次”我们事先并不知道。K-means聚类就可以帮我们自动把这批房源数据划分为若干个有意义的层次组。K值的选择非常关键。K值太小聚类结果太粗糙市中心黄金地段和普通城区的位置优势体现不出来K值太大各个类别的区分度又会变得模糊展示出来的图表让人一脑袋浆糊。我用的方法是肘部法则加业务场景验证分别跑K2到K8画出簇内误差平方和(SSE)随K变化的曲线找到“肘部”所在的位置作为参考然后再结合业务含义确认最终K的取值。这个项目我选了K5聚出来的类别大致对应核心商业区高价房、热门区域中高价位、普通城区常规价位、近郊低价位、远郊极低价位。这五层的平均租金递减趋势非常明显从图形化展示来看非常直观。特征标准化这一点值得特别强调。K-means是基于距离计算的算法如果面积取值范围是20到150而租金取值范围是1500到15000面积的特征会被租金完全淹没。所以我在聚类前对面积、租金、经纬度这些数值型特征全部做了Z-score标准化保证了每个特征在距离计算中权重一致。from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler import pandas as pd df pd.read_sql(SELECT area, price, lat, lng FROM house_info WHERE area IS NOT NULL, engine) features df[[area, price, lat, lng]].copy() scaler StandardScaler() features_scaled scaler.fit_transform(features) kmeans KMeans(n_clusters5, random_state42, n_init10) df[cluster_label] kmeans.fit_predict(features_scaled) cluster_stats df.groupby(cluster_label).agg({ price: mean, area: mean, lng: mean, lat: mean }).round(2) print(cluster_stats)除了特征标准化之外随机种子random_state的固定也很重要。K-means依赖随机初始化如果不固定随机种子每次运行聚类结果都可能不同后期复现实验的时候就会发现结果对不上。固定为42是我试过比较稳定的选择也能保证论文里实验数据的可复现性。还有一点n_init默认值是10但我在开始时没注意这个参数运行过程中出现过聚类结果不稳定。后来把n_init显式设置为10确定每个K值都从10个不同的初始中心点里选出最优结果问题就解决了。3.2 线性回归搭建租金预测模型聚类分析解决的是“现在各区域租金是什么样的分布”线性回归则解决“给定一套房源的特征它的租金应该是多少”。这两个问题一前一后从“描述现状”到“预测未来”整个项目的逻辑就完整了。线性回归我用Scikit-learn的LinearRegression实现。特征选择方面我清洗后的维度包括面积、卧室数量、所在楼层、朝向、所在城区、是否有电梯等。其中城区和朝向是类别型变量需要做One-Hot编码转换成数值。楼层特征我做了分级处理分为低层、中层、高层、顶层四个档位然后再One-Hot编码。模型训练前要把数据集按8比2划分为训练集和测试集同时为了保证训练结果的可复现性设置了固定的random_state。训练完成后评估指标看重两个R²决定系数反映模型的整体拟合优度MAE平均绝对误差反映预测价格和真实价格的平均偏差大小。在价格单位为“元/月”时MAE在300元左右算比较理想。from sklearn.model_selection import train_test_split from sklearn.linear_model import LinearRegression from sklearn.preprocessing import OneHotEncoder from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.metrics import r2_score, mean_absolute_error feature_cols [area, bedrooms, floor_level] categorical_cols [district, orientation, has_elevator] preprocessor ColumnTransformer( transformers[ (num, passthrough, feature_cols), (cat, OneHotEncoder(dropfirst, handle_unknownignore), categorical_cols) ] ) model Pipeline(steps[ (preprocessor, preprocessor), (regressor, LinearRegression()) ]) X_train, X_test, y_train, y_test train_test_split( df[feature_cols categorical_cols], df[price], test_size0.2, random_state42 ) model.fit(X_train, y_train) y_pred model.predict(X_test) print(fR²: {r2_score(y_test, y_pred):.4f}) print(fMAE: {mean_absolute_error(y_test, y_pred):.2f})One-Hot编码中的dropfirst参数设置很关键它把每个类别变量的第一个类别去掉避免产生完全共线性的问题。这一点在答辩时如果被问到“为什么做One-Hot编码”的时候可以作为加分点详细解释。特征的重要性分析也值得写。通过model.named_steps[regressor].coef_可以输出每个特征的系数。我跑出来的结果符合直觉面积的正向影响最大房间数量其次市中心城区的虚拟变量也是强正向特征而楼层高度的系数相对较小。这些系数可以用来解释模型回答许多答辩老师会问的问题——“模型怎么解释它的预测逻辑”3.3 为什么线性回归用在这个场景是“够用”的房价预测听上去是个典型的非线性问题可能有人会用随机森林或者XGBoost。但作为毕业设计需要找一个性能和可解释性的平衡点。线性回归从性能上看足够支撑这个场景的演示我的样本量在两万条左右特征维度不算高线性回归效果不错运行速度几乎是秒级完成。从解释性上看线性模型能给出每个特征的权重系数可以画成条形图放论文里答辩时能讲清楚“面积增加10平米租金大约会增加多少”数学公式也很直观。换成XGBoost虽然精度可能更高但可解释性会弱很多而且调参工作量直线上升。话虽如此我在项目里还是在算法部分保留了一个进阶接口代码中预留了切换模型的位置。如果后续想扩展可以无缝接入随机森林或者GBDT做个性能对比实验这也正好能成为论文里“未来工作”的一部分。4. Django平台整合与可视化方案4.1 Django项目如何调度爬虫和算法模块整个平台的架构分为三层数据采集层、分析层、展示层。Django处于展示层和分析层之间既要负责调用Scrapy和算法又要将结果数据传递到模板中进行渲染。实现方式上我在Django项目里为爬虫和算法分别封装了服务模块。在views.py中通过subprocess或者Celery异步任务触发Scrapy爬虫把房源数据从原本为空的表填充起来算法部分则是通过读取MySQL中的清洗数据调用训练好的pkl模型文件把聚类标签、预测结果回写数据库或者直接传参给前端。一个实用的设计是先把训练好的模型保存成本地pkl文件。这样Web平台展示预测结果时不需要重新训练模型直接加载pkl文件就可以对新的房源输入做实时预测响应速度只有几十毫秒。import joblib # 训练完成后保存模型 joblib.dump(model, rent_price_model.pkl) # Web预测时加载模型 loaded_model joblib.load(rent_price_model.pkl)Django的视图逻辑里当然要考虑在多线程环境下加载模型对象实例时可能出现的线程共享问题。我现在的做法是每请求一个实例不过还是有人喜欢放到缓存里去处理这个性能问题说到底还是看具体场景。如果只是演示每请求加载一次完全够用。4.2 ECharts做可视化地图、散点图、柱状图一个都不能少可视化是这个项目的门面数据分析和算法结果最终都要靠图表“讲出来”。我用的方案是前端引入ECharts后端通过Django的JsonResponse返回标准化数据结构然后前端用Ajax请求接口拿数据再渲染图表。核心图表包括以下几类。租金分布地图根据房源经纬度在城市地图上绘制散点图颜色映射租金高低一眼就能看出租金的热力分布。K-means结果用不同颜色区分聚类层次并在地图上叠加展示直观呈现“哪个区域属于哪个租金段”。线性回归结果则用价格对比散点图表示——横轴是真实租金纵轴是预测租金预测点越接近yx对角线说明模型越准这个是答辩时最出效果的一张图。最后再配合每个城区的房源数量柱状图和均价折线图页面信息一下就丰满了。Django后端返回数据时要注意效率。数据量太大时一次性返回全部记录会导致前端渲染卡顿。我实际测试了两万条数据直接渲染地图散点图浏览器会很吃力。后来改成接口层面做数据聚合地图模式只返回每个聚类中心点加平均租金列表模式做分页前端一页渲染几百条完全流畅。4.3 前端页面设计走什么路线设计风格上我没有做大而全的后台管理界面而是聚焦三个核心页面。首页是城市租金总览展示房源总量、均价、最高最低价、平均面积等统计卡片下面接一张地图热力图。分析页放K-means聚类结果左边是聚类参数选择面板右边是聚类散点图和统计表。预测页是线性回归交互入口用户输入面积、户型、城区、楼层这些条件点击按钮就返回预测租金。这套页面设计的核心逻辑是顺着项目故事线走从“有多少房源”到“租金如何分层”再到“给定条件租金多少”每一个页面回答一个问题答辩时照着页面顺序讲逻辑会很顺畅。Django的模板机制在页面复用上很省事base.html定义导航栏和侧边栏子页面通过extends继承只需要专注于内容区域的实现。Ajax交互这一块我建议用原生的fetch数据格式统一为JSON代码量少调错方便。5. 开发过程中踩过的坑与排查经验5.1 爬虫数据入库的重复与缺失问题第一版爬虫跑完我检查数据库发现两万条记录里居然有将近四千条是重复的。原因有两个一是Scrapy爬列表页时页与页之间偶尔会包含重复的房源链接二是详情页更新时间和列表页信息间隔导致同一房源被多次抓取。最开始只用了MySQL主键约束但很快发现租金和发布信息的版本更新让主键去重显得太死板。后来我在Pipeline里加了基于Redis集合的URL去重机制每处理一个item先检查这个URL是否已经出现过出现过直接丢弃。这个方案在Scrapy的多进程环境下也能正常工作不会出现并发重复问题。缺失字段的处理在调研后也有调整。位置信息确实拿不到全部经纬度很多房源页面只有文字描述没有坐标。后期我引入了一个地理编码接口根据地址文字反查经纬度这样地图可视化才跑得起来。但注意到这类外部接口有调用次数限制所以重复利用缓存并分批跑。5.2 K-means聚类效果差的排查最开始聚类效果很差问题出在标准化这一步。忘记做特征标准化的时候聚出来的五类几乎纯粹是按租金高低排的面积和位置几乎没什么作用。特征标准化之后类别区分度就合理多了租金、面积和地理位置都在发挥作用轮廓系数也从0.35到0.62。还有一次聚类出来某个类别的中心点落在了城市版图之外排查发现是房源数据里混进了几条经纬度异常的记录——坐标解析错误指向了其他城市。后来在数据清洗阶段增加了一个简单的经纬度范围校验超出目标城市经纬度范围的记录直接剔除这个问题就解决了。5.3 Django页面加载速度慢页面加载慢的根源在于地图散点图一次性渲染了全部房源点。首次优化的方案是按城区聚合数据页面加载时只展示城区级别汇总缩放放大后按需请求明细数据。这个简单调整就让页面从8秒加载降到了不到1秒。第二个优化点是数据库查询这块把Admin后台之外常用查询的关键字段加了联合索引。比如按城区和价格范围的查询在分析页用得最多做成联合索引后查询速度提升很大。5.4 算法模块和Web整合时的坑模型文件pkl在Web环境和训练环境跑的Python版本不一致时会加载失败。我之前在本地用Python 3.10训练并保存了模型部署到项目容器时环境是Python 3.8直接报错。解决方案是在同一个环境里训练保存和加载。如果未来改动环境一定在保存模型后做好版本记录。线程安全问题之前也提过因为模型加载本身有开销如果每次请求都从磁盘加载一次pkl高并发情况下CPU占用很猛。我后面改成了全局单例模式首次加载后放到进程内缓存后面的请求直接复用。因为模型只做推理不改状态这个方案实测稳定。6. 项目改进方向与个人经验总结6.1 下一步往哪里扩展这个项目给我最大的感触是它的“可插拔性”。如果时间充裕以下方向都值得尝试。爬虫源可以扩展到多个城市对比分析从单一城市到一线、新一线、二线城市的租金差异对比会让分析维度丰富很多。算法层面把线性回归升级成随机森林或者GBDT做一个不同模型性能对比的实验章节放在论文里整个论文的深度会明显提升。数据库层面可以把MySQL换成ClickHouse大数据场景下查询性能会更强。前端可视化层面加入ECharts的关系图或者迁徙图分析结论的展示形式会更加多样性。6.2 给学弟学妹的几点实在建议如果你们也想做类似的数据分析类毕设我自己的实战总结是下面这几点。第一技术栈一定要选自己真正能驾驭的。Scrapy、Django、Sklearn这三样能掌握到熟练程度就已经足够撑起一个优秀毕设了不需要追求上深度学习的噱头。项目复杂度并不等于技术数量和名词堆砌把一条链路做通做透比什么都强。第二数据采集量是硬指标。几百条数据跑出来的聚类和回归结果毫无说服力至少采集5000条以上的有效数据图表展示才有宏观规律可言。即便只做一个城市的租房数据也尽量把房源样本量做上去。第三答辩讲“链路”而不是讲“功能”。从爬虫如何反爬到数据清洗如何解决脏数据到特征工程如何取舍再到聚类结果如何解读、回归模型如何评估每一步其实都有自己的技术和思考在里面。把这些细节向老师展示出来比简单演示一个能跑通的路由增删改查要加分得多。第四注意保存每个版本的中间结果。聚类轮廓系数比较、特征重要性排序、清洗前后数据对比这些过程数据全部留着放进论文既增加可信度也方便答辩时展示。最后再提醒一句项目源码和数据库结构一定要做好注释和文档代码注释里写清楚每一步的理由。你会发现过两个月回来改代码时救你的永远是当时的注释。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进