feat(tradein/estimator): calibrated 80% prediction interval for expected_sold range (#1966) #2001

Merged
lekss361 merged 1 commit from feat/tradein-calibrated-expected-sold-pi into main 2026-06-27 14:01:25 +00:00
7 changed files with 103 additions and 29 deletions

View file

@ -96,6 +96,16 @@ class Settings(BaseSettings):
estimate_sb_floor_sigma: float = 0.25
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1tol)
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2median| > k×MAD
# ── #1966: honest calibrated prediction-interval для expected_sold range ─────
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
# интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold
# по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band
# expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval
# (проверено: 80.0% coverage на тех же 2366). OFF ⇒ точно старое IQR-поведение.
estimate_calibrated_pi_enabled: bool = True
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.

View file

@ -2507,8 +2507,19 @@ def _price_from_inputs(
effective_ratio = 1.0
expected_sold_per_m2 = round(median_ppm2 * effective_ratio)
expected_sold_price = round(median_price * effective_ratio)
expected_sold_range_low = round(range_low * effective_ratio)
expected_sold_range_high = round(range_high * effective_ratio)
if settings.estimate_calibrated_pi_enabled and expected_sold_price:
# #1966: калиброванный ~80% prediction interval вокруг ТОЧКИ expected_sold.
# Эмпирически отношение actual_sold_ppm2 / expected_sold_per_m2 по 2366
# прод-сделкам: p10=0.649, p90=1.392 → band [×low_mult, ×high_mult] даёт
# ~80% honest coverage. Старый IQR-производный band (asking-IQR × ratio)
# покрывал лишь ~55% реальных продаж при заявленном «диапазоне оценки».
# Множители low<1<high (дефолты) гарантируют low ≤ point ≤ high.
expected_sold_range_low = round(expected_sold_price * settings.estimate_pi_low_mult)
expected_sold_range_high = round(expected_sold_price * settings.estimate_pi_high_mult)
else:
# legacy: диапазон производный от IQR аналогов (asking-IQR × ratio).
expected_sold_range_low = round(range_low * effective_ratio)
expected_sold_range_high = round(range_high * effective_ratio)
# ── #652: ДКП-коридор реальных сделок (advisory) ─────────────────────────
dkp_corridor: DkpCorridor | None = None

View file

@ -7,16 +7,16 @@
"n_covered": 0
},
"low": {
"coverage_pct": 55.27,
"coverage_pct": 80.0,
"mape_pct": 18.63,
"n": 275,
"n_covered": 152
"n_covered": 220
},
"medium": {
"coverage_pct": 50.0,
"coverage_pct": 100.0,
"mape_pct": 19.18,
"n": 2,
"n_covered": 1
"n_covered": 2
}
},
"confidence_order": [
@ -125,9 +125,9 @@
},
"range_coverage": {
"overall": {
"coverage_pct": 55.23,
"coverage_pct": 80.14,
"n": 277,
"n_covered": 153
"n_covered": 222
},
"per_confidence": {
"high": {
@ -136,19 +136,19 @@
"n_covered": 0
},
"low": {
"coverage_pct": 55.27,
"coverage_pct": 80.0,
"n": 275,
"n_covered": 152
"n_covered": 220
},
"medium": {
"coverage_pct": 50.0,
"coverage_pct": 100.0,
"n": 2,
"n_covered": 1
"n_covered": 2
}
}
},
"sharpness": {
"median_rel_width": 0.4638,
"median_rel_width": 0.743,
"n": 277
}
}

View file

@ -228,11 +228,24 @@ def test_expected_sold_applied_when_ratio_present() -> None:
assert est.median_price_rub == expected_headline
assert est.median_price_per_m2 == 150_000
# Parallel expected_sold = asking × ratio (round()).
# Parallel expected_sold point = asking × ratio (round()).
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio)
assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio)
# #1966: expected_sold range is now a calibrated ~80% PI around the POINT
# (point × [p10, p90] of sold/expected_sold), not the old asking-IQR × ratio band.
from app.core.config import settings
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
assert (
est.expected_sold_range_low_rub
<= est.expected_sold_price_rub
<= est.expected_sold_range_high_rub
)
assert est.asking_to_sold_ratio == ratio
assert est.ratio_basis == "per_rooms"
@ -342,8 +355,15 @@ def test_expected_sold_fires_on_anchor_only_no_radius_comps() -> None:
assert est.expected_sold_price_rub > 0
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio)
assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio)
# #1966: calibrated ~80% PI around the expected_sold point (not asking-IQR × ratio).
from app.core.config import settings
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
def test_expected_sold_none_when_median_price_zero() -> None:

View file

@ -133,14 +133,25 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1(
assert (
est.expected_sold_per_m2 <= est.median_price_per_m2
), "expected_sold_per_m2 превышает median_price_per_m2"
# #1966: expected_sold range is now a calibrated ~80% PI around the point
# (point × [p10, p90] of sold/expected_sold), so it is NO LONGER bounded by the
# asking-IQR band — the high arm (point × 1.392) legitimately exceeds range_high.
# The invariant we keep is the calibrated band itself + ordering low ≤ point ≤ high.
from app.core.config import settings
assert est.expected_sold_range_high_rub is not None
assert (
est.expected_sold_range_high_rub <= est.range_high_rub
), "expected_sold_range_high > range_high"
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
assert est.expected_sold_range_low_rub is not None
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
assert (
est.expected_sold_range_low_rub <= est.range_low_rub
), "expected_sold_range_low > range_low"
est.expected_sold_range_low_rub
<= est.expected_sold_price_rub
<= est.expected_sold_range_high_rub
)
assert est.expected_sold_price_rub == est.median_price_rub
assert est.expected_sold_per_m2 == est.median_price_per_m2
clamp_logs = [r for r in caplog.records if "clamped" in r.getMessage()]
@ -154,8 +165,15 @@ def test_expected_sold_not_clamped_when_ratio_below_1() -> None:
assert est.median_price_rub > 0
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio)
assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio)
# #1966: calibrated ~80% PI around the expected_sold point (not asking-IQR × ratio).
from app.core.config import settings
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
assert est.expected_sold_price_rub < est.median_price_rub

View file

@ -413,8 +413,15 @@ def test_expected_sold_consistent_with_blended_median() -> None:
# expected_sold выведен из POST-blend значений — no stale.
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio)
assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio)
# #1966: expected_sold range — калиброванный ~80% PI вокруг точки, не asking-IQR × ratio.
from app.core.config import settings
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
# Sanity: sold < asking (ratio<1), но НЕ абсурдная «скидка» от stale 6М-базы.
assert est.expected_sold_price_rub == round(12_000_000 * ratio)

View file

@ -565,8 +565,16 @@ def test_estimate_expected_sold_distinct_after_anchor() -> None:
# поэтому сравниваем с допуском ±1 на округление float→int.
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
assert abs(est.expected_sold_per_m2 - round(est.median_price_per_m2 * ratio)) <= 1
assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio)
assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio)
# #1966: expected_sold range — калиброванный ~80% PI вокруг точки (point × [p10,p90]
# sold/expected_sold), не asking-IQR × ratio.
from app.core.config import settings
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
# Distinct: sold строго ниже asking (ratio < 1) — фикс двойных идентичных чисел.
assert est.expected_sold_price_rub < est.median_price_rub
assert est.expected_sold_per_m2 < est.median_price_per_m2