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) и в расчётах не используется.