Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
680bb1e
Merge branch 'main' of https://github.com/CDCgov/PyRenew
cdc-mitzimorris Sep 15, 2025
2cb876b
update
cdc-mitzimorris Sep 18, 2025
60db8df
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Sep 22, 2025
32a5314
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Oct 5, 2025
d6213f2
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Oct 8, 2025
96f27c9
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Nov 17, 2025
1cb6fa2
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Nov 24, 2025
f62e1e4
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Dec 4, 2025
0c6785d
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Dec 22, 2025
1ee62b9
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Jan 29, 2026
0629461
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 4, 2026
efeadee
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 5, 2026
371ba98
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 5, 2026
0304bed
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 6, 2026
ffeea65
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 9, 2026
50e7261
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 9, 2026
dae6af8
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 10, 2026
5cb3097
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 11, 2026
1d80ccc
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 11, 2026
e73b401
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 12, 2026
b1473b5
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 18, 2026
0b929b5
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 18, 2026
3ee00a7
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 24, 2026
307982a
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 24, 2026
b862bc6
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Feb 26, 2026
2c665a5
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Mar 11, 2026
60d6458
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Mar 12, 2026
ec8c464
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Mar 19, 2026
c018bf7
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Mar 24, 2026
d0207dd
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Apr 4, 2026
f3c706a
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Apr 9, 2026
684c6c5
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Apr 10, 2026
ca2454f
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Apr 13, 2026
0f38afc
merge
cdc-mitzimorris Apr 14, 2026
d8e7a57
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Apr 16, 2026
7e9b5fe
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Apr 24, 2026
e1d8014
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Apr 24, 2026
83ddbf0
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Apr 24, 2026
69ea4ea
Merge branch 'main' of github-bf06:CDCgov/PyRenew
cdc-mitzimorris Apr 24, 2026
de851b9
ascertainment model and tests
cdc-mitzimorris Apr 28, 2026
2bde8fd
lint fix
cdc-mitzimorris Apr 28, 2026
393e279
Merge remote-tracking branch 'origin/main' into integrate/mem_777_wit…
cdc-mitzimorris Apr 28, 2026
ec95c5a
adding TimeVaryingAscertainment
cdc-mitzimorris Apr 28, 2026
6fcaa11
more integration tests
cdc-mitzimorris Apr 28, 2026
bd0fd60
changes per copilot review
cdc-mitzimorris Apr 29, 2026
594da80
added tutorial
cdc-mitzimorris Apr 30, 2026
c639fe9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 30, 2026
bf2efc6
Merge branch 'mem_777_joint_ascertainment' of github-bf06:CDCgov/PyRe…
cdc-mitzimorris Apr 30, 2026
4b3646a
changes per code review
cdc-mitzimorris Apr 30, 2026
c3d9449
checkpointing - removed time-varying ascertainment
cdc-mitzimorris May 1, 2026
2485638
code cleanup; all unit tests pass
cdc-mitzimorris May 1, 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
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ pip install git+https://github.com/CDCgov/PyRenew@main
- [Latent subpopulation infections](tutorials/latent_subpopulation_infections.md) -- modeling latent infections with subpopulation structure.
- [Observation processes: count data](tutorials/observation_processes_counts.md) -- connecting latent infections to observed counts.
- [Observation processes: measurements](tutorials/observation_processes_measurements.md) -- connecting latent infections to continuous measurements.
- [Joint ascertainment](tutorials/ascertainment.md) -- sharing ascertainment structure across count signals.

## Resources

Expand Down
1 change: 1 addition & 0 deletions docs/tutorials/.pages
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ nav:
- observation_processes_measurements.md
- right_truncation.md
- day_of_week_effects.md
- ascertainment.md
264 changes: 264 additions & 0 deletions docs/tutorials/ascertainment.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
---
title: Joint ascertainment
format:
gfm:
code-fold: true
engine: jupyter
jupyter:
jupytext:
text_representation:
extension: .qmd
format_name: quarto
format_version: '1.0'
jupytext_version: 1.19.1
kernelspec:
display_name: Python 3 (ipykernel)
language: python
name: python3
---

```{python}
# | label: setup
# | output: false
import jax.nn as jnn
import jax.numpy as jnp
import jax.random as random
import numpy as np
import numpyro
import numpyro.distributions as dist
import pandas as pd
import plotnine as p9
import warnings
from plotnine.exceptions import PlotnineWarning
from _tutorial_theme import theme_tutorial

from pyrenew.ascertainment import JointAscertainment
from pyrenew.deterministic import DeterministicPMF
from pyrenew.model import PyrenewBuilder
from pyrenew.observation import NegativeBinomialNoise, PopulationCounts
from pyrenew.randomvariable import DistributionalVariable
from pyrenew.time import MMWR_WEEK

warnings.filterwarnings("ignore", category=PlotnineWarning)
```

Ascertainment is the probability that a latent infection appears in an observed signal, e.g., a hospitalization or visit to the emergency department.
For hospital admissions this probability is often called an infection-hospitalization ratio (IHR).
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
For hospital admissions this probability is often called an infection-hospitalization ratio (IHR).
For hospital admissions this probability is often called an infection-to-hospitalization rate (IHR).

For emergency department visits it might be called an infection-ED-visit ratio (IEDR).
Copy link
Copy Markdown
Collaborator

@dylanhmorris dylanhmorris May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
For emergency department visits it might be called an infection-ED-visit ratio (IEDR).
For emergency department visits it might be called an infection-to-emergency-department rate (IEDR).


PyRenew count observations accept an `ascertainment_rate_rv`.
For simple models this can be any ordinary `RandomVariable`.

For multi-signal models, PyRenew also provides model-level ascertainment components that let multiple observation processes share related ascertainment structure.
These components are intended for count signals whose observation probabilities are logically related because they are different event streams generated from the same latent
infections. Hospital admissions and ED visits are a natural example: they have different observation probabilities, but both depend on clinical care-seeking and reporting.

## Independent scalar ascertainment

Before considering multi-signal models with related ascertainment structure,
we show how to specify a multi-signal model where the signals are modeled independently.
In this model, each observation process has its own scalar ascertainment prior.

```{python}
# | label: independent-observation-processes
hosp_delay_pmf = jnp.array([0.05, 0.10, 0.15, 0.15, 0.20, 0.15, 0.15, 0.05])
hosp_delay_rv = DeterministicPMF("inf_to_hosp_delay", hosp_delay_pmf)

ed_delay_pmf = jnp.array([0.05, 0.15, 0.30, 0.30, 0.15, 0.05])
ed_delay_rv = DeterministicPMF("inf_to_ed_delay", ed_delay_pmf)

hospital_obs_independent = PopulationCounts(
name="hospital",
ascertainment_rate_rv=DistributionalVariable("ihr", dist.Beta(2, 198)),
delay_distribution_rv=hosp_delay_rv,
noise=NegativeBinomialNoise(
DistributionalVariable(
"hospital_concentration", dist.LogNormal(4.0, 0.5)
)
),
)

ed_obs_independent = PopulationCounts(
name="ed_visits",
ascertainment_rate_rv=DistributionalVariable("iedr", dist.Beta(2, 98)),
delay_distribution_rv=ed_delay_rv,
noise=NegativeBinomialNoise(
DistributionalVariable("ed_concentration", dist.LogNormal(4.0, 0.5))
),
)
```

```{python}
# | label: independent-prior-draws
n_draws = 1500
key_ihr, key_iedr = random.split(random.PRNGKey(1))

independent_ihr = dist.Beta(2, 198).sample(key_ihr, (n_draws,))
independent_iedr = dist.Beta(2, 98).sample(key_iedr, (n_draws,))

independent_df = pd.DataFrame(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a comment indicating this will be used for visualization later?

Otherwise a bit unclear what it's doing here.

{
"hospital_ihr": np.array(independent_ihr),
"ed_iedr": np.array(independent_iedr),
}
)
```

## Model-level ascertainment

To share ascertainment structure across count signals, define an ascertainment model once and register it with the builder.
Each observation process receives the appropriate signal-specific accessor from `for_signal()`.

```python
builder = PyrenewBuilder()
Copy link
Copy Markdown
Collaborator

@dylanhmorris dylanhmorris May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add an example creation of the object ascertainment?

builder.add_ascertainment(ascertainment)

PopulationCounts(
name="hospital",
ascertainment_rate_rv=ascertainment.for_signal("hospital"),
...
)
```

The model samples the ascertainment component once per model execution.
The accessors passed to the observation processes read the sampled values.

## Joint scalar ascertainment

Use `JointAscertainment` when each signal has a scalar ascertainment rate, but the rates should be correlated across signals.
The model samples the rates jointly on the logit scale and returns one probability for each signal.
These rates are constant over the model time axis.

```{python}
# | label: joint-ascertainment-object
joint_ascertainment = JointAscertainment(
name="he_ascertainment",
signals=("hospital", "ed_visits"),
baseline_rates=jnp.array([0.01, 0.02]),
scale_tril=jnp.array(
[
[0.7, 0.0],
[0.35, 0.606],
]
),
)
```

The order of the specified `signals` determines how arguments `baseline_rates` and `scale_tril` are interpreted.
In the above example, the first entry is hospitalizations and the second is ED visits.
In a NumPyro trace, this object creates one sample site, `he_ascertainment_eta`, and deterministic signal-specific rates such as `he_ascertainment_hospital` and `he_ascertainment_ed_visits`.

The next block samples directly from the same logit-normal prior used by JointAscertainment. This is only for visualization; in a fitted PyRenew model, JointAscertainment handles this sampling internally.

```{python}
# | label: joint-prior-draws
joint_eta = dist.MultivariateNormal(
loc=joint_ascertainment.baseline_logits,
scale_tril=joint_ascertainment.scale_tril,
).sample(random.PRNGKey(2), (n_draws,))
joint_rates = jnn.sigmoid(joint_eta)

joint_df = pd.DataFrame(
{
"hospital_ihr": np.array(joint_rates[:, 0]),
"ed_iedr": np.array(joint_rates[:, 1]),
}
)

compare_df = pd.concat(
[
independent_df.assign(prior="Independent scalar priors"),
joint_df.assign(prior="Joint scalar prior"),
],
ignore_index=True,
)
compare_df["prior"] = pd.Categorical(
compare_df["prior"],
categories=["Independent scalar priors", "Joint scalar prior"],
ordered=True,
)
```

The following plot compares the samples drawn from independent scalar priors and draws from the correlated logit-normal prior used by `JointAscertainment`.
The dashed line marks the prior-center ratio, IEDR = 2 × IHR.

The prior draws from independent priors show that high IHR draws do not imply high IEDR draws.
The joint prior keeps the same approximate scale while inducing positive dependence between the two rates.

```{python}
# | label: plot-joint-priors
# | fig-cap: |
# | Joint ascertainment induces correlation between scalar signal rates.
# | The dashed line marks the prior-center ratio, IEDR = 2 × IHR.
# | The joint prior keeps the same approximate scale while inducing
# | positive dependence between the two rates.

(
p9.ggplot(compare_df, p9.aes(x="hospital_ihr", y="ed_iedr"))
+ p9.geom_point(alpha=0.2, size=1.0, color="steelblue")
+ p9.geom_abline(intercept=0, slope=2, linetype="dashed", color="gray")
+ p9.facet_wrap("~prior", nrow=1)
+ p9.labs(
x="Hospital ascertainment rate (IHR)",
y="ED ascertainment rate (IEDR)",
title="Independent vs. joint scalar ascertainment",
)
+ theme_tutorial
)
```


## Using ascertainment with a builder

The main API pattern is:

```python
builder = PyrenewBuilder()
builder.configure_latent(...)

joint_ascertainment = JointAscertainment(
name="he_ascertainment",
signals=("hospital", "ed_visits"),
baseline_rates=jnp.array([0.01, 0.02]),
scale_tril=jnp.array(
[
[0.7, 0.0],
[0.35, 0.606],
]
),
)
builder.add_ascertainment(joint_ascertainment)

hospital_obs = PopulationCounts(
name="hospital",
ascertainment_rate_rv=joint_ascertainment.for_signal("hospital"),
delay_distribution_rv=hosp_delay_rv,
noise=hosp_noise_rv,
aggregation="weekly",
reporting_schedule="regular",
start_dow=MMWR_WEEK,
)
builder.add_observation(hospital_obs)

ed_obs = PopulationCounts(
name="ed_visits",
ascertainment_rate_rv=joint_ascertainment.for_signal("ed_visits"),
delay_distribution_rv=ed_delay_rv,
noise=ed_noise_rv,
day_of_week_rv=DeterministicVariable("ed_dow", ed_day_of_week_effects),
)
builder.add_observation(ed_obs)

model = builder.build()
```

Signal names in `for_signal()` must match the names used when the ascertainment model was created.
They do not have to match observation names, but matching them usually makes model code and posterior outputs easier to read.

When running a model that uses weekly observations or day-of-week effects, pass `obs_start_date` so PyRenew can align the model axis to the calendar.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure we need this line.

Suggested change
When running a model that uses weekly observations or day-of-week effects, pass `obs_start_date` so PyRenew can align the model axis to the calendar.


## Summary

- Use ordinary `RandomVariable` objects for independent scalar ascertainment rates.
- Use `JointAscertainment` for correlated scalar rates across signals.
- Ascertainment and latent infection scale are weakly identified without informative priors or external information.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is true but we didn't really demonstrate it above. Remove or justify/discuss more above.

13 changes: 13 additions & 0 deletions pyrenew/ascertainment/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# numpydoc ignore=GL08
"""
Ascertainment models for shared observation-rate structure.
"""

from pyrenew.ascertainment.base import AscertainmentModel, AscertainmentSignal
from pyrenew.ascertainment.joint import JointAscertainment

__all__ = [
"AscertainmentModel",
"AscertainmentSignal",
"JointAscertainment",
]
Loading
Loading