Calibration#
modelly.calibrate fits a model’s parameters to observed data and says how sure the fit is. You
give priors over the parameters and targets (an observed series, or summary statistics of one);
a method runs the model many times through the experiment executors and
returns a posterior: weighted parameter samples, their summaries, plots and predictions.
Method |
What it returns |
Use it when |
|---|---|---|
|
a posterior distribution |
the default: approximate Bayesian computation by sequential Monte Carlo |
|
a posterior distribution |
a quick first look, or a cheap model |
|
the best point |
you match moments (means, variances, autocorrelations) of series |
|
the best point |
each run is expensive and you can afford only tens of them |
Every run is written to a Parquet store like an experiment’s, so the store needs the
experiments extra:
pip install "modelly-abm[experiments]"
A worked example: an SIR model and case data#
The model lives in its own file, epidemic.py, so that worker processes can import it:
import math
from modelly import Model
from modelly.scenarios import Scenario
class EpidemicScenario(Scenario):
beta: float = 0.4 # infection rate
gamma: float = 0.1 # recovery rate
population: int = 1000
initial: int = 10
class Epidemic(Model):
"""A stochastic SIR model: binomial infections and recoveries each day."""
def __init__(self, scenario: EpidemicScenario = EpidemicScenario):
super().__init__(scenario=scenario)
self.susceptible = scenario.population - scenario.initial
self.infected = scenario.initial
self.recovered = 0
def step(self):
s = self.scenario
pressure = 1 - math.exp(-s.beta * self.infected / s.population)
infections = int(self.rng.binomial(self.susceptible, pressure))
recoveries = int(self.rng.binomial(self.infected, 1 - math.exp(-s.gamma)))
self.susceptible -= infections
self.infected += infections - recoveries
self.recovered += recoveries
The “case data” here are 40 days of one run at beta = 0.4, gamma = 0.1; in a real study
they come from a file (pandas.read_csv("cases.csv")["cases"]).
from epidemic import Epidemic, EpidemicScenario
from modelly.calibrate import ABCSMC, Prior, Target
truth = Epidemic(EpidemicScenario(beta=0.4, gamma=0.1, rng=99))
cases = []
for day in range(40):
truth.step()
cases.append(truth.infected)
calibration = ABCSMC(
model=Epidemic,
priors={"beta": Prior.uniform(0.05, 1.0), "gamma": Prior.uniform(0.02, 0.5)},
targets=[Target("infected", observed=cases)],
steps=40,
particles=100,
seed=2027,
)
if __name__ == "__main__": # process workers re-import this script on Windows and macOS
posterior = calibration.run(generations=6, executor="processes", max_parallel=4)
print(posterior.summary().round(3))
print(calibration.trace().round(3))
mean median sd hdi_low hdi_high
beta 0.418 0.413 0.055 0.288 0.512
gamma 0.102 0.099 0.021 0.071 0.143
generation tolerance acceptance ess runs
0 0 inf 1.00 100.000 100
1 1 234.254 0.50 98.419 200
2 2 196.962 0.25 97.974 400
3 3 160.792 0.25 98.569 400
4 4 120.497 0.20 96.417 500
5 5 87.196 0.25 98.382 400
The 95 % highest-density intervals contain the true values. Each generation lowers the tolerance (the largest accepted distance to the data) and costs more runs to fill; more generations, or more particles, narrow the posterior further.
posterior.plot().savefig("fit.png") # marginals, pairs, and the fit against the data
posterior.to_dataframe() # beta, gamma, weight, distance: one row per particle
posterior.best # the particle closest to the data
posterior.predict(
draws=20
) # 20 new runs at posterior samples: draw, replicate, step, infected
plot() returns a Matplotlib figure: weighted histograms of each parameter, scatter plots of
each pair, and for each target the observed values against the posterior median and the 95 %
band of the particles’ simulations.
Priors#
Prior |
Parameters |
|---|---|
|
every value in the interval equally likely |
|
uniform on a log scale, |
|
|
|
the logarithm is normal |
|
a normal cut to an interval |
|
one of a list, for discrete parameters |
prior.sample(rng, n) draws from a numpy generator and prior.logpdf(x) is the log density
(-inf off the support). Parameters the model needs but you do not fit go to fixed=:
ABCSMC(..., fixed={"population": 5000}). When the model’s scenario argument defaults to a
Scenario class, the names and types are checked against it before anything runs.
Targets and distances#
Target(name, observed, distance=..., weight=..., summary=..., output=...):
nameis what the model records every step, read like an experiment output: an attribute, a property or a method of the model. A model reporter of aDataCollectorthat reads an attribute reads the same thing.output=names something else to record (a dotted path or a module-level function of the model).Without
summary,observedis a series matched to the end of the simulated one: its last value against the state after the last step. 40 observed values andsteps=40compare days 1 to 40.With
summary, a function of one run’s series (step 0 first) returns the statistics compared withobserved:Target("infected", observed=[455], summary=numpy.max)fits the peak.With
replicates=above 1, the replicates’ summaries are averaged before the distance.The total distance is the sum of the targets’
weight * distance, which fits several patterns at once (pattern-oriented modelling).
Distance |
|
|---|---|
|
root mean squared error |
|
mean absolute error |
|
the norm of the difference, for vectors of statistics |
|
a weighted RMSE, one weight per value |
|
Wasserstein-1 between the two sets of values (scipy) |
any |
your own |
Counts that span orders of magnitude fit better on a log scale:
Target("prey", numpy.log1p(counts), summary=log_counts) with a module-level
def log_counts(series): return numpy.log1p(series[1:]).
ABC-SMC#
Generation 0 samples the priors and accepts every particle. Each later generation sets its
tolerance to the quantile (default 0.5) of the previous distances, draws ancestors by weight
and moves them with a perturbation kernel, keeps the particles within the tolerance and weights
them by prior density over proposal density. The kernel is a multivariate normal with twice the
weighted covariance of the previous population for continuous parameters; a choice keeps its
value with probability 0.7 and otherwise jumps to another value. The effective sample size of
the weights is in trace() and posterior.effective_sample_size.
run() stops at the first of:
Rule |
Set with |
|---|---|
a number of generations |
|
a budget of model runs |
|
a tolerance reached |
|
an acceptance rate too low |
|
posterior.info["stopped"] says which one.
Seeds, executors and resuming#
A calibration’s seed is the root of every random stream: the priors’ draws, the proposals of
each batch and the seeds of every run (SeedSequence(seed, spawn_key=...), as in experiments).
Proposals are made in batches of particles, and a generation keeps the first accepted
particles in proposal order, so the posterior is the same on "serial", "threads",
"processes", Dask, Ray or a cluster, with any number of workers, and with the compiled core
or MODELLY_PURE_PYTHON=1. With "processes", the workers are started once and reused for
every batch of the run.
A run can stop and resume:
calibration.run(generations=3)
calibration.save("sir.ckpt")
# later, possibly in another process
calibration = ABCSMC.load("sir.ckpt")
posterior = calibration.run(
generations=6
) # identical to one uninterrupted 6-generation run
run(..., checkpoint="sir.ckpt") saves after every generation. A run stopped by its budget
in the middle of a generation resumes from the last whole generation and repeats the same
proposals, so the result is still identical. With store="runs/sir" the runs themselves are
kept, and a rerun reads them instead of running them again.
Warning
A calibration checkpoint is a pickle, like a model checkpoint: only load files you wrote or otherwise trust. Distances, summaries and outputs must be module-level functions (not lambdas) for the calibration to be saved.
ABC rejection#
from modelly.calibrate import ABCRejection
rejection = ABCRejection(Epidemic, priors, targets, steps=40, seed=1)
posterior = rejection.run(budget=2000, quantile=0.05) # keep the closest 5 %
posterior = rejection.run(
budget=2000, tolerance=150.0
) # or every run within a distance
All budget samples run as one batch; the accepted ones have equal weights.
Simulated method of moments#
Each target gives moments (a series, or the statistics of its summary); the objective is
g' W g with g the gap between the replicate mean of the simulated moments and the observed
ones. It is minimised with scipy.optimize (optimizer="Nelder-Mead" or "Powell") over the
priors’ box (their support, or the central 99.8 % of an unbounded prior; log-scaled priors on a
log scale). Every evaluation uses the same replicate seeds (common random numbers), so the
optimiser sees a deterministic function.
from modelly.calibrate import SimulatedMoments
fit = SimulatedMoments(
Market, priors, targets, steps=200, replicates=8, weighting="diagonal", seed=3
)
estimate = fit.run(evaluations=150)
estimate.best # the parameters
fit.trace() # every evaluation and its objective
weighting is "identity", "diagonal" (inverse variances of the moments), "optimal"
(inverse covariance), both estimated from the replicates at the start point, or a matrix.
Bayesian optimisation#
BayesOpt is a basic optimiser built on numpy and scipy, with no extra library: a Latin
hypercube of initial points, then one point at a time, the candidate with the largest
expected improvement under a Gaussian process (squared-exponential kernel with one length scale
per parameter) fitted to the evaluations so far. It minimises the targets’ total distance with
common random numbers and returns the best point. It suits a handful of parameters and up to a
few hundred evaluations; for more, use a dedicated optimisation library on the same objective.
from modelly.calibrate import BayesOpt
search = BayesOpt(Epidemic, priors, targets, steps=40, replicates=4, seed=3)
search.run(evaluations=40, initial=10).best
From the command line#
A calibration can live in a TOML file that reuses the experiment format:
[experiment]
model = "epidemic.py:Epidemic" # relative to this file, or package.module:Class
steps = 40
seed = 2027
store = "runs/calibration" # optional; a temporary folder otherwise
[space] # a range is a uniform prior (log = true: log-uniform),
beta = { min = 0.05, max = 1.0 } # an integer range or a list a choice,
population = 1000 # anything else a fixed parameter
[priors] # other priors, by kind and arguments
gamma = { kind = "truncated_normal", mean = 0.15, sd = 0.1, low = 0.01, high = 0.5 }
[[targets]]
name = "infected"
file = "cases.csv" # or observed = [...]; column defaults to the name
column = "cases"
distance = "rmse" # or mae, euclidean, wasserstein, module:function
[calibration]
method = "abc_smc" # abc_rejection, simulated_moments, bayes_opt
particles = 100 # the method's settings
generations = 6 # and its run() arguments
budget = 5000
[run]
executor = "processes"
max_parallel = 4
modelly calibrate sir.toml
modelly calibrate sir.toml --output posterior.csv # every sample with its weight
modelly calibrate sir.toml --checkpoint sir.ckpt # resumes when the file exists
It prints the summary and the run’s diagnostics. Exit status: 0 when the calibration finished,
1 when a run failed or no sample was accepted, 2 for a bad command line or spec.
python -m modelly.calibrate is the same command; --help shows the whole reference.