nufftcf 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nufftcf/__init__.py +89 -0
- nufftcf/fft_acf.py +221 -0
- nufftcf/kernels.py +265 -0
- nufftcf/nufft_acf.py +118 -0
- nufftcf/nufft_ccf.py +199 -0
- nufftcf/realspace_acf.py +53 -0
- nufftcf/realspace_ccf.py +116 -0
- nufftcf/utils.py +17 -0
- nufftcf-0.1.0.dist-info/METADATA +400 -0
- nufftcf-0.1.0.dist-info/RECORD +13 -0
- nufftcf-0.1.0.dist-info/WHEEL +5 -0
- nufftcf-0.1.0.dist-info/licenses/LICENSE +21 -0
- nufftcf-0.1.0.dist-info/top_level.txt +1 -0
nufftcf/__init__.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nufftcf: fast ACF estimation for irregularly- AND regularly-sampled time series.
|
|
3
|
+
|
|
4
|
+
Three estimation families are provided, sharing the same (lags, t, x, bin_width)
|
|
5
|
+
calling convention:
|
|
6
|
+
|
|
7
|
+
- `compute_acf_*_nufft` : NUFFT + Wiener-Khinchin, O(n log n)-ish, fastest
|
|
8
|
+
for long IRREGULAR series, ~1-3% residual
|
|
9
|
+
amplitude bias on strongly periodic signals
|
|
10
|
+
(see module docs).
|
|
11
|
+
- `compute_acf_*_realspace` : direct real-space weighted sum, O(n) per lag,
|
|
12
|
+
artifact-free reference / alternative, works
|
|
13
|
+
for irregular AND regular sampling.
|
|
14
|
+
- `compute_acf_*_fft` : classic FFT correlation, O(n log n), for
|
|
15
|
+
REGULARLY-sampled data only -- faster than
|
|
16
|
+
`_nufft` (no NUFFT overhead) and faster than
|
|
17
|
+
`_realspace` (no numba two-pointer scan) when
|
|
18
|
+
sampling happens to be regular. Also adds a
|
|
19
|
+
`regular` (no-kernel) variant matching
|
|
20
|
+
Pastas' `bin_method="regular"`.
|
|
21
|
+
|
|
22
|
+
All families come in `gaussian` and `rectangle` kernel variants; `_fft` also
|
|
23
|
+
has `regular` (no smoothing kernel).
|
|
24
|
+
|
|
25
|
+
Example
|
|
26
|
+
-------
|
|
27
|
+
>>> import numpy as np, pandas as pd
|
|
28
|
+
>>> from nufftcf import compute_acf_gaussian_nufft, t_numeric_of
|
|
29
|
+
>>> idx = pd.date_range("2020-01-01", periods=2000, freq="D")
|
|
30
|
+
>>> x = pd.Series(np.random.randn(2000), index=idx)
|
|
31
|
+
>>> lags = np.arange(0.0, 366.0)
|
|
32
|
+
>>> t = t_numeric_of(x)
|
|
33
|
+
>>> c, b = compute_acf_gaussian_nufft(lags, t, x.to_numpy(), bin_width=0.5)
|
|
34
|
+
|
|
35
|
+
>>> # regularly-sampled data -> use the faster classic-FFT path instead:
|
|
36
|
+
>>> from nufftcf import compute_acf_gaussian_fft, compute_acf_regular_fft
|
|
37
|
+
>>> c, b = compute_acf_gaussian_fft(lags, t, x.to_numpy(), bin_width=0.5)
|
|
38
|
+
>>> c, b = compute_acf_regular_fft(lags, t, x.to_numpy()) # no kernel
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from .kernels import (
|
|
42
|
+
compute_b_gaussian,
|
|
43
|
+
compute_b_rectangle,
|
|
44
|
+
compute_c_gaussian,
|
|
45
|
+
compute_c_rectangle,
|
|
46
|
+
)
|
|
47
|
+
from .nufft_acf import compute_acf_gaussian_nufft, compute_acf_rectangle_nufft
|
|
48
|
+
from .realspace_acf import (
|
|
49
|
+
compute_acf_gaussian_realspace,
|
|
50
|
+
compute_acf_rectangle_realspace,
|
|
51
|
+
)
|
|
52
|
+
from .nufft_ccf import compute_ccf_gaussian_nufft, compute_ccf_rectangle_nufft
|
|
53
|
+
from .realspace_ccf import (
|
|
54
|
+
compute_ccf_gaussian_realspace,
|
|
55
|
+
compute_ccf_rectangle_realspace,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
from .fft_acf import (
|
|
59
|
+
compute_acf_regular_fft,
|
|
60
|
+
compute_acf_rectangle_fft,
|
|
61
|
+
compute_acf_gaussian_fft,
|
|
62
|
+
)
|
|
63
|
+
from .utils import t_numeric_of, standardize
|
|
64
|
+
|
|
65
|
+
__version__ = "0.1.0"
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
"compute_acf_gaussian_nufft",
|
|
69
|
+
"compute_acf_rectangle_nufft",
|
|
70
|
+
"compute_acf_gaussian_realspace",
|
|
71
|
+
"compute_acf_rectangle_realspace",
|
|
72
|
+
"compute_acf_regular_fft",
|
|
73
|
+
"compute_acf_rectangle_fft",
|
|
74
|
+
"compute_acf_gaussian_fft",
|
|
75
|
+
"compute_ccf_gaussian_nufft",
|
|
76
|
+
"compute_ccf_rectangle_nufft",
|
|
77
|
+
"compute_ccf_gaussian_realspace",
|
|
78
|
+
"compute_ccf_rectangle_realspace",
|
|
79
|
+
"compute_b_gaussian",
|
|
80
|
+
"compute_b_rectangle",
|
|
81
|
+
"compute_c_gaussian",
|
|
82
|
+
"compute_c_rectangle",
|
|
83
|
+
"compute_b_gaussian_cross",
|
|
84
|
+
"compute_b_rectangle_cross",
|
|
85
|
+
"compute_c_gaussian_cross",
|
|
86
|
+
"compute_c_rectangle_cross",
|
|
87
|
+
"t_numeric_of",
|
|
88
|
+
"standardize",
|
|
89
|
+
]
|
nufftcf/fft_acf.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ACF estimation for REGULARLY-sampled series, via classic FFT correlation
|
|
3
|
+
(`scipy.signal.correlate`) instead of NUFFT.
|
|
4
|
+
|
|
5
|
+
This is a fast-path companion to `nufft_acf.py` / `realspace_acf.py`: when
|
|
6
|
+
the data happens to be on a uniform grid, there is no need to pay for NUFFT
|
|
7
|
+
(or for the O(n)-per-lag numba two-pointer scan) -- a plain FFT correlation
|
|
8
|
+
plus a cheap smoothing pass gives the *exact same* gaussian/rectangle
|
|
9
|
+
estimator, faster and with no numba/finufft dependency in the hot path.
|
|
10
|
+
|
|
11
|
+
Three estimators are provided, all sharing the same (lags, t, x) calling
|
|
12
|
+
convention as the rest of the package:
|
|
13
|
+
|
|
14
|
+
- `compute_acf_regular_fft` : no smoothing kernel at all -- the windowed
|
|
15
|
+
Pearson correlation Pastas uses for its
|
|
16
|
+
"regular" bin_method (regular data only).
|
|
17
|
+
Scales ~O(n) (it is NOT a quadratic method,
|
|
18
|
+
unlike Pastas' gaussian/rectangle bin
|
|
19
|
+
methods -- see benchmark/).
|
|
20
|
+
- `compute_acf_rectangle_fft` : same rectangular-kernel definition as
|
|
21
|
+
`compute_acf_rectangle_nufft`/`_realspace`.
|
|
22
|
+
- `compute_acf_gaussian_fft` : same gaussian-kernel definition as
|
|
23
|
+
`compute_acf_gaussian_nufft`/`_realspace`.
|
|
24
|
+
|
|
25
|
+
All three require `t` to be regularly spaced (checked, raises otherwise);
|
|
26
|
+
use the `nufft` or `realspace` estimators for irregular sampling.
|
|
27
|
+
|
|
28
|
+
Implementation note on the `b` (pair-count) denominator
|
|
29
|
+
---------------------------------------------------------
|
|
30
|
+
For `gaussian`, `b` is obtained by applying the *exact same* smoothing
|
|
31
|
+
filter (`gaussian_filter1d`, same sigma, same boundary mode) to the
|
|
32
|
+
triangular "raw pair count" ramp `n - |lag|` as is applied to the raw
|
|
33
|
+
correlation numerator. This isn't just convenient: computing numerator and
|
|
34
|
+
denominator through the same discrete kernel makes any discretization
|
|
35
|
+
artifact of that kernel cancel exactly in the ratio, which is what lets
|
|
36
|
+
this estimator match `compute_acf_gaussian_realspace` to ~1e-6 without any
|
|
37
|
+
extra renormalization step. `mode="mirror"` is required (not scipy's
|
|
38
|
+
default `"reflect"`) for this cancellation to hold all the way to lag=0,
|
|
39
|
+
since the true pair-count ramp is symmetric *through* lag=0 (whole-sample
|
|
40
|
+
symmetry), not around the edge *between* lag=-1 and lag=0 (half-sample
|
|
41
|
+
symmetry, which is what `"reflect"` assumes).
|
|
42
|
+
|
|
43
|
+
For `rectangle`, the unsmoothed ramp `b = n - lag` is already exact as-is
|
|
44
|
+
-- no filtering needed -- because `uniform_filter1d` already normalizes by
|
|
45
|
+
its own window size internally.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import numpy as np
|
|
49
|
+
from scipy.signal import correlate
|
|
50
|
+
from scipy.ndimage import gaussian_filter1d, uniform_filter1d
|
|
51
|
+
|
|
52
|
+
from .utils import standardize
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _check_regular_grid(t):
|
|
56
|
+
t = np.asarray(t, dtype=float)
|
|
57
|
+
if len(t) < 2:
|
|
58
|
+
return 1.0
|
|
59
|
+
dt = np.diff(t)
|
|
60
|
+
dt0 = dt[0]
|
|
61
|
+
if not np.allclose(dt, dt0, rtol=1e-6):
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"compute_acf_*_fft requires a regularly-sampled `t` "
|
|
64
|
+
"(use the `nufft` or `realspace` estimators for irregular data)."
|
|
65
|
+
)
|
|
66
|
+
return dt0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def compute_acf_regular_fft(lags, t, x):
|
|
70
|
+
"""ACF estimate with no smoothing kernel, for regularly-sampled data --
|
|
71
|
+
matches Pastas' `bin_method="regular"` (windowed Pearson correlation)
|
|
72
|
+
to numerical precision, but vectorized instead of one `np.corrcoef`
|
|
73
|
+
call per lag.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
lags : array_like
|
|
78
|
+
Lags at which to evaluate the ACF (same units as `t`).
|
|
79
|
+
t : array_like
|
|
80
|
+
Regularly-spaced sample times, sorted ascending.
|
|
81
|
+
x : array_like
|
|
82
|
+
Sample values, same length as `t`.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
c, b : ndarray
|
|
87
|
+
ACF estimate and pair count, both shape (len(lags),).
|
|
88
|
+
"""
|
|
89
|
+
t = np.asarray(t, dtype=float)
|
|
90
|
+
x = np.asarray(x, dtype=float)
|
|
91
|
+
lags = np.asarray(lags, dtype=float)
|
|
92
|
+
dt = _check_regular_grid(t)
|
|
93
|
+
n = len(x)
|
|
94
|
+
|
|
95
|
+
lag_idx = np.round(lags / dt).astype(int)
|
|
96
|
+
b = np.where(n - lag_idx <= 0, 1e-16, (n - lag_idx).astype(float))
|
|
97
|
+
|
|
98
|
+
# Cumulative first/second moments -> exact windowed mean & std in O(1)
|
|
99
|
+
# per lag (this is the *exact* expansion Var = E[x^2] - E[x]^2, not an
|
|
100
|
+
# incremental re-centered sum -- the latter looks similar but silently
|
|
101
|
+
# drifts by ~0.1-0.3% away from a true windowed std).
|
|
102
|
+
s1 = np.concatenate(([0.0], np.cumsum(x)))
|
|
103
|
+
s2 = np.concatenate(([0.0], np.cumsum(x * x)))
|
|
104
|
+
|
|
105
|
+
def _windowed_mean_std(lo, hi):
|
|
106
|
+
cnt = np.maximum((hi - lo).astype(float), 1.0)
|
|
107
|
+
mean = (s1[hi] - s1[lo]) / cnt
|
|
108
|
+
var = np.maximum((s2[hi] - s2[lo]) / cnt - mean**2, 0.0)
|
|
109
|
+
return mean, np.sqrt(var)
|
|
110
|
+
|
|
111
|
+
n_lags = len(lag_idx)
|
|
112
|
+
hi_y = np.clip(n - lag_idx, 0, n)
|
|
113
|
+
lo_x = np.clip(lag_idx, 0, n)
|
|
114
|
+
y_mean, y_std = _windowed_mean_std(np.zeros(n_lags, dtype=int), hi_y)
|
|
115
|
+
x_mean, x_std = _windowed_mean_std(lo_x, np.full(n_lags, n))
|
|
116
|
+
|
|
117
|
+
c_raw = correlate(x, x, mode="full")[n - 1 : 2 * n - 1]
|
|
118
|
+
valid = (lag_idx >= 0) & (lag_idx < n)
|
|
119
|
+
c_raw_at = np.where(valid, c_raw[np.clip(lag_idx, 0, n - 1)], np.nan)
|
|
120
|
+
|
|
121
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
122
|
+
cov = (
|
|
123
|
+
c_raw_at / np.where(n - lag_idx > 0, n - lag_idx, np.nan) - y_mean * x_mean
|
|
124
|
+
)
|
|
125
|
+
denom = y_std * x_std
|
|
126
|
+
c = np.where(denom > 1e-12, cov / denom, np.nan)
|
|
127
|
+
c = np.where(valid, c, np.nan)
|
|
128
|
+
return c, b
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def compute_acf_rectangle_fft(lags, t, x, bin_width=0.5):
|
|
132
|
+
"""ACF estimate via FFT correlation + rectangular smoothing, for
|
|
133
|
+
regularly-sampled data. Same kernel definition (and -- on the same
|
|
134
|
+
data -- numerically equivalent result) as `compute_acf_rectangle_nufft`
|
|
135
|
+
/ `compute_acf_rectangle_realspace`, just computed via plain FFT
|
|
136
|
+
correlation instead of NUFFT / a numba two-pointer scan.
|
|
137
|
+
|
|
138
|
+
Parameters
|
|
139
|
+
----------
|
|
140
|
+
lags : array_like
|
|
141
|
+
t : array_like
|
|
142
|
+
Regularly-spaced sample times, sorted ascending.
|
|
143
|
+
x : array_like
|
|
144
|
+
bin_width : float
|
|
145
|
+
Rectangular half-width (same units as `t`).
|
|
146
|
+
|
|
147
|
+
Returns
|
|
148
|
+
-------
|
|
149
|
+
c, b : ndarray
|
|
150
|
+
"""
|
|
151
|
+
t = np.asarray(t, dtype=float)
|
|
152
|
+
x = standardize(np.asarray(x, dtype=float))
|
|
153
|
+
lags = np.asarray(lags, dtype=float)
|
|
154
|
+
dt = _check_regular_grid(t)
|
|
155
|
+
n = len(x)
|
|
156
|
+
|
|
157
|
+
lag_idx = np.round(lags / dt).astype(int)
|
|
158
|
+
# Forcing an ODD kernel size (2*k+1) is deliberate.
|
|
159
|
+
# scipy's uniform_filter1d centers an EVEN-sized window a half
|
|
160
|
+
# -sample off from the true symmetric [-bin_width, +bin_width] interval
|
|
161
|
+
# kernels.py's two-pointer scan evaluates exactly, which otherwise
|
|
162
|
+
# introduces a small but systematic (~0.3-0.5%, not just at lag=0)
|
|
163
|
+
# bias at every lag -- confirmed empirically, see tests/test_fft_acf.py.
|
|
164
|
+
kernel_size = 2 * int(round(bin_width / dt)) + 1
|
|
165
|
+
|
|
166
|
+
c_raw = correlate(x, x, mode="full")[n - 1 : 2 * n - 1]
|
|
167
|
+
c_smoothed = uniform_filter1d(c_raw, size=kernel_size, mode="nearest")
|
|
168
|
+
|
|
169
|
+
valid = (lag_idx >= 0) & (lag_idx < n)
|
|
170
|
+
c_at = np.where(valid, c_smoothed[np.clip(lag_idx, 0, n - 1)], np.nan)
|
|
171
|
+
b = np.where(valid, np.maximum(n - lag_idx, 1e-16), 1e-16)
|
|
172
|
+
|
|
173
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
174
|
+
c = c_at / b
|
|
175
|
+
return c, b
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def compute_acf_gaussian_fft(lags, t, x, bin_width=0.5):
|
|
179
|
+
"""ACF estimate via FFT correlation + gaussian smoothing, for
|
|
180
|
+
regularly-sampled data. Same kernel definition as
|
|
181
|
+
`compute_acf_gaussian_nufft` / `compute_acf_gaussian_realspace`.
|
|
182
|
+
|
|
183
|
+
Parameters
|
|
184
|
+
----------
|
|
185
|
+
lags : array_like
|
|
186
|
+
t : array_like
|
|
187
|
+
Regularly-spaced sample times, sorted ascending.
|
|
188
|
+
x : array_like
|
|
189
|
+
bin_width : float
|
|
190
|
+
Gaussian standard deviation (same units as `t`).
|
|
191
|
+
|
|
192
|
+
Returns
|
|
193
|
+
-------
|
|
194
|
+
c, b : ndarray
|
|
195
|
+
"""
|
|
196
|
+
t = np.asarray(t, dtype=float)
|
|
197
|
+
x = standardize(np.asarray(x, dtype=float))
|
|
198
|
+
lags = np.asarray(lags, dtype=float)
|
|
199
|
+
dt = _check_regular_grid(t)
|
|
200
|
+
n = len(x)
|
|
201
|
+
sigma = bin_width / dt
|
|
202
|
+
|
|
203
|
+
lag_idx = np.round(lags / dt).astype(int)
|
|
204
|
+
max_idx = int(np.max(np.abs(lag_idx))) if len(lag_idx) else 0
|
|
205
|
+
n_eval = max(n, max_idx + 1)
|
|
206
|
+
|
|
207
|
+
c_raw = correlate(x, x, mode="full")[n - 1 : 2 * n - 1]
|
|
208
|
+
c_raw = np.pad(c_raw, (0, max(0, n_eval - n)), constant_values=0.0)
|
|
209
|
+
c_smoothed = gaussian_filter1d(c_raw, sigma=sigma, mode="mirror")
|
|
210
|
+
|
|
211
|
+
b_raw = np.maximum(n - np.arange(n_eval, dtype=float), 0.0)
|
|
212
|
+
b_smoothed = gaussian_filter1d(b_raw, sigma=sigma, mode="mirror")
|
|
213
|
+
|
|
214
|
+
valid = (lag_idx >= 0) & (lag_idx < n)
|
|
215
|
+
idx = np.clip(lag_idx, 0, n_eval - 1)
|
|
216
|
+
c_at = np.where(valid, c_smoothed[idx], np.nan)
|
|
217
|
+
b_at = np.where(valid, b_smoothed[idx], 1e-16)
|
|
218
|
+
|
|
219
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
220
|
+
c = c_at / b_at
|
|
221
|
+
return c, b_at
|
nufftcf/kernels.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Numba-jitted, two-pointer-optimized kernels.
|
|
3
|
+
|
|
4
|
+
All functions here require `t` (time, in days) sorted in ascending order.
|
|
5
|
+
Complexity is O(n) per lag (instead of the naive O(n^2)), thanks to the
|
|
6
|
+
two-pointer technique: as the lag-shifted window center increases
|
|
7
|
+
monotonically with j (since t is sorted), the window bounds [lo, hi) only
|
|
8
|
+
ever advance, never retreat.
|
|
9
|
+
|
|
10
|
+
`b_*` functions compute the kernel-weighted (or counted) number of
|
|
11
|
+
contributing pairs per lag -- the normalization denominator.
|
|
12
|
+
|
|
13
|
+
`c_*` functions compute the kernel-weighted sum of x_i * x_j per lag -- the
|
|
14
|
+
correlation numerator, for the "real-space" (exact, non-NUFFT) ACF estimator.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
import numpy as np
|
|
19
|
+
from numba import njit, prange
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@njit(parallel=True, nogil=True, cache=True, fastmath=True)
|
|
23
|
+
def compute_b_gaussian(t, lags, sigma):
|
|
24
|
+
"""Gaussian-kernel weighted pair count per lag."""
|
|
25
|
+
n = len(t)
|
|
26
|
+
nlags = len(lags)
|
|
27
|
+
b = np.zeros(nlags)
|
|
28
|
+
den1 = -2 * sigma**2
|
|
29
|
+
den2 = math.sqrt(2 * math.pi) * sigma
|
|
30
|
+
six_den2 = 6 * den2
|
|
31
|
+
for k in prange(nlags):
|
|
32
|
+
lag = lags[k]
|
|
33
|
+
b_sum = 0.0
|
|
34
|
+
lo = 0
|
|
35
|
+
hi = 0
|
|
36
|
+
for j in range(n):
|
|
37
|
+
center = t[j] + lag
|
|
38
|
+
while lo < n and t[lo] < center - six_den2:
|
|
39
|
+
lo += 1
|
|
40
|
+
while hi < n and t[hi] < center + six_den2:
|
|
41
|
+
hi += 1
|
|
42
|
+
for i in range(lo, hi):
|
|
43
|
+
dtlag = t[i] - center
|
|
44
|
+
b_sum += math.exp(dtlag**2 / den1) / den2
|
|
45
|
+
b[k] = b_sum
|
|
46
|
+
if b[k] <= 0:
|
|
47
|
+
b[k] = 1e-16
|
|
48
|
+
return b
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@njit(parallel=True, nogil=True, cache=True)
|
|
52
|
+
def compute_b_rectangle(t, lags, bin_width):
|
|
53
|
+
"""Rectangular-kernel pair count per lag (direct count, no inner loop)."""
|
|
54
|
+
n = len(t)
|
|
55
|
+
nlags = len(lags)
|
|
56
|
+
b = np.zeros(nlags)
|
|
57
|
+
for k in prange(nlags):
|
|
58
|
+
lag = lags[k]
|
|
59
|
+
b_sum = 0.0
|
|
60
|
+
lo = 0
|
|
61
|
+
hi = 0
|
|
62
|
+
for j in range(n):
|
|
63
|
+
center = t[j] + lag
|
|
64
|
+
while lo < n and t[lo] < center - bin_width:
|
|
65
|
+
lo += 1
|
|
66
|
+
if hi < lo:
|
|
67
|
+
hi = lo
|
|
68
|
+
while hi < n and t[hi] <= center + bin_width:
|
|
69
|
+
hi += 1
|
|
70
|
+
b_sum += hi - lo
|
|
71
|
+
b[k] = b_sum
|
|
72
|
+
if b[k] <= 0:
|
|
73
|
+
b[k] = 1e-16
|
|
74
|
+
return b
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@njit(parallel=True, nogil=True, cache=True, fastmath=True)
|
|
78
|
+
def compute_c_gaussian(t, lags, x, sigma):
|
|
79
|
+
"""Gaussian-kernel weighted sum of x_i*x_j per lag (correlation numerator)."""
|
|
80
|
+
n = len(t)
|
|
81
|
+
nlags = len(lags)
|
|
82
|
+
c = np.zeros(nlags)
|
|
83
|
+
den1 = -2 * sigma**2
|
|
84
|
+
den2 = math.sqrt(2 * math.pi) * sigma
|
|
85
|
+
six_den2 = 6 * den2
|
|
86
|
+
for k in prange(nlags):
|
|
87
|
+
lag = lags[k]
|
|
88
|
+
c_sum = 0.0
|
|
89
|
+
lo = 0
|
|
90
|
+
hi = 0
|
|
91
|
+
for j in range(n):
|
|
92
|
+
center = t[j] + lag
|
|
93
|
+
while lo < n and t[lo] < center - six_den2:
|
|
94
|
+
lo += 1
|
|
95
|
+
while hi < n and t[hi] < center + six_den2:
|
|
96
|
+
hi += 1
|
|
97
|
+
xj = x[j]
|
|
98
|
+
for i in range(lo, hi):
|
|
99
|
+
dtlag = t[i] - center
|
|
100
|
+
w = math.exp(dtlag**2 / den1) / den2
|
|
101
|
+
c_sum += w * x[i] * xj
|
|
102
|
+
c[k] = c_sum
|
|
103
|
+
return c
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@njit(parallel=True, nogil=True, cache=True)
|
|
107
|
+
def compute_c_rectangle(t, lags, x, bin_width):
|
|
108
|
+
"""Rectangular-kernel sum of x_i*x_j per lag, via prefix sums (O(1) per j,
|
|
109
|
+
since the rectangle kernel weight is uniform inside the window: the
|
|
110
|
+
window sum of x is looked up directly from a precomputed cumulative sum,
|
|
111
|
+
rather than re-summed element by element)."""
|
|
112
|
+
n = len(t)
|
|
113
|
+
nlags = len(lags)
|
|
114
|
+
c = np.zeros(nlags)
|
|
115
|
+
cumsum_x = np.zeros(n + 1)
|
|
116
|
+
for idx in range(n):
|
|
117
|
+
cumsum_x[idx + 1] = cumsum_x[idx] + x[idx]
|
|
118
|
+
for k in prange(nlags):
|
|
119
|
+
lag = lags[k]
|
|
120
|
+
c_sum = 0.0
|
|
121
|
+
lo = 0
|
|
122
|
+
hi = 0
|
|
123
|
+
for j in range(n):
|
|
124
|
+
center = t[j] + lag
|
|
125
|
+
while lo < n and t[lo] < center - bin_width:
|
|
126
|
+
lo += 1
|
|
127
|
+
if hi < lo:
|
|
128
|
+
hi = lo
|
|
129
|
+
while hi < n and t[hi] <= center + bin_width:
|
|
130
|
+
hi += 1
|
|
131
|
+
window_sum = cumsum_x[hi] - cumsum_x[lo]
|
|
132
|
+
c_sum += x[j] * window_sum
|
|
133
|
+
c[k] = c_sum
|
|
134
|
+
return c
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── Cross-correlation kernel functions ────────────────────────────────────────
|
|
138
|
+
# Same two-pointer logic as the ACF kernels above, adapted for two DIFFERENT
|
|
139
|
+
# time arrays t (for signal x) and s (for signal y). The CCF pair condition
|
|
140
|
+
# is s_j - t_i ≈ lag (whereas for ACF it was t_i - t_j ≈ lag).
|
|
141
|
+
# As j increases (s[j] increases), center = s[j] - lag increases
|
|
142
|
+
# monotonically, so lo/hi over t still only advance → O(n_t + n_s) per lag.
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@njit(parallel=True, nogil=True, cache=True, fastmath=True)
|
|
146
|
+
def compute_b_gaussian_cross(t, s, lags, sigma):
|
|
147
|
+
"""Gaussian-kernel weighted pair count for CCF.
|
|
148
|
+
|
|
149
|
+
Counts pairs (t_i, s_j) with s_j − t_i ≈ lag, weighted by the
|
|
150
|
+
Gaussian kernel. `t` and `s` must both be sorted ascending.
|
|
151
|
+
"""
|
|
152
|
+
n_t = len(t)
|
|
153
|
+
n_s = len(s)
|
|
154
|
+
nlags = len(lags)
|
|
155
|
+
b = np.zeros(nlags)
|
|
156
|
+
den1 = -2 * sigma**2
|
|
157
|
+
den2 = math.sqrt(2 * math.pi) * sigma
|
|
158
|
+
six_den2 = 6 * den2
|
|
159
|
+
for k in prange(nlags):
|
|
160
|
+
lag = lags[k]
|
|
161
|
+
b_sum = 0.0
|
|
162
|
+
lo = 0
|
|
163
|
+
hi = 0
|
|
164
|
+
for j in range(n_s):
|
|
165
|
+
center = s[j] - lag # t_i near here → s_j − t_i ≈ lag
|
|
166
|
+
while lo < n_t and t[lo] < center - six_den2:
|
|
167
|
+
lo += 1
|
|
168
|
+
while hi < n_t and t[hi] < center + six_den2:
|
|
169
|
+
hi += 1
|
|
170
|
+
for i in range(lo, hi):
|
|
171
|
+
dtlag = t[i] - center
|
|
172
|
+
b_sum += math.exp(dtlag**2 / den1) / den2
|
|
173
|
+
b[k] = b_sum if b_sum > 0 else 1e-16
|
|
174
|
+
return b
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@njit(parallel=True, nogil=True, cache=True)
|
|
178
|
+
def compute_b_rectangle_cross(t, s, lags, bin_width):
|
|
179
|
+
"""Rectangle-kernel pair count for CCF.
|
|
180
|
+
|
|
181
|
+
Counts pairs (t_i, s_j) with |s_j − t_i − lag| ≤ bin_width.
|
|
182
|
+
"""
|
|
183
|
+
n_t = len(t)
|
|
184
|
+
n_s = len(s)
|
|
185
|
+
nlags = len(lags)
|
|
186
|
+
b = np.zeros(nlags)
|
|
187
|
+
for k in prange(nlags):
|
|
188
|
+
lag = lags[k]
|
|
189
|
+
b_sum = 0.0
|
|
190
|
+
lo = 0
|
|
191
|
+
hi = 0
|
|
192
|
+
for j in range(n_s):
|
|
193
|
+
center = s[j] - lag
|
|
194
|
+
while lo < n_t and t[lo] < center - bin_width:
|
|
195
|
+
lo += 1
|
|
196
|
+
if hi < lo:
|
|
197
|
+
hi = lo
|
|
198
|
+
while hi < n_t and t[hi] <= center + bin_width:
|
|
199
|
+
hi += 1
|
|
200
|
+
b_sum += hi - lo
|
|
201
|
+
b[k] = b_sum if b_sum > 0 else 1e-16
|
|
202
|
+
return b
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@njit(parallel=True, nogil=True, cache=True, fastmath=True)
|
|
206
|
+
def compute_c_gaussian_cross(t, x, s, y, lags, sigma):
|
|
207
|
+
"""Gaussian-kernel weighted sum of x_i * y_j for CCF.
|
|
208
|
+
|
|
209
|
+
For each lag, accumulates x(t_i) * y(s_j) for pairs where
|
|
210
|
+
s_j − t_i ≈ lag, weighted by the Gaussian kernel w(s_j − t_i − lag).
|
|
211
|
+
"""
|
|
212
|
+
n_t = len(t)
|
|
213
|
+
n_s = len(s)
|
|
214
|
+
nlags = len(lags)
|
|
215
|
+
c = np.zeros(nlags)
|
|
216
|
+
den1 = -2 * sigma**2
|
|
217
|
+
den2 = math.sqrt(2 * math.pi) * sigma
|
|
218
|
+
six_den2 = 6 * den2
|
|
219
|
+
for k in prange(nlags):
|
|
220
|
+
lag = lags[k]
|
|
221
|
+
c_sum = 0.0
|
|
222
|
+
lo = 0
|
|
223
|
+
hi = 0
|
|
224
|
+
for j in range(n_s):
|
|
225
|
+
center = s[j] - lag
|
|
226
|
+
while lo < n_t and t[lo] < center - six_den2:
|
|
227
|
+
lo += 1
|
|
228
|
+
while hi < n_t and t[hi] < center + six_den2:
|
|
229
|
+
hi += 1
|
|
230
|
+
yj = y[j]
|
|
231
|
+
for i in range(lo, hi):
|
|
232
|
+
dtlag = t[i] - center
|
|
233
|
+
w = math.exp(dtlag**2 / den1) / den2
|
|
234
|
+
c_sum += w * x[i] * yj
|
|
235
|
+
c[k] = c_sum
|
|
236
|
+
return c
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@njit(parallel=True, nogil=True, cache=True)
|
|
240
|
+
def compute_c_rectangle_cross(t, x, s, y, lags, bin_width):
|
|
241
|
+
"""Rectangle-kernel sum of x_i * y_j for CCF, via prefix sums."""
|
|
242
|
+
n_t = len(t)
|
|
243
|
+
n_s = len(s)
|
|
244
|
+
nlags = len(lags)
|
|
245
|
+
c = np.zeros(nlags)
|
|
246
|
+
cumsum_x = np.zeros(n_t + 1)
|
|
247
|
+
for idx in range(n_t):
|
|
248
|
+
cumsum_x[idx + 1] = cumsum_x[idx] + x[idx]
|
|
249
|
+
for k in prange(nlags):
|
|
250
|
+
lag = lags[k]
|
|
251
|
+
c_sum = 0.0
|
|
252
|
+
lo = 0
|
|
253
|
+
hi = 0
|
|
254
|
+
for j in range(n_s):
|
|
255
|
+
center = s[j] - lag
|
|
256
|
+
while lo < n_t and t[lo] < center - bin_width:
|
|
257
|
+
lo += 1
|
|
258
|
+
if hi < lo:
|
|
259
|
+
hi = lo
|
|
260
|
+
while hi < n_t and t[hi] <= center + bin_width:
|
|
261
|
+
hi += 1
|
|
262
|
+
window_sum = cumsum_x[hi] - cumsum_x[lo]
|
|
263
|
+
c_sum += y[j] * window_sum
|
|
264
|
+
c[k] = c_sum
|
|
265
|
+
return c
|
nufftcf/nufft_acf.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ACF estimation via NUFFT (non-uniform FFT) + Wiener-Khinchin theorem.
|
|
3
|
+
|
|
4
|
+
These estimators compute the power spectrum of the (irregularly-sampled)
|
|
5
|
+
signal via a type-1 NUFFT, then evaluate the implied autocorrelation at the
|
|
6
|
+
requested lags via a type-2 NUFFT. This scales roughly as O(n log n),
|
|
7
|
+
dramatically faster than the O(n^2) real-space approach for long series --
|
|
8
|
+
but it carries a small, known limitation (see README): because it relies on
|
|
9
|
+
a finite-domain Fourier representation, irregular/gappy sampling acts as a
|
|
10
|
+
"spectral window" that slightly distorts narrowband (e.g. periodic) signals
|
|
11
|
+
more than broadband ones. Empirically this is a ~1-3% relative bias in the
|
|
12
|
+
ACF amplitude once N1 is large enough (see N1 note below); for an
|
|
13
|
+
artifact-free reference, use the `realspace` module instead.
|
|
14
|
+
|
|
15
|
+
`N1 = 32 * n` was empirically validated (against the exact real-space
|
|
16
|
+
estimator) to bring the NUFFT result into close agreement for both gaussian
|
|
17
|
+
and rectangle kernels; pushing higher gives a marginal further
|
|
18
|
+
improvement for gaussian on strongly periodic signals, at negligible extra
|
|
19
|
+
cost.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import finufft
|
|
24
|
+
from scipy.ndimage import gaussian_filter1d, uniform_filter1d
|
|
25
|
+
|
|
26
|
+
from .kernels import compute_b_gaussian, compute_b_rectangle
|
|
27
|
+
from .utils import standardize
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _nufft_power_spectrum_at_lags(t, x, lags, N1, eps):
|
|
31
|
+
"""Shared first stage: NUFFT type-1 (time -> frequency) then type-2
|
|
32
|
+
(frequency -> [0.0] + lags), implementing Wiener-Khinchin. Always
|
|
33
|
+
evaluates an extra point at lag=0 (regardless of whether 0 is already
|
|
34
|
+
in `lags`), used downstream to normalize the result -- the NUFFT pipeline
|
|
35
|
+
has its own internal scale convention that has nothing to do with the
|
|
36
|
+
`b` denominator's scale, so dividing by `b` alone does *not* yield a
|
|
37
|
+
properly normalized correlation in [-1, 1]. Returns the raw (unsmoothed)
|
|
38
|
+
correlation at [0.0] + lags, i.e. length len(lags) + 1.
|
|
39
|
+
"""
|
|
40
|
+
x_normalized = standardize(x)
|
|
41
|
+
xc = np.complex128(x_normalized)
|
|
42
|
+
n = len(xc)
|
|
43
|
+
t_min, t_max = t.min(), t.max()
|
|
44
|
+
t_norm = (t - t_min) / (t_max - t_min) * (2 * np.pi)
|
|
45
|
+
lags_norm = (lags - 0) / (t_max - t_min) * (2 * np.pi)
|
|
46
|
+
if N1 is None:
|
|
47
|
+
N1 = 32 * n
|
|
48
|
+
f1 = finufft.nufft1d1(t_norm, xc, (N1,), eps=eps)
|
|
49
|
+
mul = f1 * np.conj(f1)
|
|
50
|
+
c_positive = finufft.nufft1d2(lags_norm, mul, eps=eps).real
|
|
51
|
+
return c_positive # index 0 is lag=0, indices [1:] correspond to `lags`
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compute_acf_gaussian_nufft(lags, t, x, bin_width=0.5, N1=None, eps=1e-9):
|
|
55
|
+
"""ACF estimate via NUFFT + gaussian smoothing.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
lags : array_like
|
|
60
|
+
Lags at which to evaluate the ACF (same units as `t`, typically days).
|
|
61
|
+
t : array_like
|
|
62
|
+
Sample times, sorted ascending (same units as `lags`).
|
|
63
|
+
x : array_like
|
|
64
|
+
Sample values, same length as `t`.
|
|
65
|
+
bin_width : float
|
|
66
|
+
Gaussian kernel standard deviation (same units as `t`).
|
|
67
|
+
N1 : int, optional
|
|
68
|
+
NUFFT frequency-grid size. Defaults to 32*len(x) in _nufft_power_spectrum_at_lags
|
|
69
|
+
eps : float
|
|
70
|
+
NUFFT requested precision.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
c, b : ndarray
|
|
75
|
+
ACF estimate and effective pair count, both shape (len(lags),).
|
|
76
|
+
"""
|
|
77
|
+
t = np.asarray(t, dtype=float)
|
|
78
|
+
x = np.asarray(x, dtype=float)
|
|
79
|
+
lags = np.asarray(lags, dtype=float)
|
|
80
|
+
lags_eval = np.concatenate(([0.0], lags))
|
|
81
|
+
|
|
82
|
+
c_positive = _nufft_power_spectrum_at_lags(t, x, lags_eval, N1, eps)
|
|
83
|
+
c_smoothed = gaussian_filter1d(c_positive, sigma=bin_width)
|
|
84
|
+
b_eval = compute_b_gaussian(t, lags_eval, bin_width)
|
|
85
|
+
with np.errstate(divide="ignore", invalid="ignore", over="ignore"):
|
|
86
|
+
c_eval = c_smoothed / b_eval
|
|
87
|
+
c = c_eval[1:] / c_eval[0] # normalize by the (always computed) lag=0 value
|
|
88
|
+
b = b_eval[1:]
|
|
89
|
+
return c, b
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def compute_acf_rectangle_nufft(lags, t, x, bin_width=0.5, N1=None, eps=1e-9):
|
|
93
|
+
"""ACF estimate via NUFFT + rectangular (box) smoothing.
|
|
94
|
+
|
|
95
|
+
Same parameters and return values as `compute_acf_gaussian_nufft`.
|
|
96
|
+
The smoothing window size (in samples) is derived from `bin_width` and
|
|
97
|
+
the average lag spacing; with the common default bin_width=0.5 and a
|
|
98
|
+
1-day lag spacing, this resolves to a 1-sample window (i.e. no-op),
|
|
99
|
+
matching the gaussian kernel's "non-overlapping bins" behavior at the
|
|
100
|
+
same bin_width.
|
|
101
|
+
"""
|
|
102
|
+
t = np.asarray(t, dtype=float)
|
|
103
|
+
x = np.asarray(x, dtype=float)
|
|
104
|
+
lags = np.asarray(lags, dtype=float)
|
|
105
|
+
lags_eval = np.concatenate(([0.0], lags))
|
|
106
|
+
|
|
107
|
+
c_positive = _nufft_power_spectrum_at_lags(t, x, lags_eval, N1, eps)
|
|
108
|
+
|
|
109
|
+
dlag = np.mean(np.diff(lags)) if len(lags) > 1 else 1.0
|
|
110
|
+
window_size = max(1, int(round(2 * bin_width / dlag)))
|
|
111
|
+
c_smoothed = uniform_filter1d(c_positive, size=window_size, mode="nearest")
|
|
112
|
+
|
|
113
|
+
b_eval = compute_b_rectangle(t, lags_eval, bin_width)
|
|
114
|
+
with np.errstate(divide="ignore", invalid="ignore", over="ignore"):
|
|
115
|
+
c_eval = c_smoothed / b_eval
|
|
116
|
+
c = c_eval[1:] / c_eval[0] # normalize by the (always computed) lag=0 value
|
|
117
|
+
b = b_eval[1:]
|
|
118
|
+
return c, b
|