From 90e8cdaef6c9bda42b4bb999df8606db442c1bb9 Mon Sep 17 00:00:00 2001 From: AryanRG Date: Fri, 23 Oct 2026 01:41:59 +0530 Subject: [PATCH] Preserve string and integer ClassHistogram labels Signed-off-by: AryanRG --- .../interpret/data/_response.py | 20 ++- .../tests/data/test_class_histogram.py | 164 ++++++++++++++++++ 2 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 python/interpret-core/tests/data/test_class_histogram.py diff --git a/python/interpret-core/interpret/data/_response.py b/python/interpret-core/interpret/data/_response.py index cc2c83a3a..a6978cac8 100644 --- a/python/interpret-core/interpret/data/_response.py +++ b/python/interpret-core/interpret/data/_response.py @@ -338,10 +338,24 @@ def explain_data(self, X, y, name=None): msg = f"y must be 1 dimensional, but got {y.ndim} dimensions with shape {y.shape}" raise ValueError(msg) - try: - y = y.astype(np.float64, copy=False) - except Exception: # object can throw anything in their __float__ function + if issubclass(y.dtype.type, np.integer) or ( + y.dtype.type is np.object_ + and all(isinstance(value, (int, np.integer)) for value in y) + ): + # Preserve integer precision and unsigned range, including list/object inputs. + y = np.asarray(y) + elif issubclass(y.dtype.type, (str, bytes)) or ( + y.dtype.type is np.object_ + and any(isinstance(value, (str, bytes)) for value in y) + ): + # Numeric-looking strings are class names, not numeric values. y = typify_classification(y) + else: + # Retain numeric float equality (including signed zero) and ordering. + try: + y = y.astype(np.float64, copy=False) + except Exception: # object can throw anything in their __float__ function + y = typify_classification(y) X, n_samples = preclean_X(X, self.feature_names, self.feature_types, len(y)) diff --git a/python/interpret-core/tests/data/test_class_histogram.py b/python/interpret-core/tests/data/test_class_histogram.py new file mode 100644 index 000000000..c4af55173 --- /dev/null +++ b/python/interpret-core/tests/data/test_class_histogram.py @@ -0,0 +1,164 @@ +# Copyright (c) 2026 The InterpretML Contributors +# Distributed under the MIT software license + +import numpy as np +import pandas as pd +import pytest +from interpret.data import ClassHistogram + + +@pytest.mark.parametrize( + "labels", + [ + pytest.param(["01", "1", "01", "1"], id="leading-zero-strings"), + pytest.param(["1", "1.0", "1", "1.0"], id="decimal-strings"), + pytest.param([2**53, 2**53 + 1, 2**53, 2**53 + 1], id="large-integers"), + pytest.param(["cat", "dog", "cat", "dog"], id="text-control"), + pytest.param([0, 1, 0, 1], id="integer-control"), + pytest.param([False, True, False, True], id="boolean-control"), + ], +) +@pytest.mark.parametrize("container", [np.array, list, pd.Series]) +def test_class_histogram_preserves_labels(labels, container): + X = np.arange(len(labels), dtype=float).reshape(-1, 1) + y = container(labels) + explanation = ClassHistogram().explain_data(X, y) + + assert explanation.data()["y"].tolist() == labels + assert explanation.data(0)["y"].tolist() == labels + assert np.unique(explanation.data()["y"]).size == len(set(labels)) + + +@pytest.mark.parametrize( + "labels", + [ + pytest.param(["01", "1", "01", "1"], id="leading-zero-strings"), + pytest.param([2**53, 2**53 + 1, 2**53, 2**53 + 1], id="large-integers"), + pytest.param(["cat", "dog", "cat", "dog"], id="text-control"), + pytest.param([0.0, 1.0, 0.0, 1.0], id="float-control"), + ], +) +def test_class_histogram_keeps_class_counts(labels): + X = np.array([["a"], ["b"], ["a"], ["b"]]) + explanation = ClassHistogram(feature_types=["nominal"]).explain_data(X, labels) + + overall = explanation.visualize() + assert len(overall.data) == 2 + assert [trace.y[0] for trace in overall.data] == [2, 2] + + feature = explanation.visualize(0) + assert len(feature.data) == 2 + assert [trace.name for trace in feature.data] == sorted({str(y) for y in labels}) + assert all(sum(trace.y) == 2 for trace in feature.data) + + +@pytest.mark.parametrize( + "factory", + [np.array, list, pd.Series, lambda y: np.array(y, dtype=object)], +) +def test_exact_unsigned_values_remain_positive(factory): + labels = [2**63, 2**63 + 4096, 2**63, 2**63, 2**63 + 4096] + X = np.array([["a"], ["b"], ["c"], ["b"], ["a"]]) + exp = ClassHistogram(feature_types=["nominal"]).explain_data(X, factory(labels)) + for key in (None, 0): + assert [int(v) for v in exp.data(key)["y"]] == labels + fig = exp.visualize() + assert [int(t.x[0]) for t in fig.data] == [2**63, 2**63 + 4096] + assert [int(t.y[0]) for t in fig.data] == [3, 2] + + +@pytest.mark.parametrize( + "factory", + [np.array, list, pd.Series, lambda y: np.array(y, dtype=object)], +) +def test_equal_float_zeros_remain_one_class(factory): + labels = [0.0, -0.0, 0.0, -0.0, 0.0] + X = np.array([["a"], ["b"], ["c"], ["b"], ["a"]]) + exp = ClassHistogram(feature_types=["nominal"]).explain_data(X, factory(labels)) + assert np.unique(exp.data()["y"]).size == 1 + assert len(exp.visualize().data) == 1 + assert exp.visualize().data[0].y == (5,) + assert len(exp.visualize(0).data) == 1 + + +def test_numeric_float_data_remains_numeric(): + labels = np.array([2.5, 10.5, 2.5, 2.5, 10.5]) + X = np.array([["a"], ["b"], ["c"], ["b"], ["a"]]) + exp = ClassHistogram(feature_types=["nominal"]).explain_data(X, labels) + assert exp.data()["y"].dtype.kind == "f" + np.testing.assert_array_equal(exp.data()["y"], labels) + + +def test_numeric_float_plot_order_remains_numeric(): + X = np.array([["a"], ["b"], ["c"], ["b"], ["a"]]) + exp = ClassHistogram(feature_types=["nominal"]).explain_data( + X, np.array([2.5, 10.5, 2.5, 2.5, 10.5]) + ) + assert [float(t.x[0]) for t in exp.visualize().data] == [2.5, 10.5] + assert [t.name for t in exp.visualize(0).data] == ["2.5", "10.5"] + + +@pytest.mark.parametrize( + "container", + [ + np.array, + list, + pd.Series, + lambda y: np.array(y, dtype=object), + lambda y: pd.Series(y, dtype="UInt64"), + ], +) +@pytest.mark.parametrize( + "labels", + [ + [2**63, 2**63 + 4096, 2**63, 2**63, 2**63 + 4096], + [2**64 - 2, 2**64 - 1, 2**64 - 2, 2**64 - 2, 2**64 - 1], + ], + ids=["exact-unsigned-control", "uint64-maximum"], +) +def test_unsigned_class_counts_and_feature_labels(labels, container): + X = np.array([["a", 0], ["b", 1], ["c", 2], ["b", 3], ["a", 4]], dtype=object) + y = container(labels) + before = repr(y) + exp = ClassHistogram(feature_types=["nominal", "continuous"]).explain_data(X, y) + for key in (None, 0, 1): + assert [int(v) for v in exp.data(key)["y"]] == labels + assert [int(t.x[0]) for t in exp.visualize().data] == sorted(set(labels)) + assert [int(t.y[0]) for t in exp.visualize().data] == [3, 2] + for key in (0, 1): + fig = exp.visualize(key) + assert [float(t.name) for t in fig.data] == [ + float(v) for v in sorted(set(labels)) + ] + assert [sum(t.y) for t in fig.data] == [3, 2] + assert isinstance(fig.to_json(), str) + assert repr(y) == before + + +@pytest.mark.parametrize( + ("labels", "classes", "counts"), + [ + ([0.0, -0.0, 0.0, -0.0, 0.0], [0.0], [5]), + ([2.5, 10.5, 2.5, 2.5, 10.5], [2.5, 10.5], [3, 2]), + ([1, 1.0, 2, 2.0, 1], [1.0, 2.0], [3, 2]), + ], + ids=["signed-zero", "numeric-order", "mixed-integer-float"], +) +@pytest.mark.parametrize( + "container", [np.array, list, pd.Series, lambda y: np.array(y, dtype=object)] +) +def test_numeric_float_feature_semantics(labels, classes, counts, container): + X = np.array([["a", 0], ["b", 1], ["c", 2], ["b", 3], ["a", 4]], dtype=object) + exp = ClassHistogram(feature_types=["nominal", "continuous"]).explain_data( + X, container(labels) + ) + for key in (None, 0, 1): + assert exp.data(key)["y"].dtype.kind == "f" + np.testing.assert_array_equal(exp.data(key)["y"], labels) + fig = exp.visualize(key) + assert [sum(t.y) for t in fig.data] == counts + if key is None: + assert [t.x[0] for t in fig.data] == classes + else: + assert [t.name for t in fig.data] == [str(v) for v in classes] + assert isinstance(fig.to_json(), str)