ECoG Preprocessing Tutorial

This tutorial will go through the common signal processing steps used to preprocess ECoG data from raw voltages to zscored high gamma amplitudes. It will operate on a Numpy array of data rather than use data in an NWB file. All of the steps in this tutorial are typically independent of the experimental task structure.

Overview of preprocessing steps

  • Resampling

  • Notch filtering

  • Re-referencing

  • Time-frequency power calculation

  • Normalizing power for each frequency

Imports

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import welch

from process_nwb.utils import generate_synthetic_data
from process_nwb.resample import resample
from process_nwb.linenoise_notch import apply_linenoise_notch
from process_nwb.common_referencing import subtract_CAR
from process_nwb.wavelet_transform import wavelet_transform

Create synthetic neural data

We will create synthetic neural data by convolving white noise with a boxcar, adding power in the high gamma range with modulating amplitude, and adding common line noise (60Hz) with different weights to each channel.

num_channels = 64
duration = 10.  # seconds
sample_rate = 10000.  # Hz
new_sample_rate = 500.  # hz
neural_data = generate_synthetic_data(duration, num_channels, sample_rate) * 1e6
t = np.linspace(0, duration, neural_data.shape[0])[:, np.newaxis]

# Here is one chanel of synthetic neural data
plt.plot(t[:10000, 0], neural_data[:10000, 0])
plt.xlabel('Time (s)')
plt.ylabel('Amplitude (au)')
_ = plt.title('One channel of neural data')
One channel of neural data

Resampling the data

Often times, the raw data (voltages) are recorded at a much higher sampling rate than is needed for a particular analysis. Here, the raw data is sampled at 10,000 Hz but the high gamma range only goes up to 150 Hz. Resampling the will make many downstream computations much faster.

rs_data = resample(neural_data, new_sample_rate, sample_rate)
t = np.linspace(0, duration, rs_data.shape[0])

plt.plot(t[:500], rs_data[:500, 0])
plt.xlabel('Time (s)')
plt.ylabel('Amplitude (au)')
_ = plt.title('One channel of neural data after resampling')
One channel of neural data after resampling

Notch filtering

Notch filtering is used to remove the 60 Hz line noise and harmonics on all channels.

nth_data = apply_linenoise_notch(rs_data, new_sample_rate)

freq, car_pwr = welch(rs_data[:, 0], fs=new_sample_rate, nperseg=1024)
_, nth_pwr = welch(nth_data[:, 0], fs=new_sample_rate, nperseg=1024)

fig, axs = plt.subplots(1, 2, figsize=(10, 4), sharey=True, sharex=True)
axs[0].semilogy(freq, car_pwr)
axs[0].set_xlabel('Frequency (Hz)')
axs[0].set_ylabel('Power density (V^2/Hz)')
axs[0].set_xlim([1, 150])
axs[0].set_title('Pre notch filtering')

axs[1].semilogy(freq, nth_pwr)
axs[1].set_xlabel('Frequency (Hz)')
axs[1].set_xlim([1, 150])
axs[1].set_title('Post notch filtering')
_ = fig.tight_layout()
Pre notch filtering, Post notch filtering

Re-referencing with common average referencing

There is often common noise from neural recording such as movement artifacts or 60 Hz line noise. Additionally, it is often desirable for ECoG channels to be referenced to a common ground. Here, we use a robust estimate of the mean across all channels (for each timepoint). This quantity is then subtracted all channels. By default, this CAR function takes the mean over the center 95% of the electrodes.

car_data = subtract_CAR(nth_data)

plt.plot(t[:500], car_data[:500, 0])
plt.xlabel('Time (s)')
plt.ylabel('Amplitude (au)')
_ = plt.title('One channel of neural data after re-referencing from resample')
One channel of neural data after re-referencing from resample

Time-frequency decomposition with wavelets

Here we decompose the neural time series into 6 different frequency subbands in the high gamma range using a wavelet transform. The wavelet transform amplitude is complex valued and here we take the absolute value.

Note how the bands with center frequency nearest 100 Hz have larger amplitude.

tf_data, _, ctr_freq, bw = wavelet_transform(car_data, new_sample_rate,
                                             filters='rat', hg_only=True)
# Z scoring the amplitude instead of the complex waveform
tf_data = abs(tf_data)

num_tf_signals = len(ctr_freq)
fig, axs = plt.subplots(num_tf_signals, 1, sharex=True, sharey=True, figsize=(15, 10))
fig.subplots_adjust(hspace=0.4)
fig.tight_layout()


for idx in range(num_tf_signals):
    sig = tf_data[:, 0, idx]
    axs[idx].plot(t, sig)
    axs[idx].set_title('Frequency = {0:.0f} ; Bandwidth = {1:0.0f}'.format(ctr_freq[idx], bw[idx]))
    axs[idx].set_ylabel('Amp. (au)')
Frequency = 75 ; Bandwidth = 9, Frequency = 84 ; Bandwidth = 11, Frequency = 94 ; Bandwidth = 12, Frequency = 106 ; Bandwidth = 13, Frequency = 119 ; Bandwidth = 15, Frequency = 134 ; Bandwidth = 17

Normalizing power by zscoring

For neural data, power falls off with frequency so it can be difficult to compare amplitude changes across different frequency bands. Furthermore, different electrodes may have different contact with the neural surface, which can lead to meaningless differences in signal amplitude across channels. To address this issue, we normalize each frequency band and electrode by zscoring. In this case, the mean and standard deviation are calculated over the first 0.25 seconds of the signal, our synthetic baseline time. Different experimental recordings and tasks may use different baseline periods.

Then, we will average over the zscored high gamma subbands to form the final high gamma signal the plot the zscored ampliced for all channels.

mean = tf_data[:125].mean(axis=0, keepdims=True)
std = tf_data[:125].std(axis=0, keepdims=True)
tf_norm_data = (tf_data - mean) / std
high_gamma = tf_norm_data.mean(axis=-1)
fig, axs = plt.subplots(4, 1, sharex=True, sharey=True, figsize=(10, 10))
fig.subplots_adjust(hspace=0.4)
fig.tight_layout()

for idx in range(4):
    sig = high_gamma[:, idx]
    axs[idx].plot(t, sig)
    axs[idx].set_title('Channel {0:.0f}'.format(idx))
    axs[idx].set_ylabel('σ')
    axs[idx].set_ylim(-4, 4)
Channel 0, Channel 1, Channel 2, Channel 3

Congrats you now know how to preprocess ECoG signals!

Total running time of the script: ( 0 minutes 8.450 seconds)

Gallery generated by Sphinx-Gallery