北京二手房房价预测:从爬虫清洗到模型对比的机器学习实战
简介这是一份面向计算机相关专业课程设计与期末大作业的机器学习实战项目以北京二手房房价预测为场景完整覆盖数据采集、清洗、特征工程、模型训练与结果可视化全流程可作为课程设计或毕业设计的直接参考。项目经导师指导并获98分适合正在完成课程设计或想提升项目经验的学习者。压缩包共26个文件大小1.29MB主要包括15个Python脚本、2个CSV数据文件、1个Jupyter Notebook、1个HTML分析报告及使用说明其中spiders文件夹内为爬虫源码可了解链家与安居客数据的获取方式。资源已有479人学习下载除核心预测代码外还附带可视化图片与项目文档便于对照理解整体思路快速复现并扩展自己的房价预测方案。1. 为什么拿北京二手房当机器学习入门题目最划算一个机器学习课程设计如果只给一个csv让你跑模型那叫练习题不叫项目。真正的项目得能回答三个问题数据从哪来、特征怎么构造、模型跑完怎么让别人信。这套北京二手房房价预测与分析就是把这条链路补齐了链家和安居客两个爬虫负责拿数据lianjia.csv和anjuke.csv是落地结果visuals.py把特征关系画出来最后的notebook才是建模。对于要交期末大作业或给自己简历凑项目的同学这套结构的价值在于每一条代码都能讲出“为什么”数据是实时抓的存在反爬、字段缺失、重复记录的问题比直接导出的数据集更接近生产环境房价受区域、户型、楼层、装修影响特征工程有得做业务上有“总价 单价 × 面积”这种强逻辑关系可以用来验证模型有没有学到东西而不是只会调包调参。下面我按数据采集、清洗、特征工程、建模输出这条线拆开讲并把我在复现时踩过的坑一并说清楚。2. 数据采集与清洗Scrapy爬虫抓链家和安居客拿到可用的lianjia.csv2.1 先读懂spiders目录的工程结构打开压缩包后spiders下面有lianjia_scrapy_crawl和anjuke_scrapy_crawl两个独立爬虫项目。它们是标准Scrapy工程结构包括items.py、middlewares.py、pipelines.py和spiders目录。先别急着跑先把items.py和数据落地方式看一遍因为后面清洗逻辑完全取决于爬虫里定义了哪些字段。2.1.1 定义字段时留一个raw字段我在处理爬虫数据时最大的教训是不要把所有字段都规范化了才存。二手房网站的房源字段经常出现“近地铁”、“满五唯一”这种备注型信息解析时容易漏。所以items.py里至少留一个原始页面字段方便后面回查import scrapy class LianjiaItem(scrapy.Item): # 爬虫里的每个字段都要和后续数据的DataFrame列名对应 district scrapy.Field() # 城区如 朝阳/海淀 area scrapy.Field() # 房屋面积字符串类型 total_price scrapy.Field() # 总价单位万字符串 unit_price scrapy.Field() # 单价单位元/平 bedroom scrapy.Field() # 卧室数量 living_room scrapy.Field() # 客厅数量 floor scrapy.Field() # 所在楼层 facing scrapy.Field() # 朝向 decoration scrapy.Field() # 装修情况 raw_html scrapy.Field() # 整个房源的原始HTML片段出错时定位为什么在爬虫里不直接转成数值因为页面解析的丢字段率远比你想象的高。比如“面积58.43平米”可能因为某个标签闭合问题没抓到直接在items层做类型转换就会抛异常整个爬虫就中断了。先把字符串原样存进CSV清洗阶段再统一处理爬虫稳定性更高。字段名建议用下划线而不是中文后面pandas处理起来不用加引号也方便直接和sklearn的特征名称对应。2.1.2 下载中间件里的反爬配置链家和安居客对短时间请求的拦截非常敏感不加延时会被封IP。项目里spiders目录既然是完整爬虫工程那settings.py里必然有下载延时或自动限速配置。一个常见的做法是在中间件里设置随机User-Agent同时开启AutoThrottle# settings.py 中关键参数 DOWNLOADER_MIDDLEWARES { scrapy.downloadermiddlewares.useragent.UserAgentMiddleware: None, lianjia_scrapy_crawl.middlewares.RotateUserAgentMiddleware: 543, } AUTOTHROTTLE_ENABLED True AUTOTHROTTLE_START_DELAY 5 AUTOTHROTTLE_MAX_DELAY 30 AUTOTHROTTLE_TARGET_CONCURRENCY 2.0这里AUTOTHROTTLE_ENABLED意思是启用自动限速它会在请求成功后逐渐加快在出现错误时自动拉长延时。对于这种非商业化爬虫目标并发设成2.0已经偏激进第一次试验建议设1.0。如果你是在课程设计环境里跑没有足够时间等全量抓取可以直接在request的meta参数里设置dont_retry避免被封后多次重试反而加重问题。提示链家的url里通常带一个houseCode抓下来的列表页后续会点击进详情页如果发现只有列表没有详情数据优先检查Pipeline里是否对重复url做了去重。2.2 把两个CSV合并并清洗成DataFrame压缩包里已经有lianjia.csv和anjuke.csv说明作者抓完直接落地了。两个网站字段名称不相同但含义对应。清洗的第一步是单独读取给每个来源打标签再合并。这个步骤在notebook里通常放在“数据加载”单元格但很多人会跳过去直接用导致两个数据集的列对不齐。2.2.1 让pandas识别“万”和“元/平”背后的真实数字import pandas as pd df_lianjia pd.read_csv(lianjia.csv) df_anjuke pd.read_csv(anjuke.csv) # 统一列名至少保证下面四个核心字段存在 df_lianjia df_lianjia[[district, area, total_price, unit_price, bedroom, floor, facing, decoration]] df_anjuke df_anjuke[[district, area, total_price, unit_price, bedroom, floor, facing, decoration]] df_lianjia[source] lianjia df_anjuke[source] anjuke df pd.concat([df_lianjia, df_anjuke], ignore_indexTrue)很多新手直接用df[total_price]做特征结果模型报错“could not convert string to float”就是因为字段里带着“万”。这时用astype会失败先做字符串清理import numpy as np # 总价列去掉万单价列去掉元/平再转float df[total_price] df[total_price].str.replace(万, ).astype(float) df[unit_price] df[unit_price].str.replace(元/平, ).str.replace(,, ).astype(float) # 面积列可能带平米同时存在“暂无数据” df[area] df[area].str.replace(平米, ) df[area] pd.to_numeric(df[area], errorscoerce) df df.replace([np.inf, -np.inf], np.nan) df df.dropna(subset[total_price, area]) df.head()这里errorscoerce是pandas最值得记住的参数转换失败时会设为NaN而不是报错这样你可以统计有多少脏数据然后决定是删除还是插补。最后做一次重复值检查同一个小区、同面积、同朝向、同价格的房源极大概率是同一条数据被两个网站重复收录也可能是在爬虫翻页时重复抓取。2.2.2 字段统一要建立映射表把两边的楼层、装修、朝向字段放到一个表里看就能发现很多是文本变体。下面是我处理时的统一规则你也可以在使用说明里放一张同样的表原字段统一值处理方式中楼层/低楼层/高楼层middle/low/high保留字符串后续转为类别特征精装/简装/毛坯/其他精装/简装/毛坯/其他粗粒度分类南北/南/北/东南/西南/西/东有南/无南二进制特征海淀区/朝阳区海淀/朝阳去掉“区”字减少类别数注意低楼层不代表便宜北京有些低楼层带花园反而比高层贵所以不要对“楼层”做单调编码把它当类别特征交给树模型去学关系。朝向之所以压缩成“有南/无南”是因为南北通透的房源价格明显更高但“东南”和“西南”的差异不大让模型自己去拆容易过拟合先合并能提高稳健性。2.3 数据质量校验看到shape和describe先别慌清洗完以后不要急着建模。先看两个数样本数量、单位价格的标准差。如果某一来源的CSV抓下来有大量total_price为0或者面积小于10平一定要先过滤。北京的学区房可能存在“过道房”或者“面积只有5平”的挂牌这类是强异常值会严重影响线性回归的斜率。# 过滤明显异常面积小于10或大于300单价小于1万 df df[(df[area] 10) (df[area] 300)] df df[df[unit_price] 10000] print(df.shape) print(df[unit_price].describe())如果describe结果里count比shape[0]少很多说明还是有缺失值。这时用df.isna().sum()按列查一下重点看朝向和装修的缺失程度。缺失率低于10%的类别字段我一般用众数填充超过20%就直接删掉这个字段因为补出来的特征本身可能就是噪声。3. 特征工程与可视化先画图再上模型的北京二手房分析3.1 用visuals.py找出特征与房价的真实关系拿到一份项目源码先看它的visuals.py而不是直接跑notebook能快速理解作者对数据做了哪些假设。这个文件通常包含几类图单价直方图、总价与面积散点图、各城区平均单价柱状图、特征间相关性热力图。我在复现时最常用的是一张按城区分面的面积-总价散点图它比热力图更能体现业务的非线性关系。3.1.1 画一张可以放进课程设计报告的核心图import matplotlib.pyplot as plt import seaborn as sns plt.rcParams[font.sans-serif] [SimHei] # 中文显示 plt.rcParams[axes.unicode_minus] False fig, axes plt.subplots(1, 3, figsize(16, 5)) # 子图1单价分布 sns.histplot(df[unit_price], bins50, kdeTrue, axaxes[0]) axes[0].set_title(北京二手房单价分布) # 子图2面积-总价散点按source区分 sns.scatterplot(datadf.sample(1000, random_state42), xarea, ytotal_price, huesource, alpha0.6, axaxes[1]) axes[1].set_title(面积与总价关系) # 子图3热门城区均价 district_price df.groupby(district)[unit_price].median().sort_values(ascendingFalse).head(8) district_price.plot(kindbar, axaxes[2]) axes[2].set_title(各城区单价中位数) plt.tight_layout() plt.show()这组图的目的是检验数据是否和常识一致单价分布应该是右偏的面积-总价应该呈现近乎线性的簇西城和东城的中位数单价应显著高于其他城区。如果画出来是均匀分布说明爬虫只抓到了某几个小区的列表而不是全城数据后面建模会严重偏置。3.1.2 相关性矩阵的局限热力图是课程设计里最常出现的图但直接在所有特征上做pearson相关性有个坑朝向、装修这些类别编码后的数值之间没有线性含义算出来的相关系数没有业务解释。我会把数值特征单独算相关性类别特征用后面提到的one-hot编码后再看。visuals.py里如果有完整相关性图多半是拿来凑报告页数的你可以保留但心里要知道它不负责证明因果关系。3.2 类别型特征编码与数值特征缩放3.2.1 用pandas的get_dummies还是用sklearn的OneHotEncoder对于这个数据量通常几万条用pandas的get_dummies就够了代码简单、结果直观生成的特征列名也能直接对应原始值。但如果要用交叉验证网格搜索我会换成ColumnTransformer避免在交叉验证时把测试集信息掺进来。from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.compose import ColumnTransformer features [district, area, bedroom, floor, facing, decoration] X df[features].copy() y df[total_price].copy() categorical_cols [district, floor, facing, decoration] numeric_cols [area, bedroom] preprocessor ColumnTransformer( transformers[ (num, StandardScaler(), numeric_cols), (cat, OneHotEncoder(handle_unknownignore), categorical_cols) ]) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42)这里handle_unknownignore非常重要爬虫抓到的城区有可能在训练集里没出现如果不设置这个参数预测阶段会直接报“Found unknown categories”。用ColumnTransformer的原因是把缩放和编码打包进预处理对象之后可以放进pipeline里一起做交叉验证避免测试集的均值、极值污染训练过程。注意random_state42只是让结果可复现不是算命答辩时被问到为什么是42直接说“固定随机种子保证结果可重复验证”就行。3.2.2 要不要对总价取log北京二手房总价高度右偏几千万的豪宅会把训练误差拉得很大。常见做法是对y取log后再训练模型评估时再exp回来。这里有一点取舍线性回归对目标变量的正态性有一定要求取log有助于拟合但随机森林和梯度提升这类树模型并不关心目标的具体分布取log反而会增加解释成本。我一般先跑baseline线性回归看残差图如果残差异方差严重再取log。3.3 防止数据泄漏先划分再编码爬虫数据还要注意时间戳数据泄漏是这种课程设计里最隐蔽的“提分项”。很多同学分数很高但模型根本没学到知识因为用了全量数据的平均值来填充缺失值或者先做了标准化再划分训练测试集。更隐蔽的是爬虫数据的发布时间不同如果训练集里出现了测试集区域的未来信息比如同一个小区在后期挂牌价普遍上涨模型往往会“记住”小区而不是“理解”特征。处理办法是如果CSV里有房源发布时间按照时间先后排序后再划分训练集和测试集。原始爬虫数据里没有这个字段的话至少做到按城区分层抽样避免某个城区只在测试集出现。在代码里用train_test_split(stratifydf[district])比默认随机划分更可靠。4. 房价预测模型对比线性回归、随机森林到梯度提升4.1 统一评估方案用RMSE还是R2房价预测的评估指标在课程答辩里一定会被问。R2适合判断模型是否学到了趋势RMSE适合看真实误差但对百万级的房价来说RMSE是几十万还是几万决定了大作业能不能过。我建议三个指标一起输出from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score import numpy as np def evaluate_model(model, X_test, y_test, name): y_pred model.predict(X_test) rmse np.sqrt(mean_squared_error(y_test, y_pred)) mae mean_absolute_error(y_test, y_pred) r2 r2_score(y_test, y_pred) print(f{name:12s} | RMSE: {rmse:10.1f} | MAE: {mae:8.1f} | R2: {r2:.4f}) return rmse, mae, r2这里RMSE的单位是“万”如果模型输出的是总价。你会发现MAE通常比RMSE小很多说明存在少数极端豪宅拉高了RMSE。在答辩时可以说“中位数价格附近偏差控制在XX万”这句话比一个光秃秃的R2更有说服力。4.2 线性回归与Lasso在高维类别特征下找主效应把第3章的preprocessor和线性回归拼在一起先用默认参数跑一次baselinefrom sklearn.linear_model import LinearRegression, Lasso from sklearn.pipeline import Pipeline linear_pipe Pipeline([ (prep, preprocessor), (model, LinearRegression()) ]) linear_pipe.fit(X_train, y_train) evaluate_model(linear_pipe, X_test, y_test, LinearRegression)第一次跑出来的R2可能只有0.5-0.7这是正常的。因为one-hot后的城区特征让线性模型拥有很强的“分区平均价格”能力但无法捕捉面积在不同城区的差异化效应。一个很重要的改进是把“面积 × 城区”交叉特征放入模型我会在后面的进阶里讲。Lasso的作用是特征选择它对重复冗余的类别特征会自动稀疏化。用Lasso时注意alpha要调不要用默认1.0lasso_pipe Pipeline([ (prep, preprocessor), (model, Lasso(alpha0.01, max_iter100000, random_state42)) ]) lasso_pipe.fit(X_train, y_train) evaluate_model(lasso_pipe, X_test, y_test, Lasso)参数说明alpha越大惩罚越强特征被压成0的越多。对于one-hot后的上百个小区特征把alpha从1降到0.01通常是保留更多有用信息。max_iter要调大因为Lasso用坐标下降法特征多了默认1000次可能不收敛。4.3 集成模型随机森林与梯度提升的差距线性模型做完接下来上集成模型。对于这种混合了类别和数值特征、非线性关系较强的数据随机森林往往比线性回归有明显提升。这里有一个学生常犯的错误不经过调参就想拿随机森林跑出高分。至少要调整n_estimators和max_depth否则默认参数下可能导致严重过拟合。from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor rf_pipe Pipeline([ (prep, preprocessor), (model, RandomForestRegressor( n_estimators200, max_depth15, min_samples_leaf2, n_jobs-1, random_state42 )) ]) rf_pipe.fit(X_train, y_train) evaluate_model(rf_pipe, X_test, y_test, RandomForest)这里min_samples_leaf2防止树在叶子节点上只学到一个样本n_jobs-1让sklearn用满所有CPU核在课程设计用的笔记本上跑200棵树大概几十秒完全可以接受。随机森林的优势是几乎不需要特征缩放所以即使前面preprocessor里包含了StandardScaler它也不会帮倒忙只会让特征值变小树模型分裂点照样能找。接着跑梯度提升。GradientBoostingRegressor比随机森林慢但往往精度更高它对学习率learning_rate极其敏感。如果时间紧张可以考虑用sklearn的HistGradientBoostingRegressor速度快很多但在答辩时可能不好解释原理这里还是用经典版gbr_pipe Pipeline([ (prep, preprocessor), (model, GradientBoostingRegressor( learning_rate0.05, n_estimators300, max_depth5, subsample0.8, random_state42 )) ]) gbr_pipe.fit(X_train, y_train) evaluate_model(gbr_pipe, X_test, y_test, GradientBoosting)subsample0.8表示每棵树只用80%的样本相当于给GBDT加随机性减少过拟合。这三个模型跑完后你的notebook里应该有一张类似下面的成绩单模型R2RMSE万MAE万LinearRegression0.62132.489.2Lasso (alpha0.01)0.65126.885.7RandomForest0.8391.662.1GradientBoosting0.8683.455.3如果发现随机森林和梯度提升的R2相差不到0.02别急着说集成没用先检查数据量。爬虫抓到的样本够多GBDT才明显占优只有一两千条数据时简单模型反而更稳。4.4 效果不符预期时优先排查这三处一是重复样本。两个网站的数据合并后可能有大量同小区、同户型、同价格的重复这会让RMSE在测试集上虚高因为模型见过这些“记忆样本”。用df.drop_duplicates()清掉。二是区域分布不均。链家的数据朝阳和海淀占比可能超过40%而延庆、密云只有十几条树模型会忽略稀有小城区。可以用df.groupby(district)[total_price].count()查看如果某个城区样本数少于20干脆归入“其他”。三是面积和总价之间没有做乘积约束。现实中单价是总价除以面积如果模型预测出的总价除以面积和原始单价差太远说明模型没有学到“面积”的强决定作用。这时在特征里加上“面积 × 城区中位数单价”这种启发式特征分数会立刻提升但要注意不能直接把unit_price作为输入否则就变成用价格预测价格在业务上是无效的。5. 把notebook变成可演示的成果模型持久化与命令行预测课程设计只交ipynb也能过但要做到98分这个级别最好把模型保存下来让老师或面试官在一个命令里看到预测结果。sklearn的joblib是首选方式它比pickle对numpy数组更友好import joblib # 训练完最后模型后保存整个pipeline joblib.dump(gbr_pipe, beijing_house_price_model.joblib)然后在项目根目录放一个predict.py从命令行接收参数并输出预测结果。这样使用说明文档里就有一条真正可复现的演示链路python predict.py --district 朝阳 --area 80 --bedroom 2 --floor middle --facing 南 --decoration 精装# predict.py 核心代码 import argparse import joblib import pandas as pd parser argparse.ArgumentParser() parser.add_argument(--district, requiredTrue) parser.add_argument(--area, typefloat, requiredTrue) parser.add_argument(--bedroom, typeint, requiredTrue) parser.add_argument(--floor, requiredTrue) parser.add_argument(--facing, requiredTrue) parser.add_argument(--decoration, requiredTrue) args parser.parse_args() model joblib.load(beijing_house_price_model.joblib) sample pd.DataFrame([{ district: args.district, area: args.area, bedroom: args.bedroom, floor: args.floor, facing: args.facing, decoration: args.decoration }]) pred model.predict(sample)[0] print(f预测总价: {pred:.1f} 万元)最后在README里放一张模型结果表并说明当你用--district 朝阳 --area 80时输出会落在一个合理区间。如果想让可视化更直观可以用model.feature_importances_画一版特征重要性条形图这里有个小技巧GradientBoostingRegressor的feature_importances_是对所有树分裂增益的平均直接取preprocessor的feature_names_in_和它对应就能知道“朝阳”这个类别在模型里占了多大权重。这样交付的就不再是一个孤立的ipynb而是一个能跑完整链路的小型系统真遇到面试官问线上怎么用你也有东西可以展开。本文还有配套的精品资源点击获取