feat(tradein/estimator): calibrated 80% prediction interval for expected_sold range (#1966)
The expected_sold_range_low/high were derived from the analog asking-IQR (× asking→sold ratio): only ~55% of actual sold prices fell inside the stated range — a 50%-ish interval mislabeled as the estimate's range. Replace with a calibrated band around the expected_sold POINT, gated behind estimate_calibrated_pi_enabled (default ON): range_low = expected_sold_price × 0.649 (empirical p10 of sold/expected_sold) range_high = expected_sold_price × 1.392 (empirical p90, n=2366 prod deals) This is a genuine ~80% prediction interval (verified 80.0% coverage on the 2366-deal sample). Headline asking range_low/high (market-asking IQR) untouched. Frozen backtest baseline regenerated: range_coverage.overall 55.23 → 80.14 (n_covered 153 → 222); expected_sold POINT mape/bias and headline unchanged (only the band moved). Tests asserting the old asking-IQR × ratio band updated to the calibrated band (documented behavior change).
This commit is contained in:
parent
40b7ca4242
commit
ed4335b932
7 changed files with 103 additions and 29 deletions
|
|
@ -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²)×(1−tol)
|
||||
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > 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%.
|
||||
|
|
|
|||
|
|
@ -2507,6 +2507,17 @@ 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)
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue