Skip to content

Commit c3f4cda

Browse files
Add more ERP measures
Co-authored-by: Jacob Woessner <Woessner.jacob@gmail.com>
1 parent 9d7e571 commit c3f4cda

5 files changed

Lines changed: 417 additions & 3 deletions

File tree

doc/api/statistics.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ ERP-related statistics:
5353
.. autosummary::
5454
:toctree: ../generated/
5555

56+
erp.compute_area
57+
erp.compute_frac_area_latency
58+
erp.compute_frac_peak_latency
59+
erp.compute_peak
5660
erp.compute_sme
5761

5862
Compute ``adjacency`` matrices for cluster-level statistics:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added 4 new ERP measures: peak latency, peak amplitude, fractional peak latency, and fractional area latency. By `Jacob Woessner` and `Beliz Sertcan Gökmen`.

mne/stats/erp.py

Lines changed: 337 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,16 @@
55
# Copyright the MNE-Python contributors.
66

77
import numpy as np
8+
from scipy import integrate
89

9-
from mne.utils import _validate_type
10+
from mne._fiff.pick import _picks_to_idx
11+
from mne.utils import (
12+
_check_option,
13+
_check_pandas_installed,
14+
_time_mask,
15+
_validate_type,
16+
fill_doc,
17+
)
1018

1119

1220
def compute_sme(epochs, start=None, stop=None):
@@ -84,3 +92,331 @@ def compute_sme(epochs, start=None, stop=None):
8492

8593
data = epochs.get_data(tmin=start, tmax=stop)
8694
return data.mean(axis=2).std(axis=0) / np.sqrt(data.shape[0])
95+
96+
97+
def _compute_peak(
98+
evoked, start=None, stop=None, picks="all", mode="abs", average=False, strict=True
99+
):
100+
"""Locate the peak shared by compute_peak and compute_frac_peak_latency."""
101+
data = evoked.get_data(picks=picks)
102+
picked_idx = _picks_to_idx(evoked.info, picks, "all", exclude=())
103+
ch_names = [evoked.ch_names[i] for i in picked_idx]
104+
times = evoked.times
105+
mask = _time_mask(times, start, stop, evoked.info["sfreq"])
106+
data_masked = data[:, mask]
107+
108+
if average:
109+
data = np.mean(data, axis=0, keepdims=True)
110+
data_masked = np.mean(data_masked, axis=0, keepdims=True)
111+
ch_names = ["Average"]
112+
113+
if mode == "abs":
114+
data_masked = np.abs(data_masked)
115+
elif mode == "neg":
116+
if strict and not np.any(data_masked < 0):
117+
raise ValueError(
118+
"No negative values encountered. Cannot operate in neg mode."
119+
)
120+
data_masked = -data_masked
121+
elif mode == "pos":
122+
if strict and not np.any(data_masked > 0):
123+
raise ValueError(
124+
"No positive values encountered. Cannot operate in pos mode."
125+
)
126+
127+
max_indices = np.argmax(data_masked, axis=1)
128+
peak_amplitudes = data[np.arange(data.shape[0]), max_indices + np.where(mask)[0][0]]
129+
peak_latencies = times[max_indices + np.where(mask)[0][0]]
130+
131+
return peak_latencies, peak_amplitudes, data_masked, mask, times, ch_names
132+
133+
134+
@fill_doc
135+
def compute_peak(
136+
evoked,
137+
start=None,
138+
stop=None,
139+
picks="all",
140+
mode="abs",
141+
average=False,
142+
strict=True,
143+
):
144+
"""Compute the peak amplitude and latency of an evoked response.
145+
146+
Parameters
147+
----------
148+
evoked : instance of Evoked
149+
The evoked response object.
150+
%(erp_evoked_start_stop)s
151+
%(picks_all)s
152+
mode : str
153+
Specifies how the peak amplitude should be determined. Can be one of:
154+
- 'abs' : The peak amplitude is the maximum absolute value.
155+
- 'neg': The peak amplitude is the maximum negative value. If there are no
156+
negative values and `strict` is True, a ValueError is raised.
157+
- 'pos': The peak amplitude is the maximum positive value. If there are no
158+
positive values and `strict` is True, a ValueError is raised.
159+
Defaults to 'abs'.
160+
average : bool
161+
If True, the peak amplitude is computed by averaging the data across
162+
channels before finding the peak. Defaults to False.
163+
%(erp_strict)s
164+
165+
Returns
166+
-------
167+
peak_df : pd.DataFrame
168+
A DataFrame with columns 'channel', 'latency', and 'amplitude'
169+
containing the peak amplitude and latency for each channel. If
170+
``average=True``, contains a single row whose 'channel' value is
171+
``'Average'``.
172+
"""
173+
pd = _check_pandas_installed(strict=True)
174+
_check_option("mode", mode, ["abs", "neg", "pos"])
175+
peak_latencies, peak_amplitudes, _, _, _, channel = _compute_peak(
176+
evoked, start, stop, picks, mode, average, strict
177+
)
178+
179+
peak_df = pd.DataFrame(
180+
{
181+
"channel": channel,
182+
"latency": peak_latencies,
183+
"amplitude": peak_amplitudes,
184+
}
185+
)
186+
187+
return peak_df
188+
189+
190+
@fill_doc
191+
def compute_area(
192+
evoked,
193+
start=None,
194+
stop=None,
195+
picks="all",
196+
mode="abs",
197+
average=False,
198+
):
199+
"""
200+
Compute the area under the curve of an evoked response within a given time window.
201+
202+
Parameters
203+
----------
204+
evoked : instance of Evoked
205+
The evoked response object.
206+
%(erp_evoked_start_stop)s
207+
%(picks_all)s
208+
mode : str
209+
Specifies how the area should be computed. Can be one of:
210+
- 'abs': The absolute value of the data is used.
211+
- 'neg': Only negative values are considered.
212+
- 'pos': Only positive values are considered.
213+
- 'intg': The integral of the data is computed without rectification.
214+
Defaults to 'abs'.
215+
average : bool
216+
If True, the area is computed by averaging the data across channels
217+
before integration. Defaults to False.
218+
219+
Returns
220+
-------
221+
area_df : pd.DataFrame
222+
A DataFrame with columns 'channel' and 'area' containing the area
223+
under the curve for each channel. If ``average=True``, contains a
224+
single row whose 'channel' value is ``'Average'``.
225+
"""
226+
pd = _check_pandas_installed(strict=True)
227+
_check_option("mode", mode, ["abs", "neg", "pos", "intg"])
228+
data = evoked.get_data(picks=picks)
229+
picked_idx = _picks_to_idx(evoked.info, picks, "all", exclude=())
230+
channel = [evoked.ch_names[i] for i in picked_idx]
231+
times = evoked.times
232+
mask = _time_mask(times, start, stop, evoked.info["sfreq"])
233+
data_masked = data[:, mask]
234+
235+
if average:
236+
data_masked = np.mean(data_masked, axis=0, keepdims=True)
237+
channel = ["Average"]
238+
if mode == "abs":
239+
data_masked = np.abs(data_masked)
240+
elif mode == "neg":
241+
data_masked = np.clip(data_masked, None, 0)
242+
elif mode == "pos":
243+
data_masked = np.clip(data_masked, 0, None)
244+
245+
area = integrate.trapezoid(data_masked, times[mask], axis=1)
246+
area_df = pd.DataFrame({"channel": channel, "area": area})
247+
248+
return area_df
249+
250+
251+
@fill_doc
252+
def compute_frac_peak_latency(
253+
evoked,
254+
frac=0.5,
255+
start=None,
256+
stop=None,
257+
picks="all",
258+
mode="abs",
259+
average=False,
260+
strict=True,
261+
):
262+
"""Compute the latency at which a fraction of the peak amplitude is reached.
263+
264+
Parameters
265+
----------
266+
evoked : instance of Evoked
267+
The evoked response object.
268+
frac : float
269+
The fraction of the peak amplitude at which to compute the latency.
270+
Defaults to 0.5.
271+
%(erp_evoked_start_stop)s
272+
%(picks_all)s
273+
mode : str
274+
Specifies how the peak amplitude should be determined. Can be one of:
275+
- 'abs' : The peak amplitude is the maximum absolute value.
276+
- 'neg': The peak amplitude is the maximum negative value. If there are no
277+
negative values and `strict` is True, a ValueError is raised.
278+
- 'pos': The peak amplitude is the maximum positive value. If there are no
279+
positive values and `strict` is True, a ValueError is raised.
280+
Defaults to 'abs'.
281+
average : bool
282+
If True, the fractional peak latency is computed by averaging the data
283+
across channels before finding the latency. Defaults to False.
284+
%(erp_strict)s
285+
286+
Returns
287+
-------
288+
frac_peak_df : pd.DataFrame
289+
A DataFrame with columns 'channel', 'fractional_peak_onset',
290+
'fractional_peak_offset', and 'amplitude' containing the latency at
291+
which the peak amplitude reaches the fractional threshold. If
292+
``average=True``, contains a single row whose 'channel' value is
293+
``'Average'``.
294+
"""
295+
pd = _check_pandas_installed(strict=True)
296+
_check_option("mode", mode, ["abs", "neg", "pos"])
297+
298+
_, peak_amplitudes, data_masked, mask, times, channel = _compute_peak(
299+
evoked, start, stop, picks, mode, average, strict
300+
)
301+
302+
peak_idx = np.argmax(data_masked, axis=1)
303+
transformed_peak = data_masked[np.arange(data_masked.shape[0]), peak_idx]
304+
frac_amplitudes = frac * transformed_peak[:, np.newaxis]
305+
306+
# Find the first time point before the peak where the signal reaches the
307+
# fractional threshold
308+
frac_peak_onset = np.argmax(data_masked >= frac_amplitudes, axis=1)
309+
frac_peak_onset_latency = times[mask][frac_peak_onset]
310+
311+
# Find the first time point after the peak where the signal falls back to
312+
# the fractional threshold; NaN if it never does before the window ends
313+
frac_peak_offset_latency = np.full(data_masked.shape[0], np.nan)
314+
for i in range(data_masked.shape[0]):
315+
below_threshold = np.where(data_masked[i, peak_idx[i] :] <= frac_amplitudes[i])[
316+
0
317+
]
318+
if len(below_threshold) > 0:
319+
frac_peak_offset_latency[i] = times[mask][peak_idx[i] + below_threshold[0]]
320+
321+
frac_peak_df = pd.DataFrame(
322+
{
323+
"channel": channel,
324+
"fractional_peak_onset": frac_peak_onset_latency,
325+
"fractional_peak_offset": frac_peak_offset_latency,
326+
"amplitude": peak_amplitudes,
327+
}
328+
)
329+
330+
return frac_peak_df
331+
332+
333+
@fill_doc
334+
def compute_frac_area_latency(
335+
evoked,
336+
frac=0.5,
337+
start=None,
338+
stop=None,
339+
picks="all",
340+
mode="abs",
341+
average=False,
342+
):
343+
"""Compute the latency at which a fraction of the total area is reached.
344+
345+
Parameters
346+
----------
347+
evoked : instance of Evoked
348+
The evoked response object.
349+
frac : float
350+
The fraction of the area at which to compute the latency. Defaults to 0.5.
351+
%(erp_evoked_start_stop)s
352+
%(picks_all)s
353+
mode : str
354+
Specifies how the area should be computed. Can be one of:
355+
- 'abs': The absolute value of the data is used.
356+
- 'neg': Only negative values are considered.
357+
- 'pos': Only positive values are considered.
358+
- 'intg': The integral of the data is computed without rectification.
359+
Defaults to 'abs'.
360+
average : bool
361+
If True, the fractional area latency is computed by averaging the data
362+
across channels before finding the latency. Defaults to False.
363+
364+
Returns
365+
-------
366+
frac_area_df : pd.DataFrame
367+
A DataFrame with columns 'channel', 'fractional_area_latency',
368+
and 'area' containing the latency at which the area under the curve
369+
reaches the fractional threshold. If ``average=True``, contains a
370+
single row whose 'channel' value is ``'Average'``.
371+
372+
Notes
373+
-----
374+
With ``mode='intg'`` the running signed area is not guaranteed to
375+
increase monotonically, so for a channel whose positive and negative
376+
portions nearly cancel, the reported latency may not correspond to any
377+
visually meaningful point in the waveform. Only a channel whose total
378+
area is *exactly* zero is guarded against (yielding ``NaN``); a total
379+
area that is merely small relative to the channel's overall activity is
380+
not. The earliest sample satisfying the fractional threshold is
381+
returned.
382+
"""
383+
pd = _check_pandas_installed(strict=True)
384+
_check_option("mode", mode, ["abs", "neg", "pos", "intg"])
385+
data = evoked.get_data(picks=picks)
386+
picked_idx = _picks_to_idx(evoked.info, picks, "all", exclude=())
387+
channel = [evoked.ch_names[i] for i in picked_idx]
388+
times = evoked.times
389+
mask = _time_mask(times, start, stop, evoked.info["sfreq"])
390+
data_masked = data[:, mask]
391+
times = times[mask]
392+
if average:
393+
data_masked = np.mean(data_masked, axis=0, keepdims=True)
394+
channel = ["Average"]
395+
if mode == "abs":
396+
data_masked = np.abs(data_masked)
397+
elif mode == "neg":
398+
data_masked = np.clip(data_masked, None, 0)
399+
elif mode == "pos":
400+
data_masked = np.clip(data_masked, 0, None)
401+
402+
cum_area = integrate.cumulative_trapezoid(data_masked, times, axis=1, initial=0)
403+
area = cum_area[:, -1]
404+
405+
frac_area_latency = np.full(data_masked.shape[0], np.nan)
406+
for ch in range(data_masked.shape[0]):
407+
if area[ch] == 0:
408+
# Nothing accumulated; no latency can be defined
409+
continue
410+
# Normalize
411+
idx = np.where(cum_area[ch] / area[ch] >= frac)[0]
412+
if len(idx) > 0:
413+
frac_area_latency[ch] = times[idx[0]]
414+
415+
frac_area_df = pd.DataFrame(
416+
{
417+
"channel": channel,
418+
"fractional_area_latency": frac_area_latency,
419+
"area": area,
420+
}
421+
)
422+
return frac_area_df

0 commit comments

Comments
 (0)