Assessing Charlottesville property assessments
Residential property assessments in Charlottesville rose by 11.7% in 2022, and local officials and residents are concerned. Is this an unusual change in assessments? Which properties saw the greatest increases, and why?
We can find the relevant data at the city's Open Data Portal. For simplicity, I'm going to treat parcels as residential if they have an entry in the residential details table. I'm also going to look up at to 20 years of data, so I'm only including homes built after 2002.
# Hack: Ignore shapely/geos version mismatch warning
import warnings
warnings.simplefilter("ignore")
import altair as alt
import branca
import folium
import numpy as np
import seaborn as sns
import statsmodels.formula.api as smf
from google.cloud import bigquery
bq = bigquery.Client()
First we can look at average assessments over time:
raw_assessments_by_year = bq.query(
"""
select
assessments.taxyear,
avg(assessments.totalvalue) as totalvalue,
count(assessments.totalvalue) as count,
from `cvilledata.cville_open_data.real_estate_all_assessments` assessments
join `cvilledata.cville_open_data.real_estate_residential_details` residential on assessments.parcelnumb = residential.parcelnumb
where assessments.taxyear >= 2002
and residential.yearbuilt <= 2002
group by assessments.taxyear
order by assessments.taxyear desc
"""
).result().to_dataframe()
alt.Chart(raw_assessments_by_year).mark_bar().encode(
y=alt.Y("totalvalue:Q"),
x=alt.X("taxyear:N", sort="x"),
tooltip="totalvalue:Q",
).properties(
title="Mean assessment by year",
)
Assessments have grown substantially over the past 20 years, almost tripling since 2002. But this doesn't tell us whether the change in values in 2022 was typical or not. Let's look at proportion change in assessments relative to the previous year over the past 20 years:
assessments_by_year = bq.query(
"""
with assessments as (
select
details.parcelnumb as parcelnumber,
details.geoparceli as gpin,
assessments.taxyear,
assessments.totalvalue,
lag(assessments.totalvalue, 1) over (partition by details.parcelnumb order by assessments.taxyear) as totalvalueprev,
safe_divide(
assessments.totalvalue - lag(assessments.totalvalue, 1) over (partition by details.parcelnumb order by assessments.taxyear),
lag(assessments.totalvalue, 1) over (partition by details.parcelnumb order by assessments.taxyear)
) as totalvaluechange,
from `cvilledata.cville_open_data.parcel_area_details` details
join `cvilledata.cville_open_data.real_estate_all_assessments` assessments on details.parcelnumb = assessments.parcelnumb
)
select
assessments.taxyear,
avg(assessments.totalvalue) as totalvalue,
avg(assessments.totalvaluechange) as totalvaluechange,
count(assessments.totalvalue) as count,
from assessments
join `cvilledata.cville_open_data.real_estate_residential_details` residential on assessments.parcelnumber = residential.parcelnumb
where assessments.taxyear >= 2002
and residential.yearbuilt < 2002
group by assessments.taxyear
order by assessments.taxyear desc
"""
).result().to_dataframe()
alt.Chart(assessments_by_year).mark_bar().encode(
y=alt.Y("totalvaluechange:Q"),
x=alt.X("taxyear:N", sort="x"),
tooltip="totalvaluechange:Q",
).properties(
title="Mean assessment proportion change by year",
)
Property values rose about 12.4% in 2022 relative to 2021. That's a large increase relative to the past decade, but assessments rose faster every year from 2003 to 2007. Assessments also rose relatively slowly in 2020 and 2021; we'll say more about this later.
Next, we can map changes in assessments by parcel across the city:
assessments_by_parcel = bq.query(
"""
with assessments as (
select
details.parcelnumb as parcelnumber,
details.geoparceli as gpin,
assessments.taxyear,
assessments.totalvalue,
lag(assessments.totalvalue, 1) over (partition by details.parcelnumb order by assessments.taxyear) as totalvalueprev,
safe_divide(
assessments.totalvalue - lag(assessments.totalvalue, 1) over (partition by details.parcelnumb order by assessments.taxyear),
lag(assessments.totalvalue, 1) over (partition by details.parcelnumb order by assessments.taxyear)
) as totalvaluechange,
from `cvilledata.cville_open_data.parcel_area_details` details
join `cvilledata.cville_open_data.real_estate_all_assessments` assessments on details.parcelnumb = assessments.parcelnumb
), sales as (
select
*,
count(*) over (partition by bookpage) as parcelcount,
from `cvilledata.cville_open_data.real_estate_sales`
)
select
assessments.parcelnumber,
assessments.taxyear,
assessments.totalvalue,
assessments.totalvaluechange,
concat(details.streetnumb, ' ', details.streetname) as address,
neighborhoods.name as neighborhoodname,
sales.saleamount is not null as soldprevyear,
st_geogfromgeojson(details.geometry) as geometry,
from assessments
join `cvilledata.cville_open_data.parcel_area_details` details on assessments.parcelnumber = details.parcelnumb
join `cvilledata.cville_open_data.real_estate_residential_details` residential on assessments.parcelnumber = residential.parcelnumb
join `cvilledata.cville_open_data_derived.geopin_to_assessment_neighborhood` neighborhoods on assessments.gpin = neighborhoods.gpin
left join sales on assessments.parcelnumber = sales.parcelnumb and sales.saleamount > 0 and sales.parcelcount = 1 and extract(year from sales.saledate) = assessments.taxyear - 1
where assessments.taxyear = 2022
and residential.yearbuilt < 2002
"""
).result().to_geodataframe()
parcel_colormap = branca.colormap.linear.YlOrRd_09
parcel_color_values = assessments_by_parcel["totalvaluechange"][~assessments_by_parcel["totalvaluechange"].isna()]
parcel_colormap = parcel_colormap.scale(*list(np.percentile(parcel_color_values, [5, 95])))
def style(feature):
color_value = feature["properties"]["totalvaluechange"]
if color_value is None or np.isnan(color_value):
fill_color = "white"
else:
fill_color = parcel_colormap.rgb_hex_str(color_value)
return {
"color": "black",
"weight": 0.05,
"fillOpacity": 0.8,
"fillColor": fill_color,
}
parcel_map = folium.Map(
location=[38.04, -78.49],
tiles="cartodbpositron",
zoom_start=13.5,
zoom_delta=0.5,
)
folium.GeoJson(
assessments_by_parcel,
tooltip=folium.GeoJsonTooltip(fields=["parcelnumber", "address", "totalvalue", "totalvaluechange", "neighborhoodname", "soldprevyear"], labels=True),
style_function=style,
).add_to(parcel_map)
parcel_colormap.add_to(parcel_map)
parcel_map