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.
- choircert-0.1.0/.gitignore +31 -0
- choircert-0.1.0/LICENSE +21 -0
- choircert-0.1.0/PKG-INFO +127 -0
- choircert-0.1.0/README.md +93 -0
- choircert-0.1.0/pyproject.toml +64 -0
- choircert-0.1.0/src/choir/__init__.py +38 -0
- choircert-0.1.0/src/choir/compose.py +196 -0
- choircert-0.1.0/src/choir/core/__init__.py +0 -0
- choircert-0.1.0/src/choir/core/calibrate.py +75 -0
- choircert-0.1.0/src/choir/core/intervals.py +74 -0
- choircert-0.1.0/src/choir/core/scores.py +47 -0
- choircert-0.1.0/src/choir/crash/__init__.py +4 -0
- choircert-0.1.0/src/choir/crash/costs.py +21 -0
- choircert-0.1.0/src/choir/crash/kabco.py +29 -0
- choircert-0.1.0/src/choir/datasets/__init__.py +11 -0
- choircert-0.1.0/src/choir/datasets/demo_fars.csv +6001 -0
- choircert-0.1.0/src/choir/datasets/loader.py +31 -0
- choircert-0.1.0/src/choir/noise.py +67 -0
- choircert-0.1.0/src/choir/partitions.py +46 -0
- choircert-0.1.0/src/choir/risk.py +64 -0
- choircert-0.1.0/src/choir/shift.py +305 -0
- choircert-0.1.0/tests/test_datasets_api.py +46 -0
- choircert-0.1.0/tests/test_shift_compose.py +177 -0
- choircert-0.1.0/tests/test_theorems.py +227 -0
|
@@ -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.
|
choircert-0.1.0/LICENSE
ADDED
|
@@ -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.
|
choircert-0.1.0/PKG-INFO
ADDED
|
@@ -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])
|