class minimize(Minimizer, CovmatSampler): ignore_prior: bool confidence_for_unbounded: float method: str override_bobyqa: Optional[Mapping] override_scipy: Optional[Mapping] seed: Optional[int] def initialize(self): self.mpi_info("Initializing") self.max_evals = read_dnumber(self.max_evals, self.model.prior.d()) # Configure target method = self.model.loglike if self.ignore_prior else self.model.logpost kwargs = {"make_finite": True} if self.ignore_prior: kwargs["return_derived"] = False self.logp = lambda x: method(x, **kwargs) # Try to load info from previous samples. # If none, sample from reference (make sure that it has finite like/post) initial_point = None if self.output: files = self.output.find_collections() collection_in = None if files: if more_than_one_process(): if 1 + get_mpi_rank() <= len(files): collection_in = Collection(self.model, self.output, name=str(1 + get_mpi_rank()), resuming=True) else: collection_in = self.output.load_collections( self.model, concatenate=True) if collection_in: initial_point = (collection_in.bestfit() if self.ignore_prior else collection_in.MAP()) initial_point = initial_point[list( self.model.parameterization.sampled_params())].values self.log.info("Starting from %s of previous chain:", "best fit" if self.ignore_prior else "MAP") if initial_point is None: this_logp = -np.inf while not np.isfinite(this_logp): initial_point = self.model.prior.reference() this_logp = self.logp(initial_point) self.log.info("Starting from random initial point:") self.log.info( dict( zip(self.model.parameterization.sampled_params(), initial_point))) self._bounds = self.model.prior.bounds( confidence_for_unbounded=self.confidence_for_unbounded) # TODO: if ignore_prior, one should use *like* covariance (this is *post*) covmat = self._load_covmat(self.output)[0] # scale by conditional parameter widths (since not using correlation structure) scales = np.minimum(1 / np.sqrt(np.diag(np.linalg.inv(covmat))), (self._bounds[:, 1] - self._bounds[:, 0]) / 3) # Cov and affine transformation # Transform to space where initial point is at centre, and cov is normalised # Cannot do rotation, as supported minimization routines assume bounds aligned # with the parameter axes. self._affine_transform_matrix = np.diag(1 / scales) self._inv_affine_transform_matrix = np.diag(scales) self._scales = scales self._affine_transform_baseline = initial_point initial_point = self.affine_transform(initial_point) np.testing.assert_allclose(initial_point, np.zeros(initial_point.shape)) bounds = np.array( [self.affine_transform(self._bounds[:, i]) for i in range(2)]).T # Configure method if self.method.lower() == "bobyqa": self.minimizer = pybobyqa.solve self.kwargs = { "objfun": (lambda x: -self.logp_transf(x)), "x0": initial_point, "bounds": np.array(list(zip(*bounds))), "seek_global_minimum": (True if get_mpi_size() in [0, 1] else False), "maxfun": int(self.max_evals) } self.kwargs = recursive_update(deepcopy(self.kwargs), self.override_bobyqa or {}) self.log.debug( "Arguments for pybobyqa.solve:\n%r", {k: v for k, v in self.kwargs.items() if k != "objfun"}) elif self.method.lower() == "scipy": self.minimizer = scpminimize self.kwargs = { "fun": (lambda x: -self.logp_transf(x)), "x0": initial_point, "bounds": bounds, "options": { "maxiter": self.max_evals, "disp": (self.log.getEffectiveLevel() == logging.DEBUG) } } self.kwargs = recursive_update(deepcopy(self.kwargs), self.override_scipy or {}) self.log.debug( "Arguments for scipy.optimize.minimize:\n%r", {k: v for k, v in self.kwargs.items() if k != "fun"}) else: methods = ["bobyqa", "scipy"] raise LoggedError(self.log, "Method '%s' not recognized. Try one of %r.", self.method, methods) def affine_transform(self, x): return (x - self._affine_transform_baseline) / self._scales def inv_affine_transform(self, x): # fix up rounding errors on bounds to avoid -np.inf likelihoods return np.clip(x * self._scales + self._affine_transform_baseline, self._bounds[:, 0], self._bounds[:, 1]) def logp_transf(self, x): return self.logp(self.inv_affine_transform(x)) def _run(self): """ Runs `scipy.minimize` """ self.log.info("Starting minimization.") try: self.result = self.minimizer(**self.kwargs) except: self.log.error("Minimizer '%s' raised an unexpected error:", self.method) raise self.success = (self.result.success if self.method.lower() == "scipy" else self.result.flag == self.result.EXIT_SUCCESS) if self.success: self.log.info("Finished successfully!") else: if self.method.lower() == "bobyqa": reason = { self.result.EXIT_MAXFUN_WARNING: "Maximum allowed objective evaluations reached. " "This is the most likely return value when using multiple restarts.", self.result.EXIT_SLOW_WARNING: "Maximum number of slow iterations reached.", self.result.EXIT_FALSE_SUCCESS_WARNING: "Py-BOBYQA reached the maximum number of restarts which decreased the" " objective, but to a worse value than was found in a previous run.", self.result.EXIT_INPUT_ERROR: "Error in the inputs.", self.result.EXIT_TR_INCREASE_ERROR: "Error occurred when solving the trust region subproblem.", self.result.EXIT_LINALG_ERROR: "Linear algebra error, e.g. the interpolation points produced a " "singular linear system." }[self.result.flag] else: reason = "" self.log.error("Finished unsuccessfully." + (" Reason: " + reason if reason else "")) self.process_results() def process_results(self): """ Determines success (or not), chooses best (if MPI) and produces output (if requested). """ evals_attr_ = evals_attr[self.method.lower()] # If something failed if not hasattr(self, "result"): return if more_than_one_process(): results = get_mpi_comm().gather(self.result, root=0) successes = get_mpi_comm().gather(self.success, root=0) _affine_transform_baselines = get_mpi_comm().gather( self._affine_transform_baseline, root=0) if is_main_process(): mins = [(getattr(r, evals_attr_) if s else np.inf) for r, s in zip(results, successes)] i_min = np.argmin(mins) self.result = results[i_min] self._affine_transform_baseline = _affine_transform_baselines[ i_min] else: successes = [self.success] if is_main_process(): if not any(successes): raise LoggedError( self.log, "Minimization failed! Here is the raw result object:\n%s", str(self.result)) elif not all(successes): self.log.warning('Some minimizations failed!') elif more_than_one_process(): if max(mins) - min(mins) > 1: self.log.warning('Big spread in minima: %r', mins) elif max(mins) - min(mins) > 0.2: self.log.warning('Modest spread in minima: %r', mins) logp_min = -np.array(getattr(self.result, evals_attr_)) x_min = self.inv_affine_transform(self.result.x) self.log.info("-log(%s) minimized to %g", "likelihood" if self.ignore_prior else "posterior", -logp_min) recomputed_post_min = self.model.logposterior(x_min, cached=False) recomputed_logp_min = (sum(recomputed_post_min.loglikes) if self.ignore_prior else recomputed_post_min.logpost) if not np.allclose(logp_min, recomputed_logp_min, atol=1e-2): raise LoggedError( self.log, "Cannot reproduce log minimum to within 0.01. Maybe your " "likelihood is stochastic or large numerical error? " "Recomputed min: %g (was %g) at %r", recomputed_logp_min, logp_min, x_min) self.minimum = OnePoint(self.model, self.output, name="", extension=get_collection_extension( self.ignore_prior)) self.minimum.add(x_min, derived=recomputed_post_min.derived, logpost=recomputed_post_min.logpost, logpriors=recomputed_post_min.logpriors, loglikes=recomputed_post_min.loglikes) self.log.info("Parameter values at minimum:\n%s", self.minimum.data.to_string()) self.minimum.out_update() self.dump_getdist() def products(self): r""" Returns a dictionary containing: - ``minimum``: :class:`OnePoint` that maximizes the posterior or likelihood (depending on ``ignore_prior``). - ``result_object``: instance of results class of `scipy <https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.OptimizeResult.html>`_ or `pyBOBYQA <https://numericalalgorithmsgroup.github.io/pybobyqa/build/html/userguide.html>`_. - ``M``: inverse of the affine transform matrix (see below). ``None`` if no transformation applied. - ``X0``: offset of the affine transform matrix (see below) ``None`` if no transformation applied. If non-trivial ``M`` and ``X0`` are returned, this means that the minimizer has been working on an affine-transformed parameter space :math:`x^\prime`, from which the real space points can be obtained as :math:`x = M x^\prime + X_0`. This inverse transformation needs to be applied to the coordinates appearing inside the ``result_object``. """ if is_main_process(): return { "minimum": self.minimum, "result_object": self.result, "M": self._inv_affine_transform_matrix, "X0": self._affine_transform_baseline } def getdist_point_text(self, params, weight=None, minuslogpost=None): lines = [] if weight is not None: lines.append(' weight = %s' % weight) if minuslogpost is not None: lines.append(' -log(Like) = %s' % minuslogpost) lines.append(' chi-sq = %s' % (2 * minuslogpost)) lines.append('') labels = self.model.parameterization.labels() label_list = list(labels) if hasattr(params, 'chi2_names'): label_list += params.chi2_names width = max(len(lab) for lab in label_list) + 2 def add_section(pars): for p, val in pars: lab = labels.get(p, p) num = label_list.index(p) + 1 if isinstance(val, (float, np.floating)) and len(str(val)) > 10: lines.append("%5d %-17.9e %-*s %s" % (num, val, width, p, lab)) else: lines.append("%5d %-17s %-*s %s" % (num, val, width, p, lab)) # num_sampled = len(self.model.parameterization.sampled_params()) # num_derived = len(self.model.parameterization.derived_params()) add_section([(p, params[p]) for p in self.model.parameterization.sampled_params()]) lines.append('') add_section([[p, value] for p, value in self.model.parameterization.constant_params().items()]) lines.append('') add_section([[p, params[p]] for p in self.model.parameterization.derived_params()]) if hasattr(params, 'chi2_names'): labels.update({ p: r'\chi^2_{\rm %s}' % (_undo_chi2_name(p).replace("_", r"\ ")) for p in params.chi2_names }) add_section([[chi2, params[chi2]] for chi2 in params.chi2_names]) return "\n".join(lines) def dump_getdist(self): if not self.output: return getdist_bf = self.getdist_point_text( self.minimum, minuslogpost=self.minimum['minuslogpost']) out_filename = os.path.join( self.output.folder, self.output.prefix + getdist_ext_ignore_prior[self.ignore_prior]) with open(out_filename, 'w', encoding="utf-8") as f: f.write(getdist_bf) @classmethod def output_files_regexps(cls, output, info=None, minimal=False): ignore_prior = bool(info.get("ignore_prior", False)) ext_collection = get_collection_extension(ignore_prior) ext_getdist = getdist_ext_ignore_prior[ignore_prior] regexps = [ re.compile(output.prefix_regexp_str + re.escape(ext.lstrip(".")) + "$") for ext in [ext_collection, ext_getdist] ] return [(r, None) for r in regexps] @classmethod def check_force_resume(cls, output, info=None): """ Performs the necessary checks on existing files if resuming or forcing (including deleting some output files when forcing). """ if output.is_resuming(): output.log.warning( "Minimizer does not support resuming. Ignoring.") output.set_resuming(False) super().check_force_resume(output, info=info)
class mcmc(Sampler): def initialize(self): """Initializes the sampler: creates the proposal distribution and draws the initial sample.""" self.log.debug("Initializing") for p in [ "burn_in", "max_tries", "output_every", "check_every", "callback_every" ]: setattr( self, p, read_dnumber(getattr(self, p), self.model.prior.d(), dtype=int)) if self.callback_every is None: self.callback_every = self.check_every # Burning-in countdown -- the +1 accounts for the initial point (always accepted) self.burn_in_left = self.burn_in + 1 # Max # checkpoints to wait, in case one process dies without sending MPI_ABORT self.been_waiting = 0 self.max_waiting = max(50, self.max_tries / self.model.prior.d()) if self.resuming and (max(self.mpi_size or 0, 1) != max( get_mpi_size(), 1)): self.log.error( "Cannot resume a sample with a different number of chains: " "was %d and now is %d.", max(self.mpi_size, 1), max(get_mpi_size(), 1)) raise HandledException if not self.resuming and self.output: # Delete previous files (if not "forced", the run would have already failed) if ((os.path.abspath(self.covmat_filename()) != os.path.abspath( str(self.covmat)))): try: os.remove(self.covmat_filename()) except OSError: pass # There may be more that chains than expected, # if #ranks was bigger in a previous run i = 0 while True: i += 1 collection_filename, _ = self.output.prepare_collection(str(i)) try: os.remove(collection_filename) except OSError: break # One collection per MPI process: `name` is the MPI rank + 1 name = str(1 + (lambda r: r if r is not None else 0)(get_mpi_rank())) self.collection = Collection(self.model, self.output, name=name, resuming=self.resuming) self.current_point = OnePoint(self.model, OutputDummy({}), name=name) # Use standard MH steps by default self.get_new_sample = self.get_new_sample_metropolis # Prepare oversampling / dragging if applicable self.effective_max_samples = self.max_samples if self.oversample and self.drag: self.log.error("Choose either oversampling or dragging, not both.") raise HandledException if self.oversample: factors, blocks = self.model.likelihood._speeds_of_params( int_speeds=True) self.oversampling_factors = factors self.log.info("Oversampling with factors:\n" + "\n".join([ " %d : %r" % (f, b) for f, b in zip(self.oversampling_factors, blocks) ])) self.i_last_slow_block = None # No way right now to separate slow and fast slow_params = list(self.model.parameterization.sampled_params()) elif self.drag: speeds, blocks = self.model.likelihood._speeds_of_params( fast_slow=True, int_speeds=True) # For now, no blocking inside either fast or slow: just 2 blocks self.i_last_slow_block = 0 if np.all(speeds == speeds[0]): self.log.error( "All speeds are equal or too similar: cannot drag! " "Make sure to define accurate likelihoods' speeds.") raise HandledException # Make the 1st factor 1: speeds = [1, speeds[1] / speeds[0]] # Target: dragging step taking as long as slow step self.drag_interp_steps = self.drag * speeds[1] # Per dragging step, the (fast) posterior is evaluated *twice*, self.drag_interp_steps /= 2 self.drag_interp_steps = int(np.round(self.drag_interp_steps)) fast_params = list(chain(*blocks[1 + self.i_last_slow_block:])) # Not too much or too little dragging drag_limits = [(int(l) * len(fast_params) if l is not None else l) for l in self.drag_limits] if drag_limits[ 0] is not None and self.drag_interp_steps < drag_limits[0]: self.log.warning( "Number of dragging steps clipped from below: was not " "enough to efficiently explore the fast directions -- " "avoid this limit by decreasing 'drag_limits[0]'.") self.drag_interp_steps = drag_limits[0] if drag_limits[ 1] is not None and self.drag_interp_steps > drag_limits[1]: self.log.warning( "Number of dragging steps clipped from above: " "excessive, probably inefficient, exploration of the " "fast directions -- " "avoid this limit by increasing 'drag_limits[1]'.") self.drag_interp_steps = drag_limits[1] # Re-scale steps between checkpoint and callback to the slow dimensions only slow_params = list(chain(*blocks[:1 + self.i_last_slow_block])) self.n_slow = len(slow_params) for p in ["check_every", "callback_every"]: setattr( self, p, int(getattr(self, p) * self.n_slow / self.model.prior.d())) self.log.info("Dragging with oversampling per step:\n" + "\n".join([ " %d : %r" % (f, b) for f, b in zip([1, self.drag_interp_steps], [blocks[0], fast_params]) ])) self.get_new_sample = self.get_new_sample_dragging else: _, blocks = self.model.likelihood._speeds_of_params() self.oversampling_factors = [1 for b in blocks] slow_params = list(self.model.parameterization.sampled_params()) self.n_slow = len(slow_params) # Turn parameter names into indices self.blocks = [[ list(self.model.parameterization.sampled_params()).index(p) for p in b ] for b in blocks] self.proposer = BlockedProposer( self.blocks, oversampling_factors=self.oversampling_factors, i_last_slow_block=self.i_last_slow_block, proposal_scale=self.proposal_scale) # Build the initial covariance matrix of the proposal, or load from checkpoint if self.resuming: covmat = np.loadtxt(self.covmat_filename()) self.log.info("Covariance matrix from checkpoint.") else: covmat = self.initial_proposal_covmat(slow_params=slow_params) self.log.info("Initial covariance matrix.") self.log.debug( "Sampling with covmat:\n%s", DataFrame( covmat, columns=self.model.parameterization.sampled_params(), index=self.model.parameterization.sampled_params()).to_string( line_width=_line_width)) self.proposer.set_covariance(covmat) # Prepare callback function if self.callback_function is not None: self.callback_function_callable = (get_external_function( self.callback_function)) def initial_proposal_covmat(self, slow_params=None): """ Build the initial covariance matrix, using the data provided, in descending order of priority: 1. "covmat" field in the "mcmc" sampler block. 2. "proposal" field for each parameter. 3. variance of the reference pdf. 4. variance of the prior pdf. The covariances between parameters when both are present in a covariance matrix provided through option 1 are preserved. All other covariances are assumed 0. """ params_infos = self.model.parameterization.sampled_params_info() covmat = np.diag([np.nan] * len(params_infos)) # Try to generate it automatically if isinstance(self.covmat, six.string_types) and self.covmat.lower() == "auto": slow_params_info = { p: info for p, info in params_infos.items() if p in slow_params } auto_covmat = self.model.likelihood._get_auto_covmat( slow_params_info) if auto_covmat: self.covmat = os.path.join(auto_covmat["folder"], auto_covmat["name"]) self.log.info("Covariance matrix selected automatically: %s", self.covmat) else: self.covmat = None self.log.info( "Could not automatically find a good covmat. " "Will generate from parameter info (proposal and prior).") # If given, load and test the covariance matrix if isinstance(self.covmat, six.string_types): covmat_pre = "{%s}" % _path_install if self.covmat.startswith(covmat_pre): self.covmat = self.covmat.format( **{ _path_install: self.path_install }).replace("/", os.sep) try: with open(self.covmat, "r") as file_covmat: header = file_covmat.readline() loaded_covmat = np.loadtxt(self.covmat) except TypeError: self.log.error( "The property 'covmat' must be a file name," "but it's '%s'.", str(self.covmat)) raise HandledException except IOError: self.log.error("Can't open covmat file '%s'.", self.covmat) raise HandledException if header[0] != "#": self.log.error( "The first line of the covmat file '%s' " "must be one list of parameter names separated by spaces " "and staring with '#', and the rest must be a square matrix, " "with one row per line.", self.covmat) raise HandledException loaded_params = header.strip("#").strip().split() elif hasattr(self.covmat, "__getitem__"): if not self.covmat_params: self.log.error( "If a covariance matrix is passed as a numpy array, " "you also need to pass the parameters it corresponds to " "via 'covmat_params: [name1, name2, ...]'.") raise HandledException loaded_params = self.covmat_params loaded_covmat = self.covmat if self.covmat is not None: if len(loaded_params) != len(set(loaded_params)): self.log.error( "There are duplicated parameters in the header of the " "covmat file '%s' ", self.covmat) raise HandledException if len(loaded_params) != loaded_covmat.shape[0]: self.log.error( "The number of parameters in the header of '%s' and the " "dimensions of the matrix do not coincide.", self.covmat) raise HandledException if not (np.allclose(loaded_covmat.T, loaded_covmat) and np.all(np.linalg.eigvals(loaded_covmat) > 0)): self.log.error( "The covmat loaded from '%s' is not a positive-definite, " "symmetric square matrix.", self.covmat) raise HandledException # Fill with parameters in the loaded covmat renames = [[p] + np.atleast_1d(v.get(_p_renames, [])).tolist() for p, v in params_infos.items()] renames = odict([[a[0], a] for a in renames]) indices_used, indices_sampler = zip(*[[ loaded_params.index(p), [ list(params_infos).index(q) for q, a in renames.items() if p in a ] ] for p in loaded_params]) if not any(indices_sampler): self.log.error( "A proposal covariance matrix has been loaded, but none of its " "parameters are actually sampled here. Maybe a mismatch between" " parameter names in the covariance matrix and the input file?" ) raise HandledException indices_used, indices_sampler = zip( *[[i, j] for i, j in zip(indices_used, indices_sampler) if j]) if any(len(j) - 1 for j in indices_sampler): first = next(j for j in indices_sampler if len(j) > 1) self.log.error( "The parameters %s have duplicated aliases. Can't assign them an " "element of the covariance matrix unambiguously.", ", ".join([list(params_infos)[i] for i in first])) raise HandledException indices_sampler = list(chain(*indices_sampler)) covmat[np.ix_(indices_sampler, indices_sampler)] = (loaded_covmat[np.ix_( indices_used, indices_used)]) self.log.info("Covariance matrix loaded for params %r", [list(params_infos)[i] for i in indices_sampler]) missing_params = set(params_infos).difference( set([list(params_infos)[i] for i in indices_sampler])) if missing_params: self.log.info("Missing proposal covariance for params %r", [ p for p in self.model.parameterization.sampled_params() if p in missing_params ]) else: self.log.info( "All parameters' covariance loaded from given covmat.") # Fill gaps with "proposal" property, if present, otherwise ref (or prior) where_nan = np.isnan(covmat.diagonal()) if np.any(where_nan): covmat[where_nan, where_nan] = np.array([ info.get(_p_proposal, np.nan)**2 for info in params_infos.values() ])[where_nan] # we want to start learning the covmat earlier self.log.info( "Covariance matrix " + ("not present" if np.all(where_nan) else "not complete") + ". " "We will start learning the covariance of the proposal earlier:" " R-1 = %g (was %g).", self.learn_proposal_Rminus1_max_early, self.learn_proposal_Rminus1_max) self.learn_proposal_Rminus1_max = self.learn_proposal_Rminus1_max_early where_nan = np.isnan(covmat.diagonal()) if np.any(where_nan): covmat[where_nan, where_nan] = ( self.model.prior.reference_covmat().diagonal()[where_nan]) assert not np.any(np.isnan(covmat)) return covmat def run(self): """ Runs the sampler. """ # Get first point, to be discarded -- not possible to determine its weight # Still, we need to compute derived parameters, since, as the proposal "blocked", # we may be saving the initial state of some block. # NB: if resuming but nothing was written (burn-in not finished): re-start self.log.info("Initial point:") if self.resuming and self.collection.n(): initial_point = (self.collection[ self.collection.sampled_params].ix[self.collection.n() - 1]).values.copy() logpost = -(self.collection[_minuslogpost].ix[self.collection.n() - 1].copy()) logpriors = -(self.collection[self.collection.prior_names].ix[ self.collection.n() - 1].copy()) loglikes = -0.5 * (self.collection[self.collection.chi2_names].ix[ self.collection.n() - 1].copy()) derived = (self.collection[self.collection.derived_params].ix[ self.collection.n() - 1].values.copy()) else: initial_point = self.model.prior.reference( max_tries=self.max_tries) logpost, logpriors, loglikes, derived = self.model.logposterior( initial_point) self.current_point.add(initial_point, derived=derived, logpost=logpost, logpriors=logpriors, loglikes=loglikes) self.log.info( "\n%s", self.current_point.data.to_string(index=False, line_width=_line_width)) # Initial dummy checkpoint (needed when 1st checkpoint not reached in prev. run) self.write_checkpoint() # Main loop! self.log.info("Sampling!" + ( " (NB: nothing will be printed until %d burn-in samples " % self.burn_in + "have been obtained)" if self.burn_in else "")) while self.n() < self.effective_max_samples and not self.converged: self.get_new_sample() # Callback function if (hasattr(self, "callback_function_callable") and not (max(self.n(), 1) % self.callback_every) and self.current_point[_weight] == 1): self.callback_function_callable(self) # Checking convergence and (optionally) learning the covmat of the proposal if self.check_all_ready(): self.check_convergence_and_learn_proposal() if self.n() == self.effective_max_samples: self.log.info( "Reached maximum number of accepted steps allowed. " "Stopping.") # Make sure the last batch of samples ( < output_every ) are written self.collection._out_update() if get_mpi(): Ns = (lambda x: np.array(get_mpi_comm().gather(x)))(self.n()) else: Ns = [self.n()] if not get_mpi_rank(): self.log.info("Sampling complete after %d accepted steps.", sum(Ns)) def n(self, burn_in=False): """ Returns the total number of steps taken, including or not burn-in steps depending on the value of the `burn_in` keyword. """ return self.collection.n() + (0 if not burn_in else self.burn_in - self.burn_in_left + 1) def get_new_sample_metropolis(self): """ Draws a new trial point from the proposal pdf and checks whether it is accepted: if it is accepted, it saves the old one into the collection and sets the new one as the current state; if it is rejected increases the weight of the current state by 1. Returns: ``True`` for an accepted step, ``False`` for a rejected one. """ trial = deepcopy( self.current_point[self.model.parameterization._sampled]) self.proposer.get_proposal(trial) logpost_trial, logprior_trial, loglikes_trial, derived = self.model.logposterior( trial) accept = self.metropolis_accept(logpost_trial, -self.current_point["minuslogpost"]) self.process_accept_or_reject(accept, trial, derived, logpost_trial, logprior_trial, loglikes_trial) return accept def get_new_sample_dragging(self): """ Draws a new trial point in the slow subspace, and gets the corresponding trial in the fast subspace by "dragging" the fast parameters. Finally, checks the acceptance of the total step using the "dragging" pdf: if it is accepted, it saves the old one into the collection and sets the new one as the current state; if it is rejected increases the weight of the current state by 1. Returns: ``True`` for an accepted step, ``False`` for a rejected one. """ # Prepare starting and ending points *in the SLOW subspace* # "start_" and "end_" mean here the extremes in the SLOW subspace start_slow_point = self.current_point[ self.model.parameterization._sampled] start_slow_logpost = -self.current_point["minuslogpost"] end_slow_point = deepcopy(start_slow_point) self.proposer.get_proposal_slow(end_slow_point) self.log.debug("Proposed slow end-point: %r", end_slow_point) # Save derived parameters of delta_slow jump, in case I reject all the dragging # steps but accept the move in the slow direction only end_slow_logpost, end_slow_logprior, end_slow_loglikes, derived = ( self.model.logposterior(end_slow_point)) if end_slow_logpost == -np.inf: self.current_point.increase_weight(1) return False # trackers of the dragging current_start_point = start_slow_point current_end_point = end_slow_point current_start_logpost = start_slow_logpost current_end_logpost = end_slow_logpost current_end_logprior = end_slow_logprior current_end_loglikes = end_slow_loglikes # accumulators for the "dragging" probabilities to be metropolist-tested # at the end of the interpolation start_drag_logpost_acc = start_slow_logpost end_drag_logpost_acc = end_slow_logpost # start dragging for i_step in range(1, 1 + self.drag_interp_steps): self.log.debug("Dragging step: %d", i_step) # take a step in the fast direction in both slow extremes delta_fast = np.zeros(len(current_start_point)) self.proposer.get_proposal_fast(delta_fast) self.log.debug("Proposed fast step delta: %r", delta_fast) proposal_start_point = deepcopy(current_start_point) proposal_start_point += delta_fast proposal_end_point = deepcopy(current_end_point) proposal_end_point += delta_fast # get the new extremes for the interpolated probability # (reject if any of them = -inf; avoid evaluating both if just one fails) # Force the computation of the (slow blocks) derived params at the starting # point, but discard them, since they contain the starting point's fast ones, # not used later -- save the end point's ones. proposal_start_logpost = self.model.logposterior( proposal_start_point)[0] proposal_end_logpost, proposal_end_logprior, \ proposal_end_loglikes, derived_proposal_end = ( self.model.logposterior(proposal_end_point) if proposal_start_logpost > -np.inf else (-np.inf, None, [], [])) if proposal_start_logpost > -np.inf and proposal_end_logpost > -np.inf: # create the interpolated probability and do a Metropolis test frac = i_step / (1 + self.drag_interp_steps) proposal_interp_logpost = ( (1 - frac) * proposal_start_logpost + frac * proposal_end_logpost) current_interp_logpost = ((1 - frac) * current_start_logpost + frac * current_end_logpost) accept_drag = self.metropolis_accept(proposal_interp_logpost, current_interp_logpost) else: accept_drag = False self.log.debug("Dragging step: %s", ("accepted" if accept_drag else "rejected")) # If the dragging step was accepted, do the drag if accept_drag: current_start_point = proposal_start_point current_start_logpost = proposal_start_logpost current_end_point = proposal_end_point current_end_logpost = proposal_end_logpost current_end_logprior = proposal_end_logprior current_end_loglikes = proposal_end_loglikes derived = derived_proposal_end # In any case, update the dragging probability for the final metropolis test start_drag_logpost_acc += current_start_logpost end_drag_logpost_acc += current_end_logpost # Test for the TOTAL step accept = self.metropolis_accept( end_drag_logpost_acc / self.drag_interp_steps, start_drag_logpost_acc / self.drag_interp_steps) self.process_accept_or_reject(accept, current_end_point, derived, current_end_logpost, current_end_logprior, current_end_loglikes) self.log.debug("TOTAL step: %s", ("accepted" if accept else "rejected")) return accept def metropolis_accept(self, logp_trial, logp_current): """ Symmetric-proposal Metropolis-Hastings test. Returns: ``True`` or ``False``. """ if logp_trial == -np.inf: return False elif logp_trial > logp_current: return True else: return np.random.exponential() > (logp_current - logp_trial) def process_accept_or_reject(self, accept_state, trial=None, derived=None, logpost_trial=None, logprior_trial=None, loglikes_trial=None): """Processes the acceptance/rejection of the new point.""" if accept_state: # add the old point to the collection (if not burning or initial point) if self.burn_in_left <= 0: self.current_point.add_to_collection(self.collection) self.log.debug("New sample, #%d: \n %r", self.n(), self.current_point) if self.n() % self.output_every == 0: self.collection._out_update() else: self.burn_in_left -= 1 self.log.debug("Burn-in sample:\n %r", self.current_point) if self.burn_in_left == 0 and self.burn_in: self.log.info( "Finished burn-in phase: discarded %d accepted steps.", self.burn_in) # set the new point as the current one, with weight one self.current_point.add(trial, derived=derived, weight=1, logpost=logpost_trial, logpriors=logprior_trial, loglikes=loglikes_trial) else: # not accepted self.current_point.increase_weight(1) # Failure criterion: chain stuck! (but be more permissive during burn_in) max_tries_now = self.max_tries * ( 1 + (10 - 1) * np.sign(self.burn_in_left)) if self.current_point[_weight] > max_tries_now: self.collection._out_update() self.log.error( "The chain has been stuck for %d attempts. Stopping sampling. " "If this has happened often, try improving your " "reference point/distribution. Alternatively (though not advisable) " "make 'max_tries: np.inf' (or 'max_tries: .inf' in yaml)", max_tries_now) raise HandledException # Functions to check convergence and learn the covariance of the proposal distribution def check_all_ready(self): """ Checks if the chain(s) is(/are) ready to check convergence and, if requested, learn a new covariance matrix for the proposal distribution. """ msg_ready = ( ("Ready to" if get_mpi() or self.learn_proposal else "") + " check convergence" + (" and" if get_mpi() and self.learn_proposal else "") + (" learn a new proposal covmat" if self.learn_proposal else "")) # If *just* (weight==1) got ready to check+learn if (self.n() > 0 and self.current_point[_weight] == 1 and not (self.n() % self.check_every)): self.log.info("Checkpoint: %d samples accepted.", self.n()) if get_mpi(): self.been_waiting += 1 if self.been_waiting > self.max_waiting: self.log.error( "Waiting for too long for all chains to be ready. " "Maybe one of them is stuck or died unexpectedly?") raise HandledException self.model.dump_timing() # If not MPI, we are ready if not get_mpi(): if msg_ready: self.log.info(msg_ready) return True # If MPI, tell the rest that we are ready -- we use a "gather" # ("reduce" was problematic), but we are in practice just pinging if not hasattr(self, "req"): # just once! self.all_ready = np.empty(get_mpi_size()) self.req = get_mpi_comm().Iallgather(np.array([1.]), self.all_ready) self.log.info(msg_ready + " (waiting for the rest...)") # If all processes are ready to learn (= communication finished) if self.req.Test() if hasattr(self, "req") else False: # Sanity check: actually all processes have finished assert np.all(self.all_ready == 1), ( "This should not happen! Notify the developers. (Got %r)", self.all_ready) if get_mpi_rank() == 0: self.log.info("All chains are r" + msg_ready[1:]) delattr(self, "req") self.been_waiting = 0 # Just in case, a barrier here get_mpi_comm().barrier() return True return False def check_convergence_and_learn_proposal(self): """ Checks the convergence of the sampling process (MPI only), and, if requested, learns a new covariance matrix for the proposal distribution from the covariance of the last samples. """ if get_mpi(): # Compute and gather means, covs and CL intervals of last half of chains mean = self.collection.mean(first=int(self.n() / 2)) cov = self.collection.cov(first=int(self.n() / 2)) mcsamples = self.collection._sampled_to_getdist_mcsamples( first=int(self.n() / 2)) try: bound = np.array([[ mcsamples.confidence(i, limfrac=self.Rminus1_cl_level / 2., upper=which) for i in range(self.model.prior.d()) ] for which in [False, True]]).T success_bounds = True except: bound = None success_bounds = False Ns, means, covs, bounds = map( lambda x: np.array(get_mpi_comm().gather(x)), [self.n(), mean, cov, bound]) else: # Compute and gather means, covs and CL intervals of last m-1 chain fractions m = 1 + self.Rminus1_single_split cut = int(self.collection.n() / m) if cut <= 1: self.log.error( "Not enough points in chain to check convergence. " "Increase `check_every` or reduce `Rminus1_single_split`.") raise HandledException Ns = (m - 1) * [cut] means = np.array([ self.collection.mean(first=i * cut, last=(i + 1) * cut - 1) for i in range(1, m) ]) covs = np.array([ self.collection.cov(first=i * cut, last=(i + 1) * cut - 1) for i in range(1, m) ]) # No logging of warnings temporarily, so getdist won't complain unnecessarily logging.disable(logging.WARNING) mcsampleses = [ self.collection._sampled_to_getdist_mcsamples( first=i * cut, last=(i + 1) * cut - 1) for i in range(1, m) ] logging.disable(logging.NOTSET) try: bounds = [ np.array([[ mcs.confidence(i, limfrac=self.Rminus1_cl_level / 2., upper=which) for i in range(self.model.prior.d()) ] for which in [False, True]]).T for mcs in mcsampleses ] success_bounds = True except: bounds = None success_bounds = False # Compute convergence diagnostics if not get_mpi_rank(): # "Within" or "W" term -- our "units" for assessing convergence # and our prospective new covariance matrix mean_of_covs = np.average(covs, weights=Ns, axis=0) # "Between" or "B" term # We don't weight with the number of samples in the chains here: # shorter chains will likely be outliers, and we want to notice them cov_of_means = np.atleast_2d(np.cov(means.T)) # , fweights=Ns) # For numerical stability, we turn mean_of_covs into correlation matrix: # rho = (diag(Sigma))^(-1/2) * Sigma * (diag(Sigma))^(-1/2) # and apply the same transformation to the mean of covs (same eigenvals!) diagSinvsqrt = np.diag(np.power(np.diag(cov_of_means), -0.5)) corr_of_means = diagSinvsqrt.dot(cov_of_means).dot(diagSinvsqrt) norm_mean_of_covs = diagSinvsqrt.dot(mean_of_covs).dot( diagSinvsqrt) # Cholesky of (normalized) mean of covs and eigvals of Linv*cov_of_means*L try: L = np.linalg.cholesky(norm_mean_of_covs) except np.linalg.LinAlgError: self.log.warning( "Negative covariance eigenvectors. " "This may mean that the covariance of the samples does not " "contain enough information at this point. " "Skipping this checkpoint") success = False else: Linv = np.linalg.inv(L) try: eigvals = np.linalg.eigvalsh( Linv.dot(corr_of_means).dot(Linv.T)) success = True except np.linalg.LinAlgError: self.log.warning("Could not compute eigenvalues. " "Skipping this checkpoint.") success = False if success: Rminus1 = max(np.abs(eigvals)) # For real square matrices, a possible def of the cond number is: condition_number = Rminus1 / min(np.abs(eigvals)) self.log.debug("Condition number = %g", condition_number) self.log.debug("Eigenvalues = %r", eigvals) self.log.info( "Convergence of means: R-1 = %f after %d accepted steps" % (Rminus1, (sum(Ns) if get_mpi() else self.n())) + (" = sum(%r)" % list(Ns) if get_mpi() else "")) # Have we converged in means? # (criterion must be fulfilled twice in a row) if max(Rminus1, self.Rminus1_last) < self.Rminus1_stop: # Check the convergence of the bounds of the confidence intervals # Same as R-1, but with the rms deviation from the mean bound # in units of the mean standard deviation of the chains if success_bounds: Rminus1_cl = (np.std(bounds, axis=0).T / np.sqrt(np.diag(mean_of_covs))) self.log.debug("normalized std's of bounds = %r", Rminus1_cl) self.log.info( "Convergence of bounds: R-1 = %f after %d " % (np.max(Rminus1_cl), (sum(Ns) if get_mpi() else self.n())) + "accepted steps" + (" = sum(%r)" % list(Ns) if get_mpi() else "")) if np.max(Rminus1_cl) < self.Rminus1_cl_stop: self.converged = True self.log.info("The run has converged!") self._Ns = Ns else: self.log.info( "Computation of the bounds was not possible. " "Waiting until the next checkpoint") if get_mpi(): # Broadcast and save the convergence status and the last R-1 of means success = get_mpi_comm().bcast( (success if not get_mpi_rank() else None), root=0) if success: self.Rminus1_last = get_mpi_comm().bcast( (Rminus1 if not get_mpi_rank() else None), root=0) self.converged = get_mpi_comm().bcast( (self.converged if not get_mpi_rank() else None), root=0) else: if success: self.Rminus1_last = Rminus1 # Do we want to learn a better proposal pdf? if self.learn_proposal and not self.converged and success: good_Rminus1 = (self.learn_proposal_Rminus1_max > self.Rminus1_last > self.learn_proposal_Rminus1_min) if not good_Rminus1: if not get_mpi_rank(): self.log.info("Bad convergence statistics: " "waiting until the next checkpoint.") return if get_mpi(): if get_mpi_rank(): mean_of_covs = np.empty( (self.model.prior.d(), self.model.prior.d())) get_mpi_comm().Bcast(mean_of_covs, root=0) elif not get_mpi(): mean_of_covs = covs[0] try: self.proposer.set_covariance(mean_of_covs) except: self.log.debug( "Updating covariance matrix failed unexpectedly. " "waiting until next checkpoint.") if not get_mpi_rank(): self.log.info("Updated covariance matrix of proposal pdf.") self.log.debug("%r", mean_of_covs) # Save checkpoint info self.write_checkpoint() def write_checkpoint(self): if not get_mpi_rank() and self.output: checkpoint_filename = self.checkpoint_filename() covmat_filename = self.covmat_filename() np.savetxt(covmat_filename, self.proposer.get_covariance(), header=" ".join( list(self.model.parameterization.sampled_params()))) checkpoint_info = { _sampler: { self.name: odict([ ["converged", bool(self.converged)], ["Rminus1_last", self.Rminus1_last], ["proposal_scale", self.proposer.get_scale()], ["blocks", self.blocks], ["oversampling_factors", self.oversampling_factors], ["i_last_slow_block", self.i_last_slow_block], [ "burn_in", ( self. burn_in # initial: repeat burn-in if not finished if not self.n() and self.burn_in_left else "d") ], # to avoid overweighting last point of prev. run ["mpi_size", get_mpi_size()] ]) } } yaml_dump_file(checkpoint_filename, checkpoint_info, error_if_exists=False) self.log.debug("Dumped checkpoint info and current covmat.") # Finally: returning the computed products ########################################### def products(self): """ Auxiliary function to define what should be returned in a scripted call. Returns: The sample ``Collection`` containing the accepted steps. """ return {"sample": self.collection}
class minimize(Sampler): def initialize(self): """Prepares the arguments for `scipy.minimize`.""" if not get_mpi_rank(): self.log.info("Initializing") self.logp = ((lambda x: self.model.logposterior(x, make_finite=True)[0]) if not self.ignore_prior else (lambda x: sum(self.model.loglikes(x, return_derived=True)[0]))) # Initial point: sample from reference and make sure that it has finite lik/post this_logp = -np.inf while not np.isfinite(this_logp): initial_point = self.model.prior.reference() this_logp = self.logp(initial_point) self.kwargs = { "fun": (lambda x: -self.logp(x)), "x0": initial_point, "bounds": self.model.prior.bounds(confidence_for_unbounded=0.999), "tol": self.tol, "options": { "maxiter": self.maxiter, "disp": (self.log.getEffectiveLevel() == logging.DEBUG)}} self.kwargs.update(self.override or {}) self.log.debug("Arguments for scipy.optimize.minimize:\n%r", self.kwargs) def run(self): """ Runs `scipy.minimize` """ self.log.info("Starting minimization.") self.result = scpminimize(**self.kwargs) if self.result.success: self.log.info("Finished succesfully.") else: self.log.error("Finished Unsuccesfully.") def close(self, *args): """ Determines success (or not), chooses best (if MPI) and produces output (if requested). """ # If something failed if not hasattr(self, "result"): return if get_mpi_size(): results = get_mpi_comm().gather(self.result, root=0) if not get_mpi_rank(): self.result = results[np.argmin([r.fun for r in results])] if not get_mpi_rank(): if not self.result.success: self.log.error("Maximization failed! Here is the `scipy` raw result:\n%r", self.result) raise HandledException self.log.info("log%s maximized at %g", "likelihood" if self.ignore_prior else "posterior", -self.result.fun) post = self.model.logposterior(self.result.x) recomputed_max = sum(post.loglikes) if self.ignore_prior else post.logpost if not np.allclose(-self.result.fun, recomputed_max): self.log.error("Cannot reproduce result. Something bad happened. " "Recomputed max: %g at %r", recomputed_max, self.result.x) raise HandledException self.maximum = OnePoint( self.model, self.output, name="maximum", extension=("likelihood" if self.ignore_prior else "posterior")) self.maximum.add(self.result.x, derived=post.derived, logpost=post.logpost, logpriors=post.logpriors, loglikes=post.loglikes) self.log.info("Parameter values at maximum:\n%s"%self.maximum.data.to_string()) self.maximum._out_update() def products(self): """ Auxiliary function to define what should be returned in a scripted call. Returns: The :class:`OnePoint` that maximizes the posterior or likelihood (depending on ``ignore_prior``), and the `scipy.optimize.OptimizeResult <https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.OptimizeResult.html>`_ instance. """ if not get_mpi_rank(): return {"maximum": self.maximum, "OptimizeResult": self.result}
class minimize(Sampler): def initialize(self): """Prepares the arguments for `scipy.minimize`.""" if am_single_or_primary_process(): self.log.info("Initializing") self.max_evals = read_dnumber(self.max_evals, self.model.prior.d()) # Configure target method = self.model.loglike if self.ignore_prior else self.model.logpost kwargs = {"make_finite": True} if self.ignore_prior: kwargs.update({"return_derived": False}) self.logp = lambda x: method(x, **kwargs) # Try to load info from previous samples. # If none, sample from reference (make sure that it has finite like/post) initial_point = None covmat = None if self.output: collection_in = self.output.load_collections(self.model, skip=0, thin=1, concatenate=True) if collection_in: initial_point = (collection_in.bestfit() if self.ignore_prior else collection_in.MAP()) initial_point = initial_point[list( self.model.parameterization.sampled_params())].values self.log.info("Starting from %s of previous chain:", "best fit" if self.ignore_prior else "MAP") # TODO: if ignore_prior, one should use *like* covariance (this is *post*) covmat = collection_in.cov() if initial_point is None: this_logp = -np.inf while not np.isfinite(this_logp): initial_point = self.model.prior.reference() this_logp = self.logp(initial_point) self.log.info("Starting from random initial point:") self.log.info( dict( zip(self.model.parameterization.sampled_params(), initial_point))) # Cov and affine transformation self._affine_transform_matrix = None self._inv_affine_transform_matrix = None self._affine_transform_baseline = None if covmat is None: # Use as much info as we have from ref & prior covmat = self.model.prior.reference_covmat() # Transform to space where initial point is at centre, and cov is normalised sigmas_diag, L = choleskyL(covmat, return_scale_free=True) self._affine_transform_matrix = np.linalg.inv(sigmas_diag) self._inv_affine_transform_matrix = sigmas_diag self._affine_transform_baseline = initial_point self.affine_transform = lambda x: (self._affine_transform_matrix.dot( x - self._affine_transform_baseline)) self.inv_affine_transform = lambda x: ( self._inv_affine_transform_matrix.dot( x) + self._affine_transform_baseline) bounds = self.model.prior.bounds( confidence_for_unbounded=self.confidence_for_unbounded) # Re-scale self.logp_transf = lambda x: self.logp(self.inv_affine_transform(x)) initial_point = self.affine_transform(initial_point) bounds = np.array( [self.affine_transform(bounds[:, i]) for i in range(2)]).T # Configure method if self.method.lower() == "bobyqa": self.minimizer = pybobyqa.solve self.kwargs = { "objfun": (lambda x: -self.logp_transf(x)), "x0": initial_point, "bounds": np.array(list(zip(*bounds))), "seek_global_minimum": (True if get_mpi_size() in [0, 1] else False), "maxfun": int(self.max_evals) } self.kwargs = recursive_update(deepcopy(self.kwargs), self.override_bobyqa or {}) self.log.debug( "Arguments for pybobyqa.solve:\n%r", {k: v for k, v in self.kwargs.items() if k != "objfun"}) elif self.method.lower() == "scipy": self.minimizer = scpminimize self.kwargs = { "fun": (lambda x: -self.logp_transf(x)), "x0": initial_point, "bounds": bounds, "options": { "maxiter": self.max_evals, "disp": (self.log.getEffectiveLevel() == logging.DEBUG) } } self.kwargs = recursive_update(deepcopy(self.kwargs), self.override_scipy or {}) self.log.debug( "Arguments for scipy.optimize.minimize:\n%r", {k: v for k, v in self.kwargs.items() if k != "fun"}) else: methods = ["bobyqa", "scipy"] raise LoggedError(self.log, "Method '%s' not recognized. Try one of %r.", self.method, methods) def run(self): """ Runs `scipy.minimize` """ self.log.info("Starting minimization.") try: self.result = self.minimizer(**self.kwargs) except: self.log.error("Minimizer '%s' raised an unexpected error:", self.method) raise self.success = (self.result.success if self.method.lower() == "scipy" else self.result.flag == self.result.EXIT_SUCCESS) if self.success: self.log.info("Finished successfully!") else: if self.method.lower() == "bobyqa": reason = { self.result.EXIT_MAXFUN_WARNING: "Maximum allowed objective evaluations reached. " "This is the most likely return value when using multiple restarts.", self.result.EXIT_SLOW_WARNING: "Maximum number of slow iterations reached.", self.result.EXIT_FALSE_SUCCESS_WARNING: "Py-BOBYQA reached the maximum number of restarts which decreased the" " objective, but to a worse value than was found in a previous run.", self.result.EXIT_INPUT_ERROR: "Error in the inputs.", self.result.EXIT_TR_INCREASE_ERROR: "Error occurred when solving the trust region subproblem.", self.result.EXIT_LINALG_ERROR: "Linear algebra error, e.g. the interpolation points produced a " "singular linear system." }[self.result.flag] else: reason = "" self.log.error("Finished unsuccessfully." + (" Reason: " + reason if reason else "")) def close(self, *args): """ Determines success (or not), chooses best (if MPI) and produces output (if requested). """ evals_attr_ = evals_attr[self.method.lower()] # If something failed if not hasattr(self, "result"): return if get_mpi_size(): results = get_mpi_comm().gather(self.result, root=0) _inv_affine_transform_matrices = get_mpi_comm().gather( self._inv_affine_transform_matrix, root=0) _affine_transform_baselines = get_mpi_comm().gather( self._affine_transform_baseline, root=0) if am_single_or_primary_process(): i_min = np.argmin([getattr(r, evals_attr_) for r in results]) self.result = results[i_min] self._inv_affine_transform_matrix = _inv_affine_transform_matrices[ i_min] self._affine_transform_baseline = _affine_transform_baselines[ i_min] if am_single_or_primary_process(): if not self.success: raise LoggedError( self.log, "Minimization failed! Here is the raw result object:\n%s", str(self.result)) logp_min = -np.array(getattr(self.result, evals_attr_)) x_min = self.inv_affine_transform(self.result.x) self.log.info("-log(%s) minimized to %g", "likelihood" if self.ignore_prior else "posterior", -logp_min) recomputed_post_min = self.model.logposterior(x_min, cached=False) recomputed_logp_min = (sum(recomputed_post_min.loglikes) if self.ignore_prior else recomputed_post_min.logpost) if not np.allclose(logp_min, recomputed_logp_min): raise LoggedError( self.log, "Cannot reproduce result. Maybe yout likelihood is stochastic? " "Recomputed min: %g (was %g) at %r", recomputed_logp_min, logp_min, x_min) self.minimum = OnePoint( self.model, self.output, name="", extension=("bestfit.txt" if self.ignore_prior else "minimum.txt")) self.minimum.add(x_min, derived=recomputed_post_min.derived, logpost=recomputed_post_min.logpost, logpriors=recomputed_post_min.logpriors, loglikes=recomputed_post_min.loglikes) self.log.info("Parameter values at minimum:\n%s", self.minimum.data.to_string()) self.minimum._out_update() self.dump_getdist() def products(self): r""" Returns a dictionary containing: - ``minimum``: :class:`OnePoint` that maximizes the posterior or likelihood (depending on ``ignore_prior``). - ``result_object``: instance of results class of `scipy <https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.OptimizeResult.html>`_ or `pyBOBYQA <https://numericalalgorithmsgroup.github.io/pybobyqa/build/html/userguide.html>`_. - ``M``: inverse of the affine transform matrix (see below). ``None`` if no transformation applied. - ``X0``: offset of the affine transform matrix (see below) ``None`` if no transformation applied. If non-trivial ``M`` and ``X0`` are returned, this means that the minimizer has been working on an affine-transformed parameter space :math:`x^\prime`, from which the real space points can be obtained as :math:`x = M x^\prime + X_0`. This inverse transformation needs to be applied to the coordinates appearing inside the ``result_object``. """ if am_single_or_primary_process(): return { "minimum": self.minimum, "result_object": self.result, "M": self._inv_affine_transform_matrix, "X0": self._affine_transform_baseline } def getdist_point_text(self, params, weight=None, minuslogpost=None): lines = [] if weight is not None: lines.append(' weight = %s' % weight) if minuslogpost is not None: lines.append(' -log(Like) = %s' % minuslogpost) lines.append(' chi-sq = %s' % (2 * minuslogpost)) lines.append('') labels = self.model.parameterization.labels() label_list = list(labels.keys()) if hasattr(params, 'chi2_names'): label_list += params.chi2_names width = max([len(lab) for lab in label_list]) + 2 def add_section(pars): for p, val in pars: lab = labels.get(p, p) num = label_list.index(p) + 1 if isinstance(val, (float, np.floating)) and len(str(val)) > 10: lines.append("%5d %-17.9e %-*s %s" % (num, val, width, p, lab)) else: lines.append("%5d %-17s %-*s %s" % (num, val, width, p, lab)) num_sampled = len(self.model.parameterization.sampled_params()) num_derived = len(self.model.parameterization.derived_params()) add_section([[p, params[p]] for p in self.model.parameterization.sampled_params()]) lines.append('') add_section([[p, value] for p, value in self.model.parameterization.constant_params().items()]) lines.append('') add_section([[p, params[p]] for p in self.model.parameterization.derived_params()]) if hasattr(params, 'chi2_names'): from cobaya.conventions import _chi2, _separator labels.update( odict([[ p, r'\chi^2_{\rm %s}' % (p.replace(_chi2 + _separator, '').replace("_", r"\ ")) ] for p in params.chi2_names])) add_section([[chi2, params[chi2]] for chi2 in params.chi2_names]) return "\n".join(lines) def dump_getdist(self): if not self.output: return getdist_bf = self.getdist_point_text( self.minimum, minuslogpost=self.minimum['minuslogpost']) out_filename = os.path.join( self.output.folder, self.output.prefix + getdist_ext_ignore_prior[self.ignore_prior]) with open(out_filename, 'w') as f: f.write(getdist_bf)
class Minimize(Minimizer, CovmatSampler): file_base_name = 'minimize' ignore_prior: bool confidence_for_unbounded: float method: str best_of: int override_bobyqa: Optional[dict] override_scipy: Optional[dict] max_evals: Union[str, int] def initialize(self): if self.method not in evals_attr: raise LoggedError(self.log, "Method '%s' not recognized. Try one of %r.", self.method, list(evals_attr)) self.mpi_info("Initializing") self.max_iter = int(read_dnumber(self.max_evals, self.model.prior.d())) # Configure target method = self.model.loglike if self.ignore_prior else self.model.logpost kwargs = {"make_finite": True} if self.ignore_prior: kwargs["return_derived"] = False self.logp = lambda x: method(x, **kwargs) # Try to load info from previous samples. # If none, sample from reference (make sure that it has finite like/post) self.initial_points = [] assert self.best_of > 0 num_starts = int(np.ceil(self.best_of / mpi.size())) if self.output: files = self.output.find_collections() else: files = None for start in range(num_starts): initial_point = None if files: collection_in: Optional[SampleCollection] if mpi.more_than_one_process() or num_starts > 1: index = 1 + mpi.rank() * num_starts + start if index <= len(files): collection_in = SampleCollection( self.model, self.output, name=str(index), resuming=True) else: collection_in = None else: collection_in = self.output.load_collections(self.model, concatenate=True) if collection_in: initial_point = (collection_in.bestfit() if self.ignore_prior else collection_in.MAP()) initial_point = initial_point[ list(self.model.parameterization.sampled_params())].values self.log.info("Starting %s/%s from %s of previous chain:", start + 1, num_starts, "best fit" if self.ignore_prior else "MAP") # Compute covmat if input but no .covmat file (e.g. with PolyChord) # Prefer old over `covmat` definition in yaml (same as MCMC) self.covmat = collection_in.cov(derived=False) self.covmat_params = list( self.model.parameterization.sampled_params()) if initial_point is None: for _ in range(self.max_iter // 10 + 5): initial_point = self.model.prior.reference(random_state=self._rng) if np.isfinite(self.logp(initial_point)): break else: raise LoggedError(self.log, "Could not find random starting point " "giving finite posterior") self.log.info("Starting %s/%s random initial point:", start + 1, num_starts) self.log.info( dict(zip(self.model.parameterization.sampled_params(), initial_point))) self.initial_points.append(initial_point) self._bounds = self.model.prior.bounds( confidence_for_unbounded=self.confidence_for_unbounded) # TODO: if ignore_prior, one should use *like* covariance (this is *post*) covmat = self._load_covmat(prefer_load_old=self.output)[0] # scale by conditional parameter widths (since not using correlation structure) scales = np.minimum(1 / np.sqrt(np.diag(np.linalg.inv(covmat))), (self._bounds[:, 1] - self._bounds[:, 0]) / 3) # Cov and affine transformation # Transform to space where initial point is at centre, and cov is normalised # Cannot do rotation, as supported minimization routines assume bounds aligned # with the parameter axes. self._affine_transform_matrix = np.diag(1 / scales) self._inv_affine_transform_matrix = np.diag(scales) self._scales = scales self.result = None def affine_transform(self, x): return (x - self._affine_transform_baseline) / self._scales def inv_affine_transform(self, x): # fix up rounding errors on bounds to avoid -np.inf likelihoods return np.clip(x * self._scales + self._affine_transform_baseline, self._bounds[:, 0], self._bounds[:, 1]) def run(self): """ Runs `scipy.Minimize` """ results = [] successes = [] def minuslogp_transf(x): return -self.logp(self.inv_affine_transform(x)) for i, initial_point in enumerate(self.initial_points): self.log.debug("Starting minimization for starting point %s.", i) self._affine_transform_baseline = initial_point initial_point = self.affine_transform(initial_point) np.testing.assert_allclose(initial_point, np.zeros(initial_point.shape)) bounds = np.array( [self.affine_transform(self._bounds[:, i]) for i in range(2)]).T try: # Configure method if self.method.lower() == "bobyqa": self.kwargs = { "objfun": minuslogp_transf, "x0": initial_point, "bounds": np.array(list(zip(*bounds))), "maxfun": self.max_iter, "rhobeg": 1., "do_logging": (self.log.getEffectiveLevel() == logging.DEBUG)} self.kwargs = recursive_update(self.kwargs, self.override_bobyqa or {}) self.log.debug("Arguments for pybobyqa.solve:\n%r", {k: v for k, v in self.kwargs.items() if k != "objfun"}) result = pybobyqa.solve(**self.kwargs) success = result.flag == result.EXIT_SUCCESS if not success: self.log.error("Finished unsuccessfully. Reason: " + _bobyqa_errors[result.flag]) else: self.kwargs = { "fun": minuslogp_transf, "x0": initial_point, "bounds": bounds, "options": { "maxiter": self.max_iter, "disp": (self.log.getEffectiveLevel() == logging.DEBUG)}} self.kwargs = recursive_update(self.kwargs, self.override_scipy or {}) self.log.debug("Arguments for scipy.optimize.Minimize:\n%r", {k: v for k, v in self.kwargs.items() if k != "fun"}) result = optimize.minimize(**self.kwargs) success = result.success if not success: self.log.error("Finished unsuccessfully.") except: self.log.error("Minimizer '%s' raised an unexpected error:", self.method) raise results += [result] successes += [success] self.process_results(*mpi.zip_gather( [results, successes, self.initial_points, [self._inv_affine_transform_matrix] * len(self.initial_points)])) @mpi.set_from_root(("_inv_affine_transform_matrix", "_affine_transform_baseline", "result", "minimum")) def process_results(self, results, successes, affine_transform_baselines, transform_matrices): """ Determines success (or not), chooses best (if MPI or multiple starts) and produces output (if requested). """ evals_attr_ = evals_attr[self.method.lower()] results = list(chain(*results)) successes = list(chain(*successes)) affine_transform_baselines = list(chain(*affine_transform_baselines)) transform_matrices = list(chain(*transform_matrices)) if len(results) > 1: mins = [(getattr(r, evals_attr_) if s else np.inf) for r, s in zip(results, successes)] i_min: int = np.argmin(mins) # type: ignore else: i_min = 0 self.result = results[i_min] self._affine_transform_baseline = affine_transform_baselines[i_min] self._inv_affine_transform_matrix = transform_matrices[i_min] if not any(successes): raise LoggedError( self.log, "Minimization failed! Here is the raw result object:\n%s", str(self.result)) elif not all(successes): self.log.warning('Some minimizations failed!') elif len(results) > 1: self.log.info('Finished successfully!') # noinspection PyUnboundLocalVariable if max(mins) - min(mins) > 1: self.log.warning('Big spread in minima: %r', mins) elif max(mins) - min(mins) > 0.2: self.log.warning('Modest spread in minima: %r', mins) logp_min = -np.array(getattr(self.result, evals_attr_)) x_min = self.inv_affine_transform(self.result.x) self.log.info("-log(%s) minimized to %g", "likelihood" if self.ignore_prior else "posterior", -logp_min) recomputed_post_min = self.model.logposterior(x_min, cached=False) recomputed_logp_min = (sum(recomputed_post_min.loglikes) if self.ignore_prior else recomputed_post_min.logpost) if not np.allclose(logp_min, recomputed_logp_min, atol=1e-2): raise LoggedError( self.log, "Cannot reproduce log minimum to within 0.01. Maybe your " "likelihood is stochastic or large numerical error? " "Recomputed min: %g (was %g) at %r", recomputed_logp_min, logp_min, x_min) self.minimum = OnePoint(self.model, self.output, name="", extension=get_collection_extension(self.ignore_prior)) self.minimum.add(x_min, derived=recomputed_post_min.derived, logpost=recomputed_post_min.logpost, logpriors=recomputed_post_min.logpriors, loglikes=recomputed_post_min.loglikes) self.log.info( "Parameter values at minimum:\n%s", self.minimum.data.to_string()) self.minimum.out_update() self.dump_getdist() def products(self): r""" Returns a dictionary containing: - ``minimum``: :class:`OnePoint` that maximizes the posterior or likelihood (depending on ``ignore_prior``). - ``result_object``: instance of results class of `scipy <https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.OptimizeResult.html>`_ or `pyBOBYQA <https://numericalalgorithmsgroup.github.io/pybobyqa/build/html/userguide.html>`_. - ``M``: inverse of the affine transform matrix (see below). ``None`` if no transformation applied. - ``X0``: offset of the affine transform matrix (see below) ``None`` if no transformation applied. If non-trivial ``M`` and ``X0`` are returned, this means that the minimizer has been working on an affine-transformed parameter space :math:`x^\prime`, from which the real space points can be obtained as :math:`x = M x^\prime + X_0`. This inverse transformation needs to be applied to the coordinates appearing inside the ``result_object``. """ return {"minimum": self.minimum, "result_object": self.result, "M": self._inv_affine_transform_matrix, "X0": self._affine_transform_baseline} def getdist_point_text(self, params, weight=None, minuslogpost=None): lines = [] if weight is not None: lines.append(' weight = %s' % weight) if minuslogpost is not None: lines.append(' -log(Like) = %s' % minuslogpost) lines.append(' chi-sq = %s' % (2 * minuslogpost)) lines.append('') labels = self.model.parameterization.labels() label_list = list(labels) if hasattr(params, 'chi2_names'): label_list += params.chi2_names width = max(len(lab) for lab in label_list) + 2 def add_section(pars): for p, val in pars: lab = labels.get(p, p) num = label_list.index(p) + 1 if isinstance(val, (float, np.floating)) and len(str(val)) > 10: lines.append("%5d %-17.9e %-*s %s" % (num, val, width, p, lab)) else: lines.append("%5d %-17s %-*s %s" % (num, val, width, p, lab)) # num_sampled = len(self.model.parameterization.sampled_params()) # num_derived = len(self.model.parameterization.derived_params()) add_section( [(p, params[p]) for p in self.model.parameterization.sampled_params()]) lines.append('') add_section([[p, value] for p, value in self.model.parameterization.constant_params().items()]) lines.append('') add_section( [[p, params[p]] for p in self.model.parameterization.derived_params()]) if hasattr(params, 'chi2_names'): labels.update({p: r'\chi^2_{\rm %s}' % ( undo_chi2_name(p).replace("_", r"\ ")) for p in params.chi2_names}) add_section([[chi2, params[chi2]] for chi2 in params.chi2_names]) return "\n".join(lines) def dump_getdist(self): if not self.output: return getdist_bf = self.getdist_point_text(self.minimum, minuslogpost=self.minimum['minuslogpost']) out_filename = os.path.join( self.output.folder, self.output.prefix + getdist_ext_ignore_prior[self.ignore_prior]) with open(out_filename, 'w', encoding="utf-8") as f: f.write(getdist_bf) @classmethod def output_files_regexps(cls, output, info=None, minimal=False): ignore_prior = bool(info.get("ignore_prior", False)) ext_collection = get_collection_extension(ignore_prior) ext_getdist = getdist_ext_ignore_prior[ignore_prior] regexps = [ re.compile(output.prefix_regexp_str + re.escape(ext.lstrip(".")) + "$") for ext in [ext_collection, ext_getdist]] return [(r, None) for r in regexps] @classmethod def check_force_resume(cls, output, info=None): """ Performs the necessary checks on existing files if resuming or forcing (including deleting some output files when forcing). """ if output.is_resuming(): if mpi.is_main_process(): raise LoggedError( output.log, "Minimizer does not support resuming. " "If you want to start over, force " "('-f', '--force', 'force: True')") super().check_force_resume(output, info=info) @classmethod def _get_desc(cls, info=None): if info is None: method = None else: method = info.get("method", cls.get_defaults()["method"]) desc_bobyqa = (r"Py-BOBYQA implementation " r"\cite{2018arXiv180400154C,2018arXiv181211343C} of the BOBYQA " r"minimization algorithm \cite{BOBYQA}") desc_scipy = (r"Scipy minimizer \cite{2020SciPy-NMeth} (check citation for the " r"actual algorithm used at \url{https://docs.scipy.org/doc/scipy/re" r"ference/generated/scipy.optimize.Minimize.html}") if method and method.lower() == "bobyqa": return desc_bobyqa elif method and method.lower() == "scipy": return desc_scipy else: # unknown method or no info passed (None) return ("Minimizer -- method unknown, possibly one of:" "\na) " + desc_bobyqa + "\nb) " + desc_scipy)
class mcmc(Sampler): def initialise(self): """Initialises the sampler: creates the proposal distribution and draws the initial sample.""" self.log.info("Initializing") # Burning-in countdown -- the +1 accounts for the initial point (always accepted) self.burn_in_left = self.burn_in + 1 # One collection per MPI process: `name` is the MPI rank + 1 name = str(1 + (lambda r: r if r is not None else 0)(get_mpi_rank())) self.collection = Collection(self.parametrization, self.likelihood, self.output, name=name) self.current_point = OnePoint(self.parametrization, self.likelihood, self.output, name=name) # Use the standard steps by default self.get_new_sample = self.get_new_sample_metropolis # Prepare oversampling / fast-dragging if applicable self.effective_max_samples = self.max_samples if self.oversample and self.drag: self.log.error( "Choose either oversampling or fast-dragging, not both.") raise HandledException # if (self.oversample or self.drag) and len(set(factors)) == 1: # self.log.error("All block speeds are similar: " # "no dragging or oversampling possible.") # raise HandledException if self.oversample: factors, blocks = self.likelihood.speeds_of_params( oversampling_factors=True) self.oversampling_factors = factors # WIP: actually, we would have to re-normalise to the dimension of the blocks. self.log.info("Oversampling with factors:\n" + "\n".join([ " %d : %r" % (f, b) for f, b in zip(self.oversampling_factors, blocks) ])) # WIP: useless until likelihoods have STATES! self.log.error("Sorry, oversampling is WIP") raise HandledException elif self.drag: # WIP: for now, can only separate between theory and likelihoods # until likelihoods have states if not self.likelihood.theory: self.log.error( "WIP: dragging disabled for now when no theory code present." ) raise HandledException # if self.max_speed_slow < min(speeds) or self.max_speed_slow >= max(speeds): # self.log.error("The maximum speed considered slow, `max_speed_slow`, must be " # "%g <= `max_speed_slow < %g, and is %g", # min(speeds), max(speeds), self.max_speed_slow) # raise HandledException speeds, blocks = self.likelihood.speeds_of_params(int_speeds=True, fast_slow=True) if np.all(speeds == speeds[0]): self.log.error( "All speeds are equal: cannot drag! Make sure to define, " "especially, the speed of the fastest likelihoods.") self.i_last_slow_block = 0 # just theory can be slow for now fast_params = list(chain(*blocks[1 + self.i_last_slow_block:])) self.n_slow = sum( len(blocks[i]) for i in range(1 + self.i_last_slow_block)) self.drag_interp_steps = int(self.drag * np.round(min(speeds[1:]) / speeds[0])) self.log.info("Dragging with oversampling per step:\n" + "\n".join([ " %d : %r" % (f, b) for f, b in zip([1, self.drag_interp_steps], [blocks[0], fast_params]) ])) self.get_new_sample = self.get_new_sample_dragging else: _, blocks = self.likelihood.speeds_of_params() self.oversampling_factors = [1 for b in blocks] self.n_slow = len(self.parametrization.sampled_params()) # Turn parameter names into indices blocks = [[ list(self.parametrization.sampled_params().keys()).index(p) for p in b ] for b in blocks] self.proposer = BlockedProposer( blocks, oversampling_factors=getattr(self, "oversampling_factors", None), i_last_slow_block=getattr(self, "i_last_slow_block", None), propose_scale=self.propose_scale) # Build the initial covariance matrix of the proposal covmat = self.initial_proposal_covmat() self.log.info("Sampling with covariance matrix:") self.log.info("%r", covmat) self.proposer.set_covariance(covmat) # Prepare callback function if self.callback_function is not None: self.callback_function_callable = (get_external_function( self.callback_function)) def initial_proposal_covmat(self): """ Build the initial covariance matrix, using the data provided, in descending order of priority: 1. "covmat" field in the "mcmc" sampler block. 2. "proposal" field for each parameter. 3. variance of the reference pdf. 4. variance of the prior pdf. The covariances between parameters when both are present in a covariance matrix provided through option 1 are preserved. All other covariances are assumed 0. """ params, params_infos = zip( *self.parametrization.sampled_params().items()) covmat = np.diag([np.nan] * len(params)) # If given, load and test the covariance matrix if isinstance(self.covmat, six.string_types): covmat_pre = "MODULES:" if self.covmat.startswith(covmat_pre): self.covmat = os.path.join(get_path_to_installation(), self.covmat[len(covmat_pre):]) try: with open(self.covmat, "r") as file_covmat: header = file_covmat.readline() loaded_covmat = np.loadtxt(self.covmat) except TypeError: self.log.error( "The property 'covmat' must be a file name," "but it's '%s'.", str(self.covmat)) raise HandledException except IOError: self.log.error("Can't open covmat file '%s'.", self.covmat) raise HandledException if header[0] != "#": self.log.error( "The first line of the covmat file '%s' " "must be one list of parameter names separated by spaces " "and staring with '#', and the rest must be a square matrix, " "with one row per line.", self.covmat) raise HandledException loaded_params = header.strip("#").strip().split() elif hasattr(self.covmat, "__getitem__"): if not self.covmat_params: self.log.error( "If a covariance matrix is passed as a numpy array, " "you also need to pass the parameters it corresponds to " "via 'covmat_params: [name1, name2, ...]'.") raise HandledException loaded_params = self.covmat_params loaded_covmat = self.covmat if self.covmat is not None: if len(loaded_params) != len(set(loaded_params)): self.log.error( "There are duplicated parameters in the header of the " "covmat file '%s' ", self.covmat) raise HandledException if len(loaded_params) != loaded_covmat.shape[0]: self.log.error( "The number of parameters in the header of '%s' and the " "dimensions of the matrix do not coincide.", self.covmat) raise HandledException if not (np.allclose(loaded_covmat.T, loaded_covmat) and np.all(np.linalg.eigvals(loaded_covmat) > 0)): self.log.error( "The covmat loaded from '%s' is not a positive-definite, " "symmetric square matrix.", self.covmat) raise HandledException # Fill with parameters in the loaded covmat aliases = [[p] + np.atleast_1d(v.get(_p_alias, [])).tolist() for p, v in zip(params, params_infos)] aliases = odict([[a[0], a] for a in aliases]) indices_used, indices_sampler = zip(*[[ loaded_params.index(p), [params.index(q) for q, a in aliases.items() if p in a] ] for p in loaded_params]) indices_used, indices_sampler = zip( *[[i, j] for i, j in zip(indices_used, indices_sampler) if j]) if any(len(j) - 1 for j in indices_sampler): first = next(j for j in indices_sampler if len(j) > 1) self.log.error( "The parameters %s have duplicated aliases. Can't assign them an " "element of the covariance matrix unambiguously.", ", ".join([params[i] for i in first])) raise HandledException indices_sampler = list(chain(*indices_sampler)) if not indices_used: self.log.error( "A proposal covariance matrix has been loaded, but none of its " "parameters are actually sampled here. Maybe a mismatch between" " parameter names in the covariance matrix and the input file?" ) raise HandledException covmat[np.ix_(indices_sampler, indices_sampler)] = (loaded_covmat[np.ix_( indices_used, indices_used)]) self.log.info("Covariance matrix loaded for params %r", [params[i] for i in indices_sampler]) missing_params = set(params).difference( set([params[i] for i in indices_sampler])) if missing_params: self.log.info("Missing proposal covarince for params %r", [ p for p in self.parametrization.sampled_params() if p in missing_params ]) else: self.log.info( "All parameters' covariance loaded from given covmat.") # Fill gaps with "proposal" property, if present, otherwise ref (or prior) where_nan = np.isnan(covmat.diagonal()) if np.any(where_nan): covmat[where_nan, where_nan] = np.array([ info.get(_p_proposal, np.nan)**2 for info in params_infos ])[where_nan] # we want to start learning the covmat earlier self.log.info( "Covariance matrix " + ("not present" if np.all(where_nan) else "not complete") + ". " "We will start learning the covariance of the proposal earlier: " "R-1 = %g (was %g).", self.learn_proposal_Rminus1_max_early, self.learn_proposal_Rminus1_max) self.learn_proposal_Rminus1_max = self.learn_proposal_Rminus1_max_early where_nan = np.isnan(covmat.diagonal()) if np.any(where_nan): covmat[where_nan, where_nan] = ( self.prior.reference_covmat().diagonal()[where_nan]) assert not np.any(np.isnan(covmat)) return covmat def run(self): """ Runs the sampler. """ # Get first point, to be discarded -- not possible to determine its weight # Still, we need to compute derived parameters, since, as the proposal "blocked", # we may be saving the initial state of some block. initial_point = self.prior.reference(max_tries=self.max_tries) logpost, _, _, derived = self.logposterior(initial_point) self.current_point.add(initial_point, derived=derived, logpost=logpost) self.log.info("Initial point:\n %r ", self.current_point) # Main loop! self.converged = False self.log.info("Sampling!" + ( "(NB: nothing will be printed until %d burn-in samples " % self.burn_in + "have been obtained)" if self.burn_in else "")) while self.n() < self.effective_max_samples and not self.converged: self.get_new_sample() # Callback function if (hasattr(self, "callback_function_callable") and not (max(self.n(), 1) % self.callback_every) and self.current_point[_weight] == 1): self.callback_function_callable(self) # Checking convergence and (optionally) learning the covmat of the proposal if self.check_all_ready(): self.check_convergence_and_learn_proposal() # Make sure the last batch of samples ( < output_every ) are written self.collection.out_update() if not get_mpi_rank(): self.log.info("Sampling complete after %d accepted steps.", self.n()) def n(self, burn_in=False): """ Returns the total number of steps taken, including or not burn-in steps depending on the value of the `burn_in` keyword. """ return self.collection.n() + (0 if not burn_in else self.burn_in - self.burn_in_left + 1) def get_new_sample_metropolis(self): """ Draws a new trial point from the proposal pdf and checks whether it is accepted: if it is accepted, it saves the old one into the collection and sets the new one as the current state; if it is rejected increases the weight of the current state by 1. Returns: ``True`` for an accepted step, ``False`` for a rejected one. """ trial = deepcopy( self.current_point[self.parametrization.sampled_params()]) self.proposer.get_proposal(trial) logpost_trial, logprior_trial, logliks_trial, derived = self.logposterior( trial) accept = self.metropolis_accept(logpost_trial, -self.current_point["minuslogpost"]) self.process_accept_or_reject(accept, trial, derived, logpost_trial, logprior_trial, logliks_trial) return accept def get_new_sample_dragging(self): """ Draws a new trial point in the slow subspace, and gets the corresponding trial in the fast subspace by "dragging" the fast parameters. Finally, checks the acceptance of the total step using the "dragging" pdf: if it is accepted, it saves the old one into the collection and sets the new one as the current state; if it is rejected increases the weight of the current state by 1. Returns: ``True`` for an accepted step, ``False`` for a rejected one. """ # Prepare starting and ending points *in the SLOW subspace* # "start_" and "end_" mean here the extremes in the SLOW subspace start_slow_point = self.current_point[ self.parametrization.sampled_params()] start_slow_logpost = -self.current_point["minuslogpost"] end_slow_point = deepcopy(start_slow_point) self.proposer.get_proposal_slow(end_slow_point) self.log.debug("Proposed slow end-point: %r", end_slow_point) # Save derived paramters of delta_slow jump, in case I reject all the dragging # steps but accept the move in the slow direction only end_slow_logpost, end_slow_logprior, end_slow_logliks, derived = ( self.logposterior(end_slow_point)) if end_slow_logpost == -np.inf: self.current_point.increase_weight(1) return False # trackers of the dragging current_start_point = start_slow_point current_end_point = end_slow_point current_start_logpost = start_slow_logpost current_end_logpost = end_slow_logpost current_end_logprior = end_slow_logprior current_end_logliks = end_slow_logliks # accumulators for the "dragging" probabilities to be metropolist-tested # at the end of the interpolation start_drag_logpost_acc = start_slow_logpost end_drag_logpost_acc = end_slow_logpost # start dragging for i_step in range(1, 1 + self.drag_interp_steps): self.log.debug("Dragging step: %d", i_step) # take a step in the fast direction in both slow extremes delta_fast = np.zeros(len(current_start_point)) self.proposer.get_proposal_fast(delta_fast) self.log.debug("Proposed fast step delta: %r", delta_fast) proposal_start_point = deepcopy(current_start_point) proposal_start_point += delta_fast proposal_end_point = deepcopy(current_end_point) proposal_end_point += delta_fast # get the new extremes for the interpolated probability # (reject if any of them = -inf; avoid evaluating both if just one fails) # Force the computation of the (slow blocks) derived params at the starting # point, but discard them, since they contain the starting point's fast ones, # not used later -- save the end point's ones. proposal_start_logpost = self.logposterior(proposal_start_point)[0] proposal_end_logpost, proposal_end_logprior, \ proposal_end_logliks, derived_proposal_end = ( self.logposterior(proposal_end_point) if proposal_start_logpost > -np.inf else (-np.inf, None, [], [])) if proposal_start_logpost > -np.inf and proposal_end_logpost > -np.inf: # create the interpolated probability and do a Metropolis test frac = i_step / (1 + self.drag_interp_steps) proposal_interp_logpost = ( (1 - frac) * proposal_start_logpost + frac * proposal_end_logpost) current_interp_logpost = ((1 - frac) * current_start_logpost + frac * current_end_logpost) accept_drag = self.metropolis_accept(proposal_interp_logpost, current_interp_logpost) else: accept_drag = False self.log.debug("Dragging step: %s", ("accepted" if accept_drag else "rejected")) # If the dragging step was accepted, do the drag if accept_drag: current_start_point = proposal_start_point current_start_logpost = proposal_start_logpost current_end_point = proposal_end_point current_end_logpost = proposal_end_logpost current_end_logprior = proposal_end_logprior current_end_logliks = proposal_end_logliks derived = derived_proposal_end # In any case, update the dragging probability for the final metropolis test start_drag_logpost_acc += current_start_logpost end_drag_logpost_acc += current_end_logpost # Test for the TOTAL step accept = self.metropolis_accept( end_drag_logpost_acc / self.drag_interp_steps, start_drag_logpost_acc / self.drag_interp_steps) self.process_accept_or_reject(accept, current_end_point, derived, current_end_logpost, current_end_logprior, current_end_logliks) self.log.debug("TOTAL step: %s", ("accepted" if accept else "rejected")) return accept def metropolis_accept(self, logp_trial, logp_current): """ Symmetric-proposal Metropolis-Hastings test. Returns: ``True`` or ``False``. """ if logp_trial == -np.inf: return False elif logp_trial > logp_current: return True else: return np.random.exponential() > (logp_current - logp_trial) def process_accept_or_reject(self, accept_state, trial=None, derived=None, logpost_trial=None, logprior_trial=None, logliks_trial=None): """Processes the acceptance/rejection of the new point.""" if accept_state: # add the old point to the collection (if not burning or initial point) if self.burn_in_left <= 0: self.current_point.add_to_collection(self.collection) self.log.debug("New sample, #%d: \n %r", self.n(), self.current_point) if self.n() % self.output_every == 0: self.collection.out_update() else: self.burn_in_left -= 1 self.log.debug("Burn-in sample:\n %r", self.current_point) if self.burn_in_left == 0: self.log.info( "Finished burn-in phase: discarded %d accepted steps.", self.burn_in) # set the new point as the current one, with weight one self.current_point.add(trial, derived=derived, weight=1, logpost=logpost_trial, logprior=logprior_trial, logliks=logliks_trial) else: # not accepted self.current_point.increase_weight(1) # Failure criterion: chain stuck! if self.current_point[_weight] > self.max_tries: self.collection.out_update() self.log.error( "The chain has been stuck for %d attempts. " "Stopping sampling. If this has happened often, try improving your" " reference point/distribution.", self.max_tries) raise HandledException # Functions to check convergence and learn the covariance of the proposal distribution def check_all_ready(self): """ Checks if the chain(s) is(/are) ready to check convergence and, if requested, learn a new covariance matrix for the proposal distribution. """ msg_ready = ( ("Ready to" if get_mpi() or self.learn_proposal else "") + (" check convergence" if get_mpi() else "") + (" and" if get_mpi() and self.learn_proposal else "") + (" learn a new proposal covmat" if self.learn_proposal else "")) # If *just* (weight==1) got ready to check+learn if (self.n() > 0 and self.current_point[_weight] == 1 and not (self.n() % (self.check_every_dimension_times * self.n_slow))): self.log.info("Checkpoint: %d samples accepted.", self.n()) # If not MPI, we are ready if not get_mpi(): if msg_ready: self.log.info(msg_ready) return True # If MPI, tell the rest that we are ready -- we use a "gather" # ("reduce" was problematic), but we are in practice just pinging if not hasattr(self, "req"): # just once! self.all_ready = np.empty(get_mpi_size()) self.req = get_mpi_comm().Iallgather(np.array([1.]), self.all_ready) self.log.info(msg_ready + " (waiting for the rest...)") # If all processes are ready to learn (= communication finished) if self.req.Test() if hasattr(self, "req") else False: # Sanity check: actually all processes have finished assert np.all(self.all_ready == 1), ( "This should not happen! Notify the developers. (Got %r)", self.all_ready) if get_mpi_rank() == 0: self.log.info("All chains are r" + msg_ready[1:]) delattr(self, "req") # Just in case, a barrier here get_mpi_comm().barrier() return True return False def check_convergence_and_learn_proposal(self): """ Checks the convergence of the sampling process (MPI only), and, if requested, learns a new covariance matrix for the proposal distribution from the covariance of the last samples. """ # Compute and gather means, covs and CL intervals of last half of chains mean = self.collection.mean(first=int(self.n() / 2)) cov = self.collection.cov(first=int(self.n() / 2)) # No logging of warnings temporarily, so getdist won't complain innecessarily logging.disable(logging.WARNING) mcsamples = self.collection.sampled_to_getdist_mcsamples( first=int(self.n() / 2)) logging.disable(logging.NOTSET) bound = np.array([[ mcsamples.confidence(i, limfrac=self.Rminus1_cl_level / 2., upper=which) for i in range(self.prior.d()) ] for which in [False, True]]).T Ns, means, covs, bounds = map( lambda x: np.array((get_mpi_comm().gather(x) if get_mpi() else [x])), [self.n(), mean, cov, bound]) # Compute convergence diagnostics if get_mpi(): if get_mpi_rank() == 0: # "Within" or "W" term -- our "units" for assessing convergence # and our prospective new covariance matrix mean_of_covs = np.average(covs, weights=Ns, axis=0) # "Between" or "B" term # We don't weight with the number of samples in the chains here: # shorter chains will likely be outliers, and we want to notice them cov_of_means = np.cov(means.T) # , fweights=Ns) # For numerical stability, we turn mean_of_covs into correlation matrix: # rho = (diag(Sigma))^(-1/2) * Sigma * (diag(Sigma))^(-1/2) # and apply the same transformation to the mean of covs (same eigenvals!) diagSinvsqrt = np.diag(np.power(np.diag(cov_of_means), -0.5)) corr_of_means = diagSinvsqrt.dot(cov_of_means).dot( diagSinvsqrt) norm_mean_of_covs = diagSinvsqrt.dot(mean_of_covs).dot( diagSinvsqrt) # Cholesky of (normalized) mean of covs and eigvals of Linv*cov_of_means*L try: L = np.linalg.cholesky(norm_mean_of_covs) except np.linalg.LinAlgError: self.log.warning( "Negative covariance eigenvectors. " "This may mean that the covariance of the samples does not " "contain enough information at this point. " "Skipping this checkpoint") success = False else: Linv = np.linalg.inv(L) eigvals = np.linalg.eigvalsh( Linv.dot(corr_of_means).dot(Linv.T)) Rminus1 = max(np.abs(eigvals)) # For real square matrices, a possible def of the cond number is: condition_number = Rminus1 / min(np.abs(eigvals)) self.log.debug("Condition number = %g", condition_number) self.log.debug("Eigenvalues = %r", eigvals) self.log.info( "Convergence of means: R-1 = %f after %d samples", Rminus1, self.n()) success = True # Have we converged in means? # (criterion must be fulfilled twice in a row) if (max(Rminus1, getattr(self, "Rminus1_last", np.inf)) < self.Rminus1_stop): # Check the convergence of the bounds of the confidence intervals # Same as R-1, but with the rms deviation from the mean bound # in units of the mean standard deviation of the chains Rminus1_cl = (np.std(bounds, axis=0).T / np.sqrt(np.diag(mean_of_covs))) self.log.debug("normalized std's of bounds = %r", Rminus1_cl) self.log.info( "Convergence of bounds: R-1 = %f after %d samples", np.max(Rminus1_cl), self.n()) if np.max(Rminus1_cl) < self.Rminus1_cl_stop: self.converged = True self.log.info("The run has converged!") # Broadcast and save the convergence status and the last R-1 of means success = get_mpi_comm().bcast( (success if not get_mpi_rank() else None), root=0) if success: self.Rminus1_last = get_mpi_comm().bcast( (Rminus1 if not get_mpi_rank() else None), root=0) self.converged = get_mpi_comm().bcast( (self.converged if not get_mpi_rank() else None), root=0) else: # No MPI pass # Do we want to learn a better proposal pdf? if self.learn_proposal and not self.converged: # update iff (not MPI, or MPI and "good" Rminus1) if get_mpi(): good_Rminus1 = (self.learn_proposal_Rminus1_max > self.Rminus1_last > self.learn_proposal_Rminus1_min) if not good_Rminus1: if not get_mpi_rank(): self.log.info("Bad convergence statistics: " "waiting until the next checkpoint.") return if get_mpi(): if get_mpi_rank(): mean_of_covs = np.empty((self.prior.d(), self.prior.d())) get_mpi_comm().Bcast(mean_of_covs, root=0) elif not get_mpi(): mean_of_covs = covs[0] self.proposer.set_covariance(mean_of_covs) if not get_mpi_rank(): self.log.info("Updated covariance matrix of proposal pdf.") self.log.debug("%r", mean_of_covs) # Finally: returning the computed products ########################################### def products(self): """ Auxiliary function to define what should be returned in a scripted call. Returns: The sample ``Collection`` containing the accepted steps. """ return {"sample": self.collection}