"""Visualization utilities for the Streamlit frontend.""" import numpy as np import pandas as pd import matplotlib.pyplot as plt import seaborn as sns from sklearn.metrics import confusion_matrix, roc_curve, auc import plotly.graph_objects as go import plotly.express as px from typing import Tuple, List from pandas.api.types import ( is_datetime64_any_dtype, is_bool_dtype, is_numeric_dtype, is_string_dtype, is_object_dtype, ) # Set style sns.set_style("whitegrid") plt.rcParams['figure.figsize'] = (10, 6) class Visualizer: """Create visualizations for model evaluation and exploration.""" @staticmethod def plot_confusion_matrix(y_true: np.ndarray, y_pred: np.ndarray, title: str = "Confusion Matrix"): """Create confusion matrix heatmap.""" cm = confusion_matrix(y_true, y_pred) fig, ax = plt.subplots(figsize=(8, 6)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', ax=ax, cbar=False) ax.set_xlabel('Predicted') ax.set_ylabel('True') ax.set_title(title) return fig @staticmethod def plot_roc_curve(y_true: np.ndarray, y_pred_proba: np.ndarray, title: str = "ROC Curve"): """Create ROC curve plot.""" fpr, tpr, _ = roc_curve(y_true, y_pred_proba[:, 1]) roc_auc = auc(fpr, tpr) fig, ax = plt.subplots(figsize=(8, 6)) ax.plot(fpr, tpr, color='darkorange', lw=2, label=f'ROC curve (AUC = {roc_auc:.2f})') ax.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--', label='Random Classifier') ax.set_xlim([0.0, 1.0]) ax.set_ylim([0.0, 1.05]) ax.set_xlabel('False Positive Rate') ax.set_ylabel('True Positive Rate') ax.set_title(title) ax.legend(loc="lower right") return fig @staticmethod def plot_feature_distribution(df: pd.DataFrame, column: str, target_col: str = None): """ Backward compatible wrapper. Uses auto_feature_view under the hood. """ fig, meta = Visualizer.auto_feature_view(df, column, target_col=target_col) # If None, return an empty figure with a readable title if fig is None: empty = go.Figure() empty.update_layout( title=f"No chart for {column}", xaxis_title="", yaxis_title="", ) return empty return fig @staticmethod def _column_profile(df: pd.DataFrame, column: str) -> dict: s = df[column] n = len(s) nunique = int(s.nunique(dropna=True)) missing = int(s.isna().sum()) unique_ratio = (nunique / n) if n > 0 else 0.0 # Basic dtype classification dtype_kind = "other" if is_datetime64_any_dtype(s): dtype_kind = "datetime" elif is_bool_dtype(s): dtype_kind = "boolean" elif is_numeric_dtype(s): dtype_kind = "numeric" elif is_string_dtype(s) or is_object_dtype(s): dtype_kind = "categorical" else: dtype_kind = "categorical" # Detect "ID-like" columns: # - high uniqueness ratio # - column name contains id-ish tokens name_lower = column.lower() looks_like_id_name = any(tok in name_lower for tok in ["id", "uuid", "guid", "key"]) looks_like_id = (unique_ratio >= 0.90 and nunique >= 50) or (looks_like_id_name and unique_ratio >= 0.70) # Numeric discreteness: numeric but small number of unique values numeric_discrete = dtype_kind == "numeric" and nunique <= 15 return { "n": n, "nunique": nunique, "missing": missing, "unique_ratio": unique_ratio, "dtype_kind": dtype_kind, "looks_like_id": looks_like_id, "numeric_discrete": numeric_discrete, } @staticmethod def auto_feature_view( df: pd.DataFrame, column: str, target_col: str = None, top_n: int = 20, ): """ Automatically choose the best visualization for a selected column. Returns: fig (plotly.graph_objects.Figure | None), meta (dict): view_type, reason, notes """ prof = Visualizer._column_profile(df, column) s = df[column] # 1) ID-like: no chart (best chart is no chart) if prof["looks_like_id"]: return None, { "view_type": "none", "reason": "This column looks like an identifier (almost all values are unique).", "notes": [ "Identifiers are useful for joining tables, not for statistical visualization.", f"Unique values: {prof['nunique']} out of {prof['n']} rows ({prof['unique_ratio']:.0%} unique).", "Suggestion: drop this column from training features, or keep only for reference.", ], } # 2) Datetime: time trend (counts over time) if prof["dtype_kind"] == "datetime": tmp = df[[column]].copy() tmp = tmp.dropna() tmp["count"] = 1 # group by day (simple default) tmp = tmp.set_index(column).resample("D")["count"].sum().reset_index() fig = px.line(tmp, x=column, y="count", markers=True, title=f"Events over time: {column}") return fig, { "view_type": "time_series", "reason": "Datetime columns are best shown as trends over time.", "notes": ["You can later add a resample selector (day/week/month)."], } # 3) Numeric discrete: bar chart (0/1, small integer sets) if prof["numeric_discrete"]: counts = s.value_counts(dropna=False).reset_index() counts.columns = [column, "count"] fig = px.bar(counts, x=column, y="count", title=f"Value counts: {column}") return fig, { "view_type": "bar_counts", "reason": "This numeric column has few unique values, so a bar chart is clearer than a histogram.", "notes": [f"Unique values: {prof['nunique']}."], } # 4) Numeric continuous: histogram (optionally overlay by target) if prof["dtype_kind"] == "numeric": fig = go.Figure() if target_col and target_col in df.columns: # overlay hist by class (good for churn / yes-no) for label in sorted(df[target_col].dropna().unique()): mask = df[target_col] == label fig.add_trace( go.Histogram( x=df.loc[mask, column], name=f"{target_col}={label}", opacity=0.6, ) ) fig.update_layout(barmode="overlay") reason = "Numeric feature shown as overlapping histograms split by target." else: fig.add_trace(go.Histogram(x=df[column], name=column)) reason = "Numeric feature shown as a histogram to reveal distribution shape." fig.update_layout( title=f"Distribution of {column}", xaxis_title=column, yaxis_title="Frequency", hovermode="x unified", ) return fig, {"view_type": "histogram", "reason": reason, "notes": []} # 5) Categorical: choose bar vs top-N # If too many categories, show top-N + 'Other' if prof["dtype_kind"] == "categorical": nunique = prof["nunique"] if nunique > top_n: vc = s.value_counts(dropna=False) top = vc.head(top_n) other_count = int(vc.iloc[top_n:].sum()) counts = top.reset_index() counts.columns = [column, "count"] if other_count > 0: counts = pd.concat([counts, pd.DataFrame([{column: "Other", "count": other_count}])], ignore_index=True) fig = px.bar(counts, x=column, y="count", title=f"Top {top_n} categories: {column}") return fig, { "view_type": "topn_bar", "reason": f"Too many categories ({nunique}). Showing top {top_n} + Other.", "notes": ["This prevents unreadable charts with hundreds of bars."], } # small categorical: grouped by target if available if target_col and target_col in df.columns and target_col != column: counts = df.groupby([column, target_col]).size().reset_index(name="count") fig = px.bar(counts, x=column, y="count", color=target_col, barmode="group", title=f"{column} by {target_col}") return fig, { "view_type": "grouped_bar", "reason": "Categorical feature grouped by target to compare outcomes.", "notes": [], } counts = s.value_counts(dropna=False).reset_index() counts.columns = [column, "count"] fig = px.bar(counts, x=column, y="count", title=f"Category counts: {column}") return fig, { "view_type": "bar", "reason": "Categorical feature with few categories shown as a bar chart.", "notes": [], } # Fallback return None, { "view_type": "none", "reason": "No suitable visualization rule matched this column type.", "notes": ["Try converting the column type or selecting a different feature."], } @staticmethod def plot_metrics_comparison(models_metrics: dict, metric_name: str = 'f1'): """Create bar chart comparing metrics across models.""" models = list(models_metrics.keys()) values = [models_metrics[m].get(metric_name, 0) for m in models] fig = go.Figure() fig.add_trace(go.Bar(x=models, y=values, text=[f'{v:.3f}' for v in values], textposition='auto')) fig.update_layout( title=f'Model Comparison - {metric_name.upper()}', xaxis_title='Model', yaxis_title=metric_name, showlegend=False, hovermode='x' ) return fig @staticmethod def plot_metrics_table(models_metrics: dict): """Create a table of all metrics.""" data = [] for model_name, metrics in models_metrics.items(): row = {'Model': model_name} row.update(metrics) data.append(row) df = pd.DataFrame(data) fig = go.Figure(data=[go.Table( header=dict(values=list(df.columns), fill_color='paleturquoise', align='left'), cells=dict(values=[df[col] for col in df.columns], fill_color='lavender', align='left') )]) fig.update_layout(title='Model Metrics Summary') return fig @staticmethod def plot_feature_importance_permutation(importance_dict: dict): """Create bar chart of permutation importance.""" features = list(importance_dict.keys()) values = list(importance_dict.values()) fig = go.Figure() fig.add_trace(go.Bar( x=features, y=values, text=[f'{v:.4f}' for v in values], textposition='auto', marker=dict(color=values, colorscale='Viridis') )) fig.update_layout( title='Feature Importance (Permutation)', xaxis_title='Feature', yaxis_title='Importance Score', hovermode='x', showlegend=False ) return fig @staticmethod def plot_sensitivity_analysis(sensitivity_df: pd.DataFrame, feature_name: str): """Create line plot for sensitivity analysis.""" fig = go.Figure() fig.add_trace(go.Scatter( x=sensitivity_df['feature_value'], y=sensitivity_df['prediction'], mode='lines+markers', name='Prediction', line=dict(color='#3498db', width=2), marker=dict(size=8) )) if 'confidence' in sensitivity_df.columns: fig.add_trace(go.Scatter( x=sensitivity_df['feature_value'], y=sensitivity_df['confidence'], mode='lines', name='Confidence', line=dict(color='#e74c3c', width=1, dash='dash') )) fig.update_layout( title=f'Sensitivity Analysis: {feature_name}', xaxis_title=feature_name, yaxis_title='Value', hovermode='x unified' ) return fig @staticmethod def plot_precision_recall_curve(pr_data: dict, current_threshold: float = 0.5): """ Create interactive precision-recall curve with threshold indicator. Args: pr_data: Dict with 'precision', 'recall', 'thresholds' lists current_threshold: Current decision threshold to highlight """ fig = go.Figure() # Add precision-recall curve fig.add_trace(go.Scatter( x=pr_data['recall'], y=pr_data['precision'], mode='lines', name='Precision-Recall Curve', line=dict(color='#3498db', width=3) )) # Add threshold markers fig.add_trace(go.Scatter( x=pr_data['recall'], y=pr_data['precision'], mode='markers', name='Thresholds', marker=dict( size=6, color=pr_data['thresholds'], colorscale='Viridis', showscale=True, colorbar=dict(title="Threshold") ), text=[f"Threshold: {t:.2f}" for t in pr_data['thresholds']], hovertemplate='%{text}
Recall: %{x:.3f}
Precision: %{y:.3f}' )) fig.update_layout( title=f'Precision-Recall Tradeoff (Current Threshold: {current_threshold:.2f})', xaxis_title='Recall', yaxis_title='Precision', hovermode='closest', height=500 ) return fig