Skip to content
Merged
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
75 changes: 74 additions & 1 deletion shapash/explainer/smart_plotter.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
compute_tsne_projection,
plot_clustering_by_explainability,
plot_confusion_matrix,
plot_lift_curve,
plot_scatter_prediction,
)
from shapash.plots.plot_feature_importance import plot_feature_importance
Expand Down Expand Up @@ -1939,6 +1940,76 @@ def confusion_matrix_plot(
auto_open=auto_open,
)

def lift_curve_plot(
self,
selection: list | None = None,
label: int | str = -1,
nb: int = 100,
target_fraction: float = 0.1,
max_points: int = 2000,
width: int = 900,
height: int = 600,
file_name: str | None = None,
auto_open: bool = False,
) -> go.Figure:
"""
Display a Plotly lift curve for classification predictions.

Parameters
----------
selection: list, optional
Contains list of index, subset of the input DataFrame that we want to plot.
label: integer or string (default -1)
If the label is of string type, check if it can be changed to integer to select the
good dataframe object.
nb: int, optional
Number of intervals used to build the curve, by default 100.
target_fraction: float, optional
Share of the ranked population used to compute the summary capture metric,
by default 0.1 for Lift@10%.
max_points: int, optional
Maximum number of observations used in the plot, by default 2000.
width : int (default: 900)
Plotly figure - layout width.
height : int (default: 600)
Plotly figure - layout height.
file_name: string (optional)
Specify the save path of html files. If it is not provided, no file will be saved.
auto_open: bool (default=False)
open automatically the plot.

Returns
-------
plotly.graph_objects.Figure
Lift curve figure.
"""

if self._explainer._case != "classification":
raise ValueError("Lift curve is only available for classification case")

label_num, label_code, label_value = self._explainer.check_label_name(label)

if self._explainer.proba_values is None:
self._explainer.predict_proba()

return plot_lift_curve(
x_data=self._explainer.x_init,
y_target=self._explainer.y_target,
y_proba_values=self._explainer.proba_values,
style_dict=self._style_dict,
selection=selection,
label_num=label_num,
label_code=label_code,
label_value=label_value,
nb=nb,
target_fraction=target_fraction,
max_points=max_points,
width=width,
height=height,
file_name=file_name,
auto_open=auto_open,
)

def distribution_plot(
self,
col: str,
Expand Down Expand Up @@ -2482,6 +2553,8 @@ def clustering_by_explainability_plot(
color_value_data = [el.loc[list_ind, :] for el in color_value_data]
colorbar_title = [el.capitalize() for el in color_value]

effective_style_dict = self._style_dict if style_dict is None else style_dict

return plot_clustering_by_explainability(
values_to_project=values_to_project,
hv_text=hv_text,
Expand All @@ -2496,7 +2569,7 @@ def clustering_by_explainability_plot(
keep_quantile=keep_quantile,
random_state=random_state,
marker_size=marker_size,
style_dict=style_dict,
style_dict=effective_style_dict,
opacity=opacity,
width=width,
height=height,
Expand Down
229 changes: 229 additions & 0 deletions shapash/plots/plot_evaluation_metrics.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Any

import numpy as np
import pandas as pd
from plotly import graph_objs as go
Expand All @@ -22,6 +24,233 @@
from shapash.utils.utils import adjust_title_height, truncate_str, tuning_colorscale


def _compute_lift_curve(
y_true_binary: np.ndarray | pd.Series,
y_score: np.ndarray | pd.Series,
nb: int = 100,
target_fraction: float = 0.1,
) -> tuple[np.ndarray, np.ndarray, float]:
Comment on lines +27 to +32

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you add a docstring?

"""
Compute lift curve coordinates and the capture rate at 10% of the population.

Parameters
----------
y_true_binary : numpy.ndarray or pandas.Series
Binary ground-truth labels where positive samples are encoded as 1.
y_score : numpy.ndarray or pandas.Series
Predicted scores or probabilities used to rank samples from most to least likely
to belong to the positive class.
nb : int, optional
Number of evenly spaced population fractions used to evaluate the curve.
target_fraction : float, optional
Share of the ranked population used to compute the summary capture metric.
The default value of ``0.1`` corresponds to the top 10% of samples.

Returns
-------
tuple[numpy.ndarray, numpy.ndarray, float]
Population fractions, cumulative share of positives captured at each fraction,
and the share of positives captured within the top 10% ranked samples.

Raises
------
ValueError
If the selected label contains no positive samples.
ValueError
If ``target_fraction`` is not strictly between 0 and 1.
"""
if not 0 < target_fraction < 1:
raise ValueError("target_fraction must be strictly between 0 and 1.")

y_true_binary = np.asarray(y_true_binary).astype(int)
y_score = np.asarray(y_score).astype(float)

order = np.argsort(y_score)[::-1]
y_sorted = y_true_binary[order]

total_positive = y_sorted.sum()
if total_positive == 0:
raise ValueError("Unable to compute lift curve: no positive samples found for the selected label.")

cum_positive = np.concatenate(([0], np.cumsum(y_sorted)))
fractions = np.linspace(0.0, 1.0, nb + 1)
cutoffs = np.rint(fractions * y_sorted.shape[0]).astype(int)
cutoffs = np.clip(cutoffs, 0, y_sorted.shape[0])

lift_curve = cum_positive[cutoffs] / total_positive

target_count = max(1, int(round(y_sorted.shape[0] * target_fraction)))
target_capture = cum_positive[target_count] / total_positive

return fractions, lift_curve, target_capture


def plot_lift_curve(
x_data: pd.DataFrame,
y_target: pd.Series | pd.DataFrame | None,
y_proba_values: pd.DataFrame,
style_dict: dict[str, Any],
selection: list[Any] | None = None,
label_num: int | None = None,
label_code: Any | None = None,
label_value: Any | None = None,
nb: int = 100,
target_fraction: float = 0.1,
max_points: int = 2000,
width: int = 900,
height: int = 600,
file_name: str | None = None,
auto_open: bool = False,
) -> go.Figure:
"""
Generate a Plotly lift curve for classification probabilities.

Parameters
----------
x_data : pandas.DataFrame
Feature dataset used to align sampling indices.
y_target : pandas.Series or pandas.DataFrame
True target values.
y_proba_values : pandas.DataFrame
Predicted probabilities for each class.
style_dict : dict
Dictionary containing style settings used in Shapash plots.
Lift curve colors can be customized through ``style_dict["lift_curve_colors"]``
with keys ``curve``, ``random``, and ``perfect``.
selection : list, optional
Subset of indices to display.
label_num : int, optional
Numeric class index in probability columns.
label_code : object, optional
Class code in original target space.
label_value : str, optional
Human-readable label for plot subtitle.
nb : int, optional
Number of intervals used to compute the curve.
target_fraction : float, optional
Share of the ranked population used to compute the summary capture metric.
The default value of ``0.1`` corresponds to Lift@10%.
max_points : int, optional
Maximum number of observations used in plot.
width : int, optional
Plotly figure width.
height : int, optional
Plotly figure height.
file_name : str, optional
File path to save html output.
auto_open : bool, optional
Whether to open the saved plot automatically.

Returns
-------
go.Figure
Plotly lift curve figure.
"""
if y_target is None:
fig = go.Figure()
fig.update_layout(
xaxis={"visible": False},
yaxis={"visible": False},
annotations=[
{
"text": "Provide the y_target argument in the compile() method to display this plot.",
"xref": "paper",
"yref": "paper",
"showarrow": False,
"font": {"size": 14},
}
],
)
return fig

if label_num is None or label_code is None:
raise ValueError("Label information is required to compute the lift curve.")

list_ind, addnote = subset_sampling(x_data, selection, max_points)

if isinstance(y_target, pd.DataFrame):
y_true = y_target.iloc[:, 0].loc[list_ind]
else:
y_true = y_target.loc[list_ind]

y_score = y_proba_values.iloc[:, label_num].loc[list_ind]
y_true_binary = (y_true == label_code).astype(int)

fractions, curve_values, target_capture = _compute_lift_curve(
y_true_binary=y_true_binary,
y_score=y_score,
nb=nb,
target_fraction=target_fraction,
)

positive_rate = y_true_binary.mean() * 100
target_percentage = target_fraction * 100
subtitle = f"Response: <b>{label_value}</b> - Lift@{target_percentage:g}%: <b>{target_capture:.3f}</b>"
if addnote:
subtitle += f" - {addnote}"

lift_curve_colors = style_dict.get("lift_curve_colors", {})
lift_curve_color = lift_curve_colors.get("curve", style_dict["prediction_plot"][1])
random_model_color = lift_curve_colors.get("random", style_dict["prediction_plot"][0])
perfect_model_color = lift_curve_colors.get("perfect", style_dict["dict_stability_bar_colors"][0])

fig = go.Figure()
fig.add_trace(
go.Scatter(
x=100 * fractions,
y=curve_values,
mode="lines",
name="Lift curve",
line={"color": lift_curve_color, "width": 3},
hovertemplate="Population: %{x:.1f}%<br>Positives captured: %{y:.3f}<extra></extra>",
)
)
fig.add_trace(
go.Scatter(
x=[0, 100],
y=[0, 1],
mode="lines",
name="Random model",
line={"color": random_model_color, "dash": "dot", "width": 2},
hovertemplate="Population: %{x:.1f}%<br>Positives captured: %{y:.3f}<extra></extra>",
)
)
fig.add_trace(
go.Scatter(
x=[0, positive_rate, 100],
y=[0, 1, 1],
mode="lines",
name="Perfect model",
line={"color": perfect_model_color, "dash": "dash", "width": 2},
hovertemplate="Population: %{x:.1f}%<br>Positives captured: %{y:.3f}<extra></extra>",
)
)

title = "Lift Curve"
title += f"<br><sup>{subtitle}</sup>"
dict_t = style_dict["dict_title"] | {"text": title, "y": adjust_title_height(height)}
dict_xaxis = style_dict["dict_xaxis"] | {"text": "Share of population targeted (%)"}
dict_yaxis = style_dict["dict_yaxis"] | {"text": "Share of positives captured"}

fig.update_layout(
template="none",
title=dict_t,
width=width,
height=height,
xaxis_title=dict_xaxis,
yaxis_title=dict_yaxis,
hovermode="closest",
)

fig.update_yaxes(range=[0, 1.05], automargin=True)
fig.update_xaxes(range=[0, 100], automargin=True)

if file_name:
plot(fig, filename=file_name, auto_open=auto_open)

return fig


def plot_scatter_prediction(
x_data,
y_pred,
Expand Down
5 changes: 5 additions & 0 deletions shapash/style/style_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ def define_style(palette):
style_dict["interactions_discrete_colors"] = palette["interaction_discrete"]
style_dict["dict_stability_bar_colors"] = convert_string_to_int_keys(palette["stability_bar"])
style_dict["dict_compacity_bar_colors"] = convert_string_to_int_keys(palette["compacity_bar"])
style_dict["lift_curve_colors"] = {
"curve": style_dict["prediction_plot"][1],
"random": style_dict["prediction_plot"][0],
"perfect": style_dict["dict_stability_bar_colors"][0],
}
style_dict["webapp_button"] = convert_string_to_int_keys(palette["webapp_button"])
style_dict["webapp_bkg"] = palette["webapp_bkg"]
style_dict["webapp_title"] = palette["webapp_title"]
Expand Down
Loading