← К портфолио Delivery Control Tower preview
Ситуация
Доставка измеряется средним временем, а вопрос «где теряется время и хватит ли мощности под прогноз» требует разреза по категориям, каналам и городам
Задача
Собрать воспроизводимый контур: ETL, витрины, прогноз заказов, анализ узких мест, проверяемые гипотезы и SQL-слой с замерами
Действия
DuckDB star-schema, rolling-origin бэктест четырёх моделей, декомпозиция времени и кривая нагрузки, Mann-Whitney с bootstrap-CI, 14 оконных запросов и 3 пары оптимизаций под EXPLAIN ANALYZE
Результат
Лучший WAPE 6.01% на h=7 у seasonal_naive; время доставки почти не зависит от нагрузки (rho = −0.092), но закуски идут 40.0 мин против 26.6 мин у пиццы; доля mp выросла на 5.7 п.п.
Стек
pythonsqlduckdbsuperset
Содержание
  1. Ситуация
  2. Задача
  3. Действия
  4. Данные и витрины
  5. Прогноз
  6. Узкие места
  7. Гипотезы
  8. SQL и презентация
  9. Результат
  10. Ограничения
  11. Документация
  12. Частые вопросы

Delivery Control Tower

Ситуация

Доставку обычно измеряют одним числом — средним временем. Этого недостаточно, чтобы понять, где процесс теряет время: разрез по категориям, каналам и городам показывает разные причины, а прогноз заказов нужен, чтобы планировать мощность заранее.

Задача

От меня требовался воспроизводимый контур аналитики: собрать данные в витрины, проверить их качество именованными проверками, построить прогноз и честно сравнить его с бейзлайном, найти узкие места, проверить заранее сформулированные гипотезы и показать SQL-слой с замерами оптимизаций.

Действия

Данные и витрины

Источник — агрегат из открытого тестового задания (Dodo Brands): 550 710 строк «день × город × канал × категория» за 2022-01-01..2022-06-30, 264 города, 3 канала, 4 категории. Второй файл — сэмпл 130 079 заказов по 2 анонимным городам для SQL-слоя.

ETL раскладывает raw → staging parquet, quality.py проверяет число строк, уникальность гранулярности и диапазоны, marts.py строит star-schema в DuckDB: шесть измерений, две фактовые таблицы и три витрины. tickets означает заказы (33 167 293), qty — позиции (57 576 550), 1.736 позиции на заказ.

Прогноз

Rolling-origin бэктест: тестовое окно 28 дней, четыре фолда, переобучение каждые 7 дней. Четыре модели на горизонтах 1/7/14. Признаки для дня t строятся только из наблюдений строго до t.

Модель h=1 h=7 h=14
naive 22.68 12.53 12.54
seasonal_naive 5.40 6.01 6.25
holt_winters 3.91 7.16 7.22
hgb 7.14 7.90 8.09

Узкие места

Три взгляда на один вопрос. Декомпозиция по каналу × категории: закуски ≈40.0 мин, пицца ≈26.5 мин, напитки ≈30.0 мин, десерты ≈25.0 мин; канал меняет время в пределах десятых. Кривая нагрузки: среднее время по децилям объёма падает с 31.88 до 31.24 мин, rho = −0.092. Рейтинг городов по наклону avg_time ~ qty: верхушку образуют Усолье-Сибирское (индекс 2.02), Грозный (1.63), Магадан (1.18), но R² наклонов 0.0004–0.0185 — это приоритизация, а не доказанный тренд.

Гипотезы

Гипотеза Эффект p 95% CI Вывод
H1: доля mp растёт быстрее web +5.70 п.п. 2.07e-11 [5.35; 6.03] подтверждена
H2: выходные медленнее будней −0.93% 1.1e-41 [−1.09; −0.76] отклонена: эффект значим в обратную сторону

SQL и презентация

sql/windows.sql содержит 14 именованных оконных запросов: скользящие средние, недельный рост, ранги, Парето, аномалии по 3×MA7, разрывы между заказами. Три пары slow/fast под EXPLAIN ANALYZE: 5.66×, 3.23× и 1.27×; лучшая — накопленный счётчик заказов по 130 079 строкам (62.3 → 11.0 мс).

Дашборд — один офлайн HTML ≈81 КБ без CDN и клиентских запросов, с тремя блоками по ролям: операции, коммерция, руководство. Superset-рецепт в superset/ написан, но не запускался: Docker в среде сборки недоступен, и это указано в README, а не замаскировано.

Результат

Прогноз даёт 6.01% WAPE на h=7 у сезонного бейзлайна; сложная модель его не обходит — так и записано в рекомендации. Узкое место — не нагрузка (rho = −0.092), а категория: закуски идут 40.0 мин против 26.6 мин у пиццы, то есть на 13.4 мин (+50%) дольше при любом канале. Канал при этом смещается: доля mp выросла на 5.7 п.п. за полгода. Один отрицательный результат — H2 о выходных — отклонён по заранее заданному правилу: эффект значим, но в обратную сторону.

Ограничения

Данные агрегированы до дня × города × канала × категории: нет времени по заказу, курьеру и ресторану. cost — денежная метрика без подтверждённой валюты, avg_price не согласуется с ней и не используется. Сэмпл заказов покрывает два анонимных города. Бэктест — четыре фолда, поэтому различия в сотые доли процента не значимы. Причинности нет: тесты ассоциативные.

Документация

README.md — архитектура, быстрый старт и ограничения; reports/report.md — аналитический отчёт; docs/data_dictionary.md — словарь полей.

Частые вопросы

Почему время доставки не зависит от нагрузки?

На агрегате день × город × канал × категория корреляция Спирмена между объёмом и временем — rho = −0.092, а среднее время меняется с 31.88 до 31.24 мин между крайними децилями. Разброс объясняется категорией, а не числом заказов.

Какая модель прогноза выбрана?

Для горизонтов 7 и 14 дней — seasonal_naive: WAPE 6.01% и 6.25%. Holt-Winters выигрывает только день вперёд (3.91%), а градиентный бустинг не обходит сезонный бейзлайн ни на одном горизонте.

Что в данных нельзя интерпретировать как выручку?

Поле cost — денежная метрика в неподтверждённых единицах источника: валюта не задокументирована. avg_price не согласуется с cost (221.8 против 138.9) и в расчётах не используется.