fix(forecasting): count non-zero observations in normalize guard, not zero-filled buckets (#1638)
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.
This commit is contained in:
bot-backend 2026-06-17 20:39:00 +03:00
parent 18da92ccc7
commit 494155654e
2 changed files with 73 additions and 8 deletions

View file

@ -36,9 +36,10 @@
МИНИМАЛЬНЫЙ ПОРОГ ДАННЫХ (guard graceful, без усиления шума на тонких данных):
сезонный фактор для месяца m применяется ТОЛЬКО если его не на чем переоценить
случайным шумом одного-двух наблюдений:
нужно _MIN_FULL_YEARS полных лет (по умолчанию 2) иначе у каждого месяца
< 2 наблюдений, «сезонность» неотличима от шума ВСЕ факторы = 1.0 (ряд
возвращается без изменений);
нужно _MIN_FULL_YEARS полных лет (по умолчанию 2) считаем только по
NON-ZERO месяцам (fill_month_grid ставит units=0 для «нет сделок», но 0 не
несёт сезонного сигнала); иначе у каждого месяца < 2 наблюдений, «сезонность»
неотличима от шума ВСЕ факторы = 1.0 (ряд возвращается без изменений);
месяц без наблюдений его фактор = 1.0 (нет базы для оценки);
overall_mean 0 (пустой/нулевой ряд) ВСЕ факторы = 1.0 (нет масштаба, и
защита от деления на ноль);
@ -114,7 +115,8 @@ def seasonal_factors(
None-значения в values пропускаются (нет наблюдения не подмешиваем 0).
GUARD (деградация к нейтрали 1.0, без деления на ноль / усиления шума):
полных лет < min_full_years ВСЕ факторы 1.0, applied=False;
полных лет (по NON-ZERO месяцам) < min_full_years ВСЕ факторы 1.0,
applied=False (fix #1638: zero-filled месяцы не считаются за наблюдения);
overall_mean 0 ВСЕ факторы 1.0 (нет масштаба / защита от /0);
месяц без наблюдений ИЛИ со средним 0 его фактор 1.0.
@ -167,15 +169,20 @@ def seasonal_factors(
def _count_full_years(months: list[date], values: list[float | int | None]) -> int:
"""Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 наблюдение) в ряду.
"""Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 NON-ZERO наблюдение).
Считаем по годам, где присутствует наблюдение в КАЖДОМ из 12 месяцев это
Считаем по годам, где есть НЕНУЛЕВОЕ наблюдение в КАЖДОМ из 12 месяцев это
«полный год» в смысле сезонного покрытия (а не просто диапазон дат). None-точки
наблюдением НЕ считаются. PURE.
и нулевые значения (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:
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)

View file

@ -138,6 +138,50 @@ class TestSeasonalFactors:
assert adj.applied is False
assert all(f == 1.0 for f in adj.factors.values())
# ── fix #1638: zero-filled series must fail the min-years guard ───────────
def test_zero_filled_36m_fails_min_years_guard(self) -> None:
"""Ряд 36 мес, все units=0 (zero-fill от fill_month_grid) → applied=False.
До fix #1638 _count_full_years считал нулевые значения за наблюдения
и возвращал n_full_years=3 guard пропускал overall_mean==0 спасал
от деления, но прохождение guard'а было семантически неверным.
После fix: 0 не считается наблюдением n_full_years=0 applied=False.
"""
months = _months(36)
adj = seasonal_factors(months, [0] * 36)
assert (
adj.n_full_years == 0
), f"expected 0 full years on all-zero series, got {adj.n_full_years}"
assert adj.applied is False
assert all(f == 1.0 for f in adj.factors.values())
def test_mixed_zero_and_real_partial_years_fails_guard(self) -> None:
"""2 года месяцев, но только часть месяцев ненулевые — не 2 полных года.
Сценарий: новый ЖК, продажи только в нескольких месяцах каждого года
(остальные zero-fill). _count_full_years должен не считать это «полным годом».
"""
months = _months(24) # 2 календарных года
# Только январь-июнь каждого года ненулевые → нет полного покрытия 12 мес.
units = [10 if d.month <= 6 else 0 for d in months]
adj = seasonal_factors(months, units)
assert (
adj.n_full_years == 0
), f"partial-coverage years should not count as full, got {adj.n_full_years}"
assert adj.applied is False
def test_real_nonzero_series_passes_guard_and_applies(self) -> None:
"""Ряд с реальными ненулевыми данными за 2+ лет проходит guard и применяется.
Страховка: fix #1638 не должен ломать штатный путь с настоящими данными.
"""
months = _months(36)
units = _seasonal_units(months) # baseline×паттерн, все > 0
adj = seasonal_factors(months, units)
assert adj.n_full_years == 3
assert adj.applied is True
def test_month_with_no_observations_factor_one(self) -> None:
# Guard «месяц без наблюдений → фактор 1.0» (defensive): март всегда пуст
# (None). Чтобы пройти year-guard и реально дойти до per-month ветки,
@ -239,3 +283,17 @@ class TestNormalizeDemand:
units = _seasonal_units(months)
result = normalize_demand(_make_series(months, units))
assert all(isinstance(u, int) and u >= 0 for u in result.units)
def test_zero_filled_series_returned_unchanged(self) -> None:
"""fix #1638: zero-filled SalesSeries (все units=0) возвращается без изменений.
До fix: guard считал 3 «полных года» на нулях adjustment.applied=True
(пройдя через overall_mean==0 check) или мог пройти guard и вернуть
идентичный ряд через нейтральные факторы. После fix: n_full_years=0
applied=False функция возвращает тот же объект (short-circuit).
"""
months = _months(36)
series = _make_series(months, [0] * 36)
result = normalize_demand(series)
assert result is series, "zero-filled series must be returned as-is (no adjustment)"
assert result.units == [0] * 36