-
Notifications
You must be signed in to change notification settings - Fork 4
[SC-17187] ZD-717: GINITable fails on multiclass models (follow-up to PR #535) #539
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| __version__ = "2.13.6" | ||
| __version__ = "2.13.7" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,8 +7,10 @@ | |
| import numpy as np | ||
| import pandas as pd | ||
| from sklearn.metrics import roc_auc_score, roc_curve | ||
| from sklearn.preprocessing import label_binarize | ||
|
|
||
| from validmind import RawData, tags, tasks | ||
| from validmind.errors import SkipTestError | ||
| from validmind.vm_models import VMDataset, VMModel | ||
|
|
||
|
|
||
|
|
@@ -58,12 +60,19 @@ def GINITable(dataset: VMDataset, model: VMModel) -> Tuple[pd.DataFrame, RawData | |
|
|
||
| - The GINI coefficient and KS statistic are both dependent on the AUC value. Therefore, any errors in the | ||
| calculation of the latter will adversely impact the former metrics too. | ||
| - Mainly suited for binary classification models and may require modifications for effective application in | ||
| multi-class scenarios. | ||
| - For multiclass models the metrics are computed one-vs-rest (one row per class plus a micro-average row), which | ||
| requires per-class probabilities from the model's `predict_proba`. Models that cannot produce a full per-class | ||
| probability matrix (e.g. metadata-only models, or predictions supplied as a single precomputed probability column) | ||
| are skipped for the multiclass case rather than crashed. | ||
| - The metrics used are threshold-dependent and may exhibit high variability based on the chosen cut-off points. | ||
| - The test does not incorporate a method to efficiently handle missing or inefficiently processed data, which could | ||
| lead to inaccuracies in the metrics if the data is not appropriately preprocessed. | ||
| """ | ||
| classes = np.unique(dataset.y) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking / follow-up: |
||
|
|
||
| if len(classes) > 2: | ||
| return _multiclass_gini_table(model, dataset, classes) | ||
|
|
||
| y_true = np.ravel(dataset.y) # Flatten y_true to make it one-dimensional | ||
| y_prob = dataset.y_prob(model) | ||
| y_true = np.array(y_true, dtype=float) | ||
|
|
@@ -87,3 +96,81 @@ def GINITable(dataset: VMDataset, model: VMModel) -> Tuple[pd.DataFrame, RawData | |
| model=model.input_id, | ||
| dataset=dataset.input_id, | ||
| ) | ||
|
|
||
|
|
||
| def _multiclass_gini_table( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking / follow-up: this preamble (raw-estimator access → |
||
| model: VMModel, dataset: VMDataset, classes: np.ndarray | ||
| ) -> Tuple[pd.DataFrame, RawData]: | ||
| """One-vs-rest AUC/GINI/KS for a multiclass model. | ||
|
|
||
| Needs the full per-class probability matrix, which the stored single | ||
| probability column cannot provide, so we ask the underlying estimator for | ||
| it directly. Models without a usable ``predict_proba`` (metadata-only, | ||
| precomputed single-column probabilities) are skipped rather than crashed. | ||
| """ | ||
| # The VMModel wrapper's y_prob is binary-only (positive-class column only), | ||
| # so reach the underlying estimator for the full per-class probability matrix. | ||
| raw_model = getattr(model, "model", None) | ||
| proba_fn = getattr(raw_model, "predict_proba", None) | ||
| if not callable(proba_fn): | ||
| raise SkipTestError( | ||
| "Multiclass GINITable requires per-class probabilities from the " | ||
| "underlying model's predict_proba, which is not available for this " | ||
| "model (e.g. metadata-only / precomputed predictions). Skipping." | ||
| ) | ||
| try: | ||
| y_prob = np.asarray(proba_fn(dataset.x_df())) | ||
| except Exception as e: | ||
| raise SkipTestError( | ||
| "Multiclass GINITable could not compute per-class probabilities " | ||
| f"({type(e).__name__}). Skipping." | ||
| ) from e | ||
|
|
||
| n_classes = len(classes) | ||
| if y_prob.ndim != 2 or y_prob.shape[1] != n_classes: | ||
| raise SkipTestError( | ||
| "Multiclass GINITable requires a per-class probability matrix with " | ||
| f"one column per class (got shape {getattr(y_prob, 'shape', None)} " | ||
| f"for {n_classes} classes). Skipping." | ||
| ) | ||
|
|
||
| # One-hot the true labels in the same class order predict_proba columns use | ||
| # (sklearn orders predict_proba columns by sorted class label == np.unique). | ||
| y_true = dataset.y.flatten() | ||
| y_bin = label_binarize(y_true, classes=classes) | ||
|
|
||
| rows = [] | ||
| raw_fpr = {} | ||
| raw_tpr = {} | ||
| for i, cls in enumerate(classes): | ||
| fpr, tpr, _ = roc_curve(y_bin[:, i], y_prob[:, i]) | ||
| auc = roc_auc_score(y_bin[:, i], y_prob[:, i]) | ||
| key = str(cls) | ||
| raw_fpr[key] = fpr | ||
| raw_tpr[key] = tpr | ||
| rows.append( | ||
| {"Class": key, "AUC": auc, "GINI": 2 * auc - 1, "KS": max(tpr - fpr)} | ||
| ) | ||
|
|
||
| # Micro-average across all one-vs-rest decisions. | ||
| micro_fpr, micro_tpr, _ = roc_curve(y_bin.ravel(), y_prob.ravel()) | ||
| micro_auc = roc_auc_score(y_bin, y_prob, average="micro", multi_class="ovr") | ||
| raw_fpr["micro"] = micro_fpr | ||
| raw_tpr["micro"] = micro_tpr | ||
| rows.append( | ||
| { | ||
| "Class": "micro", | ||
| "AUC": micro_auc, | ||
| "GINI": 2 * micro_auc - 1, | ||
| "KS": max(micro_tpr - micro_fpr), | ||
| } | ||
| ) | ||
|
|
||
| return pd.DataFrame(rows), RawData( | ||
| fpr=raw_fpr, | ||
| tpr=raw_tpr, | ||
| y_true=y_true, | ||
| y_prob=y_prob, | ||
| model=model.input_id, | ||
| dataset=dataset.input_id, | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non-blocking: there's coverage for
predict_probabeing unavailable, but not for the other skip branch — a probability matrix whose column count doesn't match the dataset's classes (e.g. model trained on 4 classes, eval dataset containing only 3). That's the branch multiclass users are most likely to hit in practice via train/testinput_grid, and it's cheap to add: fit on 4 classes, build the VM dataset from a slice withy != 3, assertSkipTestError.