先扔个数据上来热热身,这段我熟。回归任务练手,波士顿房价数据集绝对算得上祖师爷级别的存在,十几年前我刚接触机器学习的时候就是拿它起步的。现在虽然各种新数据集层出不穷,但这个506条样本、13个特征的小老头儿,依旧是理解回归问题、跑通建模流程最顺手的材料。咱们这次就直接点,不谈虚的,先把这个数据接进来,然后一路杀到模型评估,把整个回归项目从头到尾过一遍。这篇文章适合刚学完Python基础、想开始碰机器学习但不知道第一步干啥的人,也适合那些模型跑过不少但一直没系统梳理过数据分析和特征工程细节的朋友。
1. 为什么拿波士顿房价当练手题
1.1 数据集到底讲了什么事
波士顿房价数据集采集于上世纪70年代,记录的是美国波士顿地区不同街区的房屋价格中位数,以及这些街区当时的一系列社会经济、环境、地理位置等特征。听起来挺遥远,但它的结构非常干净:506行数据,每一行代表一个街区,13个输入特征,1个预测目标——自住房屋价格中位数(MEDV),单位是千美元。
这个数据的迷人之处在于,它不是那种随便造出来的“假练习数据”,而是真实社会统计的结果。也就是说,你在上面做的每一个特征分析、每一张可视化图表,背后都有实际意义。比如某个特征代表“一氧化氮浓度”,另一个代表“每万人中教师数量”,这些放在一块儿研究房价,就能看出很有意思的关联。这种真实感给了初学者很大的学习动力,因为它不是刷题,而是在处理一个真正发生过的问题。
1.2 为什么这个数据集适合入门回归
我见过很多人一上来就啃图像识别、NLP那种大工程,结果装环境就装了两天,最后连模型长什么样都没见着。练手最重要的是快速闭环,回归任务刚好是这个闭环里最不绕路的一条。
波士顿房价数据集理论上就是干这个用的。506条样本不算多,特征也只有13个,意味着你不需要GPU,不需要分布式,一台普通笔记本跑线性回归、决策树、随机森林,全部加在一起也用不了几分钟。但它麻雀虽小五脏俱全:有连续数值型特征、有二值特征(比如是否临河)、有存在偏态分布的列,还有目标变量本身就需要对比线性模型和非线性模型的差异。一个项目跑下来,从数据清洗到特征工程再到模型评估,整套流程一个都不少,这恰恰是入门阶段最需要的“结构化训练”。
1.3 13个特征分别是什么来头
咱们直接把特征列拉一遍,先混个脸熟:
| 特征名 | 含义 | 类型 | 经验判断 |
|---|---|---|---|
| CRIM | 城镇人均犯罪率 | 连续值 | 越高通常对房价负面影响 |
| ZN | 占地超过2.5万平方英尺的住宅用地比例 | 连续值 | 反映居住密度 |
| INDUS | 城镇非零售业务用地比例 | 连续值 | 偏高可能说明偏工业区 |
| CHAS | 是否临近查尔斯河(1=是,0=否) | 二值 | 临河景观溢价 |
| NOX | 一氧化氮浓度 | 连续值 | 空气污染指标 |
| RM | 平均每套住宅的房间数 | 连续值 | 越大通常房价越高 |
| AGE | 建于1940年前的自住房比例 | 连续值 | 老旧房屋越多可能拉低价格 |
| DIS | 到波士顿五大就业中心的加权距离 | 连续值 | 距离越近通勤越方便 |
| RAD | 径向高速公路可达性指数 | 连续值 | 交通便利程度 |
| TAX | 每万美元房产税率 | 连续值 | 税负成本 |
| PTRATIO | 所在城镇师生比 | 连续值 | 教育资源指标 |
| B | 城镇黑人比例相关的指标 | 连续值 | 历史遗留的统计项,需谨慎解读 |
| LSTAT | 低收入人口比例 | 连续值 | 通常负相关,但需结合时代背景看 |
这里有个地方必须提醒:B这个特征,翻译过来就是与黑人比例相关的统计量。在当年的社会背景下它被作为房价分析的变量之一,但放到今天很多统计学教材都会专门讨论它的伦理问题。咱们现在拿它练手可以,但心里要有数——数据特征是时代的切片,不代表现在的价值观,更不建议你在实际项目中用类似口径做敏感属性分析。这个点到为止,后面分析的时候我会按主流做法处理,不会在这个特征上做过度解读。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备和真实的数据加载姿势
2.1 先列出需要的库
我默认你的机器上已经装好了Python环境。装库这种事儿我就不废话了,直接给清单:
python复制import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
matplotlib和seaborn是为了画图,sklearn是建模主力,pandas和numpy是处理数据的基本盘。如果你还没装seaborn,直接pipp install seaborn就行,它会自动把依赖的pandas、matplotlib都带上来。
2.2 从sklearn直接加载的坑
回到标题说的“直接上代码”,第一步就是加载数据。以前有个特别省事儿的写法:
python复制from sklearn.datasets import load_boston
boston = load_boston()
但注意,这个写法现在已经废了。sklearn在1.2版本之后就把 load_boston 移除了,原因是这个数据集存在上面提到的伦理争议,官方不再主动维护和分发。你要是装了新版本的scikit-learn,跑这行代码直接报AttributeError。所以网上一搜一堆老教程里用的这个写法,现在都是跑不通的,这也是新手很容易卡住的第一道坎。
那怎么办?两个方案:
方案一,找一个CSV版本的波士顿房价数据文件。很多开源仓库和个人博客都留存了原始CSV,下载下来直接读:
python复制df = pd.read_csv('boston_housing.csv')
方案二,自己从原数据源拼一个加载器。其实sklearn社区有建议的替代写法,你可以在代码里把特征列名和数据集手动拼起来:
python复制import pandas as pd
import numpy as np
data_url = "http://lib.stat.cmu.edu/datasets/boston"
raw_df = pd.read_csv(data_url, sep="\s+", skiprows=22, header=None)
data = np.hstack([raw_df.values[::2, :], raw_df.values[1::2, :2]])
target = raw_df.values[1::2, 2]
df = pd.DataFrame(data, columns=[
'CRIM', 'ZN', 'INDUS', 'CHAS', 'NOX', 'RM', 'AGE',
'DIS', 'RAD', 'TAX', 'PTRATIO', 'B', 'LSTAT'
])
df['MEDV'] = target
这段是从CMU的原始数据源读取,数据格式里前13列是特征,最后1列是房价。切片操作稍显复杂,但思路就是:原始文件里一行是特征、一行是目标交替排列,所以用[::2]和[1::2]把它们拆开再拼回去。这种写法不用依赖任何外部文件,而且完全可控。我建议你直接用这个方案,反正就几行代码,以后想复现也省得再找文件。
2.3 先看一眼数据长什么样
数据加载进来之后,先不要急着建模,做一个最基本的check:
python复制print(df.shape)
print(df.info())
print(df.describe().T)
code复制(506, 14)
info会告诉你有没有缺失值,describe会列出每一列的均值、标准差、最小值、四分位数和最大值。我强烈建议这个动作成为习惯——不管拿到的数据是什么,先看shape、再看缺失、再看分布,三件套下来心里基本有底了。
跑完你会发现,这506条数据一个缺失值都没有,非常规整。但目标变量MEDV有玄机:最大值是50.0,而且刚好50的样本数量不少。这说明什么?数据采集时有上限截断——当年超过5万美元的房价统一记为50。这意味着模型的预测天花板天然被压缩了,后期评估模型时要注意这一点,别指望它能预测出超过50的值。
3. 先不急着建模,把数据摸透再说
3.1 目标变量的分布长什么样
直接把MEDV画个直方图看分布:
python复制plt.figure(figsize=(8, 5))
sns.histplot(df['MEDV'], bins=50, kde=True)
plt.xlabel('MEDV (千美元)')
plt.title('波士顿房价分布')
plt.show()
画完你会看到,MEDV整体呈现一个近似正态但右尾被压平的形状,峰值集中在20-25千美元区间,右侧在50的地方有一根高高的柱子——这就是上面说的截断效应。这种分布意味着直接用线性回归去做预测,模型天然会对高房价区间产生低估。因为你训练的时候根本没有见过大于50的真实样本,模型只能揣着明白装糊涂。
对回归任务来说,目标变量的分布状态直接影响模型选择。如果偏态非常严重,常规操作是取log变换让目标更接近正态。这里考虑到截断效应,log变换会放大上端信息,反而可能导致过拟合。所以我建议这个项目保持原始目标不变,但在心里留个预期:模型在50附近的预测会系统性地偏低。
3.2 相关性分析:先把舞台搭好
接下来看看13个特征和房价之间的关系。这一步不是花架子,它能帮你快速定位哪些特征是核心驱动因子,哪些特征之间存在高度共线,从源头上避免往模型里塞垃圾。
python复制plt.figure(figsize=(12, 10))
corr_matrix = df.corr()
sns.heatmap(corr_matrix, annot=True, fmt='.2f', cmap='coolwarm', linewidths=0.5)
plt.title('特征相关性热力图')
plt.show()
热力图一出来,几个关键信号非常明显:
- RM(房间数)和MEDV相关性最高,大约是0.7,正相关,符合直觉,房子大房间多就是贵。
- LSTAT(低收入人口比例)和MEDV呈强负相关,大约-0.74,这个方向上同样符合统计直觉。
- NOX(一氧化氮浓度)和DIS(到就业中心距离)高度负相关,相关系数接近-0.77。这个也说得通——离市中心远的地方工业污染通常少一些,NOX浓度低一些。
- RAD和TAX高度正相关,0.91,交通运输越便利的区域税率越高。
最后一条就是典型的共线性症状。如果同时把RAD和TAX扔进线性回归,模型的回归系数会变得很不稳定,谁贡献了多大解释力根本分不清。这就是为什么后面要做特征处理或者干脆用集成模型来规避这个问题。
3.3 挑两个关键特征画图直观看规律
光看相关性数字还是不够直观,画个散点图加回归线感受一下:
python复制fig, axes = plt.subplots(1, 2, figsize=(12, 5))
sns.scatterplot(data=df, x='RM', y='MEDV', ax=axes[0])
axes[0].set_title('RM vs MEDV')
sns.scatterplot(data=df, x='LSTAT', y='MEDV', ax=axes[1])
axes[1].set_title('LSTAT vs MEDV')
plt.tight_layout()
plt.show()
RM和MEDV的散点图能看出来比较明显的正向趋势,但到了RM大于7之后,点开始变得稀疏,而且有不少点落在了回归线的下方。这说明大户型对房价的边际提升不是无限增长的。LSTAT和MEDV的关系更好玩,明显不是直线,而是呈一个弯曲的负相关曲线——低收入比例低时房价高,但低收入比例一旦上去,房价会快速下跌,之后斜率变缓。这种非线性特征就是后面选模型时要重点考虑的:线性回归的默认假设是线性关系,遇到这种曲线就得靠非线性模型或者加多项式特征来弥补。
4. 预处理:标准化和数据集拆分
4.1 标准化到底解决什么问题
机器学习模型里,线性回归、SVM、KNN这类基于距离或者梯度的算法,对特征的尺度非常敏感。你看波士顿房价数据里的列,CRIM取值范围是0.006到接近90,RM取值范围是3.5到8.8,TAX范围是187到711,这数量级完全不是一个水平。如果直接丢进模型,数值大的特征天然会在距离计算里占据主导地位,模型会误以为TAX比RM重要得多——因为你光比较数值大小就已经不在一个量级上了。
标准化就是把每一列都拉回到平均水平0、标准差1的尺度上:
python复制scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
print(X_scaled.mean(axis=0))
print(X_scaled.std(axis=0))
跑完你会看到,所有列的均值都接近0,标准差都接近1。这就公平了,模型再也不用看尺度的脸色了。
4.2 拆分数据集:先切还是先缩放
数据拆分的标准动作是:先切训练集和测试集,再在训练集上做标准化,然后把同一个scaler应用到测试集。
python复制X = df.drop('MEDV', axis=1)
y = df['MEDV']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
这里头藏着一个极其重要的细节,是我见过无数新手踩过的坑:如果你先对全量数据做标准化,再切分,那么测试集的信息就已经“渗入”了训练过程。因为scaler的均值和标准差是用全量数据算出来的,测试集数据参与了训练阶段的统计计算,这在严格意义上属于数据泄漏。虽然标准化这一步泄漏的影响往往不是致命的,但这种思维方式一旦养成,后面处理特征选择、缺失值填充时就会犯更大的错误。正确做法就是上面这段代码:fit只用在训练集,transform同时作用在两个集合。
随机状态 random_state=42 也很关键。不固定这个参数,每次运行代码切分结果都不一样,模型分数每次也不同,最后你根本分不清是模型变好了还是数据切分变了。固定之后,实验结果可复现,这是做实验的基本素养。
4.3 为什么用20%做测试集
train_test_split里test_size=0.2是经验值的典型用法,意思是80%数据用来训练模型,20%数据留作最后的考试。506条样本切完,训练集404条,测试集102条。测试集数量虽然不算多,但对于这个量级的数据集来说已经足够看出模型泛化能力了。如果你把测试集设得太小,比如5%,那么评估结果方差会很大,一次划分的运气成分顶得过模型本身的实力差距。反过来说,如果你设成50%,训练数据太少,模型学不到足够的规律。20%到30%之间是普遍比较稳的区间。
5. 建模与评估:从线性回归到随机森林
5.1 先拿线性回归打底
人狠话不多,直接上代码:
python复制lr = LinearRegression()
lr.fit(X_train_scaled, y_train)
y_pred_lr = lr.predict(X_test_scaled)
mse_lr = mean_squared_error(y_test, y_pred_lr)
rmse_lr = np.sqrt(mse_lr)
mae_lr = mean_absolute_error(y_test, y_pred_lr)
r2_lr = r2_score(y_test, y_pred_lr)
print(f"Linear Regression - RMSE: {rmse_lr:.4f}, MAE: {mae_lr:.4f}, R2: {r2_lr:.4f}")
跑完大概会得到RMSE在4.8左右,R2在0.72左右的成绩。什么意思呢?RMSE的单位是千美元,4.8千美元意味着平均预测误差大约4800美元,放在当时波士顿房价的价位水平上,误差不算小但也不是离谱。R2=0.72说明模型能解释房价方差的72%,剩下的28%是特征没有覆盖到的信息或者模型本身的局限。
线性回归还有一个好处——可以看见每个特征的系数。因为我们已经做了标准化,所以系数的绝对值可以直接当作特征重要性的粗略衡量:
python复制coef_df = pd.DataFrame({
'特征': X.columns,
'系数': lr.coef_
}).sort_values('系数', key=lambda x: x.abs(), ascending=False)
print(coef_df)
结果里LSTAT和RM的系数绝对值最大,这在相关性分析里已经有了预兆。比较意外的是NOX的系数符号可能是正的——这属于多重共线性捣乱的结果。因为NOX和DIS高度相关,在线性模型里它们互相拉扯,导致单个特征的系数方向和真实含义相反。这就是上面说的共线性问题的实际体验,看系数的时候一定要小心,别急着下结论。
5.2 回归任务的评估指标怎么读
回归不像分类那样只看准确率,指标有好几个,每个指标都在说不同的事儿。简单梳理一下:
- MAE(平均绝对误差):误差绝对值的平均值,最直觉的指标。它给每个样本同样的权重,感受的是“平均差多少”。
- MSE(均方误差):误差平方的平均值。平方操作会放大大的误差,所以它惩罚的是那些预测得特别离谱的样本。
- RMSE:MSE开根号,把单位拉回和原始目标一致,这样可以直接说“平均偏差几千美元”。
- R2:决定系数,表示模型解释了多少方差,1是完美拟合,0是跟直接用均值预测差不多的水平。
看这四个指标最好放一起看。如果MAE正常但RMSE明显偏大,说明大部分样本预测得还行,但有一小部分样本误差巨大,这时候就要去看看是不是有极端值在作妖。如果R2低但RMSE也不高,可能是目标本身的方差比较小,模型再怎么努力上限也有限。
5.3 随机森林:非线性模型的实力碾压
线性回归跑完,我建议马上换成随机森林试一把,因为前面散点图里LSTAT已经暴露出了明显的非线性趋势,线性模型天生处理不了这种形状。
python复制rf = RandomForestRegressor(n_estimators=100, max_depth=10, random_state=42)
rf.fit(X_train_scaled, y_train)
y_pred_rf = rf.predict(X_test_scaled)
mse_rf = mean_squared_error(y_test, y_pred_rf)
rmse_rf = np.sqrt(mse_rf)
mae_rf = mean_absolute_error(y_test, y_pred_rf)
r2_rf = r2_score(y_test, y_pred_rf)
print(f"Random Forest - RMSE: {rmse_rf:.4f}, MAE: {mae_rf:.4f}, R2: {r2_rf:.4f}")
随机森林这边RMSE大约会降到3.8左右,R2提升到0.86左右,提升幅度非常明显。这就是非线性模型在这个数据集上的优势。随机森林通过多棵决策树投票,天然可以捕捉到LSTAT那种带拐弯的关系,同时也对特征共线性免疫。树模型做分裂时每次只看一个特征,特征之间再怎么相关也不影响分裂逻辑。
n_estimators=100意味着100棵决策树,max_depth=10限制每棵树的深度,防止单棵树过于复杂导致过拟合。这里random_state=42同样要固定,否则每次跑出来的模型结果都会不一样。
5.4 特征重要性怎么解读
随机森林有一个非常实用的副产品——特征重要性,直接给我们排了个座次:
python复制importance_df = pd.DataFrame({
'特征': X.columns,
'重要性': rf.feature_importances_
}).sort_values('重要性', ascending=False)
print(importance_df)
跑出来的结果里,LSTAT通常排第一,RM排第二,DIS和NOX紧随其后。排序本身就说明了一个道理:模型把主要赌注押在了低收入人口比例和房间数上,这跟线性回归的系数结论是吻合的。也正因如此,我们可以说这两个特征确实是房价的核心驱动因素。
但有一点要记住:特征重要性反映的是模型内部的统计关联,不是因果推断。LSTAT高和房价低是统计相关,不代表降低低收入人口比例就能直接拉高房价。因果推断是另一个完全不同的方法论,需要实验设计或者工具变量等手段,别指望一个随机森林就能给出因果结论。
6. 跑代码时最容易踩的坑
6.1 数据泄漏:标准化那一步很多人就翻车了
先fit全量数据再切分,这是数据泄漏里最典型也最隐蔽的一种。虽然对标准化来说影响不算特别大,但如果你在特征选择、缺失值填充、异常值处理等环节都用了全量数据的信息,那么测试集就不再是“新数据”了,模型的评估结果会虚高。一个常见场景:用全量数据的均值去填充缺失值,再切分,这就是泄漏。正确做法是只在训练集上计算均值,然后拿来填训练集和测试集。
判断自己有没有泄漏,最简单的方法就是问一句:我计算任何统计量的时候,有没有用到测试集的数据?如果用了,就把它改成只用训练集计算,再应用到两边。
6.2 共线性对线性模型的影响
RAD和TAX之间0.91的相关性,放在线性回归里会让你看到非常诡异的系数符号。比如RAD系数为负,TAX系数为正,看起来好像高速公路可达性越差房价越高——这完全站不住脚,但模型就是这么“学”的。原因是这两个特征几乎在输送同一份信息,模型内部的优化算法没法稳定地把贡献分配给它们,稍微一点数据扰动就会让系数大幅波动。
应对手段有这么几种:
- 从业务上删除其中一个特征,比如保留TAX删除RAD,因为税务信息在实际决策中更敏感。
- 用主成分分析(PCA)降维,把相关特征压缩成几个互相独立的主成分。
- 用岭回归或者Lasso这类带正则化的线性模型,通过压缩系数来稳定结果。
- 直接换树模型,不关心共线性,省心省力。
我个人在波士顿房价这个项目上的建议是:如果只是练手,线性回归看看系数,然后赶紧切随机森林,不为共线性过度纠结。真正需要重视共线性的场景是你要做系数解释的业务分析,或者特征个数特别多、需要做特征筛选的项目。
6.3 异常值要不要删
波士顿房价数据集里CRIM(犯罪率)有几个特别极端的值,最大能达到88.97,比75%分位数的3.68高了二十多倍。这种离群值会让标准化后的数据分布严重拖尾,也可能让线性回归的拟合被少数样本带偏。
但我不建议一上来就删。异常值处理的原则是:先看它是不是数据录入错误,如果不是,再判断它是否承载了真实信息。CRIM高达88的街区在那个年代确实存在,它不是错误数据,而是极端真实值。删掉它等于抹掉了一部分真实世界的信息。所以更稳妥的做法是保留它,在模型选择上用对异常值不那么敏感的树模型,或者对CRIM做log变换压缩它的动态范围:
python复制df['CRIM_LOG'] = np.log1p(df['CRIM'])
log1p就是log(1+x),好处是x为0时结果也是0,不会出现负无穷。变换完之后,CRIM的分布会温和很多,线性模型也能更好地处理它。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| load_boston报错 | sklearn新版已移除该数据集 | 自行加载CSV或从CMU原始源读取 |
| 模型预测值不会超过50 | 目标变量存在上限截断 | 调整预期,或单独建模处理上端样本 |
| RMSE远大于MAE | 存在极端预测误差样本 | 检查异常值、检查特征是否需要变换 |
| 线性回归系数符号怪异 | 特征存在多重共线性 | 删除相关特征、改用正则化或树模型 |
| 训练集得分高但测试集低 | 过拟合 | 增加正则化、减少模型复杂度、增加数据 |
| 每次运行结果不一致 | 未固定随机种子 | 给train_test_split和模型都设置random_state |
6.5 跑完别急着收工,试试这几个扩展动作
很多朋友跑完代码输出几个分数就结束了,但这样其实浪费了一个特别好的练手素材。我建议你在基础流程跑通之后,花半小时试试下面几个方向:
第一,把线性回归换成岭回归和Lasso,观察正则化系数从0.01到100变化时,RMSE和R2怎么变。你很快会发现,当alpha增大时,某些特征的系数会被逐渐压到0,这就是Lasso在做特征选择。
第二,尝试给线性回归加多项式特征,比如degree=2,让模型可以拟合LSTAT和MEDV之间的曲线关系。做法是用sklearn的PolynomialFeatures,把它和StandardScaler、LinearRegression串成Pipeline再训练。多项式特征理论上能提升线性模型的表达能力,但也容易过拟合,正好可以体验一下正则化的价值。
第三,用cross_val_score做五折交叉验证,看看模型在不同数据子集上的稳定性。这比单次划分出来的测试集分数更靠谱,也能帮你判断模型到底稳不稳。
这几个扩展动作做完,你对回归的理解就不仅仅停留在“跑通了一个模型”的层面,而是能说清楚每步操作背后的原因和代价。这才是练手题的真正价值。
我个人这些年带项目的一个深刻体会:波士顿房价这个数据集真正宝贵的地方,不是它能让你刷出一个多漂亮的分数,而是它像一个完美的教学切片,把数据质量、特征分布、共线性、非线性、数据泄漏这些在实际工作中要碰到的梗概全给遇了一遍。任何模型在这个数据上跑出来的数字本身没有太大意义,有意义的是你在调试那些数字的过程中建立的直觉。后面不管你换什么数据集、换什么业务场景,这套分析思路和避坑意识都是通用的。先拿这个数据热热身,把流程跑顺,再去碰真实业务,你会感谢当年那个老老实实做完EDA的自己。
