From 81980da4d49771d719f982951e79a1208d1f03b4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 01:03:50 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimator):=20=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B9=20IMV-=D1=8F=D0=BA=D0=BE=D1=80=D1=8C?= =?UTF-8?q?=20=D0=B8=20=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BD=D0=B8=D0=B2=D0=B0=D1=8E=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B2=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=B7=D0=B5=20=D1=80=D0=B5=D0=BC=D0=BE=D0=BD=D1=82=D0=B0=20(#2?= =?UTF-8?q?674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Порог блендa `anchor > median × 1.15` сравнивал два числа в РАЗНЫХ базах. `median_price` к этому моменту уже умножена на `_repair_coefficient(repair_state)` (блок 4b), то есть описывает квартиру С РЕМОНТОМ ЦЕЛИ. Якорь — цена квартиры в том классе отделки, в котором строку спросили у Авито: 2633 из 2680 строк house_imv_evaluations спрошены с литералом renovation_type='cosmetic' (до фикса параметров 06.08), свежие 47 — в модальном ремонте ДОМА. Ни то, ни другое не есть ремонт цели. Знак ошибки детерминирован ремонтом цели, и это видно на проде (1081 оценка, из них 127 Tier-D с band-совместимым якорем): - цель needs_repair: медиана ×0.94, якорь нет → порог берётся «на халяву». 6 из 14 живых блендов — такие; 5 из 6 после приведения к общей базе исчезают. - цель good/excellent (23 оценки): медиана ×1.05/×1.10 → порог недостижим, бленд не срабатывал НИ РАЗУ. Множитель — отношение уже существующих `_REPAIR_COEF`, обратное отображение вокабуляра Авито — существующий `_IMV_REPAIR_MAP`. Второй таблицы не заводим. Незнакомый/пустой класс с любой стороны → 1.0 (прежнее поведение). Сводка AvitoImvSummary продолжает показывать СЫРУЮ цифру Авито — она так и подписана. `_REPAIR_COEF` проверен данными (прод 2026-08-12, парное сравнение ppm² ВНУТРИ дома, база standard): needs_repair 0.946 при коэффициенте 0.94, good 1.039 при 1.05, excellent 1.113 при 1.10 (61/289/84 домов-пар) — эвристика попадает в измеренное, отдельный множитель не нужен. Гейт «не брать дореформенные строки» рассмотрен и ОТКЛОНЁН замером: он снимает якорь у 224 оценок из 224 (все 224 сидят на дореформенных строках), чтобы убрать 5 ложных срабатываний — 14 блендов и +16.6 млн ₽ коррекции превращаются в 0. Массовая переоценка тоже не чинит дефект: даже при идеально свежих строках, спрошенных в модальном ремонте дома, бленд у целей good/excellent срабатывает 2 раза из 23 вместо 0 — база всё равно не та. Докстринг `_fetch_house_imv_anchor` обещал «~2951 домов, fresh» — неправда по обоим пунктам. Заменён на замер с датой: 2680 строк, 2633 (98.2%) старше 40 суток, 2366 — один прогон 24.05; естественное обновление их не чинит, потому что 1628 из 2633 домов уже в imv_status='ok', а батч выбирает pending/transient_error, где сейчас 7144 дома при темпе ~25/прогон. Тесты двусторонние: на origin/main цель excellent даёт 6 600 000 вместо 7 425 000 (бленд подавлен), цель needs_repair — 6 120 000 вместо 5 640 000 (ложный бленд). Refs #2674 --- tradein-mvp/backend/app/services/estimator.py | 96 ++++++++++++++++--- .../backend/tests/test_estimator_imv_blend.py | 76 ++++++++++++++- 2 files changed, 159 insertions(+), 13 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index acb7f440..960fa77e 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -456,6 +456,9 @@ _IMV_REPAIR_MAP: dict[str | None, str | None] = { "excellent": "designer", None: None, } +# Обратное отображение (вокабуляр Авито → наш) — нужно, чтобы понять, В КАКОМ +# классе отделки спросили сохранённую домовую IMV-строку (см. _rebase_imv_anchor). +_IMV_REPAIR_INVERSE: dict[str | None, str | None] = {v: k for k, v in _IMV_REPAIR_MAP.items()} # Множители к медиане по состоянию ремонта. Аналоги в выборке — микс состояний; # коэффициент сдвигает оценку под ремонт целевой квартиры (встреча Птицы: ремонт @@ -1158,14 +1161,31 @@ def _fetch_house_imv_anchor( """Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`. В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand - скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по - house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем - запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе - самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии - house_id / пустой таблице — estimator продолжает на гео-tier'ах (no regress). + скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку: + WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort: + None при любой ошибке / отсутствии house_id / пустой таблице — estimator + продолжает на гео-tier'ах (no regress). + + СОСТОЯНИЕ ТАБЛИЦЫ, замер 2026-08-12 (прежний докстринг обещал «~2951 домов, + fresh» — неправда по обоим пунктам, #2674): + 2680 строк; 2633 из них (98.2%) старше 40 суток, 2366 — один прогон + 2026-05-24. Свежих (после фикса параметров 2026-08-06) — 47. + Дореформенные 2633 спрошены с ЛИТЕРАЛОМ renovation_type='cosmetic': + мода ремонта по объявлениям того же дома совпала лишь у 972 (36.9%), + разошлась у 1217 (46.2%), у 444 моды нет. + Естественное обновление их НЕ чинит: 1628 из 2633 домов уже в + imv_status='ok', а батч выбирает только pending/transient_error, где + сейчас 7144 дома при темпе ~25/прогон. + + Именно поэтому вызывающий код обязан привести якорь к базе ремонта ЦЕЛИ + (`_rebase_imv_anchor`), а не сравнивать его с медианой напрямую: медиана к + этому моменту уже умножена на `_repair_coefficient(repair_state)`. + Гейт «не брать дореформенные строки» рассматривался и ОТКЛОНЁН замером + (прод, 1081 оценка): он снимает якорь у 224 оценок из 224, чтобы убрать 5 + ложных срабатываний — 14 блендов и +16.6 млн ₽ коррекции превращаются в 0. Returns dict {recommended_price, lower_price, higher_price, market_count, - rooms, area_m2} или None. + rooms, area_m2, renovation_type} или None. """ if target_house_id is None: return None @@ -1175,7 +1195,7 @@ def _fetch_house_imv_anchor( text( """ SELECT recommended_price, lower_price, higher_price, - market_count, rooms, area_m2 + market_count, rooms, area_m2, renovation_type FROM house_imv_evaluations WHERE house_id = CAST(:hid AS bigint) AND recommended_price > 0 @@ -1217,6 +1237,46 @@ def _fetch_house_imv_anchor( return dict(row) if row is not None else None +def _rebase_imv_anchor( + anchor_total: int, + *, + row_renovation: str | None, + target_repair: str | None, +) -> int: + """Приводит домовой IMV-якорь к базе ремонта ЦЕЛИ (#2674). + + Зачем. К моменту блендa `median_price` уже умножена на + `_repair_coefficient(repair_state)` (блок 4b выше) — то есть описывает + квартиру С РЕМОНТОМ ЦЕЛИ. Якорь же — цена квартиры в том классе отделки, в + котором строку спросили у Авито (`house_imv_evaluations.renovation_type`: + дореформенные 2633 строки — литерал 'cosmetic', свежие — мода по дому). Порог + `anchor > median × 1.15` сравнивал два числа в РАЗНЫХ базах, и знак ошибки + детерминирован: у цели с needs_repair медиана уезжает ×0.94, а якорь нет → + порог берётся «на халяву»; у цели с good/excellent медиана ×1.05/×1.10 → + порог недостижим. Замер на проде (1081 оценка, 127 из них Tier D с якорем): + 6 из 14 живых блендов — цели needs_repair, 5 из них после приведения к общей + базе исчезают; у 23 целей с good/excellent бленд не срабатывал НИ РАЗУ (и + сработал бы лишь 2 раза даже после идеальной переоценки всех строк). + + Множитель — отношение уже существующих `_REPAIR_COEF` (той же таблицы, что + двигала медиану), обратное отображение вокабуляра Авито — существующий + `_IMV_REPAIR_MAP`. Второй таблицы не заводим. Незнакомый/пустой класс с любой + стороны → коэффициент 1.0, то есть прежнее поведение (graceful). + + Проверка `_REPAIR_COEF` данными (прод 2026-08-12, парное сравнение ppm² ВНУТРИ + дома, база standard): needs_repair 0.946 при коэффициенте 0.94, good 1.039 при + 1.05, excellent 1.113 при 1.10 (61/289/84 домов-пар). Таблица-эвристика + попадает в измеренное — отдельный множитель не нужен. + """ + if anchor_total <= 0: + return anchor_total + coef_target = _repair_coefficient(target_repair) + coef_row = _repair_coefficient(_IMV_REPAIR_INVERSE.get(row_renovation)) + if coef_row <= 0: # pragma: no cover — _REPAIR_COEF значений <= 0 не содержит + return anchor_total + return round(anchor_total * coef_target / coef_row) + + def _apply_imv_blend( *, median_price: int, @@ -3132,6 +3192,21 @@ def _price_from_inputs( _imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold ), ) + # #2674: сводка выше показывает СЫРУЮ цифру Авито (так она и подписана), + # а в бленд идёт та же цифра, переведённая в базу ремонта ЦЕЛИ — иначе + # порог ×1.15 сравнивает «квартиру с ремонтом цели» с «квартирой в том + # классе отделки, в котором спросили строку». См. _rebase_imv_anchor. + anchor_total = _rebase_imv_anchor( + anchor_total, + row_renovation=imv_anchor.get("renovation_type"), + target_repair=repair_state, + ) + if anchor_higher: + anchor_higher = _rebase_imv_anchor( + anchor_higher, + row_renovation=imv_anchor.get("renovation_type"), + target_repair=repair_state, + ) elif imv_eval is not None and imv_eval.recommended_price: anchor_total = int(imv_eval.recommended_price) anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None @@ -3266,8 +3341,7 @@ def _price_from_inputs( ) if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio: logger.info( - "quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f)" - " for %s", + "quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s", same_quarter_ratio, settings.estimate_quarter_match_skip_ratio, target_quarter, @@ -3319,7 +3393,7 @@ def _price_from_inputs( analogs_with_qi, ) explanation = (explanation or "") + ( - f" Учтена локация квартала" f" (индекс цен квартала ×{qi_factor:.2f})." + f" Учтена локация квартала (индекс цен квартала ×{qi_factor:.2f})." ) sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"}) @@ -6766,7 +6840,7 @@ def _enforce_zero_analog_low( """ if n_analogs == 0 and confidence != "low": logger.warning( - "ghost_anchor_guard #1871: forcing confidence 'low' (was %s, " "median=%s, sources=%s)", + "ghost_anchor_guard #1871: forcing confidence 'low' (was %s, median=%s, sources=%s)", confidence, median_price, sources_used, diff --git a/tradein-mvp/backend/tests/test_estimator_imv_blend.py b/tradein-mvp/backend/tests/test_estimator_imv_blend.py index 77bf6a57..df1e14a5 100644 --- a/tradein-mvp/backend/tests/test_estimator_imv_blend.py +++ b/tradein-mvp/backend/tests/test_estimator_imv_blend.py @@ -343,7 +343,7 @@ def _make_fake_geo(): ) -def _make_payload(): +def _make_payload(repair_state: str | None = None): from app.schemas.trade_in import TradeInEstimateInput return TradeInEstimateInput( @@ -352,18 +352,20 @@ def _make_payload(): rooms=1, floor=4, total_floors=16, + repair_state=repair_state, ) def _run_estimate_with_anchor( ratio_tuple: tuple[float | None, str | None], anchor: dict[str, Any] | None, + repair_state: str | None = None, ): """estimate_quality со всеми I/O застабленными; IMV-anchor форсирован.""" from app.services.estimator import estimate_quality db = MagicMock() - payload = _make_payload() + payload = _make_payload(repair_state) async def _run(): with ( @@ -441,3 +443,73 @@ def test_expected_sold_unblended_when_anchor_band_rejects() -> None: assert est.median_price_rub == 6_000_000 # pre-blend (no blend) assert est.expected_sold_price_rub == round(6_000_000 * ratio) + + +# ── #2674: якорь и медиана в ОДНОЙ базе ремонта ────────────────────────────── +# +# median_price к моменту блендa уже умножена на _repair_coefficient(repair_state), +# а домовая IMV-строка спрошена в СВОЁМ классе отделки (дореформенные 2633 строки +# на проде — литерал 'cosmetic'). Порог ×1.15 сравнивал разные базы; знак ошибки +# детерминирован ремонтом ЦЕЛИ, поэтому обе стороны проверяем отдельно. + + +def _anchor_row(recommended: int, renovation: str | None) -> dict[str, Any]: + return { + "recommended_price": recommended, + "lower_price": None, + "higher_price": None, + "market_count": 500, + "rooms": 1, + "area_m2": 40.0, + "renovation_type": renovation, + } + + +def test_rebase_pure_ratio_of_existing_repair_coefs() -> None: + """Чистая трансформация: множитель = coef(цель)/coef(класс строки).""" + from app.services.estimator import _rebase_imv_anchor + + # cosmetic(=standard, 1.00) → excellent (1.10) + assert _rebase_imv_anchor(10_000_000, row_renovation="cosmetic", target_repair="excellent") == ( + 11_000_000 + ) + # euro(=good, 1.05) → needs_repair (0.94) + assert _rebase_imv_anchor(10_500_000, row_renovation="euro", target_repair="needs_repair") == ( + round(10_500_000 * 0.94 / 1.05) + ) + # одинаковая база → тождество + assert _rebase_imv_anchor(7_000_000, row_renovation="cosmetic", target_repair="standard") == ( + 7_000_000 + ) + # незнакомый / пустой класс с любой стороны → 1.0 (прежнее поведение) + assert _rebase_imv_anchor(7_000_000, row_renovation=None, target_repair=None) == 7_000_000 + assert _rebase_imv_anchor(7_000_000, row_renovation="euroluxe", target_repair=None) == 7_000_000 + + +def test_anchor_rebased_up_lets_blend_fire_for_excellent_target() -> None: + """Цель с евроремонтом: медиана ×1.10, якорь спрошен в 'cosmetic' → порог был недостижим. + + pre-blend median = 150_000 × 40 × 1.10 = 6_600_000. + Сырой якорь 7_500_000 / 6_600_000 = 1.136 < 1.15 → на main блендa НЕТ. + В базе цели: 7_500_000 × 1.10 = 8_250_000, отношение 1.25 → бленд + (6_600_000 + 8_250_000)/2 = 7_425_000. + """ + est = _run_estimate_with_anchor( + (None, None), _anchor_row(7_500_000, "cosmetic"), repair_state="excellent" + ) + assert est.median_price_rub == 7_425_000 + + +def test_anchor_rebased_down_suppresses_spurious_blend_for_needs_repair_target() -> None: + """Цель «требует ремонта»: медиана ×0.94, якорь в 'cosmetic' → порог брался «на халяву». + + pre-blend median = 150_000 × 40 × 0.94 = 5_640_000. + Сырой якорь 6_600_000 / 5_640_000 = 1.170 > 1.15 → на main бленд СРАБАТЫВАЛ + и поднимал медиану до 6_120_000. + В базе цели: 6_600_000 × 0.94 = 6_204_000, отношение 1.100 < 1.15 → блендa нет, + медиана остаётся 5_640_000. + """ + est = _run_estimate_with_anchor( + (None, None), _anchor_row(6_600_000, "cosmetic"), repair_state="needs_repair" + ) + assert est.median_price_rub == 5_640_000