gendesign/backend/app/services/forecasting/normalize.py
bot-backend 494155654e
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
fix(forecasting): count non-zero observations in normalize guard, not zero-filled buckets (#1638)
_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.
2026-06-17 20:39:00 +03:00

281 lines
19 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) — считаем только по
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)