fix(tradein/estimate): координатный резолв квартала — за флагом OFF, порог 25 м
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m58s
CI Trade-In / browser-tests (pull_request) Has been skipped

Точность самого резолва измерена против НЕЗАВИСИМОЙ истины: 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%.
This commit is contained in:
bot-backend 2026-08-12 20:48:34 +05:00
parent 86cb294949
commit b13880ff15
3 changed files with 68 additions and 11 deletions

View file

@ -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,

View file

@ -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
)

View file

@ -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 "")