Source code for pynapple.process.perievent

"""
Functions to realign time series relative to a reference time.
"""

from numbers import Number

import numpy as np

from .. import core as nap
from ._process_functions import _perievent_continuous, _perievent_triggered_average


[docs] def compute_event_triggered_average( data, events, binsize, window=0, time_unit="s", epochs=None, ): """ Bin the event timestamps and compute the Event-Triggered Average (ETA). Notes ----- If `C` is the event count matrix and ``feature`` is a `Tsd`, then the function computes the Hankel matrix H from ``window=(-t1,+t2)`` by offseting the `Tsd`. The ETA is then defined as the dot product between `H` and `C` divided by the number of events. Parameters ---------- data : Tsd, TsdFrame, or TsdTensor The continuous timeseries to use as the feature (must be regularly sampled). events : Ts, Tsd, TsdFrame, TsdTensor, or TsGroup The events (or spike trains) to align to. If a ``TsGroup``, each unit's spikes are binned and used as a separate event count vector. If not a ``Ts``, we simply take the timestamps of the object. binsize : float or int The bin size. window : int, float, or tuple The alignment window. Can be unequal on each side, e.g. ``(-2, 1)``. time_unit : str, optional Time units of the ``window`` and ``binsize`` ('s' [default], 'ms', 'us'). epochs : IntervalSet, optional The epochs to perform the operation over. If None, uses the data's ``time_support``. Returns ------- TsdFrame or TsdTensor The event-triggered average. Raises ------ RuntimeError If ``time_unit`` is invalid, ``window`` format is invalid, or ``data`` is not regularly sampled. TypeError If ``data`` or ``events`` are not valid timeseries objects. Examples -------- Compute the ETA of a continuous feature triggered on a single event train: >>> import pynapple as nap >>> import numpy as np >>> times = np.arange(0, 10, 0.1) >>> feature = nap.Tsd(t=times, d=np.sin(times)) >>> events = nap.Ts(t=[1.0, 3.0, 5.0, 7.0]) >>> eta = nap.compute_event_triggered_average(feature, events, binsize=0.1, window=0.5) >>> eta Time (s) 0 ---------- --------- -0.5 0.0788719 -0.4 0.0994986 -0.3 0.119131 -0.2 0.137573 -0.1 0.154641 0 0.170163 0.1 0.183986 0.2 0.19597 0.3 0.205995 0.4 0.213963 0.5 0.219793 dtype: float64, shape: (11, 1) You can restrict the computation to certain parts of the data by passing ``epochs``: >>> epochs = nap.IntervalSet(start=[0, 5], end=[4, 9]) >>> eta = nap.compute_event_triggered_average(feature, events, binsize=0.1, window=0.5, epochs=epochs) >>> eta Time (s) 0 ---------- -------- -0.5 0.323254 -0.4 0.347921 -0.3 0.369112 -0.2 0.386614 -0.1 0.400254 0 0.170163 0.1 0.183986 0.2 0.19597 0.3 0.205995 0.4 0.213963 0.5 0.219793 dtype: float64, shape: (11, 1) Compute the ETA triggered on multiple spike trains (``TsGroup``), returning one column per unit: >>> unit1 = nap.Ts(t=[1.0, 3.0, 5.0]) >>> unit2 = nap.Ts(t=[2.0, 4.0, 6.0]) >>> spikes = nap.TsGroup({0: unit1, 1: unit2}) >>> eta = nap.compute_event_triggered_average(feature, spikes, binsize=0.1, window=0.5) >>> eta Time (s) 0 1 ---------- ----------- ---------- -0.5 0.0334559 -0.0196095 -0.4 0.0288176 -0.0247378 -0.3 0.0238914 -0.029619 -0.2 0.0187265 -0.0342041 -0.1 0.0133745 -0.0384476 0 0.00788891 -0.0423069 0.1 0.00232445 -0.0457434 0.2 -0.00326324 -0.0487229 0.3 -0.00881832 -0.0512156 0.4 -0.0142853 -0.0531966 0.5 -0.0196095 -0.054646 dtype: float64, shape: (11, 2) Compute the ETA of a multiple features (``TsdFrame``), returning a ``TsdTensor`` with shape ``(n_time_bins, n_events, n_features)``: >>> values = np.column_stack([np.sin(times), np.cos(times)]) >>> features = nap.TsdFrame(t=times, d=values, columns=["sin", "cos"]) >>> eta = nap.compute_event_triggered_average(features, events, binsize=0.1, window=0.5) >>> eta Time (s) ---------- -------------------------- -0.5 [[0.078872, 0.210558] ...] -0.4 [[0.099499, 0.201632] ...] -0.3 [[0.119131, 0.190691] ...] -0.2 [[0.137573, 0.177845] ...] -0.1 [[0.154641, 0.163222] ...] 0 [[0.170163, 0.146969] ...] 0.1 [[0.183986, 0.129246] ...] 0.2 [[0.19597 , 0.110233] ...] 0.3 [[0.205995, 0.090118] ...] 0.4 [[0.213963, 0.069102] ...] 0.5 [[0.219793, 0.047396] ...] dtype: float64, shape: (11, 1, 2) """ if time_unit not in ["s", "ms", "us"]: raise RuntimeError("time_unit should be 's', 'ms' or 'us'") if isinstance(window, Number): window = np.array([window, window], dtype=np.float64) if len(window) != 2 or not all(isinstance(x, Number) for x in window): raise RuntimeError("window should be a tuple of 2 numbers or a single number.") if not isinstance(data, (nap.Tsd, nap.TsdFrame, nap.TsdTensor)): raise TypeError( f"data should be a continuous time series (Tsd, TsdFrame, or TsdTensor): {type(data)}" ) if not isinstance( events, (nap.Ts, nap.Tsd, nap.TsdFrame, nap.TsdTensor, nap.TsGroup) ): raise TypeError(f"events should be a time series object: {type(events)}") if epochs is None: epochs = data.time_support if not nap.utils._is_regularly_sampled(data): raise RuntimeError( "Continuous data must be regularly sampled. " "Please interpolate or NaN-pad your data to a uniform time grid before " "calling compute_event_triggered_average." ) window = np.abs(nap.TsIndex.format_timestamps(np.array(window), time_unit)) binsize = nap.TsIndex.format_timestamps( np.array([binsize], dtype=np.float64), time_unit )[0] # Build the time index for the output idx1 = -np.arange(0, window[0] + binsize, binsize)[::-1][:-1] idx2 = np.arange(0, window[1] + binsize, binsize)[1:] time_idx = np.hstack((idx1, np.zeros(1), idx2)) windows = np.array([len(idx1), len(idx2)], dtype=np.int64) # Bin the events count = events.count(binsize, epochs) time_target_array = np.round(count.index.values - (binsize / 2), 9) count_array = count.values if count_array.ndim == 1: count_array = count_array[:, np.newaxis] starts = epochs.start ends = epochs.end time_array = data.index.values data_array = data.values eta = _perievent_triggered_average( time_target_array, count_array, time_array, data_array, starts, ends, windows, binsize, ) if eta.ndim == 2: columns = events.index if isinstance(events, nap.TsGroup) else None return nap.TsdFrame(t=time_idx, d=eta, columns=columns) else: return nap.TsdTensor(t=time_idx, d=eta)
[docs] def compute_spike_triggered_average( data, spikes, binsize, window=0, time_unit="s", epochs=None, ): """ Alias for :func:`~pynapple.process.perievent.compute_event_triggered_average` with ``spikes`` as the events argument. Parameters ---------- data : Tsd, TsdFrame, or TsdTensor The continuous timeseries to use as the feature (must be regularly sampled). spikes : Ts, Tsd, TsdFrame, TsdTensor, or TsGroup The spike train(s) to align to. binsize : float or int The bin size. window : int, float, or tuple The alignment window. Can be unequal on each side, e.g. ``(-2, 1)``. time_unit : str, optional Time units of the ``window`` and ``binsize`` ('s' [default], 'ms', 'us'). epochs : IntervalSet, optional The epochs to perform the operation over. If None, uses the data's ``time_support``. Returns ------- TsdFrame or TsdTensor The spike-triggered average. Raises ------ RuntimeError If ``time_unit`` is invalid, ``window`` format is invalid, or ``data`` is not regularly sampled. TypeError If ``data`` or ``spikes`` are not valid timeseries objects. See Also -------- :func:`~pynapple.process.perievent.compute_event_triggered_average` """ return compute_event_triggered_average( data=data, events=spikes, binsize=binsize, window=window, time_unit=time_unit, epochs=epochs, )
[docs] def compute_perievent(data, events, window, time_unit="s", epochs=None): """ Perievent alignment, handles both discrete and continuous data. This function automatically detects the data type, then applies the appropriate alignment strategy: - **discrete data:** - a single ``Ts``: returns a ``TsGroup`` with one element per event - multiple ``Ts`` in a ``TsGroup``: returns a dictionary with a ``TsGroup`` per unit - **continuous data:** - regularly sampled ``Tsd``/``TsdFrame``/``TsdTensor``: returns aligned object with events as columns and uniform time sampling - irregularly sampled data is not supported; interpolate or NaN-pad to a regular grid before calling this function Parameters ---------- data : Ts, Tsd, TsdFrame, TsdTensor, TsGroup The timeseries to align. events : Ts, Tsd, TsdFrame, TsdTensor The events to align to. If not a ``Ts``, we simply take the timestamps of the object. window : int, float, tuple The alignment window, which can be unequal on each side, e.g. ``(-2, 1)`` time_unit : str, optional Time units of the window ('s' [default], 'ms', 'us'). epochs : IntervalSet, optional The epochs to perform the operation over. If None, uses the data's ``time_support``. Returns ------- TsGroup, TsdFrame, TsdTensor, dict The aligned timeseries. Raises ------ RuntimeError If time_unit not in ["s", "ms", "us"], window format is invalid, or continuous data is not regularly sampled. Examples -------- Align discrete data (``Ts``) to events: >>> import pynapple as nap >>> spikes = nap.Ts(t=[0.5, 1.2, 2.1, 3.5, 4.8]) >>> events = nap.Ts(t=[1.0, 3.0]) >>> result = nap.compute_perievent(spikes, events, window=1.0) >>> result Index rate events ------- ------ -------- 0 1 1 1 1 3 >>> result[0] Time (s) -0.5 0.2 shape: 2 >>> result[1] Time (s) -0.9 0.5 shape: 2 Align data to events within specific epochs: >>> spikes = nap.Ts(t=[0.5, 1.2, 2.1, 3.5, 4.8]) >>> events = nap.Ts(t=[1.0, 3.0, 5.0]) >>> epochs = nap.IntervalSet(start=[0, 2.5], end=[2.4, 4.5]) >>> result = nap.compute_perievent(spikes, events, window=1.0, epochs=epochs) >>> result # only 2 events are included! Index rate events ------- ------ -------- 0 1 1 1 1 3 Align with unequal window: >>> import pynapple as nap >>> import numpy as np >>> spikes = nap.Ts(t=[0.5, 1.2, 2.1, 3.5]) >>> events = nap.Ts(t=[1.0, 3.0]) >>> result = nap.compute_perievent(spikes, events, window=(-0.5, 1.5)) >>> result[0] # window from -0.5 to +1.5 relative to first event Time (s) -0.5 0.2 1.1 shape: 3 Align multiple discrete objects (``TsGroup``): >>> unit1 = nap.Ts(t=[0.5, 1.2, 2.1, 3.5]) >>> unit2 = nap.Ts(t=[0.8, 1.5, 3.2, 4.1]) >>> spikes = nap.TsGroup({0: unit1, 1: unit2}) >>> events = nap.Ts(t=[1.0, 3.0]) >>> result = nap.compute_perievent(spikes, events, window=1.0) >>> result {0: Index rate events ------- ------ -------- 0 1 1 1 1 3, 1: Index rate events ------- ------ -------- 0 1 1 1 0.5 3} >>> result[0] # aligned unit 0 Index rate events ------- ------ -------- 0 1 1 1 1 3 >>> result[1] # aligned unit 1 Index rate events ------- ------ -------- 0 1 1 1 0.5 3 Align regularly sampled continuous data (``Tsd``) to events, returning a ``TsdFrame``: >>> times = np.arange(0, 10, 0.5) >>> values = np.sin(times) >>> data = nap.Tsd(t=times, d=values) >>> events = nap.Ts(t=[2.0, 5.0, 8.0]) >>> result = nap.compute_perievent(data, events, window=1.0) >>> result Time (s) 0 1 2 ---------- -------- --------- -------- -1 0.841471 -0.756802 0.656987 -0.5 0.997495 -0.97753 0.938 0 0.909297 -0.958924 0.989358 0.5 0.598472 -0.70554 0.798487 1 0.14112 -0.279415 0.412118 dtype: float64, shape: (5, 3) Align a regularly sampled continuous data (``TsdFrame``) to events, returning a ``TsdTensor``: >>> values = np.column_stack([np.sin(times), np.cos(times)]) >>> data = nap.TsdFrame(t=times, d=values, columns=["sin", "cos"]) >>> result = nap.compute_perievent(data, events, window=1.0) >>> result Time (s) ---------- ---------------------------- -1 [[0.841471, 0.540302] ...] -0.5 [[0.997495, 0.070737] ...] 0 [[ 0.909297, -0.416147] ...] 0.5 [[ 0.598472, -0.801144] ...] 1 [[ 0.14112 , -0.989992] ...] dtype: float64, shape: (5, 3, 2) """ if time_unit not in ["s", "ms", "us"]: raise RuntimeError("time_unit should be 's', 'ms' or 'us'") if isinstance(window, Number): window = np.array([window, window], dtype=np.float64) if len(window) != 2 or not all(isinstance(x, Number) for x in window): raise RuntimeError("window should be a tuple of 2 numbers or a single number.") # Call recursively if data is a TsGroup if isinstance(data, nap.TsGroup): return { i: compute_perievent(data[i], events, window, time_unit, epochs) for i in data } if not isinstance(data, (nap.Ts, nap.Tsd, nap.TsdFrame, nap.TsdTensor)): raise TypeError(f"data should be a time series object: {type(data)}") if not isinstance(events, (nap.Ts, nap.Tsd, nap.TsdFrame, nap.TsdTensor)): raise TypeError(f"events should be a time series object: {type(events)}") if epochs is None: epochs = data.time_support window = np.abs(nap.TsIndex.format_timestamps(np.array(window), time_unit)) events = events.restrict(epochs) new_time_support = nap.IntervalSet(start=-window[0], end=window[1]) if isinstance(data, nap.Ts): return _align_discrete(data, events, window, new_time_support) if not nap.utils._is_regularly_sampled(data): raise RuntimeError( "Continuous data must be regularly sampled. " "Please interpolate or NaN-pad your data to a uniform time grid before " "calling compute_perievent." ) return _align_regular(data, events, window, new_time_support)
def _align_discrete(data, events, window, new_time_support): """ Align a ``Ts`` to events, returning a TsGroup. Parameters ---------- data : Ts Data to align events : Ts or other timeseries Event timestamps (pre-filtered by epochs) window : array-like [before, after] window new_time_support : IntervalSet Time support for output Returns ------- TsGroup Aligned data, indexed by event number """ aligned = {} event_times = events.times() for event_idx, event_time in enumerate(event_times): start_time = event_time - window[0] end_time = event_time + window[1] mask = (data.t >= start_time) & (data.t <= end_time) shifted_times = data.t[mask] - event_time aligned[event_idx] = nap.Ts(shifted_times, time_support=new_time_support) return nap.TsGroup(aligned, metadata={"events": event_times}) def _align_regular(data, events, window, new_time_support): """ Align regularly-sampled continuous data to events using optimized matrix approach. Returns a single TsdFrame/TsdTensor where each column is one event with uniform time sampling. Parameters ---------- data : Tsd, TsdFrame, or TsdTensor Regularly-sampled continuous data events : Ts or other timeseries Event timestamps (pre-filtered by epochs) window : array-like [before, after] window new_time_support : IntervalSet Time support for output Returns ------- TsdFrame or TsdTensor Aligned data with columns/slice 0 as events """ epochs = data.time_support bin_size = data.t[1] - data.t[0] idx1 = -np.arange(0, window[0] + bin_size, bin_size)[::-1][:-1] idx2 = np.arange(0, window[1] + bin_size, bin_size)[1:] time_idx = np.hstack((idx1, np.zeros(1), idx2)) windowsize = np.array([idx1.shape[0], idx2.shape[0]], dtype=np.int64) new_data_array = _perievent_continuous( data.t, data.values, events.times(), epochs.start, epochs.end, windowsize ) if new_data_array.size == 0: raise ValueError( "No overlap between input and epochs, perievent matrix is empty..." ) if new_data_array.ndim == 2: columns = events.index if isinstance(events, nap.TsGroup) else None return nap.TsdFrame( t=time_idx, d=new_data_array, time_support=new_time_support, columns=columns ) else: return nap.TsdTensor( t=time_idx, d=new_data_array, time_support=new_time_support, )