Skip to content

Specify day-of-week effects for observation process count data #712

@cdc-mitzimorris

Description

@cdc-mitzimorris

Day-of-week effects in the count observation process

Day-of-week effects are a multiplicative adjustment to predicted counts, applied after the delay convolution and before the noise model. Adding this to the observation process extends the pipeline:

infections -> [delay convolution] -> delayed_incidence
delayed_incidence * ascertainment * day_of_week_effect -> expected_counts
expected_counts -> [noise] -> observations

Convolution must come first. Ascertainment and day-of-week effects are both element-wise multipliers on the convolved signal, so their relative order is interchangeable. The net effect is to impose a periodic weekly pattern on the expected value of the likelihood.

Interface Change to Counts

Add one optional parameter to the Counts constructor:

class Counts(_CountBase):
    def __init__(
        self,
        name: str,
        ascertainment_rate_rv: RandomVariable,
        delay_distribution_rv: RandomVariable,
        noise: CountNoise,
        day_of_week_rv: RandomVariable | None = None,  # NEW
    ) -> None: ...

day_of_week_rv is any RandomVariable that samples to a 1D array of shape (7,) summing to 7.0 — one multiplicative effect per day of the week. The sum-to-7 constraint preserves weekly totals. When None, no day-of-week adjustment is applied (the current default behavior).

Data Interface

When day-of-week effects are configured, sample() accepts a required first_day_dow:

# At model.run() time:
ed_visits={
    "obs": model.pad_observations(ed_counts),
    "first_day_dow": 2,  # timeseries starts on Wednesday (0=Mon, 6=Sun)
}

The offset is data, not a model parameter — it depends on when the timeseries begins. Setting it to None (or omitting it when day_of_week_rv is None) disables the adjustment.

Implementation

The core logic uses the existing tile_until_n utility from pyrenew.arrayutils:

def compute_day_of_week_effect(
    dow_effect: ArrayLike,
    n_timepoints: int,
    first_day_dow: int,
) -> ArrayLike:
    """
    Tile a 7-element day-of-week effect vector across a timeseries.

    Parameters
    ----------
    dow_effect : ArrayLike
        Day-of-week multiplicative effects, shape (7,).
        Entry j is the multiplier for day-of-week j (0=Monday, 6=Sunday).
        Should sum to 7.0 to preserve weekly totals.
    n_timepoints : int
        Length of the time series.
    first_day_dow : int
        Day of the week for the first timepoint (0=Monday, 6=Sunday).

    Returns
    -------
    ArrayLike
        Shape (n_timepoints,). Periodic multiplicative adjustments.
    """
    return tile_until_n(dow_effect, n_timepoints, offset=first_day_dow)

Inside Counts._predicted_obs(), after the delay convolution:

predicted = ascertainment * convolve(infections, delay_pmf)

if self.day_of_week_rv is not None and first_day_dow is not None:
    dow_effect = self.day_of_week_rv()
    daily_effect = compute_day_of_week_effect(
        dow_effect, predicted.shape[0], first_day_dow
    )
    predicted = predicted * daily_effect

Why This Works for Epidemiologists

The 7-element effect vector is the single knob that controls the day-of-week adjustment. An epidemiologist can:

  1. Use a fixed effect from external data:

    day_of_week_rv = DeterministicVariable("dow", empirical_dow_effects)
  2. Infer the effect by placing a Dirichlet prior scaled to sum to 7:

    day_of_week_rv = TransformedVariable(
        "dow_effect",
        DistributionalVariable(
            "dow_raw", dist.Dirichlet(jnp.array([5, 5, 5, 5, 5, 2, 2]))
        ),
        transform=lambda x: x * 7,
    )
  3. Disable it by omitting the parameter entirely (the default).

  4. Compare models by fitting with and without day-of-week effects and comparing ELPD/WAIC.

Reference Implementation

PyRenew already has the building blocks for this:

  • pyrenew.arrayutils.tile_until_n() — tiles a short array to length n_timepoints with an offset, used by pyrenew.process.periodiceffect.DayOfWeekEffect
  • docs/tutorials/random_variables.qmd — demonstrates AscertainmentWithDayOfWeek, a custom RandomVariable that bundles baseline ascertainment with a Dirichlet day-of-week effect, applies it after delay convolution, and shows the weekly reporting pattern
  • pyrenew-multisignal HEW model (EDVisitObservationProcess in hew/model.py:418-424) — uses tile_until_n(ed_wday_effect_raw, n) to multiply the convolved signal by a periodic day-of-week effect

The goal is to bring this capability into the Counts class as a clean, optional, composable parameter — matching the pattern established by right_truncation_rv in #702.

Builder Usage Example

With this feature, specifying day-of-week effects in the pyrenew-multisignal ED visit model via the builder:

from pyrenew.deterministic import DeterministicPMF, DeterministicVariable
from pyrenew.randomvariable import TransformedVariable, DistributionalVariable
from pyrenew.observation import Counts, NegativeBinomialNoise
from pyrenew.model import PyrenewBuilder

# --- ED Visit observation process with day-of-week ---
ed_obs = Counts(
    name="ed_visits",
    ascertainment_rate_rv=DeterministicVariable("ier", 0.005),
    delay_distribution_rv=DeterministicPMF("inf_to_ed", inf_to_ed_pmf),
    noise=NegativeBinomialNoise(
        DeterministicVariable("ed_concentration", 20.0)
    ),
    day_of_week_rv=TransformedVariable(
        "dow_effect",
        DistributionalVariable(
            "dow_raw", dist.Dirichlet(jnp.array([5, 5, 5, 5, 5, 2, 2]))
        ),
        transform=lambda x: x * 7,
    ),
)

# --- Build model ---
builder = PyrenewBuilder()
builder.configure_latent(...)
builder.add_observation(ed_obs)
model = builder.build()

# --- Fit (with day-of-week) ---
model.run(
    ...,
    ed_visits={
        "obs": model.pad_observations(ed_counts),
        "first_day_dow": 2,  # data starts on Wednesday
    },
)

# --- Forecast (day-of-week still applies) ---
model.predict(
    ...,
    ed_visits={
        "first_day_dow": 2,  # same offset for forecast continuation
    },
)

The observation process definition is ~10 lines. The day-of-week behavior is explicit, visible at construction time, and controlled by a single data parameter at run time.

Scope

This design covers:

  • Adding day_of_week_rv as an optional parameter to Counts and CountsBySubpop
  • The compute_day_of_week_effect utility function (wrapping tile_until_n)
  • Passing first_day_dow as runtime data

This design does NOT cover (separate work):

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions