feat(forecasting): seasonal demand normalization module (#979) #1039
3 changed files with 525 additions and 0 deletions
274
backend/app/services/forecasting/normalize.py
Normal file
274
backend/app/services/forecasting/normalize.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
"""§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)
|
||||
|
|
@ -554,6 +554,16 @@ def _align_sales_deltas(
|
|||
log_diff даёт Δln по сетке ПРОДАЖ; чтобы пары (Δrate[t−L], Δln[t]) были month-
|
||||
в-month, перекладываем их на сетку macro_months (месяц без продаж в выравнивании
|
||||
→ None). Так best_lag сдвигает X строго по той же временной оси. PURE.
|
||||
|
||||
TODO(#979): здесь — ЕДИНСТВЕННАЯ чистая точка интеграции month-of-year
|
||||
дессзонивания (normalize.normalize_demand / seasonal_factors+deseasonalize_values)
|
||||
ПЕРЕД log_diff. Интеграция ОТЛОЖЕНА: на коротком ряде (≤3 полных лет = ≤3 точки
|
||||
на календарный месяц) ratio-to-mean фактор может перетянуть часть режимного
|
||||
отклика на ставку в «сезонность» и сместить восстановленный β/лаг (вскрылось на
|
||||
синтетике test_rate_sensitivity). Нужно сначала: (а) гейт «дессзонивать только
|
||||
при ≥N точках/месяц или high-confidence ряде», либо (б) совместная оценка
|
||||
seasonal+rate, чтобы не вычитать сигнал ставки. Модуль + тест готовы (#979 DoD);
|
||||
подключение — отдельной задачей с бэктестом.
|
||||
"""
|
||||
deltas = log_diff(sales_units)
|
||||
by_month = dict(zip(sales_months, deltas, strict=False))
|
||||
|
|
|
|||
241
backend/tests/services/forecasting/test_normalize.py
Normal file
241
backend/tests/services/forecasting/test_normalize.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"""Unit-тесты сезонной (month-of-year) нормализации спроса (#979, ТЗ §9.4).
|
||||
|
||||
Синтетика, без живой БД. Гейт DoD:
|
||||
• flat baseline × известный month-of-year паттерн (лето +30%, зима −30%) за 2-3
|
||||
года → после normalize_demand средние по календарным месяцам ~равны (сезонность
|
||||
снята, within tolerance);
|
||||
• плоский ряд → не меняется;
|
||||
• тонкие данные (< 2 полных лет) → возвращаются ~без изменений (факторы = 1.0, без
|
||||
blowup / деления на ноль);
|
||||
• пустой ряд → пустой (без crash);
|
||||
• factors / applied / n_full_years семантика + ортогональность прочих полей.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import statistics
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from datetime import date
|
||||
|
||||
from app.services.forecasting.normalize import (
|
||||
SeasonalAdjustment,
|
||||
deseasonalize_values,
|
||||
normalize_demand,
|
||||
seasonal_factors,
|
||||
)
|
||||
from app.services.forecasting.sales_series import SalesSeries
|
||||
|
||||
# ── synthetic helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
# Известный month-of-year паттерн: лето (июнь-авг) +30%, зима (дек-фев) −30%,
|
||||
# остальное ≈ базовый уровень. Сумма множителей вокруг 1.0 → overall_mean ≈ baseline.
|
||||
_SEASONAL_PATTERN: dict[int, float] = {
|
||||
1: 0.70, # январь −30%
|
||||
2: 0.70, # февраль −30%
|
||||
3: 1.00,
|
||||
4: 1.00,
|
||||
5: 1.00,
|
||||
6: 1.30, # июнь +30%
|
||||
7: 1.30, # июль +30%
|
||||
8: 1.30, # август +30%
|
||||
9: 1.00,
|
||||
10: 1.00,
|
||||
11: 1.00,
|
||||
12: 0.70, # декабрь −30%
|
||||
}
|
||||
|
||||
_BASELINE_UNITS: int = 100
|
||||
|
||||
|
||||
def _months(n_months: int, start: date = date(2021, 1, 1)) -> list[date]:
|
||||
"""n_months подряд идущих 1-х чисел месяца, начиная со start."""
|
||||
out: list[date] = []
|
||||
y, m = start.year, start.month
|
||||
for _ in range(n_months):
|
||||
out.append(date(y, m, 1))
|
||||
m += 1
|
||||
if m > 12:
|
||||
m = 1
|
||||
y += 1
|
||||
return out
|
||||
|
||||
|
||||
def _seasonal_units(months: list[date], baseline: int = _BASELINE_UNITS) -> list[int]:
|
||||
"""baseline × month-of-year паттерн, округлённый к int (сырой сезонный спрос)."""
|
||||
return [round(baseline * _SEASONAL_PATTERN[d.month]) for d in months]
|
||||
|
||||
|
||||
def _make_series(months: list[date], units: list[int]) -> SalesSeries:
|
||||
"""SalesSeries-обёртка для теста (прочие поля — нейтральные заглушки)."""
|
||||
return SalesSeries(
|
||||
months=months,
|
||||
units=units,
|
||||
area_m2=[None] * len(months),
|
||||
avg_price_per_m2=[None] * len(months),
|
||||
n_months=len(months),
|
||||
source="corpus_room_month",
|
||||
segment={"obj_class": None, "room_bucket": None, "district": None, "price_bucket": None},
|
||||
confidence="high",
|
||||
)
|
||||
|
||||
|
||||
def _month_of_year_means(months: list[date], units: list[float | None]) -> dict[int, float]:
|
||||
"""Среднее значение по каждому календарному месяцу (1..12) — для assert «снято»."""
|
||||
buckets: dict[int, list[float]] = {}
|
||||
for d, v in zip(months, units, strict=False):
|
||||
if v is None:
|
||||
continue
|
||||
buckets.setdefault(d.month, []).append(float(v))
|
||||
return {m: statistics.mean(vs) for m, vs in buckets.items() if vs}
|
||||
|
||||
|
||||
# ── seasonal_factors (pure) ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSeasonalFactors:
|
||||
def test_recovers_known_pattern_factors(self) -> None:
|
||||
# 3 полных года сезонного ряда → факторы должны восстановить паттерн.
|
||||
months = _months(36)
|
||||
units = _seasonal_units(months)
|
||||
adj = seasonal_factors(months, units)
|
||||
assert isinstance(adj, SeasonalAdjustment)
|
||||
assert adj.applied is True
|
||||
assert adj.n_full_years == 3
|
||||
# Фактор каждого месяца ≈ его множитель паттерна (overall_mean ≈ baseline).
|
||||
for m, expected in _SEASONAL_PATTERN.items():
|
||||
assert abs(adj.factors[m] - expected) < 0.05, (m, adj.factors[m], expected)
|
||||
|
||||
def test_thin_data_under_two_years_neutral(self) -> None:
|
||||
# 18 мес < 2 полных лет → все факторы 1.0, applied=False (шум не ловим).
|
||||
months = _months(18)
|
||||
units = _seasonal_units(months)
|
||||
adj = seasonal_factors(months, units)
|
||||
assert adj.applied is False
|
||||
assert adj.n_full_years == 1
|
||||
assert all(f == 1.0 for f in adj.factors.values())
|
||||
|
||||
def test_flat_series_factors_all_one(self) -> None:
|
||||
# Плоский ряд (без сезонности) → каждый month_mean == overall_mean → 1.0.
|
||||
months = _months(36)
|
||||
units = [_BASELINE_UNITS] * 36
|
||||
adj = seasonal_factors(months, units)
|
||||
# applied может быть False (все ровно 1.0) — сезонности нет.
|
||||
assert all(abs(f - 1.0) < 1e-9 for f in adj.factors.values())
|
||||
|
||||
def test_empty_series_neutral(self) -> None:
|
||||
adj = seasonal_factors([], [])
|
||||
assert adj.applied is False
|
||||
assert adj.n_full_years == 0
|
||||
assert all(f == 1.0 for f in adj.factors.values())
|
||||
|
||||
def test_all_zero_series_neutral_no_div_zero(self) -> None:
|
||||
# overall_mean == 0 → нейтраль, без деления на ноль.
|
||||
months = _months(36)
|
||||
adj = seasonal_factors(months, [0] * 36)
|
||||
assert adj.applied is False
|
||||
assert all(f == 1.0 for f in adj.factors.values())
|
||||
|
||||
def test_month_with_no_observations_factor_one(self) -> None:
|
||||
# Guard «месяц без наблюдений → фактор 1.0» (defensive): март всегда пуст
|
||||
# (None). Чтобы пройти year-guard и реально дойти до per-month ветки,
|
||||
# ослабляем порог (min_full_years=0). Остальные месяцы получают свои факторы,
|
||||
# март — нейтраль (нет базы оценки), без KeyError / деления на пустое среднее.
|
||||
months = _months(36)
|
||||
units = _seasonal_units(months)
|
||||
vals: list[float | int | None] = [
|
||||
(None if d.month == 3 else u) for d, u in zip(months, units, strict=False)
|
||||
]
|
||||
adj = seasonal_factors(months, vals, min_full_years=0)
|
||||
assert adj.factors[3] == 1.0 # март без наблюдений → нейтраль
|
||||
# Соседний июль наблюдается → его фактор НЕ нейтральный (паттерн +30%).
|
||||
assert abs(adj.factors[7] - 1.3) < 0.1
|
||||
|
||||
|
||||
# ── deseasonalize_values (pure) ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeseasonalizeValues:
|
||||
def test_divides_by_factor(self) -> None:
|
||||
months = [date(2021, 1, 1), date(2021, 7, 1)]
|
||||
values: list[float | int | None] = [70, 130]
|
||||
factors = {1: 0.7, 7: 1.3}
|
||||
out = deseasonalize_values(months, values, factors)
|
||||
assert out[0] is not None and abs(out[0] - 100.0) < 1e-9
|
||||
assert out[1] is not None and abs(out[1] - 100.0) < 1e-9
|
||||
|
||||
def test_none_preserved(self) -> None:
|
||||
months = [date(2021, 1, 1), date(2021, 2, 1)]
|
||||
out = deseasonalize_values(months, [None, 70], {1: 0.7, 2: 0.7})
|
||||
assert out[0] is None
|
||||
assert out[1] is not None and abs(out[1] - 100.0) < 1e-9
|
||||
|
||||
def test_nonpositive_factor_falls_back_to_one(self) -> None:
|
||||
# Фактор ≤ 0 / отсутствующий месяц → нейтраль 1.0 (без деления на 0/neg).
|
||||
months = [date(2021, 1, 1), date(2021, 2, 1)]
|
||||
out = deseasonalize_values(months, [50, 60], {1: 0.0, 2: -1.0})
|
||||
assert out == [50.0, 60.0]
|
||||
|
||||
|
||||
# ── normalize_demand (public API, SalesSeries shape) ──────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeDemand:
|
||||
def test_seasonality_removed_month_means_equalised(self) -> None:
|
||||
# DoD GATE: сезонный ряд за 3 года → после normalize month-of-year средние
|
||||
# ~равны (within tolerance), т.е. сезонность снята.
|
||||
months = _months(36)
|
||||
units = _seasonal_units(months)
|
||||
raw_means = _month_of_year_means(months, [float(u) for u in units])
|
||||
# До нормализации: лето заметно выше зимы.
|
||||
assert max(raw_means.values()) / min(raw_means.values()) > 1.5
|
||||
|
||||
result = normalize_demand(_make_series(months, units))
|
||||
assert isinstance(result, SalesSeries)
|
||||
adj_means = _month_of_year_means(months, [float(u) for u in result.units])
|
||||
# После: разброс месячных средних схлопнут (≤ ~5% спред — остаток округления).
|
||||
spread = max(adj_means.values()) / min(adj_means.values())
|
||||
assert spread < 1.05, (spread, adj_means)
|
||||
|
||||
def test_flat_series_unchanged(self) -> None:
|
||||
months = _months(36)
|
||||
units = [_BASELINE_UNITS] * 36
|
||||
result = normalize_demand(_make_series(months, units))
|
||||
assert result.units == units # плоский ряд не тронут
|
||||
|
||||
def test_thin_data_returned_unchanged(self) -> None:
|
||||
# < 2 полных лет → факторы 1.0 → ряд возвращается без изменений (тот же объект).
|
||||
months = _months(18)
|
||||
units = _seasonal_units(months)
|
||||
series = _make_series(months, units)
|
||||
result = normalize_demand(series)
|
||||
assert result is series # short-circuit: applied=False → возвращаем как есть
|
||||
assert result.units == units
|
||||
|
||||
def test_empty_series_empty_no_crash(self) -> None:
|
||||
empty = _make_series([], [])
|
||||
result = normalize_demand(empty)
|
||||
assert result.months == []
|
||||
assert result.units == []
|
||||
|
||||
def test_other_fields_preserved(self) -> None:
|
||||
months = _months(36)
|
||||
units = _seasonal_units(months)
|
||||
series = _make_series(months, units)
|
||||
result = normalize_demand(series)
|
||||
# Дессзонивание трогает только units; прочее — без изменений.
|
||||
assert result.months == series.months
|
||||
assert result.area_m2 == series.area_m2
|
||||
assert result.avg_price_per_m2 == series.avg_price_per_m2
|
||||
assert result.source == series.source
|
||||
assert result.segment == series.segment
|
||||
assert result.confidence == series.confidence
|
||||
|
||||
def test_deseasonalized_units_nonnegative_int(self) -> None:
|
||||
# Контракт SalesSeries.units = list[int] ≥ 0 сохраняется после нормализации.
|
||||
months = _months(36)
|
||||
units = _seasonal_units(months)
|
||||
result = normalize_demand(_make_series(months, units))
|
||||
assert all(isinstance(u, int) and u >= 0 for u in result.units)
|
||||
Loading…
Add table
Reference in a new issue