Healthy Cities GIS Assignment: Health Inequality Across Baltimore’s Census Tracts

Author

Dev Narang

Published

Invalid Date

Load the libraries and the data

library(tidyverse)
library(tidyr)
library(leaflet)   # interactive maps
library(scales)    # axis label formatting

cities500 <- read_csv("500CitiesLocalHealthIndicators.cdc.csv")

This is the CDC’s 500 Cities: Local Data for Better Health dataset, which reports health indicator estimates for the 500 largest US cities down to the census tract level. It has 810,103 rows and 24 variables.

The GeoLocation variable has (lat, long) format

Split GeoLocation (lat, long) into two columns: lat and long.

latlong <- cities500 |>
  mutate(GeoLocation = str_replace_all(GeoLocation, "[()]", "")) |>
  separate(GeoLocation, into = c("lat", "long"), sep = ",", convert = TRUE)
head(latlong)
# A tibble: 6 × 25
   Year StateAbbr StateDesc  CityName  GeographicLevel DataSource Category      
  <dbl> <chr>     <chr>      <chr>     <chr>           <chr>      <chr>         
1  2017 CA        California Hawthorne Census Tract    BRFSS      Health Outcom…
2  2017 CA        California Hawthorne City            BRFSS      Unhealthy Beh…
3  2017 CA        California Hayward   City            BRFSS      Health Outcom…
4  2017 CA        California Hayward   City            BRFSS      Unhealthy Beh…
5  2017 CA        California Hemet     City            BRFSS      Prevention    
6  2017 CA        California Indio     Census Tract    BRFSS      Health Outcom…
# ℹ 18 more variables: UniqueID <chr>, Measure <chr>, Data_Value_Unit <chr>,
#   DataValueTypeID <chr>, Data_Value_Type <chr>, Data_Value <dbl>,
#   Low_Confidence_Limit <dbl>, High_Confidence_Limit <dbl>,
#   Data_Value_Footnote_Symbol <chr>, Data_Value_Footnote <chr>,
#   PopulationCount <dbl>, lat <dbl>, long <dbl>, CategoryID <chr>,
#   MeasureId <chr>, CityFIPS <dbl>, TractFIPS <dbl>, Short_Question_Text <chr>

Filter the dataset (walkthrough example)

Remove the StateDesc that includes the United States, select Prevention as the category, filter for crude prevalence only, and select 2017.

latlong_clean <- latlong |>
  filter(StateDesc != "United States") |>
  filter(Data_Value_Type == "Crude prevalence") |>
  filter(Year == 2017) |>
  filter(StateAbbr == "CT") |>
  filter(Category == "Unhealthy Behaviors")
head(latlong_clean)
# A tibble: 6 × 25
   Year StateAbbr StateDesc   CityName   GeographicLevel DataSource Category    
  <dbl> <chr>     <chr>       <chr>      <chr>           <chr>      <chr>       
1  2017 CT        Connecticut Bridgeport Census Tract    BRFSS      Unhealthy B…
2  2017 CT        Connecticut Danbury    City            BRFSS      Unhealthy B…
3  2017 CT        Connecticut Norwalk    Census Tract    BRFSS      Unhealthy B…
4  2017 CT        Connecticut Bridgeport Census Tract    BRFSS      Unhealthy B…
5  2017 CT        Connecticut Hartford   Census Tract    BRFSS      Unhealthy B…
6  2017 CT        Connecticut Waterbury  Census Tract    BRFSS      Unhealthy B…
# ℹ 18 more variables: UniqueID <chr>, Measure <chr>, Data_Value_Unit <chr>,
#   DataValueTypeID <chr>, Data_Value_Type <chr>, Data_Value <dbl>,
#   Low_Confidence_Limit <dbl>, High_Confidence_Limit <dbl>,
#   Data_Value_Footnote_Symbol <chr>, Data_Value_Footnote <chr>,
#   PopulationCount <dbl>, lat <dbl>, long <dbl>, CategoryID <chr>,
#   MeasureId <chr>, CityFIPS <dbl>, TractFIPS <dbl>, Short_Question_Text <chr>

What variables are included?

names(latlong_clean)
 [1] "Year"                       "StateAbbr"                 
 [3] "StateDesc"                  "CityName"                  
 [5] "GeographicLevel"            "DataSource"                
 [7] "Category"                   "UniqueID"                  
 [9] "Measure"                    "Data_Value_Unit"           
[11] "DataValueTypeID"            "Data_Value_Type"           
[13] "Data_Value"                 "Low_Confidence_Limit"      
[15] "High_Confidence_Limit"      "Data_Value_Footnote_Symbol"
[17] "Data_Value_Footnote"        "PopulationCount"           
[19] "lat"                        "long"                      
[21] "CategoryID"                 "MeasureId"                 
[23] "CityFIPS"                   "TractFIPS"                 
[25] "Short_Question_Text"       

Remove the variables that will not be used

latlong_clean2 <- latlong_clean |>
  select(-DataSource, -Data_Value_Unit, -DataValueTypeID, -Low_Confidence_Limit,
         -High_Confidence_Limit, -Data_Value_Footnote_Symbol, -Data_Value_Footnote)
head(latlong_clean2)
# A tibble: 6 × 18
   Year StateAbbr StateDesc   CityName GeographicLevel Category UniqueID Measure
  <dbl> <chr>     <chr>       <chr>    <chr>           <chr>    <chr>    <chr>  
1  2017 CT        Connecticut Bridgep… Census Tract    Unhealt… 0908000… Obesit…
2  2017 CT        Connecticut Danbury  City            Unhealt… 918430   Obesit…
3  2017 CT        Connecticut Norwalk  Census Tract    Unhealt… 0955990… Obesit…
4  2017 CT        Connecticut Bridgep… Census Tract    Unhealt… 0908000… Curren…
5  2017 CT        Connecticut Hartford Census Tract    Unhealt… 0937000… Obesit…
6  2017 CT        Connecticut Waterbu… Census Tract    Unhealt… 0980000… Obesit…
# ℹ 10 more variables: Data_Value_Type <chr>, Data_Value <dbl>,
#   PopulationCount <dbl>, lat <dbl>, long <dbl>, CategoryID <chr>,
#   MeasureId <chr>, CityFIPS <dbl>, TractFIPS <dbl>, Short_Question_Text <chr>

My Assignment

Step 1: Filter to my own subset (under 900 observations)

I chose to look inside a single city rather than across many. Baltimore is one of the 500 Cities with tract-level coverage, which lets me ask whether health outcomes vary within one city as much as they do between cities.

My filter keeps four related indicators for Baltimore’s census tracts in 2017: obesity, physical inactivity, diabetes, and current smoking.

# Filter to Baltimore census tracts, 2017, crude prevalence, four chosen measures.
# 200 tracts x 4 measures = 800 observations, which is under the 900 limit.
baltimore <- cities500 |>
  filter(StateAbbr == "MD",
         CityName == "Baltimore",
         GeographicLevel == "Census Tract",
         Year == 2017,
         Data_Value_Type == "Crude prevalence",
         Short_Question_Text %in% c("Obesity", "Physical Inactivity",
                                    "Diabetes", "Current Smoking"))

# Confirm the subset is under 900 observations.
nrow(baltimore)
[1] 800
# Split GeoLocation into lat and long, strip the commas out of PopulationCount so
# it becomes numeric, then keep only the columns needed.
baltimore_tidy <- baltimore |>
  mutate(GeoLocation = str_replace_all(GeoLocation, "[()]", "")) |>
  separate(GeoLocation, into = c("lat", "long"), sep = ",", convert = TRUE) |>
  mutate(population = as.numeric(str_remove_all(PopulationCount, ","))) |>
  select(TractFIPS, lat, long, population, Short_Question_Text, Data_Value)

# Pivot wider so each row is one census tract with all four measures side by side.
# This is what makes it possible to compare measures against each other.
baltimore_wide <- baltimore_tidy |>
  pivot_wider(names_from = Short_Question_Text, values_from = Data_Value) |>
  rename(obesity    = Obesity,
         inactivity = `Physical Inactivity`,
         diabetes   = Diabetes,
         smoking    = `Current Smoking`) |>
  # One tract has suppressed estimates, so drop rows with missing values.
  filter(!is.na(obesity), !is.na(inactivity),
         !is.na(diabetes), !is.na(smoking), !is.na(lat))

# 199 usable tracts covering roughly 621,000 residents.
nrow(baltimore_wide)
[1] 199
summary(baltimore_wide$diabetes)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
   2.70    9.55   13.00   13.00   17.05   25.90 

Diabetes prevalence runs from 2.7% to 25.9% across tracts in the same city — nearly a tenfold gap.

Step 2: A non-map plot

# Scatterplot of two indicators, with a third (obesity) carried by a colour
# gradient and a fourth (tract population) by point size. Following the Japan
# earthquake tutorial's use of scale_color_gradient for a continuous variable.
ggplot(baltimore_wide, aes(x = inactivity, y = diabetes)) +
  geom_point(aes(color = obesity, size = population), alpha = 0.8) +
  geom_smooth(method = "lm", se = FALSE, color = "grey30", lty = 2, linewidth = 0.6) +
  scale_color_gradient(name = "Obesity (%)", low = "#FFD166", high = "#7A0403") +
  scale_size_continuous(name = "Tract population", range = c(1.5, 6),
                        labels = label_comma()) +
  labs(title = "Where Baltimoreans Move Less, They Also Have More Diabetes",
       subtitle = "Each point is one census tract; the three indicators rise almost in lockstep",
       x = "Adults Physically Inactive (%)",
       y = "Adults with Diagnosed Diabetes (%)",
       caption = "Source: CDC 500 Cities: Local Data for Better Health, 2017 crude prevalence estimates") +
  theme_bw(base_size = 12)

Scatterplot of physical inactivity prevalence on the x-axis against diabetes prevalence on the y-axis for 199 Baltimore census tracts. Points rise steeply together along a strong positive trend, and point colour, representing obesity prevalence, shifts from light to dark as both variables increase.

Physical inactivity against diabetes prevalence across Baltimore census tracts
# Quantify how tightly the indicators move together.
cor(baltimore_wide$inactivity, baltimore_wide$diabetes)
[1] 0.9231879
cor(baltimore_wide$obesity, baltimore_wide$diabetes)
[1] 0.9313542

Step 3: A first map of the subset

# Baltimore's coordinates, used to centre the map.
baltimore_lat <- 39.2904
baltimore_lon <- -76.6122

# A first leaflet map: one circle per census tract, with radius scaled to
# diabetes prevalence so the harder-hit tracts stand out.
leaflet() |>
  setView(lng = baltimore_lon, lat = baltimore_lat, zoom = 12) |>
  addProviderTiles("Esri.WorldStreetMap") |>
  addCircles(data = baltimore_wide,
             lng = ~long, lat = ~lat,
             radius = ~diabetes * 12,
             color = "#7A0403",
             fillOpacity = 0.4)

Step 4: Refined map with a mouse-click tooltip

# Build the popup text. <b> bolds a label and <br> forces a line break, so each
# tract's numbers appear on their own line when clicked.
popup_tract <- paste0(
  "<b>Census Tract: </b>", baltimore_wide$TractFIPS, "<br>",
  "<b>Population: </b>", comma(baltimore_wide$population), "<br>",
  "<b>Diabetes: </b>", baltimore_wide$diabetes, "%<br>",
  "<b>Obesity: </b>", baltimore_wide$obesity, "%<br>",
  "<b>Physically inactive: </b>", baltimore_wide$inactivity, "%<br>",
  "<b>Current smoking: </b>", baltimore_wide$smoking, "%"
)

# A continuous colour palette mapped to diabetes prevalence, so colour and not
# just size encodes the value. This also lets me add a legend.
pal <- colorNumeric(palette = c("#FFD166", "#F3722C", "#7A0403"),
                    domain = baltimore_wide$diabetes)
# The refined map: colour encodes diabetes prevalence, radius reinforces it,
# a click popup gives every indicator for that tract, and a legend explains
# the colour scale.
leaflet(baltimore_wide) |>
  setView(lng = baltimore_lon, lat = baltimore_lat, zoom = 12) |>
  addProviderTiles("Esri.WorldStreetMap") |>
  addCircleMarkers(
    lng = ~long, lat = ~lat,
    radius = ~diabetes * 0.7,
    color = "#2B2B2B",
    weight = 0.6,
    fillColor = ~pal(diabetes),
    fillOpacity = 0.8,
    popup = popup_tract
  ) |>
  addLegend(position = "bottomright",
            pal = pal,
            values = ~diabetes,
            title = "Diabetes<br>prevalence (%)",
            opacity = 0.9)

Click any circle to see that tract’s population and all four health indicators.

Step 5: What the plots show

My subset covers 199 Baltimore census tracts, about 621,000 residents, with four 2017 indicators each. The scatterplot in Step 2 plots physical inactivity against diabetes, with obesity as a colour gradient and tract population as point size. The three indicators are almost interchangeable: inactivity correlates with diabetes at 0.92 and obesity with diabetes at 0.93. Tracts do not have one problem in isolation — the light yellow points sit together at the low end and the dark red ones cluster at the high end, so the same neighbourhoods carry all of it at once.

The map shows why that matters, because the pattern is geographic rather than scattered. Diabetes prevalence ranges from 2.7% to 25.9% within a single city, and the darkest circles form a band through East and West Baltimore while the lightest ones sit along the harbour and in the northern neighbourhoods. Clicking a tract makes the contrast concrete: neighbouring tracts a mile apart can differ by fifteen percentage points. What looks like a health statistic is really a map of where advantage is and is not, and a city-level average of about 13% diabetes would hide the entire story.

One honest limitation: these are modelled small-area estimates, not direct measurements. The CDC produces them by applying a statistical model to BRFSS survey responses combined with census demographics, so neighbouring tracts with similar demographics are partly predicted to look alike. The geographic pattern is real, but its sharpness is smoothed by the model that generated it.