Skip to content

Add ndoptimize.least_squares(), a batched Levenberg-Marquardt fitter. - #4

Merged
roytsmart merged 5 commits into
mainfrom
feature/least-squares
Oct 19, 2026
Merged

roytsmart merged 5 commits into
mainfrom
feature/least-squares

Conversation

@roytsmart

@roytsmart roytsmart commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Adds ndoptimize.least_squares(), which fits a model to many independent datasets at once (every spectrum of a spectral cube, for example) with the Levenberg-Marquardt method, compiled and run in parallel with Numba.

Each fit can have its own independent variable, data, noise, initial guess, bounds and Gaussian priors, all broadcast against each other. NaN data points are left out of their fit. The noise of neighboring points may be correlated, as it is in resampled data. That makes the fit generalized least squares, with a banded Cholesky whitening, and gives correct parameter uncertainties. The result is a LeastSquaresResult with the parameters, their covariance, χ², the number of points used, the iteration count and a converged flag.

Algorithm

  • Marquardt damping, A + λ diag(A), with Nielsen's gain-ratio update.
  • Projected bounds: parameters at an active bound are held fixed for that step, as are parameters with no effect on the model.
  • The Newton decrement gᵀA⁻¹g is the convergence test.
  • The noise covariance and its Cholesky factor are stored as a band, so memory grows linearly with the number of points.
  • The covariance is the inverse of the normal matrix. A parameter that nothing constrains gets infinite variance. If the rest of the normal matrix is singular, as in a fit with fewer usable points than parameters, the covariance is NaN.
  • Invalid inputs raise ValueError: bounds with lower > upper, a non-positive prior_sigma, a non-finite prior_mean with a prior, a correlation that isn't finite and positive definite, or a guess that doesn't match the model.

Models

Models are Numba functions that write the model and its Jacobian into output arrays. The new decorator compiles them to one explicit signature and names their parameters:

@ndoptimize.model(parameters=("intercept", "slope"))
def line(x, parameters, f, jacobian):
    ...

It returns an ndoptimize.Model. least_squares() checks the guess against the model's parameter names, since the compiled function doesn't check its indices. Calling a Model directly checks the shapes of its arrays. A model is copied and pickled by reference, like a function, so a copy of an object that holds one keeps the same model. The kernel takes the model as a typed function pointer, so one cached kernel serves every model, including user-defined ones. A new process imports and runs its first fit in about 1 s once the cache is built. cache=False is for models defined outside a file, or ones that read globals computed at run time.

ndoptimize.models provides gaussian, gaussian_linear and two_gaussians_linear.

Evaluation

ndoptimize.evaluate(model, x, parameters) evaluates a model for many sets of parameters at once, in parallel. It broadcasts the batch axes of the points and the parameters like least_squares() does, so the best-fit model of every fit is one call. The named-arrays wrapper, na.ModelFitFunctionArray (sun-data/named-arrays#271), uses it to evaluate a fit at new inputs and for its predictions.

Package setup

  • BSD-3-Clause license, with PEP 639 metadata, plus CITATION.cff.
  • Tests and typing workflows copied from ndfilters, including the Codecov upload, plus a Codecov badge in the README.
  • numpy and numba are the only runtime dependencies (astropy is dropped).
  • A new README and documentation introduction. The least_squares docstring has an example that fits 1,000 noisy lines and plots the centroid errors against a unit normal.

The 2024 work-in-progress stubs were never committed and aren't included.

Testing

47 tests:

  • Against scipy.optimize.least_squares for every combination of correlated noise, priors and bounds: the same minimum to 10⁻³σ and the same covariance.
  • Calibration over 500 noise realizations. With the correlation given, the reported uncertainties match the scatter; without it, they're too small.
  • Broadcasting (including arguments broadcast over only some batch axes), missing data, an unconstrained parameter, a degenerate fit, an exact linear fit, a parameter 10²⁷ times the data's scale, a NaN starting point, a poor initial guess, the iteration cap and invalid inputs.
  • The models against finite differences, shape checks on direct calls, an uncached model defined with exec, and copying and pickling.
  • evaluate against the model evaluated one set at a time, with broadcasting, at the best-fit parameters of noiseless data, and with invalid shapes.

The suite takes 3 s with Numba and 17 s with NUMBA_DISABLE_JIT=1, at 99% coverage. The misses are numerical-safety branches. pyright 1.1.411, ruff and black are clean, and the docs build without warnings.

On the IRIS Fe XII 2×4-binned raster (51,400 spectra), this kernel reproduces the existing numpy GLS fit (99.9% of parameters within 0.05σ) in about 0.1 s, about 600× faster. A synthetic benchmark of the same size (Gaussian plus line, priors, bounds and correlated noise) takes 74 ms on 48 threads.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PZRXiFhGzzrF87vsFg73B9

roytsmart and others added 2 commits October 18, 2026 21:41
`ndoptimize.least_squares()` fits a model to many independent datasets at
once, in parallel with Numba, each with its own independent variable, data,
noise, initial guess, bounds and Gaussian priors. The noise of neighboring
points may be correlated, making the fit generalized least squares through a
banded Cholesky whitening. It uses Marquardt damping with Nielsen's
gain-ratio update, projected bounds, and the Newton decrement as the
convergence test, and returns the parameter covariance.

Models are Numba functions compiled with `ndoptimize.model`, which the kernel
takes as a typed function pointer so that one cached kernel serves every
model. `ndoptimize.models` provides a Gaussian, a Gaussian plus a line, and
two Gaussians plus a line.

Also sets up the package: a BSD-3-Clause license, citation metadata, tests
and typing workflows like `ndfilters`', and the documentation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PZRXiFhGzzrF87vsFg73B9
The repository now has a `CODECOV_TOKEN` secret, so the tests workflow
uploads the coverage report like `ndfilters`' does, and the README gets a
Codecov badge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PZRXiFhGzzrF87vsFg73B9
@codecov

codecov Bot commented Oct 19, 2026

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

- Models name their parameters, `ndoptimize.model(parameters=...)`, and are
  `ndoptimize.Model` objects. `least_squares()` checks the guess against
  them, since a mismatch made the compiled model read and write outside its
  arrays, and calling a model directly checks the shapes of its arrays.
- `ndoptimize.model` takes `cache=False`, for models not defined in a file
  or reading globals computed at run time.
- The ridge in the convergence test is a fraction of each diagonal element
  of the normal matrix rather than 1e-12, which stopped fits of parameters
  with a very different scale than the data at their guess, reported as
  converged. Parameters with no effect on the model are held instead.
- The covariance is NaN where the normal matrix is singular, as in a fit
  with fewer usable points than parameters, rather than finite numbers set
  by a ridge.
- `least_squares()` rejects `lower > upper`, NaN bounds, a non-positive or
  NaN `prior_sigma`, a non-finite `prior_mean` with a prior, a non-finite
  `correlation`, a guess of the wrong shape and a negative
  `max_iterations`. A `prior_mean` without a prior is ignored.
- The noise factor is stored as a band, not a dense matrix per thread, and
  the positive-definite check of `correlation` is a banded Cholesky too.
- Arguments shared by every fit are passed to the kernel as views rather
  than copied. Together with the band, the benchmark of 51,400 fits takes
  74 ms instead of 97 ms, with the same results.
- `iterations` no longer counts a rejected step, an unreachable branch for
  a NaN objective is gone, `LeastSquaresResult` no longer generates a
  broken `__eq__`, and the inputs are annotated as `ArrayLike`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PZRXiFhGzzrF87vsFg73B9
roytsmart and others added 2 commits October 19, 2026 11:37
… parameters at once.

It runs in parallel with Numba, and broadcasts the batch axes of the points
and the parameters like `least_squares()` does, so the best-fit model of
every fit is one call. The named-arrays wrapper of `least_squares()` needs
it to give back the fitted model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PZRXiFhGzzrF87vsFg73B9
A copy of a model is the model itself, and a pickled model unpickles as
the model of the same name in its module. Before, a deep copy or a
pickle round trip made a new `Model`, which isn't equal to the original
since models compare by identity. This broke `copy()` and pickling of
named-arrays function arrays which keep a model as a field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PZRXiFhGzzrF87vsFg73B9
@roytsmart
roytsmart merged commit 61296fd into main Oct 19, 2026
18 checks passed
roytsmart added a commit to sun-data/named-arrays that referenced this pull request Oct 19, 2026
…el`.

Along an axis of `axis_model` where the inputs of a
`ModelFitFunctionArray` are the edges of bins, with one more element than
the outputs, each input which varies along it becomes two inputs of the
model, the lower and upper edges of each bin. This is what a model of the
average over each bin, like `ndoptimize.models.gaussian_binned`, needs,
and what fitting a CTIS reconstruction, whose wavelength coordinates are
bin edges, needs. Before, such a fit failed with a shape mismatch.

Calling the array with new edges evaluates the model on new bins, with
one fewer output than edges, and `predictions` has the shape of the
outputs. An input which varies along a vertex axis outside `axis_model`,
or along two vertex axes, raises an error that says why.

`ndoptimize` is now installed from its `main` branch, since
sun-data/ndoptimize#4 has merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PZRXiFhGzzrF87vsFg73B9
@roytsmart
roytsmart deleted the feature/least-squares branch October 19, 2026 21:15
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.

1 participant