做数据科学项目,我见过太多人一上来就调模型,参数折腾几天,最后发现是数据本身有问题。也有另一类朋友,卡在第一步就动不了——数据不知道去哪拿,后面清洗、预测的事更是无从谈起。这篇文章我打算借一个特别大众的场景——房价预测,把从数据爬取到模型预测的完整流程走一遍。
为什么选房价?因为它足够贴近生活,字段你看得懂,结果你有感知,不会被业务概念劝退。更重要的是,房价数据“量大、字段杂、脏数据多、有异常值”,几乎涵盖了数据科学入门阶段你能遇到的所有经典问题。这篇文章适合正在自学数据科学、刚接触 Python、或者上过一些课但没独立跑通过完整项目的人,可以把它当成一份实操路线图来用。
1. 项目整体设计:先想清楚再动手
1.1 为什么用房价练手
很多人学数据科学,最大的问题不是不会调包,而是不知道一个项目从零到一到底要经过哪些环节。房价预测这个选题,好在三个方面。
第一,数据好理解。二手房挂牌价、建筑面积、户型、楼层、朝向、装修情况,这些字段即使完全没有数据科学背景的人也能看懂。对比一些金融风控或用户画像项目,光理解业务术语就要耗掉一半精力,房价预测没有这个门槛。
第二,任务目标非常明确。我们要做的事情就是拟合“价格”和“房屋属性”之间的关系,这是一个典型的监督学习回归问题。评价标准也很直观:预测的价格和真实挂牌价差多少,偏差越小越好。
第三,脏数据种类齐全。真实爬下来的房源数据里,会有“面积”字段是空白的、总价和单价互相矛盾的、户型写成“4室2厅2卫1厨”这种混合文本的、楼层写着“高楼层(共28层)”的。这些花式脏数据恰好能逼着你去处理缺失值、异常值、文本抽取和类型转换。把这一套流程走完,之后做其他结构化数据项目,基本都能直接复用。
1.2 技术选型:Python生态就够了
整个项目我用的是 Python 标准的数据科学全家桶:requests和BeautifulSoup负责数据爬取,pandas做清洗和转换,matplotlib和seaborn做可视化,scikit-learn完成建模评估。
为什么选这套组合而不是更重的工具?原因很简单:项目范围不大,没必要引入 Spark 或大数据组件;但也不是一个纯教学玩具,它需要真实应对网页解析、数据清洗、特征工程、模型对比这些完整环节。pandas做表格处理是行业标配,scikit-learn里回归模型和预处理工具足够成熟,这套组合既能跑通流程,代码量也不会失控,对新手来说每一行都能看懂。
如果你还没装 Python 环境,我建议直接装 Anaconda,它自带 Jupyter Notebook 和大多数常用库,省掉命令行逐个安装的麻烦;编辑器用 VS Code 或者 PyCharm 都行。安装完成之后在终端跑一下python --version,确认环境没问题,再pip install requests beautifulsoup4 pandas matplotlib seaborn scikit-learn把依赖补齐。
1.3 整个流程的五个阶段
一个完整的数据科学项目,我会习惯性地拆成五个阶段:
- 数据采集:从目标网站爬取原始房源数据,保存成结构化表格文件。
- 数据清洗:处理缺失值、重复数据、异常值,统一字段格式。
- 特征工程:从原始字段中提取、组合出模型真正能用的特征。
- 建模评估:划分训练集和测试集,训练回归模型,用评价指标衡量效果。
- 分析总结:看特征重要性,复盘误差来源,决定下一步优化方向。
很多人一上来就急着写爬虫或者直接model.fit(),但我会建议你先在纸上把这五个阶段列出来,给自己定一个清晰的验收标准。比如“爬下来多少条数据”“清洗后还剩多少条”“模型 R2 达到多少”。有了目标再去动手,过程中才不会盲目。
2. 数据爬取:先把数据搞到手
2.1 数据源选择与字段设计
既然做房价预测,第一步自然是选数据源。我用的是某房产平台的二手房频道,这类平台的房源数据结构化程度高,列表页上有总价、单价、面积、户型、楼层、朝向、装修、小区名称等信息,非常适合做爬取练习。选择数据源有一个原则:优先选列表页信息完整的网站,尽量不要选那种必须点进详情页才能看到核心信息的站点,因为详情页请求量大、解析复杂,爬取难度会成倍增加。
爬取之前,先想清楚要存哪些字段。我的建议是:宁可多存,不要少存。因为后续做特征工程时,你可能会从“小区名称”里提取区位,从“楼层”里判断高低层,从“朝向”里识别南北通透。前期多存几个字段,后面就不用回头补爬。我最终保留了以下字段:
- 小区名称:可能包含商圈信息,比如“XX小区”或“XX路XX号”
- 户型:例如“3室2厅”,用于提取房间数和厅数
- 面积:建筑面积,单位平方米
- 朝向:例如“南”“南北”“东”
- 装修:例如“精装”“简装”“毛坯”
- 楼层:例如“低楼层(共6层)”,包含所在层和总层数
- 总价:单位万元,作为预测目标
- 单价:单位元/平方米,可以用于校验数据一致性
- 所在城区:发布日期或者区域信息,有时也能提供有价值的特征
2.2 爬虫核心代码实现
爬虫的思路不复杂:请求列表页 HTML,解析出房源卡片数据,翻页重复操作,最后把所有数据汇总到 DataFrame 里并保存为 CSV。
核心代码长这样:
import requests from bs4 import BeautifulSoup import pandas as pd import time HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } def parse_list_page(html): soup = BeautifulSoup(html, "html.parser") items = soup.select(".sellListContent li .info") data = [] for item in items: title = item.select_one(".title a") flood = item.select_one(".flood") address = item.select_one(".address .houseInfo") total_price = item.select_one(".totalPrice span") unit_price = item.select_one(".unitPrice span") if not title or not total_price: continue data.append({ "小区名称": title.get_text(strip=True), "户型": flood.get_text(strip=True).split(" | ")[0], "面积": flood.get_text(strip=True).split(" | ")[1], "朝向": flood.get_text(strip=True).split(" | ")[2], "装修": flood.get_text(strip=True).split(" | ")[3], "楼层": address.get_text(strip=True).split(" | ")[0], "总价": total_price.get_text(strip=True), "单价": unit_price.get_text(strip=True).replace("元/平", "") }) return data all_data = [] for page in range(1, 6): # 先爬前5页,验证流程 url = f"https://example.com/ershoufang/pg{page}/" resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code == 200: all_data.extend(parse_list_page(resp.text)) print(f"第{page}页完成,累计{len(all_data)}条") else: print(f"第{page}页请求失败,状态码:{resp.status_code}") time.sleep(1.5) # 控制访问频率 df = pd.DataFrame(all_data) df.to_csv("house_raw.csv", index=False, encoding="utf-8-sig")这里有几个细节值得展开说。
HEADERS里的User-Agent一定要设置。很多网站的反爬策略首先看请求头,如果默认是python-requests/2.31.0这种,很容易被识别为爬虫并直接拒绝。设置成常见浏览器的 User-Agent 是第一步,也是最基本的一步。
解析部分我用的是 CSS 选择器。.sellListContent li .info这个选择器把房源列表里每一条记录的外层容器选中,然后逐个提取内部的标题、户型、价格等信息。写爬虫有个小技巧:先在浏览器开发者工具里看 HTML 结构,确认目标元素的选择器能唯一匹配,再写代码,能少踩很多坑。我这里的选择器是基于通用结构写的,实际项目里你需要根据目标网站的具体结构调整。
翻页逻辑比较简单,直接在 URL 上改页码即可。range(1, 6)表示只爬前 5 页,这是刻意控制的——做练习项目没必要把数据量搞到几十万条,先拿几百条把流程跑通,之后如果要扩大规模,把循环范围改大就行。
最后保存成 CSV 时我用了encoding="utf-8-sig"。这个细节非常关键。utf-8-sig会在文件开头写入 BOM 头,用 Excel 打开 CSV 时中文才不会乱码。如果直接用utf-8,Excel 打开时可能出现中文乱码,虽然 pandas 读回来没问题,但后续人工检查数据时体验会很差。
2.3 爬虫合规与防封经验
关于爬虫,有些话我必须说在前面。
爬取公开数据时要遵守目标网站的robots.txt规则,控制请求频率,不要对目标服务器造成压力;爬下来的数据只能用于个人学习研究,不能商用或批量传播。这是数据从业者的基本底线。我建议爬取间隔至少 1 到 2 秒,不要开多线程并发爬,不是为了别的,单纯就是不要给对方服务器添麻烦,也降低自己 IP 被封的风险。
真正实操时,你可能还会遇到几种典型情况。第一种是 IP 被封,特征往往是请求返回 403 或者验证码页面。遇到这种,第一反应应该是停下来,而不是换 IP 继续硬刚。休息一段时间,降低请求频率再试;如果连续多次被限,就换个数据源。第二种是页面结构和爬虫代码不一致,比如网站上房源卡片结构改版了,CSS 选择器匹配不到内容。解决方法是重新打开网页查看当前结构,调整选择器。第三种是解析出来的字段有缺失或者错位,比如某个房源没有装修信息,flood字段拆分后不够 4 项。这种情况用try...except或显式判断跳过,保证程序不中断。
3. 数据清洗与特征工程:模型能学多好,一半看这里
3.1 缺失值、重复值和异常值处理
爬下来的原始数据 CSV,直接用df.head()看前几行,往往是灾难现场。字段类型是字符串、面积和房价混着“平米”“万元”这种单位、有些行只有一半字段有值。这个时候不能直接建模,必须先做清洗。我的标准流程分四步。
第一步是去重。房源数据经常有重复记录,同一套房被重复抓取或者业主重复挂牌都很常见。判断是否重复时,我用“小区名称 + 户型 + 面积 + 总价”四列作为唯一标识,因为这四列完全相同的两行,基本可以确定是同一套房源。
df = df.drop_duplicates(subset=["小区名称", "户型", "面积", "总价"])第二步是处理缺失值。先看一下每一列的缺失情况:
print(df.isnull().sum()) print(df.shape)如果某些字段缺失比例超过 30%,我会直接删除该列;如果只是个别行缺某个字段,可以选择删除这些行,也可以用众数或中位数填充。房价数据里,缺失值处理要特别注意:总价这一类目标变量如果缺失,直接删行,不要填充,因为填充出来的价格会污染模型。
第三步是剔除异常值。这个环节做得好不好,直接决定模型的根基。对于房价数据,我一般关注两类异常:
- 面积异常:比如面积小于 10 平方米或者大于 500 平方米的房子,很可能是录入错误或非标准住宅(车库、厂房等),直接删除。
- 价格异常:单价特别低或特别高。单价异常通常是房源性质不同造成的,比如有些是“车位”或“商业性质”,虽然显示在二手房列表里,但和普通住宅价格体系完全不同。这种样本混在训练集里,会让模型很困惑。
处理可以用分位数法,也可以设定业务合理区间。
df = df[(df["面积"] >= 20) & (df["面积"] <= 300)] df = df[(df["单价"] >= 5000) & (df["单价"] <= 100000)]第四步是类型转换。原始数据里面积、总价、单价都是字符串,比如“89.5平米”“450万”,要先转成浮点数才能计算。
df["面积"] = df["面积"].str.replace("平米", "").astype(float) df["总价"] = df["总价"].str.replace("万", "").astype(float) df["单价"] = df["单价"].astype(float)转换之后我习惯做一次交叉校验:用“总价 / 面积 * 10000”算出每平方米价格,和“单价”字段比对,如果差异超过 5%,说明原始数据里有录入错误,把这行删掉。这个校验逻辑很多人想不到,但它能有效识别数据质量差的样本。
3.2 从文本字段里抽取结构化特征
原始数据里“户型”“楼层”“朝向”都是混合字符串,比如:
- “3室2厅” → 室数=3,厅数=2
- “低楼层(共6层)” → 所在层=低楼层,总层数=6
- “南北” → 朝向=南北
这些字段不能直接喂给模型,需要拆解成结构化特征。拆解的逻辑可以用正则表达式,也可以用字符串分割。我的实现大概是:
df["室数"] = df["户型"].str.extract(r"(\d+)室").astype(float) df["厅数"] = df["户型"].str.extract(r"(\d+)厅").astype(float) df["总楼层"] = df["楼层"].str.extract(r"共(\d+)层").astype(float) df["所在层"] = df["楼层"].str.extract(r"(.+?)(").astype(str)所在层是类别变量,取值一般是“低楼层”“中楼层”“高楼层”。实际建模时,我发现“总楼层”和“所在层”组合成“所在层占比”(所在层/总楼层)更有意义。比如同样是 5 楼,在 6 层楼房里属于高楼层,在 30 层楼房里属于低楼层。这个比例的物理含义比单独的楼层数更清晰。
3.3 类别特征编码与数值特征归一化
特征工程最后一步,是把类别文本变成模型能理解的数字。常用的方式有标签编码和独热编码。
对于“朝向”“装修”“所在层”这类没有明显大小顺序的类别变量,我一般用独热编码。pandas里一行就能实现:
df = pd.get_dummies(df, columns=["朝向", "装修", "所在层", "所在城区"])对于有顺序的类别,比如“朝向”如果人为规定“南北 > 南 > 东南 > 东 > 北”,可以转成有序数字,但我实测下来,独热编码在这个项目里更稳,因为朝向和房价之间的关系并不是简单的线性增减。
数值型特征要不要归一化?如果你用的是线性回归、岭回归这类对特征尺度敏感的模型,就需要归一化;如果用的是树模型(随机森林、XGBoost 这类),特征尺度不影响分裂结果,可以不归一化。我的建议是先做归一化,因为后续对比基线模型时,线性回归和树模型用的是一套数据,省得来回改。
from sklearn.preprocessing import StandardScaler num_cols = ["面积", "室数", "厅数", "总楼层", "所在层占比"] scaler = StandardScaler() df[num_cols] = scaler.fit_transform(df[num_cols])特征工程做完,我又加了两个常用特征:一是“房龄”,如果原始数据里有竣工年限,可以用当前年份减掉它;二是“商圈均价”,把同城区的房源价格取平均作为特征,这个特征能有效编码区位效应。不过商圈均价这种特征要小心数据泄露——计算时必须只用训练集,后面会详细讲。
4. 房价预测模型:从基线到调优
4.1 数据划分与评估指标
建模前先做两件事:划分数据集、选定评估指标。
数据集划分我用 scikit-learn 的train_test_split,测试集占比 20%,随机种子固定为 42。固定随机种子的意义在于实验可复现,后面换模型、调参数时能保证对比是在同一份数据上进行的。
from sklearn.model_selection import train_test_split X = df.drop(columns=["总价"]) y = df["总价"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 )评估指标我用了两个:R2(决定系数)和 MAE(平均绝对误差)。R2 回答的问题是“模型能解释目标变量多少比例的方差”,MAE 回答的问题是“平均预测偏差多少钱”。R2 负责判断模型拟合质量,MAE 负责把误差换算成实际业务含义。对于房价预测,MAE 如果能在 30 万以内,说明预测误差大约在 6% 到 10% 之间,模型就算有实用价值了。
4.2 线性回归做基线
很多人喜欢一上来就上随机森林或者 XGBoost,我的习惯是先跑一个线性回归作为基线。目的有三个:验证数据管线通不通、得到一组基础指标、为后续对比确定基准线。
from sklearn.linear_model import LinearRegression from sklearn.metrics import r2_score, mean_absolute_error model = LinearRegression() model.fit(X_train, y_train) y_pred = model.predict(X_test) print("R2:", r2_score(y_test, y_pred)) print("MAE:", mean_absolute_error(y_test, y_pred))第一次跑通的时候,R2 大概在 0.6 到 0.7 之间,MAE 可能在 40 万左右。这个成绩说明特征和目标之间确实存在相关性,数据管线没问题,但模型还有优化空间。线性回归效果不够好是正常的,因为房价和特征之间的关系往往不是简单的线性关系,比如面积从 50 平到 100 平带来的价格增量,和从 150 平到 200 平的增量显然不一样,线性模型无法捕捉这种边际递减效应。另外,线性回归对所有特征一视同仁,没办法自动识别特征之间的复杂交互。
4.3 随机森林进阶:特征重要性与非线性捕捉
线性回归验证完数据管线,我换随机森林回归模型。随机森林的本质是构建多棵决策树,每棵树基于不同的样本子集和特征子集训练,最终把预测结果取平均。它擅长捕捉非线性关系,对异常值有更强的鲁棒性,还能输出特征重要性。
from sklearn.ensemble import RandomForestRegressor rf = RandomForestRegressor( n_estimators=300, max_depth=15, min_samples_leaf=3, random_state=42, n_jobs=-1 ) rf.fit(X_train, y_train) y_pred_rf = rf.predict(X_test) print("RandomForest R2:", r2_score(y_test, y_pred_rf)) print("RandomForest MAE:", mean_absolute_error(y_test, y_pred_rf))实测效果通常会有明显提升,R2 能到 0.8 以上,MAE 降到 30 万以内。这时候我会输出特征重要性:
importance = pd.DataFrame({ "feature": X_train.columns, "importance": rf.feature_importances_ }).sort_values("importance", ascending=False) print(importance.head(10))特征重要性会告诉你,哪些字段对价格预测贡献最大。我跑这个项目时,“面积”“所在城区_XX区”“总楼层”“装修_精装”这几个特征排在最前面,说明这些是最核心的房价决定因素。这个结果反过来也验证了特征工程的方向是对的。
如果你想进一步提升精度,还可以试梯度提升模型,比如GradientBoostingRegressor或者XGBoost,它们通常在结构化数据上表现更好。但要注意,这类模型树更多、超参数更多,训练时间更长,调参难度也更大。我的建议是先把手里的随机森林调到稳定,再尝试更复杂的模型,不要一开始就陷入调参泥潭。
4.4 模型调参与交叉验证
随机森林的超参数里,我优先调整三个:n_estimators(树的数量)、max_depth(最大深度)、min_samples_leaf(叶节点最少样本数)。树太少拟合不足,树太多训练变慢且收益递减;max_depth太大容易过拟合,太小拟合不足;min_samples_leaf设置大一点能有效抑制过拟合,特别是在数据量只有几千条的情况下。
调参方法我用网格搜索加交叉验证。GridSearchCV配合 5 折交叉验证,比手动调参快得多:
from sklearn.model_selection import GridSearchCV param_grid = { "n_estimators": [200, 300, 500], "max_depth": [10, 15, 20], "min_samples_leaf": [2, 3, 5] } grid = GridSearchCV( RandomForestRegressor(random_state=42), param_grid, cv=5, scoring="r2", n_jobs=-1, verbose=1 ) grid.fit(X_train, y_train) print(grid.best_params_)交叉验证的核心价值是防止过拟合。如果只用一份测试集反复调参,你实际上是在“适应”测试集,而不是真正提高模型泛化能力。交叉验证把训练集再切成多份,轮流当验证集,得到的结果更接近模型在未知数据上的真实表现。我调完参之后,习惯用最优参数重新训练,再用测试集评估一次,确认最终效果。
5. 实战问题排查与避坑心得
5.1 爬虫阶段:页数越爬越少,解析频繁失败
我遇到过最典型的问题是:爬前面几页好好的,到后面页数返回的数据越来越少,或者某页直接解析出 0 条。这种情况大概率不是网站改版,而是请求频率太高触发了反爬,服务器开始返回验证页面,解析器自然找不到房源节点。
排查方法是打印每次请求的状态码和响应内容长度。如果状态码变成 302 或 403,响应内容长度骤减,基本可以断定是反爬拦截。处理方式很直接:停止爬取,休息 10 到 30 分钟,再把请求间隔调大到 2 到 3 秒,重新跑。我自己在练习项目里,一般保持 1.5 秒到 2 秒的间隔就不会触发拦截。
5.2 解析乱码:编码不对,中文全变问号
requests拿到的网页内容,如果直接resp.text,requests 会根据响应头猜测编码,猜错了中文就会乱码。解决办法是手动指定编码。常见房产网站一般是 UTF-8 或 GBK,你可以打印resp.encoding确认,也可以用resp.apparent_encoding自动检测,然后设置resp.encoding = "utf-8"(根据实际情况调整)。这个坑很小,但遇到一次就能浪费半小时。
5.3 特征工程阶段:数据泄露比模型差更致命
特征工程里最隐蔽的问题就是数据泄露。举个例子,我在第 3 章提到加了一个“商圈均价”特征,如果我在划分训练集之前就用全量数据计算每个商圈的均价,那测试集的商圈均价已经包含了测试集自身的信息,模型评估结果会虚高,上线后真实效果远不如训练时的指标。我当时是单独在训练集上计算商圈均价,再映射到测试集,这样才避免泄露。
另外一个容易犯的错是:在对数值特征做标准化时,fit和transform应该只用训练集,测试集只做transform。如果你对全量数据一起fit_transform,同样属于轻微的数据泄露。很多入门教科书为了简化不会强调这一点,但我建议从一开始就养成“只从训练集学参数”的习惯,否则后期做时间序列或者正式项目时会非常被动。
5.4 建模阶段:评估指标虚高,模型上线就翻车
有时候训练集指标很好,测试集指标也还行,但一用到真实新数据上就差很多。原因往往是训练集和测试集的分布不够均匀。比如爬取的房源集中在某几个商圈的高端小区,模型学到的是这些区域的规律,拿到其他区域的房子就失灵。
缓解办法有几个方向:一是尽量均匀地采集数据,覆盖多个商圈、多种价位;二是用分层采样划分数据集,把“所在城区”作为分层依据,保证训练集和测试集中各城区的比例一致;三是用交叉验证而不是单次划分来评估模型稳定性。房价预测项目规模不大,这些方法成本都很低,但能显著提高模型在不同数据上的表现。
写在最后:两点最实在的体会
这套流程走下来,我最大的一个体会是:数据科学项目不是“爬完数据 → 跑模型”这种两步走,而是爬取、清洗、特征、建模、评估之间的反复迭代。第一次跑完线性回归,发现 R2 只有 0.6,这时候不要急着换模型,先回头看特征有没有问题;如果发现“面积”分布存在极端值,清洗后指标立刻上一个台阶,这种收益比换任何模型都明显。
另一个体会是关于流程本身的。很多人觉得“从爬虫到预测”最难的是爬虫或者建模,但我实际做下来,最花时间的永远在数据清洗和特征工程,大约占整个项目七成的工作量。这不是坏消息,反而说明你已经走上正轨了——因为数据科学真正的硬功夫,就体现在那些别人不愿多花时间的细节里。
如果你跟着这篇文章把流程跑通了一遍,建议你换一个城市、换一个数据源独立做一次。你会发现,具体代码可能不一样,但流程框架是通用的。能独立完成一次“数据爬取 → 清洗 → 特征工程 → 建模评估”全流程,你就已经具备了做更多数据科学项目的地基。