feat(tradein/estimator): hedonic year+area correction on expected_sold (#2002)

Held-out fit (n=2366 prod deals, 2026-06-27) of
log(actual_sold/expected_sold) ~ year + ln(area) gives a multiplicative
factor that corrects systematic mis-estimation by building era + unit size
(underestimates newer buildings, mis-handles large units).

factor = exp(b0 + b_year*(year-2000)/20 + b_larea*ln(area)), clamped
[0.75, 1.30]; applied to the expected_sold point BEFORE the calibrated PI
range block (so the range follows the corrected point). Headline/asking is
untouched. Gated behind estimate_hedonic_correction_enabled (OFF => exact
old behavior, proven by the regression gate).

Frozen baseline regenerated: overall expected_sold MAPE 18.63->14.24;
per-segment median_bias_pct moves toward 0 (бизнес -21.5->-9.2,
комфорт -8.0->-3.6, эконом 17.2->4.3); элит mildly better, no harm.

Tests that assert the exact ratio relation (expected_sold == headline x
ratio) hold the orthogonal hedonic layer OFF.
This commit is contained in:
bot-backend 2026-06-27 18:45:10 +03:00
parent 9448a945d4
commit 7d9ecb117f
10 changed files with 143 additions and 62 deletions

View file

@ -106,6 +106,21 @@ class Settings(BaseSettings):
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)
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
# Диагноз: estimator систематически промахивается по эре дома + размеру —
# недооценивает новостройки, плохо держит крупные лоты. Held-out fit (n=2366
# прод-сделок, 2026-06-27) регрессии log(actual_sold/expected_sold) ~ year +
# ln(area) даёт мультипликативный фактор, применяемый к expected_sold:
# median-abs-error 18.5%→16.1%; бизнес bias 22%→14% (MAPE 22.3→15.5),
# эконом/комфорт/премиум лучше, элит без изменений (no harm).
# factor = exp(b0 + b_year*(year-2000)/20 + b_larea*ln(area)), clamp [min,max].
# OFF ⇒ точно старое поведение expected_sold.
estimate_hedonic_correction_enabled: bool = True
estimate_hedonic_b0: float = 0.6146 # fit log(sold/es) ~ year + ln(area), n=2366 (#2002)
estimate_hedonic_year_coef: float = 0.1220 # per (year-2000)/20
estimate_hedonic_larea_coef: float = -0.1603 # per ln(area_m2)
estimate_hedonic_factor_min: float = 0.75
estimate_hedonic_factor_max: float = 1.30
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.

View file

@ -2507,6 +2507,30 @@ 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)
# #2002: hedonic year+area correction на ТОЧКУ expected_sold. Fit
# log(actual_sold/expected_sold) ~ year + ln(area) по 2366 прод-сделкам:
# factor = exp(b0 + b_year*(year-2000)/20 + b_larea*ln(area)), clamp [min,max].
# Применяется ДО калиброванного PI-блока ниже, чтобы диапазон считался
# вокруг скорректированной точки. OFF ⇒ точно старое expected_sold.
if (
settings.estimate_hedonic_correction_enabled
and expected_sold_price
and area_m2
and area_m2 > 0
):
_yr = ((target_year - 2000) / 20.0) if target_year else 0.0
_factor = math.exp(
settings.estimate_hedonic_b0
+ settings.estimate_hedonic_year_coef * _yr
+ settings.estimate_hedonic_larea_coef * math.log(area_m2)
)
_factor = max(
settings.estimate_hedonic_factor_min,
min(settings.estimate_hedonic_factor_max, _factor),
)
if expected_sold_per_m2:
expected_sold_per_m2 = round(expected_sold_per_m2 * _factor)
expected_sold_price = round(expected_sold_price * _factor)
if settings.estimate_calibrated_pi_enabled and expected_sold_price:
# #1966: калиброванный ~80% prediction interval вокруг ТОЧКИ expected_sold.
# Эмпирически отношение actual_sold_ppm2 / expected_sold_per_m2 по 2366

View file

@ -7,14 +7,14 @@
"n_covered": 0
},
"low": {
"coverage_pct": 80.0,
"mape_pct": 18.63,
"coverage_pct": 82.18,
"mape_pct": 14.24,
"n": 275,
"n_covered": 220
"n_covered": 226
},
"medium": {
"coverage_pct": 100.0,
"mape_pct": 19.18,
"mape_pct": 11.76,
"n": 2,
"n_covered": 2
}
@ -26,95 +26,95 @@
],
"expected_sold": {
"overall": {
"mape_pct": 18.63,
"median_bias_pct": -1.2,
"mape_pct": 14.24,
"median_bias_pct": -2.62,
"n": 277,
"n_no_analogs": 0,
"p25_pct": -16.25,
"p75_pct": 20.26
"p25_pct": -14.43,
"p75_pct": 13.18
},
"per_rooms": {
"0": {
"label": "студия",
"mape_pct": 27.97,
"median_bias_pct": 27.56,
"mape_pct": 19.38,
"median_bias_pct": 18.1,
"n": 37,
"n_no_analogs": 0,
"p25_pct": -8.97,
"p75_pct": 38.45
"p25_pct": 1.66,
"p75_pct": 33.53
},
"1": {
"label": "1к",
"mape_pct": 19.55,
"median_bias_pct": -8.08,
"mape_pct": 10.97,
"median_bias_pct": -3.47,
"n": 93,
"n_no_analogs": 0,
"p25_pct": -21.27,
"p75_pct": 13.3
"p25_pct": -12.48,
"p75_pct": 6.98
},
"2": {
"label": "2к",
"mape_pct": 16.22,
"median_bias_pct": -8.82,
"mape_pct": 17.39,
"median_bias_pct": -11.71,
"n": 74,
"n_no_analogs": 0,
"p25_pct": -21.01,
"p75_pct": 6.13
"p25_pct": -23.37,
"p75_pct": -0.36
},
"3": {
"label": "3к",
"mape_pct": 10.52,
"median_bias_pct": 4.08,
"mape_pct": 8.41,
"median_bias_pct": 1.72,
"n": 43,
"n_no_analogs": 0,
"p25_pct": -6.54,
"p75_pct": 11.96
"p25_pct": -9.8,
"p75_pct": 7.98
},
"4": {
"label": "4+",
"mape_pct": 23.53,
"median_bias_pct": 14.35,
"mape_pct": 20.27,
"median_bias_pct": 8.86,
"n": 30,
"n_no_analogs": 0,
"p25_pct": -0.72,
"p75_pct": 35.27
"p25_pct": -3.77,
"p75_pct": 23.54
}
},
"per_segment": {
"бизнес": {
"mape_pct": 22.14,
"median_bias_pct": -21.49,
"mape_pct": 12.87,
"median_bias_pct": -9.15,
"n": 46,
"p25_pct": -28.22,
"p75_pct": -9.82
"p25_pct": -21.89,
"p75_pct": -0.74
},
"комфорт": {
"mape_pct": 16.74,
"median_bias_pct": -8.05,
"mape_pct": 11.93,
"median_bias_pct": -3.63,
"n": 104,
"p25_pct": -20.63,
"p75_pct": 7.55
"p25_pct": -15.64,
"p75_pct": 8.13
},
"премиум": {
"mape_pct": 59.37,
"median_bias_pct": -59.37,
"mape_pct": 68.92,
"median_bias_pct": -68.92,
"n": 1,
"p25_pct": -59.37,
"p75_pct": -59.37
"p25_pct": -68.92,
"p75_pct": -68.92
},
"эконом": {
"mape_pct": 18.01,
"median_bias_pct": 17.17,
"mape_pct": 15.58,
"median_bias_pct": 4.28,
"n": 120,
"p25_pct": 1.73,
"p75_pct": 46.96
"p25_pct": -6.11,
"p75_pct": 26.09
},
"элит": {
"mape_pct": 38.62,
"median_bias_pct": -38.62,
"mape_pct": 31.85,
"median_bias_pct": -31.85,
"n": 6,
"p25_pct": -47.98,
"p75_pct": -33.18
"p25_pct": -42.08,
"p75_pct": -22.4
}
}
},
@ -125,9 +125,9 @@
},
"range_coverage": {
"overall": {
"coverage_pct": 80.14,
"coverage_pct": 82.31,
"n": 277,
"n_covered": 222
"n_covered": 228
},
"per_confidence": {
"high": {
@ -136,9 +136,9 @@
"n_covered": 0
},
"low": {
"coverage_pct": 80.0,
"coverage_pct": 82.18,
"n": 275,
"n_covered": 220
"n_covered": 226
},
"medium": {
"coverage_pct": 100.0,

View file

@ -325,7 +325,9 @@ def test_753_dedup_hash_source_id_takes_priority_over_url() -> None:
# ---------------------------------------------------------------------------
def test_773_expected_sold_positive_on_anchor_only_path() -> None:
def test_773_expected_sold_positive_on_anchor_only_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""#773 quality-gate: anchor-only path (radius_analogs=[]) with ratio present
-> expected_sold_price_rub > 0 (not NULL).
@ -334,6 +336,11 @@ def test_773_expected_sold_positive_on_anchor_only_path() -> None:
produced a valid median_price. The fix changed the guard to `and median_price > 0`.
Refs: PR #784 / commit ec84637.
"""
# #2002: asserts expected_sold == headline × ratio; hold the orthogonal hedonic
# year+area correction OFF (OFF ⇒ exact legacy expected_sold).
from app.core.config import settings as _settings
monkeypatch.setattr(_settings, "estimate_hedonic_correction_enabled", False)
est = _run_qa_estimate(
anchor_comps=_SB_COMPS_QG,
anchor_tier="A",

View file

@ -227,12 +227,17 @@ def test_replay_fixture_segments_span_multiple_bands() -> None:
assert len(non_empty) >= 3
def test_replay_is_arg_insensitive_order_based() -> None:
def test_replay_is_arg_insensitive_order_based(monkeypatch: pytest.MonkeyPatch) -> None:
# Order-based (FIFO) replay returns the recorded ratio REGARDLESS of the arg
# value the spine actually computes — so a recorded arg that can never equal
# the live median (999_999.0) still replays cleanly. This is the cross-platform
# robustness contract: a Linux-captured fixture must replay off-Linux even when
# libm last-ulp jitter shifts the computed median_ppm2 by an ulp.
# #2002: this asserts the recorded ratio drives the result (bias -5%). Hold the
# orthogonal hedonic correction OFF so expected_sold stays exactly headline × ratio.
from app.core.config import settings
monkeypatch.setattr(settings, "estimate_hedonic_correction_enabled", False)
fixture = _build_fixture()
fixture["deals"][0]["ratio_calls"] = [[999_999.0, [0.95, "per_rooms_all"]]]
metrics = bt.replay_fixture(fixture)

View file

@ -26,6 +26,7 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
import pytest
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
@ -218,8 +219,13 @@ def _run_estimate(ratio_tuple: tuple[float | None, str | None]):
return anyio.run(_run)
def test_expected_sold_applied_when_ratio_present() -> None:
def test_expected_sold_applied_when_ratio_present(monkeypatch: pytest.MonkeyPatch) -> None:
"""ratio present → expected_sold_* ≈ asking × ratio; headline UNCHANGED."""
# #2002: asserts the ratio mechanism (expected_sold == asking × ratio). Hold the
# orthogonal hedonic year+area correction OFF (OFF ⇒ exact legacy expected_sold).
from app.services import estimator as _est
monkeypatch.setattr(_est.settings, "estimate_hedonic_correction_enabled", False)
ratio = 0.74
est = _run_estimate((ratio, "per_rooms"))
@ -341,9 +347,15 @@ def _run_estimate_anchor_only(
return anyio.run(_run)
def test_expected_sold_fires_on_anchor_only_no_radius_comps() -> None:
def test_expected_sold_fires_on_anchor_only_no_radius_comps(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""#773: listings_clean=[], anchor sets median_price>0, ratio present
expected_sold_price > 0 (old guard `and listings_clean` blocked this)."""
# #2002: asserts expected_sold == headline × ratio; hold hedonic correction OFF.
from app.services import estimator as _est
monkeypatch.setattr(_est.settings, "estimate_hedonic_correction_enabled", False)
ratio = 0.82
est = _run_estimate_anchor_only((ratio, "per_rooms"))

View file

@ -86,6 +86,9 @@ def _run_estimate(
async def _run() -> Any:
with (
patch("app.core.config.settings.estimate_expected_sold_le_asking", new=clamp_enabled),
# #2002: these tests assert the clamp/ratio math exactly. Hold the
# orthogonal hedonic year+area correction OFF (OFF ⇒ legacy expected_sold).
patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False),
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),

View file

@ -362,6 +362,9 @@ def _run_estimate_with_anchor(
async def _run():
with (
# #2002: these tests assert expected_sold == post-blend headline × ratio.
# Hold the orthogonal hedonic correction OFF (OFF ⇒ legacy expected_sold).
patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False),
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),

View file

@ -152,8 +152,11 @@ def _call(
# ── Tests ────────────────────────────────────────────────────────────────────
def test_radius_only_median_and_expected_sold() -> None:
def test_radius_only_median_and_expected_sold(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pure radius path: 7 uniform lots → correct median, n_analogs, expected_sold."""
# #2002: this asserts the ratio mechanism (expected_sold == headline × ratio).
# Hold the orthogonal hedonic correction OFF (OFF ⇒ exact legacy expected_sold).
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
pr = _call(listings=_lots(100_000, n=7), ratio=0.95)
assert pr.median_price == int(100_000 * 50.0) # 5_000_000
@ -365,8 +368,13 @@ def test_corridor_soft_clamp_headline_above_cap() -> None:
assert pr.dkp_corridor.high_ppm2 == 150_000
def test_expected_sold_from_ratio_and_none_when_ratio_none() -> None:
def test_expected_sold_from_ratio_and_none_when_ratio_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""expected_sold = headline × ratio; when ratio is None, all expected_sold fields None."""
# #2002: ratio-mechanism test — hold the orthogonal hedonic correction OFF
# so expected_sold == headline × ratio exactly (OFF ⇒ legacy behavior).
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
# Case A: ratio=0.90 → expected_sold fields filled.
pr_ratio = _call(listings=_lots(100_000, n=5), ratio=0.90)
assert pr_ratio.asking_to_sold_ratio == 0.90

View file

@ -556,10 +556,15 @@ def test_estimate_expected_sold_distinct_after_anchor() -> None:
"""(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а
expected_sold = headline × per-rooms ratio DISTINCT, строго ниже median.
Single askingsold механизм (ratio); band-haircut больше не в headline."""
# #2002: asserts expected_sold == post-anchor headline × ratio. Hold the
# orthogonal hedonic year+area correction OFF (OFF ⇒ legacy expected_sold).
from app.core.config import settings
ratio = 0.92
est = _run_estimate(
anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", ratio_tuple=(ratio, "per_rooms")
)
with patch.object(settings, "estimate_hedonic_correction_enabled", False):
est = _run_estimate(
anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", ratio_tuple=(ratio, "per_rooms")
)
# expected_sold выведен из POST-anchor headline × ratio (не равен headline).
# per_m2 берётся от внутренней float-медианы (схема отдаёт int(median_ppm2)),
# поэтому сравниваем с допуском ±1 на округление float→int.
@ -567,7 +572,6 @@ def test_estimate_expected_sold_distinct_after_anchor() -> None:
assert abs(est.expected_sold_per_m2 - round(est.median_price_per_m2 * ratio)) <= 1
# #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