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 .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 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 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 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 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' :param k: :return: """ return k not in set(['trajectory', 'calc_future', 'models_future'])
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) 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 add_to_trajectory(self, res): """ Adds the information from a Result to the Trajectory instance """ # 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') 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_initial_value_pset(self, sampled_pset): """Pin the parameters that declare an ``initial_value`` to that point, in ONE pset. ADR-0043 Phase 2. A new-era ``parameter:`` record may carry an ``initial_value`` -- the point where the search/walk should start (PEtab ``nominalValue``). 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 an ``initial_value`` (carried on ``FreeParameter.value`` by the loader, ``config._free_parameter_from_record``) with that value, leaving every other parameter at its sampled draw. So a partially-specified seed is still a complete pset -- pinned where an ``initial_value`` was given, drawn otherwise. Returns ``sampled_pset`` unchanged when no parameter declares an ``initial_value`` (the common case), so runs without the record 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 overrides *every* chain uniformly. Edition-agnostic: it reads ``FreeParameter.value``, which the loader sets the same way however the parameter was declared, so the algorithm layer is unaware of the config edition. """ if not any(v.value is not None for v in self.variables): return sampled_pset fps = [v.set_value(v.value) if v.value is not None 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): """ 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. :param params: :type params: PSet :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=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=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']), 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']), 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 if self.refine: 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)
[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.')
[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""" if self.refine: logger.debug('Setting up Simplex refinement of previous algorithm') backup_every = self.get_backup_every() sim_count = 0 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() 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) jobs = [] pending = dict() # Maps pending futures to tuple (PSet, job_id). 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) pool = core.as_completed(futures, with_results=True, raise_errors=False) 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 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 # 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) logger.info("Cancelling %d pending jobs" % len(pending)) client.cancel(list(pending.keys())) self.output_results('final') # Copy the best simulations into the results folder 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_inference_data() self._finalize_backup_pickle() self._teardown_sim_dir() logger.info("Fitting complete")
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) return likelihood_information_criteria( self.objective, res.simdata, self.exp_data, best_pset, len(self.variables)) except Exception: logger.exception('Failed to compute information criteria for the best fit') return None def _emit_information_criteria(self, ic): """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. """ if ic is None: return aicc_str = ('%.10g' % ic.aicc) if ic.aicc is not None else 'n/a (n <= k+1)' lines = [ '# Information criteria for the best-fit parameter set (lower is better).', '# Valid for a likelihood objective only (normal / lognormal / 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, ] path = str(Path(self.res_dir) / 'information_criteria.txt') try: with open(path, 'w') as f: f.write('\n'.join(lines) + '\n') except Exception: logger.exception('Failed to write information_criteria.txt') return logger.info('Wrote information criteria %s' % path) 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)) 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).', '# fit_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