fix(tradein/estimate): не применять хедонику к неправдоподобному году постройки #2624

Merged
lekss361 merged 1 commit from fix/tradein-implausible-build-year into main 2026-08-02 11:22:23 +00:00
2 changed files with 246 additions and 0 deletions

View file

@ -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)

View 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