From b13880ff15537f22eaa4e353c7387283f8887e61 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 20:48:34 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimate):=20=D0=BA=D0=BE=D0=BE?= =?UTF-8?q?=D1=80=D0=B4=D0=B8=D0=BD=D0=B0=D1=82=D0=BD=D1=8B=D0=B9=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2=20=D0=BA=D0=B2=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=B0=20=E2=80=94=20=D0=B7=D0=B0=20=D1=84=D0=BB?= =?UTF-8?q?=D0=B0=D0=B3=D0=BE=D0=BC=20OFF,=20=D0=BF=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=2025=20=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Точность самого резолва измерена против НЕЗАВИСИМОЙ истины: 2398 домов ЕКБ, где квартал известен из кадастра DaData (ответ на адрес, а не KNN-подсказка — см. house_dedup_merge.py:126; вырожденный 66:41:0000000 у 146 домов исключён). порог ответ выдан чужой квартал точность покрытие 15 м 475 26 94.5% 19.8% 25 м 748 59 92.1% 31.2% 50 м 1341 271 79.8% 55.9% 100 м 2090 644 69.2% 87.2% Порог 50 м, заимствованный у cadastral_geo_match, замером НЕ подтверждён: каждый пятый ответ — чужой квартал, а промах стоит 1.111× по медиане и 1.447× в p90 (отношение индексов чужого и своего квартала). Порог 25 м обоснован замером. Разделение: снятие фолбэка на кадастр аналога поставляется включённым (измерено бэктестом, −0.55 пп MAPE), координатный резолв — за estimate_quarter_from_coords_enabled=False. Причина: точность РЕЗОЛВА измерена, эффект поправки на точность ЦЕНЫ — нет (фикстура гейта идёт с target_house_cadnum=None и координатный путь не проходит). Критерий приёмки и дата-предохранитель (2026-09-12) записаны в config рядом с флагом. На рабочем тракте (координаты оценок, а не домов справочника) точность ниже: из 20 оценок со своим кадастром ответ в 50 м выдан 9 раз, совпал 3; в 25 м — 3 ответа, 3 совпадения. Попадания на 0-4 м, промахи на 32-39 м. Один из промахов — Малышева 84, тот самый кейс, где координатный квартал двигал цену на −20.6%. --- tradein-mvp/backend/app/core/config.py | 17 ++++++++++ tradein-mvp/backend/app/services/estimator.py | 32 +++++++++++++------ .../test_estimator_quarter_from_own_coords.py | 30 +++++++++++++++-- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 8f0e2b31..1c2ad8c1 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -629,6 +629,23 @@ class Settings(BaseSettings): # индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал. estimate_quarter_index_factor_min: float = 0.6 estimate_quarter_index_factor_max: float = 1.8 + # Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local), + # когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде. + # ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED). + # + # Почему dormant. Точность самого резолва измерена (2544 дома ЕКБ, где кадастр + # известен независимо — ответ DaData на адрес, не KNN-подсказка): 92.1% на 25 м, + # 79.8% на 50 м. То есть механизм работоспособен. Но ЭФФЕКТ поправки на точность + # цены НЕ измерен: бэктест-гейт реплеит фикстуру с target_house_cadnum=None и + # координатный резолв не проходит. Точность резолва ≠ польза поправки, а тракт + # денежный — поэтому включение отдельным решением, после замера. + # + # Критерий приёмки (записан ДО факта, 2026-08-12): перезахватить фикстуру с + # заполненным координатным кварталом и получить overall MAPE не хуже 12.63 И + # сегмент эконом не хуже 14.20 при доле затронутых сделок >= 5%. Если к + # 2026-09-12 замер не сделан — флаг и `_lookup_target_quarter_by_coords` удалить, + # а не оставлять «на вырост». + estimate_quarter_from_coords_enabled: bool = False # ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ────────────── # Эстиматор систематически занижает верхние сегменты (live-бэктест n=561, diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index e308dcb3..7f386bb9 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1357,12 +1357,20 @@ def _lookup_quarter_indexes( return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows} -# Порог KNN для квартала ЦЕЛИ — тот же, что у geo-nearest матчинга аналогов -# (tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M=50): дальше 50 м «ближайшее здание» -# перестаёт быть зданием цели (geo цели street-level), и квартал снова становится -# чужим. Замер по 15 прод-применениям (2026-08-12): 8 из 15 целей имеют здание -# ближе 50 м, у 7 ближайшее — 59-180 м, для них поправки не будет. -_TARGET_QUARTER_RADIUS_M = 50.0 +# Порог KNN для квартала ЦЕЛИ. 50 м (как у geo-nearest матчинга аналогов, +# tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M) замером НЕ подтвердились — +# точность проверена на 2398 домах ЕКБ, где квартал известен независимо (кадастр +# от DaData по адресу, а не KNN-подсказка), 2026-08-12: +# порог ответ выдан чужой квартал точность покрытие +# 15 м 475 26 94.5% 19.8% +# 25 м 748 59 92.1% 31.2% +# 50 м 1341 271 79.8% 55.9% +# 100 м 2090 644 69.2% 87.2% +# На 50 м каждый пятый ответ — чужой квартал, а промах стоит 1.111× по медиане +# и 1.447× в p90 (отношение индексов чужого и своего квартала). Берём 25 м: +# точность 92%, и это ровно тот компромисс, который видно в замере, а не +# заимствованная у соседнего модуля константа. +_TARGET_QUARTER_RADIUS_M = 25.0 # Градусные ворота под GIST-индекс (планарные градусы, надмножество круга 50 м на # широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже. _TARGET_QUARTER_DEG_GATE = 0.0015 @@ -4274,10 +4282,16 @@ async def estimate_quality( # ── #764: квартал ЦЕЛИ для квартального индекса ────────────────────────── # Свой кадастр (DaData) — приоритет; пусто (15 из 15 применений на проде) → - # снимаем квартал по СОБСТВЕННЫМ координатам цели. Кадастр аналога сюда больше - # не попадает (см. блок #764 в _price_from_inputs). + # квартал по СОБСТВЕННЫМ координатам цели, ЕСЛИ включён флаг. Кадастр аналога + # сюда больше не попадает вообще (см. блок #764 в _price_from_inputs). + # Флаг dormant: точность резолва измерена (92% на 25 м), эффект поправки на + # точность ЦЕНЫ — нет (бэктест-фикстура его не проходит). См. config. target_quarter_cadnum: str | None = dadata.house_cadnum if dadata else None - if not target_quarter_cadnum and geo is not None: + if ( + settings.estimate_quarter_from_coords_enabled + and not target_quarter_cadnum + and geo is not None + ): target_quarter_cadnum = await asyncio.to_thread( _lookup_target_quarter_by_coords, db, geo.lat, geo.lon ) diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py index 611c6a86..d92a9ab9 100644 --- a/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py +++ b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py @@ -48,6 +48,7 @@ def _run_estimate( analogs: list[dict[str, Any]], dadata_cadnum: str | None, coords_quarter: Any = _UNPATCHED, + coords_enabled: bool = True, ): """estimate_quality с полным stub-пачем I/O; индексы кварталов — из _INDEXES. @@ -57,11 +58,15 @@ def _run_estimate( неизвестен. Этот режим исполним и на старом коде (там резолвера ещё нет) — старый код подставит квартал аналога, на чём тест и падает. * str / None — явная подмена резолвера. + + coords_enabled — значение `estimate_quarter_from_coords_enabled` на время + прогона (в проде флаг ВЫКЛЮЧЕН, см. config); тесты координатной половины + включают его явно. """ - from app.services.estimator import estimate_quality + from app.services.estimator import estimate_quality, settings db = MagicMock() - # «Ни одного здания в радиусе 50 м»: единственный db.execute(...).first() на + # «Ни одного здания в радиусе порога»: единственный db.execute(...).first() на # пути оценки (всё остальное I/O застаблено) — координатный резолв квартала. db.execute.return_value.first.return_value = None @@ -119,6 +124,7 @@ def _run_estimate( return_value=coords_quarter, ) ) + stubs.append(patch.object(settings, "estimate_quarter_from_coords_enabled", coords_enabled)) with ExitStack() as stack: for stub in stubs: stack.enter_context(stub) @@ -188,3 +194,23 @@ def test_own_cadastre_wins_over_coords() -> None: assert spy.call_count == 0 # target 1.2 / avg 0.8 = 1.5 assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5) + + +def test_coords_resolver_dormant_by_default() -> None: + """Флаг выключен (прод-дефолт) → координатный резолв не зовётся вообще. + + Поставляемое поведение = снятие фолбэка на кадастр аналога, и только оно: + точность самого резолва измерена (92% на 25 м), а эффект поправки на ЦЕНУ — + нет, поэтому включение — отдельное решение (см. config, критерий и дата). + """ + with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy: + est = _run_estimate( + analogs=_analogs_two_quarters(), + dadata_cadnum=None, + coords_quarter=_UNPATCHED, + coords_enabled=False, + ) + + assert spy.call_count == 0 + assert est.median_price_rub == round(_BASE_PPM2 * _AREA) + assert "индекс цен квартала" not in (est.confidence_explanation or "")