import matplotlib.pyplot as plt
import numpy as np
import pynapple as nap11 Neural analysis with Pynapple (coding)
This chapter is an introduction to Pynapple.
Pynapple is a python package for analysing neural data.
The package is actively maintained by the Flatiron Institute’s neuroRSE team.
Pynapple can do a lot more than what is covered in this chapter, take a look at the Pynapple documentation for more.
The only thing you’ll need to follow along is a working Python environment with pynapple installed.
See the setup section on how to set up the course environment.
If you want to run this by itself, just install Pynapple using:
pip install pynappleThen open your favourite way of running Python, import the right packages, and follow along!
11.1 Origins
2000: TSToolbox, MATLAB
McNaughton lab: David Redish & Francesco Battaglia2016: TSToolbox2, MATLAB
Adrien Peyrache & Luke Sjulson2018: neuroseries, Python
Francesco Battaglia2021: pynapple, Python
Guillaume Viejo
presently with the Flatiron Institute’s neuroRSE team:
Guillaume Viejo
Sarah Jo Venditto
Edoardo Balzani
William Broderick
Wolf De Wulf, a 2025 neuroRSE intern
11.2 Goal
Pynapple was designed to lie in between data processing and analysis. It contains functions facilitating alignment, wrangling, and performing basic neuroscientific analysis.
Recording

Processing
segmenting (caiman)
spike-sorting (spikeinterface)

Analysis
model fitting (nemos)
interpretation (you)
11.3 Pynapple fundamentals
11.3.1 Time series (Ts)
We store a time series, for example a spike train, in a Ts object.
ts = nap.Ts(t=[1,2,3,4,5])
tsTime (s)
1.0
2.0
3.0
4.0
5.0
shape: 5
Show plotting code
fig, ax = plt.subplots(constrained_layout=True, figsize=(5,3))
for timepoint in ts.times():
ax.axvline(timepoint)
ax.yaxis.set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.set_xlabel("time (s)")
plt.show()
11.3.2 Time series with data (Tsd)
We store a time series with corresponding data values, for example the speed of a mouse when it is running around, in a Tsd object.
tsd = nap.Tsd(t=[1,2,3,4,5], d=[1,1,1,2,1])
tsdTime (s)
---------- --
1 1
2 1
3 1
4 2
5 1
dtype: int64, shape: (5,)
Show plotting code
fig, ax = plt.subplots(constrained_layout=True, figsize=(5,3))
ax.plot(tsd)
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.set_xlabel("time (s)")
ax.set_ylabel("data (a.u.)")
plt.show()
11.3.3 Multiple time series
If we have multiple time series, they can be collected in a TsGroup.
This could be a collection of spike times for multiple spiking units, for example.
tsgroup = nap.TsGroup({
1: nap.Ts([1,2,3,4,5]),
2: nap.Ts([1.5, 2.2, 2.9, 4.2])
})
tsgroup Index rate
------- ------
1 1.25
2 1
Show plotting code
cmap = plt.get_cmap("tab10")
fig, ax = plt.subplots(constrained_layout=True, figsize=(5,3))
for i in tsgroup:
ax.vlines(tsgroup[i].times(), i, i+1, label=i, color=cmap(i-1))
ax.yaxis.set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.set_xlabel("time (s)")
plt.ylim(1,3)
plt.legend(frameon=False)
plt.show()
11.3.4 Time series with multiple data columns (TsdFrame)
If we have a time series with multiple data associated with the same time points, they can be stored in a TsdFrame.
This could be a collection of activity traces from multiple cells, e.g. recorded using calcium-imaging.
tsdframe = nap.TsdFrame(
t=[1,2,3,4,5],
d=[[1,2],
[1,2],
[1,3],
[2,1],
[1,2]],
columns=["A", "B"]
)
tsdframeTime (s) A B
---------- --- ---
1 1 2
2 1 2
3 1 3
4 2 1
5 1 2
dtype: int64, shape: (5, 2)
Show plotting code
fig, ax = plt.subplots(constrained_layout=True, figsize=(5,3))
for i, col in enumerate(tsdframe.columns):
ax.plot(tsdframe[:, i], label=col)
ax.set_ylabel("data (a.u.)")
ax.set_xlabel("time (s)")
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
plt.legend(frameon=False)
plt.show()
11.3.5 Intervals (IntervalSet)
Often we want to store events that occur across a period rather than at single time points, for example a behavioural stimulus that lasts multiple seconds, or a period of sleep. To represent this, we store time intervals as start and end pairs in an IntervalSet.
epochs = nap.IntervalSet(
start=[1, 3, 7],
end=[2, 5, 9]
)
epochs index start end
0 1 2
1 3 5
2 7 9
shape: (3, 2), time unit: sec.
Show plotting code
fig, ax = plt.subplots(constrained_layout=True, figsize=(5,3))
for start, stop in epochs.values:
ax.axvspan(start, stop)
ax.set_xlabel("time (s)")
ax.spines['left'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
plt.show()
11.4 Interactions
A key goal of Pynapple is to facilitate integrating neural activity and behavioural data for processing and analysis. Therefore, many Pynapple objects interact with each other.
11.4.1 Time supports (time_support)
Every time series object has a time support, an IntervalSet that gives the epochs over which the time series is defined.
ts = nap.Ts(t=[1,2,3,4,5])
ts.time_support index start end
0 1 5
shape: (1, 2), time unit: sec.
Show plotting code
fig, ax = plt.subplots(constrained_layout=True, figsize=(5,3))
for timepoint in ts.times():
ax.axvline(timepoint)
for start, stop in ts.time_support.values:
ax.axvspan(start, stop, alpha=0.2)
ax.yaxis.set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.set_xlabel("time (s)")
plt.show()
11.4.2 Restricting (restrict)
Any time series object can be restricted to certain IntervalSet, thereby updating its time support. This could be used, for example, to keep only the spike times that fall within the presentation of a behavioural stimulus.
ts = nap.Ts(t=[1,2,3,4,5])
restriction = nap.IntervalSet(start=[1.3, 3.9], end=[3.5, 4.2])
restricted = ts.restrict(restriction)
restrictedTime (s)
2.0
3.0
4.0
shape: 3
Show plotting code
fig, ax = plt.subplots(constrained_layout=True, figsize=(5,3))
for timepoint in restricted.times():
ax.axvline(timepoint)
for start, stop in restricted.time_support.values:
ax.axvspan(start, stop, alpha=0.2)
ax.yaxis.set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.set_xlabel("time (s)")
ax.set_xlim(1,5)
plt.show()
11.4.3 Taking values (value_from)
Given one time series, you can value_from to take the value corresponding to the closest time point in another time series with data. This can be handy, for example, if you want to sample the position of an animal, at particular spike times.
ts = nap.Ts(t=[1,2,3,4,5])
tsd = nap.Tsd(t=np.arange(0, 10, 0.1), d=np.random.randn(100))
values = ts.value_from(tsd)
valuesTime (s)
---------- ---------
1 0.408451
2 -0.755681
3 -0.802158
4 0.801025
5 0.249727
dtype: float64, shape: (5,)
Show plotting code
fig, ax = plt.subplots(constrained_layout=True, figsize=(5,3))
ax.plot(tsd, label="tsd")
ax.plot(values, label="values", linestyle="none", marker=".")
minimum = tsd.values.min()
cmap = plt.get_cmap("tab10")
for timepoint, value in zip(values.times(), values.values, strict=True):
ax.vlines(timepoint, ymin=minimum, ymax=value, color=cmap(1))
ax.set_ylabel("data (a.u.)")
ax.set_xlabel("time (s)")
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
fig.legend(frameon=False, bbox_to_anchor=(1.0, 1.1))
plt.show()
11.5 Wrangling
Pynapple objects are easy to transform, reshape, slice, and more.
11.5.1 Numpy
Most Numpy functions can be applied to Pynapple objects without hassle.
tsdframe = nap.TsdFrame(
t=[1,2,3,4,5],
d=[[1,2],
[1,2],
[1,3],
[2,1],
[1,2]],
columns=["A", "B"]
)
np.mean(tsdframe, axis=1)Time (s)
---------- ---
1 1.5
2 1.5
3 2
4 1.5
5 1.5
dtype: float64, shape: (5,)
tsdframe = nap.TsdFrame(
t=[1,2,3,4,5],
d=[[1,2],
[1,2],
[1,3],
[2,1],
[1,2]],
columns=["A", "B"]
)
np.diff(tsdframe, axis=1)Time (s) 0
---------- ---
1 1
2 1
3 2
4 -1
5 1
dtype: int64, shape: (5, 1)
11.5.2 Slicing
Pynapple objects can be sliced like Numpy arrays.
tsdframe = nap.TsdFrame(
t=[1,2,3,4,5],
d=[[1,2],
[1,2],
[1,3],
[2,1],
[1,2]],
columns=["A", "B"]
)
tsdframe[:, 1]Time (s)
---------- --
1 2
2 2
3 3
4 1
5 2
dtype: int64, shape: (5,)
tsdframe = nap.TsdFrame(
t=[1,2,3,4,5],
d=[[1,2],
[1,2],
[1,3],
[2,1],
[1,2]],
columns=["A", "B"]
)
tsdframe[2:4]Time (s) A B
---------- --- ---
3 1 3
4 2 1
dtype: int64, shape: (2, 2)
11.6 Core functions
Pynapple has a core set of functions designed to help with neuroscientific analysis. The following are a subset of them, for an exhaustive list, see the Pynapple documentation.
11.6.1 Binning
The time points of any time series object can be counted.
time_step_s = 0.001
times = np.arange(0, 4, time_step_s)
# Create a firing pattern that increases and decreases as a sine wave
rate = 40 * (1 + np.sin(2*np.pi*times)) / 2
# Use the rate to create discrete spike times
spikes = nap.Ts(times[np.random.rand(len(times)) < rate * time_step_s])
# Bin the spike times
counts = spikes.count(bin_size=0.1)
countsTime (s)
------------------- --
0.13 4
0.22999999999999998 6
0.33 6
0.43 3
0.53 3
0.63 0
0.7300000000000001 0
...
3.3299999999999996 2
3.4299999999999997 1
3.53 4
3.63 1
3.73 0
3.8299999999999996 1
3.9299999999999997 2
dtype: int64, shape: (39,)
Show plotting code
fig, (ax_rate, ax_spikes, ax_count) = plt.subplots(
3, 1, constrained_layout=True, figsize=(7.5,3), sharex=True
)
ax_rate.plot(times, rate)
ax_rate.spines['right'].set_visible(False)
ax_rate.spines['top'].set_visible(False)
ax_rate.set_ylabel("true rate [Hz]")
ax_spikes.vlines(spikes.times(), 0, 1)
ax_spikes.set_ylabel("spikes")
ax_spikes.set_yticks([])
ax_spikes.spines['left'].set_visible(False)
ax_spikes.spines['right'].set_visible(False)
ax_spikes.spines['top'].set_visible(False)
ax_count.step(counts.times(), counts.values)
ax_count.set_ylabel("count")
ax_count.set_xlabel("time (s)")
ax_count.spines['right'].set_visible(False)
ax_count.spines['top'].set_visible(False)
plt.show()
11.6.2 Averaging
If you have continous data already, you would not count events in bins, but rather average values across bins. For example, if you wanted to downsample a signal without losing too much information.
time_step_s = 0.01
times = np.arange(0, 10, time_step_s)
# Create a sine wave signal
signal = np.sin(2 * np.pi * times)
# Add random noise
signal += 0.2 * np.random.randn(len(times))
# Store as Tsd
tsd = nap.Tsd(t=times, d=signal)
# Average across 200ms bins
averaged = tsd.bin_average(bin_size=0.2)
averagedTime (s)
------------------- ----------
0.1 0.508097
0.30000000000000004 0.907066
0.5 -0.0582932
0.7 -0.867065
0.9 -0.607904
1.1 0.552753
1.3 0.806466
...
8.7 -0.864562
8.9 -0.566633
9.1 0.548062
9.299999999999999 0.916676
9.5 -0.0341948
9.7 -0.917063
9.9 -0.576455
dtype: float64, shape: (50,)
Show plotting code
fig, ax = plt.subplots(constrained_layout=True, figsize=(5,3))
plt.plot(tsd, label="original")
plt.plot(averaged, label="averaged")
ax.set_ylabel("data (a.u.)")
ax.set_xlabel("time (s)")
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
fig.legend(frameon=False, bbox_to_anchor=(1.0, 1.1))
plt.show()
11.6.3 Thresholding
Time series with data can be thresholded, yielding new time series objects with updated time supports.
time_step_s = 0.01
times = np.arange(0, 10, time_step_s)
# Create a sine wave signal
signal = np.sin(2 * np.pi * times)
# Add random noise
signal += 0.2 * np.random.randn(len(times))
# Store as Tsd
tsd = nap.Tsd(t=times, d=signal)
# Threshold
above = tsd.threshold(0.5, method="above")
below = tsd.threshold(-.5, method="below")
above.time_supportindex start end
0 0.065 0.07500000000000001
1 0.10500000000000001 0.41500000000000004
2 1.0550000000000002 1.065
3 1.0750000000000002 1.2349999999999999
4 1.245 1.405
5 1.415 1.4249999999999998
6 2.075 2.1550000000000002
... ... ...
33 8.365 8.434999999999999
34 9.065000000000001 9.094999999999999
35 9.105 9.125
36 9.135000000000002 9.165
37 9.175 9.365
38 9.375 9.405000000000001
39 9.415 9.425
shape: (40, 2), time unit: sec.
Show plotting code
fig, ax = plt.subplots(constrained_layout=True, figsize=(5,3))
ax.plot(tsd, label="original")
ax.plot(above, label="above", marker=".", linestyle="none")
ax.plot(below, label="below", marker=".", linestyle="none")
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
fig.legend(frameon=False, bbox_to_anchor=(1.0, 1.2))
plt.show()
11.6.4 Smoothing
Time series with data can be smoothed. Smoothing makes a signal less noisy by averaging out small, fast changes while keeping the main pattern. The operation consists of convolving the signal with a Gaussian kernel.
time_step_s = 0.01
times = np.arange(0, 10, time_step_s)
# Create a sine wave signal
signal = np.sin(2 * np.pi * times)
# Add random noise
signal += 0.2 * np.random.randn(len(times))
# Store as Tsd
tsd = nap.Tsd(t=times, d=signal)
# Gaussian smooth with a standard deviation of 100ms
smoothed = tsd.smooth(std=0.1)
smoothed/tmp/ipykernel_3688/820394606.py:14: UserWarning: Kernel size (1001 samples) is larger than the number of samples in 1 out of 1 epochs (shortest epoch has 1000 samples). The signal is zero-padded outside each epoch, so every point of the output in those epochs is contaminated by the padding and artifacts are expected. Consider decreasing the kernel/window size.
smoothed = tsd.smooth(std=0.1)
Time (s)
----------------- ---------
0.0 0.25596
0.01 0.284626
0.02 0.314658
0.03 0.345879
0.04 0.378088
0.05 0.411057
0.06 0.444544
...
9.93 -0.398379
9.94 -0.371223
9.950000000000001 -0.344258
9.96 -0.317671
9.97 -0.291639
9.98 -0.26633
9.99 -0.241895
dtype: float64, shape: (1000,)
Show plotting code
fig, ax = plt.subplots(constrained_layout=True, figsize=(5,3))
ax.plot(tsd, label="original")
ax.plot(smoothed, label="smoothed")
ax.set_ylabel("data (a.u.)")
ax.set_xlabel("time (s)")
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
fig.legend(frameon=False, bbox_to_anchor=(1.0, 1.1))
plt.show()
11.7 Advanced Functions
Beyond fundamental processing functions, Pynapple has loads of advanced functionality for neuroscientific analysis.
11.7.1 Autocorrelograms
Given one or a group of time series, you can compute autocorrelograms.
time_step_s = 0.01
times = np.arange(0, 4, time_step_s)
# Create a firing pattern that increases and decreases as a sine wave
rate = 40 * (1 + np.sin(2*np.pi*times)) / 2
# Use the rate to create 2 spiking units, one with double the rate
units = nap.TsGroup({
1: nap.Ts(times[np.random.rand(len(times)) < rate * time_step_s]),
2: nap.Ts(times[np.random.rand(len(times)) < rate*2 * time_step_s]),
})
# Compute autocorrelograms
autocorrelogram = nap.compute_autocorrelogram(
units, binsize=0.1, windowsize=1.0, norm=False
)
autocorrelogram| 1 | 2 | |
|---|---|---|
| -0.9 | 19.736842 | 44.013158 |
| -0.8 | 16.447368 | 36.118421 |
| -0.7 | 14.605263 | 27.105263 |
| -0.6 | 14.342105 | 17.894737 |
| -0.5 | 12.368421 | 15.131579 |
| -0.4 | 14.473684 | 16.973684 |
| -0.3 | 17.368421 | 27.763158 |
| -0.2 | 19.868421 | 42.434211 |
| -0.1 | 22.368421 | 55.394737 |
| 0.0 | 0.000000 | 0.000000 |
| 0.1 | 22.105263 | 55.131579 |
| 0.2 | 19.473684 | 42.236842 |
| 0.3 | 17.368421 | 27.236842 |
| 0.4 | 14.473684 | 17.236842 |
| 0.5 | 11.973684 | 14.868421 |
| 0.6 | 14.210526 | 17.894737 |
| 0.7 | 14.078947 | 26.315789 |
| 0.8 | 16.447368 | 36.052632 |
| 0.9 | 19.736842 | 43.881579 |
Show plotting code
ax=autocorrelogram.plot()
plt.ylabel("rate (Hz)")
plt.xlabel("time (s)")
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
plt.legend(frameon=False)
plt.show()
11.7.2 Crosscorrelograms
Given one or two groups of time series, you can also compute crosscorrelograms.
crosscorrelogram = nap.compute_crosscorrelogram(
units, binsize=0.1, windowsize=1.0, norm=False
)
crosscorrelogram| 1 | |
|---|---|
| 2 | |
| -0.9 | 42.105263 |
| -0.8 | 41.052632 |
| -0.7 | 35.526316 |
| -0.6 | 26.710526 |
| -0.5 | 20.263158 |
| -0.4 | 21.842105 |
| -0.3 | 27.236842 |
| -0.2 | 37.500000 |
| -0.1 | 46.315789 |
| 0.0 | 51.710526 |
| 0.1 | 50.263158 |
| 0.2 | 42.763158 |
| 0.3 | 33.947368 |
| 0.4 | 23.947368 |
| 0.5 | 15.789474 |
| 0.6 | 16.447368 |
| 0.7 | 19.736842 |
| 0.8 | 29.078947 |
| 0.9 | 35.263158 |
Show plotting code
ax=crosscorrelogram.plot()
plt.ylabel("rate (Hz)")
plt.xlabel("time (s)")
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
plt.legend(frameon=False)
plt.show()
11.7.3 Signal processing
Pynapple wraps a lot of scipy functions for signal processing.
Let’s simulate a noisy multi-frequency signal (the combination of multiple sine waves oscillating at different frequencies):
sampling_rate_hz = 1000
times = np.linspace(0, 2, sampling_rate_hz * 2)
# Signals at different frequencies
signal_2Hz = np.cos(times*2*np.pi*2)
signal_10Hz = np.cos(times*2*np.pi*10)
signal_50Hz = np.cos(times*2*np.pi*50)
# Combine
signal = signal_2Hz+signal_10Hz+signal_50Hz
# Add noise
signal += np.random.normal(0, 0.5, len(times))
# Store in Tsd
tsd = nap.Tsd(t=times,d=signal)
tsdTime (s)
--------------------- --------
0.0 2.6509
0.0010005002501250625 2.61391
0.002001000500250125 3.8986
0.003001500750375187 2.72838
0.00400200100050025 2.84741
0.0050025012506253125 1.48576
0.006003001500750374 0.873294
...
1.9939969984992494 2.14985
1.9949974987493746 2.28976
1.9959979989994996 3.1159
1.9969984992496246 2.81628
1.9979989994997498 2.68625
1.9989994997498748 2.70611
2.0 2.81182
dtype: float64, shape: (2000,)
Show plotting code
fig, (ax2, ax10, ax50, ax) = plt.subplots(
4,
1,
constrained_layout=True,
figsize=(5,6),
sharex=True
)
ax.plot(tsd)
ax2.plot(times,signal_2Hz, color="red")
ax2.spines['right'].set_visible(False)
ax2.spines['top'].set_visible(False)
ax2.set_ylabel("2Hz")
ax10.plot(times,signal_10Hz, color="red")
ax10.spines['right'].set_visible(False)
ax10.spines['top'].set_visible(False)
ax10.set_ylabel("10Hz")
ax50.plot(times,signal_50Hz, color="red")
ax50.spines['right'].set_visible(False)
ax50.spines['top'].set_visible(False)
ax50.set_ylabel("10Hz")
ax.set_ylabel("mixed signal (a.u.)")
ax.set_xlabel("time (s)")
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
plt.show()
11.7.3.1 Bandpass filter
We can bandpass filter to extract certain frequency bands. For example, if we apply a bandpass filter with a 8Hz high-pass and 12Hz low-pass cutoff, we will keep only the part of the signal in the 8-12hz range.
filtered = nap.apply_bandpass_filter(
data=tsd,
cutoff=(8, 12),
fs=sampling_rate_hz,
mode='butter'
)
filteredTime (s)
--------------------- ----------
0.0 0.351214
0.0010005002501250625 0.334084
0.002001000500250125 0.315556
0.003001500750375187 0.295669
0.00400200100050025 0.274466
0.0050025012506253125 0.251997
0.006003001500750374 0.228318
...
1.9939969984992494 0.00284643
1.9949974987493746 0.00257192
1.9959979989994996 0.00230825
1.9969984992496246 0.00205722
1.9979989994997498 0.00182024
1.9989994997498748 0.00159843
2.0 0.00139257
dtype: float64, shape: (2000,)
Show plotting code
fig, (ax_original, ax_filtered) = plt.subplots(
2,
1,
constrained_layout=True,
figsize=(5,4),
sharex=True
)
ax_original.plot(tsd, color="red")
ax_original.set_ylabel("original (a.u.)")
ax_original.spines['right'].set_visible(False)
ax_original.spines['top'].set_visible(False)
ax_filtered.plot(filtered)
ax_filtered.set_xlabel("time (s)")
ax_filtered.set_ylabel("filtered (a.u.)")
ax_filtered.spines['right'].set_visible(False)
ax_filtered.spines['top'].set_visible(False)
plt.show()
11.7.3.2 Power-spectral density
We can compute the power-spectral density (PSD) to find frequencies of interest.
The PSD shows how strongly different frequencies contribute to a signal. You only ever compute it up to half the sampling rate (the Nyquist frequency), because higher frequencies cannot be reliably recovered from the recorded data.
psd = nap.compute_power_spectral_density(
tsd,
fs=sampling_rate_hz
)
psd| 0 | |
|---|---|
| 0.0 | 0.000296 |
| 0.5 | 0.000047 |
| 1.0 | 0.000106 |
| 1.5 | 0.000802 |
| 2.0 | 1.001878 |
| ... | ... |
| 497.5 | 0.000692 |
| 498.0 | 0.000375 |
| 498.5 | 0.000028 |
| 499.0 | 0.000272 |
| 499.5 | 0.000569 |
1000 rows × 1 columns
Show plotting code
fig, ax = plt.subplots(constrained_layout=True, figsize=(5,3))
ax.plot(psd)
ax.set_ylabel("power")
ax.set_xlabel("frequency (Hz)")
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
plt.show()
11.7.4 Perievent
A typical neuroscientific analysis consists of aligning activity to a set of stimuli or events. For example, spiking activity around a stimulus presentation.
Let’s start by simulating a spiking unit that has clear stimulus-locked activity:
stimuli = nap.Ts(t=np.arange(0, 1000, 1), time_units="s")
baseline = np.random.uniform(0, 1000, 500)
burst = np.concatenate([
np.random.normal(
st + 0.1, 0.05, 3
)
for st in stimuli.times()
])
ts = nap.Ts(t=np.sort(np.concatenate([baseline, burst])))
tsTime (s)
0.023511176989550103
0.046815749340433144
0.09510798315242949
0.9051087054006146
1.0778362834571942
1.1015796216962395
1.111287371796557
...
998.0754133686511
998.1143908567869
998.1348983072199
998.8442335722334
999.0995262117243
999.1119584977065
999.1886906696067
shape: 3500
Show plotting code
segment = nap.IntervalSet(100, 103.9)
fig, ax = plt.subplots(1, 1, constrained_layout=True)
ax.vlines(ts.restrict(segment).times(), 0.04, 0.10, label="spikes")
ax.vlines(
stimuli.restrict(segment).times(),
0.0,
0.14,
color="gray",
linestyle="--",
label="stimulus",
)
ax.yaxis.set_visible(False)
ax.spines["left"].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.set_xlabel("time (s)")
plt.show()
Now, we can easily compute a peri-event time histogram (PETH) as follows:
peth = nap.compute_perievent(
data=ts,
events=stimuli,
window=(-0.1, 0.4))
pethIndex rate events
------- ------ --------
0 8.0 1.0
1 6.0 2.0
2 6.0 3.0
3 6.0 4.0
4 6.0 5.0
5 6.0 6.0
6 6.0 7.0
... ... ...
992 6.0 993.0
993 8.0 994.0
994 8.0 995.0
995 6.0 996.0
996 6.0 997.0
997 6.0 998.0
998 6.0 999.0
Show plotting code
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(peth.to_tsd(), "|", markersize=5)
ax.set_ylabel("event")
ax.set_xlabel("time from event (s)")
ax.axvline(0.0, color="gray", linestyle="--")
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
plt.show()
A PETH aligns all activity (spikes) with respect to the event times.
11.7.5 Tuning curves
A typical neuroscientific analysis is that of computing tuning curves: the mean activity curve with respect to a variable of interest. Well known examples are the response of neurons in the visual cortex with respect to the orientation of a bar stimulus, the response of neurons in the postsubiculum with respect to head direction, or the response of hippocampal neurons with respect to position on a track.
Tuning curves can be computed from both discrete (spike times) and continuous activity. Pynapple’s compute_tuning_curves function handles both cases, depending on whether you pass a Ts or Tsd as data. In what follows, we’ll simulate computing tuning curves from a circular variable (e.g. bar angle, or head direction).
Let’s start by simulating some spiking units that are clearly modulated by a circular feature:
n_neurons = 6
# Divide the circular feature space (0 to 2π) into bins
bins = np.linspace(0, 2*np.pi, 61)
# Create a Gaussian-shaped tuning curve centred at 0
# This represents a neuron that fires most strongly for one preferred value
x = np.linspace(-np.pi, np.pi, len(bins)-1)
tmp = np.roll(
np.exp(-(1.5 * x) ** 2), # Gaussian bump
(len(bins)-1)//2 # Shift peak to the centre of the array
)
# Create tuning curves for all neurons by shifting the preferred value
# Each neuron responds to a different part of the circular space
tc = np.array([
np.roll(tmp, i * (len(bins)-1)//n_neurons)
for i in range(n_neurons)
]).T
# Create a time vector
total_timesteps = 10000
time_step_sec = 0.01
timestep = np.arange(total_timesteps) * time_step_sec
# Simulate a random trajectory through the circular feature space
# The cumulative sum creates a smooth random walk
# Gaussian filtering removes abrupt jumps
# Modulo 2π keeps the feature within the circular range [0, 2π)
feature = nap.Tsd(
t=timestep,
d=np.cumsum(np.random.randn(total_timesteps) * 0.5),
).smooth(0.2) % (2*np.pi)
# Find which tuning curve bin corresponds to each feature value
index = np.digitize(feature, bins) - 1
# Look up each neuron's activity at the current feature value
# Then sample spikes randomly based on this activity level
count = np.random.poisson(tc[index]) > 0
tsgroup = nap.TsGroup({
i + 1: nap.Ts(timestep[count[:, i]])
for i in range(n_neurons)
})
epochs = nap.IntervalSet(0, 10)Let’s visualise what we generated.
We now have a random walk in the feature space:
Show plotting code
fig, ax = plt.subplots(constrained_layout=True, figsize=(6, 4))
ax.plot(feature.restrict(epochs), linestyle="none", marker="o")
ax.set_yticks([0, 2*np.pi], ["0", "2π"])
ax.set_ylim(0, 2*np.pi)
ax.set_xlabel("time(s)")
ax.set_ylabel("feature (rad)")
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
plt.show()
As well as the activity of 6 neurons, modulated by the feature:
Show plotting code
fig, ax = plt.subplots(constrained_layout=True, figsize=(6, 4))
ax.plot(tsgroup.restrict(epochs).to_tsd(), linestyle="none", marker="|")
ax.tick_params(axis="y", length=0)
ax.set_xlabel("time(s)")
ax.spines['right'].set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['top'].set_visible(False)
plt.show()
We can now use Pynapple to easily compute tuning curves with respect to the circular feature.
The output is an xarray.DataArray:
tuning_curves = nap.compute_tuning_curves(
data=tsgroup,
features=feature,
bins=120,
range=(0, 2*np.pi),
feature_names=["feature"]
)
tuning_curves<xarray.DataArray (unit: 6, feature: 120)> Size: 6kB
array([[57.77777778, 57.5 , 68.91891892, 59.25925926, 50. ,
64.81481481, 42.26804124, 67.1875 , 36.23188406, 40.57971014,
44.11764706, 37.14285714, 21.64948454, 32.53012048, 20. ,
18.55670103, 15.30612245, 7.95454545, 14.94252874, 6.81818182,
2.22222222, 8.53658537, 6.49350649, 1.44927536, 4.41176471,
2.53164557, 0. , 3.19148936, 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 2.1978022 , 3.40909091, 0. ,
1.53846154, 2.94117647, 4.34782609, 6.94444444, 7.24637681,
...
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 0. ,
0. , 0. , 0. , 0. , 1.19047619,
0. , 1.02040816, 0. , 4.34782609, 2.3255814 ,
4.3956044 , 4.21052632, 2.54237288, 4.06504065, 2.1978022 ,
7.8125 , 13.48314607, 17.77777778, 14.81481481, 26.66666667,
17.94871795, 26.59574468, 30. , 37.75510204, 44. ,
54. , 42.30769231, 54.94505495, 55.68181818, 55.17241379,
56.92307692, 45.58823529, 62.31884058, 68.05555556, 57.97101449,
71.25 , 65.75342466, 68.83116883, 51.35135135, 59.52380952,
64.83516484, 52.87356322, 50. , 50. , 32.35294118,
38.05309735, 34.48275862, 31.25 , 35.21126761, 19.1011236 ,
28.03738318, 14.13043478, 11.71171171, 8.33333333, 7.14285714]])
Coordinates:
* unit (unit) int64 48B 1 2 3 4 5 6
* feature (feature) float64 960B 0.02618 0.07854 0.1309 ... 6.152 6.205 6.257
Attributes:
occupancy: [ 90. 80. 74. 54. 50. 54. 97. 64. 69. 69. 68. 70. ...
bin_edges: [array([0. , 0.05235988, 0.10471976, 0.15707963, 0.209...
fs: 100.0
rates: [12.72127213 14.33143314 13.30133013 12.55125513 14.14141414 ...It has 2 dimensions, one for the neurons, and one for the feature bins.
These can be easily visualised:
Show plotting code
fig, ax = plt.subplots(constrained_layout=True, figsize=(6, 4))
tuning_curves.plot.line(ax=ax, x="feature")
plt.ylabel("firing rate [Hz]")
plt.xticks([0, 2*np.pi], ["0", "2π"])
legend=ax.get_legend()
legend.set_frame_on(False)
legend.set_bbox_to_anchor((1.0, 0.7))
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
plt.show()
We can see that the mean activity of the neurons is clearly modulated by the feature, and that each neuron has a clear preference, matching our generation pattern. For more, take a look at the Pynapple user guide on tuning curves, as well as the real examples.
11.7.6 Decoding
If we know the mean activity of many neurons with respect to a variable, we should be able to predict the current value of that variable, given the current activity of the neurons. This is the premise of Bayesian decoding. The tuning curves of a population of neurons is used as a ‘template’ of what activity matches what feature value. Combined with Poisson statistics, Bayesian decoding provides a simple but principled way of neural decoding.
Using Pynapple, we can easily do Bayesian decoding, provided we have:
- a set of tuning curves computed using
compute_tuning_curves - the activity of the neurons matching the tuning curves
- a (temporal) bin size, to bin activity in
- a smoothing window, if we want to smooth the activity (this often helps for Bayesian decoding, by reducing noise)
decoded, proba_feature = nap.decode_bayes(
tuning_curves=tuning_curves,
data=tsgroup,
epochs=epochs,
bin_size=0.02,
sliding_window_size=4,
)
decodedTime (s)
------------------- --------
0.01 0.445059
0.03 0.445059
0.05 0.602139
0.06999999999999999 0.654498
0.09 1.1781
0.11 1.12574
0.13 1.23046
...
9.87 5.41925
9.89 5.20981
9.91 4.73857
9.93 4.73857
9.95 4.79093
9.97 4.73857
9.99 4.94801
dtype: float64, shape: (500,)
The output consists of a Tsd, containing the predicted feature values at every time step, and a TsdFrame containing the estimated probabilities of each feature value at every time step:
Show plotting code
fig, (ax1, ax2) = plt.subplots(figsize=(6, 5), nrows=2, ncols=1, sharex=True)
ax1.plot(
feature,
label="true",
linestyle="none",
marker="o",
markersize=4,
alpha=0.5
)
ax1.plot(
decoded,
label="decoded",
c="orange",
linestyle="none",
marker="o",
markersize=3,
)
ax1.spines['right'].set_visible(False)
ax1.spines['top'].set_visible(False)
fig.legend(frameon=False, bbox_to_anchor=(1.1, 0.8))
ax1.set_ylabel("feature")
ax1.set_yticks([0, 2*np.pi], ["0", "2π"])
proba_feature = (proba_feature.values - np.min(proba_feature.values, axis=1, keepdims=True)) / np.ptp(proba_feature.values, axis=1,keepdims=True)
im = ax2.imshow(proba_feature.T, aspect="auto", origin="lower", cmap="viridis", extent=(0, 10.0, 0, 2*np.pi))
cbar_ax = fig.add_axes([0.93, 0.1, 0.015, 0.36])
fig.colorbar(im, cax=cbar_ax, label="probability")
ax2.set_xlabel("time (s)")
ax2.set_ylabel("feature")
ax2.set_yticks([0, 2*np.pi], ["0", "2π"])
plt.show()
For more, take a look at the Pynapple user guide on decoding, as well as the real examples.
11.8 Integration with other packages
11.8.2 NeMoS
NeMoS (Neural ModelS) is a statistical modeling framework optimized for systems neuroscience and powered by JAX.
It streamlines the process of defining and selecting models, through a collection of easy-to-use methods for feature design.
The core of nemos includes GPU-accelerated, well-tested implementations of standard statistical models, currently focusing on the Generalized Linear Model (GLM).
Check out this page for many examples of neural modelling using nemos and pynapple.
To install it:
pip install nemos11.9 Data
Pynapple provides many ways to load your data.
11.9.1 NWB
The main input format Pynapple can read is Neurodata Without Borders (NWB).
Pynapple will parse all data in the file that can be represented as a Pynapple object and present a dictionary-like interface for selecting them.
data = nap.load_file('A2929-200711.nwb')
dataA2929-200711
┍━━━━━━━━━━━━━━━━━━━━━━━┯━━━━━━━━━━━━━┑
│ Keys │ Type │
┝━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━━━┥
│ units │ TsGroup │
│ position_time_support │ IntervalSet │
│ epochs │ IntervalSet │
│ z │ Tsd │
│ y │ Tsd │
│ x │ Tsd │
│ rz │ Tsd │
│ ry │ Tsd │
│ rx │ Tsd │
┕━━━━━━━━━━━━━━━━━━━━━━━┷━━━━━━━━━━━━━┙
11.9.2 Raw data
Raw data can be loaded with pynapple through the NEO library (also used by SpikeInterface). Many formats are supported, see here.
data = nap.EphysReader("path/to/raw")11.9.3 Saving as .npz
Pynapple objects can be saved (and loaded) as Numpy .npz files.
tsd = nap.Tsd(t=np.arange(10), d=np.arange(10))
tsd.save("my_tsd.npz")
loaded = nap.load_file("my_tsd.npz")
loadedTime (s)
---------- --
0 0
1 1
2 2
3 3
4 4
5 5
6 6
7 7
8 8
9 9
dtype: int64, shape: (10,)
11.9.4 Streaming from DANDI
Data in the NWB format can be directly streamed from the DANDI archive into Pynapple.
Here is useful function to keep on hand that does this for you:
from pynwb import NWBHDF5IO
from dandi.dandiapi import DandiAPIClient
import fsspec
from fsspec.implementations.cached import CachingFileSystem
import h5py
def stream_dandiset(dandiset_id, filepath):
with DandiAPIClient() as client:
asset = client.get_dandiset(dandiset_id, "draft").get_asset_by_path(filepath)
s3_url = asset.get_content_url(follow_redirects=1, strip_query=True)
# first, create a virtual filesystem based on the http protocol
fs = fsspec.filesystem("http")
# create a cache to save downloaded data to disk (optional)
fs = CachingFileSystem(
fs=fs,
cache_storage="nwb-cache", # Local folder for the cache
)
# next, open the file
file = h5py.File(fs.open(s3_url, "rb"))
io = NWBHDF5IO(file=file, load_namespaces=True)
return nap.NWBFile(io.read())11.10 Your turn
Now it is your turn!
We will compute head direction tuning curves in the postsubiculum, and use these to decode head direction from neural activity. We will use the same dataset we have been working with in the preprocessing, sorting and curation chapters.
However, to ensure the decoding is accurate we will need much more data than we previously used. Therefore, we will use the full .nwb file for the session that contains pre-computed spike times, as well as behavioural data.
This dataset can be downloaded from the course data Zenodo, filename: dandiset-000939-no-traces-full.nwb. See here for more details on data provenance.
If you are confident, you can write your own code using the snippets provided in this chapter.
If you want a bit more help, follow along in this notebook.
If you are not satisfied and want to learn more, we are keeping track of all the Pynapple learning material here.
