Download app.py from mcqueenmater/interaction-intelligence: direct link, hf CLI and curl.
- Browser
- Download file 21.7 kB
-
https://huggingface.co/spaces/mcqueenmater/interaction-intelligence/resolve/main/app.py
- Command line
-
hf download hf://spaces/mcqueenmater/interaction-intelligence/app.py
-
curl -L -o app.py https://huggingface.co/spaces/mcqueenmater/interaction-intelligence/resolve/main/app.py
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;">→</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) | |