coef = 0.95 + score/100*0.10 не был связан с ценой и не участвовал в расчёте estimator'а вовсе (0 упоминаний), но интерфейс рисовал «база -> результат», обещая влияние на цену. Замеры на боевой БД: по 1500 адресам ЕКБ 67% попадают в -1%..+1%, весь город укладывается в размах 1.10x; медиана руб/м2 по бакетам коэффициента плоская и немонотонная (бакет -4% дороже бакета +3%). Для сравнения, расстояние до центра даёт монотонный градиент с размахом 2.70x (93 677 -> 249 686 руб/м2 по 31 тыс. лотов). Новый показатель — отклонение медианы руб/м2 сопоставимых активных листингов в радиусе от медианы по городу (percentile_cont, лестница радиусов до набора выборки). Честная деградация вне ЕКБ и при малой выборке вместо молчаливого вырождения в 0.95. В цену по-прежнему не идёт — аналоги берутся из того же района, локация в базовой цене уже учтена. Заодно две причины потери POI: 1. Overpass-фильтр метро ловил только station=subway, без railway=station+subway=yes. 2. v_tradein_osm_poi_ekb резала всё, что не редактировали в OSM 2 года. Проверено на проде: из 5133 точек доходило 2787 (-46%), причём смещённо — парки -84%, больницы -80%, магазины -83%, остановки 0%. Из 9 станций метро терялись 4, включая Площадь 1905 года. Станция не перестаёт существовать оттого, что её тег два года не трогали; Site Finder использует эту дату как мягкий сигнал уверенности, а не как фильтр существования.
436 lines
21 KiB
Python
436 lines
21 KiB
Python
"""Location index for trade-in estimates — replaces the broken `location_coef` (LocationDrawer).
|
||
|
||
ИСТОРИЯ / ПОЧЕМУ ПЕРЕПИСАНО:
|
||
Старый `location_coef.py` считал `coef = 0.95 + (poi_weighted_score/100) * 0.10` — диапазон
|
||
жёстко зажат в [0.95, 1.05], без какой-либо калибровки на реальных ценах. Аудит на боевой БД
|
||
(1500 адресов ЕКБ + 4000 активных лотов) показал:
|
||
- 67% адресов попадали в −1%…+1%, у ~25% coef был РОВНО 1.0 (score=50) — почти
|
||
неинформативно, весь город умещался в −4%…+5%;
|
||
- связи с ценой не было вообще: медиана ₽/м² по бакетам coef плоская и НЕ монотонна
|
||
(бакет −4% дороже бакета +3%).
|
||
Для сравнения, расстояние до центра ЕКБ на 31 тыс. лотов даёт чистый монотонный градиент
|
||
(0-2км 249 686 ₽/м² → 12-13км 93 677 ₽/м², разброс 2.7×) — сигнал в данных есть, просто
|
||
POI-score его не улавливал (POI ranking ≠ цена).
|
||
|
||
НОВЫЙ ПОКАЗАТЕЛЬ (location index):
|
||
location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки −
|
||
медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100
|
||
|
||
Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую
|
||
("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5) —
|
||
медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат
|
||
искусственно — если район реально на 40% дороже, так и покажет.
|
||
|
||
ЧЕСТНАЯ ДЕГРАДАЦИЯ (см. LocationIndexResult.status):
|
||
- "out_of_coverage" — точка вне гео-охвата продукта (bbox Екатеринбурга). НЕ 0%, НЕ
|
||
fallback-число — прочерк на фронте.
|
||
- "insufficient_data" — даже на максимальном радиусе выборки < MIN_SAMPLE_SIZE сопоставимых
|
||
активных листингов. Тоже прочерк, а не шум по 3 объявлениям.
|
||
- "ok" — index надёжен.
|
||
|
||
В ЦЕНУ НЕ ИДЁТ: estimator.py про этот модуль не знает и не должен знать — аналоги уже берутся
|
||
из того же района (локация учтена в базовой цене через сам подбор сопоставимых объектов),
|
||
повторное умножение на локационный индекс было бы двойным учётом одного и того же эффекта.
|
||
|
||
POI («что рядом» — школа/метро/остановка) сохранены как ОТДЕЛЬНАЯ качественная справка
|
||
(`nearby_poi`, ранжирование как раньше в location_coef.py), но больше не участвуют в числовом
|
||
показателе.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import text
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── Гео-охват продукта: только Екатеринбург ──────────────────────────────────
|
||
# Тот же bbox, что EKB_BBOX в backend/app/services/site_finder/poi_loader.py (main
|
||
# gendesign backend, Overpass-загрузчик osm_poi_ekb) и что использовался при аудите
|
||
# (1500 адресов / 4000 активных лотов / 2787 POI, все — "по Екатеринбургу"). tradein-mvp —
|
||
# отдельный деплой/venv от backend/, поэтому константа продублирована, не импортирована;
|
||
# при изменении bbox в одном месте — проверить и второе (комментарий в обе стороны).
|
||
_EKB_BBOX_SOUTH = 56.70
|
||
_EKB_BBOX_WEST = 60.50
|
||
_EKB_BBOX_NORTH = 56.95
|
||
_EKB_BBOX_EAST = 60.75
|
||
|
||
|
||
def _in_ekb_bbox(lat: float, lon: float) -> bool:
|
||
"""True если точка внутри гео-охвата продукта (Екатеринбург)."""
|
||
return _EKB_BBOX_SOUTH <= lat <= _EKB_BBOX_NORTH and _EKB_BBOX_WEST <= lon <= _EKB_BBOX_EAST
|
||
|
||
|
||
# ── Калибровочные константы (радиус / минимальная выборка) ──────────────────
|
||
#
|
||
# Плотность-прикидка для обоснования порядка величины (НЕ подтверждено живым запросом к
|
||
# прод-БД в этом изменении — см. PR description "непроверенное"): ЕКБ-аудит насчитал ~4000
|
||
# активных лотов в bbox площадью ~ 27.8км (0.25° широты) × 15.3км (0.25° долготы на широте
|
||
# 56.8°) ≈ 425 км² → плотность ~9.4 лота/км². Круг радиусом 800м имеет площадь ~2.01 км² →
|
||
# ожидаемо ~19 лотов при равномерной плотности — близко к MIN_SAMPLE_SIZE=20, т.е. стартовый
|
||
# радиус разумен для "средней" точки. Плотность в городе крайне неравномерна (центр много
|
||
# гуще окраин) — поэтому лестница радиусов расширяется, а не фиксированный радиус.
|
||
RADIUS_LADDER_M: tuple[int, ...] = (800, 1500, 2500)
|
||
|
||
# Ниже этого числа сопоставимых активных листингов медиана — шум, не показатель.
|
||
# Порог не откалиброван статистически (например через доверительный интервал медианы) —
|
||
# первая рабочая оценка для MVP. TODO: перепроверить на реальном распределении выборок по
|
||
# районам ЕКБ (см. "непроверенное" в отчёте задачи).
|
||
MIN_SAMPLE_SIZE = 20
|
||
|
||
# Санитарные (НЕ бизнес-калибровочные) границы ₽/м² — отсекают заведомо битые скрейп-строки
|
||
# (парсинг ошибся на порядок и т.п.), не сужают реальный рынок ЕКБ (там диапазон примерно
|
||
# 40-400 тыс₽/м², с большим запасом по краям).
|
||
_PRICE_PER_M2_SANITY_MIN = 20_000
|
||
_PRICE_PER_M2_SANITY_MAX = 1_000_000
|
||
|
||
DEFAULT_POI_RADIUS_M = 1200 # как в старом location_coef.py — подобран для МКД
|
||
DEFAULT_POI_TOP_N = 7
|
||
|
||
# Веса по категории POI — те же, что были в location_coef.py (ranking "что рядом",
|
||
# больше НЕ конвертируются в число, влияющее на индекс).
|
||
CATEGORY_WEIGHTS: dict[str, float] = {
|
||
"metro_stop": 6.0,
|
||
"school": 5.0,
|
||
"kindergarten": 4.5,
|
||
"hospital": 4.0,
|
||
"shop_mall": 4.0,
|
||
"shop_supermarket": 3.5,
|
||
"bus_stop": 4.5,
|
||
"park": 3.5,
|
||
"pharmacy": 2.5,
|
||
"tram_stop": 2.0,
|
||
"shop_small": 2.0,
|
||
"default": 1.0,
|
||
}
|
||
|
||
|
||
def _category_weight(category: str | None) -> float:
|
||
"""Вернуть вес категории. Если не знаем — default."""
|
||
return CATEGORY_WEIGHTS.get(category or "default", CATEGORY_WEIGHTS["default"])
|
||
|
||
|
||
class NearbyPoi(BaseModel):
|
||
"""Один пункт «что рядом» — качественная справка, НЕ участвует в location_index_pct."""
|
||
|
||
poi_type: str
|
||
name: str | None
|
||
distance_m: float
|
||
|
||
|
||
class LocationIndexResult(BaseModel):
|
||
"""Результат compute_location_index — потребляется эндпоинтом location-index."""
|
||
|
||
status: str # "ok" | "out_of_coverage" | "insufficient_data"
|
||
location_index_pct: float | None
|
||
local_median_price_per_m2: int | None
|
||
city_median_price_per_m2: int | None
|
||
sample_size: int
|
||
radius_m: int
|
||
nearby_poi: list[NearbyPoi]
|
||
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена)
|
||
|
||
|
||
def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
||
"""% отклонения локальной медианы от городской.
|
||
|
||
Округление до 1 знака — не создаёт ложной точности (исходные данные — шумные скрейп-цены).
|
||
"""
|
||
if city_median_ppm2 <= 0:
|
||
# Защита от деления на ноль при вырожденной городской выборке — не должно
|
||
# случаться в проде (там ~4000 активных лотов), только в пустой dev-БД.
|
||
return 0.0
|
||
return round((local_median_ppm2 - city_median_ppm2) / city_median_ppm2 * 100.0, 1)
|
||
|
||
|
||
# ── SQL: медиана ₽/м² сопоставимых активных листингов ────────────────────────
|
||
#
|
||
# percentile_cont(0.5) — тот же идиом, что уже используется в estimator.py для медианных
|
||
# ₽/м² трендов (_fetch_price_trend) — устойчив к выбросам В ОТЛИЧИЕ от AVG/min/max: единичный
|
||
# аномально дорогой/дешёвый лот не сдвигает медиану заметно.
|
||
#
|
||
# geo_precision IS DISTINCT FROM 'city' — тот же фильтр, что в estimator.py (#769 Part E):
|
||
# исключает листинги с геокодом до центра города (city-centroid fallback без номера дома),
|
||
# которые иначе "подмешивались" бы в любой радиус вокруг центра.
|
||
#
|
||
# price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк
|
||
# (см. _PRICE_PER_M2_SANITY_MIN/MAX выше).
|
||
#
|
||
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
|
||
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
|
||
# явно ограничил владелец продукта.
|
||
_MEDIAN_PPM2_LOCAL_SQL = text(
|
||
"""
|
||
SELECT
|
||
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS double precision)
|
||
AS median_ppm2,
|
||
count(*) AS n
|
||
FROM listings
|
||
WHERE is_active = true
|
||
AND price_per_m2 IS NOT NULL
|
||
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
||
AND (geo_precision IS DISTINCT FROM 'city')
|
||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||
AND CAST(:bbox_north AS double precision)
|
||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||
AND CAST(:bbox_east AS double precision)
|
||
AND ST_DWithin(
|
||
geom::geography,
|
||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
|
||
CAST(:radius_m AS double precision)
|
||
)
|
||
"""
|
||
)
|
||
|
||
_MEDIAN_PPM2_CITYWIDE_SQL = text(
|
||
"""
|
||
SELECT
|
||
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS double precision)
|
||
AS median_ppm2,
|
||
count(*) AS n
|
||
FROM listings
|
||
WHERE is_active = true
|
||
AND price_per_m2 IS NOT NULL
|
||
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
||
AND (geo_precision IS DISTINCT FROM 'city')
|
||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||
AND CAST(:bbox_north AS double precision)
|
||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||
AND CAST(:bbox_east AS double precision)
|
||
"""
|
||
)
|
||
|
||
_NEAREST_POI_SQL = text(
|
||
"""
|
||
SELECT
|
||
p.name,
|
||
p.category,
|
||
CAST(
|
||
ST_Distance(
|
||
p.geom::geography,
|
||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography
|
||
) AS double precision
|
||
) AS distance_m
|
||
FROM osm_poi_ekb_local p
|
||
WHERE p.geom IS NOT NULL
|
||
AND ST_DWithin(
|
||
p.geom::geography,
|
||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
|
||
CAST(:radius_m AS double precision)
|
||
)
|
||
ORDER BY distance_m ASC
|
||
LIMIT :limit
|
||
"""
|
||
)
|
||
|
||
|
||
def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[float | None, int]:
|
||
row = (
|
||
db.execute(
|
||
_MEDIAN_PPM2_LOCAL_SQL,
|
||
{
|
||
"lat": lat,
|
||
"lon": lon,
|
||
"radius_m": radius_m,
|
||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||
"bbox_south": _EKB_BBOX_SOUTH,
|
||
"bbox_north": _EKB_BBOX_NORTH,
|
||
"bbox_west": _EKB_BBOX_WEST,
|
||
"bbox_east": _EKB_BBOX_EAST,
|
||
},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if row is None:
|
||
return None, 0
|
||
median = row["median_ppm2"]
|
||
return (float(median) if median is not None else None), int(row["n"] or 0)
|
||
|
||
|
||
def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]:
|
||
row = (
|
||
db.execute(
|
||
_MEDIAN_PPM2_CITYWIDE_SQL,
|
||
{
|
||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||
"bbox_south": _EKB_BBOX_SOUTH,
|
||
"bbox_north": _EKB_BBOX_NORTH,
|
||
"bbox_west": _EKB_BBOX_WEST,
|
||
"bbox_east": _EKB_BBOX_EAST,
|
||
},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if row is None:
|
||
return None, 0
|
||
median = row["median_ppm2"]
|
||
return (float(median) if median is not None else None), int(row["n"] or 0)
|
||
|
||
|
||
def _fetch_nearby_poi(
|
||
db: Any, lat: float, lon: float, radius_m: int, top_n: int
|
||
) -> tuple[list[NearbyPoi], str]:
|
||
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
|
||
|
||
Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении)
|
||
→ ([], "unavailable") вместо 500 или сфабрикованного списка.
|
||
"""
|
||
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
|
||
if total == 0:
|
||
logger.warning(
|
||
"location_index: osm_poi_ekb_local is empty (refresh job not yet run on this "
|
||
"environment) — nearby_poi unavailable, no fabricated factors"
|
||
)
|
||
return [], "unavailable"
|
||
|
||
rows = (
|
||
db.execute(
|
||
_NEAREST_POI_SQL,
|
||
{"lat": lat, "lon": lon, "radius_m": radius_m, "limit": top_n * 10},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
ranked: list[tuple[float, NearbyPoi]] = []
|
||
for row in rows:
|
||
distance_m = float(row["distance_m"])
|
||
category = row["category"] or "default"
|
||
weight = (1.0 / (distance_m + 100.0)) * _category_weight(category)
|
||
ranked.append(
|
||
(
|
||
weight,
|
||
NearbyPoi(poi_type=category, name=row["name"], distance_m=round(distance_m, 1)),
|
||
)
|
||
)
|
||
|
||
ranked.sort(key=lambda pair: pair[0], reverse=True)
|
||
return [poi for _weight, poi in ranked[:top_n]], "ok"
|
||
|
||
|
||
def compute_location_index(
|
||
db: Any,
|
||
lat: float,
|
||
lon: float,
|
||
*,
|
||
radius_m: int | None = None,
|
||
poi_radius_m: int = DEFAULT_POI_RADIUS_M,
|
||
poi_top_n: int = DEFAULT_POI_TOP_N,
|
||
) -> LocationIndexResult:
|
||
"""Посчитать location index для координат (lat, lon).
|
||
|
||
location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки −
|
||
медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100. Радиус — лестница RADIUS_LADDER_M
|
||
(расширяется, пока выборка не наберёт MIN_SAMPLE_SIZE), если явный radius_m не передан
|
||
(тогда используется РОВНО он, без расширения — для отладки/тестов).
|
||
|
||
Args:
|
||
db: SQLAlchemy Session.
|
||
lat: широта целевой точки.
|
||
lon: долгота целевой точки.
|
||
radius_m: явный радиус в метрах — если задан, лестница не используется.
|
||
poi_radius_m: радиус для качественного списка «что рядом» (независим от индекса).
|
||
poi_top_n: сколько POI показать в «что рядом».
|
||
|
||
Returns:
|
||
LocationIndexResult со status:
|
||
- "out_of_coverage" — точка вне bbox Екатеринбурга, ничего не считаем.
|
||
- "insufficient_data" — даже на максимальном радиусе сопоставимых листингов
|
||
меньше MIN_SAMPLE_SIZE (или городская выборка-эталон сама вырождена).
|
||
- "ok" — location_index_pct надёжен.
|
||
"""
|
||
if not _in_ekb_bbox(lat, lon):
|
||
logger.info(
|
||
"location_index: lat=%.5f lon=%.5f outside EKB coverage bbox — out_of_coverage",
|
||
lat,
|
||
lon,
|
||
)
|
||
return LocationIndexResult(
|
||
status="out_of_coverage",
|
||
location_index_pct=None,
|
||
local_median_price_per_m2=None,
|
||
city_median_price_per_m2=None,
|
||
sample_size=0,
|
||
radius_m=radius_m or RADIUS_LADDER_M[0],
|
||
nearby_poi=[],
|
||
poi_status="unavailable",
|
||
)
|
||
|
||
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n)
|
||
|
||
city_median, city_n = _citywide_median_ppm2(db)
|
||
if city_median is None or city_n < MIN_SAMPLE_SIZE:
|
||
logger.warning(
|
||
"location_index: citywide reference sample too small (n=%d) — insufficient_data",
|
||
city_n,
|
||
)
|
||
return LocationIndexResult(
|
||
status="insufficient_data",
|
||
location_index_pct=None,
|
||
local_median_price_per_m2=None,
|
||
city_median_price_per_m2=(round(city_median) if city_median is not None else None),
|
||
sample_size=city_n,
|
||
radius_m=radius_m or RADIUS_LADDER_M[-1],
|
||
nearby_poi=nearby_poi,
|
||
poi_status=poi_status,
|
||
)
|
||
|
||
radii = [radius_m] if radius_m is not None else list(RADIUS_LADDER_M)
|
||
local_median: float | None = None
|
||
sample_size = 0
|
||
used_radius = radii[-1]
|
||
for r in radii:
|
||
local_median, sample_size = _local_median_ppm2(db, lat, lon, r)
|
||
used_radius = r
|
||
if sample_size >= MIN_SAMPLE_SIZE:
|
||
break
|
||
|
||
if local_median is None or sample_size < MIN_SAMPLE_SIZE:
|
||
logger.info(
|
||
"location_index: lat=%.5f lon=%.5f sample=%d < MIN_SAMPLE_SIZE=%d up to "
|
||
"radius=%dm — insufficient_data",
|
||
lat,
|
||
lon,
|
||
sample_size,
|
||
MIN_SAMPLE_SIZE,
|
||
used_radius,
|
||
)
|
||
return LocationIndexResult(
|
||
status="insufficient_data",
|
||
location_index_pct=None,
|
||
local_median_price_per_m2=None,
|
||
city_median_price_per_m2=round(city_median),
|
||
sample_size=sample_size,
|
||
radius_m=used_radius,
|
||
nearby_poi=nearby_poi,
|
||
poi_status=poi_status,
|
||
)
|
||
|
||
pct = _pct_deviation(local_median, city_median)
|
||
logger.debug(
|
||
"location_index: lat=%.5f lon=%.5f radius=%dm n=%d local=%d city=%d pct=%.1f",
|
||
lat,
|
||
lon,
|
||
used_radius,
|
||
sample_size,
|
||
round(local_median),
|
||
round(city_median),
|
||
pct,
|
||
)
|
||
return LocationIndexResult(
|
||
status="ok",
|
||
location_index_pct=pct,
|
||
local_median_price_per_m2=round(local_median),
|
||
city_median_price_per_m2=round(city_median),
|
||
sample_size=sample_size,
|
||
radius_m=used_radius,
|
||
nearby_poi=nearby_poi,
|
||
poi_status=poi_status,
|
||
)
|