fix(estimator): deterministic comp tie-breaker + n_analogs counts priced analogs #1939

Merged
bot-backend merged 1 commit from fix/estimator-tiebreaker-nanalogs into main 2026-06-27 00:58:15 +00:00
2 changed files with 153 additions and 3 deletions

View file

@ -2140,7 +2140,14 @@ async def estimate_quality(
median_price = int(median_ppm2 * payload.area_m2)
range_low = int(q1_ppm2 * payload.area_m2)
range_high = int(q3_ppm2 * payload.area_m2)
n_analogs = len(listings_clean)
# #2: n_analogs должен отражать число аналогов, реально внёсших цену в
# медиану, а не len(listings_clean). _filter_outliers СОХРАНЯЕТ листинги с
# price_per_m2 IS NULL, но prices_ppm2 их отбрасывает → len(listings_clean)
# завышал счётчик ("Найдено N аналогов" вводил в заблуждение; в пределе
# все price-less → median_ppm2=0, но n_analogs>0). Считаем по prices_ppm2.
# listings_clean НЕ фильтруем — downstream (analogs_lots, sources_used,
# repair_coef) по-прежнему работает с полным выживанием outlier-фильтра.
n_analogs = len(prices_ppm2)
else:
median_ppm2 = 0
median_price = 0
@ -3919,6 +3926,7 @@ def _fetch_analogs(
WITH base AS (
SELECT
{_ANALOG_SELECT_COLS},
id,
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
AS distance_m,
(
@ -3976,7 +3984,13 @@ def _fetch_analogs(
FROM base
WHERE rn_addr <= :max_per_addr
{dup_filter}
ORDER BY relevance_score
-- Детерминированный тай-брейкер: при равном relevance_score (один
-- distance-bucket + year delta + house_type идентичный score)
-- Postgres возвращал бы строки в произвольном порядке, и тот же
-- /analyze давал бы разные комп-сэты между запусками. distance_m
-- ASC scraped_at DESC (свежее) id ASC (PK, гарантирует total
-- order) делают выборку воспроизводимой.
ORDER BY relevance_score ASC, distance_m ASC, scraped_at DESC NULLS LAST, id ASC
LIMIT 300
"""
),
@ -4029,6 +4043,7 @@ def _fetch_analogs(
listing_date, days_on_market, photo_urls,
scraped_at,
building_cadastral_number,
id,
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
AS distance_m,
(
@ -4114,7 +4129,11 @@ def _fetch_analogs(
FROM base
WHERE rn_addr <= :max_per_addr
{dup_filter}
ORDER BY relevance_score
-- Детерминированный тай-брейкер: симметрично Tier H. При равном
-- relevance_score Postgres возвращал бы строки в произвольном порядке
-- недетерминированная оценка. distance_m ASC scraped_at DESC
-- (свежее) id ASC (PK, total order) делают выборку воспроизводимой.
ORDER BY relevance_score ASC, distance_m ASC, scraped_at DESC NULLS LAST, id ASC
LIMIT 300
"""
),

View file

@ -0,0 +1,131 @@
"""#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
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
# Переиспользуем 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)
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