Skip to content

Replace SHAP plotting with a native visualisation engine - #70

Merged
HamedDaneshvar merged 4 commits into
mainfrom
feat/native-visualisation-engine
Aug 14, 2026
Merged

Replace SHAP plotting with a native visualisation engine#70
HamedDaneshvar merged 4 commits into
mainfrom
feat/native-visualisation-engine

Conversation

@koo-ec

@koo-ec koo-ec commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

PR Type

  • Feature
  • Refactoring (no functional changes, no API changes)
  • Build related changes

PR Purpose

What is the current behaviour?

shap is a hard runtime dependency, used only for the plotting layer. 19 entry points call into it across two modules:

Module shap-dependent entry points
core/result.py BaseXWhyResult.to_shap, ImageClassificationXWhyResult.to_shap
plots/plots.py replace_shap_label, bar, waterfall, text, force, decision, scatter, heatmap, beeswarm, violin, embedding, group_difference, monitoring, image, image_to_text, plus the initjs / partial_dependence aliases bound straight to shap.plots

Because xwhy/__init__.py does from xwhy import plots, a bare import xwhy pulls the entirety of shap into the interpreter. The plots also carried a workaround, replace_shap_label, whose only job was to rewrite SHAP's "SHAP value" axis labels to "XWhy value" after the fact — and force/text required a shap.initjs() JavaScript handshake to render at all.

What is the new behaviour?

A new module, src/xwhy/plots/visualisation.py, reimplements the whole plotting surface natively. shap is removed from dependencies.

  • Explanation replaces shap.Explanation, supporting the idioms existing code uses: exp[0], exp[:, 2], exp.abs.mean(0), .shape, len().
  • All 16 plots reimplemented behind their existing call signatures, so notebooks need no edits.
  • matplotlib is the default backend; plotly is available on the 11 aggregate plots via backend="plotly", following the save_path convention already established in plots/tabular.py (.htmlwrite_html, otherwise write_image).
  • text() and force(backend="html") return self-contained HTML — inline styles, HTML-escaped tokens, no <script>, no handlers, no remote assets. This replaces SHAP's JavaScript bundles, so initjs() becomes a documented no-op and the output renders identically in notebooks, static doc exports and saved .html files.
  • to_explanation() is the new conversion entry point on results. to_shap() is kept as a working alias so existing notebooks keep running; it now returns an XWhy Explanation.
  • replace_shap_label is retained and exported for user code that wraps third-party plotting functions, but is no longer applied internally — the engine labels its own axes. It now introspects the wrapped function rather than shap.plots.

visualisation.py imports only numpy, matplotlib, plotly and scipy — nothing from xwhy itself — so it is independently reusable and cannot create an import cycle.

Are there any specific instructions or things that should be known prior to reviewing?

Three points worth a reviewer's attention:

  1. Unknown keyword arguments are silently ignored. Each plot accepts **kwargs and discards what it does not recognise, deliberately, so that SHAP-specific arguments left in older notebook calls do not raise. The trade-off is that a mistyped argument fails silently. Happy to make this strict if the team prefers.

  2. decision() is the one plot that does not route through to_explanation() — it consumes raw arrays, mirroring the old SHAP API it replaced.

  3. The example notebooks are intentionally untouched. They use shap as a comparison baseline against XWhy, which is a legitimate research use and distinct from a library dependency. Each example declares its own pin (Tabular_Example shap~=0.49.1, NLP_Examples and Point Cloud Examples shap~=0.51.0), so they remain self-contained and unaffected.

Other information

Verified with shap not installed in the environment:

  • 638 tests pass (128 new), no warnings
  • mypy strict: clean across all 84 source files — matching the pre-existing clean baseline on main
  • ruff: clean; all touched files are ruff format clean
  • 96% coverage on the new module; 98.85% overall
  • Two tests specifically guard the independence: one asserts import xwhy never puts shap in sys.modules, another greps every module under src/xwhy for shap imports
  • uv.lock drops from 222 shap-related lines to 0

Correctness beyond "it renders": the waterfall bars are asserted to sum exactly to f(x) − E[f(X)], and the max_display grouping is asserted to preserve the attribution total.

Rendering the output caught two defects the tests could not: the force plot overprinted labels on narrow blocks, and short negative waterfall bars collided with the y-axis tick labels. Both are fixed — narrow force blocks are now truncated or left unlabelled, and the waterfall reserves horizontal margin for its value labels.

Checklist

  • I have read the CONTRIBUTING doc
  • I have checked that unit tests pass locally with my changes
  • I have added tests that prove my fix is effective or that my feature works
  • I have added the necessary documentation (if appropriate)
  • Any dependent changes have been merged and published in downstream modules

🤖 Generated with Claude Code

XWhy depended on shap solely for its plotting layer: 19 entry points across
core/result.py and plots/plots.py called into shap, and because plots is
imported from the package __init__, a bare `import xwhy` pulled the whole of
shap into the interpreter.

Add plots/visualisation.py, a self-contained matplotlib/plotly/HTML engine
that reimplements every plot XWhy borrowed, then remove the dependency.

- Explanation replaces shap.Explanation, supporting the familiar idioms
  (exp[0], exp[:, 2], exp.abs.mean(0)).
- All 16 plots reimplemented with their existing call signatures: bar,
  waterfall, text, force, decision, scatter, heatmap, beeswarm, violin,
  embedding, group_difference, monitoring, image, image_to_text,
  partial_dependence and initjs.
- matplotlib is the default backend; plotly is available on the 11 aggregate
  plots via backend="plotly", following the save_path convention already used
  in plots/tabular.py.
- text() and force(backend="html") return self-contained HTML instead of
  SHAP's JavaScript bundles, so initjs() is now a documented no-op and the
  output renders in notebooks, static exports and saved files alike.
- BaseXWhyResult.to_explanation() is the new conversion entry point;
  to_shap() is kept as a working alias so existing notebooks keep running.
- replace_shap_label is retained and exported for user code wrapping foreign
  plotting functions, but is no longer applied internally: the engine labels
  its own axes.

The example notebooks continue to use shap as a comparison baseline and are
untouched; each declares its own pin in its requirements.txt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@koo-ec
koo-ec requested a review from HamedDaneshvar August 6, 2026 09:43
The pre-commit mypy hook checks `src` and `tests` together, and the new test
module tripped five errors that a `src`-only run did not surface.

- Build the zero-dimensional explanation with np.array rather than np.float64,
  which is both what Explanation is annotated to take and a truer expression
  of what the test asserts.
- Narrow Axes.patches to Rectangle before reading bar widths, since the base
  Patch class has no get_width.
- Wrap Line2D.get_xdata() in np.asarray, as its declared union is not Sized.
- Assert the observable behaviour of initjs() and _display_html() instead of
  comparing their return value, which mypy rejects for functions returning
  None.
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@koo-ec
koo-ec requested a review from n-akram August 6, 2026 10:06
koo-ec and others added 2 commits August 6, 2026 11:25
Codecov measures patch coverage against the coverage of the base commit, and
the new code fell short of it. Close the gap so both changed modules reach
100%, rather than lowering the target.

The uncovered paths fell into a few groups:

- Optional titles were never passed to decision, scatter, heatmap, beeswarm,
  violin, image or image_to_text, and force was never called without one.
- max_display truncation was untested for force and decision.
- Shape fallbacks were unexercised: a wrapped single instance without data, an
  array or empty base value, feature values whose length does not match the
  names, a feature matrix of the wrong width, and a background image that
  cannot be coerced to floats or that arrives already signed.
- Smaller branches: a format string yielding no decimal point, importance of a
  1D vector, a beeswarm column needing no jitter, a constant monitoring series,
  an incomplete image-to-text grid, and drift rules on the plotly backend.

The two plots.py wrappers were previously tested by patching the wrapper
itself, so neither body ever ran. Patch the engine instead, which both covers
the lines and makes the assertions meaningful.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Hamed Daneshvar <96017552+HamedDaneshvar@users.noreply.github.com>
@HamedDaneshvar
HamedDaneshvar merged commit 2c67683 into main Aug 14, 2026
0 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants