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}
|
{where_class}
|
||||||
),
|
),
|
||||||
obj_bucket AS (
|
obj_bucket AS (
|
||||||
-- Доминирующий bucket каждого ЖК = mode total_area среди
|
-- Доминирующий bucket каждого ЖК = MODE по семантике
|
||||||
-- его flats. Если flats пусты — ЖК не учитывается.
|
-- квартиры (is_studio + rooms). Раньше брали медиану
|
||||||
|
-- total_area — ЖК с mix studios+3к попадали в bucket
|
||||||
|
-- "1-к" как среднее, что искажало эластичность. MODE
|
||||||
|
-- по типу квартиры сохраняет семантику: ЖК с mix даёт
|
||||||
|
-- bucket = самый массовый тип.
|
||||||
|
--
|
||||||
|
-- Fallback на total_area для строк с NULL rooms (быват
|
||||||
|
-- в kn-API для нестандартных квартир).
|
||||||
SELECT
|
SELECT
|
||||||
f.obj_id,
|
f.obj_id,
|
||||||
CASE
|
MODE() WITHIN GROUP (ORDER BY (
|
||||||
WHEN PERCENTILE_CONT(0.5) WITHIN GROUP
|
CASE
|
||||||
(ORDER BY f.total_area) < 30 THEN '1-Студия'
|
WHEN f.is_studio = true THEN '1-Студия'
|
||||||
WHEN PERCENTILE_CONT(0.5) WITHIN GROUP
|
WHEN f.rooms = 1 THEN '2-1-к'
|
||||||
(ORDER BY f.total_area) < 45 THEN '2-1-к'
|
WHEN f.rooms = 2 THEN '3-2-к'
|
||||||
WHEN PERCENTILE_CONT(0.5) WITHIN GROUP
|
WHEN f.rooms = 3 THEN '4-3-к'
|
||||||
(ORDER BY f.total_area) < 60 THEN '3-2-к'
|
WHEN f.rooms >= 4 THEN '5-80+ м²'
|
||||||
WHEN PERCENTILE_CONT(0.5) WITHIN GROUP
|
WHEN f.total_area < 30 THEN '1-Студия'
|
||||||
(ORDER BY f.total_area) < 80 THEN '4-3-к'
|
WHEN f.total_area < 45 THEN '2-1-к'
|
||||||
ELSE '5-80+ м²'
|
WHEN f.total_area < 60 THEN '3-2-к'
|
||||||
END AS bucket
|
WHEN f.total_area < 80 THEN '4-3-к'
|
||||||
|
ELSE '5-80+ м²'
|
||||||
|
END
|
||||||
|
)) AS bucket
|
||||||
FROM domrf_kn_flats f
|
FROM domrf_kn_flats f
|
||||||
JOIN obj_pool p ON p.obj_id = f.obj_id
|
JOIN obj_pool p ON p.obj_id = f.obj_id
|
||||||
WHERE f.total_area IS NOT NULL
|
WHERE f.total_area IS NOT NULL
|
||||||
|
|
@ -1567,7 +1577,7 @@ def recommend_mix(
|
||||||
district_name: str,
|
district_name: str,
|
||||||
area_total_m2: float | None = None,
|
area_total_m2: float | None = None,
|
||||||
target_class: str | None = None,
|
target_class: str | None = None,
|
||||||
months_window: int = 12,
|
months_window: int = 24,
|
||||||
region_code: int = 66,
|
region_code: int = 66,
|
||||||
price_factor: float = 1.0,
|
price_factor: float = 1.0,
|
||||||
target_months: int | None = None,
|
target_months: int | None = None,
|
||||||
|
|
@ -1686,17 +1696,19 @@ def recommend_mix(
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4) Bucket distribution from rosreestr_deals — city-wide, last N months.
|
# 4) Bucket distribution from rosreestr_deals — city-wide, last N months.
|
||||||
# Если в каком-либо бакете <30 сделок и окно < 24 мес, расширяем до 24 мес
|
# Дефолт 24 мес — после загрузки rosreestr 2024Q1+Q2 у нас 27 мес истории,
|
||||||
# для всех бакетов и проставляем warning. Это даёт более устойчивые медианы.
|
# 24 мес даёт устойчивые per-bucket медианы для большинства региональных
|
||||||
|
# ЖК-классов. Если хоть в одном бакете <30 сделок — расширяем до 27 мес
|
||||||
|
# (мах доступного окна), это последний rampe перед deferring к global.
|
||||||
bucket_rows = _bucket_distribution(db, region_code, months_window)
|
bucket_rows = _bucket_distribution(db, region_code, months_window)
|
||||||
effective_window = 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):
|
if months_window < 27 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)
|
bucket_rows_27 = _bucket_distribution(db, region_code, 27)
|
||||||
if bucket_rows_24:
|
if bucket_rows_27:
|
||||||
bucket_rows = bucket_rows_24
|
bucket_rows = bucket_rows_27
|
||||||
effective_window = 24
|
effective_window = 27
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"Окно расширено до 24 мес: при {months_window} мес хотя бы один"
|
f"Окно расширено до 27 мес: при {months_window} мес хотя бы один"
|
||||||
" бакет имел <30 сделок — оценка была бы шумной"
|
" бакет имел <30 сделок — оценка была бы шумной"
|
||||||
)
|
)
|
||||||
total_deals = sum(int(r["deals"] or 0) for r in bucket_rows) or 1
|
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_source": elast["source"],
|
||||||
"elasticity_weighted": (round(weighted_elasticity, 4) if total_units > 0 else None),
|
"elasticity_weighted": (round(weighted_elasticity, 4) if total_units > 0 else None),
|
||||||
"elasticity_per_bucket": elast_per_bucket,
|
"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": (
|
"cadastre_median_per_m2": (
|
||||||
round(cadastre["median_per_m2"], 0)
|
round(cadastre["median_per_m2"], 0)
|
||||||
if cadastre["median_per_m2"] is not None
|
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)
|
CAST(:raw_props AS jsonb)
|
||||||
)
|
)
|
||||||
ON CONFLICT (cad_num) DO UPDATE SET
|
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,
|
area = EXCLUDED.area,
|
||||||
|
floors = EXCLUDED.floors,
|
||||||
year_built = EXCLUDED.year_built,
|
year_built = EXCLUDED.year_built,
|
||||||
year_commisioning = EXCLUDED.year_commisioning,
|
year_commisioning = EXCLUDED.year_commisioning,
|
||||||
|
cost_value = EXCLUDED.cost_value,
|
||||||
|
registration_date = EXCLUDED.registration_date,
|
||||||
status = EXCLUDED.status,
|
status = EXCLUDED.status,
|
||||||
ownership_type = EXCLUDED.ownership_type,
|
ownership_type = EXCLUDED.ownership_type,
|
||||||
cultural_heritage = EXCLUDED.cultural_heritage,
|
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"),
|
"name": opts.get("building_name"),
|
||||||
"addr": opts.get("readable_address"),
|
"addr": opts.get("readable_address"),
|
||||||
"area": _to_num(opts.get("area")),
|
"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")),
|
"yb": _to_int(opts.get("year_built")),
|
||||||
"yc": _to_int(opts.get("year_commisioning")),
|
"yc": _to_int(opts.get("year_commisioning")),
|
||||||
"cost": _to_num(opts.get("cost_value")),
|
"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"),
|
"status": opts.get("status"),
|
||||||
"ownership": opts.get("ownership_type"),
|
"ownership": opts.get("ownership_type"),
|
||||||
"cultural": opts.get("cultural_heritage"),
|
"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_area": _to_num(opts.get("build_record_area")),
|
||||||
"build_rec_type": opts.get("build_record_type"),
|
"build_rec_type": opts.get("build_record_type"),
|
||||||
"common_status": opts.get("common_data_status"),
|
"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: "",
|
district_name: "",
|
||||||
area_total_m2: null,
|
area_total_m2: null,
|
||||||
target_class: null,
|
target_class: null,
|
||||||
months_window: 12,
|
months_window: 24,
|
||||||
price_factor: 1.0,
|
price_factor: 1.0,
|
||||||
target_months: null,
|
target_months: null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useDistricts } from "@/lib/analytics-api";
|
||||||
import type { RecommendClass, RecommendMixInput } from "@/types/analytics";
|
import type { RecommendClass, RecommendMixInput } from "@/types/analytics";
|
||||||
|
|
||||||
const CLASSES: RecommendClass[] = ["Comfort", "Comfort+", "Business", "Elite"];
|
const CLASSES: RecommendClass[] = ["Comfort", "Comfort+", "Business", "Elite"];
|
||||||
const MONTHS_OPTIONS = [12, 18, 24];
|
const MONTHS_OPTIONS = [12, 18, 24, 27];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: RecommendMixInput;
|
value: RecommendMixInput;
|
||||||
|
|
@ -131,8 +131,8 @@ export function RecommendForm({
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span style={hintStyle}>
|
<span style={hintStyle}>
|
||||||
При <30 сделок в любом бакете окно автоматически расширяется до 24
|
При <30 сделок в любом бакете окно автоматически расширяется до 27
|
||||||
мес.
|
мес (max доступный rosreestr-архив).
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -388,7 +388,7 @@ export function RecommendVelocityPanel({
|
||||||
>
|
>
|
||||||
Эластичность цена↔темп <strong>{elasticity}</strong> (
|
Эластичность цена↔темп <strong>{elasticity}</strong> (
|
||||||
{scope.elasticity_source === "regression"
|
{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})`}
|
: `по умолчанию — sale_graph недостаточно (n=${scope.elasticity_n})`}
|
||||||
)
|
)
|
||||||
{scope.elasticity_weighted != null &&
|
{scope.elasticity_weighted != null &&
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,8 @@ export interface RecommendMixOutput {
|
||||||
elasticity_source: "regression" | "fallback";
|
elasticity_source: "regression" | "fallback";
|
||||||
elasticity_weighted: number | null;
|
elasticity_weighted: number | null;
|
||||||
elasticity_per_bucket: ElasticityPerBucket;
|
elasticity_per_bucket: ElasticityPerBucket;
|
||||||
|
share_window_months: number;
|
||||||
|
elasticity_window_months: number;
|
||||||
cadastre_median_per_m2: number | null;
|
cadastre_median_per_m2: number | null;
|
||||||
cadastre_buildings_n: number;
|
cadastre_buildings_n: number;
|
||||||
cadastre_vs_market_pct: number | null;
|
cadastre_vs_market_pct: number | null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue