Skip to content

API reference

Generated from the source, so it cannot drift from the code.

Core

bdp_model_gate.core.context.StructuredGateContext dataclass

StructuredGateContext(
    model=None,
    X=None,
    y_true=None,
    y_pred=None,
    protected_df=None,
    latencies_ms=None,
    cost_per_inference=None,
    model_card=None,
    generate_fn=None,
    expected_loss=None,
    predict_fn=None,
    predict_proba_fn=None,
    gradient_fn=None,
    class_order=None,
    favourable_classes=None,
    modality="structured",
    task="auto",
)

Everything a structured-data governance check needs to run.

Only model, X, y_true, and y_pred are required. Every other field is optional — omitting one simply causes the checks that depend on it to report NOT_APPLICABLE rather than raise or fail the gate. All inputs are validated eagerly by ModelGate.run() before any check executes; see bdp_model_gate.core.validation.

Attributes:

Name Type Description
model Any

A fitted model exposing .predict() — scikit-learn, Keras, LightGBM, XGBoost's sklearn API, or your own class — or any plain callable. Optional if predict_fn is supplied instead, which is the route for a PyTorch module, a raw Booster, or a remote scoring endpoint where there is no model object at all.

X DataFrame

Feature dataframe used for validation/inference.

y_true Sequence[Any] | None

Ground-truth labels for the validation set.

y_pred Sequence[Any] | None

Model predictions on X (probabilities or hard labels, per your metric).

protected_df DataFrame | None

Dataframe of protected attributes (gender, region, etc.), row-aligned to X. Needed for the fairness checks.

latencies_ms Sequence[float] | None

Per-request inference latencies from a benchmark run, for the performance gate.

cost_per_inference float | None

Estimated or measured cost per inference, for the performance gate.

model_card dict | None

Dict describing the model (legal_basis, use_case, etc.), for the compliance gate.

generate_fn Callable[[str], str] | None

callable(str) -> str, the entry point of any generative component sitting alongside the structured model, for prompt injection testing.

expected_loss Sequence[float] | None

Per-row expected loss (or technical/pure premium), row-aligned to X. Enables LossRatioParityCheck, which asks whether one group is charged a higher margin over its own expected cost — the actuarially meaningful fairness question for a pricing model, since risk-based premium differences are not by themselves discriminatory.

predict_fn Callable[[DataFrame], Any] | None

fn(DataFrame) -> array returning point predictions. Takes precedence over model. The boundary is deliberately "DataFrame in, array out": your function owns tensor conversion, device placement and batching, so this library never imports a deep-learning framework.

predict_proba_fn Callable[[DataFrame], Any] | None

fn(DataFrame) -> array returning class probabilities. (n,), (n, 1) (Keras sigmoid) and (n, 2) (scikit-learn) are all accepted. Enables CounterfactualFlipCheck for models with no .predict_proba().

gradient_fn Callable[[DataFrame], Any] | None

fn(DataFrame) -> array of shape (n_rows, n_features), aligned to X.columns. Lets a differentiable model drive a real targeted attack in AdversarialRobustnessCheck instead of the random-noise fallback.

class_order Sequence[Any] | None

For multiclass problems, the class labels in ascending order of favourability, e.g. ["decline", "refer", "accept"]. Supplying it marks the problem as ordinal, unlocking metrics that know a decline-vs-accept error costs more than a refer-vs-accept one. Omit it for a nominal problem where no ordering exists.

favourable_classes Sequence[Any] | None

Which outcomes count as a positive result for demographic parity. Defaults to the most favourable class when class_order is given (and says so in the log); without either, the multiclass parity check reports NOT_APPLICABLE rather than picking one arbitrarily.

task str

"auto" (default), "binary", "multiclass" or "regression". "auto" infers from y_true and logs what it inferred. Set it explicitly for anything you gate on: a claims-frequency target of 0/1/2/3 is indistinguishable from a four-class problem by shape. See bdp_model_gate.task.

bdp_model_gate.core.gate.ModelGate

ModelGate(checks=None, config=None)

Runs a list of governance checks against a context and aggregates a GateReport.

If no checks are supplied, defaults to the full structured-data check suite (built-in checks plus any registered via the plugin entry-point group — see bdp_model_gate.registry). Pass a custom checks list to run a subset, add your own checks (subclass BaseCheck), or reorder blocking behavior.

Source code in bdp_model_gate/core/gate.py
def __init__(self, checks: Sequence[BaseCheck] | None = None, config=None):
    from ..config import GateConfig  # local import avoids a hard cycle at module load

    self.config = config or GateConfig()
    self.checks = list(checks) if checks is not None else self._default_checks()

bdp_model_gate.core.report.GateReport dataclass

GateReport(
    results, model_metric=None, model_score=None, task=None
)

Aggregated results of a gate run.

model_metric/model_score are the headline score lifted from the performance check — whichever metric was configured, not always AUC. Both are None if no performance check ran or the score was unavailable.

model_auc property

model_auc

Deprecated. Returns model_score only when the configured metric really was ROC AUC, and None otherwise — reading it as "the AUC" was only ever correct by coincidence.

flags property

flags

All non-OK, non-NOT_APPLICABLE results.

gate_status property

gate_status

BLOCKED if any blocking check failed, NEEDS_REVIEW if only non-blocking checks failed, PASS otherwise.

to_html

to_html(
    path=None,
    checks=None,
    context=None,
    title="Model gate report",
    include_plots=True,
)

Renders the report as one self-contained HTML page.

A NEEDS_REVIEW verdict asks a person to decide, and to_json() hands that person a blob. This hands them a page: the verdict, every finding, and each check's plot beside the number it explains — no network, no JavaScript, nothing to install to open it.

Plots need the checks and the data they scored, which ModelGate.run attaches to the report for you. Pass checks and context explicitly to override, or include_plots=False for text only. Without the [plots] extra the page renders text-only by itself.

Source code in bdp_model_gate/core/report.py
def to_html(
    self,
    path: str | None = None,
    checks: Any = None,
    context: Any = None,
    title: str = "Model gate report",
    include_plots: bool = True,
) -> str:
    """Renders the report as one self-contained HTML page.

    A `NEEDS_REVIEW` verdict asks a person to decide, and `to_json()`
    hands that person a blob. This hands them a page: the verdict, every
    finding, and each check's plot beside the number it explains — no
    network, no JavaScript, nothing to install to open it.

    Plots need the checks and the data they scored, which `ModelGate.run`
    attaches to the report for you. Pass `checks` and `context` explicitly
    to override, or `include_plots=False` for text only. Without the
    `[plots]` extra the page renders text-only by itself.
    """
    from ..reporting import render_html

    page = render_html(
        self,
        checks=checks if checks is not None else self._checks,
        context=context if context is not None else self._context,
        title=title,
        include_plots=include_plots,
    )
    if path:
        with open(path, "w", encoding="utf-8") as f:
            f.write(page)
    return page

bdp_model_gate.core.base.CheckResult dataclass

CheckResult(
    check_name,
    category,
    flag,
    detail="",
    blocking=True,
    metadata=dict(),
    duration_ms=None,
)

The outcome of a single governance check.

flag is "OK", "NOT_APPLICABLE" (check skipped — e.g. optional input missing or optional dependency not installed), "CHECK_ERROR" (the check raised an exception), or a check-specific risk string such as "PROXY_RISK" or "PII_LEAKAGE_RISK".

bdp_model_gate.core.base.BaseCheck

Interface every governance check implements.

Subclasses set name, category, blocking and optionally supported_tasks as class attributes, and implement run(context). blocking=True means a failing flag from this check should block promotion outright; blocking=False routes a failure to human review instead (used for checks that need judgment, like fairness flags that may be false positives).

plot

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

Draw this check's finding, or return None if it has none to draw.

Optional. The report renderer calls plot() on every check and uses whatever comes back, so there is nothing to declare or register — an override is the whole opt-in.

Implementations take an optional matplotlib Axes and return it, which is what lets a caller compose these into their own figure and restyle the result. Draw only where the shape matters: a check whose finding is genuinely one number should leave this alone rather than chart it.

Anything a plot needs beyond CheckResult.metadata is recomputed here rather than stored on the result, so the archival JSON does not carry presentation data most consumers never read. A check that scored a subsample must redraw the same rows the finding came from — use bdp_model_gate._sampling.stable_sample, which is content-addressed and so returns the same rows by construction.

Source code in bdp_model_gate/core/base.py
def plot(self, context: Any, results: Any = None, ax: Any = None) -> Any:
    """Draw this check's finding, or return None if it has none to draw.

    Optional. The report renderer calls `plot()` on every check and uses
    whatever comes back, so there is nothing to declare or register — an
    override is the whole opt-in.

    Implementations take an optional matplotlib `Axes` and **return it**,
    which is what lets a caller compose these into their own figure and
    restyle the result. Draw only where the shape matters: a check whose
    finding is genuinely one number should leave this alone rather than
    chart it.

    Anything a plot needs beyond `CheckResult.metadata` is recomputed here
    rather than stored on the result, so the archival JSON does not carry
    presentation data most consumers never read. A check that scored a
    *subsample* must redraw the same rows the finding came from — use
    `bdp_model_gate._sampling.stable_sample`, which is content-addressed
    and so returns the same rows by construction.
    """
    return None

Configuration

bdp_model_gate.config

Configuration dataclasses for every gate category. Override any field to tune thresholds per model/use case; defaults are reasonable starting points, not regulatory guidance.

FairnessConfig dataclass

FairnessConfig(
    disparity_threshold=0.1,
    decision_threshold=0.5,
    equalised_odds_threshold=0.1,
    subgroup_calibration_threshold=0.05,
    n_calibration_bins=10,
    intersectional=False,
    mean_gap_threshold=0.1,
    error_parity_threshold=0.2,
    calibration_threshold=0.1,
    loss_ratio_threshold=0.1,
    min_group_size=30,
    proxy_corr_threshold=0.3,
    shap_gap_threshold=0.5,
    counterfactual_shift_threshold=0.05,
)

Thresholds for the fairness checks.

decision_threshold turns continuous predictions into class labels for DisparateImpactCheck, which measures selection rates and so needs hard classes. Predictions already in {0, 1} are used as-is.

Every gap threshold here is relative, not absolute: each is measured as a fraction of the corresponding overall figure — the overall mean, the overall error, or the mean absolute SHAP contribution. That is what lets one default be meaningful for a premium in naira, a claim count and a probability alike. An absolute threshold in the units of the model output flags nothing on one scale and everything on another. min_group_size guards against a three-policy segment producing a wild ratio that reads as a fairness finding.

PerformanceConfig dataclass

PerformanceConfig(
    metric=AUTO,
    min_score=0.8,
    max_error=None,
    max_ece=0.1,
    n_calibration_bins=10,
    calibration_strategy="uniform",
    decision_threshold=0.5,
    average="macro",
    max_latency_ms_p95=200.0,
    max_cost_per_inference=0.002,
)

Thresholds for the performance gate.

metric selects how the model is scored — a name from bdp_model_gate.metrics.BUILTIN_METRICS ("roc_auc", "accuracy", "f1", "precision", "recall", "balanced_accuracy", "average_precision"), a fn(y_true, y_pred) -> float callable of your own, or "auto" to use whichever of roc_auc/accuracy the installed dependencies support. Under "auto" a fallback is logged and named in the report, never silent.

Metrics point in different directions. Higher-is-better metrics (roc_auc, f1, r2, ...) are gated with min_score; error metrics where lower is better (rmse, mae, mape, poisson_deviance) are gated with max_error, which has no default because a sensible ceiling depends entirely on the scale of your target. Configuring an error metric without max_error is a GateConfigurationError rather than a guess.

min_score/max_error are interpreted against whichever metric ran, so set the metric and its threshold together.

average is the multiclass averaging strategy for f1, precision and recall (scikit-learn's average=). It defaults to "macro", which weights every class equally — so a rarely predicted "decline" counts as much as a common "accept". Use "weighted" to weight by support instead. Ignored for binary and regression. decision_threshold is used to binarize continuous predictions for metrics that need hard class labels; it's ignored for ranking metrics like roc_auc and for custom callables.

min_accuracy property writable

min_accuracy

Deprecated alias for min_score.

The old name was misleading: the threshold was compared against ROC AUC whenever scikit-learn was installed, and against accuracy otherwise. Kept working so existing configs and CLI --config files don't break.

Tasks and classes

bdp_model_gate.task

Prediction-task identification.

Nearly half the check suite is task-agnostic — PII scanning, prompt injection, model-card compliance and proxy correlation care about features and documentation, not about what the model predicts. The rest is not: demographic parity needs a favourable class, a "prediction flip" means nothing for a continuous output, and ROC AUC cannot score a premium.

Every check therefore declares the tasks it supports via BaseCheck.supported_tasks, and reports NOT_APPLICABLE for the others rather than producing a confident, meaningless number.

StructuredGateContext.task names the task. It defaults to "auto", which infers from y_true and logs what it inferred — inference is genuinely ambiguous (a claims-frequency target of 0/1/2/3 is indistinguishable from a four-class problem by shape alone), so the guess is never silent. State task explicitly for anything you intend to gate on.

resolve_task

resolve_task(context)

Returns the concrete task for a context, inferring it if set to "auto".

Raises GateConfigurationError for an unknown setting. When "auto" is asked to infer from a context with no y_true, it warns and falls back to "binary" — the assumption every release before 0.3.0 made implicitly.

Source code in bdp_model_gate/task.py
def resolve_task(context: Any) -> str:
    """Returns the concrete task for a context, inferring it if set to "auto".

    Raises GateConfigurationError for an unknown setting. When "auto" is
    asked to infer from a context with no `y_true`, it warns and falls back
    to "binary" — the assumption every release before 0.3.0 made implicitly.
    """
    task = getattr(context, "task", AUTO)
    validate_task(task)

    if task != AUTO:
        return task

    y_true = getattr(context, "y_true", None)
    if y_true is None:
        # Nothing to infer from. Every task-specific check needs y_true too,
        # so only the task-agnostic ones (PII, prompt injection, model card,
        # proxy correlation) can run and the value barely matters — but say
        # so rather than let a silent assumption sit in the report.
        logger.warning(
            'context.task="auto" cannot infer without y_true — assuming %r, which is '
            "what releases before 0.3.0 always assumed. Only task-agnostic checks can "
            "run without labels. Set task explicitly to silence this.",
            BINARY,
        )
        return BINARY

    inferred = infer_task(y_true)
    logger.info(
        'context.task="auto" inferred task=%r from y_true. Set task explicitly if that '
        "is wrong — a count target (e.g. claims frequency) is indistinguishable from a "
        "multiclass one by shape alone.",
        inferred,
    )
    return inferred

infer_task

infer_task(y_true)

Best-effort task inference from the ground-truth labels.

Deliberately conservative and explainable rather than clever: strings and booleans are classification, two distinct values are binary, integral targets with few distinct values are multiclass, everything else is regression.

Source code in bdp_model_gate/task.py
def infer_task(y_true: Any) -> str:
    """Best-effort task inference from the ground-truth labels.

    Deliberately conservative and explainable rather than clever: strings and
    booleans are classification, two distinct values are binary, integral
    targets with few distinct values are multiclass, everything else is
    regression.
    """
    arr = np.asarray(y_true)

    if arr.dtype.kind in "OSUb":  # object, string, unicode, bool
        n_unique = len(np.unique(arr))
        return BINARY if n_unique <= 2 else MULTICLASS

    finite = arr[np.isfinite(arr)] if arr.dtype.kind in "fc" else arr
    uniques = np.unique(finite)
    n_unique = len(uniques)

    if n_unique <= 2:
        return BINARY

    is_integral = bool(np.all(np.equal(np.mod(uniques, 1), 0)))
    if is_integral and n_unique <= MAX_INFERRED_CLASSES:
        return MULTICLASS
    return REGRESSION

validate_task

validate_task(task)

Rejects an unusable task setting before any check runs.

Source code in bdp_model_gate/task.py
def validate_task(task: Any) -> None:
    """Rejects an unusable `task` setting before any check runs."""
    if not isinstance(task, str) or task not in VALID_SETTINGS:
        raise GateConfigurationError(
            f"context.task must be one of {', '.join(VALID_SETTINGS)} — got {task!r}"
        )

supports

supports(check, task)

Whether check declares support for task. Checks predating supported_tasks are treated as task-agnostic.

Source code in bdp_model_gate/task.py
def supports(check: Any, task: str) -> bool:
    """Whether `check` declares support for `task`. Checks predating
    `supported_tasks` are treated as task-agnostic."""
    return task in getattr(check, "supported_tasks", ALL_TASKS)

bdp_model_gate.classes

Class ordering and favourable outcomes for multiclass problems.

Binary classification carries two conventions for free: class 1 is the positive class, and there is nothing to order. Multiclass has neither, and guessing at them is how a governance tool produces a confident wrong answer.

Two pieces of problem structure make the multiclass checks meaningful:

class_order         The classes in ascending order of favourability,
                    e.g. ["decline", "refer", "accept"]. Supplying it
                    marks the problem as **ordinal**, which unlocks
                    metrics that know a decline-vs-accept error costs
                    more than a refer-vs-accept one. Omit it for a
                    genuinely nominal problem (product category, say),
                    where no ordering exists.

favourable_classes  Which outcomes count as a positive result for
                    demographic parity. Defaults to the single most
                    favourable class when `class_order` is given, and
                    is logged when inferred.

Both live on the context rather than in GateConfig, because they describe the problem rather than a threshold you might tune per run.

resolve_favourable

resolve_favourable(
    favourable_classes=None, class_order=None, task="binary"
)

Determines which classes count as a favourable outcome.

Returns None when it cannot be determined, which callers report as NOT_APPLICABLE rather than guessing — for a nominal multiclass problem there is no basis for picking one.

Source code in bdp_model_gate/classes.py
def resolve_favourable(
    favourable_classes: Any = None,
    class_order: Sequence[Any] | None = None,
    task: str = "binary",
) -> list[Any] | None:
    """Determines which classes count as a favourable outcome.

    Returns None when it cannot be determined, which callers report as
    NOT_APPLICABLE rather than guessing — for a nominal multiclass problem
    there is no basis for picking one.
    """
    if favourable_classes is not None:
        listed = list(favourable_classes)
        if not listed:
            raise GateConfigurationError(
                "context.favourable_classes is empty — omit it, or name at least one class"
            )
        if class_order is not None:
            known = {_key(c) for c in class_order}
            unknown = [c for c in listed if _key(c) not in known]
            if unknown:
                raise GateConfigurationError(
                    f"context.favourable_classes {unknown!r} are not in "
                    f"context.class_order {list(class_order)!r}"
                )
        return listed

    if task == "binary":
        return [1]

    if class_order is not None:
        best = class_order[-1]
        logger.info(
            "context.favourable_classes not set — treating %r, the last entry in "
            "class_order, as the favourable outcome. Set it explicitly if that is wrong.",
            best,
        )
        return [best]

    return None

to_ranks

to_ranks(values, class_order)

Converts class labels into ordinal ranks.

Rank distance is what makes an ordinal metric ordinal: predicting "decline" for an "accept" case is two steps wrong, "refer" is one.

Source code in bdp_model_gate/classes.py
def to_ranks(values: Any, class_order: Sequence[Any]) -> np.ndarray:
    """Converts class labels into ordinal ranks.

    Rank distance is what makes an ordinal metric ordinal: predicting
    "decline" for an "accept" case is two steps wrong, "refer" is one.
    """
    ranks = rank_map(class_order)
    arr = np.asarray(values)
    try:
        return np.array([ranks[_key(v)] for v in arr], dtype=float)
    except KeyError as exc:
        raise GateConfigurationError(
            f"label {exc.args[0]!r} is not in context.class_order {list(class_order)!r}"
        ) from exc

validate_class_order

validate_class_order(class_order, y_true=None)

Rejects an unusable class_order before any check runs.

Source code in bdp_model_gate/classes.py
def validate_class_order(class_order: Any, y_true: Any = None) -> None:
    """Rejects an unusable `class_order` before any check runs."""
    if class_order is None:
        return
    if isinstance(class_order, (str, bytes)) or not isinstance(class_order, Sequence):
        raise GateConfigurationError(
            "context.class_order must be a sequence of class labels in ascending "
            f"order of favourability, got {type(class_order).__name__}"
        )
    ordered = list(class_order)
    if len(ordered) < 2:
        raise GateConfigurationError(
            f"context.class_order needs at least two classes, got {ordered!r}"
        )
    if len(set(map(_key, ordered))) != len(ordered):
        raise GateConfigurationError(f"context.class_order has duplicate labels: {ordered!r}")

    if y_true is not None:
        known = {_key(c) for c in ordered}
        unseen = {_key(v) for v in np.unique(np.asarray(y_true))} - known
        if unseen:
            raise GateConfigurationError(
                f"y_true contains labels missing from context.class_order: "
                f"{sorted(map(str, unseen))} — class_order must list every class"
            )

favourable_mask

favourable_mask(values, favourable)

Boolean mask of rows whose label is a favourable outcome.

Source code in bdp_model_gate/classes.py
def favourable_mask(values: Any, favourable: Sequence[Any]) -> np.ndarray:
    """Boolean mask of rows whose label is a favourable outcome."""
    wanted = {_key(c) for c in favourable}
    return np.array([_key(v) in wanted for v in np.asarray(values)], dtype=bool)

Metrics

bdp_model_gate.metrics

Metric resolution for the performance gate.

The performance gate scores a model with whichever metric the caller configured (PerformanceConfig.metric). This module owns the mapping from that config value to a callable, and — importantly — makes the choice explicit in the report rather than silently depending on which optional dependencies happen to be installed.

Three kinds of value are accepted:

"auto"        try the metrics in AUTO_PREFERENCE in order, using the
              first one whose dependencies are available. A fallback is
              logged at WARNING level and named in the check's output,
              so it never happens invisibly.
"<name>"      any key of BUILTIN_METRICS. If its dependencies are
              missing, that's a GateConfigurationError — an explicit
              request is never silently substituted.
callable      any `fn(y_true, y_pred) -> float`. Called with y_pred
              exactly as supplied (no thresholding), since only the
              caller knows what their metric expects.

Metrics differ in what they want from y_pred: ranking metrics like roc_auc need continuous scores, while accuracy/f1/precision/ recall need hard class labels. needs_hard_labels records which, and the check binarizes at PerformanceConfig.decision_threshold when needed.

MetricSpec dataclass

MetricSpec(
    name,
    sklearn_fn,
    needs_hard_labels,
    fallback=None,
    greater_is_better=True,
    tasks=CLASSIFICATION_TASKS,
    needs_average=False,
    needs_class_order=False,
)

How to obtain one named metric, and what it expects from y_pred.

ResolvedMetric dataclass

ResolvedMetric(
    name,
    fn,
    needs_hard_labels,
    greater_is_better=True,
    is_fallback=False,
    used_fallback_impl=False,
)

A metric ready to call, plus how it was arrived at.

resolve_metric

resolve_metric(
    metric, task=BINARY, average="macro", class_order=None
)

Turns a config value into a callable metric.

Raises GateConfigurationError if an explicitly named metric can't be satisfied — the gate reports that as a blocking CHECK_ERROR rather than scoring the model with something the caller didn't ask for.

Source code in bdp_model_gate/metrics.py
def resolve_metric(
    metric: MetricSetting,
    task: str = BINARY,
    average: str = "macro",
    class_order: Any = None,
) -> ResolvedMetric:
    """Turns a config value into a callable metric.

    Raises GateConfigurationError if an explicitly named metric can't be
    satisfied — the gate reports that as a blocking CHECK_ERROR rather than
    scoring the model with something the caller didn't ask for.
    """
    validate_metric(metric, task)

    if callable(metric):
        name = getattr(metric, "__name__", None) or type(metric).__name__
        # A custom callable's direction is unknowable, so it is treated as
        # greater-is-better and gated with min_score. Negate inside your own
        # function, or name a built-in error metric, if that is wrong.
        return ResolvedMetric(name=name, fn=metric, needs_hard_labels=False)

    if metric == AUTO:
        return _resolve_auto(task)

    spec = BUILTIN_METRICS[metric]

    if spec.needs_class_order:
        if class_order is None:
            raise GateConfigurationError(
                f"performance.metric={metric!r} is an ordinal metric and needs "
                "context.class_order — the ordered class labels, least to most "
                'favourable, e.g. ["decline", "refer", "accept"]. Without an ordering '
                "there is no notion of how wrong a prediction is."
            )
        return ResolvedMetric(
            spec.name,
            functools.partial(_ORDINAL_IMPLS[spec.name], class_order=class_order),
            spec.needs_hard_labels,
            spec.greater_is_better,
        )

    fn = _load_sklearn_metric(spec)
    if fn is not None and spec.needs_average and task == MULTICLASS:
        # f1/precision/recall default to average="binary", which raises on a
        # multiclass target. macro weights every class equally, so a rarely
        # predicted "decline" counts as much as a common "accept".
        fn = functools.partial(fn, average=average)
    if fn is not None:
        return ResolvedMetric(spec.name, fn, spec.needs_hard_labels, spec.greater_is_better)
    if spec.fallback is not None:
        logger.debug(
            "scikit-learn not installed — scoring %r with the built-in numpy implementation",
            spec.name,
        )
        return ResolvedMetric(
            spec.name,
            spec.fallback,
            spec.needs_hard_labels,
            spec.greater_is_better,
            used_fallback_impl=True,
        )
    raise GateConfigurationError(
        f"performance.metric={metric!r} requires scikit-learn — install it with "
        "`pip install bdp-model-gate[structured]`, or set performance.metric to "
        f"one of: {', '.join(sorted(m for m, s in BUILTIN_METRICS.items() if s.fallback))}"
    )

validate_metric

validate_metric(metric, task=None)

Cheap, import-free check that metric is a usable setting.

Called when the check is constructed so a typo'd metric name fails at configuration time rather than midway through a gate run. Whether the metric's dependencies are actually installed is deliberately not checked here — see resolve_metric.

Source code in bdp_model_gate/metrics.py
def validate_metric(metric: MetricSetting, task: str | None = None) -> None:
    """Cheap, import-free check that `metric` is a usable setting.

    Called when the check is constructed so a typo'd metric name fails at
    configuration time rather than midway through a gate run. Whether the
    metric's dependencies are actually installed is deliberately *not*
    checked here — see `resolve_metric`.
    """
    if callable(metric):
        return
    if not isinstance(metric, str):
        raise GateConfigurationError(
            f"performance.metric must be a metric name or a callable, got {type(metric).__name__}"
        )
    if metric == AUTO:
        return
    if metric not in BUILTIN_METRICS:
        valid = ", ".join([AUTO, *sorted(BUILTIN_METRICS)])
        raise GateConfigurationError(
            f"unknown performance.metric {metric!r} — valid options: {valid}"
        )
    if task is not None and task not in BUILTIN_METRICS[metric].tasks:
        applicable = ", ".join(sorted(m for m, sp in BUILTIN_METRICS.items() if task in sp.tasks))
        raise GateConfigurationError(
            f"performance.metric={metric!r} does not apply to a {task} task — "
            f"metrics available for {task}: {applicable}"
        )

to_hard_labels

to_hard_labels(y_pred, threshold)

Binarizes continuous scores for a metric that needs class labels.

Values already restricted to {0, 1} are passed through untouched, so callers who supply hard labels aren't affected by decision_threshold.

Source code in bdp_model_gate/metrics.py
def to_hard_labels(y_pred: Any, threshold: float) -> Any:
    """Binarizes continuous scores for a metric that needs class labels.

    Values already restricted to {0, 1} are passed through untouched, so
    callers who supply hard labels aren't affected by `decision_threshold`.
    """
    arr = np.asarray(y_pred)
    if arr.dtype.kind not in "fc":
        return arr
    if np.all(np.isin(arr, (0, 1))):
        return arr.astype(int)
    logger.debug("binarizing continuous y_pred at decision_threshold=%s", threshold)
    return (arr >= threshold).astype(int)

to_class_labels

to_class_labels(y_pred, class_order=None)

Reduces multiclass predictions to one label per row.

Accepts predicted labels as-is. An (n, n_classes) probability matrix is reduced by argmax, mapped back through class_order when it is known so the result is comparable with y_true rather than a bare column index.

Source code in bdp_model_gate/metrics.py
def to_class_labels(y_pred: Any, class_order: Any = None) -> Any:
    """Reduces multiclass predictions to one label per row.

    Accepts predicted labels as-is. An (n, n_classes) probability matrix is
    reduced by argmax, mapped back through `class_order` when it is known so
    the result is comparable with `y_true` rather than a bare column index.
    """
    arr = np.asarray(y_pred)
    if arr.ndim == 1:
        return arr
    if arr.ndim != 2:
        raise GateConfigurationError(
            f"y_pred has {arr.ndim} dimensions; expected labels or an "
            "(n_rows, n_classes) probability matrix"
        )
    indices = np.argmax(arr, axis=1)
    if class_order is None:
        logger.warning(
            "y_pred looks like a probability matrix but context.class_order is unset — "
            "using column indices as class labels, which will not match y_true unless "
            "your classes are 0..k-1"
        )
        return indices
    ordered = list(class_order)
    if arr.shape[1] != len(ordered):
        raise GateConfigurationError(
            f"y_pred has {arr.shape[1]} columns but context.class_order lists "
            f"{len(ordered)} classes"
        )
    return np.array([ordered[i] for i in indices])

ordinal_mae

ordinal_mae(y_true, y_pred, class_order)

Mean absolute error in rank space.

The point of an ordinal metric: for accept/refer/decline, predicting "decline" on an "accept" case is two steps wrong while "refer" is one. Plain accuracy scores both as a single mistake, which is exactly the distinction an underwriting gate needs to make.

Source code in bdp_model_gate/metrics.py
def ordinal_mae(y_true: Any, y_pred: Any, class_order: Any) -> float:
    """Mean absolute error in *rank* space.

    The point of an ordinal metric: for accept/refer/decline, predicting
    "decline" on an "accept" case is two steps wrong while "refer" is one.
    Plain accuracy scores both as a single mistake, which is exactly the
    distinction an underwriting gate needs to make.
    """
    t = to_ranks(y_true, class_order)
    p = to_ranks(y_pred, class_order)
    return float(np.mean(np.abs(t - p)))

quadratic_kappa

quadratic_kappa(y_true, y_pred, class_order)

Cohen's kappa with quadratic weights — the standard ordinal agreement measure. 1.0 is perfect, 0.0 is chance, and negative is worse than chance. Disagreements are penalised by the square of their rank distance, so a two-step error costs four times a one-step one.

Source code in bdp_model_gate/metrics.py
def quadratic_kappa(y_true: Any, y_pred: Any, class_order: Any) -> float:
    """Cohen's kappa with quadratic weights — the standard ordinal agreement
    measure. 1.0 is perfect, 0.0 is chance, and negative is worse than
    chance. Disagreements are penalised by the *square* of their rank
    distance, so a two-step error costs four times a one-step one.
    """
    t = to_ranks(y_true, class_order).astype(int)
    p = to_ranks(y_pred, class_order).astype(int)
    n_classes = len(list(class_order))

    observed = np.zeros((n_classes, n_classes), dtype=float)
    for actual, predicted in zip(t, p):
        observed[actual, predicted] += 1

    # Expected counts under independence of the two marginals.
    actual_hist = np.bincount(t, minlength=n_classes).astype(float)
    pred_hist = np.bincount(p, minlength=n_classes).astype(float)
    expected = np.outer(actual_hist, pred_hist) / max(len(t), 1)

    indices = np.arange(n_classes)
    weights = (indices[:, None] - indices[None, :]) ** 2 / max((n_classes - 1) ** 2, 1)

    denominator = float(np.sum(weights * expected))
    if denominator == 0.0:
        # Everything landed in one cell: perfect agreement, or no signal to
        # disagree about. Either way there is no chance-correction to make.
        return 1.0 if float(np.sum(weights * observed)) == 0.0 else 0.0
    return 1.0 - float(np.sum(weights * observed)) / denominator

Model adapter

bdp_model_gate.model.ModelAdapter

ModelAdapter(
    model=None,
    predict_fn=None,
    predict_proba_fn=None,
    gradient_fn=None,
)

Normalises any supported model into predict / predict_proba / gradients.

Source code in bdp_model_gate/model.py
def __init__(
    self,
    model: Any = None,
    predict_fn: PredictFn | None = None,
    predict_proba_fn: PredictFn | None = None,
    gradient_fn: PredictFn | None = None,
):
    self.model = model
    self._predict_fn = predict_fn
    self._predict_proba_fn = predict_proba_fn
    self._gradient_fn = gradient_fn

describe

describe()

How predictions are obtained, for logs and result metadata.

Source code in bdp_model_gate/model.py
def describe(self) -> str:
    """How predictions are obtained, for logs and result metadata."""
    if self._predict_fn is not None:
        return "predict_fn"
    if hasattr(self.model, "predict"):
        return f"{type(self.model).__name__}.predict"
    if callable(self.model):
        return f"{type(self.model).__name__}.__call__"
    return "none"

predict

predict(X)

Point predictions as a 1-D array.

Source code in bdp_model_gate/model.py
def predict(self, X: pd.DataFrame) -> np.ndarray:
    """Point predictions as a 1-D array."""
    if self._predict_fn is not None:
        raw = self._predict_fn(X)
    elif hasattr(self.model, "predict"):
        raw = self.model.predict(X)
    elif callable(self.model):
        # A bare callable — a torch module wrapped by the caller, or a
        # plain function. Accepted so `model=` and `predict_fn=` are
        # interchangeable rather than a trap.
        raw = self.model(X)
    else:
        raise GateConfigurationError(
            "no way to obtain predictions: context.model has no .predict() and is "
            "not callable, and no predict_fn was supplied. Pass "
            "predict_fn=lambda df: ... for a model this library cannot call directly."
        )
    return _as_1d(np.asarray(raw), "predict")

predict_positive_proba

predict_positive_proba(X)

Probability of the positive class, as a 1-D array.

Normalises the shapes different frameworks emit for binary classification: scikit-learn's (n, 2), Keras's (n, 1) from a sigmoid, and a bare (n,) vector are all reduced to one column.

Source code in bdp_model_gate/model.py
def predict_positive_proba(self, X: pd.DataFrame) -> np.ndarray:
    """Probability of the positive class, as a 1-D array.

    Normalises the shapes different frameworks emit for binary
    classification: scikit-learn's `(n, 2)`, Keras's `(n, 1)` from a
    sigmoid, and a bare `(n,)` vector are all reduced to one column.
    """
    if self._predict_proba_fn is not None:
        raw = np.asarray(self._predict_proba_fn(X))
    elif hasattr(self.model, "predict_proba"):
        raw = np.asarray(self.model.predict_proba(X))
    else:
        raise GateConfigurationError(
            "no way to obtain probabilities: context.model has no .predict_proba() "
            "and no predict_proba_fn was supplied"
        )

    if raw.ndim == 1:
        return raw.astype(float)
    if raw.ndim == 2:
        if raw.shape[1] == 1:  # Keras-style sigmoid output
            return raw[:, 0].astype(float)
        if raw.shape[1] == 2:  # scikit-learn-style [P(neg), P(pos)]
            return raw[:, 1].astype(float)
        raise GateConfigurationError(
            f"predict_proba returned {raw.shape[1]} columns, so this is not a "
            "binary classifier — there is no single positive class to take"
        )
    raise GateConfigurationError(
        f"predict_proba returned an array of {raw.ndim} dimensions; expected 1 or 2"
    )

predict_proba_matrix

predict_proba_matrix(X)

Full (n_rows, n_classes) probability matrix, for multiclass.

Unlike predict_positive_proba this keeps every column, because a multiclass check needs to pick out one or more favourable classes.

Source code in bdp_model_gate/model.py
def predict_proba_matrix(self, X: pd.DataFrame) -> np.ndarray:
    """Full (n_rows, n_classes) probability matrix, for multiclass.

    Unlike `predict_positive_proba` this keeps every column, because a
    multiclass check needs to pick out one or more favourable classes.
    """
    if self._predict_proba_fn is not None:
        raw = np.asarray(self._predict_proba_fn(X), dtype=float)
    elif hasattr(self.model, "predict_proba"):
        raw = np.asarray(self.model.predict_proba(X), dtype=float)
    else:
        raise GateConfigurationError(
            "no way to obtain probabilities: context.model has no .predict_proba() "
            "and no predict_proba_fn was supplied"
        )
    if raw.ndim != 2:
        raise GateConfigurationError(
            f"predict_proba returned {raw.ndim} dimensions; a multiclass check needs "
            "an (n_rows, n_classes) matrix"
        )
    return raw

gradients

gradients(X)

Per-row, per-feature gradients of the output, if available.

Returns an (n_rows, n_features) array aligned to X.columns, or None when no gradient_fn was supplied. A mis-shaped result is refused rather than broadcast into a meaningless perturbation.

Source code in bdp_model_gate/model.py
def gradients(self, X: pd.DataFrame) -> np.ndarray | None:
    """Per-row, per-feature gradients of the output, if available.

    Returns an (n_rows, n_features) array aligned to `X.columns`, or None
    when no `gradient_fn` was supplied. A mis-shaped result is refused
    rather than broadcast into a meaningless perturbation.
    """
    if self._gradient_fn is None:
        return None
    grads = np.asarray(self._gradient_fn(X), dtype=float)
    if grads.shape != X.shape:
        raise GateConfigurationError(
            f"gradient_fn returned shape {grads.shape}, but X is {X.shape} — it must "
            "return one gradient per (row, feature), aligned to X.columns"
        )
    return grads

Checks

Fairness

bdp_model_gate.structured.fairness

Per-feature and outcome-level fairness checks for structured data models.

ProxyCorrelationCheck

ProxyCorrelationCheck(config=None)

Bases: BaseCheck

Flags numeric input features that correlate strongly with a protected attribute — even when that attribute itself is excluded from the model.

Source code in bdp_model_gate/structured/fairness.py
def __init__(self, config: FairnessConfig | None = None):
    self.config = config or FairnessConfig()

plot

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

Heatmap of eta^2, feature by protected attribute.

Replaces a table that runs to one row per pair — forty on a modest model. The eye finds the hot cell in a grid immediately and cannot scan forty rows for it, and the cool cells matter too: they are the evidence that the flagged feature is the exception rather than the whole feature set leaking.

Source code in bdp_model_gate/structured/fairness.py
def plot(self, context, results=None, ax=None):
    """Heatmap of eta^2, feature by protected attribute.

    Replaces a table that runs to one row per pair — forty on a modest
    model. The eye finds the hot cell in a grid immediately and cannot
    scan forty rows for it, and the cool cells matter too: they are the
    evidence that the flagged feature is the exception rather than the
    whole feature set leaking.
    """
    from ..plots import require_plotting
    from ..plots.style import caption, new_axes, ring_cell, sharpen_colourbar, verdict_colour

    _, sns = require_plotting()
    if context.protected_df is None or context.protected_df.empty:
        return None
    grid = self._grid(context.X, context.protected_df)
    if grid.empty:
        return None

    # Height tracks the feature count: a fixed figure squeezes twenty
    # rows into unreadable slivers.
    ax = new_axes(ax, figsize=(1.6 + 1.3 * len(grid.columns), 1.2 + 0.34 * len(grid.index)))
    sns.heatmap(
        grid,
        ax=ax,
        annot=True,
        fmt=".2f",
        # Sequential, single-hue: eta^2 has a floor at zero and no
        # meaningful midpoint, so a diverging map would invent one.
        cmap="crest",
        vmin=0.0,
        vmax=1.0,
        linewidths=0.5,
        linecolor="white",
        # Short, because the bar is as tall as the grid and a three-feature
        # grid is two inches: a longer label runs off the top of the figure.
        cbar_kws={"label": "eta²"},
    )
    sharpen_colourbar(ax)

    # Ring what was actually reported, so the chart and the findings list
    # can be checked against each other at a glance.
    flagged = verdict_colour("NEEDS_REVIEW")
    for i, j in zip(*np.where(grid.to_numpy() > self.config.proxy_corr_threshold)):
        ring_cell(ax, int(j), int(i), flagged)

    ax.set_title(f"Proxy strength (ringed above {self.config.proxy_corr_threshold})")
    ax.set_xlabel(" ")  # a placeholder the caption can anchor beneath
    ax.set_ylabel("")
    ax.tick_params(labelrotation=0)
    caption(
        ax,
        "eta² is the share of the feature's variance explained by group membership.\n"
        "A hot cell means dropping the attribute from the model does not remove it.",
    )
    return ax

DisparateImpactCheck

DisparateImpactCheck(config=None)

Bases: BaseCheck

Outcome-level disparity check per protected attribute (demographic parity).

For multiclass, "predicted positive" means predicted into context.favourable_classes — for underwriting, typically ["accept"]. That set defaults to the most favourable entry of context.class_order when one is given; with neither, the check reports NOT_APPLICABLE rather than picking a class arbitrarily, because which outcome counts as favourable is a judgement the data cannot supply.

Demographic parity compares selection rates — the share of each group predicted positive — so it needs hard class labels. Continuous predictions are binarised at config.decision_threshold before being handed to fairlearn; predictions already in {0, 1} pass through untouched. Without that step a probability y_pred yields a selection rate of 0 in every group and a parity difference of exactly 0.0, which reads as "perfectly fair" no matter how skewed the model is.

Source code in bdp_model_gate/structured/fairness.py
def __init__(self, config: FairnessConfig | None = None):
    self.config = config or FairnessConfig()

plot

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

Parity difference swept across every decision threshold.

A single cutoff produces a single number, and the number is a cliff-edge: 0.49 and 0.51 can sit on opposite sides of the verdict. The sweep answers the question a reviewer actually has — does this verdict survive a small change of cutoff, or was it an artefact of where the cutoff happened to land?

Returns None for multiclass, where the prediction is a class rather than a score and there is no threshold to move.

Source code in bdp_model_gate/structured/fairness.py
def plot(self, context, results=None, ax=None):
    """Parity difference swept across every decision threshold.

    A single cutoff produces a single number, and the number is a
    cliff-edge: 0.49 and 0.51 can sit on opposite sides of the verdict.
    The sweep answers the question a reviewer actually has — does this
    verdict survive a small change of cutoff, or was it an artefact of
    where the cutoff happened to land?

    Returns None for multiclass, where the prediction is a class rather
    than a score and there is no threshold to move.
    """
    from ..plots import require_plotting
    from ..plots.style import (
        MUTED,
        RULE,
        caption,
        categorical,
        markers,
        new_axes,
        verdict_colour,
    )

    require_plotting()
    if context.protected_df is None or context.protected_df.empty:
        return None
    if resolve_task(context) == MULTICLASS:
        return None
    try:
        from fairlearn.metrics import demographic_parity_difference
    except ImportError:
        return None

    scores = np.asarray(context.y_pred, dtype=float)
    if np.all(np.isin(scores, (0.0, 1.0))):
        return None  # already hard labels — every threshold gives the same split

    configured = self.config.decision_threshold
    limit = self.config.disparity_threshold
    # Include the configured cutoff explicitly rather than hoping the grid
    # lands on it, so the marked point is the verdict, not an interpolation.
    sweep = np.unique(np.concatenate([np.linspace(0.05, 0.95, 37), [configured]]))

    attributes = list(context.protected_df.columns)
    ax = new_axes(ax)
    ax.axhspan(limit, 1.0, color=verdict_colour("BLOCKED"), alpha=0.07, zorder=0)
    ax.axhline(limit, color=verdict_colour("BLOCKED"), linewidth=1.0, linestyle=":", zorder=1)
    ax.axvline(configured, color=RULE, linewidth=1.2, zorder=1)

    for colour, marker, attr in zip(
        categorical(len(attributes)), markers(len(attributes)), attributes
    ):
        sensitive = context.protected_df[attr]
        curve = [
            abs(
                demographic_parity_difference(
                    context.y_true,
                    (scores >= t).astype(int),
                    sensitive_features=sensitive,
                )
            )
            for t in sweep
        ]
        ax.plot(sweep, curve, color=colour, label=attr, zorder=2)
        at_configured = curve[int(np.argmin(np.abs(sweep - configured)))]
        ax.scatter(
            [configured],
            [at_configured],
            color=colour,
            marker=marker,
            s=70,
            edgecolor="white",
            linewidth=0.9,
            zorder=3,
        )

    ax.set_xlim(0, 1)
    ax.set_ylim(bottom=0)
    ax.set_xlabel("decision threshold")
    ax.set_ylabel("|demographic parity difference|")
    ax.set_title("Does the parity verdict survive a change of cutoff?")
    ax.legend(loc="upper right")
    caption(
        ax,
        "marked points are the verdict as configured. A peak near the cutoff means the\n"
        "pass was luck: shading is the region that would be reported as a disparity.",
    )
    ax.annotate(
        f"cutoff in force: {configured:g}",
        xy=(configured, 1),
        xycoords=("data", "axes fraction"),
        xytext=(4, -4),
        textcoords="offset points",
        va="top",
        fontsize=8,
        color=MUTED,
    )
    return ax

ShapSubgroupCheck

ShapSubgroupCheck(config=None)

Bases: BaseCheck

For each feature, checks whether its SHAP contribution differs meaningfully across protected-attribute groups — catches features that look fair on average but drive outcomes differently for a subgroup.

The gap is measured relative to the mean absolute SHAP contribution, not in the raw units of the model output. SHAP values inherit the target's scale, so an absolute threshold that is sensible for a probability (contributions around 0.5) flags every feature on a premium model whose contributions run to thousands of naira. Relative, one threshold works on both: a value of 0.5 means "this feature's cross-group gap is worth half of a typical contribution".

Source code in bdp_model_gate/structured/fairness.py
def __init__(self, config: FairnessConfig | None = None):
    self.config = config or FairnessConfig()

CounterfactualFlipCheck

CounterfactualFlipCheck(config=None, n_samples=200)

Bases: BaseCheck

Flips protected-attribute values (when they're model inputs) and measures average prediction shift. Only meaningful if a protected attribute is actually included as a feature.

Source code in bdp_model_gate/structured/fairness.py
def __init__(self, config: FairnessConfig | None = None, n_samples: int = 200):
    self.config = config or FairnessConfig()
    self.n_samples = n_samples

Fairness — regression

bdp_model_gate.structured.regression_fairness

Fairness checks for continuous-output models.

Demographic parity has no regression analogue — there is no "selected" class to count — so this module asks four different questions, each answering something the others cannot:

GroupMeanGapCheck      Does one group receive systematically higher
                       predictions? Raw level difference.
ErrorParityCheck       Is the model materially *worse* for one group?
                       Quality of service, independent of level.
CalibrationParityCheck Does one group's prediction systematically
                       over- or under-shoot its realised outcome?
LossRatioParityCheck   Is one group charged a higher margin over its
                       own expected loss? The actuarial question.

The distinction matters most in insurance. A pricing model should charge more in a higher-loss segment — that is risk-based pricing, not discrimination — so a raw mean gap flags legitimate rating differences and will be noisy on its own. Loss-ratio parity is the one that isolates unfairness from actuarially justified variation, which is why it is worth supplying context.expected_loss when you have it.

Every gap is measured relative to the overall figure, so a single threshold works whether the target is a naira premium or a claim count. Groups smaller than FairnessConfig.min_group_size are reported but not scored: a three-policy segment produces wild ratios that read as findings.

GroupMeanGapCheck

GroupMeanGapCheck(config=None)

Bases: _RegressionFairnessCheck

Relative spread in mean prediction across protected groups.

The bluntest of the four. On a risk-priced model a gap here is expected and not by itself evidence of unfairness — read it alongside LossRatioParityCheck, which says whether the gap is justified by cost.

Source code in bdp_model_gate/structured/regression_fairness.py
def __init__(self, config: FairnessConfig | None = None):
    self.config = config or FairnessConfig()

ErrorParityCheck

ErrorParityCheck(config=None)

Bases: _RegressionFairnessCheck

Relative spread in per-group prediction error (MAE).

Answers a quality-of-service question rather than a pricing one: a group the model simply predicts worse for is being under-served, however fair the average price looks.

Source code in bdp_model_gate/structured/regression_fairness.py
def __init__(self, config: FairnessConfig | None = None):
    self.config = config or FairnessConfig()

CalibrationParityCheck

CalibrationParityCheck(config=None)

Bases: _RegressionFairnessCheck

Per-group bias: does one group's prediction systematically over- or under-shoot its realised outcome?

Distinct from error parity, which is scale-free about direction. A group can have perfectly typical error magnitude while being consistently over-predicted — systematically overcharged, in a pricing model.

Source code in bdp_model_gate/structured/regression_fairness.py
def __init__(self, config: FairnessConfig | None = None):
    self.config = config or FairnessConfig()

plot

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

Actual over expected, by predicted band, per group.

A mean residual is one number for the whole book, and a book is not uniform. RMSE says "wrong by 25,000"; this says "under-priced in the top decile, and only for one group" — which is the difference between a model that needs recalibrating and a model that needs withdrawing.

Bands are quantiles of the prediction, shared across groups, so the lines are comparable. A ratio above 1 means the realised outcome exceeded the prediction: under-priced.

Source code in bdp_model_gate/structured/regression_fairness.py
def plot(self, context, results=None, ax=None):
    """Actual over expected, by predicted band, per group.

    A mean residual is one number for the whole book, and a book is not
    uniform. RMSE says "wrong by 25,000"; this says "under-priced in the
    top decile, and only for one group" — which is the difference between
    a model that needs recalibrating and a model that needs withdrawing.

    Bands are quantiles of the prediction, shared across groups, so the
    lines are comparable. A ratio above 1 means the realised outcome
    exceeded the prediction: under-priced.
    """
    from ..plots import require_plotting, worst_result
    from ..plots.style import RULE, caption, categorical, markers, new_axes

    require_plotting()
    if context.protected_df is None or context.y_true is None:
        return None
    results = self.run(context) if results is None else results
    finding = worst_result(results, "relative_gap")
    if finding is None:
        return None

    attribute = finding.metadata["protected_attr"]
    protected = group_series(context.protected_df, attribute, self.config.min_group_size)
    if protected is None:
        return None
    scored = list(finding.metadata["group_bias"])

    y_true = np.asarray(context.y_true, dtype=float)
    y_pred = np.asarray(context.y_pred, dtype=float)

    # Quantile bands over the whole book, not per group: per-group edges
    # would put a different slice of business on each x position and the
    # lines would not be comparable, which is the entire point of the plot.
    n_bands = min(10, max(3, len(y_pred) // (5 * max(len(scored), 1))))
    edges = np.unique(np.quantile(y_pred, np.linspace(0, 1, n_bands + 1)))
    if len(edges) < 3:
        return None
    band = np.clip(np.digitize(y_pred, edges[1:-1]), 0, len(edges) - 2)

    ax = new_axes(ax)
    ax.axhline(1.0, color=RULE, linewidth=1.2, linestyle="--", zorder=1)

    centres = np.arange(len(edges) - 1)
    for colour, marker, value in zip(categorical(len(scored)), markers(len(scored)), scored):
        mask = np.asarray(protected.astype(str) == value)
        ratios, positions = [], []
        for b in centres:
            cell = mask & (band == b)
            predicted_total = y_pred[cell].sum()
            # A band a group barely occupies produces a ratio driven by
            # two policies. Leave the gap in the line rather than draw it.
            if cell.sum() < 5 or abs(predicted_total) <= _EPSILON:
                continue
            ratios.append(float(y_true[cell].sum() / predicted_total))
            positions.append(b)
        if positions:
            ax.plot(positions, ratios, color=colour, marker=marker, label=str(value), zorder=2)

    ax.set_xticks(centres)
    ax.set_xticklabels([f"{edges[b]:,.0f}\n{edges[b + 1]:,.0f}" for b in centres], fontsize=8)
    # Keep break-even inside the frame even when no band comes near it —
    # a chart cropped to the data hides how far off the whole book is.
    low, high = ax.get_ylim()
    ax.set_ylim(min(low, 0.95), max(high, 1.05))
    ax.set_xlabel("predicted value, by band")
    ax.set_ylabel("actual ÷ expected")
    ax.set_title(f"Actual against expected by band, split on {attribute}")
    ax.legend(loc="best")
    caption(
        ax,
        "the dashed line is break-even. Above it the outcome beat the prediction "
        "(under-predicted);\nbelow it the prediction was too high. A group drifting "
        "in one band only is a segment problem.",
    )
    return ax

LossRatioParityCheck

LossRatioParityCheck(config=None)

Bases: _RegressionFairnessCheck

Margin parity: is one group charged more relative to its own expected loss than another?

This is the actuarially meaningful fairness test for a pricing model. Charging a higher premium in a higher-loss segment is risk-based pricing; charging a higher margin over expected loss is not justified by cost, and is what this check isolates.

Requires context.expected_loss — a per-row expected loss, technical premium or pure premium, row-aligned to X. Without it the check reports NOT_APPLICABLE rather than falling back to a raw price comparison, which would answer a different question under the same name.

Source code in bdp_model_gate/structured/regression_fairness.py
def __init__(self, config: FairnessConfig | None = None):
    self.config = config or FairnessConfig()

plot

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

Charged premium against expected loss, one point per policy.

The scalar says the margin gap is 18%. It cannot say where. A uniform vertical offset between two groups is a flat loading — argue about it, but it is one decision. A fan that opens at the top of the book is a gap concentrated in high-value risks, which is a different finding with a different remedy.

The 45° line is break-even: on it, premium equals expected loss.

Source code in bdp_model_gate/structured/regression_fairness.py
def plot(self, context, results=None, ax=None):
    """Charged premium against expected loss, one point per policy.

    The scalar says the margin gap is 18%. It cannot say *where*. A
    uniform vertical offset between two groups is a flat loading — argue
    about it, but it is one decision. A fan that opens at the top of the
    book is a gap concentrated in high-value risks, which is a different
    finding with a different remedy.

    The 45° line is break-even: on it, premium equals expected loss.
    """
    from ..plots import require_plotting, worst_result
    from ..plots.style import RULE, caption, categorical, markers, new_axes

    require_plotting()
    if context.protected_df is None or context.expected_loss is None:
        return None
    results = self.run(context) if results is None else results
    finding = worst_result(results, "relative_gap")
    if finding is None:
        return None

    attribute = finding.metadata["protected_attr"]
    protected = group_series(context.protected_df, attribute, self.config.min_group_size)
    if protected is None:
        return None
    scored = list(finding.metadata["group_loss_ratio"])

    expected = np.asarray(context.expected_loss, dtype=float)
    y_pred = np.asarray(context.y_pred, dtype=float)
    positive = expected > 0
    if not positive.any():
        return None

    ax = new_axes(ax, figsize=(6.0, 5.2))
    ceiling = float(max(expected[positive].max(), y_pred[positive].max()))
    ax.plot([0, ceiling], [0, ceiling], color=RULE, linewidth=1.4, linestyle="--", zorder=1)

    for colour, marker, value in zip(categorical(len(scored)), markers(len(scored)), scored):
        cell = positive & np.asarray(protected.astype(str) == value)
        if not cell.any():
            continue
        ratio = finding.metadata["group_loss_ratio"][value]
        ax.scatter(
            expected[cell],
            y_pred[cell],
            color=colour,
            marker=marker,
            s=18,
            alpha=0.55,
            linewidth=0,
            label=f"{value} — mean ratio {ratio:.2f}",
            zorder=2,
        )
        # The group's own mean ratio as a ray from the origin: the line the
        # scalar in the report describes, drawn over the points it came from.
        ax.plot(
            [0, ceiling], [0, ceiling * ratio], color=colour, linewidth=1.1, alpha=0.9, zorder=3
        )

    ax.set_xlim(0, ceiling * 1.02)
    ax.set_ylim(0, max(ceiling, float(y_pred[positive].max())) * 1.02)
    ax.set_xlabel("expected loss")
    ax.set_ylabel("predicted premium")
    ax.set_title(f"Premium against expected loss, split on {attribute}")
    ax.legend(loc="upper left")
    caption(
        ax,
        "the dashed 45° line is break-even; each group's ray is its mean margin.\n"
        "Parallel rays are a flat loading. Diverging rays are a gap that grows with "
        "the size of the risk.",
    )
    return ax

Performance

bdp_model_gate.structured.performance

Performance/cost thresholds that must pass before promotion.

PerformanceThresholdCheck

PerformanceThresholdCheck(config=None)

Bases: BaseCheck

Hard gate on model score, p95 latency, and cost-per-inference.

The score metric is whatever PerformanceConfig.metric names — see bdp_model_gate.metrics. Which metric actually ran is recorded in the result's detail string and metadata, so a report always states what min_score was compared against.

latencies_ms and cost_per_inference are optional on the context — if neither is supplied, only the score is checked; if the score inputs are also unavailable the check reports NOT_APPLICABLE rather than failing.

Source code in bdp_model_gate/structured/performance.py
def __init__(self, config: PerformanceConfig | None = None):
    self.config = config or PerformanceConfig()
    # Fail at construction time on a typo'd metric name, rather than
    # partway through a gate run. Dependency availability is checked
    # lazily in _score(), so building the suite never needs sklearn.
    # Task is unknown at construction time, so only the name is checked
    # here; metric/task compatibility is verified in run().
    self._context = None
    validate_metric(self.config.metric)

plot

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

Confusion matrix in the caller's own class order.

Only drawn where the classes are orderedcontext.class_order set, three or more classes. quadratic_kappa penalises rank distance squared and then reports one number, which hides direction entirely: a model that sends accepts to decline and one that sends them to refer can score alike, and only one of those is a scandal. Keeping the caller's ordering on both axes is what makes distance from the diagonal readable as severity.

A binary matrix is four numbers the detail line already carries, so this returns None there rather than charting a table.

Source code in bdp_model_gate/structured/performance.py
def plot(self, context, results=None, ax=None):
    """Confusion matrix in the caller's own class order.

    Only drawn where the classes are *ordered* — `context.class_order`
    set, three or more classes. `quadratic_kappa` penalises rank distance
    squared and then reports one number, which hides direction entirely:
    a model that sends accepts to decline and one that sends them to refer
    can score alike, and only one of those is a scandal. Keeping the
    caller's ordering on both axes is what makes distance from the
    diagonal readable as severity.

    A binary matrix is four numbers the detail line already carries, so
    this returns None there rather than charting a table.
    """
    from ..plots import require_plotting
    from ..plots.style import ACCENT, caption, new_axes, ring_cell, sharpen_colourbar

    _, sns = require_plotting()
    if context.y_true is None or context.y_pred is None:
        return None
    class_order = list(getattr(context, "class_order", None) or ())
    if len(class_order) < 3 or resolve_task(context) != MULTICLASS:
        return None

    import pandas as pd

    actual = pd.Series(to_class_labels(context.y_true, class_order)).astype(str)
    predicted = pd.Series(to_class_labels(context.y_pred, class_order)).astype(str)
    labels = [str(c) for c in class_order]
    counts = (
        pd.crosstab(actual, predicted)
        .reindex(index=labels, columns=labels, fill_value=0)
        .astype(int)
    )
    # Normalise by row, so a rare class is not rendered invisible by a
    # common one — the recall of the smallest band is usually the finding.
    rates = counts.div(counts.sum(axis=1).replace(0, np.nan), axis=0).fillna(0.0)

    ax = new_axes(ax, figsize=(1.6 + 0.95 * len(labels), 1.4 + 0.85 * len(labels)))
    sns.heatmap(
        rates,
        ax=ax,
        annot=counts,
        fmt="d",
        cmap="crest",
        vmin=0.0,
        vmax=1.0,
        linewidths=0.5,
        linecolor="white",
        cbar_kws={"label": "share of the true class"},
    )
    sharpen_colourbar(ax)

    for i in range(len(labels)):  # ring the diagonal: correct, distance zero
        ring_cell(ax, i, i, ACCENT)
    ax.set_xlabel("predicted")
    ax.set_ylabel("actual")
    ax.set_title("Where the errors land — shading is the row's share, labels are counts")
    ax.tick_params(labelrotation=0)
    caption(
        ax,
        "distance from the ringed diagonal is how wrong the error was.\n"
        "quadratic_kappa squares that distance and reports one number, which hides "
        "the direction.",
    )
    return ax

Compliance

bdp_model_gate.structured.compliance

NDPA/NDPR-style compliance mapping tied to the model card.

ComplianceMappingCheck

ComplianceMappingCheck(config=None)

Bases: BaseCheck

Validates model card completeness, DPIA trigger, and explainability requirement. Expects context.model_card — a dict that can include:

legal_basis (str)
data_minimization_justification (str)
training_data_source (str)
use_case (str) — matched against config.high_risk_use_cases
dpia_completed (bool)
influences_decision_about_person (bool) — defaults to True if use_case
    matches a high-risk use case
explainability_method (str)
Source code in bdp_model_gate/structured/compliance.py
def __init__(self, config: ComplianceConfig | None = None):
    self.config = config or ComplianceConfig()

Security

bdp_model_gate.structured.security

Adversarial robustness, PII leakage, and prompt-injection checks.

AdversarialRobustnessCheck

AdversarialRobustnessCheck(
    config=None,
    n_samples=200,
    random_state=42,
    plot_sweep=False,
)

Bases: BaseCheck

Black-box robustness check: perturbs numeric features by a small relative amount and measures how much the prediction moves.

For classification that is the class flip rate — how often the predicted label changes — and a high rate means a fragile decision boundary.

For ordinal multiclass — where context.class_order is set — the flip rate is reported alongside the mean rank distance moved, because accept -> decline is a two-step error while accept -> refer is one. A model that only ever slips by one rank is materially safer than one that swings across the scale, and a bare flip rate cannot tell them apart.

For regression there is no such thing as a flip: every perturbation moves a continuous output, so a flip rate would be ~1.0 and every model would be permanently BLOCKED. Sensitivity is measured instead as the mean relative change in prediction, gated with SecurityConfig.adversarial_max_relative_shift. A model whose output moves 30% when an input moves 2% is over-sensitive regardless of task.

Source code in bdp_model_gate/structured/security.py
def __init__(
    self,
    config: SecurityConfig | None = None,
    n_samples: int = 200,
    random_state: int = 42,
    plot_sweep: bool = False,
):
    self.config = config or SecurityConfig()
    self.n_samples = n_samples
    # Seeded so the same model and data always produce the same flip
    # rate. An unseeded gate can land on either side of the threshold
    # between runs, which makes a CI verdict irreproducible.
    self.random_state = random_state
    # The robustness curve re-scores the whole subsample once per epsilon,
    # which is real money against a metered endpoint and real minutes
    # against a slow one. Off unless asked for; `run` is unaffected.
    self.plot_sweep = plot_sweep

plot

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

Prediction movement as the perturbation budget grows.

One epsilon gives one number, and the shape of the approach to it is the risk. Linear decay is a model degrading predictably; flat-then- collapse is a cliff sitting just outside the budget that happened to be configured, and it will be found by whoever looks hardest.

Opt in with AdversarialRobustnessCheck(plot_sweep=True) — each point re-scores the subsample, so this is the one plot in the suite that costs an inference bill.

Source code in bdp_model_gate/structured/security.py
def plot(self, context, results=None, ax=None):
    """Prediction movement as the perturbation budget grows.

    One epsilon gives one number, and the shape of the approach to it is
    the risk. Linear decay is a model degrading predictably; flat-then-
    collapse is a cliff sitting just outside the budget that happened to
    be configured, and it will be found by whoever looks hardest.

    Opt in with `AdversarialRobustnessCheck(plot_sweep=True)` — each point
    re-scores the subsample, so this is the one plot in the suite that
    costs an inference bill.
    """
    from ..plots import require_plotting
    from ..plots.style import ACCENT, MUTED, RULE, new_axes, verdict_colour

    require_plotting()
    if not self.plot_sweep:
        logger.debug(
            "%s.plot skipped: the epsilon sweep re-scores the sample at each point. "
            "Construct the check with plot_sweep=True to draw it.",
            self.name,
        )
        return None

    configured = self.config.adversarial_epsilon
    epsilons = sorted({round(configured * m, 10) for m in self.SWEEP_MULTIPLES})
    measured = [(e, self._measure(context, e)) for e in epsilons]
    points = [(e, m) for e, m in measured if m is not None]
    if len(points) < 2:
        return None

    regression = points[0][1]["task"] == REGRESSION
    key = "relative_shift" if regression else "flip_rate"
    limit = (
        self.config.adversarial_max_relative_shift
        if regression
        else self.config.adversarial_flip_rate_threshold
    )
    xs = [e for e, _ in points]
    ys = [m[key] for _, m in points]

    ax = new_axes(ax)
    ax.axhspan(
        limit, max(max(ys), limit) * 1.15, color=verdict_colour("BLOCKED"), alpha=0.07, zorder=0
    )
    ax.axhline(limit, color=verdict_colour("BLOCKED"), linewidth=1.0, linestyle=":", zorder=1)
    ax.axvline(configured, color=RULE, linewidth=1.2, zorder=1)
    ax.plot(xs, ys, color=ACCENT, marker="o", zorder=2)

    at_configured = ys[xs.index(configured)] if configured in xs else None
    if at_configured is not None:
        ax.scatter(
            [configured],
            [at_configured],
            s=110,
            facecolor="white",
            edgecolor=ACCENT,
            linewidth=2.0,
            zorder=3,
        )

    ax.set_xlabel("epsilon — relative size of the input perturbation")
    ax.set_ylabel("mean relative prediction shift" if regression else "class flip rate")
    ax.set_ylim(bottom=0)
    ax.set_title(f"Robustness under {points[0][1]['method']} perturbation (threshold {limit})")
    ax.annotate(
        f"budget in force: {configured:g}",
        xy=(configured, 1),
        xycoords=("data", "axes fraction"),
        xytext=(4, -4),
        textcoords="offset points",
        va="top",
        fontsize=8,
        color=MUTED,
    )
    return ax

PIILeakageCheck

PIILeakageCheck(config=None, sample_size=500)

Bases: BaseCheck

Scans string/object columns of structured data for PII patterns that shouldn't be present as raw features (should've been hashed/tokenized upstream).

Source code in bdp_model_gate/structured/security.py
def __init__(self, config: SecurityConfig | None = None, sample_size: int = 500):
    self.config = config or SecurityConfig()
    self.sample_size = sample_size

PromptInjectionCheck

PromptInjectionCheck(config=None)

Bases: BaseCheck

Only relevant if the model has a generative component (e.g. an LLM-based feature explainer, chatbot, or report generator sitting alongside the structured model). Requires context.generate_fn. Flags any response that appears to comply with an injection attempt rather than refuse.

Source code in bdp_model_gate/structured/security.py
def __init__(self, config: SecurityConfig | None = None):
    self.config = config or SecurityConfig()
    self.refusal_markers = [
        "cannot",
        "can't",
        "not able to",
        "won't",
        "against my",
        "unable to",
    ]

Plotting and reports

See Plots and Reports for the guides.

bdp_model_gate.reporting

A gate report as a page a reviewer can read and sign.

NEEDS_REVIEW is a verdict that delegates to a human. Until now that human received a JSON blob: correct, archival, and close to unreadable at the moment a decision has to be made. This renders the same report as one self-contained HTML file — no network, no JavaScript, nothing to install to open it — with each check's plot inlined beside the number it explains.

Three properties are deliberate:

  • Self-contained. No <script>, no external stylesheet, no remote font. A governance record is emailed, filed and reopened years later, and every external reference is a way for it to stop rendering.
  • Plots inlined as SVG, not <img src="data:...">. Inline SVG inherits the page's CSS, which is what makes one render read correctly in light and dark. It also stays sharp when printed.
  • Degrades rather than fails. Without the [plots] extra the page renders text-only. A plot that raises is reported in place as a note, because a broken chart must never cost a reviewer the findings around it.

render_html

render_html(
    report,
    checks=None,
    context=None,
    title="Model gate report",
    include_plots=True,
    generated_at=None,
)

Renders a GateReport as one self-contained HTML document.

checks and context are what plotting needs — a plot recomputes from the data rather than reading presentation arrays out of the archived JSON. ModelGate.run attaches both to the report it returns, so report.to_html() normally supplies them for you; pass them explicitly when rendering a report reconstructed from elsewhere.

Without them, or without the [plots] extra, the page renders text-only.

Source code in bdp_model_gate/reporting.py
def render_html(
    report: Any,
    checks: Any = None,
    context: Any = None,
    title: str = "Model gate report",
    include_plots: bool = True,
    generated_at: str | None = None,
) -> str:
    """Renders a `GateReport` as one self-contained HTML document.

    `checks` and `context` are what plotting needs — a plot recomputes from
    the data rather than reading presentation arrays out of the archived
    JSON. `ModelGate.run` attaches both to the report it returns, so
    `report.to_html()` normally supplies them for you; pass them explicitly
    when rendering a report reconstructed from elsewhere.

    Without them, or without the `[plots]` extra, the page renders text-only.
    """
    from .plots import plotting_available

    stamp = generated_at or datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
    verdict = report.gate_status

    parts: list[str] = [
        "<main>",
        f'<header class="card verdict {verdict}">',
        f"<div><h1>{_esc(title)}</h1>"
        f'<p class="sub">Generated {_esc(stamp)} · bdp-model-gate</p></div>',
        f'<p class="verdict-name">{_esc(verdict)}</p>',
        f"<p>{_esc(_VERDICT_BLURB.get(verdict, ''))}</p>",
        '<dl class="facts">',
    ]

    facts: list[tuple[str, str]] = [("Findings", str(len(report.flags)))]
    if report.task:
        facts.append(("Task", report.task))
    if report.model_metric is not None and report.model_score is not None:
        facts.append((report.model_metric, f"{report.model_score:.4f}"))
    facts.append(("Checks run", str(len({r.check_name for r in report.results}))))
    facts.append(("Duration", f"{report.total_duration_ms:.0f} ms"))
    for name, value in facts:
        parts.append(f'<div class="fact"><dt>{_esc(name)}</dt><dd>{_esc(value)}</dd></div>')
    parts.append("</dl></header>")

    by_name = {getattr(c, "name", type(c).__name__): c for c in (checks or [])}
    draw = include_plots and bool(by_name) and context is not None and plotting_available()
    if include_plots and not draw:
        logger.debug(
            "rendering text-only: checks=%s context=%s plots_installed=%s",
            bool(by_name),
            context is not None,
            plotting_available(),
        )

    categories = list(CATEGORY_ORDER) + sorted(
        {r.category for r in report.results} - set(CATEGORY_ORDER)
    )
    for category in categories:
        rows = report.by_category(category)
        if not rows:
            continue
        flagged = sum(1 for r in rows if not r.is_ok)
        parts.append(
            f'<section class="card"><div><h2>{_esc(category.title())}</h2>'
            f'<p class="sub">{flagged} finding(s) across {len(rows)} result(s)</p></div>'
        )

        # Grouped by check so a plot sits with the numbers it illustrates,
        # preserving the order the checks ran in.
        seen: list[str] = []
        for r in rows:
            if r.check_name not in seen:
                seen.append(r.check_name)

        for check_name in seen:
            own = [r for r in rows if r.check_name == check_name]
            parts.append(f'<div class="check"><h3>{_esc(check_name)}</h3>')
            for r in own:
                pill = _pill_class(r.flag, r.blocking)
                blocking_note = (
                    "" if r.is_ok else ("blocks promotion" if r.blocking else "needs review")
                )
                parts.append(
                    f'<div class="finding">'
                    f'<span class="pill {pill}">{_esc(r.flag)}</span>'
                    f'<div><p class="detail">{_esc(r.detail)}</p>'
                    + (f'<p class="blocking-note">{blocking_note}</p>' if blocking_note else "")
                    + _render_metadata(r.metadata)
                    + "</div></div>"
                )
            check = by_name.get(check_name)
            if draw and check is not None and _draws(check):
                try:
                    svg = _figure_svg(check, context, own)
                except Exception as exc:
                    # A chart is an aid. Losing the findings around it because
                    # a renderer raised would be a worse outcome than no chart.
                    logger.warning("plot failed for check=%s: %r", check_name, exc)
                    parts.append(
                        f'<p class="note">chart unavailable — {_esc(type(exc).__name__)}: '
                        f"{_esc(exc)}</p>"
                    )
                else:
                    if svg:
                        parts.append(f"<figure>{svg}</figure>")
            parts.append("</div>")
        parts.append("</section>")

    parts.append(
        "<footer>Produced by bdp-model-gate. Findings are evidence for a human decision, "
        "not the decision itself.</footer></main>"
    )

    body = "\n".join(parts)
    return (
        "<!doctype html>\n"
        f'<html lang="en"><head><meta charset="utf-8">'
        f'<meta name="viewport" content="width=device-width, initial-scale=1">'
        f"<title>{_esc(title)}{_esc(verdict)}</title>"
        f"<style>{_STYLE}</style></head><body>\n{body}\n</body></html>\n"
    )

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

Iterating protected groups, including intersections.

Checking each protected attribute on its own is the common approach and it misses the thing that matters most. A model can show acceptable disparity on gender and acceptable disparity on region while failing badly for women in one region — harm concentrates at intersections, and marginal checks are blind to it by construction.

iter_protected yields the marginal attributes and, when asked, their pairwise intersections, so every group-based check gets intersectional coverage from one place rather than each reimplementing it.

iter_protected

iter_protected(
    protected_df, intersectional=False, min_group_size=30
)

Yields (label, group series) for each protected attribute.

With intersectional=True, also yields the pairwise combinations. Only pairs are generated: three-way intersections fragment a validation set faster than any realistic min_group_size tolerates, and reporting a disparity computed over four rows is worse than not reporting one.

An intersection whose every group falls below min_group_size is skipped with a log line rather than yielded, since the downstream check would only discard it anyway.

Source code in bdp_model_gate/groups.py
def iter_protected(
    protected_df: pd.DataFrame,
    intersectional: bool = False,
    min_group_size: int = 30,
) -> Iterator[tuple[str, pd.Series]]:
    """Yields (label, group series) for each protected attribute.

    With `intersectional=True`, also yields the pairwise combinations. Only
    pairs are generated: three-way intersections fragment a validation set
    faster than any realistic `min_group_size` tolerates, and reporting a
    disparity computed over four rows is worse than not reporting one.

    An intersection whose every group falls below `min_group_size` is skipped
    with a log line rather than yielded, since the downstream check would
    only discard it anyway.
    """
    columns = list(protected_df.columns)
    for column in columns:
        yield column, protected_df[column]

    if not intersectional or len(columns) < 2:
        return

    for left, right in combinations(columns, 2):
        combined = protected_df[left].astype(str) + JOIN + protected_df[right].astype(str)
        usable = (combined.value_counts() >= min_group_size).sum()
        if usable < 2:
            logger.debug(
                "intersection %s%s%s has fewer than two groups of at least %d rows — "
                "skipping. Marginal checks on each attribute still apply.",
                left,
                JOIN,
                right,
                min_group_size,
            )
            continue
        yield f"{left}{JOIN}{right}", combined

group_series

group_series(protected_df, label, min_group_size=30)

The group series a check reported under label, or None.

A CheckResult names its groups by label — "region", or "gender × region" for an intersection — but carries only the summary statistics, not the split they came from. A plot() that wants to draw the underlying distribution has to recover it, and recovering it by re-deriving the intersection by hand is how a chart ends up illustrating a different split from the one that was scored.

Intersections are always searched, whatever the caller's intersectional setting: the label is evidence the check produced it.

Source code in bdp_model_gate/groups.py
def group_series(
    protected_df: pd.DataFrame,
    label: str,
    min_group_size: int = 30,
) -> pd.Series | None:
    """The group series a check reported under `label`, or None.

    A `CheckResult` names its groups by label — `"region"`, or
    `"gender × region"` for an intersection — but carries only the summary
    statistics, not the split they came from. A `plot()` that wants to draw
    the underlying distribution has to recover it, and recovering it by
    re-deriving the intersection by hand is how a chart ends up illustrating
    a different split from the one that was scored.

    Intersections are always searched, whatever the caller's `intersectional`
    setting: the label is evidence the check produced it.
    """
    for name, series in iter_protected(protected_df, True, min_group_size):
        if name == label:
            return series
    return None

bdp_model_gate.calibration

Calibration measurement.

A model is calibrated when its stated probabilities match observed frequencies: of the cases it scores 0.7, about 70% should be positive. Discrimination and calibration are independent properties — a model can rank perfectly (AUC 1.0) while every probability it emits is twice too high.

That distinction is why this module exists. For credit scoring and insurance pricing, calibration is often the property that matters: a well-ranked but badly calibrated model misprices every policy while scoring well on every metric the gate measured before 0.5.0.

Everything here is numpy-native, so it works on a core install.

CalibrationCurve dataclass

CalibrationCurve(bin_edges, predicted, observed, count)

Observed frequency against predicted probability, per bin.

count is included because a bin holding four observations says almost nothing, and any renderer or reader needs to weight accordingly.

populated property

populated

Mask of bins that actually contain observations.

calibration_curve

calibration_curve(
    y_true, y_prob, n_bins=10, strategy="uniform"
)

Bins predictions and compares mean prediction to observed frequency.

strategy="uniform" splits [0, 1] into equal-width bins — the classic reliability diagram. strategy="quantile" splits by equal count, which matters for a skewed score distribution where uniform bins leave the interesting region nearly empty. Fraud and default scores are usually skewed, so quantile is often the honest choice.

Source code in bdp_model_gate/calibration.py
def calibration_curve(
    y_true: np.ndarray, y_prob: np.ndarray, n_bins: int = 10, strategy: str = "uniform"
) -> CalibrationCurve:
    """Bins predictions and compares mean prediction to observed frequency.

    `strategy="uniform"` splits [0, 1] into equal-width bins — the classic
    reliability diagram. `strategy="quantile"` splits by equal count, which
    matters for a skewed score distribution where uniform bins leave the
    interesting region nearly empty. Fraud and default scores are usually
    skewed, so quantile is often the honest choice.
    """
    probabilities = _validate_probabilities(y_prob)
    actuals = np.asarray(y_true, dtype=float)
    if actuals.shape != probabilities.shape:
        raise GateConfigurationError(
            f"y_true has shape {actuals.shape} but y_prob has {probabilities.shape}"
        )
    if n_bins < 2:
        raise GateConfigurationError(f"n_bins must be at least 2, got {n_bins}")

    if strategy == "uniform":
        edges = np.linspace(0.0, 1.0, n_bins + 1)
    elif strategy == "quantile":
        edges = np.unique(np.quantile(probabilities, np.linspace(0.0, 1.0, n_bins + 1)))
        if len(edges) < 3:
            logger.debug(
                "quantile binning collapsed to %d edge(s) — the predictions are nearly "
                "constant; falling back to uniform bins",
                len(edges),
            )
            edges = np.linspace(0.0, 1.0, n_bins + 1)
    else:
        raise GateConfigurationError(
            f"unknown binning strategy {strategy!r} — use 'uniform' or 'quantile'"
        )

    # np.digitize puts values equal to an interior edge in the upper bin; clip
    # so the final edge (1.0) lands in the last bin rather than one past it.
    index = np.clip(np.digitize(probabilities, edges[1:-1], right=False), 0, len(edges) - 2)
    n_actual_bins = len(edges) - 1

    count = np.bincount(index, minlength=n_actual_bins).astype(float)
    with np.errstate(invalid="ignore", divide="ignore"):
        predicted = np.bincount(index, weights=probabilities, minlength=n_actual_bins) / count
        observed = np.bincount(index, weights=actuals, minlength=n_actual_bins) / count
    predicted = np.nan_to_num(predicted, nan=0.0)
    observed = np.nan_to_num(observed, nan=0.0)

    return CalibrationCurve(bin_edges=edges, predicted=predicted, observed=observed, count=count)

expected_calibration_error

expected_calibration_error(
    y_true, y_prob, n_bins=10, strategy="uniform"
)

Mean gap between predicted and observed frequency, weighted by bin size.

0.0 is perfect. A value of 0.05 means predictions are off by five percentage points on average.

ECE is a summary and hides shape: two models with the same ECE can be miscalibrated in opposite directions, one over-confident only at the top and another wrong throughout. Read it alongside the curve, which is why calibration_curve is public.

Source code in bdp_model_gate/calibration.py
def expected_calibration_error(
    y_true: np.ndarray, y_prob: np.ndarray, n_bins: int = 10, strategy: str = "uniform"
) -> float:
    """Mean gap between predicted and observed frequency, weighted by bin size.

    0.0 is perfect. A value of 0.05 means predictions are off by five
    percentage points on average.

    ECE is a summary and hides shape: two models with the same ECE can be
    miscalibrated in opposite directions, one over-confident only at the top
    and another wrong throughout. Read it alongside the curve, which is why
    `calibration_curve` is public.
    """
    curve = calibration_curve(y_true, y_prob, n_bins=n_bins, strategy=strategy)
    total = curve.count.sum()
    if total == 0:
        return 0.0
    gaps = np.abs(curve.observed - curve.predicted)
    return float(np.sum(curve.count * gaps) / total)

brier_score

brier_score(y_true, y_prob)

Mean squared error of the probabilities. Lower is better; 0.0 perfect.

Source code in bdp_model_gate/calibration.py
def brier_score(y_true: np.ndarray, y_prob: np.ndarray) -> float:
    """Mean squared error of the probabilities. Lower is better; 0.0 perfect."""
    probabilities = _validate_probabilities(y_prob)
    actuals = np.asarray(y_true, dtype=float)
    return float(np.mean((probabilities - actuals) ** 2))

brier_decomposition

brier_decomposition(y_true, y_prob, n_bins=10)

Murphy's decomposition: Brier = reliability - resolution + uncertainty.

Worth the extra numbers because they separate two different failures:

  • reliability — how far predictions sit from observed frequency. Lower is better, and this is the part recalibration can fix.
  • resolution — how much predictions vary from the base rate. Higher is better; a model predicting the base rate for everyone is perfectly reliable and completely useless.
  • uncertainty — the base rate's own variance. A property of the problem, not the model, and a floor nothing can improve.

A model with excellent reliability and near-zero resolution has learned nothing, and neither the Brier score nor ECE says so on its own.

The identity holds exactly for the binned forecast, so both are returned: binned_brier is what reliability - resolution + uncertainty reconstructs, and brier is the score on the raw probabilities. They differ by the information binning discards, which is small but not zero — reporting only the raw score would leave an identity that almost adds up, and a number that almost adds up is worse than two that are labelled.

Source code in bdp_model_gate/calibration.py
def brier_decomposition(
    y_true: np.ndarray, y_prob: np.ndarray, n_bins: int = 10
) -> dict[str, float]:
    """Murphy's decomposition: Brier = reliability - resolution + uncertainty.

    Worth the extra numbers because they separate two different failures:

    - **reliability** — how far predictions sit from observed frequency.
      Lower is better, and this is the part recalibration can fix.
    - **resolution** — how much predictions vary from the base rate. Higher is
      better; a model predicting the base rate for everyone is perfectly
      reliable and completely useless.
    - **uncertainty** — the base rate's own variance. A property of the
      problem, not the model, and a floor nothing can improve.

    A model with excellent reliability and near-zero resolution has learned
    nothing, and neither the Brier score nor ECE says so on its own.

    The identity holds exactly for the **binned** forecast, so both are
    returned: `binned_brier` is what `reliability - resolution + uncertainty`
    reconstructs, and `brier` is the score on the raw probabilities. They
    differ by the information binning discards, which is small but not zero —
    reporting only the raw score would leave an identity that almost adds up,
    and a number that almost adds up is worse than two that are labelled.
    """
    probabilities = _validate_probabilities(y_prob)
    actuals = np.asarray(y_true, dtype=float)
    curve = calibration_curve(actuals, probabilities, n_bins=n_bins)
    total = curve.count.sum()
    base_rate = float(np.mean(actuals)) if len(actuals) else 0.0

    if total == 0:
        return {
            "brier": 0.0,
            "binned_brier": 0.0,
            "reliability": 0.0,
            "resolution": 0.0,
            "uncertainty": 0.0,
            "base_rate": base_rate,
        }

    weights = curve.count / total
    reliability = float(np.sum(weights * (curve.predicted - curve.observed) ** 2))
    resolution = float(np.sum(weights * (curve.observed - base_rate) ** 2))
    uncertainty = float(base_rate * (1.0 - base_rate))

    return {
        "brier": brier_score(actuals, probabilities),
        "binned_brier": reliability - resolution + uncertainty,
        "reliability": reliability,
        "resolution": resolution,
        "uncertainty": uncertainty,
        "base_rate": base_rate,
    }

Registry and errors

bdp_model_gate.registry

Plugin discovery for third-party checks.

A downstream package can register additional checks without forking this library by declaring an entry point in the bdp_model_gate.checks group:

# in the plugin's pyproject.toml
[project.entry-points."bdp_model_gate.checks"]
my_check = "my_package.checks:MyCustomCheck"

Each entry point must resolve to a BaseCheck subclass (not an instance). discover_plugin_checks() instantiates each with no arguments — plugin checks that need configuration should read it from their own defaults or from environment/config files, since the shared GateConfig only has sections for the built-in categories.

discover_plugin_checks

discover_plugin_checks()

Finds and instantiates every check registered under the bdp_model_gate.checks entry-point group. A plugin that fails to load is logged and skipped rather than crashing the whole gate — a misbehaving third-party check shouldn't block your own checks from running.

Source code in bdp_model_gate/registry.py
def discover_plugin_checks() -> list[BaseCheck]:
    """Finds and instantiates every check registered under the
    `bdp_model_gate.checks` entry-point group. A plugin that fails to load
    is logged and skipped rather than crashing the whole gate — a
    misbehaving third-party check shouldn't block your own checks from running.
    """
    checks: list[BaseCheck] = []
    try:
        eps = entry_points(group=ENTRY_POINT_GROUP)
    except TypeError:
        # Python < 3.10: entry_points() takes no kwargs and returns a
        # dict-like object keyed by group instead of an EntryPoints
        # collection with .select()/group filtering built in.
        eps = entry_points().get(ENTRY_POINT_GROUP, [])  # type: ignore[attr-defined]

    for ep in eps:
        try:
            check_cls: type[BaseCheck] = ep.load()
            if not (isinstance(check_cls, type) and issubclass(check_cls, BaseCheck)):
                raise GateConfigurationError(
                    f"entry point '{ep.name}' does not resolve to a BaseCheck subclass"
                )
            checks.append(check_cls())
            logger.debug(
                "loaded plugin check '%s' from entry point '%s'", check_cls.__name__, ep.name
            )
        except Exception as exc:
            logger.warning("skipping plugin check '%s': %r", ep.name, exc)

    return checks

bdp_model_gate.exceptions

Exceptions raised by BDP Model Gate.

GateConfigurationError is raised for problems with how the gate itself was set up (bad config values, unregistered checks). GateValidationError is raised for problems with the inputs handed to a run (shape mismatches, wrong types) — both are raised eagerly, before any check executes, so a misconfigured run fails fast with a clear message instead of an opaque traceback from deep inside a check.

BDPModelGateError

Bases: Exception

Base class for all BDP Model Gate errors.

GateConfigurationError

Bases: BDPModelGateError

Raised when the gate, its config, or a check is set up incorrectly.

GateValidationError

Bases: BDPModelGateError

Raised when the context passed to a gate run is invalid.