Across the top-250 U.S. cities by population, we computed each city's 5-year ZORI rent-index CAGR (2021-04 → 2026-04) and pre-registered six hypotheses about what should predict it — education share, IRS net migration, unemployment, income level, population size, and asset-price co-movement. We ran an OLS regression, refused to fabricate features, and report the honest answer — including the one significant predictor that came out with the wrong sign.
One row per city, top-250 by population. Target = the 5-year compound
annual growth rate of the Zillow ZORI rent index from
2021-04 → 2026-04, computed in this script
directly from the monthly ZORI city series. Cities missing either
endpoint were dropped, not imputed (no fabrication of the
target). 6 of 250 cities dropped → n = 244.
Features: ZHVI 5y CAGR (asset-price control, mirror of BAH's H1);
median household income, median age, population, lat/lon (city-level from
ACS / Census); bachelor_plus_pct, unemployment_pct, IRS net migration
(joined from the nearest ZCTA centroid in zip_master.json).
Cities' centroids were matched to the closest ZCTA from the 2023
Census Gazetteer by haversine.
bachelor_plus_pct from the nearest ZCTA. Predicted positive: educated renters bid rents up.log_population. Predicted positive: durable demand at scale.| Feature | β | 95% CI low | 95% CI high | t | p | bivariate r |
|---|---|---|---|---|---|---|
| const | +0.0508 | +0.0493 | +0.0523 | +67.20 | <0.001 | — |
| zhvi_5y_cagr p<.001 | +0.0071 | +0.0055 | +0.0087 | +8.57 | <0.001 | +0.57 |
| lon p<.001 | +0.0042 | +0.0023 | +0.0060 | +4.45 | <0.001 | +0.39 |
| abs_lat p<.001 | +0.0031 | +0.0015 | +0.0046 | +3.85 | <0.001 | +0.06 |
| irs_net_migration_2022 wrong sign | −0.0030 | −0.0046 | −0.0015 | −3.79 | <0.001 | −0.06 |
| median_income p<.10 | −0.0017 | −0.0038 | +0.0003 | −1.67 | 0.097 | −0.29 |
| log_population n.s. | −0.0010 | −0.0026 | +0.0006 | −1.24 | 0.215 | −0.17 |
| unemployment_pct_acs n.s. | +0.0003 | −0.0013 | +0.0019 | +0.36 | 0.717 | +0.10 |
| bachelor_plus_pct n.s. | +0.0002 | −0.0014 | +0.0019 | +0.29 | 0.772 | −0.08 |
| median_age n.s. | +0.0003 | −0.0013 | +0.0020 | +0.42 | 0.678 | −0.10 |
Read: a one-standard-deviation increase in ZHVI 5y CAGR is associated with a +0.71 percentage-point increase in city ZORI 5y CAGR — the dominant signal. The lon and abs_lat coefficients say that, after controlling for ZHVI, eastern and northern cities grew rents faster than western and southern ones (about +0.42 and +0.31 pp/yr per sigma respectively). The IRS-migration coefficient is negative and significant — the opposite of the pre-registered direction.
scripts/run_regression.py ·
regression_results.json
| R² (train, n=195) | 0.482 |
| Adjusted R² | 0.457 |
| F-test p-value | 2.2×10⁻²² |
| MAPE train | 0.185 |
Model is jointly highly significant. ZHVI does most of the work; geographic gradient adds genuine signal on top.
| R² (test, n=49) | +0.276 |
| RMSE test | 0.0145 |
| MAPE test | 0.168 |
| Target std | 0.0149 |
Beats the unconditional mean out-of-sample. RMSE (0.0145) is the same order as the target std (0.0149); the model explains roughly a quarter of held-out variance. Honest: this is real signal, not a great forecaster.
| Metric | BAH (n=177) | City (n=244) |
|---|---|---|
| Target mean | 6.33%/yr BAH CAGR | 5.14%/yr ZORI CAGR |
| Target std | 2.30% | 1.49% |
| R² train | 0.240 | 0.482 |
| Adjusted R² | 0.200 | 0.457 |
| R² test | −0.272 | +0.276 |
| F-test p | 3.8×10⁻⁶ | 2.2×10⁻²² |
| Strongest predictor | ZHVI 5y CAGR | ZHVI 5y CAGR |
The city regression beats the BAH regression on every fit-quality metric. R² test is positive (0.28) rather than negative (−0.27). City-level rent CAGR is a more tractable prediction problem than MHA-level BAH CAGR — larger n, the target is the rent index itself rather than an administrative pass-through, and the geographic gradient that died in the BAH study survives here at p<0.001.
IRS net migration is a statistically significant negative predictor of rent CAGR at p < 0.001 with the wrong pre-registered sign. We expected “more people moving in → faster rent growth”; we got “more people moving in → slower rent growth.”
The likely mechanism is a COVID base-effect inside the 2021→2026 window. Destination cities for the 2020–22 migration wave (Sun Belt, Florida, Tennessee) had already spiked their rents by 2021. The 2021→2026 CAGR captures their subsequent normalization — high level, low subsequent CAGR. Origin cities (NYC, SF) bottomed out in 2021 and recovered, giving them higher CAGR off a low base. This is exactly what a naive “follow the migration map” thesis would miss.
Operational implication: WindMayor should NOT underwrite a long-rent thesis in high-inflow destination metros purely on net-migration data. The migration signal in this window is contrarian.
population (replaced with log_population); rent level, yield, DSCR, price-to-rent (mechanically related to the target); state dummies (50 features for 244 rows is unsafe).sklearn is not installed in the working Python environment, so the GBM result is null rather than hand-rolled.
The regression is one Python script. It reads the cities panel, the
cached Zillow ZORI/ZHVI city CSVs, the Census ZCTA gazetteer, and
zip_master.json — computes per-city features in parallel via
ThreadPoolExecutor, fits OLS, and writes the JSON result.
No external network calls; data is from cache.
[run] loading cities, ZORI, ZHVI, ZCTA centroids, zip_master... [run] 250 cities loaded [zipmaster] loaded 31,646 ZCTA records [zillow] ZORI: 4,430 city series [zcta] loaded 33,791 ZCTA centroids [zillow] ZHVI all-homes: 21,356 city series [panel] computing features for 250 cities (parallel)... [panel] 250 rows built [panel] dropped 6 rows missing zori_5y_cagr target -> 244 cities for fit [panel] 244 rows after final dropna [fit] OLS on n_train=195 features=9 === OLS coefficient summary (standardized) === const beta=+0.0508 t=+67.20 p=0.000 zhvi_5y_cagr beta=+0.0071 t= +8.57 p=0.000 *** bachelor_plus_pct beta=+0.0002 t= +0.29 p=0.772 irs_net_migration_2022 beta=-0.0030 t= -3.79 p=0.000 *** unemployment_pct_acs beta=+0.0003 t= +0.36 p=0.717 median_income beta=-0.0017 t= -1.67 p=0.097 * log_population beta=-0.0010 t= -1.24 p=0.215 abs_lat beta=+0.0031 t= +3.85 p=0.000 *** lon beta=+0.0042 t= +4.45 p=0.000 *** median_age beta=+0.0003 t= +0.42 p=0.678 n = 244 n_train = 195, n_test = 49 R^2 train = 0.4819 R^2 test = 0.2762 MAPE test = 0.1676