Извлеките значение отношений с помощью алгоритма внедрения FastRP для создания функций для задачи классификации нисходящих узлов.

Это третья статья из моей серии Twitchverse. Два предыдущих:

  1. Twitchverse: построение сети знаний Twitch в Neo4j
  2. Twitchverse: сетевой анализ вселенной Twitch с использованием Neo4j Graph Data Science

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

Повестка дня

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

  1. Очистка данных
  2. Вывести сеть для совместного чата
  3. Вложения FastRP
  4. Оценить точность классификации

Модель графа

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

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

Очистка данных

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

MATCH (l:Language)
RETURN l.name as language,
       size((l)<--()) as numberOfStreams
ORDER BY numberOfStreams
DESC

Результаты

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

MATCH (l:Language)
WHERE size((l)<--()) < 100
MATCH (l)<--(streamer)
SET streamer:Exclude
RETURN distinct 'success' as result

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

MATCH (s:Stream)
WHERE size((s)-[:HAS_LANGUAGE]->()) > 1
MATCH (s)-[:HAS_LANGUAGE]->(l)
RETURN s.name as streamer, collect(l.name) as languages

Результаты

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

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

MATCH (u:User)
WHERE NOT u:Exclude
WITH u, size((u)-[:CHATTER|VIP|MODERATOR]->()) as node_outdegree
RETURN node_outdegree, count(*) as count_of_users
ORDER BY node_outdegree ASC

Результаты

Эта линейная диаграмма была визуализирована с помощью библиотеки Seaborn. В нашей базе данных около 5000 стримеров, поэтому максимально возможное отклонение составляет 5000. Данные были получены за период в три дня. Рискну предположить, что пользователи, которые общались более чем в 1000 потоках, скорее всего, будут ботами. Я выбрал 200 в качестве фактического порога, поэтому пользователи, которые говорили более чем в 200 потоках за три дня, будут проигнорированы. Я думаю, что даже это великодушно. Чтобы достичь этого порога, вам нужно будет вести более 60 потоков в день.

MATCH (u:User)
WHERE NOT u:Exclude
WITH u, size((u)-[:CHATTER|VIP|MODERATOR]->()) as node_outdegree
WHERE node_outdegree > 200
SET u:Exclude

Также весьма вероятно, что самые активные модераторы на самом деле являются ботами.

MATCH (u:User)
WHERE NOT u:Exclude
RETURN u.name as user, size((u)-[:MODERATOR]->()) as mod_count
ORDER BY mod_count DESC LIMIT 10

Результаты

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

MATCH (u:User)
WHERE NOT u:Exclude
WITH u, size((u)-[:MODERATOR]->()) as mod_count
WHERE mod_count > 10
SET u:Exclude

Разделение данных на поезд

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

MATCH (l:Language)<-[:HAS_LANGUAGE]-(s:Stream)
WHERE NOT s:Exclude
RETURN l.name as language, count(*) as numberOfStreams
ORDER BY numberOfStreams DESC

Результаты

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

MATCH (s:Stream)-[:HAS_LANGUAGE]->(l:Language)
WHERE NOT s:Exclude
SET s.language = CASE WHEN l.name = 'en-gb' THEN 'en' ELSE l.name END

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

MATCH (s:Stream)
WHERE NOT s:Exclude
WITH s.language as language, s
ORDER BY s.name
WITH language, count(*) as count, collect(s) as streamers
WITH language, streamers, toInteger(count * 0.8) as training_size
UNWIND streamers[..training_size] as train_data
SET train_data:Train

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

Вывести сеть для совместного чата

Здесь мы начнем использовать библиотеку Neo4j Graph Data Science. Если вам нужно быстро освежить в памяти, как использовать библиотеку GDS, я предлагаю вам ознакомиться с сообщением в блоге, посвященном анализу сети Twitchverse. Вы должны знать, что библиотека GDS использует специальный граф в памяти для оптимизации производительности алгоритмов графа.

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

CALL gds.graph.create.cypher("twitch",
//node projection
"MATCH (u:User) 
 WHERE NOT u:Exclude 
 RETURN id(u) as id, labels(u) as labels, 
        coalesce(u.followers,0) as followers,
        coalesce(u.total_view_count,0) as total_view_count",
//relationship projection
"MATCH (s:User)-->(t:Stream)
 WHERE NOT s:Exclude AND NOT t:Exclude
 RETURN id(t) as source, id(s) as target",
{validateRelationships:false})

В первом утверждении мы спроецировали все узлы User, которые не были помечены вторичным ярлыком Exclude. Добавление меток узлов позволяет нам фильтровать узлы во время выполнения алгоритма. Это позволит нам фильтровать только узлы Stream при вычислении вложений FastRP. Мы также включили свойства узла follower и total_view_count. Во втором утверждении мы проецируем все отношения между пользователями и стримерами. Эти отношения показывают, какие пользователи общались в чате конкретного стримера.

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

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

  • TopK: количество сохраненных отношений для узла. Будут сохранены отношения K с наивысшими оценками сходства.
  • SimilarityCutoff: нижний предел оценки сходства, который должен присутствовать в результате. Любые отношения с оценкой ниже подобияCutoff будут автоматически проигнорированы в результатах.

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

CALL gds.nodeSimilarity.stats("twitch")
YIELD similarityDistribution
RETURN similarityDistribution

Результаты

Получаем распределение в виде процентилей. В среднем на пару стримеров приходится около 3% пользователей. Только 10% стримеров делят более 6% пользователей. В среднем стримеры не разделяют большую часть своей аудитории. Вероятно, это немного искажено, потому что данные были получены только за 3 дня, и учитываются только пользователи, которые участвовали в чате. Мы оставим для параметра подобиеCutoff значение по умолчанию 1E-42, что является очень маленьким числом, но немного больше 0. Отношения будут учитываться между парой стримеров, если они имеют хотя бы один общий Пользователь. Теперь нам нужно выбрать значение topK. Параметр topK сильно влияет на то, насколько плотной или разреженной будет результирующая одночастичная проекция. Путем проб и ошибок я решил использовать значение topK, равное 25.

CALL gds.nodeSimilarity.mutate("twitch", 
  {topK:25, mutateProperty:'score', mutateRelationshipType:'SHARED_AUDIENCE'})

Вложения FastRP

Fast Random Projection, или сокращенно FastRP, представляет собой алгоритм встраивания узлов в семейство алгоритмов случайного проецирования. Эти алгоритмы теоретически поддерживаются леммой Джонссона-Линденштрауса, согласно которой можно проецировать n векторов произвольной размерности в O (log (n)) размеры и при этом примерно сохраняют попарные расстояния между точками. Фактически, случайно выбранная линейная проекция удовлетворяет этому свойству.

Скопировано из документации.

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

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

CALL gds.fastRP.stream(
  "twitch",
  {nodeLabels:['Stream'], relationshipTypes:['SHARED_AUDIENCE'],
   relationshipWeightProperty:'score', embeddingDimension: 64}
) YIELD nodeId, embedding
WITH gds.util.asNode(nodeId) as node, nodeId, embedding
RETURN nodeId, embedding, node.language as language, CASE WHEN node:Train then 'train' else 'test' END as split

Очень часто для визуализации результирующих встраиваний узлов используется диаграмма рассеяния t-SNE. Код для следующей визуализации доступен в виде записной книжки Jupyter.

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

Оценка классификационной задачи

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

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

CALL gds.fastRP.stream(
  "twitch",
  {nodeLabels:['Stream'], relationshipTypes:['SHARED_AUDIENCE'],
   relationshipWeightProperty:'score', embeddingDimension: 64}
) YIELD nodeId, embedding
WITH gds.util.asNode(nodeId) as node, nodeId, embedding
RETURN nodeId, embedding, node.language as language, CASE WHEN node:Train then 'train' else 'test' END as split

Результаты

Без какой-либо тонкой настройки гиперпараметров алгоритмов FastRP или Random Forest мы получаем оценку f1 86%. Это очень круто. Похоже, наша гипотеза о том, что участники чата обычно общаются в потоках, использующих один и тот же язык, верна. Мы можем заметить, что модель ошибочно классифицируется между английским и второстепенными языками только при исследовании матрицы неточностей. Например, модель никогда не относила корейский язык ошибочно к португальскому языку. Это имеет смысл, поскольку английский является языком Интернета, и поэтому каждый может говорить хотя бы на своем родном языке и немного по-английски.

Теперь мы попытаемся оптимизировать гиперпараметры алгоритма FastRP для достижения большей точности.

Веса отношений

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

CALL gds.fastRP.stream(
  "twitch",
  {nodeLabels:['Stream'], relationshipTypes:['SHARED_AUDIENCE'],embeddingDimension: 64}
) YIELD nodeId, embedding
WITH gds.util.asNode(nodeId) as node, nodeId, embedding
RETURN nodeId, embedding, node.language as language, CASE WHEN node:Train then 'train' else 'test' END as split

Результаты

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

Размер встраивания

Гиперпараметр размера встраивания определяет размер выходного вектора или встраивания. В документации я нашел несколько общих рекомендаций:

Оптимальный размер встраивания зависит от количества узлов в графе. Поскольку объем информации, которую может кодировать встраивание, ограничен его размером, для более крупного графа, как правило, потребуется большее измерение для встраивания. Типичное значение - степень двойки в диапазоне 128–1024. Значение не менее 256 дает хорошие результаты на графах с порядком 105 узлов, но в целом увеличение размерности улучшает результаты. Однако увеличение размера внедрения приведет к линейному увеличению требований к памяти и времени выполнения.

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

CALL gds.fastRP.stream(
  "twitch",
  {nodeLabels:['Stream'], relationshipTypes:['SHARED_AUDIENCE'],
   embeddingDimension: 512}
) YIELD nodeId, embedding
WITH gds.util.asNode(nodeId) as node, nodeId, embedding
RETURN nodeId, embedding, node.language as language, CASE WHEN node:Train then 'train' else 'test' END as split

Результаты

Вес итерации

Следующий гиперпараметр, который мы можем настроить, - это вес итерации. В документации указано следующее:

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

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

CALL gds.fastRP.stream(
  "twitch",
  {nodeLabels:['Stream'], relationshipTypes:['SHARED_AUDIENCE'],
   embeddingDimension: 512,
   iterationWeights:[0.1, 0.5, 0.9, 1.0, 1.0]}
) YIELD nodeId, embedding
WITH gds.util.asNode(nodeId) as node, nodeId, embedding
RETURN nodeId, embedding, node.language as language, CASE WHEN node:Train then 'train' else 'test' END as split

Результаты

Нормализованный вес

Еще один параметр, который можно оптимизировать, - это вес нормализации. Снова в документации говорится:

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

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

Использование свойств узла

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

CALL gds.beta.fastRPExtended.stream(
  "twitch",
  {nodeLabels:['Stream'], relationshipTypes:['SHARED_AUDIENCE'],
   embeddingDimension: 512, featureProperties: ['followers'],
   iterationWeights:[0.1, 0.5, 0.9, 1.0, 1.0]}
) YIELD nodeId, embedding
WITH gds.util.asNode(nodeId) as node, nodeId, embedding
RETURN nodeId, embedding, node.language as language, CASE WHEN node:Train then 'train' else 'test' END as split

Результаты

Вывод

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

Как всегда, код доступен на GitHub.