Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ENH] - Add IRASA #212

Merged
merged 8 commits into from
Aug 1, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ Fluctuation Analyses
:toctree: generated/

compute_fluctuations
compute_irasa
fit_irasa

Simulations
-----------
Expand Down
1 change: 1 addition & 0 deletions neurodsp/aperiodic/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
""""Functions for calculating aperiodic properties of signals."""

from .dfa import compute_fluctuations
from .irasa import compute_irasa, fit_irasa
128 changes: 128 additions & 0 deletions neurodsp/aperiodic/irasa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""The IRASA method for separating periodic and aperiodic activity."""

import fractions

import numpy as np

from scipy import signal
from scipy.optimize import curve_fit

from neurodsp.spectral import compute_spectrum, trim_spectrum

###################################################################################################
###################################################################################################

def compute_irasa(sig, fs=None, f_range=(1, 30), hset=None, **spectrum_kwargs):
"""Separate the aperiodic and periodic components using the IRASA method.

Parameters
----------
sig : 1d array
Time series.
fs : float
The sampling frequency of sig.
f_range : tuple or None
Frequency range.
hset : 1d array
Resampling factors used in IRASA calculation.
If not provided, defaults to values from 1.1 to 1.9 with an increment of 0.05.
spectrum_kwargs : dict
Optional keywords arguments that are passed to `compute_spectrum`.

Returns
-------
freqs : 1d array
Frequency vector.
psd_aperiodic : 1d array
The aperiodic component of the power spectrum.
psd_periodic : 1d array
The periodic component of the power spectrum.

Notes
-----
Irregular-Resampling Auto-Spectral Analysis (IRASA) is described in Wen & Liu (2016).
Briefly, it aims to separate 1/f and periodic components by resampling time series, and
computing power spectra, effectively averaging away any activity that is frequency specific.

References
----------
Wen, H., & Liu, Z. (2016). Separating Fractal and Oscillatory Components in the Power Spectrum
of Neurophysiological Signal. Brain Topography, 29(1), 13–26. DOI: 10.1007/s10548-015-0448-0
"""

# Check & get the resampling factors, with rounding to avoid floating point precision errors
hset = np.arange(1.1, 1.95, 0.05) if not hset else hset
hset = np.round(hset, 4)

# The `nperseg` input needs to be set to lock in the size of the FFT's
if 'nperseg' not in spectrum_kwargs:
spectrum_kwargs['nperseg'] = int(4 * fs)

# Calculate the original spectrum across the whole signal
freqs, psd = compute_spectrum(sig, fs, **spectrum_kwargs)

# Do the IRASA resampling procedure
psds = np.zeros((len(hset), *psd.shape))
for ind, h_val in enumerate(hset):

# Get the up-sampling / down-sampling (h, 1/h) factors as integers
rat = fractions.Fraction(str(h_val))
up, dn = rat.numerator, rat.denominator

# Resample signal
sig_up = signal.resample_poly(sig, up, dn, axis=-1)
sig_dn = signal.resample_poly(sig, dn, up, axis=-1)

# Calculate the power spectrum using the same params as original
freqs_up, psd_up = compute_spectrum(sig_up, h_val * fs, **spectrum_kwargs)
freqs_dn, psd_dn = compute_spectrum(sig_dn, fs / h_val, **spectrum_kwargs)

# Geometric mean of h and 1/h
psds[ind, :] = np.sqrt(psd_up * psd_dn)

# Now we take the median resampled spectra, as an estimate of the aperiodic component
psd_aperiodic = np.median(psds, axis=0)

# Subtract aperiodic from original, to get the periodic component
psd_periodic = psd - psd_aperiodic

# Restrict spectrum to requested range
if f_range:
psds = np.array([psd_aperiodic, psd_periodic])
freqs, (psd_aperiodic, psd_periodic) = trim_spectrum(freqs, psds, f_range)

return freqs, psd_aperiodic, psd_periodic


def fit_irasa(freqs, psd_aperiodic):
"""Fit the IRASA derived aperiodic component of the spectrum.

Parameters
----------
freqs : 1d array
Frequency vector, in linear space.
psd_aperidic : 1d array
Power values, in linear space.

Returns
-------
intercept : float
Fit intercept value.
slope : float
Fit slope value.

Notes
-----
This fits a linear function of the form `y = ax + b` to the log-log aperiodic power spectrum.
"""

popt, _ = curve_fit(fit_func, np.log(freqs), np.log(psd_aperiodic))
intercept, slope = popt

return intercept, slope


def fit_func(freqs, intercept, slope):
"""A fit function to use for fitting IRASA separated 1/f power spectra components."""

return slope * freqs + intercept
44 changes: 44 additions & 0 deletions neurodsp/tests/aperiodic/test_irasa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Tests for IRASA functions."""

import numpy as np

from neurodsp.tests.settings import FS, N_SECONDS_LONG, EXP1

from neurodsp.sim import sim_combined
from neurodsp.spectral import compute_spectrum, trim_spectrum

from neurodsp.aperiodic.irasa import *

###################################################################################################
###################################################################################################

def test_compute_irasa(tsig_comb):

# Estimate periodic and aperiodic components with IRASA
f_range = [1, 30]
freqs, psd_ap, psd_pe = compute_irasa(tsig_comb, FS, f_range, noverlap=int(2*FS))

assert len(freqs) == len(psd_ap) == len(psd_pe)

# Compute r-squared for the full model, comparing to a standard power spectrum
_, powers = trim_spectrum(*compute_spectrum(tsig_comb, FS, nperseg=int(4*FS)), f_range)
r_sq = np.corrcoef(np.array([powers, psd_ap+psd_pe]))[0][1]
assert r_sq > .95

def test_fit_irasa(tsig_comb):

# Estimate periodic and aperiodic components with IRASA & fit aperiodic
freqs, psd_ap, _ = compute_irasa(tsig_comb, FS, noverlap=int(2*FS))
b0, b1 = fit_irasa(freqs, psd_ap)

assert round(b1) == EXP1
assert np.abs(b0 - np.log((psd_ap)[0])) < 1

def test_fit_func():

freqs = np.arange(30)
intercept = -2
slope = -2

fit = fit_func(freqs, intercept, slope)
assert (fit == slope * freqs + intercept).all()
12 changes: 10 additions & 2 deletions neurodsp/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@

import numpy as np

from neurodsp.sim import sim_oscillation, sim_powerlaw
from neurodsp.sim import sim_oscillation, sim_powerlaw, sim_combined
from neurodsp.utils.sim import set_random_seed
from neurodsp.tests.settings import (FS, FS_HIGH, N_SECONDS, N_SECONDS_LONG, FREQ_SINE,
from neurodsp.tests.settings import (N_SECONDS, N_SECONDS_LONG,
FS, FS_HIGH, FREQ_SINE, FREQ1, EXP1,
BASE_TEST_FILE_PATH, TEST_PLOTS_PATH)

###################################################################################################
Expand Down Expand Up @@ -38,6 +39,13 @@ def tsig_sine_long():

yield sim_oscillation(N_SECONDS_LONG, FS, freq=FREQ_SINE, variance=None, mean=None)

@pytest.fixture(scope='session')
def tsig_comb():

components = {'sim_powerlaw': {'exponent' : EXP1},
'sim_oscillation': {'freq' : FREQ1}}
yield sim_combined(n_seconds=N_SECONDS_LONG, fs=FS, components=components)

@pytest.fixture(scope='session')
def tsig_white():

Expand Down
3 changes: 2 additions & 1 deletion neurodsp/tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@
N_SECONDS = 1.0
N_SECONDS_LONG = 10.0

# Define frequency options for simulations
# Define parameter options for simulations
FREQ1 = 10
FREQ2 = 25
FREQ_SINE = 1
FREQS_LST = [8, 12, 1]
FREQS_ARR = np.array([5, 10, 15])
EXP1 = -1

# Define error tolerance levels for test comparisons
EPS = 10**(-10)
Expand Down