Skip to content

Retain clip-level echo tokens so attentive pooling actually pools - #73

Open
duckyquang wants to merge 5 commits into
mainfrom
feat/manifest-probe-training
Open

Retain clip-level echo tokens so attentive pooling actually pools#73
duckyquang wants to merge 5 commits into
mainfrom
feat/manifest-probe-training

Conversation

@duckyquang

Copy link
Copy Markdown
Member

Summary

Two things, and the second one is why this PR is bigger than I planned.

First: the probes couldn't read the manifest from #61. Each one wanted a cohort table plus one or two separate embedding tables to join on study/record id, and the manifest already carries both embeddings inline. Only the ECG probe had a CLI at all, so "retrain all four" had nothing to run. Both fixed — embedding paths are optional now, omit them and the first arg is read as a manifest, and scripts/train_probes.py does all four.

Second: while wiring that up I found the attentive pooling has never done anything. We mean-pool clips down to one vector per study, then tile that vector into identical tokens, and attention over identical tokens is a bit-exact identity — the softmax comes out uniform and the query gets exactly zero gradient, so it can't even learn its way out. The echo probe has been an MLP on a mean-pooled vector this whole time, which is the case TECHNICAL.md 4.1 specifically says isn't good enough.

Same thing hits the fused probe harder. Both cross-attention blocks degenerate, and because each one's keys/values come from the other modality, echo_ctx ended up depending only on ECG and ecg_ctx only on echo — the two halves of the concat are swapped and there's no cross-modal interaction at all.

So this adds an opt-in --max-clips that keeps clip-level vectors per study instead of averaging them away, plus padding masks, since ragged clip counts mean pad_sequence appends zero rows that would otherwise get real softmax weight.

Result

All measured on this Mac, CPU, on synthetic test fixtures — data/ is empty here, so none of this is a model result and there's no MAE or AUROC I'd stand behind yet.

Attention weight spread, D=32, eval mode (0 = uniform = degenerate):

block keys before after
ecg_to_echo echo 0.000e+00 3.724e-02
echo_to_ecg ECG 0.000e+00 0.000e+00

Only one direction is fixed — see Notes.

The first cut of the clip writer would have OOM'd. np.stack(...).tolist() boxed every float32 into a Python object and held all studies at once; measured ~7 GB projected at 7,251 studies. Rewrote it as one contiguous float32 block handed to pyarrow zero-copy:

peak RSS parquet
1,000 studies, before 1,572 MB 100.9 MB
1,000 studies, after 514 MB 66.2 MB
7,251 studies (real scale), after 1,807 MB 476 MB

Tests 65 → 90.

Notes

The fused probe is only half fixed. echo_to_ecg takes its keys and values from the ECG tokens, and those are still one pooled HuBERT-ECG vector tiled into 4 identical copies, so that direction stays dead no matter what the echo side does. Fixing it needs token-level ECG embeddings, which probably means re-extraction rather than a loader change. Split out as #72.

This is mechanism work, not validation. It shows the architecture can now attend. It says nothing about whether attending helps LVEF. That needs the manifest, which isn't on my machine — so I haven't run #63 or #64 against any of this.

Knock-on for #64: the missing-modality numbers were measured on a model with no cross-modal interaction, so they describe degradation of a concat model. Worth a rerun once the above settles.

Also worth knowing: attentive_beats_linear_val_mae in echo_only's results.json compares an MLP and a linear head on a bit-identical vector on the pooled path. It's a capacity comparison, not an attention one — don't read it as evidence for attentive pooling.

Things I didn't fix, deliberately:

  • The pyarrow list-offset ceiling is guarded with an explicit precondition, not lifted. pa.ListArray narrows offsets to int32 whatever dtype you hand it, and build_joined_manifest re-infers plain list<> through pandas, so switching the builder to LargeListArray alone buys nothing end to end. A build past the limit now fails immediately with a clear message instead of an opaque ArrowInvalid.
  • Ragged ECG token counts are rejected rather than masked. Nothing can produce them today, so symmetric masking would be dead flexibility.
  • Default pooled build now writes list<float> where it wrote list<double> — values bit-identical, type changed. An all-null shard now raises instead of writing an empty parquet.

@kevzho you've got the most history in probes/, so you're the one I'd most want eyes on this. Two specific asks: does the clip-level manifest schema fit how you expect to consume it downstream, and do you know off-hand whether we can get token-level ECG out of HuBERT-ECG without re-extracting? That's the blocker on #72.

The probes predate the manifest: each expected a cohort table plus one or two
separate embedding tables to join on study/record id. The manifest already
carries both embeddings inline, so make the embedding paths optional and read
the first argument as a manifest when they are omitted. The old two-table path
is untouched.

Only the ECG probe had a CLI, so "retrain all four" had no entrypoint. Adds
scripts/train_probes.py covering all four, reading dims off the manifest
instead of taking --embed-dim, which removes the state_dict shape mismatch
the README warns about.

Also pins a regression test for a problem this surfaced: echo embeddings
arrive as one mean-pooled vector per study, which _embedding_to_tokens tiles
into identical tokens. AttentivePool softmaxes over identical scores, so the
weights come out uniform and the layer is an exact identity. The attentive
echo probe is currently an MLP on a mean-pooled vector, which TECHNICAL.md
4.1 names as the insufficient case. The test fails once clip-level tokens
land, which is the signal the collapse is gone.
Mean-pooling clips to one vector per study made every downstream attention
layer degenerate. AttentivePool over identical tokens is a bit-exact identity
(uniform softmax) and its query gets exactly zero gradient, so the echo probe
was an MLP on a mean vector. In the fused probe the cross-attention degenerates
twice over -- identical keys force uniform weights, identical values make the
weighted sum equal the value -- leaving echo_ctx a function of ECG alone and
ecg_ctx a function of echo alone, with no cross-modal interaction at all.

build_echo_study_embeddings gains an opt-in max_clips that keeps an even-stride
subsample of clip vectors per study. Default stays mean-pooling. The write stage
now fills one contiguous float32 block and hands pyarrow a zero-copy column,
draining the accumulator as it goes; the old np.stack().tolist() round-trip
boxed every float into a Python object and peaked around 7 GB at full scale
against 1.8 GB now.

Ragged clip counts mean pad_sequence appends zero rows, which score 0 and take
real softmax weight, so echo_mask is threaded through AttentivePool,
CrossAttentionFusion, the three torch probes and fairness. Ragged ECG token
counts are rejected rather than masked, since nothing can produce them yet.

Parsing now runs before the stride decision so null clips cannot consume
retention slots or drop a study the pooled path would keep, and n_echo_clips
counts parsed clips in both modes.

Known limits: the pyarrow list-offset ceiling is guarded with an explicit
precondition, not lifted -- pa.ListArray narrows offsets to int32 regardless of
the dtype passed in, and build_joined_manifest re-infers plain list<> through
pandas, so LargeListArray in the builder alone would buy nothing. The default
pooled build now writes list<float> where it wrote list<double>; values are
bit-identical. An all-null shard now raises instead of writing an empty parquet.
@duckyquang
duckyquang requested a review from kevzho August 4, 2026 10:21

@kevzho kevzho left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, the findings are solid and properly labeled as synthetic-only.

I have the manifest locally, so I ran all four probes against the real 1,208 rows. Requesting changes to one thing that only appears with the data.

concat_mlp.py crashes, as twelve rows carry non-finite HuBERT-ECG vectors (8 train, 1 val, 3 test). cross_attn filters them and ecg_only filters them, but concat_mlp and echo_only have no finite filter on main or here. echo_only survives by luck (echo has zero non-finite rows); concat_mlp trains on NaN and dies at concat_mlp.py:85 -> common.py:44 with ValueError: Input contains NaN. manifest.load's require_both doesn't catch it — .notna() tests whether the cell is null, not whether the parsed vector contains NaN. Since this PR is what makes "retrain all four" runnable, I think it belongs here. Cleanest fix is one shared finite filter that also logs its drop count, which closes the silent row-loss gap at the same time so #68 doesn't inherit it.

Also, expose the fusion width as a CLI flag. train_probes.py passes embed_dim=echo_dim, pinning the fused probe at 1024 — 9.7M params vs 1.1M at the 256 the current checkpoint used, on 821 training rows. #67 step 3 is "check probe capacity and training budget," and I'd rather an 8.7x capacity jump not enter as an implicit default. Measured: @1024 gets MAE 10.90 / AUROC 0.742 vs @256 at 10.98 /0.734 — basically nothing for 8.7x the parameters, so 256 looks like the right default. Runtime shouldn't be an issue either way (all four train+eval in 49s / 81s on CPU).

Answers to your two questions:

  1. Yes, and it's parse_embedding specifically that makes that work. On main it doesn't, as pyarrow returns a list<list<double>> column as an object-dtype array of per-cliip arrays and .astype(float) raises, so EchoHubertDataset can't read a clip-level manifest at all. The version you have parses fine.
  2. I don't think we can get token-level ecg without re-extraction. The interim parquet is the pooled 768-d vector per record; there aren't any pre-pooling hidden states on disk. HuBERT-ECG is wav2vec2-family, so re-running it on the MIMIC-IV-ECG waveforms would give us genuine tokens but that means #72 neds to be modified into a re-extraction task instead of a loader change (and that also means #71 should be rescoped to match asw for that).

I can just push both fixes if that's faster (I can verify on real data in a minute).

concat_mlp crashed on the real 1,208-row cohort: twelve rows carry non-finite
HuBERT-ECG vectors and only ecg_only and cross_attn filtered them. echo_only was
surviving on luck, since the echo side happens to have none. manifest.load's
require_both does not help -- notna() asks whether the cell is null, not what the
vector holds.

One drop_non_finite in probes/common.py now serves all four, and each reports
n_dropped_nonfinite in its results.json so the row loss is visible rather than
silent. prepare_fused_probe_data returns the count alongside the splits, which
changes its signature for its two callers.

train_probes.py passed embed_dim=echo_dim, pinning the fused probe to the encoder
width: 9.7M params against 1.1M at 256, on 821 training rows. That is a capacity
choice, not a property of the data, so it becomes --fusion-dim defaulting to the
256 the existing checkpoint used.
@duckyquang

Copy link
Copy Markdown
Member Author

Thanks — both were real and both are fixed in 3973bff. I did them myself rather than take you up on the offer, since you'd have been fixing my bug on your Saturday, but I do need your data for the verification (below).

Non-finite rows. You were right about the root cause and I'd got require_both wrong: .notna() asks whether the cell is null, not what's inside the vector, so it sails straight past a NaN in position 0. Grep confirms your read — ecg_only and cross_attn filtered, concat_mlp and echo_only had zero isfinite references between them.

One drop_non_finite in probes/common.py now serves all four, and each writes n_dropped_nonfinite into its results.json so the row loss shows up instead of vanishing. That also meant prepare_fused_probe_data had to return the count alongside the splits — signature change, two callers updated (cross_attn.run, evaluation/missing_modality).

Regression test builds a manifest with NaN/inf/-inf inside otherwise-present ECG cells and runs all four probes. I checked it actually bites rather than just going green: pull the concat_mlp filter and test_non_finite_embeddings_are_dropped_not_trained_on[concat] fails; put it back and it passes. Suite 90 → 95.

Fusion width. Agreed, and thanks for measuring it — 8.7x the parameters for 0.08 MAE is not a trade I'd have wanted to make silently. It's --fusion-dim now, default 256. Verified the flag reaches the model rather than just parsing: --fusion-dim 4 and 16 produce echo_proj out_features of 4 and 16.

I left the encoder dims read off the manifest, since a mismatch there is the state_dict shape failure the README warns about. Only the fusion width is a knob.

One thing I want to check before we read anything into your numbers. Was that run against the pooled manifest? It has to have been unless you rebuilt with --max-clips, since the existing one predates the flag. If so those 10.90 / 10.98 figures are from the degenerate-pooling path — which makes them a fine baseline, but it means the actual question this PR exists to answer, does clip-level pooling help, is still untested. If you can rebuild with --max-clips 16 and rerun, that comparison is the thing I most want to see, and I can't run it here.

Also noting 10.90 sits right next to the old 10.28, so #67's gap against EchoJEPA's 5.97 hasn't moved yet. Not surprising if the echo branch was inert for both runs.

On your answer to (2) — that's decisive, thank you. I've rescoped #72 from a loader change to a re-extraction task and #71 to match: probe training genuinely doesn't need a GPU (your 49s/81s confirms what I measured), but re-running HuBERT-ECG over the MIMIC-IV-ECG waveforms does, so the compute ask comes back for that specifically.

And the ask that matters most: can you get me the manifest, or point me at where it lives? It's the blocker on #63, #64 and #69 for me, and you having it locally is the only reason any of this has real numbers attached.

evaluate_missing_modality still required a cohort plus two separate embedding
tables, so E07 could not score the checkpoint M10 produces even after the probes
learned to read the manifest. Both embedding paths are optional now, matching the
probes, and --manifest supersedes the two-table flags on the CLI.

checkpoint_path keeps its old positional slot but is now keyword-optional, so it
raises a named error instead of failing inside Path(None).
M10 asks for a run manifest per training run, and utils/run_manifest existed but
nothing called it. train_probes now writes run_metadata.json alongside summary.json
with the manifest path, seed, epochs, fusion width, encoder dims and the per-probe
non-finite drop counts.

The field that matters is echo_tokens_are_clip_level. Pooled and clip-level runs
produce checkpoints that look identical on disk, and telling them apart after the
fact is the whole provenance question for the pooled-vs-clip comparison.

check_ef40_prevalence already reads a manifest unchanged -- it only needs split and
ef_le_40, both present -- so M10 step 5 needs no code.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants