Source code for pybnf.algorithms.optimizers.gradient_base

"""Shared scaffolding for the gradient-based local optimizers (TRF/LM + L-BFGS-B, #386).

The metaheuristic fit types (de, pso, ss, cmaes, …) only ever ask each evaluated
``PSet`` for its scalar objective value. A **gradient** optimizer instead consumes
the residual vector + residual-Jacobian (TRF / Levenberg–Marquardt) or the scalar
gradient (L-BFGS-B) that #385 assembles from bngsim's forward output-sensitivity
tensor. :class:`GradientOptimizer` factors out everything a new gradient method
needs so a leaf (``trf.py``, ``lbfgs.py``) implements only its step math --
mirroring how :class:`StartPointOptimizer` factors the start-point / ``u`` ↔ ``PSet``
plumbing out of Powell and CMA-ES.

What this base provides
-----------------------
* **The edition + capability gate** (:meth:`_gate_gradient_supported`). Gradient
  fitting consumes the edition-2 surface (bind-by-id routing ADR-0034, the
  ``noise_model`` / measurement layer) and bngsim's forward sensitivities, so the
  fit is refused -- with a clear message pointing at a metaheuristic ``fit_type`` --
  on a legacy (edition < 2) config, a non-bngsim model, or a bngsim build without
  the ``output_sensitivities`` feature. Never a silent finite-difference fallback.
* **The gradient path activation** (:meth:`_setup_gradient_path`). Builds each
  experiment's :class:`~pybnf.gradient.routing.ExperimentRouting` **once** (it
  depends only on model structure, conditions, and free-parameter ids -- never on
  the parameter values, #449) and ``apply_routing``\\ s the union request onto every
  model, so each simulated :class:`~pybnf.data.Data` carries its sensitivity tensor.
  Run before the model scatter (from ``start_run``); the request rides the pickle to
  the workers (``BngsimModel.__getstate__`` keeps it, rebuilding only the engine).
* **Master-side scoring** (``requires_master_scoring = True``). The worker scoring
  path nulls ``res.simdata`` after scoring (#385/#388), which would discard the
  sensitivity tensors; the flag makes ``Algorithm.run`` keep scoring on the master so
  every ``Result`` returns with its full simdata for :meth:`gradient_at`.
* **The per-evaluation assembly** (:meth:`gradient_at`). Aligns a Result's
  ``simdata`` with ``exp_data`` and the prebuilt routings, and returns the assembled
  :class:`~pybnf.gradient.assembly.GradientResult` (objective gradient + residual
  Jacobian, in sampling space ``u``), folding in any constraint-penalty gradient.
* **The ``u``-space box** (:meth:`_u_bounds`). The finite reflecting box for bounded
  (``uniform_var`` / ``loguniform_var``) priors, ``±inf`` for an unbounded point
  start -- the bounds the leaf's step projects/reflects into.

The search runs in sampling space ``u`` (``StartPointOptimizer``), and #385 already
delivers the gradient transformed into ``u`` once (ADR-0029), so a leaf never
re-transforms. Leaves own their ``start_run`` / ``got_result`` state machine and
must be picklable for backup/resume, exactly like Powell and CMA-ES (ADR-0007).
"""

import numpy as np

from .concurrent_multistart import DONE, ConcurrentMultiStartOptimizer
from ...gradient import (
    GradientNotSupported,
    apply_routings,
    assemble_constraint_gradient,
    assemble_gaussian_gradient,
    route_for_model,
)
from ...printing import PybnfError, print1, print2

# ``DONE`` is the shared multi-start sentinel, re-exported here so a gradient leaf's
# ``from .gradient_base import DONE`` keeps resolving to the one object the base's
# ``got_result`` identity-checks (#500).
__all__ = ['DONE', 'GradientRunner', 'GradientOptimizer']


[docs] class GradientRunner: """Headless, picklable per-start step machine in sampling space ``u`` (#386). A gradient leaf's step math, factored out of the optimizer so a single fit can run ``N`` of them **concurrently** -- local multi-start, the diversity a purely local gradient method otherwise lacks (it only ever descends into the one basin its start lands in). A runner owns one start's entire mutable state -- the iterate, the curvature / trust-region model, the reflecting box, the tunables -- and is pure ``numpy``: it knows nothing about :class:`~pybnf.pset.PSet`\\ s, the objective, routing, backup, or dask. :class:`GradientOptimizer` drives it:: u0 = runner.start() # the first point to evaluate nxt = runner.got(u_point, score, grad) # consume one completed evaluation where ``u_point`` is the realized (box-projected) ``u``-vector of the completed evaluation, ``score`` its objective value, and ``grad`` the assembled :class:`~pybnf.gradient.assembly.GradientResult` at it; ``got`` returns the next ``u`` to evaluate, or the :data:`DONE` sentinel. Because a runner holds only plain ``float`` / ``ndarray`` / ``list``, the optimizer that owns the list of runners pickles for backup/resume exactly like the single-start machine did (ADR-0007). Being backend-free, a runner is also unit-testable offline by feeding it scores + gradients from an analytic function (no bngsim) -- how the step math is validated against a scipy oracle and how the multi-start win is demonstrated. A leaf subclass (``trf.py`` / ``lbfgs.py``) sets up its own model state in ``__init__`` and implements :meth:`got`; the orchestrator only ever calls :meth:`start`, :meth:`got`, and reads :attr:`iteration` / :attr:`fval` / :attr:`stop_reason` / :meth:`progress_detail` for reporting. """ def __init__(self, u0, lower, upper, max_iterations): self.point = np.array(u0, dtype=float) # current iterate (u-space) self._u_lower = lower # reflecting box (constant per fit) self._u_upper = upper self.n = len(self.point) self.max_iterations = max_iterations self.iteration = 0 # accepted steps so far (drives reporting / budget) self.phase = 'init' # 'init' until the start point is evaluated self.fval = None # objective F(point) self.grad = None # scalar gradient dF/du at point (u-space) self.stop_reason = None # set to a human string when got() returns DONE
[docs] def start(self): """The first ``u``-point to evaluate (the start point).""" return self.point
[docs] def got(self, u_point, score, grad): """Consume one completed evaluation and return the next ``u`` or :data:`DONE`. The orchestrator passes ``grad = None`` with a non-finite ``score`` for a **failed simulation** (a non-integrable candidate point). A leaf's ``got`` must tolerate that: mid-search its ``isfinite(score)`` guard already rejects the trial without dereferencing the gradient (the fit backs off); at the start point (``phase == 'init'``, ``grad is None``) it must terminate via :meth:`_failed_start`. See :meth:`GradientOptimizer._advance` (#492).""" raise NotImplementedError
def _failed_start(self): """Terminate this start: its start point did not simulate (a non-integrable point -- a bngsim CVODE failure, a NaN/Inf), so there is no finite objective or gradient to model the local surface from and descend. A gradient method needs a viable start; with none, this start ends. Concurrent multi-start keeps every *other* start's progress and the global best, so only this start stops (a single-start fit ends here, the failed point left in the trajectory at the ``inf`` penalty). Fed by the orchestrator as ``grad is None`` at ``phase == 'init'``; see :meth:`GradientOptimizer._advance` (#492). ``fval`` is set to the ``inf`` penalty so a consumer that reads the terminated runner's objective (e.g. the profile-likelihood grid point in :meth:`ProfileLikelihoodAlgorithm._profile_got`) sees a non-finite value rather than the ``None`` a never-evaluated runner starts with.""" self.fval = float('inf') self.stop_reason = ('start point failed to simulate (a non-integrable point); ' 'no objective/gradient to descend from') return DONE
[docs] def progress_detail(self): """A short method-specific status suffix for the per-iteration report.""" return ''
# The one-line suggestion every gradient-path refusal ends with, so a user whose # model/config cannot be differentiated is pointed straight at a working fit_type # rather than left to guess. Gradient fitting is strictly opt-in (fit_type = trf / # lbfgs); a metaheuristic always works on the same config. _FALLBACK_HINT = ( "Gradient-based fitting is not available for this fit; use a metaheuristic " "fit_type instead (e.g. fit_type = de, the default, or pso / ss / cmaes), " "which needs no gradient." )
[docs] class GradientOptimizer(ConcurrentMultiStartOptimizer): """The gradient-based leg of the concurrent multi-start base (#386/#500). A leaf subclass supplies only its per-start step math as a :class:`GradientRunner` (Levenberg–Marquardt for ``trf``, L-BFGS-B for ``lbfgs``) via :meth:`_make_runner`; the shared :class:`~pybnf.algorithms.optimizers.concurrent_multistart.ConcurrentMultiStartOptimizer` owns the ``start_run`` / ``got_result`` orchestration -- seeding the runners, the name routing, reporting, and the multi-start ``STOP`` coordination -- and this class fills in what the gradient path does differently: the pre-flight gates + master scoring, the ``u`` <-> PSet plumbing, the sensitivity-path activation (:meth:`_setup_gradient_path`, hung on :meth:`_pre_seed`), the :meth:`gradient_at` assembly, and the gradient-consuming :meth:`_advance`. The leaf must set :attr:`START_POINT_KEY` like any :class:`StartPointOptimizer`, plus :attr:`_method_label` and :meth:`_start_banner` for its messages. Local multi-start (#386). A box-start gradient fit runs ``N`` independent starts concurrently (``N`` reuses ``population_size`` -- the gradient path predates the ``n_starts`` field, hence :attr:`_n_starts_key`) -- start 0 from the box center (preserving the deterministic single-start behavior), the rest from Latin-hypercube samples across the prior box -- and keeps the global best. Every evaluated PSet across all starts lands in the trajectory (``add_to_trajectory`` runs before ``got_result``), so ``trajectory.best_fit()`` is the global best for free -- each runner only tracks its own best for its own convergence test. """ #: Keep objective scoring on the master so every Result returns with its #: simdata (the sensitivity tensors the gradient assembly reads); see #: ``Algorithm.run``. Without this the worker path nulls ``res.simdata``. requires_master_scoring = True #: Human label for the method in the per-iteration progress messages; set by #: each leaf (e.g. ``'L-BFGS-B'`` / ``'TRF'``). _method_label = 'gradient' #: The gradient path predates the ``n_starts`` field and reuses ``population_size`` as #: the box-fit start count (consistent with the metaheuristics, where it is the #: parallel-population size, and ``population_size = 1`` reproduces the historical #: single start). _n_starts_key = 'population_size' #: The verb the base logs when a start terminates (a gradient start "stops"; a #: derivative-free start "finishes") -- cosmetic, preserved verbatim. _stop_verb = 'stopping' # --- construction / reset hooks ---------------------------------------- # def _check_config_supported(self, config): """Refuse a legacy (edition < 2) config before the base builds a single model -- the cheapest gate, before the expensive network generation in ``Algorithm.__init__`` (a legacy-edition config can never carry the gradient surface).""" self._require_edition_2(config) def _after_init(self): """The gradient path's construction extras, run after the models are built and before start resolution: the sensitivity-backend and differentiability gates, and the reflecting box + (empty) per-experiment routings.""" # Per-experiment routing, keyed by (model_name, suffix); built lazily in # _setup_gradient_path (needs the initialized models). None until then, and # restored as None by reset() so a bootstrap refit rebuilds it. self._routings = None # Backend gate: every model must expose bngsim's forward-sensitivity hooks # (the capability gate itself fires later, at apply_routing). self._require_sensitivity_backend() # Differentiability gate: a discrete-event model has no smooth forward # sensitivity (bngsim refuses one), so refuse it now rather than mid-run (#461). self._require_differentiable_dynamics() # The reflecting box in sampling space u (the leaf's step projects/reflects into it). self._u_lower, self._u_upper = self._u_bounds() def _after_reset(self): """Rebuild the reflecting box and drop the routings so a bootstrap refit rebuilds them; the gates already passed at construction and never regress on a refit.""" self._routings = None self._u_lower, self._u_upper = self._u_bounds() # --- run-loop hooks ---------------------------------------------------- # def _pre_seed(self): """Activate the gradient path (enable sensitivities + build routings) before the runners are seeded -- and so before the model scatter, so the request rides the pickle to the workers.""" self._setup_gradient_path() def _build_runners(self): """One :class:`GradientRunner` per start, seeded at each start PSet's ``u``-vector. The gradient step is deterministic, so (unlike the local path) no per-start rng is provisioned -- keeping the base rng-agnostic (#500).""" return [self._make_runner(self._u_from_pset(p)) for p in self.start_psets] def _seed(self, idx, runner): """Start ``idx``'s single opening evaluation (its start point).""" return [self._dispatch(idx, runner.start())] def _advance(self, idx, runner, res): """Assemble the gradient at the completed ``res``, feed ``(u, score, grad)`` to start ``idx``'s runner, and return its next PSet -- or :data:`DONE` once it terminates. The realized (box-projected) ``u`` of the evaluated point is read back off the PSet so the runner's internal iterate is a genuinely evaluated point. A **failed simulation** (a non-integrable candidate point: a bngsim CVODE failure, a NaN/Inf, ...) returns with ``res.simdata is None`` and ``res.score`` already the ``inf`` penalty (set in ``add_to_trajectory``); there is no trajectory data to assemble a gradient from. Feed the runner that non-finite evaluation with **no gradient** (``grad = None``): mid-search the runner's own ``isfinite(score)`` guard rejects the step / shrinks its trust region and proposes a shorter one -- it never dereferences the gradient on a rejected trial -- so the fit *backs off* rather than aborting; at the start point there is no basin to descend from, so the runner terminates that start (:meth:`GradientRunner._failed_start`), leaving every other concurrent start and the trajectory's global best untouched. Mirrors the scalar path's ``inf`` penalty for a failed simulation and the ``res.simdata is None`` guard added for the sampler / constraint-tracking path in #480 -- this is the gradient path's analogous unguarded case (#492).""" u_point = self._u_from_pset(res.pset) if res.simdata is None: grad, score = None, float('inf') else: grad, score = self.gradient_at(res), float(res.score) nxt = runner.got(u_point, score, grad) if nxt is DONE: return DONE return [self._dispatch(idx, nxt)] def _dispatch(self, idx, u): """Wrap a runner's proposed ``u``-point as a uniquely named PSet bound to its owning start in :attr:`pending`, and return it for submission. The name carries a single global counter (``<fit_type>_<k>``), so a single-start fit reproduces the historical ``<fit_type>_1``, ``<fit_type>_2``, … sequence exactly while every name stays unique across concurrent starts (the routing key).""" self.probe_counter += 1 name = '%s_%i' % (self.fit_type, self.probe_counter) return self._route(idx, self._pset_from_u(u, name=name)) def _report(self, runner): """Per-iteration progress for one start (mirrors the single-start report); the method-specific suffix comes from the runner.""" if runner.iteration % self.config.config['output_every'] == 0: self.output_results() msg = 'Completed %i of %i %s iterations' % ( runner.iteration, runner.max_iterations, self._method_label) (print1 if runner.iteration % 10 == 0 else print2)(msg) print2('Current best objective: %f, %s' % (runner.fval, runner.progress_detail())) # --- gates ------------------------------------------------------------- # # The gradient path is gated in four places, each as early as it can be: # # * **edition** (:meth:`_require_edition_2`, before model build) -- the gradient # consumes the edition-2 surface (bind-by-id routing, the noise-model / # measurement layer), absent under legacy edition 1; # * **backend** (:meth:`_require_sensitivity_backend`, after model build) -- every # model must expose bngsim's forward-sensitivity hooks; a non-bngsim (e.g. # RoadRunner/SBML) model has no sensitivity tensor here; # * **differentiability** (:meth:`_require_differentiable_dynamics`, after model # build, #461) -- a discrete-event model has no smooth forward sensitivity # (bngsim refuses one, GH #205), so refuse it here rather than fail mid-run; # * **capability** (deferred to :meth:`_setup_gradient_path`'s ``apply_routing``, # #447) -- raises if the bngsim build lacks the ``output_sensitivities`` feature. # # The per-evaluation gate (an unsupported *objective* -- Laplace residual, # estimated scale, … raising :class:`GradientNotSupported`) is caught at the first # assembly in :meth:`gradient_at`. A non-ODE simulation *method* (SSA / NFsim), and a # carried-state pre-equilibration ``parameter_scan`` (#474), are likewise # non-differentiable, but the method is an action-level property (a model can mix # actions) rather than a model-structure one, so they are not hoisted here. They keep # a per-evaluation refusal in the backend (``_sensitivity_request_kwargs`` / # ``_scan_carried_state`` raise a clean :class:`PybnfError`, not a raw backend # traceback) -- but *only when that action's output is a scored gradient target* # (#475): an incidental/unscored non-ODE or carried-state action needs no # sensitivities, so it runs sensitivity-free instead of aborting a fit whose scored # objective is fully differentiable. :meth:`_setup_gradient_path` declares each # model's scored suffixes so the backend can make that per-action distinction. Events, # by contrast, are a build-time structural signal and so *can* be a pre-flight gate. def _require_edition_2(self, config): """Refuse a legacy (edition < 2) config before any model is built.""" edition = config.config.get('edition') if not edition or edition < 2: raise PybnfError( "Gradient-based fitting (fit_type = %s) requires the edition-2 " "config surface, but this fit is %s." % ( self._fit_type_label(), "edition 1 (legacy)" if not edition else "edition %d" % edition), "Opt into edition 2 ('edition = 2') and declare the fit on the " "new-era surface (experiment: / data: / noise_model, bind-by-id " "parameters). " + _FALLBACK_HINT) def _require_sensitivity_backend(self): """Refuse a model whose backend has no forward-sensitivity hooks.""" for model in self.model_list: if not hasattr(model, 'enable_output_sensitivities'): raise PybnfError( "Gradient-based fitting (fit_type = %s) requires the bngsim " "backend's forward sensitivities, but model '%s' uses a backend " "that does not provide them." % ( self._fit_type_label(), getattr(model, 'name', '?')), _FALLBACK_HINT) def _require_differentiable_dynamics(self): """Refuse a discrete-event model before the run (#461). A discrete event is a state-dependent discrete jump in the dynamics; it reinitialises the integrator state discontinuously, but bngsim's CVODES forward-sensitivity vectors are not reinitialised across the jump, so the sensitivities go silently stale -- bngsim refuses forward output sensitivities outright on such a model (GH #205). Without this gate that refusal would surface only mid-run, at the first sensitivity-bearing ``simulate()`` (caught and re-raised in ``BngsimModel.execute``). Fired here next to :meth:`_require_sensitivity_backend`, it gives the discrete-event model the same clean pre-flight "use a metaheuristic fit_type" refusal the other gates give. Models whose backend exposes no event count (``has_discrete_events`` absent) pass through untouched.""" for model in self.model_list: if getattr(model, 'has_discrete_events', False): raise PybnfError( "Gradient-based fitting (fit_type = %s) requires smooth, " "differentiable dynamics, but model '%s' contains discrete " "events (a state-dependent discrete jump). Forward output " "sensitivities go stale across such a jump, so bngsim cannot " "supply the gradient there." % ( self._fit_type_label(), getattr(model, 'name', '?')), _FALLBACK_HINT) def _fit_type_label(self): """The fit_type code for messages (the leaf's registered name, best-effort).""" return getattr(self, 'fit_type', type(self).__name__) # --- gradient-path activation ------------------------------------------ # def _setup_gradient_path(self): """Enable forward sensitivities on every model and build the per-experiment routings -- idempotent, called once from the leaf's ``start_run`` (before the model scatter, so the request rides the pickle to the workers). For each model: build one :class:`ExperimentRouting` per scored ``(model, suffix)`` (carrying that condition's chain-rule factors for the assembly), then ``apply_routings`` the **union** of their sensitivity requests -- plus the wildtype's. The wildtype alone is not a superset: a condition can route a free parameter to a column no other experiment binds (a per-condition estimated initial condition, ADR-0076), so that column is reached only through the condition and must be unioned in (an extra requested column is harmless; a missing one aborts the assembly). Raises (capability gate) if the bngsim build lacks ``output_sensitivities``.""" if self._routings is not None: return names = [v.name for v in self.variables] routings = {} for model in self.model_list: # Declare which of this model's outputs are scored gradient targets so # an incidental/unscored action (a stochastic diagnostic, a # carried-state pre-equilibration scan) runs sensitivity-free instead # of aborting the whole fit at a differentiability guard (#475). Rides # the scatter alongside the sensitivity request. model.set_scored_suffixes(self.exp_data.get(model.name, {})) # Apply the UNION sensitivity request over the wildtype and every scored # condition -> sets _sensitivity_request, which survives the scatter and is # applied at every simulate(). The wildtype alone is NOT a superset once a # condition routes a free parameter to a column no other experiment binds -- # a per-condition estimated initial condition (ADR-0076): its species-IC / # multiplier column is reached only through that condition, so the union must # include every scored routing (an extra column is harmless; a missing one aborts). wildtype = route_for_model(model, names, condition=None) model_routings = {} for suffix in self.exp_data.get(model.name, {}): condition = self._condition_for_suffix(model, suffix) model_routings[suffix] = route_for_model(model, names, condition) apply_routings(model, [wildtype, *model_routings.values()]) for suffix, routing in model_routings.items(): routings[(model.name, suffix)] = routing self._routings = routings def _condition_for_suffix(self, model, suffix): """Resolve a scored ``suffix`` to the condition (``MutationSet``) it was simulated under, or ``None`` for the wildtype. An edition-2 ``condition:`` is a named :class:`~pybnf.pset.MutationSet` added to the model as a mutant (its name is the suffix); a mutant simulation's output suffix carries the mutant's own suffix (``net_model.execute``), so a scored suffix that ends with a known mutant suffix was simulated under that condition. The wildtype experiment touches no mutant and maps to ``None`` (the unperturbed routing, all factors 1).""" best = None for mut in getattr(model, 'mutants', []) or []: ms = getattr(mut, 'suffix', '') if ms and suffix.endswith(ms) and (best is None or len(ms) > len(best.suffix)): best = mut return best # --- per-evaluation assembly ------------------------------------------- #
[docs] def gradient_at(self, res): """Assemble the objective gradient + residual-Jacobian at ``res``'s point. ``res`` is a master-scored Result, so ``res.simdata`` carries each experiment's forward-sensitivity tensor. Aligns it with ``exp_data`` over the scored ``(model, suffix)`` pairs (the same intersection the objective scores), attaches each one's prebuilt routing, and returns the assembled :class:`~pybnf.gradient.assembly.GradientResult` -- residual / Jacobian / scalar gradient in sampling space ``u`` (#385 transformed it once; the optimizer never re-transforms). Any constraint-penalty gradient is added to the scalar ``gradient`` (and clears ``least_squares_exact``, since a penalty is not a sum of squares). The free-parameter list (column order + current values) is read straight off the evaluated PSet, so the ``d theta/d u`` scale factors are taken at the point actually simulated. Converts a :class:`GradientNotSupported` (an objective the assembly cannot differentiate) into a clear, fail-fast :class:`PybnfError` pointing at a metaheuristic fit_type.""" free_params = [res.pset.get_param(v.name) for v in self.variables] experiments = [] for model_name, by_suffix in res.simdata.items(): model_exp = self.exp_data.get(model_name, {}) for suffix, sim_data in by_suffix.items(): if suffix in model_exp: experiments.append( (sim_data, model_exp[suffix], self._routings[(model_name, suffix)])) try: grad = self._assemble_objective_gradient(experiments, free_params) if self.config.constraints: cgrad = assemble_constraint_gradient( self.config.constraints, res.simdata, self._routings, free_params) grad.gradient = grad.gradient + cgrad grad.least_squares_exact = False self._attach_curvature(grad, res, experiments, free_params) except GradientNotSupported as e: raise self._unsupported_gradient_error(e) from e return grad
def _assemble_objective_gradient(self, experiments, free_params): """Assemble the data-fit objective derivatives needed by this optimizer leaf. The base ``trf`` / ``lbfgs`` path needs only the scalar gradient and residual model. ``gntr`` overrides this seam to assemble those values and its Fisher Hessian in one scored-point pass (#488). """ return assemble_gaussian_gradient(self.objective, experiments, free_params) def _attach_curvature(self, grad, res, experiments, free_params): """Hook for a curvature-consuming leaf to attach its Hessian to the assembled gradient. A **no-op on the base**, so the residual-form (``trf``) and scalar-gradient (``lbfgs``) leaves -- which never form a Hessian -- are byte-identical; the EFIM trust-region leaf (``fit_type = gntr``, #481) receives the data-fit Hessian from its combined :meth:`_assemble_objective_gradient` override, then uses this hook to add, for a constrained fit, :func:`~pybnf.gradient.assembly.assemble_constraint_hessian`. Called **inside** :meth:`gradient_at`'s :class:`GradientNotSupported` guard, so an unsupported-curvature corner (a MEDIAN-count Fisher, a MEAN-on-log estimated scale, an estimated constraint scale, ...) converts to the same fail-fast :class:`PybnfError`.""" def _unsupported_gradient_error(self, exc): """Wrap a :class:`GradientNotSupported` as the leaf's fail-fast :class:`PybnfError` with an actionable fallback hint. The base points at a metaheuristic ``fit_type``; the EFIM leaf (``gntr``) overrides the hint to point at ``lbfgs`` -- which consumes the scalar gradient and needs no Fisher Hessian, so it fits the very corners ``gntr`` refuses.""" return PybnfError( "Gradient-based fitting (fit_type = %s) cannot differentiate this " "fit's objective: %s" % (self._fit_type_label(), exc), _FALLBACK_HINT) # --- u-space box ------------------------------------------------------- # def _u_bounds(self): """The reflecting box in sampling space ``u`` as ``(lower, upper)`` arrays, ordered by ``self.variables``. Finite ``[to_sampling_space(lower_bound), to_sampling_space(upper_bound)]`` for a bounded (``uniform_var`` / ``loguniform_var``) parameter; ``(-inf, +inf)`` for the unbounded ``var`` / ``logvar`` of a point start. The same box Powell confines its line search to (#412); a leaf projects or reflects its proposed step into it.""" lower, upper = [], [] for v in self.variables: if v.bounded: lower.append(v.to_sampling_space(v.lower_bound)) upper.append(v.to_sampling_space(v.upper_bound)) else: lower.append(-np.inf) upper.append(np.inf) return np.array(lower, dtype=float), np.array(upper, dtype=float)