Training vs. Calibrating Epidemiological Models: A First-Principles Guide
Why fitting curves isn’t enough when forecasting disease dynamics
Epidemiology
Machine Learning
Mathematical Modeling
SIR Models
Calibration
Python
Author
Ravi Kalia
Published
July 23, 2026
Training vs. Calibrating Epidemiological Models
If you come from machine learning, “fit the model to the data” is second nature: pick a loss, run an optimizer, check the validation score. Mechanistic epidemiology quietly breaks that reflex. You can fit an epidemic curve almost perfectly and still produce a forecast that is not just inaccurate but wrong about the underlying disease — wrong reproduction number, wrong peak, wrong final size — while reporting no uncertainty at all.
This post builds the distinction between training and calibration from first principles, using the classic Susceptible–Infected–Recovered (SIR) model. Everything runs top to bottom; the only dependencies are numpy, scipy, pandas, matplotlib, and seaborn.
What you’ll take away
Why parameter estimation (“training”) is not sufficient for reliable epidemic forecasting.
What calibration actually means for a mechanistic model — and why it’s a different task, not a fancier fit.
Why modeling uncertainty matters as much as fitting the mean trajectory.
Why temporal validation is the only honest validation for forecasting, and why k-fold cross-validation quietly cheats.
How training and calibration complement each other in real epidemic forecasting.
Code
import numpy as npimport pandas as pdimport matplotlib.pyplot as pltimport seaborn as snsfrom scipy.integrate import solve_ivpfrom scipy.optimize import minimizefrom scipy.stats import nbinom, normsns.set_theme(style="whitegrid", context="notebook")plt.rcParams.update({"figure.dpi": 140,"savefig.dpi": 140,"font.size": 11,"axes.titlesize": 13,"axes.titleweight": "bold","axes.labelsize": 11,"legend.frameon": True,"figure.autolayout": True,})# A single random generator so every stochastic step is reproducible.RNG = np.random.default_rng(7)# Consistent colours across every figure.C_TRUTH ="#111111"# latent (unobservable) epidemicC_OBS ="#4A3AA7"# observed surveillance dataC_TRAIN ="#D1495B"# trained (MSE) modelC_CALIB ="#2A9D8F"# calibrated (MLE) model
1 Why “good fit” doesn’t mean good forecasts
Imagine two teams handed the same 35 days of case counts from a new outbreak. Team A fits a flexible curve — a spline, a gradient-boosted regressor, whatever minimizes error. Team B fits a mechanistic SIR model. Both match the historical data beautifully. Then you ask the only question that matters: when does this peak, and how big does it get?
The two forecasts diverge wildly. Worse, two mechanistic fits can agree on the past and still disagree on the future, because early epidemic data is dominated by exponential growth, and during exponential growth many different disease parameters produce nearly identical curves. The data you have simply doesn’t pin down the parameters you need.
That is the crux. Fitting is about reproducing observed data. Forecasting a mechanistic system is about recovering the process that generated it — including the messy layer of how cases get counted, and how much you should trust any single number. Those are different jobs. In this post:
Training means finding point estimates of the dynamical parameters that minimize a prediction error (here, mean squared error). It answers “what curve fits?”
Calibration means fitting the full generative model — the dynamics and the observation process and its noise — by maximum likelihood, so predictions arrive as distributions with quantifiable uncertainty. It answers “what process, with what plausible variation, could have produced this?”
The difference, at three lengths
Paragraph. Calibration and training are both loops that adjust numbers until a model matches data; they differ in how much of the data-generating story you build in. In calibration you commit to the SIR equations up front on epidemiological grounds and estimate a few interpretable quantities — β, γ, maybe I₀, plus how cases get reported and how noisy the counts are — by repeatedly solving the ODEs and comparing to observations; success means plausible, identifiable parameters with honest uncertainty, and because the mechanism is real you can extrapolate past what you’ve seen. Training is the machine-learning reflex pointed at the same data: pick a loss, minimize prediction error, guard against overfitting with validation splits, and judge success purely by out-of-sample accuracy — at its purest the functional form itself is learned, and even when you aim it at the SIR parameters it skips the reporting model and the uncertainty and just fits the curve. The clean way to say it: calibration fits the whole generative process you already believe in, while training fits the relationship that best reproduces the numbers.
Sentence. Calibration tunes a few meaningful knobs on a data-generating process you’ve already committed to; training minimizes prediction error and lets accuracy be the judge.
Word.Generative.
We’ll make both concrete, then show — with numbers — how a great-looking trained fit can hide a badly wrong mechanism, and how calibration recovers it.
2 First principles: the SIR model
Compartmental models split a population into buckets (“compartments”) and write down the rates at which people flow between them. The SIR model is the simplest useful one: everyone is either Susceptible (can catch it), Infected (has it and can transmit), or Recovered (immune, or removed).
Figure 1: Flow of individuals through the SIR compartments. Infection moves people S→I at a rate driven by contact between susceptibles and infecteds; recovery moves them I→R at a constant per-capita rate.
Intuition first. New infections happen when a susceptible person meets an infected one. The number of such encounters per day scales with how many susceptibles there are (\(S\)) times the fraction of contacts that are infectious (\(I/N\)), tuned by a transmission rate \(\beta\). Meanwhile, infected people recover at some steady per-person rate \(\gamma\), so a fraction \(\gamma\) of the infected pool leaves for \(R\) each day. Everything that leaves \(S\) arrives in \(I\); everything that leaves \(I\) arrives in \(R\). Nobody is created or destroyed.
Now the equations. Writing those flows as differential equations:
Sets the scale; \(I/N\) is the infectious fraction
\(\beta\)
Transmission rate (per day)
How fast the disease spreads on contact
\(\gamma\)
Recovery rate (per day)
\(1/\gamma\) is the average infectious period
\(R_0 = \beta/\gamma\)
Basic reproduction number
Expected secondary cases from one case in a fully susceptible population
The single most important derived quantity is \(R_0 = \beta/\gamma\). If \(R_0 > 1\) the epidemic grows; if \(R_0 < 1\) it fizzles. The epidemic turns over (the peak) exactly when the susceptible fraction has fallen to \(S/N = 1/R_0\) — susceptibles become too scarce to sustain growth. Getting \(R_0\) wrong means getting the entire shape of the future wrong, even if you nailed the past. Hold onto that.
3 Generating synthetic epidemic data
We’ll simulate a “ground truth” epidemic so that later we can check whether our methods recover parameters we actually know. Real outbreaks never hand you the answer key; synthetic data does.
Code
N =10_000.0# population sizeN_DAYS =100# length of the simulation (days)t = np.arange(0, N_DAYS +1)# The true data-generating parameters (unknown to our estimators later).TRUE =dict( beta=0.30, # transmission rate -> R0 = 3.0 gamma=0.10, # recovery rate -> 10-day infectious period I0=10.0, # initially infected rho=0.65, # reporting rate: only 65% of infections get counted k=10.0, # negative-binomial dispersion of the reporting noise)def sir_rhs(_t: float, y: np.ndarray, beta: float, gamma: float) ->list[float]:"""Right-hand side of the SIR ODE system.""" S, I, R = y new_infections = beta * S * I / Nreturn [-new_infections, new_infections - gamma * I, gamma * I]def solve_sir(beta: float, gamma: float, I0: float) -> np.ndarray:"""Integrate the SIR system on the daily grid; returns array of shape (3, T).""" sol = solve_ivp( sir_rhs, [0, N_DAYS], [N - I0, I0, 0.0], args=(beta, gamma), t_eval=t, rtol=1e-8, atol=1e-8, )return sol.ydef daily_incidence(beta: float, gamma: float, I0: float) -> np.ndarray:"""New infections per day = the S->I flow, which is what surveillance tries to count.""" S, I, _R = solve_sir(beta, gamma, I0)return beta * S * I / N
A crucial modeling choice: what do we actually observe? Surveillance systems don’t see the compartments \(S\), \(I\), \(R\). They see reported new cases — a fraction of the true daily incidence (the S→I flow), corrupted by counting noise. We model that observation layer explicitly:
Under-reporting. Only a fraction \(\rho = 0.65\) of new infections are ever recorded (asymptomatics, limited testing, reporting lags).
Over-dispersed noise. Case counts are noisier than a Poisson process — weekend effects, batch reporting, clustering. A Negative Binomial captures this: for a mean \(\mu\) and dispersion \(k\), the variance is \(\mu + \mu^2/k\), which exceeds the Poisson variance \(\mu\).
Code
latent_incidence = daily_incidence(TRUE["beta"], TRUE["gamma"], TRUE["I0"])def nb_sample(mean: np.ndarray, k: float, rng: np.random.Generator) -> np.ndarray:"""Draw Negative-Binomial counts with given mean and dispersion k (NumPy's n=k, p param).""" mean = np.asarray(mean, dtype=float) p = k / (k + mean) # so that E[counts] = k*(1-p)/p = meanreturn rng.negative_binomial(k, p)observed = nb_sample(TRUE["rho"] * latent_incidence, TRUE["k"], RNG).astype(float)S_true, I_true, R_true = solve_sir(TRUE["beta"], TRUE["gamma"], TRUE["I0"])
Figure 2: Left: the latent SIR epidemic in each compartment. Right: the true daily incidence (black) versus what surveillance actually records — under-reported, noisy case counts (purple). We only ever get to fit the purple dots.
The gap between the black curve and the purple dots is the whole problem in one picture. If you fit a model as if the dots were the epidemic, you will systematically misread its scale — and, as we’ll see, its \(R_0\).
4 Proper temporal validation
In standard supervised learning we shuffle rows and split randomly, or use k-fold cross-validation. For a forecasting problem driven by an ODE, both are quietly invalid:
Random splits leak the future into the past. If day 60 is in your training set and day 40 in your test set, you’ve used post-peak information to “predict” the growth phase. The whole point of a forecast is that the future is unavailable; a random split pretends otherwise.
K-fold cross-validation assumes exchangeable samples. Epidemic days are anything but — each day is the deterministic consequence of the days before it. Folding across time destroys the causal ordering that the model is supposed to respect.
The only honest protocol is a chronological split: train on the earliest data, validate on a later contiguous block, test on the latest.
Code
train_idx = np.arange(0, 36) # days 0-35 : early growth, what you'd have "now"val_idx = np.arange(36, 66) # days 36-65 : spans the turnover / peaktest_idx = np.arange(66, 101) # days 66-100 : the genuine out-of-sample future
Code
fig, ax = plt.subplots(figsize=(10, 4))ax.scatter(t, observed, s=16, color=C_OBS, alpha=0.7, zorder=3)spans = [(0, 35, "#8ecae6", "Train (0–35)"), (35, 65, "#ffd166", "Validation (36–65)"), (65, 100, "#ef9a9a", "Test (66–100)")]for x0, x1, color, label in spans: ax.axvspan(x0, x1, color=color, alpha=0.30, label=label)for boundary in (35, 65): ax.axvline(boundary, color="#444444", ls="--", lw=1)ax.set_title("Temporal validation: information available at each stage")ax.set_xlabel("Day"); ax.set_ylabel("Observed cases per day")ax.legend(loc="upper right")plt.show()
Figure 3: Chronological train / validation / test split. Each stage only ever sees data to its left; the test block is never touched until the final evaluation.
At training time you’d have only the blue block — the early, exponential-looking growth. The peak lives at the boundary of the yellow block, and the long tail (the epidemic burning out as susceptibles run low) is the red block. A model that looks flawless on blue can still be lost on red.
5 Training the mechanistic model
We train exactly as an ML engineer would: pick a loss, minimize it over the parameters, using only the training window. The natural default is mean squared error between the model’s predicted daily cases and the observed counts. Because a naive fitter has no notion of a reporting rate, it implicitly assumes every infection is counted (\(\rho = 1\)) — a subtle but consequential misspecification.
We optimize in log-space so \(\beta\) and \(\gamma\) stay positive, and use derivative-free Nelder–Mead because each evaluation runs an ODE solve.
Code
def mse_loss(log_theta: np.ndarray) ->float: beta, gamma = np.exp(log_theta) model = daily_incidence(beta, gamma, TRUE["I0"]) # assumes rho = 1returnfloat(np.mean((model[train_idx] - observed[train_idx]) **2))train_res = minimize( mse_loss, np.log([0.4, 0.2]), method="Nelder-Mead", options=dict(xatol=1e-6, fatol=1e-6, maxiter=2000),)beta_hat, gamma_hat = np.exp(train_res.x)trained_R0 = beta_hat / gamma_hattrained_curve = daily_incidence(beta_hat, gamma_hat, TRUE["I0"])# Give the point-forecast a minimal (Gaussian, homoscedastic) noise model so we can# later score it probabilistically. Its spread is the training residual scale.sigma_hat =float(np.sqrt(np.mean((observed[train_idx] - trained_curve[train_idx]) **2)))# Final attack rate = fraction of the population ever infected = (N - S_end) / N.# This is what the model believes about the *latent* epidemic, not the reported cases.true_attack =float((N - solve_sir(TRUE["beta"], TRUE["gamma"], TRUE["I0"])[0][-1]) / N *100)trained_attack =float((N - solve_sir(beta_hat, gamma_hat, TRUE["I0"])[0][-1]) / N *100)print(f"trained beta = {beta_hat:.3f} gamma = {gamma_hat:.3f} R0 = {trained_R0:.2f}")print(f"true beta = {TRUE['beta']:.3f} gamma = {TRUE['gamma']:.3f} R0 = {TRUE['beta']/TRUE['gamma']:.2f}")print(f"implied final attack rate: trained {trained_attack:.0f}% vs true {true_attack:.0f}%")
S_hat, _I_hat, _R_hat = solve_sir(beta_hat, gamma_hat, TRUE["I0"])cum_true = (N - S_true) / N *100# % ever infected, truthcum_trained = (N - S_hat) / N *100# % ever infected, as the trained model believesfig, (axL, axR) = plt.subplots(1, 2, figsize=(11.5, 4.4))axL.scatter(t, observed, s=16, color=C_OBS, alpha=0.55, label="Observed cases", zorder=3)axL.plot(t, trained_curve, color=C_TRAIN, lw=2.4, label="Trained model (MSE)")axL.axvspan(0, 35, color="#8ecae6", alpha=0.22)axL.axvline(35, color="#666666", ls="--", lw=1)axL.text(17, axL.get_ylim()[1] *0.86, "training window", ha="center", color="#2a6f8e", fontsize=10)axL.set_title("What you see: a great fit to reported cases")axL.set_xlabel("Day"); axL.set_ylabel("Reported cases per day")axL.legend(loc="upper right")axR.plot(t, cum_true, color=C_TRUTH, lw=2.4, label="True infections")axR.plot(t, cum_trained, color=C_TRAIN, lw=2.4, label="Trained model believes")axR.fill_between(t, cum_trained, cum_true, color=C_TRAIN, alpha=0.10)axR.annotate(f"{true_attack:.0f}% infected", xy=(100, true_attack), xytext=(60, true_attack -15), color=C_TRUTH, fontsize=11, fontweight="bold", arrowprops=dict(arrowstyle="->", color=C_TRUTH))axR.annotate(f"only {trained_attack:.0f}%", xy=(100, trained_attack), xytext=(60, trained_attack +9), color=C_TRAIN, fontsize=11, fontweight="bold", arrowprops=dict(arrowstyle="->", color=C_TRAIN))axR.set_title(rf"What you don't: a wrong epidemic ($R_0$ = {trained_R0:.2f} vs 3.0)")axR.set_xlabel("Day"); axR.set_ylabel("Cumulative % ever infected")axR.set_ylim(0, 100); axR.legend(loc="center right")plt.show()
Figure 4: Left: the trained model fits the reported case counts almost perfectly — nothing in this panel looks wrong. Right: the same fit implies a latent epidemic that infected only a fraction of the population it truly did. The failure is invisible in the data you can see and dramatic in the mechanism you can’t.
Here is the trap in one figure. On the left, the trained model is indistinguishable from a great model — it threads the observed dots across the entire horizon. On the right is the epidemic that same fit actually implies: it recovered \(R_0 \approx\) 1.57 against a true \(3.00\), and believes only about 62% of the population was ever infected when the truth is 94%.
Why does a good fit hide such a wrong mechanism? Because the model assumes every infection is reported (\(\rho = 1\)). Confronted with counts that are really only 65% of true incidence, the optimizer has one lever to explain “fewer cases than a full epidemic would produce”: make the disease milder. A lower \(R_0\) reproduces the observed curve while quietly halving the implied outbreak. The reporting rate and the epidemic’s severity are confounded, and MSE — which never models reporting — silently resolves that confounding the wrong way. Worse, there is no uncertainty anywhere: a single confident line with nothing to say when a decision-maker asks how sure it is about the peak, the final size, or the effect of an intervention.
6 Calibration
Calibration is not “training with a better optimizer.” It is a change of target: instead of minimizing an error against a point prediction, we fit the entire generative model by maximum likelihood, so that the output is a predictive distribution. Concretely, calibration takes on the parts of the problem that MSE training ignored:
the observation model — the reporting rate \(\rho\) that connects true incidence to counted cases,
the initial condition\(I_0\), treated as unknown rather than assumed,
the dispersion\(k\) of the noise, which sets how wide our predictive intervals should be,
and, jointly, the dynamics\(\beta, \gamma\) — now estimated through a correct observation model rather than against a misspecified one.
The likelihood is Negative Binomial: given parameters, the model predicts a mean number of reported cases \(\mu_t = \rho \cdot \text{incidence}_t\), and each observation is \(y_t \sim \text{NB}(\mu_t, k)\). We minimize the negative log-likelihood on train and validation (the validation block is where the peak lives, so it carries the curvature that separates \(\rho\) from \(R_0\)).
By modeling how the data were generated, calibration recovers \(R_0 \approx\) 2.80 — essentially the truth — along with the reporting rate and dispersion that MSE training could not even represent. Note the NaNs in the trained column: those parameters don’t exist in that model. That’s the point. You cannot calibrate what you refuse to model.
6.1 MSE versus negative log-likelihood
Why does the objective matter so much? Compare the two losses for a mean prediction \(\mu_t\) and observation \(y_t\):
MSE is (up to a constant) the negative log-likelihood of a Gaussian with the same variance everywhere. That is a terrible assumption for epidemic counts: the noise at the peak (hundreds of cases) dwarfs the noise in the tail (single digits). MSE therefore lets the peak dominate the fit and treats a miss of 5 cases on day 3 as identically important to a miss of 5 cases on day 40. The Negative Binomial NLL, by contrast, scales its expected error with the mean — exactly the heteroscedastic structure real counts have. Use MSE when you only need a mean and the noise is roughly constant; use a likelihood when the noise structure carries information and you need honest intervals.
6.2 Turning point estimates into a predictive distribution
Maximum likelihood gives a best-fit point in parameter space; its uncertainty is encoded in the curvature of the likelihood there. A Laplace approximation treats the parameter posterior as Gaussian, centered at the MLE with covariance equal to the inverse Hessian of the NLL. We sample parameters from that Gaussian, run each through the model, and draw Negative Binomial counts — folding together parameter uncertainty and observation noise into one predictive distribution.
Code
def numeric_hessian(f, x: np.ndarray, eps: float=1e-4) -> np.ndarray:"""Central finite-difference Hessian of scalar f at x.""" n =len(x) H = np.zeros((n, n))for i inrange(n):for j inrange(n): xpp, xpm, xmp, xmm = (x.copy() for _ inrange(4)) xpp[i] += eps; xpp[j] += eps xpm[i] += eps; xpm[j] -= eps xmp[i] -= eps; xmp[j] += eps xmm[i] -= eps; xmm[j] -= eps H[i, j] = (f(xpp) - f(xpm) - f(xmp) + f(xmm)) / (4* eps * eps)return0.5* (H + H.T) # symmetrize away round-offH = numeric_hessian(neg_log_lik, calib_res.x)cov = np.linalg.inv(H) # Laplace covariance in log-parameter space# Draw many parameter sets (parameter uncertainty), and for each draw several# Negative-Binomial count replicates (observation noise). More draws => a smoother band.N_SAMPLES, N_REPS =1500, 12param_samples = RNG.multivariate_normal(calib_res.x, cov, size=N_SAMPLES)pred_draws = np.empty((N_SAMPLES * N_REPS, len(t)))for m, s inenumerate(param_samples): beta, gamma, I0, rho, k = np.exp(s) mean = rho * daily_incidence(beta, gamma, I0) p = k / (k + mean +1e-6) pred_draws[m * N_REPS:(m +1) * N_REPS] = nbinom.rvs(k, p, size=(N_REPS, len(t)), random_state=RNG)calib_median = np.median(pred_draws, axis=0)calib_lo = np.percentile(pred_draws, 2.5, axis=0)calib_hi = np.percentile(pred_draws, 97.5, axis=0)calib_mean_curve = crho * daily_incidence(cb, cg, cI0) # expected reported cases
7 Comparing the models
Now we score both models on all three splits with three metrics: RMSE and MAE (point accuracy), and negative log-likelihood (probabilistic accuracy — does the model place probability where the data actually fall?). Each model is scored under its own predictive distribution: Gaussian for the trained point-forecast, Negative Binomial for the calibrated model.
The calibrated model’s 95% predictive interval covers close to 95% of the held-out points — it is honest about what it doesn’t know. The trained model has no interval to check; its confident line is either right or wrong, with no gradation in between.
Figure 5: The full comparison. Observed data (dots), the latent truth (black), the trained point-forecast (red), and the calibrated model with its 95% predictive band (green). The calibrated band widens where the model is genuinely less certain and contains the data across the out-of-sample horizon.
Reading the figure rather than the metrics: the red line commits to a single, mechanistically wrong story about the epidemic. The green model recovers the true curve, and — just as importantly — draws a band that is narrow where it should be confident and wide where it shouldn’t. In a real outbreak, that band is what a public-health team plans around.
8 Training vs. calibration
Aspect
Training
Calibration
Goal
Reproduce observed data
Recover the generating process and quantify uncertainty
Parameters
Dynamical rates only (\(\beta, \gamma\))
Full generative model (\(\beta, \gamma, I_0\), reporting \(\rho\), dispersion \(k\))
Loss
MSE (implicit constant-variance Gaussian)
Negative log-likelihood (explicit count model)
Data used
Training window only
Train + validation (peak curvature identifies the observation model)
Output
A single point forecast
A predictive distribution with intervals
Interpretation
“What curve fits the past?”
“What process, with what plausible variation, produced this?”
The two are complementary, not competing. Training gives you a fast, cheap point estimate and a sanity check that the dynamics can fit at all. Calibration is what makes the forecast usable: it corrects the observation model, recovers interpretable epidemiological quantities like \(R_0\), and — decisively — attaches uncertainty you can act on.
8.1 Statistically, what’s the difference?
Strip away the epidemiology and the two tasks are estimating different things.
Training chooses parameters to make good predictions. It fits a conditional distribution of outcomes given inputs by minimizing an expected loss,
where \(\theta\) can be enormous — a neural surrogate carries millions of weights — and success is measured by generalization: accuracy on inputs you have not seen. In its purest form the model’s functional form is itself learned from the data.
Calibration holds the model form fixed — here, the SIR equations — and infers the handful of parameters that make the simulated epidemic match reality,
where \(D\) is a discrepancy — least squares, or a negative log-likelihood — and \(\theta\) is low-dimensional and physically meaningful: \(\beta\), \(\gamma\), a reporting rate, an initial count. Success is measured by inference — did you recover parameters that are true and interpretable, ideally with honest uncertainty? Because the mechanism already exists, epidemiologists say calibration, not training: you are solving an inverse problem for parameters, not learning a functional form.
That is exactly why this whole post could pin the SIR skeleton for both the “trained” and “calibrated” models. With the structure fixed and the parameter set small in both, the generalization-versus-inference and high-versus-low-dimensional contrasts fall away, leaving the differences we actually care about isolated and visible: a prediction-error objective versus a likelihood, a point estimate versus a full distribution, and whether the observation process is part of what you estimate at all.
9 Reusing a model across outbreaks: Mexico → Colombia
Everything so far fit one epidemic in isolation. But the reason mechanistic models are worth the trouble is that their parameters mean something, and meaning travels. A recovery rate is a fact about the pathogen; a transmission rate is a fact about a place and its behaviour. That distinction is exactly what lets you carry a model from one outbreak to the next — if you respect which parameters transfer and which don’t.
Here is the scenario. Last year an epidemic ran its full course in a city in Mexico; we have the whole curve and can calibrate it cleanly. This year a related pathogen is spreading in Colombia, and we are standing on day 22 — still climbing, no peak in sight — with a decision to make about hospital capacity. Can last year’s Mexican model help?
The data here is synthetic
The “Mexico” and “Colombia” epidemics below are simulated — the parameters, populations, and case counts are invented so the transfer mechanics are visible and the whole thing runs reproducibly. Nothing here describes a real outbreak in either country. What’s real is the method: which parameters should transfer, and how to combine a prior with local data.
What transfers, and what doesn’t:
Recovery rate \(\gamma\) — one over the infectious period — is set by the pathogen’s biology. To a first approximation this is the same disease, so \(\gamma\) should carry over. This is the parameter we transfer.
Transmission \(\beta\) — contacts × per-contact risk — depends on density, mobility, and behaviour. Bogotá is not Mexico City; \(\beta\) must be re-estimated locally.
Reporting rate \(\rho\) and the noise scale are properties of a health system: different country, re-estimate (though borrowing the dispersion is reasonable).
Code
def sigmoid(x: float) ->float:return1.0/ (1.0+ np.exp(-x))def logit(p: float) ->float:return np.log(p / (1.0- p))def simulate_incidence(beta: float, gamma: float, I0: float, N_pop: float, t_grid: np.ndarray) -> np.ndarray:"""Daily new infections (the S->I flow) for a population of size N_pop."""def rhs(_t, y): S, I, R = y new = beta * S * I / N_popreturn [-new, new - gamma * I, gamma * I] S, I, R = solve_ivp(rhs, [t_grid[0], t_grid[-1]], [N_pop - I0, I0, 0.0], t_eval=t_grid, rtol=1e-7, atol=1e-7).yreturn beta * S * I / N_popdef nb_counts(mean: np.ndarray, k: float, rng: np.random.Generator) -> np.ndarray: mean = np.asarray(mean, dtype=float)return rng.negative_binomial(k, k / (k + mean)).astype(float)rng_x = np.random.default_rng(11)GAMMA_BIO =0.10# recovery rate = disease biology, shared across both countries# --- Mexico, last year: a full epidemic we can calibrate cleanly ---N_MX =20_000.0days_mx = np.arange(0, 141)mx_true =dict(beta=0.28, gamma=GAMMA_BIO, I0=20.0, rho=0.60, k=20.0)mx_cases = nb_counts(mx_true["rho"] * simulate_incidence(mx_true["beta"], mx_true["gamma"], mx_true["I0"], N_MX, days_mx), mx_true["k"], rng_x)def mx_neg_log_lik(z: np.ndarray) ->float: beta, gamma, I0 = np.exp(z[:3]); rho = sigmoid(z[3]); k = np.exp(z[4]) mean = rho * simulate_incidence(beta, gamma, I0, N_MX, days_mx) +1e-6 ll = nbinom.logpmf(mx_cases, k, k / (k + mean))return1e12ifnot np.all(np.isfinite(ll)) elsefloat(-ll.sum())mx_fit = minimize(mx_neg_log_lik, [np.log(0.3), np.log(0.1), np.log(20), 0.0, np.log(15)], method="Nelder-Mead", options=dict(xatol=1e-8, fatol=1e-8, maxiter=12000))mx_beta, mx_gamma, mx_I0 = np.exp(mx_fit.x[:3]); mx_rho = sigmoid(mx_fit.x[3]); mx_k = np.exp(mx_fit.x[4])mx_R0 = mx_beta / mx_gamma# The transferred prior on gamma: the Mexican Laplace uncertainty, *inflated* to allow# for the fact that Colombia is a different country (biology transfers, but not perfectly).def _gamma_nll(g): z = mx_fit.x.copy(); z[1] = np.log(g);return mx_neg_log_lik(z)_eps =1e-3gamma_laplace_sd =1.0/ np.sqrt((_gamma_nll(mx_gamma + _eps) -2* _gamma_nll(mx_gamma)+ _gamma_nll(mx_gamma - _eps)) / _eps **2)gamma_prior_mu = mx_gammagamma_prior_sd =max(6* gamma_laplace_sd, 0.012)print(f"Mexico calibrated: R0 = {mx_R0:.2f}, gamma = {mx_gamma:.3f} "f"(infectious period ~{1/mx_gamma:.0f} days)")print(f"transferred prior on gamma: N({gamma_prior_mu:.3f}, {gamma_prior_sd:.3f}) "f"[Mexican Laplace sd was {gamma_laplace_sd:.4f}]")
Mexico calibrated: R0 = 2.95, gamma = 0.093 (infectious period ~11 days)
transferred prior on gamma: N(0.093, 0.012) [Mexican Laplace sd was 0.0015]
Calibrating last year’s Mexican epidemic recovers \(R_0 \approx\) 2.95 and a recovery rate \(\gamma \approx\) 0.093 — an infectious period of about 11 days. Because we have Mexico’s entire curve, that \(\gamma\) is pinned down tightly. Widened to allow for a different country, it becomes our prior.
Code
# --- Colombia, this year: we only get to see the first stretch ---N_CO =15_000.0days_co = np.arange(0, 151)co_true =dict(beta=0.34, gamma=GAMMA_BIO, I0=8.0, rho=0.50, k=8.0)co_R0 = co_true["beta"] / co_true["gamma"]co_latent = simulate_incidence(co_true["beta"], co_true["gamma"], co_true["I0"], N_CO, days_co)co_cases_full = nb_counts(co_true["rho"] * co_latent, co_true["k"], rng_x)T_NOW =22seen, future = days_co <= T_NOW, days_co > T_NOWt_seen, y_seen = days_co[seen], co_cases_full[seen]k_obs =int(round(mx_k)) # borrow Mexico's surveillance dispersion# Strategy 1: transplant Mexico's calibrated parameters onto Colombia's population.naive_forecast = mx_rho * simulate_incidence(mx_beta, mx_gamma, co_true["I0"], N_CO, days_co)co_peak_day =int(days_co[np.argmax(co_latent)])print(f"Colombia true R0 = {co_R0:.1f}, peak on day {co_peak_day}; today is day {T_NOW}")
Colombia true R0 = 3.4, peak on day 30; today is day 22
Figure 6: Left: last year’s Mexican epidemic, observed to completion and cleanly calibrated. Right: this year in Colombia, we stand on day 22 — only the growth phase is visible (blue), while the true peak (day 30) and everything after (grey) are still in the future.
On day 22 the Colombian data is all growth — an exponential-looking climb that, as the identifiability discussion warned, barely distinguishes \(\beta\) from \(\gamma\) on its own. The true epidemic (dashed) will peak on day 30, but nothing in the observed dots forces that conclusion. We have three ways to forecast it:
Transplant Mexico’s parameters wholesale — fast, and wrong, because Colombia transmits faster.
Fit Colombia alone with vague priors — honest, but mid-growth the likelihood is a long \(\beta\)–\(\gamma\) ridge, so the forecast is barely constrained.
Bayesian calibration — keep a flexible local \(\beta\), but pin \(\gamma\) with the Mexican prior, which supplies exactly the information the growth phase lacks.
9.1 Why go Bayesian?
Training gave a point estimate; the calibration of Section 6 added a likelihood and a Laplace-approximated band. Going fully Bayesian — sampling the posterior rather than approximating it — buys three things that matter precisely in a spot like Colombia-on-day-22:
Priors are how outside knowledge enters. There is no other principled slot in the machinery to say “the infectious period is about ten days, because we measured it in Mexico last year.” A prior is exactly that sentence written as a distribution, uncertainty included. Transfer across outbreaks is a prior.
Priors regularize the unidentifiable. Mid-growth, the likelihood alone is a flat ridge in \((\beta, \gamma)\) — the data cannot separate transmission from recovery. A point estimator just picks a spot on that ridge; a Laplace approximation draws a tidy Gaussian blob that misrepresents a long, curved, open-ended one. Multiplying the ridge by the prior returns something both identified and honest.
You get the real posterior, not a Gaussian cartoon. Sampling captures the actual shape — skewed, correlated, bounded — and pushes all of it through the ODE into the forecast, so the predictive band inherits the true parameter correlations instead of assuming them away.
The cost is compute (thousands of ODE solves) and the chore of checking convergence. When parameters are well-identified and roughly Gaussian, the cheap Laplace calibration is a fine stand-in. When they are correlated, constrained, or informed by outside knowledge — as here — the Bayesian treatment is the one that stays honest.
We do the inference the first-principles way: a hand-written Metropolis–Hastings sampler. Each step proposes a small random move in \((\beta, \gamma, \rho, I_0)\), solves the SIR ODEs, and accepts or rejects by comparing the log-posterior (Negative-Binomial likelihood + prior) against the current point.
Figure 7: Left: the joint posterior over transmission and recovery. Colombia’s growth-only data leaves a long β–γ ridge (grey); the Mexican prior on γ collapses it onto the truth (star). Right: the recovery rate itself — the transferred prior (gold) anchors the posterior near the true γ = 0.10, while the vague fit wanders across implausible infectious periods.
The left panel is the whole story in one picture. Fit Colombia alone (grey) and the posterior smears along the \(\beta\)–\(\gamma\) ridge — transmission and recovery are confounded by growth-only data, exactly as warned earlier. Add the Mexican prior on \(\gamma\) (teal) and the cloud collapses onto the truth (star). The right panel shows \(\gamma\) directly: the transferred prior anchors the posterior near the true \(0.10\), while the vague version drifts across infectious periods from 5 to 30 days.
Figure 8: Forecasting Colombia from day 22. The naive transplant (orange) peaks late and low; the Colombia-only median (grey) undershoots; the Bayesian forecast with the Mexican prior (teal) tracks the true peak and decline, and its 95% band brackets the data we had not yet seen.
The payoff is the forecast. The naive transplant peaks days late and far too low — Colombia simply spreads faster than Mexico did. The Colombia-only median gets the timing closer but still undershoots the peak. The Bayesian forecast with the Mexican prior tracks the true trajectory through the unseen peak and decline: its median error over the future is about 37 cases/day versus 73 for the transplant, and its 95% band contains roughly 100% of the days we had not yet observed when the call was made. The band is wide — on day 22, honest uncertainty about the peak is genuinely large — but it brackets the truth rather than hiding it.
Figure 9: Metropolis chains for the Mexican-prior run (thinned). β and γ mix well and are stationary after the burn-in cutoff (dotted); ρ and I₀ wander more slowly — mid-epidemic, reporting and seeding are only weakly identified, and the chain says so honestly.
The traces are a quick honesty check on the sampler: \(\beta\) and \(\gamma\) mix well, while \(\rho\) and \(I_0\) drift more — a faithful signal that mid-growth data constrains transmission and recovery far better than it constrains reporting or the initial seed.
The practitioner’s lesson: transfer learning in mechanistic models is not “reuse the fitted object.” It is deciding which parameters are facts about the disease (transfer them as priors) and which are facts about the place (re-estimate them locally), then letting Bayesian calibration combine the two with the right amount of confidence in each.
9.2 The three approaches at a glance
We have now used all three on the same SIR skeleton. They form a ladder — each keeps what the previous one had and adds a layer of honesty about what it doesn’t know:
Aspect
Training
Calibration
Bayesian
Goal
Fit the observed curve
Fit the generative process + quantify uncertainty
Infer the full posterior and propagate it
Estimates
\(\beta, \gamma\) (a point)
\(\beta, \gamma, I_0, \rho, k\) (a point, by MLE)
A joint distribution over all of them
Objective
Mean squared error
Negative log-likelihood
Posterior \(\propto\) likelihood \(\times\) prior
Prior knowledge
none
none (flat)
encoded explicitly (e.g. transferred \(\gamma\))
Uncertainty
none
Gaussian (Laplace) approximation
full posterior, any shape (MCMC)
Output
one curve
curve + approximate band
calibrated predictive distribution
Unidentifiable params
picks an arbitrary point
reports a misleading blob
regularized by the prior
Cost
lowest
low
highest (sampling + convergence checks)
Reach for it when
a quick check, with clean and plentiful data
you need honest intervals and the params are identifiable
data is partial or correlated, or you have outside knowledge to inject
10 Beyond classical SIR
Everything above uses deterministic ODEs, simple likelihoods, and posteriors we approximated by hand (Laplace, then Metropolis). Real forecasting pipelines reach for heavier machinery when the problem demands it:
Bayesian calibration / MCMC. We hand-rolled a Metropolis sampler above; production work replaces it with more efficient posterior sampling (e.g. Hamiltonian Monte Carlo in Stan or PyMC). Worth it when the posterior is skewed or multimodal, parameters are correlated, or you want to encode prior knowledge — a known infectious period, or a neighbouring outbreak — rigorously.
Sequential Monte Carlo / particle filters. When data arrive in a stream and you need to update the state and parameters day by day, particle filters propagate a cloud of hypotheses forward and reweight them against each new observation — natural for nowcasting.
Ensemble Kalman filters. A scalable middle ground for high-dimensional compartmental models (many age groups, regions), trading exact inference for Gaussian approximations that stay tractable at scale.
Neural ODEs and Physics-Informed Neural Networks. When the mechanism is partly unknown, let a neural network learn residual dynamics or embed the ODE as a soft constraint in the loss — mechanistic structure where you trust it, flexibility where you don’t.
Probabilistic / ensemble forecasting. Operational systems (e.g. collaborative forecasting hubs) combine many models into calibrated ensemble predictive distributions, because no single model is reliably best across phases of an epidemic.
The through-line: each of these is a richer way to do calibration — to represent uncertainty and the observation process — not a fancier way to fit a curve.
11 Key takeaways
Six lessons
A good fit is not a good forecast. Our trained model matched the data and still recovered \(R_0 \approx 1.6\) against a true \(3.0\) — a completely different epidemic.
Calibration is a different task, not a better optimizer. It fits the observation process and noise, not just the dynamics, and returns distributions instead of points.
Uncertainty is a deliverable. A forecast without an interval can’t be planned around; the calibrated 95% band achieved near-nominal coverage on held-out data.
Validate along the arrow of time. Random splits and k-fold cross-validation leak the future into the past for ODE-driven data.
Model how the data were made. Under-reporting and over-dispersion aren’t nuisances to smooth over — ignoring them is exactly what corrupted the trained model’s \(R_0\).
Meaningful parameters travel. A Mexican prior on the recovery rate turned an unidentifiable Colombian growth curve into a usable forecast — because we transferred the biology (\(\gamma\)) and re-estimated the place (\(\beta\), reporting).
Common mistakes to avoid
Fitting case counts as if they were true infections (ignoring the reporting rate \(\rho\)).
Assuming constant-variance noise (MSE) when counts are strongly heteroscedastic.
Reporting a single trajectory with no uncertainty band.
Estimating parameters only during the exponential-growth phase, where \(\beta\) and \(\gamma\) are barely separable and \(R_0\) is nearly unidentifiable.
Using shuffled or k-fold splits and mistaking leaked information for skill.
Transplanting a whole fitted model to a new place or season instead of re-estimating the parameters that are local to it.
Practical recommendations
Always write down an explicit observation model; make \(\rho\) and the noise distribution parameters you estimate, not assumptions you bury.
Prefer a likelihood over squared error whenever you need intervals or the noise scales with the signal.
Score forecasts probabilistically (log-likelihood, CRPS, empirical coverage), not just by RMSE.
When adapting a model across regions or seasons, split parameters into transferable (biology) and local (place and behaviour): carry the first as priors, re-fit the second.
Treat point estimates as a starting point and calibration as the finish line.
11.1 Where to go next
SEIR models, adding an Exposed (latent, not-yet-infectious) compartment for diseases with an incubation period.
Bayesian inference with PyMC or Stan to get full posteriors and principled priors over epidemiological parameters.
Hierarchical / multi-region models that pool information across outbreaks (partial pooling) — the principled version of the Mexico → Colombia transfer we did by hand.
Stochastic epidemic models (chain-binomial, Gillespie simulations) for small populations where individual-level randomness — not just observation noise — drives the dynamics.