Skip to content

Commit f8154a3

Browse files
committed
.
1 parent 188401d commit f8154a3

4 files changed

Lines changed: 395 additions & 13 deletions

File tree

colibri/analytic_fit.py

Lines changed: 160 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import jax.lax.linalg as jlinalg
1515
import numpy as np
1616
import scipy.special as special
17+
from sklearn.linear_model import Ridge, RidgeCV, ElasticNet, ElasticNetCV
18+
from sklearn.preprocessing import StandardScaler
1719

1820
from colibri.core import AnalyticFit
1921
from colibri.export_results import write_replicas, export_bayes_results
@@ -151,28 +153,173 @@ def analytic_fit(
151153

152154
t0 = time.time()
153155

156+
ridge_alpha = analytic_settings.get("ridge_alpha", 0.0)
157+
ridge_cv_alphas = analytic_settings.get("ridge_cv_alphas", None)
158+
ridge_cv_folds = analytic_settings.get("ridge_cv_folds", 5)
159+
160+
elasticnet_alpha = analytic_settings.get("elasticnet_alpha", 0.0)
161+
elasticnet_l1_ratio = analytic_settings.get("elasticnet_l1_ratio", 0.5)
162+
elasticnet_cv_alphas = analytic_settings.get("elasticnet_cv_alphas", None)
163+
elasticnet_cv_l1_ratios = analytic_settings.get("elasticnet_cv_l1_ratios", None)
164+
elasticnet_cv_folds = analytic_settings.get("elasticnet_cv_folds", 5)
165+
154166
# Cholesky factorization: S = L L^T
155167
# upper False means that we want the lower triangular matrix L
156168
L = jla.cholesky(covmat, upper=False)
157169

158170
# Whiten the problem: Y' = L^-1 Y, X' = L^-1 X
171+
# (X^T Sigma^-1 X) = (X'^T X'), (X^T Sigma^-1 Y) = (X'^T Y')
159172
Y_tilde = jlinalg.triangular_solve(L, Y, left_side=True, lower=True)
160173
X_tilde = jlinalg.triangular_solve(L, X, left_side=True, lower=True)
161174

162-
if jnp.any(jla.eigh(X_tilde.T @ X_tilde)[0] <= 0.0):
163-
raise ValueError(
164-
"The obtained covariance matrix for the analytic solution is not positive definite."
175+
if ridge_cv_alphas is not None or ridge_alpha > 0.0:
176+
# Ridge regression with sklearn StandardScaler for scale-invariant regularisation.
177+
#
178+
# StandardScaler (with_mean=False) divides each column of X_tilde by its
179+
# standard deviation s_j, so the Ridge penalty ||v||^2 on the scaled
180+
# coefficients v treats every parameter equally regardless of feature scale.
181+
# After fitting, we back-transform to the original parameter space:
182+
# sol_mean = v_ridge / s
183+
# sol_covmat = D^{-1} (X_scaled^T X_scaled + alpha I)^{-1} D^{-1}, D = diag(s)
184+
# Equivalent to a Bayesian MAP with Gaussian prior N(0, (1/alpha) I) on v.
185+
log.warning(
186+
"With Ridge regularisation the Bayesian evidence metrics assume a "
187+
"Gaussian prior N(0, (1/alpha)*I) on the scaled parameters rather than a uniform prior."
165188
)
166-
167-
# Compute QR decomposition of X_tilde for numerical stability in the inversion
168-
Q, R = jla.qr(X_tilde)
169-
170-
# NOTE: R is upper triangular in QR decomposition, so we need to set lower=False
171-
sol_mean = jlinalg.triangular_solve(R, Q.T @ Y_tilde, left_side=True, lower=False)
172-
173-
I_R = jnp.eye(R.shape[0])
174-
R_inv = jlinalg.triangular_solve(R, I_R, left_side=True, lower=False)
175-
sol_covmat = R_inv @ R_inv.T
189+
n_params = len(parameters)
190+
X_tilde_np = np.array(X_tilde)
191+
Y_tilde_np = np.array(Y_tilde)
192+
193+
scaler = StandardScaler(with_mean=False)
194+
X_scaled = scaler.fit_transform(X_tilde_np)
195+
col_scales = scaler.scale_ # per-feature standard deviations, shape (n_params,)
196+
197+
if ridge_cv_alphas is not None:
198+
# Select alpha via k-fold cross-validation on the whitened, scaled problem.
199+
log.info(
200+
f"Selecting Ridge alpha via {ridge_cv_folds}-fold CV "
201+
f"over candidates {ridge_cv_alphas}."
202+
)
203+
ridge_cv = RidgeCV(
204+
alphas=ridge_cv_alphas,
205+
cv=ridge_cv_folds,
206+
fit_intercept=False,
207+
)
208+
ridge_cv.fit(X_scaled, Y_tilde_np)
209+
best_alpha = float(ridge_cv.alpha_)
210+
v_ridge = ridge_cv.coef_
211+
log.info(f"RidgeCV selected alpha={best_alpha}.")
212+
else:
213+
best_alpha = ridge_alpha
214+
log.info(f"Using Ridge regression with alpha={best_alpha}.")
215+
ridge = Ridge(alpha=best_alpha, fit_intercept=False)
216+
ridge.fit(X_scaled, Y_tilde_np)
217+
v_ridge = ridge.coef_
218+
219+
# Posterior covariance in the scaled space: (X_scaled^T X_scaled + alpha I)^{-1}
220+
A_scaled = X_scaled.T @ X_scaled + best_alpha * np.eye(n_params)
221+
v_covmat = np.linalg.inv(A_scaled)
222+
223+
# Back-transform to original parameter space
224+
sol_mean = jnp.array(v_ridge / col_scales)
225+
sol_covmat = jnp.array(v_covmat / col_scales[:, None] / col_scales[None, :])
226+
elif elasticnet_alpha > 0.0 or elasticnet_cv_alphas is not None:
227+
# Elastic Net regularisation with sklearn StandardScaler for scale-invariant
228+
# regularisation. As with Ridge we work in the whitened+scaled space so that
229+
# the L1 and L2 penalties treat every feature on equal footing, and we
230+
# back-transform afterwards.
231+
#
232+
# Elastic Net minimises (in the scaled space)
233+
# (1/(2n)) ||Y_tilde - X_scaled v||^2
234+
# + alpha * l1_ratio * ||v||_1
235+
# + 0.5 * alpha * (1 - l1_ratio) * ||v||_2^2
236+
# which has no closed-form posterior covariance, so we use the Laplace
237+
# approximation around the MAP solution v* (see below).
238+
log.warning(
239+
"With Elastic Net regularisation the Bayesian evidence metrics assume a "
240+
"Laplace approximation around the MAP solution; this can be inaccurate "
241+
"for very sparse solutions (many near-zero coefficients)."
242+
)
243+
n_params = len(parameters)
244+
X_tilde_np = np.array(X_tilde)
245+
Y_tilde_np = np.array(Y_tilde)
246+
247+
scaler = StandardScaler(with_mean=False)
248+
X_scaled = scaler.fit_transform(X_tilde_np)
249+
col_scales = scaler.scale_ # per-feature standard deviations, shape (n_params,)
250+
251+
if elasticnet_cv_alphas is not None:
252+
# Select alpha (and possibly l1_ratio) via k-fold CV on the whitened,
253+
# scaled problem.
254+
log.info(
255+
f"Selecting Elastic Net alpha via {elasticnet_cv_folds}-fold CV "
256+
f"over alpha candidates {elasticnet_cv_alphas} "
257+
f"and l1_ratio candidates {elasticnet_cv_l1_ratios}."
258+
)
259+
# ElasticNetCV defaults l1_ratio to 0.5 if None is passed; sklearn requires
260+
# a list/scalar so fall back to the fixed-alpha default.
261+
cv_l1_ratios = (
262+
elasticnet_cv_l1_ratios
263+
if elasticnet_cv_l1_ratios is not None
264+
else elasticnet_l1_ratio
265+
)
266+
enet_cv = ElasticNetCV(
267+
alphas=elasticnet_cv_alphas,
268+
l1_ratio=cv_l1_ratios,
269+
cv=elasticnet_cv_folds,
270+
fit_intercept=False,
271+
)
272+
enet_cv.fit(X_scaled, Y_tilde_np)
273+
best_alpha = float(enet_cv.alpha_)
274+
best_l1_ratio = float(enet_cv.l1_ratio_)
275+
v_enet = enet_cv.coef_
276+
log.info(
277+
f"ElasticNetCV selected alpha={best_alpha}, l1_ratio={best_l1_ratio}."
278+
)
279+
else:
280+
best_alpha = elasticnet_alpha
281+
best_l1_ratio = elasticnet_l1_ratio
282+
log.info(
283+
f"Using Elastic Net regression with alpha={best_alpha}, "
284+
f"l1_ratio={best_l1_ratio}."
285+
)
286+
enet = ElasticNet(
287+
alpha=best_alpha,
288+
l1_ratio=best_l1_ratio,
289+
fit_intercept=False,
290+
)
291+
enet.fit(X_scaled, Y_tilde_np)
292+
v_enet = enet.coef_
293+
294+
# Posterior covariance via the Laplace approximation around v*.
295+
# The Hessian of the Elastic Net objective in the scaled space at v* is
296+
# H = X_scaled^T X_scaled + diag(lambda2 + lambda1 / |v*_i|)
297+
# with lambda1 = alpha * l1_ratio and lambda2 = alpha * (1 - l1_ratio).
298+
# Near-zero coefficients are clamped (|v*_i| < 1e-10 -> 1e-10) to avoid
299+
# division by zero; the penalty there becomes very large, which correctly
300+
# reflects that the L1 term sharply pins these coefficients to zero.
301+
lambda1 = best_alpha * best_l1_ratio
302+
lambda2 = best_alpha * (1.0 - best_l1_ratio)
303+
abs_v = np.maximum(np.abs(v_enet), 1e-10)
304+
diag_penalty = lambda2 + lambda1 / abs_v
305+
H = X_scaled.T @ X_scaled + np.diag(diag_penalty)
306+
v_covmat = np.linalg.inv(H)
307+
308+
# Back-transform to original parameter space (same formula as Ridge)
309+
sol_mean = jnp.array(v_enet / col_scales)
310+
sol_covmat = jnp.array(v_covmat / col_scales[:, None] / col_scales[None, :])
311+
else:
312+
if jnp.any(jla.eigh(X_tilde.T @ X_tilde)[0] <= 0.0):
313+
raise ValueError(
314+
"The obtained covariance matrix for the analytic solution is not positive definite."
315+
)
316+
# OLS: use QR decomposition of X_tilde for numerical stability
317+
Q, R = jla.qr(X_tilde)
318+
# NOTE: R is upper triangular in QR decomposition, so lower=False
319+
sol_mean = jlinalg.triangular_solve(R, Q.T @ Y_tilde, left_side=True, lower=False)
320+
I_R = jnp.eye(R.shape[0])
321+
R_inv = jlinalg.triangular_solve(R, I_R, left_side=True, lower=False)
322+
sol_covmat = R_inv @ R_inv.T
176323

177324
key = jax.random.PRNGKey(analytic_settings["sampling_seed"])
178325

colibri/config.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,14 @@ def parse_analytic_settings(
479479
"n_posterior_samples",
480480
"sampling_seed",
481481
"full_sample_size",
482+
"ridge_alpha",
483+
"ridge_cv_alphas",
484+
"ridge_cv_folds",
485+
"elasticnet_alpha",
486+
"elasticnet_l1_ratio",
487+
"elasticnet_cv_alphas",
488+
"elasticnet_cv_l1_ratios",
489+
"elasticnet_cv_folds",
482490
}
483491

484492
kdiff = settings.keys() - known_keys
@@ -502,6 +510,132 @@ def parse_analytic_settings(
502510
# Set the full sample size
503511
analytic_settings["full_sample_size"] = settings.get("full_sample_size", 1000)
504512

513+
# Ridge regularisation: fixed alpha or CV-selected alpha are mutually exclusive.
514+
ridge_alpha = settings.get("ridge_alpha", 0.0)
515+
ridge_cv_alphas = settings.get("ridge_cv_alphas", None)
516+
517+
if ridge_alpha != 0.0 and ridge_cv_alphas is not None:
518+
raise ConfigError(
519+
"Specify either 'ridge_alpha' (fixed) or 'ridge_cv_alphas' (CV), not both.",
520+
"ridge_alpha",
521+
known_keys,
522+
)
523+
524+
if not isinstance(ridge_alpha, (int, float)) or ridge_alpha < 0:
525+
raise ConfigError(
526+
"ridge_alpha must be a non-negative number.", "ridge_alpha", known_keys
527+
)
528+
analytic_settings["ridge_alpha"] = float(ridge_alpha)
529+
530+
if ridge_cv_alphas is not None:
531+
if (
532+
not isinstance(ridge_cv_alphas, list)
533+
or len(ridge_cv_alphas) < 2
534+
or not all(isinstance(a, (int, float)) and a > 0 for a in ridge_cv_alphas)
535+
):
536+
raise ConfigError(
537+
"ridge_cv_alphas must be a list of at least two positive numbers.",
538+
"ridge_cv_alphas",
539+
known_keys,
540+
)
541+
analytic_settings["ridge_cv_alphas"] = ridge_cv_alphas
542+
543+
ridge_cv_folds = settings.get("ridge_cv_folds", 5)
544+
if not isinstance(ridge_cv_folds, int) or ridge_cv_folds < 2:
545+
raise ConfigError(
546+
"ridge_cv_folds must be an integer >= 2.", "ridge_cv_folds", known_keys
547+
)
548+
analytic_settings["ridge_cv_folds"] = ridge_cv_folds
549+
550+
# Elastic Net regularisation: fixed alpha or CV-selected alpha are mutually
551+
# exclusive, and cannot be combined with Ridge regularisation.
552+
elasticnet_alpha = settings.get("elasticnet_alpha", 0.0)
553+
elasticnet_l1_ratio = settings.get("elasticnet_l1_ratio", 0.5)
554+
elasticnet_cv_alphas = settings.get("elasticnet_cv_alphas", None)
555+
elasticnet_cv_l1_ratios = settings.get("elasticnet_cv_l1_ratios", None)
556+
elasticnet_cv_folds = settings.get("elasticnet_cv_folds", 5)
557+
558+
# Fixed-alpha and CV are mutually exclusive within Elastic Net
559+
if elasticnet_alpha != 0.0 and (
560+
elasticnet_cv_alphas is not None or elasticnet_cv_l1_ratios is not None
561+
):
562+
raise ConfigError(
563+
"Specify either 'elasticnet_alpha' (fixed) or "
564+
"'elasticnet_cv_alphas'/'elasticnet_cv_l1_ratios' (CV), not both.",
565+
"elasticnet_alpha",
566+
known_keys,
567+
)
568+
569+
# Ridge and Elastic Net cannot be mixed
570+
elasticnet_requested = (
571+
elasticnet_alpha != 0.0
572+
or elasticnet_cv_alphas is not None
573+
or elasticnet_cv_l1_ratios is not None
574+
)
575+
ridge_requested = ridge_alpha != 0.0 or ridge_cv_alphas is not None
576+
if elasticnet_requested and ridge_requested:
577+
raise ConfigError(
578+
"Cannot combine Ridge ('ridge_alpha'/'ridge_cv_alphas') with "
579+
"Elastic Net ('elasticnet_alpha'/'elasticnet_cv_alphas'/'elasticnet_cv_l1_ratios').",
580+
"elasticnet_alpha",
581+
known_keys,
582+
)
583+
584+
if not isinstance(elasticnet_alpha, (int, float)) or elasticnet_alpha < 0:
585+
raise ConfigError(
586+
"elasticnet_alpha must be a non-negative number.",
587+
"elasticnet_alpha",
588+
known_keys,
589+
)
590+
analytic_settings["elasticnet_alpha"] = float(elasticnet_alpha)
591+
592+
if (
593+
not isinstance(elasticnet_l1_ratio, (int, float))
594+
or elasticnet_l1_ratio <= 0
595+
or elasticnet_l1_ratio > 1
596+
):
597+
raise ConfigError(
598+
"elasticnet_l1_ratio must be a number in (0, 1].",
599+
"elasticnet_l1_ratio",
600+
known_keys,
601+
)
602+
analytic_settings["elasticnet_l1_ratio"] = float(elasticnet_l1_ratio)
603+
604+
if elasticnet_cv_alphas is not None:
605+
if (
606+
not isinstance(elasticnet_cv_alphas, list)
607+
or len(elasticnet_cv_alphas) < 2
608+
or not all(
609+
isinstance(a, (int, float)) and a > 0 for a in elasticnet_cv_alphas
610+
)
611+
):
612+
raise ConfigError(
613+
"elasticnet_cv_alphas must be a list of at least two positive numbers.",
614+
"elasticnet_cv_alphas",
615+
known_keys,
616+
)
617+
analytic_settings["elasticnet_cv_alphas"] = elasticnet_cv_alphas
618+
619+
if elasticnet_cv_l1_ratios is not None:
620+
if not isinstance(elasticnet_cv_l1_ratios, list) or not all(
621+
isinstance(r, (int, float)) and 0 < r <= 1
622+
for r in elasticnet_cv_l1_ratios
623+
):
624+
raise ConfigError(
625+
"elasticnet_cv_l1_ratios must be a list of numbers in (0, 1].",
626+
"elasticnet_cv_l1_ratios",
627+
known_keys,
628+
)
629+
analytic_settings["elasticnet_cv_l1_ratios"] = elasticnet_cv_l1_ratios
630+
631+
if not isinstance(elasticnet_cv_folds, int) or elasticnet_cv_folds < 2:
632+
raise ConfigError(
633+
"elasticnet_cv_folds must be an integer >= 2.",
634+
"elasticnet_cv_folds",
635+
known_keys,
636+
)
637+
analytic_settings["elasticnet_cv_folds"] = elasticnet_cv_folds
638+
505639
return analytic_settings
506640

507641
def parse_hessian_settings(self, settings):

colibri/tests/test_analytic_fit.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,46 @@ def test_analytic_fit_different_priors(caplog):
165165
)
166166

167167

168+
def test_analytic_fit_elasticnet():
169+
"""
170+
Tests that the analytic fit runs with Elastic Net regularisation
171+
(fixed alpha, l1_ratio).
172+
"""
173+
MOCK_PDF_MODEL.pred_and_pdf_func = lambda xgrid, forward_map: (
174+
lambda params, fkarrs: (params, TEST_PDF_GRID)
175+
)
176+
177+
_pred_data = TEST_FORWARD_MAP_DIS
178+
179+
enet_settings = {
180+
"sampling_seed": 123,
181+
"full_sample_size": 100,
182+
"n_posterior_samples": 10,
183+
"elasticnet_alpha": 0.1,
184+
"elasticnet_l1_ratio": 0.5,
185+
"elasticnet_cv_alphas": None,
186+
"elasticnet_cv_l1_ratios": None,
187+
"elasticnet_cv_folds": 5,
188+
}
189+
190+
result = analytic_fit(
191+
MOCK_CENTRAL_COVMAT_INDEX,
192+
_pred_data,
193+
MOCK_PDF_MODEL,
194+
enet_settings,
195+
TEST_PRIOR_SETTINGS_UNIFORM,
196+
TEST_XGRID,
197+
TEST_FK_ARRAYS,
198+
)
199+
200+
assert isinstance(result, AnalyticFit)
201+
assert result.analytic_specs == enet_settings
202+
assert (
203+
result.resampled_posterior.shape[0] == enet_settings["n_posterior_samples"]
204+
)
205+
assert len(result.param_names) == len(MOCK_PDF_MODEL.param_names)
206+
207+
168208
@patch("colibri.export_results.write_exportgrid")
169209
def test_run_analytic_fit(mock_write_exportgrid, tmp_path):
170210

0 commit comments

Comments
 (0)