Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
596856f
feat(report): improve multiclass explainability
guillaume-vignal Oct 2, 2026
e7b8cf6
Merge branch 'develop' into feature/smart-report-class-interactions
guillaume-vignal Oct 5, 2026
360c113
Fix mypy class list narrowing
guillaume-vignal Oct 5, 2026
d9b9f7b
Refine smart report class interactions handling and tests
guillaume-vignal Oct 6, 2026
e2066a0
fix(report): silence panel sizing warnings during report generation
guillaume-vignal Oct 6, 2026
c230ed9
fix(report): revert sidebar and layout css adjustments
guillaume-vignal Oct 6, 2026
990f4ff
chore: remove AGENTS.md from branch diff
guillaume-vignal Oct 6, 2026
73e047c
test(report): align CSS assertions and markdown line breaks
guillaume-vignal Oct 6, 2026
882d8f9
Merge branch 'develop' into feature/smart-report-class-interactions
guillaume-vignal Oct 6, 2026
7650225
refactor(report): drop ReportAnchor in favor of section wrapper
guillaume-vignal Oct 6, 2026
9e51e55
fix(report): restore dataframe auto-style dispatch order
guillaume-vignal Oct 6, 2026
b8b0a71
Fix Panel Plotly report errors on selector updates
guillaume-vignal Oct 6, 2026
6f5f8c6
Improve report tab title and favicon handling
guillaume-vignal Oct 6, 2026
3e33e47
test: remove warnings in report and utils unit tests
guillaume-vignal Oct 8, 2026
09a7a79
Adjust report favicon handling and align related tests
guillaume-vignal Oct 8, 2026
cf53aa0
Fix contribution report panel rendering and remove legacy bar override
guillaume-vignal Oct 8, 2026
0b8012b
docs: improve spacing in compile report_mode docstring
guillaume-vignal Oct 8, 2026
b3d8191
report doc
guerinclement Oct 8, 2026
2b9c7dd
Merge branch 'develop' into feature/smart-report-class-interactions
guillaume-vignal Oct 9, 2026
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
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
import subprocess # noqa: E402

# Generates the report example in the documentation
subprocess.call(["python", "../tutorial/generate_report/shapash_report_example.py"])
subprocess.call(["python", "../scripts/generate_report.py", "--case", "classification", "--output", "../tutorial/generate_report/output/report.html"])
html_extra_path = ["../tutorial/generate_report/output/report.html"]


Expand Down
359 changes: 359 additions & 0 deletions scripts/generate_report.py

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions scripts/reports/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Keep generated HTML reports local.
*
!.gitignore
11 changes: 11 additions & 0 deletions shapash/report/assets/default_classification_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ sections:
- type: model_analysis
params:
title: "Model analysis"
show_index: false

- type: group
params:
Expand All @@ -73,13 +74,23 @@ sections:
params:
title: "Target analysis"
show_train: true
show_index: true

# Correlation matrix; reduce `max_features` for faster/lighter reports.
- type: correlations_plot
params:
title: "Multivariate analysis"
max_features: 20

- type: group
params:
title: "Model explainability"
blocks:
- type: class_explainability
params:
title: "Explained classes"
label: 1

- type: group
params:
title: "Model performance"
Expand Down
2 changes: 2 additions & 0 deletions shapash/report/assets/default_regression_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ sections:
- type: model_analysis
params:
title: "Model analysis"
show_index: false

- type: group
params:
Expand All @@ -72,6 +73,7 @@ sections:
params:
title: "Target analysis"
show_train: true
show_index: true

# Correlation matrix; reduce `max_features` for faster/lighter reports.
- type: correlations_plot
Expand Down
2 changes: 2 additions & 0 deletions shapash/report/assets/default_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ sections:
- type: model_analysis
params:
title: "Model analysis"
show_index: false

- type: group
params:
Expand All @@ -34,6 +35,7 @@ sections:
params:
title: "Target analysis"
show_train: true
show_index: true

- type: correlations_plot
params:
Expand Down
162 changes: 136 additions & 26 deletions shapash/report/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from shapash.report.common import compute_col_types, series_dtype
from shapash.report.core import _wrap_section_anchor
from shapash.report.data_analysis import perform_global_dataframe_analysis, perform_univariate_dataframe_analysis
from shapash.report.panel_support import _add_css_classes, _auto_style_viewable, _coerce_viewable
from shapash.report.panel_support import _add_css_classes, _auto_style_viewable, _coerce_viewable, build_plotly_pane
from shapash.report.validation import render_block_error, stats_to_table
from shapash.utils.transform import apply_postprocessing, handle_categorical_missing, inverse_transform

Expand Down Expand Up @@ -113,13 +113,14 @@ def __init__(
self.explainer = explainer.explainer if explainer else None
self.x_train_init = x_train
self.x_train_pre = self._preprocess_train_data(x_train)
self.x_init = getattr(explainer, "x_init", None)
self.x_init = getattr(self.explainer, "x_init", None)
self.df_train_test = self._create_train_test_df(test=self.x_init, train=self.x_train_pre)
self.y_train, self.target_name_train = self._get_values_and_name(y_train, "target")
self.y_test, self.target_name_test = self._get_values_and_name(y_test, "target")
self.target_name = self.target_name_train if self.target_name_train is not None else self.target_name_test
self.max_points = max_points
self._inside_group = False
self.class_navigation_items: dict[str, list[dict[str, str]]] = {}

if self.explainer is not None:
if self.explainer.y_pred is not None:
Expand All @@ -134,6 +135,9 @@ def render_block(self, block_cfg: ReportBlockConfig) -> pn.viewable.Viewable | N

block_type = block_cfg.get("type", "")
params = block_cfg.get("params", {})
if block_type == "class_explainability":
params = dict(params)
params["navigation_id"] = block_cfg.get("_section_id", "class-explainability")

if block_type == "group":
previous_inside_group = getattr(self, "_inside_group", False)
Expand Down Expand Up @@ -339,14 +343,16 @@ def block_global_analysis(self, title: str = "") -> BlockContent:
return title, [stats_table]

@block
def block_model_analysis(self, title: str = "Model information") -> BlockContent:
def block_model_analysis(self, title: str = "Model information", show_index: bool = False) -> BlockContent:
"""Render model metadata and parameter tables.
Requires explainer.

Parameters
----------
title : str, default="Model information"
Section title displayed above model details.
show_index : bool, default=False
Whether the model parameter tables display their pandas row index.

Returns
-------
Expand Down Expand Up @@ -389,19 +395,38 @@ def _truncate(value: Any, max_len: int) -> str:
"Value": [_truncate(val, 300) for _, val in params_items[split_idx:]],
}
)
params_table = (left_df, pn.Spacer(width=24), right_df)
params_table = (
pn.pane.DataFrame(
left_df,
index=show_index,
width_policy="min",
sizing_mode="stretch_width",
),
pn.Spacer(width=24),
pn.pane.DataFrame(
right_df,
index=show_index,
width_policy="min",
sizing_mode="stretch_width",
),
)
Comment thread
guillaume-vignal marked this conversation as resolved.
else:
params_df = pd.DataFrame(
{
"Parameter": [_truncate(key, 50) for key, _ in params_items],
"Value": [_truncate(val, 300) for _, val in params_items],
}
)
params_table = params_df
params_table = pn.pane.DataFrame(
params_df,
index=show_index,
width_policy="min",
sizing_mode="stretch_width",
)

content: list[Any] = [
pn.pane.Markdown(
"\n".join(
" \n".join(
[
f"**Model used**: {model.__class__.__name__}",
f"**Library**: {model_module}",
Expand Down Expand Up @@ -584,6 +609,83 @@ def block_feature_importance(self, title: str = "", label: Any = None) -> BlockC
fig = explainer.plot.features_importance(label=label)
return title, [fig]

@block
def block_class_explainability(
self,
title: str = "Class-specific explainability",
label: Any = 1,
max_points: int | None = None,
navigation_id: str = "class-explainability",
include_interactions: bool = False,
nb_top_interactions: int = 5,
) -> BlockContent:
"""Render class-specific feature importance, contributions, and interactions.

Binary classification renders the requested class only. Multiclass
classification renders one set of charts for every model class. Top
interactions are included when ``include_interactions`` is True.

Parameters
----------
title : str, default="Class-specific explainability"
Section title displayed above the class-specific plots.
label : Any, default=1
Explained class for binary classification. Ignored for multiclass.
max_points : int or None, default=None
Maximum number of observations used by contribution plots.
navigation_id : str, default="class-explainability"
Stable section identifier used to connect class links to their plots.
include_interactions : bool, default=False
Whether to include a top interactions plot for each displayed class.
nb_top_interactions : int, default=5
Number of top interaction pairs to show when interactions are included.
"""
explainer = self._require_explainer("class_explainability")
if explainer._case != "classification":
raise ValueError("class_explainability block is only available for classification.")
if explainer._classes is None:
raise ValueError("class_explainability block requires model classes.")

classes = list(explainer._classes)
if len(classes) > 2:
class_codes = classes
else:
_, selected_class, _ = explainer.check_label_name(label)
class_codes = [selected_class]

content: list[Any] = []
navigation_items: list[dict[str, str]] = []
for class_code in class_codes:
_, _, class_name = explainer.check_label_name(class_code, origin="code")
anchor_id = f"{navigation_id}-class-{len(navigation_items) + 1}"
navigation_items.append({"label": str(class_name), "anchor": anchor_id})
class_content: list[pn.viewable.Viewable] = [pn.pane.Markdown(f"#### Explained class: **{class_name}**")]
importance = explainer.plot.features_importance(label=class_code)
class_content.append(build_plotly_pane(importance))
class_content.append(
self.block_contribution_plot(
title="Feature contributions",
label=class_code,
max_points=max_points,
include_all_features=True,
)
)
if include_interactions:
effective_max_points = self.max_points if max_points is None else max_points
class_content.append(pn.pane.Markdown("#### Top feature interactions"))
interactions = explainer.plot.top_interactions_plot(
nb_top_interactions=nb_top_interactions,
label=class_code,
max_points=effective_max_points,
)
class_content.append(build_plotly_pane(interactions))

class_section = _wrap_section_anchor(pn.Column(*class_content, sizing_mode="stretch_width"), anchor_id)
content.append(class_section)

self.class_navigation_items[navigation_id] = navigation_items
return title, content

@block
def block_contribution_plot(
self,
Expand Down Expand Up @@ -629,9 +731,6 @@ def block_contribution_plot(
else:
effective_max_points = max_points
fig = explainer.plot.contribution_plot(feature, label=label, max_points=effective_max_points)
for trace in fig.data:
if trace.type == "bar":
trace.marker.color = "lightgrey"
if title is None:
return self._feature_label(feature), [fig]
return title, [fig]
Expand All @@ -649,33 +748,35 @@ def block_contribution_plot(
key=lambda current_feature: (str(self._feature_label(current_feature)).lower(), str(current_feature)),
)

feature_panels: dict[str, pn.viewable.Viewable] = {}
feature_to_label: dict[str, str] = {}
for feature_name in sorted_features:
if max_points is None:
effective_max_points = self.max_points
else:
effective_max_points = max_points
fig = explainer.plot.contribution_plot(feature_name, label=label, max_points=effective_max_points)
for trace in fig.data:
if trace.type == "bar":
trace.marker.color = "lightgrey"

base_label = str(self._feature_label(feature_name))
label_text = base_label
suffix = 2
while label_text in feature_panels:
while label_text in feature_to_label:
label_text = f"{base_label} ({suffix})"
suffix += 1
feature_panels[label_text] = fig
feature_to_label[label_text] = feature_name

feature_select = pn.widgets.Select(
name="Feature",
options=list(feature_panels.keys()),
value=next(iter(feature_panels)),
label="Feature",
options=list(feature_to_label.keys()),
value=next(iter(feature_to_label)),
sizing_mode="stretch_width",
)

if max_points is None:
effective_max_points = self.max_points
else:
effective_max_points = max_points

def _render_feature_panel(selected_label: str) -> pn.viewable.Viewable:
feature_name = feature_to_label[selected_label]
fig = explainer.plot.contribution_plot(feature_name, label=label, max_points=effective_max_points)
return build_plotly_pane(fig)

selected_panel = pn.panel(
pn.bind(cast(Any, lambda selected: feature_panels[selected]), feature_select), sizing_mode="stretch_width"
pn.bind(cast(Any, _render_feature_panel), feature_select), sizing_mode="stretch_width"
)

if title is None:
Expand Down Expand Up @@ -788,6 +889,7 @@ def block_target_analysis(
show_train: bool = True,
width: int = 700,
height: int = 500,
show_index: bool = True,
) -> BlockContent:
"""Render target statistics and target distribution analysis.

Expand All @@ -801,6 +903,8 @@ def block_target_analysis(
Plot width in pixels.
height : int, default=500
Plot height in pixels.
show_index : bool, default=True
Whether the target statistics table displays its pandas row index.

Returns
-------
Expand Down Expand Up @@ -843,6 +947,12 @@ def block_target_analysis(
train_stats=train_stats[target_name] if train_stats is not None else None,
names=names,
)
target_stats = pn.pane.DataFrame(
target_stats,
index=show_index,
width_policy="min",
sizing_mode="stretch_width",
)

distribution_frames = [pd.DataFrame({target_name: y_test_series}).assign(data_train_test="test")]
if y_train_series is not None:
Expand Down Expand Up @@ -1078,7 +1188,7 @@ def block_univariate_analysis(
return title, [pn.pane.Markdown("No feature available.")]

feature_select = pn.widgets.Select(
name="Feature",
label="Feature",
options=list(feature_panels.keys()),
value=next(iter(feature_panels)),
sizing_mode="stretch_width",
Expand Down
3 changes: 3 additions & 0 deletions shapash/report/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,9 @@ def display_value(value: int | float, thousands_separator: str = ",", decimal_se
'1,255,000'

"""
if pd.isna(value):
return "N/A"

value_str = f"{value:,}".replace(",", "/thousands/").replace(".", "/decimal/")
return value_str.replace("/thousands/", thousands_separator).replace("/decimal/", decimal_separator)

Expand Down
Loading
Loading