Skip to content

Commit 45ec143

Browse files
edenoclaude
andcommitted
Collapse three flaky decoder property tests into one parameterized test
The three decoder property tests (test_posterior_probabilities_sum_to_one, test_posteriors_nonnegative_and_bounded, test_log_probabilities_finite) had identical setup, differed only in which invariant they checked at the end, and each ran a full ClusterlessDecoder fit+predict for every Hypothesis example. With max_examples=10 that was 30 fit+predict cycles total per test-suite run. The first example in each test's first execution also paid full JAX JIT compile cost (~3s laptop, ~5-9s CI), which raced the @settings(deadline=5000) ceiling and caused sporadic FlakyFailure deadline overruns on slower GitHub Actions runners. Hypothesis shrinking added nothing here: the strategy was just integers(42, 9999) — parameterized testing wearing a Hypothesis costume. The mathematical invariants being checked are fully orthogonal (sums to 1; in [0,1]; log finite) so they can all be verified on a single fit. Replace the three @given tests with one pytest.mark.parametrize test over 5 hand-picked seeds that runs fit+predict once per seed and checks all three invariants on that single result. This: - cuts cold-start work from 3× to 1× (no more paying JIT three times), - drops the per-example deadline entirely (pytest has no such thing), - keeps the same invariant coverage across a spread of seeds, - runs 5 seeds × ~1.2s warm = ~7s total local wall time vs 30+ cycles flaky at 5s each in the old design. Locally: 5 passed in 7.21s, cold seed at 2.76s, warm seeds at ~1.0s. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 18a47dd commit 45ec143

1 file changed

Lines changed: 48 additions & 150 deletions

File tree

src/non_local_detector/tests/properties/test_probability_properties.py

Lines changed: 48 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)