feat(tradein/estimator): calibrated 80% prediction interval for expected_sold range (#1966)
All checks were successful
Deploy Trade-In / test (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 47s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 51s
All checks were successful
Deploy Trade-In / test (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 47s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 51s
This commit is contained in:
parent
40b7ca4242
commit
9448a945d4
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_floor_sigma: float = 0.25
|
||||||
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol)
|
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
|
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 фикса, каждый за флагом) ──────
|
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
|
||||||
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
|
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
|
||||||
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.
|
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.
|
||||||
|
|
|
||||||
|
|
@ -2507,8 +2507,19 @@ def _price_from_inputs(
|
||||||
effective_ratio = 1.0
|
effective_ratio = 1.0
|
||||||
expected_sold_per_m2 = round(median_ppm2 * effective_ratio)
|
expected_sold_per_m2 = round(median_ppm2 * effective_ratio)
|
||||||
expected_sold_price = round(median_price * effective_ratio)
|
expected_sold_price = round(median_price * effective_ratio)
|
||||||
expected_sold_range_low = round(range_low * effective_ratio)
|
if settings.estimate_calibrated_pi_enabled and expected_sold_price:
|
||||||
expected_sold_range_high = round(range_high * effective_ratio)
|
# #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) ─────────────────────────
|
# ── #652: ДКП-коридор реальных сделок (advisory) ─────────────────────────
|
||||||
dkp_corridor: DkpCorridor | None = None
|
dkp_corridor: DkpCorridor | None = None
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,16 @@
|
||||||
"n_covered": 0
|
"n_covered": 0
|
||||||
},
|
},
|
||||||
"low": {
|
"low": {
|
||||||
"coverage_pct": 55.27,
|
"coverage_pct": 80.0,
|
||||||
"mape_pct": 18.63,
|
"mape_pct": 18.63,
|
||||||
"n": 275,
|
"n": 275,
|
||||||
"n_covered": 152
|
"n_covered": 220
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"coverage_pct": 50.0,
|
"coverage_pct": 100.0,
|
||||||
"mape_pct": 19.18,
|
"mape_pct": 19.18,
|
||||||
"n": 2,
|
"n": 2,
|
||||||
"n_covered": 1
|
"n_covered": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"confidence_order": [
|
"confidence_order": [
|
||||||
|
|
@ -125,9 +125,9 @@
|
||||||
},
|
},
|
||||||
"range_coverage": {
|
"range_coverage": {
|
||||||
"overall": {
|
"overall": {
|
||||||
"coverage_pct": 55.23,
|
"coverage_pct": 80.14,
|
||||||
"n": 277,
|
"n": 277,
|
||||||
"n_covered": 153
|
"n_covered": 222
|
||||||
},
|
},
|
||||||
"per_confidence": {
|
"per_confidence": {
|
||||||
"high": {
|
"high": {
|
||||||
|
|
@ -136,19 +136,19 @@
|
||||||
"n_covered": 0
|
"n_covered": 0
|
||||||
},
|
},
|
||||||
"low": {
|
"low": {
|
||||||
"coverage_pct": 55.27,
|
"coverage_pct": 80.0,
|
||||||
"n": 275,
|
"n": 275,
|
||||||
"n_covered": 152
|
"n_covered": 220
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"coverage_pct": 50.0,
|
"coverage_pct": 100.0,
|
||||||
"n": 2,
|
"n": 2,
|
||||||
"n_covered": 1
|
"n_covered": 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sharpness": {
|
"sharpness": {
|
||||||
"median_rel_width": 0.4638,
|
"median_rel_width": 0.743,
|
||||||
"n": 277
|
"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_rub == expected_headline
|
||||||
assert est.median_price_per_m2 == 150_000
|
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_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_per_m2 == round(est.median_price_per_m2 * ratio)
|
||||||
assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio)
|
# #1966: expected_sold range is now a calibrated ~80% PI around the POINT
|
||||||
assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio)
|
# (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.asking_to_sold_ratio == ratio
|
||||||
assert est.ratio_basis == "per_rooms"
|
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 > 0
|
||||||
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
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_per_m2 == round(est.median_price_per_m2 * ratio)
|
||||||
assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio)
|
# #1966: calibrated ~80% PI around the expected_sold point (not asking-IQR × ratio).
|
||||||
assert est.expected_sold_range_high_rub == round(est.range_high_rub * 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:
|
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 (
|
assert (
|
||||||
est.expected_sold_per_m2 <= est.median_price_per_m2
|
est.expected_sold_per_m2 <= est.median_price_per_m2
|
||||||
), "expected_sold_per_m2 превышает 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 is not None
|
||||||
assert (
|
assert est.expected_sold_range_high_rub == round(
|
||||||
est.expected_sold_range_high_rub <= est.range_high_rub
|
est.expected_sold_price_rub * settings.estimate_pi_high_mult
|
||||||
), "expected_sold_range_high > range_high"
|
)
|
||||||
assert est.expected_sold_range_low_rub is not None
|
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 (
|
assert (
|
||||||
est.expected_sold_range_low_rub <= est.range_low_rub
|
est.expected_sold_range_low_rub
|
||||||
), "expected_sold_range_low > range_low"
|
<= 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_price_rub == est.median_price_rub
|
||||||
assert est.expected_sold_per_m2 == est.median_price_per_m2
|
assert est.expected_sold_per_m2 == est.median_price_per_m2
|
||||||
clamp_logs = [r for r in caplog.records if "clamped" in r.getMessage()]
|
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.median_price_rub > 0
|
||||||
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
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_per_m2 == round(est.median_price_per_m2 * ratio)
|
||||||
assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio)
|
# #1966: calibrated ~80% PI around the expected_sold point (not asking-IQR × ratio).
|
||||||
assert est.expected_sold_range_high_rub == round(est.range_high_rub * 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
|
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.
|
# expected_sold выведен из POST-blend значений — no stale.
|
||||||
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
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_per_m2 == round(est.median_price_per_m2 * ratio)
|
||||||
assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio)
|
# #1966: expected_sold range — калиброванный ~80% PI вокруг точки, не asking-IQR × ratio.
|
||||||
assert est.expected_sold_range_low_rub == round(est.range_low_rub * 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М-базы.
|
# Sanity: sold < asking (ratio<1), но НЕ абсурдная «скидка» от stale 6М-базы.
|
||||||
assert est.expected_sold_price_rub == round(12_000_000 * ratio)
|
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.
|
# поэтому сравниваем с допуском ±1 на округление float→int.
|
||||||
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
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 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)
|
# #1966: expected_sold range — калиброванный ~80% PI вокруг точки (point × [p10,p90]
|
||||||
assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio)
|
# 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) — фикс двойных идентичных чисел.
|
# Distinct: sold строго ниже asking (ratio < 1) — фикс двойных идентичных чисел.
|
||||||
assert est.expected_sold_price_rub < est.median_price_rub
|
assert est.expected_sold_price_rub < est.median_price_rub
|
||||||
assert est.expected_sold_per_m2 < est.median_price_per_m2
|
assert est.expected_sold_per_m2 < est.median_price_per_m2
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue