Source code for pybnf.algorithms.optimizers.cmaes

"""CMA-ES -- Covariance Matrix Adaptation Evolution Strategy (``cmaes`` fit type, #403).

A native, derivative-free, black-box optimizer (Hansen & Ostermeier 2001), the
third PyBNF refiner alongside Simplex and Powell (``refine_method = cmaes``) and a
first-class standalone optimizer (``fit_type = cmaes``). Implemented inside the
run-loop contract -- ``start_run`` / ``got_result`` only, no ``run()`` override
(ADR-0007) -- and natively (no ``cma`` dependency).

Unlike the serial Simplex/Powell line searches, CMA-ES is **population-based**: it
draws ``lambda`` candidates per generation from a multivariate normal
``N(m, sigma**2 C)``, so the generation evaluates in parallel and CMA-ES actually
exploits PyBNF's cluster -- the same generation-synchronized reactor pattern as
Differential Evolution (accumulate the whole generation, then update). The search
runs in sampling space ``u`` (``StartPointOptimizer``), so log parameters adapt
geometrically.

Each generation ranks the candidates by objective and updates the distribution
mean ``m`` (weighted recombination of the best ``mu``), the step size ``sigma``
(cumulative step-length adaptation via the conjugate evolution path ``p_sigma``),
and the covariance ``C`` (rank-one ``p_c`` update + rank-``mu`` update). The
constants follow Hansen's standard defaults (the positive-weights formulation).
``lambda`` is the configured ``population_size`` (floored at 4); ``cmaes_sigma0``
is the initial step in ``u``; the run ends at ``max_iterations`` generations or
when the largest principal step ``sigma*sqrt(max eig C)`` falls below
``cmaes_stop_tol``.

CMA-ES is also a strong *global* optimizer over a bounded box, so it gains a
**box / global-start mode** (#404, ADR-0017; registry ``start_from_box=True``):
given bounded ``uniform_var`` / ``loguniform_var`` priors instead of a
``var`` / ``logvar`` start point, it begins at the box center (``StartPointOptimizer``)
and seeds the covariance with the per-coordinate box widths in ``u`` -- so the
initial per-coordinate standard deviation is ``cmaes_sigma0 * (box width)`` and
the first generation spans the whole box. There ``cmaes_sigma0`` is read as a
*fraction* of each box width (default 0.3); in point-start / refine mode it is the
absolute initial step in ``u`` as before. Candidates are repaired into the box by
``FreeParameter.set_value`` and the *reflected* point enters the update (see
``got_result``), so bounds are respected. As a refiner (``refine_method = cmaes``)
the start point is the injected best fit and the covariance starts isotropic --
box mode applies only to a standalone bounded-prior fit.

Restart for multimodal search (``cmaes_restarts``, IPOP / BIPOP; #498, ADR-0070).
A single CMA-ES run descends into the one basin its start lands in, so on a
multimodal objective it reaches only a local minimum. Opting into
``cmaes_restarts > 0`` turns on the canonical multimodal-CMA-ES restart: whenever a
run *finishes* -- it converges (Hansen's stopping battery: the search distribution
shrinks below ``cmaes_stop_tol``, the best objective stagnates, every coordinate step
falls below tolerance, the covariance goes ill-conditioned, or the step size
degenerates) or reaches ``cmaes_run_maxgen`` -- as distinct from exhausting the global generation budget
``max_iterations`` -- CMA-ES reinitializes from a fresh random point in the box with
a rescaled population and keeps searching, until ``cmaes_restarts`` restarts have run
or the global budget is spent. (The stagnation / ill-conditioning triggers are the
#506 fix: a single principal-step test never fires on an ill-conditioned landscape,
where ``max(d)`` grows as ``sigma`` shrinks, so IPOP/BIPOP would otherwise degenerate
to one trapped run.) The global best is kept for free: every evaluated
``PSet`` across every restart lands in the trajectory, so ``trajectory.best_fit()``
is the best over all runs. Two schedules (``cmaes_restart_strategy``): **IPOP** grows
the population geometrically each restart (``population_size * cmaes_ipop_factor**k``,
Auger & Hansen 2005); **BIPOP** interleaves that increasing-population regime with a
small-population regime, launching whichever has spent fewer evaluations so far
(Hansen 2009), which balances broad and fine-grained search across the budget.
``cmaes_restarts == 0`` (the default) is a single run -- byte-identical to the
pre-restart behavior. Restarts only draw fresh box points in box / global-start mode;
a point-start / refine fit has no box to resample.

An optional ``cmaes_run_maxgen`` bounds the generations spent in **each** run
(initial and restarted) before yielding to the next restart (#507, ADR-0085). This
prevents one steadily improving local basin from monopolizing the global
``max_iterations`` budget. It defaults to unset (unbounded), preserving the existing
schedule. BIPOP small runs retain their automatic evaluation-balancing cap; when the
user cap is set, the smaller of the two applies.

All state is plain ``numpy`` / ``float`` / ``list`` (mean, sigma, covariance,
evolution paths, the pending generation, the restart bookkeeping) -- picklable, so
backup/resume work like every other method.
"""

from .local_base import StartPointOptimizer
from ...config_schema import PyBNFConfigModel
from ...printing import print1, print2, PybnfError
from ...pset import PSet
from ...registry import register_fit_type

import logging
from typing import Optional

import numpy as np
from pydantic import Field


# Preserve the original module logger name so log records keep the
# 'pybnf.algorithms' channel.
logger = logging.getLogger('pybnf.algorithms')


[docs] class CMAESConfig(PyBNFConfigModel): """CMA-ES config fields, co-located with the method (ADR-0002, ADR-0006). ``cmaes_sigma0`` is the initial overall step size in sampling space ``u`` (a factor of ``10**cmaes_sigma0`` for a log parameter) in point-start / refine mode; in box / global-start mode (bounded priors, #404/ADR-0017) it is instead read as a *fraction of each box width*, so the initial per-coordinate standard deviation is ``cmaes_sigma0 * (box width)`` -- one knob, one default (0.3), interpreted relative to the start the fit actually uses. ``cmaes_stop_tol`` ends the run when the largest principal standard deviation of the search distribution shrinks below it; in restart mode (``cmaes_restarts > 0``) it is also the tolerance for the restart battery (#506) -- the relative TolFun (best-objective) stagnation threshold and the absolute TolX coordinate-step threshold. The population size ``lambda`` is the global ``population_size`` (consistent with de/pso/sa), and ``max_iterations`` remains the global generation budget across all runs. ``cmaes_run_maxgen`` optionally adds a smaller per-run generation cap; unset means no user cap. ``cmaes_start_point`` is internal (the refiner injects it), so it is not a schema field. ``cmaes_restarts`` is the maximum number of IPOP / BIPOP restarts (#498, ADR-0070); ``0`` (the default) is a single run. ``cmaes_restart_strategy`` selects the restart schedule -- ``ipop`` (increasing population, the default) or ``bipop`` (interleaved small / large populations). ``cmaes_ipop_factor`` is the geometric population growth per restart (``2.0`` = the standard population doubling), used by IPOP and by BIPOP's large regime. ``cmaes_run_maxgen`` (#507, ADR-0085) is an optional positive per-run generation cap applied to the initial run and every restart. """ cmaes_sigma0: float = 0.3 cmaes_stop_tol: float = 1e-11 cmaes_restarts: int = 0 cmaes_restart_strategy: str = 'ipop' cmaes_ipop_factor: float = 2.0 cmaes_run_maxgen: Optional[int] = Field(default=None, ge=1)
[docs] @register_fit_type('cmaes', family='optimizer', display_name='CMA-ES', schema=CMAESConfig, refiner=True, start_from_box=True) class CMAESAlgorithm(StartPointOptimizer): """CMA-ES as a picklable, generation-synchronized reactor state machine.""" #: Refiner start-point key (see StartPointOptimizer / pybnf._refine_best_fit). START_POINT_KEY = 'cmaes_start_point' #: ConditionCov threshold (#506): a covariance condition number above this is a #: numerical breakdown -- the search has degenerated to a near-line and can make no #: isotropic progress -- so in restart mode it triggers a restart. Hansen's standard #: default; not a user knob (it is a float64-conditioning limit, not a tuning tol). _COND_COV_MAX = 1e14 def __init__(self, config, refine=False): super().__init__(config) self.refine = refine self.n = len(self.variables) self.sigma0 = config.config['cmaes_sigma0'] self.base_sigma0 = self.sigma0 # un-perturbed sigma0 (BIPOP varies it per restart) self.stop_tol = config.config['cmaes_stop_tol'] self.max_generations = config.config['max_iterations'] # Restart schedule (#498, ADR-0070). cmaes_restarts == 0 (the default) is a # single run -- byte-identical to the pre-restart behavior. > 0 opts into # IPOP / BIPOP restart: on a per-run stop (convergence or cmaes_run_maxgen, # not the global generation budget) reinitialize from a fresh box point with # a rescaled population and keep searching, up to this many restarts, until # max_iterations generations total are spent. The global best is kept for free # (every evaluated pset lands in the trajectory). self.max_restarts = int(config.config['cmaes_restarts']) self.restart_strategy = config.config['cmaes_restart_strategy'] if self.restart_strategy not in ('ipop', 'bipop'): raise PybnfError( 'Invalid cmaes_restart_strategy "%s". Options are: ipop, bipop.' % self.restart_strategy) self.ipop_factor = config.config['cmaes_ipop_factor'] configured_run_maxgen = config.config['cmaes_run_maxgen'] self.configured_run_maxgen = (np.inf if configured_run_maxgen is None else int(configured_run_maxgen)) # Population (lambda) and every constant derived from it live in # _configure_strategy, since a restart rescales lambda and they all scale with # it. The base population is population_size (floored at 4); a restart scales # from this floored base. self.base_lam = int(config.config['population_size']) self._configure_strategy(self.base_lam) if self.lam != self.base_lam: print1('CMA-ES requires a population size of at least 4; using %i.' % self.lam) logger.warning('Increased CMA-ES population size to minimum allowed value of 4') self.base_lam = self.lam self.start_pset = self._resolve_start_pset() self._init_state() def _configure_strategy(self, lam): """Set the population ``lambda`` and every constant derived from it -- the parent number ``mu``, Hansen's log-decreasing recombination weights, the variance-effective selection mass ``mueff``, the step-size / covariance adaptation rates, and the damping (Hansen's standard defaults, positive-weights formulation). Called once at construction and again at every restart, since IPOP / BIPOP change ``lambda`` and all of these scale with it. ``lambda`` is floored at 4.""" self.lam = max(int(lam), 4) self.mu = self.lam // 2 weights = np.log(self.mu + 0.5) - np.log(np.arange(1, self.mu + 1)) self.weights = weights / np.sum(weights) self.mueff = 1.0 / np.sum(self.weights ** 2) n = self.n mueff = self.mueff # Adaptation rates / damping (Hansen, standard defaults). self.cs = (mueff + 2.0) / (n + mueff + 5.0) self.ds = (1.0 + 2.0 * max(0.0, np.sqrt((mueff - 1.0) / (n + 1.0)) - 1.0) + self.cs) self.cc = (4.0 + mueff / n) / (n + 4.0 + 2.0 * mueff / n) self.c1 = 2.0 / ((n + 1.3) ** 2 + mueff) self.cmu = min(1.0 - self.c1, 2.0 * (mueff - 2.0 + 1.0 / mueff) / ((n + 2.0) ** 2 + mueff)) self.chiN = np.sqrt(n) * (1.0 - 1.0 / (4.0 * n) + 1.0 / (21.0 * n ** 2)) def _init_state(self): """(Re)initialize the mutable search distribution, the restart bookkeeping, and the per-generation accumulators. Restores the base population (a prior run may have left ``lambda`` scaled by a restart) and reseeds the distribution at the configured start (box center in box / global-start mode).""" self._configure_strategy(self.base_lam) self.generation = 0 # monotonic across restarts: global budget + unique names self.restart_count = 0 # User cap applies to the initial run and every restart. Unset is infinity; # BIPOP small runs may choose a smaller schedule cap in _next_regime_bipop. self.run_maxgen = self.configured_run_maxgen self._small_regime = False # was the current run launched in the BIPOP small regime? self._n_large = 0 # completed large-regime restarts (BIPOP population growth) self._large_evals = 0 # evaluations spent so far in each BIPOP regime, for the self._small_evals = 0 # budget-balanced regime selection (Hansen 2009) self._last_large_evals = 0 # evals of the most recent large run (caps a small run) self._lam_history = [self.lam] # the population used by each run (the restart schedule) self._seed_distribution(self._u_from_pset(self.start_pset), self.sigma0) def _seed_distribution(self, mean_u, sigma0): """Seed a fresh CMA-ES run: distribution mean at ``mean_u`` (u-space), overall step ``sigma0``, and the covariance either box-width-anisotropic or isotropic. Box / global-start mode (#404): seed the covariance with the per-coordinate box widths in u so the first generation spans the whole box -- the initial std dev per coordinate is sigma0 * (box width). CMA-ES's covariance is exactly the per-coordinate-scale carrier, and the update is scale-covariant in C (the step-size path whitens by C^{-1/2}), so a diagonal width seed is the standard anisotropic init. In point-start / refine mode C is isotropic and sigma0 is an absolute u-step. Zeroes the evolution paths and the per-generation accumulators, and resets ``run_generation`` -- the CSA path-length normalization counts from the run start, so a restart's path re-normalizes from zero, not the global count. """ self.mean = np.array(mean_u, dtype=float) self.sigma = sigma0 if self._is_box_start(): self.C = np.diag(self._box_widths_u() ** 2) else: self.C = np.eye(self.n) self.pc = np.zeros(self.n) self.ps = np.zeros(self.n) self.run_generation = 0 # generations since this run's start (CSA normalization) self._run_best_history = [] # best objective per generation THIS run (TolFun window, #506) # Pending generation (filled by start_run / _sample_generation). self.pending = {} # pset name -> individual index self.gen_x = [None] * self.lam # sampled point (u-space) per index self.gen_score = [None] * self.lam self.waiting = 0
[docs] def reset(self, bootstrap=None): super().reset(bootstrap) self._init_state()
[docs] def add_iterations(self, n): self.max_generations += n
# --- sampling / accumulation ------------------------------------------ # def _eigen(self): """Eigendecomposition of the (symmetric) covariance: ``C = B diag(d2) B^T``. Returns ``B`` (columns are eigenvectors) and ``d`` (principal stddevs, ``sqrt`` of the eigenvalues, floored at a tiny positive value).""" self.C = np.triu(self.C) + np.triu(self.C, 1).T # enforce symmetry d2, B = np.linalg.eigh(self.C) d = np.sqrt(np.maximum(d2, 1e-30)) return B, d def _sample_generation(self): """Draw ``lambda`` candidates from ``N(mean, sigma**2 C)`` and queue them. Caches ``B`` / ``d`` for the post-generation update.""" self.B, self.d = self._eigen() self.pending = {} self.gen_x = [None] * self.lam self.gen_score = [None] * self.lam self.waiting = self.lam psets = [] for i in range(self.lam): z = self.rng.standard_normal(self.n) y = self.B @ (self.d * z) # ~ N(0, C) x = self.mean + self.sigma * y # ~ N(mean, sigma^2 C) # generation is monotonic across restarts, so the restart index is # redundant for uniqueness, but it keeps the sim-folder name legible. name = 'cmaes_r%i_gen%i_ind%i' % (self.restart_count, self.generation, i) self.pending[name] = i psets.append(self._pset_from_u(x, name=name)) return psets
[docs] def start_run(self): print2('Running CMA-ES with population size %i (mu=%i) for up to %i ' 'generations' % (self.lam, self.mu, self.max_generations)) return self._sample_generation()
[docs] def got_result(self, res): index = self.pending.pop(res.pset.name) # Use the actual (post-reflection) evaluated point so a candidate repaired # into the box enters the update as the point that was really scored. self.gen_x[index] = self._u_from_pset(res.pset) self.gen_score[index] = res.score self.waiting -= 1 if self.waiting > 0: return [] return self._update_distribution()
# --- distribution update ---------------------------------------------- # def _update_distribution(self): order = sorted(range(self.lam), key=lambda i: self.gen_score[i]) self._run_best_history.append(float(self.gen_score[order[0]])) # TolFun window (#506) x_sorted = np.array([self.gen_x[i] for i in order[:self.mu]]) # (mu, n) m_old = self.mean ys = (x_sorted - m_old) / self.sigma # (mu, n) yw = self.weights @ ys # (n,) self.mean = m_old + self.sigma * yw # Step-size path (uses C^{-1/2} = B diag(1/d) B^T). invsqrtC = self.B @ np.diag(1.0 / self.d) @ self.B.T self.ps = ((1.0 - self.cs) * self.ps + np.sqrt(self.cs * (2.0 - self.cs) * self.mueff) * (invsqrtC @ yw)) ps_norm = float(np.linalg.norm(self.ps)) # Heaviside stall check on the path length. Normalized by the number of # generations in the CURRENT run (run_generation), since a restart zeroes the # evolution path -- so the finite-length correction counts from the run start, # not the monotonic global generation counter. hsig = (ps_norm / np.sqrt(1.0 - (1.0 - self.cs) ** (2 * (self.run_generation + 1))) < (1.4 + 2.0 / (self.n + 1.0)) * self.chiN) # Covariance paths: rank-one (p_c) + rank-mu (weighted outer products). self.pc = ((1.0 - self.cc) * self.pc + (hsig * np.sqrt(self.cc * (2.0 - self.cc) * self.mueff)) * yw) rank_mu = (ys * self.weights[:, None]).T @ ys # sum_k w_k y_k y_k^T self.C = ((1.0 - self.c1 - self.cmu) * self.C + self.c1 * (np.outer(self.pc, self.pc) + (0.0 if hsig else self.cc * (2.0 - self.cc)) * self.C) + self.cmu * rank_mu) # Step-size update (cumulative step-length adaptation). self.sigma *= np.exp((self.cs / self.ds) * (ps_norm / self.chiN - 1.0)) self.generation += 1 self.run_generation += 1 if self.generation % self.config.config['output_every'] == 0: self.output_results() if self.generation % 10 == 0: print1('Completed %i of %i CMA-ES generations' % (self.generation, self.max_generations)) else: print2('Completed %i of %i CMA-ES generations' % (self.generation, self.max_generations)) print2(f'Current best objective: {self.gen_score[order[0]]:f}, sigma {self.sigma:g}') # The global generation budget is a hard cap across every restart -- it always # ends the fit (never triggers a restart). if self.generation >= self.max_generations: logger.info('CMA-ES stopping: reached max_iterations (%i generations)' % self.max_generations) return 'STOP' # A per-run termination (convergence / degenerate step / generation cap) # restarts the search if any restarts remain, else ends the fit (#498). reason = self._run_stop_reason() if reason is not None: if self.restart_count < self.max_restarts: return self._restart(reason) logger.info(f'CMA-ES stopping: {reason}') return 'STOP' return self._sample_generation() def _run_stop_reason(self): """A per-run termination string, or None to keep the current run going. Distinct from the global generation budget (checked separately, and always a hard stop): these are the reasons a *single* CMA-ES run has finished -- its search distribution converged below ``cmaes_stop_tol``, its step size degenerated, or it reached its optional per-run generation cap. On any of them a restart may fire; only the global budget ends the whole fit. With ``cmaes_restarts > 0`` a fuller **restart battery** (``_battery_stop_reason``, #506) is also consulted: the single principal-step test above is a geometric property of the distribution, and on an ill-conditioned landscape ``C`` elongates along the flat directions so ``max(d)`` grows while ``sigma`` shrinks and the product ``sigma*max(d)`` plateaus *above* ``cmaes_stop_tol`` -- the run then polishes a local basin forever and never yields to a restart (the IPOP/BIPOP machinery silently degenerates to one trapped run). The battery adds Hansen's canonical stagnation / degeneracy triggers, which fire on exactly those problems. It is gated on restart mode so that a single run (``cmaes_restarts == 0``, the default) stays byte-identical to the pre-restart behavior (ADR-0070).""" if not np.isfinite(self.sigma) or self.sigma <= 0.0: return 'step size sigma is no longer finite/positive' # Largest principal standard deviation of the search distribution. principal = self.sigma * float(np.max(self.d)) if principal < self.stop_tol: return (f'search distribution converged (principal step {principal:.3g} < {self.stop_tol:g})') if self.run_generation >= self.run_maxgen: return 'reached the per-run generation cap (%i generations)' % self.run_maxgen # The restart battery (#506) is a restart-only feature -- disabled for a single # run so cmaes_restarts == 0 stays byte-identical (ADR-0070). On the final run of # a restart fit (no restarts remaining) a battery trigger simply ends the fit. if self.max_restarts > 0: return self._battery_stop_reason() return None def _battery_stop_reason(self): """Hansen's canonical restart triggers beyond the single principal-step test (#506), each a per-run termination string or None: * **TolFun** -- the best objective has stagnated: its range over the last ``_tolfun_window`` generations of *this* run is within ``cmaes_stop_tol`` (relative, with an absolute floor). This is start-point- and conditioning-independent -- it fires when the run stops improving even though the (ill-conditioned) principal step has plateaued above ``cmaes_stop_tol`` -- and is the trigger the reproduction problems need. * **TolX** -- every coordinate standard deviation and evolution-path component is below ``cmaes_stop_tol``: the whole distribution has collapsed (the classic "converged" signal, complementary to the largest-principal-axis test). * **ConditionCov** -- the covariance condition number exceeds ``_COND_COV_MAX``: the search has degenerated to a near-line and can make no isotropic progress. ``self.d`` is the (pre-update) principal-standard-deviation cache from the generation just evaluated; ``self.C`` is the post-update covariance. A one-generation lag between them is immaterial to a stopping decision.""" hist = self._run_best_history window = self._tolfun_window() if len(hist) >= window: recent = hist[-window:] frange = max(recent) - min(recent) if frange <= self.stop_tol * max(1.0, abs(recent[-1])): return ('best objective stagnated (range %.3g over the last %i ' 'generations)' % (frange, window)) coord_std = self.sigma * np.sqrt(np.maximum(np.diag(self.C), 0.0)) if (np.all(coord_std < self.stop_tol) and np.all(self.sigma * np.abs(self.pc) < self.stop_tol)): return ('all coordinate steps below tolerance (max %.3g < %g)' % (float(np.max(coord_std)), self.stop_tol)) dmax, dmin = float(np.max(self.d)), float(np.min(self.d)) cond = (dmax / dmin) ** 2 if dmin > 0.0 else np.inf if cond > self._COND_COV_MAX: return ('covariance ill-conditioned (condition number %.3g > %g)' % (cond, self._COND_COV_MAX)) return None def _tolfun_window(self): """The TolFun stagnation window in generations: Hansen's ``10 + ceil(30 N / lambda)``. It scales with the population, so a larger restart population needs proportionally fewer generations of flat history to declare stagnation.""" return 10 + int(np.ceil(30.0 * self.n / self.lam)) # --- restart (IPOP / BIPOP, #498 / ADR-0070) --------------------------- # def _restart(self, reason): """Reinitialize for a fresh CMA-ES run after a convergence stop, keeping the global best (already in the trajectory). Accounts the finished run's evaluations to its BIPOP regime, picks the next population / step size / per-run cap from the schedule, reconfigures the strategy constants for the new population, reseeds the distribution at a fresh random box point, and launches the next generation. ``restart_count`` (and the monotonic ``generation``) keep every restart's pset names -- and thus its sim folders -- unique.""" finished_evals = self.run_generation * self.lam if self._small_regime: self._small_evals += finished_evals else: self._large_evals += finished_evals self._last_large_evals = finished_evals self.restart_count += 1 lam, sigma0, run_maxgen = self._next_regime() self._configure_strategy(lam) self._lam_history.append(self.lam) self.run_maxgen = run_maxgen logger.info('CMA-ES restart %d/%d after %s: population %d, sigma0 %g', self.restart_count, self.max_restarts, reason, self.lam, sigma0) print1('CMA-ES restart %d of %d (after %s); population size %d' % (self.restart_count, self.max_restarts, reason, self.lam)) self._seed_distribution(self._u_from_pset(self._random_start_pset()), sigma0) return self._sample_generation() def _next_regime(self): """The ``(population, sigma0, per-run generation cap)`` for the upcoming restart. IPOP grows the population geometrically (``base * factor**restart``) at the base step size. BIPOP interleaves that increasing-population regime with a small-population regime, launching whichever regime has consumed fewer evaluations so far (Hansen 2009): the small regime draws a random population between the base and half the current large population, a randomly shrunk step size, and a per-run budget capped at half the last large run's -- so evaluations stay balanced between the regimes. ``cmaes_run_maxgen`` bounds every regime; for a BIPOP small run the smaller of the configured cap and its automatic balancing cap applies.""" if self.restart_strategy == 'bipop': return self._next_regime_bipop() return (self.base_lam * self.ipop_factor ** self.restart_count, self.base_sigma0, self.configured_run_maxgen) def _next_regime_bipop(self): if self._small_evals <= self._large_evals: # Small-population regime: a random population in [base, lam_large/2], a # randomly shrunk sigma0, and a per-run budget capped at half the last # large run's -- so the small regime probes many quick, fine local searches. self._small_regime = True lam_large = self.base_lam * self.ipop_factor ** self._n_large u = self.rng.random() lam = self.base_lam * (0.5 * lam_large / self.base_lam) ** (u * u) sigma0 = self.base_sigma0 * 10.0 ** (-2.0 * self.rng.random()) run_maxgen = self.configured_run_maxgen if self._last_large_evals: schedule_maxgen = max( 1, int(0.5 * self._last_large_evals / max(int(lam), 4))) run_maxgen = min(run_maxgen, schedule_maxgen) return lam, sigma0, run_maxgen # Large-population regime: geometric growth and the user-configured cap. self._small_regime = False self._n_large += 1 return (self.base_lam * self.ipop_factor ** self._n_large, self.base_sigma0, self.configured_run_maxgen) def _random_start_pset(self): """A fresh start point for a restart: a uniform random draw across the prior box in box / global-start mode (each coordinate at a random quantile from the seeded ``self.rng``), so restarts probe different basins. In point-start / refine mode there is no box to sample, so fall back to the configured start point (a restart there just re-runs from the same start with a rescaled population).""" if self._is_box_start(): return PSet([v.value_from_quantile(self.rng.random()) for v in self.variables]) return self.start_pset