Exploring Income Inequality in the United States

Author

Tanner Ivusic

Introduction and Research Question

My project explores changes in income inequality in the United States over time, using 62 annual observations from 1963 through 2024. The data comes from the World Bank’s Poverty and Inequality Platform (PIP), which compiles poverty and inequality estimates based on household survey data. The World Bank describes its U.S. Gini series here: https://data.worldbank.org/indicator/SI.POV.GINI?locations=US. The PIP platform and its methods are described at https://pip.worldbank.org/about.

The main quantitative variables in this analysis are reporting_year (the year of the estimate), gini (the Gini index, where values closer to 0 indicate more equal income distributions and values closer to 1 indicate greater inequality), mean (average income measure reported in the dataset), median (median income measure), and headcount (the reported poverty headcount proportion for the dataset’s poverty line). Categorical variables include survey_acronym (the survey source label) and survey_coverage (the coverage description). To make time patterns easier to compare, I will also create a categorical period variable grouping years into four broad eras.

My research question is: How has the U.S. Gini index changed over time, and is year a statistically significant linear predictor of the Gini index in this dataset? I will inspect and clean the data, summarize important variables, visualize the Gini trend across periods, fit a simple linear regression, and assess the model using p-values and diagnostic plots.

Loading Libraries

library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.2.1     ✔ readr     2.2.0
✔ forcats   1.0.1     ✔ stringr   1.6.0
✔ ggplot2   4.0.3     ✔ tibble    3.3.1
✔ lubridate 1.9.5     ✔ tidyr     1.3.2
✔ purrr     1.2.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

Importing the Dataset

library(ggplot2)
library(dplyr)
pip_raw <- readr::read_csv("pip.csv", show_col_types = FALSE)
head(pip_raw)
# A tibble: 6 × 44
  region_name   region_code country_name  country_code reporting_year
  <chr>         <chr>       <chr>         <chr>                 <dbl>
1 North America NAC         United States USA                    1963
2 North America NAC         United States USA                    1964
3 North America NAC         United States USA                    1965
4 North America NAC         United States USA                    1966
5 North America NAC         United States USA                    1967
6 North America NAC         United States USA                    1968
# ℹ 39 more variables: reporting_level <chr>, survey_acronym <chr>,
#   survey_coverage <chr>, survey_year <dbl>, welfare_type <chr>,
#   survey_comparability <dbl>, comparable_spell <chr>, poverty_line <dbl>,
#   headcount <dbl>, poverty_gap <dbl>, poverty_severity <dbl>, watts <dbl>,
#   mean <dbl>, median <dbl>, mld <dbl>, gini <dbl>, polarization <dbl>,
#   decile1 <dbl>, decile2 <dbl>, decile3 <dbl>, decile4 <dbl>, decile5 <dbl>,
#   decile6 <dbl>, decile7 <dbl>, decile8 <dbl>, decile9 <dbl>, …

Understanding and Cleaning the Data

dim(pip_raw)
[1] 62 44
glimpse(pip_raw)
Rows: 62
Columns: 44
$ region_name          <chr> "North America", "North America", "North America"…
$ region_code          <chr> "NAC", "NAC", "NAC", "NAC", "NAC", "NAC", "NAC", …
$ country_name         <chr> "United States", "United States", "United States"…
$ country_code         <chr> "USA", "USA", "USA", "USA", "USA", "USA", "USA", …
$ reporting_year       <dbl> 1963, 1964, 1965, 1966, 1967, 1968, 1969, 1970, 1…
$ reporting_level      <chr> "national", "national", "national", "national", "…
$ survey_acronym       <chr> "CPS-LIS", "CPS-LIS", "CPS-LIS", "CPS-LIS", "CPS-…
$ survey_coverage      <chr> "national", "national", "national", "national", "…
$ survey_year          <dbl> 1963, 1964, 1965, 1966, 1967, 1968, 1969, 1970, 1…
$ welfare_type         <chr> "income", "income", "income", "income", "income",…
$ survey_comparability <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0…
$ comparable_spell     <chr> "1963 - 2001", "1963 - 2001", "1963 - 2001", "196…
$ poverty_line         <dbl> 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3…
$ headcount            <dbl> 0.016, 0.015, 0.013, 0.012, 0.011, 0.009, 0.007, …
$ poverty_gap          <dbl> 0.009553310, 0.009976391, 0.008216805, 0.00719896…
$ poverty_severity     <dbl> 0.007438426, 0.007939444, 0.006534853, 0.00553164…
$ watts                <dbl> 0.021360227, 0.022785998, 0.018700880, 0.01587368…
$ mean                 <dbl> 40.34933, 42.73532, 44.83181, 45.22071, 47.15199,…
$ median               <dbl> 33.29194, 34.55863, 36.65577, 37.03082, 38.68250,…
$ mld                  <dbl> 0.2697801, 0.2798176, 0.2682297, 0.2703966, 0.263…
$ gini                 <dbl> 0.3672647, 0.3737529, 0.3682540, 0.3709733, 0.368…
$ polarization         <dbl> 0.3172582, 0.3236903, 0.3246312, 0.3255148, 0.325…
$ decile1              <dbl> 0.01837580, 0.01816329, 0.01878334, 0.01819141, 0…
$ decile2              <dbl> 0.03981447, 0.03915924, 0.03928870, 0.03844543, 0…
$ decile3              <dbl> 0.05282091, 0.05220220, 0.05237284, 0.05186095, 0…
$ decile4              <dbl> 0.06423110, 0.06348084, 0.06397746, 0.06384244, 0…
$ decile5              <dbl> 0.07600672, 0.07499192, 0.07573089, 0.07585601, 0…
$ decile6              <dbl> 0.08907705, 0.08766364, 0.08900657, 0.08886032, 0…
$ decile7              <dbl> 0.1048156, 0.1038376, 0.1049058, 0.1049444, 0.104…
$ decile8              <dbl> 0.1258094, 0.1254762, 0.1262764, 0.1269465, 0.127…
$ decile9              <dbl> 0.1602349, 0.1603229, 0.1609037, 0.1619234, 0.162…
$ decile10             <dbl> 0.2688140, 0.2747022, 0.2687543, 0.2691291, 0.268…
$ cpi                  <dbl> 0.1130200, 0.1144654, 0.1162799, 0.1197858, 0.123…
$ ppp                  <dbl> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1…
$ reporting_pop        <dbl> 189242000, 191889000, 194303000, 196560000, 19871…
$ reporting_gdp        <dbl> 20445.84, 21333.30, 22416.62, 23599.57, 23927.60,…
$ reporting_pce        <dbl> NA, NA, NA, NA, NA, NA, NA, 15525.78, 15916.19, 1…
$ is_interpolated      <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, …
$ distribution_type    <chr> "micro", "micro", "micro", "micro", "micro", "mic…
$ estimation_type      <chr> "survey", "survey", "survey", "survey", "survey",…
$ spl                  <dbl> 17.946, 18.579, 19.628, 19.815, 20.641, 21.472, 2…
$ spr                  <dbl> 0.180, 0.179, 0.180, 0.186, 0.185, 0.176, 0.177, …
$ pg                   <dbl> 1.9473125, 1.9589834, 1.7462624, 1.6417350, 1.558…
$ estimate_type        <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
colSums(is.na(pip_raw))
         region_name          region_code         country_name 
                   0                    0                    0 
        country_code       reporting_year      reporting_level 
                   0                    0                    0 
      survey_acronym      survey_coverage          survey_year 
                   0                    0                    0 
        welfare_type survey_comparability     comparable_spell 
                   0                    0                    0 
        poverty_line            headcount          poverty_gap 
                   0                    0                    0 
    poverty_severity                watts                 mean 
                   0                    0                    0 
              median                  mld                 gini 
                   0                    0                    0 
        polarization              decile1              decile2 
                   0                    0                    0 
             decile3              decile4              decile5 
                   0                    0                    0 
             decile6              decile7              decile8 
                   0                    0                    0 
             decile9             decile10                  cpi 
                   0                    0                    0 
                 ppp        reporting_pop        reporting_gdp 
                   0                    0                    0 
       reporting_pce      is_interpolated    distribution_type 
                   9                    0                    0 
     estimation_type                  spl                  spr 
                   0                    0                    0 
                  pg        estimate_type 
                   0                   62 
# Remove the completely empty field, set categorical fields as factors, and group years into eras.
pip <- pip_raw %>%
  select(-estimate_type) %>%
  mutate(
    survey_acronym = factor(survey_acronym),
    survey_coverage = factor(survey_coverage),
    period = case_when(
      reporting_year <= 1979 ~ "1960s–1970s",
      reporting_year <= 1999 ~ "1980s–1990s",
      reporting_year <= 2019 ~ "2000s–2010s",
      TRUE ~ "2020s"
    ),
    period = factor(period, levels = c("1960s–1970s", "1980s–1990s", "2000s–2010s", "2020s"))
  )

# Check the cleaned data and confirm that the variables used for regression are not missing.
summary(pip %>% select(reporting_year, gini, mean, median, headcount, period))
 reporting_year      gini             mean           median     
 Min.   :1963   Min.   :0.3472   Min.   :40.35   Min.   :33.29  
 1st Qu.:1978   1st Qu.:0.3684   1st Qu.:56.59   1st Qu.:47.22  
 Median :1994   Median :0.3978   Median :66.88   Median :53.33  
 Mean   :1994   Mean   :0.3883   Mean   :67.95   Mean   :53.52  
 3rd Qu.:2009   3rd Qu.:0.4087   3rd Qu.:76.70   3rd Qu.:58.94  
 Max.   :2024   Max.   :0.4190   Max.   :96.94   Max.   :74.14  
   headcount                period  
 Min.   :0.003000   1960s–1970s:17  
 1st Qu.:0.006250   1980s–1990s:20  
 Median :0.009000   2000s–2010s:20  
 Mean   :0.008839   2020s      : 5  
 3rd Qu.:0.011000                   
 Max.   :0.016000                   
sum(is.na(pip$reporting_year) | is.na(pip$gini))
[1] 0

Initial Exploration

This calculates descriptive statistics for the numeric variables.

# Summarize the range, center, and spread of the main variables.
pip %>%
  summarise(
    observations = n(),
    first_year = min(reporting_year, na.rm = TRUE),
    last_year = max(reporting_year, na.rm = TRUE),
    average_gini = mean(gini, na.rm = TRUE),
    minimum_gini = min(gini, na.rm = TRUE),
    maximum_gini = max(gini, na.rm = TRUE),
    average_income_measure = mean(mean, na.rm = TRUE),
    average_median_measure = mean(median, na.rm = TRUE)
  )
# A tibble: 1 × 8
  observations first_year last_year average_gini minimum_gini maximum_gini
         <int>      <dbl>     <dbl>        <dbl>        <dbl>        <dbl>
1           62       1963      2024        0.388        0.347        0.419
# ℹ 2 more variables: average_income_measure <dbl>,
#   average_median_measure <dbl>
# Plot Gini values by period to check whether the typical level and variability differ across eras.
ggplot(pip, aes(x = period, y = gini, color = period)) +
  geom_boxplot(aes(fill = period), alpha = 0.20, outlier.shape = NA) +
  geom_jitter(width = 0.12, alpha = 0.75, size = 2) +
  scale_color_manual(values = c("#0072B2", "#D55E00", "#009E73", "#CC79A7"), name = "Time period") +
  scale_fill_manual(values = c("#0072B2", "#D55E00", "#009E73", "#CC79A7"), name = "Time period") +
  labs(
    title = "U.S. Income Inequality Across Time Periods",
    x = "Time period",
    y = "Gini index (higher values indicate greater inequality)",
    caption = "Source: World Bank, Poverty and Inequality Platform (PIP); supplied file pip.csv."
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom", axis.text.x = element_text(angle = 15, hjust = 1))

Main Visualization, The Gini Index Over Time

# Display the yearly Gini index, color points by period, and overlay the linear trend for an accessible summary.
ggplot(pip, aes(x = reporting_year, y = gini, color = period)) +
  geom_point(size = 2.4, alpha = 0.9) +
  geom_smooth(aes(group = 1), method = "lm", formula = y ~ x, se = TRUE,
              color = "#333333", fill = "#999999", linewidth = 0.9) +
  scale_color_manual(
    values = c("#0072B2", "#D55E00", "#009E73", "#CC79A7"),
    name = "Time period"
  ) +
  labs(
    title = "Income Inequality in the United States, 1963–2024",
    subtitle = "Gini index estimates and fitted linear trend",
    x = "Reporting year",
    y = "Gini index (0 = greater equality; 1 = greater inequality)",
    caption = "Source: World Bank, Poverty and Inequality Platform (PIP), U.S. estimates; supplied file pip.csv."
  ) +
  theme_classic(base_size = 13) +
  theme(legend.position = "bottom", plot.caption = element_text(hjust = 0))

The plot can be used to assess whether the Gini index generally increased or decreased across the observed years. The line summarizes the average linear pattern, while the points show that individual years do not necessarily fall exactly on the line.

Linear Regression

A linear regression models the Gini index as a function of reporting year:

[ = b_0 + b_1() ]

Here, the slope (b_1) estimates the average change in the Gini index associated with one additional calendar year. The p-value for the year coefficient tests the null hypothesis that the population linear slope is zero. The adjusted R-squared describes the proportion of variation in Gini explained by the model, adjusted for the number of predictors.

# Fit the required simple linear regression with Gini as the response and reporting year as the predictor.
gini_model <- lm(gini ~ reporting_year, data = pip)

# Coefficient tests, model fit statistics, and confidence intervals for interpretation.
summary(gini_model)

Call:
lm(formula = gini ~ reporting_year, data = pip)

Residuals:
      Min        1Q    Median        3Q       Max 
-0.025803 -0.004423  0.001892  0.006315  0.018874 

Coefficients:
                 Estimate Std. Error t value Pr(>|t|)    
(Intercept)    -1.867e+00  1.487e-01  -12.56   <2e-16 ***
reporting_year  1.131e-03  7.457e-05   15.17   <2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 0.01051 on 60 degrees of freedom
Multiple R-squared:  0.7933,    Adjusted R-squared:  0.7898 
F-statistic: 230.2 on 1 and 60 DF,  p-value: < 2.2e-16
confint(gini_model)
                       2.5 %       97.5 %
(Intercept)    -2.1647011704 -1.569989279
reporting_year  0.0009823218  0.001280635

Model Equation and Interpretation

[ = -1.8673 + 0.0011315() ]

The estimated slope is positive, about 0.00113 Gini-index units per year, which is about 0.0113 units per decade. The year coefficient’s p-value is approximately 3.32 × 10⁻²², which is far below a common significance level of 0.05. Therefore, the data provide strong evidence of a linear association between year and the Gini index in this series. The adjusted R-squared is approximately 0.790, meaning that the linear model explains about 79% of the observed variation in Gini values in this dataset after adjusting for the number of predictors.

The intercept is where the fitted line would cross the y-axis at year zero, which is outside the observed period and has no useful direct interpretation here. Statistical significance does not mean the trend is perfectly linear, that the model proves causation, or that every year follows the trend exactly.

par(mfrow = c(2, 2))
plot(gini_model)

par(mfrow = c(1, 1))
# Plot residuals in chronological order to see whether errors cluster or show a systematic pattern across years.
pip %>%
  mutate(model_residual = residuals(gini_model)) %>%
  ggplot(aes(x = reporting_year, y = model_residual)) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "#555555") +
  geom_line(color = "#0072B2", linewidth = 0.7) +
  geom_point(color = "#D55E00", size = 2) +
  labs(
    title = "Regression Residuals Over Time",
    x = "Reporting year",
    y = "Residual (observed minus fitted Gini)",
    caption = "Source: Model residuals calculated from the World Bank PIP data in pip.csv."
  ) +
  theme_light(base_size = 12)

Conclusion

The analysis examines the U.S. Gini index from 1963 to 2024 using the World Bank PIP dataset. The main plot and fitted regression show a positive linear association between reporting year and the Gini index. The year coefficient is statistically significant, and the adjusted R-squared indicates that the linear trend accounts for a substantial share of the variation in the observed Gini values. However, the residual plots should be considered before treating the model as a complete description of the data. This analysis is descriptive and does not identify the causes of income inequality. Other economic, policy, and social variables may help explain why inequality changes over time, and a more complex model could be considered if the diagnostic plots show important departures from linear regression assumptions.

Technical Challenges

A technical challenge I faced during making this project was cleaning the dataset and preparing it for regression analysis. Some variables contained missing values, and the estimate_type column did not provide useful information for the analysis. I had to examine the dataset and determine which variables were necessary for answering my research question. I focused on the year and Gini index because these variables allowed me to investigate how income inequality changed over time.

Data Source

World Bank. (2026). Poverty and Inequality Platform (PIP). World Bank Group. https://pip.worldbank.org/about. The U.S. Gini indicator page describes the source and survey basis of the series: https://data.worldbank.org/indicator/SI.POV.GINI?locations=US.