Feat/explainer story i18n - #803
Conversation
…ainer; introduce story formatting utilities
…improved narrative structure
…nd enhance plot retrieval methods
- Added story generation methods to RegressionKernelShap, RegressionPartialDependence, RegressionPermutationFeatureImportance, and TokenAblation explainers to provide narrative summaries of predictions and feature contributions. - Updated plot methods to remove text artifacts and focus solely on visual representations. - Introduced StoryBox component in the frontend to display narrative summaries alongside artifacts. - Modified existing tests to validate the new story functionality and ensure compatibility with the updated plot outputs. - Updated localization files to include new keys for narrative summaries in multiple languages.
…and regression explainers with multilingual support
cristian-tamblay
left a comment
There was a problem hiding this comment.
Blocking: explanations created before v0.9.7 will 500 on plot load
_attach_stories walks the raw pickle and its normalized counterpart with
zip(..., strict=True), which assumes the pickle is always a list. That only
holds for data written after explainer_job.py started normalizing before
pickling (58c6262dc, 2026-07-10). Tag v0.9.6 is from 2026-06-29, so any
explanation generated on 0.9.5/0.9.6 is still stored in its raw shape: a bare
plotly string or a bare dict.
Running the PR code against those shapes:
raw = bare plotly string -> ValueError: zip() argument 2 is shorter than argument 1
raw = bare dict -> ValueError: zip() argument 2 is shorter than argument 1
raw = bare dict (local) -> KeyError: 0
raw = list (current data) -> OK
When raw is a string, zip iterates its characters; when it is a dict, it
iterates its keys. The KeyError: 0 comes from _is_grouped_raw(raw[0]).
Neither call site catches it (explainers.py:491 and :790), so it surfaces
as a 500. These explanations render fine today because normalize_artifacts
handles the legacy shapes, so this is a regression for existing users.
Fix: mirror normalize_artifacts' own wrapping at the top of _attach_stories
if isinstance(raw, (str, dict, Artifact, GroupedArtifacts)):
raw = [raw]and wrap the call site in a try/except, so the "never raises" contract in the
docstring actually holds and any future mismatch degrades to "no story" instead
of taking down the plot.
test_explainer_story_attach.py only covers the current normalized shape.
Worth adding the two legacy cases.
Fixed by mirroring normalize_artifacts' own wrapping at the top of _attach_stories. Wrapped both call sites (:491 and :790, now shifted a few lines) in try/except, logging a warning and falling back to "no story" instead of a 500 on any future shape mismatch, so the "never raises" contract in the docstring actually holds. Added the two legacy-shape regression tests in test_explainer_story_attach.py (bare string for the global path, bare dict for the local/create_grouped=True path). Both reproduce the original ValueError/KeyError against the pre-fix code and pass now. |
Summary
Adds a
story()narrative hook to every explainer (13/13), so global and local explanations come with a deterministic, human-readable summary in addition to their plots. Stories are generated in all 5 supported languages (en,es,pt,de,zh) at once and returned alongside the plot response, so the frontend can switch languages instantly (via the existing i18n selector) without refetching. Also removes redundant hardcoded English text that a subset of explainers previously baked into their plots, now superseded by the multilingualstory()output.Type of Change
Changes (by file)
Core story infrastructure
DashAI/back/explainability/story.py: newformat_story()/concat_stories()helpers that build aMultilingualStringfrom per-language templates, reused by every explainer instead of duplicating 5-language boilerplate.DashAI/back/explainability/global_explainer.py,local_explainer.py: added an optionalstory(explanation, explainer_output)hook toBaseGlobalExplainer/BaseLocalExplainer(defaultNone), mirroring the existingplot()contract.DashAI/back/explainability/explainers/ (Explainers)permutation_feature_importance.py,regression_permutation_feature_importance.py: newstory()ranking features by importance; only claims the model "relies on" features with measurable (>0) importance, calling out separately when the rest showed none instead of lumping them together.partial_dependence.py,regression_partial_dependence.py: newstory()classifying each curve as increasing/decreasing/flat/non-monotonic from its actual values; added a dedicated "flat" case for curves with no real change (previously misreported as "increases").kernel_shap.py: newstory(); removed the old English-only Plotly annotation baked into the chart (predicted class/probability), now covered — with more detail — bystory().contrastive_shap.py,dice_counterfactual.py,nearest_counterfactual.py,regression_kernel_shap.py,token_ablation.py,grad_cam.py,occlusion_saliency.py,lime_text.py: migrated each explainer's existing hardcoded EnglishTextArtifactsummary into a multilingualstory()with equivalent wording, and removed the now-redundantTextArtifactfromplot().**
DashAI/back/api/api_v1/endpoints/explainers.py(API)**GET /global/plot/{id}andGET /local/plot/{id}now also loadexplanation_path(previously onlyplot_path/plots_path) and attach a"story"key ({"en": ..., "es": ..., ...}ornull) to each artifact/group in the response._resolve_story_explainer()to reconstruct the explainer from its registered class + stored parameters (no trained model needed —story()never touches it) and_attach_stories()/_attach_one_story()to compute and attach stories per artifact, never raising: any failure (unbuildable explainer, mismatched shape) just leaves"story": null._as_group_target()/_as_artifact_target()to reconstruct a real (unvalidated)ArtifactGroup/Artifactfrom the wire-format dict thatexplainer_job.pyactually persists (it normalizesplot()'s output before pickling), sostory()'sisinstancechecks work against real data, not just hand-built test objects.DashAI/front/src/components/explainers/(Frontend)StoryBox.jsx: new component that reads the active i18next language fromstoryand renders it as a plain "text" artifact through the existingArtifactViewer— same box, same download button, nothing new to maintain visually.ExplainersPlot.jsx: rendersStoryBoxunder each grouped and ungrouped artifact.utils/i18n/locales/{en,es,pt,de,zh}/explainers.json: added thestoryTitlelabel.Tests
tests/back/api/test_explainer_story_attach.py(new): covers_attach_storiesagainst the real dict-shaped outputexplainer_job.pypersists (not hand-built Pydantic objects), including the no-explainer no-op case.tests/back/explainers/test_image_explainers.py,test_lib_explainers.py,test_new_explainers.py,test_task_explainers.py: updated assertions that expected the now-removedTextArtifactinplot()'s output; assertstory()'s content directly instead.Testing