def compute_cyclepoints(sig, fs, f_range, **find_extrema_kwargs): """Compute sample indices of cyclepoints. Parameters ---------- sig : 1d array Time series. fs : float Sampling rate, in Hz. f_range : tuple of (float, float) Frequency range, in Hz, to narrowband filter the signal. Used to find zero-crossings. find_extrema_kwargs : dict, optional, default: None Keyword arguments for the function to find peaks and troughs (:func:`~.find_extrema`) that change filter parameters or boundary. By default, the boundary is set to zero. Returns ------- df_samples : pandas.DataFrame Dataframe containing sample indices of cyclepoints. Columns (listed for peak-centered cycles): - ``peaks`` : signal indices of oscillatory peaks - ``troughs`` : signal indices of oscillatory troughs - ``rises`` : signal indices of oscillatory rising zero-crossings - ``decays`` : signal indices of oscillatory decaying zero-crossings - ``sample_peak`` : sample at which the peak occurs - ``sample_zerox_decay`` : sample of the decaying zero-crossing - ``sample_zerox_rise`` : sample of the rising zero-crossing - ``sample_last_trough`` : sample of the last trough - ``sample_next_trough`` : sample of the next trough Examples -------- Compute the signal indices of cyclepoints: >>> from neurodsp.sim import sim_bursty_oscillation >>> fs = 500 >>> sig = sim_bursty_oscillation(10, fs, freq=10) >>> df_samples = compute_cyclepoints(sig, fs, f_range=(8, 12)) """ # Ensure arguments are within valid range check_param(fs, 'fs', (0, np.inf)) # Find extrema and zero-crossings locations in the signal peaks, troughs = find_extrema(sig, fs, f_range, **find_extrema_kwargs) rises, decays = find_zerox(sig, peaks, troughs) # For each cycle, identify the sample of each extrema and zero-crossing samples = {} samples['sample_peak'] = peaks[1:] samples['sample_last_zerox_decay'] = decays[:-1] samples['sample_zerox_decay'] = decays[1:] samples['sample_zerox_rise'] = rises samples['sample_last_trough'] = troughs[:-1] samples['sample_next_trough'] = troughs[1:] df_samples = pd.DataFrame.from_dict(samples) return df_samples
def test_extrema_interpolated_phase(): """Test waveform phase estimate.""" # Load signal signal = np.load(DATA_PATH + 'sim_stationary.npy') Fs = 1000 f_range = (6, 14) # Find peaks and troughs Ps, Ts = cyclepoints.find_extrema(signal, Fs, f_range, boundary=1, first_extrema='peak') # Find zerocrossings zeroxR, zeroxD = cyclepoints.find_zerox(signal, Ps, Ts) # Compute phase pha = cyclepoints.extrema_interpolated_phase(signal, Ps, Ts, zeroxR=zeroxR, zeroxD=zeroxD) assert len(pha) == len(signal) assert np.all(np.isclose(pha[Ps], 0)) assert np.all(np.isclose(pha[Ts], -np.pi)) assert np.all(np.isclose(pha[zeroxR], -np.pi / 2)) assert np.all(np.isclose(pha[zeroxD], np.pi / 2))
def test_extrema_interpolated_phase(sim_stationary): """Test waveform phase estimate.""" sig = sim_stationary fs = 1000 f_range = (6, 14) # Find peaks and troughs peaks, troughs = find_extrema(sig, fs, f_range, boundary=1, first_extrema='peak') # Find zerocrossings rises, decays = find_zerox(sig, peaks, troughs) # Compute phase pha = extrema_interpolated_phase(sig, peaks, troughs, rises=rises, decays=decays) assert len(pha) == len(sig) assert np.all(np.isclose(pha[peaks], 0)) assert np.all(np.isclose(pha[troughs], -np.pi)) assert np.all(np.isclose(pha[rises], -np.pi / 2)) assert np.all(np.isclose(pha[decays], np.pi / 2))
def test_plot_cyclepoints_array(sim_args): peaks, troughs = find_extrema(sim_args['sig'], sim_args['fs'], (6, 14)) rises, decays = find_zerox(sim_args['sig'], peaks, troughs) plot_cyclepoints_array(sim_args['sig'], sim_args['fs'], peaks=peaks, troughs=troughs, rises=rises, decays=decays, save_fig=True, file_name='test_plot_cyclepoints_array', file_path=TEST_PLOTS_PATH)
def test_plot_cyclepoints_array(sim_args): ps, ts = find_extrema(sim_args['sig'], sim_args['fs'], (6, 14)) zerox_rise, zerox_decay = find_zerox(sim_args['sig'], ps, ts) plot_cyclepoints_array(sim_args['sig'], sim_args['fs'], ps=ps, ts=ts, zerox_rise=zerox_rise, zerox_decay=zerox_decay, save_fig=True, file_name='test_plot_cyclepoints_array', file_path=TEST_PLOTS_PATH)
def test_compute_cyclepoints(sim_args): sig = sim_args['sig'] fs = sim_args['fs'] f_range = sim_args['f_range'] peaks, troughs = find_extrema(sig, fs, f_range) rises, decays = find_zerox(sig, peaks, troughs) df_samples = compute_cyclepoints(sig, fs, f_range) assert (df_samples['sample_peak'] == peaks[1:]).all() assert (df_samples['sample_zerox_decay'] == decays[1:]).all() assert (df_samples['sample_zerox_rise'] == rises).all() assert (df_samples['sample_last_trough'] == troughs[:-1]).all() assert (df_samples['sample_next_trough'] == troughs[1:]).all()
def test_find_zerox(): """Test ability to find peaks and troughs.""" # Load signal signal = np.load(DATA_PATH + 'sim_stationary.npy') Fs = 1000 f_range = (6, 14) # Find peaks and troughs Ps, Ts = cyclepoints.find_extrema(signal, Fs, f_range, boundary=1, first_extrema='peak') # Find zerocrossings zeroxR, zeroxD = cyclepoints.find_zerox(signal, Ps, Ts) assert len(Ps) == (len(zeroxR) + 1) assert len(Ts) == len(zeroxD) assert Ps[0] < zeroxD[0] assert zeroxD[0] < Ts[0] assert Ts[0] < zeroxR[0] assert zeroxR[0] < Ps[1]
def compute_features(sig, fs, f_range, center_extrema='P', burst_detection_method='cycles', burst_detection_kwargs=None, find_extrema_kwargs=None, hilbert_increase_n=False): """Segment a recording into individual cycles and compute features for each cycle. Parameters ---------- sig : 1d array Voltage time series. fs : float Sampling rate, in Hz. f_range : tuple of (float, float) Frequency range for narrowband signal of interest (Hz). center_extrema : {'P', 'T'} The center extrema in the cycle. - 'P' : cycles are defined trough-to-trough - 'T' : cycles are defined peak-to-peak burst_detection_method : {'cycles', 'amp'} Method for detecting bursts. - 'cycles': detect bursts based on the consistency of consecutive periods & amplitudes - 'amp': detect bursts using an amplitude threshold burst_detection_kwargs : dict, optional Keyword arguments for function to find label cycles as in or not in an oscillation. find_extrema_kwargs : dict, optional Keyword arguments for function to find peaks an troughs (:func:`~.find_extrema`) to change filter Parameters or boundary.By default, it sets the filter length to three cycles of the low cutoff frequency (``f_range[0]``). hilbert_increase_n : bool, optional, default: False Corresponding kwarg for :func:`~neurodsp.timefrequency.hilbert.amp_by_time`. If true, this zero-pads the signal when computing the Fourier transform, which can be necessary for computing it in a reasonable amount of time. Returns ------- df : pandas.DataFrame Dataframe containing features and identifiers for each cycle. Each row is one cycle. Columns (listed for peak-centered cycles): - ``sample_peak`` : sample of 'sig' at which the peak occurs - ``sample_zerox_decay`` : sample of the decaying zero-crossing - ``sample_zerox_rise`` : sample of the rising zero-crossing - ``sample_last_trough`` : sample of the last trough - ``sample_next_trough`` : sample of the next trough - ``period`` : period of the cycle - ``time_decay`` : time between peak and next trough - ``time_rise`` : time between peak and previous trough - ``time_peak`` : time between rise and decay zero-crosses - ``time_trough`` : duration of previous trough estimated by zero-crossings - ``volt_decay`` : voltage change between peak and next trough - ``volt_rise`` : voltage change between peak and previous trough - ``volt_amp`` : average of rise and decay voltage - ``volt_peak`` : voltage at the peak - ``volt_trough`` : voltage at the last trough - ``time_rdsym`` : fraction of cycle in the rise period - ``time_ptsym`` : fraction of cycle in the peak period - ``band_amp`` : average analytic amplitude of the oscillation computed using narrowband filtering and the Hilbert transform. Filter length is 3 cycles of the low cutoff frequency. Average taken across all time points in the cycle. - ``is_burst`` : True if the cycle is part of a detected oscillatory burst - ``amp_fraction`` : normalized amplitude - ``amp_consistency`` : difference in the rise and decay voltage within a cycle - ``period_consistency`` : difference between a cycle’s period and the period of the adjacent cycles - ``monotonicity`` : fraction of instantaneous voltage changes between consecutive samples that are positive during the rise phase and negative during the decay phase Notes ----- Peak vs trough centering - By default, the first extrema analyzed will be a peak, and the final one a trough. - In order to switch the preference, the signal is simply inverted and columns are renamed. - Columns are slightly different depending on if ``center_extrema`` is set to 'P' or 'T'. """ # Set defaults if user input is None if burst_detection_kwargs is None: burst_detection_kwargs = {} warnings.warn(''' No burst detection parameters are provided. This is not recommended. Check your data and choose appropriate parameters for "burst_detection_kwargs". Default burst detection parameters are likely not well suited for the data. ''') if find_extrema_kwargs is None: find_extrema_kwargs = {'filter_kwargs': {'n_cycles': 3}} else: # Raise warning if switch from peak start to trough start if 'first_extrema' in find_extrema_kwargs.keys(): raise ValueError(''' This function assumes that the first extrema identified will be a peak. This cannot be overwritten at this time.''') # Negate signal if to analyze trough-centered cycles if center_extrema == 'P': pass elif center_extrema == 'T': sig = -sig else: raise ValueError( 'Parameter "center_extrema" must be either "P" or "T"') # Find peak and trough locations in the signal ps, ts = find_extrema(sig, fs, f_range, **find_extrema_kwargs) # Find zero-crossings zerox_rise, zerox_decay = find_zerox(sig, ps, ts) # For each cycle, identify the sample of each extrema and zero-crossing shape_features = {} shape_features['sample_peak'] = ps[1:] shape_features['sample_zerox_decay'] = zerox_decay[1:] shape_features['sample_zerox_rise'] = zerox_rise shape_features['sample_last_trough'] = ts[:-1] shape_features['sample_next_trough'] = ts[1:] # Compute duration of period shape_features['period'] = shape_features['sample_next_trough'] - \ shape_features['sample_last_trough'] # Compute duration of peak shape_features['time_peak'] = shape_features['sample_zerox_decay'] - \ shape_features['sample_zerox_rise'] # Compute duration of last trough shape_features['time_trough'] = zerox_rise - zerox_decay[:-1] # Determine extrema voltage shape_features['volt_peak'] = sig[ps[1:]] shape_features['volt_trough'] = sig[ts[:-1]] # Determine rise and decay characteristics shape_features['time_decay'] = (ts[1:] - ps[1:]) shape_features['time_rise'] = (ps[1:] - ts[:-1]) shape_features['volt_decay'] = sig[ps[1:]] - sig[ts[1:]] shape_features['volt_rise'] = sig[ps[1:]] - sig[ts[:-1]] shape_features['volt_amp'] = (shape_features['volt_decay'] + shape_features['volt_rise']) / 2 # Compute rise-decay symmetry features shape_features[ 'time_rdsym'] = shape_features['time_rise'] / shape_features['period'] # Compute peak-trough symmetry features shape_features['time_ptsym'] = shape_features['time_peak'] / \ (shape_features['time_peak'] + shape_features['time_trough']) # Compute average oscillatory amplitude estimate during cycle amp = amp_by_time(sig, fs, f_range, hilbert_increase_n=hilbert_increase_n, n_cycles=3) shape_features['band_amp'] = [ np.mean(amp[ts[sig_idx]:ts[sig_idx + 1]]) for sig_idx in range(len(shape_features['sample_peak'])) ] # Convert feature dictionary into a DataFrame df = pd.DataFrame.from_dict(shape_features) # Define whether or not each cycle is part of a burst if burst_detection_method == 'cycles': df = detect_bursts_cycles(df, sig, **burst_detection_kwargs) elif burst_detection_method == 'amp': df = detect_bursts_df_amp(df, sig, fs, f_range, **burst_detection_kwargs) else: raise ValueError('Invalid entry for "burst_detection_method"') # Rename columns if they are actually trough-centered if center_extrema == 'T': rename_dict = { 'sample_peak': 'sample_trough', 'sample_zerox_decay': 'sample_zerox_rise', 'sample_zerox_rise': 'sample_zerox_decay', 'sample_last_trough': 'sample_last_peak', 'sample_next_trough': 'sample_next_peak', 'time_peak': 'time_trough', 'time_trough': 'time_peak', 'volt_peak': 'volt_trough', 'volt_trough': 'volt_peak', 'time_rise': 'time_decay', 'time_decay': 'time_rise', 'volt_rise': 'volt_decay', 'volt_decay': 'volt_rise' } df.rename(columns=rename_dict, inplace=True) # Need to reverse symmetry measures df['volt_peak'] = -df['volt_peak'] df['volt_trough'] = -df['volt_trough'] df['time_rdsym'] = 1 - df['time_rdsym'] df['time_ptsym'] = 1 - df['time_ptsym'] return df
#################################################################################################### # # 2. Localize rise and decay midpoints # ------------------------------------ # # In addition to localizing the peaks and troughs of a cycle, we also want to get more information # about the rise and decay periods. For instance, these flanks may have deflections if the peaks or # troughs are particularly sharp. In order to gauge a dimension of this, we localize midpoints for # each of the rise and decay segments. These midpoints are defined as the times at which the voltage # crosses halfway between the adjacent peak and trough voltages. If this threshold is crossed # multiple times, then the median time is chosen as the flank midpoint. This is not perfect; # however, this is rare, and most of these cycles should be removed by burst detection. from bycycle.cyclepoints import find_zerox zeroxR, zeroxD = find_zerox(signal_low, Ps, Ts) #################################################################################################### tlim = (13, 14) tidx = np.logical_and(t >= tlim[0], t < tlim[1]) tidxPs = Ps[np.logical_and(Ps > tlim[0] * Fs, Ps < tlim[1] * Fs)] tidxTs = Ts[np.logical_and(Ts > tlim[0] * Fs, Ts < tlim[1] * Fs)] tidxDs = zeroxD[np.logical_and(zeroxD > tlim[0] * Fs, zeroxD < tlim[1] * Fs)] tidxRs = zeroxR[np.logical_and(zeroxR > tlim[0] * Fs, zeroxR < tlim[1] * Fs)] plt.figure(figsize=(12, 2)) plt.plot(t[tidx], signal_low[tidx], 'k') plt.plot(t[tidxPs], signal_low[tidxPs], 'b.', ms=10) plt.plot(t[tidxTs], signal_low[tidxTs], 'r.', ms=10) plt.plot(t[tidxDs], signal_low[tidxDs], 'm.', ms=10)
def compute_features(x, Fs, f_range, center_extrema='P', burst_detection_method='cycles', burst_detection_kwargs=None, find_extrema_kwargs=None, hilbert_increase_N=False): """ Segment a recording into individual cycles and compute features for each cycle Parameters ---------- x : 1d array voltage time series Fs : float sampling rate (Hz) f_range : tuple of (float, float) frequency range for narrowband signal of interest (Hz) center_extrema : {'P', 'T'} The center extrema in the cycle 'P' : cycles are defined trough-to-trough 'T' : cycles are defined peak-to-peak burst_detection_method: {'consistency', 'amp'} Method for detecting bursts 'cycles': detect bursts based on the consistency of consecutive periods and amplitudes 'amp': detect bursts using an amplitude threshold burst_detection_kwargs : dict | None Keyword arguments for function to find label cycles as in or not in an oscillation find_extrema_kwargs : dict | None Keyword arguments for function to find peaks and troughs (cyclepoints.find_extrema) to change filter parameters or boundary. By default, it sets the filter length to three cycles of the low cutoff frequency (`f_range[0]`) hilbert_increase_N : bool corresponding kwarg for filt.amp_by_time If true, this zeropads the signal when computing the Fourier transform, which can be necessary for computing it in a reasonable amount of time. Returns ------- df : pandas.DataFrame dataframe containing several features and identifiers for each cycle. Each row is one cycle. Columns (listed for peak-centered cycles): - sample_peak : sample of 'x' at which the peak occurs - sample_zerox_decay : sample of the decaying zerocrossing - sample_zerox_rise : sample of the rising zerocrossing - sample_last_trough : sample of the last trough - sample_next_trough : sample of the next trough - period : period of the cycle - time_decay : time between peak and next trough - time_rise : time between peak and previous trough - time_peak : time between rise and decay zerocrosses - time_trough : duration of previous trough estimated by zerocrossings - volt_decay : voltage change between peak and next trough - volt_rise : voltage change between peak and previous trough - volt_amp : average of rise and decay voltage - volt_peak : voltage at the peak - volt_trough : voltage at the last trough - time_rdsym : fraction of cycle in the rise period - time_ptsym : fraction of cycle in the peak period - band_amp : average analytic amplitude of the oscillation computed using narrowband filtering and the Hilbert transform. Filter length is 3 cycles of the low cutoff frequency. Average taken across all time points in the cycle. - is_burst : True if the cycle is part of a detected oscillatory burst Notes ----- Peak vs trough centering - By default, the first extrema analyzed will be a peak, and the final one a trough. In order to switch the preference, the signal is simply inverted and columns are renamed. - Columns are slightly different depending on if 'center_extrema' is set to 'P' or 'T'. """ # Set defaults if user input is None if burst_detection_kwargs is None: burst_detection_kwargs = {} warnings.warn(''' No burst detection parameters are provided. This is very much not recommended. Please inspect your data and choose appropriate parameters for "burst_detection_kwargs". Default burst detection parameters are likely not well suited for your desired application. ''') if find_extrema_kwargs is None: find_extrema_kwargs = {'filter_kwargs': {'N_cycles': 3}} else: # Raise warning if switch from peak start to trough start if 'first_extrema' in find_extrema_kwargs.keys(): raise ValueError('''This function has been designed to assume that the first extrema identified will be a peak. This cannot be overwritten at this time.''') # Negate signal if to analyze trough-centered cycles if center_extrema == 'P': pass elif center_extrema == 'T': x = -x else: raise ValueError('Parameter "center_extrema" must be either "P" or "T"') # Find peak and trough locations in the signal Ps, Ts = find_extrema(x, Fs, f_range, **find_extrema_kwargs) # Find zero-crossings zeroxR, zeroxD = find_zerox(x, Ps, Ts) # For each cycle, identify the sample of each extrema and zerocrossing shape_features = {} shape_features['sample_peak'] = Ps[1:] shape_features['sample_zerox_decay'] = zeroxD[1:] shape_features['sample_zerox_rise'] = zeroxR shape_features['sample_last_trough'] = Ts[:-1] shape_features['sample_next_trough'] = Ts[1:] # Compute duration of period shape_features['period'] = shape_features['sample_next_trough'] - \ shape_features['sample_last_trough'] # Compute duration of peak shape_features['time_peak'] = shape_features['sample_zerox_decay'] - \ shape_features['sample_zerox_rise'] # Compute duration of last trough shape_features['time_trough'] = zeroxR - zeroxD[:-1] # Determine extrema voltage shape_features['volt_peak'] = x[Ps[1:]] shape_features['volt_trough'] = x[Ts[:-1]] # Determine rise and decay characteristics shape_features['time_decay'] = (Ts[1:] - Ps[1:]) shape_features['time_rise'] = (Ps[1:] - Ts[:-1]) shape_features['volt_decay'] = x[Ps[1:]] - x[Ts[1:]] shape_features['volt_rise'] = x[Ps[1:]] - x[Ts[:-1]] shape_features['volt_amp'] = (shape_features['volt_decay'] + shape_features['volt_rise']) / 2 # Comptue rise-decay symmetry features shape_features['time_rdsym'] = shape_features['time_rise'] / shape_features['period'] # Compute peak-trough symmetry features shape_features['time_ptsym'] = shape_features['time_peak'] / (shape_features['time_peak'] + shape_features['time_trough']) # Compute average oscillatory amplitude estimate during cycle amp = amp_by_time(x, Fs, f_range, hilbert_increase_N=hilbert_increase_N, filter_kwargs={'N_cycles': 3}) shape_features['band_amp'] = [np.mean(amp[Ts[i]:Ts[i + 1]]) for i in range(len(shape_features['sample_peak']))] # Convert feature dictionary into a DataFrame df = pd.DataFrame.from_dict(shape_features) # Define whether or not each cycle is part of a burst if burst_detection_method == 'cycles': df = detect_bursts_cycles(df, x, **burst_detection_kwargs) elif burst_detection_method == 'amp': df = detect_bursts_df_amp(df, x, Fs, f_range, **burst_detection_kwargs) else: raise ValueError('Invalid entry for "burst_detection_method"') # Rename columns if they are actually trough-centered if center_extrema == 'T': rename_dict = {'sample_peak': 'sample_trough', 'sample_zerox_decay': 'sample_zerox_rise', 'sample_zerox_rise': 'sample_zerox_decay', 'sample_last_trough': 'sample_last_peak', 'sample_next_trough': 'sample_next_peak', 'time_peak': 'time_trough', 'time_trough': 'time_peak', 'volt_peak': 'volt_trough', 'volt_trough': 'volt_peak', 'time_rise': 'time_decay', 'time_decay': 'time_rise', 'volt_rise': 'volt_decay', 'volt_decay': 'volt_rise'} df.rename(columns=rename_dict, inplace=True) # Need to reverse symmetry measures df['volt_peak'] = -df['volt_peak'] df['volt_trough'] = -df['volt_trough'] df['time_rdsym'] = 1 - df['time_rdsym'] df['time_ptsym'] = 1 - df['time_ptsym'] return df
# # 2. Localize rise and decay midpoints # ------------------------------------ # # In addition to localizing the peaks and troughs of a cycle, we also want to get more information # about the rise and decay periods. For instance, these flanks may have deflections if the peaks or # troughs are particularly sharp. In order to gauge a dimension of this, we localize midpoints for # each of the rise and decay segments. These midpoints are defined as the times at which the voltage # crosses halfway between the adjacent peak and trough voltages. If this threshold is crossed # multiple times, then the median time is chosen as the flank midpoint. This is not perfect; # however, this is rare, and most of these cycles should be removed by burst detection. # # Note: Plotting midpoints and extrema may also be performed using the dataframe output from # :func:`~.compute_features` with the :func:`~.plot_cyclepoints` function. rises, decays = find_zerox(sig_low, peaks, troughs) plot_cyclepoints_array(sig_low, fs, xlim=(13, 14), peaks=peaks, troughs=troughs, rises=rises, decays=decays) #################################################################################################### # # 3. Compute features of each cycle # --------------------------------- # After these 4 points of each cycle are localized, we compute some simple statistics for each # cycle. The main cycle-by-cycle function, compute_features(), returns a table (pandas.DataFrame)
#################################################################################################### # # 2. Localize rise and decay midpoints # ------------------------------------ # # In addition to localizing the peaks and troughs of a cycle, we also want to get more information # about the rise and decay periods. For instance, these flanks may have deflections if the peaks or # troughs are particularly sharp. In order to gauge a dimension of this, we localize midpoints for # each of the rise and decay segments. These midpoints are defined as the times at which the voltage # crosses halfway between the adjacent peak and trough voltages. If this threshold is crossed # multiple times, then the median time is chosen as the flank midpoint. This is not perfect; # however, this is rare, and most of these cycles should be removed by burst detection. from bycycle.cyclepoints import find_zerox zerox_rise, zerox_decay = find_zerox(sig_low, ps, ts) #################################################################################################### tlim = (13, 14) tidx = np.logical_and(times >= tlim[0], times < tlim[1]) tidx_ps = ps[np.logical_and(ps > tlim[0] * fs, ps < tlim[1] * fs)] tidx_ts = ts[np.logical_and(ts > tlim[0] * fs, ts < tlim[1] * fs)] tidx_ds = zerox_decay[np.logical_and(zerox_decay > tlim[0] * fs, zerox_decay < tlim[1] * fs)] tidx_rs = zerox_rise[np.logical_and(zerox_rise > tlim[0] * fs, zerox_rise < tlim[1] * fs)] plt.figure(figsize=(12, 2)) plt.plot(times[tidx], sig_low[tidx], 'k') plt.plot(times[tidx_ps], sig_low[tidx_ps], 'b.', ms=10)