波士顿房价数据集实战:回归建模全流程与模型对比

发布时间:2026/9/7 17:41:08
波士顿房价数据集实战:回归建模全流程与模型对比 先扔个数据上来热热身。咱们这次用波士顿房价数据集直接上代码。很多刚接触机器学习的朋友第一个完整跑通的回归项目大概率都是这个波士顿房价。它的数据量适中、特征维度不算多、目标变量连续用来理解回归建模的完整流程确实顺手。但说实话大部分人只是机械地抄了一遍代码跑出来一个 R² 就结束了对数据本身长什么样、哪些坑藏在哪、为什么某些特征对房价影响这么大反而没什么概念。这篇我就带着大家从头到尾过一遍完整的建模流程包括探索性分析、数据预处理、模型训练和评估每一段代码我都会拆开讲顺便把我自己踩过的坑也一并交代清楚。1. 选这个数据集的真实理由简单但不简陋波士顿房价数据集最早来自 1970 年代的美国波士顿地区统计了那个时期不同街区的房价中位数以及一系列可能影响房价的指标。后来被卡内基梅隆大学的研究者整理成标准数据集收录进了 UCI 机器学习库再后来被 scikit-learn 直接内置成为无数人入坑机器学习的第一个实验场。我自己直到现在带新人练手还是喜欢先从它开始。原因有三点样本量适中一共 506 条样本13 个特征计算压力小任何一台普通笔记本都能秒跑非常适合用来聚焦建模思路而不是调优性能。特征有现实含义犯罪率、房间数、师生比、区位属性等每一项都能在真实世界里找到对应解释这比一堆抽象匿名字段更容易让人建立起直觉。回归任务典型预测一个连续数值房价中位数单位千美元涉及的评估指标、过拟合问题、特征缩放需求和真实工业项目高度一致。先复习一下 13 个特征的含义这张表建议存一下字段名含义单位/说明CRIM城镇人均犯罪率数值ZN占地超过 25000 平方英尺的住宅用地比例百分比INDUS城镇非零售业务用地比例百分比CHAS是否毗邻查尔斯河1 是0 否NOX一氧化氮浓度每千万分之一RM每套住宅平均房间数数值AGE自住单元中建于 1940 年之前的比例百分比DIS到波士顿五个就业中心的加权距离数值RAD到径向公路的可达性指数数值TAX每 10000 美元财产税率数值PTRATIO城镇师生比数值B城镇黑人比例相关的指标1000(Bk - 0.63)^2LSTAT低收入人群比例百分比注意关于 B 特征的计算方式和历史背景存在争议现代建模实践中一般建议慎重解读该特征甚至直接剔除。这一点等会儿在特征工程部分会专门聊。理解了特征含义后续做分析和调参的时候心里才有底。数据科学不是把 DataFrame 丢进 fit 函数就完事每一列背后都代表现实世界的一个切面理解它们之间的关联和矛盾才是建模之外的真正收获。2. 加载数据几种方式对比顺便解决真实数据源的问题现在开始动代码。先导入基础库import numpy as np import pandas as pd import matplotlib.pyplot as plt import seaborn as sns plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False加载波士顿房价数据有几种不同的路径我分别说一下因为不同方式拿到的数据格式差异还挺大的。2.1 方式一直接调用 scikit-learn 自带的加载函数from sklearn.datasets import load_boston boston load_boston() print(boston.keys()) print(boston.DESCR)这是最不用动脑子的方式。但它有个很尴尬的问题从 scikit-learn 1.2 版本开始load_boston就已经被标记为将要移除的接口到了 1.4 版本被彻底删除了。如果你用的是最新版的 scikit-learn上面这行代码直接报错。当年官方移除它的理由是需要兼顾数据集的生命周期和维护成本但不少培训机构还在教这段代码导致很多新手一上来就撞墙。2.2 方式二从外部 CSV 文件读入既然内置接口被删了更稳妥的做法是直接找原始 CSV 数据。网上有不少地方托管了整理好的波士顿房价数据文件下载后放到本地用 pandas 加载即可。df pd.read_csv(boston_housing.csv)需要注意一点不同渠道拿到的 CSV列名可能有细微出入有些是完整的特征名称有些是简化缩写加载完之后先打印前几行确认一下结构。2.3 方式三从 UCI 机器学习库直接读取url https://archive.ics.uci.edu/ml/machine-learning-databases/housing/housing.data df pd.read_csv(url, sep\s, headerNone) df.columns [CRIM, ZN, INDUS, CHAS, NOX, RM, AGE, DIS, RAD, TAX, PTRATIO, B, LSTAT, MEDV]这种方式适合不想下载文件、希望代码一键跑通的场景。但本地网络访问国外资源偶尔不稳定如果读取失败还是老老实实把数据下载到本地再读。我个人测试下来本地文件方式最省心不用考虑网络问题后续所有代码交互也都在本地完成。本篇文章后续的代码都以 DataFrame 格式为基础。3. 探索性数据分析先看清楚数据再谈建模拿到数据第一步永远是探索性数据分析也就是 EDA。这一步做得越扎实后面建模越顺手。我习惯按下面这个顺序来。3.1 整体概览print(df.shape) print(df.info()) print(df.describe().T)运行结果里有几个信息需要特别留心RM房间数均值约 6.28最小 3.56最大 8.78。这告诉我们波士顿市区住宅普遍不大如果后续建模时遇到极端值可以结合实际场景判断是否合理。TAX的分布跨度非常大最小 187最大 711。税率和区位高度相关后面做相关性分析时大概率能看到它和 RAD 强关联。MEDV作为目标变量最大 50.0。这其实是个截断值数据集本身做了处理房价超过 50 的统一记成 50。意味着模型在该区间可能会系统性低估。3.2 目标变量分布sns.histplot(df[MEDV], kdeTrue) plt.title(MEDV 分布直方图) plt.show()MEDV 的分布整体接近正态但右侧在 50 这个位置明显有一个堆积。这个截断现象做回归模型时基本忽略不计但如果你要做更深度的分析需要知道这一点。3.3 特征与目标的相关性分析这一步非常关键它直接决定后续特征工程的思路。plt.figure(figsize(12, 10)) corr df.corr() sns.heatmap(corr, annotTrue, fmt.2f, cmapcoolwarm, squareTrue) plt.title(特征相关性热力图) plt.show()盯住热力图的最后一行也就是和 MEDV 的相关性按绝对值从大到小排序corr_with_target corr[MEDV].abs().sort_values(ascendingFalse) print(corr_with_target)正常情况下排在最前面的是LSTAT和RM。这两个特征与房价的相关性绝对值都在 0.7 左右LSTAT 是负相关——低收入人群比例越高的区域房价越低RM 是正相关——房间数越多房价越高。这两个特征几乎已经成为波士顿房价数据集的代表性符号。除此之外DIS和NOX的相关系数也非常高大约 -0.77这说明距离就业中心越远一氧化氮浓度越低现实中对应的是市中心交通密集区空气污染更严重。这类特征对之间存在明显的多重共线性后面做线性回归时会让系数的解释变得不稳定。3.4 散点图看非线性趋势相关性热力图看的是线性关系但真实世界往往更复杂。我习惯把最重要的几个特征和 MEDV 的散点图拉出来看看。fig, axes plt.subplots(1, 2, figsize(12, 4)) sns.scatterplot(datadf, xLSTAT, yMEDV, axaxes[0]) axes[0].set_title(LSTAT vs MEDV) sns.scatterplot(datadf, xRM, yMEDV, axaxes[1]) axes[1].set_title(RM vs MEDV) plt.tight_layout() plt.show()LSTAT 对 MEDV 的关系呈现明显的非线性低端是一条陡峭的下坡高收入区域后期趋于平缓RM 对 MEDV 也不是单纯的直线关系房间数超过 7 之后房价上涨得更快。如果只拿线性模型去拟合这些非线性结构就会被当成误差丢掉。这也是很多新手容易忽略的点相关性高不代表可以无脑线性拟合画图看趋势永远是建模前不可跳过的一步。4. 数据预处理标准化和训练集划分里的门道4.1 为什么需要标准化波士顿房价的 13 个特征量纲差异非常大。CRIM 的取值范围大致是 0.006 到 89 左右而 TAX 的取值范围是 187 到 711两者数量级完全不同。如果用原始值直接跑基于距离的模型或者正则化回归量纲大的特征会在计算中天然占据更大的权重模型学到的结果就会被带偏。from sklearn.preprocessing import StandardScaler X df.drop(MEDV, axis1) y df[MEDV] scaler StandardScaler() X_scaled scaler.fit_transform(X)标准化之后每个特征均值接近 0、标准差接近 1特征之间的可比性就建立起来了。这里我全量数据先做了标准化但严格的流程应该是先划分训练集和测试集再用训练集拟合 scaler最后用同一个 scaler 转换测试集防止数据泄露。4.2 正确的数据划分方式from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test)注意测试集转换时只用了transform没有fit。这样做是为了保证测试数据完全“陌生”没有被统计信息污染。数据泄露在工业界是个很恶心的问题短期看不出问题一上线就原形毕露。提示random_state42这个魔法数字目的是固定随机种子保证每次运行结果一致。你自己复现时可以随便换数字但固定下来方便和别人对结果。4.3 CHAS 特征的处理CHAS 是二元分类特征大部分样本取 0只有 35 个样本取 1。标准化对这类 0/1 特征其实意义不大保持原样即可。实际操作中如果把它也标准化了0 会变成约 -0.271 会变成约 4.7解释起来反而别扭。在这个数据集里 CHAS 对房价的影响比较小但它提醒我们一个更通用的道理不是所有特征都需要标准化。标准化的对象应该是连续数值型特征类别型特征按情况处理有的是标签编码有的是独热编码。5. 建模实战线性回归的完整代码与结果解读终于到主菜了。先上最经典、也最好解释的线性回归模型。from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error, r2_score, mean_absolute_error lr LinearRegression() lr.fit(X_train_scaled, y_train) y_train_pred lr.predict(X_train_scaled) y_test_pred lr.predict(X_test_scaled) train_mse mean_squared_error(y_train, y_train_pred) test_mse mean_squared_error(y_test, y_test_pred) train_r2 r2_score(y_train, y_train_pred) test_r2 r2_score(y_test, y_test_pred) train_mae mean_absolute_error(y_train, y_train_pred) test_mae mean_absolute_error(y_test, y_test_pred) print(训练集 MSE: {:.2f}, R2: {:.2f}, MAE: {:.2f}.format(train_mse, train_r2, train_mae)) print(测试集 MSE: {:.2f}, R2: {:.2f}, MAE: {:.2f}.format(test_mse, test_r2, test_mae))跑完输出可能类似这样训练集 MSE: 21.78, R2: 0.76, MAE: 3.30 测试集 MSE: 29.53, R2: 0.69, MAE: 3.42我先解释一下这几个指标MSE均方误差预测值和真实值差的平方的平均值。因为做了平方大误差会被放大所以 MSE 对离群点比较敏感。这里没对 y 做标准化所以 MSE 的单位是“千美元的平方”理解上不够直观。MAE平均绝对误差更直观平均每个样本预测差 3.4 千美元左右也就是 3400 美金。放在 20 世纪 70 年代的波士顿这个误差不算小但作为入门模型已经可以接受了。R²决定系数0.69 意味着模型能解释测试集 69% 的方差。比分比听起来直观但不要迷信这个数字。训练集 R² 0.76测试集 R² 0.69两者差距不算大说明模型没有严重过拟合。如果训练集 R² 是 0.95、测试集掉到 0.5那才是灾难。5.1 回归系数的解释coef_df pd.DataFrame({ feature: X.columns, coef: lr.coef_ }).sort_values(coef, ascendingFalse) print(coef_df)标准化之后回归系数可以直接比较相对重要性RM的系数显著为正印证了房间数越多房价越高的判断。LSTAT的系数显著为负低收入人群比例越高房价越低。DIS也是负的距离就业中心越远房价越低。但这里有个坑需要注意多重共线性会让系数的绝对值被分散。比如NOX和DIS高度相关两者的系数可能都在 -3 左右单独看似乎影响不大但它们其实是把同一个现实因素空气质量和区位距离拆成了两份来解释。这也是为什么互联网上一些文章直接拿线性回归系数谈“特征重要性”是很不严谨的。5.2 残差分析评估回归模型不能只看指标数字残差图必须看。sns.scatterplot(xy_test, yy_test_pred) plt.plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], r--) plt.xlabel(真实值) plt.ylabel(预测值) plt.title(真实值 vs 预测值) plt.show() residuals y_test - y_test_pred sns.histplot(residuals, kdeTrue) plt.title(残差分布) plt.show()如果模型表现良好散点图应该围绕红色对角线对称分布残差分布应该近似正态且均值为 0。但波士顿房价这个数据集跑出来的结果通常在右上角能看到几个点明显低于对角线——这就是前面提到的 MEDV 被截断在 50 的样本真实值封顶了模型预测却超出了这个上限导致系统性偏差。遇到这种情况一个务实的处理方式是检查这些点占整体样本的比例。如果只是极少数可以直接接受不影响整体评估。6. 换个角度用 Lasso 回归做特征选择与正则化线性回归虽然简单但前面提到多重共线性问题会影响可解释性。这时候 Lasso 回归就很有用了——它的 L1 正则化项会把一部分特征的系数压缩成精确的 0相当于内嵌了特征选择。from sklearn.linear_model import LassoCV lasso LassoCV(cv5, random_state42) lasso.fit(X_train_scaled, y_train) print(最优 alpha:, lasso.alpha_) y_lasso_train lasso.predict(X_train_scaled) y_lasso_test lasso.predict(X_test_scaled) print(训练集 R2: {:.2f}.format(r2_score(y_train, y_lasso_train))) print(测试集 R2: {:.2f}.format(r2_score(y_test, y_lasso_test))) coef_lasso pd.DataFrame({ feature: X.columns, coef: lasso.coef_ }) print(coef_lasso)LassoCV 的好处是不用手动指定 alpha它会在交叉验证中自动搜索最优值。跑完之后你会发现某些特征通常是 ZN、CHAS、AGE 这些系数变成 0说明在其它特征存在的情况下它们对房价的解释能力几乎可以忽略。这是我个人很推荐放在线性回归之后演示的正则化方法——它用一组简单的代码直观地回答了“哪些特征其实不重要”这个问题。而且 Lasso 对特征共线性的容忍度比普通线性回归更高适合多层特征存在强关联的复杂场景。7. 树模型对比随机森林的表现与非线性的优势线性模型搞完再上一个稳定的树模型对比一下。随机森林天生不需要特征标准化直接吃原始特征即可。from sklearn.ensemble import RandomForestRegressor rf RandomForestRegressor(n_estimators200, max_depth10, random_state42) rf.fit(X_train, y_train) y_rf_train rf.predict(X_train) y_rf_test rf.predict(X_test) print(训练集 R2: {:.2f}.format(r2_score(y_train, y_rf_train))) print(测试集 R2: {:.2f}.format(r2_score(y_test, y_rf_test))) print(测试集 MAE: {:.2f}.format(mean_absolute_error(y_test, y_rf_test)))通常随机森林的测试集 R² 能比线性回归高出 5 到 10 个百分点原因就是它可以捕捉 LSTAT 的低谷、RM 高端的加速上涨这类非线性结构。7.1 特征重要性feature_importance pd.DataFrame({ feature: X.columns, importance: rf.feature_importances_ }).sort_values(importance, ascendingFalse) print(feature_importance)随机森林给出的特征重要性排序通常把LSTAT和RM放在最前面这和相关性分析的结果是一致的。这两个特征之所以在树模型里依旧举足轻重是因为它们和 MEDV 的关系既有线性成分也有非线性成分树模型把它们切得越细涨得越快。7.2 模型调参的粗筛思路from sklearn.model_selection import GridSearchCV param_grid { n_estimators: [100, 200, 300], max_depth: [5, 10, 15], min_samples_split: [2, 5, 10] } rf_grid RandomForestRegressor(random_state42) grid_search GridSearchCV(rf_grid, param_grid, cv5, scoringr2, n_jobs-1) grid_search.fit(X_train, y_train) print(最优参数:, grid_search.best_params_) print(最优模型测试集 R2: {:.2f}.format(r2_score(y_test, grid_search.predict(X_test))))网格搜索虽然暴力但在这个数据量下计算成本很低十几秒就能出结果。真实项目里如果数据量大一般会改用 RandomizedSearchCV用随机采样替代全排列大幅缩短搜索时间。注意调参的目标是提升测试集表现不是无限拟合训练集。如果发现调参后训练集 R² 持续上升而测试集 R² 不涨说明已经进入过拟合区间适可而止。8. 踩坑记录波士顿房价数据集的实际使用教训最后这部分我把这些年实际使用这个数据集时踩过的坑集中列一下希望对你有帮助。8.1 内置数据集接口失效开头已经提过scikit-learn 新版本删除了load_boston。如果你复制了老教程里的代码跑不通不用怀疑自己直接改用外部 CSV 即可。而且从工程化角度看直接用外部文件也更容易统一数据版本。8.2 UCI 下载链接不稳定如果选择从网络直接读取数据务必加一层异常处理或者干脆下载到本地。网速抖动在课堂上问题不大在演示现场或考试环境里能活活急死人。8.3 所谓“B 特征”的歧视性争议B 特征的计算公式直接基于人口构成数据在今天的价值观和伦理标准下这类敏感人口属性作为预测变量存在明显问题。我做这个数据集的教学演示时通常会直接把 B 特征剔除掉。数据科学不只是追求预测精度更要考虑特征的合理性和社会影响。少一个特征对模型精度影响很小但对项目的合规性影响很大。8.4 数据集的年代局限波士顿房价数据反映的是 1970 年代的波士顿城市格局。今天再用它来做“预测波士顿当前房价”是不现实的城市人口结构、交通网络、经济重心早已天翻地覆。它更合适的定位是教学数据用来展示建模方法论而不是现实预测工具。8.5 标准化的时机再次强调scaler 只能用训练集拟合然后在测试集上做转换。看到过很多新手在整个数据集上先 fit 再 split虽然跑起来没问题但这是典型的数据泄露。数据量大的时候可能看不出来一旦部署上线模型的稳定性就会出问题。9. 一份可以直接运行的完整代码把整篇内容浓缩成一份可复现的脚本方便你直接保存运行import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LinearRegression, LassoCV from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import r2_score, mean_absolute_error, mean_squared_error # 1. 加载数据 df pd.read_csv(boston_housing.csv) X df.drop(MEDV, axis1) y df[MEDV] # 2. 拆分数据集 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) # 3. 标准化 scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test) # 4. 线性回归 lr LinearRegression() lr.fit(X_train_scaled, y_train) print(Linear Regression - Test R2: {:.2f}.format( r2_score(y_test, lr.predict(X_test_scaled)) )) # 5. Lasso回归 lasso LassoCV(cv5, random_state42) lasso.fit(X_train_scaled, y_train) print(Lasso - Test R2: {:.2f}.format( r2_score(y_test, lasso.predict(X_test_scaled)) )) print(Lasso 系数归零的特征:, X.columns[lasso.coef_ 0].tolist()) # 6. 随机森林 rf RandomForestRegressor(n_estimators200, max_depth10, random_state42) rf.fit(X_train, y_train) print(Random Forest - Test R2: {:.2f}.format( r2_score(y_test, rf.predict(X_test)) )) # 7. 特征重要性 importance pd.DataFrame({ feature: X.columns, importance: rf.feature_importances_ }).sort_values(importance, ascendingFalse) print(importance)这套代码从数据加载到模型对比一条线跑下来整个建模流程就通了。波士顿房价数据集虽然老了但它作为入门级回归任务的地位至今没有被完全替代。用它把线性回归、正则化、树模型、交叉验证这些核心概念串起来比直接上手复杂业务数据要高效得多。如果你练完这个数据集觉得不过瘾下一步可以试试加州房价数据集数据量更大、特征更接近现代场景、挑战也更足。