fix(tradein/sql): цена в подписи дедупликации «доли квартир дома в продаже» #2539

Merged
lekss361 merged 2 commits from fix/tradein-sale-share-dedup-signature into main 2026-07-26 22:50:39 +00:00
Owner

Проблема

Метрика «доля квартир дома в продаже» считала числитель как count(DISTINCT (rooms, round(area_m2), floor)). Замысел верный — убрать кросс-площадочные дубли, когда одна квартира висит на Авито, ЦИАН и Домклике. Но в типовом секционном доме четыре разные квартиры на одном этаже в разных подъездах имеют ровно ту же тройку и схлопываются в одну. Подъезда в данных нет.

Валидация исходной миграции 148 проверяла только «числитель не вырос ни у одного дома» — то есть недо-схлопывание не проверялось вовсе.

Замеры на боевой базе

По активным вторичным объявлениям с заполненными ключевыми полями:

записей
без дедупликации 15 424
нынешняя подпись 11 324
с ценовым бакетом 12 497

Метрика занижена примерно на 10%, правка возвращает 1 173 квартиры.

Из 3 220 схлопываемых групп 1 252 группы (1 756 записей) имеют разные цены, а 233 группы (249 записей) пришли с одной площадки. И то и другое — почти наверняка разные квартиры: одна площадка редко публикует одну квартиру дважды, а разные квартиры в одном доме почти никогда не стоят ровно одинаково.

Решение

В подпись добавлен ценовой бакет round(price_rub / 100000) — тот же приём и та же величина, что уже используются для дедупликации аналогов в estimator.py (обоснование бакета там же: ±0,5% при 21 млн, ±2% при 2,5 млн). Пустая или неположительная цена даёт NULL, то есть такая запись не схлопывается ни с чем — та же философия, что для пустых rooms/area/floor в исходной миграции.

Все накопленные наследниками условия сохранены байт в байт: гео-ограничение ≤300 м (150), проверка этажности ±3 (152), правдоподобие по медианной этажности (153), приоритет ЖКХ в знаменателе (149).

Чего решение не делает — честно

Требование «с разных площадок» в SQL не выражено. Оно потребовало бы двухуровневой агрегации по всем шести фильтрованным агрегатам, и риск регрессии там выше пользы. Следствия задокументированы в комментарии миграции:

  • 233 одноплощадочные группы, если у них к тому же совпала цена, останутся ложно схлопнутыми — недосчёт сохраняется, но на меньшем подмножестве;
  • появляется новый класс ошибки в обратную сторону: настоящий кросс-пост, у которого цена разошлась между скрейпами (снизили на одной площадке раньше другой), теперь не схлопнется, и числитель для таких домов будет завышен. Раньше он схлопывался верно.

Найдено попутно, не в этом PR

buildings_query.py::build_listings_query (панель листингов одного дома) реализует ту же старую тройку самостоятельно, своим DISTINCT ON, не читая view. После этой миграции список домов и детальная панель дома начнут расходиться в числе уникальных квартир. Стоит завести отдельную задачу на консистентность.

Test plan

  • uv run pytest -q — 2658 passed, 8 skipped; изменений в тестах не потребовалось
  • Потребители view проверены грепом — ломающихся нет
  • Миграция переименована с 189 на 190: номер 189 занят фиксом счётчика квот в параллельном PR #2538
  • Живой прогон новой подписи на проде не выполнялся — у агента не было доступа к базе; числа выше замерены мной отдельно
## Проблема Метрика «доля квартир дома в продаже» считала числитель как `count(DISTINCT (rooms, round(area_m2), floor))`. Замысел верный — убрать кросс-площадочные дубли, когда одна квартира висит на Авито, ЦИАН и Домклике. Но в типовом секционном доме четыре **разные** квартиры на одном этаже в разных подъездах имеют ровно ту же тройку и схлопываются в одну. Подъезда в данных нет. Валидация исходной миграции 148 проверяла только «числитель не вырос ни у одного дома» — то есть недо-схлопывание не проверялось вовсе. ## Замеры на боевой базе По активным вторичным объявлениям с заполненными ключевыми полями: | | записей | |---|---| | без дедупликации | 15 424 | | нынешняя подпись | 11 324 | | с ценовым бакетом | 12 497 | **Метрика занижена примерно на 10%**, правка возвращает 1 173 квартиры. Из 3 220 схлопываемых групп **1 252 группы (1 756 записей) имеют разные цены**, а 233 группы (249 записей) пришли **с одной площадки**. И то и другое — почти наверняка разные квартиры: одна площадка редко публикует одну квартиру дважды, а разные квартиры в одном доме почти никогда не стоят ровно одинаково. ## Решение В подпись добавлен ценовой бакет `round(price_rub / 100000)` — тот же приём и та же величина, что уже используются для дедупликации аналогов в `estimator.py` (обоснование бакета там же: ±0,5% при 21 млн, ±2% при 2,5 млн). Пустая или неположительная цена даёт `NULL`, то есть такая запись не схлопывается ни с чем — та же философия, что для пустых `rooms`/`area`/`floor` в исходной миграции. Все накопленные наследниками условия сохранены байт в байт: гео-ограничение ≤300 м (150), проверка этажности ±3 (152), правдоподобие по медианной этажности (153), приоритет ЖКХ в знаменателе (149). ## Чего решение не делает — честно **Требование «с разных площадок» в SQL не выражено.** Оно потребовало бы двухуровневой агрегации по всем шести фильтрованным агрегатам, и риск регрессии там выше пользы. Следствия задокументированы в комментарии миграции: - 233 одноплощадочные группы, если у них к тому же совпала цена, останутся ложно схлопнутыми — недосчёт сохраняется, но на меньшем подмножестве; - появляется **новый** класс ошибки в обратную сторону: настоящий кросс-пост, у которого цена разошлась между скрейпами (снизили на одной площадке раньше другой), теперь не схлопнется, и числитель для таких домов будет завышен. Раньше он схлопывался верно. ## Найдено попутно, не в этом PR `buildings_query.py::build_listings_query` (панель листингов одного дома) реализует **ту же старую тройку самостоятельно**, своим `DISTINCT ON`, не читая view. После этой миграции список домов и детальная панель дома начнут расходиться в числе уникальных квартир. Стоит завести отдельную задачу на консистентность. ## Test plan - [x] `uv run pytest -q` — 2658 passed, 8 skipped; изменений в тестах не потребовалось - [x] Потребители view проверены грепом — ломающихся нет - [x] Миграция переименована с 189 на 190: номер 189 занят фиксом счётчика квот в параллельном PR #2538 - [ ] Живой прогон новой подписи на проде не выполнялся — у агента не было доступа к базе; числа выше замерены мной отдельно
lekss361 added 2 commits 2026-07-26 21:43:39 +00:00
count(DISTINCT (rooms, round(area_m2), floor)) в v_building_sale_share
(мигр. 148) ложно схлопывает разные квартиры одного этажа в разных
подъездах (подъезда в данных нет). Прод-замер: 15424 raw -> 11324 старая
сигнатура -> 12497 с добавленным price_bucket=round(price_rub/100000)
(+1173, +10.4%). Продуктовое решение: дедуп только при совпадении ЕЩЁ И
цены (тот же бакет, что в estimator.py::_DEDUP_PRICE_BUCKET_RUB).

Требование "с разных площадок" не выражено в SQL (потребовало бы
двухуровневой агрегации across всех 6 FILTER-агрегатов CTE) -- задокументирован
residual risk в мигр. 189: одноплощадочные группы с совпавшей ценой
остаются ложно схлопнуты; кросс-посты с ценовым дрейфом между скрейпами
перестают схлопываться.

price_rub NULL/<=0 -> price_bucket NULL -> листинг не дедупится ни с чем
(консервативно, как в мигр. 148 для NULL rooms/area/floor).
chore(tradein/sql): переименовать миграцию 189 -> 190 (номер занят фиксом счётчика квот)
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 5m3s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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
5c91df2442
lekss361 merged commit a450aed71b into main 2026-07-26 22:50:39 +00:00
lekss361 deleted branch fix/tradein-sale-share-dedup-signature 2026-07-26 22:50:40 +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#2539
No description provided.