Skip to content
Open
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
25 changes: 22 additions & 3 deletions python/interpret-core/interpret/data/_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
AryanRG marked this conversation as resolved.
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))

Expand Down Expand Up @@ -449,6 +463,11 @@ def visualize(self, key=None):
if key is None:
y = data_dict["y"]
values, counts = np.unique(y, return_counts=True)
# Stringify integers after sorting to preserve exact labels in JavaScript.
values = [
str(value) if isinstance(value, (int, np.integer)) else value
for value in values
]
traces = [go.Bar(x=[values[i]], y=[counts[i]]) for i in range(len(values))]
layout = go.Layout(
title="Response Distribution",
Expand Down
213 changes: 213 additions & 0 deletions python/interpret-core/tests/data/test_class_histogram.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# Copyright (c) 2026 The InterpretML Contributors
# Distributed under the MIT software license

import json

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(
"labels",
[
pytest.param([2**53 + 1, 2**53, 2**53 + 1], id="signed-positive"),
pytest.param([-(2**53), -(2**53 + 1), -(2**53)], id="signed-negative"),
pytest.param([2**63 - 1, 2**63 - 2, 2**63 - 1], id="signed-maximum"),
pytest.param([10, 2, 10], id="numeric-order"),
],
)
@pytest.mark.parametrize(
"container",
[np.array, list, pd.Series, lambda y: np.array(y, dtype=object)],
)
def test_overall_integer_labels_serialize_as_exact_strings(labels, container):
X = np.array([["a"], ["b"], ["a"]])
exp = ClassHistogram(feature_types=["nominal"]).explain_data(X, container(labels))
classes = sorted(set(labels))
counts = [labels.count(value) for value in classes]

fig = exp.visualize()
serialized = json.loads(fig.to_json())
assert serialized["layout"]["xaxis"]["type"] == "category"
assert [trace["x"][0] for trace in serialized["data"]] == [
str(value) for value in classes
]
assert [trace["y"][0] for trace in serialized["data"]] == counts
assert exp.data()["y"].tolist() == labels
assert exp.data(0)["y"].tolist() == labels


@pytest.mark.parametrize("labels", [["01", "1", "01"], ["1", "1.0", "1"]])
def test_overall_string_labels_serialize_unchanged(labels):
X = np.array([["a"], ["b"], ["a"]])
exp = ClassHistogram(feature_types=["nominal"]).explain_data(X, labels)
serialized = json.loads(exp.visualize().to_json())
assert [trace["x"][0] for trace in serialized["data"]] == sorted(set(labels))
assert [trace["y"][0] for trace in serialized["data"]] == [2, 1]
assert exp.data()["y"].tolist() == labels


@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]
serialized = json.loads(exp.visualize().to_json())
assert [trace["x"][0] for trace in serialized["data"]] == [
str(value) for value in sorted(set(labels))
]
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
serialized = json.loads(fig.to_json())
assert [trace["x"][0] for trace in serialized["data"]] == classes
assert all(isinstance(trace["x"][0], float) for trace in serialized["data"])
else:
assert [t.name for t in fig.data] == [str(v) for v in classes]
assert isinstance(fig.to_json(), str)
Loading