choircert 0.1.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.
choir/__init__.py ADDED
@@ -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"
choir/compose.py ADDED
@@ -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
choir/core/__init__.py ADDED
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])
@@ -0,0 +1,74 @@
1
+ """Interval prediction sets (methods.tex Definition 2, Lemma 1, Convention 1).
2
+
3
+ C_lambda(x) = {k : s(x,k) <= lambda} is a contiguous interval by Lemma 1; if empty,
4
+ Convention 1 returns the singleton argmin_k s(x,k) (this only enlarges sets, so every
5
+ lower coverage bound is preserved).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ from choir.core.scores import score_matrix
13
+
14
+
15
+ def interval_sets(cdf: np.ndarray, lam: np.ndarray | float) -> tuple[np.ndarray, np.ndarray]:
16
+ """Return (lo, hi) 1-indexed inclusive interval endpoints per row.
17
+
18
+ lam may be a scalar or an (n,) vector of per-row thresholds (Mondrian use).
19
+ Never returns an empty set (Convention 1).
20
+ """
21
+ sm = score_matrix(cdf) # (n, K)
22
+ lam = np.broadcast_to(np.asarray(lam, dtype=float), (sm.shape[0],))
23
+ member = sm <= lam[:, None] # (n, K)
24
+
25
+ K = sm.shape[1]
26
+ idx = np.arange(1, K + 1)
27
+ lo = np.where(member.any(axis=1), np.where(member, idx, K + 1).min(axis=1), 0)
28
+ hi = np.where(member.any(axis=1), np.where(member, idx, 0).max(axis=1), 0)
29
+
30
+ empty = lo == 0
31
+ if empty.any(): # Convention 1
32
+ arg = sm[empty].argmin(axis=1) + 1
33
+ lo = lo.copy()
34
+ hi = hi.copy()
35
+ lo[empty] = arg
36
+ hi[empty] = arg
37
+
38
+ # Lemma 1 invariant: membership must be contiguous between lo and hi.
39
+ # (Cheap runtime check; guards against a non-monotone cdf slipping through.)
40
+ width = hi - lo + 1
41
+ if not np.array_equal(member.sum(axis=1)[~empty], width[~empty]):
42
+ raise AssertionError("non-contiguous set: cdf violates monotonicity")
43
+ return lo, hi
44
+
45
+
46
+ def expand_intervals(
47
+ lo: np.ndarray, hi: np.ndarray, b_plus: int, b_minus: int, K: int
48
+ ) -> tuple[np.ndarray, np.ndarray]:
49
+ """Banded compatibility expansion (Definition 3): [lo - b_plus, hi + b_minus] ∩ Y.
50
+
51
+ b_plus guards over-reporting (reach downward); b_minus guards under-reporting
52
+ (reach upward). See methods.tex Assumption N.
53
+ """
54
+ if b_plus < 0 or b_minus < 0:
55
+ raise ValueError("band widths must be non-negative")
56
+ return np.maximum(lo - b_plus, 1), np.minimum(hi + b_minus, K)
57
+
58
+
59
+ def expand_intervals_map(
60
+ lo: np.ndarray, hi: np.ndarray, tmap: dict[int, tuple[int, int]], K: int
61
+ ) -> tuple[np.ndarray, np.ndarray]:
62
+ """Category-dependent expansion (Remark 3.2).
63
+
64
+ tmap[k] = (down_k, up_k): a report k is compatible with truths [k - down_k, k + up_k].
65
+ The expanded interval is the union of T(k) over k in [lo, hi]; with interval T(k)
66
+ this is [min_k (k - down_k), max_k (k + up_k)] over k in [lo, hi].
67
+ """
68
+ lo_out = np.empty_like(lo)
69
+ hi_out = np.empty_like(hi)
70
+ for i, (a, b) in enumerate(zip(lo, hi)):
71
+ ks = range(int(a), int(b) + 1)
72
+ lo_out[i] = max(1, min(k - tmap[k][0] for k in ks))
73
+ hi_out[i] = min(K, max(k + tmap[k][1] for k in ks))
74
+ return lo_out, hi_out
choir/core/scores.py ADDED
@@ -0,0 +1,47 @@
1
+ """Ordinal cumulative score (methods.tex Definition 1).
2
+
3
+ s(x, y) = max{ F(y-1 | x), 1 - F(y | x) }, with F(0|x) = 0, F(K|x) = 1.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+
10
+
11
+ def _validate_cdf(cdf: np.ndarray) -> np.ndarray:
12
+ """Validate an (n, K) conditional-CDF matrix: rows non-decreasing, last col 1."""
13
+ cdf = np.asarray(cdf, dtype=float)
14
+ if cdf.ndim != 2:
15
+ raise ValueError(f"cdf must be 2-D (n, K); got shape {cdf.shape}")
16
+ if np.any(np.diff(cdf, axis=1) < -1e-9):
17
+ raise ValueError("cdf rows must be non-decreasing in the category index")
18
+ if not np.allclose(cdf[:, -1], 1.0, atol=1e-6):
19
+ raise ValueError("cdf last column must equal 1")
20
+ return np.clip(cdf, 0.0, 1.0)
21
+
22
+
23
+ def cdf_from_proba(proba: np.ndarray) -> np.ndarray:
24
+ """Cumulate an (n, K) class-probability matrix into a conditional CDF."""
25
+ proba = np.asarray(proba, dtype=float)
26
+ cdf = np.cumsum(proba, axis=1)
27
+ cdf /= cdf[:, -1:] # renormalize against float drift
28
+ return cdf
29
+
30
+
31
+ def score_matrix(cdf: np.ndarray) -> np.ndarray:
32
+ """All candidate-label scores: out[i, k-1] = s(x_i, k), k = 1..K.
33
+
34
+ Vectorized Definition 1: F(y-1) is the CDF shifted right with 0 prepended.
35
+ """
36
+ cdf = _validate_cdf(cdf)
37
+ below = np.concatenate([np.zeros((cdf.shape[0], 1)), cdf[:, :-1]], axis=1)
38
+ return np.maximum(below, 1.0 - cdf)
39
+
40
+
41
+ def cumulative_score(cdf: np.ndarray, y: np.ndarray) -> np.ndarray:
42
+ """s(x_i, y_i) for observed labels y in {1..K} (1-indexed)."""
43
+ y = np.asarray(y)
44
+ if y.min() < 1 or y.max() > cdf.shape[1]:
45
+ raise ValueError("labels must be 1-indexed in {1..K}")
46
+ sm = score_matrix(cdf)
47
+ return sm[np.arange(len(y)), y - 1]
@@ -0,0 +1,4 @@
1
+ from choir.crash.kabco import KABCO_LEVELS, kabco_to_int
2
+ from choir.crash.costs import usdot_relative
3
+
4
+ __all__ = ["KABCO_LEVELS", "kabco_to_int", "usdot_relative"]
choir/crash/costs.py ADDED
@@ -0,0 +1,21 @@
1
+ """Severity cost vectors for risk control (methods.tex §risk).
2
+
3
+ usdot_relative: order-of-magnitude comprehensive-crash-cost scale relative to O=1,
4
+ per the USDOT/FHWA VSL-based guidance. Exact dollar figures and citation year are
5
+ pinned during E7 (verification memo §4 item 4); the guarantees only require kappa
6
+ non-decreasing, and all E7 results are reported for the pinned vector.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+
14
+ def usdot_relative() -> np.ndarray:
15
+ """Relative comprehensive-cost scale (O, C, B, A, K), kappa(O)=1."""
16
+ return np.array([1.0, 20.0, 30.0, 150.0, 1500.0])
17
+
18
+
19
+ def fatal_omission() -> np.ndarray:
20
+ """Indicator cost for the fatal-omission guarantee (Corollary 5c)."""
21
+ return np.array([0.0, 0.0, 0.0, 0.0, 1.0])
choir/crash/kabco.py ADDED
@@ -0,0 +1,29 @@
1
+ """KABCO ordinal scale utilities.
2
+
3
+ y in {1..5}: 1=O (not injured), 2=C (possible), 3=B (non-incapacitating / suspected
4
+ minor), 4=A (incapacitating / suspected serious), 5=K (fatal).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ KABCO_LEVELS = ("O", "C", "B", "A", "K")
12
+
13
+ # CRIS Prsn_Injry_Sev_ID strings (TxDOT), verified against the 2017-2025 extract.
14
+ CRIS_MAP = {
15
+ "Not Injured": 1,
16
+ "Possible Injury": 2,
17
+ "Non-Incapacitating Injury": 3,
18
+ "Incapacitating Injury": 4,
19
+ "Killed": 5,
20
+ }
21
+
22
+
23
+ def kabco_to_int(labels, mapping: dict | None = None) -> np.ndarray:
24
+ """Map string severity labels to 1..5; unmapped values (Unknown/None) become 0.
25
+
26
+ Rows with 0 must be dropped by the caller and counted in the attrition table.
27
+ """
28
+ m = mapping or CRIS_MAP
29
+ return np.array([m.get(v, 0) for v in labels], dtype=int)
@@ -0,0 +1,11 @@
1
+ """Small public demo data so examples run without CRIS in under a minute.
2
+
3
+ The bundled table is a deterministically generated SYNTHETIC sample that follows the
4
+ public FARS/KABCO schema (no real records, no PII). It exists only to make the README
5
+ and tutorials runnable anywhere. Swap in a real FARS extract by replacing demo_fars.csv
6
+ with the same columns.
7
+ """
8
+
9
+ from choir.datasets.loader import load_demo, DEMO_COLUMNS
10
+
11
+ __all__ = ["load_demo", "DEMO_COLUMNS"]