Соавторы: Джефф Вессельшмидт и Джейсон Хелд

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

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

Именно тогда вмешалась группа данных JOOR (JDT) и выполнила всю эту работу вручную. Им пришлось просмотреть записи импортированных розничных продавцов бренда и сравнить их с существующими учетными записями розничных продавцов, чтобы найти идеальное совпадение.

Они делали это сотни тысяч раз в год.

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

Проблема

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

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

Но это было бы слишком просто.

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

Только представьте себе новый, многообещающий бренд. Назовем их Баззкилл. Они только что встретились с представителем Neiman Marcus за бокалом вина. Введение прошло хорошо, и Buzzkill готов вывести его на новый уровень. В этот момент Баззкилл берет свой бокал с вином и говорит: «У этого мерло идеальное ощущение во рту, а также какой у вас ИНН?»

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

Но была загвоздка…

Брендам по-прежнему приходилось ждать до 72 часов, пока JDT не ответит и не создаст связь.

Процесс

Во-первых, JDT получит электронную таблицу данных о розничном продавце от бренда, очистит ее и сохранит в базе данных.

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

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

Используя эти оценки, некоторые визуальные сравнения адресных данных и некоторые дополнительные эвристики, JDT будет принимать одно из трех решений для каждой импортированной записи:

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

Команда, которая когда-то построила этот процесс, была довольно умной.

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

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

Автоматизация выбора ритейлера

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

Проблемы с данными

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

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

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

Нечеткое соответствие

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

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

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

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

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

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

Примечание о данных:

  • imported_address — это объединенное имя хранилища и адрес импортированного соединения.
  • matched_address – это объединенное имя магазина и адрес потенциального совпадения.
  • matched определяет, принял ли пользователь потенциальное совпадение.

Нечеткое соответствие+

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

Что, если вместо этого мы вычислим отдельные оценки сходства для магазина и адреса? Будет ли это иметь значение?

Было ясно, что происходит некоторая кластеризация.

Когда адрес и магазин были похожи, неудивительно, что мы склонялись к совпадению. И наоборот, если они оба были совершенно разными, мы, как правило, отвергали совпадение. Однако, когда сходство находилось в диапазоне от 35 до 70, все становилось немного противоречивым.

Другими словами, мы не могли принять четкое решение о выборе, когда сходство было между 35 и 70.

Первоначально мы думали об использовании эвристического дерева решений по этим двум факторам. Но после некоторого размышления мы поняли, что точность будет только от 60% до 65%.

Итак, мы решили попробовать что-то другое. Мы подумали, почему бы не передать эти факторы в модель машинного обучения для принятия окончательного решения?

XGBoost + Сходства

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

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

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

Неуверенность в себе

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

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

Это выглядело великолепно!

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

Вернуться к точности 73% 🥹

Fuzzy Wuzzy не был Fuzzy… Хватит

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

Нечеткое сопоставление «[email protected]» с «[email protected]» даст высокий показатель сходства. Однако очень маловероятно, что Джеймс и Джейн знают друг друга, не говоря уже о том, чтобы работать в одной компании! То же самое касается телефонных номеров; совпадения по этим атрибутам должны быть точными, а не нечеткими.

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

79%! Это не 81%, но не будем жадничать.

Особенности учетной записи

JDT использовала еще две точки данных, которые мы еще не использовали в нашей модели: статус учетной записи продавца и количество подключений.

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

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

Когда мы включили эти функции, наша точность подскочила выше, чем когда-либо.

Тестирование, тестирование, тестирование

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

85% точность?! Хороший!

Развертывание

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

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

Простой подход

Наше первое решение было простым: запустить процесс логического вывода непосредственно на нашем внутреннем сервере (созданном с использованием Python, Django, gRPC).

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

class RetailerMatchClassifier(metaclass=Singleton):
    def __init__(self):
        self._model = XGBClassifier()
        model_file_path = os.path.join(
            os.path.dirname(__file__), self.__class__.__name__ + "Model.json"
        )
        self._model.load_model(model_file_path)

    def make_accept_predictions(
        self, feature_sets: Iterable[RetailerMatchFeatureSet]
    ) -> Iterable[int]:
        """
        Returns an array of 0 or 1 for each provided feature set
        indicating whether the match should be accepted.
        :param feature_sets:
        :return: Iterable[int]
        """

        X = pd.DataFrame()

        for feature_set in feature_sets:
            row = pd.DataFrame(index=[0])

            row["address_similarity"] = fuzz.ratio(
                feature_set["imported_address"].lower(),
                feature_set["retailer"]["address"].lower(),
            )
            ...
            row["conn_cnt"] = feature_set["retailer"]["conn_cnt"]

            X = pd.concat([X, row])

        return self._model.predict(X)

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

  • Увеличение загрузки ОЗУ и ЦП для нашего наиболее важного сервиса. Всплески использования будут значительными (в рыночные периоды) и относительно непредсказуемыми.
  • Проблемы масштабирования. Рабочие нагрузки машинного обучения требуют масштабирования не так, как типичная рабочая нагрузка нашего серверного сервиса.
  • Увеличен размер образа сборки на 600 МБ за счет добавления зависимостей xgboost и pandas. Это увеличит нагрузку на память на наших ноутбуках при локальном запуске сервера.
  • Плохая согласованность. Возможно, эти прогнозы не входят в сферу ответственности нашей серверной службы.
  • Долгие интервалы между скачками использования логических выводов. Это может означать повышенную загрузку памяти, которая простаивает в течение длительного времени.
  • Связанные развертывания. Если бы нам нужно было обновить модель XGBoost, нам пришлось бы повторно развернуть все приложение.

Лучший подход

Рассмотрев несколько альтернатив, включая запуск автономного микросервиса и AWS Lambda, мы остановились на конечной точке AWS SageMaker с Serverless Inference для нашего развертывания.

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

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

Архитектура

Это решение было создано с использованием четырех ресурсов AWS:

  1. Конечная точка SageMaker для нашего внутреннего сервера для вызова и прогнозирования выбора.
  2. Конфигурация конечной точки SageMaker, используемая для настройки и подготовки нашей конечной точки. Благодаря бессерверной конфигурации мы избавились от сложности масштабирования службы, а также сэкономили на затратах в периоды ее неиспользования.
  3. Модель SageMaker, которая берет двоичный файл нашей модели XGBoost и упаковывает его для выполнения вывода.
  4. Бакет S3, содержащий двоичные файлы модели XGBoost. Объекты имеют семантические версии, чтобы упростить откат и отслеживать изменения в нашей модели с течением времени.

После настройки SageMaker Serverless Endpoint в каждой из наших сред развертывания все, что осталось сделать, — это заменить наш вызов XGBClassifier API на клиентский вызов SageMaker boto3.

class RetailerMatchClassifier(metaclass=Singleton):
    def __init__(self):
        self._client = boto3.client(
            "runtime.sagemaker",
            aws_access_key_id=settings.AWS_KEY,
            aws_secret_access_key=settings.AWS_SECRET,
        )

    def make_accept_predictions(
        self, feature_sets: Iterable[RetailerMatchFeatureSet]
    ) -> Iterable[int]:
        """
        Returns an array of 0 or 1 for each provided feature set
        indicating whether the match should be accepted.
        :param feature_sets:
        :return: Iterable[int]
        """
        file_stream = io.StringIO()
        writer = csv.writer(file_stream)

        for feature_set in feature_sets:
            row = []
            row.append(
                fuzz.ratio(
                    feature_set["imported_address"].lower(),
                    feature_set["retailer"]["address"].lower(),
                )
            )
            ...
            row.append(feature_set["retailer"]["conn_cnt"])

            writer.writerow(row)

        response = self._client.invoke_endpoint(
            EndpointName=settings.RETAILER_MATCH_CLASSIFIER_ENDPOINT_NAME,
            ContentType="text/csv",
            Body=file_stream.getvalue().strip(),
        )
        result = response["Body"].read().decode("ascii")
        return [round(float(r)) for r in result.split()]

Заключение

Этот инструмент сэкономил сотни часов не только нашим внутренним командам, но, что более важно, нашим брендам.

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

Кредиты

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