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
3 changes: 3 additions & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ Auto-correlation Analyses
:toctree: generated/

compute_autocorr
compute_decay_time
fit_autocorr
compute_ac_fit

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

import numpy as np
from scipy.optimize import curve_fit

from neurodsp.utils.decorators import multidim

###################################################################################################
Expand All @@ -26,7 +28,7 @@
timepoints : 1d array
Time points, in samples, at which autocorrelations are computed.
autocorrs : array
Autocorrelation values, for across time lags.
Autocorrelation values, across time lags.

Examples
--------
Expand All @@ -47,3 +49,161 @@
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 not 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

Check warning on line 86 in neurodsp/aperiodic/autocorr.py

View check run for this annotation

Codecov / codecov/patch

neurodsp/aperiodic/autocorr.py#L86

Added line #L86 was not covered by tests

return result


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

Parameters
----------
timepoints : 1d array
Timepoints for the computed autocorrelations, in samples or seconds.
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.
bounds : tuple of list
Parameter bounds for fitting.
Organized as ([min_p1, min_p1, ...], [max_p1, max_p2, ...]).

Returns
-------
popts
Fit parameters. Parameters depend on the fitting function.
If `fit_func` is 'single_exp', fit parameters are: tau, scale, offset
If `fit_func` is 'douple_exp', fit parameters are: tau1, tau2, scale1, scale2, offset
See fit function for more details.

Notes
-----
The values / units of the returned parameters are dependent on the units of samples.
For example, if the timepoints input is in samples, the fit tau value is too.
If providing parameter bounds, these also need to match the unit of timepoints.
"""

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

popts, _ = curve_fit(AC_FIT_FUNCS[fit_function], timepoints, autocorrs, bounds=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
Result 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 function 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
Result 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, in samples or seconds.
*popts
Fit parameters.
fit_func : {'single_exp', 'double_exp'}
Which fit 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)
45 changes: 45 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,46 @@ 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):
# This is a smoke test - check it runs with no accuracy checking

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)

# Test with bounds passed in
bounds = ([0, 0, 0], [10, np.inf, np.inf])
popts1 = fit_autocorr(timepoints, autocorrs, 'single_exp', bounds)
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)

def test_fit_autocorr_acc():
# This test includes some basic accuracy checking

fs = 100
tau = 0.015
lags = np.linspace(0, 100, 1000)

# Test can fit and recompute the tau value
corrs1 = exp_decay_func(lags, tau, 1, 0)
params1 = fit_autocorr(lags, corrs1)
assert np.isclose(params1[0], tau, 0.001)

# Test can fit and recompute the tau value - timepoints as time values
lags = lags / fs
corrs2 = exp_decay_func(lags, tau, 1, 0)
params2 = fit_autocorr(lags, corrs2)
assert np.isclose(params2[0], tau, 0.001)