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:
- Assessor’s Office
- Board of Review
- 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:
-
av_mailed
- Initial assessed values when mailed to property owners, after Desk Review -
av_certified
- Assessed values after Assessor’s Office appeals -
av_board
- Assessed values after Board of Review appeals -
av_clerk
- Final assessed values used by the Clerk and Treasurer to calculate bills. Identical toav_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.
library(DBI)
library(data.table)
library(dplyr)
library(ggplot2)
library(ggspatial)
library(glue)
library(here)
library(ptaxsim)
library(sf)
ptaxsim_db_conn <- DBI::dbConnect(RSQLite::SQLite(), here("./ptaxsim.db"))
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.