Repository navigation
Fit amplitude scalings with a numba kernel on the normal equations - #4870
galenlynch wants to merge 6 commits into
Conversation
The collision fit built a mostly-zero design matrix per collision and called scipy's nnls on it, which dominated the trace pass of postprocessing. Least squares needs only the Gram matrix and its product with the waveform, and when templates are zero outside their unit's sparsity each entry is a sum over the channels two templates share. A numba kernel now computes those terms, solves the non-negative fit by Lawson-Hanson on the k x k system, and also computes the ordinary per-spike slopes. The kernel is used when numba is installed and every template is zero outside its sparsity mask; otherwise the numpy path runs unchanged. Tests compare the two paths spike by spike, with and without gains, collisions, duplicates and a sparser explicit sparsity.
Replace the hand-written Cholesky factorization and triangular solves with np.linalg.cholesky and np.linalg.solve, which numba compiles through LAPACK. A subsystem is still treated as singular when a pivot falls below 1e-12 of its diagonal entry.
Comment which numpy function each part of the kernel reproduces, why the normal equations give the same fit as fit_collision's design matrix, and why NNLS is written out rather than called from scipy. Docstrings now describe use only, and the explanation is in comments next to the code.
6e950ed to
fa7d6cb
Compare
|
Hi @galenlynch, thanks for the mention and for building on the previous changes! I checked Two cases showed different behaviour through the public API:
There is also a numerical boundary worth checking. For two almost-identical templates, with a centred design-matrix condition number around |
The normal equations lose precision with the square of the design matrix's condition number. For nearly identical templates the kernel stopped early and gave all of the amplitude to one spike, where fit_collision splits it. The kernel now checks each collision's conditioning once and leaves those it cannot solve to float32 precision to fit_collision, as it does collisions with non-finite traces, so NaN raises the same ValueError as the numpy path.
Numba reads neither float16 nor non-native byte order, so without gains the kernel failed on traces the numpy path accepts. Both now convert exactly before the kernel. fit_collision built its template columns in the traces' dtype, which truncated templates for unscaled integer or float16 traces; they are now float64, like the design matrix.
|
Thanks for testing it on ARM, @JESUSROYETH. All three cases were real. I added two new commits fix them, and added regression tests for each case.
On 10 s of a Neuropixels recording, none of the 45,786 collisions fell back, the float32 output still matches the numpy path bit for bit, and the kernel time is unchanged at 170 ms per chunk. |
|
Thanks @galenlynch! Retested the original cases on the updated version, all three are fixed and the 34 tests pass locally. LGTM from my side. @alejoe91 @chrishalcrow, would be good to get your thoughts on this optimisation too. |
amplitude_scalingsis now 13 times faster on dense Neuropixels data, with the same results.NP 2.0 recording, 519 units and
radius_um=100sparsity, on an i9-12900K. The 16-job times are from 60 s holding 311,699 spikes; the 1-job times are from 10 s, multiplied by 6.On this recording 91 % of spikes collide, so most of the time goes to
fit_collision. For each collision it builds a design matrix, mostly zeros, and passes it toscipy.optimize.nnls. Least squares needs only the matrix's Gram matrix and its product with the waveform. When templates are zero outside their sparsity, each entry of those is a sum over the channels two templates share. The new kernel in_amplitude_scalings_numba.pycomputes those sums, reusing each spike's terms across its collisions, and solves the k × k non-negative fit by Lawson–Hanson. It also fits the spikes that don't collide.This builds on @JESUSROYETH's #4759, #4764, #4786 and #4797, and reuses the unit overlap matrix from #4797.
The kernel runs when numba is installed and every template is zero outside its sparsity mask. Otherwise, as with an explicit
sparsitysparser than the templates, the numpy path runs unchanged. The kernel is a plain@njit, son_jobsalone sets the parallelism.The tests compare the two paths spike by spike to
rtol=1e-9, on synthetic chunks with non-contiguous sparsity, negative amplitudes, a zero template, spikes at both chunk edges, gains of none, float32 and float64, collision handling on and off, and duplicate spikes. A further test runs the whole extension both ways, including the fallback. On 10 s of the real recording, the two paths give identical float32 output.