Русский
Русский
English
Статистика
Реклама

Блог компании леруа мерлен

Как мы ускоряли время разгрузки товара на складе

14.07.2020 14:04:30 | Автор: admin
image
Терминал сбора данных Zebra WT-40 со сканером-кольцом. Нужен для того, чтобы была возможность быстро сканировать товар, при этом укладывать физически короба на паллету (свободные руки).

На протяжении нескольких лет мы очень быстро открывали магазины и росли. Закончилось это тем, что сейчас наши склады принимают и отправляют порядка 20 тысяч паллет в день. Естественно, сегодня у нас уже больше складов: два больших в Москве 100 и 140 тысяч квадратных метров, но есть и небольшие в других городах.

Каждая сэкономленная секунда в процессах приёмки, сборки или отправки товара в таких масштабах это возможность сберечь время на операции. А ещё это огромная экономия.

Именно поэтому два главных множителя эффективности это продуманный алгоритм действий (процесс) и настроенные ИТ-системы. Желательно как часы, но работающие чуть менее, чем идеально тоже вполне подойдёт. Всё же мы в реальном мире.

История началась шесть лет назад, когда мы присмотрелись к тому, как именно поставщики разгружают фуры у нас на складе. Это было настолько нелогично, но привычно, что сотрудники даже не замечали неоптимальности процесса. Более того, в тот момент у нас не было промышленной системы управления складом, и в основном логистические операции мы доверяли 3PL-операторам, которые использовали свой софт и опыт в построении процессов.

image

Приёмка товара


Как мы уже говорили, наша компания на тот момент (как, в принципе, и сейчас) стремилась открыть много магазинов, поэтому пришлось оптимизировать складские процессы для увеличения пропускной способности (больше товаров за меньшее количество времени). Это непростая задача, и решить её, просто увеличив персонал, было нельзя хотя бы потому, что все эти люди будут друг другу мешать. Таким образом, мы начали думать о внедрении информационной системы WMS (warehouse management system). Как и полагается, мы начали с описания целевых складских процессов и уже в самом начале обнаружили непаханое поле для улучшений в процессе приёмки товара. Нужно было отработать процессы на одном из складов, чтобы потом накатить их на остальные.

Приёмка это одна из первых больших операций на складе. Она бывает нескольких типов: когда мы просто пересчитываем количество грузовых мест и когда нам необходимо, помимо этого, посчитать, сколько и каких артикулов лежит на каждой паллете. Большая часть товаров у нас проходит по потоку кросс-докинг. Это когда товары приезжают на склад от поставщика, а склад выступает в роли роутера и старается тут же переотправить их на конечного получателя (магазин). Есть и другие потоки, например, когда склад выступает в роли кэша или в роли накопителя (нужно положить поставку в сток, разделить на части и постепенно вывозить в магазины). Наверное, про работу со стоком лучше расскажут мои коллеги, которые занимаются математическими моделями оптимизации остатков. Но тут сюрприз! Проблемы стали возникать чисто на ручных операциях.

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

Какие были потери? Их было море. Во-первых, работники склада получали бумажные документы. Они ориентировались и принимали решения, что делать с поставкой, по ним. Во-вторых, они считали паллеты вручную и в этих же товарных накладных отмечали количество. Потом заполненные бланки приёмки относились к компьютеру, где данные забивались в XLS-файл. Данные из этого файла затем импортировались в ERP, и только тогда наше ИТ-ядро по факту видело товар. Мы имели очень мало метаданных о заказе вроде времени прибытия транспорта, либо эти данные были неточными.

Первое, что мы сделали, это начали автоматизировать сами склады так, чтобы у них появилась поддержка процессов (понадобилось поставить кучу софта, железа наподобие мобильных сканеров штрихкодов, развернуть инфраструктуру для всего этого). Потом связали эти системы с ERP через шину. В конечном итоге информация о наличии товара обновляется в системе, когда грузчик проводит сканером штрихкода по паллете на приехавшем грузовике.

Стало так:

  1. Поставщик сам заполняет данные о том, что отправляет к нам и когда. Для этого есть связка из SWP и EDI-порталов. То есть магазин публикует заказ, а поставщики берутся выполнить заявку и поставить товар в нужном количестве. Они же при отправке товара указывают состав паллет в фуре и всю необходимую информацию логистического характера.
  2. Когда машина уехала от поставщика к нам, мы уже знаем, какой товар к нам идёт; более того, с поставщиками налажен электронный документооборот, поэтому мы знаем, что УПД уже подписан. Готовится схема оптимального перемещения этого товара: если это кросс-докинг, то мы уже заказали транспорт со склада, рассчитывая на товар, а также для всех логистических потоков мы уже определили, какое количество складских ресурсов нам понадобится для обработки поставок. В деталях для кросс-докинга предварительный план по транспорту со склада делается на более раннем этапе, когда поставщик только зарезервировал слот на поставку в системе управления складскими воротами (YMS yard management system), которая интегрирована с порталом поставщика. Информация приходит в YMS сразу.
  3. YMS получает номер грузовика (если быть точнее, то номер отгрузки из SWP) и записывает водителя на приёмку, то есть отводит ему необходимый слот времени. То есть теперь водителю, который приехал вовремя, не нужно ждать живой очереди, а под него отведены его законное время и док разгрузки. Это позволило нам, кроме всего прочего, оптимально распределять грузовики по территории и эффективнее использовать разгрузочные слоты. А ещё, поскольку мы заранее составляем график, кто, куда и когда приедет, то знаем, сколько людей и где нужно. То есть это ещё связано с рабочими графиками сотрудников склада.
  4. В итоге этой магии грузчики уже не нуждаются в дополнительной маршрутизации, а лишь ожидают машины для их разгрузки. Фактически их инструмент терминал говорит им, что делать и когда. На уровне абстракции это как API грузчика, но в human-computer interaction-модели. Момент сканирования первой паллеты с грузовика это ещё запись метаданных по поставке.
  5. Разгрузка пока делается всё так же руками, но по каждой паллете грузчик проводит сканером штрихкодов и подтверждает, что данные этикетки в порядке. Система контролирует, чтобы это была правильная паллета, которую мы ожидаем. К концу разгрузки в системе будет точный пересчёт всех грузовых мест. На этой стадии ещё отсеивается брак: если есть явные повреждения транспортной тары, то достаточно просто отметить это в процессе разгрузки или вовсе не принять этот товар, если он совсем негодный.
  6. Раньше паллеты пересчитывались в зоне разгрузки после того, как все будут выгружены из машины. Сейчас уже процесс физической выгрузки является пересчётом. Брак мы возвращаем сразу же, если он очевидный. Если он неочевидный и обнаруживается потом, то мы накапливаем его в специальный буфер на складе. Гораздо быстрее прокинуть паллету дальше в процесс, собрать с десяток таких и дать возможность поставщику забрать всё сразу за один отдельный приезд. Некоторые виды брака переводятся в зону утилизации (это часто касается зарубежных поставщиков, которым проще получить фотографии и прислать новый товар, чем принимать его обратно через границу).
  7. В конце разгрузки подписываются документы, и водитель уезжает дальше по своим делам.

В старом процессе паллеты перемещались зачастую в специальную буферную зону, где уже с ними работали: считали, регистрировали брак и так далее. Нужно было это для того, чтобы освободить док для следующей машины. Сейчас все процессы настроены так, что эта буферная зона просто не нужна. Есть выборочные пересчёты (один из примеров процесс выборочного внутритарного пересчёта для кросс-докинга на складе, реализованный в проекте Светофор), но большая часть товара обрабатывается сразу по факту приёмки и именно из дока едет на оптимальное место на складе или сразу в другой док для погрузки, если транспорт на отгрузку со склада уже прибыл. Знаю, для вас это звучит немного обыденно, но пять лет назад на огромном складе возможность обработать поставку сразу на конечные точки вроде дока погрузки для другого грузовика это нам казалось чем-то вроде космической программы.

image

Что дальше происходит с товаром?


Дальше, если это не кросс-докинг (и товар уже не уехал в буфер перед отправкой или прямо в док), то его нужно положить в сток на хранение.

Нужно определить, куда этот товар пойдёт, в какую ячейку хранения. В старом процессе нужно было зрительно определить, в какой зоне мы храним товары данного типа, и потом выбрать там место и отвезти, положить, записать, что положили. Сейчас у нас настроены маршруты размещения по каждому товару по топологии. Мы знаем, какой товар в какую зону и в какую ячейку должен попасть, знаем, сколько ячеек занять дополнительно рядом, если это негабарит. Человек подходит к паллете и сканирует её SSCC с помощью ТСД. Сканер показывает: Вези в А101-0001-002. Дальше он везёт туда и отмечает, что положил, тыкая сканером в код на месте. Система проверяет, что всё правильно, и отмечает. Ничего писать не нужно.

На этом первая часть работы с товаром заканчивается. Дальше магазин готов забирать его со склада. И это порождает следующий процесс, про который лучше расскажут коллеги из отдела снабжения.

Итак, в системе сток обновляется в момент приёмки заказа. А запас ячейки в момент постановки паллеты в неё. То есть мы всегда знаем, сколько товара есть на складе итого и где какой лежит конкретно.

Очень много потоков работает напрямую в хабы (региональные перевалочные склады), потому что у нас много местных поставщиков в каждом регионе. Те же кондиционеры из Воронежа удобнее ставить не на федеральный склад, а сразу в хабы на местах, если это быстрее.

Обратные потоки брака тоже слегка оптимизированы: если товар на кросс-докинге, то поставщик может забрать его со склада в Москве. Если брак вскрылся уже после открытия транспортной упаковки (и снаружи это было непонятно, то есть он появился не по вине транспортников), то есть зоны возврата в каждом магазине. Брак можно забросить на федеральный склад, а можно отдавать поставщику прямо из магазина. Второе случается чаще.

Ещё один процесс, который сейчас нуждается в оптимизации, это обработка непроданных сезонных товаров. Дело в том, что у нас есть два важных сезона: Новый год и время сада-огорода. То есть в январе мы получаем на РЦ нереализованные искусственные ёлки и гирлянды, а к зиме газонокосилки и другие сезонные товары, которые нужно сохранить, если они выдержат ещё год. По идее, нужно распродавать их полностью в конце сезона либо отдавать кому-то ещё, а не тащить обратно на склад вот это та часть, куда у нас ещё не дошли руки.

За пять лет мы сократили время приёмки товара (разгрузку машины) в четыре раза и ускорили ряд других процессов, что в общей сложности улучшило оборачиваемость кросс-докинга чуть больше, чем вдвое. Наша задача оптимизация, чтобы снизить запас и не замораживать деньги на складе. И дали возможность магазинам получать нужный товар чуть более вовремя.

По складским процессам большие улучшения заключались в том, чтобы автоматизировать то, что раньше было бумагой, избавиться от лишних этапов в процессе за счёт оборудования и правильно настроенных процессов и соединить все ИТ-системы компании в единое целое, чтобы заказ из ERP (например, в магазине чего-то не хватает на третьей полке слева) в конечном итоге превращался в конкретные действия в системе складского хранения, заказа транспорта и так далее. Сейчас оптимизация больше касается тех процессов, до которых мы ещё не добирались, и математики прогнозирования. То есть эпоха бурного внедрения закончилась, мы сделали те 30 % работы, которые дали 60 % результата, и дальше надо постепенно покрывать всё остальное. Либо двигаться на другие участки, если там можно сделать больше.

Ну и если считать в спасённых деревьях, то переход поставщиков на EDI-порталы тоже очень много дал. Сейчас практически все поставщики не звонят и не общаются с менеджером, а сами в личном кабинете смотрят заказы, подтверждают их и везут товар. По возможности отказываемся от бумаги, с 2014 года уже 98 % поставщиков на электронном документообороте. В общей сложности это сохранённые 3 000 деревьев за год только на отказе от распечатки всех нужных бумаг. Но это без учёта тепла от процессоров, но и без учёта сэкономленного рабочего времени людей вроде тех же менеджеров на телефоне.

За пять лет у нас стало в четыре раза больше магазинов, в три раза больше различных документов, и, если бы не было EDI, у нас было бы в три раза больше бухгалтеров.

Мы не останавливаемся на достигнутом и продолжаем подключать к EDI новые сообщения, новых поставщиков к электронному документообороту.

В прошлом году мы открыли крупнейший распределительный центр в Европе 140 тыс. кв. м и взялись за его механизацию. Об этом я расскажу в другой статье.
Подробнее..

Платформа услуг ремонтник и монтажник прилагаются к покупкам как сервис

08.10.2020 14:21:11 | Автор: admin
image

Смонтировать дверь это покруче, чем ТЗ на фичу. Никто не знает, как монтировать дверь правильно. Сколько это стоит, для какой двери и в какой обстановке. Как проверить качество работы. Какая должна быть гарантия. Какая квалификация мастера нужна, чтобы он вообще мог это делать.

Это всё нужно было для проекта, когда в Леруа Мерлен можно купить не только привычный товар для ремонта и обустройства дома, но и услуги. Моя команда прикрутила эти самые услуги к магазинам и корзине сайта. Например, если купили дверь сразу можно заказать её монтаж. Купили водонагреватель можно сделать так, что мастер приедет установить его вам.

Понадобилось собрать платформу, которая соединяет покупателей, исполнителей услуг и магазин, товары и услуги.

Как это работает


  1. Покупатель покупает что-то в магазине, например, обои. Или кухню.
  2. Дальше он может заказать к этому чему-то сразу мастера, который приедет и всё сделает. Поклеит обои, установит кухню.
  3. Оплата идёт от вас нам прямо с кассы. Это работает уже во всех магазинах. И даже начали создавать первые заказы на сайте.
  4. Мы Леруа Мерлен как компания в целом даём клиенту гарантию на работы этого мастера.
  5. Когда мастер делает работу, он снимает отчёт, из которого видно, что всё выполнено по стандарту.
  6. Вы подтверждаете качество работы, расписываясь в акте.
  7. Мастер подгружает акты через своё приложение.
  8. Мы звоним вам уточнить, всё ли в порядке. Если всё хорошо переводим оплату мастеру или его компании.
  9. В случае каких-то проблем во время гарантийного срока, даже если этот конкретный мастер покинул наш бренный мир как специалист, к вам приедет для гарантийного обслуживания другой мастер, которого мы оплатим.

Если надо сделать всё в два этапа (с замерщиком, потом покупкой и потом монтажом) тоже не проблема.

Конечная цель чтобы вы как клиент могли получить нужную услугу с нужным качеством за земные деньги, в магазинах была возможность продавать услуги сразу вместе с товаром (что даёт явное преимущество магазину), а подрядчики могли получать через нас заказы (что переключает часть рынка на нас, и это один из стратегических шагов).

В Москве мы делаем всё через партнёра hands.ru, но со своими стандартами. В регионах работаем напрямую с юрлицами (это не конкретные мастера, а компании-специалисты, которые оказывают такие услуги, обычно по пять десять мастеров каждая).

Благодаря нашим партнёрам платформам исполнителей и локальным специализированным ИП и ООО мы смогли собрать достаточно большую базу мастеров: около 3 000 человек по всей стране.

Цена закрепляется в момент покупки и больше не меняется. Дополнительные работы оплачиваются напрямую мастеру.

В конце работы клиент может повлиять на рейтинг мастера. Плохо работающих мастеров мы отсеиваем. Клиент не выбирает мастера, он предоставляется как стандартизированный сервис. Есть жёсткое описание, что делается в каждой услуге.

Как шёл проект


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

Запуск команды февраль 2018 года. До этого делали тесты на нескольких магазинах с profi.ru, смотрели, что именно из услуг люди готовы покупать в рознице. В июне 2018-го был первый магазин с продажами через платформу. В марте прошлого года закончили роллаут на все региональные магазины.

Задача ставилась до kick-off продукта так: автоматизация управления услугами от создания заказа до сбора обратной связи клиентов. На тот момент услуги уже были, но их было мало как в ассортименте, так и в количестве заказов. Не было специального интерфейса для продавца магазина, не было ничего. То есть, по сути, каждая услуга рассматривалась как исключение и обрабатывалась вручную.

Сейчас есть создание и исполнение заказа, активация услуг и управление ценой в магазине, конфигуратор заказа для продавца, автоматическая диспетчеризация заказа исполнителю услуг после оплаты, система управления заказом, состоящая из бэк-офиса для исполнителя и реестра заказов для магазина, контроль статусов заказа, отчёты, индекс удовлетворённости клиентов, подбор и регистрация исполнителей услуг, регистрация новых подрядчиков. Ну и сервисы передачи данных, API и вообще всё то, что нужно нормальному проекту федерального уровня.

Всё то, что касается автоматизации, поддавалось планированию легко. Мы же ИТ-специалисты, мы умеем рисовать роадмапы и делать беклог. Особенно когда нет бюрократии и не надо ждать других команд (а у нас почти всё сделано в инфраструктуре так, чтобы можно было почерпнуть любые данные из шины или отправить любые данные в шину на API микросервисов).

Сложности были с физическим миром.

Как я уже говорила, пришлось заново разрабатывать стандарты на то, как должна быть выполнена работа, что в неё входит и как её по шагам делать. Это очень похоже на работу аутсорсера-админа: что такое сделать бекап, что такое развернуть рабочее место (и входит ли туда монтаж розетки для компьютера) и так далее.

В итоге появился стандартный список услуг на 20 категорий с гарантиями. Сейчас в списке больше 30 категорий услуг, а это более 600 видов разных работ. Мы постепенно прорабатываем стандарты со специалистами, находим мастеров, сертифицируем на важные услуги (точнее, это делает компания-эксперт, наш подрядчик), раскатываем по стране.

Осталось научить их делать API, и мы приблизимся к захвату мира ещё на шаг.

Архитектура


Архитектура платформы микросервисы (как и везде в Леруа). БЭК на Jave + Springboot. Развёрнуто в контейнерах докера. А фронт React-redux и на сайт в общий стек. Партнёры дают бэк-офис для регионов, то есть приложение мастера и подрядчика (но не дают мастеров иначе как в Москве).

Наш модуль оркестратора (работа с шиной и API разных сервисов) среди прочего защищает нас от DDoS и полностью автоматизирует обмен. Мы отдельно делаем интеграцию подрядчика с ним и потом делаем уже разработку внутри себя, обращаясь к оркестратору. Нет смысла, чтобы разработчики сервиса занимались интеграцией, это могут сделать аналитики, проверить всю доступность и дать спокойно писать код.

Итог


Теперь вы можете заказать услуги вместе с товаром из Леруа Мерлен. Мы отвечаем за качество. Платформа продолжает развиваться по ассортименту. Но уже понятно, что это всё работает, и дальше стоит только вопрос развития и стандартизации услуг.

Ребята из команды Платформа услуг выступают завтра на конференции Analyst Days с докладом Аналитик и дизайнер. Есть ли разница, приходите послушать!
Подробнее..

Как попадает товар в магазины Леруа Мерлен с точки зрения математики заказа

15.12.2020 16:09:41 | Автор: admin
image
Ячейка пикинга на первом этаже стеллажа

Вы берёте один из смесителей для кухни у нас в магазине и уносите его под мышкой к кассе. На кассе вам делают документ о продаже, и в этот момент уменьшается количество этого товара на остатках в системах и на сайте.

Ночью каждый магазин считает прогноз по заказам на следующий период. Точнее, каждую неделю просчитывается прогноз на один год вперёд, а из него каждую ночь рассчитываются заказы для управляющего магазина. Скрипт видит, что кто-то купил смеситель, и если продажи пойдут такими темпами (тут целый блок сложной модели, что считать такими темпами и на каком периоде), то смесители закончатся через семь дней. Это значит, что нужно сформировать следующий заказ, чтобы их привезли.

Большая часть товаров едет в магазины либо по потоку кросс-докинга (склад перебрасывает паллеты, не открывая и не сохраняя их у себя на стоке), либо напрямую от местных поставщиков. Частично мы эти процессы уже разбирали в прошлом посте. Рассмотрим сложный случай, когда магазину смесители нужны из паллеты, которая лежит на складе в Москве.

Сложность в том, что паллета это довольно много смесителей. А в магазин нужно привезти 50 штук, скажем. Не везти же её целиком? И вот появляется процесс пикинга, когда паллета снимается с ячейки, кладётся вниз, а потом из неё достаётся вложенная тара. Это может быть транспортный короб, иннер и штука. Штуками распределительный центр почти никогда не оперирует, за исключением редкого и дорогого оборудования. Для единиц нужны фулфилмент-центры, но это уже немного другая часть логистики, и в этом посте про них не будет.

В конечном итоге, когда магазину нужен товар, потребность считается в системе прогнозирования GOLD GWR, а в ERP (Oracle RMS) появляется итоговый документ с тем, сколько чего нужно привезти и куда. Он попадает в систему управления складом (WMS) в виде задания Отгрузить туда-то и в систему управления транспортом (TMS) в виде директивы Забрать это на складе таком-то и отвезти в магазин такой-то. Дальше задача TMS обеспечить транспорт (для этого отправляется заявка логистам), а задача WMS обеспечить начало загрузки в момент открытия дока под приехавший грузовик.

Товар может быть отгружен целой паллетой или вложенной единицей хранения. Самое удобное для склада конечно, грузить целую. Потому что, когда нужна вложенная единица хранения (короб), это требует времени и ручных операций. То есть стоит каких-то денег и снижает оборачиваемость товара (не даёт использовать склад под что-то другое).

Склад уместно сравнить с базой данных. Она должна как можно быстрее давать ответ приложениям, которые её дергают. Часть её in-memory это процессы кросс-докинга, которые вообще не требуют записи в сток. Ещё часть кеш: это специальные буферы, где товар может немного полежать перед следующей операцией. И ещё часть хранилище, сток, где товар лежит для дальнейшей обработки.

Задача склада всегда уменьшить сток и увеличить его оборачиваемость (т. е. уменьшать срок его хранения). Чем меньше товара лежит без дела, тем выгоднее для компании. Но при этом, когда нужный товар кто-то запросил, а склад его дать не может, это тоже провал. Между двумя этими крайностями и работает вся математика.

Так что с моим смесителем?


Идеальный процесс с точки зрения оптимизации стока использовать склад только как роутер. Идеальный процесс с точки зрения доступности сохранить весь товар в мире на складе и выдавать по мере надобности.

В конечном итоге компромисс для смесителей выглядит так: выделяется математический оптимум того, как их везти. Иногда бывает выгодно вообще не закупать конкретно эту модель (потому что есть прямой аналог), иногда действительно выгодно закупить и привезти целую паллету и медленно её распродавать со склада магазина. Но чаще всего бывает выгодно везти транспортный короб. Если в коробе 52 единицы, а система магазина подсчитала, что нужно 50, то решение очевидно: будет заказано чуть больше 52. А вот если магазину нужно три единицы, а короб 100 единиц, то уже встаёт вопрос о целесообразности минимума.

Мы не используем алгоритм минимакс для магазинов, но сейчас его разрабатываем и будем применять в основном для малых форматов, т. к. он хорошо подходит, когда нет или мало места на складе магазина и нужно пополнять сразу полку. Для малого формата будет: максимум вместимость полки, минимум количество триггер для заказа, если сток опускается на меньшее или равное значение мы делаем заказ до максимума полки.

Формирование потребности у нас работает по принципу покрытия заказа и каждый заказ покрывает товарным запасом до даты доставки следующего заказа.

Например, делаем заказ сегодня, 01.01.2021 года, и у нас на остатке 18 штук. Дата доставки такого заказа будет 07.01.2021 (поставщик возит за одну неделю). Дата следующего заказа 14.01.2021, доставки 21.01.2021.

Представим, что мы продаём по две штуки в день всегда.

Значит, мы должны заказом 01.01.2021 покрыть запасом аж до 21.01.2021, иначе нам нечего будет продавать.

Поэтому до 21.01.2021 нам потребуется 21 * 2 = 42 штуки.

18 штук у нас на остатках есть, поэтому заказываем 24 штуки 01.01.2021.

Конечно же, в каждом заказе есть ещё и доля страхового запаса.

Дальше данные заказа вписываются в тарность. То есть если нужно 45, 48 или 49 штук заказывается минимальный короб в 50. Если нужно 55 штук нужно заказать два короба или оптимизировать модель. Как видите, чем меньше квантование (чем чаще поставки и чем больше управляемых единиц вложенности в таре), тем точнее оптимизация. Поэтому поставки в магазины делаются как можно чаще, и поэтому появляются подкороба в коробах.

image

Как короб попадает в грузовик?


Итак, из магазина задание поступает в ERP, а оттуда на склад. Склад должен обеспечить упакованный товар в доке, когда туда подъедет машина, заказанная TMS-системой, и заберёт его в магазин.

На складе все заказы кластеризуются, чтобы затем оптимизировать маршруты заданий каждому отдельному грузчику. Для каждого погрузчика считается маршрут, где он быстрее всего заберёт весь нужный товар и не будет мешать другим. Функция общее счастье, то есть снижение общего времени выполнения всех операций в целом.

Дальше люди получают конкретные координаты целей (паллет в ячейках) и действия с ними в виде алгоритма. И выполняют их. Разбивается это на шаги в духе: Езжай в ячейку такую-то, возьми паллету. Человек едет, сканирует там её штрихкод, система проверяет, что всё правильно, и даёт следующее действие: Вези туда-то. И так всё время. Мы управляем мобильными терминалами, а грузчики их слушаются. Так мы имеем своего рода API к людям на складе. Следующий шаг, конечно, убрать людей, но про это в другой раз.

Когда нужно разобрать паллету, происходит отдельный процесс пикинга. Мы берём её с ячейки хранения вверху и спускаем в нижнюю ячейку. Оттуда берём два короба смесителей, из другой три короба другого товара (пускай будут гвозди, они интересны тем, что упакованы на развес) и так далее. Всё это с терминалами сканируется и проверяется на каждом шаге. В итоге всё равно получается одна паллета для дока, только уже с разными коробами.

Когда она готова, на неё печатается отдельная этикетка на терминале, и она теперь учитывается в системе как отдельное место. Можно класть в буфер отгрузки или прямо на док.

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

Тот, кто кладёт паллету сверху вниз, и тот, кто берёт из ячейки пикинга короба, это два разных человека. Пикинер только набирает товар, а другой человек на погрузчике только жонглирует паллетами.

image

Откуда взялся грузовик?


Из инструмента TMS, который пару раз в день консолидирует все потребности всех магазинов и нарезает потребности в грузовиках. Там считается не только заказ на каждый узел графа, но и оптимальный маршрут по графу разного транспорта. Магазин размещает заказы один раз в неделю, планирование транспорта начинается за два дня до поставки. За два дня уже понятно, какой конкретно транспорт подойдёт к доку, что и в каком порядке в него класть и как он поедет. Эта автоматизация касается всего: когда этот грузовик только приедет на склад, система по номеру скажет, к каким воротам ему ехать и когда, то есть мы управляем ещё оптимизацией движения транспорта у доков.

image

Мы учитываем при этом не только фактическое наличие товара на складе, но и тот товар, который на него только едет, это тоже важная часть оптимизации.

Как принимается решение, что товар нужно пополнить на такое-то количество?


Сначала мы смотрим, будет ли ещё закупаться этот товар. Для каждого магазина есть матрицы того, что в нём продаётся, а что нет. Это региональные особенности из-за местных поставщиков, регионального спроса (очень дорогая профессиональная техника для ремонта чаще всего нужна только в Москве), культурных особенностей (в Казахстане огромная зона ковров для декора) и так далее.

Товар может перестать закупаться в магазин из-за окончания сезона: мало кому нужны газонокосилки зимой и ёлочные игрушки летом.

Если товар будет закупаться считаем, сколько его нужно на следующий период. Это довольно интересная модель, которая учитывает историю продаж товара (это главный весовой коэффициент) и учитывает профиль сезонности семейства, в которое входит этот товар. Когда по товару есть большая история продаж в этом и других магазинах, модель строится гладко и попасть можно с точностью до штуки.

Когда товар новый, истории нет. Её нужно либо откуда-то взять, либо управлять товаром вручную. В случае когда это прямой аналог чего-то известного, можно просто указать ему товар-пару в карточке. Были перчатки рабочие одного производителя, пришли другого (или просто артикул либо модель поменялись) указываем пару прошлую, и модель опирается на историю другого товара. Через шесть недель (это магическая константа) товар автоматически отвязывается и у него начинает считаться только собственная история.

Когда товар совсем новый и вообще неясно, что с ним делать, привозится какое-то количество в магазины по ручному прогнозу. Дальше набирается история. У коллег из моды про это есть огромная математика, а у нас нет: мы всё же про ремонт, и у нас мода только-только начала появляться в области дизайна кухонных линеек и вообще декора. В остальном спрос плавный почти на все группы товаров. Сезонные изменения учитываются довольно легко: для нового товара по группе, для старых по их большой истории.

Поскольку мы пока умеем правильно считать далеко не всё и система может ошибаться, итоговый документ отправляется на утверждение администратору магазина (или человеку, который отвечает за поставки товара). Точнее, обычно это делают менеджеры отделов (в магазине 15 отделов: инструменты, скобянка, сантехника и т. п.). Им выводятся все позиции без исключения, которые могут быть поставлены в рамках отдела, и их количество. То есть если в заказе 0, то там есть позиция и стоит ноль.

Мы сейчас уже тестируем автозаказ для некоторых поставщиков. Т. к. есть случаи, когда система предлагает к заказу количество, а менеджеры магазинов подтверждают параметры без изменений и продолжительное время, то пользователю незачем обрабатывать такие заказы в ERP и мы автоматически отправляем их поставщикам. Но есть группа координаторов, которые поддерживают магазины и отслеживают корректность таких заказов.

По согласованию можно убрать товар (если заканчивается сезон Нового года, то не нужно дозаказывать ещё 100 искусственных ёлок) или добавить тот, который до этого не продавался. Это требует участия ещё человека из офиса.

В 99,9 % случаев всё решается на месте в магазине, и администраторы только увеличивают количество того товара, который, по их мнению, продаётся больше и быстрее, чем система может предсказать. Всё же 5060 тысяч SKU очень сложно обрабатывать только вручную. В итоге они вносят минимальные изменения, которые помогают увеличению продаж, чувствуют контроль, но не вносят человеческие ошибки. Всё делается децентрализованно, то есть каждый магазин управляет своим заказом сам за исключением редких случаев.

Есть товары, управляемые вручную. Например, нестандартные душевые кабины. Есть товары на полуавтоматическом пополнении: система по таким товарам считает предложение к заказу и количественно отображает, сколько заказать. Менеджеры магазина видят прогнозы продаж, историю продаж и при необходимости корректируют количество. Мы занимаемся уменьшением количества корректировок к предложенному заказу, чтобы пользователи делали меньше ручных операций и система формировала идеальные предложения к заказу.

Мы хорошо понимаем сезонные коэффициенты, когда один и тот же товар продаётся весь год с разной скоростью. Но при этом товары, которые часть сезона продаются много, а часть нет, обрабатываем пока не очень точно. Модель заточена под плавные сдвиги, а не резкие колебания.

Для расчёта заказа система учитывает среднюю задержку поставщика. Если мы видим, что поставщик регулярно опаздывает на пять дней, то помимо штрафов, GOLD учтёт это в формировании заказа и закажет больше страхового запаса, чтобы учесть возможные опоздания. Дополнительно мы надстроили анализ ежедневных остатков: если товар отсутствовал на полке большое количество дней в неделе и если эти дни имели значительную вероятность продаж, то система видит это и понимает, что такую неделю для учёта истории продаж нужно исключить, а не понижать прогноз.

Откуда склад знает, сколько товара взять и откуда? Теперь вы знаете, как магазин определяет свои потребности. Вы знаете, как это консолидируется и отправляется на склад, а склад должен достать транспорт и отгрузить всё это. Но есть ещё слой: складу нужно всё это обработать.

Прогноз продаж по каждому магазину и прогноз логистики с учётом маршрутов и использования разных видов транспорта (рефрижераторов, бортов, боковых и так далее) даёт понимание, сколько товара потребуется и где. И к каким датам его нужно достать.

Дальше ещё слой оптимизации: где его выгоднее достать к этому времени? Какой партией? Некоторые поставщики ставят минимальный объём отгрузки (килограмм гвоздей заказать не выйдет), некоторые дают объёмные скидки, ретробонусы и прочие спецусловия. Надо считать, как везти товар и откуда. Эту тему мы только начали прорабатывать, пока она в довольно простом виде. Думаю, через год будет что рассказать.

Иногда товар выгоднее складировать большой партией, чем покупать несколько маленьких, иногда выгоднее разбивать поставку на десять локальных партий у разных поставщиков. И так далее.

Надо знать процент брака, чтобы не было такого, что товар приехал, а продать его не получается. Склад за счёт больших чисел сглаживает все эти колебания.

Если не прогнозировать всё точно, то уменьшается маржинальность категории товара (накапливается ненужный сток). Чем больше ассортимент тем больше вещей надо отслеживать. Когда речь идёт про десятки тысяч позиций на рынке, где есть глобальные изменения (а они есть), то без хорошей автоматизации просто не обойтись.
Подробнее..

DIY Mobile Day в формате Live coding

15.04.2021 10:21:55 | Автор: admin

В 2020 мы перезапустили мобильное приложение Леруа Мерлен и добавили в него Kotlin Multiplatform, Jetpack Navigation и многое другое. Сейчас наше приложение самое популярное среди DIY (Do It Yourself). На пути запуска мы столкнулись с рядом сложностей и получили классный опыт, которым хотим поделиться.

Ведущий Алексей Гладков, технический архитектор Леруа Мерлен.

Специальный гость Катя Петрова из Jetbrains! Расскажет о трендах и планах развития KMM.


Описание

19:10-19:50 Live coding

Мурагер Жаилхан, разработчик Леруа Мерлен

Пишем сложную навигацию на jetpack Navigation

19:50-20:30 Live coding

Вячеслав Корниенко, разработчик Леруа Мерлен

Покрываем тестами фичу в KMM

20:3020:50 regular talk

Катя Петрова, DevAdvocate, JetBrains

Что произошло после выхода в альфу, что нам принесет релиз 1.5 и что нас ждет в будущем.

Когда: 22 апреля, 19:0021:00 мск, Четверг
Ссылка на мероприятие: https://leroy-merlin.timepad.ru/event/1607225/

Подробнее..

Некоторые особенности HR-политики в ИТ-ориентированной рознице

18.06.2020 14:14:33 | Автор: admin
Привет! Меня зовут Катя, я занимаюсь автоматизацией HR-процессов Леруа Мерлен в России. Сразу скажу: сама я не пишу код, но участвую в каждом проекте. Хочу рассказать про некоторые особенности того, как это у нас устроено.

Самый частый вопрос ко мне от новых разработчиков: правда ли, что надо работать в магазине до того, как стать разработчиком Леруа? Правда, вне зависимости от того, с чем будет связана ваша работа. Типовое обучение любого сотрудника подразумевает интеграцию в магазине, то есть погружение в процессы розницы. Чем выше должность, тем больше времени на разных линейных должностях придётся проработать. Например, наш руководитель разработки четыре месяца провёл в двух магазинах, работая продавцом, участвуя в инвентаризациях и так далее.

Второй вопрос: правда ли, что мы выдаём телефоны всем сотрудникам? Правда. У нас есть корпоративные телефоны, которые выдаются всем без исключения сотрудникам магазинов и офиса.

Третий вопрос обычно про соцпакет. Да, мы выкинули оттуда всё, кроме медстраховки и еды. Каждый сотрудник в зависимости от стажа работы получает очки, на которые можно докупать остальные нужные услуги по меню. Это значит, что можно взять дополнительный полис ДМС с отличным покрытием для родственника, либо получить компенсацию отпуска (билетов, путёвок, отелей), либо получить что-то ещё нужное именно вам.

Коммуникации


У нас есть корпоративная соцсеть Facebook Work Place. На неё мы перешли в конце 2019 года с собственной разработки на базе SharePoint. Тем не менее на практике люди в магазинах общаются в Вайбере или Вотсапе, а в офисе в Телеграме. Эти приложения по умолчанию входят в корпоративный образ ОС для телефона.

У соцсети перед мессенджерами есть важное преимущество: можно найти человека, которого ты ещё не знаешь, и отправить ему личное сообщение. Когда какая-нибудь ИТ-команда запускает проект на всю страну, можно создать группу, доступную всем сотрудникам, добавить туда определённых людей и, даже не зная их телефонов и емейлов, отправить им инструкции или другие документы, ответить на вопросы по запуску и т. д.

Очень хорошо группы в мессенджерах и соцсети работают для обсуждения вопросов. Например, есть проект поддержки сотрудников с ипотекой. Или по документам для кадрового отдела. Сотрудник спрашивает прямо в общем чате, что нужно, ему отвечают. Остальные уже знают, что делать в такой ситуации. Оказывается, такой чат учит быстро и на конкретных жизненных примерах. Даже если всё есть в инструкции, то вопросы всё равно возникают, но потом редко повторяются.

Ещё соцсеть частично заменяет документооборот для совместной работы над документами и для согласований.

image
Ещё у нас есть чаты в офлайне.

Для хранения документов к соцсети прикручен шерпоинт. Ходят легенды о том, что у нас где-то есть архив аниме, но он хранится либо на локальных дисках, либо на внешних SaaS, но не в соцсети.

Сервисы самообслуживания


Мы очень много ездим в командировки, и поэтому это первое, что попало под автоматизацию. Всё, что связано с поездками, реализовано на портале. Оставляешь заявку и всё, можно ехать. Вернулся прикрепляешь все документы и отправляешь в бухгалтерию.

image
Сейчас есть новый мастер уже в приложении с телефона, что ещё удобнее.

image

Там же, на портале, есть общехозяйственные расходы (это когда надо купить маркеры или что-то ещё), чтобы компания возместила затраты. Можно запросить справку, спланировать свой отпуск, узнать о своих начислениях.

image

image

Следующее, что попало под автоматизацию после формирования документов, это все процессы найма. Мы работаем с очень большим количеством людей в магазинах. Раньше, например, не было сквозных карточек сотрудников, и результаты собеседования никуда не вносились. Теперь, если человек переехал в другой город, то кадровый специалист увидит его результаты собеседования, даже если по каким-то причинам он в штат не вошёл, и не будет второй раз задавать тех же вопросов.

Обучение


Есть обучение, которое предоставляется компанией: у нас сильный набор курсов по развитию софт-скиллс. Причём штатный HR может подсказать, что и в какой последовательности лучше развивать.

В части профессионального обучения это отдаётся на откуп сотрудникам. Вы можете найти интересный курс, согласовать с руководителем и дальше идти учиться.

Всё обучение, которое так или иначе связано с работой в компании, идёт за счёт компании.

Баллы соцпакета можно использовать на любое другое обучение, например, чтобы заниматься музыкой, нанять личного спортивного тренера и так далее.

Соцпакет


image
Наш соцпакет это меню.

История началась с того, что мы узнали, что 25 % сотрудников никогда не использовали свой полис ДМС. Просто ни одного обращения к врачу. Ещё довольно существенная доля предпочла бы получить что-то другое вместо ДМС, поскольку использовала эту услугу пару раз за год.

Тогда мы дали возможность выбирать.

Через год работы в компании начинают приходить баллы, которые можно тратить на выбор услуг соцпакета. Например, можно вернуть обратно свой полис ДМС и вместо этого получить софинансирование билетов в отпуск по модели: 80 % оплаты от компании, 20 % от сотрудника.

В первый год из вариантов выбора только ДМС. Дальше, если сотрудник не трогает личный кабинет, по умолчанию остаётся ДМС. Но можно выбирать. В итоге только 45 % персонала оставили персональные полисы ДМС (и в это число входят те, кто работает первый год). Ещё 13 % выбрали ДМС родственникам, 28 % сотрудников предпочли вместо страхования отдых, 6 % потратили баллы ДМС на обучение, 6 % на спорт.

image
Услуги одинаковы для всех независимо от должности, считается только опыт. Один год стажа можно выбрать одну опцию (например, детский лагерь ребёнку за рубежом), три года две опции (своё ДМС и ДМС ребёнку), пять лет три (лагерь ребёнку, ДМС ребёнку и оплата путёвки в отпуск).

В ДМС есть стоматология и экстренная госпитализация, ряд небазовых операций.

Примерно для 13 % сотрудников очень важен спорт, поэтому в текущем офисе на территории БЦ есть спортзал, а в новом офисе, который сейчас строится, будет уже большой собственный.

Компенсации


Предположим, что вы пришли на должность JAVA-junior или middle. С первого дня полная зарплата, потому что по ТК не бывает пониженной зарплаты на испытательном сроке. Но и ждём мы от вас полной эффективности с первого дня. Премиями тоже не балуемся и оклад ими не уменьшаем (как это часто бывает, когда часть зарплаты перекладывается в необязательную премию) всё по стандартам головной организации во Франции. Премии у нас бывают: ежемесячная, ежеквартальная и ежегодная. Премия выдаётся, если не нарушать трудовую дисциплину, то есть для разработчиков это, например, не опаздывать на встречи команды (можно приходить в офис почти в любое время, но у многих команд в 11-12 часов бывает планёрка по спринтам, это тоже встреча). Вообще, конечно, это правило было принято с фокусом на гипермаркеты, где дисциплина крайне важна для всего персонала магазина. Офису правило досталось в рамках общих свойств класса.

Ежеквартальная коллективная премия называется За прогресс, она вычисляется из сравнения с предыдущим годом, то есть нас в этом году к нам же за прошлый год. Там считается прогресс по бизнес-показателям, но также большое влияние имеет удовлетворённость клиентов на уровне магазинов и регионов, для ИТ-команд удовлетворённость внутренних пользователей.

Бывает ещё поощрение от головной компании, но это уже бонусы, которые фактически проходят мимо нашего отдела.

Есть компенсация питания, когда ежемесячно определённая сумма поступает на карты Sodexo. Их принимают почти все сетевые заведения и наши собственные столовые офиса и магазинов. Это очень удобно для удалённых сотрудников, поскольку можно пойти и съесть что-нибудь в ближайшем же ресторане.

Есть компенсация мобильной связи: корпоративная SIM-карта с пакетом голоса и трафика. Телефоны, которые мы всем выдаём, двухсимочные, поэтому можно спокойно использовать корпоративную в паре со своей. Сотрудникам выдаём ноутбуки, есть разные типы под потребности: для разработчиков помощнее, для мобильных сотрудников полегче, iOS-разработчики и UX-дизайнеры получают макбуки. В офисе стоят док-станции, мониторы, при необходимости даже два. Можно работать на своём девайсе, это не запрещается. Парк ноутбуков регулярно обновляется, старую технику утилизируем или отдаём на благотворительность.

Отпуск стандартный. У многих в договоре прописан ненормированный день, что даёт три дополнительных дня к отпуску. Отпуск можно разбивать по-разному, можно брать три недели (часто подводные камни в том, что отпуск просят бить отрезками по две недели у нас не так). При выпадении отпуска на праздники он продлевается.

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

Удалённая работа


До пандемии удалённая работа была в основном точечной: можно было уйти на несколько дней или месяцев домой. Есть полностью удалённые сотрудники, которые приезжают в офис два раза в месяц на командные встречи. В остальное время работают из других городов. Оклада это не меняет. Выход на удалёнку это вопрос доверия и взаимоотношений в команде. Часть разработчиков делает так: Я завтра планирую работать удалённо. Если из-за отсутствия человека на месте пострадает сервис, то в следующий раз удалённой работы может и не быть. По пятницам многие работали на удалёнке. Из последнего была длительная удалёнка у одного из разработчиков, потому что он улетал в Америку.

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

Рекомендательный движок за 2 строчки кода

04.02.2021 10:09:11 | Автор: admin

История

Некоторое время назад IT в Леруа Мерлен претерпело довольно много изменений. Было перепилено довольно много систем, практически всё писались с нуля. Одной из таких систем была Публикационная Платформа, которая занималась передачей данных о продуктах на сайт и пришла на замену старой системе OPUS. Помимо всего прочего, в этой системе были микросервисы, отвечающие за рекомендации. Я не буду тратить время читателя на объяснение старой логики наполнения рекомендаций, скажу лишь, что первую половину 2020 года на официальном сайте крупнейшего ритейлера можно было увидеть следующее.

Как замену для

рихтовочного набора

Рихтовочный набор набор молотков и наковален для исправления деформаций кузова автомобиля.

сайт на полном серьёзе предлагал прикупить заготовку для выпиливания лобзиком.

А к мусорному мешку предлагалось прикупить защитный комбинезон и респиратор, и это ещё до пандемии)

В общем, было весело всем, кроме бизнеса. Встал вопрос о том, как исправить ситуацию. В то время в нашей команде дата-сайентистов не было, поэтому задача ушла простым разработчикам. И вот что мы придумали.

Проблематика

У нас есть много специалистов, которые любят свою работу и хорошо знают свой товар. Они на 100% знают, что можно предложить в качестве замены, если искомого товара нет в наличии
(похожие товары) или что ещё может понадобиться дополнительно (сопутствующие товары). Большинство этих сотрудников работает в магазинах и ежедневно консультирует наших клиентов как раз по этим вопросам, но мы не можем попросить их составлять для нас рекомендации: им есть чем заняться. Специфика ритейла заключается в том, что за год меняется до 20% ассортимента. А товаров очень много. Вбивать всё это руками не вариант, нужен другой подход.

Мы подумали, что наш клиент, совершая покупку, каждый раз сам решает, подходят ли эти обои именно к такому плинтусу или это зеркало к тумбе для ванной. А в сложных моментах его консультируют наши профессионалы. И он голосует за своё решение рублём, покупая у нас товар, почему бы не использовать эти данные? Таким образом, мы переформулировали нашу задачу так:

как из массива чеков получить информацию о том, какой товар к какому подходит?

Word2Vec

Алгоритм, который мы использовали, был разработан Google в 2013 году и называется Word2Vec.
Как следует из его названия, он переводит слово в вектор на основании контекста, на котором его обучают. При этом после обучения схожие по контексту слова будут иметь близкие векторы, а различные далёкие.

Идея перевода слов в векторы может показаться нетривиальной, тут можно привести следующую аналогию.

Допустим, есть 2 машины, BMW и Запорожец. Рассмотрим их с точки зрения габаритных характеристик. Возьмём ширину и высоту каждого и построим на двухмерной плоскости векторы, соответствующие этим машинам. Получится примерно следующее:

Расстояние между двумя векторами это степень похожести двух автомобилей. Тут мы видим, что 2 машины не так уж и сильно отличаются друг от друга, если просто стоят и занимают место в гараже.

Если же мы добавим к контексту скорость, то получим совершенно другую картину:

Теперь 2 вектора имеют куда большее расстояние, и мы понимаем, что автомобили в этом контексте уже значительно отличаются друг от друга.

Это пример того, как разные сущности в зависимости от контекста могут быть представлены различными векторами.

Для желающих подробно погрузиться в теорию на Хабре есть много статей по этой теме, например, тут и тут. Мы же вернёмся к нашим рекомендациям.

Word2Vec в рекомендациях

Как я уже говорил, наш контекст, т. е. то, на чём мы будем обучать нашу модель, это чеки. А именно текстовые файлы, каждая строчка в которых это набор артикулов товаров через пробел, которые в одном чеке были куплены:

Возникает вопрос: как в этих чеках наша модель сможет найти какие-то закономерности?
Можно представить себе следующую аналогию. Допустим, есть 2 чека:

В этих чеках все товары одинаковые, кроме 13227536 и 33444222. Что это означает?
С некой долей вероятности мы можем предположить, что было 2 клиента, которые решали одну и ту же задачу (т. к. все, кроме одного, товары одинаковые), и они её решили, но решили по-разному. Т. е. с некой долей вероятности можно предположить, что товар 13227536 может быть заменён на 33444222, т. е. это похожие товары.

Теперь рассмотрим другой случай:

Тут чек номер 2 включает в себя чек номер 1. С некой долей вероятности мы можем предположить, что также были 2 клиента, решавшие одну задачу, но клиент, оформивший второй чек, решил купить больше товаров. Тут мы можем предположить, что товары, отмеченные фиолетовым, 12355478 78856433 являются сопутствующими товарами к товарам, которые отмечены жёлтым.

Теперь перейдём к обещанным двум строчкам кода.

Код

Код для обучения модели будет выглядеть следующим образом и будет представлять из себя не что иное, как вызов библиотечной функции gensim.

model = gensim.models.Word2Vec( rаw_сhecks, size=200, window=100, min_count=10, workers=4 )

Здесь везде используются дефолтные значения. Для того, чтобы получить рекомендации к товару с product_id, нам нужно попросить модель вернуть какое-то количество айдишников продуктов, векторы которых являются ближайшими к вектору продукта product_id.

similars = model.similar_by_word( product_id, 2000 )

Это весь движок. На этой строчке модель вернёт 2000 продуктов, но, как показано выше, там будут и похожие товары, и товары сопутствующие. Для того, чтобы отделить зёрна от плевел, нам придётся написать ещё 2 строчки:

substitutes = filter_same_type( similars, product_id_type )complements = filter_different_type( similars, product_id_type )

Исходя из нашей доменной области, мы можем предположить, что один товар может заменить другой, только если у него тот же тип. Т. е. молоток мы можем заменить молотком, а не микроскопом. Поэтому из similar нужно взять товары, тип которых совпадает с типом product_id. Сопутствующие же товары, наоборот, должны быть другого типа. Вот и вся любовь.

Итоги

А/Б-тест движка для похожих товаров показал общий прирост конверсии аж на 10% и на следующий же день был установлен в продакшн на 100% трафика. Хотелось бы заметить, что это не потому, что подход какой-то революционный, просто скорее всего то, что было раньше, никуда не годилось.

А/Б-тест для сопутствующих товаров показал прирост конверсии только в определённых категориях. И мы знаем почему: наши сотрудники часть сопутствующих товаров выставляют руками, а живая экспертиза довольно часто оказывается лучше машинного обучения, особенно если алгоритм такой незамысловатый. Тем не менее практика показывает, что мы можем рассматривать машинное обучение как помощник для людей, например, там, где у коллег не хватает ресурсов.

Из вышенаписанного мы можем сделать вывод, что эксперимент был удачный. А также из-за простоты модели понятно, что этому всему ещё есть куда расти.

Минусы подхода

Как я уже говорил, основной плюс в том, что это быстро и может проработать какое-то время пока, не придут настоящие дата-сайентисты и не сделают как надо. А они к нам, кстати, пришли, но об этом я расскажу в другой раз.

Из минусов подхода можно перечислить:

  • Word2Vec вряд ли подойдёт для рекомендаций фильмов, музыки, книг и т. д. Это не про предпочтение определённого автора или жанра. В специфике DIY товары покупаются комплектом и под проект: купил обои тебе точно нужен клей. Необходимость в клее не исчезнет, а вот боевики могут в какой-то период разонравиться, и хорошая рекомендательная система должна это как можно раньше диагностировать.

  • Клиент, оказывается, не всегда прав. Есть хороший пример пары товаров, которые не подходят друг другу, но которые клиент всё равно покупает вместе, потому что названия похожие. Потом, конечно, возвращает (наш движок, кстати, не учитывает возврат), но рекомендовать эти товары на официальном сайте компания себе позволить не может. Такие моменты нужно обходить в ручном режиме.

  • Голый Word2Vec в данной статье учитывает только историю продаж. И не учитывает ничего больше. Это плохо. Например, существует много других способов находить похожие товары по схожести фотографий, похожему названию и т. д.

Подробнее..

Эволюция оркестратора микросервисов. Как переход на WebClient помог пережить пандемию

19.01.2021 10:15:06 | Автор: admin

Практически во всех проектах есть такой сервис как оркестратор. Называться он может по разному, но суть одна: всё что от него требуется, это сходить в N сервисов, забрать у них необходимую информацию, как-то агрегировать, и отдать наверх клиенту ответ. И у нас, в Леруа Мерлен, он тоже есть. На данный момент наш оркестратор - это рабочая лошадка, которую используют все кому не лень, например, фронт сайта получает через него данные по товарам для того чтобы показать их пользователю. На данный момент это один из самых высоконагруженных сервисов в нашем контуре.

Как правило, подобные сервисы не особо кого волнуют ровно до того момента, пока они не становятся узким горлышком на пути к добру, которое несут сервисы ниже уровнем. Когда же ваши доменные сервисы держат больше 2к rps, а оркестратор, который агрегирует из них информацию - всего 200, у бизнеса начинает дёргаться правый глаз, а у разработчика - чесаться руки провести рефакторинг. Давайте разбираться каким образом мы можем это сделать.

Типовая архитектура оркестратора

Классическая схема оркестратора для ритейла выглядит примерно так:

Фронт запрашивает id одного или нескольких товаров, которые хочет отобразить + id маски. Маска - это alias для списка атрибутов, необходимых для отображения. На разных страницах сайта необходимо показывать разный набор атрибутов, информация об этом лежит в отдельном микросервисе. Далее, имея список товаров и необходимых атрибутов, оркестратор идёт в микросервисы, которые информацией по этим атрибутам обладают. Плюс, надо зайти в рекомендательную систему, которая вернёт список id товаров которые мы можем порекомендовать купить вместе с основным (заменители основного и ему сопутствующие). Этот список тоже нужно обогатить атрибутами по маске, которую запросит фронт.

В общем, логика не бог весть какая, но она есть.

Варианты оптимизации

Первое, что можно тут заметить: оркестратор совершает очень много запросов. По этому, первое, о чём нужно подумать когда стоит задача разогнать сервис - это уменьшить их количество в единицу времени. Сделать это можно несколькими способами:

Прикрутить кэш

Самый выгодный способ оптимизировать запрос - это не делать его вообще. Или делать, но редко. Не каждый запрос можно кешировать - иногда нужна актуальная информация из источника. Тем не менее, мы знаем, что маски меняются довольно редко, по этому смело можем ходить в сервис раз в 5 минут, забирать всю базу (она не такая большая) и вот у нас минус один последовательный запрос.

Распараллелить запросы

Как показывает практика, большинство времени подобного рода сервисы просто ждут ответа от их доменных коллег, так что если они могут "ждать" эти ответы не по очереди, а одновременно, то хорошая идея это организовать. Если мы берём стандартный сервер tomcat или undertow, то можно сделать так, чтобы каждый запрос, принимаемый сервером порождал N потоков, каждый из которых ходил бы за своим атрибутом, а поток-родитель потом это всё бы собирал и выплёвывал наверх.

Соединить запросы

Если до первых двух пунктов мы догадались в самой первой итерации сервиса, то до возможности соединять несколько запросов в один мы дошли не сразу.

Что имеется в виду: в сервис приходит массив основных продуктов и их атрибутов, по которым нужно собрать информацию. Помимо этого, к продуктам нужно подобрать рекомендации. Сервис рекомендаций, естественно, возвращает только список продуктовых id, которые также нужно обогатить своим набором атрибутов. Атрибуты эти, отличаются от атрибутов основного продукта и тоже приходят как параметр запроса нашего оркестратора.
Фактически, в оркестратор приходит список основных продуктов и 2 маски: одна для основных запрашиваемых продуктов, и вторая для продуктов, которые мы к ним рекомендуем:

{  "products": ["product1", "product2", ..., "productN"],  "mask" : "product_detail_page_attributes",  "recommendation_mask": "name_and_photo_only"}

Проблема в том, что список id основных продуктов мы знаем сразу, а за списком id продуктов-рекомендаций ещё нужно сходить. Возникает несколько вариантов того, как это сделать:

Вариант 1:

  1. Идём с известными основными продуктами и их атрибутами по микросервисам собирать о них информацию;

  2. Параллельно с этим, идём в сервис рекомендаций, который возвращает нам список id рекомендуемых продуктов;

  3. После того, как сервис рекомендаций вернул список товаров, аналогично первому пункту, идём с уже другим набором атрибутов по сервисам и получаем всю необходимую информацию.

Тут 1 и 2 выполняются параллельно, 3 же придётся подождать первых двух. Как видно, число последовательных запросов: 2, что не так много, но, как показывает практика, проблема может заключаться в их количестве. Это то, что у нас было с самого начала, после этого мы сделали следующую оптимизацию:

Вариант 2:

  1. Сначала идём в сервис рекомендаций и получаем id продуктов-рекомендаций;

  2. После этого добавляем к основным продуктам рекомендованные товары, и к атрибутам основных товаров атрибуты товаров-рекомендаций, и уже со всем этим добром идём к доменным сервисам.

  3. После того, как мы получили информацию по всем атрибутам общего списка товаров, нам нужно в памяти разделить этот список на основные и рекомендованные, и почистить лишние атрибуты т.к. клиенту необходимы разные наборы атрибутов у разного типа товаров, а мы от доменных сервисов получили всё.

В этом варианте, как и в пером, число последовательных запросов равно 2-м. Но, число запросов совершаемых сервисом, уменьшается вдвое. С другой стороны, делая 1 жирный запрос по всем продуктам и атрибутам мы по сети гоняем информацию, которая нам не нужна - нас не же не интересуют атрибуты основных товаров у рекомендаций. Этого можно избежать, если спроектировать грамотное API, но, скорее всего, в доменных сервисах запрос в базу уйдёт всё равно всё со всем.

Тем не менее, второй вариант всё же по моему мнению лучше, т.к.
меньше запросов => меньше потоков => сервис больше таких запросов сможет обработать. Также, доп запрос - это доп затраты на маршаллинг и демаршаллинг, которые могут быть значительными.

Тем не менее 2.0, нужно понимать, что если раньше сервис рекомендаций, который, положа руку на сердце, является второстепенным для бизнеса, начнёт тормозить и отваливаться по таймауту, в первом варианте, мы сразу же возвращаем критичные для бизнеса данные наверх, во втором же, нам нужно сначала обрабатывать исключение, и поход за критичными данными произойдёт только после того, как мы увидим таймаут от сервиса рекомендаций. Т.е. это будет однозначно дольше.

Выводы:

Если попытаться суммировать вышесказанное, то несмотря на то, что для нас второй вариант дал ощутимый прирост производительности и мы остановились на нём, можно представить ситуацию, когда он не будет таким хорошим - я бы рассматривал каждый случай отдельно, смотрел на специфику запросов, сколько и какие данные гоняются по сети и так далее.
Хотя мне всё-таки кажется, что в большинстве случаев чем меньше запросов - тем лучше.

Едем дальше

Итак, мы провели некие оптимизации, и да, сервис действительно стал держать больше нагрузки, но что делать, если этого всё ещё не достаточно?

Во-первых, сразу нужно сказать об ограничениях, которые у нас были.

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

  • Не всегда возможно из-за ограничений инфраструктуры. Это сейчас у нас кубер и со скалированием проблем нет, но перед пандемией у нас был всего десяток серверов для приложений и на одном сервере мы мы могли поднять только 1 инстанс оркестратора. Т.е. максимальное количество инстансов, которые мы могли себе позволить - 10;

  • Как показывает практика, это не оптимальный подход. Да, прирост производительности есть, но до для того, чтобы получить необходимые 60ms на 95-й персентиле нужно было этими инстансами прямо таки обмазаться, чего мы себе позволить не могли.

    Время ответа при 5-ти и 8-ми инстансах оркестратораВремя ответа при 5-ти и 8-ми инстансах оркестратора

Анализ проблемы

На самом деле проблему "как разогнать сервис до определённого rps?" можно переформулировать в вопрос "почему на нужном rps сервис не держит?". Для того, чтобы понять в чём проблема, даём нарастающую нагрузку, и, в момент когда сервису плохеет смотрим на состояние.
К сожалению, идея написать статью появилась после того, как мы решили проблему, по этому графики не сохранились, но, если в двух словах, мы увидели, что количество потоков постоянно росло и в какой-то момент garbage collector просто сходит с ума. Причём, ни увеличение возможного количества потоков, ни добавление памяти картину не меняло.

Чтобы разобраться почему так, давайте взглянем на то, что происходит с приложением при запросе:

рис 3. Путь запроса в undertowрис 3. Путь запроса в undertow
  1. Сервер принимает входящее http соединение. Занимаются этим потоки пула WORKER_IO_THREADS, их обычно по количеству ядер (в нашем примере 4). Допустим, наш запрос попал на XNIO-1 I/O 2. Он установил соединение, и отправил запрос дальше, освобождаясь для принятия новых запросов.

  2. От потока из пула WORKER_IO_THREADS запрос попадает уже на непосредственный обработчик - в нашем случае это XNIO-1 task-2 из пула WORKER_TASK_CORE_THREADS
    Это тот поток, который будет выполнять всё, что мы напишем внутри нашего контроллера.

  3. Как уже говорилось выше, зная список атрибутов, XNIO-1 task-2 создаёт n потоков, каждый из которых параллельно должен добыть какую-то часть информации о продуктах. Потоки берутся из кастомного пула (пулы WORKER_IO_THREADS и WORKER_TASK_CORE_THREADS - это сущности undertow)

  4. Каждый кастомный поток, идёт к клиенту доменного сервиса и делает запрос. Т.к. под капотом используется RestTemplate, поток блокируется и ждёт пока доменный сервис не ответит.

И, на самом деле, всё что описано выше - это стандартная схема, и кучу времени подобные сервисы на java писали именно так. Подход рабочий, но проблема заключается в том, что чтобы обработать всего лишь 1 запрос необходимо N потоков из кастомного пула, как результат потоков этих будет очень много и на их поддержку будут уходить ресурсы. Именно по этому при такой стандартной схеме взаимодействия сервис будет довольно таки прожорлив.

Переход на неблокирующий подход

Как мы можем заметить, проблема возникает только в CUSTOM_THREAD_POOL - там очень много потоков и большинство времени они занимаются тем, что просто ждут ответа. Существует подход, используя который, мы можем сократить число подобных потоков. Зовётся он неблокирующим.

Чтобы понять в чём суть, мне бы хотелось привести аналогию с фастфудом

Блокирующий подход:
Вы решили устроить праздник живота и порадовать себя парочкой бургеров.
Вы подходите к свободной кассе и делаете заказ.
Кассир принимает заказ. Заказ отправляется на кухню.
Вы стоите перед кассой и ждёте заказ.
Вы смотрите на кассира. Кассир смотрит на вас.
Вы смотрите на кассира. Кассир смотрит на вас.
Проходит время, минуты складываются в часы, неловкое молчание, и, наконец, заказ готов - вы отправляетесь на свой заслуженный обед.

Неблокирующий подход:
Вы всё так же хотите бургер.
Вы подходите к свободному электронному терминалу и начинаете выбирать себе еду.
Вы оплачиваете заказ, берёте чек с номером и локатор.
Вы садитесь за свободный стол, поставив на него локатор.
Пока вы ждёте заказ вы можете заниматься чем угодно полезным (или не очень) - и это основное преимущество неблокирующего подхода.
Когда ваш заказ готов, вам его принесут и вы приступаете к трапезе.

Разница в подходах заключается в том, что во втором вы ничего не ждёте. Вернее ждёте, но в момент ожидания вы можете ещё чем-то заниматься. Например, сделать ещё один заказ в другом ресторане. Ваш локатор - это временная замена вашей будущей еды, если хотите, это Future вашего заказа.

Вернёмся к нашему серверу

Если используется блокирующий RestTemplate, то поток, который получает информацию от доменного сервиса делает следующее:
Он стучится к сервису, говорит ему "здрасьте", я такое-то приложение, мне нужна такая-то информация, я буду ждать от вас ответ на таком-то порту, и начинает ждать, что на этот порт от сервиса придёт ответ. Пока ответ не придёт, или пока не наступит таймаут поток ничем полезным заниматься не будет.

Вместо того, чтобы иметь кучу потоков, каждый из которых ничем полезным не занимается, ожидая ответа от сервиса, предлагается создать пул потоков, который будет состоять всего из 4-х (по количеству CPU) потоков, но которые постоянно будут заниматься полезной работой.

Потоки из этого нового пула будут принимать "заказы" на общение с сервисами моментально возвращать управление вместе с каким-то аналогом Future и избавляя от необходимости заказчика ждать.

Они поделят между собой диапазон портов, и когда к одному из них придут с "заказом", он выберет свободный порт из диапазона, на котором он будет получать ответ, и, отправит запрос в доменный сервис. После того, как запрос отправлен, этому потоку в отличие от блокирующего подхода будет чем заняться. У него ранее "заказывали" другую информацию, и она уже могла прийти на другой порт, а значит надо сходить проверить - не ответил ли один из доменных сервисов, которых он опрашивал ранее. Если ответил, то нужно нотифицировать поток заказчика, что его заказ выполнен.

Таким образом, наши потоки либо принимают заказы и отправляют запросы, либо в бесконечном цикле обходят порты, на которых ожидается ответ. Таким образом, они всегда при деле.

Теория - это классно, как применить-то?

C 11-й Java доступен httpClient, который реализует описанный выше неблокирующий подход. А с Spring 5 есть его обёртка - WebClient, которым можно заменить RestTemplate.
WebClient - часть spring-webflux, по этому как результат запроса в доменный сервис возвращает Mono/Flux. Наш основной же код приложения работает с CompletableFuture.

Возникает вопрос: можно ли использовать WebClient как замену RestTemplate, получив преимущества неблокирующего приложения, но при этом не переписывая с CompletableFuture на Mono/Flux весь проект?

Ответ: можно, но нужно очень хорошо понимать что делаешь.

Как положить прод, если не любишь читать документацию.

У Mono/Flux есть метод toFuture(), который возвращает как раз CompletableFuture.

Может возникнуть соблазн ни о чём не задумываясь просто использовать этот метод. Так мы и сделали. И уже через несколько секунд нам пришлось откатываться, т.к. наш прод в прямом смысле этого слова встал - все запросы к нему просто тупо начали висеть.
Начинаем разбираться и видим следующее:

Потоки WebClient(по умолчанию они зовутся reactor-http-epoll-число), которые должны были постоянно работать оббегая каждый свой список портов, заблокированы.
Всё дело оказалось в методе CompletableFuture thenApply(work) который встретился у нас выше по стеку.
Этот метод, "докидывает" работы в текущий CompletableFuture на котором вызывается. И всё бы ничего, но у нас так получилось, что эта "работа" оказалась блокирующей.

Получилось следующее: через thenApplyпотоку reactor-http-epoll-1 из WebClient было "накинуто" задание сходить в ещё один сервис. Пока он этого не сделает и не вернёт управление наверх он не может выполнять свою основную работу - смотреть не ответили ли доменные сервисы. И причём, в конце этого задания у стояла блокировка, т.е. нужно было обязательно дождаться от этого сервиса ответа. И так исторически сложилось, что библиотека WebClient, распределяющая работу типа "сходить в ещё один сервис" выбрала именно reactor-http-epoll-1 для организации взаимодействия в этом запросе. То есть получилась ситуация, когда поток заблокировался, потому что ждал ответа от сервиса, в то время как он же и должен был этот ответ в получить. Но он не мог бегать и смотреть что ему пришло, т.к. был заблокирован. Вот такой вот deadlock.

Решение в данном случае: использовать thenApplyAsync, чтобы дополнительная работа выполнялась не в потоке WebClient .

Мораль же следующая:

Никогда не позволяйте потокам WebClient блокироваться - они постоянно должны работать.

Именно потому что так просто выстрелить себе в ногу, я бы рекомендовал воздержаться от использования CompletableFuture и Mono/Fluxв одном проекте. Если используете Mono/Flux , то лучше перепишите всё на Netty, благо у CompletableFuture и Mono/Fluxочень похожее API. Вот тут в документации написано почему использовать метод toFuture() конвертирующий одно в другое - не очень хорошая практика.

Хэппи-энд

Тем не менее, после правок, всё завелось. После деплоя видим следующую картину:

Где-то на 17:26 произошёл деплой новой версии. Из графика видно что:

  • количество потоков сократилось больше чем в 4 раза (это сократилось количество потоков в CUSTOM_THREAD_POOL с 1000+ до 4-х);

  • потоки в статусе timed-waiting исчезли как класс;

  • количество потоков в статусе waiting не изменилось, но это понятно - нас всё ещё есть нативные воркеры undertow. Можно использовать Netty, тогда не было бы и их - у приложения тогда бы вообще было бы всего 4 универсальных потока, которые занимались бы всем.

Результаты по перфомансу превзошли все ожидания: 0 ошибок и 80 ms 75-й персентиль, при всего 4-х инстансах приложения.

Необходимая производительность в 60 ms на 95 персентиле достигается путём поднятия единственного дополнительного инстанса.

Выводы

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

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

  • кэшировании;

  • параллельном выполнении запросов;

  • объединении запросов.

Именно эти шаги могут уменьшить время ответа запроса.

Далее, использование неблокирующего клиента в микросервисах типа "оркестратор" позволяет повысить пропускную способность сервиса, экономя на тредах.
Именно повысить пропускную способность, т.е. те же результаты, но меньшими ресурсами. Однако, если у вас куча серверов, возможно, вам проще будет поднять побольше инстансов.
В конце концов, Вы же наверняка рассчитывали, что это решит ваши проблемы с нагрузкой, не так ли?)

Не советую использовать CompletableFuture и Mono/Flux в одном проекте. Анализ описанных в данной статье проблем при их появлении не тривиален. Как минимум потому, что их не так просто воспроизвести на тестовых стендах. А если используете, то используйте с умом.

Не допускайте блокировки потоков WebClient . Как только кто-то из них заблокируется, это будет означать, что он уже не сможет выполнять свою основную функцию.

В чём минусы подхода?

Обсуждая неблокирующее программирование, мы должны понимать, что идея подобной организации потоков не нова. Может быть, она нова в Java, но в Node.js с самого начала всё так и работало. И тем не менее, когда меня пригласили в Леруа Мерлен распиливать монолит, у которого были проблемы с производительностью, то, что мы распиливали, было как раз Node.js приложением. Это говорит о том, что данный подход не является панацеей. Он хорошо работает, когда потоки не нагружены лишней работой и плохо, когда есть много тяжелых вычислений. Но это уже следующий шаг после внедрения неблокирующего клиента).

Люди строили highload на Java и до jdk11/Spring 5, неблокирующее программирование - это всего лишь один из подходов к организации, который хорошо бы иметь в своём распоряжении и применять когда в нём возникнет необходимость.

Спасибо за внимание!

Подробнее..

Опыт использования фреймворка Featuretools

21.07.2020 16:21:44 | Автор: admin
Нынче важнейшим вектором развития многих компаний является цифровизация. И почти всегда она так или иначе связана с машинным обучением, а значит, с моделями, для которых нужно считать признаки.

Можно делать это вручную, но также для этого существуют фреймворки и библиотеки, ускоряющие и упрощающие этот процесс.

Об одной из них, featuretools, а также о практическом опыте ее использования мы сегодня и поговорим.


Моднейший пайплайн

Всем привет! Я Александр Лоскутов, работаю в Леруа Мерлен аналитиком данных, или, как это модно сейчас называть, data-scientist-ом. В мои обязанности входит работа с данными, начиная c аналитических запросов и выгрузок, заканчивая обучением модели, оборачиванием ее, например, в сервис, настройкой доставки и развертывания кода, а также мониторинга его работы.

Предсказание отмен один из продуктов, над которым я работаю.

Задача продукта: предсказать вероятность отмены заказа клиентом, сделанного онлайн. С помощью такого предсказания мы можем определить, кому из клиентов, сделавших заказ, нужно позвонить в первую очередь, чтобы попросить подтвердить заказ, а кому можно и не звонить вовсе. Во-первых, сам факт звонка и подтверждения заказа от клиента по телефону уменьшает вероятность отмены, а во-вторых, если мы позвоним и человек откажется, то мы сможем сэкономить ресурсы. У сотрудников высвободится больше времени, которое они бы потратили на то, чтобы собрать заказ. К тому же так товар останется на полке, и если в этот момент находящемуся в магазине покупателю он понадобится, то он сможет его купить. И таким образом уменьшится количество товаров, которые были собраны в отмененные позже заказы и не присутствовали на полках.

Предтечи


Для пилота продукта мы берем только заказы с постоплатой при самовывозе в нескольких магазинах.

Готовое решение работает примерно так: к нам приходит заказ, с помощью Apache NiFi мы обогащаем информацию по нему например, подтягивая данные по товарам. Затем все это через брокера сообщений Apache Kafka передается в сервис, написанный на Python. Сервис рассчитывает признаки для заказа, а дальше за них берется модель машинного обучения, которая выдает вероятность отмены. После этого мы в соответствии с бизнес-логикой готовим ответ, нужно звонить клиенту сейчас или нет (например, если заказ сделан с помощью сотрудника внутри магазина или заказ сделали ночью, то звонить не стоит).

Казалось бы, что мешает звонить всем подряд? Дело в том, что количество ресурсов для звонков у нас ограничено, поэтому важно понимать, кому обязательно стоит звонить, а кто и без звонка точно заберет свой заказ.

Разработка модели


Я занимался сервисом, моделью и, соответственно, расчетом признаков для модели, о котором и пойдет дальше речь.

При расчете признаков во время обучения мы используем три источника данных.

  1. Табличка с метаинформацией о заказе: номер заказа, timestamp, устройство клиента, способ доставки, способ оплаты.
  2. Табличка с позициями в чеках: номер заказа, артикул, цена, количество, количество товара на складе. Каждая позиция идет отдельной строкой.
  3. Таблица справочник товаров: артикул, несколько полей с категорией товара, единицы измерения, описание.

Стандартными методами Python и библиотекой pandas можно легко объединить все таблицы в одну большую, после чего, используя groupby, посчитать разного рода признаки вроде агрегатов по заказу, истории по товару, по категориям товаров и т. д. Но тут возникает несколько проблем.

  • Параллельность вычислений. Стандартный groupby работает в одном потоке, и на больших данных (до 10 млн строк) сотня признаков считается уже неприемлемо долго, притом что мощности остальных ядер простаивают.
  • Количество кода: каждый такой запрос нужно отдельно написать, проверить на правильность, а потом все результаты еще нужно собрать. Это требует времени, особенно учитывая сложность некоторых вычислений например, расчета последней истории по товару в чеке и агрегации этих признаков для заказа.
  • Можно допустить ошибки, если все кодить вручную.

Плюсом подхода пишем все вручную является, конечно же, полная свобода действий, можно дать своей фантазии развернуться.

Возникает вопрос: как можно оптимизировать эту часть работы. Одним из решений является использование библиотеки featuretools.

Тут мы уже переходим к сути этой статьи, а именно к самой библиотеке и практике ее применения.

Почему featuretools?


Рассмотрим различные фреймворки для машинного обучения в виде таблички (сама картинка честно украдена отсюда, и наверняка там указаны не все, но все же):



Нас интересует в первую очередь блок Feature Engineering. Если рассмотреть все эти фреймворки и пакеты, то выяснится, что самым навороченным из них является featuretools, и он даже включает в себя функциональность некоторых других библиотек вроде tsfresh.

Также к достоинствам featuretools (совсем не реклама!) можно отнести:

  • параллельное вычисление из коробки
  • доступность множества признаков из коробки
  • гибкость в настройке можно считать довольно сложные штуки
  • учет отношений между разными табличками (реляционность)
  • меньше кода
  • меньшая вероятность допустить ошибку
  • само собой все бесплатно, без регистрации и СМС (но с pypi)

Но не все так просто.

  • Фреймворк требует некоторого изучения, а полное освоение и вовсе займет приличное количество времени.
  • У него не такое большое комьюнити, хотя самые популярные вопросы все же хорошо гуглятся.
  • Само использование также требует аккуратности, чтобы не раздувать пространство признаков без надобности и не увеличивать время расчета.

Обучение


Приведу пример конфигурации featuretools.

Далее будет код с краткими объяснениями, более подробно про featuretools, его классы, методы, возможности можно почитать в том числе в документации на сайте фреймворка. Если вам будут интересны примеры практического применения с демонстрацией некоторых интересных возможностей в реальных задачах, то пишите в комментариях, возможно, запилю отдельную статью.

Итак.

Сначала нужно создать объект класса EntitySet, который содержит таблицы с данными и знает про их связи между собой.

Напомню, что у нас три таблицы с данными:

  • orders_meta (мета-информация о заказах)
  • orders_items_lists (информация о позициях в заказах)
  • items (справочник артикулов и их свойств)

Пишем (далее используются данные только 3-х магазинов):

import featuretools as ftes = ft.EntitySet(id='orders')  # создаем пустой объект класса EntitySet# по очереди добавляем в него pandas.DataFrame-ы как сущности (ft.Entity)es = es.entity_from_dataframe(entity_id='orders_meta',                              dataframe=orders_meta,                              index='order_id',                              time_index='order_creation_dt')es = es.entity_from_dataframe(entity_id='orders_items',                              dataframe=orders_items_lists,                              index='order_item_id')es = es.entity_from_dataframe(entity_id='items',                              dataframe=items,                              index='item',                              variable_types={                                  'subclass': ft.variable_types.Categorical                              })# объявляем отношения между сущностями# для задания отношения сначала указываем сущность-родителя,# затем сущность-ребенкаrelationship_orders_items_list = ft.Relationship(es['orders_meta']['order_id'],                                                 es['orders_items']['order_id'])relationship_items_list_items = ft.Relationship(es['items']['item'],                                                es['orders_items']['item'])# добавляем отношенияes = es.add_relationship(relationship_orders_items_list)es = es.add_relationship(relationship_items_list_items)



Ура! Теперь у нас есть объект, который позволит нам считать разного рода признаки.

Приведу код для расчета довольно простых признаков: для каждого заказа посчитаем различные статистики по ценам и количеству товаров, а также пару признаков по времени и самые частые товары и категории товаров в заказе (функции, выполняющие разного рода преобразования с данными, в featuretools называются примитивами).

orders_aggs, orders_aggs_cols = ft.dfs(    entityset=es,    target_entity='orders_meta',    agg_primitives=['mean', 'count', 'mode', 'any'],    trans_primitives=['hour', 'weekday'],    instance_ids=[200315133283, 200315129511, 200315130383],    max_depth=2)





Здесь в таблицах нет столбцов типа boolean, поэтому примитив any не был применен. Вообще удобно, что featuretools сам анализирует тип данных и применяет лишь соответствующие функции.

Также я вручную указал только несколько заказов для расчета. Это позволяет быстро дебажить свои вычисления (вдруг вы сконфигурировали что-то не то).

Теперь добавим к нашим признакам еще несколько агрегатов, а именно перцентили. Но в featuretools нет встроенных примитивов для их расчета. Значит, нужно написать самим.

from featuretools.variable_types import Numericfrom featuretools.primitives.base.aggregation_primitive_base import make_agg_primitivedef percentile05(x: pandas.Series) -> float:   return numpy.percentile(x, 5)def percentile25(x: pandas.Series) -> float:   return numpy.percentile(x, 25)def percentile75(x: pandas.Series) -> float:   return numpy.percentile(x, 75)def percentile95(x: pandas.Series) -> float:   return numpy.percentile(x, 95)percentiles = [percentile05, percentile25, percentile75, percentile95]custom_agg_primitives = [make_agg_primitive(function=fun,                                            input_types=[Numeric],                                            return_type=Numeric,                                            name=fun.__name__)                         for fun in percentiles]

И добавим их в расчет:

orders_aggs, orders_aggs_cols = ft.dfs(    entityset=es,    target_entity='orders_meta',    agg_primitives=['mean', 'count', 'mode', 'any'] + custom_agg_primitives,    trans_primitives=['hour', 'weekday'],    instance_ids=[200315133283, 200315129511, 200315130383],    max_depth=2)

Дальше все то же самое. Пока все довольно просто и легко (относительно, конечно).

А что если мы хотим сохранить наш калькулятор признаков и использовать его на этапе исполнения модели, то есть в работе сервиса?

Featuretools в бою


Тут-то и начинаются основные сложности.

Для расчета признаков по входящему заказу придется опять проделать все операции с созданием EntitySet. И если для больших таблиц закидывать pandas.DataFrame-объекты в EntitySet кажется вполне нормальным, то делать аналогичные операции для DataFrame-ов из одной строки (в таблице с чеками их больше, но в среднем 3.3 позиции на один чек, то есть мало) уже не очень. Ведь создание таких объектов и расчеты с их помощью неизбежно содержат оверхед, то есть неустранимое количество операций, необходимых, например, для выделения памяти и инициализации при создании объекта любого размера или самого процесса распараллеливания при вычислении нескольких признаков одновременно.

Поэтому в режиме работы один заказ за раз в нашем продукте featuretools показывает не лучшую эффективность, занимая в среднем 75% времени ответа сервиса (в среднем 150-200 мс в зависимости от железа). Для сравнения: вычисление предсказания с помощью catboost-а на готовых признаках занимает 3% от времени ответа сервиса, т. е. не более 10 мс.

Помимо этого, есть еще один подводный камень, связанный с использованием кастомных примитивов. Дело в том, что мы не можем просто сохранить в pickle объект класса, содержащего созданные нами примитивы, так как последние не пиклятся.

Тогда почему бы не воспользоваться встроенной функцией save_features(), которая может сохранить список объектов класса FeatureBase, в том числе созданные нами примитивы?
Сохранить-то она их сохранит, а вот прочитать потом с помощью функции load_features() их не получится, если мы заранее их снова не создадим. То есть примитивы, которые мы по идее должны прочитать с диска, мы предварительно снова создаем, чтобы больше никогда их не использовать.

Выглядит это примерно так:

from __future__ import annotationsimport multiprocessingimport picklefrom typing import List, Optional, Any, Dictimport pandasfrom featuretools import EntitySet, dfs, calculate_feature_matrix, save_features, load_featuresfrom featuretools.feature_base.feature_base import FeatureBase, AggregationFeaturefrom featuretools.primitives.base.aggregation_primitive_base import make_agg_primitive# класс-калькулятор признаков# при инициализации мы указываем целевую сущность,# для объектов которой мы будем считать признаки# передаем также список агрегирующих примитивов# еще можно передать список наших кастомных примитивов (в виде параметров их создания),# глубину расчета признаков и паттерны признаков, которые мы считать не хотимclass AggregationFeaturesCalculator:    def __init__(self,                 target_entity: str,                 agg_primitives: List[str],                 custom_primitives_params: Optional[List[Dict[str, Any]]] = None,                 max_depth: int = 2,                 drop_contains: Optional[List[str]] = None):        if custom_primitives_params is None:            custom_primitives_params = []        if drop_contains is None:            drop_contains = []        self._target_entity = target_entity        self._agg_primitives = agg_primitives        self._custom_primitives_params = custom_primitives_params        self._max_depth = max_depth        self._drop_contains = drop_contains        self._features = None  # список признаков (объектов типа ft.FeatureBase)    @property    def features_are_built(self) -> bool:        return self._features is not None    @property    def features(self) -> List[AggregationFeature]:        if self._features is None:            raise AttributeError('features have not been built yet')        return self._features    # метод для создания списка признаков из наших примитивов    def build_features(self, entity_set: EntitySet) -> None:        custom_primitives = [make_agg_primitive(**primitive_params)                             for primitive_params in self._custom_primitives_params]        self._features = dfs(            entityset=entity_set,            target_entity=self._target_entity,            features_only=True,            agg_primitives=self._agg_primitives + custom_primitives,            trans_primitives=[],            drop_contains=self._drop_contains,            max_depth=self._max_depth,            verbose=False        )    # функция, считающая матрицу значений признаков    # для некоторых списка признаков и набора сущностей    @staticmethod    def calculate_from_features(features: List[FeatureBase],                                entity_set: EntitySet,                                parallelize: bool = False) -> pandas.DataFrame:        n_jobs = max(1, multiprocessing.cpu_count() - 1) if parallelize else 1        return calculate_feature_matrix(features=features, entityset=entity_set, n_jobs=n_jobs)    # непосредственно вызываемый метод класса для расчета признаков    def calculate(self, entity_set: EntitySet, parallelize: bool = False) -> pandas.DataFrame:        if not self.features_are_built:            self.build_features(entity_set)        return self.calculate_from_features(features=self.features,                                            entity_set=entity_set,                                            parallelize=parallelize)    # метод для сохранения калькулятора        # список признаков сразу запиклить нельзя,    # поэтому сначала вызывается метод save_features()    # после чего пиклятся все аргументы конструктора     @staticmethod    def save(calculator: AggregationFeaturesCalculator, path: str) -> None:        result = {            'target_entity': calculator._target_entity,            'agg_primitives': calculator._agg_primitives,            'custom_primitives_params': calculator._custom_primitives_params,            'max_depth': calculator._max_depth,            'drop_contains': calculator._drop_contains        }        if calculator.features_are_built:            result['features'] = save_features(calculator.features)        with open(path, 'wb') as f:            pickle.dump(result, f)    # метод для загрузки ранее сохраненного калькулятора    @staticmethod    def load(path: str) -> AggregationFeaturesCalculator:        with open(path, 'rb') as f:            arguments_dict = pickle.load(f)                # нужно инициализировать кастомные примитивы...        if arguments_dict['custom_primitives_params']:            custom_primitives = [make_agg_primitive(**custom_primitive_params)                                 for custom_primitive_params in arguments_dict['custom_primitives_params']]        features = None                # иначе в этом месте будет ошибка         if 'features' in arguments_dict:            features = load_features(arguments_dict.pop('features'))        calculator = AggregationFeaturesCalculator(**arguments_dict)        if features:            calculator._features = features        return calculator

В функции load() приходится создавать примитивы (объявление переменной custom_primitives), которые не будут использоваться. Но без этого дальнейшая загрузка признаков в месте вызова функции load_features() упадет с ошибкой RuntimeError: Primitive percentile05 in module featuretools.primitives.base.aggregation_primitive_base not found.

Получается не очень логично, зато работает, и можно сохранять как уже привязанный к определенному формату данных калькулятор (так как признаки завязаны на EntitySet, для которого они были посчитаны, хоть и без самих значений), так и калькулятор лишь с заданным списком примитивов.

Возможно, в будущем это поправят и можно будет удобно сохранять произвольный набор объектов FeatureBase.

Почему же тогда мы его используем?


Потому что с точки зрения времени разработки это дешево, при этом время ответа при существующей нагрузке укладывается в наш SLA (5 секунд) с запасом.

Однако стоит отдавать себе отчет в том, что для сервиса, который должен быстро отвечать на часто поступающие запросы, использовать featuretools без дополнительных приседаний вроде асинхронных вызовов будет проблематично.

Таков наш опыт использования featuretools на этапах обучения и inference-а.

В качестве инструмента для быстрого расчета большого числа признаков для обучения этот фреймворк очень хорош, сильно сокращает время разработки и уменьшает вероятность ошибок.
Использовать ли его на этапе вывода зависит от ваших задач.
Подробнее..

Лучшие data-продукты рождаются в полях

08.07.2020 16:06:56 | Автор: admin

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



image

Меня зовут Марина Калабина, яруководитель проектов вЛеруа Мерлен. Пришла вкомпанию в2011 году. Первые пять лет открывала магазины (когда япришла, ихбыло 13, сейчас 107), потом работала вмагазине вкачестве руководителя торгового сектора ивот уже полтора года занимаюсь тем, что спозиции Data-продакта помогаю магазинам организовывать операции.


Леруализмы


Поскольку ядавно работаю вкомпании, торечь моя наполнена специфическими терминами, которые яназываю леруализмы. Чтобы мыговорили свами наодном языке, привожу некоторые изних.


  • Сток запас товаров вмагазине.
  • Доступный для продажи сток количество товара, свободное отблокировок ирезервов для клиента.
  • Экспо витринный образец.
  • Артикулы товары.
  • Оперативная инвентаризация ежедневный пересчет 5 артикулов вкаждом отделе каждого магазина.

Гарантированный сток


Возможно, вынезнаете, нокогда выоформляете заказ вЛеруа Мерлен, в98% случаев онприходит вмагазин исобирается изторгового зала.


Представьте себе огромные 8000 кв. ммагазина, 40000 артикулов изадачу собрать заказ. Что может произойти сартикулами вашего заказа, которые ищет сборщик? Товар может быть уже вкорзине клиента, который ходит поторговому залу, или даже может быть продан между тем моментом, когда выего заказали, итем, когда сборщик пошел заним. Насайте товар есть, авдействительности онлибо где-то спрятан, либо его уже нет, каким-нибудь батарейкам приделали ноги. Бывает иобратная ситуация, когда товар вмагазине есть, анасайте покаким-то причинам неотображается.


Из-за этого мынеможем собрать заказ, теряем товарооборот страдает наша репутация ипоявляются недовольные клиенты.


Для того чтобы бороться сразными проблемами, ивтом числе сэтой, впрошлом году вкомпании было запущено подразделение Data Accelerator. Его миссия привить data-культуру, чтобы принимаемые вкомпании решения были data-driven. ВData Accelerator было заявлено 126 идей, изних было выбрано 5 иодна изэтих идей это тот продукт Гарантированный сток, окотором ябуду рассказывать.


Суть продукта втом, что перед публикацией стока товара насайте мыпроверяем, можемли собрать этот артикул клиенту, гарантируемли ему это. Чаще всего это достигается чуть меньшим количеством стока, который мыпубликуем насайте.


Унас была классная команда: Data Scientist, Data Engineer, Data Analysis, Product Owner иScrum-мастер.


Целями нашего продукта были:


  • сократить количество несобранных заказов, при этом неповредив количеству заказов впринципе (чтобы оно несократилось);
  • сохранить товарооборот вeCom, поскольку мыбудем меньше показывать товаров насайте.

Вобщем, при прочих равных сделать лучше.


Бюро расследований


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


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


Например, смеситель утаких товаров всегда есть образец взале. Более того, мынеимеем права продать экспо, потому что онможет быть уже поврежден игарантия нанего нераспространяется. Мынаходили такие товары, укоторых непроставлен витринный образец, адоступный сток для продажи показан 1. Но, скорее всего, это тот самый экспо, который мынесможем продать. Аклиент может его заказать. Это одна изпроблем.


image

Следующая история обратная. Мыобнаружили, что иногда утоваров бывает слишком большое количество витринных образцов. Скорее всего, либо произошел сбой системы, либо вмешался человеческий фактор. Вместо того чтобы насайте показывать 2500 установочных коробок, мыможем показать только 43, потому что унас сбой всистеме. Имынаучили наши алгоритмы находить втом числе итакие косяки.


image

Валидация


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


Что касается примеров, когда мынаходили слишком большое количество витринных образцов, практически в60% случаев мыбыли правы, предполагая ошибку. Акогда мыискали недостаточное количество экспо или ихотсутствие, тобыли правы в81%, что, вобщем-то, очень хорошие показатели.


Запуск MVP. Первый этап


Поскольку нам надо было уложиться в6 недель, мызапускали proof ofconcept вот стаким линейным алгоритмом, который находил аномальные значения, делал поправку наэти значения перед тем, как публиковать насайт. Иунас было два магазина, вдвух разных регионах, чтобы мымогли сравнить эффект.
Кроме того, был сделан дашборд, где, содной стороны, мымониторили технические параметры, асдругой показывали нашим заказчикам, посути магазинам, как отрабатывают наши алгоритмы. Тоесть мысравнивали, как они работали дозапуска икак стали работать после, показывали, сколько денег позволяет заработать использование этих алгоритмов.


Правило -1. Второй этап


Эффект отработы продукта быстро стал заметен, инас стали спрашивать, почему мыобрабатываем такое маленькое количество артикулов: Давайте возьмем весь сток магазина, изкаждого артикула вычтем одну штуку, и, может быть, это нам позволит решить проблему глобально. Кэтому моменту мыуже начали работать над моделью машинного обучения, нам казалось, что подобная ковровая бомбардировка может навредить, новозможность такого эксперимента упускать нехотелось. Имызапустили тест на4магазинах для того, чтобы проверить эту гипотезу.


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


ML-модель. Третий этап


Итак, мысделали ML-модель, запустили еевпрод в6магазинах. Какая унас получилась ML-модель?


  • Модель реализована спомощью градиентного бустинга наCatboost, иэто дает предсказание вероятности того, что сток товара вданном магазине вданный момент является некорректным.
  • Модель была обучена нарезультатах оперативной иежегодной инвентаризаций, ивтом числе наданных поотмененным заказам.
  • Вкачестве косвенных указаний навозможность некорректного стока использовались такие признаки, как данные опоследних движениях постоку данного товара, опродажах, возвратах изаказах, одоступном для продажи стоке, ономенклатуре, онекоторых характеристиках товара ипрочем.
  • Всего вмодели использовано около 70 фичей.
  • Среди всех признаков были отобраны важные сиспользованием различных подходов коценки важности, втом числе Permutation Importance иподходов, реализованных вбиблиотеке Catboost.
  • Чтобы проверить качество иподобрать гиперпараметры модели, данные были разбиты натестовую ивалидационную выборки всоотношении 80/20.
  • Модель была обучена наболее старых данных, апроверялась наболее новых.
  • Финальная модель, которая витоге пошла впрод, была обучена наполном датасете сиспользованием гиперпараметров, подобранных спомощью разбиения наtrain/valid-части.
  • Модель иданные для обучения модели версионируются спомощью DVC, версии модели идатасетов хранятся наS3.

Итоговые метрики полученной модели навалидационном наборе данных:


  • ROC-AUC: 0.68
  • Recall: 0.77

Архитектура


Немного про архитектуру как это унас реализуется впроде. Для обучения модели используются реплики операционных ипродуктовых систем компании, консолидированные ведином DataLake наплатформе GreenPlum. Наоснове реплик рассчитываются фичи, хранящиеся вMongoDB, что позволяет организовать горячий доступ кним. Оркестрация расчета фичей иинтеграция GreenPlum иMongoDB реализована сиспользованием opensource-стекаApache-инструментами Apache AirFlow иApache NiFi.


Модель машинного обучения представляет собой контейнеризованное Python-приложение, развернутое вKubernetes. Приложение работает наоснове информации отекущем состоянии стока, поступающей изразличных бизнес-систем враспределенный брокер сообщений Apache Kafka, данные изкоторого модель считывает, корректирует иотправляет насайт компании сиспользованием шины набазе Apache Kafka.


image

Результаты


Унас было 6магазинов ирезультаты показали, что изплановых 15% мысмогли сократить количество несобранных заказов на12%, при этом унас выросли товарооборот E-com иколичество заказов. Так что, мыненавредили, акак раз улучшили качество сборки заказов.


Наданный момент, обученная нами модель используется нетолько для редактирования стока перед публикацией насайте, ноидля улучшения алгоритмов оперативной инвентаризации. Какие артикулы нужно сегодня посчитать именно вэтом отделе, именно вэтом магазине такие, закоторыми придут клиенты, икоторые хорошо былобы проверить. Вобщем модель оказалась еще имультифункциональной ипереиспользуется вкомпании вдругих подразделениях.


p.s.Статья написана по выступлению на митапе Avito.Tech, посмотреть видео можно по ссылке.

Подробнее..

Что нам стоит загрузить JSON в Data Platform

16.06.2021 16:13:28 | Автор: admin

Всем привет!

В недавней статье мы рассказали, как мы шли к построению нашей Data Platform. Сегодня хотелось бы глубже погрузиться в желудок нашей платформы и попутно рассказать вам о том, как мы решали одну из задач, которая возникла в связи с ростом разнообразия интегрируемых источников данных.

То есть, если возвращаться к финальной картинке из упомянутой выше статьи (специально дублирую ее, чтобы уважаемым читателям было удобнее), то сегодня мы будем более углубленно говорить о реализации правой части схемы той, что лежит после Apache NiFi.

Схема из прошлой нашей статьи.Схема из прошлой нашей статьи.

Напомним, что в нашей компании более 350 реляционных баз данных. Естественно, не все они уникальны и многие представляют собой по сути разные экземпляры одной и той же системы, установленной во всех магазинах торговой сети, но все же зоопарк разнообразия присутствует. Поэтому без какого-либо Frameworkа, упрощающего и ускоряющего интеграцию источников в Data Platform, не обойтись.

Общая схема доставки данных из источников в ODS-слой Greenplum посредством разработанного нами frameworkа приведена ниже:

Общая схема доставки данных в ODS-слой Greenplum Общая схема доставки данных в ODS-слой Greenplum
  1. Данные из систем-источников пишутся в Kafka в AVRO-формате, обрабатываются в режиме реального времени Apache NiFi, который сохраняет их в формате parquet на S3.

  2. Затем эти файлы с сырыми данными с помощью Sparkа обрабатываются в два этапа:

    1. Compaction на данном этапе выполняется объединение для снижения количества выходных файлов с целью оптимизации записи и последующего чтения (то есть несколько более мелких файлов объединяются в несколько файлов побольше), а также производится дедубликация данных: простой distinct() и затем coalesce(). Результат сохраняется на S3. Эти файлы используются затем для parsing'а , а также являются своеобразным архивом сырых необработанных данных в формате как есть;

    2. Parsing на этой фазе производится разбор входных данных и сохранение их в плоские структуры согласно маппингу, описанному в метаданных. В общем случае из одного входного файла можно получить на выходе несколько плоских структур, которые в виде сжатых (как правило gzip) CSV-файлов сохраняются на S3.

  3. Заключительный этап загрузка данных CSV-файлов в ODS-слой хранилища: создается временная external table над данными в S3 через PXF S3 connector, после чего данные уже простым pgsql переливаются в таблицы ODS-слоя Greenplum

  4. Все это оркестрируется с помощью Airflow.

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

  • создать в ODS-слое Хранилища таблицы-приемники данных;

  • в репозитории метаданных в Git согласно принятым стандартам прописать в виде отдельных YAML-файлов:

    • общие настройки источника (такие как: расписание загрузки, входной и выходной формат файлов с данными, S3-бакет, имя сервисного пользователя, имя и email владельца для нотификации в случае сбоя загрузки и т.п.);

    • маппинг данных объектов источника на таблицы слоя ODS (при этом поддерживаются как плоские, так и вложенные структуры и массивы, а также есть возможность данные из одного объекта раскладывать в ODS-слое по нескольким таблицам). То есть описать, как необходимо сложную вложенную структуру разложить в плоские таблицы;

До недавнего времени такой подход удовлетворял текущие наши потребности, но количество и разнообразие источников данных растет. У нас стали появляться источники, которые не являются реляционными базами данных, а генерируют данные в виде потока JSON-объектов. Кроме того на горизонте уже маячила интеграция источника, который под собой имел MongoDB и поэтому будет использовать MongoDB Kafka source connector для записи данных в Kafka. Поэтому остро встала необходимость доработки нашего frameworkа для поддержки такого сценария. Хотелось, чтобы данные источника сразу попадали на S3 в формате JSON - то есть в формате "как есть", без лишнего шага конвертации в parquet посредством Apache NiFi.

В первую очередь необходимо было доработать шаг Compaction. Его код, если убрать всю обвязку и выделить только главное, очень простой:

df = spark.read.format(in_format) \               .options(**in_options) \               .load(path) \               .distinct()    new_df = df.coalesce(div)new_df.write.mode("overwrite") \             .format(out_format) \            .options(**out_options) \            .save(path)

Но если мы проделаем все те же манипуляции с JSON-данными, то волей-неволей внесем изменения во входные данные, так как при чтении JSONов Spark автоматом определит и сделает mergeSchema, т.е. мы тем самым можем исказить входные данные, чего не хотелось бы. Ведь наша задача на этом шаге только укрупнить файлы и дедублицировать данные, без какого-либо вмешательства в их структуру и наполнение. То есть сохранить их как есть.

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

рассматривать файлы с JSON-объектами как DataFrame с одной колонкой, содержащей весь JSON-объект.

Попробуем сделать это. Допустим, мы имеем следующий файл данных:

file1:

{productId: 1, productName: ProductName 1, tags: [tag 1, tag 2], dimensions: {length: 10, width: 12, height: 12.5}}{productId: 2, price: 10.01, tags: [tag 1, tag 2], dimensions: {length: 10, width: 12, height: 12.5}}

Обратите внимание на формат этого файла. Это файл с JSON-объектами, где 1 строка = 1 объект. Оставаясь, по сути, JSON-ом, он при этом не пройдет синтаксическую JSON-валидацию. Именно в таком виде мы сохраняем JSON-данные на S3 (есть специальная "галочка в процессоре Apache NiFi).

Прочитаем файл предлагаемым способом:

# Читаем данныеdf = spark.read \          .format("csv") \          .option("sep", "\a") \          .load("file1.json")# Схема получившегося DataFramedf.printSchema()root |-- _c0: string (nullable = true)# Сами данныеdf.show()+--------------------+|                 _c0|+--------------------+|{"productId": 1, ...||{"productId": 2, ...|+--------------------+

То есть мы тут читаем JSON как обычный CSV, указывая разделитель, который никогда заведомо не встретится в наших данных. Например, Bell character. В итоге мы получим DataFrame из одного поля, к которому можно будет также применить dicstinct() и затем coalesce(), то есть менять существующий код не потребуется. Нам остается только определить опции в зависимости от формата:

# Для parquetin_format = "parquet"in_options = {}# Для JSONin_format = "csv"in_options = {"sep": "\a"}

Ну и при сохранении этого же DataFrame обратно на S3 в зависимости от формата данных опять применяем разные опции:

df.write.mode("overwrite") \           .format(out_format) \.options(**out_options) \  .save(path)  # для JSON     out_format = "text" out_options = {"compression": "gzip"}  # для parquet   out_format = input_format out_options = {"compression": "snappy"}

Следующей точкой доработки был шаг Parsing. В принципе, ничего сложного, если бы задача при этом упиралась в одну маленькую деталь: JSON -файл, в отличии от parquet, не содержит в себе схему данных. Для разовой загрузки это не является проблемой, так как при чтении JSON-файла Spark умеет сам определять схему, и даже в случае, если файл содержит несколько JSON-объектов с немного отличающимся набором полей, корректно выполнит mergeSchema. Но для регулярного процесса мы не могли уповать на это. Банально может случиться так, что во всех записях какого-то файла с данными может не оказаться некоего поля field_1, так как, например, в источнике оно заполняется не во всех случаях. Тогда в получившемся Spark DataFrame вообще не окажется этого поля, и наш Parsing, построенный на метаданных, просто-напросто упадет с ошибкой из-за того, что не найдет прописанное в маппинге поле.

Проиллюстрирую. Допустим,у нас есть два файла из одного источника со следующим наполнением:

file1 (тот же что и в примере выше):

{productId: 1, productName: ProductName 1, tags: [tag 1, tag 2], dimensions: {length: 10, width: 12, height: 12.5}}{productId: 2, price: 10.01, tags: [tag 1, tag 2], dimensions: {length: 10, width: 12, height: 12.5}}

file2:

{productId: 3, productName: ProductName 3, dimensions: {length: 10, width: 12, height: 12.5, package: [10, 20.5, 30]}}

Теперь прочитаем Sparkом их и посмотрим данные и схемы получившихся DataFrame:

df = spark.read \          .format("json") \          .option("multiline", "false") \          .load(path)df.printSchema()df.show()

Первый файл (схема и данные):

root |-- dimensions: struct (nullable = true) |    |-- height: double (nullable = true) |    |-- length: long (nullable = true) |    |-- width: long (nullable = true) |-- price: double (nullable = true) |-- productId: long (nullable = true) |-- productName: string (nullable = true) |-- tags: array (nullable = true) |    |-- element: string (containsNull = true)+--------------+-----+---------+-------------+--------------+|    dimensions|price|productId|  productName|          tags|+--------------+-----+---------+-------------+--------------+|[12.5, 10, 12]| null|        1|ProductName 1|[tag 1, tag 2]||[12.5, 10, 12]|10.01|        2|         null|[tag 1, tag 2]|+--------------+-----+---------+-------------+--------------+

Второй файл (схема и данные):

root |-- dimensions: struct (nullable = true) |    |-- height: double (nullable = true) |    |-- length: long (nullable = true) |    |-- package: array (nullable = true) |    |    |-- element: double (containsNull = true) |    |-- width: long (nullable = true) |-- productId: long (nullable = true) |-- productName: string (nullable = true)+--------------------+---------+-------------+|          dimensions|productId|  productName|+--------------------+---------+-------------+|[12.5, 10, [10.0,...|        3|ProductName 3|+--------------------+---------+-------------+

Как видно, Spark корректно выстроил схему отдельно для каждого файла. Если в какой-либо записи не было обнаружено поля, имеющегося в другой, то в DataFrame мы видим корректное проставление null (поля price и productName для первого файла).

Но в целом схемы получились разные, и если у нас в маппинге прописано, что нам нужно распарсить эти данные (то есть оба файла) в следующую плоскую структуру,

root |-- price: double (nullable = true) |-- productId: long (nullable = true) |-- productName: string (nullable = true)

а во входных данных у нас присутствуют только файлы а-ля file2, где поля price нет ни у одной записи, то Spark упадет с ошибкой, так как не найдет поля price для формирования выходного DataFrame. С parquet-файлами такой проблемы как правило не возникает, так как сам parquet-файл генерируется из AVRO, который уже содержит полную схему данных и, соответственно, эта полная схема есть и в parquet-файле.

Еще надо отметить, что, естественно, мы хотели по максимум переиспользовать уже существующий и зарекомендовавший себя код нашего frameworkа, а не городить какую-то полностью отдельную ветку для обработки JSONов то есть все изменения хотелось сделать на этапе чтения JSON-файлов с S3.

Таким образом очевидно, что для корректной загрузки данных из JSON-файлов необходимо предопределить схему JSON-файла с данными и читать файлы уже с применением этой схемы. Тогда у нас даже если в JSONе нет какого-то поля, то в самом DataFrame это поле будет, но его значение подставится как null:

df = spark.read \          .format("json") \          .option("multiline","false") \          .schema(df_schema) \          .load(path)

Первая мысль была использовать для хранения схемы имеющийся сервис метаданных - то есть описать схему в YAML-формате и сохранить в имеющемся репозитории. Но с учетом того, что все данные источников у нас проходят через Kafka, решили, что логично для хранения схем использовать имеющийся Kafka Schema Registry, а схему хранить в стандартном для JSON формате (другой формат, кстати говоря, Kafka Schema Registry не позволил бы хранить).

В общем, вырисовывалась следующая реализация:

  • Читаем из Kafka Schema Registry схему

  • Импортируем ее в pyspark.sql.types.StructType что-то типа такого:

# 1. получаем через Kafka Schema Registry REST API схему данных # 2. записываем ее в переменную schema и далее:df_schema = StructType.fromJson(schema)
  • Ну и с помощью полученной схемы читаем JSON-файлы

Звучит хорошо, если бы Давайте посмотрим на формат JSON-схемы, понятной Sparkу. Пусть имеем простой JSON из file2 выше. Посмотреть его схему в формате JSON можно, выполнив:

df.schema.json()  
Получившаяся схема
{    "fields":    [        {            "metadata": {},            "name": "dimensions",            "nullable": true,            "type":            {                "fields":                [                    {"metadata":{},"name":"height","nullable":true,"type":"double"},                    {"metadata":{},"name":"length","nullable":true,"type":"long"},                    {"metadata":{},"name":"width","nullable":true,"type":"long"}                ],                "type": "struct"            }        },        {            "metadata": {},            "name": "price",            "nullable": true,            "type": "double"        },        {            "metadata": {},            "name": "productId",            "nullable": true,            "type": "long"        },        {            "metadata": {},            "name": "productName",            "nullable": true,            "type": "string"        },        {            "metadata": {},            "name": "tags",            "nullable": true,            "type":            {                "containsNull": true,                "elementType": "string",                "type": "array"            }        }    ],    "type": "struct"}

Как видно, это совсем не стандартный формат JSON-схемы.

Но мы же наверняка не первые, у кого встала задача конвертировать стандартную JSON-схему в формат, понятный Sparkу - подумали мы и В принципе, не ошиблись, но несколько часов поиска упорно выводили исключительно на примеры, из серии:

как сохранить схему уже прочитанного DataFrame в JSON, затем использовать повторно

либо на репозиторий https://github.com/zalando-incubator/spark-json-schema, который нам бы подошел, если мы использовали Scala, а не pySpark

В общем, на горизонте маячила перспектива писать SchemaConverter. Сперва решили отделаться малой кровью и написать простенький конвертер никаких ссылок и прочих сложных конструкций. Конвертер был успешно протестирован на синтетических данных, после чего захотелось скормить ему что-то приближенное к реальности.

К счастью, у нас уже был один источник, генерирующий данные в формате JSON. Как временное решение схема его интеграции в DataPlatform была незамысловата: NiFi читал данные из Kafka, преобразовывал их в parquet, использую прибитую гвоздями в NiFi схему в формате AVRO-schema, и складывал на S3. Схема данных была действительно непростой и с кучей вложенных структур и нескольких десятков полей - неплохой тест-кейс в общем-то:

Посмотреть длинную портянку, если кому интересно :)
root |-- taskId: string (nullable = true) |-- extOrderId: string (nullable = true) |-- taskStatus: string (nullable = true) |-- taskControlStatus: string (nullable = true) |-- documentVersion: long (nullable = true) |-- buId: long (nullable = true) |-- storeId: long (nullable = true) |-- priority: string (nullable = true) |-- created: struct (nullable = true) |    |-- createdBy: string (nullable = true) |    |-- created: string (nullable = true) |-- lastUpdateInformation: struct (nullable = true) |    |-- updatedBy: string (nullable = true) |    |-- updated: string (nullable = true) |-- customerId: string (nullable = true) |-- employeeId: string (nullable = true) |-- pointOfGiveAway: struct (nullable = true) |    |-- selected: string (nullable = true) |    |-- available: array (nullable = true) |    |    |-- element: string (containsNull = true) |-- dateOfGiveAway: string (nullable = true) |-- dateOfGiveAwayEnd: string (nullable = true) |-- pickingDeadline: string (nullable = true) |-- storageLocation: string (nullable = true) |-- currentStorageLocations: array (nullable = true) |    |-- element: string (containsNull = true) |-- customerType: string (nullable = true) |-- comment: string (nullable = true) |-- totalAmount: double (nullable = true) |-- currency: string (nullable = true) |-- stockDecrease: boolean (nullable = true) |-- offline: boolean (nullable = true) |-- trackId: string (nullable = true) |-- transportationType: string (nullable = true) |-- stockRebook: boolean (nullable = true) |-- notificationStatus: string (nullable = true) |-- lines: array (nullable = true) |    |-- element: struct (containsNull = true) |    |    |-- lineId: string (nullable = true) |    |    |-- extOrderLineId: string (nullable = true) |    |    |-- productId: string (nullable = true) |    |    |-- lineStatus: string (nullable = true) |    |    |-- lineControlStatus: string (nullable = true) |    |    |-- orderedQuantity: double (nullable = true) |    |    |-- confirmedQuantity: double (nullable = true) |    |    |-- assignedQuantity: double (nullable = true) |    |    |-- pickedQuantity: double (nullable = true) |    |    |-- controlledQuantity: double (nullable = true) |    |    |-- allowedForGiveAwayQuantity: double (nullable = true) |    |    |-- givenAwayQuantity: double (nullable = true) |    |    |-- returnedQuantity: double (nullable = true) |    |    |-- sellingScheme: string (nullable = true) |    |    |-- stockSource: string (nullable = true) |    |    |-- productPrice: double (nullable = true) |    |    |-- lineAmount: double (nullable = true) |    |    |-- currency: string (nullable = true) |    |    |-- markingFlag: string (nullable = true) |    |    |-- operations: array (nullable = true) |    |    |    |-- element: struct (containsNull = true) |    |    |    |    |-- operationId: string (nullable = true) |    |    |    |    |-- type: string (nullable = true) |    |    |    |    |-- reason: string (nullable = true) |    |    |    |    |-- quantity: double (nullable = true) |    |    |    |    |-- dmCodes: array (nullable = true) |    |    |    |    |    |-- element: string (containsNull = true) |    |    |    |    |-- timeStamp: string (nullable = true) |    |    |    |    |-- updatedBy: string (nullable = true) |    |    |-- source: array (nullable = true) |    |    |    |-- element: struct (containsNull = true) |    |    |    |    |-- type: string (nullable = true) |    |    |    |    |-- items: array (nullable = true) |    |    |    |    |    |-- element: struct (containsNull = true) |    |    |    |    |    |    |-- assignedQuantity: double (nullable = true) |-- linkedObjects: array (nullable = true) |    |-- element: struct (containsNull = true) |    |    |-- objectType: string (nullable = true) |    |    |-- objectId: string (nullable = true) |    |    |-- objectStatus: string (nullable = true) |    |    |-- objectLines: array (nullable = true) |    |    |    |-- element: struct (containsNull = true) |    |    |    |    |-- objectLineId: string (nullable = true) |    |    |    |    |-- taskLineId: string (nullable = true)

Естественно, я не захотел перебивать руками захардкоженную схему, а воспользовался одним из многочисленных онлайн-конвертеров, позволяющих из Avro-схемы сделать JSON-схему. И тут меня ждал неприятный сюрприз: все перепробованные мною конвертеры на выходе использовали гораздо больше синтаксических конструкций, чем понимала первая версия конвертера. Дополнительно пришло осознание, что также как и я, наши пользователи (а для нас пользователями в данном контексте являются владельцы источников данных) с большой вероятностью могут использовать подобные конвертеры для того, чтобы получить JSON-схему, которую надо зарегистрировать в Kafka Schema Registry, из того, что у них есть.

В результате наш SparkJsonSchemaConverter был доработан появилась поддержка более сложных конструкций, таких как definitions, refs (только внутренние) и oneOf. Сам же парсер был оформлен уже в отдельный класс, который сразу собирал на основании JSON-схемы объект pyspark.sql.types.StructType

У нас почти сразу же родилась мысль, что хорошо бы было поделиться им с сообществом, так как мы в Леруа Мерлен сами активно используем продукты Open Source, созданные и поддерживаемые энтузиастами со всего мира, и хотим не просто их использовать, но и контрибьютить обратно, участвуя в разработке Open Source продуктов и поддерживая сообщество. В настоящий момент мы решаем внутренние орг.вопросы по схеме выкладывания данного конвертера в Open Source и, уверен, что в ближайшее время поделимся с сообществом этой наработкой!

В итоге благодаря написанному SparkJsonSchemaConverterу доработка шага Parsing свелась только к небольшому тюнингу чтения данных с S3: в зависимости от формата входных данных источника (получаем из сервиса метаданных) читаем файлы с S3 немного по-разному:

# Для JSONdf = spark.read.format(in_format)\            .option("multiline", "false")\            .schema(json_schema) \            .load(path)# Для parquet:df = spark.read.format(in_format)\            .load(path)

А дальше отрабатывает уже существующий код, раскрывающий все вложенные структуры согласно маппингу и сохраняющий данные DataFrameа в несколько плоских CSV-файлов.

В итоге мы смогли при относительном минимуме внесенных изменений в код текущего frameworkа добавить в него поддержку интеграции в нашу Data Platform JSON-источников данных. И результат нашей работы уже заметен:

  • Всего через месяц после внедрения доработки у нас на ПРОДе проинтегрировано 4 новых JSON-источника!

  • Все текущие интеграции даже не заметили расширения функционала frameworkа, а значит, мы точно ничего не сломали произведенной доработкой.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru