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.
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¶
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¶
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_sampleis 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 bothrun()andplot()call.ProxyCorrelationCheck._grid()is the same idea: the heatmap and the findings read one object. - Tests read the values back off the
Axesand assert them againstmetadata— bar heights againstgroup_tpr, ray slopes againstgroup_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:
- Return
Nonerather than an empty frame when the inputs are missing. Most checks have nothing to draw most of the time. - Never raise. The report catches it and prints a note, but the reviewer still loses the chart.
- Recompute; do not store. Small per-group dicts in
metadataare 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. - 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 acceptax, 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
¶
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
plotting_available
¶
Whether the [plots] extra is installed. Used by the report renderer
to degrade to text rather than fail.
worst_result
¶
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
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
¶
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
new_axes
¶
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
categorical
¶
markers
¶
Marker shapes to pair with categorical, so colour is never the only
encoding.
verdict_colour
¶
Semantic colour for a CheckResult.flag. Anything unrecognised is a
risk flag, so it reads as blocked rather than silently neutral.
caption
¶
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
ring_cell
¶
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
sharpen_colourbar
¶
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
themeable_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.