"""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('')
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:
- Text (per turn) — the 768-d FinBERT embedding plus 6 handcrafted scores (sentiment, uncertainty, forward-looking language, hedging, specificity, linguistic complexity).
- Audio (per turn) — 5 prosody features (pitch level and variance, energy variance, speech rate, voice stability).
- Interaction (per turn) — 5 divergence features that measure text-audio mismatch directly: does upbeat language arrive in a flat, unstable voice? These are the "pressure cracks" between what's said and how it sounds — signal a text-only or audio-only model structurally cannot see.
- QA pressure (per call) — 7 features contrasting the prepared-remarks section against the Q&A session (sentiment drop, uncertainty spike, hedging ratio, and more). Unlike the other three, this is computed once per call, not per turn, and is added in after attention pooling.
""")
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
- Task: {m['task']}
- Population: {m['n_calls']} calls, {m['date_range'][0]} to {m['date_range'][1]}
- Filter:
{m['population_filter']}
- CV: {m['cv']['method']}, {m['cv']['n_splits']} expanding-window folds (min train frac {m['cv']['min_train_frac']})
- OOF coverage: {m['oof_coverage_pct']}% of calls fall in a test fold
- Max turns: {m['max_turns']} ({m['n_truncated_calls']} truncated)
{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('')
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)