From 1c039b358cd9fb3ae9954433e3eb48e4fc440428 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Wed, 16 Sep 2026 19:08:49 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=BD=D0=BE=D0=B6=D0=B8=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=B4=D0=BE=D0=B2=D0=B5=D1=80=D0=B8=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B0=D0=BB=D0=B0=20=D0=BF=D0=BE=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D0=B8=D0=BE=D0=BD=D0=B0=D0=BC=20(#3540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/core/config.py | 18 ++ tradein-mvp/backend/app/services/estimator.py | 49 +++++- .../backend/scripts/backtest_estimator.py | 7 + .../tests/test_estimator_expected_sold.py | 25 ++- .../test_estimator_expected_sold_clamp.py | 26 ++- .../backend/tests/test_estimator_imv_blend.py | 12 +- .../tests/test_estimator_pi_by_region.py | 161 ++++++++++++++++++ .../tests/test_same_building_anchor.py | 11 +- 8 files changed, 265 insertions(+), 44 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_estimator_pi_by_region.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 520b4bb9..d14ffd03 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -436,6 +436,24 @@ class Settings(BaseSettings): # (проверено: 80.0% coverage на тех же 2366). 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) + # ── региональные множители PI (замер 2026-09-16) ────────────────────────── + # Скаляры выше сняты ТОЛЬКО на Екатеринбурге (обл.66, #1966) и молча + # применялись ко всем регионам — прогон `--engine full --sample 2000 + # --since 2025-06-01 --spread scattered --seed 42 --resolve-house-id + # --pi-report` (гедоническая коррекция #2002 уже выключена в main) показал, + # что достигнутое покрытие «80%-й» вилки уезжает по региону: + # регион n p10 p50 p90 факт. покрытие + # 77 1969 0.448 1.043 1.440 71.2% + # 50 1632 0.703 1.117 1.500 74.8% + # 66 1360 0.649 1.143 1.584 66.2% + # Воспроизведено на независимой выборке (seed 777): 77 → 0.453/1.040/1.455, + # 50 → 0.701/1.115/1.514 — числа устойчивы, это не шум сэмпла. + # ВАЖНО: p50 (медиана actual_sold/expected_sold) > 1 во ВСЕХ трёх регионах — + # точка expected_sold систематически занижена на 4-14%. Эта правка чинит + # только ШИРИНУ/покрытие вилки по региону, а НЕ смещение точки. Калибровка + # asking→sold на более коротком (свежем) окне — отдельная незакрытая задача. + estimate_pi_low_mult_by_region: dict[int, float] = {77: 0.448, 50: 0.703, 66: 0.649} + estimate_pi_high_mult_by_region: dict[int, float] = {77: 1.440, 50: 1.500, 66: 1.584} # ── #2002: hedonic year+area correction на точку expected_sold ───────────── # Диагноз: estimator систематически промахивается по эре дома + размеру — # недооценивает новостройки, плохо держит крупные лоты. Held-out fit (n=2366 diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index e3f96093..cd477ac6 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -3311,6 +3311,42 @@ def _analog_word_dative(n: int) -> str: return "аналогу" if n == 1 else "аналогам" +def pi_multipliers_for_region(region_code: int | None) -> tuple[float, float]: + """#pi-by-region: (low_mult, high_mult) калиброванного ~80% prediction interval для региона. + + Скаляры `settings.estimate_pi_low_mult`/`estimate_pi_high_mult` (#1966) сняты + ТОЛЬКО на Екатеринбурге (обл.66) и молча применялись ко всем регионам. Замер + 2026-09-16 (`--engine full --sample 2000 --since 2025-06-01 --spread scattered + --seed 42 --resolve-house-id --pi-report`) показал достигнутое покрытие 66.2% + (обл.66) / 71.2% (77, Москва) / 74.8% (50, Мособласть) вместо заявленных ~80% — + те же множители тупее регионального рынка. Регион вне карты (или None) → + скаляры-фолбэк + WARNING: ни падения, ни молчаливой подстановки чужого + региона (см. sber_region_series_name выше — тот же паттерн). + """ + low = ( + settings.estimate_pi_low_mult_by_region.get(region_code) + if region_code is not None + else None + ) + high = ( + settings.estimate_pi_high_mult_by_region.get(region_code) + if region_code is not None + else None + ) + if low is not None and high is not None: + return low, high + logger.warning( + "PI multipliers: region_code=%r нет в estimate_pi_*_mult_by_region — беру " + "скаляры-фолбэк low=%.3f/high=%.3f (откалиброваны на обл.66). Если по " + "региону пошли сделки — сними p10/p90 и заведи в карте, иначе покрытие " + "вилки будет хуже заявленных ~80%%", + region_code, + settings.estimate_pi_low_mult, + settings.estimate_pi_high_mult, + ) + return settings.estimate_pi_low_mult, settings.estimate_pi_high_mult + + def _price_from_inputs( *, listings: list[dict], @@ -3344,6 +3380,11 @@ def _price_from_inputs( # объяснение называло тот радиус, по которому реально искали. None → # прежнее поведение (FALLBACK_RADIUS_M) для оффлайн-вызывающих (бэктест). search_radius_m: int | None = None, + # Региональные множители PI (#pi-by-region) — тот же target_region_code, что уже + # скоупит ratio_resolver/ДКП-коридор в estimate_quality (#3051 PR-A). None → + # pi_multipliers_for_region фолбэкается на скаляры (прежнее поведение + # байт-в-байт для оффлайн-вызывающих, у которых региона нет). + region_code: int | None = None, ) -> PricingResult: """Deterministic pricing orchestration — pure, synchronous, zero I/O. @@ -4102,8 +4143,11 @@ def _price_from_inputs( # ~80% honest coverage. Старый IQR-производный band (asking-IQR × ratio) # покрывал лишь ~55% реальных продаж при заявленном «диапазоне оценки». # Множители low<1 None: diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py index 8cc771d6..ec38f8a5 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py @@ -143,18 +143,16 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1( ) # #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. + # asking-IQR band — the high arm legitimately exceeds range_high. # The invariant we keep is the calibrated band itself + ordering low ≤ point ≤ high. - from app.core.config import settings + # #3xxx: multipliers are regional now — resolve via pi_multipliers_for_region. + from app.services.estimator import pi_multipliers_for_region + _low_mult, _high_mult = pi_multipliers_for_region(66) assert est.expected_sold_range_high_rub is not None - assert est.expected_sold_range_high_rub == round( - est.expected_sold_price_rub * settings.estimate_pi_high_mult - ) + assert est.expected_sold_range_high_rub == round(est.expected_sold_price_rub * _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 == round(est.expected_sold_price_rub * _low_mult) assert ( est.expected_sold_range_low_rub <= est.expected_sold_price_rub @@ -174,14 +172,12 @@ def test_expected_sold_not_clamped_when_ratio_below_1() -> None: 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) # #1966: calibrated ~80% PI around the expected_sold point (not asking-IQR × ratio). - from app.core.config import settings + # #3xxx: multipliers are regional now — resolve via pi_multipliers_for_region. + from app.services.estimator import pi_multipliers_for_region - 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 - ) + _low_mult, _high_mult = pi_multipliers_for_region(66) + assert est.expected_sold_range_low_rub == round(est.expected_sold_price_rub * _low_mult) + assert est.expected_sold_range_high_rub == round(est.expected_sold_price_rub * _high_mult) assert est.expected_sold_price_rub < est.median_price_rub diff --git a/tradein-mvp/backend/tests/test_estimator_imv_blend.py b/tradein-mvp/backend/tests/test_estimator_imv_blend.py index 3decd220..f33d8ab6 100644 --- a/tradein-mvp/backend/tests/test_estimator_imv_blend.py +++ b/tradein-mvp/backend/tests/test_estimator_imv_blend.py @@ -519,14 +519,12 @@ def test_expected_sold_consistent_with_blended_median() -> None: 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) # #1966: expected_sold range — калиброванный ~80% PI вокруг точки, не asking-IQR × ratio. - from app.core.config import settings + # #3xxx: multipliers are regional now — resolve via pi_multipliers_for_region. + from app.services.estimator import pi_multipliers_for_region - 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 - ) + _low_mult, _high_mult = pi_multipliers_for_region(66) + assert est.expected_sold_range_high_rub == round(est.expected_sold_price_rub * _high_mult) + assert est.expected_sold_range_low_rub == round(est.expected_sold_price_rub * _low_mult) # Sanity: sold < asking (ratio<1), но НЕ абсурдная «скидка» от stale 6М-базы. assert est.expected_sold_price_rub == round(12_000_000 * ratio) diff --git a/tradein-mvp/backend/tests/test_estimator_pi_by_region.py b/tradein-mvp/backend/tests/test_estimator_pi_by_region.py new file mode 100644 index 00000000..f7da1afb --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_pi_by_region.py @@ -0,0 +1,161 @@ +"""Regional prediction-interval multipliers (#1966 follow-up, 2026-09-16). + +`estimate_pi_low_mult`/`estimate_pi_high_mult` (#1966) were calibrated ONLY on +Yekaterinburg (region 66, n=2366) and applied blindly to every region. A prod +backtest (`--engine full --sample 2000 --since 2025-06-01 --spread scattered +--seed 42 --resolve-house-id --pi-report`, hedonic correction OFF) showed the +achieved coverage of the "~80%" band drifting by region: 66.2% (66), 71.2% +(77, Moscow), 74.8% (50, Moscow oblast) instead of the claimed ~80%. +`pi_multipliers_for_region` resolves per-region p10/p90 multipliers with a +scalar fallback for regions outside the map — mirrors `sber_region_series_name`. + +NOTE: importing app.services.estimator pulls app.core.config.Settings which +requires DATABASE_URL. Set it BEFORE importing app modules. +""" + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import logging + +from app.core.config import settings +from app.services import estimator +from app.services.estimator import pi_multipliers_for_region +from app.services.geocoder import GeocodeResult + +# ── helpers (mirror tests/test_estimator_range_floor.py) ────────────────────── + + +def _geo() -> GeocodeResult: + return GeocodeResult( + lat=56.838, + lon=60.597, + full_address="ул. Тестовая, 1", + provider="nominatim", + confidence="approximate", + ) + + +def _lot(ppm2: float, address: str = "ул. Тестовая, 1", source: str = "avito") -> dict: + return {"price_per_m2": ppm2, "address": address, "source": source} + + +def _lots(ppm2: float, n: int = 7) -> list[dict]: + return [_lot(ppm2, address=f"ул. Тестовая, {i + 1}") for i in range(n)] + + +def _call( + *, + listings: list[dict], + ratio: float, + region_code: int | None, + area_m2: float = 50.0, + rooms: int | None = 2, +) -> estimator.PricingResult: + def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]: + return ratio, "per_rooms" + + return estimator._price_from_inputs( + listings=listings, + area_m2=area_m2, + rooms=rooms, + repair_state=None, + floor=5, + total_floors=10, + target_year=None, + 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, + region_code=region_code, + ) + + +# ── 1. Resolver: regional maps differ from Yekaterinburg (region 66) ────────── + + +def test_moscow_and_moscow_oblast_multipliers_differ_from_ekb() -> None: + ekb_low, ekb_high = pi_multipliers_for_region(66) + msk_low, msk_high = pi_multipliers_for_region(77) + mo_low, mo_high = pi_multipliers_for_region(50) + + assert (msk_low, msk_high) != (ekb_low, ekb_high) + assert (mo_low, mo_high) != (ekb_low, ekb_high) + # Sanity against the measured 2026-09-16 backtest table. + assert msk_low == 0.448 + assert msk_high == 1.440 + assert mo_low == 0.703 + assert mo_high == 1.500 + assert ekb_low == 0.649 + assert ekb_high == 1.584 + + +# ── 2. Region outside the map (or None) falls back to the scalars ───────────── + + +def test_unknown_region_falls_back_to_scalars(caplog) -> None: + with caplog.at_level(logging.WARNING, logger=estimator.logger.name): + low, high = pi_multipliers_for_region(12345) + assert (low, high) == (settings.estimate_pi_low_mult, settings.estimate_pi_high_mult) + assert any("PI multipliers" in r.getMessage() for r in caplog.records) + + +def test_none_region_falls_back_to_scalars() -> None: + low, high = pi_multipliers_for_region(None) + assert (low, high) == (settings.estimate_pi_low_mult, settings.estimate_pi_high_mult) + + +# ── 3. Invariant from #1966: low < 1 < point ⇒ low ≤ point ≤ high ───────────── + + +def test_low_below_one_high_above_one_for_every_mapped_region() -> None: + all_regions = set(settings.estimate_pi_low_mult_by_region) | set( + settings.estimate_pi_high_mult_by_region + ) + assert all_regions, "regional PI map must not be empty" + for region_code in all_regions: + low, high = pi_multipliers_for_region(region_code) + assert low < 1 < high, f"region {region_code}: low={low} high={high}" + # Scalar fallback carries the same invariant. + assert settings.estimate_pi_low_mult < 1 < settings.estimate_pi_high_mult + + +# ── 4. Integration: _price_from_inputs uses the REGIONAL multipliers ────────── + + +def test_price_from_inputs_uses_regional_multipliers_for_moscow() -> None: + ratio = 0.9 + est = _call(listings=_lots(200_000.0), ratio=ratio, region_code=77) + low_mult, high_mult = pi_multipliers_for_region(77) + assert est.expected_sold_price is not None + assert est.expected_sold_range_low == round(est.expected_sold_price * low_mult) + assert est.expected_sold_range_high == round(est.expected_sold_price * high_mult) + # Same call with region 66 must give a DIFFERENT high arm (regions diverge). + est_ekb = _call(listings=_lots(200_000.0), ratio=ratio, region_code=66) + assert est_ekb.expected_sold_range_high != est.expected_sold_range_high + + +def test_price_from_inputs_region_none_falls_back_to_scalars() -> None: + ratio = 0.9 + est = _call(listings=_lots(200_000.0), ratio=ratio, region_code=None) + assert est.expected_sold_price is not None + assert est.expected_sold_range_low == round( + est.expected_sold_price * settings.estimate_pi_low_mult + ) + assert est.expected_sold_range_high == round( + est.expected_sold_price * settings.estimate_pi_high_mult + ) diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index c15c49c4..0f0502ee 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -568,13 +568,12 @@ 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. + # #3xxx: multipliers are regional now — resolve via pi_multipliers_for_region. + from app.services.estimator import pi_multipliers_for_region - 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 - ) + _low_mult, _high_mult = pi_multipliers_for_region(66) + assert est.expected_sold_range_high_rub == round(est.expected_sold_price_rub * _high_mult) + assert est.expected_sold_range_low_rub == round(est.expected_sold_price_rub * _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