diff --git a/.github/workflows/deploy_release.yaml b/.github/workflows/deploy_release.yaml index 8e6f34b..aa5812b 100644 --- a/.github/workflows/deploy_release.yaml +++ b/.github/workflows/deploy_release.yaml @@ -9,7 +9,7 @@ jobs: pypi: name: Deploy PyPI - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v1 diff --git a/.github/workflows/flake.yaml b/.github/workflows/flake.yaml index 8504291..dc37367 100644 --- a/.github/workflows/flake.yaml +++ b/.github/workflows/flake.yaml @@ -23,4 +23,4 @@ jobs: with: checkName: 'flake8_py3' env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 7b73c35..252423e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -5,11 +5,11 @@ jobs: build: name: Python Version Matrix - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 strategy: matrix: - python-version: ['3.9', '3.10', '3.11'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: @@ -32,4 +32,4 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} file: coverage.xml - fail_ci_if_error: true \ No newline at end of file + fail_ci_if_error: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e0ea39c..4bacfb8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,16 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: -- repo: https://github.com/pycqa/isort - rev: 5.12.0 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.4 hooks: - - id: isort - name: isort (python) - args: ["--profile", "black", "--filter-files"] + # Run the linter + - id: ruff + args: [--fix] + # Run the formatter + - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v6.0.0 hooks: - id: check-added-large-files - id: check-merge-conflict @@ -16,13 +18,4 @@ repos: args: [--allow-multiple-documents] - id: end-of-file-fixer - id: trailing-whitespace -- repo: https://github.com/psf/black - rev: 23.3.0 - hooks: - - id: black - # It is recommended to specify the latest version of Python - # supported by your project here, or alternatively use - # pre-commit's default_language_version, see - # https://pre-commit.com/#top_level-default_language_version - language_version: python3.11 exclude: '^(ThirdParty|models)/' diff --git a/.readthedocs.yaml b/.readthedocs.yaml index a5450ec..79a081f 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -21,9 +21,10 @@ formats: # Optionally set the version of Python and requirements required to build your docs python: - version: 3.8 install: - requirements: docs/requirements.txt build: - image: latest + os: "ubuntu-24.04" + tools: + python: "3.13" diff --git a/CITATION.cff b/CITATION.cff index 59435d3..75330ad 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -24,4 +24,4 @@ preferred-citation: journal: "bioRxiv" month: 5 title: "Fides: Reliable Trust-Region Optimization for Parameter Estimation of Ordinary Differential Equation Models" - year: 2021 \ No newline at end of file + year: 2021 diff --git a/README.md b/README.md index e8067e6..dc29be7 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ reliability. Fides can be installed via `pip install fides`. Further documentation is available at [Read the Docs](https://fides-optimizer.readthedocs.io/). - - + + ## Features @@ -31,4 +31,3 @@ Fides can be installed via `pip install fides`. Further documentation is * BFGS, DFP, SR1, Broyden (good and bad) and Broyden class iterative Hessian Approximation schemes * SSM, TSSM, FX, GNSBFGS and custom hybrid Hessian Approximations schemes - diff --git a/docs/_templates/autosummary/module.rst b/docs/_templates/autosummary/module.rst index 2b01f5d..abc1610 100644 --- a/docs/_templates/autosummary/module.rst +++ b/docs/_templates/autosummary/module.rst @@ -40,4 +40,4 @@ .. autofunction:: {{ function }} {% endfor %} -{% endif %} \ No newline at end of file +{% endif %} diff --git a/docs/about.rst b/docs/about.rst index efb1fd8..9984639 100644 --- a/docs/about.rst +++ b/docs/about.rst @@ -19,4 +19,3 @@ Features * Recursive Reflective and Truncated constraint management * Full and 2D subproblem solution solvers * BFGS, DFP and SR1 Hessian Approximations - diff --git a/docs/api.rst b/docs/api.rst index 9ba9568..9bd891a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -13,6 +13,3 @@ Fides Python API fides.hessian_approximation fides.logging fides.constants - - - diff --git a/docs/requirements.txt b/docs/requirements.txt index 6f87875..35ebb69 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ # Requirements just for building the docs (used by readthedocs, but also # helpful for building docs locally). -sphinx>=1.4 +sphinx>=7.2.0 numpy>=1.19.2 scipy>=1.5.2 h5py>=3.5.0 @@ -10,4 +10,4 @@ recommonmark>=0.6.0 sphinx_rtd_theme>=0.4.3 sphinx-autodoc-typehints>=1.10.3 sphinxcontrib-napoleon -pygments==2.4.1 \ No newline at end of file +pygments>=2.4.1 diff --git a/examples/Minimal.ipynb b/examples/Minimal.ipynb index 2de272a..aecf510 100644 --- a/examples/Minimal.ipynb +++ b/examples/Minimal.ipynb @@ -48,9 +48,10 @@ "metadata": {}, "outputs": [], "source": [ - "import fides\n", "import numpy as np\n", "\n", + "import fides\n", + "\n", "opt = fides.Optimizer(\n", " obj, ub=np.asarray([np.inf, 1.5]), lb=np.asarray([-1.5, -np.inf])\n", ")" diff --git a/fides/constants.py b/fides/constants.py index 0b24e76..b5161be 100644 --- a/fides/constants.py +++ b/fides/constants.py @@ -8,7 +8,6 @@ import enum from numbers import Integral, Real from pathlib import PosixPath, WindowsPath -from typing import Dict import numpy as np @@ -43,6 +42,7 @@ class SubSpaceDim(str, enum.Enum): Defines the possible choices of subspace dimension in which the subproblem will be solved. """ + TWO = '2D' #: Two dimensional Newton/Gradient subspace FULL = 'full' #: Full :math:`\mathbb{R}^n` STEIHAUG = 'scg' #: CG subspace via Steihaug's method @@ -100,7 +100,7 @@ class ExitFlag(int, enum.Enum): GTOL = 3 #: Converged according to gradient norm -def validate_options(options: Dict): +def validate_options(options: dict): """Check if the chosen options are valid""" expected_types = { Options.MAXITER: Integral, @@ -124,7 +124,9 @@ def validate_options(options: Dict): try: option = Options(option_key) except ValueError: - raise ValueError(f'{option_key} is not a valid options field.') + raise ValueError( + f'{option_key} is not a valid options field.' + ) from None if option_key is Options.SUBSPACE_DIM: option_value = SubSpaceDim(option_value) diff --git a/fides/hessian_approximation.py b/fides/hessian_approximation.py index 9f3fa43..bb421fd 100644 --- a/fides/hessian_approximation.py +++ b/fides/hessian_approximation.py @@ -6,9 +6,7 @@ is computationally too demanding. """ - import warnings -from typing import Optional import numpy as np from numpy.linalg import norm @@ -19,7 +17,7 @@ class HessianApproximation: Abstract class from which Hessian update strategies should subclass """ - def __init__(self, init_with_hess: Optional[bool] = False): + def __init__(self, init_with_hess: bool | None = False): """ Create a Hessian update strategy instance @@ -31,7 +29,7 @@ def __init__(self, init_with_hess: Optional[bool] = False): self._diff: np.ndarray = np.empty(0) self.init_with_hess = init_with_hess - def init_mat(self, dim: int, hess: Optional[np.ndarray] = None) -> None: + def init_mat(self, dim: int, hess: np.ndarray | None = None) -> None: """ Initializes this approximation instance and checks the dimensionality @@ -148,8 +146,8 @@ class Broyden(IterativeHessianApproximation): def __init__( self, phi: float, - init_with_hess: Optional[bool] = False, - enforce_curv_cond: Optional[bool] = True, + init_with_hess: bool | None = False, + enforce_curv_cond: bool | None = True, ): self.phi = phi self.enforce_curv_cond = enforce_curv_cond @@ -157,9 +155,10 @@ def __init__( warnings.warn( 'Setting phi to values outside the interval [0, 1]' 'will not guarantee that positive definiteness is ' - 'preserved during updating.' + 'preserved during updating.', + stacklevel=2, ) - super(Broyden, self).__init__(init_with_hess) + super().__init__(init_with_hess) def _compute_update(self, s: np.ndarray, y: np.ndarray): self._diff = broyden_class_update( @@ -177,10 +176,10 @@ class BFGS(Broyden): def __init__( self, - init_with_hess: Optional[bool] = False, - enforce_curv_cond: Optional[bool] = True, + init_with_hess: bool | None = False, + enforce_curv_cond: bool | None = True, ): - super(BFGS, self).__init__( + super().__init__( phi=0.0, init_with_hess=init_with_hess, enforce_curv_cond=enforce_curv_cond, @@ -197,10 +196,10 @@ class DFP(Broyden): def __init__( self, - init_with_hess: Optional[bool] = False, - enforce_curv_cond: Optional[bool] = True, + init_with_hess: bool | None = False, + enforce_curv_cond: bool | None = True, ): - super(DFP, self).__init__( + super().__init__( phi=1.0, init_with_hess=init_with_hess, enforce_curv_cond=enforce_curv_cond, @@ -263,7 +262,7 @@ def update(self, s: np.ndarray, y: np.ndarray) -> None: class HybridApproximation(HessianApproximation): - def __init__(self, happ: IterativeHessianApproximation = BFGS()): + def __init__(self, happ: IterativeHessianApproximation | None = None): """ Create a Hybrid Hessian update strategy that switches between an iterative approximation and a dynamic approximation @@ -271,12 +270,12 @@ def __init__(self, happ: IterativeHessianApproximation = BFGS()): :param happ: Iterative Hessian Approximation """ - self.hessian_update = happ - super(HybridApproximation, self).__init__() + self.hessian_update = happ if happ is not None else BFGS() + super().__init__() - def init_mat(self, dim: int, hess: Optional[np.ndarray] = None): + def init_mat(self, dim: int, hess: np.ndarray | None = None): self.hessian_update.init_mat(dim, hess) - super(HybridApproximation, self).init_mat(dim, hess) + super().init_mat(dim, hess) def requires_hess(self): return True # pragma: no cover @@ -285,18 +284,15 @@ def requires_hess(self): class HybridSwitchApproximation(HybridApproximation): def _switched_update(self, s: np.ndarray, y: np.ndarray, hess: np.ndarray): self.hessian_update.update(s, y) - if self._switched: - new_hess = self.hessian_update.get_mat() - else: - new_hess = hess + new_hess = self.hessian_update.get_mat() if self._switched else hess self._update_hess_and_store_diff(new_hess) class HybridFixed(HybridSwitchApproximation): def __init__( self, - happ: IterativeHessianApproximation = BFGS(), - switch_iteration: Optional[int] = 20, + happ: IterativeHessianApproximation | None = None, + switch_iteration: int | None = 20, ): """ Switch from a dynamic approximation to the user provided iterative @@ -312,7 +308,7 @@ def __init__( switch occurs. """ self.switch_iteration: int = switch_iteration - super(HybridFixed, self).__init__(happ) + super().__init__(happ) self._switched = False def update( @@ -330,8 +326,8 @@ def update( class HybridFraction(HybridSwitchApproximation): def __init__( self, - happ: IterativeHessianApproximation = BFGS(), - switch_threshold: Optional[float] = 0.8, + happ: IterativeHessianApproximation | None = None, + switch_threshold: float | None = 0.8, ): """ Switch from a dynamic approximation to the user provided iterative @@ -350,7 +346,7 @@ def __init__( of approximation. """ self.switch_threshold: float = switch_threshold - super(HybridFraction, self).__init__(happ) + super().__init__(happ) self._switched = False def update( @@ -369,8 +365,8 @@ def update( class FX(HybridApproximation): def __init__( self, - happ: IterativeHessianApproximation = BFGS(), - hybrid_tol: Optional[float] = 0.2, + happ: IterativeHessianApproximation | None = None, + hybrid_tol: float | None = 0.2, ): r""" Hybrid method HY2 as introduced by @@ -386,7 +382,7 @@ def __init__( switch tolerance :math:`\epsilon` """ self.hybrid_tol = hybrid_tol - super(FX, self).__init__(happ) + super().__init__(happ) def update( self, @@ -434,8 +430,8 @@ def requires_resfun(self) -> bool: class StructuredApproximation(HessianApproximation): def __init__( self, - phi: Optional[float] = 0.0, - enforce_curv_cond: Optional[bool] = True, + phi: float | None = 0.0, + enforce_curv_cond: bool | None = True, ): """ This is the base class for structured secant methods (SSM). SSMs @@ -461,14 +457,15 @@ def __init__( warnings.warn( 'Setting phi to values outside the interval [0, 1]' 'will not guarantee that positive definiteness is ' - 'preserved during updating.' + 'preserved during updating.', + stacklevel=2, ) - super(StructuredApproximation, self).__init__(init_with_hess=True) + super().__init__(init_with_hess=True) - def init_mat(self, dim: int, hess: Optional[np.ndarray] = None): + def init_mat(self, dim: int, hess: np.ndarray | None = None): self.A = np.eye(dim) * np.spacing(1) self._structured_diff = np.zeros_like(self.A) - super(StructuredApproximation, self).init_mat(dim, hess) + super().init_mat(dim, hess) def update( self, @@ -524,12 +521,12 @@ def update( yb: np.ndarray, ) -> None: # B^S = A + C(x_+) - Bs = hess + self.A + bs = hess + self.A # y^S = y^# + C(x_+)*s ys = yb + hess.dot(s) # Equation (13) self._structured_diff = broyden_class_update( - ys, s, Bs, phi=self.phi, enforce_curv_cond=self.enforce_curv_cond + ys, s, bs, phi=self.phi, enforce_curv_cond=self.enforce_curv_cond ) self.A += self._structured_diff # B_+ = C(x_+) + A + BFGS update A (=A_+) @@ -554,12 +551,12 @@ def update( yb: np.ndarray, ) -> None: # Equation (2.7) - Bs = hess + norm(r) * self.A + bs = hess + norm(r) * self.A # Equation (2.6) ys = hess.dot(s) + yb # Equation (2.10) self._structured_diff = broyden_class_update( - ys, s, Bs, phi=self.phi, enforce_curv_cond=self.enforce_curv_cond + ys, s, bs, phi=self.phi, enforce_curv_cond=self.enforce_curv_cond ) / norm(r) self.A += self._structured_diff # Equation (2.9) @@ -582,9 +579,7 @@ def __init__( switching tolerance that controls switching between update methods """ self.hybrid_tol: float = hybrid_tol - super(GNSBFGS, self).__init__( - phi=0.0, enforce_curv_cond=enforce_curv_cond - ) + super().__init__(phi=0.0, enforce_curv_cond=enforce_curv_cond) def update( self, diff --git a/fides/minimize.py b/fides/minimize.py index 6b9bae7..d7447ba 100644 --- a/fides/minimize.py +++ b/fides/minimize.py @@ -8,7 +8,7 @@ import time import uuid from collections import defaultdict -from typing import Callable, Dict, List, Optional, Tuple, Union +from collections.abc import Callable import h5py import numpy as np @@ -88,9 +88,9 @@ def __init__( fval: float, grad: np.ndarray, x: np.ndarray, - hess: Optional[np.ndarray] = None, - res: Optional[np.ndarray] = None, - sres: Optional[np.ndarray] = None, + hess: np.ndarray | None = None, + res: np.ndarray | None = None, + sres: np.ndarray | None = None, ): self.fval = fval self.grad = grad @@ -102,12 +102,12 @@ def __init__( def checkdims(self): if not np.isscalar(self.fval): raise ValueError( - 'Provided objective function must return a ' 'scalar!' + 'Provided objective function must return a scalar!' ) if np.isscalar(self.grad): raise ValueError( - 'Provided objective function gradient must ' 'return a vector!' + 'Provided objective function gradient must return a vector!' ) if not self.grad.ndim == 1: @@ -129,7 +129,7 @@ def checkdims(self): if np.isscalar(self.hess): raise ValueError( - 'Provided objective function Hessian must ' 'return a matrix!' + 'Provided objective function Hessian must return a matrix!' ) if not self.hess.ndim == 2: @@ -188,10 +188,10 @@ def __init__( fun: Callable, ub: np.ndarray, lb: np.ndarray, - verbose: Optional[int] = logging.INFO, - options: Optional[Dict] = None, - funargs: Optional[Dict] = None, - hessian_update: Optional[HessianApproximation] = None, + verbose: int | None = logging.INFO, + options: dict | None = None, + funargs: dict | None = None, + hessian_update: HessianApproximation | None = None, resfun: bool = False, ): """ @@ -258,7 +258,7 @@ def __init__( validate_options(options) - self.options: Dict = options + self.options: dict = options if ( self.get_option(Options.SUBSPACE_DIM) == SubSpaceDim.STEIHAUG @@ -266,7 +266,7 @@ def __init__( == StepBackStrategy.REFINE ): raise ValueError( - 'Selected base step is not compatible with ' 'refinement.' + 'Selected base step is not compatible with refinement.' ) self.delta: float = self.get_option(Options.DELTA_INIT) @@ -282,7 +282,7 @@ def __init__( self.fval_min = self.fval self.grad_min = self.grad - self.hessian_update: Union[HessianApproximation, None] = hessian_update + self.hessian_update: HessianApproximation | None = hessian_update self.iterations_since_tr_update: int = 0 self.n_intermediate_tr_radius: int = 0 @@ -291,13 +291,11 @@ def __init__( self.converged: bool = False self.exitflag: ExitFlag = ExitFlag.DID_NOT_RUN self.verbose: int = verbose - self.logger: Union[logging.Logger, None] = None - self.history: Dict[str, List[Union[float, int, bool]]] = defaultdict( - list - ) + self.logger: logging.Logger | None = None + self.history: dict[str, list[float | int | bool]] = defaultdict(list) self.start_id: str = '' - def _reset(self, start_id: Optional[str] = None): + def _reset(self, start_id: str | None = None): self.starttime = time.time() self.iteration = 0 self.iterations_since_tr_update = 0 @@ -312,7 +310,7 @@ def _reset(self, start_id: Optional[str] = None): self.start_id = start_id self.history = defaultdict(list) - def minimize(self, x0: np.ndarray, start_id: Optional[str] = None): + def minimize(self, x0: np.ndarray, start_id: str | None = None): """ Minimize the objective function using the interior trust-region reflective algorithm described by [ColemanLi1994] and [ColemanLi1996] @@ -679,13 +677,15 @@ def check_continue(self) -> bool: return True - def make_non_degenerate(self, eps=1e2 * np.spacing(1)) -> None: + def make_non_degenerate(self, eps=None) -> None: """ Ensures that x is non-degenerate, this should only be necessary for initial points. :param eps: degeneracy threshold """ + if eps is None: + eps = 1e2 * np.spacing(1) if ( np.min(np.abs(self.ub - self.x)) < eps or np.min(np.abs(self.x - self.lb)) < eps @@ -695,7 +695,7 @@ def make_non_degenerate(self, eps=1e2 * np.spacing(1)) -> None: self.x[upperi] = self.x[upperi] - eps self.x[loweri] = self.x[loweri] + eps - def get_affine_scaling(self) -> Tuple[np.ndarray, np.ndarray]: + def get_affine_scaling(self) -> tuple[np.ndarray, np.ndarray]: """ Computes the vector v and dv, the diagonal of its Jacobian. For the definition of v, see Definition 2 in [Coleman-Li1994] @@ -762,8 +762,8 @@ def track_history(self, accepted: bool, step: Step, funout: Funout): normg = norm(self.grad) min_ev_hess, max_ev_hess = _min_max_evs(self.hess) - min_ev_hess_update, max_ev_hess_update = np.NaN, np.NaN - min_ev_hess_supdate, max_ev_hess_supdate = np.NaN, np.NaN + min_ev_hess_update, max_ev_hess_update = np.nan, np.nan + min_ev_hess_supdate, max_ev_hess_supdate = np.nan, np.nan if self.hessian_update: if accepted: min_ev_hess_update, max_ev_hess_update = _min_max_evs( @@ -843,7 +843,7 @@ def log_header(self): f'|tr radius| ||g|| | ||step||| step|acc' ) - def check_finite(self, funout: Optional[Funout] = None): + def check_finite(self, funout: Funout | None = None): """ Checks whether objective function value, gradient and Hessian ( approximation) have finite values and optimization can continue. @@ -868,8 +868,7 @@ def check_finite(self, funout: Optional[Funout] = None): if not np.isfinite(fval): self.exitflag = ExitFlag.NOT_FINITE raise RuntimeError( - f'Encountered non-finite function {self.fval} ' - f'value {pointstr}' + f'Encountered non-finite function {self.fval} value {pointstr}' ) if not np.isfinite(grad).all(): @@ -893,7 +892,7 @@ def check_finite(self, funout: Optional[Funout] = None): f'{pointstr}' ) - def check_in_bounds(self, x: Optional[np.ndarray] = None): + def check_in_bounds(self, x: np.ndarray | None = None): """ Checks whether the current optimization variables are all within the specified boundaries @@ -910,7 +909,10 @@ def check_in_bounds(self, x: Optional[np.ndarray] = None): pointstr = f'at iteration {self.iteration}.' for ref, sign, name in zip( - [self.ub, self.lb], [-1.0, 1.0], ['upper bounds', 'lower bounds'] + [self.ub, self.lb], + [-1.0, 1.0], + ['upper bounds', 'lower bounds'], + strict=True, ): diff = sign * (ref - x) if not np.all(diff <= 0): diff --git a/fides/stepback.py b/fides/stepback.py index 3b31fc8..438cdff 100644 --- a/fides/stepback.py +++ b/fides/stepback.py @@ -6,8 +6,6 @@ had to be truncated due to non-compliance with boundary constraints. """ -from typing import List - import numpy as np from scipy.sparse import csc_matrix @@ -25,7 +23,7 @@ def stepback_reflect( theta: float, ub: np.ndarray, lb: np.ndarray, -) -> List[Step]: +) -> list[Step]: """ Compute new proposal steps according to a reflection strategy. @@ -60,7 +58,7 @@ def stepback_reflect( ) rtr_step.calculate() steps = [rtr_step] - for ireflection in range(len(x) - 1): + for _ireflection in range(len(x) - 1): if rtr_step.alpha == 1.0: break # recursively add more reflections @@ -85,7 +83,7 @@ def stepback_truncate( theta: float, ub: np.ndarray, lb: np.ndarray, -) -> List[Step]: +) -> list[Step]: """ Compute new proposal steps according to a truncation strategy. diff --git a/fides/steps.py b/fides/steps.py index 0206de2..0ee9f00 100644 --- a/fides/steps.py +++ b/fides/steps.py @@ -5,9 +5,7 @@ -reflective) step proposals """ - from logging import Logger -from typing import Union import numpy as np import scipy.linalg as linalg @@ -113,13 +111,13 @@ def __init__( """ self.x: np.ndarray = x - self.s: Union[np.ndarray, None] = None - self.sc: Union[np.ndarray, None] = None - self.ss: Union[np.ndarray, None] = None + self.s: np.ndarray | None = None + self.sc: np.ndarray | None = None + self.ss: np.ndarray | None = None - self.og_s: Union[np.ndarray, None] = None - self.og_sc: Union[np.ndarray, None] = None - self.og_ss: Union[np.ndarray, None] = None + self.og_s: np.ndarray | None = None + self.og_sc: np.ndarray | None = None + self.og_ss: np.ndarray | None = None self.sg: np.ndarray = sg.copy() self.scaling: csc_matrix = scaling.copy() @@ -142,9 +140,9 @@ def __init__( scaling * hess * scaling + g_dscaling ) - self.cg: Union[np.ndarray, None] = None - self.chess: Union[np.ndarray, None] = None - self.subspace: Union[np.ndarray, None] = None + self.cg: np.ndarray | None = None + self.chess: np.ndarray | None = None + self.subspace: np.ndarray | None = None self.s0: np.ndarray = np.zeros(sg.shape) self.ss0: np.ndarray = np.zeros(sg.shape) diff --git a/fides/subproblem.py b/fides/subproblem.py index 1694c04..692be3e 100644 --- a/fides/subproblem.py +++ b/fides/subproblem.py @@ -7,7 +7,6 @@ import logging import math -from typing import Tuple, Union import numpy as np from numpy.linalg import norm @@ -15,27 +14,27 @@ from scipy.optimize import brentq, newton -def quadratic_form(Q: np.ndarray, p: np.ndarray, x: np.ndarray) -> float: +def quadratic_form(q: np.ndarray, p: np.ndarray, x: np.ndarray) -> float: """ Computes the quadratic form :math:`x^TQx + x^Tp` - :param Q: Matrix + :param q: Matrix :param p: Vector :param x: Input :return: Value of form """ - return 0.5 * x.T.dot(Q).dot(x) + p.T.dot(x) + return 0.5 * x.T.dot(q).dot(x) + p.T.dot(x) def solve_1d_trust_region_subproblem( - B: np.ndarray, g: np.ndarray, s: np.ndarray, delta: float, s0: np.ndarray + b: np.ndarray, g: np.ndarray, s: np.ndarray, delta: float, s0: np.ndarray ) -> np.ndarray: """ Solves the special case of a one-dimensional subproblem - :param B: + :param b: Hessian of the quadratic subproblem :param g: Gradient of the quadratic subproblem @@ -56,38 +55,38 @@ def solve_1d_trust_region_subproblem( if np.array_equal(s, np.zeros_like(s)): return np.zeros((1,)) - a = 0.5 * B.dot(s).dot(s) + a = 0.5 * b.dot(s).dot(s) if not isinstance(a, float): a = a[0, 0] - b = s.T.dot(B.dot(s0) + g) + b_coef = s.T.dot(b.dot(s0) + g) - minq = -b / (2 * a) + minq = -b_coef / (2 * a) if a > 0 and norm(minq * s + s0) <= delta: # interior solution tau = minq else: - tau = get_1d_trust_region_boundary_solution(B, g, s, s0, delta) + tau = get_1d_trust_region_boundary_solution(b, g, s, s0, delta) return tau * np.ones((1,)) -def get_1d_trust_region_boundary_solution(B, g, s, s0, delta): +def get_1d_trust_region_boundary_solution(b, g, s, s0, delta): a = np.dot(s, s) - b = 2 * np.dot(s0, s) + b_coef = 2 * np.dot(s0, s) c = np.dot(s0, s0) - delta**2 - aux = b + math.copysign(np.sqrt(b**2 - 4 * a * c), b) + aux = b_coef + math.copysign(np.sqrt(b_coef**2 - 4 * a * c), b_coef) ts = [-aux / (2 * a), -2 * c / aux] - qs = [quadratic_form(B, g, s0 + t * s) for t in ts] + qs = [quadratic_form(b, g, s0 + t * s) for t in ts] return ts[np.argmin(qs)] def solve_nd_trust_region_subproblem( - B: np.ndarray, + b: np.ndarray, g: np.ndarray, delta: float, - logger: Union[logging.Logger, None] = None, -) -> Tuple[np.ndarray, str]: + logger: logging.Logger | None = None, +) -> tuple[np.ndarray, str]: r""" This function exactly solves the n-dimensional subproblem. @@ -136,7 +135,7 @@ def solve_nd_trust_region_subproblem( # instead of a cholesky factorization, we go with an eigenvalue # decomposition, which works pretty well for n=2 - eigvals, eigvecs = linalg.eig(B) + eigvals, eigvecs = linalg.eig(b) eigvals = np.real(eigvals) eigvecs = np.real(eigvecs) w = -eigvecs.T.dot(g) diff --git a/fides/trust_region.py b/fides/trust_region.py index c9fbe6a..f4af887 100644 --- a/fides/trust_region.py +++ b/fides/trust_region.py @@ -167,7 +167,7 @@ def trust_region( f'{step.type + rcountstr}: [qp:' f' {step.qpval:.2E}, ' f'a: {step.alpha:.2E}]' - for rcountstr, step in zip(rcountstrs, steps) + for rcountstr, step in zip(rcountstrs, steps, strict=True) ] ) ) diff --git a/fides/version.py b/fides/version.py index 894cebc..a62e53d 100644 --- a/fides/version.py +++ b/fides/version.py @@ -1 +1 @@ -__version__ = "0.7.8" +__version__ = '0.7.8' diff --git a/pyproject.toml b/pyproject.toml index cc128b7..bdc8059 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,79 @@ requires = [ ] build-backend = "setuptools.build_meta" -[tool.black] +[project] +name = "fides" +dynamic = ["version"] +description = "python-based Trust Region Optimization toolbox" +readme = {file = "README.md", content-type = "text/markdown"} +requires-python = ">=3.9" +license = {text = "BSD-3-Clause"} +authors = [ + {name = "The fides developers", email = "frohlichfab@gmail.com"} +] +maintainers = [ + {name = "Fabian Fröhlich", email = "frohlichfab@gmail.com"} +] +keywords = ["optimization", "trust-region", "systems biology"] +classifiers = [ + "Development Status :: 4 - Beta", + "Topic :: Software Development :: Libraries", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] +dependencies = [ + "numpy>=1.19.2", + "scipy>=1.5.2", + "h5py>=3.5.0", +] + +[project.urls] +Homepage = "https://github.com/fides-dev/fides" +Download = "https://github.com/fides-dev/fides/releases" +"Bug Tracker" = "https://github.com/fides-dev/fides/issues" +Documentation = "https://fides-optimizer.readthedocs.io/" +Changelog = "https://github.com/fides-dev/fides/releases" + +[project.optional-dependencies] +test = [ + "pytest>=5.4.2", + "pytest-cov>=4.0.0", + "flake8>=3.7.2", +] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["fides*"] + +[tool.setuptools.dynamic] +version = {attr = "fides.version.__version__"} + +[tool.ruff] line-length = 79 -target-version = ['py311'] -skip-string-normalization = true +target-version = "py313" + +[tool.ruff.lint] +# Enable pycodestyle (E, W), pyflakes (F), isort (I), and other useful rules +select = ["E", "W", "F", "I", "N", "UP", "B", "A", "C4", "PT", "SIM"] +ignore = ["PT011"] # pytest.raises() without match parameter is acceptable + +[tool.ruff.lint.per-file-ignores] +"fides/subproblem.py" = ["W605"] + +[tool.ruff.lint.isort] +# Use black-compatible import sorting +force-single-line = false +force-wrap-aliases = false -[tool.isort] -profile = "black" -line_length = 79 -multi_line_output = 3 +[tool.ruff.format] +# Use single quotes to match black's skip-string-normalization +quote-style = "single" +indent-style = "space" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 75e26a4..0000000 --- a/setup.cfg +++ /dev/null @@ -1,69 +0,0 @@ -[metadata] -name = fides -version = attr: fides.version.__version__ -description = python-based Trust Region Optimization toolbox -long_description = file: README.md -long_description_content_type = text/markdown - -url = https://github.com/fides-dev/fides -download_url = https://github.com/fides-dev/fides/releases -project_urls = - Bug Tracker = https://github.com/fides-dev/fides/issues - Documentation = https://fides-optimizer.readthedocs.io/ - Changelog = https://github.com/fides-dev/fides/releases - -author = The fides developers -author_email = frohlichfab@gmail.com -maintainer = Fabian Fröhlich -maintainer_email = frohlichfab@gmail.com - -license = BSD-3-Clause -license_files = LICENSE - -classifiers = - Development Status :: 4 - Beta - Topic :: Software Development :: Libraries - Intended Audience :: Science/Research - License :: OSI Approved :: BSD License - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.9 -keywords = - optimization - trust-region - systems biology - -[options] -install_requires = - numpy>=1.19.2 - scipy>=1.5.2 - h5py>=3.5.0 - -python_requires = >=3.9 -include_package_data = True - -# Where is my code -packages = find: - -[options.packages.find] -include = fides* - -[options.extras_require] -test = - pytest>=5.4.2 - pytest-cov>=4.0.0 - flake8>=3.7.2 - -[bdist_wheel] -# Requires Python 3 -universal = False - -[flake8] -exclude = - docs, - -per-file-ignores = - fides/subproblem.py:W605 \ No newline at end of file diff --git a/tests/test_hessian_approximation.py b/tests/test_hessian_approximation.py index f2150fa..4f38721 100644 --- a/tests/test_hessian_approximation.py +++ b/tests/test_hessian_approximation.py @@ -5,13 +5,13 @@ def test_wrong_dim(): + h = BFGS(init_with_hess=True) with pytest.raises(ValueError): - h = BFGS(init_with_hess=True) h.init_mat(dim=3, hess=np.ones((2, 2))) + h = BFGS() + h.init_mat(dim=3) with pytest.raises(ValueError): - h = BFGS() - h.init_mat(dim=3) h.set_mat(np.ones((2, 2))) diff --git a/tests/test_minimize.py b/tests/test_minimize.py index 8acdffc..4bad457 100644 --- a/tests/test_minimize.py +++ b/tests/test_minimize.py @@ -154,7 +154,7 @@ def unbounded_and_init(): @pytest.mark.parametrize( - "stepback", + 'stepback', [ StepBackStrategy.REFLECT, StepBackStrategy.SINGLE_REFLECT, @@ -164,10 +164,10 @@ def unbounded_and_init(): ], ) @pytest.mark.parametrize( - "subspace_dim", [SubSpaceDim.STEIHAUG, SubSpaceDim.FULL, SubSpaceDim.TWO] + 'subspace_dim', [SubSpaceDim.STEIHAUG, SubSpaceDim.FULL, SubSpaceDim.TWO] ) @pytest.mark.parametrize( - "bounds_and_init", + 'bounds_and_init', [ unbounded_and_init(), finite_bounds_include_optimum(), @@ -175,7 +175,7 @@ def unbounded_and_init(): ], ) @pytest.mark.parametrize( - "fun, happ", + ('fun', 'happ'), [ (rosenboth, None), # 0 (rosengrad, SR1()), # 1 @@ -213,21 +213,21 @@ def test_minimize_hess_approx( if (x0 == 0).all() and fun is fletcher: x0 += 1 - kwargs = dict( - fun=fun, - ub=ub, - lb=lb, - verbose=logging.WARNING, - hessian_update=happ if happ is not None else None, - options={ + kwargs = { + 'fun': fun, + 'ub': ub, + 'lb': lb, + 'verbose': logging.WARNING, + 'hessian_update': happ if happ is not None else None, + 'options': { fides.Options.FATOL: 0, fides.Options.FRTOL: 1e-12 if fun is fletcher else 1e-8, fides.Options.SUBSPACE_DIM: subspace_dim, fides.Options.STEPBACK_STRAT: stepback, fides.Options.MAXITER: 2e2, }, - resfun=happ.requires_resfun if happ is not None else False, - ) + 'resfun': happ.requires_resfun if happ is not None else False, + } if not ( subspace_dim == fides.SubSpaceDim.STEIHAUG and stepback == fides.StepBackStrategy.REFINE @@ -240,10 +240,7 @@ def test_minimize_hess_approx( opt.minimize(x0) assert opt.fval >= opt.fval_min - if fun is fletcher: - xsol = [0, 0] - else: - xsol = [1, 1] + xsol = [0, 0] if fun is fletcher else [1, 1] if opt.fval == opt.fval_min: assert np.isclose(opt.grad, opt.grad_min).all() @@ -260,9 +257,9 @@ def test_minimize_hess_approx( @pytest.mark.parametrize( - "stepback", [StepBackStrategy.REFLECT, StepBackStrategy.TRUNCATE] + 'stepback', [StepBackStrategy.REFLECT, StepBackStrategy.TRUNCATE] ) -@pytest.mark.parametrize("subspace_dim", [SubSpaceDim.FULL, SubSpaceDim.TWO]) +@pytest.mark.parametrize('subspace_dim', [SubSpaceDim.FULL, SubSpaceDim.TWO]) def test_multistart(subspace_dim, stepback): lb, ub, x0 = finite_bounds_exlude_optimum() fun = rosenboth @@ -304,13 +301,13 @@ def test_multistart_randomfail(): ) for _ in range(int(1e2)): + x0 = np.random.random(x0.shape) * (ub - lb) + lb with pytest.raises(RuntimeError): - x0 = np.random.random(x0.shape) * (ub - lb) + lb opt.minimize(x0) @pytest.mark.parametrize( - "fun", + 'fun', [ rosennonsquarh, rosenwrongf, @@ -331,8 +328,8 @@ def test_wrong_dim(fun): options={fides.Options.FATOL: 0, fides.Options.MAXITER: 1e3}, ) + x0 = np.random.random(x0.shape) * (ub - lb) + lb with pytest.raises(ValueError): - x0 = np.random.random(x0.shape) * (ub - lb) + lb opt.minimize(x0) diff --git a/tests/test_subproblem.py b/tests/test_subproblem.py index d6296a8..44f38f3 100644 --- a/tests/test_subproblem.py +++ b/tests/test_subproblem.py @@ -2,7 +2,7 @@ import pytest from numpy.linalg import norm from scipy import linalg -from scipy.spatial.transform import Rotation as R +from scipy.spatial.transform import Rotation from fides.steps import normalize from fides.subproblem import ( @@ -13,7 +13,7 @@ @pytest.fixture def subproblem(): - B = np.array( + b = np.array( [ [1.0, 0.0, 0.0], [0.0, 3.0, 0.0], @@ -22,47 +22,47 @@ def subproblem(): ) g = np.array([1.0, 1.0, 1.0]) - return {'B': B, 'g': g} + return {'B': b, 'g': g} -def quad(s, B, g): - return 0.5 * s.T.dot(B.dot(s)) + s.T.dot(g) +def quad(s, b, g): + return 0.5 * s.T.dot(b.dot(s)) + s.T.dot(g) -def is_local_quad_min(s, B, g): +def is_local_quad_min(s, b, g): """ - make local perturbations to verify s is a local minimum of quad(s, B, g) + make local perturbations to verify s is a local minimum of quad(s, b, g) """ - _, ev = linalg.eig(B) + _, ev = linalg.eig(b) perturbs = np.array( [ - quad(s + eps * ev[:, iv], B, g) + quad(s + eps * ev[:, iv], b, g) for iv in range(ev.shape[1]) for eps in [1e-2, -1e-2] ] ) - return np.all((perturbs - quad(s, B, g)) > 0) + return np.all((perturbs - quad(s, b, g)) > 0) -def is_bound_quad_min(s, B, g): +def is_bound_quad_min(s, b, g): """ - make local rotations to verify that s is a local minimum of quad(s, B, + make local rotations to verify that s is a local minimum of quad(s, b, g) on the sphere of radius ||s|| """ perturbs = np.array( [ quad( - R.from_rotvec(np.pi / 2 * eps * np.eye(3)[:, iv]) + Rotation.from_rotvec(np.pi / 2 * eps * np.eye(3)[:, iv]) .as_matrix() .dot(s), - B, + b, g, ) - for iv in range(B.shape[1]) + for iv in range(b.shape[1]) for eps in [1e-2, -1e-2] ] ) - return np.all((perturbs - quad(s, B, g)) > 0) + return np.all((perturbs - quad(s, b, g)) > 0) def test_convex_subproblem(subproblem): @@ -101,7 +101,7 @@ def test_nonconvex_subproblem(subproblem): assert np.isclose(sc + alpha, 1) -@pytest.mark.parametrize("minev", list(np.logspace(-1, -50, 50))) +@pytest.mark.parametrize('minev', list(np.logspace(-1, -50, 50))) def test_nonconvex_subproblem_eigvals(subproblem, minev): subproblem['B'][0, 0] = -minev delta = 1.0