Identify your research question by picking an outcome of interest and some covariates with data available at the county/state level.
In 2019, to what extent do state-level poverty rates and low educational attainment (adults aged 25+ without a high school diploma) predict state-level variations in Hepatitis A rates?
Data Source: CDC NCHHSTP AtlasPlus
library(tigris)
## To enable caching of data, set `options(tigris_use_cache = TRUE)`
## in your R script or .Rprofile.
library(dplyr)
##
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
##
## filter, lag
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
library(tidyverse)
## ββ Attaching core tidyverse packages ββββββββββββββββββββββββ tidyverse 2.0.0 ββ
## β forcats 1.0.1 β readr 2.2.0
## β ggplot2 4.0.3 β stringr 1.6.0
## β lubridate 1.9.5 β tibble 3.3.0
## β purrr 1.2.2 β tidyr 1.3.2
## ββ Conflicts ββββββββββββββββββββββββββββββββββββββββββ tidyverse_conflicts() ββ
## β dplyr::filter() masks stats::filter()
## β dplyr::lag() masks stats::lag()
## βΉ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(here)
## here() starts at /Users/kymoore/Downloads/SISMID_2026_spatial_statistics_module-main-3
library(tmap)
#poverty <- read.csv("/Users/kymoore/Downloads/Mini Project/MapData-3.csv")
#hepa <- read.csv("/Users/kymoore/Downloads/Mini Project/MapData-2.csv")
#education <- read.csv("/Users/kymoore/Downloads/Mini Project/MapData-4.csv")
poverty <- read.csv("/Users/kymoore/Downloads/MapData-3.csv", skip=9)
hepa <- read.csv("/Users/kymoore/Downloads/MapData-2.csv", skip=9)
education <- read.csv("/Users/kymoore/Downloads/MapData-4.csv", skip=9)
#here::i_am("SISMID.Gentrification Mapping Lab.2026.Rmd")
#Data Wrangling
library(dplyr)
library(stringr)
#Tasks
#1. Identify the variables needed from the downloaded datasets.
#2. Check issues related to missing data.
#check documentation
#if its missing becuase its such a low number you can set it (ex: 5 set it to 2.5) otherwise set it to NA
#3. Examine summary statistics of the variables and create some exploratory plots (boxplot, histogram, scatter plot).
# 1. Prepare Dataset A (Select and Rename)
# Syntax: new_name = old_name
poverty_sub <- poverty %>%
select(
FIPS,
poverty_p = Percent,
poverty_num = Numerator,
pov_den = Denominator
)
# 2. Prepare Dataset B (Select and Rename)
education_sub <- education %>%
select(
FIPS,
education_p = Percent,
education_num = Numerator,
education_den = Denominator
)
# 3. Merge everything into the Main Dataset
hepa_final <- hepa %>%
left_join(poverty_sub, by = "FIPS") %>%
left_join(education_sub, by = "FIPS")
hepa_final
#remove punctuation
hepa_final_1 <- hepa_final %>%
# 1. Clean and convert numeric columns
mutate(across(
c(Cases, Rate.per.100000, poverty_p, poverty_num, education_p, education_num),
~ {
x <- na_if(.x, "Data not available")
x <- str_remove_all(x, "[%, ]")
as.numeric(x)
}
)) %>%
# 2. Convert fips to numeric and keep only states + DC (< 60)
filter(as.numeric(FIPS) < 60)
# Replaces with NA
#hepa_final_1 <- hepa_final %>%
# mutate(across(c(Cases, Rate.per.100000, poverty_p, poverty_num, education_p, education_num), ~as.numeric(na_if(.x, "Data not available"))))
hist(hepa_final_1$Cases)
hist(hepa_final_1$Rate.per.100000)
#right skewed
summary(hepa_final_1$Cases)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 1.0 16.0 128.0 369.5 375.0 3392.0
boxplot(hepa_final_1$Cases)
#outliers
plot(x = hepa_final_1$education_p, y = hepa_final_1$Rate.per.100000,
main = "Low Educational Attainment vs. HepA Rate",
xlab = "% of Population 25 years and older w/o HS diploma",
ylab = "HepA Rate per 100,000",
col = "black", # Change point color
pch = 19) # Change point shape to solid circle
# Optional: Add a linear regression trend line
#abline(lm(education_p ~ Rate.per.100000, data = hepa_final_1), col = "red", lwd = 2)
# Corrected formula (predicts Rate from education):
abline(lm(Rate.per.100000 ~ education_p, data = hepa_final_1), col = "red", lwd = 2)
plot(x = hepa_final_1$poverty_p, y = hepa_final_1$Rate.per.100000,
main = "Poverty vs. HepA Rate",
xlab = "% of Households living below the federal poverty level",
ylab = "HepA Rate per 100,000",
col = "black", # Change point color
pch = 19) # Change point shape to solid circle
# Optional: Add a linear regression trend line
#abline(lm(poverty_p ~ Rate.per.100000, data = hepa_final_1), col = "red", lwd = 2)
abline(lm(Rate.per.100000 ~ poverty_p, data = hepa_final_1), col = "red", lwd = 2)
### Calculating the standardized differences
hepa_final_1 <- hepa_final_1 %>%
mutate(
# Standardized Hepatitis A Rate (Z-score)
rate_z = (Rate.per.100000 - mean(Rate.per.100000, na.rm = TRUE)) / sd(Rate.per.100000, na.rm = TRUE),
# Standardized Education
edu_z = (education_p - mean(education_p, na.rm = TRUE)) / sd(education_p, na.rm = TRUE),
# Standardized Poverty
poverty_z = (poverty_p - mean(poverty_p, na.rm = TRUE)) / sd(poverty_p, na.rm = TRUE),
# If you are taking the difference between two standardized proportions (e.g., Poverty vs Education):
poverty_edu_diff_z = poverty_z - edu_z
)
hist(hepa_final_1$rate_z)
hist(hepa_final_1$edu_z)
hist(hepa_final_1$poverty_z)
hist(hepa_final_1$poverty_edu_diff_z)
# 1. Set up a grid of 1 row and 3 columns
par(mfrow = c(2, 2))
# 2. Draw each histogram
hist(hepa_final_1$rate_z,
main = "Standardized Hepatitis A Rate",
xlab = "Z-score",
col = "lightblue")
hist(hepa_final_1$poverty_z,
main = "Standardized Poverty",
xlab = "Z-score",
col = "salmon")
hist(hepa_final_1$edu_z,
main = "Standardized Education",
xlab = "Z-score",
col = "lightgreen")
hist(hepa_final_1$poverty_edu_diff_z,
main = "Standardized Covariate Differences",
xlab = "Z-score",
col = "purple")
# 3. Reset plotting grid back to default (1x1)
par(mfrow = c(1, 1))
#Creating Geospatial Datasets and Mapping
library (tigris)
options(tigris_use_cache = TRUE)
#option cb = TRUE to download the coarser version for speed
#we choose the year 2020
#dat_sf <- counties(state = c("MD", "PA", "NY", "NJ"), cb = TRUE, year = 2020)
#all_states_sf <- states(cb = TRUE, year = 2020)
#plot(all_states_sf)
#plot(all_states_sf["STATEFP"])
# 1. Plot all attributes for US states + DC only
#all_states_sf %>%
# filter(as.numeric(STATEFP) < 60) %>%
#plot()
# 2. Plot ONLY the STATEFP attribute map
#all_states_sf %>%
# filter(as.numeric(STATEFP) < 60) %>%
# select(STATEFP) %>%
# plot()
# Pulls US states with AK & HI automatically resized and shifted
#us_shifted <- states(cb = TRUE, resolution = "20m") %>%
# shift_geometry() # Shifts AK and HI under the Lower 48!
# 1. Load spatial geometry for US states and shift AK/HI
us_shifted <- states(cb = TRUE, resolution = "20m", year = 2020) %>%
filter(as.numeric(STATEFP) < 60) %>% # Keep only states + DC
shift_geometry()
# Plot using ggplot2
ggplot(us_shifted) +
geom_sf(fill = "grey80", color = "white", size = 0.2) +
theme_void() + # Removes empty margins completely
theme(legend.position = "none") # Drops the legend box
# 1. Standardize FIPS to a 2-digit character vector with leading zeros
hepa_final_1 <- hepa_final_1 %>%
mutate(FIPS = str_pad(as.character(FIPS), width = 2, side = "left", pad = "0"))
# 2. Join your clean spatial geometry with your non-spatial dataset
# Note: Ensure state FIPS codes match (e.g., us_shifted$STATEFP == hepa_final_1$FIPS)
hepa_sf <- us_shifted %>%
left_join(hepa_final_1, by = c("STATEFP" = "FIPS"))
Now to map the outcome, using the mapping package tmapβs routines tm_shape and tm_fill. tmap builds the maps as objects making it easier to customize parts of the map (legends, credits, and the layout) and to build multi-map figures.
hepa_map <- tm_shape(hepa_sf) +
tm_fill('Rate.per.100000',
style='quantile',
palette='BuPu',
title='HepA Rates \n Per 100,000 people') +
tm_borders(alpha=0.7) +
tm_credits('Quantile (Equal-Frequency) Class Intervals',
position=c('RIGHT', 'BOTTOM')) +
tm_layout(main.title="HepA Rates in the US",
inner.margins = c(0.1, 0.1, 0.05, 0.05),
main.title.size=1.2, legend.title.size=0.5,
legend.text.size=0.5)
##
## ββ tmap v3 code detected βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
## [v3->v4] `tm_fill()`: instead of `style = "quantile"`, use fill.scale =
## `tm_scale_intervals()`.
## βΉ Migrate the argument(s) 'style', 'palette' (rename to 'values') to
## 'tm_scale_intervals(<HERE>)'
## [v3->v4] `tm_fill()`: migrate the argument(s) related to the legend of the
## visual variable `fill` namely 'title' to 'fill.legend = tm_legend(<HERE>)'
## [v3->v4] `tm_borders()`: use 'fill' for the fill color of polygons/symbols
## (instead of 'col'), and 'col' for the outlines (instead of 'border.col').
## [v3->v4] `tm_borders()`: use `fill_alpha` instead of `alpha`.
## [v3->v4] `tm_layout()`: use `tm_title()` instead of `tm_layout(main.title = )`
hepa_map
## [cols4all] color palettes: use palettes from the R package cols4all. Run
## `cols4all::c4a_gui()` to explore them. The old palette name "BuPu" is named
## "brewer.bu_pu"
## Multiple palettes called "bu_pu" found: "brewer.bu_pu", "matplotlib.bu_pu". The first one, "brewer.bu_pu", is returned.
Next, map standardized log poverty.
poverty_map <- tm_shape(hepa_sf) +
tm_fill('poverty_z',
style='quantile',
palette='BuPu',
title='Standardized Log \n Poverty (%)') + # "\n" moves text to the next line
tm_borders(alpha=0.2) +
tm_credits('Quantile (Equal-Frequency) Class Intervals',
position=c('RIGHT', 'BOTTOM')) +
tm_layout(main.title="% of Households living below the federal poverty level in the US",
inner.margins = c(0.1, 0.1, 0.05, 0.05),
main.title.size=1.2, legend.title.size=0.5,
legend.text.size=0.5)
##
## ββ tmap v3 code detected βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
## [v3->v4] `tm_fill()`: instead of `style = "quantile"`, use fill.scale =
## `tm_scale_intervals()`.
## βΉ Migrate the argument(s) 'style', 'palette' (rename to 'values') to
## 'tm_scale_intervals(<HERE>)'
## [v3->v4] `tm_fill()`: migrate the argument(s) related to the legend of the
## visual variable `fill` namely 'title' to 'fill.legend = tm_legend(<HERE>)'
## [v3->v4] `tm_borders()`: use `fill_alpha` instead of `alpha`.
## [v3->v4] `tm_layout()`: use `tm_title()` instead of `tm_layout(main.title = )`
poverty_map
## [cols4all] color palettes: use palettes from the R package cols4all. Run
## `cols4all::c4a_gui()` to explore them. The old palette name "BuPu" is named
## "brewer.bu_pu"
## Multiple palettes called "bu_pu" found: "brewer.bu_pu", "matplotlib.bu_pu". The first one, "brewer.bu_pu", is returned.
##
## [plot mode] fit legend/component: Some legend items or map compoments do not
## fit well, and are therefore rescaled.
## βΉ Set the tmap option `component.autoscale = FALSE` to disable rescaling.
Plot standardized log educational attainment.
edu_map <- tm_shape(hepa_sf) +
tm_fill('edu_z',
style='quantile',
palette='BuPu',
title='Standardized Log \n Low Educational Attainment') + #reverse coding??
tm_borders(alpha=0.2) +
tm_credits('Quantile (Equal-Frequency) Class Intervals',
position=c('RIGHT', 'BOTTOM')) +
tm_layout(main.title="% of Population 25 years and older w/o HS diploma",
inner.margins = c(0.1, 0.1, 0.05, 0.05),
main.title.size=1.2, legend.title.size=0.5,
legend.text.size=0.5)
##
## ββ tmap v3 code detected βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
## [v3->v4] `tm_fill()`: instead of `style = "quantile"`, use fill.scale =
## `tm_scale_intervals()`.
## βΉ Migrate the argument(s) 'style', 'palette' (rename to 'values') to
## 'tm_scale_intervals(<HERE>)'
## [v3->v4] `tm_fill()`: migrate the argument(s) related to the legend of the
## visual variable `fill` namely 'title' to 'fill.legend = tm_legend(<HERE>)'
## [v3->v4] `tm_borders()`: use `fill_alpha` instead of `alpha`.
## [v3->v4] `tm_layout()`: use `tm_title()` instead of `tm_layout(main.title = )`
edu_map
## [cols4all] color palettes: use palettes from the R package cols4all. Run
## `cols4all::c4a_gui()` to explore them. The old palette name "BuPu" is named
## "brewer.bu_pu"
## Multiple palettes called "bu_pu" found: "brewer.bu_pu", "matplotlib.bu_pu". The first one, "brewer.bu_pu", is returned.
###Spatial Exploratory Analysis
Defining Spatial Proximity
library (spdep)
## Loading required package: spData
## To access larger datasets in this package, install the spDataLarge
## package with: `install.packages('spDataLarge',
## repos='https://nowosad.github.io/drat/', type='source')`
## Loading required package: sf
## Linking to GEOS 3.13.0, GDAL 3.8.5, PROJ 9.5.1; sf_use_s2() is TRUE
#Drop missing data
hepa_sf = subset (hepa_sf, !is.na (hepa_sf$Rate.per.100000))
#Because our shapefile here is un-projected, distance operations can be difficult. Alternatively, we can project the data to, for example, the Lambert Conformal Conic North America.
# 1. Project the data to Lambert Conformal Conic North America (ESRI:102004)
# (Recommended for accurate distance and spatial operations across the US)
hepa_sf_proj = st_transform(hepa_sf, crs = "ESRI:102004")
center_proj = st_centroid(hepa_sf_proj)
#Create 1-st order adjacency and distance buffer (100km)
#Plot the projected geometry
#plot (st_geometry(hepa_sf_proj))
nb.1st = poly2nb (hepa_sf_proj)
nb.buffer = dnearneigh(center_proj, d1=0, d2 = 25*1000)
#nb2 = poly2nb (hepa_sf_proj, queen=FALSE)
#take the union of neighbors
nb = union.nb (nb.1st, nb.buffer) #take the union of neighbors
#A lot of good summaries about the neighbor structure
summary (nb)
## Neighbour list object:
## Number of regions: 51
## Number of nonzero links: 218
## Percentage nonzero weights: 8.381392
## Average number of links: 4.27451
## 2 regions with no links:
## 50, 51
## 3 disjoint connected subgraphs
## Link number distribution:
##
## 0 1 2 3 4 5 6 7 8
## 2 1 5 9 10 10 10 2 2
## 1 least connected region:
## 37 with 1 link
## 2 most connected regions:
## 6 18 with 8 links
The function nb2mat () then creates a proximity/weight matrix
#Create weight matrix
W= nb2listw(nb, style="W", zero.policy = TRUE)
#W = nb2mat (nb, style = "B", zero.policy = TRUE) #Stopped code - Hawaii (or states separated by water/projected geometry boundaries) do not share a physical border with any other state SO add zero.policy
W
## Characteristics of weights list object:
## Neighbour list object:
## Number of regions: 51
## Number of nonzero links: 218
## Percentage nonzero weights: 8.381392
## Average number of links: 4.27451
## 2 regions with no links:
## 50, 51
## 3 disjoint connected subgraphs
##
## Weights style: W
## Weights constants summary:
## n nn S0 S1 S2
## W 49 2401 49 24.57728 202.0046
#center_proj = st_centroid(hepa_sf_proj) ##Extract centroids of polygons
#center = st_transform(center_proj, crs = 4326) #Re-project back to WGS 84
#center = st_coordinates (center)
#plot (st_geometry(hepa_sf))
#plot (nb, center, add = TRUE, col = "blue")
#plot (nb2, center, add = TRUE, col = "red", lwd = 1)
#title (main="Blue lines = additional neighbours by queen's case")
## Using kth-nearest distance
#nb.k1 = knn2nb (knearneigh(center_proj,k=1))
#nb.k2 = knn2nb (knearneigh(center_proj,k=2), row.names=row.names(center))
#plot (st_geometry(hepa_sf))
#plot (nb.k2, center, add = TRUE, col = "red", lwd = 1)
#plot (nb.k1, center, add = TRUE, col = "blue", lwd = 1)
#title (main="Blue lines = additional second nearest-neighbour")
## Using buffer distance
#nb.buffer1 = dnearneigh(center_proj, d1=0, d2 = 25*1000)
#nb.buffer2 = dnearneigh(center_proj, d1=0, d2 = 40*1000)
#plot (st_geometry(hepa_sf))
#plot (nb.buffer2, center, add = TRUE, col = "red", lwd = 1)
#plot (nb.buffer1, center, add = TRUE, col = "blue", lwd =2)
#legend ("topright", legend =c("<25 km", "< 40 km"), col= c("blue", "red"), pch = 16)
Monte Carlo-based Moranβs I
moran.mc(hepa_sf$Rate.per.100000, W, nsim = 10000)
##
## Monte-Carlo simulation of Moran I
##
## data: hepa_sf$Rate.per.100000
## weights: W
## number of simulations + 1: 10001
##
## statistic = 0.19811, observed rank = 9779, p-value = 0.0222
## alternative hypothesis: greater
moran.mc(hepa_sf$poverty_z, W, nsim = 10000) #look at the p value
##
## Monte-Carlo simulation of Moran I
##
## data: hepa_sf$poverty_z
## weights: W
## number of simulations + 1: 10001
##
## statistic = 0.50058, observed rank = 10001, p-value = 9.999e-05
## alternative hypothesis: greater
moran.mc(hepa_sf$edu_z, W, nsim = 10000) #look at the p value
##
## Monte-Carlo simulation of Moran I
##
## data: hepa_sf$edu_z
## weights: W
## number of simulations + 1: 10001
##
## statistic = 0.52672, observed rank = 10001, p-value = 9.999e-05
## alternative hypothesis: greater
###Spatial Regression Modeling
#For state π , let ππ be the number of Hepatitis A cases, ππ be poverty or low educational attainment, and ππ be the population size. We consider several Poisson log-linear models of the following form.
#ππ log(ππ )βΌPoisson(ππ )=log(ππ )+π½0+π½1ππ +ππ
#The standard Poisson model assumes no spatial variation (ππ =0 for all π ).
#The independent random intercept model assumes that ππ βΌπ(0,π2).
#The spatial random intercept model (under the improper CAR model formulation) assumes that ππ =ππ +πΎπ , where ππ βΌπ(0,π2) and πΎπ is a conditional autoregressive model.
library (INLA)
## Warning: package 'INLA' was built under R version 4.5.3
## Loading required package: Matrix
##
## Attaching package: 'Matrix'
## The following objects are masked from 'package:tidyr':
##
## expand, pack, unpack
##
# 1. Strip commas and convert population to numeric
hepa_sf <- hepa_sf %>%
mutate(
Population = as.numeric(str_remove_all(Population, ",")),
log_pop = log(Population)
)
#Some data wrangling first
hepa_sf$areal_ID = 1:nrow (hepa_sf) #Create a county ID from 1 to 226
hepa_sf$log_pop = log (hepa_sf$Population)
hepa_sf$poverty_std = hepa_sf$poverty_p / 0.1 #standardized by the interquartile range (0.1)
#INLA needs a specific summary of adjacency structure
nb2INLA ("adj.txt", nb) #write out a file
G <- inla.read.graph(filename = "adj.txt") #read in to get INLA's graph format
#Fit glm
fit.glm = glm (Cases~poverty_std+offset (log_pop), dat = hepa_sf, family = "poisson")
#Fit exchangeable (random effect model)
#Note: "E" here is the offset and it does not need to be log-transformed
fit.exch = inla (Cases~1+f(areal_ID)+poverty_std, E= hepa_sf$Population, family = "poisson", data = hepa_sf,
control.compute = list(dic = TRUE, waic = TRUE, return.marginals.predictor=TRUE))
#Fit improper CAR model (Besag)
fit.iCAR = inla (Cases~1+f(areal_ID, model = "besag", graph = G)+poverty_std, E = hepa_sf$Population, family = "poisson", data = hepa_sf,control.compute = list(dic = TRUE, waic = TRUE))
library(knitr)
# 1. Extract GLM results
glm_sum <- summary(fit.glm)$coefficients["poverty_std", ]
glm_beta <- glm_sum["Estimate"]
glm_se <- glm_sum["Std. Error"]
glm_rr <- exp(glm_beta)
glm_lower <- exp(glm_beta - 1.96 * glm_se)
glm_upper <- exp(glm_beta + 1.96 * glm_se)
# 2. Extract Independent Random Effect (Exchangeable) results
exch_beta <- fit.exch$summary.fixed["poverty_std", "mean"]
exch_se <- fit.exch$summary.fixed["poverty_std", "sd"]
exch_rr <- exp(exch_beta)
exch_lower <- exp(fit.exch$summary.fixed["poverty_std", "0.025quant"])
exch_upper <- exp(fit.exch$summary.fixed["poverty_std", "0.975quant"])
# 3. Extract Spatial (iCAR) results
icar_beta <- fit.iCAR$summary.fixed["poverty_std", "mean"]
icar_se <- fit.iCAR$summary.fixed["poverty_std", "sd"]
icar_rr <- exp(icar_beta)
icar_lower <- exp(fit.iCAR$summary.fixed["poverty_std", "0.025quant"])
icar_upper <- exp(fit.iCAR$summary.fixed["poverty_std", "0.975quant"])
# 4. Combine into a clean Data Frame
results_table <- data.frame(
Model = c("GLM", "Ind Random Effect", "Spatial"),
beta = round(c(glm_beta, exch_beta, icar_beta), 2),
`Std Err` = round(c(glm_se, exch_se, icar_se), 2),
RR = round(c(glm_rr, exch_rr, icar_rr), 2),
`95% Interval` = sprintf("(%.2f, %.2f)",
c(glm_lower, exch_lower, icar_lower),
c(glm_upper, exch_upper, icar_upper)),
check.names = FALSE
)
# Display formatted table in R Console
kable(results_table, align = "lcccc")
| Model | beta | Std Err | RR | 95% Interval |
|---|---|---|---|---|
| GLM | 0.02 | 0.00 | 1.02 | (1.02, 1.02) |
| Ind Random Effect | 0.02 | 0.01 | 1.02 | (1.01, 1.04) |
| Spatial | 0.01 | 0.01 | 1.01 | (1.00, 1.03) |