我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Python爬虫与随机森林结合的房价预测系统实战

Python爬虫与随机森林结合的房价预测系统实战 做这个项目之前我其实已经攒了一整年的二手房交易经验。当时是帮本地一个做房产咨询的朋友搭内部工具他每天都在各大公开房产信息平台翻挂牌数据手动记录小区、面积、总价这些字段然后再凭经验估价。每次要整理几十套房子眼睛都快看花了。他问我能不能做一个自动化系统把网页上的公开房源信息抓下来存好再训练一个模型输入户型、面积、朝向这些条件就能自动给出参考价。于是就有了这个基于Python爬虫随机森林算法的房价数据分析与预测系统。这个项目不算大但链路非常完整从网络爬虫采集数据、Pandas清洗特征到scikit-learn训练随机森林模型再到最后的可视化展示每一步都踩过不少坑。我认为它最大的参考价值是帮你把一个典型的数据分析项目从头到尾走通数据不是现成的得自己抓抓回来不是能直接用的得自己清理模型不是一上来就调参得先理解数据和业务。文章后面我会按实际开发顺序把爬虫模块、特征工程、随机森林调参、系统封装这四块拆开讲每部分都给出关键代码和避坑说明。1. 项目整体设计与技术选型1.1 先看需求再看技术任何项目动手之前我都习惯先把需求拆成几个可以落地的点。这个项目表面上是“房价预测”但实际拆开是这样的数据采集层需要从公开房产平台抓取二手房挂牌列表字段包括小区名称、所在区域、户型、面积、朝向、装修情况、楼层、建筑年代、总价和单价数据存储层要足够简单一个结构合理的CSV文件就能满足数据加工层要把网页上的非结构化文本转成模型能吃的二维表格算法层要用随机森林回归模型对房价进行拟合和预测展示层要能输入一组特征快速得到预测价格同时输出一些可视化图表辅助判断。这套需求决定了技术选型基调追求高效和可读而不是为了分布式而分布式。数据量级在单机可以处理的范围内没必要上Spark或者Hadoop。我最终选择了Python 3.8作为开发环境爬虫用requests加BeautifulSoup数据处理用Pandas建模用scikit-learn可视化用matplotlib。这一套组合是数据分析项目里的“标配”安装简单社区资料多遇到问题基本都能搜到答案。1.2 为什么选随机森林而不是线性回归很多人一听到房价预测第一反应是线性回归。但房价和面积、位置、房龄、装修这些因素之间并不是简单的线性关系。举例来说面积从50平增加到90平总价可能是平滑上涨但同样面积学区房和非学区房的价格可能差了30%。这种交互效应和局部非线性线性回归需要手动构造很多交叉特征才能模拟而且对异常值非常敏感。与此同时房价数据里经常出现一些特殊房源顶层复式、带花园的一层、老城区“老破小”这些都会让线性模型非常难受。随机森林作为Bagging集成的代表本质上是通过构建多棵决策树再取平均来完成预测。它不需要做特征归一化因为它基于分裂规则而非距离它对异常值不那么敏感因为单棵树的影响会被多棵树的平均摊薄它能自动捕捉特征之间的非线性交互。我当年在一份课程设计里用线性回归做过类似的房价预测R²在0.6左右换成随机森林之后直接到了0.85。所以这个项目我一开始就把随机森林回归定为主模型。当然我也不是没考虑过XGBoost和LightGBM。这两个模型在竞赛里的表现通常更好但它们对参数更敏感调参成本高而且在小规模数据上看不出明显优势。随机森林只有几个核心参数默认值就能跑出一个不错的基线非常适合作为第一个落地模型。如果你想进一步压榨精度完全可以在随机森林的结果上再尝试XGBoost但先跑通整个流程保持“数据-特征-模型-结果”闭环的一致性和稳定性才是这个项目阶段最该做的事。1.3 系统架构与技术栈整个系统相当于一条单向流水线。第一步爬虫模块请求目标网站的列表页解析HTML提取房源信息第二步把解析结果写入CSV文件这一步起到持久化作用第三步用Pandas读取CSV做数据清洗、缺失值填充、异常值截断、文本特征转换得到特征矩阵第四步划分训练集和测试集在训练集上拟合随机森林回归模型第五步用测试集评估模型输出R²、MAE、RMSE第六步把模型保存下来配合命令行交互或者Flask接口输入新特征后返回预测价格。技术栈这块我列一张实际用到的表格模块技术选型作用数据采集requests BeautifulSoup请求页面、解析HTML数据存储CSV文件轻量存储方便Excel查看数据处理Pandas NumPy清洗、转换、特征工程建模评估scikit-learn随机森林回归、评估、调参可视化matplotlib seaborn绘制预测误差、特征重要性图接口封装Flask可选将模型封装成HTTP接口开发环境Python 3.8 VSCode开发调试这套架构最明显的优点就是干净每一层都只用最基础的组件容易排查问题。我之前也考虑过把数据存进MySQL但抓回来的数据量在几千到几万条级别CSV完全够用。MySQL反而会增加环境配置成本。如果你未来要服务多用户或者需要增量更新数据再把存储升级成SQLite或MySQL也不晚。2. 爬虫模块把公开数据变成可用数据2.1 目标站点分析与合规边界爬虫是这个项目的起点也是翻车概率最高的地方。我选目标站点时定了几条规矩只抓公开可访问的列表页不碰需要登录才能看到的用户信息抓取前查看对方网站的robots.txt确认是否允许爬取页面数据只用于学习研究不做商业化倒卖。这既是法律底线也是技术底线。很多爬虫教程喜欢教人绕各种反爬机制但真实项目里合规和克制反而能让代码更长寿。拿到目标站点后我先用浏览器开发者工具看了它的结构。列表页的URL有规律翻页参数就是页码。每套房子的信息都位于一个列表项的HTML节点内小区名称、面积、总价这些信息各自有对应的class。这些信息基本都在HTML源码里直接可见不需要等待JavaScript渲染所以用requests拉取HTML就够。如果你遇到的是动态渲染页面requests拿到源码后可能只有空壳那种情况要么去找JSON接口要么上Selenium但那种方案维护成本更高这里不展开。2.2 一个能跑的爬虫骨架爬虫的完整逻辑我写成了一个类和几个辅助函数。核心步骤就是构造请求头、循环翻页、解析列表项、提取字段。一个基础版本看起来是这样的import requests import time import random import csv from bs4 import BeautifulSoup headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example.com/ } def fetch_page(url): resp requests.get(url, headersheaders, timeout10) resp.encoding resp.apparent_encoding return resp.text def parse_page(html): soup BeautifulSoup(html, html.parser) items soup.select(.house-list li) rows [] for item in items: title item.select_one(.title).text.strip() area item.select_one(.area).text.strip() price item.select_one(.price).text.strip() rows.append({title: title, area: area, price: price}) return rows all_data [] for page in range(1, 11): url fhttps://example.com/pg{page}/ html fetch_page(url) all_data.extend(parse_page(html)) time.sleep(random.uniform(1, 3))这段代码里有几个细节值得展开。首先headers里的User-Agent一定要设置很多站点会拦截默认的Python客户端请求Referer尽量填成同站地址让请求看起来更自然。其次resp.apparent_encoding会根据页面内容自动推断编码比直接写死utf-8更可靠能有效避免中文乱码。最后time.sleep(random.uniform(1, 3))是抓取间隔这个“随机”很关键——固定1秒反而容易被识别成脚本随机间隔会让模拟浏览更真实。解析部分我用的是CSS选择器实际上也可以用XPath。如果你是XPath的忠实用户把BeautifulSoup换成etree.HTML也一样核心逻辑不变。这里我强调一点不要在爬虫代码里堆一把梭把页面解析单独封装成parse_page函数非常重要因为网站的HTML结构随时可能微调封装后用一条print语句就能定位问题出现在请求层还是解析层。2.3 字段清洗与数据落盘爬下来的数据一定带着网页上的脏文本。比如“89.76㎡”“3室2厅”“南北”“850万”“5.8万/㎡”这些字符串不能直接进入模型需要先转成数值和可用的类别。我习惯在爬虫阶段先保留原始字符串等到Pandas阶段再统一处理。这样做的原因是抓取阶段的目标是尽可能不丢数据清洗阶段才适合做规则过滤。如果清洗逻辑写在爬虫里一旦出现漏抓原始信息就没了后续想重新对应会非常麻烦。落盘时有两个踩坑点。第一是CSV编码直接用encodingutf-8写入Excel打开会乱码我用的是utf-8-sigBOM头可以让Excel正常识别。第二是写入方式我一开始是每抓一页就打开一次文件写一行结果因为中途页面解析异常导致文件只写了一半。后面的做法是先把所有数据存在内存列表里最后一次性写入。如果数据量很大就分批次写入并加上进度日志方便失败后定位。CSV的列我建议保留小区名称、区域、户型、面积、朝向、装修、楼层、建筑年代、总价、单价、抓取URL。抓取URL看着不起眼但在查重和数据溯源时非常有用。2.4 爬虫过程中的常见干扰这里分享几个爬虫阶段最容易遇到的问题。第一是请求头不够完善部分站点会检查Accept-Language、Connection等字段如果只设置User-Agent仍然返回403就去浏览器的开发者工具里把完整的Request Headers复制下来并改成字典形式。第二是列表页的房源条数和页码不完全对应某些平台会自动过滤重复房源导致最后一页数据不足我采用的方式是解析完当前页后判断是否存在“下一页”按钮没有就停止循环而不是傻傻地抓完固定页码。第三是频率控制我之前为了图快把sleep调到0.1秒抓了200条后果然被限制访问。后来把间隔调到1到3秒再配合重试机制问题就消失了。需要特别说明的是如果你因为访问频率过高被暂时限制最好的处理是停下来等待一段时间而不是反复用各种方法强行绕过对方的风控。这既是平台规则的要求从长期维护角度也是更聪明的选择。你需要的只是数据质量和系统稳定性没必要在对抗机制上花太多时间。3. 数据清洗与特征工程真正决定模型上限的部分3.1 缺失值与异常值处理数据抓回来之后直接用df.head()和df.info()观察结构。常见的缺失情况是部分房源的装修信息为空或者“建筑年代”字段没有值。我处理的原则是如果某个字段的缺失比例超过20%直接放弃这个字段如果缺失比例较小就用众数填充。例如装修类型缺失值可以填“简装”朝向缺失就可以填“未知”。但要注意填充不是越“聪明”越好关键是不能让填充动作给模型注入虚假信息。比如不能因为“大部分房子朝南”就把所有缺失朝向都填成“南”这会让模型高估朝南对价格的影响。异常值处理比缺失值更需要业务判断。房价数据里最典型的两类异常一类是地下室或车库面积不到10平却挂着“住宅”标签总价异常低另一类是花园洋房面积特别大单价异常高。我在这个项目里做的是先绘制单价的箱线图再用分位数截断——单价比1%分位低或比99%分位高的样本直接剔除。你可能会觉得这样丢数据太粗糙但实际效果是模型的稳定性和可解释性都会提升。树模型虽然对异常值相对不敏感但极端值仍然会占用分裂空间导致预测区间被拉宽。3.2 类别特征编码与特征变换特征工程这一步我把它拆成三件事文本转数值、类别特征编码、目标变量变换。文本转数值最常见的三类是面积、总价和单价。原始文本可能是“89.76㎡”“850万”这样带符号的我用正则提取数字re.findall(r\d\.?\d*, text)得到字符串类型后转成float。如果文本里同时有“万”和“元/㎡”要先把总价统一成“万元”再存避免单位混乱。户型字段“3室2厅”先保留但实际进入模型的不是原字符串而是从中拆出室数和厅数两个数值特征这样模型更容易理解。类别特征我用的是有序映射。以装修为例毛坯、简装、精装、豪华装本身有层次映射成0、1、2、3是合理的朝向则映射成“是否朝南”和“是否有东或西向”这两个特征对房价的影响方向明确。区域字段比较复杂先用LabelEncoder编码成数字。对于随机森林来说LabelEncoder和OneHotEncoder都能用但浏览器里小区域数量可能超过50个OneHot编码会让特征矩阵膨胀我选用LabelEncoder来保持特征维度简洁。有一点要记住所有编码过程都要基于训练集拟合然后把映射应用到测试集而不是对全量数据一次性编码否则会引入未来数据信息导致评估指标虚高。目标变量变换是我在这个项目里最重要的一步。房价总价是典型的右偏分布少数高价豪宅会把预测模型带偏。我把总价做了对数变换y np.log1p(df[总价])让分布更接近正态。模型训练时预测的是对数价格展示给用户时再np.expm1还原。这样处理后模型在高低价房子上都能给相对合理的预测而不是一味保大弃小。3.3 相关性观察与特征筛选在训练之前我喜欢先把特征和目标的相关性矩阵跑出来看一眼。虽然随机森林不怕共线性高相关特征也不会像线性回归那样导致系数不稳定但特征之间高度冗余会白白增加训练时间还会让特征重要性分散。我当时的处理是把“总价”和目标变量一起做对数变换因此“单价”这个特征和总价高度相关在预测总价时直接删掉“单价”避免特征泄漏。特征筛选除了看相关性还要看业务逻辑。比如“小区名称”的文本如果直接LabelEncoder会产生几百个虚拟类别对树模型来说是灾难。更好的做法是把区域、经纬度、周边配套这些能泛化的信息保留把小区名这种粒度太细的标识从特征矩阵里移除。除非你要做的是只有固定小区列表的封闭场景否则模型拿到新小区名时会无从下手。这个取舍在建模之前就要想清楚。4. 随机森林回归模型原理、训练与调参4.1 训练集构建与评估指标当特征矩阵准备好了接下来就进入建模环节。我用train_test_split按8:2划分训练集和测试集指定random_state42保证每次运行结果可复现。这个细节看起来不起眼实际调试中很重要。如果每次都随机划分你会分不清模型效果变化是调参带来的还是数据切分带来的。对小型项目来说固定随机种子是最低成本的实验管理方式。房价预测回归任务的评估指标我同时看三个R²、MAE和RMSE。R²告诉你模型解释了百分之多少的方差MAE是用原单位度量的平均误差RMSE因为对较大误差给更高权重所以能看出预测是否存在极端偏差。在房价场景下我更关注MAE因为它直接对应“预测价与真实挂牌价平均差多少钱”。如果你发现R²很高但MAE很大多半是目标变量没有做对数变换导致模型把注意力放在少数高价房源上。4.2 随机森林关键参数随机森林的核心思想是训练多棵决策树每棵树用有放回抽样得到的子样本训练并在每个节点分裂时随机选取部分特征最后回归取平均值。因为每棵树都很“笨”但很多棵不同的树组合起来既可以拟合复杂关系又能抑制过拟合。理解这一点之后就明白参数调整的方向了。参数作用常见范围调参心得n_estimators树的数量50-300太少欠拟合太多训练慢且收益递减max_depth每棵树最大深度5-20不设限制容易过拟合设太浅偏向线性min_samples_split内部节点分裂最少样本数2-10增大可以防止树学得太细min_samples_leaf叶子节点最少样本数1-5增大会让预测更平滑但可能损失精度max_features每次分裂考虑的特征数auto/sqrt回归问题默认是特征数的1/3我一开始就用默认参数跑了一遍基线R²在0.84左右。这时候不要急着上网格搜索先看测试集的预测误差分布图确认误差是否存在系统性偏移比如大面积房源都被低估说明特征没处理好。只有当误差分布看起来比较均匀才值得花时间调参。否则调参只是在掩盖数据阶段的错误。4.3 网格搜索调参实例调参我用GridSearchCV加5折交叉验证结合一个有限的参数网格避免搜索空间太大导致运行时间不可接受。实际搜索代码如下from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import GridSearchCV param_grid { n_estimators: [100, 200], max_depth: [None, 10, 15], min_samples_split: [2, 5], min_samples_leaf: [1, 2] } rf RandomForestRegressor(random_state42) grid GridSearchCV(rf, param_grid, cv5, scoringr2, n_jobs-1, verbose1) grid.fit(X_train, y_train) print(grid.best_params_) print(grid.best_score_)这里有几个细节要说清楚。n_jobs-1表示使用所有CPU核心并行训练能明显提速但如果你用的是Windows系统并且代码写在脚本里多进程并行训练需要把主流程放在if __name__ __main__:下否则会报错或者导致进程无限循环。verbose1可以打印搜索进度方便判断还要等多久。scoringr2是优化R²你当然也可以改成neg_mean_absolute_error看你的业务更在意哪一种误差。搜索结果通常会指向max_depthNone或较浅深度这是正常现象。因为随机森林有Bagging机制稍微增加深度不会立刻过拟合但完全没有限制会让训练时间变长。如果你发现最优深度很浅比如5那说明数据量可能不够或者特征信号本身就弱此时强行加深树反而会开始记忆噪声。4.4 特征重要性与模型解释随机森林的一个好处是内置特征重要性。训练好的模型可以通过feature_importances_查看每个特征对预测的贡献。我在项目里做完这一步惊喜地发现与业务直觉完全吻合面积、区域编码、建筑年代、装修等级排在前列卧室数量反而没那么重要因为面积本身已经包含了大部分信息。特征重要性不只是拿来写报告它也能指导后续迭代。比如“楼层类型”的重要性不高反向说明我在楼层分组时太粗糙没有体现出高层和底层之间的价差那么就可以去搜索引擎调研一下或者再细分成“设备层”“带电梯”“无电梯”等字段。这个过程才是特征工程真正开始发挥价值的地方。当然单棵树的重要性和整体森林的重要性会有波动我会多做几轮训练观察排序是否一致一致的特征才值得信任。5. 系统实现与预测可视化5.1 命令行交互版本模型调好之后不能只活在Jupyter里要包装成一个可用的系统。我做的第一版是命令行交互加载训练好的模型提示用户输入面积、户型、区域、装修等特征然后输出预测总价。为此我把整个流程封装成HousePricePredictor类内部处理特征编码映射和模型加载外部只暴露一个predict方法。这种封装带来的最大好处是后续切换到Flask接口时只需要在视图函数里调用相同的方法业务逻辑完全复用。命令行版本的核心思路很简单把训练阶段用到的LabelEncoder保存成pickle文件用joblib.dump保存模型预测前先用相同的处理器把用户输入转换到和训练集一致的特征空间。如果没有这步模型会直接在Raw输入上报错或者更隐蔽地编码错位之后给出完全无意义的价格。我建议在处理器的保存上用joblib而不是pickle因为joblib对大数组更高效而且兼容性也更好。5.2 可视化展示可视化主要画三张图。第一张是真实价格和预测价格的散点图以真实价格为x轴预测价格为y轴再叠加一条yx参考线。如果散点大部分落在参考线附近说明模型误差较小如果上下偏差有规律地弯曲说明某些区间有系统性误差。第二张是预测误差的分布直方图可以直观看到误差集中在哪个区间。第三张是特征重要性柱状图用来支撑业务分析和后续迭代。画图时我会用seaborn代替纯matplotlib因为默认配色和样式更接近现代数据报告代码也少。需要注意的一个坑是如果你预测的是对数价格画散点图之前一定要先np.expm1还原不然横纵轴单位会是log(万元)普通读者根本看不懂。我在第一版就犯过这个错图里看着都是3到5的小数还以为模型输出错了排查了半天才发现是目标变换没还原。5.3 升级方向Flask部署为Web服务命令行工具能自己用但朋友要的是“同事也能用”那最好做成网页。Flask是最轻的方案。我写了一个简单的API接收JSON格式的房源特征返回预测价格from flask import Flask, request, jsonify import joblib app Flask(__name__) model joblib.load(house_price_model.pkl) app.route(/predict, methods[POST]) def predict(): data request.get_json() features preprocess_input(data) price model.predict([features])[0] return jsonify({predicted_price: round(np.expm1(price), 2)}) if __name__ __main__: app.run(host0.0.0.0, port5000)这版接口非常薄没有加任何鉴权。如果你要部署到公网供多人使用强烈建议在Nginx层做限流或者加简单的验证码机制防止接口被脚本刷爆。这个话题在“后端防爬”里经常出现原则是一样的对高频访问做控制对异常行为做识别但对正常用户不造成干扰。可以作为这个项目的扩展方向也是另一个独立的技术专题。6. 踩坑记录与复盘整理6.1 问题排查速查表我把整个项目过程中真正遇到的问题整理成表格方便以后复用。这些问题出现的频率非常高你照着排查就能定位80%的问题。症状可能原因解决办法爬虫拿到乱码页面编码判断错误resp.encoding resp.apparent_encodingXPath或CSS选择器取不到数据页面结构变了或数据由JS加载检查响应内容必要时抓JSON接口“850万”转float报错字符串里有非数字字符用re.findall(r\d\.?\d*, text)提取预测结果偏小一大截目标变量做了log变换但预测后没还原np.expm1还原R²很高但MAE极大目标变量偏态未处理对总价做log1p变换随机森林训练非常慢n_estimators过大或特征过多降参或先做特征选择LabelEncoder编码错位训练和预测使用了不同映射对象用同一个pickle/joblib文件反复加载新数据中区域编码报错新区域没有出现在训练集里用“其他”类别兜底排查的总体原则是先数据后模型。每次模型指标异常先问自己训练集和测试集的分布一致吗特征编码做了同样的映射吗目标变换有没有还原大多数问题出在这些基础环节而不是算法本身。6.2 个人实操心得最后分享一些我个人的体会。做这个项目我最深的感受是数据采集和处理占用了大约70%的精力模型反而是最省事的一部分。很多人一开始学机器学时喜欢研究算法原理但真正做完一个端到端的项目就会意识到特征工程决定上限数据质量决定下限算法只是把已经准备好的信息变成答案。另一个体会是爬虫一定要带有敬畏之心。抓取公开信息要节制、要合规宁可抓得慢一点、少一点也不要给自己惹麻烦。我在这个项目里始终坚持单线程加随机延迟即使面对的是公开数据也从未使用任何绕过风控的手段。一个合格的数据工程师应该把更多的精力放在数据如何服务业务上而不是如何钻规则空子。如果你也想复现类似项目我的建议是先不要追求复杂。先抓100条数据手工标注几个字段用默认参数的随机森林跑通再逐步扩展。把流程先跑通再优化比一步到位要可靠太多。最后再说一个小技巧随机森林调参时别一上来就GridSearchCV先用默认参数跑一遍再看特征重要性和误差分布带着问题去搜索参数你会对结果更有把握。
返回列表