treecf 0.2.2__tar.gz → 0.2.4__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.
- {treecf-0.2.2 → treecf-0.2.4}/PKG-INFO +8 -1
- {treecf-0.2.2 → treecf-0.2.4}/README.md +2 -0
- {treecf-0.2.2 → treecf-0.2.4}/pyproject.toml +7 -1
- {treecf-0.2.2 → treecf-0.2.4}/rust/Cargo.lock +1 -1
- {treecf-0.2.2 → treecf-0.2.4}/rust/Cargo.toml +1 -1
- {treecf-0.2.2 → treecf-0.2.4}/rust/src/exact/search.rs +9 -8
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/__init__.py +1 -1
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/api.py +53 -3
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/audit.py +101 -4
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/batch.py +37 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/parsers/sklearn.py +35 -3
- {treecf-0.2.2 → treecf-0.2.4}/LICENSE +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/rust/src/cells.rs +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/rust/src/constraints.rs +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/rust/src/exact/domains.rs +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/rust/src/exact/mod.rs +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/rust/src/exact/orderpairs.rs +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/rust/src/exact/propagation.rs +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/rust/src/exact/test_support.rs +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/rust/src/ga.rs +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/rust/src/interrupt.rs +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/rust/src/ir.rs +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/rust/src/lib.rs +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/rust/src/py.rs +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/rust/src/regions.rs +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/_errors.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/_json.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/aim/__init__.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/aim/cells.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/__init__.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/_exact_bounds.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/_exact_domains.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/_exact_orderpairs.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/_exact_propagation.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/exact.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/exact_rust.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/genetic.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/genetic_rust.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/regions_rust.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/constraints/__init__.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/constraints/compile.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/constraints/flatten.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/constraints/objects.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/constraints/parser.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/__init__.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/conformance.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/evaluate.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/flatten.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/model.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/parsers/__init__.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/parsers/catboost.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/parsers/json_dump.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/parsers/lightgbm.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/parsers/xgboost.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/mining.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/objective.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/plausibility.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/py.typed +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/regions.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/targets.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/viz.py +0 -0
- {treecf-0.2.2 → treecf-0.2.4}/src/treecf/viz_batch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: treecf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Classifier: Development Status :: 4 - Beta
|
|
5
5
|
Classifier: Intended Audience :: Science/Research
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -29,6 +29,7 @@ Requires-Dist: lightgbm>=4.3 ; extra == 'dev'
|
|
|
29
29
|
Requires-Dist: catboost>=1.2 ; extra == 'dev'
|
|
30
30
|
Requires-Dist: scikit-learn>=1.4 ; extra == 'dev'
|
|
31
31
|
Requires-Dist: matplotlib>=3.8 ; extra == 'dev'
|
|
32
|
+
Requires-Dist: probcal>=0.2 ; extra == 'dev'
|
|
32
33
|
Requires-Dist: mkdocs>=1.6 ; extra == 'docs'
|
|
33
34
|
Requires-Dist: mkdocs-material>=9.5 ; extra == 'docs'
|
|
34
35
|
Requires-Dist: mkdocstrings[python]>=0.27 ; extra == 'docs'
|
|
@@ -37,6 +38,9 @@ Requires-Dist: mkdocs-jupyter>=0.24 ; extra == 'docs'
|
|
|
37
38
|
Requires-Dist: ipykernel>=6.29 ; extra == 'docs'
|
|
38
39
|
Requires-Dist: lightgbm>=4.3 ; extra == 'lightgbm'
|
|
39
40
|
Requires-Dist: scikit-learn>=1.4 ; extra == 'sklearn'
|
|
41
|
+
Requires-Dist: pytest>=8.0 ; extra == 'test'
|
|
42
|
+
Requires-Dist: hypothesis>=6.100 ; extra == 'test'
|
|
43
|
+
Requires-Dist: probcal>=0.2 ; extra == 'test'
|
|
40
44
|
Requires-Dist: matplotlib>=3.8 ; extra == 'viz'
|
|
41
45
|
Requires-Dist: xgboost>=2.0 ; extra == 'xgboost'
|
|
42
46
|
Provides-Extra: all
|
|
@@ -45,6 +49,7 @@ Provides-Extra: dev
|
|
|
45
49
|
Provides-Extra: docs
|
|
46
50
|
Provides-Extra: lightgbm
|
|
47
51
|
Provides-Extra: sklearn
|
|
52
|
+
Provides-Extra: test
|
|
48
53
|
Provides-Extra: viz
|
|
49
54
|
Provides-Extra: xgboost
|
|
50
55
|
License-File: LICENSE
|
|
@@ -61,6 +66,8 @@ Project-URL: Issues, https://github.com/wlazlod/treecf/issues
|
|
|
61
66
|
|
|
62
67
|
# treecf
|
|
63
68
|
|
|
69
|
+
[](https://doi.org/10.5281/zenodo.22069503)
|
|
70
|
+
|
|
64
71
|
**Constrained, threshold-aware counterfactual explanations for tree ensembles.**
|
|
65
72
|
|
|
66
73
|
`treecf` answers the question: *"what is the minimal, feasible change to this instance such
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# treecf
|
|
2
2
|
|
|
3
|
+
[](https://doi.org/10.5281/zenodo.22069503)
|
|
4
|
+
|
|
3
5
|
**Constrained, threshold-aware counterfactual explanations for tree ensembles.**
|
|
4
6
|
|
|
5
7
|
`treecf` answers the question: *"what is the minimal, feasible change to this instance such
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "treecf"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.4"
|
|
4
4
|
description = "Constrained, threshold-aware counterfactual explanations for tree ensembles (XGBoost, LightGBM, CatBoost, sklearn) — fast Rust genetic search, exact optimality proofs, certified infeasibility, and recourse regions."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -44,6 +44,11 @@ all = [
|
|
|
44
44
|
"scikit-learn>=1.4",
|
|
45
45
|
"matplotlib>=3.8",
|
|
46
46
|
]
|
|
47
|
+
test = [
|
|
48
|
+
"pytest>=8.0",
|
|
49
|
+
"hypothesis>=6.100",
|
|
50
|
+
"probcal>=0.2",
|
|
51
|
+
]
|
|
47
52
|
dev = [
|
|
48
53
|
"maturin>=1.7",
|
|
49
54
|
"pytest>=8.0",
|
|
@@ -56,6 +61,7 @@ dev = [
|
|
|
56
61
|
"catboost>=1.2",
|
|
57
62
|
"scikit-learn>=1.4",
|
|
58
63
|
"matplotlib>=3.8",
|
|
64
|
+
"probcal>=0.2",
|
|
59
65
|
]
|
|
60
66
|
docs = [
|
|
61
67
|
"mkdocs>=1.6",
|
|
@@ -1053,19 +1053,18 @@ pub fn solve_exact(
|
|
|
1053
1053
|
}
|
|
1054
1054
|
|
|
1055
1055
|
completed = completed && dropped_floor >= incumbent_cost;
|
|
1056
|
-
let lower_bound
|
|
1057
|
-
|
|
1058
|
-
if completed {
|
|
1059
|
-
lower_bound = match incumbent_row {
|
|
1056
|
+
let (lower_bound, proof) = if completed {
|
|
1057
|
+
let bound = match incumbent_row {
|
|
1060
1058
|
None => f64::INFINITY,
|
|
1061
1059
|
Some(_) if gap == 0.0 => incumbent_cost,
|
|
1062
1060
|
Some(_) => incumbent_cost / (1.0 + gap),
|
|
1063
1061
|
};
|
|
1064
|
-
|
|
1062
|
+
let label = if gap > 0.0 && gap_prune_fired {
|
|
1065
1063
|
"optimal_within_gap"
|
|
1066
1064
|
} else {
|
|
1067
1065
|
"optimal"
|
|
1068
1066
|
};
|
|
1067
|
+
(bound, label)
|
|
1069
1068
|
} else {
|
|
1070
1069
|
let mut open_view = f64::INFINITY;
|
|
1071
1070
|
if !ctx.order.is_empty() {
|
|
@@ -1081,9 +1080,11 @@ pub fn solve_exact(
|
|
|
1081
1080
|
} else {
|
|
1082
1081
|
dropped_floor
|
|
1083
1082
|
};
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1083
|
+
(
|
|
1084
|
+
py_min(py_min(open_view, incumbent_cost), set_aside_view),
|
|
1085
|
+
"heuristic",
|
|
1086
|
+
)
|
|
1087
|
+
};
|
|
1087
1088
|
|
|
1088
1089
|
let mut snapped: Vec<usize> = Vec::new();
|
|
1089
1090
|
for (level, chosen_state) in incumbent_states.iter().flatten().enumerate() {
|
|
@@ -129,6 +129,11 @@ class Counterfactual:
|
|
|
129
129
|
region: The certified box around ``x_cf``, set only when the search ran
|
|
130
130
|
with ``region=True`` (``Explainer.explain``/``explain_batch``/
|
|
131
131
|
``explain_coalitions``); ``None`` otherwise.
|
|
132
|
+
score_calibrated: The calibrator's probability at ``x_cf`` — set only
|
|
133
|
+
for a calibrated-space target whose calibrator exposes
|
|
134
|
+
``predict_proba``; ``None`` otherwise. Presentational: the engine
|
|
135
|
+
optimized and verified against the raw interval the calibrator's
|
|
136
|
+
``interval_inverse`` produced, never against this value.
|
|
132
137
|
"""
|
|
133
138
|
|
|
134
139
|
x_cf: FloatArray
|
|
@@ -141,6 +146,7 @@ class Counterfactual:
|
|
|
141
146
|
solver_stats: dict[str, object] = field(default_factory=dict)
|
|
142
147
|
snapped: dict[str, bool] = field(default_factory=dict) # value_policy outcome
|
|
143
148
|
region: RecourseRegion | None = None # set when `explain(..., region=True)`
|
|
149
|
+
score_calibrated: float | None = None # presentational read-out; see docstring
|
|
144
150
|
|
|
145
151
|
|
|
146
152
|
@dataclass(frozen=True)
|
|
@@ -171,6 +177,25 @@ class Infeasible:
|
|
|
171
177
|
solver_stats: dict[str, object] = field(default_factory=dict)
|
|
172
178
|
|
|
173
179
|
|
|
180
|
+
def _calibrated_readout(target: Target, score_raw: float) -> float | None:
|
|
181
|
+
"""Calibrated probability at a raw score, or ``None`` when unavailable.
|
|
182
|
+
|
|
183
|
+
Duck-typed on ``predict_proba`` (never a probcal import); any failure in
|
|
184
|
+
the external calibrator degrades to ``None`` rather than erroring —
|
|
185
|
+
the read-out is presentational, the engine consumed the raw interval.
|
|
186
|
+
"""
|
|
187
|
+
if target.space != "calibrated":
|
|
188
|
+
return None
|
|
189
|
+
predict = getattr(target.calibrator, "predict_proba", None)
|
|
190
|
+
if not callable(predict):
|
|
191
|
+
return None
|
|
192
|
+
try:
|
|
193
|
+
prob = 1.0 / (1.0 + np.exp(-np.float64(score_raw)))
|
|
194
|
+
return float(np.asarray(predict(np.array([prob])), dtype=np.float64).reshape(-1)[0])
|
|
195
|
+
except Exception:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
174
199
|
# documented defaults for the exact-only kwargs; ``None`` at the public call
|
|
175
200
|
# sites is the sentinel for "not explicitly passed"
|
|
176
201
|
_DEFAULT_WARM_START = True
|
|
@@ -563,6 +588,10 @@ class Explainer:
|
|
|
563
588
|
)
|
|
564
589
|
if region and isinstance(outcome, Counterfactual):
|
|
565
590
|
outcome = replace(outcome, region=self._region_for(x, outcome.x_cf, interval))
|
|
591
|
+
if isinstance(outcome, Counterfactual) and target.space == "calibrated":
|
|
592
|
+
outcome = replace(
|
|
593
|
+
outcome, score_calibrated=_calibrated_readout(target, outcome.score_raw)
|
|
594
|
+
)
|
|
566
595
|
results[name] = outcome
|
|
567
596
|
message = _degraded_summary(band_degraded, len(band_degraded), len(intervals), "bands")
|
|
568
597
|
if message is not None:
|
|
@@ -584,6 +613,10 @@ class Explainer:
|
|
|
584
613
|
)
|
|
585
614
|
if region and isinstance(result, Counterfactual):
|
|
586
615
|
result = replace(result, region=self._region_for(x, result.x_cf, interval))
|
|
616
|
+
if isinstance(result, Counterfactual) and target.space == "calibrated":
|
|
617
|
+
result = replace(
|
|
618
|
+
result, score_calibrated=_calibrated_readout(target, result.score_raw)
|
|
619
|
+
)
|
|
587
620
|
return result
|
|
588
621
|
|
|
589
622
|
def explain_batch(
|
|
@@ -1318,7 +1351,9 @@ class Explainer:
|
|
|
1318
1351
|
gap=gap, time_budget_s=time_budget_s, warm_start=warm_start,
|
|
1319
1352
|
)
|
|
1320
1353
|
|
|
1321
|
-
def check_certificate(
|
|
1354
|
+
def check_certificate(
|
|
1355
|
+
self, cert: dict[str, object], *, calibrator: object | None = None
|
|
1356
|
+
) -> dict[str, object]:
|
|
1322
1357
|
"""Validate a stored certificate against *this* explainer.
|
|
1323
1358
|
|
|
1324
1359
|
Recomputes both fingerprints (model and constraints) against this
|
|
@@ -1327,18 +1362,33 @@ class Explainer:
|
|
|
1327
1362
|
changed constraint set each flips the corresponding boolean. This
|
|
1328
1363
|
method reports — it never raises on a mismatch.
|
|
1329
1364
|
|
|
1365
|
+
Without ``calibrator=``, a calibrated-target certificate is still
|
|
1366
|
+
fully verifiable in *plan geometry*: the resolved ``raw_interval``
|
|
1367
|
+
is stored, so this proves the plan reaches the stored interval — it
|
|
1368
|
+
does not prove which calibrator produced that interval. Passing
|
|
1369
|
+
``calibrator=`` adds exactly that: the duck-typed ``fingerprint()``
|
|
1370
|
+
is compared with the stored one, and the certificate's calibrated
|
|
1371
|
+
``lo``/``hi`` are re-inverted through the supplied calibrator and
|
|
1372
|
+
compared with the stored interval (rtol 1e-9, infinities by
|
|
1373
|
+
identity). Neither mode requires treecf to import a calibration
|
|
1374
|
+
library.
|
|
1375
|
+
|
|
1330
1376
|
Args:
|
|
1331
1377
|
cert: A certificate produced by ``Explainer.certificate`` (a
|
|
1332
1378
|
``json.loads`` round trip of one works identically).
|
|
1379
|
+
calibrator: Optional duck-typed calibrator (the object handed to
|
|
1380
|
+
``Target.calibrated``) to additionally verify calibrator
|
|
1381
|
+
provenance against a calibrated-target certificate.
|
|
1333
1382
|
|
|
1334
1383
|
Returns:
|
|
1335
1384
|
``{"model_match": bool, "constraints_match": bool,
|
|
1336
1385
|
"verification_ok": bool, "mismatches": [...]}`` with one
|
|
1337
|
-
human-readable string per mismatch
|
|
1386
|
+
human-readable string per mismatch, plus ``"calibrator_match":
|
|
1387
|
+
bool`` when ``calibrator=`` was given.
|
|
1338
1388
|
"""
|
|
1339
1389
|
from treecf.audit import check_certificate
|
|
1340
1390
|
|
|
1341
|
-
return check_certificate(self, cert)
|
|
1391
|
+
return check_certificate(self, cert, calibrator=calibrator)
|
|
1342
1392
|
|
|
1343
1393
|
def _region_for(
|
|
1344
1394
|
self, x: FloatArray, x_cf: FloatArray, interval: tuple[float, float]
|
|
@@ -309,6 +309,22 @@ def _verify_infeasible(
|
|
|
309
309
|
}
|
|
310
310
|
|
|
311
311
|
|
|
312
|
+
def _duck_fingerprint(calibrator: object) -> str | None:
|
|
313
|
+
"""The calibrator's duck-typed ``fingerprint()``, or ``None``.
|
|
314
|
+
|
|
315
|
+
Absent or raising members degrade to ``None`` — provenance is optional
|
|
316
|
+
by design; treecf never requires a calibration library at runtime.
|
|
317
|
+
"""
|
|
318
|
+
fn = getattr(calibrator, "fingerprint", None)
|
|
319
|
+
if not callable(fn):
|
|
320
|
+
return None
|
|
321
|
+
try:
|
|
322
|
+
value = fn()
|
|
323
|
+
except Exception:
|
|
324
|
+
return None
|
|
325
|
+
return str(value) if value is not None else None
|
|
326
|
+
|
|
327
|
+
|
|
312
328
|
def _target_block(
|
|
313
329
|
target: Target, band: str | None, explainer: Explainer
|
|
314
330
|
) -> tuple[dict[str, object], tuple[float, float]]:
|
|
@@ -338,10 +354,25 @@ def _target_block(
|
|
|
338
354
|
if band is not None:
|
|
339
355
|
block["band"] = band
|
|
340
356
|
if target.space == "calibrated":
|
|
341
|
-
block["calibrator"] =
|
|
357
|
+
block["calibrator"] = {
|
|
358
|
+
"embedded": False,
|
|
359
|
+
"fingerprint": _duck_fingerprint(target.calibrator),
|
|
360
|
+
"type": type(target.calibrator).__name__,
|
|
361
|
+
"buffer_logit": _json_float(target.buffer_logit),
|
|
362
|
+
}
|
|
342
363
|
return block, interval
|
|
343
364
|
|
|
344
365
|
|
|
366
|
+
def _factual_block(explainer: Explainer, x: FloatArray, target: Target) -> dict[str, object]:
|
|
367
|
+
block: dict[str, object] = {"x": _encode_array(x)}
|
|
368
|
+
if target.space == "calibrated":
|
|
369
|
+
from treecf.api import _calibrated_readout
|
|
370
|
+
|
|
371
|
+
readout = _calibrated_readout(target, raw_score(explainer.ir, x))
|
|
372
|
+
block["score_calibrated"] = _json_float(readout) if readout is not None else None
|
|
373
|
+
return block
|
|
374
|
+
|
|
375
|
+
|
|
345
376
|
def build_certificate(
|
|
346
377
|
explainer: Explainer,
|
|
347
378
|
x: FloatArray,
|
|
@@ -405,7 +436,7 @@ def build_certificate(
|
|
|
405
436
|
},
|
|
406
437
|
"target": target_block,
|
|
407
438
|
"solve": solve,
|
|
408
|
-
"factual":
|
|
439
|
+
"factual": _factual_block(explainer, x, target),
|
|
409
440
|
}
|
|
410
441
|
if reproducible_reason is not None:
|
|
411
442
|
cert["reproducible_reason"] = reproducible_reason
|
|
@@ -443,7 +474,9 @@ def build_certificate(
|
|
|
443
474
|
return cert
|
|
444
475
|
|
|
445
476
|
|
|
446
|
-
def check_certificate(
|
|
477
|
+
def check_certificate(
|
|
478
|
+
explainer: Explainer, cert: dict[str, object], *, calibrator: object | None = None
|
|
479
|
+
) -> dict[str, object]:
|
|
447
480
|
"""Body of ``Explainer.check_certificate``; see its docstring."""
|
|
448
481
|
mismatches: list[str] = []
|
|
449
482
|
|
|
@@ -517,9 +550,73 @@ def check_certificate(explainer: Explainer, cert: dict[str, object]) -> dict[str
|
|
|
517
550
|
verification_ok = False
|
|
518
551
|
mismatches.append(f"verification could not be re-run: {exc}")
|
|
519
552
|
|
|
520
|
-
|
|
553
|
+
report: dict[str, object] = {
|
|
521
554
|
"model_match": model_match,
|
|
522
555
|
"constraints_match": constraints_match,
|
|
523
556
|
"verification_ok": verification_ok,
|
|
524
557
|
"mismatches": mismatches,
|
|
525
558
|
}
|
|
559
|
+
if calibrator is not None:
|
|
560
|
+
report["calibrator_match"] = _check_calibrator(cert, calibrator, mismatches)
|
|
561
|
+
return report
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _check_calibrator(
|
|
565
|
+
cert: dict[str, object], calibrator: object, mismatches: list[str]
|
|
566
|
+
) -> bool:
|
|
567
|
+
"""The two extra checks ``check_certificate(calibrator=...)`` adds.
|
|
568
|
+
|
|
569
|
+
(a) the duck-typed fingerprint against the stored one — unavailable on
|
|
570
|
+
either side is noted in ``mismatches`` without failing the match, since
|
|
571
|
+
absence is not evidence of a different calibrator; (b) the certificate's
|
|
572
|
+
calibrated ``lo``/``hi`` re-inverted through the calibrator against the
|
|
573
|
+
stored ``raw_interval`` (rtol 1e-9, infinities by identity) — the check
|
|
574
|
+
that actually proves this calibrator produces this plan geometry.
|
|
575
|
+
"""
|
|
576
|
+
ok = True
|
|
577
|
+
target = cert.get("target")
|
|
578
|
+
if not isinstance(target, dict) or target.get("space") != "calibrated":
|
|
579
|
+
mismatches.append("calibrator= given but the certificate's target is not calibrated")
|
|
580
|
+
return False
|
|
581
|
+
stored_block = target.get("calibrator")
|
|
582
|
+
stored_fp = stored_block.get("fingerprint") if isinstance(stored_block, dict) else None
|
|
583
|
+
fresh_fp = _duck_fingerprint(calibrator)
|
|
584
|
+
if stored_fp is None or fresh_fp is None:
|
|
585
|
+
mismatches.append(
|
|
586
|
+
"calibrator fingerprint unavailable on "
|
|
587
|
+
+ ("both sides" if stored_fp is None and fresh_fp is None else "one side")
|
|
588
|
+
+ " — identity not confirmed by fingerprint"
|
|
589
|
+
)
|
|
590
|
+
elif stored_fp != fresh_fp:
|
|
591
|
+
ok = False
|
|
592
|
+
mismatches.append("calibrator fingerprint does not match the certificate's")
|
|
593
|
+
stored_buffer = (
|
|
594
|
+
_from_json_float(stored_block.get("buffer_logit", 0.0))
|
|
595
|
+
if isinstance(stored_block, dict)
|
|
596
|
+
else 0.0
|
|
597
|
+
)
|
|
598
|
+
raw = target.get("raw_interval")
|
|
599
|
+
try:
|
|
600
|
+
lo = _from_json_float(target.get("lo"))
|
|
601
|
+
hi = _from_json_float(target.get("hi"))
|
|
602
|
+
fresh_lo, fresh_hi = calibrator.interval_inverse( # type: ignore[attr-defined]
|
|
603
|
+
lo, hi, space="logit", buffer_logit=stored_buffer
|
|
604
|
+
)
|
|
605
|
+
stored_lo = _from_json_float(raw[0]) # type: ignore[index]
|
|
606
|
+
stored_hi = _from_json_float(raw[1]) # type: ignore[index]
|
|
607
|
+
for fresh, stored in ((fresh_lo, stored_lo), (fresh_hi, stored_hi)):
|
|
608
|
+
if math.isinf(fresh) or math.isinf(stored):
|
|
609
|
+
same = fresh == stored
|
|
610
|
+
else:
|
|
611
|
+
same = math.isclose(fresh, stored, rel_tol=1e-9, abs_tol=1e-12)
|
|
612
|
+
if not same:
|
|
613
|
+
ok = False
|
|
614
|
+
mismatches.append(
|
|
615
|
+
"re-inverted interval does not match the stored raw_interval: "
|
|
616
|
+
f"fresh ({fresh_lo}, {fresh_hi}) vs stored ({stored_lo}, {stored_hi})"
|
|
617
|
+
)
|
|
618
|
+
break
|
|
619
|
+
except Exception as exc:
|
|
620
|
+
ok = False
|
|
621
|
+
mismatches.append(f"re-inversion through the supplied calibrator failed: {exc}")
|
|
622
|
+
return ok
|
|
@@ -93,6 +93,14 @@ class BatchRecord:
|
|
|
93
93
|
solver_stats: Exact-backend diagnostics for the solve behind this
|
|
94
94
|
record, same keys as ``Counterfactual.solver_stats``; empty for
|
|
95
95
|
genetic/python solves (those engines report no per-row stats).
|
|
96
|
+
calibrator_fingerprint: The duck-typed ``fingerprint()`` of the
|
|
97
|
+
calibrated target's calibrator, when it exposes one; ``None``
|
|
98
|
+
for raw/probability targets or fingerprint-less calibrators.
|
|
99
|
+
Repeated on every record so each file line is self-contained.
|
|
100
|
+
score_calibrated: The calibrator's probability at ``x_cf`` for a
|
|
101
|
+
calibrated target whose calibrator exposes ``predict_proba``;
|
|
102
|
+
presentational only — the engine optimized and verified on the
|
|
103
|
+
resolved raw interval. ``None`` otherwise.
|
|
96
104
|
"""
|
|
97
105
|
|
|
98
106
|
id: object
|
|
@@ -110,6 +118,11 @@ class BatchRecord:
|
|
|
110
118
|
region: RecourseRegion | None = None # set by explain_batch(..., region=True)
|
|
111
119
|
proof: str = "heuristic" # mirrors Counterfactual.proof / Infeasible.proof
|
|
112
120
|
solver_stats: dict[str, object] = field(default_factory=dict) # exact-backend only
|
|
121
|
+
# Calibrated-target provenance and read-out (0.2.4). The fingerprint is one
|
|
122
|
+
# value repeated per record on purpose: every JSON line stays self-contained
|
|
123
|
+
# for a validator who receives only a slice of the file.
|
|
124
|
+
calibrator_fingerprint: str | None = None
|
|
125
|
+
score_calibrated: float | None = None # presentational; the engine used raw_interval
|
|
113
126
|
|
|
114
127
|
|
|
115
128
|
@dataclass(frozen=True)
|
|
@@ -192,6 +205,8 @@ class BatchResult:
|
|
|
192
205
|
"blocked_lever": record.blocked_lever,
|
|
193
206
|
"coalition": record.coalition,
|
|
194
207
|
"proof": record.proof,
|
|
208
|
+
"calibrator_fingerprint": record.calibrator_fingerprint,
|
|
209
|
+
"score_calibrated": record.score_calibrated,
|
|
195
210
|
"solver_stats": {
|
|
196
211
|
key: encode_floats(value)
|
|
197
212
|
for key, value in record.solver_stats.items()
|
|
@@ -279,6 +294,9 @@ class BatchResult:
|
|
|
279
294
|
proof=raw.get(
|
|
280
295
|
"proof", "heuristic" if raw["feasible"] else "search_exhausted"
|
|
281
296
|
),
|
|
297
|
+
# absent in files written before 0.2.4
|
|
298
|
+
calibrator_fingerprint=raw.get("calibrator_fingerprint"),
|
|
299
|
+
score_calibrated=raw.get("score_calibrated"),
|
|
282
300
|
solver_stats={
|
|
283
301
|
key: decode_floats(value)
|
|
284
302
|
for key, value in raw.get("solver_stats", {}).items()
|
|
@@ -561,6 +579,24 @@ def explain_batch(
|
|
|
561
579
|
if message is not None:
|
|
562
580
|
warnings.warn(message, TreecfWarning, stacklevel=2)
|
|
563
581
|
|
|
582
|
+
if target.space == "calibrated":
|
|
583
|
+
from dataclasses import replace as _replace
|
|
584
|
+
|
|
585
|
+
from treecf.api import _calibrated_readout
|
|
586
|
+
from treecf.audit import _duck_fingerprint
|
|
587
|
+
|
|
588
|
+
calibrator_fp = _duck_fingerprint(target.calibrator)
|
|
589
|
+
if calibrator_fp is not None:
|
|
590
|
+
records = [_replace(r, calibrator_fingerprint=calibrator_fp) for r in records]
|
|
591
|
+
# The Rust wave paths assemble Counterfactuals without going through
|
|
592
|
+
# Explainer._explain, so the read-out is filled here for every path.
|
|
593
|
+
records = [
|
|
594
|
+
_replace(r, score_calibrated=_calibrated_readout(target, r.score_raw))
|
|
595
|
+
if r.feasible and r.score_calibrated is None and r.score_raw is not None
|
|
596
|
+
else r
|
|
597
|
+
for r in records
|
|
598
|
+
]
|
|
599
|
+
|
|
564
600
|
return BatchResult(
|
|
565
601
|
feature_names=explainer.ir.feature_names,
|
|
566
602
|
diversity=diversity,
|
|
@@ -595,6 +631,7 @@ def _record_from(
|
|
|
595
631
|
n_changed=cf.n_changed,
|
|
596
632
|
score_raw=cf.score_raw,
|
|
597
633
|
score_prob=cf.score_prob,
|
|
634
|
+
score_calibrated=cf.score_calibrated,
|
|
598
635
|
seed=seed,
|
|
599
636
|
blocked_lever=blocked_lever,
|
|
600
637
|
coalition=coalition,
|
|
@@ -96,8 +96,40 @@ def _parse_hist_gradient_boosting(model: Any) -> EnsembleIR:
|
|
|
96
96
|
)
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
def _effective_le_threshold(t: float) -> float:
|
|
100
|
+
"""The float64 boundary of sklearn's float32 input cast, exactly.
|
|
101
|
+
|
|
102
|
+
sklearn ``tree_``-based ensembles route ``float32(x) <= float64(t)`` —
|
|
103
|
+
the input is cast to float32 (round-to-nearest-even) before the
|
|
104
|
+
comparison. The IR evaluates in float64, so the stored threshold must be
|
|
105
|
+
the largest float64 ``T`` with ``float32(T) <= t``; then ``x <= T``
|
|
106
|
+
reproduces the native routing for *every* float64 ``x``, including points
|
|
107
|
+
exactly on split boundaries — where a counterfactual search naturally
|
|
108
|
+
lands. (Verified by a 138k-probe property sweep and the unquantized
|
|
109
|
+
conformance tests.)
|
|
110
|
+
|
|
111
|
+
Construction: let ``f`` be the largest float32 with ``f <= t`` and ``s``
|
|
112
|
+
its float32 successor; every ``x`` below their float64 midpoint rounds to
|
|
113
|
+
``<= f``. The midpoint itself rounds half-to-even: it belongs to the left
|
|
114
|
+
side exactly when it rounds back to ``f``.
|
|
115
|
+
"""
|
|
116
|
+
f32 = np.float32(t)
|
|
117
|
+
if float(f32) > t:
|
|
118
|
+
f32 = np.nextafter(f32, np.float32(-np.inf))
|
|
119
|
+
succ = np.nextafter(f32, np.float32(np.inf))
|
|
120
|
+
mid = (float(f32) + float(succ)) / 2.0
|
|
121
|
+
if float(np.float32(mid)) == float(f32):
|
|
122
|
+
return mid
|
|
123
|
+
return float(np.nextafter(mid, -np.inf))
|
|
124
|
+
|
|
125
|
+
|
|
99
126
|
def _tree_from_arrays(tree: Any, scale: float, classifier: bool) -> Tree:
|
|
100
|
-
"""Convert a fitted ``sklearn.tree._tree.Tree`` to IR nodes (LE convention).
|
|
127
|
+
"""Convert a fitted ``sklearn.tree._tree.Tree`` to IR nodes (LE convention).
|
|
128
|
+
|
|
129
|
+
Thresholds are re-expressed on the float64 grid via
|
|
130
|
+
:func:`_effective_le_threshold` so the IR's float64 routing matches
|
|
131
|
+
sklearn's float32-cast routing bit-for-bit.
|
|
132
|
+
"""
|
|
101
133
|
left = tree.children_left
|
|
102
134
|
right = tree.children_right
|
|
103
135
|
feature = tree.feature
|
|
@@ -120,7 +152,7 @@ def _tree_from_arrays(tree: Any, scale: float, classifier: bool) -> Tree:
|
|
|
120
152
|
Node(
|
|
121
153
|
node_id=i,
|
|
122
154
|
feature=int(feature[i]),
|
|
123
|
-
threshold=float(threshold[i]),
|
|
155
|
+
threshold=_effective_le_threshold(float(threshold[i])),
|
|
124
156
|
op=SplitOp.LE,
|
|
125
157
|
missing_left=missing_left,
|
|
126
158
|
left=int(left[i]),
|
|
@@ -183,7 +215,7 @@ def parse_isolation_forest(model: Any) -> EnsembleIR:
|
|
|
183
215
|
Node(
|
|
184
216
|
node_id=i,
|
|
185
217
|
feature=int(tree.feature[i]),
|
|
186
|
-
threshold=float(tree.threshold[i]),
|
|
218
|
+
threshold=_effective_le_threshold(float(tree.threshold[i])),
|
|
187
219
|
op=SplitOp.LE,
|
|
188
220
|
missing_left=None,
|
|
189
221
|
left=int(tree.children_left[i]),
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|