Skip to content

Plots

We are not replacing your plotting library. Nine checks draw a chart, and each one exists because a scalar loses something a reviewer needs.

pip install "bdp-model-gate[plots]"

Without the extra, plot() raises GateConfigurationError naming it and the HTML report renders text-only — the same way shap and fairlearn already degrade.

The contract

ax = check.plot(context, results=None, ax=None)

An Axes in, the same Axes out. That is the whole composition story: lay out your own figure and pass each cell in.

import matplotlib.pyplot as plt
from bdp_model_gate.structured.calibration_checks import (
    CalibrationCheck,
    SubgroupCalibrationCheck,
)

fig, (left, right) = plt.subplots(1, 2, figsize=(11, 5))
CalibrationCheck().plot(context, ax=left)
SubgroupCalibrationCheck().plot(context, ax=right)
fig.suptitle("Sufficiency, overall and by region")
fig.savefig("fairness.svg")

Everything after that is yours — restyle it, relabel it, drop it into a slide.

results is optional. Pass the results you already have and the plot draws those; omit it and the check runs itself.

What gets drawn, and why

Plot where a check collapses a distribution to a scalar and the shape is what you need to judge. Latency, cost and model-card completeness are genuinely scalars; charting them would be decoration. A binary confusion matrix is four numbers the detail line already carries.

Plot Check What the number cannot say
Reliability curve calibration two models with an identical ECE can be miscalibrated in opposite directions
Reliability per group subgroup_calibration where the aggregate hides a minority
TPR/FPR bars equalised_odds which notion the model fails, and by how much
η² heatmap proxy_correlation replaces a forty-row table; the eye finds the hot cell
Threshold sweep disparate_impact whether the verdict survives a small change of cutoff
Actual-vs-expected by band calibration_parity "wrong by 25,000" versus "under-priced in the top decile"
Loss-ratio scatter loss_ratio_parity whether the margin gap is flat or grows with the risk
Ordinal confusion performance_thresholds quadratic_kappa hides direction
Robustness sweep adversarial_robustness flat-then-collapse is a different risk from linear decay

Three deserve a longer note.

The threshold sweep

A parity difference is computed at one cutoff, and a cutoff is a cliff edge: 0.49 and 0.51 can land on opposite sides of the verdict. The sweep answers the question a reviewer actually has — is this pass robust, or did the cutoff happen to fall in a good place? A curve that peaks just beside the marked point is a pass you should not bank on.

Actual against expected, by band

A mean residual is one number for the whole book, and a book is not uniform. A group whose ratio sits at 1.0 across nine bands and 0.7 in the tenth has a segment problem, not a pricing problem, and only the banded view says so.

The robustness sweep — opt in

AdversarialRobustnessCheck(plot_sweep=True)

Off by default. Every other plot reads data already in hand; this one re-scores the sample at each epsilon, which is real money against a metered endpoint. When it is off, plot() returns None and the report simply omits the chart.

A chart may not contradict the number beside it

This is the rule the release was built around. A plot that disagrees with the finding printed next to it is worse than no plot: it is a second, more persuasive claim with nothing checking it.

Two checks score a subsample for speed. A plot that re-sampled would draw different rows than the verdict came from. Three things prevent it:

  • stable_sample is content-addressed. It selects rows by their contents, not their position, so the same data in a different order yields the same sample — and the plot gets the rows the finding came from by construction.
  • One implementation, not two. The perturbation core lives in AdversarialRobustnessCheck._measure(context, epsilon), which both run() and plot() call. ProxyCorrelationCheck._grid() is the same idea: the heatmap and the findings read one object.
  • Tests read the values back off the Axes and assert them against metadata — bar heights against group_tpr, ray slopes against group_loss_ratio, the ECE rebuilt from the plotted points and their marker areas.

If you write your own plot(), hold it to the same standard: recompute from the check's own helper, or assert the drawn value against metadata.

Style

The palette matches this site, and two colour systems are kept strictly apart.

Semantic — pass, review, blocked. These mean something. A group must never borrow them: a green bar that happens to be group A, sitting next to a green verdict pill, is a misread waiting to happen.

Categorical — for groups. Okabe–Ito, the standard colour-blind-safe qualitative palette, because roughly 8% of men have some colour vision deficiency and a gate report is a document a regulator may read.

Colour is never the only encoding. Series carry marker shapes, paired bars carry hatching, and heatmap cells that were reported are ringed — these reports get printed in greyscale.

from bdp_model_gate.plots.style import (
    CATEGORICAL,
    VERDICT_COLOURS,
    apply_style,
    categorical,
    markers,
)

apply_style()  # house rcParams, applied by every plot() anyway
colours = categorical(4)  # four CVD-safe hues
shapes = markers(4)  # the shapes to pair them with

apply_style() sets rcParams rather than using a style context, so a figure composed from several plots stays consistent — and your own call comes last, so it wins.

Writing a plot() for your own check

Override the method. There is nothing to register: the report renderer calls plot() on every check and uses whatever comes back.

from bdp_model_gate.core.base import BaseCheck, CheckResult


class TenureParityCheck(BaseCheck):
    name = "tenure_parity"
    category = "fairness"
    blocking = False

    def run(self, context): ...  # returns [CheckResult(..., metadata={"group_rate": {...}})]

    def plot(self, context, results=None, ax=None):
        from bdp_model_gate.plots import require_plotting, worst_result
        from bdp_model_gate.plots.style import caption, categorical, new_axes

        require_plotting()
        results = self.run(context) if results is None else results
        finding = worst_result(results, "rate_gap")
        if finding is None:
            return None  # nothing to draw is not an error

        rates = finding.metadata["group_rate"]
        ax = new_axes(ax)
        ax.bar(list(rates), list(rates.values()), color=categorical(len(rates)))
        caption(ax, "each bar is a group's rate; the gap is what was reported.")
        return ax

Four rules:

  1. Return None rather than an empty frame when the inputs are missing. Most checks have nothing to draw most of the time.
  2. Never raise. The report catches it and prints a note, but the reviewer still loses the chart.
  3. Recompute; do not store. Small per-group dicts in metadata are findings and stay. Anything array-sized — bin edges, curve points, per-row SHAP — is recomputed at plot time, so the archival JSON does not carry presentation data most consumers never read.
  4. Use seaborn's axes-level functions only (barplot, lineplot, heatmap, scatterplot, histplot). The figure-level ones (relplot, catplot, displot) build their own Figure and do not accept ax, which breaks the composition contract.

API

bdp_model_gate.plots

Optional plotting for the checks that produce a shape, not just a number.

Plots are drawn only where a check collapses a distribution to a scalar and the shape is what a reviewer needs to judge. Latency, cost and model-card completeness are genuinely scalars; charting them would be decoration.

The contract with your own plotting code is deliberately narrow: every plot() takes an optional matplotlib Axes and returns it. We draw onto your canvas and hand it back, so these compose into your figures and can be restyled. This package does not replace your plotting library.

matplotlib and seaborn live in the [plots] extra. Without them the plotting calls raise GateConfigurationError naming the extra, and the HTML report renders text-only — the same degradation shap and fairlearn already follow.

require_plotting

require_plotting()

Returns (pyplot, seaborn), or explains how to get them.

Imported lazily rather than at module load so that importing bdp_model_gate never costs a matplotlib import, which is slow and pulls a font cache on first use.

Source code in bdp_model_gate/plots/__init__.py
def require_plotting() -> tuple[Any, Any]:
    """Returns (pyplot, seaborn), or explains how to get them.

    Imported lazily rather than at module load so that importing
    `bdp_model_gate` never costs a matplotlib import, which is slow and pulls
    a font cache on first use.
    """
    try:
        import matplotlib.pyplot as plt
        import seaborn as sns
    except ImportError as exc:  # pragma: no cover - exercised in the core-install CI job
        raise GateConfigurationError(_MISSING) from exc
    return plt, sns

plotting_available

plotting_available()

Whether the [plots] extra is installed. Used by the report renderer to degrade to text rather than fail.

Source code in bdp_model_gate/plots/__init__.py
def plotting_available() -> bool:
    """Whether the `[plots]` extra is installed. Used by the report renderer
    to degrade to text rather than fail."""
    try:
        require_plotting()
    except GateConfigurationError:
        return False
    return True

worst_result

worst_result(results, key)

The result carrying the largest key, or None.

A plot() is handed one Axes, and a check may have scored six protected attributes. Where only one can be drawn, draw the one the reader is being asked to judge. Taking the first attribute instead would quietly hide the finding on any report whose verdict came from the last one.

Source code in bdp_model_gate/plots/__init__.py
def worst_result(results: Any, key: str) -> Any:
    """The result carrying the largest `key`, or None.

    A `plot()` is handed one Axes, and a check may have scored six protected
    attributes. Where only one can be drawn, draw the one the reader is being
    asked to judge. Taking the first attribute instead would quietly hide the
    finding on any report whose verdict came from the last one.
    """
    scored = [r for r in (results or []) if key in getattr(r, "metadata", {})]
    return max(scored, key=lambda r: r.metadata[key]) if scored else None

bdp_model_gate.plots.style

House style, shared with the documentation site.

Two colour systems, kept strictly apart:

  • Semantic — pass / review / blocked. These mean something, and a group must never borrow them. A green bar that happens to be group A, next to a green verdict pill, is a misread waiting to happen.
  • Categorical — for groups. Colour-blind safe, because roughly 8% of men have some colour vision deficiency and a gate report is a document a regulator may read. Accessibility is not optional here.

Colour is also never the only encoding: helpers pair it with marker shape or hatching, since these reports get printed in greyscale.

apply_style

apply_style()

Applies the house style to the current matplotlib session.

Called by every plot(), so a user who only wants one chart gets the styling without setting anything up. It sets rcParams rather than using a style context, so a caller composing several plots into one figure gets a consistent result — and can override afterwards, since their call comes last.

Source code in bdp_model_gate/plots/style.py
def apply_style() -> None:
    """Applies the house style to the current matplotlib session.

    Called by every `plot()`, so a user who only wants one chart gets the
    styling without setting anything up. It sets rcParams rather than using a
    style context, so a caller composing several plots into one figure gets a
    consistent result — and can override afterwards, since their call comes
    last.
    """
    plt, _ = _import()
    plt.rcParams.update({**_RC, "font.family": _font_stack()})

new_axes

new_axes(ax=None, figsize=(6.4, 3.8))

Returns the caller's Axes, or a new one.

Accepting an Axes is the whole composition contract: a caller can lay out small multiples and pass each cell in.

Source code in bdp_model_gate/plots/style.py
def new_axes(ax: Any = None, figsize: tuple[float, float] = (6.4, 3.8)) -> Any:
    """Returns the caller's Axes, or a new one.

    Accepting an Axes is the whole composition contract: a caller can lay out
    small multiples and pass each cell in.
    """
    plt, _ = _import()
    apply_style()
    if ax is not None:
        return ax
    _, ax = plt.subplots(figsize=figsize)
    return ax

categorical

categorical(n)

n colour-blind-safe colours, cycling if more are asked for.

Source code in bdp_model_gate/plots/style.py
def categorical(n: int) -> list[str]:
    """`n` colour-blind-safe colours, cycling if more are asked for."""
    return [CATEGORICAL[i % len(CATEGORICAL)] for i in range(n)]

markers

markers(n)

Marker shapes to pair with categorical, so colour is never the only encoding.

Source code in bdp_model_gate/plots/style.py
def markers(n: int) -> list[str]:
    """Marker shapes to pair with `categorical`, so colour is never the only
    encoding."""
    return [MARKERS[i % len(MARKERS)] for i in range(n)]

verdict_colour

verdict_colour(flag)

Semantic colour for a CheckResult.flag. Anything unrecognised is a risk flag, so it reads as blocked rather than silently neutral.

Source code in bdp_model_gate/plots/style.py
def verdict_colour(flag: str) -> str:
    """Semantic colour for a `CheckResult.flag`. Anything unrecognised is a
    risk flag, so it reads as blocked rather than silently neutral."""
    return VERDICT_COLOURS.get(flag, VERDICT_COLOURS["BLOCKED"])

caption

caption(ax, text)

A note below the axes saying how to read the plot.

The vertical position is anchored to the x-axis label artist rather than to a fixed offset from the axes: tick labels vary in height — one line on a sweep, three on a banded heatmap — and any constant offset that clears the tallest overlaps on the shortest. The horizontal position stays in axes coordinates, because the x label is centred and hanging the caption off its left edge would indent it to the middle of the plot.

Worth the trouble because these charts are read by people who did not build the model. "Above 1 means under-predicted" is the difference between a plot that informs a decision and one that decorates a page.

Source code in bdp_model_gate/plots/style.py
def caption(ax: Any, text: str) -> None:
    """A note below the axes saying how to read the plot.

    The vertical position is anchored to the x-axis *label artist* rather than
    to a fixed offset from the axes: tick labels vary in height — one line on
    a sweep, three on a banded heatmap — and any constant offset that clears
    the tallest overlaps on the shortest. The horizontal position stays in
    axes coordinates, because the x label is centred and hanging the caption
    off its left edge would indent it to the middle of the plot.

    Worth the trouble because these charts are read by people who did not
    build the model. "Above 1 means under-predicted" is the difference
    between a plot that informs a decision and one that decorates a page.
    """
    ax.annotate(
        text,
        xy=(0, 0),
        xycoords=("axes fraction", ax.xaxis.label),
        xytext=(0, -12),
        textcoords="offset points",
        ha="left",
        va="top",
        fontsize=8,
        color=MUTED,
    )

ring_cell

ring_cell(ax, x, y, colour)

Outlines one heatmap cell, legibly on any fill underneath it.

Two strokes: a wider one in the surface colour, then the real one on top. A single stroke has to be readable against both the palest and the darkest cell in the map, and no colour is.

Inset slightly, because a cell on the edge of the grid has half its outline clipped by the axes and reads as absent — which on a confusion matrix silently drops the two corners of the diagonal.

Source code in bdp_model_gate/plots/style.py
def ring_cell(ax: Any, x: int, y: int, colour: str) -> None:
    """Outlines one heatmap cell, legibly on any fill underneath it.

    Two strokes: a wider one in the surface colour, then the real one on top.
    A single stroke has to be readable against both the palest and the
    darkest cell in the map, and no colour is.

    Inset slightly, because a cell on the edge of the grid has half its
    outline clipped by the axes and reads as absent — which on a confusion
    matrix silently drops the two corners of the diagonal.
    """
    from matplotlib.patches import Rectangle

    for width, edge in ((3.4, "#ffffff"), (1.6, colour)):
        ax.add_patch(
            Rectangle(
                (x + 0.025, y + 0.025),
                0.95,
                0.95,
                fill=False,
                edgecolor=edge,
                linewidth=width,
                zorder=4,
            )
        )

sharpen_colourbar

sharpen_colourbar(ax)

Keeps a heatmap's colour bar as vector geometry.

matplotlib rasterises colour bar solids by default — a workaround for hairline seams between cells in some PDF viewers — which embeds a base64 PNG inside what is otherwise a vector figure. That PNG is then the one soft edge on a printed report, and it inflates a self-contained page by the size of a bitmap per chart. The seams are the lesser problem.

Source code in bdp_model_gate/plots/style.py
def sharpen_colourbar(ax: Any) -> None:
    """Keeps a heatmap's colour bar as vector geometry.

    matplotlib rasterises colour bar solids by default — a workaround for
    hairline seams between cells in some PDF viewers — which embeds a base64
    PNG inside what is otherwise a vector figure. That PNG is then the one
    soft edge on a printed report, and it inflates a self-contained page by
    the size of a bitmap per chart. The seams are the lesser problem.
    """
    bar = getattr(ax.collections[0], "colorbar", None) if len(ax.collections) else None
    if bar is not None and getattr(bar, "solids", None) is not None:
        bar.solids.set_rasterized(False)

themeable_svg

themeable_svg(svg)

Rewrites an SVG's structural colours as CSS custom properties.

An SVG inlined into HTML — rather than referenced as <img src="data:"> — participates in the page's cascade, so this is what lets one render read correctly in both light and dark. Every property carries the original hex as its fallback, so the SVG still stands alone in a viewer that has never heard of the variables.

Source code in bdp_model_gate/plots/style.py
def themeable_svg(svg: str) -> str:
    """Rewrites an SVG's structural colours as CSS custom properties.

    An SVG inlined into HTML — rather than referenced as `<img src="data:">` —
    participates in the page's cascade, so this is what lets one render read
    correctly in both light and dark. Every property carries the original hex
    as its fallback, so the SVG still stands alone in a viewer that has never
    heard of the variables.
    """
    for literal, template in _THEMEABLE.items():
        replacement = template.format(literal)
        svg = svg.replace(literal, replacement).replace(literal.upper(), replacement)
    return svg