feat(tradein/estimator): штраф за неизвестный год/тип дома — флагом, default OFF (#2936) #3015
3 changed files with 248 additions and 2 deletions
|
|
@ -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. Апартаменты — юридически иной
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
139
tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py
Normal file
139
tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py
Normal file
|
|
@ -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:]
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue