Skip to content

ENH: signal.lombscargle: update to the generalized Lomb-Scargle periodogram - #21277

Merged
j-bowhay merged 40 commits into
scipy:mainfrom
adammj:generalized_LS_periodogram
Oct 9, 2024
Merged

j-bowhay merged 40 commits into
scipy:mainfrom
adammj:generalized_LS_periodogram

Conversation

@adammj

@adammj adammj commented Jul 28, 2024

Copy link
Copy Markdown
Contributor

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.

…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.
@adammj
adammj requested review from ilayn and larsoner as code owners July 28, 2024 04:33
@github-actions github-actions Bot added scipy.signal Cython Issues with the internal Cython code base Meson Items related to the introduction of Meson as the new build system for SciPy enhancement A new feature or improvement labels Jul 28, 2024

@j-bowhay j-bowhay left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks @adammj, could you show some benchmarks to see how performance changes by removing the compiled code?

Comment thread scipy/signal/_spectral_py.py Resolved
Comment thread scipy/signal/_spectral_py.py Resolved
@j-bowhay
j-bowhay requested a review from DietBru July 28, 2024 08:41
@adammj

adammj commented Jul 28, 2024

Copy link
Copy Markdown
Contributor Author

Thanks @adammj, could you show some benchmarks to see how performance changes by removing the compiled code?

Yup. I'll get to work on some benchmarks. I only have two machines, so it'll be limited in diversity.

@lucascolley lucascolley removed Meson Items related to the introduction of Meson as the new build system for SciPy Cython Issues with the internal Cython code base labels Jul 28, 2024
@lucascolley lucascolley changed the title ENH: Update signal.lombscargle to the generalized Lomb-Scargle periodogram ENH: signal.lombscargle: update to the generalized Lomb-Scargle periodogram Jul 28, 2024
@adammj

adammj commented Jul 28, 2024 •

Copy link
Copy Markdown
Contributor Author

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.

M1 MacBook
result_m1

Xeon desktop
result_x86

import numpy as np
import seaborn as sns
from scipy.signal import lombscargle
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import figure_standard  # a library of mine for consistent plots


figure_standard.standardize_plot_settings()
rng = np.random.default_rng(12)
A = 2.1
w0 = 1.12  # rad/sec

gs = gridspec.GridSpec(1, 3)
ax = []
for i in range(3):
    ax += [plt.subplot(gs[0, i])]



for i, n_freqs_i in enumerate([9, 10, 11]):
    n_freqs = 2**n_freqs_i
    print(f"n_freqs: {n_freqs}")

    time_n = []
    old_avg = []
    old_std = []
    new_avg = []
    new_std = []

    for j in range(3, 15):
        n_times = 2**j
        time_n += [n_times]
        
        t = rng.uniform(0, 10*np.pi, n_times)
        y = A * np.cos(w0*t)
        w = np.linspace(0.01, 10, n_freqs)

        def test_old():
            pgram = lombscargle(t, y, w, normalize=True)  # existing scipy function

        def test_new():
            pgram_new = lombscargle_new(t, y, w, normalize=True)  # just the new function

        result_old = %timeit -n 9 -r 9 -o test_old()
        old_avg += [result_old.average]
        old_std += [result_old.stdev]

        result_new = %timeit -n 9 -r 9 -o test_new()
        new_avg += [result_new.average]
        new_std += [result_new.stdev]

    time_np = np.array(time_n)
    old_avg_np = np.array(old_avg)/9
    new_avg_np = np.array(new_avg)/9
    old_std_np = np.array(old_std)/9
    new_std_np = np.array(new_std)/9

    sns.lineplot(x=time_np, y=old_avg_np, ax=ax[i], legend=True)
    ax[i].fill_between(time_np, old_avg_np-old_std_np, old_avg_np+old_std_np, alpha=0.2)

    sns.lineplot(x=time_np, y=new_avg_np, ax=ax[i], legend=True)
    ax[i].fill_between(time_np, new_avg_np-new_std_np, new_avg_np+new_std_np, alpha=0.2)

    ax[i].set_ylim([0.00001, 1])
    ax[i].set_xlim([5, 20000])
    ax[i].set_yscale('log')
    ax[i].set_xscale('log')

    if i == 0:
        ax[i].legend(labels=['current',None,None,'new'], loc='upper left')
        ax[i].set_ylabel('time / iteration (sec)')
    
    ax[i].set_xlabel(f'# data points (for {n_freqs} freqs)')


plt.tight_layout()
plt.subplots_adjust(wspace=0.25, hspace=0)
figure_standard.scale_figure_by_axes(ax[0], 1.5, 1.5)


plt.savefig("result_linux.png",
            bbox_inches="tight",
            pad_inches=0.05,
            transparent=False,
        )

@adammj
adammj requested a review from j-bowhay July 28, 2024 23:43
@j-bowhay

Copy link
Copy Markdown
Member

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.

@DietBru

DietBru commented Aug 11, 2024

Copy link
Copy Markdown
Contributor

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:

  • The docstring could use some love:

    • It would be nice, if the documentation stated that this function is based on a weighted least-squares fit of $y_i = a\cos(\omega t_i) + b\cos(\omega t_i) + o_i$ (something similar like in Wikipedia). An advantage to a standard least squares calculation, as in scipy.linalg.lstsq(), is that this implementation is typically numerically more stable.
    • It is important to mention that the spectral values are calculated independently of each other (due to the for loop in the code). This is a difference to the more common FFT based methods. Especially in the presence of noise, the result differ quite a bit to case were all parameters would be calculated at once (e.g., there may a different offset for each frequency).
    • The Lomb-Scargle Periodograms section in the SciPy User Guide is probably not useful anymore and could be removed.
  • I am not worried much of the slowdown of the new version, since for more data points, the speed difference diminishes according to the nice benchmarks.

  • As far as I can tell, the unit tests do not check, if the phase angle is calculated correctly for normalize == 'amplitude'.

  • Keeping the classic version, i.e., not determining the offset (precenter == False) is still useful for some fringe applications: Imagine having data points from a sine wave with known frequency (no offset) and you are interested in amplitude and phase. If the data points only span a (small) part of the cycle period, subtracting the mean would be counterproductive.

@DietBru DietBru left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code-wise, I only have minor comments. It would be nice if the example in the docstring would illustrate the different normalization options.

Comment thread scipy/signal/_spectral_py.py Resolved
Comment thread scipy/signal/_spectral_py.py Resolved
Comment thread scipy/signal/_spectral_py.py Resolved
Comment thread scipy/signal/_spectral_py.py Outdated
coswt = np.empty_like(x)
sinwt = np.empty_like(x)
wcoswt = np.empty_like(x)
wsinwt = np.empty_like(x)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am not sure, if pre-declaring NumPy arrays bring a measurable speed benefit (though I never did any profiling to verify).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread scipy/signal/_spectral_py.py Outdated
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

My hunch is that

pgram = a + 1j*b

would produce the same result (while being faster). Could be easily verified by a unit test.

@adammj adammj Aug 11, 2024 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@adammj adammj Aug 12, 2024 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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).

amplitude

phase

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@adammj adammj Aug 14, 2024 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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)')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 $y_i = a cos ⁡ ( ω _i ) + b cos ⁡ ( ω t_i ) + o_i$.

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.

@adammj adammj Aug 23, 2024 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

@adammj

adammj commented Aug 11, 2024 •

Copy link
Copy Markdown
Contributor Author

@DietBru Thank you! I'll get to work on your suggestions.

On this point, however, what are you envisioning?

  • Keeping the classic version, i.e., not determining the offset (precenter == False) is still useful for some fringe applications: Imagine having data points from a sine wave with known frequency (no offset) and you are interested in amplitude and phase. If the data points only span a (small) part of the cycle period, subtracting the mean would be counterproductive.

I'll have to give this some more thought, but I can think of a few issues:

  1. Regardless of what the lowest frequency is, if the total time is only equal to half a cycle of that lowest frequency, then both formulations produce not very useful results for that frequency. The peak becomes -very- broad (as in, it's looks more like a rolling hill, than a spectral peak). I can demonstrate this with code+figure.
  2. The generalized version is supposed to catch any unknown offset regardless of if the user knows it exists. To me, trying to prevent this behavior would at the very least strongly suggest that the parameter default should be also changed to precenter=True. Because the default behavior, I believe, should be to make use of the generalized version's improvements.
  3. Alternatively, add a new parameter. The problem is the name precenter gives the impression that you're performing the operation y = y - y.mean() first. When the generalized version is not doing that at all. So, if the user explicitly wants the code to assume offset=0 for all frequencies, I think that an additional new parameter name like floating_mean (with a default =True) would more appropriately match the intention of what you're after, and the standard nomenclature of what the generalized version is doing.
  4. I'll have to think through how best I could "add back" a no-offset version. I'm thinking it would require a conditional outside the for loop (as in, two separate for loops, one for each branch), because all of the equations inside the loop are predicated on the generalized version (and adding a conditional inside the loop would be a huge performance penalty).

Update

Well, that was easier than I thought. I added a new parameter, floating_mean (default = True). Only one conditional is required inside the for loop, and another inside the calculation for the normalize=='normalize' condition. With only a single additional conditional per iteration, there won't be any performance issue.

# inside for loop
if floating_mean:
    # calculate best-fit offset for each frequency independently (default)
    C_sum = wcoswt.sum()
    S_sum = wsinwt.sum()
    YC = YC_hat - Y_sum * C_sum
    YS = YS_hat - Y_sum * S_sum
    CC = CC_hat - C_sum * C_sum
    SS = SS_hat - S_sum * S_sum
    CS = CS_hat - C_sum * S_sum
else:
    # assume offset=0 for all frequencies
    YC = YC_hat
    YS = YS_hat
    CC = CC_hat
    SS = SS_hat
    CS = CS_hat

D = CC * SS - CS * CS

# inside if normalize == "normalize" condition
if floating_mean:
    YY: float = YY_hat - Y_sum * Y_sum
else:
    YY: float = YY_hat

@adammj

adammj commented Aug 11, 2024

Copy link
Copy Markdown
Contributor Author

@DietBru

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 lombscargle function calculates the periodogram using a slightly modified algorithm due to Townsend..." should be updated to mention the generalized version. Furthermore, the equations at the end should be removed, as I don't think think they add anything to the discussion.

Comment thread scipy/signal/_spectral_py.py Resolved

@j-bowhay j-bowhay left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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!

@neutrinoceros

Copy link
Copy Markdown

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.

@j-bowhay

j-bowhay commented Oct 9, 2024

Copy link
Copy Markdown
Member

Thanks for explaining, this really isn't my domain of expertise so do you mind clarifying what you mean by better? Does this mean more accurate?

For data that has an unknown offset and especially data that has clipped peaks or troughs, yes, it is more accurate. For "simple" data, the results will be nearly identical (except, again, for the numerical differences inherent in floating point math). But there are additional features (like weights, and being able to produce the complex amplitude) that the old formulation did not allow for.

My question about backwards compatibility was mainly about the stronger input validation, are there any inputs that previously produced a valid output that might now raise an error?

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.

@adammj

adammj commented Oct 9, 2024

Copy link
Copy Markdown
Contributor Author

I'd like to ask first: is it expected to produce (even slightly) different results ?

@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.

@neutrinoceros

Copy link
Copy Markdown

Thanks for your prompt responses !

The differences we get seem kinda high for numerical noise, for instance

Max absolute difference among violations: 0.05446034
Max relative difference among violations: 4.22703988

nonetheless, those are not necessarily direct outputs from scipy. Further inquiry is needed on the side of astropy for now.

@dhomeier

dhomeier commented Oct 11, 2024 •

Copy link
Copy Markdown
Contributor

It looks like the issue was that it is not really fully backwards-compatible with the default of floating_mean=True, setting this to False seems to completely restore our previous results. Still need to discuss if the new defaults are not actually preferable, though.

@adammj

adammj commented Oct 11, 2024

Copy link
Copy Markdown
Contributor Author

@dhomeier Defaulting to using the floating mean formulation (per Zechmeister and Kürster) was discussed above, as the previous precenter parameter (which was kept for backwards compatibility of the API) is more misleading than correct--especially in astronomy light curves, where observation times might systematically prevent observing the peaks and/or troughs, which will bias the mean.

@dhomeier

Copy link
Copy Markdown
Contributor

@adammj yes, I was assuming something like that, just have not had the time to read up on the discussion.
I assumed our own pure Python + Numpy implementation must have had some motivation to use the previous convention, and in fact it does refer to Zechmeister & Kürster, though "using the floating mean" seems to be understood in the opposite sense there.
I shall propose that we at least allow using floating_mean=True in new releases, and if possible add a corresponding option to our internal implementation.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should this be

an offset for each frequency

?

@adammj adammj Oct 11, 2024 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yup. Not sure how I missed that typo.

@adammj adammj Oct 12, 2024 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in #21697

@dhomeier

Copy link
Copy Markdown
Contributor

I shall propose that we at least allow using floating_mean=True in new releases, and if possible add a corresponding option to our internal implementation.

Ah, nevermind – looks like this is exactly what @jakevdp has already implemented as fit_mean on our side (and was disabled for the scipy method ;-), so probably all that needs to be done is adding this to our scipy wrapper when available.

@jakevdp

jakevdp commented Oct 11, 2024 •

Copy link
Copy Markdown
Member

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, fit_mean is set to True by default, and to recover previous outputs it must explicitly be set to False.

@jakevdp

jakevdp commented Oct 11, 2024

Copy link
Copy Markdown
Member

I also think it would be worth discussing whether this breakage is advisable. I'd lean toward fit_mean=False by default (at least for now) to avoid this kind of breakage, despite the fact that all else being equal, fit_mean=True would be a better default.

@adammj

adammj commented Oct 11, 2024

Copy link
Copy Markdown
Contributor Author

@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 precenter=True argument). Any differences will be "for the better", as in, the new implementation is more appropriately handling these offsets by default. But, if they are comparing results between versions, and had an offset, they may see a difference.

@j-bowhay

Copy link
Copy Markdown
Member

@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 precenter=True argument). Any differences will be "for the better", as in, the new implementation is more appropriately handling these offsets by default. But, if they are comparing results between versions, and had an offset, they may see a difference.

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 fit_mean=False with a future warning that it will change to true?

@adammj

adammj commented Oct 11, 2024

Copy link
Copy Markdown
Contributor Author

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 fit_mean=False with a future warning that it will change to true?

@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, fit_mean is an internal astropy implementation detail, and not part of scipy.signal.lombscargle.

@j-bowhay

Copy link
Copy Markdown
Member

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 fit_mean=False with a future warning that it will change to true?

@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, fit_mean is an internal astropy implementation detail, and not part of scipy.signal.lombscargle.

Ah thanks, got confused trying to follow on my phone

@tylerjereddy

Copy link
Copy Markdown
Contributor

@adammj I suggested keeping the more conservative argument in the cross-linked issue and issuing a warning. If the astropy folks are actually happy in the end though it may be "ok," I'll ping them over there.

@jakevdp

jakevdp commented Oct 11, 2024 •

Copy link
Copy Markdown
Member

One place where fit_mean=True will cause problems is when you use lombscargle to find the window frequency by passing in a unit signal, like this:

signal_psd = lombscargle(x, y, freqs)
window_psd = lombscargle(x, np.ones_like(y), freqs)

If fit_mean is True by default, then window_psd is under-constrained, and you'd have to explicitly set fit_mean=False to get meaningful output. This is not "backward-compatible" even in the looser sense of the term.

@adammj

adammj commented Oct 11, 2024

Copy link
Copy Markdown
Contributor Author

@jakevdp I'm confused. fit_mean is a astropy.lombscargle parameter, and it is true by default. And, as best as I can tell, it is equivalent (in spirit) to floating_mean in scipy.lombscargle, which (currently) is also true by default. So, it looks like to me, the two methods are in agreement that a "floating mean" is best, and should be the default.

@jakevdp

jakevdp commented Oct 11, 2024 •

Copy link
Copy Markdown
Member

Yes, in astropy it has always been set to True, because it's the more sensible default when the option is available.

Here the argument is that changing the default is a breaking change, and so it shouldn't be done quietly because it will break downstream projects (as it did in the linked astropy issue).

Moving discussion to #21696 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement A new feature or improvement scipy.signal

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants