gendesign/backend/app/services/forecasting/normalize.py
Light1YT 3ecfc2d7dc
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m16s
Deploy / build-backend (push) Successful in 1m28s
Deploy / deploy (push) Successful in 1m6s
feat(forecasting): seasonal (month-of-year) demand normalization (#979)
REOPENED — normalize.py was never created; only rate-regime discount existed.
New backend/app/services/forecasting/normalize.py with normalize_demand(series):
multiplicative month-of-year deseasonalization of the raw monthly demand
SalesSeries (§9.4). Pure/deterministic; min-data guard (<2 full years / empty
month / overall_mean<=0 → factor 1.0, no divide-by-zero, no thin-data noise).
Exposes seasonal factors for explainability. Synthetic unit test: seasonality
removed (month means equalised), flat unchanged, thin/empty/all-zero safe.

DoD (module + doc + test) MET. Production wiring into
rate_sensitivity._align_sales_deltas DEFERRED (documented TODO): deseasonalizing
the short rate-driven series perturbs the recovered β/lag on current data —
needs a points-per-month gate / joint seasonal+rate estimation + backtest before
wiring. Forecast stack is advisory regardless. Refs #979
2026-06-04 11:19:50 +05:00

274 lines
18 KiB
Python
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.

"""§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) — иначе у каждого месяца
< 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, без деления на ноль / усиления шума):
• полных лет < min_full_years → ВСЕ факторы 1.0, applied=False;
• 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 наблюдение) в ряду.
Считаем по годам, где присутствует наблюдение в КАЖДОМ из 12 месяцев — это
«полный год» в смысле сезонного покрытия (а не просто диапазон дат). None-точки
наблюдением НЕ считаются. PURE.
"""
months_seen: dict[int, set[int]] = {}
for d, v in zip(months, values, strict=False):
if v is None:
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)