Measles: IF2 vs IFAD

Which algorithm reaches the higher likelihood for a comparable budget

Published

September 2, 2026

Show Code
import os
import sys
sys.path.append("..")
import report_utils as ru
from IPython.display import display, HTML, Markdown

display(HTML(f"<div style='margin-bottom: 20px;'>{ru.nav_bar('algorithms')}</div>"))

Introduction

This report compares two pypomp fitting pipelines on the UK measles model:

  1. IF2 — iterated filtering alone.
  2. IFAD — iterated filtering followed by gradient-based training.

Both arms start from the same points drawn from the same box, and IF2 is given enough extra iterations that the two arms cost roughly the same wall clock. The question is therefore not which is faster but which gets further for the money.

Model variant

Unlike the other measles reports, this one uses the continuous-time model (003) on London alone, not the discrete 001b model the R comparisons use. Gradient training needs a differentiable measurement model, which 001b does not provide. There is consequently no R baseline on this page.

  • R0: Basic reproduction number.
  • sigma: Rate of leaving the exposed class (1 / latent period).
  • gamma: Recovery rate (1 / infectious period).
  • iota: Rate of imported infections.
  • rho: Reporting probability.
  • sigmaSE: Intensity of extrademographic (environmental) noise.
  • psi: Reporting overdispersion.
  • cohort: Fraction of births entering the susceptible pool as a school-entry cohort.
  • amplitude: Amplitude of term-time seasonal forcing.
  • S_0, E_0, I_0, R_0: Initial compartment fractions.

Benchmark Settings & Environment

The table below summarizes algorithmic parameters, software environments, and compute hardware recorded in latest.json for each configuration.

Show Code
PLATFORMS = {
    "IF2": os.path.join("results", "if2"),
    "IFAD": os.path.join("results", "ifad"),
}

runs = ru.load_timing_data(PLATFORMS)
display(HTML(ru.build_settings_comparison_html(runs, is_panel=False)))
Setting / Parameter IF2 IFAD
Algorithmic & Workload Settings
Run Level 4 4
Starting Searches ($N_{starts}$) 36 36
IF2 Iterations ($N_{iter}$) 350 300
Training Iterations ($N_{train}$) 0 50
IF2 Particles ($N_p$) 5,000 5,000
Pfilter Particles ($N_{p,eval}$) 5,000 5,000
Evaluation Replicates ($N_{reps}$) 36 36
Random Seed 594709947 594709947
Software & Environment
Pomp Framework pypomp 1.0.0rc1 pypomp 1.0.0rc1
Backend / Engine JAX 0.11.1 JAX 0.11.1
Quant Git Commit 630cc23 630cc23
Run Timestamp 2026-09-02 19:29:44 2026-09-02 19:33:56
Hardware & Compute
Compute Device NVIDIA RTX PRO 6000 Blackwell Server Edition (1 GPU) NVIDIA RTX PRO 6000 Blackwell Server Edition (1 GPU)
Slurm Partition gpu-rtx6000 gpu-rtx6000
Slurm Job ID 59687777 59687781
Show Code
missing = [(label, r["dir"]) for label, r in runs.items() if not r["available"]]
for label, path in missing:
    display(HTML(f"<div class='alert alert-warning'><strong>Missing results for {label}:</strong> Expected at <code>{path}</code>.</div>"))
Show Code
import numpy as np
import pandas as pd
from plotnine import (
    ggplot, aes, geom_line, geom_density, facet_wrap, labs, theme
)

os.environ["JAX_PLATFORMS"] = "cpu"

ARMS = {"if2": "IF2", "ifad": "IFAD"}

meta = {arm: ru.load_json(os.path.join("results", arm, "latest.json")) for arm in ARMS}
final = {arm: ru.read_if_exists(os.path.join("results", arm, "results_final.csv")) for arm in ARMS}
timings = {arm: ru.read_if_exists(os.path.join("results", arm, "timings.csv")) for arm in ARMS}
traces = {
    arm: ru.load_traces(os.path.join("results", arm, "traces.csv.gz"), label)
    for arm, label in ARMS.items()
}

missing = [
    (label, os.path.join("results", arm, "results_final.csv"))
    for arm, label in ARMS.items()
    if final[arm] is None
]
have_final = not missing

Runtimes

Validation expectation

IF2 alone should take a little longer than IFAD overall — the point of the comparison is that IFAD reaches a better likelihood in the same or less time.

Show Code
rows = []
for arm, label in ARMS.items():
    tm = timings[arm]
    if tm is None:
        continue
    cfg = meta[arm].get("run_config", {})
    time_col = "time" if "time" in tm.columns else tm.columns[-1]
    per_stage = tm.groupby("method", sort=False)[time_col].sum() if "method" in tm.columns else {}
    n_mif = cfg.get("NFITR")
    n_train = cfg.get("NTRAIN") or 0
    rows.append({
        "Pipeline": label,
        "mif (s)": f"{per_stage.get('mif', np.nan):.1f}",
        "train (s)": f"{per_stage.get('train', 0.0):.1f}",
        "pfilter (s)": f"{per_stage.get('pfilter', np.nan):.1f}",
        "Total (s)": f"{tm[time_col].sum():.1f}",
        "mif iters": n_mif,
        "train iters": n_train,
        "s / mif iter": f"{per_stage.get('mif', np.nan) / n_mif:.3f}" if n_mif else "—",
        "s / train iter": f"{per_stage.get('train', 0.0) / n_train:.3f}" if n_train else "—",
    })

if rows:
    display(HTML(pd.DataFrame(rows).to_html(classes="table table-striped table-hover", index=False)))
else:
    display(Markdown("_No timings on disk._"))
Pipeline mif (s) train (s) pfilter (s) Total (s) mif iters train iters s / mif iter s / train iter
IF2 100.9 0.0 20.3 121.2 350 0 0.288
IFAD 88.1 122.8 21.0 231.9 300 50 0.294 2.457

Final log-likelihoods

Show Code
if have_final:
    summary = pd.DataFrame([
        {
            "Pipeline": label,
            "Starts": len(final[arm]),
            "Mean": final[arm]["logLik"].mean(),
            "Std": final[arm]["logLik"].std(),
            "Min": final[arm]["logLik"].min(),
            "Median": final[arm]["logLik"].median(),
            "Max": final[arm]["logLik"].max(),
        }
        for arm, label in ARMS.items()
    ]).round(2)
    display(HTML(summary.to_html(classes="table table-striped table-hover", index=False)))
else:
    display(Markdown("_No final results on disk._"))
Pipeline Starts Mean Std Min Median Max
IF2 36 -3817.56 3.67 -3831.57 -3816.90 -3812.13
IFAD 36 -3843.08 36.98 -3970.35 -3830.19 -3812.59
Show Code
if have_final:
    ll_df = pd.concat(
        [
            pd.DataFrame({"logLik": final[arm]["logLik"], "source": label})
            for arm, label in ARMS.items()
        ],
        ignore_index=True,
    )
    # A handful of starts stall far below the mode; clipping keeps the KDE readable.
    cutoff = ll_df["logLik"].median() - 5 * ll_df["logLik"].std()
    ll_df = ll_df[ll_df["logLik"] >= cutoff]

    display(
        ggplot(ll_df, aes(x="logLik", fill="source", color="source"))
        + geom_density(alpha=0.4)
        + labs(
            title="Final logLik density: IF2 vs IFAD",
            subtitle="Evaluated likelihood at each start's final parameter vector",
            x="Log-Likelihood", y="Density", fill="Pipeline", color="Pipeline",
        )
        + ru.scale_fill_premium()
        + ru.scale_color_premium()
        + ru.theme_premium
    )


Final parameter estimates

Validation expectation

IFAD tends to yield parameter estimates with lower Monte Carlo variance than IF2 alone, so its density curves should be the narrower ones.

Show Code
if have_final:
    param_cols = [
        c for c in final["if2"].columns if c not in ("theta_idx", "logLik", "se", "unit")
    ]
    params_df = pd.concat(
        [
            final[arm][param_cols]
            .melt(var_name="quantity", value_name="param_value")
            .assign(source=label)
            for arm, label in ARMS.items()
        ],
        ignore_index=True,
    )

    display(
        ggplot(params_df, aes(x="param_value", fill="source", color="source"))
        + geom_density(alpha=0.4)
        + facet_wrap("quantity", scales="free", ncol=3)
        + labs(
            title="Final parameter estimates: IF2 vs IFAD",
            subtitle="Density across all starts",
            x="Estimated value", y="Density", fill="Pipeline", color="Pipeline",
        )
        + ru.scale_fill_premium()
        + ru.scale_color_premium()
        + ru.theme_premium
        + theme(figure_size=(10, 10), panel_spacing=0.02)
    )


Search traces

Show Code
have_traces = all(t is not None for t in traces.values())
if have_traces:
    long = {arm: ru.to_long(traces[arm]) for arm in ARMS}
Validation expectation

Under IFAD, most parameters should tighten once the search switches from mif to train. Several parameters in this model are weakly identified, so tight convergence is not expected everywhere.

Show Code
if have_traces:
    display(
        ggplot(long["if2"], aes(x="iter", y="param_value", group="rep", color="factor(rep)"))
        + geom_line(show_legend=False, alpha=0.5)
        + facet_wrap("quantity", scales="free_y", ncol=3)
        + labs(
            title="IF2 parameter traces",
            subtitle="Individual start paths across iterations",
            x="Iteration", y="Parameter value",
        )
        + ru.theme_premium
        + theme(figure_size=(10, 10), panel_spacing=0.02)
    )
else:
    display(Markdown("_No traces on disk._"))

Show Code
if have_traces:
    display(
        ggplot(long["ifad"], aes(x="iter", y="param_value", group="rep", color="factor(rep)"))
        + geom_line(show_legend=False, alpha=0.5)
        + facet_wrap("quantity", scales="free_y", ncol=3)
        + labs(
            title="IFAD parameter traces",
            subtitle="Individual start paths across mif and train iterations",
            x="Iteration", y="Parameter value",
        )
        + ru.theme_premium
        + theme(figure_size=(10, 10), panel_spacing=0.02)
    )
else:
    display(Markdown("_No traces on disk._"))

Show Code
if have_traces:
    ll_traces = pd.concat(
        [
            ru.thin(traces[arm])[["rep", "iter", "logLik", "source"]].dropna(subset=["logLik"])
            for arm in ARMS
        ],
        ignore_index=True,
    )
    display(
        ggplot(ll_traces, aes(x="iter", y="logLik", group="rep", color="factor(rep)"))
        + geom_line(show_legend=False, alpha=0.6)
        + facet_wrap("source", ncol=1, scales="free_x")
        + labs(
            title="logLik traces",
            subtitle="Log-likelihood trajectory of each start",
            x="Iteration", y="Log-Likelihood",
        )
        + ru.theme_premium
        + theme(figure_size=(8, 8))
    )
else:
    display(Markdown("_No traces on disk._"))