Skip to content

Commit dc6c7fe

Browse files
Add tests: persistence, kurtosis guard, Newton, rejection
1 parent f3281a7 commit dc6c7fe

1 file changed

Lines changed: 134 additions & 3 deletions

File tree

pyAMICA/tests/torch_tests/test_ng_pdf_families.py

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
NW = 32
2525
FIELD = 30504
2626

27-
# Fortran log-normalizer literals (amica15.f90:1315/1327/1341/1353).
27+
# Fortran log-normalizer literals (amica15.f90:1315/1328/1341/1353).
2828
_LOG4 = math.log(4.0)
2929
_LSQ2PI = math.log(2.506628274)
3030
_LNSUB = math.log(4.132731354)
@@ -135,10 +135,47 @@ def test_pdftype_validation():
135135
AMICATorchNG(n_channels=NW, n_mix=1, pdftype=4, device="cpu")
136136

137137

138+
def test_kurt_schedule_validation():
139+
"""The adaptive-switch schedule params are validated at construction so a
140+
bad value fails loudly instead of crashing deep in fit()."""
141+
for bad in dict(kurt_int=0, kurt_start=0, num_kurt=-1).items():
142+
with pytest.raises(ValueError):
143+
AMICATorchNG(n_channels=NW, n_mix=1, pdftype=1, device="cpu", **dict([bad]))
144+
# The same params are inert (unvalidated) for non-adaptive pdftype.
145+
AMICATorchNG(n_channels=NW, n_mix=3, pdftype=0, device="cpu", kurt_int=0)
146+
147+
148+
def test_pdtype_from_kurtosis_decision():
149+
"""The pure kurtosis->family decision: super-G(+)->1, sub-G(-)->4, and a
150+
non-finite / dead-model kurtosis keeps the prior pdtype (the guard). This is
151+
the sub-Gaussian (code 4) switch branch that real EEG rarely triggers."""
152+
# Single model: cover +, -, NaN (keep prior), - again.
153+
m = AMICATorchNG(n_channels=4, n_mix=1, pdftype=1, device="cpu", seed=0)
154+
m._initialize_parameters() # sets self.pdtype to all-1 (prior)
155+
kurt = torch.tensor([[2.0], [-2.0], [float("nan")], [-0.5]], dtype=torch.float64)
156+
nsub = torch.tensor([10.0], dtype=torch.float64)
157+
out = m._pdtype_from_kurtosis(kurt, nsub)
158+
assert out.flatten().tolist() == [1, 4, 1, 4] # NaN -> kept prior (1)
159+
160+
# Two models, model 1 dead (nsub==0): its sources keep the prior (1) even
161+
# though their (finite) negative kurtosis would otherwise pick code 4.
162+
m2 = AMICATorchNG(
163+
n_channels=2, n_models=2, n_mix=1, pdftype=1, device="cpu", seed=0
164+
)
165+
m2._initialize_parameters()
166+
kurt2 = torch.full((2, 2), -2.0, dtype=torch.float64)
167+
nsub2 = torch.tensor([10.0, 0.0], dtype=torch.float64)
168+
out2 = m2._pdtype_from_kurtosis(kurt2, nsub2)
169+
assert out2[:, 0].tolist() == [4, 4] # live model switched to sub-Gaussian
170+
assert out2[:, 1].tolist() == [1, 1] # dead model kept prior
171+
172+
138173
@pytest.mark.skipif(not DATA_FILE.exists(), reason="sample data missing")
139174
@pytest.mark.parametrize("pdftype,n_mix", [(0, 3), (2, 3), (3, 3), (4, 1), (1, 1)])
140175
def test_family_fit_finite_and_monotone(pdftype: int, n_mix: int):
141-
"""Every family fits real EEG to a finite, monotonically improving LL."""
176+
"""Every family fits real EEG to a finite LL that does not regress below its
177+
starting value (natural-gradient AMICA can dip mid-run, so this checks net
178+
non-decrease, last >= first, not strict monotonicity)."""
142179
data = _load_real_data()
143180
kw = dict(num_kurt=0) if pdftype == 1 else {} # fixed super-G, no switching
144181
m = AMICATorchNG(
@@ -154,7 +191,8 @@ def test_family_fit_finite_and_monotone(pdftype: int, n_mix: int):
154191
@pytest.mark.skipif(not DATA_FILE.exists(), reason="sample data missing")
155192
def test_auto_switcher_runs_and_is_stable():
156193
"""The extended-Infomax switcher runs the full schedule, keeps every source
157-
in a valid family, and stays finite/monotone on real EEG."""
194+
in a valid family, and stays finite with a net non-decreasing LL (last >=
195+
first) on real EEG."""
158196
data = _load_real_data()
159197
m = AMICATorchNG(
160198
n_channels=NW,
@@ -188,6 +226,99 @@ def test_auto_switch_noop_when_num_kurt_zero():
188226
assert np.all(fixed.pdtype.cpu().numpy() == 1)
189227

190228

229+
@pytest.mark.skipif(not DATA_FILE.exists(), reason="sample data missing")
230+
def test_state_dict_roundtrips_pdftype_state():
231+
"""save/load must preserve the density family. A fixed non-GG model and the
232+
adaptive switcher's per-source pdtype/n_kurt_done must survive a round-trip
233+
(else a reloaded model silently reverts to GG)."""
234+
data = _load_real_data()
235+
236+
# Fixed logistic family.
237+
m = AMICATorchNG(n_channels=NW, n_mix=3, pdftype=3, device="cpu", seed=0)
238+
m.fit(data, max_iter=8, verbose=False)
239+
loaded = AMICATorchNG.from_state_dict(m.state_dict(), device="cpu")
240+
assert loaded.pdftype == 3 and loaded.dorho is False
241+
assert torch.equal(loaded.pdtype, m.pdtype)
242+
assert np.allclose(loaded.transform(data), m.transform(data))
243+
244+
# Adaptive switcher: per-source pdtype and the switch counter must persist.
245+
ad = AMICATorchNG(
246+
n_channels=NW,
247+
n_mix=1,
248+
pdftype=1,
249+
device="cpu",
250+
seed=0,
251+
kurt_start=3,
252+
num_kurt=5,
253+
kurt_int=1,
254+
)
255+
ad.fit(data, max_iter=12, verbose=False)
256+
ad_loaded = AMICATorchNG.from_state_dict(ad.state_dict(), device="cpu")
257+
assert ad_loaded.pdftype == 1 and ad_loaded.do_choose_pdfs is True
258+
assert ad_loaded.n_kurt_done == ad.n_kurt_done == 5
259+
assert torch.equal(ad_loaded.pdtype, ad.pdtype)
260+
261+
262+
@pytest.mark.skipif(not DATA_FILE.exists(), reason="sample data missing")
263+
@pytest.mark.parametrize("pdftype,n_mix", [(2, 3), (3, 3), (4, 1)])
264+
def test_family_fit_with_newton(pdftype: int, n_mix: int):
265+
"""Non-GG families run with the Newton preconditioner (as Fortran does) and
266+
stay finite/monotone on real EEG; the cosh curvature may fall back to natural
267+
gradient, which is expected and must not crash."""
268+
data = _load_real_data()
269+
m = AMICATorchNG(
270+
n_channels=NW,
271+
n_mix=n_mix,
272+
pdftype=pdftype,
273+
device="cpu",
274+
seed=0,
275+
do_newton=True,
276+
newt_start=5,
277+
)
278+
m.fit(data, max_iter=15, verbose=False)
279+
ll = np.asarray(m.ll_history)
280+
assert np.all(np.isfinite(ll))
281+
assert ll[-1] >= ll[0] - 1e-6
282+
283+
284+
@pytest.mark.skipif(not DATA_FILE.exists(), reason="sample data missing")
285+
def test_adaptive_switch_with_rejection():
286+
"""The adaptive switcher runs after outlier rejection has shrunk the sample
287+
set (it consumes the post-rejection X_use) without crashing on real EEG."""
288+
data = _load_real_data()
289+
m = AMICATorchNG(
290+
n_channels=NW,
291+
n_mix=1,
292+
pdftype=1,
293+
device="cpu",
294+
seed=0,
295+
do_reject=True,
296+
rejstart=2,
297+
rejint=2,
298+
maxrej=1,
299+
kurt_start=3,
300+
num_kurt=3,
301+
kurt_int=1,
302+
)
303+
m.fit(data, max_iter=12, verbose=False)
304+
assert np.all(np.isfinite(m.ll_history))
305+
assert set(np.unique(m.pdtype.cpu().numpy())).issubset({1, 4})
306+
307+
308+
@pytest.mark.skipif(not DATA_FILE.exists(), reason="sample data missing")
309+
def test_multimodel_fixed_family():
310+
"""A fixed non-GG family works with n_models>1 (exercises the per-model
311+
_pdtype_h / _choose_pdfs indexing path)."""
312+
data = _load_real_data()
313+
m = AMICATorchNG(
314+
n_channels=NW, n_models=2, n_mix=3, pdftype=2, device="cpu", seed=0
315+
)
316+
m.fit(data, max_iter=10, verbose=False)
317+
ll = np.asarray(m.ll_history)
318+
assert np.all(np.isfinite(ll))
319+
assert m.pdtype.shape == (NW, 2)
320+
321+
191322
@pytest.mark.skipif(
192323
os.environ.get("AMICA_RUN_FORTRAN") != "1",
193324
reason="opt-in Fortran-binary integration test (set AMICA_RUN_FORTRAN=1)",

0 commit comments

Comments
 (0)