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 autocorrelation related functionality #331

Merged
merged 10 commits into from
Sep 2, 2024
2 changes: 2 additions & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ Auto-correlation Analyses
:toctree: generated/

compute_autocorr
compute_decay_time
fit_autocorr

Fluctuation Analyses
~~~~~~~~~~~~~~~~~~~~
Expand Down
149 changes: 149 additions & 0 deletions neurodsp/aperiodic/autocorr.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Autocorrelation analyses of time series."""

import numpy as np
from scipy.optimize import curve_fit

###################################################################################################
###################################################################################################
Expand Down Expand Up @@ -45,3 +46,151 @@ def compute_autocorr(sig, max_lag=1000, lag_step=1, demean=True):
timepoints = np.arange(0, max_lag+1, lag_step)

return timepoints, autocorrs


def compute_decay_time(timepoints, autocorrs, fs, level=0):
"""Compute autocorrelation decay time, from precomputed autocorrelation.

Parameters
----------
timepoints : 1d array
Timepoints for the computed autocorrelations.
autocorrs : 1d array
Autocorrelation values.
fs : int
Sampling rate of the signal.
level : float
Autocorrelation decay threshold.

Returns
-------
result : float
Autocorrelation decay time.
If decay time value found, returns nan.

Notes
-----
The autocorrelation decay time is computed as the time delay for the autocorrelation
to drop to (or below) the decay time threshold.
"""

val_checks = autocorrs <= level

if np.any(val_checks):
# Get the first value to cross the threshold, and convert to time value
result = timepoints[np.argmax(val_checks)] / fs
else:
result = np.nan

return result


def fit_autocorr(timepoints, autocorrs, fs=None, fit_function='single_exp'):
"""Fit autocorrelation function, returning timescale estimate.

Parameters
----------
timepoints : 1d array
Timepoints for the computed autocorrelations.
autocorrs : 1d array
Autocorrelation values.
fs : int, optional
Sampling rate of the signal.
If provided, timepoints are converted to time values.
fit_func : {'single_exp', 'double_exp'}
Which fitting function to use to fit the autocorrelation results.

Returns
-------
popts
Fit parameters.
"""

if fs:
timepoints = timepoints / fs

if fit_function == 'single_exp':
p_bounds =([0, 0, 0], [10, np.inf, np.inf])
elif fit_function == 'double_exp':
p_bounds =([0, 0, 0, 0, 0], [10, np.inf, np.inf, np.inf, np.inf])

popts, _ = curve_fit(AC_FIT_FUNCS[fit_function], timepoints, autocorrs, bounds=p_bounds)

return popts


## AC FITTING

def exp_decay_func(timepoints, tau, scale, offset):
"""Exponential decay fit function.

Parameters
----------
timepoints : 1d array
Time values.
tau : float
Timescale value.
scale : float
Scaling factor, which captures the start value of the function.
offset : float
Offset factor, which captures the end value of the function.

Returns
-------
ac_fit : 1d array
Results of fitting the function to the autocorrelation.
"""

return scale * (np.exp(-timepoints / tau) + offset)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tau is in samples. Could make a note in docstring that tau isn't in seconds (what I expected). Or the func could accept fs as a separate argument, then do the multiplication in the func. Here is an example:

import numpy as np
from neurodsp.aperiodic.autocorr import *

fs = 1000
tau = 0.015
lags = np.linspace(0, 100, 1000)
corrs = exp_decay_func(lags, tau * fs, 1, 0) # must scale tau by fs to get correct results

params = fit_autocorr(lags, corrs, fs)
print((params[0], tau)) # returns (0.01499988686748319, 0.015)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right - there's an inconsistency here.

fs isn't a required inpit to fit_autocorr, so if not passed, then it's consistent (this make sense, I think):

corrs = exp_decay_func(lags, tau, 1, 0)
params = fit_autocorr(lags, corrs)
print((params[0], tau)) # returns (0.014996887598931195, 0.015)

But if you do pass fs into fit_autocorr, then the timepoints get rescaled, but the tau value does not, leading to the issue.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another way to write what you point out is that if fs is passed into the fit function, then the timepoints should be in time not in samples, and so recomputing the values consistently should look like (this is equivalent to multiplying the tau):
corrs = exp_decay_func(lags / fs, tau, 1, 0)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, the core of this issue is basically it's not a good idea to optionally take fs as an input in fit_autocorr and if so divide it out, because then it makes the values of the fit parameters different from the values of the inputs. I'll drop this, and then one can pass in timepoints as either samples or seconds, and it will work either way (and be consistent).



def double_exp_decay_func(timepoints, tau1, tau2, scale1, scale2, offset):
"""Exponential decay fit funtion with two timescales.

Parameters
----------
timepoints : 1d array
Time values.
tau1, tau2 : float
Timescale values, for the 1st and 2nd timescale.
scale1, scale2 : float
Scaling factors, for the 1st and 2nd timescale.
offset : float
Offset factor.

Returns
-------
ac_fit : 1d array
Results of fitting the function to the autocorrelation.
"""

return scale1 * np.exp(-timepoints / tau1) + scale2 * np.exp(-timepoints / tau2) + offset


AC_FIT_FUNCS = {
'single_exp' : exp_decay_func,
'double_exp' : double_exp_decay_func,
}


def compute_ac_fit(timepoints, *popts, fit_function='single_exp'):
"""Regenerate values of the exponential decay fit.

Parameters
----------
timepoints : 1d array
Time values.
*popts
Fit parameters.
fit_func : {'single_exp', 'double_exp'}
Which fitting function to use to fit the autocorrelation results.

Returns
-------
fit_values : 1d array
Values of the fit to the autocorrelation values.
"""

fit_func = AC_FIT_FUNCS[fit_function]

return fit_func(timepoints, *popts)
20 changes: 20 additions & 0 deletions neurodsp/tests/aperiodic/test_autocorr.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for neurodsp.aperiodic.autocorr."""

from neurodsp.tests.settings import FS

from neurodsp.aperiodic.autocorr import *

###################################################################################################
Expand All @@ -10,3 +12,21 @@ def test_compute_autocorr(tsig):
max_lag = 500
timepoints, autocorrs = compute_autocorr(tsig, max_lag=max_lag)
assert len(timepoints) == len(autocorrs) == max_lag + 1

def test_compute_decay_time(tsig):

timepoints, autocorrs = compute_autocorr(tsig, max_lag=500)
decay_time = compute_decay_time(timepoints, autocorrs, FS)
assert isinstance(decay_time, float)

def test_fit_autocorr(tsig):

timepoints, autocorrs = compute_autocorr(tsig, max_lag=500)

popts1 = fit_autocorr(timepoints, autocorrs, fit_function='single_exp')
fit_vals1 = compute_ac_fit(timepoints, *popts1, fit_function='single_exp')
assert np.all(fit_vals1)

popts2 = fit_autocorr(timepoints, autocorrs, fit_function='double_exp')
fit_vals2 = compute_ac_fit(timepoints, *popts2, fit_function='double_exp')
assert np.all(fit_vals2)
Loading