Source code for modelly.calibrate.smc

"""ABC-SMC: approximate Bayesian computation by sequential Monte Carlo.

Generation 0 samples the priors. Each later generation sets its tolerance to a
quantile of the previous generation's distances, proposes particles by drawing
an ancestor by weight and moving it with the perturbation kernel
(:mod:`modelly.calibrate.kernels`), keeps those whose distance is within the
tolerance, and weights them by prior over proposal density (Toni et al., 2009;
Beaumont et al., 2009).

Proposals are made in batches of ``particles``; a generation keeps the first
``particles`` accepted in proposal order. Batch ``b`` of generation ``g`` draws
from the stream ``(PROPOSAL, g, b)`` and runs with the seeds of ``(g, b)``, so
the posterior is the same on every executor and after a resume.

Example:
    >>> calibration = ABCSMC(
    ...     model=Epidemic,
    ...     priors={"beta": Prior.uniform(0.05, 0.5), "gamma": Prior.log_normal(-2, 0.5)},
    ...     targets=[Target("infected", observed=cases)],
    ...     steps=60,
    ...     seed=2027,
    ... )  # doctest: +SKIP
    >>> posterior = calibration.run(budget=5000, executor="processes")  # doctest: +SKIP
"""

from __future__ import annotations

import math
from dataclasses import dataclass
from typing import Any

import numpy as np
import pandas as pd

from modelly.calibrate.base import Calibration, CalibrationError
from modelly.calibrate.kernels import Kernel
from modelly.calibrate.posterior import Posterior
from modelly.calibrate.simulator import (
    DEFAULT_EXECUTOR,
    Batch,
    Execution,
    Stream,
    generator,
)

DEFAULT_PARTICLES = 100
DEFAULT_QUANTILE = 0.5
DEFAULT_GENERATIONS = 5
DEFAULT_MIN_ACCEPTANCE = 0.01
SMC_KEY = 1  # first element of every batch key of this method
GENERATIONS, BUDGET = "generations", "budget"
TOLERANCE, ACCEPTANCE = "min_tolerance", "min_acceptance"


[docs] @dataclass class Accepted: """The particles of one proposal batch that met the tolerance.""" samples: dict[str, list[Any]] distances: np.ndarray summaries: dict[str, np.ndarray]
[docs] @dataclass class Generation: """One accepted population: samples, weights, distances, summaries and costs.""" samples: dict[str, list[Any]] weights: np.ndarray distances: np.ndarray summaries: dict[str, np.ndarray] tolerance: float runs: int acceptance: float @property def effective_sample_size(self) -> float: """Kish's effective sample size of the normalised weights.""" return float(1.0 / np.sum(self.weights**2))
[docs] class ABCSMC(Calibration): """ABC-SMC with an adaptive tolerance schedule. Takes the :class:`~modelly.calibrate.base.Calibration` arguments and: Args: particles: the population size (and the size of a proposal batch). quantile: the next tolerance is this quantile of the last distances. min_tolerance: stop once the tolerance reaches this value. min_acceptance: stop when a generation accepts a smaller share of its proposals; also bounds the cost of one generation. """ method = "abc-smc" def __init__( self, *args: Any, particles: int = DEFAULT_PARTICLES, quantile: float = DEFAULT_QUANTILE, min_tolerance: float = 0.0, min_acceptance: float = DEFAULT_MIN_ACCEPTANCE, **kwargs: Any, ) -> None: """Check the settings; nothing runs before ``run``.""" super().__init__(*args, **kwargs) if particles < 1 or not 0 < quantile <= 1 or not 0 < min_acceptance <= 1: raise ValueError( "particles >= 1, 0 < quantile <= 1, 0 < min_acceptance <= 1" ) self.particles, self.quantile = particles, quantile self.min_tolerance, self.min_acceptance = min_tolerance, min_acceptance self.history: list[Generation] = [] self.stopped: str | None = None @property def runs(self) -> int: """Model runs made by the accepted generations.""" return sum(generation.runs for generation in self.history)
[docs] def run( self, budget: int | None = None, generations: int = DEFAULT_GENERATIONS, executor: Any = DEFAULT_EXECUTOR, max_parallel: int | None = None, **options: Any, ) -> Posterior: """Add generations until one of the stopping rules holds; the last posterior. Stops after ``generations`` generations in all, before a generation that would take the model runs past ``budget``, when the tolerance reaches ``min_tolerance`` or the acceptance rate falls below ``min_acceptance``. ``checkpoint=path`` (in ``options``) saves after every generation. Calling ``run`` again, after ``load`` or with a larger ``generations``, goes on from the last generation. """ checkpoint = options.pop("checkpoint", None) self.stopped = None with Execution(executor, max_parallel, options) as execution: while self.stopped is None: self.stopped = self._stop_before(generations) if self.stopped is None: self._advance(budget, execution) if checkpoint is not None: self.save(checkpoint) if not self.history: raise CalibrationError( f"no generation completed (stopped by {self.stopped})" ) return self.posterior_of(self.history[-1])
def _stop_before(self, generations: int) -> str | None: """The rule that stops the run before the next generation, if any.""" if len(self.history) >= generations: return GENERATIONS if self.history and self.history[-1].tolerance <= self.min_tolerance: return TOLERANCE if self.history and self.history[-1].acceptance < self.min_acceptance: return ACCEPTANCE return None def _advance(self, budget: int | None, execution: Execution) -> None: """Run the next generation and keep it, or set ``stopped`` to why it failed.""" index, tolerance = len(self.history), self._tolerance() kernel = self._kernel() parts: list[Accepted] = [] proposals = kept = 0 while kept < self.particles: self.stopped = self._stop_within(budget, proposals, kept) if self.stopped is not None: return batch = (index, proposals // self.particles) parts.append(self._batch(batch, kernel, tolerance, execution)) proposals += self.particles kept += len(parts[-1].distances) self.history.append(self._generation(parts, kernel, tolerance, proposals)) def _stop_within(self, budget: int | None, proposals: int, kept: int) -> str | None: """The rule that abandons the current generation before its next batch, if any.""" cost = (proposals + self.particles) * self.replicates if budget is not None and self.runs + cost > budget: return BUDGET hopeless = proposals * self.min_acceptance >= self.particles if proposals and hopeless and kept / proposals < self.min_acceptance: return ACCEPTANCE return None def _tolerance(self) -> float: """Infinite for generation 0, else the quantile of the last distances (>= the minimum).""" if not self.history: return math.inf value = float(np.quantile(self.history[-1].distances, self.quantile)) return max(value, self.min_tolerance) def _kernel(self) -> Kernel | None: """The kernel moving the last population (none before generation 0).""" if not self.history: return None last = self.history[-1] return Kernel.fit(self.priors, last.samples, last.weights) def _batch( self, batch: tuple[int, int], kernel: Kernel | None, tolerance: float, execution: Execution, ) -> Accepted: """Propose and run one batch; the particles within ``tolerance``.""" rng = generator(self.seed, Stream.PROPOSAL, SMC_KEY, *batch) if kernel is None: samples = { n: list(p.sample(rng, self.particles)) for n, p in self.priors.items() } else: samples = kernel.propose(rng, self.particles) points = self.points(samples) distances, summaries = self.evaluate( Batch((SMC_KEY, *batch), points), execution ) keep = np.flatnonzero(distances <= tolerance) chosen = {n: [values[i] for i in keep] for n, values in samples.items()} return Accepted( chosen, distances[keep], {n: s[keep] for n, s in summaries.items()} ) def _generation( self, parts: list[Accepted], kernel: Kernel | None, tolerance: float, proposals: int, ) -> Generation: """The first ``particles`` accepted, in proposal order, with importance weights.""" size = self.particles samples = { n: [v for part in parts for v in part.samples[n]][:size] for n in self.names } distances = np.concatenate([part.distances for part in parts])[:size] summaries = { t.name: np.concatenate([part.summaries[t.name] for part in parts])[:size] for t in self.targets } weights = np.full(size, 1.0 / size) if kernel is not None: log = kernel.importance(samples) weights = np.exp(log - log.max()) weights /= weights.sum() runs, acceptance = proposals * self.replicates, size / proposals return Generation( samples, weights, distances, summaries, tolerance, runs, acceptance )
[docs] def posterior_of(self, generation: Generation) -> Posterior: """The Posterior of ``generation``, with the run's diagnostics in ``info``.""" g = generation posterior = self.posterior(g.samples, g.weights, g.distances, g.summaries) posterior.info = {"generations": len(self.history), "tolerance": g.tolerance} posterior.info |= {"runs": self.runs, "acceptance": g.acceptance} posterior.info |= {"stopped": self.stopped} return posterior
[docs] def trace(self) -> pd.DataFrame: """One row per generation: tolerance, acceptance rate, effective sample size, runs.""" return pd.DataFrame( [ { "generation": index, "tolerance": g.tolerance, "acceptance": g.acceptance, "ess": g.effective_sample_size, "runs": g.runs, } for index, g in enumerate(self.history) ] )