compileml 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.
- compileml/__init__.py +17 -0
- compileml/artifact/__init__.py +7 -0
- compileml/artifact/build.py +241 -0
- compileml/artifact/calibration.py +52 -0
- compileml/artifact/recalibrate.py +98 -0
- compileml/bands/__init__.py +15 -0
- compileml/bands/builders.py +123 -0
- compileml/bands/certified.py +496 -0
- compileml/cli.py +261 -0
- compileml/compile/__init__.py +26 -0
- compileml/compile/distill.py +61 -0
- compileml/compile/extract.py +304 -0
- compileml/compile/quantize.py +78 -0
- compileml/export/__init__.py +10 -0
- compileml/export/cobol.py +190 -0
- compileml/export/sql.py +134 -0
- compileml/py.typed +0 -0
- compileml/runtime/__init__.py +47 -0
- compileml/runtime/_intmath.py +29 -0
- compileml/runtime/bands.py +27 -0
- compileml/runtime/calibrate.py +38 -0
- compileml/runtime/decide.py +144 -0
- compileml/runtime/explain.py +134 -0
- compileml/runtime/io.py +79 -0
- compileml/runtime/score.py +40 -0
- compileml/validate/__init__.py +5 -0
- compileml/validate/framework.py +275 -0
- compileml/viz/__init__.py +38 -0
- compileml/viz/_arrow.py +81 -0
- compileml/viz/_data.py +132 -0
- compileml/viz/plots.py +768 -0
- compileml/viz/svg.py +149 -0
- compileml-0.1.0.dist-info/METADATA +375 -0
- compileml-0.1.0.dist-info/RECORD +39 -0
- compileml-0.1.0.dist-info/WHEEL +5 -0
- compileml-0.1.0.dist-info/entry_points.txt +2 -0
- compileml-0.1.0.dist-info/licenses/LICENSE +201 -0
- compileml-0.1.0.dist-info/licenses/NOTICE +5 -0
- compileml-0.1.0.dist-info/top_level.txt +1 -0
compileml/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""CompileML — compile tree-ensemble models into deterministic decision artifacts.
|
|
2
|
+
|
|
3
|
+
The package splits into two halves with different dependency contracts:
|
|
4
|
+
|
|
5
|
+
- ``compileml.compile`` / ``compileml.artifact`` / ``compileml.bands`` /
|
|
6
|
+
``compileml.validate`` / ``compileml.export`` — the *compile side*, which may
|
|
7
|
+
use numpy and scikit-learn.
|
|
8
|
+
- ``compileml.runtime`` — the *decision side*, which imports only the Python
|
|
9
|
+
standard library. A compiled artifact can be scored, banded, calibrated, and
|
|
10
|
+
explained with nothing but this subpackage (or a copy of it).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from compileml.runtime import decide, load_artifact, verify_artifact
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0.dev0"
|
|
16
|
+
|
|
17
|
+
__all__ = ["decide", "load_artifact", "verify_artifact", "__version__"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Artifact assembly: build, calibrate, hash, save, recalibrate."""
|
|
2
|
+
|
|
3
|
+
from compileml.artifact.build import build_artifact, save_artifact
|
|
4
|
+
from compileml.artifact.calibration import fit_isotonic_table
|
|
5
|
+
from compileml.artifact.recalibrate import recalibrate_artifact
|
|
6
|
+
|
|
7
|
+
__all__ = ["build_artifact", "fit_isotonic_table", "recalibrate_artifact", "save_artifact"]
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Assemble, hash, and save CompileML decision artifacts (spec §3, §9)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import warnings
|
|
7
|
+
from os import PathLike
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from compileml import __version__
|
|
12
|
+
from compileml.artifact.calibration import fit_isotonic_table
|
|
13
|
+
from compileml.compile.extract import extract_trees, score_float
|
|
14
|
+
from compileml.compile.quantize import (
|
|
15
|
+
max_depth,
|
|
16
|
+
quantization_error_bound,
|
|
17
|
+
quantize_model,
|
|
18
|
+
rha,
|
|
19
|
+
)
|
|
20
|
+
from compileml.runtime.io import ARTIFACT_TYPE, SCHEMA_VERSION, canonical_hash, validate_structure
|
|
21
|
+
from compileml.runtime.score import score_micro
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _band_edges_int(band_edges, scale: int) -> list[int]:
|
|
25
|
+
"""Convert float band edges to strictly increasing fixed-point integers."""
|
|
26
|
+
edges_int = [rha(float(e) * scale) for e in band_edges]
|
|
27
|
+
collisions = [
|
|
28
|
+
(band_edges[i], band_edges[i + 1])
|
|
29
|
+
for i in range(len(edges_int) - 1)
|
|
30
|
+
if edges_int[i + 1] <= edges_int[i]
|
|
31
|
+
]
|
|
32
|
+
if collisions:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"band edges collide after fixed-point conversion at scale={scale}: "
|
|
35
|
+
f"{collisions}. Use fewer bands or a larger scale."
|
|
36
|
+
)
|
|
37
|
+
return edges_int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_artifact(
|
|
41
|
+
model,
|
|
42
|
+
feature_names,
|
|
43
|
+
baseline,
|
|
44
|
+
band_edges,
|
|
45
|
+
*,
|
|
46
|
+
band_labels=None,
|
|
47
|
+
calibration_latent=None,
|
|
48
|
+
calibration_y=None,
|
|
49
|
+
calibration: dict | None = None,
|
|
50
|
+
calibration_mode: str = "linear_int",
|
|
51
|
+
reasons: dict | None = None,
|
|
52
|
+
display_names: dict | None = None,
|
|
53
|
+
feature_meta: list | None = None,
|
|
54
|
+
missing_policy: str = "baseline",
|
|
55
|
+
metadata: dict | None = None,
|
|
56
|
+
scale: int = 1000,
|
|
57
|
+
micro_scale: int = 1_000_000,
|
|
58
|
+
top_k: int = 5,
|
|
59
|
+
threshold_decimals: int | None = None,
|
|
60
|
+
X_sample=None,
|
|
61
|
+
) -> dict:
|
|
62
|
+
"""Compile a fitted model into a complete, hashed decision artifact.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
model: Fitted sklearn GBM, XGBoost, or LightGBM model whose raw
|
|
66
|
+
output is a probability-like latent in [0, 1] (distilled
|
|
67
|
+
whiteboxes always satisfy this; classifiers emitting log-odds
|
|
68
|
+
margins must be distilled first via ``train_whitebox``).
|
|
69
|
+
feature_names: Feature order the model was trained on.
|
|
70
|
+
baseline: Reference row (typically imputer medians): imputation
|
|
71
|
+
values under missing_policy="baseline" and the attribution
|
|
72
|
+
reference point.
|
|
73
|
+
band_edges: Float latent band edges (from compileml.bands builders),
|
|
74
|
+
converted here to the fixed-point ladder.
|
|
75
|
+
band_labels: Labels per band; defaults to G01..Gnn.
|
|
76
|
+
calibration_latent: Latent sample used to fit the isotonic PD table
|
|
77
|
+
(alternatively pass a prebuilt ``calibration`` block).
|
|
78
|
+
calibration_y: Binary outcomes aligned with ``calibration_latent``.
|
|
79
|
+
reasons: Reason dictionary mapping feature name -> {code, negative,
|
|
80
|
+
positive, suppress}. **User-supplied content**: without an entry
|
|
81
|
+
a feature falls back to generic messages that are not suitable
|
|
82
|
+
for consumer-facing notices. Coverage below 100% warns and is
|
|
83
|
+
recorded in metadata (spec §7.6).
|
|
84
|
+
missing_policy: "baseline" (impute at decision time) or "reject".
|
|
85
|
+
X_sample: Optional sample rows; enables the measured quantization
|
|
86
|
+
report and the latent-range check.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The artifact dict, hashed and structurally validated.
|
|
90
|
+
"""
|
|
91
|
+
if missing_policy not in ("baseline", "reject"):
|
|
92
|
+
raise ValueError("missing_policy must be 'baseline' or 'reject'")
|
|
93
|
+
if micro_scale % scale != 0:
|
|
94
|
+
raise ValueError("micro_scale must be an integer multiple of scale")
|
|
95
|
+
|
|
96
|
+
# Accept a BandSpec (from compileml.bands builders) in place of raw edges.
|
|
97
|
+
bands_meta: dict = {}
|
|
98
|
+
if hasattr(band_edges, "edges") and hasattr(band_edges, "labels"):
|
|
99
|
+
spec = band_edges
|
|
100
|
+
if band_labels is None:
|
|
101
|
+
band_labels = list(spec.labels)
|
|
102
|
+
bands_meta = dict(getattr(spec, "metadata", {}) or {})
|
|
103
|
+
band_edges = list(spec.edges)
|
|
104
|
+
|
|
105
|
+
names = [str(n) for n in feature_names]
|
|
106
|
+
base_vals = [float(b) for b in np.asarray(baseline, dtype=float).reshape(-1)]
|
|
107
|
+
if len(names) != len(base_vals):
|
|
108
|
+
raise ValueError("feature_names and baseline must have the same length")
|
|
109
|
+
|
|
110
|
+
# --- extract + quantize -------------------------------------------------
|
|
111
|
+
extracted = extract_trees(model)
|
|
112
|
+
if extracted.n_features and extracted.n_features != len(names):
|
|
113
|
+
raise ValueError(f"model expects {extracted.n_features} features, got {len(names)} names")
|
|
114
|
+
if threshold_decimals is not None:
|
|
115
|
+
# Quantize split thresholds for decimal-arithmetic targets (spec §11).
|
|
116
|
+
# Every runtime — Python included — then compares identical values;
|
|
117
|
+
# the quantization report below measures the routing cost.
|
|
118
|
+
for tree in extracted.trees:
|
|
119
|
+
tree["threshold"] = [round(t, int(threshold_decimals)) for t in tree["threshold"]]
|
|
120
|
+
model_int = quantize_model(extracted, micro_scale=micro_scale)
|
|
121
|
+
depth = max_depth(model_int)
|
|
122
|
+
if depth > 2:
|
|
123
|
+
warnings.warn(
|
|
124
|
+
f"whitebox depth is {depth} (> 2): attribution will carry a nonzero "
|
|
125
|
+
"residual and exact_attribution will be False (spec §7.3).",
|
|
126
|
+
stacklevel=2,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# --- quantization + latent-range report ---------------------------------
|
|
130
|
+
quant_report = {"error_bound": quantization_error_bound(model_int)}
|
|
131
|
+
if threshold_decimals is not None:
|
|
132
|
+
quant_report["threshold_decimals"] = int(threshold_decimals)
|
|
133
|
+
if X_sample is not None:
|
|
134
|
+
X_arr = np.asarray(X_sample, dtype=float)
|
|
135
|
+
float_scores = np.array([score_float(extracted, [float(v) for v in row]) for row in X_arr])
|
|
136
|
+
int_scores = (
|
|
137
|
+
np.array([score_micro(model_int, [float(v) for v in row]) for row in X_arr])
|
|
138
|
+
/ micro_scale
|
|
139
|
+
)
|
|
140
|
+
quant_report["measured_max_error"] = float(np.max(np.abs(float_scores - int_scores)))
|
|
141
|
+
outside = float(np.mean((float_scores < 0.0) | (float_scores > 1.0)))
|
|
142
|
+
quant_report["share_outside_unit_interval"] = outside
|
|
143
|
+
if outside > 0.01:
|
|
144
|
+
warnings.warn(
|
|
145
|
+
f"{outside:.1%} of sample latents fall outside [0, 1] before clamping. "
|
|
146
|
+
"The artifact contract expects probability-like latents; distill "
|
|
147
|
+
"margin-space models first (train_whitebox).",
|
|
148
|
+
stacklevel=2,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# --- calibration ---------------------------------------------------------
|
|
152
|
+
if calibration is None and calibration_latent is not None:
|
|
153
|
+
if calibration_y is None:
|
|
154
|
+
raise ValueError("calibration_y is required when calibration_latent is given")
|
|
155
|
+
calibration = fit_isotonic_table(
|
|
156
|
+
calibration_latent, calibration_y, micro_scale=micro_scale, mode=calibration_mode
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# --- bands ----------------------------------------------------------------
|
|
160
|
+
edges_int = _band_edges_int(band_edges, scale)
|
|
161
|
+
n_bands = len(edges_int) - 1
|
|
162
|
+
labels = (
|
|
163
|
+
[str(label) for label in band_labels]
|
|
164
|
+
if band_labels is not None
|
|
165
|
+
else [f"G{i + 1:02d}" for i in range(n_bands)]
|
|
166
|
+
)
|
|
167
|
+
if len(labels) != n_bands:
|
|
168
|
+
raise ValueError(f"expected {n_bands} band labels, got {len(labels)}")
|
|
169
|
+
|
|
170
|
+
# --- reason coverage (spec §7.6: user-supplied content) -------------------
|
|
171
|
+
reasons = dict(reasons or {})
|
|
172
|
+
covered = [n for n in names if n in reasons]
|
|
173
|
+
uncovered = [n for n in names if n not in reasons]
|
|
174
|
+
coverage = len(covered) / len(names) if names else 1.0
|
|
175
|
+
if uncovered:
|
|
176
|
+
warnings.warn(
|
|
177
|
+
f"reason dictionary covers {len(covered)}/{len(names)} features "
|
|
178
|
+
f"({coverage:.0%}). Uncovered features fall back to generic messages "
|
|
179
|
+
f"unsuitable for consumer-facing notices: {uncovered}",
|
|
180
|
+
stacklevel=2,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
artifact = {
|
|
184
|
+
"artifact_type": ARTIFACT_TYPE,
|
|
185
|
+
"schema_version": SCHEMA_VERSION,
|
|
186
|
+
"scale": int(scale),
|
|
187
|
+
"model": model_int,
|
|
188
|
+
"calibration": calibration,
|
|
189
|
+
"bands": {
|
|
190
|
+
"edges_int": edges_int,
|
|
191
|
+
"labels": labels,
|
|
192
|
+
"boundary": "left_closed_right_open",
|
|
193
|
+
},
|
|
194
|
+
"features": {
|
|
195
|
+
"names": names,
|
|
196
|
+
"baseline": base_vals,
|
|
197
|
+
"missing_policy": missing_policy,
|
|
198
|
+
"display_names": {str(k): str(v) for k, v in (display_names or {}).items()},
|
|
199
|
+
"meta": _plain(feature_meta or []),
|
|
200
|
+
},
|
|
201
|
+
"reasons": _plain(reasons),
|
|
202
|
+
"runtime": {
|
|
203
|
+
"attribution": "pairwise_interaction_int",
|
|
204
|
+
"top_k": int(top_k),
|
|
205
|
+
"whitebox_max_depth": depth,
|
|
206
|
+
"exact_attribution": depth <= 2,
|
|
207
|
+
},
|
|
208
|
+
"metadata": {
|
|
209
|
+
**_plain(metadata or {}),
|
|
210
|
+
"compileml_version": __version__,
|
|
211
|
+
"model_family": extracted.family,
|
|
212
|
+
"n_trees": len(model_int["trees"]),
|
|
213
|
+
"reason_coverage": round(coverage, 4),
|
|
214
|
+
"quantization": _plain(quant_report),
|
|
215
|
+
**({"bands": _plain(bands_meta)} if bands_meta else {}),
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
artifact["artifact_hash"] = canonical_hash(artifact)
|
|
219
|
+
validate_structure(artifact)
|
|
220
|
+
return artifact
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def save_artifact(artifact: dict, path: str | PathLike, *, indent: int | None = 2) -> None:
|
|
224
|
+
"""Write an artifact to JSON (hash already embedded; loaders verify it)."""
|
|
225
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
226
|
+
json.dump(artifact, f, indent=indent, ensure_ascii=False)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _plain(value):
|
|
230
|
+
"""Recursively convert numpy scalars/arrays to JSON-native Python types."""
|
|
231
|
+
if isinstance(value, np.ndarray):
|
|
232
|
+
return [_plain(v) for v in value.tolist()]
|
|
233
|
+
if isinstance(value, np.floating):
|
|
234
|
+
return float(value)
|
|
235
|
+
if isinstance(value, np.integer):
|
|
236
|
+
return int(value)
|
|
237
|
+
if isinstance(value, dict):
|
|
238
|
+
return {str(k): _plain(v) for k, v in value.items()}
|
|
239
|
+
if isinstance(value, (list, tuple)):
|
|
240
|
+
return [_plain(v) for v in value]
|
|
241
|
+
return value
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Fit an isotonic calibration and freeze it as an integer table (spec §6)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from compileml.compile.quantize import rha
|
|
8
|
+
from compileml.runtime.calibrate import PD_SCALE
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def fit_isotonic_table(
|
|
12
|
+
latent,
|
|
13
|
+
y,
|
|
14
|
+
*,
|
|
15
|
+
micro_scale: int = 1_000_000,
|
|
16
|
+
mode: str = "linear_int",
|
|
17
|
+
) -> dict:
|
|
18
|
+
"""Isotonic PD calibration frozen into integer thresholds.
|
|
19
|
+
|
|
20
|
+
``latent`` is the (clipped) model latent on a calibration sample; ``y``
|
|
21
|
+
the binary outcomes. Returns the artifact's ``calibration`` block:
|
|
22
|
+
strictly increasing ``f_micro``, non-decreasing ``pd_ppm``.
|
|
23
|
+
"""
|
|
24
|
+
from sklearn.isotonic import IsotonicRegression
|
|
25
|
+
|
|
26
|
+
if mode not in ("linear_int", "step"):
|
|
27
|
+
raise ValueError("mode must be 'linear_int' or 'step'")
|
|
28
|
+
|
|
29
|
+
x = np.clip(np.asarray(latent, dtype=float).reshape(-1), 0.0, 1.0)
|
|
30
|
+
yy = np.asarray(y, dtype=float).reshape(-1)
|
|
31
|
+
if x.shape[0] != yy.shape[0]:
|
|
32
|
+
raise ValueError("latent and y must have the same length")
|
|
33
|
+
if x.shape[0] == 0:
|
|
34
|
+
raise ValueError("latent must not be empty")
|
|
35
|
+
|
|
36
|
+
iso = IsotonicRegression(y_min=0.0, y_max=1.0, out_of_bounds="clip")
|
|
37
|
+
iso.fit(x, yy)
|
|
38
|
+
|
|
39
|
+
f_micro: list[int] = []
|
|
40
|
+
pd_ppm: list[int] = []
|
|
41
|
+
for xf, yf in zip(iso.X_thresholds_, iso.y_thresholds_):
|
|
42
|
+
f = rha(float(xf) * micro_scale)
|
|
43
|
+
p = min(max(rha(float(yf) * PD_SCALE), 0), PD_SCALE)
|
|
44
|
+
if f_micro and f == f_micro[-1]:
|
|
45
|
+
# Thresholds that collide after rounding: keep the larger PD
|
|
46
|
+
# (isotonic y is non-decreasing, so this is the later one).
|
|
47
|
+
pd_ppm[-1] = max(pd_ppm[-1], p)
|
|
48
|
+
else:
|
|
49
|
+
f_micro.append(f)
|
|
50
|
+
pd_ppm.append(p)
|
|
51
|
+
|
|
52
|
+
return {"mode": mode, "f_micro": f_micro, "pd_ppm": pd_ppm}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Zero-churn artifact recalibration — the retraining story (spec §6, §9).
|
|
2
|
+
|
|
3
|
+
When the portfolio drifts, PDs go stale but the model and the band edges
|
|
4
|
+
need not move. ``recalibrate_artifact`` refits the isotonic PD table and
|
|
5
|
+
refreshes per-band bad-rate metadata on fresh outcomes while keeping the
|
|
6
|
+
model and the fixed-point ladder byte-identical. The result is a new
|
|
7
|
+
hashed artifact that records its predecessor's hash — a provenance chain:
|
|
8
|
+
|
|
9
|
+
artifact_v1 --(fresh outcomes)--> artifact_v2
|
|
10
|
+
same model, same edges, same band assignments, new PDs, new hash
|
|
11
|
+
|
|
12
|
+
Because band assignment depends only on the model and edges, **no account
|
|
13
|
+
changes band** as a result of recalibration. That is the zero-churn
|
|
14
|
+
guarantee, and it is testable.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import copy
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from compileml.artifact.calibration import fit_isotonic_table
|
|
24
|
+
from compileml.compile.quantize import rha
|
|
25
|
+
from compileml.runtime.bands import band_index
|
|
26
|
+
from compileml.runtime.io import canonical_hash
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def recalibrate_artifact(
|
|
30
|
+
artifact: dict,
|
|
31
|
+
latent,
|
|
32
|
+
y,
|
|
33
|
+
*,
|
|
34
|
+
mode: str | None = None,
|
|
35
|
+
prior_strength: float = 0.0,
|
|
36
|
+
prior_pi0: float | None = None,
|
|
37
|
+
) -> dict:
|
|
38
|
+
"""Refit calibration on fresh outcomes; model and band edges unchanged.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
artifact: An existing decision artifact.
|
|
42
|
+
latent: Fresh latent scores in [0, 1] (clipped), e.g. the artifact's
|
|
43
|
+
own ``latent_micro / micro_scale`` on the new sample.
|
|
44
|
+
y: Fresh binary outcomes aligned with ``latent``.
|
|
45
|
+
mode: Calibration mode for the new table; defaults to the old one.
|
|
46
|
+
prior_strength: Optional shrinkage weight pulling per-band empirical
|
|
47
|
+
bad rates toward a prior for the band metadata refresh — small
|
|
48
|
+
bands get stabilized rates.
|
|
49
|
+
prior_pi0: The prior rate (defaults to the global bad rate).
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
A new artifact dict with updated ``calibration``, refreshed band
|
|
53
|
+
bad-rate metadata, ``metadata.recalibrated_from`` set to the old
|
|
54
|
+
hash, and a new ``artifact_hash``.
|
|
55
|
+
"""
|
|
56
|
+
F = np.clip(np.asarray(latent, dtype=float).reshape(-1), 0.0, 1.0)
|
|
57
|
+
y_arr = np.asarray(y, dtype=float).reshape(-1)
|
|
58
|
+
if F.shape[0] != y_arr.shape[0]:
|
|
59
|
+
raise ValueError("latent and y must have the same length")
|
|
60
|
+
|
|
61
|
+
old_hash = artifact.get("artifact_hash")
|
|
62
|
+
micro_scale = int(artifact["model"]["micro_scale"])
|
|
63
|
+
scale = int(artifact["scale"])
|
|
64
|
+
ratio = micro_scale // scale
|
|
65
|
+
|
|
66
|
+
new = copy.deepcopy(artifact)
|
|
67
|
+
|
|
68
|
+
# --- refit the decision-time PD table --------------------------------
|
|
69
|
+
old_mode = (artifact.get("calibration") or {}).get("mode", "linear_int")
|
|
70
|
+
new["calibration"] = fit_isotonic_table(
|
|
71
|
+
F, y_arr, micro_scale=micro_scale, mode=mode or old_mode
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# --- refresh per-band bad-rate metadata via the deployed integer path -
|
|
75
|
+
edges_int = new["bands"]["edges_int"]
|
|
76
|
+
n_bands = len(edges_int) - 1
|
|
77
|
+
latent_int = [rha(float(v) * micro_scale) // ratio for v in F]
|
|
78
|
+
idx = np.array([band_index(v, edges_int) for v in latent_int])
|
|
79
|
+
counts = np.bincount(idx, minlength=n_bands)
|
|
80
|
+
bad = np.bincount(idx, weights=y_arr, minlength=n_bands)
|
|
81
|
+
global_pd = float(np.mean(y_arr))
|
|
82
|
+
rate = np.where(counts > 0, bad / np.maximum(counts, 1), global_pd)
|
|
83
|
+
if prior_strength > 0.0:
|
|
84
|
+
pi0 = global_pd if prior_pi0 is None else float(prior_pi0)
|
|
85
|
+
rate = (rate * counts + prior_strength * pi0) / (counts + prior_strength)
|
|
86
|
+
|
|
87
|
+
new["metadata"] = dict(new.get("metadata") or {})
|
|
88
|
+
new["metadata"]["recalibration"] = {
|
|
89
|
+
"recalibrated_from": old_hash,
|
|
90
|
+
"n_observations": int(F.shape[0]),
|
|
91
|
+
"global_bad_rate": global_pd,
|
|
92
|
+
"band_counts": [int(c) for c in counts],
|
|
93
|
+
"band_bad_rate": [float(r) for r in rate],
|
|
94
|
+
"prior_strength": float(prior_strength),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
new["artifact_hash"] = canonical_hash(new)
|
|
98
|
+
return new
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Risk band construction: plain quantile, monotone-quantile, and
|
|
2
|
+
search-and-certify builders. All return a :class:`BandSpec` consumed by
|
|
3
|
+
``compileml.artifact.build_artifact``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from compileml.bands.builders import BandSpec, monotone_quantile_bands, quantile_bands
|
|
7
|
+
from compileml.bands.certified import governance_bands, semantic_bands
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BandSpec",
|
|
11
|
+
"governance_bands",
|
|
12
|
+
"monotone_quantile_bands",
|
|
13
|
+
"quantile_bands",
|
|
14
|
+
"semantic_bands",
|
|
15
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Risk band construction from latent scores.
|
|
2
|
+
|
|
3
|
+
Builders return a :class:`BandSpec` — float edges, labels, and metadata —
|
|
4
|
+
which ``compileml.artifact.build_artifact`` freezes into the fixed-point
|
|
5
|
+
integer ladder. Note: builders deliberately record **no timestamps**;
|
|
6
|
+
identical inputs must yield identical artifacts (and identical hashes).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from sklearn.isotonic import IsotonicRegression
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class BandSpec:
|
|
19
|
+
"""Float-space band definition produced by the builders."""
|
|
20
|
+
|
|
21
|
+
edges: list[float] # n_bands + 1, strictly increasing
|
|
22
|
+
labels: list[str]
|
|
23
|
+
metadata: dict = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def n_bands(self) -> int:
|
|
27
|
+
return len(self.edges) - 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _quantile_edges(latent: np.ndarray, n_bands: int, method: str) -> np.ndarray:
|
|
31
|
+
if method == "quantile":
|
|
32
|
+
edges = np.quantile(latent, np.linspace(0.0, 1.0, n_bands + 1))
|
|
33
|
+
edges[0] = float(np.min(latent))
|
|
34
|
+
edges[-1] = float(np.max(latent)) + 1e-12
|
|
35
|
+
elif method == "equal_width":
|
|
36
|
+
edges = np.linspace(float(np.min(latent)), float(np.max(latent)), n_bands + 1)
|
|
37
|
+
edges[-1] += 1e-12
|
|
38
|
+
else:
|
|
39
|
+
raise ValueError("method must be 'quantile' or 'equal_width'")
|
|
40
|
+
# enforce strict monotonicity on degenerate distributions
|
|
41
|
+
for i in range(1, len(edges)):
|
|
42
|
+
if edges[i] <= edges[i - 1]:
|
|
43
|
+
edges[i] = edges[i - 1] + 1e-12
|
|
44
|
+
return edges
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _labels(n: int) -> list[str]:
|
|
48
|
+
return [f"G{i + 1:02d}" for i in range(n)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def quantile_bands(latent, n_bands: int = 10, *, method: str = "quantile") -> BandSpec:
|
|
52
|
+
"""Plain quantile (or equal-width) bands; no outcome data required."""
|
|
53
|
+
x = np.asarray(latent, dtype=float).reshape(-1)
|
|
54
|
+
edges = _quantile_edges(x, n_bands, method)
|
|
55
|
+
counts = np.bincount(np.clip(np.digitize(x, edges) - 1, 0, n_bands - 1), minlength=n_bands)
|
|
56
|
+
return BandSpec(
|
|
57
|
+
edges=[float(e) for e in edges],
|
|
58
|
+
labels=_labels(n_bands),
|
|
59
|
+
metadata={"method": method, "counts": [int(c) for c in counts]},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def monotone_quantile_bands(
|
|
64
|
+
latent,
|
|
65
|
+
y,
|
|
66
|
+
n_bands: int = 10,
|
|
67
|
+
*,
|
|
68
|
+
allow_merge: bool = False,
|
|
69
|
+
merge_eps: float = 0.005,
|
|
70
|
+
) -> BandSpec:
|
|
71
|
+
"""Quantile bands with empirical bad rates and isotonic-smoothed semantics.
|
|
72
|
+
|
|
73
|
+
Fixed-K quantile edges by default. With ``allow_merge=True``, adjacent
|
|
74
|
+
bands whose empirical bad rates invert by more than ``merge_eps`` are
|
|
75
|
+
merged until no material violation remains — trading band count for
|
|
76
|
+
guaranteed-monotone empirical semantics.
|
|
77
|
+
"""
|
|
78
|
+
F = np.asarray(latent, dtype=float).reshape(-1)
|
|
79
|
+
y_arr = np.asarray(y, dtype=float).reshape(-1)
|
|
80
|
+
if F.shape[0] != y_arr.shape[0]:
|
|
81
|
+
raise ValueError("latent and y must have the same length")
|
|
82
|
+
|
|
83
|
+
edges = _quantile_edges(F, n_bands, "quantile")
|
|
84
|
+
|
|
85
|
+
def stats(cur_edges):
|
|
86
|
+
idx = np.clip(np.digitize(F, cur_edges) - 1, 0, len(cur_edges) - 2)
|
|
87
|
+
k = len(cur_edges) - 1
|
|
88
|
+
counts = np.bincount(idx, minlength=k)
|
|
89
|
+
bad = np.bincount(idx, weights=y_arr, minlength=k)
|
|
90
|
+
rate = np.where(counts > 0, bad / np.maximum(counts, 1), float(np.mean(y_arr)))
|
|
91
|
+
return counts.astype(int), rate
|
|
92
|
+
|
|
93
|
+
merges: list[dict] = []
|
|
94
|
+
counts, emp_rate = stats(edges)
|
|
95
|
+
if allow_merge:
|
|
96
|
+
while len(emp_rate) > 1:
|
|
97
|
+
violations = emp_rate[:-1] - emp_rate[1:]
|
|
98
|
+
worst = float(np.max(violations))
|
|
99
|
+
if worst <= merge_eps:
|
|
100
|
+
break
|
|
101
|
+
i = int(np.argmax(violations))
|
|
102
|
+
merges.append({"merge_idx": i, "violation": worst})
|
|
103
|
+
edges = np.delete(edges, i + 1)
|
|
104
|
+
counts, emp_rate = stats(edges)
|
|
105
|
+
|
|
106
|
+
smoothed = IsotonicRegression(increasing=True, out_of_bounds="clip").fit_transform(
|
|
107
|
+
np.arange(len(emp_rate)), emp_rate
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
k = len(edges) - 1
|
|
111
|
+
return BandSpec(
|
|
112
|
+
edges=[float(e) for e in edges],
|
|
113
|
+
labels=_labels(k),
|
|
114
|
+
metadata={
|
|
115
|
+
"method": "monotone_quantile",
|
|
116
|
+
"counts": [int(c) for c in counts],
|
|
117
|
+
"empirical_bad_rate": [float(v) for v in emp_rate],
|
|
118
|
+
"smoothed_bad_rate": [float(v) for v in smoothed],
|
|
119
|
+
"allow_merge": bool(allow_merge),
|
|
120
|
+
"merge_eps": float(merge_eps),
|
|
121
|
+
"merges": merges,
|
|
122
|
+
},
|
|
123
|
+
)
|