diff --git a/DashAI/back/api/api_v1/endpoints/explainers.py b/DashAI/back/api/api_v1/endpoints/explainers.py
index 617afa2bd..af2be2d04 100755
--- a/DashAI/back/api/api_v1/endpoints/explainers.py
+++ b/DashAI/back/api/api_v1/endpoints/explainers.py
@@ -1,4 +1,5 @@
import logging
+from dataclasses import asdict
from typing import TYPE_CHECKING
from fastapi import APIRouter, Depends, status
@@ -13,6 +14,7 @@
ValidateDatasetParams,
ValidDatasetsParams,
)
+from DashAI.back.core.artifacts import Artifact, ArtifactGroup, GroupedArtifacts
from DashAI.back.core.enums.status import ExplainerStatus
from DashAI.back.dependencies.database.models import (
Dataset,
@@ -25,6 +27,8 @@
if TYPE_CHECKING:
from sqlalchemy.orm import sessionmaker
+ from DashAI.back.dependencies.registry.component_registry import ComponentRegistry
+
logging.basicConfig(level=logging.DEBUG)
log = logging.getLogger(__name__)
@@ -83,6 +87,204 @@ def collect_leaves(items):
return artifacts
+def _resolve_story_explainer(
+ explainer_name: str,
+ parameters: dict | None,
+ component_registry: "ComponentRegistry",
+):
+ """Instantiate an explainer to compute stories, without its trained model.
+
+ ``story()`` only needs the explanation dict already computed by the job
+ and the explainer's own configuration parameters, never the trained
+ model, so this avoids reloading it just to narrate an existing plot.
+
+ Parameters
+ ----------
+ explainer_name : str
+ Registered component name of the explainer (e.g. ``"KernelShap"``).
+ parameters : dict or None
+ The explainer's configuration parameters, as stored in the database.
+ component_registry : ComponentRegistry
+ Registry used to resolve ``explainer_name`` to its class.
+
+ Returns
+ -------
+ BaseGlobalExplainer or BaseLocalExplainer or None
+ The explainer instance, or ``None`` if it could not be built (logged,
+ never raised: a story is a nice-to-have, not required to see a plot).
+ """
+ try:
+ explainer_class = component_registry[explainer_name]["class"]
+ return explainer_class(model=None, **(parameters or {}))
+ except Exception as e:
+ log.warning("Could not build '%s' to compute its story: %s", explainer_name, e)
+ return None
+
+
+def _as_group_target(value) -> ArtifactGroup:
+ """Coerce a raw group into a real (unvalidated) ``ArtifactGroup``.
+
+ ``explainer_job.py`` always runs ``plot()``'s output through
+ ``normalize_artifacts`` *before* pickling it, so ``value`` is normally
+ a wire-format dict (``{"title": ..., "artifacts": [...]}``), not the
+ live ``ArtifactGroup`` ``plot()`` returned. Global explainers'
+ ``story()`` implementations tell a group from a lone top-level artifact
+ via ``isinstance(x, ArtifactGroup)``, so a generic ``.title``-only shim
+ would silently fail that check — ``model_construct`` builds a real
+ instance (skipping validation, since we only need ``.title`` and the
+ dict's artifacts may carry stray keys like ``"index"`` anyway).
+
+ Parameters
+ ----------
+ value : ArtifactGroup or dict
+ The raw group, as loaded straight from the pickle.
+
+ Returns
+ -------
+ ArtifactGroup
+ ``value`` unchanged if already one, otherwise a group exposing the
+ dict's ``"title"``.
+ """
+ if isinstance(value, ArtifactGroup):
+ return value
+ title = value.get("title") if isinstance(value, dict) else None
+ return ArtifactGroup.model_construct(title=title, artifacts=[])
+
+
+def _as_artifact_target(value) -> Artifact:
+ """Coerce a raw top-level (ungrouped) item into a real ``Artifact``.
+
+ Mirrors :func:`_as_group_target` for the lone-artifact case (e.g. the
+ single bar chart a regression permutation-importance explainer
+ returns, with no "Top N" selector around it).
+
+ Parameters
+ ----------
+ value : Artifact or dict
+ The raw artifact, as loaded straight from the pickle.
+
+ Returns
+ -------
+ Artifact
+ ``value`` unchanged if already one, otherwise an artifact exposing
+ the dict's ``"title"``.
+ """
+ if isinstance(value, Artifact):
+ return value
+ title = value.get("title") if isinstance(value, dict) else None
+ return Artifact.model_construct(title=title)
+
+
+def _attach_one_story(
+ explainer, explanation: dict, raw_output, wire_item: dict
+) -> None:
+ """Call ``explainer.story()`` for one artifact and embed it in its wire dict.
+
+ Parameters
+ ----------
+ explainer : BaseGlobalExplainer or BaseLocalExplainer
+ The explainer instance to narrate with.
+ explanation : dict
+ The explanation dictionary passed through to ``story()``.
+ raw_output : Artifact or ArtifactGroup
+ The artifact/group identifying what to narrate — already coerced
+ by :func:`_as_group_target`/:func:`_as_artifact_target`.
+ wire_item : dict
+ The corresponding wire-format dict; mutated in place with a
+ ``"story"`` key holding ``{"en": ..., "es": ..., ...}`` or ``None``.
+ """
+ try:
+ story = explainer.story(explanation, raw_output)
+ except Exception as e:
+ log.warning("Story generation failed: %s", e)
+ story = None
+ wire_item["story"] = asdict(story) if story is not None else None
+
+
+def _is_grouped_raw(value) -> bool:
+ """Match ``normalize_artifacts``' own notion of "already grouped".
+
+ True for a live ``GroupedArtifacts`` instance, but also — the normal
+ case, since ``explainer_job.py`` always normalizes before pickling —
+ for a wire-format dict (``{"type": "grouped", ...}``).
+
+ Parameters
+ ----------
+ value : Any
+ A raw item from a pickled ``plot()``/``explain*`` result.
+
+ Returns
+ -------
+ bool
+ Whether ``normalize_artifacts`` would treat this as a grouped item.
+ """
+ return isinstance(value, GroupedArtifacts) or (
+ isinstance(value, dict) and value.get("type") == "grouped"
+ )
+
+
+def _attach_stories(
+ normalized: list,
+ raw: list,
+ explanation: dict,
+ explainer,
+ create_grouped: bool = False,
+) -> None:
+ """Attach a per-language ``"story"`` dict to every matching wire artifact.
+
+ Walks ``raw`` (the pickled artifacts/groups returned by ``plot()``,
+ normally already wire-format dicts — see :func:`_as_story_target`) and
+ ``normalized`` (their wire-format counterparts, in the same order) in
+ lockstep, so each can be passed to ``explainer.story()`` alongside the
+ artifact it actually describes. A no-op if ``explainer`` is ``None`` (it
+ couldn't be built). Never raises: a group whose raw shape does not match
+ what ``story()`` expects just gets no story, handled inside
+ :func:`_attach_one_story`.
+
+ Parameters
+ ----------
+ normalized : list
+ Wire-format dicts from ``normalize_artifacts``; mutated in place.
+ raw : list
+ The pickled artifacts/groups ``normalized`` was built from.
+ explanation : dict
+ The explanation dictionary passed through to ``story()``.
+ explainer : BaseGlobalExplainer or BaseLocalExplainer or None
+ The explainer instance to narrate with.
+ create_grouped : bool
+ Must match the flag passed to ``normalize_artifacts``: when ``True``
+ and ``raw`` is a flat list of leaf artifacts, ``normalized`` was
+ collapsed into a single synthetic grouped item (one group per leaf).
+ """
+ if explainer is None:
+ return
+
+ if create_grouped and raw and not _is_grouped_raw(raw[0]):
+ wire_groups = normalized[0].get("groups", []) if normalized else []
+ for raw_leaf, wire_group in zip(raw, wire_groups, strict=True):
+ _attach_one_story(
+ explainer, explanation, _as_artifact_target(raw_leaf), wire_group
+ )
+ return
+
+ for raw_item, wire_item in zip(raw, normalized, strict=True):
+ if _is_grouped_raw(raw_item):
+ raw_groups = (
+ raw_item.groups
+ if isinstance(raw_item, GroupedArtifacts)
+ else raw_item.get("groups", [])
+ )
+ wire_groups = wire_item.get("groups", [])
+ for raw_group, wire_group in zip(raw_groups, wire_groups, strict=True):
+ _attach_one_story(
+ explainer, explanation, _as_group_target(raw_group), wire_group
+ )
+ else:
+ _attach_one_story(
+ explainer, explanation, _as_artifact_target(raw_item), wire_item
+ )
+
+
class PlotOverrideBody(BaseModel):
"""Request body for saving one plot override.
@@ -213,6 +415,7 @@ async def get_global_explanation(
async def get_global_explanation_plot(
explainer_id: int,
session_factory: "sessionmaker" = Depends(lambda: di["session_factory"]),
+ component_registry: "ComponentRegistry" = Depends(lambda: di["component_registry"]),
):
"""Returns the global explanation plot associated with id explainer_id.
@@ -223,12 +426,17 @@ async def get_global_explanation_plot(
session_factory : Callable[..., ContextManager[Session]]
A factory that creates a context manager that handles a SQLAlchemy session.
The generated session can be used to access and query the database.
+ component_registry : ComponentRegistry
+ Registry used to resolve the explainer's class, needed to compute a
+ story for explainers that implement one.
Returns
-------
List[dict]
A list of artifact dicts (``{"type", "payload", "title"}``) with the
- explanation plots.
+ explanation plots. Explainers that implement ``story()`` also carry a
+ ``"story"`` key (``{"en": ..., "es": ..., ...}`` or ``None``),
+ computed fresh on every call rather than persisted.
Raises
------
@@ -260,9 +468,14 @@ async def get_global_explanation_plot(
plot_path = global_explainer[0].plot_path
plot_overrides = global_explainer[0].plot_overrides
+ explanation_path = global_explainer[0].explanation_path
+ explainer_name = global_explainer[0].explainer_name
+ parameters = global_explainer[0].parameters
with open(plot_path, "rb") as file:
plot = pickle.load(file)
+ with open(explanation_path, "rb") as file:
+ explanation = pickle.load(file)
except exc.SQLAlchemyError as e:
log.exception(e)
@@ -271,7 +484,12 @@ async def get_global_explanation_plot(
detail="Internal database error",
) from e
- return _apply_overrides(normalize_artifacts(plot), plot_overrides)
+ artifacts = _apply_overrides(normalize_artifacts(plot), plot_overrides)
+ story_explainer = _resolve_story_explainer(
+ explainer_name, parameters, component_registry
+ )
+ _attach_stories(artifacts, plot, explanation, story_explainer)
+ return artifacts
@router.post("/global", status_code=status.HTTP_201_CREATED)
@@ -497,6 +715,7 @@ async def get_local_explanation(
async def get_local_explanation_plot(
explainer_id: int,
session_factory: "sessionmaker" = Depends(lambda: di["session_factory"]),
+ component_registry: "ComponentRegistry" = Depends(lambda: di["component_registry"]),
):
"""Returns the local explanation plot associated with id explainer_id.
@@ -507,12 +726,18 @@ async def get_local_explanation_plot(
session_factory : Callable[..., ContextManager[Session]]
A factory that creates a context manager that handles a SQLAlchemy session.
The generated session can be used to access and query the database.
+ component_registry : ComponentRegistry
+ Registry used to resolve the explainer's class, needed to compute a
+ story for explainers that implement one.
Returns
-------
List[dict]
A list of artifact dicts (``{"type", "payload", "title"}``) with the
- explanation plots, typically one per explained instance.
+ explanation plots, typically one per explained instance. Explainers
+ that implement ``story()`` also carry a ``"story"`` key (``{"en":
+ ..., "es": ..., ...}`` or ``None``), computed fresh on every call
+ rather than persisted.
Raises
------
@@ -544,9 +769,14 @@ async def get_local_explanation_plot(
plots_path = local_explainer[0].plots_path
plot_overrides = local_explainer[0].plot_overrides
+ explanation_path = local_explainer[0].explanation_path
+ explainer_name = local_explainer[0].explainer_name
+ parameters = local_explainer[0].parameters
with open(plots_path, "rb") as file:
plots = pickle.load(file)
+ with open(explanation_path, "rb") as file:
+ explanation = pickle.load(file)
except exc.SQLAlchemyError as e:
log.exception(e)
@@ -555,9 +785,14 @@ async def get_local_explanation_plot(
detail="Internal database error",
) from e
- return _apply_overrides(
+ artifacts = _apply_overrides(
normalize_artifacts(plots, create_grouped=True), plot_overrides
)
+ story_explainer = _resolve_story_explainer(
+ explainer_name, parameters, component_registry
+ )
+ _attach_stories(artifacts, plots, explanation, story_explainer, create_grouped=True)
+ return artifacts
@router.put("/{scope}/plot/{explainer_id}/override")
diff --git a/DashAI/back/explainability/explainers/contrastive_shap.py b/DashAI/back/explainability/explainers/contrastive_shap.py
index 3c6e8243d..1f17a0611 100644
--- a/DashAI/back/explainability/explainers/contrastive_shap.py
+++ b/DashAI/back/explainability/explainers/contrastive_shap.py
@@ -1,10 +1,10 @@
-from typing import List
+import re
+from typing import List, Optional
from DashAI.back.core.artifacts import (
ArtifactGroup,
GroupedArtifacts,
PlotlyArtifact,
- TextArtifact,
)
from DashAI.back.core.schema_fields import (
BaseSchema,
@@ -15,6 +15,7 @@
)
from DashAI.back.core.utils import MultilingualString
from DashAI.back.explainability.local_explainer import BaseLocalExplainer
+from DashAI.back.explainability.story import format_story
from DashAI.back.models.base_model import BaseModel
@@ -399,7 +400,7 @@ def _create_plot(self, data, fact_name, foil_name, fact_prob, foil_prob):
return fig
def plot(self, explanation: dict) -> List[GroupedArtifacts]:
- """Render each instance as a contrastive bar plot plus a text summary.
+ """Render each instance as a contrastive bar plot.
Parameters
----------
@@ -410,7 +411,7 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
-------
List[GroupedArtifacts]
A single grouped artifact with one group per explained instance,
- each holding that instance's contrastive plot and text summary.
+ each holding that instance's contrastive plot.
"""
import numpy as np
import pandas as pd
@@ -449,21 +450,96 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
fig = self._create_plot(data, fact_name, foil_name, fact_prob, foil_prob)
plot = PlotlyArtifact(payload=fig)
- top = data.iloc[::-1].head(3)
- top_features = ", ".join(
- f"{feature}={value}"
- for feature, value in zip(
- top["features"].tolist(),
- top["values"].tolist(),
- strict=True,
- )
- )
- summary = (
- f"The model predicted {fact_name} (p={fact_prob}) rather than "
- f"{foil_name} (p={foil_prob}) mainly because of: "
- f"{top_features}."
- )
- text = TextArtifact(payload=summary)
- groups.append(ArtifactGroup(title=title, artifacts=[plot, text]))
+ groups.append(ArtifactGroup(title=title, artifacts=[plot]))
return [GroupedArtifacts(groups=groups)]
+
+ def story(
+ self, explanation: dict, explainer_output: ArtifactGroup
+ ) -> Optional[MultilingualString]:
+ """Describe, in words, why the fact class beat the foil class.
+
+ Names the fact and foil classes, their probabilities, and the top-3
+ features by ``|fact - foil|`` attribution difference (the same
+ values plotted by :meth:`plot`).
+
+ Parameters
+ ----------
+ explanation : dict
+ Output of :meth:`explain_instance`.
+ explainer_output : ArtifactGroup
+ The group previously returned by :meth:`plot`, titled
+ ``"Instance {n}"``.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if
+ ``explainer_output`` is not a recognised "Instance N" group.
+ """
+ match = re.match(r"Instance (\d+)", explainer_output.title or "")
+ if match is None:
+ return None
+ index = int(match.group(1)) - 1
+ if index not in explanation:
+ return None
+
+ metadata = explanation["metadata"]
+ feature_names = metadata["feature_names"]
+ target_names = metadata["target_names"]
+ instance = explanation[index]
+
+ fact_class = instance["fact_class"]
+ foil_class = instance["foil_class"]
+ fact_name = target_names[fact_class]
+ foil_name = target_names[foil_class]
+ prediction = instance["model_prediction"]
+ fact_prob = round(prediction[fact_class], 3)
+ foil_prob = round(prediction[foil_class], 3)
+
+ ranking = sorted(
+ zip(
+ feature_names,
+ instance["instance_values"],
+ instance["delta_values"],
+ strict=True,
+ ),
+ key=lambda row: abs(row[2]),
+ reverse=True,
+ )
+ top = ranking[:3]
+ top_features = ", ".join(f"{name}={value}" for name, value, _ in top)
+
+ return format_story(
+ {
+ "en": (
+ "The model predicted {fact_name} (p={fact_prob}) rather "
+ "than {foil_name} (p={foil_prob}) mainly because of: "
+ "{top_features}."
+ ),
+ "es": (
+ "El modelo predijo {fact_name} (p={fact_prob}) en lugar "
+ "de {foil_name} (p={foil_prob}) principalmente por: "
+ "{top_features}."
+ ),
+ "pt": (
+ "O modelo previu {fact_name} (p={fact_prob}) em vez de "
+ "{foil_name} (p={foil_prob}) principalmente por: "
+ "{top_features}."
+ ),
+ "de": (
+ "Das Modell sagte eher {fact_name} (p={fact_prob}) als "
+ "{foil_name} (p={foil_prob}) voraus, hauptsächlich "
+ "aufgrund von: {top_features}."
+ ),
+ "zh": (
+ "模型预测为{fact_name}(p={fact_prob})而非{foil_name}"
+ "(p={foil_prob}),主要原因是:{top_features}。"
+ ),
+ },
+ fact_name=fact_name,
+ fact_prob=fact_prob,
+ foil_name=foil_name,
+ foil_prob=foil_prob,
+ top_features=top_features,
+ )
diff --git a/DashAI/back/explainability/explainers/dice_counterfactual.py b/DashAI/back/explainability/explainers/dice_counterfactual.py
index 381fd6e6a..6917fe184 100644
--- a/DashAI/back/explainability/explainers/dice_counterfactual.py
+++ b/DashAI/back/explainability/explainers/dice_counterfactual.py
@@ -1,11 +1,11 @@
-from typing import List
+import re
+from typing import List, Optional
from DashAI.back.core.artifacts import (
ArtifactGroup,
GroupedArtifacts,
TableArtifact,
TablePayload,
- TextArtifact,
)
from DashAI.back.core.schema_fields import (
BaseSchema,
@@ -16,6 +16,7 @@
)
from DashAI.back.core.utils import MultilingualString
from DashAI.back.explainability.local_explainer import BaseLocalExplainer
+from DashAI.back.explainability.story import concat_stories, format_story
from DashAI.back.models.base_model import BaseModel
@@ -376,7 +377,7 @@ def explain_instance(self, instances):
return explanation
def plot(self, explanation: dict) -> List[GroupedArtifacts]:
- """Render each instance as a comparison table plus a text summary.
+ """Render each instance as a comparison table.
Parameters
----------
@@ -387,10 +388,8 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
-------
List[GroupedArtifacts]
A single grouped artifact with one group per explained instance,
- each holding that instance's comparison table and text summary.
+ each holding that instance's comparison table.
"""
- import numpy as np
-
exp = explanation.copy()
metadata = exp.pop("metadata")
feature_names = metadata["feature_names"]
@@ -401,9 +400,6 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
instance = exp[i]
predicted_class = instance["predicted_class"]
predicted_name = target_names[predicted_class]
- predicted_prob = float(
- np.round(instance["model_prediction"][predicted_class], 3)
- )
counterfactuals = instance["counterfactuals"]
columns = ["Feature", "Instance"] + [
@@ -431,24 +427,140 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
table = TableArtifact(
payload=TablePayload(columns=columns, rows=rows, highlight=highlight),
)
+ groups.append(ArtifactGroup(title=title, artifacts=[table]))
- if counterfactuals:
- lines = [f"The model predicted {predicted_name} (p={predicted_prob})."]
- for cf_idx, counterfactual in enumerate(counterfactuals):
- cf_name = target_names[counterfactual["predicted_class"]]
- changed = ", ".join(counterfactual["changed_features"]) or "nothing"
- lines.append(
- f"Counterfactual {cf_idx + 1}: changing {changed} "
- f"yields {cf_name}."
- )
- summary = "\n".join(lines)
+ return [GroupedArtifacts(groups=groups)]
+
+ def story(
+ self, explanation: dict, explainer_output: ArtifactGroup
+ ) -> Optional[MultilingualString]:
+ """Describe, in words, the prediction and each counterfactual found.
+
+ Names the predicted class and probability, then lists every
+ counterfactual with the features it changed and the class it yields
+ (the same values shown in the comparison table from :meth:`plot`).
+
+ Parameters
+ ----------
+ explanation : dict
+ Output of :meth:`explain_instance`.
+ explainer_output : ArtifactGroup
+ The group previously returned by :meth:`plot`, titled
+ ``"Instance {n}"``.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if
+ ``explainer_output`` is not a recognised "Instance N" group.
+ """
+ match = re.match(r"Instance (\d+)", explainer_output.title or "")
+ if match is None:
+ return None
+ index = int(match.group(1)) - 1
+ if index not in explanation:
+ return None
+
+ metadata = explanation["metadata"]
+ target_names = metadata["target_names"]
+ instance = explanation[index]
+
+ predicted_class = instance["predicted_class"]
+ predicted_name = target_names[predicted_class]
+ predicted_prob = round(instance["model_prediction"][predicted_class], 3)
+ counterfactuals = instance["counterfactuals"]
+
+ if not counterfactuals:
+ return format_story(
+ {
+ "en": (
+ "The model predicted {predicted_name} "
+ "(p={predicted_prob}). DiCE could not generate "
+ "counterfactuals for this instance."
+ ),
+ "es": (
+ "El modelo predijo {predicted_name} "
+ "(p={predicted_prob}). DiCE no pudo generar "
+ "contrafactuales para esta instancia."
+ ),
+ "pt": (
+ "O modelo previu {predicted_name} "
+ "(p={predicted_prob}). O DiCE não conseguiu gerar "
+ "contrafactuais para esta instância."
+ ),
+ "de": (
+ "Das Modell sagte {predicted_name} "
+ "(p={predicted_prob}) voraus. DiCE konnte für diese "
+ "Instanz keine kontrafaktischen Beispiele erzeugen."
+ ),
+ "zh": (
+ "模型预测为{predicted_name}(p={predicted_prob})。"
+ "DiCE无法为该实例生成反事实样本。"
+ ),
+ },
+ predicted_name=predicted_name,
+ predicted_prob=predicted_prob,
+ )
+
+ story = format_story(
+ {
+ "en": "The model predicted {predicted_name} (p={predicted_prob}).",
+ "es": "El modelo predijo {predicted_name} (p={predicted_prob}).",
+ "pt": "O modelo previu {predicted_name} (p={predicted_prob}).",
+ "de": "Das Modell sagte {predicted_name} (p={predicted_prob}) voraus.",
+ "zh": "模型预测为{predicted_name}(p={predicted_prob})。",
+ },
+ predicted_name=predicted_name,
+ predicted_prob=predicted_prob,
+ )
+
+ for cf_idx, counterfactual in enumerate(counterfactuals):
+ cf_name = target_names[counterfactual["predicted_class"]]
+ changed_features = counterfactual["changed_features"]
+ if changed_features:
+ changed = ", ".join(changed_features)
+ line = format_story(
+ {
+ "en": (
+ "Counterfactual {n}: changing {changed} yields {cf_name}."
+ ),
+ "es": (
+ "Contrafactual {n}: cambiando {changed} se "
+ "obtiene {cf_name}."
+ ),
+ "pt": (
+ "Contrafactual {n}: alterando {changed} obtém-se {cf_name}."
+ ),
+ "de": (
+ "Kontrafaktisch {n}: Durch Ändern von {changed} "
+ "ergibt sich {cf_name}."
+ ),
+ "zh": "反事实{n}:改变{changed}会得到{cf_name}。",
+ },
+ n=cf_idx + 1,
+ changed=changed,
+ cf_name=cf_name,
+ )
else:
- summary = (
- f"The model predicted {predicted_name} "
- f"(p={predicted_prob}). DiCE could not generate "
- "counterfactuals for this instance."
+ line = format_story(
+ {
+ "en": (
+ "Counterfactual {n}: changing nothing yields {cf_name}."
+ ),
+ "es": (
+ "Contrafactual {n}: sin cambiar nada se obtiene {cf_name}."
+ ),
+ "pt": (
+ "Contrafactual {n}: sem alterar nada obtém-se {cf_name}."
+ ),
+ "de": (
+ "Kontrafaktisch {n}: Ohne Änderungen ergibt sich {cf_name}."
+ ),
+ "zh": "反事实{n}:不改变任何特征即可得到{cf_name}。",
+ },
+ n=cf_idx + 1,
+ cf_name=cf_name,
)
- text = TextArtifact(payload=summary)
- groups.append(ArtifactGroup(title=title, artifacts=[table, text]))
+ story = concat_stories(story, line, separator="\n")
- return [GroupedArtifacts(groups=groups)]
+ return story
diff --git a/DashAI/back/explainability/explainers/grad_cam.py b/DashAI/back/explainability/explainers/grad_cam.py
index d22b95a30..da65b3848 100644
--- a/DashAI/back/explainability/explainers/grad_cam.py
+++ b/DashAI/back/explainability/explainers/grad_cam.py
@@ -1,9 +1,9 @@
-from typing import List
+import re
+from typing import List, Optional
from DashAI.back.core.artifacts import (
ArtifactGroup,
GroupedArtifacts,
- TextArtifact,
)
from DashAI.back.core.schema_fields import (
BaseSchema,
@@ -19,6 +19,7 @@
iter_pil_images,
)
from DashAI.back.explainability.local_explainer import BaseLocalExplainer
+from DashAI.back.explainability.story import format_story
from DashAI.back.models.base_model import BaseModel
@@ -244,7 +245,7 @@ def explain_instance(self, instances):
return explanation
def plot(self, explanation: dict) -> List[GroupedArtifacts]:
- """Render each image as a heatmap overlay plus a text summary.
+ """Render each image as a heatmap overlay.
Parameters
----------
@@ -254,8 +255,8 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
Returns
-------
List[GroupedArtifacts]
- A single grouped artifact with one group per explained image, each
- holding that image's heatmap overlay and text summary.
+ A single grouped artifact with one group per explained image,
+ each holding that image's heatmap overlay.
"""
import numpy as np
@@ -280,13 +281,73 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
overlay = heatmap_overlay_artifact(
instance["image"], instance["heatmap"], title, subtitle
)
- text = TextArtifact(
- payload=(
- f"The model predicted {predicted_name} "
- f"(p={predicted_prob}). Highlighted regions are the "
- "areas whose activations most supported this class."
- ),
- )
- groups.append(ArtifactGroup(title=title, artifacts=[overlay, text]))
+ groups.append(ArtifactGroup(title=title, artifacts=[overlay]))
return [GroupedArtifacts(groups=groups)]
+
+ def story(
+ self, explanation: dict, explainer_output: ArtifactGroup
+ ) -> Optional[MultilingualString]:
+ """Describe, in words, the prediction the heatmap overlay explains.
+
+ Names the predicted class and its probability (the same values used
+ to build the overlay's subtitle in :meth:`plot`).
+
+ Parameters
+ ----------
+ explanation : dict
+ Output of :meth:`explain_instance`.
+ explainer_output : ArtifactGroup
+ The group previously returned by :meth:`plot`, titled
+ ``"Image {n}"``.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if
+ ``explainer_output`` is not a recognised "Image N" group.
+ """
+ match = re.match(r"Image (\d+)", explainer_output.title or "")
+ if match is None:
+ return None
+ index = int(match.group(1)) - 1
+ if index not in explanation:
+ return None
+
+ target_names = explanation["metadata"]["target_names"]
+ instance = explanation[index]
+ predicted_class = instance["predicted_class"]
+ predicted_name = target_names[predicted_class]
+ predicted_prob = round(instance["model_prediction"][predicted_class], 3)
+
+ return format_story(
+ {
+ "en": (
+ "The model predicted {predicted_name} "
+ "(p={predicted_prob}). Highlighted regions are the "
+ "areas whose activations most supported this class."
+ ),
+ "es": (
+ "El modelo predijo {predicted_name} "
+ "(p={predicted_prob}). Las regiones resaltadas son las "
+ "áreas cuyas activaciones más respaldaron esta clase."
+ ),
+ "pt": (
+ "O modelo previu {predicted_name} "
+ "(p={predicted_prob}). As regiões destacadas são as "
+ "áreas cujas ativações mais sustentaram essa classe."
+ ),
+ "de": (
+ "Das Modell sagte {predicted_name} "
+ "(p={predicted_prob}) voraus. Die hervorgehobenen "
+ "Regionen sind die Bereiche, deren Aktivierungen diese "
+ "Klasse am stärksten unterstützten."
+ ),
+ "zh": (
+ "模型预测为{predicted_name}(p={predicted_prob})。"
+ "高亮区域是激活值最支持该类别的区域。"
+ ),
+ },
+ predicted_name=predicted_name,
+ predicted_prob=predicted_prob,
+ )
diff --git a/DashAI/back/explainability/explainers/kernel_shap.py b/DashAI/back/explainability/explainers/kernel_shap.py
index 69b0eb738..c2963af6a 100644
--- a/DashAI/back/explainability/explainers/kernel_shap.py
+++ b/DashAI/back/explainability/explainers/kernel_shap.py
@@ -1,3 +1,4 @@
+import re
from typing import List, Optional
from DashAI.back.core.artifacts import (
@@ -14,6 +15,7 @@
)
from DashAI.back.core.utils import MultilingualString
from DashAI.back.explainability.local_explainer import BaseLocalExplainer
+from DashAI.back.explainability.story import format_story
from DashAI.back.models.base_model import BaseModel
from DashAI.back.types.categorical import Categorical
@@ -436,7 +438,6 @@ def _create_plot(
data,
base_value: float,
y_pred_pbb: float,
- y_pred_name: str,
title: Optional[str] = None,
):
"""Helper method to create the explanation plot using plotly.
@@ -449,8 +450,6 @@ def _create_plot(
value to set where the bar base is drawn.
y_pred_pbb: float
predicted probability.
- y_pred_name
- name of the predicted class.
title: Optional[str]
title of the resulting artifact.
@@ -505,24 +504,6 @@ def _create_plot(
showgrid=True,
)
- plot_note = (
- f"The predicted class was {y_pred_name} with probability f(x)={y_pred_pbb}."
- )
-
- fig.add_annotation(
- align="center",
- arrowsize=0.3,
- arrowwidth=0.1,
- font={"size": 12},
- showarrow=False,
- text=plot_note,
- xanchor="center",
- yanchor="bottom",
- xref="paper",
- yref="paper",
- y=-0.27,
- )
-
return PlotlyArtifact(payload=fig, title=title)
def plot(self, explanation: dict) -> List[GroupedArtifacts]:
@@ -546,7 +527,6 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
metadata = exp.pop("metadata")
base_values = exp.pop("base_values")
feature_names = metadata["feature_names"]
- target_names = metadata["target_names"]
# Normaliza feature_names a 1D
# Lazy import heavy libs
@@ -560,7 +540,6 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
instance_values = exp[i]["instance_values"]
model_prediction = exp[i]["model_prediction"]
y_pred_class = int(np.argmax(model_prediction))
- y_pred_name = target_names[y_pred_class]
y_pred_pbb = float(np.round(model_prediction[y_pred_class], 2))
# --- Normaliza valores de la instancia a 1D
@@ -660,10 +639,104 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
data,
base_value,
y_pred_pbb,
- y_pred_name,
)
groups.append(
ArtifactGroup(title=f"Instance {instance_number}", artifacts=[plot])
)
return [GroupedArtifacts(groups=groups)]
+
+ def story(
+ self, explanation: dict, explainer_output: ArtifactGroup
+ ) -> Optional[MultilingualString]:
+ """Describe, in words, the prediction and top SHAP contributors.
+
+ Names the predicted class, its probability, and the top-3 features
+ by absolute SHAP value for the predicted class (the same values
+ plotted by :meth:`plot`).
+
+ Parameters
+ ----------
+ explanation : dict
+ Output of :meth:`explain_instance`.
+ explainer_output : ArtifactGroup
+ The group previously returned by :meth:`plot`, titled
+ ``"Instance {n}"``.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if
+ ``explainer_output`` is not a recognised "Instance N" group.
+ """
+ match = re.match(r"Instance (\d+)", explainer_output.title or "")
+ if match is None:
+ return None
+ index = int(match.group(1)) - 1
+ if index not in explanation:
+ return None
+
+ # Lazy import
+ import numpy as np
+
+ feature_names = explanation["metadata"]["feature_names"]
+ target_names = explanation["metadata"]["target_names"]
+ instance = explanation[index]
+
+ model_prediction = np.asarray(instance["model_prediction"])
+ predicted_class = int(np.argmax(model_prediction))
+ predicted_name = target_names[predicted_class]
+ predicted_prob = float(model_prediction[predicted_class])
+
+ class_shap_values = np.asarray(instance["shap_values"])[predicted_class]
+ ranking = sorted(
+ zip(feature_names, class_shap_values, strict=True),
+ key=lambda pair: abs(pair[1]),
+ reverse=True,
+ )
+ top = ranking[:3]
+ feature_list = ", ".join(f"{name} ({value:+.3f})" for name, value in top)
+
+ return format_story(
+ {
+ "en": (
+ "The model predicted '{predicted_name}' with probability "
+ "{predicted_prob:.2f}. The features that contributed most "
+ "to this prediction (SHAP values) were: {feature_list}; "
+ "positive values push the prediction toward "
+ "'{predicted_name}', negative values push it away."
+ ),
+ "es": (
+ "El modelo predijo '{predicted_name}' con probabilidad "
+ "{predicted_prob:.2f}. Las características que más "
+ "contribuyeron a esta predicción (valores SHAP) fueron: "
+ "{feature_list}; los valores positivos empujan la "
+ "predicción hacia '{predicted_name}', los negativos la "
+ "alejan."
+ ),
+ "pt": (
+ "O modelo previu '{predicted_name}' com probabilidade "
+ "{predicted_prob:.2f}. As características que mais "
+ "contribuíram para essa previsão (valores SHAP) foram: "
+ "{feature_list}; valores positivos empurram a previsão "
+ "em direção a '{predicted_name}', valores negativos a "
+ "afastam."
+ ),
+ "de": (
+ "Das Modell sagte '{predicted_name}' mit einer "
+ "Wahrscheinlichkeit von {predicted_prob:.2f} voraus. Die "
+ "Merkmale, die am meisten zu dieser Vorhersage "
+ "beigetragen haben (SHAP-Werte), waren: {feature_list}; "
+ "positive Werte verstärken die Vorhersage in Richtung "
+ "'{predicted_name}', negative Werte schwächen sie ab."
+ ),
+ "zh": (
+ "模型预测为'{predicted_name}',概率为{predicted_prob:.2f}。"
+ "对该预测贡献最大的特征(SHAP值)是:{feature_list};"
+ "正值表示推动预测趋向'{predicted_name}',负值表示相反。"
+ ),
+ },
+ predicted_name=predicted_name,
+ predicted_prob=predicted_prob,
+ feature_list=feature_list,
+ )
diff --git a/DashAI/back/explainability/explainers/lime_text.py b/DashAI/back/explainability/explainers/lime_text.py
index 41fdd8e74..dbf9842ac 100644
--- a/DashAI/back/explainability/explainers/lime_text.py
+++ b/DashAI/back/explainability/explainers/lime_text.py
@@ -1,10 +1,10 @@
-from typing import List
+import re
+from typing import List, Optional
from DashAI.back.core.artifacts import (
ArtifactGroup,
GroupedArtifacts,
PlotlyArtifact,
- TextArtifact,
)
from DashAI.back.core.schema_fields import (
BaseSchema,
@@ -13,6 +13,7 @@
)
from DashAI.back.core.utils import MultilingualString
from DashAI.back.explainability.local_explainer import BaseLocalExplainer
+from DashAI.back.explainability.story import format_story
from DashAI.back.models.base_model import BaseModel
@@ -245,7 +246,7 @@ def classifier_fn(variant_texts):
return explanation
def plot(self, explanation: dict) -> List[GroupedArtifacts]:
- """Render each instance as a word weight bar plot plus a summary.
+ """Render each instance as a word weight bar plot.
Parameters
----------
@@ -256,7 +257,7 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
-------
List[GroupedArtifacts]
A single grouped artifact with one group per explained instance,
- each holding that instance's word weight plot and text summary.
+ each holding that instance's word weight plot.
"""
import numpy as np
import plotly.graph_objs as go
@@ -308,15 +309,79 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
title = f"Instance {int(i) + 1}"
plot = PlotlyArtifact(payload=fig)
- top = list(reversed(word_weights))[:3]
- top_words = ", ".join(f"'{word}' ({weight:+})" for word, weight in top)
- text = TextArtifact(
- payload=(
- f"The model predicted {predicted_name} "
- f"(p={predicted_prob}). Most influential words: "
- f"{top_words}."
- ),
- )
- groups.append(ArtifactGroup(title=title, artifacts=[plot, text]))
+ groups.append(ArtifactGroup(title=title, artifacts=[plot]))
return [GroupedArtifacts(groups=groups)]
+
+ def story(
+ self, explanation: dict, explainer_output: ArtifactGroup
+ ) -> Optional[MultilingualString]:
+ """Describe, in words, the prediction and most influential words.
+
+ Names the predicted class, its probability, and the top-3 words by
+ absolute LIME weight (the same values plotted by :meth:`plot`).
+
+ Parameters
+ ----------
+ explanation : dict
+ Output of :meth:`explain_instance`.
+ explainer_output : ArtifactGroup
+ The group previously returned by :meth:`plot`, titled
+ ``"Instance {n}"``.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if
+ ``explainer_output`` is not a recognised "Instance N" group.
+ """
+ match = re.match(r"Instance (\d+)", explainer_output.title or "")
+ if match is None:
+ return None
+ index = int(match.group(1)) - 1
+ if index not in explanation:
+ return None
+
+ target_names = explanation["metadata"]["target_names"]
+ instance = explanation[index]
+
+ predicted_class = instance["predicted_class"]
+ predicted_name = target_names[predicted_class]
+ predicted_prob = round(instance["model_prediction"][predicted_class], 3)
+
+ top = sorted(
+ instance["word_weights"], key=lambda pair: abs(pair[1]), reverse=True
+ )[:3]
+ top_words = ", ".join(f"'{word}' ({weight:+})" for word, weight in top)
+
+ return format_story(
+ {
+ "en": (
+ "The model predicted {predicted_name} "
+ "(p={predicted_prob}). Most influential words: "
+ "{top_words}."
+ ),
+ "es": (
+ "El modelo predijo {predicted_name} "
+ "(p={predicted_prob}). Palabras más influyentes: "
+ "{top_words}."
+ ),
+ "pt": (
+ "O modelo previu {predicted_name} "
+ "(p={predicted_prob}). Palavras mais influentes: "
+ "{top_words}."
+ ),
+ "de": (
+ "Das Modell sagte {predicted_name} "
+ "(p={predicted_prob}) voraus. Einflussreichste Wörter: "
+ "{top_words}."
+ ),
+ "zh": (
+ "模型预测为{predicted_name}(p={predicted_prob})。"
+ "最具影响力的词:{top_words}。"
+ ),
+ },
+ predicted_name=predicted_name,
+ predicted_prob=predicted_prob,
+ top_words=top_words,
+ )
diff --git a/DashAI/back/explainability/explainers/nearest_counterfactual.py b/DashAI/back/explainability/explainers/nearest_counterfactual.py
index 948d5b88b..0802d77cb 100644
--- a/DashAI/back/explainability/explainers/nearest_counterfactual.py
+++ b/DashAI/back/explainability/explainers/nearest_counterfactual.py
@@ -1,11 +1,11 @@
-from typing import List
+import re
+from typing import List, Optional
from DashAI.back.core.artifacts import (
ArtifactGroup,
GroupedArtifacts,
TableArtifact,
TablePayload,
- TextArtifact,
)
from DashAI.back.core.schema_fields import (
BaseSchema,
@@ -15,6 +15,7 @@
)
from DashAI.back.core.utils import MultilingualString
from DashAI.back.explainability.local_explainer import BaseLocalExplainer
+from DashAI.back.explainability.story import concat_stories, format_story
from DashAI.back.models.base_model import BaseModel
@@ -337,7 +338,7 @@ def explain_instance(self, instances):
return explanation
def plot(self, explanation: dict) -> List[GroupedArtifacts]:
- """Render each instance as a comparison table plus a text summary.
+ """Render each instance as a comparison table.
Parameters
----------
@@ -348,10 +349,8 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
-------
List[GroupedArtifacts]
A single grouped artifact with one group per explained instance,
- each holding that instance's comparison table and text summary.
+ each holding that instance's comparison table.
"""
- import numpy as np
-
exp = explanation.copy()
metadata = exp.pop("metadata")
feature_names = metadata["feature_names"]
@@ -363,9 +362,6 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
instance_values = instance["instance_values"]
predicted_class = instance["predicted_class"]
predicted_name = target_names[predicted_class]
- predicted_prob = float(
- np.round(instance["model_prediction"][predicted_class], 3)
- )
counterfactuals = instance["counterfactuals"]
columns = ["Feature", "Instance"] + [
@@ -393,24 +389,156 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
table = TableArtifact(
payload=TablePayload(columns=columns, rows=rows, highlight=highlight),
)
+ groups.append(ArtifactGroup(title=title, artifacts=[table]))
- if counterfactuals:
- lines = [f"The model predicted {predicted_name} (p={predicted_prob})."]
- for cf_idx, counterfactual in enumerate(counterfactuals):
- cf_name = target_names[counterfactual["predicted_class"]]
- changed = ", ".join(counterfactual["changed_features"]) or "nothing"
- lines.append(
- f"Counterfactual {cf_idx + 1}: changing {changed} "
- f"yields {cf_name} "
- f"(distance {counterfactual['distance']})."
- )
- summary = "\n".join(lines)
+ return [GroupedArtifacts(groups=groups)]
+
+ def story(
+ self, explanation: dict, explainer_output: ArtifactGroup
+ ) -> Optional[MultilingualString]:
+ """Describe, in words, the prediction and each nearest counterfactual.
+
+ Names the predicted class and probability, then lists every
+ counterfactual with the features it changed, the class it yields and
+ its distance to the instance (the same values shown in the
+ comparison table from :meth:`plot`).
+
+ Parameters
+ ----------
+ explanation : dict
+ Output of :meth:`explain_instance`.
+ explainer_output : ArtifactGroup
+ The group previously returned by :meth:`plot`, titled
+ ``"Instance {n}"``.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if
+ ``explainer_output`` is not a recognised "Instance N" group.
+ """
+ match = re.match(r"Instance (\d+)", explainer_output.title or "")
+ if match is None:
+ return None
+ index = int(match.group(1)) - 1
+ if index not in explanation:
+ return None
+
+ metadata = explanation["metadata"]
+ target_names = metadata["target_names"]
+ instance = explanation[index]
+
+ predicted_class = instance["predicted_class"]
+ predicted_name = target_names[predicted_class]
+ predicted_prob = round(instance["model_prediction"][predicted_class], 3)
+ counterfactuals = instance["counterfactuals"]
+
+ if not counterfactuals:
+ return format_story(
+ {
+ "en": (
+ "The model predicted {predicted_name} "
+ "(p={predicted_prob}). No counterfactual examples "
+ "were found in the training data."
+ ),
+ "es": (
+ "El modelo predijo {predicted_name} "
+ "(p={predicted_prob}). No se encontraron ejemplos "
+ "contrafactuales en los datos de entrenamiento."
+ ),
+ "pt": (
+ "O modelo previu {predicted_name} "
+ "(p={predicted_prob}). Não foram encontrados "
+ "exemplos contrafactuais nos dados de treinamento."
+ ),
+ "de": (
+ "Das Modell sagte {predicted_name} "
+ "(p={predicted_prob}) voraus. Im Trainingsdatensatz "
+ "wurden keine kontrafaktischen Beispiele gefunden."
+ ),
+ "zh": (
+ "模型预测为{predicted_name}(p={predicted_prob})。"
+ "在训练数据中未找到反事实样本。"
+ ),
+ },
+ predicted_name=predicted_name,
+ predicted_prob=predicted_prob,
+ )
+
+ story = format_story(
+ {
+ "en": "The model predicted {predicted_name} (p={predicted_prob}).",
+ "es": "El modelo predijo {predicted_name} (p={predicted_prob}).",
+ "pt": "O modelo previu {predicted_name} (p={predicted_prob}).",
+ "de": "Das Modell sagte {predicted_name} (p={predicted_prob}) voraus.",
+ "zh": "模型预测为{predicted_name}(p={predicted_prob})。",
+ },
+ predicted_name=predicted_name,
+ predicted_prob=predicted_prob,
+ )
+
+ for cf_idx, counterfactual in enumerate(counterfactuals):
+ cf_name = target_names[counterfactual["predicted_class"]]
+ changed_features = counterfactual["changed_features"]
+ distance = counterfactual["distance"]
+ if changed_features:
+ changed = ", ".join(changed_features)
+ line = format_story(
+ {
+ "en": (
+ "Counterfactual {n}: changing {changed} yields "
+ "{cf_name} (distance {distance})."
+ ),
+ "es": (
+ "Contrafactual {n}: cambiando {changed} se "
+ "obtiene {cf_name} (distancia {distance})."
+ ),
+ "pt": (
+ "Contrafactual {n}: alterando {changed} obtém-se "
+ "{cf_name} (distância {distance})."
+ ),
+ "de": (
+ "Kontrafaktisch {n}: Durch Ändern von {changed} "
+ "ergibt sich {cf_name} (Distanz {distance})."
+ ),
+ "zh": (
+ "反事实{n}:改变{changed}会得到{cf_name}"
+ "(距离{distance})。"
+ ),
+ },
+ n=cf_idx + 1,
+ changed=changed,
+ cf_name=cf_name,
+ distance=distance,
+ )
else:
- summary = (
- f"The model predicted {predicted_name} (p={predicted_prob}). "
- "No counterfactual examples were found in the training data."
+ line = format_story(
+ {
+ "en": (
+ "Counterfactual {n}: changing nothing yields "
+ "{cf_name} (distance {distance})."
+ ),
+ "es": (
+ "Contrafactual {n}: sin cambiar nada se obtiene "
+ "{cf_name} (distancia {distance})."
+ ),
+ "pt": (
+ "Contrafactual {n}: sem alterar nada obtém-se "
+ "{cf_name} (distância {distance})."
+ ),
+ "de": (
+ "Kontrafaktisch {n}: Ohne Änderungen ergibt sich "
+ "{cf_name} (Distanz {distance})."
+ ),
+ "zh": (
+ "反事实{n}:不改变任何特征即可得到"
+ "{cf_name}(距离{distance})。"
+ ),
+ },
+ n=cf_idx + 1,
+ cf_name=cf_name,
+ distance=distance,
)
- text = TextArtifact(payload=summary)
- groups.append(ArtifactGroup(title=title, artifacts=[table, text]))
+ story = concat_stories(story, line, separator="\n")
- return [GroupedArtifacts(groups=groups)]
+ return story
diff --git a/DashAI/back/explainability/explainers/occlusion_saliency.py b/DashAI/back/explainability/explainers/occlusion_saliency.py
index 5baa6a18d..4b0be0ccb 100644
--- a/DashAI/back/explainability/explainers/occlusion_saliency.py
+++ b/DashAI/back/explainability/explainers/occlusion_saliency.py
@@ -1,9 +1,9 @@
-from typing import List
+import re
+from typing import List, Optional
from DashAI.back.core.artifacts import (
ArtifactGroup,
GroupedArtifacts,
- TextArtifact,
)
from DashAI.back.core.schema_fields import (
BaseSchema,
@@ -19,6 +19,7 @@
iter_pil_images,
)
from DashAI.back.explainability.local_explainer import BaseLocalExplainer
+from DashAI.back.explainability.story import format_story
from DashAI.back.models.base_model import BaseModel
@@ -302,7 +303,7 @@ def explain_instance(self, instances):
return explanation
def plot(self, explanation: dict) -> List[GroupedArtifacts]:
- """Render each image as a saliency overlay plus a text summary.
+ """Render each image as a saliency overlay.
Parameters
----------
@@ -312,8 +313,8 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
Returns
-------
List[GroupedArtifacts]
- A single grouped artifact with one group per explained image, each
- holding that image's saliency overlay and text summary.
+ A single grouped artifact with one group per explained image,
+ each holding that image's saliency overlay.
"""
import numpy as np
@@ -335,13 +336,73 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
overlay = heatmap_overlay_artifact(
instance["image"], instance["heatmap"], title, subtitle
)
- text = TextArtifact(
- payload=(
- f"The model predicted {predicted_name} "
- f"(p={predicted_prob}). Highlighted regions are those "
- "whose occlusion most lowered that probability."
- ),
- )
- groups.append(ArtifactGroup(title=title, artifacts=[overlay, text]))
+ groups.append(ArtifactGroup(title=title, artifacts=[overlay]))
return [GroupedArtifacts(groups=groups)]
+
+ def story(
+ self, explanation: dict, explainer_output: ArtifactGroup
+ ) -> Optional[MultilingualString]:
+ """Describe, in words, the prediction the saliency overlay explains.
+
+ Names the predicted class and its probability (the same values used
+ to build the overlay's subtitle in :meth:`plot`).
+
+ Parameters
+ ----------
+ explanation : dict
+ Output of :meth:`explain_instance`.
+ explainer_output : ArtifactGroup
+ The group previously returned by :meth:`plot`, titled
+ ``"Image {n}"``.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if
+ ``explainer_output`` is not a recognised "Image N" group.
+ """
+ match = re.match(r"Image (\d+)", explainer_output.title or "")
+ if match is None:
+ return None
+ index = int(match.group(1)) - 1
+ if index not in explanation:
+ return None
+
+ target_names = explanation["metadata"]["target_names"]
+ instance = explanation[index]
+ predicted_class = instance["predicted_class"]
+ predicted_name = target_names[predicted_class]
+ predicted_prob = round(instance["model_prediction"][predicted_class], 3)
+
+ return format_story(
+ {
+ "en": (
+ "The model predicted {predicted_name} "
+ "(p={predicted_prob}). Highlighted regions are those "
+ "whose occlusion most lowered that probability."
+ ),
+ "es": (
+ "El modelo predijo {predicted_name} "
+ "(p={predicted_prob}). Las regiones resaltadas son "
+ "aquellas cuya oclusión redujo más esa probabilidad."
+ ),
+ "pt": (
+ "O modelo previu {predicted_name} "
+ "(p={predicted_prob}). As regiões destacadas são "
+ "aquelas cuja oclusão mais reduziu essa probabilidade."
+ ),
+ "de": (
+ "Das Modell sagte {predicted_name} "
+ "(p={predicted_prob}) voraus. Die hervorgehobenen "
+ "Regionen sind jene, deren Verdeckung diese "
+ "Wahrscheinlichkeit am stärksten verringerte."
+ ),
+ "zh": (
+ "模型预测为{predicted_name}(p={predicted_prob})。"
+ "高亮区域是遮挡后该概率下降最多的区域。"
+ ),
+ },
+ predicted_name=predicted_name,
+ predicted_prob=predicted_prob,
+ )
diff --git a/DashAI/back/explainability/explainers/partial_dependence.py b/DashAI/back/explainability/explainers/partial_dependence.py
index b2289fa4e..40ab27212 100644
--- a/DashAI/back/explainability/explainers/partial_dependence.py
+++ b/DashAI/back/explainability/explainers/partial_dependence.py
@@ -1,6 +1,8 @@
-from typing import List
+import re
+from typing import List, Optional, Union
from DashAI.back.core.artifacts import (
+ Artifact,
ArtifactGroup,
GroupedArtifacts,
PlotlyArtifact,
@@ -13,6 +15,7 @@
)
from DashAI.back.core.utils import MultilingualString
from DashAI.back.explainability.global_explainer import BaseGlobalExplainer
+from DashAI.back.explainability.story import format_story
from DashAI.back.models.base_model import BaseModel
from DashAI.back.types.categorical import Categorical
@@ -332,3 +335,213 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
dfs.append(data)
return self._create_plot(dfs)
+
+ def story(
+ self, explanation: dict, explainer_output: Union[Artifact, ArtifactGroup]
+ ) -> Optional[MultilingualString]:
+ """Describe, in words, the trend of one feature/class curve.
+
+ Classifies the curve as increasing, decreasing or non-monotonic from
+ its values (the same ones plotted by :meth:`plot`), and reports the
+ predicted-probability range across the feature's value range.
+
+ Parameters
+ ----------
+ explanation : dict
+ Output of :meth:`explain`.
+ explainer_output : Union[Artifact, ArtifactGroup]
+ One of the groups previously returned by :meth:`plot`, titled
+ ``"Feature: {feature} - Class: {target}"``.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if
+ ``explainer_output`` is not a recognised curve group.
+ """
+ if not isinstance(explainer_output, ArtifactGroup):
+ return None
+ match = re.match(r"Feature: (.+) - Class: (.+)", explainer_output.title or "")
+ if match is None:
+ return None
+ feature, target = match.group(1), match.group(2)
+ if feature not in explanation:
+ return None
+
+ target_names = explanation["metadata"]["target_names"]
+ if len(target_names) == 2:
+ if target != target_names[1]:
+ return None
+ row_index = 0
+ else:
+ if target not in target_names:
+ return None
+ row_index = target_names.index(target)
+
+ curve = explanation[feature]
+ average = curve["average"]
+ if row_index >= len(average):
+ return None
+ values = average[row_index]
+ grid_values = curve["grid_values"]
+
+ diffs = [values[i + 1] - values[i] for i in range(len(values) - 1)]
+ if max(values) - min(values) <= 1e-9:
+ trend = "flat"
+ elif all(d >= -1e-9 for d in diffs):
+ trend = "increases"
+ elif all(d <= 1e-9 for d in diffs):
+ trend = "decreases"
+ else:
+ trend = "non_monotonic"
+
+ start_value, end_value = grid_values[0], grid_values[-1]
+
+ if trend == "flat":
+ return format_story(
+ {
+ "en": (
+ "Changing {feature} from {start_value} to {end_value} "
+ "does not noticeably affect the predicted probability "
+ "of {target}, which stays at {start_pred}."
+ ),
+ "es": (
+ "Cambiar {feature} de {start_value} a {end_value} no "
+ "afecta de forma apreciable la probabilidad predicha "
+ "de {target}, que se mantiene en {start_pred}."
+ ),
+ "pt": (
+ "Alterar {feature} de {start_value} para {end_value} "
+ "não afeta de forma perceptível a probabilidade "
+ "prevista de {target}, que permanece em {start_pred}."
+ ),
+ "de": (
+ "Eine Änderung von {feature} von {start_value} auf "
+ "{end_value} wirkt sich nicht merklich auf die "
+ "vorhergesagte Wahrscheinlichkeit von {target} aus, "
+ "die bei {start_pred} bleibt."
+ ),
+ "zh": (
+ "将{feature}从{start_value}变化到{end_value}对"
+ "{target}的预测概率没有明显影响,其保持在"
+ "{start_pred}。"
+ ),
+ },
+ feature=feature,
+ target=target,
+ start_value=start_value,
+ end_value=end_value,
+ start_pred=values[0],
+ )
+
+ if trend == "increases":
+ return format_story(
+ {
+ "en": (
+ "As {feature} goes from {start_value} to {end_value}, "
+ "the predicted probability of {target} increases from "
+ "{start_pred} to {end_pred}."
+ ),
+ "es": (
+ "A medida que {feature} va de {start_value} a "
+ "{end_value}, la probabilidad predicha de {target} "
+ "aumenta de {start_pred} a {end_pred}."
+ ),
+ "pt": (
+ "À medida que {feature} vai de {start_value} a "
+ "{end_value}, a probabilidade prevista de {target} "
+ "aumenta de {start_pred} para {end_pred}."
+ ),
+ "de": (
+ "Während {feature} von {start_value} auf {end_value} "
+ "steigt, nimmt die vorhergesagte Wahrscheinlichkeit "
+ "von {target} von {start_pred} auf {end_pred} zu."
+ ),
+ "zh": (
+ "随着{feature}从{start_value}变化到{end_value},"
+ "{target}的预测概率从{start_pred}上升到{end_pred}。"
+ ),
+ },
+ feature=feature,
+ target=target,
+ start_value=start_value,
+ end_value=end_value,
+ start_pred=values[0],
+ end_pred=values[-1],
+ )
+
+ if trend == "decreases":
+ return format_story(
+ {
+ "en": (
+ "As {feature} goes from {start_value} to {end_value}, "
+ "the predicted probability of {target} decreases from "
+ "{start_pred} to {end_pred}."
+ ),
+ "es": (
+ "A medida que {feature} va de {start_value} a "
+ "{end_value}, la probabilidad predicha de {target} "
+ "disminuye de {start_pred} a {end_pred}."
+ ),
+ "pt": (
+ "À medida que {feature} vai de {start_value} a "
+ "{end_value}, a probabilidade prevista de {target} "
+ "diminui de {start_pred} para {end_pred}."
+ ),
+ "de": (
+ "Während {feature} von {start_value} auf {end_value} "
+ "steigt, sinkt die vorhergesagte Wahrscheinlichkeit "
+ "von {target} von {start_pred} auf {end_pred}."
+ ),
+ "zh": (
+ "随着{feature}从{start_value}变化到{end_value},"
+ "{target}的预测概率从{start_pred}下降到{end_pred}。"
+ ),
+ },
+ feature=feature,
+ target=target,
+ start_value=start_value,
+ end_value=end_value,
+ start_pred=values[0],
+ end_pred=values[-1],
+ )
+
+ return format_story(
+ {
+ "en": (
+ "As {feature} goes from {start_value} to {end_value}, "
+ "the predicted probability of {target} does not change "
+ "monotonically, ranging between {min_pred} and "
+ "{max_pred}."
+ ),
+ "es": (
+ "A medida que {feature} va de {start_value} a "
+ "{end_value}, la probabilidad predicha de {target} no "
+ "cambia de forma monótona, variando entre {min_pred} y "
+ "{max_pred}."
+ ),
+ "pt": (
+ "À medida que {feature} vai de {start_value} a "
+ "{end_value}, a probabilidade prevista de {target} não "
+ "muda de forma monótona, variando entre {min_pred} e "
+ "{max_pred}."
+ ),
+ "de": (
+ "Während {feature} von {start_value} auf {end_value} "
+ "steigt, ändert sich die vorhergesagte Wahrscheinlichkeit "
+ "von {target} nicht monoton und schwankt zwischen "
+ "{min_pred} und {max_pred}."
+ ),
+ "zh": (
+ "随着{feature}从{start_value}变化到{end_value},"
+ "{target}的预测概率并非单调变化,在{min_pred}和"
+ "{max_pred}之间波动。"
+ ),
+ },
+ feature=feature,
+ target=target,
+ start_value=start_value,
+ end_value=end_value,
+ min_pred=min(values),
+ max_pred=max(values),
+ )
diff --git a/DashAI/back/explainability/explainers/permutation_feature_importance.py b/DashAI/back/explainability/explainers/permutation_feature_importance.py
index 2bd6dc556..57b1d13e6 100644
--- a/DashAI/back/explainability/explainers/permutation_feature_importance.py
+++ b/DashAI/back/explainability/explainers/permutation_feature_importance.py
@@ -1,6 +1,8 @@
-from typing import Dict, List, Union
+import re
+from typing import Dict, List, Optional, Union
from DashAI.back.core.artifacts import (
+ Artifact,
ArtifactGroup,
GroupedArtifacts,
PlotlyArtifact,
@@ -14,6 +16,7 @@
)
from DashAI.back.core.utils import MultilingualString
from DashAI.back.explainability.global_explainer import BaseGlobalExplainer
+from DashAI.back.explainability.story import concat_stories, format_story
from DashAI.back.models.base_model import BaseModel
@@ -589,3 +592,195 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
data = data.sort_values(by=["importances_mean"], ascending=True)
return self._create_plot(data)
+
+ def story(
+ self, explanation: dict, explainer_output: Union[Artifact, ArtifactGroup]
+ ) -> Optional[MultilingualString]:
+ """Describe, in words, the top features shown in one "Top N" group.
+
+ Ranks the features by their mean importance (the same values plotted
+ by :meth:`plot`) and names the ones shown in this group, calling out
+ when the least important one among them showed no measurable effect
+ (mean importance at or below zero).
+
+ Parameters
+ ----------
+ explanation : dict
+ Output of :meth:`explain`.
+ explainer_output : Union[Artifact, ArtifactGroup]
+ One of the groups previously returned by :meth:`plot`, titled
+ ``"Top {count} features"``.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if
+ ``explainer_output`` is not a recognised "Top N features" group.
+ """
+ if not isinstance(explainer_output, ArtifactGroup):
+ return None
+
+ match = re.match(r"Top (\d+) features", explainer_output.title or "")
+ if match is None:
+ return None
+ count = int(match.group(1))
+
+ features = explanation["features"]
+ means = explanation["importances_mean"]
+
+ ranking = sorted(
+ zip(features, means, strict=True), key=lambda pair: pair[1], reverse=True
+ )
+ top = ranking[:count]
+ if not top:
+ return None
+
+ scoring_name = self.scoring.__name__.replace("_score", "").replace("_", " ")
+ positive = [(name, mean) for name, mean in top if mean > 0]
+ non_positive = [(name, mean) for name, mean in top if mean <= 0]
+
+ # All shown features actually decreased the score when shuffled:
+ # "relies most on" the whole ranked list is accurate as-is.
+ if not non_positive:
+ feature_list = ", ".join(f"{name} ({mean:.3f})" for name, mean in top)
+ return format_story(
+ {
+ "en": (
+ "Ranked by the drop in {scoring} caused by shuffling "
+ "each feature, the model relies most on: "
+ "{feature_list}."
+ ),
+ "es": (
+ "Ordenadas según la caída en {scoring} al barajar "
+ "cada característica, el modelo depende "
+ "principalmente de: {feature_list}."
+ ),
+ "pt": (
+ "Classificadas pela queda em {scoring} causada ao "
+ "embaralhar cada característica, o modelo depende "
+ "principalmente de: {feature_list}."
+ ),
+ "de": (
+ "Geordnet nach dem Rückgang von {scoring} durch das "
+ "Permutieren jedes Merkmals, verlässt sich das "
+ "Modell hauptsächlich auf: {feature_list}."
+ ),
+ "zh": (
+ "根据打乱各特征后{scoring}的下降程度排序,"
+ "模型主要依赖:{feature_list}。"
+ ),
+ },
+ scoring=scoring_name,
+ feature_list=feature_list,
+ )
+
+ # None of the shown features had any measurable effect: saying the
+ # model "relies on" them would be backwards.
+ if not positive:
+ feature_list = ", ".join(f"{name} ({mean:.3f})" for name, mean in top)
+ return format_story(
+ {
+ "en": (
+ "None of the top {count} features ({feature_list}) "
+ "showed measurable importance when shuffled — that "
+ "did not decrease {scoring}, or even improved it."
+ ),
+ "es": (
+ "Ninguna de las {count} características principales "
+ "({feature_list}) mostró una importancia medible al "
+ "barajarlas — no redujo {scoring}, o incluso lo "
+ "mejoró."
+ ),
+ "pt": (
+ "Nenhuma das {count} características principais "
+ "({feature_list}) mostrou importância mensurável ao "
+ "serem embaralhadas — não reduziu {scoring}, ou até "
+ "o melhorou."
+ ),
+ "de": (
+ "Keines der {count} wichtigsten Merkmale "
+ "({feature_list}) zeigte beim Permutieren eine "
+ "messbare Wichtigkeit — {scoring} sank dadurch "
+ "nicht oder verbesserte sich sogar."
+ ),
+ "zh": (
+ "打乱后,排名前{count}的特征({feature_list})均未"
+ "表现出可测量的重要性——并未降低{scoring},甚至有所"
+ "提升。"
+ ),
+ },
+ count=count,
+ scoring=scoring_name,
+ feature_list=feature_list,
+ )
+
+ # Mixed: only some of the shown features had a measurable effect —
+ # claim reliance on those, and separately note the rest showed none.
+ positive_list = ", ".join(f"{name} ({mean:.3f})" for name, mean in positive)
+ non_positive_list = ", ".join(name for name, _ in non_positive)
+ story = format_story(
+ {
+ "en": (
+ "Ranked by the drop in {scoring} caused by shuffling "
+ "each feature, the model relies on: {positive_list}."
+ ),
+ "es": (
+ "Ordenadas según la caída en {scoring} al barajar cada "
+ "característica, el modelo depende de: {positive_list}."
+ ),
+ "pt": (
+ "Classificadas pela queda em {scoring} causada ao "
+ "embaralhar cada característica, o modelo depende de: "
+ "{positive_list}."
+ ),
+ "de": (
+ "Geordnet nach dem Rückgang von {scoring} durch das "
+ "Permutieren jedes Merkmals, verlässt sich das Modell "
+ "auf: {positive_list}."
+ ),
+ "zh": (
+ "根据打乱各特征后{scoring}的下降程度排序,"
+ "模型依赖:{positive_list}。"
+ ),
+ },
+ scoring=scoring_name,
+ positive_list=positive_list,
+ )
+ return concat_stories(
+ story,
+ format_story(
+ {
+ "en": (
+ " The remaining features ({non_positive_list}) "
+ "showed no measurable importance when shuffled "
+ "(that did not decrease {scoring}, or even "
+ "improved it)."
+ ),
+ "es": (
+ " Las características restantes "
+ "({non_positive_list}) no mostraron una "
+ "importancia medible al barajarlas (no redujo "
+ "{scoring}, o incluso lo mejoró)."
+ ),
+ "pt": (
+ " As características restantes "
+ "({non_positive_list}) não mostraram importância "
+ "mensurável ao serem embaralhadas (não reduziu "
+ "{scoring}, ou até o melhorou)."
+ ),
+ "de": (
+ " Die übrigen Merkmale ({non_positive_list}) "
+ "zeigten beim Permutieren keine messbare "
+ "Wichtigkeit ({scoring} sank dadurch nicht oder "
+ "verbesserte sich sogar)."
+ ),
+ "zh": (
+ "其余特征({non_positive_list})在打乱后没有表现出"
+ "可测量的重要性(并未降低{scoring},甚至有所"
+ "提升)。"
+ ),
+ },
+ non_positive_list=non_positive_list,
+ scoring=scoring_name,
+ ),
+ )
diff --git a/DashAI/back/explainability/explainers/regression_kernel_shap.py b/DashAI/back/explainability/explainers/regression_kernel_shap.py
index 63661beaf..90d04caab 100644
--- a/DashAI/back/explainability/explainers/regression_kernel_shap.py
+++ b/DashAI/back/explainability/explainers/regression_kernel_shap.py
@@ -1,10 +1,10 @@
-from typing import List
+import re
+from typing import List, Optional
from DashAI.back.core.artifacts import (
ArtifactGroup,
GroupedArtifacts,
PlotlyArtifact,
- TextArtifact,
)
from DashAI.back.core.schema_fields import (
BaseSchema,
@@ -14,6 +14,7 @@
)
from DashAI.back.core.utils import MultilingualString
from DashAI.back.explainability.local_explainer import BaseLocalExplainer
+from DashAI.back.explainability.story import format_story
from DashAI.back.models.base_model import BaseModel
@@ -254,7 +255,7 @@ def explain_instance(self, instances):
return explanation
def plot(self, explanation: dict) -> List[GroupedArtifacts]:
- """Render each instance as a SHAP bar plot plus a text summary.
+ """Render each instance as a SHAP bar plot.
Parameters
----------
@@ -265,9 +266,8 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
-------
List[GroupedArtifacts]
A single grouped artifact with one group per explained instance,
- each holding that instance's plotly plot and text summary.
+ each holding that instance's plotly plot.
"""
- import numpy as np
import pandas as pd
import plotly.graph_objs as go
@@ -326,23 +326,95 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
title = f"Instance {int(i) + 1}"
plot = PlotlyArtifact(payload=fig)
- top = data.iloc[::-1].head(3)
- top_features = ", ".join(
- f"{feature}={value} ({shap:+})"
- for feature, value, shap in zip(
- top["features"].tolist(),
- top["values"].tolist(),
- top["shap_values"].tolist(),
- strict=True,
- )
- )
- delta = float(np.round(prediction - base_value, 3))
- summary = (
- f"The model predicted {output_column}={prediction}, "
- f"{delta:+} from the baseline {base_value}. "
- f"Main contributions: {top_features}."
- )
- text = TextArtifact(payload=summary)
- groups.append(ArtifactGroup(title=title, artifacts=[plot, text]))
+ groups.append(ArtifactGroup(title=title, artifacts=[plot]))
return [GroupedArtifacts(groups=groups)]
+
+ def story(
+ self, explanation: dict, explainer_output: ArtifactGroup
+ ) -> Optional[MultilingualString]:
+ """Describe, in words, the predicted value and its top contributors.
+
+ Names the predicted value, its offset from the baseline, and the
+ top-3 features by absolute SHAP value (the same values plotted by
+ :meth:`plot`).
+
+ Parameters
+ ----------
+ explanation : dict
+ Output of :meth:`explain_instance`.
+ explainer_output : ArtifactGroup
+ The group previously returned by :meth:`plot`, titled
+ ``"Instance {n}"``.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if
+ ``explainer_output`` is not a recognised "Instance N" group.
+ """
+ match = re.match(r"Instance (\d+)", explainer_output.title or "")
+ if match is None:
+ return None
+ index = int(match.group(1)) - 1
+ if index not in explanation:
+ return None
+
+ metadata = explanation["metadata"]
+ feature_names = metadata["feature_names"]
+ output_column = metadata["output_column"]
+ base_value = explanation["base_value"]
+ instance = explanation[index]
+
+ prediction = instance["model_prediction"]
+ delta = round(prediction - base_value, 3)
+
+ ranking = sorted(
+ zip(
+ feature_names,
+ instance["instance_values"],
+ instance["shap_values"],
+ strict=True,
+ ),
+ key=lambda row: abs(row[2]),
+ reverse=True,
+ )
+ top = ranking[:3]
+ top_features = ", ".join(
+ f"{name}={value} ({shap:+})" for name, value, shap in top
+ )
+
+ return format_story(
+ {
+ "en": (
+ "The model predicted {output_column}={prediction}, "
+ "{delta:+} from the baseline {base_value}. Main "
+ "contributions: {top_features}."
+ ),
+ "es": (
+ "El modelo predijo {output_column}={prediction}, "
+ "{delta:+} respecto a la base {base_value}. "
+ "Contribuciones principales: {top_features}."
+ ),
+ "pt": (
+ "O modelo previu {output_column}={prediction}, "
+ "{delta:+} em relação à base {base_value}. "
+ "Principais contribuições: {top_features}."
+ ),
+ "de": (
+ "Das Modell sagte {output_column}={prediction} voraus, "
+ "{delta:+} gegenüber der Basislinie {base_value}. "
+ "Wichtigste Beiträge: {top_features}."
+ ),
+ "zh": (
+ "模型预测{output_column}={prediction},相对基线"
+ "{base_value}的差值为{delta:+}。主要贡献:"
+ "{top_features}。"
+ ),
+ },
+ output_column=output_column,
+ prediction=prediction,
+ delta=delta,
+ base_value=base_value,
+ top_features=top_features,
+ )
diff --git a/DashAI/back/explainability/explainers/regression_partial_dependence.py b/DashAI/back/explainability/explainers/regression_partial_dependence.py
index 5c6706523..9c89f199a 100644
--- a/DashAI/back/explainability/explainers/regression_partial_dependence.py
+++ b/DashAI/back/explainability/explainers/regression_partial_dependence.py
@@ -1,6 +1,7 @@
-from typing import List
+from typing import List, Optional, Union
from DashAI.back.core.artifacts import (
+ Artifact,
ArtifactGroup,
GroupedArtifacts,
PlotlyArtifact,
@@ -13,6 +14,7 @@
)
from DashAI.back.core.utils import MultilingualString
from DashAI.back.explainability.global_explainer import BaseGlobalExplainer
+from DashAI.back.explainability.story import format_story
from DashAI.back.models.base_model import BaseModel
@@ -253,3 +255,200 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
)
return [GroupedArtifacts(groups=groups)]
+
+ def story(
+ self, explanation: dict, explainer_output: Union[Artifact, ArtifactGroup]
+ ) -> Optional[MultilingualString]:
+ """Describe, in words, the trend of one feature's dependence curve.
+
+ Classifies the curve as increasing, decreasing or non-monotonic from
+ its values (the same ones plotted by :meth:`plot`), and reports the
+ predicted-value range across the feature's grid.
+
+ Parameters
+ ----------
+ explanation : dict
+ Output of :meth:`explain`.
+ explainer_output : Union[Artifact, ArtifactGroup]
+ One of the groups previously returned by :meth:`plot`, titled
+ with the feature name.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if
+ ``explainer_output`` is not a recognised feature group.
+ """
+ if not isinstance(explainer_output, ArtifactGroup):
+ return None
+ feature = explainer_output.title
+ if feature is None or feature not in explanation:
+ return None
+
+ output_column = explanation["metadata"]["output_column"]
+ curve = explanation[feature]
+ values = curve["average"]
+ grid_values = curve["grid_values"]
+
+ diffs = [values[i + 1] - values[i] for i in range(len(values) - 1)]
+ if max(values) - min(values) <= 1e-9:
+ trend = "flat"
+ elif all(d >= -1e-9 for d in diffs):
+ trend = "increases"
+ elif all(d <= 1e-9 for d in diffs):
+ trend = "decreases"
+ else:
+ trend = "non_monotonic"
+
+ start_value, end_value = grid_values[0], grid_values[-1]
+
+ if trend == "flat":
+ return format_story(
+ {
+ "en": (
+ "Changing {feature} from {start_value} to {end_value} "
+ "does not noticeably affect the predicted "
+ "{output_column}, which stays at {start_pred}."
+ ),
+ "es": (
+ "Cambiar {feature} de {start_value} a {end_value} no "
+ "afecta de forma apreciable el {output_column} "
+ "predicho, que se mantiene en {start_pred}."
+ ),
+ "pt": (
+ "Alterar {feature} de {start_value} para {end_value} "
+ "não afeta de forma perceptível o {output_column} "
+ "previsto, que permanece em {start_pred}."
+ ),
+ "de": (
+ "Eine Änderung von {feature} von {start_value} auf "
+ "{end_value} wirkt sich nicht merklich auf den "
+ "vorhergesagten {output_column} aus, der bei "
+ "{start_pred} bleibt."
+ ),
+ "zh": (
+ "将{feature}从{start_value}变化到{end_value}对"
+ "预测的{output_column}没有明显影响,其保持在"
+ "{start_pred}。"
+ ),
+ },
+ feature=feature,
+ output_column=output_column,
+ start_value=start_value,
+ end_value=end_value,
+ start_pred=values[0],
+ )
+
+ if trend == "increases":
+ return format_story(
+ {
+ "en": (
+ "As {feature} goes from {start_value} to {end_value}, "
+ "the predicted {output_column} increases from "
+ "{start_pred} to {end_pred}."
+ ),
+ "es": (
+ "A medida que {feature} va de {start_value} a "
+ "{end_value}, el {output_column} predicho aumenta de "
+ "{start_pred} a {end_pred}."
+ ),
+ "pt": (
+ "À medida que {feature} vai de {start_value} a "
+ "{end_value}, o {output_column} previsto aumenta de "
+ "{start_pred} para {end_pred}."
+ ),
+ "de": (
+ "Während {feature} von {start_value} auf {end_value} "
+ "steigt, nimmt der vorhergesagte {output_column} von "
+ "{start_pred} auf {end_pred} zu."
+ ),
+ "zh": (
+ "随着{feature}从{start_value}变化到{end_value},"
+ "预测的{output_column}从{start_pred}上升到"
+ "{end_pred}。"
+ ),
+ },
+ feature=feature,
+ output_column=output_column,
+ start_value=start_value,
+ end_value=end_value,
+ start_pred=values[0],
+ end_pred=values[-1],
+ )
+
+ if trend == "decreases":
+ return format_story(
+ {
+ "en": (
+ "As {feature} goes from {start_value} to {end_value}, "
+ "the predicted {output_column} decreases from "
+ "{start_pred} to {end_pred}."
+ ),
+ "es": (
+ "A medida que {feature} va de {start_value} a "
+ "{end_value}, el {output_column} predicho disminuye "
+ "de {start_pred} a {end_pred}."
+ ),
+ "pt": (
+ "À medida que {feature} vai de {start_value} a "
+ "{end_value}, o {output_column} previsto diminui de "
+ "{start_pred} para {end_pred}."
+ ),
+ "de": (
+ "Während {feature} von {start_value} auf {end_value} "
+ "steigt, sinkt der vorhergesagte {output_column} von "
+ "{start_pred} auf {end_pred}."
+ ),
+ "zh": (
+ "随着{feature}从{start_value}变化到{end_value},"
+ "预测的{output_column}从{start_pred}下降到"
+ "{end_pred}。"
+ ),
+ },
+ feature=feature,
+ output_column=output_column,
+ start_value=start_value,
+ end_value=end_value,
+ start_pred=values[0],
+ end_pred=values[-1],
+ )
+
+ return format_story(
+ {
+ "en": (
+ "As {feature} goes from {start_value} to {end_value}, "
+ "the predicted {output_column} does not change "
+ "monotonically, ranging between {min_pred} and "
+ "{max_pred}."
+ ),
+ "es": (
+ "A medida que {feature} va de {start_value} a "
+ "{end_value}, el {output_column} predicho no cambia de "
+ "forma monótona, variando entre {min_pred} y "
+ "{max_pred}."
+ ),
+ "pt": (
+ "À medida que {feature} vai de {start_value} a "
+ "{end_value}, o {output_column} previsto não muda de "
+ "forma monótona, variando entre {min_pred} e "
+ "{max_pred}."
+ ),
+ "de": (
+ "Während {feature} von {start_value} auf {end_value} "
+ "steigt, ändert sich der vorhergesagte {output_column} "
+ "nicht monoton und schwankt zwischen {min_pred} und "
+ "{max_pred}."
+ ),
+ "zh": (
+ "随着{feature}从{start_value}变化到{end_value},"
+ "预测的{output_column}并非单调变化,在{min_pred}和"
+ "{max_pred}之间波动。"
+ ),
+ },
+ feature=feature,
+ output_column=output_column,
+ start_value=start_value,
+ end_value=end_value,
+ min_pred=min(values),
+ max_pred=max(values),
+ )
diff --git a/DashAI/back/explainability/explainers/regression_permutation_feature_importance.py b/DashAI/back/explainability/explainers/regression_permutation_feature_importance.py
index d341a99f0..9c3927c2e 100644
--- a/DashAI/back/explainability/explainers/regression_permutation_feature_importance.py
+++ b/DashAI/back/explainability/explainers/regression_permutation_feature_importance.py
@@ -1,6 +1,6 @@
-from typing import List
+from typing import List, Optional, Union
-from DashAI.back.core.artifacts import Artifact, PlotlyArtifact
+from DashAI.back.core.artifacts import Artifact, ArtifactGroup, PlotlyArtifact
from DashAI.back.core.schema_fields import (
BaseSchema,
enum_field,
@@ -10,6 +10,7 @@
)
from DashAI.back.core.utils import MultilingualString
from DashAI.back.explainability.global_explainer import BaseGlobalExplainer
+from DashAI.back.explainability.story import concat_stories, format_story
from DashAI.back.models.base_model import BaseModel
@@ -314,3 +315,183 @@ def plot(self, explanation: dict) -> List[Artifact]:
)
return [PlotlyArtifact(payload=fig, title="Permutation Feature Importance")]
+
+ def story(
+ self, explanation: dict, explainer_output: Union[Artifact, ArtifactGroup]
+ ) -> Optional[MultilingualString]:
+ """Describe, in words, the ranked feature importances.
+
+ Ranks the features by their mean importance (the same values
+ plotted by :meth:`plot`) and names the top-3, calling out when the
+ least important of them showed no measurable effect (mean
+ importance at or below zero).
+
+ Parameters
+ ----------
+ explanation : dict
+ Output of :meth:`explain`.
+ explainer_output : Union[Artifact, ArtifactGroup]
+ The artifact previously returned by :meth:`plot`.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if
+ ``explainer_output`` is not the importance bar chart.
+ """
+ if isinstance(explainer_output, ArtifactGroup):
+ return None
+
+ features = explanation["features"]
+ means = explanation["importances_mean"]
+
+ ranking = sorted(
+ zip(features, means, strict=True), key=lambda pair: pair[1], reverse=True
+ )
+ top = ranking[:3]
+ positive = [(name, mean) for name, mean in top if mean > 0]
+ non_positive = [(name, mean) for name, mean in top if mean <= 0]
+
+ # All top-3 features actually decreased the score when shuffled:
+ # "relies most on" the whole ranked list is accurate as-is.
+ if not non_positive:
+ feature_list = ", ".join(f"{name} ({mean:.3f})" for name, mean in top)
+ return format_story(
+ {
+ "en": (
+ "Ranked by the drop in {scoring} caused by shuffling "
+ "each feature, the model relies most on: "
+ "{feature_list}."
+ ),
+ "es": (
+ "Ordenadas según la caída en {scoring} al barajar "
+ "cada característica, el modelo depende "
+ "principalmente de: {feature_list}."
+ ),
+ "pt": (
+ "Classificadas pela queda em {scoring} causada ao "
+ "embaralhar cada característica, o modelo depende "
+ "principalmente de: {feature_list}."
+ ),
+ "de": (
+ "Geordnet nach dem Rückgang von {scoring} durch das "
+ "Permutieren jedes Merkmals, verlässt sich das "
+ "Modell hauptsächlich auf: {feature_list}."
+ ),
+ "zh": (
+ "根据打乱各特征后{scoring}的下降程度排序,"
+ "模型主要依赖:{feature_list}。"
+ ),
+ },
+ scoring=self.scoring_name,
+ feature_list=feature_list,
+ )
+
+ # None of the top-3 had any measurable effect: saying the model
+ # "relies on" them would be backwards.
+ if not positive:
+ feature_list = ", ".join(f"{name} ({mean:.3f})" for name, mean in top)
+ return format_story(
+ {
+ "en": (
+ "None of the top 3 features ({feature_list}) showed "
+ "measurable importance when shuffled — that did "
+ "not decrease {scoring}, or even improved it."
+ ),
+ "es": (
+ "Ninguna de las 3 características principales "
+ "({feature_list}) mostró una importancia medible al "
+ "barajarlas — no redujo {scoring}, o incluso lo "
+ "mejoró."
+ ),
+ "pt": (
+ "Nenhuma das 3 características principais "
+ "({feature_list}) mostrou importância mensurável ao "
+ "serem embaralhadas — não reduziu {scoring}, ou até "
+ "o melhorou."
+ ),
+ "de": (
+ "Keines der 3 wichtigsten Merkmale ({feature_list}) "
+ "zeigte beim Permutieren eine messbare Wichtigkeit "
+ "— {scoring} sank dadurch nicht oder verbesserte "
+ "sich sogar."
+ ),
+ "zh": (
+ "打乱后,排名前3的特征({feature_list})均未表现出"
+ "可测量的重要性——并未降低{scoring},甚至有所提升。"
+ ),
+ },
+ scoring=self.scoring_name,
+ feature_list=feature_list,
+ )
+
+ # Mixed: only some of the top-3 had a measurable effect — claim
+ # reliance on those, and separately note the rest showed none.
+ positive_list = ", ".join(f"{name} ({mean:.3f})" for name, mean in positive)
+ non_positive_list = ", ".join(name for name, _ in non_positive)
+ story = format_story(
+ {
+ "en": (
+ "Ranked by the drop in {scoring} caused by shuffling "
+ "each feature, the model relies on: {positive_list}."
+ ),
+ "es": (
+ "Ordenadas según la caída en {scoring} al barajar cada "
+ "característica, el modelo depende de: {positive_list}."
+ ),
+ "pt": (
+ "Classificadas pela queda em {scoring} causada ao "
+ "embaralhar cada característica, o modelo depende de: "
+ "{positive_list}."
+ ),
+ "de": (
+ "Geordnet nach dem Rückgang von {scoring} durch das "
+ "Permutieren jedes Merkmals, verlässt sich das Modell "
+ "auf: {positive_list}."
+ ),
+ "zh": (
+ "根据打乱各特征后{scoring}的下降程度排序,"
+ "模型依赖:{positive_list}。"
+ ),
+ },
+ scoring=self.scoring_name,
+ positive_list=positive_list,
+ )
+ return concat_stories(
+ story,
+ format_story(
+ {
+ "en": (
+ " The remaining features ({non_positive_list}) "
+ "showed no measurable importance when shuffled "
+ "(that did not decrease {scoring}, or even "
+ "improved it)."
+ ),
+ "es": (
+ " Las características restantes "
+ "({non_positive_list}) no mostraron una "
+ "importancia medible al barajarlas (no redujo "
+ "{scoring}, o incluso lo mejoró)."
+ ),
+ "pt": (
+ " As características restantes "
+ "({non_positive_list}) não mostraram importância "
+ "mensurável ao serem embaralhadas (não reduziu "
+ "{scoring}, ou até o melhorou)."
+ ),
+ "de": (
+ " Die übrigen Merkmale ({non_positive_list}) "
+ "zeigten beim Permutieren keine messbare "
+ "Wichtigkeit ({scoring} sank dadurch nicht oder "
+ "verbesserte sich sogar)."
+ ),
+ "zh": (
+ "其余特征({non_positive_list})在打乱后没有表现出"
+ "可测量的重要性(并未降低{scoring},甚至有所"
+ "提升)。"
+ ),
+ },
+ non_positive_list=non_positive_list,
+ scoring=self.scoring_name,
+ ),
+ )
diff --git a/DashAI/back/explainability/explainers/token_ablation.py b/DashAI/back/explainability/explainers/token_ablation.py
index 401daf1c8..1b56b4f14 100644
--- a/DashAI/back/explainability/explainers/token_ablation.py
+++ b/DashAI/back/explainability/explainers/token_ablation.py
@@ -1,10 +1,10 @@
-from typing import List
+import re
+from typing import List, Optional
from DashAI.back.core.artifacts import (
ArtifactGroup,
GroupedArtifacts,
PlotlyArtifact,
- TextArtifact,
)
from DashAI.back.core.schema_fields import (
BaseSchema,
@@ -14,6 +14,7 @@
)
from DashAI.back.core.utils import MultilingualString
from DashAI.back.explainability.local_explainer import BaseLocalExplainer
+from DashAI.back.explainability.story import format_story
from DashAI.back.models.base_model import BaseModel
@@ -272,7 +273,7 @@ def explain_instance(self, instances):
return explanation
def plot(self, explanation: dict) -> List[GroupedArtifacts]:
- """Render each instance as a token importance bar plot plus a summary.
+ """Render each instance as a token importance bar plot.
Parameters
----------
@@ -283,7 +284,7 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
-------
List[GroupedArtifacts]
A single grouped artifact with one group per explained instance,
- each holding that instance's token plot and text summary.
+ each holding that instance's token plot.
"""
import numpy as np
import pandas as pd
@@ -347,18 +348,87 @@ def plot(self, explanation: dict) -> List[GroupedArtifacts]:
title = f"Instance {int(i) + 1}"
plot = PlotlyArtifact(payload=fig)
- top = data.iloc[::-1].head(3)
- top_tokens = ", ".join(
- f"'{token}' ({importance:+})"
- for token, importance in zip(
- top["tokens"].tolist(), top["importances"].tolist(), strict=True
- )
- )
- summary = (
- f"The model predicted {predicted_name} (p={predicted_prob}). "
- f"Most influential tokens: {top_tokens}."
- )
- text = TextArtifact(payload=summary)
- groups.append(ArtifactGroup(title=title, artifacts=[plot, text]))
+ groups.append(ArtifactGroup(title=title, artifacts=[plot]))
return [GroupedArtifacts(groups=groups)]
+
+ def story(
+ self, explanation: dict, explainer_output: ArtifactGroup
+ ) -> Optional[MultilingualString]:
+ """Describe, in words, the prediction and most influential tokens.
+
+ Names the predicted class, its probability, and the top-3 tokens by
+ absolute importance (the same values plotted by :meth:`plot`).
+
+ Parameters
+ ----------
+ explanation : dict
+ Output of :meth:`explain_instance`.
+ explainer_output : ArtifactGroup
+ The group previously returned by :meth:`plot`, titled
+ ``"Instance {n}"``.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if
+ ``explainer_output`` is not a recognised "Instance N" group.
+ """
+ match = re.match(r"Instance (\d+)", explainer_output.title or "")
+ if match is None:
+ return None
+ index = int(match.group(1)) - 1
+ if index not in explanation:
+ return None
+
+ target_names = explanation["metadata"]["target_names"]
+ instance = explanation[index]
+
+ predicted_class = instance["predicted_class"]
+ predicted_name = target_names[predicted_class]
+ predicted_prob = round(instance["model_prediction"][predicted_class], 3)
+
+ labeled_tokens = [
+ f"{token} ({position})" for position, token in enumerate(instance["tokens"])
+ ]
+ ranking = sorted(
+ zip(labeled_tokens, instance["token_importances"], strict=True),
+ key=lambda pair: abs(pair[1]),
+ reverse=True,
+ )
+ top = ranking[:3]
+ top_tokens = ", ".join(
+ f"'{token}' ({importance:+})" for token, importance in top
+ )
+
+ return format_story(
+ {
+ "en": (
+ "The model predicted {predicted_name} "
+ "(p={predicted_prob}). Most influential tokens: "
+ "{top_tokens}."
+ ),
+ "es": (
+ "El modelo predijo {predicted_name} "
+ "(p={predicted_prob}). Tokens más influyentes: "
+ "{top_tokens}."
+ ),
+ "pt": (
+ "O modelo previu {predicted_name} "
+ "(p={predicted_prob}). Tokens mais influentes: "
+ "{top_tokens}."
+ ),
+ "de": (
+ "Das Modell sagte {predicted_name} "
+ "(p={predicted_prob}) voraus. Einflussreichste Tokens: "
+ "{top_tokens}."
+ ),
+ "zh": (
+ "模型预测为{predicted_name}(p={predicted_prob})。"
+ "最具影响力的token:{top_tokens}。"
+ ),
+ },
+ predicted_name=predicted_name,
+ predicted_prob=predicted_prob,
+ top_tokens=top_tokens,
+ )
diff --git a/DashAI/back/explainability/global_explainer.py b/DashAI/back/explainability/global_explainer.py
index 601d3dbb3..519040fde 100644
--- a/DashAI/back/explainability/global_explainer.py
+++ b/DashAI/back/explainability/global_explainer.py
@@ -1,8 +1,9 @@
from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING, Final, List, Tuple, Union
+from typing import TYPE_CHECKING, Final, List, Optional, Tuple, Union
from DashAI.back.config_object import ConfigObject
-from DashAI.back.core.artifacts import Artifact, GroupedArtifacts
+from DashAI.back.core.artifacts import Artifact, ArtifactGroup, GroupedArtifacts
+from DashAI.back.core.utils import MultilingualString
from DashAI.back.models.base_model import BaseModel
if TYPE_CHECKING:
@@ -64,6 +65,35 @@ def explain(self, dataset: Tuple["DatasetDict", "DatasetDict"]) -> dict:
"""
raise NotImplementedError
+ def story(
+ self, explanation: dict, explainer_output: Union[Artifact, ArtifactGroup]
+ ) -> Optional[MultilingualString]:
+ """Generate a narrative summary for one artifact of the explanation.
+
+ Optional hook: explainers that can describe their explanation in
+ words should override this method and return a deterministic
+ narrative (derived from the same ``explanation`` dict used by
+ :meth:`plot`, available in every supported language). The default
+ implementation reports that no narrative is available for this
+ explainer.
+
+ Parameters
+ ----------
+ explanation : dict
+ The explanation dictionary produced by :meth:`explain`.
+ explainer_output : Union[Artifact, ArtifactGroup]
+ One of the artifacts (or artifact groups) previously returned by
+ :meth:`plot`, identifying which part of the explanation the
+ narrative should describe.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if this
+ explainer does not implement one.
+ """
+ return None
+
@abstractmethod
def plot(self, explanation: dict) -> List[Union[Artifact, GroupedArtifacts]]:
"""Generate renderable artifacts from a previously computed explanation.
diff --git a/DashAI/back/explainability/local_explainer.py b/DashAI/back/explainability/local_explainer.py
index 5eb66d847..8ea4d72b8 100644
--- a/DashAI/back/explainability/local_explainer.py
+++ b/DashAI/back/explainability/local_explainer.py
@@ -1,8 +1,9 @@
from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING, Final, List, Tuple
+from typing import TYPE_CHECKING, Final, List, Optional, Tuple
from DashAI.back.config_object import ConfigObject
-from DashAI.back.core.artifacts import GroupedArtifacts
+from DashAI.back.core.artifacts import ArtifactGroup, GroupedArtifacts
+from DashAI.back.core.utils import MultilingualString
from DashAI.back.models.base_model import BaseModel
if TYPE_CHECKING:
@@ -87,6 +88,34 @@ def explain_instance(self, instances: "DatasetDict") -> dict:
"""
raise NotImplementedError
+ def story(
+ self, explanation: dict, explainer_output: ArtifactGroup
+ ) -> Optional[MultilingualString]:
+ """Generate a narrative summary for one explained instance.
+
+ Optional hook: explainers that can describe an instance's explanation
+ in words should override this method and return a deterministic
+ narrative (derived from the same ``explanation`` dict used by
+ :meth:`plot`, available in every supported language). The default
+ implementation reports that no narrative is available for this
+ explainer.
+
+ Parameters
+ ----------
+ explanation : dict
+ The explanation dictionary produced by :meth:`explain_instance`.
+ explainer_output : ArtifactGroup
+ The group previously returned by :meth:`plot` for the instance
+ being described.
+
+ Returns
+ -------
+ Optional[MultilingualString]
+ The narrative in every supported language, or ``None`` if this
+ explainer does not implement one.
+ """
+ return None
+
@abstractmethod
def plot(self, explanation: dict) -> List[GroupedArtifacts]:
"""Generate renderable artifacts from a previously computed explanation.
diff --git a/DashAI/back/explainability/story.py b/DashAI/back/explainability/story.py
new file mode 100644
index 000000000..81289ee61
--- /dev/null
+++ b/DashAI/back/explainability/story.py
@@ -0,0 +1,63 @@
+from dataclasses import fields
+from typing import Dict
+
+from DashAI.back.core.utils import MultilingualString
+
+
+def format_story(templates: Dict[str, str], **kwargs) -> MultilingualString:
+ """Format a per-language template dict into a MultilingualString.
+
+ Explainers use this to turn the deterministic values they compute (top
+ features, SHAP values, curve trends, etc.) into a narrative available in
+ every supported language, without each explainer having to repeat the
+ per-language formatting boilerplate.
+
+ Parameters
+ ----------
+ templates : Dict[str, str]
+ Mapping from language code (``"en"``, ``"es"``, ``"pt"``, ``"de"``,
+ ``"zh"``) to a ``str.format`` template. Every language accepted by
+ :class:`MultilingualString` must be present.
+ **kwargs : Any
+ Values interpolated into each language's template via ``str.format``.
+
+ Returns
+ -------
+ MultilingualString
+ The same narrative, formatted in every supported language.
+ """
+ formatted = {
+ lang: template.format(**kwargs) for lang, template in templates.items()
+ }
+ return MultilingualString(**formatted)
+
+
+def concat_stories(
+ *parts: MultilingualString, separator: str = ""
+) -> MultilingualString:
+ """Concatenate several MultilingualString narratives, language by language.
+
+ Useful when a story is built from a main sentence plus an optional extra
+ remark that only applies under certain conditions (e.g. appending a
+ caveat when a feature's importance is negative).
+
+ Parameters
+ ----------
+ *parts : MultilingualString
+ The narratives to concatenate, in order.
+ separator : str
+ String inserted between parts for each language. Defaults to ``""``.
+
+ Returns
+ -------
+ MultilingualString
+ The concatenation of all parts, for every supported language.
+ """
+ langs = [field.name for field in fields(MultilingualString)]
+ combined = {
+ lang: separator.join(
+ value for part in parts if (value := getattr(part, lang)) is not None
+ )
+ for lang in langs
+ }
+ return MultilingualString(**combined)
diff --git a/DashAI/front/src/components/explainers/ExplainersPlot.jsx b/DashAI/front/src/components/explainers/ExplainersPlot.jsx
index 99c8f0cec..b1049ae4e 100644
--- a/DashAI/front/src/components/explainers/ExplainersPlot.jsx
+++ b/DashAI/front/src/components/explainers/ExplainersPlot.jsx
@@ -7,6 +7,7 @@ import { getExplainerPlot as getExplainerPlotRequest } from "../../api/explainer
import { useTranslation } from "react-i18next";
import ArtifactViewer from "../shared/ArtifactViewer";
import ExplainerInstanceTable from "./ExplainerInstanceTable";
+import StoryBox from "./StoryBox";
/** Wrap legacy plotly JSON strings as plotly artifacts; pass typed dicts through. */
function parseExplanationArtifacts(items) {
@@ -158,15 +159,18 @@ function GroupedArtifactsView({
);
return (
-
+
+
+
+
);
}
@@ -196,7 +200,12 @@ function renderItem(item, ctx, datasetPath = null, selection = null) {
/>
);
}
- return ;
+ return (
+
+
+
+
+ );
}
export default function ExplainersPlot({
diff --git a/DashAI/front/src/components/explainers/StoryBox.jsx b/DashAI/front/src/components/explainers/StoryBox.jsx
new file mode 100644
index 000000000..27cf87dda
--- /dev/null
+++ b/DashAI/front/src/components/explainers/StoryBox.jsx
@@ -0,0 +1,39 @@
+import { React } from "react";
+import PropTypes from "prop-types";
+import { useTranslation } from "react-i18next";
+
+import ArtifactViewer from "../shared/ArtifactViewer";
+
+const FALLBACK_LANG = "en";
+
+/**
+ * Shows the narrative the backend generated for one explainer artifact, in
+ * whichever language is currently active. `story` carries every supported
+ * language at once (`{"en": ..., "es": ..., ...}`), so switching the app's
+ * language selector re-renders this with the matching text already in hand
+ * — no refetch. Rendered as a plain "text" artifact through ArtifactViewer,
+ * so it is the exact same box as any other artifact (border, background,
+ * download button), not a lookalike.
+ */
+export default function StoryBox({ story, groupTitle = null }) {
+ const { t, i18n } = useTranslation(["explainers"]);
+
+ if (!story) return null;
+
+ const lang = i18n.language?.split("-")[0];
+ const text = story[lang] ?? story[FALLBACK_LANG];
+ if (!text) return null;
+
+ const label = groupTitle
+ ? `${t("explainers:label.storyTitle")} — ${groupTitle}`
+ : t("explainers:label.storyTitle");
+
+ return (
+
+ );
+}
+
+StoryBox.propTypes = {
+ story: PropTypes.objectOf(PropTypes.string),
+ groupTitle: PropTypes.string,
+};
diff --git a/DashAI/front/src/utils/i18n/locales/de/explainers.json b/DashAI/front/src/utils/i18n/locales/de/explainers.json
index 85b9c49b0..e87e746e2 100644
--- a/DashAI/front/src/utils/i18n/locales/de/explainers.json
+++ b/DashAI/front/src/utils/i18n/locales/de/explainers.json
@@ -77,7 +77,8 @@
"rowModePercentage": "Prozent-Schieberegler",
"rowModeManual": "Manuelle Auswahl",
"shuffleRows": "Ausgewählte Zeilen mischen (Zufallsstichprobe)",
- "rowsSelectedManually": "Manuell ausgewählte Zeilen: {{selected}} / {{total}}"
+ "rowsSelectedManually": "Manuell ausgewählte Zeilen: {{selected}} / {{total}}",
+ "storyTitle": "Zusammenfassung als Text"
},
"message": {
"explainerJobCompleted": "Erklärungsmodell {{name}} erfolgreich abgeschlossen",
diff --git a/DashAI/front/src/utils/i18n/locales/en/explainers.json b/DashAI/front/src/utils/i18n/locales/en/explainers.json
index 00ada09bb..3c2633620 100644
--- a/DashAI/front/src/utils/i18n/locales/en/explainers.json
+++ b/DashAI/front/src/utils/i18n/locales/en/explainers.json
@@ -77,7 +77,8 @@
"rowModePercentage": "Percentage slider",
"rowModeManual": "Manual selection",
"shuffleRows": "Shuffle selected rows (random sample)",
- "rowsSelectedManually": "Rows selected manually: {{selected}} / {{total}}"
+ "rowsSelectedManually": "Rows selected manually: {{selected}} / {{total}}",
+ "storyTitle": "Narrative summary"
},
"message": {
"explainerJobCompleted": "Explainer {{name}} completed successfully",
diff --git a/DashAI/front/src/utils/i18n/locales/es/explainers.json b/DashAI/front/src/utils/i18n/locales/es/explainers.json
index 47154b54f..59a287f4c 100644
--- a/DashAI/front/src/utils/i18n/locales/es/explainers.json
+++ b/DashAI/front/src/utils/i18n/locales/es/explainers.json
@@ -77,7 +77,8 @@
"rowModePercentage": "Control deslizante de porcentaje",
"rowModeManual": "Selección manual",
"shuffleRows": "Mezclar filas seleccionadas (muestra aleatoria)",
- "rowsSelectedManually": "Filas seleccionadas manualmente: {{selected}} / {{total}}"
+ "rowsSelectedManually": "Filas seleccionadas manualmente: {{selected}} / {{total}}",
+ "storyTitle": "Resumen narrativo"
},
"message": {
"explainerJobCompleted": "Explicador {{name}} completado exitosamente",
diff --git a/DashAI/front/src/utils/i18n/locales/pt/explainers.json b/DashAI/front/src/utils/i18n/locales/pt/explainers.json
index 9c8a1c44c..2ee538182 100644
--- a/DashAI/front/src/utils/i18n/locales/pt/explainers.json
+++ b/DashAI/front/src/utils/i18n/locales/pt/explainers.json
@@ -77,7 +77,8 @@
"rowModePercentage": "Controle deslizante de porcentagem",
"rowModeManual": "Seleção manual",
"shuffleRows": "Embaralhar linhas selecionadas (amostra aleatória)",
- "rowsSelectedManually": "Linhas selecionadas manualmente: {{selected}} / {{total}}"
+ "rowsSelectedManually": "Linhas selecionadas manualmente: {{selected}} / {{total}}",
+ "storyTitle": "Resumo narrativo"
},
"message": {
"explainerJobCompleted": "Explicador {{name}} concluído com sucesso",
diff --git a/DashAI/front/src/utils/i18n/locales/zh/explainers.json b/DashAI/front/src/utils/i18n/locales/zh/explainers.json
index 8939cd457..e1460ba6e 100644
--- a/DashAI/front/src/utils/i18n/locales/zh/explainers.json
+++ b/DashAI/front/src/utils/i18n/locales/zh/explainers.json
@@ -77,7 +77,8 @@
"rowModePercentage": "百分比滑块",
"rowModeManual": "手动选择",
"shuffleRows": "打乱选中的行(随机抽样)",
- "rowsSelectedManually": "手动选择的行数:{{selected}} / {{total}}"
+ "rowsSelectedManually": "手动选择的行数:{{selected}} / {{total}}",
+ "storyTitle": "叙述性摘要"
},
"message": {
"explainerJobCompleted": "解释器 {{name}} 成功完成",
diff --git a/tests/back/api/test_explainer_story_attach.py b/tests/back/api/test_explainer_story_attach.py
new file mode 100644
index 000000000..75fbe60d7
--- /dev/null
+++ b/tests/back/api/test_explainer_story_attach.py
@@ -0,0 +1,105 @@
+"""Unit tests for the story-attaching helpers in explainers endpoints.
+
+``explainer_job.py`` always runs ``plot()``'s output through
+``normalize_artifacts`` *before* pickling it, so ``plot_path``/``plots_path``
+on disk hold plain wire-format dicts, never the live ``Artifact``/
+``ArtifactGroup`` instances ``plot()`` returned. These tests exercise
+``_attach_stories`` against that real, dict-shaped input (not hand-built
+Pydantic objects) to guard against the regression where dict-shaped groups
+crashed the plot endpoint with a 500 instead of just omitting the story.
+
+Only the pure helper functions are imported, not the FastAPI app, so this
+runs without booting a TestClient.
+"""
+
+from unittest.mock import MagicMock
+
+from DashAI.back.api.api_v1.endpoints.explainers import (
+ _as_artifact_target,
+ _as_group_target,
+ _attach_stories,
+)
+from DashAI.back.core.artifacts import Artifact, ArtifactGroup, normalize_artifacts
+from DashAI.back.explainability.explainers.kernel_shap import KernelShap
+from DashAI.back.explainability.explainers.permutation_feature_importance import (
+ PermutationFeatureImportance,
+)
+
+
+def test_attach_stories_global_dict_shaped_matches_job_output():
+ """Global plot: raw items are dicts, as ``explainer_job.py`` pickles them."""
+ explanation = {
+ "features": ["age", "income"],
+ "importances_mean": [0.084, 0.061],
+ "importances_std": [0.01, 0.01],
+ }
+ explainer = PermutationFeatureImportance(model=MagicMock(), scoring="accuracy")
+
+ # Mirrors explainer_job.py: normalize_artifacts(explainer.plot(explanation)).
+ raw = normalize_artifacts(explainer.plot(explanation))
+ normalized = normalize_artifacts(explainer.plot(explanation))
+
+ _attach_stories(normalized, raw, explanation, explainer)
+
+ group = normalized[0]["groups"][0]
+ assert group["title"] == "Top 2 features"
+ assert group["story"]["en"].startswith("Ranked by the drop in accuracy")
+
+
+def test_attach_stories_local_dict_shaped_matches_job_output():
+ """Local plot: raw items are dicts, as ``explainer_job.py`` pickles them."""
+ explanation = {
+ "metadata": {"feature_names": ["age", "income"], "target_names": ["no", "yes"]},
+ "base_values": [0.4, 0.6],
+ 0: {
+ "instance_values": [35, 50000],
+ "model_prediction": [0.2, 0.8],
+ "shap_values": [[-0.1, -0.05], [0.1, 0.05]],
+ },
+ }
+ explainer = KernelShap(model=MagicMock())
+
+ # Mirrors explainer_job.py:
+ # normalize_artifacts(explainer.plot(explanation), create_grouped=True).
+ raw = normalize_artifacts(explainer.plot(explanation), create_grouped=True)
+ normalized = normalize_artifacts(explainer.plot(explanation), create_grouped=True)
+
+ _attach_stories(normalized, raw, explanation, explainer, create_grouped=True)
+
+ group = normalized[0]["groups"][0]
+ assert group["title"] == "Instance 1"
+ assert "yes" in group["story"]["en"]
+
+
+def test_attach_stories_is_a_noop_without_a_story_explainer():
+ """No explainer (couldn't be built) means no story, not a crash."""
+ explanation = {
+ "features": ["age"],
+ "importances_mean": [0.1],
+ "importances_std": [0.0],
+ }
+ explainer = PermutationFeatureImportance(model=MagicMock(), scoring="accuracy")
+ raw = normalize_artifacts(explainer.plot(explanation))
+ normalized = normalize_artifacts(explainer.plot(explanation))
+
+ _attach_stories(normalized, raw, explanation, None)
+
+ assert normalized[0]["groups"][0].get("story") is None
+
+
+def test_as_group_target_builds_a_real_artifact_group_from_a_dict():
+ """A dict coerces into a real ``ArtifactGroup`` (isinstance must hold)."""
+ group = _as_group_target({"title": "Top 2 features", "artifacts": []})
+ assert isinstance(group, ArtifactGroup)
+ assert group.title == "Top 2 features"
+
+ live = ArtifactGroup(title="already live", artifacts=[])
+ assert _as_group_target(live) is live
+
+
+def test_as_artifact_target_builds_a_real_artifact_from_a_dict():
+ """A dict coerces into a real ``Artifact`` (isinstance must hold)."""
+ artifact = _as_artifact_target({"title": "Permutation Feature Importance"})
+ assert isinstance(artifact, Artifact)
+ assert not isinstance(artifact, ArtifactGroup)
+ assert artifact.title == "Permutation Feature Importance"
diff --git a/tests/back/explainers/test_image_explainers.py b/tests/back/explainers/test_image_explainers.py
index 88bc341b1..281546bdc 100644
--- a/tests/back/explainers/test_image_explainers.py
+++ b/tests/back/explainers/test_image_explainers.py
@@ -122,7 +122,7 @@ def test_grad_cam(images, method):
groups = plot[0].groups
assert len(groups) == len(images)
for group in groups:
- assert [a.type for a in group.artifacts] == ["plotly", "text"]
+ assert [a.type for a in group.artifacts] == ["plotly"]
def test_grad_cam_rejects_non_convolutional_models(images):
@@ -146,7 +146,7 @@ def test_occlusion_saliency(images):
groups = plot[0].groups
assert len(groups) == len(images)
for group in groups:
- assert [a.type for a in group.artifacts] == ["plotly", "text"]
+ assert [a.type for a in group.artifacts] == ["plotly"]
def test_occlusion_saliency_works_without_conv_layers(images):
diff --git a/tests/back/explainers/test_lib_explainers.py b/tests/back/explainers/test_lib_explainers.py
index acd8cf9de..dbd3790df 100644
--- a/tests/back/explainers/test_lib_explainers.py
+++ b/tests/back/explainers/test_lib_explainers.py
@@ -141,7 +141,7 @@ def test_dice_counterfactual(trained_model, dataset):
groups = plot[0].groups
assert len(groups) == len(instance_keys)
for group in groups:
- assert [a.type for a in group.artifacts] == ["table", "text"]
+ assert [a.type for a in group.artifacts] == ["table"]
class DummyTextModel:
@@ -175,5 +175,5 @@ def test_lime_text():
assert len(plot) == 1
groups = plot[0].groups
assert len(groups) == 1
- assert [a.type for a in groups[0].artifacts] == ["plotly", "text"]
- assert "good" in groups[0].artifacts[1].payload
+ assert [a.type for a in groups[0].artifacts] == ["plotly"]
+ assert "good" in explainer.story(explanation, groups[0]).en
diff --git a/tests/back/explainers/test_new_explainers.py b/tests/back/explainers/test_new_explainers.py
index 775a59366..63418b633 100644
--- a/tests/back/explainers/test_new_explainers.py
+++ b/tests/back/explainers/test_new_explainers.py
@@ -139,12 +139,12 @@ def test_nearest_counterfactual(trained_model, dataset):
plot = explainer.plot(explanation)
# A single grouped artifact with one group per instance, each holding a
- # table and a text artifact.
+ # comparison table.
assert len(plot) == 1
groups = plot[0].groups
assert len(groups) == len(instance_keys)
for group in groups:
- assert [a.type for a in group.artifacts] == ["table", "text"]
+ assert [a.type for a in group.artifacts] == ["table"]
first_table = groups[0].artifacts[0].payload
# Feature rows plus the predicted class row.
@@ -202,8 +202,8 @@ def test_contrastive_shap(trained_model, dataset):
assert len(plot) == 1
groups = plot[0].groups
assert len(groups) == len(instance_keys)
- assert [a.type for a in groups[0].artifacts] == ["plotly", "text"]
- assert "rather than" in groups[0].artifacts[1].payload
+ assert [a.type for a in groups[0].artifacts] == ["plotly"]
+ assert "rather than" in explainer.story(explanation, groups[0]).en
def test_contrastive_shap_fixed_foil(trained_model, dataset):
diff --git a/tests/back/explainers/test_task_explainers.py b/tests/back/explainers/test_task_explainers.py
index c656efc59..65549c706 100644
--- a/tests/back/explainers/test_task_explainers.py
+++ b/tests/back/explainers/test_task_explainers.py
@@ -173,8 +173,8 @@ def test_regression_kernel_shap(trained_regressor, regression_dataset):
assert len(plot) == 1
groups = plot[0].groups
assert len(groups) == len(instance_keys)
- assert [a.type for a in groups[0].artifacts] == ["plotly", "text"]
- assert "baseline" in groups[0].artifacts[1].payload
+ assert [a.type for a in groups[0].artifacts] == ["plotly"]
+ assert "baseline" in explainer.story(explanation, groups[0]).en
def test_regression_partial_dependence(trained_regressor, regression_dataset):
@@ -276,8 +276,8 @@ def test_token_ablation_explains_influential_tokens():
assert len(plot) == 1
groups = plot[0].groups
assert len(groups) == 2
- assert [a.type for a in groups[0].artifacts] == ["plotly", "text"]
- assert "good" in groups[0].artifacts[1].payload
+ assert [a.type for a in groups[0].artifacts] == ["plotly"]
+ assert "good" in explainer.story(explanation, groups[0]).en
def test_token_ablation_ignores_tokenizer_columns():