[tradein][backend] Сегментация аналогов/ratio по ppm²-тиру — убрать смешение подрынков (expected_sold недооценка) #928

Closed
opened 2026-05-31 20:19:06 +00:00 by lekss361 · 2 comments
Owner

Проблема (найдено при golden-сверке #794 vs Brusnika EKB-2485)

Golden-смок (Софьи Перовской 119, 2к/42.9м²/панель-1978): headline median 3.94 млн — в эталоне (gold asking 3.4–4.6 млн, +2.4% к середине ). Но expected_sold 3.16 млн — на ~9% ниже выкупа Брусники (~3.47 млн).

Root cause: пул asking_to_sold_ratios бакетится только по district × rooms_bucketratio=0.8025 для 2-комн района вбирает монолит-новостройки Пехотинцев (107–117k ₽/м²), разбавляя эконом-панель и занижая sold-коррекцию. Headline не страдает (там работает same-building anchor + IQR/MAD), течёт именно ratio-пул и, шире, отбор аналогов по «чужому» подрынку.

Данные (прод, active listings) — почему именно ценовой признак

сигнал покрытие
price_per_m2 100% (20459/20459)
year_built 27% (5455)
house_type 22% (4457)

house_type/year_built слишком разрежены для жёсткого фильтра (выкинут 70–78% аналогов). ppm² — единственный data-complete признак. Класс дома — soft-обогащение на потом (когда дозаполним enrichment через houses).

Предлагаемое решение

  1. ppm²-тир, якорённый на same-building anchor (против циркулярности — band от цены того же дома, не от самой медианы).
  2. asking_to_sold_ratios: расширить ключ district × rooms_bucket× ppm²_tier (терцили распределения district×rooms). Миграция 080-таблицы + пересчёт (tasks/asking_to_sold_ratio.py).
  3. Отбор аналогов (_fetch_analogs) и ДКП-коридор (_fetch_dkp_corridor): опциональный soft-band по ppm² вокруг anchor, чтобы отсекать заведомо чужой подрынок (мягко, без схлопывания выборки).
  4. Класс дома (year_built-band / house_type через join на houses) — soft-сигнал/tiebreaker там, где заполнено; НЕ хард-фильтр.

DoD

  • Спроектирован тир-ключ + способ его вычисления (терцили? фикс-границы?).
  • Миграция asking_to_sold_ratios (additive, idempotent, авто-apply на прод через deploy — учесть, что это деструктивно/строго).
  • Точки фильтрации в estimator определены с сохранением graceful-fallback при тонкой выборке.
  • Бэктест на golden-наборе (55 offline + Brusnika EKB-2485): expected_sold должен приблизиться к gold, headline — НЕ деградировать.

Ограничения

  • ⚠️ НЕ в live до демо (2026-06-01) — headline уже точен, менять диапазон ради expected_sold рискованно. Реализация после демо, за бэктест-гейтом (как #887 step 4).
  • scope: только tradein estimator. Файлы: app/services/estimator.py (_get_asking_sold_ratio, _fetch_analogs, _fetch_dkp_corridor), data/sql/080_asking_to_sold_ratios.sql, tasks/asking_to_sold_ratio.py.

Связано: #794, #652, #648 (asking→sold ratio), Brusnika EKB-2485.

## Проблема (найдено при golden-сверке #794 vs Brusnika EKB-2485) Golden-смок (Софьи Перовской 119, 2к/42.9м²/панель-1978): **headline median 3.94 млн — в эталоне** (gold asking 3.4–4.6 млн, +2.4% к середине ✅). Но **expected_sold 3.16 млн — на ~9% ниже** выкупа Брусники (~3.47 млн). **Root cause:** пул `asking_to_sold_ratios` бакетится только по `district × rooms_bucket` → `ratio=0.8025` для 2-комн района вбирает монолит-новостройки Пехотинцев (107–117k ₽/м²), разбавляя эконом-панель и занижая sold-коррекцию. Headline не страдает (там работает same-building anchor + IQR/MAD), течёт именно ratio-пул и, шире, отбор аналогов по «чужому» подрынку. ## Данные (прод, active listings) — почему именно ценовой признак | сигнал | покрытие | |---|---| | `price_per_m2` | **100%** (20459/20459) | | `year_built` | 27% (5455) | | `house_type` | 22% (4457) | → `house_type`/`year_built` слишком разрежены для жёсткого фильтра (выкинут 70–78% аналогов). **ppm² — единственный data-complete признак.** Класс дома — soft-обогащение на потом (когда дозаполним enrichment через `houses`). ## Предлагаемое решение 1. **ppm²-тир, якорённый на same-building anchor** (против циркулярности — band от цены того же дома, не от самой медианы). 2. **`asking_to_sold_ratios`**: расширить ключ `district × rooms_bucket` → `× ppm²_tier` (терцили распределения district×rooms). Миграция 080-таблицы + пересчёт (`tasks/asking_to_sold_ratio.py`). 3. **Отбор аналогов** (`_fetch_analogs`) и **ДКП-коридор** (`_fetch_dkp_corridor`): опциональный soft-band по ppm² вокруг anchor, чтобы отсекать заведомо чужой подрынок (мягко, без схлопывания выборки). 4. Класс дома (`year_built`-band / `house_type` через join на `houses`) — soft-сигнал/tiebreaker там, где заполнено; НЕ хард-фильтр. ## DoD - Спроектирован тир-ключ + способ его вычисления (терцили? фикс-границы?). - Миграция `asking_to_sold_ratios` (additive, idempotent, авто-apply на прод через deploy — учесть, что это деструктивно/строго). - Точки фильтрации в estimator определены с сохранением graceful-fallback при тонкой выборке. - **Бэктест на golden-наборе** (55 offline + Brusnika EKB-2485): expected_sold должен приблизиться к gold, headline — НЕ деградировать. ## Ограничения - ⚠️ **НЕ в live до демо** (2026-06-01) — headline уже точен, менять диапазон ради expected_sold рискованно. Реализация **после демо, за бэктест-гейтом** (как #887 step 4). - scope: только tradein estimator. Файлы: `app/services/estimator.py` (`_get_asking_sold_ratio`, `_fetch_analogs`, `_fetch_dkp_corridor`), `data/sql/080_asking_to_sold_ratios.sql`, `tasks/asking_to_sold_ratio.py`. Связано: #794, #652, #648 (asking→sold ratio), Brusnika EKB-2485.
lekss361 added the
priority/p2
scope/backend
status/needs-analysis
tradein
labels 2026-05-31 20:19:12 +00:00
Author
Owner

План реализации (tech-analyst, read-only)

Подтверждено по коду: asking_to_sold_ratios ключ сейчас (rooms_bucket, district=''); ratio 0.72–0.93; refresh — tasks/asking_to_sold_ratio.py + миграции 080/082. Headline якорится same-building + IQR/MAD (не трогаем). Течёт ratio-пул.

Шаги (owner)

  1. SQL-анализ распределения ppm² по rooms×active-listings (database-expert) — валидировать, что терцильный сплит даёт достаточно данных per-cell (порог n≥30 deals/listings); рекомендация terciles vs fixed-bands. ⚠️ analyst НЕ выполнил реальный distribution-SQL — это первый шаг.
  2. Схема + миграция (083) (database-expert)ppm2_tier в ключ → (rooms_bucket, district, ppm2_tier) + таблица/JSONB границ тиров. Additive (ADD COLUMN + ON CONFLICT), НЕ DROP — авто-apply на прод строгий. Старые 6 строк → tier='mid' по умолчанию.
  3. Refresh-задача (backend-engineer) — считать границы терцилей по текущим данным per-rooms, вставлять per (rooms×tier); graceful: cell n<30 → не вставляем → estimator падает на rooms-only → global.
  4. Estimator tier-aware lookup (backend-engineer)_get_asking_sold_ratio(..., anchor_ppm2) где anchor_ppm2 = Tukey-clipped медиана same-building/первых аналогов (НЕ raw, против outlier-перекоса тира); fallback tier→rooms→global; cache-key с tier.
  5. Бэктест golden (backend-engineer) — Brusnika EKB-2485 → expected_sold 3.16M должен подрасти к ~3.35M+ (−9%→≤−4%), headline БЕЗ регресса.
  6. Deploy + флаг (devops)TIER_AWARE_RATIO_ENABLED=false по умолчанию → merge «тёмной», включить после ручного QA после демо.

Не делать

  • headline не трогать; year_built/house_type как hard-filter (27%/22% покрытие); merge без feature-flag; пропускать шаг 1 (дизайн тира зависит от реального распределения).

Открытые вопросы (нужен вход)

  1. Где 55 offline golden-кейсов? В репо только Brusnika EKB-2485 (1 кейс). Без набора бэктест (шаг 5) — только на 1 точке, рискованно. → нужны кейсы или согласие бэктестить на малом наборе.
  2. terciles vs fixed-bands — решаем по результату шага 1 (database-expert).
  3. boundary-storage: отдельная таблица (audit) vs JSONB-колонка — на ревью с database-expert.

Статус: запланировано, за демо-гейтом (флаг off). В очередь воркеров не ставлю до демо.

## План реализации (tech-analyst, read-only) **Подтверждено по коду:** `asking_to_sold_ratios` ключ сейчас `(rooms_bucket, district='')`; ratio 0.72–0.93; refresh — `tasks/asking_to_sold_ratio.py` + миграции 080/082. Headline якорится same-building + IQR/MAD (не трогаем). Течёт ratio-пул. ### Шаги (owner) 1. **SQL-анализ распределения ppm² по `rooms×active-listings`** *(database-expert)* — валидировать, что терцильный сплит даёт достаточно данных per-cell (порог n≥30 deals/listings); рекомендация terciles vs fixed-bands. ⚠️ analyst НЕ выполнил реальный distribution-SQL — это первый шаг. 2. **Схема + миграция (083)** *(database-expert)* — `ppm2_tier` в ключ → `(rooms_bucket, district, ppm2_tier)` + таблица/JSONB границ тиров. **Additive (ADD COLUMN + ON CONFLICT), НЕ DROP** — авто-apply на прод строгий. Старые 6 строк → tier='mid' по умолчанию. 3. **Refresh-задача** *(backend-engineer)* — считать границы терцилей по текущим данным per-rooms, вставлять per (rooms×tier); graceful: cell n<30 → не вставляем → estimator падает на rooms-only → global. 4. **Estimator tier-aware lookup** *(backend-engineer)* — `_get_asking_sold_ratio(..., anchor_ppm2)` где anchor_ppm2 = **Tukey-clipped** медиана same-building/первых аналогов (НЕ raw, против outlier-перекоса тира); fallback tier→rooms→global; cache-key с tier. 5. **Бэктест golden** *(backend-engineer)* — Brusnika EKB-2485 → expected_sold 3.16M должен подрасти к ~3.35M+ (−9%→≤−4%), headline БЕЗ регресса. 6. **Deploy + флаг** *(devops)* — `TIER_AWARE_RATIO_ENABLED=false` по умолчанию → merge «тёмной», включить после ручного QA **после демо**. ### Не делать - ❌ headline не трогать; ❌ year_built/house_type как hard-filter (27%/22% покрытие); ❌ merge без feature-flag; ❌ пропускать шаг 1 (дизайн тира зависит от реального распределения). ### Открытые вопросы (нужен вход) 1. **Где 55 offline golden-кейсов?** В репо только Brusnika EKB-2485 (1 кейс). Без набора бэктест (шаг 5) — только на 1 точке, рискованно. → нужны кейсы или согласие бэктестить на малом наборе. 2. **terciles vs fixed-bands** — решаем по результату шага 1 (database-expert). 3. boundary-storage: отдельная таблица (audit) vs JSONB-колонка — на ревью с database-expert. Статус: запланировано, **за демо-гейтом** (флаг off). В очередь воркеров не ставлю до демо.
lekss361 removed the
status/needs-analysis
label 2026-05-31 20:22:46 +00:00
Author
Owner

Реализация → PR #934 (флаг OFF, dark merge)

Шаг 1 (distribution gate, validated read-only на проде): все 15 ячеек (5 rooms × 3 tier) проходят ≥30 deals AND ≥30 listings (min 169 deals / 583 listings) → терцили жизнеспособны, fallback не нужен. Динамические терцили (не fixed-bands) — границы дрейфуют с рынком, refresh-задача их пересчитывает.

Границы (₽/m², active listings) и tier-ratio vs текущий global:

rooms t33 t66 low mid high global сейчас
0 166k 197k 0.805 0.977 0.992 0.726
1 146k 183k 0.871 0.982 0.980 0.749
2 129k 164k 0.911 0.978 0.991 0.803
3 126k 162k 0.945 0.975 0.994 0.842
4+ 119k 172k 0.982 1.000 0.940 0.929

Brusnika EKB-2485 (2к, anchor ppm² ≈92k → low-тир): ratio 0.803 → 0.911 ⇒ expected_sold 3.16M → ≈3.57M (ошибка −9% → +3%, выкуп 3.47M). Цель достигнута.

⚠️ Методологический caveat (зафиксирован для гейта): при биннинге обеих сторон в asking-терцили tier-ratio в mid/high схлопывается к ~0.98–1.0 (sold≈asking внутри полосы) — реальная коррекция остаётся в low-тире (где живёт эконом-панель — ровно цель issue). mid/high sold-сторона не протестирована (golden-набор = asking-цены). Митигировано: shrinkage к global + флаг OFF + held-out backtest перед флипом.

Шаги 2–4 (PR #934): отдельная таблица asking_to_sold_ratios_tiered + asking_to_sold_tier_bounds (НЕ ADD COLUMN — иначе flag-off fetchone() вернул бы несколько строк → тихая регрессия), миграция 098 (additive/idempotent), refresh-задача расширена (атомарно), _get_asking_sold_ratio(db, rooms, anchor_ppm2) tier-aware с fallback tier→all→global, флаг tier_aware_ratio_enabled=False. code-reviewer: ⚠️ MINOR без блокеров (cache-key collision + band-drift guard — исправлены). 1677 тестов зелёных.

Решение по нумерации: миграция = 098 (083–097 заняты, в т.ч. коллизия двух 084), не 083.

Шаг 5 (backtest) — ПОСЛЕ демо, гейт перед флипом флага: Brusnika expected_sold ≥3.35M (flag on) + 53 golden-кейса headline median байт-в-байт pre/post + held-out ДКП для mid/high + M3 (tier placement сейчас по median_ppm2, не по same-building anchor — anchor считается после call site; пересмотреть до флипа).

Связь с Decision_Tradein_Valuation_Algorithm_V2: distribution подтвердил composition-bias в deals-vs-listings пуле, о котором предупреждал V2 (рекомендовал параметрический haircut 4–7%). #928 чинит пул тирингом; если held-out backtest покажет, что тиринг недостаточно чистит mid/high — параметрический haircut остаётся записанной альтернативой.

## Реализация → PR #934 (флаг OFF, dark merge) **Шаг 1 (distribution gate, validated read-only на проде):** ✅ все 15 ячеек (5 rooms × 3 tier) проходят ≥30 deals AND ≥30 listings (min 169 deals / 583 listings) → терцили жизнеспособны, fallback не нужен. **Динамические терцили** (не fixed-bands) — границы дрейфуют с рынком, refresh-задача их пересчитывает. Границы (₽/m², active listings) и tier-ratio vs текущий global: | rooms | t33 | t66 | low | mid | high | global сейчас | |---|---|---|---|---|---|---| | 0 | 166k | 197k | 0.805 | 0.977 | 0.992 | 0.726 | | 1 | 146k | 183k | 0.871 | 0.982 | 0.980 | 0.749 | | 2 | 129k | 164k | **0.911** | 0.978 | 0.991 | **0.803** | | 3 | 126k | 162k | 0.945 | 0.975 | 0.994 | 0.842 | | 4+ | 119k | 172k | 0.982 | 1.000 | 0.940 | 0.929 | **Brusnika EKB-2485** (2к, anchor ppm² ≈92k → **low**-тир): ratio 0.803 → **0.911** ⇒ expected_sold 3.16M → **≈3.57M** (ошибка −9% → +3%, выкуп 3.47M). Цель достигнута. **⚠️ Методологический caveat (зафиксирован для гейта):** при биннинге обеих сторон в asking-терцили tier-ratio в mid/high схлопывается к ~0.98–1.0 (sold≈asking внутри полосы) — реальная коррекция остаётся в low-тире (где живёт эконом-панель — ровно цель issue). mid/high sold-сторона **не протестирована** (golden-набор = asking-цены). Митигировано: shrinkage к global + флаг OFF + held-out backtest перед флипом. **Шаги 2–4 (PR #934):** отдельная таблица `asking_to_sold_ratios_tiered` + `asking_to_sold_tier_bounds` (НЕ ADD COLUMN — иначе flag-off `fetchone()` вернул бы несколько строк → тихая регрессия), миграция 098 (additive/idempotent), refresh-задача расширена (атомарно), `_get_asking_sold_ratio(db, rooms, anchor_ppm2)` tier-aware с fallback tier→all→global, флаг `tier_aware_ratio_enabled=False`. code-reviewer: ⚠️ MINOR без блокеров (cache-key collision + band-drift guard — исправлены). 1677 тестов зелёных. **Решение по нумерации:** миграция = **098** (083–097 заняты, в т.ч. коллизия двух 084), не 083. **Шаг 5 (backtest) — ПОСЛЕ демо**, гейт перед флипом флага: Brusnika expected_sold ≥3.35M (flag on) + 53 golden-кейса headline median байт-в-байт pre/post + held-out ДКП для mid/high + M3 (tier placement сейчас по median_ppm2, не по same-building anchor — anchor считается после call site; пересмотреть до флипа). **Связь с Decision_Tradein_Valuation_Algorithm_V2:** distribution подтвердил composition-bias в deals-vs-listings пуле, о котором предупреждал V2 (рекомендовал параметрический haircut 4–7%). #928 чинит пул тирингом; если held-out backtest покажет, что тиринг недостаточно чистит mid/high — параметрический haircut остаётся записанной альтернативой.
Sign in to join this conversation.
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#928
No description provided.