Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
277a267
checkpointing; bugfix implemented by claude app
cdc-mitzimorris Oct 11, 2026
39508f0
update tutorials
cdc-mitzimorris Oct 12, 2026
f331b29
changes per co-pilot review
cdc-mitzimorris Oct 13, 2026
dc3d817
changes per co-pilot review
cdc-mitzimorris Oct 13, 2026
31caf25
changes per co-pilot review
cdc-mitzimorris Oct 13, 2026
90165b6
Update docs/tutorials/building_multisignal_models.qmd
cdc-mitzimorris Oct 13, 2026
9ac7e19
Update docs/tutorials/building_multisignal_models.qmd
cdc-mitzimorris Oct 13, 2026
0254cda
Merge branch 'mem_bugfix_903_subpop_scaling' of github-bf06:CDCgov/Py…
cdc-mitzimorris Oct 13, 2026
8681961
generalize docs language: use member of subpopulation instead of res…
cdc-mitzimorris Oct 13, 2026
cef08e0
Update pyrenew/model/multisignal_model.py
cdc-mitzimorris Oct 14, 2026
80db5d3
Update pyrenew/observation/__init__.py
cdc-mitzimorris Oct 14, 2026
942bece
Update pyrenew/observation/base.py
cdc-mitzimorris Oct 14, 2026
c48737e
Update pyrenew/observation/base.py
cdc-mitzimorris Oct 14, 2026
545640e
Update pyrenew/observation/measurement_observations.py
cdc-mitzimorris Oct 14, 2026
223bf28
Update pyrenew/observation/measurement_observations.py
cdc-mitzimorris Oct 14, 2026
e6081a5
Update pyrenew/observation/measurement_observations.py
cdc-mitzimorris Oct 14, 2026
a4f88be
Update pyrenew/observation/measurement_observations.py
cdc-mitzimorris Oct 14, 2026
19ac501
Update test/integration/test_subpopulation_counts_scaling.py
cdc-mitzimorris Oct 14, 2026
4b70a8b
Update test/integration/test_subpopulation_counts_scaling.py
cdc-mitzimorris Oct 14, 2026
b60d287
Merge branch 'mem_bugfix_903_subpop_scaling' of github-bf06:CDCgov/Py…
cdc-mitzimorris Oct 14, 2026
de99c05
Update test/integration/test_subpopulation_counts_scaling.py
cdc-mitzimorris Oct 14, 2026
5120879
Update test/integration/test_subpopulation_counts_scaling.py
cdc-mitzimorris Oct 14, 2026
253aa48
Update test/integration/test_subpopulation_counts_scaling.py
cdc-mitzimorris Oct 14, 2026
66b57e4
Merge branch 'mem_bugfix_903_subpop_scaling' of github-bf06:CDCgov/Py…
cdc-mitzimorris Oct 14, 2026
1fe36eb
changes per code review
cdc-mitzimorris Oct 14, 2026
44081fb
changes per code review
cdc-mitzimorris Oct 14, 2026
aa0773e
refine pytests - use pytest-env
cdc-mitzimorris Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/tutorials/ascertainment.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ model = builder.build()

- Ascertainment models produce one scalar baseline rate per signal.
- Supplying `temporal_processes` adds signal-specific deviations to selected baselines to model time-varying ascertainment rates.
- For `JointAscertainment` and `RatioLinkedAscertainemt` models, the temporal deviations do not preserve the baseline covariance or ratio pointwise over time.
- For `JointAscertainment` and `RatioLinkedAscertainment` models, the temporal deviations do not preserve the baseline covariance or ratio pointwise over time.

The choice of ascertainment model should consider:

Expand Down
28 changes: 19 additions & 9 deletions docs/tutorials/building_multisignal_models.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,21 @@ flowchart TB
Different observation processes observe different levels of the model hierarchy.
Each observation process declares an **infection resolution** that determines what infection data it receives:

| Resolution | Receives | Example signals |
| ------------- | ------------------------------------------------------------------- | -------------------------------------- |
| `"aggregate"` | Aggregated infections (sum across all subpopulations), shape `(T,)` | Hospital admissions, case counts |
| `"subpop"` | Infection matrix for all subpopulations, shape `(T, n_subpops)` | Wastewater, site-specific surveillance |
| Resolution | Receives | Example signals |
| --------------------- | ------------------------------------------------------------------- | -------------------------------- |
| `"aggregate"` | Infection counts for the whole jurisdiction, shape `(T,)` | Hospital admissions, case counts |
| `"subpop"` | Infection counts in each subpopulation, shape `(T, n_subpops)` | Regional case counts |
| `"subpop_per_capita"` | Infections per member of each subpopulation, shape `(T, n_subpops)` | Wastewater concentrations |

The `PyrenewBuilder` routes latent infections to observation processes based on each process's declared resolution.
The model routes latent infections to observation processes based on each process's declared resolution.

The latent infection process generates infections per individual: $I_k(t)$ is the number of new infections among members of subpopulation $k$ on day $t$, divided by the number of members of $k$.
The model converts these to the scale each kind of signal needs, using `population_size` ($N$) and `subpop_fractions` ($p_k$):

- **Count signals** need infection counts.
Subpopulation $k$ has $N p_k$ members, so it has $N p_k I_k(t)$ infections, and these counts sum to the jurisdiction total $N \sum_k p_k I_k(t)$.
- **Some measurement signals** provide information about infections per member of the subpopulation.
For example, the number genomes shed into wastewater scales with the number of infected individuals, and the total volume of wastewater scales with the total population, so it can be reasonable to model wastewater concentrations purely as a function of per-capita incidence $I_k(t)$.

For subpopulation-level observations, the observation process selects which subpopulations it observes using `subpop_indices` provided at sample/fit time.
This allows flexible observation patterns---for example, wastewater samples might only cover 5 of 6 subpopulations (catchment areas), while the 6th represents areas without wastewater monitoring.
Expand Down Expand Up @@ -297,7 +306,7 @@ Observation processes transform latent infections into observable signals and de
Each observation process:

- Has a unique **name** that identifies the signal in model outputs
- Declares what **infection resolution** it needs (`"aggregate"` or `"subpop"`)
- Declares what **infection resolution** it needs (`"aggregate"`, `"subpop"`, or `"subpop_per_capita"`)
- Applies signal-specific transformations (ascertainment, delay convolution, shedding kinetics)
- Defines the noise model

Expand Down Expand Up @@ -425,8 +434,9 @@ class Wastewater(MeasurementObservation):
"""
Wastewater viral concentration observation process.

Transforms site-level infections into predicted log-concentrations
via shedding kinetics convolution and genome/volume scaling.
Transforms infections per member of each subpopulation into
predicted log-concentrations via shedding kinetics convolution and
genome/volume scaling.
"""

def __init__(
Expand Down Expand Up @@ -524,7 +534,7 @@ shedding_pmf = shedding_pmf / shedding_pmf.sum() # normalize
shedding_rv = DeterministicPMF("shedding_kinetics", shedding_pmf)

# Log10 genomes shed per infection
log10_genome_rv = DeterministicVariable("log10_genome_per_inf", 9.0)
log10_genome_rv = DeterministicVariable("log10_genome_per_inf", 4.1)

# Wastewater volume per person per day (mL)
ml_per_person_per_day = 1000.0
Expand Down
4 changes: 1 addition & 3 deletions docs/tutorials/latent_subpopulation_infections.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,6 @@ $$
I_{\text{aggregate}}(t) = \sum_{k=1}^{K} p_k \, I_k(t).
$$

The aggregate trajectory $I_{\text{aggregate}}(t)$ is then passed to observation processes, which transform it into expected observations as described in the observation model tutorials.

### Relationship to the single-population model

This model generalizes the single-population renewal model:
Expand Down Expand Up @@ -759,7 +757,7 @@ Each observation process selects a subset of subpopulations (via `subpop_indices
The `PyrenewBuilder` handles the wiring:

1. **`configure_latent()`** sets the hierarchical infection process (called once)
2. **`add_observation()`** adds an observation process, specifying which subpopulations it observes (called once per data stream)
2. **`add_observation()`** adds an observation process (called once per data stream)
3. **`build()`** computes `n_initialization_points` from all delay distributions and produces a model ready for inference

To learn how to build complete models with observation processes, see:
Expand Down
1 change: 1 addition & 0 deletions docs/tutorials/observation_processes_counts.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ In the renewal modeling framework, observations are generated in two steps:
2. A **stochastic observation model**, where observed counts are drawn from a distribution with that predicted value.

Observed data can be aggregated or available as subpopulation-level counts, which are modeled by the classes `PopulationCounts` and `SubpopulationCounts`, respectively.
See [Infection Resolution](building_multisignal_models.md#infection-resolution) for how a multi-signal model converts latent infections to counts for each.

### Observation equation

Expand Down
21 changes: 13 additions & 8 deletions docs/tutorials/observation_processes_measurements.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ The core convolution structure is shared with count observations, but key aspect
| Output space | Linear (expected counts) | Often log-transformed |
| Noise model | Poisson or Negative Binomial | Normal (often on log scale) |
| Scaling | Ascertainment rate $\alpha \in [0,1]$ | Domain-specific |
| Subpop structure | Optional (`CountsBySubpop`) | Inherent (sensor/site effects) |
| Subpop structure | Optional (`SubpopulationCounts`) | Inherent (sensor/site effects) |

### The noise model

Expand Down Expand Up @@ -203,7 +203,7 @@ $$

where:

- $I(t-d)$ is the number of infections on day $t-d$
- $I(t-d)$ is the number of infections per subpopulation member on day $t-d$ (see [Infection Resolution](building_multisignal_models.md#infection-resolution))
- $\pi_d$ is the shedding kinetics PMF, giving the fraction of total shedding occurring $d$ days after infection, analogous to the delay distribution in count observation models
- $G$ is the number of genome copies shed per infection
- $V$ is the wastewater volume per person per day
Expand Down Expand Up @@ -245,8 +245,9 @@ class Wastewater(MeasurementObservation):
"""
Wastewater viral concentration observation process.

Transforms site-level infections into predicted log-concentrations
via shedding kinetics convolution and genome/volume scaling.
Transforms infections per member of each subpopulation into
predicted log-concentrations via shedding kinetics convolution and
genome/volume scaling.
"""

def __init__(
Expand Down Expand Up @@ -461,7 +462,7 @@ day_one = first_valid_observation_day(ww_process)
# Create infections with a spike (shape: n_days x n_subpops)
infection_spike_day = day_one + 10
infections = jnp.zeros((n_days, 1)) # 1 subpopulation
infections = infections.at[infection_spike_day, 0].set(2000.0)
infections = infections.at[infection_spike_day, 0].set(0.002)

# For plotting
rel_spike_day = infection_spike_day - day_one
Expand Down Expand Up @@ -506,7 +507,11 @@ plot_infections = (
linetype="dashed",
alpha=0.5,
)
+ p9.labs(x="Day", y="Daily Infections", title="Infections (Input)")
+ p9.labs(
x="Day",
y="Daily Infections per Subpopulation member",
title="Infections (Input)",
)
+ theme_tutorial
+ p9.annotate(
"text",
Expand Down Expand Up @@ -714,8 +719,8 @@ The observed concentrations reflect both the underlying infection differences AN

# Two subpopulations with different infection patterns
n_days_mp = 40
infections_subpop1 = 1000.0 * jnp.exp(-jnp.arange(n_days_mp) / 20.0) # Slow decay
infections_subpop2 = 2000.0 * jnp.exp(-jnp.arange(n_days_mp) / 10.0) # Fast decay
infections_subpop1 = 0.001 * jnp.exp(-jnp.arange(n_days_mp) / 20.0) # Slow decay
infections_subpop2 = 0.002 * jnp.exp(-jnp.arange(n_days_mp) / 10.0) # Fast decay
infections_multi = jnp.stack([infections_subpop1, infections_subpop2], axis=1)

# Two sensors, each observing a different subpopulation
Expand Down
35 changes: 23 additions & 12 deletions pyrenew/model/multisignal_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,16 @@ class MultiSignalModel(Model):
-----
The model automatically routes latent infections to observations based on
each observation's infection_resolution() method:
- "aggregate" → receives aggregate infections from latent process
- "subpop" → receives all subpopulation infections; observation selects via indices

- ``"aggregate"``: infection counts for the whole jurisdiction,
shape ``(n_total,)``.
- ``"subpop"``: infection counts in each subpopulation,
shape ``(n_total, n_subpops)``.
- ``"subpop_per_capita"``: infections per resident of each
Comment thread
cdc-mitzimorris marked this conversation as resolved.
Outdated
subpopulation, shape ``(n_total, n_subpops)``.

Subpopulation observations select their subpopulations via
``subpop_indices``.
"""

def __init__(
Expand Down Expand Up @@ -86,7 +94,7 @@ def __init__(
self.ascertainment_models = ascertainment_models
self.validate()

_SUPPORTED_RESOLUTIONS = {"aggregate", "subpop"}
_SUPPORTED_RESOLUTIONS = {"aggregate", "subpop", "subpop_per_capita"}

def validate(self) -> None:
"""
Expand Down Expand Up @@ -371,8 +379,9 @@ def sample(
n_days_post_init
Number of days to simulate after initialization period
population_size
Total population size. Used to convert infection proportions
(from latent process) to infection counts (for observation processes).
Total population size. Aggregate infections are multiplied by
``population_size``; infections in subpopulation ``k`` are
multiplied by ``population_size * subpop_fractions[k]``.
subpop_fractions
Population fractions for all subpopulations. Shape: (n_subpops,).
obs_start_date
Expand Down Expand Up @@ -400,25 +409,27 @@ def sample(
self._check_obs_start_date(obs_start_date)
first_day_dow = self._resolve_first_day_dow(obs_start_date)

# Generate latent infections (proportions)
latent_sample = self.latent.sample(
n_days_post_init=n_days_post_init,
subpop_fractions=subpop_fractions,
first_day_dow=first_day_dow,
)
pop = self.latent._parse_and_validate_fractions(
subpop_fractions=subpop_fractions,
)

# Scale from proportions to counts
inf_aggregate = latent_sample.aggregate * population_size
inf_all = latent_sample.all_subpops * population_size
inf_subpop = (
latent_sample.all_subpops * population_size * pop.fractions[jnp.newaxis, :]
)

# Record scaled infections for posterior analysis
numpyro.deterministic("latent_infections", inf_aggregate)
numpyro.deterministic("latent_infections_by_subpop", inf_all)
numpyro.deterministic("latent_infections_by_subpop", inf_subpop)

# Map infection resolution to infection arrays
latent_map = {
"aggregate": inf_aggregate,
"subpop": inf_all,
"subpop": inf_subpop,
"subpop_per_capita": latent_sample.all_subpops,
}

n_timepoints = self.latent.n_initialization_points + n_days_post_init
Expand Down
5 changes: 4 additions & 1 deletion pyrenew/observation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
All observation processes implement:

- ``sample()``: Sample observations given infections
- ``infection_resolution()``: returns ``"aggregate"`` or ``"subpop"``
- ``infection_resolution()``: returns ``"aggregate"`` (infection counts for
the whole jurisdiction), ``"subpop"`` (infection counts in each
subpopulation), or ``"subpop_per_capita"`` (infections per resident of
Comment thread
cdc-mitzimorris marked this conversation as resolved.
Outdated
each subpopulation)
- ``lookback_days()``: returns required infection history length

Noise models (``CountNoise``, ``MeasurementNoise``) are composable—pass them
Expand Down
18 changes: 11 additions & 7 deletions pyrenew/observation/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ class BaseObservationProcess(RandomVariable):
Subclasses must implement:

- ``lookback_days()``: Return PMF length for initialization
- ``infection_resolution()``: Return ``"aggregate"`` or ``"subpop"``
- ``infection_resolution()``: Return ``"aggregate"``, ``"subpop"``,
or ``"subpop_per_capita"``
- ``_predicted_obs()``: Transform infections to predicted values
- ``sample()``: Apply noise model to predicted observations

Expand Down Expand Up @@ -107,19 +108,22 @@ def lookback_days(self) -> int:
@abstractmethod
def infection_resolution(self) -> str:
"""
Return whether this observation uses aggregate or subpop infections.
Return which infections this observation receives.

Returns one of:

- ``"aggregate"``: Uses a single aggregated infection trajectory.
Shape: ``(n_days,)``
- ``"subpop"``: Uses subpopulation-level infection trajectories.
Shape: ``(n_days, n_subpops)``, indexed via ``subpop_indices``.
- ``"aggregate"``: infection counts for the whole jurisdiction.
Comment thread
cdc-mitzimorris marked this conversation as resolved.
Outdated
Shape: ``(n_total,)``
- ``"subpop"``: infection counts in each subpopulation.
Shape: ``(n_total, n_subpops)``, indexed via ``subpop_indices``.
- ``"subpop_per_capita"``: infections per resident of each
Comment thread
cdc-mitzimorris marked this conversation as resolved.
Outdated
subpopulation. Shape: ``(n_total, n_subpops)``, indexed via
``subpop_indices``.

Returns
-------
str
Either ``"aggregate"`` or ``"subpop"``
``"aggregate"``, ``"subpop"``, or ``"subpop_per_capita"``

Notes
-----
Expand Down
2 changes: 1 addition & 1 deletion pyrenew/observation/count_observations.py
Original file line number Diff line number Diff line change
Expand Up @@ -975,7 +975,7 @@ def sample(
Parameters
----------
infections
Subpopulation-level infections from the infection process.
Infection counts in each subpopulation.
Shape ``(n_total, n_subpops)``.
obs
Observed counts. For ``reporting_schedule="regular"``:
Expand Down
18 changes: 10 additions & 8 deletions pyrenew/observation/measurement_observations.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ class MeasurementObservation(BaseObservationProcess):
-----
Subclasses must implement ``_predicted_obs()`` according to their
specific signal processing (e.g., wastewater shedding kinetics,
dilution factors, etc.).
dilution factors, etc.). ``_predicted_obs()`` receives infections
per resident of each subpopulation.
Comment thread
cdc-mitzimorris marked this conversation as resolved.
Outdated

See Also
--------
Expand Down Expand Up @@ -92,17 +93,18 @@ def lookback_days(self) -> int:

def infection_resolution(self) -> str:
"""
Return "subpop" for measurement observations.
Return "subpop_per_capita" for measurement observations.

Measurement observations require subpopulation-level infections
because each measurement corresponds to a specific catchment area.
Measurement observations receive infections per resident of each
Comment thread
cdc-mitzimorris marked this conversation as resolved.
Outdated
subpopulation, because each measurement corresponds to a specific
catchment area and depends on infections per resident.
Comment thread
cdc-mitzimorris marked this conversation as resolved.
Outdated

Returns
-------
str
``"subpop"``
``"subpop_per_capita"``
"""
return "subpop"
return "subpop_per_capita"

def validate_data(
self,
Expand Down Expand Up @@ -175,8 +177,8 @@ def sample(
Parameters
----------
infections
Infections from the infection process.
Shape: (n_total, n_subpops)
Infections per resident of each subpopulation.
Comment thread
cdc-mitzimorris marked this conversation as resolved.
Outdated
Shape: (n_total, n_subpops).
times
Day index for each observation on the shared time axis.
Must be in range [0, n_total). Shape: (n_obs,)
Expand Down
34 changes: 25 additions & 9 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,20 @@
This module provides reusable fixtures for creating observation processes,
test data, and common configurations used across multiple test files.

The module also sets two JAX environment variables before any jax import so
that all tests (unit and integration) run with 64-bit precision and with
four logical host devices available for parallel MCMC chains. JAX reads
these variables at import time, so they must be set before the first
``import jax`` anywhere in the test process; placing them at the top of
this file (loaded by pytest before any test module) satisfies that
requirement. The ``setdefault`` form respects any value the caller already
set at the shell level (e.g., a CI with different configuration).
All tests (unit and integration) run with 64-bit floats. The module sets
``JAX_ENABLE_X64`` before any jax import, overriding any shell value, and
the autouse fixture ``require_float64`` fails any test that runs without
64-bit floats. It also makes four logical host devices available for
parallel MCMC chains, unless the caller already set ``XLA_FLAGS``. JAX
reads these variables at import time, so they must be set before the
first ``import jax`` anywhere in the test process; placing them at the
top of this file (loaded by pytest before any test module) satisfies that
requirement.
"""

import os

os.environ.setdefault("JAX_ENABLE_X64", "true")
os.environ["JAX_ENABLE_X64"] = "true"
Comment thread
cdc-mitzimorris marked this conversation as resolved.
Outdated
os.environ.setdefault("XLA_FLAGS", "--xla_force_host_platform_device_count=4")

import jax.numpy as jnp
Expand All @@ -39,6 +40,21 @@
from pyrenew.time import MMWR_WEEK
from test.test_helpers import fixed_ar1, fixed_random_walk


@pytest.fixture(autouse=True)
def require_float64() -> None:
"""
Fail any test that would run without 64-bit floats.

Raises
------
pytest.fail.Exception
If JAX creates 32-bit floating point arrays by default.
"""
if jnp.asarray(0.0).dtype != jnp.float64:
pytest.fail("Tests must run with 64-bit floats; JAX_ENABLE_X64 is off.")


# =============================================================================
# PMF Fixtures
# =============================================================================
Expand Down
Loading
Loading