diff --git a/python/interpret-core/interpret/core/templates.py b/python/interpret-core/interpret/core/templates.py index 0b9a30d86..581ada057 100644 --- a/python/interpret-core/interpret/core/templates.py +++ b/python/interpret-core/interpret/core/templates.py @@ -61,7 +61,7 @@ def data(self, key=None): return None return self._internal_obj["specific"][key] - def visualize(self, key=None, title=None): + def visualize(self, key=None, title=None, plot_type="bar", **kwargs): """Provides interactive visualizations. Args: @@ -82,6 +82,7 @@ def visualize(self, key=None, title=None): plot_horizontal_bar, plot_line, plot_pairwise_heatmap, + plot_waterfall, sort_take, ) @@ -98,6 +99,9 @@ def visualize(self, key=None, title=None): # Handle local instance graphs if self.explanation_type == "local": + if plot_type == "waterfall": + return plot_waterfall(data_dict, title=title, **kwargs) + if isinstance(key, tuple) and len(key) == 2: provider, key = key # TODO: MLI should handle multiclass at a future date. diff --git a/python/interpret-core/interpret/glassbox/_ebm.py b/python/interpret-core/interpret/glassbox/_ebm.py index d1662ee56..f693e15e8 100644 --- a/python/interpret-core/interpret/glassbox/_ebm.py +++ b/python/interpret-core/interpret/glassbox/_ebm.py @@ -226,7 +226,13 @@ class EBMExplanation(FeatureValueExplanation): explanation_type = None - def visualize(self, key: int | None = None, title: str | None = None) -> Any: + def visualize( + self, + key: int | None = None, + title: str | None = None, + plot_type: str = "bar", + **kwargs: Any, + ) -> Any: """Provide interactive visualizations. Args: @@ -320,11 +326,23 @@ def visualize(self, key: int | None = None, title: str | None = None) -> Any: # Local explanation graph if self.explanation_type == "local": - figure = super().visualize(key) - figure.update_layout( - title="Local Explanation (" + figure.layout.title.text + ")", - xaxis_title="Contribution to Prediction", - ) + figure = super().visualize(key, title=title, plot_type=plot_type, **kwargs) + if figure is None: + return None + orig_title = figure.layout.title.text if figure.layout.title else "" + if plot_type == "waterfall": + figure.update_layout( + title="Local Explanation (" + orig_title + ")" + if orig_title + else "Local Explanation", + ) + else: + figure.update_layout( + title="Local Explanation (" + orig_title + ")" + if orig_title + else "Local Explanation", + xaxis_title="Contribution to Prediction", + ) figure._interpret_help_text = ( "A local explanation shows the breakdown of how much " "each term contributed to the prediction for a single sample. The intercept " diff --git a/python/interpret-core/interpret/glassbox/_linear.py b/python/interpret-core/interpret/glassbox/_linear.py index 087d8ba80..ff42a2d96 100644 --- a/python/interpret-core/interpret/glassbox/_linear.py +++ b/python/interpret-core/interpret/glassbox/_linear.py @@ -367,7 +367,7 @@ class LinearExplanation(FeatureValueExplanation): explanation_type = None - def visualize(self, key=None): + def visualize(self, key=None, title=None, plot_type="bar", **kwargs): """Provides interactive visualizations. Args: @@ -428,7 +428,7 @@ def visualize(self, key=None): data_dict, title="Overall Importance:
Coefficients" ) - return super().visualize(key) + return super().visualize(key, title=title, plot_type=plot_type, **kwargs) class LinearRegression(_SKRegressorMixin, BaseLinear): diff --git a/python/interpret-core/interpret/provider/_visualize.py b/python/interpret-core/interpret/provider/_visualize.py index e58ce721d..e76b3a222 100644 --- a/python/interpret-core/interpret/provider/_visualize.py +++ b/python/interpret-core/interpret/provider/_visualize.py @@ -80,6 +80,7 @@ def render(self, explanation, key=-1, **kwargs): class PreserveProvider(VisualizeProvider): def render(self, explanation, key=-1, **kwargs): file_name = kwargs.pop("file_name", None) + plot_type = kwargs.pop("plot_type", "bar") # NOTE: Preserve didn't support returning everything. If key is -1 default to key is None. # This is for backward-compatibility. All of this will be deprecated shortly anyway. @@ -87,7 +88,7 @@ def render(self, explanation, key=-1, **kwargs): key = None # Get visual object - visual = explanation.visualize(key=key) + visual = explanation.visualize(key=key, plot_type=plot_type) # Output to front-end/file @@ -234,4 +235,5 @@ def render(self, explanation, key=-1, **kwargs): default_key=key, detected_envs=self.detected_envs, js_url=self.js_url, + **kwargs, ) diff --git a/python/interpret-core/interpret/visual/_inline.py b/python/interpret-core/interpret/visual/_inline.py index 10cf47552..1581f0df1 100644 --- a/python/interpret-core/interpret/visual/_inline.py +++ b/python/interpret-core/interpret/visual/_inline.py @@ -106,15 +106,15 @@ def _build_viz_err_obj(err_msg): } -def _build_viz_obj(explanation, detected_envs): - overall = _build_viz_figure(explanation.visualize(), detected_envs) +def _build_viz_obj(explanation, detected_envs, **kwargs): + overall = _build_viz_figure(explanation.visualize(**kwargs), detected_envs) if explanation.selector is None: # NOTE: Unsure if this should be a list or None in the long term. specific = [] selector_obj = {"columns": [], "data": []} else: specific = [ - _build_viz_figure(explanation.visualize(i), detected_envs) + _build_viz_figure(explanation.visualize(i, **kwargs), detected_envs) for i in range(len(explanation.selector["data"])) ] selector_obj = explanation.selector @@ -218,7 +218,14 @@ def _render_databricks(js): # pragma: no cover _databricks_displayHTML(js) -def render(explanation, id_str=None, default_key=-1, detected_envs=None, js_url=None): +def render( + explanation, + id_str=None, + default_key=-1, + detected_envs=None, + js_url=None, + **kwargs, +): from IPython.display import HTML, display global _jupyter_initialized @@ -227,7 +234,7 @@ def render(explanation, id_str=None, default_key=-1, detected_envs=None, js_url= msg = "Dashboard not yet supported in cloud environments." viz_obj = _build_viz_err_obj(msg) else: - viz_obj = _build_viz_obj(explanation, detected_envs) + viz_obj = _build_viz_obj(explanation, detected_envs, **kwargs) init_js, body_js = _build_javascript( viz_obj, id_str, default_key=default_key, js_url=js_url diff --git a/python/interpret-core/interpret/visual/plot.py b/python/interpret-core/interpret/visual/plot.py index 61f7b69ba..d2cfb54d8 100644 --- a/python/interpret-core/interpret/visual/plot.py +++ b/python/interpret-core/interpret/visual/plot.py @@ -605,6 +605,167 @@ def plot_horizontal_bar( return go.Figure(data=traces, layout=layout) +def plot_waterfall( + data_dict, + title="", + xtitle="", + ytitle="", + orientation="h", + top_n=15, + class_idx=None, + increasing_color=None, + decreasing_color=None, + total_color=None, +): + """Produces a waterfall plot for local explanations. + + Visualizes how the model prediction is obtained by accumulating the intercept + and individual feature contributions towards the final total prediction. + + Args: + data_dict: Dictionary containing explanation data for a single instance. + title: Title of the graph. If empty, generates a summary title from perf metrics. + xtitle: Label for the x-axis. + ytitle: Label for the y-axis. + orientation: "h" for horizontal waterfall (default), "v" for vertical waterfall. + top_n: Number of top contributing terms to show individually. Remaining terms + are aggregated into an "Other features" step. + class_idx: For multiclass explanations, which class index to plot. Defaults to + the predicted class. + increasing_color: Color for positive contributions (defaults to COLORS[1]). + decreasing_color: Color for negative contributions (defaults to COLORS[0]). + total_color: Color for the total / prediction bar (defaults to COLORS[2]). + + Returns: + A Plotly Figure object. + """ + if data_dict.get("scores", None) is None: # pragma: no cover + return None + + is_multiclass = is_multiclass_local_data_dict(data_dict) + if is_multiclass: + if class_idx is None: + class_idx = ( + data_dict["perf"]["predicted"] + if "perf" in data_dict and "predicted" in data_dict["perf"] + else 0 + ) + scores = [float(s[class_idx]) for s in data_dict["scores"]] + else: + scores = [float(s) for s in data_dict["scores"]] + + names = list(data_dict["names"]) + values = data_dict.get("values", None) + if values is not None: + values = list(values) + names = _names_with_values(names, values) + + extra = data_dict.get("extra", None) + if extra is not None and "scores" in extra and len(extra["scores"]) > 0: + if is_multiclass: + intercept = float(extra["scores"][0][class_idx]) + else: + intercept = float(extra["scores"][0]) + intercept_name = extra.get("names", ["Intercept"])[0] + else: + intercept = 0.0 + intercept_name = "Intercept" + + if title == "": + title_items = [] + if data_dict.get("perf", None) is not None: + predicted = data_dict["perf"]["predicted"] + actual = data_dict["perf"]["actual"] + predicted_score = data_dict["perf"]["predicted_score"] + actual_score = data_dict["perf"]["actual_score"] + + if "meta" in data_dict and "label_names" in data_dict["meta"]: + label_names = data_dict["meta"]["label_names"] + pred_label = label_names[predicted] + + title_str = "" + if not np.isnan(actual): + actual_class = label_names[actual] + title_str += f"Actual Class: {actual_class} | " + + title_str += f"Predicted Class: {pred_label}" + title_str += f"
Pr(y = {pred_label}): {predicted_score:.3f}" + + if not np.isnan(actual) and len({predicted, actual}) == 2: + title_str += f" | Pr(y = {actual}): {actual_score:.3f}" + title_items.append(title_str) + else: + if not np.isnan(actual): + actual_score_str = _pretty_number(actual_score) + title_items.append(f"Actual: {actual_score_str}") + + pred_score_str = _pretty_number(predicted_score) + title_items.append(f"Predicted: {pred_score_str}") + + if is_multiclass and "meta" in data_dict and "label_names" in data_dict["meta"]: + cls_name = data_dict["meta"]["label_names"][class_idx] + title_items.append(f"Class: {cls_name}") + + title = " | ".join(title_items) + + pairs = sorted(zip(names, scores, strict=False), key=lambda x: -abs(x[1])) + if top_n is not None and len(pairs) > top_n: + top_pairs = pairs[:top_n] + remaining_sum = sum(s for _, s in pairs[top_n:]) + if abs(remaining_sum) > 1e-9: + top_pairs.append(("Other features", remaining_sum)) + pairs = top_pairs + + step_names = [intercept_name] + [p[0] for p in pairs] + ["Prediction"] + step_scores = [intercept] + [p[1] for p in pairs] + [0.0] + measures = ["relative"] * (len(step_names) - 1) + ["total"] + + inc_color = increasing_color or COLORS[1] + dec_color = decreasing_color or COLORS[0] + tot_color = total_color or COLORS[2] + + is_horizontal = orientation.lower().startswith("h") + if is_horizontal: + waterfall_trace = go.Waterfall( + orientation="h", + measure=measures, + x=step_scores, + y=step_names, + connector={"line": {"color": "rgb(63, 63, 63)", "width": 1}}, + increasing={"marker": {"color": inc_color}}, + decreasing={"marker": {"color": dec_color}}, + totals={"marker": {"color": tot_color}}, + ) + layout = { + "title": title, + "yaxis": { + "automargin": True, + "title": ytitle, + "dtick": 1, + "autorange": "reversed", + }, + "xaxis": {"title": xtitle or "Score Contribution"}, + } + else: + waterfall_trace = go.Waterfall( + orientation="v", + measure=measures, + x=step_names, + y=step_scores, + connector={"line": {"color": "rgb(63, 63, 63)", "width": 1}}, + increasing={"marker": {"color": inc_color}}, + decreasing={"marker": {"color": dec_color}}, + totals={"marker": {"color": tot_color}}, + ) + layout = { + "title": title, + "xaxis": {"automargin": True, "title": xtitle, "dtick": 1}, + "yaxis": {"title": ytitle or "Score Contribution"}, + } + + return go.Figure(data=[waterfall_trace], layout=layout) + + def mli_plot_horizontal_bar( scores, names, diff --git a/python/interpret-core/tests/visual/test_plot.py b/python/interpret-core/tests/visual/test_plot.py index dd30902ef..d5e565cb9 100644 --- a/python/interpret-core/tests/visual/test_plot.py +++ b/python/interpret-core/tests/visual/test_plot.py @@ -1,8 +1,10 @@ # Copyright (c) 2023 The InterpretML Contributors # Distributed under the MIT software license -from interpret.visual.plot import plot_line -from interpret.visual.plot import plot_density +import numpy as np + +from interpret.glassbox import ExplainableBoostingRegressor, LinearRegression +from interpret.visual.plot import plot_density, plot_line, plot_waterfall def test_plot_line_bounds_smoke(): @@ -30,3 +32,110 @@ def test_plot_density_large_numbers(): # The x-axis tick text should show ranges using our new formatting assert "90T - 100T" in figure.layout.xaxis.ticktext[0] assert "100T - 1.00e+15" in figure.layout.xaxis.ticktext[1] + + +def test_plot_waterfall_basic(): + data_dict = { + "names": ["Feature A", "Feature B", "Feature C"], + "scores": [2.5, -1.2, 0.8], + "values": [10, "Yes", 3.14], + "extra": {"names": ["Intercept"], "scores": [5.0]}, + "perf": { + "actual": 7.1, + "predicted": 7.1, + "actual_score": 7.1, + "predicted_score": 7.1, + }, + } + + fig = plot_waterfall(data_dict) + assert fig is not None + assert len(fig.data) == 1 + trace = fig.data[0] + assert trace.type == "waterfall" + assert trace.orientation == "h" + assert trace.measure[-1] == "total" + assert trace.measure[0] == "relative" + assert trace.y[0] == "Intercept" + assert trace.y[-1] == "Prediction" + assert trace.x[0] == 5.0 + assert "Actual: 7.1" in fig.layout.title.text + assert "Predicted: 7.1" in fig.layout.title.text + + +def test_plot_waterfall_vertical(): + data_dict = { + "names": ["F1", "F2"], + "scores": [1.0, -0.5], + "extra": {"names": ["Intercept"], "scores": [2.0]}, + } + + fig = plot_waterfall(data_dict, orientation="v", title="Custom Title") + assert fig is not None + trace = fig.data[0] + assert trace.orientation == "v" + assert trace.x[0] == "Intercept" + assert trace.x[-1] == "Prediction" + assert trace.y[0] == 2.0 + assert fig.layout.title.text == "Custom Title" + + +def test_plot_waterfall_top_n(): + data_dict = { + "names": [f"F_{i}" for i in range(10)], + "scores": [float(i) for i in range(10)], + "extra": {"names": ["Intercept"], "scores": [1.0]}, + } + + fig = plot_waterfall(data_dict, top_n=3) + assert fig is not None + trace = fig.data[0] + # Intercept + top 3 + Other features + Prediction = 6 steps + assert len(trace.y) == 6 + assert "Other features" in trace.y + assert trace.y[-1] == "Prediction" + + +def test_plot_waterfall_multiclass(): + data_dict = { + "names": ["F1", "F2"], + "scores": [np.array([0.5, -0.2, 0.1]), np.array([0.3, 0.8, -0.4])], + "extra": {"names": ["Intercept"], "scores": [np.array([1.0, 2.0, 3.0])]}, + "perf": { + "actual": 1, + "predicted": 1, + "actual_score": 0.8, + "predicted_score": 0.8, + }, + "meta": {"label_names": ["ClassA", "ClassB", "ClassC"]}, + } + + fig = plot_waterfall(data_dict, class_idx=1) + assert fig is not None + trace = fig.data[0] + assert trace.type == "waterfall" + assert trace.x[0] == 2.0 # ClassB intercept + assert "ClassB" in fig.layout.title.text + + +def test_explanation_visualize_waterfall(): + rng = np.random.default_rng(42) + X = rng.normal(size=(40, 2)) + y = 2.0 * X[:, 0] - 1.0 * X[:, 1] + 3.0 + + # Test EBMExplanation + ebm = ExplainableBoostingRegressor(max_rounds=5, outer_bags=1, n_jobs=1) + ebm.fit(X, y) + exp = ebm.explain_local(X[:2], y[:2]) + fig = exp.visualize(0, plot_type="waterfall") + assert fig is not None + assert fig.data[0].type == "waterfall" + assert "Local Explanation" in fig.layout.title.text + + # Test LinearExplanation + lr = LinearRegression() + lr.fit(X, y) + exp_lr = lr.explain_local(X[:2], y[:2]) + fig_lr = exp_lr.visualize(0, plot_type="waterfall") + assert fig_lr is not None + assert fig_lr.data[0].type == "waterfall"