|
1 | 1 | """ |
2 | | -Compute binned kernel activation from sparse events. |
| 2 | +Compute binned kernel activation from events. |
3 | 3 |
|
4 | 4 | This module provides efficient computation of kernel-convolved features |
5 | 5 | at a lower output rate than the input. For exponential and alpha kernels, |
6 | 6 | uses a state-based approach that is O(n_events + n_bins) instead of |
7 | 7 | O(n_samples). |
| 8 | +
|
| 9 | +Input may be either ``sparse.COO`` (the typical output of |
| 10 | +:class:`ezmsg.event.peak.ThresholdCrossingTransformer` in default mode) or a |
| 11 | +dense array (from the same transformer with ``output_format=DENSE``). When the |
| 12 | +input is dense and the configuration is COUNT + SUM (the rate-computation |
| 13 | +case), the binning runs on the input's array namespace and stays on device |
| 14 | +(e.g., MLX, CuPy). Other configurations with dense input fall back to |
| 15 | +event-extraction and use the same code path as sparse input. |
8 | 16 | """ |
9 | 17 |
|
10 | 18 | from enum import Enum |
11 | 19 |
|
12 | 20 | import ezmsg.core as ez |
13 | 21 | import numpy as np |
14 | 22 | import numpy.typing as npt |
| 23 | +import sparse |
| 24 | +from array_api_compat import get_namespace, is_numpy_array |
15 | 25 | from ezmsg.baseproc import BaseStatefulTransformer, BaseTransformerUnit, processor_state |
16 | 26 | from ezmsg.util.messages.axisarray import AxisArray, replace |
17 | 27 |
|
@@ -122,7 +132,8 @@ def _hash_message(self, message: AxisArray) -> int: |
122 | 132 | n_channels = message.data.shape[message.get_axis_idx("ch")] if "ch" in message.dims else 1 |
123 | 133 | if "time" not in message.axes or not hasattr(message.axes["time"], "gain"): |
124 | 134 | raise ValueError("Could not determine sample rate from input message") |
125 | | - return hash((message.data.ndim, message.data.dtype.kind, n_channels, message.axes["time"].gain)) |
| 135 | + # str(dtype) works for numpy ('bool', 'float32', ...) and mlx (which doesn't expose dtype.kind). |
| 136 | + return hash((message.data.ndim, str(message.data.dtype), n_channels, message.axes["time"].gain)) |
126 | 137 |
|
127 | 138 | def _reset_state(self, message: AxisArray) -> None: |
128 | 139 | """Initialize state for new input stream.""" |
@@ -197,6 +208,31 @@ def _get_activation_at_sample(self, channel: int, sample: int) -> float: |
197 | 208 | return self._state.activation[channel] |
198 | 209 |
|
199 | 210 | def _process(self, message: AxisArray) -> AxisArray: |
| 211 | + """Compute binned activation from sparse or dense event input. |
| 212 | +
|
| 213 | + Dispatch: |
| 214 | + - Dense input + COUNT + SUM: fast path that stays on the input's array |
| 215 | + namespace (e.g., MLX, CuPy on device). |
| 216 | + - Dense input + any other config: extract events from non-zero entries |
| 217 | + and use the same code path as sparse input. |
| 218 | + - Sparse input: existing event-based path. |
| 219 | + """ |
| 220 | + data = message.data |
| 221 | + is_sparse_input = isinstance(data, sparse.SparseArray) |
| 222 | + |
| 223 | + if not is_sparse_input: |
| 224 | + if ( |
| 225 | + self.settings.kernel_type == ActivationKernelType.COUNT |
| 226 | + and self.settings.aggregation == BinAggregation.SUM |
| 227 | + ): |
| 228 | + return self._process_dense_count_sum(message) |
| 229 | + # Fall back: convert dense to sparse so the existing event-based path can run. |
| 230 | + data_np = data if is_numpy_array(data) else np.asarray(data) |
| 231 | + message = replace(message, data=sparse.COO.from_numpy(data_np)) |
| 232 | + |
| 233 | + return self._process_events(message) |
| 234 | + |
| 235 | + def _process_events(self, message: AxisArray) -> AxisArray: |
200 | 236 | """Compute binned activation from sparse events.""" |
201 | 237 | sparse_data = message.data |
202 | 238 | n_samples = sparse_data.shape[0] |
@@ -348,6 +384,105 @@ def _process(self, message: AxisArray) -> AxisArray: |
348 | 384 | }, |
349 | 385 | ) |
350 | 386 |
|
| 387 | + def _process_dense_count_sum(self, message: AxisArray) -> AxisArray: |
| 388 | + """Fast path: dense input + COUNT kernel + SUM aggregation. |
| 389 | +
|
| 390 | + Bins are summed using cumulative-sum arithmetic in the input's array |
| 391 | + namespace, so accelerator-resident inputs (MLX, CuPy) stay on device. |
| 392 | + Carry-over for the partial bin spanning chunk boundaries is held in |
| 393 | + ``state.activation`` (numpy) and shuttled across boundaries. |
| 394 | + """ |
| 395 | + xp = get_namespace(message.data) |
| 396 | + data = message.data |
| 397 | + n_samples = data.shape[0] |
| 398 | + feature_shape = tuple(data.shape[1:]) |
| 399 | + |
| 400 | + samples_per_bin = self.settings.bin_duration * self._state.fs |
| 401 | + accumulator_before = self._state.bin_accumulator |
| 402 | + total_samples = n_samples + accumulator_before |
| 403 | + n_bins = int(total_samples / samples_per_bin) |
| 404 | + |
| 405 | + # Per-sample contribution: 1 per non-zero, or the value itself if scaling. |
| 406 | + # Use the .astype() method form so the same call works for both numpy and mlx |
| 407 | + # (mlx.core has no top-level astype). |
| 408 | + if n_samples == 0: |
| 409 | + contrib = xp.zeros((0,) + feature_shape, dtype=xp.float32) |
| 410 | + elif self.settings.scale_by_value: |
| 411 | + contrib = data.astype(xp.float32) |
| 412 | + else: |
| 413 | + contrib = (data != 0).astype(xp.float32) |
| 414 | + |
| 415 | + # Pull state into the input namespace for on-device math. |
| 416 | + overflow_xp = xp.asarray(self._state.activation.reshape(feature_shape)).astype(xp.float32) |
| 417 | + |
| 418 | + if n_bins == 0: |
| 419 | + # No complete bins this chunk — accumulate everything into the carry-over. |
| 420 | + new_overflow = overflow_xp + (xp.sum(contrib, axis=0) if n_samples > 0 else overflow_xp * 0) |
| 421 | + self._state.activation = np.asarray(new_overflow).reshape(self._state.activation.shape) |
| 422 | + self._state.bin_accumulator = total_samples |
| 423 | + return replace( |
| 424 | + message, |
| 425 | + data=xp.zeros((0,) + feature_shape, dtype=xp.float32), |
| 426 | + axes={ |
| 427 | + **message.axes, |
| 428 | + "time": replace(message.axes["time"], gain=self.settings.bin_duration), |
| 429 | + }, |
| 430 | + ) |
| 431 | + |
| 432 | + # Bin boundaries (in input-sample space, integer-truncated as in the event-based path). |
| 433 | + first_bin_end = samples_per_bin - accumulator_before |
| 434 | + bin_ends_float = first_bin_end + np.arange(n_bins) * samples_per_bin |
| 435 | + bin_end_samples = bin_ends_float.astype(np.int64) |
| 436 | + bin_start_samples = np.concatenate(([np.int64(0)], bin_end_samples[:-1])) |
| 437 | + |
| 438 | + # Cumulative sum, prepended with zeros so cumsum_padded[k] = sum(contrib[:k]). |
| 439 | + # Use cumsum (in both numpy and mlx); numpy via array_api_compat also exposes |
| 440 | + # the standard `cumulative_sum`, but mlx does not. |
| 441 | + cumsum = xp.cumsum(contrib, axis=0) |
| 442 | + zero_row = xp.zeros((1,) + feature_shape, dtype=cumsum.dtype) |
| 443 | + cumsum_padded = xp.concat((zero_row, cumsum), axis=0) |
| 444 | + |
| 445 | + end_idx = xp.asarray(bin_end_samples) |
| 446 | + start_idx = xp.asarray(bin_start_samples) |
| 447 | + bin_sums = xp.take(cumsum_padded, end_idx, axis=0) - xp.take(cumsum_padded, start_idx, axis=0) |
| 448 | + |
| 449 | + # Add carry-over from the previous chunk's partial bin into bin 0. |
| 450 | + overflow_pad_first = overflow_xp[None, ...] |
| 451 | + if n_bins > 1: |
| 452 | + overflow_pad_rest = xp.zeros((n_bins - 1,) + feature_shape, dtype=bin_sums.dtype) |
| 453 | + overflow_pad = xp.concat((overflow_pad_first, overflow_pad_rest), axis=0) |
| 454 | + else: |
| 455 | + overflow_pad = overflow_pad_first |
| 456 | + output = bin_sums + overflow_pad |
| 457 | + |
| 458 | + # New carry-over: events past the last complete bin remain in the partial bin. |
| 459 | + last_bin_end = int(bin_end_samples[-1]) |
| 460 | + if last_bin_end < n_samples: |
| 461 | + new_overflow = xp.sum(contrib[last_bin_end:], axis=0) |
| 462 | + else: |
| 463 | + new_overflow = xp.zeros(feature_shape, dtype=cumsum.dtype) |
| 464 | + self._state.activation = np.asarray(new_overflow).reshape(self._state.activation.shape) |
| 465 | + self._state.bin_accumulator = total_samples - n_bins * samples_per_bin |
| 466 | + |
| 467 | + if self.settings.rate_normalize: |
| 468 | + output = output / self.settings.bin_duration |
| 469 | + |
| 470 | + accumulator_time = accumulator_before / self._state.fs |
| 471 | + input_offset = message.axes["time"].offset if "time" in message.axes else 0.0 |
| 472 | + output_offset = input_offset - accumulator_time |
| 473 | + |
| 474 | + return replace( |
| 475 | + message, |
| 476 | + data=output, |
| 477 | + axes={ |
| 478 | + **message.axes, |
| 479 | + "time": AxisArray.TimeAxis( |
| 480 | + fs=1.0 / self.settings.bin_duration, |
| 481 | + offset=output_offset, |
| 482 | + ), |
| 483 | + }, |
| 484 | + ) |
| 485 | + |
351 | 486 |
|
352 | 487 | class BinnedKernelActivationUnit( |
353 | 488 | BaseTransformerUnit[ |
|
0 commit comments