fix(tradein/estimator): домовой IMV-якорь и медиана сравниваются в одной базе ремонта (#2674)
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m29s
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m29s
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Порог бленд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
This commit is contained in:
parent
3cd7e0a9c4
commit
81980da4d4
2 changed files with 159 additions and 13 deletions
|
|
@ -456,6 +456,9 @@ _IMV_REPAIR_MAP: dict[str | None, str | None] = {
|
||||||
"excellent": "designer",
|
"excellent": "designer",
|
||||||
None: None,
|
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-оценку target-дома из `house_imv_evaluations`.
|
||||||
|
|
||||||
В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand
|
В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand
|
||||||
скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по
|
скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку:
|
||||||
house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем
|
WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort:
|
||||||
запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе
|
None при любой ошибке / отсутствии house_id / пустой таблице — estimator
|
||||||
самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии
|
продолжает на гео-tier'ах (no regress).
|
||||||
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,
|
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:
|
if target_house_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -1175,7 +1195,7 @@ def _fetch_house_imv_anchor(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT recommended_price, lower_price, higher_price,
|
SELECT recommended_price, lower_price, higher_price,
|
||||||
market_count, rooms, area_m2
|
market_count, rooms, area_m2, renovation_type
|
||||||
FROM house_imv_evaluations
|
FROM house_imv_evaluations
|
||||||
WHERE house_id = CAST(:hid AS bigint)
|
WHERE house_id = CAST(:hid AS bigint)
|
||||||
AND recommended_price > 0
|
AND recommended_price > 0
|
||||||
|
|
@ -1217,6 +1237,46 @@ def _fetch_house_imv_anchor(
|
||||||
return dict(row) if row is not None else None
|
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(
|
def _apply_imv_blend(
|
||||||
*,
|
*,
|
||||||
median_price: int,
|
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
|
_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:
|
elif imv_eval is not None and imv_eval.recommended_price:
|
||||||
anchor_total = int(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
|
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:
|
if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio:
|
||||||
logger.info(
|
logger.info(
|
||||||
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f)"
|
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s",
|
||||||
" for %s",
|
|
||||||
same_quarter_ratio,
|
same_quarter_ratio,
|
||||||
settings.estimate_quarter_match_skip_ratio,
|
settings.estimate_quarter_match_skip_ratio,
|
||||||
target_quarter,
|
target_quarter,
|
||||||
|
|
@ -3319,7 +3393,7 @@ def _price_from_inputs(
|
||||||
analogs_with_qi,
|
analogs_with_qi,
|
||||||
)
|
)
|
||||||
explanation = (explanation or "") + (
|
explanation = (explanation or "") + (
|
||||||
f" Учтена локация квартала" f" (индекс цен квартала ×{qi_factor:.2f})."
|
f" Учтена локация квартала (индекс цен квартала ×{qi_factor:.2f})."
|
||||||
)
|
)
|
||||||
sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"})
|
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":
|
if n_analogs == 0 and confidence != "low":
|
||||||
logger.warning(
|
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,
|
confidence,
|
||||||
median_price,
|
median_price,
|
||||||
sources_used,
|
sources_used,
|
||||||
|
|
|
||||||
|
|
@ -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
|
from app.schemas.trade_in import TradeInEstimateInput
|
||||||
|
|
||||||
return TradeInEstimateInput(
|
return TradeInEstimateInput(
|
||||||
|
|
@ -352,18 +352,20 @@ def _make_payload():
|
||||||
rooms=1,
|
rooms=1,
|
||||||
floor=4,
|
floor=4,
|
||||||
total_floors=16,
|
total_floors=16,
|
||||||
|
repair_state=repair_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run_estimate_with_anchor(
|
def _run_estimate_with_anchor(
|
||||||
ratio_tuple: tuple[float | None, str | None],
|
ratio_tuple: tuple[float | None, str | None],
|
||||||
anchor: dict[str, Any] | None,
|
anchor: dict[str, Any] | None,
|
||||||
|
repair_state: str | None = None,
|
||||||
):
|
):
|
||||||
"""estimate_quality со всеми I/O застабленными; IMV-anchor форсирован."""
|
"""estimate_quality со всеми I/O застабленными; IMV-anchor форсирован."""
|
||||||
from app.services.estimator import estimate_quality
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
payload = _make_payload()
|
payload = _make_payload(repair_state)
|
||||||
|
|
||||||
async def _run():
|
async def _run():
|
||||||
with (
|
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.median_price_rub == 6_000_000 # pre-blend (no blend)
|
||||||
assert est.expected_sold_price_rub == round(6_000_000 * ratio)
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue