Forecasting Hospital Admissions Using Particle Filtering

Warning

Epymorph’s Bayesian filtering functionality is currently in beta and subject to change.

Scenario

We can use epymorph’s built in particle filter to fit a data set and then run an ensemble forecast from the last data point.

from math import ceil

import matplotlib.pyplot as plt
import numpy as np

from epymorph.adrio import acs5, us_tiger
from epymorph.adrio.cdc import InfluenzaStateHospitalizationDaily
from epymorph.forecasting.dynamic_params import (
    ExponentialTransform,
    GaussianPrior,
    OrnsteinUhlenbeck,
)
from epymorph.forecasting.filter import ModelLink, Observations
from epymorph.forecasting.forecast import ForecastSimulator
from epymorph.forecasting.likelihood import NegativeBinomialLikelihood
from epymorph.forecasting.particle_filter import ParticleFilterSimulator
from epymorph.forecasting.pipeline import PipelineConfig, UnknownParam
from epymorph.initializer import RandomLocationsAndRandomSeed
from epymorph.kit import *
from epymorph.log.messaging import pipeline_messaging
from epymorph.time import EveryNDays

Exercise 1

First, we will develop a multi-node simulation that does not include the effects of movement.

states = ["AZ", "CO", "NM", "NV"]


cutoff_week = 7
pf_time_frame = TimeFrame.of("2022-10-20", 7 * cutoff_week)
print(f"Last day of inferred data: {pf_time_frame.end_date}")

rume = SingleStrataRUME.build(
    ipm=ipm.SIRH(),
    mm=mm.No(),
    scope=StateScope.in_states(states, year=2015),
    init=RandomLocationsAndRandomSeed(num_locations=len(states), seed_max=10_000),
    time_frame=pf_time_frame,
    params={
        "beta": ExponentialTransform("log_beta"),
        "gamma": 0.2,
        "xi": 1 / 365,
        "hospitalization_prob": 200 / 100_000,
        "hospitalization_duration": 5.0,
        "population": acs5.Population(),
        "centroid": us_tiger.InternalPoint(),
    },
)
Last day of inferred data: 2022-12-07
adrio = InfluenzaStateHospitalizationDaily(column="admissions")

observations = Observations(
    source=adrio,
    model_link=ModelLink(
        quantity=rume.ipm.select.events("I->H"),
        time=rume.time_frame.select.all().group(EveryNDays(1)).agg(),
        geo=rume.scope.select.all(),
    ),
    likelihood=NegativeBinomialLikelihood(r=10),
)

The process we choose here is a Black-Karasinski process, a stochastic process whose logarithm is an Ornstein-Uhlenbeck process. The dynamics are controlled by three parameters, the mean, the damping, and the standard deviation. The mean controls the mean of the logarithm of the stationary distribution, the damping controls the rate of mean reversion, and the standard deviation controls the standard deviation of the logarithm of the stationary distribution. We chose the parameters based on experimentation but for many cases parameter estimation algorithms to determine these values may be appropriate.

num_realizations = 100

unknown_params = {
    "log_beta": UnknownParam(
        prior=GaussianPrior(
            mean=np.log(0.25),
            standard_deviation=0.2,
        ),
        dynamics=OrnsteinUhlenbeck(
            damping=1 / 35,
            mean=np.log(0.25),
            standard_deviation=0.2,
        ),
    )
}

particle_filter_simulator = ParticleFilterSimulator(
    config=PipelineConfig.from_rume(
        rume, num_realizations, unknown_params=unknown_params
    ),
    observations=observations,
)
rng = np.random.default_rng(0)

with pipeline_messaging():
    particle_filter_output = particle_filter_simulator.run(rng=rng)
Loading epymorph.adrio.acs5.Population:
  |####################| 100%  (1.153s)
Loading epymorph.adrio.cdc.InfluenzaStateHospitalizationDaily:
  |####################| 100%  (0.384s)
Running simulation (ParticleFilterSimulator):
• 2022-10-20 to 2022-12-07 (49 days)
• 4 geo nodes
• 100 realizations
  |####################| 100% 
Runtime: 53.384s
extend_duration = 28  # Days

forecast_simulator = ForecastSimulator(
    PipelineConfig.from_output(particle_filter_output, extend_duration)
)

with pipeline_messaging():
    forecast_output = forecast_simulator.run(rng=rng)
Loading epymorph.adrio.acs5.Population:
  |####################| 100%  (0.997s)
Running simulation (ForecastSimulator):
• 2022-12-08 to 2023-01-04 (28 days)
• 4 geo nodes
• 100 realizations
  |####################| 100% 
Runtime: 3.391s
# Allow changes to the number of nodes.
cols = 2
rows = ceil(rume.scope.nodes / cols)
fig, axes = plt.subplots(rows, cols, figsize=(6 * cols, 4 * rows), sharex=True)
axes = axes.flatten()

# Calculate the appropriate x-values for plotting.
data_dates = np.arange(0, rume.time_frame.days, 1)
sim_dates = np.arange(rume.time_frame.days, rume.time_frame.days + extend_duration, 1)
total_dates = np.arange(0, rume.time_frame.days + extend_duration, 1)


# Fetch the real data.
data_time_frame = TimeFrame.of(
    rume.time_frame.start_date, rume.time_frame.duration_days + extend_duration
)
real_data_result = (
    adrio.with_context(scope=rume.scope, time_frame=data_time_frame).inspect().result
)
real_data = real_data_result["value"]

for i in range(rume.scope.nodes):
    ax = axes[i]
    ax.set_title(f"State {rume.scope.labels[i]}")
    ax.set_xlabel("Days")

    # Plot the posterior estimate of the daily admissions.
    posterior_admissions = particle_filter_output.posterior_values[:, :, i, 0]
    lower_pf = np.percentile(posterior_admissions, 2.5, axis=0)
    upper_pf = np.percentile(posterior_admissions, 97.5, axis=0)
    mean_pf = np.median(posterior_admissions, axis=0)

    ax.fill_between(data_dates, lower_pf, upper_pf, alpha=0.3, label="95% CI of PF")
    ax.plot(data_dates, mean_pf, color="black", label="median of PF")

    # Plot the forecasted estimate of the daily admissions.
    forecast_admissions = forecast_output.events[:, :, i, 1]
    lower_fct = np.percentile(forecast_admissions, 2.5, axis=0)
    upper_fct = np.percentile(forecast_admissions, 97.5, axis=0)
    median_fct = np.median(forecast_admissions, axis=0)

    ax.fill_between(
        sim_dates, lower_fct, upper_fct, alpha=0.3, label="95% CI of forecast"
    )
    ax.plot(sim_dates, median_fct, color="purple", label="median of forecast")

    # Plot the reported number of admissions.
    ax.scatter(
        total_dates, real_data[:, i], color="blue", marker="x", label="real data"
    )
    ax.legend()

plt.tight_layout()
plt.show()

We can layer the particle filter and forecast to incorporate new data points. Let’s examine how the particle filter performs when we incorporate the next week’s data and forecast another four weeks ahead.

extend_pf = 7  # Extend the particle filter by one week.
particle_filter_simulator = ParticleFilterSimulator(
    config=PipelineConfig.from_output(particle_filter_output, extend_pf),
    observations=observations,
)

with pipeline_messaging():
    particle_filter_output_update = particle_filter_simulator.run(rng=rng)
Loading epymorph.adrio.acs5.Population:
  |####################| 100%  (1.004s)
Loading epymorph.adrio.cdc.InfluenzaStateHospitalizationDaily:
  |####################| 100%  (0.429s)
Running simulation (ParticleFilterSimulator):
• 2022-12-08 to 2022-12-14 (7 days)
• 4 geo nodes
• 100 realizations
  |####################| 100% 
Runtime: 7.573s
forecast_simulator = ForecastSimulator(
    PipelineConfig.from_output(particle_filter_output_update, extend_duration)
)

with pipeline_messaging():
    forecast_output_update = forecast_simulator.run(rng=rng)
Loading epymorph.adrio.acs5.Population:
  |####################| 100%  (1.072s)
Running simulation (ForecastSimulator):
• 2022-12-15 to 2023-01-11 (28 days)
• 4 geo nodes
• 100 realizations
  |####################| 100% 
Runtime: 3.319s
combined_particle_filter_events = np.concatenate(
    (
        particle_filter_output.posterior_values,
        particle_filter_output_update.posterior_values,
    ),
    axis=1,
)

# Allow changes to the number of nodes.
cols = 2
rows = ceil(rume.scope.nodes / cols)
fig, axes = plt.subplots(rows, cols, figsize=(6 * cols, 4 * rows), sharex=True)
axes = axes.flatten()

# Calculate the appropriate x-values for plotting.
data_dates = np.arange(0, rume.time_frame.days + extend_pf, 1)
sim_dates = np.arange(
    rume.time_frame.days + extend_pf,
    rume.time_frame.days + extend_pf + extend_duration,
    1,
)
total_dates = np.arange(0, rume.time_frame.days + extend_pf + extend_duration, 1)


# Fetch the real data.
data_time_frame = TimeFrame.of(
    rume.time_frame.start_date,
    rume.time_frame.duration_days + extend_pf + extend_duration,
)
real_data_result = (
    adrio.with_context(scope=rume.scope, time_frame=data_time_frame).inspect().result
)
real_data = real_data_result["value"]

for i in range(rume.scope.nodes):
    ax = axes[i]
    ax.set_title(f"State {rume.scope.labels[i]}")
    ax.set_xlabel("Days")

    # Plot the posterior estimate of the daily admissions.
    posterior_admissions = combined_particle_filter_events[:, :, i, 0]
    lower_pf = np.percentile(posterior_admissions, 2.5, axis=0)
    upper_pf = np.percentile(posterior_admissions, 97.5, axis=0)
    mean_pf = np.median(posterior_admissions, axis=0)

    ax.fill_between(data_dates, lower_pf, upper_pf, alpha=0.3, label="95% CI of PF")
    ax.plot(data_dates, mean_pf, color="black", label="median of PF")

    ax.axvspan(
        rume.time_frame.days,
        rume.time_frame.days + extend_pf,
        alpha=0.5,
        color="gray",
        label="Additional data",
    )

    # Plot the forecasted estimate of the daily admissions.
    forecast_admissions = forecast_output_update.events[:, :, i, 1]
    lower_fct = np.percentile(forecast_admissions, 2.5, axis=0)
    upper_fct = np.percentile(forecast_admissions, 97.5, axis=0)
    median_fct = np.median(forecast_admissions, axis=0)

    ax.fill_between(
        sim_dates, lower_fct, upper_fct, alpha=0.3, label="95% CI of forecast"
    )
    ax.plot(sim_dates, median_fct, color="purple", label="median of forecast")

    # Plot the reported number of admissions.
    ax.scatter(
        total_dates, real_data[:, i], color="blue", marker="x", label="real data"
    )
    ax.legend()

plt.tight_layout()
plt.show()

Notes

We see a narrowing of the forecast uncertainty as more data is assimilated by the filter. The downwards trajectory of hospitalizations is captured in 3 of the 4 states. Colorado’s hospitalizations admissions exhibit sub-exponential growth which proves a challenge for this model.