Оценка: дедуп склеивает улицы вида «40-летия Комсомола», пороги Tier H — в настройки для бэктеста #3552
3 changed files with 93 additions and 6 deletions
|
|
@ -711,6 +711,14 @@ class Settings(BaseSettings):
|
||||||
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
|
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
|
||||||
estimate_unknown_attr_penalty_enabled: bool = False
|
estimate_unknown_attr_penalty_enabled: bool = False
|
||||||
estimate_unknown_attr_penalty_min_n: int = 5
|
estimate_unknown_attr_penalty_min_n: int = 5
|
||||||
|
# #3234: Tier H в _fetch_analogs («тот же класс дома»). Порог отдачи и окно
|
||||||
|
# этажности были литералами; вынесены, чтобы бэктест мог сравнить варианты
|
||||||
|
# через env без правки кода. Дефолты = прежнее поведение байт-в-байт.
|
||||||
|
# Прод, Loki 30 сут (12.09): 61 из 86 попыток Tier H уходят в Tier W, в 31 из
|
||||||
|
# них в своей полосе 0 комплов. Менять дефолт — только по MAPE бэктеста.
|
||||||
|
# ENV: ESTIMATE_TIER_H_MIN_COMPS, ESTIMATE_TIER_H_FLOORS_TOL.
|
||||||
|
estimate_tier_h_min_comps: int = 5
|
||||||
|
estimate_tier_h_floors_tol: float = 0.30 # total_floors × (1 ± tol)
|
||||||
#
|
#
|
||||||
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
|
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
|
||||||
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
|
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
|
||||||
|
|
|
||||||
|
|
@ -6551,8 +6551,9 @@ def _fetch_analogs(
|
||||||
_normalize_building_key + _house_boundary_regex, тот же принцип что и anchor
|
_normalize_building_key + _house_boundary_regex, тот же принцип что и anchor
|
||||||
Tier A) + ST_DWithin(radius_m). Если ≥3 результатов → tier='S'.
|
Tier A) + ST_DWithin(radius_m). Если ≥3 результатов → tier='S'.
|
||||||
|
|
||||||
**Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors ±30%.
|
**Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors
|
||||||
Если ≥5 результатов → возвращаем; tier='H'.
|
±estimate_tier_h_floors_tol (0.30). Если ≥ estimate_tier_h_min_comps (5)
|
||||||
|
результатов → возвращаем; tier='H'.
|
||||||
Пропускается если year_built или total_floors неизвестны.
|
Пропускается если year_built или total_floors неизвестны.
|
||||||
|
|
||||||
**Tier W (wide / current):** текущая логика без year/floors WHERE фильтра.
|
**Tier W (wide / current):** текущая логика без year/floors WHERE фильтра.
|
||||||
|
|
@ -6747,12 +6748,12 @@ def _fetch_analogs(
|
||||||
)
|
)
|
||||||
return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S"
|
return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S"
|
||||||
|
|
||||||
# ── Tier H: same class (year ±15, total_floors ±30%) ─────────────────────
|
# ── Tier H: same class (year ±15, total_floors ±tol, #3234: tol/порог в settings) ─
|
||||||
if year_built is not None and total_floors is not None:
|
if year_built is not None and total_floors is not None:
|
||||||
year_min = year_built - 15
|
year_min = year_built - 15
|
||||||
year_max = year_built + 15
|
year_max = year_built + 15
|
||||||
tf_min = math.floor(total_floors * 0.7)
|
tf_min = math.floor(total_floors * (1 - settings.estimate_tier_h_floors_tol))
|
||||||
tf_max = math.ceil(total_floors * 1.3)
|
tf_max = math.ceil(total_floors * (1 + settings.estimate_tier_h_floors_tol))
|
||||||
|
|
||||||
tier_h_rows = (
|
tier_h_rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -6860,7 +6861,7 @@ def _fetch_analogs(
|
||||||
min_n=settings.estimate_unknown_attr_penalty_min_n,
|
min_n=settings.estimate_unknown_attr_penalty_min_n,
|
||||||
)
|
)
|
||||||
tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0)
|
tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0)
|
||||||
if len(tier_h) >= 5:
|
if len(tier_h) >= settings.estimate_tier_h_min_comps:
|
||||||
logger.info(
|
logger.info(
|
||||||
"analogs tier=H year=%d±15 tf=%d-%d → %d results",
|
"analogs tier=H year=%d±15 tf=%d-%d → %d results",
|
||||||
year_built,
|
year_built,
|
||||||
|
|
|
||||||
78
tradein-mvp/backend/tests/test_3234_tier_h_settings.py
Normal file
78
tradein-mvp/backend/tests/test_3234_tier_h_settings.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""#3234: порог и окно этажности Tier H берутся из settings, дефолты = прежние литералы.
|
||||||
|
|
||||||
|
Tier H в _fetch_analogs отдаёт «тот же класс дома» только при ≥ N комплов, иначе
|
||||||
|
уходит в Tier W без фильтра по классу. N и окно total_floors были литералами (5 и
|
||||||
|
×0.7…×1.3); вынесены, чтобы бэктест сравнил варианты через env. Проверяем значения,
|
||||||
|
которые реально уходят в SQL, и тир, который реально возвращается.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import app.services.estimator as est
|
||||||
|
|
||||||
|
|
||||||
|
def _run(n_tier_h: int, total_floors: int) -> tuple[str, dict]:
|
||||||
|
"""_fetch_analogs на mock-БД: Tier H отдаёт n_tier_h строк, Tier W — одну."""
|
||||||
|
tier_h_params: dict = {}
|
||||||
|
|
||||||
|
def side_effect(stmt, params): # type: ignore[no-untyped-def]
|
||||||
|
result = MagicMock()
|
||||||
|
if "tf_min" in params:
|
||||||
|
tier_h_params.update(params)
|
||||||
|
rows = [{"source": "avito", "relevance_score": float(i)} for i in range(n_tier_h)]
|
||||||
|
else:
|
||||||
|
rows = [{"source": "cian", "relevance_score": 0.0}]
|
||||||
|
result.mappings.return_value.all.return_value = rows
|
||||||
|
return result
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.side_effect = side_effect
|
||||||
|
_, _, tier = est._fetch_analogs(
|
||||||
|
db,
|
||||||
|
lat=56.83,
|
||||||
|
lon=60.6,
|
||||||
|
rooms=2,
|
||||||
|
area=50.0,
|
||||||
|
radius_m=1000,
|
||||||
|
year_built=1975,
|
||||||
|
total_floors=total_floors,
|
||||||
|
)
|
||||||
|
return tier, tier_h_params
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_keep_old_literals() -> None:
|
||||||
|
tier, params = _run(n_tier_h=5, total_floors=9)
|
||||||
|
assert (params["tf_min"], params["tf_max"]) == (6, 12) # floor(6.3), ceil(11.7)
|
||||||
|
assert tier == "H"
|
||||||
|
tier, params = _run(n_tier_h=4, total_floors=16)
|
||||||
|
assert (params["tf_min"], params["tf_max"]) == (11, 21) # floor(11.2), ceil(20.8)
|
||||||
|
assert tier == "W"
|
||||||
|
|
||||||
|
|
||||||
|
def test_min_comps_from_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(est.settings, "estimate_tier_h_min_comps", 4)
|
||||||
|
assert _run(n_tier_h=4, total_floors=9)[0] == "H"
|
||||||
|
assert _run(n_tier_h=3, total_floors=9)[0] == "W"
|
||||||
|
|
||||||
|
|
||||||
|
def test_floors_tol_from_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(est.settings, "estimate_tier_h_floors_tol", 0.5)
|
||||||
|
_, params = _run(n_tier_h=5, total_floors=9)
|
||||||
|
assert (params["tf_min"], params["tf_max"]) == (4, 14) # floor(4.5), ceil(13.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_reaches_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# Бэктест вариантов идёт через `docker exec -e ESTIMATE_TIER_H_…` — env обязан доехать.
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
monkeypatch.setenv("ESTIMATE_TIER_H_MIN_COMPS", "4")
|
||||||
|
monkeypatch.setenv("ESTIMATE_TIER_H_FLOORS_TOL", "0.5")
|
||||||
|
s = Settings()
|
||||||
|
assert (s.estimate_tier_h_min_comps, s.estimate_tier_h_floors_tol) == (4, 0.5)
|
||||||
Loading…
Add table
Reference in a new issue