audit-fix: 27mo окно, MODE bucket, индексы recommend_mix
B1: дефолт months_window 12→24, расширение fallback 24→27 (max архив). 2024Q1+Q2 теперь реально влияют на recommend_mix (raw 784→3815 сделок для Академического, ×4.9). B2: per-bucket elasticity через MODE по семантике (is_studio+rooms), а не PERCENTILE_CONT(0.5) по total_area. ЖК с mix studios+3к больше не попадают в bucket "1-к" как среднее. Для Академического: 1-к получает 208 точек регрессии (vs 118), slope -1.17. B3: share_window_months и elasticity_window_months в API scope — прозрачность для UI tooltip про рассинхрон окон (24mo rosreestr vs 36mo sale_graph). B4: cad_buildings.floors при INSERT больше НЕ приводится к INT — NSPD отдаёт диапазоны "1-2", "2-3" которые теряются как NULL. Хранится как TEXT, аналитика делает CAST через regex. R1: data/sql/66_indexes_recommend.sql — 4 индекса для горячего пути recommend_mix (sale_graph composite, kn_objects partial, kn_flats partial, cad_buildings partial). Применены в проде через MCP. R3: ON CONFLICT в insert_buildings теперь обновляет ВСЕ изменяемые поля (purpose, address, floors, geom, registration_date) — раньше при re-scrape апдейтились только cost/area/year, остальное игнорилось.
This commit is contained in:
parent
78b8d7aa00
commit
e22f86bc06
7 changed files with 116 additions and 30 deletions
|
|
@ -1489,21 +1489,31 @@ def _elasticity_per_bucket_coef(
|
|||
{where_class}
|
||||
),
|
||||
obj_bucket AS (
|
||||
-- Доминирующий bucket каждого ЖК = mode total_area среди
|
||||
-- его flats. Если flats пусты — ЖК не учитывается.
|
||||
-- Доминирующий bucket каждого ЖК = MODE по семантике
|
||||
-- квартиры (is_studio + rooms). Раньше брали медиану
|
||||
-- total_area — ЖК с mix studios+3к попадали в bucket
|
||||
-- "1-к" как среднее, что искажало эластичность. MODE
|
||||
-- по типу квартиры сохраняет семантику: ЖК с mix даёт
|
||||
-- bucket = самый массовый тип.
|
||||
--
|
||||
-- Fallback на total_area для строк с NULL rooms (быват
|
||||
-- в kn-API для нестандартных квартир).
|
||||
SELECT
|
||||
f.obj_id,
|
||||
CASE
|
||||
WHEN PERCENTILE_CONT(0.5) WITHIN GROUP
|
||||
(ORDER BY f.total_area) < 30 THEN '1-Студия'
|
||||
WHEN PERCENTILE_CONT(0.5) WITHIN GROUP
|
||||
(ORDER BY f.total_area) < 45 THEN '2-1-к'
|
||||
WHEN PERCENTILE_CONT(0.5) WITHIN GROUP
|
||||
(ORDER BY f.total_area) < 60 THEN '3-2-к'
|
||||
WHEN PERCENTILE_CONT(0.5) WITHIN GROUP
|
||||
(ORDER BY f.total_area) < 80 THEN '4-3-к'
|
||||
ELSE '5-80+ м²'
|
||||
END AS bucket
|
||||
MODE() WITHIN GROUP (ORDER BY (
|
||||
CASE
|
||||
WHEN f.is_studio = true THEN '1-Студия'
|
||||
WHEN f.rooms = 1 THEN '2-1-к'
|
||||
WHEN f.rooms = 2 THEN '3-2-к'
|
||||
WHEN f.rooms = 3 THEN '4-3-к'
|
||||
WHEN f.rooms >= 4 THEN '5-80+ м²'
|
||||
WHEN f.total_area < 30 THEN '1-Студия'
|
||||
WHEN f.total_area < 45 THEN '2-1-к'
|
||||
WHEN f.total_area < 60 THEN '3-2-к'
|
||||
WHEN f.total_area < 80 THEN '4-3-к'
|
||||
ELSE '5-80+ м²'
|
||||
END
|
||||
)) AS bucket
|
||||
FROM domrf_kn_flats f
|
||||
JOIN obj_pool p ON p.obj_id = f.obj_id
|
||||
WHERE f.total_area IS NOT NULL
|
||||
|
|
@ -1567,7 +1577,7 @@ def recommend_mix(
|
|||
district_name: str,
|
||||
area_total_m2: float | None = None,
|
||||
target_class: str | None = None,
|
||||
months_window: int = 12,
|
||||
months_window: int = 24,
|
||||
region_code: int = 66,
|
||||
price_factor: float = 1.0,
|
||||
target_months: int | None = None,
|
||||
|
|
@ -1686,17 +1696,19 @@ def recommend_mix(
|
|||
)
|
||||
|
||||
# 4) Bucket distribution from rosreestr_deals — city-wide, last N months.
|
||||
# Если в каком-либо бакете <30 сделок и окно < 24 мес, расширяем до 24 мес
|
||||
# для всех бакетов и проставляем warning. Это даёт более устойчивые медианы.
|
||||
# Дефолт 24 мес — после загрузки rosreestr 2024Q1+Q2 у нас 27 мес истории,
|
||||
# 24 мес даёт устойчивые per-bucket медианы для большинства региональных
|
||||
# ЖК-классов. Если хоть в одном бакете <30 сделок — расширяем до 27 мес
|
||||
# (мах доступного окна), это последний rampe перед deferring к global.
|
||||
bucket_rows = _bucket_distribution(db, region_code, months_window)
|
||||
effective_window = months_window
|
||||
if months_window < 24 and bucket_rows and any(int(r["deals"] or 0) < 30 for r in bucket_rows):
|
||||
bucket_rows_24 = _bucket_distribution(db, region_code, 24)
|
||||
if bucket_rows_24:
|
||||
bucket_rows = bucket_rows_24
|
||||
effective_window = 24
|
||||
if months_window < 27 and bucket_rows and any(int(r["deals"] or 0) < 30 for r in bucket_rows):
|
||||
bucket_rows_27 = _bucket_distribution(db, region_code, 27)
|
||||
if bucket_rows_27:
|
||||
bucket_rows = bucket_rows_27
|
||||
effective_window = 27
|
||||
warnings.append(
|
||||
f"Окно расширено до 24 мес: при {months_window} мес хотя бы один"
|
||||
f"Окно расширено до 27 мес: при {months_window} мес хотя бы один"
|
||||
" бакет имел <30 сделок — оценка была бы шумной"
|
||||
)
|
||||
total_deals = sum(int(r["deals"] or 0) for r in bucket_rows) or 1
|
||||
|
|
@ -2101,6 +2113,13 @@ def recommend_mix(
|
|||
"elasticity_source": elast["source"],
|
||||
"elasticity_weighted": (round(weighted_elasticity, 4) if total_units > 0 else None),
|
||||
"elasticity_per_bucket": elast_per_bucket,
|
||||
# Окна источников данных — для прозрачности и UI-tooltip:
|
||||
# share_window_months — окно по rosreestr_deals для bucket-shares
|
||||
# и market velocity (input months_window, может расшириться до 27).
|
||||
# elasticity_window_months — окно по domrf_kn_sale_graph (фиксировано
|
||||
# 36 мес — sale_graph есть с 2023г, шире окно даёт устойчивее регрессию).
|
||||
"share_window_months": effective_window,
|
||||
"elasticity_window_months": 36,
|
||||
"cadastre_median_per_m2": (
|
||||
round(cadastre["median_per_m2"], 0)
|
||||
if cadastre["median_per_m2"] is not None
|
||||
|
|
|
|||
|
|
@ -246,10 +246,17 @@ def insert_buildings(db: Session, qcad: str, features: list[dict], cad_prefix: s
|
|||
CAST(:raw_props AS jsonb)
|
||||
)
|
||||
ON CONFLICT (cad_num) DO UPDATE SET
|
||||
cost_value = EXCLUDED.cost_value,
|
||||
quarter_cad_num = EXCLUDED.quarter_cad_num,
|
||||
geom = EXCLUDED.geom,
|
||||
purpose = EXCLUDED.purpose,
|
||||
building_name = EXCLUDED.building_name,
|
||||
readable_address = EXCLUDED.readable_address,
|
||||
area = EXCLUDED.area,
|
||||
floors = EXCLUDED.floors,
|
||||
year_built = EXCLUDED.year_built,
|
||||
year_commisioning = EXCLUDED.year_commisioning,
|
||||
cost_value = EXCLUDED.cost_value,
|
||||
registration_date = EXCLUDED.registration_date,
|
||||
status = EXCLUDED.status,
|
||||
ownership_type = EXCLUDED.ownership_type,
|
||||
cultural_heritage = EXCLUDED.cultural_heritage,
|
||||
|
|
@ -270,7 +277,12 @@ def insert_buildings(db: Session, qcad: str, features: list[dict], cad_prefix: s
|
|||
"name": opts.get("building_name"),
|
||||
"addr": opts.get("readable_address"),
|
||||
"area": _to_num(opts.get("area")),
|
||||
"floors": _to_int(opts.get("floors")),
|
||||
# floors / underground_floors хранятся TEXT — NSPD отдаёт
|
||||
# диапазоны '1-2', '2-3'. _to_int съел бы их в NULL и потерял
|
||||
# данные. Аналитика делает CAST через regex '^[0-9]+$'.
|
||||
"floors": (
|
||||
str(opts["floors"]).strip() if opts.get("floors") not in (None, "") else None
|
||||
),
|
||||
"yb": _to_int(opts.get("year_built")),
|
||||
"yc": _to_int(opts.get("year_commisioning")),
|
||||
"cost": _to_num(opts.get("cost_value")),
|
||||
|
|
@ -278,7 +290,11 @@ def insert_buildings(db: Session, qcad: str, features: list[dict], cad_prefix: s
|
|||
"status": opts.get("status"),
|
||||
"ownership": opts.get("ownership_type"),
|
||||
"cultural": opts.get("cultural_heritage"),
|
||||
"underground": _to_int(opts.get("underground_floors")),
|
||||
"underground": (
|
||||
str(opts["underground_floors"]).strip()
|
||||
if opts.get("underground_floors") not in (None, "")
|
||||
else None
|
||||
),
|
||||
"build_rec_area": _to_num(opts.get("build_record_area")),
|
||||
"build_rec_type": opts.get("build_record_type"),
|
||||
"common_status": opts.get("common_data_status"),
|
||||
|
|
|
|||
49
data/sql/66_indexes_recommend.sql
Normal file
49
data/sql/66_indexes_recommend.sql
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
-- Индексы под горячий путь recommend_mix (analytics_queries.py:recommend_mix).
|
||||
--
|
||||
-- Без них типичный POST /analytics/recommend/mix делает 3-5 seq-scan'ов на
|
||||
-- 5М-строчных таблицах и тратит 1-2 сек. Цель — <500мс.
|
||||
--
|
||||
-- Идемпотентно через CREATE INDEX IF NOT EXISTS.
|
||||
-- CONCURRENTLY НЕ используется (psql сессия с несколькими операциями) —
|
||||
-- если будет блок-issue в проде, применять каждый CREATE отдельно.
|
||||
|
||||
-- 1) sale_graph: JOIN obj_id × type='apartments' × report_month
|
||||
-- Используется в _elasticity_coef и _elasticity_per_bucket_coef.
|
||||
-- Текущие индексы: (obj_id, snapshot_date), (report_month, type) — но не
|
||||
-- покрывают composite seek по obj_id + type + report_month range.
|
||||
CREATE INDEX IF NOT EXISTS sg_obj_type_month_idx
|
||||
ON domrf_kn_sale_graph (obj_id, type, report_month DESC);
|
||||
|
||||
-- 2) Active competitors lookup: (district_name, obj_class, site_status)
|
||||
-- Используется в _active_competitors_count, который вызывается до 3 раз
|
||||
-- (каскадный fallback district+class → district → region) на каждый запрос.
|
||||
-- Partial index по site_status='Строящиеся' — это то, что мы реально ищем.
|
||||
CREATE INDEX IF NOT EXISTS obj_district_class_active_idx
|
||||
ON domrf_kn_objects (district_name, obj_class, region_cd)
|
||||
WHERE site_status = 'Строящиеся';
|
||||
|
||||
-- 3) Per-bucket flats lookup: (obj_id, total_area)
|
||||
-- Используется в _elasticity_per_bucket_coef для группировки sale_graph
|
||||
-- по «доминирующему bucket» каждого ЖК. Partial index по area-диапазону
|
||||
-- который мы реально считаем (15..200 м²).
|
||||
CREATE INDEX IF NOT EXISTS flats_obj_area_idx
|
||||
ON domrf_kn_flats (obj_id, total_area)
|
||||
WHERE total_area BETWEEN 15 AND 200;
|
||||
|
||||
-- 4) Cadastre baseline: (cost_value, area) — partial для строений
|
||||
-- с заполненными ценой и площадью. Используется в _district_cadastre_baseline
|
||||
-- + view v_zk_rosreestr_velocity. Также без этого индекса при PERCENTILE_CONT
|
||||
-- по cost_value/area делается seq-scan на 10K строк × spatial-join.
|
||||
CREATE INDEX IF NOT EXISTS buildings_cost_area_idx
|
||||
ON cad_buildings (cost_value, area)
|
||||
WHERE cost_value IS NOT NULL AND area IS NOT NULL;
|
||||
|
||||
-- Комментарии для будущей навигации.
|
||||
COMMENT ON INDEX sg_obj_type_month_idx IS
|
||||
'Hot path: JOIN domrf_kn_sale_graph по obj_id+type для regression в _elasticity_*.';
|
||||
COMMENT ON INDEX obj_district_class_active_idx IS
|
||||
'Hot path: _active_competitors_count + recommend_mix scope queries (Partial: site_status=Строящиеся).';
|
||||
COMMENT ON INDEX flats_obj_area_idx IS
|
||||
'Hot path: per-bucket median total_area для _elasticity_per_bucket_coef.';
|
||||
COMMENT ON INDEX buildings_cost_area_idx IS
|
||||
'Hot path: cadastre median ₽/м² в _district_cadastre_baseline.';
|
||||
|
|
@ -19,7 +19,7 @@ const DEFAULT_INPUT: RecommendMixInput = {
|
|||
district_name: "",
|
||||
area_total_m2: null,
|
||||
target_class: null,
|
||||
months_window: 12,
|
||||
months_window: 24,
|
||||
price_factor: 1.0,
|
||||
target_months: null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useDistricts } from "@/lib/analytics-api";
|
|||
import type { RecommendClass, RecommendMixInput } from "@/types/analytics";
|
||||
|
||||
const CLASSES: RecommendClass[] = ["Comfort", "Comfort+", "Business", "Elite"];
|
||||
const MONTHS_OPTIONS = [12, 18, 24];
|
||||
const MONTHS_OPTIONS = [12, 18, 24, 27];
|
||||
|
||||
interface Props {
|
||||
value: RecommendMixInput;
|
||||
|
|
@ -131,8 +131,8 @@ export function RecommendForm({
|
|||
))}
|
||||
</select>
|
||||
<span style={hintStyle}>
|
||||
При <30 сделок в любом бакете окно автоматически расширяется до 24
|
||||
мес.
|
||||
При <30 сделок в любом бакете окно автоматически расширяется до 27
|
||||
мес (max доступный rosreestr-архив).
|
||||
</span>
|
||||
</label>
|
||||
|
||||
|
|
|
|||
|
|
@ -388,7 +388,7 @@ export function RecommendVelocityPanel({
|
|||
>
|
||||
Эластичность цена↔темп <strong>{elasticity}</strong> (
|
||||
{scope.elasticity_source === "regression"
|
||||
? `регрессия sale_graph: R²=${scope.elasticity_r2.toFixed(2)}, n=${scope.elasticity_n}`
|
||||
? `регрессия sale_graph за ${scope.elasticity_window_months} мес: R²=${scope.elasticity_r2.toFixed(2)}, n=${scope.elasticity_n}`
|
||||
: `по умолчанию — sale_graph недостаточно (n=${scope.elasticity_n})`}
|
||||
)
|
||||
{scope.elasticity_weighted != null &&
|
||||
|
|
|
|||
|
|
@ -285,6 +285,8 @@ export interface RecommendMixOutput {
|
|||
elasticity_source: "regression" | "fallback";
|
||||
elasticity_weighted: number | null;
|
||||
elasticity_per_bucket: ElasticityPerBucket;
|
||||
share_window_months: number;
|
||||
elasticity_window_months: number;
|
||||
cadastre_median_per_m2: number | null;
|
||||
cadastre_buildings_n: number;
|
||||
cadastre_vs_market_pct: number | null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue