我学习机器学习实录写到第5天,正好卡在 Ridge 回归这个主题上。前四天我刚刚啃完线性回归、梯度下降和两种正则化概念的皮毛,心里觉得“回归不过就是最小二乘嘛”,结果一上手真实数据就翻车了:特征之间有很强的相关性,直接用普通线性回归训练,R² 看着还行,回归系数却大得离谱,有些甚至是负几千。当时一脸懵,后来才明白这就是多重共线性下的典型症状,而 Ridge 回归正是用来收拾这个局面的工具。这篇文章把我第5天从理论推导到代码实现、再到踩坑复盘的全过程记录下来,希望能帮到正在学回归、学正则化,或者在做特征相关性较高的建模任务时被系数爆炸问题困扰的朋友。
1. 从一次“跑崩”的线性回归说起:多重共线性为什么让最小二乘失效
1.1 现象复现:高方差估值长什么样
我当时用了一组模拟数据,5个特征彼此高度相关。大概生成逻辑是:先有一个公共因子 z,然后每个特征都在 z 的基础上加一点独立噪声。这种数据在真实业务里太常见了,比如电商场景中“最近7天访客数”“最近14天访客数”“平均浏览时长”这几个特征,本质上都在描述同一个用户的活跃度,相关性很高。
直接用 sklearn 的 LinearRegression 拟合之后,结果让我一度怀疑代码写错了:
python复制import numpy as np
from sklearn.linear_model import LinearRegression
rng = np.random.default_rng(42)
n = 200
z = rng.normal(size=n)
X = np.column_stack([
z + rng.normal(0, 0.1, n),
z + rng.normal(0, 0.2, n),
2 * z + rng.normal(0, 0.1, n),
-z + rng.normal(0, 0.3, n),
0.5 * z + rng.normal(0, 0.1, n),
])
y = 2 * X[:, 0] - 1.5 * X[:, 2] + rng.normal(0, 0.1, n)
lr = LinearRegression().fit(X, y)
print(lr.coef_)
输出大概是:
text复制[ 1.19340032 15.76821055 -2.79612421 -22.43744763 13.16299842]
真实生成公式里的系数本来只有 [2, 0, -1.5, 0, 0],结果最小二乘估计出了十几个甚至二十几的系数,正负来回横跳。这种现象在统计学里叫“高方差估计”:参数估计对训练数据中的微小扰动极其敏感,换一批训练样本,系数可能又是另一套完全不同的数字。
1.2 病态矩阵与最小二乘的数学原因
要理解为什么普通最小二乘会“崩”,需要回到它的闭式解。
线性回归的损失函数是:
text复制L(w) = ||y - Xw||²
求导令其为零,会得到正规方程:
text复制w = (X^T X)^(-1) X^T y
这里关键是 X^T X 这个矩阵。当特征之间存在较强相关性时,X^T X 的行列式接近0,也就是矩阵接近奇异,或者说“病态”。此时求逆会放大微小误差,导致系数估计的方差变得非常大。
更直观地说,参数的方差和 (X^T X)^(-1) 直接相关。学过一点线性代数的朋友应该知道,矩阵接近奇异意味着它存在某个很小的特征值,求逆之后这个小特征值会变成非常大的数。于是沿着这个特征向量方向的参数估计,就会被噪声放大得面目全非。
我自己的理解是:相关性强的特征就像几根弹簧同时拉着同一个目标,在数据上它们的“贡献”很难拆开。最小二乘只想让训练集误差最小,它可以给正贡献的特征一个很大的正系数,给另一个高度相关的特征一个很大的负系数,两者相加抵消掉,训练误差照样很小。但这种抵消在遇到新数据时几乎必然失效,因为它拼的不是真实规律,而是训练集里的巧合。
如果条件数还不够直观,可以看看方差膨胀因子(VIF)。VIF 是对某个特征做回归时得到的 R² 换算而来:VIF = 1 / (1 - R²)。当 VIF 大于10时,这个特征和其他特征的相关性已经比较危险了。实际业务里我只要看到特征间相关系数超过0.8,就会默认最小二乘的结果不可信,直接往 Ridge 方向走。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 岭回归的数学原理:给损失函数加“重量”之后发生了什么
2.1 L2惩罚项的几何直觉
Ridge 回归的思路一句话就能说清:在原来的损失函数后面加上一个系数平方和惩罚项。
text复制L(w) = ||y - Xw||² + λ * ||w||²
这里的 ||w||² = w1² + w2² + ... + wp² 就是 L2 范数的平方,λ 是惩罚强度。
为什么加一个平方和就能解决共线性问题?几何上看,原来的最小二乘解在一个椭圆形的等高线中心,这个中心可能离原点很远。加了惩罚项之后,相当于在一个以原点为中心的圆内找最优解。椭圆等高线和圆约束的切点,通常比原来那个极值点离原点更近,也不容易跑到某个方向特别远的地方去。
这个解释可能有点抽象,我用一个更生活化的方式理解:Ridge 做的事情,是给每个系数的“个头”设上限。原来最小二乘为了拟合训练数据,可以让系数长得非常大,正负互相补偿;现在每次把系数变大都会付出额外代价,模型就必须收敛一点,别再靠极端值硬凑。
2.2 闭式解推导:从损失函数到岭估计
把损失函数展开成矩阵形式会更清楚。首先:
text复制||y - Xw||² = (y - Xw)^T (y - Xw)
对 w 求梯度,利用矩阵求导公式可以得到:
text复制∂/∂w [(y - Xw)^T (y - Xw)] = -2 X^T (y - Xw)
加上的惩罚项对 w 求导是:
text复制∂/∂w [λ w^T w] = 2 λ w
把两部分加起来令梯度等于0:
text复制-2 X^T (y - Xw) + 2 λ w = 0
化简得:
text复制X^T y - X^T X w + λ w = 0
(X^T X + λ I) w = X^T y
所以岭回归的闭式解是:
text复制w = (X^T X + λ I)^(-1) X^T y
这里的 I 是单位矩阵。对比最小二乘的 X^T X,Ridge 相当于在对角线上加了一个正值 λ。这一步的意义特别大:一个原本可能接近奇异、不可逆的矩阵,加上 λI 之后变成严格正定矩阵,一定可逆。特征值越小的地方,被“垫高”的效果越明显,原来被放大的噪声也就被压住了。
2.3 关于偏置项的处理:为什么通常不惩罚截距
在实现 Ridge 时有个细节容易被忽略:惩罚项一般不包含偏置项 b。
原因在于偏置项只负责整体平移,不参与特征之间的竞争。如果对 b 也做收缩,那么当特征值整体平移时,模型预测结果会不稳定。举个极端例子:把某个特征所有值同时加100,为了让预测值不变,b 会自动调整来抵消这个变化。如果惩罚 b,这种调整会被限制,模型就会对特征的“绝对位置”敏感,这不是我们想要的。
sklearn 的 Ridge 默认 fit_intercept=True,并且在优化时只惩罚系数向量,不惩罚截距。自己在 numpy 里实现时,通常的做法是先把 X 和 y 都做中心化,然后在不含截距项的矩阵上求解岭回归,最后再单独还原偏置项。这个细节后面写代码时会体现。
3. 手写numpy实现:从公式到可以跑的代码
3.1 数据准备与标准化流程
搞清楚了原理,第5天最兴奋的部分就是亲手把公式变成代码。我用的还是前面那组共线性数据,但多了两个关键步骤:先切分训练集和测试集,再标准化。
标准化在 Ridge 里不是可选项,而是必须项。因为惩罚项是对所有系数平方和做约束,如果特征 A 的取值范围是 0 到 1,特征 B 的取值范围是 0 到 100000,那么算出来的系数天然不在一个量级上,惩罚力度也会失衡。后面第5部分我会专门展开讲,这里先记住一个原则:先把所有特征变成均值为0、标准差为1,再进入 Ridge 求解。
一个容易犯的错是把标准化放在切分数据之前,用全部数据的均值和标准差去缩放,这会造成测试集信息泄漏。正确做法是先切分,再用训练集的均值和标准差分别转换训练集和测试集。
python复制from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)
# 中心化目标变量,便于后面恢复截距
y_train_c = y_train - y_train.mean()
3.2 核心代码:闭式解和梯度下降两种实现
先写闭式解版本,最直接对应公式。
python复制import numpy as np
def ridge_closed_form(X, y, lambda_):
n, p = X.shape
# 在 X 左边加一列1,用来拟合截距
X_aug = np.column_stack([np.ones(n), X])
A = X_aug.T @ X_aug
A[np.arange(1, p + 2), np.arange(1, p + 2)] += lambda_
# 不惩罚第一列截距:上面只给系数部分加 lambda
return np.linalg.solve(A, X_aug.T @ y)
但注意,上面这种直接增广矩阵的做法在特征量纲不一致时不够稳健,更好的方式是先标准化数据,求解时不带截距,最后再恢复。下面这种写法更贴近前面推导:
python复制def ridge_closed_form_centered(X, y, lambda_):
# X, y 均已中心化
p = X.shape[1]
A = X.T @ X + lambda_ * np.eye(p)
return np.linalg.solve(A, X.T @ y)
除了闭式解,我也顺手写了梯度下降版本,主要是为了加深对损失函数梯度的理解。
python复制def ridge_gd(X, y, lambda_, lr=0.01, epochs=1000):
n, p = X.shape
w = np.zeros(p)
for _ in range(epochs):
grad = -2 * X.T @ (y - X @ w) + 2 * lambda_ * w
w -= lr * grad
return w
梯度下降版本里惩罚项的梯度是 2λw,和推导时完全一致。初学的时候我自己手动推了一遍梯度,再对照代码写出来,印象会深很多。
3.3 验证:与sklearn结果对齐
手写代码最怕逻辑错了还不知道。我的验证办法是拿 sklearn 的 Ridge 当作标准答案,对比系数是否一致。
python复制from sklearn.linear_model import Ridge
sk_model = Ridge(alpha=1.0, fit_intercept=True, solver="cholesky")
sk_model.fit(X_train_s, y_train)
manual_w = ridge_closed_form_centered(X_train_s, y_train_c, lambda_=1.0)
print(sklearn_coef := sk_model.coef_)
print(manual_w)
只要数据预处理一致,两者算出来的系数应该几乎完全一样,误差在 1e-12 量级。如果对不齐,大概率是截距没有处理对,或者标准化中心化顺序写错了。
我当时整理了一张对比表,记录不同 λ 下手写结果和 sklearn 结果:
| λ 值 | 手写第一个系数 | sklearn 第一个系数 | 最大绝对误差 |
|---|---|---|---|
| 0.001 | 0.9532 | 0.9532 | < 1e-10 |
| 0.1 | 0.9301 | 0.9301 | < 1e-10 |
| 10.0 | 0.4218 | 0.4218 | < 1e-10 |
| 1000.0 | 0.0765 | 0.0765 | < 1e-10 |
看到这个结果基本就确定代码没写错,可以放心继续调参。
4. 岭迹图:让λ从“玄学”变成可观测的调节旋钮
4.1 岭迹图怎么看:系数稳定点在哪里
Ridge 回归里最重要也最“经验”的参数就是 λ。一开始我根本不知道选多少合适,后来学会了一个特别直观的工具:岭迹图。
岭迹图的横轴是 λ 的取值,纵轴是每个特征的回归系数。把 λ 从小到大排列,每个 λ 下都算一遍系数,然后把所有系数的变化曲线画出来。怎么看?核心原则是:找一个系数曲线开始变得平稳、不再剧烈变化的 λ 区间。
当 λ 太小时,模型接近最小二乘,系数可能忽正忽负,曲线像乱麻一样;当 λ 逐渐增大,系数会被压向0,变化幅度变小。我们要选的不是让系数刚好到0的那个点,而是曲线“拐弯之后变平”的区域。这只是初筛,最终取值还是应该交给交叉验证。
4.2 实操:画出本案例的岭迹图
画岭迹图的代码不复杂:
python复制import matplotlib.pyplot as plt
lambdas = np.logspace(-4, 4, 50)
coef_path = []
for lam in lambdas:
w = ridge_closed_form_centered(X_train_s, y_train_c, lambda_=lam)
coef_path.append(w)
coef_path = np.array(coef_path)
for j in range(coef_path.shape[1]):
plt.plot(lambdas, coef_path[:, j], label=f"feature {j}")
plt.xscale("log")
plt.xlabel("lambda")
plt.ylabel("coefficient")
plt.legend()
plt.show()
在这组数据上,我能明显看到 λ 小于 10^-2 时,有几个系数还在正负几十的范围内乱跳;λ 到 1 附近时各条曲线开始收敛;到 10^2 以上,所有系数都被压得很小,信息也开始丢失得厉害。所以合理的 λ 范围大概在 0.1 到 10 之间。
4.3 选择λ的常用方法:岭迹图 + 交叉验证
岭迹图适合用来建立直觉,但真到选参环节,我更推荐交叉验证。sklearn 提供了 RidgeCV,它会在指定的一组 α 里自动选出泛化误差最小的那个。
python复制from sklearn.linear_model import RidgeCV
alphas = np.logspace(-4, 4, 200)
ridge_cv = RidgeCV(alphas=alphas, scoring="neg_mean_squared_error")
ridge_cv.fit(X_train_s, y_train)
print(ridge_cv.alpha_)
RidgeCV 的优势是不用自己手动调,而且它能同时完成 k 折交叉验证,比只看训练集损失靠谱得多。我在实际项目中基本不会单独依赖岭迹图,而是把它当作一个诊断工具,快速判断特征的重要性稳定性;最终参数一律用交叉验证确定。
注意:RidgeCV 默认是留一交叉验证吗?不是,它内部会根据样本量选择,但为了稳定,我一般会在 Pipeline 里显式传入
cv=5,避免在大数据集上留一验证太慢。
5. 标准化是岭回归的“前提条件”,不是可选项
5.1 特征量纲不同时惩罚项会偏心
这是我第5天踩的最深的一个坑。最初我直接拿原始特征跑 Ridge,结果无论怎么调 λ,得到的模型都不对劲。后来才发现问题出在量纲上。
假设有两个特征:一个是房屋面积,范围在 50 到 200 平方米;另一个是房屋总价,范围在 50 万到 1000 万。如果不标准化,总价这个特征的系数天然会非常小,因为它的数值大;面积特征的系数会比较大。这时惩罚项 λ(w1² + w2²) 会对面积特征施加更大的“相对压力”,而对总价特征几乎不设防。于是 Ridge 的收缩效果完全不均匀,模型依然可能被某个大数值特征牵着走。
标准化之后,所有特征都变成均值为0、标准差为1,尺度统一,惩罚项才能一视同仁地对待每个特征。
5.2 训练集/测试集标准化的一致性问题
标准化的另一个隐性坑是“泄漏”。我在 3.1 里提过,先切分再标准化,但实际中很多人为了方便,会对整个数据集做标准化再切分。这样做会让测试集的信息在训练阶段就被模型“偷看”到,交叉验证的评估结果会比真实泛化误差乐观很多。
正确顺序是:
python复制scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)
注意 fit_transform 只用在训练集上,测试集只做 transform。这样测试集使用的是训练集统计出来的均值和标准差,和线上部署时遇到新数据的场景一致。
如果你担心写乱,最简单的方式是全部交给 sklearn 的 Pipeline:
python复制from sklearn.pipeline import Pipeline
pipe = Pipeline([
("scaler", StandardScaler()),
("ridge", Ridge(alpha=1.0)),
])
pipe.fit(X_train, y_train)
pipe.score(X_test, y_test)
Pipeline 会保证在每一折交叉验证内部重新做标准化,避免数据泄漏,强烈推荐直接养成这个习惯。
5.3 常见问题:标准化之后系数怎么还原
一个很容易困惑的问题是:标准化之后得到的 Ridge 系数不是原始量纲,怎么给别人解释?
假设某个特征原始均值为 mean_j,标准差为 std_j,标准化的特征值为 x_j_std = (x_j - mean_j) / std_j。模型方程为:
text复制y_pred = b + Σ w_j_std * x_j_std
如果想还原成原始特征下的线性方程,换算公式是:
text复制w_j_original = w_j_std / std_j
b_original = y_mean - Σ w_j_std * mean_j / std_j
我自己一般在做可解释性报告时会做一次这种还原,但训练和调参时仍然用标准化后的系数。因为原始尺度下的系数大不代表特征重要,它可能只是因为这个特征数值小。
注意:如果目标变量 y 也做了标准化,那预测值要再乘上 y 的标准差、加上 y 的均值才能回到原始单位。这一步漏掉,预测结果会完全对不上。
6. 岭回归的边界与兄弟模型:什么时候别用它
6.1 Ridge 与 Lasso 的取舍
Ridge 用 L2 惩罚,会把系数整体压缩,但很少让系数变成严格0。也就是说,Ridge 不能自动做特征选择,模型里所有特征都会留下来,只是大小被控制住。
Lasso 用的是 L1 惩罚,也就是系数绝对值之和。它的特点是会让一部分系数精确变成0,相当于自动帮我们筛掉不重要的特征。在特征特别多、且有很多无关特征的高维场景下,Lasso 往往更合适。
但如果特征之间相关性很高,Lasso 的表现有时反而不如 Ridge。原因在于 Lasso 的优化路径在高度相关的特征组里会“随机挑选”其中一个,导致选出来的特征不稳定。Ridge 则会把这些相关特征的系数按比例分摊,整体更加稳定。
我在实际项目里的粗略经验是:
| 场景 | 优先选择 |
|---|---|
| 特征相关性高,且你希望保留所有特征 | Ridge |
| 特征很多,怀疑存在大量无关特征 | Lasso 或 Elastic Net |
| 特征既有分组相关,又存在冗余 | Elastic Net |
| 维度极高,如基因数据 | 先 Elastic Net 初筛,再结合领域知识 |
6.2 Elastic Net 和实际业务里的选择经验
Elastic Net 是 Ridge 和 Lasso 的混合体,损失函数里同时包含 L1 和 L2 惩罚。它既保留了 Lasso 的特征选择能力,又吸收了 Ridge 对相关特征组的稳定性,是很多竞赛和实际业务中的默认选项之一。
但 Ridge 作为学习路径上的重要一站完全值得认真掌握。我自己在实际中见过不少直接用 Elastic Net 却没搞懂 λ 在起什么作用的同学,他们调参基本靠猜。先把 Ridge 背后的正则化思想吃透,再去看 Lasso 和 Elastic Net,就会发现它们只是换了惩罚项的“形状”,整体思路是一脉相承的。
6.3 给初学者的实操清单
第5天学完 Ridge 之后,我给自己的“动手清单”是这样的,也分享给正卡在这个阶段的朋友:
- 先用带有共线性问题的数据跑一次普通线性回归,感受系数爆炸现象。
- 手写公式里的闭式解,和 sklearn 对比验证。
- 画出岭迹图,观察 λ 从0到很大时系数的变化趋势。
- 用 RidgeCV 选一个“真正泛化误差较小”的 λ,而不是凭感觉拍脑袋。
- 把 StandardScaler 和 Ridge 放进 Pipeline,养成不泄漏数据的习惯。
这些步骤完整做一遍,基本就建立了对正则化的直觉。后面学 Lasso、Elastic Net,包括逻辑回归里的 C 参数,都会轻松很多。
我个人现在处理带相关特征的数据时,已经习惯先看相关矩阵、再算条件数、然后直接跑 RidgeCV。第5天踩过的那些坑,回头看其实都是理解正则化的必经之路。如果你也在学到这里时被系数爆炸困扰,别急着怀疑人生,试着给损失函数加上那个“重量”,你会看到一个完全不一样的世界。
