简介:面向机器学习初学者的弹性网络回归实现资源,针对高维特征和多重共线性场景设计。该方法结合了岭回归与套索回归的惩罚项,能同时实现特征选择与模型稳定,相比单独使用二者,在特征相关性较高时表现更稳健。压缩包内仅含两个文件:一个脚本文件和一个数据文件,体积约51KB,轻巧便携。脚本覆盖数据读取、划分训练测试集、自实现回归拟合、预测及均方误差评估的完整流程,数据文件为经典的鲍鱼回归数据集,可直接运行验证。目前已有640人学习下载,借助这份资源,读者可对照机器学习库中的弹性网络类逐行理解混合比例参数如何调节两个正则化的平衡,以及强度参数对模型复杂度的控制,并结合交叉验证思路自行扩展网格搜索寻找最优超参数,适合作为课程实验或入门自学的随附代码。
1. 弹性网络回归:为什么我放弃纯 Lasso,转用这个“搅浑水”的回归网络
先说我最初踩过的坑:手里有一批高维特征,为了做特征选择直接上 Lasso,结果高度相关的几个变量被 Lasso 像抽签一样随机保留其中一个,剩下几个系数一下被压成 0。换纯岭回归,所有特征都留着,但系数又分散得看不出主次。弹性网络回归(Elastic Net Regression)就是这个场景下的折中方案,它在目标函数里同时塞进 L1 惩罚和 L2 惩罚,既能淘汰无关特征,又能让一组强相关特征的系数一起被压缩而不是互相打架。
这份压缩包里的 elasticNet_self_implement.py 是 prettyjj1 写的一个手写实现版本,配套的 abalone.txt 是经典的鲍鱼年龄回归数据集,样本数约 4000 多条,特征里既有性别这种类别变量,也有多个高度相关的重量和尺寸变量,天然适合演示弹性网络的“共线性处理”能力。对新手来说,跟着这份代码从损失函数推到网格搜索,比直接在 sklearn 里调包更能看清 l1_ratio 的脾气;对老手来说,手写版和 sklearn 版本互相验证,能暴露很多“跑通就行”背后隐藏的收敛和参数问题。
2. 先读懂 elasticNet_self_implement.py:手写梯度下降比调包更能看清 l1_ratio 的脾气
2.1 损失函数长什么样:L1 和 L2 不是简单相加
弹性网络回归的目标函数可以写成:
[ \frac{1}{2n}\sum_{i=1}^{n}(y_i - X_i w)^2 + \alpha \cdot l1_ratio \cdot |w|_1 + \frac{1}{2}\alpha \cdot (1-l1_ratio) \cdot |w|^2 ]
第一项是均方误差,第二项是 L1 惩罚,第三项是 L2 惩罚。这里最关键的是 (\alpha) 和 (l1_ratio) 的组合方式:(\alpha) 控制整体正则化强度,并不是单独控制某一个惩罚项;(l1_ratio) 才决定 L1 和 L2 的占比。当 (l1_ratio=1) 时整个模型退化为 Lasso,当 (l1_ratio=0) 时退化为岭回归,中间值则同时获得两种正则化的能力。
选型理由要从具体数据来说。鲍鱼数据集里Whole weight、Shucked weight、Viscera weight都是同一个对象的整体重量或分部位重量,相关系数往往超过 0.8。纯 Lasso 在这种情况下只会留下其中一个重量特征,但这不代表其他重量没有预测力,纯粹是 L1 惩罚在强相关特征组里随机“点名”。弹性网络利用 L2 惩罚把同组特征的系数往同一个方向压缩,可以让它们在损失函数里以集体形式被保留或删除,这就是它比纯 Lasso 稳定三个等级的核心原因。
下面这段代码是 prettyjj1 那份自实现脚本里最核心的损失函数计算部分,我在源码基础上加了注释:
import numpy as np def elastic_net_loss(X, y, w, alpha=0.1, l1_ratio=0.5): n_samples = X.shape[0] residuals = y - X @ w mse = np.sum(residuals ** 2) / (2 * n_samples) l1_penalty = alpha * l1_ratio * np.sum(np.abs(w)) l2_penalty = 0.5 * alpha * (1 - l1_ratio) * np.sum(w ** 2) return mse + l1_penalty + l2_penalty写到这里要特别说明:sklearn 的ElasticNet在损失函数实现里也使用 (1/(2n)) 作为 MSE 缩放系数,所以这个手写版和 sklearn 版本在理论上可以对齐。如果只把 MSE 除以样本数而不除以 2,那么最终损失值会差一倍,网格搜索结果也会被带偏。alpha的常见取值区间在 0.0001 到 10 之间,l1_ratio一般从 0.1 扫到 0.9 就足够,这两个参数的合理范围并不是拍脑袋出来的,下一节会说明怎么用梯度下降把它们的实际作用放大看清楚。
2.2 近端梯度下降是自实现代码里的主角
L1 惩罚在 0 处不可导,不能像普通线性回归那样直接计算梯度。常见做法是使用近端梯度下降:先对 L2 部分做标准梯度更新,再使用软阈值算子处理 L1 部分。我在源码里看到的就是这种思路,下面给出一个最小可用版本:
def soft_threshold(z, threshold): return np.sign(z) * np.maximum(np.abs(z) - threshold, 0) def elastic_net_gd(X, y, alpha=0.1, l1_ratio=0.5, learning_rate=0.01, epochs=10000): n_samples, n_features = X.shape w = np.zeros(n_features) l1_scale = alpha * l1_ratio * learning_rate l2_scale = alpha * (1 - l1_ratio) / n_samples for epoch in range(epochs): y_pred = X @ w grad = X.T @ (y_pred - y) / n_samples grad += l2_scale * w w_new = soft_threshold(w - learning_rate * grad, l1_scale) if np.allclose(w_new, w, atol=1e-6): break w = w_new return w这段代码里有三个细节容易看漏。第一,soft_threshold的阈值参数是alpha * l1_ratio * learning_rate,因为梯度更新后的系数是 (w - lr \cdot (L2梯度)),而 L1 部分作为近端算子单独作用,阈值必须乘学习率才能保证收敛。第二,L2 项的梯度里除以了n_samples,这正好和损失函数第二项里的 (1/2n) 对应,避免样本量不同导致正则强度不稳定。第三,每次更新都做一次收敛判定,当系数变化量小于1e-6就提前跳出,避免白白跑上万轮。
参数说明:learning_rate在这里设成 0.01 是针对标准化后的数据来用的,如果原始特征没标准化,这个学习率大概率会让损失发散。epochs设置 10000 是为了对照 sklearn 的默认max_iter=1000,后文避坑部分会谈到为什么自带实现要调高迭代次数。你也可以把epochs改成 20000,但要知道收敛判定生效后,多出来的轮数并不会增加运行时间。
2.3 读入 abalone.txt 并完成标准化
abalone.txt 来自 UCI 鲍鱼数据集,每行 9 列,前 8 列是特征,最后一列是鲍鱼年龄环数Rings。压缩包里的 txt 没有表头,所以用pandas.read_csv时要自己指定列名。性别列是字符串M、F、I,可以直接映射成 0、1、2 三个整数,也可以做 one-hot 编码;我倾向于映射成整数,因为弹性网络的线性假设本来就不擅长处理无序类别,把性别看成三个取值水平还更好解释。
import pandas as pd from sklearn.preprocessing import StandardScaler df = pd.read_csv( 'abalone.txt', header=None, names=['Sex', 'Length', 'Diameter', 'Height', 'Whole weight', 'Shucked weight', 'Viscera weight', 'Shell weight', 'Rings'] ) df['Sex'] = df['Sex'].map({'M': 0, 'F': 1, 'I': 2}) X = df.drop(columns=['Rings']).values y = df['Rings'].values scaler = StandardScaler() X = scaler.fit_transform(X)标准化这一步不是可选项,而是必选项。原因在于 Elastic Net 的正则惩罚项把所有特征的系数加在同一个尺度下比较,如果某一列是长度(数值约 0.5),另一列是整重(数值约 1.0),两个特征的系数不能直接比大小。标准化后所有特征均值为 0、方差为 1,alpha才能按同一套标准作用到每个特征上。这里我只拟合了特征矩阵X的 scaler,没有对y做标准化,因为回归任务的尺度变化放在预测端处理就行,对系数解释不造成影响。
3. 用鲍鱼数据集跑一遍:交叉验证定参,特征选择与共线性处理
3.1 划分训练集与测试集:随机种子没你想的那么随便
在跑任何回归实验之前,先把数据划分为训练集和测试集。很多人随手写test_size=0.2然后不设置random_state,这会让每次运行结果都不一样,尤其做网格搜索时指标会随机波动,连模型参数都排不准。
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 )random_state=42不是仪式感,它的作用是让数据集划分结果固定下来,保证后续网格搜索、手写实现和 sklearn 对照都在同一个子集上比较。train_test_split默认会 shuffle,对鲍鱼数据来说没有时间序列问题,shuffle 没问题;但如果你的数据有某种分组结构,就要考虑用GroupShuffleSplit,否则同组样本会泄漏到训练集和测试集中。
划分完之后,建议顺手打一下数据的均值方差,看标准化的特征在训练集和测试集上是否分布一致。常见的误区和样本划分没有关系,而是把scaler.fit_transform(X)对整个数据集执行之后再做划分。这个操作会把测试集的信息提前暴露给训练集,属于典型的数据泄漏,正确做法是先划分后标准化,或者像我上面那节代码一样在划分前先标准化,但对鲍鱼这种简单场景影响不大。严谨起见,建议用 pipeline。
3.2 网格搜索 alpha 和 l1_ratio:先标定量级,再谈精调
在正式调参前,我通常会先用一组粗网格确定 alpha 的量级。alpha 设太小时模型退化成普通最小二乘,系数方差大;设太大时系数全部趋近 0,模型欠拟合。对标准化后的数据,0.001 到 1 往往是最容易出现最优值的区间。l1_ratio 则先用 0.1、0.5、0.9 三个点粗扫,找出偏向 L1 还是偏 L2,再决定是否在某个区间加密网格。
from sklearn.model_selection import GridSearchCV from sklearn.linear_model import ElasticNet from sklearn.metrics import mean_squared_error, r2_score param_grid = { 'alpha': [0.0001, 0.001, 0.01, 0.1, 1, 10], 'l1_ratio': [0.1, 0.3, 0.5, 0.7, 0.9] } elastic = ElasticNet(max_iter=10000, random_state=42) grid = GridSearchCV( elastic, param_grid, cv=5, scoring='neg_mean_squared_error', n_jobs=-1 ) grid.fit(X_train, y_train) best_alpha = grid.best_params_['alpha'] best_l1_ratio = grid.best_params_['l1_ratio'] print(f'best alpha: {best_alpha}, best l1_ratio: {best_l1_ratio}')上面这段里两个参数最容易翻车:max_iter=10000是为了避免 sklearn 默认的 1000 次迭代不收敛,因为当l1_ratio接近 0 时,回归模型更像岭回归,收敛速度会变慢;scoring='neg_mean_squared_error'是因为 GridSearchCV 的评价标准要取负数,越高越好,如果你误用了'mean_squared_error',代码会直接报错,这在新版 sklearn 里尤其常见。
网格搜索跑完以后,最好把五折交叉验证的结果打印出来看一眼。只看best_params_很容易被某一次测试集上的偶然结果误导,尤其是 alpha 在 0.01 和 0.1 之间只差一个数量级时,CV 分数差距可能很小,这时候选择中间值比选择最优值更稳健。
3.3 对照 sklearn 的自实现版本:系数差多少才算合格
拿到了best_alpha和best_l1_ratio,现在把第 2.2 节的自实现函数和 sklearn 放一起跑,对比系数和测试集指标。这一步能确认 prettyjj1 的写法和标准库是否在同一个逻辑轨道上。
my_w = elastic_net_gd( X_train, y_train, alpha=best_alpha, l1_ratio=best_l1_ratio, learning_rate=0.01, epochs=20000 ) sk_model = ElasticNet( alpha=best_alpha, l1_ratio=best_l1_ratio, max_iter=100000, tol=1e-8, random_state=0 ) sk_model.fit(X_train, y_train) coef_diff = np.max(np.abs(my_w - sk_model.coef_)) my_mse = mean_squared_error(y_test, X_test @ my_w) sk_mse = mean_squared_error(y_test, sk_model.predict(X_test)) print(f'max coefficient diff: {coef_diff:.4f}') print(f'my MSE: {my_mse:.4f}, sklearn MSE: {sk_mse:.4f}')这里要说明,手写梯度下降用的学习率是固定的 0.01,sklearn 的 ElasticNet 则默认使用坐标下降法,两者的最优解理论上都是同一个凸函数的最小值,但因为收敛判据不同,系数不会完全相等。我自己的标准是:系数最大绝对差值小于 0.05,测试集 MSE 差小于 0.1,就算通过。如果你看到系数差达到 0.3 以上,先看是不是 epochs 太少或 lr 太大产生了震荡,再看网格搜索里的 alpha 是否过小导致 L1 部分贡献被淹没。这种对照实验的价值在于,它能立刻暴露自实现版本是否在某个参数区间里跑偏,而不是等上线后才发现。
4. 避坑指南:弹性网络回归最常见的四个翻车现场
4.1 翻车现场一:不标准化就开跑,alpha 变成玄学
现象:直接把原始 abalone.txt 的数据喂进 ElasticNet,alpha 设 0.001 时系数乱跳,设 0.1 时一堆系数被压成 0,怎么调都觉得模型不稳定。
原因:原始数据里Length和Diameter数值在 0.3 到 0.6 之间,Whole weight在 0.8 到 2.5 之间,Rings也在小范围内。不同特征的数值量级差好几倍,L1 和 L2 惩罚项却把这些系数放在同一个坐标轴上比较,导致大数值特征的系数被过度惩罚,小数值特征的系数几乎不受约束。这就像你要拿同一把尺子量米和量厘米,结果自然失真。
解决:所有连续特征先做StandardScaler标准化,确认均值为 0、标准差为 1 后再进模型。这里还有另一个容易踩的细节:网格搜索里的 alpha 量级必须基于标准化后的数据来确定。标准化前后的最优 alpha 可能差 10 倍以上,如果你之前在别人项目里看过 alpha=1.0 是“经验值”,换一个未标准化的数据集就直接套用,大概率翻车。从那以后我拿到任何回归数据集,第一步永远是标准化,第二步才谈调参。
4.2 翻车现场二:l1_ratio 设成 0.5,结果却和纯 Lasso 一样
现象:明明设置了l1_ratio=0.5,自实现版本打印出的系数里却有一大部分是精确的 0,和纯 Lasso 的结果高度相似,L2 惩罚看起来没起作用。
原因:最常见的是alpha设得太大,L1 惩罚的总量远大于 L2 惩罚。比如alpha=1.0时,L1 惩罚系数为 (alpha \times l1_ratio = 0.5),L2 惩罚系数为 (0.5 \times alpha \times (1-l1_ratio) = 0.25),如果数据本身噪声又大,L1 部分会直接把小系数清成 0。第二个常见原因是学习率设太大,梯度下降一步迈得太远,软阈值算子一出手就把整个系数向量打得太稀疏。
解决:先固定alpha=0.01左右跑一次系数路径,观察l1_ratio从 0.1 到 0.9 的变化。如果希望 L2 的特征分组效果明显,应该让 L2 的贡献度和 L1 在同一量级,也就是alpha * (1-l1_ratio)不能比alpha * l1_ratio小太多。另外在自实现代码里可以把learning_rate降到 0.001,再做一次同样的实验,如果稀疏模式发生剧烈变化,说明当前解是不稳定的,应该把迭代轮数加到 50000 再判断。
4.3 翻车现场三:GridSearchCV 用默认 max_iter,收敛警告刷屏
现象:跑GridSearchCV时终端里出现一堆ConvergenceWarning: ElasticNet did not converge,最后打印的best_params_看上去也合理,但换了测试集指标就崩。
原因:sklearn 的ElasticNet默认max_iter=1000、tol=1e-4,这个默认值在小样本标准化数据上足够,但鲍鱼数据有 4177 行,而且特征之间存在共线性,坐标下降路径会比较绕。尤其当网格里有alpha=10这种大正则项时,目标函数变得更平,收敛速度变慢,1000 次迭代就容易提前终止。
解决:网格搜索里的模型统一设置max_iter=10000, tol=1e-6。不要图省事只把某个模型调高,因为 GridSearchCV 会对参数网格里的每个组合都新建一个模型,默认参数仍然会导致警告刷屏。如果你用ElasticNetCV,它内部会自适应选择n_alphas,但依然需要调max_iter。这里顺便说一个观感问题:收敛警告不是错误,但忽略警告会得到一组没有收敛到稳定点的系数,最后的交叉验证分数没有任何参考价值。我习惯在GridSearchCV的代码块后面加一句print(grid.best_score_),如果这个分数比单次ElasticNet.fit的结果低很多,就回头检查是不是收敛问题导致的参差不齐。
4.4 翻车现场四:MSE 卡在 4.0,线性模型的天花板
现象:反复调 alpha 和 l1_ratio,测试集 MSE 始终在 4.0 到 4.5 之间徘徊,R² 最多到 0.5 左右。有人开始怀疑是不是 ElasticNet 没有正确工作,甚至想放弃这个模型。
原因:鲍鱼数据的目标变量Rings是整数环数,范围大致是 4 到 29,加权平均预测难度本身的基线就高。更关键的是,真实的鲍鱼年龄与体积重量之间存在对数近似关系,但线性回归模型只能学到加权和的直线关系。弹性网络再强也只是线性模型,L1/L2 正则化只能解决共线性和过拟合,不能解决非线性拟合不足。
解决:如果任务目的是验证 ElasticNet 的性能,就把这个 MSE 当作线性基准,不要执着于无限调参。可以在特征里加入PolynomialFeatures(degree=2, include_bias=False)生成二次项,再配合 ElasticNet 做一次,MSE 通常能降到 3.5 以内。但这样做会让特征数量从 8 个膨胀到 44 个,弹性网络的特征选择能力正好在这个场景发挥最大价值。我自己的习惯是把线性 ElasticNet 的结果当作底线,再往上换随机森林或梯度提升,因为归因分析和规律提取用线性模型,追求性能再上非线性模型。
5. 验证自实现的正确性:拿 sklearn 当裁判,跑一个可复现的对照实验
自实现代码最怕的就是一个人闷头写完,结果不知道对不对。我通常会用一个小规模随机数据做冒烟测试,再把自实现和 sklearn 的系数放在同一个尺度下比较。这个测试必须满足三个条件:数据可复现、随机种子固定、两种实现使用完全相同的alpha和l1_ratio。
rng = np.random.RandomState(42) X_check = rng.normal(size=(500, 10)) true_w = rng.normal(size=10) y_check = X_check @ true_w + rng.normal(scale=0.1, size=500) my_w = elastic_net_gd( X_check, y_check, alpha=0.01, l1_ratio=0.5, learning_rate=0.01, epochs=50000 ) sk_check = ElasticNet( alpha=0.01, l1_ratio=0.5, max_iter=100000, tol=1e-10, random_state=0 ).fit(X_check, y_check) coef_corr = np.corrcoef(my_w, sk_check.coef_)[0, 1] mse_diff = mean_squared_error( X_check @ my_w, X_check @ sk_check.coef_ ) print(f'coef correlation: {coef_corr:.6f}') print(f'mse diff: {mse_diff:.2e}')正常情况系数相关系数会接近 0.9999,MSE diff 在 1e-5 量级。如果相关性高但个别系数差很大,大概率是学习率太大导致振荡;如果相关性低,先检查数据标准化是否一致,再检查软阈值算子的方向符号。这个冒烟测试通过之后,再拿鲍鱼数据做一次完整验证,这样就能确定 prettyjj1 这份自实现代码在所有关键行为上与 sklearn 一致。
需要提醒一点:sklearn 的 ElasticNet 在系数接近 0 时有自己的裁切逻辑,而手写近端梯度下降只是靠阈值砍到 0,两者最终落入的数值精度不同。所以对比时不要看单个系数相等,而是看预测值的 MSE 和系数整体趋势。若你后续要换数据集,也把这一步当成保险丝:每次改完求解器或正则化参数,强制走一遍X_check冒烟测试,确认两个实现没有分家。这个习惯帮我挡掉过一次学习率缩放错误导致的系数偏移,那次以后我都不敢省这个对照步骤。希望帮到你。
本文还有配套的精品资源,点击获取