Source code for pybnf.algorithms.base

"""The Algorithm base class and the module-level helpers it relies on.

Extracted from ``algorithms.py`` (M1 Step 2). The run loop resolves the three
monkeypatched names as ``core.run_job`` / ``core.as_completed`` / ``core.Job``
(ADR-0001), so it imports the ``core`` module object rather than binding those
names locally; the never-patched data classes are imported by name.
"""


from . import core
from . import best_fit_confirmation
from . import multistart_report
from .core import (
    FailedSimulation,
    JobGroup,
    MultimodelJobGroup,
    HybridJobGroup,
    result_from_completed,
)
from subprocess import run, CalledProcessError, TimeoutExpired, STDOUT
from ..pset import PSet, Trajectory, BNGLModel, NetModel, run_subprocess, _stage_and_rewrite_tfun_files, _format_bngl_number
from .. import edition
from ..bngsim_model import (
    BngsimModel,
    BngsimNfModel,
    BNGSIM_AVAILABLE,
    BNGSIM_ERROR,
    BNGSIM_BACKEND_NET,
    BNGSIM_BACKEND_NF,
    BNGSIM_BACKEND_HYBRID,
    classify_actions_for_bngsim,
    missing_bngsim_nf_action_support,
)
from ..printing import print0, print1, print2, PybnfError
from ..objective import ObjectiveCalculator, likelihood_information_criteria
from ..budget import format_duration

from abc import ABC, abstractmethod
import logging
import numpy as np
import os
import shutil
import copy
import traceback
import pickle
import re
from pathlib import Path
from glob import glob
from concurrent.futures import CancelledError


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


BNGL_BACKEND_AUTO = 'auto'
BNGL_BACKEND_BNGSIM = 'bngsim'
BNGSIM_SUPPORTED_BNGL_BACKENDS = (BNGSIM_BACKEND_NET, BNGSIM_BACKEND_NF, BNGSIM_BACKEND_HYBRID)


def _bngsim_runtime_available():
    return BNGSIM_AVAILABLE and not os.environ.get('PYBNF_NO_BNGSIM')


def _bngsim_unavailable_reason():
    if os.environ.get('PYBNF_NO_BNGSIM'):
        return 'PYBNF_NO_BNGSIM is set'
    return BNGSIM_ERROR or 'bngsim is not available'


[docs] class Algorithm(ABC): """Base class for every PyBNF fit type ("method"); defines the run-loop contract. **The contract (ADR-0007).** A method plugs into the framework by subclassing ``Algorithm`` and implementing exactly two abstract methods: * :meth:`start_run` ``() -> list[PSet]`` — the initial parameter sets to evaluate. * :meth:`got_result` ``(Result) -> list[PSet] | 'STOP'`` — given one completed result, the next parameter sets to evaluate, or the string ``'STOP'`` to end the run. plus registering itself with a config schema via ``@register_fit_type(..., schema=...)`` (ADR-0002, ADR-0005). The ``.name`` of any returned PSet, if set, MUST be unique across the run (it determines the simulation folder name; uniqueness is not checked elsewhere). Everything else the framework needs has an overridable default (``reset``, ``add_iterations``, ``cleanup``, ``get_backup_every``, ``should_pickle``). The **run loop** itself — job submission, ``as_completed`` draining, backup cadence, best-fit save, sim-dir teardown — lives in :meth:`run` and is shared by every method, not replaceable: a method customizes *what to propose*, never the outer loop. """ # Overridable flag, set True by SimplexAlgorithm. Replaces an # ``isinstance(self, SimplexAlgorithm)`` check in run()'s teardown so the # base class does not reference a leaf subclass. _is_simplex = False # Overridable flag, set True by the gradient optimizers (#386). When True, # run() keeps objective scoring on the master (calc_future = None) so each # Result returns with its full simdata -- the per-experiment forward # sensitivity tensors the gradient assembly consumes (#385). The worker # scoring path nulls res.simdata after scoring (core.Job.run_simulation), so # a method that needs simdata back must opt out of it. The same base-class # flag pattern as _is_simplex, so run() never references a leaf subclass. requires_master_scoring = False # Overridable flag, set True by the generational optimizers (de, cmaes, ss). # These propose a whole generation of parameter sets, wait for every simulation # in it to finish, then propose the next generation. Toward the end of each # generation only a few simulations are still running, so some workers sit idle by # design. _report_parallelism() says so, to keep that expected idle time from being # mistaken for a fit that is using fewer processors than it should (#621). The same # base-class flag pattern as _is_simplex, so run() never names a leaf subclass. waits_for_full_generation = False # Overridable: the name of the setting the parallelism report points at when it # advises the user (#655). Most fits size their concurrency from population_size, so # that is the default. A fit that follows a different setting names it instead (the # local multi-start optimizers name their own n_starts key), and a fit where no # single setting controls it sets this to None, which leaves the advice talking only # about how many processors to reserve. parallelism_setting = 'population_size' #: The fit's total wall-clock budget (``wall_time_fit``, #529/ADR-0093), or None for #: an unbounded fit. Set by ``pybnf.main()`` on the algorithm it is about to run -- #: and passed on to a refiner / reused across bootstrap replicates -- so one deadline #: bounds the whole run rather than each phase separately. A **class** attribute so an #: algorithm unpickled by ``--resume`` (which deliberately does not carry the stale #: deadline; see :meth:`should_pickle`) still reads a well-defined None. budget = None #: Why the last :meth:`run` stopped, when that reason must not be mistaken for #: convergence (today: the wall-time budget). None for an ordinary run. stop_reason = None #: Simulations the last :meth:`run` consumed results from -- what the wall-time stop #: reason reports, kept so the run's phase record can report it too (#564). A class #: attribute for the same reason ``budget`` is: an unpickled algorithm reads 0. completed_simulations = 0 #: The run's executed-method record (``Results/method_chain.json``, #564/ADR-0107), #: or None when nothing is keeping one. Like ``budget``, it is attached by #: ``pybnf.main()`` and shared with the refiner, so one file describes the whole run. method_chain = None #: The noise scales this fit profiled out of the search, at the best fit #: (``{name: sigma_hat}``, ``noise_profiling = 1``, ADR-0108) -- captured by the #: end-of-run tail when it scores the best point, and written to #: ``Results/profiled_noise.txt``. Empty for a fit that profiles nothing (the default). #: A **class** attribute, replaced rather than mutated, so an unpickled algorithm -- and #: the run loop's own tail -- always read a well-defined empty map. _profiled_noise = {} #: Name of the parameter set ``Results/information_criteria_backup.txt`` currently #: describes (#560), or None while no checkpoint has been written. The checkpoint costs #: one re-simulation, so it is skipped while this still names the best fit -- the file on #: disk already describes it. A **class** attribute for the same reason ``_profiled_noise`` #: is: an algorithm unpickled from a backup written before this existed reads a #: well-defined None and simply writes its first checkpoint. _ic_checkpoint_name = None def __init__(self, config): """ Instantiates an Algorithm with a Configuration object. Also initializes a Trajectory instance to track the fitting progress, and performs various additional configuration that is consistent for all algorithms :param config: The fitting configuration :type config: Configuration """ self.config = config self.exp_data = self.config.exp_data self.objective = self.config.obj logger.debug('Instantiating Trajectory object') self.trajectory = Trajectory(self.config.config['num_to_output']) self.job_id_counter = 0 self.output_counter = 0 self.job_group_dir = dict() self.fail_count = 0 self.success_count = 0 self.max_iterations = config.config['max_iterations'] logger.debug('Creating output directory') if not os.path.isdir(self.config.config['output_dir']): os.mkdir(self.config.config['output_dir']) if self.config.config['simulation_dir']: self.sim_dir = str(Path(self.config.config['simulation_dir']) / 'Simulations') else: self.sim_dir = str(Path(self.config.config['output_dir']) / 'Simulations') self.res_dir = str(Path(self.config.config['output_dir']) / 'Results') self.failed_logs_dir = str(Path(self.config.config['output_dir']) / 'FailedSimLogs') # Generate a list of variable names. Under noise_profiling (ADR-0108) this is the # SEARCHED subset: the estimated noise scales the objective solves for analytically # have been partitioned out into config.profiled_noise_params, so every algorithm # builds its box, population and PSets over the reduced dimension without knowing # profiling exists. They remain estimated parameters for AIC/BIC (see # _compute_information_criteria) and are reported by _emit_profiled_noise. self.variables = self.config.variables # Store a list of all Model objects. Change this as needed for compatibility with other parts logger.debug('Initializing models') self.model_list = self._initialize_models() self.bootstrap_number = None # Retry counter for the current bootstrap replicate (0 on the first attempt). # _run_bootstrapping bumps it before re-resetting a rejected replicate so reset() # advances the RNG sub-stream and the retry resamples afresh (see reset()). self.bootstrap_attempt = 0 self.best_fit_obj = None self.calc_future = None # Created during Algorithm.run() self.models_future = None # Scattered model_list Future; created during Algorithm.run() self.refine = False # Random number generation. Every algorithm owns its own # np.random.Generator (PCG64) seeded from the run's resolved seed; the # legacy global np.random (MT19937) is no longer used. The seed was # resolved + logged by pybnf._initialize_random_seed before construction. self._init_rng() def _init_rng(self): """Build this algorithm's random streams from the resolved config seed. ``self.rng`` is the root Generator used for algorithm-level draws and for cross-chain coordination (replica exchange, outlier reset) that happens at deterministic synchronization barriers. ``self._seed_sequence`` spawns independent per-chain Generators on demand (:meth:`spawn_chain_rngs`). """ self._base_seed = self.config.config['random_seed'] self._reseed(np.random.SeedSequence(self._base_seed)) def _reseed(self, seed_sequence): """Point this algorithm's RNG at a (possibly fresh) SeedSequence. Used both at construction and per bootstrap replicate, where each replicate gets an independent, deterministic sub-stream so its fit is reproducible from the run seed yet distinct across replicates. """ self._seed_sequence = seed_sequence self.rng = np.random.default_rng(seed_sequence)
[docs] def spawn_chain_rngs(self, n): """Return ``n`` independent Generators, one per parallel chain/replica. Spawning is deterministic in ``(seed, n)``, so chain ``i`` always draws from the same stream regardless of the order in which dask returns its results. This is what makes the parallel samplers reproducible under nondeterministic scheduling -- a single shared stream would interleave by completion order and so would not reproduce run to run. """ return [np.random.default_rng(s) for s in self._seed_sequence.spawn(n)]
def _rebuild_chain_rngs(self): """Hook: rebuild any per-chain Generators after a :meth:`_reseed`. A no-op for the single-root-Generator optimizers; :class:`BayesianAlgorithm` overrides it to re-spawn its ``chain_rngs`` list. """
[docs] def reset(self, bootstrap): """ Resets the Algorithm, keeping loaded variables and models :param bootstrap: The bootstrap number (None if not bootstrapping) :type bootstrap: int or None :return: A rejected bootstrap replicate (objective over ``bootstrap_max_obj``) is retried with :attr:`bootstrap_attempt` incremented by the caller; that advances the RNG sub-stream so the retry draws a *fresh* resample and fit rather than repeating the identical failing run. ``bootstrap_attempt == 0`` (the first try, and every non-bootstrap reset) reproduces the historical replicate-only seeding byte for byte. """ logger.info('Resetting Algorithm for another run') self.trajectory = Trajectory(self.config.config['num_to_output']) self.job_id_counter = 0 self.output_counter = 0 # The next run writes its own Results directory, so nothing there describes a # best fit yet -- and PSet names restart from iter0run0, so a name carried over # from the previous run would suppress the first checkpoint (#560). self._ic_checkpoint_name = None self.job_group_dir = dict() self.fail_count = 0 self.success_count = 0 if bootstrap is not None: self.bootstrap_number = bootstrap self.sim_dir = self.config.config['output_dir'] + f'/Simulations-boot{bootstrap}' self.res_dir = self.config.config['output_dir'] + f'/Results-boot{bootstrap}' self.failed_logs_dir = self.config.config['output_dir'] + f'/FailedSimLogs-boot{bootstrap}' for boot_dir in (self.sim_dir, self.res_dir, self.failed_logs_dir): if os.path.exists(boot_dir): try: shutil.rmtree(boot_dir) except OSError: logger.error('Failed to remove bootstrap directory '+boot_dir) os.mkdir(boot_dir) # Give this bootstrap replicate an independent, deterministic RNG # sub-stream (keyed by the replicate number, plus the retry counter so a # retried replicate resamples afresh) so the replicate's fit -- and its # resampled data weights -- are reproducible from the run seed yet distinct # from the main fit and from every other replicate/retry. The first attempt # keeps the historical replicate-only spawn key for backward compatibility. attempt = self.bootstrap_attempt spawn_key = (bootstrap + 1,) if attempt == 0 else (bootstrap + 1, attempt) self._reseed(np.random.SeedSequence(self._base_seed, spawn_key=spawn_key)) self._rebuild_chain_rngs() self.best_fit_obj = None
def _param_vec(self, pset): """Project a PSet onto its parameter vector in sampling space ``u``, ordered by ``self.variables`` (``log10`` for log-scaled parameters, identity otherwise). The single PSet→u bridge: the Bayesian samplers use it for chain history and proposal arithmetic, the start-point optimizers for their search coordinate (where it is also exposed under the name ``_u_from_pset``). Hoisted here from ``BayesianAlgorithm`` once the start-point optimizers grew the identical transform (the ≥2-user event, ADR-0009). Asks each parameter for its θ→u transform rather than inlining ``log10`` (#412). """ return np.array( [v.to_sampling_space(pset[v.name]) for v in self.variables], dtype=float) def _pset_from_u(self, u, name=None, reflect=True): """Materialize a sampling-space vector ``u`` (ordered by ``self.variables``) into a PSet -- the inverse peer of :meth:`_param_vec`. Each coordinate is mapped back to a stored value by its parameter (``FreeParameter.from_sampling_space``) and assigned with ``set_value``, which folds it into the box when ``reflect`` is True. ``reflect=False`` lets a caller reject an out-of-bounds proposal instead of folding it: the offending ``set_value`` raises ``OutOfBoundsException`` (DREAM relies on this to discard a proposal rather than reflect it). Hoisted here next to the forward bridge so the u-vector↔PSet conversion lives in one place (#412); the start-point optimizers reach it through the ``_u_from_pset`` / ``_pset_from_u`` alias pair in ``local_base``. """ fps = [v.set_value(v.from_sampling_space(u[i]), reflect) for i, v in enumerate(self.variables)] ps = PSet(fps) if name is not None: ps.name = name return ps @staticmethod def _chain_index_from_name(name): """Parse the parallel-run index from a PSet name of the form ``...run<N>...`` — the ``iter%irun%i`` naming the population samplers and Simulated Annealing assign their per-chain PSets.""" return int(re.search(r'(?<=run)\d+', name).group(0))
[docs] @staticmethod def should_pickle(k): """ Checks to see if key 'k' should be included in pickling. Currently allows all entries in instance dictionary except for 'trajectory' 'budget' is excluded too: a wall-clock deadline is meaningless once restored into a later process, and a resumed run is a new run of its own -- ``main()`` builds it a fresh budget from the same ``wall_time_fit``, so ``--resume`` grants another full budget rather than inheriting an already-expired one (#529). 'method_chain' is excluded for the same reason: it records the *phases this process executed*, and ``main()`` builds the resumed run its own (#564). :param k: :return: """ return k not in set(['trajectory', 'calc_future', 'models_future', 'budget', 'method_chain'])
def __getstate__(self): return {k: v for k, v in self.__dict__.items() if self.should_pickle(k)} def __setstate__(self, state): self.__dict__.update(state) try: backup_params = 'sorted_params_backup.txt' if not self.refine else 'sorted_params_refine_backup.txt' self.trajectory = Trajectory.load_trajectory(f'{self.res_dir}/{backup_params}', self.config.variables, self.config.config['num_to_output']) except IOError: logger.exception('Failed to load trajectory from file') print1('Failed to load Results/sorted_params_backup.txt . Still resuming your run, but when I save the ' 'best fits, it will only be the ones I\'ve seen since resuming.') self.trajectory = Trajectory(self.config.config['num_to_output']) def _initialize_models(self): """ Checks initial BNGLModel instances from the Configuration object for models that can be reinstantiated as NetModel instances :return: list of Model instances """ # Todo: Move to config or BNGL model class? home_dir = os.getcwd() os.chdir(self.config.config['output_dir']) # requires creation of this directory prior to function call logger.debug('Copying list of models') init_model_list = copy.deepcopy(list(self.config.models.values())) # keeps Configuration object unchanged final_model_list = [] init_dir = str(Path(os.getcwd()) / 'Initialize') bngl_backend = self.config.config.get('bngl_backend', BNGL_BACKEND_AUTO) auto_bngsim = bngl_backend == BNGL_BACKEND_AUTO explicit_bngsim = bngl_backend == BNGL_BACKEND_BNGSIM allow_bngsim = auto_bngsim or explicit_bngsim bngsim_available = _bngsim_runtime_available() # Match the subprocess BNGLModel/NetModel behavior: when # delete_old_files=0 (keep every per-evaluation file), bngsim-backed # models must write .gdat/.scan during execute() so the final-results # copy at delete_old_files==0 finds something to copy. Best-fit reruns # flip this on explicitly below. bngsim_save_files = self.config.config.get('delete_old_files', 1) == 0 for m in init_model_list: bridge_backend = None if isinstance(m, BNGLModel): bridge_backend = classify_actions_for_bngsim(m.actions) missing_nf_support = () if isinstance(m, BNGLModel) and bridge_backend in (BNGSIM_BACKEND_NF, BNGSIM_BACKEND_HYBRID): missing_nf_support = missing_bngsim_nf_action_support(m.actions) if isinstance(m, BNGLModel) and explicit_bngsim: if bridge_backend not in BNGSIM_SUPPORTED_BNGL_BACKENDS: raise PybnfError( f'bngl_backend = bngsim was requested for model {m.name}, but its BNGL actions are not ' 'supported by the bngsim bridge.' ) if not bngsim_available: raise PybnfError( f'bngl_backend = bngsim was requested for model {m.name}, but {_bngsim_unavailable_reason()}.' ) if missing_nf_support: raise PybnfError( 'bngl_backend = bngsim was requested for model {}, but the installed bngsim ' 'does not provide {} support.'.format(m.name, ', '.join(missing_nf_support)) ) if isinstance(m, BNGLModel) and m.generates_network: logger.debug(f'Model {m.name} requires network generation') if not os.path.isdir(init_dir): logger.debug(f'Creating initialization directory: {init_dir}') os.mkdir(init_dir) os.chdir(init_dir) gnm_name = f'{m.name}_gen_net' default_pset = PSet([var.set_value(var.default_value) for var in self.variables]) m.save(gnm_name, gen_only=True, pset=default_pset) gn_cmd = [self.config.config['bng_command'], f'{gnm_name}.bngl'] if os.name == 'nt': # Windows # Explicitly call perl because the #! line in BNG2.pl is not supported. gn_cmd = ['perl'] + gn_cmd try: with open(f'{gnm_name}.log', 'w') as lf: print2(f'Generating network for model {gnm_name}.bngl') run_subprocess(gn_cmd, timeout=self.config.config['wall_time_gen'], stdout=lf, stderr=STDOUT) except CalledProcessError as c: logger.error(f"Command {gn_cmd} failed in directory {os.getcwd()}") logger.error(c.stdout) print0(f'Error: Initial network generation failed for model {m.name}... see BioNetGen error log at ' f'{os.getcwd()}/{gnm_name}.log') exit(1) except TimeoutExpired: logger.debug("Network generation exceeded %d seconds... exiting" % self.config.config['wall_time_gen']) print0("Network generation took too long. Increase 'wall_time_gen' configuration parameter") exit(1) except: tb = traceback.format_exc() logger.debug(f"Other exception occurred:\n{tb}") print0("Unknown error occurred during network generation, see log... exiting") exit(1) finally: os.chdir(home_dir) logger.info(f'Output for network generation of model {m.name} logged in {init_dir}/{gnm_name}.log') net_path = str(Path(init_dir) / f'{gnm_name}.net') use_bngsim = allow_bngsim and bngsim_available and bridge_backend == BNGSIM_BACKEND_NET use_hybrid = ( allow_bngsim and bngsim_available and bridge_backend in (BNGSIM_BACKEND_NF, BNGSIM_BACKEND_HYBRID) and not missing_nf_support ) if auto_bngsim and bngsim_available and bridge_backend not in BNGSIM_SUPPORTED_BNGL_BACKENDS: logger.info( 'Model %s uses actions not supported by the `.net` bngsim bridge; ' 'falling back to BioNetGen subprocess simulation', m.name, ) if use_hybrid: # Hybrid path: generate_network already ran; now generate XML # by running BNG2.pl again with generate_network + writeXML logger.info( 'Model %s is hybrid (generate_network + NF simulate); ' 'generating XML for bngsim network-free simulation', m.name, ) os.chdir(init_dir) hybrid_name = f'{m.name}_gen_hybrid' m_copy = copy.deepcopy(m) m_copy.actions = ['writeXML()'] try: m_copy.save(hybrid_name, pset=default_pset) except Exception as exc: if explicit_bngsim: os.chdir(home_dir) raise PybnfError( f'bngl_backend = bngsim was requested for model {m.name}, but staging the ' f'hybrid BNGL for XML generation failed: {exc}' ) logger.exception( 'Failed to stage the hybrid BNGL for model %s. ' 'Falling back to subprocess simulation.', m.name, ) os.chdir(home_dir) final_model_list.append(m) final_model_list[-1].bng_command = m.bng_command continue hybrid_cmd = [self.config.config['bng_command'], f'{hybrid_name}.bngl'] if os.name == 'nt': hybrid_cmd = ['perl'] + hybrid_cmd try: with open(f'{hybrid_name}.log', 'w') as lf: print2(f'Generating XML for hybrid model {hybrid_name}.bngl') run_subprocess( hybrid_cmd, timeout=self.config.config['wall_time_gen'], stdout=lf, stderr=STDOUT, ) except (CalledProcessError, TimeoutExpired, Exception) as exc: if explicit_bngsim: raise PybnfError( f'bngl_backend = bngsim was requested for model {m.name}, but hybrid XML ' f'generation failed: {exc}' ) logger.exception( 'Hybrid XML generation failed for model %s. ' 'Falling back to subprocess simulation.', m.name, ) os.chdir(home_dir) final_model_list.append(m) final_model_list[-1].bng_command = m.bng_command continue finally: os.chdir(home_dir) xml_path = str(Path(init_dir) / f'{hybrid_name}.xml') if not os.path.isfile(xml_path): if explicit_bngsim: raise PybnfError( f'bngl_backend = bngsim was requested for model {m.name}, but hybrid XML ' f'generation did not produce {xml_path}.' ) logger.warning( 'XML file not found at %s for model %s. ' 'Falling back to subprocess simulation.', xml_path, m.name, ) final_model_list.append(m) final_model_list[-1].bng_command = m.bng_command continue try: model = BngsimNfModel( m.name, m.actions, m.suffixes, m.mutants, xml_path, bngl_model_lines=m.model_lines, split_line_index=m.split_line_index, param_names=m.param_names, source_dir=os.path.dirname(os.path.abspath(m.file_path)), protocol=m.protocol, save_files=bngsim_save_files, ) model.bng_command = m.bng_command final_model_list.append(model) except Exception as exc: if explicit_bngsim: raise PybnfError( f'bngl_backend = bngsim was requested for model {m.name}, but bngsim NF ' f'bridge initialization failed: {exc}' ) logger.exception( 'Failed to initialize the bngsim NF bridge for hybrid model %s. ' 'Falling back to BNGLModel subprocess simulation.', m.name, ) final_model_list.append(m) final_model_list[-1].bng_command = m.bng_command elif use_bngsim: try: logger.info(f'Using bngsim for in-process simulation of model {m.name}') model = BngsimModel(m.name, m.actions, m.suffixes, m.mutants, nf=net_path, protocol=m.protocol, save_files=bngsim_save_files) except Exception as exc: if explicit_bngsim: raise PybnfError( f'bngl_backend = bngsim was requested for model {m.name}, but bngsim bridge ' f'initialization failed: {exc}' ) logger.exception( 'Failed to initialize bngsim bridge for model %s. Falling back to NetModel.', m.name, ) model = NetModel(m.name, m.actions, m.suffixes, m.mutants, nf=net_path) final_model_list.append(model) final_model_list[-1].bng_command = m.bng_command else: model = NetModel(m.name, m.actions, m.suffixes, m.mutants, nf=net_path) final_model_list.append(model) final_model_list[-1].bng_command = m.bng_command elif isinstance(m, BNGLModel) and allow_bngsim and bridge_backend == BNGSIM_BACKEND_NF: if not bngsim_available: if explicit_bngsim: raise PybnfError( f'bngl_backend = bngsim was requested for model {m.name}, but {_bngsim_unavailable_reason()}.' ) logger.info( 'Model %s uses NF actions, but bngsim is not available; ' 'falling back to BioNetGen subprocess simulation', m.name, ) final_model_list.append(m) continue if missing_nf_support: if explicit_bngsim: raise PybnfError( 'bngl_backend = bngsim was requested for model {}, but the installed bngsim ' 'does not provide {} support.'.format(m.name, ', '.join(missing_nf_support)) ) logger.info( 'Model %s uses NF actions, but the installed bngsim lacks %s support; ' 'falling back to BioNetGen subprocess simulation', m.name, ', '.join(missing_nf_support), ) final_model_list.append(m) continue logger.info(f'Model {m.name} is NF-only; generating XML for bngsim network-free simulation') if not os.path.isdir(init_dir): logger.debug(f'Creating initialization directory: {init_dir}') os.mkdir(init_dir) os.chdir(init_dir) gnm_name = f'{m.name}_gen_xml' default_pset = PSet([var.set_value(var.default_value) for var in self.variables]) m_copy = copy.deepcopy(m) m_copy.actions = ['writeXML()'] try: m_copy.save(gnm_name, pset=default_pset) except Exception as exc: if explicit_bngsim: os.chdir(home_dir) raise PybnfError( f'bngl_backend = bngsim was requested for model {m.name}, but staging the ' f'XML-generation BNGL failed: {exc}' ) logger.exception( 'Failed to stage the XML-generation BNGL for model %s. ' 'Falling back to subprocess simulation.', m.name, ) os.chdir(home_dir) final_model_list.append(m) continue gn_cmd = [self.config.config['bng_command'], f'{gnm_name}.bngl'] if os.name == 'nt': gn_cmd = ['perl'] + gn_cmd try: with open(f'{gnm_name}.log', 'w') as lf: print2(f'Generating XML for network-free model {gnm_name}.bngl') run_subprocess( gn_cmd, timeout=self.config.config['wall_time_gen'], stdout=lf, stderr=STDOUT, ) except CalledProcessError as c: logger.error(f"Command {gn_cmd} failed in directory {os.getcwd()}") logger.error(c.stdout) if explicit_bngsim: raise PybnfError( f'bngl_backend = bngsim was requested for model {m.name}, but XML generation ' f'failed: {c}' ) logger.warning( 'XML generation failed for model %s. Falling back to subprocess simulation.', m.name, ) os.chdir(home_dir) final_model_list.append(m) continue except TimeoutExpired: if explicit_bngsim: raise PybnfError( f'bngl_backend = bngsim was requested for model {m.name}, but XML generation ' 'timed out.' ) logger.warning( 'XML generation timed out for model %s. Falling back to subprocess simulation.', m.name, ) os.chdir(home_dir) final_model_list.append(m) continue except Exception as exc: if explicit_bngsim: raise PybnfError( f'bngl_backend = bngsim was requested for model {m.name}, but XML generation ' f'failed: {exc}' ) logger.exception( 'Unknown error during XML generation for model %s. ' 'Falling back to subprocess simulation.', m.name, ) os.chdir(home_dir) final_model_list.append(m) continue finally: os.chdir(home_dir) xml_path = str(Path(init_dir) / f'{gnm_name}.xml') if not os.path.isfile(xml_path): if explicit_bngsim: raise PybnfError( f'bngl_backend = bngsim was requested for model {m.name}, but XML generation did ' f'not produce {xml_path}.' ) logger.warning( 'XML file not found at %s for model %s. Falling back to subprocess simulation.', xml_path, m.name, ) final_model_list.append(m) continue try: model = BngsimNfModel( m.name, m.actions, m.suffixes, m.mutants, xml_path, bngl_model_lines=m.model_lines, split_line_index=m.split_line_index, param_names=m.param_names, source_dir=os.path.dirname(os.path.abspath(m.file_path)), protocol=m.protocol, save_files=bngsim_save_files, ) model.bng_command = m.bng_command final_model_list.append(model) except Exception as exc: if explicit_bngsim: raise PybnfError( f'bngl_backend = bngsim was requested for model {m.name}, but bngsim NF bridge ' f'initialization failed: {exc}' ) logger.exception( 'Failed to initialize the bngsim NF bridge for model %s. ' 'Falling back to BNGLModel subprocess simulation.', m.name, ) final_model_list.append(m) else: logger.info(f'Model {m.name} does not require network generation') final_model_list.append(m) os.chdir(home_dir) # Off-diagonal cross-product pruning (#484, ADR-0069): attach each model's emit-set # (the full output suffixes any consumer reads) to the *runtime* model, so the # backend execute() simulates only the scored (action, condition) diagonal. Set here # rather than in config because this method rebuilds the models (a fresh # BngsimModel/NetModel per model drops config-set attributes -- the same reason the # gradient path sets set_scored_suffixes on the model_list, not in config). A model # config left out of emit_suffixes gets None -> pruning off -> byte-identical. Rides # copy.copy/scatter to the workers alongside the model. emit_suffixes = getattr(self.config, 'emit_suffixes', {}) or {} if emit_suffixes: # only edition-2 Mechanism-A jobs; else models keep the None default for model in final_model_list: model.emit_suffixes = emit_suffixes.get(model.name) return final_model_list
[docs] @abstractmethod def start_run(self): """ Called by the scheduler at the start of a fitting run. Must return a list of PSets that the scheduler should run. Algorithm subclasses optionally may set the .name field of the PSet objects to give a meaningful unique identifier such as 'gen0ind42'. If so, they MUST BE UNIQUE, as this determines the folder name. Uniqueness will not be checked elsewhere. :return: list of PSets """
[docs] @abstractmethod def got_result(self, res): """ Called by the scheduler when a simulation is completed, with the pset that was run, and the resulting simulation data :param res: result from the completed simulation :type res: Result :return: List of PSet(s) to be run next or 'STOP' string. """
[docs] def score_result(self, res): """Make sure this Result carries an objective value, and return it. Does nothing when the workers already scored it (the default path, where an ObjectiveCalculator was scattered). Otherwise normalizes, post-processes, and scores it here on the master, which is the path a smoothing / model-parallel / gradient fit takes. Anything that goes wrong penalizes this one evaluation with an infinite objective rather than crashing the run (lanl/PyBNF#388). Split out of :meth:`add_to_trajectory` so the end-of-fit confirmation stage (#659), which scores results it deliberately does not put in the trajectory, goes through exactly the same path the fit did. """ # Evaluate objective if it wasn't done on workers. if res.score is None: # Check if the objective wasn't evaluated on the workers try: res.normalize(self.config.config['normalization']) # Do custom postprocessing, if any try: res.postprocess_data(self.config.postprocessing) except Exception: logger.exception('User-defined post-processing script failed') traceback.print_exc() print0('User-defined post-processing script failed') res.score = np.inf else: res.score = self.objective.evaluate_multiple(res.simdata, self.exp_data, res.pset, self.config.constraints) except Exception: # A failure while normalizing or scoring this one parameter set should # penalize this evaluation, not crash the whole run. See lanl/PyBNF#388. logger.exception(f'Objective evaluation failed for Result {res.name}') res.score = np.inf print1(f'Objective evaluation failed for Result {res.name}; discarding this parameter set') if res.score is None: # Check if the above evaluation failed res.score = np.inf logger.warning(f'Simulation corresponding to Result {res.name} contained NaNs or Infs') logger.warning(f'Discarding Result {res.name} as having an infinite objective function value') print1(f'Simulation data in Result {res.name} has NaN or Inf values. Discarding this parameter set') return res.score
[docs] def add_to_trajectory(self, res): """ Adds the information from a Result to the Trajectory instance """ self.score_result(res) logger.debug(f'Adding Result {res.name} to Trajectory with score {res.score:.4f}') self.trajectory.add(res.pset, res.score, res.name)
[docs] def random_pset(self): """ Generates a random PSet based on the distributions and bounds for each parameter specified in the configuration :return: """ logger.debug("Generating a randomly distributed PSet") pset_vars = [] for var in self.variables: pset_vars.append(var.sample_initial_value(self.rng)) return PSet(pset_vars)
def _seed_start_point_pset(self, sampled_pset): """Pin the parameters that declare a start point to it, in ONE pset. ADR-0043 Phase 2, re-pointed at the unified carrier by ADR-0117. The declared start point -- ``start_point = <p> <v>`` lines and ``parameter:`` records' ``initial_value:`` fields alike, resolved and validated into ``Configuration.start_point`` -- is where the search/walk should begin (a PEtab ``nominalValue``, a published optimum). Exactly one member of a population algorithm's initial population is seeded there: this helper takes a member already drawn from the prior/bounds and overwrites each parameter that declares a start, leaving every other parameter at its sampled draw. So a partially-specified seed is still a complete pset -- pinned where a start was given, drawn otherwise. Returns ``sampled_pset`` unchanged when nothing declares a start point (the common case), so runs without one are byte-for-byte unaffected. Only this one member is ever touched, so the rest of the population keeps the full diversity global search (de/pso/ss) needs; this is distinct from the sampler-only ``starting_params``, which overrode *every* chain uniformly. Reading the config's resolved dict rather than ``FreeParameter.value`` is what makes the carrier universal: ``.value`` is unset for a no-prior record (whose start the loader folds into ``p1`` in sampling space instead) and unreachable from a legacy ``*_var`` conf, so it could never have carried the start point for every declaration style. Values in the dict are already validated against the declared box, so ``set_value`` here can never fold one. """ declared = getattr(self.config, 'start_point', None) or {} if not declared: return sampled_pset # Only the FIRST start is pinned. ``MultiStartOptimizer`` re-enters the inner # search's start_run once per start, so without this gate a declared start point # would re-seed one member of every start's population -- turning an n_starts # multi-start into n partially degenerate searches, and disagreeing with the # start-point optimizers, where a declared start pins start 0 and leaves the rest # scattered. ``_start_index`` is absent on a single-start algorithm, hence the 0 # default (#583). if getattr(self, '_start_index', 0) != 0: return sampled_pset fps = [v.set_value(declared[v.name], reflect=False) if v.name in declared else sampled_pset.get_param(v.name) for v in self.variables] seeded = PSet(fps) seeded.name = sampled_pset.name return seeded
[docs] def random_latin_hypercube_psets(self, n): """ Generates n random PSets with a latin hypercube distribution. Variables with bounded initialization distributions follow the Latin hypercube; the others are randomized independently from their initializer. :param n: Number of psets to generate :return: """ logger.debug("Generating PSets using Latin hypercube sampling") num_uniform_vars = sum(1 for var in self.variables if var.has_bounded_initialization) # Generate latin hypercube of dimension = number of uniformly distributed variables. rands = latin_hypercube(n, num_uniform_vars, self.rng) psets = [] for row in rands: # Initialize the variables # Convert the 0 to 1 random numbers to the required variable range pset_vars = [] rowindex = 0 for var in self.variables: if var.has_bounded_initialization: pset_vars.append(var.initial_value_from_quantile(row[rowindex])) rowindex += 1 else: pset_vars.append(var.sample_initial_value(self.rng)) psets.append(PSet(pset_vars)) return psets
def _job_models(self, model_slice=None): """Return the ``(models, model_slice)`` pair to hand a Job. Prefer the model_list Future scattered once per fit in :meth:`run` (so a submit re-pickles only a lightweight Future, not the whole model graph); the Job resolves and slices it worker-side. Fall back to a concrete (already-sliced) list when no scatter is available -- e.g. make_job called outside run() (some tests). ``model_slice`` is ``(start, stop)`` or ``None`` for the whole list. See issue #416. """ if self.models_future is not None: return self.models_future, model_slice if model_slice is None: return self.model_list, None return self.model_list[model_slice[0]:model_slice[1]], None
[docs] def make_job(self, params, replicate_offset=0): """ Creates a new Job using the specified params, and additional specifications that are already saved in the Algorithm object If smoothing or model-level parallelism is turned on, makes grouped subjobs. ``replicate_offset`` shifts every job's replicate index by a fixed amount. Under the default ``stochastic_seed`` policy a stochastic simulation's seed is derived from the parameter values and the replicate index and nothing else, so running the same parameter set again with the same index reproduces the same trajectory exactly. The end-of-fit confirmation stage (#659) needs fresh trajectories for a parameter set the fit has already run, so it asks for an offset past every index the fit itself used. Zero, the default, is the ordinary path and is unchanged. :param params: :type params: PSet :param replicate_offset: Amount to add to every job's replicate index :type replicate_offset: int :return: list of Jobs """ if params.name: job_id = params.name else: self.job_id_counter += 1 job_id = 'sim_%i' % self.job_id_counter logger.debug(f'Creating Job {job_id}') if self.config.config['smoothing'] > 1 and self.config.config['parallelize_models'] > 1: # Create smoothing replicates, and partition each replicate's model list across jobs newjobs = [] replica_subjob_ids = [] model_count = len(self.model_list) rep_count = self.config.config['parallelize_models'] for rep in range(self.config.config['smoothing']): replica_id = '%s_rep%i' % (job_id, rep) newnames = [] for part in range(rep_count): thisname = '%s_part%i' % (replica_id, part) newnames.append(thisname) models, mslice = self._job_models( (model_count*part//rep_count, model_count*(part+1)//rep_count)) newjobs.append(core.Job(models, params, thisname, self.sim_dir, self.config.config['wall_time_sim'], self.calc_future, self.config.config['normalization'], dict(), bool(self.config.config['delete_old_files']), replicate_index=replicate_offset + rep, stochastic_seed_policy=self.config.config['stochastic_seed'], model_slice=mslice)) replica_subjob_ids.append((replica_id, newnames)) new_group = HybridJobGroup(job_id, replica_subjob_ids) for n in new_group.subjob_ids: self.job_group_dir[n] = new_group return newjobs elif self.config.config['smoothing'] > 1: # Create multiple identical Jobs for use with smoothing newjobs = [] newnames = [] for i in range(self.config.config['smoothing']): thisname = '%s_rep%i' % (job_id, i) newnames.append(thisname) # calc_future is supposed to be None here - the workers don't have enough info to calculate the # objective on their own models, mslice = self._job_models() newjobs.append(core.Job(models, params, thisname, self.sim_dir, self.config.config['wall_time_sim'], self.calc_future, self.config.config['normalization'], dict(), bool(self.config.config['delete_old_files']), replicate_index=replicate_offset + i, stochastic_seed_policy=self.config.config['stochastic_seed'], model_slice=mslice)) new_group = JobGroup(job_id, newnames) for n in newnames: self.job_group_dir[n] = new_group return newjobs elif self.config.config['parallelize_models'] > 1: # Partition our model list into n different jobs newjobs = [] newnames = [] model_count = len(self.model_list) rep_count = self.config.config['parallelize_models'] for i in range(rep_count): thisname = '%s_part%i' % (job_id, i) newnames.append(thisname) # calc_future is supposed to be None here - the workers don't have enough info to calculate the # objective on their own models, mslice = self._job_models( (model_count*i//rep_count, model_count*(i+1)//rep_count)) newjobs.append(core.Job(models, params, thisname, self.sim_dir, self.config.config['wall_time_sim'], self.calc_future, self.config.config['normalization'], dict(), bool(self.config.config['delete_old_files']), replicate_index=replicate_offset, stochastic_seed_policy=self.config.config['stochastic_seed'], model_slice=mslice)) new_group = MultimodelJobGroup(job_id, newnames) for n in newnames: self.job_group_dir[n] = new_group return newjobs else: # Create a single job models, mslice = self._job_models() return [core.Job(models, params, job_id, self.sim_dir, self.config.config['wall_time_sim'], self.calc_future, self.config.config['normalization'], self.config.postprocessing, bool(self.config.config['delete_old_files']), replicate_index=replicate_offset, stochastic_seed_policy=self.config.config['stochastic_seed'], model_slice=mslice)]
[docs] def output_results(self, name='', no_move=False): """ Tells the Trajectory to output a log file now with the current best fits. This should be called periodically by each Algorithm subclass, and is called by the Algorithm class at the end of the simulation. :return: :param name: Custom string to add to the saved filename. If omitted, we just use a running counter of the number of times we've outputted. :param no_move: If True, overrides the config setting delete_old_files=2, and does not move the result to overwrite sorted_params.txt :type name: str """ if name == '': name = str(self.output_counter) self.output_counter += 1 alias = None if self.refine: # A refine's END-OF-RUN output is the RUN's end-of-run output, so it is # written under the conventional name too, not only the refine_-prefixed one. # Historically sorted_params_final.txt kept the PRE-refine point while # information_criteria.txt (which the refiner rewrites from the same tail) # described the refined one -- two files in Results/ disagreeing about which # parameter set they describe, with the conventional name carrying the point # the user's requested method chain did not end on (#564). if name == 'final': alias = 'final' name = f'refine_{name}' filepath = f'{self.res_dir}/sorted_params_{name}.txt' logger.info(f'Outputting results to file {filepath}') self.trajectory.write_to_file(filepath) # If the user has asked for fewer output files, each time we're here, move the new file to # Results/sorted_params.txt, overwriting the previous one. if self.config.config['delete_old_files'] >= 2 and not no_move: logger.debug("Overwriting previous 'sorted_params.txt'") noname_filepath = f'{self.res_dir}/sorted_params.txt' if os.path.isfile(noname_filepath): os.remove(noname_filepath) os.replace(filepath, noname_filepath) # That single file is already the alias: everything collapses into it. elif alias is not None: alias_path = f'{self.res_dir}/sorted_params_{alias}.txt' logger.info(f'Also outputting the refined results to {alias_path}') self.trajectory.write_to_file(alias_path)
[docs] def backup(self, pending_psets=()): """ Create a backup of this algorithm object that can be reloaded later to resume the run :param pending_psets: Iterable of PSets that are currently submitted as jobs, and will need to get re-submitted when resuming the algorithm :return: """ logger.info('Saving a backup of the algorithm') # Save a backup of the PSets self.output_results(name='backup', no_move=True) # Pickle the algorithm # Save to a temporary file first, so we can't get interrupted and left with no backup. picklepath = '{}/alg_backup.bp'.format(self.config.config['output_dir']) temppicklepath = '{}/alg_backup_temp.bp'.format(self.config.config['output_dir']) try: with open(temppicklepath, 'wb') as f: pickle.dump((self, pending_psets), f) os.replace(temppicklepath, picklepath) except IOError as e: logger.exception('Failed to save backup of algorithm') print1('Failed to save backup of the algorithm.\nSee log for more information') if e.strerror == 'Too many open files': print0('Too many open files! See "Troubleshooting" in the documentation for how to deal with this ' 'problem.') # Then the other half of a scoreable result: the absolute log-likelihood behind the # information criteria, which lives in no parameter table (#560). LAST, because it is # the only part of a checkpoint that can take a while -- it re-simulates the best fit # -- and a kill during it must not leave the resume state a whole interval behind the # parameter sets it belongs with. self._checkpoint_information_criteria()
[docs] def get_backup_every(self): """ Returns a number telling after how many individual simulation returns should we back up the algorithm. Makes a good guess, but could be overridden in a subclass """ return self.config.config['backup_every'] * self.config.config['population_size'] * \ self.config.config['smoothing']
[docs] def add_iterations(self, n): """ Adds n additional iterations to the algorithm. May be overridden in subclasses that don't use self.max_iterations to track the iteration count """ self.max_iterations += n
def _fold_group_result(self, res): """Accumulate one completed sub-result into its JobGroup (smoothing / model-level parallelism). Returns the combined Result once every sub-job in the group has finished, or None if more are still pending. Split out of run() so the folding decision can be unit-tested without a dask client. See tests/test_run_loop.py. """ group = self.job_group_dir.pop(res.name) done = group.job_finished(res) if not done: return None return group.average_results() def _record_result_and_decide(self, res): """Classify a completed (already group-folded) result, record it in the trajectory, and decide what the run loop should do next. Returns ``'STOP'`` to end the run, or the list of PSets the algorithm wants evaluated next. Raises ``PybnfError`` on a fatal condition (all jobs failing with none succeeding, or a cancelled future). Split out of run() so these decisions can be unit-tested without a dask client. See tests/test_run_loop.py. """ if isinstance(res, FailedSimulation): if res.fail_type >= 1: self.fail_count += 1 tb = '\n'+res.traceback if res.fail_type == 1 else '' logger.debug('Job %s failed with code %d%s' % (res.name, res.fail_type, tb)) if res.fail_type >= 1: print1(f'Job {res.name} failed') else: print1(f'Job {res.name} timed out') if self.success_count == 0 and self.fail_count >= self.config.config['max_failed_simulations']: raise PybnfError('Aborted because all jobs are failing', 'Your simulations are failing to run. Logs from failed simulations are saved in ' 'the FailedSimLogs directory. For help troubleshooting this error, refer to ' 'https://lanl.github.io/PyBNF/troubleshooting.html#failed-simulations') elif isinstance(res, CancelledError): raise PybnfError('PyBNF has encountered a fatal error. If the error has occurred on the initial run please verify your model ' 'is functional. To resume the run please restart PyBNF using the -r flag') else: self.success_count += 1 logger.debug('Job %s complete') self.add_to_trajectory(res) if res.score < self.config.config['min_objective']: logger.info('Minimum objective value achieved') print1('Minimum objective value achieved') return 'STOP' response = self.got_result(res) if response == 'STOP': self.best_fit_obj = self.trajectory.best_score() logger.info(f"Stop criterion satisfied with objective function value of {self.best_fit_obj}") print1(f"Stop criterion satisfied with objective function value of {self.best_fit_obj}") return 'STOP' return response
[docs] def run(self, client, resume=None, debug=False): """Main loop for executing the algorithm. Submits work, drains completions through :meth:`_drain_job_pool`, and then runs the end-of-fit path -- final parameter sets, best-fit simulations, information criteria, the inference-data sidecar, the backup rename, the sim-dir teardown. That path is reached the same way however the loop ended: on the algorithm's own stop criterion, on an exhausted job pool, or on the wall-time budget (``wall_time_fit``, #529). A budgeted run is a *finished* run whose search was cut short, so it writes exactly what a converged one writes; only :attr:`stop_reason` differs (ADR-0093). """ self.stop_reason = None self.completed_simulations = 0 if self.refine: logger.debug('Setting up Simplex refinement of previous algorithm') backup_every = self.get_backup_every() logger.debug('Generating initial parameter sets') if resume: psets = resume logger.debug('Resume algorithm with the following PSets: %s' % [p.name for p in resume]) else: psets = self.start_run() # Record where this fit actually began, before anything is scored (#583, ADR-0117). # Placed here because it is the one point every family passes through with its start # in hand: the start-point optimizers resolved theirs in __init__, the populations and # samplers in the start_run() just above, and a resumed run has none of either. self._emit_start_point(psets, resumed=bool(resume)) if not os.path.isdir(self.failed_logs_dir): os.mkdir(self.failed_logs_dir) # Decide where the objective is scored. The default offloads scoring to # the workers (scatter an ObjectiveCalculator) for parallelism. A # gradient optimizer instead needs every Result's simdata back on the # master to assemble the residual Jacobian (#386), so it forces # master-side scoring via requires_master_scoring -- the worker path # would otherwise null res.simdata after scoring (#385). if self.config.config['local_objective_eval'] == 0 and self.config.config['smoothing'] == 1 and \ self.config.config['parallelize_models'] == 1 and not self.requires_master_scoring: calculator = ObjectiveCalculator(self.objective, self.exp_data, self.config.constraints) [self.calc_future] = client.scatter([calculator], broadcast=True) else: self.calc_future = None # Scatter the parameter-independent model_list once and broadcast it to # every worker, so each job submission carries a lightweight Future # instead of re-serializing the whole model graph on every evaluation # (parameters live in the separate PSet). Jobs resolve and slice it # worker-side; see make_job and core.Job._get_models. Mirrors the # calc_future scatter above (issue #416). # # Both scattered Futures (models_future here, calc_future above) are # handed to client.submit below as *direct kwargs* -- NOT left buried as # Job attributes. dask only substitutes a Future for its concrete value # when the Future is a direct submit arg; a Future pickled inside the Job # deserializes broken on the worker under distributed >= 2026.6.0 and # raises on .result() (lanl/PyBNF #476). core.run_job rebinds the resolved # values onto the Job. See core._ResolvedFuture. [self.models_future] = client.scatter([self.model_list], broadcast=True) pending = dict() # Maps pending futures to tuple (PSet, job_id). if self._budget_spent(): # The budget was already gone before a single job was submitted (model # loading / network generation ate it). Launch nothing -- an expired budget # means "stop launching new work" -- and go straight to the finalize path, # which reports whatever this run has (nothing, on a fresh fit). self.stop_reason = self._wall_time_stop_reason(0) else: jobs = [] for p in psets: jobs += self.make_job(p) jobs[0].show_warnings = True # For only the first job submitted, show warnings if exp data is unused. logger.info('Submitting initial set of %d Jobs' % len(jobs)) futures = [] for job in jobs: f = client.submit(core.run_job, job, True, self.failed_logs_dir, models=self.models_future, calc=self.calc_future) futures.append(f) pending[f] = (job.params, job.job_id) # With a wall-time budget, bound the blocking wait for the next completion by # what is left of it, so the deadline lands on time even while every worker is # mid-simulation: as_completed then raises TimeoutError out of next() instead # of blocking until a simulation happens to finish (#529). Without a budget the # call is exactly the historical one. pool_kwargs = {'timeout': self.budget.remaining()} if self.budget is not None else {} pool = core.as_completed(futures, with_results=True, raise_errors=False, **pool_kwargs) self._report_parallelism(client, len(futures), len(psets)) self.completed_simulations = self._drain_job_pool(client, pool, pending, backup_every, debug) logger.info("Cancelling %d pending jobs" % len(pending)) client.cancel(list(pending.keys())) self._finalize_run(client)
[docs] def expected_parallelism(self): """How many parameter sets this fit has out for evaluation at once once it is under way, or None when that is however many the run loop submits in its first batch (#655). The run loop knows only the size of the first batch of jobs it submits. For most fits that is also how many run at once for the rest of the fit, so the default here is None and :meth:`_report_parallelism` uses the first batch. A fit whose first batch is a one-time initialization round of a different size overrides this and returns the number it settles at, so the parallelism report describes the fit rather than its opening move. Scatter search is the case that prompted this: it starts with ``init_size`` parameter sets and then runs ``population_size * (population_size - 1)`` simulations per iteration forever after. Count parameter sets, not jobs. One parameter set is several jobs under ``smoothing`` or ``parallelize_models``, and :meth:`_report_parallelism` applies that multiplier itself. """ return None
def _report_parallelism(self, client, jobs_in_flight, psets_in_flight=None): """Log how many jobs the fit runs at once against how many workers connected, and warn when the two differ by a large margin (#621). How many simulations a fit runs at once follows its settings, usually population_size, not how many processors were reserved. When many more workers connect than there are jobs to run, the extra workers sit idle for the whole run and nothing else would say so, so a user can reserve several machines and quietly use a fraction of them. The opposite, many more jobs than workers, means work queues up and the larger population buys no extra speed. Either way both numbers go in the log so a finished run can be looked at afterwards. The number reported is the one the fit sustains, from :meth:`expected_parallelism`, which is not always the size of the first batch of jobs the run loop submits (#655). When the two differ, the first batch is a one-time initialization round, so it is reported as such and the warnings are decided on the sustained number. ``psets_in_flight`` is how many parameter sets that first batch of jobs came from, which is how a parameter-set count is converted into a job count; None means one job per parameter set. Only cluster runs are reported. A local run's worker count is exactly what the user asked for through parallel_count, so there is nothing to compare it against. The worker count comes from dask; anything that goes wrong reading it is logged and never stops a fit. """ # A local run drives a LocalCluster that the Client owns, so client.cluster is set. # A cluster run connects to a scheduler by file or address and has no such object. if getattr(client, 'cluster', None) is not None: return try: n_workers = len(client.scheduler_info().get('workers', {})) except Exception: logger.exception('Could not read the number of connected workers from dask, ' 'so the parallelism report is skipped') return if n_workers <= 0: return n_jobs = self.expected_parallelism() if n_jobs is None or n_jobs <= 0: n_jobs = jobs_in_flight elif psets_in_flight: # expected_parallelism() counts parameter sets, but one parameter set is more # than one job when smoothing runs replicates of it or parallelize_models # splits it across models. The first batch shows how many jobs a parameter set # becomes, and that ratio holds for the rest of the fit. n_jobs = int(round(n_jobs * jobs_in_flight / psets_in_flight)) logger.info('Parallelism: the fit runs %d job(s) at a time and %d worker(s) are ' 'connected.' % (n_jobs, n_workers)) notes = [] # A first batch of a different size is a one-time initialization round. Say so, so # that a user watching the start of the fit is not surprised by it and does not # read it as how busy the fit will be. if jobs_in_flight != n_jobs: notes.append('This fit begins with a one-time round of %d job(s) before it ' 'settles at %d.' % (jobs_in_flight, n_jobs)) # A generational fit drains each generation to almost nothing before starting the # next, so some idle time is expected with one and should not be read as a fault. if self.waits_for_full_generation: notes.append('This fit runs one generation at a time and waits for all of it ' 'to finish before starting the next, so some idle time toward ' 'the end of each generation is expected.') note = (' ' + ' '.join(notes)) if notes else '' # Name the setting that sizes the concurrency, when one setting does. A fit where # none does (profile likelihood, whose concurrency follows how many parameters it # profiles) leaves the advice to talk about processors only. setting = self.parallelism_setting by_setting = ('the fitting settings, mainly %s,' % setting) if setting \ else 'the fitting settings,' raise_setting = ('raising %s or ' % setting) if setting else '' lower_setting = ('lowering %s or ' % setting) if setting else '' # A factor of two in either direction is the "large margin" that draws a warning. if n_workers >= 2 * n_jobs: msg = ('The fit runs only %d job(s) at a time but %d worker(s) are connected, ' 'so about %d worker(s) will sit idle. How many jobs run at once is set ' 'by %s not by how many processors were reserved. ' 'Consider %sreserving fewer processors.%s' % (n_jobs, n_workers, n_workers - n_jobs, by_setting, raise_setting, note)) logger.warning(msg) print1('Warning: ' + msg) elif n_jobs >= 2 * n_workers: msg = ('The fit runs %d job(s) at a time but only %d worker(s) are connected, ' 'so jobs will queue and the extra jobs buy no extra speed. How many ' 'jobs run at once is set by %s not by how many processors were ' 'reserved. Consider %sreserving more processors.%s' % (n_jobs, n_workers, by_setting, lower_setting, note)) logger.warning(msg) print1('Warning: ' + msg) elif note: # The counts are close, but there is still something worth putting on the # record for this run. logger.info(note.strip()) def _finalize_run(self, client=None): """The end-of-fit path: stop reason, final parameter sets, best-fit artifacts, teardown. ``client`` is the dask client the run was driven with, which the best-fit confirmation stage (#659) needs because it submits simulations of its own. It is optional so a caller that has no client -- some tests -- still gets every other artifact; that stage is the only thing skipped without one. Reached the same way however the search ended -- the algorithm's own stop criterion, an exhausted job pool, or the wall-time budget -- because a budgeted run is a *finished* run whose search was cut short and writes exactly what a converged one writes (ADR-0093). Split out of :meth:`run` so a method that does **not** drive the propose/score loop still writes the same artifacts every other fit type does, rather than forking the tail: today that is ``job_type = ms``, whose unit of work is a trajectory segment rather than a PSet evaluation and which therefore overrides :meth:`run` (as ``hmc`` does, for the same reason). Everything here reads :attr:`trajectory` and :attr:`stop_reason` and nothing about how they were filled. """ self._announce_stop_reason() # How every start of a multi-start fit did, next to why the fit stopped -- both are # statements about the run rather than about its best parameter set, and a fit whose # starts all failed still has this to report even though it has no best fit below. self._emit_multistart_summary() # Write the final parameter sets, then copy the best simulations into the results # folder. A run that stopped before any result came back (an expired budget, an # immediately exhausted pool) has no parameter sets at all, so there is nothing to # write, copy, re-simulate, or score -- say so rather than raising out of an # otherwise-finished run. if len(self.trajectory) == 0: logger.warning('No parameter set completed, so there is no best fit to report') print1('No simulation completed, so there is no best fit to report.') else: # Decide which of the top parameter sets is really the best before anything # reads the best fit below. For a stochastic model the search ranked its # candidates on one noisy simulation each, so the entry holding the lowest # value is often just the one that got lucky (#659). A no-op for every # deterministic fit. self._confirm_best_fit(client) self.output_results('final') best_name = self.trajectory.best_fit_name() best_pset = self.trajectory.best_fit() self._copy_best_fit_sims(best_pset, best_name) self._rerun_best_fit_to_save_data(best_pset) self._emit_best_fit_bngl(best_pset, best_name) self._emit_information_criteria(self._compute_information_criteria(best_pset)) self._emit_profiled_noise() self._emit_inference_data() self._finalize_backup_pickle() self._teardown_sim_dir() logger.info("Fitting complete") def _drain_job_pool(self, client, pool, pending, backup_every, debug): """Drain completed jobs until the run stops, resubmitting what the algorithm asks for. Mutates ``pending`` (the in-flight future -> (PSet, job_id) map) and returns the number of results consumed. :meth:`run`'s inner loop, extracted so its three exits -- the algorithm's own ``'STOP'``, an exhausted pool, and the wall-time budget (#529) -- are one unit that can be exercised without the surrounding setup. A budget exit records :attr:`stop_reason` and leaves the pending futures for ``run`` to cancel, exactly as a ``'STOP'`` exit does: the difference between a budgeted stop and a converged one is the reason, not the artifacts. """ sim_count = 0 backed_up = True while True: if sim_count % backup_every == 0 and not backed_up: self.backup(set([pending[fut][0] for fut in pending])) backed_up = True try: f, res = next(pool) except StopIteration: logger.warning('Job pool exhausted unexpectedly — no pending futures remain. ' 'This can happen when all proposed parameter sets in a generation ' 'are out of bounds. Ending run.') print1('Warning: job pool exhausted (all proposals may have been out of bounds). Ending run.') break except TimeoutError: # The budget ran out while every worker was still busy; the in-flight # simulations are abandoned (run() cancels them next). self.stop_reason = self._wall_time_stop_reason(sim_count) break res = result_from_completed(f, res, pending[f][0], pending[f][1]) del pending[f] # For smoothing / model-parallel runs, accumulate sub-results into # their group and skip ahead until the group is complete. if self.config.config['smoothing'] > 1 or self.config.config['parallelize_models'] > 1: res = self._fold_group_result(res) if res is None: continue sim_count += 1 backed_up = False decision = self._record_result_and_decide(res) if decision == 'STOP': break # The completed result is recorded first (it is already paid for), then the # budget decides whether anything new may be launched. if self._budget_spent(): self.stop_reason = self._wall_time_stop_reason(sim_count) break # Submit the next round of jobs the algorithm asked for. new_futures = [] for ps in decision: new_js = self.make_job(ps) for new_j in new_js: new_f = client.submit(core.run_job, new_j, (debug or self.fail_count < 10), self.failed_logs_dir, models=self.models_future, calc=self.calc_future) pending[new_f] = (ps, new_j.job_id) new_futures.append(new_f) logger.debug('Submitting %d new Jobs' % len(new_futures)) pool.update(new_futures) return sim_count def _budget_spent(self): """Whether this fit's total wall-clock budget (``wall_time_fit``) is gone. Always False when no budget was configured (the default).""" return self.budget is not None and self.budget.expired() def _wall_time_stop_reason(self, sim_count): """The one-line reason a wall-time budget stop is reported under (#529). Deliberately in universal terms -- elapsed time, simulations completed, best objective so far -- because there is no iteration counter shared by every fit type (a generation, a start, and a chain step are all "one iteration" to different algorithms). """ best = self.trajectory.best_score() if len(self.trajectory) else None # A held-back reserve means this phase stopped short of the run's own deadline, # on purpose, to leave the refine something to run on (#564). Say so, or the # elapsed time will not add up against wall_time_fit for anyone reading it. allowance = '' if self.budget.reserve > 0: allowance = (', less %d s reserved for the refine (wall_time_refine_frac)' % int(round(self.budget.reserve))) return ('Wall-time budget reached: %sstopped after %s (wall_time_fit = %d s%s) and %d completed ' 'simulation(s), with a best objective of %s.' % ('the refine ' if self.refine else '', format_duration(self.budget.elapsed()), int(self.budget.limit), allowance, sim_count, ('%.10g' % best) if best is not None else 'n/a (no completed simulation)')) def _announce_stop_reason(self): """Log, print, and record why a run stopped, when it stopped for a reason the user must not mistake for convergence (today: the wall-time budget, #529). The durable half is ``Results/stop_reason.txt``: a budgeted run writes the same artifacts a converged one writes, so the *presence* of this file -- and nothing in the scoreable outputs -- is what tells a downstream consumer the fit hit its deadline instead of its stop criterion. A no-op for an ordinary run. A refine's reason is **appended** rather than written over the fit's: both phases share one Results directory, and a run where the search hit the deadline AND the polish did has two facts to report, not one that replaces the other (#564). The per-phase, machine-readable form of the same thing is ``method_chain.json``. """ if not self.stop_reason: return logger.info(self.stop_reason) print1(self.stop_reason) path = str(Path(self.res_dir) / 'stop_reason.txt') try: with open(path, 'a' if self.refine else 'w') as f: f.write(self.stop_reason + '\n') except Exception: logger.exception('Failed to write stop_reason.txt') def _best_fit_confirmation_settings(self): """How many candidate parameter sets to run again at the end of a stochastic fit, and how many times to run each, as a ``(candidates, replicates)`` pair (#659). Ten and ten under a modern edition, which the issue's arithmetic says removes about ninety percent of the optimism in the reported objective value. Off under the legacy edition, whose whole contract is that a conf that does not name an edition keeps behaving exactly as it always has (ADR-0031); a legacy fit that wants the stage names the two keys itself, and one whose models are stochastic is told so at config load. An explicit value wins under either edition. """ modern = edition.is_modern(edition.resolve_edition(self.config.config.get('edition'))) default = 10 if modern else 0 candidates = self.config.config.get('best_fit_candidates') replicates = self.config.config.get('best_fit_replicates') return (default if candidates is None else int(candidates), default if replicates is None else int(replicates)) def _replicates_would_differ(self): """Whether running a parameter set again would actually give a different answer. True when some model is stochastic, with one exception: under an ``_honorbngl`` seed policy an explicit ``seed=>N`` in a BNGL action is honored verbatim, so every run of that model reproduces one trajectory. When that pins every stochastic model in the fit there is nothing for replicates to average over, and running them would only spend simulations. This is the same trap ``smoothing`` guards against in ``Configuration._check_smoothing_misuse``. """ models = list(self.config.models.values()) stochastic = [m for m in models if getattr(m, 'stochastic', False)] if not stochastic: return False policy = str(self.config.config.get('stochastic_seed') or 'auto') if policy.endswith('_honorbngl') and all(getattr(m, 'seeded', False) for m in stochastic): return False return True def _confirm_best_fit(self, client): """Decide which of the top parameter sets a stochastic fit really found, by running each of them again several times and ranking them by average objective value (#659). A stochastic model gives a different objective value every time it is run, so every value the search recorded is a noisy measurement of that parameter set rather than a fixed number. The search picks its answer by taking the best value it ever saw, over tens of thousands of parameter sets, so the winner of that comparison is very often the parameter set that happened to get a lucky simulation. Two things then come out wrong: the reported objective value is the best of many noisy draws and so is optimistic by a wide margin, and the reported parameter values are not the best ones found. This runs the top ``best_fit_candidates`` parameter sets ``best_fit_replicates`` more times each, ranks them by their average, records the winner as the run's best fit (:meth:`pybnf.pset.Trajectory.pin_best`, so every later artifact agrees on it), and writes ``Results/best_fit_confirmation.txt``. All of the simulations go out at once, which uses processors that would otherwise sit idle at the end of a run. A no-op unless at least one model is stochastic, and a no-op without a dask client to submit the work to. Deliberately scoped to the final answer: the same noise also biases the search while it runs, which is a much larger piece of work. Every failure is logged and swallowed. The fit has finished by this point, and this stage must never be the reason a finished run dies. """ try: n_candidates, n_replicates = self._best_fit_confirmation_settings() if n_candidates < 1 or n_replicates < 2: return if not self._replicates_would_differ(): return if client is None: logger.info('No dask client available, so the best fit of this stochastic ' 'fit was not confirmed by running its top parameter sets again') return if self._budget_spent(): # A wall-time budget is a promise about the whole run, and this stage costs # candidates x replicates more simulations. A run that is already out of # time spends nothing further, since overrunning it risks the run being # killed before it has written anything at all. msg = ('The wall-time budget is spent, so the best fit was not confirmed by ' 'running the top parameter sets again. This fit used a stochastic ' 'model, so the objective value it reports is the best of many noisy ' 'single simulations and is optimistic. Raise wall_time_fit to leave ' 'room for the check.') logger.info(msg) print1('Warning: ' + msg) return candidates = self.trajectory.top_fits(n_candidates) if not candidates: return print1('Confirming the best fit: this fit used a stochastic model, so the top ' '%d parameter set(s) are each being run %d more times to see which of ' 'them is really best.' % (len(candidates), n_replicates)) scored = self._run_confirmation_replicates(client, candidates, n_replicates) except Exception: logger.exception('Failed to confirm the best fit by running the top parameter ' 'sets again; reporting the best fit the search picked') print1('Could not confirm the best fit by running the top parameter sets again, ' 'so the best fit the search picked is reported as is. See log for details.') return try: self._emit_best_fit_confirmation(scored, n_replicates) except Exception: logger.exception('Failed to write the best-fit confirmation report') def _run_confirmation_replicates(self, client, candidates, n_replicates): """Run each candidate ``n_replicates`` more times and collect its objective values. Returns one :class:`~pybnf.algorithms.best_fit_confirmation.Candidate` per input candidate, in the order the search ranked them. Every simulation is submitted before any result is collected. Each replicate is a whole evaluation as this fit defines one, built by :meth:`make_job` and scored by :meth:`score_result`, so its objective value is the same quantity the search recorded and the two are directly comparable. Under ``smoothing`` that means one replicate is itself an average of ``smoothing`` simulations. The replicate indices start past every index the fit itself used, because under the default seed policy a stochastic simulation's seed comes from the parameter values and the replicate index, so reusing an index would reproduce a trajectory the fit has already seen instead of drawing a new one. """ smoothing = max(1, int(self.config.config.get('smoothing') or 1)) grouped = smoothing > 1 or self.config.config.get('parallelize_models', 1) > 1 jobs = [] owner = dict() # job name of a finished evaluation -> index of the candidate it belongs to for i, (_, _, chosen_pset) in enumerate(candidates): for r in range(n_replicates): # A copy so the run gets its own name (which is the name of the folder it # writes to) without renaming the parameter set the trajectory holds. replicate_pset = copy.copy(chosen_pset) replicate_pset.name = 'bestfit_check%d_run%d' % (i + 1, r + 1) owner[replicate_pset.name] = i jobs += self.make_job(replicate_pset, replicate_offset=(r + 1) * smoothing) logger.info('Confirming the best fit: submitting %d job(s) for %d candidate ' 'parameter set(s) at %d replicate(s) each' % (len(jobs), len(candidates), n_replicates)) pending = dict() futures = [] for job in jobs: f = client.submit(core.run_job, job, False, self.failed_logs_dir, models=getattr(self, 'models_future', None), calc=getattr(self, 'calc_future', None)) futures.append(f) pending[f] = (job.params, job.job_id) scores = [[] for _ in candidates] failures = [0 for _ in candidates] pool = core.as_completed(futures, with_results=True, raise_errors=False) for f, raw in pool: res = result_from_completed(f, raw, pending[f][0], pending[f][1]) del pending[f] if not isinstance(res, core.Result): # A cancelled future, which the run loop treats as fatal. Here the fit is # already over, so it costs this replicate and nothing more. logger.warning('A best-fit confirmation job was cancelled') continue if grouped: res = self._fold_group_result(res) if res is None: continue index = owner.get(res.name) if index is None: logger.warning('Ignoring unexpected best-fit confirmation result %s' % res.name) continue if isinstance(res, FailedSimulation): failures[index] += 1 continue score = self.score_result(res) if score is None or not np.isfinite(score): failures[index] += 1 else: scores[index].append(float(score)) return [best_fit_confirmation.Candidate(name=name, pset=candidate_pset, search_objective=float(obj), scores=tuple(scores[i]), failures=failures[i]) for i, (obj, name, candidate_pset) in enumerate(candidates)] def _emit_best_fit_confirmation(self, candidates, n_replicates): """Record the winner as this run's best fit and write ``Results/best_fit_confirmation.txt`` (#659). The winner is pinned on the trajectory rather than handed to the caller, so everything downstream of here -- the saved simulations, the best-fit model file, the information criteria, a refine's start point, a bootstrap replicate's recorded answer -- reports the same parameter set without any of them having to know this stage exists. A refine writes to the ``_refine`` name, as the other artifacts of a second phase do, rather than writing over the searching fit's own table. """ best = best_fit_confirmation.winner(candidates) if best is None: logger.warning('No candidate parameter set produced a usable objective value ' 'when it was run again, so the best fit the search picked stands') else: self.trajectory.pin_best(best.pset, best_fit_confirmation.mean_objective(best), best.name) logger.info('Best-fit confirmation: %s wins with an average objective of %.10g ' 'over %d run(s); the search had recorded %.10g for it' % (best.name, best_fit_confirmation.mean_objective(best), len(best.scores), best.search_objective)) name = 'best_fit_confirmation_refine.txt' if self.refine else 'best_fit_confirmation.txt' path = str(Path(self.res_dir) / name) lines = best_fit_confirmation.summary_lines(candidates, n_replicates) with open(path, 'w') as f: f.write('\n'.join(lines) + '\n') logger.info('Wrote the best-fit confirmation table %s' % path) for line in best_fit_confirmation.console_lines(candidates, n_replicates, path): print1(line) def _copy_best_fit_sims(self, best_pset, best_name): """Copy the best-fit parameter set's simulation outputs into Results/. For each model, save a fresh copy parameterized by ``best_pset``; and when ``delete_old_files == 0`` (nothing was cleaned mid-run), also copy each suffix's already-written gdat/scan from ``Simulations/<best_name>/`` into Results/. A missing best-fit file is logged, not fatal. Split out of run()'s tail so the copy orchestration can be unit-tested without a dask client. See tests/test_run_loop.py. """ logger.info('Copying simulation results from best fit parameter set to Results/ folder') for m in self.config.models: this_model = self.config.models[m] to_save = this_model.copy_with_param_set(best_pset) to_save.save_all(f'{self.res_dir}/{to_save.name}_{best_name}') if self.config.config['delete_old_files'] == 0: for suffix_entry in this_model.suffixes: # Most model types carry (sim_type, suffix) 2-tuples, but # AnalyticalModel and SbmlModelNoTimeout use plain-string # suffixes. Accept both rather than crashing on the unpack. if isinstance(suffix_entry, (tuple, list)): simtype, suf = suffix_entry else: simtype, suf = 'simulate', suffix_entry if simtype == 'simulate': ext = 'gdat' else: # parameter_scan ext = 'scan' if self.config.config['smoothing'] > 1: best_name = best_name + '_rep0' # Look for one specific replicate of the data try: shutil.copy(f'{self.sim_dir}/{best_name}/{m}_{best_name}_{suf}.{ext}', f'{self.res_dir}') except FileNotFoundError: logger.error('Cannot find files corresponding to best fit parameter set') print0('Could not find your best fit gdat file. This could happen if all of the simulations\n' ' in your run failed, or if that gdat file was somehow deleted during the run.') def _rerun_best_fit_to_save_data(self, best_pset): """Rerun the best-fit pset so its gdat/scan files land in Results/. Only when delete_old_files>0 (the per-suffix copy in _copy_best_fit_sims was skipped) and save_best_data is set. Toggles save_files on the in-process backends (SBML / Antimony / bngsim BNGL+NF; subprocess BNGLModels write via BNG2.pl regardless), submits a single bestfit Job through the core seam, and on success copies every gdat/scan into Results/. save_files is restored even if the rerun raises, so later bootstrapping/refinement is unaffected. Split out of run()'s tail so the rerun orchestration — and the core.Job / core.run_job seam (ADR-0001) — can be unit-tested without a dask client. See tests/test_run_loop.py. """ if self.config.config['delete_old_files'] > 0 and self.config.config['save_best_data']: # Rerun the best fit parameter set so the gdat file(s) are saved in the Results folder. logger.info('Rerunning best fit parameter set to save data files.') # Enable saving files for in-process backends (SBML / Antimony / bngsim BNGL+NF). # Subprocess BNGLModels always write via BNG2.pl so they're skipped here. for m in self.model_list: if hasattr(m, 'save_files'): m.save_files = True finaljob = core.Job(self.model_list, best_pset, 'bestfit', self.sim_dir, self.config.config['wall_time_sim'], None, self.config.config['normalization'], self.config.postprocessing, False, stochastic_seed_policy=self.config.config['stochastic_seed']) try: core.run_job(finaljob) except Exception: logger.exception('Failed to rerun best fit parameter set') print1('Failed to rerun best fit parameter set. See log for details') else: # Copy all gdat and scan to Results for fname in glob(str(Path(self.sim_dir) / 'bestfit' / '*.gdat')) + glob(str(Path(self.sim_dir) / 'bestfit' / '*.scan')): shutil.copy(fname, self.res_dir) # Restore save_files defaults (in case there is future bootstrapping or refinement) for m in self.model_list: if hasattr(m, 'save_files'): m.save_files = False def _emit_best_fit_bngl(self, best_pset, best_name): """Write a stable-named, family-labelled best-fit BNGL per model (ADR-0048). New-era (``edition >= 2``) only, and a no-op when there is no best fit (e.g. every evaluation failed). For each BNGL model, render ``best_pset`` into a runnable ``Results/<model>_bestfit.bngl`` prefaced by a header comment that records the objective value and labels the point honestly per algorithm family: an optimizer's *best fit (minimum objective)*; a Bayesian sampler's *maximum-likelihood point* -- the recorded objective excludes the prior, so it is NOT the MAP (see :meth:`_best_fit_header` and the ADR). When ``embed_best_fit_data`` is set, each time-indexed observable's experimental data is embedded inline (ADR-0054) as a ``tfun([t...],[y...], time)`` reference function (:meth:`_build_exp_data_tfuns`); when ``smooth_plot_points`` is set, the data-derived time-course actions render on a uniform fine grid so the artifact plots a smooth curve (:meth:`_smooth_action_lines`). Reuses the ADR-0034 rendering path (``copy_with_param_set`` -> ``model_text``) and the same ``_stage_and_rewrite_tfun_files`` staging ``save()`` uses, so a legacy run is untouched (it never reaches here) and a model carrying its own tfun file refs stays runnable. Split out of run()'s tail so it can be unit tested without a dask client; per-model failures are logged, never fatal. """ if not edition.is_modern(edition.resolve_edition(self.config.config.get('edition'))): return if best_pset is None: return try: best_obj = self.trajectory.best_score() except (ValueError, IndexError): # Empty trajectory -> no best fit to emit. return header = self._best_fit_header(best_obj, best_name) embed = bool(self.config.config.get('embed_best_fit_data')) for m in self.config.models: model = self.config.models[m] if not isinstance(model, BNGLModel): # Only BNGL models render to a .bngl artifact; SBML/analytical models # carry non-BNGL text and are out of scope here. continue try: self._write_one_best_fit_bngl(model, best_pset, header, embed) except Exception: logger.exception('Failed to write best-fit BNGL for model %s' % m) print1('Could not write the best-fit BNGL artifact for model %s; see log.' % m) def _compute_information_criteria(self, best_pset): """AIC / BIC / AICc for the best-fit parameter set, or ``None``. A no-op (``None``) unless there is a best fit AND the objective is a proper likelihood (``supports_pointwise_log_likelihood`` -- the ADR-0011 noise families). A bare least-squares / distance / pass-through objective carries no normalized density, so no information criterion is defined for it -- the same gate LOO/WAIC use (ADR-0056). Otherwise the best pset is re-simulated once, in-process, to get its simulation data back (``core.run_job`` with no calculator future does not null ``res.simdata`` the way worker-side scoring does), then scored through the same normalize -> postprocess -> pointwise-``log_density`` path the fit used -- giving the FULL normalized log-likelihood behind an ABSOLUTE AIC. One extra simulation at the end of a run that already ran thousands is negligible, and it is the only place the best pset's simdata is in hand (an optimizer discards it after scoring on the workers). Every failure is logged and swallowed (returns ``None``): the run has otherwise completed, and a diagnostics field must never abort it. Split out of run()'s tail so it can be unit-tested without a dask client. """ if best_pset is None: return None if not getattr(self.objective, 'supports_pointwise_log_likelihood', False): return None try: # calc_future=None keeps simdata on the Result (no worker-side scoring); # delete_folder=True cleans up the one-off rerun (its gdat/scan are already # saved by _copy_best_fit_sims / _rerun_best_fit_to_save_data above). job = core.Job(self.model_list, best_pset, 'bestfit_infocrit', self.sim_dir, self.config.config['wall_time_sim'], None, self.config.config['normalization'], self.config.postprocessing, True, stochastic_seed_policy=self.config.config['stochastic_seed']) res = core.run_job(job) if getattr(res, 'failed', False) or res.simdata is None: logger.warning('Could not re-simulate the best fit to compute information criteria') return None # Mirror add_to_trajectory's scoring setup so the log-likelihood is scored # against exactly the data the fit's objective saw. res.normalize(self.config.config['normalization']) res.postprocess_data(self.config.postprocessing) # An analytically profiled noise scale (ADR-0108) is still an ESTIMATED quantity, # so it keeps counting in k -- only the search dropped it. Counting the searched # variables alone would shift every AIC/BIC in a profiled fit relative to the same # fit run without profiling, which is exactly the comparison k exists to support. profiled = getattr(self.config, 'profiled_noise_params', ()) or () k = len(self.variables) + len(profiled) ic = likelihood_information_criteria( self.objective, res.simdata, self.exp_data, best_pset, k) # The scoring call above put every profiled scale at its MLE for the best fit, so # the objective now holds the values this fit estimated for the removed dimensions. self._profiled_noise = dict(getattr(self.objective, '_profiled_noise', None) or {}) return ic except Exception: logger.exception('Failed to compute information criteria for the best fit') return None def _checkpoint_information_criteria(self): """Write ``Results/information_criteria_backup.txt`` for the best fit so far -- the information-criteria half of the run's checkpoint (#560). ``sorted_params_backup.txt`` has been checkpointed since forever, but the information criteria were written only on the terminal path, so the two halves of a scoreable result had entirely different lifetimes: a run was un-scoreable at every moment except its last, even though the best parameter vector had been on disk the whole time. That matters because ``log_likelihood`` here is the only place PyBNF reports the FULL normalized log-likelihood -- the minimized ``Obj`` column of the parameter table is the *reduced* objective -- so an absolute AIC/BIC, or any benchmark score built on one, cannot be computed from the parameter checkpoint alone. A killed, crashed, or simply not-yet-finished run now carries both halves. Cheap by construction, and free unless it has something new to say: * a no-op unless the objective is a proper likelihood (the gate in :meth:`_compute_information_criteria`), so nothing is spent on the ``sos`` / ``sod`` / ``norm_sos`` / ``kl`` / ``wasserstein`` / ``direct_pass`` fits for which no information criterion is defined; * a no-op while the best fit is unchanged -- the file on disk already describes it. That is exactly the long converged tail of a search, where the reported number has stopped moving and only stragglers are still running; * otherwise one extra simulation per checkpoint, i.e. one per ``backup_every * population_size * smoothing`` simulation returns. ``backup_information_criteria = 0`` turns it off for a model where even that is too expensive. Failures are logged and swallowed by the two helpers this calls: a diagnostics file must never abort a run, and a *periodic* one must not start aborting one mid-flight either. """ if not self.config.config['backup_information_criteria']: return if len(self.trajectory) == 0: return best_name = self.trajectory.best_fit_name() if best_name == self._ic_checkpoint_name: logger.debug('Best fit is unchanged since the last information-criteria checkpoint; ' 'not re-simulating it') return # _compute_information_criteria captures the profiled noise scales at whatever point # it scores, and profiled_noise.txt reports the run's FINAL best fit, so a checkpoint # must not leave a mid-run value behind for the end-of-run tail to report if its own # scoring pass fails. saved_profiled_noise = self._profiled_noise try: ic = self._compute_information_criteria(self.trajectory.best_fit()) finally: self._profiled_noise = saved_profiled_noise if ic is None: return # Mirrors sorted_params_backup.txt / sorted_params_refine_backup.txt, so both halves # of one phase's checkpoint carry the same name, and the final artifact keeps its # exact current meaning. name = 'refine_backup' if self.refine else 'backup' wrote = self._emit_information_criteria( ic, name=name, preamble=['# CHECKPOINT of a run still in progress: the best fit SO FAR, not the', "# run's result. That is information_criteria.txt, written at the end.", '# Parameter set: %s -- its row in sorted_params_%s.txt.' % (best_name, name)]) if wrote: # Only a written file licenses skipping the next recompute; a transient I/O # failure must not leave the checkpoint permanently stale on a run whose best # fit never changes again. self._ic_checkpoint_name = best_name def _start_pset_for_record(self, psets): """The PSet that is genuinely this algorithm's start, for :meth:`_emit_start_point`. Not simply ``psets[0]``. A start-point optimizer resolved its start in ``__init__`` and keeps it (``start_pset`` for CMA-ES, ``start_psets`` for the concurrent multi-start family), and for CMA-ES in particular the start is **never evaluated** -- it only seeds the distribution mean, so the first generation's members are draws around it and none of them is the start. The population algorithms and samplers have no such attribute: their start really is the first member of the initial population, which ``_seed_start_point_pset`` has already pinned. """ pset = getattr(self, 'start_pset', None) if pset is not None: return pset psets_attr = getattr(self, 'start_psets', None) if psets_attr: return psets_attr[0] return psets[0] if psets else None def _emit_start_point(self, psets, resumed=False): """Write ``Results/start_point.txt`` -- where this fit actually began (#583, ADR-0117). Provenance, not configuration: it answers "what point did the search start from, and was that the point I asked for?" for a run that has already happened. Every failure #583 catalogues is silent precisely because no artifact ever recorded the resolved start, so a fit displaced from its intended start is indistinguishable from a correct one -- the 1.97x displacement that motivated the issue was caught only because a known point failed to score what it provably scores. One row per free parameter, in **declaration** order (which is the order every u-space vector uses; note ``PSet``'s own string helpers sort alphabetically, so the two must not be mixed). Each row carries the start value, the box it was checked against, and its source, so the file is readable without the .conf beside it. ``psets`` is the initial population/start list. The first is the one the start point governs; the count is reported so a multi-start fit says outright how many of its starts were scattered rather than pinned. Written to ``self.res_dir``, so a bootstrap replicate records its own. Log-and-swallow on failure, like every artifact here: a provenance file must never be the reason a fit dies. """ if resumed: # A resumed run has no start to resolve: `psets` is the pending in-flight list # unpickled from the backup, i.e. arbitrary mid-run individuals. Writing them # here would overwrite the original run's record -- destroying the one artifact # that says where the fit began -- and label mid-run values as a start point. # The record the original run left is the true one; leave it alone. return try: declared = getattr(self.config, 'start_point', None) or {} first = self._start_pset_for_record(psets) # Whether this algorithm RESOLVES a start point (a start-point optimizer, which # keeps `start_pset`/`start_psets`) or DRAWS an initial population. For the # latter an undeclared coordinate is a random / Latin-hypercube draw, not the box # centre -- labelling it 'box_center' would be a false statement in the one file # that exists so a reader can tell a displaced start from a correct one. resolves_start = (getattr(self, 'start_pset', None) is not None or bool(getattr(self, 'start_psets', None))) rows = [] for v in self.variables: spk = getattr(self, 'START_POINT_KEY', None) if spk is not None and spk in self.config.config: source = 'refine' elif v.name in declared: source = getattr(self.config, 'start_point_spelling', {}).get(v.name, 'declared') elif not resolves_start: source = 'sampled' elif v.has_bounded_support: source = 'box_center' elif not v.has_prior: source = 'point' else: source = 'prior' try: value = '%.17g' % first.get_param(v.name).value if first is not None else 'n/a' except Exception: value = 'n/a' lo_u, hi_u = v.prior_support() lo = '%.10g' % v.from_sampling_space(lo_u) if np.isfinite(lo_u) else '-inf' hi = '%.10g' % v.from_sampling_space(hi_u) if np.isfinite(hi_u) else 'inf' rows.append('%s\t%s\t%s\t%s\t%s' % (v.name, value, source, lo, hi)) lines = [ '# The point this fit started from, recorded before any parameter set was scored.', '# One row per free parameter, in declaration order (NOT the alphabetical order', '# the parameter tables use).', '# source: start_point -- a "start_point = <p> <v>" line', '# initial_value -- the initial_value: field of a parameter: record', '# box_center -- the 0.5 quantile of the bounded prior. For a truncated', '# non-uniform prior this is the MEDIAN, which is not the location', '# parameter under asymmetric truncation -- declare a start point to pin it', '# point -- the single var / logvar / lnvar value', '# prior -- an unbounded prior with no declared start (p1)', '# sampled -- drawn from the prior / bounds, as a population', '# algorithm or sampler does for every undeclared coordinate', '# refine -- the previous phase best fit, injected by a method chain', '# lower/upper are the DECLARED box, or +-inf where the parameter declares none', '# (a parameter with no box can also finish outside any range you have in mind).', # A multi-start fit pins its FIRST start and scatters the rest, so say how # many there are: a reader who assumes their declared point governed the # whole fit is making the mistake this file exists to prevent. Only # MultiStartOptimizer has a start COUNT; for everyone else the initial pset # list is a population or a chain set, which is a different thing entirely. 'starts\t%d' % int(getattr(self, 'n_starts', 1) or 1), 'starts_pinned\t%d' % (1 if declared else 0), '#', '# parameter\tstart\tsource\tlower\tupper', ] + rows # A refine shares the fit's Results/ directory (pybnf.py points the refiner at # alg.res_dir), so it takes the _refine filename suffix every other artifact of a # second phase uses rather than overwriting the fit's own record. name = 'start_point_refine.txt' if self.refine else 'start_point.txt' path = str(Path(self.res_dir) / name) with open(path, 'w') as f: f.write('\n'.join(lines) + '\n') logger.info('Wrote start point %s' % path) except Exception: logger.exception('Failed to write start_point.txt') def _emit_information_criteria(self, ic, name='', preamble=()): """Write ``Results/information_criteria.txt`` (and a console line) for a fit. ``ic`` is the :class:`~pybnf.objective.InformationCriteria` from :meth:`_compute_information_criteria`, or ``None`` -- a non-likelihood objective, or no best fit -- in which case nothing is written. AIC/BIC/AICc rank this fit against competing models (lower is better); this is the first-class form of the AIC lesson 45 (model selection) computes by hand. :param name: File suffix. ``''`` (the default) writes the run's result, ``information_criteria.txt``; anything else writes ``information_criteria_<name>.txt`` -- today the periodic checkpoint (:meth:`_checkpoint_information_criteria`, #560). A checkpoint differs from the result only in its ``#`` comments and in staying off the console, so one parser reads either file. :param preamble: Extra ``#`` comment lines placed above the standard header. :return: True if a file was written. """ if ic is None: return False aicc_str = ('%.10g' % ic.aicc) if ic.aicc is not None else 'n/a (n <= k+1)' lines = list(preamble) + [ '# Information criteria for the best-fit parameter set (lower is better).', '# Valid for a likelihood objective only (normal / lognormal / lnnormal / laplace /', '# neg_bin / student_t). Computed from the full normalized log-likelihood', "# at the best fit (the sum of the noise model's per-point log_density,", '# ADR-0056), so AIC is an absolute value comparable across models.', '# AIC = 2k - 2*lnL', '# BIC = k*ln(n) - 2*lnL', '# AICc = AIC + 2k(k+1)/(n-k-1) (undefined when n <= k+1)', 'k\t%d' % ic.k, 'n\t%d' % ic.n, 'log_likelihood\t%.10g' % ic.log_likelihood, 'AIC\t%.10g' % ic.aic, 'BIC\t%.10g' % ic.bic, 'AICc\t%s' % aicc_str, ] filename = 'information_criteria.txt' if name == '' else 'information_criteria_%s.txt' % name path = str(Path(self.res_dir) / filename) try: with open(path, 'w') as f: f.write('\n'.join(lines) + '\n') except Exception: logger.exception('Failed to write %s' % filename) return False logger.info('Wrote information criteria %s' % path) if name == '': # The checkpoint says the same thing about a run still in progress, on a # cadence; like the parameter-set checkpoint beside it, it stays in the log. print1('Information criteria (best fit): AIC=%.6g BIC=%.6g AICc=%s ' '(k=%d, n=%d, lnL=%.6g)' % (ic.aic, ic.bic, aicc_str, ic.k, ic.n, ic.log_likelihood)) return True
[docs] def multistart_records(self): """One :class:`~pybnf.algorithms.multistart_report.StartRecord` per start of this fit, in any order -- :meth:`_emit_multistart_summary` sorts them (#658). Empty here, which is right for the great majority of fit types: they run one search and have nothing to compare it against. The three families that run several starts override this, each reading the numbers off wherever it happens to keep them (see :mod:`pybnf.algorithms.multistart_report`). """ return ()
def _emit_multistart_summary(self): """Write ``Results/multistart_summary.txt`` and print a short version of it (#658). A fit that runs several searches from different starting points reports the best of them. On its own that number says nothing about whether the search can be trusted, because a run whose starts all agreed and a run whose starts all disagreed print the same thing. This is the table that separates them: one row per start, best objective value first. Nothing is written for a fit with fewer than two starts, which includes every fit type that does not run a multi-start at all and every refine (a refine polishes the one point it was handed). A refine that does run several starts writes to the ``_refine`` name, as the other artifacts of a second phase do, rather than overwriting the searching fit's own table. Every failure is logged and swallowed. The fit has finished by this point, and a report must never be the reason a finished run dies. """ try: records = list(self.multistart_records() or ()) except Exception: logger.exception('Failed to collect the per-start results for the multi-start summary') return if len(records) < 2: return name = 'multistart_summary_refine.txt' if self.refine else 'multistart_summary.txt' path = str(Path(self.res_dir) / name) lines = multistart_report.summary_lines( records, job_type=self.config.config.get('fit_type')) try: with open(path, 'w') as f: f.write('\n'.join(lines) + '\n') except Exception: logger.exception('Failed to write %s' % name) return logger.info('Wrote the multi-start summary %s' % path) for line in multistart_report.console_lines(records, path): print1(line) def _emit_profiled_noise(self): """Write ``Results/profiled_noise.txt`` for a fit that profiles a noise scale out of the search (``noise_profiling = 1``, ADR-0108, #562). A profiled scale is fitted, not proposed: it is not a coordinate of the best PSet, so it appears in no ``sorted_params_*.txt`` row. This is where its estimate is reported -- the maximum-likelihood value over the points that share it, at the best fit -- so a profiled run states every quantity it estimated, exactly as an unprofiled one does. A no-op when nothing was profiled (the default) or when the end-of-run scoring that produces the values did not run. Every failure is logged and swallowed: the run has completed, and a report must never abort it.""" if not self._profiled_noise: return lines = [ '# Noise scales profiled out of the search analytically (noise_profiling = 1).', '# Each value is the maximum-likelihood scale over the scored points that share', '# it, evaluated at the best fit -- the estimate for a parameter the search', '# never proposed, so it appears in no sorted_params_*.txt row. These ARE', '# estimated parameters: they are counted in k in information_criteria.txt.', '# parameter\tvalue', ] lines += ['%s\t%.10g' % (name, value) for name, value in sorted(self._profiled_noise.items())] path = str(Path(self.res_dir) / 'profiled_noise.txt') try: with open(path, 'w') as f: f.write('\n'.join(lines) + '\n') except Exception: logger.exception('Failed to write profiled_noise.txt') return logger.info('Wrote profiled noise scales %s' % path) print1('Profiled noise scale(s) at the best fit: %s' % ', '.join('%s=%.6g' % (n, v) for n, v in sorted(self._profiled_noise.items()))) def _emit_inference_data(self): """Write Results/inference_data.nc when ``output_inference_data`` is set (ADR-0055). Opt-in, and a no-op on a non-Bayesian fit (only the samplers write ``samples.txt``, the bridge's source). Builds the InferenceData from the saved samples via :func:`pybnf.inference_data.from_pybnf`, passing the live ``self.variables`` so log parameters land in sampling space without a config reload. The ``arviz`` extra is optional and imported lazily by the bridge; a missing extra (or any build/write failure) is logged, never fatal -- the run has already completed and written every other artifact. """ if not self.config.config.get('output_inference_data'): return from .samplers.base import BayesianAlgorithm if not isinstance(self, BayesianAlgorithm): return try: from ..inference_data import from_pybnf idata = from_pybnf(self.res_dir, variables=self.variables) out_path = str(Path(self.res_dir) / 'inference_data.nc') idata.to_netcdf(out_path) logger.info('Wrote ArviZ InferenceData %s' % out_path) except ImportError: logger.warning('output_inference_data is set but the optional arviz extra is not ' 'installed; skipping inference_data.nc. Install with: pip install pybnf[arviz]') print1('Skipped inference_data.nc: the optional arviz extra is not installed ' '(pip install pybnf[arviz]).') except Exception: logger.exception('Failed to write inference_data.nc') print1('Could not write the ArviZ InferenceData artifact; see log.') def _best_fit_header(self, best_obj, best_name): """The comment block prefacing a best-fit BNGL, labelled by family (ADR-0048). Optimizers get *best fit*; Bayesian samplers get *maximum-likelihood point* with an explicit note that the recorded objective omits the prior (so it is not the MAP) and a pointer to ``samples.txt`` for the posterior mode. The discriminator is the ``BayesianAlgorithm`` family base every sampler subclasses, imported lazily here to avoid the base<-sampler import cycle. """ from .samplers.base import BayesianAlgorithm lines = [ '# Best-fit model emitted by PyBNF (ADR-0048).', '# job_type: %s' % self.config.config.get('fit_type', '?'), '# Parameter set: %s' % best_name, '# Objective (minimum recorded): %.10g' % best_obj, ] if isinstance(self, BayesianAlgorithm): lines += [ '# Point: MAXIMUM-LIKELIHOOD (minimum recorded objective).', '# NOTE: this is NOT the MAP. The recorded objective is the negative', '# log-likelihood only -- the prior is not folded in. For the', '# posterior mode (MAP), take the row with the largest Ln_probability', '# in Results/samples.txt.', ] else: lines.append('# Point: BEST FIT (minimum objective).') return '\n'.join(lines) + '\n' def _write_one_best_fit_bngl(self, model, best_pset, header, embed): """Render one BNGL model's best-fit artifact (+ optional embedded data) to Results/.""" to_save = model.copy_with_param_set(best_pset) # Smooth-curve opt-in (ADR-0054): re-render the data-derived time-course actions # onto a uniform fine grid so the artifact plots as a smooth curve. Acts only on # this deep-copied artifact model -- the fit already scored on the data grid, so # the objective is untouched. A no-op (0) leaves the ragged grid byte-identical. smooth = self.config.config.get('smooth_plot_points', 0) or 0 if smooth > 0: to_save.actions = self._smooth_action_lines(to_save.actions, smooth) text = to_save.model_text() # Stage any tfun file refs the *source* model already carries (as save() does). # The embedded data below is now inline (ADR-0054), so it needs no staging. text = _stage_and_rewrite_tfun_files( text, os.path.dirname(model.file_path), self.res_dir) if embed: text = self._inject_function_lines(text, self._build_exp_data_tfuns(model)) out_path = str(Path(self.res_dir) / ('%s_bestfit.bngl' % to_save.name)) with open(out_path, 'w') as f: f.write(header + text) logger.info('Wrote best-fit BNGL artifact %s' % out_path) # The bracketed sample_times list of a synthesized time-course simulate action. _SAMPLE_TIMES_RE = re.compile(r'sample_times=>\[([^\]]*)\]') @classmethod def _smooth_action_lines(cls, action_lines, n_points): """Re-render data-derived time-course actions onto a uniform fine grid (ADR-0054). A new-era time course is emitted with the data's ragged ``sample_times=>[t0,...,tN]`` grid (``pset._timecourse_line``); for a smooth, plottable best-fit artifact each such ``simulate(...)`` is re-rendered as ``t_end=>{max(tᵢ)},n_steps=>{n_points}`` -- exactly the uniform form ``_timecourse_line`` emits when ``explicit_points is None``, so ``method``/``t_start``/``suffix``/condition ``setParameter``s are preserved and the artifact stays a faithful, denser re-simulation of the same experiment. Only ``sample_times`` lists are rewritten: ``parameter_scan`` actions carry ``par_scan_vals`` (a swept axis, not time) and the steady-state pre-equilibration phase carries no ``sample_times`` -- both are left as-is. Returns a new list; the input is not mutated. Scoring is unaffected (this only touches the end-of-run artifact copy). """ def repl(m): pts = [float(x) for x in m.group(1).split(',') if x.strip()] return 't_end=>%s,n_steps=>%d' % (_format_bngl_number(max(pts)), n_points) return [cls._SAMPLE_TIMES_RE.sub(repl, line) for line in action_lines] def _build_exp_data_tfuns(self, model): """Embed this model's experimental data as inline ``tfun`` reference functions. For each time-indexed experiment in ``self.exp_data[model.name]`` and each of its observable columns (excluding the independent variable and ``_SD`` noise columns), return an inline function line embedding the experimental (time, value) pairs directly in the BNGL (ADR-0054, was a sidecar ``.tfun`` file under ADR-0048):: <fn>() = tfun([t0,t1,...],[y0,y1,...], time) so ``<model>_bestfit.bngl`` self-contains its comparison curves in one file (no sidecar directory). The pairs are sorted and de-duplicated to the strictly- increasing index ``tfun`` requires; default linear interpolation. ``tfun`` is a bngsim feature (BNG2.pl parses no ``tfun`` form), so the embedded overlay is consumed through a bngsim path -- see ADR-0054. Non-time-indexed experiments (parameter scan / dose-response, whose indvar is a swept parameter) are skipped with a log note: a ``tfun(..., time)`` would misrepresent them. Columns with fewer than two finite points are skipped (``tfun`` needs at least two). Returns ``[]`` when nothing is embeddable. """ func_lines = [] model_data = self.exp_data.get(model.name, {}) for suffix in sorted(model_data): data = model_data[suffix] if data.indvar != 'time': logger.info('best-fit data embed: skipping non-time experiment %r ' '(independent variable %r)' % (suffix, data.indvar)) continue times = data[data.indvar] for obs in sorted(data.cols, key=data.cols.get): if obs == data.indvar or obs.endswith('_SD'): continue pairs = self._clean_tfun_pairs(times, data[obs]) if len(pairs) < 2: continue fn = self._sanitize_id('expt_%s_%s' % (suffix, obs)) ts = ','.join(_format_bngl_number(t) for t, _ in pairs) vs = ','.join(_format_bngl_number(v) for _, v in pairs) func_lines.append('%s() = tfun([%s],[%s], time)' % (fn, ts, vs)) return func_lines @staticmethod def _clean_tfun_pairs(times, values): """(time, value) pairs ready for a ``.tfun``: finite, sorted, strictly increasing. Drops NaN/Inf in either column, sorts by time, and collapses repeated times to the first value seen (``tfun`` requires a strictly increasing index).""" pairs = [] seen = set() for t, v in sorted(zip(times, values), key=lambda p: p[0]): if not (np.isfinite(t) and np.isfinite(v)) or t in seen: continue seen.add(t) pairs.append((float(t), float(v))) return pairs @staticmethod def _sanitize_id(name): """A safe BNGL identifier / filename stem: non-word chars -> ``_``, never leading-digit (the ``expt_``/``<exp>__`` prefixes already guarantee this).""" s = re.sub(r'\W', '_', str(name)) return s if re.match(r'[A-Za-z_]', s) else '_' + s @staticmethod def _inject_function_lines(text, func_lines): """Insert ``func_lines`` into ``text``'s ``begin functions`` block (ADR-0048). Merges into an existing ``functions`` block (before its ``end functions``); otherwise opens a fresh ``begin functions ... end functions`` block before the model's ``end model`` (or, lacking one, ``begin actions``). A no-op for an empty ``func_lines``.""" if not func_lines: return text body = '\n'.join(func_lines) end_fn = re.search(r'(?im)^[ \t]*end[ \t]+functions\b', text) if end_fn and re.search(r'(?im)^[ \t]*begin[ \t]+functions\b', text): i = end_fn.start() return text[:i] + body + '\n' + text[i:] block = 'begin functions\n' + body + '\nend functions\n' anchor = (re.search(r'(?im)^[ \t]*end[ \t]+model\b', text) or re.search(r'(?im)^[ \t]*begin[ \t]+actions\b', text)) if anchor: i = anchor.start() return text[:i] + block + text[i:] return text + '\n' + block def _finalize_backup_pickle(self): """Rename the periodic backup pickle to its 'finished' name on completion. On the final (non-intermediate-bootstrap) pass, alg_backup.bp becomes alg_finished.bp — or alg_refine_finished.bp for a Simplex refinement — so a resumed run can tell a completed fit from one interrupted mid-flight. A missing backup (it was never written) is a warning, not a failure. Split out of run()'s tail so the rename can be unit-tested without a dask client. See tests/test_run_loop.py. """ if self.bootstrap_number is None or self.bootstrap_number == self.config.config['bootstrap']: try: os.replace('{}/alg_backup.bp'.format(self.config.config['output_dir']), '{}/alg_{}.bp'.format(self.config.config['output_dir'], ('finished' if not self.refine else 'refine_finished'))) logger.info('Renamed pickled algorithm backup to alg_%s.bp' % ('finished' if not self.refine else 'refine_finished')) except OSError: logger.warning('Tried to move pickled algorithm, but it was not found') def _teardown_sim_dir(self): """Delete the Simulations/ working directory at the end of a fit. Skipped for an intermediate bootstrap replicate (bootstrap_number set) and for a non-final pass of a refinement chain (refine==1 on a non-Simplex algorithm), and only when delete_old_files>=1. Uses ``rm -rf`` on POSIX (more robust than rmtree against partially-written sim trees) and shutil.rmtree on Windows. Split out of run()'s tail so the teardown decision can be unit-tested without a dask client. See tests/test_run_loop.py. """ if (self._is_simplex or self.config.config['refine'] != 1) and self.bootstrap_number is None: # End of fitting; delete unneeded files if self.config.config['delete_old_files'] >= 1: if os.name == 'nt': # Windows try: shutil.rmtree(self.sim_dir) except OSError: logger.error('Failed to remove simulations directory '+self.sim_dir) else: run(['rm', '-rf', self.sim_dir]) # More likely to succeed than rmtree()
[docs] def cleanup(self): """ Called before the program exits due to an exception. :return: """ self.output_results('end')
[docs] def latin_hypercube(nsamples, ndims, rng): """ Latin hypercube sampling. Returns a nsamples by ndims array, with entries in the range [0,1] You'll have to rescale them to your actual param ranges. ``rng`` is the caller's np.random.Generator (the algorithm's root rng). """ if ndims == 0: # Weird edge case - needed for other code counting on result having a number of rows return np.zeros((nsamples, 0)) value_table = np.transpose(np.array([[i/nsamples + 1/nsamples * rng.random() for i in range(nsamples)] for dim in range(ndims)])) for dim in range(ndims): rng.shuffle(value_table[:, dim]) return value_table
[docs] def exp10(n): """ Raise 10 to the power of a possibly user-defined value, and raise a helpful error if it overflows :param n: A float :return: 10.** n """ try: with np.errstate(over='raise'): ans = 10.**n except (OverflowError, FloatingPointError): logger.error('Overflow error in exp10()') logger.error(''.join(traceback.format_stack())) # Log the entire traceback raise PybnfError('Overflow when calculating 10^%d\n' 'Logs are saved in bnf.log\n' 'This may be because you declared a lognormal_var or a logvar, and specified the ' 'arguments in regular space instead of log10 space.' % n) return ans