Replace SHAP plotting with a native visualisation engine - #70
Merged
Conversation
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>
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 Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR Type
PR Purpose
What is the current behaviour?
shapis a hard runtime dependency, used only for the plotting layer. 19 entry points call into it across two modules:core/result.pyBaseXWhyResult.to_shap,ImageClassificationXWhyResult.to_shapplots/plots.pyreplace_shap_label,bar,waterfall,text,force,decision,scatter,heatmap,beeswarm,violin,embedding,group_difference,monitoring,image,image_to_text, plus theinitjs/partial_dependencealiases bound straight toshap.plotsBecause
xwhy/__init__.pydoesfrom xwhy import plots, a bareimport xwhypulls the entirety ofshapinto 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 — andforce/textrequired ashap.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.shapis removed fromdependencies.Explanationreplacesshap.Explanation, supporting the idioms existing code uses:exp[0],exp[:, 2],exp.abs.mean(0),.shape,len().backend="plotly", following thesave_pathconvention already established inplots/tabular.py(.html→write_html, otherwisewrite_image).text()andforce(backend="html")return self-contained HTML — inline styles, HTML-escaped tokens, no<script>, no handlers, no remote assets. This replaces SHAP's JavaScript bundles, soinitjs()becomes a documented no-op and the output renders identically in notebooks, static doc exports and saved.htmlfiles.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 XWhyExplanation.replace_shap_labelis 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 thanshap.plots.visualisation.pyimports only numpy, matplotlib, plotly and scipy — nothing fromxwhyitself — 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:
Unknown keyword arguments are silently ignored. Each plot accepts
**kwargsand 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.decision()is the one plot that does not route throughto_explanation()— it consumes raw arrays, mirroring the old SHAP API it replaced.The example notebooks are intentionally untouched. They use
shapas a comparison baseline against XWhy, which is a legitimate research use and distinct from a library dependency. Each example declares its own pin (Tabular_Exampleshap~=0.49.1,NLP_ExamplesandPoint Cloud Examplesshap~=0.51.0), so they remain self-contained and unaffected.Other information
Verified with
shapnot installed in the environment:strict: clean across all 84 source files — matching the pre-existing clean baseline onmainruff formatcleanimport xwhynever putsshapinsys.modules, another greps every module undersrc/xwhyfor shap importsuv.lockdrops from 222 shap-related lines to 0Correctness beyond "it renders": the waterfall bars are asserted to sum exactly to
f(x) − E[f(X)], and themax_displaygrouping 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
🤖 Generated with Claude Code