Source code for machinelens.plots.plots

"""Visualization layer for the MachineLens library.

This module provides the ``DiagnosticPlotter`` class, which consumes
``DiagnosticResults`` and generates highly polished, interactive Plotly
charts. These charts are styled with a premium dark-slate aesthetic
and designed for zero-friction JSON serialization.
"""

from __future__ import annotations

import json
from typing import Any, Dict, Optional

import numpy as np
import pandas as pd
import plotly.graph_objects as go
from scipy.stats import gaussian_kde, norm

from machinelens.core.data_classes import DiagnosticResults, LowessData, QQData

# ---------------------------------------------------------------------------
# Design Tokens & Styling Constants (Clean Light White Theme)
# ---------------------------------------------------------------------------
_FONT_FAMILY = "Inter, system-ui, -apple-system, sans-serif"

# Clean Light Palette
_COLOR_BG_PAPER = "#ffffff"  # Pure white background
_COLOR_BG_PLOT = "#fafbfc"  # Soft off-white surface
_COLOR_GRID = "rgba(0, 0, 0, 0.06)"  # Very faint dark gridlines
_COLOR_BORDER = "rgba(0, 0, 0, 0.08)"  # Soft dark border

# Typography Colors
_COLOR_TEXT_MAIN = "#1e293b"  # Slate 800 (Deep dark text)
_COLOR_TEXT_MUTED = "#64748b"  # Slate 500 (Muted gray-blue)

# Accent Colors (Vibrant Light Theme Accents)
_COLOR_BLUE = "#3b82f6"  # Bright Blue
_COLOR_GREEN = "#10b981"  # Emerald Green
_COLOR_AMBER = "#f59e0b"  # Amber
_COLOR_RED = "#ef4444"  # Vibrant Red
_COLOR_PURPLE = "#a855f7"  # Indigo-Purple
_COLOR_PINK = "#ec4899"  # Clean Pink

# Modern Premium Color Scale for Continuous Heatmaps/Gradients
_COLORSCALE_RESIDUALS = [
    [0.0, "#3b82f6"],  # Deep Blue (Small residuals)
    [0.4, "#a855f7"],  # Purple
    [0.7, "#f97316"],  # Orange
    [1.0, "#ef4444"],  # Bright Red (High residuals)
]

_COLORSCALE_HEATMAP = [
    [0.0, "#f8fafc"],  # Slate 50
    [0.5, "#93c5fd"],  # Soft Sky Blue
    [1.0, "#3b82f6"],  # Vibrant Deep Blue
]


# ======================================================================
# DiagnosticPlotter
# ======================================================================


[docs] class DiagnosticPlotter: """Visualization suite for MachineLens diagnostic results. This class reads from a strongly-typed ``DiagnosticResults`` object and generates interactive, premium Plotly figures. It is completely decoupled from any computing tasks, acting purely as a visual rendering engine. All figure objects returned can be easily serialized to JSON via ``fig.to_json()`` for rendering in web frontends. Parameters ---------- results : DiagnosticResults The complete calculated diagnostic results to visualize. """ def __init__(self, results: DiagnosticResults) -> None: """Initialize the DiagnosticPlotter. Parameters ---------- results : DiagnosticResults The complete calculated diagnostic results to visualize. """ self.results = results # ------------------------------------------------------------------ # Core Theme Applier (Private) # ------------------------------------------------------------------ def _compact_colorbar(self, title_text: str) -> dict: """Create a highly compact, thin, and elegantly styled colorbar. Parameters ---------- title_text : str The label for the colorbar title. Returns ------- dict Plotly colorbar layout dictionary. """ return dict( title=dict( text=title_text, font=dict(size=8, color=_COLOR_TEXT_MUTED), ), tickfont=dict(color=_COLOR_TEXT_MUTED, size=8), thickness=10, # Elegant narrow width len=0.7, # Slightly shorter to avoid overlapping xpad=5, # Reduce horizontal padding ypad=5, ) def _apply_theme( self, fig: go.Figure, title: str, xaxis_title: str, yaxis_title: str, show_legend: bool = True, ) -> None: """Apply an ultra-premium clean theme to a Plotly figure. Parameters ---------- fig : go.Figure The Plotly figure to customize. title : str The title text of the chart. xaxis_title : str The label for the x-axis. yaxis_title : str The label for the y-axis. show_legend : bool, default=True Whether to show the legend. """ fig.update_layout( title=dict( text=f"<b>{title}</b>", font=dict(family=_FONT_FAMILY, color=_COLOR_TEXT_MAIN, size=15), x=0.05, xanchor="left", y=0.96, ), paper_bgcolor=_COLOR_BG_PAPER, plot_bgcolor=_COLOR_BG_PLOT, font=dict(family=_FONT_FAMILY, color=_COLOR_TEXT_MAIN, size=11), margin=dict(l=55, r=20, t=65, b=45), showlegend=show_legend, legend=dict( orientation="h", yanchor="bottom", y=1.01, xanchor="left", x=0.02, font=dict(size=9, color=_COLOR_TEXT_MUTED), bgcolor="rgba(0,0,0,0)", bordercolor="rgba(0,0,0,0)", borderwidth=0, ), hoverlabel=dict( bgcolor=_COLOR_BG_PAPER, bordercolor=_COLOR_BORDER, font=dict(family=_FONT_FAMILY, color=_COLOR_TEXT_MAIN, size=11), ), hovermode="closest", ) fig.update_xaxes( title=dict( text=xaxis_title, font=dict(size=12, color=_COLOR_TEXT_MUTED, family=_FONT_FAMILY), ), gridcolor=_COLOR_GRID, linecolor=_COLOR_BORDER, tickfont=dict(color=_COLOR_TEXT_MUTED, size=10), zerolinecolor=_COLOR_GRID, zerolinewidth=1, ) fig.update_yaxes( title=dict( text=yaxis_title, font=dict(size=12, color=_COLOR_TEXT_MUTED, family=_FONT_FAMILY), ), gridcolor=_COLOR_GRID, linecolor=_COLOR_BORDER, tickfont=dict(color=_COLOR_TEXT_MUTED, size=10), zerolinecolor=_COLOR_GRID, zerolinewidth=1, ) def _add_lowess_trace( self, fig: go.Figure, lowess: Optional[LowessData], name: str = "LOWESS Curve", color: str = _COLOR_PURPLE, ) -> None: """Add a LOWESS curve and confidence band to a scatter plot. Parameters ---------- fig : go.Figure The Plotly figure to modify. lowess : LowessData or None The calculated LOWESS trend line and confidence interval. name : str, default="LOWESS Curve" The trace label for the trend line. color : str, default=_COLOR_PURPLE The line color string. """ if lowess is None or len(lowess.x_smooth) == 0: return # Shade confidence interval fig.add_trace( go.Scatter( x=np.concatenate([lowess.x_smooth, lowess.x_smooth[::-1]]).tolist(), y=np.concatenate([lowess.ci_upper, lowess.ci_lower[::-1]]).tolist(), fill="toself", fillcolor="rgba(192, 132, 252, 0.12)", # Translucent purple line=dict(color="rgba(255,255,255,0)"), showlegend=False, name=f"{name} 95% CI", hoverinfo="skip", ) ) # Draw trend line fig.add_trace( go.Scatter( x=lowess.x_smooth.tolist(), y=lowess.y_smooth.tolist(), mode="lines", line=dict(color=color, width=2.5, shape="spline"), name=name, hoverinfo="skip", ) ) # ------------------------------------------------------------------ # Regression Plotting Methods # ------------------------------------------------------------------
[docs] def plot_actual_vs_predicted(self, subset: str = "test") -> go.Figure: """Plot Actual vs. Predicted values. Includes an identity reference line (y = x) representing ideal fit. Points are colored by absolute residual values to highlight mistakes. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ sd = getattr(self.results, f"{subset}_data", None) if sd is None: raise ValueError(f"No {subset} subset data available in results.") fig = go.Figure() # Aligned lists y_true = sd.y_true.tolist() y_pred = sd.y_pred.tolist() abs_res = getattr(sd, "abs_residuals", np.abs(sd.y_true - sd.y_pred)).tolist() # Add perfect fit identity line (y = x) min_val = float(min(min(y_true), min(y_pred))) max_val = float(max(max(y_true), max(y_pred))) fig.add_trace( go.Scatter( x=[min_val, max_val], y=[min_val, max_val], mode="lines", line=dict(color=_COLOR_TEXT_MUTED, width=1.5, dash="dash"), name="Perfect Prediction (y=x)", hoverinfo="skip", ) ) # Add observations scatter trace vmax = float(np.percentile(abs_res, 95)) if len(abs_res) > 0 else 1.0 fig.add_trace( go.Scatter( x=y_true, y=y_pred, mode="markers", name="Observations", marker=dict( size=7, color=abs_res, colorscale=_COLORSCALE_RESIDUALS, cmin=0, cmax=vmax, colorbar=self._compact_colorbar("|Error|"), line=dict(color=_COLOR_BG_PAPER, width=0.5), ), hovertemplate=( "<b>Actual:</b> %{x:.4f}<br>" "<b>Predicted:</b> %{y:.4f}<br>" "<b>Absolute Error:</b> %{marker.color:.4f}<extra></extra>" ), customdata=sd.X_data.index.tolist(), ) ) self._apply_theme( fig, title=f"Actual vs. Predicted Values ({subset.capitalize()})", xaxis_title="Actual Values", yaxis_title="Predicted Values", show_legend=True, ) return fig
[docs] def plot_residuals(self, subset: str = "test") -> go.Figure: """Plot Residuals vs. Predicted values. Overlays a horizontal zero line (y = 0) and the LOWESS trend line with bootstrap confidence intervals to identify systematic bias or non-linearity. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ sd = getattr(self.results, f"{subset}_data", None) if sd is None: raise ValueError(f"No {subset} subset data available in results.") fig = go.Figure() y_pred = sd.y_pred.tolist() residuals = getattr(sd, "residuals", sd.y_true - sd.y_pred).tolist() abs_res = getattr(sd, "abs_residuals", np.abs(residuals)).tolist() # Horizontal zero-reference line fig.add_hline( y=0, line=dict(color=_COLOR_TEXT_MUTED, width=1.5, dash="dash"), annotation_text="Zero Residual", annotation_position="bottom right", annotation_font=dict(size=9, color=_COLOR_TEXT_MUTED), ) # Plotly scatter points vmax = float(np.percentile(abs_res, 95)) if len(abs_res) > 0 else 1.0 fig.add_trace( go.Scatter( x=y_pred, y=residuals, mode="markers", name="Residuals", marker=dict( size=7, color=abs_res, colorscale=_COLORSCALE_RESIDUALS, cmin=0, cmax=vmax, colorbar=self._compact_colorbar("|Error|"), line=dict(color=_COLOR_BG_PAPER, width=0.5), ), hovertemplate=( "<b>Predicted:</b> %{x:.4f}<br>" "<b>Residual:</b> %{y:.4f}<br>" "<b>Absolute Error:</b> %{marker.color:.4f}<extra></extra>" ), customdata=sd.X_data.index.tolist(), ) ) # Add optional LOWESS overlay lowess = getattr(self.results, f"{subset}_linearity_lowess", None) self._add_lowess_trace(fig, lowess, name="LOWESS Trend", color=_COLOR_PURPLE) self._apply_theme( fig, title=f"Residuals vs. Predicted Values ({subset.capitalize()})", xaxis_title="Predicted Values", yaxis_title="Residual (Actual - Predicted)", show_legend=True, ) return fig
[docs] def plot_qq(self, subset: str = "test") -> go.Figure: """Plot a Normal Q-Q Plot of Standardized Residuals. Highlights departures from normality with a 95% confidence interval envelope. Points are dynamically colored by their distance from the theoretical line. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ qq: Optional[QQData] = getattr(self.results, f"{subset}_qq", None) if qq is None: raise ValueError(f"No {subset} Q-Q plot coordinates available in results.") sd = getattr(self.results, f"{subset}_data", None) fig = go.Figure() # Add 95% Confidence Interval Envelope ref_line = qq.intercept + qq.slope * qq.theoretical ci_lower_y = qq.intercept + qq.slope * qq.ci_lower ci_upper_y = qq.intercept + qq.slope * qq.ci_upper fig.add_trace( go.Scatter( x=np.concatenate([qq.theoretical, qq.theoretical[::-1]]).tolist(), y=np.concatenate([ci_upper_y, ci_lower_y[::-1]]).tolist(), fill="toself", fillcolor="rgba(56, 189, 248, 0.08)", # Sky Blue translucent line=dict(color="rgba(255,255,255,0)"), showlegend=True, name="95% CI Envelope", hoverinfo="skip", ) ) # Add perfect normal theoretical reference line fig.add_trace( go.Scatter( x=qq.theoretical.tolist(), y=ref_line.tolist(), mode="lines", line=dict(color=_COLOR_TEXT_MUTED, width=1.5, dash="dash"), name="Normal Distribution", hoverinfo="skip", ) ) # Calculate absolute standardized residuals to map colors consistently with |Error| abs_std_res = np.abs(qq.sample) vmax = float(np.percentile(abs_std_res, 95)) if len(abs_std_res) > 0 else 1.0 # Add sample quantiles scatter points orig_indices = [] if ( sd is not None and hasattr(sd, "std_residuals") and sd.std_residuals is not None ): sorted_positions = np.argsort(sd.std_residuals).tolist() dataset_idx = sd.X_data.index.tolist() orig_indices = [dataset_idx[i] for i in sorted_positions] fig.add_trace( go.Scatter( x=qq.theoretical.tolist(), y=qq.sample.tolist(), mode="markers", name="Sample Quantiles", marker=dict( size=6, color=abs_std_res.tolist(), colorscale=_COLORSCALE_RESIDUALS, cmin=0, cmax=vmax, colorbar=self._compact_colorbar("|Error|"), line=dict(color=_COLOR_BG_PAPER, width=0.5), ), hovertemplate=( "<b>Theoretical Quantile:</b> %{x:.4f}<br>" "<b>Sample Quantile:</b> %{y:.4f}<extra></extra>" ), customdata=orig_indices, ) ) self._apply_theme( fig, title=f"Normal Q-Q Plot of Residuals ({subset.capitalize()})", xaxis_title="Theoretical Quantiles (Standard Normal)", yaxis_title="Sample Quantiles (Standardized Residuals)", show_legend=True, ) return fig
[docs] def plot_scale_location(self, subset: str = "test") -> go.Figure: """Generate a Scale-Location Plot. Plots Predicted Values vs. sqrt(|Standardized Residuals|). Overlays a LOWESS smoothed line to check homoscedasticity. A flat trend line indicates constant residual variance. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ sd = getattr(self.results, f"{subset}_data", None) if sd is None: raise ValueError(f"No {subset} subset data available in results.") fig = go.Figure() y_pred = sd.y_pred.tolist() std_res = getattr(sd, "std_residuals", np.zeros_like(sd.y_pred)) sqrt_abs_res = np.sqrt(np.abs(std_res)).tolist() abs_std_res = np.abs(std_res) vmax = float(np.percentile(abs_std_res, 95)) if len(abs_std_res) > 0 else 1.0 fig.add_trace( go.Scatter( x=y_pred, y=sqrt_abs_res, mode="markers", name="Residuals", marker=dict( size=7, color=abs_std_res.tolist(), colorscale=_COLORSCALE_RESIDUALS, cmin=0, cmax=vmax, colorbar=self._compact_colorbar("|Error|"), line=dict(color=_COLOR_BG_PAPER, width=0.5), ), hovertemplate=( "<b>Predicted:</b> %{x:.4f}<br>" "<b>√|Std. Residual|:</b> %{y:.4f}<extra></extra>" ), customdata=sd.X_data.index.tolist(), ) ) # Overlay LOWESS smoothed line lowess = getattr(self.results, f"{subset}_scale_loc_lowess", None) self._add_lowess_trace( fig, lowess, name="Homoscedasticity Trend", color=_COLOR_PURPLE ) self._apply_theme( fig, title=f"Scale-Location Plot ({subset.capitalize()})", xaxis_title="Predicted Values", yaxis_title="√|Standardized Residuals|", show_legend=True, ) return fig
[docs] def plot_leverage(self) -> go.Figure: """Plot Standardized Residuals vs Leverage. Useful for identifying highly influential data points or outliers in predictor space. Marker sizes are proportional to Cook's Distance. Returns ------- go.Figure The Plotly figure object. """ leverage = self.results.leverage if leverage is None: raise ValueError("No leverage data available in results.") train_sd = self.results.train_data if train_sd is None or train_sd.std_residuals is None: raise ValueError("No training data available for leverage plotting.") fig = go.Figure() std_res = train_sd.std_residuals.tolist() lev_list = leverage.tolist() cooks = self.results.cooks_distance marker_sizes = [6] * len(lev_list) if cooks is not None: # Map cooks distance to marker sizes dynamically (range 6 to 24) c_max = float(np.max(cooks)) if len(cooks) > 0 else 1.0 marker_sizes = (6 + 18 * (cooks / (c_max + 1e-12))).tolist() abs_std_res = np.abs(train_sd.std_residuals).tolist() vmax = float(np.percentile(abs_std_res, 95)) if len(abs_std_res) > 0 else 1.0 fig.add_trace( go.Scatter( x=lev_list, y=std_res, mode="markers", name="Observations", marker=dict( size=marker_sizes, color=abs_std_res, colorscale=_COLORSCALE_RESIDUALS, cmin=0, cmax=vmax, colorbar=self._compact_colorbar("|Error|"), line=dict(color=_COLOR_BG_PAPER, width=0.5), ), text=( [f"Cook's D: {c:.4f}" for c in cooks.tolist()] if cooks is not None else [] ), hovertemplate=( "<b>Leverage:</b> %{x:.4f}<br>" "<b>Std. Residual:</b> %{y:.4f}<br>" "<b>Cook's Distance:</b> %{marker.color:.4f}<extra></extra>" ), customdata=train_sd.X_data.index.tolist(), ) ) # Plot reference thresholds at +/- 3 standardized residuals fig.add_hline( y=3, line=dict(color=_COLOR_RED, width=1.2, dash="dash"), name="Outlier limit", ) fig.add_hline(y=-3, line=dict(color=_COLOR_RED, width=1.2, dash="dash")) fig.add_hline(y=0, line=dict(color=_COLOR_TEXT_MUTED, width=1)) # LOWESS Trend Line self._add_lowess_trace( fig, self.results.leverage_lowess, name="Leverage Trend", color=_COLOR_BLUE ) self._apply_theme( fig, title="Residuals vs. Leverage (Train)", xaxis_title="Leverage (Hat Values)", yaxis_title="Standardized Residuals", show_legend=True, ) return fig
# ------------------------------------------------------------------ # Classification Plotting Methods # ------------------------------------------------------------------
[docs] def plot_confusion_matrix(self, subset: str = "test") -> go.Figure: """Plot a labeled, proportional Confusion Matrix heatmap. Shows both raw sample count and row-wise accuracy percentages in each cell. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ cm_df = getattr(self.results, f"{subset}_confusion_matrix", None) if cm_df is None: raise ValueError(f"No {subset} confusion matrix available in results.") z = cm_df.values x_labels = [f"Class {c}" for c in cm_df.columns] y_labels = [f"Class {r}" for r in cm_df.index] # Calculate row-wise percentages (proportions) row_sums = z.sum(axis=1, keepdims=True) z_pct = np.where(row_sums > 0, z / row_sums * 100, 0.0) # Custom cell text formatting cell_text = [] for i in range(len(y_labels)): row_text = [] for j in range(len(x_labels)): row_text.append(f"<b>{z[i, j]}</b><br>{z_pct[i, j]:.1f}%") cell_text.append(row_text) fig = go.Figure( go.Heatmap( z=z_pct.tolist(), # Color map based on proportional percentages x=x_labels, y=y_labels, text=cell_text, texttemplate="%{text}", textfont=dict(size=12, color=_COLOR_TEXT_MAIN, family=_FONT_FAMILY), colorscale=_COLORSCALE_HEATMAP, showscale=True, colorbar=dict( title=dict( text="Recall %", font=dict(size=8, color=_COLOR_TEXT_MUTED) ), ticksuffix="%", tickfont=dict(color=_COLOR_TEXT_MUTED, size=8), thickness=10, len=0.7, xpad=5, ypad=5, ), hovertemplate=( "<b>Predicted:</b> %{x}<br>" "<b>Actual:</b> %{y}<br>" "<b>Recall Accuracy:</b> %{z:.2f}%<extra></extra>" ), ) ) self._apply_theme( fig, title=f"Confusion Matrix ({subset.capitalize()})", xaxis_title="Predicted Class", yaxis_title="Actual Class", show_legend=False, ) fig.update_xaxes(type="category") fig.update_yaxes(type="category", autorange="reversed") return fig
[docs] def plot_roc_curve(self, subset: str = "test") -> go.Figure: """Plot Receiver Operating Characteristic (ROC) curves. Overlays diagonal no-skill guideline and calculates AUC scores. Handles both binary and multi-class classification formats seamlessly. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ curves = getattr(self.results, f"{subset}_roc_curves", None) if not curves: raise ValueError(f"No {subset} ROC curve coordinates calculated.") fig = go.Figure() # Add random guess guideline (y = x) fig.add_trace( go.Scatter( x=[0, 1], y=[0, 1], mode="lines", line=dict(color=_COLOR_TEXT_MUTED, width=1.5, dash="dash"), name="Random Guess (AUC = 0.50)", hoverinfo="skip", ) ) # Draw ROC curve for each class colors = [_COLOR_BLUE, _COLOR_GREEN, _COLOR_PURPLE, _COLOR_PINK, _COLOR_AMBER] for idx, curve in enumerate(curves): c_color = colors[idx % len(colors)] c_name = ( "Model ROC" if curve.label == "binary" else f"Class {curve.label.replace('class_', '')}" ) fig.add_trace( go.Scatter( x=curve.fpr.tolist(), y=curve.tpr.tolist(), mode="lines", line=dict(color=c_color, width=2.5), name=f"{c_name} (AUC = {curve.auc_score:.4f})", hovertemplate=( "<b>FPR (1-Spec):</b> %{x:.4f}<br>" "<b>TPR (Sens):</b> %{y:.4f}<extra></extra>" ), ) ) self._apply_theme( fig, title=f"Receiver Operating Characteristic (ROC) Curve ({subset.capitalize()})", xaxis_title="False Positive Rate (1 - Specificity)", yaxis_title="True Positive Rate (Sensitivity / Recall)", show_legend=True, ) fig.update_xaxes(range=[-0.01, 1.01]) fig.update_yaxes(range=[-0.01, 1.01]) return fig
[docs] def plot_pr_curve(self, subset: str = "test") -> go.Figure: """Plot Precision-Recall (PR) curves with baseline reference line. Highly recommended for class-imbalanced datasets. Includes Average Precision (AP) scores. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ curves = getattr(self.results, f"{subset}_pr_curves", None) if not curves: raise ValueError(f"No {subset} PR curve coordinates calculated.") fig = go.Figure() colors = [_COLOR_BLUE, _COLOR_GREEN, _COLOR_PURPLE, _COLOR_PINK, _COLOR_AMBER] for idx, curve in enumerate(curves): c_color = colors[idx % len(colors)] c_name = ( "Model PR" if curve.label == "binary" else f"Class {curve.label.replace('class_', '')}" ) # Draw PR curve fig.add_trace( go.Scatter( x=curve.recall_arr.tolist(), y=curve.precision_arr.tolist(), mode="lines", line=dict(color=c_color, width=2.5), name=f"{c_name} (AP = {curve.average_precision:.4f})", hovertemplate=( "<b>Recall:</b> %{x:.4f}<br>" "<b>Precision:</b> %{y:.4f}<extra></extra>" ), ) ) # Draw baseline reference line fig.add_hline( y=curve.baseline, line=dict(color=c_color, width=1.2, dash="dot"), annotation_text=f"Baseline ({curve.baseline:.2f})", annotation_position="bottom left", annotation_font=dict(size=8, color=_COLOR_TEXT_MUTED), ) self._apply_theme( fig, title=f"Precision-Recall Curve ({subset.capitalize()})", xaxis_title="Recall", yaxis_title="Precision", show_legend=True, ) fig.update_xaxes(range=[-0.01, 1.01]) fig.update_yaxes(range=[-0.01, 1.01]) return fig
[docs] def plot_calibration_curve(self, subset: str = "test") -> go.Figure: """Plot Calibration curves (Reliability diagrams). Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ curves = getattr(self.results, f"{subset}_calibration_curves", None) if not curves: raise ValueError(f"No {subset} calibration curves calculated.") fig = go.Figure() # Add perfect calibration line fig.add_trace( go.Scatter( x=[0, 1], y=[0, 1], mode="lines", line=dict(color=_COLOR_TEXT_MUTED, width=1.5, dash="dash"), name="Perfect Calibration", hoverinfo="skip", ) ) colors = [_COLOR_BLUE, _COLOR_GREEN, _COLOR_PURPLE, _COLOR_PINK, _COLOR_AMBER] for idx, curve in enumerate(curves): c_color = colors[idx % len(colors)] c_name = ( "Model Calibration" if curve.label == "binary" else f"Class {curve.label.replace('class_', '')}" ) fig.add_trace( go.Scatter( x=curve.prob_pred.tolist(), y=curve.prob_true.tolist(), mode="lines+markers", line=dict(color=c_color, width=2.5), marker=dict(size=6), name=c_name, hovertemplate=( "<b>Pred Prob:</b> %{x:.4f}<br>" "<b>True Prob:</b> %{y:.4f}<extra></extra>" ), ) ) self._apply_theme( fig, title=f"Calibration Curve ({subset.capitalize()})", xaxis_title="Mean Predicted Probability", yaxis_title="Fraction of Positives", show_legend=True, ) fig.update_xaxes(range=[-0.01, 1.01]) fig.update_yaxes(range=[-0.01, 1.01]) return fig
[docs] def plot_threshold_analysis(self, subset: str = "test") -> go.Figure: """Plot Precision, Recall, and F1-Score across thresholds. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ analyses = getattr(self.results, f"{subset}_threshold_analysis", None) if not analyses: raise ValueError(f"No {subset} threshold analysis calculated.") curve = analyses[0] fig = go.Figure() c_name = ( "" if curve.label == "binary" else f" (Class {curve.label.replace('class_', '')})" ) fig.add_trace( go.Scatter( x=curve.thresholds.tolist(), y=curve.precision.tolist(), mode="lines", line=dict(color=_COLOR_BLUE, width=2), name=f"Precision{c_name}", ) ) fig.add_trace( go.Scatter( x=curve.thresholds.tolist(), y=curve.recall.tolist(), mode="lines", line=dict(color=_COLOR_GREEN, width=2), name=f"Recall{c_name}", ) ) fig.add_trace( go.Scatter( x=curve.thresholds.tolist(), y=curve.f1_score.tolist(), mode="lines", line=dict(color=_COLOR_PURPLE, width=3), name=f"F1 Score{c_name}", ) ) self._apply_theme( fig, title=f"Decision Threshold Analysis ({subset.capitalize()})", xaxis_title="Threshold", yaxis_title="Score", show_legend=True, ) fig.update_xaxes(range=[0, 1]) fig.update_yaxes(range=[0, 1.05]) return fig
[docs] def plot_residual_distribution(self, subset: str = "test") -> go.Figure: """Plot a histogram of residuals with an overlaid normal distribution curve. Helps verify if the model errors are symmetrically distributed and normally concentrated around zero. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ sd = getattr(self.results, f"{subset}_data", None) if sd is None: raise ValueError(f"No {subset} subset data available in results.") fig = go.Figure() residuals = getattr(sd, "residuals", sd.y_true - sd.y_pred) residuals_clean = residuals[np.isfinite(residuals)] if len(residuals_clean) == 0: raise ValueError(f"No finite residuals to plot for subset {subset}.") # Compute histogram bins in Python to apply colorscale dynamically counts, bin_edges = np.histogram(residuals_clean, bins="auto", density=True) bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2.0 bar_colors = np.abs(bin_centers) vmax = ( float(np.percentile(np.abs(residuals_clean), 95)) if len(residuals_clean) > 0 else 1.0 ) # Add histogram trace using go.Bar with gradient colorscale fig.add_trace( go.Bar( x=bin_centers.tolist(), y=counts.tolist(), width=[float(bin_edges[1] - bin_edges[0])] * len(bin_centers), # Ensure bars touch like a histogram name="Residuals Density", marker=dict( color=bar_colors.tolist(), colorscale=_COLORSCALE_RESIDUALS, cmin=0, cmax=vmax, line=dict(color=_COLOR_BG_PAPER, width=0.5), ), hovertemplate=( "<b>Residual Range:</b> %{x:.4f}<br>" "<b>Density:</b> %{y:.4f}<extra></extra>" ), ) ) # Compute normal curve mean_res = float(np.mean(residuals_clean)) std_res = float(np.std(residuals_clean)) if std_res > 0: x_curve = np.linspace( float(np.min(residuals_clean)), float(np.max(residuals_clean)), 200 ) y_curve = norm.pdf(x_curve, mean_res, std_res) fig.add_trace( go.Scatter( x=x_curve.tolist(), y=y_curve.tolist(), mode="lines", line=dict(color=_COLOR_AMBER, width=2.5), name=f"Normal Fit (μ={mean_res:.2f}, σ={std_res:.2f})", hovertemplate=( "<b>Residual:</b> %{x:.4f}<br>" "<b>Normal Density:</b> %{y:.4f}<extra></extra>" ), ) ) self._apply_theme( fig, title=f"Residual Distribution ({subset.capitalize()})", xaxis_title="Residual (Actual - Predicted)", yaxis_title="Density", show_legend=True, ) return fig
[docs] def plot_residuals_vs_actual(self, subset: str = "test") -> go.Figure: """Plot Residuals vs. Actual target values. Helps visualize error behavior across the target's true range. Systematic trends suggest missing non-linear relationships. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ sd = getattr(self.results, f"{subset}_data", None) if sd is None: raise ValueError(f"No {subset} subset data available in results.") fig = go.Figure() y_true = sd.y_true.tolist() residuals = getattr(sd, "residuals", sd.y_true - sd.y_pred).tolist() abs_res = getattr(sd, "abs_residuals", np.abs(residuals)).tolist() # Zero baseline reference line fig.add_hline( y=0, line=dict(color=_COLOR_TEXT_MUTED, width=1.5, dash="dash"), annotation_text="Zero Residual", annotation_position="bottom right", annotation_font=dict(size=9, color=_COLOR_TEXT_MUTED), ) # Plot observations vmax = float(np.percentile(abs_res, 95)) if len(abs_res) > 0 else 1.0 fig.add_trace( go.Scatter( x=y_true, y=residuals, mode="markers", name="Residuals", marker=dict( size=7, color=abs_res, colorscale=_COLORSCALE_RESIDUALS, cmin=0, cmax=vmax, colorbar=self._compact_colorbar("|Error|"), line=dict(color=_COLOR_BG_PAPER, width=0.5), ), hovertemplate=( "<b>Actual Value:</b> %{x:.4f}<br>" "<b>Residual:</b> %{y:.4f}<extra></extra>" ), customdata=sd.X_data.index.tolist(), ) ) # Compute on-the-fly LOWESS curve try: from statsmodels.nonparametric.smoothers_lowess import lowess as sm_lowess xs = np.asarray(y_true) ys = np.asarray(residuals) idx = np.argsort(xs) xs_s, ys_s = xs[idx], ys[idx] smooth = sm_lowess(ys_s, xs_s, frac=0.6667, return_sorted=True) fig.add_trace( go.Scatter( x=smooth[:, 0].tolist(), y=smooth[:, 1].tolist(), mode="lines", line=dict(color=_COLOR_PURPLE, width=2.5, shape="spline"), name="LOWESS Trend", hoverinfo="skip", ) ) except Exception as exc: import logging logging.getLogger(__name__).warning( "On-the-fly LOWESS curve failed: %s", exc ) self._apply_theme( fig, title=f"Residuals vs. Actual Values ({subset.capitalize()})", xaxis_title="Actual Target Values", yaxis_title="Residual (Actual - Predicted)", show_legend=True, ) return fig
[docs] def plot_posterior_predictive(self, subset: str = "test") -> go.Figure: """Plot the Posterior Predictive Density comparison. Compares the density/KDE curves of the actual and predicted values to verify if the model captures the shape, modality, and spread of the true target variable. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ sd = getattr(self.results, f"{subset}_data", None) if sd is None: raise ValueError(f"No {subset} subset data available in results.") fig = go.Figure() y_true = sd.y_true[np.isfinite(sd.y_true)] y_pred = sd.y_pred[np.isfinite(sd.y_pred)] if len(y_true) < 2 or len(y_pred) < 2: raise ValueError("Insufficient finite data points to estimate density.") try: # Generate common evaluation grid min_val = min(float(np.min(y_true)), float(np.min(y_pred))) max_val = max(float(np.max(y_true)), float(np.max(y_pred))) pad = (max_val - min_val) * 0.15 x_grid = np.linspace(min_val - pad, max_val + pad, 250) # Actual KDE kde_true = gaussian_kde(y_true) y_true_density = kde_true(x_grid) fig.add_trace( go.Scatter( x=x_grid.tolist(), y=y_true_density.tolist(), mode="lines", fill="tozeroy", fillcolor="rgba(56, 189, 248, 0.15)", # Sky Blue translucent line=dict(color=_COLOR_BLUE, width=2.5), name="Actual Values", hovertemplate=( "<b>Value:</b> %{x:.4f}<br>" "<b>Density:</b> %{y:.4f}<extra></extra>" ), ) ) # Predicted KDE kde_pred = gaussian_kde(y_pred) y_pred_density = kde_pred(x_grid) fig.add_trace( go.Scatter( x=x_grid.tolist(), y=y_pred_density.tolist(), mode="lines", fill="tozeroy", fillcolor="rgba(245, 158, 11, 0.15)", # Amber translucent line=dict(color=_COLOR_AMBER, width=2.5), name="Predicted Values", hovertemplate=( "<b>Value:</b> %{x:.4f}<br>" "<b>Density:</b> %{y:.4f}<extra></extra>" ), ) ) except Exception: # Fallback to overlay histograms fig.add_trace( go.Histogram( x=y_true.tolist(), histnorm="probability density", name="Actual Values (Hist)", opacity=0.5, marker_color=_COLOR_BLUE, ) ) fig.add_trace( go.Histogram( x=y_pred.tolist(), histnorm="probability density", name="Predicted Values (Hist)", opacity=0.5, marker_color=_COLOR_AMBER, ) ) self._apply_theme( fig, title=f"Posterior Predictive Density Comparison ({subset.capitalize()})", xaxis_title="Value", yaxis_title="Probability Density", show_legend=True, ) return fig
[docs] def plot_outliers(self) -> go.Figure: """Plot Outliers diagnostic. Displays the values of the top significant outlier-predicting feature grouped by standardized residual magnitude, highlighting anomalous samples. Returns ------- go.Figure The Plotly figure object. """ oa = self.results.outlier_analysis test_sd = self.results.test_data if test_sd is None or test_sd.std_residuals is None: raise ValueError("No test data available for outlier plotting.") fig = go.Figure() std_residuals = test_sd.std_residuals abs_std_residuals = np.abs(std_residuals) threshold = ( oa.threshold if oa is not None else float(np.percentile(abs_std_residuals, 95)) ) # Check if outlier analysis found significant features top_feature = None if oa is not None and oa.results_df is not None and len(oa.results_df) > 0: top_feature = oa.results_df.iloc[0]["feature"] if top_feature is None: num_cols = test_sd.X_data.select_dtypes(include=[np.number]).columns if len(num_cols) > 0: top_feature = num_cols[0] if top_feature is None: raise ValueError("No numeric features available to analyze outliers.") feature_values = test_sd.X_data[top_feature].values outlier_mask = abs_std_residuals >= threshold # Plot observations with consistent |Error| colorscale and outlier sizing/borders vmax = ( float(np.percentile(abs_std_residuals, 95)) if len(abs_std_residuals) > 0 else 1.0 ) # Build dynamic marker settings for outliers vs typical observations marker_sizes = np.where(outlier_mask, 9, 6).tolist() marker_line_widths = np.where(outlier_mask, 1.2, 0.5).tolist() marker_line_colors = np.where( outlier_mask, _COLOR_TEXT_MAIN, _COLOR_BG_PAPER ).tolist() fig.add_trace( go.Scatter( x=feature_values.tolist(), y=std_residuals.tolist(), mode="markers", name="Observations", marker=dict( size=marker_sizes, color=abs_std_residuals.tolist(), colorscale=_COLORSCALE_RESIDUALS, cmin=0, cmax=vmax, colorbar=self._compact_colorbar("|Error|"), line=dict( color=marker_line_colors, width=marker_line_widths, ), ), hovertemplate=( f"<b>{top_feature}:</b> %{{x:.4f}}<br>" f"<b>Std. Residual:</b> %{{y:.4f}}<br>" f"<b>Absolute Error:</b> %{{marker.color:.4f}}<extra></extra>" ), customdata=test_sd.X_data.index.tolist(), ) ) # Overlays thresholds fig.add_hline( y=threshold, line=dict(color=_COLOR_RED, width=1.5, dash="dash"), name="Outlier Threshold", ) fig.add_hline(y=-threshold, line=dict(color=_COLOR_RED, width=1.5, dash="dash")) fig.add_hline(y=0, line=dict(color=_COLOR_TEXT_MUTED, width=1)) # LOWESS Trend Line if calculated for this feature if oa is not None and top_feature in oa.lowess_curves: self._add_lowess_trace( fig, oa.lowess_curves[top_feature], name="Outliers Trend", color=_COLOR_PURPLE, ) self._apply_theme( fig, title=f"Outlier Feature Analysis: Standardized Residuals vs. {top_feature}", xaxis_title=f"{top_feature} Values", yaxis_title="Standardized Residuals", show_legend=True, ) return fig
[docs] def plot_metrics(self, subset: str = "test") -> go.Figure: """Plot a highly stylized Summary Metrics Card. Renders scalar model metrics as a publication-grade graphical card. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ fig = go.Figure() if self.results.problem_type == "regression": m = getattr(self.results, f"{subset}_metrics", None) if m is None: raise ValueError( f"No regression metrics calculated for {subset} subset." ) headers = ["Evaluation Metric", "Calculated Value"] cells = [ [ "R² Coefficient", "MAE (Mean Absolute Error)", "MSE (Mean Squared Error)", "RMSE (Root Mean Squared Error)", "MAPE (Percentage Error)", "Observations Count", ], [ f"{m.r2:.4f}", f"{m.mae:.4f}", f"{m.mse:.4f}", f"{m.rmse:.4f}", f"{m.mape:.2f}%", f"{m.n_samples}", ], ] title = f"Regression Performance Summary ({subset.capitalize()})" else: m = getattr(self.results, f"{subset}_clf_metrics", None) if m is None: raise ValueError( f"No classification metrics calculated for {subset} subset." ) headers = ["Evaluation Metric", "Calculated Value"] metric_names = [ "Accuracy Score", "Precision (Macro/Binary)", "Recall (Macro/Binary)", "F1-Score (Macro/Binary)", "Matthews Corrcoef (MCC)", ] metric_vals = [ f"{m.accuracy * 100:.2f}%", f"{m.precision * 100:.2f}%", f"{m.recall * 100:.2f}%", f"{m.f1_score * 100:.2f}%", f"{m.mcc:.4f}", ] if getattr(m, "roc_auc", None) is not None: metric_names.append("ROC-AUC") metric_vals.append(f"{m.roc_auc:.4f}") if getattr(m, "pr_auc", None) is not None: metric_names.append("PR-AUC (Average Precision)") metric_vals.append(f"{m.pr_auc:.4f}") if getattr(m, "brier_score", None) is not None: metric_names.append("Brier Score Loss") metric_vals.append(f"{m.brier_score:.4f}") if getattr(m, "log_loss", None) is not None: metric_names.append("Log-Loss (Cross-Entropy)") metric_vals.append(f"{m.log_loss:.4f}") cells = [metric_names, metric_vals] title = f"Classification Performance Summary ({subset.capitalize()})" fig.add_trace( go.Table( header=dict( values=[f"<b>{h}</b>" for h in headers], fill_color="#f1f5f9", # Soft slate header background align="left", font=dict(color=_COLOR_TEXT_MAIN, size=13, family=_FONT_FAMILY), line_color=_COLOR_BORDER, height=32, ), cells=dict( values=cells, fill_color="#ffffff", # Pure white background align="left", font=dict(color=_COLOR_TEXT_MUTED, size=12, family=_FONT_FAMILY), line_color=_COLOR_BORDER, height=28, ), ) ) self._apply_theme( fig, title=title, xaxis_title="", yaxis_title="", show_legend=False, ) # Clean layout of margins for tables fig.update_layout(margin=dict(l=40, r=40, t=65, b=40)) return fig
[docs] def plot_class_distribution(self, subset: str = "test") -> go.Figure: """Plot the Actual vs. Predicted Class Distributions. Displays class proportions as a grouped bar chart to immediately highlight prediction biases and class imbalances. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ sd = getattr(self.results, f"{subset}_data", None) if sd is None: raise ValueError(f"No {subset} subset data available in results.") fig = go.Figure() y_true = np.asarray(sd.y_true) y_pred = np.asarray(sd.y_pred) classes, counts_true = np.unique(y_true, return_counts=True) pred_classes, counts_pred = np.unique(y_pred, return_counts=True) # Standardize matching class counts all_classes = np.unique(np.concatenate((classes, pred_classes))) counts_true_map = dict(zip(classes, counts_true, strict=False)) counts_pred_map = dict(zip(pred_classes, counts_pred, strict=False)) final_counts_true = [counts_true_map.get(c, 0) for c in all_classes] final_counts_pred = [counts_pred_map.get(c, 0) for c in all_classes] class_labels = [str(c) for c in all_classes] fig.add_trace( go.Bar( x=class_labels, y=final_counts_true, name="Actual Class", marker_color=_COLOR_BLUE, marker_line_color=_COLOR_BG_PAPER, marker_line_width=1, hovertemplate=( "<b>Class:</b> %{x}<br>" "<b>Actual Count:</b> %{y}<extra></extra>" ), ) ) fig.add_trace( go.Bar( x=class_labels, y=final_counts_pred, name="Predicted Class", marker_color=_COLOR_AMBER, marker_line_color=_COLOR_BG_PAPER, marker_line_width=1, hovertemplate=( "<b>Class:</b> %{x}<br>" "<b>Predicted Count:</b> %{y}<extra></extra>" ), ) ) self._apply_theme( fig, title=f"Class Distribution Comparison ({subset.capitalize()})", xaxis_title="Target Class Labels", yaxis_title="Instance Count", show_legend=True, ) fig.update_layout(barmode="group") return fig
[docs] def plot_probability_distribution(self, subset: str = "test") -> go.Figure: """Plot the distribution of predicted probabilities. Categorizes samples into Correctly Predicted vs. Misclassified, showing the confidence distribution (winning class probability). Helps visualize model calibration and uncertainty. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ sd = getattr(self.results, f"{subset}_data", None) if sd is None: raise ValueError(f"No {subset} subset data available in results.") if sd.y_prob is None: raise ValueError( f"No prediction probabilities available for {subset} subset." ) fig = go.Figure() y_true = np.asarray(sd.y_true) y_pred = np.asarray(sd.y_pred) y_prob = np.asarray(sd.y_prob) # Get the probability of the predicted winning class (confidence) classes = np.unique(y_true) class_to_idx = {c: idx for idx, c in enumerate(classes)} confidences = [] for i, pred_class in enumerate(y_pred): try: idx = class_to_idx[pred_class] confidences.append(float(y_prob[i, idx])) except Exception: confidences.append(float(np.max(y_prob[i]))) conf_arr = np.asarray(confidences) correct_mask = y_true == y_pred correct_conf = conf_arr[correct_mask] wrong_conf = conf_arr[~correct_mask] try: # Generate KDE evaluation grid (clamped between 0 and 1) x_grid = np.linspace(0.4, 1.0, 150) # Correct predictions KDE if len(correct_conf) > 1: kde_correct = gaussian_kde(correct_conf) fig.add_trace( go.Scatter( x=x_grid.tolist(), y=kde_correct(x_grid).tolist(), mode="lines", fill="tozeroy", fillcolor="rgba(16, 185, 129, 0.15)", # Translucent Emerald Green line=dict(color=_COLOR_GREEN, width=2.5), name="Correct Predictions", hovertemplate=( "<b>Confidence:</b> %{x:.2f}%<br>" "<b>Density:</b> %{y:.4f}<extra></extra>" ), ) ) # Incorrect predictions KDE if len(wrong_conf) > 1: kde_wrong = gaussian_kde(wrong_conf) fig.add_trace( go.Scatter( x=x_grid.tolist(), y=kde_wrong(x_grid).tolist(), mode="lines", fill="tozeroy", fillcolor="rgba(248, 113, 113, 0.15)", # Translucent Red line=dict(color=_COLOR_RED, width=2.5), name="Misclassified Predictions", hovertemplate=( "<b>Confidence:</b> %{x:.2f}%<br>" "<b>Density:</b> %{y:.4f}<extra></extra>" ), ) ) except Exception: # Fallback to histogram fig.add_trace( go.Histogram( x=correct_conf.tolist(), name="Correct (Hist)", opacity=0.6, marker_color=_COLOR_GREEN, ) ) fig.add_trace( go.Histogram( x=wrong_conf.tolist(), name="Incorrect (Hist)", opacity=0.6, marker_color=_COLOR_RED, ) ) self._apply_theme( fig, title=f"Prediction Confidence Distribution ({subset.capitalize()})", xaxis_title="Predicted Class Winning Probability", yaxis_title="Probability Density", show_legend=True, ) fig.update_xaxes(range=[0.38, 1.02]) return fig
[docs] def plot_misclassification_features(self, subset: str = "test") -> go.Figure: """Plot the Misclassification Feature diagnostic. Highlights the feature density boundaries of the top significant feature that separates Correctly Predicted vs. Misclassified samples. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ mc = getattr(self.results, f"{subset}_misclassification", None) sd = getattr(self.results, f"{subset}_data", None) if sd is None: raise ValueError(f"No {subset} subset data available in results.") fig = go.Figure() y_true = np.asarray(sd.y_true) y_pred = np.asarray(sd.y_pred) correct_mask = y_true == y_pred # Pick top significant feature from misclassification result top_feature = None if mc is not None and mc.results_df is not None and len(mc.results_df) > 0: top_feature = mc.results_df.iloc[0]["feature"] if top_feature is None: num_cols = sd.X_data.select_dtypes(include=[np.number]).columns if len(num_cols) > 0: top_feature = num_cols[0] if top_feature is None: raise ValueError( "No numeric features available to evaluate misclassifications." ) feature_values = sd.X_data[top_feature].values correct_vals = feature_values[correct_mask] wrong_vals = feature_values[~correct_mask] correct_vals_clean = correct_vals[np.isfinite(correct_vals)] wrong_vals_clean = wrong_vals[np.isfinite(wrong_vals)] try: # Generate common evaluation grid min_val = min( float(np.min(correct_vals_clean)), float(np.min(wrong_vals_clean)) ) max_val = max( float(np.max(correct_vals_clean)), float(np.max(wrong_vals_clean)) ) pad = (max_val - min_val) * 0.1 x_grid = np.linspace(min_val - pad, max_val + pad, 200) # Correctly predicted density if len(correct_vals_clean) > 1: kde_correct = gaussian_kde(correct_vals_clean) fig.add_trace( go.Scatter( x=x_grid.tolist(), y=kde_correct(x_grid).tolist(), mode="lines", fill="tozeroy", fillcolor="rgba(16, 185, 129, 0.15)", line=dict(color=_COLOR_GREEN, width=2.5), name="Correct Predictions", hovertemplate=( "<b>Feature Value:</b> %{x:.4f}<br>" "<b>Density:</b> %{y:.4f}<extra></extra>" ), ) ) # Misclassified density if len(wrong_vals_clean) > 1: kde_wrong = gaussian_kde(wrong_vals_clean) fig.add_trace( go.Scatter( x=x_grid.tolist(), y=kde_wrong(x_grid).tolist(), mode="lines", fill="tozeroy", fillcolor="rgba(248, 113, 113, 0.15)", line=dict(color=_COLOR_RED, width=2.5), name="Misclassified Predictions", hovertemplate=( "<b>Feature Value:</b> %{x:.4f}<br>" "<b>Density:</b> %{y:.4f}<extra></extra>" ), ) ) except Exception: # Fallback to histogram fig.add_trace( go.Histogram( x=correct_vals_clean.tolist(), name="Correct (Hist)", opacity=0.6, marker_color=_COLOR_GREEN, ) ) fig.add_trace( go.Histogram( x=wrong_vals_clean.tolist(), name="Incorrect (Hist)", opacity=0.6, marker_color=_COLOR_RED, ) ) self._apply_theme( fig, title=f"Misclassification Boundary: Density of {top_feature} ({subset.capitalize()})", xaxis_title=f"{top_feature} Values", yaxis_title="Probability Density", show_legend=True, ) return fig
# --- Graph Aliases to exactly match user endpoints ---
[docs] def plot_act_vs_pred(self, subset: str = "test") -> go.Figure: """Alias for plot_actual_vs_predicted. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ return self.plot_actual_vs_predicted(subset)
[docs] def plot_res_dist(self, subset: str = "test") -> go.Figure: """Alias for plot_residual_distribution. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ return self.plot_residual_distribution(subset)
[docs] def plot_res_vs_pred(self, subset: str = "test") -> go.Figure: """Alias for plot_residuals. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ return self.plot_residuals(subset)
[docs] def plot_res_vs_act(self, subset: str = "test") -> go.Figure: """Alias for plot_residuals_vs_actual. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ return self.plot_residuals_vs_actual(subset)
[docs] def plot_scale_loc(self, subset: str = "test") -> go.Figure: """Alias for plot_scale_location. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ return self.plot_scale_location(subset)
[docs] def plot_post_pred(self, subset: str = "test") -> go.Figure: """Alias for plot_posterior_predictive. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ return self.plot_posterior_predictive(subset)
[docs] def plot_prob_dist(self, subset: str = "test") -> go.Figure: """Alias for plot_probability_distribution. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ return self.plot_probability_distribution(subset)
[docs] def plot_misclass(self, subset: str = "test") -> go.Figure: """Alias for plot_misclassification_features. Parameters ---------- subset : str, default="test" The data subset to plot (``"train"`` or ``"test"``). Returns ------- go.Figure The Plotly figure object. """ return self.plot_misclassification_features(subset)
# ------------------------------------------------------------------ # Zero-friction Serialization Helpers # ------------------------------------------------------------------ # ------------------------------------------------------------------ # SHAP Plotting Methods # ------------------------------------------------------------------
[docs] def plot_shap_summary(self, subset: str = "test") -> go.Figure: """Plot the SHAP summary (global feature importance) as a bar chart. Parameters ---------- subset : str, default="test" The subset to evaluate. Returns ------- go.Figure The Plotly figure. """ shap_data = getattr(self.results, f"{subset}_shap", None) if shap_data is None: raise ValueError(f"No SHAP data found for subset '{subset}'.") mean_abs_shap = shap_data.mean_abs_shap feature_names = shap_data.feature_names sorted_indices = np.argsort(mean_abs_shap) top_indices = sorted_indices[-10:] x = mean_abs_shap[top_indices] y = [feature_names[i] for i in top_indices] fig = go.Figure( data=go.Bar( x=x, y=y, orientation='h', marker=dict(color=_COLOR_BLUE), hovertemplate="<b>%{y}</b><br>Mean |SHAP|: %{x:.4f}<extra></extra>" ) ) self._apply_theme( fig, title=f"Global Feature Importance (SHAP) ({subset.title()})", xaxis_title="Mean |SHAP value| (average impact on model output)", yaxis_title="" ) fig.update_layout(showlegend=False) return fig
[docs] def plot_shap_beeswarm(self, subset: str = "test") -> go.Figure: """Plot the SHAP beeswarm chart. Parameters ---------- subset : str, default="test" The subset to evaluate. Returns ------- go.Figure The Plotly figure. """ shap_data = getattr(self.results, f"{subset}_shap", None) if shap_data is None: raise ValueError(f"No SHAP data found for subset '{subset}'.") mean_abs_shap = shap_data.mean_abs_shap feature_names = shap_data.feature_names shap_values = shap_data.shap_values feature_values = ( shap_data.feature_values if shap_data.feature_values is not None else shap_values ) sorted_indices = np.argsort(mean_abs_shap)[-10:] fig = go.Figure() n_samples = shap_values.shape[0] np.random.seed(42) jitter = (np.random.rand(n_samples) - 0.5) * 0.35 tickvals = [] ticktext = [] for y_pos, f_idx in enumerate(sorted_indices): f_name = feature_names[f_idx] sv = shap_values[:, f_idx] fv = feature_values[:, f_idx] f_min = np.min(fv) f_max = np.max(fv) f_range = f_max - f_min if f_max > f_min else 1.0 cv = (fv - f_min) / f_range y_vals = y_pos + jitter marker = dict( size=5, color=cv, colorscale=[[0, '#008bfb'], [1, '#ff0052']], cmin=0, cmax=1, line=dict(width=0), showscale=(y_pos == 0), ) if y_pos == 0: marker['colorbar'] = dict( title="Feature value", tickvals=[0, 1], ticktext=["Low", "High"], thickness=10, len=0.5, outlinewidth=0, ) hover_text = [ f"Row {shap_data.eval_index[i] if shap_data.eval_index else i}<br>" f"{f_name}: SHAP={sv[i]:.4f}, Val={fv[i]:.4f}" for i in range(n_samples) ] fig.add_trace(go.Scatter( x=sv, y=y_vals, mode='markers', marker=marker, text=hover_text, hovertemplate="%{text}<extra></extra>", showlegend=False )) tickvals.append(y_pos) ticktext.append(f_name) self._apply_theme( fig, title=f"SHAP Beeswarm Plot ({subset.title()})", xaxis_title="SHAP value (impact on model output)", yaxis_title="" ) fig.update_yaxes( tickvals=tickvals, ticktext=ticktext, showgrid=False, zeroline=False ) return fig
[docs] def to_json_bundle(self, subset: str = "test") -> str: """Serialize a pre-packaged bundle of all available diagnostic charts. Perfect for sending over HTTP or rendering directly in drag-and-drop dashboards. Uses the exact 18 standard endpoints as dictionary keys. Parameters ---------- subset : str, default="test" The subset of charts to compile (``"train"`` or ``"test"``). Returns ------- str A JSON string containing the complete mapped dictionary of figures. """ bundle: Dict[str, Any] = {} if self.results.problem_type == "regression": # 1. act_vs_pred try: bundle["act_vs_pred"] = json.loads( self.plot_actual_vs_predicted(subset).to_json() ) except Exception as e: bundle["act_vs_pred"] = {"error": str(e)} # 2. res_dist try: bundle["res_dist"] = json.loads( self.plot_residual_distribution(subset).to_json() ) except Exception as e: bundle["res_dist"] = {"error": str(e)} # 3. res_vs_pred try: bundle["res_vs_pred"] = json.loads( self.plot_residuals(subset).to_json() ) except Exception as e: bundle["res_vs_pred"] = {"error": str(e)} # 4. res_vs_act try: bundle["res_vs_act"] = json.loads( self.plot_residuals_vs_actual(subset).to_json() ) except Exception as e: bundle["res_vs_act"] = {"error": str(e)} # 5. qq try: bundle["qq"] = json.loads(self.plot_qq(subset).to_json()) except Exception as e: bundle["qq"] = {"error": str(e)} # 6. scale_loc try: bundle["scale_loc"] = json.loads( self.plot_scale_location(subset).to_json() ) except Exception as e: bundle["scale_loc"] = {"error": str(e)} # 7. post_pred try: bundle["post_pred"] = json.loads( self.plot_posterior_predictive(subset).to_json() ) except Exception as e: bundle["post_pred"] = {"error": str(e)} # 8. leverage if subset == "train": try: bundle["leverage"] = json.loads(self.plot_leverage().to_json()) except Exception as e: bundle["leverage"] = {"error": str(e)} # 11. outliers if subset == "test": try: bundle["outliers"] = json.loads(self.plot_outliers().to_json()) except Exception as e: bundle["outliers"] = {"error": str(e)} # 12. metrics (Regression) try: bundle["metrics"] = json.loads(self.plot_metrics(subset).to_json()) except Exception as e: bundle["metrics"] = {"error": str(e)} elif self.results.problem_type == "classification": # 1. metrics (Classification) try: bundle["metrics"] = json.loads(self.plot_metrics(subset).to_json()) except Exception as e: bundle["metrics"] = {"error": str(e)} # 2. cm try: bundle["cm"] = json.loads(self.plot_confusion_matrix(subset).to_json()) except Exception as e: bundle["cm"] = {"error": str(e)} # 3. class_dist try: bundle["class_dist"] = json.loads( self.plot_class_distribution(subset).to_json() ) except Exception as e: bundle["class_dist"] = {"error": str(e)} # 4. prob_dist try: bundle["prob_dist"] = json.loads( self.plot_probability_distribution(subset).to_json() ) except Exception as e: bundle["prob_dist"] = {"error": str(e)} # 5. roc try: bundle["roc"] = json.loads(self.plot_roc_curve(subset).to_json()) except Exception as e: bundle["roc"] = {"error": str(e)} # 6. pr try: bundle["pr"] = json.loads(self.plot_pr_curve(subset).to_json()) except Exception as e: bundle["pr"] = {"error": str(e)} # 7. misclass try: bundle["misclass"] = json.loads( self.plot_misclassification_features(subset).to_json() ) except Exception as e: bundle["misclass"] = {"error": str(e)} # 8. calibration try: bundle["calibration"] = json.loads( self.plot_calibration_curve(subset).to_json() ) except Exception as e: bundle["calibration"] = {"error": str(e)} # 9. threshold try: bundle["threshold"] = json.loads( self.plot_threshold_analysis(subset).to_json() ) except Exception as e: bundle["threshold"] = {"error": str(e)} # Add SHAP raw data to the bundle shap_data = getattr(self.results, f"{subset}_shap", None) if shap_data is not None: try: bundle["shap_raw"] = { "feature_names": shap_data.feature_names, "base_value": shap_data.base_value, "shap_values": shap_data.shap_values.tolist(), "mean_abs_shap": shap_data.mean_abs_shap.tolist(), "eval_index": shap_data.eval_index, "feature_values": ( shap_data.feature_values.tolist() if shap_data.feature_values is not None else None ), } except Exception as e: bundle["shap_raw"] = {"error": str(e)} # Placeholders for frontend JS rendering if "error" not in bundle.get("shap_raw", {}): bundle["shap_summary"] = {"data": [], "layout": {}} bundle["shap_beeswarm"] = {"data": [], "layout": {}} return json.dumps(bundle)
def _get_subset_table(self, subset: str) -> Optional[Dict[str, Any]]: """Extract and format subset data to split dictionary format for efficient web transfer. Parameters ---------- subset : str The subset to extract ("train" or "test"). Returns ------- dict or None A split dictionary containing columns, index, and data, or None. """ sd = getattr(self.results, f"{subset}_data", None) if sd is None: return None # Build consolidated dataframe df = sd.X_data.copy() # Add actual and predicted columns with special identifiers df["__actual__"] = sd.y_true df["__predicted__"] = sd.y_pred if self.results.problem_type == "regression": residuals = sd.y_true - sd.y_pred df["__residual__"] = residuals df["__abs_error__"] = np.abs(residuals) else: df["__correct__"] = (sd.y_true == sd.y_pred).astype(bool) # Cap the table to 10,000 rows as requested cap_df = df.head(10000) # Ensure NaNs and infs are converted to None (json null) cap_df = cap_df.replace([np.inf, -np.inf], np.nan).where( pd.notnull(cap_df), None ) # Convert to split orientation table_dict = cap_df.to_dict(orient="split") return table_dict
[docs] def to_json_full_bundle(self) -> str: """Serialize a full diagnostics bundle containing train, test, and split tables. Returns ------- str A JSON string containing 'problem_type', 'test', 'train', and 'tables'. """ tables = {} for subset in ["test", "train"]: tbl = self._get_subset_table(subset) if tbl is not None: tables[subset] = tbl return json.dumps( { "problem_type": self.results.problem_type, "test": json.loads(self.to_json_bundle(subset="test")), "train": json.loads(self.to_json_bundle(subset="train")), "tables": tables, } )
[docs] def save_dashboard_bundle( self, filepath: str = "dashboard/active_bundle.json" ) -> None: """Save the full train/test diagnostic bundle directly to a file. (defaulting to the dashboard's active bundle path). Parameters ---------- filepath : str, default="dashboard/active_bundle.json" The filepath to write the bundle JSON to. """ import os # Ensure parent directory exists os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True) with open(filepath, "w", encoding="utf-8") as f: f.write(self.to_json_full_bundle())