Source code for pybnf.algorithms.core

"""Core execution primitives shared by every algorithm.

Holds the ``Result`` / ``FailedSimulation`` containers, the ``Job`` runner,
the ``JobGroup`` smoothing/model-parallel folding family, ``run_job``,
``result_from_completed``, and the dask ``as_completed`` import.

This module is the patched seam (ADR-0001). Tests and the slow
``integration_harness`` patch ``run_job`` / ``as_completed`` / ``Job`` here,
and the run loop resolves them as ``core.<name>`` so that "patch where it is
defined" stays honest as the algorithm classes move out of the package
facade in later commits.

Import direction is one-way: this module imports only leaf modules
(``data`` / ``pset`` / ``printing``) and never anything from the
``algorithms`` package facade or any algorithm leaf.
"""

import logging
import os
import shutil
import sys
import traceback
from pathlib import Path

import numpy as np
from concurrent.futures import CancelledError
from subprocess import run, CalledProcessError, TimeoutExpired
# Re-exported (not used inside this module): the run loop calls
# core.as_completed and the tests/integration_harness patch it here -- this is
# the dask seam (ADR-0001). `as as_completed` marks the intentional re-export.
from distributed import as_completed as as_completed

from ..data import Data
from ..pset import FailedSimulationError
from ..printing import print0, print1, PybnfError

# Preserve the original module logger name (was ``getLogger(__name__)`` when
# these functions lived in ``pybnf/algorithms.py``) so log routing stays
# byte-identical across the split. ``Job.jlogger`` keeps its own hardcoded
# ``'pybnf.algorithms.job'`` name below.
logger = logging.getLogger('pybnf.algorithms')


[docs] class Result: """ Container for the results of a single evaluation in the fitting algorithm """ def __init__(self, paramset, simdata, name): """ Instantiates a Result :param paramset: The parameters corresponding to this evaluation :type paramset: PSet :param simdata: The simulation results corresponding to this evaluation, as a nested dictionary structure. Top-level keys are model names and values are dictionaries whose keys are action suffixes and values are Data instances :type simdata: dict Returns a :param log: The stdout + stderr of the simulations :type log: list of str """ self.pset = paramset self.simdata = simdata self.name = name self.score = None # To be set later when the Result is scored. self.failed = False
[docs] def normalize(self, settings): """ Normalizes the Data object in this result, according to settings :param settings: Config value for 'normalization': a string representing the normalization type, a dict mapping exp files to normalization type, or None :return: """ if settings is None: return for m in self.simdata: for suff in self.simdata[m]: if type(settings) == str: self.simdata[m][suff].normalize(settings) elif suff in settings: self.simdata[m][suff].normalize(settings[suff])
[docs] def postprocess_data(self, settings): """ Postprocess the Data objects in this result with a user-defined Python script :param settings: A dict that maps a tuple (model, suffix) to a Python filename to load. That file is expected to contain the definition for the function postprocess(data), which takes a Data object and returns a processed data object :return: None """ for m, suff in settings: rawdata = self.simdata[m][suff] # This could generate all kinds of errors if the user's script is bad. Whatever happens, it's caught # by the caller of postprocess_data() # exec(settings[m][suff]) # noinspection PyUnresolvedReferences # self.simdata[m][suff] = postprocess(rawdata) # Cleaner attempt - follows good practice and is probably faster, but makes it hard for the user to create # a new Data object if they want to do that. # However, they can do that by `dataclass = data.__class__` `newdata = dataclass()` # Import the user-specified script as a module import importlib.util spec = importlib.util.spec_from_file_location("postprocessor", settings[m, suff]) postproc = importlib.util.module_from_spec(spec) spec.loader.exec_module(postproc) # Now postproc is the user-defined Python module self.simdata[m][suff] = postproc.postprocess(rawdata)
[docs] def add_result(self, other): """ Add simulation data of other models from another Result object into this Result object :param other: The other Result object :return: """ self.simdata.update(other.simdata)
[docs] class FailedSimulation(Result): def __init__(self, paramset, name, fail_type, einfo=tuple([None, None, None])): """ Instantiates a FailedSimulation :param paramset: :param log: :param name: :param fail_type: 0 - Exceeded walltime, 1 - Other crash :type fail_type: int :param einfo: :type einfo: tuple """ super().__init__(paramset, None, name) self.fail_type = fail_type self.failed = True self.traceback = ''.join(traceback.format_exception(*einfo))
[docs] def normalize(self, settings): return
[docs] def postprocess_data(self, settings): return
class _ResolvedFuture: """A minimal future-like handle wrapping an already-resolved value. dask substitutes a scattered ``Future`` with its concrete value only when the Future is a *direct* argument to ``client.submit`` (positional or keyword). A Future buried as an attribute of a submitted object is pickled verbatim and, since distributed 2026.6.0, deserializes "not properly initialized" on the worker, so calling ``.result()`` on it raises (lanl/PyBNF #476). ``Algorithm.run`` therefore passes the once-scattered model_list / objective Futures to ``client.submit`` as direct ``models=`` / ``calc=`` kwargs, and ``run_job`` rebinds their worker-resolved values onto the Job wrapped in this handle. That keeps the Job's duck-typed ``.result()`` consumers (``Job._get_models`` and the scoring in ``Job.run_simulation``) working unchanged while guaranteeing no raw Future is ever ``.result()``-ed off a Job attribute worker-side. """ __slots__ = ('_value',) def __init__(self, value): self._value = value def result(self): return self._value
[docs] def run_job(j, debug=False, failed_logs_dir='', models=None, calc=None): """ Runs the Job j. This function is passed to Dask instead of j.run_simulation because if you pass j.run_simulation, Dask leaks memory associated with j. ``models`` / ``calc`` are the worker-resolved model_list and ObjectiveCalculator that ``Algorithm.run`` scattered once per fit and hands to ``client.submit`` as direct kwargs, so dask substitutes the Futures with their concrete values here on the worker (see :class:`_ResolvedFuture` for why a Future stashed as a Job attribute cannot be used instead). When provided they are rebound onto the Job; when omitted -- the in-process paths that call ``run_job`` directly (``_rerun_best_fit_to_save_data``, ``ModelCheck``, the folder-free test harnesses) -- the Job's own ``models`` / ``calc_future`` attributes are used as-is. """ if models is not None: j.models = _ResolvedFuture(models) if calc is not None: j.calc_future = _ResolvedFuture(calc) try: return j.run_simulation(debug, failed_logs_dir) except RuntimeError as e: # Catch the error for running out of threads here - it's the only place outside dask where we can catch it. if e.args[0] == "can't start new thread": logger.error("Reached thread limit - can't start new thread") print0('Too many threads! See "Troubleshooting" in the documentation for how to deal with this problem') return FailedSimulation(j.params, j.job_id, 1) else: raise
[docs] class Job: """ Container for information necessary to perform a single evaluation in the fitting algorithm """ # Seeing these logs for cluster-based fitting requires configuring dask to log to the # "pybnf.algorithms.job" logger jlogger = logging.getLogger('pybnf.algorithms.job') def __init__(self, models, params, job_id, output_dir, timeout, calc_future, norm_settings, postproc_settings, delete_folder=False, replicate_index=0, stochastic_seed_policy='auto', model_slice=None): """ Instantiates a Job :param models: The models to evaluate, as either a concrete list of Model instances or a dask Future wrapping the whole model_list scattered once per fit (see Algorithm.run / make_job). A Future is resolved worker-side and sliced by ``model_slice``; this avoids re-serializing the parameter-independent model graph on every evaluation (issue #416). :type models: list of Model instances, or distributed.Future :param model_slice: ``(start, stop)`` applied to the resolved model_list when ``models`` is a Future (parallelize_models hands each Job a slice); ``None`` means use the whole list. Ignored when ``models`` is a concrete list. :type model_slice: tuple or None :param params: The parameter set with which to evaluate the model :type params: PSet :param job_id: Job identification; also the folder name that the job gets saved to :type job_id: str :param output_dir path to the directory where I should create my simulation folder :type output_dir: str :param calc_future: Future for an ObjectiveCalculator containing the objective function and experimental data, which we can use to calculate the objective value. :type calc_future: Future :param norm_settings: Config value for 'normalization': a string representing the normalization type, a dict mapping exp files to normalization type, or None :type norm_settings: Union[str, dict, NoneType] :param postproc_settings: dict mapping (model, suffix) tuples to the path of a Python postprocessing file to run on the result. :param delete_folder: If True, delete the folder and files created after the simulation runs :type delete_folder: bool :param replicate_index: Smoothing replicate index (0..smoothing-1); folded into stochastic seed derivation so replicates of the same param set yield distinct deterministic trajectories. :type replicate_index: int :param stochastic_seed_policy: One of 'auto', 'auto_honorbngl', 'random', 'random_honorbngl' from the `stochastic_seed` config option. Stamped onto each model copy at execute time. :type stochastic_seed_policy: str """ self.models = models self.model_slice = model_slice self._resolved_models = None # cache for _get_models() (worker-side) self.params = params self.job_id = job_id self.calc_future = calc_future self.norm_settings = norm_settings self.postproc_settings = postproc_settings # Whether to show warnings about missing data if the job includes an objective evaluation. Toggle this after # construction if needed. self.show_warnings = False self.home_dir = os.getcwd() # This is safe because it is called from the scheduler, not the workers. # Force absolute paths for bngcommand and output_dir, because workers do not get the relative path info. if output_dir[0] == '/': self.output_dir = output_dir else: self.output_dir = str(Path(self.home_dir) / output_dir) self.timeout = timeout # Folder where we save the model files and outputs. self.folder = f'{self.output_dir}/{self.job_id}' self.delete_folder = delete_folder self.replicate_index = replicate_index self.stochastic_seed_policy = stochastic_seed_policy def _name_with_id(self, model): return f'{model.name}_{self.job_id}' def _get_models(self): """Resolve ``self.models`` to a concrete list of Model instances. ``self.models`` is either a plain list (tests / a Job built outside the run loop) or a dask Future for the model_list scattered once per fit (see Algorithm.make_job). A Future is resolved here, worker-side, and sliced by ``self.model_slice`` -- mirroring how ``calc_future`` is resolved via ``.result()``. The resolved list is cached so a job that dask happens to run twice does not re-fetch it. """ if self._resolved_models is None: models = self.models if hasattr(models, 'result'): # a scattered Future, not a list models = models.result() if self.model_slice is not None: models = models[self.model_slice[0]:self.model_slice[1]] self._resolved_models = list(models) return self._resolved_models def _run_models(self): ds = {} for model in self._get_models(): model_file_prefix = self._name_with_id(model) model_with_params = model.copy_with_param_set(self.params) # Stamp seed-policy context onto the per-evaluation copy so model # backends can derive deterministic seeds without plumbing config # through every execute() signature. model_with_params._pybnf_replicate_index = self.replicate_index model_with_params._pybnf_stochastic_seed_policy = self.stochastic_seed_policy ds[model.name] = model_with_params.execute(self.folder, model_file_prefix, self.timeout) return ds def _copy_log_files(self, failed_logs_dir): if failed_logs_dir == '': self.jlogger.error('Cannot save log files without specified directory') return for m in self._get_models(): lf = f'{self.folder}/{self._name_with_id(m)}.log' if os.path.isfile(lf): self.jlogger.debug(f'Copying log file {lf}') shutil.copy(lf, failed_logs_dir)
[docs] def run_simulation(self, debug=False, failed_logs_dir=''): """Runs the simulation and reads in the result""" # Force absolute path for failed_logs_dir if len(failed_logs_dir) > 0 and failed_logs_dir[0] != '/': failed_logs_dir = str(Path(self.home_dir) / failed_logs_dir) # The check here is in case dask decides to run the same job twice, both of them can complete. made_folder = False failures = 0 while not made_folder: try: os.mkdir(self.folder) self.jlogger.debug(f'Created folder {self.folder} for simulation') made_folder = True except OSError: self.jlogger.warning(f'Failed to create folder {self.folder}, trying again.') failures += 1 self.folder = '%s/%s_rerun%i' % (self.output_dir, self.job_id, failures) if failures > 1000: self.jlogger.error(f'Job {self.job_id} failed because it was unable to write to the Simulations folder') return FailedSimulation(self.params, self.job_id, 1) try: simdata = self._run_models() res = Result(self.params, simdata, self.job_id) except (CalledProcessError, FailedSimulationError): if debug: self._copy_log_files(failed_logs_dir) res = FailedSimulation(self.params, self.job_id, 1) except TimeoutExpired: if debug: self._copy_log_files(failed_logs_dir) res = FailedSimulation(self.params, self.job_id, 0) except FileNotFoundError: self.jlogger.exception(f'File not found during job {self.job_id}. This should only happen if the fitting ' 'is already done.') res = FailedSimulation(self.params, self.job_id, 2, sys.exc_info()) except Exception: if debug: self._copy_log_files(failed_logs_dir) print1('A simulation failed with an unknown error. See the log for details, and consider reporting this ' 'as a bug.') self.jlogger.exception(f'Unknown error during job {self.job_id}') res = FailedSimulation(self.params, self.job_id, 2, sys.exc_info()) else: if self.calc_future is not None: try: res.normalize(self.norm_settings) try: res.postprocess_data(self.postproc_settings) except Exception: self.jlogger.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.calc_future.result().evaluate_objective(res.simdata, res.pset, show_warnings=self.show_warnings) res.out = simdata if res.score is None: 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') except Exception: # A failure while normalizing or scoring this one parameter set # (e.g. a PybnfError from mismatched simulation/exp columns) should # penalize this evaluation, not crash the whole run. See lanl/PyBNF#388. if debug: self._copy_log_files(failed_logs_dir) self.jlogger.exception(f'Objective evaluation failed during job {self.job_id}') res = FailedSimulation(self.params, self.job_id, 1, sys.exc_info()) res.simdata = None if self.delete_folder: if os.name == 'nt': # Windows try: shutil.rmtree(self.folder) self.jlogger.debug(f'Removed folder {self.folder}') except OSError: self.jlogger.error(f'Failed to remove folder {self.folder}.') else: try: run(['rm', '-rf', self.folder], check=True, timeout=1800) self.jlogger.debug(f'Removed folder {self.folder}') except (CalledProcessError, TimeoutExpired): self.jlogger.error(f'Failed to remove folder {self.folder}.') return res
[docs] class JobGroup: """ Represents a group of jobs that are identical replicates to be averaged together for smoothing """ def __init__(self, job_id, subjob_ids): """ :param job_id: The name of the Job this group is representing :param subjob_ids: A list of the ids of the identical replicate Jobs. """ self.job_id = job_id self.subjob_ids = subjob_ids self.result_list = [] self.failed = None
[docs] def job_finished(self, res): """ Called when one job in this group has finished :param res: Result object for the completed job :return: Boolean, whether everything in this job group has finished """ # Handle edge cases of failed simulations - if we get one FailedSimulation, we declare the group is done, # and return a FailedSimulation object as the average if self.failed: # JobGroup already finished when a previous failed simulation came in. return False if isinstance(res, FailedSimulation): self.failed = res return True if res.name not in self.subjob_ids: raise ValueError(f'Job group {self.job_id} received unwanted result {res.name}') self.result_list.append(res) return len(self.result_list) == len(self.subjob_ids)
[docs] def average_results(self): """ To be called after all results are in for this group. Averages the results and returns a new Result object containing the averages :return: New Result object with the job_id of this JobGroup and the averaged Data as the simdata """ if self.failed: self.failed.name = self.job_id return self.failed # Iterate through the models and suffixes in the simdata strucutre, and calculate the average for each # Data object it contains avedata = dict() for m in self.result_list[0].simdata: avedata[m] = dict() for suf in self.result_list[0].simdata[m]: avedata[m][suf] = Data.average([r.simdata[m][suf] for r in self.result_list]) return Result(self.result_list[0].pset, avedata, self.job_id)
[docs] class MultimodelJobGroup(JobGroup): """ A JobGroup to handle model-level parallelism """
[docs] def average_results(self): """ To be called after all results are in for this group. Combines all results from the submodels into a single Result object :return: """ if self.failed: self.failed.name = self.job_id return self.failed # Merge all models into a single Result object final_result = Result(self.result_list[0].pset, dict(), self.job_id) for res in self.result_list: final_result.add_result(res) return final_result
[docs] class HybridJobGroup(JobGroup): """ A JobGroup to handle combined smoothing and model-level parallelism. """ def __init__(self, job_id, replica_subjob_ids): """ :param job_id: The name of the Job this group is representing :param replica_subjob_ids: List of (replica id, subjob ids) pairs for model partitions in each smoothing replica. """ subjob_ids = [sid for _, ids in replica_subjob_ids for sid in ids] super().__init__(job_id, subjob_ids) self.replica_groups = [MultimodelJobGroup(replica_id, ids) for replica_id, ids in replica_subjob_ids]
[docs] def job_finished(self, res): """ Called when one job in this group has finished. :param res: Result object for the completed job :return: Boolean, whether everything in this job group has finished """ if self.failed: return False if isinstance(res, FailedSimulation): self.failed = res return True if res.name not in self.subjob_ids: raise ValueError(f'Job group {self.job_id} received unwanted result {res.name}') for group in self.replica_groups: if res.name in group.subjob_ids: group.job_finished(res) break return all(len(group.result_list) == len(group.subjob_ids) for group in self.replica_groups)
[docs] def average_results(self): """ Merge model partitions within each smoothing replica, then average the complete replicas. """ if self.failed: self.failed.name = self.job_id return self.failed replica_results = [group.average_results() for group in self.replica_groups] avedata = dict() for m in replica_results[0].simdata: avedata[m] = dict() for suf in replica_results[0].simdata[m]: avedata[m][suf] = Data.average([r.simdata[m][suf] for r in replica_results]) return Result(replica_results[0].pset, avedata, self.job_id)
[docs] def result_from_completed(future, result, params, job_id): """ Translate one completed item from ``as_completed(futures, with_results=True, raise_errors=False)`` into a Result. With those flags, dask hands back per future: * the job's return value (a Result / FailedSimulation) for a finished job, * the worker's ``(type, exception, traceback)`` tuple for an errored job, and * a ``CancelledError`` for a cancelled job. A failed job becomes a ``FailedSimulation`` so the run continues, except a user-targeted ``PybnfError`` is re-raised (it would fail every job, so failing fast is better than burning the whole fit). A ``CancelledError`` is returned unchanged for the caller to treat as fatal, and anything unrecognized is treated as a failed simulation rather than crashing the run. ``Future.status`` is part of dask's public Future API, so this relies on no dask internals. See lanl/PyBNF#388 for the history (the previous approach subclassed as_completed and overrode a private method that dask later renamed). """ if getattr(future, 'status', None) == 'error': typ, exc, tb = result if isinstance(exc, PybnfError): raise exc # User-targeted error should be raised instead of skipped logger.error(f'Job {job_id} failed with an exception') logger.error(''.join(traceback.format_exception(typ, exc, tb))) return FailedSimulation(params, job_id, 3) if isinstance(result, CancelledError): return result if not isinstance(result, Result): # e.g. a raw tuple leaking from as_completed for an errored future whose # status we somehow didn't catch; never crash the run over it. logger.error(f'Job {job_id} returned an unexpected result of type {type(result).__name__}; treating as a failed simulation') return FailedSimulation(params, job_id, 3) return result