简介:这是一份面向计算机相关专业课程设计与期末大作业的机器学习实战项目,以北京二手房房价预测为场景,完整覆盖数据采集、清洗、特征工程、模型训练与结果可视化全流程,可作为课程设计或毕业设计的直接参考。项目经导师指导并获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.csv
2.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_index=True)很多新手直接用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'], errors='coerce') df = df.replace([np.inf, -np.inf], np.nan) df = df.dropna(subset=['total_price', 'area']) df.head()这里errors='coerce'是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'], bins=50, kde=True, ax=axes[0]) axes[0].set_title('北京二手房单价分布') # 子图2:面积-总价散点,按source区分 sns.scatterplot(data=df.sample(1000, random_state=42), x='area', y='total_price', hue='source', alpha=0.6, ax=axes[1]) axes[1].set_title('面积与总价关系') # 子图3:热门城区均价 district_price = df.groupby('district')['unit_price'].median().sort_values(ascending=False).head(8) district_price.plot(kind='bar', ax=axes[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_unknown='ignore'), categorical_cols) ]) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42)这里handle_unknown='ignore'非常重要:爬虫抓到的城区有可能在训练集里没出现,如果不设置这个参数,预测阶段会直接报“Found unknown categories”。用ColumnTransformer的原因是把缩放和编码打包进预处理对象,之后可以放进pipeline里一起做交叉验证,避免测试集的均值、极值污染训练过程。注意,random_state=42只是让结果可复现,不是算命,答辩时被问到为什么是42,直接说“固定随机种子,保证结果可重复验证”就行。
3.2.2 要不要对总价取log
北京二手房总价高度右偏,几千万的豪宅会把训练误差拉得很大。常见做法是对y取log后再训练模型,评估时再exp回来。这里有一点取舍:线性回归对目标变量的正态性有一定要求,取log有助于拟合;但随机森林和梯度提升这类树模型并不关心目标的具体分布,取log反而会增加解释成本。我一般先跑baseline线性回归,看残差图,如果残差异方差严重再取log。
3.3 防止数据泄漏:先划分再编码,爬虫数据还要注意时间戳
数据泄漏是这种课程设计里最隐蔽的“提分项”。很多同学分数很高,但模型根本没学到知识,因为用了全量数据的平均值来填充缺失值,或者先做了标准化再划分训练测试集。更隐蔽的是,爬虫数据的发布时间不同,如果训练集里出现了测试集区域的未来信息,比如同一个小区在后期挂牌价普遍上涨,模型往往会“记住”小区而不是“理解”特征。
处理办法是,如果CSV里有房源发布时间,按照时间先后排序后再划分训练集和测试集。原始爬虫数据里没有这个字段的话,至少做到按城区分层抽样,避免某个城区只在测试集出现。在代码里用train_test_split(stratify=df['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和线性回归拼在一起,先用默认参数跑一次baseline:
from 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.0:
lasso_pipe = Pipeline([ ('prep', preprocessor), ('model', Lasso(alpha=0.01, max_iter=100000, random_state=42)) ]) 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_estimators=200, max_depth=15, min_samples_leaf=2, n_jobs=-1, random_state=42 )) ]) rf_pipe.fit(X_train, y_train) evaluate_model(rf_pipe, X_test, y_test, 'RandomForest')这里min_samples_leaf=2防止树在叶子节点上只学到一个样本;n_jobs=-1让sklearn用满所有CPU核,在课程设计用的笔记本上跑200棵树大概几十秒,完全可以接受。随机森林的优势是几乎不需要特征缩放,所以即使前面preprocessor里包含了StandardScaler,它也不会帮倒忙,只会让特征值变小,树模型分裂点照样能找。
接着跑梯度提升。GradientBoostingRegressor比随机森林慢,但往往精度更高,它对学习率learning_rate极其敏感。如果时间紧张,可以考虑用sklearn的HistGradientBoostingRegressor,速度快很多,但在答辩时可能不好解释原理,这里还是用经典版:
gbr_pipe = Pipeline([ ('prep', preprocessor), ('model', GradientBoostingRegressor( learning_rate=0.05, n_estimators=300, max_depth=5, subsample=0.8, random_state=42 )) ]) gbr_pipe.fit(X_train, y_train) evaluate_model(gbr_pipe, X_test, y_test, 'GradientBoosting')subsample=0.8表示每棵树只用80%的样本,相当于给GBDT加随机性,减少过拟合。这三个模型跑完后,你的notebook里应该有一张类似下面的成绩单:
| 模型 | R2 | RMSE(万) | MAE(万) |
|---|---|---|---|
| LinearRegression | 0.62 | 132.4 | 89.2 |
| Lasso (alpha=0.01) | 0.65 | 126.8 | 85.7 |
| RandomForest | 0.83 | 91.6 | 62.1 |
| GradientBoosting | 0.86 | 83.4 | 55.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', required=True) parser.add_argument('--area', type=float, required=True) parser.add_argument('--bedroom', type=int, required=True) parser.add_argument('--floor', required=True) parser.add_argument('--facing', required=True) parser.add_argument('--decoration', required=True) 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,而是一个能跑完整链路的小型系统,真遇到面试官问线上怎么用,你也有东西可以展开。
本文还有配套的精品资源,点击获取