gendesign/tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py
bot-backend 865e64b783
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (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
CI Trade-In / backend-tests (pull_request) Successful in 4m14s
feat(tradein/estimator): штраф за неизвестный год/тип дома — флагом, default OFF (#2936)
В SQL-формуле relevance_score кандидат без year_built получает штраф 0 —
столько же, сколько точное попадание в год, и лучше, чем кандидат с
известным годом, отличающимся на 24 (2.0). То же с house_type. Отсутствие
данных выигрывает у знания и возвышает источник с худшей полнотой.

Флаг estimate_unknown_attr_penalty_enabled (default OFF): кандидат с NULL
получает МЕДИАННЫЙ по пулу штраф того же признака среди тех, у кого он
известен — не наказание и не награда; считается по тем же термам, что
SQL (abs(Δyear)/12.0, 1.5 за несовпадение типа). Лежит в Python-слое
после SQL, рядом с kitchen/ceiling (#2012), по тому же контракту:
включать — только по бэктесту.

Без чего флаг был бы мёртв (и был в первой редакции): _ANALOG_SELECT_COLS
не выбирал year_built/house_type — SQL считал по ним CASE, но в словарь
кандидата колонки не попадали, Python-слой видел None у ВСЕХ и не штрафовал
никого по построению. Probe-лог в прод-оверлее: pool=30 null_year=30.
Добавлены в _ANALOG_SELECT_COLS, во внешние SELECT тиров H/W и во
внутренний base Tier W (он строится явным списком). Контроль: флаг OFF с
колонками и без — метрики бэктеста идентичны до сотых. На этот инвариант
стоит тест по исходнику запросов.

Живой A/B (бэктест в прод-контейнере, оверлей /tmp/ab, 300 сделок ЕКБ
Q2 2026, одна и та же выборка в обоих прогонах — проверено по deal_id):
  состав топ-50: сменилось 96 слотов из 5 915 (1.6 %), 27 сделок из 300
  источники: avito 55.7→55.3 %, cian 24.5→24.8 %, yandex 12.7→12.6 %
  цена: MAPE 16.70→16.70, bias −4.52→−4.52, coverage 84.46→84.46 —
  идентично до сотых по всем срезам (сегменты, комнатность).
Нижний Тагил (300 сделок, пулы 21/p90 40): 0 из 5 666 слотов сменилось.

Почему эффект в разы меньше замера задачи (×0.20 avito): тот замер шёл
по SQL тира H без стратификации. В боевом пути 54.9 % слотов топ-50 —
гарантированная квота MIN_ANALOGS_PER_SOURCE=5, раздаётся ДО сортировки
остатка; и у avito в топ-50 NULL-год лишь у 25.7 % (задача мерила 56 %
по всем активным объявлениям). Обе величины измерены по фикстурам A/B.

Что это значит: артефакт в формуле есть, флаг его корректно лечит, но на
итоговую цену он не влияет измеримо. Включать по умолчанию оснований
нет — и это и есть ответ, ради которого флаг заводился вместо правки.

pytest tradein-mvp/backend: test_2936 7 passed; гейт фикстуры и
roundtrip 8 passed; -k "estimat or analog" 735 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:27:18 +05:00

139 lines
8.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Неизвестный year_built / house_type перестаёт считаться идеальным совпадением (#2936).
SQL-формула relevance_score даёт кандидату без year_built штраф 0 — столько же,
сколько точному попаданию, и ЛУЧШЕ, чем известному году с разницей 24 (2.0).
Отсутствие данных выигрывает у знания, и это возвышает источник с худшей
полнотой: avito (год 44 %, тип 0 %) берёт 47 % слотов топ-20 при 21 % доли в пуле.
Флаг, а не правка формулы: влияние на цену измерено дважды — смещения нет, есть
шум (39 % целей сдвигаются >5 %). Какой отбор ТОЧНЕЕ — отвечает только бэктест на
сделках, и включать можно только по его итогу (тот же контракт, что у #2012).
Механизм: NULL получает МЕДИАННЫЙ по пулу штраф того же признака среди кандидатов,
у которых он известен, — не наказание и не награда.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.estimator import _apply_unknown_attr_penalty
def _pool() -> list[dict]:
# Штрафы за год при target=2000: 0.0 / 1.0 / 2.0 / 3.0 / 4.0 → медиана 2.0.
# Типы при target='panel': известные — 3 совпадения (0) и 2 несовпадения (1.5) →
# медиана 0.0; добавим ещё одно несовпадение, чтобы медиана стала ненулевой.
return [
{"id": 1, "year_built": 2000, "house_type": "panel", "relevance_score": 0.5},
{"id": 2, "year_built": 2012, "house_type": "panel", "relevance_score": 0.5},
{"id": 3, "year_built": 2024, "house_type": "brick", "relevance_score": 0.5},
{"id": 4, "year_built": 1964, "house_type": "brick", "relevance_score": 0.5},
{"id": 5, "year_built": 1952, "house_type": "brick", "relevance_score": 0.5},
{"id": 6, "year_built": None, "house_type": None, "relevance_score": 0.5}, # NULL
{"id": 7, "year_built": 2000, "house_type": None, "relevance_score": 0.5}, # NULL тип
]
def test_null_year_gets_pool_median_penalty() -> None:
"""Головной: NULL-год получает медиану штрафов известных, а не 0.
На origin/main функции нет — кандидат 6 остаётся с 0.5 и обгоняет
кандидатов 35 с известным годом.
"""
pool = _pool()
_apply_unknown_attr_penalty(pool, target_year=2000, target_house_type=None, min_n=5)
by = {c["id"]: c["relevance_score"] for c in pool}
# Известных годов ШЕСТЬ (ид. 1,2,3,4,5 и 7): штрафы 0,1,2,3,4 и 0 → медиана 1.5.
# (Первая редакция теста считала пятерых и ждала 2.0 — арифметика, не код.)
assert (
by[6] == 0.5 + 1.5
), f"NULL-год должен получить медианный штраф 1.5, получил {by[6] - 0.5}"
assert (
by[1] == 0.5 and by[3] == 0.5
), "известный год не должен трогаться — его уже оштрафовал SQL"
def test_null_house_type_gets_pool_median_penalty() -> None:
"""NULL-тип: медиана штрафов известных типов (3×0, 3×1.5 → медиана 0.75)."""
pool = _pool()
# Делаем 3 совпадения и 3 несовпадения: 1,2 panel; 3,4,5 brick; добавим ещё panel.
pool.append({"id": 8, "year_built": 2000, "house_type": "panel", "relevance_score": 0.5})
_apply_unknown_attr_penalty(pool, target_year=None, target_house_type="panel", min_n=5)
by = {c["id"]: c["relevance_score"] for c in pool}
assert by[6] == 0.5 + 0.75 and by[7] == 0.5 + 0.75, f"NULL-тип: {by[6] - 0.5}, {by[7] - 0.5}"
assert by[3] == 0.5, "известный несовпадающий тип уже оштрафован SQL — не трогаем"
def test_unknown_target_penalizes_nobody() -> None:
"""Симметрия с SQL: если target неизвестен, SQL не штрафует никого — и мы тоже."""
pool = _pool()
_apply_unknown_attr_penalty(pool, target_year=None, target_house_type=None, min_n=5)
assert all(c["relevance_score"] == 0.5 for c in pool)
def test_sparse_pool_is_skipped() -> None:
"""Контроль от шума: меньше min_n известных значений → сигнал пропущен целиком."""
pool = [*_pool()[:3], {"id": 9, "year_built": None, "house_type": None, "relevance_score": 0.5}]
_apply_unknown_attr_penalty(pool, target_year=2000, target_house_type="panel", min_n=5)
assert all(c["relevance_score"] == 0.5 for c in pool), "медиана по трём строкам — не сигнал"
def test_flag_default_off_means_no_call() -> None:
"""Контроль: дефолт флага OFF — поведение прода не меняется этим PR."""
from app.core.config import settings
assert settings.estimate_unknown_attr_penalty_enabled is False
def test_penalty_reproduces_sql_terms_not_its_own_idea_of_distance() -> None:
"""Штраф считается по ТЕМ ЖЕ термам, что SQL: abs(Δyear)/12.0 и 1.5 за тип.
Ловит «починку», где NULL получал бы произвольную константу вместо медианы
реально начисленных SQL-штрафов.
"""
pool = [
{"id": i, "year_built": 2000 + 12 * i, "house_type": "x", "relevance_score": 0.0}
for i in range(1, 6)
] + [{"id": 99, "year_built": None, "house_type": "x", "relevance_score": 0.0}]
# штрафы 1,2,3,4,5 → медиана 3.0 (= 36 лет / 12)
_apply_unknown_attr_penalty(pool, target_year=2000, target_house_type=None, min_n=5)
assert next(c for c in pool if c["id"] == 99)["relevance_score"] == 3.0
def test_tier_h_and_w_outer_selects_carry_year_and_house_type() -> None:
"""Сторож на «мёртвый флаг»: внешние SELECT тиров H и W обязаны выбирать
year_built и house_type — иначе Python-слой видит None у ВСЕХ и не штрафует
никого по построению.
Именно так флаг и был мёртв в первой редакции: _ANALOG_SELECT_COLS дополнили,
а внешние списки — нет; A/B на 300 целях ЕКБ дал 0 из 5 915 сменённых слотов,
probe-лог показал pool=30 null_year=30. Проверка по исходнику запроса, а не по
живой БД: это инвариант текста SQL.
"""
import inspect
import re
from app.services import estimator as m
src = inspect.getsource(m)
# Внешний SELECT тира — блок между `\n<отступ>SELECT\n` и `\n<отступ>FROM base` с ТЕМ
# ЖЕ отступом (иначе rfind цепляет SELECT внутри оконной функции/CASE).
outer_blocks = []
for mt in re.finditer(r"\n(\s+)FROM base\n", src):
i = mt.start()
j = src.rfind("\n" + mt.group(1) + "SELECT\n", 0, i)
if j >= 0:
outer_blocks.append(src[j:i])
assert len(outer_blocks) >= 3, f"найдено внешних SELECT: {len(outer_blocks)}"
# Штрафуемые тиры — ровно те, куда #2012 добавил kitchen/ceiling: H и W.
# Tier S (same building) и anchor-тир штрафов не применяют — их внешние
# списки kitchen_area_m2 не содержат, и гейт их не трогает.
penalized = [b for b in outer_blocks if "kitchen_area_m2" in b]
assert len(penalized) == 2, f"ожидал 2 внешних SELECT тиров H и W, нашёл {len(penalized)}"
for b in penalized:
assert "year_built" in b and "house_type" in b, (
"внешний SELECT тира не выбирает year_built/house_type — флаг #2936 мёртв:\n" + b[-400:]
)