# Core collection
library(tidyverse)
# Data wrangling / summaries
library(dplyr)
library(janitor)
library(skimr)
# Visualization
library(ggplot2)
library(ggthemes)
library(ggpubr)
library(scales)
library(viridis)
library(patchwork)
# Text analysis
library(stringr)
library(tidytext)
# Dates
library(lubridate)
# Tables / reporting
library(flextable)
library(gt)
library(knitr)
# Missing data / imputation
library(mice)
library(naniar)
# Modeling
library(tidymodels)
# Spatial
library(tigris)
library(sf)
library(leaflet)STA518 - The Affordability Squeeze
How Rising Costs Have Outpaced Income Across American Counties (2009–2023)
Overview
Between 2009 and 2023, the U.S. Consumer Price Index rose by approximately 42%, reflecting a sustained period of inflation that reshaped household budgets across the country. This report investigates whether income growth kept pace with that inflation — and whether the burden fell equally across all communities.
Using county-level American Community Survey (ACS) data merged with Bureau of Labor Statistics (BLS) CPI data, we examine trends in real median income, housing costs, and poverty rates across more than 3,000 U.S. counties. Our analysis finds that high-poverty counties experienced meaningfully lower inflation-adjusted income growth, widening the gap between the most and least economically vulnerable communities.
Data Dictionary
The analysis draws on two datasets, described below:
Census Dataset (census_data_county_2009-2023.csv)
Source: U.S. Census Bureau, American Community Survey (ACS) 5-Year Estimates
| Variable | Type | Description |
|---|---|---|
geoid |
Character | 5-digit Federal Information Processing Standard (FIPS) county code |
county_state |
Character | County and state name (e.g., “Kent County, Michigan”) |
year |
Integer | Survey year (2009–2023) |
population |
Numeric | Total county population estimate |
median_income |
Numeric | Median household income in nominal dollars |
median_monthly_rent_cost |
Numeric | Median gross monthly rent cost in nominal dollars |
median_monthly_home_cost |
Numeric | Median selected monthly owner costs in nominal dollars |
prop_female |
Numeric | Proportion of county population identifying as female (0–1) |
prop_male |
Numeric | Proportion of county population identifying as male (0–1) |
prop_poverty |
Numeric | Proportion of county population below the federal poverty line (0–1) |
CPI Dataset (cpi_2000-2024.csv)
Source: Bureau of Labor Statistics, CPI-U (All Urban Consumers, All Items)
| Variable | Type | Description |
|---|---|---|
series_id |
Character | BLS series identifier |
year |
Integer | Year of observation |
period |
Character | Month code (e.g., M01 for January) |
period_name |
Character | Month name (e.g., “January”) |
date |
Character | Full date string |
cpi |
Numeric | Consumer Price Index value (base period: 1982–84 = 100) |
Derived Variables (created during analysis)
| Variable | Type | Description |
|---|---|---|
geoid |
Character | 5-digit FIPS code uniquely identifying each county |
county_state |
Character | County and state name (e.g., “Kent County, Michigan”) |
county_name |
Character | County name extracted from county_state |
state |
Character | State name extracted from county_state |
year |
Integer | Survey year (2009–2023) |
date_based_data |
Date | First day of the survey year as a Date object (parsed via lubridate) |
annual_avg_cpi |
Numeric | Average of 12 monthly CPI-U values for the given year (base: 1982–84 = 100) |
population |
Numeric | Total county population estimate at time of survey |
prop_female |
Numeric | Proportion of county population identifying as female (0–1) |
prop_male |
Numeric | Proportion of county population identifying as male (0–1) |
prop_poverty |
Numeric | Proportion of county population below the federal poverty line (0–1) |
poverty_tier |
Factor | Annual poverty quartile: Low Poverty, Medium-Low, Medium-High, High Poverty |
median_income |
Numeric | Median household income in nominal dollars at time of survey |
median_income_2024_dollars |
Numeric | Median household income adjusted to 2024 dollars using CPI ratio |
median_monthly_rent_cost |
Numeric | Median gross monthly rent in nominal dollars at time of survey |
median_monthly_rent_cost_2024_dollars |
Numeric | Median gross monthly rent adjusted to 2024 dollars |
median_monthly_home_cost |
Numeric | Median selected monthly owner costs in nominal dollars at time of survey |
median_monthly_home_cost_2024_dollars |
Numeric | Median selected monthly owner costs adjusted to 2024 dollars |
rent_to_income_ratio |
Numeric | Monthly rent as a share of monthly income: median_monthly_rent / (median_income / 12) |
Data Cleaning
# Helper: view head and tail of a data frame together
ht <- function(df, n = 5) {
cat("--- First", n, "rows ---\n")
print(head(df, n))
cat("\n--- Last", n, "rows ---\n")
print(tail(df, n))
}Loading Raw Data
# Data source URLs from course GitHub repository
census_url <- "https://raw.githubusercontent.com/dilernia/STA418-518/main/Data/census_data_county_2009-2023.csv"
cpi_url <- "https://raw.githubusercontent.com/dilernia/STA418-518/refs/heads/main/Data/cpi_2000-2024.csv"
# Read both datasets
census_raw <- read_csv(census_url, show_col_types = FALSE)
cpi_raw <- read_csv(cpi_url, show_col_types = FALSE)Cleaning the Census Dataset
# Standardize column names using janitor
census <- census_raw |> clean_names()
# Clean and cast all variables to correct types
census_clean <- census |>
mutate(
# str_pad ensures all FIPS codes are exactly 5 characters (e.g., "01001")
geoid = str_pad(as.character(geoid), width = 5, side = "left", pad = "0"),
# str_squish removes leading/trailing/internal extra whitespace from county names
county_state = str_squish(as.character(county_state)),
year = as.integer(year),
population = as.numeric(population),
median_income = as.numeric(median_income),
median_monthly_rent_cost = as.numeric(median_monthly_rent_cost),
median_monthly_home_cost = as.numeric(median_monthly_home_cost),
prop_female = as.numeric(prop_female),
prop_male = as.numeric(prop_male),
prop_poverty = as.numeric(prop_poverty)
) |>
# Remove duplicate rows
distinct() |>
# Restrict to study window (2009-2023)
filter(year >= 2009, year <= 2023)
# Remove rows with impossible values (negatives, proportions outside 0-1)
census_clean <- census_clean |>
filter(
is.na(population) | population >= 0,
is.na(median_income) | median_income >= 0,
is.na(median_monthly_rent_cost) | median_monthly_rent_cost >= 0,
is.na(median_monthly_home_cost) | median_monthly_home_cost >= 0,
is.na(prop_female) | between(prop_female, 0, 1),
is.na(prop_male) | between(prop_male, 0, 1),
is.na(prop_poverty) | between(prop_poverty, 0, 1)
)Cleaning the CPI Dataset
# Standardize CPI column names
cpi <- cpi_raw |> clean_names()
# Clean types and remove duplicates
cpi_clean <- cpi |>
mutate(
year = as.integer(year),
period = as.character(period),
# str_squish trims whitespace from period_name (e.g., "January " -> "January")
period_name = str_squish(as.character(period_name)),
series_id = as.character(series_id),
date = as.character(date),
cpi = as.numeric(cpi)
) |>
distinct()
# Keep only monthly records (period codes like M01, M02, ..., M12)
# str_detect used here to filter with a regex pattern
cpi_monthly <- cpi_clean |>
filter(str_detect(period, "^M\\d{2}$"))
# Aggregate monthly CPI to yearly average to match census data structure
# ymd() from lubridate parses the date column; min() gets the first date of each year
cpi_yearly <- cpi_monthly |>
group_by(year) |>
summarise(
date_based_data = min(ymd(date), na.rm = TRUE), # lubridate: parse date string
annual_avg_cpi = mean(cpi, na.rm = TRUE),
.groups = "drop"
)Validation Checks
# Confirm no county appears more than once per year
census_clean |>
count(geoid, year) |>
filter(n > 1) |>
nrow() |>
cat("Duplicate county-year rows:", ... = _, "\n")Duplicate county-year rows: 0
# Confirm sex proportions sum to approximately 1
census_clean |>
mutate(sex_total = prop_female + prop_male) |>
summarise(
min_sex_total = min(sex_total, na.rm = TRUE),
max_sex_total = max(sex_total, na.rm = TRUE),
avg_sex_total = mean(sex_total, na.rm = TRUE)
) |>
knitr::kable(caption = "Sex proportion totals (should be ~1.0)")| min_sex_total | max_sex_total | avg_sex_total |
|---|---|---|
| 1 | 1 | 1 |
Merging Data Sets
# LEFT JOIN: attach yearly CPI to every county-year row
# This preserves all census rows even if a year has no CPI match
county_fused <- census_clean |>
left_join(cpi_yearly, by = "year")
# Verify no CPI values were lost in the join
county_fused |>
summarise(
total_rows = n(),
missing_cpi_rows = sum(is.na(annual_avg_cpi))
) |>
knitr::kable(caption = "Join result: rows and CPI coverage")| total_rows | missing_cpi_rows |
|---|---|
| 45090 | 0 |
# CPI for 2024 is used as the reference year for all inflation adjustments
cpi_2024 <- cpi_yearly |>
filter(year == 2024) |>
pull(annual_avg_cpi)
# Adjust all monetary variables to 2024 dollars using the CPI ratio
county_fused <- county_fused |>
mutate(
median_income_2024_dollars =
median_income * (cpi_2024 / annual_avg_cpi),
median_monthly_rent_cost_2024_dollars =
median_monthly_rent_cost * (cpi_2024 / annual_avg_cpi),
median_monthly_home_cost_2024_dollars =
median_monthly_home_cost * (cpi_2024 / annual_avg_cpi)
)# Poverty quartile tier: counties ranked annually into 4 equal groups by poverty rate
county_fused <- county_fused |>
mutate(
poverty_tier = ntile(prop_poverty, 4),
poverty_tier = factor(poverty_tier,
levels = 1:4,
labels = c("Low Poverty", "Medium-Low", "Medium-High", "High Poverty")
)
)
# Extract state and county name from the combined county_state field using stringr
county_fused <- county_fused |>
mutate(
# str_extract pulls the part after the last comma (state name)
state = str_extract(county_state, "[^,]+$") |> str_squish(),
# str_extract pulls everything before the first comma (county name)
county_name = str_extract(county_state, "^[^,]+") |> str_squish()
)
# Compute monthly rent-to-income ratio for housing burden analysis
county_fused <- county_fused |>
mutate(
rent_to_income_ratio = median_monthly_rent_cost / (median_income / 12)
)
# Reorder columns for readability
county_fused <- county_fused |>
select(
geoid, county_state, county_name, state, year, date_based_data,
annual_avg_cpi, population, poverty_tier, prop_poverty,
median_income, median_income_2024_dollars,
median_monthly_rent_cost, median_monthly_rent_cost_2024_dollars,
median_monthly_home_cost, median_monthly_home_cost_2024_dollars,
rent_to_income_ratio, prop_female, prop_male
)String Manipulation
The following stringr functions are used throughout the pipeline for meaningful data preparation:
| Function | Where Used | Purpose |
|---|---|---|
str_pad() |
Census cleaning | Ensure FIPS codes are exactly 5 characters with leading zeros |
str_squish() |
Census and CPI cleaning | Remove leading, trailing, and internal excess whitespace |
str_detect() |
CPI filtering; Michigan filter | Filter rows matching a regex pattern (monthly periods; Michigan counties) |
str_extract() |
Derived variables | Pull state and county name from a combined county_state string |
str_remove() |
Michigan chart | Strip ” County” suffix from labels for cleaner axis text |
Date / Time Functions
The lubridate package is used in two places:
ymd(date)— Parses the raw character date column in the CPI dataset into a properDateobject, enabling use ofmin()to find the first date of each year.year(date_based_data)— Extracts the year integer from the parsed date for display in summary tables.
# lubridate::year() extracts the integer year from the Date column created by ymd()
cpi_yearly |>
mutate(display_year = year(date_based_data)) |> # lubridate: year()
select(display_year, annual_avg_cpi) |>
filter(display_year >= 2009, display_year <= 2023) |>
slice_head(n = 5) |>
knitr::kable(caption = "Sample: year extracted from parsed date using lubridate::year()")| display_year | annual_avg_cpi |
|---|---|
| 2009 | 214.5370 |
| 2010 | 218.0555 |
| 2011 | 224.9392 |
| 2012 | 229.5939 |
| 2013 | 232.9571 |
Exploratory Data Analysis
Patterns of Missingness
# Count NAs per column in the merged dataset
na_counts <- county_fused |>
summarise(across(everything(), ~ sum(is.na(.)))) |>
pivot_longer(everything(), names_to = "variable", values_to = "n_missing") |>
filter(n_missing > 0) |>
arrange(desc(n_missing))
na_counts |>
knitr::kable(caption = "Variables with missing values in the merged county dataset")| variable | n_missing |
|---|---|
| rent_to_income_ratio | 43 |
| median_monthly_rent_cost | 40 |
| median_monthly_rent_cost_2024_dollars | 40 |
| median_monthly_home_cost | 20 |
| median_monthly_home_cost_2024_dollars | 20 |
| median_income | 6 |
| median_income_2024_dollars | 6 |
| poverty_tier | 1 |
| prop_poverty | 1 |
# Visualize missingness using naniar
gg_miss_var(county_fused,
show_pct = TRUE) +
labs(
title = "Missing Data by Variable",
subtitle = "Percentage of observations missing across all county-year rows",
caption = "Source: U.S. Census ACS | BLS CPI-U"
) +
theme_few(base_size = 13)
Missingness reflection: The primary missing variables are the monetary measures (median_income, median_monthly_rent_cost, median_monthly_home_cost) and their inflation-adjusted counterparts. These tend to be missing together, suggesting that some counties did not report complete ACS estimates in a given year, likely smaller, more rural counties where sample sizes are too thin for reliable estimates. This is a form of non-random missingness (MNAR): the counties most likely to be missing data are systematically different from those with complete records. As a result, any national summary statistics computed on this data will slightly overrepresent larger, urban counties, and the true picture for rural America may be worse than our charts suggest.
Tables of Summary Statistics
# National yearly averages across all counties
avg_nationwide_summaries <- county_fused |>
group_by(date_based_data) |>
summarise(
AMI = mean(median_income, na.rm = TRUE),
MMI = mean(median_income, na.rm = TRUE) / 12,
MR = mean(median_monthly_rent_cost, na.rm = TRUE),
MH = mean(median_monthly_home_cost, na.rm = TRUE),
A_CPI = mean(annual_avg_cpi, na.rm = TRUE),
APP = mean(prop_poverty, na.rm = TRUE) * 100
)
flextable(avg_nationwide_summaries) |>
set_header_labels(values = c(
date_based_data = "Year",
AMI = "Median Income (Annual)",
MMI = "Median Income (Monthly)",
MR = "Median Rent (Monthly)",
MH = "Median Home Cost (Monthly)",
A_CPI = "Annual CPI",
APP = "Poverty %"
)) |>
add_header_lines(values = "Average U.S. Monetary Information by Year") |>
align(i = 1, align = "center", part = "header") |>
colformat_date(j = "date_based_data", fmt_date = "%Y") |>
colformat_double(j = c("AMI", "MMI", "MR", "MH"), prefix = "$", digits = 0, big.mark = ",") |>
colformat_double(j = "APP", digits = 1, suffix = "%") |>
colformat_double(j = "A_CPI", digits = 2, big.mark = ",") |>
autofit()Average U.S. Monetary Information by Year | ||||||
|---|---|---|---|---|---|---|
Year | Median Income (Annual) | Median Income (Monthly) | Median Rent (Monthly) | Median Home Cost (Monthly) | Annual CPI | Poverty % |
2009 | $42,818 | $3,568 | $604 | $746 | 214.54 | 16.2% |
2010 | $43,613 | $3,634 | $621 | $763 | 218.06 | 16.3% |
2011 | $44,620 | $3,718 | $646 | $782 | 224.94 | 16.7% |
2012 | $44,973 | $3,748 | $662 | $782 | 229.59 | 17.1% |
2013 | $45,265 | $3,772 | $676 | $775 | 232.96 | 17.5% |
2014 | $45,857 | $3,821 | $690 | $771 | 236.74 | 17.6% |
2015 | $46,130 | $3,844 | $694 | $758 | 237.02 | 17.5% |
2016 | $47,252 | $3,938 | $707 | $754 | 240.01 | 17.2% |
2017 | $48,995 | $4,083 | $728 | $763 | 245.12 | 16.8% |
2018 | $50,792 | $4,233 | $750 | $777 | 251.11 | 16.4% |
2019 | $52,648 | $4,387 | $767 | $787 | 255.66 | 15.9% |
2021 | $57,327 | $4,777 | $821 | $826 | 270.97 | 15.2% |
2022 | $62,327 | $5,194 | $891 | $888 | 292.65 | 15.1% |
2023 | $65,047 | $5,421 | $928 | $923 | 304.70 | 15.0% |
# Annual CPI distribution statistics (2009-2023)
cpi_summaries <- cpi_clean |>
filter(year >= 2009, year < 2024) |>
group_by(year) |>
summarise(
Min = min(cpi, na.rm = TRUE),
Q1 = quantile(cpi, 0.25, na.rm = TRUE),
Median = median(cpi, na.rm = TRUE),
Q3 = quantile(cpi, 0.75, na.rm = TRUE),
Max = max(cpi, na.rm = TRUE),
Mean = mean(cpi, na.rm = TRUE),
SD = sd(cpi, na.rm = TRUE)
) |>
mutate(year = as.character(year))
flextable(cpi_summaries) |>
set_header_labels(values = c(
year = "Year", Min = "Min CPI", Q1 = "Q1 CPI", Median = "Median CPI",
Q3 = "Q3 CPI", Max = "Max CPI", Mean = "Mean CPI", SD = "Std Dev"
)) |>
add_header_lines(values = "U.S. CPI Distribution by Year (2009–2023)") |>
align(i = 1, align = "center", part = "header") |>
colformat_double(j = c("Min","Q1","Median","Q3","Max","Mean"), digits = 2, big.mark = ",") |>
colformat_double(j = "SD", digits = 3, big.mark = ",") |>
autofit()U.S. CPI Distribution by Year (2009–2023) | |||||||
|---|---|---|---|---|---|---|---|
Year | Min CPI | Q1 CPI | Median CPI | Q3 CPI | Max CPI | Mean CPI | Std Dev |
2009 | 211.14 | 213.11 | 215.52 | 215.95 | 216.33 | 214.54 | 1.812 |
2010 | 216.69 | 217.88 | 218.09 | 218.51 | 219.18 | 218.06 | 0.755 |
2011 | 220.22 | 224.55 | 225.82 | 226.28 | 226.89 | 224.94 | 2.153 |
2012 | 226.66 | 229.32 | 229.71 | 230.26 | 231.41 | 229.59 | 1.354 |
2013 | 230.28 | 232.71 | 233.06 | 233.56 | 234.15 | 232.96 | 1.016 |
2014 | 233.92 | 235.82 | 237.25 | 237.93 | 238.34 | 236.74 | 1.530 |
2015 | 233.71 | 236.42 | 237.57 | 238.04 | 238.65 | 237.02 | 1.558 |
2016 | 236.92 | 238.98 | 240.74 | 241.37 | 241.73 | 240.01 | 1.732 |
2017 | 242.84 | 244.34 | 244.87 | 246.56 | 246.82 | 245.12 | 1.338 |
2018 | 247.87 | 250.30 | 251.79 | 252.06 | 252.88 | 251.11 | 1.550 |
2019 | 251.71 | 255.21 | 256.35 | 256.81 | 257.35 | 255.66 | 1.816 |
2020 | 256.39 | 257.93 | 258.89 | 260.24 | 260.47 | 258.81 | 1.498 |
2021 | 261.58 | 266.51 | 272.35 | 274.88 | 278.80 | 270.97 | 5.809 |
2022 | 281.15 | 288.71 | 296.22 | 296.80 | 298.01 | 292.65 | 5.870 |
2023 | 299.17 | 302.98 | 305.40 | 307.03 | 307.79 | 304.70 | 2.867 |
Data Visualizations
Section 1: The Inflation Context
Between 2009 and 2023, the CPI rose from approximately 214 to 305 — a 42% increase. The pace was relatively steady until 2020, when pandemic-era supply shocks accelerated inflation sharply.
cpi_yearly |>
ggplot(aes(x = year, y = annual_avg_cpi)) +
# Highlight the study window
annotate("rect",
xmin = 2009, xmax = 2023,
ymin = -Inf, ymax = Inf,
fill = "#E8F4FD", alpha = 0.6
) +
geom_line(color = "grey70", linewidth = 0.8) +
geom_point(aes(color = year >= 2009 & year <= 2023), size = 2.5, show.legend = FALSE) +
scale_color_manual(values = c("FALSE" = "grey60", "TRUE" = "#1565C0")) +
# Annotate the 42% rise
annotate("segment",
x = 2009, xend = 2023, y = 214.5, yend = 304.7,
arrow = arrow(length = unit(0.2, "cm"), ends = "both"),
color = "#C62828", linewidth = 0.7
) +
annotate("label",
x = 2016, y = 245,
label = "+42% (2009-2023)",
color = "#C62828", size = 4, fontface = "bold",
fill = "white", label.size = 0.3
) +
scale_x_continuous(breaks = seq(2000, 2024, by = 2)) +
scale_y_continuous(labels = scales::comma, expand = expansion(mult = c(0.02, 0.05))) +
labs(
title = "U.S. Consumer Price Index, 2000-2024",
subtitle = "Shaded region (2009-2023) corresponds to the census data window",
x = "Year",
y = "Annual Average CPI",
caption = "Source: Bureau of Labor Statistics, CPI-U"
) +
theme_few(base_size = 14) +
theme(panel.grid.minor = element_blank())
Section 2: Did Incomes Keep Up?
High-poverty counties started lower and grew more slowly. By 2023, the gap in real median income between Low Poverty and High Poverty counties had widened, meaning the affordability squeeze landed hardest on the communities least able to absorb it. The post-2020 acceleration in income for Low Poverty counties reflects labor market tightening after the pandemic, an effect that did not reach poorer communities equally.
# Aggregate to national yearly averages by poverty tier
income_trend <- county_fused |>
filter(!is.na(median_income_2024_dollars), !is.na(poverty_tier)) |>
group_by(year, poverty_tier) |>
summarise(
avg_real_income = mean(median_income_2024_dollars, na.rm = TRUE),
n_counties = n(),
.groups = "drop"
)
# Color-blind-friendly palette: blue for low poverty, orange/red for high
tier_colors <- c(
"Low Poverty" = "#1565C0",
"Medium-Low" = "#42A5F5",
"Medium-High" = "#EF6C00",
"High Poverty" = "#B71C1C"
)
income_trend |>
ggplot(aes(x = year, y = avg_real_income, color = poverty_tier, group = poverty_tier)) +
geom_line(linewidth = 1.2) +
geom_point(size = 2) +
# Direct labels at 2023 endpoints
geom_text(
data = income_trend |> filter(year == 2023),
aes(label = poverty_tier),
hjust = -0.08, size = 3.2, fontface = "bold"
) +
scale_color_manual(values = tier_colors) +
scale_x_continuous(
breaks = c(2009, 2012, 2015, 2017, 2019, 2021, 2023),
expand = expansion(mult = c(0.02, 0.22))
) +
scale_y_continuous(labels = scales::dollar_format(scale = 1e-3, suffix = "K")) +
labs(
title = "Inflation-Adjusted Median Income by County Poverty Tier (2009-2023)",
subtitle = "All values in 2024 dollars. Poverty tiers assigned annually by quartile.",
x = "Year",
y = "Average Real Median Income (2024 $)",
caption = "Source: U.S. Census ACS | BLS CPI-U"
) +
theme_few(base_size = 14) +
theme(legend.position = "none")
Section 3: The Housing Burden
The 30% rule of thumb holds that households spending more than 30% of their gross income on rent are housing-cost-burdened. The scatter below shows that in 2023, many low-income (high-poverty) counties fall above or near this threshold. Michigan’s highest-cost counties skew toward wealthier, lower-poverty areas. The burden there is driven by rising market prices rather than income deprivation.
# National scatter: rent burden vs income, 2023
scatter_2023 <- county_fused |>
filter(
year == 2023,
!is.na(median_monthly_rent_cost_2024_dollars),
!is.na(median_income_2024_dollars),
!is.na(prop_poverty)
)
p_scatter <- scatter_2023 |>
ggplot(aes(
x = median_income_2024_dollars,
y = median_monthly_rent_cost_2024_dollars,
color = prop_poverty
)) +
geom_point(alpha = 0.5, size = 1.4) +
# 30% burden threshold line
geom_abline(
slope = 0.30 / 12, intercept = 0,
linetype = "dashed", color = "#C62828", linewidth = 0.9
) +
annotate("text",
x = 155000, y = 3550,
label = "30% burden\nthreshold",
color = "#C62828", size = 3.2, hjust = 1
) +
scale_color_viridis_c(option = "C", labels = scales::percent, name = "Poverty Rate") +
scale_x_continuous(labels = scales::dollar_format(scale = 1e-3, suffix = "K")) +
scale_y_continuous(labels = scales::dollar_format()) +
labs(
title = "Rent Burden vs. Median Income, U.S. Counties (2023)",
subtitle = "Points above dashed line are housing-cost-burdened.",
x = "Median Annual Income (2024 $)",
y = "Median Monthly Rent (2024 $)",
caption = "Source: U.S. Census ACS | BLS CPI-U"
) +
theme_few(base_size = 12)
# Michigan regional callout: top 10 counties by real home cost
michigan_2023 <- county_fused |>
filter(
str_detect(county_state, regex("michigan", ignore_case = TRUE)),
year == 2023,
!is.na(median_monthly_home_cost_2024_dollars)
) |>
# str_remove strips " County" from label for cleaner axis text
mutate(county_label = str_remove(county_name, " County")) |>
arrange(desc(median_monthly_home_cost_2024_dollars)) |>
slice_head(n = 10)
p_michigan <- michigan_2023 |>
ggplot(aes(
x = median_monthly_home_cost_2024_dollars,
y = fct_reorder(county_label, median_monthly_home_cost_2024_dollars),
fill = prop_poverty
)) +
geom_col() +
scale_fill_viridis_c(option = "C", labels = scales::percent, name = "Poverty Rate") +
scale_x_continuous(
labels = scales::dollar_format(),
expand = expansion(mult = c(0, 0.1))
) +
labs(
title = "Top 10 Michigan Counties: Real Monthly Home Costs (2023)",
subtitle = "Inflation-adjusted to 2024 dollars.",
x = "Median Monthly Home Cost (2024 $)",
y = NULL,
caption = "Source: U.S. Census ACS | BLS CPI-U"
) +
theme_few(base_size = 12)
# Combine side by side using patchwork
p_scatter + p_michigan + plot_layout(widths = c(1.6, 1))
Section 4: Where It Hurts Most
The two maps below show the geographic concentration of poverty and rent burden at the county level for 2023. High poverty is concentrated in the Deep South, Appalachian corridor, and parts of the Southwest. The rent burden map reveals a different geography — coastal metros and mountain resort counties carry the highest ratios, reflecting markets where rent outpaces income regardless of poverty rate.
Map A: County Poverty Rates (Interactive)
leaflet(gg_map_2023_simple, options = leafletOptions(preferCanvas = TRUE)) |>
addProviderTiles(providers$CartoDB.PositronNoLabels) |>
addPolygons(
fillColor = ~pal_poverty(prop_poverty),
fillOpacity = 0.8,
color = "grey80",
weight = 0.4,
smoothFactor = 0.5,
highlightOptions = highlightOptions(
weight = 1.5, color = "#333333", fillOpacity = 0.95, bringToFront = TRUE
),
label = ~lapply(paste0(
"<b>", county_state, "</b><br>",
"Poverty Rate: ", scales::percent(prop_poverty, accuracy = 0.1), "<br>",
"Median Income: ", scales::dollar(median_income, accuracy = 1)
), htmltools::HTML)
) |>
addLegend(
position = "bottomright", pal = pal_poverty,
values = ~prop_poverty, title = "Poverty Rate",
labFormat = labelFormat(suffix = "%", transform = function(x) round(x * 100, 1))
) |>
addControl(
html = "<b>County Poverty Rates (2023)</b><br><small>Source: U.S. Census ACS</small>",
position = "topleft"
)