This report checks the particle filter’s likelihood estimate by running it many times at a single fixed parameter vector for each model. Only the random seed varies across replicates, so any systematic difference between pypomp and R (pomp, or panelPomp for the panel) would be a difference between the implementations.
Dhaka cholera: the published maximum likelihood estimate (MLE) that pomp uses as the default for dacca().
Measles: the He et al. (2010) MLEs for London, the largest unit, and Halesworth, one of the smallest. pypomp runs once in single and once in double precision. Since pomp uses double precision throughout, the 64-bit run is the direct comparison, while the 32-bit run shows the effect of pypomp’s default precision.
Panel measles: the He et al. (2010) MLEs for London, Halesworth, Hastings and Cardiff. The parameters R0, sigma, gamma, sigmaSE, cohort and amplitude are shared across units and set to the average of the four towns’ estimates, while iota, rho, psi, S_0, E_0, I_0 and R_0 are unit-specific. Since the panel log-likelihood is the sum of the unit log-likelihoods, we compare each unit separately.
Why the panel covariates are imported from pomp
The Python and R smoothing splines interpolate the birth rate and population covariates differently, most noticeably for Cardiff, and this alone shifts the log-likelihood. Since we want to compare the filters rather than two slightly different models, we export the interpolated covariates from pomp to tests/panel_measles/R_covariates.csv and load them into the pypomp objects (model.align_covariates). As such, both packages filter the same model.
Show Code
MODELS = ru.models("loglik")runs = {m: ru.load_runs(m, "loglik") for m in MODELS}def load_logliks(model): spec = ru.MODELS[model]["loglik"] frames = []for arm, sub in spec["arms"].items(): path = os.path.join(ru.results_dir(spec["dir"], sub), "pfilter_logliks.csv")ifnot os.path.exists(path):continue df = pd.read_csv(path).dropna(subset=["logLik"])if"unit"notin df.columns: df["unit"] =""if arm == spec["reference"]: role ="R"elif arm in spec.get("info", []): role = armelse: role ="pypomp" frames.append(df[["unit", "logLik"]].assign(model=model, source=arm, role=role))return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame( columns=["unit", "logLik", "model", "source", "role"] )combined = pd.concat([load_logliks(m) for m in MODELS], ignore_index=True)combined["label"] = combined["model"].map(lambda m: ru.MODELS[m]["label"])combined["panel"] = np.where( combined["unit"] =="", combined["label"], combined["label"] +"\n"+ combined["unit"])combined["panel"] = pd.Categorical(combined["panel"], categories=combined["panel"].unique())
Show Code
ru.show_missing(runs)
Automated checks
Show Code
records = {}print(f"*{checks.CAVEAT}*\n")for model in MODELS: spec = ru.MODELS[model]["loglik"] ref, info = spec["reference"], spec.get("info", []) df = combined[combined["model"] == model] units =sorted(df["unit"].unique()) or [""] arms = [a for a in spec["arms"] if a != ref] m =2*len(units) *sum(a notin info for a in arms) rows = []for arm in arms:for unit in units: sel = df["unit"] == unit compared =f"{arm} vs {ref}"if unit ==""elsef"{unit}: {arm} vs {ref}" rows += checks.loglik_checks( df[sel & (df["source"] == arm)]["logLik"], df[sel & (df["source"] == ref)]["logLik"], compared, m, verdict=arm notin info, ) level = checks.run_level(runs[model]) rows = checks.gate_smoke(rows, level) version = runs[model][arms[0]]["meta"].get("pypomp_version") records[model] = checks.record(rows, level, pypomp_version=version)print(ru.heading(model))print(f"Verdict: {checks.badge(records[model]['verdict'])}\n")if info:print(f"Rows for {', '.join(info)} are for reference and receive no verdict.\n")print(ru.raw_html(checks.rows_html(rows)))print(f"\n<small>{checks.rule_text()}</small>\n")checks.save_models(records, "loglik_checks.json")
These checks are computed from the results each time the report is rendered. They only compare summary statistics, so the plots can show problems that the checks miss. Visual review lists what the plots should show.
SPX
Verdict: PASS
Check
Compared
Result
Verdict
Mean log-likelihood
pypomp (GPU) vs R pomp
difference +0.050 nats (SE 0.049), z = +1.04
PASS
Distribution (KS)
pypomp (GPU) vs R pomp
D = 0.0275, p = 0.131
PASS
Dhaka cholera
Verdict: PASS
Check
Compared
Result
Verdict
Mean log-likelihood
pypomp (GPU) vs R pomp
difference +0.000 nats (SE 0.019), z = +0.02
PASS
Distribution (KS)
pypomp (GPU) vs R pomp
D = 0.0125, p = 0.941
PASS
Measles
Verdict: PASS
Rows for pypomp (32-bit) are for reference and receive no verdict.
Check
Compared
Result
Verdict
Mean log-likelihood
Halesworth: pypomp (64-bit) vs R pomp
difference -0.004 nats (SE 0.024), z = -0.15
PASS
Distribution (KS)
Halesworth: pypomp (64-bit) vs R pomp
D = 0.0161, p = 0.739
PASS
Mean log-likelihood
London: pypomp (64-bit) vs R pomp
difference -0.022 nats (SE 0.030), z = -0.74
PASS
Distribution (KS)
London: pypomp (64-bit) vs R pomp
D = 0.0219, p = 0.351
PASS
Mean log-likelihood
Halesworth: pypomp (32-bit) vs R pomp
difference -0.009 nats (SE 0.024), z = -0.39
info
Distribution (KS)
Halesworth: pypomp (32-bit) vs R pomp
D = 0.0181, p = 0.6
info
Mean log-likelihood
London: pypomp (32-bit) vs R pomp
difference -0.143 nats (SE 0.030), z = -4.68
info
Distribution (KS)
London: pypomp (32-bit) vs R pomp
D = 0.0567, p = 1.9e-05
info
Panel measles
Verdict: PASS
Check
Compared
Result
Verdict
Mean log-likelihood
Cardiff: pypomp (GPU) vs R panelPomp
difference +0.084 nats (SE 0.053), z = +1.58
PASS
Distribution (KS)
Cardiff: pypomp (GPU) vs R panelPomp
D = 0.0253, p = 0.2
PASS
Mean log-likelihood
Halesworth: pypomp (GPU) vs R panelPomp
difference +0.026 nats (SE 0.032), z = +0.83
PASS
Distribution (KS)
Halesworth: pypomp (GPU) vs R panelPomp
D = 0.0192, p = 0.523
PASS
Mean log-likelihood
Hastings: pypomp (GPU) vs R panelPomp
difference +0.025 nats (SE 0.043), z = +0.57
PASS
Distribution (KS)
Hastings: pypomp (GPU) vs R panelPomp
D = 0.0203, p = 0.45
PASS
Mean log-likelihood
London: pypomp (GPU) vs R panelPomp
difference -0.097 nats (SE 0.069), z = -1.40
PASS
Distribution (KS)
London: pypomp (GPU) vs R panelPomp
D = 0.0186, p = 0.561
PASS
PASS when |z| < 3, CHECK when 3 ≤ |z| < 5, and FAIL when |z| ≥ 5. KS p-values use the equivalent thresholds. Both are Bonferroni-adjusted within each model over the tests that receive a verdict.
Visual review
Density comparison: in every panel, the pypomp and R densities should overlap heavily. For measles, the 32-bit and 64-bit runs differ only in precision, so any shift of the 32-bit density away from the other two should be due to precision rather than the filter.
Density comparison
Each panel is centered on the mean of the R replicates, so the horizontal axis is in nats from that mean.
Show Code
r_mean = ( combined[combined["role"] =="R"] .groupby("panel", observed=True)["logLik"] .mean() .rename("r_mean"))plot_df = combined.join(r_mean, on="panel")plot_df["centered"] = plot_df["logLik"] - plot_df["r_mean"]( ggplot(plot_df, aes(x="centered", fill="role", color="role"))+ geom_density(alpha=0.3)+ geom_vline(xintercept=0, linetype="dashed", color="#7f8c8d")+ facet_wrap("~panel", scales="free", ncol=4)+ labs( title="Particle filter log-likelihood at fixed parameters", subtitle="One density per implementation, per model and unit", x="Log-likelihood minus the R mean (nats)", y="Density", fill="Source", color="Source", )+ ru.scale_fill_premium()+ ru.scale_color_premium()+ ru.theme_premium)
Summary statistics
We include logmeanexp, the log of the mean likelihood, since it is the likelihood estimate that pomp reports.
Intel(R) Xeon(R) Gold 6154 CPU @ 3.00GHz (36 cores)
NVIDIA RTX PRO 6000 Blackwell Server Edition (1 GPU)
Slurm Partition
standard
gpu-rtx6000
Slurm Job ID
57205142
61839613
References
Sun, Weizhe. 2024. “Model BasedInference of StochasticVolatility via IteratedFiltering.” Undergraduate Honors Thesis, University of Michigan.
Source Code
---title: "Fixed-Parameter Likelihood Validation"subtitle: "Particle filter log-likelihood distribution: R pomp vs. pypomp, all models"date: nowformat: html: theme: light: cosmo dark: darkly page-layout: full toc: true toc-depth: 3 code-fold: true code-summary: "Show Code" code-tools: true embed-resources: truebibliography: ../tests/spx/references.bibengine: jupyter---```{python Navigation header}import osimport numpy as npimport pandas as pdimport report_utils as ru # puts tests/ on sys.pathimport checksfrom IPython.display import HTML, displayfrom plotnine import aes, facet_wrap, geom_density, geom_vline, ggplot, labsru.nav_bar("loglik")```## IntroductionThis report checks the particle filter's likelihood estimate by running it manytimes at a single fixed parameter vector for each model. Only the random seedvaries across replicates, so any systematic difference between `pypomp` and R(`pomp`, or `panelPomp` for the panel) would be a difference between theimplementations.- **SPX**: the estimates from @sun2024.- **Dhaka cholera**: the published maximum likelihood estimate (MLE) that `pomp` uses as the default for `dacca()`.- **Measles**: the He et al. (2010) MLEs for London, the largest unit, and Halesworth, one of the smallest. `pypomp` runs once in single and once in double precision. Since `pomp` uses double precision throughout, the 64-bit run is the direct comparison, while the 32-bit run shows the effect of`pypomp`'s default precision.- **Panel measles**: the He et al. (2010) MLEs for London, Halesworth, Hastings and Cardiff. The parameters `R0`, `sigma`, `gamma`, `sigmaSE`, `cohort` and`amplitude` are shared across units and set to the average of the four towns' estimates, while `iota`, `rho`, `psi`, `S_0`, `E_0`, `I_0` and `R_0` are unit-specific. Since the panel log-likelihood is the sum of the unit log-likelihoods, we compare each unit separately.::: {.callout-note collapse="true"}### Why the panel covariates are imported from `pomp`The Python and R smoothing splines interpolate the birth rate and populationcovariates differently, most noticeably for Cardiff, and this alone shifts thelog-likelihood. Since we want to compare the filters rather than two slightlydifferent models, we export the interpolated covariates from `pomp` to`tests/panel_measles/R_covariates.csv` and load them into the `pypomp` objects(`model.align_covariates`). As such, both packages filter the same model.:::```{python Load results}MODELS = ru.models("loglik")runs = {m: ru.load_runs(m, "loglik") for m in MODELS}def load_logliks(model): spec = ru.MODELS[model]["loglik"] frames = []for arm, sub in spec["arms"].items(): path = os.path.join(ru.results_dir(spec["dir"], sub), "pfilter_logliks.csv")ifnot os.path.exists(path):continue df = pd.read_csv(path).dropna(subset=["logLik"])if"unit"notin df.columns: df["unit"] =""if arm == spec["reference"]: role ="R"elif arm in spec.get("info", []): role = armelse: role ="pypomp" frames.append(df[["unit", "logLik"]].assign(model=model, source=arm, role=role))return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame( columns=["unit", "logLik", "model", "source", "role"] )combined = pd.concat([load_logliks(m) for m in MODELS], ignore_index=True)combined["label"] = combined["model"].map(lambda m: ru.MODELS[m]["label"])combined["panel"] = np.where( combined["unit"] =="", combined["label"], combined["label"] +"\n"+ combined["unit"])combined["panel"] = pd.Categorical(combined["panel"], categories=combined["panel"].unique())``````{python Missing sources}ru.show_missing(runs)```---## Automated checks```{python Automated checks}#| output: asisrecords = {}print(f"*{checks.CAVEAT}*\n")for model in MODELS: spec = ru.MODELS[model]["loglik"] ref, info = spec["reference"], spec.get("info", []) df = combined[combined["model"] == model] units =sorted(df["unit"].unique()) or [""] arms = [a for a in spec["arms"] if a != ref] m =2*len(units) *sum(a notin info for a in arms) rows = []for arm in arms:for unit in units: sel = df["unit"] == unit compared =f"{arm} vs {ref}"if unit ==""elsef"{unit}: {arm} vs {ref}" rows += checks.loglik_checks( df[sel & (df["source"] == arm)]["logLik"], df[sel & (df["source"] == ref)]["logLik"], compared, m, verdict=arm notin info, ) level = checks.run_level(runs[model]) rows = checks.gate_smoke(rows, level) version = runs[model][arms[0]]["meta"].get("pypomp_version") records[model] = checks.record(rows, level, pypomp_version=version)print(ru.heading(model))print(f"Verdict: {checks.badge(records[model]['verdict'])}\n")if info:print(f"Rows for {', '.join(info)} are for reference and receive no verdict.\n")print(ru.raw_html(checks.rows_html(rows)))print(f"\n<small>{checks.rule_text()}</small>\n")checks.save_models(records, "loglik_checks.json")```## Visual review- [Density comparison](#density-comparison): in every panel, the `pypomp` and R densities should overlap heavily. For measles, the 32-bit and 64-bit runs differ only in precision, so any shift of the 32-bit density away from the other two should be due to precision rather than the filter.---## Density comparisonEach panel is centered on the mean of the R replicates, so the horizontal axisis in nats from that mean.```{python Density plot}#| fig-width: 12#| fig-height: 7r_mean = ( combined[combined["role"] =="R"] .groupby("panel", observed=True)["logLik"] .mean() .rename("r_mean"))plot_df = combined.join(r_mean, on="panel")plot_df["centered"] = plot_df["logLik"] - plot_df["r_mean"]( ggplot(plot_df, aes(x="centered", fill="role", color="role"))+ geom_density(alpha=0.3)+ geom_vline(xintercept=0, linetype="dashed", color="#7f8c8d")+ facet_wrap("~panel", scales="free", ncol=4)+ labs( title="Particle filter log-likelihood at fixed parameters", subtitle="One density per implementation, per model and unit", x="Log-likelihood minus the R mean (nats)", y="Density", fill="Source", color="Source", )+ ru.scale_fill_premium()+ ru.scale_color_premium()+ ru.theme_premium)```---## Summary statisticsWe include `logmeanexp`, the log of the mean likelihood, since it is thelikelihood estimate that `pomp` reports.```{python Summary table}summary = ( combined.groupby(["model", "label", "unit", "source"], sort=False)["logLik"] .agg(n="count", mean="mean", sd="std", min="min", max="max", logmeanexp=ru.logmeanexp) .reset_index() .drop(columns="model") .rename(columns={"label": "model"}))display(HTML(summary.round(3).to_html(classes="table table-striped table-hover", index=False)))```### Panel log-likelihoodThe sum of the unit `logmeanexp` values is the panel log-likelihood estimatethat each package would report.```{python Panel total}panel = ( summary[summary["model"] == ru.MODELS["panel_measles"]["label"]] .groupby("source")["logmeanexp"] .sum() .reset_index() .rename(columns={"logmeanexp": "panel logLik"}))display(HTML(panel.round(3).to_html(classes="table table-striped table-hover", index=False)))```---## Appendix: settings and environment```{python Settings}#| output: asisprint(ru.settings_sections(runs))```## References