[HIGH] tradein/estimate: домовой якорь подбирается без учёта ремонта, а подмешивается только вверх — после починки параметров это станет системати… #2677

Closed
opened 2026-08-05 19:55:47 +00:00 by bot-backend · 2 comments
Collaborator

Найдено при ревью PR #2675. Дефекта в том PR нет — наоборот, риск создаёт сама починка, и лучше увидеть его до массовой пересъёмки, а не после.

Что происходит

Подбор домового якоря оценки Авито идёт по комнатности и площади и не смотрит на тип ремонта сохранённой строки. А подмешивание якоря к цене однонаправленное вверх: срабатывает при превышении порога и тянет оценку только в большую сторону, симметричного эффекта вниз нет.

Пока все 2685 домовых оценок были запрошены как «косметический ремонт» (#2674), база была кривой, но однородной — якорь одинаково смещён у всех, и это частично гасилось калибровкой.

После того как параметры начнут браться из данных, однородность исчезнет. Дом, где объявления скошены в «евро» и «дизайнерский», получит более дорогой якорь — и этот якорь применится к клиентской квартире с любым её ремонтом, включая «требует ремонта». Только вверх.

Сценарий

Дом, где два из трёх объявлений — «евро». Якорь снимается по этой моде и оказывается заметно выше рынка «убитых» квартир того же дома. Клиент с квартирой, требующей ремонта, получает оценку, поднятую якорем чужого сегмента.

Величина сдвига не измерена — измерить нельзя, пока лежит сайдкар (#2676) и пока не пересняты параметры. Это и есть главный аргумент за осторожность.

Что предлагается

  1. Не делать массовую пересъёмку сразу. В PR #2675 это уже заложено: 1343 дома, около 5400 запросов, 27 суток при текущем темпе. Первые ~50 домов переснять отдельно и замерить сдвиг оценки до и после на тех же входных данных.
  2. Учитывать ремонт при подборе якоря — либо выбирать строку, близкую по ремонту к оцениваемой квартире, либо корректировать якорь отношением коэффициентов ремонта (они в коде уже есть).
  3. Если ни то ни другое не выходит дёшево — сузить условие подмешивания, чтобы якорь с ремонтом, далёким от клиентского, не участвовал вовсе.

Почему это HIGH, хотя сегодня не стреляет

Сегодня домовая оценка не сохраняет ни одной строки два с половиной месяца (#2674), поэтому эффекта нет. Но как только прокси-парк починится и бэкфилл заработает, механизм включится сам, без единого нового коммита — и первым, что заметят, будет «оценки поехали вверх», без очевидной причины в истории изменений.

Связано: #2674, #2675, #2676, #2638.

Найдено при ревью PR #2675. Дефекта в том PR нет — наоборот, **риск создаёт сама починка**, и лучше увидеть его до массовой пересъёмки, а не после. ## Что происходит Подбор домового якоря оценки Авито идёт по комнатности и площади и **не смотрит на тип ремонта** сохранённой строки. А подмешивание якоря к цене **однонаправленное вверх**: срабатывает при превышении порога и тянет оценку только в большую сторону, симметричного эффекта вниз нет. Пока все 2685 домовых оценок были запрошены как «косметический ремонт» (#2674), база была **кривой, но однородной** — якорь одинаково смещён у всех, и это частично гасилось калибровкой. После того как параметры начнут браться из данных, однородность исчезнет. Дом, где объявления скошены в «евро» и «дизайнерский», получит **более дорогой якорь** — и этот якорь применится к клиентской квартире с **любым** её ремонтом, включая «требует ремонта». Только вверх. ## Сценарий Дом, где два из трёх объявлений — «евро». Якорь снимается по этой моде и оказывается заметно выше рынка «убитых» квартир того же дома. Клиент с квартирой, требующей ремонта, получает оценку, поднятую якорем чужого сегмента. Величина сдвига **не измерена** — измерить нельзя, пока лежит сайдкар (#2676) и пока не пересняты параметры. Это и есть главный аргумент за осторожность. ## Что предлагается 1. **Не делать массовую пересъёмку сразу.** В PR #2675 это уже заложено: 1343 дома, около 5400 запросов, 27 суток при текущем темпе. Первые ~50 домов переснять отдельно и **замерить сдвиг оценки до и после** на тех же входных данных. 2. **Учитывать ремонт при подборе якоря** — либо выбирать строку, близкую по ремонту к оцениваемой квартире, либо корректировать якорь отношением коэффициентов ремонта (они в коде уже есть). 3. Если ни то ни другое не выходит дёшево — **сузить условие подмешивания**, чтобы якорь с ремонтом, далёким от клиентского, не участвовал вовсе. ## Почему это HIGH, хотя сегодня не стреляет Сегодня домовая оценка не сохраняет ни одной строки два с половиной месяца (#2674), поэтому эффекта нет. Но как только прокси-парк починится и бэкфилл заработает, механизм включится **сам**, без единого нового коммита — и первым, что заметят, будет «оценки поехали вверх», без очевидной причины в истории изменений. Связано: #2674, #2675, #2676, #2638.
Author
Collaborator

Второй разбор, 2026-08-10. PR #2816 — открыт, не смержен: правка двигает цену, решение за владельцем.

Все вызывающие _fetch_house_imv_anchor (полнота)

  1. app/services/estimator.py:3913 (estimate_quality, POST) → imv_anchor_price_from_inputsединственный путь, влияющий на цену.
  2. app/api/v1/trade_in.py:357 (GET-rehydrate по ?id=/shared-link/PDF) → только карточка avito_imv в ответе; медиана берётся из персистированной строки. На цену не влияет.
  3. scripts/backtest_estimator.py:1654 — офлайн-харнесс, не прод.

Полнота проверена не одним грепом по имени: (а) грep по имени функции по всему репо; (б) грep по таблице house_imv_evaluations во всём app/, scripts/, packages/ — читает её только эстиматор, остальные точки пишут (house_imv_backfill, house_dedup_merge, admin); (в) грep по динамическому доступу (getattr(est|estimator|m…), globals()[...], importlib, __dict__[...]) в app/пусто; (г) грep по вызывающим _price_from_inputs — в проде один, estimate_quality.

Перемеренные числа против первого разбора

Замер прогнан тем же кодом, что и прод, внутри tradein-backend: match_house_readonly_fetch_house_imv_anchor_apply_imv_blend, только SELECT + rollback, 1061 персистированная оценка.

первый разбор этот замер
срабатываний blend'а 0 0 (0 маркеров «скорректирована по оценке Avito IMV»)
медиана anchor/median 0.876 0.911 (n=236; p25 0.764, p75 1.043)
оценок с резолвнутым домом 674/1061
оценок с домовым якорем 240/1061
якорь > median×1.15 19/240

«0 срабатываний» подтвердилось, но другим прибором: не по вызовам функции, а по следу в confidence_explanation. Этот канал показать срабатывание способен — в той же колонке лежат 174 заметки про ремонт, 269 про «того же дома», 456 про «ближайшее окружение».

Расхождение 0.876 → 0.911 не воспроизводится точно: знаменатель у меня — персистированная (пост-всё) медиана, у первого разбора он не назван. Обе цифры < 1 и ведут к одному выводу.

У нуля есть причина, и это не «неприменимо по построению». Все 19 пересечений порога гасит гейт anchor_tier is None (blend — фолбэк Tier D): у дома, где есть IMV-строка, почти всегда есть и комплы того же дома. Механизм заблокирован выше по потоку, а не выключен.

Дефект по существу — есть

median_price к моменту blend'а домножен на _repair_coefficient, якорь — нет. Порог и смешивание кладут два базиса на одну шкалу; при весе 0.5 сработавший blend возвращает половину поправки на ремонт обратно.

Контрфакт на 19 оценках, старый код против базис-согласованного: 9 оценок «требует ремонта» −3.3…−9.8 % (медиана −7.9 %, −4.13 млн ₽), 1 оценка «хороший ремонт» +9.6 % (+0.47 млн ₽), остальные 9 — ровно 0 (якорь cosmetic, ремонт клиента standard/неизвестен).

Реализованный денежный эффект сегодня: 0 ₽.

Опровергнутые предпосылки

  • «Дом со скосом в „евро“ получит более дорогой якорь» — сегодня не наступило. 2668 из 2674 строк по-прежнему cosmetic; после разблокировки #2698 переснято 22 дома, из них с не-cosmetic ремонтом 6 (3 euro, 3 required). Однородность базы пока держится.
  • «Подмешивается только вверх» — про blend верно, про взаимодействие с ремонтом нет. Знак ошибки идёт за коэффициентом клиента: «требует ремонта» завышается (+3 % от якоря плюс более частое срабатывание), «евро» занижается (−5 %) и срабатывает реже. Не систематический сдвиг вверх, а наполовину отменённая поправка на ремонт.
  • «needs_repair срабатывает в 6 раз чаще» — артефакт счёта по оценкам. По оценкам 9/32 (28 %) против 4–14 % у остальных, но 9 срабатываний дают 4 разных дома (170577 ×4, 184767 ×3, 279888, 235114) — это повторные оценки одной квартиры. По РАЗНЫМ ДОМАМ: needs_repair 4/17 (23.5 %), standard 3/15 (20 %), ремонт неизвестен 5/26 (19.2 %) — разницы нет. Что остаётся: good 0/9 и excellent 0/7, направленно верно, но n мал.
  • «Выбрать строку, ближе по ремонту» — невыполнимо, подтверждено. house_imv_eval_house_uniq_idx UNIQUE (house_id), строка на дом одна.
  • ✔️ Ремонт не указан у 768/1061 = 72.4 % оценок — подтверждается. Для них множитель = 1.0, правка их не трогает.
  • Моя собственная предпосылка, что повышающая половина правки безопасна, — не подтвердилась. На известном объекте (дом 6767, Гаршина 3/2, «хороший ремонт») правка поднимает 153 629 → 168 324 ₽/м² при p75 сделок дома 165 479. Понижающая половина проверку проходит (дом 184767, Софьи Перовской 119: 100 411 → 90 566 ₽/м² при медиане сделок ≈91 300), повышающая — нет. n = 2 объекта, обе опираются на те же непроверенные _REPAIR_COEF.

Что сделано / не сделано

Сделано: якорь приводится к базису ремонта клиента отношением тех же коэффициентов, что уже применены к медиане (+ renovation_type в SELECT — без него правка была бы тихим no-op). Тест: 6 кейсов, все красные на origin/main на цифрах (6170000≠5640000, 6500000≠6000000, 7820000≠7520000).

Не сделано осознанно: однонаправленность blend'а (продуктовое решение, не дефект #2677) и гейт anchor_tier is None (существующий дизайн, именно он держит эффект на нуле).

Критерий прод-приёмки записан в PR до мержа, дата проверки 2026-09-10.

Второй разбор, 2026-08-10. PR #2816 — открыт, **не смержен**: правка двигает цену, решение за владельцем. ## Все вызывающие `_fetch_house_imv_anchor` (полнота) 1. `app/services/estimator.py:3913` (`estimate_quality`, POST) → `imv_anchor` → `_price_from_inputs` → **единственный путь, влияющий на цену**. 2. `app/api/v1/trade_in.py:357` (GET-rehydrate по `?id=`/shared-link/PDF) → только карточка `avito_imv` в ответе; медиана берётся из персистированной строки. **На цену не влияет.** 3. `scripts/backtest_estimator.py:1654` — офлайн-харнесс, не прод. Полнота проверена не одним грепом по имени: (а) грep по имени функции по всему репо; (б) грep по таблице `house_imv_evaluations` во всём `app/`, `scripts/`, `packages/` — читает её только эстиматор, остальные точки пишут (`house_imv_backfill`, `house_dedup_merge`, `admin`); (в) грep по динамическому доступу (`getattr(est|estimator|m…)`, `globals()[...]`, `importlib`, `__dict__[...]`) в `app/` — **пусто**; (г) грep по вызывающим `_price_from_inputs` — в проде один, `estimate_quality`. ## Перемеренные числа против первого разбора Замер прогнан **тем же кодом**, что и прод, внутри `tradein-backend`: `match_house_readonly` → `_fetch_house_imv_anchor` → `_apply_imv_blend`, только SELECT + rollback, 1061 персистированная оценка. | | первый разбор | этот замер | |---|---|---| | срабатываний blend'а | 0 | **0** (0 маркеров `«скорректирована по оценке Avito IMV»`) | | медиана `anchor/median` | 0.876 | **0.911** (n=236; p25 0.764, p75 1.043) | | оценок с резолвнутым домом | — | **674**/1061 | | оценок с домовым якорем | — | **240**/1061 | | якорь > `median×1.15` | — | **19**/240 | «0 срабатываний» **подтвердилось**, но другим прибором: не по вызовам функции, а по следу в `confidence_explanation`. Этот канал показать срабатывание способен — в той же колонке лежат 174 заметки про ремонт, 269 про «того же дома», 456 про «ближайшее окружение». Расхождение 0.876 → 0.911 не воспроизводится точно: знаменатель у меня — персистированная (пост-всё) медиана, у первого разбора он не назван. Обе цифры < 1 и ведут к одному выводу. **У нуля есть причина, и это не «неприменимо по построению».** Все 19 пересечений порога гасит гейт `anchor_tier is None` (blend — фолбэк Tier D): у дома, где есть IMV-строка, почти всегда есть и комплы того же дома. Механизм заблокирован **выше по потоку**, а не выключен. ## Дефект по существу — есть `median_price` к моменту blend'а домножен на `_repair_coefficient`, якорь — нет. Порог и смешивание кладут два базиса на одну шкалу; при весе 0.5 сработавший blend возвращает половину поправки на ремонт обратно. Контрфакт на 19 оценках, старый код против базис-согласованного: 9 оценок «требует ремонта» −3.3…−9.8 % (медиана −7.9 %, **−4.13 млн ₽**), 1 оценка «хороший ремонт» +9.6 % (+0.47 млн ₽), остальные 9 — ровно 0 (якорь `cosmetic`, ремонт клиента `standard`/неизвестен). **Реализованный денежный эффект сегодня: 0 ₽.** ## Опровергнутые предпосылки - ❌ **«Дом со скосом в „евро“ получит более дорогой якорь» — сегодня не наступило.** 2668 из 2674 строк по-прежнему `cosmetic`; после разблокировки #2698 переснято 22 дома, из них с не-`cosmetic` ремонтом **6** (3 `euro`, 3 `required`). Однородность базы пока держится. - ❌ **«Подмешивается только вверх» — про blend верно, про взаимодействие с ремонтом нет.** Знак ошибки идёт за коэффициентом клиента: «требует ремонта» завышается (~+3 % от якоря плюс более частое срабатывание), «евро» **занижается** (~−5 %) и срабатывает реже. Не систематический сдвиг вверх, а наполовину отменённая поправка на ремонт. - ❌ **«needs_repair срабатывает в 6 раз чаще» — артефакт счёта по оценкам.** По оценкам 9/32 (28 %) против 4–14 % у остальных, но 9 срабатываний дают **4 разных дома** (170577 ×4, 184767 ×3, 279888, 235114) — это повторные оценки одной квартиры. По РАЗНЫМ ДОМАМ: `needs_repair` 4/17 (23.5 %), `standard` 3/15 (20 %), ремонт неизвестен 5/26 (19.2 %) — разницы нет. Что остаётся: `good` 0/9 и `excellent` 0/7, направленно верно, но n мал. - ❌ **«Выбрать строку, ближе по ремонту» — невыполнимо, подтверждено.** `house_imv_eval_house_uniq_idx UNIQUE (house_id)`, строка на дом одна. - ✔️ Ремонт не указан у **768/1061 = 72.4 %** оценок — подтверждается. Для них множитель = 1.0, правка их не трогает. - ❌ **Моя собственная предпосылка, что повышающая половина правки безопасна, — не подтвердилась.** На известном объекте (дом 6767, Гаршина 3/2, «хороший ремонт») правка поднимает 153 629 → 168 324 ₽/м² при p75 сделок дома 165 479. Понижающая половина проверку проходит (дом 184767, Софьи Перовской 119: 100 411 → 90 566 ₽/м² при медиане сделок ≈91 300), повышающая — нет. n = 2 объекта, обе опираются на те же непроверенные `_REPAIR_COEF`. ## Что сделано / не сделано Сделано: якорь приводится к базису ремонта клиента отношением тех же коэффициентов, что уже применены к медиане (+ `renovation_type` в SELECT — без него правка была бы тихим no-op). Тест: 6 кейсов, все красные на `origin/main` на цифрах (6170000≠5640000, 6500000≠6000000, 7820000≠7520000). Не сделано осознанно: однонаправленность blend'а (продуктовое решение, не дефект #2677) и гейт `anchor_tier is None` (существующий дизайн, именно он держит эффект на нуле). Критерий прод-приёмки записан в PR **до** мержа, дата проверки 2026-09-10.
lekss361 added the
bug
priority/p1
scope/backend
tradein
labels 2026-08-16 10:25:14 +00:00
Owner

Закрываю по итогам разбора трекера 16.08.2026

Вердикт: сделано кодом.

Доказательство: PR #2816 (смержен 12.08.2026): _anchor_repair_factor() в estimator.py:494-513 приводит IMV-якорь к базису ремонта целевой квартиры перед смешиванием (estimator.py:3141-3152). Ровно тот перекос, о котором задача: евро-якорь задирал квартиру с убитым ремонтом.

Независимая проверка. Вердикт проверялся отдельным проходом, задачей которого было именно опровергнуть закрытие, а не подтвердить его — опровергнуть не удалось.

Если что-то из перечисленного всё же живо — переоткройте задачу, разбор мог упустить частный случай.

## Закрываю по итогам разбора трекера 16.08.2026 **Вердикт:** сделано кодом. **Доказательство:** PR #2816 (смержен 12.08.2026): `_anchor_repair_factor()` в `estimator.py:494-513` приводит IMV-якорь к базису ремонта целевой квартиры перед смешиванием (`estimator.py:3141-3152`). Ровно тот перекос, о котором задача: евро-якорь задирал квартиру с убитым ремонтом. **Независимая проверка.** Вердикт проверялся отдельным проходом, задачей которого было именно опровергнуть закрытие, а не подтвердить его — опровергнуть не удалось. Если что-то из перечисленного всё же живо — переоткройте задачу, разбор мог упустить частный случай.
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#2677
No description provided.