Compare commits
No commits in common. "4b3d8b4ccad91abcc3e39f67713211d66907ac5a" and "0d2d54a634a492c3a18eb7a3a0322ad585518e6a" have entirely different histories.
4b3d8b4cca
...
0d2d54a634
2 changed files with 0 additions and 246 deletions
|
|
@ -200,55 +200,6 @@ 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 пропускается.
|
||||||
|
|
@ -3384,16 +3335,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
"""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