Learning Objectives: - Apply spatial operations to answer policy-relevant research questions - Integrate census demographic data with spatial analysis - Create publication-quality visualizations and maps - Work with spatial data from multiple sources - Communicate findings effectively for policy audiences
Part 1: Healthcare Access for Vulnerable Populations
Research Question
Which Pennsylvania counties have the highest proportion of vulnerable populations (elderly + low-income) living far from hospitals?
# Determining what variables are needed for >= 65 years:acs_vars_2022 <-load_variables(2022, "acs5", cache =TRUE) acs_vars_2022_65_above <- acs_vars_2022 %>%filter(concept =="Sex by Age") %>%filter(str_detect(label, "([6-9][5-9]|[7-9][0-9]|85).*years"))acs_age_pop <- acs_vars_2022_65_above$name# In the wide format, we need names assigned to the 12 population vars based on age and sexnames(acs_age_pop) <- acs_vars_2022_65_above$label %>%str_to_lower() %>%str_replace_all("estimate!!(total!!)?", "") %>%# remove 'estimate!!' and 'total!!' if presentstr_replace_all("!!|and| ", "_") %>%# replace !!, 'and', and spaces with _str_replace_all("years", "") %>%# remove 'years'str_replace_all("[^a-z0-9_]", "") # remove any remaining punctuationtract_demographics <-get_acs(geography ="tract",variables =c(total_pop ="B01003_001",median_income ="B19013_001", acs_age_pop ),state ="PA",year =2022,output ="wide")final_tract_demographics <- tract_demographics %>%mutate(pop_65_plus =rowSums(select(., starts_with("total_male_") |starts_with("total_female_")) %>%select(., ends_with("E")),na.rm =TRUE ) ) %>%select(GEOID, NAME, total_popE, median_incomeE, pop_65_plus, total_popM, median_incomeM)# Join to tract boundariestracts_with_data <- census_tracts %>%left_join(final_tract_demographics, by ="GEOID")sum(is.na(tracts_with_data$median_incomeE))
What is the median income across all PA census tracts?
$70188
Step 3: Define Vulnerable Populations
Identify census tracts with vulnerable populations based on TWO criteria: 1. Low median household income (choose an appropriate threshold) 2. Significant elderly population (choose an appropriate threshold)
I went with a quantile approach, because I wanted to see what median incomes are especially low in the context of Pennsylvania. The threshold is the bottom 25% of median incomes in the state. Based on 2022 data, that is $55924. This approach can also stand the test of time, because it will update as we get new data.
What elderly population threshold did you choose and why?
I went with a similar approach as above, but this time picking the counties that are in the top 25% of tracts with population above 65 This is a dynamic cutoff, but with 2022 data it is 23.1% of the population being >=65.
How many tracts meet your vulnerability criteria?
165 tracts meet this vulnerability criteria (having income in the lowest 25% of PA census tracts AND having an elderly population in the top 25%)
What percentage of PA census tracts are considered vulnerable by your definition?
~4.79% of PA census tracts meet this criteria. (165 vulnerable / 3445 total)
Step 4: Calculate Distance to Hospitals
For each vulnerable tract, calculate the distance to the nearest hospital.
Use an appropriate projected coordinate system for Pennsylvania
Calculate distances in miles
Done with “as.numeric() / 1609.34”.
Explain why you chose your projection
I used NAD83 / Pennsylvania South (EPSG:3365) for this statewide analysis. Pennsylvania spans two State Plane zones designed to reduce distortion across the state’s large east-west extent. Rather than conducting separate analyses for each zone, I applied the South zone projection statewide to balance efficiency with measurement accuracy. Some of the northern counties will experience distortion as a result.
Questions to answer:
What is the average distance to the nearest hospital for vulnerable tracts?
~15.57 miles
What is the maximum distance?
~62.86 miles
How many vulnerable tracts are more than 15 miles from the nearest hospital?
56 tracts
Step 5: Identify Underserved Areas
Define “underserved” as vulnerable tracts that are more than 15 miles from the nearest hospital.
# Doing some data explortation to see what distinct counties contain the underserved counties# tracts_with_data %>%# st_drop_geometry() %>%# filter(underserved == TRUE) %>%# distinct(NAMELSADCO) %>%# arrange(NAMELSADCO)
Questions to answer:
How many tracts are underserved?
56 tracts
What percentage of vulnerable tracts are underserved?
33.94%
Does this surprise you? Why or why not?
Not entirely. I explored the counties that contain these underserved tracts and most of them are in rural Pennsylvania, where it’s reasonable to expect an aging population and lower income levels. Rural counties are also where you can expect large distances to hospitals.
Step 6: Aggregate to County Level
Use spatial joins and aggregation to calculate county-level statistics about vulnerable populations and hospital access.
# Spatial join tracts to countiestracts_by_county <- tracts_with_data %>%st_join(pa_counties %>%select(COUNTY_NAM))# tracts_by_county is larger than tracts_with_data, which seems to be because tracts are appearing for multiple counties. For the sake of this analysis, I will do the join again but tie a census tract to the county it's mostly in (largest county).tracts_by_county <- tracts_with_data %>%st_join(pa_counties %>%select(COUNTY_NAM), largest =TRUE)# Aggregate statistics by countycounty_stats <- tracts_by_county %>%st_drop_geometry() %>%group_by(COUNTY_NAM) %>%summarise(total_tracts =n(), # including to understand the scale of the countyvulnerable_tracts =sum(vulnerable, na.rm =TRUE),underserved_tracts =sum(underserved, na.rm =TRUE),pct_vulnerable_underserved =round(ifelse(vulnerable_tracts >0, (underserved_tracts / vulnerable_tracts) *100, 0), 1),avg_dist_to_hospital =round(mean(dist_to_hospital, na.rm =TRUE), 2),total_pop_in_vulnerable =sum(total_popE[vulnerable ==TRUE], na.rm =TRUE), ) %>%arrange(desc(pct_vulnerable_underserved))county_stats %>%kable(col.names =c("County", "Total Tracts", "Vulnerable Tracts", "Underserved Tracts", "% Vulnerable", "Avg Distance (mi)", "Total Pop in Vulnerable Tracts"))
Percentage of vulnerable tracts that are underserved
Average distance to nearest hospital for vulnerable tracts
Total vulnerable population
Questions to answer:
Which 5 counties have the highest percentage of underserved vulnerable tracts?
Based on my criteria, 18 counties have the same percentage of vulnerable tracts that are underserved. Those 18 counties are printed above.
Which counties have the most vulnerable people living far from hospitals?
Fayette, Northumberland, Westmoreland, and Clearfield, and Somerset.
Are there any patterns in where underserved counties are located?
Most of the counties (and all of the counties with the most vulnerable people) are large rural counties.
Step 7: Create Summary Table
tracts_by_county %>%st_drop_geometry() %>%filter(underserved ==TRUE) %>%group_by(COUNTY_NAM) %>%summarise(underserved_tracts =n(),vulnerable_pop_far_from_hospitals =sum(total_popE, na.rm =TRUE) ) %>%arrange(desc(vulnerable_pop_far_from_hospitals)) %>%head(10) %>%kable(col.names =c("County", "Underserved Tracts", "Total Population"),align =c("l", "r", "r"),format.args =list(big.mark =","),caption ="Top 10 Priority Counties for Healthcare Investment - Ranked by total population in underserved vulnerable tracts (>15 miles from nearest hospital)" )
Top 10 Priority Counties for Healthcare Investment - Ranked by total population in underserved vulnerable tracts (>15 miles from nearest hospital)
County
Underserved Tracts
Total Population
FAYETTE
3
13,304
NORTHUMBERLAND
4
9,087
WESTMORELAND
3
8,574
CLEARFIELD
3
7,771
SOMERSET
3
7,209
LUZERNE
2
5,728
INDIANA
2
5,703
BRADFORD
1
5,466
MCKEAN
2
5,452
WARREN
2
5,103
Note: Vulnerable tracts are defined as those in the bottom 25% for median household income and top 25% for percentage of population 65+. Underserved tracts are vulnerable tracts located more than 15 miles from the nearest hospital.
Part 2: Comprehensive Visualization
Map 1: County-Level Choropleth
county_map_data <- pa_counties %>%left_join(county_stats, by ="COUNTY_NAM")ggplot() +geom_sf(data = county_map_data, aes(fill = pct_vulnerable_underserved),color ="white", size =0.3) +geom_sf(data = hospitals, color ="navy", size =1.5, alpha =0.75,shape =3) +scale_fill_gradient(low ="#fee6ce",high ="#a63603",na.value ="grey90",name ="% of Vulnerable Census Tracts\nthat are Underserved ",labels =function(x) paste0(x, "%") ) +labs(title ="Healthcare Access Challenges in Pennsylvania",subtitle ="Percentage of vulnerable tracts in a county that are underserved (>15 miles from a hospital)",caption ="Vulnerable tracts: bottom 25% in median income & top 25% in population 65+, relative to the rest of PA\nBlue crosses indicate hospital locations" ) +theme_void() +theme(plot.title =element_text(face ="bold", hjust =0.5),plot.subtitle =element_text(size =10, hjust =0.5),plot.caption =element_text(size =8, hjust =0.5),legend.position ="left" )
Map 2: Detailed Vulnerability Map
Create a map highlighting underserved vulnerable tracts.
ggplot() +geom_sf(data = tracts_with_data, fill ="gray85", color ="white", size =0.1) +geom_sf(data = tracts_with_data %>%filter(underserved ==TRUE), fill ="maroon",color ="white", size =0.1) +geom_sf(data = pa_counties, fill =NA, color ="black", size =0.5) +# Hospital locationsgeom_sf(data = hospitals, color ="navy", size =1.5, alpha =0.75,shape =3) +labs(title ="Underserved Vulnerable Census Tracts in Pennsylvania",subtitle ="Red areas show vulnerable tracts where the center is more than 15 miles from the nearest hospital",caption ="Vulnerable tracts: bottom 25% in median income & top 25% in population 65+, relative to the rest of PA\nBlue crosses indicate hospital locations" ) +theme_void() +theme(plot.title =element_text(face ="bold", size =14, hjust =0.5),plot.subtitle =element_text(size =10, hjust =0.5, margin =margin(b =10)),plot.caption =element_text(size =8, hjust =0, margin =margin(t =10)),plot.margin =margin(20, 20, 20, 20) )
Chart: Distribution Analysis
Create a visualization showing the distribution of distances to hospitals for vulnerable populations.
# Create distribution visualizationtracts_with_data %>%st_drop_geometry() %>%filter(vulnerable ==TRUE) %>%ggplot(aes(x = dist_to_hospital)) +geom_histogram(bins =30, fill ="navy", color ="white") +geom_vline(xintercept =15, linetype ="dashed", color ="maroon", size =1) +labs(title ="Distance to Nearest Hospital for Vulnerable Tracts",x ="Distance (miles)",y ="Number of Vulnerable Tracts",caption ="Vulnerable tracts: bottom 25% in median income & top 25% in population 65+, relative to the rest of PA\nDashed line shows 15-mile threshold used to define underserved.\nNote how there is a clustering of tracts with relatively close hospital access (<15 miles) and a very long tail beyond the 15 mile cutoff." ) +theme_minimal()
Part 3: Bring Your Own Data Analysis
Choose your own additional spatial dataset and conduct a supplementary analysis.
Question: “Are polling places accessible for elderly and disabled voters?”
Operations: Buffer polling places and transit stops, identify vulnerable populations, find areas lacking access
Policy relevance: Voting rights, election infrastructure, ADA compliance
Data Sources
OpenDataPhilly: https://opendataphilly.org/datasets/ - Most datasets available as GeoJSON, Shapefile, or CSV with coordinates - Always check the Metadata for a data dictionary of the fields.
Additional Sources: - Pennsylvania Open Data: https://data.pa.gov/ - Census Bureau (via tidycensus): Demographics, economic indicators, commute patterns - TIGER/Line (via tigris): Geographic boundaries
I chose polling data because I found it interesting that they’re classified by fairly detailed accessibility codes. Above is a table of the polling locations in Philadelphia by accessibility type. I also chose data points describing SEPT bus stops. I want to see if there are any polling locations not near bus stops.
map_5min <-ggplot() +geom_sf(data = philly_tracts, fill ="gray95", color ="white", size =0.2) +geom_sf(data = polling_with_distance %>%filter(beyond_5min), color ="gold", size =1.5, alpha =0.7) +geom_sf(data = polling_with_distance %>%filter(!beyond_5min), color ="gray70", size =0.5, alpha =0.3) +theme_void() +labs(title ="> 5 min walk") +theme(plot.title =element_text(hjust =0.5, face ="bold"))# Map 2: Beyond 10 minutesmap_10min <-ggplot() +geom_sf(data = philly_tracts, fill ="gray95", color ="white", size =0.2) +geom_sf(data = polling_with_distance %>%filter(beyond_10min), color ="orangered", size =1.5, alpha =0.7) +geom_sf(data = polling_with_distance %>%filter(!beyond_10min), color ="gray70", size =0.5, alpha =0.3) +theme_void() +labs(title ="> 10 min walk") +theme(plot.title =element_text(hjust =0.5, face ="bold"))# Map 3: Beyond 15 minutesmap_15min <-ggplot() +geom_sf(data = philly_tracts, fill ="gray95", color ="white", size =0.2) +geom_sf(data = polling_with_distance %>%filter(beyond_15min), color ="maroon", size =1.5, alpha =0.7) +geom_sf(data = polling_with_distance %>%filter(!beyond_15min), color ="gray70", size =0.5, alpha =0.3) +theme_void() +labs(title ="> 15 min walk") +theme(plot.title =element_text(hjust =0.5, face ="bold"))# Combine mapsmap_5min + map_10min + map_15min +plot_annotation(title ="Philadelphia Polling Places Beyond Certain Walking Distances from Bus Stops",subtitle ="Colored dots show polling locations beyond specified walking time to nearest bus stop",theme =theme(plot.title =element_text(hjust =0.5, face ="bold", size =14),plot.subtitle =element_text(hjust =0.5, size =10)) )
Your interpretation:
It seems that there’s more interesting analysis that can occur for polling stations more than 5 minutes walk from a bus stop. They seem mostly concentrated along the periphery of the city. Future analysis can be done on the condition of streets around these polling stations (a 5 minute walk across a quaint neighborhood is much easier than a 5 minute walk across/adjacent a busy highway). This can be combined with the accessibility information of polling stations, whether they’re in vulnerable census tracts, or near other forms of transit (SEPTA Metro, Trolleys, IndeGo bikeshare, bike lanes, etc.)
Finally - A few comments about your incorporation of feedback!
I made sure to hide my API key this time, using R Studio’s environment variables. I cleaned up more setup and instruction comments.