Skip to content

Commit 1fc704b

Browse files
Merge pull request #162 from sccn/feature/issue-133-epic-mir-pmi-metrics
Epic: MIR and PMI separation-quality metrics
2 parents 1cf287a + b5323da commit 1fc704b

30 files changed

Lines changed: 3403 additions & 49 deletions

.context/issue-136/cmp_modprob.png

243 KB
Loading

.context/issue-136/cmp_pmi.png

290 KB
Loading
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# Phase 4 MATLAB gate: MIR/PMI visualizations (issue #136, epic #133)
2+
3+
> **OUTCOME (end of Phase 4): two plots shipped, `plot_topo_pdf` was CUT.**
4+
> `plot_pmi_heatmap` and `plot_model_probability` shipped, both fully pinned to
5+
> MATLAB (see the data gate below) with the visual gate accepted.
6+
> `plot_topo_pdf` is deferred to **#159**. It derives source activations from a
7+
> loaded `AmicaOutput`, and that turns out to rest on an unsettled `W`
8+
> convention: `loadmodout`'s `W` does not reproduce the live model's
9+
> `transform()` sources at high fidelity under EITHER orientation, even for a
10+
> single-model fit where `c` is identically zero (`W @ sphered` -> mean |corr|
11+
> 0.877; `W.T @ sphered` -> 0.932; **0/32** components above 0.999, where a
12+
> merely normalised + variance-reordered `W` would give ~1.000 for all 32, since
13+
> correlation already quotients out scale, sign and permutation). It is also the
14+
> only plot with **no MATLAB oracle** (`pop_topohistplot` is broken upstream,
15+
> trap 5), so nothing external could catch a wrong activation space. Shipping a
16+
> scalp map that renders plausibly but cannot be verified is precisely the
17+
> failure mode this epic kept hitting, so it was cut rather than documented-and-
18+
> shipped. The two plots that did ship touch neither raw data nor sources.
19+
>
20+
> Chasing this DID pay for itself: it uncovered a real, pre-existing, shipped
21+
> bug (`gammaln` for `gamma` in `numpy_impl/pdf.py`, trap 3b below).
22+
23+
## The contract
24+
25+
Same bidirectional standard as [issue #155](../issue-155/matlab_interop_verification.md),
26+
adapted to plots. Plots have no byte-level contract, so the gate has two levels:
27+
28+
1. **Data gate**: every quantity we plot must match MATLAB's where an oracle exists.
29+
2. **Visual gate**: our figure and MATLAB's, rendered on the same real data, must be
30+
a meaningful representation of the same thing (a human judgement call, made by
31+
the user; accepted 2026-07-16).
32+
33+
MATLAB cannot run in CI, so this is recorded rather than automated.
34+
35+
## Licensing posture (why this gate exists at all)
36+
37+
`pop_topohistplot.m` and `pop_modPMI.m` carry explicit **GPL-2.0-or-later** headers
38+
(Copyright Ozgur Baklan, SCCN, INC, UCSD). `modprobplot.m`, `minfojp.m`, `LLt2v.m`,
39+
`smooth_amica_prob.m` carry no header but sit inside that GPL plugin, so they are
40+
conservatively GPL too. pyAMICA is BSD-3-Clause, which is why Phase 2's PMI was a
41+
clean-room reimplementation.
42+
43+
**Posture: run-and-observe only. No `.m` implementation source was read at any point.**
44+
Everything below came from MATLAB `help` text (public API documentation), rendered
45+
figures, and black-box input/output behaviour. That is what makes the MATLAB gate not
46+
merely a check but the *mechanism* that keeps the reimplementation clean: it lets us
47+
match observable behaviour without deriving from protected expression.
48+
49+
`pre_ICA_cleaning/getMIR.m` is **Apache-2.0** and was legitimately read (Phase 1
50+
ported it with attribution; see `THIRD_PARTY_NOTICES.md`).
51+
52+
## Data gate: results
53+
54+
All on real bundled sample EEG (`eeglab_data.fdt`, 32ch x 30504, srate 128) and a
55+
real 2-model `AMICATorchNG` fit. Compared with `np.array_equal`/`corrcoef`, never
56+
`allclose` where an exact match was expected.
57+
58+
| Quantity | MATLAB oracle | Result |
59+
|---|---|---|
60+
| `mir()` | `getMIR.m` (Apache-2.0) | **1.7e-15 relative** (33.110492656163046 vs ...103 at N=30504; 37.541867541278549 vs ...663 at N=4096) |
61+
| P(model \| data) | `LLt2v` | **1.4e-14** vs our `softmax(Lht)` |
62+
| `v` (log10 model odds) | `loadmodout15.m:284` | **bit-exact** (max diff 0.0) |
63+
| Hanning-smoothed probability | `smooth_amica_prob` | **r = 0.988577** (1 s), **r = 0.959376** (5 s); mean abs diff 0.014 / 0.045 |
64+
| `pairwise_mi` | `minfojp` via `pop_modPMI` (GPL, black-box) | **r = 0.9887** off-diagonal, on identical signals |
65+
66+
Notes on the two correlation-rather-than-equality rows:
67+
68+
- **Smoothing**: `max|diff|` is ~1.0 and that is expected, not alarming. Near a model
69+
switch the probability is near-binary, so a sub-sample timing difference flips 0<->1.
70+
Mean abs diff (0.014) and correlation are the meaningful measures. We deliberately do
71+
not replicate MATLAB's endpoint convention (see divergences below).
72+
- **PMI**: our estimator is clean-room, so it is a *different* estimator. Correlation is
73+
the right bar; equality would be suspicious. **Do not tune ours toward theirs** — that
74+
would convert a clean-room reimplementation into a derivation.
75+
76+
## Visual gate: accepted
77+
78+
Side-by-side renders on the same data are committed here:
79+
80+
- `cmp_modprob.png` — MATLAB `modprobplot` vs `pyAMICA.viz.plot_model_probability`
81+
(1 s smoothing). Same two stacked panels, same switching times (~1.4, 3.4, 6.5, 9.3,
82+
12.5, 15.7 s), same log-likelihood trace and range (-105 to -125), seconds axis.
83+
- `cmp_pmi.png` — MATLAB `pop_modPMI` vs `pyAMICA.viz.plot_pmi_heatmap`, rendered on
84+
**identical signals** (MATLAB's own `EEG.icaact`) so the comparison isolates the
85+
estimator and the ordering. Both show the same dependent-subspace cluster near the
86+
centre with the same radiating cross pattern, at the same MI scale.
87+
88+
Accepted deliberate differences: viridis vs jet, we add a colorbar (MATLAB has none),
89+
0-based component labels (pythonic; MATLAB is 1-based), and a different ordering
90+
algorithm (ours greedy nearest-neighbour chain vs MATLAB's iterative cost minimisation,
91+
`cost = 20.0533 -> 19.5856 -> 18.9268`).
92+
93+
## Deliberate divergences (documented so nobody "fixes" them toward MATLAB)
94+
95+
1. **Smoothing endpoints.** MATLAB pins the exact first/last sample to the raw
96+
*unsmoothed* input value (a MATLAB `smooth()` idiom). We return a locally averaged
97+
value there. Ours is arguably more correct (the first sample of a smoothed signal
98+
should not be unsmoothed), and matching exactly would require reading GPL source.
99+
2. **MI diagonal.** MATLAB zeroes it. Our `pairwise_mi` diagonal is self-entropy
100+
(~2.83 vs off-diagonal ~0.06), so `plot_pmi_heatmap` masks it (`mask_diagonal=True`).
101+
Plotting it unmasked destroys the colour scale and hides all structure.
102+
3. **Negative MI.** MATLAB's bias correction yields small negatives (-0.006); ours is
103+
strictly positive. Expected estimator difference, not a bug.
104+
105+
## Traps found the hard way (re-read before touching this)
106+
107+
1. **`mInfoMatrix` is stored ALREADY REORDERED** by `mInforOrders`. Comparing it raw
108+
against a natural-order matrix gives **r = -0.13 and 0/8 overlap in top dependent
109+
pairs**, which reads exactly like "our PMI is broken". Un-permuted it is **r = 0.9887**.
110+
Always un-permute (`inv = np.argsort(order-1); mi[np.ix_(inv, inv)]`) first.
111+
2. **A naive Hanning smooth silently corrupts both plot edges.**
112+
`np.convolve(row, w/w.sum(), mode="same")` zero-pads, and since `Lht ~= -108` (nowhere
113+
near 0) the padding drags the first/last half-window toward zero: input[0] = -123.4701
114+
became **-60.39**. After the softmax that yields confidently WRONG model probabilities
115+
at the start and end of every plot — plausible-looking and completely wrong. Fix, used
116+
in `viz.py`: divide by the window overlap (`np.convolve(np.ones_like(row), w, "same")`).
117+
3. **Do NOT derive sources from an `AmicaOutput` yet -- the loader is broken (#159).**
118+
`loadmodout` reads `W` (load.py:174), `sbeta` (:278) and `rho` (:292) in C order,
119+
while `write_amicaout` writes all of them `order="F"` and only `alpha`/`mu` are
120+
read back correctly. So `out.W`/`out.sbeta`/`out.rho` are wrong for GENUINE
121+
Fortran output, and `out.A`/`out.svar`/`out.origord` are wrong too since they are
122+
derived from the mis-read `W`. There is no correct activation formula on the
123+
shipped loader; fix #159 first.
124+
125+
Proven by an EXTERNAL, non-circular oracle: recompute the bundled Fortran
126+
fixture's OWN reported converged LL from its OWN written parameters. LL is not
127+
transpose-symmetric, so it discriminates.
128+
129+
| W order | mixture order | recomputed LL | \|diff vs its own LL file\| |
130+
|---|---|---|---|
131+
| **F** | **F** | **-3.4018468** | **0.00003** |
132+
| C | F | -3.5167152 | 0.115 |
133+
| F | C | -3.5024789 | 0.101 |
134+
| C | C | -3.5539805 | 0.152 |
135+
136+
(The fixture's `LL` file reports -3.4018730; the 3e-5 residual is the Fortran's
137+
own block truncation.) Confirmed independently for the mixture params by a
138+
pairing test, `loaded[:, i] == live[:, origord[i]]`: `alpha` (read `order="F"`)
139+
matches exactly, `rho` (read C) does not, max diff **0.969**.
140+
141+
Once #159 lands, `c` SHOULD be subtracted (`out.W @ (sphered - out.c[:, m])`),
142+
and `c` is sphered-channel indexed (Fortran computes `wc = W @ c`,
143+
amica15.f90:1979, and maps it through the sphering pseudo-inverse at :2280), so
144+
`loadmodout` not variance-reordering `c` is correct.
145+
146+
**THIS ENTRY WAS WRONG TWICE, in opposite directions.** v1 claimed
147+
`W @ S @ (X-mean)` was wrong and the transpose canonical. v2 "corrected" it to
148+
claim `AmicaOutput.W` is row-convention so `out.W @ sphered` is right. Both were
149+
asserted from reasoning and self-consistency checks; both were wrong; a reviewer
150+
escalated v1 as a critical bug in correct code. Record of what misled, so nobody
151+
repeats it:
152+
153+
- **`A @ (W @ sphered) == centered` is circular** -- `A := pinv(W @ S)`, so the
154+
identity is a tautology. A completely RANDOM `W` passes it at 2e-15.
155+
- **Best-match correlation vs `transform()` cannot see `c`** -- Pearson
156+
correlation is shift-invariant, returning bit-identical numbers with and
157+
without it.
158+
- **The activation-mean test is degenerate** -- the fitted mixture is
159+
near-symmetric (`sum(alpha*mu) ~= 0.01`) and `E[sphered] = 0`, so the no-c
160+
formula matches trivially regardless of correctness. (The mixture tracks the
161+
RESPONSIBILITY-WEIGHTED mean, which is 0 by construction since `c` is itself
162+
the `v_h`-weighted mean; comparing against the unweighted mean compares
163+
different quantities.)
164+
- **Histogram-vs-PDF L1 is insensitive AND was pooled wrongly** -- the per-model
165+
mixture describes only model h's own samples weighted by `v_h`; pooling all
166+
samples misaligns it. Responsibility-weighting flips the verdict.
167+
- **`A @ W == I` cannot discriminate the W order** -- `A@W=I` implies
168+
`A_transposed @ W_transposed = I`, so both conventions satisfy it.
169+
170+
Standing lesson: **do not assert a byte-order or convention from reasoning or
171+
from any self-consistency check.** Only an external oracle settles it -- the
172+
Fortran's own reported LL, its own written `A`, MATLAB reading our bytes, or a
173+
constraint the data must satisfy (e.g. alpha's simplex).
174+
175+
3b. **A real, pre-existing `pdf.py` bug found while chasing trap 3 (fixed in this PR).**
176+
`numpy_impl/pdf.py: compute_pdf` used `special.gammaln` where the generalized
177+
Gaussian needs `special.gamma`:
178+
`p(y) = exp(-|y|^rho) / (2 * Gamma(1 + 1/rho))`. Dividing by `log(Gamma(...))`
179+
makes the "density" NEGATIVE for every rho outside the special-cased 1 and 2 — it
180+
integrated to **-8.82 at the default `rho0=1.5`**. How it happened: the Fortran
181+
computes this in LOG space (`- gamln(1+1/rho) - log(2)`, amica15.f90:1305-1306),
182+
where log-gamma is right; the port transcribed `gamln` straight into a linear-space
183+
expression. Blast radius:
184+
- **The fit path was never affected.** `numpy_impl/core.py` has its own log-space
185+
`_compute_log_pdf` mirroring the Fortran, and never imports `.pdf`. Parity is safe.
186+
- The pre-existing `numpy_impl/viz.py: plot_pdf_fits` HAS been drawing wrong curves.
187+
- `compute_log_pdf` inherited it (`np.log(pdf)` of a negative number -> NaN).
188+
It survived because the only tests calling `compute_pdf` used rho=1.0 and rho=2.0 —
189+
precisely the two special-cased branches that are correct. Guarded now by
190+
`test_pdf_is_a_normalized_density_for_general_rho` (integrates to 1 for rho in
191+
1.0..3.0, a property no restatement of the formula could fake) and
192+
`test_pdf_general_branch_is_continuous_with_the_special_cases` (rho=2 vs rho=2+1e-9
193+
must agree, since exact-equality dispatch sends them down different branches).
194+
195+
4. **postAmicaUtility's own `loadmodout15.m` is broken on R2025b** (`Unmatched ']'`,
196+
line 120). pyAMICA's bundled copy works. `addpath` `pyAMICA/sample_data` LAST so ours
197+
shadows theirs, or the model never loads and every plot fails with a misleading
198+
"No AMICA solution found".
199+
5. **`pop_topohistplot` is broken upstream** (`Unrecognized function or variable
200+
'showhist'` on current EEGLAB), so there is **no visual reference** for the topo/PDF
201+
plot. `plot_topo_pdf` is therefore a fresh design, which #136 already mandated.
202+
6. **Do not read the figures to settle numeric questions.** The maintainer twice
203+
misread which model was active at t=0 from a low-res render and briefly believed the
204+
models were swapped. Three numeric checks (correlation +0.9886, direct values at t=0,
205+
and pixel-colour sampling showing orange's plateau starting at x=74 vs blue's at
206+
x=120) all confirmed there was no swap. Trust the data gate over the visual one.
207+
208+
## Reproducing
209+
210+
MATLAB R2025b at `/Volumes/S1/Applications/MATLAB_R2025b.app/bin/matlab -batch`.
211+
References are NOT vendored (GPL); clone to a scratch dir:
212+
213+
```
214+
git clone --depth 1 https://github.com/sccn/eeglab.git
215+
git clone --depth 1 https://github.com/sccn/postAmicaUtility.git # GPL: run, never read
216+
git clone --depth 1 https://github.com/bigdelys/pre_ICA_cleaning.git # Apache-2.0: getMIR.m
217+
```
218+
219+
Then: `addpath(genpath(eeglab)); addpath(postAmicaUtility); addpath(pyAMICA/sample_data)`
220+
(last, per trap 4); `eeglab nogui`; `pop_loadset` + `pop_loadmodout(EEG, <amicaout dir>)`;
221+
`modprobplot(EEG, 1:num_models, smooth_sec, [])` returns `[v2plot, llt2plot]` — the exact
222+
series it draws — and `pop_modPMI(EEG, 'models2plot', 1, 'order', true)` writes
223+
`mInfoMatrix`/`mInfoVar`/`mInforOrders` into `EEG.etc.amica`. Figures are uifigures; export
224+
with `exportapp`, not `print`.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# LLt interop verification: pyAMICA <-> EEGLAB `loadmodout15.m` (issue #155, PR #156)
2+
3+
## The contract
4+
5+
The acceptance bar for the `LLt` work is bidirectional interop, not internal
6+
self-consistency:
7+
8+
1. **pyAMICA's reader must understand Fortran's output.**
9+
2. **EEGLAB's reader (`loadmodout15.m`) must understand pyAMICA's output.**
10+
11+
PR #156's automated tests only pin direction 1 (via the real bundled
12+
`sample_data/amicaout/LLt` fixture). They do **not** pin direction 2: the
13+
round-trip tests prove pyAMICA's writer agrees with pyAMICA's *own* reader,
14+
which a self-consistently-wrong writer/reader pair would also satisfy. Only
15+
the genuine MATLAB reader can close that.
16+
17+
This matters because issue #92 caught a **real** column-major bug exactly this
18+
way: `loadmodout15.m` read scrambled mixture params (per-component `alpha` did
19+
not sum to 1) that code review had missed. `LLt` is a new file in the writer
20+
and had never been through that gate. See `.context/scratch_history.md`.
21+
22+
MATLAB cannot run in CI, so this is a manual verification, recorded here the
23+
same way #92's was.
24+
25+
## Method
26+
27+
MATLAB R2025b, real `loadmodout15.m` from `pyAMICA/sample_data/`, run against
28+
three directories:
29+
30+
- `pyamica_m1` -- pyAMICA `AMICATorchNG` single-model fit, real bundled sample
31+
EEG (`eeglab_data.fdt`, 32ch x 4096 samples), seed 1, 20 iters.
32+
- `pyamica_m2` -- same data, 2 models, seed 4, 8 iters (non-trivial `mod_prob`,
33+
so the reader's gm-based model reordering is actually exercised).
34+
- `amicaout` -- the genuine Fortran-produced fixture bundled in the repo.
35+
36+
Each result was compared against `pyAMICA.numpy_impl.load.loadmodout`'s view of
37+
the same directory with `np.array_equal` (not `allclose`).
38+
39+
## Result: bit-exact in both directions
40+
41+
```
42+
pyamica_m1 Lht (1, 4096) equal=True | Lt (4096,) equal=True | max|dH|=0.0 max|dT|=0.0
43+
pyamica_m2 Lht (2, 4096) equal=True | Lt (4096,) equal=True | max|dH|=0.0 max|dT|=0.0
44+
fortran Lht (1, 30504) equal=True | Lt (30504,) equal=True | max|dH|=0.0 max|dT|=0.0
45+
46+
EEGLAB loadmodout15 == pyAMICA loadmodout, bit-exact, all cases: True
47+
pyamica_m1 single-model Lht[0]==Lt via MATLAB: True
48+
fortran single-model Lht[0]==Lt via MATLAB: True
49+
```
50+
51+
Corroborating details:
52+
53+
- 2-model `mod_prob` agrees: MATLAB `[0.554689 0.445311]` vs pyAMICA
54+
`[0.5547 0.4453]`. Both readers apply the same gm-descending model
55+
reordering to `Lht`, so the model axis stays aligned with `W`/`mod_prob`
56+
across the two implementations.
57+
- Genuine Fortran fixture reads to `Lt mean = -108.859935652062` under BOTH
58+
readers, matching `nw * final_ll = 32 * -3.40187` independently.
59+
- MATLAB reads `Lt` as `(1, N)` and pyAMICA as `(N,)`; that is MATLAB's
60+
everything-is-2D convention, not a layout difference (values compare equal
61+
after `ravel`).
62+
63+
## Why it agrees
64+
65+
`loadmodout15.m:119-124` does `fread(...,'double')` then
66+
`reshape(LLt, num_models+1, N)`. MATLAB's `reshape` is column-major, so this is
67+
`order="F"`. pyAMICA's writer (`numpy_impl/load.py:write_amicaout`) does
68+
`vstack([Lht, Lt]).ravel(order="F")`, and its reader does
69+
`reshape(num_models+1, -1, order="F")`. All three agree with Fortran's
70+
`write_output` (`amica15.f90:2308-2333`), which writes per timepoint each
71+
model's `modloglik` then the total.
72+
73+
`loadmodout15.m:284` then derives `v(h,:) = 0.4343 * (Lht(h,:) - Lt)`, the
74+
log10 model odds. The `0.4343` scaling is the reader's job; the writer emits
75+
natural-log values, which is what both readers expect.
76+
77+
## Reproducing
78+
79+
MATLAB is not on CI, so re-run manually if the `LLt` layout, the writer, or
80+
`loadmodout` ever change:
81+
82+
1. Fit a real single-model and multi-model model, `write_amica_output` each.
83+
2. `matlab -batch` -> `loadmodout15('<outdir>/')` (note the trailing slash; the
84+
function concatenates paths directly).
85+
3. Compare its `Lht`/`Lt` against `loadmodout`'s with `np.array_equal`.
86+
4. Also run it against `sample_data/amicaout/` so both readers are checked
87+
against the same genuine Fortran bytes.
88+
89+
Do not weaken step 3 to `allclose`: both readers are parsing identical bytes,
90+
so anything short of exact equality means a real layout or convention bug.

0 commit comments

Comments
 (0)