Skip to content

Commit aa2a5c8

Browse files
authored
feat: Expand adaptive lasso support and fix warm start (#11)
- Support omega parameter for adaptive lasso in ElasticNet classifier/regressor - Allow omega elements to be zero (no L1 penalty for specific features) - Fix warm start bug of `plqERM_ElasticNet` to handle hyperparameter changes correctly - Add detailed CI tests for related updates
1 parent 377a8f9 commit aa2a5c8

5 files changed

Lines changed: 185 additions & 14 deletions

File tree

rehline/_class.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -527,8 +527,9 @@ class plqERM_ElasticNet(_BaseReHLine, BaseEstimator):
527527
The ElasticNet mixing parameter, with 0 <= l1_ratio < 1. For l1_ratio = 0 the penalty
528528
is an L2 penalty. For 0 < l1_ratio < 1, the penalty is a combination of L1 and L2.
529529
530-
omega : array of shape (n_features, ), default=np.empty(shape=(0, 0))
531-
Weight coefficients for adaptive lasso.
530+
omega : array of shape (n_features, ), default=np.empty(shape=0)
531+
Non-negative weight coefficients for adaptive lasso. If not provided, all coefficients receive the
532+
same L1 penalty controlled by ``l1_ratio``.
532533
533534
verbose : int, default=0
534535
Enable verbose output. Note that this setting takes advantage of a
@@ -606,8 +607,7 @@ def __init__(
606607
self.constraint = constraint if constraint is not None else []
607608
self.C = C
608609
self.l1_ratio = l1_ratio
609-
self.C_eff = C / (1 - l1_ratio)
610-
self.omega = omega if omega is not None else np.empty(shape=(0, 0))
610+
self.omega = omega if omega is not None else np.empty(shape=(0))
611611
self._U = U if U is not None else np.empty(shape=(0, 0))
612612
self._V = V if V is not None else np.empty(shape=(0, 0))
613613
self._S = S if S is not None else np.empty(shape=(0, 0))
@@ -627,7 +627,7 @@ def __init__(
627627
self._Lambda = np.empty(shape=(0, 0))
628628
self._Gamma = np.empty(shape=(0, 0))
629629
self._xi = np.empty(shape=(0, 0))
630-
self._mu = np.empty(shape=(0, 0))
630+
self._mu = np.empty(shape=(0))
631631
self.coef_ = None
632632

633633
def fit(self, X, y, sample_weight=None):
@@ -664,14 +664,14 @@ def fit(self, X, y, sample_weight=None):
664664
self.auto_shape()
665665

666666
sample_weight = _check_sample_weight(sample_weight, X, dtype=X.dtype)
667-
667+
C_eff = self.C / (1 - self.l1_ratio)
668668
U_weight, V_weight, Tau_weight, S_weight, T_weight = _cast_sample_weight(
669669
self._U,
670670
self._V,
671671
self._Tau,
672672
self._S,
673673
self._T,
674-
C=self.C_eff,
674+
C=C_eff,
675675
sample_weight=sample_weight,
676676
)
677677

@@ -680,7 +680,7 @@ def fit(self, X, y, sample_weight=None):
680680
self._Lambda = np.empty(shape=(0, 0))
681681
self._Gamma = np.empty(shape=(0, 0))
682682
self._xi = np.empty(shape=(0, 0))
683-
self._mu = np.empty(shape=(0, 0))
683+
self._mu = np.empty(shape=(0))
684684

685685
if self.l1_ratio == 0:
686686
self.rho = None
@@ -695,9 +695,9 @@ def fit(self, X, y, sample_weight=None):
695695
raise ValueError(
696696
f"Omega length {self.omega.size} must be 0 or {d} (n_features)"
697697
)
698-
if not np.all(self.omega > 0):
698+
if not np.all(self.omega >= 0):
699699
raise ValueError(
700-
"All elements in omega must be strictly positive."
700+
"All elements in omega must be strictly non-negative."
701701
)
702702
self.rho = np.full(d, self.l1_ratio / (1 - self.l1_ratio)) * (self.omega if self.omega.size == d else 1.0)
703703

rehline/_sklearn_mixin.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,11 @@ class plq_ElasticNet_Classifier(plqERM_ElasticNet, ClassifierMixin):
714714
- 0 < l1_ratio < 1 → combined L1 + L2 penalty
715715
Must be strictly less than 1.0 to avoid division by zero in rho/C_eff.
716716
717+
omega : array of shape (n_features, ), default=np.empty(shape=(0, 0))
718+
Non-negative weight coefficients for adaptive lasso. If not provided, all non-intercept coefficients
719+
receive the same L1 penalty controlled by ``l1_ratio``. The penalty for the intercept
720+
can be scaled via ``intercept_scaling``.
721+
717722
fit_intercept : bool, default=True
718723
Whether to fit an intercept term via an augmented constant feature column.
719724
@@ -754,6 +759,7 @@ def __init__(
754759
constraint=None,
755760
C=1.0,
756761
l1_ratio=0.5,
762+
omega=None,
757763
U=None,
758764
V=None,
759765
Tau=None,
@@ -780,6 +786,7 @@ def __init__(
780786
)
781787

782788
constraint = [] if constraint is None else constraint
789+
omega = np.empty((0,)) if omega is None else omega
783790
U = np.empty((0, 0)) if U is None else U
784791
V = np.empty((0, 0)) if V is None else V
785792
Tau = np.empty((0, 0)) if Tau is None else Tau
@@ -794,6 +801,7 @@ def __init__(
794801
constraint=constraint,
795802
C=C,
796803
l1_ratio=l1_ratio,
804+
omega=omega,
797805
U=U,
798806
V=V,
799807
Tau=Tau,
@@ -850,6 +858,7 @@ def _fit_subproblem(estimator, X_aug, y_pm, sample_weight, fit_intercept):
850858
constraint=estimator.constraint,
851859
C=estimator.C,
852860
l1_ratio=estimator.l1_ratio,
861+
omega=estimator.omega,
853862
max_iter=estimator.max_iter,
854863
tol=estimator.tol,
855864
shrink=estimator.shrink,
@@ -908,17 +917,19 @@ def fit(self, X, y, sample_weight=None):
908917

909918
# Intercept augmentation
910919
X_aug = X
920+
omega_copy = self.omega.copy()
911921
if self.fit_intercept:
912922
col = np.full((X.shape[0], 1), self.intercept_scaling, dtype=X.dtype)
913923
X_aug = np.hstack([X, col])
924+
self.omega = np.append(self.omega, 1) if self.omega.size > 0 else self.omega
914925

915926
if self.classes_.size == 2:
916927
y01 = le.transform(y)
917928
y_pm = 2 * y01 - 1
918929

919930
# super() resolves to plqERM_ElasticNet.fit()
920931
super().fit(X_aug, y_pm, sample_weight=sample_weight)
921-
932+
self.omega = omega_copy
922933
if self.fit_intercept:
923934
self.intercept_ = float(self.coef_[-1])
924935
self.coef_ = self.coef_[:-1].copy()
@@ -931,6 +942,7 @@ def fit(self, X, y, sample_weight=None):
931942
f"multi_class must be 'ovr' or 'ovo' for multiclass problems, got '{self.multi_class}'."
932943
)
933944
self._fit_multiclass(X_aug, y, sample_weight)
945+
self.omega = omega_copy
934946

935947
return self
936948

@@ -1067,6 +1079,11 @@ class plq_ElasticNet_Regressor(plqERM_ElasticNet, RegressorMixin):
10671079
- l1_ratio = 0 → pure Ridge (equivalent to plq_Ridge_Regressor)
10681080
- 0 < l1_ratio < 1 → combined L1 + L2 penalty
10691081
Must be strictly less than 1.0 to avoid division by zero in rho/C_eff.
1082+
1083+
omega : array of shape (n_features, ), default=np.empty(shape=(0, 0))
1084+
Non-negative weight coefficients for adaptive lasso. If not provided, all non-intercept coefficients
1085+
receive the same L1 penalty controlled by ``l1_ratio``. The penalty for the intercept
1086+
can be scaled via ``intercept_scaling``.
10701087
10711088
fit_intercept : bool, default=True
10721089
If True, append a constant column (value = ``intercept_scaling``) to
@@ -1101,6 +1118,7 @@ def __init__(
11011118
constraint=None,
11021119
C=1.0,
11031120
l1_ratio=0.5,
1121+
omega=None,
11041122
U=None,
11051123
V=None,
11061124
Tau=None,
@@ -1125,6 +1143,7 @@ def __init__(
11251143

11261144
loss = {"name": "QR", "qt": 0.5} if loss is None else loss
11271145
constraint = [] if constraint is None else constraint
1146+
omega = np.empty((0,)) if omega is None else omega
11281147
U = np.empty((0, 0)) if U is None else U
11291148
V = np.empty((0, 0)) if V is None else V
11301149
Tau = np.empty((0, 0)) if Tau is None else Tau
@@ -1138,6 +1157,7 @@ def __init__(
11381157
constraint=constraint,
11391158
C=C,
11401159
l1_ratio=l1_ratio,
1160+
omega=omega,
11411161
U=U,
11421162
V=V,
11431163
Tau=Tau,
@@ -1183,12 +1203,15 @@ def fit(self, X, y, sample_weight=None):
11831203
self.n_features_in_ = X.shape[1]
11841204

11851205
X_aug = X
1206+
omega_copy = self.omega.copy()
11861207
if self.fit_intercept:
11871208
col = np.full((X.shape[0], 1), self.intercept_scaling, dtype=X.dtype)
11881209
X_aug = np.hstack([X, col])
1210+
self.omega = np.append(self.omega, 1) if self.omega.size > 0 else self.omega
11891211

11901212
# MRO resolves super() to plqERM_ElasticNet.fit()
11911213
super().fit(X_aug, y, sample_weight=sample_weight)
1214+
self.omega = omega_copy
11921215

11931216
if self.fit_intercept:
11941217
self.intercept_ = float(self.coef_[-1])

tests/test_elastic_net.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,8 +317,8 @@ def test_omega_validation():
317317
tol=1e-4,
318318
)
319319
clf.fit(X_scaled, y)
320-
# Test invalid omega value (all elements must be strictly positive)
321-
with pytest.raises(ValueError, match="All elements in omega must be strictly positive"):
320+
# Test invalid omega value (all elements must be strictly non-negative)
321+
with pytest.raises(ValueError, match="All elements in omega must be strictly non-negative"):
322322
omega = np.ones(n_features)
323323
omega[0] = -1
324324
clf = plqERM_ElasticNet(
@@ -341,3 +341,39 @@ def test_omega_validation():
341341
tol=1e-4,
342342
)
343343
clf.fit(X_scaled, y)
344+
345+
346+
def test_zero_omega_vs_ridge():
347+
"""ElasticNet with omega=(0, 0, ..., 0) should exactly match Ridge within 1e-4.."""
348+
n, n_features, C, l1_ratio = 2000, 10, 0.01, 0.5
349+
350+
X, y = make_regression(
351+
n_samples=n,
352+
n_features=n_features,
353+
noise=0.1,
354+
random_state=42,
355+
n_informative=6,
356+
)
357+
scaler = StandardScaler()
358+
X_scaled = scaler.fit_transform(X)
359+
360+
clf_EN = plqERM_ElasticNet(
361+
loss={"name": "mse"},
362+
C=C,
363+
l1_ratio=l1_ratio,
364+
omega=np.zeros(n_features),
365+
max_iter=5000,
366+
tol=1e-4,
367+
)
368+
clf_EN.fit(X_scaled, y)
369+
370+
clf_RG = plqERM_Ridge(
371+
loss={"name": "mse"},
372+
C=C/(1-l1_ratio),
373+
max_iter=5000,
374+
tol=1e-4,
375+
)
376+
clf_RG.fit(X_scaled, y)
377+
378+
max_diff = np.max(np.abs(clf_EN.coef_.flatten() - clf_RG.coef_.flatten()))
379+
assert max_diff < 1e-4, f"ElasticNet(omega=(0, 0, ..., 0)) should match Ridge within 1e-4, max_diff={max_diff:.6e}"

tests/test_sklearn_elasticnet.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,19 @@ def test_elasticnet_clf_l1_ratio_invalid_raises():
101101
with pytest.raises(ValueError, match="l1_ratio"):
102102
plq_ElasticNet_Classifier(loss={"name": "svm"}, C=1.0, l1_ratio=1.0)
103103

104+
def test_elasticnet_clf_binary_omega_effect():
105+
"""Model coefficient with higher omega weights should be smaller."""
106+
X, y = _binary_dataset()
107+
omega_small = np.random.rand(10)
108+
omega_large = omega_small * 5
109+
110+
clf1 = plq_ElasticNet_Classifier(loss={"name": "svm"}, C=1.0, l1_ratio=0.5, omega=omega_small)
111+
clf1.fit(X, y)
112+
clf2 = plq_ElasticNet_Classifier(loss={"name": "svm"}, C=1.0, l1_ratio=0.5, omega=omega_large)
113+
clf2.fit(X, y)
114+
115+
assert np.sum(np.abs(clf2.coef_)) <= np.sum(np.abs(clf1.coef_))
116+
104117

105118
# ===========================================================================
106119
# plq_ElasticNet_Classifier — multiclass OvR
@@ -142,6 +155,32 @@ def test_elasticnet_clf_ovr_pipeline():
142155
assert pipe.predict(X).shape == (len(y),)
143156

144157

158+
def test_elasticnet_clf_ovr_omega_effect():
159+
"""Model coefficient with higher omega weights should be smaller."""
160+
X, y = _multiclass_dataset(n_classes=3)
161+
omega_small = np.random.rand(10)
162+
omega_large = omega_small * 5
163+
164+
clf1 = plq_ElasticNet_Classifier(loss={"name": "svm"},
165+
C=1.0,
166+
l1_ratio=0.5,
167+
fit_intercept=True,
168+
omega=omega_small,
169+
multi_class="ovr"
170+
)
171+
clf1.fit(X, y)
172+
clf2 = plq_ElasticNet_Classifier(loss={"name": "svm"},
173+
C=1.0,
174+
l1_ratio=0.5,
175+
fit_intercept=True,
176+
omega=omega_large,
177+
multi_class="ovr"
178+
)
179+
clf2.fit(X, y)
180+
181+
assert np.sum(np.abs(clf2.coef_)) <= np.sum(np.abs(clf1.coef_))
182+
183+
145184
# ===========================================================================
146185
# plq_ElasticNet_Classifier — multiclass OvO
147186
# ===========================================================================
@@ -179,6 +218,31 @@ def test_elasticnet_clf_multiclass_invalid_strategy_raises():
179218
clf.fit(X, y)
180219

181220

221+
def test_elasticnet_clf_ovo_omega_effect():
222+
"""Model coefficient with higher omega weights should be smaller."""
223+
X, y = _multiclass_dataset(n_classes=3)
224+
omega_small = np.random.rand(10)
225+
omega_large = omega_small * 5
226+
227+
clf1 = plq_ElasticNet_Classifier(loss={"name": "svm"},
228+
C=1.0,
229+
l1_ratio=0.5,
230+
fit_intercept=False,
231+
omega=omega_small,
232+
multi_class="ovo"
233+
)
234+
clf1.fit(X, y)
235+
clf2 = plq_ElasticNet_Classifier(loss={"name": "svm"},
236+
C=1.0,
237+
l1_ratio=0.5,
238+
fit_intercept=False,
239+
omega=omega_large,
240+
multi_class="ovo"
241+
)
242+
clf2.fit(X, y)
243+
244+
assert np.sum(np.abs(clf2.coef_)) <= np.sum(np.abs(clf1.coef_))
245+
182246
# ===========================================================================
183247
# plq_ElasticNet_Regressor
184248
# ===========================================================================
@@ -256,3 +320,16 @@ def test_elasticnet_reg_predict_equals_decision_function():
256320
reg = plq_ElasticNet_Regressor(loss={"name": "QR", "qt": 0.5}, C=1.0, l1_ratio=0.5)
257321
reg.fit(X_tr, y_tr)
258322
np.testing.assert_array_equal(reg.predict(X_te), reg.decision_function(X_te))
323+
324+
def test_elasticnet_reg_omega_effect():
325+
"""Model coefficient with higher omega weights should be smaller."""
326+
X, y = _reg_dataset()
327+
omega_small = np.random.rand(10)
328+
omega_large = omega_small * 5
329+
330+
reg1 = plq_ElasticNet_Regressor(loss={"name": "mae"}, C=1.0, l1_ratio=0.5, omega=omega_small)
331+
reg1.fit(X, y)
332+
reg2 = plq_ElasticNet_Regressor(loss={"name": "mae"}, C=1.0, l1_ratio=0.5, omega=omega_large)
333+
reg2.fit(X, y)
334+
335+
assert np.sum(np.abs(reg2.coef_)) <= np.sum(np.abs(reg1.coef_))

tests/test_warmstart.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import numpy as np
1010

11-
from rehline import ReHLine, plqERM_Ridge
11+
from rehline import ReHLine, plqERM_Ridge, plqERM_ElasticNet
1212
from rehline._base import ReHLine_solver
1313

1414

@@ -140,3 +140,38 @@ def test_plqERM_Ridge_warmstart_coef_consistent():
140140
atol=1e-3,
141141
err_msg="plqERM_Ridge: warm-start and cold-start should agree at the same C",
142142
)
143+
144+
145+
# ---------------------------------------------------------------------------
146+
# plqERM_ElasticNet
147+
# ---------------------------------------------------------------------------
148+
149+
150+
def test_plqERM_ElasticNet_warmstart_coef_consistent():
151+
"""Warm-started plqERM_ElasticNet should match cold-start solution for the same C."""
152+
X, y = _make_classification_data()
153+
C = 0.5
154+
l1_ratio = 0.2
155+
156+
clf_cold = plqERM_ElasticNet(loss={"name": "svm"}, C=C, l1_ratio=l1_ratio, verbose=0)
157+
clf_cold.fit(X=X, y=y)
158+
159+
# Fit at C, then warm-start at 2*C
160+
clf_warm = plqERM_ElasticNet(loss={"name": "svm"}, C=C, l1_ratio=l1_ratio, verbose=0)
161+
clf_warm.fit(X=X, y=y)
162+
clf_warm.C = 2 * C
163+
clf_warm.warm_start = 1
164+
clf_warm.fit(X=X, y=y)
165+
coef_warm_2C = clf_warm.coef_.copy()
166+
167+
# Reference: cold-start at 2*C
168+
clf_ref = plqERM_ElasticNet(loss={"name": "svm"}, C=2 * C, l1_ratio=l1_ratio, verbose=0)
169+
clf_ref.fit(X=X, y=y)
170+
coef_ref_2C = clf_ref.coef_.copy()
171+
172+
np.testing.assert_allclose(
173+
coef_warm_2C,
174+
coef_ref_2C,
175+
atol=1e-3,
176+
err_msg="plqERM_ElasticNet: warm-start and cold-start should agree at the same C",
177+
)

0 commit comments

Comments
 (0)