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
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:
parent
86cb294949
commit
b13880ff15
3 changed files with 68 additions and 11 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 "")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue