Skip to content

Grouped evaluation

Data these snippets assume

Blocks on this page use a fixed set of names for held-out data instead of re-deriving it each time. To run them, define the names first:

# docs: no-run — the definitions the snippets on this page assume
import numpy as np
from probcal import BetaCalibrator, Masterscale, make_pd_portfolio
from probcal.monitor import CalibrationMonitor

cal_set, new_set = make_pd_portfolio(n=3000, random_state=0), make_pd_portfolio(n=1000, random_state=1)
s_cal, y_cal = cal_set.scores, cal_set.y          # held-out calibration scores and outcomes
w_cal = np.ones_like(y_cal)                       # uniform sample weights
s_new = new_set.scores                            # scores of new obligors
ms = Masterscale.from_edges([0.01, 0.05], names=["G1", "G2", "G3"])   # the masterscale
p_cal = BetaCalibrator().fit(s_cal, y_cal).predict_proba(s_cal)         # calibrated PDs
grades = ms.assign(p_cal)                                              # rating labels
segments = np.array(["seg-a", "seg-b", "seg-c"])[np.arange(len(s_cal)) % 3]  # segment labels
model = ...   # any object with predict_proba(X); the docs use a stub that reads X[:, 0]
mon = CalibrationMonitor(alpha=0.05)              # with a few batches of a calibrated forecast applied

Every block also names, in its first comment line, which of these it uses.

How-to; the bootstrap protocol itself is documented in the Metrics and tests concepts chapter. This page covers only what by= adds on top of it.

# s_cal, y_cal: held-out calibration scores and outcomes
import numpy as np
from probcal.metrics import evaluate
from probcal.plots import plot_reliability   # probcal[viz]
from probcal.curves import reliability_binned

segment = np.where(s_cal < 0.01, "retail", "corporate")

report = evaluate(y_cal, s_cal, n_boot=100, seed=42, by=segment)   # 100: fast for the docs harness
report.groups          # sorted labels, e.g. ("corporate", "retail")
report.pooled          # MetricReport on the full data (seed unchanged)
report.reports         # one MetricReport per group, aligned with .groups
report.counts          # observation count per group
report.to_frame()      # long-format rows: group, metric, value, ci_low, ci_high

fig = plot_reliability(reliability_binned(y_cal, s_cal), y=y_cal, p=s_cal, by=segment)

Faceted reliability grid on the logit scale: a pooled panel sitting on the identity beside three synthetic segment panels (corporate above the identity, retail below it, sme on it), showing a level split the pooled view averages away

That is the case for looking: the pooled panel is on the identity, and the three segments are not. Two offsetting level errors cancel in the pool, so a portfolio that passes every aggregate guardrail can still be systematically optimistic for one segment and pessimistic for another. That is what by= is for, and what Segmented calibration repairs once you have seen it.

Each group's report is the same call you would make by hand on that group's slice: evaluate(y[mask], scores[mask], seed=42 + 1000 * i, ...), where i is the group's position in sorted-label order. Results are identical whether you group with by= or slice manually, and deterministic regardless of how many groups exist or what the labels are. A group with only one outcome class raises the same ValueError a direct call on that slice would, naming the offending group. plot_reliability(by=...) draws the same panels: a pooled panel plus one per group, laid out on shared axes (curve itself is ignored in this mode; each panel rebuilds its own binned curve from that group's data).

What this is not. by= reports side by side; it runs no test of whether groups differ, and applies no multiple-comparison correction across the group reports it returns. Formal group-conditional calibration testing is future work, not implemented here. Read report.reports descriptively, the way you would read several evaluate() calls made by hand.