feat(tradein/estimator): штраф за неизвестный год/тип дома — флагом, default OFF (#2936) #3015

Merged
bot-backend merged 1 commit from feat/2936-null-penalty-flag into main 2026-08-21 08:36:26 +00:00
Collaborator

Флаг вместо правки формулы — и измеренный ответ на вопрос, который задача оставила открытым: «какой отбор точнее».

Что сделано

Флаг estimate_unknown_attr_penalty_enabled, default OFF. Кандидат с NULL year_built/house_type получает медианный по пулу штраф того же признака среди тех, у кого он известен — не наказание и не награда, по тем же термам, что SQL (abs(Δyear)/12.0, 1.5 за несовпадение). Лежит в Python-слое после SQL, рядом с kitchen/ceiling (#2012), по тому же контракту: включать — только по бэктесту.

Без чего флаг был бы мёртв — и был в первой редакции. _ANALOG_SELECT_COLS не выбирал year_built/house_type: SQL считал по ним CASE, но в словарь кандидата колонки не попадали, Python-слой видел None у всех и не штрафовал никого по построению. Поймал probe-логом в прод-оверлее: pool=30 null_year=30. Добавлены в три места (константа, внешние SELECT тиров H/W, внутренний base Tier W — он строится явным списком). Контроль: флаг OFF с колонками и без — метрики бэктеста идентичны до сотых. На инвариант стоит тест по исходнику запросов.

Живой A/B — бэктест в прод-контейнере

Оверлей /tmp/ab (прод-код + этот diff), --city Екатеринбург --sample 300, одна и та же выборка в обоих прогонах (проверено по deal_id: 300 общих).

состав топ-50:    сменилось 96 слотов из 5 915 (1.6 %), 27 сделок из 300
источники:        avito 55.7→55.3 %  cian 24.5→24.8 %  yandex 12.7→12.6 %  domklik 7.1→7.3 %
цена:             MAPE 16.70→16.70   bias −4.52→−4.52   coverage 84.46→84.46   sharpness 0.743→0.743
                  идентично до сотых по сегментам (эконом/комфорт/бизнес) и комнатности
Нижний Тагил:     300 сделок, пулы 21/p90 40 — 0 из 5 666 слотов сменилось

Почему эффект в разы меньше, чем замер в задаче (×0.20 avito)

Тот замер шёл по SQL тира H без стратификации и без квоты. В боевом пути — измерено по фикстурам A/B:

  • 54.9 % слотов топ-50 — гарантированная квота MIN_ANALOGS_PER_SOURCE=5, раздаётся ДО сортировки остатка; штраф по построению переставляет меньше половины слотов;
  • у avito в топ-50 NULL-год лишь у 25.7 % (задача мерила 56 % по всем активным объявлениям) — в пулах боевого пути avito полнее, чем в среднем по таблице.

Что это значит

Артефакт в формуле реален, флаг его корректно лечит (состав меняется, probe видит штрафы), но на итоговую цену он не влияет измеримо. Оснований включать по умолчанию нет — и это и есть ответ, ради которого флаг заводился вместо правки формулы.

Оговорки, которые я обязан назвать

  • Выборка ЕКБ — 300 сделок Q2 2026 (самые свежие с geom; ORDER BY id DESC), одна выборка, один город. Не «весь рынок».
  • Бэктест по умолчанию (без --city) берёт 300 последних по id с geom — это малые города Q2 2024 (Тагил, Каменск…), где пулы 6–9 и штрафу нечего переставлять. Первые два A/B я прогнал именно так и получил «0 различий» — это артефакт выборки, не результат.
  • Между первыми прогонами я же геокодировал 7 610 Q2-сделок (#2998), и выборка «уехала» — поймано по deal_id, перегнано.

Как проверено

  • test_2936_unknown_attr_penalty.py — 7 тестов: медиана штрафа для NULL-года и NULL-типа, симметрия с SQL при неизвестном target, sparse-пропуск при < min_n, дефолт OFF, воспроизведение SQL-термов, и сторож «внешние SELECT тиров H/W выбирают year_built/house_type» (иначе флаг мёртв молча).
  • Гейт фикстуры + roundtrip — 8 passed (фикстура реплеится с замороженными listings, новые колонки её не трогают).
  • -k "estimat or analog" — 735 passed.

Часть #2936.

Флаг вместо правки формулы — и измеренный ответ на вопрос, который задача оставила открытым: «какой отбор точнее». ## Что сделано **Флаг `estimate_unknown_attr_penalty_enabled`, default OFF.** Кандидат с NULL `year_built`/`house_type` получает **медианный по пулу** штраф того же признака среди тех, у кого он известен — не наказание и не награда, по тем же термам, что SQL (`abs(Δyear)/12.0`, `1.5` за несовпадение). Лежит в Python-слое после SQL, рядом с kitchen/ceiling (#2012), по тому же контракту: включать — только по бэктесту. **Без чего флаг был бы мёртв — и был в первой редакции.** `_ANALOG_SELECT_COLS` не выбирал `year_built`/`house_type`: SQL считал по ним CASE, но в словарь кандидата колонки не попадали, Python-слой видел `None` у **всех** и не штрафовал никого по построению. Поймал probe-логом в прод-оверлее: `pool=30 null_year=30`. Добавлены в три места (константа, внешние SELECT тиров H/W, внутренний `base` Tier W — он строится явным списком). Контроль: флаг OFF с колонками и без — метрики бэктеста идентичны до сотых. На инвариант стоит тест по исходнику запросов. ## Живой A/B — бэктест в прод-контейнере Оверлей `/tmp/ab` (прод-код + этот diff), `--city Екатеринбург --sample 300`, одна и та же выборка в обоих прогонах (проверено по `deal_id`: 300 общих). ``` состав топ-50: сменилось 96 слотов из 5 915 (1.6 %), 27 сделок из 300 источники: avito 55.7→55.3 % cian 24.5→24.8 % yandex 12.7→12.6 % domklik 7.1→7.3 % цена: MAPE 16.70→16.70 bias −4.52→−4.52 coverage 84.46→84.46 sharpness 0.743→0.743 идентично до сотых по сегментам (эконом/комфорт/бизнес) и комнатности Нижний Тагил: 300 сделок, пулы 21/p90 40 — 0 из 5 666 слотов сменилось ``` ## Почему эффект в разы меньше, чем замер в задаче (×0.20 avito) Тот замер шёл по SQL тира H без стратификации и без квоты. В боевом пути — измерено по фикстурам A/B: - **54.9 % слотов топ-50 — гарантированная квота `MIN_ANALOGS_PER_SOURCE=5`**, раздаётся ДО сортировки остатка; штраф по построению переставляет меньше половины слотов; - у avito в топ-50 NULL-год лишь у **25.7 %** (задача мерила 56 % по всем активным объявлениям) — в пулах боевого пути avito полнее, чем в среднем по таблице. ## Что это значит Артефакт в формуле реален, флаг его корректно лечит (состав меняется, probe видит штрафы), но **на итоговую цену он не влияет измеримо**. Оснований включать по умолчанию нет — и это и есть ответ, ради которого флаг заводился вместо правки формулы. ## Оговорки, которые я обязан назвать - Выборка ЕКБ — 300 сделок **Q2 2026** (самые свежие с geom; `ORDER BY id DESC`), одна выборка, один город. Не «весь рынок». - Бэктест по умолчанию (без `--city`) берёт 300 последних по id с geom — это малые города Q2 2024 (Тагил, Каменск…), где пулы 6–9 и штрафу нечего переставлять. Первые два A/B я прогнал именно так и получил «0 различий» — это артефакт выборки, не результат. - Между первыми прогонами я же геокодировал 7 610 Q2-сделок (#2998), и выборка «уехала» — поймано по `deal_id`, перегнано. ## Как проверено - `test_2936_unknown_attr_penalty.py` — 7 тестов: медиана штрафа для NULL-года и NULL-типа, симметрия с SQL при неизвестном target, sparse-пропуск при `< min_n`, дефолт OFF, воспроизведение SQL-термов, и сторож «внешние SELECT тиров H/W выбирают year_built/house_type» (иначе флаг мёртв молча). - Гейт фикстуры + roundtrip — 8 passed (фикстура реплеится с замороженными `listings`, новые колонки её не трогают). - `-k "estimat or analog"` — 735 passed. Часть #2936.
bot-backend added 1 commit 2026-08-21 08:28:11 +00:00
feat(tradein/estimator): штраф за неизвестный год/тип дома — флагом, default OFF (#2936)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
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 / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m14s
865e64b783
В SQL-формуле relevance_score кандидат без year_built получает штраф 0 —
столько же, сколько точное попадание в год, и лучше, чем кандидат с
известным годом, отличающимся на 24 (2.0). То же с house_type. Отсутствие
данных выигрывает у знания и возвышает источник с худшей полнотой.

Флаг estimate_unknown_attr_penalty_enabled (default OFF): кандидат с NULL
получает МЕДИАННЫЙ по пулу штраф того же признака среди тех, у кого он
известен — не наказание и не награда; считается по тем же термам, что
SQL (abs(Δyear)/12.0, 1.5 за несовпадение типа). Лежит в Python-слое
после SQL, рядом с kitchen/ceiling (#2012), по тому же контракту:
включать — только по бэктесту.

Без чего флаг был бы мёртв (и был в первой редакции): _ANALOG_SELECT_COLS
не выбирал year_built/house_type — SQL считал по ним CASE, но в словарь
кандидата колонки не попадали, Python-слой видел None у ВСЕХ и не штрафовал
никого по построению. Probe-лог в прод-оверлее: pool=30 null_year=30.
Добавлены в _ANALOG_SELECT_COLS, во внешние SELECT тиров H/W и во
внутренний base Tier W (он строится явным списком). Контроль: флаг OFF с
колонками и без — метрики бэктеста идентичны до сотых. На этот инвариант
стоит тест по исходнику запросов.

Живой A/B (бэктест в прод-контейнере, оверлей /tmp/ab, 300 сделок ЕКБ
Q2 2026, одна и та же выборка в обоих прогонах — проверено по deal_id):
  состав топ-50: сменилось 96 слотов из 5 915 (1.6 %), 27 сделок из 300
  источники: avito 55.7→55.3 %, cian 24.5→24.8 %, yandex 12.7→12.6 %
  цена: MAPE 16.70→16.70, bias −4.52→−4.52, coverage 84.46→84.46 —
  идентично до сотых по всем срезам (сегменты, комнатность).
Нижний Тагил (300 сделок, пулы 21/p90 40): 0 из 5 666 слотов сменилось.

Почему эффект в разы меньше замера задачи (×0.20 avito): тот замер шёл
по SQL тира H без стратификации. В боевом пути 54.9 % слотов топ-50 —
гарантированная квота MIN_ANALOGS_PER_SOURCE=5, раздаётся ДО сортировки
остатка; и у avito в топ-50 NULL-год лишь у 25.7 % (задача мерила 56 %
по всем активным объявлениям). Обе величины измерены по фикстурам A/B.

Что это значит: артефакт в формуле есть, флаг его корректно лечит, но на
итоговую цену он не влияет измеримо. Включать по умолчанию оснований
нет — и это и есть ответ, ради которого флаг заводился вместо правки.

pytest tradein-mvp/backend: test_2936 7 passed; гейт фикстуры и
roundtrip 8 passed; -k "estimat or analog" 735 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bot-backend merged commit 6b4800c81d into main 2026-08-21 08:36:26 +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#3015
No description provided.