Skip to content

Commit 8712c59

Browse files
Return best iterate from AMICATorchNG.fit (#51)
Multi-model NG log-likelihood was ~0.02 lower and ~13x more variable than Fortran because fit() returned the LAST EM iterate under the deliberately non-monotone lrate schedule (both NG and Fortran anneal only after an LL decrease). The variance was driven by late Newton-fallback overshoots: on the sample EEG one seed peaked at LL -3.357 then crashed to -3.545 in its final iterations. It was return-last, not a bad basin. fit() now tracks and returns the highest-LL iterate: - keep_best (default True) restores the best iterate when the run ends more than _KEEP_BEST_TOL (1e-9) below its peak; a monotone single-model run has best == last, so no restore fires and issue #24 parity stays byte-for-byte identical (verified: max param diff 0.0). Inactive under do_reject (the good-sample set, hence the LL normalization, changes across iterations). - final_ll_ reports the returned iterate's LL; ll_history stays the true trajectory. The AMICA wrapper, validate_implementations, and the ensemble scripts read final_ll_. - state_dict format bumped 2 -> 3 (adds keep_best, final_ll). At matched 100-iter budget this cuts the LL sd from 12.7x to 2.0x Fortran's. The residual ~0.009 mean gap is convergence speed, not a worse optimum: NG reaches Fortran's exact mean (-3.3541) at 200 iters and exceeds it at 300. Tested: 69 torch tests pass (+3 keep_best tests); ruff clean; ty no new diagnostics. ADR 0003, .context/issue-51/.
1 parent 5c12548 commit 8712c59

15 files changed

Lines changed: 601 additions & 27 deletions
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# ADR 0003: Return the best-log-likelihood iterate from AMICATorchNG.fit
2+
3+
**Status:** accepted
4+
**Date:** 2026-07-06
5+
**Owner:** neuromechanist
6+
7+
## Context
8+
9+
Issue #51: on the real sample EEG, multi-model `AMICATorchNG` (`n_models=2`,
10+
100 iterations) reaches a final log-likelihood distribution that is ~0.02 lower
11+
and ~13x more variable than the Fortran reference, even though the per-block
12+
sufficient statistics and one M-step are bit-exact vs Fortran (~1e-15). The
13+
partition distribution is already statistically equivalent to Fortran's (ADR-era
14+
work on #27), so this is an optimizer-quality residual, not a correctness bug.
15+
16+
Diagnosing the variance (NG-only sweep, 20 seeds): the spread is driven almost
17+
entirely by a *late overshoot*, not a bad basin. One seed climbed to LL -3.3573
18+
(dead in the pack) then crashed to -3.5452 in its final two iterations after
19+
Newton went non-positive-definite and fell back to the natural gradient; the
20+
lrate ramp re-inflated the step and the run ended mid-crash. Nine of twenty
21+
"good" runs also ended a small amount below their own peak. The learning-rate
22+
schedule is deliberately non-monotone (both NG and Fortran anneal the rate only
23+
*after* an LL decrease, `amica15.f90:1038-1058`), so the *last* EM iterate is not
24+
guaranteed to be the best one. `fit` returned the last iterate.
25+
26+
## Decision
27+
28+
`AMICATorchNG.fit` tracks the highest-log-likelihood iterate and restores it when
29+
the run ends more than `_KEEP_BEST_TOL` (1e-9, per sample-channel LL) below that
30+
peak. A new `keep_best: bool = True` constructor flag controls it; `final_ll_`
31+
exposes the log-likelihood of the *returned* parameters (while `ll_history` stays
32+
the true per-iteration trajectory, overshoot and all). The safeguard is inactive
33+
under `do_reject`, where the good-sample set (and thus the LL normalization)
34+
changes across iterations and per-iteration LLs are not comparable.
35+
36+
## Consequences
37+
38+
- The pathological low-LL tail is removed and the LL variance collapses toward
39+
Fortran's: on the 20-seed sample ensemble NG goes from mean -3.3738 (sd 0.040)
40+
to mean -3.363 (sd ~0.007). See `.context/issue-51/`.
41+
- **Single-model issue #24 parity stays bit-exact.** A monotone fit has its best
42+
iterate == its last iterate, the gap is 0 < tol, no restore fires, and the
43+
returned parameters are byte-for-byte identical to `keep_best=False` (verified:
44+
max parameter difference 0.0 across A/W/mu/beta/alpha/rho/c/gm at 100 iters).
45+
- New obligation: `ll_history[-1]` is no longer the fitted model's LL when a
46+
restore fired. Consumers must read `final_ll_` (or `max(ll_history)`). The
47+
validation harness and ensemble scripts were updated accordingly.
48+
- A residual mean gap (~0.009) remains at 100 iterations, but it is *convergence
49+
speed*, not a worse optimum: at 200 iterations NG reaches Fortran's exact mean
50+
LL (-3.3541) and by 300 slightly exceeds it. NG's per-iteration progress is ~2x
51+
slower than Fortran's; the reachable solution is identical (the M-step is
52+
bit-exact vs Fortran, #27). Not a correctness issue.
53+
- `state_dict` format bumped 2 -> 3 (adds `keep_best`, `final_ll`).
54+
55+
## Alternatives considered
56+
57+
- **Do nothing / raise `max_iter`:** more iterations let a crashed run's annealing
58+
recover, but waste compute on every run and do not help a fit that already
59+
peaked and then overshot. Rejected as the primary fix (kept as a knob).
60+
- **Trust-region / reject-the-update in-loop:** clamp any LL-decreasing step. This
61+
changes the optimization trajectory and risks perturbing the bit-exact
62+
single-model path; return-best is a pure post-hoc selection that leaves the
63+
trajectory (and `ll_history`) untouched. Rejected.
64+
- **Rewrite `ll_history[-1]` to the best value:** dishonest (hides the overshoot)
65+
and breaks the fixed-length trajectory contract. Rejected in favor of a
66+
separate `final_ll_`.
67+
- **Restore best even on a degenerate (nan_ll) stop:** would salvage finite
68+
parameters from a diverged run, but entangles with issue #50's degenerate-fit
69+
contract; the safeguard is skipped for degenerate stops and left to #50.
70+
71+
## Receipts
72+
73+
- `pyAMICA/torch_impl/amica_torch_ng.py` (`keep_best`, `_snapshot_params`/
74+
`_restore_params`, `final_ll_`, `_KEEP_BEST_TOL`).
75+
- `pyAMICA/tests/torch_tests/test_ng_backend.py::test_keep_best_*`.
76+
- `.context/issue-51/ensemble_ll.py` (Fortran-vs-NG LL ensemble, real data).
77+
- Fortran schedule: `pyAMICA/amica15.f90:1038-1058` (anneal-on-decrease).

.context/issue-27/multimodel_ensemble.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def run_ng(data, seed):
9393
ng.fit(data, max_iter=MAX_ITER, verbose=False)
9494
return (
9595
np.vstack([ng.get_unmixing_matrix(0), ng.get_unmixing_matrix(1)]),
96-
ng.ll_history[-1],
96+
ng.final_ll_, # LL of the returned iterate (issue #51 best-iterate safeguard)
9797
)
9898

9999

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Multi-model NG log-likelihood: best-iterate safeguard (issue #51)
2+
3+
**Bottom line.** The multi-model NG log-likelihood was ~0.02 lower and ~13x more
4+
variable than Fortran because `AMICATorchNG.fit` returned the *last* EM iterate
5+
under a deliberately non-monotone learning-rate schedule. Returning the *best*
6+
iterate (`keep_best`, default on) removes the variance pathology; the remaining
7+
mean gap is convergence speed, not a worse optimum -- with more iterations NG
8+
reaches Fortran's exact solution.
9+
10+
## Root cause: return-last, not a bad basin
11+
12+
An NG-only sweep (20 seeds, `n_models=2`, 100 iters, real sample EEG) reproduced
13+
the #51 finding (mean -3.3738, sd 0.040) and localized the variance to a **late
14+
overshoot**, not a wrong basin:
15+
16+
- The single variance-driving seed (#3) climbed to LL **-3.3573** (dead in the
17+
pack) by iter 97, then **crashed to -3.5452** in its final two iterations after
18+
Newton went non-positive-definite and fell back to the natural gradient; the
19+
lrate ramp re-inflated the step and the run ended mid-crash.
20+
- 9 of 20 "good" seeds also ended a small amount below their own peak.
21+
22+
The lrate schedule anneals only *after* an LL decrease (`amica15.f90:1038-1058`,
23+
mirrored in NG), so the last iterate is not guaranteed to be the best. `fit`
24+
returned the last iterate.
25+
26+
## Fix: return the best iterate
27+
28+
`AMICATorchNG` tracks the highest-LL iterate and restores it when the run ends
29+
more than `_KEEP_BEST_TOL` (1e-9) below that peak. `keep_best=True` by default;
30+
`final_ll_` reports the returned iterate's LL, while `ll_history` stays the true
31+
trajectory. Inactive under `do_reject` (the good-sample set, hence the LL
32+
normalization, changes across iterations). A monotone single-model fit has
33+
best == last, so no restore fires and **issue #24 parity stays bit-exact**
34+
(verified: max parameter difference 0.0 with keep_best on vs off).
35+
36+
## Results (real sample EEG, Fortran binary + NG, NO MOCK)
37+
38+
`ensemble_ll.py`, N=20 each, `n_models=2`, matched schedule:
39+
40+
| max_iter=100 | mean LL | sd | mean gap | sd ratio | KS p | TOST(±0.01) |
41+
|---|---:|---:|---:|---:|---:|---|
42+
| Fortran | -3.3541 | 0.0031 | -- | -- | -- | -- |
43+
| NG return-last | -3.3738 | 0.0399 | -0.0197 | 12.7x | 9.5e-6 | inconclusive |
44+
| **NG keep_best** | **-3.3634** | **0.0064** | **-0.0093** | **2.0x** | 5.6e-5 | inconclusive |
45+
46+
keep_best cuts the variance from **12.7x -> 2.0x** Fortran's sd (the headline
47+
"~13x more variable" defect) and halves the mean gap. See `ll_before_after.png`.
48+
49+
## The residual is convergence speed, not a worse optimum
50+
51+
The ~0.009 mean gap that remains at 100 iters is *iteration budget*, not a wrong
52+
term. NG keep_best mean LL vs iteration budget (8 seeds):
53+
54+
| max_iter | NG mean | NG sd | Fortran mean (100 it) |
55+
|---:|---:|---:|---:|
56+
| 100 | -3.3639 | 0.0076 | -3.3541 |
57+
| **200** | **-3.3541** | 0.0050 | -3.3541 |
58+
| 300 | -3.3523 | 0.0040 | -3.3541 |
59+
60+
At 200 iterations NG reaches Fortran's **exact** mean (-3.3541); by 300 it slightly
61+
exceeds it. NG's per-iteration progress is ~2x slower than Fortran's, but it
62+
converges to the same optimum -- as expected from the M-step being bit-exact vs
63+
Fortran (#27). This is optimizer efficiency, not correctness: the reachable
64+
solution is identical.
65+
66+
## Acceptance
67+
68+
1. **Variance pathology removed** at matched budget (12.7x -> 2.0x). *(Held.)*
69+
2. **Same optimum**: NG reaches Fortran's exact mean LL with adequate iterations
70+
(200-iter mean == -3.3541). *(Held.)*
71+
3. **Single-model #24 parity** stays bit-exact under the safeguard. *(Held --
72+
`test_keep_best_single_model_is_bit_exact`.)*
73+
74+
## Reproduction
75+
76+
- `ensemble_ll.py [N] [MAX_ITER]` -> `ensemble_ll.npz` (Fortran + NG, return-last
77+
and keep_best final LLs). `plot_ll.py` renders `ll_before_after.png`.
78+
- Real sample data + the macOS Fortran binary (x86_64, Rosetta) only.

.context/issue-51/ensemble_ll.npz

1.68 KB
Binary file not shown.

.context/issue-51/ensemble_ll.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Issue #51 acceptance measurement: does the best-iterate safeguard (keep_best)
2+
make NG's multi-model log-likelihood distribution equivalent to Fortran's?
3+
4+
Runs N Fortran + N NG fits on the real sample EEG (n_models=2, matched schedule),
5+
comparing the final-LL distributions three ways:
6+
7+
- Fortran vs NG *return-last* (keep_best=False; reproduces the #51 defect)
8+
- Fortran vs NG *keep_best* (keep_best=True; the fix, reports final_ll_)
9+
10+
Reports mean/sd, KS, and TOST (mean-equivalence within +/-DELTA) for each, and
11+
the sd ratio. Real sample data + the macOS Fortran binary only (NO MOCK).
12+
13+
uv run python .context/issue-51/ensemble_ll.py [N] [MAX_ITER]
14+
"""
15+
16+
import os
17+
import shutil
18+
import subprocess
19+
import sys
20+
import tempfile
21+
from pathlib import Path
22+
23+
import numpy as np
24+
from scipy import stats
25+
26+
from pyAMICA.torch_impl import AMICATorchNG
27+
from pyAMICA.torch_impl.utils import load_eeglab_data
28+
29+
HERE = Path(__file__).resolve().parent
30+
REPO = HERE.parents[1]
31+
BIN = REPO / "pyAMICA/sample_data/amica15mac"
32+
FDT = REPO / "pyAMICA/sample_data/eeglab_data.fdt"
33+
FIXTURE = REPO / "pyAMICA/tests/torch_tests/_ng_e2e_tmp/fortran_run/input.param"
34+
NW, FIELD = 32, 30504
35+
DELTA_LL = 0.01 # TOST equivalence margin on the mean LL (per sample-channel)
36+
37+
38+
def load_data():
39+
return load_eeglab_data(str(FDT), data_dim=NW, field_dim=FIELD).astype(np.float64)
40+
41+
42+
def run_fortran(work, tag, max_iter):
43+
d = work / f"fort_{tag}"
44+
(d / "fortran_output").mkdir(parents=True, exist_ok=True)
45+
shutil.copy(FDT, d / "eeglab_data.fdt")
46+
lines = []
47+
for ln in FIXTURE.read_text().splitlines():
48+
if ln.startswith("num_models"):
49+
lines.append("num_models 2")
50+
elif ln.startswith("max_iter"):
51+
lines.append(f"max_iter {max_iter}")
52+
else:
53+
lines.append(ln)
54+
(d / "input.param").write_text("\n".join(lines) + "\n")
55+
orig = os.getcwd()
56+
os.chdir(d)
57+
try:
58+
r = subprocess.run(
59+
[str(BIN), "input.param"], capture_output=True, text=True, timeout=900
60+
)
61+
finally:
62+
os.chdir(orig)
63+
if r.returncode != 0:
64+
raise RuntimeError(r.stderr[-400:])
65+
return next(
66+
(
67+
float(ln.split("LL =")[1].split()[0])
68+
for ln in reversed(r.stdout.splitlines())
69+
if "LL =" in ln
70+
),
71+
np.nan,
72+
)
73+
74+
75+
def run_ng(data, seed, keep_best, max_iter):
76+
ng = AMICATorchNG(
77+
n_channels=NW, n_models=2, n_mix=3, block_size=512, lrate=0.05, minlrate=1e-8,
78+
lratefact=0.5, maxdecs=3, do_newton=True, newt_start=50, newt_ramp=10,
79+
newtrate=1.0, rho0=1.5, minrho=1.0, maxrho=2.0, rholrate=0.05,
80+
rholratefact=0.5, invsigmin=1e-8, invsigmax=100.0, doscaling=True,
81+
scalestep=1, seed=seed, device="cpu", keep_best=keep_best,
82+
) # fmt: skip
83+
ng.fit(data, max_iter=max_iter, verbose=False)
84+
return ng.final_ll_
85+
86+
87+
def compare(name, F, G):
88+
F, G = np.asarray(F), np.asarray(G)
89+
ks = stats.ks_2samp(G, F).pvalue
90+
diff = G.mean() - F.mean()
91+
se = np.sqrt(G.var(ddof=1) / G.size + F.var(ddof=1) / F.size)
92+
p_tost = max(
93+
stats.norm.sf((diff + DELTA_LL) / se), stats.norm.cdf((diff - DELTA_LL) / se)
94+
)
95+
print(
96+
f" {name:16s} F={F.mean():.4f}(sd {F.std():.4f}) "
97+
f"NG={G.mean():.4f}(sd {G.std():.4f}) diff={diff:+.4f} "
98+
f"sd_ratio={G.std() / max(F.std(), 1e-9):.1f}x "
99+
f"KS p={ks:.1e} TOST(+/-{DELTA_LL}) p={p_tost:.1e} "
100+
f"{'EQUIV' if p_tost < 0.05 else 'inconclusive'}"
101+
)
102+
103+
104+
def main():
105+
n = int(sys.argv[1]) if len(sys.argv) > 1 else 20
106+
max_iter = int(sys.argv[2]) if len(sys.argv) > 2 else 100
107+
data = load_data()
108+
work = Path(tempfile.mkdtemp(prefix="amica_ll51_"))
109+
print(f"scratch: {work} N={n} max_iter={max_iter}")
110+
111+
F = []
112+
for k in range(n):
113+
F.append(run_fortran(work, str(k), max_iter))
114+
print(f" Fortran {k + 1}/{n}: LL={F[-1]:.4f}", flush=True)
115+
G_last, G_best = [], []
116+
for k in range(n):
117+
G_last.append(run_ng(data, k, False, max_iter))
118+
G_best.append(run_ng(data, k, True, max_iter))
119+
print(
120+
f" NG {k + 1}/{n}: last={G_last[-1]:.4f} best={G_best[-1]:.4f}", flush=True
121+
)
122+
123+
print(f"\n==== N={n} max_iter={max_iter} ====")
124+
compare("return-last", F, G_last)
125+
compare("keep_best", F, G_best)
126+
np.savez(
127+
HERE / "ensemble_ll.npz",
128+
F=F,
129+
G_last=G_last,
130+
G_best=G_best,
131+
n=n,
132+
max_iter=max_iter,
133+
)
134+
print(f"saved -> {HERE / 'ensemble_ll.npz'}")
135+
136+
137+
if __name__ == "__main__":
138+
main()
31 KB
Binary file not shown.
96.3 KB
Loading

.context/issue-51/plot_ll.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Render the issue #51 before/after LL distribution figure from ensemble_ll.npz
2+
(written by ensemble_ll.py). Fortran vs NG return-last vs NG keep_best."""
3+
4+
from pathlib import Path
5+
6+
import numpy as np
7+
import matplotlib
8+
9+
matplotlib.use("Agg")
10+
import matplotlib.pyplot as plt # noqa: E402
11+
12+
HERE = Path(__file__).resolve().parent
13+
C_FORT, C_LAST, C_BEST = "#0072B2", "#D55E00", "#009E73" # Okabe-Ito
14+
15+
16+
def main():
17+
d = np.load(HERE / "ensemble_ll.npz")
18+
F, G_last, G_best = d["F"], d["G_last"], d["G_best"]
19+
n, mi = int(d["n"]), int(d["max_iter"])
20+
21+
plt.rcParams.update(
22+
{"font.size": 11, "axes.spines.top": False, "axes.spines.right": False}
23+
)
24+
fig, ax = plt.subplots(figsize=(8.2, 4.6))
25+
lo = min(F.min(), G_last.min()) - 0.005
26+
hi = max(F.max(), G_last.max()) + 0.005
27+
bins = np.linspace(lo, hi, 40)
28+
for arr, col, lab in [
29+
(F, C_FORT, f"Fortran ({F.mean():.4f}, sd {F.std():.4f})"),
30+
(
31+
G_last,
32+
C_LAST,
33+
f"NG return-last ({G_last.mean():.4f}, sd {G_last.std():.4f})",
34+
),
35+
(G_best, C_BEST, f"NG keep_best ({G_best.mean():.4f}, sd {G_best.std():.4f})"),
36+
]:
37+
ax.hist(arr, bins=bins, density=True, color=col, alpha=0.32)
38+
ax.hist(
39+
arr, bins=bins, density=True, histtype="step", color=col, lw=2, label=lab
40+
)
41+
ax.axvline(arr.mean(), color=col, ls="--", lw=1.2)
42+
ax.set_xlabel("final log-likelihood (per sample-channel)")
43+
ax.set_ylabel("density")
44+
ax.set_title(
45+
f"Multi-model AMICA (n_models=2, N={n}, {mi} iters): LL distributions",
46+
loc="left",
47+
fontweight="bold",
48+
)
49+
ax.legend(frameon=False, fontsize=9, loc="upper left")
50+
ax.text(
51+
0.98,
52+
0.97,
53+
"keep_best (issue #51): sd 12.7x -> 2.0x Fortran,\n"
54+
"mean gap 0.020 -> 0.009. Removes the low-LL\n"
55+
"overshoot tail; small residual is optimizer\n"
56+
"efficiency (NG peaks sit ~0.009 below Fortran).",
57+
transform=ax.transAxes,
58+
va="top",
59+
ha="right",
60+
fontsize=8.5,
61+
bbox=dict(boxstyle="round", fc="white", ec="0.7", alpha=0.9),
62+
)
63+
fig.tight_layout()
64+
fig.savefig(HERE / "ll_before_after.png", bbox_inches="tight", dpi=200)
65+
fig.savefig(HERE / "ll_before_after.pdf", bbox_inches="tight")
66+
print(f"figure -> {HERE / 'll_before_after.png'}")
67+
68+
69+
if __name__ == "__main__":
70+
main()

.context/plan.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@
3030
- [x] Multi-model AMICA per-model bias `c` update (issue #27): ported to both backends, guarded
3131
no-op for `n_models=1`; controlled A/B shows +0.011 cross-corr, gap is intrinsic partition
3232
ambiguity (see `.context/issue-27/multimodel_c_update.md`).
33+
- [x] Best-iterate safeguard (issue #51): `AMICATorchNG.fit` returns the highest-LL iterate
34+
(`keep_best`, `final_ll_`), not the last, so a late Newton-fallback overshoot no longer leaves
35+
the model below a peak it reached. Root cause was return-last, not a bad basin (the sole
36+
variance-driving seed peaked at -3.357 then crashed to -3.545 in its final iterations).
37+
Single-model #24 parity stays bit-exact (monotone => no restore). See ADR 0003,
38+
`.context/issue-51/`.
3339
- [ ] Component sharing
3440

3541
### Priority 3: Testing & validation

0 commit comments

Comments
 (0)