Source code for astrophot.fit.scipy_fit

from typing import Sequence, Literal

from scipy.optimize import minimize
import numpy as np

from .base import BaseOptimizer
from .. import config
from ..backend_obj import backend

__all__ = ("ScipyFit",)


[docs] class ScipyFit(BaseOptimizer): """Scipy-based optimizer for fitting models to data using various optimization methods. The optimizer uses the `scipy.optimize.minimize` function to perform the fitting. The Scipy package is widely used and well tested for optimization tasks. It supports a variety of methods, however only a subset allow users to define boundaries for the parameters. This wrapper is only for those methods. :param model: The model to fit, which should be an instance of `Model`. :param initial_state: Initial guess for the model parameters as a 1D Array. :param method: The optimization method to use. Default is "Nelder-Mead", but can be set to any of: "Nelder-Mead", "L-BFGS-B", "TNC", "SLSQP", "Powell", or "trust-constr". :param ndf: Optional number of degrees of freedom for the fit. If not provided, it is calculated as the number of data points minus the number of parameters. """ def __init__( self, model, initial_state: Sequence = None, method: Literal[ "Nelder-Mead", "L-BFGS-B", "TNC", "SLSQP", "Powell", "trust-constr" ] = "Nelder-Mead", likelihood: Literal["gaussian", "poisson"] = "gaussian", ndf=None, **kwargs, ): super().__init__(model, initial_state, **kwargs) self.method = method self.likelihood = likelihood # Degrees of freedom if ndf is None: sub_target = self.model.target[self.model.window] ndf = ( np.prod(sub_target.flatten("data").shape) - backend.sum(sub_target.flatten("mask")).item() ) self.ndf = max(1.0, ndf - len(self.current_state)) else: self.ndf = ndf
[docs] def numpy_bounds(self): """Convert the model's parameter bounds to a format suitable for scipy.optimize.""" bounds = [] for param in self.model.dynamic_params: if param.shape == (): bound = [None, None] if param.valid[0] is not None: bound[0] = backend.to_numpy(param.valid[0]) if param.valid[1] is not None: bound[1] = backend.to_numpy(param.valid[1]) bounds.append(tuple(bound)) else: for i in range(np.prod(param.value.shape)): bound = [None, None] if param.valid[0] is not None: bound[0] = backend.to_numpy(param.valid[0].flatten()[i]) if param.valid[1] is not None: bound[1] = backend.to_numpy(param.valid[1].flatten()[i]) bounds.append(tuple(bound)) return bounds
[docs] def density(self, state: Sequence) -> float: if self.likelihood == "gaussian": return -self.model.gaussian_log_likelihood( backend.as_array(state, dtype=config.DTYPE, device=config.DEVICE) ).item() elif self.likelihood == "poisson": return -self.model.poisson_log_likelihood( backend.as_array(state, dtype=config.DTYPE, device=config.DEVICE) ).item() else: raise ValueError(f"Unknown likelihood type: {self.likelihood}")
[docs] def fit(self): res = minimize( lambda x: self.density(x), self.current_state, method=self.method, bounds=self.numpy_bounds(), options={ "maxiter": self.max_iter, }, ) self.scipy_res = res self.message = self.message + f"success: {res.success}, message: {res.message}" self.current_state = backend.as_array(res.x, dtype=config.DTYPE, device=config.DEVICE) if self.verbose > 0: config.logger.info( f"Final 2NLL/DoF: {2*self.density(res.x)/self.ndf:.6g}. Converged: {self.message}" ) self.model.set_values(self.current_state) return self