Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions openavmkit/vertical_equity_study.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ def get_vertical_equity_scores(df, sale_field: str, valuation_field: str) -> Dic
"group_stats": None
}

# Guard against a degenerate market proxy. When the valuation is all-zero or
# all-NaN (e.g. a tax-exempt model group with $0 assessed values), median_ratio
# collapses to 0 or NaN and market_proxy becomes non-finite; likewise a proxy
# with fewer distinct values than requested percentile groups makes pd.qcut
# raise "Bin edges must be unique". Degrade to NaN -- as we already do for
# fewer than 20 observations -- rather than crashing the whole modeling run.
market_proxy_finite = (
df["market_proxy"].replace([np.inf, -np.inf], np.nan).dropna()
)
if (
not np.isfinite(median_ratio)
or median_ratio == 0
or market_proxy_finite.nunique() < 2
):
return {
"vei": np.nan,
"vei_significance": np.nan,
"group_stats": None
}

def ci_90_lower(x):
if len(x) <= 1: return np.nan
# 90% CI uses the 95th percentile for a two-tailed test
Expand All @@ -47,18 +67,33 @@ def ci_90_upper(x):
if len(x) <= 1: return np.nan
return np.mean(x) + stats.sem(x) * stats.t.ppf(0.95, df=len(x) - 1)

#split the df into percentile_group_count groups based on the market proxy
df["percentile_group"] = pd.qcut(df["market_proxy"], q=percentile_group_count, labels=False)
# Split the df into value tiers by the market proxy. duplicates="drop"
# tolerates concentrated values (repeated quantile edges) instead of raising;
# it can yield fewer tiers than requested, so we index the actual top/bottom
# tiers below rather than assuming labels 0..percentile_group_count-1.
df["percentile_group"] = pd.qcut(
df["market_proxy"], q=percentile_group_count, labels=False,
duplicates="drop"
)
grouped = df.groupby("percentile_group")
group_stats = grouped['ratio'].agg(
ratio='median',
lower=ci_90_lower,
upper=ci_90_upper
)
#VEI is 100 * (median of last percentile grop - median of first percentile group)/median_ratio
vei_score = 100 * (group_stats[group_stats.index == (percentile_group_count - 1)].iloc[0]["ratio"] - group_stats[group_stats.index == 0].iloc[0]["ratio"]) / median_ratio
# VEI significance = 100 * (Lower CI for Highest PG - Upper CI for Lowest PG)/median
vei_significance = 100 * (group_stats[group_stats.index == (percentile_group_count -1)].iloc[0]["lower"] - group_stats[group_stats.index == 0].iloc[0]["upper"]) / median_ratio
if len(group_stats) < 2:
# need at least a bottom and top tier to measure vertical equity
return {
"vei": np.nan,
"vei_significance": np.nan,
"group_stats": None
}
bottom = group_stats.index.min()
top = group_stats.index.max()
# VEI is 100 * (top-tier median ratio - bottom-tier median ratio) / median_ratio
vei_score = 100 * (group_stats.loc[top, "ratio"] - group_stats.loc[bottom, "ratio"]) / median_ratio
# VEI significance = 100 * (Lower CI for top tier - Upper CI for bottom tier) / median_ratio
vei_significance = 100 * (group_stats.loc[top, "lower"] - group_stats.loc[bottom, "upper"]) / median_ratio
return {
"vei": vei_score,
"vei_significance": vei_significance,
Expand Down
76 changes: 76 additions & 0 deletions tests/test_vertical_equity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Tests for openavmkit.vertical_equity_study.get_vertical_equity_scores.

Regression tests for a crash when the VEI "market proxy" is degenerate: with
>= 20 observations (so the small-sample guard doesn't apply) but a market proxy
that is all-NaN or has fewer distinct values than requested percentile groups,
``pd.qcut`` raised ``ValueError: Bin edges must be unique``, aborting the whole
modeling run. The function should instead degrade gracefully to a NaN result,
exactly like it already does for < 20 observations.
"""
import numpy as np
import pandas as pd

from openavmkit.vertical_equity_study import get_vertical_equity_scores


def test_all_zero_valuation_returns_nan_not_crash():
# Reproduces the real trigger: a tax-exempt model group whose assessed
# (valuation) values are all $0. Then median_ratio == 0, so
# market_proxy = sale*0.5 + valuation/0 = NaN for every row, and qcut on an
# all-NaN column raises. With >= 20 rows the small-sample guard doesn't fire.
rng = np.random.default_rng(0)
df = pd.DataFrame({
"sale": rng.uniform(50_000, 500_000, size=40),
"valuation": np.zeros(40),
})

result = get_vertical_equity_scores(df, "sale", "valuation")

assert np.isnan(result["vei"])
assert np.isnan(result["vei_significance"])


def test_constant_market_proxy_returns_nan_not_crash():
# Fewer distinct market-proxy values than requested percentile groups also
# makes qcut raise "Bin edges must be unique". Constant sale + valuation is
# the extreme case (one distinct value).
df = pd.DataFrame({
"sale": np.full(30, 100_000.0),
"valuation": np.full(30, 90_000.0),
})

result = get_vertical_equity_scores(df, "sale", "valuation")

assert np.isnan(result["vei"])


def test_concentrated_values_do_not_crash():
# Even with many distinct values, a heavy concentration at one value makes
# several quantile boundaries land on the same edge, so plain qcut(q=N) still
# raises "Bin edges must be unique". This is common in assessment data (capped
# or round-number values). Should degrade gracefully, not crash.
sale = np.concatenate([np.full(70, 100_000.0),
np.linspace(120_000, 900_000, 30)])
df = pd.DataFrame({"sale": sale, "valuation": sale * 0.9})

result = get_vertical_equity_scores(df, "sale", "valuation")

# Either a finite VEI (from the tiers that could be formed) or NaN, but no raise.
assert np.isnan(result["vei"]) or np.isfinite(result["vei"])


def test_healthy_data_still_returns_finite_vei():
# Guard must not disturb the happy path: well-spread values with a real
# regressive tilt should yield a finite VEI and per-group stats.
rng = np.random.default_rng(42)
true_value = rng.uniform(50_000, 500_000, size=200)
sale = true_value * rng.uniform(0.95, 1.05, size=200)
# regressive tilt: low-value assessed high, high-value assessed low
tilt = (true_value - true_value.min()) / (true_value.max() - true_value.min())
valuation = true_value * (1.15 - 0.3 * tilt)
df = pd.DataFrame({"sale": sale, "valuation": valuation})

result = get_vertical_equity_scores(df, "sale", "valuation")

assert np.isfinite(result["vei"])
assert result["group_stats"] is not None
Loading