feat(tradein-estimator): cohort filter + Tier 0 for analog selection quality (#519)
All checks were successful
Deploy Trade-In / deploy (push) Successful in 32s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 40s

This commit is contained in:
lekss361 2026-05-24 13:04:58 +00:00
parent 92d00ec874
commit 7cc7fafeeb

View file

@ -64,6 +64,37 @@ MIN_ANALOGS_PER_SOURCE = 5 # гарантированный минимум н
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
DEALS_PERIOD_MONTHS = 12 # сделки за последний год DEALS_PERIOD_MONTHS = 12 # сделки за последний год
# Когорта по году постройки — типизация массовой застройки РФ.
# Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24).
# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
COHORTS = (
# (cohort_name, year_min_inclusive, year_max_inclusive)
('khrushchev', 1955, 1969), # Хрущёвки 5-эт
('brezhnev', 1970, 1989), # Брежневка кирпич/панель 912-эт
('late_soviet', 1985, 1999), # Поздний СССР (overlap с brezhnev intentional)
('2000s', 2000, 2010), # Ранние новостройки
('modern', 2011, 2100), # Современные ЖК
)
# Минимум аналогов чтобы остаться на Tier 0 (с cohort); ниже — fallback на Tier A.
MIN_ANALOGS_TIER_0 = 5
def _target_cohort_range(year_built: int | None) -> tuple[int, int] | None:
"""Maps a target year to its cohort year range [min, max] inclusive.
Returns None if year_built is None caller will skip cohort filter.
Picks the FIRST matching cohort (so 1988 'brezhnev', not 'late_soviet').
"""
if year_built is None:
return None
for _name, ymin, ymax in COHORTS:
if ymin <= year_built <= ymax:
return (ymin, ymax)
# Out-of-range год (например, 1900 или 2050) — cohort фильтр не применяем,
# лучше показать что есть в радиусе, чем 0 результатов.
return None
# Поправочные коэффициенты на состояние ремонта. Аналоги в выборке — микс # Поправочные коэффициенты на состояние ремонта. Аналоги в выборке — микс
# состояний (≈ "стандартный/косметический"), коэффициент сдвигает медиану под # состояний (≈ "стандартный/косметический"), коэффициент сдвигает медиану под
# конкретный ремонт целевой квартиры. Встреча Птицы: ремонт влияет на цену. # конкретный ремонт целевой квартиры. Встреча Птицы: ремонт влияет на цену.
@ -494,15 +525,39 @@ 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
# 3. House-match: S → H → W tiered lookup (see _fetch_analogs docstring). # 3. Four-tier fallback (PR 9 — added Tier 0 with cohort filter):
# Radius fallback still applies when W tier has < 5 results at 1km. # 0) 1km + ±15% area + cohort match (year_built — если задан)
listings, fallback_used, analog_tier = _fetch_analogs( # a) 1km + ±15% area (без cohort — drop fallback)
db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, # b) 2km + ±15% area (fallback_used = True)
radius_m=DEFAULT_RADIUS_M, # c) 2km + ±25% area (fallback_used = True, area_widened = True)
full_address=geo.full_address, cohort_range = _target_cohort_range(target_year)
year_built=target_year, house_type=target_house_type,
total_floors=payload.total_floors, if cohort_range is not None:
) cy_min, cy_max = cohort_range
listings_tier0, _, analog_tier = _fetch_analogs(
db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2,
radius_m=DEFAULT_RADIUS_M,
full_address=geo.full_address,
year_built=target_year, house_type=target_house_type,
total_floors=payload.total_floors,
cohort_year_min=cy_min, cohort_year_max=cy_max,
)
else:
listings_tier0 = []
analog_tier = 'W'
if len(listings_tier0) >= MIN_ANALOGS_TIER_0:
listings = listings_tier0
fallback_used = False
else:
# Tier 0 пуст/мал — graceful fallback на Tier A без cohort
listings, fallback_used, analog_tier = _fetch_analogs(
db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2,
radius_m=DEFAULT_RADIUS_M,
full_address=geo.full_address,
year_built=target_year, house_type=target_house_type,
total_floors=payload.total_floors,
)
area_widened = False area_widened = False
if len(listings) < 5: if len(listings) < 5:
@ -977,6 +1032,8 @@ def _fetch_analogs(
area_tolerance: float = AREA_TOLERANCE, area_tolerance: float = AREA_TOLERANCE,
year_built: int | None = None, house_type: str | None = None, year_built: int | None = None, house_type: str | None = None,
total_floors: int | None = None, total_floors: int | None = None,
cohort_year_min: int | None = None, # NEW: lower bound year_built inclusive
cohort_year_max: int | None = None, # NEW: upper bound year_built inclusive
# TODO: когда listings получит колонку house_id_fk — добавить ext_house_id JOIN для Tier S. # TODO: когда listings получит колонку house_id_fk — добавить ext_house_id JOIN для Tier S.
) -> tuple[list[dict[str, Any]], bool, str]: ) -> tuple[list[dict[str, Any]], bool, str]:
"""SELECT аналогов — трёхуровневый house-match (S → H → W). """SELECT аналогов — трёхуровневый house-match (S → H → W).
@ -999,6 +1056,11 @@ def _fetch_analogs(
3. Оставшиеся слоты заполняются из остальных кандидатов по relevance. 3. Оставшиеся слоты заполняются из остальных кандидатов по relevance.
4. Итоговый список отсортирован по relevance, LIMIT 50. 4. Итоговый список отсортирован по relevance, LIMIT 50.
Когортный фильтр (PR 9): если переданы cohort_year_min/max добавляется
hard-filter WHERE year_built BETWEEN min AND max OR year_built IS NULL.
NULL допускается чтобы не отсеивать листинги с неизвестным годом
(типично для Avito anonymous-address объявлений).
Returns: Returns:
(list_of_listings_as_dicts, fallback_radius_used_flag, tier) (list_of_listings_as_dicts, fallback_radius_used_flag, tier)
tier: 'S' | 'H' | 'W' tier: 'S' | 'H' | 'W'
@ -1216,6 +1278,16 @@ def _fetch_analogs(
AND is_active = true AND is_active = true
AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND price_rub > 0 AND price_rub > 0
-- Когортный фильтр (PR 9): отсеивает разные эпохи застройки
-- (хрущёвка vs новостройка). Если cohort_year_min IS NULL
-- фильтр прозрачен. CAST обязателен psycopg3 prepared statement
-- не выводит тип $N при IS NULL в predicate (см. PR #518 fix).
AND (
CAST(:cohort_year_min AS integer) IS NULL
OR year_built IS NULL
OR year_built BETWEEN CAST(:cohort_year_min AS integer)
AND CAST(:cohort_year_max AS integer)
)
-- 2026-05-23: Avito coords теперь real (PR #487 убрал jitter после -- 2026-05-23: Avito coords теперь real (PR #487 убрал jitter после
-- C-5 audit). Listings с NULL coords отфильтруются через ST_DWithin -- C-5 audit). Listings с NULL coords отфильтруются через ST_DWithin
-- (geom IS NULL не matches). geocode-missing-listings backfill -- (geom IS NULL не matches). geocode-missing-listings backfill
@ -1246,6 +1318,8 @@ def _fetch_analogs(
"target_year": year_built, "target_year": year_built,
"target_house_type": house_type, "target_house_type": house_type,
"max_per_addr": MAX_ANALOGS_PER_ADDRESS, "max_per_addr": MAX_ANALOGS_PER_ADDRESS,
"cohort_year_min": cohort_year_min, # NEW
"cohort_year_max": cohort_year_max, # NEW
}, },
).mappings().all() ).mappings().all()