fix(tradein/estimate): нет индекса у аналогов — нет квартальной поправки (#2583 C3) #2836

Merged
lekss361 merged 2 commits from fix/2583-c3-quarter-index-fallback into main 2026-08-17 07:47:28 +00:00
Collaborator

Что не так

estimator.py (было :2759, сейчас :3201):

avg_analog_index = weighted_sum / weight_total if weight_total > 0 else 1.0

price_index нормирован на медиану Екатеринбурга (99a_quarter_price_index.sql), так что 1.0 — это не «нейтраль», а «аналоги стоят как в ЕКБ». Утверждение делается ровно там, где про аналоги неизвестно ничего. factor = target_index / avg_analog_index схлопывается в factor = target_qi, а индексы области — 0.28–0.82. Пользователю это уходит подписью «Учтена локация квартала (индекс цен квартала ×0.60)», то есть догадка читается как методика.

Замер: сегодня эффект НУЛЕВОЙ

Применений индекса за всё время 15, все — Екатеринбург
Из них с weight_total = 0 (фолбэк 1.0) 0
Применений вне ЕКБ 0

Реконструкция по persisted-аналогам всех 15 оценок: у каждой был минимум 2 аналога с квартальным индексом. Фолбэк не срабатывал на проде ни разу. Ни одна существующая оценка после этой правки не изменится.

Область до блока не доходит вовсе: target_quarter резолвится либо из dadata.house_cadnum (9 из 701 оценок, все ЕКБ), либо из кадастра первого аналога — а кадастр вне ЕКБ отсутствует (Тагил 1/1063, Каменск 0/823, Серов 0/381, Первоуральск 7/683, Пышма 10/461), причём все 18 «областных» кадастров на самом деле 66:41: (ЕКБ), и на них Guard-2 даёт same_quarter_ratio = 1.0 → skip.

Это задача «на вырост», не срочная. Чиню потому, что мина взведена и правка стоит 21 строку.

Насколько взведена

92 дома вне ЕКБ уже имеют кадастр + квартал в индексе с n_deals >= 10, и рядом с ними ноль проиндексированных аналогов. Как только кадастр цели дойдёт до эстиматора (это и есть #2576), текущий код навяжет им:

срез множитель срез цены
p10 0.96 −4.0%
медиана 0.787 −21.3%
p90 / кламп-пол (13 домов) 0.60 −40.0%

В рублях, типовой лот 45 м² по медианам городов:

город после фикса текущий код, медианный случай текущий код, хвост потеря (медиана / хвост)
Верхняя Пышма 5 240 070 4 181 576 3 144 042 −1 058 494 / −2 096 028
Нижний Тагил 3 071 059 2 450 705 1 842 636 −620 354 / −1 228 424
Первоуральск 3 016 174 2 406 907 1 809 704 −609 267 / −1 206 469
Каменск-Уральский 2 861 813 2 283 727 1 717 088 −578 086 / −1 144 725
Серов 2 552 537 2 036 924 1 531 522 −515 612 / −1 021 015

Что вместо 1.0 и почему

Пропуск поправки. Из трёх вариантов он единственный, который не выдумывает данные, — и он же выигрывает по числам.

Ключевое: factor = target_qi / avg, поэтому «пропустить поправку» алгебраически тождественно «взять индекс своего квартала как индекс аналогов» (avg := target_qifactor = 1.0). То есть вариант «индекс своего города по неполным данным» тут не конкурент, а тот же самый ответ, записанный иначе.

Насколько target_qi лучше 1.0 в роли неизвестного avg — замер на 400 случайных лотах с кадастром (полная формула из эстиматора, аналоги в 1 км):

оценка неизвестного avg медиана |ошибки| p90 |ошибки|
1.0 (как сейчас) 0.1612 0.5172
target_qi (= пропуск) 0.1155 0.3374

Внутри ЕКБ, где нормировка играет в пользу 1.0, пропуск уже на 28% точнее по медиане и на 35% по хвосту. Вне ЕКБ разрыв кратный.

Вариант «индекс региона» отброшен: медианы районов области 0.29–0.82 при разбросе внутри района до 4×, региональное среднее не ближе к истине, чем свой же квартал.

Проверка направления на реальных сделках

Правка поднимает цену — значит, обязана быть проверена на известных объектах, а не принята за «стало больше». Медианы ₽/м² реальных ДКП Росреестра за 12 мес против того, что даёт каждый вариант:

город сделок правда ₽/м² после фикса ошибка текущий код ошибка
Екатеринбург 12 573 123 393 140 177 +14% 84 106 −32%
Нижний Тагил 1 981 63 175 68 246 +8% 40 947 −35%
Каменск-Уральский 992 63 529 63 596 0% 38 158 −40%
Первоуральск 771 66 561 67 026 +1% 40 216 −40%
Верхняя Пышма 593 104 695 116 446 +11% 69 868 −33%
Серов 269 55 932 56 723 +1% 34 034 −39%

Ошибка падает с 32–40% до 0–14% во всех шести городах. Остаточные +8…+14% — смещение коэффициента выкупа (#2617/#647), к C3 отношения не имеет.

Тест: красный прогон на старом коде

Естиматор откачен к origin/main, тесты новые:

FAILED test_c3_no_analog_has_cadastre_skips_correction
FAILED test_c3_analog_quarters_not_in_index_skips_correction
E       AssertionError: assert 3600000 == 6000000
2 failed, 36 passed

6 000 000 → 3 600 000, ровно −40%. С фиксом: 38 passed.

Третий тест (test_c3_one_indexed_analog_is_enough_to_correct) держит обратную сторону: как только уровень аналогов известен, поправка работает как раньше — фикс не глушит механизм, а требует под него данные.

Два прежних теста опирались на сам дефектtest_quarter_index_correction_applied и test_bimodal_guard_allows_high_index_large_n задавали «уровень аналогов = 1.0» тем, что не давали аналогам кадастра, и комментарий это фиксировал: «делаем аналоги БЕЗ кадастрового номера → avg_analog_index = 1.0». Переведены на явную карту analog_indexes — утверждения о цене сохранены дословно, изменился только способ задать вход.

Опровергнутые предпосылки исходной задачи

  1. «Блок обезоружен, ноль применений» (комментарий от 02.08) — устарело. PR #2641 вернул грант 2026-08-04, FDW живой (1894 строки, computed_at 2026-08-05), блок отработал 6 раз с 5 по 12 августа, последний — сегодня 13:00 UTC.
  2. «Правка только в коде может не дать эффекта, данные идут через MV и FDW» — для C3 неверно. Второй 1.0 (basis='city_fallback' в 99a) и district-фолбэк имеют n_deals 3–4, а эстиматор требует n_deals >= 10. На проде: quarter 1612 строк / 1222 видимы, district 281 / 0, city_fallback 1 / 0. До эстиматора доходят только строки basis='quarter'. MV и FDW трогать не нужно — и не нужно рисковать вторым потребителем (site_finder в main-БД).
  3. «Нормировка на медиану ЕКБ — сама по себе ошибка» — нет. factor это отношение, city_median сокращается. Нормировка протекает ровно в одной точке — когда одну сторону отношения подменяют литералом. После фикса зависимость от медианы ЕКБ исчезает из ценового тракта целиком.
  4. Сценарий из задачи «Нижний Тагил → 2.05 млн» сегодня недостижим: он требует, чтобы у цели был тагильский квартал, а у аналогов — ноль индексов. Но то же отсутствие кадастра, которое обнуляет weight_total, не даёт резолвиться и target_quarter. Аудит увидел вторую половину и не заметил первую.

Найдено попутно (не чиню здесь)

  • target_quarter берётся из кадастра первого попавшегося аналога, когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений. Спасает только то, что listings_clean отсортирован relevance_score, distance_m, то есть это ближайший аналог. Но «индекс цены квартала цели» на деле является индексом квартала соседа. Это отдельный дефект того же блока, шире C3.
  • 18 областных лотов несут кадастры 66:41: (ЕКБ) — Ревда и Первоуральск с екатеринбургскими кадастрами. Порча данных на стороне лоадера, не эстиматора.

Test plan

  • pytest tests/test_estimator_quarter_index.py → 38 passed
  • pytest -k "estimator or estimate" → 717 passed, 2 skipped
  • pre-commit (ruff + ruff-format, пиннутый v0.7.4) → passed
  • красный прогон новых тестов на origin/main-эстиматоре → 2 failed
  • не мержить без решения владельца — правка двигает видимую цену

Refs #2583

## Что не так `estimator.py` (было :2759, сейчас :3201): ```python avg_analog_index = weighted_sum / weight_total if weight_total > 0 else 1.0 ``` `price_index` нормирован на медиану Екатеринбурга (`99a_quarter_price_index.sql`), так что `1.0` — это не «нейтраль», а **«аналоги стоят как в ЕКБ»**. Утверждение делается ровно там, где про аналоги неизвестно ничего. `factor = target_index / avg_analog_index` схлопывается в `factor = target_qi`, а индексы области — 0.28–0.82. Пользователю это уходит подписью «Учтена локация квартала (индекс цен квартала ×0.60)», то есть догадка читается как методика. ## Замер: сегодня эффект НУЛЕВОЙ | | | |---|---| | Применений индекса за всё время | **15**, все — Екатеринбург | | Из них с `weight_total = 0` (фолбэк 1.0) | **0** | | Применений вне ЕКБ | **0** | Реконструкция по persisted-аналогам всех 15 оценок: у каждой был минимум 2 аналога с квартальным индексом. **Фолбэк не срабатывал на проде ни разу.** Ни одна существующая оценка после этой правки не изменится. Область до блока не доходит вовсе: `target_quarter` резолвится либо из `dadata.house_cadnum` (9 из 701 оценок, все ЕКБ), либо из кадастра первого аналога — а кадастр вне ЕКБ отсутствует (Тагил 1/1063, Каменск 0/823, Серов 0/381, Первоуральск 7/683, Пышма 10/461), причём все 18 «областных» кадастров на самом деле `66:41:` (ЕКБ), и на них Guard-2 даёт `same_quarter_ratio = 1.0` → skip. **Это задача «на вырост», не срочная.** Чиню потому, что мина взведена и правка стоит 21 строку. ## Насколько взведена 92 дома вне ЕКБ уже имеют кадастр + квартал в индексе с `n_deals >= 10`, и рядом с ними ноль проиндексированных аналогов. Как только кадастр цели дойдёт до эстиматора (это и есть #2576), текущий код навяжет им: | срез | множитель | срез цены | |---|---|---| | p10 | 0.96 | −4.0% | | **медиана** | **0.787** | **−21.3%** | | p90 / кламп-пол (13 домов) | 0.60 | **−40.0%** | В рублях, типовой лот 45 м² по медианам городов: | город | после фикса | текущий код, медианный случай | текущий код, хвост | потеря (медиана / хвост) | |---|---|---|---|---| | Верхняя Пышма | 5 240 070 | 4 181 576 | 3 144 042 | −1 058 494 / −2 096 028 | | Нижний Тагил | 3 071 059 | 2 450 705 | 1 842 636 | −620 354 / −1 228 424 | | Первоуральск | 3 016 174 | 2 406 907 | 1 809 704 | −609 267 / −1 206 469 | | Каменск-Уральский | 2 861 813 | 2 283 727 | 1 717 088 | −578 086 / −1 144 725 | | Серов | 2 552 537 | 2 036 924 | 1 531 522 | −515 612 / −1 021 015 | ## Что вместо 1.0 и почему **Пропуск поправки.** Из трёх вариантов он единственный, который не выдумывает данные, — и он же выигрывает по числам. Ключевое: `factor = target_qi / avg`, поэтому «пропустить поправку» **алгебраически тождественно** «взять индекс своего квартала как индекс аналогов» (`avg := target_qi` → `factor = 1.0`). То есть вариант «индекс своего города по неполным данным» тут не конкурент, а тот же самый ответ, записанный иначе. Насколько `target_qi` лучше `1.0` в роли неизвестного `avg` — замер на 400 случайных лотах с кадастром (полная формула из эстиматора, аналоги в 1 км): | оценка неизвестного avg | медиана \|ошибки\| | p90 \|ошибки\| | |---|---|---| | `1.0` (как сейчас) | 0.1612 | 0.5172 | | `target_qi` (= пропуск) | **0.1155** | **0.3374** | Внутри ЕКБ, где нормировка играет в пользу `1.0`, пропуск уже на 28% точнее по медиане и на 35% по хвосту. Вне ЕКБ разрыв кратный. Вариант «индекс региона» отброшен: медианы районов области 0.29–0.82 при разбросе внутри района до 4×, региональное среднее не ближе к истине, чем свой же квартал. ## Проверка направления на реальных сделках Правка **поднимает** цену — значит, обязана быть проверена на известных объектах, а не принята за «стало больше». Медианы ₽/м² реальных ДКП Росреестра за 12 мес против того, что даёт каждый вариант: | город | сделок | правда ₽/м² | после фикса | ошибка | текущий код | ошибка | |---|---|---|---|---|---|---| | Екатеринбург | 12 573 | 123 393 | 140 177 | **+14%** | 84 106 | −32% | | Нижний Тагил | 1 981 | 63 175 | 68 246 | **+8%** | 40 947 | −35% | | Каменск-Уральский | 992 | 63 529 | 63 596 | **0%** | 38 158 | −40% | | Первоуральск | 771 | 66 561 | 67 026 | **+1%** | 40 216 | −40% | | Верхняя Пышма | 593 | 104 695 | 116 446 | **+11%** | 69 868 | −33% | | Серов | 269 | 55 932 | 56 723 | **+1%** | 34 034 | −39% | Ошибка падает с 32–40% до 0–14% во всех шести городах. Остаточные +8…+14% — смещение коэффициента выкупа (#2617/#647), к C3 отношения не имеет. ## Тест: красный прогон на старом коде Естиматор откачен к `origin/main`, тесты новые: ``` FAILED test_c3_no_analog_has_cadastre_skips_correction FAILED test_c3_analog_quarters_not_in_index_skips_correction E AssertionError: assert 3600000 == 6000000 2 failed, 36 passed ``` 6 000 000 → 3 600 000, ровно −40%. С фиксом: `38 passed`. Третий тест (`test_c3_one_indexed_analog_is_enough_to_correct`) держит обратную сторону: как только уровень аналогов известен, поправка работает как раньше — фикс не глушит механизм, а требует под него данные. **Два прежних теста опирались на сам дефект** — `test_quarter_index_correction_applied` и `test_bimodal_guard_allows_high_index_large_n` задавали «уровень аналогов = 1.0» тем, что не давали аналогам кадастра, и комментарий это фиксировал: «делаем аналоги БЕЗ кадастрового номера → avg_analog_index = 1.0». Переведены на явную карту `analog_indexes` — утверждения о цене сохранены дословно, изменился только способ задать вход. ## Опровергнутые предпосылки исходной задачи 1. **«Блок обезоружен, ноль применений»** (комментарий от 02.08) — устарело. PR #2641 вернул грант 2026-08-04, FDW живой (1894 строки, `computed_at` 2026-08-05), блок отработал 6 раз с 5 по 12 августа, последний — сегодня 13:00 UTC. 2. **«Правка только в коде может не дать эффекта, данные идут через MV и FDW»** — для C3 неверно. Второй `1.0` (`basis='city_fallback'` в `99a`) и district-фолбэк имеют `n_deals` 3–4, а эстиматор требует `n_deals >= 10`. На проде: `quarter` 1612 строк / 1222 видимы, `district` 281 / **0**, `city_fallback` 1 / **0**. До эстиматора доходят только строки `basis='quarter'`. MV и FDW трогать не нужно — и не нужно рисковать вторым потребителем (site_finder в main-БД). 3. **«Нормировка на медиану ЕКБ — сама по себе ошибка»** — нет. `factor` это отношение, `city_median` сокращается. Нормировка протекает ровно в одной точке — когда одну сторону отношения подменяют литералом. После фикса зависимость от медианы ЕКБ исчезает из ценового тракта целиком. 4. **Сценарий из задачи «Нижний Тагил → 2.05 млн»** сегодня недостижим: он требует, чтобы у цели был тагильский квартал, а у аналогов — ноль индексов. Но то же отсутствие кадастра, которое обнуляет `weight_total`, не даёт резолвиться и `target_quarter`. Аудит увидел вторую половину и не заметил первую. ## Найдено попутно (не чиню здесь) - **`target_quarter` берётся из кадастра первого попавшегося аналога**, когда `dadata.house_cadnum` пуст — а он пуст в 15 из 15 применений. Спасает только то, что `listings_clean` отсортирован `relevance_score, distance_m`, то есть это ближайший аналог. Но «индекс цены квартала цели» на деле является индексом квартала соседа. Это отдельный дефект того же блока, шире C3. - **18 областных лотов несут кадастры `66:41:` (ЕКБ)** — Ревда и Первоуральск с екатеринбургскими кадастрами. Порча данных на стороне лоадера, не эстиматора. ## Test plan - [x] `pytest tests/test_estimator_quarter_index.py` → 38 passed - [x] `pytest -k "estimator or estimate"` → 717 passed, 2 skipped - [x] pre-commit (ruff + ruff-format, пиннутый v0.7.4) → passed - [x] красный прогон новых тестов на `origin/main`-эстиматоре → 2 failed - [ ] **не мержить без решения владельца** — правка двигает видимую цену Refs #2583
bot-backend added 1 commit 2026-08-12 14:19:18 +00:00
fix(tradein/estimate): нет индекса у аналогов — нет квартальной поправки (#2583 C3)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
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 Trade-In / backend-tests (pull_request) Successful in 3m59s
dbe3e05fb1
price_index нормирован на медиану Екатеринбурга (99a_quarter_price_index.sql),
поэтому фолбэк `avg_analog_index = ... else 1.0` подставлял в знаменатель
gap-коррекции не «нейтраль», а уровень ЕКБ. Для цели вне ЕКБ (индексы области
0.28–0.82) это превращало поправку в безусловную скидку: factor = target_qi,
после клампа до −40%, с подписью «Учтена локация квартала» — то есть догадка
выдавалась пользователю за методику.

Нет данных → нет поправки. Ровно тот же factor=1.0 получается из avg := target_qi,
и это лучшая оценка неизвестного avg на живых данных: медиана |ошибки| 0.116
против 0.161 у 1.0, p90 0.337 против 0.517 (400 лотов, 2026-08-12).

Проверка направления на сделках Росреестра (12 мес, медианы ₽/м² по городам):
без поправки ошибка +0…+14%, с текущей поправкой −32…−40%. Правка поднимает
цену и одновременно уводит её к правде, а не просто вверх.

MV и FDW не трогаются намеренно: строки basis='district'/'city_fallback' имеют
n_deals 3–4, а эстиматор требует n_deals >= 10 — второй 1.0 (city_fallback в
99a) до него структурно не доходит (прод: 0 из 1894 строк видимы).

Тесты: два прежних кейса задавали уровень аналогов отсутствием кадастра,
то есть опирались на сам дефект — переведены на явную карту analog_indexes.

Refs #2583
bot-backend added 1 commit 2026-08-17 07:41:39 +00:00
Merge remote-tracking branch 'forgejo/main' into local/pr2836
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 4m36s
c66047683c
lekss361 merged commit db4fb0a975 into main 2026-08-17 07:47:28 +00:00
lekss361 deleted branch fix/2583-c3-quarter-index-fallback 2026-08-17 07:47:28 +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#2836
No description provided.