Skip to content

Commit 575ffbb

Browse files
implemented lipschitz for dense case, support for AndersonCD and adressed feedback comments
1 parent e4888d9 commit 575ffbb

File tree

2 files changed

+82
-62
lines changed

2 files changed

+82
-62
lines changed

examples/plot_smooth_quantile.py

Lines changed: 31 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -16,43 +16,18 @@ def pinball_loss(residuals, quantile):
1616
return np.mean(residuals * (quantile - (residuals < 0)))
1717

1818

19-
def create_data(n_samples=1000, n_features=10, noise=0.1):
20-
X, y = make_regression(n_samples=n_samples, n_features=n_features, noise=noise)
21-
return X, y
22-
23-
24-
def plot_quantile_huber():
25-
quantiles = [0.1, 0.5, 0.9]
26-
delta = 0.5
27-
residuals = np.linspace(-3, 3, 500)
28-
_, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
29-
for tau in quantiles:
30-
qh = QuantileHuber(quantile=tau, delta=delta)
31-
loss = [qh._loss_scalar(r) for r in residuals]
32-
grad = [qh._grad_scalar(r) for r in residuals]
33-
ax1.plot(residuals, loss, label=f"τ={tau}")
34-
ax2.plot(residuals, grad, label=f"τ={tau}")
35-
ax1.set_title("QuantileHuber Loss")
36-
ax1.set_xlabel("Residual")
37-
ax1.set_ylabel("Loss")
38-
ax1.legend()
39-
ax2.set_title("QuantileHuber Gradient")
40-
ax2.set_xlabel("Residual")
41-
ax2.set_ylabel("Gradient")
42-
ax2.legend()
43-
plt.tight_layout()
44-
plt.show()
45-
46-
4719
if __name__ == "__main__":
48-
X, y = create_data()
20+
X, y = make_regression(n_samples=10000, n_features=1000, noise=0.1, random_state=0)
4921
tau = 0.8
22+
X_c = X - X.mean(axis=0)
23+
q_tau = np.quantile(y, tau)
24+
y_c = y - q_tau
5025

5126
start = time.time()
5227
sk = QuantileRegressor(quantile=tau, alpha=0.1, fit_intercept=False)
53-
sk.fit(X, y)
28+
sk.fit(X_c, y_c)
29+
sk_pred = sk.predict(X_c) + q_tau
5430
sk_time = time.time() - start
55-
sk_pred = sk.predict(X)
5631
sk_cov = np.mean(y <= sk_pred)
5732
sk_pinball = pinball_loss(y - sk_pred, tau)
5833

@@ -61,13 +36,14 @@ def plot_quantile_huber():
6136
quantile=tau,
6237
alpha=0.1,
6338
delta_init=0.5,
64-
delta_final=0.05,
39+
delta_final=0.01,
6540
n_deltas=5,
41+
solver="AndersonCD",
6642
verbose=True
6743
)
68-
qh.fit(X, y)
44+
qh.fit(X_c, y_c)
6945
qh_time = time.time() - start
70-
qh_pred = qh.predict(X)
46+
qh_pred = qh.predict(X_c) + q_tau
7147
qh_cov = np.mean(y <= qh_pred)
7248
qh_pinball = pinball_loss(y - qh_pred, tau)
7349

@@ -79,4 +55,24 @@ def plot_quantile_huber():
7955
print(f"{'QuantileHuber':<12} {tau:<4} {qh_cov:<8.3f} {qh_time:<6.3f} "
8056
f"{qh_pinball:<8.4f}")
8157

82-
plot_quantile_huber()
58+
59+
quantiles = [0.1, 0.5, 0.9]
60+
delta = 0.5
61+
residuals = np.linspace(-3, 3, 500)
62+
_, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
63+
for tau in quantiles:
64+
qh = QuantileHuber(quantile=tau, delta=delta)
65+
loss = [qh._loss_sample(r) for r in residuals]
66+
grad = [qh._grad_per_sample(r) for r in residuals]
67+
ax1.plot(residuals, loss, label=f"τ={tau}")
68+
ax2.plot(residuals, grad, label=f"τ={tau}")
69+
ax1.set_title("QuantileHuber Loss")
70+
ax1.set_xlabel("Residual")
71+
ax1.set_ylabel("Loss")
72+
ax1.legend()
73+
ax2.set_title("QuantileHuber Gradient")
74+
ax2.set_xlabel("Residual")
75+
ax2.set_ylabel("Gradient")
76+
ax2.legend()
77+
plt.tight_layout()
78+
plt.show()

skglm/experimental/quantile_huber.py

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import numpy as np
2+
from numpy.linalg import norm
23
from numba import float64
3-
from skglm.datafits.single_task import Huber
4+
from skglm.datafits.base import BaseDatafit
45
from sklearn.base import BaseEstimator, RegressorMixin
5-
from sklearn.utils.validation import check_X_y, check_array
6-
from skglm.solvers import FISTA
6+
from skglm.solvers import FISTA, AndersonCD
77
from skglm.penalties import L1
88
from skglm.estimators import GeneralizedLinearEstimator
99

1010

11-
class QuantileHuber(Huber):
11+
class QuantileHuber(BaseDatafit):
1212
r"""Quantile Huber loss for quantile regression.
1313
1414
Implements the smoothed pinball loss:
@@ -51,11 +51,11 @@ def value(self, y, w, Xw):
5151
res = 0.0
5252
for i in range(n_samples):
5353
residual = y[i] - Xw[i]
54-
res += self._loss_scalar(residual)
54+
res += self._loss_sample(residual)
5555
return res / n_samples
5656

57-
def _loss_scalar(self, residual):
58-
"""Calculate loss for a single residual."""
57+
def _loss_sample(self, residual):
58+
"""Calculate loss for a single sample."""
5959
tau = self.quantile
6060
delta = self.delta
6161
r = residual
@@ -79,11 +79,11 @@ def gradient_scalar(self, X, y, w, Xw, j):
7979
grad_j = 0.0
8080
for i in range(n_samples):
8181
residual = y[i] - Xw[i]
82-
grad_j += -X[i, j] * self._grad_scalar(residual)
82+
grad_j += -X[i, j] * self._grad_per_sample(residual)
8383
return grad_j / n_samples
8484

85-
def _grad_scalar(self, residual):
86-
"""Calculate gradient for a single residual."""
85+
def _grad_per_sample(self, residual):
86+
"""Calculate gradient for a single sample."""
8787
tau = self.quantile
8888
delta = self.delta
8989
r = residual
@@ -101,12 +101,26 @@ def _grad_scalar(self, residual):
101101
# Lower linear tail: r <= -delta
102102
return tau - 1
103103

104+
def get_lipschitz(self, X, y):
105+
n_features = X.shape[1]
106+
107+
lipschitz = np.zeros(n_features, dtype=X.dtype)
108+
c = max(self.quantile, 1 - self.quantile) / self.delta
109+
for j in range(n_features):
110+
lipschitz[j] = c * (X[:, j] ** 2).sum() / len(y)
111+
112+
return lipschitz
113+
114+
def get_global_lipschitz(self, X, y):
115+
c = max(self.quantile, 1 - self.quantile) / self.delta
116+
return c * norm(X, ord=2) ** 2 / len(y)
117+
104118

105119
class SmoothQuantileRegressor(BaseEstimator, RegressorMixin):
106120
"""Quantile regression with progressive smoothing."""
107121

108122
def __init__(self, quantile=0.5, alpha=0.1, delta_init=1.0, delta_final=1e-3,
109-
n_deltas=10, max_iter=1000, tol=1e-4, verbose=False):
123+
n_deltas=10, max_iter=1000, tol=1e-4, verbose=False, solver="FISTA"):
110124
self.quantile = quantile
111125
self.alpha = alpha
112126
self.delta_init = delta_init
@@ -115,10 +129,10 @@ def __init__(self, quantile=0.5, alpha=0.1, delta_init=1.0, delta_final=1e-3,
115129
self.max_iter = max_iter
116130
self.tol = tol
117131
self.verbose = verbose
132+
self.solver = solver
118133

119134
def fit(self, X, y):
120135
"""Fit using progressive smoothing: delta_init --> delta_final."""
121-
X, y = check_X_y(X, y)
122136
w = np.zeros(X.shape[1])
123137
deltas = np.geomspace(self.delta_init, self.delta_final, self.n_deltas)
124138

@@ -127,19 +141,26 @@ def fit(self, X, y):
127141
f"Progressive smoothing: delta {self.delta_init:.3f} --> "
128142
f"{self.delta_final:.3f} in {self.n_deltas} steps")
129143

130-
for i, delta in enumerate(deltas):
131-
datafit = QuantileHuber(quantile=self.quantile, delta=delta)
132-
penalty = L1(alpha=self.alpha)
133-
solver = FISTA(max_iter=self.max_iter, tol=self.tol)
144+
datafit = QuantileHuber(quantile=self.quantile, delta=self.delta_init)
145+
penalty = L1(alpha=self.alpha)
146+
# Solver selection
147+
if isinstance(self.solver, str):
148+
if self.solver == "FISTA":
149+
solver = FISTA(max_iter=self.max_iter, tol=self.tol)
150+
solver.warm_start = True
151+
elif self.solver == "AndersonCD":
152+
solver = AndersonCD(max_iter=self.max_iter, tol=self.tol,
153+
warm_start=True, fit_intercept=False)
154+
else:
155+
raise ValueError(f"Unknown solver: {self.solver}")
156+
else:
157+
solver = self.solver
134158

135-
est = GeneralizedLinearEstimator(
136-
datafit=datafit,
137-
penalty=penalty,
138-
solver=solver
139-
)
159+
est = GeneralizedLinearEstimator(
160+
datafit=datafit, penalty=penalty, solver=solver)
140161

141-
if i > 0:
142-
est.coef_ = w.copy()
162+
for i, delta in enumerate(deltas):
163+
datafit.delta = float(delta)
143164

144165
est.fit(X, y)
145166
w = est.coef_.copy()
@@ -151,13 +172,16 @@ def fit(self, X, y):
151172

152173
print(
153174
f" Stage {i+1:2d}: delta={delta:.4f}, "
154-
f"coverage={coverage:.3f}, pinball_loss={pinball_loss:.6f}")
175+
f"coverage={coverage:.3f}, pinball_loss={pinball_loss:.6f}, "
176+
f"n_iter={est.n_iter_}"
177+
)
155178

156-
self.coef_ = w
179+
self.est = est
157180

158181
return self
159182

160183
def predict(self, X):
161184
"""Predict using the fitted model."""
162-
X = check_array(X)
163-
return X @ self.coef_
185+
if not hasattr(self, "est"):
186+
raise ValueError("Call 'fit' before 'predict'.")
187+
return self.est.predict(X)

0 commit comments

Comments
 (0)