Uncertainty¶
A calibration gives one set of parameters. This section shows how far you can trust it: which parameters the data identify, how the inputs move the outputs, and whether the intervals you report cover what they claim. This page defines what every method returns and reads: a weighted parameter sample, the predictive of a sample, and the scores that check it against data. Each method has its own page, listed in the table below.
Quick path¶
from commol import (
Aggregate,
CalibrationParameter,
CalibrationProblem,
Calibrator,
ModelBuilder,
NelderMeadConfig,
NoNoise,
ObservedDataPoint,
ParameterSample,
Predictive,
SampleMethod,
Simulation,
scores,
)
def basic_model(k1: float | None, k2: float | None):
return (
ModelBuilder(name="Basic Model")
.add_bin(id="A", name="State A")
.add_bin(id="B", name="State B")
.add_bin(id="C", name="State C")
.add_parameter(id="k1", value=k1)
.add_parameter(id="k2", value=k2)
.add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total")
.add_transition(id="t_bc", source=["B"], target=["C"], rate="k2 * B")
.build(
Aggregate(bin_amounts={"A": 990, "B": 10, "C": 0}),
engine="DifferenceEquations",
)
)
truth = Simulation(basic_model(0.4, 0.1)).run(num_steps=60)
observations = [
ObservedDataPoint(at=step, output="B", value=truth["B"][step])
for step in range(5, 61, 5)
]
# Fit the first 40 steps; hold out the rest to check the predictive.
fitted = [point for point in observations if point.at <= 40]
held_out = [point for point in observations if point.at > 40]
problem = CalibrationProblem(
observed_data=fitted,
parameters=[
CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0),
CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.0, max_bound=1.0),
],
loss_function="sse",
optimization_config=NelderMeadConfig(max_iterations=2000),
seed=42,
)
calibrator = Calibrator(Simulation(basic_model(None, None)), problem)
# A sample of the parameters. A method returns one; here it is written by hand.
sample = ParameterSample.equally_weighted(
names=["k1", "k2"],
bounds=[(0.0, 1.0), (0.0, 1.0)],
values=[[0.38, 0.09], [0.40, 0.10], [0.42, 0.11]],
method=SampleMethod(name="by hand"),
)
print(sample.interval(0.9)["k1"]) # (0.38, 0.42)
# The predictive: every draw run to step 60, and its band of the model.
predictive = Predictive(calibrator, sample, positions=list(range(61)))
band = predictive.band(confidence_level=0.9)
print(band.lower["B"][50] <= truth["B"][50] <= band.upper["B"][50]) # True
# Scores of the band of the model on the held-out observations.
print(scores.coverage(predictive, held_out, NoNoise(), confidence_level=0.9).mean)
print(scores.crps(predictive, held_out, NoNoise()).per_series)
The three questions¶
| Question | What answers it |
|---|---|
| Which parameters fit the data? | The identifiability methods; a sample of the parameters, or a profile of the likelihood or the loss; several starts that end apart with equal losses show that the data do not identify the parameters |
| How do the inputs move the outputs? | The predictive of a sample: the run of every draw, and its band; with no data, a sensitivity analysis, which propagates the inputs and ranks them |
| Are the intervals calibrated? | Scores of the predictive against observations the method did not see, and coverage studies |
A band of the model, or a prediction interval¶
A band of the model is the spread of the model's output across the draws of
the parameters. It says where the model curve lies, not where the next
observation will fall: an observation also carries measurement noise. A
prediction interval adds that noise to every draw before it reduces them
(the ObservationNoise port of commol-inference). Every reader of
observations takes the noise as an argument with no default: NoNoise() gives
a band of the model, ResidualNoise(...) adds residuals of a fit, which
gives a prediction interval with no noise model
(Resampling), and DistributionNoise()
draws from the observation models of the problem. Comparing a band of the
model with data underestimates the spread, so its coverage of the data is below
its level. A conformal interval is a prediction interval with a
coverage guarantee, from the scores of observations the fit did not use.
Which method answers which question¶
| Method | Question | Needs a noise model | Page |
|---|---|---|---|
Several starts, Calibrator.run(starts=n) |
Which parameters fit the data: distinct ends with equal losses | No | Optimizers |
StructuralRank |
Which parameters any data of this design can determine, and which combinations | No | Identifiability |
Collinearity |
Which parameters can be fitted together | No | Identifiability |
SampleIdentifiability |
Which parameters a sample determines: contraction, correlations, modes | No | Identifiability |
StartsIdentifiability |
Which parameters differ between starts of equal loss | No | Identifiability |
BayesianBootstrap |
How far the parameters move when the data are reweighted by blocks | No | Resampling |
ResidualBootstrap |
How far the parameters move when the residuals of the fit are resampled by blocks | No | Resampling |
WildBootstrap |
As ResidualBootstrap, for noise whose size varies along the series |
No | Resampling |
BlockJackknife |
The standard error and bias of each parameter, and which block of data moves it most | No | Resampling |
ResidualNoise |
Where the next observations fall: a prediction interval | No | Resampling |
GibbsPosterior |
A posterior of the parameters from the loss and priors, with a scale η of the loss |
No | Sequential Monte Carlo |
ABCSMC |
A posterior of the parameters whose runs come within a tolerance of the data, with priors | No | Sequential Monte Carlo |
ABCRejection |
The same, from the closest draws of the priors | No | Sequential Monte Carlo |
SplitConformal |
Where new observations fall, with a guaranteed coverage, from held-out observations | No | Conformal |
RollingConformal |
Where the next observations fall at each horizon ahead of the data | No | Conformal |
JackknifePlus |
Where new observations fall, with a guaranteed coverage and no held-out data | No | Conformal |
EnbPI |
Where the next observations of a series with dependent errors fall | No | Conformal |
AdaptiveConformal |
Intervals for observations as they arrive, whose share of misses tends to its level | No | Conformal |
observation_models with Calibrator.run() |
The maximum-likelihood fit of the parameters and the noise | Yes | Observation Models |
BayesianPosterior |
The posterior of the parameters from the likelihood and priors, with its evidence | Yes | Observation Models |
ParametricBootstrap |
How far the parameters move in data sets drawn from the models at the fit | Yes | Observation Models |
DistributionNoise |
Where the next observations fall: a prediction interval of the models | Yes | Observation Models |
Laplace |
A normal around the fit from the curvature of −log L, and the band of the outputs by the delta method |
Yes | Local Approximations |
Sandwich |
A covariance around the fit that holds when the noise model is wrong or the noise depends on its neighbours | No | Local Approximations |
LaplaceMixture |
A posterior from Laplace approximations at the optima of several starts, corrected by importance sampling, with k_hat |
With LikelihoodTarget |
Local Approximations |
Propagation |
How uncertainty in the inputs moves the outputs, with no data | No | Sensitivity Analysis |
PRCC |
How steadily each output rises or falls with each input, of independent inputs or a sample | No | Sensitivity Analysis |
Morris |
Which inputs matter, in few runs | No | Sensitivity Analysis |
Sobol |
The share of the variance of an output each input explains, alone and with interactions | No | Sensitivity Analysis |
PolynomialChaos |
A polynomial surrogate of an output and its Sobol indices, in few runs | No | Sensitivity Analysis |
ProfileLikelihood |
The interval of each parameter and whether the data determine it, from refits with the parameter held | No: Threshold.chi_square needs one, beale and bootstrap_calibrated do not |
Profile Likelihood |
PredictionProfile |
The interval of a prediction: the values the data do not reject | No, as ProfileLikelihood |
Profile Likelihood |
The parameter sample¶
A ParameterSample holds draws of the parameters of one problem, each with a
weight. Every method with draws returns one; the plots, the reports, the
predictive and the scores read it, never the result of a method.
| Field | Meaning |
|---|---|
names |
One name per column, in the order of the problem's parameters |
bounds |
The bounds of each parameter; every value lies inside them |
values |
One row per draw |
log_weights |
The log weight of each draw, normalized so the weights sum to 1; -inf for a weight of zero |
groups |
The chain, replicate or wave of each draw, or None |
method |
The name and settings of the method that made it (SampleMethod); the settings of a method of Calibrator.uncertainty name the seed of the calibrator, settings["seed"], which repeats the sample when set as problem.seed |
Every statistic is weighted and computed in Rust. With equal weights it is the unweighted statistic.
| Member | Returns |
|---|---|
mean() |
The weighted mean of each parameter |
covariance(), correlation() |
Weighted matrices, in the order of names; with equal weights, NumPy's cov and corrcoef |
quantiles(levels), interval(confidence_level) |
Weighted quantiles by the one convention |
effective_size |
Kish's effective sample size, 1 / Σ wᵢ² (a property) |
draws() |
One {name: value} mapping per draw |
resample(count, seed) |
A sample of equal weights, drawn by weight |
summary(confidence_level) |
A ParameterSummary per parameter, for a report |
A sample has no single set of parameters, so
Model.apply_calibration_parameters rejects it with a TypeError; apply one of
sample.draws(). A sample saves to JSON and loads back equal
(model_dump_json, model_validate_json).
The one quantile¶
Every interval of commol reads one weighted quantile. It inverts the
empirical distribution of the weighted values, and interpolates between the
midpoints of their cumulative weights. With equal weights it equals numpy.percentile(..., method="hazen").
A draw of weight zero does not count. weighted_quantiles computes it for any
values, and member_statistics reduces ensemble members with it.
from commol import weighted_quantiles
print(weighted_quantiles([0.0, 1.0], levels=[0.5], weights=[1.0, 3.0])) # [0.75]
The predictive¶
Predictive(source, sample, positions) runs every draw at positions, in
parallel in Rust, through the forward model of its source: a Calibrator, or
a SensitivityAnalysis of the inputs of the sample. With a stochastic engine
every replicate of a draw is a draw of the predictive, with an equal share of
its weight. The runs follow one tree of seeds: draw j takes the seed
draw_seeds[j], derived from the seed of the source, and its replicate i
takes the seed that replicate i of Simulation.replicates(count,
seed=draw_seeds[j]) takes. So every draw shows fresh noise, the predictive
repeats from the seed of the source, and the runs of a draw change neither
with the number of replicates nor with the number of draws.
The positions are sorted and unique, and each one is a position the engine
reports at; otherwise Predictive raises ValueError before any draw runs.
A draw whose run leaves the values its model allows raises
InfeasibleStateError with the index of its parameter set and the message of
the run: the compartment, the step and the flows, as Simulation.run reports
them (Amounts below zero).
A draw whose initial amounts leave the remainder below zero raises
InfeasibleStateError with the index of its parameter set and the message a
calibration gives for the same values: the remainder and the excess
(Calibrating initial conditions).
| Member | Returns |
|---|---|
band(confidence_level) |
The weighted mean, median and central interval of every output at every position (PredictiveBand) |
series_band(outputs, confidence_level, cumulative=False) |
The band of the sum of some outputs, or of its running total; each draw is reduced first |
at_observations(observed_data, noise) |
Every draw's value of each observation, read through the observation operator the loss uses (sums, windows and factors), with noise drawn from the calibrator's seed: NoNoise(), ResidualNoise(...) or DistributionNoise() (ObservationDraws) |
runs(), weights |
The trajectory and weight of every draw |
quantities(quantities) |
The quantities of interest of every draw, one dict per draw, as SimulationResults.quantities reads one run |
draw_seeds |
The seed of each draw of the sample: Simulation.replicates(count, seed=draw_seeds[j]) of the model at the values of draw j gives its runs |
report(confidence_level, positions, window, windows, observed_data=(), accumulators=(), prediction_intervals=None) |
The report of a run, with each number reduced across the draws with their weights (Reports); prediction_intervals adds the intervals of a conformal method |
plot.series(…), plot.cumulative(…), plot.observations(…) |
The weighted median of the draws of each series and their band, those of its running total, or those of each observed series, read through at_observations(…, NoNoise()) (Plotting) |
predictive.plot.series() and predictive.plot.cumulative() draw the band of
the predictive, each draw read through the observation operator of its
observations, and Predictive.report reduces its draws with their weights. Model.applied_parameters takes the
sample itself (sample=, with a confidence_level).
Scenarios¶
Scenarios(source, sample, scenarios, positions) runs every draw of a sample
under each scenario: a set of parameter values that override those of the
model of the source, any value Model.update_parameters takes (a number, a
formula, the points of a series). Each scenario holds the Predictive of the
sample on a copy of the model with its values; the source and its model stay
unchanged. Scenarios is a mapping from the name of a scenario to its
predictive, so plot_compared(scenarios) draws them on the same panels
(Plotting).
Run k of every scenario is the same draw, and every scenario takes the seed
of the source (Calibrator.with_model, SensitivityAnalysis.with_model), so
with a stochastic engine run k of each scenario runs with the same seed:
common random numbers, whose noise cancels in a difference. compare(name,
baseline, quantities, confidence_level) takes the difference name −
baseline of each quantity within each run, then reduces the differences
across the runs with their weights. So the interval of a difference is the
interval of the paired differences, not the difference of two intervals.
from commol import Peak, Scenarios, ValueAt
# k1 is fitted; k2 is a value of the model that a scenario overrides.
problem_k1 = CalibrationProblem(
observed_data=fitted,
parameters=[
CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0),
],
loss_function="sse",
optimization_config=NelderMeadConfig(max_iterations=2000),
seed=42,
)
calibrator_k1 = Calibrator(Simulation(basic_model(None, 0.1)), problem_k1)
sample_k1 = ParameterSample.equally_weighted(
names=["k1"],
bounds=[(0.0, 1.0)],
values=[[0.38], [0.40], [0.42]],
method=SampleMethod(name="by hand"),
)
scenarios = Scenarios(
calibrator_k1,
sample_k1,
{"fitted": {}, "slower": {"k2": 0.05}},
positions=list(range(61)),
)
quantities = [ValueAt(series="C", at=60), Peak(series="B", positions=list(range(61)))]
for summary in scenarios.compare("slower", "fitted", quantities, confidence_level=0.9):
print(summary.label, round(summary.median), round(summary.lower), round(summary.upper))
# C at 60 -72 -73 -69
# peak of B 213 209 218
| Member | Returns |
|---|---|
scenarios[name] |
The Predictive of one scenario: its runs(), bands, plots and reports |
summary(name, quantities, confidence_level) |
The weighted mean, median and central interval of each quantity across the runs of one scenario (ScenarioSummary) |
compare(name, baseline, quantities, confidence_level) |
The same for the difference name − baseline, taken run by run |
weights |
The weight of each run, the same in every scenario |
A quantity is one of the quantities of interest
(Sensitivity Analysis), reduced in
Rust by Predictive.quantities; a Trajectory gives one summary per
position. The runs read them at positions, so the positions hold
quantity_schedule(quantities). For anything else, scenarios[name].runs()
gives the runs, in the order of weights.
The point estimate of a fit is a sample of one draw:
Calibrator.point_sample(result) gives it, with the names and bounds of the
problem and SampleMethod(name="point", settings={"seed": result.seed}). With one run, a summary gives the
value of that run and no interval (lower and upper are None).
fit = calibrator_k1.run()
point = Scenarios(
calibrator_k1,
calibrator_k1.point_sample(fit),
{"fitted": {}, "slower": {"k2": 0.05}},
positions=list(range(61)),
)
[at_fit] = point.compare("slower", "fitted", [ValueAt(series="C", at=60)], 0.9)
print(round(at_fit.median), at_fit.lower) # -72 None
| Case | Result |
|---|---|
| A scenario sets a parameter of the sample | ValueError that names the scenario and the parameter: each draw sets it |
| A scenario sets a parameter the model does not have | ValueError that names the scenario and the parameter |
| A quantity reads a position the runs lack | ValueError that lists each such position with its quantity |
| A quantity is NaN in a run | ValueError that names the quantity |
Scores¶
The functions of commol.scores compare a predictive with observations, and
return a Score: the value of each observation, the mean of each series and
the overall mean. Which observations to score and the observation noise are
arguments with no default: score data the method did not see, such as the
held-out positions above, and say whether you score a band of the model
(NoNoise()) or a prediction interval (ResidualNoise(...),
DistributionNoise()).
| Function | Score | Better |
|---|---|---|
coverage(predictive, observations, noise, confidence_level) |
1 inside the central interval, 0 outside; the mean is the empirical coverage | Close to the level |
interval_score(predictive, observations, noise, confidence_level) |
(u − l) + (2/α)(l − y)₊ + (2/α)(y − u)₊, α = 1 − c |
Lower |
weighted_interval_score(predictive, observations, noise, confidence_levels) |
The weighted interval score of Bracher et al. (2021) | Lower |
crps(predictive, observations, noise) |
Σ wᵢ|xᵢ − y| − ½ Σᵢⱼ wᵢwⱼ|xᵢ − xⱼ|, the CRPS of a stochastic calibration's loss |
Lower |
pit(predictive, observations, noise) |
F(y), randomized where draws tie with y, from the seed of the source (Calibrator.seed or SensitivityAnalysis.seed): the same draws give the same values |
Uniform |
log_score(predictive, observations, DistributionNoise()) |
log Σ_d w_d · p(y | μ_d), the log density of y under the mixture of the observation models over the draws; another noise raises a TypeError |
Higher |
The entry point¶
Every method runs through Calibrator.uncertainty(method). A method is a
configuration, a subclass of UncertaintyMethod[R] whose type parameter R is
its result; the call returns an R, which a static type checker infers from
the configuration's type. A configuration with no registered runner raises a
ValueError that names the registered ones.
The identifiability methods run through their own entry point,
Calibrator.identifiability(method), with a subclass of
IdentifiabilityMethod[R]: they answer another question
(Identifiability). Both kinds subclass
InferenceMethod[R] and share one registry; each entry point refuses the
other kind with a TypeError that names the right one. The sensitivity
methods, subclasses of SensitivityMethod[R], run with no data through
SensitivityAnalysis(simulation, inputs).run(method), the third entry point
of the registry (Sensitivity Analysis).
from commol import BayesianBootstrap
result = calibrator.uncertainty(BayesianBootstrap(replicates=20, block_length=1))
print(type(result).__name__, len(result.sample)) # BootstrapResult 20
Comparing results¶
Two results of commol are equal (==) when they are of one class and their
fields hold the same values, whatever the objects that hold them: a NaN equals
a NaN, 0.0 equals -0.0, and the order of the keys of a dict does not count.
This holds for the result of a run and of replicates (SimulationResults
and SimulationReplicates, which also compare their positions, seed and
model), of a calibration, of every method, of a sample, a band, a scenario
summary and a score; a plain dict or list is not a result. So two runs
with one seed give equal results, also when they hold a NaN, such as the PRCC
of an input that the other inputs explain entirely. A frozen result, every
one but the results of runs and replicates, CalibrationResult and
CalibrationHistory, has a hash that agrees: equal results have equal
hashes, so they can be keys of a dict or members of a set.
from commol import ParameterSummary
again = calibrator.uncertainty(BayesianBootstrap(replicates=20, block_length=1))
print(again == result, hash(again) == hash(result)) # True True
first = ParameterSummary(
mean=0.5, median=0.5, std=float("nan"), lower=0.0, upper=1.0, min=0.0, max=1.0
)
second = first.model_copy(update={"std": float("nan")})
print(first == second, len({first, second})) # True 1
Checklist¶
- [ ] A band of the model is reported as such, never as a prediction interval.
- [ ] You score observations the method did not see.
- [ ] The effective size of a weighted sample is large enough for the intervals you read.
- [ ] You compare scenarios with
Scenarios.compare, run by run, not as the difference of two intervals.
Next steps¶
| To learn | Read |
|---|---|
| Maximum likelihood, the posterior and prediction intervals of observation models | Observation Models |
| Which parameters the data determine | Identifiability |
| Bootstraps, the jackknife and prediction intervals with no noise model | Resampling |
| Posteriors with no noise model: the Gibbs posterior and ABC | Sequential Monte Carlo |
| The curvature at the fit: Laplace, the sandwich and the Laplace mixture | Local Approximations |
| Intervals and verdicts from profiles of the likelihood or the loss | Profile Likelihood |
| Prediction intervals with a coverage guarantee | Conformal Prediction Intervals |
| How the inputs move the outputs, with no data | Sensitivity Analysis |
| Several starts of an optimizer | Optimizers |
| Reports of a predictive | Reports |
| Plots of a predictive, and several results on the same panels | Running Simulations |