"""The BayesianAlgorithm base class — shared by all Bayesian sampler fit types.
Extracted from the algorithms package (M1 Step 3). Holds posterior/prior
bookkeeping and the rank-normalized split-R-hat / ESS convergence diagnostics
(Vehtari et al. 2021). The run loop and execution seam live in the Algorithm
base (``..base``); BayesianAlgorithm inherits them and adds no core.* call of
its own, so this module does not import the ``core`` seam.
"""
from ..base import Algorithm
from ...config_schema import PyBNFConfigModel
from ...printing import print1, print2, PybnfError
from ... import diagnostics
import logging
import numpy as np
from pathlib import Path
from pydantic import Field
# 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')
[docs]
class MCMCFamilyConfig(PyBNFConfigModel):
"""Config shared by the whole Bayesian/MCMC family (mh, pt, am, dream,
p_dream), co-located with the family base (ADR-0002, ADR-0006). (sa was once
in this family; M2.2/ADR-0008 made it a standalone optimizer.)
Holds the defaulted keys the shared ``BayesianAlgorithm`` base reads (so they
are common to every MCMC method) plus the β-ladder ``postprocess`` hook. The
per-method keys live on the leaf models that subclass this and co-locate with
their algorithm class: :class:`BasicMCMCConfig` (mh/pt/sa, in basic_mcmc.py),
:class:`AdaptiveMCMCConfig` (am, in adaptive_mcmc.py),
:class:`DreamConfig` (dream, in dream.py) and
:class:`PDreamConfig` (p_dream, in pdream.py). ``neg_bin_r`` stays in
``GlobalConfig`` -- an objfunc param read regardless of fit_type. Values are
byte-identical to the old global defaults; under narrowing (ADR-0013) these
keys appear only in each MCMC fit_type's own effective config. The per-fit_type
validity of individual keys is reported by ``check_unused_keys`` in
``config.py``.
"""
step_size: float = 0.2
burn_in: int = 10000
sample_every: int = 100
output_hist_every: int = 100
hist_bins: int = 10
adaptive: int = 10000
credible_intervals: list = Field(default_factory=lambda: [68.0, 95.0])
beta: list = Field(default_factory=lambda: [1.0])
continue_run: int = 0
rhat_threshold: float = 0.0 # R-hat / ESS diagnostics live on the base
diagnostics_every: int = 0
# beta_range is a user input the beta-ladder postprocess consumes (geomspaces it
# into beta_list) rather than a stored field; valid for every MCMC fit (#401).
RUNTIME_KEYS = frozenset({'beta_range'})
[docs]
@classmethod
def postprocess(cls, conf_dict, fit_type):
"""The β-ladder (ported from ``Configuration.postprocess_mcmc_keys``).
Algorithms 'mh', 'pt', 'am', 'dream', 'p_dream' have similar but
non-identical valid config keys; this builds ``beta_list`` / ``reps_per_beta``
and reconciles ``exchange_every`` / ``population_size``. Mutates ``conf_dict``
in place (operating on the RAW config dict, before defaults merge, so
raw-presence checks like ``'beta' not in conf_dict`` mean "user did not set
it"). ``config._build_config`` dispatches this uniformly; non-MCMC models
inherit the no-op ``postprocess`` from ``PyBNFConfigModel`` (ADR-0006 #3).
Only the config *transformations* live here now. The per-method unused-key
*warnings* this hook used to also emit (``exchange_every``/``reps_per_beta``
on non-pt, the sa-only ``cooling``/``beta_max``, the DREAM-only
``crossover_number``/``zeta``/``lambda``/``gamma_prob`` on mh/pt/am) moved to
the unified, schema-derived ``Configuration.check_unused_keys`` (#401,
ADR-0014), which now warns about every foreign key each MCMC fit does not own
-- more precisely than this hook's hand-listed branches did.
"""
# exchange_every/reps_per_beta are pt-only; pin them for the non-pt methods
# (the warning for a user who set them on a non-pt fit comes from
# check_unused_keys now).
if conf_dict['fit_type'] != 'pt':
conf_dict['exchange_every'] = np.inf
conf_dict['reps_per_beta'] = 1
elif 'reps_per_beta' not in conf_dict:
conf_dict['reps_per_beta'] = 1 # Default value if using pt but didn't specify
# Create the starting list of betas based on the various available options. Warn if tried to do something weird
if 'beta' not in conf_dict and 'beta_range' not in conf_dict:
conf_dict['beta'] = [1.]
# Handle the Parallel Tempering case where reps_per_beta is specified.
# First, check it's divisible by the population size
if conf_dict['population_size'] % conf_dict['reps_per_beta'] != 0:
conf_dict['population_size'] -= conf_dict['population_size'] % conf_dict['reps_per_beta']
print1('Warning: Lowered your population_size to %i so that it is divisible by your setting for '
'reps_per_beta' % conf_dict['population_size'])
# Then, we want the beta_list generated below to contain only one copy of the spread of betas to use
# At the end, we make reps_per_beta copies of that list to arrive at the final beta list.
subpop_size = conf_dict['population_size'] // conf_dict['reps_per_beta']
if 'beta_range' in conf_dict:
if len(conf_dict['beta_range']) != 2:
raise PybnfError("Wrong number of entries in beta_range",
"Config key 'beta_range' must have exactly 2 numbers: the min and the max.")
if 'beta' in conf_dict:
print1("Warning: Ignoring config key 'beta' because it is overridden by config key 'beta_range'")
if conf_dict['fit_type'] != 'pt':
print1("Warning: You used 'beta_range' with the method {}. This is an odd thing to do. Usually, you "
"would want all your replicates starting at the same beta value.".format(conf_dict['fit_type']))
betalist = list(np.geomspace(conf_dict['beta_range'][0], conf_dict['beta_range'][1], subpop_size))
elif len(conf_dict['beta']) > 1:
betalist = conf_dict['beta']
if conf_dict['fit_type'] != 'pt':
print1("Warning: You specified multiple beta values with the method {}. This is an odd thing to do. "
"Usually, you would specify one beta value to use with all your replicates. ".format(conf_dict['fit_type']))
if len(betalist) != subpop_size:
print1("Warning: You specified %i beta values, so I will run %i replicates instead of using your "
"population_size setting" % (len(betalist), len(betalist)*conf_dict['reps_per_beta']))
conf_dict['population_size'] = len(betalist)*conf_dict['reps_per_beta']
else:
betalist = conf_dict['beta'] * subpop_size # n copies of the single beta value
if conf_dict['fit_type'] == 'pt':
print1("Warning: You specified a single beta value with the method pt. This makes the algorithm's "
"replica exchanges accomplish nothing. To make good use of this algorithm, set the key "
"'beta_range' or specify multiple values with the 'beta' key.")
betalist.sort()
betalist = betalist * conf_dict['reps_per_beta']
conf_dict['beta_list'] = betalist
if conf_dict['fit_type'] == 'pt' and betalist[-1] != 1:
print1('Warning: You are about to calculate a distribution with beta=%i instead of 1. That means your '
'calculated distribution will be %s than the true probability distribution' %
(betalist[-1], 'narrower' if betalist[-1] > 1 else 'broader'))
return conf_dict
[docs]
class BayesianAlgorithm(Algorithm):
"""Superclass for Bayesian MCMC algorithms"""
def __init__(self, config):
super().__init__(config)
self.num_parallel = config.config['population_size']
self.max_iterations = config.config['max_iterations']
self.step_size = config.config['step_size']
self.n_dim = len(self.variables)
# One independent Generator per parallel chain, indexed by chain index, so
# each chain's proposal/accept draws come from its own stream regardless of
# dask completion order (cross-chain barriers still use the root self.rng).
self._rebuild_chain_rngs()
self.iteration = [0] * self.num_parallel # Iteration number that each PSet is on
self.current_pset = None # List of n PSets corresponding to the n independent runs
self.ln_current_P = None # List of n probabilities of those n PSets.
self.burn_in = config.config['burn_in'] # todo: 'auto' option
self.adaptive = config.config['adaptive']
self.sample_every = config.config['sample_every']
self.output_hist_every = config.config['output_hist_every']
# A list of the % credible intervals to save, eg [68. 95]
self.credible_intervals = config.config['credible_intervals']
self.num_bins = config.config['hist_bins']
self.wait_for_sync = [False] * self.num_parallel
self.prior = None
self.load_priors()
self.samples_file = str(Path(self.config.config['output_dir']) / 'Results' / 'samples.txt')
# Chain history for convergence diagnostics (R-hat, ESS)
self.chain_history = [[] for _ in range(self.num_parallel)]
self.ln_posterior_history = [[] for _ in range(self.num_parallel)]
# Convergence threshold (0 = disabled)
self.rhat_threshold = config.config['rhat_threshold']
# How often (in iterations) to compute the R-hat/ESS convergence
# diagnostics. Each computation rank-normalizes/autocorrelates the last
# 50% of the chain history, whose length grows with the run, so a fixed
# cadence makes total diagnostic cost ~O(max_iterations^2). A stride that
# grows with the run instead caps the number of computations (~100),
# keeping the cost ~O(max_iterations). The *value* reported at any given
# iteration is unchanged — only how often it is computed. 0 = auto.
self.diagnostics_every = config.config['diagnostics_every']
if self.diagnostics_every <= 0:
self.diagnostics_every = max(10, self.max_iterations // 100)
# Total model evaluations for ESS/evaluation metric
self.total_evaluations = 0
# Constraint satisfaction tracking
self.all_constraints = []
for cset in self.config.constraints:
self.all_constraints.extend(cset.constraints)
self.current_constraint_satisfied = [None] * self.num_parallel
self.constraint_samples_file = str(Path(self.config.config['output_dir']) / 'Results' / 'constraint_samples.txt')
# Per-observation log-likelihood sidecar for LOO/WAIC (ADR-0056, #438 item 4).
# Recorded only when output_inference_data is set AND the objective is a per-point
# likelihood (a least-squares / distance / pass-through objective has no normalized
# density to leave one out of -- warn once and stay off so the rest of the run is
# untouched). Like constraint satisfaction, the accepted pset's pointwise vector is
# cached per chain at accept time (where its simdata is in hand) and written for the
# current pset at each sample iteration, so log_likelihood.txt stays row-aligned with
# samples.txt (the bridge reads the two in lockstep).
self.log_likelihood_file = str(Path(self.config.config['output_dir']) / 'Results' / 'log_likelihood.txt')
self.current_pointwise_loglik = [None] * self.num_parallel
self._loglik_ids = None
self._loglik_header_written = False
self._record_loglik = bool(self.config.config.get('output_inference_data')) \
and getattr(self.objective, 'supports_pointwise_log_likelihood', False)
if self.config.config.get('output_inference_data') and not self._record_loglik:
print1("Note: output_inference_data is set, but objfunc '%s' is not a per-point "
"likelihood, so no pointwise log-likelihoods are recorded -- the "
"InferenceData will omit the log_likelihood group and az.loo/az.waic will "
"be unavailable. Use a likelihood objfunc (chi_sq, chi_sq_dynamic, "
"lognormal, laplace, neg_bin, neg_bin_dynamic) for LOO/WAIC."
% self.config.config.get('objfunc'))
# Check that the iteration range is valid with respect to the burnin and or adaptive iterations
def _rebuild_chain_rngs(self):
"""Spawn one independent Generator per parallel chain (overrides the base
no-op). Called at construction and after each bootstrap-replicate reseed so
every replicate's chains are reproducible from the run seed yet distinct."""
self.chain_rngs = self.spawn_chain_rngs(self.num_parallel)
[docs]
def load_priors(self):
"""Builds the data structures for the priors, based on the variables specified in the config."""
self.prior = dict() # Maps each variable name to the FreeParameter containing its scipy.stats distribution.
for var in self.variables:
if var.has_prior:
self.prior[var.name] = var
[docs]
def start_run(self, setup_samples=True):
if self.config.config['initialization'] == 'lh':
first_psets = self.random_latin_hypercube_psets(self.num_parallel)
else:
first_psets = [self.random_pset() for i in range(self.num_parallel)]
# ADR-0043 Phase 2: seed exactly one chain's initial pset at the initial_value
# point (a no-op unless a parameter: record declares one); the other chains stay
# random. Placed before continue_run / starting_params below so those still take
# precedence when set -- they override every chain's start uniformly.
first_psets[0] = self._seed_initial_value_pset(first_psets[0])
self.ln_current_P = [np.nan]*self.num_parallel # Forces accept on the first run
self.current_pset = [None]*self.num_parallel
if self.config.config['continue_run'] == 1:
self.mle_start = np.loadtxt(Path(self.config.config['output_dir']) / 'adaptive_files' / 'MLE_params.txt')
for n in range(self.num_parallel):
for i,p in enumerate(first_psets[n]):
p.value = self.mle_start[i]
if self.config.config['starting_params'] and self.config.config['continue_run'] != 1:
for n in range(self.num_parallel):
for i,p in enumerate(first_psets[n]):
p.value = self.config.config['starting_params'][i]
for i in range(len(first_psets)):
first_psets[i].name = 'iter0run%i' % i
# Set up the output files
# Cant do this in the constructor because that happens before the output folder is potentially overwritten.
if setup_samples:
with open(self.samples_file, 'w') as f:
f.write('# Name\tLn_probability\t'+first_psets[0].keys_to_string()+'\n')
if self._record_loglik:
# Truncate any stale sidecar in lockstep with samples.txt above (a fresh
# run starts both empty; a continue_run leaves both untouched, so the two
# append in step and stay row-aligned). The id header is written lazily on
# the first sample, once an accepted draw reveals the observation labels.
open(self.log_likelihood_file, 'w').close()
self._loglik_header_written = False
if self.all_constraints:
with open(self.constraint_samples_file, 'w') as f:
header = '\t'.join(c.source_line or 'constraint_%i' % i
for i, c in enumerate(self.all_constraints))
f.write('# ' + header + '\n')
(Path(self.config.config['output_dir']) / 'Results' / 'Histograms').mkdir(parents=True, exist_ok=True)
return first_psets
[docs]
def got_result(self, res):
NotImplementedError("got_result() must be implemented in BayesianAlgorithm subclass")
[docs]
def ln_prior(self, pset):
"""
Returns the value of the prior distribution for the given parameter set
:param pset:
:type pset: PSet
:return: float value of ln times the prior distribution
"""
total = 0.
for v, prior_var in self.prior.items():
contribution = prior_var.prior_logpdf(pset[v])
if not np.isfinite(contribution) and prior_var.has_bounded_support:
logger.warning(f'Box-constrained parameter {v} reached a value outside the box.')
total += contribution
return total
[docs]
def evaluate_constraints(self, simdata, chain_index):
"""
Evaluate all constraints against simulation data and cache the pass/fail results for this chain.
:param simdata: Simulation data dictionary
:param chain_index: Index of the chain that was accepted
"""
if not self.all_constraints:
return
satisfied = []
for c in self.all_constraints:
satisfied.append(1 if c.penalty(simdata) == 0 else 0)
self.current_constraint_satisfied[chain_index] = satisfied
[docs]
def record_pointwise_loglik(self, res, chain_index):
"""Cache the just-accepted pset's per-observation log-likelihoods for this chain
(ADR-0056), mirroring :meth:`evaluate_constraints`: computed here, where
``res.simdata`` for the accepted pset is in hand, and written later by
:meth:`sample_pset` for whichever pset is current at a sample iteration. A no-op
unless ``output_inference_data`` is set and the objective is a likelihood; any
failure is logged, never fatal (the run must not die for a diagnostics sidecar)."""
if not self._record_loglik:
return
try:
result = self.objective.evaluate_pointwise(res.simdata, self.exp_data, res.pset)
except Exception:
logger.debug('Could not compute pointwise log-likelihood for chain %d',
chain_index, exc_info=True)
return
if result is None:
return
ids, values = result
if self._loglik_ids is None:
self._loglik_ids = ids
self.current_pointwise_loglik[chain_index] = values
[docs]
def sample_pset(self, pset, ln_prob, chain_index=None):
"""
Adds this pset to the set of sampled psets for the final distribution.
:param pset:
:type pset: PSet
:param ln_prob - The probability of this PSet to record in the samples file.
:type ln_prob: float
:param chain_index: Index of the chain, used to look up cached constraint results.
:type chain_index: int or None
"""
with open(self.samples_file, 'a') as f:
f.write(pset.name+'\t'+str(ln_prob)+'\t'+pset.values_to_string()+'\n')
if self.all_constraints and chain_index is not None and self.current_constraint_satisfied[chain_index] is not None:
with open(self.constraint_samples_file, 'a') as f:
f.write('\t'.join(str(x) for x in self.current_constraint_satisfied[chain_index]) + '\n')
if self._record_loglik:
self._write_pointwise_loglik(chain_index)
def _write_pointwise_loglik(self, chain_index):
"""Append the current chain's cached per-observation log-likelihood vector to
log_likelihood.txt, one row per sample so it stays row-aligned with the
samples.txt row :meth:`sample_pset` just wrote -- the bridge reads the two in
lockstep (ADR-0056). The id header is written lazily on the first row, once the
observation labels are known. If a vector is somehow missing but other chains
have recorded (essentially impossible past burn-in, where every chain has
accepted), a NaN row preserves the alignment rather than silently dropping a row."""
if chain_index is None:
return
values = self.current_pointwise_loglik[chain_index]
if values is None:
if self._loglik_ids is None:
return # nothing recorded anywhere yet -- no row to align to
values = np.full(len(self._loglik_ids), np.nan)
with open(self.log_likelihood_file, 'a') as f:
if not self._loglik_header_written:
f.write('# ' + '\t'.join(self._loglik_ids) + '\n')
self._loglik_header_written = True
f.write('\t'.join('%.17g' % float(v) for v in values) + '\n')
[docs]
def report_constraint_satisfaction(self, file_ext):
"""
Read the constraint samples file and write a summary of constraint satisfaction percentages.
:param file_ext: String to append to the output file name
"""
if not self.all_constraints:
return
try:
dat = np.loadtxt(self.constraint_samples_file)
except (OSError, ValueError):
return
if dat.ndim < 2 or dat.shape[0] == 0:
return
n_samples = dat.shape[0]
filepath = self.config.config['output_dir'] + f'/Results/constraint_satisfaction{file_ext}.txt'
with open(filepath, 'w') as f:
f.write('# constraint\tpercent_satisfied\tn_satisfied\tn_total\n')
for i, c in enumerate(self.all_constraints):
n_satisfied = int(np.sum(dat[:, i]))
pct = 100.0 * n_satisfied / n_samples
label = c.source_line or 'constraint_%i' % i
f.write('%s\t%.1f%%\t%i\t%i\n' % (label, pct, n_satisfied, n_samples))
[docs]
def update_histograms(self, file_ext):
"""
Updates the files that contain histogram points for each variable
:param file_ext: String to append to the save file names
:type file_ext: str
:return:
"""
# Read the samples file into an array, ignoring the first row (header)
# and first 2 columns (pset names, probabilities)
dat_array = np.genfromtxt(self.samples_file, delimiter='\t', dtype=float,
usecols=range(2, len(self.variables)+2))
if dat_array.ndim < 2 or dat_array.shape[0] == 0:
logger.warning('No samples collected — skipping histogram generation')
return
# Open the file(s) to save the credible intervals
cred_files = []
for i in self.credible_intervals:
f = open(Path(self.config.config['output_dir']) / 'Results' / f'credible{i}{file_ext}.txt', 'w')
f.write('# param\tlower_bound\tupper_bound\n')
cred_files.append(f)
for i in range(len(self.variables)):
v = self.variables[i]
fname = self.config.config['output_dir']+f'/Results/Histograms/{v.name}{file_ext}.txt'
# Bin in the parameter's sampling space u -- log10 for a log variable,
# identity otherwise; ask the parameter for the transform (#412).
histdata = v.to_sampling_space(dat_array[:, i])
# Label the bin edges with the variable's actual sampling space (log10 / ln /
# linear) so a natural-log parameter is not mislabeled 'log10' (ADR-0043).
edge_prefix = f'{v.scale_name}_' if v.log_space else ''
header = f'{edge_prefix}lower_bound\t{edge_prefix}upper_bound\tcount'
hist, bin_edges = np.histogram(histdata, bins=self.num_bins)
result_array = np.stack((bin_edges[:-1], bin_edges[1:], hist), axis=-1)
np.savetxt(fname, result_array, delimiter='\t', header=header)
sorted_data = sorted(dat_array[:, i])
for interval, file in zip(self.credible_intervals, cred_files):
n = len(sorted_data)
want = n * (interval/100)
# Clamp to valid indices: an interval >= 100% (or rounding at small n)
# would otherwise drive max_index to n (IndexError) or min_index < 0
# (silently wraps to the wrong end via negative indexing).
min_index = max(0, int(np.round(n/2 - want/2)))
max_index = min(n - 1, int(np.round(n/2 + want/2 - 1)))
file.write(f'{v.name}\t{sorted_data[min_index]}\t{sorted_data[max_index]}\n')
for file in cred_files:
file.close()
[docs]
def compute_rhat(self):
"""Rank-normalized split-R-hat per parameter (Vehtari et al. 2021).
Thin glue over :func:`pybnf.diagnostics.rhat` (ADR-0009): reads this
instance's chain history and delegates the pure math. Returns an
``(n_dim,)`` array, or ``None`` if there is insufficient history.
"""
return diagnostics.rhat(self.chain_history, self.num_parallel)
[docs]
def compute_ess(self):
"""Bulk and tail effective sample size per parameter (Vehtari et al. 2021).
Thin glue over :func:`pybnf.diagnostics.ess` (ADR-0009): reads this
instance's chain history and delegates the pure math. Returns
``(bulk_ess, tail_ess)`` arrays of shape ``(n_dim,)``, or ``(None, None)``.
"""
return diagnostics.ess(self.chain_history, self.num_parallel)
[docs]
def report_convergence_diagnostics(self, iteration):
"""
Compute and report R-hat, ESS, and ESS/evaluation. Called every 10 iterations.
Returns max_rhat for convergence checking, or None.
"""
rhat = self.compute_rhat()
max_rhat = None
if rhat is not None:
max_rhat = np.nanmax(rhat)
print1(f'Max R-hat: {max_rhat:.4f}')
print2(f'R-hat per parameter: {str(np.round(rhat, 4))}')
logger.info(f'R-hat values: {str(rhat)}')
bulk_ess, tail_ess = self.compute_ess()
if bulk_ess is not None:
print1(f'Min bulk ESS: {np.nanmin(bulk_ess):.1f} Min tail ESS: {np.nanmin(tail_ess):.1f}')
print2(f'Bulk ESS per parameter: {str(np.round(bulk_ess, 1))}')
print2(f'Tail ESS per parameter: {str(np.round(tail_ess, 1))}')
logger.info(f'Bulk ESS: {str(bulk_ess)}')
logger.info(f'Tail ESS: {str(tail_ess)}')
if self.total_evaluations > 0:
ess_per_eval = bulk_ess / self.total_evaluations
print2(f'Bulk ESS/evaluation: {str(np.round(ess_per_eval, 6))}')
logger.info(f'Bulk ESS/evaluation: {str(ess_per_eval)}')
# Write diagnostics to file
self._write_diagnostics(iteration, rhat, bulk_ess, tail_ess)
return max_rhat
[docs]
def check_convergence(self, iteration, max_rhat):
"""Check if R-hat has converged below threshold. Returns True if should stop."""
if (self.rhat_threshold > 0
and iteration > self.burn_in
and max_rhat is not None
and max_rhat <= self.rhat_threshold):
print1(f'R-hat converged ({max_rhat:.4f} <= {self.rhat_threshold:.4f}). Stopping.')
self.update_histograms('_final')
self.report_constraint_satisfaction('_final')
return True
return False
def _write_diagnostics(self, iteration, rhat, bulk_ess, tail_ess):
"""Append convergence diagnostics to the diagnostics output file."""
diag_file = Path(self.config.config['output_dir']) / 'Results' / 'diagnostics.txt'
write_header = not diag_file.exists()
param_names = [v.name for v in self.variables]
with open(diag_file, 'a') as f:
if write_header:
cols = ['iteration', 'total_evaluations']
for name in param_names:
cols.extend([f'rhat_{name}', f'bulk_ess_{name}', f'tail_ess_{name}'])
f.write('# ' + '\t'.join(cols) + '\n')
vals = [str(iteration), str(self.total_evaluations)]
for i in range(len(param_names)):
rhat_val = f'{rhat[i]:.6f}' if rhat is not None else 'nan'
bulk_val = f'{bulk_ess[i]:.2f}' if bulk_ess is not None else 'nan'
tail_val = f'{tail_ess[i]:.2f}' if tail_ess is not None else 'nan'
vals.extend([rhat_val, bulk_val, tail_val])
f.write('\t'.join(vals) + '\n')
[docs]
def cleanup(self):
"""Called when quitting due to error.
Save the histograms in addition to the usual algorithm cleanup"""
super().cleanup()
self.update_histograms('_end')
self.report_constraint_satisfaction('_end')