fix(tradein/estimate): не применять хедонику к неправдоподобному году постройки #2624
2 changed files with 246 additions and 0 deletions
|
|
@ -200,6 +200,55 @@ def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]:
|
|||
return bands
|
||||
|
||||
|
||||
# Правдоподобный диапазон года постройки МКД (guard на входе — Mera-audit 2026-08-02).
|
||||
# Нижняя граница 1917: массовая многоквартирная застройка в РФ/СССР началась
|
||||
# после революции — год раньше почти гарантированно ошибка источника
|
||||
# (house_metadata OSM/кадастр смешивают год постройки дома с годом основания
|
||||
# места/памятника на тех же координатах — прод-инцидент 2026-08: house_metadata
|
||||
# отдал year_built=1829 для обычной вторички, см. vault fixes). Верхняя граница
|
||||
# — текущий год + 3: допуск на цели trade-in со строящимся домом (год сдачи по
|
||||
# ДДУ известен заранее, но не более чем на несколько лет вперёд).
|
||||
# Год вне диапазона трактуем как ОТСУТСТВУЮЩИЙ (None), а НЕ клампим к границе —
|
||||
# хедонический фактор (_price_from_inputs, #2002) экстраполирует regression fit
|
||||
# far вне обучающей выборки (COHORTS ниже даже не определяет когорту раньше
|
||||
# 1955 — модель никогда не видела осмысленного объёма домов старше этого), и
|
||||
# estimate_hedonic_factor_min=0.75 в таком случае не защита, а маскировка
|
||||
# выхода за диапазон под видом уверенной −25% поправки. «Не знаем год» —
|
||||
# честный сигнал, который просто отключает year-term фактора (нейтрален).
|
||||
MIN_PLAUSIBLE_BUILD_YEAR = 1917
|
||||
MAX_PLAUSIBLE_BUILD_YEAR_LEAD = 3 # текущий год + N — допуск на стройки
|
||||
|
||||
|
||||
def _sanitize_build_year(
|
||||
year: int | None, *, house_id: int | None = None, address: str | None = None
|
||||
) -> int | None:
|
||||
"""Отбрасывает неправдоподобный год постройки, трактуя его как «неизвестен».
|
||||
|
||||
Валидный диапазон — [MIN_PLAUSIBLE_BUILD_YEAR, текущий год + LEAD]. Год вне
|
||||
диапазона логируется на WARNING (с идентификатором дома — house_id либо
|
||||
адрес) и заменяется на None, а не клампится к границе: клампинг превращает
|
||||
заведомый мусор источника (house_metadata OSM/кадастр, либо year_built из
|
||||
payload — ge=1800 в схеме пропускает подобные значения) в уверенный вход
|
||||
для хедонической поправки (_price_from_inputs), хотя физического смысла
|
||||
у результата нет.
|
||||
"""
|
||||
if year is None:
|
||||
return None
|
||||
max_year = datetime.now(UTC).year + MAX_PLAUSIBLE_BUILD_YEAR_LEAD
|
||||
if year < MIN_PLAUSIBLE_BUILD_YEAR or year > max_year:
|
||||
logger.warning(
|
||||
"estimate: implausible year_built=%s dropped (house_id=%s, address=%s) — "
|
||||
"valid range [%s, %s]",
|
||||
year,
|
||||
house_id,
|
||||
address,
|
||||
MIN_PLAUSIBLE_BUILD_YEAR,
|
||||
max_year,
|
||||
)
|
||||
return None
|
||||
return year
|
||||
|
||||
|
||||
# Когорта по году постройки — типизация массовой застройки РФ.
|
||||
# Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24).
|
||||
# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
|
||||
|
|
@ -3335,6 +3384,16 @@ async def estimate_quality(
|
|||
if target_house_type is None:
|
||||
target_house_type = house_meta.house_type
|
||||
|
||||
# 2b. Mera-audit 2026-08-02: неправдоподобный год (payload user-input ge=1800/le=2100 в схеме,
|
||||
# либо house_metadata OSM/кадастр — прод-инцидент year_built=1829) — на
|
||||
# «неизвестен» ДО того как target_year уйдёт в cohort-фильтр (ниже),
|
||||
# _fetch_analogs house-match scoring и хедонический фактор
|
||||
# (_price_from_inputs, #2002). Единая точка входа — все три места ниже
|
||||
# используют этот же target_year.
|
||||
target_year = _sanitize_build_year(
|
||||
target_year, house_id=target_house_id, address=payload.address
|
||||
)
|
||||
|
||||
# 3. Four-tier fallback (PR 9 — added Tier 0 with cohort filter):
|
||||
# 0) 1km + ±15% area + cohort match (year_built — если задан)
|
||||
# a) 1km + ±15% area (без cohort — drop fallback)
|
||||
|
|
|
|||
187
tradein-mvp/backend/tests/test_estimator_implausible_year.py
Normal file
187
tradein-mvp/backend/tests/test_estimator_implausible_year.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""Guard against implausible year_built poisoning the hedonic correction (Mera-audit 2026-08-02).
|
||||
|
||||
house_metadata (OSM/кадастр, best-effort enrichment) и
|
||||
TradeInEstimateInput.year_built (payload, схема допускает ge=1800) могут
|
||||
отдать явно ошибочный год постройки МКД — прод-инцидент: house_metadata
|
||||
year_built=1829 для обычной вторички (см. vault fixes).
|
||||
|
||||
Без валидации этот год уходит в хедонический year+area фактор
|
||||
(_price_from_inputs, #2002), который экстраполирует regression fit далеко за
|
||||
пределы обучающей выборки (COHORTS не определяет когорту раньше 1955 — см.
|
||||
estimator.py) и упирается в нижний кламп estimate_hedonic_factor_min=0.75 —
|
||||
выкупная цена режется на фиксированные −25% без физического смысла.
|
||||
|
||||
_sanitize_build_year() отсекает год вне
|
||||
[MIN_PLAUSIBLE_BUILD_YEAR, текущий год + MAX_PLAUSIBLE_BUILD_YEAR_LEAD] на
|
||||
входе, трактуя его как «неизвестен» (None) — хедонический year-term
|
||||
становится нейтральным (эквивалент year=2000, см. test_estimator_hedonic.py
|
||||
::test_target_year_none_is_neutral), а не клампится к произвольной границе.
|
||||
|
||||
NOTE: importing app.services.estimator pulls app.core.config.Settings which
|
||||
requires DATABASE_URL. Set it BEFORE importing app modules (см. паттерн
|
||||
test_estimator_hedonic.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services import estimator
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
# ── _sanitize_build_year: unit-level ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_implausible_low_year_dropped_to_none(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""год=1829 (прод-инцидент house_metadata) → трактуется как отсутствующий."""
|
||||
with caplog.at_level("WARNING"):
|
||||
result = estimator._sanitize_build_year(1829, house_id=42, address="ул. Тестовая, 1")
|
||||
assert result is None
|
||||
assert any("1829" in r.message for r in caplog.records)
|
||||
assert any("42" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_plausible_year_unchanged() -> None:
|
||||
"""год=1960 (хрущёвка) — валиден, работает как раньше (без изменений)."""
|
||||
assert estimator._sanitize_build_year(1960) == 1960
|
||||
|
||||
|
||||
def test_future_year_beyond_lead_dropped() -> None:
|
||||
"""год = текущий+10 (далеко за допуском для строек) → отбрасывается."""
|
||||
future_year = datetime.now(UTC).year + 10
|
||||
assert estimator._sanitize_build_year(future_year) is None
|
||||
|
||||
|
||||
def test_near_future_year_within_lead_kept() -> None:
|
||||
"""год = текущий + LEAD (граница допуска для строек) — остаётся валидным."""
|
||||
near_future = datetime.now(UTC).year + estimator.MAX_PLAUSIBLE_BUILD_YEAR_LEAD
|
||||
assert estimator._sanitize_build_year(near_future) == near_future
|
||||
|
||||
|
||||
def test_none_year_unchanged() -> None:
|
||||
"""Отсутствие года — поведение НЕ меняется (уже было честным «не знаем»)."""
|
||||
assert estimator._sanitize_build_year(None) is None
|
||||
|
||||
|
||||
def test_boundary_year_min_plausible_kept() -> None:
|
||||
"""MIN_PLAUSIBLE_BUILD_YEAR сам — валиден (inclusive)."""
|
||||
year = estimator.MIN_PLAUSIBLE_BUILD_YEAR
|
||||
assert estimator._sanitize_build_year(year) == year
|
||||
|
||||
|
||||
def test_boundary_year_below_min_dropped() -> None:
|
||||
"""MIN_PLAUSIBLE_BUILD_YEAR - 1 — уже невалиден."""
|
||||
assert estimator._sanitize_build_year(estimator.MIN_PLAUSIBLE_BUILD_YEAR - 1) is None
|
||||
|
||||
|
||||
# ── price impact via _price_from_inputs (hermetic, no DB) — #1966-стиль ────
|
||||
|
||||
|
||||
def _geo() -> GeocodeResult:
|
||||
return GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.597,
|
||||
full_address="ул. Тестовая, 1",
|
||||
provider="nominatim",
|
||||
confidence="approximate",
|
||||
)
|
||||
|
||||
|
||||
def _lots(ppm2: float, n: int = 7) -> list[dict]:
|
||||
"""n unique-address lots all at the same ₽/m² → median_ppm2 == ppm2."""
|
||||
return [
|
||||
{"price_per_m2": ppm2, "address": f"ул. Тестовая, {i + 1}", "source": "avito"}
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def _price(*, target_year: int | None, area_m2: float = 50.0) -> estimator.PricingResult:
|
||||
"""Pure radius-only spine call (no anchor / dkp / imv) with a forced ratio.
|
||||
|
||||
Зеркалит helper из test_estimator_hedonic.py — прогоняет ровно тот же
|
||||
вызов _price_from_inputs, который estimate_quality делает после
|
||||
_sanitize_build_year(target_year, ...).
|
||||
"""
|
||||
|
||||
def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]:
|
||||
return 0.85, "per_rooms"
|
||||
|
||||
return estimator._price_from_inputs(
|
||||
listings=_lots(100_000.0),
|
||||
area_m2=area_m2,
|
||||
rooms=2,
|
||||
repair_state=None,
|
||||
floor=5,
|
||||
total_floors=10,
|
||||
target_year=target_year,
|
||||
analog_tier="W",
|
||||
fallback_used=False,
|
||||
area_widened=False,
|
||||
anchor_comps=[],
|
||||
anchor_tier_fetched=None,
|
||||
dkp_raw=None,
|
||||
imv_anchor=None,
|
||||
imv_eval=None,
|
||||
yandex_val_present=False,
|
||||
cian_val_present=False,
|
||||
ratio_resolver=ratio_resolver,
|
||||
quarter_index_lookup=lambda q: None,
|
||||
quarter_indexes_lookup=lambda qs: {},
|
||||
target_house_cadnum=None,
|
||||
dadata_coarse=False,
|
||||
geo=_geo(),
|
||||
dadata_qc_geo=None,
|
||||
)
|
||||
|
||||
|
||||
def test_price_not_cut_after_sanitizing_1829(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Прод-репро (Mera-audit 2026-08-02): год=1829 без guard'а клампит хедонический фактор в
|
||||
пол estimate_hedonic_factor_min=0.75 (−25% к цене). После sanitize
|
||||
(estimate_quality прогоняет target_year через _sanitize_build_year ДО
|
||||
_price_from_inputs) год трактуется как отсутствующий — фактор нейтрален,
|
||||
цена НЕ порезана.
|
||||
"""
|
||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
|
||||
|
||||
# "Было бы" без фикса: 1829 идёт в хедонику напрямую.
|
||||
unfixed = _price(target_year=1829)
|
||||
# "Стало" с фиксом: estimate_quality сначала санитайзит год.
|
||||
sanitized_year = estimator._sanitize_build_year(1829)
|
||||
assert sanitized_year is None
|
||||
fixed = _price(target_year=sanitized_year)
|
||||
|
||||
assert unfixed.expected_sold_price is not None
|
||||
assert fixed.expected_sold_price is not None
|
||||
|
||||
ratio_only = round(unfixed.median_price * 0.85)
|
||||
factor_before = unfixed.expected_sold_price / ratio_only
|
||||
factor_after = fixed.expected_sold_price / ratio_only
|
||||
|
||||
# До фикса — кламп ровно в пол (фиксированная −25% недоплата).
|
||||
assert factor_before == pytest.approx(estimator.settings.estimate_hedonic_factor_min, abs=1e-3)
|
||||
# После фикса — год «неизвестен», фактор около нейтрали (НЕ 0.75).
|
||||
assert factor_after > 0.95
|
||||
assert fixed.expected_sold_price > unfixed.expected_sold_price
|
||||
|
||||
# Совпадает байт-в-байт с явным "год не указан" (test_target_year_none_is_neutral).
|
||||
none_year = _price(target_year=None)
|
||||
assert fixed.expected_sold_price == none_year.expected_sold_price
|
||||
assert fixed.expected_sold_per_m2 == none_year.expected_sold_per_m2
|
||||
|
||||
|
||||
def test_year_1960_hedonic_unaffected_by_guard(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""год=1960 (валидная хрущёвка) — guard не меняет хедоническую поправку."""
|
||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
|
||||
sanitized_year = estimator._sanitize_build_year(1960)
|
||||
assert sanitized_year == 1960
|
||||
|
||||
before = _price(target_year=1960)
|
||||
after = _price(target_year=sanitized_year)
|
||||
|
||||
assert before.expected_sold_price == after.expected_sold_price
|
||||
assert before.expected_sold_per_m2 == after.expected_sold_per_m2
|
||||
Loading…
Add table
Reference in a new issue