Code
library(tidyverse)
library(ptaxsim)
library(DBI)
library(httr)
library(jsonlite)
library(glue)
library(DT)

knitr::opts_chunk$set(warning = FALSE, message = FALSE)

# # Create an empty data frame with a column named "year"
# params <- data.frame(year = numeric(0))
# 
# # Add the value 2021 to the "year" column
# params <- rbind(params, data.frame(year = 2021))

i = params$year

class_dict <- read_csv("../Necessary_Files/class_dict_singlefamcodes.csv") %>% 
  mutate(class_code = as.character(class_code)) # change variable type to character so the join works.

eq_factor <- read_csv("../Necessary_Files/eq_factor.csv") %>% 
  filter(year == i) |> select(eq_factor = eq_factor_final) |> as.numeric()


muni_ratechange <- read_csv(paste0("../Output/muni_ratechange_", i, "_test.csv"))

muni_rates <- muni_ratechange |> # keep just a couple variables to avoid join variable name conflicts later
  select(clean_name, current_rate_avg, rate_noGHE, rate_current)
  
pin_data <- read_csv(paste0("../Output/Dont_Upload/0_joined_PIN_data_", i, "_test.csv")) %>%
  mutate(class = as.character(class))

c2pins <- pin_data |> 
  filter(class > 199 & class < 300) |>
  mutate(eav_postexemptions = taxed_eav)

Addendum to Exemption Report - Tax Year 2023

Code
q = c(.1, .25, .5, .75, .9)

## ranks properties that are considered C2 properties in order of AV for each Muni
muni_quartiles <- c2pins %>%
  group_by(clean_name) %>%
  arrange(av) %>%
  summarize(count_pins = n(), 
            min = min(av),
            quant10 = round(quantile(av, probs = q[1])), 
            quant25 = round(quantile(av, probs = q[2])), 
            quant50 = round(quantile(av, probs = q[3])),
            quant75 = round(quantile(av, probs = q[4])),
            quant90 = round(quantile(av, probs = q[5])),

            max = max(av)
           ) %>% 
  arrange(desc(quant50))


# Class 2 Descriptive Stats 
C2_munistats <- c2pins %>% 
  filter(class > 199 & class < 300) %>%
  group_by(clean_name)  %>%
  arrange(av) %>%
  summarize(
    median_eav = round(median(taxed_eav)), 
    median_av = round(median(av)), 
    avg_av = round(mean(av)),
    avg_eav = round(mean(taxed_eav)),
    C2_pins_in_muni = n(),
    C2_current_exemptions = sum(all_exemptions, na.rm = TRUE),
    C2_HO_exemps = sum(exe_homeowner, na.rm = TRUE),
  ) 

# All Class 2 Properties
munis_ranked <- c2pins  %>%
  inner_join(muni_quartiles, by = c("clean_name")) %>% 
  mutate(rank = case_when(
   av > (quant10-500) & (av<quant10+500) ~ "q10",
    av > (quant25-500) & (av<quant25+500) ~ "q25",
    av > (quant50-500) & (av<quant50+500) ~ "q50",
    av > (quant75-500) & (av<quant75+500) ~ "q75",
    av > (quant90-500) & (av<quant90+500) ~ "q90")

    ) %>%
  select(clean_name, rank, av, pin, class, everything())

Many of the figures and tables in the Exemption Addendum (2024) refer to the “Alternative Scenario” (AS) and the “Status Quo” (SQ). The status quo is simply Cook County’s current property tax regime. The alternative scenario, by way of contrast, sets the general homestead exemption (and only general homestead exemption) to zero.

We believe this decision is a feature and not bug of the analysis in the Addendum, which has an emphasis on equity. The effects of changing the value of the most prevalent homestead exemption illuminate the effect of changing other exemptions.

Which taxpayers benefit the most and least from homestead exemptions?

Table 1. Comparison of Tax Bills for Median Assessed Value (AV) Class 2 Properties in Selected Municpalities under the Status Quo and Alternative Scenario

Code
tax_bill_change_HO <- c2pins %>% 
  left_join(muni_rates) |>
  filter(av > 0) %>% 
  arrange(av) %>%
  mutate(#eq_av = av_clerk*eq_factor,
         bill_current = (taxed_eav*tax_code_rate),
         bill_noexemps =  rate_noGHE*(taxed_eav+exe_homeowner),
         bill_change = bill_noexemps - bill_current) %>%
  group_by(clean_name, has_HO_exemp) %>% 
  summarize(median_AV = median(av),
            median_EAV = median(taxed_eav),
            median_bill_cur = round(median(bill_current)),
            median_bill_new = round(median(bill_noexemps)),
            median_change = round(median(bill_change)),
            pincount=n(),
            perceived_savings = median(exe_homeowner*tax_code_rate)
  )

tax_bill_change_HO |> filter(clean_name %in% c("Park Forest", "Phoenix", "Winnetka", "Riverdale")) |>

  DT::datatable(rownames = FALSE, 
                colnames = c(
                  'Claims GHE' = 'has_HO_exemp',
                  'Current Bill' = 'median_bill_cur',
                  'Hyp. Bill' = 'median_bill_new', 
                  'Bill Change' = 'median_change',  
                  'Perceived Savings'='perceived_savings'),
                caption = "Change in Tax Bill for Properties with an Assessed Value of $15,000.")  |>
  formatCurrency(columns = c(2, 4:9), digits = 0)
Table 5.1: AV stands for “assessed value” “Tax calculator reduction” refers to the exemption values shown on the tax bill received by taxpayers. Values are estimated using an average of the properties with an AV within $1,000 of it’s municipality’s median AV to enlarge the number of observations. Thus, our calculations do not precisely represent the median.

Figure 2. Value of Median Residential Property Indifferent to Elimination of GHE

Code
muni_breakeven_points <- c2pins |>
  left_join(muni_rates) |>
  filter(exe_homeowner == 10000) %>% 
  filter(exe_senior == 0 & 
           exe_freeze == 0 & 
           exe_longtime_homeowner == 0 & 
           exe_disabled == 0 & 
           exe_vet_returning == 0 & 
           exe_vet_dis_lt50 == 0 & 
           exe_vet_dis_50_69 == 0 & 
           exe_vet_dis_ge70 == 0 & 
           exe_abate == 0  &
           exe_missing_disvet == 0) %>%
  
  group_by(clean_name) %>%
  summarize(
    median_AV = median(av),     # median class 2 AV for properties that did claim the GHE but not other exemptions
    bill_current = mean(eav_postexemptions * tax_code_rate), # money collected by non-TIF agencies
    
    bill_noGHE = mean(rate_noGHE * (eav_postexemptions+exe_homeowner)), # this uses tax code tax rate
   # rate_change = mean(tax_code_rate - tc_hyp_taxrate),   # avg tax code level rate change 
    muni_avg_rate_change = mean(tax_code_rate - rate_noGHE),
    # nobillchange_propertyEAV = round(mean(exe_homeowner * 
    #                                         (tax_code_rate / (tax_code_rate-tc_hyp_taxrate)))), #   
     nobillchange_propertyEAV_muni = round(mean(exe_homeowner *
                                                 (rate_current/(rate_current-rate_noGHE) )))
  ) |> 
  mutate(nochange_av = nobillchange_propertyEAV_muni / eq_factor,
         nochange_av_muni = nobillchange_propertyEAV_muni/ eq_factor,
         ) 




muni_breakeven_points %>%       
  filter(clean_name %in% c("Chicago","Park Forest", "Phoenix", "Riverdale", "Winnetka")) |>
  ggplot(aes(y=median_AV, x = clean_name)) +
  geom_col()+
    geom_text(aes(y=median_AV + 3000, label = round(median_AV) ) ) +
  scale_y_continuous(labels = scales::dollar) +
  theme_classic() +
  scale_x_discrete(label = c("Chicago","Park Forest", "Phoenix", "Riverdale", "Winnetka")) + 
  labs(y = "Median Residential AV", x = "", 
       title = "Median Residential Property Assessed Value - All Class 2 property Types")
Break even points vary across municipalities, sometimes by orders of magnitude

Code
muni_breakeven_points %>%       
  filter(clean_name %in% c("Chicago","Park Forest", "Phoenix", "Riverdale", "Winnetka")) |>
  ggplot(aes(y=nochange_av, x = clean_name)) +
  geom_col() +
  geom_text(aes(y=nochange_av + 3000, label = round(nochange_av)) ) +
  scale_y_continuous(labels = scales::dollar) +
  theme_classic() +
  scale_x_discrete(label = c("Chicago", "Park Forest", "Phoenix", "Riverdale", "Winnetka")) + 
  labs(y = "Breakeven Point - AV", x = "", 
  title = "Residential Property AV Breakeven Point", 
  caption = "Residential properties above these values would have their bills decrease if the GHE were eliminated
       (if they had claimed the GHE before)")
Break even points vary across municipalities, sometimes by orders of magnitude

Of homeowners who take the GHE, those that gain the largest monetary benefit from the exemption in proportion to their home values are those who own below-average valued properties that are in a municipality with relatively high tax rates. Property owners who benefit the least from taking the exemption (again, relative to the value of their homes) are those with above-average valued homes in jurisdictions with low tax rates. Note that these are not the same homeowners who benefit the most in absolute dollar terms—that outcome depends on both the absolute and relative value of the property and the composition of properties within the jurisdiction.

Code
muni_breakeven_points %>%
 # filter(nochange_av < 300000) %>%
  ggplot(aes(y=nochange_av, 
             x = median_AV, 
             label=clean_name
             )) +
  geom_abline(intercept = 0, slope = 1, lty = 2, alpha = .4) +
  geom_point(aes(alpha = .5)) +
 # geom_smooth(method = "lm" )+
  geom_text(aes(y = (nochange_av-5000), x = (median_AV)), size = 2)+
  theme_classic() +
  scale_x_continuous(labels = scales::dollar) +
  scale_y_continuous(labels = scales::dollar) +
  theme(legend.position = "none")+
  labs(y = " Breakeven Point", 
       x = "Median AV - Class 2 Properties in Municipality", 
      # title = "Some highly valued homes would have lower tax bills if the GHE were eliminated", 
       #caption = "The breakeven point is the assessed values at which a major class 2 
     #  property would not have their taxbill change if the GHE were eliminated."
     )
Figure 5.1: Includes all municipalities
Code
muni_breakeven_points %>%
  filter(nochange_av < 300000) %>%
  ggplot(aes(y=nochange_av, 
             x = median_AV, 
             label=clean_name
             )) +
  geom_abline(intercept = 0, slope = 1, lty = 2, alpha = .4) +

  geom_point(aes(alpha = .5)) +
  
  geom_point(data = (muni_breakeven_points %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Riverdale","Dolton"))), aes(y = nochange_av, x = median_AV, color = "red"), size = 3) +
  ggrepel::geom_label_repel(data = (muni_breakeven_points %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Riverdale", "Dolton"))), aes(y = (nochange_av), x = (median_AV)), size = 3)+
  theme_classic() +
  theme(panel.background = element_blank()) + 
  scale_x_continuous(labels = scales::dollar, expand = c(0,0), limits = c(0,170000) ) +
  scale_y_continuous(labels = scales::dollar, expand = c(0,0), limits = c(0,170000) ) +
    theme(legend.position = "none") +

  labs(title = "Municipalities' Median AV & Breakeven Point \nClass 2 Properties Only",
    y = "Breakeven Point", 
       x = "Median AV", 
 # caption = "The breakeven point is the assessed values at which a major class 2 property would not have their taxbill change if the 
 # GHE were eliminated. Excludes outliers: University Park, Bedford Park, McCook, Hodgkins, and Rosemont. Class 2 properties that had
 # claimed the GHE would have lower taxbills even if the GHE were removed if the AV is above the breakeven point."
 ) + coord_fixed()

#ggsave("breakevenpoint.svg", bg = "transparent")
Figure 5.2: Breakeven Point …

How progressive are exemptions?

Figure 3. Ratio of Tax Bills to AV for 25th and 75th Percentile AV Class 2 Properties with $10,000 GHE

For the different exemption scenarios, we created new exemption variables for alternate exemption amounts that can be subtracted from a properties equalized AV.

  • ex. For exe_neg10 all exemption amounts are equal to zero.
  • For exe_0, This variable should be the same as the current tax system since we did not add or remove any exempt EAV to the PIN. If the EAV for a PIN is less than 10000 EAV and they did claim the general homestead exemption, then their exempt EAV is equal to their EAV.
  • If the exempt EAV allowable for the GHE was increased to 20,000 EAV, then the variable exe_plus10 is used.
Code
pin_data <- pin_data %>% 
  left_join(class_dict, by = c("class" = "class_code")) %>%
  #  mutate(has_HO_exemp = ifelse( exe_homeowner > 0, 1, 0),
         # has_other_exe = ifelse(exe_senior + exe_freeze + exe_longtime_homeowner + exe_disabled + exe_vet_returning + exe_vet_dis_lt50 + exe_vet_dis_50_69 + exe_vet_dis_ge70 > 0, 1, 0 )) %>% 
 # left_join(tax_codes) |>
 # left_join(tc_muninames) %>%
 # mutate(av = av_clerk) |>
 # left_join(tax_code_change) |>
 # left_join(tif_distribution) |>
  mutate(
    # tax_code_distribution_pct = ifelse(is.na(tax_code_distribution_pct), 0, tax_code_distribution_pct),
    # in_tif = ifelse(tax_code_num %in% tif_distribution$tax_code_num, 1, 0),
    # has_HO_exemp = ifelse(exe_homeowner > 0, 1, 0),
    # has_other_exe = ifelse(exe_senior + exe_freeze + exe_longtime_homeowner + exe_disabled +
    #                          exe_vet_returning + exe_vet_dis_lt50 + exe_vet_dis_50_69 + 
    #                          exe_vet_dis_ge70 > 0, 1, 0 ),
    # all_exemptions = rowSums(across(starts_with("exe_"))),
    # final_tax_to_tif = tax_bill_total *  tax_code_distribution_pct,
    # final_tax_to_dist = tax_bill_total-final_tax_to_tif,
    tif_increment_eav = final_tax_to_tif / tax_code_rate,
    
    nontif_eav_preexe = av_clerk*eq_factor - tif_increment_eav,
    nontif_eav_postexe = av_clerk*eq_factor - tif_increment_eav - all_exemptions,
    
    eav_postexemptions = av_clerk*eq_factor - all_exemptions,
    eav_preexemptions = av_clerk*eq_factor) |>
  ## Create variables for alternate exemption amounts that can be subtracted from a properties equalized AV
  mutate(
    eav = eav_preexemptions,
    exe_neg10 = 0,
         
      # exe_0 implies no additional or removed EAV. Current tax system. 
         exe_0 = ifelse(eav < 10000 & exe_homeowner!=0, eav, 
                             ifelse(eav>10000 & exe_homeowner!=0, 10000, 0 )),  # no change in current exemptions
         exe_plus10 = ifelse(eav < 20000 & exe_homeowner!=0, eav, 
                             ifelse(eav>20000 & exe_homeowner!=0, 20000, 0 )),
         exe_plus20 = ifelse(eav < 30000 & exe_homeowner!=0, eav, 
                             ifelse(eav>30000 & exe_homeowner!=0, 30000, 0 ) ),
         exe_plus30 = ifelse(eav < 40000 & exe_homeowner!=0, eav, 
                             ifelse(eav>40000 & exe_homeowner!=0, 40000, 0) ),
         exe_plus40 = ifelse(eav < 50000 & exe_homeowner!=0, eav, 
                             ifelse(eav>50000 & exe_homeowner!=0, 50000, 0) ),
         mil_home = ifelse(major_class_code == 2 & av*10 > 1000000, 1, 0))


# all pins in munis fully within cook county that are some form of single-family, detached home
singfam_pins <- pin_data %>% 
  filter(Option2 == "Single-Family")
Code
# Calculates tax rates for all exemption scenarios.
scenario_calcs <- pin_data %>%    
  group_by(clean_name) %>%

    summarize(MuniLevy = round(sum(final_tax_to_dist, na.rm = TRUE)), # amount billed by munis with current exemptions in place
            current_nonTIF_EAV_post_exemps = sum(final_tax_to_dist/(tax_code_rate), na.rm = TRUE),
            current_TIF_increment_EAV = sum(final_tax_to_tif/(tax_code_rate), na.rm=TRUE),  
            current_Exempt_EAV = sum(all_exemptions, na.rm=T), 
            current_GHE = sum(exe_homeowner, na.rm=TRUE),
            Total_EAV = sum(all_exemptions+(final_tax_to_dist+final_tax_to_tif)/(tax_code_rate), na.rm = TRUE),
            exe_neg10 = sum(exe_neg10),
            exe_0 = sum(exe_0), # no change, for comparison
            exe_plus10 = sum(exe_plus10),
            exe_plus20 = sum(exe_plus20),
            exe_plus30 = sum(exe_plus30),
            exe_plus40 = sum(exe_plus40),
            mil_home = sum(mil_home)
            ) %>%

  # remove all GHE (up to 10,000 EAV added back to base per PIN), 
  # add exe_homeowner back to taxable base
  mutate(neg10_taxable_eav = Total_EAV - current_TIF_increment_EAV - current_Exempt_EAV + current_GHE,    # adds GHE exempt EAV back to taxable base and decreases tax rates
         plus10_taxable_eav = Total_EAV - current_TIF_increment_EAV - current_Exempt_EAV + current_GHE - exe_plus10, # will increase tax rates
         plus20_taxable_eav = Total_EAV - current_TIF_increment_EAV - current_Exempt_EAV + current_GHE - exe_plus20,
         plus30_taxable_eav = Total_EAV - current_TIF_increment_EAV - current_Exempt_EAV + current_GHE - exe_plus30,
         plus40_taxable_eav = Total_EAV - current_TIF_increment_EAV - current_Exempt_EAV + current_GHE - exe_plus40,
         scenario_noexemptions_taxable_eav = Total_EAV - current_TIF_increment_EAV) %>%
  
  mutate(tr_neg10 = MuniLevy / neg10_taxable_eav,
         tr_nochange = MuniLevy / current_nonTIF_EAV_post_exemps,
         tr_plus10 = MuniLevy / plus10_taxable_eav,
         tr_plus20 = MuniLevy / plus20_taxable_eav,
         tr_plus30 = MuniLevy / plus30_taxable_eav,
         tr_plus40 = MuniLevy / plus40_taxable_eav, 
         tax_rate_current = MuniLevy/current_nonTIF_EAV_post_exemps,
         taxrate_noexemps = MuniLevy /(Total_EAV - current_TIF_increment_EAV  ),
         taxrate_noTIFs = MuniLevy / (Total_EAV - current_Exempt_EAV),
         taxrate_noTIFs_orExemps = MuniLevy / Total_EAV) %>%
  select(clean_name, MuniLevy, tr_neg10:taxrate_noTIFs_orExemps, everything())

scenario_taxrates <- scenario_calcs %>%  
  select(clean_name, MuniLevy, tr_neg10:taxrate_noTIFs_orExemps) 

scenario_taxrates |> mutate(across(.cols=(tr_neg10:taxrate_noTIFs_orExemps), round, digits=3)) |>
  rename(Municipality = clean_name,
        `Composite Levy` = MuniLevy,
    `Rate if \nGHE=0K` = tr_neg10, 
         `Current\nTax Rate`= tr_nochange, 
         `Rate if\nGHE=20K`= tr_plus10, 
         `Rate if\nGHE=30K` = tr_plus20, 
         `Rate if\nGHE=40K` = tr_plus30, 
         `Rate if\nGHE=50K` = tr_plus40) |> 
  DT::datatable(rownames = FALSE)
Code
munis_billchange <- munis_ranked %>% 
    left_join(scenario_taxrates) %>%

  mutate(
    eav = eq_av,
    exe_neg10 = 0,
         
      # exe_0 implies no additional or removed EAV. Current tax system. 
         exe_0 = ifelse(eav < 10000 & exe_homeowner!=0, eav, 
                             ifelse(eav>10000 & exe_homeowner!=0, 10000, 0 )),  # no change in current exemptions
         exe_plus10 = ifelse(eav < 20000 & exe_homeowner!=0, eav, 
                             ifelse(eav>20000 & exe_homeowner!=0, 20000, 0 )),
         exe_plus20 = ifelse(eav < 30000 & exe_homeowner!=0, eav, 
                             ifelse(eav>30000 & exe_homeowner!=0, 30000, 0 ) ),
         exe_plus30 = ifelse(eav < 40000 & exe_homeowner!=0, eav, 
                             ifelse(eav>40000 & exe_homeowner!=0, 40000, 0) ),
         exe_plus40 = ifelse(eav < 50000 & exe_homeowner!=0, eav, 
                             ifelse(eav>50000 & exe_homeowner!=0, 50000, 0) ),
         mil_home = ifelse(av*10 > 1000000, 1, 0)) |>
    mutate(,
    # current bill = current tax rate * portion of levy billed
         equalized_AV = av_clerk*eq_factor,
         
   # ## Made negative tax bills!! ## #
          bill_neg10 = tr_neg10*(equalized_AV-all_exemptions+ exe_homeowner -exe_neg10),

         bill_current = tax_code_rate*(equalized_AV-all_exemptions),
         bill_plus10 =  tr_plus10*(equalized_AV-all_exemptions+ exe_homeowner -exe_plus10),
         bill_plus20 = tr_plus20*(equalized_AV-all_exemptions+ exe_homeowner -exe_plus20),
         bill_plus30 = tr_plus20*(equalized_AV-all_exemptions+ exe_homeowner -exe_plus30),
         bill_plus40 = tr_plus20*(equalized_AV-all_exemptions+ exe_homeowner -exe_plus40),
    
    # ## make bills $1 if they had been negative.
         bill_neg10 = ifelse(bill_neg10 < 1, 1, bill_neg10),
         bill_current = ifelse(bill_current < 1, 1, bill_current),
         bill_plus10 = ifelse(bill_plus10 < 1, 1, bill_plus10),
         bill_plus20 = ifelse(bill_plus20 < 1, 1, bill_plus20),
         bill_plus30 = ifelse(bill_plus30 < 1, 1, bill_plus30),
         bill_plus40 = ifelse(bill_plus40 < 1, 1, bill_plus40),

         )%>%
  mutate(
         zerodol_bills_ghe0 = ifelse(bill_neg10 < 5, 1, 0),
         zerodol_bills_current = ifelse(bill_current < 5, 1, 0),
         zerodol_bills_ghe20 = ifelse(bill_plus10 < 5, 1, 0),
         zerodol_bills_ghe30 = ifelse(bill_plus20 < 5, 1, 0),
         zerodol_bills_ghe40 = ifelse(bill_plus30 < 5, 1, 0),
         zerodol_bills_ghe50 = ifelse(bill_plus40 < 5, 1, 0),
  ) %>%
  ungroup() |>
  group_by(clean_name, rank, has_HO_exemp) %>% 
  summarize(median_AV = round(median(av)),
            median_EAV = round(median(eav)),
            mean_bill_neg10 = round(mean(bill_neg10, na.rm=TRUE)),
            mean_bill_cur = round(mean(bill_current, na.rm=TRUE)),
            mean_bill_plus10 = round(mean(bill_plus10, na.rm=TRUE)),
            mean_bill_plus20 = round(mean(bill_plus20, na.rm=TRUE)),
            mean_bill_plus30 = round(mean(bill_plus30, na.rm=TRUE)),
            mean_bill_plus40 = round(mean(bill_plus40, na.rm=TRUE)),
            
            tr_neg10 = round(mean(tr_neg10*100), digits = 2), 
            cur_comp_TC_rate = round(mean(tax_code_rate*100), digits = 2),
            tr_plus10 = round(mean(tr_plus10*100), digits = 2),
            tr_plus20 = round(mean(tr_plus20*100), digits = 2),
            tr_plus30 = round(mean(tr_plus30*100), digits = 2),
            tr_plus40 = round(mean(tr_plus40*100), digits = 2),
            pincount=n(),
            zerodol_bills_ghe0 = sum(zerodol_bills_ghe0),           
            zerodol_bills_current = sum(zerodol_bills_current),
            zerodol_bills_ghe20 = sum(zerodol_bills_ghe20),
            zerodol_bills_ghe30 = sum(zerodol_bills_ghe30),
            zerodol_bills_ghe40 = sum(zerodol_bills_ghe40),
            zerodol_bills_ghe50 = sum(zerodol_bills_ghe50),


  ) %>%
  mutate(clean_name = ifelse(clean_name == "Hazelcrest", "Hazel Crest", clean_name)) %>%
  arrange(has_HO_exemp, rank)





ratios <- munis_billchange %>% 
  filter(has_HO_exemp == 1 &  !is.na(rank)) %>% # claimed exemption in 2021
  mutate(currbill_to_AV_25 = ifelse(rank == "q25", mean_bill_cur/median_AV, NA)) %>%
  mutate(currbill_to_AV_75 = ifelse(rank == "q75", mean_bill_cur/median_AV, NA)) %>% 
  group_by(clean_name) %>%
  summarize(currbill_to_AV_25 = max(currbill_to_AV_25, na.rm=TRUE),
            currbill_to_AV_75 = max(currbill_to_AV_75, na.rm=TRUE)) %>%
  mutate(muni_ratio_25to75 = currbill_to_AV_25/currbill_to_AV_75) %>% filter(muni_ratio_25to75 > 0.01)



ggplot(data = ratios, aes(y = currbill_to_AV_25, x = currbill_to_AV_75, label = clean_name)) + 
  geom_abline(intercept = 0, slope = 1, lty = 2, alpha = .4) +
  geom_point(data = ratios, aes(alpha = .5)) + 
  ggrepel::geom_label_repel(data = (ratios %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Glencoe", "Riverdale", "Dolton", "Markham", "Chicago Heights", "Hazel Crest", "Phoenix"))), aes(y = currbill_to_AV_25, x = currbill_to_AV_75), size = 2, max.overlaps = Inf, point.padding = 0, # additional padding around each point
    min.segment.length = 0, # draw all line segments
    )+
    geom_point(data = (ratios %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Glencoe", "Riverdale", "Dolton", "Markham", "Chicago Heights", "Hazel Crest", "Phoenix"))), aes(y = currbill_to_AV_25, x = currbill_to_AV_75, color = "red"), size = 2)+

  theme_classic() +
  theme(legend.background = element_rect(fill = "transparent"), 
        legend.box.background = element_rect(fill = "transparent"), 
        panel.background = element_rect(fill = "transparent"), 
        panel.grid.major = element_blank(), 
        panel.grid.minor = element_blank(), 
        plot.background = element_rect(fill = "transparent", 
                                       color = NA) ) +
    scale_y_continuous(limits = c(0, .6), expand = c(0,0))+
   scale_x_continuous(limits = c(0, .6), expand = c(0,0))+
  theme(legend.position = "none") +
  labs(title="", y="Tax Burden as a Share of AV \n(25th Percentile)", x="Tax Burden as a Share of AV \n(75th Percentile)", subtitle = "Current Tax System, GHE = 10,000 EAV") + coord_fixed()

Code
pin_data %>%
  filter(av < 300000) %>% # just to see the histogram better
  ggplot( aes(x=av)) +
  geom_histogram(bins = 50)  +
  theme_classic()+
  labs(title = "Cook County Class 2 Residential PIN Distribution of AV", 
       x = "Assessed Value ($)", y="# of Pins", 
       caption = "Dropped PINs with AVs over $300,000 for better visual of histogram bins.") +
  scale_x_continuous(label = scales::dollar) +
  scale_y_continuous(label = scales::comma)
singfam_pins %>%
 filter(av < 300000) %>% # just to see the histogram better
  
  ggplot( aes(x=av)) +
  geom_histogram(bins = 50)  +
  theme_classic()+
  labs(title = "Cook County Single-Family PIN Distribution of AV", 
       x = "Assessed Value ($)", y="# of Pins", 
       caption = "Dropped PINs with AVs over $300,000 for better visual of histogram bins."
       ) +
  scale_x_continuous(label = scales::dollar)+
  scale_y_continuous(label = scales::comma)
Distribution of Assessed Values: Class 2 Properties vs Single-Family Properties There is a difference in the assessed values depending on if you use all 200 level properties or only a subset of them. All figures and tables were created for both options when writing the report as a robustness check.

Distribution of Assessed Values: Class 2 Properties vs Single-Family Properties There is a difference in the assessed values depending on if you use all 200 level properties or only a subset of them. All figures and tables were created for both options when writing the report as a robustness check.

What would happen if the GHE value were increased?

Figure 4. Effect of Changing the GHE on Progressivity

Code
ratios <- munis_billchange %>% 
  filter(has_HO_exemp == 1 &  !is.na(rank)) %>% # claimed exemption in 2021
  mutate(currbill_to_AV_25 = ifelse(rank == "q25", mean_bill_neg10/median_AV, NA)) %>%
  mutate(currbill_to_AV_75 = ifelse(rank == "q75", mean_bill_neg10/median_AV, NA)) %>% 
  group_by(clean_name) %>%
  summarize(GHE_0_bill_to_AV_25 = max(currbill_to_AV_25, na.rm=TRUE),
            GHE_0_bill_to_AV_75 = max(currbill_to_AV_75, na.rm=TRUE)) %>%
  mutate(muni_ratio_25to75 = GHE_0_bill_to_AV_25/GHE_0_bill_to_AV_75) 


ggplot(data = ratios, aes(y = GHE_0_bill_to_AV_25, x = GHE_0_bill_to_AV_75, label = clean_name)) + 
  geom_abline(intercept = 0, slope = 1) +
  geom_point(aes(alpha = .5)) + 
  ggrepel::geom_label_repel(data = (ratios %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Glencoe", "Riverdale", "Dolton", "Markham", "Chicago Heights", "Hazel Crest", "Phoenix"))), aes(y = GHE_0_bill_to_AV_25, x = GHE_0_bill_to_AV_75, label = clean_name), size = 3)+
    geom_point(data = (ratios %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Glencoe", "Riverdale", "Dolton", "Markham", "Chicago Heights", "Hazel Crest", "Phoenix"))), aes(y = GHE_0_bill_to_AV_25, x = GHE_0_bill_to_AV_75, color = "red"), size = 2)+
  theme_classic() + 
    scale_y_continuous(limits = c(0, .6), expand = c(0,0))+
  scale_x_continuous(limits = c(0, .6), expand = c(0,0))+
  theme(legend.position = "none") +
  labs(title = "GHE = $0 EAV", x = "", y = "")
ratios<- munis_billchange %>% 
  filter(has_HO_exemp == 1 &  !is.na(rank)) %>% # claimed exemption in 2021
  mutate(currbill_to_AV_25 = ifelse(rank == "q25", mean_bill_cur/median_AV, NA)) %>%
  mutate(currbill_to_AV_75 = ifelse(rank == "q75", mean_bill_cur/median_AV, NA)) %>% 
  group_by(clean_name) %>%
  summarize(currbill_to_AV_25 = max(currbill_to_AV_25, na.rm=TRUE),
            currbill_to_AV_75 = max(currbill_to_AV_75, na.rm=TRUE)) %>%
  mutate(muni_ratio_25to75 = currbill_to_AV_25/currbill_to_AV_75) %>% filter(muni_ratio_25to75 > 0.01)



ggplot(data = ratios, aes(y = currbill_to_AV_25, x = currbill_to_AV_75, label = clean_name)) + 
  geom_abline(intercept = 0, slope = 1) +
  geom_point(data = ratios, aes(alpha = .5)) + 
  ggrepel::geom_label_repel(data = (ratios %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Glencoe", "Riverdale", "Dolton", "Markham", "Chicago Heights", "Hazel Crest", "Phoenix"))), aes(y = currbill_to_AV_25, x = currbill_to_AV_75), size = 3, max.overlaps = Inf, point.padding = 0, # additional padding around each point
    min.segment.length = 0, # draw all line segments
    )+
    geom_point(data = (ratios %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Glencoe", "Riverdale", "Dolton", "Markham", "Chicago Heights", "Hazel Crest", "Phoenix"))), aes(y = currbill_to_AV_25, x = currbill_to_AV_75, color = "red"), size = 2)+

  theme_classic() +
    scale_y_continuous(limits = c(0, .6), expand = c(0,0))+
   scale_x_continuous(limits = c(0, .6), expand = c(0,0))+
  theme(legend.position = "none") +
  labs(title = "GHE = $10,000 EAV (Current)", x="", y="")
new_ratios<- munis_billchange %>% 
  filter(has_HO_exemp == 1 &  !is.na(rank)) %>% # claimed exemption in 2021
  mutate(newbill_to_AV_25 = ifelse(rank == "q25", mean_bill_plus10/median_AV, NA)) %>%
  mutate(newbill_to_AV_75 = ifelse(rank == "q75", mean_bill_plus10/median_AV, NA)) %>% 
  group_by(clean_name) %>%
  summarize(newbill_to_AV_25 = max(newbill_to_AV_25, na.rm=TRUE),
            newbill_to_AV_75 = max(newbill_to_AV_75, na.rm=TRUE)) %>%
  mutate(muni_ratio_25to75 = newbill_to_AV_25/newbill_to_AV_75) %>% 
  filter(muni_ratio_25to75 > 0.01)


ggplot(data = new_ratios, aes(y = newbill_to_AV_25, x = newbill_to_AV_75, label = clean_name)) + 
  geom_abline(intercept = 0, slope = 1) +
  geom_point(data = new_ratios, aes(alpha = .5)) + 
  ggrepel::geom_label_repel(data = (new_ratios %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Glencoe", "Riverdale", "Dolton", "Markham", "Chicago Heights", "Hazel Crest", "Phoenix"))), aes(y = newbill_to_AV_25, x = newbill_to_AV_75), size = 3, max.overlaps = Inf, point.padding = 0, # additional padding around each point
    min.segment.length = 0, # draw all line segments
    )+
    geom_point(data = (new_ratios %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Glencoe", "Riverdale", "Dolton", "Markham", "Chicago Heights", "Hazel Crest", "Phoenix"))), aes(y = newbill_to_AV_25, x = newbill_to_AV_75, color = "red"), size = 2)+
  theme_classic() + 
  scale_y_continuous(limits = c(0, .6), expand = c(0,0))+
  scale_x_continuous(limits = c(0, .6), expand = c(0,0))+
  theme(legend.position = "none") + 
  labs(title = "GHE = $20,000 EAV", x= "", y="")
new_ratios<- munis_billchange %>% 
  filter(has_HO_exemp == 1 &  !is.na(rank)) %>% # claimed exemption in 2021
  mutate(newbill_to_AV_25 = ifelse(rank == "q25", mean_bill_plus20/median_AV, NA)) %>%
  mutate(newbill_to_AV_75 = ifelse(rank == "q75", mean_bill_plus20/median_AV, NA)) %>% 
  group_by(clean_name) %>%
  summarize(newbill_to_AV_25 = max(newbill_to_AV_25, na.rm=TRUE),
            newbill_to_AV_75 = max(newbill_to_AV_75, na.rm=TRUE)) %>%
  mutate(muni_ratio_25to75 = newbill_to_AV_25/newbill_to_AV_75) %>% 
  
  filter(muni_ratio_25to75 > 0.01)


ggplot(data = new_ratios, aes(y = newbill_to_AV_25, x = newbill_to_AV_75, label = clean_name)) + 
  geom_abline(intercept = 0, slope = 1) +
  geom_point(aes(alpha = .5)) + 
  ggrepel::geom_label_repel(data = (new_ratios %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Glencoe", "Riverdale", "Dolton", "Markham", "Chicago Heights", "Hazel Crest", "Phoenix"))), aes(y = newbill_to_AV_25, x = newbill_to_AV_75), size = 3, max.overlaps = Inf, point.padding = 0, # additional padding around each point
    min.segment.length = 0, # draw all line segments
    )+
    geom_point(data = (new_ratios %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Glencoe", "Riverdale", "Dolton", "Markham", "Chicago Heights", "Hazel Crest", "Phoenix"))), aes(y = newbill_to_AV_25, x = newbill_to_AV_75, color = "red"), size = 2)+
  theme_classic() + 
    scale_y_continuous(limits = c(0, .6), expand = c(0,0))+

  scale_x_continuous(limits = c(0, .6), expand = c(0,0))+
  theme(legend.position = "none") + 
  labs(title = "GHE = $30,000 EAV",
                                        x = "", y = "")
new_ratios<- munis_billchange %>% 
  filter(has_HO_exemp == 1 &  !is.na(rank)) %>% # claimed exemption in 2021
  mutate(newbill_to_AV_25 = ifelse(rank == "q25", mean_bill_plus30/median_AV, NA)) %>%
  mutate(newbill_to_AV_75 = ifelse(rank == "q75", mean_bill_plus30/median_AV, NA)) %>% 
  group_by(clean_name) %>%
  summarize(newbill_to_AV_25 = max(newbill_to_AV_25, na.rm=TRUE),
            newbill_to_AV_75 = max(newbill_to_AV_75, na.rm=TRUE)) %>%
  mutate(muni_ratio_25to75 = newbill_to_AV_25/newbill_to_AV_75) %>% 
  filter(muni_ratio_25to75 > 0.01)


ggplot(data = new_ratios, aes(y = newbill_to_AV_25, x = newbill_to_AV_75, label = clean_name)) + 
  geom_abline(intercept = 0, slope = 1) +
  geom_point(aes(alpha = .5)) + 
  ggrepel::geom_label_repel(data = (new_ratios %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Glencoe", "Riverdale", "Dolton", "Markham", "Chicago Heights", "Hazel Crest", "Phoenix"))), aes(y = newbill_to_AV_25, x = newbill_to_AV_75), size = 3, max.overlaps = Inf, point.padding = 0, # additional padding around each point
    min.segment.length = 0, # draw all line segments
    )+
    geom_point(data = (new_ratios %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Glencoe", "Riverdale", "Dolton", "Markham", "Chicago Heights", "Hazel Crest", "Phoenix"))), aes(y = newbill_to_AV_25, x = newbill_to_AV_75, color = "red"), size = 2)+
  theme_classic() + 
    scale_y_continuous(limits = c(0, .6), expand = c(0,0))+

  scale_x_continuous(limits = c(0, .6), expand = c(0,0))+
  theme(legend.position = "none") + 
  labs(title = "GHE = $40,000 EAV", 
                                     x= "", y = "")
new_ratios<- munis_billchange %>% 
  filter(has_HO_exemp == 1 &  !is.na(rank)) %>% # claimed exemption in 2021
  mutate(newbill_to_AV_25 = ifelse(rank == "q25", mean_bill_plus40/median_AV, NA)) %>%
  mutate(newbill_to_AV_75 = ifelse(rank == "q75", mean_bill_plus40/median_AV, NA)) %>% 
  group_by(clean_name) %>%
  summarize(newbill_to_AV_25 = max(newbill_to_AV_25, na.rm=TRUE),
            newbill_to_AV_75 = max(newbill_to_AV_75, na.rm=TRUE)) %>%
  mutate(muni_ratio_25to75 = newbill_to_AV_25/newbill_to_AV_75) %>% filter(muni_ratio_25to75 > 0.01)


ggplot(data = new_ratios, aes(y = newbill_to_AV_25, x = newbill_to_AV_75, label = clean_name)) + 
  geom_abline(intercept = 0, slope = 1) +
  geom_point(aes(alpha = .5)) + 
  ggrepel::geom_label_repel(data = (new_ratios %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Glencoe", "Riverdale", "Dolton", "Markham", "Chicago Heights", "Hazel Crest", "Phoenix"))), aes(y = newbill_to_AV_25, x = newbill_to_AV_75), size = 3, max.overlaps = Inf, point.padding = 0, # additional padding around each point
    min.segment.length = 0, # draw all line segments
    )+
    geom_point(data = (new_ratios %>% filter(clean_name %in% c("Park Forest", "Chicago", "Winnetka", "Glencoe", "Riverdale", "Dolton", "Markham", "Chicago Heights", "Hazel Crest", "Phoenix"))), aes(y = newbill_to_AV_25, x = newbill_to_AV_75, color = "red"), size = 2)+
  theme_classic() + 
    scale_y_continuous(limits = c(0, .6), expand = c(0,0))+

  scale_x_continuous(limits = c(0, .6), expand = c(0,0))+
  theme(legend.position = "none") + labs(title = "GHE = $50,000", 
                                      x = "", y = "")

Table 3. Revenue Neutral Tax Rates

Revenue Neutral Tax Rates (Percent) for Select Municipalities as a Function of GHE Value

Code
scenario_taxrates %>% 
  filter(clean_name %in% c("Chicago", "Dolton", "Glencoe")) |>
  select(Municipality = clean_name,
         "0 GHE" = tr_neg10,
         "10,000 GHE (Current)" = tr_nochange,
         "20,000 GHE" = tr_plus10,
         "30,000 GHE" = tr_plus20,
         "40,000 GHE" = tr_plus30,
         "50,000 GHE" = tr_plus40)

Figure 5. GHE Value and Revenue Neutral Tax Rates

Relationship of GHE Value and Revenue Neutral Tax Rates in Chicago, Dolton, and Glencoe

Code
scenario_taxrates %>% 
  filter(clean_name %in% c("Chicago", "Dolton", "Glencoe")) |>
  select(Municipality = clean_name,
         "0 GHE" = tr_neg10,
         "10,000 GHE\n(Current)" = tr_nochange,
         "20,000 GHE" = tr_plus10,
         "30,000 GHE" = tr_plus20,
         "40,000 GHE" = tr_plus30,
         "50,000 GHE" = tr_plus40) %>%
  pivot_longer(cols = c(`0 GHE`:`50,000 GHE`), names_to = "Scenarios") %>%

  ggplot() +
  geom_line(aes(x=Scenarios, y = value, group = Municipality, color = Municipality)) + theme_classic() + 
  labs(title = "Tax Rates for GHE Scenarios", x = "GHE Amount", y = "Tax Rate") +
  scale_y_continuous(label = scales::percent)

Paying for the inequitable effects of exemptions

Figure 6. Exemptions by Value in Class 8 Municipalities

Code
pin_data %>% 
  group_by(clean_name) %>% 
  summarize(
    Homeowner = sum(exe_homeowner),
    Senior = sum(exe_senior),
    `Senior Freeze` = sum(exe_freeze),
   # exe_longtime_homeowner = sum(exe_longtime_homeowner),
    Other = sum(exe_disabled),
    
  ) %>%
  filter(clean_name %in% c("Blue Island", "Markham", "Matteson", "Park Forest")) %>%
  pivot_longer(cols = c(Homeowner:Other), names_to = "Exemption Type") %>%
  ggplot() + 
  geom_col(aes(x=clean_name, y = value, fill = `Exemption Type`), position = "dodge") +
  scale_y_continuous(label = scales::dollar) + 
  theme_classic() + 
  labs(x = element_blank(), y = "Exempt EAV")
Figure 5.3: Exempt EAV by Type in Select Municipalities. Shows values as calculated in original report that undercounted disabled veteran exemptions.
Code
pin_data %>% 
  group_by(clean_name) %>% 
  summarize(
    Homeowner = sum(exe_homeowner),
    Senior = sum(exe_senior),
    `Senior Freeze` = sum(exe_freeze),
   # exe_longtime_homeowner = sum(exe_longtime_homeowner),
    Other = sum(exe_missing_disvet+exe_disabled),
    
  ) %>%
  filter(clean_name %in% c("Blue Island", "Markham", "Matteson", "Park Forest")) %>%
  pivot_longer(cols = c(Homeowner:Other), names_to = "Exemption Type") %>%
  ggplot() + 
  geom_col(aes(x=clean_name, y = value, fill = `Exemption Type`), position = "dodge") +
  scale_y_continuous(label = scales::dollar) + 
  theme_classic() + 
  labs(x = element_blank(), y = "Exempt EAV")
Figure 5.4: Exempt EAV by Type in Select Municipalities. Shows adjusted values of exemption amounts after finding data error.
Code
pin_data %>% 
  group_by(clean_name) %>% 
  summarize(
    Homeowner = sum(exe_homeowner),
    Senior = sum(exe_senior),
    `Senior Freeze` = sum(exe_freeze),
   # exe_longtime_homeowner = sum(exe_longtime_homeowner),
    Other = sum(exe_missing_disvet+exe_disabled),
    
  ) %>%
  arrange(desc(Other))

Figure 7. Exemption Share in Class 8 Municipalities

Code
pin_data %>% 
  group_by(clean_name) %>% 
  summarize(
    all_exemptions = sum(all_exemptions),
    eq_av = sum(av_clerk*eq_factor),
    tif_increment_eav = sum(final_tax_to_tif/tax_code_rate)
  ) %>%
  mutate(taxable_eav = eq_av - tif_increment_eav - all_exemptions) |>
  filter(clean_name %in% c("Blue Island", "Markham", "Matteson", "Park Forest")) %>%
  pivot_longer(cols = c(all_exemptions:taxable_eav), names_to = "exe_type") %>%
  filter(exe_type %in% c("taxable_eav", "all_exemptions"))|>
  ggplot() + 
  geom_col(aes(x=clean_name, y = value, fill = exe_type), position = "stack") +
  scale_y_continuous(label = scales::dollar, limits = c(0,600000000)) + 
  theme_classic() + 
  labs(x = element_blank(), y = "Non-TIF Increment EAV in Municipality")