--- title: "A worked example with Alabama Pre-K sites" output: rmarkdown::html_vignette: toc: true toc_depth: 2 math_method: mathml bibliography: references.bib vignette: > %\VignetteIndexEntry{A worked example with Alabama Pre-K sites} %\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 [ library(tidyr) ``` ```{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) ``` The last three of the five functions that `cacs_run()` calls are run here one at a time, for three example sites. The result is then checked against a single call to `cacs_run()` and turned into tables for a report. The tables answer questions of the kind asked about Pre-K sites. What share of the people within a short drive of each site are below the poverty level, for example, and at which site is that share highest? ## The sites and the example data `cacs_run()` needs a point for each site, with an identifier in a column `site_id`; `?cacs_run` describes the two forms it accepts. The package's dataset `cacs_alabama_sites` is a table of this kind. Its ten points were made from public coordinates of the centers of ten Alabama cities, each moved by a small random amount, less than 1 km. They are not the locations of Pre-K programs or of any other facility: ```{r} #| label: preview-sites #| eval: true cacs_alabama_sites |> sf::st_drop_geometry() |> select(site_id, site_name, county_fips, region_label) ``` `cacs_run()` uses `site_id` and the points and ignores the other columns. The points are in longitude and latitude on WGS 84 (EPSG:4326), the coordinate reference system that `cacs_run()` requires for sites given as an sf object: ```{r} #| label: preview-sites-geometry #| eval: true sf::st_crs(cacs_alabama_sites)$epsg # X is the longitude and Y the latitude of each point. data.frame( site_id = cacs_alabama_sites$site_id, sf::st_coordinates(cacs_alabama_sites) ) ``` Building drive-time areas around these points needs a routing service, and downloading American Community Survey (ACS) estimates for census tracts needs a Census API key. The calculations in this article therefore use three other files installed with the package. `legacy_2025_sites.rds` holds 20 made-up sites, `AL_SITE_01` to `AL_SITE_20`. `legacy_2025_isochrones.rds` holds their drive-time areas for 5, 10, and 15 minutes, drawn as circles 5, 10, and 15 km in radius instead of by a routing service. `sample_alabama_subset.rds` holds made-up ACS estimates for 911 squares of about 9 km² that play the part of census tracts, each with a margin of error (MOE), the half-width of its 90 percent confidence interval. The Census Bureau publishes the margins of error of ACS estimates at this level [@census2020understanding, chap. 7]. The squares lie apart from each other, so a circle takes in only a few of them. The site file uses the `site_id` values of `cacs_alabama_sites` for other points: `AL_SITE_07` and `AL_SITE_08` below are far from the points of the same names in `cacs_alabama_sites` (see `?cacs_alabama_sites`). The article uses three of the 20 sites, which keeps the tables short. `AL_SITE_07`, `AL_SITE_08`, and `AL_SITE_11` lie in Alabama, and none of the squares in their drive-time areas has a missing estimate, so every row of the results below has a value. ```{r} #| label: choose-sites #| eval: true site_ids <- c("AL_SITE_07", "AL_SITE_08", "AL_SITE_11") ``` The package also installs `legacy_2025_golden_output.rds`, which holds some of the variables and rates of a `cacs_run()` result for the 20 example sites; one of the package's tests compares it with a new run. Its name refers to an analysis from 2025, but it was computed from the same made-up data as this article and says nothing about real places. Half of its rows are placeholders with `NA` values for population weighting, which is not implemented. ## The five steps, one at a time `cacs_run()` calls five functions in turn: `cacs_acs_prefetch()`, `cacs_isochrone()`, `cacs_intersect_weight()`, `cacs_propagate_moe()`, and `cacs_derive_rates()`. The first two get the ACS estimates from the Census Bureau and the drive-time areas from a routing service, and the other three compute from those two results. ### Steps 1 and 2: downloading and routing The first call needs a Census API key, and both need an internet connection: ```{r} #| label: stage1-acs-live #| eval: false # Needs a Census API key and an internet connection. acs <- cacs_acs_prefetch(state = "AL", year = 2023) ``` ```{r} #| label: stage2-iso-live #| eval: false # Needs the osrm package and an internet connection; the public OSRM server # needs no key. iso <- cacs_isochrone( sites = cacs_alabama_sites, drive_times = c(5, 10, 15), provider = "osrm", osrm_mode = "demo" ) ``` `cacs_acs_prefetch()` returns an sf table with one row for each tract and variable and the columns `GEOID`, `NAME`, `variable`, `estimate`, `moe`, and `geometry`. `cacs_isochrone()` returns an sf table with one row for each site and drive time. The two example files have the same columns, so the other three steps run on them as they would on downloaded data: ```{r} #| label: load-fixtures #| eval: true # The three example files described above sites <- readRDS(system.file( "extdata", "legacy_2025_sites.rds", package = "catchmentACS" )) acs <- readRDS(system.file( "extdata", "sample_alabama_subset.rds", package = "catchmentACS" )) iso <- readRDS(system.file( "extdata", "legacy_2025_isochrones.rds", package = "catchmentACS" )) # Keep the three sites and the drive times of 5, 10, and 15 minutes. analysis_sites <- sites |> filter(site_id %in% site_ids) iso <- iso |> filter(site_id %in% site_ids, drive_time_min %in% c(5L, 10L, 15L)) list( acs_dim = dim(acs), acs_crs = sf::st_crs(acs)$epsg, acs_variables = length(unique(acs$variable)), acs_tracts = length(unique(acs$GEOID)), iso_dim = dim(iso), iso_crs = sf::st_crs(iso)$epsg ) ``` The ACS data hold `r length(unique(acs$variable))` variables for `r length(unique(acs$GEOID))` squares in NAD83 (EPSG:4269), and the drive-time areas are in WGS 84 (EPSG:4326): the two coordinate reference systems that `cacs_intersect_weight()` requires. ### Step 3: overlapping tracts and their weights `cacs_intersect_weight()` finds the tracts, here the squares, that overlap each drive-time area and combines their estimates: ```{r} #| label: stage3-intersect #| eval: true weighted <- cacs_intersect_weight( iso_sf = iso, acs_sf = acs, weight_method = "area", verbose = FALSE ) dim(weighted) ``` The result has a row for each site, drive time, and ACS variable. These are the rows for the 10-minute area of `AL_SITE_11`: ```{r} #| label: stage3-read #| eval: true area_11 <- weighted |> filter(site_id == "AL_SITE_11", drive_time_min == 10) |> select(variable, estimate, weight_basis, weight_sum, n_tracts) area_11 ``` A count, such as the total population (`B01003_001`), is the sum of the tract estimates, each multiplied by the tract's coverage weight: the share of the tract's area that lies inside the drive-time area (`weight_basis = "coverage"`). This assumes that whatever a variable counts is spread evenly over each tract. Median household income (`B19013_001`) and per capita income (`B19301_001`) are instead averages of the tract values, weighted by each tract's share of the overlapping area (`"area_mean"`). These weights ignore how many people live in each tract, so the average can be far from the median or per-person value of the drive-time area when the tracts differ in population density. The help page of `cacs_intersect_weight()` defines both weights, and `vignette("theory-spatial-aggregation", package = "catchmentACS")` explains their assumptions. `n_tracts` counts the tracts in the area, and `weight_sum` adds up their coverage weights. The two are equal only when every tract lies wholly inside the area. Here `n_tracts` is `r area_11$n_tracts[1]` and `weight_sum` is `r format(round(area_11$weight_sum[1], 2), nsmall = 2)`: the square around the site lies inside the 10-minute area, and two neighboring squares lie mostly outside it. ### Step 4: margins of error `cacs_intersect_weight()` has already combined the margins of error of the tracts. `cacs_propagate_moe()` computes them again at the confidence level given by its argument `level`, 0.9 by default, and records the formula used for each row in `moe_formula_effective`: ```{r} #| label: stage4-moe #| eval: true with_moe <- cacs_propagate_moe(weighted, verbose = FALSE) with_moe |> select(site_id, drive_time_min, variable, estimate, moe, moe_formula_effective) |> arrange(site_id, drive_time_min, variable) |> head(8) ``` At the default level, the estimates and margins of error are those of step 3. `weighted_sum`, used for counts, is the formula for the margin of error of a sum, applied to the tract margins of error multiplied by the coverage weights. `weighted_mean`, used for medians and per-person values, is the same formula with the area shares. Both treat the tract estimates as independent and the weights as fixed. `vignette("theory-moe-propagation", package = "catchmentACS")` gives the formulas and discusses these assumptions. ### Step 5: rates `cacs_derive_rates()` adds five rows for each site and drive time, one for each rate in `cacs_acs_default_rates`, after the rows of the ACS variables. Each rate divides one weighted count by another, and its margin of error is computed from those of the two counts: ```{r} #| label: stage5-rates #| eval: true final <- cacs_derive_rates(with_moe, verbose = FALSE) dim(final) ``` The rate rows are those whose `estimand_family`, the kind of quantity in the row, is `"derived_rate"`: ```{r} #| label: stage5-read #| eval: true final |> filter(estimand_family == "derived_rate") |> select(site_id, drive_time_min, variable, estimate, moe, moe_formula_effective) |> arrange(site_id, drive_time_min, variable) |> head(10) ``` By default, the margins of error of all five rates come from the ratio formula, recorded as `general_ratio_conservative`. The Census Bureau's handbook gives this formula for a ratio whose numerator is not part of its denominator [@census2020understanding, chap. 8]. The numerator of each rate is part of its denominator, so each rate is a proportion, for which the handbook gives the proportion formula; that formula never gives a wider margin of error than the ratio formula. The argument `formula_dispatch` of `cacs_derive_rates()` and `cacs_run()` chooses between the two: with `"auto"` or `"proportion_subset"`, `poverty_rate` and `labor_force_participation` use the proportion formula, and `snap_rate`, `ssi_rate`, and `unemp_rate` keep the ratio formula. A row for which the value under the square root of the proportion formula is negative gets the ratio formula instead, with `moe_fallback = TRUE`. `vignette("theory-derived-rates", package = "catchmentACS")` describes the five rates, gives both formulas, and explains why `unemp_rate` keeps the ratio formula. ## The same result in one call `cacs_run()` runs the same five functions. The call below supplies the drive-time areas through `precomputed_isochrones` and the ACS data through `acs`, so `cacs_run()` skips the download and the routing and runs the last three functions on the example data: ```{r} #| label: recompose-run #| eval: true result <- cacs_run( sites = analysis_sites, state = "AL", year = 2023, drive_times = c(5, 10, 15), variables = unname(cacs_acs_default_vars), provider = "osrm", precomputed_isochrones = iso, acs = acs, weight_method = "area", output = "long", verbose = FALSE ) class(result) dim(result) ``` The estimates and margins of error are the same as those computed step by step above: ```{r} #| label: recompose-equivalence #| eval: true manual <- final |> arrange(site_id, drive_time_min, variable) oneshot <- tibble::as_tibble(result) |> arrange(site_id, drive_time_min, variable) cols <- c("site_id", "drive_time_min", "variable", "estimate", "moe") all.equal(as.data.frame(manual)[cols], as.data.frame(oneshot)[cols]) ``` The rest of the article works from `result`. ## Tables for a report The tables below are made with dplyr and tidyr from `result`. ### A plain table with the main columns `tibble::as_tibble()` turns `result` into a plain tibble, which keeps its rows in their order. The tables use seven of its columns: ```{r} #| label: explore-columns #| eval: true result_tbl <- tibble::as_tibble(result) report_cols <- c( "site_id", "drive_time_min", "variable", "estimate", "moe", "n_tracts", "failure_origin" ) result_tbl |> select(all_of(report_cols)) |> head(12) ``` In this result `failure_origin`, which records the step at which a row failed, is `"none"` on every row; `?cacs_run` lists its other values and explains how missing tract estimates show up in the result. ### Rates by site and drive time `tidyr::pivot_wider()` turns the rate rows into a table with one row for each site and drive time and one column for each rate: ```{r} #| label: rate-matrix #| eval: true rate_matrix <- result_tbl |> filter(variable %in% names(cacs_acs_default_rates)) |> select(site_id, drive_time_min, variable, estimate) |> pivot_wider(names_from = variable, values_from = estimate) |> arrange(site_id, drive_time_min) print(rate_matrix, width = Inf) ``` `summary(result)` holds the same rates in its element `rates_per_site`. Its element `rates_per_site_moe` writes each rate with its margin of error, rounded to three decimals: ```{r} #| label: rate-matrix-moe #| eval: true print(summary(result)$rates_per_site_moe, width = Inf) ``` A rate that could not be computed for a site and drive time is `NA` in these tables. ### Sites ranked by a rate The code below orders the three sites by the poverty rate of their 15-minute areas, highest first. This rate is the share of the people for whom poverty status is determined who are below the poverty level. The filter on `failure_origin` leaves out a site whose poverty rate is `NA` because routing failed or because a count or margin of error that the rate needs is missing: ```{r} #| label: priority-table #| eval: true ranked_15 <- result_tbl |> filter( drive_time_min == 15, variable == "poverty_rate", failure_origin == "none" ) |> arrange(desc(estimate)) |> transmute( rank = row_number(), site_id, poverty_rate = estimate, moe_90 = moe ) ranked_15 ``` Two estimates $\hat{p}_j$ and $\hat{p}_k$ differ at the 90 percent level when $|\hat{p}_j - \hat{p}_k| > 1.645 \sqrt{\mathrm{SE}_j^2 + \mathrm{SE}_k^2}$, where the standard error $\mathrm{SE}$ of each estimate is its margin of error at the 90 percent level divided by 1.645. This is the Census Bureau's test for comparing two estimates [@census2020understanding, chap. 7]. The code below applies it to each site and the next one in the ranking: ```{r} #| label: ranking-test #| eval: true # Each site against the next one in the ranking, at the 90 percent level ranking_test <- ranked_15 |> mutate( se = moe_90 / 1.645, next_site = lead(site_id), difference = poverty_rate - lead(poverty_rate), threshold = 1.645 * sqrt(se^2 + lead(se)^2), differ = difference > threshold ) |> filter(!is.na(next_site)) |> select(site_id, next_site, difference, threshold, differ) ranking_test ``` The poverty rate of `AL_SITE_07` exceeds that of `AL_SITE_08` by `r sprintf("%.1f", 100 * ranking_test$difference[1])` percentage points, more than the threshold of `r sprintf("%.1f", 100 * ranking_test$threshold[1])` points. The difference between `AL_SITE_08` and `AL_SITE_11`, `r sprintf("%.1f", 100 * ranking_test$difference[2])` points, is below its threshold of `r sprintf("%.1f", 100 * ranking_test$threshold[2])` points, so the test does not show that their poverty rates differ. The margins of error in this test come from the ratio formula, which `cacs_run()` uses for all five rates by default. With `formula_dispatch = "auto"`, `poverty_rate` uses the proportion formula where it can, and that formula never gives a wider margin of error. With the default margins of error, the test therefore finds no more differences than with those from `"auto"`, and it can find fewer. The code below computes the rates again with `"auto"` and repeats the test: ```{r} #| label: ranking-test-auto #| eval: true # suppressWarnings() hides a warning that counts the rows for which the ratio # formula replaced the proportion formula. rates_auto <- suppressWarnings( cacs_derive_rates(with_moe, formula_dispatch = "auto", verbose = FALSE) ) ranking_auto <- rates_auto |> filter( drive_time_min == 15, variable == "poverty_rate", failure_origin == "none" ) |> arrange(desc(estimate)) |> mutate( se = moe / 1.645, next_site = lead(site_id), difference = estimate - lead(estimate), threshold = 1.645 * sqrt(se^2 + lead(se)^2), differ = difference > threshold ) |> select(site_id, moe, moe_fallback, next_site, difference, threshold, differ) ranking_auto ``` With `"auto"`, the margin of error of the 15-minute poverty rate of `AL_SITE_08` is `r sprintf("%.4f", ranking_auto$moe[2])` instead of `r sprintf("%.4f", ranked_15$moe_90[2])`. For `AL_SITE_07` and `AL_SITE_11`, the value under the square root of the proportion formula is negative, so their margins of error still come from the ratio formula (`moe_fallback = TRUE`). The difference between `AL_SITE_08` and `AL_SITE_11`, `r sprintf("%.1f", 100 * ranking_auto$difference[2])` points, is now above its threshold of `r sprintf("%.1f", 100 * ranking_auto$threshold[2])` points. Whether the test shows that these two poverty rates differ therefore depends on the formula for their margins of error. The test treats the two estimates as independent, which is reasonable when the two areas share no tract, as here, where the sites are far apart. Areas that overlap share tracts, and so do the areas of one site for different drive times; the Limitations section of `vignette("methodology", package = "catchmentACS")` explains what this means for the test. With many sites, the test is run on many pairs, so some of the differences it shows may be due to chance. ### One site at three drive times The five rates of `AL_SITE_08` for its three drive-time areas, with their margins of error and 90 percent confidence intervals: ```{r} #| label: focal-site-profile #| eval: true focal_site <- "AL_SITE_08" result_tbl |> filter( site_id == focal_site, variable %in% names(cacs_acs_default_rates) ) |> arrange(variable, drive_time_min) |> transmute( variable, drive_time_min, estimate, moe_90 = moe, ci_low = estimate - moe, ci_high = estimate + moe ) ``` `ci_low` and `ci_high` are the ends of the 90 percent confidence interval, the estimate minus and plus its margin of error. Because each of the three areas lies inside the next larger one, the test above does not apply to the differences between them as it stands. The labor force participation rate of `AL_SITE_08` happens to be almost the same at all three drive times. The made-up values of each variable were drawn separately, so in one square of the 15-minute area the labor force (`B23025_002`) exceeds the population 16 years and over (`B23025_001`), of which it is part. ### A table to save The last table keeps, for each rate, the estimate and its margin of error as proportions and as percentages. Its columns `failure_origin`, `moe_formula_effective`, and `moe_fallback` record whether and how the rate and its margin of error were computed: ```{r} #| label: export-shape #| eval: true export_tbl <- result_tbl |> filter(variable %in% names(cacs_acs_default_rates)) |> transmute( site_id, drive_time_min, variable, estimate, moe_90 = moe, estimate_pct = 100 * estimate, moe_90_pct = 100 * moe, failure_origin, moe_formula_effective, moe_fallback ) |> arrange(site_id, drive_time_min, variable) head(export_tbl, 15) ``` `write.csv(export_tbl, "rates.csv", row.names = FALSE)` saves it as a CSV file. ## A map The map below needs the leaflet package. It shows, on a web base map, `AL_SITE_08`, its 15-minute area, and the squares of the example data that overlap the area, shaded by their poverty rate: ```{r} #| label: focal-map #| eval: !expr requireNamespace("leaflet", quietly = TRUE) # The site and its 15-minute area (both in EPSG:4326) focal_pt <- analysis_sites[analysis_sites$site_id == focal_site, ] area_15 <- iso[iso$site_id == focal_site & iso$drive_time_min == 15, ] # The poverty rate of each tract, from its ACS counts, for the tracts that # overlap the 15-minute area. pov <- acs |> sf::st_drop_geometry() |> filter(variable %in% c("B17001_001", "B17001_002")) |> select(GEOID, variable, estimate) |> tidyr::pivot_wider( id_cols = GEOID, names_from = variable, values_from = estimate ) |> transmute(GEOID, poverty_rate = B17001_002 / B17001_001) tract_geom <- acs |> filter(variable == "B17001_001") |> select(GEOID) |> left_join(pov, by = "GEOID") |> sf::st_transform(4326) squares <- tract_geom[ sf::st_intersects(tract_geom, area_15, sparse = FALSE)[, 1], ] pal <- leaflet::colorNumeric("YlOrRd", domain = squares$poverty_rate) leaflet::leaflet(squares) |> leaflet::addProviderTiles("OpenStreetMap") |> leaflet::addPolygons( fillColor = ~pal(poverty_rate), fillOpacity = 0.6, weight = 0.5, color = "#666666", label = ~sprintf("Tract %s: %.1f%%", GEOID, 100 * poverty_rate) ) |> leaflet::addPolygons( data = area_15, fill = FALSE, weight = 2, opacity = 0.8, color = "#c05621" ) |> leaflet::addCircleMarkers( data = focal_pt, radius = 5, color = "#2b6cb0", fillOpacity = 0.9, stroke = FALSE, popup = ~site_id ) |> leaflet::addLegend( pal = pal, values = ~poverty_rate, title = "Tract poverty rate", labFormat = leaflet::labelFormat(transform = function(x) 100 * x, suffix = "%") ) ``` The blue dot is the site, and the orange outline is its 15-minute area, a circle with a 15 km radius in the example data. The three shaded squares are the tracts from which the estimates for this area come; the rest of the circle holds no square and adds nothing to them. `vignette("visual-walkthrough", package = "catchmentACS")` maps each step for one site in Birmingham, with a drive-time area built by OSRM and ACS estimates for real tracts. ## Running on your own sites With your own sites in `sites` and without `precomputed_isochrones` and `acs`, the call to `cacs_run()` above builds the drive-time areas with the routing service named in `provider` and downloads the ACS data. This needs the osrm package, a Census API key, and an internet connection; the routing services, the API keys, and the cache that keeps downloaded data and drive-time areas for later calls (by default, until the R session ends) are the subject of `vignette("providers", package = "catchmentACS")`. The tables in this article then come out the same way, with the estimates of real tracts in place of the made-up ones. ## References ```{r} #| label: restore-options #| include: false options(old_options) rm(old_options) ```