Bayesian calibration¶
This page shows how to use miscore.tools.calibration with PyMC for Bayesian calibration.
In this example, we use a simple model without disease: individuals are simply born and die of other causes.
Other-cause mortality follows a Gompertz life table with two parameters: c and scale.
We will calibrate these two parameters in this tutorial.
The target data is the number of people dying from other causes in five different age intervals.
Note
This tutorial assumes that you read the Calibrate a model tutorial.
Constructing the Fit object¶
First, we build the calibrated model similar to the Calibrate a model tutorial.
We define a function gompertz_life_table() that generates a Gompertz life table, given the two parameters c and scale of the Gompertz distribution.
Then we build the model_from_x() function which creates a MISCore model for a given parameter set x.
This model has a single universe which contains an OC process with the Gompertz life table.
1import numpy as np
2from scipy import stats
3
4from miscore import Model, Universe
5from miscore.processes import OC
6from miscore.tools.calibration import Fit, MultinomialLogLikelihood, Target
7
8
9# Generates a life table from a Gompertz distribution with parameters c and scale.
10def gompertz_life_table(c, scale):
11 ages = np.arange(101)
12 cdf = stats.gompertz.cdf(ages, c=c, scale=scale)
13 return np.stack([cdf, ages], axis=1)
14
15
16# Generates a MISCore model from a parameter set x. x should contain two values: c and scale.
17def model_from_x(x):
18 life_table = gompertz_life_table(*x)
19 oc = OC(life_table=life_table)
20 universe = Universe("universe", processes=[oc])
21 model = Model([universe])
22 return model
Second, we consider the targets, where we assume the following target data:
5% of individuals dies before age 40;
8% of individuals dies between ages 40 and 60;
30% of individuals dies between ages 60-80;
54% of individuals dies between 80-95;
3% of individuals dies after age 95.
The code below creates a Target object for these data.
In that object, we use custom result_to_sim and result_to_m functions.
The first calculates the number of deaths in each age group, whereas the second uses a
lambda expression to simply return the number of simulated individuals, which is the denominator of our target.
Since we calibrate to a percentage, we use MultinomialLogLikelihood for goodness_of_fit.
25# The age groups of our target data
26target_ages = np.array([0, 40, 60, 80, 95])
27
28
29# Custom result_to_sim function that extracts other-cause deaths at the specific age groups.
30def result_to_sim(result):
31 deaths = result.events.set_index(["tag", "age"]).loc["oc_death", ["number"]]
32 return deaths.reindex(target_ages, fill_value=0).to_numpy().T
33
34
35target = Target(
36 name="Alive",
37 x=[0],
38 obs=[[5, 8, 30, 54, 3]],
39 n=[100],
40 category_names=["0-40", "40-60", "60-80", "80-95", "95+"],
41 result_to_sim=result_to_sim,
42 result_to_m=lambda result: np.array([[result.n]]),
43 goodness_of_fit=MultinomialLogLikelihood()
44)
Note
MISCore also includes built-in functions for binomial and Poisson log likelihoods. See Calibration.
Next, we combine the model and the target in the Fit object.
Note that the outputs are logged in the age groups of the targets by specifying the event_ages parameter.
46fit = Fit(
47 model_from_x=model_from_x,
48 event_ages=target_ages,
49 targets=[target],
50 n=1e3,
51 seed=123
52)
Frequentist calibration approach¶
Let us first optimize the likelihood using the Nelder-Mead algorithm to obtain the maximum likelihood estimate of the Gompertz distribution parameters. Similar to the Calibrate a model tutorial, we use the implementation from SciPy. Next, we plot the calibration outputs.
55from matplotlib import pyplot as plt
56from scipy import optimize
57
58# Use Nelder-Mead for calibration to find the Maximum-likelihood estimate
59mle = optimize.minimize(lambda x: -fit(x), x0=[0.0025, 12], method="Nelder-Mead")
60
61# Plot the calibrated life table and the target data
62ages = np.arange(101)
63plt.plot(ages, stats.gompertz.sf(ages, c=mle.x[0], scale=mle.x[1]))
64plt.plot(target_ages[1:], (100 - target.obs.cumsum()[:4]) / 100, "o")
65plt.xlabel("Age")
66plt.ylabel("Survival")
67plt.savefig("life_table_mle.png")
This results in the following life table. Observed values are shown as dots and the line represents the calibrated model.
Bayesian calibration approach¶
Alternatively, a Bayesian approach can be used to include prior information and parameter uncertainty. PyMC is a Python package for Bayesian inference. Its most efficient samplers are gradient-based. However, the derivative of our log-likelihood function is not available, so those algorithms cannot be used. Instead, our MISCore model can be considered a black box likelihood function and instructions on this page in the PyMC documentation should be followed to use “complex external code” in a PyMC model.
The following code demonstrates how our simple model can be calibrated using PyMC.
Following the PyMC tutorials, we write the LogLike class which contains our fit object (our log-likelihood function).
70import arviz_plots as azp
71import pymc as pm
72from pytensor.graph import Apply, Op
73import pytensor.tensor as pt
74
75try:
76 azp.style.use("arviz-variat")
77except ValueError:
78 pass
79
80
81# Our PyTensor Op that integrates our fit object
82class LogLike(Op):
83 def make_node(self, x) -> Apply:
84 x = pt.as_tensor(x)
85 inputs = [x]
86 outputs = [pt.dscalar()]
87 return Apply(self, inputs, outputs)
88
89 def perform(self, node: Apply, inputs: list[np.ndarray], outputs: list[list[None]]):
90 # Calculate the log-likelihood by calling our Fit object for these inputs
91 loglike_eval = fit(inputs[0])
92 outputs[0][0] = np.array([loglike_eval])
93
94
95loglike_op = LogLike()
96
97with pm.Model():
98 # Set prior distributions of the two model parameters
99 gompertz_c = pm.LogNormal("gompertz_c", mu=0, sigma=1.5)
100 gompertz_scale = pm.LogNormal("gompertz_scale", mu=2, sigma=1)
101
102 # Create custom likelihood function
103 x = pm.math.stack([gompertz_c, gompertz_scale])
104 pm.Potential("likelihood", loglike_op(x))
105
106 # Calibrate and sample from the posterior distribution
107 idata = pm.sample(cores=1, initvals={"gompertz_c": 0.0025, "gompertz_scale": 12})
After the calibration, the posterior distribution of the two parameters c and scale can be plotted using ArviZ.
110# Plot the posterior distributions of the parameters
111azp.plot_trace_dist(idata)
112plt.savefig("posterior.png")
We can also calculate the posterior median and 95% credible interval of our calibrated life table.
114# Calculate and plot the posterior median and 95% credible interval of the lifetable
115plt.figure()
116ages = np.arange(101)
117posterior_life_table = stats.gompertz.sf(
118 ages[:, None],
119 c=idata["posterior"]["gompertz_c"].to_numpy().flatten(),
120 scale=idata["posterior"]["gompertz_scale"].to_numpy().flatten()
121)
122plt.fill_between(
123 ages,
124 *np.quantile(posterior_life_table, [0.025, 0.975], axis=1),
125 alpha=0.2
126)
127plt.plot(ages, np.quantile(posterior_life_table, 0.5, axis=1))
128plt.plot(target_ages[1:], (100 - target.obs.cumsum()[:4]) / 100, "o")
129plt.xlabel("Age")
130plt.ylabel("Survival")
131plt.savefig("life_table_posterior.png")
This results in the following plot. The blue line is the posterior median with 95% credible interval, the dots represent the target data.
A few final thoughts regarding Bayesian calibration:
This example calibrates a simple model using only a life table. More sophisticated models can be calibrated by modifying the
model_from_x()function and specifying the prior distribution of additional parameters, similar to the example in Calibrate a model. Therefore this example can relatively simply be extended to disease models.The parameter uncertainty can be propagated to model outcomes by performing model simulations with all samples from the posterior distribution. It is recommended to build a separate
Modelfor each posterior sample, and not to use a separateUniverse. Often, the calibrated model parameters are used to generateproperties()of aProcessand such parameters cannot be varied across universes. See Limits of Universes for further clarification.