From d540f8d7ed9c46be2acdfdaebc68adef327e9ead Mon Sep 17 00:00:00 2001 From: Juan Date: Tue, 1 Sep 2026 22:53:24 +0200 Subject: [PATCH 1/3] Replace Plotly Figure Factory calls removed in Plotly 7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plotly 7.0.0 removed create_annotated_heatmap and create_distplot, so five tests raised AttributeError on any environment that resolved Plotly 7. Port the three heatmaps to go.Heatmap with native text/texttemplate, which renders the same array in the same orientation, and compute the two KDE curves with scipy.stats.gaussian_kde directly — the same 500-point estimate create_distplot made internally, so the curves are unchanged to floating point. TargetPredictionDistributionPlot was building two whole distplot figures only to read their x/y off them, so it loses more code than it gains. RegressionResidualsPlot gains the guarded scipy import the other statistical tests already use; it depended on scipy through create_distplot but let a bare ImportError escape. Keep the dependency at plotly (>=6.0.0) with no upper bound. The ported code runs on both majors, and texttemplate has been available on go.Heatmap since Plotly 5.5, so a cap would constrain users without protecting them. Add unit tests for ConfusionMatrix, RegressionResidualsPlot and TargetPredictionDistributionPlot, none of which had any — the reason only one of the five broken tests was identified from the report. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/bulk_ai_test_updates.py | 4 +- .../sklearn/test_ConfusionMatrix.py | 92 +++++++++++++++++++ .../test_RegressionResidualsPlot.py | 68 ++++++++++++++ .../test_TargetPredictionDistributionPlot.py | 78 ++++++++++++++++ .../LaggedCorrelationHeatmap.py | 20 ++-- .../TimeSeriesMissingValues.py | 22 +++-- .../RegressionResidualsPlot.py | 49 ++++++++-- .../sklearn/ConfusionMatrix.py | 42 +++++---- .../TargetPredictionDistributionPlot.py | 37 ++++---- 9 files changed, 352 insertions(+), 60 deletions(-) create mode 100644 tests/unit_tests/model_validation/sklearn/test_ConfusionMatrix.py create mode 100644 tests/unit_tests/model_validation/test_RegressionResidualsPlot.py create mode 100644 tests/unit_tests/ongoing_monitoring/test_TargetPredictionDistributionPlot.py diff --git a/scripts/bulk_ai_test_updates.py b/scripts/bulk_ai_test_updates.py index 3f33dbb69..971df549c 100644 --- a/scripts/bulk_ai_test_updates.py +++ b/scripts/bulk_ai_test_updates.py @@ -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 @@ -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) diff --git a/tests/unit_tests/model_validation/sklearn/test_ConfusionMatrix.py b/tests/unit_tests/model_validation/sklearn/test_ConfusionMatrix.py new file mode 100644 index 000000000..37ba968bf --- /dev/null +++ b/tests/unit_tests/model_validation/sklearn/test_ConfusionMatrix.py @@ -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() diff --git a/tests/unit_tests/model_validation/test_RegressionResidualsPlot.py b/tests/unit_tests/model_validation/test_RegressionResidualsPlot.py new file mode 100644 index 000000000..b8cb7647e --- /dev/null +++ b/tests/unit_tests/model_validation/test_RegressionResidualsPlot.py @@ -0,0 +1,68 @@ +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. + self.assertAlmostEqual(np.trapz(curve.y, curve.x), 1.0, places=1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/ongoing_monitoring/test_TargetPredictionDistributionPlot.py b/tests/unit_tests/ongoing_monitoring/test_TargetPredictionDistributionPlot.py new file mode 100644 index 000000000..3256d4a6d --- /dev/null +++ b/tests/unit_tests/ongoing_monitoring/test_TargetPredictionDistributionPlot.py @@ -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() diff --git a/validmind/tests/data_validation/LaggedCorrelationHeatmap.py b/validmind/tests/data_validation/LaggedCorrelationHeatmap.py index 073ccc19f..a2224d67d 100644 --- a/validmind/tests/data_validation/LaggedCorrelationHeatmap.py +++ b/validmind/tests/data_validation/LaggedCorrelationHeatmap.py @@ -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 @@ -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={ @@ -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) diff --git a/validmind/tests/data_validation/TimeSeriesMissingValues.py b/validmind/tests/data_validation/TimeSeriesMissingValues.py index c5c27e7c8..14e3ed645 100644 --- a/validmind/tests/data_validation/TimeSeriesMissingValues.py +++ b/validmind/tests/data_validation/TimeSeriesMissingValues.py @@ -6,7 +6,6 @@ import pandas as pd import plotly.express as px -import plotly.figure_factory as ff import plotly.graph_objects as go from validmind import RawData, tags, tasks @@ -94,16 +93,23 @@ def TimeSeriesMissingValues( z = missing_mask.T.astype(int).values x = missing_mask.index.tolist() y = missing_mask.columns.tolist() - heatmap = ff.create_annotated_heatmap( - z=z, - x=x, - y=y, - colorscale="Reds", - showscale=False, + heatmap = go.Figure( + go.Heatmap( + z=z, + x=x, + y=y, + colorscale="Reds", + showscale=False, + texttemplate="%{z}", + ) ) # Update the layout after creation - heatmap.update_layout(title="Missing Values Heatmap") + heatmap.update_layout( + title="Missing Values Heatmap", + xaxis=dict(side="top", dtick=1), + yaxis=dict(dtick=1), + ) return ( [ diff --git a/validmind/tests/model_validation/RegressionResidualsPlot.py b/validmind/tests/model_validation/RegressionResidualsPlot.py index 2e6db697a..356d43a3c 100644 --- a/validmind/tests/model_validation/RegressionResidualsPlot.py +++ b/validmind/tests/model_validation/RegressionResidualsPlot.py @@ -5,12 +5,29 @@ from typing import Tuple import numpy as np -import plotly.figure_factory as ff import plotly.graph_objects as go from validmind import RawData, tags, tasks +from validmind.errors import MissingDependencyError from validmind.vm_models import VMDataset, VMModel +# The first colour in Plotly's default distplot palette, kept so the ported figure +# matches what `ff.create_distplot` produced. +KDE_COLOR = "rgb(31, 119, 180)" + +try: + from scipy.stats import gaussian_kde +except ImportError as e: + if "scipy" in str(e): + raise MissingDependencyError( + "Missing required package `scipy` for RegressionResidualsPlot. " + "Please run `pip install validmind[stats]` to use statistical tests", + required_dependencies=["scipy"], + extra="stats", + ) from e + + raise e + @tags("model_performance", "visualization") @tasks("regression") @@ -65,12 +82,30 @@ def RegressionResidualsPlot( # Residuals plot residuals = y_true.flatten() - y_pred.flatten() - fig = ff.create_distplot( - hist_data=[residuals], - group_labels=["Residuals"], - bin_size=[bin_size], - show_hist=True, - show_rug=False, + # A 500-point KDE across the data range, which is what `ff.create_distplot` + # computed internally with scipy before Plotly 7 removed it. + curve_x = np.linspace(residuals.min(), residuals.max(), 500, endpoint=False) + curve_y = gaussian_kde(residuals)(curve_x) + fig = go.Figure( + data=[ + go.Histogram( + x=residuals, + histnorm="probability density", + autobinx=False, + xbins=dict(start=residuals.min(), end=residuals.max(), size=bin_size), + name="Residuals", + marker=dict(color=KDE_COLOR), + opacity=0.7, + ), + go.Scatter( + x=curve_x, + y=curve_y, + mode="lines", + name="Residuals", + showlegend=False, + marker=dict(color=KDE_COLOR), + ), + ] ) fig.update_layout( title="Distribution of Residuals", diff --git a/validmind/tests/model_validation/sklearn/ConfusionMatrix.py b/validmind/tests/model_validation/sklearn/ConfusionMatrix.py index 70d6a569a..e9ced28af 100644 --- a/validmind/tests/model_validation/sklearn/ConfusionMatrix.py +++ b/validmind/tests/model_validation/sklearn/ConfusionMatrix.py @@ -6,7 +6,6 @@ from typing import Tuple import numpy as np -import plotly.figure_factory as ff import plotly.graph_objects as go from sklearn.metrics import confusion_matrix @@ -42,9 +41,9 @@ def ConfusionMatrix( The mechanism used involves taking the predicted results (`y_test_predict`) from the classification model and comparing them against the actual values (`y_test_true`). A confusion matrix is built using the unique labels extracted from `y_test_true`, employing scikit-learn's metrics. The matrix is then visually rendered with the help - of Plotly's `create_annotated_heatmap` function. A heatmap is created which provides a two-dimensional graphical - representation of the model's performance, showcasing distributions of True Positives (TP), True Negatives (TN), - False Positives (FP), and False Negatives (FN). + of a Plotly heatmap. A heatmap is created which provides a two-dimensional graphical representation of the + model's performance, showcasing distributions of True Positives (TP), True Negatives (TN), False Positives (FP), + and False Negatives (FN). ### Signs of High Risk @@ -110,21 +109,32 @@ def ConfusionMatrix( ], ] - fig = ff.create_annotated_heatmap( - z=cm, - colorscale="Blues", - x=labels, - y=labels, - annotation_text=text, + if text is None: + text = cm.astype(str) + + fig = go.Figure( + go.Heatmap( + z=cm, + x=labels, + y=labels, + colorscale="Blues", + showscale=False, + text=text, + texttemplate="%{text}", + # Heatmap text auto-shrinks to fit the cell; pin it to the layout font + # size so the cells read the same as the old annotation-based figure. + textfont=dict(size=12), + hovertemplate=( + "True Label:%{y}
Predicted Label:%{x}
Count:%{z}" + ), + ) ) - fig["data"][0][ - "hovertemplate" - ] = "True Label:%{y}
Predicted Label:%{x}
Count:%{z}" - fig.update_layout( - xaxis=dict(title="Predicted label"), - yaxis=dict(title="True label"), + # `side="top"` and `dtick=1` keep the axis placement and per-class ticks that + # `ff.create_annotated_heatmap` used to apply for us. + xaxis=dict(title="Predicted label", side="top", dtick=1), + yaxis=dict(title="True label", dtick=1), autosize=False, width=600, height=600, diff --git a/validmind/tests/ongoing_monitoring/TargetPredictionDistributionPlot.py b/validmind/tests/ongoing_monitoring/TargetPredictionDistributionPlot.py index 048604cb9..e74486e89 100644 --- a/validmind/tests/ongoing_monitoring/TargetPredictionDistributionPlot.py +++ b/validmind/tests/ongoing_monitoring/TargetPredictionDistributionPlot.py @@ -4,8 +4,8 @@ from typing import Dict, List, Tuple +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 @@ -13,7 +13,7 @@ from validmind.vm_models import VMDataset, VMModel try: - from scipy.stats import kurtosis, skew + from scipy.stats import gaussian_kde, kurtosis, skew except ImportError as e: if "scipy" in str(e): raise MissingDependencyError( @@ -26,6 +26,17 @@ raise e +def _kde_curve(values): + """A 500-point KDE across the data range. + + This is what `ff.create_distplot(..., show_hist=False)` computed internally + before Plotly 7 removed the Figure Factory functions. + """ + x = np.linspace(values.min(), values.max(), 500, endpoint=False) + + return x, gaussian_kde(values)(x) + + @tags("visualization") @tasks("monitoring") def TargetPredictionDistributionPlot( @@ -110,18 +121,8 @@ def TargetPredictionDistributionPlot( moments = moments.set_index("Statistic", drop=False) # Create KDE for both distributions - ref_kde = ff.create_distplot( - [pred_ref["Reference Prediction"].values], - ["Reference"], - show_hist=False, - show_rug=False, - ) - monitor_kde = ff.create_distplot( - [pred_monitor["Monitoring Prediction"].values], - ["Monitoring"], - show_hist=False, - show_rug=False, - ) + ref_x, ref_y = _kde_curve(pred_ref["Reference Prediction"].values) + monitor_x, monitor_y = _kde_curve(pred_monitor["Monitoring Prediction"].values) # Create new figure fig = go.Figure() @@ -129,8 +130,8 @@ def TargetPredictionDistributionPlot( # Add reference distribution fig.add_trace( go.Scatter( - x=ref_kde.data[0].x, - y=ref_kde.data[0].y, + x=ref_x, + y=ref_y, fill="tozeroy", name="Reference Prediction", line=dict(color="blue", width=2), @@ -141,8 +142,8 @@ def TargetPredictionDistributionPlot( # Add monitoring distribution fig.add_trace( go.Scatter( - x=monitor_kde.data[0].x, - y=monitor_kde.data[0].y, + x=monitor_x, + y=monitor_y, fill="tozeroy", name="Monitor Prediction", line=dict(color="red", width=2), From bb96f974cdcc04d703193e7768f550df4478b80a Mon Sep 17 00:00:00 2001 From: Juan Date: Tue, 1 Sep 2026 23:11:42 +0200 Subject: [PATCH 2/3] Support NumPy 2, which removed np.trapz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python 3.14 dependency leg resolves numpy>=2.3, where np.trapz no longer exists — it was renamed to np.trapezoid. Resolve the name once through getattr, which short-circuits on NumPy 2 before touching the missing attribute and still finds trapz on NumPy 1. GiniCoefficient is library code and had no test at all, so nothing caught that it raises AttributeError for anyone on Python 3.14. Add one, along with the same fallback in the residuals test added earlier on this branch. Co-Authored-By: Claude Opus 5 (1M context) --- .../test_RegressionResidualsPlot.py | 6 +- .../unit_metrics/test_GiniCoefficient.py | 62 +++++++++++++++++++ .../regression/GiniCoefficient.py | 6 +- 3 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 tests/unit_tests/unit_metrics/test_GiniCoefficient.py diff --git a/tests/unit_tests/model_validation/test_RegressionResidualsPlot.py b/tests/unit_tests/model_validation/test_RegressionResidualsPlot.py index b8cb7647e..800c10313 100644 --- a/tests/unit_tests/model_validation/test_RegressionResidualsPlot.py +++ b/tests/unit_tests/model_validation/test_RegressionResidualsPlot.py @@ -60,8 +60,10 @@ def test_residuals_figure_has_histogram_and_kde(self): 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. - self.assertAlmostEqual(np.trapz(curve.y, curve.x), 1.0, places=1) + # 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__": diff --git a/tests/unit_tests/unit_metrics/test_GiniCoefficient.py b/tests/unit_tests/unit_metrics/test_GiniCoefficient.py new file mode 100644 index 000000000..fe5e554e9 --- /dev/null +++ b/tests/unit_tests/unit_metrics/test_GiniCoefficient.py @@ -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() diff --git a/validmind/unit_metrics/regression/GiniCoefficient.py b/validmind/unit_metrics/regression/GiniCoefficient.py index 9e2ec3147..7b4d4517e 100644 --- a/validmind/unit_metrics/regression/GiniCoefficient.py +++ b/validmind/unit_metrics/regression/GiniCoefficient.py @@ -7,6 +7,10 @@ from validmind import tags, tasks from validmind.vm_models import VMDataset, VMModel +# NumPy 2 removed `np.trapz` in favour of `np.trapezoid`; the library supports both +# (numpy<2 below Python 3.14, numpy>=2.3 from 3.14 on). +_trapezoid = getattr(np, "trapezoid", None) or np.trapz + @tags("regression") @tasks("regression") @@ -29,7 +33,7 @@ def GiniCoefficient(dataset: VMDataset, model: VMModel) -> float: cumsum_pred_norm = cumsum_pred / np.max(cumsum_pred) # Compute area under the Lorenz curve - area_lorenz = np.trapz(cumsum_pred_norm, x=cumsum_true_norm) + area_lorenz = _trapezoid(cumsum_pred_norm, x=cumsum_true_norm) # Compute Gini coefficient return 1 - 2 * area_lorenz From 4fc39bde3b2fe6f06e2288c7c7c5a974be06e116 Mon Sep 17 00:00:00 2001 From: Juan Date: Tue, 1 Sep 2026 23:39:39 +0200 Subject: [PATCH 3/3] 2.13.12 --- pyproject.toml | 2 +- r/validmind/DESCRIPTION | 2 +- uv.lock | 2 +- validmind/__version__.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 37b9d360c..1ec0fa068 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/r/validmind/DESCRIPTION b/r/validmind/DESCRIPTION index e31bb985d..ecfae362e 100644 --- a/r/validmind/DESCRIPTION +++ b/r/validmind/DESCRIPTION @@ -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 diff --git a/uv.lock b/uv.lock index 882938164..24c4e25c3 100644 --- a/uv.lock +++ b/uv.lock @@ -11268,7 +11268,7 @@ wheels = [ [[package]] name = "validmind" -version = "2.13.11" +version = "2.13.12" source = { editable = "." } dependencies = [ { name = "aiohttp", extra = ["speedups"] }, diff --git a/validmind/__version__.py b/validmind/__version__.py index 3fa3779e6..896ad7fd7 100644 --- a/validmind/__version__.py +++ b/validmind/__version__.py @@ -1 +1 @@ -__version__ = "2.13.11" +__version__ = "2.13.12"