2424NW = 32
2525FIELD = 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 )])
140175def 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" )
155192def 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