diff --git a/python/interpret-core/interpret/core/templates.py b/python/interpret-core/interpret/core/templates.py
index 0b9a30d86..294a7599e 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,10 @@ def visualize(self, key=None, title=None):
# Handle local instance graphs
if self.explanation_type == "local":
+ if plot_type == "waterfall":
+ title_str = "" if title is None else title
+ return plot_waterfall(data_dict, title=title_str, **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 bb8284324..ef4f7cbee 100644
--- a/python/interpret-core/interpret/glassbox/_ebm.py
+++ b/python/interpret-core/interpret/glassbox/_ebm.py
@@ -293,7 +293,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:
@@ -387,11 +393,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: # pragma: no cover
+ 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 "
@@ -2604,19 +2622,34 @@ def explain_local(
perf = None if perf_dicts is None else perf_dicts[row_idx]
perf_list.append(perf)
- for data, sample_scores, perf in zip(X_unified, explanations, perf_list):
+ for row_idx, (data, sample_scores, perf) in enumerate(
+ zip(X_unified, explanations, perf_list, strict=False)
+ ):
values = [
data[tfs[0]] if len(tfs) == 1 else "" for tfs in self.term_features_
]
+ extra_names = ["Intercept"]
+ extra_scores = [intercept]
+ extra_values = [1]
+ if init_score is not None:
+ sample_init = init_score[row_idx]
+ if (classes is None or len(classes) <= 2) and isinstance(
+ sample_init, (list, np.ndarray)
+ ):
+ sample_init = sample_init[0]
+ extra_names.append("Init score")
+ extra_scores.append(sample_init)
+ extra_values.append(1)
+
data_dict = {
"type": "univariate",
"names": list(self.term_names_),
"scores": list(sample_scores),
"values": values,
"extra": {
- "names": ["Intercept"],
- "scores": [intercept],
- "values": [1],
+ "names": extra_names,
+ "scores": extra_scores,
+ "values": extra_values,
},
"perf": perf,
}
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..492903cad 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", None)
# 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,10 @@ def render(self, explanation, key=-1, **kwargs):
key = None
# Get visual object
- visual = explanation.visualize(key=key)
+ if plot_type is not None and plot_type != "bar":
+ visual = explanation.visualize(key=key, plot_type=plot_type)
+ else:
+ visual = explanation.visualize(key=key)
# Output to front-end/file
@@ -201,12 +205,14 @@ def link(self, explanation, **kwargs):
return self.app_runner.display_link(explanation)
- def render(self, explanation, **kwargs):
+ def render(self, explanation, key=-1, **kwargs):
self.idempotent_start()
# Register
share_tables = kwargs.pop("share_tables", None)
- self.app_runner.register(explanation, share_tables=share_tables)
+ self.app_runner.register(
+ explanation, share_tables=share_tables, key=key, **kwargs
+ )
# Display
open_link = isinstance(explanation, list)
@@ -234,4 +240,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/_udash.py b/python/interpret-core/interpret/visual/_udash.py
index 7f1489db4..3fd4f4dea 100644
--- a/python/interpret-core/interpret/visual/_udash.py
+++ b/python/interpret-core/interpret/visual/_udash.py
@@ -81,6 +81,7 @@ def __init__(self, *args, **kwargs):
# Dash app for a single explanation only
def generate_app_mini(
ctx_item,
+ options=None,
url_base_pathname=None,
requests_pathname_prefix=None,
routes_pathname_prefix=None,
@@ -99,12 +100,14 @@ def generate_app_mini(
requests_pathname_prefix=requests_pathname_prefix,
routes_pathname_prefix=routes_pathname_prefix,
)
+ app.options = options or {}
app.scripts.config.serve_locally = True
app.css.config.serve_locally = True
app.config["suppress_callback_exceptions"] = True
app.ctx_item = ctx_item
app.logger.handlers = []
app.title = "InterpretML"
+ app.options = options or {}
server = app.server
# Items in drop-down.
@@ -145,7 +148,12 @@ def generate_app_mini(
id="component-drop",
options=data_options,
multi=False,
- value=-1,
+ value=(
+ (options or {}).get("key", -1)
+ if (options or {}).get("key", -1) is not None
+ and (options or {}).get("key", -1) != -1
+ else -1
+ ),
)
],
className="card-body",
@@ -179,10 +187,15 @@ def update_viz_container(value):
return None
explanation, selector = app.ctx_item
+ viz_kwargs = {
+ k: v
+ for k, v in (app.options or {}).items()
+ if k not in ("share_tables", "key")
+ }
if value == -1:
- output_div = gen_overall_plot(explanation, int(value))
+ output_div = gen_overall_plot(explanation, int(value), **viz_kwargs)
else:
- output_div = gen_plot(explanation, int(value), 0, 0)
+ output_div = gen_plot(explanation, int(value), 0, 0, **viz_kwargs)
return output_div
@@ -195,8 +208,14 @@ def handle_error(e): # pragma: no cover
return app
-def gen_overall_plot(exp, model_idx):
- figure = exp.visualize(key=None)
+def gen_overall_plot(exp, model_idx, **kwargs):
+ if kwargs:
+ try:
+ figure = exp.visualize(key=None, **kwargs)
+ except TypeError:
+ figure = exp.visualize(key=None)
+ else:
+ figure = exp.visualize(key=None)
if figure is None:
_log.info(f"No overall plot to display: {model_idx}|{exp.name}")
# Provide default 'no overall' graph
@@ -272,8 +291,14 @@ def gen_overall_plot(exp, model_idx):
)
-def gen_plot(exp, picker, model_idx, counter):
- figure = exp.visualize(key=picker)
+def gen_plot(exp, picker, model_idx, counter, **kwargs):
+ if kwargs:
+ try:
+ figure = exp.visualize(key=picker, **kwargs)
+ except TypeError:
+ figure = exp.visualize(key=picker)
+ else:
+ figure = exp.visualize(key=picker)
if isinstance(figure, _NDFrame):
records = figure.to_dict("records")
columns = [
@@ -330,7 +355,10 @@ def gen_plot(exp, picker, model_idx, counter):
# Dash app code
# TODO: Consider reducing complexity of this function.
def generate_app_full(
- url_base_pathname=None, requests_pathname_prefix=None, routes_pathname_prefix=None
+ options=None,
+ url_base_pathname=None,
+ requests_pathname_prefix=None,
+ routes_pathname_prefix=None,
):
"""Generates the Dash application including callbacks.
@@ -786,8 +814,13 @@ def gen_plots_container(model_idx, picker_idx):
output = []
counter = 0
+ viz_kwargs = {
+ k: v
+ for k, v in (app.options or {}).items()
+ if k not in ("share_tables", "key")
+ }
for picker in reversed(picker_idx):
- output_div = gen_plot(exp, picker, model_idx, counter)
+ output_div = gen_plot(exp, picker, model_idx, counter, **viz_kwargs)
counter += 1
output.append(output_div)
return html.Div(output)
@@ -798,7 +831,12 @@ def gen_overall_plot_container(model_idx):
ctx = app.ctx
exp = ctx[model_idx][0]
- output_div = gen_overall_plot(exp, model_idx)
+ viz_kwargs = {
+ k: v
+ for k, v in (app.options or {}).items()
+ if k not in ("share_tables", "key")
+ }
+ output_div = gen_overall_plot(exp, model_idx, **viz_kwargs)
return html.Div(output_div)
@@ -888,12 +926,14 @@ def generate_app(
new_item = _expand_ctx_item(ctx)
return generate_app_mini(
new_item,
+ options=options,
url_base_pathname=url_base_pathname,
requests_pathname_prefix=requests_pathname_prefix,
routes_pathname_prefix=routes_pathname_prefix,
)
app = generate_app_full(
+ options=options,
requests_pathname_prefix=requests_pathname_prefix,
routes_pathname_prefix=routes_pathname_prefix,
)
diff --git a/python/interpret-core/interpret/visual/dashboard.py b/python/interpret-core/interpret/visual/dashboard.py
index 61c2a7c73..d53b05c10 100644
--- a/python/interpret-core/interpret/visual/dashboard.py
+++ b/python/interpret-core/interpret/visual/dashboard.py
@@ -209,9 +209,9 @@ def __init__(self, base_url=None, use_relative_links=False):
def obj_id(self, obj):
return str(id(obj))
- def register(self, ctx, share_tables=None):
+ def register(self, ctx, share_tables=None, **kwargs):
ctx_id = self.obj_id(ctx)
- if ctx_id not in self.pool:
+ if ctx_id not in self.pool or kwargs:
_log.info(f"Creating App Entry: {ctx_id}")
ctx_path = (
f"/{ctx_id}/"
@@ -220,7 +220,7 @@ def register(self, ctx, share_tables=None):
)
app = _udash.generate_app(
ctx,
- {"share_tables": share_tables},
+ {"share_tables": share_tables, **kwargs},
# url_base_pathname=ctx_path,
requests_pathname_prefix=ctx_path,
routes_pathname_prefix=ctx_path,
diff --git a/python/interpret-core/interpret/visual/plot.py b/python/interpret-core/interpret/visual/plot.py
index f96d8bb94..5ca145975 100644
--- a/python/interpret-core/interpret/visual/plot.py
+++ b/python/interpret-core/interpret/visual/plot.py
@@ -655,6 +655,195 @@ 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 or None, 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
+
+ if title is None:
+ title = ""
+
+ 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)
+ extra_steps = []
+ if extra is not None and "scores" in extra and len(extra["scores"]) > 0:
+ extra_names = extra.get("names", ["Intercept"])
+ for idx, sc in enumerate(extra["scores"]):
+ name = extra_names[idx] if idx < len(extra_names) else f"Extra {idx}"
+ if is_multiclass:
+ score_val = (
+ float(sc[class_idx]) if hasattr(sc, "__getitem__") else float(sc)
+ )
+ else:
+ score_val = (
+ float(sc[0]) if isinstance(sc, (list, np.ndarray)) else float(sc)
+ )
+ extra_steps.append((name, score_val))
+ else:
+ extra_steps.append(("Intercept", 0.0))
+
+ if "init_score" in data_dict and not any(
+ s[0].lower() == "init score" for s in extra_steps
+ ):
+ raw_init = data_dict["init_score"]
+ if is_multiclass:
+ init_val = (
+ float(raw_init[class_idx])
+ if hasattr(raw_init, "__getitem__")
+ else float(raw_init)
+ )
+ else:
+ init_val = (
+ float(raw_init[0])
+ if isinstance(raw_init, (list, np.ndarray))
+ else float(raw_init)
+ )
+ extra_steps.append(("Init score", init_val))
+
+ 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 remaining_sum != 0.0:
+ top_pairs.append(("Other features", remaining_sum))
+ pairs = top_pairs
+
+ step_names = [s[0] for s in extra_steps] + [p[0] for p in pairs] + ["Prediction"]
+ step_scores = [s[1] for s in extra_steps] + [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..bfa30f41a 100644
--- a/python/interpret-core/tests/visual/test_plot.py
+++ b/python/interpret-core/tests/visual/test_plot.py
@@ -1,8 +1,14 @@
# 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.provider import PreserveProvider
+from interpret.visual._udash import gen_plot
+from interpret.visual.plot import plot_density, plot_line, plot_waterfall
def test_plot_line_bounds_smoke():
@@ -17,9 +23,7 @@ def test_plot_line_bounds_smoke():
def test_plot_density_large_numbers():
- """
- Test that density plots handle large numbers correctly using the new number formatting
- """
+ """Test that density plots handle large numbers correctly using the new number formatting."""
data_dict = {
"scores": [1.0, 1.0],
"names": [9e13, 1e14, 1e15], # 1e15 value will trigger new formatting
@@ -30,3 +34,199 @@ 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_small_remainder():
+ # Test that small non-zero values (e.g. 1e-10) are not discarded by an absolute threshold
+ data_dict = {
+ "names": [f"F_{i}" for i in range(20)],
+ "scores": [1e-10 for _ in range(20)],
+ "extra": {"names": ["Intercept"], "scores": [0.0]},
+ }
+
+ fig = plot_waterfall(data_dict, top_n=15)
+ assert fig is not None
+ trace = fig.data[0]
+ # Intercept + top 15 + Other features + Prediction = 18 steps
+ assert len(trace.y) == 18
+ assert "Other features" in trace.y
+ other_idx = list(trace.y).index("Other features")
+ assert np.isclose(trace.x[other_idx], 5e-10)
+
+
+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_plot_waterfall_binary_title():
+ data_dict = {
+ "names": ["F1", "F2"],
+ "scores": [0.5, -0.2],
+ "extra": {"names": ["Intercept"], "scores": [0.1]},
+ "perf": {
+ "actual": 0,
+ "predicted": 1,
+ "actual_score": 0.3,
+ "predicted_score": 0.7,
+ },
+ "meta": {"label_names": ["No", "Yes"]},
+ }
+ fig = plot_waterfall(data_dict)
+ assert fig is not None
+ assert "Actual Class: No" in fig.layout.title.text
+ assert "Predicted Class: Yes" in fig.layout.title.text
+ assert "Pr(y = Yes): 0.700" 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])
+ # visualize with default title=None should generate summary in title
+ 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
+ assert "Predicted:" 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"
+
+
+def test_ebm_waterfall_with_init_score():
+ rng = np.random.default_rng(42)
+ X = rng.normal(size=(30, 2))
+ y = 2.0 * X[:, 0] + 5.0
+ init_score = np.full(30, 10.0)
+
+ ebm = ExplainableBoostingRegressor(max_rounds=5, outer_bags=1, n_jobs=1)
+ ebm.fit(X, y)
+ exp = ebm.explain_local(X[:2], y[:2], init_score=init_score[:2])
+
+ fig = exp.visualize(0, plot_type="waterfall")
+ assert fig is not None
+ trace = fig.data[0]
+ assert "Init score" in trace.y
+ init_idx = list(trace.y).index("Init score")
+ assert np.isclose(trace.x[init_idx], 10.0)
+
+
+def test_preserve_provider_plot_type():
+ rng = np.random.default_rng(42)
+ X = rng.normal(size=(20, 2))
+ y = 2.0 * X[:, 0] + 1.0
+
+ ebm = ExplainableBoostingRegressor(max_rounds=5, outer_bags=1, n_jobs=1)
+ ebm.fit(X, y)
+ exp = ebm.explain_local(X[:2], y[:2])
+
+ provider = PreserveProvider()
+
+ # Preserve with default bar should not raise TypeError
+ provider.render(exp, key=0)
+
+ # Preserve with waterfall
+ provider.render(exp, key=0, plot_type="waterfall")
+
+
+def test_udash_propagation_waterfall():
+ rng = np.random.default_rng(42)
+ X = rng.normal(size=(20, 2))
+ y = 2.0 * X[:, 0] + 1.0
+
+ ebm = ExplainableBoostingRegressor(max_rounds=5, outer_bags=1, n_jobs=1)
+ ebm.fit(X, y)
+ exp = ebm.explain_local(X[:2], y[:2])
+
+ div = gen_plot(exp, 0, 0, 0, plot_type="waterfall")
+ assert div is not None