estimate_dedup_analogs_enabled: False → True. Бэктест #1966 OFF vs ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv без изменений) — включение меняет только user-visible n_analogs: перестаёт быть раздутым кросс-постингом одного физлота на avito+cian+domklik ×3. Откат — ENV ESTIMATE_DEDUP_ANALOGS_ENABLED=false. Тесты разведены default vs explicit (не ослаблены): - test_estimator_dedup_cross_source_2087: добавлен test_dedup_default_is_on (без monkeypatch — дедуп активен по умолчанию); test_dedup_flag_off_is_noop оставляет ЯВНЫЙ OFF-override. - test_backtest_regression_gate: пиним флаг OFF — гейт это байт-идентичный replay frozen OFF-capture (recorded call-sequence фикстуры без дедупа); с ON _dedup_cross_source триммит listings до quarter_indexes_lookup и control-flow расходится с записью. - test_estimator_n_analogs_priced (autouse) + test_radius_path_n_analogs_unchanged: пиним OFF — инвариант «n_analogs = число priced-аналогов»/radius-passthrough ортогонален дедупу, а синтетические аналоги делят адрес/этаж/площадь и схлопнулись бы как кросс-посты. Refs #2087, #2173
145 lines
7.5 KiB
Python
145 lines
7.5 KiB
Python
"""#2 (adversarial audit) — n_analogs должен считать аналоги С ЦЕНОЙ, а не все.
|
||
|
||
Дефект: `_filter_outliers` СОХРАНЯЕТ листинги с `price_per_m2 IS NULL` (нечего
|
||
судить как outlier — keep). Затем агрегация считает медиану только по
|
||
`prices_ppm2` (price-less отброшены), но `n_analogs = len(listings_clean)` считал
|
||
ВСЕ листинги, включая безценовые. Итог: «Найдено N аналогов» завышен, а в пределе
|
||
(все радиусные аналоги без цены) median_ppm2=0 при n_analogs>0.
|
||
|
||
Фикс: в radius-aggregation ветке `n_analogs = len(prices_ppm2)` — число аналогов,
|
||
реально внёсших цену. listings_clean НЕ фильтруется (downstream analogs_lots /
|
||
sources_used / repair_coef сохраняют полное выживание outlier-фильтра).
|
||
|
||
Контракты, которые фикс НЕ ломает (проверены отдельными тестами в
|
||
test_same_building_anchor.py): #698 anchor-путь перезаписывает n_analogs на
|
||
anchor['n']; #691 gate + _enforce_zero_analog_low ловят n_analogs==0 → 'low'.
|
||
|
||
Полный estimate-путь со всеми I/O застаблен через harness _run_estimate из
|
||
test_same_building_anchor.py (anchor_comps=[] / anchor_tier=None → radius-путь).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
import os
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||
|
||
from app.services import estimator
|
||
|
||
# Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный
|
||
# estimate путь со всеми I/O застабленными). anchor_comps=[] + anchor_tier=None
|
||
# направляет estimate в radius-aggregation ветку (где живёт фикс #2).
|
||
_ANCHOR_TEST = Path(__file__).parent / "test_same_building_anchor.py"
|
||
_spec = importlib.util.spec_from_file_location("_anchor_harness_n_analogs", _ANCHOR_TEST)
|
||
assert _spec is not None and _spec.loader is not None
|
||
_h = importlib.util.module_from_spec(_spec)
|
||
_spec.loader.exec_module(_h)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _pin_dedup_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
# Эти тесты проверяют инвариант #2 «n_analogs = число priced-аналогов»,
|
||
# ортогональный кросс-source физдедупу (#2087 H4, default ON с #2173).
|
||
# Синтетические аналоги делят один адрес/этаж/площадь → дедуп схлопнул бы
|
||
# близкие по цене как кросс-посты и исказил счётчик. Пиним флаг OFF, чтобы
|
||
# мерить именно #2 в изоляции (не полагаясь на прод-default, который теперь ON).
|
||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
|
||
|
||
|
||
def _listing(price_per_m2: float | None, area_m2: float = 60.0) -> dict[str, Any]:
|
||
"""Радиусный аналог; price_per_m2=None моделирует листинг без ₽/м².
|
||
|
||
Это и есть прод-сценарий дефекта: SQL-фильтр гарантирует price_rub>0, но
|
||
price_per_m2 — отдельно вычисляемая колонка и может быть NULL (напр. area_m2
|
||
отсутствует → ₽/м² не определён). _filter_outliers СОХРАНЯЕТ такой листинг
|
||
(нечего судить по ₽/м²), он попадает в listings_clean, но НЕ в prices_ppm2.
|
||
price_rub остаётся валидным (как в проде, WHERE price_rub > 0), поэтому
|
||
_listing_to_analog не падает — дефект именно в счётчике, не в построении.
|
||
"""
|
||
return {
|
||
"source": "cian",
|
||
"source_url": "https://cian.ru/offer/1",
|
||
"address": "ЕКБ, ул. Хохрякова, 48",
|
||
"lat": 56.830,
|
||
"lon": 60.592,
|
||
"rooms": 2,
|
||
"area_m2": area_m2,
|
||
"floor": 5,
|
||
"total_floors": 14,
|
||
# price_rub всегда задан (прод WHERE price_rub > 0); ₽/м² может быть NULL.
|
||
"price_rub": (price_per_m2 * area_m2) if price_per_m2 else 9_000_000.0,
|
||
"price_per_m2": price_per_m2,
|
||
"listing_date": datetime(2026, 5, 1),
|
||
"days_on_market": 10,
|
||
"photo_urls": [],
|
||
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||
"distance_m": 0.0,
|
||
"relevance_score": 0.0,
|
||
"listing_segment": "vtorichka",
|
||
}
|
||
|
||
|
||
def test_n_analogs_counts_only_priced_radius_analogs() -> None:
|
||
"""Radius-путь: 3 аналога с ценой + 2 без цены (price_per_m2=None) пережили
|
||
outlier-фильтр → n_analogs == 3 (число ВНЁСШИХ цену), а не 5 (всего).
|
||
|
||
До фикса n_analogs = len(listings_clean) = 5 → «Найдено 5 аналогов» вводило в
|
||
заблуждение (медиана построена лишь по 3)."""
|
||
radius = [
|
||
_listing(price_per_m2=200_000.0),
|
||
_listing(price_per_m2=210_000.0),
|
||
_listing(price_per_m2=220_000.0),
|
||
_listing(price_per_m2=None),
|
||
_listing(price_per_m2=None),
|
||
]
|
||
est = _h._run_estimate(
|
||
anchor_comps=[],
|
||
anchor_tier=None,
|
||
radius_analogs=radius,
|
||
payload=_h._make_payload(area=60.0, rooms=2),
|
||
)
|
||
# n_analogs = число PRICED аналогов (3), НЕ len(listings_clean) (5).
|
||
assert est.n_analogs == 3
|
||
# Медиана построена → headline ненулевой (sanity: фикс не обнулил оценку).
|
||
assert est.median_price_rub > 0
|
||
|
||
|
||
def test_n_analogs_all_priceless_yields_zero_and_low() -> None:
|
||
"""Radius-путь, ВСЕ аналоги без цены (price_per_m2=None) → median_ppm2=0,
|
||
n_analogs=0, и confidence='low' (zero-analog guard применяется).
|
||
|
||
Это крайний случай дефекта: раньше n_analogs>0 при median=0 («Найдено N
|
||
аналогов» при нулевой цене). Теперь n_analogs=0 → _compute_confidence
|
||
(median_ppm2==0 → 'low') + _enforce_zero_analog_low не дублирует (уже 'low')."""
|
||
radius = [_listing(price_per_m2=None) for _ in range(4)]
|
||
est = _h._run_estimate(
|
||
anchor_comps=[],
|
||
anchor_tier=None,
|
||
radius_analogs=radius,
|
||
payload=_h._make_payload(area=60.0, rooms=2),
|
||
)
|
||
assert est.n_analogs == 0
|
||
assert est.median_price_rub == 0
|
||
assert est.median_price_per_m2 == 0
|
||
assert est.confidence == "low"
|
||
|
||
|
||
def test_n_analogs_all_priced_unchanged() -> None:
|
||
"""Контроль: все аналоги С ценой → n_analogs = их число (поведение не меняется,
|
||
зеркало test_radius_path_n_analogs_unchanged для priced-only выборки)."""
|
||
radius = [_listing(price_per_m2=200_000.0 + i * 1_000) for i in range(5)]
|
||
est = _h._run_estimate(
|
||
anchor_comps=[],
|
||
anchor_tier=None,
|
||
radius_analogs=radius,
|
||
payload=_h._make_payload(area=60.0, rooms=2),
|
||
)
|
||
assert est.n_analogs == 5
|
||
assert est.median_price_rub > 0
|