Skip to content

API: visualization

viz

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

plot_changes

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.

PARAMETER DESCRIPTION
cf

The counterfactual to plot.

TYPE: Counterfactual

ax

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

TYPE: Any DEFAULT: None

RETURNS DESCRIPTION
The axes the chart was drawn on.
RAISES DESCRIPTION
MissingExtraError

If matplotlib is not installed.

Source code in src/treecf/viz.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
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.

    Parameters
    ----------
    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

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.

PARAMETER DESCRIPTION
results

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

TYPE: Sequence[Counterfactual]

ax

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

TYPE: Any DEFAULT: None

RETURNS DESCRIPTION
The axes the matrix was drawn on.
RAISES DESCRIPTION
MissingExtraError

If matplotlib is not installed.

Source code in src/treecf/viz.py
 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
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.

    Parameters
    ----------
    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_ladder

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".

PARAMETER DESCRIPTION
bands_result

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

TYPE: Mapping[str, object]

ax

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

TYPE: Any DEFAULT: None

RETURNS DESCRIPTION
The axes the chart was drawn on.
RAISES DESCRIPTION
MissingExtraError

If matplotlib is not installed.

Source code in src/treecf/viz.py
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
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"``.

    Parameters
    ----------
    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_alternatives

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.

PARAMETER DESCRIPTION
results

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

TYPE: Any

explainer

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

TYPE: Any DEFAULT: None

ax

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

TYPE: Any DEFAULT: None

RETURNS DESCRIPTION
The axes the chart was drawn on.
RAISES 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
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
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.

    Parameters
    ----------
    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_tradeoff

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

PARAMETER DESCRIPTION
results

The plans to plot; see above for accepted shapes.

TYPE: Any

target

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

TYPE: Any DEFAULT: None

ax

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

TYPE: Any DEFAULT: None

RETURNS DESCRIPTION
The axes the chart was drawn on.
RAISES DESCRIPTION
MissingExtraError

If matplotlib is not installed.

TreecfError

If results contains no feasible plans.

Source code in src/treecf/viz.py
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
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).

    Parameters
    ----------
    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_recourse_map

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.

PARAMETER DESCRIPTION
explainer

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

TYPE: Any

x

Factual feature vector.

TYPE: Any

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.

TYPE: Any

target

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

TYPE: Any

ax

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

TYPE: Any DEFAULT: None

space

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

TYPE: str DEFAULT: 'auto'

annotate

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

TYPE: bool DEFAULT: True

max_changes_per_label

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

TYPE: int DEFAULT: 3

fmt

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

TYPE: str DEFAULT: '{:.3g}'

schematic

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

TYPE: bool DEFAULT: False

region_labels

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

TYPE: tuple[str, str] DEFAULT: ('Reject', 'Accept')

show_factual_label

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

TYPE: bool DEFAULT: True

RETURNS DESCRIPTION
The axes the recourse map was drawn on.
RAISES 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
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
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
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.

    Parameters
    ----------
    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_waterfall

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.

PARAMETER DESCRIPTION
explainer

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

TYPE: Any

cf

The counterfactual to decompose.

TYPE: Counterfactual

target

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

TYPE: Any DEFAULT: None

ax

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

TYPE: Any DEFAULT: None

RETURNS DESCRIPTION
The axes the waterfall was drawn on.
RAISES DESCRIPTION
MissingExtraError

If matplotlib is not installed.

Source code in src/treecf/viz.py
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
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.

    Parameters
    ----------
    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

plot_effort

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.

PARAMETER DESCRIPTION
explainer

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

TYPE: Any

cf

The counterfactual to decompose.

TYPE: Counterfactual

ax

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

TYPE: Any DEFAULT: None

RETURNS DESCRIPTION
The axes the chart was drawn on.
RAISES DESCRIPTION
MissingExtraError

If matplotlib is not installed.

Source code in src/treecf/viz.py
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
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.

    Parameters
    ----------
    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_recourse_menu

plot_recourse_menu(
    menu: Any,
    *,
    ax: Any = None,
    order: str = "cost",
    max_rows: int = 25,
    annotate: bool = True,
    explainer: Any = None,
) -> Any

Lever-set by feature matrix of a recourse menu.

One row per menu entry — in the menu's own order (minimal frontier first) when order="cost", by set size then key when order="size" — followed by the unresolved sets, and one column per candidate lever. A filled cell marks a feature the plan changed, shaded by the size of the change: |Δ|/σ when explainer is given (categorical levers hatched), otherwise |Δ| relative to the largest change of that lever across the menu. The row label carries the plan cost, and a glyph before it the proof: filled square optimal, half square optimal_within_gap, open square heuristic, cross certified infeasible, dot search_exhausted, question mark unresolved. A DiverseSet built from a menu renders through it.

PARAMETER DESCRIPTION
menu

A RecourseMenu, or a DiverseSet whose menu is set.

TYPE: Any

ax

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

TYPE: Any DEFAULT: None

order

"cost" (the menu's order) or "size".

TYPE: str DEFAULT: 'cost'

max_rows

Rows drawn before the rest is cut; the title says how many of the total are shown.

TYPE: int DEFAULT: 25

annotate

Write each changed feature's new value in its cell.

TYPE: bool DEFAULT: True

explainer

The explainer the menu came from, for sigma-scaled shading and categorical hatching; optional.

TYPE: Any DEFAULT: None

RETURNS DESCRIPTION
The axes the matrix was drawn on.
RAISES DESCRIPTION
MissingExtraError

If matplotlib is not installed.

TreecfError

If a DiverseSet without a menu is given, or the menu has no levers.

ValueError

If order is unknown.

Source code in src/treecf/viz.py
 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
def plot_recourse_menu(
    menu: Any,
    *,
    ax: Any = None,
    order: str = "cost",
    max_rows: int = 25,
    annotate: bool = True,
    explainer: Any = None,
) -> Any:
    """Lever-set by feature matrix of a recourse menu.

    One row per menu entry — in the menu's own order (minimal frontier
    first) when ``order="cost"``, by set size then key when
    ``order="size"`` — followed by the unresolved sets, and one column per
    candidate lever. A filled cell marks a feature the plan changed, shaded
    by the size of the change: ``|Δ|/σ`` when ``explainer`` is given
    (categorical levers hatched), otherwise ``|Δ|`` relative to the largest
    change of that lever across the menu. The row label carries the plan
    cost, and a glyph before it the proof: filled square ``optimal``, half
    square ``optimal_within_gap``, open square ``heuristic``, cross
    certified infeasible, dot ``search_exhausted``, question mark
    unresolved. A ``DiverseSet`` built from a menu renders through it.

    Parameters
    ----------
    menu
        A ``RecourseMenu``, or a ``DiverseSet`` whose ``menu`` is set.
    ax
        Existing axes to draw on; a new figure is created if omitted.
    order
        ``"cost"`` (the menu's order) or ``"size"``.
    max_rows
        Rows drawn before the rest is cut; the title says how many of the
        total are shown.
    annotate
        Write each changed feature's new value in its cell.
    explainer
        The explainer the menu came from, for sigma-scaled shading and
        categorical hatching; optional.

    Returns
    -------
    The axes the matrix was drawn on.

    Raises
    ------
    MissingExtraError
        If matplotlib is not installed.
    TreecfError
        If a ``DiverseSet`` without a menu is given, or the menu has no
        levers.
    ValueError
        If ``order`` is unknown.
    """
    from matplotlib.patches import Rectangle

    from treecf._menu import DiverseSet

    plt = _import_pyplot()
    if isinstance(menu, DiverseSet):
        if menu.menu is None:
            raise TreecfError(
                "this DiverseSet carries no menu (coalition criterion); draw its plans "
                "with plot_alternatives instead"
            )
        menu = menu.menu
    if order not in ("cost", "size"):
        raise ValueError(f"order must be 'cost' or 'size', got {order!r}")
    levers = list(menu.levers)
    if not levers:
        raise TreecfError("the menu has no candidate levers to draw")

    rows: list[tuple[str, Any]] = [*menu.items(), *((key, None) for key in menu.unresolved)]
    if order == "size":
        rows.sort(key=lambda row: (len(row[0].split("+")), row[0]))
    total = len(rows)
    rows = rows[:max_rows]

    categorical = set()
    sigma: dict[str, float] = {}
    if explainer is not None:
        categorical = {explainer.ir.feature_names[j] for j in explainer.ir.categorical}
        sigma = dict(
            zip(explainer.ir.feature_names, [float(s) for s in explainer.sigma], strict=True)
        )

    def magnitude(name: str, before: float, after: float) -> float:
        if name in categorical:
            return 1.0
        if math.isnan(before) or math.isnan(after):
            return 1.0
        delta = abs(after - before)
        return delta / sigma[name] if sigma else delta

    scale: dict[str, float] = dict.fromkeys(levers, 0.0)
    for _key, entry in rows:
        if isinstance(entry, Counterfactual):
            for name, (before, after) in entry.changes.items():
                if name in scale:
                    scale[name] = max(scale[name], magnitude(name, before, after))
    if sigma:
        top = max(scale.values(), default=1.0) or 1.0
        scale = dict.fromkeys(levers, top)

    if ax is None:
        _, ax = plt.subplots(figsize=(0.6 * len(levers) + 3.0, 0.38 * len(rows) + 1.6))
    cmap = plt.get_cmap("Blues")
    labels: list[str] = []
    present: list[str] = []
    for i, (key, entry) in enumerate(rows):
        members = set(key.split("+"))
        changes = entry.changes if isinstance(entry, Counterfactual) else {}
        for j, name in enumerate(levers):
            if name in changes:
                before, after = changes[name]
                share = magnitude(name, before, after) / (scale[name] or 1.0)
                ax.add_patch(Rectangle(
                    (j - 0.5, i - 0.5), 1.0, 1.0,
                    facecolor=cmap(0.35 + 0.6 * min(share, 1.0)),
                    edgecolor="white", hatch="//" if name in categorical else None,
                    label="_cell_filled",
                ))
                if annotate:
                    text = "NaN" if math.isnan(after) else f"{after:.3g}"
                    ax.text(j, i, text, ha="center", va="center", fontsize=7,
                            color="white" if share > 0.55 else "0.15")
            else:
                ax.add_patch(Rectangle(
                    (j - 0.5, i - 0.5), 1.0, 1.0,
                    facecolor="0.97" if name in members else "white",
                    edgecolor="0.85", label="_cell_empty",
                ))
        kind = _menu_glyph_kind(entry)
        marker, fillstyle, _ = _MENU_GLYPHS[kind]
        ax.plot([-0.9], [i], marker=marker, fillstyle=fillstyle, color="0.2",
                markersize=7 if marker != "$?$" else 9, linestyle="none",
                label=f"_glyph_{kind}")
        if kind not in present:
            present.append(kind)
        if isinstance(entry, Counterfactual):
            labels.append(f"{key}  J={entry.distance:.3g}")
        else:
            labels.append(key)

    ax.set_xlim(-1.4, len(levers) - 0.5)
    ax.set_ylim(len(rows) - 0.5, -0.5)
    ax.set_xticks(range(len(levers)))
    ax.set_xticklabels(levers, rotation=30, ha="right")
    ax.set_yticks(range(len(rows)))
    ax.set_yticklabels(labels, fontsize=8)
    ax.tick_params(length=0)
    for spine in ax.spines.values():
        spine.set_visible(False)
    title = f"recourse menu — {total} lever set(s)"
    if len(rows) < total:
        title += f" (showing {len(rows)} of {total})"
    ax.set_title(title)
    from matplotlib.lines import Line2D

    handles: list[Any] = [
        Line2D([], [], marker=_MENU_GLYPHS[kind][0], fillstyle=_MENU_GLYPHS[kind][1],
               color="0.2", linestyle="none", markersize=7, label=_MENU_GLYPHS[kind][2])
        for kind in present
    ]
    handles.append(Rectangle((0, 0), 1, 1, facecolor=cmap(0.7), edgecolor="white",
                             label="changed lever (shade: size of change)"))
    ax.legend(handles=handles, loc="upper left", bbox_to_anchor=(1.01, 1.0),
              fontsize=7, frameon=False)
    return ax

plot_region

plot_region(
    explainer: Any,
    x: Any,
    result: Any,
    *,
    ax: Any = None,
    units: str = "sigma",
    order: str = "index",
    annotate: bool = True,
    fmt: str = "{:.3g}",
    max_features: int | None = None,
) -> Any

Per-feature view of a certified recourse region: how far each value can move while staying certified, and what stopped it.

Each widened numeric feature draws its certified interval as a thick bar (units="sigma": one shared axis in sigma-units from the factual, so every factual sits at 0; units="raw": small multiples, one strip per feature on its own scale). The factual is a hollow circle, the counterfactual a filled marker, and the instance bounds faint whiskers. A finite bar end carries a cap saying what limited it: a bracket where the end coincides with an instance bound (a constraint stopped it), a plain tick where the model's own routing did; an infinite end runs to the axis edge with an open arrow. Categorical features draw one tile per category code — filled when certified, outlined at the factual's code, marked at the counterfactual's, hatched where a declared allowed set excludes the code; the tiles are nominal, so their positions carry no meaning. The legend records that the region is certified but not necessarily maximal.

PARAMETER DESCRIPTION
explainer

The explainer that produced the result (bounds, normalizers, names).

TYPE: Explainer

x

The factual row the region is anchored at.

TYPE: array - like

result

A result carrying region, or an explicit (region, x_cf) pair.

TYPE: Counterfactual or (RecourseRegion, array - like)

ax

Target axes for units="sigma"; ignored for units="raw".

TYPE: matplotlib axes DEFAULT: None

units

Shared sigma-unit axis, or per-feature raw-value strips.

TYPE: ('sigma', 'raw') DEFAULT: "sigma"

order

Row order: ascending feature index, or descending cost contribution.

TYPE: ('index', 'cost') DEFAULT: "index"

annotate

Annotate raw values at the bar ends.

TYPE: bool DEFAULT: True

fmt

Format string for annotations.

TYPE: str DEFAULT: '{:.3g}'

max_features

Cap on rows; the rest are summarized as "(+k more)".

TYPE: int DEFAULT: None

RETURNS DESCRIPTION
matplotlib axes, or an array of axes for ``units="raw"``.
RAISES DESCRIPTION
MissingExtraError

If matplotlib is not installed.

TreecfError

If the result carries no region, or an argument is unrecognized.

Source code in src/treecf/viz.py
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
def plot_region(
    explainer: Any,
    x: Any,
    result: Any,
    *,
    ax: Any = None,
    units: str = "sigma",
    order: str = "index",
    annotate: bool = True,
    fmt: str = "{:.3g}",
    max_features: int | None = None,
) -> Any:
    """Per-feature view of a certified recourse region: how far each value can
    move while staying certified, and what stopped it.

    Each widened numeric feature draws its certified interval as a thick bar
    (``units="sigma"``: one shared axis in sigma-units from the factual, so
    every factual sits at 0; ``units="raw"``: small multiples, one strip per
    feature on its own scale). The factual is a hollow circle, the
    counterfactual a filled marker, and the instance bounds faint whiskers. A
    finite bar end carries a cap saying what limited it: a bracket where the
    end coincides with an instance bound (a constraint stopped it), a plain
    tick where the model's own routing did; an infinite end runs to the axis
    edge with an open arrow. Categorical features draw one tile per category
    code — filled when certified, outlined at the factual's code, marked at
    the counterfactual's, hatched where a declared allowed set excludes the
    code; the tiles are nominal, so their positions carry no meaning. The
    legend records that the region is certified but not necessarily maximal.

    Parameters
    ----------
    explainer : Explainer
        The explainer that produced the result (bounds, normalizers, names).
    x : array-like
        The factual row the region is anchored at.
    result : Counterfactual or (RecourseRegion, array-like)
        A result carrying ``region``, or an explicit ``(region, x_cf)`` pair.
    ax : matplotlib axes, optional
        Target axes for ``units="sigma"``; ignored for ``units="raw"``.
    units : {"sigma", "raw"}
        Shared sigma-unit axis, or per-feature raw-value strips.
    order : {"index", "cost"}
        Row order: ascending feature index, or descending cost contribution.
    annotate : bool
        Annotate raw values at the bar ends.
    fmt : str
        Format string for annotations.
    max_features : int, optional
        Cap on rows; the rest are summarized as ``"(+k more)"``.

    Returns
    -------
    matplotlib axes, or an array of axes for ``units="raw"``.

    Raises
    ------
    MissingExtraError
        If matplotlib is not installed.
    TreecfError
        If the result carries no region, or an argument is unrecognized.
    """
    plt = _import_pyplot()
    import numpy as np

    from treecf._errors import TreecfError

    if isinstance(result, tuple):
        region, x_cf = result
    else:
        region = getattr(result, "region", None)
        x_cf = getattr(result, "x_cf", None)
    if region is None:
        raise TreecfError("result has no region; pass region=True to explain")
    if units not in ("sigma", "raw"):
        raise TreecfError(f"unknown units {units!r}; use 'sigma' or 'raw'")
    if order not in ("index", "cost"):
        raise TreecfError(f"unknown order {order!r}; use 'index' or 'cost'")

    x = np.asarray(x, dtype=np.float64)
    x_cf = np.asarray(x_cf, dtype=np.float64)
    names = tuple(explainer.ir.feature_names)
    index = {name: j for j, name in enumerate(names)}
    sigma = np.asarray(explainer.sigma, dtype=np.float64)
    weights = np.asarray(explainer.weights, dtype=np.float64)
    lo_b, hi_b, _frozen = explainer.compiled.instance_bounds(x)
    lo_b = np.where(np.isnan(lo_b), -math.inf, lo_b)
    hi_b = np.where(np.isnan(hi_b), math.inf, hi_b)

    rows: list[tuple[int, str, str]] = []  # (feature index, name, kind)
    for name in region.feature_intervals:
        rows.append((index[name], name, "numeric"))
    for name in getattr(region, "feature_categories", {}):
        rows.append((index[name], name, "categorical"))

    def cost_of(j: int) -> float:
        if math.isnan(x[j]) or math.isnan(x_cf[j]) or x[j] == x_cf[j]:
            return 0.0
        delta = 1.0 if j in explainer.ir.categorical else abs(x_cf[j] - x[j])
        return float(weights[j] * delta / sigma[j])

    if order == "index":
        rows.sort(key=lambda row: row[0])
    else:
        rows.sort(key=lambda row: (-cost_of(row[0]), row[0]))
    total_rows = len(rows)
    hidden = 0
    if max_features is not None and total_rows > max_features:
        hidden = total_rows - max_features
        rows = rows[:max_features]
    # the caveat line is only owed while some side is neither at a bound nor proved
    maximal_categories = getattr(region, "maximal_categories", {})
    show_caveat = any(
        _unproven_sides(region, name, float(lo_b[j]), float(hi_b[j]))
        if kind == "numeric"
        else not maximal_categories.get(name, False)
        for j, name, kind in rows
    )
    show_data = any(
        any(_data_limited_sides(region, name)) for _j, name, kind in rows if kind == "numeric"
    )

    if units == "raw":
        _, axes = plt.subplots(
            len(rows), 1, figsize=(7, 1.1 * max(2, len(rows))), squeeze=False
        )
        axes = axes[:, 0]
        for strip, (j, name, kind) in zip(axes, rows, strict=True):
            _region_row(
                strip, explainer, region, x, x_cf, j, name, kind,
                sigma_units=False, y=0.0, lo_b=lo_b, hi_b=hi_b,
                annotate=annotate, fmt=fmt,
            )
            strip.set_yticks([0.0])
            strip.set_yticklabels([name])
        _region_legend(axes[0], show_caveat, show_data)
        axes[0].set_title(f"certified recourse region — {total_rows} feature(s)")
        if hidden:
            axes[-1].annotate(
                f"(+{hidden} more)", xy=(0.99, 0.02), xycoords="axes fraction",
                ha="right", fontsize=8, color="0.4",
            )
        return axes

    if ax is None:
        _, ax = plt.subplots(figsize=(7, 0.6 * max(2, len(rows))))
    for i, (j, name, kind) in enumerate(rows):
        _region_row(
            ax, explainer, region, x, x_cf, j, name, kind,
            sigma_units=True, y=float(len(rows) - 1 - i), lo_b=lo_b, hi_b=hi_b,
            annotate=annotate, fmt=fmt,
        )
    ax.set_yticks([float(len(rows) - 1 - i) for i in range(len(rows))])
    ax.set_yticklabels([name for _, name, _ in rows])
    ax.set_xlabel("distance from the factual (sigma units)")
    ax.set_title(f"certified recourse region — {total_rows} feature(s)")
    if hidden:
        ax.annotate(
            f"(+{hidden} more)", xy=(0.99, 0.02), xycoords="axes fraction",
            ha="right", fontsize=8, color="0.4",
        )
    _region_legend(ax, show_caveat, show_data)
    return ax

plot_certification_trace

plot_certification_trace(
    result: Any, *, ax: Any = None
) -> Any

How an exact search's proof formed: incumbent and lower bound over nodes.

Reads solver_stats["trace"] — the samples the exact backend takes at every incumbent update and every power-of-two node count — and draws the incumbent cost (what has been found) and the lower bound (what can still be ruled out) against the nodes expanded on a log axis, with the gap between them shaded. The terminal marker names the outcome: optimal, within gap, certified infeasible, or stopped early for a search that ran out of budget or withdrew its claim (the solve-time warning says which).

PARAMETER DESCRIPTION
result

A Counterfactual, Infeasible, or BatchRecord produced by backend="exact".

TYPE: Any

ax

Axes to draw on; a new figure is created when omitted.

TYPE: Any DEFAULT: None

RETURNS DESCRIPTION
The matplotlib ``Axes`` drawn on.
RAISES DESCRIPTION
MissingExtraError

If matplotlib is not installed.

TreecfError

If result carries no trace (a genetic or python-backend result).

Source code in src/treecf/viz.py
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
def plot_certification_trace(result: Any, *, ax: Any = None) -> Any:
    """How an exact search's proof formed: incumbent and lower bound over nodes.

    Reads ``solver_stats["trace"]`` — the samples the exact backend takes at
    every incumbent update and every power-of-two node count — and draws the
    incumbent cost (what has been found) and the lower bound (what can still
    be ruled out) against the nodes expanded on a log axis, with the gap
    between them shaded. The terminal marker names the outcome: ``optimal``,
    ``within gap``, ``certified infeasible``, or ``stopped early`` for a
    search that ran out of budget or withdrew its claim (the solve-time
    warning says which).

    Parameters
    ----------
    result
        A ``Counterfactual``, ``Infeasible``, or ``BatchRecord`` produced
        by ``backend="exact"``.
    ax
        Axes to draw on; a new figure is created when omitted.

    Returns
    -------
    The matplotlib ``Axes`` drawn on.

    Raises
    ------
    MissingExtraError
        If matplotlib is not installed.
    TreecfError
        If ``result`` carries no trace (a genetic or python-backend result).
    """
    plt = _import_pyplot()

    stats = getattr(result, "solver_stats", None)
    trace = stats.get("trace") if isinstance(stats, dict) else None
    if not trace:
        raise TreecfError(
            "result carries no certification trace; only backend='exact' records one"
        )
    assert isinstance(stats, dict)  # narrowed by the trace check above
    nodes = [max(int(n), 1) for n, _, _ in trace]  # a log axis cannot show node 0
    incumbents = [None if c is None else float(c) for _, c, _ in trace]
    bounds = [float(b) for _, _, b in trace]

    if ax is None:
        _, ax = plt.subplots(figsize=(6, 3.2))
    known_bound = [(n, b) for n, b in zip(nodes, bounds, strict=True) if math.isfinite(b)]
    if known_bound:
        ax.plot(
            [n for n, _ in known_bound], [b for _, b in known_bound],
            drawstyle="steps-post", color="C3", linewidth=1.5, label="lower bound",
        )
    known_inc = [(n, c) for n, c in zip(nodes, incumbents, strict=True) if c is not None]
    if known_inc:
        ax.plot(
            [n for n, _ in known_inc], [c for _, c in known_inc],
            drawstyle="steps-post", color="C0", linewidth=1.5, label="incumbent",
        )
    both = [
        (n, c, b)
        for n, c, b in zip(nodes, incumbents, bounds, strict=True)
        if c is not None and math.isfinite(b)
    ]
    if both:
        ax.fill_between(
            [n for n, _, _ in both], [b for _, _, b in both], [c for _, c, _ in both],
            step="post", color="C0", alpha=0.15, linewidth=0, label="_gap",
        )
    ax.set_xscale("log")
    ax.set_xlabel("nodes expanded")
    ax.set_ylabel("cost")
    ax.set_title("certification trace")

    outcome = _trace_outcome(result, stats)
    last_n = nodes[-1]
    last_y = incumbents[-1] if incumbents[-1] is not None else (
        bounds[-1] if math.isfinite(bounds[-1]) else 0.0
    )
    ax.plot([last_n], [last_y], marker="o", color="0.2", markersize=5, zorder=5,
            label="_terminal")
    ax.annotate(
        outcome, xy=(last_n, last_y), xytext=(-6, 8), textcoords="offset points",
        ha="right", fontsize=8, color="0.2",
    )
    if known_bound or known_inc:
        ax.legend(fontsize=7, frameon=False, loc="best")
    return ax

viz_batch

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_levers

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.

PARAMETER DESCRIPTION
batch

The batch result to summarize.

TYPE: BatchResult

k

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

TYPE: int | None DEFAULT: 0

normalize

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

TYPE: bool DEFAULT: True

top_n

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

TYPE: int DEFAULT: 20

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

TYPE: bool DEFAULT: True

ax

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

TYPE: Any DEFAULT: None

RETURNS DESCRIPTION
The axes the chart was drawn on.
RAISES DESCRIPTION
MissingExtraError

If matplotlib is not installed.

TreecfError

If batch has no plan matching k.

Source code in src/treecf/viz_batch.py
 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
119
120
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.

    Parameters
    ----------
    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

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.

PARAMETER DESCRIPTION
batch

The batch result to visualize.

TYPE: BatchResult

explainer

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

TYPE: Any DEFAULT: None

k

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

TYPE: int | None DEFAULT: 0

sort_rows

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

TYPE: bool DEFAULT: True

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.

TYPE: int DEFAULT: 30

ax

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

TYPE: Any DEFAULT: None

RETURNS DESCRIPTION
The axes the heatmap was drawn on.
RAISES 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
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
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.

    Parameters
    ----------
    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

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

PARAMETER DESCRIPTION
batch

The batch result to summarize.

TYPE: BatchResult

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.

TYPE: int | None DEFAULT: 0

axs

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

TYPE: Any DEFAULT: None

RETURNS DESCRIPTION
The array of 3 axes (cost, sparsity, feasibility) the panels were
drawn on.
RAISES DESCRIPTION
MissingExtraError

If matplotlib is not installed.

TreecfError

If batch has no records at all.

Source code in src/treecf/viz_batch.py
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
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).

    Parameters
    ----------
    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

plot_batch_deltas

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.

PARAMETER DESCRIPTION
batch

The batch result to visualize.

TYPE: BatchResult

explainer

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

TYPE: Any DEFAULT: None

k

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

TYPE: int | None DEFAULT: 0

top_n

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

TYPE: int DEFAULT: 10

ax

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

TYPE: Any DEFAULT: None

RETURNS DESCRIPTION
The axes the strip plot was drawn on.
RAISES 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
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
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.

    Parameters
    ----------
    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

recourse_burden_table

recourse_burden_table(
    batch: BatchResult,
    groups: Sequence[object],
    *,
    group_order: Sequence[object] | None = None,
    min_group_size: int = 10,
) -> list[dict[str, object]]

Recourse cost and availability by segment, one dict per group.

groups assigns a segment label to every input row of the batch, in the order the rows were solved (one label per distinct id). A row's burden is its cheapest feasible plan's distance; a row with no feasible plan has no burden and counts toward certified_no_share when every infeasibility marker it carries is certified, else unproven_no_share — an exhausted search is not a proven "no".

Burden compares costs under one declared cost model and constraint set; a disparity between groups is a finding to investigate, not a fairness verdict — which metric matters is a choice this table does not make.

PARAMETER DESCRIPTION
batch

The batch whose rows are being segmented.

TYPE: BatchResult

groups

One segment label per input row, aligned with the batch's row order.

TYPE: sequence

group_order

The groups to report, in order; defaults to the sorted labels.

TYPE: sequence DEFAULT: None

min_group_size

Groups smaller than this are flagged small.

TYPE: int DEFAULT: 10

RETURNS DESCRIPTION
list of dict

Per group: group, n, recourse_share, certified_no_share, unproven_no_share, median_burden, mean_burden, p90_burden, small (NaN burdens where a group has no feasible row).

RAISES DESCRIPTION
TreecfError

If groups does not have one label per batch row.

Source code in src/treecf/viz_batch.py
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
def recourse_burden_table(
    batch: BatchResult,
    groups: Sequence[object],
    *,
    group_order: Sequence[object] | None = None,
    min_group_size: int = 10,
) -> list[dict[str, object]]:
    """Recourse cost and availability by segment, one dict per group.

    ``groups`` assigns a segment label to every input row of the batch, in the
    order the rows were solved (one label per distinct id). A row's burden is
    its cheapest feasible plan's ``distance``; a row with no feasible plan has
    no burden and counts toward ``certified_no_share`` when every infeasibility
    marker it carries is certified, else ``unproven_no_share`` — an exhausted
    search is not a proven "no".

    Burden compares costs under one declared cost model and constraint set; a
    disparity between groups is a finding to investigate, not a fairness
    verdict — which metric matters is a choice this table does not make.

    Parameters
    ----------
    batch : BatchResult
        The batch whose rows are being segmented.
    groups : sequence
        One segment label per input row, aligned with the batch's row order.
    group_order : sequence, optional
        The groups to report, in order; defaults to the sorted labels.
    min_group_size : int
        Groups smaller than this are flagged ``small``.

    Returns
    -------
    list of dict
        Per group: ``group``, ``n``, ``recourse_share``,
        ``certified_no_share``, ``unproven_no_share``, ``median_burden``,
        ``mean_burden``, ``p90_burden``, ``small`` (NaN burdens where a group
        has no feasible row).

    Raises
    ------
    TreecfError
        If ``groups`` does not have one label per batch row.
    """
    import numpy as np

    rows = _rows_with_burdens(batch)
    if len(groups) != len(rows):
        raise TreecfError(
            f"groups has {len(groups)} labels but the batch has {len(rows)} rows"
        )
    by_group: dict[object, list[tuple[float | None, bool]]] = {}
    for label, (_row_id, burden, certified) in zip(groups, rows, strict=True):
        by_group.setdefault(label, []).append((burden, certified))
    order = list(group_order) if group_order is not None else sorted(by_group, key=str)

    table: list[dict[str, object]] = []
    for label in order:
        members = by_group.get(label, [])
        n = len(members)
        burdens = np.array([b for b, _ in members if b is not None], dtype=np.float64)
        certified_no = sum(1 for b, certified in members if b is None and certified)
        unproven_no = sum(1 for b, certified in members if b is None and not certified)
        table.append(
            {
                "group": label,
                "n": n,
                "recourse_share": len(burdens) / n if n else math.nan,
                "certified_no_share": certified_no / n if n else math.nan,
                "unproven_no_share": unproven_no / n if n else math.nan,
                "median_burden": float(np.median(burdens)) if len(burdens) else math.nan,
                "mean_burden": float(np.mean(burdens)) if len(burdens) else math.nan,
                "p90_burden": float(np.percentile(burdens, 90)) if len(burdens) else math.nan,
                "small": n < min_group_size,
            }
        )
    return table

plot_recourse_burden

plot_recourse_burden(
    batch: BatchResult,
    groups: Sequence[object],
    *,
    axes: Any = None,
    group_order: Sequence[object] | None = None,
    min_group_size: int = 10,
    stat: str = "median",
) -> Any

Who pays for recourse, and who has none: burden and availability by segment.

Panel A draws one burden ECDF per group among the rows that have recourse (colour and linestyle both cycle, so groups stay tellable apart without colour); panel B stacks each group's availability into has recourse, certified no recourse, and unproven no recourse — the last hatched, because an exhausted search is not a proven "no" and the eye must not merge the two.

Burden compares costs under one declared cost model and constraint set; a disparity between groups is a finding to investigate, not a fairness verdict — which metric matters is a choice this plot does not make. recourse_burden_table exposes the numbers behind the picture.

PARAMETER DESCRIPTION
batch

The batch whose rows are being segmented.

TYPE: BatchResult

groups

One segment label per input row, aligned with the batch's row order.

TYPE: sequence

axes

Target axes; a 1x2 figure is created when omitted.

TYPE: array of two matplotlib axes DEFAULT: None

group_order

The groups to draw, in order; defaults to the sorted labels.

TYPE: sequence DEFAULT: None

min_group_size

Groups smaller than this get " — small" appended in the legend.

TYPE: int DEFAULT: 10

stat

Which burden statistic the legend reports per group.

TYPE: ('median', 'mean', 'p90') DEFAULT: "median"

RETURNS DESCRIPTION
array of the two axes.
RAISES DESCRIPTION
MissingExtraError

If matplotlib is not installed.

TreecfError

If groups is misaligned or stat is unrecognized.

Source code in src/treecf/viz_batch.py
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
def plot_recourse_burden(
    batch: BatchResult,
    groups: Sequence[object],
    *,
    axes: Any = None,
    group_order: Sequence[object] | None = None,
    min_group_size: int = 10,
    stat: str = "median",
) -> Any:
    """Who pays for recourse, and who has none: burden and availability by segment.

    Panel A draws one burden ECDF per group among the rows that have recourse
    (colour and linestyle both cycle, so groups stay tellable apart without
    colour); panel B stacks each group's availability into *has recourse*,
    *certified no recourse*, and *unproven no recourse* — the last hatched,
    because an exhausted search is not a proven "no" and the eye must not
    merge the two.

    Burden compares costs under one declared cost model and constraint set; a
    disparity between groups is a finding to investigate, not a fairness
    verdict — which metric matters is a choice this plot does not make.
    ``recourse_burden_table`` exposes the numbers behind the picture.

    Parameters
    ----------
    batch : BatchResult
        The batch whose rows are being segmented.
    groups : sequence
        One segment label per input row, aligned with the batch's row order.
    axes : array of two matplotlib axes, optional
        Target axes; a 1x2 figure is created when omitted.
    group_order : sequence, optional
        The groups to draw, in order; defaults to the sorted labels.
    min_group_size : int
        Groups smaller than this get ``" — small"`` appended in the legend.
    stat : {"median", "mean", "p90"}
        Which burden statistic the legend reports per group.

    Returns
    -------
    array of the two axes.

    Raises
    ------
    MissingExtraError
        If matplotlib is not installed.
    TreecfError
        If ``groups`` is misaligned or ``stat`` is unrecognized.
    """
    plt = _import_pyplot()
    import numpy as np

    stat_key = {"median": "median_burden", "mean": "mean_burden", "p90": "p90_burden"}
    if stat not in stat_key:
        raise TreecfError(f"unknown stat {stat!r}; use 'median', 'mean', or 'p90'")
    table = recourse_burden_table(
        batch, groups, group_order=group_order, min_group_size=min_group_size
    )
    rows = _rows_with_burdens(batch)
    by_group: dict[object, list[tuple[float | None, bool]]] = {}
    for label, (_row_id, burden, certified) in zip(groups, rows, strict=True):
        by_group.setdefault(label, []).append((burden, certified))

    if axes is None:
        _, axes = plt.subplots(1, 2, figsize=(11, 4))
    ecdf_ax, avail_ax = axes[0], axes[1]

    linestyles = ("-", "--", "-.", ":")
    for i, entry in enumerate(table):
        label = entry["group"]
        burdens = np.sort(
            np.array(
                [b for b, _ in by_group.get(label, []) if b is not None],
                dtype=np.float64,
            )
        )
        if len(burdens) == 0:
            continue
        y = np.arange(1, len(burdens) + 1) / len(burdens)
        suffix = " — small" if entry["small"] else ""
        value = entry[stat_key[stat]]
        ecdf_ax.step(
            burdens,
            y,
            where="post",
            color=f"C{i % 10}",
            linestyle=linestyles[i % len(linestyles)],
            label=f"{label} (n={entry['n']}, {stat} J={value:.3g}){suffix}",
        )
    ecdf_ax.set_xlabel("recourse cost J")
    ecdf_ax.set_ylabel("share of rows with recourse")
    ecdf_ax.set_ylim(0.0, 1.05)
    ecdf_ax.legend(fontsize=7, frameon=False)
    ecdf_ax.set_title("burden among rows with recourse")

    positions = np.arange(len(table), dtype=np.float64)
    for i, entry in enumerate(table):
        n = int(entry["n"])  # type: ignore[call-overload]
        members = by_group.get(entry["group"], [])
        has = sum(1 for b, _ in members if b is not None)
        certified_no = sum(1 for b, certified in members if b is None and certified)
        unproven_no = n - has - certified_no
        shares = [
            (has, "has recourse", "C0", None),
            (certified_no, "certified no recourse", "C3", None),
            (unproven_no, "unproven no recourse", "0.7", "///"),
        ]
        bottom = 0.0
        for count, seg_label, color, hatch in shares:
            share = count / n if n else 0.0
            avail_ax.bar(
                positions[i], share, bottom=bottom, width=0.7, color=color,
                hatch=hatch, edgecolor="white",
                label=seg_label if i == 0 else "_nolegend_",
            )
            if count:
                avail_ax.annotate(
                    str(count), xy=(positions[i], bottom + share / 2),
                    ha="center", va="center", fontsize=7, color="white" if hatch is None else "0.2",
                )
            bottom += share
        avail_ax.annotate(
            f"n={n}", xy=(positions[i], 1.02), ha="center", fontsize=7, color="0.35"
        )
    avail_ax.set_xticks(positions)
    avail_ax.set_xticklabels([str(entry["group"]) for entry in table])
    avail_ax.set_ylim(0.0, 1.12)
    avail_ax.set_ylabel("share of rows")
    avail_ax.legend(fontsize=7, frameon=False, loc="lower right")
    avail_ax.set_title("recourse availability")
    return axes