Skip to content

Commit bea46f4

Browse files
authored
Merge pull request #398 from pynapple-org/update_psd
Update the definition of `compute_power_spectral_density` to compute the true PSD
2 parents 925cb43 + b3cadf5 commit bea46f4

File tree

5 files changed

+164
-40
lines changed

5 files changed

+164
-40
lines changed

doc/external.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ As such, it can function as a foundational element for other analysis packages h
88

99
![image](https://raw.githubusercontent.com/flatironinstitute/nemos/main/docs/assets/glm_features_scheme.svg)
1010

11-
[NeMoS](https://nemos.readthedocs.io/en/stable/) is a statistical modeling framework optimized for systems neuroscience and powered by JAX. It streamlines the process of defining and selecting models, through a collection of easy-to-use methods for feature design.
11+
[NeMoS](https://nemos.readthedocs.io/en/latest/index.html) is a statistical modeling framework optimized for systems neuroscience and powered by JAX. It streamlines the process of defining and selecting models, through a collection of easy-to-use methods for feature design.
1212

1313
The core of nemos includes GPU-accelerated, well-tested implementations of standard statistical models, currently focusing on the Generalized Linear Model (GLM).
1414

15-
Check out this [page](https://nemos.readthedocs.io/en/stable/generated/neural_modeling/) for many examples of neural modelling using nemos and pynapple.
15+
Check out this [page](https://nemos.readthedocs.io/en/latest/tutorials/README.html) for many examples of neural modelling using nemos and pynapple.
1616

1717
```{eval-rst}
1818
.. Note::

pynapple/process/__init__.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,7 @@
2222
shift_timestamps,
2323
shuffle_ts_intervals,
2424
)
25-
from .spectrum import (
26-
compute_mean_power_spectral_density,
27-
compute_power_spectral_density,
28-
)
25+
from .spectrum import compute_fft, compute_mean_fft, compute_power_spectral_density
2926
from .tuning_curves import (
3027
compute_1d_mutual_info,
3128
compute_1d_tuning_curves,

pynapple/process/spectrum.py

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,9 @@ def wrapper(*args, **kwargs):
4646

4747

4848
@_validate_spectrum_inputs
49-
def compute_power_spectral_density(
50-
sig, fs=None, ep=None, full_range=False, norm=False, n=None
51-
):
49+
def compute_fft(sig, fs=None, ep=None, full_range=False, norm=False, n=None):
5250
"""
53-
Compute Power Spectral Density over a single epoch.
51+
Compute Fast Fourier Transform over a single epoch.
5452
Perform numpy fft on sig, returns output assuming a constant sampling rate for the signal.
5553
5654
Parameters
@@ -74,12 +72,14 @@ def compute_power_spectral_density(
7472
-------
7573
pandas.DataFrame
7674
Time frequency representation of the input signal, indexes are frequencies, values
77-
are powers.
75+
are the FFT.
7876
7977
Notes
8078
-----
8179
This function computes fft on only a single epoch of data. This epoch be given with the ep
8280
parameter otherwise will be sig.time_support, but it must only be a single epoch.
81+
82+
If `full_range` is False, the nyquist frequency is excluded in the output due to how computes the FFT frequencies in `numpy.fft.fftfreq`.
8383
"""
8484
if ep is None:
8585
ep = sig.time_support
@@ -105,7 +105,73 @@ def compute_power_spectral_density(
105105

106106

107107
@_validate_spectrum_inputs
108-
def compute_mean_power_spectral_density(
108+
def compute_power_spectral_density(sig, fs=None, ep=None, full_range=False, n=None):
109+
"""
110+
Compute Power Spectral Density over a single epoch.
111+
Perform numpy fft on sig and obtain the periodogram, returns output assuming a constant sampling rate for the signal.
112+
113+
Parameters
114+
----------
115+
sig : pynapple.Tsd or pynapple.TsdFrame
116+
Time series.
117+
fs : float, optional
118+
Sampling rate, in Hz. If None, will be calculated from the given signal
119+
ep : None or pynapple.IntervalSet, optional
120+
The epoch to calculate the fft on. Must be length 1.
121+
full_range : bool, optional
122+
If true, will return full fft frequency range, otherwise will return only positive values
123+
n: int, optional
124+
Length of the transformed axis of the output. If n is smaller than the length of the input,
125+
the input is cropped. If it is larger, the input is padded with zeros. If n is not given,
126+
the length of the input along the axis specified by axis is used.
127+
128+
Returns
129+
-------
130+
pandas.DataFrame
131+
Power spectral density of the input signal, indexes are frequencies, values
132+
are powers/frequency.
133+
134+
Notes
135+
-----
136+
This function computes fft on only a single epoch of data. This epoch be given with the ep
137+
parameter otherwise will be sig.time_support, but it must only be a single epoch.
138+
139+
The power spectral density is calculated as the square of the absolute value of the FFT, scaled by the sampling rate and length of the signal.
140+
See [this tutorial](https://www.mathworks.com/help/signal/ug/power-spectral-density-estimates-using-fft.html) for more information.
141+
142+
If `full_range` is False, the nyquist frequency is excluded in the output due to how computes the FFT frequencies in `numpy.fft.fftfreq`.
143+
"""
144+
145+
if ep is None:
146+
ep = sig.time_support
147+
if len(ep) != 1:
148+
raise ValueError("Given epoch (or signal time_support) must have length 1")
149+
if fs is None:
150+
fs = sig.rate
151+
if n is None:
152+
n = len(sig.restrict(ep))
153+
154+
fft = compute_fft(sig, fs=fs, ep=ep, n=n, full_range=full_range)
155+
156+
# transform to power spectral density, power/Hz
157+
psd = (1 / (fs * n)) * np.abs(fft) ** 2
158+
159+
if full_range is False:
160+
# frequencies not at 0 and not at the nyquist frequency occur twice
161+
# subtract from the nyquist frequency to adjust for floating point error in np.fft.fftfreq
162+
# nyquist freq may occur at negative end of frequencies if N is even
163+
doubled_freqs = (
164+
(fft.index != 0) # not 0
165+
& (fft.index < (fs / 2 - 1e-6)) # less than positive nyquist freq
166+
& (fft.index > (-fs / 2 + 1e-6)) # greater than negative nyquist freq
167+
)
168+
psd[doubled_freqs] *= 2
169+
170+
return psd
171+
172+
173+
@_validate_spectrum_inputs
174+
def compute_mean_fft(
109175
sig,
110176
interval_size,
111177
fs=None,

tests/test_power_spectral_density.py

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,40 @@ def get_sorted_fft(data, fs):
2222
return fft_freq[order], fft[order]
2323

2424

25-
def test_compute_power_spectral_density():
25+
def get_periodogram(data, fs, full_range=False):
26+
return_onesided = not full_range
27+
f, p = signal.periodogram(
28+
data, fs, return_onesided=return_onesided, detrend=False, axis=0
29+
)
30+
if p.ndim == 1:
31+
p = p[:, np.newaxis]
32+
if full_range:
33+
order = np.argsort(f)
34+
f = f[order]
35+
p = p[order]
36+
else:
37+
f = f[:-1]
38+
p = p[:-1]
39+
return f, p
40+
41+
42+
def test_compute_fft():
2643

2744
t = np.linspace(0, 1, 1000)
2845
sig = nap.Tsd(d=np.random.random(1000), t=t)
29-
r = nap.compute_power_spectral_density(sig)
46+
r = nap.compute_fft(sig)
3047
assert isinstance(r, pd.DataFrame)
3148
assert r.shape[0] == 500
3249

3350
a, b = get_sorted_fft(sig.values, sig.rate)
3451
np.testing.assert_array_almost_equal(r.index.values, a[a >= 0])
3552
np.testing.assert_array_almost_equal(r.values, b[a >= 0])
3653

37-
r = nap.compute_power_spectral_density(sig, norm=True)
54+
r = nap.compute_fft(sig, norm=True)
3855
np.testing.assert_array_almost_equal(r.values, b[a >= 0] / len(sig))
3956

4057
sig = nap.TsdFrame(d=np.random.random((1000, 4)), t=t)
41-
r = nap.compute_power_spectral_density(sig)
58+
r = nap.compute_fft(sig)
4259
assert isinstance(r, pd.DataFrame)
4360
assert r.shape == (500, 4)
4461

@@ -47,14 +64,57 @@ def test_compute_power_spectral_density():
4764
np.testing.assert_array_almost_equal(r.values, b[a >= 0])
4865

4966
sig = nap.TsdFrame(d=np.random.random((1000, 4)), t=t)
50-
r = nap.compute_power_spectral_density(sig, full_range=True)
67+
r = nap.compute_fft(sig, full_range=True)
5168
assert isinstance(r, pd.DataFrame)
5269
assert r.shape == (1000, 4)
5370

5471
a, b = get_sorted_fft(sig.values, sig.rate)
5572
np.testing.assert_array_almost_equal(r.index.values, a)
5673
np.testing.assert_array_almost_equal(r.values, b)
5774

75+
t = np.linspace(0, 1, 1000)
76+
sig = nap.Tsd(d=np.random.random(1000), t=t)
77+
r = nap.compute_fft(sig, ep=sig.time_support)
78+
assert isinstance(r, pd.DataFrame)
79+
assert r.shape[0] == 500
80+
81+
t = np.linspace(0, 1, 1000)
82+
sig = nap.Tsd(d=np.random.random(1000), t=t)
83+
r = nap.compute_fft(sig, fs=1000)
84+
assert isinstance(r, pd.DataFrame)
85+
assert r.shape[0] == 500
86+
87+
88+
def test_compute_power_spectral_density():
89+
90+
t = np.linspace(0, 1, 1000)
91+
sig = nap.Tsd(d=np.random.random(1000), t=t)
92+
r = nap.compute_power_spectral_density(sig)
93+
assert isinstance(r, pd.DataFrame)
94+
assert r.shape[0] == 500
95+
96+
a, b = get_periodogram(sig.values, sig.rate)
97+
np.testing.assert_array_almost_equal(r.index.values, a[a >= 0])
98+
np.testing.assert_array_almost_equal(r.values, b[a >= 0])
99+
100+
sig = nap.TsdFrame(d=np.random.random((1000, 4)), t=t)
101+
r = nap.compute_power_spectral_density(sig)
102+
assert isinstance(r, pd.DataFrame)
103+
assert r.shape == (500, 4)
104+
105+
a, b = get_periodogram(sig.values, sig.rate)
106+
np.testing.assert_array_almost_equal(r.index.values, a[a >= 0])
107+
np.testing.assert_array_almost_equal(r.values, b[a >= 0])
108+
109+
sig = nap.TsdFrame(d=np.random.random((1000, 4)), t=t)
110+
r = nap.compute_power_spectral_density(sig, full_range=True)
111+
assert isinstance(r, pd.DataFrame)
112+
assert r.shape == (1000, 4)
113+
114+
a, b = get_periodogram(sig.values, sig.rate, full_range=True)
115+
np.testing.assert_array_almost_equal(r.index.values, a)
116+
np.testing.assert_array_almost_equal(r.values, b)
117+
58118
t = np.linspace(0, 1, 1000)
59119
sig = nap.Tsd(d=np.random.random(1000), t=t)
60120
r = nap.compute_power_spectral_density(sig, ep=sig.time_support)
@@ -135,9 +195,12 @@ def test_compute_power_spectral_density():
135195
),
136196
],
137197
)
138-
def test_compute_power_spectral_density_raise_errors(sig, kwargs, expectation):
198+
def test_compute_fft_raise_errors(sig, kwargs, expectation):
139199
with expectation:
140-
nap.compute_power_spectral_density(sig, **kwargs)
200+
nap.compute_fft(sig, **kwargs)
201+
if "norm" not in kwargs:
202+
with expectation:
203+
nap.compute_power_spectral_density(sig, **kwargs)
141204

142205

143206
############################################################
@@ -172,23 +235,23 @@ def get_signal_and_output(f=2, fs=1000, duration=100, interval_size=10, overlap=
172235
return (sig, out, freq)
173236

174237

175-
def test_compute_mean_power_spectral_density():
238+
def test_compute_mean_fft():
176239
sig, out, freq = get_signal_and_output()
177-
psd = nap.compute_mean_power_spectral_density(sig, 10)
240+
psd = nap.compute_mean_fft(sig, 10)
178241
assert isinstance(psd, pd.DataFrame)
179242
assert psd.shape[0] > 0 # Check that the psd DataFrame is not empty
180243
np.testing.assert_array_almost_equal(psd.values.flatten(), out[freq >= 0])
181244
np.testing.assert_array_almost_equal(psd.index.values, freq[freq >= 0])
182245

183246
# Full range
184-
psd = nap.compute_mean_power_spectral_density(sig, 10, full_range=True)
247+
psd = nap.compute_mean_fft(sig, 10, full_range=True)
185248
assert isinstance(psd, pd.DataFrame)
186249
assert psd.shape[0] > 0 # Check that the psd DataFrame is not empty
187250
np.testing.assert_array_almost_equal(psd.values.flatten(), out)
188251
np.testing.assert_array_almost_equal(psd.index.values, freq)
189252

190253
# Norm
191-
psd = nap.compute_mean_power_spectral_density(sig, 10, norm=True)
254+
psd = nap.compute_mean_fft(sig, 10, norm=True)
192255
assert isinstance(psd, pd.DataFrame)
193256
assert psd.shape[0] > 0 # Check that the psd DataFrame is not empty
194257
np.testing.assert_array_almost_equal(
@@ -200,7 +263,7 @@ def test_compute_mean_power_spectral_density():
200263
sig2 = nap.TsdFrame(
201264
t=sig.t, d=np.repeat(sig.values[:, None], 2, 1), time_support=sig.time_support
202265
)
203-
psd = nap.compute_mean_power_spectral_density(sig2, 10, full_range=True)
266+
psd = nap.compute_mean_fft(sig2, 10, full_range=True)
204267
assert isinstance(psd, pd.DataFrame)
205268
assert psd.shape[0] > 0 # Check that the psd DataFrame is not empty
206269
np.testing.assert_array_almost_equal(psd.values, np.repeat(out[:, None], 2, 1))
@@ -210,7 +273,7 @@ def test_compute_mean_power_spectral_density():
210273
sig2 = nap.TsdFrame(
211274
t=sig.t, d=np.repeat(sig.values[:, None], 2, 1), time_support=sig.time_support
212275
)
213-
psd = nap.compute_mean_power_spectral_density(sig2, 10, full_range=True, fs=1000)
276+
psd = nap.compute_mean_fft(sig2, 10, full_range=True, fs=1000)
214277
assert isinstance(psd, pd.DataFrame)
215278
assert psd.shape[0] > 0 # Check that the psd DataFrame is not empty
216279
np.testing.assert_array_almost_equal(psd.values, np.repeat(out[:, None], 2, 1))
@@ -329,8 +392,6 @@ def test_compute_mean_power_spectral_density():
329392
),
330393
],
331394
)
332-
def test_compute_mean_power_spectral_density_raise_errors(
333-
sig, interval_size, kwargs, expectation
334-
):
395+
def test_compute_mean_fft_raise_errors(sig, interval_size, kwargs, expectation):
335396
with expectation:
336-
nap.compute_mean_power_spectral_density(sig, interval_size, **kwargs)
397+
nap.compute_mean_fft(sig, interval_size, **kwargs)

0 commit comments

Comments
 (0)