From e78b25680b540cc27b4cf124de8f96c2f67f4ff2 Mon Sep 17 00:00:00 2001 From: jbbqqf Date: Sat, 9 May 2026 20:19:55 +0200 Subject: [PATCH] data: fix Marginal.visualize crash on categorical features (#119) Marginal(...).explain_data(...).visualize(key) raised TypeError: unsupported operand type(s) for -: 'str' and 'str' whenever feature_types[key] was nominal/ordinal because the histogram path unconditionally computed density_dict["names"][1] - density_dict["names"][0] for the bin size, even though for categorical features the names list holds string category labels rather than numeric bin edges. Compute bin_size lazily only on the numeric branch, and apply the same guard on the response side so that a categorical response (e.g. classification target) also renders without crashing. A small helper _is_numeric_bin_edges keeps the predicate explicit. Add tests/data/test_marginal_visualize.py with four cases covering the two regression branches plus the previously-working continuous and overall paths to lock in the contract. Co-Authored-By: Claude Code Signed-off-by: jbbqqf --- .../interpret/data/_response.py | 57 +++++++++++---- python/interpret-core/tests/data/__init__.py | 0 .../tests/data/test_marginal_visualize.py | 73 +++++++++++++++++++ 3 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 python/interpret-core/tests/data/__init__.py create mode 100644 python/interpret-core/tests/data/test_marginal_visualize.py diff --git a/python/interpret-core/interpret/data/_response.py b/python/interpret-core/interpret/data/_response.py index 16d4d222f..f78d5e95f 100644 --- a/python/interpret-core/interpret/data/_response.py +++ b/python/interpret-core/interpret/data/_response.py @@ -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.""" @@ -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", @@ -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"] diff --git a/python/interpret-core/tests/data/__init__.py b/python/interpret-core/tests/data/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/interpret-core/tests/data/test_marginal_visualize.py b/python/interpret-core/tests/data/test_marginal_visualize.py new file mode 100644 index 000000000..b41b1d25e --- /dev/null +++ b/python/interpret-core/tests/data/test_marginal_visualize.py @@ -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