"""Classification diagnostics calculation engine for MachineLens."""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
import numpy as np
import pandas as pd
from scipy import stats
from sklearn.calibration import calibration_curve
from sklearn.metrics import (
accuracy_score,
auc,
average_precision_score,
brier_score_loss,
log_loss,
matthews_corrcoef,
precision_recall_curve,
precision_recall_fscore_support,
roc_auc_score,
roc_curve,
)
from sklearn.metrics import confusion_matrix as sk_confusion_matrix
from sklearn.preprocessing import label_binarize
from machinelens.analyzer.shap_calculator import compute_shap_values
from machinelens.core import ModelInterface
from machinelens.core.data_classes import (
CalibrationCurveData,
ClassificationMetrics,
ClassificationSubsetData,
DiagnosticResults,
MisclassificationResult,
PrCurveData,
RocCurveData,
ThresholdAnalysisData,
)
from machinelens.utils import _to_dataframe
logger = logging.getLogger(__name__)
[docs]
class ClassificationAnalyzer:
"""Specialized engine for classification diagnostics.
Parameters
----------
interface : ModelInterface
A validated interface wrapping the fitted model and data splits.
"""
def __init__(self, interface: ModelInterface) -> None:
"""Initialize the ClassificationAnalyzer.
Parameters
----------
interface : ModelInterface
A validated interface wrapping the fitted model and data splits.
"""
self._iface = interface
self._model = interface.model
[docs]
def analyze(self, dr: DiagnosticResults) -> None:
"""Populate *dr* with all classification diagnostics.
Parameters
----------
dr : DiagnosticResults
The result container to populate.
"""
for subset in ("train", "test"):
try:
sd = self._prepare_classification_subset(subset)
if sd is not None:
setattr(dr, f"{subset}_data", sd)
except Exception as exc:
logger.warning("Classification subset '%s' failed: %s", subset, exc)
for subset in ("train", "test"):
sd = getattr(dr, f"{subset}_data", None)
if sd is None or not isinstance(sd, ClassificationSubsetData):
continue
# Metrics
try:
setattr(
dr,
f"{subset}_clf_metrics",
self._classification_metrics(sd.y_true, sd.y_pred, sd.y_prob),
)
except Exception as exc:
logger.warning("Clf metrics (%s) failed: %s", subset, exc)
# Confusion matrix
try:
cm = sk_confusion_matrix(sd.y_true, sd.y_pred)
labels = np.unique(np.concatenate((sd.y_true, sd.y_pred)))
setattr(
dr,
f"{subset}_confusion_matrix",
pd.DataFrame(cm, index=labels, columns=labels),
)
except Exception as exc:
logger.warning("Confusion matrix (%s) failed: %s", subset, exc)
# ROC / PR (need probabilities)
if sd.y_prob is not None:
try:
setattr(
dr,
f"{subset}_roc_curves",
self._compute_roc(sd.y_true, sd.y_prob),
)
except Exception as exc:
logger.warning("ROC (%s) failed: %s", subset, exc)
try:
setattr(
dr,
f"{subset}_pr_curves",
self._compute_pr(sd.y_true, sd.y_prob),
)
except Exception as exc:
logger.warning("PR (%s) failed: %s", subset, exc)
try:
setattr(
dr,
f"{subset}_calibration_curves",
self._compute_calibration(sd.y_true, sd.y_prob),
)
except Exception as exc:
logger.warning("Calibration (%s) failed: %s", subset, exc)
try:
setattr(
dr,
f"{subset}_threshold_analysis",
self._compute_threshold_metrics(sd.y_true, sd.y_prob),
)
except Exception as exc:
logger.warning("Threshold analysis (%s) failed: %s", subset, exc)
# Misclassification analysis
try:
mc = self._misclassification_analysis(sd.X_data, sd.y_true, sd.y_pred)
if mc is not None:
setattr(dr, f"{subset}_misclassification", mc)
except Exception as exc:
logger.warning("Misclassification (%s) failed: %s", subset, exc)
# -- SHAP --
train_sd = dr.train_data
test_sd = dr.test_data
if train_sd is not None:
try:
dr.train_shap = compute_shap_values(
self._model,
train_sd.X_data,
train_sd.X_data,
is_classification=True,
)
if test_sd is not None:
dr.test_shap = compute_shap_values(
self._model,
train_sd.X_data,
test_sd.X_data,
is_classification=True,
)
except Exception as exc:
logger.warning("SHAP calculation failed: %s", exc)
# -- Classification helpers --
def _prepare_classification_subset(
self, subset: str
) -> Optional[ClassificationSubsetData]:
"""Prepare classification features, targets, and predictions for a subset.
Parameters
----------
subset : str
The data subset to prepare (``"train"`` or ``"test"``).
Returns
-------
ClassificationSubsetData or None
The prepared classification subset data container, or None if features
or targets are unavailable.
"""
iface = self._iface
X_raw = getattr(iface, f"X_{subset}", None)
y_raw = getattr(iface, f"y_{subset}", None)
X = _to_dataframe(X_raw)
if X is None or y_raw is None:
return None
y_true = np.asarray(y_raw).ravel()
if subset == "test" and iface.y_pred is not None:
y_pred = np.asarray(iface.y_pred).ravel()
elif hasattr(self._model, "predict"):
y_pred = np.asarray(self._model.predict(X_raw)).ravel()
else:
return None
# Probabilities
y_prob = None
if hasattr(self._model, "predict_proba") and callable(
self._model.predict_proba
):
try:
y_prob = np.asarray(self._model.predict_proba(X_raw))
except Exception as exc:
logger.warning("predict_proba failed: %s", exc)
n = min(len(X), len(y_true), len(y_pred))
if y_prob is not None:
n = min(n, len(y_prob))
y_prob = y_prob[:n]
return ClassificationSubsetData(
X_data=X.iloc[:n].copy(),
y_true=y_true[:n],
y_pred=y_pred[:n],
y_prob=y_prob,
)
@staticmethod
def _classification_metrics(
y_true: np.ndarray, y_pred: np.ndarray, y_prob: Optional[np.ndarray] = None
) -> ClassificationMetrics:
"""Calculate scalar classification evaluation metrics.
Parameters
----------
y_true : np.ndarray
Ground truth target values.
y_pred : np.ndarray
Predicted target values.
y_prob : np.ndarray, optional
Predicted class probabilities.
Returns
-------
ClassificationMetrics
Calculated evaluation metrics containing accuracy, precision, recall,
F1, MCC, and probabilistic metrics if y_prob is available.
"""
unique = np.unique(y_true)
avg = "binary" if len(unique) <= 2 else "macro"
p, r, f, _ = precision_recall_fscore_support(
y_true, y_pred, average=avg, zero_division=0
)
mcc = float(matthews_corrcoef(y_true, y_pred))
roc_auc = None
pr_auc = None
brier = None
lloss = None
if y_prob is not None:
try:
if len(unique) <= 2:
y_prob_pos = y_prob[:, 1] if y_prob.ndim > 1 else y_prob
roc_auc = float(roc_auc_score(y_true, y_prob_pos))
pr_auc = float(average_precision_score(y_true, y_prob_pos))
# Brier score is inherently binary or multi-class but scikit-learn's
# brier_score_loss is binary only natively unless using BrierScoreLoss
# which is different. We'll support binary brier score.
brier = float(brier_score_loss(y_true, y_prob_pos))
else:
# Multi-class
roc_auc = float(
roc_auc_score(
y_true, y_prob, multi_class="ovr", average="macro"
)
)
# PR AUC macro for multiclass
y_true_bin = label_binarize(y_true, classes=unique)
pr_auc = float(
average_precision_score(y_true_bin, y_prob, average="macro")
)
except Exception as e:
logger.warning(f"Probabilistic metric (AUC/PR) failed: {e}")
try:
lloss = float(log_loss(y_true, y_prob))
except Exception as e:
logger.warning(f"Log loss calculation failed: {e}")
return ClassificationMetrics(
accuracy=float(accuracy_score(y_true, y_pred)),
precision=float(p),
recall=float(r),
f1_score=float(f),
mcc=mcc,
roc_auc=roc_auc,
pr_auc=pr_auc,
brier_score=brier,
log_loss=lloss,
)
@staticmethod
def _compute_roc(y_true: np.ndarray, y_prob: np.ndarray) -> List[RocCurveData]:
"""Compute ROC curves and AUC scores.
Parameters
----------
y_true : np.ndarray
Ground truth target values.
y_prob : np.ndarray
Predicted class probabilities.
Returns
-------
list of RocCurveData
List of calculated ROC curve data objects for each class or the
binary case.
"""
classes = np.unique(y_true)
result: List[RocCurveData] = []
if len(classes) == 2:
pp = y_prob[:, 1] if y_prob.shape[1] > 1 else y_prob.ravel()
fpr, tpr, _ = roc_curve(y_true, pp, pos_label=classes[1])
result.append(
RocCurveData(
fpr=fpr, tpr=tpr, auc_score=float(auc(fpr, tpr)), label="binary"
)
)
elif len(classes) > 2:
y_bin = label_binarize(y_true, classes=classes)
for i, cls in enumerate(classes):
fpr, tpr, _ = roc_curve(y_bin[:, i], y_prob[:, i])
result.append(
RocCurveData(
fpr=fpr,
tpr=tpr,
auc_score=float(auc(fpr, tpr)),
label=f"class_{cls}",
)
)
return result
@staticmethod
def _compute_pr(y_true: np.ndarray, y_prob: np.ndarray) -> List[PrCurveData]:
"""Compute Precision-Recall curves and average precision scores.
Parameters
----------
y_true : np.ndarray
Ground truth target values.
y_prob : np.ndarray
Predicted class probabilities.
Returns
-------
list of PrCurveData
List of calculated PR curve data objects for each class or the
binary case.
"""
classes = np.unique(y_true)
result: List[PrCurveData] = []
if len(classes) == 2:
pp = y_prob[:, 1] if y_prob.shape[1] > 1 else y_prob.ravel()
prec, rec, _ = precision_recall_curve(y_true, pp, pos_label=classes[1])
ap = average_precision_score(y_true, pp, pos_label=classes[1])
result.append(
PrCurveData(
precision_arr=prec,
recall_arr=rec,
average_precision=float(ap),
baseline=float(np.mean(y_true == classes[1])),
label="binary",
)
)
elif len(classes) > 2:
y_bin = label_binarize(y_true, classes=classes)
for i, cls in enumerate(classes):
prec, rec, _ = precision_recall_curve(y_bin[:, i], y_prob[:, i])
ap = average_precision_score(y_bin[:, i], y_prob[:, i])
result.append(
PrCurveData(
precision_arr=prec,
recall_arr=rec,
average_precision=float(ap),
baseline=float(np.mean(y_bin[:, i])),
label=f"class_{cls}",
)
)
return result
@staticmethod
def _misclassification_analysis(
X: pd.DataFrame,
y_true: np.ndarray,
y_pred: np.ndarray,
alpha: float = 0.05,
min_group: int = 5,
) -> Optional[MisclassificationResult]:
"""Perform feature-level statistical analysis on misclassified samples.
Parameters
----------
X : pd.DataFrame
The input feature matrix.
y_true : np.ndarray
Ground truth target values.
y_pred : np.ndarray
Predicted target values.
alpha : float, default=0.05
Significance level for False Discovery Rate correction.
min_group : int, default=5
Minimum number of samples required in both correct and incorrect
groups to perform the statistical test.
Returns
-------
MisclassificationResult or None
Statistical test results for each feature, or None if no valid
tests were performed.
"""
mask_wrong = y_true != y_pred
Xr = X.reset_index(drop=True)
X_inc, X_cor = Xr.loc[mask_wrong], Xr.loc[~mask_wrong]
num_cols = X.select_dtypes(include=[np.number]).columns
rows: List[Dict[str, Any]] = []
for col in num_cols:
iv = X_inc[col].dropna().values
cv = X_cor[col].dropna().values
if len(iv) < min_group or len(cv) < min_group:
continue
try:
stat, pval = stats.mannwhitneyu(iv, cv, alternative="two-sided")
eff = float((2.0 * stat) / (len(iv) * len(cv)) - 1.0)
rows.append(
{
"feature": col,
"stat": float(stat),
"p_value": float(pval),
"effect_size": eff,
}
)
except Exception as exc:
logger.warning("Mann-Whitney U failed on feature %s: %s", col, exc)
if not rows:
return None
from statsmodels.stats.multitest import multipletests
pvals = [r["p_value"] for r in rows]
reject, adj, _, _ = multipletests(pvals, alpha=alpha, method="fdr_bh")
for r, a, rej in zip(rows, adj, reject, strict=False):
r["adj_p_value"] = float(a)
r["significant"] = bool(rej)
df = pd.DataFrame(rows).sort_values("adj_p_value").reset_index(drop=True)
return MisclassificationResult(results_df=df)
@staticmethod
def _compute_calibration(
y_true: np.ndarray, y_prob: np.ndarray, n_bins: int = 10
) -> List[CalibrationCurveData]:
"""Compute calibration curve data for reliability diagrams.
Parameters
----------
y_true : np.ndarray
Ground truth target values.
y_prob : np.ndarray
Predicted class probabilities.
n_bins : int, default=10
Number of bins to compute the calibration curve.
Returns
-------
list of CalibrationCurveData
List of calculated calibration curve data objects.
"""
classes = np.unique(y_true)
result: List[CalibrationCurveData] = []
if len(classes) == 2:
pp = y_prob[:, 1] if y_prob.shape[1] > 1 else y_prob.ravel()
prob_true, prob_pred = calibration_curve(
y_true, pp, n_bins=n_bins, pos_label=classes[1]
)
result.append(
CalibrationCurveData(
prob_true=prob_true, prob_pred=prob_pred, label="binary"
)
)
elif len(classes) > 2:
y_bin = label_binarize(y_true, classes=classes)
for i, cls in enumerate(classes):
prob_true, prob_pred = calibration_curve(
y_bin[:, i], y_prob[:, i], n_bins=n_bins
)
result.append(
CalibrationCurveData(
prob_true=prob_true, prob_pred=prob_pred, label=f"class_{cls}"
)
)
return result
@staticmethod
def _compute_threshold_metrics(
y_true: np.ndarray, y_prob: np.ndarray
) -> List[ThresholdAnalysisData]:
"""Compute threshold decision analysis metrics.
Parameters
----------
y_true : np.ndarray
Ground truth target values.
y_prob : np.ndarray
Predicted class probabilities.
Returns
-------
list of ThresholdAnalysisData
List of calculated threshold analysis data objects.
"""
classes = np.unique(y_true)
result: List[ThresholdAnalysisData] = []
if len(classes) == 2:
pp = y_prob[:, 1] if y_prob.shape[1] > 1 else y_prob.ravel()
prec, rec, thresh = precision_recall_curve(y_true, pp, pos_label=classes[1])
f1 = 2 * (prec[:-1] * rec[:-1]) / (prec[:-1] + rec[:-1] + 1e-9)
result.append(
ThresholdAnalysisData(
thresholds=thresh,
precision=prec[:-1],
recall=rec[:-1],
f1_score=f1,
label="binary",
)
)
elif len(classes) > 2:
y_bin = label_binarize(y_true, classes=classes)
for i, cls in enumerate(classes):
prec, rec, thresh = precision_recall_curve(y_bin[:, i], y_prob[:, i])
f1 = 2 * (prec[:-1] * rec[:-1]) / (prec[:-1] + rec[:-1] + 1e-9)
result.append(
ThresholdAnalysisData(
thresholds=thresh,
precision=prec[:-1],
recall=rec[:-1],
f1_score=f1,
label=f"class_{cls}",
)
)
return result