Skip to content

Commit 03bbdf5

Browse files
authored
docs: Equations and plots (#339)
- Tweak plots, including color scale - bug: Preprocess filtering now removes dates after end of last season - Improve equation notation - Add tool for rendering equation
1 parent c38c872 commit 03bbdf5

10 files changed

Lines changed: 149 additions & 84 deletions

File tree

Makefile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
CONFIG = scripts/config_vignette.yaml
21
RAW_DATA = data/raw.parquet
32

43
RUN_ID = $(shell python scripts/get_run_id.py --config=$(CONFIG))

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Vaccination coverage forecasting
22

3-
_Formerly known as Immunization Uptake Projections, or `vcf`._
3+
_Formerly known as Immunization Uptake Projections, or `iup`._
44

55
## Getting started
66

@@ -22,9 +22,8 @@ The vignette uses monthly estimates of season flu vaccination coverage, from the
2222

2323
### Running the vignette
2424

25-
1. Run the pipeline with `make`. (You can run steps in parallel with, e.g., `make -j4`.)
26-
- By default, `make` will use `scripts/config_vignette.yaml` for its configuration.
27-
- You can use different configs by running `make CONFIG=/path/to/config.yaml`.
25+
1. Run the pipeline with `mise run vignette`.
26+
- Inspect `mise.toml` for how to use `make` to run with different numbers of threads and different configs.
2827
2. Inspect `output/vignette/`:
2928
- `config.yaml`: a copy of the input config
3029
- `data.parquet`: the preprocessed, observed data

docs/lpl.md

Lines changed: 25 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,50 +4,44 @@ This model proposes a latent true coverage curve, which is subject to observatio
44

55
## Terminology and notation
66

7-
- _feature_: A categorical feature of the data (e.g., season, geography) that partially determines coverage
8-
- _level_: A value taken on by a feature (e.g., New Jersey, or 2018/2019)
9-
- _group_: A unique combination of features (e.g., New Jersey in 2018/2019)
10-
- $t$: time since the start of the season, measured in $\text{year}^{-1}$
11-
- $n_{gt}$: number of people in group $g$, surveyed at time $t$. Drawn from the `sample_size` column of the NIS data; called `N_tot` in the codebase.
12-
- $x_{gt}$: number of people in group $g$, surveyed at time $t$, who are vaccinated. Approximated as $\mathrm{round}(\hat{v}_{gt}, n_{gt})$, where $\hat{v}_{gt}$ is the `estimate` column.
13-
- $v_g(t)$: latent true coverage among group $g$ at time $t$
14-
- $z_{gj}$: integer index indicating the level of the $j$-th feature for group $g$.
15-
16-
For example, let the features be season and geography, in that order. Let group 5 be associated with the fourth season and the third geography. Then $z_{51} = 4$ and $z_{52} = 3$.
17-
18-
## Model overview
19-
20-
For each group $g$, the latent coverage $v_g(t)$ is assumed to be a sum of a logistic curve and a line with intercept at $t=0$. The shape parameter $K$ and midpoint $\tau$ of the logistic curve are assumed to be common to all groups. The height $A_g$ of the logistic curve is a grand mean $\mu_A$ plus effects $\delta_{A,j,z_{gj}}$ for each feature $j$. For example, the $A_g$ for Alaska in 2018/2019 will be the grand mean $\mu_A$, plus the Alaska effect, plus the 2018/2019 effect. In fact, the model uses a third interaction term season-geography, so there is also an Alaska-in-2018/2019 term.
21-
22-
The slopes $M_g$ follow a similar pattern.
23-
24-
The observations $x_{gt}$ are beta binomial-distributed around the mean $v_g(t) \cdot n_{gt}$, with variance modified by an extra parameter $D$.
7+
- $C_{sgm}$: reported, estimated coverage (proportion of people vaccinated) in season $s$, geography (i.e., state) $g$, and month $m$ (`estimate` in the data)
8+
- $N_{sgm}$: number of people surveyed (`sample_size` in the data)
9+
- $v_{sg}(t)$: latent true coverage at time $t$ (measured in years)
2510

2611
## Model equations
2712

2813
```math
2914
\begin{align*}
30-
x_{gt} &\sim \mathrm{BetaBinom}\big(v_g(t) \cdot D, [1-v_g(t)] \cdot D, n_{gt}\big) \\
31-
v_g(t) &= \frac{A_g}{1 + \exp\{- K \cdot (t - \tau)\}} + M_g t \\
32-
A_g &= \mu_A + \sum_j \delta_{Aj z_{gj}} \\
33-
M_g &= \mu_M + \sum_j \delta_{Mj z_{gj}} \\
34-
\mu_A &\sim \text{Beta}(100.0, 180.0) \\
35-
\mu_M &\sim \text{Gamma}(\text{shape} = 1.0, \text{rate} = 10.0) \\
36-
\delta_{Ajk} &\sim \mathcal{N}(0, \sigma_{Aj}) \\
37-
\delta_{Mjk} &\sim \mathcal{N}(0, \sigma_{Mj}) \\
38-
\sigma_{Aj} &\sim \text{Exp}(40.0) \\
39-
\sigma_{Mj} &\sim \text{Exp}(40.0) \\
15+
X_{sgm} &= \mathrm{round}(C_{sgm} \cdot N_{sgm}) \\
16+
v_{sg}(t) &= \frac{A_{sg}}{1 + \exp\{- K \cdot (t - \tau)\}} + B_{sg} t \\
17+
A_{sg} &= \beta^{(A)} + \beta_s^{(AS)} + \beta_g^{(AG)} + \beta_{sg}^{(ASG)} \\
18+
B_{sg} &= \beta^{(B)} + \beta_s^{(BS)} + \beta_g^{(BG)} + \beta_{sg}^{(BSG)} \\
19+
\\
20+
X_{sgm} &\sim \mathrm{BetaBinom}\big( N_{sgm}, v_{sg}(t_m) \cdot D, [1-v_{sg}(t_m)] \cdot D \big) \\
4021
K &\sim \text{Gamma}(\text{shape} = 25.0, \text{rate} = 1.0) \\
4122
\tau &\sim \text{Beta}(100.0, 225.0) \\
4223
D &\sim \text{Gamma}(\text{shape} = 350.0, \text{rate} = 1.0) \\
24+
\beta^{(A)} &\sim \text{Beta}(100.0, 180.0) \\
25+
\beta_s^{(AS)} &\sim \mathcal{N}\left(0, \sigma^{(AS)}\right) \\
26+
\beta_g^{(AG)} &\sim \mathcal{N}\left(0, \sigma^{(AG)}\right) \\
27+
\beta_{sg}^{(ASG)} &\sim \mathcal{N}\left(0, \sigma^{(ASG)}\right) \\
28+
\beta^{(B)} &\sim \text{Gamma}(\text{shape} = 1.0, \text{rate} = 10.0) \\
29+
\beta_s^{(BS)} &\sim \mathcal{N}\left(0, \sigma^{(BS)}\right) \\
30+
\beta_g^{(BG)} &\sim \mathcal{N}\left(0, \sigma^{(BG)}\right) \\
31+
\beta_{sg}^{(BSG)} &\sim \mathcal{N}\left(0, \sigma^{(BSG)}\right) \\
32+
\sigma^{(\bullet)} &\sim \text{Exp}(40.0) \\
4333
\end{align*}
4434
```
4535

46-
Note that:
36+
Notes:
37+
38+
- The latent coverage $v_{sg}(t)$ is assumed to be a sum of a logistic curve and a line with intercept at $t=0$
39+
- The shape parameter $K$ and midpoint $\tau$ of the logistic curve are assumed to be common to all groups
40+
- The height $A_{sg}$ of the logistic curve is a grand mean $\beta^{(A)}$ plus effects for the season, state, and season-state interaction. The slopes $M_g$ follow a similar pattern.
4741

4842
```math
4943
\begin{align*}
50-
\mathbb{E}[x_{gt}] &= v_g(t) \cdot n_{gt} \\
51-
\mathrm{Var}[x_{gt}] &= v_g(t) \cdot [1-v_g(t)] \cdot \frac{n_{gt} (n_{gt} + D)}{D+1}
44+
\mathbb{E}[X_{sgk}] &= v_{sg}(t_k) \cdot N_{sgk} \\
45+
\mathrm{Var}[X_{sgk}] &= v_{sg}(t_k) \cdot [1-v_{sg}(t_k)] \cdot \frac{N_{sgk} (N_{sgk} + D)}{D+1}
5246
\end{align*}
5347
```

docs/render_eq.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
Render equation block as an image
3+
"""
4+
5+
import subprocess
6+
import tempfile
7+
from pathlib import Path
8+
9+
import pdf2image
10+
11+
12+
def get_eqn(path="docs/lpl.md") -> str:
13+
"""Collect the first back-ticked math block from `path`"""
14+
lines = []
15+
in_eq = False
16+
with open(path) as f:
17+
for line in f:
18+
line = line.strip()
19+
if not in_eq and line == "```math":
20+
in_eq = True
21+
elif in_eq and line == "```":
22+
in_eq = False
23+
break
24+
elif in_eq:
25+
lines.append(line)
26+
else:
27+
pass
28+
29+
return "\n".join(lines)
30+
31+
32+
def latex_to_png(latex_str: str, out_path: str, dpi: int = 400, verbose: bool = False):
33+
"""Render `latex_str` using pdflatex"""
34+
content = (
35+
r"""
36+
\documentclass[preview,border=2pt]{standalone}
37+
\usepackage{amsmath,amssymb}
38+
\begin{document}
39+
%s
40+
\end{document}
41+
"""
42+
% latex_str
43+
)
44+
45+
with tempfile.TemporaryDirectory() as td:
46+
path = Path(td) / "eq.tex"
47+
path.write_text(content)
48+
result = subprocess.run(
49+
[
50+
"pdflatex",
51+
"-interaction=nonstopmode",
52+
"-output-directory",
53+
td,
54+
str(path),
55+
],
56+
capture_output=True,
57+
)
58+
59+
if verbose:
60+
print(result.stdout.decode("utf-8"))
61+
print(result.stderr.decode("utf-8"))
62+
63+
pages = pdf2image.convert_from_path(str(Path(td) / "eq.pdf"), dpi=dpi)
64+
65+
pages[0].save(out_path, "PNG")
66+
67+
68+
if __name__ == "__main__":
69+
eqn = get_eqn()
70+
print(eqn)
71+
latex_to_png(eqn, "tmp_eqn.png", verbose=False)

mise.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[tasks]
2+
vignette = "make -j4 CONFIG=scripts/config_vignette.yaml"
3+
full = "make -j4 CONFIG=scripts/config_full.yaml"

scripts/eval.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@
3434
assert len(forecast_season) == 1, "Can only score forecasts from one season"
3535
forecast_season = forecast_season[0]
3636

37-
eos_abs_diff = vcf.eos_abs_diff(
38-
obs=data.filter(pl.col("season") == pl.lit(forecast_season)),
39-
pred=pred.filter(pl.col("season") == pl.lit(forecast_season)),
40-
features=["season", "geography"],
41-
)
37+
obs = data.filter(pl.col("season") == pl.lit(forecast_season))
38+
pred = pred.filter(pl.col("season") == pl.lit(forecast_season))
39+
features = ["season", "geography"]
4240

43-
eos_abs_diff.write_parquet(args.output)
41+
scores = vcf.eos_abs_diff(obs=obs, pred=pred, features=features)
42+
43+
scores.write_parquet(args.output)

scripts/plot_data.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import yaml
99

1010
AXIS_PERCENT = alt.Axis(format=".0%")
11+
SCALE = alt.Scale(domain=(0, 0.70))
1112
LINE_OPACITY = 0.25
1213

1314
MEDIAN_POINT_KWARGS = {
@@ -18,14 +19,10 @@
1819
}
1920

2021
MEDIAN_ENCODINGS = [
21-
alt.Color(
22-
"type",
23-
scale=alt.Scale(domain=["datum", "median"], range=["black", "red"]),
24-
legend=None,
25-
),
2622
alt.Shape(
2723
"type",
2824
scale=alt.Scale(domain=["datum", "median"], range=["circle", "stroke"]),
25+
legend=None,
2926
),
3027
alt.Size("type", scale=alt.Scale(domain=["datum", "median"], range=[20, 200])),
3128
]
@@ -187,20 +184,26 @@ def hightlight_state(df, month, order_n, value="score_value", state_var="geograp
187184
eos = data.filter((pl.col("time_end") == pl.col("time_end").max()).over("season"))
188185

189186
# for each season, show eos spread over states
190-
alt.Chart(add_medians(eos, "season")).mark_point().encode(
187+
alt.Chart(eos).mark_boxplot(color="black").encode(
191188
alt.X("season", title=None),
192-
alt.Y("estimate", title="End of season coverage", axis=AXIS_PERCENT),
193-
*MEDIAN_ENCODINGS,
189+
alt.Y(
190+
"estimate", title="End of season coverage", axis=AXIS_PERCENT, scale=SCALE
191+
),
194192
).save(out_dir / "coverage_by_season.svg")
195193

196194
# for each state, show eos spread over seasons
197-
alt.Chart(add_medians(eos, "geography")).mark_point().encode(
195+
alt.Chart(add_medians(eos, "geography")).mark_point(color="black").encode(
198196
alt.X(
199197
"geography",
200198
title=None,
201199
sort=alt.EncodingSortField("estimate", "median", "descending"),
202200
),
203-
alt.Y("estimate", title="End of season coverage", axis=AXIS_PERCENT),
201+
alt.Y(
202+
"estimate",
203+
title="End of season coverage",
204+
axis=AXIS_PERCENT,
205+
scale=SCALE,
206+
),
204207
*MEDIAN_ENCODINGS,
205208
).save(out_dir / "coverage_by_state.svg")
206209

scripts/plot_preds.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,32 @@
99
import vcf
1010

1111
LINE_OPACITY = 0.4
12+
MODEL_COLOR_SCALE = alt.Scale(
13+
domain=["LPLModel", "RFModel"], range=["#a6611a", "#018571"]
14+
)
1215

1316

1417
def plot_data_cone(
1518
chart_data: pl.DataFrame,
1619
facet_kwargs: dict,
17-
out_dir: str,
20+
out_dir: Path,
1821
filename: str,
1922
properties_kwargs: dict | None = None,
2023
config_legend_kwargs: dict | None = None,
2124
config_axis_kwargs: dict | None = None,
2225
):
2326
base = alt.Chart(chart_data).encode(
24-
alt.X("time_end", title=None, axis=alt.Axis(format="%b"))
27+
alt.X("time_end", title=None, axis=alt.Axis(format="%b", grid=False))
2528
)
2629
fc_cone = base.mark_area(opacity=0.25).encode(
27-
alt.Y("pred_lci", title="", axis=AXIS_PERCENT),
30+
alt.Y(
31+
"pred_lci", title="", axis=AXIS_PERCENT, scale=alt.Scale(domain=(0.0, 0.70))
32+
),
2833
alt.Y2("pred_uci"),
29-
alt.Color("model"),
34+
alt.Color("model", scale=MODEL_COLOR_SCALE),
3035
)
3136
fc_points = base.mark_line(opacity=0.75).encode(
32-
alt.Y("pred_estimate"), alt.Color("model")
37+
alt.Y("pred_estimate"), alt.Color("model", scale=MODEL_COLOR_SCALE)
3338
)
3439
data_points = base.mark_point(color="black").encode(alt.Y("obs_estimate"))
3540
data_error = base.mark_rule(color="black").encode(
@@ -135,7 +140,7 @@ def plot_data_cone(
135140
plot_data_cone(
136141
chart_data=chart_data,
137142
facet_kwargs={
138-
"column": "forecast_date",
143+
"column": alt.Column("forecast_date", header=alt.Header(format="%b %Y")),
139144
"row": "geography",
140145
},
141146
out_dir=out_dir,
@@ -174,7 +179,9 @@ def plot_data_cone(
174179
sort=["South Dakota", "North Dakota"],
175180
),
176181
"column": alt.Column(
177-
"forecast_date", header=alt.Header(labelFontSize=20), title=""
182+
"forecast_date",
183+
header=alt.Header(format="%b %Y", labelFontSize=20),
184+
title="",
178185
),
179186
},
180187
config_axis_kwargs={"labelFontSize": 20, "titleFontSize": 24},
@@ -196,7 +203,9 @@ def plot_data_cone(
196203
sort=["Wyoming", "Vermont"],
197204
),
198205
"column": alt.Column(
199-
"forecast_date", header=alt.Header(labelFontSize=20), title=""
206+
"forecast_date",
207+
header=alt.Header(format="%b %Y", labelFontSize=20),
208+
title="",
200209
),
201210
},
202211
config_axis_kwargs={"labelFontSize": 20, "titleFontSize": 24},

scripts/plot_scores.py

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import altair as alt
55
import polars as pl
66
import yaml
7+
from plot_preds import MODEL_COLOR_SCALE
78

89
LINE_OPACITY = 0.4
910

@@ -26,34 +27,17 @@
2627
out_dir = out_flag.parent
2728
out_dir.mkdir(parents=True, exist_ok=True)
2829

29-
# sis = score in season
30-
data = scores.filter(pl.col("score_fun") == pl.lit("eos_abs_diff"))
31-
32-
base = alt.Chart(data).encode(
33-
alt.X("forecast_date", type="temporal", axis=alt.Axis(format="%b"))
34-
)
35-
line_chart = base.mark_line(point=True, opacity=LINE_OPACITY).encode(
36-
alt.Y("score_value", title="Score (abs. end-of-season diff.)"),
37-
alt.Detail("geography"),
38-
alt.Color("model"),
39-
)
40-
41-
# Filter for the final forecast date and use the gather_n function to
42-
# make plots with ticks
43-
44-
line_chart.save(out_dir / "scores.svg")
45-
4630
## boxplot of scores across states by forecast date ##
4731
alt.Chart(scores).mark_boxplot(ticks=True).encode(
48-
x=alt.X(
32+
alt.X(
4933
"model",
5034
title=None,
5135
axis=alt.Axis(labels=False, ticks=False),
5236
scale=alt.Scale(padding=1),
5337
),
54-
y=alt.Y("score_value", title="End-of-season Abs Diff"),
55-
color="model",
56-
column=alt.Column(
38+
alt.Y("score_value", title="End-of-season abs. diff. (p.p.)"),
39+
alt.Color("model", scale=MODEL_COLOR_SCALE),
40+
alt.Column(
5741
"forecast_date",
5842
title="",
5943
header=alt.Header(orient="bottom", labelFontSize=20, format="%b"),
@@ -62,6 +46,6 @@
6246
stroke=None
6347
).configure_axis(labelFontSize=20, titleFontSize=24).configure_legend(
6448
labelFontSize=20, title=None
65-
).save(out_dir / "scores_boxplot.svg")
49+
).save(out_dir / "scores.svg")
6650

6751
out_flag.touch()

scripts/preprocess.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ def preprocess(
4646
4747
"""
4848

49+
start_date = pl.date(start_year, season_start_month, season_start_day)
50+
end_date = pl.date(end_year, season_end_month, season_end_day)
51+
4952
def geo_filter(df: pl.DataFrame) -> pl.DataFrame:
5053
"""Optionally retain rows belonging to selected geographies."""
5154
if geographies is None:
@@ -70,8 +73,8 @@ def geo_filter(df: pl.DataFrame) -> pl.DataFrame:
7073
)
7174
)
7275
.filter(
73-
# drop dates before or after the outermost season
74-
pl.col(date_col).dt.year().is_between(start_year, end_year),
76+
# keep dates only within the desired data range
77+
pl.col(date_col).is_between(start_date, end_date),
7578
# drop out-of-season dates between seasons
7679
pl.col("season").is_null().not_(),
7780
)

0 commit comments

Comments
 (0)