Chicago, IL — Land Value Tax model¶
Models a revenue-neutral shift of the City of Chicago's own property tax levy from the current value-based system toward land, under two reforms:
- 2.5:1 split-rate — land taxed at 2.5× the improvement rate (canonical cross-city export).
- Full building abatement — improvements 100% exempt (pure land value tax), shown as a comparison.
The 2.5:1 ratio is set by the Illinois Constitution's classification limit (Art. IX §4(b)): in counties over 200,000, the ratio between the highest- and lowest-assessed classes may not exceed 2.5:1 — the same cap that gives Cook County its 10% residential / 25% commercial structure. Applying it to the land-vs-improvement split keeps the reform within that legal ceiling.
Policy decisions (confirmed before modeling)¶
| Decision | Choice |
|---|---|
| Government body | City of Chicago levy only (not the composite of all taxing agencies) |
| Reform | Both — 2.5:1 split-rate and 100% building abatement, compared |
| Exemptions | Preserve intent, but modeled on gross EAV — see limitations |
| Validation target | City of Chicago Corporate extension, TY2024 |
Data sources (public, reproducible)¶
| Need | Cook County dataset | Fields |
|---|---|---|
| Land/building assessed values | Assessor – Assessed Values (uzyt-m557) |
board_land, board_bldg, board_tot (+ certified/mailed fallback), class, township_code |
| City filter + census geometry | Assessor – Parcel Universe (nj4t-kc8j) |
lat, lon, tax_code, chicago_community_area_name |
The eight Cook County assessment townships 70–77 are coterminous with the City of Chicago; the filter uses those township codes (≈ 883k parcels, matching the city's parcel count).
Cook County assessment mechanics (TY2024)¶
- Assessed value → market value: Cook County assesses by class — residential/vacant/multi-family (major classes 1/2/3/9) at 10% of market value, commercial/industrial (class 5) at 25%, not-for-profit (class 4) at 20%, incentive classes (6/7/8) at a reduced 10% in their active phase. The published values are assessed values; market value = assessed ÷ level-of-assessment.
- Equalized Assessed Value (EAV) = assessed × the IDOR Cook County state equalizer = 3.0355 (TY2024 final).
- The City of Chicago Corporate levy (TY2024) is $1,642,587,611 at an agency rate of 1.495780% (Cook County Clerk 2024 Tax Rate Report). Total Chicago EAV that year was $109.8B.
Why the reform is computed on market value¶
The current system taxes EAV, which embeds Cook County's non-uniform class ratios (a $ of commercial land is taxed 2.5× a $ of residential land). A land value tax taxes a dollar of land value uniformly, so the split-rate / abatement are applied to market-value land and improvement (assessed ÷ class level of assessment). The modeled change therefore reflects both the move to a uniform land base and the land/building shift.
Limitations¶
- Exemptions not applied. The public Assessed Values dataset carries no per-parcel homeowner/senior exemptions, so the model runs on gross EAV. The effective city rate derived below is therefore lower than the published 1.4958% (the same levy spread over a broader, pre-exemption base). Because the model is revenue-neutral and current tax is proportional to EAV, the distribution of winners and losers is robust to this; absolute rates are not exemption-exact.
- TIF increment not removed from the base (same reasoning).
- Incentive classes (6/7/8) are assumed to be at their 10% active-phase level; a minority in years 11–12 (15%/20%) or expired would have market value slightly overstated. Small share of parcels.
- TY2024 is the City of Chicago triennial reassessment year, so Board of Review certification is
still rolling out township-by-township. Each parcel uses the most final value available — Board where
present, else certified, else mailed (
coalescebelow) — so a minority of parcels reflect a pre-Board stage. Values are final for the majority; this will settle as the reassessment completes.
Section 1 — Imports and setup¶
import sys
import time
from pathlib import Path
import geopandas as gpd
import matplotlib
matplotlib.use('Agg') # headless
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
from shapely.geometry import Point
from dotenv import load_dotenv
sys.path.insert(0, '../..')
REPO_ROOT = Path('../..').resolve()
load_dotenv(REPO_ROOT / '.env')
from lvt.lvt_utils import (
model_split_rate_tax,
model_full_building_abatement,
calculate_current_tax,
calculate_category_tax_summary,
print_category_tax_summary,
save_standard_export,
)
from lvt.census_utils import get_census_data_with_boundaries, match_to_census_blockgroups
# ---- Parameters: single source of truth, shared with the report pipeline ----
# (edit cities/chicago/chicago_params.py to change any of these — the notebook and
# every downstream report import from the same module, so they cannot drift.)
sys.path.insert(0, '.')
from chicago_params import (
CITY_NAME, STATE_FIPS, COUNTY_FIPS, MODEL_TYPE, LAND_IMPROVEMENT_RATIO, TAX_YEAR,
EQUALIZER, CITY_LEVY, PUBLISHED_CITY_RATE, CHICAGO_TOWNSHIPS, class_to_loa, classify,
)
DATA_DIR = Path('data')
DATA_DIR.mkdir(exist_ok=True)
print('setup OK')
setup OK
Section 2 — Fetch / load parcel data¶
Downloads from the Cook County open-data Socrata API with pagination, filtered to the eight
Chicago townships and TAX_YEAR. Assessed values (uzyt-m557) are joined to the Parcel Universe
(nj4t-kc8j) for latitude/longitude (used to build the census geometry) and locational fields.
Cached to data/parcels.gpq.
SOCRATA = 'https://datacatalog.cookcountyil.gov/resource'
def fetch_socrata(resource, select, where, page=25000, max_pages=400, retries=5):
# Page through a Socrata resource into a DataFrame, with retry/backoff
# (the API can be slow/flaky for these large, wide tables).
rows, offset = [], 0
for _ in range(max_pages):
params = {
'$select': select,
'$where': where,
'$limit': page,
'$offset': offset,
'$order': ':id',
}
batch = None
for attempt in range(retries):
try:
r = requests.get(f'{SOCRATA}/{resource}.json', params=params, timeout=120)
r.raise_for_status()
batch = r.json()
break
except Exception as e:
if attempt == retries - 1:
raise
time.sleep(2 ** attempt) # exponential backoff
if not batch:
break
rows.extend(batch)
offset += page
if len(batch) < page:
break
time.sleep(0.2)
return pd.DataFrame(rows)
PARCEL_PATH = DATA_DIR / 'parcels.gpq'
if PARCEL_PATH.exists():
gdf = gpd.read_parquet(PARCEL_PATH)
print(f"Loaded {len(gdf):,} parcels from cache")
else:
town_list = ','.join(f"'{t}'" for t in CHICAGO_TOWNSHIPS)
# Assessed values (land / building / total), all three assessment stages
av = fetch_socrata(
'uzyt-m557',
select=('pin,class,township_code,'
'board_land,board_bldg,board_tot,'
'certified_land,certified_bldg,certified_tot,'
'mailed_land,mailed_bldg,mailed_tot'),
where=f"year={TAX_YEAR} AND township_code in({town_list})",
)
print(f"Assessed values: {len(av):,} rows")
# Parcel Universe: location + census geometry source
pu = fetch_socrata(
'nj4t-kc8j',
select='pin,lat,lon,tax_code,chicago_community_area_name',
where=f"year={TAX_YEAR} AND township_code in({town_list})",
)
print(f"Parcel universe: {len(pu):,} rows")
df = av.merge(pu, on='pin', how='left')
num_cols = ['board_land','board_bldg','board_tot','certified_land','certified_bldg',
'certified_tot','mailed_land','mailed_bldg','mailed_tot','lat','lon']
for c in num_cols:
df[c] = pd.to_numeric(df[c], errors='coerce')
# Point geometry from parcel centroid (used by the census block-group join)
df = df[df['lat'].notna() & df['lon'].notna()].copy()
gdf = gpd.GeoDataFrame(
df, geometry=[Point(xy) for xy in zip(df['lon'], df['lat'])], crs='EPSG:4326'
)
gdf.to_parquet(PARCEL_PATH)
print(f"Downloaded and cached {len(gdf):,} parcels")
print(gdf[['pin','class','board_land','board_bldg','board_tot']].head())
Loaded 882,904 parcels from cache
pin class board_land board_bldg board_tot
0 19151120251019 299 915.0 16834.0 17749.0
1 19151190381006 299 868.0 11132.0 12000.0
2 19151250350000 234 3661.0 24339.0 28000.0
3 19153260150000 203 3780.0 19220.0 23000.0
4 19154300260000 590 12263.0 896.0 13159.0
Section 3 — Classify, normalize to market value, flag exempt¶
Column mapping
| Concept | Column | Notes |
|---|---|---|
| Land assessed value | assessed_land |
coalesce board → certified → mailed |
| Improvement assessed value | assessed_bldg |
coalesce board → certified → mailed |
| Total assessed value | assessed_tot |
coalesce board → certified → mailed |
| Class / use code | class |
Cook County 3-char class (e.g. 299, EX) |
| Exempt flag | full_exmp |
1 for class EX/RR or zero/blank assessment |
| Level of assessment | loa |
0.10 / 0.20 / 0.25 by major class |
| Market land value | market_land |
assessed_land / loa |
| Market improvement value | market_bldg |
assessed_bldg / loa |
Levels of assessment and category labels follow the Cook County Assessor Classifications of Real Property (revised 12/16/2024). A Transportation - Parking category captures automotive improvements (5-22, 7-22, 8-22, 7-52), gasoline stations (classes ending in -23), and minor improvements (classes ending in -90, including 1-90 and surface parking lots); these keep their major-class level of assessment and are held out of the "$0 improvement → Vacant Land" override. Class 2-41 (vacant land under common ownership with an adjacent residence) is categorized as Vacant Land but, as a Class 2 parcel, is taxed at the 10% residential level.
# --- Coalesce assessment stages: board (final) -> certified -> mailed ---
def coalesce(*cols):
out = gdf[cols[0]].copy()
for c in cols[1:]:
out = out.where(out > 0, gdf[c])
return out.fillna(0).clip(lower=0)
gdf['assessed_land'] = coalesce('board_land', 'certified_land', 'mailed_land')
gdf['assessed_bldg'] = coalesce('board_bldg', 'certified_bldg', 'mailed_bldg')
gdf['assessed_tot'] = coalesce('board_tot', 'certified_tot', 'mailed_tot')
# Level of assessment + property category come from chicago_params (imported in §1),
# so the notebook and the report pipeline share one definition.
gdf['loa'] = gdf['class'].map(class_to_loa)
gdf['PROPERTY_CATEGORY'] = gdf['class'].map(classify)
# --- Exempt flag: class EX/RR, or no taxable assessment ---
gdf['full_exmp'] = (
gdf['class'].astype(str).str.upper().isin(['EX', 'RR'])
| (gdf['assessed_tot'] <= 0)
| gdf['loa'].isna()
).astype(int)
# --- Market values (assessed / level of assessment) ---
loa = gdf['loa'].fillna(0.10)
gdf['market_land'] = (gdf['assessed_land'] / loa).clip(lower=0)
gdf['market_bldg'] = (gdf['assessed_bldg'] / loa).clip(lower=0)
# Override: zero improvement -> Vacant Land (regardless of class), EXCEPT parking
# (surface lots often have $0 building value but should stay Transportation - Parking).
gdf.loc[(gdf['assessed_bldg'] <= 0) & (gdf['PROPERTY_CATEGORY'] != 'Transportation - Parking'),
'PROPERTY_CATEGORY'] = 'Vacant Land'
print(f"Parcels: {len(gdf):,} Fully-exempt (EX/RR/zero): {(gdf['full_exmp']==1).sum():,}")
print("\nCategory counts:")
print(gdf['PROPERTY_CATEGORY'].value_counts())
print("\nLevel-of-assessment distribution (taxable):")
print(gdf.loc[gdf['full_exmp']==0, 'loa'].value_counts(dropna=False))
Parcels: 882,904 Fully-exempt (EX/RR/zero): 52,735 Category counts: Condominium 287166 Single Family Residential 278520 Small Multi-Family (2-4 units) 120202 Vacant Land 92560 Townhome / Rowhouse 23020 Mixed Use 15962 Transportation - Parking 15957 Retail / General Commercial 13232 Industrial 11404 Large Multi-Family (5+ units) 11258 Other Commercial 4359 Other Residential 4309 Office / Commercial Condo 4245 Hotel 436 Other 172 Exempt 100 Agricultural 2 Name: PROPERTY_CATEGORY, dtype: int64 Level-of-assessment distribution (taxable): 0.10 781295 0.25 48664 0.20 210 Name: loa, dtype: int64
Section 4 — Current tax (City of Chicago levy only)¶
The City of Chicago Corporate levy is spread across all taxable equalized assessed value at a uniform citywide agency rate. We compute gross EAV per parcel, then derive the effective city rate that reproduces the official levy on this (pre-exemption) base. Revenue match to the official levy is exact by construction; the meaningful check is that the derived effective rate is in the neighborhood of the published 1.4958% — lower, because exemptions/TIF are not removed from the base.
# Gross EAV
gdf['eav'] = gdf['assessed_tot'] * EQUALIZER
taxable = gdf['full_exmp'] == 0
total_eav_taxable = float(gdf.loc[taxable, 'eav'].sum())
# Derive the effective city rate that reproduces the official City Corporate levy
city_rate = CITY_LEVY / total_eav_taxable
gdf['millage_rate'] = city_rate * 1000.0 # calculate_current_tax divides by 1000
current_revenue, _, gdf = calculate_current_tax(
df=gdf,
tax_value_col='eav',
millage_rate_col='millage_rate',
exemption_flag_col='full_exmp',
)
print(f"Taxable gross EAV: ${total_eav_taxable:,.0f}")
print(f"Derived effective rate: {city_rate*100:.4f}% (published City Corporate: {PUBLISHED_CITY_RATE*100:.4f}%)")
print(f"Modeled current revenue: ${current_revenue:,.0f}")
print(f"Official City levy: ${CITY_LEVY:,.0f}")
print(f"Gap: {(current_revenue/CITY_LEVY - 1)*100:+.3f}% (≈0 by construction)")
rate_ratio = city_rate / PUBLISHED_CITY_RATE
print(f"\nDerived/published rate ratio: {rate_ratio:.3f} "
f"(<1 expected: gross EAV exceeds the net taxable base by exemptions + TIF)")
Taxable gross EAV: $139,992,554,872 Derived effective rate: 1.1733% (published City Corporate: 1.4958%) Modeled current revenue: $1,642,587,611 Official City levy: $1,642,587,611 Gap: +0.000% (≈0 by construction) Derived/published rate ratio: 0.784 (<1 expected: gross EAV exceeds the net taxable base by exemptions + TIF)
Section 5 — Split-rate model (2.5:1) on market value¶
Fully-exempt parcels are held out of the revenue-neutral solver and excluded from the export and report charts (they carry no signal). The reform is solved on market-value land and improvement so a dollar of land is taxed uniformly across classes.
# Hold out fully-exempt parcels (excluded from model, export, and charts)
n_exempt = int((gdf['full_exmp'] == 1).sum())
gdf = gdf[gdf['full_exmp'] == 0].copy()
# Taxable value columns expected by save_standard_export = the reform (market) base
gdf['taxable_land_value'] = gdf['market_land']
gdf['taxable_improvement_value'] = gdf['market_bldg']
land_millage, improvement_millage, new_revenue, gdf = model_split_rate_tax(
df=gdf,
land_value_col='taxable_land_value',
improvement_value_col='taxable_improvement_value',
current_revenue=gdf['current_tax'].sum(),
land_improvement_ratio=LAND_IMPROVEMENT_RATIO,
)
print(f"Held out {n_exempt:,} fully-exempt parcels (excluded from model, export, and charts).")
print(f"Land millage: {land_millage:.5f} Improvement millage: {improvement_millage:.5f} "
f"(ratio {land_millage/improvement_millage:.1f}:1)")
print(f"Revenue check: ${new_revenue:,.0f} vs current ${gdf['current_tax'].sum():,.0f}")
category_summary = calculate_category_tax_summary(
df=gdf, category_col='PROPERTY_CATEGORY',
current_tax_col='current_tax', new_tax_col='new_tax',
)
print_category_tax_summary(category_summary, title=f"{CITY_NAME} — {LAND_IMPROVEMENT_RATIO}:1 split-rate tax impact")
Held out 52,735 fully-exempt parcels (excluded from model, export, and charts). Land millage: 8.35810 Improvement millage: 3.34324 (ratio 2.5:1) Revenue check: $1,642,587,611 vs current $1,642,587,611
chicago — 2.5:1 split-rate tax impact
================================================================================
Category Count Total Tax Δ ($) Total Δ (%) Mean Δ ($) Median Δ ($) Avg % Δ Median % Δ % Parcels > +10% % Parcels < -10%
Condominium 287166 $40,238,041 13.5% $140 $50 14.9% 9.2% 47.0% 0.0%
Single Family Residential 278520 $108,944,042 33.6% $391 $220 32.1% 29.4% 93.1% 0.0%
Small Multi-Family (2-4 units) 120202 $61,124,098 32.5% $509 $294 27.8% 24.3% 84.6% 0.0%
Vacant Land 39925 $17,379,227 135.2% $435 $135 354.8% 134.7% 100.0% 0.0%
Townhome / Rowhouse 23020 $8,793,764 25.2% $382 $201 27.4% 25.0% 82.1% 0.0%
Mixed Use 15962 $-4,636,450 -6.8% $-290 $285 12.7% 17.8% 66.3% 18.8%
Transportation - Parking 15957 $-4,584,365 -13.2% $-287 $-61 361.4% -8.2% 20.8% 39.2%
Retail / General Commercial 13232 $-24,011,358 -35.8% $-1,815 $-876 -38.2% -41.0% 0.1% 95.0%
Industrial 11404 $-16,909,319 -23.4% $-1,483 $-381 -20.7% -18.4% 4.2% 62.2%
Large Multi-Family (5+ units) 11258 $22,781,489 16.3% $2,024 $790 31.1% 20.6% 74.2% 0.0%
Other Commercial 4359 $-40,385,324 -46.8% $-9,265 $-1,449 -44.3% -53.0% 3.3% 93.4%
Other Residential 4309 $2,479,290 33.2% $575 $187 113.2% 123.3% 99.0% 0.0%
Office / Commercial Condo 4245 $-142,665,892 -56.1% $-33,608 $-1,136 -51.8% -54.1% 0.4% 99.2%
Hotel 436 $-27,759,750 -54.0% $-63,669 $-14,701 -49.8% -55.0% 1.4% 95.0%
Other 172 $-788,295 -28.4% $-4,583 $-554 -25.8% -30.6% 5.8% 79.7%
Agricultural 2 $802 7.5% $401 $401 52.3% 52.3% 50.0% 0.0%
OVERALL SUMMARY:
Total Properties: 830,169
Total Tax Δ: $0
Net Revenue Δ: $0
Average Percent Δ (mean of means): 49.82%
Median Percent Δ (median of medians): 13.50%
Percent of ALL parcels with tax increase > +10%: 70.12%
Percent of ALL parcels with tax decrease < -10%: 4.55%
Section 5b — Gate 5 artifact scan¶
Read the category table against economic priors before trusting it. Watch for: distinct categories piled at one extreme value (ceiling clustering), implausible building shares, or large placeholder/zero building counts. A condo unit with apportioned land is normal in Cook County; a built commercial category showing ~0% building share is not.
d = gdf.copy()
d['bldg_share'] = d['market_bldg'] / (d['market_land'] + d['market_bldg']).replace(0, np.nan)
scan = d.groupby('PROPERTY_CATEGORY').agg(
n=('tax_change_pct', 'size'),
median_pct=('tax_change_pct', 'median'),
median_bldg_share=('bldg_share', 'median'),
pct_zero_bldg=('market_bldg', lambda s: (s <= 1).mean()),
).sort_values('median_pct', ascending=False)
pd.set_option('display.width', 160)
print(scan.round(3))
n median_pct median_bldg_share pct_zero_bldg PROPERTY_CATEGORY Vacant Land 39925 134.668 0.000 0.998 Other Residential 4309 123.280 0.081 0.000 Agricultural 2 52.319 0.585 0.000 Single Family Residential 278520 29.352 0.748 0.000 Townhome / Rowhouse 23020 24.997 0.779 0.000 Small Multi-Family (2-4 units) 120202 24.317 0.784 0.000 Large Multi-Family (5+ units) 11258 20.603 0.810 0.000 Mixed Use 15962 17.845 0.793 0.000 Condominium 287166 9.150 0.891 0.000 Transportation - Parking 15957 -8.190 0.055 0.004 Industrial 11404 -18.358 0.277 0.000 Other 172 -30.578 0.681 0.000 Retail / General Commercial 13232 -41.013 0.620 0.000 Other Commercial 4359 -53.019 0.836 0.000 Office / Commercial Condo 4245 -54.085 0.852 0.000 Hotel 436 -54.952 0.875 0.000
Section 6 — Building abatement scenario (100% improvement exemption)¶
A full building abatement (pure land value tax), revenue-neutral to the same City levy, on the same modeled (non-exempt) parcels. Shown for comparison; the canonical cross-city export remains the 2.5:1 split-rate.
gdf_abate = gdf.copy()
abate_millage, abate_revenue, gdf_abate = model_full_building_abatement(
df=gdf_abate,
land_value_col='taxable_land_value',
improvement_value_col='taxable_improvement_value',
current_revenue=gdf_abate['current_tax'].sum(),
abatement_percentage=1.0,
)
print(f"Abatement land millage: {abate_millage:.5f}")
print(f"Revenue check: ${abate_revenue:,.0f}")
abate_summary = calculate_category_tax_summary(
df=gdf_abate, category_col='PROPERTY_CATEGORY',
current_tax_col='current_tax', new_tax_col='new_tax',
)
print_category_tax_summary(abate_summary, title=f"{CITY_NAME} — 100% building abatement (pure LVT)")
# Side-by-side median % change by category
cmp = pd.DataFrame({
'split_4to1_median_pct': gdf.groupby('PROPERTY_CATEGORY')['tax_change_pct'].median(),
'abatement_median_pct': gdf_abate.groupby('PROPERTY_CATEGORY')['tax_change_pct'].median(),
}).round(1)
print("\nMedian % change by category — split-rate vs abatement:")
print(cmp.sort_values('split_4to1_median_pct'))
Building abatement model (100.0% abatement) Millage rate: 19.2833 Total tax revenue: $1,642,587,611.00 Target revenue: $1,642,587,611.00 Revenue difference: $0.00 (0.0000%)
Abatement land millage: 19.28330 Revenue check: $1,642,587,611
chicago — 100% building abatement (pure LVT)
================================================================================
Category Count Total Tax Δ ($) Total Δ (%) Mean Δ ($) Median Δ ($) Avg % Δ Median % Δ % Parcels > +10% % Parcels < -10%
Condominium 287166 $-72,635,066 -24.4% $-253 $-150 -19.7% -41.2% 21.8% 70.0%
Single Family Residential 278520 $170,789,793 52.6% $613 $240 47.1% 36.4% 65.8% 21.7%
Small Multi-Family (2-4 units) 120202 $91,204,603 48.5% $759 $181 30.4% 17.1% 55.0% 31.5%
Vacant Land 39925 $56,737,183 441.2% $1,421 $442 441.3% 441.4% 100.0% 0.0%
Townhome / Rowhouse 23020 $7,141,479 20.5% $310 $131 29.1% 19.7% 55.3% 33.4%
Mixed Use 15962 $-638,704 -0.9% $-40 $-16 13.6% -1.6% 42.7% 44.5%
Transportation - Parking 15957 $25,467,260 73.4% $1,596 $571 947.8% 108.6% 86.8% 10.8%
Retail / General Commercial 13232 $1,409,663 2.1% $107 $-340 -7.2% -17.6% 32.3% 55.6%
Industrial 11404 $20,946,372 29.0% $1,837 $386 51.3% 63.7% 64.3% 28.0%
Large Multi-Family (5+ units) 11258 $-19,199,049 -13.7% $-1,705 $60 43.1% 2.8% 46.8% 43.3%
Other Commercial 4359 $-46,215,047 -53.5% $-10,602 $-1,302 -37.6% -63.8% 18.8% 75.6%
Other Residential 4309 $3,821,131 51.1% $887 $586 358.7% 397.6% 97.7% 1.9%
Office / Commercial Condo 4245 $-201,151,198 -79.1% $-47,385 $-1,252 -60.1% -67.9% 5.9% 92.2%
Hotel 436 $-37,525,470 -73.0% $-86,068 $-19,602 -57.4% -71.5% 7.3% 87.8%
Other 172 $-147,888 -5.3% $-860 $-145 4.7% -13.5% 40.1% 51.2%
Agricultural 2 $-5,061 -47.5% $-2,530 $-2,530 124.8% 124.8% 50.0% 50.0%
OVERALL SUMMARY:
Total Properties: 830,169
Total Tax Δ: $0
Net Revenue Δ: $0
Average Percent Δ (mean of means): 119.38%
Median Percent Δ (median of medians): 9.95%
Percent of ALL parcels with tax increase > +10%: 49.09%
Percent of ALL parcels with tax decrease < -10%: 40.83%
Median % change by category — split-rate vs abatement:
split_4to1_median_pct abatement_median_pct
PROPERTY_CATEGORY
Hotel -55.0 -71.5
Office / Commercial Condo -54.1 -67.9
Other Commercial -53.0 -63.8
Retail / General Commercial -41.0 -17.6
Other -30.6 -13.5
Industrial -18.4 63.7
Transportation - Parking -8.2 108.6
Condominium 9.2 -41.2
Mixed Use 17.8 -1.6
Large Multi-Family (5+ units) 20.6 2.8
Small Multi-Family (2-4 units) 24.3 17.1
Townhome / Rowhouse 25.0 19.7
Single Family Residential 29.4 36.4
Agricultural 52.3 124.8
Other Residential 123.3 397.6
Vacant Land 134.7 441.4
Section 7 — Census join + export¶
Census join must run before export. The canonical join uses parcel-centroid geometry to attach
block-group demographics. The split-rate result is the canonical cross-city export
(analysis/data/chicago.csv + analysis/reports/chicago/); the abatement scenario gets its own report
folder (analysis/reports/chicago_abatement/) but is not added to the cross-city dataset.
# Census join — must happen before export.
# Cook County has 4,002 census tracts; the default TIGERweb block-group fetch
# (get_census_data_with_boundaries) queries tract-by-tract and takes ~40 min here.
# Instead pull the whole Illinois block-group file once from the Census FTP (seconds)
# and filter to the county — same std_geoid + boundaries the canonical path produces.
from lvt.census_utils import (
get_census_blockgroups_from_ftp, get_census_data, match_to_census_blockgroups,
)
_fips = STATE_FIPS + COUNTY_FIPS
try:
census_gdf = get_census_blockgroups_from_ftp(_fips, 2022) # one state zip, fast
census_data = get_census_data(_fips, 2022) # ACS 5-year demographics
census_gdf = census_gdf.merge(
census_data, on='std_geoid', how='left', suffixes=('', '_census'))
census_gdf = census_gdf.loc[:, ~census_gdf.columns.str.endswith('_census')]
gdf = match_to_census_blockgroups(gdf, census_gdf)
if 'minority_pct' not in gdf.columns and 'total_pop' in gdf.columns and 'white_pop' in gdf.columns:
gdf['minority_pct'] = ((gdf['total_pop'] - gdf['white_pop']) / gdf['total_pop'] * 100).round(2)
if 'black_pct' not in gdf.columns and 'total_pop' in gdf.columns and 'black_pop' in gdf.columns:
gdf['black_pct'] = (gdf['black_pop'] / gdf['total_pop'] * 100).round(2)
print(f"Census join: {gdf['std_geoid'].notna().mean()*100:.1f}% matched")
except Exception as e:
print(f"Census join failed: {e}")
for _col in ['std_geoid', 'median_income', 'minority_pct', 'black_pct']:
gdf[_col] = float('nan')
📥 Downloading block groups for state 17 from Census FTP...
✅ Downloaded 18,258,643 bytes 📊 Reading shapefile: tl_2022_17_bg.shp
🎯 Filtered to 4002 block groups in county 17031
Census join: 100.0% matched
# Propagate census columns onto the abatement frame (same index)
for _col in ['std_geoid', 'median_income', 'minority_pct', 'black_pct']:
if _col in gdf.columns:
gdf_abate[_col] = gdf[_col]
# Export (canonical) — split-rate 2.5:1
from lvt.lvt_utils import save_standard_export
out_df = save_standard_export(
df=gdf,
city=CITY_NAME,
output_path=f'../../analysis/data/{CITY_NAME}.csv',
model_type=MODEL_TYPE,
land_millage=land_millage,
improvement_millage=improvement_millage,
property_category_col='PROPERTY_CATEGORY',
current_tax_col='current_tax',
new_tax_col='new_tax',
tax_change_col='tax_change',
tax_change_pct_col='tax_change_pct',
taxable_land_col='taxable_land_value',
taxable_improvement_col='taxable_improvement_value',
)
# Standard report — 7 PNGs in analysis/reports/chicago/
from lvt.viz import create_city_report
create_city_report(out_df, CITY_NAME, show=False)
# Building-abatement scenario — separate export (not in cross-city) + own report folder
abate_out = save_standard_export(
df=gdf_abate,
city=f'{CITY_NAME}_abatement',
output_path=f'data/{CITY_NAME}_abatement.csv',
model_type='abatement:100pct',
land_millage=abate_millage,
improvement_millage=0.0,
property_category_col='PROPERTY_CATEGORY',
current_tax_col='current_tax',
new_tax_col='new_tax',
tax_change_col='tax_change',
tax_change_pct_col='tax_change_pct',
taxable_land_col='taxable_land_value',
taxable_improvement_col='taxable_improvement_value',
)
create_city_report(abate_out, f'{CITY_NAME}_abatement', show=False)
print("Done.")
✓ chicago: 830,169 rows → ../../analysis/data/chicago.csv [model: split_rate:2.5]
/opt/anaconda3/lib/python3.9/site-packages/scipy/__init__.py:155: UserWarning: A NumPy version >=1.18.5 and <1.25.0 is required for this version of SciPy (detected version 1.26.4
warnings.warn(f"A NumPy version >={np_minversion} and <{np_maxversion}"
✓ chicago_abatement: 830,169 rows → data/chicago_abatement.csv [model: abatement:100pct]
Done.
Validation summary¶
| Check | Result |
|---|---|
| Revenue match | Current revenue set to the official City of Chicago Corporate levy $1,642,587,611 (TY2024, Cook County Clerk 2024 Tax Rate Report) — exact by construction |
| Effective rate | Derived effective city rate (see Section 4) vs published 1.4958%; difference reflects gross-EAV modeling (exemptions/TIF not removed) |
| Parcel count | ≈883k Chicago parcels; fully-exempt held out & excluded from charts |
| Census coverage | See Section 7 output |
| PNGs generated | analysis/reports/chicago/ (split-rate) and analysis/reports/chicago_abatement/ |
| Artifact scan (Gate 5) | See Section 5b |
Figures populated on execution.