From 865e64b78319dc7876aa5ea419275459a29c0783 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 13:27:18 +0500 Subject: [PATCH] =?UTF-8?q?feat(tradein/estimator):=20=D1=88=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=84=20=D0=B7=D0=B0=20=D0=BD=D0=B5=D0=B8=D0=B7=D0=B2?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B9=20=D0=B3=D0=BE=D0=B4/?= =?UTF-8?q?=D1=82=D0=B8=D0=BF=20=D0=B4=D0=BE=D0=BC=D0=B0=20=E2=80=94=20?= =?UTF-8?q?=D1=84=D0=BB=D0=B0=D0=B3=D0=BE=D0=BC,=20default=20OFF=20(#2936)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В 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 --- tradein-mvp/backend/app/core/config.py | 27 ++++ tradein-mvp/backend/app/services/estimator.py | 84 ++++++++++- .../tests/test_2936_unknown_attr_penalty.py | 139 ++++++++++++++++++ 3 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index cac916e8..42067d74 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -562,6 +562,33 @@ class Settings(BaseSettings): # sparse-coverage выше). estimate_kitchen_ceiling_signal_min_n: int = 5 # + # ── #2936: штраф за НЕИЗВЕСТНЫЙ year_built / house_type (default OFF) ────── + # В SQL-формуле relevance_score кандидат без year_built получает штраф 0 — + # столько же, сколько точное попадание в год, и ЛУЧШЕ, чем кандидат с + # известным годом, отличающимся на 24 (2.0). То же с house_type. Отсутствие + # данных выигрывает у знания, и это возвышает источник с худшей полнотой: + # замер 19.08 — avito (год 44 %, тип 0 %) берёт 47 % слотов топ-20 при 21 % + # доли в пуле, yandex (год 99 %) — 9 % при 37 %. + # + # Влияние на цену измерено дважды (#2936): систематического смещения НЕТ + # (медиана сдвига 0.29 %), но у 39 % целей медиана сопоставимых уходит >5 % + # в зависимости от того, как оценено незнание — шум от полноты сбора. + # Чего замеры НЕ говорят: какой из двух отборов ТОЧНЕЕ. Поэтому флаг, а не + # правка формулы: включать — только после бэктеста на сделках (MAPE), как + # требует тот же контракт, что у kitchen/ceiling (#2012) выше. + # + # Механизм: НЕ наказание, но и не награда — кандидат с NULL получает + # МЕДИАННЫЙ по пулу штраф того же признака среди кандидатов, у которых он + # известен (self-referential, как kitchen/ceiling). Пул = кандидаты тира + # после SQL (до 300), ДО сортировки и LIMIT 50. Известное ограничение: + # per-address cap (rn_addr ≤ MAX_ANALOGS_PER_ADDRESS) в SQL уже отработал + # без штрафа — Python-слой переранжирует то, что SQL оставил, как и #2012. + # Sparse-safe: если известных значений меньше min_n — сигнал пропускается + # целиком (медиана по трём строкам — не «типичный штраф», а шум). + # ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED. + estimate_unknown_attr_penalty_enabled: bool = False + estimate_unknown_attr_penalty_min_n: int = 5 + # # is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая # корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard # #1186 (`listing_segment`) в _COMMON_WHERE. Апартаменты — юридически иной diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 5d42d82f..3248e13a 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -5473,6 +5473,54 @@ def _adjust_relevance_by_pool_deviation( c["relevance_score"] = (c.get("relevance_score") or 0.0) + penalty +def _apply_unknown_attr_penalty( + candidates: list[dict[str, Any]], + *, + target_year: int | None, + target_house_type: str | None, + min_n: int, +) -> None: + """#2936: неизвестный year_built / house_type получает МЕДИАННЫЙ по пулу штраф. + + SQL-формула relevance_score даёт кандидату без year_built штраф 0 — столько + же, сколько точному попаданию в год, и лучше, чем известному году с разницей + 24 (2.0). Отсутствие данных выигрывает у знания и возвышает источник с худшей + полнотой (см. комментарий к estimate_unknown_attr_penalty_enabled в config). + + Штраф за NULL = медиана штрафов того же признака среди кандидатов пула, у + которых он известен: не наказание и не награда. Считается ТОЛЬКО по тем + членам пула, где SQL реально начислил штраф, — то есть воспроизводит ровно + SQL-термы abs(year_built - target)/12.0 и 1.5-за-несовпадение, без своего + понимания «насколько далеко». Если target неизвестен, SQL штраф не начисляет + никому — и здесь тоже (симметрия: CASE WHEN :target_... IS NOT NULL). + + Sparse-safe: известных значений меньше ``min_n`` → сигнал пропущен целиком. + Мутирует relevance_score IN PLACE — та же конвенция, что у kitchen/ceiling. + """ + if target_year is not None: + known = [ + abs(int(c["year_built"]) - int(target_year)) / 12.0 + for c in candidates + if c.get("year_built") is not None + ] + if len(known) >= min_n: + median_pen = statistics.median(known) + for c in candidates: + if c.get("year_built") is None: + c["relevance_score"] = (c.get("relevance_score") or 0.0) + median_pen + if target_house_type is not None: + known_ht = [ + 1.5 if str(c["house_type"]) != str(target_house_type) else 0.0 + for c in candidates + if c.get("house_type") is not None + ] + if len(known_ht) >= min_n: + median_pen = statistics.median(known_ht) + for c in candidates: + if c.get("house_type") is None: + c["relevance_score"] = (c.get("relevance_score") or 0.0) + median_pen + + def _apply_kitchen_ceiling_signal(candidates: list[dict[str, Any]]) -> None: """#2012: apply the kitchen_area_m2 / ceiling_height_m comp-scoring signals. @@ -5511,7 +5559,16 @@ _ANALOG_SELECT_COLS = """ -- Tier H/W применяют их (см. _apply_kitchen_ceiling_signal) — Tier S -- (same building) не трогают, симметрично house_type/year_built, которые -- тоже не участвуют в Tier S relevance (там фиксированный 0.0). - kitchen_area_m2, ceiling_height_m + kitchen_area_m2, ceiling_height_m, + -- #2936: year_built / house_type ВЫБИРАЮТСЯ, а не только участвуют в SQL-CASE. + -- До этого SQL считал по ним relevance_score, но в словарь кандидата колонки не + -- попадали: Python-слой видел None у ВСЕХ и штрафовать «неизвестное» не мог по + -- построению — прогон A/B с флагом дал 0 из 5 915 сменённых слотов при 55.7 % + -- avito в топ-50. Замечено probe-логом в оверлее: pool=30 null_year=30 + -- null_type=30. Для путей, которые эти ключи не читают, добавление байт- + -- идентично; фикстура бэктеста реплеится с замороженными listings и гейта не + -- трогает. + year_built, house_type """ _COMMON_WHERE = """ @@ -5879,7 +5936,10 @@ def _fetch_analogs( listing_date, days_on_market, photo_urls, scraped_at, distance_m, relevance_score, building_cadastral_number, - kitchen_area_m2, ceiling_height_m + kitchen_area_m2, ceiling_height_m, + -- #2936: see _ANALOG_SELECT_COLS — внешний список дублирует + -- внутренний, без этого Python-слой не видит год и тип дома. + year_built, house_type FROM base WHERE rn_addr <= :max_per_addr {dup_filter} @@ -5912,6 +5972,13 @@ def _fetch_analogs( tier_h = [dict(r) for r in tier_h_rows] _apply_kitchen_ceiling_signal(tier_h) + if settings.estimate_unknown_attr_penalty_enabled: # #2936, default OFF + _apply_unknown_attr_penalty( + tier_h, + target_year=year_built, + target_house_type=house_type, + min_n=settings.estimate_unknown_attr_penalty_min_n, + ) tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0) if len(tier_h) >= 5: logger.info( @@ -5945,6 +6012,9 @@ def _fetch_analogs( scraped_at, building_cadastral_number, kitchen_area_m2, ceiling_height_m, + -- #2936: Tier W строит base явным списком (не _ANALOG_SELECT_COLS), + -- поэтому год и тип дома добавляются здесь отдельно. + year_built, house_type, id, ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m, @@ -6039,6 +6109,9 @@ def _fetch_analogs( scraped_at, building_cadastral_number, kitchen_area_m2, ceiling_height_m, + -- #2936: see _ANALOG_SELECT_COLS — внешний список дублирует + -- внутренний, без этого Python-слой не видит год и тип дома. + year_built, house_type, distance_m, relevance_score FROM base @@ -6076,6 +6149,13 @@ def _fetch_analogs( candidates: list[dict[str, Any]] = [dict(r) for r in tier_w_rows] _apply_kitchen_ceiling_signal(candidates) + if settings.estimate_unknown_attr_penalty_enabled: # #2936, default OFF + _apply_unknown_attr_penalty( + candidates, + target_year=year_built, + target_house_type=house_type, + min_n=settings.estimate_unknown_attr_penalty_min_n, + ) candidates.sort(key=lambda r: r.get("relevance_score") or 0.0) logger.info("analogs tier=W radius=%dm → %d candidates", radius_m, len(candidates)) return _stratify_candidates(candidates), radius_m > DEFAULT_RADIUS_M, "W" diff --git a/tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py b/tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py new file mode 100644 index 00000000..1dea4cab --- /dev/null +++ b/tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py @@ -0,0 +1,139 @@ +"""Неизвестный 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:] + )