normalize.py: 0-заполненная сетка SalesSeries.units сводит guard _count_full_years к диапазону дат, а не к числу реальных наблюдений (латентно, до подключения #979) #1638

Closed
opened 2026-06-16 18:44:33 +00:00 by bot-backend · 0 comments
Collaborator

Severity: low · Категория: logic/thin-data-guard · Файл: backend/app/services/forecasting/normalize.py:169-181, 139-144

Файл: backend/app/services/forecasting/normalize.py:169-181 (_count_full_years) и 132-144 (seasonal_factors).

Суть: _count_full_years считает 'полным годом' любой календарный год, у которого во всех 12 месяцах есть не-None значение (строки 177-181: if v is None: continue ... len(present) == _MONTHS_IN_YEAR). То же условие if v is None есть в seasonal_factors (строка 133). Но фактический вход — SalesSeries.units, который sales_series.fill_month_grid строит как НЕПРЕРЫВНУЮ сетку с units=0 для месяцев без сделок (sales_series.py:310 units: list[int], :316 units.append(0); None в нём не бывает — подтверждено чтением). Значит ветка if v is None никогда не срабатывает для прод-данных, и КАЖДЫЙ месяц сетки = 'наблюдение'. Поэтому n_full_years = число полных календарных лет в ДИАПАЗОНЕ дат ряда, а не число лет с реальными сделками.

Импакт: guard n_full_years < _MIN_FULL_YEARS (=2), чья заявленная цель (docstring 36-47, 71-76: '≥2 наблюдения на месяц, иначе сезонность неотличима от шума') удовлетворяется даже на ряде, где почти все месяцы = 0 продаж. month_mean<=0 (строка 160) защищает только полностью нулевой месяц; месяц с 1-2 редкими сделками среди нулей даст шумный фактор, который применится (applied=True), ровно усиливая шум, от которого guard должен защищать.

ВАЖНО — дефект ЛАТЕНТНЫЙ: forecasting.normalize_demand НЕ подключён в прод-путь регрессии (grep: единственная ссылка вне модуля — TODO(#979) в rate_sensitivity.py:559, интеграция явно отложена). Сейчас это мёртвый advisory-код; сработает в момент подключения, искажая Y-ось §9.6 на тонких сегментах (типовой случай room×class×price сетки ЕКБ).

Фикс: считать число РЕАЛЬНЫХ наблюдений на месяц как units>0, а не non-None; в _count_full_years учитывать год полным только если в каждом месяце ≥1 ненулевое наблюдение, и/или ввести гейт 'дессзонировать только при ≥N ненулевых точек/месяц' (вариант (а) из TODO(#979)). Это снимет расхождение docstring↔реализация ДО подключения.

Почему баг: Заявленная семантика guard'а (порог по числу РЕАЛЬНЫХ наблюдений на месяц) расходится с фактической (порог по диапазону дат), потому что 0-заполнение сетки маскирует отсутствие сделок. ВАЖНО про импакт: forecasting.normalize.normalize_demand НЕ подключён в production-путь регрессии — rate_sensitivity.py:558-566 явно оставляет интеграцию как TODO(#979) ('подключение — отдельной задачей с бэктестом'), и grep по репозиторию не находит ни одного вызова forecasting.normalize_demand вне самого модуля (вызовы normalize_demand в site_finder/locations.py — это ДРУГАЯ функция). Поэтому сейчас дефект латентный (мёртвый advisory-код), но он сработает в момент подключения, искажая Y-ось §9.6 на тонких сегментах ЕКБ (а тонкие сегменты — типовой случай для room×class×price сетки).

Как чинить: Считать сезонный guard по числу НЕНУЛЕВЫХ (реальных) наблюдений на календарный месяц, а не по плотности 0-заполненной сетки: например требовать ≥ _MIN_FULL_YEARS месяцев с units>0 в каждом календарном месяце-группе, либо считать n_full_years только по годам, где все 12 месяцев имеют хотя бы одну ненулевую сделку. Иначе явно задокументировать, что вход уже должен быть рядом сделок без 0-fill.


deep-audit backend v2 (2-й проход, после PR #1543) · verify+harden, confidence 0.7 · unit B26

**Severity:** low · **Категория:** logic/thin-data-guard · **Файл:** `backend/app/services/forecasting/normalize.py:169-181, 139-144` Файл: backend/app/services/forecasting/normalize.py:169-181 (_count_full_years) и 132-144 (seasonal_factors). Суть: _count_full_years считает 'полным годом' любой календарный год, у которого во всех 12 месяцах есть не-None значение (строки 177-181: `if v is None: continue` ... `len(present) == _MONTHS_IN_YEAR`). То же условие `if v is None` есть в seasonal_factors (строка 133). Но фактический вход — SalesSeries.units, который sales_series.fill_month_grid строит как НЕПРЕРЫВНУЮ сетку с units=0 для месяцев без сделок (sales_series.py:310 `units: list[int]`, :316 `units.append(0)`; None в нём не бывает — подтверждено чтением). Значит ветка `if v is None` никогда не срабатывает для прод-данных, и КАЖДЫЙ месяц сетки = 'наблюдение'. Поэтому n_full_years = число полных календарных лет в ДИАПАЗОНЕ дат ряда, а не число лет с реальными сделками. Импакт: guard `n_full_years < _MIN_FULL_YEARS` (=2), чья заявленная цель (docstring 36-47, 71-76: '≥2 наблюдения на месяц, иначе сезонность неотличима от шума') удовлетворяется даже на ряде, где почти все месяцы = 0 продаж. month_mean<=0 (строка 160) защищает только полностью нулевой месяц; месяц с 1-2 редкими сделками среди нулей даст шумный фактор, который применится (applied=True), ровно усиливая шум, от которого guard должен защищать. ВАЖНО — дефект ЛАТЕНТНЫЙ: forecasting.normalize_demand НЕ подключён в прод-путь регрессии (grep: единственная ссылка вне модуля — TODO(#979) в rate_sensitivity.py:559, интеграция явно отложена). Сейчас это мёртвый advisory-код; сработает в момент подключения, искажая Y-ось §9.6 на тонких сегментах (типовой случай room×class×price сетки ЕКБ). Фикс: считать число РЕАЛЬНЫХ наблюдений на месяц как units>0, а не non-None; в _count_full_years учитывать год полным только если в каждом месяце ≥1 ненулевое наблюдение, и/или ввести гейт 'дессзонировать только при ≥N ненулевых точек/месяц' (вариант (а) из TODO(#979)). Это снимет расхождение docstring↔реализация ДО подключения. **Почему баг:** Заявленная семантика guard'а (порог по числу РЕАЛЬНЫХ наблюдений на месяц) расходится с фактической (порог по диапазону дат), потому что 0-заполнение сетки маскирует отсутствие сделок. ВАЖНО про импакт: forecasting.normalize.normalize_demand НЕ подключён в production-путь регрессии — rate_sensitivity.py:558-566 явно оставляет интеграцию как TODO(#979) ('подключение — отдельной задачей с бэктестом'), и grep по репозиторию не находит ни одного вызова forecasting.normalize_demand вне самого модуля (вызовы normalize_demand в site_finder/locations.py — это ДРУГАЯ функция). Поэтому сейчас дефект латентный (мёртвый advisory-код), но он сработает в момент подключения, искажая Y-ось §9.6 на тонких сегментах ЕКБ (а тонкие сегменты — типовой случай для room×class×price сетки). **Как чинить:** Считать сезонный guard по числу НЕНУЛЕВЫХ (реальных) наблюдений на календарный месяц, а не по плотности 0-заполненной сетки: например требовать ≥ _MIN_FULL_YEARS месяцев с units>0 в каждом календарном месяце-группе, либо считать n_full_years только по годам, где все 12 месяцев имеют хотя бы одну ненулевую сделку. Иначе явно задокументировать, что вход уже должен быть рядом сделок без 0-fill. --- <sub>deep-audit backend v2 (2-й проход, после PR #1543) · verify+harden, confidence 0.7 · unit B26</sub>
bot-backend added the
week ревью 1
label 2026-06-16 18:44:33 +00:00
bot-backend added the
needs-human
label 2026-06-16 20:44:22 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#1638
No description provided.