Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
369 changes: 369 additions & 0 deletions notebooks/code_sharing/multiclass_classification_tests.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,369 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "325f27e9",
"metadata": {},
"source": [
"# Multiclass support for sklearn classification tests\n",
"\n",
"This sample exercises four ValidMind sklearn model-validation tests on both a **binary**\n",
"and a **multiclass** classification model:\n",
"\n",
"- `ROCCurve`\n",
"- `PrecisionRecallCurve`\n",
"- `ConfusionMatrix`\n",
"- `PopulationStabilityIndex`\n",
"\n",
"Each test keeps its original behavior for binary targets and gains explicit multiclass\n",
"handling:\n",
"\n",
"- **ROCCurve / PrecisionRecallCurve** — render **one-vs-rest** curves for multiclass\n",
" targets (one curve per class plus a micro-average), using the model's per-class\n",
" `predict_proba`.\n",
"- **ConfusionMatrix** — renders the full N×N matrix. The `threshold` parameter only\n",
" applies to binary targets; for multiclass the model's argmax class predictions are used.\n",
"- **PopulationStabilityIndex** — computes **one-vs-rest** PSI (one table/plot per class)\n",
" from the model's per-class `predict_proba`.\n",
"\n",
"Where a multiclass model can't produce a full per-class probability matrix (e.g.\n",
"metadata-only / Foundation models, or predictions supplied as a single precomputed\n",
"probability column), the ROC/PR/PSI tests **skip gracefully** rather than crashing.\n",
"\n",
"> Runs fully offline — no ValidMind platform connection required (`generate_description=False`)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "94ce80a2",
"metadata": {},
"outputs": [],
"source": [
"# This notebook exercises UNRELEASED multiclass changes on this branch, so install\n",
"# THIS local checkout (editable) into the kernel — not the PyPI release:\n",
"%pip install -q -e ../..\n",
"\n",
"# To test the published release instead (note: no multiclass ROC/PR/PSI support yet), use:\n",
"# %pip install -q validmind"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "4d3c54c0",
"metadata": {},
"outputs": [],
"source": [
"import numpy as np\n",
"import pandas as pd\n",
"from sklearn.datasets import make_classification\n",
"from sklearn.ensemble import RandomForestClassifier\n",
"from sklearn.model_selection import train_test_split\n",
"\n",
"import validmind as vm\n",
"from validmind.tests import run_test\n",
"\n",
"ROC = \"validmind.model_validation.sklearn.ROCCurve\"\n",
"PR = \"validmind.model_validation.sklearn.PrecisionRecallCurve\"\n",
"CM = \"validmind.model_validation.sklearn.ConfusionMatrix\"\n",
"PSI = \"validmind.model_validation.sklearn.PopulationStabilityIndex\"\n",
"\n",
"\n",
"def build_inputs(n_classes, prefix):\n",
" \"\"\"Fit a RandomForest and return (model, train_dataset, test_dataset).\n",
"\n",
" PopulationStabilityIndex compares two datasets, so we keep the train split\n",
" around too; ROC/PR/ConfusionMatrix only use the test dataset.\n",
" \"\"\"\n",
" X, y = make_classification(\n",
" n_samples=1500,\n",
" n_features=8,\n",
" n_informative=5,\n",
" n_classes=n_classes,\n",
" n_clusters_per_class=1,\n",
" random_state=42,\n",
" )\n",
" cols = [f\"f{i}\" for i in range(X.shape[1])]\n",
" X_train, X_test, y_train, y_test = train_test_split(\n",
" X, y, test_size=0.25, random_state=42\n",
" )\n",
"\n",
" train_df = pd.DataFrame(X_train, columns=cols)\n",
" train_df[\"target\"] = y_train\n",
" test_df = pd.DataFrame(X_test, columns=cols)\n",
" test_df[\"target\"] = y_test\n",
"\n",
" train_ds = vm.init_dataset(\n",
" input_id=f\"{prefix}_train\", dataset=train_df, target_column=\"target\", __log=False\n",
" )\n",
" test_ds = vm.init_dataset(\n",
" input_id=f\"{prefix}_test\", dataset=test_df, target_column=\"target\", __log=False\n",
" )\n",
"\n",
" clf = RandomForestClassifier(n_estimators=100, random_state=42).fit(X_train, y_train)\n",
" model = vm.init_model(input_id=f\"{prefix}_model\", model=clf, __log=False)\n",
" train_ds.assign_predictions(model)\n",
" test_ds.assign_predictions(model)\n",
" return model, train_ds, test_ds"
]
},
{
"cell_type": "markdown",
"id": "fff08866",
"metadata": {},
"source": [
"## 1. ROC & Precision-Recall curves\n",
"\n",
"### 1a. Binary classification\n",
"\n",
"Expect a single ROC curve (with AUC) and a single Precision-Recall curve — the original behavior."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6b0ea48a",
"metadata": {},
"outputs": [],
"source": [
"bin_model, bin_train_ds, bin_ds = build_inputs(n_classes=2, prefix=\"binary\")\n",
"\n",
"run_test(ROC, inputs={\"model\": bin_model, \"dataset\": bin_ds}, generate_description=False)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "57830ec5",
"metadata": {},
"outputs": [],
"source": [
"run_test(PR, inputs={\"model\": bin_model, \"dataset\": bin_ds}, generate_description=False)"
]
},
{
"cell_type": "markdown",
"id": "6a5394b7",
"metadata": {},
"source": [
"### 1b. Multiclass classification (one-vs-rest)\n",
"\n",
"Expect **one curve per class plus a micro-average**. `ROCCurve` also draws the random\n",
"baseline; `PrecisionRecallCurve` reports average precision (AP) per class."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a47072c5",
"metadata": {},
"outputs": [],
"source": [
"mc_model, mc_train_ds, mc_ds = build_inputs(n_classes=4, prefix=\"multiclass\")\n",
"\n",
"run_test(ROC, inputs={\"model\": mc_model, \"dataset\": mc_ds}, generate_description=False)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "898061e9",
"metadata": {},
"outputs": [],
"source": [
"run_test(PR, inputs={\"model\": mc_model, \"dataset\": mc_ds}, generate_description=False)"
]
},
{
"cell_type": "markdown",
"id": "c72442b1",
"metadata": {},
"source": [
"## 2. Confusion matrix\n",
"\n",
"The confusion matrix renders for any number of classes. For **binary** targets the cells\n",
"are labeled TN / FP / FN / TP and the `threshold` parameter splits the positive-class\n",
"probability. For **multiclass** targets it renders the full N×N matrix from the model's\n",
"argmax class predictions (`threshold` is ignored)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7494b029",
"metadata": {},
"outputs": [],
"source": [
"run_test(CM, inputs={\"model\": bin_model, \"dataset\": bin_ds}, generate_description=False)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3a7191b8",
"metadata": {},
"outputs": [],
"source": [
"run_test(CM, inputs={\"model\": mc_model, \"dataset\": mc_ds}, generate_description=False)"
]
},
{
"cell_type": "markdown",
"id": "8df7f354",
"metadata": {},
"source": [
"## 3. Population Stability Index\n",
"\n",
"PSI compares the model's predicted-probability distribution between two datasets (here\n",
"train vs test). For **binary** targets it produces a single PSI table/plot on the\n",
"positive-class probability. For **multiclass** targets it produces **one-vs-rest** PSI —\n",
"one table and one subplot per class — from the model's per-class `predict_proba`."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "fa437289",
"metadata": {},
"outputs": [],
"source": [
"run_test(PSI, inputs={\"model\": bin_model, \"datasets\": [bin_train_ds, bin_ds]}, generate_description=False)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "c3dafafc",
"metadata": {},
"outputs": [],
"source": [
"run_test(PSI, inputs={\"model\": mc_model, \"datasets\": [mc_train_ds, mc_ds]}, generate_description=False)"
]
},
{
"cell_type": "markdown",
"id": "7ceb0272",
"metadata": {},
"source": [
"## 4. Inspect the raw values\n",
"\n",
"For multiclass, `RawData` is keyed per class (by label). ROC/PR also include a `micro`\n",
"entry. This confirms the one-vs-rest breakdown behind the plots."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "96ebd0fa",
"metadata": {},
"outputs": [],
"source": [
"from validmind.tests.model_validation.sklearn.ROCCurve import ROCCurve\n",
"from validmind.tests.model_validation.sklearn.PrecisionRecallCurve import (\n",
" PrecisionRecallCurve,\n",
")\n",
"from validmind.tests.model_validation.sklearn.PopulationStabilityIndex import (\n",
" PopulationStabilityIndex,\n",
")\n",
"\n",
"_, roc_raw = ROCCurve(mc_model, mc_ds)\n",
"print(\"ROC per-class + micro AUC:\")\n",
"print({k: round(float(v), 3) for k, v in roc_raw.auc.items()})\n",
"\n",
"_, pr_raw = PrecisionRecallCurve(mc_model, mc_ds)\n",
"print(\"\\nPR per-class + micro average precision:\")\n",
"print({k: round(float(v), 3) for k, v in pr_raw.average_precision.items()})\n",
"\n",
"_, _, psi_raw = PopulationStabilityIndex([mc_train_ds, mc_ds], mc_model)\n",
"print(\"\\nPSI one-vs-rest total per class:\")\n",
"print({cls: round(rows[-1][\"psi\"], 4) for cls, rows in psi_raw.psi_raw.items()})"
]
},
{
"cell_type": "markdown",
"id": "d1ff9f16",
"metadata": {},
"source": [
"## 5. Graceful skip\n",
"\n",
"When a multiclass model cannot provide a per-class probability matrix, the ROC/PR/PSI\n",
"tests raise `SkipTestError` instead of crashing. Below we simulate that with a model\n",
"whose `predict_proba` returns a single precomputed probability column."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "94e6b3a9",
"metadata": {},
"outputs": [],
"source": [
"from validmind.errors import SkipTestError\n",
"\n",
"\n",
"# A model whose predict_proba can't yield a per-class matrix for these classes.\n",
"class SingleColumnProbaModel:\n",
" def predict(self, X):\n",
" return np.zeros(len(X), dtype=int)\n",
"\n",
" def predict_proba(self, X):\n",
" return np.full(len(X), 0.5) # 1-D: not a per-class matrix\n",
"\n",
"\n",
"skip_model = vm.init_model(\n",
" input_id=\"skip_model\", model=SingleColumnProbaModel(), __log=False\n",
")\n",
"\n",
"# The multiclass paths read the underlying model's predict_proba directly, so no\n",
"# assign_predictions is needed here — the missing per-class matrix triggers the skip.\n",
"try:\n",
" ROCCurve(skip_model, mc_ds)\n",
"except SkipTestError as e:\n",
" print(\"ROCCurve skipped as expected:\", e)\n",
"\n",
"try:\n",
" PopulationStabilityIndex([mc_train_ds, mc_ds], skip_model)\n",
"except SkipTestError as e:\n",
" print(\"PopulationStabilityIndex skipped as expected:\", e)"
]
},
{
"cell_type": "markdown",
"id": "copyright-2ccf7188bf3e4f0c93be0902ec5c20d0",
"metadata": {},
"source": [
"<!-- VALIDMIND COPYRIGHT -->\n",
"\n",
"<small>\n",
"\n",
"***\n",
"\n",
"Copyright © 2023-2026 ValidMind Inc. All rights reserved.<br>\n",
"Refer to [LICENSE](https://github.com/validmind/validmind-library/blob/main/LICENSE) for details.<br>\n",
"SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial</small>"
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.9"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
Loading
Loading