Данат Кузьменко
ML-сервис
← все проекты

Data Science

Рекомендации на корзинах зоомагазина

Не учебный датасет: 4743 настоящих заказа за два с половиной года из проекта, который лежит рядом в этом же портфолио. Сервис учится на них и подсказывает товары прямо на карточке в витрине.

Как это устроено

  • FP-Growth даёт правила «берут вместе» с внятными метриками: support, confidence, lift. Но покрывает не всех — редкие товары не набирают поддержки
  • Поэтому вторая модель, item-item по косинусной близости: соседи есть у 93% товаров, которые вообще встречались в заказах
  • Выдача берёт правила, потом добирает соседями, и у каждой рекомендации указано, откуда она взялась
  • Витрина от сервиса не зависит: таймаут две секунды, запасной путь по таксономии, ответ кэшируется на час

Запрашиваю сервис…

Методика

Откуда данные

Таблица заказов предыдущей версии Zoo-Land — того самого сайта, который лежит рядом в портфолио как «исходная точка миграции». 4743 заказа за 2,7 года, 1271 товар. Для обучения берутся корзины с двумя и более позициями: их 3382, по одиночным заказам ассоциативные правила строить не из чего.

База обезличена скриптом до того, как попала в разработку: имена, телефоны и адреса заменены. Состав корзин при этом сохранён — ровно то, что нужно market-basket анализу.

Какие метрики и почему им можно верить

  • У каждого правила выводятся support, confidence и lift — все три, а не только удобная. Порог support 0,004 подобран вручную; это честно названо ограничением, а не «оптимальным значением»
  • Покрытие указано рядом с цифрами: правила закрывают 195 товаров из 1271, остальное добирает item-item — 1238 товаров с соседями. Без покрытия любая метрика выглядит полной
  • Качество кластеризации мерится adjusted Rand index против дерева категорий магазина. Он равен −0,017, и это не провал: кластеры оказались марочными, а каталог построен по животным. Модель нашла структуру, которой в каталоге нет
  • RFM считается по 12,6% заказов, и эта доля стоит в самом ответе API. Причина — в блоке ниже

Две метрики, которые я отверг сам

Обе выглядели отличными результатами, и обе были неправдой. Показываю их потому, что метрика, которой никто не проверял, — это украшение, а не измерение.

чистота кластеров 0,975

Цифра отличная, но рядом стоял Rand index около нуля — сочетание слишком хорошее, чтобы быть правдой. Оказалось, что категории я брал только из корней дерева (parent = 0), а товары висят на листьях: 419 товаров из 445 в крупнейшем кластере попадали в класс «без категории». Чистота мерила однородность пустого класса. После подъёма от листа к корню стало 0,872 — и цифра начала означать то, что должна.

1000 покупателей, 100% повторных покупок

Ключом покупателя я взял телефон: в обезличенной базе их ровно 1000, и это выглядело готовым псевдонимом. Но обезличивание выводит телефон из id заказа (id MOD 1000) — заказы, различающиеся на 1000, получают один номер. «1000 покупателей со стопроцентными повторными покупками» — это просто 4743 / 1000. Пересчитал по user_id: 160 покупателей, 599 заказов, покрытие 12,6%.

Общий вывод: обезличивание сохранило состав корзин — то, что нужно рекомендациям, — и уничтожило идентичность покупателя, которая нужна RFM. Одни и те же данные годятся для одной задачи и не годятся для другой, и это стоит проверять до того, как цифра попадёт в отчёт.

Переобучение и что бывает, когда рекомендаций нет

Расписания переобучения нет — и это не недоделка. Датасет исторический: заказы за 2021–2023 годы из архива магазина, новые не появляются. Переобучать по графику нечего, поэтому пересчёт запускается руками (train.py, analyze.py), а служба перезапускается после — модели читаются один раз на старте. Появится живой поток заказов — понадобится и расписание, и сравнение метрик до и после.

Покрытие: честные две цифры

В каталоге 2771 товар, но в заказах встречались 1271 — остальные ни разу никто не купил. Рекомендации есть у 1187 товаров: это 93% от тех, что вообще попадали в корзины, и 42,8% каталога. Вторая цифра важнее первой: на большей части карточек сервис молчит, и это нормальное поведение, а не сбой.

Три способа остаться без ответа

  • Товар вне покрытия — сервис отвечает 200 и пустым списком, а не ошибкой. Витрина в этом случае показывает подбор по таксономии, и посетитель разницы не замечает
  • Файлов моделей нет — /health отдаёт «no model» вместо «ok». Ручки продолжают отвечать пустыми списками, поэтому витрина деградирует так же мягко
  • Служба остановлена — витрина ждёт две секунды и уходит на запасной путь. Проверено остановкой службы, а не только чтением кода

Почему сервис работает только на одном магазине

В портфолио есть второй магазин — Melanj, пряжа. Витрина у него такая же, и место под рекомендации на карточке товара готово. Подставить их туда всё равно нельзя, и причина не в коде.

  • Модель обучена на корзинах зоомагазина. Её соседи и правила — это конкретные id зоотоваров; к каталогу пряжи они отношения не имеют
  • Обучить вторую модель на корзинах Melanj не выйдет: таблицы заказов в той базе нет вовсе, истории покупок не существует

Поэтому рекомендации стоят там, где есть история покупок, а на Melanj похожие товары по-прежнему подбираются по марке. Это осознанное решение, а не недоделка: подставить сюда чужие товары было бы демонстрацией, которая работает только на скриншоте.

Что данные не дают

База обезличена скриптом, и он сохранил состав корзин — ровно то, что нужно market-basket анализу, — но уничтожил идентичность покупателя: телефон в ней выводится из id заказа. Поэтому RFM считается только по авторизованным пользователям, а это 12,6% заказов. Цифра покрытия стоит рядом с сегментами намеренно: без неё выборка выглядела бы полной.