"""§9.4 сезонная (month-of-year) нормализация сырого месячного спроса перед регрессией. #979 (951-C, ТЗ §9.4), EPIC 7 «Чувствительность к ключевой ставке». DoD: УБРАТЬ сезонность календарного месяца (month-of-year) из СЫРОГО месячного ряда спроса ДО регрессии/прогноза §9.6/§9.8. Это ОТДЕЛЬНАЯ от `demand_normalization.py` вещь: там — rate-regime дисконт «бумного» темпа под смену ключевой ставки (β·Δrate); здесь — чисто КАЛЕНДАРНОЕ дессзонивание (январь систематически слабее июля и т.п.), не зависящее от ставки. Оба нужны и ортогональны: сначала снимаем календарный паттерн (этот модуль), потом — режимный дисконт (demand_normalization). РАЦИОНАЛ «не переносить sell-through при низкой эфф.ставке в будущее по рыночной» (scope #979): сырой месячный спрос несёт ДВА смешанных сигнала — (1) календарную сезонность (устойчивый внутригодовой паттерн: весенний/осенний всплеск, новогодний провал) и (2) режимный отклик на ставку/льготную ипотеку. Регрессия §9.6 (продажи ↔ ставка) ищет ВТОРОЕ; если не снять ПЕРВОЕ, сезонный пик, случайно совпавший с окном низкой ставки, читается как «эффект ставки» и завышает перенос sell-through вперёд. Дессзонивание изолирует календарный паттерн, чтобы регрессия видела режимный сигнал чище — а не тащила сезонно-раздутый низкоставочный темп в будущее наивно. МЕТОД — классическая МУЛЬТИПЛИКАТИВНАЯ month-of-year сезонная корректировка (stable-seasonal / ratio-to-mean, тот же дух, что seasonal-индексы X-11/decompose): 1. Группируем значения ряда по календарному месяцу m ∈ 1..12. 2. month_mean[m] = среднее наблюдений месяца m; overall_mean = среднее всех. 3. seasonal_factor[m] = month_mean[m] / overall_mean (мультипликативно: >1 — месяц систематически выше года, <1 — ниже). 4. Дессзонированное значение точки t = raw_t / seasonal_factor[month_of(t)]. Так среднее по каждому календарному месяцу выравнивается к общему — устойчивый внутригодовой паттерн снят, режимная/трендовая динамика сохранена. ПОЧЕМУ МУЛЬТИПЛИКАТИВНО (а не аддитивно): спрос на жильё растёт/падает в %-шкале (сезонная амплитуда пропорциональна уровню — в «жирный» год и пик, и провал крупнее в абсолюте). Это та же логлинейная/процентная шкала, на которой работает §9.6 (Y = Δln(units), x_pct = 100·(exp(β)−1)) — дессзонивание делением согласовано с ней. МИНИМАЛЬНЫЙ ПОРОГ ДАННЫХ (guard — graceful, без усиления шума на тонких данных): сезонный фактор для месяца m применяется ТОЛЬКО если его не на чем переоценить случайным шумом одного-двух наблюдений: • нужно ≥ _MIN_FULL_YEARS полных лет (по умолчанию 2) — считаем только по NON-ZERO месяцам (fill_month_grid ставит units=0 для «нет сделок», но 0 не несёт сезонного сигнала); иначе у каждого месяца < 2 наблюдений, «сезонность» неотличима от шума → ВСЕ факторы = 1.0 (ряд возвращается без изменений); • месяц без наблюдений → его фактор = 1.0 (нет базы для оценки); • overall_mean ≤ 0 (пустой/нулевой ряд) → ВСЕ факторы = 1.0 (нет масштаба, и защита от деления на ноль); • месяц со средним ≤ 0 (все наблюдения нули) → фактор = 1.0 (не раздуваем нули). Деградация всегда к НЕЙТРАЛИ (factor=1.0 = «не корректируем»), НИКОГДА не делим на ноль и НИКОГДА не усиливаем шум тонких данных. Дух market_metrics / rate_sensitivity. PURE, детерминированно, без БД, без LLM. numpy используется для среднего (уже dep). Возвращаем тот же shape входа с дессзонированными значениями + сами факторы (для explainability / следующего слоя нормализации). """ from __future__ import annotations import logging from dataclasses import dataclass, replace from datetime import date import numpy as np from app.services.forecasting.sales_series import SalesSeries logger = logging.getLogger(__name__) # ── Named-константы ─────────────────────────────────────────────────────────── # Нейтральный сезонный фактор: 1.0 = «месяц не корректируем» (деление на 1.0). _NEUTRAL_FACTOR: float = 1.0 # Минимум ПОЛНЫХ лет наблюдений, чтобы вообще оценивать сезонность. < 2 лет → у # каждого месяца < 2 точек, ratio-to-mean ловит случайный шум, а не устойчивый # внутригодовой паттерн → дессзонивание отключаем целиком (все факторы 1.0). 2 — # абсолютный минимум, при котором у месяца есть ≥2 наблюдения для усреднения # (классический порог стабильности сезонного индекса). Уточняется бэктестом. _MIN_FULL_YEARS: int = 2 # Месяцев в году — размер группировки month-of-year (1..12). _MONTHS_IN_YEAR: int = 12 @dataclass(frozen=True) class SeasonalAdjustment: """Результат сезонной (month-of-year) нормализации месячного ряда спроса. Детерминированный. `factors` — {месяц 1..12: мультипликативный сезонный фактор} (>1 месяц систематически выше года, <1 ниже, ровно 1.0 = не корректировали: тонкие данные / нет наблюдений / нулевой месяц). `applied` = True, только если хотя бы один фактор ≠ 1.0 (реально сняли сезонность); False = деградация к нейтрали (< _MIN_FULL_YEARS лет / пустой ряд) — ряд вернулся без изменений. `n_full_years` — сколько полных лет покрывает ряд (для explainability порога). """ factors: dict[int, float] # {month 1..12: seasonal_factor} (1.0 = не трогали) applied: bool # True = снята реальная сезонность; False = деградация к нейтрали n_full_years: int # полных лет в ряду (база для _MIN_FULL_YEARS guard) # ────────────────────────────────────────────────────────────────────────────── # Pure-логика — без БД, полностью юнит-тестируемо (numpy-среднее на синтетике). # ────────────────────────────────────────────────────────────────────────────── def seasonal_factors( months: list[date], values: list[float | int | None], *, min_full_years: int = _MIN_FULL_YEARS, ) -> SeasonalAdjustment: """Оценить мультипликативные month-of-year сезонные факторы ряда. PURE. seasonal_factor[m] = month_mean[m] / overall_mean по календарному месяцу m∈1..12 (см. метод в module docstring). months[i] и values[i] выровнены по индексу; None-значения в values пропускаются (нет наблюдения — не подмешиваем 0). GUARD (деградация к нейтрали 1.0, без деления на ноль / усиления шума): • полных лет (по NON-ZERO месяцам) < min_full_years → ВСЕ факторы 1.0, applied=False (fix #1638: zero-filled месяцы не считаются за наблюдения); • overall_mean ≤ 0 → ВСЕ факторы 1.0 (нет масштаба / защита от /0); • месяц без наблюдений ИЛИ со средним ≤ 0 → его фактор 1.0. Args: months: список дат точек (любой день месяца — берём .month), index-aligned. values: значения спроса (units), None = нет наблюдения; index-aligned. min_full_years: минимум полных лет для оценки сезонности (_MIN_FULL_YEARS). Returns: SeasonalAdjustment с factors {1..12: фактор}, applied, n_full_years. """ # Собираем наблюдения по календарному месяцу (None пропускаем — не нули). by_month: dict[int, list[float]] = {m: [] for m in range(1, _MONTHS_IN_YEAR + 1)} all_values: list[float] = [] for d, v in zip(months, values, strict=False): if v is None: continue fv = float(v) by_month[d.month].append(fv) all_values.append(fv) n_full_years = _count_full_years(months, values) neutral = {m: _NEUTRAL_FACTOR for m in range(1, _MONTHS_IN_YEAR + 1)} # Тонкие данные: < min_full_years полных лет → сезонность неотличима от шума. if n_full_years < min_full_years: return SeasonalAdjustment(factors=neutral, applied=False, n_full_years=n_full_years) # Нет масштаба / непозитивный overall_mean → нейтраль (и защита от деления /0). if not all_values: return SeasonalAdjustment(factors=neutral, applied=False, n_full_years=n_full_years) overall_mean = float(np.mean(all_values)) if overall_mean <= 0.0: return SeasonalAdjustment(factors=neutral, applied=False, n_full_years=n_full_years) factors: dict[int, float] = {} for m in range(1, _MONTHS_IN_YEAR + 1): obs = by_month[m] if not obs: factors[m] = _NEUTRAL_FACTOR # нет наблюдений месяца → не корректируем continue month_mean = float(np.mean(obs)) if month_mean <= 0.0: factors[m] = _NEUTRAL_FACTOR # все нули в месяце → не раздуваем нули continue factors[m] = month_mean / overall_mean applied = any(f != _NEUTRAL_FACTOR for f in factors.values()) return SeasonalAdjustment(factors=factors, applied=applied, n_full_years=n_full_years) def _count_full_years(months: list[date], values: list[float | int | None]) -> int: """Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 NON-ZERO наблюдение). Считаем по годам, где есть НЕНУЛЕВОЕ наблюдение в КАЖДОМ из 12 месяцев — это «полный год» в смысле сезонного покрытия (а не просто диапазон дат). None-точки и нулевые значения (zero-filled месяцы из fill_month_grid) наблюдением НЕ считаются: у ряда с units=0 нет сезонного сигнала для оценки факторов. PURE. Это исправляет баг #1638: fill_month_grid выставляет units=0 для отсутствующих месяцев (не None), поэтому старый guard, пропускавший только None, насчитывал «полные годы» на фактически пустом ряду. """ months_seen: dict[int, set[int]] = {} for d, v in zip(months, values, strict=False): if v is None or float(v) == 0.0: continue months_seen.setdefault(d.year, set()).add(d.month) return sum(1 for present in months_seen.values() if len(present) == _MONTHS_IN_YEAR) def deseasonalize_values( months: list[date], values: list[float | int | None], factors: dict[int, float], ) -> list[float | None]: """Поделить значения на сезонный фактор их месяца: out[t] = raw_t / factor[m]. PURE. None-значение остаётся None (нет наблюдения). Фактор ≤ 0 или отсутствующий месяц → нейтраль 1.0 (страховка: деления на ноль/отрицательное не делаем). Длина выхода = длине входа, index-aligned. Args: months: даты точек (берём .month), index-aligned с values. values: сырые значения (units), None = нет наблюдения. factors: {month 1..12: seasonal_factor} из seasonal_factors(). Returns: Дессзонированные значения той же длины (None там, где был None). """ out: list[float | None] = [] for d, v in zip(months, values, strict=False): if v is None: out.append(None) continue factor = factors.get(d.month, _NEUTRAL_FACTOR) if factor <= 0.0: factor = _NEUTRAL_FACTOR out.append(float(v) / factor) return out # ────────────────────────────────────────────────────────────────────────────── # Публичный API — дроп-ин в demand→regression путь (§9.6 rate_sensitivity). # ────────────────────────────────────────────────────────────────────────────── def normalize_demand(series: SalesSeries) -> SalesSeries: """Снять month-of-year сезонность с сырого месячного ряда спроса (DoD #979). Принимает и возвращает `SalesSeries` (тот самый объект, что течёт в регрессию §9.6: build_sales_series → normalize_demand → log_diff(units)). Дессзонирует `units` мультипликативной month-of-year корректировкой (см. module docstring), остальные поля (area_m2, avg_price, source, segment, confidence) сохраняет. ⚠️ Это КАЛЕНДАРНОЕ дессзонивание, ОРТОГОНАЛЬНОЕ rate-regime дисконту `demand_normalization.compute_demand_normalization` (β·Δrate). Применять ПЕРЕД регрессией §9.6, чтобы сезонный пик не читался как «эффект ставки» и не тащил низкоставочный sell-through вперёд (рационал — module docstring). GUARD (graceful): < _MIN_FULL_YEARS полных лет / пустой / нулевой ряд → факторы все 1.0 → ряд возвращается ФАКТИЧЕСКИ без изменений (deseasonalized units равны исходным, с точностью до float). НИКОГДА не делит на ноль, не усиливает шум. Дессзонированные units — float (деление на фактор), но `SalesSeries.units` типизирован как list[int]: спрос дессзонивания идёт в log_diff (float-математика), поэтому округляем к int ТОЛЬКО для сохранения контракта dataclass; для регрессии важна форма паттерна, а не целочисленность. Если нужны точные float-значения — бери deseasonalize_values(...) напрямую (см. ниже helper). Детерминированно. Args: series: сырой месячный ряд продаж сегмента (build_sales_series, §9.6 Y-ось). Returns: Новый SalesSeries с дессзонированными units (тот же shape; прочие поля как у входа). Пустой ряд → возвращается как есть. """ if not series.months: return series adjustment = seasonal_factors(series.months, series.units) if not adjustment.applied: logger.info( "normalize_demand: no seasonal adjustment applied (segment=%s n_full_years=%d " "< min) → series unchanged", series.segment, adjustment.n_full_years, ) return series deseasonalized = deseasonalize_values(series.months, series.units, adjustment.factors) # Контракт SalesSeries.units = list[int]: округляем дессзонированные значения # (None в units не бывает — units всегда ≥0 int; страховка max(0, …)). new_units = [max(0, round(v)) if v is not None else 0 for v in deseasonalized] logger.info( "normalize_demand: deseasonalized segment=%s n_full_years=%d factors=%s", series.segment, adjustment.n_full_years, {m: round(f, 3) for m, f in adjustment.factors.items()}, ) return replace(series, units=new_units)