County-Level Analysis

For this analysis, Madison County, Alabama, was selected as the primary county of interest. The ACS variable analyzed was DP02_0068P, which measures the percentage of residents age 25 and older who have earned a bachelor’s degree or higher. The analysis compared Madison County with the neighboring counties: Limestone, Morgan, Marshall, Jackson, Lawrence, and Cullman Counties.

Madison County had the highest estimated rate of bachelor’s degree attainment among the counties examined, at 46.4%. The next-highest county was Limestone County at 30.2%, while the remaining counties ranged from about 15% to 24%. The leaflet map and dot plot both show that Madison County stands apart from the surrounding counties, indicating a substantially larger share of adults holding bachelor’s degrees or higher.

The results were not surprising and generally confirmed my expectations. Madison County is home to several institutions of higher education, including The University of Alabama in Huntsville (UAH), Alabama A&M University (AAMU), and Oakwood University. In addition, the county’s strong concentration of technology, engineering, and aerospace employers may contribute to a workforce with higher levels of educational attainment. The county-level map clearly highlighted Madison County as the regional leader in bachelor’s degree attainment, making the difference easy to identify visually.

Within-County Analysis

The subdivisions of Madison County, known as Census County Divisions (CCDs), showed substantial variation in bachelor’s degree attainment among adults aged 25 and older. The areas with the highest values were Madison CCD, Madison County, Alabama (64.0%), Gurley CCD, Madison County, Alabama (63.4%), and Triana CCD, Madison County, Alabama (59.2%). In contrast, the areas with the lowest values were New Market CCD, Madison County, Alabama (34.1%), Hazel Green CCD, Madison County, Alabama (29.4%), and Madison Crossroads CCD, Madison County, Alabama (24.5%).

A noticeable geographic pattern is that several CCDs in the southern and central portions of Madison County generally have higher levels of educational attainment than some of the northern portions of the county. This pattern is also evident in the dot plot, where the highest-performing subdivisions are clearly separated from the lowest-performing subdivisions.

Several factors may help explain this variation. Madison County is home to major employers such as Redstone Arsenal, as well as the technology, engineering, aerospace, and defense industries concentrated in the Huntsville area. These industries often employ individuals with advanced education and specialized training. In addition, the presence of institutions such as UAH, AAMU, and Oakwood University may contribute to the higher educational attainment observed in some subdivisions. Overall, the sub-county analysis demonstrates that educational attainment is unevenly distributed across Madison County and that important local differences can be hidden when examining only county-level averages.

Reflection

Before completing the analysis, I expected to find that Madison County would have a relatively high percentage of adults aged 25 and older with a bachelor’s degree or higher. Given the presence of major employers, higher education institutions, and a strong technology and aerospace industry, I anticipated that educational attainment would be above average throughout much of the county.

Overall, the results aligned with my expectations. Madison County ranked highest among the neighboring counties in the county-level analysis, and the sub-county analysis also revealed several areas with particularly high levels of educational attainment. However, I was somewhat surprised by the degree of variation among the CCDs, as some areas had much lower educational attainment rates than others.

The county-level map showed that Madison County had a high overall percentage of residents with a bachelor’s degree or higher. However, it did not reveal how educational attainment varied within the county. The subdivision-level map made these differences visible by showing that some CCDs, such as Madison CCD and Gurley CCD, had rates exceeding 60%. In contrast, others, such as Madison Crossroads CCD and Hazel Green CCD, had considerably lower rates. This demonstrates how county-level averages can mask important local differences.

If I were a journalist, I would pursue a story titled “Educational Attainment Varies Widely Across Madison County.” The story would explore why some CCDs have substantially higher rates of bachelor’s degree attainment than others and examine the role that factors such as proximity to Redstone Arsenal, employment opportunities, access to higher education, and local economic conditions may play in shaping these differences.

Area Variable Estimate Margin of Error
Madison County, Alabama DP02_0068P 46.4 0.8
Limestone County, Alabama DP02_0068P 30.2 1.7
Morgan County, Alabama DP02_0068P 23.3 1.3
Marshall County, Alabama DP02_0068P 21.5 1.2
Cullman County, Alabama DP02_0068P 19.7 1.7
Jackson County, Alabama DP02_0068P 15.9 1.5
Lawrence County, Alabama DP02_0068P 15.2 2.3
Area Variable Estimate Margin of Error
Madison CCD, Madison County, Alabama DP02_0068P 64.0 2.8
Gurley CCD, Madison County, Alabama DP02_0068P 63.4 4.1
Triana CCD, Madison County, Alabama DP02_0068P 59.2 5.4
Redstone Arsenal CCD, Madison County, Alabama DP02_0068P 53.7 13.6
New Hope CCD, Madison County, Alabama DP02_0068P 45.8 3.3
Huntsville CCD, Madison County, Alabama DP02_0068P 43.8 1.0
New Market CCD, Madison County, Alabama DP02_0068P 34.1 4.2
Hazel Green CCD, Madison County, Alabama DP02_0068P 29.4 5.1
Madison Crossroads CCD, Madison County, Alabama DP02_0068P 24.5 4.3

County-Level Map Code:

############################################################
# STEP 1: Load Packages
############################################################

if (!require("tidycensus")) install.packages("tidycensus")
if (!require("tidyverse")) install.packages("tidyverse")
if (!require("kableExtra")) install.packages("kableExtra")
if (!require("sf")) install.packages("sf")
if (!require("leaflet")) install.packages("leaflet")
if (!require("htmlwidgets")) install.packages("htmlwidgets")
if (!require("plotly")) install.packages("plotly")

library(tidycensus)
library(tidyverse)
library(kableExtra)
library(sf)
library(leaflet)
library(htmlwidgets)
library(plotly)

############################################################
# STEP 2: Verify Census API Key
############################################################

if (!nzchar(Sys.getenv("CENSUS_API_KEY"))) {
  
  stop(
    paste(
      "\nNo Census API key was found.",
      "\n\nTo obtain a free Census API key, visit:",
      "\nhttps://api.census.gov/data/key_signup.html",
      "\n\nThen run the one-time setup code at the",
      "\ntop of this script to store your key.",
      "\n"
    )
  )
  
}

############################################################
# STEP 3: Explore 2024 Codebooks and Choose an ACS Variable
############################################################

ProfileTables <- load_variables(2024, "acs5/profile")

# DetailedTables <- load_variables(2024, "acs5")
# SubjectTables  <- load_variables(2024, "acs5/subject")

# Replace the variable code below with the ACS variable
# you want to download.

my_variable <- "DP02_0068P"

# Example variables:
#
# DP05_0001  = Total population
# DP03_0062  = Median Household Income
# DP03_0128P = Poverty Rate
# DP02_0068P = Bachelor's Degree or Higher Rate
# DP05_0018  = Median Age
# DP04_0046P = Homeownership Rate
# DP04_0134  = Median Gross Rent
# DP04_0142P = Pct. renters paying 35%+ HH income in rent (GRAPI)
# DP05_0090P = Hispanic or Latino Population Percentage
# DP02_0094P = Foreign-Born Percentage
# DP03_0009P = Unemployment Rate
# DP03_0025  = Average Commute Time

############################################################
# STEP 4: Download ACS Data
############################################################

# Common ACS geography options:
#
# "state"
# "county"
# "county subdivision"
# "tract"
# "block group"
# "zcta"
#
# Most of this script works for any ACS geography.
# Some geographies may require additional filtering
# or display adjustments.

mydata <- get_acs(
  geography = "county",
  state = "AL",
  year = 2024,
  survey = "acs5",
  variables = my_variable,
  geometry = TRUE
)

############################################################
# STEP 5: Keep Needed Variables and Geometry
############################################################

mydata <- mydata |>
  select(
    Area = NAME,
    Variable = variable,
    Estimate = estimate,
    Margin_of_Error = moe,
    geometry
  )

############################################################
# STEP 5B: Transform Coordinates for Leaflet
############################################################

mydata <- st_transform(
  mydata,
  crs = 4326
)

############################################################
# STEP 6: Optional Filtering
############################################################

# Uncomment and edit the code below if you want to keep only
# selected areas.
#
# To uncomment:
# 1. Select the lines with your mouse.
# 2. Press Ctrl+Shift+C (Windows) or Cmd+Shift+C (Mac).  

mydata <- mydata |>
  filter(
    Area %in% c(
      "Madison County, Alabama",
      "Limestone County, Alabama",
      "Morgan County, Alabama",
      "Marshall County, Alabama",
      "Jackson County, Alabama",
      "Lawrence County, Alabama",
      "Cullman County, Alabama"
    )
  )

############################################################
# STEP 7: Sort Results from Highest to Lowest
############################################################

mydata <- mydata |>
  arrange(desc(Estimate))

############################################################
# STEP 8: Display Results as a Table
############################################################

CountyResultsTable <- mydata |>
  st_drop_geometry() |>
  kbl(
    col.names = c(
      "Area",
      "Variable",
      "Estimate",
      "Margin of Error"
    ),
    format.args = list(big.mark = ",")
  ) |>
  kable_styling(
    bootstrap_options = c("striped", "hover"),
    full_width = FALSE
  )

CountyResultsTable

############################################################
# STEP 9: Create Pop-Up Content
############################################################

mydata$popup <- paste0(
  "<strong>", mydata$Area, "</strong><br/>",
  "Estimate: ",
  format(mydata$Estimate, big.mark = ","),
  "<br/>",
  "Plus/Minus: ",
  format(mydata$Margin_of_Error, big.mark = ",")
)

############################################################
# STEP 10: Build Color Palette
############################################################

qs <- unique(
  quantile(
    mydata$Estimate,
    probs = seq(0, 1, length.out = 6),
    na.rm = TRUE
  )
)

pal <- colorBin(
  palette = "Blues",
  domain = mydata$Estimate,
  bins = qs,
  pretty = FALSE
)

############################################################
# STEP 11: Create Choropleth Map
############################################################

CountyMap <- leaflet(mydata) |>
  addProviderTiles(
    providers$Esri.WorldTopoMap
  ) |>
  addPolygons(
    fillColor = ~pal(Estimate),
    fillOpacity = 0.5,
    color = "black",
    weight = 1,
    popup = ~popup
  ) |>
  addLegend(
    pal = pal,
    values = ~Estimate,
    title = "Estimate",
    labFormat = labelFormat(
      big.mark = ","
    )
  )

CountyMap

############################################################
# STEP 12: Create Plotly Dot Plot
############################################################

plotdf <- mydata |>
  st_drop_geometry() |>
  mutate(
    point_color = pal(Estimate),
    y_ordered = reorder(
      Area,
      Estimate
    ),
    hover_text = paste0(
      Area,
      "
Estimate: ",
      format(Estimate, big.mark = ","),
      "
Plus/Minus: ",
      format(Margin_of_Error, big.mark = ",")
    )
  )

CountyDotPlot <- plot_ly(
  data = plotdf,
  x = ~Estimate,
  y = ~as.character(y_ordered),
  type = "scatter",
  mode = "markers",
  showlegend = FALSE,
  marker = list(
    color = ~point_color,
    size = 8,
    line = list(
      color = "rgba(120,120,120,0.9)",
      width = 0.5
    )
  ),
  error_x = list(
    type = "data",
    array = ~Margin_of_Error,
    arrayminus = ~Margin_of_Error,
    color = "rgba(0,0,0,0.65)",
    thickness = 1
  ),
  text = ~hover_text,
  hovertemplate = "%{text}"
) |>
  layout(
    title = list(
      text = paste0(
        "ACS Estimates by Area",
        "
Error bars show ACS margins of error."
      )
    ),
    xaxis = list(
      title = "Estimate",
      tickformat = ",.0f",
      automargin = TRUE
    ),
    yaxis = list(
      title = "",
      automargin = TRUE,
      categoryorder = "array",
      categoryarray = levels(plotdf$y_ordered)
    ),
    margin = list(
      l = 200,
      r = 20,
      b = 60,
      t = 60,
      pad = 2
    )
  )

CountyDotPlot

############################################################
# STEP 13: Export Dot Plot (Optional)
############################################################

saveWidget(
  widget = as_widget(CountyDotPlot),
  file = "ACSGraph.html",
  selfcontained = TRUE
)

############################################################
# STEP 14: Export Map (Optional)
############################################################

saveWidget(
  widget = CountyMap,
  file = "ACSMap.html",
  selfcontained = TRUE
)

Sub-County Map Code:

############################################################
# STEP 1: Load Packages
############################################################

if (!require("tidycensus")) install.packages("tidycensus")
if (!require("tidyverse")) install.packages("tidyverse")
if (!require("kableExtra")) install.packages("kableExtra")
if (!require("sf")) install.packages("sf")
if (!require("leaflet")) install.packages("leaflet")
if (!require("htmlwidgets")) install.packages("htmlwidgets")
if (!require("plotly")) install.packages("plotly")

library(tidycensus)
library(tidyverse)
library(kableExtra)
library(sf)
library(leaflet)
library(htmlwidgets)
library(plotly)

############################################################
# STEP 2: Verify Census API Key
############################################################

if (!nzchar(Sys.getenv("CENSUS_API_KEY"))) {
  
  stop(
    paste(
      "\nNo Census API key was found.",
      "\n\nTo obtain a free Census API key, visit:",
      "\nhttps://api.census.gov/data/key_signup.html",
      "\n\nThen run the one-time setup code at the",
      "\ntop of this script to store your key.",
      "\n"
    )
  )
  
}

############################################################
# STEP 3: Explore 2024 Codebooks and Choose an ACS Variable
############################################################

ProfileTables <- load_variables(2024, "acs5/profile")

# DetailedTables <- load_variables(2024, "acs5")
# SubjectTables  <- load_variables(2024, "acs5/subject")

# Replace the variable code below with the ACS variable
# you want to download.

my_variable <- "DP02_0068P"

# Example variables:
#
# DP05_0001  = Total population
# DP03_0062  = Median Household Income
# DP03_0128P = Poverty Rate
# DP02_0068P = Bachelor's Degree or Higher Rate
# DP05_0018  = Median Age
# DP04_0046P = Homeownership Rate
# DP04_0134  = Median Gross Rent
# DP04_0142P = Pct. renters paying 35%+ HH income in rent (GRAPI)
# DP05_0090P = Hispanic or Latino Population Percentage
# DP02_0094P = Foreign-Born Percentage
# DP03_0009P = Unemployment Rate
# DP03_0025  = Average Commute Time

############################################################
# STEP 4: Download ACS Data
############################################################

# Common ACS geography options:
#
# "state"
# "county"
# "county subdivision"
# "tract"
# "block group"
# "zcta"
#
# Most of this script works for any ACS geography.
# Some geographies may require additional filtering
# or display adjustments.

mydata <- get_acs(
  geography = "county",
  state = "AL",
  year = 2024,
  survey = "acs5",
  variables = my_variable,
  geometry = TRUE
)

############################################################
# STEP 5: Keep Needed Variables and Geometry
############################################################

mydata <- mydata |>
  select(
    Area = NAME,
    Variable = variable,
    Estimate = estimate,
    Margin_of_Error = moe,
    geometry
  )

############################################################
# STEP 5B: Transform Coordinates for Leaflet
############################################################

mydata <- st_transform(
  mydata,
  crs = 4326
)

############################################################
# STEP 6: Optional Filtering
############################################################

# Uncomment and edit the code below if you want to keep only
# selected areas.
#
# To uncomment:
# 1. Select the lines with your mouse.
# 2. Press Ctrl+Shift+C (Windows) or Cmd+Shift+C (Mac).  

mydata <- mydata |>
  filter(
    Area %in% c(
      "Madison County, Alabama",
      "Limestone County, Alabama",
      "Morgan County, Alabama",
      "Marshall County, Alabama",
      "Jackson County, Alabama",
      "Lawrence County, Alabama",
      "Cullman County, Alabama"
    )
  )

############################################################
# STEP 7: Sort Results from Highest to Lowest
############################################################

mydata <- mydata |>
  arrange(desc(Estimate))

############################################################
# STEP 8: Display Results as a Table
############################################################

CountyResultsTable <- mydata |>
  st_drop_geometry() |>
  kbl(
    col.names = c(
      "Area",
      "Variable",
      "Estimate",
      "Margin of Error"
    ),
    format.args = list(big.mark = ",")
  ) |>
  kable_styling(
    bootstrap_options = c("striped", "hover"),
    full_width = FALSE
  )

CountyResultsTable

############################################################
# STEP 9: Create Pop-Up Content
############################################################

mydata$popup <- paste0(
  "<strong>", mydata$Area, "</strong><br/>",
  "Estimate: ",
  format(mydata$Estimate, big.mark = ","),
  "<br/>",
  "Plus/Minus: ",
  format(mydata$Margin_of_Error, big.mark = ",")
)

############################################################
# STEP 10: Build Color Palette
############################################################

qs <- unique(
  quantile(
    mydata$Estimate,
    probs = seq(0, 1, length.out = 6),
    na.rm = TRUE
  )
)

pal <- colorBin(
  palette = "Blues",
  domain = mydata$Estimate,
  bins = qs,
  pretty = FALSE
)

############################################################
# STEP 11: Create Choropleth Map
############################################################

CountyMap <- leaflet(mydata) |>
  addProviderTiles(
    providers$Esri.WorldTopoMap
  ) |>
  addPolygons(
    fillColor = ~pal(Estimate),
    fillOpacity = 0.5,
    color = "black",
    weight = 1,
    popup = ~popup
  ) |>
  addLegend(
    pal = pal,
    values = ~Estimate,
    title = "Estimate",
    labFormat = labelFormat(
      big.mark = ","
    )
  )

CountyMap

############################################################
# STEP 12: Create Plotly Dot Plot
############################################################

plotdf <- mydata |>
  st_drop_geometry() |>
  mutate(
    point_color = pal(Estimate),
    y_ordered = reorder(
      Area,
      Estimate
    ),
    hover_text = paste0(
      Area,
      "
Estimate: ",
      format(Estimate, big.mark = ","),
      "
Plus/Minus: ",
      format(Margin_of_Error, big.mark = ",")
    )
  )

CountyDotPlot <- plot_ly(
  data = plotdf,
  x = ~Estimate,
  y = ~as.character(y_ordered),
  type = "scatter",
  mode = "markers",
  showlegend = FALSE,
  marker = list(
    color = ~point_color,
    size = 8,
    line = list(
      color = "rgba(120,120,120,0.9)",
      width = 0.5
    )
  ),
  error_x = list(
    type = "data",
    array = ~Margin_of_Error,
    arrayminus = ~Margin_of_Error,
    color = "rgba(0,0,0,0.65)",
    thickness = 1
  ),
  text = ~hover_text,
  hovertemplate = "%{text}"
) |>
  layout(
    title = list(
      text = paste0(
        "ACS Estimates by Area",
        "
Error bars show ACS margins of error."
      )
    ),
    xaxis = list(
      title = "Estimate",
      tickformat = ",.0f",
      automargin = TRUE
    ),
    yaxis = list(
      title = "",
      automargin = TRUE,
      categoryorder = "array",
      categoryarray = levels(plotdf$y_ordered)
    ),
    margin = list(
      l = 200,
      r = 20,
      b = 60,
      t = 60,
      pad = 2
    )
  )

CountyDotPlot

############################################################
# STEP 13: Export Dot Plot (Optional)
############################################################

saveWidget(
  widget = as_widget(CountyDotPlot),
  file = "ACSGraph.html",
  selfcontained = TRUE
)

############################################################
# STEP 14: Export Map (Optional)
############################################################

saveWidget(
  widget = CountyMap,
  file = "ACSMap.html",
  selfcontained = TRUE
)