Some checks failed
CI / changes (push) Successful in 8s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Failing after 8m52s
_count_full_years treated units=0 as a valid observation, so a series where fill_month_grid zero-filled every month still accumulated 3 full years and passed the _MIN_FULL_YEARS guard. Zero-filled months carry no seasonal signal, so they must be skipped in the year counter — the same way None values already were. Fix: skip v==0 alongside v is None in _count_full_years. Add four tests: zero-filled 36-month series → n_full_years=0/applied=False; partial-coverage years (only 6 non-zero months/year) → not counted as full; real non-zero series still passes guard; normalize_demand on zero-filled SalesSeries returns series unchanged.
281 lines
19 KiB
Python
281 lines
19 KiB
Python
"""§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)
|