Skip to content

Enable fit_mean=True for Lomb-Scargle periodograms - #20412

Merged
pllim merged 21 commits into
astropy:mainfrom
astrofrog:lombscargle-scipy-fit-mean
Nov 10, 2026
Merged

pllim merged 21 commits into
astropy:mainfrom
astrofrog:lombscargle-scipy-fit-mean

Conversation

@astrofrog

@astrofrog astrofrog commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Description

With my astropy.timeseries maintainer hat on, and in the interest of unblocking two competing (and now stale) PRs to enable fit_mean=True in the Lomb-Scargle scipy mode, I have made this PR which is a combination of the two PRs:

and am closing the two PRs. We should now focus all discussions here.

I have used all the commits from the first except the precenter one (since scipy will deprecate this parameter), as well as the normalization fix from the second, and then applied some additional fixes on top of this. Compared to main, the changes are as follows:

  • Enables fit_mean=True for the scipy Lomb-Scargle method by passing scipy 1.15+'s floating_mean option through to scipy.signal.lombscargle. On older scipy versions a clear ValueError is raised
  • method='auto' can now select the scipy backend for the default configuration (fit_mean=True, no dy) - previously it was excluded whenever the mean was fitted - but never when method_kwds are passed (scipy accepts none)
  • Accepts uniform (constant) dy arrays for the scipy method instead of rejecting all uncertainties
  • Corrects the standard/log/model normalizations for the floating-mean model with uncentered data, now computed via the package's shared compute_chi2_ref()/convert_normalization() helpers; results match the slow/cython implementations (to ~1e-12)
  • The new fit_mean argument of lombscargle_scipy is keyword-only, so existing positional callers are unaffected
  • There are no user-visible default changes and no deprecations: fit_mean=True stays the default everywhere, and plain LombScargle(t, y).power(freq) returns the same results as before
  • Tests unskip the scipy method for fit_mean=True and uniform-dy combinations, comparing every method against the exact slow reference; docs (method table and narrative) updated to reflect the new capabilities

Note that no deprecation is needed because the old scipy method raised an error for anything except an explicit fit_mean=False, which still behaves exactly as before, so there are no existing users whose unspecified
default could silently change meaning.

AI Disclosure

If AI tools were used to develop this pull request, describe the tools including specific model and version, how they were used, and what content is AI generated. Otherwise enter "N/A".

I used Claude Fable to help review the changes from the two PRs and understand how to best combine them, and help identify and fix some of the issues that came up.

  • I certify that I am human and that I take full responsibility for this pull request including all interactions with reviewers.

Merge method

  • By checking this box, the PR author has requested that maintainers do NOT use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.

  • Do the proposed changes actually accomplish desired goals?
  • Do the proposed changes follow the Astropy coding guidelines?
  • Are tests added/updated as required? If so, do they follow the Astropy testing guidelines?
  • Are docs added/updated as required? If so, do they follow the Astropy documentation guidelines?
  • Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for rebase and squash.
  • Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the bot.
  • Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  • Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  • At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) before merge.

@astrofrog

Copy link
Copy Markdown
Member Author

This is not ready to look at yet, will finalize tomorrow

@pllim pllim added this to the v8.1.0 milestone Sep 16, 2026
@astrofrog astrofrog changed the title Lombscargle scipy fit mean Enable fit_mean=True for Lomb-Scargle periodograms Sep 16, 2026
@astrofrog
astrofrog force-pushed the lombscargle-scipy-fit-mean branch from b042094 to 853be33 Compare September 16, 2026 09:04
if fit_mean and SCIPY_LT_1_15:
pytest.skip("SciPy 1.15+ required for using `fit_mean=True`")
elif errors == "full" or (errors == "partial" and normalization == "psd"):
pytest.skip("scipy method only supports uniform uncertainties dy")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@jakevdp suggested in https://github.com/astropy/astropy/pull/17170/changes#r1798477154 that we could pass through non-uniform errors, which is true in principle but this still fails due to a pre-existing issue on main, so I'll address in a follow-up PR to keep this one focused.

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.

@astrofrog
astrofrog marked this pull request as ready for review September 16, 2026 09:05
@astrofrog

Copy link
Copy Markdown
Member Author

@dhomeier @neutrinoceros - let me know if you are happy with this approach and the result, or if there are things from your PRs that you think should be included here.

@astrofrog
astrofrog force-pushed the lombscargle-scipy-fit-mean branch from 853be33 to ac6b4ee Compare October 16, 2026 11:21

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

Thanks for reviving this Tom.
Generally this looks good to me, but I do have a couple suggestions I would like addressed before I formally approve.

the new fit_mean argument of lombscargle_scipy is keyword-only, so existing positional callers are unaffected

this doesn't make sense to me. Did you mean "has a default value" instead of "is keyword-only" ? To be clear:

  • keyword-only parameters usually have default values
  • parameters with default values are usually expected to be passed as keywords
  • even though the overlap is considerable in practice, none of these set is a subset to the other; one can define a keyword-only parameter without a default value, and default values can be added to any parameter (including positional-only ones), as long as every parameter that follow also has one.

(note that I'm also distinguishing "parameters", which are part of a function signature from "arguments", which are values actually passed to functions at call sites)

# importing scipy itself; if scipy is installed without metadata (e.g. in
# some frozen applications) assume it is recent, and if it is not installed
# at all this value is never consulted since HAS_SCIPY gates all uses.
try:

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.

this should use HAS_SCIPY instead of a try/except

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.

alternatively... why not use minversion here ?

@astrofrog astrofrog Nov 3, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 8fbdd81

P_float = LombScargle(t, y, dy, **kwds).power(frequency, method=method)
P_int = LombScargle(t_int, y_int, dy_int, **kwds).power(frequency, method=method)
assert_allclose(P_float, P_int)
assert_allclose(P_int, P_float)

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.

if I'm following correctly you're switching arguments to better fit the convention that the know-good value (expected) is on the second position. In this specific instance it looks surprising to me; I expect int-based math to produce more reliable results so I would expect it to serve as the reference. What is your reasoning ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This was done in the original PRs I combined here - but I agree it's not necessary, so reverted in 1ea6fde

@dhomeier dhomeier Nov 3, 2026 •

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.

In my PR I was indeed trying to match the calling pattern of actual, desired. In this case, t_int, y_int etc. are cast (and rounded) from the original float values, so the latter should really be the reference. But I don't think it makes any difference for assert_allclose internally.

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.

internally, indeed it doesn't. But switching the arguments has a visible effect of changing the error message raised in case of a mismatch, and then it's preferable to have the reference on the right side

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.

Yes, that was probably the reason I switched a number of those, plus it's easier to grasp when looking at the code.

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.

For context, the whole test was added in #4965 to check integer inputs (which in the case of scipy are simply cast back to float before passing to signal.lombscargle).

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.

Looks like I dropped the ball on this, but just noting that afaics the other methods are also just internally converting integer inputs to float again, when multiplying in weights at the latest. A native integer implementation of a periodogram does not seem to make much sense to me anyway.

for norm in from_to:
if norm not in NORMALIZATIONS:
raise ValueError(f"{from_normalization} is not a valid normalization")
raise ValueError(f"{norm} is not a valid normalization")

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.

there's an opportunity to improve this validation here by:

  • collecting all violations first (building a list[ValueError] by incremental appends)
  • if the list ends up empty, do nothing
  • otherwise:
    • if there's exactly one exception, raise it
    • otherwise: raise all exceptions under an ExceptionGroup

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.

TBC, I'm not requiring this to be implemented here, but it should be easy enough that maybe we might as well

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'd prefer not to do this, as otherwise downstream users will have to catch either ValueError or ExceptionGroup depending on the number of issues.

@neutrinoceros neutrinoceros Nov 3, 2026 •

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.

not really, they'd just have to use except* ValueError instead of except ValueError

>>> try:
...     raise ExceptionGroup('a group', [ValueError('1'), ValueError('2')])
... except ValueError:
...     print('gotcha')
...     
  + Exception Group Traceback (most recent call last):
  |   File "<python-input-2>", line 2, in <module>
  |     raise ExceptionGroup('a group', [ValueError('1'), ValueError('2')])
  | ExceptionGroup: a group (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | ValueError: 1
    +---------------- 2 ----------------
    | ValueError: 2
    +------------------------------------
>>> try:
...     raise ExceptionGroup('a group', [ValueError('1'), ValueError('2')])
... except* ValueError:
...     print('gotcha')
...     
gotcha
>>> try:
...     raise ExceptionGroup('a group', [ValueError])
... except* ValueError:
...     print('gotcha')
...     
gotcha
>>> try:
...     raise ValueError
... except* ValueError:
...     print('gotcha')
...     
gotcha

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 7ec283d

@astrofrog

Copy link
Copy Markdown
Member Author

this doesn't make sense to me. Did you mean "has a default value" instead of "is keyword-only" ? To be clear:

Yes I meant has a default value here

dhomeier and others added 12 commits November 3, 2026 14:04
…g-mean model, where the reference chi-squared is the variance about the mean rather than about zero (extracted from astropy#17600)
… now that the normalizations handle uncentered data
…ribution metadata, let lombscargle_scipy own the version guard, avoid auto-selecting scipy when method_kwds are passed, and reuse the shared normalization helpers
…ods instead of re-deriving the auto selection
… via minversion instead of an import-time metadata probe
…handled dy scale rather than a uniformity restriction
…n supported scipy versions and skip instead of returning
…in test_integer_inputs independent of the version skip, restore the original assert_allclose argument order, and reformat the validate_method signature
… method_kwds in validate_method to satisfy numpydoc validation
@astrofrog
astrofrog force-pushed the lombscargle-scipy-fit-mean branch from 34eac7a to 83f0c59 Compare November 3, 2026 14:14
@astrofrog

Copy link
Copy Markdown
Member Author

@neutrinoceros - I think I've addressed all your review comments, would you mind taking another look?

@neutrinoceros

Copy link
Copy Markdown
Contributor

thanks. I would like us to converge on #20412 (comment) but then I can switch my review status

@astrofrog

Copy link
Copy Markdown
Member Author

@neutrinoceros - I've done this now (implemented the ExceptionGroup)

Comment on lines +58 to +63
with pytest.raises(ExceptionGroup, match="invalid normalizations") as excinfo:
convert_normalization(np.ones(3), 10, "spam", "eggs")
assert [str(exc) for exc in excinfo.value.exceptions] == [
"spam is not a valid normalization",
"eggs is not a valid normalization",
]

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.

Much preferable to use pytest.RaisesGroup and pytest.RaisesExc here. Note that we'll need pytest 8.4 as the minimal requirement but that should be perfectly okay to bump now. I'm actually planning to require 9.0+ in January

If you need an example of how to use these see https://github.com/neutrinoceros/gpgi/blob/54f5174a43ac3d9445aa468e8643f8db5f5cdda8/tests/test_api.py#L337

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.

see gh-20810

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done

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

the test needs a small rewrite

@astrofrog

Copy link
Copy Markdown
Member Author

@neutrinoceros - I've now re-written the test

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

thanks ! that's a go for me

@pllim
pllim merged commit 131a74f into astropy:main Nov 10, 2026
41 of 44 checks passed
@dhomeier

dhomeier commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

So you want to keep treating the cast to int version as test reference?

@astrofrog

Copy link
Copy Markdown
Member Author

I don't feel strongly about it

@dhomeier

Copy link
Copy Markdown
Contributor

Yes, it's been backwards all the time without apparently creating much confusion.

@pllim

pllim commented Nov 11, 2026

Copy link
Copy Markdown
Member

Just to be clear... is there a follow-up issue to be opened here? This PR is already merged.

@dhomeier

Copy link
Copy Markdown
Contributor

There was an unresolved discussion on the tests, but it's probably not worth opening a new PR.

@dhomeier

Copy link
Copy Markdown
Contributor

Maybe could keep it in mind when addressing #20412 (comment) though

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants