fix(tradein/location): заменить сломанный коэффициент локации на калиброванный индекс #2531

Merged
lekss361 merged 4 commits from fix/tradein-location-index-calibration into main 2026-07-26 21:48:16 +00:00
Owner

Summary

Жалоба пользователя: «коэффициент локации всегда 0». Разбор показал три разные проблемы, из которых жалоба — самая безобидная.

Замеры на боевой БД

1. Схлопывание в ноль. По 1500 адресам ЕКБ 67% попадают в −1%…+1%, ровно 0% показывается почти четверти. Весь город умещается в −4%…+5%, размах 1,10×. Это не случайность: нормировка подбиралась так, чтобы «сильный центр ≈ 1.0».

2. Связи с ценой нет. По 4000 активных лотов медиана ₽/м² по бакетам коэффициента плоская и немонотонная:

коэф лотов медиана ₽/м²
−4% 87 164 281
−2% 558 149 323
0% 854 171 370
+2% 655 162 199
+3% 173 154 839
+5% 32 180 222

Бакет −4% дороже бакета +3%.

3. Масштаб не тот на порядок. Одна тривиальная переменная — расстояние до центра — даёт монотонный градиент по 31 тыс. лотов: 249 686 ₽/м² в центре против 93 677 на краю, размах 2,70×. Реальный сигнал локации примерно в 25 раз шире того, что формула способна выразить.

Ключевой факт

Коэффициент никогда не влиял на цену — в estimator.py ноль упоминаний location_coef. При этом панель «КАК РАССЧИТАНО» рисовала пару «база → результат» (result_price_rub = round(base_price_rub * coef)), обещая клиенту влияние, которого нет.

Что сделано

Новый показатель — отклонение медианы ₽/м² сопоставимых активных объявлений в радиусе от медианы по городу (percentile_cont, лестница радиусов 800/1500/2500 м до набора выборки ≥20). Диапазон искусственно не зажимается. В цену по-прежнему не идёт и идти не должен: аналоги берутся из того же района, локация в базовой цене уже учтена — умножение дало бы двойной учёт и систематическое завышение центра.

Честная деградация вместо молчаливого вырождения в 0.95: out_of_coverage (адрес вне ЕКБ) и insufficient_data (мало сопоставимых) — разные, объяснённые состояния.

Контракт: /location-coef/location-index; убраны coef, base_price_rub, result_price_rub, weight.

Тексты: «КОЭФ. ЛОКАЦИИ» → «ЛОКАЦИЯ», в подсказке прямо: «Сравнение медианы ₽/м² района и города. На итоговую оценку не влияет.»

Попутно: потеря 46% точек интереса

Разбираясь, почему признак не работает, нашли причину глубже. v_tradein_osm_poi_ekb резала всё, что не редактировали в OSM два года. Замерено на проде: из 5133 точек доходило 2787. Потеря систематически смещена — режет стабильную инфраструктуру и не трогает часто правимую:

категория в источнике доходит отсеяно
магазины у дома 815 138 83%
парки 287 46 84%
больницы 258 52 80%
метро 9 5 44%
остановки 1020 1020 0%

Из девяти станций метро терялись четыре, включая Площадь 1905 года — центральную. У всех четырёх last_osm_edit_date — февраль 2023.

Это объясняет и провал самого коэффициента: в зеркале осталось 1020 остановок из 2787 точек, то есть «взвешенный счёт по семи POI» вырождался в «сколько рядом остановок».

Требование свежести задумывалось как мягкий сигнал уверенности — Site Finder так его и использует (parcels.py только понижает confidence-подscore, ни одной точки не выбрасывает). Расхождение возникло во view для trade-in при постройке FDW-моста. Миграция 188_tradein_osm_poi_view_relax_freshness.sql убирает фильтр. Плюс в poi_loader.py расширен Overpass-фильтр метро: ловил только station=subway, добавлена комбинация railway=station + subway=yes.

Test plan

  • uv run pytest -q — 2633 passed, 8 skipped (33 новых теста: деградация вне ЕКБ, недостаточная выборка на каждом уровне лестницы, устойчивость к выбросам, монотонность на синтетике, IDOR эндпоинта)
  • ruff check через uv run с проектным конфигом — чисто
  • npx tsc --noEmit — чисто, грепом подтверждено 0 живых упоминаний старого контракта
  • После деплоя обеих миграций (188 на gendesign + прогон osm_poi_ekb_refresh на tradein): SELECT category, count(*) FROM osm_poi_ekb_local GROUP BY category — метро должно стать 9, общее число вырасти примерно вдвое
  • Визуал на проде: бейдж «ЛОКАЦИЯ» в карточке 182px, три состояния, переписанная панель
## Summary Жалоба пользователя: «коэффициент локации всегда 0». Разбор показал три разные проблемы, из которых жалоба — самая безобидная. ### Замеры на боевой БД **1. Схлопывание в ноль.** По 1500 адресам ЕКБ **67% попадают в −1%…+1%**, ровно 0% показывается почти четверти. Весь город умещается в −4%…+5%, размах **1,10×**. Это не случайность: нормировка подбиралась так, чтобы «сильный центр ≈ 1.0». **2. Связи с ценой нет.** По 4000 активных лотов медиана ₽/м² по бакетам коэффициента плоская и **немонотонная**: | коэф | лотов | медиана ₽/м² | |---|---|---| | −4% | 87 | 164 281 | | −2% | 558 | 149 323 | | 0% | 854 | 171 370 | | +2% | 655 | 162 199 | | +3% | 173 | 154 839 | | +5% | 32 | 180 222 | Бакет −4% дороже бакета +3%. **3. Масштаб не тот на порядок.** Одна тривиальная переменная — расстояние до центра — даёт монотонный градиент по 31 тыс. лотов: 249 686 ₽/м² в центре против 93 677 на краю, размах **2,70×**. Реальный сигнал локации примерно в 25 раз шире того, что формула способна выразить. ### Ключевой факт **Коэффициент никогда не влиял на цену** — в `estimator.py` ноль упоминаний `location_coef`. При этом панель «КАК РАССЧИТАНО» рисовала пару «база → результат» (`result_price_rub = round(base_price_rub * coef)`), обещая клиенту влияние, которого нет. ## Что сделано **Новый показатель** — отклонение медианы ₽/м² сопоставимых активных объявлений в радиусе от медианы по городу (`percentile_cont`, лестница радиусов 800/1500/2500 м до набора выборки ≥20). Диапазон искусственно не зажимается. **В цену по-прежнему не идёт** и идти не должен: аналоги берутся из того же района, локация в базовой цене уже учтена — умножение дало бы двойной учёт и систематическое завышение центра. **Честная деградация** вместо молчаливого вырождения в 0.95: `out_of_coverage` (адрес вне ЕКБ) и `insufficient_data` (мало сопоставимых) — разные, объяснённые состояния. **Контракт:** `/location-coef` → `/location-index`; убраны `coef`, `base_price_rub`, `result_price_rub`, `weight`. **Тексты:** «КОЭФ. ЛОКАЦИИ» → «ЛОКАЦИЯ», в подсказке прямо: «Сравнение медианы ₽/м² района и города. На итоговую оценку не влияет.» ## Попутно: потеря 46% точек интереса Разбираясь, почему признак не работает, нашли причину глубже. `v_tradein_osm_poi_ekb` резала всё, что не редактировали в OSM два года. **Замерено на проде: из 5133 точек доходило 2787.** Потеря систематически смещена — режет стабильную инфраструктуру и не трогает часто правимую: | категория | в источнике | доходит | отсеяно | |---|---|---|---| | магазины у дома | 815 | 138 | 83% | | парки | 287 | 46 | 84% | | больницы | 258 | 52 | 80% | | метро | 9 | 5 | 44% | | остановки | 1020 | 1020 | 0% | Из девяти станций метро терялись четыре, включая **Площадь 1905 года** — центральную. У всех четырёх `last_osm_edit_date` — февраль 2023. Это объясняет и провал самого коэффициента: в зеркале осталось 1020 остановок из 2787 точек, то есть «взвешенный счёт по семи POI» вырождался в «сколько рядом остановок». Требование свежести задумывалось как **мягкий сигнал уверенности** — Site Finder так его и использует (`parcels.py` только понижает confidence-подscore, ни одной точки не выбрасывает). Расхождение возникло во view для trade-in при постройке FDW-моста. Миграция `188_tradein_osm_poi_view_relax_freshness.sql` убирает фильтр. Плюс в `poi_loader.py` расширен Overpass-фильтр метро: ловил только `station=subway`, добавлена комбинация `railway=station + subway=yes`. ## Test plan - [x] `uv run pytest -q` — 2633 passed, 8 skipped (33 новых теста: деградация вне ЕКБ, недостаточная выборка на каждом уровне лестницы, устойчивость к выбросам, монотонность на синтетике, IDOR эндпоинта) - [x] `ruff check` через `uv run` с проектным конфигом — чисто - [x] `npx tsc --noEmit` — чисто, грепом подтверждено 0 живых упоминаний старого контракта - [ ] **После деплоя обеих миграций** (188 на gendesign + прогон `osm_poi_ekb_refresh` на tradein): `SELECT category, count(*) FROM osm_poi_ekb_local GROUP BY category` — метро должно стать 9, общее число вырасти примерно вдвое - [ ] Визуал на проде: бейдж «ЛОКАЦИЯ» в карточке 182px, три состояния, переписанная панель
lekss361 added 4 commits 2026-07-26 20:33:11 +00:00
coef = 0.95 + score/100*0.10 не был связан с ценой и не участвовал в расчёте
estimator'а вовсе (0 упоминаний), но интерфейс рисовал «база -> результат»,
обещая влияние на цену. Замеры на боевой БД: по 1500 адресам ЕКБ 67% попадают
в -1%..+1%, весь город укладывается в размах 1.10x; медиана руб/м2 по бакетам
коэффициента плоская и немонотонная (бакет -4% дороже бакета +3%). Для
сравнения, расстояние до центра даёт монотонный градиент с размахом 2.70x
(93 677 -> 249 686 руб/м2 по 31 тыс. лотов).

Новый показатель — отклонение медианы руб/м2 сопоставимых активных листингов
в радиусе от медианы по городу (percentile_cont, лестница радиусов до набора
выборки). Честная деградация вне ЕКБ и при малой выборке вместо молчаливого
вырождения в 0.95. В цену по-прежнему не идёт — аналоги берутся из того же
района, локация в базовой цене уже учтена.

Заодно две причины потери POI:
1. Overpass-фильтр метро ловил только station=subway, без railway=station+subway=yes.
2. v_tradein_osm_poi_ekb резала всё, что не редактировали в OSM 2 года. Проверено
   на проде: из 5133 точек доходило 2787 (-46%), причём смещённо — парки -84%,
   больницы -80%, магазины -83%, остановки 0%. Из 9 станций метро терялись 4,
   включая Площадь 1905 года. Станция не перестаёт существовать оттого, что её
   тег два года не трогали; Site Finder использует эту дату как мягкий сигнал
   уверенности, а не как фильтр существования.
Старый интерфейс рисовал в панели «КАК РАССЧИТАНО» пару «база -> результат»,
из чего клиент делал вывод, что локация подвинула его цену. Она её не двигала:
estimator.py про location_coef не знает вовсе. Пара удалена.

Подпись «КОЭФ. ЛОКАЦИИ» заменена на «ЛОКАЦИЯ» — слово «коэффициент»
подразумевает множитель. В тултипе прямо сказано: «Сравнение медианы руб/м2
района и города. На итоговую оценку не влияет.»

Три состояния недоступности теперь различимы вместо одного прочерка:
«вне ЕКБ» (индекс считаем только по Екатеринбургу), «мало данных»
(сопоставимых объявлений меньше порога) и загрузка. В панели каждое состояние
объяснено человеческим текстом, а при status=ok показано, по скольким
объявлениям и в каком радиусе посчитано.

Список «что рядом» сохранён как качественная справка, weight из ответа убран
(был внутренней ранжирующей величиной, клиенту не значил ничего).
Merge remote-tracking branch 'forgejo/main' into fix/tradein-location-index-calibration
All checks were successful
CI Trade-In / frontend-checks (pull_request) Successful in 1m54s
CI / backend-tests (pull_request) Successful in 16m1s
CI / openapi-codegen-check (pull_request) Successful in 3m25s
CI Trade-In / backend-tests (pull_request) Successful in 5m23s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI / frontend-tests (pull_request) Has been skipped
3d36861fb2
lekss361 merged commit 580be61914 into main 2026-07-26 21:48:16 +00:00
lekss361 deleted branch fix/tradein-location-index-calibration 2026-07-26 21:48:17 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2531
No description provided.