fix(tradein/estimator): hedonic respects le_asking + hedonic unit test (#2002)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped

Review found the hedonic factor (≤1.30) was applied AFTER the
estimate_expected_sold_le_asking clamp, so expected_sold could exceed the
asking headline (median) by up to ~26% for new/small lots — an overpay risk
that violated the default-True le_asking invariant.

Fix: re-assert le_asking right after the hedonic block and before the
calibrated-PI range block. Final order: ratio clamp -> hedonic factor ->
le_asking re-clamp -> calibrated PI range (range still wraps the clamped
point). round(median_ppm2) keeps expected_sold_per_m2 an int.

The re-clamp does NOT materially erode the новостройка/бизнес benefit
(бизнес bias -9.15->-10.19, MAPE held 12.87) and actually improves overall
expected_sold MAPE on the frozen fixture (14.24->13.23) by capping
over-corrections that overshot actual sold prices.

Adds tests/test_estimator_hedonic.py (hermetic, no DB): factor magnitude vs
OFF, both clamp boundaries, neutral year term for target_year=None, and the
le_asking invariant under hedonic ON. Baseline regenerated; gate green.

Also clarifies the config comment: the quoted held-out fit metrics
(18.5->16.1 etc., n=2366) are distinct from the 277-deal backtest fixture
(18.63->13.23).
This commit is contained in:
bot-backend 2026-06-27 19:03:01 +03:00
parent 7d9ecb117f
commit f999011b30
4 changed files with 235 additions and 31 deletions

View file

@ -110,10 +110,14 @@ class Settings(BaseSettings):
# Диагноз: 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).
# ln(area) даёт мультипликативный фактор, применяемый к expected_sold.
# factor = exp(b0 + b_year*(year-2000)/20 + b_larea*ln(area)), clamp [min,max].
# ВАЖНО: цифры ниже — метрики ТОГО ЖЕ 2366-сделочного held-out FIT, а НЕ
# 277-сделочного frozen backtest-фикстура, на котором гоняется regression-gate
# (там overall expected_sold MAPE 18.63→14.24):
# held-out median-abs-error 18.5%→16.1%; бизнес bias 22%→14% (MAPE 22.3→15.5),
# эконом/комфорт/премиум лучше, элит без изменений (no harm).
# После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
# 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)

View file

@ -2531,6 +2531,15 @@ def _price_from_inputs(
if expected_sold_per_m2:
expected_sold_per_m2 = round(expected_sold_per_m2 * _factor)
expected_sold_price = round(expected_sold_price * _factor)
# #2002: re-assert the le_asking invariant — the hedonic factor (≤1.30) can
# push expected_sold above the asking headline (median) for new/small lots,
# which is an overpay risk for trade-in. Re-clamp the corrected point back to
# the headline so the calibrated PI range below wraps the clamped point.
# round(median_ppm2) keeps expected_sold_per_m2 an int (median_ppm2 is float).
if settings.estimate_expected_sold_le_asking and expected_sold_price:
expected_sold_price = min(expected_sold_price, median_price)
if expected_sold_per_m2:
expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2))
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": 82.18,
"mape_pct": 14.24,
"coverage_pct": 81.82,
"mape_pct": 13.23,
"n": 275,
"n_covered": 226
"n_covered": 225
},
"medium": {
"coverage_pct": 100.0,
"mape_pct": 11.76,
"mape_pct": 14.64,
"n": 2,
"n_covered": 2
}
@ -26,12 +26,12 @@
],
"expected_sold": {
"overall": {
"mape_pct": 14.24,
"median_bias_pct": -2.62,
"mape_pct": 13.23,
"median_bias_pct": -2.87,
"n": 277,
"n_no_analogs": 0,
"p25_pct": -14.43,
"p75_pct": 13.18
"p75_pct": 12.51
},
"per_rooms": {
"0": {
@ -63,37 +63,37 @@
},
"3": {
"label": "3к",
"mape_pct": 8.41,
"median_bias_pct": 1.72,
"mape_pct": 9.77,
"median_bias_pct": -3.08,
"n": 43,
"n_no_analogs": 0,
"p25_pct": -9.8,
"p75_pct": 7.98
"p25_pct": -10.26,
"p75_pct": 5.77
},
"4": {
"label": "4+",
"mape_pct": 20.27,
"median_bias_pct": 8.86,
"median_bias_pct": 8.54,
"n": 30,
"n_no_analogs": 0,
"p25_pct": -3.77,
"p25_pct": -3.78,
"p75_pct": 23.54
}
},
"per_segment": {
"бизнес": {
"mape_pct": 12.87,
"median_bias_pct": -9.15,
"median_bias_pct": -10.19,
"n": 46,
"p25_pct": -21.89,
"p75_pct": -0.74
"p25_pct": -22.6,
"p75_pct": -1.31
},
"комфорт": {
"mape_pct": 11.93,
"median_bias_pct": -3.63,
"mape_pct": 11.61,
"median_bias_pct": -4.07,
"n": 104,
"p25_pct": -15.64,
"p75_pct": 8.13
"p75_pct": 7.38
},
"премиум": {
"mape_pct": 68.92,
@ -103,17 +103,17 @@
"p75_pct": -68.92
},
"эконом": {
"mape_pct": 15.58,
"mape_pct": 15.17,
"median_bias_pct": 4.28,
"n": 120,
"p25_pct": -6.11,
"p25_pct": -6.59,
"p75_pct": 26.09
},
"элит": {
"mape_pct": 31.85,
"median_bias_pct": -31.85,
"mape_pct": 33.2,
"median_bias_pct": -33.2,
"n": 6,
"p25_pct": -42.08,
"p25_pct": -42.75,
"p75_pct": -22.4
}
}
@ -125,9 +125,9 @@
},
"range_coverage": {
"overall": {
"coverage_pct": 82.31,
"coverage_pct": 81.95,
"n": 277,
"n_covered": 228
"n_covered": 227
},
"per_confidence": {
"high": {
@ -136,9 +136,9 @@
"n_covered": 0
},
"low": {
"coverage_pct": 82.18,
"coverage_pct": 81.82,
"n": 275,
"n_covered": 226
"n_covered": 225
},
"medium": {
"coverage_pct": 100.0,

View file

@ -0,0 +1,191 @@
"""Focused hedonic year+area correction tests (#2002).
Exercises the multiplicative hedonic factor on the expected_sold POINT directly
via ``_price_from_inputs`` (hermetic no DB, no network). Verifies:
* the factor magnitude vs the hedonic-OFF baseline (mid case);
* both clamp boundaries ( factor_min via huge area, factor_max via small+new);
* the neutral year term when ``target_year`` is None ( year 2000);
* the le_asking invariant the corrected expected_sold never exceeds the asking
headline (median) when ``estimate_expected_sold_le_asking`` is on.
NOTE: importing app.services.estimator pulls app.core.config.Settings which
requires DATABASE_URL. Set it BEFORE importing app modules.
"""
from __future__ import annotations
import math
import os
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import estimator
from app.services.geocoder import GeocodeResult
# ── helpers ──────────────────────────────────────────────────────────────────
def _geo() -> GeocodeResult:
return GeocodeResult(
lat=56.838,
lon=60.597,
full_address="ул. Тестовая, 1",
provider="nominatim",
confidence="approximate",
)
def _lots(ppm2: float, n: int = 7) -> list[dict]:
"""n unique-address lots all at the same ₽/m² → median_ppm2 == ppm2."""
return [
{"price_per_m2": ppm2, "address": f"ул. Тестовая, {i + 1}", "source": "avito"}
for i in range(n)
]
def _price(
*,
area_m2: float,
target_year: int | None,
ratio: float,
ppm2: float = 100_000.0,
) -> estimator.PricingResult:
"""Pure radius-only spine call (no anchor / dkp / imv) with a forced ratio."""
def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]:
return ratio, "per_rooms"
return estimator._price_from_inputs(
listings=_lots(ppm2),
area_m2=area_m2,
rooms=2,
repair_state=None,
floor=5,
total_floors=10,
target_year=target_year,
analog_tier="W",
fallback_used=False,
area_widened=False,
anchor_comps=[],
anchor_tier_fetched=None,
dkp_raw=None,
imv_anchor=None,
imv_eval=None,
yandex_val_present=False,
cian_val_present=False,
ratio_resolver=ratio_resolver,
quarter_index_lookup=lambda q: None,
quarter_indexes_lookup=lambda qs: {},
target_house_cadnum=None,
dadata_coarse=False,
geo=_geo(),
dadata_qc_geo=None,
)
def _expected_factor(area_m2: float, target_year: int | None) -> float:
"""Reproduce the production factor from the live settings (no hard-coding)."""
s = estimator.settings
yr = ((target_year - 2000) / 20.0) if target_year else 0.0
raw = math.exp(
s.estimate_hedonic_b0
+ s.estimate_hedonic_year_coef * yr
+ s.estimate_hedonic_larea_coef * math.log(area_m2)
)
return max(s.estimate_hedonic_factor_min, min(s.estimate_hedonic_factor_max, raw))
# ── tests ────────────────────────────────────────────────────────────────────
def test_mid_case_shifts_by_expected_factor(monkeypatch: pytest.MonkeyPatch) -> None:
"""year≈2010, area≈50 → expected_sold shifts by the hedonic factor vs OFF."""
# OFF baseline (exact legacy expected_sold).
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
off = _price(area_m2=50.0, target_year=2010, ratio=0.85)
# ON.
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
on = _price(area_m2=50.0, target_year=2010, ratio=0.85)
factor = _expected_factor(50.0, 2010)
# 2010 + 50 m² → mild uplift, strictly inside the clamp band.
assert 1.0 < factor < estimator.settings.estimate_hedonic_factor_max
assert off.expected_sold_price is not None and on.expected_sold_price is not None
# ratio 0.85 × factor < 1.0 → le_asking re-clamp is a no-op here (no confound).
assert on.expected_sold_price == round(off.expected_sold_price * factor)
assert on.expected_sold_per_m2 == round(off.expected_sold_per_m2 * factor)
def test_factor_clamps_to_min_for_huge_area(monkeypatch: pytest.MonkeyPatch) -> None:
"""Very large area → raw factor < factor_min → clamped to the floor."""
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
off = _price(area_m2=10_000.0, target_year=None, ratio=0.85)
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
on = _price(area_m2=10_000.0, target_year=None, ratio=0.85)
factor = _expected_factor(10_000.0, None)
assert factor == estimator.settings.estimate_hedonic_factor_min
assert off.expected_sold_price is not None and on.expected_sold_price is not None
assert on.expected_sold_price == round(
off.expected_sold_price * estimator.settings.estimate_hedonic_factor_min
)
def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch) -> None:
"""Small area + new year → raw factor > factor_max → clamped to the ceiling.
le_asking is held OFF so the raw ceiling factor is observable on the point
(otherwise the re-clamp would cap it at the asking headline).
"""
monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False)
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
off = _price(area_m2=15.0, target_year=2025, ratio=0.85)
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
on = _price(area_m2=15.0, target_year=2025, ratio=0.85)
factor = _expected_factor(15.0, 2025)
assert factor == estimator.settings.estimate_hedonic_factor_max
assert off.expected_sold_price is not None and on.expected_sold_price is not None
assert on.expected_sold_price == round(
off.expected_sold_price * estimator.settings.estimate_hedonic_factor_max
)
def test_target_year_none_is_neutral(monkeypatch: pytest.MonkeyPatch) -> None:
"""target_year=None → year term is 0 → identical to year 2000 (intercept+area)."""
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
none_year = _price(area_m2=50.0, target_year=None, ratio=0.85)
year_2000 = _price(area_m2=50.0, target_year=2000, ratio=0.85)
assert none_year.expected_sold_price == year_2000.expected_sold_price
assert none_year.expected_sold_per_m2 == year_2000.expected_sold_per_m2
assert _expected_factor(50.0, None) == _expected_factor(50.0, 2000)
def test_le_asking_invariant_holds_under_hedonic(monkeypatch: pytest.MonkeyPatch) -> None:
"""With le_asking on, the hedonic-corrected expected_sold never exceeds asking."""
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", True)
# small area + new year → factor 1.30; ratio 0.95 → 0.95×1.30 ≈ 1.235 > 1 →
# uncorrected the point would exceed the asking headline; the re-clamp must bind.
res = _price(area_m2=15.0, target_year=2025, ratio=0.95)
assert res.expected_sold_price is not None
assert res.expected_sold_price <= res.median_price
assert res.expected_sold_per_m2 is not None
assert res.expected_sold_per_m2 <= res.median_ppm2
# The clamp binds exactly at the asking headline (proves it actually fired).
assert res.expected_sold_price == res.median_price
def test_le_asking_off_allows_hedonic_above_asking(monkeypatch: pytest.MonkeyPatch) -> None:
"""Control: with le_asking OFF, the hedonic uplift may exceed asking (no clamp)."""
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False)
res = _price(area_m2=15.0, target_year=2025, ratio=0.95)
assert res.expected_sold_price is not None
# 0.95 × 1.30 ≈ 1.235 → point is allowed above the asking headline.
assert res.expected_sold_price > res.median_price