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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "validmind"
version = "2.13.11"
version = "2.13.12"
description = "ValidMind Library"
readme = "README.pypi.md"
requires-python = ">=3.9,<3.15"
Expand Down
2 changes: 1 addition & 1 deletion r/validmind/DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Package: validmind
Type: Package
Title: Interface to the 'ValidMind' Platform
Version: 2.13.11
Version: 2.13.12
Authors@R: c(person("Andres", "Rodriguez", role = c("aut", "cre","cph"),
email = "andres@validmind.ai"))
Maintainer: Andres Rodriguez <andres@validmind.ai>
Expand Down
4 changes: 2 additions & 2 deletions scripts/bulk_ai_test_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ def ExampleConfusionMatrix(model: VMModel, dataset: VMDataset):

cm = confusion_matrix(y_true, y_pred, labels=labels)

fig = ff.create_annotated_heatmap()
fig = go.Heatmap()
..

return fig
Expand All @@ -230,7 +230,7 @@ def ExampleConfusionMatrix(model: VMModel, dataset: VMDataset):

cm = confusion_matrix(y_true, y_pred, labels=labels)

fig = ff.create_annotated_heatmap()
fig = go.Heatmap()
..

return fig, RawData(confusion_matrix=cm, model=model.input_id, dataset=dataset.input_id)
Expand Down
92 changes: 92 additions & 0 deletions tests/unit_tests/model_validation/sklearn/test_ConfusionMatrix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import unittest

import numpy as np
import pandas as pd
import plotly.graph_objects as go
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix

import validmind as vm
from validmind import RawData
from validmind.tests.model_validation.sklearn.ConfusionMatrix import ConfusionMatrix


def _build(n_classes=2):
"""Fit a logistic regression on ``n_classes`` and return (dataset, model)."""
X, y = make_classification(
n_samples=400,
n_features=5,
n_informative=4,
n_redundant=0,
n_classes=n_classes,
n_clusters_per_class=1,
random_state=7,
)
model = LogisticRegression(max_iter=1000).fit(X, y)

df = pd.DataFrame(X, columns=[f"f{i}" for i in range(5)])
df["target"] = y

vm_model = vm.init_model(input_id="cm_model", model=model, __log=False)
vm_dataset = vm.init_dataset(
input_id="cm_dataset", dataset=df, target_column="target", __log=False
)
vm_dataset.assign_predictions(vm_model)

return vm_dataset, vm_model


class TestConfusionMatrix(unittest.TestCase):
def test_returns_heatmap_and_raw_data(self):
dataset, model = _build()
fig, raw_data = ConfusionMatrix(dataset, model)

self.assertIsInstance(fig, go.Figure)
self.assertIsInstance(raw_data, RawData)
self.assertEqual(fig.data[0].type, "heatmap")

def test_z_matches_sklearn_confusion_matrix(self):
dataset, model = _build()
fig, _ = ConfusionMatrix(dataset, model)

y_prob = dataset.y_prob(model)
if y_prob.ndim == 2:
y_prob = y_prob[:, 1]
y_pred = (y_prob > 0.5).astype(int)
y_true = dataset.y.astype(y_pred.dtype)
labels = sorted(np.unique(y_true).tolist())

expected = confusion_matrix(y_true, y_pred, labels=labels)
np.testing.assert_array_equal(np.asarray(fig.data[0].z), expected)

def test_binary_labels_are_not_transposed(self):
"""TN must sit top-left and TP bottom-right.

The cell text is the only thing naming which quadrant is which, so a
row/column flip in the heatmap would be silent without this assertion.
"""
dataset, model = _build()
fig, _ = ConfusionMatrix(dataset, model)

text = fig.data[0].text
self.assertIn("True Negatives", text[0][0])
self.assertIn("False Positives", text[0][1])
self.assertIn("False Negatives", text[1][0])
self.assertIn("True Positives", text[1][1])
self.assertEqual(fig.data[0].texttemplate, "%{text}")

def test_multiclass_annotates_raw_counts(self):
dataset, model = _build(n_classes=3)
fig, _ = ConfusionMatrix(dataset, model)

z = np.asarray(fig.data[0].z)
self.assertEqual(z.shape, (3, 3))

# Without the binary TN/FP/FN/TP labels, every cell shows its own count.
text = np.asarray(fig.data[0].text)
np.testing.assert_array_equal(text, z.astype(str))


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

import numpy as np
import pandas as pd
import plotly.graph_objects as go
from sklearn.linear_model import LinearRegression

import validmind as vm
from validmind import RawData
from validmind.tests.model_validation.RegressionResidualsPlot import (
RegressionResidualsPlot,
)


class TestRegressionResidualsPlot(unittest.TestCase):
def setUp(self):
np.random.seed(42)
X = np.arange(200).reshape(-1, 1)
y = 2 * X.ravel() + np.random.normal(0, 5, 200)

df = pd.DataFrame({"feature": X.ravel(), "target": y})
model = LinearRegression().fit(X, y)

self.vm_model = vm.init_model(input_id="rrp_model", model=model, __log=False)
self.vm_dataset = vm.init_dataset(
input_id="rrp_dataset", dataset=df, target_column="target", __log=False
)
self.vm_dataset.assign_predictions(self.vm_model)

def test_return_structure(self):
result = RegressionResidualsPlot(self.vm_model, self.vm_dataset, bin_size=1.0)

self.assertEqual(len(result), 3)
self.assertIsInstance(result[0], go.Figure)
self.assertIsInstance(result[1], go.Figure)
self.assertIsInstance(result[2], RawData)

def test_residuals_figure_has_histogram_and_kde(self):
"""The residuals figure is a density histogram overlaid with a KDE curve.

Both must use the same normalisation, or the curve sits orders of
magnitude away from the bars and the plot is silently useless.
"""
fig, _, _ = RegressionResidualsPlot(
self.vm_model, self.vm_dataset, bin_size=1.0
)

hist = next(t for t in fig.data if t.type == "histogram")
curve = next(t for t in fig.data if t.type == "scatter")

self.assertEqual(hist.histnorm, "probability density")
self.assertEqual(hist.xbins.size, 1.0)

residuals = (
self.vm_dataset.y.flatten()
- self.vm_dataset.y_pred(self.vm_model).flatten()
)
self.assertAlmostEqual(curve.x[0], residuals.min())
self.assertLessEqual(curve.x[-1], residuals.max())
self.assertEqual(len(curve.x), 500)
self.assertTrue(np.all(np.asarray(curve.y) > 0))

# A density curve integrates to ~1 over the data range. NumPy 2 renamed
# trapz to trapezoid, and the Python 3.14 leg resolves numpy>=2.3.
trapezoid = getattr(np, "trapezoid", None) or np.trapz
self.assertAlmostEqual(trapezoid(curve.y, curve.x), 1.0, places=1)


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

import numpy as np
import pandas as pd
import plotly.graph_objects as go
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression

import validmind as vm
from validmind import RawData
from validmind.tests.ongoing_monitoring.TargetPredictionDistributionPlot import (
TargetPredictionDistributionPlot,
)


class TestTargetPredictionDistributionPlot(unittest.TestCase):
def setUp(self):
X, y = make_classification(
n_samples=600,
n_features=5,
n_informative=4,
n_redundant=0,
n_classes=2,
random_state=7,
)
model = LogisticRegression(max_iter=1000).fit(X, y)
self.vm_model = vm.init_model(input_id="tpd_model", model=model, __log=False)

def _dataset(input_id, rows):
df = pd.DataFrame(X[rows], columns=[f"f{i}" for i in range(5)])
df["target"] = y[rows]
ds = vm.init_dataset(
input_id=input_id, dataset=df, target_column="target", __log=False
)
ds.assign_predictions(self.vm_model)

return ds

self.datasets = [
_dataset("reference", slice(0, 300)),
_dataset("monitoring", slice(300, 600)),
]

def test_return_structure(self):
tables, fig, passed, raw_data = TargetPredictionDistributionPlot(
self.datasets, self.vm_model
)

self.assertIn("Distribution Moments", tables)
moments = tables["Distribution Moments"]
self.assertIn("Drift (%)", moments.columns)
self.assertIn("Pass/Fail", moments.columns)
self.assertIsInstance(fig, go.Figure)
self.assertIsInstance(passed, (bool, np.bool_))
self.assertIsInstance(raw_data, RawData)

def test_two_kde_curves(self):
"""One filled KDE curve per dataset, each spanning its own value range."""
_, fig, _, _ = TargetPredictionDistributionPlot(self.datasets, self.vm_model)

self.assertEqual(len(fig.data), 2)
self.assertEqual(
[t.name for t in fig.data],
["Reference Prediction", "Monitor Prediction"],
)

for trace, dataset in zip(fig.data, self.datasets):
self.assertEqual(trace.type, "scatter")
self.assertEqual(len(trace.x), 500)

probabilities = dataset.y_prob_df(self.vm_model).iloc[:, 0].values
self.assertAlmostEqual(trace.x[0], probabilities.min())
self.assertLessEqual(trace.x[-1], probabilities.max())
self.assertTrue(np.all(np.asarray(trace.y) > 0))


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

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

import validmind as vm
from validmind.unit_metrics.regression.GiniCoefficient import GiniCoefficient


def _build(noise):
"""Fit a linear model on data whose residual scale is set by ``noise``."""
rng = np.random.RandomState(42)
X = np.arange(200).reshape(-1, 1)
y = 2 * X.ravel() + rng.normal(0, noise, 200)

df = pd.DataFrame({"feature": X.ravel(), "target": y})
model = LinearRegression().fit(X, y)

vm_model = vm.init_model(input_id=f"gini_model_{noise}", model=model, __log=False)
vm_dataset = vm.init_dataset(
input_id=f"gini_dataset_{noise}",
dataset=df,
target_column="target",
__log=False,
)
vm_dataset.assign_predictions(vm_model)

return vm_dataset, vm_model


class TestGiniCoefficient(unittest.TestCase):
def test_returns_finite_float(self):
"""Covers the trapezoid call, which had no test at all.

`np.trapz` was removed in NumPy 2, so this raised AttributeError on any
Python 3.14 environment (numpy>=2.3) before the fallback was added.
"""
dataset, model = _build(noise=5)
gini = GiniCoefficient(dataset, model)

self.assertIsInstance(gini, float)
self.assertTrue(np.isfinite(gini))

def test_tighter_fit_scores_nearer_zero(self):
"""Predictions tracking the target put the Lorenz curve on the diagonal.

A near-perfect fit gives an area of ~0.5 and so a coefficient of ~0; the
noisier the residuals, the further the coefficient moves away from it.
"""
tight_dataset, tight_model = _build(noise=1)
loose_dataset, loose_model = _build(noise=400)

tight = GiniCoefficient(tight_dataset, tight_model)
loose = GiniCoefficient(loose_dataset, loose_model)

self.assertAlmostEqual(tight, 0.0, places=3)
self.assertGreater(abs(loose), abs(tight))


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion validmind/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2.13.11"
__version__ = "2.13.12"
20 changes: 11 additions & 9 deletions validmind/tests/data_validation/LaggedCorrelationHeatmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import numpy as np
import pandas as pd
import plotly.figure_factory as ff
import plotly.graph_objects as go

from validmind import RawData, tags, tasks
Expand Down Expand Up @@ -86,13 +85,15 @@ def LaggedCorrelationHeatmap(
index=dataset.feature_columns,
)

fig = ff.create_annotated_heatmap(
z=correlation_df.values,
x=list(correlation_df.columns),
y=list(correlation_df.index),
colorscale=COOLWARM,
annotation_text=correlation_df.round(2).values,
showscale=True,
fig = go.Figure(
go.Heatmap(
z=correlation_df.values,
x=list(correlation_df.columns),
y=list(correlation_df.index),
colorscale=COOLWARM,
showscale=True,
texttemplate="%{z:.2f}",
)
)
fig.update_layout(
title={
Expand All @@ -103,7 +104,8 @@ def LaggedCorrelationHeatmap(
"yanchor": "top",
},
font=dict(size=14),
xaxis_title="Lags",
xaxis=dict(title="Lags", side="top", dtick=1),
yaxis=dict(dtick=1),
)

return fig, RawData(correlation_matrix=correlation_df, dataset=dataset.input_id)
Loading
Loading