idaniloju 0.1.0__tar.gz

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.
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.4
2
+ Name: idaniloju
3
+ Version: 0.1.0
4
+ Summary: Conformal prediction from scratch: the exact finite-sample guarantee, what drift does to it, and the online repair
5
+ Author-email: Kenny Obidele <obidelek19@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Kenny0bi/idaniloju
8
+ Project-URL: Issues, https://github.com/Kenny0bi/idaniloju/issues
9
+ Keywords: conformal-prediction,uncertainty-quantification,prediction-intervals,distribution-free,drift
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: numpy>=1.24
18
+
19
+ # idaniloju
20
+
21
+ Conformal prediction from scratch: the exact finite-sample guarantee,
22
+ proof by experiment, the deployment that breaks it, and the online
23
+ repair. The tenth and last project of this series, and it closes the
24
+ loop: the drift that kills the guarantee here is detected by
25
+ [iyipada](https://github.com/Kenny0bi/iyipada), my drift library,
26
+ installed from PyPI like any other user would.
27
+
28
+ ```
29
+ pip install idaniloju
30
+ ```
31
+
32
+ *Idaniloju* is Yoruba for assurance. Conformal prediction is the cleanest
33
+ promise in machine learning: wrap ANY point predictor, and its intervals
34
+ will contain the truth 90% of the time, guaranteed at finite sample size
35
+ with no distributional assumptions at all. The entire method is one
36
+ counting argument, and this repository walks it from the theorem to the
37
+ hurricane that broke it.
38
+
39
+ ## The guarantee is a law, not a hope
40
+
41
+ ![The law](assets/law.svg)
42
+
43
+ 600 fresh train/calibrate/test splits of the same data. Conditional
44
+ coverage is not just ">= 90% on average": it has a known exact
45
+ distribution, Beta(n+1-l, l) over the calibration draw (Vovk 2012). The
46
+ 600-run empirical mean lands on the law's mean to the fourth digit
47
+ (0.9001 vs 0.9001), and the histogram sits inside the density like it
48
+ was poured there. Also visible: 47% of individual runs are below 0.90,
49
+ which is exactly what the law says should happen, and exactly the fine
50
+ print people miss.
51
+
52
+ ![The animation](assets/ranks.gif)
53
+
54
+ The whole proof: an exchangeable point's score has uniform rank among
55
+ the n+1 scores, so it falls at or below the ceil((n+1)(1-alpha))-th
56
+ calibration score with probability at least 1-alpha. Counting, nothing
57
+ else. (Source: [assets/manim_ranks.py](assets/manim_ranks.py), video in
58
+ [assets/ranks.mp4](assets/ranks.mp4).)
59
+
60
+ ## Then you deploy it, and the fine print detonates
61
+
62
+ Train on early 2011 of the Washington DC bike-sharing panel, calibrate
63
+ on late 2011, then serve every hour of 2012 in order:
64
+
65
+ ![The break](assets/break.svg)
66
+
67
+ 2012 ridership grew about 65% over 2011. Exchangeability with the
68
+ calibration set is simply false, and the guarantee follows it down:
69
+ 78% delivered against 90% promised for the year, and when Hurricane
70
+ Sandy closes the city on October 29 (22 rides all day against a normal
71
+ 7,500), the rolling window bottoms out at 42%.
72
+
73
+ Three things about that figure that I care about:
74
+
75
+ - **iyipada alarms in the first week of 2012** (PSI 0.15 on the model's
76
+ conformity scores, p = 0.005), eleven months before Sandy. The drift
77
+ was structural, not meteorological.
78
+ - **The shuffled control behaves**: run the same machinery on a
79
+ shuffled (exchangeable) version of the stream and coverage holds at
80
+ 90.3% with zero alarm weeks out of 51. The alarm is not trigger-happy;
81
+ the guarantee did not break by accident.
82
+ - **Adaptive conformal repairs it online.** ACI (Gibbs & Candes 2021)
83
+ moves the miscoverage dial with the observed errors,
84
+ alpha_t+1 = alpha_t + gamma(alpha - err_t), and ends the year at
85
+ 89.97% against the 90% target, hurricane included, without ever
86
+ refitting the model.
87
+
88
+ ## What the repair costs
89
+
90
+ ![The price](assets/price.svg)
91
+
92
+ Nothing is free: the adaptive intervals are about 5% wider in the median
93
+ hour, and for 36 hours around Sandy the dial demands more assurance than
94
+ 4,395 calibration points can certify, so the honest interval is
95
+ unbounded. I consider that a feature: a system that says "this week I
96
+ can promise nothing" is telling the truth, and the fixed system that
97
+ kept quoting tidy intervals through a hurricane was not.
98
+
99
+ ![The anatomy](assets/anatomy.svg)
100
+
101
+ ## Using it
102
+
103
+ ```python
104
+ from idaniloju import Ridge, SplitConformal, AdaptiveConformal, coverage
105
+
106
+ sc = SplitConformal(Ridge(lam=5.0), alpha=0.1) # any model with fit/predict
107
+ sc.fit(X_train, y_train, X_cal, y_cal)
108
+ lo, hi = sc.predict(X_new)
109
+
110
+ aci = AdaptiveConformal(sc, gamma=0.01) # for streams
111
+ lo, hi, err = aci.step(x_t, y_t)
112
+ ```
113
+
114
+ `SplitConformal` takes any object with fit/predict (a scale model makes
115
+ the intervals difficulty-aware without touching the guarantee), and
116
+ `conformal_quantile` is the exact order statistic the theorem is proved
117
+ for, not an interpolated quantile: at small n and small alpha it
118
+ returns an infinite interval instead of a fake finite one.
119
+
120
+ ## Honest limits
121
+
122
+ - The guarantee is **marginal**: 90% over everything, not 90% for every
123
+ hour of the day or every kind of input. Group-conditional coverage
124
+ needs group-wise calibration this library does not implement.
125
+ - ACI trades a broken assumption for a feedback loop: it needs y_t
126
+ observed reasonably soon after predicting. With delayed labels the
127
+ dial lags exactly that delay.
128
+ - The base model here is a ridge regression on purpose (any model
129
+ works). Sharper models give narrower intervals; nothing about the
130
+ coverage story changes.
131
+ - The 65% growth and the hurricane make this dataset a vivid drift
132
+ story, which is why I chose it; datasets with gentler drift break more
133
+ slowly and less photogenically.
134
+
135
+ ## Reproduce it
136
+
137
+ ```bash
138
+ python -m venv .venv && .venv/bin/pip install numpy pytest iyipada
139
+ bash data/get_data.sh # or let the loader fetch it
140
+
141
+ .venv/bin/python -m pytest tests/ -q # 6 contracts
142
+ .venv/bin/python benchmarks/bench_guarantee.py # the Beta law, 600 refits
143
+ .venv/bin/python benchmarks/bench_stream.py # the 2012 deployment
144
+ .venv/bin/python assets/make_visuals.py # the four figures
145
+ ```
146
+
147
+ ## Layout
148
+
149
+ - [idaniloju/conformal.py](idaniloju/conformal.py) the method: exact
150
+ quantile, split conformal, difficulty scaling, ACI
151
+ - [idaniloju/models.py](idaniloju/models.py) a closed-form ridge, the
152
+ deliberately boring predictor
153
+ - [idaniloju/bike.py](idaniloju/bike.py) the panel, with the year column
154
+ deliberately withheld from the features (deployment does not get told
155
+ the world changed)
156
+ - [benchmarks/](benchmarks/) the law and the deployment, everything into
157
+ JSON the figures and tests read
158
+
159
+ ## Papers
160
+
161
+ - Vovk, Gammerman, Shafer (2005), *Algorithmic Learning in a Random
162
+ World*. The theory.
163
+ - Lei et al. (2018), *Distribution-Free Predictive Inference for
164
+ Regression*, JASA. The split conformal recipe used here.
165
+ - Vovk (2012), *Conditional validity of inductive conformal predictors*.
166
+ The Beta law the first figure draws.
167
+ - Gibbs & Candes (2021), *Adaptive Conformal Inference Under
168
+ Distribution Shift*. The repair.
@@ -0,0 +1,150 @@
1
+ # idaniloju
2
+
3
+ Conformal prediction from scratch: the exact finite-sample guarantee,
4
+ proof by experiment, the deployment that breaks it, and the online
5
+ repair. The tenth and last project of this series, and it closes the
6
+ loop: the drift that kills the guarantee here is detected by
7
+ [iyipada](https://github.com/Kenny0bi/iyipada), my drift library,
8
+ installed from PyPI like any other user would.
9
+
10
+ ```
11
+ pip install idaniloju
12
+ ```
13
+
14
+ *Idaniloju* is Yoruba for assurance. Conformal prediction is the cleanest
15
+ promise in machine learning: wrap ANY point predictor, and its intervals
16
+ will contain the truth 90% of the time, guaranteed at finite sample size
17
+ with no distributional assumptions at all. The entire method is one
18
+ counting argument, and this repository walks it from the theorem to the
19
+ hurricane that broke it.
20
+
21
+ ## The guarantee is a law, not a hope
22
+
23
+ ![The law](assets/law.svg)
24
+
25
+ 600 fresh train/calibrate/test splits of the same data. Conditional
26
+ coverage is not just ">= 90% on average": it has a known exact
27
+ distribution, Beta(n+1-l, l) over the calibration draw (Vovk 2012). The
28
+ 600-run empirical mean lands on the law's mean to the fourth digit
29
+ (0.9001 vs 0.9001), and the histogram sits inside the density like it
30
+ was poured there. Also visible: 47% of individual runs are below 0.90,
31
+ which is exactly what the law says should happen, and exactly the fine
32
+ print people miss.
33
+
34
+ ![The animation](assets/ranks.gif)
35
+
36
+ The whole proof: an exchangeable point's score has uniform rank among
37
+ the n+1 scores, so it falls at or below the ceil((n+1)(1-alpha))-th
38
+ calibration score with probability at least 1-alpha. Counting, nothing
39
+ else. (Source: [assets/manim_ranks.py](assets/manim_ranks.py), video in
40
+ [assets/ranks.mp4](assets/ranks.mp4).)
41
+
42
+ ## Then you deploy it, and the fine print detonates
43
+
44
+ Train on early 2011 of the Washington DC bike-sharing panel, calibrate
45
+ on late 2011, then serve every hour of 2012 in order:
46
+
47
+ ![The break](assets/break.svg)
48
+
49
+ 2012 ridership grew about 65% over 2011. Exchangeability with the
50
+ calibration set is simply false, and the guarantee follows it down:
51
+ 78% delivered against 90% promised for the year, and when Hurricane
52
+ Sandy closes the city on October 29 (22 rides all day against a normal
53
+ 7,500), the rolling window bottoms out at 42%.
54
+
55
+ Three things about that figure that I care about:
56
+
57
+ - **iyipada alarms in the first week of 2012** (PSI 0.15 on the model's
58
+ conformity scores, p = 0.005), eleven months before Sandy. The drift
59
+ was structural, not meteorological.
60
+ - **The shuffled control behaves**: run the same machinery on a
61
+ shuffled (exchangeable) version of the stream and coverage holds at
62
+ 90.3% with zero alarm weeks out of 51. The alarm is not trigger-happy;
63
+ the guarantee did not break by accident.
64
+ - **Adaptive conformal repairs it online.** ACI (Gibbs & Candes 2021)
65
+ moves the miscoverage dial with the observed errors,
66
+ alpha_t+1 = alpha_t + gamma(alpha - err_t), and ends the year at
67
+ 89.97% against the 90% target, hurricane included, without ever
68
+ refitting the model.
69
+
70
+ ## What the repair costs
71
+
72
+ ![The price](assets/price.svg)
73
+
74
+ Nothing is free: the adaptive intervals are about 5% wider in the median
75
+ hour, and for 36 hours around Sandy the dial demands more assurance than
76
+ 4,395 calibration points can certify, so the honest interval is
77
+ unbounded. I consider that a feature: a system that says "this week I
78
+ can promise nothing" is telling the truth, and the fixed system that
79
+ kept quoting tidy intervals through a hurricane was not.
80
+
81
+ ![The anatomy](assets/anatomy.svg)
82
+
83
+ ## Using it
84
+
85
+ ```python
86
+ from idaniloju import Ridge, SplitConformal, AdaptiveConformal, coverage
87
+
88
+ sc = SplitConformal(Ridge(lam=5.0), alpha=0.1) # any model with fit/predict
89
+ sc.fit(X_train, y_train, X_cal, y_cal)
90
+ lo, hi = sc.predict(X_new)
91
+
92
+ aci = AdaptiveConformal(sc, gamma=0.01) # for streams
93
+ lo, hi, err = aci.step(x_t, y_t)
94
+ ```
95
+
96
+ `SplitConformal` takes any object with fit/predict (a scale model makes
97
+ the intervals difficulty-aware without touching the guarantee), and
98
+ `conformal_quantile` is the exact order statistic the theorem is proved
99
+ for, not an interpolated quantile: at small n and small alpha it
100
+ returns an infinite interval instead of a fake finite one.
101
+
102
+ ## Honest limits
103
+
104
+ - The guarantee is **marginal**: 90% over everything, not 90% for every
105
+ hour of the day or every kind of input. Group-conditional coverage
106
+ needs group-wise calibration this library does not implement.
107
+ - ACI trades a broken assumption for a feedback loop: it needs y_t
108
+ observed reasonably soon after predicting. With delayed labels the
109
+ dial lags exactly that delay.
110
+ - The base model here is a ridge regression on purpose (any model
111
+ works). Sharper models give narrower intervals; nothing about the
112
+ coverage story changes.
113
+ - The 65% growth and the hurricane make this dataset a vivid drift
114
+ story, which is why I chose it; datasets with gentler drift break more
115
+ slowly and less photogenically.
116
+
117
+ ## Reproduce it
118
+
119
+ ```bash
120
+ python -m venv .venv && .venv/bin/pip install numpy pytest iyipada
121
+ bash data/get_data.sh # or let the loader fetch it
122
+
123
+ .venv/bin/python -m pytest tests/ -q # 6 contracts
124
+ .venv/bin/python benchmarks/bench_guarantee.py # the Beta law, 600 refits
125
+ .venv/bin/python benchmarks/bench_stream.py # the 2012 deployment
126
+ .venv/bin/python assets/make_visuals.py # the four figures
127
+ ```
128
+
129
+ ## Layout
130
+
131
+ - [idaniloju/conformal.py](idaniloju/conformal.py) the method: exact
132
+ quantile, split conformal, difficulty scaling, ACI
133
+ - [idaniloju/models.py](idaniloju/models.py) a closed-form ridge, the
134
+ deliberately boring predictor
135
+ - [idaniloju/bike.py](idaniloju/bike.py) the panel, with the year column
136
+ deliberately withheld from the features (deployment does not get told
137
+ the world changed)
138
+ - [benchmarks/](benchmarks/) the law and the deployment, everything into
139
+ JSON the figures and tests read
140
+
141
+ ## Papers
142
+
143
+ - Vovk, Gammerman, Shafer (2005), *Algorithmic Learning in a Random
144
+ World*. The theory.
145
+ - Lei et al. (2018), *Distribution-Free Predictive Inference for
146
+ Regression*, JASA. The split conformal recipe used here.
147
+ - Vovk (2012), *Conditional validity of inductive conformal predictors*.
148
+ The Beta law the first figure draws.
149
+ - Gibbs & Candes (2021), *Adaptive Conformal Inference Under
150
+ Distribution Shift*. The repair.
@@ -0,0 +1,16 @@
1
+ """idaniloju: conformal prediction from scratch, and what drift does to it.
2
+
3
+ *Idaniloju* is Yoruba for assurance. Conformal prediction turns any point
4
+ predictor into interval predictions with a finite-sample coverage
5
+ guarantee that assumes nothing about the data's distribution, only that
6
+ calibration and future points are exchangeable. That one assumption is
7
+ also the method's weak joint: break it with drift and the guarantee
8
+ quietly dies. This library ships the estimator, the proof-by-experiment,
9
+ the failure, and the online repair.
10
+ """
11
+
12
+ from .conformal import SplitConformal, AdaptiveConformal, coverage
13
+ from .models import Ridge
14
+
15
+ __version__ = "0.1.0"
16
+ __all__ = ["SplitConformal", "AdaptiveConformal", "Ridge", "coverage"]
@@ -0,0 +1,69 @@
1
+ """The Washington DC bike-sharing panel (UCI 275): 17,379 hours over
2
+ 2011-2012, with weather. Chosen because its drift is real, not
3
+ simulated: 2012 ridership grew about 65% over 2011, and no model trained
4
+ on 2011 has seen that world.
5
+
6
+ The year indicator is deliberately excluded from the features: a
7
+ deployed model does not get told "the world has changed" as a column.
8
+ """
9
+
10
+ import csv
11
+ import io
12
+ import urllib.request
13
+ import zipfile
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+
18
+ DATA = Path(__file__).resolve().parents[1] / "data" / "hour.csv"
19
+ URL = "https://archive.ics.uci.edu/static/public/275/bike+sharing+dataset.zip"
20
+
21
+
22
+ def _fetch():
23
+ """Installed from PyPI there is no data/ folder; fetch to a cache."""
24
+ cache = Path.home() / ".cache" / "idaniloju" / "hour.csv"
25
+ if not cache.exists():
26
+ cache.parent.mkdir(parents=True, exist_ok=True)
27
+ with urllib.request.urlopen(URL, timeout=120) as r:
28
+ z = zipfile.ZipFile(io.BytesIO(r.read()))
29
+ cache.write_bytes(z.read("hour.csv"))
30
+ return cache
31
+
32
+
33
+ def load(path=None):
34
+ p = Path(path) if path else (DATA if DATA.exists() else _fetch())
35
+ rows = list(csv.DictReader(open(p, encoding="utf-8")))
36
+ n = len(rows)
37
+
38
+ def f(name):
39
+ return np.array([float(r[name]) for r in rows])
40
+
41
+ hr, wd = f("hr"), f("weekday")
42
+ season = f("season").astype(int)
43
+ weather = f("weathersit").astype(int)
44
+ feats = [
45
+ np.sin(2 * np.pi * wd / 7), np.cos(2 * np.pi * wd / 7),
46
+ f("temp"), f("atemp"), f("hum"), f("windspeed"),
47
+ f("workingday"), f("holiday"),
48
+ ]
49
+ names = ["wd_sin", "wd_cos", "temp", "atemp", "hum", "windspeed",
50
+ "workingday", "holiday"]
51
+ hh = hr.astype(int)
52
+ for h in range(1, 24): # hour-of-day dummies: the ridership
53
+ feats.append((hh == h).astype(float)) # pattern is not sinusoidal
54
+ names.append(f"hr_{h}")
55
+ # working-day x hour interactions for the commute spikes
56
+ work = f("workingday")
57
+ for h in (7, 8, 9, 17, 18, 19):
58
+ feats.append(((hh == h) * work).astype(float))
59
+ names.append(f"work_hr_{h}")
60
+ for s in (2, 3, 4):
61
+ feats.append((season == s).astype(float))
62
+ names.append(f"season_{s}")
63
+ for w in (2, 3, 4):
64
+ feats.append((weather == w).astype(float))
65
+ names.append(f"weather_{w}")
66
+ X = np.column_stack(feats)
67
+ y = np.log1p(f("cnt"))
68
+ dates = np.array([r["dteday"] for r in rows])
69
+ return X, y, dates, names
@@ -0,0 +1,104 @@
1
+ """Split conformal prediction, and the online repair for when drift
2
+ breaks it.
3
+
4
+ The whole method is a counting argument. Hold out n calibration points,
5
+ score each by how wrong the model was (here |y - yhat|, optionally
6
+ scaled by a difficulty estimate), and let q be the
7
+ ceil((n+1)(1-alpha))-th smallest score. If a new point is exchangeable
8
+ with the calibration points, its score's rank among all n+1 is uniform,
9
+ so
10
+
11
+ P(score_new <= q) >= (1 - alpha)
12
+
13
+ exactly, for any model, any distribution, any finite n. No asymptotics,
14
+ no Gaussians. The price is the assumption of exchangeability, which
15
+ drift destroys; AdaptiveConformal (Gibbs & Candes 2021) repairs coverage
16
+ online by moving alpha with the observed errors.
17
+ """
18
+
19
+ import numpy as np
20
+
21
+
22
+ def conformal_quantile(scores, alpha):
23
+ """The ceil((n+1)(1-alpha))/n empirical quantile, the exact one the
24
+ guarantee is proved for (not numpy's interpolated quantiles)."""
25
+ s = np.sort(np.asarray(scores, float))
26
+ n = len(s)
27
+ k = int(np.ceil((n + 1) * (1 - alpha)))
28
+ if k > n:
29
+ return np.inf # not enough calibration data for this alpha
30
+ return float(s[k - 1])
31
+
32
+
33
+ def coverage(y, lo, hi):
34
+ y = np.asarray(y, float)
35
+ return float(np.mean((y >= np.asarray(lo)) & (y <= np.asarray(hi))))
36
+
37
+
38
+ class SplitConformal:
39
+ """Intervals yhat +/- q, with q from a held-out calibration set.
40
+
41
+ scale_model, if given, is fit to |residual| on the calibration set's
42
+ training half and divides the scores, so hard inputs get wide
43
+ intervals and easy ones narrow (locally weighted conformal). The
44
+ guarantee is unchanged: it never depends on either model being good.
45
+ """
46
+
47
+ def __init__(self, model, alpha=0.1, scale_model=None):
48
+ self.model = model
49
+ self.alpha = alpha
50
+ self.scale_model = scale_model
51
+
52
+ def _sigma(self, X):
53
+ if self.scale_model is None:
54
+ return np.ones(len(X))
55
+ return np.maximum(self.scale_model.predict(X), 1e-6)
56
+
57
+ def fit(self, X_train, y_train, X_cal, y_cal):
58
+ self.model.fit(X_train, y_train)
59
+ if self.scale_model is not None:
60
+ resid = np.abs(y_train - self.model.predict(X_train))
61
+ self.scale_model.fit(X_train, resid)
62
+ scores = (np.abs(y_cal - self.model.predict(X_cal))
63
+ / self._sigma(X_cal))
64
+ self.scores_ = np.sort(scores)
65
+ self.q_ = conformal_quantile(scores, self.alpha)
66
+ return self
67
+
68
+ def predict(self, X, alpha=None):
69
+ q = (self.q_ if alpha is None
70
+ else conformal_quantile(self.scores_, alpha))
71
+ yhat = self.model.predict(X)
72
+ w = q * self._sigma(X)
73
+ return yhat - w, yhat + w
74
+
75
+
76
+ class AdaptiveConformal:
77
+ """Adaptive conformal inference on a stream (Gibbs & Candes 2021).
78
+
79
+ Keeps the fitted SplitConformal but retunes its miscoverage level
80
+ online:
81
+
82
+ alpha_{t+1} = alpha_t + gamma * (alpha_target - err_t)
83
+
84
+ with err_t = 1 when y_t misses the interval. Coverage errors above
85
+ target push alpha down (wider intervals), quiet stretches push it
86
+ back up. The long-run error rate converges to alpha_target for ANY
87
+ sequence, even adversarial: the guarantee moves from exchangeability
88
+ to online feedback.
89
+ """
90
+
91
+ def __init__(self, conformal, gamma=0.005):
92
+ self.c = conformal
93
+ self.gamma = gamma
94
+ self.alpha_t = conformal.alpha
95
+ self.history = []
96
+
97
+ def step(self, x, y):
98
+ a = float(np.clip(self.alpha_t, 1e-4, 0.999))
99
+ lo, hi = self.c.predict(x[None, :], alpha=a)
100
+ err = float(not (lo[0] <= y <= hi[0]))
101
+ self.alpha_t += self.gamma * (self.c.alpha - err)
102
+ self.history.append({"alpha": a, "lo": float(lo[0]),
103
+ "hi": float(hi[0]), "err": err})
104
+ return lo[0], hi[0], err
@@ -0,0 +1,30 @@
1
+ """A plain ridge regression, closed form, numpy only.
2
+
3
+ Conformal prediction wraps any point predictor, so the predictor here is
4
+ deliberately boring: standardized features, an intercept, and one line of
5
+ linear algebra. The interesting guarantees live in conformal.py.
6
+ """
7
+
8
+ import numpy as np
9
+
10
+
11
+ class Ridge:
12
+ def __init__(self, lam=1.0):
13
+ self.lam = lam
14
+
15
+ def fit(self, X, y):
16
+ X = np.asarray(X, float)
17
+ y = np.asarray(y, float)
18
+ self.mu_ = X.mean(0)
19
+ self.sd_ = X.std(0)
20
+ self.sd_[self.sd_ == 0] = 1.0
21
+ Xs = (X - self.mu_) / self.sd_
22
+ n, d = Xs.shape
23
+ A = Xs.T @ Xs + self.lam * np.eye(d)
24
+ self.coef_ = np.linalg.solve(A, Xs.T @ (y - y.mean()))
25
+ self.intercept_ = float(y.mean())
26
+ return self
27
+
28
+ def predict(self, X):
29
+ Xs = (np.asarray(X, float) - self.mu_) / self.sd_
30
+ return Xs @ self.coef_ + self.intercept_
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.4
2
+ Name: idaniloju
3
+ Version: 0.1.0
4
+ Summary: Conformal prediction from scratch: the exact finite-sample guarantee, what drift does to it, and the online repair
5
+ Author-email: Kenny Obidele <obidelek19@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Kenny0bi/idaniloju
8
+ Project-URL: Issues, https://github.com/Kenny0bi/idaniloju/issues
9
+ Keywords: conformal-prediction,uncertainty-quantification,prediction-intervals,distribution-free,drift
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: numpy>=1.24
18
+
19
+ # idaniloju
20
+
21
+ Conformal prediction from scratch: the exact finite-sample guarantee,
22
+ proof by experiment, the deployment that breaks it, and the online
23
+ repair. The tenth and last project of this series, and it closes the
24
+ loop: the drift that kills the guarantee here is detected by
25
+ [iyipada](https://github.com/Kenny0bi/iyipada), my drift library,
26
+ installed from PyPI like any other user would.
27
+
28
+ ```
29
+ pip install idaniloju
30
+ ```
31
+
32
+ *Idaniloju* is Yoruba for assurance. Conformal prediction is the cleanest
33
+ promise in machine learning: wrap ANY point predictor, and its intervals
34
+ will contain the truth 90% of the time, guaranteed at finite sample size
35
+ with no distributional assumptions at all. The entire method is one
36
+ counting argument, and this repository walks it from the theorem to the
37
+ hurricane that broke it.
38
+
39
+ ## The guarantee is a law, not a hope
40
+
41
+ ![The law](assets/law.svg)
42
+
43
+ 600 fresh train/calibrate/test splits of the same data. Conditional
44
+ coverage is not just ">= 90% on average": it has a known exact
45
+ distribution, Beta(n+1-l, l) over the calibration draw (Vovk 2012). The
46
+ 600-run empirical mean lands on the law's mean to the fourth digit
47
+ (0.9001 vs 0.9001), and the histogram sits inside the density like it
48
+ was poured there. Also visible: 47% of individual runs are below 0.90,
49
+ which is exactly what the law says should happen, and exactly the fine
50
+ print people miss.
51
+
52
+ ![The animation](assets/ranks.gif)
53
+
54
+ The whole proof: an exchangeable point's score has uniform rank among
55
+ the n+1 scores, so it falls at or below the ceil((n+1)(1-alpha))-th
56
+ calibration score with probability at least 1-alpha. Counting, nothing
57
+ else. (Source: [assets/manim_ranks.py](assets/manim_ranks.py), video in
58
+ [assets/ranks.mp4](assets/ranks.mp4).)
59
+
60
+ ## Then you deploy it, and the fine print detonates
61
+
62
+ Train on early 2011 of the Washington DC bike-sharing panel, calibrate
63
+ on late 2011, then serve every hour of 2012 in order:
64
+
65
+ ![The break](assets/break.svg)
66
+
67
+ 2012 ridership grew about 65% over 2011. Exchangeability with the
68
+ calibration set is simply false, and the guarantee follows it down:
69
+ 78% delivered against 90% promised for the year, and when Hurricane
70
+ Sandy closes the city on October 29 (22 rides all day against a normal
71
+ 7,500), the rolling window bottoms out at 42%.
72
+
73
+ Three things about that figure that I care about:
74
+
75
+ - **iyipada alarms in the first week of 2012** (PSI 0.15 on the model's
76
+ conformity scores, p = 0.005), eleven months before Sandy. The drift
77
+ was structural, not meteorological.
78
+ - **The shuffled control behaves**: run the same machinery on a
79
+ shuffled (exchangeable) version of the stream and coverage holds at
80
+ 90.3% with zero alarm weeks out of 51. The alarm is not trigger-happy;
81
+ the guarantee did not break by accident.
82
+ - **Adaptive conformal repairs it online.** ACI (Gibbs & Candes 2021)
83
+ moves the miscoverage dial with the observed errors,
84
+ alpha_t+1 = alpha_t + gamma(alpha - err_t), and ends the year at
85
+ 89.97% against the 90% target, hurricane included, without ever
86
+ refitting the model.
87
+
88
+ ## What the repair costs
89
+
90
+ ![The price](assets/price.svg)
91
+
92
+ Nothing is free: the adaptive intervals are about 5% wider in the median
93
+ hour, and for 36 hours around Sandy the dial demands more assurance than
94
+ 4,395 calibration points can certify, so the honest interval is
95
+ unbounded. I consider that a feature: a system that says "this week I
96
+ can promise nothing" is telling the truth, and the fixed system that
97
+ kept quoting tidy intervals through a hurricane was not.
98
+
99
+ ![The anatomy](assets/anatomy.svg)
100
+
101
+ ## Using it
102
+
103
+ ```python
104
+ from idaniloju import Ridge, SplitConformal, AdaptiveConformal, coverage
105
+
106
+ sc = SplitConformal(Ridge(lam=5.0), alpha=0.1) # any model with fit/predict
107
+ sc.fit(X_train, y_train, X_cal, y_cal)
108
+ lo, hi = sc.predict(X_new)
109
+
110
+ aci = AdaptiveConformal(sc, gamma=0.01) # for streams
111
+ lo, hi, err = aci.step(x_t, y_t)
112
+ ```
113
+
114
+ `SplitConformal` takes any object with fit/predict (a scale model makes
115
+ the intervals difficulty-aware without touching the guarantee), and
116
+ `conformal_quantile` is the exact order statistic the theorem is proved
117
+ for, not an interpolated quantile: at small n and small alpha it
118
+ returns an infinite interval instead of a fake finite one.
119
+
120
+ ## Honest limits
121
+
122
+ - The guarantee is **marginal**: 90% over everything, not 90% for every
123
+ hour of the day or every kind of input. Group-conditional coverage
124
+ needs group-wise calibration this library does not implement.
125
+ - ACI trades a broken assumption for a feedback loop: it needs y_t
126
+ observed reasonably soon after predicting. With delayed labels the
127
+ dial lags exactly that delay.
128
+ - The base model here is a ridge regression on purpose (any model
129
+ works). Sharper models give narrower intervals; nothing about the
130
+ coverage story changes.
131
+ - The 65% growth and the hurricane make this dataset a vivid drift
132
+ story, which is why I chose it; datasets with gentler drift break more
133
+ slowly and less photogenically.
134
+
135
+ ## Reproduce it
136
+
137
+ ```bash
138
+ python -m venv .venv && .venv/bin/pip install numpy pytest iyipada
139
+ bash data/get_data.sh # or let the loader fetch it
140
+
141
+ .venv/bin/python -m pytest tests/ -q # 6 contracts
142
+ .venv/bin/python benchmarks/bench_guarantee.py # the Beta law, 600 refits
143
+ .venv/bin/python benchmarks/bench_stream.py # the 2012 deployment
144
+ .venv/bin/python assets/make_visuals.py # the four figures
145
+ ```
146
+
147
+ ## Layout
148
+
149
+ - [idaniloju/conformal.py](idaniloju/conformal.py) the method: exact
150
+ quantile, split conformal, difficulty scaling, ACI
151
+ - [idaniloju/models.py](idaniloju/models.py) a closed-form ridge, the
152
+ deliberately boring predictor
153
+ - [idaniloju/bike.py](idaniloju/bike.py) the panel, with the year column
154
+ deliberately withheld from the features (deployment does not get told
155
+ the world changed)
156
+ - [benchmarks/](benchmarks/) the law and the deployment, everything into
157
+ JSON the figures and tests read
158
+
159
+ ## Papers
160
+
161
+ - Vovk, Gammerman, Shafer (2005), *Algorithmic Learning in a Random
162
+ World*. The theory.
163
+ - Lei et al. (2018), *Distribution-Free Predictive Inference for
164
+ Regression*, JASA. The split conformal recipe used here.
165
+ - Vovk (2012), *Conditional validity of inductive conformal predictors*.
166
+ The Beta law the first figure draws.
167
+ - Gibbs & Candes (2021), *Adaptive Conformal Inference Under
168
+ Distribution Shift*. The repair.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ idaniloju/__init__.py
4
+ idaniloju/bike.py
5
+ idaniloju/conformal.py
6
+ idaniloju/models.py
7
+ idaniloju.egg-info/PKG-INFO
8
+ idaniloju.egg-info/SOURCES.txt
9
+ idaniloju.egg-info/dependency_links.txt
10
+ idaniloju.egg-info/requires.txt
11
+ idaniloju.egg-info/top_level.txt
12
+ tests/test_idaniloju.py
@@ -0,0 +1 @@
1
+ numpy>=1.24
@@ -0,0 +1 @@
1
+ idaniloju
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "idaniloju"
7
+ version = "0.1.0"
8
+ description = "Conformal prediction from scratch: the exact finite-sample guarantee, what drift does to it, and the online repair"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "Kenny Obidele", email = "obidelek19@gmail.com"}]
13
+ keywords = ["conformal-prediction", "uncertainty-quantification",
14
+ "prediction-intervals", "distribution-free", "drift"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Science/Research",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Scientific/Engineering :: Information Analysis",
21
+ ]
22
+ dependencies = ["numpy>=1.24"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/Kenny0bi/idaniloju"
26
+ Issues = "https://github.com/Kenny0bi/idaniloju/issues"
27
+
28
+ [tool.setuptools]
29
+ packages = ["idaniloju"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,97 @@
1
+ """Contracts: the quantile the guarantee is proved for, the guarantee
2
+ itself on synthetic data, ACI's error convergence, and the benchmark
3
+ numbers once they exist."""
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import pytest
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(ROOT))
14
+
15
+ from idaniloju import (AdaptiveConformal, Ridge, SplitConformal, # noqa
16
+ coverage)
17
+ from idaniloju.conformal import conformal_quantile # noqa
18
+
19
+ STREAM = ROOT / "benchmarks" / "stream.json"
20
+ GUAR = ROOT / "benchmarks" / "guarantee.json"
21
+
22
+
23
+ def test_conformal_quantile_is_the_exact_order_statistic():
24
+ scores = np.arange(1, 11, dtype=float) # 1..10, n = 10
25
+ # ceil(11 * 0.9) = 10 -> the 10th smallest = 10
26
+ assert conformal_quantile(scores, 0.1) == 10.0
27
+ # ceil(11 * 0.5) = 6 -> 6
28
+ assert conformal_quantile(scores, 0.5) == 6.0
29
+ # alpha so small that k > n: must refuse with an infinite interval
30
+ assert conformal_quantile(scores, 0.05) == np.inf
31
+
32
+
33
+ def test_guarantee_holds_on_synthetic_exchangeable_data():
34
+ rng = np.random.default_rng(11)
35
+ covs = []
36
+ for _ in range(200):
37
+ X = rng.normal(0, 1, (700, 3))
38
+ y = X @ np.array([2.0, -1.0, 0.5]) + rng.standard_t(3, 700)
39
+ sc = SplitConformal(Ridge(lam=1.0), alpha=0.2)
40
+ sc.fit(X[:300], y[:300], X[300:500], y[300:500])
41
+ lo, hi = sc.predict(X[500:])
42
+ covs.append(coverage(y[500:], lo, hi))
43
+ # marginal coverage >= 1 - alpha in expectation; with 200 trials the
44
+ # mean should sit within a point of the target band
45
+ assert np.mean(covs) >= 0.79
46
+
47
+
48
+ def test_aci_error_rate_converges_even_under_shift():
49
+ rng = np.random.default_rng(5)
50
+ X = rng.normal(0, 1, (3000, 2))
51
+ y = X @ np.array([1.0, 1.0]) + rng.normal(0, 1, 3000)
52
+ sc = SplitConformal(Ridge(lam=1.0), alpha=0.1)
53
+ sc.fit(X[:1000], y[:1000], X[1000:1500], y[1000:1500])
54
+ # the stream drifts hard: outputs shift by +3 halfway through
55
+ Xs, ys = X[1500:], y[1500:].copy()
56
+ ys[750:] += 3.0
57
+ aci = AdaptiveConformal(sc, gamma=0.02)
58
+ errs = [aci.step(Xs[i], ys[i])[2] for i in range(len(ys))]
59
+ # long-run error close to alpha despite the jump
60
+ assert abs(np.mean(errs) - 0.1) < 0.03
61
+
62
+
63
+ def test_scale_model_widens_hard_inputs():
64
+ rng = np.random.default_rng(2)
65
+ X = rng.uniform(-1, 1, (2000, 1))
66
+ y = (X[:, 0] * 2 + rng.normal(0, 0.2 + 1.3 * (X[:, 0] > 0), 2000))
67
+ sc = SplitConformal(Ridge(0.1), alpha=0.1, scale_model=Ridge(0.1))
68
+ sc.fit(X[:800], y[:800], X[800:1400], y[800:1400])
69
+ lo, hi = sc.predict(X[1400:])
70
+ w = hi - lo
71
+ right = X[1400:, 0] > 0
72
+ assert w[right].mean() > 1.5 * w[~right].mean()
73
+ assert coverage(y[1400:], lo, hi) > 0.85
74
+
75
+
76
+ @pytest.mark.skipif(not STREAM.exists(), reason="run bench_stream first")
77
+ def test_stream_results_hold():
78
+ s = json.loads(STREAM.read_text())
79
+ assert s["static_coverage"] < 0.80 # drift broke the guarantee
80
+ assert abs(s["aci_coverage"] - 0.9) < 0.01 # ACI repaired it
81
+ assert s["first_alarm"]["start"] == "2012-01-01" # iyipada saw it
82
+ ex = s["exchangeable_check"]
83
+ assert ex["weeks_alarmed"] == 0 # and does not cry wolf
84
+ assert ex["coverage"] > 0.89
85
+
86
+
87
+ @pytest.mark.skipif(not GUAR.exists(), reason="run bench_guarantee first")
88
+ def test_coverage_distribution_matches_beta_law():
89
+ g = json.loads(GUAR.read_text())
90
+ covs = np.array(g["coverages"])
91
+ a, b = g["beta_a"], g["beta_b"]
92
+ beta_mean = a / (a + b)
93
+ beta_sd = np.sqrt(a * b / ((a + b) ** 2 * (a + b + 1)))
94
+ assert abs(covs.mean() - beta_mean) < 0.004
95
+ # spread of the same order as the law predicts (test-set noise adds a
96
+ # little on top of the Beta's own width)
97
+ assert beta_sd * 0.8 < covs.std() < beta_sd * 2.2