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;">&#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)