From 53dfdb6d222745c350c2888d597435c3f382495e Mon Sep 17 00:00:00 2001 From: benedettoleto Date: Sat, 18 Apr 2026 15:34:23 +0200 Subject: [PATCH 01/14] feat: define temporal contrast decoder algorithm --- spikify/decoders/__init__.py | 1 + spikify/decoders/temporal/__init__.py | 1 + .../decoders/temporal/contrast/__init__.py | 1 + .../temporal/contrast/decoder_algorithm.py | 108 ++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 spikify/decoders/__init__.py create mode 100644 spikify/decoders/temporal/__init__.py create mode 100644 spikify/decoders/temporal/contrast/__init__.py create mode 100644 spikify/decoders/temporal/contrast/decoder_algorithm.py diff --git a/spikify/decoders/__init__.py b/spikify/decoders/__init__.py new file mode 100644 index 0000000..149d474 --- /dev/null +++ b/spikify/decoders/__init__.py @@ -0,0 +1 @@ +"""Decoders package.""" diff --git a/spikify/decoders/temporal/__init__.py b/spikify/decoders/temporal/__init__.py new file mode 100644 index 0000000..e61d8fd --- /dev/null +++ b/spikify/decoders/temporal/__init__.py @@ -0,0 +1 @@ +"""Temporal package.""" diff --git a/spikify/decoders/temporal/contrast/__init__.py b/spikify/decoders/temporal/contrast/__init__.py new file mode 100644 index 0000000..fb9d6d9 --- /dev/null +++ b/spikify/decoders/temporal/contrast/__init__.py @@ -0,0 +1 @@ +"""Temporal Contrast package.""" diff --git a/spikify/decoders/temporal/contrast/decoder_algorithm.py b/spikify/decoders/temporal/contrast/decoder_algorithm.py new file mode 100644 index 0000000..39a578e --- /dev/null +++ b/spikify/decoders/temporal/contrast/decoder_algorithm.py @@ -0,0 +1,108 @@ +""" +.. raw:: html + +

Contrast Decoder

+""" + +import numpy as np + + +def contrast_decoder( + spikes: np.ndarray, + threshold: float | int | list[float | int] | np.ndarray, + start_point: float | int | list[float | int] | np.ndarray, +) -> np.ndarray: + """ + Perform Contrast family decoding on the input spike train. + + This function takes a spike train encoded with any of the Contrast family algorithms, + Threshold-Based Representation (TBR), Step-Forward (SF), or Moving Window (MW), and reconstructs the original + continuous signal. The reconstruction is performed by starting from the initial signal value and applying the + threshold incrementally based on the spike values. + + **Code Example:** + + .. code-block:: python + + import numpy as np + from spikify.decoders.temporal.contrast import contrast_decoder + + spikes = np.array([0, 0, 0, 1, 1, 1], dtype=np.int8) + threshold = 0.2 + startpoint = 0.1 + reconstructed_signal = contrast_decoder(spikes, threshold, startpoint) + reconstructed_signal + + .. doctest:: + :hide: + + >>> import numpy as np + >>> from spikify.decoders.temporal.contrast import contrast_decoder + >>> spikes = np.array([0, 0, 0, 1, 1, 1], dtype=np.int8) + >>> threshold = 0.2 + >>> startpoint = 0.1 + >>> reconstructed_signal = contrast_decoder(spikes, threshold, startpoint) + >>> reconstructed_signal + array([0.1, 0.1, 0.1, 0.3, 0.5, 0.7]) + + :param spikes: The input spike train to be decoded. This should be a numpy ndarray with values in {-1, 0, +1}, + as produced by any of the Contrast family encoders (TBR, SF, MW). + :type spikes: numpy.ndarray + :param threshold: Threshold(s) used during encoding; scalar or 1D sequence matching features. + :type threshold: float | int | list[float | int] | numpy.ndarray + :param start_point: Initial signal value(s) for reconstruction; scalar or 1D sequence matching features. + :type start_point: float | int | list[float | int] | numpy.ndarray + :return: A numpy array representing the reconstructed continuous signal. + :rtype: numpy.ndarray + :raises ValueError: If the input spike train is empty or if the threshold/start_point dimensions do not match + the spike train features. + :raises TypeError: If the threshold or start_point parameter is of invalid dimension. + + """ + # Check for empty spike train + if len(spikes) == 0: + raise ValueError("Spike train cannot be empty.") + + # Ensure 2D processing (T, F) + if spikes.ndim == 1: + spikes = spikes.reshape(-1, 1) + + T, F = spikes.shape + + # Handle threshold + if np.isscalar(threshold): + thresholds = np.full(F, float(threshold)) + else: + thresholds = np.asarray(threshold, dtype=float) + if thresholds.ndim != 1: + raise TypeError("Threshold must be a scalar or a 1D sequence of numbers.") + if thresholds.size != F: + raise ValueError("Threshold must match the number of features in the spike train.") + + # Handle startpoint + if np.isscalar(start_point): + start_points = np.full(F, float(start_point)) + else: + start_points = np.asarray(start_point, dtype=float) + if start_points.ndim != 1: + raise TypeError("Startpoint must be a scalar or a 1D sequence of numbers.") + if start_points.size != F: + raise ValueError("Startpoint must match the number of features in the spike train.") + + signal = np.zeros((T, F), dtype=float) + signal[0] = start_points + + for t in range(1, T): + for f in range(F): + if spikes[t, f] == 1: + signal[t, f] = signal[t - 1, f] + thresholds[f] + elif spikes[t, f] == -1: + signal[t, f] = signal[t - 1, f] - thresholds[f] + else: + signal[t, f] = signal[t - 1, f] + + # Flatten if input was 1D + if F == 1: + signal = signal.flatten() + + return signal From 3822d95c4aa1a49fdb7dff3152bfdb8b4a62501a Mon Sep 17 00:00:00 2001 From: benedettoleto Date: Sat, 18 Apr 2026 15:34:50 +0200 Subject: [PATCH 02/14] docs: add documentation for contrast decoder --- docs/api/python/decoders/index.rst | 17 +++++++++++++++++ .../temporal_decoding/contrast/decoder.rst | 8 ++++++++ .../temporal_decoding/contrast/index.rst | 17 +++++++++++++++++ .../decoders/temporal_decoding/index.rst | 18 ++++++++++++++++++ docs/api/python/index.rst | 1 + 5 files changed, 61 insertions(+) create mode 100644 docs/api/python/decoders/index.rst create mode 100644 docs/api/python/decoders/temporal_decoding/contrast/decoder.rst create mode 100644 docs/api/python/decoders/temporal_decoding/contrast/index.rst create mode 100644 docs/api/python/decoders/temporal_decoding/index.rst diff --git a/docs/api/python/decoders/index.rst b/docs/api/python/decoders/index.rst new file mode 100644 index 0000000..91760de --- /dev/null +++ b/docs/api/python/decoders/index.rst @@ -0,0 +1,17 @@ +.. _decoding: + +:octicon:`file-directory;0.9em;sd-mr-1 fill-primary` decoders +============================================================= + +The ``decoders`` module within the spikify library contains the essential components for converting spike trains back into continuous signals or meaningful representations. This section is organized to reflect the primary structure of the decoding algorithms included in the library: + +- **Temporal Coding**: Encloses algorithms that decode data based on the precise timing of spikes, reconstructing signals from spike timing information. + +Each submodule is dedicated to a specific family of decoding techniques, making it easy to navigate and understand the purpose of each algorithm within the library structure. + +Below, you will find links to the specific modules library for each decoding method: + +.. toctree:: + :maxdepth: 1 + + temporal_decoding/index diff --git a/docs/api/python/decoders/temporal_decoding/contrast/decoder.rst b/docs/api/python/decoders/temporal_decoding/contrast/decoder.rst new file mode 100644 index 0000000..fbc9934 --- /dev/null +++ b/docs/api/python/decoders/temporal_decoding/contrast/decoder.rst @@ -0,0 +1,8 @@ +.. _contrast_decoder_function: + +.. title:: Contrast Decoder + +.. automodule:: spikify.decoders.temporal.contrast.decoder_algorithm + :members: contrast_decoder + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/api/python/decoders/temporal_decoding/contrast/index.rst b/docs/api/python/decoders/temporal_decoding/contrast/index.rst new file mode 100644 index 0000000..df2201f --- /dev/null +++ b/docs/api/python/decoders/temporal_decoding/contrast/index.rst @@ -0,0 +1,17 @@ +.. _contrast_decoder: + +:octicon:`file-directory;0.9em;sd-mr-1 fill-primary` contrast +================================================================ + +The ``contrast`` folder within the temporal decoding module includes the algorithm responsible for reconstructing the original continuous signal from spike trains generated by any of the Contrast family encoders. Since the encoding methods in this family share a common spike representation based on threshold increments, a single unified decoder is able to handle all of them. + +Contents of the ``contrast`` folder: + +- **Contrast Decoder**: A unified decoding algorithm compatible with spike trains produced by the Moving Window (MW), Step-Forward (SF), and Threshold-Based (TB) encoders. + +Below, you will find links to the specific modules for the contrast decoding algorithm: + +.. toctree:: + :maxdepth: 1 + + decoder diff --git a/docs/api/python/decoders/temporal_decoding/index.rst b/docs/api/python/decoders/temporal_decoding/index.rst new file mode 100644 index 0000000..43b8095 --- /dev/null +++ b/docs/api/python/decoders/temporal_decoding/index.rst @@ -0,0 +1,18 @@ +.. _temporal_decoding: + +:octicon:`file-directory;0.9em;sd-mr-1 fill-primary` temporal +================================================================ + +The ``temporal`` module within the decoding module contains algorithms that reconstruct signals based on the timing of individual spikes. This method is especially effective for scenarios where the timing of events conveys more information than their frequency. + +Contents of the ``temporal`` module: + +- **Contrast**: Algorithms that decode based on variations in the signal over time. +- **Deconvolution**: Approaches that reconstruct the original signal from a spike train using deconvolution techniques. + +Below, you will find links to the specific modules for each temporal decoding algorithm: + +.. toctree:: + :maxdepth: 1 + + contrast/index diff --git a/docs/api/python/index.rst b/docs/api/python/index.rst index c464368..fa0e921 100644 --- a/docs/api/python/index.rst +++ b/docs/api/python/index.rst @@ -21,3 +21,4 @@ Below, you will find links to the specific modules within the Python API: filters/index encoders/index + decoders/index From 2cf42c9e2f0e29e5ee0c3edc359aa7b73c8b6c06 Mon Sep 17 00:00:00 2001 From: benedettoleto Date: Sat, 18 Apr 2026 15:35:19 +0200 Subject: [PATCH 03/14] tests: add tests for contrast decoder --- tests/decoders/__init__.py | 0 .../temporal/contrast/test_decoder.py | 103 ++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 tests/decoders/__init__.py create mode 100644 tests/decoders/temporal/contrast/test_decoder.py diff --git a/tests/decoders/__init__.py b/tests/decoders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/decoders/temporal/contrast/test_decoder.py b/tests/decoders/temporal/contrast/test_decoder.py new file mode 100644 index 0000000..128c5a7 --- /dev/null +++ b/tests/decoders/temporal/contrast/test_decoder.py @@ -0,0 +1,103 @@ +import unittest +import numpy as np +from spikify.decoders.temporal.contrast.decoder_algorithm import contrast_decoder + + +class TestContrastDecoderInput(unittest.TestCase): + + def test_empty_spike_train_raises(self): + with self.assertRaises(ValueError): + contrast_decoder(np.array([]), threshold=0.2, start_point=0.1) + + def test_invalid_threshold_dimension_raises(self): + spikes = np.array([0, 1, -1, 0], dtype=np.int8) + with self.assertRaises(TypeError): + contrast_decoder(spikes, threshold=np.array([[0.2, 0.1], [0.3, 0.4]]), start_point=0.1) + + def test_invalid_start_point_dimension_raises(self): + spikes = np.array([0, 1, -1, 0], dtype=np.int8) + with self.assertRaises(TypeError): + contrast_decoder(spikes, threshold=0.2, start_point=np.array([[0.1, 0.2], [0.3, 0.4]])) + + def test_threshold_size_mismatch_raises(self): + spikes = np.array([[0, 1], [-1, 0], [1, -1]], dtype=np.int8) + with self.assertRaises(ValueError): + contrast_decoder(spikes, threshold=[0.2, 0.3, 0.4], start_point=0.1) + + def test_start_point_size_mismatch_raises(self): + spikes = np.array([[0, 1], [-1, 0], [1, -1]], dtype=np.int8) + with self.assertRaises(ValueError): + contrast_decoder(spikes, threshold=0.2, start_point=[0.1, 0.2, 0.3]) + + def test_output_is_ndarray(self): + spikes = np.array([0, 1, -1, 0], dtype=np.int8) + result = contrast_decoder(spikes, threshold=0.2, start_point=0.1) + self.assertIsInstance(result, np.ndarray) + + def test_1d_input_returns_1d_output(self): + spikes = np.array([0, 1, -1, 0], dtype=np.int8) + result = contrast_decoder(spikes, threshold=0.2, start_point=0.1) + self.assertEqual(result.ndim, 1) + + def test_2d_input_returns_2d_output(self): + spikes = np.array([[0, 1], [-1, 0], [1, -1]], dtype=np.int8) + result = contrast_decoder(spikes, threshold=0.2, start_point=0.1) + self.assertEqual(result.ndim, 2) + + def test_output_length_matches_input(self): + spikes = np.array([0, 1, -1, 0, 1], dtype=np.int8) + result = contrast_decoder(spikes, threshold=0.2, start_point=0.1) + self.assertEqual(len(result), len(spikes)) + + def test_output_shape_matches_2d_input(self): + spikes = np.array([[0, 1], [-1, 0], [1, -1]], dtype=np.int8) + result = contrast_decoder(spikes, threshold=0.2, start_point=0.1) + self.assertEqual(result.shape, spikes.shape) + + def test_all_zeros_returns_constant_signal(self): + spikes = np.array([0, 0, 0, 0], dtype=np.int8) + result = contrast_decoder(spikes, threshold=0.2, start_point=0.5) + expected = np.array([0.5, 0.5, 0.5, 0.5]) + np.testing.assert_array_almost_equal(result, expected) + + def test_all_positive_spikes_increments(self): + spikes = np.array([0, 1, 1, 1], dtype=np.int8) + result = contrast_decoder(spikes, threshold=0.2, start_point=0.1) + expected = np.array([0.1, 0.3, 0.5, 0.7]) + np.testing.assert_array_almost_equal(result, expected) + + def test_all_negative_spikes_decrements(self): + spikes = np.array([0, -1, -1, -1], dtype=np.int8) + result = contrast_decoder(spikes, threshold=0.2, start_point=0.7) + expected = np.array([0.7, 0.5, 0.3, 0.1]) + np.testing.assert_array_almost_equal(result, expected) + + def test_mixed_spikes_reconstruction(self): + spikes = np.array([0, 0, 0, 1, 1, 1], dtype=np.int8) + result = contrast_decoder(spikes, threshold=0.2, start_point=0.1) + expected = np.array([0.1, 0.1, 0.1, 0.3, 0.5, 0.7]) + np.testing.assert_array_almost_equal(result, expected) + + def test_alternating_spikes_returns_to_start(self): + spikes = np.array([0, 1, -1, 1, -1], dtype=np.int8) + result = contrast_decoder(spikes, threshold=0.2, start_point=0.5) + expected = np.array([0.5, 0.7, 0.5, 0.7, 0.5]) + np.testing.assert_array_almost_equal(result, expected) + + def test_scalar_threshold_and_start_point(self): + spikes = np.array([0, 1, 0, -1], dtype=np.int8) + result = contrast_decoder(spikes, threshold=0.1, start_point=0.0) + expected = np.array([0.0, 0.1, 0.1, 0.0]) + np.testing.assert_array_almost_equal(result, expected) + + def test_list_threshold_and_start_point(self): + spikes = np.array([[0, 0], [1, -1], [0, 1]], dtype=np.int8) + result = contrast_decoder(spikes, threshold=[0.2, 0.3], start_point=[0.1, 0.5]) + expected = np.array([[0.1, 0.5], [0.3, 0.2], [0.3, 0.5]]) + np.testing.assert_array_almost_equal(result, expected) + + def test_ndarray_threshold_and_start_point(self): + spikes = np.array([[0, 0], [1, 1]], dtype=np.int8) + result = contrast_decoder(spikes, threshold=np.array([0.2, 0.4]), start_point=np.array([0.0, 0.0])) + expected = np.array([[0.0, 0.0], [0.2, 0.4]]) + np.testing.assert_array_almost_equal(result, expected) From 5e0b63f3fc8b979d75dc9b26750868d4ad7bcfaf Mon Sep 17 00:00:00 2001 From: benedettoleto Date: Sat, 18 Apr 2026 15:46:30 +0200 Subject: [PATCH 04/14] fix: import package --- spikify/decoders/temporal/contrast/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spikify/decoders/temporal/contrast/__init__.py b/spikify/decoders/temporal/contrast/__init__.py index fb9d6d9..aa03780 100644 --- a/spikify/decoders/temporal/contrast/__init__.py +++ b/spikify/decoders/temporal/contrast/__init__.py @@ -1 +1,5 @@ """Temporal Contrast package.""" + +from .decoder_algorithm import contrast_decoder + +__all__ = ["contrast_decoder"] From 13fe9dbdc06cd902a9dd60aeaeeb306336e8b17f Mon Sep 17 00:00:00 2001 From: benedettoleto Date: Tue, 21 Apr 2026 15:37:15 +0200 Subject: [PATCH 05/14] feat: add deconvolution decoder --- .../temporal/deconvolution/__init__.py | 5 + .../deconvolution/decoder_algorithm.py | 102 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 spikify/decoders/temporal/deconvolution/__init__.py create mode 100644 spikify/decoders/temporal/deconvolution/decoder_algorithm.py diff --git a/spikify/decoders/temporal/deconvolution/__init__.py b/spikify/decoders/temporal/deconvolution/__init__.py new file mode 100644 index 0000000..e9fca18 --- /dev/null +++ b/spikify/decoders/temporal/deconvolution/__init__.py @@ -0,0 +1,5 @@ +"""Temporal Deconvolution package.""" + +from .decoder_algorithm import deconvolution_decoder + +__all__ = ["deconvolution_decoder"] diff --git a/spikify/decoders/temporal/deconvolution/decoder_algorithm.py b/spikify/decoders/temporal/deconvolution/decoder_algorithm.py new file mode 100644 index 0000000..c7be8a1 --- /dev/null +++ b/spikify/decoders/temporal/deconvolution/decoder_algorithm.py @@ -0,0 +1,102 @@ +""" +.. raw:: html + +

Deconvolution Decoder

+""" + +import numpy as np +from scipy.signal import lfilter + + +def deconvolution_decoder( + spikes: np.ndarray, + fir_bank: np.ndarray, + shift: np.ndarray, + norm: np.ndarray | None = None, +) -> np.ndarray: + """ + Perform signal reconstruction via deconvolution decoding of a spike train. + + The Deconvolution Decoder reverses the encoding performed by spike-based deconvolution algorithms such as + the Hough Spiker (HSA), its Modified variant (MHSA) and Bens Spiker (BSA). Given a binary spike train and the FIR + filter bank produced during encoding, it reconstructs an approximation of the original continuous signal + by applying each feature's filter to its corresponding spike train using causal linear filtering. + + .. note:: + - The ``fir_bank``, ``shift``, and ``norm`` parameters should be the values returned directly by the + encoder algorithm to ensure accurate inversion of all pre-processing steps. + - If ``norm`` is ``None``, no amplitude rescaling is applied; only the per-feature shift is restored. + Pass ``norm`` explicitly whenever the encoder performed normalization. Only MHSA and HSA have normalization + steps, while BSA does not. + + **Code Example:** + + .. code-block:: python + + import numpy as np + from spikify.encoders.temporal.deconvolution import modified_hough_spiker + from spikify.decoders.temporal.deconvolution import deconvolution_decoder + + signal = np.array([0.1, 0.2, 0.3, 1.0, 0.5, 0.3, 0.1]) + window_length = 3 + threshold = 0.5 + cutoff = 0.1 + spikes, fir_bank, shift, norm = modified_hough_spiker(signal, window_length, cutoff, threshold) + reconstructed = deconvolution_decoder(spikes, fir_bank, shift, norm) + + .. doctest:: + :hide: + + >>> import numpy as np + >>> from spikify.encoders.temporal.deconvolution import modified_hough_spiker + >>> from spikify.decoders.temporal.deconvolution import deconvolution_decoder + >>> signal = np.array([0.0, 0.0, 0.5, 0.9, 0.7, 0.4, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + >>> window_length = 5 + >>> threshold = 0.3 + >>> cutoff = 0.1 + >>> spikes, fir_bank, shift, norm = modified_hough_spiker(signal, window_length, cutoff, threshold) + >>> reconstructed = deconvolution_decoder(spikes, fir_bank, shift, norm) + >>> reconstructed.flatten() + array([0. , 0.24793707, 0.50412586, 0.49587414, 0.50412586, + 0.24793707, 0. , 0. , 0. , 0. , + 0. , 0. , 0. , 0. , 0.24793707]) + + :param spikes: Binary spike train to decode (values in {0, 1}), as produced by + any of the deconvolution family encoders (HSA, MHSA, BSA). + :type spikes: numpy.ndarray + :param fir_bank: FIR filter coefficients used during encoding. + Each column corresponds to the filter applied to one feature or channel. This is the ``fir_bank`` value + returned directly by the encoder. + :type fir_bank: numpy.ndarray + :param shift: Per-feature shift values subtracted during encoding to ensure signal non-negativity. + This is the ``shift`` value returned directly by the encoder. + :type shift: numpy.ndarray + :param norm: Per-feature normalization values used during encoding to scale the signal to [0, 1]. + If ``None``, no amplitude rescaling is applied and only the shift is restored. + This is the ``norm`` value returned directly by the encoder. + :type norm: numpy.ndarray | None + :return: A numpy array representing the reconstructed continuous signal approximation. + :rtype: numpy.ndarray + :raises ValueError: If the input spike train is empty. + + """ + + # Check for empty spike train + if len(spikes) == 0: + raise ValueError("Spike train cannot be empty.") + + # Ensure 2D processing (T, F) + if spikes.ndim == 1: + spikes = spikes.reshape(-1, 1) + + T, F = spikes.shape + signal = np.zeros((T, F)) + + for f in range(F): + x = lfilter(fir_bank[:, f], 1, spikes[:, f], axis=0) + if norm is not None: + signal[:, f] = x * norm[f] + shift[f] + else: + signal[:, f] = x + shift[f] + + return signal From aeaa4be8f4b9eab60ecd2f2eb8bafdcabccb606c Mon Sep 17 00:00:00 2001 From: benedettoleto Date: Tue, 21 Apr 2026 15:39:00 +0200 Subject: [PATCH 06/14] refactor: remove unnecessary initialization for certain arguments --- .../temporal/contrast/decoder_algorithm.py | 71 +++++++++---------- 1 file changed, 35 insertions(+), 36 deletions(-) diff --git a/spikify/decoders/temporal/contrast/decoder_algorithm.py b/spikify/decoders/temporal/contrast/decoder_algorithm.py index 39a578e..5cd3d59 100644 --- a/spikify/decoders/temporal/contrast/decoder_algorithm.py +++ b/spikify/decoders/temporal/contrast/decoder_algorithm.py @@ -9,54 +9,67 @@ def contrast_decoder( spikes: np.ndarray, - threshold: float | int | list[float | int] | np.ndarray, + thresholds: np.ndarray, start_point: float | int | list[float | int] | np.ndarray, ) -> np.ndarray: """ Perform Contrast family decoding on the input spike train. This function takes a spike train encoded with any of the Contrast family algorithms, - Threshold-Based Representation (TBR), Step-Forward (SF), or Moving Window (MW), and reconstructs the original - continuous signal. The reconstruction is performed by starting from the initial signal value and applying the - threshold incrementally based on the spike values. + Threshold-Based Representation (TBR), Step-Forward (SF), Moving Window (MW) or Zero-Crossing Step-Forward (ZCSF), + and reconstructs the original continuous signal. The reconstruction is performed by starting from the initial + signal value and applying the threshold incrementally based on the spike values. + + .. note:: + - The ``thresholds`` parameter should be the ``thresholds`` array returned directly by the encoder + (TBR, SF, or MW). All three encoders return this array as part of their output to ensure the step + size used for reconstruction exactly matches the one used during encoding. + - The ``start_point`` parameter should be set to the first sample of the original signal prior to + encoding (e.g. ``signal[0]`` for a 1D signal, or ``signal[0, :]`` for a multi-feature/channel signal). + Since the Contrast family encodes only differences between consecutive samples, the absolute signal + level is lost during encoding and must be restored by anchoring reconstruction to this initial value. + **Code Example:** .. code-block:: python import numpy as np + from spikify.encoders.temporal.contrast import moving_window from spikify.decoders.temporal.contrast import contrast_decoder - spikes = np.array([0, 0, 0, 1, 1, 1], dtype=np.int8) - threshold = 0.2 - startpoint = 0.1 - reconstructed_signal = contrast_decoder(spikes, threshold, startpoint) - reconstructed_signal + signal = np.array([0.1, 0.3, 0.2, 0.5, 0.8, 1.0]) + window_length = 3 + spikes, thresholds = moving_window(signal, window_length, threshold=0.2) + reconstructed_signal = contrast_decoder(spikes, thresholds, start_point=signal[0]) .. doctest:: :hide: >>> import numpy as np + >>> from spikify.encoders.temporal.contrast import moving_window >>> from spikify.decoders.temporal.contrast import contrast_decoder - >>> spikes = np.array([0, 0, 0, 1, 1, 1], dtype=np.int8) - >>> threshold = 0.2 - >>> startpoint = 0.1 - >>> reconstructed_signal = contrast_decoder(spikes, threshold, startpoint) - >>> reconstructed_signal + >>> signal = np.array([0.1, 0.3, 0.2, 0.5, 0.8, 1.0]) + >>> spikes, thresholds = moving_window(signal, window_length=3, threshold=0.2) + >>> reconstructed_signal = contrast_decoder(spikes, thresholds, start_point=signal[0]) + >>> reconstructed_signal.flatten() array([0.1, 0.1, 0.1, 0.3, 0.5, 0.7]) :param spikes: The input spike train to be decoded. This should be a numpy ndarray with values in {-1, 0, +1}, - as produced by any of the Contrast family encoders (TBR, SF, MW). + as produced by any of the Contrast family encoders (TBR, SF, MW, ZCSF). :type spikes: numpy.ndarray - :param threshold: Threshold(s) used during encoding; scalar or 1D sequence matching features. - :type threshold: float | int | list[float | int] | numpy.ndarray - :param start_point: Initial signal value(s) for reconstruction; scalar or 1D sequence matching features. + :param threshold: Per-feature or channels threshold values used during encoding, as returned directly by the TBR, + SF, MW or ZCSF encoder. Passing the encoder's returned ``thresholds`` array + ensures the reconstruction step size exactly matches the one used during encoding. + :type threshold: numpy.ndarray + :param start_point: Initial signal value(s) for reconstruction (scalar or 1D sequence matching features). + Should be set to the first sample of the original signal before encoding (e.g. ``signal[0]``), as the + Contrast family encodes only signal differences and the absolute offset must be restored manually. :type start_point: float | int | list[float | int] | numpy.ndarray :return: A numpy array representing the reconstructed continuous signal. :rtype: numpy.ndarray - :raises ValueError: If the input spike train is empty or if the threshold/start_point dimensions do not match - the spike train features. - :raises TypeError: If the threshold or start_point parameter is of invalid dimension. + :raises ValueError: If the input spike train is empty or if the start_point dimensions do not match + the spike train feature dimensions. """ # Check for empty spike train @@ -69,23 +82,13 @@ def contrast_decoder( T, F = spikes.shape - # Handle threshold - if np.isscalar(threshold): - thresholds = np.full(F, float(threshold)) - else: - thresholds = np.asarray(threshold, dtype=float) - if thresholds.ndim != 1: - raise TypeError("Threshold must be a scalar or a 1D sequence of numbers.") - if thresholds.size != F: - raise ValueError("Threshold must match the number of features in the spike train.") - # Handle startpoint if np.isscalar(start_point): start_points = np.full(F, float(start_point)) else: start_points = np.asarray(start_point, dtype=float) if start_points.ndim != 1: - raise TypeError("Startpoint must be a scalar or a 1D sequence of numbers.") + raise ValueError("Startpoint must be a scalar or a 1D sequence of numbers.") if start_points.size != F: raise ValueError("Startpoint must match the number of features in the spike train.") @@ -101,8 +104,4 @@ def contrast_decoder( else: signal[t, f] = signal[t - 1, f] - # Flatten if input was 1D - if F == 1: - signal = signal.flatten() - return signal From cdf96edaa79ee49aac2af890889e56acd5ddab43 Mon Sep 17 00:00:00 2001 From: benedettoleto Date: Tue, 21 Apr 2026 15:42:26 +0200 Subject: [PATCH 07/14] refactor: clarify documentation and avoit flattening output --- .../contrast/moving_window_algorithm.py | 30 +++++++++--------- .../contrast/step_forward_algorithm.py | 31 ++++++++++--------- .../contrast/threshold_based_algorithm.py | 21 +++++++------ .../zero_cross_step_forward_algorithm.py | 28 ++++++++--------- 4 files changed, 55 insertions(+), 55 deletions(-) diff --git a/spikify/encoders/temporal/contrast/moving_window_algorithm.py b/spikify/encoders/temporal/contrast/moving_window_algorithm.py index 549361d..2d3a8c7 100644 --- a/spikify/encoders/temporal/contrast/moving_window_algorithm.py +++ b/spikify/encoders/temporal/contrast/moving_window_algorithm.py @@ -9,7 +9,7 @@ def moving_window( signal: np.ndarray, window_length: int, threshold: float | int | list[float | int] | np.ndarray -) -> np.ndarray: +) -> tuple[np.ndarray, np.ndarray]: """ Perform Moving Window (MW) encoding on the input signal. @@ -29,8 +29,7 @@ def moving_window( signal = np.array([0.1, 0.3, 0.2, 0.5, 0.8, 1.0]) window_length = 3 threshold = 0.2 - encoded_signal = moving_window(signal, window_length, threshold) - encoded_signal + encoded_signal, thresholds = moving_window(signal, window_length, threshold) .. doctest:: :hide: @@ -40,20 +39,23 @@ def moving_window( >>> signal = np.array([0.1, 0.3, 0.2, 0.5, 0.8, 1.0]) >>> window_length = 3 >>> threshold = 0.2 - >>> encoded_signal = moving_window(signal, window_length, threshold) - >>> encoded_signal + >>> encoded_signal, _ = moving_window(signal, window_length, threshold) + >>> encoded_signal.flatten() array([0, 0, 0, 1, 1, 1], dtype=int8) - :param signal: The input signal to be encoded. This should be a numpy ndarray. + :param signal: Input signal to encode (1D or 2D: time × features or channels). :type signal: numpy.ndarray :param window_length: The size of the sliding window for calculating the signal base mean. :type window_length: int :param threshold: Threshold(s) for spike generation; scalar or 1D sequence matching features. :type threshold: float | int | list[float | int] | numpy.ndarray - :return: A numpy array representing the encoded spike train. - :rtype: numpy.ndarray - :raises ValueError: If the input signal is empty or if the threshold dimensions do not match the signal features. - :raises TypeError: If the threshold parameter is of invalid dimension. + :return: + - spikes: A numpy array representing the encoded spike train. (values in {-1, 0, +1}) + - thresholds: Per-feature or channel thresholds used for encoding, returned for use in decoding, + shape (features or channels,). + :rtype: tuple[numpy.ndarray, numpy.ndarray] + :raises ValueError: If the input signal is empty or if the threshold dimensions do not match the signal f + eature dimensions. """ @@ -73,7 +75,7 @@ def moving_window( else: thresholds = np.asarray(threshold, dtype=float) if thresholds.ndim != 1: - raise TypeError("Threshold must be a scalar or a 1D sequence of numbers.") + raise ValueError("Threshold must be a scalar or a 1D sequence of numbers.") if thresholds.size != F: raise ValueError("Threshold must match the number of features in the signal.") @@ -100,8 +102,4 @@ def moving_window( elif signal[t, f] < base - thresholds[f]: spikes[t, f] = -1 - # Flatten if input was 1D - if F == 1: - spikes = spikes.flatten() - - return spikes + return spikes, thresholds diff --git a/spikify/encoders/temporal/contrast/step_forward_algorithm.py b/spikify/encoders/temporal/contrast/step_forward_algorithm.py index 8dfc6ef..1fd2224 100644 --- a/spikify/encoders/temporal/contrast/step_forward_algorithm.py +++ b/spikify/encoders/temporal/contrast/step_forward_algorithm.py @@ -7,7 +7,9 @@ import numpy as np -def step_forward(signal: np.ndarray, threshold: float | int | list[float | int] | np.ndarray) -> np.ndarray: +def step_forward( + signal: np.ndarray, threshold: float | int | list[float | int] | np.ndarray +) -> tuple[np.ndarray, np.ndarray]: """ Perform Step-Forward (SF) encoding on the input signal. @@ -25,7 +27,7 @@ def step_forward(signal: np.ndarray, threshold: float | int | list[float | int] from spikify.encoders.temporal.contrast import step_forward signal = np.array([0.1, 0.3, 0.4, 0.2, 0.5, 0.6]) threshold = 0.2 - encoded_signal = step_forward(signal, threshold) + encoded_signal, thresholds = step_forward(signal, threshold) .. doctest:: :hide: @@ -34,18 +36,21 @@ def step_forward(signal: np.ndarray, threshold: float | int | list[float | int] >>> from spikify.encoders.temporal.contrast import step_forward >>> signal = np.array([0.1, 0.3, 0.4, 0.2, 0.5, 0.6]) >>> threshold = 0.2 - >>> encoded_signal = step_forward(signal, threshold) - >>> encoded_signal + >>> encoded_signal, _ = step_forward(signal, threshold) + >>> encoded_signal.flatten() array([0, 0, 1, 0, 0, 1], dtype=int8) - :param signal: The input signal to be encoded. This should be a numpy ndarray. + :param signal: Input signal to encode (1D or 2D: time × features or channels). :type signal: numpy.ndarray :param threshold: Threshold(s) for spike generation; scalar or 1D sequence matching features. :type threshold: float | int | list[float | int] | numpy.ndarray - :return: A numpy array representing the encoded spike train. - :rtype: numpy.ndarray - :raises ValueError: If the input signal is empty or if the threshold dimensions do not match the signal features. - :raises TypeError: If the threshold parameter is of invalid dimension. + :return: + - spikes: A numpy array representing the encoded spike train. (values in {-1, 0, +1}) + - thresholds: Per-feature or channel thresholds used for encoding, returned for use in decoding, + shape (features or channels,). + :rtype: tuple[numpy.ndarray, numpy.ndarray] + :raises ValueError: If the input signal is empty or if the threshold dimensions do not match the signal + features dimensions. """ @@ -65,7 +70,7 @@ def step_forward(signal: np.ndarray, threshold: float | int | list[float | int] else: thresholds = np.asarray(threshold, dtype=float) if thresholds.ndim != 1: - raise TypeError("Threshold must be a scalar or a 1D sequence of numbers.") + raise ValueError("Threshold must be a scalar or a 1D sequence of numbers.") if thresholds.size != F: raise ValueError("Threshold must match the number of features in the signal.") @@ -86,8 +91,4 @@ def step_forward(signal: np.ndarray, threshold: float | int | list[float | int] spike[t, feat] = -1 base -= thresholds[feat] - # Flatten if input was 1D - if F == 1: - spike = spike.flatten() - - return spike + return spike, thresholds diff --git a/spikify/encoders/temporal/contrast/threshold_based_algorithm.py b/spikify/encoders/temporal/contrast/threshold_based_algorithm.py index 1862b06..ae5b5be 100644 --- a/spikify/encoders/temporal/contrast/threshold_based_algorithm.py +++ b/spikify/encoders/temporal/contrast/threshold_based_algorithm.py @@ -36,19 +36,21 @@ def threshold_based_representation( >>> from spikify.encoders.temporal.contrast import threshold_based_representation >>> signal = np.array([0.1, 0.3, 0.4, 0.2, 0.5, 0.6]) >>> factor = 0.5 - >>> encoded_signal, threshold = threshold_based_representation(signal, factor) - >>> encoded_signal + >>> encoded_signal, _ = threshold_based_representation(signal, factor) + >>> encoded_signal.flatten() array([ 1, 0, -1, 1, 0, 0], dtype=int8) - :param signal: The input signal to be encoded. This should be a numpy ndarray. + :param signal: Input signal to encode (1D or 2D: time × features or channels). :type signal: numpy.ndarray :param factor: The factor value (`factor`) that controls the noise-reduction threshold. Can be a float, an integer, or a list of floats or integers. :type factor: float | int | list[float | int] | numpy.ndarray - :return: A tuple containing the encoded spike train and the computed threshold for each feature. + :return: + - spikes: A numpy array representing the encoded spike train. (values in {-1, 0, +1}) + - thresholds: Per-feature or channel thresholds used for encoding, returned for use in decoding, + shape (features or channels,). :rtype: tuple[numpy.ndarray, numpy.ndarray] :raises ValueError: If the input signal is empty or if the factor length does not match the number of features. - :raises TypeError: If the factor parameter is of invalid dimension. """ @@ -68,7 +70,7 @@ def threshold_based_representation( else: factors = np.asarray(factor, dtype=float) if factors.ndim != 1: - raise TypeError("Factor must be a scalar or a 1D sequence of numbers.") + raise ValueError("Factor must be a scalar or a 1D sequence of numbers.") if factors.size != F: raise ValueError("Factor must match the number of features in the signal.") spike = np.zeros_like(signal, dtype=np.int8) @@ -87,8 +89,7 @@ def threshold_based_representation( spike[diff > threshold] = 1 spike[diff < -threshold] = -1 - # Flatten if input was 1D - if F == 1: - spike = spike.flatten() + # Reshape threshold to 1D for return + threshold = threshold.flatten() - return spike, threshold.flatten() + return spike, threshold diff --git a/spikify/encoders/temporal/contrast/zero_cross_step_forward_algorithm.py b/spikify/encoders/temporal/contrast/zero_cross_step_forward_algorithm.py index 1ce4b36..1db5f6f 100644 --- a/spikify/encoders/temporal/contrast/zero_cross_step_forward_algorithm.py +++ b/spikify/encoders/temporal/contrast/zero_cross_step_forward_algorithm.py @@ -7,7 +7,9 @@ import numpy as np -def zero_cross_step_forward(signal: np.ndarray, threshold: float | int | list[float | int] | np.ndarray) -> np.ndarray: +def zero_cross_step_forward( + signal: np.ndarray, threshold: float | int | list[float | int] | np.ndarray +) -> tuple[np.ndarray, np.ndarray]: """ Perform Zero-Crossing Step-Forward (ZCSF) encoding on the input signal. @@ -26,7 +28,7 @@ def zero_cross_step_forward(signal: np.ndarray, threshold: float | int | list[fl from spikify.encoders.temporal.contrast import zero_cross_step_forward signal = np.array([-0.2, 0.1, 0.5, 0.0, 1.2, 0.3]) threshold = 0.4 - encoded_signal = zero_cross_step_forward(signal, threshold) + encoded_signal, thresholds = zero_cross_step_forward(signal, threshold) .. doctest:: :hide: @@ -35,18 +37,20 @@ def zero_cross_step_forward(signal: np.ndarray, threshold: float | int | list[fl >>> from spikify.encoders.temporal.contrast import zero_cross_step_forward >>> signal = np.array([-0.2, 0.1, 0.5, 0.0, 1.2, 0.3]) >>> threshold = 0.4 - >>> encoded_signal = zero_cross_step_forward(signal, threshold) - >>> encoded_signal + >>> encoded_signal, _ = zero_cross_step_forward(signal, threshold) + >>> encoded_signal.flatten() array([0, 0, 1, 0, 1, 0], dtype=int8) - :param signal: The input signal to be encoded. This should be a numpy ndarray. + :param signal: Input signal to encode (1D or 2D: time × features or channels). :type signal: numpy.ndarray :param threshold: Threshold(s) for spike generation; scalar or 1D sequence matching features. :type threshold: float | int | list[float | int] | numpy.ndarray - :return: A numpy array representing the encoded spike train. - :rtype: numpy.ndarray + :return: + - spikes: A numpy array representing the encoded spike train. (values in {0, +1}) + - thresholds: Per-feature or channel thresholds used for encoding, returned for use in decoding, + shape (features or channels,). + :rtype: tuple[numpy.ndarray, numpy.ndarray] :raises ValueError: If the input signal is empty or if the threshold dimensions do not match the signal features. - :raises TypeError: If the threshold parameter is of invalid dimension. """ @@ -66,7 +70,7 @@ def zero_cross_step_forward(signal: np.ndarray, threshold: float | int | list[fl else: thresholds = np.asarray(threshold, dtype=float) if thresholds.ndim != 1: - raise TypeError("Threshold must be a scalar or a 1D sequence of numbers.") + raise ValueError("Threshold must be a scalar or a 1D sequence of numbers.") if thresholds.size != F: raise ValueError("Threshold must match the number of features in the signal.") @@ -78,8 +82,4 @@ def zero_cross_step_forward(signal: np.ndarray, threshold: float | int | list[fl # Apply threshold condition spike[signal > thresholds] = 1 - # Flatten if input was 1D - if F == 1: - spike = spike.flatten() - - return spike + return spike, thresholds From 6192af519a4d1a45abe541c736ecb0d6801b9537 Mon Sep 17 00:00:00 2001 From: benedettoleto Date: Tue, 21 Apr 2026 15:43:32 +0200 Subject: [PATCH 08/14] tets: fix dimension of retuned array --- .../temporal/contrast/test_moving_window.py | 28 +++++++++------ .../temporal/contrast/test_step_forward.py | 34 ++++++++++++------- .../temporal/contrast/test_threshold_based.py | 8 ++++- .../contrast/test_zero_cross_step_forward.py | 31 +++++++++++------ 4 files changed, 67 insertions(+), 34 deletions(-) diff --git a/tests/encoders/temporal/contrast/test_moving_window.py b/tests/encoders/temporal/contrast/test_moving_window.py index 2e8476a..ad26de7 100644 --- a/tests/encoders/temporal/contrast/test_moving_window.py +++ b/tests/encoders/temporal/contrast/test_moving_window.py @@ -12,7 +12,8 @@ def test_basic_functionality(self): window_length = 3 threshold = 0.5 expected_spikes = np.array([-1, 0, 1, 1, 1, 0, -1, -1, -1]) - result = moving_window(signal, window_length, threshold) + result, _ = moving_window(signal, window_length, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_empty_signal(self): @@ -37,7 +38,8 @@ def test_all_zero_signal(self): window_length = 3 threshold = 0.5 expected_spikes = np.array([0, 0, 0, 0, 0]) - result = moving_window(signal, window_length, threshold) + result, _ = moving_window(signal, window_length, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_large_signal(self): @@ -45,7 +47,8 @@ def test_large_signal(self): signal = np.random.randn(1000) window_length = 50 threshold = 0.3 - result = moving_window(signal, window_length, threshold) + result, _ = moving_window(signal, window_length, threshold) + result = result.flatten() self.assertEqual(len(result), len(signal)) def test_no_spikes(self): @@ -54,7 +57,8 @@ def test_no_spikes(self): window_length = 3 threshold = 5 expected_spikes = np.array([0, 0, 0, 0, 0]) - result = moving_window(signal, window_length, threshold) + result, _ = moving_window(signal, window_length, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_variable_window_length(self): @@ -65,14 +69,16 @@ def test_variable_window_length(self): window_length_3 = 3 threshold_3 = 0.5 expected_spikes_3 = np.array([-1, 0, 1, 1, 1, 1, 1, 0, -1, -1, -1, -1, -1]) - result_3 = moving_window(signal, window_length_3, threshold_3) + result_3, _ = moving_window(signal, window_length_3, threshold_3) + result_3 = result_3.flatten() np.testing.assert_array_equal(result_3, expected_spikes_3) # Test case for window length of 5 window_length_5 = 5 threshold_5 = 0.5 expected_spikes_5 = np.array([-1, -1, 0, 1, 1, 1, 1, 1, -1, -1, -1, -1, -1]) - result_5 = moving_window(signal, window_length_5, threshold_5) + result_5, _ = moving_window(signal, window_length_5, threshold_5) + result_5 = result_5.flatten() np.testing.assert_array_equal(result_5, expected_spikes_5) def test_with_multiple_features(self): @@ -80,12 +86,14 @@ def test_with_multiple_features(self): np.random.seed(42) signal = np.random.rand(10, 2) window_length = 2 - encoded_signal = moving_window(signal, window_length, threshold=0.1) + encoded_signal, _ = moving_window(signal, window_length, threshold=0.1) self.assertEqual(encoded_signal.shape, signal.shape) signal_f1 = signal[:, 0] signal_f2 = signal[:, 1] - encoded_signal_f1 = moving_window(signal_f1, window_length, threshold=0.1) - encoded_signal_f2 = moving_window(signal_f2, window_length, threshold=0.1) + encoded_signal_f1, _ = moving_window(signal_f1, window_length, threshold=0.1) + encoded_signal_f1 = encoded_signal_f1.flatten() + encoded_signal_f2, _ = moving_window(signal_f2, window_length, threshold=0.1) + encoded_signal_f2 = encoded_signal_f2.flatten() np.testing.assert_array_equal(encoded_signal[:, 0], encoded_signal_f1) np.testing.assert_array_equal(encoded_signal[:, 1], encoded_signal_f2) @@ -94,7 +102,7 @@ def test_threshold_dimension(self): signal = np.random.rand(10, 2) window_length = 2 threshold = np.array([[0.1, 0.2], [0.3, 0.4]]) - with self.assertRaises(TypeError): + with self.assertRaises(ValueError): moving_window(signal, window_length, threshold) def test_threshold_dims_different_from_features(self): diff --git a/tests/encoders/temporal/contrast/test_step_forward.py b/tests/encoders/temporal/contrast/test_step_forward.py index 7e81030..6732356 100644 --- a/tests/encoders/temporal/contrast/test_step_forward.py +++ b/tests/encoders/temporal/contrast/test_step_forward.py @@ -11,7 +11,8 @@ def test_basic_functionality(self): signal = np.array([0, 2, 4, 6, 4, 2, 0], dtype=float) threshold = 2.0 expected_spikes = np.array([0, 0, 1, 1, 0, 0, -1]) - result = step_forward(signal, threshold) + result, _ = step_forward(signal, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_empty_signal(self): @@ -26,7 +27,8 @@ def test_no_spikes(self): signal = np.array([1, 1.5, 1.8, 1.6, 1.4, 1.2]) threshold = 2.0 expected_spikes = np.array([0, 0, 0, 0, 0, 0]) - result = step_forward(signal, threshold) + result, _ = step_forward(signal, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_single_positive_spike(self): @@ -34,7 +36,8 @@ def test_single_positive_spike(self): signal = np.array([0, 0, 5, 0, 0], dtype=float) threshold = 4.0 expected_spikes = np.array([0, 0, 1, 0, 0]) - result = step_forward(signal, threshold) + result, _ = step_forward(signal, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_single_negative_spike(self): @@ -42,7 +45,8 @@ def test_single_negative_spike(self): signal = np.array([0, 0, -5, 0, 0], dtype=float) threshold = 4.0 expected_spikes = np.array([0, 0, -1, 0, 0]) - result = step_forward(signal, threshold) + result, _ = step_forward(signal, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_alternating_spikes(self): @@ -50,7 +54,8 @@ def test_alternating_spikes(self): signal = np.array([0, 5, 0, -5, 0], dtype=float) threshold = 4.0 expected_spikes = np.array([0, 1, 0, -1, 0]) - result = step_forward(signal, threshold) + result, _ = step_forward(signal, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_signal_with_noise(self): @@ -58,14 +63,16 @@ def test_signal_with_noise(self): signal = np.array([0, 0.5, 2.5, 1.5, 0, -1, -2.5, -1.5, 0]) threshold = 2.0 expected_spikes = np.array([0, 0, 1, 0, 0, -1, -1, 0, 0]) - result = step_forward(signal, threshold) + result, _ = step_forward(signal, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_large_signal(self): """Test the function's performance and correctness on a large signal.""" signal = np.random.randn(1000) * 10 # Random signal with mean 0 and standard deviation 10 threshold = 15.0 - result = step_forward(signal, threshold) + result, _ = step_forward(signal, threshold) + result = result.flatten() self.assertEqual(len(result), len(signal)) def test_boundary_conditions(self): @@ -73,7 +80,8 @@ def test_boundary_conditions(self): signal = np.array([10, 0, -10], dtype=float) threshold = 5.0 expected_spikes = np.array([0, -1, -1]) - result = step_forward(signal, threshold) + result, _ = step_forward(signal, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_with_multiple_features(self): @@ -84,11 +92,13 @@ def test_with_multiple_features(self): signal_f2 = signal[:, 1] threshold = [0.1, 0.3] - encoded_signal = step_forward(signal, threshold) + encoded_signal, _ = step_forward(signal, threshold) self.assertEqual(encoded_signal.shape, signal.shape) - encoded_signal_f1 = step_forward(signal_f1, threshold[0]) - encoded_signal_f2 = step_forward(signal_f2, threshold[1]) + encoded_signal_f1, _ = step_forward(signal_f1, threshold[0]) + encoded_signal_f1 = encoded_signal_f1.flatten() + encoded_signal_f2, _ = step_forward(signal_f2, threshold[1]) + encoded_signal_f2 = encoded_signal_f2.flatten() np.testing.assert_array_equal(encoded_signal[:, 0], encoded_signal_f1) np.testing.assert_array_equal(encoded_signal[:, 1], encoded_signal_f2) @@ -105,5 +115,5 @@ def test_threshold_dimension(self): """Test that the function raises TypeError when threshold is of invalid dimension.""" signal = np.random.rand(10, 2) threshold = np.array([[0.1, 0.2], [0.3, 0.4]]) - with self.assertRaises(TypeError): + with self.assertRaises(ValueError): step_forward(signal, threshold) diff --git a/tests/encoders/temporal/contrast/test_threshold_based.py b/tests/encoders/temporal/contrast/test_threshold_based.py index 7a2d2ec..df64c62 100644 --- a/tests/encoders/temporal/contrast/test_threshold_based.py +++ b/tests/encoders/temporal/contrast/test_threshold_based.py @@ -12,6 +12,7 @@ def test_basic_functionality(self): factor = 1.0 expected_spikes = np.array([1, 1, 1, -1, -1, -1, -1]) result, _ = threshold_based_representation(signal, factor) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_empty_signal(self): @@ -27,6 +28,7 @@ def test_no_variation(self): factor = 1.0 expected_spikes = np.array([0, 0, 0, 0, 0]) result, _ = threshold_based_representation(signal, factor) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_signal_with_noise(self): @@ -52,12 +54,14 @@ def test_varying_factor(self): factor_high = 2.0 expected_spikes_high = np.array([0, 0, 0, 0, 0, 0, 0]) result_high, _ = threshold_based_representation(signal, factor_high) + result_high = result_high.flatten() np.testing.assert_array_equal(result_high, expected_spikes_high) # Lower threshold factor, more sensitivity factor_low = 0.1 expected_spikes_low = np.array([1, 1, 1, -1, -1, -1, -1]) result_low, _ = threshold_based_representation(signal, factor_low) + result_low = result_low.flatten() np.testing.assert_array_equal(result_low, expected_spikes_low) def test_with_multiple_features(self): @@ -70,7 +74,9 @@ def test_with_multiple_features(self): signal_f1 = signal[:, 0] signal_f2 = signal[:, 1] encoded_signal_f1, _ = threshold_based_representation(signal_f1, threshold[0]) + encoded_signal_f1 = encoded_signal_f1.flatten() encoded_signal_f2, _ = threshold_based_representation(signal_f2, threshold[1]) + encoded_signal_f2 = encoded_signal_f2.flatten() np.testing.assert_array_equal(encoded_signal[:, 0], encoded_signal_f1) np.testing.assert_array_equal(encoded_signal[:, 1], encoded_signal_f2) @@ -93,5 +99,5 @@ def test_factor_dimension(self): """Test that the function raises TypeError when factor is of invalid dimension.""" signal = np.random.rand(10, 2) factor = np.array([[1.0, 2.0], [3.0, 4.0]]) - with self.assertRaises(TypeError): + with self.assertRaises(ValueError): threshold_based_representation(signal, factor) diff --git a/tests/encoders/temporal/contrast/test_zero_cross_step_forward.py b/tests/encoders/temporal/contrast/test_zero_cross_step_forward.py index 866c515..d0cf3d6 100644 --- a/tests/encoders/temporal/contrast/test_zero_cross_step_forward.py +++ b/tests/encoders/temporal/contrast/test_zero_cross_step_forward.py @@ -11,7 +11,8 @@ def test_basic_functionality(self): signal = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) threshold = 5.0 expected_spikes = np.array([0, 0, 0, 0, 0, 0, 1, 1, 1, 1]) - result = zero_cross_step_forward(signal, threshold) + result, _ = zero_cross_step_forward(signal, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_empty_signal(self): @@ -26,7 +27,8 @@ def test_all_below_threshold(self): signal = np.array([0, 1, 2, 3, 4]) threshold = 5.0 expected_spikes = np.array([0, 0, 0, 0, 0]) - result = zero_cross_step_forward(signal, threshold) + result, _ = zero_cross_step_forward(signal, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_all_above_threshold(self): @@ -34,7 +36,8 @@ def test_all_above_threshold(self): signal = np.array([6, 7, 8, 9, 10]) threshold = 5.0 expected_spikes = np.array([1, 1, 1, 1, 1]) - result = zero_cross_step_forward(signal, threshold) + result, _ = zero_cross_step_forward(signal, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_signal_with_negative_values(self): @@ -42,7 +45,8 @@ def test_signal_with_negative_values(self): signal = np.array([-1, -2, -3, 4, 5, -6, 7]) threshold = 5.0 expected_spikes = np.array([0, 0, 0, 0, 0, 0, 1]) - result = zero_cross_step_forward(signal, threshold) + result, _ = zero_cross_step_forward(signal, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_no_spikes(self): @@ -50,14 +54,16 @@ def test_no_spikes(self): signal = np.array([0, 0, 0, 0, 0]) threshold = 1.0 expected_spikes = np.array([0, 0, 0, 0, 0]) - result = zero_cross_step_forward(signal, threshold) + result, _ = zero_cross_step_forward(signal, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_large_signal(self): """Test the function's performance and correctness on a large signal.""" signal = np.random.randint(-10, 20, size=1000) threshold = 10.0 - result = zero_cross_step_forward(signal, threshold) + result, _ = zero_cross_step_forward(signal, threshold) + result = result.flatten() self.assertEqual(len(result), len(signal)) def test_threshold_at_zero(self): @@ -65,7 +71,8 @@ def test_threshold_at_zero(self): signal = np.array([-1, 0, 1, 2, 3]) threshold = 0.0 expected_spikes = np.array([0, 0, 1, 1, 1]) - result = zero_cross_step_forward(signal, threshold) + result, _ = zero_cross_step_forward(signal, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_shape_with_multiple_features(self): @@ -73,12 +80,14 @@ def test_shape_with_multiple_features(self): np.random.seed(42) signal = np.random.rand(10, 2) threshold = [0.5, 0.3] - encoded_signal = zero_cross_step_forward(signal, threshold) + encoded_signal, _ = zero_cross_step_forward(signal, threshold) self.assertEqual(encoded_signal.shape, signal.shape) signal_f1 = signal[:, 0] signal_f2 = signal[:, 1] - encoded_signal_f1 = zero_cross_step_forward(signal_f1, threshold[0]) - encoded_signal_f2 = zero_cross_step_forward(signal_f2, threshold[1]) + encoded_signal_f1, _ = zero_cross_step_forward(signal_f1, threshold[0]) + encoded_signal_f1 = encoded_signal_f1.flatten() + encoded_signal_f2, _ = zero_cross_step_forward(signal_f2, threshold[1]) + encoded_signal_f2 = encoded_signal_f2.flatten() np.testing.assert_array_equal(encoded_signal[:, 0], encoded_signal_f1) np.testing.assert_array_equal(encoded_signal[:, 1], encoded_signal_f2) @@ -94,5 +103,5 @@ def test_threshold_dimension(self): """Test that the function raises TypeError when threshold is of invalid dimension.""" signal = np.random.rand(10, 2) threshold = np.array([[0.1, 0.2], [0.3, 0.4]]) - with self.assertRaises(TypeError): + with self.assertRaises(ValueError): zero_cross_step_forward(signal, threshold) From 601b195744005ea63bb153b3a658ec37cee01423 Mon Sep 17 00:00:00 2001 From: benedettoleto Date: Tue, 21 Apr 2026 15:44:19 +0200 Subject: [PATCH 09/14] refactor: rename test file due to pycache error --- .../contrast/test_contrast_decoder.py | 263 ++++++++++++++++++ .../temporal/contrast/test_decoder.py | 103 ------- 2 files changed, 263 insertions(+), 103 deletions(-) create mode 100644 tests/decoders/temporal/contrast/test_contrast_decoder.py delete mode 100644 tests/decoders/temporal/contrast/test_decoder.py diff --git a/tests/decoders/temporal/contrast/test_contrast_decoder.py b/tests/decoders/temporal/contrast/test_contrast_decoder.py new file mode 100644 index 0000000..73cfc6a --- /dev/null +++ b/tests/decoders/temporal/contrast/test_contrast_decoder.py @@ -0,0 +1,263 @@ +import unittest +import numpy as np +from spikify.decoders.temporal.contrast.decoder_algorithm import contrast_decoder +from spikify.encoders.temporal.contrast import ( + threshold_based_representation, + step_forward, + moving_window, + zero_cross_step_forward, +) + + +class TestContrastDecoderInput(unittest.TestCase): + + def test_empty_spike_train_raises(self): + with self.assertRaises(ValueError): + contrast_decoder(np.array([]), thresholds=np.array([0.2]), start_point=0.1) + + def test_invalid_start_point_dimension_raises(self): + spikes = np.array([0, 1, -1, 0], dtype=np.int8) + with self.assertRaises(ValueError): + contrast_decoder(spikes, thresholds=np.array([0.2]), start_point=np.array([[0.1, 0.2], [0.3, 0.4]])) + + def test_start_point_size_mismatch_raises(self): + spikes = np.array([[0, 1], [-1, 0], [1, -1]], dtype=np.int8) + with self.assertRaises(ValueError): + contrast_decoder(spikes, thresholds=np.array([0.2, 0.3]), start_point=[0.1, 0.2, 0.3]) + + def test_output_is_ndarray(self): + spikes = np.array([0, 1, -1, 0], dtype=np.int8) + result = contrast_decoder(spikes, thresholds=np.array([0.2]), start_point=0.1) + self.assertIsInstance(result, np.ndarray) + + def test_1d_input_returns_2d_output(self): + spikes = np.array([0, 1, -1, 0], dtype=np.int8) + result = contrast_decoder(spikes, thresholds=np.array([0.2]), start_point=0.1) + self.assertEqual(result.ndim, 2) + + def test_2d_input_returns_2d_output(self): + spikes = np.array([[0, 1], [-1, 0], [1, -1]], dtype=np.int8) + result = contrast_decoder(spikes, thresholds=np.array([0.2, 0.2]), start_point=0.1) + self.assertEqual(result.ndim, 2) + + def test_output_length_matches_input(self): + spikes = np.array([0, 1, -1, 0, 1], dtype=np.int8) + result = contrast_decoder(spikes, thresholds=np.array([0.2]), start_point=0.1) + self.assertEqual(len(result), len(spikes)) + + def test_output_shape_matches_2d_input(self): + spikes = np.array([[0, 1], [-1, 0], [1, -1]], dtype=np.int8) + result = contrast_decoder(spikes, thresholds=np.array([0.2, 0.2]), start_point=0.1) + self.assertEqual(result.shape, spikes.shape) + + def test_all_zeros_returns_constant_signal(self): + spikes = np.array([0, 0, 0, 0], dtype=np.int8) + result = contrast_decoder(spikes, thresholds=np.array([0.2]), start_point=0.5) + expected = np.array([0.5, 0.5, 0.5, 0.5]).reshape(-1, 1) + np.testing.assert_array_almost_equal(result, expected) + + def test_all_positive_spikes_increments(self): + spikes = np.array([0, 1, 1, 1], dtype=np.int8) + result = contrast_decoder(spikes, thresholds=np.array([0.2]), start_point=0.1) + expected = np.array([0.1, 0.3, 0.5, 0.7]).reshape(-1, 1) + np.testing.assert_array_almost_equal(result, expected) + + def test_all_negative_spikes_decrements(self): + spikes = np.array([0, -1, -1, -1], dtype=np.int8) + result = contrast_decoder(spikes, thresholds=np.array([0.2]), start_point=0.7) + expected = np.array([0.7, 0.5, 0.3, 0.1]).reshape(-1, 1) + np.testing.assert_array_almost_equal(result, expected) + + def test_mixed_spikes_reconstruction(self): + spikes = np.array([0, 0, 0, 1, 1, 1], dtype=np.int8) + result = contrast_decoder(spikes, thresholds=np.array([0.2]), start_point=0.1) + expected = np.array([0.1, 0.1, 0.1, 0.3, 0.5, 0.7]).reshape(-1, 1) + np.testing.assert_array_almost_equal(result, expected) + + def test_alternating_spikes_returns_to_start(self): + spikes = np.array([0, 1, -1, 1, -1], dtype=np.int8) + result = contrast_decoder(spikes, thresholds=np.array([0.2]), start_point=0.5) + expected = np.array([0.5, 0.7, 0.5, 0.7, 0.5]).reshape(-1, 1) + np.testing.assert_array_almost_equal(result, expected) + + def test_scalar_start_point(self): + spikes = np.array([0, 1, 0, -1], dtype=np.int8) + result = contrast_decoder(spikes, thresholds=np.array([0.1]), start_point=0.0) + expected = np.array([0.0, 0.1, 0.1, 0.0]).reshape(-1, 1) + np.testing.assert_array_almost_equal(result, expected) + + def test_multi_feature_ndarray_threshold_and_list_start_point(self): + spikes = np.array([[0, 0], [1, -1], [0, 1]], dtype=np.int8) + result = contrast_decoder(spikes, thresholds=np.array([0.2, 0.3]), start_point=[0.1, 0.5]) + expected = np.array([[0.1, 0.5], [0.3, 0.2], [0.3, 0.5]]) + np.testing.assert_array_almost_equal(result, expected) + + def test_multi_feature_ndarray_threshold_and_ndarray_start_point(self): + spikes = np.array([[0, 0], [1, 1]], dtype=np.int8) + result = contrast_decoder(spikes, thresholds=np.array([0.2, 0.4]), start_point=np.array([0.0, 0.0])) + expected = np.array([[0.0, 0.0], [0.2, 0.4]]) + np.testing.assert_array_almost_equal(result, expected) + + +class TestContrastDecoderRoundTripTBR(unittest.TestCase): + + def test_round_trip_output_shape(self): + signal = np.array([0.1, 0.3, 0.4, 0.2, 0.5, 0.6]).reshape(-1, 1) + spikes, thresholds = threshold_based_representation(signal, factor=0.5) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertEqual(result.shape, signal.shape) + + def test_round_trip_output_is_ndarray(self): + signal = np.array([0.1, 0.3, 0.4, 0.2, 0.5, 0.6]) + spikes, thresholds = threshold_based_representation(signal, factor=0.5) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertIsInstance(result, np.ndarray) + + def test_round_trip_starts_at_signal_start_point(self): + signal = np.array([0.1, 0.3, 0.4, 0.2, 0.5, 0.6]) + spikes, thresholds = threshold_based_representation(signal, factor=0.5) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertAlmostEqual(result[0], signal[0]) + + def test_round_trip_multi_feature(self): + signal = np.array([[0.1, 0.5], [0.3, 0.4], [0.4, 0.6], [0.2, 0.3], [0.5, 0.7], [0.6, 0.8]]) + spikes, thresholds = threshold_based_representation(signal, factor=0.5) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertEqual(result.shape, signal.shape) + + def test_round_trip_zero_factor_all_zeros(self): + signal = np.array([0.5, 0.5, 0.5, 0.5, 0.5]).reshape(-1, 1) + spikes, thresholds = threshold_based_representation(signal, factor=0.0) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + np.testing.assert_array_almost_equal(result, np.full_like(signal, signal[0])) + + +class TestContrastDecoderRoundTripSF(unittest.TestCase): + + def test_round_trip_output_shape(self): + signal = np.array([0.1, 0.3, 0.4, 0.2, 0.5, 0.6]).reshape(-1, 1) + spikes, thresholds = step_forward(signal, threshold=0.2) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertEqual(result.shape, signal.shape) + + def test_round_trip_output_is_ndarray(self): + signal = np.array([0.1, 0.3, 0.4, 0.2, 0.5, 0.6]) + spikes, thresholds = step_forward(signal, threshold=0.2) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertIsInstance(result, np.ndarray) + + def test_round_trip_starts_at_signal_start_point(self): + signal = np.array([0.1, 0.3, 0.4, 0.2, 0.5, 0.6]) + spikes, thresholds = step_forward(signal, threshold=0.2) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertAlmostEqual(result[0], signal[0]) + + def test_round_trip_flat_signal_no_spikes(self): + signal = np.array([0.5, 0.5, 0.5, 0.5, 0.5]).reshape(-1, 1) + spikes, thresholds = step_forward(signal, threshold=0.2) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + np.testing.assert_array_almost_equal(result, np.full_like(signal, signal[0])) + + def test_round_trip_monotonic_increase(self): + signal = np.array([0.0, 0.2, 0.4, 0.6, 0.8, 1.0]).reshape(-1, 1) + spikes, thresholds = step_forward(signal, threshold=0.2) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertEqual(result.shape, signal.shape) + self.assertTrue(np.all(result >= signal[0])) + + def test_round_trip_multi_feature(self): + signal = np.array([[0.1, 0.5], [0.3, 0.4], [0.4, 0.6], [0.2, 0.3], [0.5, 0.7], [0.6, 0.8]]) + spikes, thresholds = step_forward(signal, threshold=0.2) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertEqual(result.shape, signal.shape) + + +class TestContrastDecoderRoundTripMW(unittest.TestCase): + + def test_round_trip_output_shape(self): + signal = np.array([0.1, 0.3, 0.2, 0.5, 0.8, 1.0]).reshape(-1, 1) + spikes, thresholds = moving_window(signal, window_length=3, threshold=0.2) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertEqual(result.shape, signal.shape) + + def test_round_trip_output_is_ndarray(self): + signal = np.array([0.1, 0.3, 0.2, 0.5, 0.8, 1.0]) + spikes, thresholds = moving_window(signal, window_length=3, threshold=0.2) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertIsInstance(result, np.ndarray) + + def test_round_trip_starts_at_signal_start_point(self): + signal = np.array([0.1, 0.3, 0.2, 0.5, 0.8, 1.0]) + spikes, thresholds = moving_window(signal, window_length=3, threshold=0.2) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertAlmostEqual(result[0], signal[0]) + + def test_round_trip_flat_signal_no_spikes(self): + signal = np.array([0.5, 0.5, 0.5, 0.5, 0.5]).reshape(-1, 1) + spikes, thresholds = moving_window(signal, window_length=3, threshold=0.2) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + np.testing.assert_array_almost_equal(result, np.full_like(signal, signal[0])) + + def test_round_trip_multi_feature(self): + signal = np.array([[0.1, 0.5], [0.3, 0.4], [0.4, 0.6], [0.2, 0.3], [0.5, 0.7], [0.6, 0.8]]) + spikes, thresholds = moving_window(signal, window_length=3, threshold=0.2) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertEqual(result.shape, signal.shape) + + def test_round_trip_window_length_one(self): + signal = np.array([0.1, 0.3, 0.2, 0.5, 0.8, 1.0]).reshape(-1, 1) + spikes, thresholds = moving_window(signal, window_length=1, threshold=0.1) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertEqual(result.shape, signal.shape) + + +class TestContrastDecoderRoundTripZCSF(unittest.TestCase): + + def test_round_trip_output_shape(self): + signal = np.array([-0.2, 0.1, 0.5, 0.0, 1.2, 0.3]).reshape(-1, 1) + spikes, thresholds = zero_cross_step_forward(signal, threshold=0.4) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertEqual(result.shape, signal.shape) + + def test_round_trip_output_is_ndarray(self): + signal = np.array([-0.2, 0.1, 0.5, 0.0, 1.2, 0.3]) + spikes, thresholds = zero_cross_step_forward(signal, threshold=0.4) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertIsInstance(result, np.ndarray) + + def test_round_trip_starts_at_signal_start_point(self): + signal = np.array([-0.2, 0.1, 0.5, 0.0, 1.2, 0.3]) + spikes, thresholds = zero_cross_step_forward(signal, threshold=0.4) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertAlmostEqual(result[0], signal[0]) + + def test_round_trip_all_negative_signal_no_spikes(self): + signal = np.array([-0.5, -0.3, -0.1, -0.8, -0.2]).reshape(-1, 1) + spikes, thresholds = zero_cross_step_forward(signal, threshold=0.4) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + np.testing.assert_array_almost_equal(result, np.full_like(signal, signal[0])) + + def test_round_trip_all_below_threshold_no_spikes(self): + signal = np.array([0.1, 0.2, 0.1, 0.3, 0.2]).reshape(-1, 1) + spikes, thresholds = zero_cross_step_forward(signal, threshold=0.4) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + np.testing.assert_array_almost_equal(result, np.full_like(signal, signal[0])) + + def test_round_trip_only_positive_spikes(self): + signal = np.array([-0.2, 0.1, 0.5, 0.0, 1.2, 0.3]) + spikes, thresholds = zero_cross_step_forward(signal, threshold=0.4) + self.assertTrue(np.all(spikes >= 0)) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertTrue(np.all(np.diff(result) >= 0)) + + def test_round_trip_multi_feature(self): + signal = np.array([[-0.2, 0.5], [0.1, 0.0], [0.5, 1.2], [0.0, 0.3], [1.2, 0.8], [0.3, 0.6]]) + spikes, thresholds = zero_cross_step_forward(signal, threshold=0.4) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + self.assertEqual(result.shape, signal.shape) + + def test_round_trip_high_threshold_no_spikes(self): + signal = np.array([0.1, 0.3, 0.2, 0.5, 0.4]).reshape(-1, 1) + spikes, thresholds = zero_cross_step_forward(signal, threshold=999.0) + result = contrast_decoder(spikes, thresholds, start_point=signal[0]) + np.testing.assert_array_almost_equal(result, np.full_like(signal, signal[0])) diff --git a/tests/decoders/temporal/contrast/test_decoder.py b/tests/decoders/temporal/contrast/test_decoder.py deleted file mode 100644 index 128c5a7..0000000 --- a/tests/decoders/temporal/contrast/test_decoder.py +++ /dev/null @@ -1,103 +0,0 @@ -import unittest -import numpy as np -from spikify.decoders.temporal.contrast.decoder_algorithm import contrast_decoder - - -class TestContrastDecoderInput(unittest.TestCase): - - def test_empty_spike_train_raises(self): - with self.assertRaises(ValueError): - contrast_decoder(np.array([]), threshold=0.2, start_point=0.1) - - def test_invalid_threshold_dimension_raises(self): - spikes = np.array([0, 1, -1, 0], dtype=np.int8) - with self.assertRaises(TypeError): - contrast_decoder(spikes, threshold=np.array([[0.2, 0.1], [0.3, 0.4]]), start_point=0.1) - - def test_invalid_start_point_dimension_raises(self): - spikes = np.array([0, 1, -1, 0], dtype=np.int8) - with self.assertRaises(TypeError): - contrast_decoder(spikes, threshold=0.2, start_point=np.array([[0.1, 0.2], [0.3, 0.4]])) - - def test_threshold_size_mismatch_raises(self): - spikes = np.array([[0, 1], [-1, 0], [1, -1]], dtype=np.int8) - with self.assertRaises(ValueError): - contrast_decoder(spikes, threshold=[0.2, 0.3, 0.4], start_point=0.1) - - def test_start_point_size_mismatch_raises(self): - spikes = np.array([[0, 1], [-1, 0], [1, -1]], dtype=np.int8) - with self.assertRaises(ValueError): - contrast_decoder(spikes, threshold=0.2, start_point=[0.1, 0.2, 0.3]) - - def test_output_is_ndarray(self): - spikes = np.array([0, 1, -1, 0], dtype=np.int8) - result = contrast_decoder(spikes, threshold=0.2, start_point=0.1) - self.assertIsInstance(result, np.ndarray) - - def test_1d_input_returns_1d_output(self): - spikes = np.array([0, 1, -1, 0], dtype=np.int8) - result = contrast_decoder(spikes, threshold=0.2, start_point=0.1) - self.assertEqual(result.ndim, 1) - - def test_2d_input_returns_2d_output(self): - spikes = np.array([[0, 1], [-1, 0], [1, -1]], dtype=np.int8) - result = contrast_decoder(spikes, threshold=0.2, start_point=0.1) - self.assertEqual(result.ndim, 2) - - def test_output_length_matches_input(self): - spikes = np.array([0, 1, -1, 0, 1], dtype=np.int8) - result = contrast_decoder(spikes, threshold=0.2, start_point=0.1) - self.assertEqual(len(result), len(spikes)) - - def test_output_shape_matches_2d_input(self): - spikes = np.array([[0, 1], [-1, 0], [1, -1]], dtype=np.int8) - result = contrast_decoder(spikes, threshold=0.2, start_point=0.1) - self.assertEqual(result.shape, spikes.shape) - - def test_all_zeros_returns_constant_signal(self): - spikes = np.array([0, 0, 0, 0], dtype=np.int8) - result = contrast_decoder(spikes, threshold=0.2, start_point=0.5) - expected = np.array([0.5, 0.5, 0.5, 0.5]) - np.testing.assert_array_almost_equal(result, expected) - - def test_all_positive_spikes_increments(self): - spikes = np.array([0, 1, 1, 1], dtype=np.int8) - result = contrast_decoder(spikes, threshold=0.2, start_point=0.1) - expected = np.array([0.1, 0.3, 0.5, 0.7]) - np.testing.assert_array_almost_equal(result, expected) - - def test_all_negative_spikes_decrements(self): - spikes = np.array([0, -1, -1, -1], dtype=np.int8) - result = contrast_decoder(spikes, threshold=0.2, start_point=0.7) - expected = np.array([0.7, 0.5, 0.3, 0.1]) - np.testing.assert_array_almost_equal(result, expected) - - def test_mixed_spikes_reconstruction(self): - spikes = np.array([0, 0, 0, 1, 1, 1], dtype=np.int8) - result = contrast_decoder(spikes, threshold=0.2, start_point=0.1) - expected = np.array([0.1, 0.1, 0.1, 0.3, 0.5, 0.7]) - np.testing.assert_array_almost_equal(result, expected) - - def test_alternating_spikes_returns_to_start(self): - spikes = np.array([0, 1, -1, 1, -1], dtype=np.int8) - result = contrast_decoder(spikes, threshold=0.2, start_point=0.5) - expected = np.array([0.5, 0.7, 0.5, 0.7, 0.5]) - np.testing.assert_array_almost_equal(result, expected) - - def test_scalar_threshold_and_start_point(self): - spikes = np.array([0, 1, 0, -1], dtype=np.int8) - result = contrast_decoder(spikes, threshold=0.1, start_point=0.0) - expected = np.array([0.0, 0.1, 0.1, 0.0]) - np.testing.assert_array_almost_equal(result, expected) - - def test_list_threshold_and_start_point(self): - spikes = np.array([[0, 0], [1, -1], [0, 1]], dtype=np.int8) - result = contrast_decoder(spikes, threshold=[0.2, 0.3], start_point=[0.1, 0.5]) - expected = np.array([[0.1, 0.5], [0.3, 0.2], [0.3, 0.5]]) - np.testing.assert_array_almost_equal(result, expected) - - def test_ndarray_threshold_and_start_point(self): - spikes = np.array([[0, 0], [1, 1]], dtype=np.int8) - result = contrast_decoder(spikes, threshold=np.array([0.2, 0.4]), start_point=np.array([0.0, 0.0])) - expected = np.array([[0.0, 0.0], [0.2, 0.4]]) - np.testing.assert_array_almost_equal(result, expected) From f9eed66253d3d7b34ed5c93971ca32205112781d Mon Sep 17 00:00:00 2001 From: benedettoleto Date: Tue, 21 Apr 2026 15:46:47 +0200 Subject: [PATCH 10/14] refactor: clarify documentation change return order arguments and remove flattening output --- .../deconvolution/bens_spiker_algorithm.py | 25 ++++++++----------- .../deconvolution/hough_spiker_algorithm.py | 22 +++++++--------- .../modified_hough_spiker_algorithm.py | 25 ++++++++----------- 3 files changed, 29 insertions(+), 43 deletions(-) diff --git a/spikify/encoders/temporal/deconvolution/bens_spiker_algorithm.py b/spikify/encoders/temporal/deconvolution/bens_spiker_algorithm.py index 943f384..07c6913 100644 --- a/spikify/encoders/temporal/deconvolution/bens_spiker_algorithm.py +++ b/spikify/encoders/temporal/deconvolution/bens_spiker_algorithm.py @@ -55,7 +55,7 @@ def bens_spiker( window_length = 3 threshold = 0.1 cutoff = 0.1 - encoded_signal, shift, fir_coeffs = bens_spiker(signal, window_length, cutoff, threshold) + encoded_signal, fir_coeffs, shift = bens_spiker(signal, window_length, cutoff, threshold) .. doctest:: :hide: @@ -66,11 +66,11 @@ def bens_spiker( >>> window_length = 3 >>> threshold = 0.1 >>> cutoff = 0.1 - >>> encoded_signal, shift, fir_coeffs = bens_spiker(signal, window_length, cutoff, threshold) - >>> encoded_signal + >>> encoded_signal, _, _ = bens_spiker(signal, window_length, cutoff, threshold) + >>> encoded_signal.flatten() array([0, 1, 1, 0, 0, 0, 0], dtype=int8) - :param signal: Input signal to encode (1D or 2D: time × features). + :param signal: Input signal to encode (1D or 2D: time × features or channels). :type signal: numpy.ndarray :param window_length: Length of the FIR filter (number of coefficients). :type window_length: int @@ -92,13 +92,12 @@ def bens_spiker( :param fs: Sampling frequency (used for physical frequency units in cutoff; optional). :type fs: float | None :return: - - spikes: Spike train (same shape as input, dtype=int8, values 0 or 1) - - shift: Per-feature shift values subtracted to make signal non-negative (1D array) - - fir_bank: Final filter coefficients used (after scaling), shape (window_length, n_features) + - spikes: A numpy array representing the encoded spike train. (values in {0, +1}) + - fir_bank: Final filter coefficients used, shape (window_length, features or channels). + - shift: Per-feature shift values subtracted to make signal non-negative, shape (features or channels,). :rtype: tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray] :raises ValueError: If the input signal is empty or if the threshold dimensions do not match the signal - features or if the window_length is greater than the signal lenght. - :raises TypeError: If the threshold parameter is of invalid dimension. + feature dimensions or if the window_length is greater than the signal lenght. """ @@ -122,7 +121,7 @@ def bens_spiker( else: thresholds = np.asarray(threshold, dtype=float) if thresholds.ndim != 1: - raise TypeError("Threshold must be a scalar or a 1D sequence of numbers.") + raise ValueError("Threshold must be a scalar or a 1D sequence of numbers.") if thresholds.size != F: raise ValueError("Threshold must match the number of features in the signal.") @@ -161,8 +160,4 @@ def bens_spiker( spikes[t, f] = 1 signal_copy[t : t + window_length, f] -= fir_bank[:, f] # update signal by removing filter effect - # Flatten if input was 1D - if F == 1: - spikes = spikes.flatten() - - return spikes, shift, fir_bank + return spikes, fir_bank, shift diff --git a/spikify/encoders/temporal/deconvolution/hough_spiker_algorithm.py b/spikify/encoders/temporal/deconvolution/hough_spiker_algorithm.py index b2f6438..fb1d662 100644 --- a/spikify/encoders/temporal/deconvolution/hough_spiker_algorithm.py +++ b/spikify/encoders/temporal/deconvolution/hough_spiker_algorithm.py @@ -46,7 +46,7 @@ def hough_spiker( signal = np.array([0.1, 0.2, 4.1, 1.0, 3.0, 0.3, 0.1]) window_length = 3 cutoff = 0.1 - encoded_signal, shift, norm, fir_coeffs = hough_spiker(signal, window_length, cutoff) + encoded_signal, fir_coeffs, shift, norm = hough_spiker(signal, window_length, cutoff) .. doctest:: :hide: @@ -56,11 +56,11 @@ def hough_spiker( >>> signal = np.array([0.1, 0.2, 4.1, 1.0, 3.0, 0.3, 0.1]) >>> window_length = 3 >>> cutoff = 0.1 - >>> encoded_signal, shift, norm, fir_coeffs = hough_spiker(signal, window_length, cutoff) - >>> encoded_signal + >>> encoded_signal, _, _, _ = hough_spiker(signal, window_length, cutoff) + >>> encoded_signal.flatten() array([0, 1, 0, 0, 0, 0, 0], dtype=int8) - :param signal: Input signal to encode (1D or 2D: time × features). + :param signal: Input signal to encode (1D or 2D: time × features or channels). :type signal: numpy.ndarray :param window_length: Length of the FIR filter (number of coefficients). :type window_length: int @@ -79,10 +79,10 @@ def hough_spiker( :param fs: Sampling frequency (used for physical frequency units in cutoff; optional). :type fs: float | None :return: - - spikes: Spike train (same shape as input, dtype=int8, values 0 or 1) - - shift: Per-feature shift values subtracted to make signal non-negative (1D array) - - norm: Per-feature normalization values used to scale signal to [0, 1] (1D array) - - fir_bank: Final filter coefficients used, shape (window_length, n_features) + - spikes: A numpy array representing the encoded spike train. (values in {0, +1}) + - fir_bank: Final filter coefficients used (window_length, features or channels). + - shift: Per-feature shift values subtracted to make signal non-negative, shape (features or channels,). + - norm: Per-feature normalization values used to scale signal to [0, 1], shape (features or channels,). :rtype: tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray] :raises ValueError: If the input signal is empty or if the window_length is greater than the signal lenght. @@ -129,8 +129,4 @@ def hough_spiker( signal_copy[t : t + window_length, f] -= fir_bank[:, f] spikes[t, f] = 1 - # Flatten if input was 1D - if F == 1: - spikes = spikes.flatten() - - return spikes, shift, norm, fir_bank + return spikes, fir_bank, shift, norm diff --git a/spikify/encoders/temporal/deconvolution/modified_hough_spiker_algorithm.py b/spikify/encoders/temporal/deconvolution/modified_hough_spiker_algorithm.py index fdc78e7..b568daa 100644 --- a/spikify/encoders/temporal/deconvolution/modified_hough_spiker_algorithm.py +++ b/spikify/encoders/temporal/deconvolution/modified_hough_spiker_algorithm.py @@ -54,7 +54,7 @@ def modified_hough_spiker( window_length = 3 threshold = 0.5 cutoff = 0.1 - encoded_signal, shift, norm, fir_coeffs = modified_hough_spiker(signal, window_length, cutoff, threshold) + encoded_signal, fir_coeffs, shift, norm, = modified_hough_spiker(signal, window_length, cutoff, threshold) .. doctest:: :hide: @@ -65,11 +65,11 @@ def modified_hough_spiker( >>> window_length = 3 >>> threshold = 0.5 >>> cutoff = 0.1 - >>> encoded_signal, shift, norm, fir_coeffs = modified_hough_spiker(signal, window_length, cutoff, threshold) - >>> encoded_signal + >>> encoded_signal, _, _, _ = modified_hough_spiker(signal, window_length, cutoff, threshold) + >>> encoded_signal.flatten() array([0, 0, 1, 1, 0, 0, 1], dtype=int8) - :param signal: Input signal to encode (1D or 2D: time × features). + :param signal: Input signal to encode (1D or 2D: time × features or channels). :type signal: numpy.ndarray :param window_length: Length of the FIR filter (number of coefficients). :type window_length: int @@ -91,14 +91,13 @@ def modified_hough_spiker( :param fs: Sampling frequency (used for physical frequency units in cutoff; optional). :type fs: float | None :return: - - spikes: Spike train (same shape as input, dtype=int8, values 0 or 1) - - shift: Per-feature shift values subtracted to make signal non-negative (1D array) - - norm: Per-feature normalization values used to scale signal to [0, 1] (1D array) - - fir_bank: Final filter coefficients used, shape (window_length, n_features) + - spikes: A numpy array representing the encoded spike train. (values in {0, +1}) + - fir_bank: Final filter coefficients used, shape (window_length, features or channels). + - shift: Per-feature shift values subtracted to make signal non-negative, shape (features or channels,). + - norm: Per-feature normalization values used to scale signal to [0, 1], shape (features or channels,). :rtype: tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray] :raises ValueError: If the input signal is empty or if the threshold dimensions do not match the signal features or if the window_length is greater than the signal lenght. - :raises TypeError: If the threshold parameter is of invalid dimension. """ # Check for empty signal @@ -121,7 +120,7 @@ def modified_hough_spiker( else: thresholds = np.asarray(threshold, dtype=float) if thresholds.ndim != 1: - raise TypeError("Threshold must be a scalar or a 1D sequence of numbers.") + raise ValueError("Threshold must be a scalar or a 1D sequence of numbers.") if thresholds.size != F: raise ValueError("Threshold must match the number of features in the signal.") @@ -163,8 +162,4 @@ def modified_hough_spiker( signal_copy[t:end_index, f] -= filter_segment spikes[t, f] = 1 - # Flatten if input was 1D - if F == 1: - spikes = spikes.flatten() - - return spikes, shift, norm, fir_bank + return spikes, fir_bank, shift, norm From 5af93a6a5c705217d33f3d52a7d242b7748ef758 Mon Sep 17 00:00:00 2001 From: benedettoleto Date: Tue, 21 Apr 2026 15:47:36 +0200 Subject: [PATCH 11/14] tests: return array dimension update --- .../temporal/deconvolution/test_ben_spiker.py | 18 ++++++++++++------ .../deconvolution/test_hough_spiker.py | 14 +++++++++++--- .../test_modified_hough_spiker.py | 17 +++++++++++++---- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/tests/encoders/temporal/deconvolution/test_ben_spiker.py b/tests/encoders/temporal/deconvolution/test_ben_spiker.py index efada42..9f570d5 100644 --- a/tests/encoders/temporal/deconvolution/test_ben_spiker.py +++ b/tests/encoders/temporal/deconvolution/test_ben_spiker.py @@ -16,6 +16,7 @@ def test_basic_functionality(self): cutoff = 0.2 expected_spikes = np.array([0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0]) result, _, _ = bens_spiker(signal, window_length, cutoff, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_threshold_sensitivity(self): @@ -27,9 +28,7 @@ def test_threshold_sensitivity(self): threshold_high = 10.0 cutoff = 0.2 result_low, _, _ = bens_spiker(signal, window_length, cutoff, threshold_low) - print(result_low) result_high, _, _ = bens_spiker(signal, window_length, cutoff, threshold_high) - print(result_high) self.assertTrue(np.any(result_low)) self.assertFalse(np.any(result_high)) @@ -71,7 +70,7 @@ def test_threshold_dimension(self): threshold = np.array([[0.1, 0.2], [0.3, 0.4]]) window_length = 3 cutoff = 0.2 - with self.assertRaises(TypeError): + with self.assertRaises(ValueError): bens_spiker(signal, window_length, cutoff, threshold) def test_no_matching_pattern(self): @@ -84,6 +83,7 @@ def test_no_matching_pattern(self): cutoff = 0.2 expected_spikes = np.array([0, 0, 0, 0, 0]) result, _, _ = bens_spiker(signal, window_length, cutoff, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_varying_filter_window_size(self): @@ -97,12 +97,14 @@ def test_varying_filter_window_size(self): window_length_3 = 3 expected_spikes_3 = np.array([0, 0, 0, 0, 0, 0, 0, 0, 0]) result_3, _, _ = bens_spiker(signal, window_length_3, cutoff, threshold) + result_3 = result_3.flatten() np.testing.assert_array_equal(result_3, expected_spikes_3) # Test case for filter window size of 5 window_length_5 = 5 expected_spikes_5 = np.array([0, 1, 0, 1, 0, 0, 0, 0, 0]) result_5, _, _ = bens_spiker(signal, window_length_5, cutoff, threshold) + result_5 = result_5.flatten() np.testing.assert_array_equal(result_5, expected_spikes_5) # Test case for filter window size of 7 @@ -110,6 +112,7 @@ def test_varying_filter_window_size(self): window_length_7 = 7 expected_spikes_7 = np.array([1, 0, 1, 0, 0, 0, 0, 0, 0]) result_7, _, _ = bens_spiker(signal, window_length_7, cutoff, threshold) + result_7 = result_7.flatten() np.testing.assert_array_equal(result_7, expected_spikes_7) def test_large_signal(self): @@ -120,6 +123,7 @@ def test_large_signal(self): threshold = 5.0 cutoff = 0.2 result, _, _ = bens_spiker(signal, window_length, cutoff, threshold) + result = result.flatten() self.assertEqual(len(result), len(signal)) def test_with_multiple_features(self): @@ -134,7 +138,9 @@ def test_with_multiple_features(self): signal_f1 = signal[:, 0] signal_f2 = signal[:, 1] encoded_signal_f1, _, _ = bens_spiker(signal_f1, window_length, cutoff, threshold[0]) + encoded_signal_f1 = encoded_signal_f1.flatten() encoded_signal_f2, _, _ = bens_spiker(signal_f2, window_length, cutoff, threshold[1]) + encoded_signal_f2 = encoded_signal_f2.flatten() np.testing.assert_array_equal(encoded_signal[:, 0], encoded_signal_f1) np.testing.assert_array_equal(encoded_signal[:, 1], encoded_signal_f2) @@ -155,7 +161,7 @@ def test_signal_with_negative_values(self): window_length = 3 cutoff = 0.4 threshold = 0.95 - _, shift, fir_coeff = bens_spiker(signal, window_length, cutoff, threshold) + _, fir_coeff, shift = bens_spiker(signal, window_length, cutoff, threshold) expected_shift = np.array([-0.1], dtype=np.float32) expected_fir_sum = 1.0 @@ -170,7 +176,7 @@ def test_signal_with_positive_values_lower_than_one(self): window_length = 3 cutoff = 0.4 threshold = 0.95 - _, shift, fir_coeff = bens_spiker(signal, window_length, cutoff, threshold) + _, fir_coeff, shift = bens_spiker(signal, window_length, cutoff, threshold) expected_shift = np.array([0.0], dtype=np.float32) expected_fir_sum = 1.0 @@ -185,7 +191,7 @@ def test_signal_with_positive_values_greater_than_one(self): window_length = 3 cutoff = 0.4 threshold = 0.95 - _, shift, fir_coeff = bens_spiker(signal, window_length, cutoff, threshold) + _, fir_coeff, shift = bens_spiker(signal, window_length, cutoff, threshold) expected_shift = np.array([0.0], dtype=np.float32) expected_fir_sum = 3.8 diff --git a/tests/encoders/temporal/deconvolution/test_hough_spiker.py b/tests/encoders/temporal/deconvolution/test_hough_spiker.py index b60075a..8e1cacd 100644 --- a/tests/encoders/temporal/deconvolution/test_hough_spiker.py +++ b/tests/encoders/temporal/deconvolution/test_hough_spiker.py @@ -15,6 +15,7 @@ def test_basic_functionality(self): cutoff = 0.1 expected_spikes = np.array([0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0]) result, _, _, _ = hough_spiker(signal, window_length, cutoff) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_empty_signal(self): @@ -54,6 +55,7 @@ def test_single_point_spike(self): cutoff = 0.2 expected_spikes = np.array([0, 1, 0, 0, 0, 0, 0, 0]) result, _, _, _ = hough_spiker(signal, window_length, cutoff) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_varying_filter_window_size(self): @@ -66,18 +68,21 @@ def test_varying_filter_window_size(self): window_length_3 = 3 expected_spikes_3 = np.array([0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]) result_3, _, _, _ = hough_spiker(signal, window_length_3, cutoff) + result_3 = result_3.flatten() np.testing.assert_array_equal(result_3, expected_spikes_3) # Test case for filter window size of 5 window_length_5 = 5 expected_spikes_5 = np.array([1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0]) result_5, _, _, _ = hough_spiker(signal, window_length_5, cutoff) + result_5 = result_5.flatten() np.testing.assert_array_equal(result_5, expected_spikes_5) # Test case for filter window size of 7 window_length_7 = 7 expected_spikes_7 = np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]) result_7, _, _, _ = hough_spiker(signal, window_length_7, cutoff) + result_7 = result_7.flatten() np.testing.assert_array_equal(result_7, expected_spikes_7) def test_large_signal(self): @@ -87,6 +92,7 @@ def test_large_signal(self): window_length = 10 cutoff = 0.1 result, _, _, _ = hough_spiker(signal, window_length, cutoff) + result = result.flatten() self.assertEqual(len(result), len(signal)) def test_with_multiple_features(self): @@ -100,7 +106,9 @@ def test_with_multiple_features(self): signal_f1 = signal[:, 0] signal_f2 = signal[:, 1] encoded_signal_f1 = hough_spiker(signal_f1, window_length, cutoff)[0] + encoded_signal_f1 = encoded_signal_f1.flatten() encoded_signal_f2 = hough_spiker(signal_f2, window_length, cutoff)[0] + encoded_signal_f2 = encoded_signal_f2.flatten() np.testing.assert_array_equal(encoded_signal[:, 0], encoded_signal_f1) np.testing.assert_array_equal(encoded_signal[:, 1], encoded_signal_f2) @@ -110,7 +118,7 @@ def test_signal_with_negative_values(self): signal = np.array([-0.1, 0.1, 0.2, 0.3, 0.4, 0.3, 0.2, 0.1], dtype=np.float32) window_length = 3 cutoff = 0.4 - _, shift, norm, fir_coeff = hough_spiker(signal, window_length, cutoff) + _, fir_coeff, shift, norm = hough_spiker(signal, window_length, cutoff) expected_shift = np.array([-0.1], dtype=np.float32) expected_norm = np.array([1.0], dtype=np.float32) @@ -126,7 +134,7 @@ def test_signal_with_positive_values_lower_than_one(self): signal = np.array([0.1, 0.3, 0.5, 0.7, 0.9, 0.6, 0.4, 0.2], dtype=np.float32) window_length = 3 cutoff = 0.4 - _, shift, norm, fir_coeff = hough_spiker(signal, window_length, cutoff) + _, fir_coeff, shift, norm = hough_spiker(signal, window_length, cutoff) expected_shift = np.array([0.0], dtype=np.float32) expected_norm = np.array([1.0], dtype=np.float32) @@ -142,7 +150,7 @@ def test_signal_with_positive_values_greater_than_one(self): signal = np.array([0.1, 0.3, 0.5, 0.7, 1.9, 1.6, 1.4, 0.2], dtype=np.float32) window_length = 3 cutoff = 0.4 - _, shift, norm, fir_coeff = hough_spiker(signal, window_length, cutoff) + _, fir_coeff, shift, norm = hough_spiker(signal, window_length, cutoff) expected_shift = np.array([0.0], dtype=np.float32) expected_norm = np.array([1.9], dtype=np.float32) diff --git a/tests/encoders/temporal/deconvolution/test_modified_hough_spiker.py b/tests/encoders/temporal/deconvolution/test_modified_hough_spiker.py index 1f88642..3c8cce5 100644 --- a/tests/encoders/temporal/deconvolution/test_modified_hough_spiker.py +++ b/tests/encoders/temporal/deconvolution/test_modified_hough_spiker.py @@ -16,6 +16,7 @@ def test_basic_functionality(self): cutoff = 0.2 expected_spikes = np.array([0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1]) result, _, _, _ = modified_hough_spiker(signal, window_length, cutoff, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_empty_signal(self): @@ -49,6 +50,7 @@ def test_no_matching_pattern(self): threshold = -0.1 expected_spikes = np.array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) result, _, _, _ = modified_hough_spiker(signal, window_length, cutoff, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_single_point_spike(self): @@ -60,6 +62,7 @@ def test_single_point_spike(self): threshold = 0.1 expected_spikes = np.array([0, 0, 1, 0, 0]) result, _, _, _ = modified_hough_spiker(signal, window_length, cutoff, threshold) + result = result.flatten() np.testing.assert_array_equal(result, expected_spikes) def test_varying_filter_window_size(self): @@ -72,18 +75,21 @@ def test_varying_filter_window_size(self): window_length_3 = 3 expected_spikes_3 = np.array([0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1]) result_3, _, _, _ = modified_hough_spiker(signal, window_length_3, cutoff, threshold) + result_3 = result_3.flatten() np.testing.assert_array_equal(result_3, expected_spikes_3) # Test case for filter window size of 5 window_length_5 = 5 expected_spikes_5 = np.array([1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 1]) result_5, _, _, _ = modified_hough_spiker(signal, window_length_5, cutoff, threshold) + result_5 = result_5.flatten() np.testing.assert_array_equal(result_5, expected_spikes_5) # Test case for filter window size of 7 window_length_7 = 7 expected_spikes_7 = np.array([1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1]) result_7, _, _, _ = modified_hough_spiker(signal, window_length_7, cutoff, threshold) + result_7 = result_7.flatten() np.testing.assert_array_equal(result_7, expected_spikes_7) def test_large_signal(self): @@ -94,6 +100,7 @@ def test_large_signal(self): cutoff = 0.1 threshold = 1.0 result, _, _, _ = modified_hough_spiker(signal, window_length, cutoff, threshold) + result = result.flatten() self.assertEqual(len(result), len(signal)) def test_with_multiple_features(self): @@ -108,7 +115,9 @@ def test_with_multiple_features(self): signal_f1 = signal[:, 0] signal_f2 = signal[:, 1] encoded_signal_f1, _, _, _ = modified_hough_spiker(signal_f1, window_length, cutoff, threshold[0]) + encoded_signal_f1 = encoded_signal_f1.flatten() encoded_signal_f2, _, _, _ = modified_hough_spiker(signal_f2, window_length, cutoff, threshold[1]) + encoded_signal_f2 = encoded_signal_f2.flatten() np.testing.assert_array_equal(encoded_signal[:, 0], encoded_signal_f1) np.testing.assert_array_equal(encoded_signal[:, 1], encoded_signal_f2) @@ -129,7 +138,7 @@ def test_threshold_dimension(self): threshold = np.array([[0.1, 0.2], [0.3, 0.4]]) window_length = 3 cutoff = 0.2 - with self.assertRaises(TypeError): + with self.assertRaises(ValueError): modified_hough_spiker(signal, window_length, cutoff, threshold) def test_signal_with_negative_values(self): @@ -139,7 +148,7 @@ def test_signal_with_negative_values(self): window_length = 3 cutoff = 0.4 threshold = 0.1 - _, shift, norm, fir_coeff = modified_hough_spiker(signal, window_length, cutoff, threshold) + _, fir_coeff, shift, norm = modified_hough_spiker(signal, window_length, cutoff, threshold) expected_shift = np.array([-0.1], dtype=np.float32) expected_norm = np.array([1.0], dtype=np.float32) @@ -156,7 +165,7 @@ def test_signal_with_positive_values_lower_than_one(self): window_length = 3 cutoff = 0.4 threshold = 0.1 - _, shift, norm, fir_coeff = modified_hough_spiker(signal, window_length, cutoff, threshold) + _, fir_coeff, shift, norm = modified_hough_spiker(signal, window_length, cutoff, threshold) expected_shift = np.array([0.0], dtype=np.float32) expected_norm = np.array([1.0], dtype=np.float32) @@ -173,7 +182,7 @@ def test_signal_with_positive_values_greater_than_one(self): window_length = 3 cutoff = 0.4 threshold = 0.1 - _, shift, norm, fir_coeff = modified_hough_spiker(signal, window_length, cutoff, threshold) + _, fir_coeff, shift, norm = modified_hough_spiker(signal, window_length, cutoff, threshold) expected_shift = np.array([0.0], dtype=np.float32) expected_norm = np.array([1.9], dtype=np.float32) From 28b6519d1b76f2d1e95dfed30e508d99da46ddce Mon Sep 17 00:00:00 2001 From: benedettoleto Date: Tue, 21 Apr 2026 15:48:17 +0200 Subject: [PATCH 12/14] tests: add new testunits for deconvolution decoder --- .../test_deconvolution_decoder.py | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 tests/decoders/temporal/deconvolution/test_deconvolution_decoder.py diff --git a/tests/decoders/temporal/deconvolution/test_deconvolution_decoder.py b/tests/decoders/temporal/deconvolution/test_deconvolution_decoder.py new file mode 100644 index 0000000..1fcb7ef --- /dev/null +++ b/tests/decoders/temporal/deconvolution/test_deconvolution_decoder.py @@ -0,0 +1,283 @@ +import unittest +import numpy as np +from spikify.decoders.temporal.deconvolution.decoder_algorithm import deconvolution_decoder +from spikify.encoders.temporal.deconvolution import ( + bens_spiker, + hough_spiker, + modified_hough_spiker, +) + + +class TestDeconvolutionDecoderInput(unittest.TestCase): + + def test_empty_spike_train_raises(self): + with self.assertRaises(ValueError): + deconvolution_decoder( + np.array([]), + fir_bank=np.array([[0.25, 0.5, 0.25]]).T, + shift=np.array([0.0]), + ) + + def test_output_is_ndarray(self): + spikes = np.array([0, 1, 0, 0], dtype=np.int8) + fir_bank = np.array([[0.25, 0.5, 0.25]]).T + shift = np.array([0.0]) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift) + self.assertIsInstance(result, np.ndarray) + + def test_1d_input_returns_2d_output(self): + spikes = np.array([0, 1, 0, 0], dtype=np.int8) + fir_bank = np.array([[0.25, 0.5, 0.25]]).T + shift = np.array([0.0]) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift) + self.assertEqual(result.ndim, 2) + + def test_2d_input_returns_2d_output(self): + spikes = np.array([[0, 0], [1, 0], [0, 1], [0, 0]], dtype=np.int8) + fir_bank = np.array([[0.25, 0.5, 0.25], [0.25, 0.5, 0.25]]).T + shift = np.array([0.0, 0.0]) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift) + self.assertEqual(result.ndim, 2) + + def test_output_length_matches_input(self): + spikes = np.array([0, 1, 0, 0, 1], dtype=np.int8) + fir_bank = np.array([[0.25, 0.5, 0.25]]).T + shift = np.array([0.0]) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift) + self.assertEqual(len(result), len(spikes)) + + def test_output_shape_matches_2d_input(self): + spikes = np.array([[0, 0], [1, 0], [0, 1], [0, 0]], dtype=np.int8) + fir_bank = np.array([[0.25, 0.5, 0.25], [0.25, 0.5, 0.25]]).T + shift = np.array([0.0, 0.0]) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift) + self.assertEqual(result.shape, spikes.shape) + + def test_all_zeros_with_no_norm_returns_only_shift(self): + spikes = np.array([0, 0, 0, 0], dtype=np.int8) + fir_bank = np.array([[0.25, 0.5, 0.25]]).T + shift = np.array([0.1]) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=None) + expected = np.full((4, 1), 0.1) + np.testing.assert_array_almost_equal(result, expected) + + def test_norm_none_applies_only_shift(self): + spikes = np.array([0, 0, 0, 0], dtype=np.int8) + fir_bank = np.array([[0.25, 0.5, 0.25]]).T + shift = np.array([0.5]) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=None) + self.assertTrue(np.all(result == 0.5)) + + def test_norm_provided_scales_output(self): + spikes = np.array([1, 0, 0, 0], dtype=np.int8) + fir_bank = np.array([[0.25, 0.5, 0.25]]).T + shift = np.array([0.0]) + norm = np.array([2.0]) + result_with_norm = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=norm) + result_no_norm = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=None) + # with norm=2.0, output should be 2x larger (before shift) + np.testing.assert_array_almost_equal(result_with_norm, result_no_norm * 2.0) + + def test_single_spike_produces_nonzero_output(self): + spikes = np.array([1, 0, 0, 0, 0], dtype=np.int8) + fir_bank = np.array([[0.25, 0.5, 0.25]]).T + shift = np.array([0.0]) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift) + self.assertTrue(np.any(result != 0.0)) + + def test_shift_offsets_output(self): + spikes = np.array([0, 0, 0, 0], dtype=np.int8) + fir_bank = np.array([[0.25, 0.5, 0.25]]).T + shift_a = np.array([0.0]) + shift_b = np.array([1.0]) + result_a = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift_a) + result_b = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift_b) + np.testing.assert_array_almost_equal(result_b - result_a, np.full((4, 1), 1.0)) + + def test_multi_feature_output_shape(self): + spikes = np.array([[0, 1], [1, 0], [0, 0], [0, 1]], dtype=np.int8) + fir = np.array([0.25, 0.5, 0.25]) + fir_bank = np.stack([fir, fir], axis=0).T + shift = np.array([0.0, 0.1]) + norm = np.array([1.0, 2.0]) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=norm) + self.assertEqual(result.shape, spikes.shape) + + def test_multi_feature_independent_shift(self): + spikes = np.zeros((4, 2), dtype=np.int8) + fir = np.array([0.25, 0.5, 0.25]) + fir_bank = np.stack([fir, fir], axis=0).T + shift = np.array([0.2, 0.8]) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=None) + np.testing.assert_array_almost_equal(result[:, 0], np.full(4, 0.2)) + np.testing.assert_array_almost_equal(result[:, 1], np.full(4, 0.8)) + + +class TestDeconvolutionDecoderRoundTripBSA(unittest.TestCase): + + def test_round_trip_output_shape(self): + signal = np.array([0.1, 0.2, 0.8, 0.95, 0.5, 0.3, 0.1]).reshape(-1, 1) + spikes, fir_bank, shift = bens_spiker(signal, window_length=3, cutoff=0.1, threshold=0.1) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift) + self.assertEqual(result.shape, signal.shape) + + def test_round_trip_output_is_ndarray(self): + signal = np.array([0.1, 0.2, 0.8, 0.95, 0.5, 0.3, 0.1]) + spikes, fir_bank, shift = bens_spiker(signal, window_length=3, cutoff=0.1, threshold=0.1) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift) + self.assertIsInstance(result, np.ndarray) + + def test_round_trip_flat_signal_no_spikes(self): + signal = np.array([0.5, 0.5, 0.5, 0.5, 0.5]).reshape(-1, 1) + spikes, fir_bank, shift = bens_spiker(signal, window_length=3, cutoff=0.1, threshold=0.1) + self.assertTrue(np.all(spikes == 0)) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift) + np.testing.assert_array_almost_equal(result, np.full_like(signal, shift[0])) + + def test_round_trip_positive_signal_no_shift(self): + signal = np.array([0.1, 0.3, 0.6, 0.8, 0.5]) + spikes, fir_bank, shift = bens_spiker(signal, window_length=3, cutoff=0.1, threshold=0.1) + np.testing.assert_array_almost_equal(shift, np.array([0.0])) + + def test_round_trip_negative_signal_applies_shift(self): + signal = np.array([-0.5, 0.1, 0.3, 0.6, 0.2]) + spikes, fir_bank, shift = bens_spiker(signal, window_length=3, cutoff=0.1, threshold=0.1) + self.assertAlmostEqual(shift[0], -0.5) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift) + self.assertEqual(result.shape[0], len(signal)) + + def test_round_trip_bsa_does_not_return_norm(self): + signal = np.array([0.1, 0.3, 0.6, 0.8, 0.5, 0.2, 0.4]) + result_tuple = bens_spiker(signal, window_length=3, cutoff=0.1, threshold=0.1) + # BSA returns (spikes, fir_bank, shift) — no norm + self.assertEqual(len(result_tuple), 3) + + def test_round_trip_multi_feature(self): + signal = np.array([[0.1, 0.5], [0.3, 0.4], [0.6, 0.9], [0.8, 0.7], [0.5, 0.3], [0.2, 0.1]]) + spikes, fir_bank, shift = bens_spiker(signal, window_length=3, cutoff=0.1, threshold=0.1) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift) + self.assertEqual(result.shape, signal.shape) + + def test_round_trip_high_threshold_no_spikes(self): + signal = np.array([0.1, 0.3, 0.6, 0.8, 0.5, 0.2, 0.4]).reshape(-1, 1) + spikes, fir_bank, shift = bens_spiker(signal, window_length=3, cutoff=0.1, threshold=999.0) + self.assertTrue(np.all(spikes == 0)) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift) + np.testing.assert_array_almost_equal(result, np.full_like(signal, shift[0])) + + def test_round_trip_output_non_negative_for_non_negative_signal(self): + signal = np.array([0.1, 0.4, 0.7, 0.9, 0.6, 0.3, 0.2]) + spikes, fir_bank, shift = bens_spiker(signal, window_length=3, cutoff=0.1, threshold=0.05) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift) + self.assertTrue(np.all(result >= 0)) + + def test_round_trip_large_amplitude_signal(self): + signal = np.array([1.0, 2.5, 5.0, 3.0, 1.5, 0.5, 0.2]) + spikes, fir_bank, shift = bens_spiker(signal, window_length=3, cutoff=0.1, threshold=0.1) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift) + self.assertEqual(result.shape[0], len(signal)) + + +class TestDeconvolutionDecoderRoundTripMHSA(unittest.TestCase): + + def test_round_trip_output_shape(self): + signal = np.array([0.1, 0.2, 0.3, 1.0, 0.5, 0.3, 0.1]).reshape(-1, 1) + spikes, fir_bank, shift, norm = modified_hough_spiker(signal, window_length=3, cutoff=0.1, threshold=0.5) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=norm) + self.assertEqual(result.shape, signal.shape) + + def test_round_trip_output_is_ndarray(self): + signal = np.array([0.1, 0.2, 0.3, 1.0, 0.5, 0.3, 0.1]) + spikes, fir_bank, shift, norm = modified_hough_spiker(signal, window_length=3, cutoff=0.1, threshold=0.5) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=norm) + self.assertIsInstance(result, np.ndarray) + + def test_round_trip_mhsa_returns_norm(self): + signal = np.array([0.1, 0.3, 0.6, 0.8, 0.5, 0.2, 0.4]) + result_tuple = modified_hough_spiker(signal, window_length=3, cutoff=0.1, threshold=0.5) + # MHSA returns (spikes, fir_bank, shift, norm) + self.assertEqual(len(result_tuple), 4) + + def test_round_trip_negative_signal_applies_shift(self): + signal = np.array([-0.5, 0.1, 0.3, 0.6, 0.2, 0.4, 0.1]) + spikes, fir_bank, shift, norm = modified_hough_spiker(signal, window_length=3, cutoff=0.1, threshold=0.5) + self.assertAlmostEqual(shift[0], -0.5) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=norm) + self.assertEqual(result.shape[0], len(signal)) + + def test_round_trip_multi_feature(self): + signal = np.array([[0.1, 0.5], [0.3, 0.4], [0.4, 0.6], [0.2, 0.3], [0.5, 0.7], [0.6, 0.8]]) + spikes, fir_bank, shift, norm = modified_hough_spiker(signal, window_length=3, cutoff=0.1, threshold=0.5) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=norm) + self.assertEqual(result.shape, signal.shape) + + def test_round_trip_large_amplitude_applies_norm(self): + signal = np.array([1.0, 2.5, 5.0, 3.0, 1.5, 0.5, 0.2]) + spikes, fir_bank, shift, norm = modified_hough_spiker(signal, window_length=3, cutoff=0.1, threshold=0.5) + # norm should be > 1 since signal exceeds 1 after shift + self.assertTrue(norm[0] > 1) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=norm) + self.assertEqual(result.shape[0], len(signal)) + + def test_round_trip_only_unipolar_spikes(self): + signal = np.array([0.1, 0.2, 0.3, 1.0, 0.5, 0.3, 0.1]) + spikes, fir_bank, shift, norm = modified_hough_spiker(signal, window_length=3, cutoff=0.1, threshold=0.5) + self.assertTrue(np.all(spikes >= 0)) + + +class TestDeconvolutionDecoderRoundTripHSA(unittest.TestCase): + + def test_round_trip_output_shape(self): + signal = np.array([0.1, 0.2, 4.1, 1.0, 3.0, 0.3, 0.1]).reshape(-1, 1) + spikes, fir_bank, shift, norm = hough_spiker(signal, window_length=3, cutoff=0.1) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=norm) + self.assertEqual(result.shape, signal.shape) + + def test_round_trip_output_is_ndarray(self): + signal = np.array([0.1, 0.2, 4.1, 1.0, 3.0, 0.3, 0.1]) + spikes, fir_bank, shift, norm = hough_spiker(signal, window_length=3, cutoff=0.1) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=norm) + self.assertIsInstance(result, np.ndarray) + + def test_round_trip_hsa_returns_norm(self): + signal = np.array([0.1, 0.3, 0.6, 0.8, 0.5, 0.2, 0.4]) + result_tuple = hough_spiker(signal, window_length=3, cutoff=0.1) + # HSA returns (spikes, fir_bank, shift, norm) + self.assertEqual(len(result_tuple), 4) + + def test_round_trip_only_unipolar_spikes(self): + signal = np.array([0.1, 0.2, 4.1, 1.0, 3.0, 0.3, 0.1]) + spikes, fir_bank, shift, norm = hough_spiker(signal, window_length=3, cutoff=0.1) + self.assertTrue(np.all(spikes >= 0)) + + def test_round_trip_negative_signal_applies_shift(self): + signal = np.array([-0.5, 0.1, 0.3, 0.6, 0.2, 0.4, 0.1]) + spikes, fir_bank, shift, norm = hough_spiker(signal, window_length=3, cutoff=0.1) + self.assertAlmostEqual(shift[0], -0.5) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=norm) + self.assertEqual(result.shape[0], len(signal)) + + def test_round_trip_large_amplitude_applies_norm(self): + signal = np.array([1.0, 2.5, 5.0, 3.0, 1.5, 0.5, 0.2]) + spikes, fir_bank, shift, norm = hough_spiker(signal, window_length=3, cutoff=0.1) + self.assertTrue(norm[0] > 1) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=norm) + self.assertEqual(result.shape[0], len(signal)) + + def test_round_trip_multi_feature(self): + signal = np.array([[0.1, 0.5], [0.3, 0.4], [0.4, 0.6], [0.2, 0.3], [0.5, 0.7], [0.6, 0.8]]) + spikes, fir_bank, shift, norm = hough_spiker(signal, window_length=3, cutoff=0.1) + result = deconvolution_decoder(spikes, fir_bank=fir_bank, shift=shift, norm=norm) + self.assertEqual(result.shape, signal.shape) + + def test_round_trip_hsa_vs_mhsa_same_signal_different_sparsity(self): + # HSA is stricter than MHSA, so it should produce fewer or equal spikes + signal = np.array([0.1, 0.2, 0.5, 0.9, 0.7, 0.4, 0.2]) + hsa_spikes, _, _, _ = hough_spiker(signal, window_length=3, cutoff=0.1) + mhsa_spikes, _, _, _ = modified_hough_spiker(signal, window_length=3, cutoff=0.1, threshold=0.3) + self.assertLessEqual(hsa_spikes.sum(), mhsa_spikes.sum()) + + def test_round_trip_window_length_equals_signal_length_raises(self): + signal = np.array([0.1, 0.3, 0.5]) + with self.assertRaises(ValueError): + hough_spiker(signal, window_length=4, cutoff=0.1) From c559a4de8c3c34a8891072b19f13efd3c5ed8e3b Mon Sep 17 00:00:00 2001 From: benedettoleto Date: Tue, 21 Apr 2026 15:48:36 +0200 Subject: [PATCH 13/14] docs: add documentation for deconvolution decoder --- .../temporal_decoding/contrast/index.rst | 2 +- .../deconvolution/decoder.rst | 8 +++++++ .../temporal_decoding/deconvolution/index.rst | 22 +++++++++++++++++++ .../decoders/temporal_decoding/index.rst | 1 + 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 docs/api/python/decoders/temporal_decoding/deconvolution/decoder.rst create mode 100644 docs/api/python/decoders/temporal_decoding/deconvolution/index.rst diff --git a/docs/api/python/decoders/temporal_decoding/contrast/index.rst b/docs/api/python/decoders/temporal_decoding/contrast/index.rst index df2201f..5069dd6 100644 --- a/docs/api/python/decoders/temporal_decoding/contrast/index.rst +++ b/docs/api/python/decoders/temporal_decoding/contrast/index.rst @@ -7,7 +7,7 @@ The ``contrast`` folder within the temporal decoding module includes the algorit Contents of the ``contrast`` folder: -- **Contrast Decoder**: A unified decoding algorithm compatible with spike trains produced by the Moving Window (MW), Step-Forward (SF), and Threshold-Based (TB) encoders. +- **Contrast Decoder**: A unified decoding algorithm compatible with spike trains produced by the Moving Window (MW), Step-Forward (SF), Threshold-Based (TB) encoders ans Zero-Crossing Step Forward (ZCSF). Below, you will find links to the specific modules for the contrast decoding algorithm: diff --git a/docs/api/python/decoders/temporal_decoding/deconvolution/decoder.rst b/docs/api/python/decoders/temporal_decoding/deconvolution/decoder.rst new file mode 100644 index 0000000..2ef626e --- /dev/null +++ b/docs/api/python/decoders/temporal_decoding/deconvolution/decoder.rst @@ -0,0 +1,8 @@ +.. _deconvolution_decoder_function: + +.. title:: Deconvolution Decoder + +.. automodule:: spikify.decoders.temporal.deconvolution.decoder_algorithm + :members: deconvolution_decoder + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/api/python/decoders/temporal_decoding/deconvolution/index.rst b/docs/api/python/decoders/temporal_decoding/deconvolution/index.rst new file mode 100644 index 0000000..32aa45c --- /dev/null +++ b/docs/api/python/decoders/temporal_decoding/deconvolution/index.rst @@ -0,0 +1,22 @@ +.. _deconvolution_decoder: + +:octicon:`file-directory;0.9em;sd-mr-1 fill-primary` deconvolution +==================================================================== + +The ``deconvolution`` module within the temporal decoding module includes the algorithm responsible for +reconstructing the original continuous signal from spike trains generated by any of the Deconvolution +family encoders. Since the encoding methods in this family share a common reconstruction approach based +on causal linear filtering with the same FIR filter used during encoding, a single unified decoder is +able to handle all of them. + +Contents of the ``deconvolution`` folder: + +- **Deconvolution Decoder**: A unified decoding algorithm compatible with spike trains produced by the + Hough Spiker (HSA), Modified Hough Spiker (MHSA), and Ben's Spiker (BSA) encoders. + +Below, you will find links to the specific modules for the deconvolution decoding algorithm: + +.. toctree:: + :maxdepth: 1 + + decoder diff --git a/docs/api/python/decoders/temporal_decoding/index.rst b/docs/api/python/decoders/temporal_decoding/index.rst index 43b8095..0717a7a 100644 --- a/docs/api/python/decoders/temporal_decoding/index.rst +++ b/docs/api/python/decoders/temporal_decoding/index.rst @@ -16,3 +16,4 @@ Below, you will find links to the specific modules for each temporal decoding al :maxdepth: 1 contrast/index + deconvolution/index From 61c4f61069f24b545fe9320cc52dd39162a6d77b Mon Sep 17 00:00:00 2001 From: benedettoleto Date: Tue, 21 Apr 2026 15:58:12 +0200 Subject: [PATCH 14/14] docs: stryle optimization --- spikify/decoders/temporal/contrast/decoder_algorithm.py | 2 +- spikify/encoders/temporal/contrast/moving_window_algorithm.py | 2 +- spikify/encoders/temporal/contrast/step_forward_algorithm.py | 2 +- spikify/encoders/temporal/contrast/threshold_based_algorithm.py | 2 +- .../temporal/contrast/zero_cross_step_forward_algorithm.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/spikify/decoders/temporal/contrast/decoder_algorithm.py b/spikify/decoders/temporal/contrast/decoder_algorithm.py index 5cd3d59..d08fd35 100644 --- a/spikify/decoders/temporal/contrast/decoder_algorithm.py +++ b/spikify/decoders/temporal/contrast/decoder_algorithm.py @@ -66,7 +66,7 @@ def contrast_decoder( Should be set to the first sample of the original signal before encoding (e.g. ``signal[0]``), as the Contrast family encodes only signal differences and the absolute offset must be restored manually. :type start_point: float | int | list[float | int] | numpy.ndarray - :return: A numpy array representing the reconstructed continuous signal. + :return: A numpy array representing the reconstructed continuous signal approximation. :rtype: numpy.ndarray :raises ValueError: If the input spike train is empty or if the start_point dimensions do not match the spike train feature dimensions. diff --git a/spikify/encoders/temporal/contrast/moving_window_algorithm.py b/spikify/encoders/temporal/contrast/moving_window_algorithm.py index 2d3a8c7..f67ce17 100644 --- a/spikify/encoders/temporal/contrast/moving_window_algorithm.py +++ b/spikify/encoders/temporal/contrast/moving_window_algorithm.py @@ -52,7 +52,7 @@ def moving_window( :return: - spikes: A numpy array representing the encoded spike train. (values in {-1, 0, +1}) - thresholds: Per-feature or channel thresholds used for encoding, returned for use in decoding, - shape (features or channels,). + shape (features or channels,). :rtype: tuple[numpy.ndarray, numpy.ndarray] :raises ValueError: If the input signal is empty or if the threshold dimensions do not match the signal f eature dimensions. diff --git a/spikify/encoders/temporal/contrast/step_forward_algorithm.py b/spikify/encoders/temporal/contrast/step_forward_algorithm.py index 1fd2224..7131946 100644 --- a/spikify/encoders/temporal/contrast/step_forward_algorithm.py +++ b/spikify/encoders/temporal/contrast/step_forward_algorithm.py @@ -47,7 +47,7 @@ def step_forward( :return: - spikes: A numpy array representing the encoded spike train. (values in {-1, 0, +1}) - thresholds: Per-feature or channel thresholds used for encoding, returned for use in decoding, - shape (features or channels,). + shape (features or channels,). :rtype: tuple[numpy.ndarray, numpy.ndarray] :raises ValueError: If the input signal is empty or if the threshold dimensions do not match the signal features dimensions. diff --git a/spikify/encoders/temporal/contrast/threshold_based_algorithm.py b/spikify/encoders/temporal/contrast/threshold_based_algorithm.py index ae5b5be..d3cbb09 100644 --- a/spikify/encoders/temporal/contrast/threshold_based_algorithm.py +++ b/spikify/encoders/temporal/contrast/threshold_based_algorithm.py @@ -48,7 +48,7 @@ def threshold_based_representation( :return: - spikes: A numpy array representing the encoded spike train. (values in {-1, 0, +1}) - thresholds: Per-feature or channel thresholds used for encoding, returned for use in decoding, - shape (features or channels,). + shape (features or channels,). :rtype: tuple[numpy.ndarray, numpy.ndarray] :raises ValueError: If the input signal is empty or if the factor length does not match the number of features. diff --git a/spikify/encoders/temporal/contrast/zero_cross_step_forward_algorithm.py b/spikify/encoders/temporal/contrast/zero_cross_step_forward_algorithm.py index 1db5f6f..d141380 100644 --- a/spikify/encoders/temporal/contrast/zero_cross_step_forward_algorithm.py +++ b/spikify/encoders/temporal/contrast/zero_cross_step_forward_algorithm.py @@ -48,7 +48,7 @@ def zero_cross_step_forward( :return: - spikes: A numpy array representing the encoded spike train. (values in {0, +1}) - thresholds: Per-feature or channel thresholds used for encoding, returned for use in decoding, - shape (features or channels,). + shape (features or channels,). :rtype: tuple[numpy.ndarray, numpy.ndarray] :raises ValueError: If the input signal is empty or if the threshold dimensions do not match the signal features.