Tutorial for PanelPOMP Data Analysis using Pypomp: Supplement 2 for “Mechanistic models for panel data: Analysis of ecological experiments with four interacting species”

Authors
Affiliations

Bo Yang

Department of Marketing, Columbia University

Jesse Wheeler

Department of Mathematics and Statistics, Utah State University

Meghan A. Duffy

Department of Ecology and Evolutionary Biology, University of Michigan

Aaron A. King

Department of Ecology and Evolutionary Biology, University of Michigan

Edward Ionides

Department of Statistics, University of Michigan

Status

This tutorial extends the peer-reviewed article but is not itself peer-reviewed. It provides additional description of implementation details and data-analysis considerations that arose during the reported research. This tutorial is an accessory intended to support understanding and reproducibility.

Introduction

Ecological experiments often yield panel time series data, wherein multiple trajectories are observed across replicated experimental units. These replicates may exhibit correlation through shared parameters governing the underlying stochastic processes. This tutorial demonstrates the construction, estimation, and validation of mechanistic models for panel data using the partially observed Markov process (POMP) framework, in the Python package pypomp.

We analyze panel time series data from a controlled mesocosm experiment examining the population dynamics of two freshwater zooplankton species (Daphnia dentifera and D. lumholtzi), an algal food source (Ankistrodesmus falcatus), and a fungal parasite historically identified as Metschnikowia bicuspidata and subsequently described as Australozyma monospora (Lachance et al. 2025). The experiment, conducted by Searle et al. (2016), was designed to investigate how interspecific competition between the native D. dentifera and invasive D. lumholtzi is modified by their different susceptibility to the parasite.

The panel iterated filter (PIF) algorithm (Bretó, Ionides, and King 2020) enables plug-and-play likelihood-based inference for general PanelPOMP models. We employ both standard PIF and its marginalized variant, MPIF, to maximize likelihood functions. Profile likelihood confidence intervals are constructed using the Monte Carlo Adjusted Profile (MCAP) method (Ionides et al. 2017; Ning, Ionides, and Ritov 2021) to account for Monte Carlo uncertainty in likelihood evaluations. General PanelPOMP notation and algorithmic details are provided in Section S2 of the Supplement.

We also evaluate competing model specifications using Akaike’s Information Criterion, \(\text{AIC} = 2p - 2\ell(\hat{\theta})\), where \(p\) is the number of estimated parameters and \(\ell(\hat{\theta})\) is the maximized log-likelihood (Akaike 1974).

Section 1: SRJF Model

Experimental Design and Data Structure

The treatment consists of \(U = 10\) replicated mesocosms containing D. dentifera populations in the absence of parasites, each observed at \(N = 10\) time points \(t_{u,n} = 5n + 2\) days.

Show data processing and plotting code
# Load the dent-only sheet. The Excel file is in reverse chronological order
# within each rep (day 10 → day 1), so we sort (rep, day) ascending. See
# quality_reports/audits/DATA_SCHEMA.md for the full schema.
xls = pd.ExcelFile('../data/Mesocosmdata.xls')
srjf_raw = xls.parse('dent-only treatments').iloc[0:100].copy()
srjf_raw = srjf_raw.sort_values(['rep', 'day']).reset_index(drop=True)

srjf_raw['day'] = (srjf_raw['day'] - 1) * 5 + 7

srjf_data = srjf_raw[['rep', 'day', 'dent.adult', 'dent.juv']].copy()

trials = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J']
trial_to_mesocosm = {t: f"Mesocosm {i + 1}" for i, t in enumerate(trials)}

srjf_data['Mesocosm'] = srjf_data['rep'].map(trial_to_mesocosm)
srjf_data['Mesocosm'] = pd.Categorical(
    srjf_data['Mesocosm'],
    categories=[f"Mesocosm {i + 1}" for i in range(10)],
    ordered=True,
)

srjf_data['dent.adult.plot'] = srjf_data['dent.adult']
srjf_data['dent.juv.plot'] = srjf_data['dent.juv']

fig, axes = plt.subplots(
    2, 10,
    sharex=True,
    sharey='row',
    figsize=(10, 4),
    gridspec_kw={'hspace': 0.15, 'wspace': 0.25},
)

adult_color = PALETTE["adult"]
juv_color = PALETTE["juvenile"]
line_kwargs = dict(linewidth=0.8, linestyle='-')

for j, label in enumerate([f"Mesocosm {i + 1}" for i in range(10)]):
    sub = srjf_data[srjf_data['Mesocosm'] == label]

    ax_top = axes[0, j]
    ax_bot = axes[1, j]

    ax_top.plot(sub['day'], sub['dent.adult.plot'], color=adult_color, **line_kwargs)
    ax_bot.plot(sub['day'], sub['dent.juv.plot'], color=juv_color, **line_kwargs)

    ax_top.set_title(
        label.replace('Mesocosm ', 'Mesocosm-'),
        fontsize=FONTS["panel_title"],
    )

    ax_top.set_yscale('function', functions=(np.sqrt, np.square))
    ax_bot.set_yscale('function', functions=(np.sqrt, np.square))

    for ax in (ax_top, ax_bot):
        ax.set_xlim(0, 52)
        ax.set_xticks([0, 25, 50])
        ax.tick_params(axis='both', labelsize=FONTS["tick"])
        ax.spines['top'].set_visible(False)
        ax.spines['right'].set_visible(False)

        if j != 0:
            ax.tick_params(axis='y', which='both', labelleft=False)

    if j == 0:
        ax_top.set_ylabel(
            'Adult density\n(ind./L)',
            fontsize=FONTS["panel_title"],
        )
        ax_bot.set_ylabel(
            'Juvenile density\n(ind./L)',
            fontsize=FONTS["panel_title"],
        )

    ax_bot.set_xlabel('Day', fontsize=FONTS["panel_title"])

fig.align_ylabels(axes[:, 0])
fig.subplots_adjust(left=0.07, right=0.99, top=0.92, bottom=0.13)

plt.show()
Figure 1: Observed population densities for adult (top panel) and juvenile (bottom panel) D. dentifera across 10 replicate mesocosms. Square-root transformation applied to y-axes for visual clarity. Each panel represents an independent experimental unit.

Mechanistic Model

Model Specification

The SRJF model tracks adult D. dentifera (\(S^n\)), juveniles (\(J^n\)) and the algal food resource (\(F\)), driven by multiplicative noise on each state.

Biological Mechanisms

Juveniles mature into adults at rate \(\lambda_J = 0.1\); adults die at rate \(\theta^n_S\); reproduction is proportional to resource consumption \(f^n_S F S^n\); the resource is replenished at rate \(\mu = 0.37\) and diluted at \(\delta = 0.013\).

Parameter Specification

The estimated parameters are \(r^n\), \(f^n_S\), \(\theta^n_S\), \(\theta^n_J\), \(\sigma^n_J\), \(\sigma_F\) and \(k^n_S\). The adult process-noise scale \(\sigma^n_S\) is held at zero.

PanelPOMP Implementation Framework

Process Model Simulator

Show Euler-Maruyama process simulator
# State variables tracked by the process model. `error_count` is reset at every
# observation time via Pypomp's `accumvars` mechanism.
STATENAMES = ["Sn", "Jn", "F", "T_Sn", "error_count"]

# Canonical parameter ordering used throughout this section.
PARAM_NAMES = ["rn", "f_Sn", "theta_Sn", "theta_Jn",
               "sigSn", "sigJn", "sigF", "k_Sn"]


def srjf_rproc(X_, theta_, key, covars, t, dt):
    """One Euler-Maruyama step for the SRJF model."""
    Sn, Jn, F = X_["Sn"], X_["Jn"], X_["F"]
    error_count = X_["error_count"]
    sigSn, sigJn, sigF = theta_["sigSn"], theta_["sigJn"], theta_["sigF"]
    theta_Sn, theta_Jn = theta_["theta_Sn"], theta_["theta_Jn"]
    rn, f_Sn = theta_["rn"], theta_["f_Sn"]

    # Fixed experimental constants
    delta    = 0.013   # sampling/dilution rate (day^-1)
    mu_food  = 0.37    # algal replenishment (10^6 cells L^-1 day^-1)
    lambda_J = 0.1     # juvenile maturation rate (day^-1)
    xi_J     = 1.0     # juvenile filtration ratio

    # Independent Gaussian innovations with SD * sqrt(dt)
    k1, k2, k3 = jax.random.split(key, 3)
    sqdt = jnp.sqrt(dt)
    noiSn = sigSn * sqdt * jax.random.normal(k1)
    noiJn = sigJn * sqdt * jax.random.normal(k2)
    noiF  = sigF  * sqdt * jax.random.normal(k3)

    # Deterministic + stochastic increments
    Sn_term = (lambda_J * Jn * dt
               - theta_Sn * Sn * dt
               - delta    * Sn * dt
               + Sn * noiSn)
    Jn_term = (rn * f_Sn * F * Sn * dt
               - lambda_J * Jn * dt
               - theta_Jn * Jn * dt
               - delta    * Jn * dt
               + Jn * noiJn)
    F_term  = (-f_Sn * F * (Sn + xi_J * Jn) * dt
               - delta * F * dt
               + mu_food * dt
               + F * noiF)

    Sn_new = Sn + Sn_term
    Jn_new = Jn + Jn_term
    F_new  = F  + F_term

    # Boundary rule: clamp to the nearest bound.
    Sn_violated = (Sn_new < 0.0) | (Sn_new > 1e5)
    Jn_violated = (Jn_new < 0.0) | (Jn_new > 1e5)
    F_violated  = (F_new  < 0.0) | (F_new  > 1e20)

    # Clamp to whichever boundary was violated:
    #   Sn = (Sn < 0.0) ? 0.0 : 1e5
    Sn_new = jnp.where(Sn_violated, jnp.where(Sn_new < 0.0, 0.0, 1e5),  Sn_new)
    Jn_new = jnp.where(Jn_violated, jnp.where(Jn_new < 0.0, 0.0, 1e5),  Jn_new)
    F_new  = jnp.where(F_violated,  jnp.where(F_new  < 0.0, 0.0, 1e20), F_new)

    error_count_new = (error_count
                       + jnp.where(Sn_violated, 1.0,    0.0)
                       + jnp.where(Jn_violated, 0.001,  0.0)
                       + jnp.where(F_violated,  1000.0, 0.0))

    # Non-negative observable consumed by the measurement model.
    T_Sn_new = jnp.abs(Sn_new)

    return {
        "Sn": Sn_new,
        "Jn": Jn_new,
        "F":  F_new,
        "T_Sn": T_Sn_new,
        "error_count": error_count_new,
    }

Initial State Specification

Show initial-state specification
def srjf_rinit(theta_, key, covars, t0):
    """Deterministic initial conditions, identical across units."""
    return {
        "Sn":          jnp.array(3.0),
        "Jn":          jnp.array(0.0),
        "F":           jnp.array(16.667),
        "T_Sn":        jnp.array(0.0),
        "error_count": jnp.array(0.0),
    }

Measurement Model

Show measurement log-density (negative binomial)
def srjf_dmeas(Y_, X_, theta_, covars, t):
    """Negative binomial log-pmf of dent.adult given latent S^n."""
    y           = Y_["dentadult"]
    mu          = jnp.maximum(X_["T_Sn"], 1e-10)
    size        = jnp.maximum(theta_["k_Sn"], 1e-10)
    error_count = X_["error_count"]

    ll = (jax.scipy.special.gammaln(y + size)
          - jax.scipy.special.gammaln(size)
          - jax.scipy.special.gammaln(y + 1.0)
          + size * jnp.log(size / (size + mu))
          + y    * jnp.log(mu   / (size + mu)))

    # Soft penalty: replace ll on biologically implausible trajectories.
    return jnp.where(error_count > 0.0, -150.0, ll)
Show measurement sampler
def srjf_rmeas(X_, theta_, key, covars, t):
    """Sample dent.adult ~ NBinomial(mean = T_Sn, size = k_Sn)."""
    mu   = jnp.maximum(X_["T_Sn"], 1e-10)
    size = jnp.maximum(theta_["k_Sn"], 1e-10)
    k1, k2 = jax.random.split(key)
    scale = mu / size
    gamma_sample = jax.random.gamma(k1, size) * scale
    obs = jax.random.poisson(k2, gamma_sample)
    return jnp.array([obs], dtype=float)
Show parameter transformations (log)
# Parameters that are strictly positive and benefit from log-transform.
_LOG_PARAMS = ("rn", "f_Sn", "theta_Sn", "theta_Jn",
               "sigJn", "sigF", "k_Sn")


def srjf_to_est(theta):
    """Natural -> estimation scale (log for positive params)."""
    out = {**theta}
    for name in _LOG_PARAMS:
        out[name] = jnp.log(jnp.maximum(theta[name], 1e-30))
    # sigSn is fixed at 0; pass through untransformed.
    out["sigSn"] = theta["sigSn"]
    return out


def srjf_from_est(theta):
    """Estimation -> natural scale."""
    out = {**theta}
    for name in _LOG_PARAMS:
        out[name] = jnp.exp(theta[name])
    out["sigSn"] = theta["sigSn"]
    return out


srjf_par_trans = pp.ParTrans(to_est=srjf_to_est, from_est=srjf_from_est)
# Seeds the unit objects, and the starting values of the unit-specific
# MPIF search below.
parameters = {
    "rn":       2.121818e+02,
    "f_Sn":     4.271306e-04,
    "theta_Sn": 1.514775e-01,
    "theta_Jn": 1.633677e-02,
    "sigSn":    0.000000e+00,
    "sigJn":    2.688333e-01,
    "sigF":     1.459130e-03,
    "k_Sn":     8.917721e+01,
}
SRJF_PARAMS = list(parameters)
unit_names = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J']
t0_srjf = 0.0

srjf_pomp_dict = {}
for u in unit_names:
    sub = (srjf_data[srjf_data['rep'] == u][['day', 'dent.adult']]
           .rename(columns={'dent.adult': 'dentadult'})
           .sort_values('day'))
    ys_u = sub.set_index('day')[['dentadult']].astype(float)
    srjf_pomp_dict[u] = pp.Pomp(
        ys=ys_u, theta=pp.PompParameters(parameters), statenames=STATENAMES,
        t0=t0_srjf, rinit=srjf_rinit, rproc=srjf_rproc, dmeas=srjf_dmeas,
        rmeas=srjf_rmeas, par_trans=srjf_par_trans, dt=0.25,
        accumvars=("error_count",),
    )
print(f"Constructed {len(srjf_pomp_dict)} SRJF unit objects")
Constructed 10 SRJF unit objects
shared_parameter = {
    "rn":       1.535539e+03,
    "f_Sn":     1.306857e-04,
    "theta_Sn": 6.353239e-01,
    "theta_Jn": 1.376217e-03,
    "sigSn":    0.000000e+00,
    "sigJn":    3.018772e-01,
    "sigF":     8.658726e-07,
    "k_Sn":     1.417860e+01,
}


def shared_theta(d):
    """All-shared PanelParameters payload. The unit block is an empty frame
    whose columns carry the unit names, which is where Pypomp reads them."""
    return {
        "shared": pd.DataFrame({"shared": [d[k] for k in SRJF_PARAMS]},
                               index=SRJF_PARAMS),
        "unit_specific": pd.DataFrame(index=[], columns=unit_names),
    }


panelfood = pp.PanelPomp(Pomp_dict=srjf_pomp_dict,
                         theta=pp.PanelParameters(shared_theta(shared_parameter)))
print("Shared parameters:", panelfood.canonical_shared_param_names)
Shared parameters: ['rn', 'f_Sn', 'theta_Sn', 'theta_Jn', 'sigSn', 'sigJn', 'sigF', 'k_Sn']
# Seven parameters deliberately corrupted by factors of 1e2 to 1e14;
# theta_Sn and sigSn are left unchanged.
shared_parameter_wrong = {
    "rn":       1.535539e-03,
    "f_Sn":     1.306857e+04,
    "theta_Sn": 6.353239e-01,
    "theta_Jn": 1.376217e+03,
    "sigSn":    0.000000e+00,
    "sigJn":    3.018772e+01,
    "sigF":     8.658726e+07,
    "k_Sn":     1.417860e-01,
}
panelfood_wrong = pp.PanelPomp(
    Pomp_dict=srjf_pomp_dict,
    theta=pp.PanelParameters(shared_theta(shared_parameter_wrong)),
)
print("Mis-scaled panel constructed")
Mis-scaled panel constructed

Parameter Estimation via Panel Iterated Filtering

The run_level parameter controls computational intensity. Level 1 provides rapid prototyping, level 2 is the setting used for this document, and level 3 delivers production-quality estimates.

# Np -> evaluation particles, Mp -> MIF particles,
# Np_rep -> evaluation replicates.
algorithmic_params = {
    "Np":     [50,  600, 1000],
    "Np_rep": [ 2,   10,   20],
    "Mp":     [50,  500, 1000],
    "Nmif":   [ 2,  320,  250],
}
Np     = algorithmic_params["Np"][RL]
Np_rep = algorithmic_params["Np_rep"][RL]
Mp     = algorithmic_params["Mp"][RL]
Nmif   = algorithmic_params["Nmif"][RL]
print(f"run_level = {run_level}: Np={Np}, Np_rep={Np_rep}, Mp={Mp}, Nmif={Nmif}")
run_level = 2: Np=600, Np_rep=10, Mp=500, Nmif=320

The random walk standard deviation controls the magnitude of parameter perturbations during iterated filtering. We employ a uniform perturbation intensity across all parameters:

dent_rw_sd = 0.02                       # random-walk sd for the search
n_searches = 10 * N_WORKERS             # ten independent searches per worker

# Every candidate is dispersed log-uniformly over [theta/3, theta*3]; the
# undispersed vector is not retained as a start.
rng = np.random.default_rng(20260723)


def disperse(base, n):
    out = []
    for _ in range(n):
        cand = dict(base)
        for k, v in base.items():
            if v > 0:
                cand[k] = float(np.exp(rng.uniform(np.log(v / 3.0),
                                                   np.log(v * 3.0))))
        out.append(cand)
    return out


parameter_candidates = disperse(shared_parameter, n_searches)

# All eight parameters perturb at dent_rw_sd. sigSn is zero in value, so a
# log-scale perturbation leaves it at zero either way.
srjf_rw_sigmas = {k: dent_rw_sd for k in SRJF_PARAMS}
srjf_rw_sigmas["sigSn"] = 0.0
srjf_rw_sd = pp.RWSigma(sigmas=srjf_rw_sigmas,
                        init_names=[]).geometric_cooling(0.7)
print(f"{n_searches} independent searches, rw_sd = {dent_rw_sd}")
360 independent searches, rw_sd = 0.02

The unit-specific parameterization partitions the parameter vector into a shared component and a unit-specific component. Because this model contains unit-specific parameters, block=True selects marginalized panel iterated filtering (MPIF).

# Starting values come from `parameters`, not from the all-shared search
# result.
specific_names = ["theta_Sn"]
shared_names_uspec = [k for k in SRJF_PARAMS if k not in specific_names]
shared_parameter_uspec = {k: parameters[k] for k in shared_names_uspec}
specific_mat = pd.DataFrame(
    {u: [parameters[p] for p in specific_names] for u in unit_names},
    index=specific_names,
)


def uspec_theta(shared_d, spec_df):
    return {
        "shared": pd.DataFrame({"shared": [shared_d[k] for k in shared_names_uspec]},
                               index=shared_names_uspec),
        "unit_specific": spec_df.copy(deep=True),
    }


# `panelfood` is replaced here by the unit-specific object, which Diagnostic 1
# below filters. Fitting returns a new object and leaves this one at its
# STARTING values, so it must not be reassigned to a fitted panel afterwards.
# One parameter set throughout.
panelfood = pp.PanelPomp(
    Pomp_dict=srjf_pomp_dict,
    theta=pp.PanelParameters(uspec_theta(shared_parameter_uspec, specific_mat)),
)

# sigSn is omitted from the random walk entirely for the unit-specific search.
uspec_rw_sigmas = {k: dent_rw_sd for k in SRJF_PARAMS}
uspec_rw_sigmas["sigSn"] = 0.0
uspec_rw_sd = pp.RWSigma(sigmas=uspec_rw_sigmas,
                         init_names=[]).geometric_cooling(0.7)

uspec_payloads = [uspec_theta(shared_parameter_uspec, specific_mat)] * n_searches
ll_uspec, se_uspec, theta_uspec, traces_uspec = [], [], [], []
for i in range(0, n_searches, SEARCH_BATCH):
    part = uspec_payloads[i:i + SEARCH_BATCH]
    pnl = pp.PanelPomp(Pomp_dict=srjf_pomp_dict, theta=pp.PanelParameters(part))
    pnl.mif(J=Mp, M=Nmif, rw_sd=uspec_rw_sd, block=True,
            key=jax.random.key(303 + i))
    pnl.pfilter(J=Np, reps=Np_rep, key=jax.random.key(404 + i))
    a, b = summarise(pnl.results_history[-1].logLiks.values)
    ll_uspec.extend(a); se_uspec.extend(b)
    theta_uspec.extend(pnl.theta.params(as_list=True))
    t_u = pnl.traces()
    t_u["theta_idx"] = t_u["theta_idx"] + i
    traces_uspec.append(t_u)

# `mf` is reassigned here; `best` and the estimate are then taken from it.
mf = {"ll": np.array(ll_uspec), "se": np.array(se_uspec),
      "theta": theta_uspec, "traces": pd.concat(traces_uspec, ignore_index=True)}
best = int(np.nanargmax(mf["ll"]))
mif_estimate = mf["theta"][best]
pf_loglik_of_mif_estimate = float(mf["ll"][best])
se_of_pf_loglik_of_mif_estimate = float(mf["se"][best])
print(f"Unit-specific MPIF best logLik: {pf_loglik_of_mif_estimate:.2f} "
      f"(SE {se_of_pf_loglik_of_mif_estimate:.2f})")
Unit-specific MPIF best logLik: -490.02 (SE 0.24)

Diagnostic 1: Parameter Scaling Verification

To demonstrate the numerical pathologies induced by parameter mis-specification, we compare particle filter performance between the correctly scaled panelfood object and the deliberately corrupted panelfood_wrong object.

# Both filters use Np and Np_rep from algorithmic_params.
panelfood.pfilter(J=Np, reps=Np_rep, key=jax.random.key(505))
unit_ll_correct = np.asarray(
    panelfood.results_history[-1].logLiks.values)[0]     # (units, reps)
panelfood_wrong.pfilter(J=Np, reps=Np_rep, key=jax.random.key(606))
unit_ll_wrong = np.asarray(
    panelfood_wrong.results_history[-1].logLiks.values)[0]

# Aggregated arithmetically: mean(ll_vec), sd(ll_vec)/sqrt(n).
ll_correct_vec = unit_ll_correct.sum(axis=0)
ll_wrong_vec = unit_ll_wrong.sum(axis=0)
print("Well-scaled parameters:")
print(f"  Panel log-likelihood: {np.mean(ll_correct_vec):.2f} "
      f"(SE {np.std(ll_correct_vec, ddof=1) / np.sqrt(len(ll_correct_vec)):.2f})")
print("\nMis-scaled parameters:")
print(f"  Panel log-likelihood: {np.mean(ll_wrong_vec):.2f} "
      f"(SE {np.std(ll_wrong_vec, ddof=1) / np.sqrt(len(ll_wrong_vec)):.2f})")
print(f"\nLog-likelihood difference: "
      f"{np.mean(ll_correct_vec) - np.mean(ll_wrong_vec):.2f}")
Well-scaled parameters:
  Panel log-likelihood: -725.73 (SE 2.22)

Mis-scaled parameters:
  Panel log-likelihood: -15000.00 (SE 0.00)

Log-likelihood difference: 14274.27
# Connected markers, arithmetic row means, -Inf relocated.
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
unit_means_correct = unit_ll_correct.mean(axis=1)
unit_means_wrong = unit_ll_wrong.mean(axis=1)

axes[0].plot(range(1, 11), unit_means_correct, marker="o", linestyle="-",
             color="black")
axes[0].axhline(np.mean(unit_means_correct), color="red", linestyle="--",
                linewidth=2)
axes[0].set_xticks(range(1, 11)); axes[0].set_xticklabels(unit_names)
axes[0].set_xlabel("Unit"); axes[0].set_ylabel("Unit log-likelihood")
axes[0].set_title("Correctly Scaled Parameters")

finite = np.isfinite(unit_means_wrong)
wrong_plot = unit_means_wrong.copy()
if finite.any() and (~finite).any():
    span = np.ptp(unit_means_wrong[finite])
    wrong_plot[~finite] = unit_means_wrong[finite].min() - max(1.0, 0.05 * span)
axes[1].plot(range(1, 11), wrong_plot, marker="o", linestyle="-", color="black")
if finite.any():
    axes[1].axhline(np.mean(unit_means_wrong[finite]), color="red",
                    linestyle="--", linewidth=2)
for i in np.flatnonzero(~finite):
    axes[1].annotate("-Inf", (i + 1, wrong_plot[i]), xytext=(0, 5),
                     textcoords="offset points", ha="center", fontsize=8)
axes[1].set_xticks(range(1, 11)); axes[1].set_xticklabels(unit_names)
axes[1].set_xlabel("Unit"); axes[1].set_ylabel("Unit log-likelihood")
axes[1].set_title("Mis-Scaled Parameters")
fig.tight_layout()
Figure 2: Unit-level log-likelihood contributions under correctly scaled (left) and mis-scaled (right) parameters. Red dashed lines show the mean finite contribution.

Diagnostic 2: Evidence for Unit-Specific Parameterization

Figure 3 shows whether substantial heterogeneity in unit-level log-likelihoods under the all-shared model indicates that a single parameter vector inadequately describes all units:

# Arithmetic means, normalised axis, points and segments.
unit_ll_means = unit_ll_correct.mean(axis=1)
unit_ll_ses = unit_ll_correct.std(axis=1, ddof=1) / np.sqrt(Np_rep)
normalized = unit_ll_means - unit_ll_means.mean()
lower = unit_ll_means - 2 * unit_ll_ses - unit_ll_means.mean()
upper = unit_ll_means + 2 * unit_ll_ses - unit_ll_means.mean()

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(range(1, 11), normalized, "o", color="blue")
ax.vlines(range(1, 11), lower, upper, color="blue")
ax.axhline(0.0, linestyle="--", color="gray")
ax.set_xlabel("Unit"); ax.set_ylabel("Normalized log-likelihood")
ax.set_title("Unit-Level Likelihood Contributions")
fig.tight_layout()
Figure 3: Unit-level log-likelihood contributions under all-shared parameterization. Error bars show Monte Carlo standard errors.
p_shared = len(shared_parameter)                                    # 8
aic_shared = 2 * p_shared - 2 * pf_loglik_of_mif_estimate

# A second unit-specific search runs here without blocking, so this
# comparison uses standard PIF.
ll_specific_all, se_specific_all, _, _ = run_searches(
    [uspec_theta(shared_parameter_uspec, specific_mat)] * (2 * N_WORKERS),
    uspec_rw_sd, Mp, Nmif, Np, Np_rep, key0=707,
)
best_specific = int(np.nanargmax(ll_specific_all))
ll_specific = float(ll_specific_all[best_specific])
se_specific = float(se_specific_all[best_specific])

p_specific = len(shared_parameter_uspec) + specific_mat.size            # 7 + 10
aic_specific = 2 * p_specific - 2 * ll_specific

print(f"  All-shared:    p = {p_shared:2d}, logLik = "
      f"{pf_loglik_of_mif_estimate:9.2f}, AIC = {aic_shared:9.2f}")
print(f"  Unit-specific: p = {p_specific:2d}, logLik = {ll_specific:9.2f}, "
      f"AIC = {aic_specific:9.2f}")
print(f"  Delta AIC: {aic_specific - aic_shared:.2f}")
print(f"  Likelihood improvement: {ll_specific - pf_loglik_of_mif_estimate:.2f}")
print(f"  Improvement in SE units: "
      f"{(ll_specific - pf_loglik_of_mif_estimate) / np.sqrt(se_specific**2 + se_of_pf_loglik_of_mif_estimate**2):.2f}")
  All-shared:    p =  8, logLik =   -490.02, AIC =    996.05
  Unit-specific: p = 17, logLik =   -490.73, AIC =   1015.45
  Delta AIC: 19.40
  Likelihood improvement: -0.70
  Improvement in SE units: -2.50

Diagnostic 3: MIF searches visualization

Convergence traces from the independent searches are plotted against iteration number.

# Traces come from `mf`, that is, from the unit-specific search.
tr = mf["traces"]
tr = tr[(tr["unit"] == "shared") & (tr["method"] == "mif")]
tr = tr[np.isfinite(tr["logLik"])]

fig, ax = plt.subplots(figsize=(8, 5))
cmap = plt.get_cmap("turbo")
idx = sorted(tr["theta_idx"].unique())
# The set is seeded with the best run and then looped from index 2, so run 1
# is dropped and the best run appears twice.
best_trace = best if best in idx else idx[0]
plot_order = [best_trace] + [j for j in idx if j != idx[0]]
for n, j in enumerate(plot_order):
    sub = tr[tr["theta_idx"] == j]
    ax.scatter(sub["iteration"], sub["logLik"], s=6,
               color=cmap(n / max(1, len(plot_order) - 1)))
ax.set_xlabel("MIF iteration"); ax.set_ylabel("log-likelihood")
ax.set_title("MIF convergence traces")
fig.tight_layout()
Figure 4: MIF log-likelihood traces across independent searches.

Diagnostic 4: Monte Carlo Adjusted Profile

Monte Carlo Adjusted Profile (MCAP) methods provide confidence intervals for situations where the likelihood is evaluated and maximized by Monte Carlo algorithms. A smoothed estimate of the profile likelihood is used to reduce Monte Carlo error, quantify this error, and adjust the confidence intervals accordingly to maintain their coverage.

The following code demonstrates an example about running MCAP analysis on parameter \(\theta_{S_n}\).

# A distinct starting vector from the one used for estimation above.
shared_parameter_mcap = {
    "sigF":     1.116997e-05,
    "sigSn":    0.000000e+00,
    "f_Sn":     3.116171e-04,
    "rn":       3.369070e+02,
    "k_Sn":     6.257932e+01,
    "sigJn":    3.073612e-01,
    "theta_Sn": 2.436524e-01,
    "theta_Jn": 3.093602e-03,
}
shared_parameter_mcap = {k: shared_parameter_mcap[k] for k in SRJF_PARAMS}

nprof_sn = 20
_, subset_data_theta_Sn = run_profile("theta_Sn", nprof_sn,
                                      shared_parameter_mcap, seed=1000)
mcap_object_theta_Sn = pp.mcap(
    parameter=subset_data_theta_Sn["log_theta_Sn"].to_numpy(),
    loglik=subset_data_theta_Sn["loglik"].to_numpy(),
    level=0.95, span=0.6, n_grid=1000,
)
theta_Sn_mle = mcap_object_theta_Sn.mle
print(f"MCAP theta_Sn: MLE(log) = {theta_Sn_mle:.4f} -> "
      f"{np.exp(theta_Sn_mle):.4g}")
print(f"  95% CI (log): [{mcap_object_theta_Sn.ci[0]}, "
      f"{mcap_object_theta_Sn.ci[1]}]")
mcap_plot(subset_data_theta_Sn, "theta_Sn", mcap_object_theta_Sn,
          r"$\log(\theta^n_{S})$", "")
theta_Sn: 400 evaluations, logLik range [-601.1, -498.1], max terminal k_Sn 625.4
MCAP theta_Sn: MLE(log) = -0.2250 -> 0.7985
  95% CI (log): [-0.7458994544658233, 0.42498465688650455]
Figure 5: MCAP profile for the adult mortality rate theta_Sn on the log scale. Points are Monte Carlo profile evaluations; the red curve is the smoothed profile, dashed vertical lines are the adjusted 95% confidence limits, and the vertical solid line marks the MCAP estimate.

However, MCAP analysis does not uniformly yield well-defined confidence intervals across all parameters. To illustrate this variability in inferential precision, we examine the profile for the juvenile mortality rate \(\theta_{J_n}\).

nprof_jn = 50
_, subset_data_theta_Jn = run_profile("theta_Jn", nprof_jn,
                                      shared_parameter_mcap, seed=2000)

# Thin to 51 bins and keep the best point in each before calling mcap.
edges = np.linspace(subset_data_theta_Jn["log_theta_Jn"].min(),
                    subset_data_theta_Jn["log_theta_Jn"].max(), 51)
binned = subset_data_theta_Jn.assign(
    _bin=np.digitize(subset_data_theta_Jn["log_theta_Jn"], edges))
subset_data_theta_Jn = (binned.loc[binned.groupby("_bin")["loglik"].idxmax()]
                              .sort_values("log_theta_Jn")
                              .reset_index(drop=True))

mcap_object_theta_Jn = pp.mcap(
    parameter=subset_data_theta_Jn["log_theta_Jn"].to_numpy(),
    loglik=subset_data_theta_Jn["loglik"].to_numpy(),
    level=0.8, span=0.95, n_grid=1000,
)
print(f"MCAP theta_Jn: MLE(log) = {mcap_object_theta_Jn.mle:.4f}")
print(f"  80% CI (log): [{mcap_object_theta_Jn.ci[0]}, "
      f"{mcap_object_theta_Jn.ci[1]}]")
mcap_plot(subset_data_theta_Jn, "theta_Jn", mcap_object_theta_Jn,
          r"$\log(\theta^n_{J})$", "")
theta_Jn: 2500 evaluations, logLik range [-11363.7, -498.0], max terminal k_Sn 625.6
MCAP theta_Jn: MLE(log) = -8.0810
  80% CI (log): [None, None]
/user/by2418/Documents/pypomp/pypomp/mcap.py:272: RuntimeWarning: invalid value encountered in sqrt
  se_stat=float(np.sqrt(se_stat2)),
Figure 6: MCAP profile for the juvenile mortality rate theta_Jn on the log scale. The nearly flat profile indicates weak identification over the explored range.

The contrast between the profile likelihood shapes reveals fundamental differences in parameter identifiability. For \(\theta_{S_n}\) the smoothed profile exhibits pronounced curvature with a well-defined interior maximum, whereas the profile for \(\theta_{J_n}\) remains nearly flat across the explored parameter range, characteristic of weak or near-non-identification.

Section 2: SIRJPF2 Model

Experimental Design and Data Structure

The treatment consists of \(U = 8\) replicated mesocosms, each initialised with both host species in the presence of parasite inoculum, sampled at \(N = 10\) time points.

Show data load and plotting code
# Load sheet 3 ("both species combined"). The 'dent.ephip ' column has a
# trailing space; strip after read. Column-name and slicing details are in
# quality_reports/audits/DATA_SCHEMA.md.
xls = pd.ExcelFile('../data/Mesocosmdata.xls')
sirjpf_raw = xls.parse('both species combined').iloc[90:170].copy()
sirjpf_raw.columns = sirjpf_raw.columns.str.strip()
sirjpf_raw['day'] = (sirjpf_raw['day'] - 1) * 5 + 7

sirjpf_data = (sirjpf_raw[['rep', 'day', 'dent.adult', 'dent.inf',
                           'lum.adult', 'lum.adult.inf']]
               .sort_values(['rep', 'day'])
               .reset_index(drop=True))

sirjpf_unit_names = ['K', 'L', 'M', 'N', 'O', 'P', 'Q', 'S']
sirjpf_trial_to_mesocosm = {t: f"Mesocosm {i+1}"
                            for i, t in enumerate(sirjpf_unit_names)}
sirjpf_data['Mesocosm'] = sirjpf_data['rep'].map(sirjpf_trial_to_mesocosm)
sirjpf_data['Mesocosm'] = pd.Categorical(
    sirjpf_data['Mesocosm'],
    categories=[f"Mesocosm {i+1}" for i in range(8)],
    ordered=True,
)

# Four-row faceted plot. `sharey='row'` keeps each observation channel on a
# single comparable scale across the eight mesocosms, with y-axis tick labels
# only in the left column.
fig, axes = plt.subplots(
    4, 8,
    sharex=True,
    sharey='row',
    figsize=(10, 6),
    gridspec_kw={'hspace': 0.18, 'wspace': 0.20},
)

obs_cols = ['dent.adult', 'dent.inf', 'lum.adult', 'lum.adult.inf']
row_titles = [r"Native $S^n$",  r"Native $I^n$",
              r"Invasive $S^l$", r"Invasive $I^l$"]
row_colors = [PALETTE["adult"], PALETTE["infected"],
              PALETTE["lum_adult"], PALETTE["lum_inf"]]
row_styles = ['-', '-', '--', '--']

sqrt_scale = dict(value='function', functions=(np.sqrt, np.square))

for j, label in enumerate([f"Mesocosm {i+1}" for i in range(8)]):
    sub = sirjpf_data[sirjpf_data['Mesocosm'] == label]
    for i, col in enumerate(obs_cols):
        ax = axes[i, j]
        ax.plot(sub['day'], sub[col],
                color=row_colors[i], linestyle=row_styles[i], linewidth=0.7)
        ax.set_yscale(**sqrt_scale)
        ax.yaxis.set_major_locator(plt.MaxNLocator(nbins=3))
        ax.set_xlim(0, 52)
        ax.set_xticks([0, 25, 50])
        ax.tick_params(axis='both', labelsize=FONTS["tick"])
        ax.spines['top'].set_visible(False)
        ax.spines['right'].set_visible(False)
        if i == 0:
            ax.set_title(label.replace('Mesocosm ', 'Mesocosm-'),
                         fontsize=FONTS["panel_title"])
        if j == 0:
            ax.set_ylabel(row_titles[i] + '\n(ind./L)',
                          fontsize=FONTS["panel_title"])
        if i == 3:
            ax.set_xlabel('Day', fontsize=FONTS["panel_title"])

fig.align_ylabels(axes[:, 0])
fig.subplots_adjust(left=0.08, right=0.99, top=0.94, bottom=0.08)
Figure 7: The four adult observation streams used by the SIRJPF2 likelihood across \(U = 8\) replicate mesocosms: native susceptible, native infected, invasive susceptible, and invasive infected densities. Square-root y-axes enhance visibility at low densities.

Mechanistic Model

Model Specification

The SIRJPF2 model extends the SRJF baseline to the full ecological complexity of the Searle et al. (2016) mesocosm experiment: native D. dentifera and invasive D. lumholtzi compete for a shared algal food resource while both are exposed to A. monospora.

Biological Mechanisms

Susceptible adults of each species acquire infection through contact with free-living spores, infected adults die at elevated rates and release spores, and both species draw on the same algal resource.

Parameter Estimation via Panel Iterated Filtering

Marginalized panel iterated filtering (MPIF) is designed for panel models containing unit-specific as well as shared parameters. In Pypomp, block=True selects MPIF and changes the resampling treatment of unit-specific parameter particles across panel units. The marginalized-Bayes-map interpretation of MPIF is developed by Wheeler, Abkemeier, and Ionides (2025).

The SIRJPF2 specification used here contains only shared parameters. Consequently, it provides no unit-specific parameter particles for MPIF to marginalize, and a block=True versus block=False comparison is not informative for this model. We therefore use standard panel iterated filtering for SIRJPF2 and present the MPIF implementation in the unit-specific SRJF example above.

Below we present the complete implementation of standard panel iterated filtering for the SIRJPF2 model.

Show 8-state SDE simulator with parasite dynamics
# Eight latent states + four non-negative observables (T_*) + error_count.
SIRJPF_STATENAMES = [
    "Sn", "In", "Jn", "Si", "Ii", "Ji", "F", "P",
    "T_Sn", "T_In", "T_Si", "T_Ii", "error_count",
]


def sirjpf_rproc(X_, theta_, key, covars, t, dt):
    """One Euler-Maruyama step of the SIRJPF2 SDE system."""
    Sn, Jn, In = X_["Sn"], X_["Jn"], X_["In"]
    Si, Ji, Ii = X_["Si"], X_["Ji"], X_["Ii"]
    F, P = X_["F"], X_["P"]
    error_count = X_["error_count"]

    sigSn, sigIn = theta_["sigSn"], theta_["sigIn"]
    sigSi, sigIi = theta_["sigSi"], theta_["sigIi"]
    sigJn, sigJi = theta_["sigJn"], theta_["sigJi"]
    sigF, sigP   = theta_["sigF"],  theta_["sigP"]
    theta_Sn, theta_In = theta_["theta_Sn"], theta_["theta_In"]
    theta_Si, theta_Ii = theta_["theta_Si"], theta_["theta_Ii"]
    theta_Jn, theta_Ji = theta_["theta_Jn"], theta_["theta_Ji"]
    theta_P   = theta_["theta_P"]
    f_Sn, f_Si = theta_["f_Sn"], theta_["f_Si"]
    rn, ri     = theta_["rn"],   theta_["ri"]
    probn, probi = theta_["probn"], theta_["probi"]
    xi         = theta_["xi"]

    # Fixed experimental constants
    delta    = 0.013   # sampling/dilution rate
    mu_food  = 0.37    # algal replenishment
    lambda_J = 0.1     # juvenile maturation rate (both species)

    # Eight independent Gaussian innovations
    keys = jax.random.split(key, 8)
    sqdt = jnp.sqrt(dt)
    noiSn = sigSn * sqdt * jax.random.normal(keys[0])
    noiIn = sigIn * sqdt * jax.random.normal(keys[1])
    noiSi = sigSi * sqdt * jax.random.normal(keys[2])
    noiIi = sigIi * sqdt * jax.random.normal(keys[3])
    noiJn = sigJn * sqdt * jax.random.normal(keys[4])
    noiJi = sigJi * sqdt * jax.random.normal(keys[5])
    noiF  = sigF  * sqdt * jax.random.normal(keys[6])
    noiP  = sigP  * sqdt * jax.random.normal(keys[7])

    # Native species (n)
    Sn_term = (lambda_J * Jn * dt
               - theta_Sn * Sn * dt
               - probn * f_Sn * Sn * P * dt
               - delta * Sn * dt + Sn * noiSn)
    Jn_term = (rn * f_Sn * F * Sn * dt
               - lambda_J * Jn * dt
               - theta_Jn * Jn * dt
               - delta * Jn * dt + Jn * noiJn)
    In_term = (probn * f_Sn * Sn * P * dt
               - theta_In * In * dt
               - delta * In * dt + In * noiIn)

    # Invasive species (l, written `i` in code)
    Si_term = (lambda_J * Ji * dt
               - theta_Si * Si * dt
               - probi * f_Si * Si * P * dt
               - delta * Si * dt + Si * noiSi)
    Ji_term = (ri * f_Si * F * Si * dt
               - lambda_J * Ji * dt
               - theta_Ji * Ji * dt
               - delta * Ji * dt + Ji * noiJi)
    Ii_term = (probi * f_Si * Si * P * dt
               - theta_Ii * Ii * dt
               - delta * Ii * dt + Ii * noiIi)

    # Shared resources
    F_term = (- f_Sn * F * (Sn + xi * In + Jn) * dt
              - f_Si * F * (Si + xi * Ii + Ji) * dt
              - delta * F * dt
              + mu_food * dt + F * noiF)
    P_term = (30.0 * theta_In * In * dt
              + 30.0 * theta_Ii * Ii * dt
              - f_Sn * (Sn + xi * In) * P * dt
              - f_Si * (Si + xi * Ii) * P * dt
              - theta_P * P * dt
              - delta * P * dt + P * noiP)

    Sn_new = Sn + Sn_term
    In_new = In + In_term
    Jn_new = Jn + Jn_term
    Si_new = Si + Si_term
    Ii_new = Ii + Ii_term
    Ji_new = Ji + Ji_term
    F_new  = F  + F_term
    P_new  = P  + P_term

    # Add the inoculum exactly once on the transition beginning at day 4.
    inoculate = (t <= 4.0) & ((t + dt) > 4.0)
    P_new = P_new + jnp.where(inoculate, 25.0, 0.0)

    # Production reset rule with weighted error_count contributions.
    def viol(x, hi):
        return (x < 0.0) | (x > hi)

    eps = 0.0
    eps += jnp.where(viol(Sn_new, 1e5), 1.0,    0.0)
    eps += jnp.where(viol(Si_new, 1e5), 1.0e6,  0.0)
    eps += jnp.where(viol(F_new,  1e20), 1.0e3, 0.0)
    eps += jnp.where(viol(In_new, 1e5), 1.0e-3, 0.0)
    eps += jnp.where(viol(Ii_new, 1e5), 1.0e-9, 0.0)
    eps += jnp.where(viol(Jn_new, 1e5), 1.0e-3, 0.0)
    eps += jnp.where(viol(Ji_new, 1e5), 1.0e-9, 0.0)
    eps += jnp.where(viol(P_new,  1e20) & (t > 3.9), 1.0e-6, 0.0)

    Sn_new = jnp.where(viol(Sn_new, 1e5), 0.0, Sn_new)
    In_new = jnp.where(viol(In_new, 1e5), 0.0, In_new)
    Jn_new = jnp.where(viol(Jn_new, 1e5), 0.0, Jn_new)
    Si_new = jnp.where(viol(Si_new, 1e5), 0.0, Si_new)
    Ii_new = jnp.where(viol(Ii_new, 1e5), 0.0, Ii_new)
    Ji_new = jnp.where(viol(Ji_new, 1e5), 0.0, Ji_new)
    F_new  = jnp.where(viol(F_new, 1e20), 0.0, F_new)
    P_new  = jnp.where(viol(P_new, 1e20) & (t > 3.9), 0.0, P_new)

    return {
        "Sn": Sn_new, "In": In_new, "Jn": Jn_new,
        "Si": Si_new, "Ii": Ii_new, "Ji": Ji_new,
        "F":  F_new,  "P":  P_new,
        "T_Sn": jnp.abs(Sn_new), "T_In": jnp.abs(In_new),
        "T_Si": jnp.abs(Si_new), "T_Ii": jnp.abs(Ii_new),
        "error_count": error_count + eps,
    }
Show initial-state specification
def sirjpf_rinit(theta_, key, covars, t0):
    """Deterministic initial conditions; t0 = 1."""
    return {
        "Sn":          jnp.array(2.333),   # 35 / 15 L
        "In":          jnp.array(0.0),
        "Jn":          jnp.array(0.0),
        "Si":          jnp.array(0.667),   # 10 / 15 L
        "Ii":          jnp.array(0.0),
        "Ji":          jnp.array(0.0),
        "F":           jnp.array(16.667),  # 250e6 cells / 15 L
        "P":           jnp.array(0.0),
        "T_Sn":        jnp.array(0.0),
        "T_In":        jnp.array(0.0),
        "T_Si":        jnp.array(0.0),
        "T_Ii":        jnp.array(0.0),
        "error_count": jnp.array(0.0),
    }
Show 4-D negative-binomial log-density
def _sirjpf_nb_logpmf(y, mu, size):
    mu   = jnp.maximum(mu,   1e-10)
    size = jnp.maximum(size, 1e-10)
    return (jax.scipy.special.gammaln(y + size)
            - jax.scipy.special.gammaln(size)
            - jax.scipy.special.gammaln(y + 1.0)
            + size * jnp.log(size / (size + mu))
            + y    * jnp.log(mu   / (size + mu)))


def sirjpf_dmeas(Y_, X_, theta_, covars, t):
    """4-D NB log-pmf: dent.adult, dent.inf, lum.adult, lum.adult.inf."""
    error_count = X_["error_count"]
    ll_dent_adult = _sirjpf_nb_logpmf(Y_["dentadult"], X_["T_Sn"], theta_["k_Sn"])
    ll_dent_inf   = _sirjpf_nb_logpmf(Y_["dentinf"],   X_["T_In"], theta_["k_In"])
    ll_lum_adult  = _sirjpf_nb_logpmf(Y_["lumadult"],  X_["T_Si"], theta_["k_Si"])
    ll_lum_inf    = _sirjpf_nb_logpmf(Y_["luminf"],    X_["T_Ii"], theta_["k_Ii"])
    ll = ll_dent_adult + ll_dent_inf + ll_lum_adult + ll_lum_inf
    # Soft penalty when the simulator violated a state bound during the step.
    return jnp.where(error_count > 0.0, -150.0, ll)
Show 4-D measurement sampler
def _sirjpf_nb_sample(key, mu, size):
    mu   = jnp.maximum(mu,   1e-10)
    size = jnp.maximum(size, 1e-10)
    k1, k2 = jax.random.split(key)
    scale = mu / size
    g = jax.random.gamma(k1, size) * scale
    return jax.random.poisson(k2, g)


def sirjpf_rmeas(X_, theta_, key, covars, t):
    """Simulate the four-dimensional NB observation."""
    keys = jax.random.split(key, 4)
    y_dent_adult = _sirjpf_nb_sample(keys[0], X_["T_Sn"], theta_["k_Sn"])
    y_dent_inf   = _sirjpf_nb_sample(keys[1], X_["T_In"], theta_["k_In"])
    y_lum_adult  = _sirjpf_nb_sample(keys[2], X_["T_Si"], theta_["k_Si"])
    y_lum_inf    = _sirjpf_nb_sample(keys[3], X_["T_Ii"], theta_["k_Ii"])
    return jnp.array(
        [y_dent_adult, y_dent_inf, y_lum_adult, y_lum_inf], dtype=float,
    )
Show parameter transformations
# Every positive parameter is log-transformed. sigSn and sigSi are fixed at 0
# in MIF (rw_sd = 0) and therefore pass through to_est / from_est unchanged.
_SIRJPF_LOG_PARAMS = (
    "rn", "ri", "f_Sn", "f_Si", "probn", "probi", "xi",
    "theta_Sn", "theta_Si", "theta_In", "theta_Ii",
    "theta_Jn", "theta_Ji", "theta_P",
    "sigIn", "sigIi", "sigJn", "sigJi", "sigF", "sigP",
    "k_Sn", "k_Si", "k_In", "k_Ii",
)


def sirjpf_to_est(theta):
    """Natural -> estimation scale."""
    out = {**theta}
    for n in _SIRJPF_LOG_PARAMS:
        out[n] = jnp.log(jnp.maximum(theta[n], 1e-30))
    out["sigSn"] = theta["sigSn"]
    out["sigSi"] = theta["sigSi"]
    return out


def sirjpf_from_est(theta):
    """Estimation -> natural scale."""
    out = {**theta}
    for n in _SIRJPF_LOG_PARAMS:
        out[n] = jnp.exp(theta[n])
    out["sigSn"] = theta["sigSn"]
    out["sigSi"] = theta["sigSi"]
    return out


sirjpf_par_trans = pp.ParTrans(to_est=sirjpf_to_est, from_est=sirjpf_from_est)
Code
# Shared SIRJPF2 starting vector.
sirjpf_shared_theta = {
    "ri":       1.307600e+04,  "rn":       5.904676e+01,
    "f_Si":     1.838259e-05,  "f_Sn":     1.105668e-03,
    "probi":    3.110083e+01,  "probn":    2.565626e-01,
    "xi":       2.865620e+01,
    "theta_Sn": 1.479834e-01,  "theta_Si": 3.186040e-02,
    "theta_Ii": 3.531879e-01,  "theta_In": 5.489315e-01,
    "theta_P":  2.024991e-02,
    "theta_Ji": 1.299562e-04,  "theta_Jn": 1.532613e-04,
    "sigSn":    0.0,           "sigSi":    0.0,
    "sigIn":    3.063207e-04,  "sigIi":    2.208698e-02,
    "sigJi":    2.727418e-01,  "sigJn":    2.836891e-01,
    "sigF":     1.551729e-01,  "sigP":     2.385890e-01,
    "k_Ii":     1.241092e+00,  "k_In":     1.005756e+00,
    "k_Si":     4.715556e+00,  "k_Sn":     4.282648e+00,
}

sirjpf_unit_names_full = ['K', 'L', 'M', 'N', 'O', 'P', 'Q', 'S']
t0_sirjpf = 1.0  # 6-day pre-observation window; parasite inoculum at t = 4.

# Build one Pomp object per replicate.
sirjpf_pomp_dict = {}
for u in sirjpf_unit_names_full:
    sub = (sirjpf_data[sirjpf_data['rep'] == u]
           [['day', 'dent.adult', 'dent.inf', 'lum.adult', 'lum.adult.inf']]
           .rename(columns={
               'dent.adult':     'dentadult',
               'dent.inf':       'dentinf',
               'lum.adult':      'lumadult',
               'lum.adult.inf':  'luminf',
           })
           .sort_values('day'))
    ys_u = (sub.set_index('day')
              [['dentadult', 'dentinf', 'lumadult', 'luminf']]
              .astype(float))

    sirjpf_pomp_dict[u] = pp.Pomp(
        ys=ys_u,
        theta=pp.PompParameters(sirjpf_shared_theta),
        statenames=SIRJPF_STATENAMES,
        t0=t0_sirjpf,
        rinit=sirjpf_rinit,
        rproc=sirjpf_rproc,
        dmeas=sirjpf_dmeas,
        rmeas=sirjpf_rmeas,
        par_trans=sirjpf_par_trans,
        dt=0.25,
        accumvars=("error_count",),
    )

# All-shared parameterisation.
sirjpf_shared_df = pd.DataFrame(
    {"shared": list(sirjpf_shared_theta.values())},
    index=list(sirjpf_shared_theta.keys()),
)
sirjpf_unit_specific_df = pd.DataFrame(
    index=[], columns=sirjpf_unit_names_full,
)
panelfood_sirjpf = pp.PanelPomp(
    Pomp_dict=sirjpf_pomp_dict,
    theta=pp.PanelParameters(
        {"shared": sirjpf_shared_df,
         "unit_specific": sirjpf_unit_specific_df}
    ),
)
print(f"Number of units: {len(panelfood_sirjpf.unit_objects)}")
print(f"Shared parameters: {len(panelfood_sirjpf.canonical_shared_param_names)}")
Number of units: 8
Shared parameters: 26
# Section 2 uses its own algorithmic settings.
algorithmic_params_sirjpf = {
    "Np":     [50,  500, 1000],
    "Np_rep": [ 2,   10,   10],
    "Mp":     [50,  500, 1000],
    "Nmif":   [ 2,  300,  250],
}
Np_s     = algorithmic_params_sirjpf["Np"][RL]
Np_rep_s = algorithmic_params_sirjpf["Np_rep"][RL]
Mp_s     = algorithmic_params_sirjpf["Mp"][RL]
Nmif_s   = algorithmic_params_sirjpf["Nmif"][RL]

dent_rw_sd_sirjpf = 0.05                       # random-walk sd for Section 2
n_searches_sirjpf = 2 * N_WORKERS              # two searches per worker

# One identical start is replicated; the runs differ only through the RNG.
sirjpf_rw_sigmas = {k: dent_rw_sd_sirjpf for k in sirjpf_shared_theta}
sirjpf_rw_sigmas["sigSn"] = 0.0
sirjpf_rw_sigmas["sigSi"] = 0.0
sirjpf_rw_sd = pp.RWSigma(sigmas=sirjpf_rw_sigmas,
                          init_names=[]).geometric_cooling(0.7)

panelfood_sirjpf = pp.PanelPomp(
    Pomp_dict=sirjpf_pomp_dict,
    theta=pp.PanelParameters(
        [{"shared": sirjpf_shared_df.copy(deep=True),
          "unit_specific": sirjpf_unit_specific_df.copy(deep=True)}]
        * n_searches_sirjpf),
)
panelfood_sirjpf.mif(J=Mp_s, M=Nmif_s, rw_sd=sirjpf_rw_sd, block=False,
                     key=jax.random.key(1601))
panelfood_sirjpf.pfilter(J=Np_s, reps=Np_rep_s, key=jax.random.key(1602))
lls_sirjpf2, ses_sirjpf2 = summarise(
    panelfood_sirjpf.results_history[-1].logLiks.values)

best_sirjpf2 = int(np.nanargmax(lls_sirjpf2))
mif_estimate_sirjpf2 = panelfood_sirjpf.theta.params(as_list=True)[best_sirjpf2]
pf_loglik_of_mif_estimate_sirjpf2 = float(lls_sirjpf2[best_sirjpf2])
se_of_pf_loglik_sirjpf2 = float(ses_sirjpf2[best_sirjpf2])
print(f"SIRJPF2 best logLik: {pf_loglik_of_mif_estimate_sirjpf2:.2f} "
      f"(SE {se_of_pf_loglik_sirjpf2:.2f}) over {n_searches_sirjpf} searches")
SIRJPF2 best logLik: -884.83 (SE 0.34) over 72 searches

The SIRJPF2 searches use standard PIF because the fitted specification contains only shared parameters. The terminal parameter estimates are evaluated by replicated particle filters, and the search with the largest estimated panel log-likelihood is retained for subsequent inference.

Akaike, H. 1974. “A New Look at the Statistical Model Identification.” IEEE Transactions on Automatic Control 19 (6): 716–23.
Bretó, Carles, Edward L. Ionides, and Aaron A. King. 2020. “Panel Data Analysis via Mechanistic Models.” Journal of the American Statistical Association 115 (531): 1178–88. https://doi.org/10.1080/01621459.2019.1604367.
Ionides, Edward L, Carles Breto, Joonha Park, Richard A Smith, and Aaron A King. 2017. “Monte Carlo Profile Confidence Intervals for Dynamic Systems.” Journal of the Royal Society Interface 14: 1–10.
Lachance, Marc-André, Carla E. Cáceres, Molly J. Fredericks, Meghan A. Duffy, and Tara E. Stewart Merrill. 2025. “Reviving Élie Metschnikoff’s Monospora: The Obligately Parasitic Yeast Australozyma Monospora Sp. Nov.” FEMS Yeast Research 25: foaf041. https://doi.org/10.1093/femsyr/foaf041.
Ning, N., E. L. Ionides, and Y. Ritov. 2021. “Scalable Monte Carlo Inference and Rescaled Local Asymptotic Normality.” Bernoulli 27: 2532–55.
Searle, Catherine L, Michael H Cortez, Katherine K Hunsberger, Dylan C Grippi, Isabella A Oleksy, Clara L Shaw, Solanus B de la Serna, Chloe L Lash, Kailash L Dhir, and Meghan A Duffy. 2016. “Population Density, Not Host Competence, Drives Patterns of Disease in an Invaded Community.” The American Naturalist 188 (5): 554–66. https://doi.org/10.1086/688402.
Wheeler, Jesse, Aaron J. Abkemeier, and Edward L. Ionides. 2025. “Iterating Marginalized Bayes Maps for Likelihood Maximization.” In Preparation.