1. 为什么我坚持用波士顿房价数据集练手线性回归
很多人入门机器学习时,第一个接触的算法就是线性回归,第一个跑的数据集往往就是波士顿房价。这个组合几乎成了行业里的"Hello World"。但有意思的是,我见过太多人跑完一遍sklearn的LinearRegression,看到R²大概0.7左右,就觉得自己已经掌握了线性回归。等到真正拿到一份业务数据,面对几十个字段、缺失值、异常值、多重共线性的时候,整个人是懵的。
问题出在哪?出在代码编写顺序上。大部分教程给的是"能跑通"的代码,而不是"能理解"的代码。它们把数据加载、特征处理、建模、评估揉成一坨,你照着敲一遍,跑出来结果,但脑子里没有形成一条清晰的流水线。等到要换数据集、换任务,你就不知道哪一步该改、哪一步该保留。
这篇笔记我想做的事情很具体:把波士顿房价线性回归这个项目,按照一个从业者真正写代码的顺序,从头到尾拆一遍。不是按教科书的章节顺序,而是按你打开编辑器之后,手指头实际应该先敲什么、再敲什么、为什么这么敲的顺序。我会讲清楚每一步背后的动机,哪些地方容易翻车,哪些参数值得调,哪些坑我亲自踩过。
适合谁看?如果你已经知道线性回归的公式长什么样,但自己独立写一个完整项目时总觉得心里没底,那这篇就是写给你的。如果你完全零基础,也能看,因为我会把每个概念用生活化的方式解释一遍,只是你需要多一点耐心跟着敲。
先明确一下这个项目的定位:它是一个监督学习中的回归任务,目标是用房屋的若干特征(犯罪率、房间数、税率等)去预测房价中位数。数据集本身不大,506条样本,13个特征,非常适合用来训练"写代码的肌肉记忆"。但正因为小,很多人会忽略它的陷阱——比如特征量纲差异巨大、目标变量存在截断、部分特征和房价的关系并非线性。这些细节,恰恰是训练代码顺序感的最好素材。
2. 拿到数据后的第一件事不是建模,而是建立"数据直觉"
2.1 加载数据后先做三件看似无用的事
我见过太多人load_boston()之后直接train_test_split,然后fit,然后看分数。这个流程没有错,但它跳过了最关键的一步:建立对数据的直觉。什么叫数据直觉?就是你在建模之前,已经大概知道每个特征长什么样、分布是否正常、和目标变量大概是什么关系。
我的习惯是,加载完数据后,先做三件事,每件不超过两分钟,但收益极大。
第一件,把数据转成DataFrame,打印head()和describe()。head()让你看到真实的值长什么样,describe()让你看到每个特征的均值、标准差、最小值、最大值和分位数。这一步能立刻暴露量纲问题。比如波士顿数据集中,"城镇犯罪率"这个特征的取值范围大概在0到89之间,而"住宅平均房间数"大概在3到9之间。量纲差了一个数量级,这对后面是否要做标准化、是否要用正则化,有直接影响。
第二件,检查缺失值。用df.isnull().sum()一行代码就能看完。波士顿数据集本身是干净的,没有缺失值,但你必须养成这个习惯。真实业务数据里,缺失值处理往往占掉整个项目一半的时间。如果这里跳过,后面模型报错你都不知道从哪查。
第三件,看目标变量的分布。用df['MEDV'].hist()画个直方图,或者直接看describe()里目标变量的分位数。波士顿房价的目标变量在50处有一个明显的截断——因为原始数据里超过5万美元的房价被统一记成了50。这个细节非常重要,它意味着你的模型在预测高价房时会系统性偏低,而且R²会被这个截断拉低。知道这一点,你就不会对着0.7的R²焦虑,因为你知道天花板在哪。
提示:这三件事我建议写成一个固定的函数,每次拿到新数据先调用一遍。代码不用复杂,十几行就够,但能帮你省下后面大量的返工时间。
2.2 用散点图矩阵快速筛出"可疑特征"
建立完基础直觉后,下一步是看特征和目标变量的关系。最直接的办法是画散点图。13个特征全画一遍太多,我通常先画和目标变量相关性最高的几个。
怎么找相关性最高的?用df.corr()算相关系数矩阵,然后看MEDV那一列,按绝对值排序。波士顿数据集里,房间数(RM)和房价正相关最强,大概0.7;低收入人群比例(LSTAT)和房价负相关最强,大概-0.74。这两个特征几乎撑起了线性回归的大半壁江山。
但相关系数只能看线性关系,有些特征和房价是非线性的。比如"到就业中心的距离"这个特征,它和房价的关系不是一条直线,而是先降后平。如果你只用相关系数筛选,可能会漏掉这类特征。所以我的做法是:先用相关系数排序,取前6到8个特征画散点图,肉眼确认关系形态。如果看到明显的曲线,就在特征工程阶段考虑加多项式项或者分箱。
这一步的产出是一张"特征关系图",它决定了你后面特征工程的策略。比如看到房间数和房价近似线性,那就不需要动它;看到距离和房价是曲线,那就考虑加个平方项。这种基于观察的决策,比盲目套用"标准化+多项式"的模板要靠谱得多。
2.3 划分训练集和测试集时最容易犯的错
数据直觉建立完,接下来是划分数据集。这一步看起来简单,但有两个坑我反复见到。
第一个坑是在划分之前做了全局标准化。很多人习惯先把整个数据集标准化,再train_test_split。这是典型的数据泄露。因为标准化用到了均值和标准差,而这两个统计量是从包含测试集的数据里算出来的。正确做法是先划分,再在训练集上fit标准化器,然后transform测试集。用sklearn的Pipeline可以优雅地解决这个问题,后面我会讲。
第二个坑是随机种子不固定。波士顿数据集只有506条,随机划分的波动很大。同样的模型,换个随机种子,R²可能差0.05以上。所以我在划分时一定写random_state=42,保证结果可复现。这不是迷信,而是工程习惯——你后面调参、对比模型,都需要一个稳定的基准。
划分比例上,506条数据我通常用8:2,训练集404条,测试集102条。也有人用7:3,但测试集太小的话评估结果波动会更大。8:2是个比较稳的选择。
3. 特征工程不是"越多越好",而是"每一步都要有理由"
3.1 标准化:什么时候必须做,什么时候可以跳过
标准化是线性回归项目里最常见的预处理步骤,但它不是无脑必做的。要不要做,取决于你后面用什么方法。
如果你用的是普通最小二乘(OLS)的LinearRegression,标准化其实不影响预测结果。因为OLS的系数会自动调整尺度,标准化前后模型的预测值完全一样,只是系数的大小和解释变了。但如果你要用正则化(Ridge、Lasso)或者梯度下降优化,那标准化就是必须的。因为正则项对系数的惩罚和系数的尺度有关,不标准化的话,量纲大的特征会被过度惩罚。
波士顿数据集里,各特征量纲差异明显,所以我一般直接上标准化,用StandardScaler。这样后面无论换什么模型,都不用再改预处理。标准化的公式很简单:对每个特征,减去均值再除以标准差。用生活化的话说,就是把所有特征拉到同一个"起跑线"上,让模型不会因为某个特征数值大就高看它一眼。
代码上,我强烈建议用Pipeline把标准化和模型串起来:
from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LinearRegression pipe = Pipeline([ ('scaler', StandardScaler()), ('model', LinearRegression()) ])这样在交叉验证时,每一折都会在训练部分fit标准化器,避免数据泄露。这个细节很多人忽略,但在小数据集上影响不小。
3.2 处理非线性关系:多项式特征该加几阶
波士顿数据集里,有几个特征和房价的关系明显不是直线。最典型的是"低收入人群比例"(LSTAT),它和房价的关系是一条向下弯曲的曲线。如果你只用原始特征做线性回归,模型会欠拟合这部分关系。
解决办法是加多项式特征。但加几阶?加多了会过拟合,加少了没效果。我的经验是:先加二阶,看交叉验证分数有没有提升;如果有,再试三阶;如果三阶没提升甚至下降,就停在二阶。
用PolynomialFeatures可以自动生成多项式项。但要注意,它会把所有特征的组合都生成出来,13个特征加二阶,会变成104个特征(13+13*12/2+13)。特征一多,过拟合风险就上来了。所以我通常只对那几个非线性明显的特征加多项式,而不是全量加。
具体操作上,可以用ColumnTransformer指定只对某几列做多项式变换:
from sklearn.compose import ColumnTransformer from sklearn.preprocessing import PolynomialFeatures preprocessor = ColumnTransformer([ ('poly', PolynomialFeatures(degree=2, include_bias=False), ['LSTAT', 'DIS']), ('scale', StandardScaler(), ['RM', 'CRIM', 'TAX', 'PTRATIO']) ])这样既捕捉了非线性,又控制了特征数量。实测下来,这种"选择性多项式"比全量多项式在测试集上稳定得多。
3.3 特征选择:用Lasso做一次"自动瘦身"
特征工程做到这一步,你手上可能已经有几十个特征了。但并不是所有特征都有用,有些是噪声,有些是冗余。这时候可以用Lasso回归做一次特征选择。
Lasso的原理是在损失函数里加一个L1正则项,它会把不重要的特征系数直接压到0。换句话说,Lasso会自动帮你"踢掉"没用的特征。用LassoCV可以自动选正则化强度,代码大概长这样:
from sklearn.linear_model import LassoCV lasso = LassoCV(cv=5, random_state=42, max_iter=10000) lasso.fit(X_train_scaled, y_train) selected = X_train.columns[lasso.coef_ != 0] print(f"Lasso保留了{len(selected)}个特征:{list(selected)}")跑完之后,你可以看看哪些特征被保留了,哪些被踢了。如果被踢掉的特征里有你觉得重要的,那说明它可能和别的特征高度相关,信息被替代了。这个过程能帮你理解数据的内在结构,比单纯看相关系数矩阵直观得多。
注意:Lasso的特征选择要在训练集上做,不能碰测试集。而且做完选择后,最好用选出的特征重新训练一个普通线性回归,对比一下效果。有时候Lasso踢掉特征后效果反而更好,有时候会损失信息,需要实测。
4. 模型训练与评估:分数之外,你还要看什么
4.1 交叉验证:单次划分的分数不可信
模型训练完,第一件事是看分数。但如果你只看一次train_test_split的测试集分数,那这个分数很可能骗你。506条数据,换一次随机种子,R²可能从0.72跳到0.68。所以我的习惯是先用交叉验证看模型的稳定性和平均表现。
cross_val_score一行代码就能做5折交叉验证:
from sklearn.model_selection import cross_val_score scores = cross_val_score(pipe, X_train, y_train, cv=5, scoring='r2') print(f"5折R²:{scores.mean():.4f} ± {scores.std():.4f}")这里我关注两个数:均值和标准差。均值代表模型的平均表现,标准差代表稳定性。如果标准差很大,说明模型对数据划分敏感,可能需要更多数据或者更简单的模型。波士顿数据集上,一个合理的线性回归模型,5折R²均值大概在0.70到0.75之间,标准差在0.03到0.06之间。
除了R²,我还会看均方根误差(RMSE)。R²是相对指标,RMSE是绝对指标,单位是房价的千美元。比如RMSE是4.5,意味着模型预测房价平均偏差4500美元。这个数字对业务方更有说服力。
4.2 残差分析:模型哪里预测得不好
分数看完,下一步是看残差。残差就是真实值减去预测值。如果模型是好的,残差应该随机分布在0附近,不应该有规律。如果残差图呈现喇叭形或者曲线形,说明模型有问题。
画残差图很简单:
import matplotlib.pyplot as plt y_pred = pipe.predict(X_test) residuals = y_test - y_pred plt.scatter(y_pred, residuals, alpha=0.6) plt.axhline(y=0, color='r', linestyle='--') plt.xlabel('预测房价') plt.ylabel('残差') plt.show()波士顿数据集上,你大概率会看到残差在预测值较高时偏向负——因为目标变量在50处截断,模型预测高价房时系统性偏低。这个发现不是模型的问题,而是数据本身的问题。知道这一点,你就不会盲目去调模型,而是考虑是否要剔除高价房样本,或者对目标变量做变换。
残差分析还能帮你发现异常值。如果某个样本的残差特别大,那它可能是异常点,值得单独看看它的特征。有时候一个异常点就能把模型带偏,剔除后效果明显提升。
4.3 系数解读:线性回归最大的优势别浪费
线性回归相比神经网络、集成模型,最大的优势是可解释性。训练完之后,你可以直接看每个特征的系数,知道它对房价的影响方向和大小。
coefs = pd.Series(pipe.named_steps['model'].coef_, index=feature_names) print(coefs.sort_values(ascending=False))在标准化之后,系数的大小可以直接比较。系数为正,说明该特征越大房价越高;系数为负,说明该特征越大房价越低。系数绝对值越大,影响越强。
波士顿数据集上,你会看到房间数(RM)的系数是正的且很大,低收入人群比例(LSTAT)的系数是负的且很大。这符合常识。但有些特征的系数可能反直觉,比如"到就业中心的距离"系数可能是正的——按理说离就业中心越远房价越低,但数据里可能因为某些区域本身环境好,距离远反而房价高。这种反直觉的系数,恰恰是值得深挖的地方,可能暗示着特征之间的交互作用。
提示:解读系数时一定要记住,这是在"其他特征不变"的前提下。如果特征之间有相关性,系数的解释会变得复杂。所以看系数之前,先检查一下VIF(方差膨胀因子),排除多重共线性的干扰。
5. 调参与模型对比:把"差不多"变成"有依据"
5.1 正则化:Ridge和Lasso怎么选
普通线性回归在特征多、样本少的时候容易过拟合。波士顿数据集只有506条,如果你加了多项式特征,特征数可能上百,这时候正则化就很有必要。
Ridge加的是L2正则,它会把系数整体缩小但不会压到0;Lasso加的是L1正则,它会把不重要的系数压到0,相当于自动特征选择。怎么选?我的经验是:如果你觉得大部分特征都有用,只是需要控制一下幅度,用Ridge;如果你觉得有很多噪声特征需要剔除,用Lasso。
实际项目中,我通常两个都跑一遍,用交叉验证比分数。代码上,RidgeCV和LassoCV都能自动选正则化强度:
from sklearn.linear_model import RidgeCV ridge = RidgeCV(alphas=[0.01, 0.1, 1, 10, 100], cv=5) ridge.fit(X_train_scaled, y_train) print(f"最优alpha:{ridge.alpha_}")波士顿数据集上,Ridge和Lasso的效果通常比普通线性回归好一点点,但不会天差地别。真正的提升往往来自特征工程,而不是调正则化参数。这一点要有心理预期,不要指望调参能带来质变。
5.2 用网格搜索把调参过程标准化
如果你不想手动试参数,可以用GridSearchCV把调参过程自动化。比如同时调多项式阶数和正则化强度:
from sklearn.model_selection import GridSearchCV param_grid = { 'preprocessor__poly__degree': [1, 2], 'model__alpha': [0.01, 0.1, 1, 10] } grid = GridSearchCV(pipe, param_grid, cv=5, scoring='r2') grid.fit(X_train, y_train) print(f"最佳参数:{grid.best_params_}") print(f"最佳分数:{grid.best_score_:.4f}")网格搜索的好处是系统化,不会漏掉组合。坏处是计算量大,如果参数多、数据大,跑起来很慢。波士顿数据集小,跑起来很快,适合用来练手。真实项目里,我通常先用随机搜索(RandomizedSearchCV)缩小范围,再用网格搜索精细调。
5.3 模型对比表:让选择有据可依
调完参,最后一步是把所有试过的模型放在一张表里对比。这张表至少包含:模型名称、5折交叉验证R²均值、标准差、测试集R²、测试集RMSE。有了这张表,你选哪个模型就不是拍脑袋,而是有数据支撑。
| 模型 | 5折CV R²均值 | CV标准差 | 测试集R² | 测试集RMSE |
|---|---|---|---|---|
| 普通线性回归 | 0.72 | 0.04 | 0.71 | 4.8 |
| Ridge | 0.73 | 0.04 | 0.72 | 4.7 |
| Lasso | 0.73 | 0.03 | 0.73 | 4.6 |
| 二阶多项式+Ridge | 0.78 | 0.05 | 0.76 | 4.2 |
这张表是我虚构的示例,但结构是真实的。你会发现,多项式特征带来的提升往往比换正则化方法更明显。这也印证了前面说的:特征工程比调参更重要。
6. 那些教程不会告诉你的实操细节
6.1 目标变量截断的处理方式
前面提过,波士顿房价的目标变量在50处截断。这个截断会拉低R²,也会让残差分析出现规律。处理方式有几种:一是剔除房价等于50的样本,二是对目标变量做变换,三是保留但接受这个限制。
我通常先看看有多少样本被截断。波士顿数据集里大概有16个样本房价等于50,占比3%左右。剔除它们对模型影响不大,但能让残差更干净。如果你要做房价预测的业务系统,那就要注意:模型对高价房的预测会偏低,需要在业务层面做修正。
6.2 随机种子的选择与结果复现
random_state这个参数看起来不起眼,但在小数据集上影响很大。我见过同一个模型,换个随机种子,测试集R²从0.68变到0.75。所以我的习惯是:在项目开始时固定一个种子,所有涉及随机的地方都用它。这样你后面做的任何改动,效果变化都能归因到改动本身,而不是随机波动。
如果你要写报告或者分享结果,一定要注明随机种子。别人复现你的结果时,才能得到一样的数字。
6.3 特征名称的保留与系数对应
用Pipeline和ColumnTransformer之后,特征名称容易丢失。比如你用了多项式变换,原来的13个特征变成了几十个,你根本不知道哪个系数对应哪个特征。这时候可以用get_feature_names_out()把名称取出来:
feature_names = pipe.named_steps['preprocessor'].get_feature_names_out() coefs = pd.Series(pipe.named_steps['model'].coef_, index=feature_names)这样你就能看到每个多项式项对应的系数,解读起来方便很多。这个细节很小,但能省下你大量猜名字的时间。
6.4 保存模型与加载模型
项目做完,模型要保存下来,方便后面用。joblib是最常用的工具:
import joblib joblib.dump(pipe, 'boston_linear_model.pkl')加载的时候直接joblib.load就行。注意保存的是整个Pipeline,不是单独的模型。这样加载后直接可以predict,不用再手动做标准化和多项式变换。这个习惯在部署时特别重要,能避免预处理不一致导致的预测错误。
7. 从这份笔记里能带走的东西
写到这里,我把波士顿房价线性回归的代码编写顺序完整走了一遍。从加载数据、建立直觉、特征工程、模型训练、评估、调参到保存模型,每一步我都尽量说清楚"为什么这么做"和"容易在哪翻车"。
如果只让我提炼三条最重要的经验,我会说:第一,拿到数据先别急着建模,花十分钟看分布、看缺失、看目标变量,这十分钟能省你后面几小时;第二,特征工程比调参重要,多项式特征和特征选择带来的提升,往往比换正则化方法大得多;第三,永远用交叉验证看模型稳定性,单次划分的分数不可信。
这份笔记里的代码不是让你照抄的,而是让你理解顺序背后的逻辑。你换成别的数据集,比如某电商的销量预测、某工厂的设备寿命预测,这套顺序依然适用:先理解数据,再处理特征,然后建模评估,最后调参对比。顺序对了,代码就不会乱;代码不乱,结果就可信。
最后分享一个小技巧:每次做完项目,把关键步骤和踩过的坑写成笔记,哪怕只有几行。下次遇到类似任务,翻出来看一眼,能少走很多弯路。我自己就是这么攒下这些经验的。