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 +21 -0
- imbcal/_cgmt_copies.py +85 -0
- imbcal/_cgmt_weights.py +116 -0
- imbcal/calibration.py +63 -0
- imbcal/diagnostics.py +17 -0
- imbcal/estimator.py +280 -0
- imbcal/generators.py +73 -0
- imbcal/metrics.py +70 -0
- imbcal/planning.py +91 -0
- imbcal-0.3.0.dist-info/METADATA +81 -0
- imbcal-0.3.0.dist-info/RECORD +14 -0
- imbcal-0.3.0.dist-info/WHEEL +5 -0
- imbcal-0.3.0.dist-info/licenses/LICENSE +21 -0
- imbcal-0.3.0.dist-info/top_level.txt +1 -0
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
|
+
|
imbcal/_cgmt_weights.py
ADDED
|
@@ -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,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
|