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
В 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>
139 lines
8.5 KiB
Python
139 lines
8.5 KiB
Python
"""Неизвестный 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 и обгоняет
|
||
кандидатов 3–5 с известным годом.
|
||
"""
|
||
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:]
|
||
)
|