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]; за этими порогами — артефакт, а не сигнал.
|
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
|
||||||
estimate_quarter_index_factor_min: float = 0.6
|
estimate_quarter_index_factor_min: float = 0.6
|
||||||
estimate_quarter_index_factor_max: float = 1.8
|
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) ──────────────
|
# ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ──────────────
|
||||||
# Эстиматор систематически занижает верхние сегменты (live-бэктест n=561,
|
# Эстиматор систематически занижает верхние сегменты (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}
|
return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows}
|
||||||
|
|
||||||
|
|
||||||
# Порог KNN для квартала ЦЕЛИ — тот же, что у geo-nearest матчинга аналогов
|
# Порог KNN для квартала ЦЕЛИ. 50 м (как у geo-nearest матчинга аналогов,
|
||||||
# (tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M=50): дальше 50 м «ближайшее здание»
|
# tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M) замером НЕ подтвердились —
|
||||||
# перестаёт быть зданием цели (geo цели street-level), и квартал снова становится
|
# точность проверена на 2398 домах ЕКБ, где квартал известен независимо (кадастр
|
||||||
# чужим. Замер по 15 прод-применениям (2026-08-12): 8 из 15 целей имеют здание
|
# от DaData по адресу, а не KNN-подсказка), 2026-08-12:
|
||||||
# ближе 50 м, у 7 ближайшее — 59-180 м, для них поправки не будет.
|
# порог ответ выдан чужой квартал точность покрытие
|
||||||
_TARGET_QUARTER_RADIUS_M = 50.0
|
# 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 м на
|
# Градусные ворота под GIST-индекс (планарные градусы, надмножество круга 50 м на
|
||||||
# широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже.
|
# широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже.
|
||||||
_TARGET_QUARTER_DEG_GATE = 0.0015
|
_TARGET_QUARTER_DEG_GATE = 0.0015
|
||||||
|
|
@ -4274,10 +4282,16 @@ async def estimate_quality(
|
||||||
|
|
||||||
# ── #764: квартал ЦЕЛИ для квартального индекса ──────────────────────────
|
# ── #764: квартал ЦЕЛИ для квартального индекса ──────────────────────────
|
||||||
# Свой кадастр (DaData) — приоритет; пусто (15 из 15 применений на проде) →
|
# Свой кадастр (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
|
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(
|
target_quarter_cadnum = await asyncio.to_thread(
|
||||||
_lookup_target_quarter_by_coords, db, geo.lat, geo.lon
|
_lookup_target_quarter_by_coords, db, geo.lat, geo.lon
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ def _run_estimate(
|
||||||
analogs: list[dict[str, Any]],
|
analogs: list[dict[str, Any]],
|
||||||
dadata_cadnum: str | None,
|
dadata_cadnum: str | None,
|
||||||
coords_quarter: Any = _UNPATCHED,
|
coords_quarter: Any = _UNPATCHED,
|
||||||
|
coords_enabled: bool = True,
|
||||||
):
|
):
|
||||||
"""estimate_quality с полным stub-пачем I/O; индексы кварталов — из _INDEXES.
|
"""estimate_quality с полным stub-пачем I/O; индексы кварталов — из _INDEXES.
|
||||||
|
|
||||||
|
|
@ -57,11 +58,15 @@ def _run_estimate(
|
||||||
неизвестен. Этот режим исполним и на старом коде (там резолвера ещё нет) —
|
неизвестен. Этот режим исполним и на старом коде (там резолвера ещё нет) —
|
||||||
старый код подставит квартал аналога, на чём тест и падает.
|
старый код подставит квартал аналога, на чём тест и падает.
|
||||||
* str / None — явная подмена резолвера.
|
* 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()
|
db = MagicMock()
|
||||||
# «Ни одного здания в радиусе 50 м»: единственный db.execute(...).first() на
|
# «Ни одного здания в радиусе порога»: единственный db.execute(...).first() на
|
||||||
# пути оценки (всё остальное I/O застаблено) — координатный резолв квартала.
|
# пути оценки (всё остальное I/O застаблено) — координатный резолв квартала.
|
||||||
db.execute.return_value.first.return_value = None
|
db.execute.return_value.first.return_value = None
|
||||||
|
|
||||||
|
|
@ -119,6 +124,7 @@ def _run_estimate(
|
||||||
return_value=coords_quarter,
|
return_value=coords_quarter,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
stubs.append(patch.object(settings, "estimate_quarter_from_coords_enabled", coords_enabled))
|
||||||
with ExitStack() as stack:
|
with ExitStack() as stack:
|
||||||
for stub in stubs:
|
for stub in stubs:
|
||||||
stack.enter_context(stub)
|
stack.enter_context(stub)
|
||||||
|
|
@ -188,3 +194,23 @@ def test_own_cadastre_wins_over_coords() -> None:
|
||||||
assert spy.call_count == 0
|
assert spy.call_count == 0
|
||||||
# target 1.2 / avg 0.8 = 1.5
|
# target 1.2 / avg 0.8 = 1.5
|
||||||
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 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