User Tools

Site Tools



Spike-field relationships: phase locking, phase precession, etc.


  • Appreciate the conceptual importance of relating LFP patterns to spiking activity
  • Implement several measures of spike-field relationships
  • Understand the relative strengths and weaknesses of different measures
  • Cultivate awareness of some common pitfalls


FIXME The code in this module works, but needs to be converted to use the most recent data types


Local field potentials and related quantities such as the EEG ultimately result from the spatiotemporal summation of electrical currents across the cell membrane of neurons. Thus, in general, it is expected that spiking activity should be related to LFPs somehow. However, the exact nature of this relationship has proven to be far from trivial, and depends on many factors such as the three-dimensional arrangement of neurons, ion channel distributions, and more rapid dynamics arising from the interaction of multiple inputs. Accordingly, measures that characterize the relationship between spikes and LFPs have painted a surprisingly rich picture of how individual neurons participate in population- and systems-level activity (see e.g. Womelsdorf et al. 2005, Benchenane et al. 2010 and many others for striking examples).

A different, practical issue is that LFPs are susceptible to volume conduction, that is, they can be recorded some distance away from their source. The exact amount distance depends on numerous factors, but even intracranially in the rat the hippocampally-generated theta rhythm can be recorded several millimeters away in the cortex (Sirota et al. 2008). Thus, is is not a priori clear that a LFP recorded from a particular brain structure is generated there (is locally relevant) raising the possibility of attributing properties of the recording to the wrong site! One of the major ways to determine if a LFP is locally relevant is to establish if it is related to spiking activity, which as a fast and relatively local signal does not volume-conduct nearly as far.

In this module, we will examine basic measures of spike-field relationships useful for both approaches.

Spike-triggered averaging

Consider the following snippet of a LFP and spike trains from three neurons, simultaneously recorded from dorsal CA1 in the rat hippocampus:

From visual inspection alone, it seems clear that the spike times have some relationship to the LFP. The spikes in the top row, for instance, appear to occur preferentially at the troughs of the prominent theta oscillation (count the peaks in one second, there are approximately 8). The neuron on the middle row seems to fire at a wider ranges of spikes, but appears to be avoiding the throughs, as is also apparent in the antiphase (alternating) relationship with the top neuron. (If you want to recreate this plot: the LFP is R016-2012-10-03-CSC02d.ncs and the three neurons are the .t files for the same session.)

How can we characterize and quantify such relationships?

Spike-triggered LFP

Perhaps the simplest way to visualize possible relationships between spikes and a LFP is to simply average, over all spikes, the surrounding LFP. This is known as the spike-triggered average (STA) and is easy to obtain. Before loading the data, rename any *._t files to *.t; this is because FieldTrip will reject the files otherwise. Then, load the data:

% cd to R016-2012-10-03 folder
sd.fc = FindFiles('*.t');
sd.S = LoadSpikes(sd.fc);
csc = myLoadCSC('R016-2012-10-03-CSC02d.ncs');
cscR = Range(csc); cscD = Data(csc);
Fs = 2000; dt = 1./Fs;
cscD = locdetrend(cscD,Fs,[1 0.5]); % remove slow drifts in signal (this can mess up the STA)

Then, we simply grab a LFP snippet for each spike and average:

w = [-1 1]; % time window to compute STA over
tvec = w(1):dt:w(2); % time axis for STA
iC = 3; % only do the third neuron for now
clear sta;
spk_t = Data(sd.S{iC});
h = waitbar(0,sprintf('Cell %d/%d...',iC,length(sd.S)));
for iSpk = length(spk_t):-1:1 % for each spike...
   sta_t = spk_t(iSpk)+w(1);
   sta_idx = nearest(cscR,sta_t); % find index of leading window edge
   toAdd = cscD(sta_idx:sta_idx+length(tvec)-1); % grab LFP snippet for this window
   % note this way can be dangerous if there are gaps in the data
   sta{iC}(iSpk,:) = toAdd'; % build up matrix of [spikes x samples] to average later

If you run this, you will see it is quite slow, as indicated by the waitbar. When it finishes however, you can do

set(gca,'FontSize',14,'XLim',[-0.5 0.5]); xlabel('time (s)'); grid on;

You should get:

Some features of interest of this STA include:

  • There is a sharp spike visible at time 0. On the one hand, this is as expected; after all, time 0 is defined as the time of a spike. However, recall that the LFP is filtered between 1 and 425Hz, as can be verified by inspecting the header (getHeader(csc)). Clearly, this filter does not eliminate all spike components.
  • There is a clear oscillation in the theta range visible. This indicates that the spikes tend to occur firstly, during theta oscillations, and secondly, at a non-random phase of this oscillation (otherwise the oscillations would average out, assuming there are sufficient spikes that make up the average)

☛ There seems to be a certain asymmetry in the STA theta oscillation (clearer on the left than on the right). How might we interpret this?

Diversion: the MATLAB profiler

In situations like this, where your code is running slowly, it is often useful to use the profiler, a built-in tool that monitors the CPU time spent in each command. We may suspect that in the above code, the loop over spikes and the many calls to nearest() that result are the cause of slowness, but we can confirm this with a test. Enable the profiler as follows:

profile on

Now run the STA code again, but now for cell 1 rather than cell 3. Wait until it completes. You can now view the profiler report with the profview command. As suspected, we see that nearest() takes up the bulk of computation time. Turn the profiler off again with profile off.

A faster way to compute the STA, that avoids the for loop and the use of nearest() is the following:

bin_edges = cscR+(dt/2);
len = length(tvec);
clear sta;
iC = 1;
spk_ts = Restrict(sd.S{iC},cscR(1)-w(1),cscR(end)-w(2));
spk_t = Data(spk_ts)+w(1); % times corresponding to start of window
[~,spk_bins] = histc(spk_t,bin_edges); % index into data for start of window
spk_bins2 = spk_bins(:, ones(len,1));
toadd = repmat(0:len-1,[length(spk_bins) 1]);
spk_bins3 = spk_bins2+toadd;
sta = cscD(spk_bins3);

This solution is about a hundred times faster, a speed-up accomplished by constructing a large matrix of appropriately chosen indices into the LFP: each row contains sequential indices corresponding to a LFP snippet centered on a single spike.

☛ Plot the STA by averaging the sta matrix over spikes. What differences between the STA for neurons 1 and 3 do you notice?

Spike-triggered averages in FieldTrip

For a more sophisticated analysis of STAs, we move to FieldTrip. Since we now need to load not just LFPs, but also spikes, there are a few preliminaries to take care of first.

:!: For this section to work, you need to use the version of read_mclust_t.m that is in the nsb2015 codebase, NOT the one in fieldtrip!

Now we can load some LFPs and the spikes from this neuron. Ignore the usual warnings about timestamp mismatches arising from gaps in the LFP data.

spike = ft_read_spike('R016-2012-10-03-TT02_2.t'); % needs fixed read_mclust_t.m
fc = {'R016-2012-10-03-CSC02b.ncs','R016-2012-10-03-CSC02d.ncs','R016-2012-10-03-CSC03d.ncs'};
data = ft_read_neuralynx_interp(fc);
data_all = ft_appendspike([],data, spike);

ft_appendspike() creates a binned version of the spike train, with bins set to match the sampling frequency of the LFP. You can verify this as follows:

plot(data_all.time{1},data_all.trial{1}(1,:)) % a LFP
hold on;
plot(data_all.time{1},data_all.trial{1}(4,:)*500,'r') % binarized spike train (0 means no spike, 1 means spike)

data_all.labels keeps track of what is what, with the indices matching those of data_all.trial{1}. Recall that currently there is only one cell because by default, FieldTrip treats the entire recording as one “trial”.

Clearly, some precision about spike times is lost when converting spike times to this binarized format: as a result, spike times are now only known with a precision of 1/2000 s, rather than Neuralynx's native 0.1 us. However, when examining spike-field relationships for oscillations below 100Hz or so, 1/2000 is generally sufficient. FieldTrip has ways of maintaining full-precision spike representations explained in the spikefield tutorial, but for now we will use this fast and convenient method.

Let's compute and plot the STA for this neuron:

cfg              = [];
cfg.timwin       = [-0.5 0.5]; %
cfg.spikechannel = spike.label{1}; % first unit      = data.label(1:3); % first 3 LFPs
staAll           = ft_spiketriggeredaverage(cfg, data_all);
% plot
plot(staAll.time, staAll.avg(:,:)');
legend(data.label); h = title(cfg.spikechannel); set(h,'Interpreter','none');
xlabel('time (s)'); grid on;

You should see:

FieldTrip makes it easy to process several LFPs simultaneously. Note that as in our manually computed STA above, the LFP channels on the same tetrode as the spikes (“CSC02a-d” are the LFP channels for tetrode “TT02”) have the clear spike artifact at time zero. Also, the red STA is from a different tetrode (“TT03”) and consequently looks very different. In fact, this tetrode was positioned not in the hippocampus (as TT02 was) but in the ventral striatum. If you look carefully, you can just make out a faint theta modulation in the vStr STA as well.

One common question is to ask if the spike-LFP relationship is related to task events. To explore this, we first need to segment our data into trials, as we have done in previous modules:

cfg = [];
cfg.trialfun = 'ft_trialfun_lineartracktone2';
cfg.trialdef.hdr = data.hdr;
cfg.trialdef.pre = 2.5; = 5;
cfg.trialdef.eventtype = 'nosepoke'; % could be 'nosepoke', 'reward', 'cue'
cfg.trialdef.location = 'both'; % could be 'left', 'right', 'both'
cfg.trialdef.block = 'both'; % could be 'value', 'risk'
cfg.trialdef.cue = {'c1','c3','c5'}; % cell array with choice of elements {'c1','c3','c5','lo','hi'}
[trl, event] = ft_trialfun_lineartracktone2(cfg);
cfg.trl = trl;
data_trl = ft_redefinetrial(cfg,data_all);

Notice that the contents of data_trl now have multiple cells (88) corresponding to trials, rather than just one for the entire session as before.

Now, we can compute the STA for specific task segments, defined relative to task event time zero, the nosepokes into the reward receptacle:

cfg              = [];
cfg.timwin       = [-0.5 0.5];
cfg.latency      = [-2.5 0];
cfg.spikechannel = spike.label{1}; % first unit      = data.label(1:3); % first 3 LFPs
staPre           = ft_spiketriggeredaverage(cfg, data_trl);
% plot
plot(staPre.time, staPre.avg(:,:)');
legend(data.label); h = title(cfg.spikechannel); set(h,'Interpreter','none');
xlabel('time (s)'); grid on;

Note the use of the latency field: this restricts the STA to the “reward approach” from 2.5 seconds before to the nosepoke.

☛ How does this pre-nosepoke STA compare to the non-restricted STA? Confirm your impressions by also computing and plotting a “post-nosepoke” STA of equivalent window length.

The spike artifact at time zero is going to cause us trouble later on. FieldTrip provides functionality for removing it:

cfg              = [];
cfg.timwin       = [-0.002 0.006]; % remove 4 ms around every spike
cfg.spikechannel = spike.label{1};      = data.label(2); % only remove spike in the second LFP ('02d')
cfg.method       = 'linear'; % remove the replaced segment with interpolation
data_trli        = ft_spiketriggeredinterpolation(cfg, data_trl);

If you now recompute and plot the pre-nosepoke STA as above (remembering to use data_trli, not the original data_trl) you should get:

The spike artifact is now no longer visible in the green '02d' STA.

Note that a possibly better way, avoiding potential issues arising from the removal procedure, is to compute the STA against a nearby channel that does not have the spike waveform; however, this is not always possible.

Phase locking

The STA gives a useful visualization of spike-field relationships, but it is not well suited for statistical tests, or visualizations of changes over time. To do this, we start with computing the Fourier transform of a small window of data centered on every spike, to obtain a periodogram with magnitude and phase information. This idea can be visualized as follows:

(source: FieldTrip spike-field tutorial; “DFT” is a specific implementation of the Fourier transform for sampled data)

The code:

cfg              = [];
cfg.method       = 'convol';
cfg.foi          = 1:1:100;
cfg.t_ftimwin    = 5./cfg.foi; % 5 cycles per frequency
cfg.taper        = 'hanning';
cfg.spikechannel = spike.label{1};      = data.label{1};
stsConvol        = ft_spiketriggeredspectrum(cfg, data_trli);

To understand the output struct stsConvol better, we can plot the spike-triggered PSD by averaging over spikes:


You will see a clear peak in the theta range, indicating that spikes are associated with elevated theta power.

Similarly, we can plot the distribution of phases in the theta frequency range:


The spike phases are not distributed uniformly across the phase range, indicating a relationship between the spike times and the LFP. Indeed, the spike phases form the basis for statistical measures of spike-field relationships, such as the pairwise phase consistency (PPC; Vinck et al. 2010):

cfg               = [];
cfg.method        = 'ppc0'; % compute the Pairwise Phase Consistency
cfg.spikechannel  = stsConvol.label;       = stsConvol.lfplabel; % selected LFP channels
cfg.avgoverchan   = 'unweighted'; % weight spike-LFP phases irrespective of LFP power
cfg.timwin        = 'all'; % compute over all available spikes in the window
cfg.latency       = [-2.5 0];
statSts           = ft_spiketriggeredspectrum_stat(cfg,stsConvol);

% plot the results

You should get:

A clear peak in the theta frequency is visible, suggesting a spike-field relationship for that range. However, there is a strange increasing value with increasing frequency. Could this be due to the spike artifact at time zero?

☛ Modify the cfg for ft_spiketriggeredspectrum() and subsequent steps, but now using the LFP channel from which we had previously removed the spike artifact. What does the the resulting PPC over frequency look like?

The pairwise phase consistency is a modified version of the phase-locked value, the length of the vector mean of all the phases. PLV is intuitive, but suffers from an upward bias for low numbers of spikes, because if there is only one spike, the resultant mean vector length is also 1.

Changes in phase locking over time

We can compute the PPC using a sliding window:

param = 'ppc0'; % set the desired parameter
cfg                = [];
cfg.method         = param;
cfg.spikechannel  = stsConvol.label;       = stsConvol.lfplabel; % selected LFP channels
cfg.avgoverchan    = 'unweighted';
cfg.winstepsize    = 0.01; % step size of the window that we slide over time
cfg.timwin         = 0.5; % duration of sliding window
statSts            = ft_spiketriggeredspectrum_stat(cfg,stsConvol);
cfg            = [];
cfg.parameter  = param;
cfg.refchannel = statSts.labelcmb{1,1};    = statSts.labelcmb{1,2};
cfg.xlim       = [-2 3]; cfg.ylim = [2 30];
ft_singleplotTFR(cfg, statSts)

This results in:

This plot indicates that there is some phase-locking in the high theta range, which disappears from -0.5 seconds to about 0.5 seconds after the nosepoke, after which a complex pattern appears. Some of this may be due to chewing artifacts.

Spike-field coherence

An alternative method of quantifying spike-field relationships is to not extract a phase for each spike, as done above, but instead compute a spectrum over multiple spikes. This uses the same approach as what we did previously for LFP-LFP coherence, relying on the idea of a cross-spectral density:

cfg              = [];
cfg.output       = 'powandcsd';
cfg.method       = 'mtmconvol';
cfg.taper        = 'hanning';
cfg.foi          = 1:1:100; % frequencies to use
cfg.t_ftimwin    = 5./cfg.foi;  % frequency-dependent, 5 cycles per time window
cfg.keeptrials   = 'yes';      = {'R016-2012-10-03-CSC02b', 'R016-2012-10-03-TT02_2'};
cfg.channelcmb   = {'R016-2012-10-03-CSC02b', 'R016-2012-10-03-TT02_2'}; % channel pairs to compute csd for
cfg.toi          = -2:0.05:3;
TFR_pre = ft_freqanalysis(cfg, data_trl);
cfg            = [];
cfg.method     = 'ppc'; % compute coherence; other measures of connectivity are also available
fd             = ft_connectivityanalysis(cfg,TFR_pre);
iC = 1; % which signal pair to plot
lbl = [fd.labelcmb{1,:}]; % get the label of this pair
imagesc(fd.time,fd.freq,sq(fd.ppcspctrm(iC,:,:))); axis xy; colorbar
xlabel('time (s)'); ylabel('Frequency (Hz)'); title(lbl);

This gives:

As noted previously, the Fourier transform of the autocorrelation function of a LFP is related to the Fourier transform of the signal itself, and the Fourier transform of the cross-correlation function gives the cross-spectral density, the basis for the coherence measure that characterizes the degree to which two signals maintain a consistent phase relationship. Since we can compute the acf and ccf of spike trains, this means we can similarly compute a cross-spectral density between a spike train and a LFP. However, the spike spectrum does not maintain information about how many spikes were used to estimate it, and as a result the spike-field coherence is also subject to biases. The PPC method based on a separate FFT for each spike is the preferred current method.

Phase precession

The idea of assigning a phase to each spike also lends itself to other interesting analyses. We can for instance ask if spike phase varies systematically as a function of task:

csc = myLoadCSC('R016-2012-10-03-CSC02d.ncs');
cscR = Range(csc); cscD = Data(csc);
% filter in theta range
Fs = 2000;
Wp = [ 6 10] * 2 / Fs;
Ws = [ 4 12] * 2 / Fs;
[N,Wn] = cheb1ord( Wp, Ws, 3, 20); % determine filter parameters
[b_c1,a_c1] = cheby1(N,0.5,Wn); % builds filter
csc_filtered = filtfilt(b_c1,a_c1,cscD);
phi = angle(hilbert(csc_filtered));

Now we have a time series of theta phases, which we can use to get a theta phase for each spike:

S = LoadSpikes({'R016-2012-10-03-TT02_1.t'});
spk_t = Data(S{1});
spk_phi = interp1(cscR,phi,spk_t,'nearest');

As before, the phase histogram is non-uniform, indicating a spike-LFP relationship. Now we can plot phase as a function of position:

[Timestamps, X, Y, Angles, Targets, Points, Header] = Nlx2MatVT('VT1.nvt', [1 1 1 1 1 1], 1, 1, []);
Timestamps = Timestamps*10^-6;
toRemove = (X == 0 & Y == 0);
X = X(~toRemove); Y = Y(~toRemove); Timestamps = Timestamps(~toRemove);
spk_x = interp1(Timestamps,X,spk_t,'linear');
spk_y = interp1(Timestamps,Y,spk_t,'linear');
plot(X,Y,'.','Color',[0.5 0.5 0.5],'MarkerSize',1); axis off; hold on;
h = scatterplotC(spk_x,spk_y,spk_phi,'Scale',[-pi pi],'solid_face',1,'plotchar','.');

You should get:

Each spike is now plotted according to where the rat was when it occurred, and the color indicates the theta phase. Note that there is a systematic phase change visible as the rat runs along the bottom edge of the track: this is the famous theta phase precession phenomenon characteristic of hippocampal place cells.

analysis/nsb2015/week13.txt · Last modified: 2018/07/07 10:19 (external edit)