Skip to contents

Introduction

Appeals are a way to contest the assessed value (AV) of a property after it is determined by the Assessor’s Office. If an appeal is granted, the property value is lowered and the owner pays taxes on the reduced value. Cook County has three opportunities to appeal, which happen in order:

  1. Assessor’s Office
  2. Board of Review
  3. Property Tax Appeal Board (PTAB) or Circuit Court

Due to the nature of Cook County’s property tax system, appeals are a zero-sum affair: each successful appeal with the Assessor’s Office or Board of Review will increase the property tax bill of other property owners. However, measuring this effect is difficult since it requires recalculating tax bills pre- and post-appeal for many properties.

Enter PTAXSIM, which can simulate counterfactual estimated tax bills and contains data on assessed values at each “stage” of appeals. The stages are stored in separate columns of the pin table in the PTAXSIM database:

  1. av_mailed - Initial assessed values when mailed to property owners, after Desk Review
  2. av_certified - Assessed values after Assessor’s Office appeals
  3. av_board - Assessed values after Board of Review appeals
  4. av_clerk - Final assessed values used by the Clerk and Treasurer to calculate bills. Identical to av_board in 99.9% of cases

We can use these values and PTAXSIM’s tax_bill() function to calculate pre- and post-appeal estimated property tax bills and ultimately determine the impact of appeals. This vignette will cover that process.

Appeals in Schaumburg

Using PTAXSIM, we’re going to examine property values and tax bills in Schaumburg, IL, a village in northwestern Cook County. Specifically, we’ll focus on the impact of appeals after Schaumburg’s 2019 reassessment.

First, load some useful libraries and instantiate a PTAXSIM database connection with the default name (ptaxsim_db_conn) expected by PTAXSIM functions.

Gathering PINs of interest

To determine the impact of appeals, we first need a way to gather all the properties (PINs) in Schaumburg. Fortunately, PTAXSIM’s database has all the data required to accomplish this task.

First, we’ll need to determine the Village of Schaumburg’s agency number. This is the ID used by the Clerk to track different taxing bodies. To find Schaumburg’s ID, we can directly query PTAXSIM’s database and look in the agency_info table:

sb_agency_nums <- DBI::dbGetQuery(
  ptaxsim_db_conn,
  "SELECT agency_num, agency_name
  FROM agency_info
  WHERE agency_name LIKE '%SCHAUMBURG%'"
)

sb_agency_nums
#>    agency_num                                                       agency_name
#> 1   020260000                                                   TOWN SCHAUMBURG
#> 2   020260002                                     GENERAL ASSISTANCE SCHAUMBURG
#> 3   020260003                                        ROAD AND BRIDGE SCHAUMBURG
#> 4   020260004                                 SCHAUMBURG MENTAL HEALTH DISTRICT
#> 5   020260006                       SCHAUMBURG TOWNSHIP SPECIAL POLICE DISTRICT
#> 6   031150000                                             VILLAGE OF SCHAUMBURG
#> 7   031150010  VILLAGE SCHAUMBURG SPECIAL SERVICE AREA 5 / 2003 DEFICIENCY LEVY
#> 8   031150011 VILLAGE SCHAUMBURG SPECIAL SERVICE AREA 11 / 2003 DEFICIENCY LEVY
#> 9   031150102                      VILLAGE OF SCHAUMBURG SPECIAL SERVICE AREA 5
#> 10  031150103                      VILLAGE OF SCHAUMBURG SPECIAL SERVICE AREA 6
#> 11  031150104                      VILLAGE OF SCHAUMBURG SPECIAL SERVICE AREA 7
#> 12  031150105                      VILLAGE OF SCHAUMBURG SPECIAL SERVICE AREA 8
#> 13  031150106                      VILLAGE OF SCHAUMBURG SPECIAL SERVICE AREA 9
#> 14  031150107                     VILLAGE OF SCHAUMBURG SPECIAL SERVICE AREA 10
#> 15  031150108                     VILLAGE OF SCHAUMBURG SPECIAL SERVICE AREA 11
#> 16  031150109                     VILLAGE OF SCHAUMBURG SPECIAL SERVICE AREA 12
#> 17  031150110                     VILLAGE OF SCHAUMBURG SPECIAL SERVICE AREA 13
#> 18  031150112   VILLAGE OF SCHAUMBURG SPECIAL SERVICE AREA EXPERIOR TRANSPORT 1
#> 19  031150500                              TIF - SCHAUMBURG - OLDE SCHAUMBURG 1
#> 20  031150501                                TIF - SCHAUMBURG - STAR LINE / TOD
#> 21  031150502                               TIF - SCHAUMBURG - NORTH SCHAUMBURG
#> 22  031150503                                       TIF - SCHAUMBURG - EXPERIOR
#> 23  050960000                                          SCHAUMBURG PARK DISTRICT
#> 24  060480000                       SCHAUMBURG TOWNSHIP DISTRICT PUBLIC LIBRARY

Here we can see the various taxing bodies around Schaumburg. The agency number we want is 031150000 for the VILLAGE OF SCHAUMBURG. With the agency number, we can find all of the tax codes that make up the municipality. To do so, we can again query PTAXSIM directly, this time looking in the tax_code table:

sb_tax_codes <- DBI::dbGetQuery(
  ptaxsim_db_conn, "
  SELECT tax_code_num
  FROM tax_code
  WHERE agency_num = '031150000'
  AND year = 2019
  "
)

Finally, we can find all of Schaumburg’s PINs with one last direct query. This time we’ll look in the pin table using the tax codes that make up Schaumburg. We’ll use the glue library for string expansion to make things a bit easier:

sb_pins <- DBI::dbGetQuery(
  ptaxsim_db_conn,
  glue_sql("
    SELECT pin, class
    FROM pin
    WHERE tax_code_num IN ({sb_tax_codes$tax_code_num*})
    AND year = 2019
    ",
    .con = ptaxsim_db_conn
  )
)

Mapping the area

Now that we have Schaumburg’s PINs, we can use them to get a quick look at the area using a map. First, we’ll get the PIN boundaries. These are included in PTAXSIM’s database and can be retrieved using lookup_pin10_geometry().

# PIN geometries only exist for 10-digit PINs, so we'll need to truncate our
# existing Schaumburg PINs
sb_pin10s <- unique(substr(sb_pins$pin, 1, 10))

# The geometry is stored in the database as simple text, so we need to convert
# it to the correct type using sf
sb_pins_geo <- lookup_pin10_geometry(year = 2020, pin10 = sb_pin10s)
sb_pins_geo <- sb_pins_geo %>%
  st_as_sf(wkt = "geometry", crs = 4326)

# We'll also attach the first class code from each PIN14 to each PIN10 geometry
# and group them into broad buckets. This is just to help our visualization
sb_pins_geo <- sb_pins_geo %>%
  left_join(
    sb_pins %>%
      mutate(pin10 = substr(pin, 1, 10)) %>%
      group_by(pin10) %>%
      summarize(class = first(class)),
    by = "pin10"
  ) %>%
  mutate(
    major_class = substr(class, 1, 1),
    major_class_fct = recode_factor(
      major_class,
      "2" = "2 - Residential",
      "3" = "3 & 5 - Commercial",
      "5" = "3 & 5 - Commercial",
      "0" = "Other", "1" = "Other", "4" = "Other", "6" = "Other", "7" = "Other"
    )
  )

For added flavor, we can get the municipality boundary for Schaumburg from Cook Central, the County GIS data portal.

muni <- st_read(paste0(
  "https://opendata.arcgis.com/api/v3/datasets/",
  "534226c6b1034985aca1e14a2eb234af_2/downloads/",
  "data?format=geojson&spatialRefId=4326&where=1%3D1"
))

muni <- muni %>%
  filter(MUNICIPALITY == "Schaumburg")

Finally, we can plot our PINs of interest on a map, along with Schaumburg’s boundary and a basemap.

Click here to show plot code
sb_pins_map <- ggplot() +
  annotation_map_tile(type = "cartolight", zoomin = -1) +
  geom_sf(
    data = sb_pins_geo,
    aes(fill = major_class_fct),
    alpha = 0.5,
    linewidth = 0.1
  ) +
  geom_sf(data = muni, fill = "transparent", linewidth = 1) +
  annotation_scale(location = "br") +
  scale_fill_brewer(name = "", palette = "Set1") +
  guides(color = guide_legend(override.aes = list(size = 5))) +
  theme_void() +
  theme(
    legend.title = element_text(size = 14),
    legend.text = element_text(size = 12),
    legend.key.size = unit(24, "points"),
    legend.position = "bottom"
  )


The map clearly shows that most commercial buildings cluster on the north side of the village. This is the location of Woodfield Mall and many adjacent commercial buildings.

Appeals’ impact on AVs

Now that we’ve gathered our PINs of interest, we can start to investigate how they were affected by appeals after the 2019 reassessment. Let’s start by looking at changes in assessed values. We can gather the AVs from each assessment stage using PTAXSIM’s lookup_pin() function.

# Iterate through the difference stages, then combine into a single data.frame
sb_pins_all <- purrr::map_dfr(c("mailed", "certified", "board"), function(x) {
  lookup_pin(2019, sb_pins$pin, stage = x) %>%
    mutate(stage = x)
})

# Append 2018 final values to use as a starting point for an index
sb_pins_all <- lookup_pin(2018, sb_pins$pin, stage = "board") %>%
  mutate(stage = "board") %>%
  bind_rows(sb_pins_all)

Next, we get the median AV for each assessment stage and major class. We also create an index to make the relative changes in AV easier to see. The indexed stage is 2018 board, meaning a change of 20 means a 20% increase in the median AV from the 2018 board median AV.

sb_pins_summ <- sb_pins_all %>%
  mutate(major_class = substr(class, 1, 1)) %>%
  filter(major_class %in% c("2", "3", "5")) %>%
  mutate(
    major_class = recode_factor(
      major_class,
      "2" = "2 - Residential",
      "3" = "3 & 5 - Commercial",
      "5" = "3 & 5 - Commercial"
    )
  ) %>%
  group_by(year, stage, major_class) %>%
  summarize(med_av = median(av), count = n()) %>%
  ungroup() %>%
  mutate(
    stage = factor(
      paste0(year, "\n", stage),
      levels = c(
        "2018\nboard", "2019\nmailed",
        "2019\ncertified", "2019\nboard"
      )
    ),
    idx = (med_av / med_av[stage == "2018\nboard"]) * 100
  )

Finally, we can plot the index over time to see how reassessment and the subsequent appeals at each stage impacted assessed values.

Click here to show plot code
# Create conditional x-axis colors for reassessment stage
stage_labs <- ifelse(
  as.factor(c(
    "2018\nboard", "2019\nmailed",
    "2019\ncertified", "2019\nboard"
  )) == "2019\nmailed",
  "red", "black"
)

sb_pins_av_plot <- ggplot() +
  geom_vline(
    xintercept = "2019\nmailed",
    linetype = "dotted",
    color = "red"
  ) +
  geom_line(
    data = sb_pins_summ,
    aes(x = stage, y = idx, color = major_class, group = major_class),
    linewidth = 1.1
  ) +
  scale_color_brewer(name = "", palette = "Set1") +
  lims(y = c(95, 170)) +
  labs(
    x = "Stage",
    y = "Median AV, Indexed to 2018 Board",
    caption = "Highlighted stage is a reassessment"
  ) +
  theme_minimal() +
  theme(
    axis.title = element_text(size = 13),
    axis.title.x = element_text(margin = margin(t = 6)),
    axis.title.y = element_text(margin = margin(r = 6), size = 12),
    axis.text = element_text(size = 11),
    axis.text.x = element_text(color = stage_labs),
    strip.text = element_text(size = 16),
    strip.background = element_rect(fill = "#c9c9c9"),
    legend.title = element_text(size = 14),
    legend.key.size = unit(24, "points"),
    legend.text = element_text(size = 12),
    legend.position = "bottom"
  )


There’s a significant drop in the median commercial property AV due to appeals, particularly after the second level of review (at the Board of Review). However, commercial properties also received much larger AV increases during the 2019 reassessment compared to residential properties.

Appeals’ impact on bills

Now that we’ve looked at AVs, we can also use PTAXSIM to examine the impact of appeals on tax bills. To do so, we’ll first calculate bills at each stage of assessment. The bills for the mailed and certified stages are counterfactual; they represent estimates of what bills would have been at each stage if no further appeals were granted. The board stage bills are the actual bills received by property owners.

# Grab equalization factors from the PTAXSIM database
eq_dt <- DBI::dbGetQuery(ptaxsim_db_conn, "SELECT * FROM eq_factor")

# Calculate tax bills from 2019 at each stage of appeal. Note that recalculating
# bills with counterfactual AVs also requires recalculating the base of each
# taxing district
sb_bills_all <- purrr::map_dfr(c("mailed", "certified", "board"), function(x) {
  sb_pin_dt_actual <- lookup_pin(2019, sb_pins$pin, stage = "clerk")
  sb_pin_dt_stage <- lookup_pin(
    2019,
    sb_pins$pin,
    stage = x,
    eq_version = case_when(
      x %in% c("mailed", "certified") ~ "tentative",
      x %in% c("board", "clerk") ~ "final"
    )
  )

  # Use the tentative equalizer for mailed/certified values and the final
  # equalizer for board/clerk values
  eq_dt_stage <- eq_dt %>%
    mutate(stage_eq_factor = case_when(
      x %in% c("mailed", "certified") ~ eq_factor_tentative,
      x %in% c("board", "clerk") ~ eq_factor_final
    )) %>%
    select(year, stage_eq_factor, eq_factor_final)

  # The base of each district in the included PTAXSIM data is based on 2019
  # board (post-appeal) values. It is therefore smaller than it would be if
  # appeals had not been granted. As such, we need to calculate the difference
  # for each PIN between the AV at each stage and the AV at the board stage,
  # then add the total difference to each district's base
  sb_pin_diff <- sb_pin_dt_actual %>%
    left_join(
      sb_pin_dt_stage %>%
        select(year, pin, stage_av = av),
      by = c("year", "pin")
    ) %>%
    left_join(eq_dt_stage, by = "year") %>%
    mutate(
      eav_diff = (stage_av - av) * stage_eq_factor,
      exe_total = rowSums(across(starts_with("exe_"))),
      exe_diff = (exe_total * stage_eq_factor) - (exe_total * eq_factor_final),
      tax_code = lookup_tax_code(2019, pin)
    ) %>%
    group_by(year, tax_code) %>%
    summarize(
      eav_diff_total = sum(eav_diff),
      exe_diff_total = sum(exe_diff)
    )

  # Update each district base using the amount recovered from "undoing" appeals
  # Note that the base must be converted to use the "correct" equalizer for each
  # stage. Additionally, because exemptions change with the equalizer, we must
  # also calculate their difference and add them to the base
  sb_agency_dt <- lookup_agency(sb_pin_diff$year, sb_pin_diff$tax_code) %>%
    left_join(sb_pin_diff, by = c("year", "tax_code")) %>%
    left_join(eq_dt_stage, by = "year") %>%
    mutate(
      # Recalculate the base in terms of the "tentative" or "final" equalizer
      agency_total_eav = round(
        (agency_total_eav / eq_factor_final) * stage_eq_factor
      ),
      agency_total_eav = agency_total_eav + exe_diff_total,
      agency_total_eav = agency_total_eav + eav_diff_total
    ) %>%
    select(
      -eav_diff_total, -exe_diff_total,
      -eq_factor_final, -stage_eq_factor
    ) %>%
    setkey(year, tax_code, agency_num)

  tax_bill(
    year = 2019,
    pin = sb_pin_dt_stage$pin,
    pin_dt = sb_pin_dt_stage,
    agency_dt = sb_agency_dt
  ) %>%
    mutate(stage = x)
})

# Append unaltered 2018 bills, again as a starting point for our index
sb_bills_all <- tax_bill(2018, sb_pins$pin) %>%
  mutate(stage = "board") %>%
  bind_rows(sb_bills_all)

We now have two sets of bills: the real bills from each board stage, and the counterfactual bills from the mailed and certified stages. Each bill from tax_bill() is broken out into per-district line items. In order to visualize the changes in bills, we first collapse the bills into their totals, then aggregate and index them in the same way we did for AVs.

sb_bills_summ <- sb_bills_all %>%
  group_by(year, pin, class, stage) %>%
  summarize(bill_total = sum(final_tax)) %>%
  ungroup() %>%
  mutate(major_class = substr(class, 1, 1)) %>%
  filter(major_class %in% c("2", "3", "5")) %>%
  mutate(
    major_class = recode_factor(
      major_class,
      "2" = "2 - Residential",
      "3" = "3 & 5 - Commercial",
      "5" = "3 & 5 - Commercial"
    )
  ) %>%
  group_by(year, stage, major_class) %>%
  summarize(med_bill = median(bill_total), count = n()) %>%
  ungroup() %>%
  mutate(
    stage = factor(
      paste0(year, "\n", stage),
      levels = c(
        "2018\nboard", "2019\nmailed",
        "2019\ncertified", "2019\nboard"
      )
    ),
    idx = (med_bill / med_bill[stage == "2018\nboard"]) * 100
  )

We can then plot the change in the median total tax bill (indexed to the 2018 board median bill) to see the effect of each stage of appeals. The earlier plot showing assessed values is added for reference.

Click here to show plot code
sb_bills_plot <- bind_rows(
  sb_pins_summ %>% mutate(type = "Assessed Values"),
  sb_bills_summ %>% mutate(type = "Tax Bills")
) %>%
  ggplot() +
  geom_vline(
    xintercept = "2019\nmailed",
    linetype = "dotted",
    color = "red"
  ) +
  geom_line(
    aes(x = stage, y = idx, color = major_class, group = major_class),
    linewidth = 1.1
  ) +
  scale_color_brewer(name = "", palette = "Set1") +
  lims(y = c(95, 170)) +
  labs(
    x = "Stage",
    y = "Median Value, Indexed to 2018 Board",
    caption = "Highlighted stage is a reassessment"
  ) +
  facet_wrap(vars(type)) +
  theme_minimal() +
  theme(
    axis.title = element_text(size = 13),
    axis.title.x = element_text(margin = margin(t = 6)),
    axis.title.y = element_text(margin = margin(r = 6), size = 12),
    axis.text = element_text(size = 11),
    axis.text.x = element_text(color = stage_labs),
    strip.text = element_text(size = 16),
    strip.background = element_rect(fill = "#c9c9c9"),
    legend.title = element_text(size = 14),
    legend.key.size = unit(24, "points"),
    legend.text = element_text(size = 12),
    legend.position = "bottom"
  )
#> Warning: Vectorized input to `element_text()` is not officially supported.
#>  Results may be unexpected or may change in future versions of ggplot2.


Overall, post-reassessment appeals in Schaumburg resulted in a roughly 7% increase in the median residential tax bill and a +20% decrease in the median commercial tax bill. Without appeals, particularly at the Board of Review, the median commercial bill would have increased roughly 30% from 2018.

Counterfactual scenario

The plot above shows the zero-sum nature of assessment appeals. The large AV decreases for commercial properties in Schaumburg raised the median tax bill for residential property owners. We can test this by holding the assessed value of all commercial properties constant (as if no appeals were granted) and then recalculating tax bills.

# Again we need to calculate bills at each stage of appeal. This time it's more
# complicated since we need to exclude commercial appeals
sb_cntr_all <- purrr::map_dfr(c("mailed", "certified", "board"), function(x) {
  sb_pin_dt_actual <- lookup_pin(2019, sb_pins$pin, stage = "clerk")
  sb_pin_dt_mailed <- lookup_pin(
    2019,
    sb_pins$pin,
    stage = "mailed",
    eq_version = "tentative"
  )
  sb_pin_dt_stage <- lookup_pin(
    2019,
    sb_pins$pin,
    stage = x,
    eq_version = case_when(
      x %in% c("mailed", "certified") ~ "tentative",
      x %in% c("board", "clerk") ~ "final"
    )
  )

  # Again use tentative equalizer for any non-final (board) values
  eq_dt_stage <- eq_dt %>%
    mutate(stage_eq_factor = case_when(
      x %in% c("mailed", "certified") ~ eq_factor_tentative,
      x %in% c("board", "clerk") ~ eq_factor_final
    )) %>%
    select(year, stage_eq_factor, eq_factor_final)

  # To recalculate the base, we get two distinct sets of differences:
  # 1. Residential PINs act normally, the amount back to the base from each
  #    PIN is the difference between the stage EAV and the 2019 final EAV
  # 2. Commercial PINs are not granted appeals. So the amount back to the base
  #    for all stages is the difference between the initial mailed value (no
  #    appeals) and the final 2019 value (which contains appeals)
  sb_pin_diff <- rbind(
    sb_pin_dt_actual %>%
      filter(!substr(class, 1, 1) %in% c("3", "5")) %>%
      left_join(
        sb_pin_dt_stage %>%
          select(year, pin, stage_av = av),
        by = c("year", "pin")
      ) %>%
      left_join(eq_dt_stage, by = "year") %>%
      mutate(
        eav_diff = (stage_av - av) * stage_eq_factor,
        exe_total = rowSums(across(starts_with("exe_"))),
        exe_diff = (exe_total * stage_eq_factor) -
          (exe_total * eq_factor_final),
        tax_code = lookup_tax_code(2019, pin)
      ),
    sb_pin_dt_actual %>%
      filter(substr(class, 1, 1) %in% c("3", "5")) %>%
      left_join(
        sb_pin_dt_mailed %>%
          select(year, pin, stage_av = av),
        by = c("year", "pin")
      ) %>%
      left_join(eq_dt_stage, by = "year") %>%
      mutate(
        eav_diff = (stage_av - av) * stage_eq_factor,
        tax_code = lookup_tax_code(2019, pin)
      ),
    fill = TRUE
  ) %>%
    group_by(year, tax_code) %>%
    summarize(
      eav_diff_total = sum(eav_diff),
      exe_diff_total = sum(exe_diff, na.rm = TRUE)
    )

  # Aggregate the PIN EAV and exemption differences into a new agency
  # data table with an adjusted base
  sb_agency_dt <- lookup_agency(sb_pin_diff$year, sb_pin_diff$tax_code) %>%
    left_join(sb_pin_diff, by = c("year", "tax_code")) %>%
    left_join(eq_dt_stage, by = "year") %>%
    mutate(
      agency_total_eav = round(
        (agency_total_eav / eq_factor_final) * stage_eq_factor
      ),
      agency_total_eav = agency_total_eav + exe_diff_total,
      agency_total_eav = agency_total_eav + eav_diff_total
    ) %>%
    select(
      -eav_diff_total, -exe_diff_total,
      -eq_factor_final, -stage_eq_factor
    ) %>%
    setkey(year, tax_code, agency_num)

  # Commercial PINs don't get appeals, so their EAVs only come from the
  # "mailed" stage. However, commercial EAVs must use the equalizer for their
  # respective stage, so we adjust the "mailed" EAVs to the final equalizer
  # (only for the final/board stage)
  if (x == "board") {
    sb_pin_dt_mailed <- sb_pin_dt_mailed %>%
      left_join(eq_dt, by = "year") %>%
      mutate(
        eav = round((eav / eq_factor_tentative) * eq_factor_final)
      ) %>%
      select(-starts_with("eq_factor_"))
  }

  # Combine residential and adjusted into a single PIN input data table
  sb_pin_dt_combo <- rbind(
    sb_pin_dt_stage %>%
      filter(!substr(class, 1, 1) %in% c("3", "5")),
    sb_pin_dt_mailed %>%
      filter(substr(class, 1, 1) %in% c("3", "5"))
  ) %>%
    setkey(year, pin)

  tax_bill(
    year = 2019,
    pin = sb_pin_dt_combo$pin,
    pin_dt = sb_pin_dt_combo,
    agency_dt = sb_agency_dt
  ) %>%
    mutate(stage = x)
})

sb_cntr_all <- tax_bill(2018, sb_pins$pin) %>%
  mutate(stage = "board") %>%
  bind_rows(sb_cntr_all)

We again have two sets of bills: real bills from the mailed stage, and counterfactual bills from the 2019 certified and board stages, as if no commercial appeals were granted. We can aggregate and index these bills the same way we did previously.

sb_cntr_summ <- sb_cntr_all %>%
  group_by(year, pin, class, stage) %>%
  summarize(bill_total = sum(final_tax)) %>%
  ungroup() %>%
  mutate(major_class = substr(class, 1, 1)) %>%
  filter(major_class %in% c("2", "3", "5")) %>%
  mutate(
    major_class = recode_factor(
      major_class,
      "2" = "2 - Residential",
      "3" = "3 & 5 - Commercial",
      "5" = "3 & 5 - Commercial"
    )
  ) %>%
  group_by(year, stage, major_class) %>%
  summarize(med_bill = median(bill_total), count = n()) %>%
  ungroup() %>%
  mutate(
    stage = factor(
      paste0(year, "\n", stage),
      levels = c(
        "2018\nboard", "2019\nmailed",
        "2019\ncertified", "2019\nboard"
      )
    ),
    idx = (med_bill / med_bill[stage == "2018\nboard"]) * 100
  )

Finally, we again plot the change in the median bill for each stage. The previous plot is shown again (top row) for reference. The earlier AV plot (bottom left) is also recalculated to exclude commercial appeals.

Click here to show plot code
sb_pins_no_app <- purrr::map_dfr(
  c("mailed", "certified", "board"),
  function(x) {
    comm_pins <- sb_pins %>%
      filter(substr(class, 1, 1) %in% c("3", "5")) %>%
      pull(pin)
    other_pins <- sb_pins %>%
      filter(!substr(class, 1, 1) %in% c("3", "5")) %>%
      pull(pin)

    rbind(
      lookup_pin(2019, comm_pins, stage = "mailed") %>%
        mutate(stage = x),
      lookup_pin(2019, other_pins, stage = x) %>%
        mutate(stage = x)
    )
  }
)

sb_pins_no_app <- lookup_pin(2018, sb_pins$pin, stage = "board") %>%
  mutate(stage = "board") %>%
  bind_rows(sb_pins_no_app)

sb_pins_summ_no_app <- sb_pins_no_app %>%
  mutate(major_class = substr(class, 1, 1)) %>%
  filter(major_class %in% c("2", "3", "5")) %>%
  mutate(
    major_class = recode_factor(
      major_class,
      "2" = "2 - Residential",
      "3" = "3 & 5 - Commercial",
      "5" = "3 & 5 - Commercial"
    )
  ) %>%
  group_by(year, stage, major_class) %>%
  summarize(med_av = median(av), count = n()) %>%
  ungroup() %>%
  mutate(
    stage = factor(
      paste0(year, "\n", stage),
      levels = c(
        "2018\nboard", "2019\nmailed",
        "2019\ncertified", "2019\nboard"
      )
    ),
    idx = (med_av / med_av[stage == "2018\nboard"]) * 100
  )

sb_cntr_plot <- bind_rows(
  sb_pins_summ %>% mutate(type = "Assessed Values"),
  sb_bills_summ %>% mutate(type = "Tax Bills"),
  sb_pins_summ_no_app %>%
    mutate(type = "Assessed Values\n(No Commercial Appeals)"),
  sb_cntr_summ %>%
    mutate(type = "Tax Bills\n(No Commercial Appeals)")
) %>%
  ggplot() +
  geom_vline(
    xintercept = "2019\nmailed",
    linetype = "dotted",
    color = "red"
  ) +
  geom_line(
    aes(x = stage, y = idx, color = major_class, group = major_class),
    linewidth = 1.1
  ) +
  scale_color_brewer(name = "", palette = "Set1") +
  facet_wrap(vars(type), nrow = 2, ncol = 2, dir = "v") +
  lims(y = c(95, 170)) +
  labs(
    x = "Stage",
    y = "Median Value, Indexed to 2018 Board",
    caption = "Highlighted stage is a reassessment"
  ) +
  theme_minimal() +
  theme(
    axis.title = element_text(size = 13),
    axis.title.x = element_text(margin = margin(t = 6)),
    axis.title.y = element_text(margin = margin(r = 6)),
    axis.text = element_text(size = 11),
    axis.text.x = element_text(color = stage_labs),
    strip.text = element_text(size = 16),
    strip.background = element_rect(fill = "#c9c9c9"),
    legend.title = element_text(size = 14),
    legend.key.size = unit(24, "points"),
    legend.text = element_text(size = 12),
    legend.position = "bottom"
  )
#> Warning: Vectorized input to `element_text()` is not officially supported.
#>  Results may be unexpected or may change in future versions of ggplot2.


Holding commercial appeals constant makes their effect on residential tax bills clearer: large commercial appeals shift the property tax burden back to residential property owners. In the case of Schaumburg, the drop in commercial AVs during the second level of review (at the Board of Review) contributed to a roughly 7% rise in the median residential tax bill.

Whether or not this is correct is mostly a matter of interpretation. If the Assessor’s commercial values are accurate, then the subsequent appeals unjustly shifted tax burden back to residential properties. If the Board of Review’s post-appeal values are accurate, then their appeals rightly reversed the unjust increase in commercial tax bills resulting from the 2019 reassessment.

PTAXSIM doesn’t offer a definitive answer or make any normative claims, but it’s an incredibly useful tool for weighing the effects and trade-offs associated with appeals.