Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Add stories to local_explainer

Revision ID: 0b08a41e4dbd
Revises: 6273b0d04af3
Create Date: 2026-08-04 10:46:18.347215

"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "0b08a41e4dbd"
down_revision: Union[str, None] = "6273b0d04af3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column("local_explainer", sa.Column("stories", sa.JSON(), nullable=True))


def downgrade() -> None:
op.drop_column("local_explainer", "stories")
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Add story fields to global_explainer

Revision ID: 6273b0d04af3
Revises: c4e8a1d20f3b
Create Date: 2026-08-03 23:22:57.981298

"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "6273b0d04af3"
down_revision: Union[str, None] = "c4e8a1d20f3b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"global_explainer",
sa.Column("story", sa.String(), nullable=True),
)
op.add_column(
"global_explainer",
sa.Column("story_huey_id", sa.String(), nullable=True),
)


def downgrade() -> None:
op.drop_column("global_explainer", "story_huey_id")
op.drop_column("global_explainer", "story")
1 change: 1 addition & 0 deletions DashAI/back/api/api_v1/endpoints/explainers.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
if TYPE_CHECKING:
from sqlalchemy.orm import sessionmaker


logging.basicConfig(level=logging.DEBUG)
log = logging.getLogger(__name__)

Expand Down
3 changes: 3 additions & 0 deletions DashAI/back/dependencies/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ class GlobalExplainer(Base):
explanation_path: Mapped[str] = mapped_column(String, nullable=True)
plot_path: Mapped[str] = mapped_column(String, nullable=True)
plot_overrides: Mapped[JSON] = mapped_column(JSON, nullable=True)
story: Mapped[str] = mapped_column(String, nullable=True)
story_huey_id: Mapped[str] = mapped_column(String, nullable=True)
parameters: Mapped[JSON] = mapped_column(JSON)
created: Mapped[DateTime] = mapped_column(DateTime, default=datetime.now)
status: Mapped[Enum] = mapped_column(
Expand Down Expand Up @@ -392,6 +394,7 @@ class LocalExplainer(Base):
explanation_path: Mapped[str] = mapped_column(String, nullable=True)
plots_path: Mapped[str] = mapped_column(String, nullable=True)
plot_overrides: Mapped[JSON] = mapped_column(JSON, nullable=True)
stories: Mapped[JSON] = mapped_column(JSON, nullable=True)
input_dataset_path: Mapped[str] = mapped_column(String, nullable=True)
parameters: Mapped[JSON] = mapped_column(JSON)
fit_parameters: Mapped[JSON] = mapped_column(JSON)
Expand Down
35 changes: 35 additions & 0 deletions DashAI/back/dependencies/registry/component_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,36 @@ def _get_base_type(self, new_component: type) -> str:

return base_classes_cantidates[0].TYPE

@staticmethod
def _overrides_story(component: type) -> bool:
"""Whether ``component`` provides its own ``story`` implementation.

``BaseGlobalExplainer``/``BaseLocalExplainer`` both declare a
``story`` method that just raises ``NotImplementedError``, so
inheriting it unmodified means the explainer does not support story
generation. Walks the MRO (most derived first) to find which class
actually defines ``story``, rather than importing the base classes
directly (avoids a needless coupling to the explainability module).

Parameters
----------
component : type
The component class to inspect.

Returns
-------
bool
``True`` if some class more specific than the two base
explainer classes defines ``story``.
"""
for klass in component.__mro__:
if "story" in vars(klass):
return klass.__name__ not in (
"BaseGlobalExplainer",
"BaseLocalExplainer",
)
return False

@staticmethod
@beartype
def _collect_compatible_components(component: type) -> List[str]:
Expand Down Expand Up @@ -251,6 +281,11 @@ def register_component(self, new_component: Type) -> None:
"color": getattr(new_component, "COLOR", None),
}

if hasattr(new_component, "story"):
new_register_component["supports_story"] = self._overrides_story(
new_component
)

if base_type not in self._registry:
self._registry[base_type] = {new_component.__name__: new_register_component}
else:
Expand Down
125 changes: 105 additions & 20 deletions DashAI/back/explainability/explainers/contrastive_shap.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
ArtifactGroup,
GroupedArtifacts,
PlotlyArtifact,
TextArtifact,
)
from DashAI.back.core.schema_fields import (
BaseSchema,
Expand Down Expand Up @@ -307,7 +306,8 @@ def explain_instance(self, instances):
-------
dict
Dictionary with, for each instance, the fact and foil classes and
the per-feature attribution difference (fact minus foil).
the per-feature attribution difference (fact minus foil). Also
cached on ``self.explanation`` for :meth:`story`.
"""
import numpy as np

Expand Down Expand Up @@ -342,6 +342,7 @@ def explain_instance(self, instances):
"delta_values": np.round(delta, 3).tolist(),
}

self.explanation = explanation
return explanation

def _create_plot(self, data, fact_name, foil_name, fact_prob, foil_prob):
Expand Down Expand Up @@ -398,8 +399,62 @@ def _create_plot(self, data, fact_name, foil_name, fact_prob, foil_prob):

return fig

def _summarize_instance(self, instance: dict, metadata: dict) -> str:
"""Build the "why P rather than Q" sentence for one explained instance.

Computed directly from the explanation's own numbers (fact/foil
class, their probabilities, and the top contributing features by
absolute delta), independent of how :meth:`plot` renders them, so
:meth:`story` can call this without depending on a rendered artifact.

Parameters
----------
instance : dict
One instance's entry from the explanation dict (i.e.
``explanation[i]`` for some index ``i``, excluding the top-level
``"metadata"`` entry).
metadata : dict
The explanation's ``"metadata"`` entry (``feature_names``,
``target_names``).

Returns
-------
str
The contrastive summary sentence.
"""
import numpy as np

feature_names = metadata["feature_names"]
target_names = metadata["target_names"]

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 = float(np.round(prediction[fact_class], 3))
foil_prob = float(np.round(prediction[foil_class], 3))

instance_values = instance["instance_values"]
delta = np.asarray(instance["delta_values"])
# Stable sort to match plot()'s previous pandas-based tie-breaking:
# largest |delta| first among the top 3.
top_indices = np.argsort(np.abs(delta), kind="stable")[::-1][:3]
top_features = ", ".join(
f"{feature_names[j]}={instance_values[j]}" for j in top_indices
)

return (
f"The model predicted {fact_name} (p={fact_prob}) rather than "
f"{foil_name} (p={foil_prob}) mainly because of: "
f"{top_features}."
)

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.

The narrative summary is not computed here: it is only built on
demand by :meth:`story`.

Parameters
----------
Expand All @@ -410,7 +465,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
Expand Down Expand Up @@ -449,21 +504,51 @@ 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, explainer_output, prediction_context):
"""Build the "why P rather than Q" sentence from ``self.explanation``.

Computed on demand, only when a story is requested: :meth:`plot`
never builds this narrative, so no cost is paid unless it is asked
for.

Parameters
----------
explainer_output : GroupedArtifacts
The explained instance's group, as produced by :meth:`plot`. Only
its ``title`` (``"Instance {n}"``) is used, to recover which
entry of ``self.explanation`` this call is about.
prediction_context : DashAIDataset
Unused; the summary is built entirely from ``self.explanation``.

Returns
-------
str
The instance's contrastive summary sentence.

Raises
------
ValueError
If the instance's title cannot be matched to an explained
instance, or ``self.explanation`` was not set before calling this
(see :meth:`explain_instance`).
"""
if not self.explanation:
raise ValueError(
"self.explanation must be set before calling story() "
"(see explain_instance)."
)

title = explainer_output.groups[0].title or ""
try:
index = int(title.rsplit(" ", 1)[-1]) - 1
instance = self.explanation[index]
except (ValueError, KeyError) as e:
raise ValueError(
f"Could not match group title {title!r} to an explained instance."
) from e

return self._summarize_instance(instance, self.explanation["metadata"])
Loading
Loading