End-to-end system identification: a symbolic ODE modelΒΆ

This notebook identifies the parameters of an epidemic model written as a system of differential equations with SymPy, and asks the same question its user-defined companion asks: once the model fits, can the parameters actually be recovered?

Here the answer depends on when you stopped collecting data, which makes it a concrete demonstration that identifiability is a property of the experiment and not only of the model.

A MATLAB Live Script version (Examples/sir_symbolic.m) runs the same computation with the same section numbering. Throughout, # MATLAB: comments give the equivalent toolbox call.

The modelΒΆ

The classical SIR system for a closed population of $N=1000$:

$$\frac{dS}{dt}=-\beta\frac{SI}{N},\qquad \frac{dI}{dt}=\beta\frac{SI}{N}-\gamma I,\qquad \frac{dR}{dt}=\gamma I$$

Two factors are to be identified: $\beta$ (transmission rate, 1/day) and $\gamma$ (recovery rate, 1/day). Their ratio is the basic reproduction number $R_0=\beta/\gamma$, the quantity an epidemiologist actually wants.

Only the infected compartment $I(t)$ is observed, and the initial conditions are known: one infected individual in an otherwise susceptible population.

The three expressions below are the whole model.

InΒ [1]:
import warnings
warnings.filterwarnings("ignore")

import numpy as np
import pandas as pd
import sympy as sp
import matplotlib.pyplot as plt

from gsua_csb import (SymbolicODEModel, design_matrix, uncertainty_analysis,
                      coverage_metric, parameter_estimation,
                      identifiability_analysis, profile_likelihood)

N = 1000.0                                  # closed population
t = sp.Symbol("t")                          # time symbol
S, I, R = sp.symbols("S I R")               # state variables
beta, gamma = sp.symbols("beta gamma")      # parameters to identify

# One right-hand side per state, in the same order as the state list below.
# MATLAB: odes = [diff(S) == -beta*S*I/N; diff(I) == beta*S*I/N - gamma*I; diff(R) == gamma*I]
odes = [-beta * S * I / N,                  # dS/dt  susceptible
         beta * S * I / N - gamma * I,      # dI/dt  infected   (this is what we observe)
         gamma * I]                         # dR/dt  recovered

odes
Out[1]:
[-0.001*I*S*beta, 0.001*I*S*beta - I*gamma, I*gamma]

1. Preparing the environmentΒΆ

For a symbolic model the factor vector is the initial conditions of the states first, in the order the states are listed, followed by the model parameters β€” the same convention MATLAB uses. Giving a factor a degenerate range fixes it, so the five rows below leave only $\beta$ and $\gamma$ free.

The model integrates all three states, but only $I(t)$ is measured. output=1 selects it once, at construction, and every routine that follows honours it β€” the counterpart of MATLAB's 'output',2 argument to gsua_dataprep, or of editing T.Properties.CustomProperties.output afterwards. It can be changed at any time with model.set_output(...).

InΒ [2]:
NAMES  = ["S0", "I0", "R0i", "beta", "gamma"]
truth  = np.array([999.0, 1.0, 0.0, 0.35, 0.10])   # S0, I0, R0, then beta, gamma
ranges = np.array([[999, 999],      # S0    susceptible at t=0  (known -> degenerate)
                   [1,   1],        # I0    infected at t=0     (known -> degenerate)
                   [0,   0],        # R0    recovered at t=0    (known -> degenerate)
                   [0.15, 0.9],     # beta  to estimate
                   [0.02, 0.4]])    # gamma to estimate
FREE = [3, 4]                                       # indices of beta and gamma


def build_model(domain):
    """Build the SIR model over a given time domain, observing I(t) only.

    MATLAB: [T,~] = gsua_dataprep(odes, vars, [0 tmax], 'sirEpidemicModel',
                                  'range', ranges, 'output', 2)
    """
    return SymbolicODEModel(odes, [S, I, R], t, [beta, gamma], domain=domain,
                            names=NAMES, range=ranges, nominal=truth,
                            output=1)                # <-- observe state 1, I(t)


model_full = build_model(np.linspace(1, 80, 20))
print("free factors:", [NAMES[i] for i in FREE], " | true R0 =", truth[3] / truth[4])
print("evaluate returns", model_full.evaluate(truth).shape, "-- one row, because output=1")
free factors: ['beta', 'gamma']  | true R0 = 3.4999999999999996
evaluate returns (1, 20) -- one row, because output=1

2. Synthetic dataΒΆ

The true epidemic uses $\beta=0.35$ and $\gamma=0.10$, so $R_0=3.5$. Case counts are noisier when they are larger, so the measurements carry Poisson-like noise whose spread grows with the count.

InΒ [3]:
xfull = np.linspace(1, 80, 20)              # 80 days of surveillance, 20 reports

rng = np.random.default_rng(3)              # fix the noise draw so the page reproduces
# MATLAB: cleanFull = gsua_eval(truth, T, xfull, [], false, false);
clean_full = model_full.evaluate(truth, xfull)              # already just I(t)
yfull = clean_full + np.sqrt(np.maximum(clean_full, 1)) * rng.standard_normal(clean_full.shape)

tdense = np.linspace(1, 80, 300)            # dense grid, for drawing curves only

plt.figure(figsize=(6.4, 3.8))
plt.plot(tdense, model_full.evaluate(truth, tdense)[0], lw=1.6, label="true epidemic")
plt.plot(xfull, yfull[0], "ko", ms=5, label="surveillance data")
plt.axvline(25, ls="--", c="grey")
plt.text(26, 300, "end of early phase", fontsize=9, color="grey")
plt.xlabel("time (days)"); plt.ylabel("infected individuals I(t)")
plt.title("Simulated epidemic, $R_0$ = 3.5")
plt.legend(); plt.grid(alpha=.3); plt.tight_layout(); plt.show()
No description has been provided for this image

3. Can these parameters be estimated at all?ΒΆ

Before spending any optimizer budget it is worth asking whether the data is even reachable: does it fall inside the range of epidemics the model can produce over the $\beta$ and $\gamma$ bounds declared in section 1? If it does not, no amount of optimization will help. This is the reachability check that opens the toolbox's semi-automated identification cycle.

InΒ [4]:
# MATLAB: M0 = gsua_dmatrix(T,300);  Y0 = gsua_ua(M0,T,'xdata',xfull,'ynom',yfull);
M0 = design_matrix(model_full, 300, seed=0)     # 300 samples of the (beta, gamma) box
ua0 = uncertainty_analysis(model_full, M0, xdata=xfull, y_exp=yfull)

cost_data, cost_band, p5, _, p95 = coverage_metric(ua0.Y, yfull, margin=0.1)
contained = float(np.mean((yfull >= p5) & (yfull <= p95)))

pd.DataFrame({"contained": [contained],
              "median_band_width": [float(np.median(p95 - p5))],
              "cost_data": [cost_data],
              "cost_band": [cost_band]}).round(4)
Out[4]:
contained median_band_width cost_data cost_band
0 0.9 313.9139 12219.2235 1.440196e+17
InΒ [5]:
plt.figure(figsize=(6.4, 3.8))
plt.fill_between(xfull, p5[0], p95[0], color="0.85")
plt.plot(xfull, p5[0], color="0.4", lw=1, label="5th percentile")
plt.plot(xfull, p95[0], color="0.4", lw=1, label="95th percentile")
plt.plot(xfull, yfull[0], "ko", ms=5, label="surveillance data")
plt.xlabel("time (days)"); plt.ylabel("infected individuals I(t)")
plt.title(f"Reachable band before fitting ({contained:.0%} of data contained)")
plt.legend(); plt.grid(alpha=.3); plt.tight_layout(); plt.show()
No description has been provided for this image

Nearly all the observations fall inside the reachable band, so an epidemic of this shape is within the model's declared range and estimation is worth attempting. The band itself spans much of the population β€” far too wide to be an answer β€” which is the uncertainty the next sections set out to reduce.

4. Estimating from the full epidemicΒΆ

With the whole curve in hand β€” growth, peak and decline β€” both factors are estimated by multistart least squares, twenty restarts.

InΒ [6]:
# MATLAB: [Tfull,resFull] = gsua_pe(T, xfull, yfull, 'solver','lsqc','N',20,'margin',0.1)
pe_full = parameter_estimation(model_full, xfull, yfull, n=20,
                               solver="least_squares", margin=0.1, seed=0)
best_full = pe_full.x[np.argmin(pe_full.cost)]
ia_full = identifiability_analysis(model_full, pe_full.x, cost=pe_full.cost,
                                   cost_rtol=0.1, seed=0)
# margin = assumed relative standard deviation. MATLAB offsets this by one: margin=1.1 there.
pl_full = profile_likelihood(model_full, xfull, yfull, alpha=0.95, margin=0.1, params=FREE)

pd.DataFrame({
    "true":      truth[FREE],
    "estimated": best_full[FREE].round(4),
    "CI_low":    pl_full.range[FREE, 0].round(4),
    "CI_high":   pl_full.range[FREE, 1].round(4),
    "width":     (pl_full.range[FREE, 1] - pl_full.range[FREE, 0]).round(4),
}, index=["beta", "gamma"])
Out[6]:
true estimated CI_low CI_high width
beta 0.35 0.3442 0.3189 0.3556 0.0367
gamma 0.10 0.0996 0.0966 0.1429 0.0463
InΒ [7]:
print(f"cost          : {pe_full.cost.min():.5g}")
print(f"R0 estimated  : {best_full[3] / best_full[4]:.3f}    (true 3.500)")
cost          : 3979.1
R0 estimated  : 3.454    (true 3.500)

5. Estimating from the early phase onlyΒΆ

Now suppose the analysis had to be done during the outbreak, with only the first 25 days available β€” the situation every real-time epidemic assessment faces. Nothing about the model changes; only the observation window shrinks.

InΒ [8]:
xearly = np.linspace(1, 25, 12)             # 25 days, 12 reports
model_early = build_model(xearly)           # same system, shorter domain

rng = np.random.default_rng(3)
clean_early = model_early.evaluate(truth, xearly)
yearly = clean_early + np.sqrt(np.maximum(clean_early, 1)) * rng.standard_normal(clean_early.shape)

pe_early = parameter_estimation(model_early, xearly, yearly, n=20,
                                solver="least_squares", margin=0.1, seed=0)
best_early = pe_early.x[np.argmin(pe_early.cost)]
ia_early = identifiability_analysis(model_early, pe_early.x, cost=pe_early.cost,
                                    cost_rtol=0.1, seed=0)
pl_early = profile_likelihood(model_early, xearly, yearly, alpha=0.95, margin=0.1, params=FREE)

pd.DataFrame({
    "true":      truth[FREE],
    "estimated": best_early[FREE].round(4),
    "CI_low":    pl_early.range[FREE, 0].round(4),
    "CI_high":   pl_early.range[FREE, 1].round(4),
    "width":     (pl_early.range[FREE, 1] - pl_early.range[FREE, 0]).round(4),
}, index=["beta", "gamma"])
Out[8]:
true estimated CI_low CI_high width
beta 0.35 0.4062 0.2336 0.4845 0.2509
gamma 0.10 0.1461 0.0200 0.2437 0.2237

The estimates still look plausible. Note the cost, though: the early-phase fit is better than the full-epidemic fit was β€” fewer points, all of them on a smooth exponential rise β€” while the intervals have widened.

6. Diagnosing identifiabilityΒΆ

The correlation between the repeated estimates explains what happened.

InΒ [9]:
pd.DataFrame({
    "full_epidemic": [ia_full.correlation[3, 4]],
    "early_phase":   [ia_early.correlation[3, 4]],
}, index=["corr(beta, gamma)"]).round(4)
Out[9]:
full_epidemic early_phase
corr(beta, gamma) 0.913 1.0

In the early phase it is essentially $1$. During exponential growth the data constrain only the growth rate, roughly $\beta-\gamma$, so any pair with the right difference reproduces the observations equally well. It takes the peak β€” where susceptibles are depleted and the curve turns over β€” to separate the two.

7. The two windows side by sideΒΆ

InΒ [10]:
pd.DataFrame({
    "full epidemic": [pe_full.cost.min(), ia_full.correlation[3, 4],
                      pl_full.range[3, 1] - pl_full.range[3, 0],
                      pl_full.range[4, 1] - pl_full.range[4, 0],
                      best_full[3] / best_full[4]],
    "early phase":   [pe_early.cost.min(), ia_early.correlation[3, 4],
                      pl_early.range[3, 1] - pl_early.range[3, 0],
                      pl_early.range[4, 1] - pl_early.range[4, 0],
                      best_early[3] / best_early[4]],
}, index=["best cost", "corr(beta, gamma)", "CI width beta",
          "CI width gamma", "R0 (true 3.5)"]).round(4)
Out[10]:
full epidemic early phase
best cost 3979.0835 1089.0670
corr(beta, gamma) 0.9130 1.0000
CI width beta 0.0367 0.2509
CI width gamma 0.0463 0.2237
R0 (true 3.5) 3.4545 2.7809
InΒ [11]:
fig, ax = plt.subplots(1, 2, figsize=(11, 3.8))

x = np.arange(2); w = 0.35
ax[0].bar(x - w/2, [pl_full.range[i, 1] - pl_full.range[i, 0] for i in FREE], w,
          label="full epidemic")
ax[0].bar(x + w/2, [pl_early.range[i, 1] - pl_early.range[i, 0] for i in FREE], w,
          label="early phase")
ax[0].set_xticks(x); ax[0].set_xticklabels([r"$\beta$", r"$\gamma$"])
ax[0].set_ylabel("95% CI width"); ax[0].set_title("Confidence interval width")
ax[0].legend(); ax[0].grid(alpha=.3, axis="y")

ax[1].plot(tdense, model_full.evaluate(best_full, tdense)[0], lw=1.6, label="fit to full epidemic")
ax[1].plot(tdense, model_full.evaluate(best_early, tdense)[0], "--", lw=1.6,
           label="fit to early phase, extrapolated")
ax[1].plot(xfull, yfull[0], "ko", ms=4, label="data")
ax[1].set_xlabel("time (days)"); ax[1].set_ylabel("infected individuals I(t)")
ax[1].set_title("Where the early-phase fit leads")
ax[1].legend(fontsize=8); ax[1].grid(alpha=.3)
plt.tight_layout(); plt.show()
No description has been provided for this image

8. What this example showsΒΆ

The same model and the same estimator produced two very different states of knowledge, and the difference was the observation window rather than anything about the algorithm. The better-fitting dataset was the less informative one.

Fit quality measures agreement with the points you have; identifiability measures whether those points could have distinguished your parameters from the alternatives. They are different questions, and only the second tells you whether an estimate is worth reporting.

For a real outbreak the consequence follows directly: an $R_0$ estimated before the peak carries a confidence interval wide enough to change policy conclusions, and quoting the point estimate alone would hide that.

The companion user-defined example reaches the same conclusion from the opposite direction: there, adding information by fixing a known factor makes the fit slightly worse and the parameters recoverable.

Where a multistart run does spread across the factor space, identifiability_analysis adds detection of multiple global minima, noise_floor calibrates which fits to accept against the observation noise, and design_matrix(..., method="joint") propagates the accepted set without destroying its correlation structure.