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
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:
parent
18da92ccc7
commit
494155654e
2 changed files with 73 additions and 8 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue