fix(tradein): «медианный торг» гаснет на псевдорепликах и на неправдоподобном минусе (#2672) #2706

Merged
bot-backend merged 1 commit from fix/2672-distinct-listings-gate into main 2026-08-06 06:48:49 +00:00
Collaborator

Что было

Гейт #2666/#2671 считал пары, а пары не являются независимыми наблюдениями. DISTINCT ON подбирает по объявлению на сделку, но одно объявление переиспользуется на десятках сделок улицы: у показываемых групп медиана — 18 сделок на одно различное объявление.

Из 64 показываемых чисел 22 (34%) стояли на ОДНОМ объявлении, 50 (78%) — меньше чем на трёх. Живой худший случай: Белинского 1-комн. — 50 пар, 1 объявление, −50.6%. «50 пар» там означало не 50 наблюдений рынка, а 50 сделок, поделённых на ОДНУ цену предложения: число говорило о том, чем эта конкретная квартира отличалась от типичной сделки. Это не компромисс «мало данных» — это фабрикация точности.

Второе: нижняя граница −60% оказалась слишком мягкой, а не слишком строгой. 26 из 64 показываемых чисел лежали ниже −23.7% (худший объяснимый рынком бакет asking_to_sold_ratios), самое глубокое — −58.5%. Мы гасили «+34%» и показывали «−58.5%», полученный из ТОГО ЖЕ артефакта пейринга.

Что стало

SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS = 2 (новая проверка между «мало пар» и «вне диапазона») и SANE_DISCOUNT_MIN_PCT −60.0 → −35.0. Форма отказа прежняя: median_discount_explanation вместо пустоты.

Новый текст называет оба числа — «сделок 30, а разных объявлений для сравнения всего 1»: «сделок 30» в одиночку читается как «данных достаточно», то есть ровно наоборот. В лог добавлено distinct_listings= — частота нового признака видна в проде, а не только здесь.

ВИДИМОЕ СОКРАЩЕНИЕ: 64 → 32 группы (50% → 25% покрытия)

Это половина показываемых чисел, и это заметно на экране. Замер сделан на том коде, что мержится: обе версии модуля (origin/main и эта ветка) загружены через importlib в один процесс и прогнаны через один вход — 128 групп пар, снятых с прода симуляцией эндпоинта на настоящих пользовательских запросах (DISTINCT address/area/rooms из trade_in_estimates, 410 запросов → 128 групп хотя бы с одной парой).

групп % от 128
было (пар ≥ 10, [−60,+20]) 64 50%
стало (+ объявлений ≥ 2, [−35,+20]) 32 25%
погашено по «одно объявление» 22
погашено по границе −35% 10

Проверено тем же прогоном: погашенных без объяснения — 0; total_deals / deals_with_listings / linkage_rate_pct / data_quality / список пар побайтово совпадают между версиями (assert в замере) — гаснет ровно строка «медианный торг».

Что стало с 26 числами ниже −23.7%: осталось 10 (от −33.9 до −25.2, все на ≥2 объявлениях). Граница −35% намеренно «заведомо не рынок», а не «рыночно правдоподобно»: у большого минуса есть законный механизм (занижение цены в ДКП), и запас под него сохранён.

Почему порог по объявлениям = 2, а не 3

Это граница выразимости, а не статистики. На группе с одним объявлением ни джекнайф, ни кластерный бутстрап не дают числа вообще: выкидывать нечего, пересэмплировать нечего, отклонение тождественно 0. Их «нулевая ошибка» — не малая ошибка, а отсутствие измерения, и агрегат по всем 64 группам от их присутствия УЛУЧШАЛСЯ (кластер-бутстрап p90 16.0 → 11.5): метрика становилась тем зеленее, чем больше в ней неизмеримого. Ниже 2 нет выборки, о разбросе которой можно спрашивать.

Выше 2 порог уже статистический, и данные говорят, что он должен быть выше — но ценой почти всей витрины (прод 2026-08-06, джекнайф по объявлениям на переживших гейт):

порог групп % джекнайф p90
объявлений ≥ 2 42 33% 17.4 п.п.
объявлений ≥ 3 14 11% 10.9 п.п.
объявлений ≥ 4 7 5% 5.5 п.п.

Принятая в #2671 планка шума (бутстрап пар, k=10) — p90 12.0. В неё попадает только ≥ 3, и он оставил бы 9% витрины после границы −35% — при этом всё равно не сделал бы число защищаемым: ошибка со стороны СДЕЛОК никуда не делась и складывается с этой. Выбор между «9% покрытия» и «выключить строку совсем» — решение владельца, а не гейта; здесь снимается ровно то, что не является наблюдением рынка в принципе. Цифры для обоих вариантов — в шапке секции в коде, поднять порог = одна константа.

Понижать MIN_PAIRS в компенсацию нельзя: вернувшиеся группы стоят на тех же одном-двух объявлениях (ложная точность) — это отдельно показано в #2672.

Что НЕ входит

  • Пейринг по дому вместо улицы не делается — ADR #721, это и есть настоящее решение. Гейт по различным объявлениям убирает числа, которые не являются наблюдением, но оставшиеся 32 всё ещё сравнивают сделку в одном доме с объявлением в другом. Остаётся открытым.
  • Пункт 3 issue (поштучный discount_pct в таблице пар не гасится вместе со сводным числом — под погашенной медианой видны строки +76%, +73% против той же одной цены) — фронт, отдельная задача.

Test plan

tradein-mvp/backend/tests/test_sales_vs_listings.py — 25 passed. Полный бэкенд-прогон: 3542 passed, 9 skipped. tests/test_search_api.py не собирается (ValidationError на импорте) — предсуществующее, воспроизведено на этой же ветке с откаченным гейтом.

Новое (все — живые прод-кейсы, а не выдуманные фикстуры):

  • test_median_discount_gated_when_all_pairs_share_one_listingАкадемика Ландау 1-комн.: 30 пар, 1 объявление, −12.36%. Кейс намеренно «скучный»: пар втрое больше порога, значение рыночное — сработать может только новый признак;
  • test_median_discount_one_listing_gated_before_rangeБелинского 1-комн.: 50 пар, 1 объявление, −50.6%. Закрепляет порядок: причина «одно объявление», а не «неправдоподобное значение»;
  • test_median_discount_kosmonavtov_117_pairs_two_listings — 117 пар / 2 объявления / −37.6%: порог по объявлениям НЕ срабатывает (2 ≥ 2), гасит граница;
  • test_median_discount_kept_at_min_distinct_listings_boundary — 117 пар / 2 объявления / −12.98% проходят (граница включительная);
  • test_median_discount_gated_below_tightened_floor — Серов, Ленина 163, 21 пара, −46.5% (живой пользовательский запрос из ревью).

Хелпер _rows_on_shared_listings(discounts, n_listings) — псевдореплики: сделки уникальны, listing_id переиспользуется. Существующий _rows_with_discounts (одно объявление на пару) эту форму данных не выражал, поэтому старые тесты и не могли поймать дефект.

Фальсификация (патч-метод, без stash):

  1. Откат только гейта в api/v1/trade_in.py (тесты на месте) → 4 failed: три новых + gated_below_tightened_floor. Старый код показывает все эти числа.
  2. Тесты «число сохраняется» на шаге 1 зелёные и покраснеть не могут — они сторожат противоположное направление. Отдельный прогон с ужесточёнными константами (MIN_DISTINCT_LISTINGS 2→3, SANE_MIN −35→−34) кладёт именно их: 3 failed, включая оба kept_*.

ruff check + ruff format --check на изменённых файлах — чисто, pre-commit прошёл. Миграция не нужна: гейт целиком прикладной, SQL-функция не меняется.

Что проверить руками после деплоя

Открыть оценку по улице с длинной историей сделок (например Белинского 1-комн.) и убедиться, что вместо «медианный торг −50.6%» видна причина со словами «разных объявлений для сравнения всего 1», а карточка (сделки / медиана ₽/м² / диапазон / таблица пар) на месте.

Refs #2672

## Что было Гейт #2666/#2671 считал **пары**, а пары не являются независимыми наблюдениями. `DISTINCT ON` подбирает по объявлению на сделку, но одно объявление переиспользуется на десятках сделок улицы: у показываемых групп медиана — **18 сделок на одно различное объявление**. Из 64 показываемых чисел **22 (34%) стояли на ОДНОМ объявлении**, 50 (78%) — меньше чем на трёх. Живой худший случай: `Белинского` 1-комн. — **50 пар, 1 объявление, −50.6%**. «50 пар» там означало не 50 наблюдений рынка, а 50 сделок, поделённых на ОДНУ цену предложения: число говорило о том, чем эта конкретная квартира отличалась от типичной сделки. Это не компромисс «мало данных» — это фабрикация точности. Второе: нижняя граница `−60%` оказалась слишком **мягкой**, а не слишком строгой. **26 из 64** показываемых чисел лежали ниже −23.7% (худший объяснимый рынком бакет `asking_to_sold_ratios`), самое глубокое — **−58.5%**. Мы гасили «+34%» и показывали «−58.5%», полученный из ТОГО ЖЕ артефакта пейринга. ## Что стало `SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS = 2` (новая проверка между «мало пар» и «вне диапазона») и `SANE_DISCOUNT_MIN_PCT` −60.0 → **−35.0**. Форма отказа прежняя: `median_discount_explanation` вместо пустоты. Новый текст называет **оба** числа — «сделок 30, а разных объявлений для сравнения всего 1»: «сделок 30» в одиночку читается как «данных достаточно», то есть ровно наоборот. В лог добавлено `distinct_listings=` — частота нового признака видна в проде, а не только здесь. ## ВИДИМОЕ СОКРАЩЕНИЕ: 64 → 32 группы (50% → 25% покрытия) Это половина показываемых чисел, и это заметно на экране. **Замер сделан на том коде, что мержится**: обе версии модуля (`origin/main` и эта ветка) загружены через `importlib` в один процесс и прогнаны через один вход — 128 групп пар, снятых с прода симуляцией эндпоинта на настоящих пользовательских запросах (`DISTINCT address/area/rooms` из `trade_in_estimates`, 410 запросов → 128 групп хотя бы с одной парой). | | групп | % от 128 | |---|---|---| | было (пар ≥ 10, [−60,+20]) | 64 | 50% | | **стало** (+ объявлений ≥ 2, [−35,+20]) | **32** | **25%** | | погашено по «одно объявление» | 22 | | | погашено по границе −35% | 10 | | Проверено тем же прогоном: погашенных **без объяснения — 0**; `total_deals` / `deals_with_listings` / `linkage_rate_pct` / `data_quality` / список пар побайтово совпадают между версиями (assert в замере) — гаснет ровно строка «медианный торг». Что стало с 26 числами ниже −23.7%: осталось **10** (от −33.9 до −25.2, все на ≥2 объявлениях). Граница −35% намеренно «заведомо не рынок», а не «рыночно правдоподобно»: у большого минуса есть законный механизм (занижение цены в ДКП), и запас под него сохранён. ## Почему порог по объявлениям = 2, а не 3 **Это граница выразимости, а не статистики.** На группе с одним объявлением ни джекнайф, ни кластерный бутстрап не дают числа вообще: выкидывать нечего, пересэмплировать нечего, отклонение тождественно 0. Их «нулевая ошибка» — не малая ошибка, а **отсутствие измерения**, и агрегат по всем 64 группам от их присутствия УЛУЧШАЛСЯ (кластер-бутстрап p90 16.0 → 11.5): метрика становилась тем зеленее, чем больше в ней неизмеримого. Ниже 2 нет выборки, о разбросе которой можно спрашивать. Выше 2 порог уже статистический, и данные говорят, что он должен быть выше — но ценой почти всей витрины (прод 2026-08-06, джекнайф по объявлениям на переживших гейт): | порог | групп | % | джекнайф p90 | |---|---|---|---| | объявлений ≥ 2 | 42 | 33% | 17.4 п.п. | | объявлений ≥ 3 | 14 | 11% | 10.9 п.п. | | объявлений ≥ 4 | 7 | 5% | 5.5 п.п. | Принятая в #2671 планка шума (бутстрап пар, k=10) — p90 12.0. В неё попадает только **≥ 3**, и он оставил бы 9% витрины после границы −35% — при этом всё равно не сделал бы число защищаемым: ошибка со стороны СДЕЛОК никуда не делась и складывается с этой. Выбор между «9% покрытия» и «выключить строку совсем» — решение владельца, а не гейта; здесь снимается ровно то, что не является наблюдением рынка в принципе. Цифры для обоих вариантов — в шапке секции в коде, поднять порог = одна константа. Понижать `MIN_PAIRS` в компенсацию нельзя: вернувшиеся группы стоят на тех же одном-двух объявлениях (ложная точность) — это отдельно показано в #2672. ## Что НЕ входит - **Пейринг по дому вместо улицы не делается** — ADR #721, это и есть настоящее решение. Гейт по различным объявлениям убирает числа, которые не являются наблюдением, но оставшиеся 32 всё ещё сравнивают сделку в одном доме с объявлением в другом. **Остаётся открытым.** - **Пункт 3 issue** (поштучный `discount_pct` в таблице пар не гасится вместе со сводным числом — под погашенной медианой видны строки +76%, +73% против той же одной цены) — фронт, отдельная задача. ## Test plan `tradein-mvp/backend/tests/test_sales_vs_listings.py` — 25 passed. Полный бэкенд-прогон: 3542 passed, 9 skipped. `tests/test_search_api.py` не собирается (`ValidationError` на импорте) — **предсуществующее**, воспроизведено на этой же ветке с откаченным гейтом. Новое (все — живые прод-кейсы, а не выдуманные фикстуры): - `test_median_discount_gated_when_all_pairs_share_one_listing` — `Академика Ландау` 1-комн.: 30 пар, 1 объявление, −12.36%. Кейс намеренно «скучный»: пар втрое больше порога, значение рыночное — сработать может только новый признак; - `test_median_discount_one_listing_gated_before_range` — `Белинского` 1-комн.: 50 пар, 1 объявление, −50.6%. Закрепляет порядок: причина «одно объявление», а не «неправдоподобное значение»; - `test_median_discount_kosmonavtov_117_pairs_two_listings` — 117 пар / 2 объявления / −37.6%: порог по объявлениям НЕ срабатывает (2 ≥ 2), гасит граница; - `test_median_discount_kept_at_min_distinct_listings_boundary` — 117 пар / 2 объявления / −12.98% проходят (граница включительная); - `test_median_discount_gated_below_tightened_floor` — Серов, Ленина 163, 21 пара, −46.5% (живой пользовательский запрос из ревью). Хелпер `_rows_on_shared_listings(discounts, n_listings)` — псевдореплики: сделки уникальны, `listing_id` переиспользуется. Существующий `_rows_with_discounts` (одно объявление на пару) эту форму данных не выражал, поэтому старые тесты и не могли поймать дефект. **Фальсификация (патч-метод, без stash):** 1. Откат только гейта в `api/v1/trade_in.py` (тесты на месте) → **4 failed**: три новых + `gated_below_tightened_floor`. Старый код показывает все эти числа. 2. Тесты «число сохраняется» на шаге 1 зелёные и покраснеть не могут — они сторожат противоположное направление. Отдельный прогон с ужесточёнными константами (`MIN_DISTINCT_LISTINGS` 2→3, `SANE_MIN` −35→−34) кладёт именно их: **3 failed**, включая оба `kept_*`. `ruff check` + `ruff format --check` на изменённых файлах — чисто, pre-commit прошёл. Миграция не нужна: гейт целиком прикладной, SQL-функция не меняется. ## Что проверить руками после деплоя Открыть оценку по улице с длинной историей сделок (например `Белинского` 1-комн.) и убедиться, что вместо «медианный торг −50.6%» видна причина со словами «разных объявлений для сравнения всего 1», а карточка (сделки / медиана ₽/м² / диапазон / таблица пар) на месте. Refs #2672
bot-backend added 1 commit 2026-08-06 06:45:11 +00:00
fix(tradein): «медианный торг» гаснет на псевдорепликах и на неправдоподобном минусе (#2672)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 3m2s
189379627d
Гейт #2666/#2671 считал ПАРЫ, а пары не являются независимыми наблюдениями:
DISTINCT ON берёт по объявлению на сделку, но одно объявление переиспользуется
на десятках сделок улицы (у показываемых групп медиана — 18 сделок на одно
различное объявление). Из 64 показываемых чисел 22 (34%) стояли на ОДНОМ
объявлении: «50 пар» у Белинского 1-комн. означало 50 сделок, поделённых на
одну цену предложения, то есть не наблюдение рынка, а фабрикацию точности.

Порог по объявлениям = 2 — это граница выразимости, а не статистики: на одном
объявлении ни джекнайф, ни кластерный бутстрап не дают числа вообще (выкидывать
и пересэмплировать нечего, отклонение тождественно 0), и агрегат по всем 64
группам от добавления таких групп УЛУЧШАЛСЯ — метрика становилась тем зеленее,
чем больше в ней неизмеримого. Обоснование порогов выше 2 и их цена — в шапке
секции.

Нижняя граница ужесточена −60% → −35%. Исходное подозрение «−60% режет живой
рынок» проверено и опровергнуто: до −60% проходило всё. Ошибка была в другую
сторону — 26 из 64 показываемых чисел лежали ниже −23.7% (худший объяснимый
рынком бакет asking_to_sold_ratios), самое глубокое — −58.5%. Мы гасили «+34%»
и показывали «−58.5%» из того же артефакта пейринга, а абсурдный минус, в
отличие от абсурдного плюса, себя не опровергает.

Прод-замер (2026-08-06, 128 групп с парами, обе версии модуля в одном процессе):
показываемых 64 (50%) → 32 (25%); погасло 32 — 22 по «одно объявление», 10 по
границе. Каждое погашенное число уходит с объяснением (0 молчаливых), пары,
linkage и медиана ₽/м² не тронуты.

Refs #2672
bot-backend merged commit 90c193f898 into main 2026-08-06 06:48:49 +00:00
bot-backend deleted branch fix/2672-distinct-listings-gate 2026-08-06 06:48:49 +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#2706
No description provided.