Skip to content

Commit 010c399

Browse files
make basic version without intercept handling and progressive smoothing
1 parent 21f1459 commit 010c399

File tree

4 files changed

+154
-218
lines changed

4 files changed

+154
-218
lines changed

examples/plot_smooth_quantile.py

Lines changed: 60 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,75 @@
11
"""
2-
===========================================
3-
Smooth Quantile Regression Example
4-
===========================================
5-
2+
QuantileHuber vs Sklearn
63
"""
7-
84
import numpy as np
9-
import matplotlib.pyplot as plt
105
import time
11-
from sklearn.datasets import make_regression
12-
from sklearn.preprocessing import StandardScaler
136
from sklearn.linear_model import QuantileRegressor
14-
from skglm.experimental.smooth_quantile_regressor import SmoothQuantileRegressor
15-
from skglm.experimental.quantile_huber import QuantileHuber
7+
from skglm.experimental.quantile_huber import QuantileHuber, SimpleQuantileRegressor
8+
import matplotlib.pyplot as plt
9+
from sklearn.datasets import make_regression
10+
11+
# TODO: no smoothing and no intercept handling yet
12+
13+
14+
def pinball_loss(residuals, quantile):
15+
"""True pinball loss."""
16+
return np.mean(residuals * (quantile - (residuals < 0)))
1617

17-
X, y = make_regression(n_samples=1000, n_features=10, noise=0.1, random_state=42)
18-
X = StandardScaler().fit_transform(X)
19-
tau = 0.75
2018

21-
t0 = time.time()
22-
reg_skglm = SmoothQuantileRegressor(quantile=tau).fit(X, y)
23-
t1 = time.time()
24-
reg_sklearn = QuantileRegressor(quantile=tau, alpha=0.1, solver='highs').fit(X, y)
25-
t2 = time.time()
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
2622

27-
y_pred_skglm, y_pred_sklearn = reg_skglm.predict(X), reg_sklearn.predict(X)
28-
coverage_skglm = np.mean(y <= y_pred_skglm)
29-
coverage_sklearn = np.mean(y <= y_pred_sklearn)
3023

31-
print(f"\nTiming: skglm={t1-t0:.3f}s, sklearn={t2-t1:.3f}s, "
32-
f"speedup={(t2-t1)/(t1-t0):.1f}x")
33-
print(f"Coverage (target {tau}): skglm={coverage_skglm:.3f}, "
34-
f"sklearn={coverage_sklearn:.3f}")
35-
print(f"Non-zero coefs: skglm={np.sum(reg_skglm.coef_ != 0)}, "
36-
f"sklearn={np.sum(reg_sklearn.coef_ != 0)}")
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+
fig, (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()
3745

3846

39-
# Visualizations
40-
def pinball(y_true, y_pred):
41-
diff = y_true - y_pred
42-
return np.mean(np.where(diff >= 0, tau * diff, (1 - tau) * -diff))
47+
if __name__ == "__main__":
48+
X, y = create_data()
49+
tau = 0.8
4350

51+
start = time.time()
52+
sk = QuantileRegressor(quantile=tau, alpha=0.001, fit_intercept=False)
53+
sk.fit(X, y)
54+
sk_time = time.time() - start
55+
sk_pred = sk.predict(X)
56+
sk_cov = np.mean(y <= sk_pred)
57+
sk_pinball = pinball_loss(y - sk_pred, tau)
4458

45-
print(f"Pinball loss: skglm={pinball(y, y_pred_skglm):.4f}, "
46-
f"sklearn={pinball(y, y_pred_sklearn):.4f}")
59+
start = time.time()
60+
qh = SimpleQuantileRegressor(quantile=tau, alpha=0.001, delta=0.05)
61+
qh.fit(X, y)
62+
qh_time = time.time() - start
63+
qh_pred = qh.predict(X)
64+
qh_cov = np.mean(y <= qh_pred)
65+
qh_pinball = pinball_loss(y - qh_pred, tau)
4766

48-
plt.figure(figsize=(12, 5))
49-
plt.subplot(121)
50-
residuals = np.linspace(-2, 2, 1000)
51-
for delta in [1.0, 0.5, 0.1]:
52-
loss = QuantileHuber(quantile=tau, delta=delta)
53-
losses = [loss.value(np.array([r]), np.array([[1]]), np.array([0]))
54-
for r in residuals]
55-
plt.plot(residuals, losses, label=f'δ={delta}')
56-
plt.plot(residuals, [tau * max(r, 0) + (1 - tau) * max(-r, 0)
57-
for r in residuals], 'k--', label='Pinball')
58-
plt.axvline(x=0, color='k', linestyle='--', alpha=0.3)
59-
plt.xlabel('Residual (y - y_pred)')
60-
plt.ylabel('Loss')
61-
plt.title('Quantile Huber Loss (τ=0.75)')
62-
plt.legend()
63-
plt.grid(True, alpha=0.3)
67+
print(f"{'Method':<12} {'Q':<4} {'Coverage':<8} {'Time':<6} "
68+
f"{'Pinball':<8}")
69+
print("-" * 55)
70+
print(f"{'Sklearn':<12} {tau:<4} {sk_cov:<8.3f} {sk_time:<6.3f} "
71+
f"{sk_pinball:<8.4f}")
72+
print(f"{'QuantileHuber':<12} {tau:<4} {qh_cov:<8.3f} {qh_time:<6.3f} "
73+
f"{qh_pinball:<8.4f}")
6474

65-
plt.subplot(122)
66-
plt.hist(y - y_pred_skglm, bins=50, alpha=0.5, label='skglm')
67-
plt.hist(y - y_pred_sklearn, bins=50, alpha=0.5, label='sklearn')
68-
plt.axvline(0, color='k', linestyle='--')
69-
plt.xlabel('Residual (y - y_pred)')
70-
plt.ylabel('Count')
71-
plt.title('Residuals Histogram')
72-
plt.legend()
73-
plt.grid(True, alpha=0.3)
74-
plt.tight_layout()
75-
plt.show()
75+
plot_quantile_huber()

skglm/experimental/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
from .sqrt_lasso import SqrtLasso, SqrtQuadratic
33
from .pdcd_ws import PDCD_WS
44
from .quantile_regression import Pinball
5-
from .quantile_huber import QuantileHuber
6-
from .smooth_quantile_regressor import SmoothQuantileRegressor
5+
from .quantile_huber import QuantileHuber, SimpleQuantileRegressor
76

87
__all__ = [
98
IterativeReweightedL1,
@@ -12,5 +11,5 @@
1211
SqrtQuadratic,
1312
SqrtLasso,
1413
QuantileHuber,
15-
SmoothQuantileRegressor,
14+
SimpleQuantileRegressor,
1615
]
Lines changed: 92 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
import numpy as np
21
from numba import float64
32
from skglm.datafits.single_task import Huber
4-
from skglm.utils.sparse_ops import spectral_norm
3+
from sklearn.base import BaseEstimator, RegressorMixin
4+
from sklearn.utils.validation import check_X_y, check_array
5+
from skglm.solvers import FISTA
6+
from skglm.penalties import L1
7+
from skglm.estimators import GeneralizedLinearEstimator
58

69

710
class QuantileHuber(Huber):
811
r"""Quantile Huber loss for quantile regression.
912
10-
Implements the smoothed pinball loss with quadratic region:
13+
Implements the smoothed pinball loss:
1114
1215
.. math::
1316
@@ -25,17 +28,13 @@ class QuantileHuber(Huber):
2528
Desired quantile level between 0 and 1.
2629
delta : float, default=1.0
2730
Width of quadratic region.
28-
29-
References
30-
----------
31-
Chen, C. (2007). A Finite Smoothing Algorithm for Quantile Regression.
32-
Journal of Computational and Graphical Statistics, 16(1), 136–164.
33-
http://www.jstor.org/stable/27594233
3431
"""
3532

3633
def __init__(self, quantile=0.5, delta=1.0):
3734
if not 0 < quantile < 1:
3835
raise ValueError("quantile must be between 0 and 1")
36+
if delta <= 0:
37+
raise ValueError("delta must be positive")
3938
self.delta = float(delta)
4039
self.quantile = float(quantile)
4140

@@ -45,80 +44,93 @@ def get_spec(self):
4544
def params_to_dict(self):
4645
return dict(delta=self.delta, quantile=self.quantile)
4746

48-
def _loss_and_grad_scalar(self, residual):
49-
"""Calculate loss and gradient for a single residual."""
47+
def value(self, y, w, Xw):
48+
"""Compute the quantile Huber loss value."""
49+
n_samples = len(y)
50+
res = 0.0
51+
for i in range(n_samples):
52+
residual = y[i] - Xw[i]
53+
res += self._loss_scalar(residual)
54+
return res / n_samples
55+
56+
def _loss_scalar(self, residual):
57+
"""Calculate loss for a single residual."""
5058
tau = self.quantile
5159
delta = self.delta
52-
abs_r = abs(residual)
53-
54-
# Quadratic core: |r| ≤ delta
55-
if abs_r <= delta:
56-
if residual >= 0:
57-
# 0 ≤ r ≤ delta
58-
loss = tau * residual**2 / (2 * delta)
59-
grad = tau * residual / delta
60-
else:
61-
# -delta ≤ r < 0
62-
loss = (1 - tau) * residual**2 / (2 * delta)
63-
grad = (1 - tau) * residual / delta
64-
return loss, grad
65-
66-
# Linear tails: |r| > delta
67-
if residual > delta:
68-
loss = tau * (residual - delta / 2)
69-
grad = tau
70-
return loss, grad
60+
r = residual
61+
62+
if r >= delta:
63+
# Upper linear tail: r >= delta
64+
return tau * (r - delta/2)
65+
elif r >= 0:
66+
# Upper quadratic: 0 <= r < delta
67+
return tau * r**2 / (2 * delta)
68+
elif r > -delta:
69+
# Lower quadratic: -delta < r < 0
70+
return (1 - tau) * r**2 / (2 * delta)
7171
else:
72-
loss = (1 - tau) * (-residual - delta / 2)
73-
grad = tau - 1
74-
return loss, grad
72+
# Lower linear tail: r <= -delta
73+
return (1 - tau) * (-r - delta/2)
7574

76-
def value(self, y, w, Xw):
77-
"""Compute the quantile Huber loss value."""
78-
residuals = y - Xw
79-
loss = np.zeros_like(residuals)
80-
for i, r in enumerate(residuals):
81-
loss[i], _ = self._loss_and_grad_scalar(r)
82-
return np.mean(loss)
83-
84-
def raw_grad(self, y, Xw):
85-
"""Compute gradient of datafit w.r.t Xw."""
86-
residuals = y - Xw
87-
grad = np.zeros_like(residuals)
88-
for i, r in enumerate(residuals):
89-
_, grad[i] = self._loss_and_grad_scalar(r)
90-
return -grad
91-
92-
def get_lipschitz(self, X, y):
93-
"""Compute coordinate-wise Lipschitz constants."""
94-
weight = max(self.quantile, 1 - self.quantile)
95-
return weight * (X ** 2).sum(axis=0) / (len(y) * self.delta)
96-
97-
def get_global_lipschitz(self, X, y):
98-
"""Compute global Lipschitz constant."""
99-
weight = max(self.quantile, 1 - self.quantile)
100-
return weight * np.linalg.norm(X, 2) ** 2 / (len(y) * self.delta)
101-
102-
def get_lipschitz_sparse(self, X_data, X_indptr, X_indices, y):
103-
"""Compute coordinate-wise Lipschitz constants for sparse X."""
104-
n_samples = len(y)
105-
weight = max(self.quantile, 1 - self.quantile)
106-
n_features = len(X_indptr) - 1
107-
lipschitz = np.zeros(n_features, dtype=X_data.dtype)
108-
for j in range(n_features):
109-
nrm2 = 0.0
110-
for idx in range(X_indptr[j], X_indptr[j + 1]):
111-
nrm2 += X_data[idx] ** 2
112-
lipschitz[j] = weight * nrm2 / (n_samples * self.delta)
113-
return lipschitz
114-
115-
def get_global_lipschitz_sparse(self, X_data, X_indptr, X_indices, y):
116-
"""Compute global Lipschitz constant for sparse X."""
75+
def gradient_scalar(self, X, y, w, Xw, j):
76+
"""Compute gradient w.r.t. w_j - following parent class pattern."""
11777
n_samples = len(y)
118-
weight = max(self.quantile, 1 - self.quantile)
119-
return weight * spectral_norm(
120-
X_data, X_indptr, X_indices, n_samples
121-
) ** 2 / (n_samples * self.delta)
78+
grad_j = 0.0
79+
for i in range(n_samples):
80+
residual = y[i] - Xw[i]
81+
grad_j += -X[i, j] * self._grad_scalar(residual)
82+
return grad_j / n_samples
83+
84+
def _grad_scalar(self, residual):
85+
"""Calculate gradient for a single residual."""
86+
tau = self.quantile
87+
delta = self.delta
88+
r = residual
89+
90+
if r >= delta:
91+
# Upper linear tail: r >= delta
92+
return tau
93+
elif r >= 0:
94+
# Upper quadratic: 0 <= r < delta
95+
return tau * r / delta
96+
elif r > -delta:
97+
# Lower quadratic: -delta < r < 0
98+
return (1 - tau) * r / delta
99+
else:
100+
# Lower linear tail: r <= -delta
101+
return tau - 1
102+
103+
104+
class SimpleQuantileRegressor(BaseEstimator, RegressorMixin):
105+
"""Simple quantile regression without progressive smoothing."""
106+
107+
def __init__(self, quantile=0.5, alpha=0.1, delta=0.1, max_iter=1000, tol=1e-4):
108+
self.quantile = quantile
109+
self.alpha = alpha
110+
self.delta = delta
111+
self.max_iter = max_iter
112+
self.tol = tol
113+
114+
def fit(self, X, y):
115+
"""Fit using FISTA with fixed delta."""
116+
X, y = check_X_y(X, y)
117+
118+
datafit = QuantileHuber(quantile=self.quantile, delta=self.delta)
119+
penalty = L1(alpha=self.alpha)
120+
solver = FISTA(max_iter=self.max_iter, tol=self.tol)
121+
122+
est = GeneralizedLinearEstimator(
123+
datafit=datafit,
124+
penalty=penalty,
125+
solver=solver
126+
)
127+
128+
est.fit(X, y)
129+
self.coef_ = est.coef_
130+
131+
return self
122132

123-
def intercept_update_step(self, y, Xw):
124-
return -np.mean(self.raw_grad(y, Xw))
133+
def predict(self, X):
134+
"""Predict using the fitted model."""
135+
X = check_array(X)
136+
return X @ self.coef_

0 commit comments

Comments
 (0)