Merge pull request 'fix(tradein/estimate): не применять хедонику к неправдоподобному году постройки' (#2624) from fix/tradein-implausible-build-year into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m36s
Deploy Trade-In / build-backend (push) Successful in 56s
Deploy Trade-In / deploy (push) Successful in 1m24s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m36s
Deploy Trade-In / build-backend (push) Successful in 56s
Deploy Trade-In / deploy (push) Successful in 1m24s
This commit is contained in:
commit
4b3d8b4cca
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
|
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).
|
# Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24).
|
||||||
# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
|
# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
|
||||||
|
|
@ -3335,6 +3384,16 @@ async def estimate_quality(
|
||||||
if target_house_type is None:
|
if target_house_type is None:
|
||||||
target_house_type = house_meta.house_type
|
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):
|
# 3. Four-tier fallback (PR 9 — added Tier 0 with cohort filter):
|
||||||
# 0) 1km + ±15% area + cohort match (year_built — если задан)
|
# 0) 1km + ±15% area + cohort match (year_built — если задан)
|
||||||
# a) 1km + ±15% area (без cohort — drop fallback)
|
# 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