最近在折腾一组高维数据,特征数量超过了样本量的一半,普通线性回归直接跑出个虚高的R方,测试集上一塌糊涂。我重新把LASSO回归和弹性网络回归从头到尾捋了一遍,顺便把Python代码也做了封装。这篇文章就把原理、实操和踩坑经验一次性讲透,适合正在学正则化模型、或者想在项目里做特征筛选但不确定选哪个模型的朋友。
1. 为什么普通线性回归和岭回归都满足不了“稀疏筛选”需求
1.1 最小二乘在高维场景下的过拟合本质
普通线性回归的目标是把残差平方和压到最低。如果特征维度少、样本量大,这个目标没有太大问题。可一旦特征数接近甚至超过样本数,最小二乘的正规方程解就会出现严重的不稳定性。
我打个比方:你现在有100个样本,却要估计120个系数。这相当于用100个方程去解120个未知数,解出来的系数会剧烈波动,甚至出现极大正数和极大负数相互抵消的情况。这时候训练集的R方可能高得吓人,但换一批样本立刻崩盘。
再举一个经典例子:两个强相关的特征X1和X2,真实关系是y=2X1+0X2。在有限样本下,最小二乘可能给出系数组合比如3.5和-1.4,整体预测能力还行,但单独看任何一个系数,都会得出“这个特征很重要”的错误结论。
这都是过拟合的表现。要抑制这种不稳定,就得给损失函数加上正则化项,用小小的偏差换取大大的方差下降。
1.2 岭回归的局限:收缩了,但一个系数都不会变成零
岭回归加的是L2惩罚,也就是系数平方和。它能让系数整体变小、模型更稳定,这是它的好处。但问题在于,L2惩罚的几何形状是球形的约束域,它会收缩每个系数,却不会让任何一个系数恰好归零。
这意味着什么?如果我有300个特征,做完岭回归依然得到300个非零系数。你问它“哪些特征无关”,它一个都说不出来。
这在某些需要解释性很强的场景下就很难受。比如医疗或风控项目,业务方想知道的是“到底哪几个变量在起作用”,而不是“这300个变量多多少少都有点用”。退回一步说,部署模型时,300个特征就意味着要采集、清洗、存储300个特征的生产数据,成本不小。
1.3 真正需要的是“既收缩又清零”的正则化方式
带着这个需求,L1正则化就进入了视野。L1惩罚是系数绝对值之和,它不仅会把大系数缩小,还会把不重要的系数直接压成零。这就实现了自动特征选择:帮你从一堆特征里挑出真正有贡献的那部分,其余全部忽略。
LASSO回归(Least Absolute Shrinkage and Selection Operator,最小绝对收缩与选择算子)就是L1正则化的线性回归。它既解决了过拟合,又解决了特征筛选问题,尤其适合“特征多、样本不多、大部分特征其实是噪声”的数据场景。
当然,L1正则化也不是万能药。如果特征高度相关,它的表现会不太稳定。这时候就需要弹性网络回归,把L1和L2惩罚组合起来用,既保留稀疏性,又借助L2的稳定效应。接下来我逐个拆解。
2. LASSO的目标函数与几何直觉:L1惩罚为什么能让系数变零
2.1 目标函数形式和数据标准化的必要性
LASSO回归的优化目标可以写成:
[ \min_{w} \frac{1}{2n} | y - Xw |_2^2 + \alpha | w |_1 ]
第一部分是均方误差,第二部分是L1正则项。这里的 (\alpha) 是正则化强度,越大,惩罚越重,系数被压得越狠,最终稀疏程度越高。
在动手写代码前,有一个前置步骤绝不能被省略:特征标准化。L1惩罚是对系数绝对值求和,而系数的尺度受特征量纲影响。假设特征A的取值范围是0到1,特征B的取值范围是0到100000,那么同样重要的两个特征,在L1惩罚下受到的“待遇”完全不同。B的系数天然会小很多,为了压缩B,惩罚项可能更容易把它误伤清零。
所以实战中,我会先把所有特征做StandardScaler标准化,让它们都处于同一量纲。这一步不是可选项,是必选项。后面专门的避坑章节还会细说。
2.2 约束域的几何形状:菱形和球体的差别
回顾一下L1和L2的数学形式。对于同一目标函数,二者可以等价写成带约束的优化问题:
- L1约束:(|w|_1 \le t),在二维坐标系里是个旋转45度的菱形。
- L2约束:(|w|_2^2 \le t),在二维坐标系里是个圆形。
无约束的普通最小二乘解可能落在约束域外部,我们需要在约束域内找到使损失函数最小的点。损失函数的等值线是椭圆形的,沿着约束区域边界滑动时,和L1菱形的交点更容易出现在“顶点”上,也就是某个坐标等于0的位置。而圆形边界光滑,切点几乎不会落在坐标轴上。
这就解释了为什么Lasso能产生严格为零的系数,而岭回归只是把所有系数整体压缩。
我在给别人讲的时候喜欢说:L1约束是一个“带棱角的框”,你在这个框里找最优点,往往卡在棱角上;L2约束是“光滑的球”,怎么滚都很难停在坐标轴上。这个直觉非常有用。
2.3 软阈值算子是L1解的核心机制
从算法层面看,坐标下降法求解Lasso时,每一次对单个系数的更新,其实就是一个软阈值操作:
[ w_j = \text{sign}(z_j) \cdot \max(|z_j| - \alpha, 0) ]
其中 (z_j) 是当前残差与第 (j) 个特征的相关性量。稍微极端地说,只要 (|z_j|) 没有超过惩罚强度 (\alpha),这个系数就会被直接置零;超过一部分,就整体向零收缩一段距离。
这个操作非常直觉化:先看这个特征和当前残差的相关性强不强,弱就一刀切掉,强就保留一部分。循环迭代所有坐标,直到收敛,就得到了最终的稀疏解。注意,这个公式的前提是特征正交,实际非正交情况下需要加一个分母项,但理解它的行为逻辑完全够了。
3. Python实现LASSO回归:完整流程与代码拆解
3.1 生成模拟数据,先看稀疏解长什么样
为了避免直接使用内部数据集造成干扰,我通常先用模拟数据验证逻辑。下面的数据生成中,只有3个特征对标签有真实贡献,其余50个都是噪声特征。
import numpy as np from sklearn.linear_model import Lasso, LinearRegression from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.metrics import r2_score, mean_squared_error np.random.seed(42) n_samples = 200 n_features = 60 X = np.random.randn(n_samples, n_features) true_coef = np.zeros(n_features) true_coef[:3] = [3.5, -2.0, 1.2] y = X @ true_coef + np.random.randn(n_samples) * 0.5 X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)数据里第0、1、2号特征是真正有效的,其他57个都是随机噪声。真实系数我设定为 [3.5, -2.0, 1.2],用来验证模型能否把这个稀疏结构找回来。
先看不加正则化的普通线性回归会怎样。60个特征在140个训练样本上,拟合优度看着还行,但求得的部分噪声系数会很大。这里我直接给大家展示结果:
lr = LinearRegression().fit(X_train, y_train) lr_test_r2 = r2_score(y_test, lr.predict(X_test)) print(f"普通线性回归测试集R2: {lr_test_r2:.4f}") print(f"非零系数个数: {np.sum(lr.coef_ != 0)}")输出会显示,测试集R2明显低于训练集,而且非零系数仍然是60个。虽然在这个模拟里普通回归还不至于完全崩掉,但它显然把大量的噪声特征都保留了下来。
3.2 先标准化还是先划分数据集?顺序错了全盘皆错
在训练任何带惩罚的线性模型之前,必须对特征做标准化,但标准化操作有一个很容易踩的细节:先划分训练集和测试集,再在训练集上fit标准化器,最后用同一个标准化器去transform训练集和测试集。
绝对不能先把整个数据集统一fit,再划分训练测试。因为那样做会让标准化器“偷看”到测试集的均值和方差,造成信息泄露,测试集评估自然偏乐观。
正确写法:
scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)这里记得是transform,而不是fit_transform。在实际项目中,我会用sklearn的Pipeline把标准化和模型串起来,避免在多步操作中忘记某个环节,也方便后续做交叉验证时保持流程一致。
3.3 拟合Lasso观察系数稀疏化
下面用sklearn的Lasso直接拟合:
lasso = Lasso(alpha=0.1) lasso.fit(X_train_scaled, y_train) y_pred = lasso.predict(X_test_scaled) n_used = np.sum(lasso.coef_ != 0) print(f"LASSO非零系数个数: {n_used}") print(f"R2: {r2_score(y_test, y_pred):.4f}") print(f"RMSE: {mean_squared_error(y_test, y_pred, squared=False):.4f}") print("前10个系数的值:") print(lasso.coef_[:10])输出大致是:非零系数个数在10个以内,R2在0.9附近,RMSE大约0.5。RMSE与噪声标准差0.5接近,说明模型已经很好地抓到了真实信号。
这里有一个值得说的点:(\alpha=0.1) 不是拍脑袋选的,是我根据特征方差和经验预测出的初值。实际项目中通常会用交叉验证来选择,后面专门章节讲。
3.4 可视化系数路径:不同alpha下模型的动态变化
如果想观察alpha从大到小的过程中哪些特征先被选入、哪些最后仍然被放弃,可以画一条系数路径图。sklearn的lasso_path函数可以直接返回所有alpha下的系数矩阵:
from sklearn.linear_model import lasso_path import matplotlib.pyplot as plt alphas_lasso, coefs_lasso, _ = lasso_path(X_train_scaled, y_train, alphas=np.logspace(-2, 0.5, 50)) plt.figure(figsize=(10, 6)) for i in range(n_features): plt.plot(alphas_lasso, coefs_lasso[i, :], lw=1) plt.xscale('log') plt.xlabel('alpha') plt.ylabel('coefficients') plt.title('LASSO coefficient path') plt.show()这张图能很直观地看到,alpha很大时所有系数都为零;alpha逐渐变小,强特征先被选出来;再继续降低alpha,一些弱噪声特征也开始挤进来。观察哪些特征在较长的一段alpha区间内都保持非零,通常说明它们是真正重要的信号。
4. 弹性网络回归:L1的稀疏性和L2的稳定性如何兼得
4.1 LASSO在高度相关特征面前的三个麻烦
LASSO的稀疏性很好用,但它有两个客观存在的问题。
第一,当两个特征高度相关时,Lasso往往会随机选择其中一个,把另一个系数压成零,而不是两个都保留。这其实很不稳定:换一批样本,它可能选出另一个,系数的符号也可能会变,模型的可解释性大打折扣。
第二,特征数多于样本数时,Lasso最多只能选出 n个非零系数(n是样本数),这个约束在某些场景下不够用。
第三,L1单纯做稀疏筛选,但缺乏对相似特征“分组”的天然机制。比如一组特征都是同一个来源的派生变量,我们更希望它们联动进出模型,而不是被随机挑一个代表。
弹性网络(Elastic Net)正是为解决这些问题设计的。它把L1和L2惩罚加在一起,用L1做到变量筛选,用L2稳定系数、鼓励相关性强的特征一起被选或一起被淘汰。
4.2 弹性网络的目标函数和l1_ratio参数含义
弹性网络的目标函数是:
[ \min_{w} \frac{1}{2n} | y - Xw |_2^2 + \alpha \cdot l1_ratio \cdot | w |_1 + \frac{\alpha}{2} \cdot (1 - l1_ratio) \cdot | w |_2^2 ]
其中 (\alpha) 是整体正则化强度,(l1_ratio) 控制L1和L2在惩罚里的占比。
参数边界很容易记:
- (l1_ratio=1):纯LASSO。
- (l1_ratio=0):纯岭回归。
- (l1_ratio=0.5):L1和L2等权重。
实战经验是,如果数据特征之间相关性较强,或者特征数远大于样本数,(l1_ratio) 一般设置在0.2到0.6之间比较有效。
4.3 ElasticNet代码实操与组效应观察
用模拟数据展示弹性网络和Lasso在系数上的差异:
from sklearn.linear_model import ElasticNet enet = ElasticNet(alpha=0.1, l1_ratio=0.5, random_state=42) enet.fit(X_train_scaled, y_train) print(f"弹性网络非零系数个数: {np.sum(enet.coef_ != 0)}") print(f"R2: {r2_score(y_test, enet.predict(X_test_scaled)):.4f}")如果在此基础上构造两个完全相同的重复特征,Lasso会在两个特征之间随机选一个给系数,另一个为0;弹性网络则倾向于把样本的贡献“分摊”到两个相似特征上,两者系数大体接近。这就是组效应的体现:相似的变量被当作一个整体对待,而不是厚此薄彼。
这是我觉得弹性网络在高维场景下比Lasso更稳定的根本原因——它保留了L2的约束,抑制了系数在高度相关特征之间的“囚徒困境”式震荡。
5. 交叉验证自动选参:LassoCV和ElasticNetCV才是实战主力
5.1 为什么宁可多花算力也绝不手选alpha
alpha太大,特征被全删,模型欠拟合;alpha太小,稀疏筛选失效,模型接近普通线性回归。手选alpha只能依赖经验猜测,而且同一个alpha在不同数据尺度下含义完全不同,非常容易翻车。
正确做法是使用交叉验证:在训练集内部做K折,每一折都计算模型在验证子集上的误差,选平均误差最小的那组参数。sklearn的LassoCV和ElasticNetCV把这件事封装得很干净。
5.2 LassoCV内部路径机制与参数解读
LassoCV不会盲目搜索,它会在内部构造一条从充分大到充分小的alpha路径,针对每个alpha做交叉验证,然后选出最优alpha。我们可以指定候选alpha的数量和路径密度。
from sklearn.linear_model import LassoCV lasso_cv = LassoCV(eps=1e-3, n_alphas=100, cv=5, random_state=42) lasso_cv.fit(X_train_scaled, y_train) print(f"最优alpha: {lasso_cv.alpha_:.4f}") print(f"非零系数个数: {np.sum(lasso_cv.coef_ != 0)}") print(f"测试集R2: {r2_score(y_test, lasso_cv.predict(X_test_scaled)):.4f}")输出里会给一个最优alpha的数值。这里的eps控制alpha路径的最小值与最大值之比,通常保持默认或设到1e-3。cv=5表示5折交叉验证,n_alphas越大,搜索越细,代价是训练时间变长。
5.3 ElasticNetCV联合搜索alpha和l1_ratio
弹性网络比Lasso多一个超参数,自然要多承担一份搜索成本。ElasticNetCV允许传入一组l1_ratio候选值,每个候选值内部再搜索alpha:
from sklearn.linear_model import ElasticNetCV enet_cv = ElasticNetCV( l1_ratio=[0.1, 0.3, 0.5, 0.7, 0.9], eps=1e-3, n_alphas=100, cv=5, random_state=42 ) enet_cv.fit(X_train_scaled, y_train) print(f"最优l1_ratio: {enet_cv.l1_ratio_:.2f}") print(f"最优alpha: {enet_cv.alpha_:.4f}") print(f"非零系数个数: {np.sum(enet_cv.coef_ != 0)}") print(f"测试集R2: {r2_score(y_test, enet_cv.predict(X_test_scaled)):.4f}")一个小的经验是,如果你的数据特征数特别多,ElasticNetCV的计算量会比较可观。可以考虑把n_alphas调小到50,或者把l1_ratio的候选集缩窄到[0.2, 0.5, 0.8],先探出大致方向,再细化。
5.4 用交叉验证参数重新评估时的一个纪律
交叉验证选出参数之后,我把最终模型在测试集上评估。但这里有一个很容易犯的“测试集污染”错误:反复看测试集结果,发现效果不好,就回去改参数再跑一遍,直到测试集分数漂亮为止。
这本质上等于把测试集当训练集用,最终得到的分数会虚高,泛化能力被高估。正确的节奏是:测试集只在最终阶段评估一次,中途的参数调整都要依赖交叉验证的验证集分数来完成。
6. 我实际项目中踩过的坑与经验总结
6.1 忘做标准化,模型差点把“量纲错误”当成稀疏信号
有一次我在处理一组包含“访问次数”(几百)和“耗时比例”(0到1之间)的数据,直接对原始特征做Lasso。结果Lasso给出的非零系数几乎全部集中在量纲大的那几个特征上,量纲小的特征系数全被压成零。
我一开始还以为是这些特征真的不相关,后来才发现是标准化的坑:L1惩罚作用于系数绝对值,量纲大的特征系数天然小,因此吃到的惩罚更少,更容易被保留;量纲小的特征系数巨大,反而更容易被惩罚清零。这就是量纲差异造成的虚假稀疏。
那之后我给所有Lasso和ElasticNet流程都强制套上Pipeline:
from sklearn.pipeline import Pipeline pipeline = Pipeline([ ('scaler', StandardScaler()), ('model', LassoCV(cv=5, random_state=42)) ]) pipeline.fit(X_train, y_train)Pipeline的好处是,交叉验证时标准化流程自动包含在CV循环里,避免在每一折中重复手工操作,也杜绝了数据泄露。实际上标准化应该用每一折的训练子集来fit,再transform对应的验证子集,Pipeline能自动保证这件事。
6.2 高度相关特征让Lasso的系数符号来回跳
以前跑一个项目,有两列特征是从同一份底层数据里按不同窗口算出来的,相关性达到0.98。Lasso做出来的结果里,这一对特征的系数一正一负,而且换一次交叉验证随机种子,符号就反转一次。模型预测能力没变,但业务逻辑完全讲不通。
后来换成弹性网络,这组特征系数变得均匀了一些,符号也稳定在同一个方向。原因还是那个几何直觉:弹性网络的L2惩罚把高度相关的特征“捆”在一起,避免它们内部互相打架。
6.3 收敛警告的处理:不要忽略max_iter和tol
样本量一大,特征维度一高,sklearn的Lasso和ElasticNet有时候会弹出ConvergenceWarning。很多人直接忽略,但这样得到的系数还没完全收敛,结果解释起来没有意义。
处理方式很简单:调大max_iter,必要时放宽tol。
Lasso(alpha=0.1, max_iter=100000, tol=1e-4)如果调大max_iter后依然不收敛,通常说明alpha太小,正则化惩罚不足以平抑特征共线性带来的震荡,可以适当增大alpha,或者换用弹性网络。收敛警告不应该被当作无所谓的日志,它本身就是模型状态的一种信号。
6.4 评价指标不能只看R2
Lasso和ElasticNet做出来之后,很多人只看R2判断好坏,这在高维稀疏场景下不够充分。R2在特征筛选之后必然下降一些,因为模型放弃了大量弱信号特征,可预测稳定性提升了。我更习惯把RMSE、MAE和R2三个指标都列出来,再结合业务对误差的容忍度决定阈值。
另外一个好习惯是,观察模型选中的系数在业务层面是否说得通。如果某个特征的符号与业务常识相悖,先不要急着接受模型结果,排查一下是不是数据泄露、标准化顺序或者相关特征干扰导致的。
6.5 一个小技巧:用LassoCV的alpha结果指导ElasticNetCV搜索范围
如果不想让ElasticNetCV在稀薄的网格里大海捞针,可以先跑一次LassoCV拿到近似最优alpha,然后以这个alpha为中心,指数级别地小范围扩展后传给ElasticNetCV:
lasso_cv = LassoCV(cv=5, random_state=42).fit(X_train_scaled, y_train) alpha_center = lasso_cv.alpha_ enet_cv = ElasticNetCV( l1_ratio=[0.2, 0.4, 0.6, 0.8], alphas=np.linspace(alpha_center * 0.5, alpha_center * 2, 50), cv=5, random_state=42 ).fit(X_train_scaled, y_train)这样既缩小了搜索空间,又能借助Lasso得到的稀疏性强信号,让弹性网络在一个合理范围内做精细调整。实际用下来,不仅速度更快,选出的参数也更稳健。
最后说一点个人经验:如果任务要求明确说“我要做特征筛选”,且特征之间相对独立,直接用Lasso;如果特征之间关联关系复杂、或者样本不够充裕,弹性网络是更好的默认选择。两者在sklearn里都是成熟的实现,真正区分模型效果的往往不是算法本身,而是数据预处理、参数搜索方式和评估纪律。