Repository navigation
ENH: signal.lombscargle: update to the generalized Lomb-Scargle periodogram - #21277
Conversation
…ogram The function is a backwards-compatible replacement, but with additional functionality for sample weights and correctly removing any unknown y offset (including where the peaks are clipped, such that a naive mean removal will not work). Additional tests were created for the new options and outputs. Finally, files (and references to them) that are no longer needed were removed (such as the Cython and pythan version of the old code). The compiled versions are not needed, as the vectorized NumPy code is nearly the same speed as the nested for loops.
Yup. I'll get to work on some benchmarks. I only have two machines, so it'll be limited in diversity. |
Benchmarks (code at the bottom)I ran this on an M1 MacBook (macOS 14.5) and a Xeon desktop (Ubuntu 22.04), using the latest scipy (1.14.0) and numpy (2.0.1). I sampled a large range of data points (8-16384) for 3 reasonable frequency counts (512, 1024, 2048). (The benchmark code is a little ugly and the plots could be prettier, especially the legends.) The times, especially for a small sample sizes, are just so short that I feel the differences are basically inconsequential. Especially given the simpler code and ease of maintenance. |
|
Thanks for the benchmarks, I am not a signal person so don't have a sense of whether that performance hit is acceptable so I will leave that decision to another maintainer. |
|
I finally found some time to familiarize myself with the (generalized) Lomb–Scargle periodogram a bit. I think this PR would be a valuable addition—especially, being able to also determine the spectrum's phase is a big plus, IMO. I do have a couple of remarks:
|
DietBru
left a comment
There was a problem hiding this comment.
Code-wise, I only have minor comments. It would be nice if the example in the docstring would illustrate the different normalization options.
| coswt = np.empty_like(x) | ||
| sinwt = np.empty_like(x) | ||
| wcoswt = np.empty_like(x) | ||
| wsinwt = np.empty_like(x) |
There was a problem hiding this comment.
I am not sure, if pre-declaring NumPy arrays bring a measurable speed benefit (though I never did any profiling to verify).
There was a problem hiding this comment.
I didn't exhaustively test this, but I found a tiny performance impact if those 4 arrays (used only inside the loop) were not preallocated. Given those arrays could be quite large, I think it's safer to error on the side of caution and only allocate them once.
| # return the complex representation of the amplitude and phase | ||
| phase = np.arctan2(b, a) # radians | ||
| pgram = np.sqrt(pgram) * (np.cos(phase) + 1j * np.sin(phase)) | ||
| return pgram |
There was a problem hiding this comment.
My hunch is that
pgram = a + 1j*bwould produce the same result (while being faster). Could be easily verified by a unit test.
There was a problem hiding this comment.
I vaguely remember edge cases where this doesn't work. I'll have to dig back through my notes and simulations to find some examples. Once I found those edges, and realized I needed to use arctan, I specifically stored the a and b array and calculated arctan outside the loop, because my first solution had too much of a performance impact. But, yeah, it seems like it should be an easy swap. I'll check my notes and investigate.
There was a problem hiding this comment.
Okay, I found where the simple pgram = a + 1j * b breaks down: near freq=0. I also verified, using a symbolic math library, that no, the two equations are not equivalent.
The amplitude starts increasing exponentially, while the phase is still correct. See these two plots for comparing correct formula (blue) with simple, but incorrect formula (orange). I should add the input had no y offset (but it's still wrong whether it has a y offset or not).
There was a problem hiding this comment.
Hmm, it looks a bit strange that those plots do not show a systematic deviation for all frequencies. It smells a lot like a numeric problem. I toyed a bit with an alternative implementation, which utilized lstsq directly, which had numeric instabilities near f=0 as well (which could be illustrated by looking at the condition number). What was noticeable that the estimated offset also grew near f=0, just like in your plot (which is not really surprising, since all spectral lobes have some width in frequency). Do you happen to know what the true parameters of that dataset are?
That said, it is probably best to keep the existing implementation for the time being.
There was a problem hiding this comment.
Yeah. Before the first commit, I had spent a little time refactoring the equations to make the code not only a little easier to follow while also making the transforms to each of the 3 possible output units neater as well. (I still think the "default" units are nonsense and not a form I've ever used in my career, but backwards compatibility and all).
When I was playing around 2 days ago with the complex output, I was able to get other forms that didn't exhibit this issue, but they are pretty gnarly. Although it looks kinda dumb to do the two-step to phase and back to a complex unit circle, it's really the amplitude that's causing the problem (as we can see). YC and YS tend towards zero as f approaches 0, which perfectly counteract the explosion of a and b (caused by D going towards zero too) in the amplitude (sqrt(2*(a*YC + b*YS))).
Also, it seems that I misspoke earlier, there was an offset. On that note, as you found, the offset also tends towards -infinity as f tends to 0. This looks like because C_sum and S_sum do not tend towards zero, to counteract a and b in c = Y_sum - a * C_sum - b * S_sum. I just had an epiphany, since the default formulation of p(ω) is as a relative reduction in the power given the data, this is why using sqrt(2*(a*YC + b*YS)) instead of sqrt(a^2+b^2) doesn't blow up as f goes towards zero. It makes me wonder if there's a formulation for c that also wouldn't blow up (this function is not outputting c, even though I leave the formula in as a comment for the interested reader).
For any future reader, here is the formulation of the offset that is stable(ish) as f approaches 0. The absolute values are required because although the sum a*YC + b*YS is always >= 0, it's possible that either half can be negative, and imaginary numbers don't make sense in this case.
a_new = np.sqrt(2*np.abs(a*YC))
b_new = np.sqrt(2*np.abs(b*YS))
c_new = Y_sum - a_new*C_sum - b_new*S_sum
Here's the code I used for the earlier plots:
nin = 10000
nout = 1000
p = 0.7
np.random.seed(2353425)
r = np.random.rand(nin)
t = np.linspace(0.01*np.pi, 10.*np.pi, nin)[r >= p]
w = np.linspace(0.01, 5., nout)
w_i = 100
phi = 0
A = 2
off = 1
y = A*np.cos(w[w_i]*t + phi) + off
# modified to also output `pgram2 = a + 1j * b`
ls_gen_complex, ls_gen_complex_alt = lombscargle(t, y, w, normalize = 'amplitude')
ax = sns.lineplot(x=w,y=np.abs(ls_gen_complex))
sns.lineplot(x=w,y=np.abs(ls_gen_complex_alt), ax=ax)
ax.set_yscale('log')
ax.set_title('amplitude')
ax.set_ylabel('amplitude (units)')
ax.set_xlabel('frequency (rad/sec)')
ax = sns.lineplot(x=w,y=np.angle(ls_gen_complex))
sns.lineplot(x=w,y=np.angle(ls_gen_complex_alt), ax=ax)
ax.set_title('angle')
ax.set_ylabel('angle (radians)')
ax.set_xlabel('frequency (rad/sec)')
There was a problem hiding this comment.
Scratch all of that. Something wasn't sitting right, so I dug deeper. Using trickier inputs (inputs with clipped peaks, instead of only just randomly sampled time points), it becomes obvious that although sqrt(2*(a*YC + b*YS)) is the same as the amplitude for the best-fit cosine for semi-perfect inputs, it starts failing badly when there are larger gaps in the data. (i.e., the larger the gaps, the smaller the predicted amplitude with this formula).
So, you were right, the formula for the complex amplitude should just be amplitude = a + 1j * b (which means the offset should also be c = Y_sum - a * C_sum - b * S_sum, both which match the paper). This means that, indeed, the trend of amplitude towards +infinity and c (offset) towards -infinity as f approaches 0 is just a side effect (which, as you said, shows up when you just do another least-squares solution). It looks like it always starts to go off the rails at f < (2*pi)/(t_max-t_min)—which makes sense; the "limit" is where 1 cycle can just cover the entire time span. I wonder if there should be a warning (or error?), if there are any freqs provided that are below the "critical limit", since at f_crit/2 the values are already well off the rails. Thoughts?
I've already corrected the 'amplitude' output in the function.
There was a problem hiding this comment.
After looking at your explanations and doing a little bit more of reading, I understand now that the Lombscargle method may not be interpreted as an alternative formulation of the least squares minimization of
Regarding the "critical limit": Formulating generic criteria for input data when the Lombscargle method becomes unreliable is a larger feat, as far as I understand. It heavily depends on the underlying noise model(s) and the desired accuracy. Even for the case of the standard linear least squares (where the condition number provides a sort of quality measure of the estimate), it is not straightforward.
Regarding the f < (2*pi)/(t_max-t_min) heuristic: My guts think that that condition might be too fuzzy to be useful—but I am not able to pinpoint the problem. Determining some sort of standard deviation of the average sample rate (or some other statistic) would appear more plausible to me. Some literature reference in that regard might helpful.
For this PR, I think it would be sufficient to document that the quality of the Lombscargle method heavily depends on the temporal distribution of the input samples and perhaps that there is no direct equivalent to the Nyquist frequency and the frequency resolution being inversely proportional to the signal duration.
Regarding the documentation: I think VanderPlas's paper might be a useful reference, since it discusses the difference between standard FFT periodogram and the Lombscargle method from an application perspective.
There was a problem hiding this comment.
I really like VanderPlas' paper. I found it while I was writing this PR, and I wish it had existed back when I first discovered Lombscargle. It does a really good job of tying all of the literature together—with clear figures!
Yeah, there are a ton of gotchas and not straightforward heuristics with non-uniform sampling in general. See also many papers on trying to derive robust formulas for a "pseudo Nyquist limit" (this is a -fun- rabbit hole, and has provided me with hours of debate). The ability to resolve above fs/2 is one of the things I really like about non-uniform sampling.
For now, I'll remove the check (moving to documentation) and notes and the reference.
|
@DietBru Thank you! I'll get to work on your suggestions. On this point, however, what are you envisioning?
I'll have to give this some more thought, but I can think of a few issues:
Update Well, that was easier than I thought. I added a new parameter, |
|
You said:
I think it should stick around. Over the years, when discussing lombscargle, I've had to spend immense amount of time explaining LSSA, and how, mathematically it reduces to the same thing as Fourier analysis when using uniformly sampled data. (Until then, they think it's some "fake" frequency analysis.) However, I think everything after and including "The |
j-bowhay
left a comment
There was a problem hiding this comment.
CI failures are unrelated.
As mentioned I am not familiar with the subject matter but things look good from a Python perspective and it looks like @DietBru has looked at the technical parts carefully.
Thanks @adammj for pushing this through and congratulations on your first contribution to SciPy.
Thanks @DietBru for reviewing!
|
FYI I have identified this patch as the root cause of some new failures in astropy's test suite: astropy/astropy#17149 I haven't scanned through the whole discussion but since the OP opens as "The function is a backwards-compatible replacement" I'd like to ask first: is it expected to produce (even slightly) different results ? I'll have more time to go through the whole history tomorrow, apologies if my question is already answered somewhere in the discussion. |
Thanks for reporting @neutrinoceros this should be the relevant part of the discussion. However please do let us know if you find the above not to be the case. |
@neutrinoceros Yes, the values posted in your downstream issue in astropy/astropy#17149 just highlight numerical differences that are always present when using floating-point math. Even if two formulas mathematically reduce to the same exact thing, the path your calculations take using float32, float64, floatWHATEVER, will wind up with ever so slightly different results. |
|
Thanks for your prompt responses ! The differences we get seem kinda high for numerical noise, for instance nonetheless, those are not necessarily direct outputs from scipy. Further inquiry is needed on the side of astropy for now. |
|
It looks like the issue was that it is not really fully backwards-compatible with the default of |
|
@dhomeier Defaulting to using the floating mean formulation (per Zechmeister and Kürster) was discussed above, as the previous |
|
@adammj yes, I was assuming something like that, just have not had the time to read up on the discussion. |
| weights : array_like, optional | ||
| Weights for each sample. Weights must be nonnegative. | ||
| floating_mean : bool, optional | ||
| Determines an offset or each frequency independently, if True. |
There was a problem hiding this comment.
Should this be
an offset for each frequency
?
There was a problem hiding this comment.
Yup. Not sure how I missed that typo.
Ah, nevermind – looks like this is exactly what @jakevdp has already implemented as |
|
The PR description notes backward compatibility, but it should be updated to make clear that although the API is backward compatible, the outputs are not: the function outputs a different statistic than it did previously. Namely, |
|
I also think it would be worth discussing whether this breakage is advisable. I'd lean toward |
|
@tylerjereddy I wasn't sure who to notify, but I see your username next to each of the recent releases. As @mhvk mentions in astropy/astropy#17149, it's worth highlighting this change in the release notes. While the API is backwards compatible, it may cause some results to change with respect to the previous version's output, if the data has a vertical offset (even if it is removed with the |
Thanks for explaining. I think I'd prefer to be on the conservative side, as unexpected changes to values can cause headaches for our downstream users. Perhaps we should set |
@j-bowhay I think the previous 3 comments by @jakevdp and @dhomeier were supposed to go on astropy/astropy#17149, as they're talking about astropy features. Specifically, |
Ah thanks, got confused trying to follow on my phone |
|
@adammj I suggested keeping the more conservative argument in the cross-linked issue and issuing a warning. If the |
|
One place where signal_psd = lombscargle(x, y, freqs)
window_psd = lombscargle(x, np.ones_like(y), freqs)If |
|
@jakevdp I'm confused. |
|
Moving discussion to #21696 (comment) |




The function is a backwards-compatible replacement, but with additional functionality for sample weights and correctly removing any unknown y offset (including where the peaks are clipped, such that a naive mean removal will not work). Additional tests were created for the new options and outputs. Finally, files (and references to them) that are no longer needed were removed (such as the Cython and pythran version of the old code). The compiled versions are not needed, as the vectorized NumPy code is nearly the same speed as the nested for loops.
Reference issue
None, but discussed in more detail at Scientific Python.
What does this implement/fix?
Replaces the current Lomb-Scargle peridogram with the generalized version (published in 2009 arxiv version of published paper link) which enables sample weighting as well as more correctly removing any unknown y offset. Besides the one additional new parameter, the function is completely backwards compatible in API and the results (for the previous functionality).
Additional information
Since the compiled code is no longer needed, I removed the files and all references to them. New tests were added.