我读研究生那会儿第一次接触 LASSO 回归,是在处理一个高维生物信息数据集的作业里。当时特征数量接近三千个,样本量只有一百出头,用普通线性回归一跑,训练集 R² 接近 1,测试集直接负数。导师看了一眼结果就说了句:你想办法把那些没用的特征砍掉,但不能手动一个一个删。那是我第一次意识到,模型不仅要会拟合,还得会做减法。后来我系统过了一遍 LASSO 回归的原理和代码实现,才彻底理解它为什么能自动把无关特征的系数压成零,也才明白它和岭回归的本质差别到底在哪。
这篇笔记是《Python 应用机器学习:代码实战指南》系列的第 21 篇,不绕弯子,直接讲清楚 LASSO 回归从数学原理到 Python 实战的完整链路。内容包括:它到底解决了什么问题、为什么 L1 惩罚项会产生稀疏解、坐标下降的核心思想是怎么落到代码里的、alpha 这个超参数该怎么调、以及在真实项目里你会踩到哪些文档里根本没写明白的坑。无论你是正在复习机器学习期末考的学生,还是工作上第一次碰到高维数据的工程师,这篇都能给你省下不少查资料的时间。
1. 从线性回归的两个老大难问题说起
1.1 高维数据下为什么普通线性回归会失效
先回到全宇宙最简单的线性回归。假设我们有 n 个样本,每个样本有 p 个特征,模型长这样:
$$y = X\beta + \epsilon$$
普通最小二乘(OLS)做的事情就是最小化残差平方和:
$$\hat{\beta}{OLS} = \arg\min{\beta} \sum_{i=1}^n (y_i - x_i^T\beta)^2$$
它的闭式解高中数学竞赛都推过:
$$\hat{\beta} = (X^T X)^{-1}X^T y$$
问题就藏在这个公式里。想让 $(X^T X)$ 可逆,数据必须满足几个条件:特征之间不能高度相关(没有多重共线性)、样本量大于特征数($n > p$)、特征没有完全线性相关。
但现实项目里没这么理想。比如你做用户行为分析,后台埋点几百个事件,每个事件还要拆成近期频率、历史次数、最近一周是否有行为这样的衍生特征,轻轻松松破千维。再比如文本分类任务,用 TF-IDF 向量化之后,特征维度动辄几万。这时候 $p \gg n$,$X^T X$ 不满秩,是奇异矩阵,逆矩阵不存在,OLS 解直接算不出来。就算勉强用伪逆求出一个解,那个解的范数也大得离谱,对数据里的微小扰动极其敏感——你今天训练完的模型,明天换一批数据就崩了。
这个问题的本质是:信息量不够,却要估计太多参数。就好比你只有一个模糊的目击者描述,却要警察画出十个嫌疑人的全脸画像,这根本是欠定问题。数学上有一万种 $\beta$ 能让训练集误差为零,但没一种是真正学到了数据背后的规律。
1.2 理解偏差-方差困境和过拟合的关系
要透彻理解 LASSO 为什么有必要存在,得先把“偏差-方差分解”这个概念翻出来说清楚。
一个模型的期望预测误差可以拆成三部分:噪声(irreducible error)、偏差的平方(bias²)、方差(variance)。噪声是数据本身自带的、任何模型都消除不掉的随机波动。偏差是模型预测的期望与真实值的系统性偏离,说白了就是模型太简单、没学够。方差是模型对不同训练集的敏感程度,换一批数据结果就大变天。
普通线性回归在高维场景下的问题是:参数自由度太高,模型有充分的能力把训练数据里的每一个点都摸得透透的。这会导致训练误差逼近零,但方差被拉满到极致。模型过度适应了训练集里的噪声和异常值,把这些跟真实规律无关的细节也当成规律学进去了,这就是过拟合。
一句话总结:过拟合的本质不是模型“太努力”,而是模型自由度过高、在信息不足的数据上过度反应。正则化的思想根源,就是主动给模型的自由度戴上枷锁,牺牲一点点偏差,换取方差的大幅下降。LASSO 就是这个思路最经典的代表之一。
1.3 LASSO、岭回归和弹性网到底差在哪
LASSO 的完整名字是 Least Absolute Shrinkage and Selection Operator,直译是“最小绝对收缩和选择算子”。它的目标函数是:
$$\hat{\beta}{lasso} = \arg\min{\beta} \left{ \sum_{i=1}^n (y_i - x_i^T\beta)^2 + \lambda \sum_{j=1}^p |\beta_j| \right}$$
后面多出来的是 L1 惩罚项,也就是 $\beta$ 各个分量绝对值之和乘以正则化系数 $\lambda$。Ridge 回归用的是 L2 惩罚项:$\lambda \sum \beta_j^2$。
很多教材把两者的区别描述成“L1 会让系数变成零,L2 只会让系数变小”。这个说法是对的,但要讲得再深一点:因为它俩对应的惩罚项在参数空间的几何形状不同,导致最优解落在什么地方的行为不一样。
L2 惩罚项在二维平面上是一个以原点为中心的圆形。带约束的优化问题可以拉格朗日化,等价于在“$\beta_1^2 + \beta_2^2 \le t$”的圆形区域内找使残差平方和最小的点。椭圆等值线和圆的切点通常不会恰好落在坐标轴上,因为圆的边界处导数不为零,不太容易恰好让某个坐标卡死为零。所以 Ridge 的结果是:所有系数都被压缩到一个比较小的数值,但很少有系数会严格等于 0。
L1 惩罚项在二维平面上的约束区域是一个顶点在坐标轴上的菱形。菱形有尖角,这些角就落在轴上——也就是 $\beta_1 = 0$ 或 $\beta_2 = 0$ 的位置。椭圆等值线和菱形相交时,最优解落在顶点上的概率非常大。一旦落在顶点上,另一个维度的系数就已经被压成零了。高维情况下,这个特性放大得非常明显:L1 惩罚会把一大批无关特征的系数精确地变成 0,只留下一小部分“真正有用”的特征。
Elastic Net(弹性网)则是把 L1 和 L2 按比例混合:
$$\hat{\beta}{enet} = \arg\min{\beta} \left{ \sum_{i=1}^n (y_i - x_i^T\beta)^2 + \lambda_1 \sum_{j=1}^p |\beta_j| + \lambda_2 \sum_{j=1}^p \beta_j^2 \right}$$
为什么要混着来?因为 LASSO 有两个不完美的地方:一是当特征之间存在强相关时,它倾向于只随机挑其中一个,而不是把一组相关特征都保留下来;二是当 $p > n$ 的时候,它最多只能选出 $n$ 个非零特征。弹性网加了个 L2 项进去,能让高度相关的特征组以“组”的形式被选中或剔除,实际效果在很多场景下比纯 LASSO 更稳。如果你的特征里有明显的分组结构(比如几个 one-hot 编码出来的变量其实来自同一个类别变量),弹性网是比 LASSO 更好的起点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把数学原理说人话:L1 惩罚、软阈值与坐标下降
2.1 KKT 条件与稀疏性的来源
只停留在几何直觉还不够,面试或者期末考试很可能会问到这一步:为什么 L1 规范会产生精确的零解?
回到带约束形式的拉格朗日对偶视角,LASSO 等价于在球形约束域内做最小化,它的 KKT(Karush-Kuhn-Tucker)条件可以写成:最优解 $\hat{\beta}$ 满足,对于每一个分量 $j$,存在次梯度条件(因为 $|\beta_j|$ 在零处不可导,要引入次梯度的概念):
$$\frac{\partial RSS}{\partial \beta_j} + \lambda \cdot s_j = 0$$
其中 $s_j$ 是 $|\beta_j|$ 在 $\hat{\beta}_j$ 处的次梯度。当 $\hat{\beta}_j > 0$ 时,$s_j = 1$;当 $\hat{\beta}_j < 0$ 时,$s_j = -1$;当 $\hat{\beta}_j = 0$ 时,$s_j$ 可以取 $[-1, 1]$ 之间的任意值。
正是“梯度等于 0 处 $s_j$ 可以取一个区间”这个性质,为主最优解的 $\hat{\beta}_j$ 精确等于 0 留出了空间。当数据对第 $j$ 个特征的支撑不够强(也就是 $X_j$ 与当前残差的相关系数绝对值不超过 $\lambda$)时,KKT 条件可以直接在 $\hat{\beta}_j = 0$ 处满足,不需要给这个特征指派任何非零系数。坐标下降的算法逻辑,本质上就是在逐维度轮流检查这个条件。
2.2 软阈值操作是什么
当你把 LASSO 的目标函数按照单个坐标 $\beta_j$ 拆开看时,固定其他所有 $\beta_k$($k \neq j$),那么只剩下一元二次函数加一个绝对值函数:
$$\frac{1}{2}a \beta_j^2 - c \beta_j + \lambda |\beta_j| + \text{(一些常数项)}$$
其中 $a$ 是 $X_j$ 的平方和(如果做了标准化,通常是 $a = n$),$c$ 是 $X_j$ 与当前残差(去掉其他变量贡献后的部分)的相关系数。求解这个一元问题有个漂亮清爽的闭式解,叫软阈值算子:
$$\beta_j^* = S_{\lambda / a} \left( \frac{c}{a} \right) = \begin{cases} \dfrac{c}{a} - \dfrac{\lambda}{a}, & \text{if } \dfrac{c}{a} > \dfrac{\lambda}{a} \[6pt] 0, & \text{if } \left|\dfrac{c}{a}\right| \le \dfrac{\lambda}{a} \[6pt] \dfrac{c}{a} + \dfrac{\lambda}{a}, & \text{if } \dfrac{c}{a} < -\dfrac{\lambda}{a} \end{cases}$$
它的作用可以理解为:先把 $c/a$ 往零的方向收缩 $\lambda/a$ 那么多,如果收缩后跨过了零点,就直接把它砍成 0。这个“软”是相对于把绝对值部分当成硬截断(小于阈值的直接归零、大于阈值的不动)来说的——软阈值除了做截断,还会对保留下来的值进行整体收缩。
2.3 坐标下降如何一步步逼近全局最优
坐标下降算法的思路很朴素:循环地把所有特征轮流更新一遍,每次只优化一个参数,固定其他所有参数不变。因为 LASSO 的目标函数整体是凸函数,坐标下降不需要担心像神经网络那样陷到局部最优出不来——它在凸问题上是能保证收敛到全局最优的。
标准做法是先对数据做中心化和标准化(每个特征均值归零、平方和归一),然后循环执行:
- 初始化所有 $\beta_j = 0$,计算初始残差 $r = y$,设初始 $\lambda$ 为一个较大的值。
- 对每个特征 $j$:计算 $c_j = X_j^T r$(这个量就是当前残差与特征 $j$ 的相关度),然后用软阈值公式更新 $\beta_j$,同步更新残差 $r = r - (\beta_j^{new} - \beta_j^{old}) X_j$。
- 所有特征轮流更新一轮后,检查目标函数的变化量是否小于容忍阈值;如果不是,就继续下一轮;直到收敛。
实际实现中还有点加速技巧,比如提前用特征之间的内积矩阵 $\langle X_j, X_k \rangle$ 来缓存循环里的重复计算,这样可以避免每轮都扫描一遍原始数据矩阵。Scikit-learn 内部用的是 glmnet 作者 Friedman 等发表的“强规则”(strong rule)筛选策略配合坐标下降,在大规模数据上能跳过绝大部分不需要更新的特征,只在一个“候选集”上跑迭代,这就把计算开销压缩了一个量级。
我在自己实现过一个简化版做教学之后,最大的体会是:坐标下降才是 LASSO 工程化能落地的根基。如果不走坐标下降,直接用通用凸优化求解器,一千个特征就慢得没法看;上了坐标下降+特征筛选,上万维特征也就几十毫秒一轮。
3. 为什么强调特征标准化是 LASSO 代码实战里的第一步
这个坑我替很多同学踩过:拿原始数据直接丢进 Lasso,出来结果乱七八糟,偏以为是自己调参的问题,实际上数据没标准化。LASSO 的惩罚项 $\lambda \sum |\beta_j|$ 是对所有系数一视同仁加惩罚的,这意味着如果特征 $j$ 本身的数值尺度特别大,那它的系数会天然被压得特别小;反之数值尺度小的特征,系数会被允许长得比较大。这个效应跟特征重要度没有任何关系,纯粹是单位不同造成的。
举个例子:一个特征存的是“用户年龄”,数值在 0 到 100 之间;另一个特征存的是“年收入”,数值可能是 50000 到 2000000。如果对原始值直接跑 LASSO,达到同等目标函数惩罚效果时,年龄特征的系数只需要年收入特征系数的五万分之一。但是实际上年收入几万块的波动对目标变量的解释作用,可能跟年龄涨一岁差不多量级。这种情况不归一,$\lambda$ 的选值就完全失真了。
所以进入 LASSO 之前,必须对每个特征做标准化处理:减均值、除以标准差,让每个特征都变成单位方差、零均值。这听上去是很基础的预处理步骤,但它对 LASSO 的正确性有直接影响。正则化前的第一步永远是消除特征的量纲差异。
实操层面对连续特征直接用 StandardScaler 或者 sklearn.preprocessing.scale 即可。对类别特征,如果想用 LASSO 做特征选择,应当先做 one-hot 编码,编码后每个哑变量也建议做标准化吗?我的操作习惯是:one-hot 编码出的 0/1 变量不参与连续特征的标准化,因为 0/1 变量本身尺度一致,做了反而会让每个类别的概率被扭曲。但如果你后续用的是基因数据那种全是浮点数连续表达的矩阵,那直接对整个矩阵标准化就没问题。若同时混合连续特征和离散哑变量,可以用 ColumnTransformer 分别处理。
4. 手把手写一段 LASSO 实战代码:从生成数据到模型对比
4.1 构造一份带噪声的高维数据
理论讲再多,不如跑代码直观。我们用 numpy 生成一份仿真数据:两百个样本,一千个特征,其中只有前 5 个特征真正对目标变量有影响,剩余 995 个全是噪声。这样的设置在真实场景里对应着“稀疏模型假设”——真正重要的特征占比很低,正好是 LASSO 的表演舞台。
python复制import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression, Ridge, Lasso
from sklearn.metrics import mean_squared_error
np.random.seed(42)
n_samples = 200
n_features = 1000
n_informative = 5
X = np.random.randn(n_samples, n_features)
true_beta = np.zeros(n_features)
true_coefs = np.array([3.0, -2.0, 1.5, 0.5, -1.0])
true_beta[:n_informative] = true_coefs
y = X @ true_beta + np.random.normal(0, 0.5, size=n_samples)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42)
这里 X 是标准正态分布采样出来的,所以理论上可以不用再标准化;但为了走完整流程,我们后面依然在 Pipeline 里套一层标准化。y 是前五个特征的真实线性组合,叠加标准差 0.5 的噪声。信噪比足够,LASSO 应当能从中恢复出稀疏结构。
4.2 普通线性回归、岭回归、LASSO 三种踢馆对比
现在分别用线性回归、岭回归、LASSO 在训练集上训练,然后看它们在测试集上的均方根误差和各自的非零系数数量:
python复制models = {
"Linear Regression": LinearRegression(),
"Ridge": Ridge(alpha=1.0),
"Lasso": Lasso(alpha=0.1)
}
for name, model in models.items():
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
mse = mean_squared_error(y_test, y_pred)
n_nonzero = np.sum(np.abs(model.coef_) > 1e-5)
print(f"{name:20s} | MSE: {mse:.4f} | 非零系数数量: {n_nonzero}")
输出结果大概是这样:
text复制Linear Regression | MSE: 681.2432 | 非零系数数量: 1000
Ridge | MSE: 0.4321 | 非零系数数量: 1000
Lasso | MSE: 0.2817 | 非零系数数量: 5
普通线性回归在样本数小于特征数时,通过最小范数解勉强拟合了训练数据,但测试误差爆炸到几百,这是典型的方差失控。岭回归把一千个系数都压得很小,虽然没有砍掉任何一个,但相比 OLS 好在预测稳定,MSE 一下子降到了 0.43,这说明收缩对控制方差确实有效。LASSO 直接把非零系数数量收敛到了 5,MSE 也最低——0.28。可见当数据本身满足稀疏性假设时,L1 正则化的双重效果(特征选择+方差控制)能同时发挥作用。
也可以打印一下 LASSO 学到的前几个系数,会和真实的 [3.0, -2.0, 1.5, 0.5, -1.0] 比较接近,但不是完全一致。因为 L1 惩罚在把系数往零收缩的过程中,对保留下来的真实信号系数也有压缩作用——这是 LASSO 的“收缩偏差”。如果用在解释性需求高的场景里,可以用一个降级技巧:先用 LASSO 筛出特征子集,再用筛完的特征跑一遍不带正则项的普通线性回归,用无偏估计重新校准系数的大小。这种做法在不少 Kaggle 方案里出现过,叫“去偏 LASSO”的一种手工实现。
4.3 可视化系数变化路径:理解正则化强度的渐进过程
固定一个具体 alpha,你看到的是某个正则化强度下的终态。但要深入理解正则化的过程,更有价值的做法是画出正则化路径(regularization path)——横轴是 $\lambda$(从大到小),纵轴是各个特征的系数估计值,把 Lasso 从“强惩罚”到“弱惩罚”的整个系数变化过程展示出来。
Scikit-learn 里可以用 lasso_path 一次性算出一条路径。下面是代码:
python复制from sklearn.linear_model import lasso_path
alphas_lasso, coefs_lasso, _ = lasso_path(X_train, y_train, alphas=None, cv=5)
fig, ax = plt.subplots(figsize=(12, 6))
for i in range(coefs_lasso.shape[0]):
ax.plot(alphas_lasso, coefs_lasso[i], linewidth=0.8)
ax.set_xscale('log')
ax.set_xlabel('alpha (log scale)')
ax.set_ylabel('系数值')
ax.set_title('LASSO 正则化路径')
plt.gca().invert_xaxis()
plt.show()
正则化路径能直观展示两个重要现象:第一,当 alpha 在最大值附近时,所有系数都被压到 0,非零系数数量为零;随着 alpha 逐渐减小,系数按照各自与响应的相关性从大到小依次“入场”。第二,每条系数曲线在入场后并不是单调直线上涨的,它们在弱正则化的末端会有波动,这在特征相关性较高时尤其明显。你还能在图上看到:LASSO 选出的那 5 个真实特征的系数曲线会最早从零分叉出来,并且在路径中始终比其他 995 个噪声特征更突出。
这就在实操层面给了一个选 alpha 的直觉:如果你画出的路径图中非零系数数量对 alpha 的变化极其敏感,稍微一动 alpha,选出的特征数量就从 3 跳到 300,那就说明信号和噪声在数据里没有清晰隔开,或者特征间的相关性太强,此时应当试试 Elastic Net。
5. 把 alpha 调好这件事,比想象中更能决定模型命运
5.1 为什么不能凭手感乱试 alpha:过拟合与欠拟合的博弈
LASSO 只有一个真正的核心超参数:$\lambda$,在 sklearn 的接口里叫 alpha。它控制惩罚的强度,本质上是在偏差和方差之间拨一个滑杆。
当 alpha 太大时,惩罚项盖过了拟合项,模型会把所有系数都往零方向压。极端情况下 alpha 大到一定程度,所有系数都会被压成 0,模型输出的预测永远是训练集的均值——严重欠拟合,偏差爆炸。当 alpha 太小,比如设成 0.0001,惩罚项几乎不起作用,模型又退化成接近普通线性回归,高维数据下方差失控。所以要找的是一个刚刚好的中间点:既能足够强烈地把噪声特征压为零,又不至于把真正的信号特征也连累压缩掉。
有个直觉概念要理清楚:alpha 不是越高越好,也不是越低越好,它代表着你对“模型稀疏性”和“模型拟合程度”的权衡态度。如果业务要求一个强可解释模型——比如合规分析里需要向前任领导讲清楚为什么这几个用户被标记——那宁可多牺牲一点预测精度,也要选择更大的 alpha,换来更少的特征。如果任务纯粹比预测精度、没有解释压力,那应该以交叉验证误差为准,选让测试误差最小的 alpha。
5.2 交叉验证选择最优 alpha 的正确姿势
LassoCV 是最省心的选择:它内部自带交叉验证和多轮坐标下降,能帮你扫出一组候选 alpha 值,再对每个 alpha 做 K 折交叉验证,选出平均误差最小的那个。代码示例如下:
python复制from sklearn.linear_model import LassoCV
lasso_cv = LassoCV(
eps=1e-3,
n_alphas=200,
cv=5,
max_iter=10000,
random_state=42,
)
lasso_cv.fit(X_train, y_train)
print(f"最优 alpha: {lasso_cv.alpha_:.4f}")
print(f"非零系数数量: {np.sum(lasso_cv.coef_ != 0)}")
y_pred = lasso_cv.predict(X_test)
mse = mean_squared_error(y_test, y_pred)
print(f"测试集 MSE: {mse:.4f}")
关键参数说明:
eps:代表候选 alpha 序列中最小值与最大值的比例。默认是 0.001,意味着最小 alpha 是最大 alpha 的千分之一。如果你的特征数量特别大,可以考虑调小到 1e-4,让序列覆盖更宽的搜索范围。n_alphas:候选 alpha 的数量。200 是默认值,一般够用;追求更精细可以用 300,但计算时间也会线性上涨。cv:交叉验证折数。默认 5 折。样本量小的时候用 3 折更稳,样本量大时用 10 折也不错。
LassoCV 跑完之后,lasso_cv.alpha_ 就是选出的最优正则化强度,lasso_cv.coef_ 是对应系数。要注意的是,LassoCV 会自动把 X 中心化,但不会自动标准化——所以你在前面套一个 StandardScaler 依然很重要。
5.3 自定义网格搜索 alpha 时容易忽略的随机性问题
有时候你想详细观察每个 alpha 的交叉验证表现,可以自己用 GridSearchCV 配 Lasso,但这时候有个容易忽略的细节:Lasso 求解本身是确定的,但你的 GridSearchCV 如果不固定 cv 的切分种子,每次跑出的最优 alpha 都可能不同。同一个结果复现不出来,在分析工作中非常头疼。
策略很简单:给 KFold 固定 random_state,比如 cv=KFold(n_splits=5, shuffle=True, random_state=42)。然后把 Lasso 里的 random_state 也固定,双保险。这样无论你跑多少次,结果都能原样复现。
我还建议额外做一步:打印出最优 alpha 附近的交叉验证误差曲线。如果最优 alpha 邻域内误差变化很平缓,说明模型的预测性能对 alpha 不敏感——这种模型稳健,可以放心部署。如果曲线呈一个尖锐的 V 型,误差对 alpha 极度敏感,最优 alpha 前后差一个数量级就误差翻倍,这时候要警惕:这说明你的数据规模和特征结构对正则化强度的匹配很脆弱,可能需要在 LassoCV 里提高 n_alphas 或者改用弹性网来增加稳定性。
6. 模型评估与系数解释:不能只看测试集误差
6.1 利用均方误差与 R² 衡量高维模型好坏
高维场景下的模型评估,不能只看单个指标,尤其不能只盯着训练集 R²。我用三次元经验换来的教训是:训练集 R² 在一千维数据上几乎是必然等于 0.99 以上,哪怕系数全是胡猜的,因为它有办法记住每一个样本。想要诚实评估,至少要看三个数:
- 测试集 MSE(或 RMSE):真实预测误差的直接度量,量纲和数据标签一致。
- 测试集 R²:相对“用均值预测”这个最笨基线提升了多少。
- 非零系数数量:LASSO 模型实际用掉的自由度,它告诉你模型有多复杂。
R² 的计算可以用 sklearn 内置的 r2_score。实践里有个经验法则:如果测试集 R² 跑出来是负数,说明模型的表现比“直接取均值”还差——那绝对不是 alpha 没调好的问题,而是数据本身没规律,或者特征和标签的关联方式根本不是线性模型能捕捉的。
6.2 系数的符号和幅度如何解读:零系数、大系数、小系数意味着什么
LASSO 选出来的特征子集,系数符号和幅度并不是随便看的。符号说明该特征对目标变量的影响方向——在控制了其他入选特征之后,它是在正向推高标签还是反向压低标签。幅度则反映影响的相对强度。
但注意:LASSO 的系数幅度只能在入选特征之间做相对比较,不能直接当成“每增加一个单位,标签增加多少”的因果解释。原因有两点。第一,L1 收缩使系数都往零偏,真实幅度被低估了;第二,如果特征之间存在相关性,系数的具体分配会有一定随机性——两个高度相关的特征,谁进模型谁是零,可能只取决数据中极微小的噪声扰动。样本一换,选中的可能就换了一个。
如果你要给业务方出结论,我的建议是:不要单看某一次 LASSO 选出来的特征名单,要多跑几轮不同随机种子的交叉验证,统计一下每个特征被选中的频率。如果一个特征在一百次实验中八次被选中,它大概率是个边角料;如果一个特征在一百次实验里九十多次都被选中,那它才是稳定可靠的重要变量。这类做法在统计学习里叫“稳定性选择”,实现起来不算复杂——外层循环套一个 LassoCV 就行。
6.3 使用系数路径图判断特征重要性与稳定性
前面画过的 lasso_path 图,在高维业务场景里也是判断特征稳定性的利器。真正的核心特征,它的系数曲线会随着 alpha 减小较早地从 0 分裂出来,并且在很大的 alpha 范围内稳步偏离 0。而噪声特征往往要等 alpha 缩得很小才“勉为其难”地冒出来,而且系数值一直很小、没什么增长后劲。
我通常的筛选标准是:看着路径图找一个 alpha 的“膝点”(elbow),也就是非零系数数量开始急剧上升的那个拐弯处,把模型选在膝点之前。因为在这个区间里,加入模型的都是信号强烈、路径稳定的主要特征;过了膝点之后,进来的基本都是噪声特征。这是偏保守的建模策略,但后面模型被拿去上线接受真实数据毒打时,稳定性会好很多。
7. LASSO 实战升级:处理分类问题、高维稀疏矩阵与超大规模数据
7.1 二分类任务中的 L1 逻辑回归
LASSO 本身是回归方法,但它的稀疏化思想被继承到了广义线性模型里。处理二分类任务时,只需把线性回归换成逻辑回归,惩罚项照样加上 L1,就成了稀疏逻辑回归。Sklearn 里就是简单一行 LogisticRegression(penalty='l1', solver='liblinear')。
python复制from sklearn.linear_model import LogisticRegression
from sklearn.datasets import make_classification
X_cls, y_cls = make_classification(
n_samples=300, n_features=300,
n_informative=10, n_redundant=0,
random_state=42
)
clf = LogisticRegression(
penalty='l1',
solver='liblinear',
C=0.1,
random_state=42
)
clf.fit(X_cls, y_cls)
n_selected = np.sum(clf.coef_ != 0)
print(f"模型选中的特征数: {n_selected}")
注意这里的命名:sklearn 的 C 是正则化强度的倒数,C 越小代表正则化越强、越稀疏。很多人最开始从 alpha 切过来会搞混方向。在 LogisticRegression 中你还需要注意 solver 的选择:liblinear 和 saga 支持 L1 惩罚项,而默认的 lbfgs 不支持。如果你跑代码时遇到 “Solver lbfgs supports only 'l2' penalties” 这类报错,多半就是忘了换求解器。saga 在数据量较大的时候收敛更稳,但需要多设一点 max_iter,否则容易在没收敛的情况下直接停止。
7.2 针对文本 TF-IDF 特征的稀疏矩阵注意事项
文本分类任务做特征选择时,经常面临的矩阵形态是:几万篇文档,几十万词汇特征,但每篇文档里的有效词可能只有几百个——这矩阵稀疏度高达 99%。LASSO 的坐标下降算法能高效处理这种矩阵,因为它只需要在当前非零的位置上计算内积和更新残差。
实际操作时你要留个心眼:Lasso 和 LassoCV 默认要求输入是稠密矩阵。如果你的 X 是 scipy.sparse 格式,需要在 Lasso 初始化时传 precompute='auto' 或者用 SparseLasso 相关的封装。不过 sklearn 新版本里,Lasso 其实可以直接吃稀疏 CSR 矩阵,但 precompute 参数最好设成 True 或 'auto',让算法提前预计算特征的 Gram 矩阵缓存。如果默认 precompute=False,那每次计算都会重复横跨整个稀疏矩阵,性能会掉一大截。
对于 TF-IDF 这种特征矩阵,我通常会先把 max_features 限制到一个合理范围(比如 5 万到 10 万),再跑 LassoCV。在不做任何特征筛选直接把五十万维稀疏矩阵丢进去的话,坐标下降的轮次数会非常多,即使每次迭代开销低,整体耗时也会让人怀疑人生。更实际的流程是:先用一个非常小的 alpha 跑一次快速筛选,砍掉大部分对任何交叉验证折都拿不到非零系数的特征,然后在小得多的特征集上做精细的 LassoCV。
8. 高维小样本中的坑,以及我总结的几条避坑原则
8.1 多重共线性导致 LASSO 选取结果不稳定怎么办
真实业务特征里几乎不可能完全独立。举一个金融风控的典型例子:用户收入、用户月消费、用户可用额度这三者高度相关——收入高的人消费通常也高,额度也大。LASSO 面对这一组相关特征时,可能这次选收入、下次选消费,换一个随机种子结果完全不一样。这不代表模型坏了,而是因为相关特征可以互相替代,目标函数在这些方向上是“平原”,惩罚项稍一变动就会导致选择结果跳到另一个等效点。
应对方案有三个层次:
第一个是接受这种不确定性,并采用前面提到的稳定性选择,用多个随机种子跑很多次,每次记录哪些特征被选中,最后看所有特征的选择频率分布。第二个是改用 Elastic Net,因为 L2 项会拉近相关特征的系数幅度,让它们倾向于一起被选中或者一起被剔除。第三个是先用层次聚类或者相关性分析对特征做分组,每组挑一个代表特征进 LASSO——这一步虽然听起来原始,但能大幅缓解共线性引发的选择不确定性问题。
8.2 系数随样本量波动很大?从三个方向上排查
我遇到过不少同学跑来问:我的 LASSO 模型每次重新采样一部分数据训练,选出来的特征名单都不一样,怎么办?这个问题要分情况讨论。
首先确认你的样本量是不是太小。LASSO 在 $n$ 很小、$p$ 很大的时候,特征选择的稳定性天然就差。就像是让模型从信息不足的数据里猜哪些特征重要——噪声多了自然猜不准。解决思路是降低 n_alphas 或直接依靠 LassoCV 的交叉验证结果选择合适的惩罚强度,让选特征数不要过多。
其次检查特征之间的相关性。上面已经说过共线性问题会让选择在等价特征间摇摆。处理方式就是前面提到过的按相关性分组、用弹性网等方式来稳定结果。
最后检查标签本身是否真的与特征存在线性关系。如果数据里实际上没有一个稀疏线性规律可以挖掘,那任何基于 L1 惩罚的方法都会在选择上飘忽不定。可以先画一个特征与标签的散点图矩阵,或者用随机森林跑一遍看特征重要度排序,确认数据结构方向没有问题。
8.3 系数置信区间和 p 值为什么不容易获取
LASSO 的精妙之处也是它的麻烦之处:L1 惩罚的不可导性使得系数估计的分布理论很复杂,没法直接套用 OLS 的 t 检验给出 p 值。它做的事情本质上是对所有系数做了一个自适应筛选,保留下来的系数因为经过了选择和收缩,它们在零假设下的分布已经被扭曲了。因此,直接对 LASSO 系数做传统显著性推断在理论上站不住脚。
不过如果你真的需要给出一个“系数是否显著”的结论,可以考虑两个方向:一是 bootstrap 法,对原始数据有放回抽样几百次,每次跑一遍 LassoCV,记录每个特征被选中并估计出的系数分布,然后根据系数分布是否显著偏离零来判断稳定性;二是用“去偏 LASSO”(debias Lasso)这类论文里提出的方法,把 L1 收缩造成的偏差修掉后构造近似正态的统计量做推断。前一种工程实现简单,后一种更加统计严谨但门槛稍高。对大多数产品项目,我推荐 bootstrap,因为它的结论容易跟业务方解释清楚。
9. 我的 LASSO 项目复盘与一些额外的经验
9.1 实际项目中我为什么要选 LASSO 而不是只有一次机会
我回顾这些年在实际业务里用 LASSO 的场景,发现它最大的价值往往不是“我要拿它做最终预测模型”,而是“我要在几百几千个乱糟糟的特征里,快速圈定一个值得继续深挖的小范围”。它充当的角色更像舞台上的第一轮海选评委,先快速刷掉大多数显然没用的选手,让后面更精细的模型把精力花在真正需要的地方。
在早期用户流失预警项目里,我手里有运营埋点的两百多个行为特征,外加各种用户画像、渠道来源、客服记录里清洗出来的衍生字段,总共一千多个变量。我第一轮就用了 LassoCV,让它从一千多个特征里选出 18 个。之后把模型的输出和业务规则交叉比对,发现选出来的特征集中在“近30天登录频次”“资金变动幅度”“最近投诉次数”这几个维度上,逻辑上完全说得通。再往后我用这 18 个特征去跑 XGBoost 和逻辑回归组合模型做最终预测,快速敏捷而且效果稳定。如果没有 LASSO 先做一轮筛选,让 XGBoost 直接面对一千多维特征,模型也能跑,但解释性、稳定性都会差不少。
9.2 我觉得值得反复咀嚼的三个体会
第一,LASSO 不是“万能自动特征选择机”。它假设真正的规律是稀疏的——重要特征只占总特征的一小部分。如果业务数据里几乎所有特征都有一点点真实信号,比如好几百个特征都跟标签存在微弱但真实的相关关系,强行用 LASSO 选出来的子集在预测上未必比岭回归更好。它的收益主要在于模型的可解释性和部署成本,而不是绝对预测精度。
第二,用 alpha 惩罚系数的时候,惩罚的不仅是数值大小,也是我们对“这个特征确定是重要”的信心。当你没把握的时候,不妨把 alpha 稍微调大一点,让模型更保守。
第三,数据标准化、随机种子固定、特征名称留档这些“小事”,对可复现分析工作来说比换一个高级模型更关键。我见过太多人在折腾 alpha 调参之前,样本切分都没固定随机种子,导致每次跑出的特征名单不一样,最后一晚上都在怀疑人生。
如果你想把这篇笔记里的代码跑通,我建议最少在 Python 3.9 以上环境装好 numpy、scikit-learn、matplotlib。如果只是做演示,sklearn 自带的 diabetes 数据集和 make_regression 就完全够用。之后你可以拿自己的领域数据试一下,建议从五十维到两百维左右的数据开始,先感受一下 LASSO 的收敛速度和系数衰减路径,再换高维数据体验一下它和普通线性回归在测试集上的巨大差别——这种体验层面的记忆,比看十篇教材都深刻。
