imbcal 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
imbcal/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """imbcal: calibrated probability estimation under class imbalance.
2
+
3
+ Main entry point: :class:`ImbalanceCalibratedClassifier`. The safe default is to fit at the natural prevalence and
4
+ recalibrate (``strategy="none"``); prevalence-preserving augmentation (``strategy="augment"``) can use any synthetic
5
+ data generator and is selected by cross-validation of the recalibrated loss, with no augmentation always available.
6
+ :func:`rebalancing_cost` predicts, before any model is fitted, the calibration slope, AUC and log-loss of
7
+ maximum likelihood and of rebalanced logistic regressions in the proportional regime.
8
+ """
9
+ from .calibration import IsotonicCalibrator, PlattCalibrator, crossfit_calibrated
10
+ from .diagnostics import majority_exposure
11
+ from .estimator import Candidate, ImbalanceCalibratedClassifier
12
+ from .generators import BaseGenerator, CallableGenerator, GaussianGenerator, SMOTEGenerator
13
+ from .metrics import (calibration_slope_intercept, expected_calibration_error, paired_comparison,
14
+ probability_report)
15
+ from .planning import print_rebalancing_cost, rebalancing_cost
16
+
17
+ __version__ = "0.3.0"
18
+ __all__ = ["ImbalanceCalibratedClassifier", "Candidate", "BaseGenerator", "SMOTEGenerator", "GaussianGenerator",
19
+ "CallableGenerator", "PlattCalibrator", "IsotonicCalibrator", "crossfit_calibrated",
20
+ "calibration_slope_intercept", "expected_calibration_error", "probability_report", "paired_comparison",
21
+ "majority_exposure", "rebalancing_cost", "print_rebalancing_cost"]
imbcal/_cgmt_copies.py ADDED
@@ -0,0 +1,85 @@
1
+ """Problema límite exacto (vía CGMT, derivación heurística) para copias contraídas: cada unidad minoritaria
2
+ aporta x_i y r − 1 copias de x̄₁ + √γ (x_i − x̄₁). Se separa z_i = z̄ + e_i (Σ e_i = 0): w = α μ/s + τ z̄/‖z̄‖ + θ u.
3
+ Ecuaciones (λ_r ≥ 0; F(e) = ℓ(A+e) + (r−1)ℓ(A+√γ e), A = αs + τ√(κ/π) + b, ‖w‖² = α² + τ² + θ²):
4
+ e = prox_{λp F}(‖w‖Z − λp ν), c = prox_{λp ℓ}(αs − b + ‖w‖Z₀)
5
+ (b) π E[F_A] = (1−π) E[ℓ'(c)] F_A = ℓ'(A+e) + (r−1)ℓ'(A+√γ e)
6
+ (α) α(Γ̄ + λ_r) = −s[π E F_A + (1−π) E ℓ'(c)]
7
+ (τ) τ(Γ̄ + λ_r) = −√(κπ) E F_A
8
+ (θ) Γ̄ + λ_r = κ/λp
9
+ (ν) E F'(e) = −ν (media cero de la holgura minoritaria)
10
+ (q) λp² [π E(F'(e)+ν)² + (1−π) E ℓ'(c)²] = κ θ²
11
+ con Γ̄ = π E[F''/(1+λp F'')] + (1−π) E[ℓ''/(1+λp ℓ'')]. Con γ = 1 se recupera la logística con peso c = r."""
12
+ import sys, json, warnings, numpy as np
13
+ from scipy.optimize import root
14
+ from scipy.special import expit
15
+ from ._cgmt_weights import gh_x as gh_x_hermite, gh_w as gh_w_hermite
16
+ # Cuadratura: Gauss–Legendre con 1500 nodos en [−14, 14] contra la densidad normal. Cerca de la separabilidad
17
+ # (κ = 0,1) la norma de ŵ es grande y Gauss–Hermite con 160 nodos no basta (escala 6,242 en vez de 6,233).
18
+ from numpy.polynomial.legendre import leggauss as _lg
19
+ _x, _w = _lg(1500); gh_x = 14.0 * _x; gh_w = _w * 14.0 * np.exp(-gh_x**2 / 2); gh_w = gh_w / gh_w.sum()
20
+ l1 = lambda t: -expit(-t) # ℓ'
21
+ l2 = lambda t: expit(t) * expit(-t) # ℓ''
22
+
23
+ def prox_F(v, lp, A, r, g):
24
+ sg = np.sqrt(g); lo, hi = v - 1e-12, v + lp * (1 + (r - 1) * sg) + 1e-12
25
+ for _ in range(80):
26
+ e = 0.5 * (lo + hi); f = e + lp * (l1(A + e) + (r - 1) * sg * l1(A + sg * e)) - v
27
+ lo = np.where(f < 0, e, lo); hi = np.where(f < 0, hi, e)
28
+ return 0.5 * (lo + hi)
29
+
30
+ def prox_l(v, lp):
31
+ lo, hi = v - 1e-12, v + lp + 1e-12
32
+ for _ in range(80):
33
+ c = 0.5 * (lo + hi); f = c + lp * l1(c) - v
34
+ lo = np.where(f < 0, c, lo); hi = np.where(f < 0, hi, c)
35
+ return 0.5 * (lo + hi)
36
+
37
+ def residuals(x, s, pi, kappa, r, g, lr):
38
+ a, lt, lth, b, llp, nu = x
39
+ tau, th, lp = lt, np.exp(lth), np.exp(llp)
40
+ nw = np.sqrt(a * a + tau * tau + th * th); sg = np.sqrt(g)
41
+ A = a * s + tau * np.sqrt(kappa / pi) + b
42
+ e = prox_F(nw * gh_x - lp * nu, lp, A, r, g)
43
+ FA = l1(A + e) + (r - 1) * l1(A + sg * e)
44
+ Fp = l1(A + e) + (r - 1) * sg * l1(A + sg * e)
45
+ Fpp = l2(A + e) + (r - 1) * g * l2(A + sg * e)
46
+ c = prox_l(a * s - b + nw * gh_x, lp)
47
+ EFA, ElpC = np.sum(gh_w * FA), np.sum(gh_w * l1(c))
48
+ Gam = pi * np.sum(gh_w * Fpp / (1 + lp * Fpp)) + (1 - pi) * np.sum(gh_w * l2(c) / (1 + lp * l2(c)))
49
+ EFp = np.sum(gh_w * Fp)
50
+ q = lp**2 * (pi * np.sum(gh_w * (Fp + nu) ** 2) + (1 - pi) * np.sum(gh_w * l1(c) ** 2))
51
+ return [pi * EFA - (1 - pi) * ElpC,
52
+ a * (Gam + lr) + s * (pi * EFA + (1 - pi) * ElpC),
53
+ tau * (Gam + lr) + np.sqrt(kappa * pi) * EFA,
54
+ (Gam + lr) - kappa / lp,
55
+ EFp + nu,
56
+ (q - kappa * th**2) / max(kappa * th**2, 1e-12)]
57
+
58
+ def solve(s, pi, kappa, r, g, lr=0.0, x0=None):
59
+ if x0 is None: x0 = np.array([2 * s, 0.1, np.log(0.3), -2.0, np.log(kappa / (pi * (1 - pi))), 0.0])
60
+ best = None
61
+ for method in ["hybr", "lm"]:
62
+ rr = root(residuals, x0, args=(s, pi, kappa, r, g, lr), method=method, options={"xtol": 1e-13} if method == "hybr" else {"xtol": 1e-14, "ftol": 1e-14})
63
+ res = float(np.max(np.abs(rr.fun)))
64
+ if best is None or res < best[1]: best = (rr.x, res)
65
+ x, res = best; a, tau, th = x[0], x[1], np.exp(x[2]); nw = np.sqrt(a * a + tau * tau + th * th)
66
+ return dict(alpha=a, tau=tau, theta=th, b=x[3], lp=np.exp(x[4]), nu=x[5], scale=nw / (2 * s), cos=a / nw, res=res), x
67
+
68
+ def init_from_weights(s, pi, kappa, r):
69
+ """Solución de pesos c = r (γ = 1) expresada en las variables de este sistema."""
70
+ from . import _cgmt_weights as CP
71
+ E = CP.path(s, pi, kappa, [r])[r]
72
+ a, sig, lam, b = E["alpha"], E["sigma"], E["lam"], E["b"]
73
+ nw = np.hypot(a, sig); t = CP.prox(a * s + b + nw * gh_x, lam * r); Epsi = np.sum(gh_w * r * expit(-t))
74
+ tau = lam * Epsi * np.sqrt(pi / kappa); th = np.sqrt(max(sig**2 - tau**2, 1e-8))
75
+ return np.array([a, tau, np.log(th), b, np.log(lam), Epsi])
76
+
77
+ def path(s, pi, kappa, r, g, lr=0.0, steps=20):
78
+ """continuación en γ desde 1 (pesos c = r) hasta g, a κ fijo"""
79
+ x = init_from_weights(s, pi, kappa, r)
80
+ for gg in np.linspace(1.0, g, steps):
81
+ rr = root(residuals, x, args=(s, pi, kappa, r, gg, lr), method="hybr", options={"xtol": 1e-13}); x = rr.x
82
+ a, tau, th = x[0], x[1], np.exp(x[2]); nw = np.sqrt(a * a + tau * tau + th * th)
83
+ return dict(alpha=a, tau=tau, theta=th, b=x[3], lp=np.exp(x[4]), nu=x[5], scale=nw / (2 * s), cos=a / nw,
84
+ res=float(np.max(np.abs(rr.fun))))
85
+
@@ -0,0 +1,116 @@
1
+ """Internal solver (ported from the replication package of de la Cruz, Paper A).
2
+
3
+ Sistema de punto fijo (CGMT / leave-one-out) para la logística SIN regularizar con pesos de clase
4
+ en el 2-GMM, régimen proporcional d/n → κ, por debajo del umbral de separabilidad.
5
+
6
+ Modelo: y = ±1 con P(y = 1) = π; x = yμ + z, z ~ N(0, I_d), ‖μ‖ = s.
7
+ Estimador: min_{w,b} Σ_i c(y_i) ℓ(y_i(⟨w, x_i⟩ + b)), ℓ(t) = log(1 + e^{−t}), c(+1) = c, c(−1) = 1.
8
+ Descomposición: w = α μ/s + σ u, u ⊥ μ unitario. Margen de entrenamiento (con corrección LOO):
9
+ t = prox_{λ c(y) ℓ}(v), v = α(s + H) + σ G + y b, H, G ~ N(0, 1) independientes,
10
+ ψ = c(y) σ(−t) = (t − v)/λ ≥ 0.
11
+ Ecuaciones (incógnitas α, σ, λ, b):
12
+ (E1) E[y ψ] = 0 (intercepto)
13
+ (E2) E[ψ (s + H)] = 0 (estacionariedad en la dirección de μ)
14
+ (E3) λ² E[ψ²] = κ σ² (norma de la parte ortogonal)
15
+ (E4) E[1 / (1 + λ c(y) ℓ''(t))] = 1 − κ (grados de libertad)
16
+ Predicción para x nuevo: logit η̂ = α(y s + h) + σ g + b − log c. Verdad: 2(y s² + s h) + logit π.
17
+ Con c = 1/π·(1−π) se obtiene "pesos balanceados". κ → 0 recupera α = 2s, σ = 0, b = logit π + log c.
18
+ """
19
+ import numpy as np
20
+ from scipy.optimize import fsolve
21
+ from scipy.special import expit, logit
22
+
23
+ NODES = 160
24
+ gh_x, gh_w = np.polynomial.hermite_e.hermegauss(NODES)
25
+ gh_w = gh_w / gh_w.sum()
26
+ # Reducción a una dimensión: v = α s + y b + τ Z con τ² = α² + σ², y por Stein
27
+ # E[ψ H] = α E[dψ/dv], dψ/dv = −c ℓ''(t) / (1 + λ c ℓ''(t)).
28
+
29
+ def prox(v, lam_c, it=8):
30
+ """t = v + λc σ(−t): raíz única en [v, v + λc]; bisección y luego Newton."""
31
+ lo, hi = v.copy(), v + lam_c
32
+ for _ in range(45):
33
+ mid = 0.5 * (lo + hi)
34
+ f = mid - v - lam_c * expit(-mid)
35
+ lo = np.where(f < 0, mid, lo); hi = np.where(f < 0, hi, mid)
36
+ t = 0.5 * (lo + hi)
37
+ for _ in range(it):
38
+ t = t - (t - v - lam_c * expit(-t)) / (1 + lam_c * expit(t) * expit(-t))
39
+ return t
40
+
41
+ def residuals(par, s, pi, kappa, c):
42
+ a, ls, ll, b = par
43
+ sig, lam = np.exp(ls), np.exp(ll)
44
+ tau = np.hypot(a, sig)
45
+ e1 = e2 = e3 = e4 = 0.0
46
+ for y, py, cy in [(1, pi, c), (-1, 1 - pi, 1.0)]:
47
+ v = a * s + y * b + tau * gh_x
48
+ t = prox(v, lam * cy)
49
+ psi = cy * expit(-t)
50
+ l2 = cy * expit(t) * expit(-t)
51
+ dpsi = -l2 / (1 + lam * l2)
52
+ e1 += py * np.sum(gh_w * y * psi)
53
+ e2 += py * np.sum(gh_w * (s * psi + a * dpsi))
54
+ e3 += py * np.sum(gh_w * psi**2)
55
+ e4 += py * np.sum(gh_w / (1 + lam * l2))
56
+ return [e1, e2, lam**2 * e3 - kappa * sig**2, e4 - (1 - kappa)]
57
+
58
+ def solve(s, pi, kappa, c, x0=None):
59
+ from scipy.optimize import root
60
+ if x0 is None:
61
+ lam = kappa / (pi * (1 - pi))
62
+ x0 = [2 * s, np.log(np.sqrt(20 * kappa)), np.log(lam), logit(pi) + np.log(c)]
63
+ r = root(residuals, x0, args=(s, pi, kappa, c), method="hybr", options={"xtol": 1e-12})
64
+ a, ls, ll, b = r.x
65
+ return dict(alpha=a, sigma=np.exp(ls), lam=np.exp(ll), b=b, ok=bool(r.success),
66
+ res=float(np.max(np.abs(r.fun)))), r.x
67
+
68
+ def metrics(sol, s, pi, c, nmc=400000, seed=0):
69
+ a, sig, b = sol["alpha"], sol["sigma"], sol["b"]
70
+ rng = np.random.default_rng(seed)
71
+ y = np.where(rng.random(nmc) < pi, 1, -1); h = rng.normal(size=nmc); g = rng.normal(size=nmc)
72
+ zt = 2 * (y * s**2 + s * h) + logit(pi)
73
+ zh = a * (y * s + h) + sig * g + b - np.log(c)
74
+ eta, p = expit(zt), expit(zh)
75
+ yy = (y == 1).astype(float)
76
+ from sklearn.linear_model import LogisticRegression
77
+ th = [0, LogisticRegression(C=np.inf, max_iter=5000).fit(zh.reshape(-1, 1), yy).coef_[0, 0]]
78
+ roi = eta >= 0.05
79
+ err = np.abs(p - eta)
80
+ nb = 2 * s; nrm = np.hypot(a, sig)
81
+ return dict(coseno=a / nrm, norma_rel=nrm / nb, proy=a / nb, ortog=sig / nb, slope=th[1],
82
+ mae_eta=err.mean(), mae_eta_roi=err[roi].mean(),
83
+ logloss=-np.mean(yy * np.log(p) + (1 - yy) * np.log(1 - p)))
84
+
85
+ def _solve_robust(s, pi, kappa, c, x0):
86
+ from scipy.optimize import root
87
+ sol, x = solve(s, pi, kappa, c, x0)
88
+ if not sol["ok"] or sol["res"] > 1e-9:
89
+ r = root(residuals, x0, args=(s, pi, kappa, c), method="lm", options={"xtol": 1e-14, "ftol": 1e-14})
90
+ a, ls, ll, b = r.x
91
+ res = float(np.max(np.abs(r.fun)))
92
+ if res < sol["res"]:
93
+ sol, x = dict(alpha=a, sigma=np.exp(ls), lam=np.exp(ll), b=b, ok=res < 1e-9, res=res), r.x
94
+ return sol, x
95
+
96
+ def path(s, pi, kappa, cs, steps_per_unit=30):
97
+ """Continuación fina: en κ desde 0.005 con c = 1, luego en log c hacia arriba y hacia abajo."""
98
+ x0 = None
99
+ for k in np.linspace(min(0.005, kappa), kappa, 60):
100
+ _, x0 = _solve_robust(s, pi, k, 1.0, x0) if x0 is not None else solve(s, pi, k, 1.0, None)
101
+ out = {1.0: solve(s, pi, kappa, 1.0, x0)[0]}
102
+ for direction in (+1, -1):
103
+ targets = sorted([c for c in cs if (c > 1 if direction > 0 else c < 1)], key=lambda c: abs(np.log(c)))
104
+ if not targets: continue
105
+ lmax = abs(np.log(targets[-1]))
106
+ grid = np.linspace(0, lmax, max(2, int(lmax * steps_per_unit)))
107
+ grid = np.unique(np.r_[grid, [abs(np.log(c)) for c in targets]])
108
+ x = x0.copy()
109
+ for lg in grid[1:]:
110
+ c = float(np.exp(direction * lg))
111
+ sol, x = _solve_robust(s, pi, kappa, c, x)
112
+ for ct in targets:
113
+ if abs(abs(np.log(ct)) - lg) < 1e-12:
114
+ out[ct] = sol
115
+ return out
116
+
imbcal/calibration.py ADDED
@@ -0,0 +1,63 @@
1
+ """Recalibration maps fitted on out-of-fold scores."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ from scipy.special import expit, logit
6
+ from sklearn.isotonic import IsotonicRegression
7
+ from sklearn.linear_model import LogisticRegression
8
+
9
+ EPS = 1e-4
10
+
11
+
12
+ def to_logit(p: np.ndarray, eps: float = EPS) -> np.ndarray:
13
+ return logit(np.clip(np.asarray(p, float), eps, 1 - eps))
14
+
15
+
16
+ class PlattCalibrator:
17
+ """Logistic recalibration ``expit(a + b * logit(p))`` (Platt scaling on the logit scale)."""
18
+
19
+ def fit(self, p, y):
20
+ self.model_ = LogisticRegression(C=1e6).fit(to_logit(p).reshape(-1, 1), y)
21
+ self.intercept_ = float(self.model_.intercept_[0])
22
+ self.slope_ = float(self.model_.coef_[0, 0])
23
+ return self
24
+
25
+ def predict(self, p):
26
+ return expit(self.intercept_ + self.slope_ * to_logit(p))
27
+
28
+
29
+ class IsotonicCalibrator:
30
+ """Isotonic recalibration (needs many events; unstable with fewer than a few hundred)."""
31
+
32
+ def fit(self, p, y):
33
+ self.model_ = IsotonicRegression(out_of_bounds="clip", y_min=1e-6, y_max=1 - 1e-6).fit(p, y)
34
+ return self
35
+
36
+ def predict(self, p):
37
+ return self.model_.predict(p)
38
+
39
+
40
+ class IdentityCalibrator:
41
+ def fit(self, p, y):
42
+ return self
43
+
44
+ def predict(self, p):
45
+ return np.asarray(p, float)
46
+
47
+
48
+ def make_calibrator(name):
49
+ if name in (None, "none"):
50
+ return IdentityCalibrator()
51
+ if name == "platt":
52
+ return PlattCalibrator()
53
+ if name == "isotonic":
54
+ return IsotonicCalibrator()
55
+ raise ValueError(f"unknown calibration {name!r}")
56
+
57
+
58
+ def crossfit_calibrated(p_oof: np.ndarray, y: np.ndarray, folds, calibration: str) -> np.ndarray:
59
+ """Recalibrate out-of-fold scores without optimism: the map for fold j is fitted on the other folds."""
60
+ out = np.empty(len(y))
61
+ for tr, va in folds:
62
+ out[va] = make_calibrator(calibration).fit(p_oof[tr], y[tr]).predict(p_oof[va])
63
+ return out
imbcal/diagnostics.py ADDED
@@ -0,0 +1,17 @@
1
+ """Diagnostics for synthetic minority data."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+
6
+
7
+ def majority_exposure(prob_fn, X_min, X_syn) -> dict:
8
+ """Shift in estimated risk between synthetic and real minority points.
9
+
10
+ ``delta = mean(prob_fn(X_syn)) - mean(prob_fn(X_min))``. With the true risk function this is the signed term of
11
+ the weighted-bias identity ∫(1-η)(q-f1) = -delta: negative values mean that the generator moves minority mass
12
+ towards regions dominated by the majority class, where the bias costs most. It is a descriptive heuristic: a
13
+ positive value does not rule out local displacement that cancels on average.
14
+ ``prob_fn`` maps an array of features to probabilities (e.g. a fitted model's ``predict_proba(X)[:, 1]``).
15
+ """
16
+ r = np.asarray(prob_fn(X_min), float); s = np.asarray(prob_fn(X_syn), float)
17
+ return dict(delta=float(s.mean() - r.mean()), mean_real=float(r.mean()), mean_synthetic=float(s.mean()))
imbcal/estimator.py ADDED
@@ -0,0 +1,280 @@
1
+ """Probability estimation under class imbalance with a scikit-learn compatible estimator."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ import itertools
7
+
8
+ import numpy as np
9
+ from sklearn.base import BaseEstimator, ClassifierMixin, clone
10
+ from sklearn.metrics import brier_score_loss, log_loss
11
+ from sklearn.model_selection import KFold, StratifiedKFold
12
+ from sklearn.utils.multiclass import type_of_target
13
+ from sklearn.utils.validation import check_is_fitted, check_X_y, check_array, validate_data
14
+
15
+ from .calibration import crossfit_calibrated, make_calibrator
16
+ from .generators import GaussianGenerator, SMOTEGenerator
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Candidate:
21
+ """A training scheme: no correction (generator None, chi 1) or prevalence-preserving augmentation."""
22
+ generator: object
23
+ chi: float
24
+
25
+ def label(self) -> str:
26
+ if self.generator is None:
27
+ return "none"
28
+ if self.generator == "weights":
29
+ return "weights"
30
+ return f"{self.generator!r}|chi={self.chi:g}"
31
+
32
+
33
+ def _loss(name, y, p):
34
+ p = np.clip(p, 1e-12, 1 - 1e-12)
35
+ if name == "log_loss":
36
+ return -(y * np.log(p) + (1 - y) * np.log(1 - p))
37
+ if name == "brier":
38
+ return (p - y) ** 2
39
+ raise ValueError(f"unknown criterion {name!r}")
40
+
41
+
42
+ class ImbalanceCalibratedClassifier(ClassifierMixin, BaseEstimator):
43
+ """Wrap any probabilistic classifier for calibrated probabilities at the natural prevalence.
44
+
45
+ Parameters
46
+ ----------
47
+ estimator : scikit-learn classifier with ``sample_weight`` support in ``fit`` and ``predict_proba``.
48
+ strategy : {"none", "weights", "augment"}
49
+ ``"none"``: fit at the natural prevalence (recommended default).
50
+ ``"weights"``: balanced class weights with recalibration (provided for comparison; it usually hurts).
51
+ ``"augment"``: prevalence-preserving augmentation. Real minority points get weight ``chi`` and ``m``
52
+ synthetic points from a generator get weight ``(1 - chi) n_min / m``, so the training prevalence is the
53
+ natural one. ``(generator, chi)`` is chosen by cross-validation of the *recalibrated* loss, with
54
+ ``chi = 1`` (no augmentation) always among the candidates.
55
+ generators : list of generators for ``"augment"``. Default: Gaussian smoothed bootstrap with h in {0.25, 0.5}
56
+ and SMOTE with k = 5 and k = sqrt(n_min). Features should be on a common scale.
57
+ chi_grid : weights of the real minority points for ``"augment"`` (1 is always added).
58
+ calibration : {"platt", "isotonic", None}. Fitted on out-of-fold scores of the selected scheme.
59
+ criterion : {"log_loss", "brier"} used for selection.
60
+ selection : {"best", "1se", "safe"}. ``"1se"`` keeps ``chi = 1`` unless the best candidate improves on it by more
61
+ than one standard error of the per-observation loss difference. ``"safe"`` augments only if some candidate
62
+ improves on ``chi = 1`` with simultaneous significance at level ``alpha``: paired cross-validated loss
63
+ differences, within-fold variance (Bayle, Bayle, Janson and Mackey, 2020) and a max-T critical value from the
64
+ estimated correlation between candidates; among the candidates that pass, the one with the smallest criterion.
65
+ Asymptotically, the probability of selecting a candidate that is not better than ``chi = 1`` is at most
66
+ ``alpha``.
67
+ alpha : level of the ``"safe"`` selection (accepted risk of selecting a candidate that does not improve).
68
+ variance_floor : for ``"safe"``, each candidate's standard deviation is replaced by
69
+ ``max(sd, variance_floor * sd(loss of chi = 1))``; this makes the guarantee hold without fixing in advance which
70
+ candidates are non-degenerate (a floor can only make the test more conservative).
71
+ n_synthetic : number of synthetic points; default ``n_majority - n_minority``.
72
+ cv : number of stratified folds.
73
+ nested : {"aligned", "crossfit"}
74
+ How the recalibration enters the selection criterion. ``"aligned"`` (default) is strictly nested: the
75
+ candidate evaluated on fold v, including its recalibration map, is built from the other folds only; the map of
76
+ fold v is fitted on scores of fold u != v from models trained without folds u and v, so each candidate costs
77
+ ``cv + cv(cv-1)/2`` fits. ``"crossfit"`` (v0.1 behaviour) fits the map of fold v on the out-of-fold scores of
78
+ the other folds, whose models saw fold v; cheaper (``cv`` fits) but not covered by the oracle inequality.
79
+ output : {"ensemble", "refit"}
80
+ ``"ensemble"`` (default) predicts with the average of the ``cv`` recalibrated fold predictors of the selected
81
+ candidate, which inherits the guarantee of the selection by convexity of the loss and needs no extra fit.
82
+ ``"refit"`` refits the selected candidate on all data and recalibrates it with the map fitted on the
83
+ out-of-fold scores. With high-variance base learners (for example unpenalized logistic regression with few
84
+ events per variable) the ensemble can be underconfident, with calibration slope above one: each fold map
85
+ corrects the overfitting of a single fold model, but the average of the fold models already overfits less.
86
+ ``"refit"`` is then usually better calibrated; with stable learners the ensemble is usually as good or better.
87
+ Compare both on the problem at hand when calibration slope matters.
88
+ stratify : bool
89
+ Stratified folds (default, more stable with few events). The oracle inequality assumes folds independent of
90
+ the data, i.e. ``stratify=False``.
91
+ random_state : int or None.
92
+
93
+ Attributes
94
+ ----------
95
+ selected_ : Candidate chosen.
96
+ cv_results_ : dict label -> cross-validated recalibrated loss.
97
+ candidate_distance_ : root mean squared difference between the out-of-fold probabilities of the selected
98
+ scheme and those of ``chi = 1`` (controls the worst-case loss of the selection, Prop. 4 of the paper).
99
+ calibrator_ : fitted recalibration map (``output="refit"``).
100
+ estimators_, calibrators_ : fold predictors and their maps (``output="ensemble"``).
101
+ """
102
+
103
+ def __init__(self, estimator, strategy="none", generators=None, chi_grid=(0.0, 0.1, 0.25, 0.5, 0.75),
104
+ calibration="platt", criterion="log_loss", selection="best", n_synthetic=None, cv=5,
105
+ nested="aligned", output="ensemble", stratify=True, alpha=0.2, variance_floor=0.05,
106
+ random_state=None):
107
+ self.estimator = estimator
108
+ self.strategy = strategy
109
+ self.generators = generators
110
+ self.chi_grid = chi_grid
111
+ self.calibration = calibration
112
+ self.criterion = criterion
113
+ self.selection = selection
114
+ self.n_synthetic = n_synthetic
115
+ self.cv = cv
116
+ self.nested = nested
117
+ self.output = output
118
+ self.stratify = stratify
119
+ self.alpha = alpha
120
+ self.variance_floor = variance_floor
121
+ self.random_state = random_state
122
+
123
+ # ------------------------------------------------------------------ internals
124
+ def _candidates(self):
125
+ if self.strategy == "none":
126
+ return [Candidate(None, 1.0)]
127
+ if self.strategy == "weights":
128
+ return [Candidate("weights", 1.0)]
129
+ if self.strategy != "augment":
130
+ raise ValueError(f"unknown strategy {self.strategy!r}")
131
+ gens = self.generators if self.generators is not None else [
132
+ GaussianGenerator(0.25), GaussianGenerator(0.5), SMOTEGenerator(5), SMOTEGenerator("sqrt")]
133
+ return [Candidate(None, 1.0)] + [Candidate(g, float(c)) for g in gens for c in self.chi_grid if c < 1]
134
+
135
+ def _fit_predict(self, X, y, Xt, cand, rng, synth_cache=None):
136
+ est = clone(self.estimator)
137
+ if cand.generator is None:
138
+ est.fit(X, y)
139
+ elif cand.generator == "weights":
140
+ n1 = y.sum(); r = (len(y) - n1) / n1
141
+ est.fit(X, y, sample_weight=np.where(y == 1, r, 1.0))
142
+ else:
143
+ n1 = int(y.sum()); n0 = len(y) - n1
144
+ m = int(self.n_synthetic) if self.n_synthetic else max(n0 - n1, n1)
145
+ key = id(cand.generator)
146
+ if synth_cache is not None and key in synth_cache:
147
+ S = synth_cache[key]
148
+ else:
149
+ S = cand.generator.sample(X[y == 1], m, rng)
150
+ if synth_cache is not None:
151
+ synth_cache[key] = S
152
+ XX = np.vstack([X, S]); YY = np.r_[y, np.ones(len(S), int)]
153
+ W = np.r_[np.where(y == 1, cand.chi, 1.0), np.full(len(S), (1 - cand.chi) * n1 / len(S))]
154
+ est.fit(XX, YY, sample_weight=W)
155
+ return est, np.clip(est.predict_proba(Xt)[:, 1], 1e-6, 1 - 1e-6)
156
+
157
+ # ------------------------------------------------------------------ API
158
+ def __sklearn_tags__(self):
159
+ tags = super().__sklearn_tags__()
160
+ tags.classifier_tags.multi_class = False
161
+ return tags
162
+
163
+ def fit(self, X, y):
164
+ X, y = validate_data(self, X, y)
165
+ y_type = type_of_target(y, input_name="y", raise_unknown=True)
166
+ if y_type not in ("binary",):
167
+ if y_type in ("continuous", "continuous-multioutput"):
168
+ raise ValueError(f"Unknown label type: {y_type!r}")
169
+ raise ValueError(f"Only binary classification is supported; got target type {y_type!r}")
170
+ self.classes_ = np.unique(y)
171
+ if len(self.classes_) < 2:
172
+ raise ValueError("The data contains only one class; this classifier needs samples of 2 classes.")
173
+ if self.nested not in ("aligned", "crossfit"):
174
+ raise ValueError(f"unknown nested {self.nested!r}")
175
+ if self.output not in ("ensemble", "refit"):
176
+ raise ValueError(f"unknown output {self.output!r}")
177
+ if self.selection not in ("best", "1se", "safe"):
178
+ raise ValueError(f"unknown selection {self.selection!r}")
179
+ y = (y == self.classes_[1]).astype(int)
180
+ rng = np.random.default_rng(self.random_state)
181
+ V = self.cv
182
+ splitter = StratifiedKFold if self.stratify else KFold
183
+ folds = list(splitter(V, shuffle=True, random_state=int(rng.integers(2**31 - 1))).split(X, y))
184
+ fid = np.empty(len(y), int)
185
+ for v, (_, va) in enumerate(folds):
186
+ fid[va] = v
187
+ cands = self._candidates()
188
+ oof = {c: np.empty(len(y)) for c in cands}
189
+ fold_est = {c: [None] * V for c in cands}
190
+ for v, (tr, va) in enumerate(folds):
191
+ cache = {}
192
+ for c in cands:
193
+ fold_est[c][v], oof[c][va] = self._fit_predict(X[tr], y[tr], X[va], c, rng, cache)
194
+ maps = {c: [None] * V for c in cands}
195
+ pred = {c: np.empty(len(y)) for c in cands}
196
+ if self.nested == "aligned":
197
+ pair = {c: {} for c in cands}
198
+ for u, v in itertools.combinations(range(V), 2):
199
+ idx = np.where((fid != u) & (fid != v))[0]
200
+ iu, iv = folds[u][1], folds[v][1]
201
+ cache = {}
202
+ for c in cands:
203
+ _, s = self._fit_predict(X[idx], y[idx], np.vstack([X[iu], X[iv]]), c, rng, cache)
204
+ pair[c][(u, v)] = s[:len(iu)]
205
+ pair[c][(v, u)] = s[len(iu):]
206
+ for c in cands:
207
+ for v, (_, va) in enumerate(folds):
208
+ others = [u for u in range(V) if u != v]
209
+ s = np.concatenate([pair[c][(u, v)] for u in others])
210
+ yy = np.concatenate([y[folds[u][1]] for u in others])
211
+ maps[c][v] = make_calibrator(self.calibration).fit(s, yy)
212
+ pred[c][va] = maps[c][v].predict(oof[c][va])
213
+ else:
214
+ for c in cands:
215
+ for v, (tr, va) in enumerate(folds):
216
+ maps[c][v] = make_calibrator(self.calibration).fit(oof[c][tr], y[tr])
217
+ pred[c][va] = maps[c][v].predict(oof[c][va])
218
+ crit_obs = {c: _loss(self.criterion, y, pred[c]) for c in cands}
219
+ crit = {c: float(v.mean()) for c, v in crit_obs.items()}
220
+ best = min(cands, key=crit.get)
221
+ base = cands[0]
222
+ if self.selection == "1se" and best != base:
223
+ d = crit_obs[best] - crit_obs[base]
224
+ if not d.mean() < -d.std(ddof=1) / np.sqrt(len(d)):
225
+ best = base
226
+ elif self.selection == "safe":
227
+ best, self.safe_z_, self.safe_critical_value_ = self._safe_select(cands, crit, crit_obs, fid, rng)
228
+ self.selected_ = best
229
+ self.cv_results_ = {c.label(): crit[c] for c in cands}
230
+ self.candidate_distance_ = float(np.sqrt(np.mean((oof[best] - oof[base]) ** 2)))
231
+ if self.output == "ensemble":
232
+ self.estimators_ = fold_est[best]
233
+ self.calibrators_ = maps[best]
234
+ self.fold_weights_ = np.array([len(va) for _, va in folds], float) / len(y)
235
+ else:
236
+ self.calibrator_ = make_calibrator(self.calibration).fit(oof[best], y)
237
+ self.estimator_, _ = self._fit_predict(X, y, X[:1], best, rng)
238
+ self.n_features_in_ = X.shape[1]
239
+ return self
240
+
241
+ def _safe_select(self, cands, crit, crit_obs, fid, rng):
242
+ base = cands[0]
243
+ others = [c for c in cands[1:]]
244
+ n = len(fid)
245
+ D = np.array([crit_obs[c] - crit_obs[base] for c in others])
246
+ Dc = D.copy()
247
+ var = np.zeros(len(others))
248
+ for v in np.unique(fid):
249
+ idx = fid == v
250
+ var += idx.sum() / n * D[:, idx].var(1, ddof=1)
251
+ Dc[:, idx] -= Dc[:, idx].mean(1, keepdims=True)
252
+ tau = self.variance_floor * crit_obs[base].std()
253
+ active = np.flatnonzero(np.maximum(var, tau ** 2) > 0)
254
+ z = np.zeros(len(others))
255
+ z[active] = D[active].mean(1) / np.sqrt(np.maximum(var[active], tau ** 2) / n)
256
+ zdict = {c.label(): float(z[j]) for j, c in enumerate(others)}
257
+ if len(active) == 0:
258
+ return base, zdict, np.inf
259
+ R = np.atleast_2d(np.nan_to_num(np.corrcoef(Dc[active]))) + 1e-9 * np.eye(len(active))
260
+ W = rng.multivariate_normal(np.zeros(len(active)), R, size=20000, method="eigh")
261
+ c_alpha = float(-np.quantile(W.min(1), self.alpha))
262
+ passed = [others[j] for j in active if z[j] < -c_alpha]
263
+ if not passed:
264
+ return base, zdict, c_alpha
265
+ return min(passed, key=crit.get), zdict, c_alpha
266
+
267
+ def predict_proba(self, X):
268
+ check_is_fitted(self, "selected_")
269
+ X = validate_data(self, X, reset=False)
270
+ if self.output == "ensemble":
271
+ p1 = np.average([m.predict(np.clip(e.predict_proba(X)[:, 1], 1e-6, 1 - 1e-6))
272
+ for e, m in zip(self.estimators_, self.calibrators_)], axis=0,
273
+ weights=self.fold_weights_)
274
+ else:
275
+ p1 = self.calibrator_.predict(np.clip(self.estimator_.predict_proba(X)[:, 1], 1e-6, 1 - 1e-6))
276
+ return np.c_[1 - p1, p1]
277
+
278
+ def predict(self, X, threshold: float = 0.5):
279
+ check_is_fitted(self, "selected_")
280
+ return self.classes_[(self.predict_proba(X)[:, 1] >= threshold).astype(int)]
imbcal/generators.py ADDED
@@ -0,0 +1,73 @@
1
+ """Synthetic minority generators.
2
+
3
+ A generator draws ``m`` synthetic minority points from the observed minority sample. Any object with a
4
+ ``sample(X_min, m, rng)`` method can be used, so deep generators (CTGAN, TVAE, diffusion models) plug in through a
5
+ thin wrapper.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ from sklearn.neighbors import NearestNeighbors
11
+
12
+
13
+ class BaseGenerator:
14
+ """Interface: ``sample(X_min, m, rng) -> ndarray of shape (m, n_features)``."""
15
+
16
+ def sample(self, X_min: np.ndarray, m: int, rng: np.random.Generator) -> np.ndarray: # pragma: no cover
17
+ raise NotImplementedError
18
+
19
+ def __repr__(self) -> str:
20
+ args = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
21
+ return f"{type(self).__name__}({args})"
22
+
23
+
24
+ class SMOTEGenerator(BaseGenerator):
25
+ """SMOTE interpolation towards one of the ``k`` nearest minority neighbours.
26
+
27
+ ``k="sqrt"`` uses ``ceil(sqrt(n_min))`` neighbours, a wider kernel than the usual ``k=5``.
28
+ """
29
+
30
+ def __init__(self, k: int | str = 5):
31
+ self.k = k
32
+
33
+ def sample(self, X_min, m, rng):
34
+ n1 = len(X_min)
35
+ if n1 < 2:
36
+ raise ValueError("SMOTE needs at least two minority points")
37
+ k = int(np.ceil(np.sqrt(n1))) if self.k == "sqrt" else int(self.k)
38
+ k = max(1, min(k, n1 - 1))
39
+ nb = NearestNeighbors(n_neighbors=k + 1).fit(X_min).kneighbors(X_min, return_distance=False)[:, 1:]
40
+ i = rng.integers(0, n1, m)
41
+ j = nb[i, rng.integers(0, k, m)]
42
+ return X_min[i] + rng.random((m, 1)) * (X_min[j] - X_min[i])
43
+
44
+
45
+ class GaussianGenerator(BaseGenerator):
46
+ """Smoothed bootstrap: a resampled minority point plus ``N(0, h^2 I)`` noise.
47
+
48
+ ``h`` is in the units of the features passed to ``fit`` (standardize first for a scale-free ``h``). With a
49
+ Gaussian-kernel learner this is exactly a wider bandwidth for the minority class.
50
+ """
51
+
52
+ def __init__(self, h: float = 0.25):
53
+ self.h = h
54
+
55
+ def sample(self, X_min, m, rng):
56
+ return X_min[rng.integers(0, len(X_min), m)] + self.h * rng.normal(size=(m, X_min.shape[1]))
57
+
58
+
59
+ class CallableGenerator(BaseGenerator):
60
+ """Wrap a function ``f(X_min, m, rng) -> ndarray``."""
61
+
62
+ def __init__(self, func, name: str = "callable"):
63
+ self.func = func
64
+ self.name = name
65
+
66
+ def sample(self, X_min, m, rng):
67
+ out = np.asarray(self.func(X_min, m, rng), dtype=float)
68
+ if out.shape != (m, X_min.shape[1]):
69
+ raise ValueError(f"generator returned shape {out.shape}, expected {(m, X_min.shape[1])}")
70
+ return out
71
+
72
+ def __repr__(self) -> str:
73
+ return f"CallableGenerator({self.name})"
imbcal/metrics.py ADDED
@@ -0,0 +1,70 @@
1
+ """Metrics for probability estimation and paired comparisons over repeated splits."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ from scipy.special import logit
6
+ from sklearn.linear_model import LogisticRegression
7
+ from sklearn.metrics import brier_score_loss, log_loss, roc_auc_score
8
+
9
+
10
+ def calibration_slope_intercept(y, p, eps: float = 1e-6):
11
+ """Logistic recalibration of ``logit(p)``: returns (slope, intercept). Ideal values: 1 and 0."""
12
+ z = logit(np.clip(np.asarray(p, float), eps, 1 - eps)).reshape(-1, 1)
13
+ m = LogisticRegression(C=1e6).fit(z, y)
14
+ return float(m.coef_[0, 0]), float(m.intercept_[0])
15
+
16
+
17
+ def expected_calibration_error(y, p, n_bins: int = 10, strategy: str = "quantile"):
18
+ """ECE with equal-frequency (default) or equal-width bins. A secondary, binning-dependent summary."""
19
+ y = np.asarray(y, float); p = np.asarray(p, float)
20
+ if strategy == "quantile":
21
+ edges = np.unique(np.quantile(p, np.linspace(0, 1, n_bins + 1)))
22
+ else:
23
+ edges = np.linspace(0, 1, n_bins + 1)
24
+ idx = np.clip(np.searchsorted(edges, p, side="right") - 1, 0, len(edges) - 2)
25
+ ece = 0.0
26
+ for b in range(len(edges) - 1):
27
+ sel = idx == b
28
+ if sel.any():
29
+ ece += sel.mean() * abs(y[sel].mean() - p[sel].mean())
30
+ return float(ece)
31
+
32
+
33
+ def probability_report(y, p) -> dict:
34
+ """Log-loss, Brier score, AUC, calibration slope and intercept, ECE and mean predicted probability."""
35
+ p = np.clip(np.asarray(p, float), 1e-12, 1 - 1e-12)
36
+ slope, intercept = calibration_slope_intercept(y, p)
37
+ return dict(log_loss=float(log_loss(y, p, labels=[0, 1])), brier=float(brier_score_loss(y, p)),
38
+ auc=float(roc_auc_score(y, p)) if 0 < np.sum(y) < len(y) else float("nan"),
39
+ calibration_slope=slope, calibration_intercept=intercept,
40
+ ece=expected_calibration_error(y, p), mean_prediction=float(p.mean()), prevalence=float(np.mean(y)))
41
+
42
+
43
+ def paired_comparison(scores_a, scores_b, n_train: int, n_test: int) -> dict:
44
+ """Compare two methods evaluated on the same B repeated splits (lower score = better).
45
+
46
+ Returns the mean difference ``a - b``, the naive and the Nadeau--Bengio corrected standard errors,
47
+ sd(D) * sqrt(1/B + n_test/n_train), the corrected z statistic, the number of splits in which ``a`` is
48
+ better (``wins_a``) and the two-sided exact sign-test p-value.
49
+
50
+ The correction was derived for random train/test splits of one data set with n_test < n_train. When the
51
+ training sets are small relative to the test sets the factor n_test/n_train makes it very conservative
52
+ (it does not vanish as B grows), and a warning is issued; the sign test is then a useful complement.
53
+ """
54
+ import warnings
55
+ from scipy.stats import binomtest
56
+ d = np.asarray(scores_a, float) - np.asarray(scores_b, float)
57
+ B = len(d)
58
+ if B < 2:
59
+ raise ValueError("need at least two splits")
60
+ if n_test > n_train:
61
+ warnings.warn("n_test > n_train: the Nadeau-Bengio correction is very conservative in this design",
62
+ UserWarning, stacklevel=2)
63
+ sd = d.std(ddof=1)
64
+ se_naive = sd / np.sqrt(B)
65
+ se_nb = sd * np.sqrt(1 / B + n_test / n_train)
66
+ wins = int(np.sum(d < 0)); ties = int(np.sum(d == 0))
67
+ p_sign = float(binomtest(wins, B - ties, 0.5).pvalue) if B - ties > 0 else 1.0
68
+ return dict(mean_difference=float(d.mean()), se_naive=float(se_naive), se_corrected=float(se_nb),
69
+ z_corrected=float(d.mean() / se_nb) if se_nb > 0 else 0.0, wins_a=wins, sign_test_p=p_sign,
70
+ n_splits=B)
imbcal/planning.py ADDED
@@ -0,0 +1,91 @@
1
+ """rebalancing_cost: what will rebalancing cost before you fit the model?
2
+
3
+ Asymptotic prediction (proportional regime, two-class Gaussian mixture; Theorems 2 and 5 of the paper, the latter
4
+ solved at zero penalty) of the validation calibration slope, AUC and log-loss of an unpenalized logistic regression fitted
5
+ - by maximum likelihood ("mle"),
6
+ - with balanced class weights followed by the intercept correction ("weights"),
7
+ - after random undersampling to balance, with the intercept correction ("undersampling"),
8
+ - after balanced SMOTE, with the intercept correction ("smote"; contracted-copies model),
9
+ together with the van Houwelingen shrinkage factor used in sample-size criteria (Riley et al., 2019).
10
+
11
+ Inputs are quantities that can be specified before data collection: the number of model parameters p, the
12
+ outcome prevalence, either the development sample size n or the events per variable (EPV), and the anticipated
13
+ AUC of a well-fitted model. The predictions hold in the Gaussian-mixture model. In the two applications of the paper
14
+ the observed damage was at least as large, more so with many rare binary predictors and at low EPV; this is an
15
+ empirical observation, not a guarantee.
16
+
17
+ Usage:
18
+ >>> from imbcal.planning import rebalancing_cost, print_rebalancing_cost
19
+ >>> print_rebalancing_cost(rebalancing_cost(p=71, prevalence=0.092, epv=2.5, auc=0.81))
20
+ """
21
+ import warnings
22
+ import numpy as np
23
+ from scipy.stats import norm
24
+ from scipy.special import expit, logit
25
+ from scipy.optimize import brentq
26
+ from . import _cgmt_weights as CP
27
+ from . import _cgmt_copies as CC
28
+
29
+ GH_X, GH_W = CP.gh_x, CP.gh_w
30
+
31
+ def _weights_solution(s, pi, kappa, c):
32
+ """Unpenalized class-weighted logistic regression (Theorem 2): returns (m, q, b) with continuation in kappa and c."""
33
+ sol = CP.path(s, pi, kappa, [c])
34
+ S = sol[c] if c != 1 else sol[1.0]
35
+ a, sig = S["alpha"], S["sigma"]
36
+ return a * s, a * a + sig * sig, S["b"], bool(S["ok"])
37
+
38
+ def _evaluate(m, q, b_eff, s, pi):
39
+ """Score T | y ~ N(y m, q); predicted log-odds T + b_eff; returns slope, AUC, log-loss (exact under the mixture)."""
40
+ rho = m / (s * np.sqrt(q))
41
+ ll = 0.0
42
+ for y, py in [(1, pi), (-1, 1 - pi)]:
43
+ z = y * m + np.sqrt(q) * GH_X + b_eff
44
+ ll += py * np.sum(GH_W * (np.logaddexp(0, -z) if y == 1 else np.logaddexp(0, z)))
45
+ return dict(calibration_slope=2 * m / q, auc=float(norm.cdf(np.sqrt(2) * s * rho)), log_loss=float(ll))
46
+
47
+ def rebalancing_cost(p, prevalence, auc, n=None, epv=None, smote_contraction=2 / 3):
48
+ """Predicted validation metrics for each method. Give n (development sample size) or epv (events per parameter).
49
+ auc: anticipated AUC of a well-fitted model, used to set the separation s = Phi^{-1}(auc)/sqrt(2).
50
+ smote_contraction: squared-distance contraction of SMOTE points toward the minority mean (2/3 in high dimension,
51
+ Lemma 2; larger, e.g. 0.8-0.9, with many binary predictors)."""
52
+ with warnings.catch_warnings():
53
+ warnings.simplefilter("ignore")
54
+ return _rebalancing_cost(p, prevalence, auc, n=n, epv=epv, smote_contraction=smote_contraction)
55
+
56
+
57
+ def _rebalancing_cost(p, prevalence, auc, n=None, epv=None, smote_contraction=2 / 3):
58
+ if (n is None) == (epv is None):
59
+ raise ValueError("give exactly one of n or epv")
60
+ pi = float(prevalence)
61
+ n = float(n) if n is not None else epv * p / pi
62
+ kappa = p / n
63
+ s = norm.ppf(auc) / np.sqrt(2)
64
+ r = (1 - pi) / pi
65
+ out = {}
66
+ m, q, b, ok = _weights_solution(s, pi, kappa, 1.0); out["mle"] = _evaluate(m, q, b, s, pi)
67
+ m, q, b, ok = _weights_solution(s, pi, kappa, r); out["weights"] = _evaluate(m, q, b - np.log(r), s, pi)
68
+ m, q, b, ok = _weights_solution(s, 0.5, kappa / (2 * pi), 1.0); out["undersampling"] = _evaluate(m, q, b - np.log(r), s, pi)
69
+ sol = CC.path(s, pi, kappa, r, smote_contraction)
70
+ nw2 = sol["alpha"] ** 2 + sol["tau"] ** 2 + sol["theta"] ** 2
71
+ A = sol["alpha"] * s # m = <w, mu>
72
+ out["smote"] = _evaluate(A, nw2, sol["b"] - np.log(r), s, pi)
73
+ # van Houwelingen shrinkage factor with R2_CS of the Bayes model
74
+ H = -(pi * np.log(pi) + (1 - pi) * np.log(1 - pi))
75
+ Lb = _evaluate(2 * s * s, 4 * s * s, logit(pi), s, pi)["log_loss"]
76
+ R2 = 1 - np.exp(-2 * (H - Lb))
77
+ vh = brentq(lambda S: (S - 1) * np.log(1 - R2 / S) - kappa, R2 + 1e-9, 1 - 1e-12)
78
+ return dict(inputs=dict(p=p, prevalence=pi, n=n, epv=pi * n / p, kappa=kappa, auc=auc, separation=s,
79
+ smote_contraction=smote_contraction),
80
+ methods=out, van_houwelingen_shrinkage=vh)
81
+
82
+ def print_rebalancing_cost(res):
83
+ i = res["inputs"]
84
+ print(f"p={i['p']} prevalence={i['prevalence']:.3f} n={i['n']:.0f} EPV={i['epv']:.2f} kappa=p/n={i['kappa']:.4f} "
85
+ f"anticipated AUC={i['auc']:.3f}")
86
+ print(f"{'method':<15}{'cal. slope':>11}{'AUC':>8}{'log-loss':>10}")
87
+ for k, v in res["methods"].items():
88
+ print(f"{k:<15}{v['calibration_slope']:>11.3f}{v['auc']:>8.3f}{v['log_loss']:>10.4f}")
89
+ print(f"van Houwelingen shrinkage factor (maximum likelihood): {res['van_houwelingen_shrinkage']:.3f}")
90
+ print("Asymptotic Gaussian-mixture predictions; in the paper's two applications the observed damage was at least this large (empirical, not a guarantee).")
91
+
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: imbcal
3
+ Version: 0.3.0
4
+ Summary: Calibrated probability estimation under class imbalance: safe pipelines, prevalence-preserving augmentation and diagnostics
5
+ Author-email: Rolando de la Cruz <rolando.delacruz@uai.cl>
6
+ License-Expression: MIT
7
+ Keywords: calibration,class imbalance,SMOTE,synthetic data,recalibration,scikit-learn,risk prediction
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: numpy>=1.24
22
+ Requires-Dist: scipy>=1.10
23
+ Requires-Dist: scikit-learn>=1.6
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest>=7; extra == "test"
26
+ Dynamic: license-file
27
+
28
+ # imbcal
29
+
30
+ **Calibrated probability estimation under class imbalance.**
31
+
32
+ ```
33
+ pip install imbcal
34
+ ```
35
+
36
+ Rebalancing (class weights, undersampling, SMOTE) is the default answer to class imbalance, but it targets classification and damages the probabilities that risk prediction needs, even after recalibration. `imbcal` wraps any scikit-learn classifier with pipelines that estimate probabilities at the natural prevalence:
37
+
38
+ - **`strategy="none"`** (recommended default): fit at the natural prevalence, then recalibrate (Platt or isotonic) on out-of-fold scores.
39
+ - **`strategy="augment"`**: *prevalence-preserving augmentation*. Synthetic minority points from any generator receive weights that keep the training prevalence unchanged. Real minority points get weight `chi`, and the `m` synthetic points get `(1 - chi) n_min / m`. The generator and `chi` are chosen by cross-validation of the **recalibrated** loss, and `chi = 1` (no augmentation) is always a candidate, so the selection can fall back to the natural fit.
40
+ - **`strategy="weights"`**: balanced weights with recalibration, for comparison.
41
+ - **`selection="safe"`**: augment only if some candidate beats no augmentation with simultaneous significance (cross-validation with confidence and a max-T critical value), which controls the probability of choosing a harmful augmentation.
42
+
43
+ It also provides metrics (log-loss, Brier score, calibration slope and intercept, ECE), paired comparisons over repeated splits (Nadeau–Bengio corrected standard errors and a sign test), diagnostics for synthetic data, and a planning tool:
44
+
45
+ ```python
46
+ from imbcal import rebalancing_cost, print_rebalancing_cost
47
+ # predicted calibration slope, AUC and log-loss of maximum likelihood and of rebalanced
48
+ # logistic regressions, before any data are collected
49
+ print_rebalancing_cost(rebalancing_cost(p=36, prevalence=0.092, auc=0.81, epv=2.5))
50
+ ```
51
+
52
+ ## Example
53
+
54
+ ```python
55
+ from sklearn.ensemble import RandomForestClassifier
56
+ from imbcal import ImbalanceCalibratedClassifier, GaussianGenerator, SMOTEGenerator, probability_report
57
+
58
+ model = ImbalanceCalibratedClassifier(
59
+ RandomForestClassifier(min_samples_leaf=10),
60
+ strategy="augment",
61
+ generators=[GaussianGenerator(0.25), SMOTEGenerator(k="sqrt")],
62
+ calibration="platt", selection="1se", random_state=0,
63
+ ).fit(X_train, y_train)
64
+
65
+ print(model.selected_.label(), model.candidate_distance_)
66
+ print(probability_report(y_test, model.predict_proba(X_test)[:, 1]))
67
+ ```
68
+
69
+ Any object with a `sample(X_min, m, rng)` method can be used as a generator, including wrappers around deep tabular generators.
70
+
71
+ ## Status
72
+
73
+ Alpha (0.3.0). Binary outcomes only; requires scikit-learn >= 1.6. The API may change before 1.0. Planned additions:
74
+ - generators for mixed (continuous and categorical) features;
75
+ - group-wise calibration diagnostics;
76
+ - wrappers for deep tabular generators;
77
+ - multiclass outcomes.
78
+
79
+ ## License
80
+
81
+ MIT.
@@ -0,0 +1,14 @@
1
+ imbcal/__init__.py,sha256=uu0tzQAYxbDINBeJJthLeckGXHAohSTuJa_WlSvwo3g,1522
2
+ imbcal/_cgmt_copies.py,sha256=lgBPoa1FihXGNLfk7e-p_DWQfY6vjvxZTHoM4K4WBU4,4998
3
+ imbcal/_cgmt_weights.py,sha256=UAqDbvr9Vtpq-2d3ifL-htz_jEI_K1nQQJMmg6VBtfo,5546
4
+ imbcal/calibration.py,sha256=A1HGy6Oa41EmIPXxFhxkhk6SPJVVGHzcd5VyBr8PJW4,1945
5
+ imbcal/diagnostics.py,sha256=4QutKPOfMCWQd5xZfGv-HFn8aofiSXKxddq3DyeOizI,970
6
+ imbcal/estimator.py,sha256=Wdwz-t3wKWF8AQnoW6m7DNRpMqeAcsf8Pw3lOXdB5XI,15108
7
+ imbcal/generators.py,sha256=ZG52DdVRMMb1dzVhC3gAU9zvwhigghP6qJMvIa65g_M,2662
8
+ imbcal/metrics.py,sha256=HDWyG3R7aIcKwoypVSnMwlg0Yrn1HEQWtJ660NwF3hU,3595
9
+ imbcal/planning.py,sha256=WgomwCgf8ZhFqBgQd7lk6ntkfvPlN2AMQyt3xnDQ3Zc,5302
10
+ imbcal-0.3.0.dist-info/licenses/LICENSE,sha256=Dm0S1bb57ViulhZc6fylUMG01s1sCxMcB7HPNxoHJ7I,1075
11
+ imbcal-0.3.0.dist-info/METADATA,sha256=9p3UycwJn9snIstfjSzhPMIXrglmAb6krXpnsiBMJ-c,4044
12
+ imbcal-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ imbcal-0.3.0.dist-info/top_level.txt,sha256=Y137XCijEDPuPw32x_iB5ICllkTqO-j83zJNwbPGcfY,7
14
+ imbcal-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rolando de la Cruz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ imbcal