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/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..800c10313 --- /dev/null +++ b/tests/unit_tests/model_validation/test_RegressionResidualsPlot.py @@ -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() 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/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/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" 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), 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