Skip to content

NLP explainability: standalone NlpExplainer stack - #775

Merged
milton-minervino merged 147 commits into
developfrom
feature/nlp-refacto
Oct 14, 2026
Merged

milton-minervino merged 147 commits into
developfrom
feature/nlp-refacto

Conversation

@milton-minervino

Copy link
Copy Markdown
Collaborator

Description

Adds a parallel, standalone stack for explaining text classifiers — NlpExplainer / NlpExplanation / NlpWebApp — built alongside SmartExplainer rather than through it. It deliberately bypasses SmartExplainer.compile() instead of adding a half-finished modality="text" branch that would need to be written and then deleted once tabular and text are unified behind one API. Full design rationale, architecture diagram, and API reference: docs/wiki/NLP-Explainability.md

Nothing about the existing public API changed — shapash/__init__.py and shapash/explainer/smart_explainer.py are untouched. SmartExplainer users are unaffected; the nlp extra is opt-in and adds no dependencies to the default install.

What's in this PR

1. The NLP stack (new code)

  • shapash/model/ — capability-based TextModel layer (ABC + SupportsTokenization/Embeddings/Gradients/CaptumIG mixins) with HF pipeline, HF checkpoint, torch encoder+head, and sentence-transformers adapters
  • shapash/backend/nlp_{shap,lime,captum_lig}_backend.py — three interchangeable attribution backends, self-registered by .name
  • shapash/compute/generators/ — HotFlipGenerator / AblationFlipGenerator counterfactuals over a shared minimal-perturbation search
  • shapash/compute/diagnostics/ — in-house confident-learning label-noise detection + an independent LabelProbe to distinguish label errors from model errors
  • shapash/compute/{embeddings,embedding_store,retrieval} — embedding cache + nearest-neighbour similar-example retrieval
  • shapash/explainer/{nlp_explainer,nlp_explanation,nlp_plotter,interactive}.py — NlpExplainer (fit/explain, memoized), NlpExplanation (frozen, model-free artifact, save/load as zip of parquet — no pickle)
  • shapash/webapp/nlp_app.py + nlp_components/ — a second, capability-gated Dash app (10 panels: word importance, scatter, waterfall, error analysis, what-if lab, etc.)
  • shapash/plots/ — 8 new pure plot functions reused by the above
  • tutorial/nlp/overview.ipynb, demo/serve_nlp.py (+ Dockerfile/compose) — runnable end-to-end examples

2. Small, deliberate touches to shared/tabular code

  • shapash/backend/{__init__,base_backend,shap_backend}.py — generalized Backend base class so get_backend_cls_from_name covers both tabular and text backends; BaseBackend now extends it (tabular backends are unaffected)
  • shapash/webapp/smart_app.py — fixes an index-selection click-relay bug in the tabular webapp (n_submit/n_clicks were treated as booleans instead of counters, which could drop triggers); own test in test_smart_app_click_relay.py
  • shapash/plots/plot_contribution.py, plot_evaluation_metrics.py, style/colors.json, style/style_utils.py, webapp/assets/style.css — shared styling/utility additions consumed by the new NLP plots, reused as-is by tabular code
  • Confusion-matrix and other fixes carried over from develop merges (plotly/scikit-learn upgrade compatibility)

3. Packaging & CI

  • New nlp extra (torch, transformers, datasets, sentence-transformers, sentencepiece, protobuf, captum, pacmap) — opt-in, uv sync --extra nlp or pip install ".[nlp]"
  • Dev/test dependencies migrated to PEP 735 [dependency-groups]; the old all extra is removed
  • CI now runs the full suite including NLP (CPU-only torch, HF model caching) across Python 3.11–3.14, with coverage on 3.12 only; a pandas_compat job explicitly asserts the NLP stack stays absent to guard the classical (non-NLP) install path
  • CONTRIBUTING.md rewrite, PR template nudge to target develop, new .dockerignore

Why a separate stack, not a SmartExplainer extension

Text classification needs a materially different pipeline (tokenization, gradient/embedding access, counterfactual search, label-noise diagnostics) that doesn't map onto compile()'s tabular assumptions. Building it as a capability-based Model/Interpreter/Generator split (similar to Google PAIR's LIT) alongside SmartExplainer lets it ship and be used now, while keeping the pieces (TextModel capabilities, Backend, CounterfactualGenerator, WebappComponent) positioned to generalize into a unified API later rather than being a throwaway prototype. See "Why a separate stack" in the linked doc for the full reasoning.

Type of change

  • New feature (non-breaking — adds a new opt-in module, no existing functionality changes)
  • Bug fix (tabular webapp click-relay fix, shapash/webapp/smart_app.py)
  • This change requires a documentation update (added: docs/wiki/NLP-Explainability.md)

How has this been tested?

  • Full unit test suite added alongside every new module (tests/unit_tests/{model,compute,backend,explainer,webapp,plots}/...), including dedicated coverage for each of the 10 new webapp panels and all 8 new plot functions
  • Integration tests under tests/integration_tests/ exercise the stack against a real (tiny) HF model; NLP test modules are marked pytest.mark.nlp and skip cleanly when the nlp extra isn't installed
  • CI matrix validates both paths: full suite with NLP across Python 3.11–3.14 (coverage on 3.12), plus a pandas_compat job confirming the classical install has no NLP dependencies
  • Manual validation via demo/serve_nlp.py against bhadresh-savani/distilbert-base-uncased-emotion / dair-ai/emotion (cached run in demo/nlp_cache/) and tutorial/nlp/overview.ipynb

Test configuration:

  • OS: Linux
  • Python version: 3.11+
  • Shapash version: this branch (feature/nlp-refacto)

Checklist

  • My code follows the style guidelines of this project (ruff/mypy hooks on shapash/)
  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation (docs/wiki/NLP-Explainability.md)
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

after the user picks a token (e.g. great, terrible), color all points by that token's SHAP contribution
@milton-minervino milton-minervino self-assigned this Sep 21, 2026
@milton-minervino milton-minervino added the enhancement New feature or request label Sep 21, 2026
@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.06335% with 174 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.08%. Comparing base (b06e36c) to head (f3b1e20).

Files with missing lines Patch % Lines
shapash/explainer/nlp_explainer.py 88.92% 21 Missing and 9 partials ⚠️
shapash/model/torch_models.py 77.34% 19 Missing and 10 partials ⚠️
shapash/model/encoder.py 94.32% 15 Missing and 1 partial ⚠️
shapash/webapp/nlp_app.py 94.22% 10 Missing and 3 partials ⚠️
shapash/backend/nlp_lime_backend.py 88.76% 5 Missing and 5 partials ⚠️
shapash/webapp/nlp_components/similar_examples.py 90.00% 4 Missing and 6 partials ⚠️
shapash/model/hf.py 93.69% 6 Missing and 1 partial ⚠️
shapash/explainer/nlp_plotter.py 96.42% 1 Missing and 5 partials ⚠️
shapash/backend/nlp_captum_lig_backend.py 95.14% 2 Missing and 3 partials ⚠️
shapash/plots/plot_waterfall.py 90.90% 3 Missing and 2 partials ⚠️
... and 18 more
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #775      +/-   ##
===========================================
+ Coverage    80.16%   86.08%   +5.92%     
===========================================
  Files           59      115      +56     
  Lines         7410    11825    +4415     
  Branches      1557     2158     +601     
===========================================
+ Hits          5940    10180    +4240     
- Misses         901     1009     +108     
- Partials       569      636      +67     
Files with missing lines Coverage Δ
shapash/_optional.py 100.00% <100.00%> (ø)
shapash/backend/__init__.py 86.66% <100.00%> (+6.66%) ⬆️
shapash/backend/base_backend.py 77.41% <100.00%> (ø)
shapash/compute/backend_comparison.py 100.00% <100.00%> (ø)
shapash/compute/diagnostics/__init__.py 100.00% <100.00%> (ø)
shapash/compute/diagnostics/label_probe.py 100.00% <100.00%> (ø)
shapash/compute/embedding_store.py 100.00% <100.00%> (ø)
shapash/compute/embeddings.py 100.00% <100.00%> (ø)
shapash/compute/generators/__init__.py 100.00% <100.00%> (ø)
shapash/compute/generators/cf_utils.py 100.00% <100.00%> (ø)
... and 50 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

milton-minervino and others added 18 commits September 30, 2026 16:53
…ce SHAP

- NlpExplanation carries output_space, is_signed and baseline_token; all
  three are saved to meta.json and output_space/baseline_token enter the
  cache key
- NlpShapBackend(output_space="logit") explains raw logits via the new
  SupportsLogits capability
- LIG uses [MASK] as its baseline token (like SHAP) and defaults to
  n_steps=100
- serve_nlp.py gains --output-space; the webapp info box shows the space
- NlpLimeBackend accepts a TextModel, like the SHAP and LIG backends: it scores through `predict` and takes `label_names` from the model unless you pass your own.
- NlpLimeBackend without `label_names` now asks the model for its output width and explains every class. Before, LIME explained class 1 only and the contributions were silently zero.
- NlpExplainer._predict accepts a bare scoring callable that returns an (n_texts, n_classes) array (e.g. sklearn's predict_proba), with clear errors for a non-2-D array or mismatched label_names.
- explain(cache_dir=...) refuses a bare callable: it has no identity, so two different callables would share cache entries. HFPipelineModel.model_id now includes the pipeline's checkpoint for the same reason.
- The webapp honours `is_additive`: the Waterfall tab is not mounted and the "Base + Σ = Total" summary is dropped (webapp and `.plot.sentence`) for non-additive explanations such as LIME.
- Rewrite the NlpLimeBackend and NlpExplainer docstrings with runnable examples (TextModel, sklearn predict_proba, HF pipeline).

Tests: merge test_captum_backends.py into test_nlp_backends.py, with one section per backend (SHAP, LIG, LIME) plus the cross-backend checks. Only the LIME tests are gated on the `lime` extra. Move the AblationFlip flip test to test_nlp_whatif.py and drop the pipeline auto-bind test, which that file already covered. Add unit tests for the fixes above.
- default num_features to "all", sized per text from LIME's own split
- cast vocabulary tokens to plain str
- add show_progress, sharing _progress_iter on NlpBackend with LIG
- fix random_state=0 in the docstring examples; document passing it as an int
- add explanation.plot.compare(others, kind="heatmap"|"bars"|"highlight")
- align each backend's word units to the reference by character span
  (NaN where a backend attributes nothing, e.g. punctuation for LIME)
- normalise per backend (max_abs by default, sum_abs or raw)
- show Spearman and top-k overlap against the reference in the subtitle
- warn when the explanations are in different output spaces
the two tests used assertWarns, which loops over every module in sys.modules and reads __warningregistry__. Once other tests have loaded transformers, that read triggers its lazy imports, and one of them pulls in torchvision which is not installed
- corpus_agreement() scores every text × backend pair; plot.compare_corpus() shows the distribution
- add pearson, cosine and per-backend top-word share to the agreement scores
- rename compute/token_alignment.py to compute/backend_comparison.py; add explainer/nlp_comparison.py
Each NLP backend now records, for every unit it explains, the (start, end)
character spans of the source text that unit covers. These spans are a
coordinate all backends can share, even though they cut a text into units
differently and have no common token axis.

- New shapash.compute.spans: build spans from tokenizer offsets, trim
  whitespace, partition a text into groups every unit fits in, aggregate
  unit values onto those groups, and locate spans from strings for older
  artifacts.
- NlpContributions and NlpExplanation carry token_spans. The .xpl file
  stores them in token_spans.parquet. Older files load without spans.
- SHAP reads the masker tokenizer's offsets. Words are taken from the
  source text, so characters shared by two tokens are no longer
  duplicated, and tokens that share a character merge into one word.
- LIG reads offsets through the new SupportsCaptumIG.token_offsets().
  Words are shown as written (case, accents, emoji), and units covering
  only whitespace are folded into the baseline, so attributions still
  sum to the total.
- LIME records every occurrence of each word.
- The cache key now includes a unit-layout version, so cached
  explanations are recomputed once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Backend comparison now matches units by the characters they cover
instead of searching for their strings in the text. String search failed
silently on every tokenizer except DistilBERT's: words were dropped
(stripped accents, glued punctuation, emoji) or given another word's
value (naive vs naïve).

- align_units merges all backends' units into the finest groups every
  unit fits in, then sums each backend's values per group. A group with
  no unit is NaN. Where one backend's unit covers several of another's
  ("didn't" against "didn" "'" "t"), the finer units are summed into it,
  so no attribution is lost. LIME's single weight per word counts at
  each occurrence.
- align_row reads all backends on these shared units instead of the
  reference backend's words.
- align_token_values and locate_tokens are removed.
- Explanations saved without spans fall back to string search, and
  check_comparable warns about it.
- backend_agreement adds a coverage score: the share of groups both
  backends attribute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs the real SHAP, LIG and LIME backends on six tokenizer families:
WordPiece (uncased), byte-level BPE, ModernBERT BPE, and SentencePiece
(XLM-R unigram, uncased ALBERT, DeBERTa-v3). The input texts are chosen
to break string matching: accents (precomposed and decomposed), emoji,
curly quotes, full-width characters, double spaces and newlines,
contractions, and CJK. The tests check that every unit is placed and
reads as written, that SHAP and LIG cover the same characters, that
SHAP stays additive, that alignment keeps each backend's total, and
that truncation shows up as partial coverage.

The models are tiny and randomly initialised from each checkpoint's
config, so only the config and tokenizer are downloaded.

SHAP's Text masker rebuilds its input from segments. That can collapse
whitespace and repeat characters shared by two tokens, so the
additivity test checks against the string the masker actually builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some docstrings and comments pointed to design notes that are not part
of the repository. Those references are removed; the explanations
stay in the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
shap.maskers.Text re-tokenizes masked strings, so the model can see another text ("était" -> "éétait", collapsed whitespace), and hiding a token changes its neighbours ("Ġlove" -> "love").

- NlpShapBackend masks the model's own encoding when the model exposes token ids with a fast tokenizer: hidden tokens take LIG's [MASK] reference ids, and each word is one branch of the Partition hierarchy. Other models, an explicit masker= or explainer_args keep the string path; backend.masking reports which path ran. NlpExplainer's default backend takes the new path.
- A shared WordLayout (compute/word_layout.py) builds words, spans and baseline folding for both SHAP and LIG, so they report identical units.
- The cache key uses shapash.__version__ instead of a units layout version.
- Tests: exact token-id SHAP values on an additive fake model, word layout unit tests, and integration checks across six tokenizer families (additivity against the text, SHAP/LIG unit identity, SHAP vs LIME in probability space, LIG fallbacks without offsets).
@milton-minervino
milton-minervino marked this pull request as ready for review October 13, 2026 14:46
@milton-minervino
milton-minervino merged commit 520e9c7 into develop Oct 14, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants