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
43 changes: 43 additions & 0 deletions tests/unit_tests/model_validation/sklearn/test_CalibrationCurve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import unittest

import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression

import validmind as vm
from validmind.errors import SkipTestError
from validmind.tests.model_validation.sklearn.CalibrationCurve import CalibrationCurve


def _dataset_and_model(labels, seed):
n = 200
rng = np.random.RandomState(seed)
y = rng.choice(labels, size=n)
df = pd.DataFrame(
{"f1": np.linspace(0.0, 10.0, n), "f2": rng.randn(n), "target": y}
)
ds = vm.init_dataset(
input_id=f"cc_{seed}", dataset=df, target_column="target", __log=False
)
est = LogisticRegression(max_iter=1000).fit(df[["f1", "f2"]].to_numpy(), y)
model = vm.init_model(input_id=f"cc_{seed}_m", model=est, __log=False)
ds.assign_predictions(model=model)
return ds, model


class TestCalibrationCurve(unittest.TestCase):
def test_multiclass_skips_cleanly(self):
# sklearn's calibration_curve is binary-only and raised a cryptic
# "pos_label is not specified" error on multiclass; it must skip instead.
ds, model = _dataset_and_model([0, 1, 2, 3, 4], seed=1)
with self.assertRaises(SkipTestError):
CalibrationCurve(model=model, dataset=ds)

def test_binary_runs(self):
ds, model = _dataset_and_model([0, 1], seed=2)
result = CalibrationCurve(model=model, dataset=ds)
self.assertIsNotNone(result)


if __name__ == "__main__":
unittest.main()
76 changes: 76 additions & 0 deletions tests/unit_tests/model_validation/sklearn/test_OverfitDiagnosis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import unittest

import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression, LogisticRegression

import validmind as vm
from validmind.tests.model_validation.sklearn.OverfitDiagnosis import OverfitDiagnosis


def _pair(labels, seed=0, n=200, classification=True):
"""Build a train/test VMDataset pair sharing one fitted VMModel."""

def build(input_id, s, model=None):
r = np.random.RandomState(s)
f1 = np.linspace(0.0, 10.0, n)
f2 = r.randn(n)
if classification:
y = r.choice(labels, size=n)
# ensure the lowest feature bin holds only a subset of the classes
y[:20] = r.choice(labels[:2], size=20)
else:
y = 3.0 * f1 + 2.0 * f2 + r.randn(n)
df = pd.DataFrame({"f1": f1, "f2": f2, "target": y})
ds = vm.init_dataset(
input_id=input_id, dataset=df, target_column="target", __log=False
)
if model is None:
est = (
LogisticRegression(max_iter=2000)
if classification
else LinearRegression()
)
est.fit(df[["f1", "f2"]].to_numpy(), y)
model = vm.init_model(input_id=f"{input_id}_m", model=est, __log=False)
ds.assign_predictions(model=model)
return ds, model

train, model = build(f"of_{seed}_train", seed)
test, _ = build(f"of_{seed}_test", seed + 1, model=model)
return train, test, model


class TestOverfitDiagnosis(unittest.TestCase):
def test_multiclass_default_auc_runs(self):
# Regression test (ZD-704 family): default metric is "auc"; multiclass
# previously raised "multi_class must be in ('ovo', 'ovr')".
train, test, model = _pair([0, 1, 2, 3, 4], seed=1)
result = OverfitDiagnosis(model=model, datasets=[train, test])
self.assertIsNotNone(result)

def test_multiclass_f1_runs(self):
# metric="f1" previously raised "Target is multiclass but average='binary'".
train, test, model = _pair([0, 1, 2, 3, 4], seed=2)
result = OverfitDiagnosis(model=model, datasets=[train, test], metric="f1")
self.assertIsNotNone(result)

def test_binary_non_standard_labels_run(self):
# Binary target encoded as {0, 4}: default auc and f1 must both run.
train, test, model = _pair([0, 4], seed=3)
self.assertIsNotNone(OverfitDiagnosis(model=model, datasets=[train, test]))
self.assertIsNotNone(
OverfitDiagnosis(model=model, datasets=[train, test], metric="f1")
)

def test_standard_binary_runs(self):
train, test, model = _pair([0, 1], seed=4)
self.assertIsNotNone(OverfitDiagnosis(model=model, datasets=[train, test]))

def test_regression_runs(self):
train, test, model = _pair(None, seed=5, classification=False)
self.assertIsNotNone(OverfitDiagnosis(model=model, datasets=[train, test]))


if __name__ == "__main__":
unittest.main()
108 changes: 108 additions & 0 deletions tests/unit_tests/model_validation/sklearn/test_RobustnessDiagnosis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import unittest

import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression, LogisticRegression

import validmind as vm
from validmind.tests.model_validation.sklearn.RobustnessDiagnosis import (
RobustnessDiagnosis,
)


def _pair(labels, seed=0, n=200, classification=True):
"""Build a train/test VMDataset pair sharing one fitted VMModel."""

def build(input_id, s, model=None):
r = np.random.RandomState(s)
f1 = np.linspace(0.0, 10.0, n)
f2 = r.randn(n)
if classification:
y = r.choice(labels, size=n)
else:
y = 3.0 * f1 + 2.0 * f2 + r.randn(n)
df = pd.DataFrame({"f1": f1, "f2": f2, "target": y})
ds = vm.init_dataset(
input_id=input_id, dataset=df, target_column="target", __log=False
)
if model is None:
est = (
LogisticRegression(max_iter=2000)
if classification
else LinearRegression()
)
est.fit(df[["f1", "f2"]].to_numpy(), y)
model = vm.init_model(input_id=f"{input_id}_m", model=est, __log=False)
ds.assign_predictions(model=model)
return ds, model

train, model = build(f"rb_{seed}_train", seed)
test, _ = build(f"rb_{seed}_test", seed + 1, model=model)
return train, test, model


class TestRobustnessDiagnosis(unittest.TestCase):
# keep runs cheap: a single small perturbation is enough to exercise the paths
SMALL = [0.1]

def test_multiclass_default_auc_runs(self):
# Regression test (ZD-704 family): default metric is "auc"; multiclass
# previously raised "multi_class must be in ('ovo', 'ovr')".
train, test, model = _pair([0, 1, 2, 3, 4], seed=1)
result = RobustnessDiagnosis(
datasets=[train, test], model=model, scaling_factor_std_dev_list=self.SMALL
)
self.assertIsNotNone(result)

def test_multiclass_f1_runs(self):
# metric="f1" previously raised "Target is multiclass but average='binary'".
train, test, model = _pair([0, 1, 2, 3, 4], seed=2)
result = RobustnessDiagnosis(
datasets=[train, test],
model=model,
metric="f1",
scaling_factor_std_dev_list=self.SMALL,
)
self.assertIsNotNone(result)

def test_binary_non_standard_labels_run(self):
train, test, model = _pair([0, 4], seed=3)
self.assertIsNotNone(
RobustnessDiagnosis(
datasets=[train, test],
model=model,
scaling_factor_std_dev_list=self.SMALL,
)
)
self.assertIsNotNone(
RobustnessDiagnosis(
datasets=[train, test],
model=model,
metric="f1",
scaling_factor_std_dev_list=self.SMALL,
)
)

def test_standard_binary_runs(self):
train, test, model = _pair([0, 1], seed=4)
self.assertIsNotNone(
RobustnessDiagnosis(
datasets=[train, test],
model=model,
scaling_factor_std_dev_list=self.SMALL,
)
)

def test_regression_runs(self):
train, test, model = _pair(None, seed=5, classification=False)
self.assertIsNotNone(
RobustnessDiagnosis(
datasets=[train, test],
model=model,
scaling_factor_std_dev_list=self.SMALL,
)
)


if __name__ == "__main__":
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import unittest

import numpy as np

from validmind.tests.model_validation.sklearn.SHAPGlobalImportance import (
select_shap_values,
)


class TestSelectShapValues(unittest.TestCase):
def test_binary_or_regression_2d_array_passthrough(self):
# A single (samples, features) array (binary or regression) is used as-is.
values = np.arange(6.0).reshape(3, 2)
np.testing.assert_array_equal(select_shap_values(values), values)

def test_list_binary_defaults_to_class_one(self):
class0 = np.zeros((3, 2))
class1 = np.ones((3, 2))
np.testing.assert_array_equal(select_shap_values([class0, class1]), class1)

def test_list_multiclass_selects_requested_class(self):
classes = [np.full((3, 2), i, dtype=float) for i in range(5)]
np.testing.assert_array_equal(
select_shap_values(classes, class_of_interest=3),
np.full((3, 2), 3.0),
)

def test_3d_array_multiclass_selects_trailing_class_axis(self):
# Newer SHAP returns (samples, features, classes); regression test for the
# IndexError raised when this array was passed straight to summary_plot.
samples, features, classes = 4, 2, 5
values = np.arange(samples * features * classes, dtype=float).reshape(
samples, features, classes
)
selected = select_shap_values(values, class_of_interest=2)
self.assertEqual(selected.shape, (samples, features))
np.testing.assert_array_equal(selected, values[:, :, 2])

def test_3d_array_multiclass_without_class_raises_clear_error(self):
values = np.zeros((4, 2, 5))
with self.assertRaises(ValueError) as ctx:
select_shap_values(values)
self.assertIn("class_of_interest", str(ctx.exception))

def test_class_of_interest_out_of_bounds_raises(self):
values = np.zeros((4, 2, 3))
with self.assertRaises(ValueError):
select_shap_values(values, class_of_interest=9)


if __name__ == "__main__":
unittest.main()
Loading
Loading