"""Plotly figure builders for Fusion Lab. Every figure shares the dark token palette from theme.py so charts sit visually flush with the surrounding Gradio surfaces.""" import numpy as np import pandas as pd import plotly.graph_objects as go from plotly.subplots import make_subplots from theme import ( ACCENT, BG_SURFACE, BORDER_HAIRLINE, FONT_MONO, FONT_UI, SIG_NEGATIVE, SIG_NEUTRAL, SIG_NOT_SIGNIFICANT, SIG_POSITIVE, ) RUNG_LABELS = { "A_tfidf_logreg": "A · TF-IDF + LogReg", "B_audio_only_tabular": "B · Audio-only (LightGBM)", "C_finbert_attn_text": "C · FinBERT text-only", "D_concat_fusion": "D · Concat fusion", "E_gated_fusion": "E · Gated fusion", } RUNG_ORDER = list(RUNG_LABELS.keys()) def _base_layout(fig: go.Figure, *, height: int = 380, title: str | None = None) -> go.Figure: fig.update_layout( height=height, paper_bgcolor=BG_SURFACE, plot_bgcolor=BG_SURFACE, font=dict(family=FONT_UI, color="#8A97A8", size=12), title=dict(text=title, font=dict(family=FONT_UI, color="#E6EDF3", size=14)) if title else None, margin=dict(l=50, r=20, t=44 if title else 16, b=40), legend=dict(bgcolor="rgba(0,0,0,0)", bordercolor=BORDER_HAIRLINE, borderwidth=0), ) fig.update_xaxes(gridcolor=BORDER_HAIRLINE, zerolinecolor=BORDER_HAIRLINE, linecolor=BORDER_HAIRLINE) fig.update_yaxes(gridcolor=BORDER_HAIRLINE, zerolinecolor=BORDER_HAIRLINE, linecolor=BORDER_HAIRLINE) return fig def benchmark_bars(ladder: dict, metric: str = "macro_f1") -> go.Figure: """Grouped bar chart: one bar per rung, CI whiskers, best-scoring rung in accent.""" rungs = ladder["rungs"] present = [r for r in RUNG_ORDER if r in rungs] names = [RUNG_LABELS[r] for r in present] means = [rungs[r][metric]["mean"] for r in present] err_hi = [rungs[r][metric]["ci_high"] - rungs[r][metric]["mean"] for r in present] err_lo = [rungs[r][metric]["mean"] - rungs[r][metric]["ci_low"] for r in present] best_rung = present[means.index(max(means))] colors = [ACCENT if r == best_rung else "#3A4656" for r in present] fig = go.Figure( go.Bar( x=names, y=means, error_y=dict(type="data", symmetric=False, array=err_hi, arrayminus=err_lo, color="#8A97A8"), marker_color=colors, marker_line_width=0, text=[f"{m:.3f}" for m in means], textposition="outside", textfont=dict(family=FONT_MONO, color="#E6EDF3", size=12), ) ) fig.update_yaxes(title=metric.replace("_", " ").title(), range=[0, 1.05]) return _base_layout(fig, title=f"{ladder['target'].title()} — {metric.replace('_', ' ').title()} by rung") def delta_bars(ladder: dict) -> go.Figure: """Pairwise comparison bars centered at zero; colored by whether the CI excludes zero.""" comparisons = ladder["comparisons"] keys = list(comparisons.keys()) labels = [f"{c['a'].split('_')[0]} vs {c['b'].split('_')[0]}" for c in comparisons.values()] diffs = [c["macro_f1_diff"] for c in comparisons.values()] err_hi = [c["ci_high"] - c["macro_f1_diff"] for c in comparisons.values()] err_lo = [c["macro_f1_diff"] - c["ci_low"] for c in comparisons.values()] significant = [c["ci_low"] > 0 or c["ci_high"] < 0 for c in comparisons.values()] colors = [ACCENT if s else SIG_NOT_SIGNIFICANT for s in significant] fig = go.Figure( go.Bar( x=labels, y=diffs, error_y=dict(type="data", symmetric=False, array=err_hi, arrayminus=err_lo, color="#8A97A8"), marker_color=colors, marker_line_width=0, ) ) fig.add_hline(y=0, line_color=BORDER_HAIRLINE, line_width=1) fig.update_yaxes(title="Macro-F1 difference") return _base_layout(fig, title=f"{ladder['target'].title()} — pairwise deltas (95% CI)") def calibration_reliability(calls_df: pd.DataFrame, n_bins: int = 5) -> go.Figure: """Reliability diagram: bucket covered calls by confidence, plot observed agreement rate per bin against the perfect-calibration diagonal.""" df = calls_df[calls_df["oof_covered"]].copy() df["bin"] = pd.cut(df["confidence"], bins=np.linspace(1 / 3, 1.0, n_bins + 1), include_lowest=True) grouped = df.groupby("bin", observed=True).agg( mean_confidence=("confidence", "mean"), agreement_rate=("correct", "mean"), n=("correct", "size") ) fig = go.Figure() fig.add_trace( go.Scatter( x=[1 / 3, 1.0], y=[1 / 3, 1.0], mode="lines", line=dict(color=BORDER_HAIRLINE, dash="dash"), name="Perfect calibration", ) ) fig.add_trace( go.Scatter( x=grouped["mean_confidence"], y=grouped["agreement_rate"], mode="lines+markers", line=dict(color=ACCENT, width=2), marker=dict(size=grouped["n"] / grouped["n"].max() * 20 + 6), name="Observed", hovertemplate="confidence=%{x:.2f}
agreement=%{y:.2f}", ) ) fig.update_xaxes(title="Mean predicted confidence", range=[0.3, 1.02]) fig.update_yaxes(title="Observed agreement rate", range=[0.3, 1.02]) return _base_layout(fig, height=380, title="Confidence calibration (reliability diagram)") def fold_timeline(n_calls: int, n_splits: int, min_train_frac: float = 0.35) -> go.Figure: """Horizontal timeline showing each walk-forward fold's train/test span over the chronologically-ordered call population.""" boundaries = np.linspace(int(n_calls * min_train_frac), n_calls, n_splits + 1).astype(int) fig = go.Figure() for i in range(n_splits): train_end = boundaries[i] test_end = boundaries[i + 1] fig.add_trace( go.Bar(y=[f"Fold {i}"], x=[train_end], orientation="h", marker_color="#3A4656", name="train", showlegend=i == 0) ) fig.add_trace( go.Bar( y=[f"Fold {i}"], x=[test_end - train_end], base=train_end, orientation="h", marker_color=ACCENT, name="test", showlegend=i == 0, ) ) fig.update_layout(barmode="stack") fig.update_xaxes(title="Call index (chronological)") return _base_layout(fig, height=260, title="Walk-forward expanding-window folds") COMPANY_TREND_COLORS = { "behavioral_consistency": ACCENT, "avg_confidence": SIG_POSITIVE, "avg_specificity": SIG_NEUTRAL, } COMPANY_TREND_LABELS = { "behavioral_consistency": "Behavioral Consistency", "avg_stress": "Stress", "avg_confidence": "Confidence", "avg_specificity": "Specificity", } def company_trend_lines(calls: list[dict], dims: list[str], company_label: str) -> go.Figure: """Two-panel view of a ticker's behavioral dimensions across its calls, chronologically: a line chart of the "positive" dimensions on top, and a Stress bar chart underneath colored by that call's risk_tier with n_risk_events annotated — so a viewer sees both the trend AND which quarters actually carried flagged friction, not just an average. `calls` is companies.json's per-ticker `calls` list (already sorted by call_date); `dims` are the line-chart dimensions to plot (e.g. behavioral_consistency, avg_confidence, avg_specificity — avg_stress is always drawn in the bottom panel instead).""" from product_language import risk_tier_color dates = [c["call_date"] for c in calls] fig = make_subplots( rows=2, cols=1, shared_xaxes=True, row_heights=[0.62, 0.38], vertical_spacing=0.08, # top panel's own legend already names its three lines; a subplot title here # would just repeat that and risks clipping at narrow (mobile) plot widths subplot_titles=("", "Stress, by risk tier"), ) for dim in dims: values = [c.get(dim) for c in calls] if all(v is None for v in values): continue fig.add_trace( go.Scatter( x=dates, y=values, mode="lines+markers", name=COMPANY_TREND_LABELS.get(dim, dim), line=dict(color=COMPANY_TREND_COLORS.get(dim, ACCENT), width=2), marker=dict(size=7), connectgaps=False, ), row=1, col=1, ) stress_vals = [c.get("avg_stress") for c in calls] bar_colors = [risk_tier_color(c.get("risk_tier")) for c in calls] hover = [ f"{c['call_id']}
Stress: {c.get('avg_stress'):.2f}
Risk events: {c.get('n_risk_events', 0)}" if c.get("avg_stress") is not None else c["call_id"] for c in calls ] fig.add_trace( go.Bar( x=dates, y=stress_vals, marker_color=bar_colors, marker_line_width=0, text=[str(c.get("n_risk_events", 0)) or "" for c in calls], textposition="outside", textfont=dict(family=FONT_MONO, color="#8A97A8", size=10), hovertext=hover, hoverinfo="text", showlegend=False, ), row=2, col=1, ) fig.update_yaxes(title="Score", range=[0, 1.05], row=1, col=1) fig.update_yaxes(title="Stress", range=[0, 1.05], row=2, col=1) fig.update_xaxes(title="Call date", row=2, col=1) fig = _base_layout(fig, height=520, title=f"{company_label} — behavioral dimensions") fig.update_annotations(font=dict(family=FONT_UI, color="#8A97A8", size=11)) return fig def attention_entropy_gauge(entropy: float) -> go.Figure: """Small horizontal gauge: 0 = concentrated on one turn, 1 = diffuse/uniform.""" fig = go.Figure( go.Bar(y=["entropy"], x=[entropy], orientation="h", marker_color=ACCENT, marker_line_width=0) ) fig.add_vline(x=entropy, line_color=ACCENT, line_width=0) fig.update_xaxes(range=[0, 1], visible=True, title="Focused ← → Diffuse") fig.update_yaxes(visible=False) return _base_layout(fig, height=90)