---
title: "Rates and their margins of error"
output:
  rmarkdown::html_vignette:
    toc: true
    toc_depth: 2
    math_method: mathml
bibliography: references.bib
vignette: >
  %\VignetteIndexEntry{Rates and their margins of error}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r}
#| label: knitr-options
#| include: false

knitr::opts_chunk$set(
  collapse = FALSE,
  comment = "#>",
  message = FALSE,
  fig.width = 7,
  fig.height = 5,
  fig.align = "center",
  out.width = "85%"
)
```

```{r}
#| label: setup
#| eval: true

library(catchmentACS)
library(dplyr)
library(sf)  # needed to subset the bundled sf objects with [
```

```{r}
#| label: setup-cache
#| include: false

# Compute every result in this article instead of reading saved ones; the
# option is restored at the end of the article.
old_options <- options(catchmentACS.cache_enabled = FALSE)
```

For each drive-time area, `cacs_derive_rates()` computes five rates from
American Community Survey (ACS) estimates for census tracts, such as the share
of people below the poverty level. Each rate comes with a margin of error (MOE),
the half-width of its confidence interval, at the 90 percent level unless
another level was set with the `level` argument of `cacs_propagate_moe()`,
whose result `cacs_derive_rates()` takes. A `level` given to
`cacs_derive_rates()` itself gives an error. The margin of error is computed
with one of two formulas, chosen for each rate according to the
argument `formula_dispatch`, and the last section of this article checks two
rates against hand calculations.

## A rate is the ratio of two weighted counts

Each rate divides one ACS count by another, such as people below the poverty
level by people for whom poverty status is determined. We use the notation of
`vignette("methodology", package = "catchmentACS")`. For tract $j$, $A_j$ and
$B_j$ are the estimates of the numerator and the denominator, and $w^{cov}_{sj}$
is the coverage weight, the share of the tract's area inside the drive-time area
of site $s$. The rate for the site is

$$
\widehat{R}_s = \frac{\widehat{A}_s}{\widehat{B}_s},
\qquad
\widehat{A}_s = \sum_j w^{cov}_{sj}\, A_j,
\qquad
\widehat{B}_s = \sum_j w^{cov}_{sj}\, B_j .
$$

The numerator and the denominator are counts and are combined like any other
count, which assumes that whatever each of the two variables counts is
spread evenly over the tract's area [@comber2019spatial, p. 8]. The coverage
weights are explained in
`vignette("theory-spatial-aggregation", package = "catchmentACS")`.

The package adds up the numerator and the denominator over the tracts first and
divides once; it does not compute a rate for each tract. When the numerator and
the denominator cover the same tracts and every $B_j$ is positive, the result is
an average of the tract rates $A_j / B_j$ with weights $w^{cov}_{sj} B_j$, the
part of each tract's denominator counted in the drive-time area:

$$
\widehat{R}_s = \sum_j \frac{w^{cov}_{sj}\, B_j}{\widehat{B}_s} \cdot \frac{A_j}{B_j}.
$$

An average of the tract rates weighted by coverage weight or by overlap area
alone would ignore how large each tract's denominator is. Because both counts of
a tract get the same coverage weight, an area that overlaps only one tract has
that tract's rate.

The rates are computed from the weighted counts alone, without the geometry.

## The five rates

The numerator and denominator codes of the five rates are listed in
`cacs_acs_default_rates`:

```{r}
#| label: rate-codes
#| eval: true

# ACS codes of the numerator (num) and the denominator (den) of each rate
cacs_acs_default_rates
```

| Rate | Numerator | Denominator |
|---|---|---|
| `poverty_rate` | people whose income in the past 12 months was below the poverty level (`B17001_002`) | people for whom poverty status is determined (`B17001_001`) |
| `snap_rate` | households that received Food Stamps or the Supplemental Nutrition Assistance Program (SNAP) in the past 12 months (`B22003_002`) | all households (`B22003_001`) |
| `ssi_rate` | households with Supplemental Security Income (SSI) in the past 12 months (`B19056_002`) | all households (`B19056_001`) |
| `unemp_rate` | unemployed people in the civilian labor force (`B23025_005`) | the civilian labor force, among people 16 years and over (`B23025_003`) |
| `labor_force_participation` | people in the labor force, including the armed forces (`B23025_002`) | people 16 years and over (`B23025_001`) |

In each rate the numerator is part of the denominator: the people or households
it counts are among those that the denominator counts. `unemp_rate` and
`labor_force_participation` come from the same ACS table, `B23025`, but divide
by different counts: the civilian labor force for the unemployment rate, and
everyone 16 years and over for labor force participation.

The two counts of a rate are summed over different tracts when a tract in the
area has a row for one of the two codes and not for the other. The package
keeps rows whose estimate is missing, and a rate that needs such a tract is
`NA` rather than wrong. The two counts cover different tracts when those rows
are dropped before `cacs_intersect_weight()` runs, for example by a filter of
your own. The numerator is then no longer part of the denominator, and the rate
can be much larger or smaller than the share for the area, even above 1. No
warning is given. The columns `n_tracts_num` and
`n_tracts_den` of the rate row, described in the help page of
`cacs_derive_rates()`, give the two numbers of tracts.

These five are the only rates that `cacs_derive_rates()` computes. Its `rates`
argument accepts only `cacs_acs_default_rates`; any other list, including a
subset or a reordering of it, gives an error. Other rates, such as a poverty
rate for children built from the age and sex groups of table `B17001`, can be
computed from the results of `cacs_propagate_moe()` or `cacs_run()`. These have
a row with the weighted count and its margin of error for each ACS count
requested. A numerator that adds up several of these counts gets its margin of
error from the formula that the Census Bureau's handbook gives for a sum, which
again treats the counts as independent [@census2020understanding, chap. 8]. The
formulas below then give the margin of error of the rate.

## Two formulas for the margin of error of a rate

The Census Bureau's handbook has a formula for a proportion (its formula 6) and
one for a ratio (formula 7). A proportion is a ratio whose numerator is part of
its denominator; the ratio formula is for a ratio whose numerator is not. Both
compute the margin of error of $\widehat{R}_s$ from the margins of error
$M(\widehat{A}_s)$ and $M(\widehat{B}_s)$ of the two weighted counts
[@census2020understanding, chap. 8]:

$$
\begin{aligned}
\text{proportion formula:} \quad
M(\widehat{R}_s) &= \frac{\sqrt{M(\widehat{A}_s)^2 - \widehat{R}_s^2\, M(\widehat{B}_s)^2}}{\widehat{B}_s},\\
\text{ratio formula:} \quad
M(\widehat{R}_s) &= \frac{\sqrt{M(\widehat{A}_s)^2 + \widehat{R}_s^2\, M(\widehat{B}_s)^2}}{\left| \widehat{B}_s \right|}.
\end{aligned}
$$

The package's warnings and the help page of `cacs_propagate_moe()` call them C1
and C2, and the columns `moe_formula_requested` and `moe_formula_effective`
record them as `"proportion_subset"` and `"general_ratio_conservative"`. The
ratio formula divides by the absolute value of the denominator, which the hand
calculations below use as well; for the five rates, whose denominators are
counts, that is the denominator itself.

The two values under the square root differ by
$2 \widehat{R}_s^2 M(\widehat{B}_s)^2$, so the ratio formula never gives the
narrower margin of error. How much wider it is varies from rate to rate and
from one area to another, and
`vignette("theory-moe-propagation", package = "catchmentACS")` gives the factor.
Both formulas start from margins of error of the weighted counts that treat the
tract estimates as independent, an assumption discussed in the same article.

## The formula used for each rate

By default, `cacs_derive_rates()` and `cacs_run()` use the ratio formula for all
five rates, although the numerator of each is part of its denominator. For these
proportions the handbook gives the proportion formula, so the default is a
choice of the package. The argument `formula_dispatch` of either function takes
one of three values:

::: {style="overflow-x: auto;"}

| Rate | `"general_ratio_conservative"` (default) | `"auto"` | `"proportion_subset"` |
|---|---|---|---|
| `poverty_rate` | ratio | proportion | proportion |
| `labor_force_participation` | ratio | proportion | proportion |
| `snap_rate` | ratio | ratio | ratio |
| `ssi_rate` | ratio | ratio | ratio |
| `unemp_rate` | ratio | ratio | ratio |

:::

`"proportion_subset"` gives the same formulas as `"auto"` and, in addition, a
warning naming the three rates that keep the ratio formula. `unemp_rate` keeps
the ratio formula with every value of `formula_dispatch`, although its
numerator is part of its denominator, to reproduce the 2025 analysis the
package was first written for. The column `moe_formula_requested` of each rate
row records the formula chosen for the rate. For `snap_rate`, `ssi_rate`, and
`unemp_rate`, the proportion formula can be applied by hand to the `estimate`
and `moe` of the rows of the two ACS codes, unless the value under its square
root is negative.

If the proportion formula is chosen for a rate and the value under its square
root is negative, the package uses the ratio formula for that row instead, as
the handbook advises.
`vignette("theory-moe-propagation", package = "catchmentACS")` describes the
columns that record the substitution.

## Two rates computed by hand

The checks below compare hand calculations with the output of
`cacs_derive_rates()` for two rates: `snap_rate` with the ratio formula and
`labor_force_participation` with the proportion formula. For
`labor_force_participation`, the margin of error from the ratio formula is also
computed by hand, for comparison.

The example is the 15-minute area around site `AL_SITE_03` in the data shipped
with the package. Its drive-time area is a circle and its tract estimates are
made-up values, so the rates show the arithmetic and are not estimates for any
real area. The code runs only the three steps of `cacs_run()` that come after
the download and the routing, and it uses `formula_dispatch = "auto"` so that
two of the rates use the proportion formula:

```{r}
#| label: vf-pipeline
#| eval: true

# Drive-time areas and made-up ACS data bundled with the package
iso <- readRDS(system.file(
  "extdata", "legacy_2025_isochrones.rds", package = "catchmentACS"
))
acs <- readRDS(system.file(
  "extdata", "sample_alabama_subset.rds", package = "catchmentACS"
))

site_id    <- "AL_SITE_03"
drive_time <- 15L

iso_one <- iso[
  iso$site_id == site_id & iso$drive_time_min == drive_time, ,
  drop = FALSE
]

weighted <- cacs_intersect_weight(
  iso_sf           = iso_one,
  acs_sf           = acs,
  weight_method    = "area",
  verbose          = FALSE
)
propagated <- cacs_propagate_moe(weighted, verbose = FALSE)

# "auto": the proportion formula for poverty_rate and
# labor_force_participation, the ratio formula for the other three rates
rates <- suppressWarnings(
  cacs_derive_rates(propagated, formula_dispatch = "auto", verbose = FALSE)
)
```

`suppressWarnings()` hides the warning that `cacs_derive_rates()` gives at the
end, which counts the rows where the ratio formula replaced the proportion
formula.

The checks read $\widehat{A}_s$, $\widehat{B}_s$, and their variances, the
numbers that `cacs_derive_rates()` itself uses, from the attribute
`cacs_aggregation_carriers` of the result of `cacs_intersect_weight()`.
`cacs_derive_rates()` removes that attribute, so the checks below need the
result of the earlier step, not a rate table or a result of `cacs_run()`,
where it is `NULL`.
The variances (`var_total_raw`) are squared standard errors (SE), so a margin of
error is $M(\widehat{A}_s) = z\,\mathrm{SE}(\widehat{A}_s)$, with $z = 1.645$
at the 90 percent level:

```{r}
#| label: vf-carriers
#| eval: true

z      <- 1.645
totals <- attr(weighted, "cacs_aggregation_carriers")

# The weighted count (est_total) or its variance (var_total_raw) for one ACS code
total_of <- function(code, col) totals[[col]][totals$variable == code]

totals |>
  filter(variable %in% c(
    "B22003_002", "B22003_001",   # snap_rate  num / den
    "B23025_002", "B23025_001",   # labor_force_participation num / den
    "B17001_002", "B17001_001"    # poverty_rate num / den
  )) |>
  select(variable, est_total, var_total_raw, weight_sum, n_tracts)
```

In this example, `weight_sum` equals `n_tracts`: the
`r totals$n_tracts[totals$variable == "B17001_001"]` tracts lie entirely inside the
drive-time area, so each weighted count is the plain sum of the tract estimates.

## `snap_rate` with the ratio formula

`snap_rate` uses the ratio formula with every value of `formula_dispatch`.
Written with the variances, the formula is
$M(\widehat{R}_s) = z\sqrt{\mathrm{SE}(\widehat{A}_s)^2 + \widehat{R}_s^2\, \mathrm{SE}(\widehat{B}_s)^2}\,/\,\widehat{B}_s$:

```{r}
#| label: vf-c2-hand
#| eval: true

A_snap    <- total_of("B22003_002", "est_total")
B_snap    <- total_of("B22003_001", "est_total")
VarA_snap <- total_of("B22003_002", "var_total_raw")
VarB_snap <- total_of("B22003_001", "var_total_raw")

R_snap        <- A_snap / B_snap                                     # the rate A / B
hand_moe_snap <- z * sqrt(VarA_snap + R_snap^2 * VarB_snap) / abs(B_snap)

pkg_snap <- rates |>
  filter(variable == "snap_rate") |>
  select(estimate, moe, moe_formula_effective, moe_fallback)

list(
  A = A_snap, B = B_snap,
  hand_estimate = R_snap,
  hand_moe_C2   = hand_moe_snap,
  pkg           = pkg_snap
)
```

The package's row records the ratio formula with no substitution, and its
estimate, `r sprintf("%.4f", pkg_snap$estimate)`, and margin of error,
`r sprintf("%.4f", pkg_snap$moe)`, are the values computed by hand:

```{r}
#| label: vf-c2-assert
#| eval: true

all.equal(
  c(estimate = R_snap, moe = hand_moe_snap),
  c(estimate = pkg_snap$estimate, moe = pkg_snap$moe)
)
```

## `labor_force_participation` with the proportion formula

With `formula_dispatch = "auto"`, `labor_force_participation` uses the
proportion formula,
$M(\widehat{R}_s) = z\sqrt{\mathrm{SE}(\widehat{A}_s)^2 - \widehat{R}_s^2\, \mathrm{SE}(\widehat{B}_s)^2}\,/\,\widehat{B}_s$,
unless the value under the square root is negative. The code computes that
value, the margin of error from the proportion formula, and, for comparison, the
margin of error from the ratio formula:

```{r}
#| label: vf-c1-hand
#| eval: true

A_lfp    <- total_of("B23025_002", "est_total")
B_lfp    <- total_of("B23025_001", "est_total")
VarA_lfp <- total_of("B23025_002", "var_total_raw")
VarB_lfp <- total_of("B23025_001", "var_total_raw")

R_lfp       <- A_lfp / B_lfp
under_root  <- VarA_lfp - R_lfp^2 * VarB_lfp           # under the proportion formula's root
hand_moe_C1 <- z * sqrt(under_root) / B_lfp
hand_moe_C2 <- z * sqrt(VarA_lfp + R_lfp^2 * VarB_lfp) / abs(B_lfp)  # for comparison

list(
  under_root  = under_root,     # positive, so the proportion formula can be used
  hand_moe_C1 = hand_moe_C1,
  hand_moe_C2 = hand_moe_C2     # the ratio formula
)
```

The value under the square root is positive, so the proportion formula applies.
In this area, the proportion formula gives a margin of error of
`r sprintf("%.4f", hand_moe_C1)`, and the ratio formula gives
`r sprintf("%.4f", hand_moe_C2)`, `r sprintf("%.1f", hand_moe_C2 / hand_moe_C1)`
times as wide. The package's row records the proportion formula with no
substitution, and its margin of error is the value computed by hand:

```{r}
#| label: vf-c1-assert
#| eval: true

pkg_lfp <- rates |>
  filter(variable == "labor_force_participation") |>
  select(estimate, moe, moe_formula_effective, moe_fallback, moe_fallback_reason)

pkg_lfp

all.equal(hand_moe_C1, pkg_lfp$moe)
```

## All five rates of the example

The rate rows of the example record, for each rate, the formula chosen and the
formula used, and the tract counts:

```{r}
#| label: vf-routing-table
#| eval: true

rates |>
  filter(estimand_family == "derived_rate") |>
  select(variable, estimate, moe,
         moe_formula_requested, moe_formula_effective,
         moe_fallback, moe_fallback_reason,
         n_tracts, n_tracts_num, n_tracts_den) |>
  print(width = Inf)
```

`moe_formula_requested` follows the `"auto"` column of the table in the formula
section: `"proportion_subset"` for `poverty_rate` and
`labor_force_participation`, and `"general_ratio_conservative"` for the other
three rates. `moe_formula_effective` differs from it only for `poverty_rate`.
`n_tracts` is `NA` on every rate row, and `n_tracts_num` equals `n_tracts_den`
because each tract of the area has a row for every ACS code. `poverty_rate`
records a substitution: in this area the value under the square root of its
proportion formula is negative, so the ratio formula was used.
`vignette("theory-moe-propagation", package = "catchmentACS")` works through
such a case by hand.

## A rate whose numerator is missing

A rate and its margin of error are `NA` when the numerator or the denominator,
or the margin of error of either, is missing for the site and drive time. This
happens when one of the two ACS codes has no rows in the ACS data or when a tract
in the area has a missing estimate or margin of error for one of them. It also
happens for every rate of a pair with no tract left. The code below removes
every row of the SSI numerator, `B19056_002`, from the example data and runs the
three steps again, this time with the default value of `formula_dispatch`:

```{r}
#| label: vf-carrier-missing
#| eval: true

acs_drop <- acs[sf::st_drop_geometry(acs)$variable != "B19056_002", ]

weighted_d   <- cacs_intersect_weight(
  iso_sf = iso_one, acs_sf = acs_drop,
  weight_method = "area", verbose = FALSE
)
propagated_d <- cacs_propagate_moe(weighted_d, verbose = FALSE)
rates_d      <- suppressWarnings(cacs_derive_rates(propagated_d, verbose = FALSE))

rates_d |>
  filter(estimand_family == "derived_rate") |>
  select(variable, estimate, moe,
         moe_fallback_reason, failure_origin, n_tracts_num, n_tracts_den) |>
  print(width = Inf)
```

Here `suppressWarnings()` hides the warning at the end, which counts the rate
rows that are `NA` for this reason. `ssi_rate` has `NA` in `estimate` and
`moe`, and `"carrier"` in `failure_origin`, the column that records the step at
which a row failed; `"carrier"` means that a count or margin of error that the
rate needs is missing. `n_tracts_num` and `n_tracts_den` are `NA`, and
`moe_fallback_reason` is `"n/a"` because no margin-of-error formula was
applied. The other four rates have the same estimates as before. With the
default `formula_dispatch`, all four use the ratio formula, so
`labor_force_participation` now has the ratio-formula margin of error computed
for comparison in the second check, and `poverty_rate` records no
substitution.

## References

```{r}
#| label: restore-options
#| include: false

options(old_options)
rm(old_options)
```
