跳到正文
格致开物MATHWIKI

Lasso回归

内容维护:格致开物 · 编写与核验 · 修订记录 · 纠错

Lasso回归是在线性回归平方损失中加入系数绝对值和惩罚的方法。它会缩小系数,并可使一些系数精确等于零。零值来自目标在零点的尖角和最优性条件,而不只是计算后把较小数字四舍五入删除。

四个合成样本的两个特征列分别为 x1=(1,1,−1,−1)𝖳、x2=(1,−1,1,−1)𝖳,响应为 y=(3,1,−1,−3)𝖳=2x1+x2。两个特征均值为零、均方为一且互相正交;它们让读者能独立复算完整最优解,再看一般坐标更新如何实现。

目标与尺度约定

对 X∈ℝn×p、y∈ℝn,本文最小化 Q(β0,β)=12n∑i=1n(yi−β0−xi𝖳β)2+λ∑j=1p|βj|,λ≥0. 截距不受惩罚。若各特征列已中心化,则截距最优为 y¯;将y也减去均值后可只计算β。通常还将每列的均方调整为一,避免一个特征仅因单位较大而以更小系数承担相同预测,从而受到不同惩罚。本文λ的数值对应前面的 1/(2n) 损失系数,不能与省去n或二的写法直接比较。[1]

在截距已单独确定的中心化问题中,目标是凸函数:平方项关于β的Hessian为 X𝖳X/n、半正定,绝对值和也凸。λ为正时惩罚随系数大小趋向无穷,保证最小值存在;若中心化后的X满列秩,平方项严格凸,β最优解唯一。λ为零回到最小二乘,解在列相关时可能不唯一。

一维最小化与软阈值

固定其他系数,定义去掉第j个特征贡献后的部分残差 r(j)=y−∑k≠jxkβk。与 b=βj 有关的目标为 qj(b)=aj2b2−zjb+λ|b|+常数,aj=xj𝖳xjn>0,zj=xj𝖳r(j)n. b为正时导数为 ajb−zj+λ,只有z大于λ才产生正根;b为负时导数为 ajb−zj−λ,只有z小于负λ才产生负根。零点左右导数分别为 −zj−λ 与 −zj+λ,零为最优恰当 |zj|≤λ。所以统一更新是 βj←S(zj,λ)aj,S(z,λ)=sign⁡(z)max⁡(|z|−λ,0). 这叫软阈值:先向零平移λ,未超过阈值的整段直接归零;它与只删除小系数、保留大系数原值的硬阈值不同。

软阈值在负一点二至正一点二区间为零,外侧斜率一
λ=1.2。纵轴为标准化单坐标更新 S(z,λ),零点整段平坦使系数可精确归零。

对整体残差 r=y−Xβ,最优的必要且充分条件为 xj𝖳rn=λsign⁡(βj)(βj≠0),|xj𝖳rn|≤λ(βj=0). 这是平方梯度与绝对值次梯度之和含零的条件;凸性使它充分。它既解释变量何时归零,也给出停止算法的可检查标准,而不只观察相邻两次系数是否看似不变。

正交例子的最优解和目标值

主例 X𝖳X/n=I,X𝖳y/n=(2,1)𝖳,目标逐坐标分离。取λ=1.2,软阈值给出 β^=(0.8,0)。预测为 (0.8,0.8,−0.8,−0.8),残差为 (2.2,0.2,−0.2,−2.2),故平方损失和惩罚分别为 2.22+0.22+(−0.2)2+(−2.2)28=1.22,1.2⋅0.8=0.96. 总目标2.18,小于全部系数为零时的2.5,也小于无残差系数(2,1)的3.6。完整KKT核对为 x1𝖳r/4=1.2、x2𝖳r/4=1≤1.2,因此是全局最优;主例满秩,解唯一。

变式。 λ改为0.5时,解为 (1.5,0.5),平方损失0.25、惩罚一,总目标1.25;λ至少为二时,两个系数都为零。一般中心化问题中,只要 λ≥maxj|xj𝖳y|/n,零向量满足全部KKT条件。主例系数随λ的路径分段线性,但不能把这两个独立阈值公式原样用于相关列,相关时部分残差会随其他系数变化。

正交设计中两个系数随惩罚强度增加分别在一和二归零
本例 β1=max(2−λ,0),β2=max(1−λ,0);路径对应原始合成数据。

坐标下降实现

程序先中心化并按均方根标准化X,同时中心化y;在标准化坐标上施加上述λ,最后还原原单位系数和截距。常数特征需先移除;输入为有限实数、正λ,函数不改写输入。有限输入仍可能在求和、平方或还原单位时超出浮点范围,程序对派生均值、尺度、残差及KKT量逐一检查,超出时抛出 ArithmeticError,不会把NaN当成零误差而成功返回。每轮更新保持 r=ycentered−Xstandardizedβ,用部分残差计算精确一维最小值,因此在精确算术下目标不增。循环后检查所有KKT误差,达到指定容差才返回。

Python 3
import math

def lasso_coordinate(X, y, penalty, tol=1e-9, max_sweeps=10000):
    X, y = tuple(tuple(row) for row in X), tuple(y)
    n = len(y)
    if n < 2 or len(X) != n or not X or len(X[0]) == 0:
        raise ValueError("nonempty matched matrix and response required")
    p = len(X[0])
    if (any(len(row) != p for row in X)
            or any(not math.isfinite(v) for row in X for v in row)
            or any(not math.isfinite(v) for v in y)):
        raise ValueError("finite rectangular data required")
    if not math.isfinite(penalty) or penalty <= 0:
        raise ValueError("penalty must be positive")
    if not math.isfinite(tol) or tol <= 0 or type(max_sweeps) is not int or max_sweeps < 1:
        raise ValueError("invalid convergence controls")
    def checked(values):
        values = list(values)
        if any(not math.isfinite(v) for v in values):
            raise ArithmeticError("derived quantity exceeds finite floating range")
        return values

    def finite_sum(values):
        try:
            value = math.fsum(checked(values))
        except (OverflowError, ValueError) as error:
            raise ArithmeticError("sum exceeds floating range") from error
        return checked([value])[0]

    ymean = finite_sum(y) / n
    means = [finite_sum(row[j] for row in X) / n for j in range(p)]
    centered = [checked(row[j] - means[j] for row in X) for j in range(p)]
    if any(all(v == 0 for v in column) for column in centered):
        raise ValueError("remove constant predictor columns")
    scales = checked(math.sqrt(finite_sum(v * v for v in column) / n)
                     for column in centered)
    if any(scale == 0 for scale in scales):
        raise ArithmeticError("predictor scale underflows to zero")
    columns = [checked(v / scale for v in column)
               for column, scale in zip(centered, scales)]
    beta, residual = [0.0] * p, checked(v - ymean for v in y)
    for _ in range(max_sweeps):
        for j, column in enumerate(columns):
            z = finite_sum(x * (r + x * beta[j]) for x, r in zip(column, residual)) / n
            new = math.copysign(max(abs(z) - penalty, 0.0), z)
            difference = new - beta[j]
            checked([new, difference])
            residual = checked(r - x * difference for x, r in zip(column, residual))
            beta[j] = new
        violation = 0.0
        for b, column in zip(beta, columns):
            score = finite_sum(x * r for x, r in zip(column, residual)) / n
            gap = (abs(score - penalty * math.copysign(1, b)) if b != 0
                   else max(abs(score) - penalty, 0.0))
            checked([gap])
            violation = max(violation, gap)
        if violation <= tol:
            original = checked(b / s for b, s in zip(beta, scales))
            intercept = checked([ymean - finite_sum(b * m for b, m in zip(original, means))])[0]
            return intercept, original, beta, violation
    raise RuntimeError("KKT tolerance not reached")

X = [[1, 1], [1, -1], [-1, 1], [-1, -1]]
y = [3, 1, -1, -3]
intercept, coefficients, standardized, gap = lasso_coordinate(X, y, 1.2)
assert abs(intercept) < 1e-12
assert abs(coefficients[0] - 0.8) < 1e-12 and coefficients[1] == 0
assert gap < 1e-12
assert all(abs(a - b) < 1e-12 for a, b in
           zip(lasso_coordinate(X, y, 0.5)[1], [1.5, 0.5]))
print(intercept, coefficients, gap)

每轮所有坐标的残差更新及KKT核验为 O(np),预处理及保存标准化列需要 O(np) 空间;总时间乘实际扫描轮数。λ为正、各列非零时,这类循环精确坐标最小化对该凸可分惩罚问题可求到最优目标;列强相关时可能收敛慢,代码以扫描上限和KKT容差报告实际结果。浮点容差只给数值近似的最优性检查,不能保证每个参数在病态设计下都有同样小的误差。[2]

Lasso的零系数也不等于某变量在现实中没有作用。若两列完全相同,目标只依赖系数和;当最佳和为正时,许多非负拆分具有相同绝对值和与相同拟合,系数可能不唯一。惩罚强度应结合预测目的和独立验证选取,做变量选择后也不能直接套用事先固定模型的普通回归置信区间。

参考文献

  1. ↑ Robert Tibshirani(1996),Regression Shrinkage and Selection via the Lasso,§2.1定义与标准化、§2.2正交情形;本篇统一采用平均平方损失和独立推导的坐标更新,访问日期:2026年10月25日。
  2. ↑ Jerome Friedman、Trevor Hastie、Rob Tibshirani(2010),Regularization Paths for Generalized Linear Models via Coordinate Descent,§2、§2.1,式(1)、(5)–(8),标准化坐标更新与残差维护。访问日期:2026年10月25日。