pyfector.cv
Cross-validation for hyperparameter selection.
This module provides two entry points used by pyfector.fect:
cv_ife()picks the number of latent factorsrfor the IFE estimator by grid search overr_range = (r_min, r_max).cv_mc()picks the nuclear-norm penaltylamfor the matrix-completion estimator over an automatically generated log-spaced grid (or a user-supplied one).
CV scheme
- Hold-out cells are sampled only from control observations
(
II). Whencv_treat=True(the default) we further restrict the hold-out cells to pre-treatment periods of ever-treated units, so CV mimics the prediction task the estimator faces on treated cells. - For each candidate we fit on the non-masked cells and score on the
masked cells using one of
mspe(mean squared prediction error),gmspe(geometric mean of squared errors), ormad(median absolute deviation). The paper-faithful default iscv_rule="min", which chooses the candidate with the lowest validation score.cv_rule="onepct"is an explicit slack option: it chooses the simplest candidate within 1% of the best score (lowerrfor IFE and higherlamfor MC). - Fold parallelism uses a thread-backed joblib pool so the heavy numpy/BLAS work runs concurrently without pickling.
- Randomness (fold construction) is seeded via
pyfector.backend.make_rng()so CV is reproducible givenseed.
The CV inner loop calls into pyfector.estimators.estimate_ife()
and pyfector.estimators.estimate_mc() with a relaxed convergence
tolerance max(tol, 1e-3) — the same trick R fect uses to keep CV
cheap without affecting the final fit, which always uses the tight
tolerance.
1""" 2Cross-validation for hyperparameter selection. 3 4This module provides two entry points used by :func:`pyfector.fect`: 5 6* :func:`cv_ife` picks the number of latent factors ``r`` for the IFE 7 estimator by grid search over ``r_range = (r_min, r_max)``. 8* :func:`cv_mc` picks the nuclear-norm penalty ``lam`` for the 9 matrix-completion estimator over an automatically generated 10 log-spaced grid (or a user-supplied one). 11 12CV scheme 13--------- 14* Hold-out cells are sampled *only* from control observations 15 (``II``). When ``cv_treat=True`` (the default) we further restrict 16 the hold-out cells to pre-treatment periods of ever-treated units, so 17 CV mimics the prediction task the estimator faces on treated cells. 18* For each candidate we fit on the non-masked cells and score on the 19 masked cells using one of ``mspe`` (mean squared prediction error), 20 ``gmspe`` (geometric mean of squared errors), or ``mad`` (median 21 absolute deviation). The paper-faithful default is 22 ``cv_rule="min"``, which chooses the candidate with the lowest 23 validation score. ``cv_rule="onepct"`` is an explicit slack option: 24 it chooses the simplest candidate within 1% of the best score (lower 25 ``r`` for IFE and higher ``lam`` for MC). 26* Fold parallelism uses a thread-backed joblib pool so the heavy 27 numpy/BLAS work runs concurrently without pickling. 28* Randomness (fold construction) is seeded via :func:`pyfector.backend.make_rng` 29 so CV is reproducible given ``seed``. 30 31The CV inner loop calls into :func:`pyfector.estimators.estimate_ife` 32and :func:`pyfector.estimators.estimate_mc` with a relaxed convergence 33tolerance ``max(tol, 1e-3)`` — the same trick R fect uses to keep CV 34cheap without affecting the final fit, which always uses the tight 35tolerance. 36""" 37 38from __future__ import annotations 39 40import math 41import warnings 42from typing import Literal, NamedTuple 43 44import numpy as np 45from joblib import Parallel, delayed, parallel_config 46 47from .backend import get_backend, to_numpy, to_device, make_rng 48from .estimators import estimate_ife, estimate_mc 49 50 51class CVResult(NamedTuple): 52 """Selected hyperparameters and validation scores.""" 53 best_r: int | None # selected number of factors (IFE) 54 """Selected IFE rank, or None for MC.""" 55 best_lambda: float | None # selected lambda (MC) 56 """Selected MC penalty, or None for IFE.""" 57 scores: dict # {candidate: {criterion: score}} 58 """Validation scores by candidate and criterion.""" 59 all_residuals: dict # {candidate: residuals array} 60 """Validation residuals for each candidate when retained.""" 61 62 63# --------------------------------------------------------------------------- 64# CV sampling 65# --------------------------------------------------------------------------- 66 67def _make_cv_folds( 68 II: np.ndarray, # (T, N) control indicator 69 D: np.ndarray, # (T, N) treatment indicator 70 k: int, 71 cv_prop: float, 72 cv_nobs: int, 73 cv_treat: bool, 74 cv_donut: int, 75 rng, 76) -> list[dict]: 77 """Generate k CV folds by masking control observations. 78 79 Returns list of dicts with keys 'mask' (cells to hide) and 'eval' (cells to score). 80 """ 81 xp = get_backend() 82 folds = [] 83 T, N = II.shape 84 85 # Eligible cells: observed control cells 86 if cv_treat: 87 # Only mask pre-treatment cells of treated units 88 ever_treated = xp.any(D > 0, axis=0) # (N,) 89 eligible = II.copy() 90 eligible[:, ~ever_treated] = 0 91 else: 92 eligible = II.copy() 93 94 # Total control obs to mask per fold 95 total_eligible = int(xp.sum(eligible)) 96 rm_count = max(1, int(total_eligible * cv_prop)) 97 98 # Get indices of eligible cells 99 II_np = to_numpy(II) 100 D_np = to_numpy(D) 101 eligible_np = to_numpy(eligible) 102 elig_rows, elig_cols = np.where(eligible_np > 0) 103 n_eligible = len(elig_rows) 104 105 rng_np = np.random.default_rng(int(rng.integers(2**31)) if hasattr(rng, 'integers') else 42) 106 107 if n_eligible == 0: 108 raise ValueError("No eligible control observations for cross-validation") 109 110 for fold_i in range(k): 111 mask = np.zeros((T, N), dtype=bool) 112 113 if cv_nobs <= 1 or rm_count <= 2: 114 chosen = rng_np.choice(n_eligible, size=min(rm_count, n_eligible), replace=False) 115 mask[elig_rows[chosen], elig_cols[chosen]] = True 116 else: 117 # Match R fect's block-style CV more closely: hide short 118 # within-unit runs rather than only isolated cells. 119 target = min(rm_count, n_eligible) 120 n_masked = 0 121 while n_masked < target: 122 base = int(rng_np.integers(n_eligible)) 123 row = int(elig_rows[base]) 124 col = int(elig_cols[base]) 125 stop = min(T, row + cv_nobs) 126 rows = np.arange(row, stop) 127 rows = rows[eligible_np[rows, col] > 0] 128 if len(rows) == 0: 129 if not mask[row, col]: 130 mask[row, col] = True 131 n_masked += 1 132 else: 133 new_rows = rows[~mask[rows, col]] 134 if len(new_rows) > 0: 135 mask[new_rows, col] = True 136 n_masked += int(len(new_rows)) 137 138 selected = np.flatnonzero(mask.ravel()) 139 if len(selected) > rm_count: 140 drop = rng_np.choice(selected, size=len(selected) - rm_count, replace=False) 141 mask.ravel()[drop] = False 142 143 # Keep every row and usable unit identifiable after masking. 144 II_after = II_np.copy() 145 II_after[mask] = 0 146 bad_rows = np.where(II_after.sum(axis=1) < 1)[0] 147 if len(bad_rows) > 0: 148 mask[bad_rows, :] = False 149 II_after = II_np.copy() 150 II_after[mask] = 0 151 orig_col_counts = II_np.sum(axis=0) 152 bad_cols = np.where((orig_col_counts > 0) & (II_after.sum(axis=0) < 1))[0] 153 if len(bad_cols) > 0: 154 mask[:, bad_cols] = False 155 156 # Eval indices (same as mask, optionally excluding donut around treatment) 157 eval_mask = mask.copy() 158 if cv_donut > 0: 159 for j in range(N): 160 treat_times = np.where(D_np[:, j] > 0)[0] 161 if len(treat_times) > 0: 162 first_treat = treat_times[0] 163 donut_start = max(0, first_treat - cv_donut) 164 donut_end = min(T, first_treat + cv_donut + 1) 165 eval_mask[donut_start:donut_end, j] = False 166 167 folds.append({ 168 "mask": to_device(mask), 169 "eval": to_device(eval_mask), 170 }) 171 172 return folds 173 174 175# --------------------------------------------------------------------------- 176# Scoring 177# --------------------------------------------------------------------------- 178 179def _score_residuals( 180 residuals: np.ndarray, 181 eval_mask: np.ndarray, 182 count_weights: np.ndarray | None = None, 183) -> dict[str, float]: 184 """Compute CV scoring criteria from residuals at eval cells.""" 185 e = to_numpy(residuals[eval_mask]) 186 n = max(int(e.size), 1) 187 188 mspe = float(np.sum(e ** 2) / n) 189 190 # GMSPE: geometric mean of squared errors 191 e2 = e ** 2 192 e2_pos = e2[e2 > 0] 193 if len(e2_pos) > 0: 194 gmspe = float(np.exp(np.mean(np.log(e2_pos)))) 195 else: 196 gmspe = float("inf") 197 198 # MAD: median absolute deviation 199 mad = float(np.median(np.abs(e))) if len(e) > 0 else float("inf") 200 201 return { 202 "mspe": mspe, 203 "gmspe": gmspe, 204 "mad": mad, 205 } 206 207 208# --------------------------------------------------------------------------- 209# CV for IFE (selecting r) 210# --------------------------------------------------------------------------- 211 212def cv_ife( 213 Y: np.ndarray, 214 Y0: np.ndarray, 215 X: np.ndarray | None, 216 I: np.ndarray, 217 II: np.ndarray, 218 D: np.ndarray, 219 W: np.ndarray | None, 220 beta0: np.ndarray | None, 221 force: int, 222 r_range: tuple[int, int], 223 k: int = 10, 224 cv_prop: float = 0.1, 225 cv_nobs: int = 3, 226 cv_treat: bool = True, 227 cv_donut: int = 0, 228 criterion: str = "mspe", 229 cv_rule: Literal["min", "onepct"] = "min", 230 tol: float = 1e-5, 231 max_iter: int = 1000, 232 n_jobs: int = 1, 233 seed: int | None = None, 234) -> CVResult: 235 """Cross-validate to select number of factors *r*. 236 237 Parameters 238 ---------- 239 r_range : (r_min, r_max) 240 Range of candidate factor numbers. 241 k : int 242 Number of CV folds. 243 cv_rule : {"min", "onepct"} 244 ``"min"`` selects the lowest-score candidate, matching the 245 paper's cross-validation description. ``"onepct"`` selects the 246 lowest rank within 1% of the global best score. 247 n_jobs : int 248 Number of parallel workers for fold evaluation. 249 """ 250 xp = get_backend() 251 rng = make_rng(seed) 252 253 # Looser tolerance for CV (like R fect) 254 cv_tol = max(tol, 1e-3) 255 256 # Generate folds 257 folds = _make_cv_folds(II, D, k, cv_prop, cv_nobs, cv_treat, cv_donut, rng) 258 259 r_candidates = list(range(r_range[0], r_range[1] + 1)) 260 scores = {} 261 262 def _score_candidate(r_cand: int, parallel: Parallel | None = None) -> dict[str, float]: 263 def _run_fold(fold): 264 II_cv = II.copy() 265 II_cv[fold["mask"]] = 0 266 # Re-initialize Y0 for masked cells with simple mean 267 Y0_cv = Y0.copy() 268 if xp.sum(II_cv) > 0: 269 fill_val = xp.sum(Y * II_cv) / (xp.sum(II_cv) + 1e-10) 270 Y0_cv = xp.where(II_cv, Y0_cv, fill_val) 271 272 result = estimate_ife( 273 Y, Y0_cv, X, II_cv, W, beta0, 274 r=r_cand, force=force, tol=cv_tol, max_iter=max_iter, 275 ) 276 # Score on eval cells 277 resid = Y - result.fit 278 return _score_residuals(resid, fold["eval"]) 279 280 if parallel is None: 281 fold_scores = [_run_fold(f) for f in folds] 282 else: 283 fold_scores = parallel(delayed(_run_fold)(f) for f in folds) 284 285 # Average scores across folds 286 return { 287 metric: sum(fs[metric] for fs in fold_scores) / len(fold_scores) 288 for metric in fold_scores[0] 289 } 290 291 if n_jobs == 1: 292 for r_cand in r_candidates: 293 scores[r_cand] = _score_candidate(r_cand) 294 else: 295 with parallel_config(backend="loky", inner_max_num_threads=1), \ 296 Parallel(n_jobs=n_jobs) as parallel: 297 for r_cand in r_candidates: 298 scores[r_cand] = _score_candidate(r_cand, parallel) 299 300 # r_candidates is ordered from simpler to more flexible. 301 best_r = _select_best(scores, r_candidates, criterion, cv_rule=cv_rule) 302 303 return CVResult(best_r=best_r, best_lambda=None, scores=scores, all_residuals={}) 304 305 306def cv_mc( 307 Y: np.ndarray, 308 Y0: np.ndarray, 309 X: np.ndarray | None, 310 I: np.ndarray, 311 II: np.ndarray, 312 D: np.ndarray, 313 W: np.ndarray | None, 314 beta0: np.ndarray | None, 315 force: int, 316 lambda_candidates: np.ndarray | None = None, 317 nlambda: int = 10, 318 k: int = 10, 319 cv_prop: float = 0.1, 320 cv_nobs: int = 3, 321 cv_treat: bool = True, 322 cv_donut: int = 0, 323 criterion: str = "mspe", 324 cv_rule: Literal["min", "onepct"] = "min", 325 tol: float = 1e-5, 326 max_iter: int = 1000, 327 n_jobs: int = 1, 328 seed: int | None = None, 329) -> CVResult: 330 """Cross-validate to select lambda (nuclear norm penalty). 331 332 If ``lambda_candidates`` is None, generates a log-spaced grid from the 333 data's largest singular value down to 0. With ``cv_rule="onepct"``, the 334 largest penalty within 1% of the global best score is selected. 335 """ 336 xp = get_backend() 337 rng = make_rng(seed) 338 cv_tol = max(tol, 1e-3) 339 340 # Generate lambda grid if not provided 341 if lambda_candidates is None: 342 lambda_candidates = _make_lambda_grid(Y, II, nlambda) 343 else: 344 lambda_candidates = np.asarray(lambda_candidates, dtype=float) 345 if lambda_candidates.ndim != 1 or lambda_candidates.size == 0: 346 raise ValueError("lambda_candidates must be a non-empty one-dimensional array") 347 if not np.all(np.isfinite(lambda_candidates)): 348 raise ValueError("lambda_candidates must contain only finite values") 349 if np.any(lambda_candidates < 0): 350 raise ValueError("lambda_candidates must be non-negative") 351 352 # Generate folds 353 folds = _make_cv_folds(II, D, k, cv_prop, cv_nobs, cv_treat, cv_donut, rng) 354 355 scores = {} 356 def _score_candidate(lam: float, parallel: Parallel | None = None) -> dict[str, float]: 357 lam_key = float(lam) 358 359 def _run_fold(fold): 360 II_cv = II.copy() 361 II_cv[fold["mask"]] = 0 362 Y0_cv = Y0.copy() 363 if xp.sum(II_cv) > 0: 364 fill_val = xp.sum(Y * II_cv) / (xp.sum(II_cv) + 1e-10) 365 Y0_cv = xp.where(II_cv, Y0_cv, fill_val) 366 367 result = estimate_mc( 368 Y, Y0_cv, X, II_cv, W, beta0, 369 lam=lam_key, force=force, tol=cv_tol, max_iter=max_iter, 370 ) 371 resid = Y - result.fit 372 return _score_residuals(resid, fold["eval"]) 373 374 if parallel is None: 375 fold_scores = [_run_fold(f) for f in folds] 376 else: 377 fold_scores = parallel(delayed(_run_fold)(f) for f in folds) 378 379 return { 380 metric: sum(fs[metric] for fs in fold_scores) / len(fold_scores) 381 for metric in fold_scores[0] 382 } 383 384 if n_jobs == 1: 385 for lam in lambda_candidates: 386 scores[float(lam)] = _score_candidate(float(lam)) 387 else: 388 with parallel_config(backend="loky", inner_max_num_threads=1), \ 389 Parallel(n_jobs=n_jobs) as parallel: 390 for lam in lambda_candidates: 391 scores[float(lam)] = _score_candidate(float(lam), parallel) 392 393 # Select from heavier to lighter regularization, i.e. from simpler 394 # to more flexible. Sort here so user-supplied grids behave the same 395 # way as the generated grid. 396 lambda_order = sorted([float(l) for l in lambda_candidates], reverse=True) 397 best_lambda = _select_best( 398 scores, lambda_order, criterion, 399 cv_rule=cv_rule, 400 ) 401 _warn_if_lambda_on_boundary(best_lambda, lambda_candidates) 402 403 return CVResult(best_r=None, best_lambda=best_lambda, scores=scores, all_residuals={}) 404 405 406def _make_lambda_grid(Y, II, nlambda: int): 407 """Generate log-spaced lambda grid from data's singular values.""" 408 xp = get_backend() 409 T, N = Y.shape 410 Y_obs = Y * II 411 # Get largest singular value 412 _, s, _ = xp.linalg.svd(Y_obs / (T * N), full_matrices=False) 413 lam_max = float(s[0]) 414 log_max = math.log10(max(lam_max, 1e-10)) 415 step = 3.0 / max(nlambda - 2, 1) 416 grid = [10 ** (log_max - i * step) for i in range(nlambda - 1)] 417 grid.append(0.0) 418 return np.array(grid) 419 420 421def _select_best( 422 scores: dict, 423 candidates: list, 424 criterion: str, 425 cv_rule: Literal["min", "onepct"] = "min", 426): 427 """Select a CV candidate by min score or an explicit 1% slack rule. 428 429 ``"min"`` is the paper-faithful rule: select the candidate with the 430 lowest validation score. For ``cv_rule="onepct"``, ``candidates`` 431 must be ordered from simpler to more flexible; the selected candidate 432 is the first candidate within 1% of the global best score. 433 """ 434 if not candidates: 435 raise ValueError("candidates must not be empty") 436 if cv_rule not in {"min", "onepct"}: 437 raise ValueError("cv_rule must be 'min' or 'onepct'") 438 439 vals = [(c, float(scores[c][criterion])) for c in candidates] 440 if cv_rule == "min": 441 return min(vals, key=lambda x: x[1])[0] 442 443 best_score = min(v for _, v in vals) 444 threshold = best_score * 1.01 445 for c, v in vals: 446 if v <= threshold: 447 return c 448 449 return min(vals, key=lambda x: x[1])[0] 450 451 452def _warn_if_lambda_on_boundary(best_lambda: float | None, lambda_candidates) -> None: 453 """Warn when MC cross-validation selects a grid boundary.""" 454 if best_lambda is None: 455 return 456 457 candidates = np.asarray([float(lam) for lam in lambda_candidates], dtype=float) 458 if candidates.size <= 1: 459 return 460 461 best = float(best_lambda) 462 lower = float(np.min(candidates)) 463 upper = float(np.max(candidates)) 464 465 if np.isclose(best, lower, rtol=1e-12, atol=1e-12): 466 warnings.warn( 467 ( 468 "MC cross-validation selected the lowest lambda in the candidate " 469 f"grid ({lower:g}). This boundary choice may indicate that little " 470 "or no regularization is preferred; compare cv_rule='onepct' or " 471 "set lam explicitly after validating the boundary selection." 472 ), 473 UserWarning, 474 stacklevel=3, 475 ) 476 elif np.isclose(best, upper, rtol=1e-12, atol=1e-12): 477 warnings.warn( 478 ( 479 "MC cross-validation selected the highest lambda in the candidate " 480 f"grid ({upper:g}). This boundary choice may indicate that the " 481 "grid should be extended upward." 482 ), 483 UserWarning, 484 stacklevel=3, 485 )
class
CVResult(typing.NamedTuple):
52class CVResult(NamedTuple): 53 """Selected hyperparameters and validation scores.""" 54 best_r: int | None # selected number of factors (IFE) 55 """Selected IFE rank, or None for MC.""" 56 best_lambda: float | None # selected lambda (MC) 57 """Selected MC penalty, or None for IFE.""" 58 scores: dict # {candidate: {criterion: score}} 59 """Validation scores by candidate and criterion.""" 60 all_residuals: dict # {candidate: residuals array} 61 """Validation residuals for each candidate when retained."""
Selected hyperparameters and validation scores.
def
cv_ife( Y: numpy.ndarray, Y0: numpy.ndarray, X: numpy.ndarray | None, I: numpy.ndarray, II: numpy.ndarray, D: numpy.ndarray, W: numpy.ndarray | None, beta0: numpy.ndarray | None, force: int, r_range: tuple[int, int], k: int = 10, cv_prop: float = 0.1, cv_nobs: int = 3, cv_treat: bool = True, cv_donut: int = 0, criterion: str = 'mspe', cv_rule: Literal['min', 'onepct'] = 'min', tol: float = 1e-05, max_iter: int = 1000, n_jobs: int = 1, seed: int | None = None) -> CVResult:
213def cv_ife( 214 Y: np.ndarray, 215 Y0: np.ndarray, 216 X: np.ndarray | None, 217 I: np.ndarray, 218 II: np.ndarray, 219 D: np.ndarray, 220 W: np.ndarray | None, 221 beta0: np.ndarray | None, 222 force: int, 223 r_range: tuple[int, int], 224 k: int = 10, 225 cv_prop: float = 0.1, 226 cv_nobs: int = 3, 227 cv_treat: bool = True, 228 cv_donut: int = 0, 229 criterion: str = "mspe", 230 cv_rule: Literal["min", "onepct"] = "min", 231 tol: float = 1e-5, 232 max_iter: int = 1000, 233 n_jobs: int = 1, 234 seed: int | None = None, 235) -> CVResult: 236 """Cross-validate to select number of factors *r*. 237 238 Parameters 239 ---------- 240 r_range : (r_min, r_max) 241 Range of candidate factor numbers. 242 k : int 243 Number of CV folds. 244 cv_rule : {"min", "onepct"} 245 ``"min"`` selects the lowest-score candidate, matching the 246 paper's cross-validation description. ``"onepct"`` selects the 247 lowest rank within 1% of the global best score. 248 n_jobs : int 249 Number of parallel workers for fold evaluation. 250 """ 251 xp = get_backend() 252 rng = make_rng(seed) 253 254 # Looser tolerance for CV (like R fect) 255 cv_tol = max(tol, 1e-3) 256 257 # Generate folds 258 folds = _make_cv_folds(II, D, k, cv_prop, cv_nobs, cv_treat, cv_donut, rng) 259 260 r_candidates = list(range(r_range[0], r_range[1] + 1)) 261 scores = {} 262 263 def _score_candidate(r_cand: int, parallel: Parallel | None = None) -> dict[str, float]: 264 def _run_fold(fold): 265 II_cv = II.copy() 266 II_cv[fold["mask"]] = 0 267 # Re-initialize Y0 for masked cells with simple mean 268 Y0_cv = Y0.copy() 269 if xp.sum(II_cv) > 0: 270 fill_val = xp.sum(Y * II_cv) / (xp.sum(II_cv) + 1e-10) 271 Y0_cv = xp.where(II_cv, Y0_cv, fill_val) 272 273 result = estimate_ife( 274 Y, Y0_cv, X, II_cv, W, beta0, 275 r=r_cand, force=force, tol=cv_tol, max_iter=max_iter, 276 ) 277 # Score on eval cells 278 resid = Y - result.fit 279 return _score_residuals(resid, fold["eval"]) 280 281 if parallel is None: 282 fold_scores = [_run_fold(f) for f in folds] 283 else: 284 fold_scores = parallel(delayed(_run_fold)(f) for f in folds) 285 286 # Average scores across folds 287 return { 288 metric: sum(fs[metric] for fs in fold_scores) / len(fold_scores) 289 for metric in fold_scores[0] 290 } 291 292 if n_jobs == 1: 293 for r_cand in r_candidates: 294 scores[r_cand] = _score_candidate(r_cand) 295 else: 296 with parallel_config(backend="loky", inner_max_num_threads=1), \ 297 Parallel(n_jobs=n_jobs) as parallel: 298 for r_cand in r_candidates: 299 scores[r_cand] = _score_candidate(r_cand, parallel) 300 301 # r_candidates is ordered from simpler to more flexible. 302 best_r = _select_best(scores, r_candidates, criterion, cv_rule=cv_rule) 303 304 return CVResult(best_r=best_r, best_lambda=None, scores=scores, all_residuals={})
Cross-validate to select number of factors r.
Parameters
- r_range ((r_min, r_max)): Range of candidate factor numbers.
- k (int): Number of CV folds.
- cv_rule ({"min", "onepct"}):
"min"selects the lowest-score candidate, matching the paper's cross-validation description."onepct"selects the lowest rank within 1% of the global best score. - n_jobs (int): Number of parallel workers for fold evaluation.
def
cv_mc( Y: numpy.ndarray, Y0: numpy.ndarray, X: numpy.ndarray | None, I: numpy.ndarray, II: numpy.ndarray, D: numpy.ndarray, W: numpy.ndarray | None, beta0: numpy.ndarray | None, force: int, lambda_candidates: numpy.ndarray | None = None, nlambda: int = 10, k: int = 10, cv_prop: float = 0.1, cv_nobs: int = 3, cv_treat: bool = True, cv_donut: int = 0, criterion: str = 'mspe', cv_rule: Literal['min', 'onepct'] = 'min', tol: float = 1e-05, max_iter: int = 1000, n_jobs: int = 1, seed: int | None = None) -> CVResult:
307def cv_mc( 308 Y: np.ndarray, 309 Y0: np.ndarray, 310 X: np.ndarray | None, 311 I: np.ndarray, 312 II: np.ndarray, 313 D: np.ndarray, 314 W: np.ndarray | None, 315 beta0: np.ndarray | None, 316 force: int, 317 lambda_candidates: np.ndarray | None = None, 318 nlambda: int = 10, 319 k: int = 10, 320 cv_prop: float = 0.1, 321 cv_nobs: int = 3, 322 cv_treat: bool = True, 323 cv_donut: int = 0, 324 criterion: str = "mspe", 325 cv_rule: Literal["min", "onepct"] = "min", 326 tol: float = 1e-5, 327 max_iter: int = 1000, 328 n_jobs: int = 1, 329 seed: int | None = None, 330) -> CVResult: 331 """Cross-validate to select lambda (nuclear norm penalty). 332 333 If ``lambda_candidates`` is None, generates a log-spaced grid from the 334 data's largest singular value down to 0. With ``cv_rule="onepct"``, the 335 largest penalty within 1% of the global best score is selected. 336 """ 337 xp = get_backend() 338 rng = make_rng(seed) 339 cv_tol = max(tol, 1e-3) 340 341 # Generate lambda grid if not provided 342 if lambda_candidates is None: 343 lambda_candidates = _make_lambda_grid(Y, II, nlambda) 344 else: 345 lambda_candidates = np.asarray(lambda_candidates, dtype=float) 346 if lambda_candidates.ndim != 1 or lambda_candidates.size == 0: 347 raise ValueError("lambda_candidates must be a non-empty one-dimensional array") 348 if not np.all(np.isfinite(lambda_candidates)): 349 raise ValueError("lambda_candidates must contain only finite values") 350 if np.any(lambda_candidates < 0): 351 raise ValueError("lambda_candidates must be non-negative") 352 353 # Generate folds 354 folds = _make_cv_folds(II, D, k, cv_prop, cv_nobs, cv_treat, cv_donut, rng) 355 356 scores = {} 357 def _score_candidate(lam: float, parallel: Parallel | None = None) -> dict[str, float]: 358 lam_key = float(lam) 359 360 def _run_fold(fold): 361 II_cv = II.copy() 362 II_cv[fold["mask"]] = 0 363 Y0_cv = Y0.copy() 364 if xp.sum(II_cv) > 0: 365 fill_val = xp.sum(Y * II_cv) / (xp.sum(II_cv) + 1e-10) 366 Y0_cv = xp.where(II_cv, Y0_cv, fill_val) 367 368 result = estimate_mc( 369 Y, Y0_cv, X, II_cv, W, beta0, 370 lam=lam_key, force=force, tol=cv_tol, max_iter=max_iter, 371 ) 372 resid = Y - result.fit 373 return _score_residuals(resid, fold["eval"]) 374 375 if parallel is None: 376 fold_scores = [_run_fold(f) for f in folds] 377 else: 378 fold_scores = parallel(delayed(_run_fold)(f) for f in folds) 379 380 return { 381 metric: sum(fs[metric] for fs in fold_scores) / len(fold_scores) 382 for metric in fold_scores[0] 383 } 384 385 if n_jobs == 1: 386 for lam in lambda_candidates: 387 scores[float(lam)] = _score_candidate(float(lam)) 388 else: 389 with parallel_config(backend="loky", inner_max_num_threads=1), \ 390 Parallel(n_jobs=n_jobs) as parallel: 391 for lam in lambda_candidates: 392 scores[float(lam)] = _score_candidate(float(lam), parallel) 393 394 # Select from heavier to lighter regularization, i.e. from simpler 395 # to more flexible. Sort here so user-supplied grids behave the same 396 # way as the generated grid. 397 lambda_order = sorted([float(l) for l in lambda_candidates], reverse=True) 398 best_lambda = _select_best( 399 scores, lambda_order, criterion, 400 cv_rule=cv_rule, 401 ) 402 _warn_if_lambda_on_boundary(best_lambda, lambda_candidates) 403 404 return CVResult(best_r=None, best_lambda=best_lambda, scores=scores, all_residuals={})
Cross-validate to select lambda (nuclear norm penalty).
If lambda_candidates is None, generates a log-spaced grid from the
data's largest singular value down to 0. With cv_rule="onepct", the
largest penalty within 1% of the global best score is selected.