gendesign/backend/tests/services/forecasting/test_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

241 lines
11 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.

"""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)