Add cross_attention_collapse_proof.md (4,316 bytes)
Browse files
cross_attention_collapse_proof.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cross-Attention Collapse β Mathematical Proof
|
| 2 |
+
|
| 3 |
+
## The buggy code (pipeline.py:1119-1124)
|
| 4 |
+
|
| 5 |
+
```python
|
| 6 |
+
def forward(self, input_ids, attention_mask, chart_window):
|
| 7 |
+
t_out = self.text_model(input_ids=input_ids, attention_mask=attention_mask)
|
| 8 |
+
t_pool = t_out.last_hidden_state[:,0] # (B, D) - text CLS pool
|
| 9 |
+
c_pool = self.chart_enc(chart_window) # (B, D) - chart pool
|
| 10 |
+
fused, _ = self.fuse(t_pool.unsqueeze(1), # query (B, 1, D)
|
| 11 |
+
c_pool.unsqueeze(1), # key (B, 1, D)
|
| 12 |
+
c_pool.unsqueeze(1)) # value (B, 1, D)
|
| 13 |
+
f = fused.squeeze(1) # (B, D)
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
`self.fuse` is `nn.MultiheadAttention(text_dim, num_heads=8, batch_first=True)`.
|
| 17 |
+
|
| 18 |
+
## Why this collapses
|
| 19 |
+
|
| 20 |
+
Standard MHA forward, with Q, K, V each shaped `(B, T, D)`:
|
| 21 |
+
|
| 22 |
+
1. Linear projection split into H heads:
|
| 23 |
+
- `Q' = Q @ W_q`, `K' = K @ W_k`, `V' = V @ W_v` β reshape to `(B, H, T, d_head)`
|
| 24 |
+
2. Scaled-dot scores: `S = Q' @ K'^T / sqrt(d_head)` β `(B, H, T_q, T_k)`
|
| 25 |
+
3. Attention weights: `A = softmax(S, dim=-1)` β `(B, H, T_q, T_k)`
|
| 26 |
+
4. Weighted values: `O = A @ V'` β `(B, H, T_q, d_head)` β concat to `(B, T_q, D)`
|
| 27 |
+
5. Output: `Y = O @ W_o + b_o`
|
| 28 |
+
|
| 29 |
+
In our call, `T_q = T_k = T_v = 1`. Hence:
|
| 30 |
+
|
| 31 |
+
- `S` has shape `(B, H, 1, 1)` β a single scalar per head, per batch.
|
| 32 |
+
- `softmax` over a 1-element vector is identically `1.0`. So `A` is identically 1.
|
| 33 |
+
- `O = A @ V' = V'` exactly (per head).
|
| 34 |
+
- `Y = V' @ W_o + b_o`.
|
| 35 |
+
|
| 36 |
+
Plug in: `V' = V @ W_v = (c_pool.unsqueeze(1)) @ W_v`. Squeeze:
|
| 37 |
+
|
| 38 |
+
> `f = c_pool @ W_v @ W_o + b_o`
|
| 39 |
+
|
| 40 |
+
**The text query `t_pool` and key `t_pool` never enter `f`.** `W_q`, `W_k`, and the
|
| 41 |
+
text encoder's entire 336M parameters are mathematically irrelevant to the
|
| 42 |
+
forward pass output.
|
| 43 |
+
|
| 44 |
+
## Independent sanity check
|
| 45 |
+
|
| 46 |
+
You can verify by toggling the text input β any input that produces the same
|
| 47 |
+
`c_pool` will produce the same `f`. Concretely:
|
| 48 |
+
|
| 49 |
+
```python
|
| 50 |
+
m.train(False) # disable dropout/batchnorm side-effects
|
| 51 |
+
text_a = tokenizer("Apple beats Q3 earnings", return_tensors='pt', padding='max_length', max_length=128)
|
| 52 |
+
text_b = tokenizer("Bitcoin crashes 40%", return_tensors='pt', padding='max_length', max_length=128)
|
| 53 |
+
chart = torch.randn(1, 128, 5) # same chart for both
|
| 54 |
+
|
| 55 |
+
out_a = m(text_a.input_ids, text_a.attention_mask, chart)
|
| 56 |
+
out_b = m(text_b.input_ids, text_b.attention_mask, chart)
|
| 57 |
+
# Predicted: out_a['sig_logits'] == out_b['sig_logits'] (bit-exact)
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
If this is bit-exact, the bug is confirmed.
|
| 61 |
+
|
| 62 |
+
## Why it matters
|
| 63 |
+
|
| 64 |
+
All four heads (`head_sig`, `head_dir`, `head_mag`, `head_reason`) consume only
|
| 65 |
+
`f`. So:
|
| 66 |
+
|
| 67 |
+
- The model cannot use news semantics for any prediction.
|
| 68 |
+
- Iteration scoreboard's plateau at 2.495 is the **chart-only loss floor**, not a
|
| 69 |
+
data/label noise floor.
|
| 70 |
+
- The "encoder choice doesn't matter" observation is trivially true because no
|
| 71 |
+
encoder choice is ever exercised.
|
| 72 |
+
- `sig_acc = 0.557 = majority class` is what a chart-only model achieves when it
|
| 73 |
+
cannot read the news that triggers the move.
|
| 74 |
+
- The v6 "winner" claim (delta=0.0004 over v4) is comparing two run-to-run noise
|
| 75 |
+
draws of effectively the same chart-only model.
|
| 76 |
+
|
| 77 |
+
## The fix
|
| 78 |
+
|
| 79 |
+
Replace the cross-attention with a real fusion. Examples:
|
| 80 |
+
|
| 81 |
+
```python
|
| 82 |
+
# Option A β concatenate then project (no attention, deterministic)
|
| 83 |
+
self.fuse = nn.Sequential(nn.Linear(2*text_dim, text_dim), nn.GELU())
|
| 84 |
+
def forward(...):
|
| 85 |
+
f = self.fuse(torch.cat([t_pool, c_pool], dim=-1))
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
```python
|
| 89 |
+
# Option B β proper cross-attention with chart used as a SEQUENCE
|
| 90 |
+
# Don't pool c_pool yet; let chart_enc return per-bar features (B, T, D),
|
| 91 |
+
# and use t_pool as query against all T chart bars:
|
| 92 |
+
class ChartEncoder(nn.Module):
|
| 93 |
+
def forward(self, x):
|
| 94 |
+
...
|
| 95 |
+
return self.proj_out(h) # (B, T, D) β DO NOT mean(dim=1)
|
| 96 |
+
|
| 97 |
+
def forward(self, ...):
|
| 98 |
+
c_seq = self.chart_enc(chart_window) # (B, T, D)
|
| 99 |
+
fused, _ = self.fuse(t_pool.unsqueeze(1), # (B, 1, D) query
|
| 100 |
+
c_seq, c_seq) # (B, T, D) key/value
|
| 101 |
+
f = fused.squeeze(1)
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
Option B is the architecturally honest version of the original intent.
|
| 105 |
+
|
| 106 |
+
## Severity
|
| 107 |
+
|
| 108 |
+
**CRITICAL.** Until this is fixed, the project is, at best, a chart-only price
|
| 109 |
+
movement classifier conditioned on _which symbol_ the news happened to mention.
|
| 110 |
+
No news semantics are used.
|