Skip to content

fix(#124): make GLS convergence invariant to observable amplitude - #134

Open
marcohost33-maker wants to merge 10 commits into
mainfrom
chatgpt/issue-124-residual-scaling
Open

fix(#124): make GLS convergence invariant to observable amplitude#134
marcohost33-maker wants to merge 10 commits into
mainfrom
chatgpt/issue-124-residual-scaling

Conversation

@marcohost33-maker

Copy link
Copy Markdown
Owner

Summary

Closes #124 on top of PR #121 (base=pr107-fix) as one isolated numerical-correctness slice.

Defect

For a curve such as 1e-40 * exp(-1.3 t), the raw residuals and their numerical gradient are already far below SciPy least_squares termination scales at the starting point. TRF can therefore return the seed with success=True after one evaluation even though the same curve at order-one amplitude fits normally.

That violates a basic invariance: multiplying an observable and its model by a positive constant must not change the fitted rate or whether a fit occurred.

Fix

The residual vector presented to least_squares is divided by the observed curve's own finite, non-zero magnitude max(abs(y)) after the existing #123 degeneracy guard.

This is a positive constant scaling of the least-squares objective, so it does not change the mathematical minimizer. It only changes the numerical units seen by the optimizer. Raw residuals, AR(1) estimation, reported sigma, and likelihood remain in the caller's original data units.

No solver tolerances are loosened or tightened, and this deliberately does not attempt the separate time/parameter nondimensionalisation tracked in #111.

SciPy documents that least_squares success is tied to ftol/xtol/gtol, with TRF's gradient criterion compared to gtol; it separately describes x_scale as a scaled-variable reformulation. The correction here addresses the residual-amplitude axis directly rather than retuning the solver.

Reference: https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.least_squares.html

Discrimination tests

tests/test_gls_amplitude_scale.py pins the exact acceptance pair from #124:

  • exp(-1.3 t) at order-one amplitude must still converge to 1.3;
  • 1e-40 * exp(-1.3 t) must converge to the same rate and must move far away from the seed 0.2.

The second assertion kills the pre-fix path, which returned the seed as a successful fit.

Scope boundary

Change-control note

No CHANGELOG/CITATION edit is bundled into this stacked corrective slice to avoid another conflict in the already-large #121 stack. Before integration into main, the results-changing numerical correction must be recorded once in the canonical [Unreleased]/pending-release notes together with the final CI evidence.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Copy link
Copy Markdown
Owner Author

Exact-head verification is complete on 8a694a79029f71a2d702f0d46786cbbb8941e1b1: CI, ci-reusable-pilot, Quality Contract, QuTiP cross-checks, and CodeQL all completed successfully. Verification PR #138 is therefore evidence-only and can be closed. Integration remains intentionally stacked on pr107-fix; do not infer that the base stack itself is merge-ready from this slice-level evidence.

@marcohost33-maker

Copy link
Copy Markdown
Owner Author

Status 2026-09-07 — the head IS matrix-tested, but 13 commits stale

Correcting an assumption that applies to this stack generally: because ci.yml triggers only on push: [main, "claude/**"] and pull_request: branches: [main], a chatgpt/* branch based on pr107-fix triggers neither. Nevertheless this head does carry a real matrix:

head 8a694a79 -> check-runs: 13, test-matrix jobs: 5, non-success: 0

Those runs exist because the now-closed exact-head verification PR #138 carried this same SHA against main. So #134 is not one of the two CI-blind PRs in this portfolio — #131 (0 check-runs) and #147 (3 check-runs, 0 test jobs) are. Worth stating explicitly, because the trigger gap is real and it would be easy to over-generalise it.

Staleness, measured (git rev-list --left-right --count against pr107-fix@bac5fe0d, the head of #121):

missing from this PR : 13     unique to this PR : 2

It declares pr107-fix as its base but forked 13 commits back, so the green matrix above describes a tree that predates the round-24 to round-27 work on #121. No open PR in this repository contains the current pr107-fix head.

No issue band added: this PR targets pr107-fix, not main, so Closes #124 would be structurally inert here and I have not written one.

What Marco has to decide

Rebase onto the current pr107-fix before trusting the rollup — and note that a rebase invalidates the exact-head evidence above, which then has to be re-earned through whatever trigger fix is chosen. Merging #121 first and rebasing this stack onto the result is the ordering that makes that re-earning happen once instead of six times.

Read-only measurement. No push, no merge, no rebase, no re-run, nothing resolved.

…nerate-bca

fix(#125): fail closed on degenerate BCa intervals
Base automatically changed from pr107-fix to main September 8, 2026 14:32
marcohost33-maker and others added 3 commits September 11, 2026 18:19
… representable

The #124 rescaling divides the optimiser residuals by max|y|. SciPy's
finite-difference probes step in absolute parameter units, so for a tiny
scale the rescaled residual, Jacobian or cost leaves float64; least_squares
then raised or failed and the fit was reported unsuccessful. That broke the
#147 contract (an unrepresentable MLE scale withholds only the CI, not the
fit): tests/test_issue135_likelihood_scale.py failed 2/21 after main was
merged in, and _fit_with_model("M0") regressed vs main at 1e-150 and 1e-310.

The rescaled solve now runs under np.errstate(call=...); any FP exception or
non-finite cost/fun/jac sends the iteration to the raw residuals (exactly
main's problem) with a RuntimeWarning. A finite rescaled solve that did not
converge is still a failure. Adds a Jacobian-level regression test.

Full-suite evidence run follows this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186oBqQsooNFqonGmyBd9Mp
Equalita NO-GO for 7ad4829:
1. The residual detector also ran on the RAW residuals; for max|y| >= ~1e155
   their whitening overflows although they are finite, and the private
   _ScaledResidualOverflowError escaped fit_gls_ar1 and _fit_with_model
   (main: success=False). The raw path is now main's residual verbatim.
2. Three detector parts were unpinned. The explicit residual check is gone
   (the division now counts as an FP event itself); one discriminating test
   each for the non-finite arm, exception-without-event, and the
   no-retry-on-nonconverged rule. Own mutation run: 7/7 mutants red.
3. The model is evaluated under the caller's FP policy, so a benign model
   event (sinc 0/0 at t=0) no longer forces the fallback: 1e-40 now fits
   rate 1.3003772913207243 (2.0: 1.3003772913320941) instead of the seed.
4. CHANGELOG corrected.

Full-suite evidence run follows this commit (~13 min).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186oBqQsooNFqonGmyBd9Mp
Comment thread tests/test_gls_amplitude_scale.py Fixed
@marcohost33-maker

Copy link
Copy Markdown
Owner Author

@codex review — please review 7ad4829..9a623e8: GLS residual scaling now falls back to the unscaled path (identical to main) when the scaled solve raises a floating-point event in /scale, whitening or SciPy arithmetic; model evaluation keeps the caller's float behaviour. Focus: amplitude invariance (#124) vs. unrepresentable-scale fits staying selectable (#147/#135), and large amplitudes (>=1e150).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9a623e8e1c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

r_raw = y - model(t, params)
# Counted: an overflow of the division or the whitening IS the
# rescaled problem leaving float64.
return _whiten(r_raw / scale, rho_local)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Normalize residuals before estimating AR(1) rho

When the default n_iters > 1 is used and fitted raw residuals are below roughly 1e-162 or above roughly 1e154, this normalization protects only least_squares; the subsequent ar1_correlation_corrected call still forms unscaled dot products in neff.py, which underflow to zero or overflow to NaN. The next iteration consequently whitens with a correction-floor rho or NaN, so amplitude-equivalent correlated-noise curves can produce different fitted rates or success states. Estimate rho from normalized residuals, or use scale-safe dot products, before the next iteration.

Useful? React with 👍 / 👎.

Comment thread CHANGELOG.md
Comment on lines +66 to +68
- **GLS optimiser residuals are divided by the curve's own scale, and fall
back to the raw residuals where that rescaling is not representable (issue
#124, PR #134).** Rescaling makes SciPy's termination amplitude-invariant

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Record the GLS methodology change in CITATION.cff

This changes the fitting methodology and can change fitted rates and model selectability, but the commit leaves CITATION.cff unchanged, so its pending-next-release methodology record omits this correction. Add a bounded description of the GLS scaling and fallback behavior to the citation metadata.

AGENTS.md reference: AGENTS.md:L121-L123

Useful? React with 👍 / 👎.

marcohost33-maker and others added 2 commits September 11, 2026 20:16
…t form

1. Codex P1 (gls.py:248): rho between Cochrane-Orcutt iterations came from
   ar1_correlation on UNSCALED residuals; its dot products underflow to 0
   below ~1e-162 (corrected rho collapses to the floor 1/(n-3)) and overflow
   to NaN above ~1e154. Measured with n_iters=3 on one AR(1) curve: rho
   0.012987 / 0.44637 / NaN (success=False) at 1e-170 / 1e0 / 1e160.
   ar1_correlation now normalises by an exact power of two (frexp/ldexp);
   rho is bit-identical where the old arithmetic stayed in range (200/200
   random series 1e-100..1e100) and 0.44636512337/0.44636512373/
   0.44636512351 at the three scales after the fix.
2. Codex P1 (CHANGELOG.md:68): CITATION.cff "Also pending for the next cut"
   block for the GLS rescaling, fallback and rho normalisation (DoD #5).
3. CodeQL: test_gls_amplitude_scale.py no longer imports liouscope.fitting.gls
   both as a module and via from-import.

Full-suite evidence run follows this commit (~9-13 min).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186oBqQsooNFqonGmyBd9Mp
…arning

Equalita follow-up on 9a623e8:
(a) Mutation E3 (model under the detector's errstate) stayed green. New
    test: the model raises 0/0 only at the seed, i.e. inside the solve, so
    the post-fit evaluation cannot mask it; with np.errstate(invalid="raise")
    the fit must raise FloatingPointError, as it does on main. Red under E3
    and under "model counted", green with the fix and against main's gls.py.
(b) CHANGELOG: the fallback at max|y| >= ~1e150 applies to a FREE amplitude
    only (fixed amplitude: no fallback, rate 1.3 at 1e150/1e200/1e300), and
    the warning is new relative to main. Free amplitude at 1e-40 still
    returns the seed 0.2 after the rho change (claim re-measured).
(c) The fallback warning is AmplitudeRescalingFallbackWarning, a
    RuntimeWarning subclass, emitted per fit, so -W error users can filter
    it by class. Test red when the category is reverted to RuntimeWarning.

Full-suite evidence run follows this commit (~7-13 min).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186oBqQsooNFqonGmyBd9Mp
@marcohost33-maker

Copy link
Copy Markdown
Owner Author

@codex review — please re-review 9a623e8..28c7db2: AR(1) rho is now estimated from power-of-two-normalised residuals in neff.py (exact, bit-identical where the old computation stayed in range); CITATION.cff pending-methodology block; dedicated AmplitudeRescalingFallbackWarning; test pinning that user models run under the caller's float settings. Known and deliberately not in this push: estimate_neff_geyer/_autocorr has the same scaling class (N_eff = n at 1e-170/1e160).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 28c7db2c40

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# computation stayed in range -- the audit-pinned formula does not move.
peak = float(np.max(np.abs(x)))
if math.isfinite(peak) and peak > 0.0:
x = np.ldexp(x, -math.frexp(peak)[1])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ignore normalization-only underflow in ldexp

When callers enable strict underflow handling (for example, with np.errstate(under="raise")) and finite residuals span more than roughly 1074 binary exponents, this normalization underflows the smallest entries and raises FloatingPointError before computing rho. For example, ar1_correlation([1e150, 1e-200]) now raises under that policy, whereas the previous mean-centered calculation returns -0.5; the exception can also escape a multi-iteration GLS fit. Suppress underflow specifically around this implementation-detail scaling while retaining the caller's policy for other arithmetic.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A fit can return its own seed with success=True when the curve is far below the solver's absolute tolerances

2 participants