Decoders

The decoder is responsible for predicting behavior from electrophysiology raw data. In the BCI closed loop paradigm, the decoder is consuming the neural data and its output is used to control an external device (e.g., a cursor on a screen) in order to perform a task.

NDS comes with a basic, yet complete, decoder implementation that can be used as a template to guide your own development. The NDS decoder input is the raw data stream from the ephys generator and the output is the predicted cursor movement in the horizontal and vertical directions.

Provided decoder model

The complete decoder implementation including the signal processing (spike detection and spike rate estimation) is contained in the decoder.decoders.Decoder class. This class is using a linear regression model that predicts cursor movement from spike rates. See the Train models for the encoder and decoder example for more details on how the model was trained.

The decoder.decoders.Decoder class can also be used with a custom decoder model implementation that conforms to the decoder.decoders.DecoderModel protocol.

Configuring and running the included decoder

To configure the decoder, change the file settings_decoder.yaml, which is located by default in the $HOME/.nds/ folder. You can point the script to use a different configuration file by passing the --settings-path argument.

Upon start, the decoder connects to the LSL input raw data stream and creates an LSL outlet to write the predicted behavior to. If the input stream cannot be found, the decoder will not be able to start, therefore make sure that the ephys generator is running before starting the decoder.

To use a different model file for predicting velocities, change the following config:

decoder:
  model_file: "sample_data/session_4_simple_decoder.joblib"

The threshold for spike detection can be adjusted by updating the config:

decoder:
  spike_threshold: -200 # uV

To start the decoder, complete the installation with the [extras] option, then run the script:

decoder

Integrating a new decoder

If the included Decoder is not well suited for your use case and training a new decoder model by following the example is not a good option, you can create your own decoder and integrate it into the NDS closed loop. For a seamless integration your decoder should satisfy the following requirements:

  • provide an LSL outlet named NDS-Decoder. By default, the GUI is configured to read data from this outlet.

  • output at 50 Hz. By default, the GUI loop is running at 50 Hz.

  • output data is an array with shape (n_samples, 2) where the 2 columns correspond to the velocity in the horizontal and vertical direction.

A new decoder can read data from one of the different LSL streams that NDS provides by default:

  • NDS-RawData: the raw spiking data stream.

  • NDS-LFPData: the local field potentials stream.

  • NDS-SpikeEvents: the stream containing spike events for each unit.

  • NDS-SpikeRates: the stream containing spike rates for each channel.

Note

When adding a new decoder to the loop, ensure that the default decoder is not running in order to prevent an LSL outlet name conflict. To achieve this change the run-closed-loop Makefile target removing the poetry run decoder command.

For example, a decoder that reads based on the NDS-SpikeRates stream could be implemented as follows:

import os
import time

import joblib
import numpy as np
import pylsl
from neural_data_simulator.util.runtime import get_sample_data_dir


def get_lsl_outlet():
    stream_info = pylsl.StreamInfo(
        name="NDS-Decoder",
        type="behavior",
        channel_count=2,
        nominal_srate=50,
        channel_format="float32",
        source_id="centerout_behavior",
    )
    channels_info = stream_info.desc().append_child("channels")
    for ch_name in ["vel_x", "vel_y"]:
        ch = channels_info.append_child("channel")
        ch.append_child_value("label", ch_name)

    return pylsl.StreamOutlet(stream_info)


def get_lsl_inlet():
    stream_infos = pylsl.resolve_byprop("name", "NDS-SpikeRates")
    if len(stream_infos) > 0:
        return pylsl.StreamInlet(stream_infos[0])
    else:
        raise ConnectionError("Inlet could not find requested LSL stream.")


def main():
    loaded_decoder = joblib.load(
        os.path.join(get_sample_data_dir(), "session_4_simple_decoder.joblib")
    )
    n_channels = 190
    decoder_interval = 20 * 1e6  # nanoseconds

    lsl_outlet = get_lsl_outlet()
    lsl_inlet = get_lsl_inlet()
    last_run_time = time.perf_counter_ns()

    while True:
        time_now = time.perf_counter_ns()
        elapsed_time_ns = time_now - last_run_time
        if elapsed_time_ns >= decoder_interval:
            samples, _ = lsl_inlet.pull_chunk()
            if len(samples) > 0:
                spike_rates = np.array(samples)[0]
                velocities = loaded_decoder.predict(spike_rates.reshape(-1, n_channels))
                lsl_outlet.push_sample(velocities[0])
            last_run_time = time_now

        else:
            time.sleep(0.0001)


if __name__ == "__main__":
    main()

This decoder is not practical because it is simply reversing the output of the encoder, but it can serve as a starting point for your own decoder since it satisfies the requirements mentioned above.