ARTICLE DETAIL

资讯详情

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

Python爬虫+机器学习实战:二手房房价预测全流程

Python爬虫+机器学习实战:二手房房价预测全流程 简介这份资源面向计算机相关专业的毕业设计、期末大作业与课程设计需求者提供一套从数据采集到模型预测的完整二手房房价分析方案覆盖Python爬虫与机器学习两大技能模块新手也能借助详细注释快速上手。压缩包共26个文件约1.28MB以15个py脚本为核心配合1个ipynb笔记本、1个html分析报告、2个csv数据集、4张jpg可视化图表以及read、md说明文档和gitignore配置结构清晰、便于按模块查阅。内容围绕链家与安居客两个爬虫项目展开涵盖数据抓取、清洗、特征处理到房价预测建模的完整链路并附有可视化脚本与图表输出方便直接复现与二次修改。目前已有416人学习下载适合需要快速搭建可运行项目、对照代码理解爬虫与机器学习流程的读者参考使用。1. 从链家挂出一套房到模型吐出估价这条链路到底在做什么二手房挂牌数据每天都在变同一小区不同楼层、不同朝向、满五唯一与否价格能差出一辆代步车。很多人想用 Python 把「爬虫 机器学习」串起来做房价预测但真正动手时才发现爬下来的数据脏得没法看特征工程做完维度爆炸模型训出来 R² 只有 0.4。这篇笔记拆的就是这条链路——从 requests 抓取二手房挂牌页到清洗、特征构造再到用 sklearn 训一个能解释的回归模型最后落成一个可以反复跑的脚本。适合有 Python 基础、想完整走一遍「数据采集→建模→预测」流程的从业者也适合正在做课程设计或想验证某个城市房价逻辑的人。核心不是调包而是每一步为什么这么做、参数怎么定、哪里容易翻车。2. 爬虫层把二手房列表页和详情页拆成可入库的字段2.1 先定字段再写代码二手房预测需要哪些列很多人上来就requests.get然后print(r.text)结果抓了一堆 HTML 不知道怎么用。正确的顺序是先想清楚模型需要什么特征再倒推爬虫要抓哪些字段。二手房价格预测里真正有解释力的字段通常分四类位置类小区名、区域、地铁距离、商圈房屋属性类面积、户型、楼层、朝向、装修、梯户比交易属性类挂牌总价、单价、满几年、是否唯一时间类挂牌日期、调价次数其中「调价次数」和「挂牌日期」是很多人忽略的强特征——一套房如果三个月内调价三次说明房东心态在变最终成交价往往低于挂牌价。字段定好后列表页负责抓小区、面积、总价、单价这些摘要信息详情页补楼层、朝向、梯户比、调价记录。注意不同城市的二手房平台字段名不一样但结构大同小异。先手动打开一个详情页用浏览器开发者工具看 Network 里 XHR 返回的 JSON比直接解析 HTML 稳得多。2.2 requests 分页参数列表页抓取的最小可用脚本下面这段代码是我常用的列表页抓取骨架核心是构造分页 URL、带 headers、解析返回的 JSON 或 HTML 片段。不同平台返回格式不同这里以返回 JSON 的接口为例import requests import time import pandas as pd 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, Referer: https://example.com/ershoufang/, } def fetch_list_page(city, page): 抓取某一页二手房列表返回记录列表 url fhttps://example.com/api/{city}/ershoufang params { page: page, page_size: 30, order: default, # 默认排序避免被个性化推荐干扰 } resp requests.get(url, headersHEADERS, paramsparams, timeout10) if resp.status_code ! 200: print(fpage {page} status {resp.status_code}) return [] data resp.json() records [] for item in data.get(data, {}).get(list, []): records.append({ title: item.get(title), community: item.get(community_name), region: item.get(region), area: item.get(area), total_price: item.get(total_price), unit_price: item.get(unit_price), room_type: item.get(room_type), floor: item.get(floor), orientation: item.get(orientation), url: item.get(detail_url), }) return records def crawl_city(city, max_page50): all_records [] for p in range(1, max_page 1): recs fetch_list_page(city, p) if not recs: break all_records.extend(recs) time.sleep(1.5) # 控制频率别把对方打挂 return pd.DataFrame(all_records) df crawl_city(sh, max_page10) df.to_csv(ershoufang_list.csv, indexFalse, encodingutf-8-sig)逻辑说明fetch_list_page负责单页抓取和字段映射crawl_city负责翻页和限速。参数上page_size一般平台上限是 30 或 100设太大容易被截断time.sleep(1.5)是血泪经验低于 1 秒连续请求很容易触发验证码或 IP 限制order参数不要用「价格升序」这类排序否则抓到的样本有偏模型会学歪。2.3 详情页补字段用 playwright 处理动态渲染列表页拿不到梯户比、调价记录这些字段需要进详情页。如果详情页是服务端渲染requests 直接解析 HTML 就行如果是前端异步渲染常见做法是上 playwright。它比直接解码 HTML 的优势在于能等元素加载完再取文本不用猜接口。from playwright.sync_api import sync_playwright import re def fetch_detail(url): with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(url, timeout20000) page.wait_for_selector(.baseInfo, timeout10000) info {} # 梯户比 try: info[ladder_ratio] page.locator( text梯户比例).locator(..).inner_text() except Exception: info[ladder_ratio] None # 调价记录条数 try: price_history page.locator(.priceHistory li).count() info[price_adjust_count] price_history except Exception: info[price_adjust_count] 0 browser.close() return info逻辑说明wait_for_selector是必须的否则页面还没渲染完就取文本拿到的全是空值。locator(..)是取父节点文本因为很多平台把标签和值放在相邻的 span 里。price_adjust_count直接数调价记录条数比解析具体日期更稳。参数上timeout设 20 秒是平衡值太短容易误判失败太长会拖慢整体速度。2.4 入库与去重sqlalchemy 存二手房数据抓完直接存 CSV 不是不行但字段一多、要增量更新时数据库更省心。用 sqlalchemy 建一张二手房表把列表页和详情页的数据合并后写入from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime from sqlalchemy.orm import declarative_base, sessionmaker from datetime import datetime Base declarative_base() class House(Base): __tablename__ ershoufang id Column(Integer, primary_keyTrue, autoincrementTrue) title Column(String(200)) community Column(String(100)) region Column(String(50)) area Column(Float) total_price Column(Float) unit_price Column(Float) room_type Column(String(50)) floor Column(String(50)) orientation Column(String(50)) ladder_ratio Column(String(50)) price_adjust_count Column(Integer, default0) crawl_time Column(DateTime, defaultdatetime.now) engine create_engine(sqlite:///houses.db) Base.metadata.create_all(engine) Session sessionmaker(bindengine) session Session() # 假设 df 是列表页 DataFramedetails 是详情页字典列表 for _, row in df.iterrows(): house House( titlerow[title], communityrow[community], regionrow[region], arearow[area], total_pricerow[total_price], unit_pricerow[unit_price], room_typerow[room_type], floorrow[floor], orientationrow[orientation], ) session.add(house) session.commit()逻辑说明用crawl_time记录抓取时间方便后续做增量。去重可以在入库前按title community area做一次 drop_duplicates也可以在数据库层加唯一索引。参数上String(200)对标题够用Float存面积和价格price_adjust_count默认 0 避免空值。3. 清洗与特征工程把脏数据变成模型能吃的矩阵3.1 二手房数据里最常见的五类脏值爬下来的原始数据几乎不能直接喂模型常见问题包括面积带「㎡」后缀、总价带「万」、楼层写「中楼层/共 18 层」、朝向写「南 北」、梯户比写「2 梯 4 户」。这些字段如果直接丢给 sklearn要么报错要么被当成类别变量处理维度爆炸。清洗策略分三步先统一单位把面积转成 float、总价转成万元再拆复合字段楼层拆出「楼层位置」和「总层数」梯户比拆出「梯数」和「户数」最后处理缺失值面积和总价缺失的直接丢朝向缺失的填「未知」调价次数缺失填 0。import re import numpy as np def parse_area(x): if pd.isna(x): return np.nan m re.search(r([\d.]), str(x)) return float(m.group(1)) if m else np.nan def parse_total_price(x): if pd.isna(x): return np.nan m re.search(r([\d.]), str(x)) return float(m.group(1)) if m else np.nan def parse_floor(x): 返回 (楼层位置, 总层数) if pd.isna(x): return None, np.nan pos None for k in [低, 中, 高]: if k in str(x): pos k break m re.search(r共\s*(\d)\s*层, str(x)) total int(m.group(1)) if m else np.nan return pos, total df[area] df[area].apply(parse_area) df[total_price] df[total_price].apply(parse_total_price) df[[floor_pos, total_floor]] df[floor].apply( lambda x: pd.Series(parse_floor(x))) df df.dropna(subset[area, total_price]) df df[df[area] 10] # 过滤明显异常 df df[df[total_price] 20]逻辑说明parse_area和parse_total_price用正则提取数字比字符串替换稳。parse_floor同时返回楼层位置和总层数后面可以构造「楼层占比」特征。过滤条件area 10和total_price 20是经验值能去掉车位、储藏室这类非住宅记录。3.2 构造三个强特征单价、房龄、地铁距离原始字段清洗完后真正提升模型效果的是构造特征。二手房场景里我一般会加这三个单价total_price / area比总价更能反映房屋价值房龄如果详情页有建成年份用当前年份减没有就用小区平均房龄替代地铁距离如果详情页有「距地铁 X 米」直接提取没有就按区域均值填充df[unit_price_calc] df[total_price] / df[area] # 假设详情页抓到了 build_year df[house_age] 2025 - df[build_year] df[house_age] df[house_age].clip(0, 50) # 地铁距离提取 def parse_subway(x): if pd.isna(x): return np.nan m re.search(r距.*?地铁.*?(\d)\s*米, str(x)) return float(m.group(1)) if m else np.nan df[subway_dist] df[subway_info].apply(parse_subway) df[subway_dist] df.groupby(region)[subway_dist].transform( lambda s: s.fillna(s.median()))逻辑说明unit_price_calc和平台给的单价可以互相校验差太多的记录要排查。house_age用 clip 限制在 0 到 50 年避免异常年份。地铁距离按区域中位数填充比全局均值合理因为不同区域地铁密度差异大。3.3 类别变量编码区域和朝向怎么处理区域、朝向、楼层位置都是类别变量。区域用 one-hot 会导致维度爆炸一个城市几十个区常见做法是目标编码或频率编码。朝向和楼层位置类别少直接 one-hot 就行。from sklearn.preprocessing import OneHotEncoder from sklearn.model_selection import KFold # 区域目标编码用 K 折避免泄漏 def target_encode(train_df, test_df, col, target, n_splits5): kf KFold(n_splitsn_splits, shuffleTrue, random_state42) train_df[f{col}_te] np.nan for tr_idx, val_idx in kf.split(train_df): means train_df.iloc[tr_idx].groupby(col)[target].mean() train_df.loc[train_df.index[val_idx], f{col}_te] \ train_df.iloc[val_idx][col].map(means) global_mean train_df[target].mean() train_df[f{col}_te] train_df[f{col}_te].fillna(global_mean) test_df[f{col}_te] test_df[col].map( train_df.groupby(col)[target].mean()).fillna(global_mean) return train_df, test_df df, _ target_encode(df, df.copy(), region, unit_price_calc) ohe OneHotEncoder(sparse_outputFalse, handle_unknownignore) ohe_arr ohe.fit_transform(df[[orientation, floor_pos]].fillna(未知)) ohe_df pd.DataFrame(ohe_arr, columnsohe.get_feature_names_out()) df pd.concat([df.reset_index(dropTrue), ohe_df], axis1)逻辑说明目标编码必须用 K 折否则训练集里每个区域的目标均值都包含了自身价格模型会过拟合。handle_unknownignore保证测试集出现新朝向时不报错。参数上n_splits5是常规选择数据量小可以降到 3。4. 模型训练与调参从线性回归到 LightGBM 的取舍4.1 先跑一个线性回归当基线别一上来就上 XGBoost。先用线性回归跑一遍看 R² 和残差分布能快速判断特征里有没有明显问题。如果线性回归 R² 只有 0.3说明特征工程没做到位换复杂模型也救不了多少。from sklearn.model_selection import train_test_split from sklearn.linear_model import LinearRegression from sklearn.metrics import r2_score, mean_absolute_error feature_cols [area, house_age, subway_dist, price_adjust_count, region_te, total_floor] list(ohe.get_feature_names_out()) X df[feature_cols].fillna(0) y df[unit_price_calc] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42) lr LinearRegression() lr.fit(X_train, y_train) pred lr.predict(X_test) print(R2:, r2_score(y_test, pred)) print(MAE:, mean_absolute_error(y_test, pred))逻辑说明fillna(0)是临时处理正式跑之前要确认缺失值比例。random_state42保证可复现。如果 MAE 超过单价的 20%说明模型不可用先回去查特征。4.2 LightGBM 的参数怎么设四个必调项线性回归跑通后换 LightGBM 通常能提升 10% 到 20% 的 R²。参数不用全调先盯这四个参数作用常用范围建议起点n_estimators树的数量100-2000500learning_rate学习率0.01-0.30.05num_leaves叶子数15-25531min_child_samples叶子最小样本5-10020import lightgbm as lgb model lgb.LGBMRegressor( n_estimators500, learning_rate0.05, num_leaves31, min_child_samples20, subsample0.8, colsample_bytree0.8, random_state42, ) model.fit(X_train, y_train, eval_set[(X_test, y_test)], eval_metricmae, callbacks[lgb.early_stopping(50)]) pred_lgb model.predict(X_test) print(LGB R2:, r2_score(y_test, pred_lgb))逻辑说明early_stopping(50)在验证集 MAE 50 轮不下降时停止防止过拟合。subsample和colsample_bytree设 0.8 是常规正则化。参数上num_leaves不要超过 2 的max_depth次方否则容易过拟合。4.3 特征重要性怎么看哪些字段在真正影响房价LightGBM 训完后用feature_importances_看哪些特征贡献大。二手房场景里通常面积、区域目标编码、地铁距离排前三。如果「调价次数」重要性很高说明市场博弈激烈这个特征值得保留。importance pd.DataFrame({ feature: feature_cols, importance: model.feature_importances_, }).sort_values(importance, ascendingFalse) print(importance.head(15))逻辑说明feature_importances_是分裂次数加权不是绝对影响大小但排序够用。如果某个 one-hot 特征重要性异常高检查是不是编码时泄漏了目标信息。5. 避坑与排查二手房预测项目里最容易翻车的五件事5.1 爬虫被封现象是返回 403 或验证码页现象跑了几十页后status_code变成 403或者返回的 HTML 里出现「验证」字样。原因通常是请求频率太高、headers 缺失、或者 IP 被标记。解决把time.sleep调到 2 到 3 秒补全User-Agent和Referer必要时用代理池轮换。别用多线程硬怼二手房平台风控比电商还严。5.2 目标泄漏现象是 R² 高得离谱现象模型 R² 到 0.95 以上但换一批数据预测就崩。原因多半是特征里混入了「成交价」或「评估价」这类和目标强相关的字段。解决检查特征列表把任何在交易完成后才能拿到的字段全部删掉。挂牌价预测只能用挂牌时已知的信息。5.3 区域目标编码过拟合现象是训练集 R² 远高于测试集现象训练集 R² 0.9测试集 0.5。原因是目标编码时用了全量数据算均值每个样本都「见过」自己的价格。解决严格用 K 折目标编码或者改用频率编码。如果区域样本量少于 50直接归到「其他」。5.4 面积单位不统一现象是单价分布出现双峰现象单价直方图有两个峰一个在 2 万左右一个在 20 万左右。原因是部分记录面积单位是平方米部分是平方英尺或者总价单位是元不是万。解决清洗时统一除以 10000 转成万元面积统一用平方米异常值直接过滤。5.5 时间穿越现象是模型在旧数据上表现好新数据上差现象用 2023 年数据训练预测 2025 年房价误差巨大。原因是房价本身随时间漂移模型学的是旧市场规律。解决训练时加入时间特征或者只用最近 6 个月数据。如果做长期预测要定期重新训练。6. 把模型跑成可复用的脚本增量抓取与预测接口6.1 增量抓取只抓新挂牌和调价房源全量抓一次成本高日常维护只需要抓新增和调价。做法是在数据库里记录每套房源的crawl_time和price_adjust_count每次抓取时对比列表页的调价标记只进详情页抓变化的房源。def incremental_crawl(city): # 先抓列表页拿到当前所有房源摘要 df_new crawl_city(city, max_page5) # 从数据库读已有房源 existing pd.read_sql(SELECT title, community, area, price_adjust_count FROM ershoufang, engine) merged df_new.merge(existing, on[title, community, area], howleft, suffixes(, _old)) # 只保留新增或调价次数变化的 to_update merged[ merged[price_adjust_count_old].isna() | (merged[price_adjust_count] ! merged[price_adjust_count_old]) ] return to_update逻辑说明merge用标题、小区、面积做联合键比单用标题稳。to_update筛选出新增和调价房源只对这些跑详情页抓取。参数上max_page5是日常增量够用的页数首次全量再调大。6.2 预测接口用 joblib 保存模型并加载模型训完后用 joblib 存下来预测时加载不用每次重训。import joblib joblib.dump(model, house_price_lgb.pkl) joblib.dump(ohe, house_price_ohe.pkl) def predict_price(area, house_age, subway_dist, region, orientation, floor_pos, total_floor, price_adjust_count): model joblib.load(house_price_lgb.pkl) ohe joblib.load(house_price_ohe.pkl) # 构造特征向量注意顺序和训练时一致 region_te region_mean_map.get(region, global_mean) ohe_arr ohe.transform([[orientation, floor_pos]]) feat np.concatenate([[area, house_age, subway_dist, price_adjust_count, region_te, total_floor], ohe_arr[0]]) return model.predict([feat])[0]逻辑说明region_mean_map和global_mean要在训练时保存下来预测时直接用。特征顺序必须和训练时完全一致否则预测结果会错得莫名其妙。参数上joblib比 pickle 更适合存 sklearn 模型压缩率高。6.3 验证模型有没有退化每月跑一次 MAE 对比模型上线后不是一劳永逸。每月抽一批新挂牌房源用模型预测单价和实际挂牌单价对比算 MAE。如果 MAE 比上月涨了 30% 以上说明市场变了要重新训练。我一般会把每次的 MAE 记在一个 CSV 里画条趋势线比看单次指标直观。def monitor_mae(new_df, model, feature_cols): X_new new_df[feature_cols].fillna(0) pred model.predict(X_new) mae mean_absolute_error(new_df[unit_price_calc], pred) with open(mae_log.csv, a) as f: f.write(f{datetime.now().date()},{mae}\n) return mae逻辑说明mae_log.csv按日期追加方便后续画图。如果连续两个月 MAE 上升别犹豫重新跑一遍特征工程和训练。这个习惯帮我提前发现过一次区域政策调整导致的房价跳变比等业务方反馈快了两周。希望帮到你。本文还有配套的精品资源点击获取
返回列表