CoderCastrov logo
CoderCastrov
Neo4j

Анализ новостей с помощью графа знаний

Анализ новостей с помощью графа знаний
просмотров
13 мин чтение
#Neo4j

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

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

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

Конвейер данных состоит из трех частей. В первой части мы парсим статьи с провайдера новостей в Интернете. Затем мы обрабатываем статьи с помощью конвейера NLP и сохраняем результаты в виде графа знаний. В последней части конвейера данных мы обогащаем наши знания информацией из API WikiData. Чтобы продемонстрировать преимущества использования графа знаний для хранения информации из конвейера данных, мы проводим простой сетевой анализ и пытаемся найти инсайты.

Повестка дня

  • Парсинг новостей из интернета
  • Связывание сущностей с помощью Wikifier
  • Обогащение данных из Википедии
  • Анализ сети

Модель графа

Мы используем Neo4j для хранения нашего графа знаний. Если вы хотите следовать за этим блогом, вам нужно скачать Neo4j и установить библиотеки APOC и Graph Data Science. Весь код также доступен на GitHub.

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

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

CREATE CONSTRAINT IF NOT EXISTS ON (a:Article) ASSERT a.url IS UNIQUE;
CREATE CONSTRAINT IF NOT EXISTS ON (e:Entity) ASSERT e.wikiDataItemId is UNIQUE;
CREATE CONSTRAINT IF NOT EXISTS ON (t:Tag) ASSERT t.name is UNIQUE;

Парсинг новостей из интернета

Затем мы производим парсинг новостного портала CNET. Я выбрал портал CNET, потому что у него самая последовательная структура HTML, что облегчает демонстрацию концепции конвейера данных, не фокусируясь на парсинге. Мы используем процедуру apoc.load.html для парсинга HTML. Она использует библиотеку jsoup внутри себя. Более подробную информацию можно найти в документации.

Сначала мы перебираем популярные темы и сохраняем ссылку на последние десяток статей по каждой теме в Neo4j.

CALL apoc.load.html("[https://www.cnet.com/news/](https://www.cnet.com/news/)", 
  {topics:"div.tag-listing > ul > li > a"}) YIELD value
UNWIND value.topics as topic  
WITH "[https://www.cnet.com](https://www.cnet.com)" + topic.attributes.href as link
CALL apoc.load.html(link, {article:"div.row.asset > div > a"}) YIELD value
UNWIND value.article as article
WITH distinct "[https://www.cnet.com](https://www.cnet.com)" + article.attributes.href as article_link
MERGE (a:Article{url:article_link});

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

MATCH (a:Article)
CALL apoc.load.html(a.url,
{date:"time", title:"h1.speakableText", text:"div.article-main-body > p", tags: "div.tagList > a"}) YIELD value
SET a.datetime = datetime(value.date[0].attributes.datetime)
FOREACH (_ IN CASE WHEN value.title[0].text IS NOT NULL THEN [true] ELSE [] END | 
           CREATE (a)-[:HAS_TITLE]->(:Section{text:value.title[0].text})
)
FOREACH (t in value.tags | 
  MERGE (tag:Tag{name:t.text}) MERGE (a)-[:HAS_TAG]->(tag)
)
WITH a, value.text as texts
UNWIND texts as row
WITH a,row.text as text
WHERE text IS NOT NULL
CREATE (a)-[:HAS_SECTION]->(:Section{text:text});

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

MATCH (n:Tag)
WHERE n.name CONTAINS "Notification"
DETACH DELETE n;

Давайте оценим наш процесс парсинга и посмотрим, сколько статей было успешно спарсено.

MATCH (a:Article)
RETURN exists((a)-[:HAS_SECTION]->()) as scraped_articles,
       count(*) as count

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

Давайте также рассмотрим самые часто встречающиеся теги статей.

MATCH (n:Tag)
RETURN n.name as tag, size((n)<-[:HAS_TAG]-()) as articles
ORDER BY articles DESC
LIMIT 10

Вот результаты:

Image by author

Все графики в этом блог-посте созданы с использованием библиотеки Seaborn. Самый частый тег - CNET Apps Today. Я думаю, что это просто общий тег для ежедневных новостей. Мы можем наблюдать, что у них есть специальные теги для различных крупных компаний, таких как Amazon, Apple и Google.

Связывание именованных сущностей: Викификация

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

Прежде всего, что такое связывание именованных сущностей?

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

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

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

Существует дюжина моделей связывания сущностей. Некоторые из них:

Я из Словении, поэтому мое предвзятое решение - использовать словенское решение Wikifier [1]. Они фактически не предлагают свою модель NLP, но у них есть бесплатный API-эндпоинт, который можно использовать. Вам просто нужно зарегистрироваться. Они даже не требуют вашего пароля или электронной почты, что приятно.

Wikifier поддерживает более 100 языков. Он также предлагает некоторые параметры, которые можно использовать для настройки результатов. Я заметил, что наиболее важным параметром является параметр pageRankSqThreshold, который можно использовать для оптимизации точности или полноты модели.

Если мы запустим вышеуказанный пример через API Wikifier, мы получим следующие результаты:

Мы можем наблюдать, что API Wikifier вернул три сущности и их соответствующий URL Википедии, а также идентификатор элемента WikiData. Мы используем идентификатор элемента WikiData в качестве уникального идентификатора для их сохранения в Neo4j.

В библиотеке APOC есть процедура apoc.load.json, которую можно использовать для получения результатов из любого API-эндпоинта. Если у вас есть дело с большим объемом данных, вам следует использовать процедуру apoc.periodic.iterate для разбиения на пакеты.

Если мы объединим все вместе, следующий запрос Cypher извлекает результаты аннотации для каждого раздела из API-эндпоинта и сохраняет результаты в Neo4j.

CALL apoc.periodic.iterate('
 MATCH (s:Section) RETURN s
 ','
 WITH s, "[http://www.wikifier.org/annotate-article](http://www.wikifier.org/annotate-article)?" +
        "text=" + apoc.text.urlencode(s.text) + "&" +
        "lang=en&" +
        "pageRankSqThreshold=0.80&" +
        "applyPageRankSqThreshold=true&" +
        "nTopDfValuesToIgnore=200&" +
        "nWordsToIgnoreFromList=200&" +
        "minLinkFrequency=100&" + 
        "maxMentionEntropy=10&" +
        "wikiDataClasses=false&" +
        "wikiDataClassIds=false&" +
        "userKey=" + $userKey as url
CALL apoc.load.json(url) YIELD value
UNWIND value.annotations as annotation
MERGE (e:Entity{wikiDataItemId:annotation.wikiDataItemId})
ON CREATE SET e.title = annotation.title, e.url = annotation.url
MERGE (s)-[:HAS_ENTITY]->(e)',
{batchSize:100, params: {userKey:$user_key}})

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

MATCH (e:Entity)
RETURN e.title, size((e)<--()) as mentions
ORDER BY mentions DESC LIMIT 10;

Вот результаты:

Image by author

Apple Inc. - самая часто упоминаемая сущность. Я предполагаю, что все знаки доллара или упоминания USD связаны с долларом США. Мы также можем изучить наиболее часто упоминаемые теги по статьям.

MATCH (e:Entity)<-[:HAS_ENTITY]-()<-[:HAS_SECTION]-()-[:HAS_TAG]->(tag)
WITH tag.name as tag, e.title as title, count(*) as mentions
ORDER BY mentions DESC
RETURN tag, collect(title)[..3] as top_3_mentions
LIMIT 5;

Вот результаты:

Image by author

Обогащение данных из WikiData

Дополнительным преимуществом использования процесса Викификации является наличие идентификатора элемента WikiData для наших сущностей. Это позволяет нам легко получить дополнительную информацию из API WikiData.

Допустим, мы хотим определить все бизнес-сущности и сущности-людей. Мы получим классы сущностей из API WikiData и используем эту информацию для группировки сущностей. Опять же, мы будем использовать процедуру apoc.load.json для получения ответа от конечной точки API.

MATCH (e:Entity)
// Подготовим SparQL-запрос
WITH 'SELECT *
      WHERE{
        ?item rdfs:label ?name .
        filter (?item = wd:' + e.wikiDataItemId + ')
        filter (lang(?name) = "en" ) .
      OPTIONAL{
        ?item wdt:P31 [rdfs:label ?class] .
        filter (lang(?class)="en")
      }}' AS sparql, e
// Сделаем запрос к WikiData
CALL apoc.load.jsonParams(
    "[https://query.wikidata.org/sparql?query=](https://query.wikidata.org/sparql?query=)" + 
    apoc.text.urlencode(sparql),
     { Accept: "application/sparql-results+json"}, null)
YIELD value
UNWIND value['results']['bindings'] as row
FOREACH(ignoreme in case when row['class'] is not null then [1] else [] end | 
        MERGE (c:Class{name:row['class']['value']})
        MERGE (e)-[:INSTANCE_OF]->(c));

Затем мы исследуем наиболее часто встречающиеся классы сущностей.

MATCH (c:Class)
RETURN c.name as class, size((c)<--()) as count
ORDER BY count DESC LIMIT 5;

Вот результаты:

Image by author

Процесс Викификации нашел почти 250 сущностей-людей и 100 бизнес-сущностей. Мы присваиваем вторичную метку сущностям "Person" и "Business", чтобы упростить наши дальнейшие запросы на языке Cypher.

MATCH (e:Entity)-[:INSTANCE_OF]->(c:Class)
WHERE c.name in ["human"]
SET e:Person;
MATCH (e:Entity)-[:INSTANCE_OF]->(c:Class)
WHERE c.name in ["business", "enterprise"]
SET e:Business;

С добавленной вторичной меткой мы теперь легко можем изучить наиболее часто упоминаемые бизнес-сущности.

MATCH (b:Business)
RETURN b.title as business, size((b)<-[:HAS_ENTITY]-()) as mentions
ORDER BY mentions DESC
LIMIT 10

Вот результаты:

Image by author

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

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

MATCH (e:Business)
// Подготовим SparQL-запрос
WITH 'SELECT *
      WHERE{
        ?item rdfs:label ?name .
        filter (?item = wd:' + e.wikiDataItemId + ')
        filter (lang(?name) = "en" ) .
      OPTIONAL{
        ?item wdt:P452 [rdfs:label ?industry] .
        filter (lang(?industry)="en")
      }}' AS sparql, e
// Сделаем запрос к WikiData
CALL apoc.load.jsonParams(
    "[https://query.wikidata.org/sparql?query=](https://query.wikidata.org/sparql?query=)" + 
    apoc.text.urlencode(sparql),
     { Accept: "application/sparql-results+json"}, null)
YIELD value
UNWIND value['results']['bindings'] as row
FOREACH(ignoreme in case when row['industry'] is not null then [1] else [] end | 
        MERGE (i:Industry{name:row['industry']['value']})
        MERGE (e)-[:PART_OF_INDUSTRY]->(i));

Анализ графа исследования

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

MATCH (b:Business)
WITH b, size((b)<-[:HAS_ENTITY]-()) as mentions
ORDER BY mentions DESC 
LIMIT 1
MATCH (other_entities)<-[:HAS_ENTITY]-()-[:HAS_ENTITY]->(b)
RETURN other_entities.title as entity, count(*) as count
ORDER BY count DESC LIMIT 10;

Вот результаты:

Изображение автора

Здесь нет ничего особенного. Apple Inc. упоминается в разделах, где также упоминаются iPhone, Apple Watch и VR. Давайте посмотрим на более интересные новости. Я искал любые соответствующие теги статей, которые могут быть интересными.

У CNET есть много конкретных тегов, но тег Фондовый рынок выделяется как более общий и очень актуальный в наше время. Давайте проверим наиболее часто упоминаемые отрасли в категории статей о фондовом рынке.

MATCH (t:Tag)<-[:HAS_TAG]-()-[:HAS_SECTION]->()-[:HAS_ENTITY]->(entity:Business)-[:PART_OF_INDUSTRY]->(industry)
WHERE t.name = "Фондовый рынок"
RETURN industry.name as industry, count(*) as mentions
ORDER BY mentions DESC
LIMIT 10

Вот результаты:

Изображение автора

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

MATCH (t:Tag)<-[:HAS_TAG]-()-[:HAS_SECTION]->()-[:HAS_ENTITY]->(entity)
WHERE t.name = "Фондовый рынок" AND (entity:Person OR entity:Business)
RETURN entity.title as entity, count(*) as mentions
ORDER BY mentions DESC
LIMIT 10

Вот результаты:

Изображение автора

Хорошо, так GameStop стал огромным на этой неделе с более чем 40 упоминаниями. Очень далеко позади находятся Джим Креймер, Илон Маск и Александрия Окасио-Кортес. Давайте попробуем понять, почему GameStop так популярен, посмотрев на совместно упоминаемые сущности.

MATCH (b:Business{title:"GameStop"})<-[:HAS_ENTITY]-()-[:HAS_ENTITY]->(other_entity)
RETURN other_entity.title as co_occurent_entity, count(*) as mentions
ORDER BY mentions DESC
LIMIT 10

Вот результаты:

Изображение автора

Наиболее часто упоминаемые сущности в том же разделе, что и GameStop, - это акции, Reddit и американский доллар. Если вы посмотрите на новости, вы можете понять, что результаты имеют смысл. Я предполагаю, что AMC (телеканал) был неправильно идентифицирован и, вероятно, должен быть компания AMC Theaters.

В процессе обработки естественного языка всегда будут ошибки. Мы можем немного отфильтровать результаты и поискать наиболее совместно упоминаемые личности или бизнесы GameStop.

MATCH (b:Business{title:"GameStop"})<-[:HAS_ENTITY]-()-[:HAS_ENTITY]->(other_entity:Person)
RETURN other_entity.title as co_occurent_entity, count(*) as mentions
ORDER BY mentions DESC
LIMIT 10

Вот результаты:

Изображение автора

Александрия Окасио-Кортес (AOC) и Илон Маск появляются в трех разделах с GameStop. Давайте рассмотрим текст, где AOC совпадает с GameStop.

MATCH (b:Business{title:"GameStop"})<-[:HAS_ENTITY]-(section)-[:HAS_ENTITY]->(p:Person{title:"Alexandria Ocasio-Cortez"})
RETURN section.text as text

Вот результаты:

Изображение автора

Графовый анализ данных

До сих пор мы только выполняли несколько агрегаций с использованием языка запросов Cypher. Поскольку мы используем граф знаний для хранения нашей информации, давайте выполним некоторые графовые алгоритмы на нем. Библиотека Neo4j Graph Data Science - это плагин для Neo4j, который в настоящее время содержит более 50 графовых алгоритмов. Алгоритмы охватывают обнаружение сообществ и центральность, а также встраивание узлов и графовые нейронные сети.

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

Изображение автора

Запрос Cypher для вывода сети совместного появления лиц выглядит следующим образом:

MATCH (s:Person)<-[:HAS_ENTITY]-()-[:HAS_ENTITY]->(t:Person)
WHERE id(s) < id(t)
WITH s,t, count(*) as weight
MERGE (s)-[c:CO_OCCURENCE]-(t)
SET c.weight = weight

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

CALL gds.wcc.write({
    nodeProjection:'Person',
    relationshipProjection:'CO_OCCURENCE',
    writeProperty:'wcc'})
YIELD componentCount, componentDistribution

Вот результаты:

Изображение автора

Алгоритм обнаружил 134 отключенных компонента в нашем графе. Значение p50 - это 50-й процентиль размера сообщества. Большая часть компонент состоит из одного узла.

Это означает, что у них нет отношений CO_OCCURENCE. Самый большой остров узлов состоит из 30 участников. Мы помечаем его участников вторичной меткой.

MATCH (p:Person)
WITH p.wcc as wcc, collect(p) as members
ORDER BY size(members) DESC LIMIT 1
UNWIND members as member
SET member:LargestWCC

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

CALL gds.graph.create('person-cooccurence', 'LargestWCC',   
  {CO_OCCURENCE:{orientation:'UNDIRECTED'}}, 
  {relationshipProperties:['weight']})

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

CALL gds.pageRank.write('person-cooccurence', {relationshipWeightProperty:'weight', writeProperty:'pagerank'})

Затем мы запускаем алгоритм Лувена, который является алгоритмом обнаружения сообществ.

CALL gds.louvain.write('person-cooccurence', {relationshipWeightProperty:'weight', writeProperty:'louvain'})

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

Цвет узла представляет сообщества, а размер узла представляет оценку PageRank. Изображение автора

Заключение

Мне очень нравится, как NLP и графы знаний идеально сочетаются. Надеюсь, я дал вам некоторые идеи и указания о том, как вы можете реализовать свой конвейер данных и сохранить результаты в виде графа знаний. Дайте мне знать, что вы думаете!

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

Ссылки

[1] Janez Brank, Gregor Leban, Marko Grobelnik. Annotating Documents with Relevant Wikipedia Concepts. Proceedings of the Slovenian Conference on Data Mining and Data Warehouses (SiKDD 2017), Ljubljana, Slovenia, 9 October 2017.