choircert 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,31 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ .buildenv/
12
+ .testenv/
13
+ .env/
14
+ venv/
15
+
16
+ # Tooling caches
17
+ .pytest_cache/
18
+ .ruff_cache/
19
+ .mypy_cache/
20
+ .coverage
21
+ htmlcov/
22
+
23
+ # Docs build
24
+ site/
25
+
26
+ # OS
27
+ .DS_Store
28
+ Thumbs.db
29
+
30
+ # NOTE: src/choir/datasets/demo_fars.csv is a public FARS-schema demo (no PII)
31
+ # and MUST stay tracked. Do not add a blanket *.csv ignore here.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amir Rafe and Subasish Das, Texas State University
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,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: choircert
3
+ Version: 0.1.0
4
+ Summary: A certification layer for ordinal, safety-critical prediction: distribution-free validity, label-noise robustness, deployment-shift certificates, and severity-weighted risk control.
5
+ Project-URL: Homepage, https://github.com/pozapas/choircert
6
+ Project-URL: Repository, https://github.com/pozapas/choircert
7
+ Project-URL: Issues, https://github.com/pozapas/choircert/issues
8
+ Author-email: Amir Rafe <amir.rafe@txstate.edu>, Subasish Das <subasish@txstate.edu>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: conformal prediction,crash severity,distribution-free inference,ordinal classification,risk control,trustworthy AI,uncertainty quantification
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: numpy>=1.24
19
+ Provides-Extra: benchmarks
20
+ Requires-Dist: crepes<1.0,>=0.7; extra == 'benchmarks'
21
+ Requires-Dist: mapie<2.0,>=1.0; extra == 'benchmarks'
22
+ Requires-Dist: scikit-learn>=1.4; extra == 'benchmarks'
23
+ Provides-Extra: dev
24
+ Requires-Dist: hypothesis>=6; extra == 'dev'
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Requires-Dist: ruff>=0.5; extra == 'dev'
27
+ Provides-Extra: econ
28
+ Requires-Dist: scipy>=1.10; extra == 'econ'
29
+ Provides-Extra: maps
30
+ Requires-Dist: matplotlib>=3.7; extra == 'maps'
31
+ Provides-Extra: torch
32
+ Requires-Dist: torch>=2.0; extra == 'torch'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # CHOIR
36
+
37
+ **A certification layer for ordinal, safety-critical prediction.** Distribution name
38
+ on PyPI: `choircert`; import name: `choir`.
39
+
40
+ Wrap any ordinal severity model and obtain finite-sample, distribution-free guarantees:
41
+ contiguous ordinal prediction sets with marginal and group-conditional coverage
42
+ (heterogeneity classes, jurisdiction-year strata), coverage on the *true* label under a
43
+ declared banded reporting-noise assumption, deployment-shift transfer certificates, and
44
+ severity-weighted risk control including a fatal-omission guarantee, all composable with
45
+ an explicit slack budget.
46
+
47
+ Every guarantee is a statement about prediction-set coverage or expected risk under a
48
+ declared sampling assumption. The package estimates no causal quantities.
49
+
50
+ ## Install
51
+
52
+ ```
53
+ pip install choircert # core (numpy only)
54
+ pip install "choircert[torch]" # + the DLCON deep base model
55
+ pip install "choircert[econ,maps]" # + scipy models, county maps
56
+ ```
57
+
58
+ ## Quickstart
59
+
60
+ ```python
61
+ import numpy as np
62
+ from choir import CertifiedOrdinal, NoiseModel
63
+
64
+ cert = CertifiedOrdinal(
65
+ base=any_model_with_predict_proba, # ordered logit, XGBoost, deep net, ...
66
+ partition=latent_class_assigner, # fit on the training split (or None)
67
+ noise=NoiseModel.kabco(delta=0.02), # declared band; swept in sensitivity curves
68
+ n_min=1000, # per-cell floor with automatic rollup
69
+ )
70
+ cert.fit(X_train, y_train).calibrate(X_cal, y_reported)
71
+
72
+ lo, hi = cert.predict_set(X_new, alpha=0.10) # contiguous KABCO intervals
73
+ lo, hi = cert.predict_set_risk(X_new, beta=0.05) # severity-cost risk control
74
+ for c in cert.certificate(alpha=0.10):
75
+ print(c.cell, c.n_cal, c.floor) # per-cell slack budget
76
+ ```
77
+
78
+ Runnable end to end on bundled synthetic data:
79
+
80
+ ```python
81
+ from choir.datasets import load_demo
82
+ rows, y, cols = load_demo() # FARS-schema synthetic sample, no PII, no download
83
+ ```
84
+
85
+ `python examples/demo.py` runs in seconds. `pytest tests/` reproduces every guarantee
86
+ on simulated data: marginal validity, class-conditional coverage, banded-noise transfer,
87
+ group-weighted shift, weighted transfer, cost risk control, and composition.
88
+
89
+ ## How it compares
90
+
91
+ Generic conformal toolkits (MAPIE, crepes, puncc) provide split and Mondrian machinery.
92
+ They are correct and attain marginal coverage. What they do not provide for an ordinal,
93
+ safety-critical target is contiguity, a guarantee on the true (noisy) label, and a
94
+ fatal-omission guarantee. The table below is produced by `benchmarks/vs_mapie_crepes.py`
95
+ on the bundled demo at a nominal 0.90 level.
96
+
97
+ | method | coverage | avg set size | contiguous sets | true-label guarantee | fatal-omission guarantee |
98
+ |--------|:--------:|:------------:|:---------------:|:--------------------:|:------------------------:|
99
+ | CHOIR | 0.895 | 2.19 | yes (by construction) | yes | yes |
100
+ | MAPIE | 0.899 | 2.22 | 99% | no | no |
101
+ | crepes | 0.899 | 2.22 | 99% | no | no |
102
+
103
+ The coverage is deliberately the same; the guarantee is the same theorem. The difference
104
+ is that CHOIR's sets are always contiguous intervals on the KABCO scale, and that CHOIR
105
+ additionally transfers coverage to the true injury under a declared band and bounds the
106
+ probability of excluding a true fatality. On this demo, the generic toolkits return a
107
+ non-contiguous set about one percent of the time, which is not an operationally meaningful
108
+ "B or worse" statement.
109
+
110
+ ## What is guaranteed
111
+
112
+ The four guarantees and their proofs are in the companion paper (Transportation Research
113
+ Part B, under review). Each is finite-sample and distribution-free in the wrapped model:
114
+ class-conditional coverage (validity for any partition, oracle efficiency for a good one),
115
+ true-label coverage `1 - alpha - delta` under a banded compatibility assumption,
116
+ deployment transfer certificates reported as (nominal level, slack lower confidence bound,
117
+ parametric slack estimate), and severity-cost risk control including the fatal-omission
118
+ bound. The composition theorem combines them with an additive, assumption-attributable
119
+ slack budget.
120
+
121
+ ## Citing
122
+
123
+ See `CITATION.cff`. Cite both the software (Zenodo DOI, on release) and the paper.
124
+
125
+ ## License
126
+
127
+ MIT.
@@ -0,0 +1,93 @@
1
+ # CHOIR
2
+
3
+ **A certification layer for ordinal, safety-critical prediction.** Distribution name
4
+ on PyPI: `choircert`; import name: `choir`.
5
+
6
+ Wrap any ordinal severity model and obtain finite-sample, distribution-free guarantees:
7
+ contiguous ordinal prediction sets with marginal and group-conditional coverage
8
+ (heterogeneity classes, jurisdiction-year strata), coverage on the *true* label under a
9
+ declared banded reporting-noise assumption, deployment-shift transfer certificates, and
10
+ severity-weighted risk control including a fatal-omission guarantee, all composable with
11
+ an explicit slack budget.
12
+
13
+ Every guarantee is a statement about prediction-set coverage or expected risk under a
14
+ declared sampling assumption. The package estimates no causal quantities.
15
+
16
+ ## Install
17
+
18
+ ```
19
+ pip install choircert # core (numpy only)
20
+ pip install "choircert[torch]" # + the DLCON deep base model
21
+ pip install "choircert[econ,maps]" # + scipy models, county maps
22
+ ```
23
+
24
+ ## Quickstart
25
+
26
+ ```python
27
+ import numpy as np
28
+ from choir import CertifiedOrdinal, NoiseModel
29
+
30
+ cert = CertifiedOrdinal(
31
+ base=any_model_with_predict_proba, # ordered logit, XGBoost, deep net, ...
32
+ partition=latent_class_assigner, # fit on the training split (or None)
33
+ noise=NoiseModel.kabco(delta=0.02), # declared band; swept in sensitivity curves
34
+ n_min=1000, # per-cell floor with automatic rollup
35
+ )
36
+ cert.fit(X_train, y_train).calibrate(X_cal, y_reported)
37
+
38
+ lo, hi = cert.predict_set(X_new, alpha=0.10) # contiguous KABCO intervals
39
+ lo, hi = cert.predict_set_risk(X_new, beta=0.05) # severity-cost risk control
40
+ for c in cert.certificate(alpha=0.10):
41
+ print(c.cell, c.n_cal, c.floor) # per-cell slack budget
42
+ ```
43
+
44
+ Runnable end to end on bundled synthetic data:
45
+
46
+ ```python
47
+ from choir.datasets import load_demo
48
+ rows, y, cols = load_demo() # FARS-schema synthetic sample, no PII, no download
49
+ ```
50
+
51
+ `python examples/demo.py` runs in seconds. `pytest tests/` reproduces every guarantee
52
+ on simulated data: marginal validity, class-conditional coverage, banded-noise transfer,
53
+ group-weighted shift, weighted transfer, cost risk control, and composition.
54
+
55
+ ## How it compares
56
+
57
+ Generic conformal toolkits (MAPIE, crepes, puncc) provide split and Mondrian machinery.
58
+ They are correct and attain marginal coverage. What they do not provide for an ordinal,
59
+ safety-critical target is contiguity, a guarantee on the true (noisy) label, and a
60
+ fatal-omission guarantee. The table below is produced by `benchmarks/vs_mapie_crepes.py`
61
+ on the bundled demo at a nominal 0.90 level.
62
+
63
+ | method | coverage | avg set size | contiguous sets | true-label guarantee | fatal-omission guarantee |
64
+ |--------|:--------:|:------------:|:---------------:|:--------------------:|:------------------------:|
65
+ | CHOIR | 0.895 | 2.19 | yes (by construction) | yes | yes |
66
+ | MAPIE | 0.899 | 2.22 | 99% | no | no |
67
+ | crepes | 0.899 | 2.22 | 99% | no | no |
68
+
69
+ The coverage is deliberately the same; the guarantee is the same theorem. The difference
70
+ is that CHOIR's sets are always contiguous intervals on the KABCO scale, and that CHOIR
71
+ additionally transfers coverage to the true injury under a declared band and bounds the
72
+ probability of excluding a true fatality. On this demo, the generic toolkits return a
73
+ non-contiguous set about one percent of the time, which is not an operationally meaningful
74
+ "B or worse" statement.
75
+
76
+ ## What is guaranteed
77
+
78
+ The four guarantees and their proofs are in the companion paper (Transportation Research
79
+ Part B, under review). Each is finite-sample and distribution-free in the wrapped model:
80
+ class-conditional coverage (validity for any partition, oracle efficiency for a good one),
81
+ true-label coverage `1 - alpha - delta` under a banded compatibility assumption,
82
+ deployment transfer certificates reported as (nominal level, slack lower confidence bound,
83
+ parametric slack estimate), and severity-cost risk control including the fatal-omission
84
+ bound. The composition theorem combines them with an additive, assumption-attributable
85
+ slack budget.
86
+
87
+ ## Citing
88
+
89
+ See `CITATION.cff`. Cite both the software (Zenodo DOI, on release) and the paper.
90
+
91
+ ## License
92
+
93
+ MIT.
@@ -0,0 +1,64 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "choircert"
7
+ version = "0.1.0"
8
+ description = "A certification layer for ordinal, safety-critical prediction: distribution-free validity, label-noise robustness, deployment-shift certificates, and severity-weighted risk control."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Amir Rafe", email = "amir.rafe@txstate.edu" },
14
+ { name = "Subasish Das", email = "subasish@txstate.edu" },
15
+ ]
16
+ keywords = ["conformal prediction", "ordinal classification", "uncertainty quantification",
17
+ "distribution-free inference", "risk control", "crash severity", "trustworthy AI"]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Intended Audience :: Science/Research",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Programming Language :: Python :: 3",
23
+ "Topic :: Scientific/Engineering :: Mathematics",
24
+ ]
25
+ dependencies = ["numpy>=1.24"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/pozapas/choircert"
29
+ Repository = "https://github.com/pozapas/choircert"
30
+ Issues = "https://github.com/pozapas/choircert/issues"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=8", "hypothesis>=6", "ruff>=0.5"]
34
+ torch = ["torch>=2.0"]
35
+ econ = ["scipy>=1.10"]
36
+ maps = ["matplotlib>=3.7"]
37
+ # Third-party conformal toolkits compared against CHOIR in
38
+ # benchmarks/vs_mapie_crepes.py (paper Table 7). Both bounds are load-bearing:
39
+ # the script targets MAPIE's v1.x SplitConformalClassifier API
40
+ # (conformalize/predict_set), which replaced the v0.x MapieClassifier API, so
41
+ # <2.0 guards against a future rename and >=1.0 excludes the old path. crepes
42
+ # >=0.7 is the line whose WrapClassifier.predict_set accepts the `labels=`
43
+ # keyword the script passes.
44
+ benchmarks = ["mapie>=1.0,<2.0", "crepes>=0.7,<1.0", "scikit-learn>=1.4"]
45
+
46
+ # Ship the bundled demo table even though the repo .gitignore excludes *.csv
47
+ # (PII guard). `artifacts` forces it into BOTH sdist and wheel regardless of
48
+ # VCS-ignore status, so `python -m build` (wheel-from-sdist) and fresh clones
49
+ # both carry it. See RELEASE.md.
50
+ [tool.hatch.build]
51
+ artifacts = ["src/choir/datasets/demo_fars.csv"]
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/choir"]
55
+
56
+ [tool.hatch.build.targets.sdist]
57
+ include = ["src/choir", "tests", "README.md", "LICENSE"]
58
+
59
+ [tool.pytest.ini_options]
60
+ testpaths = ["tests"]
61
+
62
+ [tool.ruff]
63
+ line-length = 100
64
+ target-version = "py310"
@@ -0,0 +1,38 @@
1
+ """choir: a certification layer for ordinal, safety-critical prediction.
2
+
3
+ Guarantees are statements about prediction-set coverage and expected risk under
4
+ declared sampling assumptions. No causal quantities are estimated or reported.
5
+ """
6
+
7
+ from choir.core.scores import cumulative_score, score_matrix, cdf_from_proba
8
+ from choir.core.intervals import interval_sets, expand_intervals
9
+ from choir.core.calibrate import (
10
+ conformal_quantile,
11
+ split_calibrate,
12
+ mondrian_calibrate,
13
+ weighted_quantile,
14
+ )
15
+ from choir.noise import NoiseModel
16
+ from choir.partitions import Partition
17
+ from choir.compose import Certificate, CertifiedOrdinal
18
+ from choir.risk import crc_threshold, inflated_costs
19
+
20
+ __all__ = [
21
+ "cumulative_score",
22
+ "score_matrix",
23
+ "cdf_from_proba",
24
+ "interval_sets",
25
+ "expand_intervals",
26
+ "conformal_quantile",
27
+ "split_calibrate",
28
+ "mondrian_calibrate",
29
+ "weighted_quantile",
30
+ "NoiseModel",
31
+ "Partition",
32
+ "Certificate",
33
+ "CertifiedOrdinal",
34
+ "crc_threshold",
35
+ "inflated_costs",
36
+ ]
37
+
38
+ __version__ = "0.1.0"
@@ -0,0 +1,196 @@
1
+ """Certificate objects, slack-budget algebra, and the CertifiedOrdinal API
2
+ (methods.tex Thm 6; CHOIR_framework.md 5.1).
3
+
4
+ Canonical composition order, enforced by construction:
5
+ condition (partition) -> weight (within cell) -> calibrate -> expand (noise) -> risk-adjust.
6
+ Slacks are additive and each is attributed to one declared assumption (Thm 6).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ import numpy as np
14
+
15
+ from choir.core.scores import cdf_from_proba, cumulative_score
16
+ from choir.core.intervals import interval_sets
17
+ from choir.core.calibrate import conformal_quantile
18
+ from choir.noise import NoiseModel
19
+ from choir.partitions import Partition
20
+ from choir.risk import crc_threshold, inflated_costs
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Certificate:
25
+ """A coverage certificate: nominal level minus named, attributed slacks."""
26
+
27
+ nominal: float # 1 - alpha
28
+ slacks: dict = field(default_factory=dict) # name -> (value, assumption)
29
+ cell: object = None
30
+ n_cal: int = 0
31
+
32
+ @property
33
+ def floor(self) -> float:
34
+ return self.nominal - sum(v for v, _ in self.slacks.values())
35
+
36
+ def as_row(self) -> dict:
37
+ row = {"cell": self.cell, "n_cal": self.n_cal, "nominal": self.nominal,
38
+ "floor": self.floor}
39
+ for name, (v, assumption) in self.slacks.items():
40
+ row[f"slack_{name}"] = v
41
+ row[f"assumption_{name}"] = assumption
42
+ return row
43
+
44
+
45
+ class CertifiedOrdinal:
46
+ """Wrap any ordinal severity model; export certified interval predictions.
47
+
48
+ base: object with predict_proba(X) -> (n, K), or a callable X -> conditional CDF
49
+ (n, K). The base model must be fit on the training split only.
50
+ partition: None | Partition | anything Partition accepts (fit on training split).
51
+ noise: NoiseModel or None.
52
+ n_min: per-cell floor; cells below it roll up (product cell -> class -> global).
53
+ """
54
+
55
+ def __init__(self, base, K: int = 5, partition=None, noise: NoiseModel | None = None,
56
+ n_min: int = 1000):
57
+ self.base = base
58
+ self.K = K
59
+ self.partition = (partition if isinstance(partition, Partition) or partition is None
60
+ else Partition(partition))
61
+ self.noise = noise
62
+ self.n_min = n_min
63
+ self._cal: dict | None = None
64
+
65
+ # -- base-model plumbing --
66
+
67
+ def _cdf(self, X) -> np.ndarray:
68
+ if callable(self.base) and not hasattr(self.base, "predict_proba"):
69
+ cdf = np.asarray(self.base(X), dtype=float)
70
+ else:
71
+ cdf = cdf_from_proba(self.base.predict_proba(X))
72
+ if cdf.shape[1] != self.K:
73
+ raise ValueError(f"base model emits {cdf.shape[1]} categories, expected {self.K}")
74
+ return cdf
75
+
76
+ def fit(self, X_train, y_train):
77
+ if hasattr(self.base, "fit"):
78
+ self.base.fit(X_train, np.asarray(y_train))
79
+ return self
80
+
81
+ # -- calibration (condition -> calibrate) --
82
+
83
+ def _keys(self, X, strata=None) -> tuple[np.ndarray, np.ndarray]:
84
+ """Return (cell_keys, class_keys) as string arrays 'class|stratum'."""
85
+ cls = self.partition.labels(X) if self.partition is not None else np.zeros(len(X), int)
86
+ cls_keys = np.array([str(c) for c in cls])
87
+ if strata is None:
88
+ return cls_keys.copy(), cls_keys
89
+ strata = np.asarray(strata)
90
+ cell_keys = np.array([f"{c}|{g}" for c, g in zip(cls_keys, strata)])
91
+ return cell_keys, cls_keys
92
+
93
+ def calibrate(self, X_cal, y_cal, strata=None):
94
+ y_cal = np.asarray(y_cal)
95
+ scores = cumulative_score(self._cdf(X_cal), y_cal)
96
+ cell_keys, cls_keys = self._keys(X_cal, strata)
97
+ self._cal = {
98
+ "scores": scores, "y": y_cal,
99
+ "cell_keys": cell_keys, "class_keys": cls_keys,
100
+ }
101
+ return self
102
+
103
+ def _threshold_for(self, key: str, cls_key: str, alpha: float) -> tuple[float, int, str]:
104
+ """Rollup: product cell -> class -> global, first level with n >= n_min."""
105
+ cal = self._cal
106
+ for level, mask in (
107
+ ("cell", cal["cell_keys"] == key),
108
+ ("class", cal["class_keys"] == cls_key),
109
+ ("global", np.ones(len(cal["scores"]), bool)),
110
+ ):
111
+ n = int(mask.sum())
112
+ if n >= self.n_min or level == "global":
113
+ return conformal_quantile(cal["scores"][mask], alpha), n, level
114
+ raise AssertionError("unreachable")
115
+
116
+ # -- prediction (calibrate -> expand) --
117
+
118
+ def predict_set(self, X, alpha: float = 0.1, strata=None):
119
+ """Contiguous KABCO intervals with per-cell thresholds and noise expansion.
120
+
121
+ Returns (lo, hi), 1-indexed inclusive endpoints on the TRUE-label scale if a
122
+ noise model is set (expanded), else on the reported-label scale.
123
+ """
124
+ if self._cal is None:
125
+ raise RuntimeError("call calibrate() first")
126
+ cdf = self._cdf(X)
127
+ keys, cls_keys = self._keys(X, strata)
128
+ uniq, inverse = np.unique(keys, return_inverse=True)
129
+ thr = np.empty(len(uniq))
130
+ for j, key in enumerate(uniq):
131
+ cls_key = cls_keys[np.argmax(inverse == j)]
132
+ thr[j], _, _ = self._threshold_for(key, cls_key, alpha)
133
+ lam = thr[inverse]
134
+ lo, hi = interval_sets(cdf, lam)
135
+ if self.noise is not None:
136
+ lo, hi = self.noise.expand(lo, hi)
137
+ return lo, hi
138
+
139
+ def predict_set_risk(self, X, beta: float = 0.05, kappa=None, strata=None):
140
+ """Severity-cost risk-controlled sets (Thm 5a/5b), cell-wise CRC thresholds.
141
+
142
+ With a noise model set, CRC runs on band-inflated costs kappa_plus and the
143
+ output is expanded (Thm 5b guarantee: risk <= beta*kmax + delta*kmax).
144
+ """
145
+ if self._cal is None:
146
+ raise RuntimeError("call calibrate() first")
147
+ if kappa is None:
148
+ from choir.crash.costs import usdot_relative
149
+ kappa = usdot_relative()
150
+ kappa = np.asarray(kappa, float)
151
+ kmax = float(kappa.max())
152
+ b_minus = 0
153
+ if self.noise is not None:
154
+ b_minus = (max(up for _, up in self.noise.tmap.values())
155
+ if self.noise.tmap else self.noise.b_minus)
156
+ cal = self._cal
157
+ costs = (inflated_costs(cal["y"], kappa, b_minus) if b_minus > 0
158
+ else kappa[cal["y"] - 1])
159
+
160
+ cdf = self._cdf(X)
161
+ keys, cls_keys = self._keys(X, strata)
162
+ uniq, inverse = np.unique(keys, return_inverse=True)
163
+ thr = np.empty(len(uniq))
164
+ for j, key in enumerate(uniq):
165
+ cls_key = cls_keys[np.argmax(inverse == j)]
166
+ for mask_level in (cal["cell_keys"] == key, cal["class_keys"] == cls_key,
167
+ np.ones(len(cal["scores"]), bool)):
168
+ if mask_level.sum() >= self.n_min or mask_level.all():
169
+ thr[j] = crc_threshold(cal["scores"][mask_level],
170
+ costs[mask_level], kmax, beta)
171
+ break
172
+ lam = thr[inverse]
173
+ lo, hi = interval_sets(cdf, lam)
174
+ if self.noise is not None:
175
+ lo, hi = self.noise.expand(lo, hi)
176
+ return lo, hi
177
+
178
+ # -- certificates (Thm 6 slack budget) --
179
+
180
+ def certificate(self, alpha: float = 0.1) -> list[Certificate]:
181
+ """Per-cell coverage certificates for all calibrated cells (observed strata).
182
+
183
+ New-stratum certificates additionally need the TV-slack diagnostics of
184
+ choir.shift (tv_slack_lcb); attach via shift tools in the experiments layer.
185
+ """
186
+ if self._cal is None:
187
+ raise RuntimeError("call calibrate() first")
188
+ out = []
189
+ delta = self.noise.delta if self.noise is not None else 0.0
190
+ for key in np.unique(self._cal["cell_keys"]):
191
+ n = int((self._cal["cell_keys"] == key).sum())
192
+ slacks = {}
193
+ if self.noise is not None:
194
+ slacks["noise"] = (delta, "N(T, delta) compatibility, declared")
195
+ out.append(Certificate(nominal=1 - alpha, slacks=slacks, cell=key, n_cal=n))
196
+ return out
File without changes
@@ -0,0 +1,75 @@
1
+ """Split, Mondrian, and weighted conformal calibration (methods.tex Prop 1, Thm 2, Thm 4).
2
+
3
+ All calibration consumes scores only; enforcing that partitions/weights were fit
4
+ without calibration labels is the caller's contract (documented, and enforced by the
5
+ high-level CertifiedOrdinal API which fits partitions on the training split only).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+
13
+ def conformal_quantile(scores: np.ndarray, alpha: float) -> float:
14
+ """The ceil((1-alpha)(n+1))-th smallest score; +inf if index exceeds n (Prop 1)."""
15
+ scores = np.asarray(scores, dtype=float)
16
+ n = len(scores)
17
+ if not 0.0 < alpha < 1.0:
18
+ raise ValueError("alpha must be in (0, 1)")
19
+ k = int(np.ceil((1.0 - alpha) * (n + 1)))
20
+ if k > n:
21
+ return np.inf
22
+ return float(np.partition(scores, k - 1)[k - 1])
23
+
24
+
25
+ def split_calibrate(scores: np.ndarray, alpha: float) -> float:
26
+ """Marginal split conformal threshold (Proposition 1)."""
27
+ return conformal_quantile(scores, alpha)
28
+
29
+
30
+ def mondrian_calibrate(
31
+ scores: np.ndarray,
32
+ groups: np.ndarray,
33
+ alpha: float,
34
+ ) -> dict:
35
+ """Per-group conformal thresholds (Theorem 2 / Theorem 4a).
36
+
37
+ groups: array of hashable group labels, same length as scores, produced by a
38
+ function fit independently of the calibration labels (split discipline).
39
+ Returns {group: threshold}. Groups absent at prediction time must be handled
40
+ by the caller's rollup rule (see choir.shift.rollup).
41
+ """
42
+ scores = np.asarray(scores, dtype=float)
43
+ groups = np.asarray(groups)
44
+ return {
45
+ g: conformal_quantile(scores[groups == g], alpha)
46
+ for g in np.unique(groups)
47
+ }
48
+
49
+
50
+ def weighted_quantile(
51
+ scores: np.ndarray,
52
+ weights: np.ndarray,
53
+ test_weight: float,
54
+ alpha: float,
55
+ ) -> float:
56
+ """Weighted conformal threshold (Theorem 4b display equation).
57
+
58
+ q = inf{ t : sum_i w_i 1{S_i <= t} >= (1-alpha) * (sum_i w_i + w_test) },
59
+ with the test point's mass placed at +inf (conservative placement per
60
+ Tibshirani et al. 2019); +inf when the calibration mass cannot reach the target.
61
+ """
62
+ scores = np.asarray(scores, dtype=float)
63
+ weights = np.asarray(weights, dtype=float)
64
+ if np.any(weights < 0) or test_weight < 0:
65
+ raise ValueError("weights must be non-negative")
66
+ total = weights.sum() + test_weight
67
+ if total <= 0:
68
+ raise ValueError("all weights are zero")
69
+ order = np.argsort(scores, kind="stable")
70
+ csum = np.cumsum(weights[order])
71
+ target = (1.0 - alpha) * total
72
+ idx = np.searchsorted(csum, target, side="left")
73
+ if idx >= len(scores):
74
+ return np.inf
75
+ return float(scores[order][idx])