|
14 | 14 | import jax.lax.linalg as jlinalg |
15 | 15 | import numpy as np |
16 | 16 | import scipy.special as special |
| 17 | +from sklearn.linear_model import Ridge, RidgeCV, ElasticNet, ElasticNetCV |
| 18 | +from sklearn.preprocessing import StandardScaler |
17 | 19 |
|
18 | 20 | from colibri.core import AnalyticFit |
19 | 21 | from colibri.export_results import write_replicas, export_bayes_results |
@@ -151,28 +153,173 @@ def analytic_fit( |
151 | 153 |
|
152 | 154 | t0 = time.time() |
153 | 155 |
|
| 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 | + |
154 | 166 | # Cholesky factorization: S = L L^T |
155 | 167 | # upper False means that we want the lower triangular matrix L |
156 | 168 | L = jla.cholesky(covmat, upper=False) |
157 | 169 |
|
158 | 170 | # 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') |
159 | 172 | Y_tilde = jlinalg.triangular_solve(L, Y, left_side=True, lower=True) |
160 | 173 | X_tilde = jlinalg.triangular_solve(L, X, left_side=True, lower=True) |
161 | 174 |
|
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." |
165 | 188 | ) |
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 |
176 | 323 |
|
177 | 324 | key = jax.random.PRNGKey(analytic_settings["sampling_seed"]) |
178 | 325 |
|
|
0 commit comments