gendesign/data/sql/120_fix_mv_layout_velocity_weighted_avg.sql
Light1YT 1f30e3d234 fix(mv): weighted AVG в mv_layout_velocity (#295)
naive AVG() over objective_corpus_room_month rows gives equal weight to
zero-deal months, diluting per-project averages by 2-10x. Projects with
sparse deal history (e.g. 5 active months out of 24) were shown avg_area
≈27m² instead of the correct ≈81m² for 3-room flats.

Replace both aggregates with SUM(val*cnt)/NULLIF(SUM(cnt),0) so only
months with actual deals contribute weight. NULLIF prevents division-by-zero
and returns NULL for all-zero projects instead of a misleading 0.

Prod check (Vitamin-квартал на Титова, last 24 mo): studio 26m²/176k,
1-rm 37m²/174k, 2-rm 56m²/135k, 3-rm 81m²/136k — matches expected ranges.
2026-05-28 18:23:55 +05:00

92 lines
4.5 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 120_fix_mv_layout_velocity_weighted_avg.sql
-- Fix #295 — mv_layout_velocity used naive AVG() over pre-aggregated per-month rows.
-- objective_corpus_room_month has rows even for months with 0 deals; those zero rows
-- dragged the average down 2-10x depending on how sparse the project's deal history is.
-- (e.g. a project with 1 deal in 1 month out of 24 reported months: AVG divided by 24,
-- weighted AVG divides by 1 → correct value.)
--
-- Fix: replace AVG() with count-weighted formula
-- SUM(val * deals_total_count) / NULLIF(SUM(deals_total_count), 0)
-- applied to both avg_area_m2 and avg_price_thousand_rub_per_m2.
--
-- Prod verification (2026-05-28, Vitamin-квартал на Титова, last 24 mo):
-- room | buggy_area | wtd_area | buggy_price | wtd_price
-- studio| 19.11 | 26.03 | 129.83 | 175.83
-- 1-rm | 31.72 | 37.15 | 145.68 | 174.21
-- 2-rm | 41.44 | 55.89 | 98.52 | 134.97
-- 3-rm | 27.14 | 81.43 | 45.40 | 136.20
-- Weighted values match expected (studio ≈28m², 1-rm ≈38m², 2-rm ≈57m², 3-rm ≈81m²,
-- prices 110-180k ₽/m²). Sparse 3-rm (5 deals / 15 months) had 3x undercount.
--
-- All indexes recreated identically to 94_mv_layout_velocity.sql:
-- mv_layout_velocity_pk (UNIQUE on obj_id, room_bucket) — required for REFRESH CONCURRENTLY
-- mv_layout_velocity_obj_idx (btree on obj_id)
--
-- CASCADE check (2026-05-28): no dependent views or tables found → no extra DDL needed.
--
-- Deploy: auto-applied by deploy.yml via _schema_migrations tracking.
-- After apply: REFRESH MATERIALIZED VIEW CONCURRENTLY mv_layout_velocity is safe
-- (MV is populated WITH DATA in this migration).
--
-- WARN: Re-applying this file will DROP + recreate the MV (CASCADE-safe as no dependents),
-- but will cause a brief data gap. Normally prevented by _schema_migrations tracking.
BEGIN;
DROP MATERIALIZED VIEW IF EXISTS mv_layout_velocity CASCADE;
CREATE MATERIALIZED VIEW mv_layout_velocity AS
WITH last24mo AS (
SELECT
ocm.project_name,
CASE
WHEN ocm.room_bucket = 'студия' THEN 'studio'
ELSE ocm.room_bucket
END AS room_bucket,
ocm.deals_total_count,
ocm.deals_total_avg_area_m2,
ocm.deals_total_avg_price_thousand_rub_per_m2,
ocm.deals_total_vol_m2,
ocm.report_month
FROM objective_corpus_room_month ocm
WHERE ocm.report_month >= (NOW() - INTERVAL '24 months')::date
)
SELECT
cm.domrf_obj_id AS obj_id,
l.room_bucket,
SUM(l.deals_total_count)::int AS total_deals_24mo,
-- Count-weighted average area: avoids zero-deal months dragging value down
(SUM(l.deals_total_avg_area_m2 * l.deals_total_count)
/ NULLIF(SUM(l.deals_total_count), 0))::numeric(10, 2) AS avg_area_m2,
-- Count-weighted average price: same reasoning
(SUM(l.deals_total_avg_price_thousand_rub_per_m2 * l.deals_total_count)
/ NULLIF(SUM(l.deals_total_count), 0))::numeric(12, 2) AS avg_price_thousand_rub_per_m2,
SUM(l.deals_total_vol_m2)::numeric(12, 2) AS total_vol_m2,
MIN(l.report_month) AS window_start,
MAX(l.report_month) AS window_end,
COUNT(DISTINCT l.report_month)::int AS months_with_data
FROM last24mo l
JOIN objective_complex_mapping cm
ON cm.objective_complex_name = l.project_name
WHERE l.room_bucket IS NOT NULL
AND cm.domrf_obj_id IS NOT NULL
AND cm.objective_group = 'Екатеринбург' -- защита от cross-region Cartesian при future multi-city
GROUP BY cm.domrf_obj_id, l.room_bucket
WITH DATA;
-- UNIQUE index required for REFRESH CONCURRENTLY (periodic refreshes via layout_velocity_refresh.py)
CREATE UNIQUE INDEX mv_layout_velocity_pk
ON mv_layout_velocity (obj_id, room_bucket);
-- Lookup index used by /best-layouts endpoint
CREATE INDEX mv_layout_velocity_obj_idx
ON mv_layout_velocity (obj_id);
COMMENT ON MATERIALIZED VIEW mv_layout_velocity IS
'Per-(obj_id, room_bucket) deals aggregation за last 24 months. '
'Source: objective_corpus_room_month × objective_complex_mapping (EKB only). '
'avg_area_m2 and avg_price_thousand_rub_per_m2 are COUNT-WEIGHTED to avoid '
'zero-deal months skewing the average (fix #295). '
'Refresh via layout_velocity_refresh.py (concurrently=True — MV is pre-populated).';
COMMIT;