Skip to content

Commit 3692d31

Browse files
w4ffl35claude
andcommitted
Shuffle event training batches, and test the reader that landed without tests
SSC's training split is sorted by class -- 35 contiguous runs over 75,466 samples. The image path has always built its loader with shuffle=train; event_batches read range(start, stop) in order and had no shuffle at all, so every batch was a single class in class order and the network learned only to name whichever class it was currently being shown. Held-out accuracy went from 3.84% to 11.88% against 2.86% chance once the order was fixed. No test could have caught it. The synthetic fixture labels samples index % num_classes, so sixteen consecutive indices carry sixteen distinct classes -- accidentally perfect interleaving, and the exact opposite of what a real recording does. It took a real dataset to expose it, which is the fourth time in this area that a fixture was better behaved than reality. event_batches now takes shuffle and seed; the engine passes shuffle=train. The default stays sequential, so the dashboard's demo pass is unchanged. Also here: tests/test_hsd_reader.py, which belonged with the reader in db0c338 and was left untracked when that commit swept the reader up under a message about documentation links. The reader has had no tests in history until now. Its tripwire on tonic's arithmetic is gated on the promotion rule it depends on. Asserting the multiply stays float16 only holds under NEP 50; NumPy 1.x widens it to float32 and the timestamps survive, so on a legacy NumPy -- and this project supports numpy>=1.26 -- the premise does not exist and the test was failing for the wrong reason. It now skips there, naming the version, and still fires on NumPy 2. The SSC row is still not published. Three 3-epoch configurations reach 9-12%, all with loss barely under ln(35) = 3.555, so the stock defaults are genuinely a poor fit for audio rather than still broken. Publishing that is a judgement about what a catalog slot should mean, and separately the hub's compatibility layer builds its comparison preset with no parameters, so it rejects any checkpoint that is not 10-class. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent db0c338 commit 3692d31

9 files changed

Lines changed: 397 additions & 26 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,32 @@ that and describe the local source tree only.
1313

1414
### Added
1515

16+
- **Heidelberg audio splits are read directly, bypassing tonic's decode.**
17+
`spikeforge/events/hsd_reader.py` reads the SHD/SSC HDF5 itself and scales
18+
seconds to microseconds in `float64`, which recovers every timestamp tonic
19+
loses (see **Fixed**). It replaces exactly one thing — the per-sample decode.
20+
Tonic still downloads the dataset, extracts it, owns the cache layout, and
21+
supplies the sensor geometry; the reader is handed the constructed tonic
22+
dataset and consults it for all of that. The emitted stream keeps tonic's own
23+
`(t, x, p)` structured layout, so nothing downstream can tell the two paths
24+
apart.
25+
26+
Routing is declared on the dataset, not hard-coded in the loader:
27+
`DatasetSpec.native_reader` names the reader, and only `ssc` sets it — the
28+
DVS datasets keep tonic's decode, which is correct for them. `h5py` arrives
29+
with tonic itself and is imported defensively, so this module is to `h5py`
30+
what `tonic_api` is to `tonic`: the only one that imports it.
31+
32+
Pinning `numpy<2` would also have worked and was rejected: a published number
33+
whose reproduce command silently yields single-bin garbage on numpy 2 is not
34+
reproducible. This fix is correct on any numpy, and
35+
`tests/test_hsd_reader.py` asserts the broken promotion *is* still broken, so
36+
the workaround cannot quietly outlive its reason.
37+
38+
It also opens the HDF5 once rather than per `__getitem__` as tonic does,
39+
which is what lets the source's open-once caching actually pay off here:
40+
0.50 ms/sample against 2.29 ms through tonic.
41+
1642
- **Auditory event sensors load.** A cochlea has channels, not pixel rows:
1743
tonic's SHD and SSC declare `sensor_size = (700, 1, 1)` and their streams
1844
carry `(t, x, p)` with no `y` field at all, so `events_to_sample` raised
@@ -125,11 +151,33 @@ that and describe the local source tree only.
125151

126152
### Fixed
127153

154+
- **Event training never shuffled, so a class-ordered dataset trained one class
155+
per batch.** The image path builds its loader with `shuffle=train`; the event
156+
path read `range(start, stop)` strictly in order and had no shuffle anywhere.
157+
Real event datasets ship grouped by class — SSC's training split is exactly
158+
**35 contiguous runs over 75,466 samples** — so every batch contained a
159+
single class in class order, and the network learned only to name whichever
160+
class it was currently being shown. Measured on SSC: **3.84%** unshuffled
161+
against a 2.86% chance baseline on 35 classes, rising to **11.88%** once the
162+
training split is visited in a seeded random order.
163+
164+
The synthetic fixture hid this completely. Its labels are
165+
`index % num_classes`, so consecutive indices cycle through every class —
166+
accidentally perfect interleaving, which is why no existing test caught it
167+
and why it only surfaced against a real recording.
168+
169+
`event_batches` now takes `shuffle` and `seed`; the engine passes
170+
`shuffle=train`, mirroring the image loader exactly, and the order is seeded
171+
so a published row stays reproducible. The default is sequential, so the
172+
dashboard and every existing caller are unchanged.
173+
128174
- **Tonic destroys every SHD and SSC timestamp, and the pipeline would have
129175
trained on it silently.** The Heidelberg files store `spikes/times` as
130176
`float16`, whose maximum is 65504. Tonic's reader converts seconds to
131-
microseconds with `times * 1e6`, which overflows float16 to `inf`, becomes
132-
`NaN`, and casts to `INT64_MIN` — for **every timestamp in every sample**,
177+
microseconds with `times * 1e6`; under NumPy 2's NEP 50 promotion that stays
178+
in `float16`, where `1e6` itself does not fit — so the scale factor becomes
179+
`inf`, every product is `inf` (or `NaN` where the timestamp is 0), and the
180+
cast to `int64` yields `INT64_MIN` for **every timestamp in every sample**,
133181
in both SHD and SSC, on tonic 1.4.3. Our binning then saw a zero-width span
134182
and collapsed all events into the first time step: a spiking network trained
135183
on that has had all of its timing removed, and would still report a

‎documentation/event-datasets.md‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,15 @@ of decoding a missing image.
158158
is still an error.
159159
- **🔴 SHD and SSC cannot currently be trained on.** The Heidelberg files
160160
store timestamps as `float16`, and tonic's reader scales them by `1e6` —
161-
which overflows float16 to `inf`, then `NaN`, then `INT64_MIN` for every
162-
timestamp in every sample. All timing is gone before the data reaches us.
163-
`EventTimestampError` refuses such a stream rather than binning every event
164-
into the first time step and reporting a number measured on it. Verified
165-
against tonic 1.4.3 on both datasets; it needs an upstream fix.
161+
which under NumPy 2 stays in `float16`, where `1e6` does not fit, so every
162+
timestamp becomes `inf`/`NaN` and casts to `INT64_MIN`. All timing is gone
163+
before the data reaches us. `EventTimestampError` refuses such a stream
164+
rather than binning every event into the first time step and reporting a
165+
number measured on it. **`ssc` routes around it**: the registry's
166+
`native_reader` field sends it to `spikeforge.events.hsd_reader`, which
167+
reads the HDF5 itself and scales in `float64`. Tonic still downloads the
168+
dataset and still owns the cache layout; only the per-sample decode is
169+
replaced, and only for the datasets tonic decodes wrongly.
166170
- **The DVS datasets cannot currently be downloaded.** `dvs128_gesture` and
167171
`cifar10_dvs` are served from figshare, which answers tonic's downloader
168172
with `HTTP 202` and an empty body (tonic then reports "File not found or

‎scripts/train_reference_models.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ def engine_kwargs(self) -> Dict[str, Any]:
169169
name="ssc-fc-legacy",
170170
dataset="ssc",
171171
topology="fc_legacy",
172-
epochs=1,
172+
epochs=3,
173173
num_steps=25,
174174
batch_size=128,
175175
# A cochlea has 700 channels and no second spatial axis, so the flat

‎spikeforge/data/event_errors.py‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,12 @@ class EventTimestampError(ValueError):
5555
This is not hypothetical. Tonic's SHD/SSC reader scales the file's
5656
timestamps by ``1e6`` to convert seconds to microseconds, but the
5757
Heidelberg files store them as ``float16``, whose maximum is 65504 -- so
58-
the multiply overflows to ``inf``, becomes ``NaN``, and every timestamp
59-
in the sample casts to ``INT64_MIN``. Binning then sees a zero-width
60-
time span and collapses every event into the first time step, which
61-
trains and scores perfectly happily while having destroyed all timing.
62-
A named failure is the only honest outcome.
58+
under NumPy 2 the scale factor itself becomes ``inf`` and every product
59+
is ``inf`` (or ``NaN`` where the timestamp is 0), which casts to
60+
``INT64_MIN``. Binning then sees a zero-width time span and collapses
61+
every event into the first time step, which trains and scores perfectly
62+
happily while having destroyed all timing. A named failure is the only
63+
honest outcome. :mod:`spikeforge.events.hsd_reader` is the way around it.
6364
"""
6465

6566
def __init__(self, detail: str = "") -> None:
@@ -68,8 +69,8 @@ def __init__(self, detail: str = "") -> None:
6869
"event timestamps are negative, so they are not recording times "
6970
f"{detail}; this is what a non-finite timestamp cast to an "
7071
"integer looks like. Tonic's SHD/SSC reader produces it by "
71-
"scaling float16 seconds by 1e6 (the multiply overflows to inf, "
72-
"then NaN, then INT64_MIN), which would silently collapse every "
73-
"event into one time bin. Refusing rather than reporting a "
74-
"number measured on destroyed timing."
72+
"scaling float16 seconds by 1e6, which does not fit in float16 "
73+
"and so becomes inf, then INT64_MIN on cast; that would "
74+
"silently collapse every event into one time bin. Refusing "
75+
"rather than reporting a number measured on destroyed timing."
7576
)

‎spikeforge/events/hsd_reader.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
33
Tonic downloads these datasets correctly and then decodes them wrongly. Its
44
reader converts the file's timestamps from seconds to microseconds with
5-
``times * 1e6``, but the Heidelberg files store them as ``float16`` (maximum
6-
65504). Under NumPy 2's NEP 50 promotion a ``float16`` array times a Python
7-
float stays ``float16``, so the multiply overflows to ``inf``, becomes ``NaN``,
8-
and casts to ``INT64_MIN`` -- for every timestamp in every sample. Binning then
9-
sees a zero-width span and collapses the whole recording into one time step, so
10-
a 25-step spiking network trains on a single static frame and still reports a
5+
``times * 1e6``, but the Heidelberg files store them as ``float16``, whose
6+
maximum is 65504. Under NumPy 2's NEP 50 promotion a ``float16`` array times a
7+
Python float stays ``float16``, and ``1e6`` does not fit: the scale factor
8+
itself becomes ``inf``. Every product is then ``inf`` -- or ``NaN`` wherever
9+
the timestamp is exactly 0 -- and casting either to ``int64`` yields
10+
``INT64_MIN``, for every timestamp in every sample. Binning then sees a
11+
zero-width span and collapses the whole recording into one time step, so a
12+
25-step spiking network trains on a single static frame and still reports a
1113
plausible accuracy.
1214
1315
So this module replaces that one conversion. It does **not** replace tonic:

‎spikeforge/training/event_batches.py‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,34 @@ def batch_event_samples(
5353
return spikes, labels
5454

5555

56+
def visit_order(
57+
total: int, shuffle: bool, seed: Optional[int] = None
58+
) -> List[int]:
59+
"""Return the sample indices one epoch visits, in visiting order.
60+
61+
Sequential unless ``shuffle``. Shuffling is not cosmetic here: real event
62+
datasets arrive grouped by class -- SSC's training split is 35 contiguous
63+
runs over 75,466 samples -- so reading them in order makes every batch a
64+
single class and training collapses to predicting whichever class it is
65+
currently being shown. ``seed`` makes the permutation reproducible, which a
66+
published row needs.
67+
"""
68+
if not shuffle:
69+
return list(range(total))
70+
generator = None
71+
if seed is not None:
72+
generator = torch.Generator().manual_seed(int(seed))
73+
return [int(i) for i in torch.randperm(total, generator=generator)]
74+
75+
5676
def event_batches(
5777
source: EventSampleSource,
5878
spec: TopologySpec,
5979
subset: int,
6080
batch_size: int,
6181
samples: Optional[int] = None,
82+
shuffle: bool = False,
83+
seed: Optional[int] = None,
6284
) -> List[Batch]:
6385
"""Return the epoch's ``(spikes, labels)`` batches in bridge layout.
6486
@@ -73,16 +95,19 @@ def event_batches(
7395
checkpoint claiming the full training split requires. ``subset`` is
7496
bypassed when ``samples`` is given, since the two would otherwise both be
7597
trying to set the same thing.
98+
99+
``shuffle`` visits the samples in a seeded random order, which a training
100+
epoch over a class-ordered dataset requires; see :func:`visit_order`. It
101+
matches the image path, whose loader is built with ``shuffle=train``.
76102
"""
77103
size = max(1, int(batch_size))
78104
total = (
79105
max(1, int(samples))
80106
if samples is not None
81107
else batch_count(subset) * size
82108
)
109+
order = visit_order(total, shuffle, seed)
83110
return [
84-
batch_event_samples(
85-
source, spec, range(start, min(start + size, total))
86-
)
111+
batch_event_samples(source, spec, order[start:start + size])
87112
for start in range(0, total, size)
88113
]

‎spikeforge/training/event_engine.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,18 @@ def _epoch_batches(
130130
``train`` selects which source is read. The two sources opened
131131
different dataset splits, so the batches are different data, not the
132132
same stream under a different name.
133+
134+
The training split is visited in a seeded random order and the
135+
held-out split sequentially, exactly as the image path's loader is
136+
built with ``shuffle=train``. That is load-bearing rather than tidy:
137+
real event datasets ship grouped by class, so reading a training epoch
138+
in order makes every batch a single class.
133139
"""
134140
source = self._event_source if train else self._test_source
135141
return event_batches.event_batches(
136142
source, self._spec, self._subset, self._batch_size,
137143
self._epoch_samples if train else None,
144+
shuffle=train, seed=self._seed,
138145
)
139146

140147
def _load_test_batches(

0 commit comments

Comments
 (0)