Skip to content

API reference

The public surface is deliberately small: two band constructors, one estimator convenience, the result object, and the warning/error taxonomy. Everything else is private or documented as internal.

At a glance

Object Description
roc_band Simultaneous confidence band for a binary ROC curve
roc_band_ovr One-vs-rest bands for multiclass scores
from_estimator Build a band directly from a fitted scikit-learn-style estimator
RocBand Frozen result object: band arrays, AUC, plotting, export
NormalityReport Diagnostics attached when normal=True
plotting.plot_band Free-function band plot for composing your own figures
plotting.plot_diagnostics Free-function diagnostic panels
show_versions Environment report for bug reports

All rocci warnings derive from RocciWarning and all input errors are RocciError, so one filterwarnings rule or except clause covers the whole family.

Band constructors

roc_band

roc_band(
    y_true: ArrayLike,
    y_score: ArrayLike,
    *,
    confidence: float = 0.95,
    n_boot: int = 2000,
    normal: bool = False,
    grid_size: int | None = None,
    pos_label: int | str | bool | None = None,
    score_reduce: Literal["mean", "median"] | None = None,
    nan_policy: Literal["raise", "omit"] = "raise",
    random_state: int | None = None,
    diagnostics: bool = False,
    n_threads: int | None = None,
) -> RocBand

Simultaneous confidence band for a population ROC curve.

The default (normal=False) is the distribution-free studentized bootstrap envelope with the Wilson rectangle and Beta order-statistic floors — calibrated across score distributions and invariant to any strictly monotone transform of the scores.

Parameters:

Name Type Description Default
y_true ArrayLike

Labels in any container :mod:rocci.ingest can coerce.

required
y_score ArrayLike

Scores (higher = more positive); 1-D, an (n, 2) probability matrix, or posterior draws with score_reduce set.

required
confidence float

Simultaneous coverage target in (0, 1).

0.95
n_boot int

Bootstrap replicates (>= 100); ignored when normal=True.

2000
normal bool

False → distribution-free envelope method. True → Working-Hotelling binormal band plus normality diagnostics.

False
grid_size int | None

FPR grid points K; Nonemin(512, n_neg + 1).

None
pos_label int | str | bool | None

Which label is positive; None infers.

None
score_reduce Literal['mean', 'median'] | None

"mean"/"median" for posterior-draw scores.

None
nan_policy Literal['raise', 'omit']

"raise" (default) or "omit".

'raise'
random_state int | None

Seeds the bootstrap; same seed + backend + version ⇒ bit-identical band. Ignored when normal=True.

None
diagnostics bool

Render the diagnostics figure immediately (a notebook convenience; requires matplotlib). The same figure is available later via :meth:~rocci._result.RocBand.plot_diagnostics, and the attribution data is always stored on the result.

False
n_threads int | None

Rust thread count; None or -1 → all cores.

None

Returns:

Name Type Description
A RocBand

class:~rocci._result.RocBand.

Raises:

Type Description
RocciError

On invalid inputs (see :mod:rocci.ingest, confidence, n_boot), or for diagnostics=True without matplotlib.

Examples:

>>> import numpy as np
>>> from rocci import roc_band
>>> rng = np.random.default_rng(0)
>>> y_true = np.r_[np.zeros(80), np.ones(80)]
>>> y_score = np.r_[rng.normal(0, 1, 80), rng.normal(1.5, 1, 80)]
>>> band = roc_band(y_true, y_score, random_state=0)
>>> band.method
'envelope'
>>> bool(0.0 <= band.auc <= 1.0)
True
Source code in python/rocci/_api.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def roc_band(
    y_true: ArrayLike,
    y_score: ArrayLike,
    *,
    confidence: float = 0.95,
    n_boot: int = 2000,
    normal: bool = False,
    grid_size: int | None = None,
    pos_label: int | str | bool | None = None,
    score_reduce: Literal["mean", "median"] | None = None,
    nan_policy: Literal["raise", "omit"] = "raise",
    random_state: int | None = None,
    diagnostics: bool = False,
    n_threads: int | None = None,
) -> RocBand:
    """Simultaneous confidence band for a population ROC curve.

    The default (``normal=False``) is the distribution-free studentized
    bootstrap envelope with the Wilson rectangle and Beta order-statistic
    floors — calibrated across score distributions and invariant to any
    strictly monotone transform of the scores.

    Args:
        y_true: Labels in any container :mod:`rocci.ingest` can coerce.
        y_score: Scores (higher = more positive); 1-D, an ``(n, 2)``
            probability matrix, or posterior draws with ``score_reduce`` set.
        confidence: Simultaneous coverage target in ``(0, 1)``.
        n_boot: Bootstrap replicates (``>= 100``); ignored when ``normal=True``.
        normal: ``False`` → distribution-free envelope method. ``True`` →
            Working-Hotelling binormal band plus normality diagnostics.
        grid_size: FPR grid points ``K``; ``None`` → ``min(512, n_neg + 1)``.
        pos_label: Which label is positive; ``None`` infers.
        score_reduce: ``"mean"``/``"median"`` for posterior-draw scores.
        nan_policy: ``"raise"`` (default) or ``"omit"``.
        random_state: Seeds the bootstrap; same seed + backend + version ⇒
            bit-identical band. Ignored when ``normal=True``.
        diagnostics: Render the diagnostics figure immediately (a notebook
            convenience; requires matplotlib). The same figure is available
            later via :meth:`~rocci._result.RocBand.plot_diagnostics`, and
            the attribution data is always stored on the result.
        n_threads: Rust thread count; ``None`` or ``-1`` → all cores.

    Returns:
        A :class:`~rocci._result.RocBand`.

    Raises:
        RocciError: On invalid inputs (see :mod:`rocci.ingest`, ``confidence``,
            ``n_boot``), or for ``diagnostics=True`` without matplotlib.

    Examples:
        >>> import numpy as np
        >>> from rocci import roc_band
        >>> rng = np.random.default_rng(0)
        >>> y_true = np.r_[np.zeros(80), np.ones(80)]
        >>> y_score = np.r_[rng.normal(0, 1, 80), rng.normal(1.5, 1, 80)]
        >>> band = roc_band(y_true, y_score, random_state=0)
        >>> band.method
        'envelope'
        >>> bool(0.0 <= band.auc <= 1.0)
        True
    """
    alpha = check_confidence(confidence)
    if not normal:
        check_n_boot(n_boot)
    check_grid_size(grid_size)
    kernel_threads = check_n_threads(n_threads)
    data = ingest(
        y_true,
        y_score,
        pos_label=pos_label,
        score_reduce=score_reduce,
        nan_policy=nan_policy,
    )

    neg = np.sort(data.neg)
    pos = np.sort(data.pos)
    grid = make_grid(data.n_neg, grid_size)
    auc = mann_whitney_auc(neg, pos)

    if normal:
        lower, upper = working_hotelling_band(neg, pos, grid, alpha)
        fpr_v, tpr_v = empirical_roc_vertices(neg, pos)
        report = normality_report(
            neg, pos, fpr_v, tpr_v, heavy_ties=heavy_ties(neg, pos)
        )
        if report.suspect:
            warnings.warn(report.warning, NormalityWarning, stacklevel=2)
        result = RocBand(
            fpr=grid,
            tpr=empirical_roc_on_grid(neg, pos, grid),
            lower=lower,
            upper=upper,
            confidence=confidence,
            method="working_hotelling",
            n_neg=data.n_neg,
            n_pos=data.n_pos,
            n_boot=None,
            auc=auc,
            auc_ci=None,
            attribution=np.zeros(len(grid), dtype=np.int8),
            vacuous_below=None,
            normality=report,
            backend=_backend.BACKEND,
            random_state=None,
            notes=data.notes,
            _diag=ScoreDiagnostics(neg_sorted=neg, pos_sorted=pos),
        )
    else:
        k_indices = grid_k_indices(grid, data.n_neg)
        seed = resolve_seed(random_state)
        boot_tpr = _backend.bootstrap_tpr_matrix(
            neg, pos, k_indices, n_boot, seed, kernel_threads
        )
        band = assemble_envelope_band(boot_tpr, grid, neg, pos, alpha)
        auc_ci = bootstrap_auc_ci(boot_tpr, grid, neg, pos, alpha)
        result = RocBand(
            fpr=band.grid,
            tpr=band.tpr,
            lower=band.lower,
            upper=band.upper,
            confidence=confidence,
            method="envelope",
            n_neg=data.n_neg,
            n_pos=data.n_pos,
            n_boot=n_boot,
            auc=auc,
            auc_ci=auc_ci,
            attribution=band.attribution,
            vacuous_below=band.vacuous_below,
            normality=None,
            backend=_backend.BACKEND,
            random_state=random_state,
            notes=data.notes,
            _diag=band,
        )

    if diagnostics:
        result.plot_diagnostics()
    return result

roc_band_ovr

roc_band_ovr(
    y_true: ArrayLike,
    y_score: ArrayLike,
    *,
    confidence: float = 0.95,
    family: Literal["bonferroni", "none"] = "bonferroni",
    classes: Any = None,
    **roc_band_kwargs: Any,
) -> dict[Any, RocBand]

One-vs-rest confidence bands for a multiclass problem.

A thin loop over :func:roc_band — no new statistics beyond the alpha split. Column j of y_score scores classes[j] against the rest.

Parameters:

Name Type Description Default
y_true ArrayLike

Labels with m > 2 distinct classes.

required
y_score ArrayLike

(n, m) score/probability matrix.

required
confidence float

Family coverage target.

0.95
family Literal['bonferroni', 'none']

"bonferroni" (default) → each band at 1 - alpha/m so joint coverage of all m curves is >= confidence (exact, conservative, no independence needed). "none" → each band at confidence marginally, no joint claim.

'bonferroni'
classes Any

Class order for the columns; defaults to np.unique(y_true).

None
**roc_band_kwargs Any

Forwarded to :func:roc_band.

{}

Returns:

Type Description
dict[Any, RocBand]

dict mapping each class label to its :class:~rocci._result.RocBand

dict[Any, RocBand]

(each carrying its per-class effective confidence).

Raises:

Type Description
RocciError

If normal=True (the rest class is a mixture — the Working-Hotelling failure mode), if m <= 2, on duplicate or absent class labels, on a column/class count mismatch, or on an invalid family.

Examples:

>>> import numpy as np
>>> from rocci import roc_band_ovr
>>> rng = np.random.default_rng(0)
>>> y = np.repeat([0, 1, 2], 40)
>>> scores = rng.random((120, 3))
>>> bands = roc_band_ovr(y, scores, random_state=0)
>>> sorted(int(c) for c in bands)
[0, 1, 2]
>>> round(bands[0].confidence, 6)
0.983333
Source code in python/rocci/_api.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
def roc_band_ovr(
    y_true: ArrayLike,
    y_score: ArrayLike,
    *,
    confidence: float = 0.95,
    family: Literal["bonferroni", "none"] = "bonferroni",
    classes: Any = None,
    **roc_band_kwargs: Any,
) -> dict[Any, RocBand]:
    """One-vs-rest confidence bands for a multiclass problem.

    A thin loop over :func:`roc_band` — no new statistics beyond the alpha
    split. Column ``j`` of ``y_score`` scores ``classes[j]`` against the rest.

    Args:
        y_true: Labels with ``m > 2`` distinct classes.
        y_score: ``(n, m)`` score/probability matrix.
        confidence: Family coverage target.
        family: ``"bonferroni"`` (default) → each band at ``1 - alpha/m`` so
            joint coverage of all ``m`` curves is ``>= confidence`` (exact,
            conservative, no independence needed). ``"none"`` → each band at
            ``confidence`` marginally, no joint claim.
        classes: Class order for the columns; defaults to ``np.unique(y_true)``.
        **roc_band_kwargs: Forwarded to :func:`roc_band`.

    Returns:
        ``dict`` mapping each class label to its :class:`~rocci._result.RocBand`
        (each carrying its per-class effective ``confidence``).

    Raises:
        RocciError: If ``normal=True`` (the rest class is a mixture — the
            Working-Hotelling failure mode), if ``m <= 2``, on duplicate or
            absent class labels, on a column/class count mismatch, or on an
            invalid ``family``.

    Examples:
        >>> import numpy as np
        >>> from rocci import roc_band_ovr
        >>> rng = np.random.default_rng(0)
        >>> y = np.repeat([0, 1, 2], 40)
        >>> scores = rng.random((120, 3))
        >>> bands = roc_band_ovr(y, scores, random_state=0)
        >>> sorted(int(c) for c in bands)
        [0, 1, 2]
        >>> round(bands[0].confidence, 6)
        0.983333
    """
    if roc_band_kwargs.get("normal", False):
        raise RocciError(
            "roc_band_ovr does not support normal=True: each one-vs-rest 'rest' "
            "class is a mixture of the remaining classes — structurally the "
            "bimodal-negatives regime where Working-Hotelling coverage collapses. "
            "If you insist, loop roc_band(normal=True) per class yourself."
        )
    if family not in ("bonferroni", "none"):
        raise RocciError(f"family must be 'bonferroni' or 'none', got {family!r}.")

    yt = np.asarray(y_true)
    ys = np.asarray(y_score)
    class_list = list(np.unique(yt) if classes is None else classes)
    m = len(class_list)
    if m <= 2:
        raise RocciError(
            f"roc_band_ovr needs m > 2 classes, found {m}; for a binary problem "
            "use roc_band directly."
        )
    if len({str(c) for c in class_list}) != m:
        raise RocciError(
            f"classes contains duplicates: {class_list!r}; each class must "
            "appear exactly once."
        )
    present = np.unique(yt)
    missing = [c for c in class_list if not np.any(present == c)]
    if missing:
        raise RocciError(
            f"classes {missing!r} do not occur in y_true (present: "
            f"{present.tolist()!r}); every one-vs-rest split needs at least one "
            "positive sample."
        )
    if ys.ndim != 2 or ys.shape[1] != m:
        raise RocciError(
            f"y_score must be an (n, m={m}) matrix to match the {m} classes; got "
            f"shape {ys.shape}. Column j scores classes[j] against the rest."
        )

    alpha = 1.0 - confidence
    per_class_conf = 1.0 - alpha / m if family == "bonferroni" else confidence
    random_state = roc_band_kwargs.pop("random_state", None)
    seeds = np.random.SeedSequence(random_state).spawn(m)

    bands: dict[Any, RocBand] = {}
    for j, cls in enumerate(class_list):
        seed_j = int(seeds[j].generate_state(1, dtype=np.uint64)[0])
        bands[cls] = roc_band(
            yt == cls,
            ys[:, j],
            confidence=per_class_conf,
            random_state=seed_j,
            **roc_band_kwargs,
        )
    return bands

from_estimator

from_estimator(
    estimator: Any,
    X: ArrayLike,
    y: ArrayLike,
    *,
    response_method: Literal[
        "auto", "predict_proba", "decision_function"
    ] = "auto",
    **roc_band_kwargs: Any,
) -> RocBand

Build a band from a fitted estimator's scores.

Duck-typed like RocCurveDisplay.from_estimator — no sklearn import. Uses predict_proba(X) when available (the positive column is selected during ingestion), otherwise decision_function(X).

Parameters:

Name Type Description Default
estimator Any

Anything exposing predict_proba and/or decision_function.

required
X ArrayLike

Feature matrix passed to the estimator.

required
y ArrayLike

True labels.

required
response_method Literal['auto', 'predict_proba', 'decision_function']

"auto" (default), "predict_proba", or "decision_function".

'auto'
**roc_band_kwargs Any

Forwarded to :func:roc_band.

{}

Returns:

Name Type Description
A RocBand

class:~rocci._result.RocBand.

Raises:

Type Description
RocciError

If the requested response method is unavailable.

Examples:

>>> import numpy as np
>>> from rocci import from_estimator
>>> class Stub:
...     def decision_function(self, X):
...         return np.asarray(X).ravel()
>>> rng = np.random.default_rng(0)
>>> X = np.r_[rng.normal(0, 1, 60), rng.normal(1.5, 1, 60)][:, None]
>>> y = np.r_[np.zeros(60), np.ones(60)]
>>> band = from_estimator(Stub(), X, y, random_state=0)
>>> band.method
'envelope'
Source code in python/rocci/_api.py
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
def from_estimator(
    estimator: Any,
    X: ArrayLike,  # noqa: N803 — sklearn convention (feature matrix)
    y: ArrayLike,
    *,
    response_method: Literal["auto", "predict_proba", "decision_function"] = "auto",
    **roc_band_kwargs: Any,
) -> RocBand:
    """Build a band from a fitted estimator's scores.

    Duck-typed like ``RocCurveDisplay.from_estimator`` — no sklearn import.
    Uses ``predict_proba(X)`` when available (the positive column is selected
    during ingestion), otherwise ``decision_function(X)``.

    Args:
        estimator: Anything exposing ``predict_proba`` and/or ``decision_function``.
        X: Feature matrix passed to the estimator.
        y: True labels.
        response_method: ``"auto"`` (default), ``"predict_proba"``, or
            ``"decision_function"``.
        **roc_band_kwargs: Forwarded to :func:`roc_band`.

    Returns:
        A :class:`~rocci._result.RocBand`.

    Raises:
        RocciError: If the requested response method is unavailable.

    Examples:
        >>> import numpy as np
        >>> from rocci import from_estimator
        >>> class Stub:
        ...     def decision_function(self, X):
        ...         return np.asarray(X).ravel()
        >>> rng = np.random.default_rng(0)
        >>> X = np.r_[rng.normal(0, 1, 60), rng.normal(1.5, 1, 60)][:, None]
        >>> y = np.r_[np.zeros(60), np.ones(60)]
        >>> band = from_estimator(Stub(), X, y, random_state=0)
        >>> band.method
        'envelope'
    """
    if response_method not in ("auto", "predict_proba", "decision_function"):
        raise RocciError(
            f"response_method must be 'auto', 'predict_proba', or "
            f"'decision_function', got {response_method!r}."
        )
    has_proba = hasattr(estimator, "predict_proba")
    has_decision = hasattr(estimator, "decision_function")

    if response_method in ("auto", "predict_proba") and has_proba:
        scores = estimator.predict_proba(X)
    elif response_method in ("auto", "decision_function") and has_decision:
        scores = estimator.decision_function(X)
    else:
        wanted = "predict_proba or decision_function"
        if response_method != "auto":
            wanted = response_method
        raise RocciError(
            f"estimator does not expose {wanted}; from_estimator needs one of "
            "predict_proba / decision_function."
        )
    return roc_band(y, scores, **roc_band_kwargs)

Results

RocBand dataclass

RocBand(
    fpr: FloatArray,
    tpr: FloatArray,
    lower: FloatArray,
    upper: FloatArray,
    confidence: float,
    method: Literal["envelope", "working_hotelling"],
    n_neg: int,
    n_pos: int,
    n_boot: int | None,
    auc: float,
    auc_ci: tuple[float, float] | None,
    attribution: NDArray[int8],
    vacuous_below: float | None,
    normality: NormalityReport | None,
    backend: Literal["rust", "numpy"],
    random_state: int | None,
    notes: tuple[str, ...] = (),
    _diag: EnvelopeBand | ScoreDiagnostics | None = None,
)

A simultaneous confidence band for a population ROC curve.

Returned by :func:rocci.roc_band. Immutable; arrays are shape (K,) on the FPR grid fpr. eq is disabled because array fields make value-equality ill-defined.

Attributes:

Name Type Description
fpr FloatArray

FPR grid, shape (K,).

tpr FloatArray

Empirical ROC at the grid.

lower FloatArray

Lower band.

upper FloatArray

Upper band.

confidence float

Simultaneous coverage level of this band.

method Literal['envelope', 'working_hotelling']

"envelope" or "working_hotelling".

n_neg int

Number of negatives.

n_pos int

Number of positives.

n_boot int | None

Bootstrap replicates (None for Working-Hotelling).

auc float

Exact Mann-Whitney AUC, ties weighted 1/2 — identical to sklearn.metrics.roc_auc_score.

auc_ci tuple[float, float] | None

Recentered percentile bootstrap AUC CI (None for Working-Hotelling); consistent with auc even under ties.

attribution NDArray[int8]

int8 codes — 0 bootstrap, 1 Beta floor, 2 Wilson floor, 3 pinned endpoint.

vacuous_below float | None

FPR below which the lower band is provably vacuous (None for Working-Hotelling).

normality NormalityReport | None

Diagnostics on the normal=True path, else None.

backend Literal['rust', 'numpy']

Compute backend that produced the band ("rust"/"numpy").

random_state int | None

The seed the caller passed (None if unseeded).

notes tuple[str, ...]

INFO-level ingestion notes surfaced by :meth:summary.

Examples:

>>> import numpy as np
>>> from rocci import roc_band
>>> rng = np.random.default_rng(0)
>>> y_true = np.r_[np.zeros(60), np.ones(60)]
>>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
>>> band = roc_band(y_true, y_score, random_state=0)
>>> band.fpr.shape == band.lower.shape == band.upper.shape
True
>>> bool((band.lower <= band.upper + 1e-12).all())
True

band_area property

band_area: float

Mean vertical band width — a scalar tightness metric for the band.

Examples:

>>> import numpy as np
>>> from rocci import roc_band
>>> rng = np.random.default_rng(1)
>>> y_true = np.r_[np.zeros(60), np.ones(60)]
>>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
>>> band = roc_band(y_true, y_score, random_state=0)
>>> 0.0 < band.band_area < 1.0
True

at

at(fpr: ArrayLike) -> tuple[FloatArray, FloatArray, FloatArray]

Step-interpolate the band at arbitrary FPR points.

Uses the same right-continuous step convention as the band construction.

Parameters:

Name Type Description Default
fpr ArrayLike

Query FPR value(s) in [0, 1].

required

Returns:

Type Description
tuple[FloatArray, FloatArray, FloatArray]

Tuple (lower, tpr, upper) at the query points.

Raises:

Type Description
RocciError

If any query is outside [0, 1].

Examples:

>>> import numpy as np
>>> from rocci import roc_band
>>> rng = np.random.default_rng(2)
>>> y_true = np.r_[np.zeros(60), np.ones(60)]
>>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
>>> band = roc_band(y_true, y_score, random_state=0)
>>> lo, tp, up = band.at([0.1, 0.5])
>>> bool((lo <= tp).all() and (tp <= up).all())
True
Source code in python/rocci/_result.py
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def at(self, fpr: ArrayLike) -> tuple[FloatArray, FloatArray, FloatArray]:
    """Step-interpolate the band at arbitrary FPR points.

    Uses the same right-continuous step convention as the band construction.

    Args:
        fpr: Query FPR value(s) in ``[0, 1]``.

    Returns:
        Tuple ``(lower, tpr, upper)`` at the query points.

    Raises:
        RocciError: If any query is outside ``[0, 1]``.

    Examples:
        >>> import numpy as np
        >>> from rocci import roc_band
        >>> rng = np.random.default_rng(2)
        >>> y_true = np.r_[np.zeros(60), np.ones(60)]
        >>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
        >>> band = roc_band(y_true, y_score, random_state=0)
        >>> lo, tp, up = band.at([0.1, 0.5])
        >>> bool((lo <= tp).all() and (tp <= up).all())
        True
    """
    query = np.asarray(fpr, dtype=np.float64)
    # NaN fails every comparison, so check containment as a negation.
    if query.size and not ((query >= 0.0) & (query <= 1.0)).all():
        raise RocciError(
            "at() queries must lie in [0, 1] (the FPR axis); got a value "
            "outside that range or NaN."
        )
    return (
        step_lookup(self.fpr, self.lower, query),
        step_lookup(self.fpr, self.tpr, query),
        step_lookup(self.fpr, self.upper, query),
    )

sens_at_spec

sens_at_spec(spec: ArrayLike) -> tuple[FloatArray, FloatArray, FloatArray]

Read the sensitivity (TPR) band at given specificities.

A vertical read of the band, re-parameterized onto the specificity axis: specificity is 1 - fpr, so this queries the band at fpr = 1 - spec and returns the sensitivity there. Because the band is simultaneous, you may read as many specificities as you like from one band with no multiple-comparison correction — the joint coverage already covers them. Shares the same right-continuous step convention as :meth:at.

Parameters:

Name Type Description Default
spec ArrayLike

Query specificity value(s) in [0, 1].

required

Returns:

Type Description
FloatArray

Tuple (lower, sens, upper) at the query specificities, where

FloatArray

sens is the empirical sensitivity and [lower, upper] its

FloatArray

simultaneous interval.

Raises:

Type Description
RocciError

If any query is outside [0, 1].

Examples:

>>> import numpy as np
>>> from rocci import roc_band
>>> rng = np.random.default_rng(2)
>>> y_true = np.r_[np.zeros(60), np.ones(60)]
>>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
>>> band = roc_band(y_true, y_score, random_state=0)
>>> lo, se, up = band.sens_at_spec([0.9, 0.5])
>>> bool((lo <= se).all() and (se <= up).all())
True
>>> bool(np.array_equal(band.sens_at_spec(0.9), band.at(1 - 0.9)))
True
Source code in python/rocci/_result.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
def sens_at_spec(
    self, spec: ArrayLike
) -> tuple[FloatArray, FloatArray, FloatArray]:
    """Read the sensitivity (TPR) band at given specificities.

    A vertical read of the band, re-parameterized onto the specificity axis:
    specificity is ``1 - fpr``, so this queries the band at ``fpr = 1 - spec``
    and returns the sensitivity there. Because the band is simultaneous, you
    may read as many specificities as you like from one band with no
    multiple-comparison correction — the joint coverage already covers them.
    Shares the same right-continuous step convention as :meth:`at`.

    Args:
        spec: Query specificity value(s) in ``[0, 1]``.

    Returns:
        Tuple ``(lower, sens, upper)`` at the query specificities, where
        ``sens`` is the empirical sensitivity and ``[lower, upper]`` its
        simultaneous interval.

    Raises:
        RocciError: If any query is outside ``[0, 1]``.

    Examples:
        >>> import numpy as np
        >>> from rocci import roc_band
        >>> rng = np.random.default_rng(2)
        >>> y_true = np.r_[np.zeros(60), np.ones(60)]
        >>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
        >>> band = roc_band(y_true, y_score, random_state=0)
        >>> lo, se, up = band.sens_at_spec([0.9, 0.5])
        >>> bool((lo <= se).all() and (se <= up).all())
        True
        >>> bool(np.array_equal(band.sens_at_spec(0.9), band.at(1 - 0.9)))
        True
    """
    query = np.asarray(spec, dtype=np.float64)
    # NaN fails every comparison, so check containment as a negation.
    if query.size and not ((query >= 0.0) & (query <= 1.0)).all():
        raise RocciError(
            "sens_at_spec() queries must lie in [0, 1] (the specificity "
            "axis); got a value outside that range or NaN."
        )
    # fpr = 1 - spec; the TPR band there is the sensitivity band. A valid
    # spec keeps 1 - spec in [0, 1], so at()'s own check never fires here.
    return self.at(1.0 - query)

spec_at_sens

spec_at_sens(sens: ArrayLike) -> tuple[FloatArray, FloatArray, FloatArray]

Read the specificity (1 - FPR) band at given sensitivities.

A horizontal read of the band: the true ROC attains sensitivity s at some FPR, and the FPRs consistent with that sensitivity are those whose band interval [lower, upper] brackets s. Under the right-continuous step convention of :meth:at, a bracketing grid column covers every FPR up to the next grid point, so the consistent set runs from the first bracketing column through the right edge of the last one. The specificity interval is the 1 - fpr image of that set. This is a projection of the same simultaneous region as :meth:at, so it inherits the joint coverage with no multiple-comparison correction.

Two behaviors worth knowing:

  • Vacuous region. For a low sensitivity target the lower arm is 0 across the vacuous FPR region (below vacuous_below), so the specificity interval is deliberately wide — there is no distribution-free bound to report there, not a bug.
  • No consistent FPR. If a tall step in the empirical ROC leaves s bracketed by no grid column, the interval is nan (the query is still in-domain, so this is reported rather than raised). On the normal=True path, where the arms are not forced monotone, the interval is the conservative outer hull of a possibly-disconnected set.

Parameters:

Name Type Description Default
sens ArrayLike

Query sensitivity value(s) in [0, 1].

required

Returns:

Type Description
FloatArray

Tuple (lower, spec, upper) at the query sensitivities, where

FloatArray

spec is the empirical specificity and [lower, upper] its

FloatArray

simultaneous interval.

Raises:

Type Description
RocciError

If any query is outside [0, 1].

Examples:

>>> import numpy as np
>>> from rocci import roc_band
>>> rng = np.random.default_rng(2)
>>> y_true = np.r_[np.zeros(60), np.ones(60)]
>>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
>>> band = roc_band(y_true, y_score, random_state=0)
>>> lo, sp, up = band.spec_at_sens([0.8, 0.5])
>>> bool((lo <= sp).all() and (sp <= up).all())
True
Source code in python/rocci/_result.py
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
def spec_at_sens(
    self, sens: ArrayLike
) -> tuple[FloatArray, FloatArray, FloatArray]:
    """Read the specificity (1 - FPR) band at given sensitivities.

    A horizontal read of the band: the true ROC attains sensitivity ``s`` at
    some FPR, and the FPRs consistent with that sensitivity are those whose
    band interval ``[lower, upper]`` brackets ``s``. Under the
    right-continuous step convention of :meth:`at`, a bracketing grid column
    covers every FPR up to the next grid point, so the consistent set runs
    from the first bracketing column through the right edge of the last one.
    The specificity interval is the ``1 - fpr`` image of that set. This is a
    projection of the same simultaneous region as :meth:`at`, so it inherits
    the joint coverage with no multiple-comparison correction.

    Two behaviors worth knowing:

    - **Vacuous region.** For a low sensitivity target the lower arm is 0
      across the vacuous FPR region (below ``vacuous_below``), so the
      specificity interval is deliberately wide — there is no
      distribution-free bound to report there, not a bug.
    - **No consistent FPR.** If a tall step in the empirical ROC leaves ``s``
      bracketed by no grid column, the interval is ``nan`` (the query is
      still in-domain, so this is reported rather than raised). On the
      ``normal=True`` path, where the arms are not forced monotone, the
      interval is the conservative outer hull of a possibly-disconnected set.

    Args:
        sens: Query sensitivity value(s) in ``[0, 1]``.

    Returns:
        Tuple ``(lower, spec, upper)`` at the query sensitivities, where
        ``spec`` is the empirical specificity and ``[lower, upper]`` its
        simultaneous interval.

    Raises:
        RocciError: If any query is outside ``[0, 1]``.

    Examples:
        >>> import numpy as np
        >>> from rocci import roc_band
        >>> rng = np.random.default_rng(2)
        >>> y_true = np.r_[np.zeros(60), np.ones(60)]
        >>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
        >>> band = roc_band(y_true, y_score, random_state=0)
        >>> lo, sp, up = band.spec_at_sens([0.8, 0.5])
        >>> bool((lo <= sp).all() and (sp <= up).all())
        True
    """
    s = np.asarray(sens, dtype=np.float64)
    # NaN fails every comparison, so check containment as a negation.
    if s.size and not ((s >= 0.0) & (s <= 1.0)).all():
        raise RocciError(
            "spec_at_sens() queries must lie in [0, 1] (the sensitivity "
            "axis); got a value outside that range or NaN."
        )
    s2 = np.atleast_1d(s)
    # Grid columns whose band interval [lower, upper] brackets each query s.
    # For monotone (envelope) arms this is a contiguous run so min/max are
    # exact; for non-monotone (Working-Hotelling) arms they give the outer
    # hull of a possibly-disconnected set, a conservative (wider) interval.
    member = (self.lower[None, :] <= s2[:, None]) & (
        s2[:, None] <= self.upper[None, :]
    )
    has = member.any(axis=1)
    # Column i's interval applies over [fpr[i], fpr[i+1]) under the
    # right-continuous step convention (see at()), so a bracketing column
    # is consistent with every FPR up to its right edge, not only its own
    # grid point: the left projection uses grid points, the right one uses
    # right edges. The final column's edge is its own grid point — the
    # grid ends at fpr = 1, where the step has zero width.
    right_edge = np.append(self.fpr[1:], self.fpr[-1])
    f_lo = np.where(member, self.fpr[None, :], np.inf).min(axis=1)
    f_hi = np.where(member, right_edge[None, :], -np.inf).max(axis=1)
    # Empty rows leave f_lo=+inf, f_hi=-inf; report nan rather than garbage.
    spec_lo = np.where(has, 1.0 - f_hi, np.nan)
    spec_hi = np.where(has, 1.0 - f_lo, np.nan)
    # Point estimate: invert the empirical ROC, which is right-continuous
    # non-decreasing, so its quantile inverse takes side="left" (the dual of
    # the side="right" value lookup). The empirical inverse ignores the lower
    # arm, so at a tall step it can exceed spec_hi by up to one grid step;
    # clip to keep lower <= estimate <= upper (nan-safe, so empty rows pass).
    idx = np.clip(np.searchsorted(self.tpr, s2, side="left"), 0, self.tpr.size - 1)
    spec_hat = np.clip(1.0 - self.fpr[idx], spec_lo, spec_hi)
    return (
        spec_lo.reshape(s.shape),
        spec_hat.reshape(s.shape),
        spec_hi.reshape(s.shape),
    )

plot

plot(ax: Axes | None = None, **style: Any) -> Axes

Plot the band, the empirical ROC, and the chance diagonal.

Requires matplotlib (pip install 'rocci[plot]'); imported lazily.

Parameters:

Name Type Description Default
ax Axes | None

Axes to draw into; None creates a new figure.

None
**style Any

Style overrides forwarded to :func:rocci.plotting.plot_band (color, band_alpha, label, show_vacuous).

{}

Returns:

Type Description
Axes

The Axes, for composition.

Raises:

Type Description
RocciError

If matplotlib is not installed.

Examples:

>>> import numpy as np
>>> from rocci import roc_band
>>> rng = np.random.default_rng(5)
>>> y_true = np.r_[np.zeros(60), np.ones(60)]
>>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
>>> ax = roc_band(y_true, y_score, random_state=0).plot()
>>> ax.get_xlabel()
'False positive rate'
Source code in python/rocci/_result.py
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
def plot(
    self, ax: matplotlib.axes.Axes | None = None, **style: Any
) -> matplotlib.axes.Axes:
    """Plot the band, the empirical ROC, and the chance diagonal.

    Requires matplotlib (``pip install 'rocci[plot]'``); imported lazily.

    Args:
        ax: Axes to draw into; ``None`` creates a new figure.
        **style: Style overrides forwarded to
            :func:`rocci.plotting.plot_band` (``color``, ``band_alpha``,
            ``label``, ``show_vacuous``).

    Returns:
        The Axes, for composition.

    Raises:
        RocciError: If matplotlib is not installed.

    Examples:
        >>> import numpy as np
        >>> from rocci import roc_band
        >>> rng = np.random.default_rng(5)
        >>> y_true = np.r_[np.zeros(60), np.ones(60)]
        >>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
        >>> ax = roc_band(y_true, y_score, random_state=0).plot()
        >>> ax.get_xlabel()
        'False positive rate'
    """
    from rocci import plotting

    return plotting.plot_band(self, ax=ax, **style)

plot_diagnostics

plot_diagnostics(fig: Figure | None = None) -> Figure

Plot the "why did my band do that" diagnostics figure.

Envelope path: the band with the lower arm color-coded by floor attribution, plus the variance channels that drive the floor gate. Working-Hotelling path: the band plus the normality diagnostics (per-class QQ plots and the probit-linearity fit).

Requires matplotlib (pip install 'rocci[plot]'); imported lazily.

Parameters:

Name Type Description Default
fig Figure | None

Figure to draw into; None creates a new one.

None

Returns:

Type Description
Figure

The Figure.

Raises:

Type Description
RocciError

If matplotlib is not installed.

Examples:

>>> import numpy as np
>>> from rocci import roc_band
>>> rng = np.random.default_rng(6)
>>> y_true = np.r_[np.zeros(60), np.ones(60)]
>>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
>>> fig = roc_band(y_true, y_score, random_state=0).plot_diagnostics()
>>> len(fig.axes)
2
Source code in python/rocci/_result.py
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
def plot_diagnostics(
    self, fig: matplotlib.figure.Figure | None = None
) -> matplotlib.figure.Figure:
    """Plot the "why did my band do that" diagnostics figure.

    Envelope path: the band with the lower arm color-coded by floor
    attribution, plus the variance channels that drive the floor gate.
    Working-Hotelling path: the band plus the normality diagnostics
    (per-class QQ plots and the probit-linearity fit).

    Requires matplotlib (``pip install 'rocci[plot]'``); imported lazily.

    Args:
        fig: Figure to draw into; ``None`` creates a new one.

    Returns:
        The Figure.

    Raises:
        RocciError: If matplotlib is not installed.

    Examples:
        >>> import numpy as np
        >>> from rocci import roc_band
        >>> rng = np.random.default_rng(6)
        >>> y_true = np.r_[np.zeros(60), np.ones(60)]
        >>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
        >>> fig = roc_band(y_true, y_score, random_state=0).plot_diagnostics()
        >>> len(fig.axes)
        2
    """
    from rocci import plotting

    return plotting.plot_diagnostics(self, fig=fig)

to_dataframe

to_dataframe() -> DataFrame

Return the band as a pandas DataFrame (lazy import).

Returns:

Type Description
DataFrame

Columns [fpr, lower, tpr, upper, attribution].

Raises:

Type Description
RocciError

If pandas is not installed.

Examples:

>>> import numpy as np
>>> from rocci import roc_band
>>> rng = np.random.default_rng(3)
>>> y_true = np.r_[np.zeros(60), np.ones(60)]
>>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
>>> band = roc_band(y_true, y_score, random_state=0)
>>> list(band.to_dataframe().columns)
['fpr', 'lower', 'tpr', 'upper', 'attribution']
Source code in python/rocci/_result.py
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
def to_dataframe(self) -> pd.DataFrame:
    """Return the band as a pandas DataFrame (lazy import).

    Returns:
        Columns ``[fpr, lower, tpr, upper, attribution]``.

    Raises:
        RocciError: If pandas is not installed.

    Examples:
        >>> import numpy as np
        >>> from rocci import roc_band
        >>> rng = np.random.default_rng(3)
        >>> y_true = np.r_[np.zeros(60), np.ones(60)]
        >>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
        >>> band = roc_band(y_true, y_score, random_state=0)
        >>> list(band.to_dataframe().columns)
        ['fpr', 'lower', 'tpr', 'upper', 'attribution']
    """
    try:
        import pandas as pd
    except ImportError as err:
        raise RocciError(
            "to_dataframe() requires pandas — pip install pandas."
        ) from err
    return pd.DataFrame(
        {
            "fpr": self.fpr,
            "lower": self.lower,
            "tpr": self.tpr,
            "upper": self.upper,
            "attribution": self.attribution,
        }
    )

summary

summary() -> str

Return a human-readable report of the band.

Examples:

>>> import numpy as np
>>> from rocci import roc_band
>>> rng = np.random.default_rng(4)
>>> y_true = np.r_[np.zeros(60), np.ones(60)]
>>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
>>> text = roc_band(y_true, y_score, random_state=0).summary()
>>> text.startswith("rocci confidence band (envelope)")
True
Source code in python/rocci/_result.py
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
def summary(self) -> str:
    """Return a human-readable report of the band.

    Examples:
        >>> import numpy as np
        >>> from rocci import roc_band
        >>> rng = np.random.default_rng(4)
        >>> y_true = np.r_[np.zeros(60), np.ones(60)]
        >>> y_score = np.r_[rng.normal(0, 1, 60), rng.normal(1.4, 1, 60)]
        >>> text = roc_band(y_true, y_score, random_state=0).summary()
        >>> text.startswith("rocci confidence band (envelope)")
        True
    """
    method_label = "envelope" if self.method == "envelope" else "Working-Hotelling"
    lines = [
        f"rocci confidence band ({method_label})",
        f"  samples: n_neg={self.n_neg}, n_pos={self.n_pos}",
        f"  coverage: {self.confidence:.0%} simultaneous",
        f"  AUC: {self.auc:.4f}"
        + (
            f"  (CI: {self.auc_ci[0]:.4f}, {self.auc_ci[1]:.4f})"
            if self.auc_ci is not None
            else ""
        ),
        f"  band area (mean width): {self.band_area:.4f}",
        f"  backend: {self.backend}"
        + (f", n_boot={self.n_boot}" if self.n_boot is not None else ""),
    ]
    if self.vacuous_below is not None:
        lines.append(
            f"  no distribution-free lower bound exists below FPR ~= "
            f"{self.vacuous_below:.4f}; increase the number of negatives to "
            "certify lower FPRs."
        )
    jurisdictions = {
        _ATTR_NAMES[code]: int(np.count_nonzero(self.attribution == code))
        for code in (1, 2)
    }
    active = [f"{name}: {n} pts" for name, n in jurisdictions.items() if n]
    if active:
        lines.append("  floor jurisdictions: " + ", ".join(active))
    if self.normality is not None and self.normality.suspect:
        lines.append("  normality: SUSPECT - " + self.normality.warning)
    lines.extend(f"  note: {n}" for n in self.notes)
    lines.append("  please cite rocci (see CITATION.cff).")
    return "\n".join(lines)

NormalityReport dataclass

NormalityReport(
    neg_sf_stat: float,
    neg_sf_pvalue: float,
    neg_k2_stat: float,
    neg_k2_pvalue: float,
    neg_skew: float,
    neg_excess_kurtosis: float,
    pos_sf_stat: float,
    pos_sf_pvalue: float,
    pos_k2_stat: float,
    pos_k2_pvalue: float,
    pos_skew: float,
    pos_excess_kurtosis: float,
    probit_r2: float,
    suspect: bool,
    warning: str,
)

Normality diagnostics for the Working-Hotelling band.

Populated only on the normal=True path; RocBand carries None on the envelope path. Each class gets two complementary checks — Shapiro-Francia (QQ-plot straightness, run for 5 <= n <= 5000) and D'Agostino K² (skewness + kurtosis, run for n >= 20) — plus the moment effect sizes themselves; a check that does not apply at the class size (or to a constant class) reports nan and never creates suspicion. The suspect flag is the OR of every check: any one tripping flags the fit, with thresholds tuned to the MCC-optimal balance of sensitivity and specificity for predicting Working-Hotelling miscoverage. A quiet gate is weak evidence, not a certificate — there is no safe diagnostic region.

Attributes:

Name Type Description
neg_sf_stat float

Shapiro-Francia W' for the negative class.

neg_sf_pvalue float

Its p-value.

neg_k2_stat float

D'Agostino K² for the negative class.

neg_k2_pvalue float

Its p-value.

neg_skew float

Negative-class sample skewness (Gaussian ~ 0).

neg_excess_kurtosis float

Negative-class excess kurtosis (Gaussian ~ 0; positive = heavy tails, negative = short tails/bimodal).

pos_sf_stat float

Shapiro-Francia W' for the positive class.

pos_sf_pvalue float

Its p-value.

pos_k2_stat float

D'Agostino K² for the positive class.

pos_k2_pvalue float

Its p-value.

pos_skew float

Positive-class sample skewness.

pos_excess_kurtosis float

Positive-class excess kurtosis.

probit_r2 float

OLS R² of probit-TPR on probit-FPR over the ROC interior (nan when too few interior vertices).

suspect bool

Whether binormality looks doubtful.

warning str

The exact warning text emitted (empty if none).

Examples:

>>> from rocci import NormalityReport
>>> rep = NormalityReport(
...     neg_sf_stat=0.99,
...     neg_sf_pvalue=0.4,
...     neg_k2_stat=1.2,
...     neg_k2_pvalue=0.55,
...     neg_skew=0.05,
...     neg_excess_kurtosis=-0.1,
...     pos_sf_stat=0.98,
...     pos_sf_pvalue=0.3,
...     pos_k2_stat=2.0,
...     pos_k2_pvalue=0.37,
...     pos_skew=-0.02,
...     pos_excess_kurtosis=0.2,
...     probit_r2=0.995,
...     suspect=False,
...     warning="",
... )
>>> rep.suspect
False
>>> rep.neg_pvalue  # headline: smallest check p-value for the class
0.4

neg_pvalue property

neg_pvalue: float

Smallest negative-class check p-value (nan if none applied).

Falls below the suspect threshold exactly when one of the class's checks does — a headline number, not a calibrated single-test p-value.

pos_pvalue property

pos_pvalue: float

Smallest positive-class check p-value (nan if none applied).

Plotting

Free-function equivalents of the RocBand plot methods, for composition into your own figures. matplotlib is an optional extra (pip install 'rocci[plot]').

plot_band

plot_band(
    band: RocBand,
    ax: Axes | None = None,
    *,
    color: str = _BLUE,
    band_alpha: float = 0.25,
    label: str | None = None,
    show_vacuous: bool = False,
) -> Axes

Plot the confidence band, the empirical ROC, and the chance diagonal.

Both the band arms and the empirical curve are drawn as right-continuous steps — the same convention used by :meth:~rocci.RocBand.at — so the figure shows exactly what the band certifies.

Parameters:

Name Type Description Default
band RocBand

The band to plot.

required
ax Axes | None

Axes to draw into; None creates a (5.5, 5.0) figure.

None
color str

Band and ROC line color.

_BLUE
band_alpha float

Opacity of the band fill.

0.25
label str | None

Legend label for the band; None composes "{confidence:.0%} simultaneous band (<method>)".

None
show_vacuous bool

Hatch the FPR region below band.vacuous_below where no distribution-free lower bound exists.

False

Returns:

Type Description
Axes

The Axes, for composition.

Raises:

Type Description
RocciError

If matplotlib is not installed.

Examples:

>>> import numpy as np
>>> from rocci import roc_band
>>> from rocci.plotting import plot_band
>>> rng = np.random.default_rng(0)
>>> y = np.r_[np.zeros(60), np.ones(60)]
>>> s = np.r_[rng.normal(0, 1, 60), rng.normal(1.5, 1, 60)]
>>> ax = plot_band(roc_band(y, s, random_state=0), show_vacuous=True)
>>> ax.get_ylabel()
'True positive rate'
Source code in python/rocci/plotting.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def plot_band(
    band: RocBand,
    ax: Axes | None = None,
    *,
    color: str = _BLUE,
    band_alpha: float = 0.25,
    label: str | None = None,
    show_vacuous: bool = False,
) -> Axes:
    """Plot the confidence band, the empirical ROC, and the chance diagonal.

    Both the band arms and the empirical curve are drawn as right-continuous
    steps — the same convention used by :meth:`~rocci.RocBand.at` — so
    the figure shows exactly what the band certifies.

    Args:
        band: The band to plot.
        ax: Axes to draw into; ``None`` creates a ``(5.5, 5.0)`` figure.
        color: Band and ROC line color.
        band_alpha: Opacity of the band fill.
        label: Legend label for the band; ``None`` composes
            ``"{confidence:.0%} simultaneous band (<method>)"``.
        show_vacuous: Hatch the FPR region below ``band.vacuous_below``
            where no distribution-free lower bound exists.

    Returns:
        The Axes, for composition.

    Raises:
        RocciError: If matplotlib is not installed.

    Examples:
        >>> import numpy as np
        >>> from rocci import roc_band
        >>> from rocci.plotting import plot_band
        >>> rng = np.random.default_rng(0)
        >>> y = np.r_[np.zeros(60), np.ones(60)]
        >>> s = np.r_[rng.normal(0, 1, 60), rng.normal(1.5, 1, 60)]
        >>> ax = plot_band(roc_band(y, s, random_state=0), show_vacuous=True)
        >>> ax.get_ylabel()
        'True positive rate'
    """
    plt = _require_pyplot()
    if ax is None:
        _, ax = plt.subplots(figsize=_FIGSIZE, layout="constrained")

    method_note = "rocci envelope" if band.method == "envelope" else "Working-Hotelling"
    band_label = (
        label
        if label is not None
        else f"{band.confidence:.0%} simultaneous band ({method_note})"
    )
    ax.fill_between(
        band.fpr,
        band.lower,
        band.upper,
        step="post",
        color=color,
        alpha=band_alpha,
        linewidth=0,
        label=band_label,
    )
    ax.plot(
        band.fpr,
        band.tpr,
        drawstyle="steps-post",
        color=color,
        linewidth=1.5,
        label=f"empirical ROC (AUC = {band.auc:.3f})",
    )
    ax.plot([0, 1], [0, 1], linestyle=":", color=_GREY, linewidth=1.0, label="chance")
    if show_vacuous and band.vacuous_below is not None and band.vacuous_below > 0.0:
        ax.axvspan(
            0.0,
            band.vacuous_below,
            facecolor="none",
            edgecolor=_GREY,
            hatch="///",
            linewidth=0,
            label=f"no certifiable lower bound (FPR < {band.vacuous_below:.3g})",
        )
    ax.set_xlim(0.0, 1.0)
    ax.set_ylim(0.0, 1.0)
    ax.set_xlabel("False positive rate")
    ax.set_ylabel("True positive rate")
    ax.legend(loc="lower right", fontsize="small")
    return ax

plot_diagnostics

plot_diagnostics(band: RocBand, fig: Figure | None = None) -> Figure

Render the two-panel "why did my band do that here" figure.

Envelope path: panel 1 is the band with the lower arm color-coded by floor attribution (yellow Beta floor, green Wilson rectangle floor) and jurisdiction boundaries marked; panel 2 shows the variance channels that drive the floor gate (raw bootstrap variance vs the Wilson floor, log scale) with the active-floor regions shaded.

Working-Hotelling path: the band plus the normality evidence — per-class normal QQ plots and the probit-probit ROC linearity fit with its R².

Parameters:

Name Type Description Default
band RocBand

A band produced by :func:rocci.roc_band.

required
fig Figure | None

Figure to draw into; None creates one.

None

Returns:

Type Description
Figure

The Figure.

Raises:

Type Description
RocciError

If matplotlib is not installed, or if band carries no diagnostics payload (i.e. it was not produced by roc_band).

Examples:

>>> import numpy as np
>>> from rocci import roc_band
>>> from rocci.plotting import plot_diagnostics
>>> rng = np.random.default_rng(1)
>>> y = np.r_[np.zeros(60), np.ones(60)]
>>> s = np.r_[rng.normal(0, 1, 60), rng.normal(1.5, 1, 60)]
>>> fig = plot_diagnostics(roc_band(y, s, random_state=0))
>>> len(fig.axes)
2
Source code in python/rocci/plotting.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def plot_diagnostics(band: RocBand, fig: Figure | None = None) -> Figure:
    """Render the two-panel "why did my band do that here" figure.

    Envelope path: panel 1 is the band with the lower arm color-coded by
    floor attribution (yellow Beta floor, green Wilson rectangle floor) and
    jurisdiction boundaries marked; panel 2 shows the variance channels that
    drive the floor gate (raw bootstrap variance vs the Wilson floor, log
    scale) with the active-floor regions shaded.

    Working-Hotelling path: the band plus the normality evidence — per-class
    normal QQ plots and the probit-probit ROC linearity fit with its R².

    Args:
        band: A band produced by :func:`rocci.roc_band`.
        fig: Figure to draw into; ``None`` creates one.

    Returns:
        The Figure.

    Raises:
        RocciError: If matplotlib is not installed, or if ``band`` carries no
            diagnostics payload (i.e. it was not produced by ``roc_band``).

    Examples:
        >>> import numpy as np
        >>> from rocci import roc_band
        >>> from rocci.plotting import plot_diagnostics
        >>> rng = np.random.default_rng(1)
        >>> y = np.r_[np.zeros(60), np.ones(60)]
        >>> s = np.r_[rng.normal(0, 1, 60), rng.normal(1.5, 1, 60)]
        >>> fig = plot_diagnostics(roc_band(y, s, random_state=0))
        >>> len(fig.axes)
        2
    """
    plt = _require_pyplot()
    diag = band._diag
    if isinstance(diag, EnvelopeBand):
        if fig is None:
            fig = plt.figure(
                figsize=(2 * _FIGSIZE[0], _FIGSIZE[1]), layout="constrained"
            )
        ax_band, ax_var = fig.subplots(1, 2)
        _draw_attribution_panel(band, ax_band)
        _draw_variance_panel(band, diag, ax_var)
        return fig
    if isinstance(diag, ScoreDiagnostics):
        if fig is None:
            fig = plt.figure(
                figsize=(2 * _FIGSIZE[0], 2 * _FIGSIZE[1]), layout="constrained"
            )
        axes = fig.subplots(2, 2)
        plot_band(band, ax=axes[0, 0])
        _draw_probit_panel(band, diag, axes[0, 1])
        _draw_qq_panel(diag.neg_sorted, "negative class", axes[1, 0])
        _draw_qq_panel(diag.pos_sorted, "positive class", axes[1, 1])
        return fig
    raise RocciError(
        "this RocBand carries no diagnostics payload; bands built by "
        "rocci.roc_band always do. Rebuild the band with roc_band to plot "
        "diagnostics."
    )

Warnings and errors

All rocci warnings derive from RocciWarning, so one filterwarnings rule silences or escalates the whole family; all input errors are RocciError (a ValueError subclass).

RocciError

Bases: ValueError

Base class for all rocci input and usage errors.

Subclasses ValueError so that generic error handling around array-validation code keeps working, while letting callers catch rocci-specific failures precisely.

Examples:

>>> from rocci._exceptions import RocciError
>>> try:
...     raise RocciError("pass pos_label= to disambiguate")
... except ValueError as err:
...     print(err)
pass pos_label= to disambiguate

RocciWarning

Bases: UserWarning

Base class for all rocci warnings.

Examples:

>>> import warnings
>>> from rocci._warnings import RocciWarning, TiesWarning
>>> with warnings.catch_warnings(record=True) as caught:
...     warnings.simplefilter("always")
...     warnings.warn("heavy ties", TiesWarning, stacklevel=1)
>>> issubclass(caught[0].category, RocciWarning)
True

NormalityWarning

Bases: RocciWarning

Binormality looks doubtful for the Working-Hotelling band.

LowConfidenceWarning

Bases: RocciWarning

Confidence below 0.90: sup-norm bands intentionally over-cover there.

SmallSampleWarning

Bases: RocciWarning

A class has fewer than 20 samples; exact floors dominate the band.

TiesWarning

Bases: RocciWarning

Scores are heavily tied; the band stays valid but conservative.

FallbackBackendWarning

Bases: RocciWarning

The Rust core is missing; the slower NumPy fallback kernel is active.

Environment

show_versions

show_versions() -> None

Print an environment report for bug reports.

Reports the rocci version, active backend, dependency versions, OS, and CPU count. scipy and matplotlib are not rocci dependencies but are relevant to bug reports (test oracle and plotting extra); each is shown as not installed when absent.

Examples:

>>> from rocci import show_versions
>>> show_versions()
rocci...
Source code in python/rocci/_api.py
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
def show_versions() -> None:
    """Print an environment report for bug reports.

    Reports the rocci version, active backend, dependency versions, OS, and
    CPU count. scipy and matplotlib are not rocci dependencies but are
    relevant to bug reports (test oracle and plotting extra); each is shown
    as ``not installed`` when absent.

    Examples:
        >>> from rocci import show_versions
        >>> show_versions()  # doctest: +ELLIPSIS
        rocci...
    """
    from rocci import __version__

    try:
        import scipy

        scipy_version = scipy.__version__
    except ImportError:
        scipy_version = "not installed"

    try:
        import matplotlib

        mpl_version = matplotlib.__version__
    except ImportError:
        mpl_version = "not installed"

    lines = [
        f"rocci: {__version__}",
        f"backend: {_backend.BACKEND}",
        f"numpy: {np.__version__}",
        f"scipy: {scipy_version}",
        f"matplotlib: {mpl_version}",
        f"platform: {platform.platform()}",
        f"cpu_count: {os.cpu_count()}",
    ]
    print("\n".join(lines))