Всем привет! Меня зовут Дмитрий Дивин, я хотел бы поделиться своим опытом проектирования алгоритма для рекомендательной системы.
Примерно два месяца назад я начал интересоваться рекомендательными системами и способами их реализации.
Есть некоторые идеи и неплохие результаты, с которыми я бы хотел поделиться.
Постановка задачи
С точки зрения пользователя, моя задача выглядит следующим образом.
Одна группа пользователей публикует посты, в которых имеется следующий контент:
Заголовок
Теги
Картинки
Другая группа пользователей взаимодействует с этим постами и тем самым накапливает опыт, который в последующем и используется, чтобы сформировать рекомендацию.
Мой проект, для которого я проектирую рекомендательную систему, должен соблюдать следующие требования согласно моей доменной области:
Рекомендации должны предлагаться через взаимодействие пользователя с контентом.
В рекомендациях не нужно учитывать интересы других пользователей.
Старый опыт взаимодействия с контентом должен отбрасываться через неделю, а возможно и через несколько дней.
Низкое время отклика рекомендации: любое взаимодействие с контентом должно тут же попадать в рекомендацию
Забегая вперед, мне удалось решить эту задачу с помощью графовой базы Neo4J и получить отличный latency для рекомендаций в реальном времени. Хочу отдать должное разработчикам этой базы за то, что это отличный продукт, для того чтобы понять, что такое графовые базы. Это был мой первый опыт работы с таким типом баз данных, но я уверяю Вас, что подход, который я опишу ниже, можно реализовать и на обычных реляционных базах.
Модель данных
Моя модель данных и их связи выглядят следующим образом. Посты связаны с классификаторами через связь один ко многим. В качестве классификаторов я использовал ключевые слова. Предполагается, что теги, описание картинок и заголовок, должны соответствовать какому-то набору ключевых слов. Таким образом эти связи и образуются.
Пользовательский опыт можно описать следующем образом.
При каждом взаимодействии с постом берутся все классификаторы этого поста и суммируются очки взаимодействия с уже существующим пользовательским опытом.
Очки события - это эмоциональные признаки. Чем выше эмоция у пользователя, тем выше очки взаимодействия.
Эмоциональные признаки можно описать следующим образом:
Удержать внимание на посте +1 очко
Поставил лайк посту +2 очка, и т.д.
Можем рассмотреть пример накопления очков подробней, который может понять данный подход.
Предположим, что у пользователя еще нет опыта и ему попался интересующий его пост, на котором он удержал внимание.
У этого поста есть три связи на классификаторы: sky, ocean и nature, значит пользователю добавится следующий опыт: sky(+1), ocean(+1), nature(+1).
Затем пользователь лайкнул следующий пост, у которого есть две связи на классификаторы: mountain, nature.
Складываем его с уже существующем опытом и получаем: sky(1), ocean(1), nature(1+2), mountain(+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)
lair
26.06.2023 15:22-2Я правильно понимаю, что ваш "алгоритм" сводится к косинусному расстоянию по фиксированному набору признаков?
У алгоритма есть линейная сложность
o(n)
....где
n
- это что?divin_dmitry Автор
26.06.2023 15:22Да, все верно, алгоритм сводится к косинусному расстоянию по фиксированному набору признаков. Вообще-то сложность O(4N), так как в нем четыре независимых цикла, но в описании сложности принято константы отбрасывать.
lair
26.06.2023 15:22Вообще-то сложность O(4N), так как в нем четыре независимых цикла, но в описании сложности принято константы отбрасывать.
Где
N
- это что?divin_dmitry Автор
26.06.2023 15:22N - это количество итераций цикла, которое зависит от количества параметров, т.е чем больше параметров, тем больше итераций и тем выше latency.
lair
26.06.2023 15:22То есть у вас алгоритм с линейной сложностью от числа параметров. Вроде бы, для косинусного расстояния это тривиально. Но не суть.
Я правильно понимаю, что у вас эта сложность посчитана для одной рекомендации; иными словами, для
M
объектов, из которых мы выбираем рекомендации, "реальная" (наблюдаемая пользователем) сложность будетO(M*N)
(потому что вам для каждого объекта надо выполнить ваш алгоритм)?PS Еще, кстати, интересный вопрос:
N
- это число всех возможных параметров (т.е. всех ключевых слов в системе), или только используемых в конкретной паре?divin_dmitry Автор
26.06.2023 15:22Отвечаю на ваш первый вопрос:
Да все верно, сложность только для одного сравнения.
В моем случае, все посты имели в среднем 76 классификаторов, а у пользователя в качестве опыта их было 45
Количество постов было ~10000 и рекомендация отдавалась за 600ms, что меня полностью устраивало.Отвечаю на ваш второй вопрос:
В сравнении участвуют только классификаторы отдельно взятого поста и пользователяlair
26.06.2023 15:22Да все верно, сложность только для одного сравнения.
Тогда правильнее говорить, что сложность -
O(N*M)
. И это, будем честными, тривиальная сложность для косинуса.Проблемы начинаются тогда, когда
N
радикально больше, чем у вас.В сравнении участвуют только классификаторы отдельно взятого поста и пользователя
А вы уверены, что получающиеся результаты (дистанции, полученные в разных пространствах) корректно сравнивать между собой?
AndrewShmig
26.06.2023 15:22За случайный минус прошу простить - ткнул с телефона мимо, плюсанул в карму в качестве извинений.
lair
26.06.2023 15:22Для второй части вектора применим те самые действия, что и для первого, и уравновесим их, приводя их максимумы к одному общему знаменателю по следующей формуле: n/max(n).
А почему, кстати, нормализация по максимуму, а не по сумме?
Вообще, конечно, в этот момент уже надо доставать из кармана тестовый датасет, и на нем считать метрики, какой подход дает лучший результат.
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
AndrewShmig
Ожидания - МЛ, эмбединги, векторные БД… реальность - Neo4j…