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

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

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

Постановка задачи

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

  • Заголовок

  • Теги

  • Картинки

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

  1. Рекомендации должны предлагаться через взаимодействие пользователя с контентом.

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

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

  4. Низкое время отклика рекомендации: любое взаимодействие с контентом должно тут же попадать в рекомендацию

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

Модель данных

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

Связи классификаторов к посту
Связи классификаторов к посту

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

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

Эмоциональные признаки можно описать следующим образом:

  • Удержать внимание на посте +1 очко

  • Поставил лайк посту +2 очка, и т.д.

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

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

У этого поста есть три связи на классификаторы: sky, ocean и nature, значит пользователю добавится следующий опыт: sky(+1), ocean(+1), nature(+1).

Задержать внимание добавляет +1 очка каждой классификации
Задержать внимание добавляет +1 очка каждой классификации

Затем пользователь лайкнул следующий пост, у которого есть две связи на классификаторы: mountain, nature.

Складываем его с уже существующем опытом и получаем: sky(1), ocean(1), nature(1+2), mountain(+2).

Лайк посту добавляет +2 очка каждой классификации
Лайк посту добавляет +2 очка каждой классификации



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

Описание алгоритма

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

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

Перед тем, как мы заполним наши вектора, нам нужно сделать объединение наших классификаторов между пользовательским опытом и постом с соблюдением следующих условий:

  • Для первого вектора:

    • все классификаторы пользовательского добавляются как есть.

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

  • Для вектора с которым будем сравнивать:

    • все классификаторы поста двигаются к максимуму.

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

Разберем пример


Предположим, что у пользователя есть следующий накопленный опыт:
sky(1), ocean(1), nature(3), mountain(2)
А пост содержит следующие классификации:
mountain, alp

Результат для первого вектора будет равен:
sky(1), ocean(1), nature(3), mountain(2), alp(0)

Результат для второго вектора, с которым будем сравнивать, будет равен:
sky(0), ocean(0), nature(0), mountain(3), alp(3)

Остается только вычислить косинус угла.
cosine([1,1,3,2,0], [0,0,0,3,3]) = 0.365148

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

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

Например, алгоритмы нейронной сети распознали ключевые слова на картинке с вероятностью:

  • nature 98.9%

  • mountain 3.6%

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

Добавляем к нашим классификаторам признак соответствия от 0 до 1. Очевидно то, что 0 не может быть признаком соответствия и это зависит от доменной области. В своем проекте я использовал классификаторы с признаками соответствия, которые лежали в диапазоне от 0.15 до 1 , где - 1 была полным соответствием, а 0.15 - минимальным.

Доработка алгоритма была в следующем.

Для второй части вектора применим те самые действия, что и для первого, и уравновесим их, приводя их максимумы к одному общему знаменателю по следующей формуле: n/max(n).

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

Разберем пример


Предположим, что у пользователя есть следующий опыт:
sky(1), ocean(1), nature(3), mountain(2)

Пост содержит следующие классификаторы с признаками соответствия:
mountain(0.96), alp(0.85)

Результат для первого вектора будет равен:
sky(1/3=0.33333333333), ocean(1/3=0.33333333333), nature(3/3=1), mountain(2/3=0.66666666666), alp(0.0)

Результат для второго вектора, с которым будем сравнивать, будет равен:
sky(0.0), ocean(0.0), nature(0.0), mountain(0.96/0.96=1.0), alp(0.85/0.96=0.88541666666)

Остается только вычислить косинус угла:
cosine([0.33333333333,0.33333333333,1.0,0.66666666666,0.0], [0.0,0.0,0.0,1.0,0.88541666666]) = 0.386626

Анализ алгоритма

У алгоритма есть линейная сложность o(n).
Так как алгоритм основан на косинусном сравнении, то он унаследует и проблему энтропии, но для рекомендаций это и погрешностью считать сложно.

Реализацию данного алгоритма я выложил в своем GitHub аккаунте в виде функции плагина к Neo4j. Там же можно найти и примеры использования.

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

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

Комментарии (12)


  1. AndrewShmig
    26.06.2023 15:22

    Ожидания - МЛ, эмбединги, векторные БД… реальность - Neo4j…


  1. lair
    26.06.2023 15:22
    -2

    Я правильно понимаю, что ваш "алгоритм" сводится к косинусному расстоянию по фиксированному набору признаков?

    У алгоритма есть линейная сложность o(n).

    ...где n - это что?


    1. divin_dmitry Автор
      26.06.2023 15:22

      Да, все верно, алгоритм сводится к косинусному расстоянию по фиксированному набору признаков. Вообще-то сложность O(4N), так как в нем четыре независимых цикла, но в описании сложности принято константы отбрасывать.


      1. lair
        26.06.2023 15:22

        Вообще-то сложность O(4N), так как в нем четыре независимых цикла, но в описании сложности принято константы отбрасывать.

        Где N - это что?


        1. divin_dmitry Автор
          26.06.2023 15:22

          N - это количество итераций цикла, которое зависит от количества параметров, т.е чем больше параметров, тем больше итераций и тем выше latency.


          1. lair
            26.06.2023 15:22

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

            Я правильно понимаю, что у вас эта сложность посчитана для одной рекомендации; иными словами, для M объектов, из которых мы выбираем рекомендации, "реальная" (наблюдаемая пользователем) сложность будет O(M*N) (потому что вам для каждого объекта надо выполнить ваш алгоритм)?

            PS Еще, кстати, интересный вопрос: N - это число всех возможных параметров (т.е. всех ключевых слов в системе), или только используемых в конкретной паре?


            1. divin_dmitry Автор
              26.06.2023 15:22

              Отвечаю на ваш первый вопрос:
              Да все верно, сложность только для одного сравнения.
              В моем случае, все посты имели в среднем 76 классификаторов, а у пользователя в качестве опыта их было 45
              Количество постов было ~10000 и рекомендация отдавалась за 600ms, что меня полностью устраивало.

              Отвечаю на ваш второй вопрос:
              В сравнении участвуют только классификаторы отдельно взятого поста и пользователя


              1. lair
                26.06.2023 15:22

                Да все верно, сложность только для одного сравнения.

                Тогда правильнее говорить, что сложность - O(N*M). И это, будем честными, тривиальная сложность для косинуса.

                Проблемы начинаются тогда, когда N радикально больше, чем у вас.

                В сравнении участвуют только классификаторы отдельно взятого поста и пользователя

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


    1. AndrewShmig
      26.06.2023 15:22

      За случайный минус прошу простить - ткнул с телефона мимо, плюсанул в карму в качестве извинений.


  1. lair
    26.06.2023 15:22

    Для второй части вектора применим те самые действия, что и для первого, и уравновесим их, приводя их максимумы к одному общему знаменателю по следующей формуле: n/max(n).

    А почему, кстати, нормализация по максимуму, а не по сумме?

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


    1. divin_dmitry Автор
      26.06.2023 15:22

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

      Я это сделал с помощью следующего запроса:

      CREATE (:Classifier{id: 0, keyword: 'sky'})
      CREATE (:Classifier{id: 1, keyword: 'ocean'})
      CREATE (:Classifier{id: 2, keyword: 'nature'})
      CREATE (:Classifier{id: 3, keyword: 'alp'})
      CREATE (:Classifier{id: 4, keyword: 'mountain'})

      MATCH (c:Classifier) WHERE rand() > 0.5
      WITH apoc.coll.shuffle(COLLECT(c)) AS classifiers
      UNWIND classifiers AS classifier
      WITH classifier, toFloat(rand()*100) AS rnd
      WITH COLLECT(classifier.id) AS ux_classifiers, COLLECT(rnd) AS ux_features, COLLECT(classifier.keyword + '(' + toInteger(rnd) + ')') AS ux_title
      UNWIND range(0, 10) AS it
      MATCH (c:Classifier) WHERE rand() > 0.7
      WITH it, ux_classifiers, ux_features, ux_title, COLLECT(c.id) AS classifiers, COLLECT(1.0) AS features, COLLECT(c.keyword) AS title
      RETURN ux_title, title, alg.classifiers.similar(ux_classifiers, ux_features, classifiers, features) AS score
      ORDER BY score DESC


      1. lair
        26.06.2023 15:22

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