|
| 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`. |
0 commit comments