File size: 21,704 Bytes
2b21f2d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 | """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;">→</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→D→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' <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)
|