Evaluation¶
Holdout model evaluation — metrics, calibration, and the expected-cost lenses (binary business-value + multiclass K×K). See the Evaluation & trust guide.
In short
compute_model_evaluationscores a live model on held-out splits;evaluate_from_predictionsscores stored predictions.- Each split keeps a raw
(y_true, scores)sample of up to 200,000 rows; curves are re-derived from it on demand. - Headline numbers use the full split. A capped sample can be class-enriched: weight it with
raw_sample_weight. - Multiclass thresholds are tuned on the first split only, so that split's per-class numbers are optimistic.
compute_model_evaluation¶
compute_model_evaluation ¶
compute_model_evaluation(
model: Any,
*,
task_type: TaskType,
adapter: Any,
splits: Mapping[str, SplitSpec],
decision_threshold: (
ResolvedThreshold | float | None
) = None,
positive_class: Any = None,
multiclass_threshold_metric: str = "f1",
n_bootstrap: int = 0,
ci_alpha: float = 0.05
) -> ModelEvaluationResult
Score holdout splits and compute evaluation artifacts.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Any
|
Fitted estimator used for scoring holdout rows. |
required |
task_type
|
TaskType
|
|
required |
adapter
|
Any
|
Model adapter exposing |
required |
splits
|
Mapping[str, SplitSpec]
|
|
required |
decision_threshold
|
ResolvedThreshold | float | None
|
Threshold for classification metrics at the profiler cut-off (see
:func: |
None
|
positive_class
|
Any
|
The binary positive label; the model's |
None
|
multiclass_threshold_metric
|
str
|
F-beta spec for per-class OvR threshold tuning
(multiclass-plan §3.3 leak-prevention plumbing). The first
split in iteration order — typically |
'f1'
|
Split protocol / in-sample optimism
Pass the tuning split ("train" or "valid") first and the
reporting split ("test") after it: per-class operating thresholds
are tuned on the first split and frozen for the rest, so only the
first split's own multiclass metrics are read at an in-sample-tuned
threshold and are correspondingly optimistic. Every subsequent split
is scored at those frozen thresholds and is leak-safe. If you pass a
single split, its thresholds are tuned and reported on the same
rows — treat those metrics as optimistic. The binary path's
optimal_* fields are likewise tuned in-sample on each split (see
:func:parleml_mli.evaluation.metrics.evaluate_classification_split).
evaluate_from_predictions¶
evaluate_from_predictions ¶
evaluate_from_predictions(
*,
task_type: TaskType,
splits: Mapping[str, PredictionSplitSpec],
classes: Sequence[Any] | None = None,
positive_class: Any = None,
decision_threshold: (
ResolvedThreshold | float | None
) = None,
predicted_labels: Mapping[str, ndarray] | None = None,
multiclass_threshold_metric: str = "f1",
multiclass_tuning_split: str | None = None,
n_bootstrap: int = 0,
ci_alpha: float = 0.05
) -> ModelEvaluationResult
Build a :class:ModelEvaluationResult from precomputed scores.
This is the single dispatch over (y_true, scores) tuples shared by the
model-scoring path (:func:compute_model_evaluation, which resolves the
scores from a model first) and the bring-your-own / precomputed-holdout
path (e.g. monitor_predictions's lite pipeline, or a deployment that
persisted the holdout predictions at fit time). No model or feature
matrix is required — only the targets and the model's scores.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
task_type
|
TaskType
|
|
required |
splits
|
Mapping[str, PredictionSplitSpec]
|
|
required |
classes
|
Sequence[Any] | None
|
Class label ordering (e.g. |
None
|
positive_class
|
Any
|
The binary positive label. The binary target is resolved once with
:func: |
None
|
decision_threshold
|
ResolvedThreshold | float | None
|
Classification cut-off for the at-threshold metrics — a
:class: |
None
|
multiclass_threshold_metric
|
str
|
F-beta spec for leak-safe per-class OvR threshold tuning — tuned on
one split and frozen for the rest. See
:func: |
'f1'
|
predicted_labels
|
Mapping[str, ndarray] | None
|
Optional multiclass decisions by split, from the fitted model's decision layer. These drive confusion matrices and label metrics; the original probabilities still drive proper scores and calibration. Omit to use argmax. |
None
|
multiclass_tuning_split
|
str | None
|
The split whose rows tune the per-class thresholds. |
None
|
n_bootstrap
|
int
|
Number of bootstrap resamples for per-metric confidence intervals.
|
0
|
ci_alpha
|
float
|
Two-sided level for the bootstrap CIs (default |
0.05
|
Split protocol / in-sample optimism
Multiclass per-class operating thresholds are tuned on one split —
multiclass_tuning_split, else the first usable split — and frozen
for every other split. That split's own metrics are read at an
in-sample-tuned threshold and are optimistic; the frozen-threshold splits
are leak-safe. With a single split, tuning and reporting share the same
rows — the metrics are optimistic. multiclass_tuning_split on the
result names the tuned split either way.
ModelEvaluationResult¶
ModelEvaluationResult
dataclass
¶
ModelEvaluationResult(
task_type: str,
splits: dict[
str,
"ClassificationSplitEvaluation | RegressionSplitEvaluation | MulticlassEvaluation",
] = dict(),
multiclass_tuning_split: str | None = None,
)
Optional holdout evaluation attached to a profiling result.
The splits dict carries one evaluation per holdout split:
- :class:
ClassificationSplitEvaluation(akaBinaryEvaluation) for binary classifiers. - :class:
RegressionSplitEvaluationfor regression models. - :class:
parleml_mli.evaluation.MulticlassEvaluationfor multiclass classifiers (len(model.classes_) > 2). Multiclass plan §3.3 - Phase 4: dispatched automatically based on the model — the viewer's Performance page renders the K×K matrix, per-class OvR ROC/PR/lift/KS, and per-class reliability diagrams off the active class dropdown.
The string task_type is one of "binary" / "regression"
/ "multiclass" (the last added in Phase 4) so the viewer can
pick the right rendering branch without sniffing the split values.
preferred_split ¶
preferred_split() -> (
"tuple[str, ClassificationSplitEvaluation | RegressionSplitEvaluation | MulticlassEvaluation] | None"
)
(name, split) of the split a headline number should quote.
The first of :data:PREFERRED_SPLIT_ORDER the result carries
(held-out test before validation before a generic holdout before
out-of-fold), else the first split stored; None without splits.
Every reader that quotes one split — the Trust tiles, the model card,
the narrators — picks it here, so they all quote the same one.
Per-split evaluation types¶
The typed payloads stored in ModelEvaluationResult.splits — one per
holdout split, dispatched by task. BinaryEvaluation is an alias of
ClassificationSplitEvaluation.
ClassificationSplitEvaluation
dataclass
¶
ClassificationSplitEvaluation(
split_name: str,
n_samples: int,
positive_rate: float,
decision_threshold: float,
accuracy: float,
precision: float,
recall: float,
f1: float,
f05: float,
f2: float,
roc_auc: float | None,
pr_auc: float | None,
log_loss: float | None,
confusion_matrix: list[list[int]],
optimal_f05: OptimalMetric,
optimal_f1: OptimalMetric,
optimal_f2: OptimalMetric,
optimal_accuracy: OptimalMetric,
ks_statistic: float,
ks_threshold: float,
raw_y_true: ndarray,
raw_scores: ndarray,
brier_score: float | None = None,
expected_calibration_error: float | None = None,
raw_sample_weight: ndarray | None = None,
metric_cis: (
dict[str, tuple[float, float]] | None
) = None,
)
Classification metrics for one data split, backed by the raw sample.
The split stores only (a) the raw (y_true, scores) sample — capped
at :data:RAW_EVAL_SAMPLE_CAP rows — and (b) cheap scalars that are
expensive or random to recompute. Every threshold-dependent array
(threshold sweep, ROC/PR, ranking, calibration curve, score
distribution) is derived on demand from the raw arrays via
:mod:parleml_mli.evaluation.derive.
RegressionSplitEvaluation
dataclass
¶
RegressionSplitEvaluation(
split_name: str,
n_samples: int,
mae: float,
rmse: float,
r2: float,
mape: float | None,
mean_residual: float,
median_abs_error: float,
raw_y_true: ndarray,
raw_y_pred: ndarray,
metric_cis: (
dict[str, tuple[float, float]] | None
) = None,
raw_sample_weight: ndarray | None = None,
)
Regression metrics for one data split, backed by the raw sample.
Stores headline scalars + the raw (y_true, y_pred) sample (capped at
:data:RAW_EVAL_SAMPLE_CAP); actual-vs-predicted and residual plots are
derived from the raw arrays via :mod:parleml_mli.evaluation.derive.
MulticlassEvaluation
dataclass
¶
MulticlassEvaluation(
split_name: str,
class_keys: tuple[str, ...],
n_samples: int,
accuracy: float,
macro_f1: float,
macro_precision: float,
macro_recall: float,
log_loss: float,
brier_score: float,
confusion_matrix: ndarray,
y_true_idx: ndarray = (lambda: empty(0, dtype=int))(),
per_class: dict[str, BinaryEvaluation] = dict(),
macro_expected_calibration_error: float | None = None,
)
Holdout evaluation for a multiclass (K > 2) classifier on one split.
Attributes:
| Name | Type | Description |
|---|---|---|
split_name |
str
|
Split identifier ( |
class_keys |
tuple[str, ...]
|
Canonical class keys (string form of class labels), in
|
n_samples |
int
|
Number of holdout rows scored. |
accuracy, macro_f1, macro_precision, macro_recall |
Macro-averaged metrics computed from the argmax predictions
( |
|
log_loss |
float
|
Multinomial log-loss over every class
(:func: |
brier_score |
float
|
The multiclass Brier score — the squared error between the
one-hot target and the probability row, summed over classes and
averaged over rows, in |
confusion_matrix |
ndarray
|
K × K matrix, rows = true class index, cols = predicted index,
ordered by :attr: |
per_class |
dict[str, BinaryEvaluation]
|
|
macro_expected_calibration_error |
float | None
|
Mean ECE over the per-class slices — the headline for the viewer's macro strip. |
proba
property
¶
Reconstruct the (m, K) probability sample from the OvR slices.
The single copy of the proba data lives in each per-class slice's
raw_scores; stacking them in class_keys order (they share one
subsample) rebuilds the matrix without a duplicate stored field.
confusion_matrix_df ¶
Friendly DataFrame view of the K×K matrix, labelled with
:attr:class_keys for easy display.
OptimalMetric
dataclass
¶
Best value of a scalar metric and the threshold that achieved it.
The persisted raw sample is not always an unbiased draw¶
A split stores only the raw (y_true, scores) arrays plus cheap scalars; every
curve (ROC, PR, reliability, lift/gains, threshold sweep, business value) is
re-derived from them on demand. Two facts matter if you read those arrays
yourself:
- Headline scalars are exact.
roc_auc,brier_score,expected_calibration_error, the at-threshold metrics and theoptimal_*fields are all computed on the full split before any capping. - The arrays may be capped and class-stratified. Above
RAW_EVAL_SAMPLE_CAProws the sample is subsampled, and at rare-event prevalence it is deliberately enriched with minority rows so the re-derived curves are not positive-class noise. That enrichment makes the sample's base rate different from the split's.
raw_sample_weight is how the two are reconciled: None means the sample is an
unbiased view (whole split, or a uniform draw) and every row counts once;
otherwise it is a per-row population weight whose sum is n_samples.
Every prevalence-sensitive statistic must consume it — precision, the observed
positive rate per reliability bin, the gains baseline, and any per-row expected
value all shift with the base rate. parleml_mli.evaluation.derive.* and
parleml_mli.evaluation.plots.* already do; a custom consumer should too:
split = result.model_evaluation_.splits["test"]
precision, recall = derive.pr_curve(
split.raw_y_true, split.raw_scores, sample_weight=split.raw_sample_weight,
)
Rank-only statistics (ROC, KS) are invariant to a per-class-constant weight, but they accept it too, so no caller has to know which kind it is holding.
RawSample ¶
Bases: NamedTuple
Row indices for a persisted raw sample plus their population weights.
Attributes:
| Name | Type | Description |
|---|---|---|
index |
ndarray
|
Sorted row indices into the full split. |
weight |
ndarray | None
|
|
A stratified draw (see |
func:`subsample_index`) is *not* an unbiased view:
|
|
it deliberately over-samples the minority class. Every prevalence-sensitive |
|
|
statistic re-derived from such a sample — the PR curve, the reliability |
|
|
diagram, lift/gains, the score distribution, the business-value sweep — must |
|
|
consume |
attr:`weight` or it reports the model's behaviour at the wrong base
|
|
rate (mli review H1). Rank-only statistics (ROC) are invariant either way but |
|
|
take the weight too, so no caller has to know which is which. |
|
subsample_index ¶
subsample_index(
n: int,
*,
cap: int,
rng: Generator,
sample_index: ndarray | None = None,
stratify_labels: ndarray | None = None
) -> RawSample
Rows (and their population weights) for the persisted raw sample.
Returns sample_index verbatim when the caller supplies one (so
multiclass OvR slices and the parent y_true_idx all share identical
rows); otherwise the full range when within cap, else a sorted
cap-sized draw. Single home for the capping policy so every evaluator
subsamples the same way.
When stratify_labels (a binary 0/1 vector aligned to n) is given and
a draw is needed, the sample is class-stratified: every minority row is
kept when the minority class comfortably fits, else the classes are sampled
in proportion. An unstratified draw at a rare-event prevalence would keep
only a prevalence-thinned handful of positives, so the re-derived
ROC/PR/reliability/gains curves would carry positive-class noise the headline
scalars (computed pre-cap) don't share (LOC-N8).
Stratifying trades that noise for bias: the sample's base rate is not the
split's. The returned :attr:RawSample.weight is the inverse inclusion
probability per class (n_class_total / n_class_kept) that undoes it, and
is None for the draws that need no correction (mli review H1).
weighted_class_totals ¶
weighted_class_totals(
y_bin: ndarray, sample_weight: ndarray | None = None
) -> tuple[float, float]
(n_pos, n_neg) in population units, for the gains/lift baseline.
The ranking table's baseline is the split's base rate, so its class totals must be weighted whenever the raw sample is a stratified draw.
Multiclass operating points are tuned on one split¶
Per-class OvR thresholds are tuned on the first split in
ModelEvaluationResult.splits and frozen for the rest —
multiclass_tuning_split records which one that was. That split's own
per_class metrics are read at an in-sample-tuned threshold and are therefore
optimistic; every later split is leak-safe. fit_profiler always puts train
first.
Calibration¶
compute_calibration ¶
compute_calibration(
y_true: ndarray,
y_score: ndarray,
*,
n_bins: int = 10,
strategy: CalibrationStrategy = "quantile",
min_samples: int = DEFAULT_MIN_SAMPLES_FOR_CURVE,
sample_weight: ndarray | None = None
) -> tuple[float, CalibrationCurve | None, float]
Convenience wrapper returning (brier, curve, ece) in one call.
Used by :func:parleml_mli.evaluation.metrics.evaluate_classification_split
and by per-class OvR slices inside MulticlassEvaluation.
y_true is the {0, 1} positive-class indicator and y_score the
positive-class probability; the Brier score is
:func:parleml_ml4.delivery.scoring.brier_score over the [1 - p, p] frame —
the positive-class Brier, brier_score_loss(y_true, y_score).
sample_weight is forwarded to both the Brier score and the curve, and
the ECE then inherits the weighted bin counts. Labels outside {0, 1}
and scores outside [0, 1] raise ValueError — ml4's Brier score
would otherwise clip them into range and report a plausible number.
Business value¶
evaluate_business_value ¶
evaluate_business_value(
split: ClassificationSplitEvaluation,
*,
cost_fp: float = 0.0,
cost_fn: float = 0.0,
gain_tp: float = 0.0,
gain_tn: float = 0.0,
fbeta: float | None = None,
capacity_top_k: int | None = None,
capacity_fraction: float | None = None,
objective: Objective = "expected_value"
) -> BusinessValueCurve
Cost-matrix / capacity / Fβ lens over an evaluated classification split.
Cheap (one threshold sweep over the raw sample, no model / dataset
access) so the Diagnose page can call this on every slider drag.
Derives the per-threshold confusion counts + capacity via
:func:parleml_mli.evaluation.derive.threshold_sweep for the expected-value
and capacity arithmetic, and reuses split.optimal_f{05,1,2} (or a
small custom-β recompute) for the Fβ recommendation.
With a capacity (capacity_top_k / capacity_fraction) every recommendation respects
it: the expected-value and Fβ optima are taken over the thresholds that flag at most that
fraction, the free optima land in unconstrained (so the cost of the cap is visible), and
at_capacity states precision / recall / lift / counts at the selected sampled threshold,
which can underfill the budget. Ties stay together; this is not an exact top-k quota.
If no sampled threshold respects an explicit cap, raise rather than exceed it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
split
|
ClassificationSplitEvaluation
|
The output of
:func: |
required |
cost_fp
|
float
|
Cost-matrix entries in per-row utility units (typically
dollars). Default zeros render the EV curve uninformative; pass
the actual costs for the |
0.0
|
cost_fn
|
float
|
Cost-matrix entries in per-row utility units (typically
dollars). Default zeros render the EV curve uninformative; pass
the actual costs for the |
0.0
|
gain_tp
|
float
|
Cost-matrix entries in per-row utility units (typically
dollars). Default zeros render the EV curve uninformative; pass
the actual costs for the |
0.0
|
gain_tn
|
float
|
Cost-matrix entries in per-row utility units (typically
dollars). Default zeros render the EV curve uninformative; pass
the actual costs for the |
0.0
|
fbeta
|
float | None
|
Which β to use for the |
None
|
capacity_top_k
|
int | None
|
Mutually exclusive. |
None
|
capacity_fraction
|
int | None
|
Mutually exclusive. |
None
|
objective
|
Objective
|
Which τ from |
'expected_value'
|
Returns:
| Type | Description |
|---|---|
BusinessValueCurve
|
Dataclass carrying the full per-threshold curve plus the
recommended τ for each objective. Backend projects scalar
headlines into the FastAPI |
BudgetPoint
dataclass
¶
BudgetPoint(
threshold: float,
flagged_fraction: float,
flagged: int,
tp: int,
fp: int,
fn: int,
tn: int,
precision: float,
recall: float,
lift: float,
)
What the selected capacity threshold yields (scores >= threshold, ties together).
The sampled threshold may underfill the budget. Counts are in split units (the raw sample's weights
rescale a capped sample); lift is precision over the split's positive rate.
Multiclass cost lens¶
The N×N analogue of the binary business-value lens: a full K×K cost
matrix and the Bayes-optimal minimum-expected-cost decision rule
(argmin_j Σ_i P(i|x)·C[i, j]) — no threshold to sweep.
evaluate_multiclass_cost ¶
evaluate_multiclass_cost(
evaluation: MulticlassEvaluation,
*,
cost_matrix: ndarray
) -> MulticlassCostResult
Apply the minimum-expected-cost decision rule to a multiclass split.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
evaluation
|
MulticlassEvaluation
|
A published :class: |
required |
cost_matrix
|
ndarray
|
|
required |
Returns:
| Type | Description |
|---|---|
MulticlassCostResult
|
|
MulticlassCostResult
dataclass
¶
MulticlassCostResult(
class_keys: tuple[str, ...],
cost_matrix: ndarray,
confusion_baseline: ndarray,
confusion_cost_optimal: ndarray,
total_cost_baseline: float,
total_cost_optimal: float,
per_row_cost_baseline: float,
per_row_cost_optimal: float,
n_evaluated: int,
n_samples: int,
is_sampled: bool,
)
Cost-optimal vs argmax decision over a multiclass split's raw sample.
All counts/totals are on the n_evaluated sampled rows; per_row_cost
fields let callers scale to n_samples (the full split).
Label-noise scan¶
Confident-learning review queue of likely-mislabelled rows, with the estimated share of wrong labels (classification only). See the Label-noise scan guide section.
compute_label_noise ¶
compute_label_noise(
y_true: Sequence[Any] | ndarray | Series,
oof_probabilities: ndarray,
*,
classes: Sequence[Any] | None = None,
row_index: Sequence[int] | ndarray | None = None,
method: Method = "auto",
max_suspects: int = _DEFAULT_MAX_SUSPECTS,
oof_source: str = "provided",
threshold_rows: ndarray | None = None
) -> LabelNoiseResult
Confident-learning label-noise scan over out-of-fold probabilities.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
y_true
|
Sequence[Any] | ndarray | Series
|
Given labels, one per row. When a :class: |
required |
oof_probabilities
|
ndarray
|
|
required |
classes
|
Sequence[Any] | None
|
Class label per probability column. Defaults to |
None
|
method
|
Method
|
|
'auto'
|
max_suspects
|
int
|
Cap on the surfaced |
_DEFAULT_MAX_SUSPECTS
|
threshold_rows
|
ndarray | None
|
Boolean mask of the rows whose probabilities set each class's
|
None
|
Notes
estimated_noise_rate is not n_flagged / n_total. A row the model is
confident about no class for (confident_joint only) is evidence neither
way, so it is left out of its class's share rather than counted as a clean
row: per given class, the rate is the flagged share of its confidently
predicted rows, and the overall rate weights those shares by how common each
class is. That is the calibrated confident joint's off-diagonal mass, and it
is at least the flagged share. Under self_confidence every row counts,
so the two coincide.
build_label_noise_result ¶
build_label_noise_result(
y_true: Sequence[Any] | ndarray | Series,
*,
oof_probabilities: ndarray | None = None,
estimator: "ClassifierMixin | None" = None,
x: DataFrame | ndarray | None = None,
classes: Sequence[Any] | None = None,
row_index: Sequence[int] | ndarray | None = None,
method: Method = "auto",
max_suspects: int = _DEFAULT_MAX_SUSPECTS,
cv_folds: int = 5,
random_state: int = 0
) -> LabelNoiseResult
Build a :class:LabelNoiseResult, resolving out-of-fold probabilities.
Prefers caller-supplied oof_probabilities (e.g. persisted OOF from the engine).
Falls back to sklearn.model_selection.cross_val_predict(estimator, x,
y_true, method="predict_proba") for a bring-your-own sklearn estimator
with its input frame x. Raises when neither is available.
LabelNoiseResult
dataclass
¶
LabelNoiseResult(
suspects: DataFrame,
estimated_noise_rate: float,
per_class_noise: dict[Any, float],
n_flagged: int,
n_total: int,
method: str,
oof_source: str,
classes: list[Any] = list(),
)
Likely-mislabelled training rows and the estimated noise rate.
A standalone evaluation artifact (like
:class:~parleml_mli.evaluation.business_value.BusinessValueCurve): returned by
:func:compute_label_noise / :func:build_label_noise_result and rendered
by :func:parleml_mli.evaluation.plots.plot_label_noise — not a profiler stage.