Descriptive Statistics and Data Exploration

Property tax levies and tax bases in Cook County, IL

This page compares the source taxing-agency records before sample exclusions with the grouped panel used in the corrected manuscript analysis after exclusions. Within every comparison, the after-exclusion results appear first and are therefore the default tab.

The annual-change variables follow the corrected model definitions:

The before-exclusion results use the original PTAXSIM agency number as the unit. The after-exclusion results use the grouped taxing agency used in the manuscript. The two tabs therefore show how both filtering and agency grouping change the sample.

Sample composition

The tabs compare the same 2008–2023 period. The before-exclusion tab counts original PTAXSIM agency numbers; the after-exclusion tab counts grouped manuscript agencies.

Sample overview

Show code
sample_overview_after <- make_overview(
  main_df,
  analysis_df,
  agency_id,
  "Grouped taxing agencies after sample exclusions"
)

knitr::kable(sample_overview_after, align = c("l", "r"))
Table 1: Overview of the grouped manuscript panel
Measure Value
Panel definition Grouped taxing agencies after sample exclusions
First year 2008
Last year 2023
Years in panel 16
Agency-year observations 6592
Unique taxing-agency units 421
Units observed in every panel year 403
Agency-year observations with a prior year 6180
Observations with finite d_levy and d_eav 6176

Agencies by type

Show code
agency_counts_after <- main_df |>
  filter(year == latest_year) |>
  distinct(agency_id, agency_type) |>
  count(agency_type, name = "Agencies") |>
  mutate(Share = scales::percent(Agencies / sum(Agencies), accuracy = 0.1))

knitr::kable(
  agency_counts_after,
  col.names = c("Agency type", "Agencies", "Share"),
  align = c("l", "r", "r")
)
Table 2: Grouped taxing agencies represented in the latest panel year
Agency type Agencies Share
Home-rule municipalities 70 17.0%
Non-home-rule municipalities 37 9.0%
Schools 133 32.3%
Townships 28 6.8%
Other 144 35.0%
Show code
agency_counts_after |>
  ggplot(aes(x = agency_type, y = Agencies)) +
  geom_col() +
  geom_text(aes(label = Agencies), hjust = -0.15, size = 3.5) +
  coord_flip() +
  scale_y_continuous(
    labels = scales::comma,
    expand = expansion(mult = c(0, 0.14))
  ) +
  labs(x = NULL, y = "Number of grouped agencies")
Figure 1: Grouped taxing agencies after sample exclusions

Agency counts over time

Show code
agency_counts_time_after <- main_df |>
  distinct(year, agency_id, agency_type) |>
  count(year, agency_type, name = "Agencies")

agency_counts_time_after |>
  ggplot(aes(
    x = year,
    y = Agencies,
    group = agency_type,
    color = agency_type,
    linetype = agency_type
  )) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 1.8) +
  scale_x_continuous(breaks = scales::breaks_pretty(n = 8)) +
  labs(
    x = "Year",
    y = "Number of grouped agencies",
    color = "Agency type",
    linetype = "Agency type"
  ) +
  guides(
    color = guide_legend(nrow = 2, byrow = TRUE),
    linetype = guide_legend(nrow = 2, byrow = TRUE)
  ) +
  theme(legend.position = "bottom")
Figure 2: Grouped taxing agencies represented in the panel each year

Panel completeness

Show code
panel_completeness_after <- main_df |>
  distinct(agency_id, agency_type, year) |>
  count(agency_id, agency_type, name = "Years observed")

panel_completeness_after |>
  ggplot(aes(x = `Years observed`)) +
  geom_bar() +
  facet_wrap(
    ~agency_type,
    scales = "free_y",
    labeller = agency_facet_labeller
  ) +
  scale_x_continuous(breaks = seq(1, n_distinct(main_df$year), by = 2)) +
  labs(x = "Years observed", y = "Number of grouped agencies") +
  theme(strip.text = element_text(size = 9))
Figure 3: Years observed for each grouped taxing agency
Show code
panel_completeness_after |>
  group_by(agency_type) |>
  summarise(
    Agencies = n(),
    `Minimum years` = min(`Years observed`),
    `Median years` = median(`Years observed`),
    `Maximum years` = max(`Years observed`),
    `Observed every year` = sum(`Years observed` == n_distinct(main_df$year)),
    .groups = "drop"
  ) |>
  knitr::kable(align = c("l", rep("r", 5)))
Table 3: Panel completeness after exclusions
agency_type Agencies Minimum years Median years Maximum years Observed every year
Home-rule municipalities 70 2 16 16 68
Non-home-rule municipalities 39 6 16 16 37
Schools 137 2 16 16 129
Townships 28 16 16 16 28
Other 147 1 16 16 141

The source data tab retains all original agency types and does not apply the Cook County share, home-rule-status, agency-type, levy, or grouped-agency exclusions. Calculations that require positive, nonmissing levy or EAV values necessarily use only records on which those quantities can be calculated.

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  make_overview(
    before_df,
    before_change_df,
    agency_id,
    "Original PTAXSIM agency numbers before sample exclusions"
  ) |>
    knitr::kable(align = c("l", "r"))
}
Table 4: Overview of the joined PTAXSIM source records
Measure Value
Panel definition Original PTAXSIM agency numbers before sample exclusions
First year 2008
Last year 2023
Years in panel 16
Agency-year observations 16113
Unique taxing-agency units 1227
Units observed in every panel year 827
Agency-year observations with a prior year 15036
Observations with finite d_levy and d_eav 12421

Agencies by manuscript category

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  agency_counts_before <- before_df |>
    filter(year == latest_year) |>
    distinct(agency_id, agency_type) |>
    count(agency_type, name = "Agencies") |>
    mutate(Share = scales::percent(Agencies / sum(Agencies), accuracy = 0.1))

  knitr::kable(
    agency_counts_before,
    col.names = c("Agency type", "Agency numbers", "Share"),
    align = c("l", "r", "r")
  )
}
Table 5: Original agency numbers represented in the latest comparison year
Agency type Agency numbers Share
Home-rule municipalities 90 9.6%
Non-home-rule municipalities 43 4.6%
Schools 153 16.3%
Townships 29 3.1%
Other 624 66.5%
Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  agency_counts_before |>
    ggplot(aes(x = agency_type, y = Agencies)) +
    geom_col() +
    geom_text(aes(label = Agencies), hjust = -0.15, size = 3.5) +
    coord_flip() +
    scale_y_continuous(
      labels = scales::comma,
      expand = expansion(mult = c(0, 0.14))
    ) +
    labs(x = NULL, y = "Distinct original agency numbers")
}
Figure 4: Original agency numbers before sample exclusions

Original PTAXSIM minor types

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  before_df |>
    filter(year == latest_year, !is.na(original_minor_type)) |>
    distinct(agency_id, original_minor_type) |>
    count(original_minor_type, name = "Agencies", sort = TRUE) |>
    ggplot(aes(x = reorder(original_minor_type, Agencies), y = Agencies)) +
    geom_col() +
    geom_text(aes(label = scales::comma(Agencies)), hjust = -0.1, size = 3) +
    coord_flip() +
    scale_y_continuous(
      labels = scales::comma,
      expand = expansion(mult = c(0, 0.14))
    ) +
    labs(x = NULL, y = "Distinct original agency numbers")
}
Figure 5: Original PTAXSIM agency types before sample exclusions

Agency counts over time

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  agency_counts_time_before <- before_df |>
    distinct(year, agency_id, agency_type) |>
    count(year, agency_type, name = "Agencies")

  agency_counts_time_before |>
    ggplot(aes(
      x = year,
      y = Agencies,
      group = agency_type,
      color = agency_type,
      linetype = agency_type
    )) +
    geom_line(linewidth = 0.9) +
    geom_point(size = 1.8) +
    scale_x_continuous(breaks = scales::breaks_pretty(n = 8)) +
    labs(
      x = "Year",
      y = "Distinct original agency numbers",
      color = "Agency type",
      linetype = "Agency type"
    ) +
    guides(
      color = guide_legend(nrow = 2, byrow = TRUE),
      linetype = guide_legend(nrow = 2, byrow = TRUE)
    ) +
    theme(legend.position = "bottom")
}
Figure 6: Original agency numbers represented in the source data each year

Panel completeness

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  panel_completeness_before <- before_df |>
    distinct(agency_id, agency_type, year) |>
    count(agency_id, agency_type, name = "Years observed")

  panel_completeness_before |>
    ggplot(aes(x = `Years observed`)) +
    geom_bar() +
    facet_wrap(
      ~agency_type,
      scales = "free_y",
      labeller = agency_facet_labeller
    ) +
    scale_x_continuous(breaks = seq(1, n_distinct(before_df$year), by = 2)) +
    labs(x = "Years observed", y = "Original agency numbers") +
    theme(strip.text = element_text(size = 9))
}
Figure 7: Years observed for each original agency number
Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  panel_completeness_before |>
    group_by(agency_type) |>
    summarise(
      Agencies = n(),
      `Minimum years` = min(`Years observed`),
      `Median years` = median(`Years observed`),
      `Maximum years` = max(`Years observed`),
      `Observed every year` = sum(`Years observed` == n_distinct(before_df$year)),
      .groups = "drop"
    ) |>
    knitr::kable(align = c("l", rep("r", 5)))
}
Table 6: Panel completeness before exclusions
agency_type Agencies Minimum years Median years Maximum years Observed every year
Home-rule municipalities 90 2 16 16 82
Non-home-rule municipalities 51 2 16 16 43
Schools 153 16 16 16 153
Townships 30 6 16 16 29
Other 911 1 16 16 512

Searchable source-agency inventory

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  raw_agency_inventory <- before_df |>
    group_by(
      agency_id,
      agency_name_display,
      original_major_type,
      original_minor_type
    ) |>
    summarise(
      `First year` = min(year, na.rm = TRUE),
      `Last year` = max(year, na.rm = TRUE),
      `Years observed` = n_distinct(year),
      .groups = "drop"
    ) |>
    arrange(agency_name_display) |>
    rename(
      `Agency number` = agency_id,
      `Agency name` = agency_name_display,
      `Major type` = original_major_type,
      `Minor type` = original_minor_type
    )

  DT::datatable(
    raw_agency_inventory,
    rownames = FALSE,
    filter = "top",
    class = "compact stripe hover",
    options = list(
      pageLength = 15,
      lengthMenu = list(
        c(10, 15, 25, 50, 100, -1),
        c("10", "15", "25", "50", "100", "All")
      ),
      order = list(list(1, "asc")),
      scrollX = TRUE,
      autoWidth = TRUE
    )
  )
}
Table 7

Levy and tax-base levels

Latest-year snapshot

Show code
latest_levels_after <- main_df |>
  filter(year == latest_year) |>
  group_by(agency_type) |>
  summarise(
    Agencies = n_distinct(agency_id),
    `Median levy ($ millions)` = median(total_final_levy, na.rm = TRUE) / 1e6,
    `Total levy ($ billions)` = sum(total_final_levy, na.rm = TRUE) / 1e9,
    `Median EAV ($ millions)` = median(eav, na.rm = TRUE) / 1e6,
    `Total EAV ($ billions)` = sum(eav, na.rm = TRUE) / 1e9,
    .groups = "drop"
  ) |>
  mutate(across(where(is.numeric), ~ round(.x, 2)))

knitr::kable(latest_levels_after, align = c("l", rep("r", 5)))
Table 8: Levy and EAV levels after exclusions
agency_type Agencies Median levy ($ millions) Total levy ($ billions) Median EAV ($ millions) Total EAV ($ billions)
Home-rule municipalities 70 15.82 3.16 601.14 171.43
Non-home-rule municipalities 37 4.52 0.24 384.51 16.00
Schools 133 23.96 5.54 609.70 178.26
Townships 28 4.32 0.16 2690.82 94.60
Other 144 2.18 0.90 489.25 216.49

Median levels over time

Show code
median_levels_after <- main_df |>
  select(year, agency_type, Levy = total_final_levy, EAV = eav) |>
  pivot_longer(c(Levy, EAV), names_to = "Measure", values_to = "Value") |>
  group_by(year, agency_type, Measure) |>
  summarise(`Median value` = median(Value, na.rm = TRUE) / 1e6, .groups = "drop")

median_levels_after |>
  ggplot(aes(
    x = year,
    y = `Median value`,
    group = agency_type,
    color = agency_type,
    linetype = agency_type
  )) +
  geom_line(linewidth = 0.9) +
  facet_wrap(~Measure, scales = "free_y", ncol = 1) +
  scale_x_continuous(breaks = scales::breaks_pretty(n = 8)) +
  scale_y_continuous(labels = scales::label_comma()) +
  labs(
    x = "Year",
    y = "Millions of dollars",
    color = "Agency type",
    linetype = "Agency type"
  ) +
  guides(
    color = guide_legend(nrow = 2, byrow = TRUE),
    linetype = guide_legend(nrow = 2, byrow = TRUE)
  ) +
  theme(legend.position = "bottom")
Figure 8: Median levy and EAV after exclusions

Indexed aggregate growth

Show code
indexed_levels_after <- main_df |>
  select(year, agency_type, Levy = total_final_levy, EAV = eav) |>
  pivot_longer(c(Levy, EAV), names_to = "Measure", values_to = "Value") |>
  group_by(year, agency_type, Measure) |>
  summarise(Total = sum(Value, na.rm = TRUE), .groups = "drop") |>
  group_by(agency_type, Measure) |>
  arrange(year, .by_group = TRUE) |>
  mutate(Index = 100 * Total / first(Total)) |>
  ungroup()

indexed_levels_after |>
  ggplot(aes(
    x = year,
    y = Index,
    group = agency_type,
    color = agency_type,
    linetype = agency_type
  )) +
  geom_hline(yintercept = 100, linetype = "dotted") +
  geom_line(linewidth = 0.9) +
  facet_wrap(~Measure, ncol = 1, scales = "free_y") +
  scale_x_continuous(breaks = scales::breaks_pretty(n = 8)) +
  labs(
    x = "Year",
    y = paste0("Index (", first_level_year, " = 100)"),
    color = "Agency type",
    linetype = "Agency type"
  ) +
  guides(
    color = guide_legend(nrow = 2, byrow = TRUE),
    linetype = guide_legend(nrow = 2, byrow = TRUE)
  ) +
  theme(legend.position = "bottom")
Figure 9: Aggregate levy and EAV after exclusions, indexed to the first panel year

Latest-year snapshot

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  before_df |>
    filter(year == latest_year) |>
    group_by(agency_type) |>
    summarise(
      Agencies = n_distinct(agency_id),
      `Valid levy values` = sum(is.finite(total_final_levy)),
      `Median levy ($ millions)` = median(total_final_levy, na.rm = TRUE) / 1e6,
      `Total levy ($ billions)` = sum(total_final_levy, na.rm = TRUE) / 1e9,
      `Valid EAV values` = sum(is.finite(eav)),
      `Median EAV ($ millions)` = median(eav, na.rm = TRUE) / 1e6,
      `Total EAV ($ billions)` = sum(eav, na.rm = TRUE) / 1e9,
      .groups = "drop"
    ) |>
    mutate(across(where(is.numeric), ~ round(.x, 2))) |>
    knitr::kable(align = c("l", rep("r", 7)))
}
Table 9: Levy and EAV levels before exclusions
agency_type Agencies Valid levy values Median levy ($ millions) Total levy ($ billions) Valid EAV values Median EAV ($ millions) Total EAV ($ billions)
Home-rule municipalities 90 90 11.38 2.71 90 599.02 186.42
Non-home-rule municipalities 43 43 3.38 0.20 43 390.28 20.60
Schools 153 152 25.97 6.68 152 668.63 209.28
Townships 29 29 2.63 0.12 29 2623.11 94.82
Other 624 624 0.42 3.98 617 227.64 1966.08

Median levels over time

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  median_levels_before <- before_df |>
    select(year, agency_type, Levy = total_final_levy, EAV = eav) |>
    pivot_longer(c(Levy, EAV), names_to = "Measure", values_to = "Value") |>
    filter(is.finite(Value)) |>
    group_by(year, agency_type, Measure) |>
    summarise(`Median value` = median(Value, na.rm = TRUE) / 1e6, .groups = "drop")

  median_levels_before |>
    ggplot(aes(
      x = year,
      y = `Median value`,
      group = agency_type,
      color = agency_type,
      linetype = agency_type
    )) +
    geom_line(linewidth = 0.9) +
    facet_wrap(~Measure, scales = "free_y", ncol = 1) +
    scale_x_continuous(breaks = scales::breaks_pretty(n = 8)) +
    scale_y_continuous(labels = scales::label_comma()) +
    labs(
      x = "Year",
      y = "Millions of dollars",
      color = "Agency type",
      linetype = "Agency type"
    ) +
    guides(
      color = guide_legend(nrow = 2, byrow = TRUE),
      linetype = guide_legend(nrow = 2, byrow = TRUE)
    ) +
    theme(legend.position = "bottom")
}
Figure 10: Median levy and EAV before exclusions

Indexed aggregate growth

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  indexed_levels_before <- before_df |>
    select(year, agency_type, Levy = total_final_levy, EAV = eav) |>
    pivot_longer(c(Levy, EAV), names_to = "Measure", values_to = "Value") |>
    filter(is.finite(Value)) |>
    group_by(year, agency_type, Measure) |>
    summarise(Total = sum(Value, na.rm = TRUE), .groups = "drop") |>
    group_by(agency_type, Measure) |>
    arrange(year, .by_group = TRUE) |>
    filter(first(Total) > 0) |>
    mutate(Index = 100 * Total / first(Total)) |>
    ungroup()

  indexed_levels_before |>
    ggplot(aes(
      x = year,
      y = Index,
      group = agency_type,
      color = agency_type,
      linetype = agency_type
    )) +
    geom_hline(yintercept = 100, linetype = "dotted") +
    geom_line(linewidth = 0.9) +
    facet_wrap(~Measure, ncol = 1, scales = "free_y") +
    scale_x_continuous(breaks = scales::breaks_pretty(n = 8)) +
    labs(
      x = "Year",
      y = paste0("Index (", first_level_year, " = 100)"),
      color = "Agency type",
      linetype = "Agency type"
    ) +
    guides(
      color = guide_legend(nrow = 2, byrow = TRUE),
      linetype = guide_legend(nrow = 2, byrow = TRUE)
    ) +
    theme(legend.position = "bottom")
}
Figure 11: Aggregate levy and EAV before exclusions, indexed to the first panel year

Annual changes

The distribution and scatter plots trim the first and ninety-ninth percentiles for display only. The summary statistics retain all finite annual changes.

Summary statistics

Show code
change_summary_after <- analysis_df |>
  group_by(agency_type) |>
  summarise(
    N = sum(is.finite(d_levy) & is.finite(d_eav)),
    `Mean d_levy` = mean(d_levy[is.finite(d_levy)], na.rm = TRUE),
    `Median d_levy` = median(d_levy[is.finite(d_levy)], na.rm = TRUE),
    `SD d_levy` = sd(d_levy[is.finite(d_levy)], na.rm = TRUE),
    `Mean d_eav` = mean(d_eav[is.finite(d_eav)], na.rm = TRUE),
    `Median d_eav` = median(d_eav[is.finite(d_eav)], na.rm = TRUE),
    `SD d_eav` = sd(d_eav[is.finite(d_eav)], na.rm = TRUE),
    .groups = "drop"
  ) |>
  mutate(across(where(is.numeric), ~ round(.x, 2)))

knitr::kable(change_summary_after, align = c("l", rep("r", 7)))
Table 10: Annual levy and EAV changes after exclusions
agency_type N Mean d_levy Median d_levy SD d_levy Mean d_eav Median d_eav SD d_eav
Home-rule municipalities 1030 3.57 2.95 5.88 0.43 -1.44 11.47
Non-home-rule municipalities 571 2.42 2.28 8.70 0.57 -1.57 11.73
Schools 1995 2.62 2.29 4.68 0.51 -1.62 11.60
Townships 420 2.49 2.73 5.66 0.51 -1.39 10.75
Other 2160 2.22 2.28 20.36 0.42 -1.56 11.78

Median annual changes over time

Show code
median_changes_after <- analysis_df |>
  select(year, agency_type, d_levy, d_eav) |>
  pivot_longer(c(d_levy, d_eav), names_to = "Measure", values_to = "Annual change") |>
  filter(is.finite(`Annual change`)) |>
  mutate(Measure = recode(Measure, d_levy = "Levy change", d_eav = "EAV change")) |>
  group_by(year, agency_type, Measure) |>
  summarise(`Median annual change` = median(`Annual change`), .groups = "drop")

median_changes_after |>
  ggplot(aes(
    x = year,
    y = `Median annual change`,
    group = agency_type,
    color = agency_type,
    linetype = agency_type
  )) +
  geom_hline(yintercept = 0, linetype = "dotted") +
  geom_line(linewidth = 0.9) +
  facet_wrap(~Measure, ncol = 1, scales = "free_y") +
  scale_x_continuous(breaks = scales::breaks_pretty(n = 8)) +
  labs(
    x = "Year",
    y = "100 × log change",
    color = "Agency type",
    linetype = "Agency type"
  ) +
  guides(
    color = guide_legend(nrow = 2, byrow = TRUE),
    linetype = guide_legend(nrow = 2, byrow = TRUE)
  ) +
  theme(legend.position = "bottom")
Figure 12: Median annual levy and EAV changes after exclusions

Distribution of annual changes

Show code
change_long_after <- analysis_df |>
  select(agency_type, d_levy, d_eav) |>
  pivot_longer(c(d_levy, d_eav), names_to = "Measure", values_to = "Annual change") |>
  mutate(Measure = recode(Measure, d_levy = "Levy change", d_eav = "EAV change")) |>
  filter(is.finite(`Annual change`))

bounds_after <- change_long_after |>
  group_by(Measure) |>
  summarise(
    lower = quantile(`Annual change`, 0.01),
    upper = quantile(`Annual change`, 0.99),
    .groups = "drop"
  )

change_long_after |>
  left_join(bounds_after, by = "Measure") |>
  filter(`Annual change` >= lower, `Annual change` <= upper) |>
  ggplot(aes(x = `Annual change`)) +
  geom_histogram(bins = 35) +
  facet_grid(
    Measure ~ agency_type,
    scales = "free",
    labeller = agency_facet_labeller
  ) +
  labs(x = "100 × log change", y = "Agency-year observations") +
  theme(strip.text.x = element_text(size = 8.5))
Figure 13: Distribution of annual levy and EAV changes after exclusions

Levy changes and EAV changes

Show code
scatter_bounds_after <- analysis_df |>
  summarise(
    d_eav_low = quantile(d_eav[is.finite(d_eav)], 0.01),
    d_eav_high = quantile(d_eav[is.finite(d_eav)], 0.99),
    d_levy_low = quantile(d_levy[is.finite(d_levy)], 0.01),
    d_levy_high = quantile(d_levy[is.finite(d_levy)], 0.99)
  )

analysis_df |>
  filter(
    is.finite(d_eav), is.finite(d_levy),
    d_eav >= scatter_bounds_after$d_eav_low,
    d_eav <= scatter_bounds_after$d_eav_high,
    d_levy >= scatter_bounds_after$d_levy_low,
    d_levy <= scatter_bounds_after$d_levy_high
  ) |>
  ggplot(aes(x = d_eav, y = d_levy)) +
  geom_hline(yintercept = 0, linetype = "dotted") +
  geom_vline(xintercept = 0, linetype = "dotted") +
  geom_point(alpha = 0.25, size = 1) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 0.8) +
  facet_wrap(
    ~agency_type,
    scales = "free",
    labeller = agency_facet_labeller
  ) +
  labs(x = "Annual EAV change", y = "Annual levy change") +
  theme(strip.text = element_text(size = 9))
Figure 14: Relationship between annual EAV and levy changes after exclusions

Summary statistics

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  before_change_df |>
    group_by(agency_type) |>
    summarise(
      N = sum(is.finite(d_levy) & is.finite(d_eav)),
      `Mean d_levy` = mean(d_levy[is.finite(d_levy)], na.rm = TRUE),
      `Median d_levy` = median(d_levy[is.finite(d_levy)], na.rm = TRUE),
      `SD d_levy` = sd(d_levy[is.finite(d_levy)], na.rm = TRUE),
      `Mean d_eav` = mean(d_eav[is.finite(d_eav)], na.rm = TRUE),
      `Median d_eav` = median(d_eav[is.finite(d_eav)], na.rm = TRUE),
      `SD d_eav` = sd(d_eav[is.finite(d_eav)], na.rm = TRUE),
      .groups = "drop"
    ) |>
    mutate(across(where(is.numeric), ~ round(.x, 2))) |>
    knitr::kable(align = c("l", rep("r", 7)))
}
Table 11: Annual levy and EAV changes before exclusions
agency_type N Mean d_levy Median d_levy SD d_levy Mean d_eav Median d_eav SD d_eav
Home-rule municipalities 1244 3.41 2.96 8.15 0.46 -1.02 12.57
Non-home-rule municipalities 691 2.78 2.24 11.83 0.14 -1.22 13.58
Schools 2282 2.62 2.34 4.61 0.50 -1.48 11.35
Townships 440 2.24 2.63 10.22 0.48 -1.43 10.84
Other 7764 1.79 1.87 26.87 -0.04 -0.84 32.29

Median annual changes over time

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  median_changes_before <- before_change_df |>
    select(year, agency_type, d_levy, d_eav) |>
    pivot_longer(c(d_levy, d_eav), names_to = "Measure", values_to = "Annual change") |>
    filter(is.finite(`Annual change`)) |>
    mutate(Measure = recode(Measure, d_levy = "Levy change", d_eav = "EAV change")) |>
    group_by(year, agency_type, Measure) |>
    summarise(`Median annual change` = median(`Annual change`), .groups = "drop")

  median_changes_before |>
    ggplot(aes(
      x = year,
      y = `Median annual change`,
      group = agency_type,
      color = agency_type,
      linetype = agency_type
    )) +
    geom_hline(yintercept = 0, linetype = "dotted") +
    geom_line(linewidth = 0.9) +
    facet_wrap(~Measure, ncol = 1, scales = "free_y") +
    scale_x_continuous(breaks = scales::breaks_pretty(n = 8)) +
    labs(
      x = "Year",
      y = "100 × log change",
      color = "Agency type",
      linetype = "Agency type"
    ) +
    guides(
      color = guide_legend(nrow = 2, byrow = TRUE),
      linetype = guide_legend(nrow = 2, byrow = TRUE)
    ) +
    theme(legend.position = "bottom")
}
Figure 15: Median annual levy and EAV changes before exclusions

Distribution of annual changes

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  change_long_before <- before_change_df |>
    select(agency_type, d_levy, d_eav) |>
    pivot_longer(c(d_levy, d_eav), names_to = "Measure", values_to = "Annual change") |>
    mutate(Measure = recode(Measure, d_levy = "Levy change", d_eav = "EAV change")) |>
    filter(is.finite(`Annual change`))

  bounds_before <- change_long_before |>
    group_by(Measure) |>
    summarise(
      lower = quantile(`Annual change`, 0.01),
      upper = quantile(`Annual change`, 0.99),
      .groups = "drop"
    )

  change_long_before |>
    left_join(bounds_before, by = "Measure") |>
    filter(`Annual change` >= lower, `Annual change` <= upper) |>
    ggplot(aes(x = `Annual change`)) +
    geom_histogram(bins = 35) +
    facet_grid(
      Measure ~ agency_type,
      scales = "free",
      labeller = agency_facet_labeller
    ) +
    labs(x = "100 × log change", y = "Agency-year observations") +
    theme(strip.text.x = element_text(size = 8.5))
}
Figure 16: Distribution of annual levy and EAV changes before exclusions

Levy changes and EAV changes

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  scatter_bounds_before <- before_change_df |>
    summarise(
      d_eav_low = quantile(d_eav[is.finite(d_eav)], 0.01),
      d_eav_high = quantile(d_eav[is.finite(d_eav)], 0.99),
      d_levy_low = quantile(d_levy[is.finite(d_levy)], 0.01),
      d_levy_high = quantile(d_levy[is.finite(d_levy)], 0.99)
    )

  before_change_df |>
    filter(
      is.finite(d_eav), is.finite(d_levy),
      d_eav >= scatter_bounds_before$d_eav_low,
      d_eav <= scatter_bounds_before$d_eav_high,
      d_levy >= scatter_bounds_before$d_levy_low,
      d_levy <= scatter_bounds_before$d_levy_high
    ) |>
    ggplot(aes(x = d_eav, y = d_levy)) +
    geom_hline(yintercept = 0, linetype = "dotted") +
    geom_vline(xintercept = 0, linetype = "dotted") +
    geom_point(alpha = 0.25, size = 1) +
    geom_smooth(method = "lm", se = FALSE, linewidth = 0.8) +
    facet_wrap(
      ~agency_type,
      scales = "free",
      labeller = agency_facet_labeller
    ) +
    labs(x = "Annual EAV change", y = "Annual levy change") +
    theme(strip.text = element_text(size = 9))
}
Figure 17: Relationship between annual EAV and levy changes before exclusions

Reassessment cycle

These are descriptive comparisons of instrument relevance, not tests of instrument validity.

Show code
reassessment_counts_after <- analysis_df |>
  count(agency_type, reassessment_status, name = "Observations") |>
  pivot_wider(names_from = reassessment_status, values_from = Observations, values_fill = 0)

knitr::kable(reassessment_counts_after, align = c("l", "r", "r"))
Table 12: Reassessment-year observations after exclusions
agency_type Other year Reassessment year
Home-rule municipalities 688 344
Non-home-rule municipalities 382 191
Schools 1330 665
Townships 280 140
Other 1440 720
Show code
analysis_df |>
  filter(is.finite(d_eav)) |>
  group_by(agency_type, reassessment_status) |>
  summarise(`Median EAV change` = median(d_eav), .groups = "drop") |>
  ggplot(aes(x = agency_type, y = `Median EAV change`, fill = reassessment_status)) +
  geom_hline(yintercept = 0, linetype = "dotted") +
  geom_col(position = "dodge") +
  coord_flip() +
  labs(x = NULL, y = "Median annual EAV change", fill = NULL) +
  theme(legend.position = "bottom")
Figure 18: Median EAV change in reassessment and other years after exclusions
Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  before_change_df |>
    count(agency_type, reassessment_status, name = "Observations") |>
    pivot_wider(names_from = reassessment_status, values_from = Observations, values_fill = 0) |>
    knitr::kable(align = c("l", "r", "r"))
}
Table 13: Reassessment-year observations before exclusions
agency_type Other year Reassessment year NA
Home-rule municipalities 865 437 0
Non-home-rule municipalities 465 228 0
Schools 0 0 2295
Townships 10 5 425
Other 3735 1836 4735
Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  before_change_df |>
    filter(is.finite(d_eav)) |>
    group_by(agency_type, reassessment_status) |>
    summarise(`Median EAV change` = median(d_eav), .groups = "drop") |>
    ggplot(aes(x = agency_type, y = `Median EAV change`, fill = reassessment_status)) +
    geom_hline(yintercept = 0, linetype = "dotted") +
    geom_col(position = "dodge") +
    coord_flip() +
    labs(x = NULL, y = "Median annual EAV change", fill = NULL) +
    theme(legend.position = "bottom")
}
Figure 19: Median EAV change in reassessment and other years before exclusions

Long-run growth

Long-run growth is calculated only for units observed in both 2008 and 2023 with finite endpoint growth rates. Readers can search for an agency or sort the growth columns to identify large increases and declines.

Show code
long_run_growth_after |>
  group_by(agency_type) |>
  summarise(
    Agencies = n(),
    `Median levy growth (%)` = median(levy_growth),
    `Mean levy growth (%)` = mean(levy_growth),
    `Median EAV growth (%)` = median(eav_growth),
    `Mean EAV growth (%)` = mean(eav_growth),
    .groups = "drop"
  ) |>
  mutate(across(where(is.numeric), ~ round(.x, 1))) |>
  knitr::kable(align = c("l", rep("r", 5)))
Table 14: Long-run levy and EAV growth after exclusions
agency_type Agencies Median levy growth (%) Mean levy growth (%) Median EAV growth (%) Mean EAV growth (%)
Home-rule municipalities 66 66.2 77.7 3.9 7.0
Non-home-rule municipalities 37 49.5 47.2 12.9 12.1
Schools 130 46.9 50.4 7.1 9.3
Townships 28 56.3 48.4 5.8 8.5
Other 144 46.1 49.5 5.3 8.5

Searchable agency-level growth

Show code
growth_table_after <- long_run_growth_after |>
  transmute(
    Agency = agency_name_display,
    `Agency type` = as.character(agency_type),
    `Start levy` = start_levy,
    `End levy` = end_levy,
    `Levy growth (%)` = levy_growth,
    `Start EAV` = start_eav,
    `End EAV` = end_eav,
    `EAV growth (%)` = eav_growth,
    `Levy minus EAV growth (percentage points)` = levy_minus_eav_growth
  ) |>
  arrange(desc(`Levy growth (%)`))

DT::datatable(
  growth_table_after,
  rownames = FALSE,
  filter = "top",
  class = "compact stripe hover",
  options = list(
    pageLength = 15,
    lengthMenu = list(
      c(10, 15, 25, 50, 100, -1),
      c("10", "15", "25", "50", "100", "All")
    ),
    order = list(list(4, "desc")),
    scrollX = TRUE,
    autoWidth = TRUE
  )
) |>
  DT::formatCurrency(
    columns = c("Start levy", "End levy", "Start EAV", "End EAV"),
    currency = "$",
    digits = 0,
    mark = ","
  ) |>
  DT::formatRound(
    columns = c(
      "Levy growth (%)",
      "EAV growth (%)",
      "Levy minus EAV growth (percentage points)"
    ),
    digits = 1
  )
Table 15
Show code
long_run_growth_after |>
  select(agency_type, `Levy growth` = levy_growth, `EAV growth` = eav_growth) |>
  pivot_longer(c(`Levy growth`, `EAV growth`), names_to = "Measure", values_to = "Growth") |>
  group_by(Measure) |>
  mutate(
    lower = quantile(Growth, 0.01),
    upper = quantile(Growth, 0.99)
  ) |>
  ungroup() |>
  filter(Growth >= lower, Growth <= upper) |>
  ggplot(aes(x = Growth)) +
  geom_histogram(bins = 30) +
  facet_grid(
    Measure ~ agency_type,
    scales = "free",
    labeller = agency_facet_labeller
  ) +
  labs(
    x = paste0("Percent growth, ", first_level_year, "–", last_year),
    y = "Grouped agencies"
  ) +
  theme(strip.text.x = element_text(size = 8.5))
Figure 20: Distribution of long-run levy and EAV growth after exclusions
Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  long_run_growth_before |>
    group_by(agency_type) |>
    summarise(
      Agencies = n(),
      `Median levy growth (%)` = median(levy_growth),
      `Mean levy growth (%)` = mean(levy_growth),
      `Median EAV growth (%)` = median(eav_growth),
      `Mean EAV growth (%)` = mean(eav_growth),
      .groups = "drop"
    ) |>
    mutate(across(where(is.numeric), ~ round(.x, 1))) |>
    knitr::kable(align = c("l", rep("r", 5)))
}
Table 16: Long-run levy and EAV growth before exclusions
agency_type Agencies Median levy growth (%) Mean levy growth (%) Median EAV growth (%) Mean EAV growth (%)
Home-rule municipalities 76 65.3 77.7 4.2 7.4
Non-home-rule municipalities 51 50.4 82.0 8.2 8.6
Schools 152 46.9 50.3 7.0 9.3
Townships 29 44.5 48.2 7.0 8.9
Other 431 43.8 44.4 5.7 20.7

Searchable agency-level growth

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  growth_table_before <- long_run_growth_before |>
    transmute(
      `Agency number` = agency_id,
      Agency = agency_name_display,
      `Agency type` = as.character(agency_type),
      `Start levy` = start_levy,
      `End levy` = end_levy,
      `Levy growth (%)` = levy_growth,
      `Start EAV` = start_eav,
      `End EAV` = end_eav,
      `EAV growth (%)` = eav_growth,
      `Levy minus EAV growth (percentage points)` = levy_minus_eav_growth
    ) |>
    arrange(desc(`Levy growth (%)`))

  DT::datatable(
    growth_table_before,
    rownames = FALSE,
    filter = "top",
    class = "compact stripe hover",
    options = list(
      pageLength = 15,
      lengthMenu = list(
        c(10, 15, 25, 50, 100, -1),
        c("10", "15", "25", "50", "100", "All")
      ),
      order = list(list(5, "desc")),
      scrollX = TRUE,
      autoWidth = TRUE
    )
  ) |>
    DT::formatCurrency(
      columns = c("Start levy", "End levy", "Start EAV", "End EAV"),
      currency = "$",
      digits = 0,
      mark = ","
    ) |>
    DT::formatRound(
      columns = c(
        "Levy growth (%)",
        "EAV growth (%)",
        "Levy minus EAV growth (percentage points)"
      ),
      digits = 1
    )
}
Table 17
Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  long_run_growth_before |>
    select(agency_type, `Levy growth` = levy_growth, `EAV growth` = eav_growth) |>
    pivot_longer(c(`Levy growth`, `EAV growth`), names_to = "Measure", values_to = "Growth") |>
    group_by(Measure) |>
    mutate(
      lower = quantile(Growth, 0.01),
      upper = quantile(Growth, 0.99)
    ) |>
    ungroup() |>
    filter(Growth >= lower, Growth <= upper) |>
    ggplot(aes(x = Growth)) +
    geom_histogram(bins = 30) +
    facet_grid(
      Measure ~ agency_type,
      scales = "free",
      labeller = agency_facet_labeller
    ) +
    labs(
      x = paste0("Percent growth, ", first_level_year, "–", last_year),
      y = "Original agency numbers"
    ) +
    theme(strip.text.x = element_text(size = 8.5))
}
Figure 21: Distribution of long-run levy and EAV growth before exclusions

Data completeness

Show code
analysis_df |>
  group_by(agency_type) |>
  summarise(
    `Agency-year observations` = n(),
    `Finite d_levy` = sum(is.finite(d_levy)),
    `Finite d_eav` = sum(is.finite(d_eav)),
    `Finite d_levy and d_eav` = sum(is.finite(d_levy) & is.finite(d_eav)),
    `Complete IG-revenue change` = sum(!is.na(d_total_ig_revenue)),
    `Complete enrollment change` = sum(!is.na(d_enrollment)),
    .groups = "drop"
  ) |>
  knitr::kable(align = c("l", rep("r", 6)))
Table 18: Availability of model variables after exclusions
agency_type Agency-year observations Finite d_levy Finite d_eav Finite d_levy and d_eav Complete IG-revenue change Complete enrollment change
Home-rule municipalities 1032 1030 1032 1030 505 0
Non-home-rule municipalities 573 571 573 571 99 0
Schools 1995 1995 1995 1995 1722 1722
Townships 420 420 420 420 101 0
Other 2160 2160 2160 2160 171 0

Enrollment is primarily relevant for school districts, so its missingness for other agency types is structural rather than a general data-quality problem.

The original source file does not contain the Census control variables used later in the manuscript. This tab therefore focuses on the availability of levy, EAV, and their annual changes.

Show code
if (!before_available) {
  cat(before_missing_message)
} else {
  before_change_df |>
    group_by(agency_type) |>
    summarise(
      `Agency-year observations` = n(),
      `Finite levy level` = sum(is.finite(total_final_levy)),
      `Finite EAV level` = sum(is.finite(eav)),
      `Finite d_levy` = sum(is.finite(d_levy)),
      `Finite d_eav` = sum(is.finite(d_eav)),
      `Finite d_levy and d_eav` = sum(is.finite(d_levy) & is.finite(d_eav)),
      .groups = "drop"
    ) |>
    knitr::kable(align = c("l", rep("r", 6)))
}
Table 19: Availability of levy and EAV variables before exclusions
agency_type Agency-year observations Finite levy level Finite EAV level Finite d_levy Finite d_eav Finite d_levy and d_eav
Home-rule municipalities 1302 1302 1302 1244 1301 1244
Non-home-rule municipalities 693 693 693 691 693 691
Schools 2295 2282 2282 2282 2282 2282
Townships 440 440 440 440 440 440
Other 10306 10306 10208 7766 10044 7764

Optional fund-level appendix

The agency-fund extract is separate from both panel files. This appendix remains disabled so the main descriptive page does not depend on another large data source.

Remove eval: false when agency_funds.csv is available.

Show code
agency_funds <- readr::read_csv("agency_funds.csv", show_col_types = FALSE)
latest_fund_year <- max(agency_funds$year, na.rm = TRUE)

top_funds <- agency_funds |>
  filter(
    year == latest_fund_year,
    levy > 0,
    major_type != "COOK COUNTY",
    minor_type != "SSA"
  ) |>
  count(fund_name, sort = TRUE, name = "Agencies") |>
  slice_head(n = 15)

top_funds |>
  ggplot(aes(x = reorder(fund_name, Agencies), y = Agencies)) +
  geom_col() +
  coord_flip() +
  labs(
    x = NULL,
    y = "Number of agencies",
    title = paste("Most common fund names in", latest_fund_year)
  )