Source code for OCDocker.OCScore.Analysis.Plotting.Stats

#!/usr/bin/env python3

# Description
###############################################################################
'''
Plotting helpers for statistical summaries (scatter/box/bar, diagnostics, PCA
importance). These utilities are used by Analysis workflows and StatTests.

Usage:

import OCDocker.OCScore.Analysis.Plotting.Stats as ocstatplot
'''

# Imports
###############################################################################
import warnings

import matplotlib.lines as mlines
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
from matplotlib.axes import Axes
import numpy as np
import pandas as pd
import scipy.stats as sstats
import seaborn as sns
import OCDocker.Error as ocerror

from typing import Any, Mapping, Optional, Sequence

# License
###############################################################################
'''Copyright (c) Federal University of Rio de Janeiro (UFRJ), Artur Duque Rossi, and Pedro Henrique Monteiro Torres.

SPDX-License-Identifier: BSD-3-Clause

See the LICENSE file for full terms.
'''

# Classes
###############################################################################

# Palette for the shortcut-risk scatter (validated for CVD separation).
COLOR_REFERENCE = "#2a78d6"   # blue   -> the reference model
COLOR_RETAINED = "#0ca30c"    # green  -> eligible with a distributed explanation
COLOR_DISCARDED = "#d03b3b"   # red    -> eligible, but dependent on a dominant feature
COLOR_OTHER = "#b6b4ab"       # gray   -> not eligible; the shortcut rule does not apply
COLOR_TEXT = "#0b0b0b"
COLOR_TEXT_MUTED = "#52514e"
COLOR_GRID = "#e1e0d9"

# Functions
###############################################################################
## Private ##

## Public ##

[docs] def plot_ablation_bedroc_significance_bars( significance_df: pd.DataFrame, *, reference_policy: str = 'full_ocscore', metric_label: str = 'BEDROC', output_dir: str = 'plots', alpha: float = 0.05 ) -> None: ''' Plot per-policy BEDROC means vs a reference policy, colored by paired significance. Parameters ---------- significance_df : pd.DataFrame Output of ``OCScore.Analysis.AblationSignificance.compute_ablation_significance`` (expects columns 'policy', 'reference_mean', 'policy_mean', 'mean_diff', 'pvalue_corrected', 'reject_null'). reference_policy : str Name of the reference policy, used for the axis label and reference line. Default: 'full_ocscore'. metric_label : str Metric label for titling. Default: 'BEDROC'. output_dir : str Where to save the plot image. Default: 'plots'. alpha : float Family-wise significance threshold used only for the subtitle text. Default: 0.05. ''' df = significance_df.sort_values(by='policy_mean', ascending=True).reset_index(drop=True) reference_mean = float(df['reference_mean'].iloc[0]) if not df.empty else float('nan') def stars(p: float) -> str: '''Convert a corrected p-value to significance stars. Parameters ---------- p : float The corrected p-value to convert. Returns ------- str Significance stars: '***' for p < 0.001, '**' for p < 0.01, '*' for p < 0.05, '' otherwise. ''' if pd.isna(p): return '' return '***' if p < 0.001 else ('**' if p < 0.01 else ('*' if p < 0.05 else '')) def bar_colour(row: pd.Series) -> str: if not bool(row['reject_null']): return 'tab:gray' return 'tab:blue' if row['mean_diff'] > 0 else 'tab:red' palette = dict(zip(df['policy'], df.apply(bar_colour, axis=1))) plt.figure(figsize=(10, max(6, 0.35 * len(df)))) ax = sns.barplot(data=df, x='policy_mean', y='policy', hue='policy', palette=palette, legend=False, orient='h') ax.axvline(reference_mean, color='black', linestyle='--', linewidth=1, label=f'{reference_policy} (reference)') for i, (mean_val, p_corr) in enumerate(zip(df['policy_mean'], df['pvalue_corrected'])): ax.text( mean_val + 0.005, i, f"{mean_val:.3f} {stars(p_corr)}", ha='left', va='center', fontsize=8, ) legend_handles = [ mlines.Line2D([0], [0], color='black', linestyle='--', label=f'{reference_policy} (reference)'), mlines.Line2D([0], [0], color='tab:red', lw=6, label='Significantly worse'), mlines.Line2D([0], [0], color='tab:blue', lw=6, label='Significantly better'), mlines.Line2D([0], [0], color='tab:gray', lw=6, label=f'Not significant (Holm, alpha={alpha:g})'), ] ax.legend(handles=legend_handles, loc='upper left', bbox_to_anchor=(1.01, 1.0), fontsize=8, borderaxespad=0.0) ax.set_title(f'{metric_label} per feature-ablation policy vs {reference_policy}') ax.set_xlabel(metric_label) ax.set_ylabel('Feature policy') plt.grid(True, axis='x', linestyle=':', linewidth=0.5) plt.tight_layout() plt.savefig(f"{output_dir}/ablation_{metric_label.lower()}_significance_bars.png", dpi=300, bbox_inches='tight') plt.close()
[docs] def classify_policies_by_shortcut_rule( plot_df: pd.DataFrame, *, reference_policy: str = 'full_ocscore', risk_threshold: float = 20.0, bedroc_column: str = 'bedroc_mean', risk_column: str = 'shortcut_risk_max_pct', ) -> tuple[list[str], list[str]]: ''' Split policies into retained and discarded by the shortcut-risk rule. A policy is discarded when it beats the reference policy's mean metric *and* concentrates ``risk_threshold`` percent or more of its total SHAP importance in a single feature: the gain is real but rides on one dominant feature. Policies that do not beat the reference are not candidates, so the rule does not apply to them and they belong to neither group. Parameters ---------- plot_df : pd.DataFrame One row per policy, with a ``'policy'`` column plus ``bedroc_column`` and ``risk_column``. reference_policy : str Policy whose mean metric defines the candidacy cutoff. Default: 'full_ocscore'. risk_threshold : float Maximum single-feature SHAP share, in percent, tolerated in a candidate. Default: 20.0. bedroc_column : str Column holding the per-policy mean metric. Default: 'bedroc_mean'. risk_column : str Column holding the per-policy shortcut risk, in percent. Default: 'shortcut_risk_max_pct'. Returns ------- tuple[list[str], list[str]] Retained (low-risk) and discarded (high-risk) candidate policy names. Raises ------ ValueError If ``reference_policy`` is absent from ``plot_df``. ''' reference_rows = plot_df[plot_df['policy'] == reference_policy] if reference_rows.empty: raise ValueError(f"reference policy {reference_policy!r} is not present in plot_df.") reference_metric = float(reference_rows[bedroc_column].iloc[0]) candidates = plot_df[ (plot_df[bedroc_column] > reference_metric) & (plot_df['policy'] != reference_policy) ] retained = candidates[candidates[risk_column] < risk_threshold]['policy'].tolist() discarded = candidates[candidates[risk_column] >= risk_threshold]['policy'].tolist() return retained, discarded
[docs] def classify_policies_by_eligibility_and_shortcut_risk( plot_df: pd.DataFrame, *, reference_policy: str = 'full_ocscore', eligibility_column: str = 'eligible', risk_threshold: float = 20.0, risk_column: str = 'shortcut_risk_max_pct', ) -> tuple[list[str], list[str]]: '''Split statistically eligible policies by their shortcut risk. Unlike :func:`classify_policies_by_shortcut_rule`, eligibility is supplied explicitly instead of being inferred from whether the plotted mean exceeds the reference mean. This is appropriate when candidacy comes from a paired significance test while the scatter axis shows the validation-set metric. Parameters ---------- plot_df : pandas.DataFrame One row per policy, including ``'policy'``, ``eligibility_column`` and ``risk_column``. reference_policy : str Reference policy, excluded from both returned groups. Default: 'full_ocscore'. eligibility_column : str Boolean column identifying policies that passed the formal performance screen. Default: 'eligible'. risk_threshold : float Maximum single-feature SHAP share tolerated in a retained policy, in percent. Default: 20.0. risk_column : str Column holding shortcut risk, in percent. Default: 'shortcut_risk_max_pct'. Returns ------- tuple[list[str], list[str]] Retained (eligible and low-risk) and discarded (eligible and high-risk) policy names. Raises ------ ValueError If the eligibility or risk column is absent. ''' required_columns = {eligibility_column, risk_column} missing_columns = required_columns - set(plot_df.columns) if missing_columns: raise ValueError(f"plot_df is missing required column(s): {sorted(missing_columns)}") candidates = plot_df[ plot_df[eligibility_column].fillna(False).astype(bool) & (plot_df['policy'] != reference_policy) ] retained = candidates[candidates[risk_column] < risk_threshold]['policy'].tolist() discarded = candidates[candidates[risk_column] >= risk_threshold]['policy'].tolist() return retained, discarded
def _detect_x_break( values: Sequence[float], min_gap_share: float = 0.35, pad_share: float = 0.12, ) -> Optional[tuple[tuple[float, float], tuple[float, float]]]: ''' Find an empty x-region wide enough to justify a broken axis. A single far-out policy (a sanity-check control, typically) otherwise squeezes every other point into a fraction of the axis. When the widest gap between two consecutive values spans at least ``min_gap_share`` of the full range, the axis is worth splitting there. Parameters ---------- values : sequence[float] The x values to be plotted. min_gap_share : float, optional Minimum share of the full range that the largest gap must span for a break to be worthwhile, by default 0.35. pad_share : float, optional Padding added around each panel's points, as a share of that panel's own span, by default 0.12. Returns ------- tuple[tuple[float, float], tuple[float, float]] | None ``(left_xlim, right_xlim)`` when a break is warranted, else None. ''' ordered = sorted(float(v) for v in values) if len(ordered) < 3: return None full_range = ordered[-1] - ordered[0] if full_range <= 0: return None gaps = [(b - a, i) for i, (a, b) in enumerate(zip(ordered, ordered[1:]))] widest, index = max(gaps) if widest / full_range < min_gap_share: return None left_values = ordered[: index + 1] right_values = ordered[index + 1:] def limits(subset: list[float]) -> tuple[float, float]: span = subset[-1] - subset[0] pad = max(span * pad_share, widest * 0.05) return subset[0] - pad, subset[-1] + pad return limits(left_values), limits(right_values) def _detect_x_segments( values: Sequence[float], min_gap_share: float = 0.30, pad_share: float = 0.12, max_segments: int = 3, ) -> Optional[list[tuple[tuple[float, float], list[float]]]]: '''Split an x distribution across as many as three wide empty regions. The validation BEDROC distribution contains one extreme sanity-check model, two weak baselines and a dense cluster of the remaining configurations. A single broken axis still compresses that dense cluster, so this helper keeps up to ``max_segments`` occupied ranges separated by genuinely wide gaps. ''' ordered = sorted(float(value) for value in values) if len(ordered) < 3 or max_segments < 2: return None full_range = ordered[-1] - ordered[0] if full_range <= 0: return None qualifying_gaps = [ (b - a, index) for index, (a, b) in enumerate(zip(ordered, ordered[1:])) if (b - a) / full_range >= min_gap_share ] if not qualifying_gaps: return None split_indices = sorted( index for _, index in sorted(qualifying_gaps, reverse=True)[:max_segments - 1] ) groups: list[list[float]] = [] start = 0 for split_index in split_indices: groups.append(ordered[start:split_index + 1]) start = split_index + 1 groups.append(ordered[start:]) minimum_pad = full_range * 0.012 segments: list[tuple[tuple[float, float], list[float]]] = [] for group in groups: span = group[-1] - group[0] pad = max(span * pad_share, minimum_pad) segments.append(((group[0] - pad, group[-1] + pad), group)) return segments
[docs] def plot_bedroc_vs_shortcut_risk_scatter( plot_df: pd.DataFrame, *, reference_policy: str = 'full_ocscore', good_policies: Optional[Sequence[str]] = None, bad_policies: Optional[Sequence[str]] = None, show_rule_geometry: Optional[bool] = None, risk_threshold: float = 20.0, bedroc_column: str = 'bedroc_mean', risk_column: str = 'shortcut_risk_max_pct', label_column: Optional[str] = None, highlight_policy: Optional[str] = None, metric_label: str = 'BEDROC', title: Optional[str] = None, xlabel: Optional[str] = None, ylabel: str = 'Shortcut risk\n(max. % of SHAP importance in a single feature)', legend_labels: Optional[Mapping[str, str]] = None, threshold_note: Optional[str] = None, zone_note: Optional[str] = None, reference_note: Optional[str] = None, highlight_note: Optional[str] = None, label_offsets: Optional[Mapping[str, tuple[float, float]]] = None, break_x_axis: bool = True, figsize: tuple[float, float] = (6.9, 4.6), dpi: int = 300, output_dir: str = 'plots', ) -> None: ''' Scatter per-policy mean BEDROC against SHAP shortcut risk, under the shortcut rule. By default, point colors are derived from whether a policy beats the reference mean and from ``risk_threshold``. Explicit ``good_policies`` / ``bad_policies`` may instead provide groups obtained from an independent eligibility rule, such as a Holm-corrected paired validation test. In that case the mean-reference geometry is hidden by default because it is contextual, not a decision cutoff. When one policy sits far from every other on the x axis (a low-signal control, typically), it compresses the interesting cluster into a fraction of the width. ``break_x_axis`` splits the axis across that empty region instead, keeping every point visible. Parameters ---------- plot_df : pd.DataFrame One row per policy, with a ``'policy'`` column plus ``bedroc_column`` and ``risk_column`` (e.g. the output of ``SHAP.Dominance.aggregate_dominant_feature_risk`` merged with per-policy mean BEDROC). reference_policy : str Policy plotted as the reference marker, with a dotted vertical guide at its metric value. Default: 'full_ocscore'. good_policies : sequence[str] | None, optional Overrides the retained group. Derived from the rule when None. Default: None. bad_policies : sequence[str] | None, optional Overrides the discarded group. Derived from the rule when None. Default: None. show_rule_geometry : bool | None, optional Draw the "beats the reference" vertical guide and the shaded discard quadrant, both of which visualize the *derived* rule's x-axis condition. When ``good_policies``/``bad_policies`` override that rule (e.g. coloring by a statistical eligibility test while the x axis plots the corresponding mean), a point can legitimately sit on the "wrong" side of that geometry, which reads as a contradiction. Defaults to ``True`` when the grouping is derived (no override) and ``False`` when either override is supplied; pass explicitly to force either behavior. The horizontal risk-threshold guide is unaffected, since it always matches ``risk_threshold`` regardless of grouping source. Default: None. risk_threshold : float Shortcut-risk cutoff, in percent; also the horizontal guide line. Default: 20.0. bedroc_column : str Column with the per-policy mean metric value. Default: 'bedroc_mean'. risk_column : str Column with the per-policy shortcut-risk value. Default: 'shortcut_risk_max_pct'. label_column : str | None, optional Column used to annotate each point (falls back to ``'policy'``). Default: None. highlight_policy : str | None, optional Policy to call out with an arrow and a bold label (the final recommendation, typically). Default: None. metric_label : str Metric name, used in the default x label and in the output filename. Default: 'BEDROC'. title, xlabel, ylabel : str | None, optional Plot text. Override to render the figure in another language. legend_labels : mapping[str, str] | None, optional Legend text, keyed by ``'reference'``, ``'retained'``, ``'discarded'`` and ``'other'``. Override to render the figure in another language. threshold_note, zone_note, reference_note, highlight_note : str | None, optional In-plot annotations for the risk guide line, the discard quadrant, the reference guide line and the highlighted policy. Override to render the figure in another language. label_offsets : mapping[str, tuple[float, float]] | None, optional Per-policy ``(dx, dy)`` label offset override, in points, for policies whose default offset collides with a nearby marker or label. Overridden labels receive a subtle leader line back to their marker. Default: None. break_x_axis : bool, optional Split the x axis across a wide empty region when one is present. Default: True. figsize : tuple[float, float] Figure size in inches, sized to be embedded at roughly 1:1. Default: (6.9, 4.6). dpi : int Figure DPI. Default: 300. output_dir : str Where to save the plot image. Default: 'plots'. ''' df = plot_df.reset_index(drop=True) label_col = label_column or 'policy' derived_good, derived_bad = classify_policies_by_shortcut_rule( df, reference_policy = reference_policy, risk_threshold = risk_threshold, bedroc_column = bedroc_column, risk_column = risk_column, ) good = set(good_policies if good_policies is not None else derived_good) bad = set(bad_policies if bad_policies is not None else derived_bad) draw_rule_geometry = ( show_rule_geometry if show_rule_geometry is not None else (good_policies is None and bad_policies is None) ) reference_metric = float(df.loc[df['policy'] == reference_policy, bedroc_column].iloc[0]) styles = { 'reference': (COLOR_REFERENCE, 'D', 115, COLOR_TEXT), 'retained': (COLOR_RETAINED, '^', 95, COLOR_TEXT_MUTED), 'discarded': (COLOR_DISCARDED, 'v', 95, COLOR_TEXT_MUTED), 'other': (COLOR_OTHER, 'o', 62, COLOR_TEXT_MUTED), } def group_of(policy: str) -> str: if policy == reference_policy: return 'reference' if policy in good: return 'retained' if policy in bad: return 'discarded' return 'other' x_segments = _detect_x_segments(df[bedroc_column].tolist()) if break_x_axis else None ax_left: Optional[Axes] if x_segments is None: fig, ax_right = plt.subplots(figsize = figsize, dpi = dpi) axes = [ax_right] ax_left = None else: width_ratios = [min(8, max(1, len(group))) for _, group in x_segments] fig, axes_array = plt.subplots( 1, len(x_segments), sharey = True, figsize = figsize, dpi = dpi, gridspec_kw = {'width_ratios': width_ratios, 'wspace': 0.035}, ) axes = list(np.atleast_1d(axes_array)) ax_left = axes[0] ax_right = axes[-1] for ax in axes: ax.axhline(risk_threshold, color = COLOR_TEXT_MUTED, lw = 1.0, ls = (0, (4, 2)), zorder = 1) ax.grid(True, color = COLOR_GRID, lw = 0.7, ls = ':', zorder = 0) ax.set_axisbelow(True) ax.tick_params(labelsize = 9, colors = COLOR_TEXT_MUTED, length = 0) # the discard quadrant: beats the reference AND sits above the risk threshold. # Only meaningful when the grouping is actually derived from that condition; # skipped under an override, where a point can legitimately fall outside it. if draw_rule_geometry: right_limits = x_segments[-1][0] if x_segments is not None else ax_right.get_xlim() ax_right.add_patch(mpatches.Rectangle( (reference_metric, risk_threshold), right_limits[1] - reference_metric, 100.0 - risk_threshold, facecolor = COLOR_DISCARDED, alpha = 0.055, edgecolor = 'none', zorder = 0.5, )) ax_right.axvline(reference_metric, color = COLOR_TEXT, lw = 0.9, ls = (0, (2, 2)), alpha = 0.55, zorder = 1) def _axis_for_value(value: float) -> Axes: if x_segments is None: return ax_right for ax, (limits, _) in zip(axes, x_segments): if limits[0] <= value <= limits[1]: return ax return ax_right for _, row in df.iterrows(): group = group_of(row['policy']) color, marker, size, edge = styles[group] point_ax = _axis_for_value(float(row[bedroc_column])) point_ax.scatter( row[bedroc_column], row[risk_column], color = color, marker = marker, s = size, edgecolor = edge, linewidth = 1.1 if group == 'reference' else 0.6, zorder = 3, ) has_custom_offset = row['policy'] in (label_offsets or {}) offset = (label_offsets or {}).get(row['policy'], (7, 5)) point_ax.annotate( str(row[label_col]), (row[bedroc_column], row[risk_column]), textcoords = 'offset points', xytext = offset, fontsize = 8.5, color = COLOR_TEXT, fontweight = 'bold' if row['policy'] == highlight_policy else 'normal', zorder = 4, bbox = dict(boxstyle = 'round,pad=0.13', fc = 'white', ec = 'none', alpha = 0.84), arrowprops = ( dict(arrowstyle = '-', color = COLOR_TEXT_MUTED, lw = 0.45, shrinkA = 2.5, shrinkB = 5.0, alpha = 0.65) if has_custom_offset else None ), ) if highlight_policy is not None and highlight_note: target = df[df['policy'] == highlight_policy] if not target.empty: row = target.iloc[0] ax_right.annotate( highlight_note, (row[bedroc_column], row[risk_column]), textcoords = 'offset points', xytext = (16, 10), fontsize = 7.8, color = COLOR_RETAINED, fontweight = 'bold', zorder = 4, arrowprops = dict(arrowstyle = '-', color = COLOR_RETAINED, lw = 0.9, shrinkA = 0, shrinkB = 4, alpha = 0.8), ) # inverted: the higher the point, the more distributed the explanation axes[0].set_ylim(100, 0) if x_segments is not None: assert ax_left is not None, "ax_left is always set alongside x_segments" for index, (ax, (limits, segment_values)) in enumerate(zip(axes, x_segments)): ax.set_xlim(*limits) if max(segment_values) - min(segment_values) < 1e-12: ax.set_xticks([round(segment_values[0], 3)]) if index < len(axes) - 1: ax.spines['right'].set_visible(False) if index > 0: ax.spines['left'].set_visible(False) ax.tick_params(left = False) ax.spines['top'].set_visible(False) ax.spines['bottom'].set_color(COLOR_GRID) ax_left.spines['left'].set_color(COLOR_GRID) ax_right.spines['right'].set_visible(False) # diagonal break marks straddling the two panels mark: dict[str, Any] = dict( marker = [(-1, -0.9), (1, 0.9)], markersize = 7, linestyle = 'none', color = COLOR_TEXT_MUTED, mec = COLOR_TEXT_MUTED, mew = 1.1, clip_on = False, ) for left_axis, right_axis in zip(axes, axes[1:]): left_axis.plot([1, 1], [0, 1], transform = left_axis.transAxes, **mark) right_axis.plot([0, 0], [0, 1], transform = right_axis.transAxes, **mark) else: for spine in ('top', 'right'): ax_right.spines[spine].set_visible(False) for spine in ('bottom', 'left'): ax_right.spines[spine].set_color(COLOR_GRID) if threshold_note: ax_right.text( ax_right.get_xlim()[1], risk_threshold - 1.5, threshold_note, fontsize = 7.6, color = COLOR_TEXT_MUTED, ha = 'right', va = 'bottom', style = 'italic', ) if draw_rule_geometry and zone_note: ax_right.text( ax_right.get_xlim()[1], 97.5, zone_note, fontsize = 7.8, color = COLOR_DISCARDED, ha = 'right', va = 'bottom', fontweight = 'bold', alpha = 0.85, ) if draw_rule_geometry: ax_right.text( reference_metric, -2.5, reference_note if reference_note is not None else f'{metric_label} of the reference model', fontsize = 7.6, color = COLOR_TEXT_MUTED, ha = 'center', va = 'bottom', style = 'italic', clip_on = False, ) ax_right.set_xlabel(xlabel or f'{metric_label} (test)', fontsize = 10.5, color = COLOR_TEXT) ax_right.xaxis.set_label_coords(0.44, -0.075) axes[0].set_ylabel(ylabel, fontsize = 9.5, color = COLOR_TEXT) fig.suptitle( title or f'Ranking performance vs shortcut risk ({len(df)} policies)', fontsize = 11.0, color = COLOR_TEXT, fontweight = 'bold', x = 0.135, ha = 'left', y = 0.975, ) text = { 'reference': f'Full model ({reference_policy}, reference)', 'retained': f'Retained: beat the reference, risk < {risk_threshold:g}% (n={len(good)})', 'discarded': f'Discarded: beat the reference, risk >= {risk_threshold:g}% (n={len(bad)})', 'other': 'Do not beat the reference (rule does not apply)', **dict(legend_labels or {}), } legend_handles = [ mlines.Line2D([], [], color = COLOR_REFERENCE, marker = 'D', ls = 'none', ms = 8, mec = COLOR_TEXT, label = text['reference']), mlines.Line2D([], [], color = COLOR_DISCARDED, marker = 'v', ls = 'none', ms = 8.5, mec = COLOR_TEXT_MUTED, mew = 0.6, label = text['discarded']), mlines.Line2D([], [], color = COLOR_RETAINED, marker = '^', ls = 'none', ms = 8.5, mec = COLOR_TEXT_MUTED, mew = 0.6, label = text['retained']), mlines.Line2D([], [], color = COLOR_OTHER, marker = 'o', ls = 'none', ms = 7, mec = COLOR_TEXT_MUTED, mew = 0.6, label = text['other']), ] fig.subplots_adjust(left = 0.135, right = 0.985, top = 0.875, bottom = 0.255) fig.legend( handles = legend_handles, loc = 'lower center', frameon = False, fontsize = 7.7, labelcolor = COLOR_TEXT_MUTED, ncol = 2, bbox_to_anchor = (0.5, 0.012), handlelength = 1.2, columnspacing = 2.6, labelspacing = 1.25, ) fig.savefig(f"{output_dir}/ablation_{metric_label.lower()}_vs_shortcut_risk_scatter.png", dpi = dpi) plt.close(fig)
[docs] def plot_bar_with_significance( gh_df: pd.DataFrame, metric: str, y_col: str = 'diff', colour_mapping: Optional[dict[str, tuple[float, float, float]]] = None, output_dir: str = 'plots', top_n: Optional[int] = 30 ) -> None: ''' Plot Games-Howell pairwise differences as a horizontal bar chart. Parameters ---------- gh_df : pd.DataFrame Output of pingouin.pairwise_gameshowell (expects columns 'A','B','diff','pval'). metric : str Metric label for titling ('AUC' or 'RMSE'). y_col : str Which column from gh_df to plot as bar length (default 'diff'). colour_mapping : dict | None, optional Unused here, accepted for API compatibility. Default: None. output_dir : str Where to save the plot image. Default: 'plots'. top_n : int | None, optional If given, keep the top-N pairs by smallest p-value. Default: 30. ''' df = gh_df.copy() if 'pval' not in df.columns: # pingouin sometimes returns 'pval'/'pval_corr'; tolerate variants pcol = next((c for c in df.columns if c.startswith('pval')), None) if pcol is None: # User-facing error: missing required data in DataFrame ocerror.Error.data_not_found("Games-Howell dataframe must contain a p-value column (pval, pval_corr, etc.)") raise ValueError('Games-Howell dataframe must contain a p-value column.') df['pval'] = df[pcol] df['pair'] = df['A'].astype(str) + ' vs ' + df['B'].astype(str) df.sort_values(by=['pval', y_col], ascending=[True, False], inplace=True) if top_n is not None: df = df.head(top_n) # Color positive diffs blue, negative red for quick read colors = df[y_col].map(lambda v: 'tab:blue' if v >= 0 else 'tab:red') plt.figure(figsize=(max(8, 0.25 * len(df)), max(6, 0.35 * len(df)))) ax = sns.barplot(data=df, x=y_col, y='pair', palette=colors, orient='h') # Annotate p-values and significance stars def stars(p: float) -> str: '''Convert p-value to significance stars. Parameters ---------- p : float The p-value to convert. Returns ------- str Significance stars: '***' for p < 0.001, '**' for p < 0.01, '*' for p < 0.05, '' otherwise. ''' return '***' if p < 0.001 else ('**' if p < 0.01 else ('*' if p < 0.05 else '')) df_plot = df.reset_index(drop = True) y_values = df_plot[y_col].to_numpy() p_values = df_plot['pval'].to_numpy() for i, (y_val, p_val) in enumerate(zip(y_values, p_values)): ax.text( y_val + (0.01 if y_val >= 0 else -0.01), i, f"{y_val:.3f} (p={p_val:.2e}) {stars(p_val)}", ha='left' if y_val >= 0 else 'right', va='center', fontsize=8, ) ax.set_title(f'Games-Howell pairwise differences — {metric}') ax.set_xlabel(f'Difference in {metric}') ax.set_ylabel('Pair (A vs B)') plt.grid(True, axis='x', linestyle=':', linewidth=0.5) plt.tight_layout() plt.savefig(f"{output_dir}/games_howell_bar_{metric}.png", dpi=300) plt.close()
[docs] def plot_barplots(df: pd.DataFrame, n_trials: int, colour_mapping: dict[str, tuple[float, float, float]], output_dir: str) -> None: ''' Generate sorted barplots of mean RMSE and AUC across methodologies with annotations. Parameters ---------- df : pd.DataFrame Data containing 'RMSE', 'AUC', and 'Methodology'. n_trials : int Trial number for title and output naming. colour_mapping : dict[str, tuple[float, float, float]] Dictionary mapping methodologies to colors. output_dir : str Directory to save the barplot images. ''' df_means = df.groupby('Methodology')[['RMSE', 'AUC']].mean().reset_index() plt.figure(figsize = (16, 6)) for i, metric in enumerate(['RMSE', 'AUC']): plt.subplot(1, 2, i + 1) df_sorted = df_means.sort_values(by = metric) method_order = df_sorted['Methodology'].tolist() palette_sorted = {k: colour_mapping[k] for k in method_order} ax = sns.barplot( data = df_sorted, x = 'Methodology', y = metric, hue = 'Methodology', palette = palette_sorted, legend = False ) for j, val in enumerate(df_sorted[metric]): plt.text(j, val + 0.01, f"{val:.2f}", ha = 'center', va = 'bottom', fontsize = 9) plt.xticks(rotation = 90) plt.title(f'{metric} Mean per Method ({n_trials} Trials)') plt.grid(True) plt.minorticks_on() plt.grid(which = 'minor', linestyle = ':', linewidth = 0.5) plt.tight_layout() plt.savefig(f'{output_dir}/barplot_rmse_auc_{n_trials}.png') plt.close()
[docs] def plot_boxplots(df: pd.DataFrame, n_trials: int, colour_mapping: dict[str, tuple[float, float, float]], output_dir: str, show_simple_consensus: bool = False) -> None: ''' Generate enhanced boxplots of RMSE and AUC across methodologies, with group shading and mean lines. Parameters ---------- df : pd.DataFrame Data containing 'RMSE', 'AUC', and 'Methodology'. n_trials : int Number of trials used for title and filenames. colour_mapping : dict[str, tuple[float, float, float]] Dictionary mapping methodologies to colors. output_dir : str Directory to save the boxplot images. show_simple_consensus : bool Whether to include consensus methodologies (any label ending with "consensus"). ''' plot_df = df.copy() if not show_simple_consensus: plot_df = plot_df[~plot_df['Methodology'].str.endswith('consensus', na = False)] plt.figure(figsize = (16, 12)) mean_line_rmse, mean_line_auc = None, None for i, metric in enumerate(['RMSE', 'AUC']): plt.subplot(2, 1, i + 1) with warnings.catch_warnings(): # Seaborn currently forwards a deprecated Matplotlib `vert` kwarg # internally in some versions; silence this third-party warning. warnings.filterwarnings( "ignore", message = "vert: bool will be deprecated in a future version.*", category = PendingDeprecationWarning, ) ax = sns.boxplot( data = plot_df, x = 'Methodology', y = metric, hue = 'Methodology', palette = colour_mapping, showfliers = False, legend = False ) # Distinct line color for each metric mean_val = plot_df[metric].mean() line_color = 'red' if metric == 'RMSE' else 'blue' line = ax.axhline(mean_val, color = line_color, linestyle = '--', label = f'Mean {metric}') if i == 0: mean_line_rmse = line else: mean_line_auc = line plt.xticks(rotation = 90) plt.title(f'{metric} Distribution ({n_trials} Trials)') plt.grid(True, linestyle = ':', linewidth = 0.5) plt.minorticks_on() # Highlight NN, XGB, Transformer groups for prefix, color in [('NN', 'lightblue'), ('XGB', 'lightgreen'), ('Transformer', 'lightcoral')]: for method in plot_df['Methodology'].unique(): if method.startswith(prefix): idx = list(plot_df['Methodology'].unique()).index(method) plt.axvspan(idx - 0.5, idx + 0.5, color = color, alpha = 0.2) # Add figure-level legend at the bottom plt.figlegend( handles = [mean_line_rmse, mean_line_auc], labels = ['Mean RMSE', 'Mean AUC'], loc = 'lower center', bbox_to_anchor = (0.5, 0.02), ncol = 2, frameon = False ) # Adjust layout to avoid overlap plt.tight_layout(rect = (0, 0.08, 1, 1)) plt.savefig(f'{output_dir}/boxplots_rmse_auc_{n_trials}.png', dpi = 300) plt.close()
[docs] def plot_combined_metric_scatter(df: pd.DataFrame, n_trials: int, colour_mapping: dict[str, tuple[float, float, float]], output_dir: str, alpha: float = 0.9) -> None: ''' Generate a detailed scatter plot showing RMSE vs AUC across methods with shading and symbol cues. Parameters ---------- df : pd.DataFrame DataFrame with RMSE, AUC, and Methodology columns. n_trials : int Number of top trials considered. colour_mapping : dict[str, tuple[float, float, float]] Dictionary mapping methodologies to colors. output_dir : str Directory to save the scatter plot image. alpha : float, optional Transparency for the markers. Default is 0.9. ''' df = df.copy() df['AUC_adj'] = df['AUC'].apply(lambda x: 1 - x if x < 0.5 else x) df['AUC_category'] = df['AUC'].apply(lambda x: '>= 0.5' if x >= 0.5 else '< 0.5') df.loc[df['AUC_category'] == '< 0.5', 'AUC'] = df['AUC_adj'] plt.figure(figsize = (10, 8)) # Scatter for AUC ≥ 0.5 sns.scatterplot( data = df[df['AUC_category'] == '>= 0.5'], x = 'RMSE', y = 'AUC', hue = 'Methodology', palette = colour_mapping, alpha = alpha, marker = 'o', s = 100, legend = False ) # Scatter for AUC < 0.5 sns.scatterplot( data = df[df['AUC_category'] == '< 0.5'], x = 'RMSE', y = 'AUC', hue = 'Methodology', palette = colour_mapping, alpha = alpha, marker = '*', s = 130, legend = False ) plt.xlabel('RMSE') plt.ylabel('AUC (adjusted)') plt.title(f'Combined Metric Comparison ({n_trials} Trials)') plt.grid(True) plt.minorticks_on() plt.grid(which = 'minor', linestyle = ':', linewidth = 0.3) # Legends method_labels = df['Methodology'].unique().tolist() method_handles = [mlines.Line2D([0], [0], color = colour_mapping[m], lw = 4.1) for m in method_labels] shape_handles = [ mlines.Line2D([0], [0], marker = 'o', color = 'w', label = 'AUC ≥ 0.5', markerfacecolor = 'gray', markersize = 10), mlines.Line2D([0], [0], marker = '*', color = 'w', label = 'AUC < 0.5 (adjusted)', markerfacecolor = 'gray', markersize = 12) ] plt.figlegend(method_handles, method_labels, title = 'Methodology', loc = 'lower center', bbox_to_anchor = (0.5, 0.07), ncol = 5) plt.figlegend(shape_handles, ['AUC ≥ 0.5', 'AUC < 0.5 (adjusted)'], title = 'Marker Type', loc = 'lower center', bbox_to_anchor = (0.5, 0.01), ncol = 2) plt.tight_layout(rect = (0, 0.22, 1, 1)) plt.savefig(f'{output_dir}/scatter_combined_metric_{n_trials}.png', bbox_inches = 'tight', dpi = 300) plt.close()
[docs] def plot_heatmap( gh_df: pd.DataFrame, title: str, metric: str, output_dir: str = 'plots' ) -> None: '''Heatmap of Games-Howell p-values across methodology pairs. Parameters ---------- gh_df : pd.DataFrame Output of pingouin.pairwise_gameshowell (expects columns 'A','B 'diff','pval'). title : str Title for the heatmap. metric : str Metric label for titling ('AUC' or 'RMSE'). output_dir : str Where to save the plot image. Default: 'plots'. ''' df = gh_df.copy() pcol = 'pval' if 'pval' in df.columns else next((c for c in df.columns if c.startswith('pval')), None) if pcol is None: # User-facing error: missing required data in DataFrame ocerror.Error.data_not_found("Games-Howell dataframe must contain a p-value column (pval, pval_corr, etc.)") raise ValueError('Games-Howell dataframe must contain a p-value column.') mat = df.pivot(index='A', columns='B', values=pcol) # Mirror to make a symmetric matrix, leaving diagonal as NaN mat_full = mat.combine_first(mat.T) np.fill_diagonal(mat_full.values, np.nan) plt.figure(figsize=(max(8, 0.6 * mat_full.shape[1]), max(6, 0.35 * mat_full.shape[0]))) ax = sns.heatmap(-np.log10(mat_full), cmap='mako', annot=False, cbar_kws={'label': '-log10(p)'}) ax.set_title(title) plt.tight_layout() plt.savefig(f"{output_dir}/games_howell_heatmap_{metric}.png", dpi=300) plt.close()
[docs] def plot_normality_and_variance_diagnostics( df: pd.DataFrame, metric: str, n_trials: int, output_dir: str = 'plots' ) -> None: ''' Perform and plot normality and variance diagnostics across methodologies. Quick diagnostics across groups: - Shapiro-Wilk p-values per methodology (bar of -log10 p) - Group variances (bar) and Levene's p-value annotated Parameters ---------- df : pd.DataFrame Data containing 'Methodology' and the specified metric. metric : str Metric column to analyze (e.g., 'AUC' or 'RMSE'). n_trials : int Number of trials for title and output naming. output_dir : str Directory to save the diagnostics plot. Default: 'plots'. ''' # Compute Shapiro p-values and variances per group rows = [] groups = [] for method, sub in df.groupby('Methodology'): x = pd.to_numeric(sub[metric], errors='coerce').dropna().to_numpy() if x.size >= 3: try: p_shap = sstats.shapiro(x).pvalue except (ValueError, TypeError, AttributeError): # Fallback to NaN if statistical test fails p_shap = np.nan else: p_shap = np.nan var = float(np.var(x, ddof=1)) if x.size >= 2 else np.nan rows.append({'Methodology': method, 'p_shapiro': p_shap, 'variance': var}) groups.append(x) diag = pd.DataFrame(rows).sort_values(by='p_shapiro', ascending=True) # Levene across all groups try: groups_nonempty = [g for g in groups if g.size >= 2] p_levene = sstats.levene(*groups_nonempty).pvalue if len(groups_nonempty) >= 2 else np.nan except (ValueError, TypeError, AttributeError): # Fallback to NaN if statistical test fails p_levene = np.nan # Plot two panels plt.figure(figsize=(16, 6)) plt.subplot(1, 2, 1) sns.barplot(data=diag, x='Methodology', y=-np.log10(diag['p_shapiro']), color='steelblue') plt.xticks(rotation=90) plt.ylabel('-log10 Shapiro p-value') plt.title(f'Normality (Shapiro) — {metric}') plt.grid(True, axis='y', linestyle=':', linewidth=0.5) plt.subplot(1, 2, 2) sns.barplot(data=diag, x='Methodology', y='variance', color='tab:orange') plt.xticks(rotation=90) plt.ylabel('Group variance') lev_txt = f"Levene p={p_levene:.2e}" if isinstance(p_levene, float) and np.isfinite(p_levene) else "Levene p=N/A" plt.title(f'Variance across groups — {metric} ({lev_txt})') plt.grid(True, axis='y', linestyle=':', linewidth=0.5) plt.tight_layout() plt.savefig(f"{output_dir}/diagnostics_{metric}_{n_trials}.png", dpi=300) plt.close()
[docs] def plot_pca_importance_barplot( importance_df: pd.DataFrame, pca_type: str, n_features: int, n_trials: int, output_dir: str = 'plots' ) -> None: '''Barplot of top-N PCA feature importances. Parameters ---------- importance_df : pd.DataFrame DataFrame with 'Feature' and 'Importance' columns. pca_type : str PCA type label for titling (e.g., '1', '2'). n_features : int Number of top features to display. n_trials : int Number of trials for title and output naming. output_dir : str Directory to save the barplot image. Default: 'plots'. ''' top = importance_df.head(n_features) plt.figure(figsize=(10, max(5, 0.35 * len(top)))) sns.barplot(data=top, x='Importance', y='Feature', orient='h', color='steelblue') plt.title(f'PCA{pca_type}: Top {len(top)} feature importances') plt.xlabel('Importance (variance-weighted loadings)') plt.ylabel('Feature') plt.tight_layout() plt.savefig(f"{output_dir}/pca{pca_type}_importance_top{len(top)}_{n_trials}.png", dpi=300) plt.close()
[docs] def plot_pca_importance_histogram( importance_df: pd.DataFrame, pca_type: str, n_trials: int, output_dir: str = 'plots' ) -> None: '''Histogram of PCA feature importances. Parameters ---------- importance_df : pd.DataFrame DataFrame with 'Feature' and 'Importance' columns. pca_type : str PCA type label for titling (e.g., '1', '2'). n_trials : int Number of trials for title and output naming. output_dir : str Directory to save the histogram image. Default: 'plots'. ''' plt.figure(figsize=(8, 5)) sns.histplot(importance_df['Importance'], bins=30, color='tab:purple') plt.title(f'PCA{pca_type}: Distribution of feature importances') plt.xlabel('Importance') plt.ylabel('Count') plt.tight_layout() plt.savefig(f"{output_dir}/pca{pca_type}_importance_hist_{n_trials}.png", dpi=300) plt.close()
[docs] def plot_scatterplot( df_rmse: pd.DataFrame, df_auc: pd.DataFrame, df_all: pd.DataFrame, n_trials: int, colour_mapping: dict[str, tuple[float, float, float]], output_dir: str, orientation: str = 'horizontal', alpha: float = 0.9 ) -> None: '''Create scatter plots of RMSE vs AUC for all methods and filtered subsets. Create a 1x3 panel of scatter plots (RMSE vs AUC): - All filtered points - RMSE-filtered subset - AUC-filtered subset Parameters ---------- df_all : pd.DataFrame DataFrame with all filtered points. df_rmse : pd.DataFrame DataFrame filtered by RMSE threshold. df_auc : pd.DataFrame DataFrame filtered by AUC threshold. n_trials : int Number of top trials considered. colour_mapping : dict[str, tuple[float, float, float]] Dictionary mapping methodologies to colors. output_dir : str Directory to save the scatter plot image. orientation : str, optional Orientation of the scatter plot. Default is 'horizontal'. Options: 'horizontal', 'vertical'. alpha : float, optional Transparency for the markers. Default is 0.9. Raises ------ ValueError If the orientation parameter is not 'horizontal' or 'vertical'. ''' # Make orientation case-insensitive orientation = orientation.lower() if orientation == 'vertical': plt.figure(figsize=(8, 14)) elif orientation == 'horizontal': plt.figure(figsize=(18, 8)) else: # User-facing error: invalid orientation ocerror.Error.value_error(f"Invalid orientation: '{orientation}'. Must be 'horizontal' or 'vertical'.") raise ValueError(f"Orientation must be 'horizontal' or 'vertical', got {orientation}.") panels = [ (df_rmse, 'Error vs. AUC (Smallest Error)'), (df_auc, 'Error vs. AUC (Biggest AUC)'), (df_all, 'Error vs. AUC (Smallest Error - AUC)') ] for i, (df, title) in enumerate(panels, start=1): df = df.copy() df['AUC_adj'] = df['AUC'].apply(lambda x: 1 - x if x < 0.5 else x) df['AUC_category'] = df['AUC'].apply(lambda x: '>= 0.5' if x >= 0.5 else '< 0.5') df.loc[df['AUC_category'] == '< 0.5', 'AUC'] = df['AUC_adj'] if orientation == 'vertical': plt.subplot(3, 1, i) else: plt.subplot(1, 3, i) # Scatter for AUC ≥ 0.5 df_auc_ge = df[df['AUC_category'] == '>= 0.5'] if not df_auc_ge.empty: sns.scatterplot( data = df_auc_ge, x = 'RMSE', y = 'AUC', hue = 'Methodology', palette = colour_mapping, alpha = alpha, s = 30, legend = False, ) # Scatter for AUC < 0.5 df_auc_lt = df[df['AUC_category'] == '< 0.5'] if not df_auc_lt.empty: sns.scatterplot( data = df_auc_lt, x ='RMSE', y ='AUC', hue = 'Methodology', palette = colour_mapping, alpha = alpha, s = 50, marker = '*', legend = False, ) plt.title(title) plt.grid(True, linestyle=':', linewidth=0.5) plt.xlabel('RMSE') plt.ylabel('AUC') # Legends - define before use method_labels = df_all['Methodology'].unique().tolist() method_handles = [mlines.Line2D([0], [0], color = colour_mapping[m], lw = 4.1) for m in method_labels] shape_handles = [ mlines.Line2D([0], [0], marker = 'o', color = 'w', label = 'AUC ≥ 0.5', markerfacecolor = 'gray', markersize = 10), mlines.Line2D([0], [0], marker = '*', color = 'w', label = 'AUC < 0.5 (adjusted)', markerfacecolor = 'gray', markersize = 12) ] if orientation == 'vertical': # Methodology legend plt.figlegend(method_handles, method_labels, title = 'Methodology', loc = 'lower center', bbox_to_anchor = (0.5, 0.09), ncol = 5) # Shape legend plt.figlegend(shape_handles, ['AUC ≥ 0.5', 'AUC < 0.5 (adjusted)'], title = 'Marker Type', loc = 'lower center', bbox_to_anchor = (0.5, 0.03), ncol = 2) plt.tight_layout(rect = (0, 0.18, 1, 1)) else: # Methodology legend plt.figlegend(method_handles, method_labels, title = 'Methodology', loc = 'lower center', bbox_to_anchor = (0.5, 0.09), ncol = 5) # Shape legend plt.figlegend(shape_handles, ['AUC ≥ 0.5', 'AUC < 0.5 (adjusted)'], title = 'Marker Type', loc = 'lower center', bbox_to_anchor = (0.5, 0.02), ncol = 2) # Methodology legend plt.figlegend(method_handles, method_labels, title = 'Methodology', loc = 'lower center', bbox_to_anchor = (0.5, 0.09), ncol = 5) # Shape legend plt.figlegend(shape_handles, ['AUC ≥ 0.5', 'AUC < 0.5 (adjusted)'], title = 'Marker Type', loc = 'lower center', bbox_to_anchor = (0.5, 0.02), ncol = 2) if orientation == 'vertical': plt.subplots_adjust(bottom=0.28) plt.tight_layout(rect = (0, 0.25, 1, 1)) plt.savefig(f'{output_dir}/scatter_rmse_auc_panels_{n_trials}.png', dpi=300) plt.close()
[docs] def save_pca_importance_bins( importance_df: pd.DataFrame, pca_type: str, n_trials: int, output_dir: str = 'plots', n_bins: int = 10 ) -> None: '''Assign quantile bins (qcut) and save as CSV. Parameters ---------- importance_df : pd.DataFrame DataFrame with 'Feature' and 'Importance' columns. pca_type : str PCA type label for titling (e.g., '1', '2'). n_trials : int Number of trials for title and output naming. output_dir : str Directory to save the plot image. Default: 'plots'. n_bins : int Number of quantile bins to create. Default: 10. ''' df = importance_df.copy() try: df['bin'] = pd.qcut(df['Importance'], q=n_bins, labels=False, duplicates='drop') except ValueError: # Not enough unique values; fallback to rank-based bins ranks = df['Importance'].rank(method='average', pct=True) df['bin'] = (ranks * (n_bins - 1)).astype(int) df.to_csv(f"{output_dir}/pca{pca_type}_importance_bins_{n_trials}.csv", index=False)
[docs] def save_pca_importance_groups( importance_df: pd.DataFrame, pca_type: str, n_trials: int, output_dir: str = 'plots' ) -> None: '''Assign coarse groups by quantiles and save as CSV. Parameters ---------- importance_df : pd.DataFrame DataFrame with 'Feature' and 'Importance' columns. pca_type : str PCA type label for titling (e.g., '1', '2'). n_trials : int Number of trials for title and output naming. output_dir : str Directory to save the plot image. Default: 'plots'. ''' q = importance_df['Importance'].quantile bins = [0.0, q(0.2), q(0.4), q(0.6), q(0.8), q(1.0)] labels = ['Very Low', 'Low', 'Medium', 'High', 'Very High'] df = importance_df.copy() df['Group'] = pd.cut(df['Importance'], bins=bins, labels=labels, include_lowest=True, duplicates='drop') df.to_csv(f"{output_dir}/pca{pca_type}_importance_groups_{n_trials}.csv", index=False)