/ proof-of-concept / audio-spike-sorting
Sign in
proof-of-concept / audio-spike-sorting
Add a coincidence knob: a fraction of overlapping events land within a small window of their host's onset, the hardest case for detection and one the scorer's greedy matching handles with a documented caveat
Jeremy Magland <jmagland@flatironinstitute.org> committed commit df3f6136c928 parent 32dec4f Browse files
9 changed files+102−11
README.mdmodified+26−5View file
@@ -42,7 +42,14 @@ between them.
4242 information about identity; at the default of 0 dB no adjustment is
4343 made. An overlap knob in [0, 1] sets the fraction of events
4444 deliberately placed inside another event's span; the rest keep at
45- least one snippet length of separation. Run r uses seed + r, so the
45+ least one snippet length of separation. A coincidence knob, also in
46+ [0, 1], then sets what fraction of those overlapping events land on
47+ their host's onset instead of at a random offset within its span,
48+ within an adjustable window (default 5 ms either side). This is the
49+ hardest case the synthesis can produce: two onsets no detector can
50+ separate in time, and a mixture the classifier must take apart to
51+ name both. It is off by default, so datasets from earlier
52+ configurations reproduce unchanged. Run r uses seed + r, so the
4653 whole benchmark is reproducible from the configuration.
4754 4. **Execution.** Each algorithm is a `.m` file defining
4855 `function [times, labels] = name(x, fs, training)`, where `training`
@@ -102,9 +109,15 @@ Four built-ins seed the comparison and can be edited or reset in place:
102109
103110 On the synthetic demo data (three types, 20% overlap, 12 dB of level
104111 variation), the baselines land around 0.6 to 0.8 accuracy, with the
105-losses concentrated in the overlapping events. None of the built-ins attempts overlap resolution
106-(subtraction or joint fitting), which is exactly the direction candidate
107-algorithms are expected to explore.
112+losses concentrated in the overlapping events. Turning the coincidence
113+knob up costs another few points: at 40% overlap on the same data,
114+onset_knn falls from about 0.57 to 0.50 when every overlapping event is
115+made coincident, since two onsets inside 5 ms present as one detection
116+and the second event is scored as a miss. None of the built-ins attempts
117+overlap resolution (subtraction or joint fitting), which is exactly the
118+direction candidate algorithms are expected to explore, and coincident
119+events are the case where that resolution has to be joint rather than
120+sequential.
108121
109122 ## Running it
110123
@@ -136,6 +149,14 @@ snippet library can be shared or committed.
136149 to place events over a recorded room-tone bed.
137150 - The matching is greedy rather than globally optimal; with a 20 ms
138151 tolerance and events at realistic densities the difference is
139- negligible, but it is not the Hungarian algorithm.
152+ negligible, but it is not the Hungarian algorithm. Note that
153+ coincident events are the case where greedy matching is least safe:
154+ two truth events within the match tolerance of each other compete for
155+ the same detections, and the closest-pair-first rule can assign a
156+ detection to the wrong one of the pair. The effect is on which of two
157+ simultaneous events is called correct, not on the totals.
158+- A coincident event is placed on top of a host that may itself be
159+ coincident, so at a high coincidence fraction the synthesis produces
160+ occasional pile-ups of three or more events rather than only pairs.
140161 - Algorithm execution is single-threaded per run, so per-algorithm
141162 timings are comparable but a benchmark with many runs takes their sum.
scripts/test-node.tsmodified+29−0View file
@@ -24,6 +24,8 @@ const cfg: SynthConfig = {
2424 eventsPerType: 30,
2525 gainMinDb: -12,
2626 overlap: 0.2,
27+ coincidence: 0,
28+ coincidenceMs: 5,
2729 trainFraction: 0.3,
2830 seed: 1,
2931 };
@@ -63,6 +65,33 @@ const mean = (v: number[]) => v.reduce((a, b) => a + b, 0) / Math.max(v.length,
6365 console.log('\nmean accuracy over runs:');
6466 for (const a of algos) console.log(` ${a.name.padEnd(26)} ${mean(acc[a.name] ?? []).toFixed(3)}`);
6567
68+// The coincidence knob must actually place events near-simultaneously:
69+// with every overlapper aimed at its host's onset, most consecutive
70+// ground-truth pairs closer than a snippet length should be inside the
71+// coincidence window, where they are outside it when the knob is off.
72+function nearPairs(coincidence: number, windowMs: number): { close: number; overlapping: number } {
73+ const ds = synthesize(lib, { ...cfg, overlap: 0.5, coincidence, coincidenceMs: windowMs }, 0);
74+ let close = 0, overlapping = 0;
75+ for (let i = 1; i < ds.truth.length; i++) {
76+ const dtMs = (ds.truth[i].t - ds.truth[i - 1].t) * 1000;
77+ if (dtMs < 200) overlapping++;
78+ if (dtMs <= windowMs) close++;
79+ }
80+ return { close, overlapping };
81+}
82+const off = nearPairs(0, 5);
83+const on = nearPairs(1, 5);
84+console.log(`\ncoincidence off: ${off.close}/${off.overlapping} overlapping pairs within 5 ms`);
85+console.log(`coincidence on: ${on.close}/${on.overlapping} overlapping pairs within 5 ms`);
86+if (!(on.close > 0.6 * on.overlapping)) {
87+ console.error('FAIL: coincidence knob did not place events near-simultaneously');
88+ process.exit(1);
89+}
90+if (!(off.close < 0.35 * off.overlapping)) {
91+ console.error('FAIL: events cluster within the coincidence window with the knob off');
92+ process.exit(1);
93+}
94+
6695 const mf = mean(acc['template_matched_filter'] ?? []);
6796 const knn = mean(acc['onset_knn'] ?? []);
6897 if (!(mf > 0.7)) { console.error('FAIL: matched filter accuracy too low'); process.exit(1); }
scripts/test-offline.tsmodified+1−1View file
@@ -26,7 +26,7 @@ import naiveSrc from '../src/algorithms/naive_threshold.m?raw';
2626 const lib = makeSyntheticLibrary();
2727 const cfg: SynthConfig = {
2828 durationS: 20, eventsPerType: 15, gainMinDb: -12,
29- overlap: 0.2, trainFraction: 0.3, seed: 1,
29+ overlap: 0.2, coincidence: 0, coincidenceMs: 5, trainFraction: 0.3, seed: 1,
3030 };
3131 const NRUNS = 2;
3232 const TOL_MS = 20;
src/bench/synthesize.tsmodified+17−4View file
@@ -3,9 +3,13 @@
33 * set and a placement pool, then place events on a silent timeline. The
44 * overlap knob controls how events land: a "clean" event keeps at least one
55 * snippet-length of separation from everything placed so far, an
6- * "overlapper" is aimed inside the span of an already-placed event. Each
7- * placed snippet gets a random gain in dB, so level carries no information
8- * about identity. Ground truth is exact by construction.
6+ * "overlapper" is aimed inside the span of an already-placed event. The
7+ * coincidence knob then decides what fraction of those overlappers land on
8+ * their host's onset rather than at a random offset within its span, within
9+ * a window of a few milliseconds, which is the hardest case: two onsets a
10+ * detector cannot separate in time and a mixture a classifier must take
11+ * apart. Each placed snippet gets a random gain in dB, so level carries no
12+ * information about identity. Ground truth is exact by construction.
913 */
1014 import { mulberry32, shuffle, type Rng } from './rng.ts';
1115 import type { Dataset, GroundTruthEvent, SnippetLibrary, SynthConfig } from './types.ts';
@@ -38,6 +42,8 @@ export function synthesize(lib: SnippetLibrary, cfg: SynthConfig, runIndex: numb
3842 const rng = mulberry32(cfg.seed + runIndex);
3943 const { train, pool } = splitSnippets(lib, cfg.trainFraction, rng);
4044 const snipLen = lib.pre + lib.post;
45+ const coincidence = cfg.coincidence ?? 0;
46+ const coincWin = Math.round(((cfg.coincidenceMs ?? 0) / 1000) * FS);
4147 const n = Math.round(cfg.durationS * FS);
4248 const margin = snipLen; // keep events off the very edges
4349
@@ -62,7 +68,14 @@ export function synthesize(lib: SnippetLibrary, cfg: SynthConfig, runIndex: numb
6268 let start = -1;
6369 if (overlapper) {
6470 const host = placed[Math.floor(rng() * placed.length)];
65- const off = Math.round((rng() - 0.5) * snipLen);
71+ // A coincident overlapper starts within +/- coincidenceMs of its
72+ // host; an ordinary one anywhere inside the host's span. The
73+ // coincidence draw is skipped entirely when the knob is off, so a
74+ // seed that predates it still produces the same dataset.
75+ const coincident = coincidence > 0 && rng() < coincidence;
76+ const off = coincident
77+ ? Math.round((2 * rng() - 1) * coincWin)
78+ : Math.round((rng() - 0.5) * snipLen);
6679 const s = host.start + off;
6780 if (s >= 0 && s + snipLen <= n) start = s;
6881 }
src/bench/types.tsmodified+11−0View file
@@ -35,6 +35,17 @@ export interface SynthConfig {
3535 /** Overlap knob in [0, 1]: fraction of events deliberately placed inside
3636 * another event's span; the rest keep a clean separation. */
3737 overlap: number;
38+ /** Coincidence knob in [0, 1]: of the overlapping events, the fraction
39+ * aimed at their host's onset instead of a random offset within its
40+ * span. A coincident event starts within +/- coincidenceMs of the host,
41+ * so the two onsets fall inside one detector's resolution and, for a
42+ * small window, inside one scoring match window as well. 0 leaves every
43+ * overlapper at a random offset, which is the pre-existing behaviour. */
44+ coincidence: number;
45+ /** Half-width in milliseconds of the coincident placement window: a
46+ * coincident event starts uniformly within +/- this of its host. 0
47+ * places the two events at exactly the same sample. */
48+ coincidenceMs: number;
3849 /** Fraction of each type's snippets given to the algorithm as training;
3950 * the rest form the placement pool. */
4051 trainFraction: number;
src/export/assets/OFFLINE_README.mdmodified+4−0View file
@@ -23,6 +23,10 @@ scores.
2323 - Overlap fraction: {{OVERLAP}} of events deliberately placed inside
2424 another event's span; the rest keep at least one snippet length of
2525 separation
26+- Coincident fraction: {{COINCIDENCE}} of those overlapping events placed
27+ at the onset of the event they overlap, within {{COINCIDENCE_MS}} ms
28+ either side, so the two onsets are nearly simultaneous; the remaining
29+ overlappers sit at a random offset inside the host's span
2630 - Training fraction: {{TRAIN_FRACTION}} of each type's snippets went to
2731 the training set, drawn independently per run; the rest were placed
2832 - Match tolerance for scoring: {{TOLERANCE_MS}} ms
src/export/offline.tsmodified+4−1View file
@@ -22,7 +22,8 @@ export interface OfflineExportInput {
2222 toleranceMs: number;
2323 config: {
2424 durationS: number; eventsPerType: number; gainMinDb: number;
25- overlap: number; trainFraction: number; seed: number;
25+ overlap: number; coincidence: number; coincidenceMs: number;
26+ trainFraction: number; seed: number;
2627 };
2728 algorithms: { source: string }[];
2829 runs: {
@@ -92,6 +93,8 @@ export function buildOfflineFiles(input: OfflineExportInput): OfflineFile[] {
9293 PRE: String(input.pre),
9394 GAIN_MIN_DB: String(input.config.gainMinDb),
9495 OVERLAP: String(input.config.overlap),
96+ COINCIDENCE: String(input.config.coincidence),
97+ COINCIDENCE_MS: String(input.config.coincidenceMs),
9598 TRAIN_FRACTION: String(input.config.trainFraction),
9699 TOLERANCE_MS: String(input.toleranceMs),
97100 };
src/main.tsmodified+8−0View file
@@ -340,6 +340,14 @@ const CONFIG_FIELDS: ConfigField[] = [
340340 title: 'Each placed event is scaled by a random gain drawn between this many dB below its recorded volume and 0 dB. 0 keeps every event at its original volume.',
341341 },
342342 { key: 'overlap', label: 'overlap fraction (0-1)', min: 0, max: 1, step: 0.05 },
343+ {
344+ key: 'coincidence', label: 'coincident fraction (0-1)', min: 0, max: 1, step: 0.05,
345+ title: 'Of the overlapping events, the fraction placed at their host event\u2019s onset rather than at a random offset inside its span, so the two onsets are separated by at most the coincidence window below. 0 leaves every overlapping event at a random offset.',
346+ },
347+ {
348+ key: 'coincidenceMs', label: 'coincidence window (ms)', min: 0, max: 100, step: 1,
349+ title: 'Half-width of the coincident placement window: a coincident event starts within this many milliseconds either side of its host. 0 places the two events at exactly the same sample.',
350+ },
343351 { key: 'trainFraction', label: 'training fraction (0-1)', min: 0.05, max: 0.95, step: 0.05 },
344352 { key: 'runs', label: 'runs', min: 1, max: 50, step: 1 },
345353 { key: 'seed', label: 'base seed', min: 0, max: 1e9, step: 1 },
src/store.tsmodified+2−0View file
@@ -34,6 +34,8 @@ export const DEFAULT_CONFIG: BenchConfig = {
3434 eventsPerType: 30,
3535 gainMinDb: 0,
3636 overlap: 0.2,
37+ coincidence: 0,
38+ coincidenceMs: 5,
3739 trainFraction: 0.3,
3840 seed: 1,
3941 preMs: 20,
↑↓ move↵openescclose