[HIGH] МЕРА/эстиматор: отбор сопоставимых предпочитает источник с худшими данными — отсутствие поля считается идеальным совпадением #2936

Closed
opened 2026-08-19 13:33:30 +00:00 by bot-backend · 4 comments
Collaborator

Механизм: отсутствие данных засчитывается как идеальное совпадение

relevance_score в тирах эстиматора (estimator.py:5830-5844 и три близнеца) складывается из расстояния и двух штрафов:

+ CASE WHEN year_built IS NOT NULL
       THEN abs(year_built - :target_year) / 12.0
       ELSE 0                                        -- <-- NULL не штрафуется
  END
+ CASE WHEN :target_house_type IS NOT NULL
            AND house_type IS NOT NULL
            AND house_type <> :target_house_type
       THEN 1.5
       ELSE 0                                        -- <-- NULL не штрафуется
  END

Меньше — релевантнее, и по этому числу берётся row_number() для отбора топ-N сопоставимых.

Замысел понятен и сам по себе разумен: не наказывать за то, чего не знаем. Но следствие обратное — кандидат без года получает штраф 0, то есть ровно столько же, сколько кандидат с точным попаданием в год, и лучше, чем кандидат, отличающийся на 24 года (штраф 2.0). То же с типом дома: неизвестный тип обгоняет известный несовпадающий.

Отсутствие данных выигрывает у знания.

Кого это возвышает: источник с худшей полнотой

Заполненность полей у активных объявлений (замер 19.08):

источник активных год постройки тип дома кухня
cian 19 345 97% 59% 45%
yandex 16 144 99% 71% 55%
avito 13 160 44% 0% 21%
domklik 984 99% 0% 35%

Перекос на реальных данных

150 целей (объявления Циана с известными годом и типом дома), кандидаты в радиусе 1 км, формула воспроизведена по SQL выше, топ-20 по relevance_score:

источник доля в пуле доля в топ-20 перекос
avito 21.3% 42.1% ×1.98
cian 39.4% 46.4% ×1.18
domklik 2.0% 2.9% ×1.45
yandex 37.3% 8.7% ×0.23

Авито забирает вдвое больше мест среди сопоставимых, чем его доля в пуле. Яндекс — источник с ЛУЧШИМ покрытием года (99%) — получает вчетверо меньше своей доли. Направление ровно противоположно желаемому: отбор систематически предпочитает то, о чём мы знаем меньше.

Что в этом замере честно, а что нет

  • Формула воспроизведена по SQL тира H, но это не боевой запрос: без тировых фильтров, дедупа и площадных ограничений. Порядок величины и направление показательны, точные числа — нет.
  • Цели взяты только из Циана (нужны непустые год и тип дома, иначе оба штрафа не работают вовсе).
  • Влияние на итоговую оценку не измерено. Перекос в составе сопоставимых — не то же самое, что смещение цены: если авитошные аналоги в среднем не отличаются по цене, эффекта может не быть. Это следующий замер, и делать вывод о деньгах до него нельзя.

Почему это не чинится наполнением Авито

Пробел в полях — следствие #2827: detail-страницы Авито отдают HTTP 439 независимо от выходного IP и типа клиента, обогащение не работает с 16.08 (до этого — с перерывами). То есть «просто дособрать поля» сейчас нельзя, и перекос будет жить, пока живёт блок.

Варианты (решение за владельцем)

  1. Штрафовать неизвестность — заменить ELSE 0 на срединный штраф (например, медиану штрафа по пулу). Тогда незнание перестаёт быть преимуществом, но и не приравнивается к худшему случаю.
  2. Требовать поле — исключать кандидатов без года, когда у цели год известен. Резко, режет пул на четверть.
  3. Нормировать по источнику — квота на долю источника в топ-N. Сложно и лечит симптом.
  4. Ничего, если следующий замер покажет, что на итоговой цене это не сказывается.

Первый вариант выглядит соразмерным, но начинать надо с замера влияния на цену, а не с правки формулы.

## Механизм: отсутствие данных засчитывается как идеальное совпадение `relevance_score` в тирах эстиматора (`estimator.py:5830-5844` и три близнеца) складывается из расстояния и двух штрафов: ```sql + CASE WHEN year_built IS NOT NULL THEN abs(year_built - :target_year) / 12.0 ELSE 0 -- <-- NULL не штрафуется END + CASE WHEN :target_house_type IS NOT NULL AND house_type IS NOT NULL AND house_type <> :target_house_type THEN 1.5 ELSE 0 -- <-- NULL не штрафуется END ``` Меньше — релевантнее, и по этому числу берётся `row_number()` для отбора топ-N сопоставимых. Замысел понятен и сам по себе разумен: не наказывать за то, чего не знаем. Но следствие обратное — кандидат **без года** получает штраф 0, то есть ровно столько же, сколько кандидат с точным попаданием в год, и **лучше**, чем кандидат, отличающийся на 24 года (штраф 2.0). То же с типом дома: неизвестный тип обгоняет известный несовпадающий. Отсутствие данных выигрывает у знания. ## Кого это возвышает: источник с худшей полнотой Заполненность полей у активных объявлений (замер 19.08): | источник | активных | год постройки | тип дома | кухня | |---|---:|---:|---:|---:| | cian | 19 345 | 97% | 59% | 45% | | yandex | 16 144 | **99%** | 71% | 55% | | avito | 13 160 | **44%** | **0%** | 21% | | domklik | 984 | 99% | **0%** | 35% | ## Перекос на реальных данных 150 целей (объявления Циана с известными годом и типом дома), кандидаты в радиусе 1 км, формула воспроизведена по SQL выше, топ-20 по `relevance_score`: | источник | доля в пуле | доля в топ-20 | перекос | |---|---:|---:|---:| | **avito** | 21.3% | **42.1%** | **×1.98** | | cian | 39.4% | 46.4% | ×1.18 | | domklik | 2.0% | 2.9% | ×1.45 | | **yandex** | 37.3% | **8.7%** | **×0.23** | Авито забирает вдвое больше мест среди сопоставимых, чем его доля в пуле. Яндекс — источник с ЛУЧШИМ покрытием года (99%) — получает вчетверо меньше своей доли. Направление ровно противоположно желаемому: отбор систематически предпочитает то, о чём мы знаем меньше. ### Что в этом замере честно, а что нет - Формула воспроизведена по SQL тира H, но это **не** боевой запрос: без тировых фильтров, дедупа и площадных ограничений. Порядок величины и направление показательны, точные числа — нет. - Цели взяты только из Циана (нужны непустые год и тип дома, иначе оба штрафа не работают вовсе). - Влияние на **итоговую оценку** не измерено. Перекос в составе сопоставимых — не то же самое, что смещение цены: если авитошные аналоги в среднем не отличаются по цене, эффекта может не быть. Это следующий замер, и делать вывод о деньгах до него нельзя. ## Почему это не чинится наполнением Авито Пробел в полях — следствие #2827: detail-страницы Авито отдают HTTP 439 независимо от выходного IP и типа клиента, обогащение не работает с 16.08 (до этого — с перерывами). То есть «просто дособрать поля» сейчас нельзя, и перекос будет жить, пока живёт блок. ## Варианты (решение за владельцем) 1. **Штрафовать неизвестность** — заменить `ELSE 0` на срединный штраф (например, медиану штрафа по пулу). Тогда незнание перестаёт быть преимуществом, но и не приравнивается к худшему случаю. 2. **Требовать поле** — исключать кандидатов без года, когда у цели год известен. Резко, режет пул на четверть. 3. **Нормировать по источнику** — квота на долю источника в топ-N. Сложно и лечит симптом. 4. **Ничего**, если следующий замер покажет, что на итоговой цене это не сказывается. Первый вариант выглядит соразмерным, но начинать надо с замера влияния на цену, а не с правки формулы.
Author
Collaborator

Замер влияния на цену сделан — вывод придётся смягчить

В теле задачи я написал, что вывод о деньгах делать нельзя, пока не измерено влияние на итоговую оценку. Измерил. 390 целей, те же пулы, сравнение медианы price_per_m2 топ-20 с авитошными аналогами и без них:

целей                         390
медиана сдвига               0.00 %
p90                        +10.84 %
p10                         −6.91 %
сдвиг больше 2%          184 цели  (47%)
сдвиг больше 5%          114 целей (29%)

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

Что остаётся: разброс. У 29% целей медиана сопоставимых уходит больше чем на 5% в зависимости от того, попали ли переоценённые авитошные аналоги в топ-20. Это не смещение, а шум — и порождён он артефактом полноты данных, а не свойствами объекта.

Чего этот замер НЕ различает

Сравнение «с Авито против без Авито» смешивает две разные вещи:

  1. артефакт переоценки (Авито берёт вдвое больше мест, чем его доля в пуле);
  2. настоящий вклад Авито как источника (13 160 живых объявлений — это реальный рынок, а не мусор).

Убрав Авито целиком, я убрал обе. Чистый тест артефакта — сравнить «Авито с его естественной долей пула» против «Авито переоценённый», а не против его отсутствия. Пока это не сделано, утверждать «артефакт добавляет N% шума» нельзя: часть разброса — законная.

Что это меняет в приоритете

Задачу оставляю открытой, но понижаю в срочности: механизм доказан по SQL и перекос состава измерен (×1.98 против ×0.23), а вот доказанного вреда для цены нет. Вариант 2 из тела («исключать кандидатов без года») теперь выглядит явно избыточным — он резал бы четверть пула ради устранения шума, величина которого не отделена от полезного сигнала.

Разумный следующий шаг — не правка формулы, а тот самый разделяющий замер.

### Замер влияния на цену сделан — вывод придётся смягчить В теле задачи я написал, что вывод о деньгах делать нельзя, пока не измерено влияние на итоговую оценку. Измерил. 390 целей, те же пулы, сравнение медианы `price_per_m2` топ-20 **с** авитошными аналогами и **без** них: ``` целей 390 медиана сдвига 0.00 % p90 +10.84 % p10 −6.91 % сдвиг больше 2% 184 цели (47%) сдвиг больше 5% 114 целей (29%) ``` **Систематического смещения цены нет.** Медианный сдвиг ровно нулевой, хвосты почти симметричны. То есть естественное опасение «перекос в пользу Авито завышает или занижает оценки» данными **не подтверждается**. Что остаётся: разброс. У 29% целей медиана сопоставимых уходит больше чем на 5% в зависимости от того, попали ли переоценённые авитошные аналоги в топ-20. Это не смещение, а **шум** — и порождён он артефактом полноты данных, а не свойствами объекта. ### Чего этот замер НЕ различает Сравнение «с Авито против без Авито» смешивает две разные вещи: 1. артефакт переоценки (Авито берёт вдвое больше мест, чем его доля в пуле); 2. настоящий вклад Авито как источника (13 160 живых объявлений — это реальный рынок, а не мусор). Убрав Авито целиком, я убрал обе. Чистый тест артефакта — сравнить «Авито с его естественной долей пула» против «Авито переоценённый», а не против его отсутствия. Пока это не сделано, утверждать «артефакт добавляет N% шума» нельзя: часть разброса — законная. ### Что это меняет в приоритете Задачу оставляю открытой, но понижаю в срочности: механизм доказан по SQL и перекос состава измерен (×1.98 против ×0.23), а вот доказанного вреда для цены нет. Вариант 2 из тела («исключать кандидатов без года») теперь выглядит явно избыточным — он резал бы четверть пула ради устранения шума, величина которого не отделена от полезного сигнала. Разумный следующий шаг — не правка формулы, а тот самый разделяющий замер.
Author
Collaborator

Разделяющий замер сделан: артефакт даёт не смещение, а шум

В прошлом комментарии я написал, что сравнение «с Авито против без Авито» смешивает артефакт переоценки и законный вклад источника, и что нужен другой замер. Сделал его.

Метод. Тот же пул, тот же топ-20, меняется ТОЛЬКО одно: штраф за незнание. Сейчас неизвестный год и неизвестный тип дома дают 0 — то есть считаются идеальным совпадением. В контрфактическом варианте они получают медианный по пулу штраф: не наказание, но и не награда. Источники, дистанции, пороги — не тронуты.

Что меняется в составе (400 целей, 7 979 слотов топ-20):

источник сейчас при медианном штрафе
avito 3 775 (47.3%) 764 (9.6%) ×0.20
yandex 701 (8.8%) 1 826 (22.9%) ×2.6

Пятикратное падение доли Авито и двух-с-половиной-кратный рост Яндекса — это и есть чистая величина артефакта. Ни свойства объектов, ни расстояния при этом не изменились: сдвинулось только то, как оценивается ОТСУТСТВИЕ данных.

Что меняется в цене:

целей                        400
медиана сдвига              0.29 %
p90                       +14.67 %
p10                        −7.05 %
сдвиг больше 5%          157 целей  (39%)

Систематического смещения нет — 0.29% при разбросе в десятки процентов это ноль. Гипотезу «отбор в пользу источника с худшими данными завышает или занижает оценки» надо считать отвергнутой во второй раз, теперь на чистом эксперименте.

Остаётся шум: у 39% целей медиана сопоставимых уходит больше чем на 5% в зависимости от того, как оценено незнание. Это не свойство объекта и не свойство рынка — это свойство полноты нашего сбора. Хвост при этом асимметричный (p90 +14.7 против p10 −7.1), то есть артефакт чаще завышает, чем занижает, но не настолько, чтобы это стало смещением.

Чего этот замер по-прежнему НЕ говорит

Он показывает, что два отбора различаются, но не какой из них точнее. Возможно, авитошные аналоги — объективно лучшие сопоставимые по причинам, не связанным с годом и типом дома, и тогда текущее поведение случайно полезно.

Ответить может только сверка с фактом: прогнать оба варианта отбора через бэктест на состоявшихся сделках и сравнить MAPE. Это следующий шаг, и до него менять формулу не стоит — иначе получится правка, обоснованная тем, что «стало по-другому», а не тем, что «стало точнее».

Итог по приоритету

Механизм доказан по SQL, величина артефакта измерена (×0.20 / ×2.6), влияние на цену измерено и оказалось шумом без смещения. Задача перестаёт быть срочной и становится кандидатом на калибровку вместе с бэктестом.

### Разделяющий замер сделан: артефакт даёт не смещение, а шум В прошлом комментарии я написал, что сравнение «с Авито против без Авито» смешивает артефакт переоценки и законный вклад источника, и что нужен другой замер. Сделал его. **Метод.** Тот же пул, тот же топ-20, меняется ТОЛЬКО одно: штраф за незнание. Сейчас неизвестный год и неизвестный тип дома дают `0` — то есть считаются идеальным совпадением. В контрфактическом варианте они получают **медианный по пулу** штраф: не наказание, но и не награда. Источники, дистанции, пороги — не тронуты. **Что меняется в составе (400 целей, 7 979 слотов топ-20):** | источник | сейчас | при медианном штрафе | | |---|---:|---:|---| | **avito** | 3 775 (47.3%) | **764 (9.6%)** | ×0.20 | | **yandex** | 701 (8.8%) | **1 826 (22.9%)** | ×2.6 | Пятикратное падение доли Авито и двух-с-половиной-кратный рост Яндекса — это и есть чистая величина артефакта. Ни свойства объектов, ни расстояния при этом не изменились: сдвинулось только то, как оценивается ОТСУТСТВИЕ данных. **Что меняется в цене:** ``` целей 400 медиана сдвига 0.29 % p90 +14.67 % p10 −7.05 % сдвиг больше 5% 157 целей (39%) ``` **Систематического смещения нет** — 0.29% при разбросе в десятки процентов это ноль. Гипотезу «отбор в пользу источника с худшими данными завышает или занижает оценки» надо считать отвергнутой во второй раз, теперь на чистом эксперименте. Остаётся **шум**: у 39% целей медиана сопоставимых уходит больше чем на 5% в зависимости от того, как оценено незнание. Это не свойство объекта и не свойство рынка — это свойство полноты нашего сбора. Хвост при этом асимметричный (p90 +14.7 против p10 −7.1), то есть артефакт чаще завышает, чем занижает, но не настолько, чтобы это стало смещением. ### Чего этот замер по-прежнему НЕ говорит Он показывает, что два отбора **различаются**, но не какой из них **точнее**. Возможно, авитошные аналоги — объективно лучшие сопоставимые по причинам, не связанным с годом и типом дома, и тогда текущее поведение случайно полезно. Ответить может только сверка с фактом: прогнать оба варианта отбора через бэктест на состоявшихся сделках и сравнить MAPE. Это следующий шаг, и до него менять формулу не стоит — иначе получится правка, обоснованная тем, что «стало по-другому», а не тем, что «стало точнее». ### Итог по приоритету Механизм доказан по SQL, величина артефакта измерена (×0.20 / ×2.6), влияние на цену измерено и оказалось шумом без смещения. Задача перестаёт быть срочной и становится кандидатом на калибровку вместе с бэктестом.
Author
Collaborator

Бэктест сделан — ответ на открытый вопрос «какой отбор точнее»: никакой

Предыдущие замеры в этом треде закончились честным «до бэктеста формулу менять не стоит». Сделал флаг (default OFF) и прогнал бэктест живьём в прод-контейнере, PR #3015.

Главный результат, 300 сделок ЕКБ, одна выборка в обоих прогонах:

состав топ-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
                 идентично до сотых по сегментам и комнатности

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

Почему ×0.20 из замера выше не воспроизводится в боевом пути

Тот замер — по SQL тира H без стратификации. Измерено по фикстурам A/B:

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

Две находки по пути, важнее самого A/B

  1. Флаг был мёртв в первой редакции — и так же мёртв был бы любой Python-штраф по этим признакам. _ANALOG_SELECT_COLS не выбирал year_built/house_type: SQL считал по ним CASE, но в словарь кандидата колонки не попадали. Первые A/B дали «0 различий» — probe-лог показал pool=30 null_year=30. Добавлены в три места (константа, внешние SELECT H/W, внутренний base W), на инвариант стоит тест по исходнику.
  2. Бэктест по умолчанию мерит не ЕКБ. Без --city он берёт 300 последних по id с geom — это малые города Q2 2024 (Тагил, Каменск, Первоуральск), пулы 6–9, эффекта нет по построению. Два моих первых A/B прошли именно так. С --city Екатеринбург выборка — Q2 2026 (самые свежие, только что геокодированные #2998). Это стоит иметь в виду при чтении любых прошлых «бэктест нейтрален».

Статус

Задача — кандидат на закрытие как «измерено, включать не стоит», флаг остаётся инструментом для будущих калибровок. Решение оставляю владельцу.

## Бэктест сделан — ответ на открытый вопрос «какой отбор точнее»: никакой Предыдущие замеры в этом треде закончились честным «до бэктеста формулу менять не стоит». Сделал флаг (default OFF) и прогнал бэктест живьём в прод-контейнере, PR #3015. **Главный результат, 300 сделок ЕКБ, одна выборка в обоих прогонах:** ``` состав топ-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 идентично до сотых по сегментам и комнатности ``` Артефакт реален, штраф его лечит (состав меняется, probe-лог видит начисления) — **на цену это не влияет измеримо**. Включать нет оснований. ### Почему ×0.20 из замера выше не воспроизводится в боевом пути Тот замер — по SQL тира H без стратификации. Измерено по фикстурам A/B: - **54.9 % слотов топ-50 — гарантированная квота `MIN_ANALOGS_PER_SOURCE=5`**, раздаётся ДО сортировки остатка. Штраф переставляет меньше половины слотов по построению. - У avito **в топ-50** NULL-год лишь у 25.7 % — против 56 % по всем активным объявлениям, которые мерила задача. В пулах боевого пути avito полнее среднего. ### Две находки по пути, важнее самого A/B 1. **Флаг был мёртв в первой редакции — и так же мёртв был бы любой Python-штраф по этим признакам.** `_ANALOG_SELECT_COLS` не выбирал `year_built`/`house_type`: SQL считал по ним CASE, но в словарь кандидата колонки не попадали. Первые A/B дали «0 различий» — probe-лог показал `pool=30 null_year=30`. Добавлены в три места (константа, внешние SELECT H/W, внутренний `base` W), на инвариант стоит тест по исходнику. 2. **Бэктест по умолчанию мерит не ЕКБ.** Без `--city` он берёт 300 последних по `id` с geom — это малые города Q2 2024 (Тагил, Каменск, Первоуральск), пулы 6–9, эффекта нет по построению. Два моих первых A/B прошли именно так. С `--city Екатеринбург` выборка — Q2 2026 (самые свежие, только что геокодированные #2998). Это стоит иметь в виду при чтении любых прошлых «бэктест нейтрален». ### Статус Задача — кандидат на закрытие как «измерено, включать не стоит», флаг остаётся инструментом для будущих калибровок. Решение оставляю владельцу.
Owner

Ревизия открытых задач 2026-08-30. Проверено в коде на forgejo/main — сделано, закрываю.

Штраф за неизвестный атрибут есть: отсутствующий year_built/house_type больше не считается идеальным совпадением. tests/test_2936_unknown_attr_penalty.py — докстринг описывает ровно дефект из тикета (relevance_score давал 0 и кандидату без года, и точному попаданию).

Ревизия открытых задач 2026-08-30. Проверено в коде на forgejo/main — сделано, закрываю. Штраф за неизвестный атрибут есть: отсутствующий year_built/house_type больше не считается идеальным совпадением. tests/test_2936_unknown_attr_penalty.py — докстринг описывает ровно дефект из тикета (relevance_score давал 0 и кандидату без года, и точному попаданию).
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#2936
No description provided.