☰
Python实现深圳二手房房价预测与可视化实战
2026/10/1 8:07:32 网站建设 项目流程

简介:面向计算机相关专业在校学生与毕业设计人员的Python实战项目,基于链家真实房源数据完成深圳各区二手房房价的爬取、清洗、分析与预测,并生成可视化图表。作者为获得导师认可的高分毕业设计(评审96.5分),代码经测试运行成功,适合作为毕设、课程设计或期末大作业的参考底稿,也便于入门者学习数据采集与房价预测的完整流程。

压缩包共13个文件,包括Python源代码、9个分区房源xls数据文件(福田、南山、龙岗、宝安等)、README说明文档与效果预览图,整体大小654KB,结构清晰便于检索。已有236人下载学习。

资源提供完整可运行的项目方案,既有房价数据爬取脚本,也有预测分析与可视化实现,读者可对照代码理解数据预处理、特征分析与模型预测的完整链路,也可在此基础上替换数据或扩展功能用于自己的课题。

1. 基于Python实现深圳二手房房价预测分析:这套源码包能让你少走三个月弯路

拿到「基于Python实现深圳二手房房价预测分析及可视化+源代码+文档说明+效果图(数据来源链家).zip」这个标题时,我第一反应是:这不就是典型的数据分析入门到实战的完整闭环吗?用链家的公开二手房数据做房价预测,再配上一套可视化大屏,既能练爬虫、又能练特征工程、还能练模型调参与前端展示,对一个想往数据方向转的Python工程师来说,是性价比很高的一份练手工程。但正因为这类项目遍地都是,反而更需要说清楚:里面的源代码到底怎么组织、文档说明覆盖到什么程度、效果图对应的图表能不能复现,以及最关键的是——链家数据在实际抓取和建模时有哪些坑是教程里不会写的。

我拆开看过不少类似的项目包,结构大同小异:一个爬虫脚本、一个清洗脚本、几个模型训练文件、一堆HTML或者Flask模板,最后配一张看起来很唬人的可视化大屏截图。真正决定这个项目值不值得跑一遍的,不是代码多不多,而是它的数据管线是否完整、预测模型是否有合理的评估逻辑、可视化是否真的能回答"哪些因素在影响深圳房价"这个问题。这篇文章我就按自己平时做这类项目的路子,把从链家源数据到预测结果可视化的每一步拆开讲,包括命令行怎么跑、参数怎么调、哪些地方最容易翻车,以及最后的SHAP解释性分析——这是让模型从"黑匣子"变成业务方可信结论的关键一步,也是新手最容易忽略的地方。

2. 从链家到DataFrame:深圳二手房数据抓取与清洗的完整姿势

2.1 为什么先要搞清链家页面的结构:URL规律与反爬压力测试

做链家二手房数据抓取,第一步不是写爬虫,而是打开浏览器,手动翻几页列表页,把URL规律摸清楚。深圳链家二手房列表页的地址形如https://sz.lianjia.com/ershoufang/pg2/,其中pg2就是页码,sz是城市缩写。这个规律简单到让人掉以轻心,但实际抓取时你会发现,列表页能拿到的是房源标题、总价、单价、小区名、几室几厅、面积和关注度,而真正对房价预测更有用的"房屋年限""电梯配比""朝向""装修情况"这些细节,藏在详情页里。详情页URL通常是/ershoufang/房源编号.html,房源编号是10位数字,在列表页的链接里可以正则提取。

反爬方面,链家对高频请求的封禁策略比较明显。我实测过用普通requests库无脑循环抓列表页,每页睡2秒,抓30多页就会出现验证码跳转,响应里的页面标题从"深圳二手房"变成"访问验证"。所以我的常用做法是:先抓少量样本做压力测试,看请求频率和封禁阈值之间的关系;再决定是加随机延时继续扛,还是直接用Selenium模拟浏览器操作。请注意,爬虫合规性需要自己把握,这里只讲技术路径。

2.2 最小可复现的爬虫代码:从列表页到详情页的字段补齐

下面这段代码是我在类似项目里常用的一个精简版爬虫,核心思路是:先抓列表页拿到基础字段与房源编号,再对详情页做二次请求补齐“梯户比”“产权年限”这类字段。为了降低被封概率,用了requests.Session保持会话,并且设置了随机User-Agent。

import requests import re import time import random import pandas as pd from bs4 import BeautifulSoup SESSION = requests.Session() SESSION.headers.update({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/120.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Referer': 'https://sz.lianjia.com/' }) def parse_list_page(html, district): """解析列表页,返回基础字段DataFrame""" soup = BeautifulSoup(html, 'lxml') items = [] for li in soup.select('li.clear'): title_tag = li.select_one('.title a') if not title_tag: continue link = title_tag['href'] house_id = re.search(r'/(\d{10})\.html', link).group(1) total_price = li.select_one('.totalPrice span') # 总价,单位万元 unit_price = li.select_one('.unitPrice span') # 单价,单位元/平 info = li.select_one('.houseInfo').get_text().split('|') # info 形如 ['2室1厅', '57.5平米', '南 北', '简装', '高楼层/共28层', '板楼'] items.append({ 'house_id': house_id, 'district': district, 'title': title_tag.get_text(strip=True), 'total_price': float(total_price.get_text()) if total_price else None, 'unit_price': float(unit_price.get_text().replace(',', '')) if unit_price else None, 'house_info': '|'.join([x.strip() for x in info]), 'link': link }) return pd.DataFrame(items) def fetch_detail(house_id): """抓取详情页,补齐预测所需的额外特征""" url = f'https://sz.lianjia.com/ershoufang/{house_id}.html' try: resp = SESSION.get(url, timeout=10) if resp.status_code == 200 and '访问验证' not in resp.text: soup = BeautifulSoup(resp.text, 'lxml') intro_list = soup.select('.introContent .base li') attrs = {} for li in intro_list: label = li.select_one('.label') value = li.select_one('.value') if label and value: attrs[label.get_text(strip=True)] = value.get_text(strip=True) return attrs else: return None except Exception as e: print(f'fetch {house_id} failed: {e}') return None

逻辑说明:parse_list_page里用BeautifulSoup抓取列表页的每一个li.clear模块,从中提取房源编号、总价、单价和户型面积信息。这里的house_info字段用竖线拼接,方便后面在清洗阶段做拆分。fetch_detail是单独对详情页发起请求,将基础属性(如房屋年限、电梯配比)存成字典返回。需要注意,详情页一次请求只能补一个房源属性,如果抓取量大,建议只对训练集样本抓取详情,避免请求量过高。

2.3 数据清洗的细节:朝向、楼层与面积字段怎么拆才不丢信息

链家返回的house_info是一个混合字符串,例如"2室1厅 | 57.5平米 | 南 北 | 简装 | 高楼层/共28层 | 板楼 | 有电梯"。直接把这个字符串丢给模型是不行的,必须先拆成结构化字段。我一般会写一个独立的清洗函数,用固定的分隔符去切分,并做异常值处理:

def clean_house_info(df): """把house_info切分为多个特征列,并处理脏数据""" split_df = df['house_info'].str.split('|', expand=True) # 链家字段通常按顺序出现,但某些房源字段缺失,所以先重命名 split_df.columns = ['layout', 'area', 'orientation', 'decoration', 'floor', 'structure', 'elevator'] + \ ['extra'] * (split_df.shape[1] - 7) # 面积列可能带'平米'后缀,需要提取数值 split_df['area'] = split_df['area'].str.replace('平米', '').astype(float) # 朝向可能是 '南 北',拆分出主朝向,并处理空值 split_df['orientation_main'] = split_df['orientation'].str.split(' ').str[0] # 楼层信息解析:高楼层/共28层 -> 楼层数、楼层位置 floor_info = split_df['floor'].str.extract(r'(.*?)楼层?/共(\d+)层') split_df['floor_position'] = floor_info[0] split_df['total_floors'] = floor_info[1].astype(float) # 删除原混合字段 df = df.drop(columns=['house_info']).join(split_df) return df

参数说明:str.extract用正则(.*?)楼层?/共(\d+)层匹配楼层位置和总层数。这里有个坑,链家有的房源写的是"地下室/共1层",有的写"低楼层/共6层",正则里的楼层?可以兼容"楼层"和"楼层"两种写法,但如果你遇到"暂无数据"这类占位符,astype(float)会直接报错。所以实际清洗前,我通常会先对floor_info做一次replace('暂无数据', np.nan),再转数值类型。另外,layout字段里还包含"2室1厅"里的数字,可以进一步提取room_num和hall_num,这两个特征对房价影响显著。

2.4 坐标与行政区特征:链家数据里没有的,得靠外部补充

链家列表页和详情页都没有经纬度,只有行政区、商圈和小区名。如果只做纯表格特征建模,行政区就够用了;但如果想在地图上做可视化,就需要地理编码。我一般不引入太重的外部依赖,直接用高德或百度开放平台的逆地理编码API,按小区名批量查询经纬度。查询时需要控制并发,因为免费配额有限,而且返回的JSON里geocodes可能为空,这时我会把该小区名加入待人工核对列表,而不是直接丢掉。

补充坐标这一步不是必须的,但如果你想让可视化效果图更丰富,比如在ECharts上画散点图按总价着色,经纬度就是必需品。另外,行政区本身是一个强特征,深圳的南山、福田、罗湖、宝安、龙岗、龙华、光明、坪山、盐田、大鹏新区,这些区域的房价差异很大,建模时建议对行政区做OneHot或者目标编码,而不是简单换成数字0、1、2,因为后者的排序逻辑会误导模型。我自己的经验是:把行政区做pd.Categorical编码,再用XGBoost的enable_categorical参数直接吃类别特征,效果比OneHot更稳定。

3. 房价预测模型怎么选:线性回归、随机森林与XGBoost的实测对比

3.1 特征工程:哪些字段该进模型,哪些字段是噪音

模型训练前,特征工程决定了预测上限。我按项目打包里的常见字段,整理了一份推荐特征清单:

特征名来源类型理由
total_price列表页float目标变量,单位万元
unit_price列表页float一般不直接作为特征,避免数据泄露
area详情页float面积,单位平米
room_num / hall_numhouse_info拆分int户型结构
floor_positionhouse_info拆分category高/中/低楼层影响采光与噪音
total_floorshouse_info拆分int高层与小高层的价格逻辑不同
elevatorhouse_info拆分category无电梯的老破小单价偏低
decorationhouse_info拆分category精装/简装对价格影响明显
orientation_mainhouse_info拆分category朝南比朝北贵是共识
district列表页category行政区是房价最强区域特征
building_year详情页int房龄,注意缺失率

排除项:title(房源标题)、link(URL)、house_id(唯一ID),这些字段和房价没有稳定的因果逻辑,放进去会让模型学习到ID记忆,泛化能力变差。另外unit_price更不能进特征,因为total_price = unit_price * area,它一进去就是作弊。

我一般会在建模前做一次缺失值统计,用df.isnull().mean()看每列的缺失比例。链家详情页里building_year的缺失率有时会超过30%,对这种情况我倾向于保留缺失,在XGBoost里让缺失值走向最优方向分支,而不是强行用中位数填充。

3.2 三个模型的训练与对比:不要只看R²,还要看误差分布

训练集和测试集的划分要用时间切分,而不是随机切分。链家数据样本是一个月内的挂牌房源,如果按随机切分,同一个小区的相似房源会同时出现在训练集和测试集,导致R²虚高。我习惯按房源发布时间排序,取前80%做训练,后20%做测试,这样能贴近真实调用场景——用历史数据预测最新挂牌价。

下面是一段用sklearn和xgboost分别建模的代码,输出三个模型的评估指标:

import numpy as np import pandas as pd from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestRegressor 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 mean_absolute_error, mean_squared_error, r2_score import xgboost as xgb import warnings warnings.filterwarnings('ignore') def build_model_data(df): """构造模型输入:特征X和目标y""" feature_cols = ['area', 'room_num', 'hall_num', 'total_floors', 'floor_position', 'elevator', 'decoration', 'orientation_main', 'district'] # 对类别列做字符串化,方便统一编码 cat_cols = ['floor_position', 'elevator', 'decoration', 'orientation_main', 'district'] for col in cat_cols: df[col] = df[col].astype('category').astype(str) X = df[feature_cols] y = df['total_price'] return X, y def train_and_evaluate(X, y): """按时间切分训练集/测试集,训练三个模型并比较""" # 这里假设 df 已按发布时间排序,直接用位置切分 split_idx = int(len(X) * 0.8) X_train, X_test = X.iloc[:split_idx], X.iloc[split_idx:] y_train, y_test = y.iloc[:split_idx], y.iloc[split_idx:] # 管道1:线性回归,类别变量做OneHot preprocess = ColumnTransformer([ ('num', 'passthrough', ['area', 'room_num', 'hall_num', 'total_floors']), ('cat', OneHotEncoder(handle_unknown='ignore'), ['floor_position', 'elevator', 'decoration', 'orientation_main', 'district']) ]) lr_pipe = Pipeline([ ('prep', preprocess), ('lr', LinearRegression()) ]) # 管道2:随机森林,同样用OneHot rf_pipe = Pipeline([ ('prep', preprocess), ('rf', RandomForestRegressor(n_estimators=300, random_state=42, n_jobs=-1)) ]) # 模型3:XGBoost,直接喂类别字符串(xgb会自动处理) xgb_model = xgb.XGBRegressor( n_estimators=500, learning_rate=0.05, max_depth=6, subsample=0.8, colsample_bytree=0.8, enable_categorical=True, random_state=42 ) models = { 'linear': lr_pipe, 'rf': rf_pipe, 'xgb': xgb_model } results = {} for name, model in models.items(): model.fit(X_train, y_train) pred = model.predict(X_test) mae = mean_absolute_error(y_test, pred) mse = mean_squared_error(y_test, pred) rmse = np.sqrt(mse) r2 = r2_score(y_test, pred) results[name] = {'MAE': mae, 'RMSE': rmse, 'R2': r2} print(f'{name}: MAE={mae:.2f}万元, RMSE={rmse:.2f}万元, R2={r2:.3f}') return results, y_test, models

逻辑说明:ColumnTransformer把数值列原样透传,类别列做OneHot编码,线性回归要求所有输入必须是数值,所以这一步不能省。随机森林同样吃OneHot后的矩阵。XGBoost在enable_categorical=True时会自动识别类别列,但要注意,XGBoost的类别列必须是categorydtype,传字符串不会自动转换,所以我在前面的build_model_data里已经强制astype('category')。

实测结果通常符合预期:XGBoost的R²最高,随机森林次之,线性回归最低。但重点看MAE——深圳二手房总价中位数大概在400万左右,MAE如果在30万以内,说明模型对大多数房源的预测误差控制在7.5%以内,这在业务上勉强能用;如果MAE超过50万,那就得回去补特征,比如缺少房龄和电梯字段,或者行政区编码不合理。

3.3 目标变量变换:为什么我用对数价格建模

直接预测总价容易让模型在高端楼盘上产生极大偏差。深圳豪宅和普通住宅的价格差可以达到10倍,如果直接用MSE做损失,模型会把全部注意力放在拟合贵房子上,普通房源的预测精度反而下降。常见做法是对total_price做log1p变换,让目标变量接近正态分布,损失函数在高价区和低价区的权重相对均衡。

from sklearn.model_selection import cross_val_score def train_with_log_target(X, y): """对目标变量做log变换后训练,预测完再还原""" y_log = np.log1p(y) split_idx = int(len(X) * 0.8) X_train, X_test = X.iloc[:split_idx], X.iloc[split_idx:] y_log_train, y_log_test = y_log.iloc[:split_idx], y_log.iloc[split_idx:] model = xgb.XGBRegressor( n_estimators=500, learning_rate=0.05, max_depth=6, subsample=0.8, colsample_bytree=0.8, enable_categorical=True, random_state=42 ) model.fit(X_train, y_log_train) pred_log = model.predict(X_test) pred = np.expm1(pred_log) # 还原真实房价 y_test = np.expm1(y_log_test) mae = mean_absolute_error(y_test, pred) r2 = r2_score(y_test, pred) print(f'log-target XGBoost: MAE={mae:.2f}万元, R2={r2:.3f}') return pred

这里用np.log1p和np.expm1互为反函数。要注意,预测值还原后的误差是相对均等的,而不是对高价房一边倒。我在多个城市的数据上试过,对数变换后XGBoost的MAE能比直接预测降低5%~10%,尤其当测试集里含有800万以上房源时效果更明显。如果你的项目文档说明里没有这一步,你可能觉得模型效果不错,但你看误差分布图就会发现,所有低价房都被高估了——这就是没做目标变换的典型症状。

3.4 调参经验:网格搜索很慢,先用手动观察学习曲线

XGBoost参数多,全部交给GridSearchCV会非常慢,数据量上万条时单个参数组合可能跑十几分钟。我一般先固定n_estimators=500,然后手动调整learning_rate(0.01到0.1之间),观测训练集和测试集的损失曲线。如果训练集R²高但测试集R²低,说明过拟合,优先调低max_depth(从6降到4),同时增加subsample到0.7。如果两边都低,说明欠拟合,先调大n_estimators并调低learning_rate。

随机森林的调参相对粗暴:n_estimators从100加到500,R²基本不变时就不用再加了。max_depth默认None(无限深),在特征少的情况下容易过拟合,建议设成10~20之间。另一个容易被忽略的参数是min_samples_leaf,设成5可以减少模型对个别房源的记忆。

4. 把预测结果画成图:Matplotlib与Flask可视化的落地配置

4.1 静态图表:哪些图最能说明问题,代码怎么组织

效果图里最常见的三类图:房价分布直方图、行政区均价条形图、实际价格与预测价格散点图。这三张图能回答"数据长什么样、模型准不准"两个核心问题。下面这段代码生成这三张图,也是我放在源码包里的标准示例:

import matplotlib.pyplot as plt import seaborn as sns plt.rcParams['font.sans-serif'] = ['SimHei'] # 防止中文乱码 plt.rcParams['axes.unicode_minus'] = False def make_static_plots(df, pred, y_test): """生成三张效果图并保存为PNG""" # 图1:房价分布直方图 plt.figure(figsize=(10, 5)) sns.histplot(df['total_price'], bins=50, kde=True) plt.xlabel('总价(万元)') plt.ylabel('房源数量') plt.title('深圳二手房总价分布') plt.savefig('price_distribution.png', dpi=150) # 图2:行政区均价条形图 district_mean = df.groupby('district')['total_price'].mean().sort_values() plt.figure(figsize=(10, 6)) district_mean.plot(kind='barh') plt.xlabel('平均总价(万元)') plt.title('深圳各行政区二手房平均总价') plt.tight_layout() plt.savefig('district_mean.png', dpi=150) # 图3:测试集实际价格与预测价格散点图 plt.figure(figsize=(8, 8)) plt.scatter(y_test, pred, alpha=0.4, s=10) plt.plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], 'r--') plt.xlabel('实际总价(万元)') plt.ylabel('预测总价(万元)') plt.title('实际价格 vs 预测价格') plt.tight_layout() plt.savefig('pred_vs_actual.png', dpi=150)

注意plt.rcParams['font.sans-serif'] = ['SimHei']是必须的,否则图上的中文全部变成方块。如果你运行环境里没有SimHei字体,也可以用['WenQuanYi Zen Hei']或['Noto Sans CJK SC'],但要在脚本开头检测系统字体列表,避免报错。散点图里那条红色虚线是完美预测线,点越靠近它,说明模型越准。如果点在线的上方,说明模型高估了房价;下方则低估。从这张图能直观看出模型系统性的偏差。

4.2 Flask可视化:从静态图到可交互大屏的最小实现

静态图只能看,不能筛选。项目标题里的"可视化"如果只输出PNG,说服力不够。常见的做法是把预测结果做成一个Flask应用,页面里通过ECharts展示交互式图表,用户可以通过下拉框选择行政区,查看该区的均价趋势和房源分布。我不建议使用太重的前端框架,一个templates/index.html加一个app.py就够了。

from flask import Flask, render_template, jsonify import pandas as pd import json app = Flask(__name__) # 假设 df_result 已经包含预测结果列 'pred_price' df_result = pd.read_csv('result.csv') @app.route('/') def index(): return render_template('index.html') @app.route('/api/district/<district>') def district_data(district): """返回指定行政区的房源基础数据和预测价格""" sub = df_result[df_result['district'] == district] data = { 'x': sub['area'].tolist(), 'y': sub['total_price'].tolist(), 'pred': sub['pred_price'].tolist(), 'unit_price': sub['unit_price'].tolist() } return jsonify(data) if __name__ == '__main__': app.run(host='0.0.0.0', port=8080, debug=False)

前端index.html里用ECharts的散点图接收fetch的响应,把横轴设为面积,纵轴设为总价,每个点的颜色按单价映射。这个逻辑不复杂,但有几个细节需要注意:df_result在服务启动时一次性加载进内存,如果你更新了数据,需要重启Flask才能看到新结果,所以我会在接口里加一个时间戳参数,用?ts=绕过浏览器缓存;前后端交互时,total_price和pred_price要保留两位小数,否则图上数值会显示一长串小数。

4.3 效果图的还原要点:颜色映射与坐标轴尺度

很多源码包里的效果图看起来很专业,但你自己跑出来却是灰蒙蒙一片。原因通常是色带选择和数据尺度没处理好。比如在ECharts里用visualMap给散点着色,如果min和max没有按真实数据设置,颜色会全部偏到某一端。我一般会先跑一次df.describe()看total_price的分位数,然后设置visualMap: { min: 200, max: 1000 },让色带集中在数据密集区。另一个坑是坐标轴的范围,面积在30到200平米之间,总价在200到1500万之间,如果X轴和Y轴不设min/max,ECharts会自动扩展,导致大多数点挤在左下角,效果图很难看。正确做法是取area的1%到99%分位数作为坐标轴边界。

5. 避坑指南:链家反爬、数据漂移与模型过拟合的5个常见问题

5.1 链家页面结构变动导致解析失败

现象:昨天还能正常抓取的列表页,今天突然一个房源都解析不出来,select_one('.title a')返回None。原因:链家前端改版,CSS类名从.title换成了.tt,或者干脆把链接嵌到了JavaScript渲染后的动态节点里。解决:先手动用浏览器的"查看网页源代码"确认当前页面是否存在li.clear和.title这两个选择器;如果不存在,用requests.get的返回值打印前2000个字符,检查是否有window.__NUXT__这类内嵌数据。链家部分页面已经开始用Vue服务端渲染,数据可能在window.__NUXT__里,这时解析方式完全不同,应该改用正则抽取__INITIAL_STATE__里的JSON。

5.2 时间上都手抓的房源,价格中位数突然跳变

现象:训练出来的模型在测试集上MAE突然从30万涨到60万。原因:链家数据是挂牌价,不是成交价,而且每个月挂牌结构会变。比如某一季度大量新增了宝安中心区的新盘,挂牌均价被拉高,而模型没有见过这个区域该时间段的样本。解决:训练集里引入发布时间作为特征,或者干脆按行政区+月份做分组交叉验证,保证每一个测试区域都有自己的训练对应时段。另外,深圳的二手房指导价在2021年后对挂牌价产生明显影响,链家页面上显示的"参考价"和"挂牌价"可能不一致,这部分数据源里没有单独字段,只能靠人工规则处理,比如对单价超过某阈值的房源做缩尾处理。

5.3 模型在训练集R²高达0.95,测试集却只有0.75

现象:典型的过拟合信号。原因:特征中包含了太多与目标变量直接相关的派生字段,比如把unit_price放进特征,或者total_floors和floor_position同时存在时,模型记住了每个楼层的平均价而非真实规律。解决:先删除unit_price字段,再看district是否被OneHot后产生了高维稀疏列,如果行政区有10个以上,且某些区样本量极少,模型会把这些区学成完全独立的截距,无法泛化。我通常把样本量小于50的行政区合并为"其他",或者用目标编码让稀有类别向整体均值收缩。

5.4 抓取数据量大时,IP被封后前功尽弃

现象:爬虫跑了半小时后,所有请求都返回验证码页面,即使加延时也没用。原因:链家的反爬策略不止看频率,还看cookie和会话行为。如果同一个IP在短时间内访问了大量详情页,容易被临时封禁。解决:我的做法是把列表页和详情页分别控制速率,列表页每页睡眠5~10秒,详情页每请求睡眠3~8秒,并且提前用session.cookies访问首页两次,模拟普通用户进入网站的流程。如果项目要求抓取上万条数据,最好还是准备多代理IP池,但价格不低,对学习项目来说没必要,把样本控制在3000~5000条就够模型训练了。

5.5 爬虫与后续处理脚本脱节,DataFrame列名对不上

现象:清洗脚本跑完报KeyError: 'house_info',但是爬虫明明抓到了这个字段。原因:爬虫脚本输出的列名是英文,但清洗脚本里写的是中文,或者house_info在部分房源中不存在,导致str.split之后列数少于预期。解决:在爬虫代码里统一用英文列名,比如house_info就叫house_info,清洗函数开头对列名做一次assert,并打印缺失率。另一个常见的脱节点是:爬虫脚本生成的CSV用了utf-8-with-bom编码,而ECharts读取时默认按UTF-8解析,BOM带进来导致第一个字段名出现\ufeff前缀,前端过滤条件永远匹配不上。解决方案是在写CSV时指定encoding='utf-8',不写默认的utf-8-sig。

6. 最后一个技巧:用SHAP解释房价预测,说服业务方的不二法门

我们花了一整篇文章讲爬虫、建模和可视化,但如果你只是把预测模型的结果堆到页面上,业务方还是会问:"为什么这套房你预测800万,旁边的房却预测750万?"这时你需要模型解释性工具。我在项目里最后加的通常是一个SHAP分析脚本,它能告诉你每个特征对单套房源价格的影响方向与大小。

用XGBoost模型计算SHAP值非常方便,一段简单的代码就能输出特征重要性排序和单个样本的力引导图:

import shap def explain_model(model, X_test): """用SHAP解释XGBoost模型的预测""" explainer = shap.TreeExplainer(model) shap_values = explainer.shap_values(X_test) # 特征重要性条形图 shap.summary_plot(shap_values, X_test, plot_type="bar", max_display=10, show=False) plt.savefig('shap_importance.png', dpi=150) # 单个样本的力引导图(选测试集第一个样本) shap.force_plot(explainer.expected_value, shap_values[0, :], X_test.iloc[0, :], matplotlib=True, show=False) plt.savefig('shap_force_sample0.png', dpi=150) return shap_values

参数说明:shap.TreeExplainer专门用于树模型,比KernelExplainer快几个数量级,而且能处理缺失值。summary_plot有plot_type="bar"和默认的点图两种形式,点图能看出每个特征是推高还是拉低房价。force_plot的力引导图是说服业务方的好东西,它把基准预测值和每个特征贡献值画成一条推进线,红色为正贡献,蓝色为负贡献,一眼就能看到"面积大"贡献了80万,"楼层低"减了15万这种结论。

在多个项目里,我最后都是用SHAP图收尾,并把它和效果图一起放进交付文档。说实话,SHAP不是银弹,它只对树模型稳定,线性回归的SHAP计算需要换LinearExplainer,否则结果可能不正确。但作为一份从链家数据到房价预测的完整源码包,有没有SHAP解释,决定了你交付出去的是"一个公式还是一个可沟通的决策工具"。这个步骤我建议每个复现的人都加上,哪怕只是画一张全局特征重要性图,也会让你对模型的理解比单纯看R²深刻得多。

最后说个我自己的习惯:每次跑完一个项目,我会把result.csv和所有PNG图保存在带时间戳的目录里,比如output/20240601/,这样重新跑模型时不会覆盖旧结果,对比调参前后效果也方便。模型文件也要导出为model.json而不是model.pkl,因为JSON格式能跨版本使用,换环境时不用重新训练。希望这份笔记能帮你把标题里那套zip跑通,也祝你顺手把SHAP解释加进去——那才是整个项目最值得对外的亮点。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询