@@ -228,83 +228,43 @@ def test_normalize_scales_correctly(self, dist, scale_factor):
228228
229229 assert jnp .allclose (normalized_original , normalized_scaled , rtol = 1e-6 )
230230
231+ # The three decoder invariant checks below used to be three separate
232+ # Hypothesis tests, each running a full decoder fit+predict for every
233+ # example. With ``max_examples=10`` that was 30 fit+predict cycles
234+ # across the three tests, and each first example paid full JAX JIT
235+ # compile time (~3s on a laptop, ~5-9s on CI), which raced the
236+ # ``deadline=5000`` ceiling and caused FlakyFailure on slower runners.
237+ #
238+ # Hypothesis's shrinking machinery adds no value here: the strategy
239+ # was just ``integers(42, 9999)``, which is "parameterized testing
240+ # dressed up as property-based". Collapse the three tests into one
241+ # parameterized pytest test that fits the decoder **once per seed**
242+ # and checks all three invariants on that single fit. Each of the N
243+ # seeds exercises a different simulated trajectory but reuses the
244+ # warm JAX caches, so the whole collapsed suite runs in roughly the
245+ # time of a single old example. N=5 is plenty of diversity for
246+ # invariant checking.
231247 @pytest .mark .slow
232- @settings (deadline = 5000 , max_examples = 10 ) # Decoder tests are slower
233- @given (st .integers (min_value = 42 , max_value = 9999 ))
234- def test_posterior_probabilities_sum_to_one (self , seed ):
235- """Property: decoder posteriors sum to 1 across position dimension."""
236- # Generate simulation
237- # NOTE: n_runs must be >= 3 to create proper 2D position data
238- # NOTE: Need substantial data for RandomWalk to build proper position bins
248+ @pytest .mark .parametrize ("seed" , [42 , 137 , 1234 , 5678 , 9999 ])
249+ def test_decoder_posterior_invariants (self , seed : int ) -> None :
250+ """Decoder posteriors satisfy probability invariants for any seed.
251+
252+ Checks on a single ClusterlessDecoder fit+predict:
253+ - posterior sums to 1.0 across the state_bins axis,
254+ - all values in [0, 1],
255+ - log(posterior) is finite or -inf (never NaN).
256+
257+ Re-fitting per seed exercises the simulator + decoder over a
258+ spread of trajectories; all three invariants are checked on
259+ each fit so a single decoder run yields three assertions.
260+ """
261+ # NOTE: n_runs must be >= 3 to create proper 2D position data.
262+ # NOTE: Need substantial data for RandomWalk to build proper
263+ # position bins.
239264 sim = make_simulated_run_data (
240265 n_tetrodes = 2 ,
241266 place_field_means = np .arange (0 , 80 , 20 ), # 4 neurons
242267 sampling_frequency = 500 ,
243- n_runs = 3 , # Multiple runs to ensure 2D position array
244- seed = seed ,
245- )
246-
247- # Use 70/30 train/test split on all data
248- n_encode = int (0.7 * len (sim .position_time ))
249- is_training = np .ones (len (sim .position_time ), dtype = bool )
250- is_training [n_encode :] = False
251-
252- decoder = ClusterlessDecoder (
253- clusterless_algorithm = "clusterless_kde" ,
254- clusterless_algorithm_params = {
255- "position_std" : 6.0 ,
256- "waveform_std" : 24.0 ,
257- "block_size" : 50 ,
258- },
259- continuous_transition_types = [[RandomWalk (movement_var = 25.0 )]],
260- )
261-
262- decoder .fit (
263- sim .position_time ,
264- sim .position ,
265- sim .spike_times ,
266- sim .spike_waveform_features ,
267- is_training = is_training ,
268- )
269-
270- # Predict on small test set (10 time bins only for speed)
271- test_start_idx = n_encode
272- test_end_idx = min (n_encode + 10 , len (sim .position_time ))
273- results = decoder .predict (
274- spike_times = [
275- st [
276- (st >= sim .position_time [test_start_idx ])
277- & (st < sim .position_time [test_end_idx ])
278- ]
279- for st in sim .spike_times
280- ],
281- spike_waveform_features = [
282- swf [
283- (sim .spike_times [i ] >= sim .position_time [test_start_idx ])
284- & (sim .spike_times [i ] < sim .position_time [test_end_idx ])
285- ]
286- for i , swf in enumerate (sim .spike_waveform_features )
287- ],
288- time = sim .position_time [test_start_idx :test_end_idx ],
289- position = sim .position [test_start_idx :test_end_idx ],
290- position_time = sim .position_time [test_start_idx :test_end_idx ],
291- )
292-
293- # Check posterior sums to 1 across spatial dimension (state_bins)
294- posterior_sums = results .acausal_posterior .sum (dim = "state_bins" )
295- assert np .allclose (posterior_sums .values , 1.0 , atol = 1e-10 )
296-
297- @pytest .mark .slow
298- @settings (deadline = 5000 , max_examples = 10 )
299- @given (st .integers (min_value = 42 , max_value = 9999 ))
300- def test_posteriors_nonnegative_and_bounded (self , seed ):
301- """Property: decoder posteriors are in [0, 1]."""
302- # NOTE: n_runs must be >= 3 to create proper 2D position data
303- # NOTE: Need substantial data for RandomWalk to build proper position bins
304- sim = make_simulated_run_data (
305- n_tetrodes = 2 ,
306- place_field_means = np .arange (0 , 80 , 20 ),
307- sampling_frequency = 500 ,
308268 n_runs = 3 ,
309269 seed = seed ,
310270 )
@@ -322,7 +282,6 @@ def test_posteriors_nonnegative_and_bounded(self, seed):
322282 },
323283 continuous_transition_types = [[RandomWalk (movement_var = 25.0 )]],
324284 )
325-
326285 decoder .fit (
327286 sim .position_time ,
328287 sim .position ,
@@ -333,95 +292,34 @@ def test_posteriors_nonnegative_and_bounded(self, seed):
333292
334293 test_start_idx = n_encode
335294 test_end_idx = min (n_encode + 10 , len (sim .position_time ))
295+ test_start_t = sim .position_time [test_start_idx ]
296+ test_end_t = sim .position_time [test_end_idx ]
336297 results = decoder .predict (
337298 spike_times = [
338- st [
339- (st >= sim .position_time [test_start_idx ])
340- & (st < sim .position_time [test_end_idx ])
341- ]
342- for st in sim .spike_times
299+ st [(st >= test_start_t ) & (st < test_end_t )] for st in sim .spike_times
343300 ],
344301 spike_waveform_features = [
345- swf [
346- (sim .spike_times [i ] >= sim .position_time [test_start_idx ])
347- & (sim .spike_times [i ] < sim .position_time [test_end_idx ])
348- ]
349- for i , swf in enumerate (sim .spike_waveform_features )
302+ wf [(st >= test_start_t ) & (st < test_end_t )]
303+ for st , wf in zip (
304+ sim .spike_times , sim .spike_waveform_features , strict = False
305+ )
350306 ],
351307 time = sim .position_time [test_start_idx :test_end_idx ],
352308 position = sim .position [test_start_idx :test_end_idx ],
353309 position_time = sim .position_time [test_start_idx :test_end_idx ],
354310 )
355311
356- # Check all values in [0, 1]
357- assert np .all (results .acausal_posterior .values >= 0.0 )
358- assert np .all (results .acausal_posterior .values <= 1.0 )
359-
360- @pytest .mark .slow
361- @settings (deadline = 5000 , max_examples = 10 )
362- @given (st .integers (min_value = 42 , max_value = 9999 ))
363- def test_log_probabilities_finite (self , seed ):
364- """Property: log probabilities should be finite (or -inf for zero prob)."""
365- # NOTE: n_runs must be >= 3 to create proper 2D position data
366- # NOTE: Need substantial data for RandomWalk to build proper position bins
367- sim = make_simulated_run_data (
368- n_tetrodes = 2 ,
369- place_field_means = np .arange (0 , 80 , 20 ),
370- sampling_frequency = 500 ,
371- n_runs = 3 ,
372- seed = seed ,
373- )
374-
375- n_encode = int (0.7 * len (sim .position_time ))
376- is_training = np .ones (len (sim .position_time ), dtype = bool )
377- is_training [n_encode :] = False
378-
379- decoder = ClusterlessDecoder (
380- clusterless_algorithm = "clusterless_kde" ,
381- clusterless_algorithm_params = {
382- "position_std" : 6.0 ,
383- "waveform_std" : 24.0 ,
384- "block_size" : 50 ,
385- },
386- continuous_transition_types = [[RandomWalk (movement_var = 25.0 )]],
387- )
388-
389- decoder .fit (
390- sim .position_time ,
391- sim .position ,
392- sim .spike_times ,
393- sim .spike_waveform_features ,
394- is_training = is_training ,
395- )
312+ posterior = results .acausal_posterior .values
396313
397- test_start_idx = n_encode
398- test_end_idx = min (n_encode + 10 , len (sim .position_time ))
399- results = decoder .predict (
400- spike_times = [
401- st [
402- (st >= sim .position_time [test_start_idx ])
403- & (st < sim .position_time [test_end_idx ])
404- ]
405- for st in sim .spike_times
406- ],
407- spike_waveform_features = [
408- swf [
409- (sim .spike_times [i ] >= sim .position_time [test_start_idx ])
410- & (sim .spike_times [i ] < sim .position_time [test_end_idx ])
411- ]
412- for i , swf in enumerate (sim .spike_waveform_features )
413- ],
414- time = sim .position_time [test_start_idx :test_end_idx ],
415- position = sim .position [test_start_idx :test_end_idx ],
416- position_time = sim .position_time [test_start_idx :test_end_idx ],
417- )
314+ # Invariant 1: posteriors sum to 1 across state_bins.
315+ posterior_sums = results .acausal_posterior .sum (dim = "state_bins" )
316+ assert np .allclose (posterior_sums .values , 1.0 , atol = 1e-10 )
418317
419- # Take log of posteriors
420- log_posterior = np .log (
421- results .acausal_posterior .values + 1e-300
422- ) # Avoid log(0)
318+ # Invariant 2: posteriors are in [0, 1].
319+ assert np .all (posterior >= 0.0 )
320+ assert np .all (posterior <= 1.0 )
423321
424- # Should not have NaN
322+ # Invariant 3: log(posterior) is finite or -inf (never NaN).
323+ log_posterior = np .log (posterior + 1e-300 ) # avoid log(0)
425324 assert not np .any (np .isnan (log_posterior ))
426- # Should be finite or -inf
427325 assert np .all (np .isfinite (log_posterior ) | np .isneginf (log_posterior ))
0 commit comments