mcqueenmater's picture
Deploy Fusion Lab: mobile-friendly UI + bug fixes
2b21f2d verified
Raw History Blame Contribute Delete
21.7 kB
"""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"""
<div class="fl-split-hero">
<div class="fl-split-left">
<div class="fl-stat-sub" style="margin-bottom: 10px; font-style: italic; opacity: 0.8;">Illustrative pattern β€” a composite, not a single verified call. See the Evidence Room tab for real, individual calls.</div>
<div class="fl-stat-label" style="margin-bottom: 16px; font-size: 14px; letter-spacing: 0.1em;">What They Said</div>
<div class="fl-serif-text">{ILLUSTRATIVE_QUOTE}</div>
<div class="fl-stat-sub fl-serif-text" style="margin-top: 16px; font-size: 14px; font-style: normal; color: var(--fl-text-secondary);">β€” {ILLUSTRATIVE_SPEAKER}</div>
</div>
<div class="fl-split-right fl-mono">
<div class="fl-stat-label" style="margin-bottom: 16px; font-size: 14px; letter-spacing: 0.1em; color: var(--fl-accent);">What We Heard</div>
<div style="color: var(--fl-accent); font-size: 16px; line-height: 1.6; margin-bottom: 16px;">
> BEHAVIORAL_SIGNAL_DETECTED<br>
> VOCAL_STRESS : HIGH<br>
> SPECIFICITY_DROP : DETECTED
</div>
<div class="fl-stat-label" style="margin-bottom: 8px;">Outcome (Market Reaction)</div>
<div class="fl-stat-value" style="color: var(--fl-negative);">{ILLUSTRATIVE_OUTCOME}</div>
</div>
</div>
""")
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"""
<div style="display:flex; align-items:center; gap:9px; padding:9px 14px 9px 9px;
border:1px solid {chip_border}; border-radius:8px; background:var(--fl-bg-void); {glow}">
<span style="display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px;
border-radius:50%; background:{badge_bg}; color:{badge_color}; font-weight:700; font-size:12px; flex-shrink:0;">{letter}</span>
<span style="color:var(--fl-text-primary); font-size:13px; font-weight:500; white-space:nowrap;">{label}</span>
</div>"""
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('<span style="color:var(--fl-text-secondary); font-size:16px;">&#8594;</span>')
definitions_html = "".join(
f"""<div style="padding:10px 0; border-bottom:1px solid var(--fl-border);">
<span style="color:var(--fl-text-primary); font-weight:600; font-size:13px;">{RUNG_LABELS[k]}</span>
<div style="color:var(--fl-text-secondary); font-size:13px; line-height:1.5; margin-top:3px;">{RUNG_DESCRIPTIONS[k]}</div>
</div>"""
for k in RUNG_LABELS
)
gr.HTML(f"""
<div class="fl-panel" style="padding: 32px; margin-bottom: 24px; border-left: 4px solid var(--fl-text-primary);">
<h2 style="margin-top: 0; color: var(--fl-text-primary); font-size: 26px; font-weight: 600; letter-spacing: -0.01em;">Fusion Lab</h2>
<p style="color: var(--fl-text-secondary); font-size: 15px; margin-bottom: 24px; max-width: 900px; line-height: 1.6;">
<strong>Problem:</strong> Does adding vocal-delivery signal to a text sentiment model
actually improve 3-class sentiment classification of earnings calls, or is text alone enough?
</p>
<div style="font-size: 14px; margin-top: 16px;">
<strong style="color: var(--fl-text-primary); text-transform: uppercase; font-size: 12px; letter-spacing: 0.04em;">Models Compared (5 Rungs)</strong>
<div style="margin-top: 16px;">
<div style="color:var(--fl-text-secondary); font-size:11px; text-transform:uppercase; letter-spacing:0.05em; margin-bottom:8px;">
Floors β€” two unimodal baselines, evaluated independently of each other
</div>
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
{floor_chips}
</div>
</div>
<div style="margin-top: 18px;">
<div style="color:var(--fl-text-secondary); font-size:11px; text-transform:uppercase; letter-spacing:0.05em; margin-bottom:8px;">
Fusion model β€” the same neural architecture, in three configurations
</div>
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
{"".join(fusion_chips)}
</div>
</div>
<p style="color: var(--fl-text-secondary); font-size: 12.5px; margin-top: 14px; font-style: italic;">
A and B are separate sanity checks (how good is one modality alone, with a simple classical
model?) β€” not a step toward C. C&#8594;D&#8594;E is the real progression: same fusion
architecture, increasingly sophisticated about how it combines text and audio.
</p>
<div style="margin-top: 20px;">
{definitions_html}
</div>
</div>
</div>
""")
with gr.Row():
gr.HTML(
f'<div class="fl-stat-block"><div class="fl-stat-label">Gated Fusion (winner)</div>'
f'<div class="fl-stat-value fl-accent">{e:.3f}</div>'
f'<div class="fl-stat-sub">macro-F1, 5-fold walk-forward, n=1,038 calls</div></div>'
)
gr.HTML(
f'<div class="fl-stat-block"><div class="fl-stat-label">Text-only baseline</div>'
f'<div class="fl-stat-value">{c:.3f}</div>'
f'<div class="fl-stat-sub">FinBERT + attention pooling, same folds</div></div>'
)
gr.HTML(
f'<div class="fl-stat-block"><div class="fl-stat-label">Delta vs. text-only</div>'
f'<div class="fl-stat-value fl-accent">+{diff["macro_f1_diff"]:.3f}</div>'
f'<div class="fl-stat-sub">95% CI [{diff["ci_low"]:.3f}, {diff["ci_high"]:.3f}] '
f'&nbsp;<span class="fl-badge {badge_class}">{badge_text}</span></div></div>'
)
gr.HTML("""
<div class="fl-panel" style="padding: 16px; margin-top: 16px; margin-bottom: 32px; border-left: 4px solid var(--fl-accent);">
<p style="margin: 0; color: var(--fl-text-primary); font-size: 14.5px; line-height: 1.6;">
Multimodal fusion beats text-only by <strong>+5.1 points</strong> of macro-F1, a gap whose
95% confidence interval excludes zero. The same architecture does <strong>not</strong> beat
text-only on the volatility-regime target β€” see the negative result further down this page,
shown with equal weight, not hidden.
</p>
</div>
""")
gr.HTML('<div class="fl-section-header" style="font-size: 14px; color: var(--fl-accent);">Why multimodal</div>')
with gr.Row():
with gr.Column(scale=2):
gr.HTML("""
<div class="fl-panel" style="padding: 20px; height: 100%;">
<p style="color: var(--fl-text-secondary); margin-top: 0; line-height: 1.6;">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 <em>gap</em> between them, plus a fourth signal about how the call itself unfolded. Four feature groups feed the model:</p>
<ul style="color: var(--fl-text-primary); font-size: 14px; line-height: 1.6; padding-left: 20px;">
<li style="margin-bottom: 8px;"><strong>Text</strong> (per turn) β€” the 768-d FinBERT embedding plus 6 handcrafted scores (sentiment, uncertainty, forward-looking language, hedging, specificity, linguistic complexity).</li>
<li style="margin-bottom: 8px;"><strong>Audio</strong> (per turn) β€” 5 prosody features (pitch level and variance, energy variance, speech rate, voice stability).</li>
<li style="margin-bottom: 8px;"><strong>Interaction</strong> (per turn) β€” 5 <em>divergence</em> 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.</li>
<li><strong>QA pressure</strong> (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 <em>after</em> attention pooling.</li>
</ul>
</div>
""")
with gr.Column(scale=1):
gr.HTML("""
<div class="fl-panel" style="padding: 20px; background: rgba(255, 87, 34, 0.05); height: 100%;">
<h4 style="margin-top: 0; color: var(--fl-accent);">A Learned Gate</h4>
<p style="color: var(--fl-text-secondary); font-size: 13.5px; line-height: 1.6; margin-bottom: 0;">
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.
</p>
</div>
""")
gr.HTML('<div style="margin: 32px 0;">' + build_architecture_diagram() + '</div>')
with gr.Row():
with gr.Column(scale=1):
gr.HTML(f"""
<div class="fl-panel" style="padding: 20px;">
<h3 style="margin-top: 0; color: var(--fl-text-primary);">Methodology</h3>
<ul style="color: var(--fl-text-secondary); font-size: 13px; line-height: 1.6; padding-left: 16px; margin-bottom: 0;">
<li><strong>Task:</strong> {m['task']}</li>
<li><strong>Population:</strong> {m['n_calls']} calls, {m['date_range'][0]} to {m['date_range'][1]}</li>
<li><strong>Filter:</strong> <code>{m['population_filter']}</code></li>
<li><strong>CV:</strong> {m['cv']['method']}, {m['cv']['n_splits']} expanding-window folds (min train frac {m['cv']['min_train_frac']})</li>
<li><strong>OOF coverage:</strong> {m['oof_coverage_pct']}% of calls fall in a test fold</li>
<li><strong>Max turns:</strong> {m['max_turns']} ({m['n_truncated_calls']} truncated)</li>
</ul>
<div class="fl-caveat" style="margin-top: 16px; border-radius: 4px;">{m["label_caveat"]}</div>
</div>
""")
with gr.Column(scale=1):
gr.HTML("""
<div class="fl-panel" style="padding: 20px;">
<h3 style="margin-top: 0; color: var(--fl-text-primary);">Architecture Details</h3>
<div style="font-size: 13px; line-height: 1.6; color: var(--fl-text-secondary);">
<p><strong>Gated fusion</strong> 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).</p>
<p><strong>Attention pooling</strong> is additive (Bahdanau-style): a small network scores every turn, masks out padding before the softmax, and produces the per-turn weights.</p>
<p style="margin-bottom: 0;"><strong>QA pressure</strong> β€” 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) <em>after</em> pooling, bypassing attention entirely.</p>
</div>
</div>
""")
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('<div class="fl-section-header" style="font-size: 14px; color: var(--fl-text-primary);">Volatility β€” an honest negative result</div>')
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"""
<div class="fl-panel" style="padding: 20px; border-left: 4px solid var(--fl-negative); height: 100%;">
<h4 style="margin-top: 0; color: var(--fl-negative);">Verdict</h4>
<p style="color: var(--fl-text-secondary); font-size: 14px;">{VOLATILITY_LADDER['verdict']}</p>
</div>
""")
else:
gr.Markdown("*Volatility ladder data not found in space/data/.*")
gr.HTML("""
<div class="fl-panel" style="padding: 20px; margin-top: 24px;">
<h4 style="margin-top: 0; color: var(--fl-text-primary);">Glossary</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; font-size: 13px; color: var(--fl-text-secondary);">
<div><strong style="color: var(--fl-text-primary);">Rung</strong> β€” one of the 5 model configurations (A–E) in the benchmark ladder; see Models Compared above.</div>
<div><strong style="color: var(--fl-text-primary);">Macro-F1</strong> β€” F1 averaged equally across the 3 classes, insensitive to class imbalance.</div>
<div><strong style="color: var(--fl-text-primary);">Walk-forward CV</strong> β€” expanding-window, time-ordered folds; no future call ever trains a model evaluated on an earlier one.</div>
<div><strong style="color: var(--fl-text-primary);">Bootstrap CI</strong> β€” 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.</div>
<div><strong style="color: var(--fl-text-primary);">Gated / Concat fusion</strong> β€” how branches combine before classification; gated learns a per-turn weight, concat simply concatenates.</div>
<div><strong style="color: var(--fl-text-primary);">Divergence</strong> β€” a per-turn signal measuring mismatch between text sentiment and vocal delivery (e.g. upbeat words spoken in a strained voice).</div>
<div><strong style="color: var(--fl-text-primary);">QA pressure</strong> β€” 7 call-level features contrasting the prepared-remarks section against the Q&A session; added after attention pooling, never shown as a highlighted turn.</div>
<div><strong style="color: var(--fl-text-primary);">OOF coverage</strong> β€” 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.</div>
<div><strong style="color: var(--fl-text-primary);">Attention entropy</strong> β€” normalized Shannon entropy of a call's attention weights; 0 = concentrated on one turn, 1 = spread uniformly.</div>
</div>
</div>
""")
gr.HTML("""
<div class="fl-panel" style="padding: 20px; margin-top: 24px;">
<h4 style="margin-top: 0; color: var(--fl-text-primary);">Explore further</h4>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; font-size: 13px;">
<div>
<strong style="color: var(--fl-accent);">Evidence Room</strong>
<p style="color: var(--fl-text-secondary); margin: 4px 0 0 0; line-height: 1.5;">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.</p>
</div>
<div>
<strong style="color: var(--fl-accent);">The Scorecard</strong>
<p style="color: var(--fl-text-secondary); margin: 4px 0 0 0; line-height: 1.5;">The full 5-rung benchmark ladder for both targets, plus every pairwise significance test behind the claims made on this page.</p>
</div>
<div>
<strong style="color: var(--fl-accent);">Corporate Deep Dives</strong>
<p style="color: var(--fl-text-secondary); margin: 4px 0 0 0; line-height: 1.5;">How one company's behavioral signals β€” confidence, stress, specificity β€” evolved across its calls over time.</p>
</div>
</div>
</div>
""")
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)