Chicago, IL — Land Value Tax model (CLASSIFICATION-PRESERVED VARIANT)¶
Variant of
model.ipynb. The split-rate and abatement are run on raw assessed values (which keep Cook County's 10%/25% class ratios) instead of market value, isolating the land-shift effect from the classification-removal effect. Outputs use*_classpreservedslugs and do not overwrite the canonical exports.
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['assessed_land'] # classification-preserved (assessed, embeds 10%/25% class ratios)
gdf['taxable_improvement_value'] = gdf['assessed_bldg'] # classification-preserved
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: 65.64175 Improvement millage: 26.25670 (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 $-32,235,577 -10.8% $-112 $-71 -9.8% -14.3% 7.5% 64.5%
Single Family Residential 278520 $15,870,770 4.9% $57 $10 3.8% 1.6% 29.9% 16.7%
Small Multi-Family (2-4 units) 120202 $7,609,022 4.0% $63 $-24 0.4% -2.4% 21.4% 24.4%
Vacant Land 39925 $10,896,860 84.7% $273 $84 257.2% 84.3% 100.0% 0.0%
Townhome / Rowhouse 23020 $-585,131 -1.7% $-25 $-14 0.1% -1.8% 22.6% 27.4%
Mixed Use 15962 $2,794,447 4.1% $175 $-43 0.8% -3.4% 22.3% 27.0%
Transportation - Parking 15957 $17,511,011 50.4% $1,097 $360 339.9% 78.2% 91.9% 2.2%
Retail / General Commercial 13232 $17,251,406 25.7% $1,304 $289 21.0% 15.8% 58.8% 8.7%
Industrial 11404 $24,828,619 34.4% $2,177 $483 47.0% 53.7% 79.7% 2.5%
Large Multi-Family (5+ units) 11258 $-12,104,551 -8.7% $-1,075 $-153 3.0% -5.3% 25.9% 37.4%
Other Commercial 4359 $-5,424,195 -6.3% $-1,244 $-91 2.8% -8.1% 32.0% 47.1%
Other Residential 4309 $342,754 4.6% $80 $109 67.4% 75.4% 95.5% 1.4%
Office / Commercial Condo 4245 $-40,249,389 -15.8% $-9,482 $-154 -6.2% -9.9% 11.0% 49.5%
Hotel 436 $-6,847,685 -13.3% $-15,706 $-2,326 -5.4% -12.5% 16.1% 56.2%
Other 172 $343,297 12.4% $1,996 $156 16.5% 9.0% 49.4% 14.5%
Agricultural 2 $-1,658 -15.6% $-829 $-829 19.6% 19.6% 50.0% 50.0%
OVERALL SUMMARY:
Total Properties: 830,169
Total Tax Δ: $0
Net Revenue Δ: $0
Average Percent Δ (mean of means): 47.38%
Median Percent Δ (median of medians): -0.12%
Percent of ALL parcels with tax increase > +10%: 26.46%
Percent of ALL parcels with tax decrease < -10%: 33.98%
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 84.300 0.000 0.998 Transportation - Parking 15957 78.226 0.055 0.004 Other Residential 4309 75.356 0.081 0.000 Industrial 11404 53.712 0.277 0.000 Agricultural 2 19.627 0.585 0.000 Retail / General Commercial 13232 15.764 0.620 0.000 Other 172 9.043 0.681 0.000 Single Family Residential 278520 1.589 0.748 0.000 Townhome / Rowhouse 23020 -1.832 0.779 0.000 Small Multi-Family (2-4 units) 120202 -2.366 0.784 0.000 Mixed Use 15962 -3.389 0.793 0.000 Large Multi-Family (5+ units) 11258 -5.282 0.810 0.000 Other Commercial 4359 -8.102 0.836 0.000 Office / Commercial Condo 4245 -9.873 0.852 0.000 Hotel 436 -12.507 0.875 0.000 Condominium 287166 -14.277 0.891 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: 149.8960 Total tax revenue: $1,642,587,611.00 Target revenue: $1,642,587,611.00 Revenue difference: $0.00 (0.0000%)
Abatement land millage: 149.89601 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 $-122,687,582 -41.2% $-427 $-271 -37.6% -54.3% 12.9% 79.8%
Single Family Residential 278520 $60,463,261 18.6% $217 $38 14.4% 6.1% 47.1% 37.7%
Small Multi-Family (2-4 units) 120202 $28,989,356 15.4% $241 $-91 1.4% -9.0% 36.4% 48.9%
Vacant Land 39925 $41,258,268 320.9% $1,033 $321 320.9% 320.9% 100.0% 0.0%
Townhome / Rowhouse 23020 $-2,220,434 -6.4% $-96 $-55 0.4% -7.0% 37.9% 47.9%
Mixed Use 15962 $10,646,357 15.6% $667 $-163 2.9% -12.9% 35.2% 52.0%
Transportation - Parking 15957 $66,637,999 191.9% $4,176 $1,370 875.9% 297.7% 94.0% 4.2%
Retail / General Commercial 13232 $65,669,855 97.9% $4,963 $1,102 80.1% 60.0% 70.1% 20.8%
Industrial 11404 $94,497,085 130.9% $8,286 $1,837 179.0% 204.4% 88.0% 7.0%
Large Multi-Family (5+ units) 11258 $-46,042,832 -32.9% $-4,090 $-580 11.3% -20.1% 35.4% 55.7%
Other Commercial 4359 $-20,627,870 -23.9% $-4,732 $-346 10.2% -30.8% 39.0% 56.3%
Other Residential 4309 $1,305,875 17.5% $303 $415 256.6% 286.8% 96.8% 2.5%
Office / Commercial Condo 4245 $-153,138,038 -60.2% $-36,075 $-585 -23.4% -37.6% 15.6% 65.2%
Hotel 436 $-26,052,066 -50.7% $-59,752 $-8,849 -20.6% -47.6% 19.7% 72.2%
Other 172 $1,307,075 47.2% $7,599 $594 62.8% 34.4% 58.1% 29.7%
Agricultural 2 $-6,308 -59.2% $-3,154 $-3,154 74.7% 74.7% 50.0% 50.0%
OVERALL SUMMARY:
Total Properties: 830,169
Total Tax Δ: $0
Net Revenue Δ: $0
Average Percent Δ (mean of means): 113.06%
Median Percent Δ (median of medians): -0.44%
Percent of ALL parcels with tax increase > +10%: 37.48%
Percent of ALL parcels with tax decrease < -10%: 51.63%
Median % change by category — split-rate vs abatement:
split_4to1_median_pct abatement_median_pct
PROPERTY_CATEGORY
Condominium -14.3 -54.3
Hotel -12.5 -47.6
Office / Commercial Condo -9.9 -37.6
Other Commercial -8.1 -30.8
Large Multi-Family (5+ units) -5.3 -20.1
Mixed Use -3.4 -12.9
Small Multi-Family (2-4 units) -2.4 -9.0
Townhome / Rowhouse -1.8 -7.0
Single Family Residential 1.6 6.1
Other 9.0 34.4
Retail / General Commercial 15.8 60.0
Agricultural 19.6 74.7
Industrial 53.7 204.4
Other Residential 75.4 286.8
Transportation - Parking 78.2 297.7
Vacant Land 84.3 320.9
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 + '_classpreserved',
output_path=f'data/{CITY_NAME}_classpreserved.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 + '_classpreserved', 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_classpreserved',
output_path=f'data/{CITY_NAME}_abatement_classpreserved.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_classpreserved', show=False)
print("Done.")
✓ chicago_classpreserved: 830,169 rows → data/chicago_classpreserved.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_classpreserved: 830,169 rows → data/chicago_abatement_classpreserved.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.