"""Fusion Lab — a model-credibility explorer for the turn-level late-fusion sentiment model. Reads static JSON exported by src/evaluation/export_dashboard_data.py (copied into space/data/ by sync_data.sh); no live inference, no backend dependency.""" import gradio as gr from charts import ( RUNG_LABELS, benchmark_bars, calibration_reliability, delta_bars, fold_timeline, ) from company import build_company_tab from data_loader import load_calls_index, load_company_index, load_methodology, load_sentiment_ladder from diagram import build_architecture_diagram from header import build_header_ribbon from theme import CUSTOM_CSS, build_theme from workspace import build_workspace_tab THEME = build_theme() SENTIMENT_LADDER = load_sentiment_ladder() METHODOLOGY = load_methodology() CALLS_DF = load_calls_index() COMPANY_INDEX_DF = load_company_index() try: import json from pathlib import Path with open(Path(__file__).parent / "data" / "volatility_ladder.json") as f: VOLATILITY_LADDER = json.load(f) except FileNotFoundError: VOLATILITY_LADDER = None LADDERS = {"Sentiment": SENTIMENT_LADDER, "Volatility": VOLATILITY_LADDER} def build_benchmarks_tab(): target_radio = gr.Radio(["Sentiment", "Volatility"], value="Sentiment", label="Target") metric_radio = gr.Radio(["macro_f1", "weighted_f1"], value="macro_f1", label="Metric") with gr.Row(): bars_plot = gr.Plot(value=benchmark_bars(SENTIMENT_LADDER, "macro_f1")) delta_plot = gr.Plot(value=delta_bars(SENTIMENT_LADDER)) def _update(target, metric): ladder = LADDERS[target] if ladder is None: return None, None return benchmark_bars(ladder, metric), delta_bars(ladder) gr.on( triggers=[target_radio.change, metric_radio.change], fn=_update, inputs=[target_radio, metric_radio], outputs=[bars_plot, delta_plot], ) return target_radio, metric_radio, bars_plot, delta_plot ILLUSTRATIVE_QUOTE = "“We remain confident in our outlook for the back half of the year.”" ILLUSTRATIVE_SPEAKER = "CEO, prepared remarks" ILLUSTRATIVE_PREDICTION = "Bearish" ILLUSTRATIVE_OUTCOME = "−4.2%" ILLUSTRATIVE_REVEAL = ( "The words were confident. The behavioral signal wasn't: elevated vocal stress " "and a sharp drop in specificity on the very same turn. The answer wasn't in " "the words — it was in how they were spoken." ) def build_thesis_tab(): m = METHODOLOGY ladder = SENTIMENT_LADDER e = ladder["rungs"]["E_gated_fusion"]["macro_f1"]["mean"] c = ladder["rungs"]["C_finbert_attn_text"]["macro_f1"]["mean"] diff = ladder["comparisons"]["E_vs_C"] significant = diff["ci_low"] > 0 badge_class = "fl-badge-significant" if significant else "fl-badge-not-significant" badge_text = "Statistically Significant" if significant else "Not Significant" gr.HTML(f"""
Illustrative pattern — a composite, not a single verified call. See the Evidence Room tab for real, individual calls.
What They Said
{ILLUSTRATIVE_QUOTE}
— {ILLUSTRATIVE_SPEAKER}
What We Heard
> BEHAVIORAL_SIGNAL_DETECTED
> VOCAL_STRESS : HIGH
> SPECIFICITY_DROP : DETECTED
Outcome (Market Reaction)
{ILLUSTRATIVE_OUTCOME}
""") RUNG_DESCRIPTIONS = { "A_tfidf_logreg": "Classic bag-of-words baseline — TF-IDF vectors of the transcript into logistic regression. No embeddings, no audio; the floor for text.", "B_audio_only_tabular": "Prosody only — pitch, energy, speech rate, and voice stability aggregated per call into LightGBM. No text at all; the floor for audio.", "C_finbert_attn_text": "Frozen FinBERT turn embeddings + attention pooling across the call. Strong text-only baseline — everything rung E gets from text.", "D_concat_fusion": "Text and audio branches literally concatenated before classification — both modalities present, but no learned interaction between them.", "E_gated_fusion": "Text and audio combined via a learned per-turn gate (not a fixed weighting), plus a text-audio divergence signal and call-level QA-pressure features (prepared-remarks-vs-Q&A contrast) — the flagship model.", } def _rung_chip(k: str) -> str: letter, label = RUNG_LABELS[k].split(" · ", 1) is_winner = k == "E_gated_fusion" badge_bg = "var(--fl-accent)" if is_winner else "var(--fl-border)" badge_color = "var(--fl-bg-void)" if is_winner else "var(--fl-text-primary)" chip_border = "var(--fl-accent)" if is_winner else "var(--fl-border)" glow = "box-shadow: 0 0 10px rgba(255, 87, 34, 0.25);" if is_winner else "" return f"""
{letter} {label}
""" floor_keys = ["A_tfidf_logreg", "B_audio_only_tabular"] floor_chips = "".join(_rung_chip(k) for k in floor_keys) fusion_keys = ["C_finbert_attn_text", "D_concat_fusion", "E_gated_fusion"] fusion_chips = [] for i, k in enumerate(fusion_keys): fusion_chips.append(_rung_chip(k)) if i < len(fusion_keys) - 1: fusion_chips.append('→') definitions_html = "".join( f"""
{RUNG_LABELS[k]}
{RUNG_DESCRIPTIONS[k]}
""" for k in RUNG_LABELS ) gr.HTML(f"""

Fusion Lab

Problem: Does adding vocal-delivery signal to a text sentiment model actually improve 3-class sentiment classification of earnings calls, or is text alone enough?

Models Compared (5 Rungs)
Floors — two unimodal baselines, evaluated independently of each other
{floor_chips}
Fusion model — the same neural architecture, in three configurations
{"".join(fusion_chips)}

A and B are separate sanity checks (how good is one modality alone, with a simple classical model?) — not a step toward C. C→D→E is the real progression: same fusion architecture, increasingly sophisticated about how it combines text and audio.

{definitions_html}
""") with gr.Row(): gr.HTML( f'
Gated Fusion (winner)
' f'
{e:.3f}
' f'
macro-F1, 5-fold walk-forward, n=1,038 calls
' ) gr.HTML( f'
Text-only baseline
' f'
{c:.3f}
' f'
FinBERT + attention pooling, same folds
' ) gr.HTML( f'
Delta vs. text-only
' f'
+{diff["macro_f1_diff"]:.3f}
' f'
95% CI [{diff["ci_low"]:.3f}, {diff["ci_high"]:.3f}] ' f' {badge_text}
' ) gr.HTML("""

Multimodal fusion beats text-only by +5.1 points of macro-F1, a gap whose 95% confidence interval excludes zero. The same architecture does not beat text-only on the volatility-regime target — see the negative result further down this page, shown with equal weight, not hidden.

""") gr.HTML('
Why multimodal
') with gr.Row(): with gr.Column(scale=2): gr.HTML("""

The model doesn't just read the transcript and listen to the audio side by side — it also builds a third signal directly from the gap between them, plus a fourth signal about how the call itself unfolded. Four feature groups feed the model:

""") with gr.Column(scale=1): gr.HTML("""

A Learned Gate

Combines text and audio per turn (not a fixed weighting), speaker-role and call-section identity are added before pooling. An attention mechanism decides which turns matter most for the final call-level prediction — see the diagram below.

""") gr.HTML('
' + build_architecture_diagram() + '
') with gr.Row(): with gr.Column(scale=1): gr.HTML(f"""

Methodology

{m["label_caveat"]}
""") with gr.Column(scale=1): gr.HTML("""

Architecture Details

Gated fusion learns a per-turn sigmoid gate over the text/audio projections — a convex combination, not literal concatenation (that's the weaker Concat-fusion rung D) — concatenated with the interaction branch (144-d), then with 16-d learned speaker-role and 16-d segment-type embeddings (176-d).

Attention pooling is additive (Bahdanau-style): a small network scores every turn, masks out padding before the softmax, and produces the per-turn weights.

QA pressure — 7 call-level features contrasting prepared remarks against the Q&A session — is concatenated directly onto the pooled 176-d call vector (183-d total) after pooling, bypassing attention entirely.

""") with gr.Row(): with gr.Column(): gr.Plot(fold_timeline(m["n_calls"], m["cv"]["n_splits"], m["cv"]["min_train_frac"])) with gr.Column(): gr.Plot(calibration_reliability(CALLS_DF)) gr.HTML('
Volatility — an honest negative result
') if VOLATILITY_LADDER is not None: with gr.Row(): with gr.Column(scale=2): gr.Plot(benchmark_bars(VOLATILITY_LADDER, "macro_f1")) with gr.Column(scale=1): gr.HTML(f"""

Verdict

{VOLATILITY_LADDER['verdict']}

""") else: gr.Markdown("*Volatility ladder data not found in space/data/.*") gr.HTML("""

Glossary

Rung — one of the 5 model configurations (A–E) in the benchmark ladder; see Models Compared above.
Macro-F1 — F1 averaged equally across the 3 classes, insensitive to class imbalance.
Walk-forward CV — expanding-window, time-ordered folds; no future call ever trains a model evaluated on an earlier one.
Bootstrap CI — a 95% confidence interval on a metric or a metric's difference, estimated by resampling; used throughout the Scorecard tab to judge whether a gap is real or noise.
Gated / Concat fusion — how branches combine before classification; gated learns a per-turn weight, concat simply concatenates.
Divergence — a per-turn signal measuring mismatch between text sentiment and vocal delivery (e.g. upbeat words spoken in a strained voice).
QA pressure — 7 call-level features contrasting the prepared-remarks section against the Q&A session; added after attention pooling, never shown as a highlighted turn.
OOF coverage — the share of calls that land in a walk-forward test fold at some point; the earliest calls are train-only and never get an out-of-fold prediction.
Attention entropy — normalized Shannon entropy of a call's attention weights; 0 = concentrated on one turn, 1 = spread uniformly.
""") gr.HTML("""

Explore further

Evidence Room

See whether the model called it right on a real earnings call, then explore exactly where in the call it caught the dissonance between words and tone.

The Scorecard

The full 5-rung benchmark ladder for both targets, plus every pairwise significance test behind the claims made on this page.

Corporate Deep Dives

How one company's behavioral signals — confidence, stress, specificity — evolved across its calls over time.

""") def build_app() -> gr.Blocks: with gr.Blocks(title="Fusion Lab") as demo: gr.HTML(build_header_ribbon(SENTIMENT_LADDER)) with gr.Tabs() as tabs: with gr.Tab("The Thesis", id="overview"): build_thesis_tab() with gr.Tab("Evidence Room", id="workspace"): build_workspace_tab(CALLS_DF, demo) with gr.Tab("The Scorecard", id="benchmarks"): build_benchmarks_tab() with gr.Tab("Corporate Deep Dives", id="company"): build_company_tab(COMPANY_INDEX_DF) return demo demo = build_app() if __name__ == "__main__": demo.launch(theme=THEME, css=CUSTOM_CSS)