docs(tradein/estimator): докстринг якоря говорит правду о таблице, а код-фикс отдан #2816 (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m21s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

Правка ремонта якоря, добавленная первым коммитом ветки, — дубль уже открытого
PR #2816 (`_anchor_repair_factor`, #2677, открыт 10.08, mergeable). Свой код снят,
чтобы не мержить одно и то же дважды; независимая перепроверка чисел #2816 —
в описании PR.

Остаётся то, чего не чинит ни один открытый PR: докстринг `_fetch_house_imv_anchor`
обещал «house_imv_evaluations популирована (~2951 домов, fresh)». Неправда по обоим
пунктам, и #2816 её тоже не тронул — прочитал и оставил. Заменено на замер с датой:
2680 строк, 2633 (98.2%) старше 40 суток, 2366 — один прогон 24.05, свежих 47;
дореформенные спрошены с литералом renovation_type='cosmetic' (мода дома совпала
у 972, разошлась у 1217) и с дефолтом house_type='panel' (тип разошёлся у 74).

Плюс записаны два отвергнутых замером решения, чтобы их не предлагали заново:
гейт эры снимает якорь у 224 оценок из 224 (все band-совместимые строки на проде —
дореформенные), массовая переоценка упирается в 1628 домов уже в imv_status='ok'
при очереди 7144 и темпе ~25/прогон, и корень всё равно не в свежести.

Refs #2674, #2677
This commit is contained in:
bot-backend 2026-08-13 01:09:08 +05:00
parent 81980da4d4
commit 95e3b209ce
2 changed files with 26 additions and 150 deletions

View file

@ -456,9 +456,6 @@ _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()}
# Множители к медиане по состоянию ремонта. Аналоги в выборке — микс состояний;
# коэффициент сдвигает оценку под ремонт целевой квартиры (встреча Птицы: ремонт
@ -1166,26 +1163,32 @@ def _fetch_house_imv_anchor(
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/прогон.
СОСТОЯНИЕ ТАБЛИЦЫ, замер 2026-08-12 (прежний текст обещал «популирована
~2951 домов, fresh» неправда по обоим пунктам, #2674):
2680 строк, одна на дом (house_imv_eval_house_uniq_idx UNIQUE(house_id)
поэтому ORDER BY ниже выбирает из 1 кандидата). 2633 из них (98.2%)
старше 40 суток, 2366 один прогон 2026-05-24; свежих, после фикса
параметров 2026-08-06, всего 47.
Дореформенные 2633 спрошены у Авито с ЛИТЕРАЛОМ renovation_type='cosmetic'
и с дефолтом house_type='panel' при нераспознанном типе: мода ремонта по
объявлениям того же дома совпала лишь у 972 (36.9%), разошлась у 1217
(46.2%), у 444 моды нет; тип дома разошёлся у 74 из 2633 (46 раз ушла
«панель» вместо «монолита»).
Именно поэтому вызывающий код обязан привести якорь к базе ремонта ЦЕЛИ
(`_rebase_imv_anchor`), а не сравнивать его с медианой напрямую: медиана к
этому моменту уже умножена на `_repair_coefficient(repair_state)`.
Гейт «не брать дореформенные строки» рассматривался и ОТКЛОНЁН замером
(прод, 1081 оценка): он снимает якорь у 224 оценок из 224, чтобы убрать 5
ложных срабатываний 14 блендов и +16.6 млн коррекции превращаются в 0.
ЧТО С ЭТИМ РЕШЕНО (замер на проде, 1081 персистированная оценка):
· Гейт «не брать дореформенные строки» ОТКЛОНЁН: band-совместимая строка
находится у 224 оценок, и все 224 дореформенные. Гейт снял бы якорь у
224 из 224, то есть просто выключил бы механизм.
· Массовая переоценка не чинит корень и упирается в очередь: 1628 из
2633 домов уже в imv_status='ok', а батч берёт только pending/
transient_error, где сейчас 7144 дома при темпе ~25/прогон.
· Корень не свежесть, а РАЗНЫЕ БАЗЫ РЕМОНТА: median_price к моменту
блендa уже умножена на _repair_coefficient(repair_state), а якорь стоит
в том классе отделки, в котором строку спросили. Чинится приведением
якоря к базе цели (#2677).
Returns dict {recommended_price, lower_price, higher_price, market_count,
rooms, area_m2, renovation_type} или None.
rooms, area_m2} или None.
"""
if target_house_id is None:
return None
@ -1195,7 +1198,7 @@ def _fetch_house_imv_anchor(
text(
"""
SELECT recommended_price, lower_price, higher_price,
market_count, rooms, area_m2, renovation_type
market_count, rooms, area_m2
FROM house_imv_evaluations
WHERE house_id = CAST(:hid AS bigint)
AND recommended_price > 0
@ -1237,46 +1240,6 @@ 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,
@ -3192,21 +3155,6 @@ 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

View file

@ -343,7 +343,7 @@ def _make_fake_geo():
)
def _make_payload(repair_state: str | None = None):
def _make_payload():
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
@ -352,20 +352,18 @@ def _make_payload(repair_state: str | None = None):
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(repair_state)
payload = _make_payload()
async def _run():
with (
@ -443,73 +441,3 @@ 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