fix(forecasting): count non-zero observations in normalize guard (#1638) #1683
2 changed files with 73 additions and 8 deletions
|
|
@ -36,9 +36,10 @@
|
||||||
МИНИМАЛЬНЫЙ ПОРОГ ДАННЫХ (guard — graceful, без усиления шума на тонких данных):
|
МИНИМАЛЬНЫЙ ПОРОГ ДАННЫХ (guard — graceful, без усиления шума на тонких данных):
|
||||||
сезонный фактор для месяца m применяется ТОЛЬКО если его не на чем переоценить
|
сезонный фактор для месяца m применяется ТОЛЬКО если его не на чем переоценить
|
||||||
случайным шумом одного-двух наблюдений:
|
случайным шумом одного-двух наблюдений:
|
||||||
• нужно ≥ _MIN_FULL_YEARS полных лет (по умолчанию 2) — иначе у каждого месяца
|
• нужно ≥ _MIN_FULL_YEARS полных лет (по умолчанию 2) — считаем только по
|
||||||
< 2 наблюдений, «сезонность» неотличима от шума → ВСЕ факторы = 1.0 (ряд
|
NON-ZERO месяцам (fill_month_grid ставит units=0 для «нет сделок», но 0 не
|
||||||
возвращается без изменений);
|
несёт сезонного сигнала); иначе у каждого месяца < 2 наблюдений, «сезонность»
|
||||||
|
неотличима от шума → ВСЕ факторы = 1.0 (ряд возвращается без изменений);
|
||||||
• месяц без наблюдений → его фактор = 1.0 (нет базы для оценки);
|
• месяц без наблюдений → его фактор = 1.0 (нет базы для оценки);
|
||||||
• overall_mean ≤ 0 (пустой/нулевой ряд) → ВСЕ факторы = 1.0 (нет масштаба, и
|
• overall_mean ≤ 0 (пустой/нулевой ряд) → ВСЕ факторы = 1.0 (нет масштаба, и
|
||||||
защита от деления на ноль);
|
защита от деления на ноль);
|
||||||
|
|
@ -114,7 +115,8 @@ def seasonal_factors(
|
||||||
None-значения в values пропускаются (нет наблюдения — не подмешиваем 0).
|
None-значения в values пропускаются (нет наблюдения — не подмешиваем 0).
|
||||||
|
|
||||||
GUARD (деградация к нейтрали 1.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);
|
• overall_mean ≤ 0 → ВСЕ факторы 1.0 (нет масштаба / защита от /0);
|
||||||
• месяц без наблюдений ИЛИ со средним ≤ 0 → его фактор 1.0.
|
• месяц без наблюдений ИЛИ со средним ≤ 0 → его фактор 1.0.
|
||||||
|
|
||||||
|
|
@ -167,15 +169,20 @@ def seasonal_factors(
|
||||||
|
|
||||||
|
|
||||||
def _count_full_years(months: list[date], values: list[float | int | None]) -> int:
|
def _count_full_years(months: list[date], values: list[float | int | None]) -> int:
|
||||||
"""Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 наблюдение) в ряду.
|
"""Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 NON-ZERO наблюдение).
|
||||||
|
|
||||||
Считаем по годам, где присутствует наблюдение в КАЖДОМ из 12 месяцев — это
|
Считаем по годам, где есть НЕНУЛЕВОЕ наблюдение в КАЖДОМ из 12 месяцев — это
|
||||||
«полный год» в смысле сезонного покрытия (а не просто диапазон дат). None-точки
|
«полный год» в смысле сезонного покрытия (а не просто диапазон дат). 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]] = {}
|
months_seen: dict[int, set[int]] = {}
|
||||||
for d, v in zip(months, values, strict=False):
|
for d, v in zip(months, values, strict=False):
|
||||||
if v is None:
|
if v is None or float(v) == 0.0:
|
||||||
continue
|
continue
|
||||||
months_seen.setdefault(d.year, set()).add(d.month)
|
months_seen.setdefault(d.year, set()).add(d.month)
|
||||||
return sum(1 for present in months_seen.values() if len(present) == _MONTHS_IN_YEAR)
|
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 adj.applied is False
|
||||||
assert all(f == 1.0 for f in adj.factors.values())
|
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:
|
def test_month_with_no_observations_factor_one(self) -> None:
|
||||||
# Guard «месяц без наблюдений → фактор 1.0» (defensive): март всегда пуст
|
# Guard «месяц без наблюдений → фактор 1.0» (defensive): март всегда пуст
|
||||||
# (None). Чтобы пройти year-guard и реально дойти до per-month ветки,
|
# (None). Чтобы пройти year-guard и реально дойти до per-month ветки,
|
||||||
|
|
@ -239,3 +283,17 @@ class TestNormalizeDemand:
|
||||||
units = _seasonal_units(months)
|
units = _seasonal_units(months)
|
||||||
result = normalize_demand(_make_series(months, units))
|
result = normalize_demand(_make_series(months, units))
|
||||||
assert all(isinstance(u, int) and u >= 0 for u in result.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