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 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
+ )