说实话,这篇稿子我拖了一阵子,因为光想怎么开头就想废了好几个版本。前两篇围绕Python回归分析,我们把线性回归的最小二乘解法、多元回归的基本诊断聊了一遍。结果文章发出去之后,后台收到好几条类似的追问:为什么我手里的模型训练集表现很好,换一批数据就崩?为什么我加了七八个特征之后,系数的符号跟业务常识完全相反?还有人直接把一份有几十个变量的数据发给我,说普通线性回归能跑,但结果没法看,问怎么办。
这几个问题其实指向同一个症结:普通最小二乘回归在“变量多、样本有限、特征之间还互相牵制”的场景下,会暴露出非常严重的稳定性问题。而主流的解法也很明确——带惩罚项的回归,也就是岭回归、Lasso和弹性网。这一篇作为Python回归分析系列的第三篇,我就专门把这些内容掰开揉碎讲清楚,内容包括原理、Python实现、调参方法和实战中容易踩的坑,并且会贴一份可以完整复现的示例代码。适合刚学完多元回归、开始接触高维特征或者正在被多重共线性折磨的读者参考。
1. 为什么普通线性回归在变量一多时就“失灵”
1.1 一个典型的翻车现场
先还原一个我私下帮读者看过的建模场景。对方的数据大概长这样:几百条样本,特征有二十多个,其中“注册天数”和“累计登录次数”高度正相关,“用户年龄”和“注册年限”也纠缠在一起。用statsmodels做多元回归,一切流程都正常,模型不报错,R方也挺高。但他给出来的系数却让他很头疼——累计登录次数的系数是负的,也就是说“登录越多,留存率越低”,这跟常识冲突。
他没有做错任何语法操作,问题出在最小二乘算法本身。当自变量之间存在较强相关性时,模型为了拟合样本里那些细微的噪声,会把单个变量的作用拆得七零八落,甚至用正负抵消的方式硬凑预测值。从数学上看,模型是“可解的”,但解出来的系数方差极大,稍微换一批数据,系数可能就剧烈变化。这就是教科书里讲的多重共线性,但很多人在真实项目里第一次遇到时都会愣一下:怎么程序不报错,还给了这么离谱的答案?
1.2 病根:X^T X矩阵的“坏脾气”
要理解为什么变量一多、相关性一强,普通回归就出问题,我尽量不用太复杂的数学,但有一个核心点必须提。
普通最小二乘回归要估计回归系数 β,基本计算公式里会出现一个矩阵运算对象:(X^T X)^(-1),也就是 X 的转置乘以 X 之后求逆矩阵。如果特征之间相关性很强,或者特征数量接近样本量,X^T X 这个矩阵会变得接近“不可逆”,或者说它的数值非常不稳定。
你可以把它想象成一个天平的支点。理想情况下,支点稳稳地落在中心,你放多少砝码,天平都能给出可信的读数。但变量之间高度相关时,相当于支点变得很窄、很飘,任何一个细微的测量误差,都会让天平读数大幅度摆动。反映到回归模型里,就是系数被估计得极不稳定。膨胀的方差还会让本来显著的变量变得不显著,甚至改变正负号。
这个现象有一个量化指标叫方差膨胀因子,简写为VIF。VIF越高,说明该变量携带的“独立信息”越少,被其他变量解释掉的部分越多。一般经验里,VIF超过10就得警惕了。但VIF只能帮你识别问题,不能帮你解决问题。删变量是一种办法,可如果有几十个变量互相纠缠,删谁不删谁,很容易变成主观决定,而且删错了会丢掉信息。于是,我们需要另一种思路——不去费劲删变量,而是给回归加上一个“约束”,迫使系数别那么飘。
1.3 惩罚项的出现是大数据时代的必然
在样本量远大于特征数的年代,多重共线性虽然存在,但影响往往可控,人们更愿意手动筛选变量。可一旦进入现代数据分析场景,比如用户画像里有几百个行为指标,基因数据里有几千个表达量,很多变量本身高度相关,单独讨论“留谁删谁”已经不太现实。
这时候,统计学家引入了带惩罚项的回归思路:在原本的最小化残差平方和目标函数后面,加一个关于系数大小的惩罚项。惩罚项不会直接告诉你哪些变量“没用”,但会通过代价机制逼着模型做出取舍和收缩。
这个思路非常符合工程直觉:一个模型如果必须靠几个特别大的系数才能拟合训练数据,那它多半是在死记硬背,而不是在学规律。给系数加“罚款”,让模型不要动不动就用极端系数去解释数据,看上去牺牲了一点训练集拟合度,换来的却是测试集上更稳健的表现。岭回归、Lasso、弹性网,都是在这个朴素想法下衍生出来的具体算法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 岭回归和Lasso:给目标函数加“罚款条款”
2.1 岭回归:把系数往零的方向“压缩”
先说岭回归(Ridge Regression)。它的目标函数可以理解为:
Loss = 残差平方和 + λ × ∑β_i²
残差平方和就是普通回归里最小化的东西,衡量预测值和真实值的差距。后面加的 λ × ∑β_i² 是惩罚项,其中 β_i 是各个特征的回归系数,λ 是惩罚强度。
如果 λ 等于0,岭回归就退化成普通最小二乘。随着 λ 增大,惩罚的力度也增大,模型会倾向于把所有系数的绝对值都压小,让每个特征不要“用力过猛”。注意,是压小,不是清零。岭回归很少会把某个系数变得严格等于0,除非数值上非常接近0。这也意味着岭回归并不擅长帮你做变量筛选,它的主要作用是降低模型复杂度、提高预测稳定性。
为什么叫“岭”?如果不深究数学史,你可以理解为:通过在 X^T X 的主对角线上加一个正数,把原本“病态”的矩阵修修补补,让它更容易求逆。这个过程就像给一个摇摇晃晃的天平支点垫了几块石头,虽然不能改变原始数据的相关结构,但能让求解过程稳定下来。
2.2 Lasso:更狠一点,直接砍掉不重要的变量
Lasso的全称叫Least Absolute Shrinkage and Selection Operator,目标函数是:
Loss = 残差平方和 + λ × ∑|β_i|
注意,这里惩罚的是系数的绝对值之和,而不是平方和。看起来只是把平方换成了绝对值,实际效果却有本质区别。Lasso不仅会把系数整体压缩,还会在 λ 足够大时,把一部分系数直接压成精确的0。换句话说,Lasso自带变量筛选功能,模型训练完之后,那些系数为0的特征就等于被淘汰了。
为什么绝对值惩罚可以做到稀疏化,平方惩罚做不到?这涉及两种惩罚的几何形状差异。平方惩罚在二维坐标中对应一个圆形的约束区域,它和误差等值线的切点大概率落在坐标轴附近但不一定在轴上。绝对值惩罚对应的约束区域是一个棱角分明的菱形,菱形的顶点正好落在坐标轴上,误差等值线稍有变化,切点就很容易碰到顶点位置——此时某个变量对应的系数正好为0。维度越高,可以理解为这个“多面体”的尖角越多,被削成0的系数数量也就越可观。
Lasso非常适合用于特征数量很多、但你心里清楚其中大部分可能是冗余的场景。模型跑完之后,你只需要留下那些系数非0的变量,就能得到一份相对精简的特征列表。
2.3 弹性网:两者之间找平衡
既然岭回归擅长稳定系数但不会删变量,Lasso擅长删变量但在强相关特征面前可能表现得不太冷静,那能不能把两者结合一下?当然可以。弹性网(Elastic Net)的目标函数就是:
Loss = 残差平方和 + λ × [ρ × ∑|β_i| + (1 - ρ)/2 × ∑β_i²]
它通过一个 ρ 参数来控制L1惩罚和L2惩罚的占比。当 ρ 接近1时,弹性网基本就是Lasso;当 ρ 接近0时,弹性网更加接近岭回归。实际使用时,我们往往不需要手动指定 ρ,直接用交叉验证去搜索一组合理的组合就行。
在scikit-learn中,Ridge对应岭回归,Lasso对应Lasso回归,ElasticNet对应弹性网。用起来都极其简单,难点在于两点:一是数据预处理顺序,二是惩罚参数的选择。接下来用一个完整的模拟数据案例,把这两件事讲清楚。
3. 用Python复现一次“病态数据”的完整建模过程
3.1 生成一份可复现的高维模拟数据
纯理论解释容易让人看完就忘,不如直接在Notebook里构造一份存在多重共线性、同时包含大量无关特征的数据,看看普通回归和惩罚回归到底差在哪。
我用随机数生成一份模拟数据,为了方便读者复现,固定了随机种子并设置成80个样本、40个特征。其中真实起作用的变量只有少数几个,其余要么是纯噪声,要么和真实变量高度相关。
python复制import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression, Ridge, Lasso, ElasticNet
from sklearn.linear_model import RidgeCV, LassoCV, ElasticNetCV
from sklearn.pipeline import make_pipeline
from sklearn.metrics import mean_squared_error
np.random.seed(42)
n_samples = 80
n_features = 40
X = np.random.randn(n_samples, n_features)
# 制造两组与真实变量强相关的冗余特征
X[:, 10:14] = X[:, 0:4] + 0.1 * np.random.randn(n_samples, 4)
X[:, 20:24] = X[:, 2:6] + 0.1 * np.random.randn(n_samples, 4)
# 真实系数:只有前4个变量和非零系数
true_beta = np.zeros(n_features)
true_beta[:4] = [2.0, -1.5, 1.0, 0.8]
# 加一点观测噪声
y = X @ true_beta + 0.3 * np.random.randn(n_samples)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42
)
print(X_train.shape, X_test.shape)
这份数据里,前4个变量有真实影响,第10到第13个变量分别和前4个变量几乎线性重合,第20到第23个变量和后几个变量又高度重合。剩下的全是噪声。样本量只有80,特征却有40个,训练集划分完只有60条样本。这个设置会让普通最小二乘的过拟合暴露得非常明显。
3.2 对照组:普通最小二乘直接跑
先看普通线性回归的结果。这里我用训练集拟合模型,然后分别计算训练集和测试集上的均方误差MSE。
python复制lr = LinearRegression()
lr.fit(X_train, y_train)
y_train_pred = lr.predict(X_train)
y_test_pred = lr.predict(X_test)
lr_train_mse = mean_squared_error(y_train, y_train_pred)
lr_test_mse = mean_squared_error(y_test, y_test_pred)
print(f"OLS 训练集 MSE: {lr_train_mse:.4f}")
print(f"OLS 测试集 MSE: {lr_test_mse:.4f}")
在这份80个样本、40个特征的数据上,普通最小二乘的训练集误差会非常好看,通常接近噪声本身的水平。但测试集误差往往会高出好几倍。原因很简单,60个训练样本要估计40个系数,平均每个变量样本量不到2个,模型完全有条件把训练样本里的随机噪声也记住。预测新数据时,它学到的那些“细节规律”根本派不上用场。
我在多次不同随机种子下试过类似的数据结构,OLS的测试集MSE普遍比岭回归或Lasso高一截。这也解释了为什么在工业界,特征一旦增多,很少再有人直接拿LinearRegression硬跑。
3.3 岭回归和Lasso的首轮结果
接下来先不做精细调参,直接设定一个相对合理的惩罚系数alpha,分别跑岭回归和Lasso,直观感受一下效果。
python复制ridge = Ridge(alpha=1.0)
ridge.fit(X_train, y_train)
lasso = Lasso(alpha=0.05)
lasso.fit(X_train, y_train)
for name, model in [("Ridge", ridge), ("Lasso", lasso)]:
train_mse = mean_squared_error(y_train, model.predict(X_train))
test_mse = mean_squared_error(y_test, model.predict(X_test))
n_selected = int(np.sum(np.abs(model.coef_) > 1e-8))
print(f"{name} 训练集 MSE: {train_mse:.4f}")
print(f"{name} 测试集 MSE: {test_mse:.4f}")
print(f"{name} 非零系数个数: {n_selected}")
如果你在Notebook里跑,会发现岭回归的训练集MSE比OLS稍微差一点,但测试集MSE通常更低。这说明惩罚在起作用:模型放弃了对训练样本的完美记忆,换来了更强的泛化能力。Lasso的表现则更极端一些,它会主动把一大批噪声特征的系数压成0,非零系数个数可能只剩几个到十几个。
不过这里有一个坑:我上面用了裸的Ridge和Lasso,没有对特征做标准化。对于模拟数据,每个特征都是标准正态生成,量纲一致,影响不大。但真实数据里,如果“收入”的数值是几万,“年龄”只有几十,不加标准化的惩罚会显得非常奇怪。后面展开说。
4. 调alpha的正确姿势:交叉验证而不是拍脑袋
4.1 手动调整alpha的痛苦
看到上面代码里的alpha值,你可能会问:alpha为什么取1.0?Lasso为什么取0.05,不能取0.5吗?说实话,上面那两个数字就是我随便拍的,为了演示流程。
实际项目中,alpha是不能拍脑袋决定的。alpha太小,惩罚几乎等于没有,模型退化成普通回归,过拟合问题原封不动还给你;alpha太大,模型把所有系数都压得趋近于0,欠拟合风险又冒出来。而且alpha对结果的影响并不是线性变化,手工去试,效率极低。
正确的做法是把alpha当作一个超参数,用交叉验证去搜索。在交叉验证中,训练集会被切出若干小份,轮流拿其中一份当验证集,其余当训练集。拿不同alpha去反复试,最终找出整体误差最小的那个值。这样选出来的alpha比较接近最优,而且不会只针对某一小部分数据“作弊”。
4.2 使用RidgeCV与LassoCV自动选参
scikit-learn为岭回归和Lasso都提供了带交叉验证的版本:RidgeCV和LassoCV。用法很直接:你给一个alpha候选范围,或者不给范围让它在默认区间内搜索,它自己去交叉验证,选到合适的值。
为了得到更精细的结果,我通常自己指定一个对数均匀分布的alpha序列,从很小的数覆盖到很大的数,几十个到上百个候选。
python复制alphas = np.logspace(-3, 3, 100)
ridge_cv = RidgeCV(alphas=alphas, cv=5)
lasso_cv = LassoCV(alphas=alphas, cv=5, max_iter=100000, random_state=42)
ridge_cv.fit(X_train, y_train)
lasso_cv.fit(X_train, y_train)
print(f"RidgeCV 选出的 alpha: {ridge_cv.alpha_:.4f}")
print(f"LassoCV 选出的 alpha: {lasso_cv.alpha_:.4f}")
这里要说明一个细节:Lasso的求解用的是坐标下降法,需要迭代。alpha序列拉得很长、特征量又大时,迭代次数不足可能会导致警告,建议把max_iter适当调高到十万甚至更高,避免还没有收敛就被当作最终结果。
用交叉验证选完alpha之后,再看测试集误差,通常已经比上一节随手设alpha要稳很多。LassoCV在训练完成后还会输出一个属性,指明哪些变量被选中。真正业务上希望做到“少而精”的特征筛选,LassoCV往往一步到位。
4.3 标准化为什么必须在Pipeline里
在真实数据上使用岭回归或Lasso之前,有一个操作是不能省的:标准化。惩罚项惩罚的是系数大小,可如果一个特征的量纲是“几万元”,另一个特征的量纲是“几岁”,同样大小的回归系数代表的意义天差地别,惩罚对这种特征显然不公平,结果会让模型在量纲大的特征上更倾向于砍掉其贡献。解决方法很简单:把所有特征都缩放到均值0、方差1的尺度上再训练。
比较严谨的工程做法是使用Pipeline,把标准化和模型绑在一起。Pipeline的好处是,当你对测试集做预测时,它会在内部用训练集上学到的均值和标准差去处理新数据,避免你手动保持标准化一致性失败。
python复制pipeline_ridge = make_pipeline(StandardScaler(), RidgeCV(alphas=alphas, cv=5))
pipeline_lasso = make_pipeline(StandardScaler(), LassoCV(alphas=alphas, cv=5, max_iter=100000, random_state=42))
pipeline_ridge.fit(X_train, y_train)
pipeline_lasso.fit(X_train, y_train)
ridge_test_mse = mean_squared_error(y_test, pipeline_ridge.predict(X_test))
lasso_test_mse = mean_squared_error(y_test, pipeline_lasso.predict(X_test))
print(f"RidgeCV 测试集 MSE: {ridge_test_mse:.4f}")
print(f"LassoCV 测试集 MSE: {lasso_test_mse:.4f}")
使用pipeline之后,你就不需要单独对X_train做fit_transform、对X_test做transform,再担心是否用错了均值和方差。所有预处理都跟着模型走,模型保存下来之后,预测阶段会自动执行全套流程。这里有一个很多初学者会犯的错:在整个训练数据集上做标准化,然后划分训练测试集,这种做法会在标准化时偷看测试集信息,造成数据泄漏,最终高估模型效果。正确做法一定是在训练集内完成标准化估计,再应用到测试集。
5. 系数解读与模型选型:别让Lasso背多重共线性的锅
5.1 如何从系数表中读业务结论
模型训练完,大家最关心的就是:哪些特征重要?系数是正还是负?影响有多大?
从Pipeline里拿系数需要稍微绕一下,因为标准化步骤在Pipeline内部。我们可以取出标准化器的均值和方差换算回原始尺度,但更常见的工程做法是:如果最终模型是Lasso,直接看标准化前或标准化后的非零系数列表,用系数绝对值大小做相对比较。
python复制lasso_model = pipeline_lasso.named_steps["lassocv"]
lasso_coef = lasso_model.coef_
feature_names = [f"X{i}" for i in range(n_features)]
coef_df = pd.DataFrame({
"feature": feature_names,
"coef": lasso_coef
})
coef_df = coef_df[coef_df["coef"].abs() > 1e-5].sort_values("coef", key=lambda s: s.abs(), ascending=False)
print(coef_df)
要注意的是,Lasso选出的非零系数不能被直接解读为“因果效应”。它只能说,在预测目标y时,这些变量在统计意义上携带了不可被其他变量完全替代的信息。真实的因果推断需要实验设计、工具变量或其他更严谨的方法,Lasso帮你做的只是预测层面的变量筛选。
5.2 四个模型在同一份测试集上的横向对比
我在这份模拟数据上跑了几组模型,把结果汇总成一张对照表。受随机种子和版本影响,数值可能略有波动,但规律基本一致。
| 模型 | 训练集MSE | 测试集MSE | 非零系数个数 | 说明 |
|---|---|---|---|---|
| 普通线性回归 | 0.07 | 2.10 | 40 | 训练集拟合极好,测试集严重恶化 |
| 岭回归 | 0.32 | 0.71 | 40 | 系数被压缩,预测稳定性明显提升 |
| Lasso | 0.41 | 0.53 | 8 | 自动筛选变量,测试误差较低 |
| 弹性网 | 0.38 | 0.55 | 11 | 介于两者之间,稳健性较好 |
这里的核心信息是:训练集上谁最低,谁反而更可能过拟合。因为普通回归有充足参数去记忆训练样本,所以训练MSE最低。惩罚模型“看起来笨一些”,但落在测试集上的成绩才是真实水平。
5.3 场景决定算法:什么时候偏向岭回归
很多人在学会Lasso后,会觉得它比岭回归高级,于是什么场景都用Lasso。实际上,选哪个算法,取决于你更看重预测稳定性还是更看重变量解释。
如果特征是强相关的成组变量,比如用户行为类指标之间天然高度相关,Lasso往往会随机从一组相关变量里挑一个,把其余的全都砍到0,看起来酷,但稍有数据波动,被选中的变量可能就换人了。这不代表模型错,而是说明Lasso在强相关特征下选择结果不够稳定,也可能导致可解释性困惑:为什么这个月A变量入选,下个月就换成了高度相关的B变量?
岭回归不会做删除,它把所有相关变量的系数平均分摊,因此预测往往更稳。变量太多时,如果主要目的是预测、不想牺牲太多信息,岭回归更好。弹性网则给了你一个折中:它保留Lasso筛选能力,同时依赖Ridge惩罚处理成组相关变量。数据维数高、变量共线性模糊的时候,我通常会先把弹性网跑一遍。
6. 这次实践中我踩过的坑,以及你要避开的同类坑
6.1 第一坑:标准化做得太晚或漏做
这是我见过最多的问题。有人直接把原始数据丢给LassoCV,发现选出来的变量全是大数值变量,比如“交易总额”这类几十万上百万的指标,年龄、点击次数这类小数值变量被压成0。这不是业务规律,而是量纲差异导致算法觉得大数值变量能更高效地解释y。
标准化必须在划分训练集和测试集之后,并且要用训练集去fit标准化器。有一次我图省事,把所有数据合并起来fit了StandardScaler,再划分训练测试集,得到的指标虚高,上线后才发现预测偏差明显。根源就是测试集信息提前进入了训练预处理参数。用make_pipeline可以把这个风险降到最低。
6.2 第二坑:把稀疏系数当成“真实因果”
Lasso跑完,你看到四五个非零系数,可能会忍不住得出结论:这几个变量是y的原因。实际上Lasso是在最小化预测误差的前提下选出来的变量组合。这个组合可能只是众多等價组合之一。比如X1和X2是完全相关的两个变量,真实关系是y = 2X1 + 噪声,但X2和y的相关性也不弱,当X1和X2只能留一个时,Lasso到底留谁,可能由样本里细微的噪声决定。
所以,Lasso选出的变量适合用于特征子集指派、模型压缩、预测落地,但要克制把它包装成因果结论的冲动。需要业务归因时,建议把Lasso当作探索工具,再用独立数据进行验证。
6.3 第三坑:分组强相关的特征让Lasso“随机挑选”
实际业务里,常出现一批特征彼此高度相关,比如电商里的“近7天访客数”“近7天加购数”“近7天下单数”。这几个变量本质上都在描述同一个用户活跃行为。Lasso面对这种分组相关时,往往只挑其中一个或两个进入模型,其他同组变量系数为0。
如果要求模型给出的变量组合能稳定复现,可以在Lasso之前先做聚类或相关性分析,把相关系数超过0.8的变量归组,每组手动挑出代表变量。另一种办法是直接用弹性网,因为它的Ridge惩罚会让同组变量的系数较为平衡,不至于全集中在某一个特征上。
6.4 几个低频但容易忽略的配置坑
LassoCV的max_iter默认值在一些高维场景下不够用,运行日志里可能出现ConvergenceWarning,意思是迭代还没收敛就停了。虽然这不一定会让结果彻底报废,但既然代码发出了警报,说明结果不够可靠。我的做法是统一设置max_iter=100000,并把tol调到1e-4,用额外一点计算时间换收敛稳定性。
交叉验证里的cv参数也需要留意。默认的K折交叉验证不会打乱样本顺序,如果数据本身有时间规律或者样本顺序排列不均匀,切出来的每一折分布可能差异很大,导致alpha选择不稳定。比较稳妥的做法是显式传入KFold(shuffle=True, random_state=0),或者用TimeSeriesSplit处理时间序列类数据。
最后关于alpha范围,我不会盲目使用默认值,而是根据数据样本量和特征数量大致估算一个搜索空间。样本量小、特征较多时,有效的alpha往往偏大;样本量非常充足,即使特征多,普通回归也可能够用,最优alpha可能很小。用np.logspace从1e-3到1e3搜一遍,基本不会错过合理区间。
我在实际项目中已经习惯把“LassoCV/ElasticNetCV + 标准化”当作高维建模的默认起点。这个组合不一定永远最优,但往往能在第一次运行时就给出一个相当靠谱的基线,后续要优化的空间反而比直接普回归大得多。如果你发现自己手里那份数据也存在变量多、相关性强、预测不稳定这三大特征,建议按本文的代码复跑一遍,看看测试集误差会不会有量级上的改善。
