Skip to content

API reference

Explainer and results

Counterfactual explainer for a tree-ensemble model.

Parses the model, compiles the constraints, and fits the distance normalizers once at construction, so repeated explain/explain_batch/ explain_coalitions calls reuse that work.

Parameters:

Name Type Description Default
model object

A native model object (XGBoost/LightGBM/CatBoost/sklearn ensemble), a JSON dump file path or dict, or an already-parsed EnsembleIR. See Models and the IR for which native types are supported.

required
background FloatArray | None

Sample used to fit the per-feature distance normalizers (sigma, one per feature). Required unless normalizers is given instead; ignored when it is.

None
constraints Sequence[Constraint]

Constraint objects (Freeze, Range, Monotone, Linear, Implies, OneHot, AllowMissing, or a string parsed by constraint()) compiled and validated immediately. Defaults to no constraints. See Constraints.

()
weights dict[str, float] | None

Per-feature multiplier on distance cost, {feature: weight}; a feature not listed defaults to 1.0. Use to make some levers relatively cheaper or more expensive than the normalized default.

None
normalizers FloatArray | dict[str, float] | None

Per-feature distance scale sigma, either an array aligned to the model's feature order or a {feature: sigma} dict. Pass this instead of background to reuse known scales (e.g. across several explainers on the same features).

None
value_policy dict[str, ValuePolicy] | None

Per-feature snapping rule, {feature: policy}, where a policy is "raw" (no snapping; the default for a feature not listed), "integer" (round to the nearest feasible integer), a Grid(step, anchor=0.0) (snap to a fixed lattice), or a callable float -> float. The exact backend treats a policy as a hard constraint on its candidates; the genetic backend snaps its winning row afterward and reverts the snap if it would break feasibility (Counterfactual.snapped records which happened) — see Certification.

None
plausibility Plausibility | None

Optional hard isolation-forest bound keeping every returned counterfactual inside the data manifold (see Plausibility.isolation_forest). Cannot be combined with AllowMissing, and explain/explain_batch/ explain_coalitions reject a factual containing NaN once it is set (isolation forests define no NaN routing) — see Plausibility.

None

Raises:

Type Description
TreecfError

If neither background nor normalizers is given, if normalizers omits a feature or resolves to a non-positive scale, if value_policy names an unknown feature or an unrecognized string policy, or if plausibility is given together with AllowMissing or a mismatched feature space.

ConstraintValidationError

If constraints contains a malformed or self-contradictory constraint.

Source code in src/treecf/api.py
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
class Explainer:
    """Counterfactual explainer for a tree-ensemble model.

    Parses the model, compiles the constraints, and fits the distance
    normalizers once at construction, so repeated ``explain``/``explain_batch``/
    ``explain_coalitions`` calls reuse that work.

    Args:
        model: A native model object (XGBoost/LightGBM/CatBoost/sklearn
            ensemble), a JSON dump file path or dict, or an already-parsed
            ``EnsembleIR``. See [Models and the IR](concepts/models.md) for
            which native types are supported.
        background: Sample used to fit the per-feature distance normalizers
            (``sigma``, one per feature). Required unless ``normalizers`` is
            given instead; ignored when it is.
        constraints: Constraint objects (``Freeze``, ``Range``, ``Monotone``,
            ``Linear``, ``Implies``, ``OneHot``, ``AllowMissing``, or a string
            parsed by ``constraint()``) compiled and validated immediately.
            Defaults to no constraints. See [Constraints](concepts/constraints.md).
        weights: Per-feature multiplier on distance cost, ``{feature: weight}``;
            a feature not listed defaults to ``1.0``. Use to make some levers
            relatively cheaper or more expensive than the normalized default.
        normalizers: Per-feature distance scale ``sigma``, either an array
            aligned to the model's feature order or a ``{feature: sigma}``
            dict. Pass this instead of ``background`` to reuse known scales
            (e.g. across several explainers on the same features).
        value_policy: Per-feature snapping rule, ``{feature: policy}``, where
            a policy is ``"raw"`` (no snapping; the default for a feature not
            listed), ``"integer"`` (round to the nearest feasible integer), a
            ``Grid(step, anchor=0.0)`` (snap to a fixed lattice), or a callable
            ``float -> float``. The exact backend treats a policy as a hard
            constraint on its candidates; the genetic backend snaps its
            winning row afterward and reverts the snap if it would break
            feasibility (``Counterfactual.snapped`` records which happened) —
            see [Certification](concepts/certification.md#value-policies-under-certification).
        plausibility: Optional hard isolation-forest bound keeping every
            returned counterfactual inside the data manifold (see
            ``Plausibility.isolation_forest``). Cannot be combined with
            ``AllowMissing``, and ``explain``/``explain_batch``/
            ``explain_coalitions`` reject a factual containing NaN once it is
            set (isolation forests define no NaN routing) — see
            [Plausibility](concepts/plausibility.md).

    Raises:
        TreecfError: If neither ``background`` nor ``normalizers`` is given, if
            ``normalizers`` omits a feature or resolves to a non-positive
            scale, if ``value_policy`` names an unknown feature or an
            unrecognized string policy, or if ``plausibility`` is given
            together with ``AllowMissing`` or a mismatched feature space.
        ConstraintValidationError: If ``constraints`` contains a malformed or
            self-contradictory constraint.
    """

    _rust_cache: dict[str, object]  # marshaled Rust objects, filled on first solve
    _prepared_trees: tuple[TreeArrays, ...]  # vectorized-verify arrays, created on first batch

    def __init__(
        self,
        model: object,
        background: FloatArray | None = None,
        constraints: Sequence[Constraint] = (),
        weights: dict[str, float] | None = None,
        normalizers: FloatArray | dict[str, float] | None = None,
        value_policy: dict[str, ValuePolicy] | None = None,
        plausibility: Plausibility | None = None,
    ) -> None:
        self.ir = model if isinstance(model, EnsembleIR) else parse_model(model)
        names = self.ir.feature_names
        self.compiled = compile_constraints(constraints, names)
        self.plausibility = plausibility
        if plausibility is not None:
            if plausibility.if_ir.n_features != self.ir.n_features:
                raise TreecfError("plausibility forest must share the model's feature space")
            if self.compiled.allow_missing:
                raise TreecfError(
                    "plausibility with AllowMissing is not supported "
                    "(isolation forests define no NaN routing)"
                )
        self.background = (
            None if background is None else np.asarray(background, dtype=np.float64)
        )
        self.sigma = _resolve_sigma(names, background, normalizers)
        self.weights = np.array([(weights or {}).get(name, 1.0) for name in names])
        self.value_policy = value_policy or {}
        self._rust_cache = {}
        for name, policy in self.value_policy.items():
            if name not in names:
                raise TreecfError(f"value_policy references unknown feature {name!r}")
            if isinstance(policy, str) and policy not in ("raw", "integer"):
                raise TreecfError(f"unknown value policy {policy!r} for {name!r}")

    def explain(
        self,
        x: FloatArray,
        target: Target,
        backend: str = "genetic",
        time_budget_s: float = 10.0,
        sparsity_weight: float = 0.0,
        seed: int | None = None,
        warm_start: bool | None = None,
        node_budget: int | None = None,
        gap: float | None = None,
        region: bool = False,
    ) -> Counterfactual | Infeasible | dict[str, object]:
        """Search for a counterfactual (or one per band for ``Target.bands``).

        ``x`` is the factual instance (one row, aligned to the model's feature
        order); ``target`` bounds the model output the counterfactual must
        reach. ``time_budget_s`` caps wall time per solve (per band, when
        ``target`` is a ``Target.bands`` ladder); ``math.inf`` is accepted
        and removes the time cut, letting an exact search run until it
        proves its answer (Ctrl-C still aborts promptly). ``sparsity_weight`` makes
        the search minimize ``distance + sparsity_weight * n_changed``
        instead of plain ``distance``, trading a cheaper plan that touches
        more features against a sparser one that costs more per feature; the
        returned ``Counterfactual.distance`` itself always excludes the
        sparsity term. ``0.0`` (the default) does not penalize sparsity at
        all. ``seed`` fixes the genetic search's (and, on the
        exact backend, the warm start's) randomness for reproducibility;
        ``None`` draws a fresh one each call.

        ``backend="genetic"`` runs the bundled Rust engine (default);
        ``backend="python"`` runs the reference numpy implementation of the
        same algorithm; ``backend="exact"`` runs a branch-and-bound search
        over the same candidate grid that proves optimality when it finds a
        counterfactual and proves infeasibility when it does not, at the cost
        of a potentially longer solve. Every result is float-verified before
        being returned.

        ``warm_start`` (default ``True``), ``node_budget`` (default
        ``2_000_000``), and ``gap`` (default ``0.0``) configure the exact
        backend only; passing a non-default value together with another
        backend raises ``ValueError`` — deliberately not the usual
        ``TreecfError``, since this rejects a Python-level argument
        combination rather than a modeling error. ``warm_start=True`` runs a
        short genetic pass first (about a quarter of ``time_budget_s``,
        capped at 2 seconds) and, if it lands a verified counterfactual, feeds
        it to the exact search as a starting incumbent; the exact search
        still gets the full ``time_budget_s`` afterwards, so warm start is
        additive rather than deducted from the budget. ``gap`` lets the exact
        search settle for a counterfactual within that relative fraction of
        the true optimum, reported through ``proof="optimal_within_gap"``.

        An exact search can return a feasible row with ``proof="heuristic"``
        without exhausting ``node_budget`` or ``time_budget_s``: conservative
        repair of some constraint shapes can withdraw the optimality
        certificate honestly rather than claim a cheapest row it did not
        prove — the row itself is still real and verified, only the
        "cheapest possible" claim is dropped. Whenever the exact search
        returns any result with ``solver_stats["completed"] is False`` — for
        that reason or because the budget genuinely ran out — a
        ``TreecfWarning`` names which of the two happened, never the
        other one. ``Target.bands``, ``explain_coalitions``, and
        ``explain_batch`` collapse this into one aggregate warning per call
        instead of one per solve.

        If the factual itself violates a constraint, a ``TreecfWarning``
        is emitted: the returned plan will include changes made solely to
        satisfy the constraint set.

        ``region=True`` widens every successful ``Counterfactual`` into a
        certified ``RecourseRegion`` (``cf.region``) —
        works with any backend, genetic included. Costs one oracle call per
        attempted per-feature, per-direction expansion; see
        ``Explainer.recourse_region``.

        Returns:
            A single ``Counterfactual`` or ``Infeasible`` when ``target`` is a
            plain interval (``Target.raw``/``probability``/``calibrated``); a
            ``{band_name: Counterfactual | Infeasible}`` dict, one entry per
            band in solved order, when ``target`` is a ``Target.bands``
            ladder. ``Infeasible`` means the search found no verified
            counterfactual — see ``Infeasible.proof`` for whether that is a
            certified impossibility or just an unsuccessful search.

        Raises:
            ValueError: If ``warm_start``, ``node_budget``, or ``gap`` is
                given a non-default value together with a ``backend`` other
                than ``"exact"``.
            TreecfError: If ``backend`` is not one of ``"genetic"``,
                ``"python"``, or ``"exact"``, or if ``plausibility`` is
                configured on this explainer and ``x`` contains NaN.
            ConstraintValidationError: If ``backend="exact"`` and this
                explainer's constraints include an unsupported multi-feature
                ``Linear`` shape, or a callable ``value_policy``; the message
                names ``backend="genetic"`` as the fallback.
        """
        return self._explain(
            x, target, backend, time_budget_s, sparsity_weight, seed, warn_factual=True,
            warm_start=warm_start, node_budget=node_budget, gap=gap, region=region,
        )

    def _explain(
        self,
        x: FloatArray,
        target: Target,
        backend: str,
        time_budget_s: float,
        sparsity_weight: float,
        seed: int | None,
        *,
        warn_factual: bool,
        warm_start: bool | None = None,
        node_budget: int | None = None,
        gap: float | None = None,
        region: bool = False,
        degraded: list[_Degradation] | None = None,
        incumbent: tuple[float, FloatArray] | None = None,
    ) -> Counterfactual | Infeasible | dict[str, object]:
        """``explain`` body; ``explain_batch`` calls it with ``warn_factual=False``
        after emitting its own aggregate warning. ``degraded`` collects exact-backend
        degradations for an external caller's own aggregate instead of warning
        immediately; ignored (a fresh local collector is used instead) when
        ``target.bands_spec`` is set, since batch/coalitions never pass bands through.
        ``incumbent`` forwards to ``_explain_exact`` for the single-interval case only
        (``target.bands_spec`` rows never receive one — batch, the only caller that
        passes one, already rejects bands)."""
        x = np.asarray(x, dtype=np.float64)
        if warn_factual:
            violations = self.compiled.factual_violations(x)
            if violations:
                warnings.warn(
                    f"factual violates {len(violations)} constraint(s): "
                    + "; ".join(violations)
                    + ". The returned plan will include changes made solely to satisfy them.",
                    TreecfWarning,
                    stacklevel=3,  # _explain <- explain <- user code
                )
        if self.plausibility is not None and np.isnan(x).any():
            raise TreecfError("plausibility with missing factual values is not supported")
        if backend not in ("genetic", "genetic-rust", "python", "exact"):
            raise TreecfError(f"unknown backend {backend!r}; use 'genetic', 'python', or 'exact'")
        resolved_warm_start, resolved_node_budget, resolved_gap = _resolve_exact_kwargs(
            backend, warm_start, node_budget, gap
        )
        rust = backend in ("genetic", "genetic-rust")

        if target.bands_spec is not None:
            results: dict[str, object] = {}
            band_degraded: list[_Degradation] = []
            intervals = target.band_intervals(self.ir.link)
            for name, interval in intervals.items():
                outcome = (
                    self._explain_exact(
                        x, interval, time_budget_s, resolved_warm_start,
                        resolved_node_budget, resolved_gap, sparsity_weight, seed,
                        degraded=band_degraded,
                    )
                    if backend == "exact"
                    else self._explain_genetic(
                        x, interval, time_budget_s, sparsity_weight, seed, rust=rust
                    )
                )
                if region and isinstance(outcome, Counterfactual):
                    outcome = replace(outcome, region=self._region_for(x, outcome.x_cf, interval))
                if isinstance(outcome, Counterfactual) and target.space == "calibrated":
                    outcome = replace(
                        outcome, score_calibrated=_calibrated_readout(target, outcome.score_raw)
                    )
                results[name] = outcome
            message = _degraded_summary(band_degraded, len(band_degraded), len(intervals), "bands")
            if message is not None:
                warnings.warn(
                    message, TreecfWarning, stacklevel=3  # _explain <- explain <- user code
                )
            return results
        interval = target.raw_interval(self.ir.link)
        result = (
            self._explain_exact(
                x, interval, time_budget_s, resolved_warm_start,
                resolved_node_budget, resolved_gap, sparsity_weight, seed,
                incumbent=incumbent, degraded=degraded,
            )
            if backend == "exact"
            else self._explain_genetic(
                x, interval, time_budget_s, sparsity_weight, seed, rust=rust
            )
        )
        if region and isinstance(result, Counterfactual):
            result = replace(result, region=self._region_for(x, result.x_cf, interval))
        if isinstance(result, Counterfactual) and target.space == "calibrated":
            result = replace(
                result, score_calibrated=_calibrated_readout(target, result.score_raw)
            )
        return result

    def explain_batch(
        self,
        X: FloatArray,
        target: Target,
        n_per_example: int = 1,
        diversity: str = "seeds",
        ids: Sequence[object] | None = None,
        backend: str = "genetic",
        time_budget_s: float = 10.0,
        sparsity_weight: float = 0.0,
        seed: int = 0,
        coalitions: Mapping[str, Sequence[str]] | None = None,
        include_full: bool = False,
        warm_start: bool | None = None,
        node_budget: int | None = None,
        gap: float | None = None,
        region: bool = False,
        allow_exact_batch: bool = False,
    ) -> Any:
        """Mass-produce counterfactuals for a dataset; see ``treecf.batch``.

        ``X`` is the factual dataset (one row per instance, aligned to the
        model's feature order); ``ids`` labels each row (defaults to its
        integer index) and must have one entry per row of ``X``.
        ``n_per_example`` alternatives per row via ``diversity="seeds"`` (distinct
        change-sets from different seeds, best-effort) or ``"lever-blocking"``
        (freeze each plan's biggest lever; also records essential levers).
        ``diversity="coalitions"`` instead produces one plan per named feature
        group in ``coalitions`` per row (``n_per_example`` unused; see
        ``explain_coalitions`` for ``coalitions``/``include_full`` semantics,
        which are only valid in this mode). The returned ``BatchResult`` supports
        save/load/for_id/to_frame. ``time_budget_s``, ``sparsity_weight``, and
        ``seed`` carry the same meaning as in ``Explainer.explain``, applied
        per solve (``seed`` is combined with each row's index to derive a
        distinct per-row seed).

        Solves run in parallel inside the Rust engine; ``time_budget_s`` is
        per solve, so a solve that hits its wall-clock budget while sharing
        cores may stop earlier than it would sequentially (results are
        otherwise identical to solving row by row).

        ``backend="exact"`` has no vectorized population to parallelize, so
        this loops the single-instance exact solve per row (and per plan, for
        lever-blocking) sequentially — expect roughly linear-in-rows wall
        time rather than the Rust engine's parallel wave scheduling, and each
        row still gets the full, undiminished ``time_budget_s``. Because that
        wall time is easy to underestimate, ``backend="exact"`` here is
        opt-in: without ``allow_exact_batch=True`` this raises ``ValueError``
        naming an estimate (rows × plans × ``time_budget_s``, hours-formatted)
        instead of silently running -- a floor, not a ceiling, since
        ``diversity="seeds"`` can retry each plan up to 3x on a seed
        collision; passing it through with any other backend also raises
        ``ValueError``. Opting in additionally
        replaces ``warm_start``'s N sequential per-row (or, in seeds mode,
        per-attempt) genetic warm passes with a single vectorized one across
        every row — see ``treecf.batch.explain_batch`` for exactly which
        modes it covers and which keep 0.2.0's per-solve behavior.
        ``node_budget``/``gap`` thread through to every solve unchanged; see
        ``Explainer.explain``. A ``KeyboardInterrupt`` during any batch solve
        discards whatever the batch has not yet finished — there is no
        partial ``BatchResult``. ``region=True`` attaches a certified
        ``RecourseRegion`` (``BatchRecord.region``) to every feasible record,
        at the same one-oracle-call-per-expansion cost.

        Returns:
            A ``BatchResult`` holding one ``BatchRecord`` per (row, plan) pair
            — infeasible rows/plans get a record with ``feasible=False`` and
            no ``x_cf`` rather than being omitted.

        Raises:
            TreecfError: If ``target`` is a ``Target.bands`` ladder, if
                ``diversity`` is not ``"seeds"``, ``"lever-blocking"``, or
                ``"coalitions"``, if ``coalitions``/``include_full`` is given
                outside ``diversity="coalitions"`` (or omitted inside it), or
                if ``ids`` does not have one entry per row of ``X``.
            ValueError: If ``warm_start``, ``node_budget``, or ``gap`` is
                given a non-default value together with a ``backend`` other
                than ``"exact"``; if ``backend="exact"`` is requested without
                ``allow_exact_batch=True`` (message names the wall time
                estimate, a floor rather than a ceiling); or if
                ``allow_exact_batch=True`` is passed with a ``backend`` other
                than ``"exact"``.
        """
        from treecf.batch import explain_batch

        return explain_batch(
            self, X, target, n_per_example=n_per_example, diversity=diversity,
            ids=ids, backend=backend, time_budget_s=time_budget_s,
            sparsity_weight=sparsity_weight, seed=seed,
            coalitions=coalitions, include_full=include_full,
            warm_start=warm_start, node_budget=node_budget, gap=gap, region=region,
            allow_exact_batch=allow_exact_batch,
        )

    def explain_coalitions(
        self,
        x: FloatArray,
        target: Target,
        coalitions: Mapping[str, Sequence[str]],
        include_full: bool = False,
        backend: str = "genetic",
        time_budget_s: float = 10.0,
        sparsity_weight: float = 0.0,
        seed: int | None = None,
        warm_start: bool | None = None,
        node_budget: int | None = None,
        gap: float | None = None,
        region: bool = False,
    ) -> dict[str, Counterfactual | Infeasible]:
        """One counterfactual per named feature coalition (opt-in mode).

        ``x``/``target``/``backend``/``time_budget_s``/``sparsity_weight``/
        ``seed`` carry the same meaning as in ``Explainer.explain``, applied
        once per coalition. ``coalitions`` maps a group name to the features
        it may change; each coalition is solved with every feature *outside*
        it frozen, so a plan only ever asks for changes within one group —
        grouped recourse instead of one plan that mixes unrelated levers.
        Coalitions may overlap; features in no coalition are never modified;
        an ``Infeasible`` for a coalition means that group alone cannot reach
        the target. ``include_full=True`` prepends an unrestricted baseline
        under the reserved key ``"(all levers)"``. One solve per coalition
        (milliseconds each); this mode is optional and never the default.
        ``warm_start``/``node_budget``/``gap``/``region`` thread through to every
        coalition's solve; see ``Explainer.explain``. A degraded exact result
        (``solver_stats["completed"] is False``) in any coalition's solve is
        collapsed into one aggregate ``TreecfWarning`` for the whole call,
        rather than one per coalition.

        Returns:
            ``{coalition_name: Counterfactual | Infeasible}``, one entry per
            key of ``coalitions`` plus ``"(all levers)"`` when
            ``include_full=True``, in that insertion order.

        Raises:
            TreecfError: If ``target`` is a ``Target.bands`` ladder, if
                ``coalitions`` is empty, names a coalition with no members, or
                references an unknown feature, or if ``include_full=True`` and
                a coalition is named ``"(all levers)"`` (the reserved key).
            ValueError: If ``warm_start``, ``node_budget``, or ``gap`` is
                given a non-default value together with a ``backend`` other
                than ``"exact"``.
        """
        if target.bands_spec is not None:
            raise TreecfError(
                "Target.bands is not supported in explain_coalitions; loop bands explicitly"
            )
        normalized = _validate_coalitions(coalitions, self.ir.feature_names, include_full)
        results: dict[str, Counterfactual | Infeasible] = {}
        degraded: list[_Degradation] = []
        if include_full:
            results[_ALL_LEVERS] = self._explain_one(
                x, target, backend, time_budget_s, sparsity_weight, seed,
                warm_start=warm_start, node_budget=node_budget, gap=gap, region=region,
                degraded=degraded,
            )
        for name, clone in self._coalition_explainers(normalized).items():
            results[name] = clone._explain_one(
                x, target, backend, time_budget_s, sparsity_weight, seed,
                warm_start=warm_start, node_budget=node_budget, gap=gap, region=region,
                degraded=degraded,
            )
        message = _degraded_summary(degraded, len(degraded), len(results), "coalitions")
        if message is not None:
            warnings.warn(message, TreecfWarning, stacklevel=2)  # explain_coalitions <- user code
        return results

    def _explain_one(
        self,
        x: FloatArray,
        target: Target,
        backend: str,
        time_budget_s: float,
        sparsity_weight: float,
        seed: int | None,
        warn_factual: bool = True,
        warm_start: bool | None = None,
        node_budget: int | None = None,
        gap: float | None = None,
        region: bool = False,
        degraded: list[_Degradation] | None = None,
        incumbent: tuple[float, FloatArray] | None = None,
    ) -> Counterfactual | Infeasible:
        """`explain` for a single-interval target, with the bands arm ruled out."""
        result = self._explain(
            x, target, backend, time_budget_s, sparsity_weight, seed,
            warn_factual=warn_factual,
            warm_start=warm_start, node_budget=node_budget, gap=gap, region=region,
            degraded=degraded, incumbent=incumbent,
        )
        assert not isinstance(result, dict)  # bands are rejected by the callers
        return result

    def _coalition_explainers(
        self, coalitions: dict[str, tuple[str, ...]]
    ) -> dict[str, Explainer]:
        """One freeze-complement clone per coalition (Rust ensemble shared)."""
        names = self.ir.feature_names
        return {
            name: self._with_extra_freezes([f for f in names if f not in set(members)])
            for name, members in coalitions.items()
        }

    def _with_extra_freezes(self, features: Sequence[str]) -> Explainer:
        """Clone with additional Freeze constraints (lever-blocking, coalitions).

        ``AllowMissing`` on a newly frozen feature is dropped: a frozen value
        cannot transition to NaN, and keeping both would (correctly) fail
        constraint validation.
        """
        from treecf.constraints.objects import AllowMissing, Freeze

        frozen = set(features)
        kept = [
            c
            for c in self.compiled.constraints
            if not (isinstance(c, AllowMissing) and c.feature in frozen)
        ]
        clone = Explainer(
            self.ir,
            background=self.background,
            constraints=kept + [Freeze(f) for f in features],
            weights=dict(zip(self.ir.feature_names, self.weights.tolist(), strict=True)),
            normalizers=self.sigma,
            value_policy=self.value_policy,
            plausibility=self.plausibility,
        )
        # Same frozen IR -> the marshaled Rust ensembles are reusable; only the
        # constraints differ, so that cache entry is deliberately left out.
        clone._rust_cache = {
            key: self._rust_cache[key]
            for key in ("ensemble", "if_ensemble", "missing_defined", "if_missing_defined")
            if key in self._rust_cache
        }
        return clone

    def _explain_genetic(
        self,
        x: FloatArray,
        interval: tuple[float, float],
        time_budget_s: float,
        sparsity_weight: float,
        seed: int | None,
        rust: bool = True,
    ) -> Counterfactual | Infeasible:
        if rust:
            from treecf.backends.genetic_rust import solve_genetic_rust

            result = solve_genetic_rust(
                self.ir,
                x,
                interval,
                self.compiled,
                self.sigma,
                self.weights,
                lam=sparsity_weight,
                background=self.background,
                plausibility=self._plausibility_bound(),
                seed=seed,
                time_budget_s=time_budget_s,
                cache=self._rust_cache,
            )
        else:
            from treecf.backends.genetic import solve_genetic

            result = solve_genetic(
                self.ir,
                x,
                interval,
                self.compiled,
                self.sigma,
                self.weights,
                lam=sparsity_weight,
                background=self.background,
                plausibility=self._plausibility_bound(),
                seed=seed,
                time_budget_s=time_budget_s,
            )
        if result.x_cf is None:
            return Infeasible(
                reason="heuristic search exhausted (genetic backend)",
                proof="search_exhausted",
            )
        return self._finalize_candidate(x, result.x_cf, interval, result.stats)

    def _finalize_candidate(
        self,
        x: FloatArray,
        x_cf: FloatArray,
        interval: tuple[float, float],
        stats: dict[str, object],
        score: float | None = None,
    ) -> Counterfactual | Infeasible:
        """Verify, snap, and package one solver candidate.

        ``score`` is an optional precomputed ``raw_score(self.ir, x_cf)``
        (e.g. from a vectorized batch evaluation); it is recomputed whenever
        value policies modify the candidate.
        """
        if score is None:
            score = raw_score(self.ir, x_cf)
        verification = self._verify(x, x_cf, interval, score=score)
        if verification is not None:  # defensive: the GA only returns checked individuals
            return Infeasible(
                reason=f"heuristic solution failed verification: {verification}",
                proof="search_exhausted",
            )
        x_cf = self._prune_changes(x, x_cf, interval)
        final_cf, snapped = self._apply_value_policies(x, x_cf, interval)
        score = raw_score(self.ir, final_cf)
        return self._result(x, final_cf, "heuristic", stats, snapped, score=score)

    def _explain_exact(
        self,
        x: FloatArray,
        interval: tuple[float, float],
        time_budget_s: float,
        warm_start: bool,
        node_budget: int,
        gap: float,
        sparsity_weight: float,
        seed: int | None,
        incumbent: tuple[float, FloatArray] | None = None,
        degraded: list[_Degradation] | None = None,
    ) -> Counterfactual | Infeasible:
        """Exact-backend counterfactual for one target interval.

        ``warm_start`` runs a short genetic pass first, exactly as
        ``_explain_genetic`` does (Rust engine, same seed), with
        ``time_budget_s`` cut to ``min(time_budget_s * 0.25, 2.0)``. A
        verified counterfactual from that pass is re-costed on the exact
        backend's own objective and handed to ``solve_exact`` as an
        incumbent — the exact search still runs with the full, undiminished
        ``time_budget_s`` afterwards.

        ``incumbent``, when given, is used as that starting incumbent
        directly and this method's own internal warm pass never runs (the
        pass only runs when ``incumbent is None and warm_start``) — used by
        ``explain_batch``'s opt-in exact path, which computes one incumbent
        per row in a single vectorized genetic pass instead of every row (or,
        in seeds mode, every attempt) running its own.

        The search itself dispatches rust-first: when the `_treecf_core`
        extension is importable, ``exact_rust.solve_exact_rust`` runs instead
        of the pure-Python ``solve_exact``. The rust engine is a bit-parity
        mirror, not a heuristic stand-in — every fixture in
        ``tests/fixtures/exact/`` proves the two produce a RESULT-IDENTICAL
        answer (same ``x_cf`` or both ``None``, same ``distance``, ``proof``,
        and all seven ``stats`` keys), so the fallback only ever changes
        which engine ran, never what it found.

        A ``ConstraintValidationError`` from the exact backend's constraint
        validation (an unsupported multi-feature ``Linear`` shape, or a
        callable ``value_policy``) propagates unchanged; it already names
        ``backend="genetic"`` as the fallback.

        When the returned ``stats["completed"] is False``, ``degraded`` (when
        given) collects a ``_Degradation`` instead of warning here
        directly, for an external caller's own aggregate warning; ``None``
        (the default) warns immediately.
        """
        from treecf.backends._exact_domains import _cost_of_row
        from treecf.backends.exact_rust import _rust_available, solve_exact_rust

        if incumbent is None and warm_start:
            warm_budget = min(time_budget_s * 0.25, 2.0)
            warm = self._explain_genetic(
                x, interval, warm_budget, sparsity_weight, seed, rust=True
            )
            if isinstance(warm, Counterfactual) and self._verify(x, warm.x_cf, interval) is None:
                cost = _cost_of_row(
                    x, warm.x_cf, self.sigma, self.weights, sparsity_weight,
                    self.compiled.allow_missing,
                )
                incumbent = (cost, warm.x_cf)

        # Wraps only the solver call: any Python<->Rust marshaling overhead this
        # measurement picks up can only push `elapsed` up, which only ever
        # biases the exhaustion classifier below toward "exhausted" -- the
        # honest direction, never toward the stronger "conservative
        # withdrawal" reading it has not earned.
        start = time.monotonic()
        if _rust_available():
            res = solve_exact_rust(
                self.ir,
                x,
                interval,
                self.compiled,
                self.sigma,
                self.weights,
                sparsity_weight,
                value_policies=self.value_policy,
                plausibility=self._plausibility_bound(),
                node_budget=node_budget,
                gap=gap,
                time_budget_s=time_budget_s,
                incumbent=incumbent,
                cache=self._rust_cache,
            )
        else:
            from treecf.backends.exact import solve_exact

            res = solve_exact(
                self.ir,
                x,
                interval,
                self.compiled,
                self.sigma,
                self.weights,
                sparsity_weight,
                value_policies=self.value_policy,
                plausibility=self._plausibility_bound(),
                node_budget=node_budget,
                gap=gap,
                time_budget_s=time_budget_s,
                incumbent=incumbent,
            )
        elapsed = time.monotonic() - start

        if res.stats["completed"] is False:
            degradation = _degradation_for(res, node_budget, time_budget_s, elapsed, seed)
            if degraded is None:
                warnings.warn(
                    degradation.message + (_SEED_CLAUSE if degradation.unseeded else ""),
                    TreecfWarning,
                    stacklevel=4,  # _explain_exact <- _explain <- explain/_explain_one <- caller
                )
            else:
                degraded.append(degradation)

        if res.x_cf is None:
            # Certification is read from stats["completed"], never from
            # res.proof: proof carries no meaning on an infeasible result
            # (see solve_exact's docstring).
            if res.stats["completed"] is True:
                return Infeasible(
                    reason=(
                        "no counterfactual exists in the target interval under the "
                        f"given constraints (certified; {res.stats['nodes_expanded']} nodes)"
                    ),
                    proof="certified",
                    solver_stats=res.stats,
                )
            # completed=False does not always mean the budget ran out: a
            # conservative order-pair repair can withdraw the certificate
            # without spending the whole budget, so this reason names both
            # possibilities rather than claiming the budget was exhausted.
            return Infeasible(
                reason=(
                    "exact search ended without an infeasibility certificate "
                    "(budget exhausted or conservative pruning)"
                ),
                proof="search_exhausted",
                solver_stats=res.stats,
            )
        return self._finalize_exact(x, res, interval)

    def _finalize_exact(
        self,
        x: FloatArray,
        res: ExactResult,
        interval: tuple[float, float],
    ) -> Counterfactual | Infeasible:
        """Verify and package an exact-backend result.

        No ``_prune_changes``/``_apply_value_policies`` here: the exact
        search already reasons over the refined constraint geometry
        (``_constraint_cells``) and bakes value policies into its own
        domains, so post-hoc pruning or snapping would second-guess a
        solution the search already committed to.
        """
        assert res.x_cf is not None
        verification = self._verify(x, res.x_cf, interval)
        if verification is not None:  # defensive: the search only returns checked rows
            return Infeasible(
                reason=f"exact solution failed verification: {verification}",
                proof="search_exhausted",
                solver_stats=res.stats,
            )
        return self._result(x, res.x_cf, res.proof, res.stats, res.snapped)

    def _prune_changes(
        self, x: FloatArray, x_cf: FloatArray, interval: tuple[float, float]
    ) -> FloatArray:
        """Greedily revert changes that verification proves unnecessary.

        The search's revert-to-factual mutation is stochastic, so a stalled
        run can leave residual micro-changes that cross no decision threshold
        — they cost distance without moving the score. Reverting candidates
        one at a time (cheapest change first) can only lower the objective,
        and every kept revert is re-verified in float space, so the returned
        plan keeps all its guarantees.
        """
        allow = self.compiled.allow_missing

        def effort(j: int) -> float:
            source, dest = x[j], x_cf[j]
            if math.isnan(dest):
                delta = allow[j][0]
            elif math.isnan(source):
                delta = allow[j][1]
            else:
                delta = abs(dest - source)
            return float(self.weights[j] * delta / self.sigma[j])

        changed = [
            j
            for j in range(len(x))
            if (x[j] != x_cf[j]) and not (math.isnan(x[j]) and math.isnan(x_cf[j]))
        ]
        if len(changed) < 2:  # a single change is necessary by feasibility
            return x_cf
        candidate = x_cf.copy()
        for j in sorted(changed, key=effort):
            trial = candidate.copy()
            trial[j] = x[j]
            if self._verify(x, trial, interval) is None:
                candidate = trial
        return candidate

    def _prepared_tree_arrays(self) -> tuple[TreeArrays, ...]:
        if not hasattr(self, "_prepared_trees"):
            self._prepared_trees = prepare_tree_arrays(self.ir)
        return self._prepared_trees

    def _solve_batch(
        self,
        X: FloatArray,
        tasks: Sequence[tuple[int, int]],
        interval: tuple[float, float],
        time_budget_s: float,
        sparsity_weight: float,
    ) -> list[GeneticResult]:
        """Run independent seeded searches in one parallel Rust call."""
        from treecf.backends.genetic_rust import solve_genetic_batch_rust

        return solve_genetic_batch_rust(
            self.ir,
            X,
            tasks,
            interval,
            self.compiled,
            self.sigma,
            self.weights,
            lam=sparsity_weight,
            background=self.background,
            plausibility=self._plausibility_bound(),
            time_budget_s=time_budget_s,
            cache=self._rust_cache,
        )

    def _verify(
        self,
        x: FloatArray,
        x_cf: FloatArray,
        interval: tuple[float, float],
        score: float | None = None,
    ) -> str | None:
        """Float-space re-check of target and constraints. None = OK."""
        if score is None:
            score = raw_score(self.ir, x_cf)
        if not (interval[0] <= score <= interval[1]):
            return f"score {score} outside target {interval}"
        lo, hi, _frozen = self.compiled.instance_bounds(x)  # bounds anchor at the factual x
        lo = np.where(np.isnan(lo), -math.inf, lo)
        hi = np.where(np.isnan(hi), math.inf, hi)
        for j, value in enumerate(x_cf):
            if math.isnan(value):
                if not math.isnan(x[j]) and j not in self.compiled.allow_missing:
                    return f"feature {self.ir.feature_names[j]!r} became NaN without AllowMissing"
                continue
            if not (lo[j] <= value <= hi[j]):
                return f"feature {self.ir.feature_names[j]!r} violates its bounds"

        slack = 1e-9
        for lin in self.compiled.linears:
            values = [x_cf[j] for j in lin.indices]
            if any(math.isnan(v) for v in values):
                if lin.missing_policy == "satisfied":
                    continue
                return "Linear constraint references a missing value"
            total = sum(c * v for c, v in zip(lin.coefs, values, strict=True))
            ok = (
                total <= lin.rhs + slack
                if lin.op == "<="
                else total >= lin.rhs - slack
                if lin.op == ">="
                else abs(total - lin.rhs) <= slack
            )
            if not ok:
                return f"Linear constraint violated: {lin.coefficients} {lin.op} {lin.rhs}"
        for imp in self.compiled.implications:
            if x_cf[imp.cond_index] == imp.cond_value and x_cf[imp.cons_index] != imp.cons_value:
                return "Implies constraint violated"
        for group in self.compiled.onehot_groups:
            # exact float equality is intentional: repair writes literal 0.0/1.0,
            # and a tolerance would mask genuinely broken candidates
            if sum(x_cf[j] for j in group) != 1.0:
                return "OneHot constraint violated"
        if self.plausibility is not None:
            score_anomaly = self.plausibility.anomaly_score(x_cf)
            if score_anomaly > self.plausibility.max_anomaly_score + 1e-12:
                return f"anomaly score {score_anomaly:.4f} exceeds plausibility bound"
        return None

    def _plausibility_bound(self) -> tuple[EnsembleIR, float] | None:
        if self.plausibility is None:
            return None
        return self.plausibility.if_ir, self.plausibility.min_total_path

    def recourse_region(
        self, x: FloatArray, x_cf: FloatArray, target: Target
    ) -> RecourseRegion:
        """Certify a per-feature box around an already-verified counterfactual.

        ``x`` is the original factual and ``x_cf`` the counterfactual to widen
        (typically ``Counterfactual.x_cf`` from a prior ``explain`` call);
        ``target`` is the single interval ``x_cf`` was solved against. ``x_cf``
        must independently pass the same float-space re-check
        ``explain`` runs on its own results; a row that fails it raises
        ``TreecfError`` naming the reason, since there is nothing sound to
        widen. Works for a counterfactual from any backend. Costs one oracle
        call — a full interval-tree walk of every ensemble tree — per
        attempted per-feature, per-direction expansion; see
        ``RecourseRegion``. The returned region is certified
        but neither maximal nor monotone in ``target``: a strictly narrower
        target can still grow a strictly wider region on some feature. See
        [Certification](concepts/certification.md#regions-certified-not-maximal-not-monotone).

        Returns:
            The certified ``RecourseRegion`` around ``x_cf``.

        Raises:
            TreecfError: If ``target`` is a ``Target.bands`` ladder (pass the
                single band's own interval instead), or if ``x_cf`` fails the
                float-space re-check against ``x``/``target`` — the message
                names the specific check that failed.
        """
        x = np.asarray(x, dtype=np.float64)
        x_cf = np.asarray(x_cf, dtype=np.float64)
        if target.bands_spec is not None:
            raise TreecfError(
                "Target.bands is not supported in recourse_region; pass the single "
                "band's own interval via Target.raw/probability/calibrated"
            )
        interval = target.raw_interval(self.ir.link)
        try:
            verification = self._verify(x, x_cf, interval)
        except ValueError as exc:
            # _verify's own raw_score re-check raises ValueError when x_cf's
            # path hits a split with no missing routing defined (an unrouted
            # NaN) -- surfaced here as the TreecfError this method's docstring
            # promises, since _verify itself stays a float-space re-check that
            # never wraps its own scoring call.
            raise TreecfError(
                f"cannot certify a region for an unverified counterfactual: {exc}"
            ) from exc
        if verification is not None:
            raise TreecfError(
                f"cannot certify a region for an unverified counterfactual: {verification}"
            )
        return self._region_for(x, x_cf, interval)

    def certificate(
        self,
        x: FloatArray,
        result: Counterfactual | Infeasible,
        target: Target,
        *,
        band: str | None = None,
        seed: int | None = None,
        node_budget: int | None = None,
        gap: float | None = None,
        time_budget_s: float | None = None,
        warm_start: bool | None = None,
    ) -> dict[str, object]:
        """Issue an audit certificate for a stored result (post-hoc, like
        ``recourse_region``).

        A certificate is a reproducibility record plus a fresh verification —
        it binds the result to a model fingerprint, a constraint fingerprint,
        and the solve parameters, and re-verifies the returned plan at issue
        time; it does not cryptographically prove that a search ran or that a
        ``proof="optimal"`` claim is true — re-running with the recorded seed
        and budgets on a fingerprint-matching model is how a validator checks
        that. See
        [Certification — audit certificates](concepts/certification.md#audit-certificates)
        for the schema.

        The certificate is a plain ``dict`` (``"schema_version": 1``) that
        serializes with ``json.dumps(cert, allow_nan=False, sort_keys=True)``;
        non-finite floats are encoded as the strings ``"NaN"``/``"Infinity"``/
        ``"-Infinity"``. Accepts a ``Counterfactual`` or an ``Infeasible`` —
        the certified "no" is exactly the case a validator cares most about.
        The verification block is computed fresh here, never copied from the
        solve: the plan's score, target membership, and constraint check are
        recomputed (plus the plausibility bound when configured, and a sampled
        set of region points when the result carries a region). A certificate
        whose fresh verification fails is still returned, with the failing
        booleans recorded — but a ``TreecfWarning`` names the failed check.

        ``seed``/``node_budget``/``gap``/``time_budget_s``/``warm_start`` are
        recorded under ``solve.declared`` when given: the result object does
        not carry them, so they are caller-supplied, and the block's name
        makes that provenance explicit.

        Args:
            x: The factual instance the result was solved from.
            result: The ``Counterfactual`` or ``Infeasible`` to certify.
            target: The target the result was solved against.
            band: For a ``Target.bands`` result, the band this result belongs
                to; required then, invalid otherwise.
            seed: The seed the solve ran with, if the caller wants it recorded.
            node_budget: The node budget the solve ran with, likewise.
            gap: The relative gap the solve ran with, likewise.
            time_budget_s: The time budget the solve ran with, likewise.
            warm_start: The warm-start setting the solve ran with, likewise.

        Returns:
            The certificate as a strict-JSON-serializable ``dict``.

        Raises:
            TreecfError: If ``target`` is a ``Target.bands`` ladder and
                ``band`` is missing or unknown, or if ``band`` is given for a
                plain-interval target.
        """
        from treecf.audit import build_certificate

        return build_certificate(
            self, x, result, target, band=band, seed=seed, node_budget=node_budget,
            gap=gap, time_budget_s=time_budget_s, warm_start=warm_start,
        )

    def check_certificate(
        self, cert: dict[str, object], *, calibrator: object | None = None
    ) -> dict[str, object]:
        """Validate a stored certificate against *this* explainer.

        Recomputes both fingerprints (model and constraints) against this
        explainer and re-runs the certificate's verification block from its
        stored factual/plan, so a tampered ``x_cf``, a swapped model, or a
        changed constraint set each flips the corresponding boolean. This
        method reports — it never raises on a mismatch.

        Without ``calibrator=``, a calibrated-target certificate is still
        fully verifiable in *plan geometry*: the resolved ``raw_interval``
        is stored, so this proves the plan reaches the stored interval — it
        does not prove which calibrator produced that interval. Passing
        ``calibrator=`` adds exactly that: the duck-typed ``fingerprint()``
        is compared with the stored one, and the certificate's calibrated
        ``lo``/``hi`` are re-inverted through the supplied calibrator and
        compared with the stored interval (rtol 1e-9, infinities by
        identity). Neither mode requires treecf to import a calibration
        library.

        Args:
            cert: A certificate produced by ``Explainer.certificate`` (a
                ``json.loads`` round trip of one works identically).
            calibrator: Optional duck-typed calibrator (the object handed to
                ``Target.calibrated``) to additionally verify calibrator
                provenance against a calibrated-target certificate.

        Returns:
            ``{"model_match": bool, "constraints_match": bool,
            "verification_ok": bool, "mismatches": [...]}`` with one
            human-readable string per mismatch, plus ``"calibrator_match":
            bool`` when ``calibrator=`` was given.
        """
        from treecf.audit import check_certificate

        return check_certificate(self, cert, calibrator=calibrator)

    def _region_for(
        self, x: FloatArray, x_cf: FloatArray, interval: tuple[float, float]
    ) -> RecourseRegion:
        """Build the region for an already-verified ``x_cf`` (no re-verification)."""
        from treecf.regions import _recourse_region

        if_ir, min_total_path = (None, 0.0)
        plaus = self._plausibility_bound()
        if plaus is not None:
            if_ir, min_total_path = plaus
        return _recourse_region(
            self.ir, x, x_cf, interval, self.compiled, if_ir, min_total_path,
            cache=self._rust_cache,
        )

    def _apply_value_policies(
        self, x: FloatArray, x_cf: FloatArray, interval: tuple[float, float]
    ) -> tuple[FloatArray, dict[str, bool]]:
        """Snap changed values per policy inside their cells; never break validity.

        The unsnapped ``x_cf`` is already verified, so reverting offending features
        one by one is guaranteed to terminate in a valid state.
        """
        applicable = [
            (j, name, self.value_policy[name])
            for j, name in enumerate(self.ir.feature_names)
            if name in self.value_policy
            and self.value_policy[name] != "raw"
            and not math.isnan(x_cf[j])
            and x_cf[j] != x[j]
        ]
        if not applicable:
            return x_cf, {}

        # Genetic-path-only: this is post-hoc snapping onto the unrefined
        # routing grid. The exact backend never calls this function — its
        # geometry lives in `_constraint_cells` (refined for constraints) and
        # its value policies are already baked into `_build_domains`'
        # candidate states, so routing a winning exact row back through here
        # would snap it against the wrong grid.
        cells = feature_cells(self.ir)
        lo_b, hi_b, _ = self.compiled.instance_bounds(x)
        snapped: dict[str, bool] = {}
        candidate = x_cf.copy()
        for j, name, policy in applicable:
            cell = cells[j][cell_index(cells[j], x_cf[j])]
            value = _snap(x_cf[j], policy, cell.contains, float(lo_b[j]), float(hi_b[j]))
            if value is None:
                snapped[name] = False
            else:
                candidate[j] = value
                snapped[name] = True

        # Revert snapped features one at a time until the candidate verifies.
        order = [name for name in snapped if snapped[name]]
        while self._verify(x, candidate, interval) is not None and order:
            name = order.pop()
            j = self.ir.feature_names.index(name)
            candidate[j] = x_cf[j]
            snapped[name] = False
        if self._verify(x, candidate, interval) is not None:
            return x_cf, dict.fromkeys(snapped, False)
        return candidate, snapped

    def _result(
        self,
        x: FloatArray,
        x_cf: FloatArray,
        status: str,
        stats: dict[str, object],
        snapped: dict[str, bool] | None = None,
        score: float | None = None,
    ) -> Counterfactual:
        changes: dict[str, tuple[float, float]] = {}
        distance = 0.0
        for j, name in enumerate(self.ir.feature_names):
            x_nan, cf_nan = math.isnan(x[j]), math.isnan(x_cf[j])
            if (x[j] == x_cf[j]) or (x_nan and cf_nan):
                continue
            changes[name] = (float(x[j]), float(x_cf[j]))
            if cf_nan:  # value -> NaN priced by delta_miss
                delta = self.compiled.allow_missing[j][0]
            elif x_nan:  # NaN -> value priced by delta_from_miss
                delta = self.compiled.allow_missing[j][1]
            else:
                delta = abs(x_cf[j] - x[j])
            distance += self.weights[j] * delta / self.sigma[j]
        if score is None:
            score = raw_score(self.ir, x_cf)
        return Counterfactual(
            x_cf=x_cf,
            changes=changes,
            distance=float(distance),
            n_changed=len(changes),
            score_raw=score,
            score_prob=apply_link(Link.SIGMOID, score) if self.ir.link is Link.SIGMOID else None,
            proof=status,
            solver_stats=stats,
            snapped=snapped or {},
        )

certificate(x, result, target, *, band=None, seed=None, node_budget=None, gap=None, time_budget_s=None, warm_start=None)

Issue an audit certificate for a stored result (post-hoc, like recourse_region).

A certificate is a reproducibility record plus a fresh verification — it binds the result to a model fingerprint, a constraint fingerprint, and the solve parameters, and re-verifies the returned plan at issue time; it does not cryptographically prove that a search ran or that a proof="optimal" claim is true — re-running with the recorded seed and budgets on a fingerprint-matching model is how a validator checks that. See Certification — audit certificates for the schema.

The certificate is a plain dict ("schema_version": 1) that serializes with json.dumps(cert, allow_nan=False, sort_keys=True); non-finite floats are encoded as the strings "NaN"/"Infinity"/ "-Infinity". Accepts a Counterfactual or an Infeasible — the certified "no" is exactly the case a validator cares most about. The verification block is computed fresh here, never copied from the solve: the plan's score, target membership, and constraint check are recomputed (plus the plausibility bound when configured, and a sampled set of region points when the result carries a region). A certificate whose fresh verification fails is still returned, with the failing booleans recorded — but a TreecfWarning names the failed check.

seed/node_budget/gap/time_budget_s/warm_start are recorded under solve.declared when given: the result object does not carry them, so they are caller-supplied, and the block's name makes that provenance explicit.

Parameters:

Name Type Description Default
x FloatArray

The factual instance the result was solved from.

required
result Counterfactual | Infeasible

The Counterfactual or Infeasible to certify.

required
target Target

The target the result was solved against.

required
band str | None

For a Target.bands result, the band this result belongs to; required then, invalid otherwise.

None
seed int | None

The seed the solve ran with, if the caller wants it recorded.

None
node_budget int | None

The node budget the solve ran with, likewise.

None
gap float | None

The relative gap the solve ran with, likewise.

None
time_budget_s float | None

The time budget the solve ran with, likewise.

None
warm_start bool | None

The warm-start setting the solve ran with, likewise.

None

Returns:

Type Description
dict[str, object]

The certificate as a strict-JSON-serializable dict.

Raises:

Type Description
TreecfError

If target is a Target.bands ladder and band is missing or unknown, or if band is given for a plain-interval target.

Source code in src/treecf/api.py
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
def certificate(
    self,
    x: FloatArray,
    result: Counterfactual | Infeasible,
    target: Target,
    *,
    band: str | None = None,
    seed: int | None = None,
    node_budget: int | None = None,
    gap: float | None = None,
    time_budget_s: float | None = None,
    warm_start: bool | None = None,
) -> dict[str, object]:
    """Issue an audit certificate for a stored result (post-hoc, like
    ``recourse_region``).

    A certificate is a reproducibility record plus a fresh verification —
    it binds the result to a model fingerprint, a constraint fingerprint,
    and the solve parameters, and re-verifies the returned plan at issue
    time; it does not cryptographically prove that a search ran or that a
    ``proof="optimal"`` claim is true — re-running with the recorded seed
    and budgets on a fingerprint-matching model is how a validator checks
    that. See
    [Certification — audit certificates](concepts/certification.md#audit-certificates)
    for the schema.

    The certificate is a plain ``dict`` (``"schema_version": 1``) that
    serializes with ``json.dumps(cert, allow_nan=False, sort_keys=True)``;
    non-finite floats are encoded as the strings ``"NaN"``/``"Infinity"``/
    ``"-Infinity"``. Accepts a ``Counterfactual`` or an ``Infeasible`` —
    the certified "no" is exactly the case a validator cares most about.
    The verification block is computed fresh here, never copied from the
    solve: the plan's score, target membership, and constraint check are
    recomputed (plus the plausibility bound when configured, and a sampled
    set of region points when the result carries a region). A certificate
    whose fresh verification fails is still returned, with the failing
    booleans recorded — but a ``TreecfWarning`` names the failed check.

    ``seed``/``node_budget``/``gap``/``time_budget_s``/``warm_start`` are
    recorded under ``solve.declared`` when given: the result object does
    not carry them, so they are caller-supplied, and the block's name
    makes that provenance explicit.

    Args:
        x: The factual instance the result was solved from.
        result: The ``Counterfactual`` or ``Infeasible`` to certify.
        target: The target the result was solved against.
        band: For a ``Target.bands`` result, the band this result belongs
            to; required then, invalid otherwise.
        seed: The seed the solve ran with, if the caller wants it recorded.
        node_budget: The node budget the solve ran with, likewise.
        gap: The relative gap the solve ran with, likewise.
        time_budget_s: The time budget the solve ran with, likewise.
        warm_start: The warm-start setting the solve ran with, likewise.

    Returns:
        The certificate as a strict-JSON-serializable ``dict``.

    Raises:
        TreecfError: If ``target`` is a ``Target.bands`` ladder and
            ``band`` is missing or unknown, or if ``band`` is given for a
            plain-interval target.
    """
    from treecf.audit import build_certificate

    return build_certificate(
        self, x, result, target, band=band, seed=seed, node_budget=node_budget,
        gap=gap, time_budget_s=time_budget_s, warm_start=warm_start,
    )

check_certificate(cert, *, calibrator=None)

Validate a stored certificate against this explainer.

Recomputes both fingerprints (model and constraints) against this explainer and re-runs the certificate's verification block from its stored factual/plan, so a tampered x_cf, a swapped model, or a changed constraint set each flips the corresponding boolean. This method reports — it never raises on a mismatch.

Without calibrator=, a calibrated-target certificate is still fully verifiable in plan geometry: the resolved raw_interval is stored, so this proves the plan reaches the stored interval — it does not prove which calibrator produced that interval. Passing calibrator= adds exactly that: the duck-typed fingerprint() is compared with the stored one, and the certificate's calibrated lo/hi are re-inverted through the supplied calibrator and compared with the stored interval (rtol 1e-9, infinities by identity). Neither mode requires treecf to import a calibration library.

Parameters:

Name Type Description Default
cert dict[str, object]

A certificate produced by Explainer.certificate (a json.loads round trip of one works identically).

required
calibrator object | None

Optional duck-typed calibrator (the object handed to Target.calibrated) to additionally verify calibrator provenance against a calibrated-target certificate.

None

Returns:

Type Description
dict[str, object]

``{"model_match": bool, "constraints_match": bool,

dict[str, object]

"verification_ok": bool, "mismatches": [...]}`` with one

dict[str, object]

human-readable string per mismatch, plus ``"calibrator_match":

dict[str, object]

boolwhencalibrator=`` was given.

Source code in src/treecf/api.py
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
def check_certificate(
    self, cert: dict[str, object], *, calibrator: object | None = None
) -> dict[str, object]:
    """Validate a stored certificate against *this* explainer.

    Recomputes both fingerprints (model and constraints) against this
    explainer and re-runs the certificate's verification block from its
    stored factual/plan, so a tampered ``x_cf``, a swapped model, or a
    changed constraint set each flips the corresponding boolean. This
    method reports — it never raises on a mismatch.

    Without ``calibrator=``, a calibrated-target certificate is still
    fully verifiable in *plan geometry*: the resolved ``raw_interval``
    is stored, so this proves the plan reaches the stored interval — it
    does not prove which calibrator produced that interval. Passing
    ``calibrator=`` adds exactly that: the duck-typed ``fingerprint()``
    is compared with the stored one, and the certificate's calibrated
    ``lo``/``hi`` are re-inverted through the supplied calibrator and
    compared with the stored interval (rtol 1e-9, infinities by
    identity). Neither mode requires treecf to import a calibration
    library.

    Args:
        cert: A certificate produced by ``Explainer.certificate`` (a
            ``json.loads`` round trip of one works identically).
        calibrator: Optional duck-typed calibrator (the object handed to
            ``Target.calibrated``) to additionally verify calibrator
            provenance against a calibrated-target certificate.

    Returns:
        ``{"model_match": bool, "constraints_match": bool,
        "verification_ok": bool, "mismatches": [...]}`` with one
        human-readable string per mismatch, plus ``"calibrator_match":
        bool`` when ``calibrator=`` was given.
    """
    from treecf.audit import check_certificate

    return check_certificate(self, cert, calibrator=calibrator)

explain(x, target, backend='genetic', time_budget_s=10.0, sparsity_weight=0.0, seed=None, warm_start=None, node_budget=None, gap=None, region=False)

Search for a counterfactual (or one per band for Target.bands).

x is the factual instance (one row, aligned to the model's feature order); target bounds the model output the counterfactual must reach. time_budget_s caps wall time per solve (per band, when target is a Target.bands ladder); math.inf is accepted and removes the time cut, letting an exact search run until it proves its answer (Ctrl-C still aborts promptly). sparsity_weight makes the search minimize distance + sparsity_weight * n_changed instead of plain distance, trading a cheaper plan that touches more features against a sparser one that costs more per feature; the returned Counterfactual.distance itself always excludes the sparsity term. 0.0 (the default) does not penalize sparsity at all. seed fixes the genetic search's (and, on the exact backend, the warm start's) randomness for reproducibility; None draws a fresh one each call.

backend="genetic" runs the bundled Rust engine (default); backend="python" runs the reference numpy implementation of the same algorithm; backend="exact" runs a branch-and-bound search over the same candidate grid that proves optimality when it finds a counterfactual and proves infeasibility when it does not, at the cost of a potentially longer solve. Every result is float-verified before being returned.

warm_start (default True), node_budget (default 2_000_000), and gap (default 0.0) configure the exact backend only; passing a non-default value together with another backend raises ValueError — deliberately not the usual TreecfError, since this rejects a Python-level argument combination rather than a modeling error. warm_start=True runs a short genetic pass first (about a quarter of time_budget_s, capped at 2 seconds) and, if it lands a verified counterfactual, feeds it to the exact search as a starting incumbent; the exact search still gets the full time_budget_s afterwards, so warm start is additive rather than deducted from the budget. gap lets the exact search settle for a counterfactual within that relative fraction of the true optimum, reported through proof="optimal_within_gap".

An exact search can return a feasible row with proof="heuristic" without exhausting node_budget or time_budget_s: conservative repair of some constraint shapes can withdraw the optimality certificate honestly rather than claim a cheapest row it did not prove — the row itself is still real and verified, only the "cheapest possible" claim is dropped. Whenever the exact search returns any result with solver_stats["completed"] is False — for that reason or because the budget genuinely ran out — a TreecfWarning names which of the two happened, never the other one. Target.bands, explain_coalitions, and explain_batch collapse this into one aggregate warning per call instead of one per solve.

If the factual itself violates a constraint, a TreecfWarning is emitted: the returned plan will include changes made solely to satisfy the constraint set.

region=True widens every successful Counterfactual into a certified RecourseRegion (cf.region) — works with any backend, genetic included. Costs one oracle call per attempted per-feature, per-direction expansion; see Explainer.recourse_region.

Returns:

Type Description
Counterfactual | Infeasible | dict[str, object]

A single Counterfactual or Infeasible when target is a

Counterfactual | Infeasible | dict[str, object]

plain interval (Target.raw/probability/calibrated); a

Counterfactual | Infeasible | dict[str, object]

{band_name: Counterfactual | Infeasible} dict, one entry per

Counterfactual | Infeasible | dict[str, object]

band in solved order, when target is a Target.bands

Counterfactual | Infeasible | dict[str, object]

ladder. Infeasible means the search found no verified

Counterfactual | Infeasible | dict[str, object]

counterfactual — see Infeasible.proof for whether that is a

Counterfactual | Infeasible | dict[str, object]

certified impossibility or just an unsuccessful search.

Raises:

Type Description
ValueError

If warm_start, node_budget, or gap is given a non-default value together with a backend other than "exact".

TreecfError

If backend is not one of "genetic", "python", or "exact", or if plausibility is configured on this explainer and x contains NaN.

ConstraintValidationError

If backend="exact" and this explainer's constraints include an unsupported multi-feature Linear shape, or a callable value_policy; the message names backend="genetic" as the fallback.

Source code in src/treecf/api.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
def explain(
    self,
    x: FloatArray,
    target: Target,
    backend: str = "genetic",
    time_budget_s: float = 10.0,
    sparsity_weight: float = 0.0,
    seed: int | None = None,
    warm_start: bool | None = None,
    node_budget: int | None = None,
    gap: float | None = None,
    region: bool = False,
) -> Counterfactual | Infeasible | dict[str, object]:
    """Search for a counterfactual (or one per band for ``Target.bands``).

    ``x`` is the factual instance (one row, aligned to the model's feature
    order); ``target`` bounds the model output the counterfactual must
    reach. ``time_budget_s`` caps wall time per solve (per band, when
    ``target`` is a ``Target.bands`` ladder); ``math.inf`` is accepted
    and removes the time cut, letting an exact search run until it
    proves its answer (Ctrl-C still aborts promptly). ``sparsity_weight`` makes
    the search minimize ``distance + sparsity_weight * n_changed``
    instead of plain ``distance``, trading a cheaper plan that touches
    more features against a sparser one that costs more per feature; the
    returned ``Counterfactual.distance`` itself always excludes the
    sparsity term. ``0.0`` (the default) does not penalize sparsity at
    all. ``seed`` fixes the genetic search's (and, on the
    exact backend, the warm start's) randomness for reproducibility;
    ``None`` draws a fresh one each call.

    ``backend="genetic"`` runs the bundled Rust engine (default);
    ``backend="python"`` runs the reference numpy implementation of the
    same algorithm; ``backend="exact"`` runs a branch-and-bound search
    over the same candidate grid that proves optimality when it finds a
    counterfactual and proves infeasibility when it does not, at the cost
    of a potentially longer solve. Every result is float-verified before
    being returned.

    ``warm_start`` (default ``True``), ``node_budget`` (default
    ``2_000_000``), and ``gap`` (default ``0.0``) configure the exact
    backend only; passing a non-default value together with another
    backend raises ``ValueError`` — deliberately not the usual
    ``TreecfError``, since this rejects a Python-level argument
    combination rather than a modeling error. ``warm_start=True`` runs a
    short genetic pass first (about a quarter of ``time_budget_s``,
    capped at 2 seconds) and, if it lands a verified counterfactual, feeds
    it to the exact search as a starting incumbent; the exact search
    still gets the full ``time_budget_s`` afterwards, so warm start is
    additive rather than deducted from the budget. ``gap`` lets the exact
    search settle for a counterfactual within that relative fraction of
    the true optimum, reported through ``proof="optimal_within_gap"``.

    An exact search can return a feasible row with ``proof="heuristic"``
    without exhausting ``node_budget`` or ``time_budget_s``: conservative
    repair of some constraint shapes can withdraw the optimality
    certificate honestly rather than claim a cheapest row it did not
    prove — the row itself is still real and verified, only the
    "cheapest possible" claim is dropped. Whenever the exact search
    returns any result with ``solver_stats["completed"] is False`` — for
    that reason or because the budget genuinely ran out — a
    ``TreecfWarning`` names which of the two happened, never the
    other one. ``Target.bands``, ``explain_coalitions``, and
    ``explain_batch`` collapse this into one aggregate warning per call
    instead of one per solve.

    If the factual itself violates a constraint, a ``TreecfWarning``
    is emitted: the returned plan will include changes made solely to
    satisfy the constraint set.

    ``region=True`` widens every successful ``Counterfactual`` into a
    certified ``RecourseRegion`` (``cf.region``) —
    works with any backend, genetic included. Costs one oracle call per
    attempted per-feature, per-direction expansion; see
    ``Explainer.recourse_region``.

    Returns:
        A single ``Counterfactual`` or ``Infeasible`` when ``target`` is a
        plain interval (``Target.raw``/``probability``/``calibrated``); a
        ``{band_name: Counterfactual | Infeasible}`` dict, one entry per
        band in solved order, when ``target`` is a ``Target.bands``
        ladder. ``Infeasible`` means the search found no verified
        counterfactual — see ``Infeasible.proof`` for whether that is a
        certified impossibility or just an unsuccessful search.

    Raises:
        ValueError: If ``warm_start``, ``node_budget``, or ``gap`` is
            given a non-default value together with a ``backend`` other
            than ``"exact"``.
        TreecfError: If ``backend`` is not one of ``"genetic"``,
            ``"python"``, or ``"exact"``, or if ``plausibility`` is
            configured on this explainer and ``x`` contains NaN.
        ConstraintValidationError: If ``backend="exact"`` and this
            explainer's constraints include an unsupported multi-feature
            ``Linear`` shape, or a callable ``value_policy``; the message
            names ``backend="genetic"`` as the fallback.
    """
    return self._explain(
        x, target, backend, time_budget_s, sparsity_weight, seed, warn_factual=True,
        warm_start=warm_start, node_budget=node_budget, gap=gap, region=region,
    )

explain_batch(X, target, n_per_example=1, diversity='seeds', ids=None, backend='genetic', time_budget_s=10.0, sparsity_weight=0.0, seed=0, coalitions=None, include_full=False, warm_start=None, node_budget=None, gap=None, region=False, allow_exact_batch=False)

Mass-produce counterfactuals for a dataset; see treecf.batch.

X is the factual dataset (one row per instance, aligned to the model's feature order); ids labels each row (defaults to its integer index) and must have one entry per row of X. n_per_example alternatives per row via diversity="seeds" (distinct change-sets from different seeds, best-effort) or "lever-blocking" (freeze each plan's biggest lever; also records essential levers). diversity="coalitions" instead produces one plan per named feature group in coalitions per row (n_per_example unused; see explain_coalitions for coalitions/include_full semantics, which are only valid in this mode). The returned BatchResult supports save/load/for_id/to_frame. time_budget_s, sparsity_weight, and seed carry the same meaning as in Explainer.explain, applied per solve (seed is combined with each row's index to derive a distinct per-row seed).

Solves run in parallel inside the Rust engine; time_budget_s is per solve, so a solve that hits its wall-clock budget while sharing cores may stop earlier than it would sequentially (results are otherwise identical to solving row by row).

backend="exact" has no vectorized population to parallelize, so this loops the single-instance exact solve per row (and per plan, for lever-blocking) sequentially — expect roughly linear-in-rows wall time rather than the Rust engine's parallel wave scheduling, and each row still gets the full, undiminished time_budget_s. Because that wall time is easy to underestimate, backend="exact" here is opt-in: without allow_exact_batch=True this raises ValueError naming an estimate (rows × plans × time_budget_s, hours-formatted) instead of silently running -- a floor, not a ceiling, since diversity="seeds" can retry each plan up to 3x on a seed collision; passing it through with any other backend also raises ValueError. Opting in additionally replaces warm_start's N sequential per-row (or, in seeds mode, per-attempt) genetic warm passes with a single vectorized one across every row — see treecf.batch.explain_batch for exactly which modes it covers and which keep 0.2.0's per-solve behavior. node_budget/gap thread through to every solve unchanged; see Explainer.explain. A KeyboardInterrupt during any batch solve discards whatever the batch has not yet finished — there is no partial BatchResult. region=True attaches a certified RecourseRegion (BatchRecord.region) to every feasible record, at the same one-oracle-call-per-expansion cost.

Returns:

Type Description
Any

A BatchResult holding one BatchRecord per (row, plan) pair

Any

— infeasible rows/plans get a record with feasible=False and

Any

no x_cf rather than being omitted.

Raises:

Type Description
TreecfError

If target is a Target.bands ladder, if diversity is not "seeds", "lever-blocking", or "coalitions", if coalitions/include_full is given outside diversity="coalitions" (or omitted inside it), or if ids does not have one entry per row of X.

ValueError

If warm_start, node_budget, or gap is given a non-default value together with a backend other than "exact"; if backend="exact" is requested without allow_exact_batch=True (message names the wall time estimate, a floor rather than a ceiling); or if allow_exact_batch=True is passed with a backend other than "exact".

Source code in src/treecf/api.py
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
def explain_batch(
    self,
    X: FloatArray,
    target: Target,
    n_per_example: int = 1,
    diversity: str = "seeds",
    ids: Sequence[object] | None = None,
    backend: str = "genetic",
    time_budget_s: float = 10.0,
    sparsity_weight: float = 0.0,
    seed: int = 0,
    coalitions: Mapping[str, Sequence[str]] | None = None,
    include_full: bool = False,
    warm_start: bool | None = None,
    node_budget: int | None = None,
    gap: float | None = None,
    region: bool = False,
    allow_exact_batch: bool = False,
) -> Any:
    """Mass-produce counterfactuals for a dataset; see ``treecf.batch``.

    ``X`` is the factual dataset (one row per instance, aligned to the
    model's feature order); ``ids`` labels each row (defaults to its
    integer index) and must have one entry per row of ``X``.
    ``n_per_example`` alternatives per row via ``diversity="seeds"`` (distinct
    change-sets from different seeds, best-effort) or ``"lever-blocking"``
    (freeze each plan's biggest lever; also records essential levers).
    ``diversity="coalitions"`` instead produces one plan per named feature
    group in ``coalitions`` per row (``n_per_example`` unused; see
    ``explain_coalitions`` for ``coalitions``/``include_full`` semantics,
    which are only valid in this mode). The returned ``BatchResult`` supports
    save/load/for_id/to_frame. ``time_budget_s``, ``sparsity_weight``, and
    ``seed`` carry the same meaning as in ``Explainer.explain``, applied
    per solve (``seed`` is combined with each row's index to derive a
    distinct per-row seed).

    Solves run in parallel inside the Rust engine; ``time_budget_s`` is
    per solve, so a solve that hits its wall-clock budget while sharing
    cores may stop earlier than it would sequentially (results are
    otherwise identical to solving row by row).

    ``backend="exact"`` has no vectorized population to parallelize, so
    this loops the single-instance exact solve per row (and per plan, for
    lever-blocking) sequentially — expect roughly linear-in-rows wall
    time rather than the Rust engine's parallel wave scheduling, and each
    row still gets the full, undiminished ``time_budget_s``. Because that
    wall time is easy to underestimate, ``backend="exact"`` here is
    opt-in: without ``allow_exact_batch=True`` this raises ``ValueError``
    naming an estimate (rows × plans × ``time_budget_s``, hours-formatted)
    instead of silently running -- a floor, not a ceiling, since
    ``diversity="seeds"`` can retry each plan up to 3x on a seed
    collision; passing it through with any other backend also raises
    ``ValueError``. Opting in additionally
    replaces ``warm_start``'s N sequential per-row (or, in seeds mode,
    per-attempt) genetic warm passes with a single vectorized one across
    every row — see ``treecf.batch.explain_batch`` for exactly which
    modes it covers and which keep 0.2.0's per-solve behavior.
    ``node_budget``/``gap`` thread through to every solve unchanged; see
    ``Explainer.explain``. A ``KeyboardInterrupt`` during any batch solve
    discards whatever the batch has not yet finished — there is no
    partial ``BatchResult``. ``region=True`` attaches a certified
    ``RecourseRegion`` (``BatchRecord.region``) to every feasible record,
    at the same one-oracle-call-per-expansion cost.

    Returns:
        A ``BatchResult`` holding one ``BatchRecord`` per (row, plan) pair
        — infeasible rows/plans get a record with ``feasible=False`` and
        no ``x_cf`` rather than being omitted.

    Raises:
        TreecfError: If ``target`` is a ``Target.bands`` ladder, if
            ``diversity`` is not ``"seeds"``, ``"lever-blocking"``, or
            ``"coalitions"``, if ``coalitions``/``include_full`` is given
            outside ``diversity="coalitions"`` (or omitted inside it), or
            if ``ids`` does not have one entry per row of ``X``.
        ValueError: If ``warm_start``, ``node_budget``, or ``gap`` is
            given a non-default value together with a ``backend`` other
            than ``"exact"``; if ``backend="exact"`` is requested without
            ``allow_exact_batch=True`` (message names the wall time
            estimate, a floor rather than a ceiling); or if
            ``allow_exact_batch=True`` is passed with a ``backend`` other
            than ``"exact"``.
    """
    from treecf.batch import explain_batch

    return explain_batch(
        self, X, target, n_per_example=n_per_example, diversity=diversity,
        ids=ids, backend=backend, time_budget_s=time_budget_s,
        sparsity_weight=sparsity_weight, seed=seed,
        coalitions=coalitions, include_full=include_full,
        warm_start=warm_start, node_budget=node_budget, gap=gap, region=region,
        allow_exact_batch=allow_exact_batch,
    )

explain_coalitions(x, target, coalitions, include_full=False, backend='genetic', time_budget_s=10.0, sparsity_weight=0.0, seed=None, warm_start=None, node_budget=None, gap=None, region=False)

One counterfactual per named feature coalition (opt-in mode).

x/target/backend/time_budget_s/sparsity_weight/ seed carry the same meaning as in Explainer.explain, applied once per coalition. coalitions maps a group name to the features it may change; each coalition is solved with every feature outside it frozen, so a plan only ever asks for changes within one group — grouped recourse instead of one plan that mixes unrelated levers. Coalitions may overlap; features in no coalition are never modified; an Infeasible for a coalition means that group alone cannot reach the target. include_full=True prepends an unrestricted baseline under the reserved key "(all levers)". One solve per coalition (milliseconds each); this mode is optional and never the default. warm_start/node_budget/gap/region thread through to every coalition's solve; see Explainer.explain. A degraded exact result (solver_stats["completed"] is False) in any coalition's solve is collapsed into one aggregate TreecfWarning for the whole call, rather than one per coalition.

Returns:

Type Description
dict[str, Counterfactual | Infeasible]

{coalition_name: Counterfactual | Infeasible}, one entry per

dict[str, Counterfactual | Infeasible]

key of coalitions plus "(all levers)" when

dict[str, Counterfactual | Infeasible]

include_full=True, in that insertion order.

Raises:

Type Description
TreecfError

If target is a Target.bands ladder, if coalitions is empty, names a coalition with no members, or references an unknown feature, or if include_full=True and a coalition is named "(all levers)" (the reserved key).

ValueError

If warm_start, node_budget, or gap is given a non-default value together with a backend other than "exact".

Source code in src/treecf/api.py
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
def explain_coalitions(
    self,
    x: FloatArray,
    target: Target,
    coalitions: Mapping[str, Sequence[str]],
    include_full: bool = False,
    backend: str = "genetic",
    time_budget_s: float = 10.0,
    sparsity_weight: float = 0.0,
    seed: int | None = None,
    warm_start: bool | None = None,
    node_budget: int | None = None,
    gap: float | None = None,
    region: bool = False,
) -> dict[str, Counterfactual | Infeasible]:
    """One counterfactual per named feature coalition (opt-in mode).

    ``x``/``target``/``backend``/``time_budget_s``/``sparsity_weight``/
    ``seed`` carry the same meaning as in ``Explainer.explain``, applied
    once per coalition. ``coalitions`` maps a group name to the features
    it may change; each coalition is solved with every feature *outside*
    it frozen, so a plan only ever asks for changes within one group —
    grouped recourse instead of one plan that mixes unrelated levers.
    Coalitions may overlap; features in no coalition are never modified;
    an ``Infeasible`` for a coalition means that group alone cannot reach
    the target. ``include_full=True`` prepends an unrestricted baseline
    under the reserved key ``"(all levers)"``. One solve per coalition
    (milliseconds each); this mode is optional and never the default.
    ``warm_start``/``node_budget``/``gap``/``region`` thread through to every
    coalition's solve; see ``Explainer.explain``. A degraded exact result
    (``solver_stats["completed"] is False``) in any coalition's solve is
    collapsed into one aggregate ``TreecfWarning`` for the whole call,
    rather than one per coalition.

    Returns:
        ``{coalition_name: Counterfactual | Infeasible}``, one entry per
        key of ``coalitions`` plus ``"(all levers)"`` when
        ``include_full=True``, in that insertion order.

    Raises:
        TreecfError: If ``target`` is a ``Target.bands`` ladder, if
            ``coalitions`` is empty, names a coalition with no members, or
            references an unknown feature, or if ``include_full=True`` and
            a coalition is named ``"(all levers)"`` (the reserved key).
        ValueError: If ``warm_start``, ``node_budget``, or ``gap`` is
            given a non-default value together with a ``backend`` other
            than ``"exact"``.
    """
    if target.bands_spec is not None:
        raise TreecfError(
            "Target.bands is not supported in explain_coalitions; loop bands explicitly"
        )
    normalized = _validate_coalitions(coalitions, self.ir.feature_names, include_full)
    results: dict[str, Counterfactual | Infeasible] = {}
    degraded: list[_Degradation] = []
    if include_full:
        results[_ALL_LEVERS] = self._explain_one(
            x, target, backend, time_budget_s, sparsity_weight, seed,
            warm_start=warm_start, node_budget=node_budget, gap=gap, region=region,
            degraded=degraded,
        )
    for name, clone in self._coalition_explainers(normalized).items():
        results[name] = clone._explain_one(
            x, target, backend, time_budget_s, sparsity_weight, seed,
            warm_start=warm_start, node_budget=node_budget, gap=gap, region=region,
            degraded=degraded,
        )
    message = _degraded_summary(degraded, len(degraded), len(results), "coalitions")
    if message is not None:
        warnings.warn(message, TreecfWarning, stacklevel=2)  # explain_coalitions <- user code
    return results

recourse_region(x, x_cf, target)

Certify a per-feature box around an already-verified counterfactual.

x is the original factual and x_cf the counterfactual to widen (typically Counterfactual.x_cf from a prior explain call); target is the single interval x_cf was solved against. x_cf must independently pass the same float-space re-check explain runs on its own results; a row that fails it raises TreecfError naming the reason, since there is nothing sound to widen. Works for a counterfactual from any backend. Costs one oracle call — a full interval-tree walk of every ensemble tree — per attempted per-feature, per-direction expansion; see RecourseRegion. The returned region is certified but neither maximal nor monotone in target: a strictly narrower target can still grow a strictly wider region on some feature. See Certification.

Returns:

Type Description
RecourseRegion

The certified RecourseRegion around x_cf.

Raises:

Type Description
TreecfError

If target is a Target.bands ladder (pass the single band's own interval instead), or if x_cf fails the float-space re-check against x/target — the message names the specific check that failed.

Source code in src/treecf/api.py
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
def recourse_region(
    self, x: FloatArray, x_cf: FloatArray, target: Target
) -> RecourseRegion:
    """Certify a per-feature box around an already-verified counterfactual.

    ``x`` is the original factual and ``x_cf`` the counterfactual to widen
    (typically ``Counterfactual.x_cf`` from a prior ``explain`` call);
    ``target`` is the single interval ``x_cf`` was solved against. ``x_cf``
    must independently pass the same float-space re-check
    ``explain`` runs on its own results; a row that fails it raises
    ``TreecfError`` naming the reason, since there is nothing sound to
    widen. Works for a counterfactual from any backend. Costs one oracle
    call — a full interval-tree walk of every ensemble tree — per
    attempted per-feature, per-direction expansion; see
    ``RecourseRegion``. The returned region is certified
    but neither maximal nor monotone in ``target``: a strictly narrower
    target can still grow a strictly wider region on some feature. See
    [Certification](concepts/certification.md#regions-certified-not-maximal-not-monotone).

    Returns:
        The certified ``RecourseRegion`` around ``x_cf``.

    Raises:
        TreecfError: If ``target`` is a ``Target.bands`` ladder (pass the
            single band's own interval instead), or if ``x_cf`` fails the
            float-space re-check against ``x``/``target`` — the message
            names the specific check that failed.
    """
    x = np.asarray(x, dtype=np.float64)
    x_cf = np.asarray(x_cf, dtype=np.float64)
    if target.bands_spec is not None:
        raise TreecfError(
            "Target.bands is not supported in recourse_region; pass the single "
            "band's own interval via Target.raw/probability/calibrated"
        )
    interval = target.raw_interval(self.ir.link)
    try:
        verification = self._verify(x, x_cf, interval)
    except ValueError as exc:
        # _verify's own raw_score re-check raises ValueError when x_cf's
        # path hits a split with no missing routing defined (an unrouted
        # NaN) -- surfaced here as the TreecfError this method's docstring
        # promises, since _verify itself stays a float-space re-check that
        # never wraps its own scoring call.
        raise TreecfError(
            f"cannot certify a region for an unverified counterfactual: {exc}"
        ) from exc
    if verification is not None:
        raise TreecfError(
            f"cannot certify a region for an unverified counterfactual: {verification}"
        )
    return self._region_for(x, x_cf, interval)

One verified counterfactual: the changed row, its cost, and how strong a claim the search makes about it being the cheapest one.

proof is always one of exactly three values: {"heuristic", "optimal", "optimal_within_gap"}. The genetic and python backends always report "heuristic" — they never claim optimality. The exact backend reports "optimal" when it proved no cheaper row exists, "optimal_within_gap" when gap > 0 and it only proved none exists more than that relative fraction cheaper, and — more rarely — "heuristic" for a row it is not claiming is cheapest: see Explainer.explain for when that happens. See Certification for the full proof taxonomy.

Attributes:

Name Type Description
x_cf FloatArray

The full counterfactual feature vector, same order and length as the factual; unchanged features keep the factual's own value.

changes dict[str, tuple[float, float]]

{feature: (factual_value, counterfactual_value)} for every feature that actually differs (including a transition to or from NaN); unchanged features are omitted.

distance float

The weighted, normalized sum of per-feature changes (sum(weight * |delta| / sigma), with AllowMissing's delta_miss/delta_from_miss pricing any NaN transition), excluding the sparsity term. When sparsity_weight > 0 the search minimizes distance + sparsity_weight * n_changed, so distance alone does not reproduce the search's own ranking.

n_changed int

len(changes) — the number of features actually changed.

score_raw float

The model's raw score at x_cf (pre-link, i.e. margin for a sigmoid-link model).

score_prob float | None

sigmoid(score_raw) for a sigmoid-link model, otherwise None.

proof str

The optimality claim this result makes; see above.

solver_stats dict[str, object]

Backend-specific diagnostics. Populated by the exact backend (nodes_expanded, nodes_pruned_score, nodes_pruned_cost, lower_bound, gap, completed, warm_start_used); empty or backend-specific for genetic/python.

snapped dict[str, bool]

{feature: bool} for every feature under a value_policy that also changed — True when the genetic backend's post-hoc snap held, False when it was reverted (or never applied) to keep the result feasible. Empty when no changed feature carries a policy, or on the exact backend (policies are baked into its own search and never post-hoc snapped).

region RecourseRegion | None

The certified box around x_cf, set only when the search ran with region=True (Explainer.explain/explain_batch/ explain_coalitions); None otherwise.

score_calibrated float | None

The calibrator's probability at x_cf — set only for a calibrated-space target whose calibrator exposes predict_proba; None otherwise. Presentational: the engine optimized and verified against the raw interval the calibrator's interval_inverse produced, never against this value.

Source code in src/treecf/api.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
@dataclass(frozen=True)
class Counterfactual:
    """One verified counterfactual: the changed row, its cost, and how strong
    a claim the search makes about it being the cheapest one.

    ``proof`` is always one of exactly three values:
    ``{"heuristic", "optimal", "optimal_within_gap"}``. The genetic and
    python backends always report ``"heuristic"`` — they never claim
    optimality. The exact backend reports ``"optimal"`` when it proved no
    cheaper row exists, ``"optimal_within_gap"`` when ``gap > 0`` and it only
    proved none exists more than that relative fraction cheaper, and — more
    rarely — ``"heuristic"`` for a row it is not claiming is cheapest: see
    ``Explainer.explain`` for when that happens. See
    [Certification](concepts/certification.md) for the full proof taxonomy.

    Attributes:
        x_cf: The full counterfactual feature vector, same order and length as
            the factual; unchanged features keep the factual's own value.
        changes: ``{feature: (factual_value, counterfactual_value)}`` for every
            feature that actually differs (including a transition to or from
            ``NaN``); unchanged features are omitted.
        distance: The weighted, normalized sum of per-feature changes
            (``sum(weight * |delta| / sigma)``, with ``AllowMissing``'s
            ``delta_miss``/``delta_from_miss`` pricing any NaN transition),
            excluding the sparsity term. When ``sparsity_weight > 0`` the
            search minimizes ``distance + sparsity_weight * n_changed``, so
            ``distance`` alone does not reproduce the search's own ranking.
        n_changed: ``len(changes)`` — the number of features actually changed.
        score_raw: The model's raw score at ``x_cf`` (pre-link, i.e. margin for
            a sigmoid-link model).
        score_prob: ``sigmoid(score_raw)`` for a sigmoid-link model, otherwise
            ``None``.
        proof: The optimality claim this result makes; see above.
        solver_stats: Backend-specific diagnostics. Populated by the exact
            backend (``nodes_expanded``, ``nodes_pruned_score``,
            ``nodes_pruned_cost``, ``lower_bound``, ``gap``, ``completed``,
            ``warm_start_used``); empty or backend-specific for genetic/python.
        snapped: ``{feature: bool}`` for every feature under a ``value_policy``
            that also changed — ``True`` when the genetic backend's post-hoc
            snap held, ``False`` when it was reverted (or never applied) to keep
            the result feasible. Empty when no changed feature carries a policy,
            or on the exact backend (policies are baked into its own search and
            never post-hoc snapped).
        region: The certified box around ``x_cf``, set only when the search ran
            with ``region=True`` (``Explainer.explain``/``explain_batch``/
            ``explain_coalitions``); ``None`` otherwise.
        score_calibrated: The calibrator's probability at ``x_cf`` — set only
            for a calibrated-space target whose calibrator exposes
            ``predict_proba``; ``None`` otherwise. Presentational: the engine
            optimized and verified against the raw interval the calibrator's
            ``interval_inverse`` produced, never against this value.
    """

    x_cf: FloatArray
    changes: dict[str, tuple[float, float]]
    distance: float
    n_changed: int
    score_raw: float
    score_prob: float | None
    proof: str  # "heuristic" | "optimal" | "optimal_within_gap"
    solver_stats: dict[str, object] = field(default_factory=dict)
    snapped: dict[str, bool] = field(default_factory=dict)  # value_policy outcome
    region: RecourseRegion | None = None  # set when `explain(..., region=True)`
    score_calibrated: float | None = None  # presentational read-out; see docstring

No counterfactual returned — the search made no claim, or proved none exists.

proof is always one of exactly two values: {"search_exhausted", "certified"}. "search_exhausted" (the default) means the search ran out of budget, hit a heuristic dead end, or gave up an optimality certificate along the way — nothing is proven about whether a counterfactual exists at all. "certified" is exact-backend only: every assignment the searched grid allows was tried and none was feasible, so reason names the node count behind that proof. See Certification for the full proof taxonomy.

Attributes:

Name Type Description
reason str

Human-readable explanation of why no counterfactual was returned; names the node count behind a "certified" proof, or describes the exhaustion/repair cause for "search_exhausted".

proof str

The claim this non-result makes; see above.

solver_stats dict[str, object]

Backend-specific diagnostics, populated the same way as Counterfactual.solver_stats for the exact backend; empty for genetic/python.

Source code in src/treecf/api.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
@dataclass(frozen=True)
class Infeasible:
    """No counterfactual returned — the search made no claim, or proved none exists.

    ``proof`` is always one of exactly two values:
    ``{"search_exhausted", "certified"}``. ``"search_exhausted"`` (the
    default) means the search ran out of budget, hit a heuristic dead end, or
    gave up an optimality certificate along the way — nothing is proven about
    whether a counterfactual exists at all. ``"certified"`` is exact-backend
    only: every assignment the searched grid allows was tried and none was
    feasible, so ``reason`` names the node count behind that proof. See
    [Certification](concepts/certification.md) for the full proof taxonomy.

    Attributes:
        reason: Human-readable explanation of why no counterfactual was
            returned; names the node count behind a ``"certified"`` proof, or
            describes the exhaustion/repair cause for ``"search_exhausted"``.
        proof: The claim this non-result makes; see above.
        solver_stats: Backend-specific diagnostics, populated the same way as
            ``Counterfactual.solver_stats`` for the exact backend; empty for
            genetic/python.
    """

    reason: str
    proof: str = "search_exhausted"  # "search_exhausted" | "certified"
    solver_stats: dict[str, object] = field(default_factory=dict)

Batch production

Counterfactuals for a whole dataset, addressable by row id.

Returned by Explainer.explain_batch; supports len(), iteration over its records, id lookup (for_id), a JSON round trip (save/load), and a pandas view (to_frame).

Attributes:

Name Type Description
feature_names tuple[str, ...]

The model's feature names, in the order x_cf arrays are indexed by.

diversity str

The diversity mode explain_batch ran with ("seeds", "lever-blocking", or "coalitions").

records tuple[BatchRecord, ...]

Every BatchRecord, feasible and infeasible, across every row and alternative/coalition; order matches the originating explain_batch call.

essential_levers dict[object, list[str]]

{row_id: [feature, ...]} — for diversity="lever-blocking" rows only, the features whose freezing made every alternative infeasible (so the primary plan has no substitute for that lever). Empty for other diversity modes.

Source code in src/treecf/batch.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
@dataclass(frozen=True)
class BatchResult:
    """Counterfactuals for a whole dataset, addressable by row id.

    Returned by ``Explainer.explain_batch``; supports ``len()``, iteration
    over its ``records``, id lookup (``for_id``), a JSON round trip
    (``save``/``load``), and a pandas view (``to_frame``).

    Attributes:
        feature_names: The model's feature names, in the order ``x_cf``
            arrays are indexed by.
        diversity: The ``diversity`` mode ``explain_batch`` ran with
            (``"seeds"``, ``"lever-blocking"``, or ``"coalitions"``).
        records: Every ``BatchRecord``, feasible and infeasible, across every
            row and alternative/coalition; order matches the originating
            ``explain_batch`` call.
        essential_levers: ``{row_id: [feature, ...]}`` — for
            ``diversity="lever-blocking"`` rows only, the features whose
            freezing made every alternative infeasible (so the primary plan
            has no substitute for that lever). Empty for other diversity
            modes.
    """

    feature_names: tuple[str, ...]
    diversity: str
    records: tuple[BatchRecord, ...]
    essential_levers: dict[object, list[str]] = field(default_factory=dict)

    def __len__(self) -> int:
        return len(self.records)

    def __iter__(self) -> Iterator[BatchRecord]:
        return iter(self.records)

    def for_id(self, row_id: object) -> list[BatchRecord]:
        """Every record (all alternatives/coalitions) for one dataset row.

        Args:
            row_id: A value from ``explain_batch``'s ``ids`` (or the row's
                integer index when ``ids`` was not given).

        Returns:
            The matching records, in their original order; ``[]`` if
            ``row_id`` is not present in this result.
        """
        return [r for r in self.records if r.id == row_id]

    def save(self, path: str | os.PathLike[str]) -> None:
        """Write this result to a portable JSON file, reloadable with ``load``.

        Every field is encoded explicitly (NaN/Infinity-safe floats via
        ``encode_floats``), including ``region`` when set, so a round trip
        through ``save``/``load`` is lossless.

        Args:
            path: Destination file path; overwritten if it already exists.
        """
        data = {
            "feature_names": list(self.feature_names),
            "diversity": self.diversity,
            "essential_levers": {str(k): v for k, v in self.essential_levers.items()},
            "essential_lever_ids": [encode_floats(k) for k in self.essential_levers],
            "records": [
                {
                    "id": record.id,
                    "k": record.k,
                    "feasible": record.feasible,
                    "x_cf": None if record.x_cf is None else encode_floats(record.x_cf),
                    "changes": {
                        name: encode_floats(list(pair))
                        for name, pair in record.changes.items()
                    },
                    "distance": record.distance,
                    "n_changed": record.n_changed,
                    "score_raw": record.score_raw,
                    "score_prob": record.score_prob,
                    "seed": record.seed,
                    "blocked_lever": record.blocked_lever,
                    "coalition": record.coalition,
                    "proof": record.proof,
                    "calibrator_fingerprint": record.calibrator_fingerprint,
                    "score_calibrated": record.score_calibrated,
                    "solver_stats": {
                        key: encode_floats(value)
                        for key, value in record.solver_stats.items()
                    },
                    "region": (
                        None
                        if record.region is None
                        else {
                            "lo": encode_floats(record.region.lo),
                            "hi": encode_floats(record.region.hi),
                            "feature_intervals": {
                                name: encode_floats(list(pair))
                                for name, pair in record.region.feature_intervals.items()
                            },
                            "certified": record.region.certified,
                        }
                    ),
                }
                for record in self.records
            ],
        }
        with open(path, "w", encoding="utf-8") as fh:
            json.dump(data, fh)

    @classmethod
    def load(cls, path: str | os.PathLike[str]) -> BatchResult:
        """Read a ``BatchResult`` previously written by ``save``.

        A file saved without ``region=True``, or by a version of treecf
        before regions existed, loads with every record's ``region`` set to
        ``None``; a file saved before coalition support loads with every
        record's ``coalition`` set to ``None``; a file saved before per-record
        proofs existed loads with ``proof`` defaulted by feasibility
        (``"heuristic"``/``"search_exhausted"``) and empty ``solver_stats``.

        Args:
            path: Path to a file written by ``save``.

        Returns:
            The reconstructed ``BatchResult``.
        """
        from treecf.regions import RecourseRegion

        with open(path, encoding="utf-8") as fh:
            data = json.load(fh)
        records = []
        for raw in data["records"]:
            raw_region = raw.get("region")  # absent key (pre-region files) -> None
            region = (
                None
                if raw_region is None
                else RecourseRegion(
                    lo=np.asarray(decode_floats(raw_region["lo"]), dtype=np.float64),
                    hi=np.asarray(decode_floats(raw_region["hi"]), dtype=np.float64),
                    feature_intervals={
                        name: tuple(decode_floats(pair))
                        for name, pair in raw_region["feature_intervals"].items()
                    },
                    certified=bool(raw_region["certified"]),
                )
            )
            records.append(
                BatchRecord(
                    id=raw["id"],
                    k=int(raw["k"]),
                    feasible=bool(raw["feasible"]),
                    x_cf=(
                        None
                        if raw["x_cf"] is None
                        else np.asarray(decode_floats(raw["x_cf"]), dtype=np.float64)
                    ),
                    changes={
                        name: tuple(decode_floats(pair))
                        for name, pair in raw["changes"].items()
                    },
                    distance=raw["distance"],
                    n_changed=raw["n_changed"],
                    score_raw=raw["score_raw"],
                    score_prob=raw["score_prob"],
                    seed=raw["seed"],
                    blocked_lever=raw["blocked_lever"],
                    coalition=raw.get("coalition"),  # absent in pre-coalition files
                    region=region,
                    # pre-0.2.2 files carry neither field; default by feasibility
                    proof=raw.get(
                        "proof", "heuristic" if raw["feasible"] else "search_exhausted"
                    ),
                    # absent in files written before 0.2.4
                    calibrator_fingerprint=raw.get("calibrator_fingerprint"),
                    score_calibrated=raw.get("score_calibrated"),
                    solver_stats={
                        key: decode_floats(value)
                        for key, value in raw.get("solver_stats", {}).items()
                    },
                )
            )
        essential_ids = [decode_floats(k) for k in data.get("essential_lever_ids", [])]
        essential_values = list(data.get("essential_levers", {}).values())
        return cls(
            feature_names=tuple(data["feature_names"]),
            diversity=data["diversity"],
            records=tuple(records),
            essential_levers=dict(zip(essential_ids, essential_values, strict=True)),
        )

    def to_frame(self) -> Any:
        """One row per (id, k), wide ``cf_<feature>`` columns (pandas, lazy import).

        Every ``BatchRecord`` field except ``x_cf``/``changes``/``region``/
        ``solver_stats`` becomes its own column (``solver_stats`` stays
        record-only — read it off the ``BatchRecord`` directly); ``x_cf`` is
        spread into one ``cf_<feature>`` column per model feature (``NaN`` for
        an infeasible record, or an unchanged feature's factual-equal value);
        ``changes`` is summarized as a ``changed_features`` column (sorted
        feature names).

        Returns:
            A pandas ``DataFrame`` with one row per record.

        Raises:
            TreecfError: If pandas is not installed.
        """
        try:
            import pandas as pd
        except ImportError as exc:  # pragma: no cover - exercised without pandas
            raise TreecfError("to_frame() requires pandas: pip install pandas") from exc
        rows = []
        for record in self.records:
            row: dict[str, object] = {
                "id": record.id,
                "k": record.k,
                "feasible": record.feasible,
                "distance": record.distance,
                "n_changed": record.n_changed,
                "score_raw": record.score_raw,
                "score_prob": record.score_prob,
                "seed": record.seed,
                "blocked_lever": record.blocked_lever,
                "coalition": record.coalition,
                "proof": record.proof,
                "changed_features": sorted(record.changes),
            }
            for j, name in enumerate(self.feature_names):
                row[f"cf_{name}"] = (
                    float(record.x_cf[j]) if record.x_cf is not None else math.nan
                )
            rows.append(row)
        return pd.DataFrame(rows)

for_id(row_id)

Every record (all alternatives/coalitions) for one dataset row.

Parameters:

Name Type Description Default
row_id object

A value from explain_batch's ids (or the row's integer index when ids was not given).

required

Returns:

Type Description
list[BatchRecord]

The matching records, in their original order; [] if

list[BatchRecord]

row_id is not present in this result.

Source code in src/treecf/batch.py
162
163
164
165
166
167
168
169
170
171
172
173
def for_id(self, row_id: object) -> list[BatchRecord]:
    """Every record (all alternatives/coalitions) for one dataset row.

    Args:
        row_id: A value from ``explain_batch``'s ``ids`` (or the row's
            integer index when ``ids`` was not given).

    Returns:
        The matching records, in their original order; ``[]`` if
        ``row_id`` is not present in this result.
    """
    return [r for r in self.records if r.id == row_id]

load(path) classmethod

Read a BatchResult previously written by save.

A file saved without region=True, or by a version of treecf before regions existed, loads with every record's region set to None; a file saved before coalition support loads with every record's coalition set to None; a file saved before per-record proofs existed loads with proof defaulted by feasibility ("heuristic"/"search_exhausted") and empty solver_stats.

Parameters:

Name Type Description Default
path str | PathLike[str]

Path to a file written by save.

required

Returns:

Type Description
BatchResult

The reconstructed BatchResult.

Source code in src/treecf/batch.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
@classmethod
def load(cls, path: str | os.PathLike[str]) -> BatchResult:
    """Read a ``BatchResult`` previously written by ``save``.

    A file saved without ``region=True``, or by a version of treecf
    before regions existed, loads with every record's ``region`` set to
    ``None``; a file saved before coalition support loads with every
    record's ``coalition`` set to ``None``; a file saved before per-record
    proofs existed loads with ``proof`` defaulted by feasibility
    (``"heuristic"``/``"search_exhausted"``) and empty ``solver_stats``.

    Args:
        path: Path to a file written by ``save``.

    Returns:
        The reconstructed ``BatchResult``.
    """
    from treecf.regions import RecourseRegion

    with open(path, encoding="utf-8") as fh:
        data = json.load(fh)
    records = []
    for raw in data["records"]:
        raw_region = raw.get("region")  # absent key (pre-region files) -> None
        region = (
            None
            if raw_region is None
            else RecourseRegion(
                lo=np.asarray(decode_floats(raw_region["lo"]), dtype=np.float64),
                hi=np.asarray(decode_floats(raw_region["hi"]), dtype=np.float64),
                feature_intervals={
                    name: tuple(decode_floats(pair))
                    for name, pair in raw_region["feature_intervals"].items()
                },
                certified=bool(raw_region["certified"]),
            )
        )
        records.append(
            BatchRecord(
                id=raw["id"],
                k=int(raw["k"]),
                feasible=bool(raw["feasible"]),
                x_cf=(
                    None
                    if raw["x_cf"] is None
                    else np.asarray(decode_floats(raw["x_cf"]), dtype=np.float64)
                ),
                changes={
                    name: tuple(decode_floats(pair))
                    for name, pair in raw["changes"].items()
                },
                distance=raw["distance"],
                n_changed=raw["n_changed"],
                score_raw=raw["score_raw"],
                score_prob=raw["score_prob"],
                seed=raw["seed"],
                blocked_lever=raw["blocked_lever"],
                coalition=raw.get("coalition"),  # absent in pre-coalition files
                region=region,
                # pre-0.2.2 files carry neither field; default by feasibility
                proof=raw.get(
                    "proof", "heuristic" if raw["feasible"] else "search_exhausted"
                ),
                # absent in files written before 0.2.4
                calibrator_fingerprint=raw.get("calibrator_fingerprint"),
                score_calibrated=raw.get("score_calibrated"),
                solver_stats={
                    key: decode_floats(value)
                    for key, value in raw.get("solver_stats", {}).items()
                },
            )
        )
    essential_ids = [decode_floats(k) for k in data.get("essential_lever_ids", [])]
    essential_values = list(data.get("essential_levers", {}).values())
    return cls(
        feature_names=tuple(data["feature_names"]),
        diversity=data["diversity"],
        records=tuple(records),
        essential_levers=dict(zip(essential_ids, essential_values, strict=True)),
    )

save(path)

Write this result to a portable JSON file, reloadable with load.

Every field is encoded explicitly (NaN/Infinity-safe floats via encode_floats), including region when set, so a round trip through save/load is lossless.

Parameters:

Name Type Description Default
path str | PathLike[str]

Destination file path; overwritten if it already exists.

required
Source code in src/treecf/batch.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
def save(self, path: str | os.PathLike[str]) -> None:
    """Write this result to a portable JSON file, reloadable with ``load``.

    Every field is encoded explicitly (NaN/Infinity-safe floats via
    ``encode_floats``), including ``region`` when set, so a round trip
    through ``save``/``load`` is lossless.

    Args:
        path: Destination file path; overwritten if it already exists.
    """
    data = {
        "feature_names": list(self.feature_names),
        "diversity": self.diversity,
        "essential_levers": {str(k): v for k, v in self.essential_levers.items()},
        "essential_lever_ids": [encode_floats(k) for k in self.essential_levers],
        "records": [
            {
                "id": record.id,
                "k": record.k,
                "feasible": record.feasible,
                "x_cf": None if record.x_cf is None else encode_floats(record.x_cf),
                "changes": {
                    name: encode_floats(list(pair))
                    for name, pair in record.changes.items()
                },
                "distance": record.distance,
                "n_changed": record.n_changed,
                "score_raw": record.score_raw,
                "score_prob": record.score_prob,
                "seed": record.seed,
                "blocked_lever": record.blocked_lever,
                "coalition": record.coalition,
                "proof": record.proof,
                "calibrator_fingerprint": record.calibrator_fingerprint,
                "score_calibrated": record.score_calibrated,
                "solver_stats": {
                    key: encode_floats(value)
                    for key, value in record.solver_stats.items()
                },
                "region": (
                    None
                    if record.region is None
                    else {
                        "lo": encode_floats(record.region.lo),
                        "hi": encode_floats(record.region.hi),
                        "feature_intervals": {
                            name: encode_floats(list(pair))
                            for name, pair in record.region.feature_intervals.items()
                        },
                        "certified": record.region.certified,
                    }
                ),
            }
            for record in self.records
        ],
    }
    with open(path, "w", encoding="utf-8") as fh:
        json.dump(data, fh)

to_frame()

One row per (id, k), wide cf_<feature> columns (pandas, lazy import).

Every BatchRecord field except x_cf/changes/region/ solver_stats becomes its own column (solver_stats stays record-only — read it off the BatchRecord directly); x_cf is spread into one cf_<feature> column per model feature (NaN for an infeasible record, or an unchanged feature's factual-equal value); changes is summarized as a changed_features column (sorted feature names).

Returns:

Type Description
Any

A pandas DataFrame with one row per record.

Raises:

Type Description
TreecfError

If pandas is not installed.

Source code in src/treecf/batch.py
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
def to_frame(self) -> Any:
    """One row per (id, k), wide ``cf_<feature>`` columns (pandas, lazy import).

    Every ``BatchRecord`` field except ``x_cf``/``changes``/``region``/
    ``solver_stats`` becomes its own column (``solver_stats`` stays
    record-only — read it off the ``BatchRecord`` directly); ``x_cf`` is
    spread into one ``cf_<feature>`` column per model feature (``NaN`` for
    an infeasible record, or an unchanged feature's factual-equal value);
    ``changes`` is summarized as a ``changed_features`` column (sorted
    feature names).

    Returns:
        A pandas ``DataFrame`` with one row per record.

    Raises:
        TreecfError: If pandas is not installed.
    """
    try:
        import pandas as pd
    except ImportError as exc:  # pragma: no cover - exercised without pandas
        raise TreecfError("to_frame() requires pandas: pip install pandas") from exc
    rows = []
    for record in self.records:
        row: dict[str, object] = {
            "id": record.id,
            "k": record.k,
            "feasible": record.feasible,
            "distance": record.distance,
            "n_changed": record.n_changed,
            "score_raw": record.score_raw,
            "score_prob": record.score_prob,
            "seed": record.seed,
            "blocked_lever": record.blocked_lever,
            "coalition": record.coalition,
            "proof": record.proof,
            "changed_features": sorted(record.changes),
        }
        for j, name in enumerate(self.feature_names):
            row[f"cf_{name}"] = (
                float(record.x_cf[j]) if record.x_cf is not None else math.nan
            )
        rows.append(row)
    return pd.DataFrame(rows)

One counterfactual (or the infeasibility marker) for one dataset row.

Fields mirror Counterfactual (x_cf, changes, distance, n_changed, score_raw, score_prob, proof, solver_stats, region), plus batch bookkeeping: id and k place the record in the dataset, and feasible distinguishes a real plan from the infeasibility marker.

Attributes:

Name Type Description
id object

The row identifier this record belongs to (an element of explain_batch's ids, or the row's integer index when ids was not given).

k int

Rank of this plan among the row's feasible alternatives, 0-based, ascending by distance (0 is always the cheapest). For a wholly infeasible row (diversity="seeds"/ "lever-blocking"), the single infeasibility marker gets k=0; for diversity="coalitions", an infeasible coalition's marker instead continues the same row's ascending sequence after its feasible plans, so each coalition still gets a distinct k.

feasible bool

False marks the infeasibility marker for a row (or coalition) that produced no plan; x_cf/changes/ distance/n_changed/score_raw/score_prob are then None/{} rather than real values.

x_cf FloatArray | None

The full counterfactual feature vector, or None when feasible is False.

changes dict[str, tuple[float, float]]

{feature: (factual_value, counterfactual_value)} for every feature that differs; {} when feasible is False.

distance float | None

The weighted, normalized sum of per-feature changes, excluding the sparsity term (see Counterfactual.distance), or None when feasible is False.

n_changed int | None

len(changes), or None when feasible is False.

score_raw float | None

The model's raw score at x_cf, or None when feasible is False.

score_prob float | None

sigmoid(score_raw) for a sigmoid-link model, None for an identity-link model or when feasible is False.

seed int | None

The seed that produced this plan, set only for diversity="seeds"; None otherwise.

blocked_lever str | None

The feature frozen to produce this plan, set only for diversity="lever-blocking" alternatives (not the primary plan, k=0); None otherwise.

coalition str | None

The coalition name this plan belongs to, set only for diversity="coalitions" (including the reserved "(all levers)" baseline when include_full=True); None otherwise.

region RecourseRegion | None

The certified box around x_cf, set only when explain_batch ran with region=True and feasible is True; None otherwise.

proof str

The claim this record makes, mirroring the single-instance result that produced it: Counterfactual.proof ("heuristic" | "optimal" | "optimal_within_gap") for a feasible record, Infeasible.proof ("search_exhausted" | "certified") for an infeasibility marker.

solver_stats dict[str, object]

Exact-backend diagnostics for the solve behind this record, same keys as Counterfactual.solver_stats; empty for genetic/python solves (those engines report no per-row stats).

calibrator_fingerprint str | None

The duck-typed fingerprint() of the calibrated target's calibrator, when it exposes one; None for raw/probability targets or fingerprint-less calibrators. Repeated on every record so each file line is self-contained.

score_calibrated float | None

The calibrator's probability at x_cf for a calibrated target whose calibrator exposes predict_proba; presentational only — the engine optimized and verified on the resolved raw interval. None otherwise.

Source code in src/treecf/batch.py
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
@dataclass(frozen=True)
class BatchRecord:
    """One counterfactual (or the infeasibility marker) for one dataset row.

    Fields mirror ``Counterfactual`` (``x_cf``, ``changes``, ``distance``,
    ``n_changed``, ``score_raw``, ``score_prob``, ``proof``, ``solver_stats``,
    ``region``), plus batch bookkeeping: ``id`` and ``k`` place the record in
    the dataset, and ``feasible`` distinguishes a real plan from the
    infeasibility marker.

    Attributes:
        id: The row identifier this record belongs to (an element of
            ``explain_batch``'s ``ids``, or the row's integer index when
            ``ids`` was not given).
        k: Rank of this plan among the row's feasible alternatives,
            ``0``-based, ascending by distance (``0`` is always the
            cheapest). For a wholly infeasible row (``diversity="seeds"``/
            ``"lever-blocking"``), the single infeasibility marker gets
            ``k=0``; for ``diversity="coalitions"``, an infeasible
            coalition's marker instead continues the same row's ascending
            sequence after its feasible plans, so each coalition still gets
            a distinct ``k``.
        feasible: ``False`` marks the infeasibility marker for a row (or
            coalition) that produced no plan; ``x_cf``/``changes``/
            ``distance``/``n_changed``/``score_raw``/``score_prob`` are then
            ``None``/``{}`` rather than real values.
        x_cf: The full counterfactual feature vector, or ``None`` when
            ``feasible`` is ``False``.
        changes: ``{feature: (factual_value, counterfactual_value)}`` for
            every feature that differs; ``{}`` when ``feasible`` is ``False``.
        distance: The weighted, normalized sum of per-feature changes,
            excluding the sparsity term (see ``Counterfactual.distance``), or
            ``None`` when ``feasible`` is ``False``.
        n_changed: ``len(changes)``, or ``None`` when ``feasible`` is
            ``False``.
        score_raw: The model's raw score at ``x_cf``, or ``None`` when
            ``feasible`` is ``False``.
        score_prob: ``sigmoid(score_raw)`` for a sigmoid-link model, ``None``
            for an identity-link model or when ``feasible`` is ``False``.
        seed: The seed that produced this plan, set only for
            ``diversity="seeds"``; ``None`` otherwise.
        blocked_lever: The feature frozen to produce this plan, set only for
            ``diversity="lever-blocking"`` alternatives (not the primary
            plan, ``k=0``); ``None`` otherwise.
        coalition: The coalition name this plan belongs to, set only for
            ``diversity="coalitions"`` (including the reserved
            ``"(all levers)"`` baseline when ``include_full=True``); ``None``
            otherwise.
        region: The certified box around ``x_cf``, set only when
            ``explain_batch`` ran with ``region=True`` and ``feasible`` is
            ``True``; ``None`` otherwise.
        proof: The claim this record makes, mirroring the single-instance
            result that produced it: ``Counterfactual.proof`` (``"heuristic"``
            | ``"optimal"`` | ``"optimal_within_gap"``) for a feasible
            record, ``Infeasible.proof`` (``"search_exhausted"`` |
            ``"certified"``) for an infeasibility marker.
        solver_stats: Exact-backend diagnostics for the solve behind this
            record, same keys as ``Counterfactual.solver_stats``; empty for
            genetic/python solves (those engines report no per-row stats).
        calibrator_fingerprint: The duck-typed ``fingerprint()`` of the
            calibrated target's calibrator, when it exposes one; ``None``
            for raw/probability targets or fingerprint-less calibrators.
            Repeated on every record so each file line is self-contained.
        score_calibrated: The calibrator's probability at ``x_cf`` for a
            calibrated target whose calibrator exposes ``predict_proba``;
            presentational only — the engine optimized and verified on the
            resolved raw interval. ``None`` otherwise.
    """

    id: object
    k: int
    feasible: bool
    x_cf: FloatArray | None
    changes: dict[str, tuple[float, float]]
    distance: float | None
    n_changed: int | None
    score_raw: float | None
    score_prob: float | None
    seed: int | None = None  # diversity="seeds": the seed that produced this plan
    blocked_lever: str | None = None  # diversity="lever-blocking": the frozen lever
    coalition: str | None = None  # diversity="coalitions": the group this plan may touch
    region: RecourseRegion | None = None  # set by explain_batch(..., region=True)
    proof: str = "heuristic"  # mirrors Counterfactual.proof / Infeasible.proof
    solver_stats: dict[str, object] = field(default_factory=dict)  # exact-backend only
    # Calibrated-target provenance and read-out (0.2.4). The fingerprint is one
    # value repeated per record on purpose: every JSON line stays self-contained
    # for a validator who receives only a slice of the file.
    calibrator_fingerprint: str | None = None
    score_calibrated: float | None = None  # presentational; the engine used raw_interval

Targets

Closed interval target, expressed in raw-score, probability, or calibrated space.

Construct through one of the classmethods (raw, probability, calibrated, bands) rather than the constructor directly — they validate their space's bounds and normalize the range/op/value shorthand into lo/hi. Target.bands builds a named ladder of intervals (rating grades); Explainer.explain then returns one result per band instead of a single Counterfactual/Infeasible. See Targets and, for calibrated, Calibration.

Attributes:

Name Type Description
space str

"raw", "probability", or "calibrated" — which space lo/hi are expressed in.

lo float

Lower bound of the target interval, in space.

hi float

Upper bound of the target interval, in space.

bands_spec tuple[tuple[str, float, float], ...] | None

(name, lo, hi) per band, set only by Target.bands; None for a plain single-interval target. When set, lo/ hi/space describe the first band only — use band_intervals to resolve every band.

calibrator _SupportsIntervalInverse | None

The fitted calibrator, set only by Target.calibrated (or Target.bands(space="calibrated", ...)); None otherwise.

buffer_logit float

Logit-space shrinkage applied before inverting a calibrated interval; 0.0 (no shrinkage) unless Target.calibrated was given a positive value.

Source code in src/treecf/targets.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
@dataclass(frozen=True)
class Target:
    """Closed interval target, expressed in raw-score, probability, or calibrated space.

    Construct through one of the classmethods (``raw``, ``probability``,
    ``calibrated``, ``bands``) rather than the constructor directly — they
    validate their space's bounds and normalize the ``range``/``op``/``value``
    shorthand into ``lo``/``hi``. ``Target.bands`` builds a named ladder of
    intervals (rating grades); ``Explainer.explain`` then returns one result
    per band instead of a single ``Counterfactual``/``Infeasible``. See
    [Targets](concepts/targets.md) and, for ``calibrated``,
    [Calibration](concepts/calibration.md).

    Attributes:
        space: ``"raw"``, ``"probability"``, or ``"calibrated"`` — which
            space ``lo``/``hi`` are expressed in.
        lo: Lower bound of the target interval, in ``space``.
        hi: Upper bound of the target interval, in ``space``.
        bands_spec: ``(name, lo, hi)`` per band, set only by ``Target.bands``;
            ``None`` for a plain single-interval target. When set, ``lo``/
            ``hi``/``space`` describe the first band only — use
            ``band_intervals`` to resolve every band.
        calibrator: The fitted calibrator, set only by ``Target.calibrated``
            (or ``Target.bands(space="calibrated", ...)``); ``None``
            otherwise.
        buffer_logit: Logit-space shrinkage applied before inverting a
            calibrated interval; ``0.0`` (no shrinkage) unless
            ``Target.calibrated`` was given a positive value.
    """

    space: str  # "raw" | "probability" | "calibrated"
    lo: float
    hi: float
    bands_spec: tuple[tuple[str, float, float], ...] | None = None
    calibrator: _SupportsIntervalInverse | None = None
    buffer_logit: float = 0.0

    @classmethod
    def raw(
        cls,
        range: tuple[float, float] | None = None,
        op: str | None = None,
        value: float | None = None,
    ) -> Target:
        """Target on the model's raw output (pre-link margin for a sigmoid model).

        Specify exactly one of ``range=(lo, hi)`` or ``op=``/``value=``
        (``op="<="`` or ``op=">="`` with ``value``, giving a half-open bound
        against ``-inf``/``+inf``).

        Args:
            range: Explicit ``(lo, hi)`` bound, ``lo < hi`` required.
            op: ``"<="`` or ``">="``, paired with ``value``; mutually
                exclusive with ``range``.
            value: The threshold paired with ``op``.

        Returns:
            A ``Target`` with ``space="raw"``.

        Raises:
            TargetError: If neither or both of ``range`` and ``op``/``value``
                are given, if ``op`` is not ``"<="``/``">="``, or if the
                resulting interval is empty.
        """
        lo, hi = _interval_from(range, op, value, lo_limit=-math.inf, hi_limit=math.inf)
        return cls(space="raw", lo=lo, hi=hi)

    @classmethod
    def probability(
        cls,
        range: tuple[float, float] | None = None,
        op: str | None = None,
        value: float | None = None,
    ) -> Target:
        """Target on the model's own probability output.

        If model outputs are post-hoc calibrated downstream, this constructor
        targets the *uncalibrated* model probability; use ``Target.calibrated``
        with your calibrator instead. Requires a SIGMOID-link model — resolved
        lazily, at ``raw_interval`` time, not here. Specify exactly one of
        ``range=(lo, hi)`` or ``op=``/``value=``, as in ``Target.raw``.

        Args:
            range: Explicit ``(lo, hi)`` bound within ``[0, 1]``.
            op: ``"<="`` or ``">="``, paired with ``value``; mutually
                exclusive with ``range``.
            value: The threshold paired with ``op``.

        Returns:
            A ``Target`` with ``space="probability"``.

        Raises:
            TargetError: If neither or both of ``range`` and ``op``/``value``
                are given, if ``op`` is not ``"<="``/``">="``, or if the
                resulting interval is empty or falls outside ``[0, 1]``.
        """
        lo, hi = _interval_from(range, op, value, lo_limit=0.0, hi_limit=1.0)
        if not (0.0 <= lo < hi <= 1.0):
            raise TargetError(f"probability interval [{lo}, {hi}] must lie within [0, 1]")
        return cls(space="probability", lo=lo, hi=hi)

    @classmethod
    def calibrated(
        cls,
        calibrator: _SupportsIntervalInverse,
        range: tuple[float, float] | None = None,
        op: str | None = None,
        value: float | None = None,
        *,
        buffer_logit: float = 0.0,
    ) -> Target:
        """Target on the *calibrated* probability ``g(model probability)``.

        The interval is inverted through the calibrator's generalized inverse
        lazily, at ``raw_interval`` time; the calibrator is held by reference,
        so refitting it between construction and ``explain`` is the caller's
        responsibility. ``buffer_logit`` shrinks the calibrated interval in
        logit space before inversion, making the counterfactual robust to
        future recalibration or central-tendency drift of that magnitude.
        Specify exactly one of ``range=(lo, hi)`` or ``op=``/``value=``, as in
        ``Target.raw``. See [Calibration](concepts/calibration.md).

        Args:
            calibrator: Object exposing ``is_monotone_: bool`` (must be
                ``True``) and ``interval_inverse(lo, hi, *, space,
                buffer_logit)``, returning generalized-inverse bounds on the
                logit of the model probability.
            range: Explicit ``(lo, hi)`` bound within ``[0, 1]``, in the
                calibrated probability's own space.
            op: ``"<="`` or ``">="``, paired with ``value``; mutually
                exclusive with ``range``.
            value: The threshold paired with ``op``.
            buffer_logit: Logit-space shrinkage applied before inversion;
                must be ``>= 0.0``. Defaults to ``0.0`` (no shrinkage).

        Returns:
            A ``Target`` with ``space="calibrated"``.

        Raises:
            TargetError: If ``calibrator`` does not expose
                ``interval_inverse``/``is_monotone_`` or is not monotone, if
                ``buffer_logit < 0.0``, if neither or both of ``range`` and
                ``op``/``value`` are given, if ``op`` is not ``"<="``/``">="``,
                or if the resulting interval is empty or falls outside
                ``[0, 1]``.
        """
        _validate_calibrator(calibrator, buffer_logit)
        lo, hi = _interval_from(range, op, value, lo_limit=0.0, hi_limit=1.0)
        if not (0.0 <= lo < hi <= 1.0):
            raise TargetError(f"calibrated interval [{lo}, {hi}] must lie within [0, 1]")
        return cls(
            space="calibrated", lo=lo, hi=hi, calibrator=calibrator, buffer_logit=buffer_logit
        )

    @classmethod
    def bands(
        cls,
        bands: dict[str, tuple[float, float]],
        space: str = "probability",
        *,
        calibrator: _SupportsIntervalInverse | None = None,
        buffer_logit: float = 0.0,
    ) -> Target:
        """A named ladder of intervals — rating grades solved in one ``explain`` call.

        ``Explainer.explain(x, target=Target.bands(...))`` returns a
        ``{band_name: Counterfactual | Infeasible}`` dict instead of a single
        result, one entry per band in ``bands``' insertion order.

        Args:
            bands: ``{name: (lo, hi)}`` per band, in ``space``; at least one
                required.
            space: ``"raw"``, ``"probability"`` (the default), or
                ``"calibrated"`` — the space every band's ``(lo, hi)`` is
                expressed in.
            calibrator: Required when ``space="calibrated"``; see
                ``Target.calibrated``. Ignored otherwise.
            buffer_logit: Logit-space shrinkage applied to every band when
                ``space="calibrated"``; see ``Target.calibrated``. Defaults to
                ``0.0``.

        Returns:
            A ``Target`` with ``bands_spec`` set to one ``(name, lo, hi)`` per
            band; ``lo``/``hi``/``space`` mirror the first band for callers
            that only look at the plain-interval fields.

        Raises:
            TargetError: If ``space`` is not ``"raw"``/``"probability"``/
                ``"calibrated"``, if ``bands`` is empty, if any band's
                interval is empty or (for ``"probability"``/``"calibrated"``)
                falls outside ``[0, 1]``, or if ``space="calibrated"`` and
                ``calibrator`` fails the same validation as
                ``Target.calibrated``.
        """
        if space not in ("raw", "probability", "calibrated"):
            raise TargetError("bands space must be 'raw', 'probability', or 'calibrated'")
        if space == "calibrated":
            _validate_calibrator(calibrator, buffer_logit)
        if not bands:
            raise TargetError("bands must contain at least one named interval")
        spec = []
        for name, (lo, hi) in bands.items():
            if not lo < hi:
                raise TargetError(f"band {name!r}: empty interval [{lo}, {hi}]")
            if space in ("probability", "calibrated") and not (0.0 <= lo < hi <= 1.0):
                raise TargetError(f"band {name!r} must lie within [0, 1]")
            spec.append((name, float(lo), float(hi)))
        first = spec[0]
        return cls(
            space=space,
            lo=first[1],
            hi=first[2],
            bands_spec=tuple(spec),
            calibrator=calibrator,
            buffer_logit=buffer_logit,
        )

    def raw_interval(self, link: Link) -> tuple[float, float]:
        """Resolve this target to an ``[L, U]`` interval on the model's raw score.

        A ``space="raw"`` target returns ``(lo, hi)`` unchanged. A
        ``space="probability"`` target inverts through the logistic function.
        A ``space="calibrated"`` target inverts through ``calibrator``'s own
        generalized inverse in logit space, shrunk by ``buffer_logit`` first.
        Both non-raw spaces require ``link`` to be ``Link.SIGMOID`` — this is
        where that requirement is actually enforced, not at construction time.

        Args:
            link: The model's link function (``Explainer.ir.link``).

        Returns:
            ``(lo, hi)`` on the raw score, ``lo <= hi``.

        Raises:
            TargetError: If ``space`` is ``"probability"`` or ``"calibrated"``
                and ``link`` is not ``Link.SIGMOID``, or if ``calibrator``
                raises while inverting the interval.
        """
        if self.space == "raw":
            return self.lo, self.hi
        if self.space == "calibrated":
            if link is not Link.SIGMOID:
                raise TargetError(
                    "calibrated target requires a SIGMOID-link model; "
                    "use Target.raw for identity-link outputs"
                )
            assert self.calibrator is not None
            try:
                return self.calibrator.interval_inverse(
                    self.lo, self.hi, space="logit", buffer_logit=self.buffer_logit
                )
            except TargetError:
                raise
            except Exception as exc:
                raise TargetError(
                    f"calibrator could not invert [{self.lo}, {self.hi}]: {exc}"
                ) from exc
        if link is not Link.SIGMOID:
            raise TargetError(
                "probability target requires a SIGMOID-link model; "
                "use Target.raw for identity-link outputs"
            )
        return _logit(self.lo), _logit(self.hi)

    def band_intervals(self, link: Link) -> dict[str, tuple[float, float]]:
        """Resolve every band of a ``Target.bands`` ladder to a raw-score interval.

        Applies ``raw_interval`` band by band, in ``bands_spec``'s order,
        each against a copy of this target's own ``space``/``calibrator``/
        ``buffer_logit``.

        Args:
            link: The model's link function (``Explainer.ir.link``).

        Returns:
            ``{band_name: (lo, hi)}`` on the raw score, one entry per band.

        Raises:
            TargetError: Under the same conditions as ``raw_interval``, for
                any band.
        """
        assert self.bands_spec is not None
        out: dict[str, tuple[float, float]] = {}
        for name, lo, hi in self.bands_spec:
            single = Target(
                space=self.space,
                lo=lo,
                hi=hi,
                calibrator=self.calibrator,
                buffer_logit=self.buffer_logit,
            )
            out[name] = single.raw_interval(link)
        return out

band_intervals(link)

Resolve every band of a Target.bands ladder to a raw-score interval.

Applies raw_interval band by band, in bands_spec's order, each against a copy of this target's own space/calibrator/ buffer_logit.

Parameters:

Name Type Description Default
link Link

The model's link function (Explainer.ir.link).

required

Returns:

Type Description
dict[str, tuple[float, float]]

{band_name: (lo, hi)} on the raw score, one entry per band.

Raises:

Type Description
TargetError

Under the same conditions as raw_interval, for any band.

Source code in src/treecf/targets.py
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
def band_intervals(self, link: Link) -> dict[str, tuple[float, float]]:
    """Resolve every band of a ``Target.bands`` ladder to a raw-score interval.

    Applies ``raw_interval`` band by band, in ``bands_spec``'s order,
    each against a copy of this target's own ``space``/``calibrator``/
    ``buffer_logit``.

    Args:
        link: The model's link function (``Explainer.ir.link``).

    Returns:
        ``{band_name: (lo, hi)}`` on the raw score, one entry per band.

    Raises:
        TargetError: Under the same conditions as ``raw_interval``, for
            any band.
    """
    assert self.bands_spec is not None
    out: dict[str, tuple[float, float]] = {}
    for name, lo, hi in self.bands_spec:
        single = Target(
            space=self.space,
            lo=lo,
            hi=hi,
            calibrator=self.calibrator,
            buffer_logit=self.buffer_logit,
        )
        out[name] = single.raw_interval(link)
    return out

bands(bands, space='probability', *, calibrator=None, buffer_logit=0.0) classmethod

A named ladder of intervals — rating grades solved in one explain call.

Explainer.explain(x, target=Target.bands(...)) returns a {band_name: Counterfactual | Infeasible} dict instead of a single result, one entry per band in bands' insertion order.

Parameters:

Name Type Description Default
bands dict[str, tuple[float, float]]

{name: (lo, hi)} per band, in space; at least one required.

required
space str

"raw", "probability" (the default), or "calibrated" — the space every band's (lo, hi) is expressed in.

'probability'
calibrator _SupportsIntervalInverse | None

Required when space="calibrated"; see Target.calibrated. Ignored otherwise.

None
buffer_logit float

Logit-space shrinkage applied to every band when space="calibrated"; see Target.calibrated. Defaults to 0.0.

0.0

Returns:

Type Description
Target

A Target with bands_spec set to one (name, lo, hi) per

Target

band; lo/hi/space mirror the first band for callers

Target

that only look at the plain-interval fields.

Raises:

Type Description
TargetError

If space is not "raw"/"probability"/ "calibrated", if bands is empty, if any band's interval is empty or (for "probability"/"calibrated") falls outside [0, 1], or if space="calibrated" and calibrator fails the same validation as Target.calibrated.

Source code in src/treecf/targets.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
@classmethod
def bands(
    cls,
    bands: dict[str, tuple[float, float]],
    space: str = "probability",
    *,
    calibrator: _SupportsIntervalInverse | None = None,
    buffer_logit: float = 0.0,
) -> Target:
    """A named ladder of intervals — rating grades solved in one ``explain`` call.

    ``Explainer.explain(x, target=Target.bands(...))`` returns a
    ``{band_name: Counterfactual | Infeasible}`` dict instead of a single
    result, one entry per band in ``bands``' insertion order.

    Args:
        bands: ``{name: (lo, hi)}`` per band, in ``space``; at least one
            required.
        space: ``"raw"``, ``"probability"`` (the default), or
            ``"calibrated"`` — the space every band's ``(lo, hi)`` is
            expressed in.
        calibrator: Required when ``space="calibrated"``; see
            ``Target.calibrated``. Ignored otherwise.
        buffer_logit: Logit-space shrinkage applied to every band when
            ``space="calibrated"``; see ``Target.calibrated``. Defaults to
            ``0.0``.

    Returns:
        A ``Target`` with ``bands_spec`` set to one ``(name, lo, hi)`` per
        band; ``lo``/``hi``/``space`` mirror the first band for callers
        that only look at the plain-interval fields.

    Raises:
        TargetError: If ``space`` is not ``"raw"``/``"probability"``/
            ``"calibrated"``, if ``bands`` is empty, if any band's
            interval is empty or (for ``"probability"``/``"calibrated"``)
            falls outside ``[0, 1]``, or if ``space="calibrated"`` and
            ``calibrator`` fails the same validation as
            ``Target.calibrated``.
    """
    if space not in ("raw", "probability", "calibrated"):
        raise TargetError("bands space must be 'raw', 'probability', or 'calibrated'")
    if space == "calibrated":
        _validate_calibrator(calibrator, buffer_logit)
    if not bands:
        raise TargetError("bands must contain at least one named interval")
    spec = []
    for name, (lo, hi) in bands.items():
        if not lo < hi:
            raise TargetError(f"band {name!r}: empty interval [{lo}, {hi}]")
        if space in ("probability", "calibrated") and not (0.0 <= lo < hi <= 1.0):
            raise TargetError(f"band {name!r} must lie within [0, 1]")
        spec.append((name, float(lo), float(hi)))
    first = spec[0]
    return cls(
        space=space,
        lo=first[1],
        hi=first[2],
        bands_spec=tuple(spec),
        calibrator=calibrator,
        buffer_logit=buffer_logit,
    )

calibrated(calibrator, range=None, op=None, value=None, *, buffer_logit=0.0) classmethod

Target on the calibrated probability g(model probability).

The interval is inverted through the calibrator's generalized inverse lazily, at raw_interval time; the calibrator is held by reference, so refitting it between construction and explain is the caller's responsibility. buffer_logit shrinks the calibrated interval in logit space before inversion, making the counterfactual robust to future recalibration or central-tendency drift of that magnitude. Specify exactly one of range=(lo, hi) or op=/value=, as in Target.raw. See Calibration.

Parameters:

Name Type Description Default
calibrator _SupportsIntervalInverse

Object exposing is_monotone_: bool (must be True) and interval_inverse(lo, hi, *, space, buffer_logit), returning generalized-inverse bounds on the logit of the model probability.

required
range tuple[float, float] | None

Explicit (lo, hi) bound within [0, 1], in the calibrated probability's own space.

None
op str | None

"<=" or ">=", paired with value; mutually exclusive with range.

None
value float | None

The threshold paired with op.

None
buffer_logit float

Logit-space shrinkage applied before inversion; must be >= 0.0. Defaults to 0.0 (no shrinkage).

0.0

Returns:

Type Description
Target

A Target with space="calibrated".

Raises:

Type Description
TargetError

If calibrator does not expose interval_inverse/is_monotone_ or is not monotone, if buffer_logit < 0.0, if neither or both of range and op/value are given, if op is not "<="/">=", or if the resulting interval is empty or falls outside [0, 1].

Source code in src/treecf/targets.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
@classmethod
def calibrated(
    cls,
    calibrator: _SupportsIntervalInverse,
    range: tuple[float, float] | None = None,
    op: str | None = None,
    value: float | None = None,
    *,
    buffer_logit: float = 0.0,
) -> Target:
    """Target on the *calibrated* probability ``g(model probability)``.

    The interval is inverted through the calibrator's generalized inverse
    lazily, at ``raw_interval`` time; the calibrator is held by reference,
    so refitting it between construction and ``explain`` is the caller's
    responsibility. ``buffer_logit`` shrinks the calibrated interval in
    logit space before inversion, making the counterfactual robust to
    future recalibration or central-tendency drift of that magnitude.
    Specify exactly one of ``range=(lo, hi)`` or ``op=``/``value=``, as in
    ``Target.raw``. See [Calibration](concepts/calibration.md).

    Args:
        calibrator: Object exposing ``is_monotone_: bool`` (must be
            ``True``) and ``interval_inverse(lo, hi, *, space,
            buffer_logit)``, returning generalized-inverse bounds on the
            logit of the model probability.
        range: Explicit ``(lo, hi)`` bound within ``[0, 1]``, in the
            calibrated probability's own space.
        op: ``"<="`` or ``">="``, paired with ``value``; mutually
            exclusive with ``range``.
        value: The threshold paired with ``op``.
        buffer_logit: Logit-space shrinkage applied before inversion;
            must be ``>= 0.0``. Defaults to ``0.0`` (no shrinkage).

    Returns:
        A ``Target`` with ``space="calibrated"``.

    Raises:
        TargetError: If ``calibrator`` does not expose
            ``interval_inverse``/``is_monotone_`` or is not monotone, if
            ``buffer_logit < 0.0``, if neither or both of ``range`` and
            ``op``/``value`` are given, if ``op`` is not ``"<="``/``">="``,
            or if the resulting interval is empty or falls outside
            ``[0, 1]``.
    """
    _validate_calibrator(calibrator, buffer_logit)
    lo, hi = _interval_from(range, op, value, lo_limit=0.0, hi_limit=1.0)
    if not (0.0 <= lo < hi <= 1.0):
        raise TargetError(f"calibrated interval [{lo}, {hi}] must lie within [0, 1]")
    return cls(
        space="calibrated", lo=lo, hi=hi, calibrator=calibrator, buffer_logit=buffer_logit
    )

probability(range=None, op=None, value=None) classmethod

Target on the model's own probability output.

If model outputs are post-hoc calibrated downstream, this constructor targets the uncalibrated model probability; use Target.calibrated with your calibrator instead. Requires a SIGMOID-link model — resolved lazily, at raw_interval time, not here. Specify exactly one of range=(lo, hi) or op=/value=, as in Target.raw.

Parameters:

Name Type Description Default
range tuple[float, float] | None

Explicit (lo, hi) bound within [0, 1].

None
op str | None

"<=" or ">=", paired with value; mutually exclusive with range.

None
value float | None

The threshold paired with op.

None

Returns:

Type Description
Target

A Target with space="probability".

Raises:

Type Description
TargetError

If neither or both of range and op/value are given, if op is not "<="/">=", or if the resulting interval is empty or falls outside [0, 1].

Source code in src/treecf/targets.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
@classmethod
def probability(
    cls,
    range: tuple[float, float] | None = None,
    op: str | None = None,
    value: float | None = None,
) -> Target:
    """Target on the model's own probability output.

    If model outputs are post-hoc calibrated downstream, this constructor
    targets the *uncalibrated* model probability; use ``Target.calibrated``
    with your calibrator instead. Requires a SIGMOID-link model — resolved
    lazily, at ``raw_interval`` time, not here. Specify exactly one of
    ``range=(lo, hi)`` or ``op=``/``value=``, as in ``Target.raw``.

    Args:
        range: Explicit ``(lo, hi)`` bound within ``[0, 1]``.
        op: ``"<="`` or ``">="``, paired with ``value``; mutually
            exclusive with ``range``.
        value: The threshold paired with ``op``.

    Returns:
        A ``Target`` with ``space="probability"``.

    Raises:
        TargetError: If neither or both of ``range`` and ``op``/``value``
            are given, if ``op`` is not ``"<="``/``">="``, or if the
            resulting interval is empty or falls outside ``[0, 1]``.
    """
    lo, hi = _interval_from(range, op, value, lo_limit=0.0, hi_limit=1.0)
    if not (0.0 <= lo < hi <= 1.0):
        raise TargetError(f"probability interval [{lo}, {hi}] must lie within [0, 1]")
    return cls(space="probability", lo=lo, hi=hi)

raw(range=None, op=None, value=None) classmethod

Target on the model's raw output (pre-link margin for a sigmoid model).

Specify exactly one of range=(lo, hi) or op=/value= (op="<=" or op=">=" with value, giving a half-open bound against -inf/+inf).

Parameters:

Name Type Description Default
range tuple[float, float] | None

Explicit (lo, hi) bound, lo < hi required.

None
op str | None

"<=" or ">=", paired with value; mutually exclusive with range.

None
value float | None

The threshold paired with op.

None

Returns:

Type Description
Target

A Target with space="raw".

Raises:

Type Description
TargetError

If neither or both of range and op/value are given, if op is not "<="/">=", or if the resulting interval is empty.

Source code in src/treecf/targets.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
@classmethod
def raw(
    cls,
    range: tuple[float, float] | None = None,
    op: str | None = None,
    value: float | None = None,
) -> Target:
    """Target on the model's raw output (pre-link margin for a sigmoid model).

    Specify exactly one of ``range=(lo, hi)`` or ``op=``/``value=``
    (``op="<="`` or ``op=">="`` with ``value``, giving a half-open bound
    against ``-inf``/``+inf``).

    Args:
        range: Explicit ``(lo, hi)`` bound, ``lo < hi`` required.
        op: ``"<="`` or ``">="``, paired with ``value``; mutually
            exclusive with ``range``.
        value: The threshold paired with ``op``.

    Returns:
        A ``Target`` with ``space="raw"``.

    Raises:
        TargetError: If neither or both of ``range`` and ``op``/``value``
            are given, if ``op`` is not ``"<="``/``">="``, or if the
            resulting interval is empty.
    """
    lo, hi = _interval_from(range, op, value, lo_limit=-math.inf, hi_limit=math.inf)
    return cls(space="raw", lo=lo, hi=hi)

raw_interval(link)

Resolve this target to an [L, U] interval on the model's raw score.

A space="raw" target returns (lo, hi) unchanged. A space="probability" target inverts through the logistic function. A space="calibrated" target inverts through calibrator's own generalized inverse in logit space, shrunk by buffer_logit first. Both non-raw spaces require link to be Link.SIGMOID — this is where that requirement is actually enforced, not at construction time.

Parameters:

Name Type Description Default
link Link

The model's link function (Explainer.ir.link).

required

Returns:

Type Description
tuple[float, float]

(lo, hi) on the raw score, lo <= hi.

Raises:

Type Description
TargetError

If space is "probability" or "calibrated" and link is not Link.SIGMOID, or if calibrator raises while inverting the interval.

Source code in src/treecf/targets.py
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
def raw_interval(self, link: Link) -> tuple[float, float]:
    """Resolve this target to an ``[L, U]`` interval on the model's raw score.

    A ``space="raw"`` target returns ``(lo, hi)`` unchanged. A
    ``space="probability"`` target inverts through the logistic function.
    A ``space="calibrated"`` target inverts through ``calibrator``'s own
    generalized inverse in logit space, shrunk by ``buffer_logit`` first.
    Both non-raw spaces require ``link`` to be ``Link.SIGMOID`` — this is
    where that requirement is actually enforced, not at construction time.

    Args:
        link: The model's link function (``Explainer.ir.link``).

    Returns:
        ``(lo, hi)`` on the raw score, ``lo <= hi``.

    Raises:
        TargetError: If ``space`` is ``"probability"`` or ``"calibrated"``
            and ``link`` is not ``Link.SIGMOID``, or if ``calibrator``
            raises while inverting the interval.
    """
    if self.space == "raw":
        return self.lo, self.hi
    if self.space == "calibrated":
        if link is not Link.SIGMOID:
            raise TargetError(
                "calibrated target requires a SIGMOID-link model; "
                "use Target.raw for identity-link outputs"
            )
        assert self.calibrator is not None
        try:
            return self.calibrator.interval_inverse(
                self.lo, self.hi, space="logit", buffer_logit=self.buffer_logit
            )
        except TargetError:
            raise
        except Exception as exc:
            raise TargetError(
                f"calibrator could not invert [{self.lo}, {self.hi}]: {exc}"
            ) from exc
    if link is not Link.SIGMOID:
        raise TargetError(
            "probability target requires a SIGMOID-link model; "
            "use Target.raw for identity-link outputs"
        )
    return _logit(self.lo), _logit(self.hi)

Constraints

Parse "2*a - b <= 3"-style sugar into a canonical Linear object.

Only linear expressions over +/-/* and one of <=/>=/ == are accepted; anything richer (nonlinear terms, multiple comparisons) must be written as constraint objects directly. Terms on both sides are folded into coefficients/rhs on the left-hand side's convention, so "a <= b" and "a - b <= 0" produce the same Linear. See Constraints.

Parameters:

Name Type Description Default
text str

The constraint string, e.g. "2*a - b <= 3" or "income >= 0".

required
feature_names Sequence[str] | None

When given, every identifier in text is validated against it immediately; when omitted (the default), unknown identifiers are only caught later, at Explainer/compile_constraints time.

None

Returns:

Type Description
Linear

The parsed Linear constraint.

Raises:

Type Description
ConstraintParseError

If text contains an unexpected character, is missing an operator or a term, has trailing tokens after the right-hand side, references no feature at all, or (when feature_names is given) references an identifier not in it. The message carries a caret marking the offending token.

Source code in src/treecf/constraints/parser.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def constraint(text: str, feature_names: Sequence[str] | None = None) -> Linear:
    """Parse ``"2*a - b <= 3"``-style sugar into a canonical ``Linear`` object.

    Only linear expressions over ``+``/``-``/``*`` and one of ``<=``/``>=``/
    ``==`` are accepted; anything richer (nonlinear terms, multiple
    comparisons) must be written as constraint objects directly. Terms on
    both sides are folded into ``coefficients``/``rhs`` on the left-hand
    side's convention, so ``"a <= b"`` and ``"a - b <= 0"`` produce the same
    ``Linear``. See [Constraints](concepts/constraints.md).

    Args:
        text: The constraint string, e.g. ``"2*a - b <= 3"`` or
            ``"income >= 0"``.
        feature_names: When given, every identifier in ``text`` is validated
            against it immediately; when omitted (the default), unknown
            identifiers are only caught later, at
            ``Explainer``/``compile_constraints`` time.

    Returns:
        The parsed ``Linear`` constraint.

    Raises:
        ConstraintParseError: If ``text`` contains an unexpected character, is
            missing an operator or a term, has trailing tokens after the
            right-hand side, references no feature at all, or (when
            ``feature_names`` is given) references an identifier not in it.
            The message carries a caret marking the offending token.
    """
    tokens = _tokenize(text)
    parser = _Parser(text, tokens, feature_names)
    return parser.parse()

Canonical constraint objects. Frozen dataclasses; validation at compile time.

Pass these (or a constraint() string) to Explainer(..., constraints=[...]). See Constraints for what each one compiles to and how they compose.

AllowMissing dataclass

NaN is a feasible counterfactual value for this feature.

delta_miss prices the value<->NaN transition; pass delta_from_miss for an asymmetric NaN->value cost (defaults to delta_miss). See Missing values.

Attributes:

Name Type Description
feature str

The feature name NaN is allowed on.

delta_miss float

Distance cost of a value-to-NaN change on this feature.

delta_from_miss float | None

Distance cost of a NaN-to-value change on this feature; defaults to delta_miss when None.

Source code in src/treecf/constraints/objects.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
@dataclass(frozen=True)
class AllowMissing:
    """NaN is a feasible counterfactual value for this feature.

    ``delta_miss`` prices the value<->NaN transition; pass ``delta_from_miss``
    for an asymmetric NaN->value cost (defaults to ``delta_miss``). See
    [Missing values](concepts/missing-values.md).

    Attributes:
        feature: The feature name NaN is allowed on.
        delta_miss: Distance cost of a value-to-NaN change on this feature.
        delta_from_miss: Distance cost of a NaN-to-value change on this
            feature; defaults to ``delta_miss`` when ``None``.
    """

    feature: str
    delta_miss: float
    delta_from_miss: float | None = None

Equals dataclass

Binary-feature equality (used standalone or inside Implies).

Attributes:

Name Type Description
feature str

The feature name to compare.

value float

The value feature must equal (typically 0.0/1.0 for a binary indicator).

Source code in src/treecf/constraints/objects.py
84
85
86
87
88
89
90
91
92
93
94
95
@dataclass(frozen=True)
class Equals:
    """Binary-feature equality (used standalone or inside ``Implies``).

    Attributes:
        feature: The feature name to compare.
        value: The value ``feature`` must equal (typically ``0.0``/``1.0``
            for a binary indicator).
    """

    feature: str
    value: float

Freeze dataclass

The feature is immutable: the counterfactual keeps the factual value.

Attributes:

Name Type Description
feature str

The feature name to freeze.

Source code in src/treecf/constraints/objects.py
13
14
15
16
17
18
19
20
21
@dataclass(frozen=True)
class Freeze:
    """The feature is immutable: the counterfactual keeps the factual value.

    Attributes:
        feature: The feature name to freeze.
    """

    feature: str

Implies dataclass

If condition holds then consequence must hold; binary features only.

Attributes:

Name Type Description
condition Equals

The antecedent equality.

consequence Equals

The equality condition requires when it holds.

Source code in src/treecf/constraints/objects.py
 98
 99
100
101
102
103
104
105
106
107
108
@dataclass(frozen=True)
class Implies:
    """If ``condition`` holds then ``consequence`` must hold; binary features only.

    Attributes:
        condition: The antecedent equality.
        consequence: The equality ``condition`` requires when it holds.
    """

    condition: Equals
    consequence: Equals

Linear dataclass

Linear inter-feature constraint: sum(coef * feature) op rhs.

missing_policy resolves the constraint when a referenced feature is NaN in the counterfactual: "satisfied" (vacuously true, the default), "violated"/"forbid_missing" (the counterfactual may not use NaN there). The exact backend supports single-feature and the canonical two-feature order-pair shape exactly; any other multi-feature shape raises ConstraintValidationError naming backend="genetic" as the fallback — see Certification.

Attributes:

Name Type Description
coefficients dict[str, float]

{feature: coefficient} for every feature in the sum; at least one entry.

op str

"<=", ">=", or "==".

rhs float

The right-hand-side constant.

missing_policy str

"satisfied" (the default — the constraint is vacuously satisfied when a referenced feature is NaN) or "violated"/"forbid_missing" (a NaN there fails the constraint, so the counterfactual may not use NaN on a referenced feature).

Source code in src/treecf/constraints/objects.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
@dataclass(frozen=True)
class Linear:
    """Linear inter-feature constraint: sum(coef * feature) op rhs.

    ``missing_policy`` resolves the constraint when a referenced feature is NaN
    in the counterfactual: "satisfied" (vacuously true, the default),
    "violated"/"forbid_missing" (the counterfactual may not use NaN there).
    The exact backend supports single-feature and the canonical two-feature
    order-pair shape exactly; any other multi-feature shape raises
    ``ConstraintValidationError`` naming ``backend="genetic"`` as the
    fallback — see
    [Certification](concepts/certification.md#what-the-exact-backend-does-not-certify-yet).

    Attributes:
        coefficients: ``{feature: coefficient}`` for every feature in the
            sum; at least one entry.
        op: ``"<="``, ``">="``, or ``"=="``.
        rhs: The right-hand-side constant.
        missing_policy: ``"satisfied"`` (the default — the constraint is
            vacuously satisfied when a referenced feature is NaN) or
            ``"violated"``/``"forbid_missing"`` (a NaN there fails the
            constraint, so the counterfactual may not use NaN on a referenced
            feature).
    """

    coefficients: dict[str, float]
    op: str  # "<=" | ">=" | "=="
    rhs: float
    missing_policy: str = "satisfied"

Monotone dataclass

The feature may only move in one direction from the factual value.

Attributes:

Name Type Description
feature str

The feature name to constrain.

direction str

"increase" (the counterfactual value must be >= the factual) or "decrease" (<= the factual).

Source code in src/treecf/constraints/objects.py
24
25
26
27
28
29
30
31
32
33
34
35
@dataclass(frozen=True)
class Monotone:
    """The feature may only move in one direction from the factual value.

    Attributes:
        feature: The feature name to constrain.
        direction: ``"increase"`` (the counterfactual value must be
            ``>=`` the factual) or ``"decrease"`` (``<=`` the factual).
    """

    feature: str
    direction: str  # "increase" | "decrease"

OneHot dataclass

The listed binary columns sum to exactly one.

Attributes:

Name Type Description
features tuple[str, ...]

The mutually exclusive binary feature names; at least two.

Source code in src/treecf/constraints/objects.py
111
112
113
114
115
116
117
118
119
@dataclass(frozen=True)
class OneHot:
    """The listed binary columns sum to exactly one.

    Attributes:
        features: The mutually exclusive binary feature names; at least two.
    """

    features: tuple[str, ...]

Range dataclass

Hard domain bounds for the counterfactual value (inclusive).

Attributes:

Name Type Description
feature str

The feature name to bound.

lo float

Lower bound, inclusive.

hi float

Upper bound, inclusive.

Source code in src/treecf/constraints/objects.py
38
39
40
41
42
43
44
45
46
47
48
49
50
@dataclass(frozen=True)
class Range:
    """Hard domain bounds for the counterfactual value (inclusive).

    Attributes:
        feature: The feature name to bound.
        lo: Lower bound, inclusive.
        hi: Upper bound, inclusive.
    """

    feature: str
    lo: float
    hi: float

Mining

Mine candidate invariants from a background sample, for human review.

Scans X for pairwise orders and equalities, binary implications (a=1 => b=1), one-hot groups, missingness links (miss(a) => miss(b)), and integer-valuedness; optionally also observed 1st/99th percentile ranges. Mined constraints are sample invariants, not domain truths — min_support=1.0 on a finite sample can be coincidence — so nothing here is ever auto-applied; inspect result[i].as_code() and pass the ones you accept to Explainer(..., constraints=[...]) yourself. See Constraints — mining candidates from data.

Parameters:

Name Type Description Default
X FloatArray

Background sample, one row per instance, aligned to feature_names (or the model's own feature order, if that is how feature_names was derived).

required
feature_names Sequence[str] | None

Column names for X; defaults to ["f0", "f1", ...] when omitted.

None
min_support float

Minimum fraction of co-present rows a pairwise order must hold on to be suggested ("order" kind only; every other kind requires exact 1.0 support). Defaults to 1.0 (only invariants with zero observed violations).

1.0
top_k int

Maximum number of suggestions to return, after ranking by support and rationale strength; findings are not subject to this limit.

50
report_threshold float

Minimum support for a near-invariant (one below min_support) to be reported as a DataQualityFinding instead of silently dropped.

0.999
include_ranges bool

When True, also emit one advisory "range" suggestion per feature with its observed 1st/99th percentile band (padded by 10%); these carry constraint=None.

False

Returns:

Type Description
SuggestionSet

A SuggestionSet with suggestions (candidate constraints,

SuggestionSet

ranked and trimmed to top_k) and findings (near-invariants

SuggestionSet

that fell short of min_support).

Source code in src/treecf/mining.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
def suggest_constraints(
    X: FloatArray,
    feature_names: Sequence[str] | None = None,
    min_support: float = 1.0,
    top_k: int = 50,
    report_threshold: float = 0.999,
    include_ranges: bool = False,
) -> SuggestionSet:
    """Mine candidate invariants from a background sample, for human review.

    Scans ``X`` for pairwise orders and equalities, binary implications
    (``a=1 => b=1``), one-hot groups, missingness links (``miss(a) =>
    miss(b)``), and integer-valuedness; optionally also observed 1st/99th
    percentile ranges. Mined constraints are sample invariants, not domain
    truths — ``min_support=1.0`` on a finite sample can be coincidence — so
    nothing here is ever auto-applied; inspect ``result[i].as_code()`` and
    pass the ones you accept to ``Explainer(..., constraints=[...])``
    yourself. See
    [Constraints — mining candidates from
    data](concepts/constraints.md#mining-candidates-from-data).

    Args:
        X: Background sample, one row per instance, aligned to
            ``feature_names`` (or the model's own feature order, if that is
            how ``feature_names`` was derived).
        feature_names: Column names for ``X``; defaults to ``["f0", "f1",
            ...]`` when omitted.
        min_support: Minimum fraction of co-present rows a pairwise order
            must hold on to be suggested (``"order"`` kind only; every other
            kind requires exact ``1.0`` support). Defaults to ``1.0`` (only
            invariants with zero observed violations).
        top_k: Maximum number of suggestions to return, after ranking by
            support and rationale strength; findings are not subject to this
            limit.
        report_threshold: Minimum support for a near-invariant (one below
            ``min_support``) to be reported as a ``DataQualityFinding``
            instead of silently dropped.
        include_ranges: When ``True``, also emit one advisory ``"range"``
            suggestion per feature with its observed 1st/99th percentile band
            (padded by 10%); these carry ``constraint=None``.

    Returns:
        A ``SuggestionSet`` with ``suggestions`` (candidate constraints,
        ranked and trimmed to ``top_k``) and ``findings`` (near-invariants
        that fell short of ``min_support``).
    """
    X = np.asarray(X, dtype=np.float64)
    n, p = X.shape
    names = list(feature_names) if feature_names is not None else [f"f{i}" for i in range(p)]
    present = ~np.isnan(X)

    binary = [
        j
        for j in range(p)
        if present[:, j].any() and np.isin(X[present[:, j], j], (0.0, 1.0)).all()
    ]
    binary_set = set(binary)

    suggestions: list[SuggestedConstraint] = []
    findings: list[DataQualityFinding] = []

    # --- pairwise order / equality (O(p^2 n), vectorized per anchor column) ---
    order_edges: dict[tuple[int, int], SuggestedConstraint] = {}
    equal_pairs: list[tuple[int, int]] = []
    for a in range(p):
        for b in range(a + 1, p):
            both = present[:, a] & present[:, b]
            n_both = int(both.sum())
            if n_both == 0:
                continue
            va, vb = X[both, a], X[both, b]
            viol_ab = int((va > vb).sum())  # violations of a <= b
            viol_ba = int((vb > va).sum())
            if viol_ab == 0 and viol_ba == 0:
                equal_pairs.append((a, b))
                suggestions.append(
                    SuggestedConstraint(
                        constraint=Linear({names[a]: 1.0, names[b]: -1.0}, op="==", rhs=0.0),
                        kind="equality",
                        support=1.0,
                        n_rows_checked=n_both,
                        n_violations=0,
                        rationale=f"{names[a]} == {names[b]} on every co-present row; "
                        "usually a redundant feature, not a constraint to impose",
                    )
                )
                continue
            for lo_idx, hi_idx, viol in ((a, b, viol_ab), (b, a, viol_ba)):
                support = 1.0 - viol / n_both
                if support >= min_support:
                    order_edges[(lo_idx, hi_idx)] = SuggestedConstraint(
                        constraint=Linear(
                            {names[lo_idx]: 1.0, names[hi_idx]: -1.0}, op="<=", rhs=0.0
                        ),
                        kind="order",
                        support=support,
                        n_rows_checked=n_both,
                        n_violations=viol,
                        evidence=_violation_evidence(X, present, lo_idx, hi_idx),
                        rationale=_order_rationale(names[lo_idx], names[hi_idx]),
                    )
                elif support >= report_threshold:
                    findings.append(
                        DataQualityFinding(
                            kind="near_invariant",
                            description=f"{names[lo_idx]} <= {names[hi_idx]} holds on "
                            f"{support:.4%} of rows — likely an ETL defect",
                            support=support,
                            n_rows_checked=n_both,
                            n_violations=viol,
                            evidence=_violation_evidence(X, present, lo_idx, hi_idx),
                        )
                    )

    # equality-class collapse, then transitive reduction of the <= graph
    representative = _union_find(p, equal_pairs)
    rep_edges = {
        (representative[a], representative[b])
        for (a, b) in order_edges
        if representative[a] != representative[b]
    }
    reduced = transitive_reduction(rep_edges)
    for (a, b), suggestion in order_edges.items():
        edge = (representative[a], representative[b])
        if edge in reduced and edge[0] != edge[1]:
            suggestions.append(suggestion)
            reduced.discard(edge)  # one edge per class pair

    # --- binary implications A=1 => B=1 ---
    for a in binary:
        a_is_one = present[:, a] & (X[:, a] == 1.0)
        if not a_is_one.any():
            continue
        for b in binary:
            if a == b:
                continue
            checked = a_is_one & present[:, b]
            if not checked.any():
                continue
            if (X[checked, b] == 1.0).all():
                suggestions.append(
                    SuggestedConstraint(
                        constraint=Implies(Equals(names[a], 1.0), Equals(names[b], 1.0)),
                        kind="implication",
                        support=1.0,
                        n_rows_checked=int(checked.sum()),
                        n_violations=0,
                        rationale=f"{names[a]}=1 always co-occurs with {names[b]}=1",
                    )
                )

    # --- one-hot groups: exclusivity components with row sum == 1 ---
    complete_binary = [j for j in binary if present[:, j].all()]
    for component in _exclusivity_components(X, complete_binary):
        if len(component) < 2:
            continue
        if np.all(X[:, component].sum(axis=1) == 1.0):
            suggestions.append(
                SuggestedConstraint(
                    constraint=OneHot(tuple(names[j] for j in component)),
                    kind="onehot",
                    support=1.0,
                    n_rows_checked=n,
                    n_violations=0,
                    rationale="binary columns with row sum identically 1",
                )
            )

    # --- missingness links miss(A) => miss(B) ---
    for a in range(p):
        miss_a = ~present[:, a]
        if not miss_a.any():
            continue
        for b in range(p):
            if a == b or present[:, b].all():
                continue
            if (~present[miss_a, b]).all():
                both_ways = bool((~present[~present[:, b], a]).all())
                suggestions.append(
                    SuggestedConstraint(
                        constraint=None,
                        kind="missing_link",
                        support=1.0,
                        n_rows_checked=int(miss_a.sum()),
                        n_violations=0,
                        rationale=(
                            f"miss({names[a]}) {'<=>' if both_ways else '=>'} miss({names[b]}); "
                            "consider joint AllowMissing / missing_policy"
                        ),
                    )
                )
            if bool((~present[~present[:, b], a]).all()):
                break  # symmetric link already reported from this anchor

    # --- integer-valuedness -> value_policy suggestion ---
    for j in range(p):
        col = X[present[:, j], j]
        if len(col) and j not in binary_set and np.all(col == np.round(col)):
            suggestions.append(
                SuggestedConstraint(
                    constraint=None,
                    kind="integer",
                    support=1.0,
                    n_rows_checked=len(col),
                    n_violations=0,
                    rationale=(
                        f"{names[j]} is integer-valued; "
                        f'value_policy={{"{names[j]}": "integer"}}'
                    ),
                )
            )

    if include_ranges:
        for j in range(p):
            col = X[present[:, j], j]
            if len(col):
                lo, hi = np.percentile(col, [1, 99])
                pad = 0.1 * (hi - lo)
                suggestions.append(
                    SuggestedConstraint(
                        constraint=None,
                        kind="range",
                        support=1.0,
                        n_rows_checked=len(col),
                        n_violations=0,
                        rationale=f"observed 1-99% range [{lo:.4g}, {hi:.4g}] padded by {pad:.4g}",
                    )
                )

    suggestions.sort(key=_rank_key, reverse=True)
    return SuggestionSet(suggestions=tuple(suggestions[:top_k]), findings=tuple(findings))

One candidate invariant mined from a background sample, for human review.

suggest_constraints never applies a suggestion itself; the workflow is to inspect as_code()/rationale/evidence, decide which suggestions are real domain rules, and pass their constraint objects to Explainer(..., constraints=[...]) explicitly. See Constraints — mining candidates from data.

Attributes:

Name Type Description
constraint Constraint | None

The compiled constraint object this suggestion proposes, or None for an advisory kind ("missing_link", "integer", "range") that has no direct constraint-object form — read rationale for what to do about it instead.

kind str

"order" (a <= b on every co-present row), "equality" (a == b, usually a redundant feature), "implication" (a=1 => b=1 on binary features), "onehot" (mutually exclusive binary group), "missing_link" (miss(a) => miss(b) or <=>), "integer" (integer-valued column, a value_policy candidate), or "range" (observed 1–99th percentile band, only when include_ranges=True).

support float

Fraction of checked rows the invariant held on, in [0, 1]; 1.0 for every kind except "order", which can be suggested down to min_support.

n_rows_checked int

Number of rows the check was evaluated over; what counts as checkable depends on kind — co-present rows for "order"/"equality", rows where the antecedent holds and the consequent feature is present for "implication", rows where a is missing for "missing_link", present rows for "integer"/"range".

n_violations int

Number of those rows that violated the invariant; 0 for every kind except "order".

evidence list[dict[str, object]]

Up to 5 violating rows, {"row": index, "values": (a, b)} — populated for "order" suggestions only.

rationale str

Human-readable justification: shared name tokens for "order", or the advisory text for kinds with no constraint.

Source code in src/treecf/mining.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
@dataclass(frozen=True)
class SuggestedConstraint:
    """One candidate invariant mined from a background sample, for human review.

    ``suggest_constraints`` never applies a suggestion itself; the workflow is
    to inspect ``as_code()``/``rationale``/``evidence``, decide which
    suggestions are real domain rules, and pass their ``constraint`` objects
    to ``Explainer(..., constraints=[...])`` explicitly. See
    [Constraints — mining candidates from
    data](concepts/constraints.md#mining-candidates-from-data).

    Attributes:
        constraint: The compiled constraint object this suggestion proposes,
            or ``None`` for an advisory ``kind`` (``"missing_link"``,
            ``"integer"``, ``"range"``) that has no direct constraint-object
            form — read ``rationale`` for what to do about it instead.
        kind: ``"order"`` (``a <= b`` on every co-present row), ``"equality"``
            (``a == b``, usually a redundant feature), ``"implication"``
            (``a=1 => b=1`` on binary features), ``"onehot"`` (mutually
            exclusive binary group), ``"missing_link"`` (``miss(a) => miss(b)``
            or ``<=>``), ``"integer"`` (integer-valued column, a
            ``value_policy`` candidate), or ``"range"`` (observed 1–99th
            percentile band, only when ``include_ranges=True``).
        support: Fraction of checked rows the invariant held on, in
            ``[0, 1]``; ``1.0`` for every kind except ``"order"``, which can
            be suggested down to ``min_support``.
        n_rows_checked: Number of rows the check was evaluated over; what
            counts as checkable depends on ``kind`` — co-present rows for
            ``"order"``/``"equality"``, rows where the antecedent holds and
            the consequent feature is present for ``"implication"``, rows
            where ``a`` is missing for ``"missing_link"``, present rows for
            ``"integer"``/``"range"``.
        n_violations: Number of those rows that violated the invariant;
            ``0`` for every kind except ``"order"``.
        evidence: Up to 5 violating rows, ``{"row": index, "values": (a, b)}``
            — populated for ``"order"`` suggestions only.
        rationale: Human-readable justification: shared name tokens for
            ``"order"``, or the advisory text for kinds with no
            ``constraint``.
    """

    constraint: Constraint | None  # None for advisory kinds (missing_link, integer)
    kind: str  # "order" | "equality" | "implication" | "onehot" | "missing_link" | "integer"
    support: float
    n_rows_checked: int
    n_violations: int
    evidence: list[dict[str, object]] = field(default_factory=list)
    rationale: str = ""

    def as_code(self) -> str:
        """This suggestion rendered as a copy-pasteable Python snippet.

        Returns:
            A ``constraint(...)``/``Implies(...)``/``OneHot(...)`` call (or,
            for kinds with no direct constraint form, a ``#``-commented
            description) followed by a ``# support=..., n=...`` trailer.
        """
        tail = f"  # support={self.support:.4f}, n={self.n_rows_checked}"
        if self.kind == "order" and isinstance(self.constraint, Linear):
            coeffs = self.constraint.coefficients
            smaller = max(coeffs, key=lambda k: coeffs[k])
            larger = min(coeffs, key=lambda k: coeffs[k])
            return f'constraint("{smaller} <= {larger}")' + tail
        if self.kind == "equality" and isinstance(self.constraint, Linear):
            a, b = list(self.constraint.coefficients)
            return f'# equality: {a} == {b} — likely a redundant feature' + tail
        if self.kind == "implication" and isinstance(self.constraint, Implies):
            c = self.constraint
            return (
                f'Implies(Equals("{c.condition.feature}", {c.condition.value:g}), '
                f'Equals("{c.consequence.feature}", {c.consequence.value:g}))' + tail
            )
        if self.kind == "onehot" and isinstance(self.constraint, OneHot):
            inner = ", ".join(f'"{f}"' for f in self.constraint.features)
            return f"OneHot(({inner}))" + tail
        return f"# {self.kind}: {self.rationale}" + tail

as_code()

This suggestion rendered as a copy-pasteable Python snippet.

Returns:

Type Description
str

A constraint(...)/Implies(...)/OneHot(...) call (or,

str

for kinds with no direct constraint form, a #-commented

str

description) followed by a # support=..., n=... trailer.

Source code in src/treecf/mining.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def as_code(self) -> str:
    """This suggestion rendered as a copy-pasteable Python snippet.

    Returns:
        A ``constraint(...)``/``Implies(...)``/``OneHot(...)`` call (or,
        for kinds with no direct constraint form, a ``#``-commented
        description) followed by a ``# support=..., n=...`` trailer.
    """
    tail = f"  # support={self.support:.4f}, n={self.n_rows_checked}"
    if self.kind == "order" and isinstance(self.constraint, Linear):
        coeffs = self.constraint.coefficients
        smaller = max(coeffs, key=lambda k: coeffs[k])
        larger = min(coeffs, key=lambda k: coeffs[k])
        return f'constraint("{smaller} <= {larger}")' + tail
    if self.kind == "equality" and isinstance(self.constraint, Linear):
        a, b = list(self.constraint.coefficients)
        return f'# equality: {a} == {b} — likely a redundant feature' + tail
    if self.kind == "implication" and isinstance(self.constraint, Implies):
        c = self.constraint
        return (
            f'Implies(Equals("{c.condition.feature}", {c.condition.value:g}), '
            f'Equals("{c.consequence.feature}", {c.consequence.value:g}))' + tail
        )
    if self.kind == "onehot" and isinstance(self.constraint, OneHot):
        inner = ", ".join(f'"{f}"' for f in self.constraint.features)
        return f"OneHot(({inner}))" + tail
    return f"# {self.kind}: {self.rationale}" + tail

A near-invariant that fell short of min_support — likely an ETL defect.

Reported separately from SuggestedConstraint because support in [report_threshold, min_support) usually means a rule that should be universal is being violated by a small number of dirty rows, not that the rule is genuinely conditional — worth fixing upstream rather than encoding the exception as a constraint.

Attributes:

Name Type Description
kind str

Always "near_invariant" in this release.

description str

Human-readable summary, e.g. "a <= b holds on 99.95% of rows — likely an ETL defect".

support float

Fraction of checked rows the near-invariant held on, in [report_threshold, min_support).

n_rows_checked int

Number of rows where both referenced features were present.

n_violations int

Number of those rows that violated the near-invariant.

evidence list[dict[str, object]]

Up to 5 violating rows, {"row": index, "values": (a, b)}.

Source code in src/treecf/mining.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
@dataclass(frozen=True)
class DataQualityFinding:
    """A near-invariant that fell short of ``min_support`` — likely an ETL defect.

    Reported separately from ``SuggestedConstraint`` because support in
    ``[report_threshold, min_support)`` usually means a rule that should be
    universal is being violated by a small number of dirty rows, not that the
    rule is genuinely conditional — worth fixing upstream rather than
    encoding the exception as a constraint.

    Attributes:
        kind: Always ``"near_invariant"`` in this release.
        description: Human-readable summary, e.g. ``"a <= b holds on 99.95%
            of rows — likely an ETL defect"``.
        support: Fraction of checked rows the near-invariant held on, in
            ``[report_threshold, min_support)``.
        n_rows_checked: Number of rows where both referenced features were
            present.
        n_violations: Number of those rows that violated the near-invariant.
        evidence: Up to 5 violating rows, ``{"row": index, "values": (a, b)}``.
    """

    kind: str  # "near_invariant"
    description: str
    support: float
    n_rows_checked: int
    n_violations: int
    evidence: list[dict[str, object]] = field(default_factory=list)

Plausibility

A hard isolation-forest bound keeping counterfactuals inside the data manifold.

Construct through Plausibility.isolation_forest rather than the constructor directly. Pass the result as Explainer(..., plausibility=...); every returned counterfactual then also satisfies anomaly_score(x_cf) <= max_anomaly_score, enforced as a hard constraint by every backend. Cannot be combined with AllowMissing or a NaN-containing factual (isolation forests define no NaN routing). See Plausibility.

Attributes:

Name Type Description
if_ir EnsembleIR

The isolation forest, parsed through the same tree IR the model uses.

max_anomaly_score float

The upper bound on anomaly_score; lower values are stricter (closer to the training distribution).

Source code in src/treecf/plausibility.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
@dataclass(frozen=True)
class Plausibility:
    """A hard isolation-forest bound keeping counterfactuals inside the data manifold.

    Construct through ``Plausibility.isolation_forest`` rather than the
    constructor directly. Pass the result as ``Explainer(...,
    plausibility=...)``; every returned counterfactual then also satisfies
    ``anomaly_score(x_cf) <= max_anomaly_score``, enforced as a hard
    constraint by every backend. Cannot be combined with ``AllowMissing`` or a
    NaN-containing factual (isolation forests define no NaN routing). See
    [Plausibility](concepts/plausibility.md).

    Attributes:
        if_ir: The isolation forest, parsed through the same tree IR the
            model uses.
        max_anomaly_score: The upper bound on ``anomaly_score``; lower values
            are stricter (closer to the training distribution).
    """

    if_ir: EnsembleIR
    max_anomaly_score: float

    @classmethod
    def isolation_forest(
        cls, model_or_ir: object, max_anomaly_score: float = 0.55
    ) -> Plausibility:
        """Build a ``Plausibility`` bound from a fitted isolation forest.

        Args:
            model_or_ir: A native isolation-forest model (currently
                sklearn's ``IsolationForest``) or an already-parsed
                ``EnsembleIR``.
            max_anomaly_score: Upper bound on the isolation-forest anomaly
                score, in ``(0, 1)``; lower is a stricter plausibility
                requirement. Defaults to ``0.55``.

        Returns:
            A ``Plausibility`` wrapping the parsed forest.

        Raises:
            TreecfError: If ``max_anomaly_score`` is not in ``(0, 1)``.
        """
        if not 0.0 < max_anomaly_score < 1.0:
            raise TreecfError("max_anomaly_score must lie in (0, 1)")
        if isinstance(model_or_ir, EnsembleIR):
            if_ir = model_or_ir
        else:
            from treecf.ir.parsers.sklearn import parse_isolation_forest

            if_ir = parse_isolation_forest(model_or_ir)
        return cls(if_ir=if_ir, max_anomaly_score=max_anomaly_score)

    @property
    def normalizer(self) -> float:
        """The average path length ``c(n)`` for the forest's subsample size ``n``.

        Standard isolation-forest normalizer, derived from ``if_ir``'s
        ``max_samples`` metadata; used to turn a raw total path length into
        the ``[0, 1]`` anomaly score.
        """
        from treecf.ir.parsers.sklearn import _avg_path

        return _avg_path(float(self.if_ir.meta["max_samples"]))  # type: ignore[arg-type]

    @property
    def min_total_path(self) -> float:
        """The feasibility bound compiled into every backend's plausibility check.

        Equivalent to ``max_anomaly_score`` re-expressed as a lower bound on
        the summed depth-adjusted path length across every tree: a
        counterfactual is plausible iff its total path length is at least
        this value.
        """
        n_trees = len(self.if_ir.trees)
        return -n_trees * self.normalizer * math.log2(self.max_anomaly_score)

    def anomaly_score(self, x: FloatArray) -> float:
        """The isolation-forest anomaly score at ``x``, in ``[0, 1]``.

        ``2 ** (-mean_path / normalizer)``: close to ``1`` for a point the
        forest isolates in very few splits (anomalous), close to ``0`` for
        one that takes many (typical). A point is plausible under this bound
        iff its score is ``<= max_anomaly_score``.

        Args:
            x: A feature vector, aligned to the forest's feature order.

        Returns:
            The anomaly score at ``x``.
        """
        total = raw_score(self.if_ir, np.asarray(x, dtype=np.float64))
        mean_path = total / len(self.if_ir.trees)
        return float(2.0 ** (-mean_path / self.normalizer))

min_total_path property

The feasibility bound compiled into every backend's plausibility check.

Equivalent to max_anomaly_score re-expressed as a lower bound on the summed depth-adjusted path length across every tree: a counterfactual is plausible iff its total path length is at least this value.

normalizer property

The average path length c(n) for the forest's subsample size n.

Standard isolation-forest normalizer, derived from if_ir's max_samples metadata; used to turn a raw total path length into the [0, 1] anomaly score.

anomaly_score(x)

The isolation-forest anomaly score at x, in [0, 1].

2 ** (-mean_path / normalizer): close to 1 for a point the forest isolates in very few splits (anomalous), close to 0 for one that takes many (typical). A point is plausible under this bound iff its score is <= max_anomaly_score.

Parameters:

Name Type Description Default
x FloatArray

A feature vector, aligned to the forest's feature order.

required

Returns:

Type Description
float

The anomaly score at x.

Source code in src/treecf/plausibility.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def anomaly_score(self, x: FloatArray) -> float:
    """The isolation-forest anomaly score at ``x``, in ``[0, 1]``.

    ``2 ** (-mean_path / normalizer)``: close to ``1`` for a point the
    forest isolates in very few splits (anomalous), close to ``0`` for
    one that takes many (typical). A point is plausible under this bound
    iff its score is ``<= max_anomaly_score``.

    Args:
        x: A feature vector, aligned to the forest's feature order.

    Returns:
        The anomaly score at ``x``.
    """
    total = raw_score(self.if_ir, np.asarray(x, dtype=np.float64))
    mean_path = total / len(self.if_ir.trees)
    return float(2.0 ** (-mean_path / self.normalizer))

isolation_forest(model_or_ir, max_anomaly_score=0.55) classmethod

Build a Plausibility bound from a fitted isolation forest.

Parameters:

Name Type Description Default
model_or_ir object

A native isolation-forest model (currently sklearn's IsolationForest) or an already-parsed EnsembleIR.

required
max_anomaly_score float

Upper bound on the isolation-forest anomaly score, in (0, 1); lower is a stricter plausibility requirement. Defaults to 0.55.

0.55

Returns:

Type Description
Plausibility

A Plausibility wrapping the parsed forest.

Raises:

Type Description
TreecfError

If max_anomaly_score is not in (0, 1).

Source code in src/treecf/plausibility.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@classmethod
def isolation_forest(
    cls, model_or_ir: object, max_anomaly_score: float = 0.55
) -> Plausibility:
    """Build a ``Plausibility`` bound from a fitted isolation forest.

    Args:
        model_or_ir: A native isolation-forest model (currently
            sklearn's ``IsolationForest``) or an already-parsed
            ``EnsembleIR``.
        max_anomaly_score: Upper bound on the isolation-forest anomaly
            score, in ``(0, 1)``; lower is a stricter plausibility
            requirement. Defaults to ``0.55``.

    Returns:
        A ``Plausibility`` wrapping the parsed forest.

    Raises:
        TreecfError: If ``max_anomaly_score`` is not in ``(0, 1)``.
    """
    if not 0.0 < max_anomaly_score < 1.0:
        raise TreecfError("max_anomaly_score must lie in (0, 1)")
    if isinstance(model_or_ir, EnsembleIR):
        if_ir = model_or_ir
    else:
        from treecf.ir.parsers.sklearn import parse_isolation_forest

        if_ir = parse_isolation_forest(model_or_ir)
    return cls(if_ir=if_ir, max_anomaly_score=max_anomaly_score)

Regions

A certified box around one verified counterfactual.

Every point z with lo <= z <= hi coordinate-wise (z_j = x_cf_j at a degenerate or NaN coordinate) is provably in-target, plausible when plausibility is configured, and feasible against every compiled constraint -- the same guarantees the counterfactual itself carries, not a heuristic neighbourhood around it.

lo/hi cover every feature (degenerate coordinates included, as a single point); feature_intervals keys only the non-degenerate ones by name, for display. Regions are certified but neither maximal (a larger sound box may exist) nor monotone in the target interval (a strictly narrower target can still produce a strictly wider region on some feature: growth is greedy and order-dependent, so a feature that is forced to stop early frees room a later feature grows into). See Certification.

Attributes:

Name Type Description
lo FloatArray

Lower bound per feature, same order as the model's features; equal to hi at a degenerate (never-widened) coordinate.

hi FloatArray

Upper bound per feature, same order as the model's features.

feature_intervals dict[str, tuple[float, float]]

{feature: (lo, hi)} for every non-degenerate feature only, for display (describe() renders these as phrases).

certified bool

Always True in this release — every region returned by Explainer.recourse_region/explain(..., region=True) is a sound certificate; the field is reserved for a future relaxed mode.

Source code in src/treecf/regions.py
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
@dataclass(frozen=True)
class RecourseRegion:
    """A certified box around one verified counterfactual.

    Every point ``z`` with ``lo <= z <= hi`` coordinate-wise (``z_j = x_cf_j``
    at a degenerate or NaN coordinate) is provably in-target, plausible when
    plausibility is configured, and feasible against every compiled
    constraint -- the same guarantees the counterfactual itself carries, not
    a heuristic neighbourhood around it.

    ``lo``/``hi`` cover every feature (degenerate coordinates included, as a
    single point); ``feature_intervals`` keys only the non-degenerate ones by
    name, for display. Regions are certified but neither maximal (a larger
    sound box may exist) nor monotone in the target interval (a strictly
    narrower target can still produce a strictly wider region on some
    feature: growth is greedy and order-dependent, so a feature that is
    forced to stop early frees room a later feature grows into). See
    [Certification](concepts/certification.md#regions-certified-not-maximal-not-monotone).

    Attributes:
        lo: Lower bound per feature, same order as the model's features;
            equal to ``hi`` at a degenerate (never-widened) coordinate.
        hi: Upper bound per feature, same order as the model's features.
        feature_intervals: ``{feature: (lo, hi)}`` for every non-degenerate
            feature only, for display (``describe()`` renders these as
            phrases).
        certified: Always ``True`` in this release — every region returned
            by ``Explainer.recourse_region``/``explain(..., region=True)`` is
            a sound certificate; the field is reserved for a future relaxed
            mode.
    """

    lo: FloatArray
    hi: FloatArray
    feature_intervals: dict[str, tuple[float, float]]
    certified: bool  # always True in this release; the field is reserved

    def contains(self, x: FloatArray) -> bool:
        """Whether ``x`` lies inside the region, coordinate by coordinate.

        A degenerate coordinate (``lo == hi``, including NaN) requires ``x``
        to match it exactly; every other coordinate requires
        ``lo <= x[j] <= hi[j]``.

        Args:
            x: A feature vector, same order and length as the region.

        Returns:
            ``True`` iff every coordinate of ``x`` satisfies the region's
            bound.
        """
        for j in range(len(self.lo)):
            xj = float(x[j])
            if math.isnan(self.lo[j]):
                if not math.isnan(xj):
                    return False
                continue
            if math.isnan(xj) or not (self.lo[j] <= xj <= self.hi[j]):
                return False
        return True

    def describe(self) -> dict[str, str]:
        """One human-readable phrase per non-degenerate feature.

        One-sided (``"<= v"``/``">= v"``) when the other endpoint is
        infinite, two-sided (``"in [lo, hi]"``) otherwise, and
        ``"unconstrained"`` when both endpoints are infinite; values
        formatted ``"{:.3g}"``.

        Returns:
            ``{feature: phrase}`` for every key of ``feature_intervals``.
        """
        out: dict[str, str] = {}
        for name, (lo, hi) in self.feature_intervals.items():
            if lo == -math.inf and hi == math.inf:
                out[name] = "unconstrained"
            elif lo == -math.inf:
                out[name] = f"≤ {hi:.3g}"
            elif hi == math.inf:
                out[name] = f"≥ {lo:.3g}"
            else:
                out[name] = f"in [{lo:.3g}, {hi:.3g}]"
        return out

contains(x)

Whether x lies inside the region, coordinate by coordinate.

A degenerate coordinate (lo == hi, including NaN) requires x to match it exactly; every other coordinate requires lo <= x[j] <= hi[j].

Parameters:

Name Type Description Default
x FloatArray

A feature vector, same order and length as the region.

required

Returns:

Type Description
bool

True iff every coordinate of x satisfies the region's

bool

bound.

Source code in src/treecf/regions.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def contains(self, x: FloatArray) -> bool:
    """Whether ``x`` lies inside the region, coordinate by coordinate.

    A degenerate coordinate (``lo == hi``, including NaN) requires ``x``
    to match it exactly; every other coordinate requires
    ``lo <= x[j] <= hi[j]``.

    Args:
        x: A feature vector, same order and length as the region.

    Returns:
        ``True`` iff every coordinate of ``x`` satisfies the region's
        bound.
    """
    for j in range(len(self.lo)):
        xj = float(x[j])
        if math.isnan(self.lo[j]):
            if not math.isnan(xj):
                return False
            continue
        if math.isnan(xj) or not (self.lo[j] <= xj <= self.hi[j]):
            return False
    return True

describe()

One human-readable phrase per non-degenerate feature.

One-sided ("<= v"/">= v") when the other endpoint is infinite, two-sided ("in [lo, hi]") otherwise, and "unconstrained" when both endpoints are infinite; values formatted "{:.3g}".

Returns:

Type Description
dict[str, str]

{feature: phrase} for every key of feature_intervals.

Source code in src/treecf/regions.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
def describe(self) -> dict[str, str]:
    """One human-readable phrase per non-degenerate feature.

    One-sided (``"<= v"``/``">= v"``) when the other endpoint is
    infinite, two-sided (``"in [lo, hi]"``) otherwise, and
    ``"unconstrained"`` when both endpoints are infinite; values
    formatted ``"{:.3g}"``.

    Returns:
        ``{feature: phrase}`` for every key of ``feature_intervals``.
    """
    out: dict[str, str] = {}
    for name, (lo, hi) in self.feature_intervals.items():
        if lo == -math.inf and hi == math.inf:
            out[name] = "unconstrained"
        elif lo == -math.inf:
            out[name] = f"≤ {hi:.3g}"
        elif hi == math.inf:
            out[name] = f"≥ {lo:.3g}"
        else:
            out[name] = f"in [{lo:.3g}, {hi:.3g}]"
    return out

Audit

SHA-256 fingerprint of an ensemble over a canonical byte encoding.

The encoding is positional bytes — the link name, the base score, the tree count, then every node of every tree in index order with fixed-width little-endian fields and fixed sentinel bytes where a field does not apply to the node kind — so the fingerprint is stable across Python versions, platforms, and dict ordering, and changes when any structural or numeric detail of the ensemble changes (a one-ulp leaf perturbation included).

Parameters:

Name Type Description Default
ir EnsembleIR

The parsed ensemble to fingerprint (Explainer.ir).

required

Returns:

Type Description
str

A 64-character SHA-256 hex digest.

Source code in src/treecf/audit.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
def ir_fingerprint(ir: EnsembleIR) -> str:
    """SHA-256 fingerprint of an ensemble over a canonical byte encoding.

    The encoding is positional bytes — the link name, the base score, the
    tree count, then every node of every tree in index order with fixed-width
    little-endian fields and fixed sentinel bytes where a field does not
    apply to the node kind — so the fingerprint is stable across Python
    versions, platforms, and dict ordering, and changes when any structural
    or numeric detail of the ensemble changes (a one-ulp leaf perturbation
    included).

    Args:
        ir: The parsed ensemble to fingerprint (``Explainer.ir``).

    Returns:
        A 64-character SHA-256 hex digest.
    """
    hasher = hashlib.sha256()
    hasher.update(ir.link.name.encode("utf-8") + b"\x00")
    hasher.update(struct.pack("<d", ir.base_score))
    hasher.update(struct.pack("<I", len(ir.trees)))
    for tree in ir.trees:
        hasher.update(struct.pack("<I", len(tree.nodes)))
        for node in tree.nodes:
            if node.feature is None:  # leaf
                assert node.value is not None
                hasher.update(b"\x00" + struct.pack("<I", _NONE_U32) + _NONE_F64)
                hasher.update(b"\x00\x02")  # op / missing_left sentinels
                hasher.update(struct.pack("<II", _NONE_U32, _NONE_U32))
                hasher.update(struct.pack("<d", node.value))
            else:
                assert node.threshold is not None and node.op is not None
                assert node.missing_left is not None
                assert node.left is not None and node.right is not None
                hasher.update(b"\x01" + struct.pack("<I", node.feature))
                hasher.update(struct.pack("<d", node.threshold))
                hasher.update(
                    bytes((1 if node.op is SplitOp.LT else 2, 1 if node.missing_left else 0))
                )
                hasher.update(struct.pack("<II", node.left, node.right))
                hasher.update(_NONE_F64)
    return hasher.hexdigest()

SHA-256 fingerprint of an explainer's effective objective and constraints.

Covers the compiled constraint set (type tags, resolved feature indices, parameters), the distance normalizers sigma, the per-feature weights, and every value-policy entry, all as canonical little-endian bytes. A callable value policy has no canonical encoding: it is hashed as a fixed unhashable_custom tag, and any certificate built from the explainer records "reproducible": false with a reason.

Parameters:

Name Type Description Default
explainer Explainer

The explainer whose constraint set to fingerprint.

required

Returns:

Type Description
str

A 64-character SHA-256 hex digest.

Source code in src/treecf/audit.py
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
def constraints_fingerprint(explainer: Explainer) -> str:
    """SHA-256 fingerprint of an explainer's effective objective and constraints.

    Covers the compiled constraint set (type tags, resolved feature indices,
    parameters), the distance normalizers ``sigma``, the per-feature
    ``weights``, and every value-policy entry, all as canonical little-endian
    bytes. A callable value policy has no canonical encoding: it is hashed as
    a fixed ``unhashable_custom`` tag, and any certificate built from the
    explainer records ``"reproducible": false`` with a reason.

    Args:
        explainer: The explainer whose constraint set to fingerprint.

    Returns:
        A 64-character SHA-256 hex digest.
    """
    encoding, _ = _constraints_encoding(explainer)
    return hashlib.sha256(encoding).hexdigest()

Visualization

Counterfactual visualizations. matplotlib lives behind the [viz] extra.

plot_alternatives(results, explainer=None, ax=None)

Overlaid dumbbells: every alternative plan's changes for one instance.

Accepts a sequence of Counterfactual objects or feasible BatchRecord entries, or a mapping of outcomes as returned by explain_coalitions (keys become legend labels; Infeasible values are skipped). Each plan keeps one color across all its changes — meant for a handful of alternatives for the same row (at most 10). With explainer, changes are plotted as standardized deltas from the factual (Δ/σ), so features of different scales share one axis; without, raw values are shown with gray factual dots.

Parameters:

Name Type Description Default
results Any

The plans to overlay — a sequence, or a mapping keyed by plan name; see above for accepted element types.

required
explainer Any

When given, changes are standardized by its per-feature sigma; when omitted, raw feature values are plotted instead.

None
ax Any

Existing axes to draw on; a new figure is created if omitted.

None

Returns:

Type Description
Any

The axes the chart was drawn on.

Raises:

Type Description
MissingExtraError

If matplotlib is not installed.

TreecfError

If results contains no feasible plans, or more than 10.

Source code in src/treecf/viz.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
def plot_alternatives(results: Any, explainer: Any = None, ax: Any = None) -> Any:
    """Overlaid dumbbells: every alternative plan's changes for one instance.

    Accepts a sequence of ``Counterfactual`` objects or feasible
    ``BatchRecord`` entries, or a mapping of outcomes as returned by
    ``explain_coalitions`` (keys become legend labels; ``Infeasible`` values
    are skipped). Each plan keeps one color across all its changes — meant
    for a handful of alternatives for the same row (at most 10). With
    ``explainer``, changes are plotted as standardized deltas from the
    factual (Δ/σ), so features of different scales share one axis; without,
    raw values are shown with gray factual dots.

    Args:
        results: The plans to overlay — a sequence, or a mapping keyed by
            plan name; see above for accepted element types.
        explainer: When given, changes are standardized by its per-feature
            ``sigma``; when omitted, raw feature values are plotted instead.
        ax: Existing axes to draw on; a new figure is created if omitted.

    Returns:
        The axes the chart was drawn on.

    Raises:
        MissingExtraError: If matplotlib is not installed.
        TreecfError: If ``results`` contains no feasible plans, or more than
            10.
    """
    plt = _import_pyplot()
    plans = _plans_with_labels(results)
    if not plans:
        raise TreecfError("no feasible plans to plot")
    if len(plans) > 10:
        raise TreecfError("plot_alternatives compares at most 10 plans")
    sigma: dict[str, float] = {}
    if explainer is not None:
        sigma = {
            name: float(s)
            for name, s in zip(explainer.ir.feature_names, explainer.sigma, strict=True)
        }
    frequency: dict[str, int] = {}
    for _, plan in plans:
        for name in plan.changes:
            frequency[name] = frequency.get(name, 0) + 1
    features = sorted(frequency, key=lambda name: (-frequency[name], name))
    slots = {name: i for i, name in enumerate(features)}

    if ax is None:
        _, ax = plt.subplots(figsize=(7, 0.8 * max(2, len(features))))
    step = min(0.18, 0.7 / len(plans))
    for p, (plan_name, plan) in enumerate(plans):
        color = f"C{p}"
        offset = (p - (len(plans) - 1) / 2) * step
        base = plan_name if plan_name is not None else f"plan {p + 1}"
        label: str | None = f"{base} (J={plan.distance:.3g})"
        for name, (source, dest) in plan.changes.items():
            y = slots[name] + offset
            if math.isnan(dest) or math.isnan(source):
                anchor = source if math.isnan(dest) else dest
                if explainer is not None:
                    anchor = 0.0
                ax.plot([anchor], [y], "o", color=color, markersize=5, label=label)
                ax.annotate(
                    "-> NaN" if math.isnan(dest) else "NaN ->",
                    xy=(anchor, y), xytext=(6, 0), textcoords="offset points",
                    va="center", color="tab:red", fontsize=9,
                )
            else:
                if explainer is not None:
                    start, end = 0.0, (dest - source) / sigma[name]
                else:
                    start, end = source, dest
                ax.plot([start, end], [y, y], "-", color=color, alpha=0.5, zorder=1)
                ax.plot([start], [y], "o", color="tab:gray", markersize=4)
                ax.plot([end], [y], "o", color=color, markersize=5, label=label)
            label = None  # one legend entry per plan
    if explainer is not None:
        ax.axvline(0.0, color="0.6", linestyle="--", linewidth=1)
        ax.set_xlabel("standardized change from factual (Δ/σ)")
    else:
        ax.set_xlabel("feature value (gray = factual)")
    ax.set_yticks(range(len(features)), features)
    ax.invert_yaxis()
    ax.set_title(f"{len(plans)} alternative plan(s) for one instance")
    ax.legend(loc="best")
    return ax

plot_changes(cf, ax=None)

Dumbbell chart of per-feature changes (from -> to); NaN transitions annotated.

One row per changed feature, a gray dot at the factual value and a blue dot at the counterfactual value joined by a line; a feature that transitions to or from NaN is drawn as a single gray dot annotated "-> NaN"/"NaN ->" instead.

Parameters:

Name Type Description Default
cf Counterfactual

The counterfactual to plot.

required
ax Any

Existing axes to draw on; a new figure is created if omitted.

None

Returns:

Type Description
Any

The axes the chart was drawn on.

Raises:

Type Description
MissingExtraError

If matplotlib is not installed.

Source code in src/treecf/viz.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def plot_changes(cf: Counterfactual, ax: Any = None) -> Any:
    """Dumbbell chart of per-feature changes (from -> to); NaN transitions annotated.

    One row per changed feature, a gray dot at the factual value and a blue
    dot at the counterfactual value joined by a line; a feature that
    transitions to or from ``NaN`` is drawn as a single gray dot annotated
    ``"-> NaN"``/``"NaN ->"`` instead.

    Args:
        cf: The counterfactual to plot.
        ax: Existing axes to draw on; a new figure is created if omitted.

    Returns:
        The axes the chart was drawn on.

    Raises:
        MissingExtraError: If matplotlib is not installed.
    """
    plt = _import_pyplot()
    if ax is None:
        _, ax = plt.subplots(figsize=(7, 0.6 * max(2, len(cf.changes))))
    names = list(cf.changes)
    labeled = False
    for i, name in enumerate(names):
        source, target = cf.changes[name]
        if math.isnan(target) or math.isnan(source):
            anchor = source if math.isnan(target) else target
            ax.plot([anchor], [i], "o", color="tab:gray")
            ax.annotate(
                "-> NaN" if math.isnan(target) else "NaN ->",
                xy=(anchor, i),
                xytext=(6, 0),
                textcoords="offset points",
                va="center",
                color="tab:red",
            )
            continue
        ax.plot([source, target], [i, i], "-", color="tab:gray", zorder=1)
        factual_label = None if labeled else "factual"
        cf_label = None if labeled else "counterfactual"
        ax.plot([source], [i], "o", color="tab:gray", label=factual_label)
        ax.plot([target], [i], "o", color="tab:blue", label=cf_label)
        labeled = True
    ax.set_yticks(range(len(names)), names)
    ax.set_xlabel("feature value")
    ax.set_title(f"{cf.n_changed} change(s), distance {cf.distance:.3g} ({cf.proof})")
    if labeled:
        ax.legend(loc="best")
    return ax

plot_counterfactuals(results, ax=None)

Changed-feature matrix comparing diverse counterfactuals.

One row per result, one column per feature changed by any of them; a filled cell marks that the row's plan changed that column's feature. Rows are labeled by rank and distance (#1 (J=...), ...), in the order results is given.

Parameters:

Name Type Description Default
results Sequence[Counterfactual]

The counterfactuals to compare (e.g. the k alternatives for one row from diversity="seeds"/"lever-blocking").

required
ax Any

Existing axes to draw on; a new figure is created if omitted.

None

Returns:

Type Description
Any

The axes the matrix was drawn on.

Raises:

Type Description
MissingExtraError

If matplotlib is not installed.

Source code in src/treecf/viz.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def plot_counterfactuals(results: Sequence[Counterfactual], ax: Any = None) -> Any:
    """Changed-feature matrix comparing diverse counterfactuals.

    One row per result, one column per feature changed by any of them; a
    filled cell marks that the row's plan changed that column's feature.
    Rows are labeled by rank and distance (``#1 (J=...)``, ...), in the order
    ``results`` is given.

    Args:
        results: The counterfactuals to compare (e.g. the ``k`` alternatives
            for one row from ``diversity="seeds"``/``"lever-blocking"``).
        ax: Existing axes to draw on; a new figure is created if omitted.

    Returns:
        The axes the matrix was drawn on.

    Raises:
        MissingExtraError: If matplotlib is not installed.
    """
    plt = _import_pyplot()
    features = sorted({name for cf in results for name in cf.changes})
    if ax is None:
        _, ax = plt.subplots(figsize=(1.0 + 0.8 * len(features), 0.8 + 0.5 * len(results)))
    matrix = [[1.0 if f in cf.changes else 0.0 for f in features] for cf in results]
    ax.imshow(matrix, cmap="Blues", aspect="auto", vmin=0.0, vmax=1.0)
    ax.set_xticks(range(len(features)), features, rotation=45, ha="right")
    ax.set_yticks(
        range(len(results)),
        [f"#{i + 1} (J={cf.distance:.3g})" for i, cf in enumerate(results)],
    )
    ax.set_title("changed features per counterfactual")
    return ax

plot_effort(explainer, cf, ax=None)

Cost-space companion: how the distance J splits across the changes.

One horizontal bar per changed feature, its length the feature's own contribution w * |delta| / sigma to cf.distance (a NaN transition priced via AllowMissing's delta_miss/delta_from_miss), descending. Unlike plot_waterfall's exact score deltas, this decomposes the recourse cost, not the model score.

Parameters:

Name Type Description Default
explainer Any

Explainer wrapping the model; supplies the distance weights and normalizers each contribution is computed from.

required
cf Counterfactual

The counterfactual to decompose.

required
ax Any

Existing axes to draw on; a new figure is created if omitted.

None

Returns:

Type Description
Any

The axes the chart was drawn on.

Raises:

Type Description
MissingExtraError

If matplotlib is not installed.

Source code in src/treecf/viz.py
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
def plot_effort(explainer: Any, cf: Counterfactual, ax: Any = None) -> Any:
    """Cost-space companion: how the distance J splits across the changes.

    One horizontal bar per changed feature, its length the feature's own
    contribution ``w * |delta| / sigma`` to ``cf.distance`` (a NaN transition
    priced via ``AllowMissing``'s ``delta_miss``/``delta_from_miss``),
    descending. Unlike ``plot_waterfall``'s exact score deltas, this
    decomposes the recourse *cost*, not the model score.

    Args:
        explainer: Explainer wrapping the model; supplies the distance
            weights and normalizers each contribution is computed from.
        cf: The counterfactual to decompose.
        ax: Existing axes to draw on; a new figure is created if omitted.

    Returns:
        The axes the chart was drawn on.

    Raises:
        MissingExtraError: If matplotlib is not installed.
    """
    plt = _import_pyplot()
    contributions = sorted(
        _change_effort(explainer, cf.changes).items(), key=lambda pair: pair[1], reverse=True
    )

    if ax is None:
        _, ax = plt.subplots(figsize=(7, 0.6 * max(2, len(contributions)) + 0.8))
    labels = [name for name, _ in contributions]
    efforts = [effort for _, effort in contributions]
    ax.barh(range(len(labels)), efforts, color="tab:blue", height=0.6)
    for i, effort in enumerate(efforts):
        ax.annotate(
            f"{effort:.3g}", xy=(effort, i), xytext=(4, 0),
            textcoords="offset points", va="center", fontsize=9,
        )
    ax.set_yticks(range(len(labels)), labels)
    ax.invert_yaxis()
    ax.set_xlabel("effort contribution (w·|Δ|/σ)")
    ax.set_title(f"where the effort goes — total J = {cf.distance:.3g}")
    return ax

plot_ladder(bands_result, ax=None)

Cost of reaching each rating band (Target.bands): the price of every grade.

One bar per band, named and ordered like bands_result; a Counterfactual bar is its distance, an Infeasible band is drawn at zero height and labeled "infeasible".

Parameters:

Name Type Description Default
bands_result Mapping[str, object]

The dict returned by explain(x, target=Target.bands(...)).

required
ax Any

Existing axes to draw on; a new figure is created if omitted.

None

Returns:

Type Description
Any

The axes the chart was drawn on.

Raises:

Type Description
MissingExtraError

If matplotlib is not installed.

Source code in src/treecf/viz.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def plot_ladder(bands_result: Mapping[str, object], ax: Any = None) -> Any:
    """Cost of reaching each rating band (``Target.bands``): the price of every grade.

    One bar per band, named and ordered like ``bands_result``; a
    ``Counterfactual`` bar is its ``distance``, an ``Infeasible`` band is
    drawn at zero height and labeled ``"infeasible"``.

    Args:
        bands_result: The dict returned by ``explain(x, target=Target.bands(...))``.
        ax: Existing axes to draw on; a new figure is created if omitted.

    Returns:
        The axes the chart was drawn on.

    Raises:
        MissingExtraError: If matplotlib is not installed.
    """
    plt = _import_pyplot()
    if ax is None:
        _, ax = plt.subplots(figsize=(1.5 + 0.9 * len(bands_result), 4))
    names = list(bands_result)
    heights = []
    for name in names:
        outcome = bands_result[name]
        heights.append(outcome.distance if isinstance(outcome, Counterfactual) else 0.0)
    bars = ax.bar(names, heights, color="tab:blue")
    for bar, name in zip(bars, names, strict=True):
        outcome = bands_result[name]
        if isinstance(outcome, Infeasible):
            ax.text(
                bar.get_x() + bar.get_width() / 2,
                0.02,
                "infeasible",
                ha="center",
                va="bottom",
                rotation=90,
                color="tab:red",
            )
    ax.set_xticks(range(len(names)), names)
    ax.set_ylabel("distance J")
    ax.set_title("cost of reaching each band")
    return ax

plot_recourse_map(explainer, x, results, target, *, ax=None, space='auto', annotate=True, max_changes_per_label=3, fmt='{:.3g}', schematic=False, region_labels=('Reject', 'Accept'), show_factual_label=True)

Recourse diagram: what each plan costs and where it lands relative to the target.

Plots one point per feasible plan in results at (model output, recourse cost J), with the factual instance drawn as a red dot at cost 0 and an arrow from the factual to each plan. A green band marks the target interval on the model-output axis; the axis flips automatically so that "improving" always reads as a move toward the band. Model output is shown as a probability for sigmoid-link models (or when space="probability") and as the raw score otherwise (space="raw"; space="auto" picks based on the model's link function).

In the default quantitative view, each plan is labeled with one line — its name (or "plan {i}", ascending by distance, when unnamed) and its cost — when annotate is set; the map's job here is the overview, not a change list. Infeasible entries in results are drawn as grey markers above the plans, labeled by name ("infeasible" alone when unlabeled, with a (certified) suffix when the entry carries a certified proof) regardless of annotate.

schematic=True swaps the quantitative axes and target band for a slide-friendly rendering: a wavy decision-boundary line instead of a band, no ticks or axis labels, and "If ..." phrased plan labels (using each plan's changed features, largest-effort first, truncated to max_changes_per_label and formatted with fmt). annotate also gates show_factual_label there — an anchored corner box on the factual's screen side listing the features any plan changed, at their original values (schematic mode only; the quantitative view never draws it). region_labels names the two sides of the boundary in schematic mode.

Parameters:

Name Type Description Default
explainer Any

Explainer wrapping the model; supplies the link function and the counterfactual distance weights used to order each plan's changes.

required
x Any

Factual feature vector.

required
results Any

Counterfactual outcomes for x — a single result, a sequence, or a mapping (as returned by explain_coalitions). Feasible and infeasible entries are both accepted.

required
target Any

The target interval the plans were solved against; also drawn as the band (or boundary, in schematic mode).

required
ax Any

Existing axes to draw on; a new figure is created if omitted.

None
space str

"probability", "raw", or "auto" (default) to pick the model-output axis space from the model's link function.

'auto'
annotate bool

Draw a text label at each plan's point; in schematic mode, also gates whether show_factual_label draws its block.

True
max_changes_per_label int

Schematic mode only. Number of changed features shown per label before truncating to "(+k more)".

3
fmt str

Schematic mode only. Format string for changed feature values in labels.

'{:.3g}'
schematic bool

Render the slide-friendly boundary view instead of the quantitative axes.

False
region_labels tuple[str, str]

The (reject-side, accept-side) names drawn next to the boundary in schematic mode.

('Reject', 'Accept')
show_factual_label bool

Schematic mode only. Draw an anchored corner box, on the factual's screen side, listing the features any plan changed.

True

Returns:

Type Description
Any

The axes the recourse map was drawn on.

Raises:

Type Description
MissingExtraError

If matplotlib is not installed.

TreecfError

If results contains no plans at all, or more than 10 feasible plans.

Source code in src/treecf/viz.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
def plot_recourse_map(
    explainer: Any,
    x: Any,
    results: Any,
    target: Any,
    *,
    ax: Any = None,
    space: str = "auto",
    annotate: bool = True,
    max_changes_per_label: int = 3,
    fmt: str = "{:.3g}",
    schematic: bool = False,
    region_labels: tuple[str, str] = ("Reject", "Accept"),
    show_factual_label: bool = True,
) -> Any:
    """Recourse diagram: what each plan costs and where it lands relative to the target.

    Plots one point per feasible plan in ``results`` at (model output, recourse
    cost ``J``), with the factual instance drawn as a red dot at cost 0 and an
    arrow from the factual to each plan. A green band marks the target
    interval on the model-output axis; the axis flips automatically so that
    "improving" always reads as a move toward the band. Model output is shown
    as a probability for sigmoid-link models (or when ``space="probability"``)
    and as the raw score otherwise (``space="raw"``; ``space="auto"`` picks
    based on the model's link function).

    In the default quantitative view, each plan is labeled with one line —
    its name (or ``"plan {i}"``, ascending by distance, when unnamed) and its
    cost — when ``annotate`` is set; the map's job here is the overview, not
    a change list. Infeasible entries in ``results`` are drawn as grey
    markers above the plans, labeled by name (``"infeasible"`` alone when
    unlabeled, with a ``(certified)`` suffix when the entry carries a
    certified proof) regardless of ``annotate``.

    ``schematic=True`` swaps the quantitative axes and target band for a
    slide-friendly rendering: a wavy decision-boundary line instead of a
    band, no ticks or axis labels, and "If ..." phrased plan labels (using
    each plan's changed features, largest-effort first, truncated to
    ``max_changes_per_label`` and formatted with ``fmt``). ``annotate`` also
    gates ``show_factual_label`` there — an anchored corner box on the
    factual's screen side listing the features any plan changed, at their
    original values (schematic mode only; the quantitative view never draws
    it). ``region_labels`` names the two sides of the boundary in
    ``schematic`` mode.

    Args:
        explainer: Explainer wrapping the model; supplies the link function
            and the counterfactual distance weights used to order each
            plan's changes.
        x: Factual feature vector.
        results: Counterfactual outcomes for ``x`` — a single result, a
            sequence, or a mapping (as returned by ``explain_coalitions``).
            Feasible and infeasible entries are both accepted.
        target: The target interval the plans were solved against; also
            drawn as the band (or boundary, in schematic mode).
        ax: Existing axes to draw on; a new figure is created if omitted.
        space: ``"probability"``, ``"raw"``, or ``"auto"`` (default) to pick
            the model-output axis space from the model's link function.
        annotate: Draw a text label at each plan's point; in ``schematic``
            mode, also gates whether ``show_factual_label`` draws its block.
        max_changes_per_label: Schematic mode only. Number of changed
            features shown per label before truncating to "(+k more)".
        fmt: Schematic mode only. Format string for changed feature values
            in labels.
        schematic: Render the slide-friendly boundary view instead of the
            quantitative axes.
        region_labels: The (reject-side, accept-side) names drawn next to
            the boundary in schematic mode.
        show_factual_label: Schematic mode only. Draw an anchored corner
            box, on the factual's screen side, listing the features any
            plan changed.

    Returns:
        The axes the recourse map was drawn on.

    Raises:
        MissingExtraError: If matplotlib is not installed.
        TreecfError: If ``results`` contains no plans at all, or more than
            10 feasible plans.
    """
    from treecf.ir.evaluate import apply_link, raw_score
    from treecf.ir.model import Link

    plt = _import_pyplot()
    plans, failures = _plans_and_failures(results)
    if not plans and not failures:
        raise TreecfError("no plans to plot")
    if len(plans) > 10:
        raise TreecfError("plot_recourse_map compares at most 10 plans")

    link = explainer.ir.link
    prob = space == "probability" or (space == "auto" and link is Link.SIGMOID)
    s_raw = raw_score(explainer.ir, x)
    x_fact = apply_link(Link.SIGMOID, s_raw) if prob else s_raw

    def _plan_x(plan: Any) -> float:
        if prob and plan.score_prob is not None:
            return float(plan.score_prob)
        if prob:
            return apply_link(Link.SIGMOID, plan.score_raw)
        return float(plan.score_raw)

    ordered = sorted(plans, key=lambda pair: pair[1].distance)
    plan_points = [(label, plan, _plan_x(plan), plan.distance) for label, plan in ordered]

    if ax is None:
        _, ax = plt.subplots(figsize=(7, 5))

    lo, hi = _display_interval(target, link, space)
    finite_edges = [b for b in (lo, hi) if math.isfinite(b)]
    xs = [x_fact, *(px for _, _, px, _ in plan_points), *finite_edges]
    span = max(xs) - min(xs)
    pad = 0.08 * span

    if not schematic:
        lo_edge = lo if math.isfinite(lo) else min(xs)
        hi_edge = hi if math.isfinite(hi) else max(xs)
        ax.axvspan(lo_edge - pad, hi_edge + pad, color="tab:green", alpha=0.12)
        for edge in finite_edges:
            ax.axvline(edge, color="0.4", linestyle="--")

    if math.isfinite(hi) and hi < x_fact:
        ax.invert_xaxis()

    ax.plot([x_fact], [0.0], "o", color="tab:red", markersize=9, zorder=3)
    for i, (_label, _plan, px, py) in enumerate(plan_points):
        ax.plot([px], [py], "o", color="tab:green", markersize=8, zorder=3)
        sign = 1 if i % 2 else -1
        r = 0.0 if i == 0 else sign * 0.12 * math.ceil(i / 2)
        ax.annotate(
            "",
            xy=(px, py),
            xytext=(x_fact, 0.0),
            arrowprops={
                "arrowstyle": "->",
                "color": "0.15",
                "lw": 1.2,
                "connectionstyle": f"arc3,rad={r}",
            },
        )

    if not schematic:
        ax.set_xlabel("model output (probability)" if prob else "model output (raw score)")
        ax.set_ylabel("recourse cost J")
        ax.set_title(f"{len(plans)} recourse option(s)")

    label_bbox = {
        "boxstyle": "round,pad=0.25",
        "facecolor": "white",
        "edgecolor": "none",
        "alpha": 0.75,
    }

    if annotate:
        for i, (label, plan, px, py) in enumerate(plan_points):
            if schematic:
                text = _format_plan(
                    label, plan, explainer, fmt, max_changes_per_label, schematic=True
                )
                fontsize = 8
            else:
                # Minimal mode: the map's job is the overview, not the change list —
                # one line naming the plan (or its ascending-distance ordinal) and its cost.
                plan_name = label if label is not None else f"plan {i + 1}"
                text = f"{plan_name} (J={plan.distance:.3g})"
                fontsize = 9
            dx, ha = _grow_inward(ax, px)
            ax.annotate(
                text,
                xy=(px, py),
                xytext=(dx, 4),
                textcoords="offset points",
                ha=ha,
                va="bottom",
                fontsize=fontsize,
                bbox=label_bbox,
                zorder=2,  # marker (zorder=3) stays on top of its own label box
            )

    if schematic and annotate and show_factual_label:
        touched: dict[str, float] = {}
        for _label, plan, _px, _py in plan_points:
            for name, (source, _dest) in plan.changes.items():
                touched.setdefault(name, source)
        if touched:
            lines = ["factual:"] + [
                f"{name} = NaN"
                if math.isnan(touched[name])
                else f"{name} = {fmt.format(touched[name])}"
                for name in sorted(touched)
            ]
            _, side_ha = _grow_inward(ax, x_fact)
            fx = 0.02 if side_ha == "left" else 0.98
            ax.text(
                fx,
                0.03,
                "\n".join(lines),
                transform=ax.transAxes,
                ha=side_ha,
                va="bottom",
                fontsize=8,
                bbox={**label_bbox, "facecolor": "0.96"},
                zorder=2,  # factual dot (zorder=3) stays on top of the box background
            )

    y_top = max((py for _, _, _, py in plan_points), default=0.0)
    step_y = 0.12 * (y_top or 1.0)
    fail_dx, fail_ha = _grow_inward(ax, x_fact)
    for i, (label, r) in enumerate(failures):
        y = y_top + (i + 1) * step_y
        ax.plot([x_fact], [y], "x", color="0.5", markersize=8, zorder=3)
        text = f"{label}: infeasible" if label is not None else "infeasible"
        if getattr(r, "proof", "") == "certified":
            text += " (certified)"
        ax.annotate(
            text,
            xy=(x_fact, y),
            xytext=(fail_dx, 0),
            textcoords="offset points",
            ha=fail_ha,
            fontsize=8,
            color="0.35",
            zorder=2,  # marker (zorder=3) stays on top of its own label
        )

    n_failures = len(failures)
    if schematic:
        # Extra top headroom keeps multi-line plan labels and the topmost infeasible
        # marker's label clear of the schematic region labels (y=0.95) and boundary
        # caption (y=0.86), which both sit near the top of the axes.
        top_ref = (y_top + n_failures * step_y) or 1.0
        ax.set_ylim(bottom=-0.05 * top_ref, top=1.5 * top_ref)
    else:
        # Minimal mode: inflate just enough to fit the infeasible stack above the
        # highest plan (or a touch of breathing room when there's no stack at all).
        top_ref = max(y_top + (n_failures + 1) * step_y, y_top * 1.1)
        ax.set_ylim(bottom=-0.05 * top_ref, top=top_ref)

    if schematic:
        _schematic_dressing(ax, finite_edges, span, region_labels)

    return ax

plot_tradeoff(results, target=None, ax=None)

Cost vs achieved score for alternative plans of one instance.

One dot per plan: x = distance J, y = the achieved probability (sigmoid models) or raw score. target draws the interval bounds the plans had to reach. Accepts a sequence of Counterfactual objects or feasible BatchRecord entries, or a mapping as returned by explain_coalitions (keys label the dots; Infeasible skipped).

Parameters:

Name Type Description Default
results Any

The plans to plot; see above for accepted shapes.

required
target Any

When given, draws the target interval's finite bounds (mapped into the same probability/raw space as the plans) as horizontal reference lines.

None
ax Any

Existing axes to draw on; a new figure is created if omitted.

None

Returns:

Type Description
Any

The axes the chart was drawn on.

Raises:

Type Description
MissingExtraError

If matplotlib is not installed.

TreecfError

If results contains no feasible plans.

Source code in src/treecf/viz.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
def plot_tradeoff(results: Any, target: Any = None, ax: Any = None) -> Any:
    """Cost vs achieved score for alternative plans of one instance.

    One dot per plan: x = distance J, y = the achieved probability (sigmoid
    models) or raw score. ``target`` draws the interval bounds the plans had
    to reach. Accepts a sequence of ``Counterfactual`` objects or feasible
    ``BatchRecord`` entries, or a mapping as returned by
    ``explain_coalitions`` (keys label the dots; ``Infeasible`` skipped).

    Args:
        results: The plans to plot; see above for accepted shapes.
        target: When given, draws the target interval's finite bounds
            (mapped into the same probability/raw space as the plans) as
            horizontal reference lines.
        ax: Existing axes to draw on; a new figure is created if omitted.

    Returns:
        The axes the chart was drawn on.

    Raises:
        MissingExtraError: If matplotlib is not installed.
        TreecfError: If ``results`` contains no feasible plans.
    """
    plt = _import_pyplot()
    plans = _plans_with_labels(results)
    if not plans:
        raise TreecfError("no feasible plans to plot")
    prob_space = all(plan.score_prob is not None for _, plan in plans)

    if ax is None:
        _, ax = plt.subplots(figsize=(6, 4))
    for p, (plan_name, plan) in enumerate(plans):
        score = plan.score_prob if prob_space else plan.score_raw
        ax.plot([plan.distance], [score], "o", color=f"C{p}", markersize=8)
        ax.annotate(
            plan_name if plan_name is not None else f"{p + 1}",
            xy=(plan.distance, score), xytext=(6, 4),
            textcoords="offset points", fontsize=9,
        )
    if target is not None:
        for bound in _target_bounds(target, prob_space):
            ax.axhline(bound, color="tab:red", linewidth=1)
    ax.set_xlabel("distance J (effort)")
    ax.set_ylabel("model probability" if prob_space else "raw score")
    ax.set_title("what each plan costs, and what it buys")
    return ax

plot_waterfall(explainer, cf, target=None, ax=None)

SHAP-style waterfall: exact score deltas of the counterfactual's changes.

Starts at the factual score, applies the changes one at a time (largest single effect first), each bar being the EXACT score delta from that change (recomputed through the IR — endpoints are exact; per-bar attribution is sequential and therefore order-dependent, like any sequential decomposition). Sigmoid-link models are plotted in probability space.

Parameters:

Name Type Description Default
explainer Any

Explainer wrapping the model; supplies the IR the score deltas are recomputed through and the link function.

required
cf Counterfactual

The counterfactual to decompose.

required
target Any

When given, draws the target interval's finite bounds (in the same display space) as vertical reference lines.

None
ax Any

Existing axes to draw on; a new figure is created if omitted.

None

Returns:

Type Description
Any

The axes the waterfall was drawn on.

Raises:

Type Description
MissingExtraError

If matplotlib is not installed.

Source code in src/treecf/viz.py
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
def plot_waterfall(explainer: Any, cf: Counterfactual, target: Any = None, ax: Any = None) -> Any:
    """SHAP-style waterfall: exact score deltas of the counterfactual's changes.

    Starts at the factual score, applies the changes one at a time (largest
    single effect first), each bar being the EXACT score delta from that change
    (recomputed through the IR — endpoints are exact; per-bar attribution is
    sequential and therefore order-dependent, like any sequential decomposition).
    Sigmoid-link models are plotted in probability space.

    Args:
        explainer: Explainer wrapping the model; supplies the IR the score
            deltas are recomputed through and the link function.
        cf: The counterfactual to decompose.
        target: When given, draws the target interval's finite bounds (in the
            same display space) as vertical reference lines.
        ax: Existing axes to draw on; a new figure is created if omitted.

    Returns:
        The axes the waterfall was drawn on.

    Raises:
        MissingExtraError: If matplotlib is not installed.
    """
    import numpy as np

    from treecf.ir.evaluate import apply_link, raw_score
    from treecf.ir.model import Link

    plt = _import_pyplot()
    ir = explainer.ir
    index = {name: j for j, name in enumerate(ir.feature_names)}

    x = cf.x_cf.copy()
    for name, (source, _) in cf.changes.items():
        x[index[name]] = source

    def single_delta(name: str) -> float:
        probe = x.copy()
        probe[index[name]] = cf.changes[name][1]
        return raw_score(ir, probe) - raw_score(ir, x)

    order = sorted(cf.changes, key=lambda f: abs(single_delta(f)), reverse=True)

    sigmoid = ir.link is Link.SIGMOID
    to_display = (lambda s: apply_link(Link.SIGMOID, s)) if sigmoid else (lambda s: s)

    current = x.copy()
    scores = [to_display(raw_score(ir, current))]
    for name in order:
        current[index[name]] = cf.changes[name][1]
        scores.append(to_display(raw_score(ir, current)))

    if ax is None:
        _, ax = plt.subplots(figsize=(7, 0.7 * max(2, len(order)) + 1))
    for i, _name in enumerate(order):
        before, after = scores[i], scores[i + 1]
        delta = after - before
        color = "tab:blue" if delta < 0 else "tab:orange"
        ax.barh(i, delta, left=before, color=color, height=0.6)
        ax.plot([after, after], [i, i + 1], color="0.6", linestyle=":", linewidth=1)
        ax.annotate(
            f"{delta:+.4g}",
            xy=(max(before, after), i),
            xytext=(4, 0),
            textcoords="offset points",
            va="center",
            fontsize=9,
        )
    ax.axvline(scores[0], color="0.4", linestyle="--", linewidth=1)
    ax.text(scores[0], -0.55, f"f(x) = {scores[0]:.4g}", ha="center", va="top", fontsize=9)
    ax.axvline(scores[-1], color="tab:green", linestyle="--", linewidth=1)
    ax.text(
        scores[-1], len(order) - 0.3, f"f(x') = {scores[-1]:.4g}",
        ha="center", va="bottom", fontsize=9, color="tab:green",
    )
    if target is not None:
        for bound in target.raw_interval(ir.link):
            if np.isfinite(bound):
                ax.axvline(to_display(bound), color="tab:red", linewidth=1)
    ax.set_yticks(range(len(order)), order)
    ax.invert_yaxis()  # largest effect on top, like SHAP
    ax.set_xlabel("model probability" if sigmoid else "raw score")
    ax.set_title("what moves the score (sequential, exact)")
    if sigmoid:
        low = min(0.0, min(scores))
        high = max(1.0, max(scores))
        ax.set_xlim(low - 0.02, min(high + 0.05, 1.05))
    return ax

Batch-level counterfactual visualizations. matplotlib lives behind the [viz] extra.

Every function consumes a BatchResult. k=0 (the default) keeps each row's best plan; k=None keeps every feasible plan, so shares are per plan, not per row.

plot_batch_deltas(batch, explainer=None, k=0, top_n=10, ax=None)

Strip plot of actual deltas (to − from) per feature, top-N most-changed.

One jittered dot per plan, a median tick per feature; NaN transitions are counted in a per-feature annotation instead of plotted. With explainer, deltas are divided by the per-feature normalizer sigma so features of different scales share one axis.

Parameters:

Name Type Description Default
batch BatchResult

The batch result to visualize.

required
explainer Any

When given, standardizes deltas by its per-feature sigma; must describe the same feature space as batch. Without it, raw deltas are plotted.

None
k int | None

Which plan(s) to include per row — 0 (the default) keeps only each row's best plan; None keeps every feasible plan.

0
top_n int

Maximum number of features to show, most-changed first.

10
ax Any

Existing axes to draw on; a new figure is created if omitted.

None

Returns:

Type Description
Any

The axes the strip plot was drawn on.

Raises:

Type Description
MissingExtraError

If matplotlib is not installed.

TreecfError

If explainer is given and its feature space does not match batch.feature_names, or if batch has no plan matching k.

Source code in src/treecf/viz_batch.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
def plot_batch_deltas(
    batch: BatchResult,
    explainer: Any = None,
    k: int | None = 0,
    top_n: int = 10,
    ax: Any = None,
) -> Any:
    """Strip plot of actual deltas (to − from) per feature, top-N most-changed.

    One jittered dot per plan, a median tick per feature; NaN transitions are
    counted in a per-feature annotation instead of plotted. With ``explainer``,
    deltas are divided by the per-feature normalizer sigma so features of
    different scales share one axis.

    Args:
        batch: The batch result to visualize.
        explainer: When given, standardizes deltas by its per-feature
            ``sigma``; must describe the same feature space as ``batch``.
            Without it, raw deltas are plotted.
        k: Which plan(s) to include per row — ``0`` (the default) keeps only
            each row's best plan; ``None`` keeps every feasible plan.
        top_n: Maximum number of features to show, most-changed first.
        ax: Existing axes to draw on; a new figure is created if omitted.

    Returns:
        The axes the strip plot was drawn on.

    Raises:
        MissingExtraError: If matplotlib is not installed.
        TreecfError: If ``explainer`` is given and its feature space does not
            match ``batch.feature_names``, or if ``batch`` has no plan
            matching ``k``.
    """
    plt = _import_pyplot()
    import numpy as np

    if explainer is not None and tuple(explainer.ir.feature_names) != batch.feature_names:
        raise TreecfError("explainer and batch describe different feature spaces")
    selected = _select_records(batch, k)
    sigma = {name: 1.0 for name in batch.feature_names}
    if explainer is not None:
        sigma = dict(zip(batch.feature_names, (float(s) for s in explainer.sigma), strict=True))
    deltas: dict[str, list[float]] = {}
    nan_counts: Counter[str] = Counter()
    totals: Counter[str] = Counter()
    for record in selected:
        for name, (source, dest) in record.changes.items():
            totals[name] += 1
            if math.isnan(source) or math.isnan(dest):
                nan_counts[name] += 1
            else:
                deltas.setdefault(name, []).append((dest - source) / sigma[name])
    order = sorted(totals, key=lambda name: (-totals[name], name))[:top_n]

    if ax is None:
        _, ax = plt.subplots(figsize=(7, 0.6 * max(2, len(order))))
    rng = np.random.default_rng(0)  # fixed jitter: figures stay deterministic
    for i, name in enumerate(order):
        values = deltas.get(name, [])
        if values:
            jitter = rng.uniform(-0.15, 0.15, len(values))
            ax.plot(values, i + jitter, "o", color="tab:blue", alpha=0.6, markersize=4)
            ax.plot([float(np.median(values))], [i], "|", color="tab:orange", markersize=14)
        if nan_counts[name]:
            ax.annotate(
                f"→NaN ×{nan_counts[name]}", xy=(1.0, i),
                xycoords=("axes fraction", "data"), xytext=(-4, 0),
                textcoords="offset points", ha="right", va="center",
                color="tab:red", fontsize=9,
            )
    ax.axvline(0.0, color="0.6", linestyle="--", linewidth=1)
    ax.set_yticks(range(len(order)), order)
    ax.invert_yaxis()
    ax.set_xlabel("delta (to − from)" if explainer is None else "standardized delta (Δ/σ)")
    ax.set_title(f"how far the levers move ({len(selected)} plan(s))")
    return ax

plot_batch_levers(batch, k=0, normalize=True, top_n=20, show_essential=True, ax=None)

Horizontal stacked bars: share of plans changing each feature, by direction.

Increases, decreases, and NaN transitions stack per feature, ordered by how often the feature is used. For diversity="lever-blocking" results, features recorded as essential levers are annotated with their count.

Parameters:

Name Type Description Default
batch BatchResult

The batch result to summarize.

required
k int | None

Which plan(s) to include per row — 0 (the default) keeps only each row's best plan; None keeps every feasible plan.

0
normalize bool

When True (the default), bar widths are a fraction of the selected plans; when False, raw plan counts.

True
top_n int

Maximum number of features to show, most-used first.

20
show_essential bool

When True (the default) and batch.diversity == "lever-blocking", annotates each bar with how many rows recorded that feature as an essential lever (batch.essential_levers).

True
ax Any

Existing axes to draw on; a new figure is created if omitted.

None

Returns:

Type Description
Any

The axes the chart was drawn on.

Raises:

Type Description
MissingExtraError

If matplotlib is not installed.

TreecfError

If batch has no plan matching k.

Source code in src/treecf/viz_batch.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def plot_batch_levers(
    batch: BatchResult,
    k: int | None = 0,
    normalize: bool = True,
    top_n: int = 20,
    show_essential: bool = True,
    ax: Any = None,
) -> Any:
    """Horizontal stacked bars: share of plans changing each feature, by direction.

    Increases, decreases, and NaN transitions stack per feature, ordered by how
    often the feature is used. For ``diversity="lever-blocking"`` results,
    features recorded as essential levers are annotated with their count.

    Args:
        batch: The batch result to summarize.
        k: Which plan(s) to include per row — ``0`` (the default) keeps only
            each row's best plan; ``None`` keeps every feasible plan.
        normalize: When ``True`` (the default), bar widths are a fraction of
            the selected plans; when ``False``, raw plan counts.
        top_n: Maximum number of features to show, most-used first.
        show_essential: When ``True`` (the default) and
            ``batch.diversity == "lever-blocking"``, annotates each bar with
            how many rows recorded that feature as an essential lever
            (``batch.essential_levers``).
        ax: Existing axes to draw on; a new figure is created if omitted.

    Returns:
        The axes the chart was drawn on.

    Raises:
        MissingExtraError: If matplotlib is not installed.
        TreecfError: If ``batch`` has no plan matching ``k``.
    """
    plt = _import_pyplot()
    selected = _select_records(batch, k)
    increase: Counter[str] = Counter()
    decrease: Counter[str] = Counter()
    to_nan: Counter[str] = Counter()
    for record in selected:
        for name, (source, dest) in record.changes.items():
            if math.isnan(source) or math.isnan(dest):
                to_nan[name] += 1
            elif dest > source:
                increase[name] += 1
            else:
                decrease[name] += 1
    total = increase + decrease + to_nan
    order = sorted(total, key=lambda name: (-total[name], name))[:top_n]
    scale = 1.0 / len(selected) if normalize else 1.0

    if ax is None:
        _, ax = plt.subplots(figsize=(7, 0.6 * max(2, len(order))))
    positions = range(len(order))
    left = [0.0] * len(order)
    parts = [("increase", increase, "tab:orange"), ("decrease", decrease, "tab:blue"),
             ("NaN", to_nan, "tab:gray")]
    for label, counter, color in parts:
        widths = [counter[name] * scale for name in order]
        if not any(widths):
            continue
        ax.barh(positions, widths, left=left, height=0.6, color=color, label=label)
        left = [acc + w for acc, w in zip(left, widths, strict=True)]

    essential: Counter[str] = Counter()
    if show_essential and batch.diversity == "lever-blocking":
        essential = Counter(
            lever for levers in batch.essential_levers.values() for lever in levers
        )
    for i, name in enumerate(order):
        if essential[name]:
            ax.annotate(
                f"essential ×{essential[name]}", xy=(left[i], i), xytext=(4, 0),
                textcoords="offset points", va="center", color="tab:red", fontsize=9,
            )
    ax.set_yticks(positions, order)
    ax.invert_yaxis()
    ax.set_xlabel("fraction of plans" if normalize else "plans")
    ax.set_title(f"levers used across {len(selected)} plan(s)")
    ax.legend(loc="best")
    return ax

plot_batch_matrix(batch, explainer=None, k=0, sort_rows=True, max_row_labels=30, ax=None)

Plans × features heatmap: binary changes, or effort-shaded with an explainer.

With explainer, each cell shows the change's effort w·|Δ|/σ (NaN legs priced via AllowMissing); without, cells mark changed features like plot_counterfactuals. Rows sort by distance; columns by how often the feature is changed.

Parameters:

Name Type Description Default
batch BatchResult

The batch result to visualize.

required
explainer Any

When given, shades cells by change effort instead of a flat binary mark; must describe the same feature space as batch.

None
k int | None

Which plan(s) to include per row — 0 (the default) keeps only each row's best plan; None keeps every feasible plan.

0
sort_rows bool

When True (the default), rows are ordered by ascending distance.

True
max_row_labels int

Row id labels are drawn only when the selected plan count is at or below this limit; beyond it, the y-axis is left unlabeled with a plan-count caption instead.

30
ax Any

Existing axes to draw on; a new figure is created if omitted.

None

Returns:

Type Description
Any

The axes the heatmap was drawn on.

Raises:

Type Description
MissingExtraError

If matplotlib is not installed.

TreecfError

If explainer is given and its feature space does not match batch.feature_names, or if batch has no plan matching k.

Source code in src/treecf/viz_batch.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def plot_batch_matrix(
    batch: BatchResult,
    explainer: Any = None,
    k: int | None = 0,
    sort_rows: bool = True,
    max_row_labels: int = 30,
    ax: Any = None,
) -> Any:
    """Plans × features heatmap: binary changes, or effort-shaded with an explainer.

    With ``explainer``, each cell shows the change's effort ``w·|Δ|/σ`` (NaN
    legs priced via ``AllowMissing``); without, cells mark changed features
    like ``plot_counterfactuals``. Rows sort by distance; columns by how often
    the feature is changed.

    Args:
        batch: The batch result to visualize.
        explainer: When given, shades cells by change effort instead of a
            flat binary mark; must describe the same feature space as
            ``batch``.
        k: Which plan(s) to include per row — ``0`` (the default) keeps only
            each row's best plan; ``None`` keeps every feasible plan.
        sort_rows: When ``True`` (the default), rows are ordered by ascending
            distance.
        max_row_labels: Row id labels are drawn only when the selected plan
            count is at or below this limit; beyond it, the y-axis is left
            unlabeled with a plan-count caption instead.
        ax: Existing axes to draw on; a new figure is created if omitted.

    Returns:
        The axes the heatmap was drawn on.

    Raises:
        MissingExtraError: If matplotlib is not installed.
        TreecfError: If ``explainer`` is given and its feature space does not
            match ``batch.feature_names``, or if ``batch`` has no plan
            matching ``k``.
    """
    plt = _import_pyplot()
    import numpy as np

    if explainer is not None and tuple(explainer.ir.feature_names) != batch.feature_names:
        raise TreecfError("explainer and batch describe different feature spaces")
    selected = _select_records(batch, k)
    if sort_rows:
        selected.sort(key=lambda record: record.distance or 0.0)
    frequency = Counter(name for record in selected for name in record.changes)
    features = sorted(frequency, key=lambda name: (-frequency[name], name))

    matrix = np.zeros((len(selected), len(features)))
    for i, record in enumerate(selected):
        row_values = (
            {name: 1.0 for name in record.changes}
            if explainer is None
            else _change_effort(explainer, record.changes)
        )
        for jf, name in enumerate(features):
            matrix[i, jf] = row_values.get(name, 0.0)

    if ax is None:
        height = 0.8 + min(0.3 * max(2, len(selected)), 6.0)
        _, ax = plt.subplots(figsize=(1.0 + 0.8 * len(features), height))
    if explainer is None:
        vmax = 1.0
    else:
        # robust ceiling: one extreme change must not wash out the rest
        positive = matrix[matrix > 0]
        vmax = max(float(np.percentile(positive, 95)) if positive.size else 0.0, 1e-12)
    ax.imshow(matrix, cmap="Blues", aspect="auto", vmin=0.0, vmax=vmax)
    ax.set_xticks(range(len(features)), features, rotation=45, ha="right")
    if len(selected) <= max_row_labels:
        labels = [
            f"{r.id} (J={r.distance:.3g})" + (f" k={r.k}" if k is None else "")
            for r in selected
        ]
        ax.set_yticks(range(len(selected)), labels)
    else:
        ax.set_yticks([])
        ax.set_ylabel(f"{len(selected)} plans")
    ax.set_title(
        "effort per change (w·|Δ|/σ)" if explainer is not None else "changed features per plan"
    )
    return ax

plot_batch_summary(batch, k=0, axs=None)

Three-panel batch overview: plan cost, sparsity, and feasibility.

Creates its own figure when axs is None and returns the array of three axes (unlike the single-axes functions, which return one ax). Panels: a histogram of distance over the selected plans, a bar chart of n_changed counts, and a feasible-vs-infeasible bar over every row (independent of k — every row counts once).

Parameters:

Name Type Description Default
batch BatchResult

The batch result to summarize.

required
k int | None

Which plan(s) feed the cost/sparsity panels — 0 (the default) keeps only each row's best plan; None keeps every feasible plan. Does not affect the feasibility panel.

0
axs Any

Existing array of 3 axes to draw on; a new figure is created if omitted.

None

Returns:

Type Description
Any

The array of 3 axes (cost, sparsity, feasibility) the panels were

Any

drawn on.

Raises:

Type Description
MissingExtraError

If matplotlib is not installed.

TreecfError

If batch has no records at all.

Source code in src/treecf/viz_batch.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
def plot_batch_summary(batch: BatchResult, k: int | None = 0, axs: Any = None) -> Any:
    """Three-panel batch overview: plan cost, sparsity, and feasibility.

    Creates its own figure when ``axs`` is None and returns the array of three
    axes (unlike the single-axes functions, which return one ``ax``). Panels:
    a histogram of ``distance`` over the selected plans, a bar chart of
    ``n_changed`` counts, and a feasible-vs-infeasible bar over every row
    (independent of ``k`` — every row counts once).

    Args:
        batch: The batch result to summarize.
        k: Which plan(s) feed the cost/sparsity panels — ``0`` (the default)
            keeps only each row's best plan; ``None`` keeps every feasible
            plan. Does not affect the feasibility panel.
        axs: Existing array of 3 axes to draw on; a new figure is created if
            omitted.

    Returns:
        The array of 3 axes (cost, sparsity, feasibility) the panels were
        drawn on.

    Raises:
        MissingExtraError: If matplotlib is not installed.
        TreecfError: If ``batch`` has no records at all.
    """
    plt = _import_pyplot()
    ids_all = {record.id for record in batch.records}
    if not ids_all:
        raise TreecfError("empty batch")
    ids_ok = {record.id for record in batch.records if record.feasible}
    selected = [r for r in batch.records if r.feasible and (k is None or r.k == k)]

    own_figure = axs is None
    if own_figure:
        _, axs = plt.subplots(1, 3, figsize=(11, 3.2), constrained_layout=True)
    distances = [record.distance for record in selected if record.distance is not None]
    if distances:
        axs[0].hist(distances, bins="auto", color="tab:blue")
    else:
        axs[0].text(
            0.5, 0.5, "no feasible plans", ha="center", va="center",
            transform=axs[0].transAxes, color="tab:red",
        )
    axs[0].set_xlabel("distance J")
    axs[0].set_title("plan cost")

    sparsity = Counter(record.n_changed for record in selected)
    counts = sorted((n, c) for n, c in sparsity.items() if n is not None)
    if counts:
        axs[1].bar([n for n, _ in counts], [c for _, c in counts], color="tab:blue")
        axs[1].set_xticks([n for n, _ in counts])
    axs[1].set_xlabel("features changed")
    axs[1].set_title("sparsity")

    axs[2].bar(
        ["feasible", "infeasible"],
        [len(ids_ok), len(ids_all) - len(ids_ok)],
        color=["tab:blue", "tab:red"],
    )
    axs[2].set_title(f"{len(ids_ok) / len(ids_all):.0%} of rows solvable")

    if own_figure:
        axs[0].figure.suptitle(
            f"batch summary — {len(ids_all)} rows, diversity={batch.diversity!r}"
        )
    return axs