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
57 changes: 42 additions & 15 deletions python/interpret-core/interpret/data/_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@
from ..utils._unify_data import unify_data


def _is_numeric_bin_edges(names):
"""Return True iff `names` looks like a numeric bin-edge sequence.

For continuous features the density payload stores numeric bin edges
(e.g. ``[0.0, 1.5, 3.0, ...]``); for categorical features it stores the
string category labels themselves (e.g. ``["a", "b", "c"]``). The
histogram path computes ``names[1] - names[0]`` to derive the plotly
bin size, which raises ``TypeError`` on the categorical case (Issue #119).
Use this guard before doing the subtraction.
"""
return len(names) >= 2 and all(
isinstance(n, (int, float, np.integer, np.floating)) for n in names[:2]
)


class Marginal(DataExplainer):
"""Provides a marginal plot for provided data."""

Expand Down Expand Up @@ -200,15 +215,18 @@ def visualize(self, key=None):

# Show feature graph
density_dict = data_dict["feature_density"]

bin_size = density_dict["names"][1] - density_dict["names"][0]
is_categorical = (
self.feature_types[key] == "nominal" or self.feature_types[key] == "ordinal"
)

# Bin sizing only makes sense for numeric histograms. For categorical
# features density_dict["names"] holds string category labels (Issue #119),
# so subtracting them raises TypeError. Compute bin_size lazily only on
# the numeric branch.
if is_categorical:
trace1 = go.Histogram(x=data_dict["x"], name="x density", yaxis="y2")
else:
bin_size = density_dict["names"][1] - density_dict["names"][0]
trace1 = go.Histogram(
x=data_dict["x"],
name="x density",
Expand All @@ -222,19 +240,28 @@ def visualize(self, key=None):
)
data = []
resp_density_dict = data_dict["response_density"]
resp_bin_size = density_dict["names"][1] - density_dict["names"][0]

trace2 = go.Histogram(
y=data_dict["y"],
name="y density",
xaxis="x2",
autobiny=False,
ybins={
"start": resp_density_dict["names"][0],
"end": resp_density_dict["names"][-1],
"size": resp_bin_size,
},
)
# The response side: only build a fixed-bin histogram when the response
# is numeric. response_density["names"] are bin edges; for a continuous
# response we can compute resp_bin_size from those. For a categorical
# response we let plotly auto-bin.
is_categorical_response = not _is_numeric_bin_edges(resp_density_dict["names"])
if is_categorical_response:
trace2 = go.Histogram(y=data_dict["y"], name="y density", xaxis="x2")
else:
resp_bin_size = (
resp_density_dict["names"][1] - resp_density_dict["names"][0]
)
trace2 = go.Histogram(
y=data_dict["y"],
name="y density",
xaxis="x2",
autobiny=False,
ybins={
"start": resp_density_dict["names"][0],
"end": resp_density_dict["names"][-1],
"size": resp_bin_size,
},
)
data.append(trace1)
data.append(trace2)
x = data_dict["feature_samples"]
Expand Down
Empty file.
73 changes: 73 additions & 0 deletions python/interpret-core/tests/data/test_marginal_visualize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Copyright (c) 2026 The InterpretML Contributors
# Distributed under the MIT software license

"""Regression tests for Marginal.visualize() on categorical features.

Issue #119: ``Marginal(...).explain_data(...).visualize(key)`` raised
``TypeError: unsupported operand type(s) for -: 'str' and 'str'`` when
``feature_types[key]`` was ``"nominal"`` because the histogram path
unconditionally computed ``density_dict["names"][1] - density_dict["names"][0]``
even though categorical features carry string labels in ``names``.
"""

import numpy as np
import pandas as pd
import pytest
from interpret.data import Marginal


@pytest.fixture
def mixed_dataframe():
return pd.DataFrame(
{
"cat": ["a", "b", "a", "c", "b", "a", "c", "b"],
"cont": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
}
)


def test_visualize_nominal_feature_continuous_target_succeeds(mixed_dataframe):
# BEFORE: this call raised TypeError on str-str subtraction.
# AFTER: returns a plotly Figure.
y = np.array([0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5])
explanation = Marginal(feature_types=["nominal", "continuous"]).explain_data(
mixed_dataframe, y
)
fig = explanation.visualize(0)
assert fig is not None
# We don't depend on plotly types beyond the .data attribute existing.
assert hasattr(fig, "data")


def test_visualize_continuous_feature_still_works(mixed_dataframe):
# Non-regression: the continuous branch must continue to work after
# the categorical fix.
y = np.array([0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5])
explanation = Marginal(feature_types=["nominal", "continuous"]).explain_data(
mixed_dataframe, y
)
fig = explanation.visualize(1)
assert fig is not None
assert hasattr(fig, "data")


def test_visualize_nominal_feature_classification_target(mixed_dataframe):
# Classification target: response_density["names"] are also categorical
# (class labels). The fix must handle that branch too.
y = np.array([0, 1, 0, 1, 0, 1, 0, 1])
explanation = Marginal(feature_types=["nominal", "continuous"]).explain_data(
mixed_dataframe, y
)
fig = explanation.visualize(0)
assert fig is not None


def test_visualize_overall_unaffected(mixed_dataframe):
# The key=None path is independent of the bug; guard against
# accidental regression.
y = np.array([0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5])
explanation = Marginal(feature_types=["nominal", "continuous"]).explain_data(
mixed_dataframe, y
)
fig = explanation.visualize(None)
assert fig is not None
Loading