library(ggraph)
library(igraph)
library(tidyverse)
library(xml2)Анализ сетевых данных
Компьютерный анализ текстов, модуль 3
Импорт необходимых для работы библиотек
Импорт данных
В данном домашнем задании я решил работать со вторым вариантом — персонажами «Войны и мира» Льва Николаевича Толстого. Для начала работы нужно импортировать данные из xml-файла.
# чтение xml-файла по его URL
doc <- read_xml('https://dataverse.pushdom.ru/api/access/datafile/4212')Для анализа я пробую работать над вторым томом произведения. Он мой самый любимый из всех четырёх, потому что там больше «мира»; том изобилует описаниями жизни высшего общества, и это в сво время было очень интересно читать.
# задать пространство имён и присвоить ему более информативное название
ns <- xml_ns_rename(xml_ns(doc), d1 = 'tei')
# сохранить корневой элемент
rootnode <- xml_root(doc)
# список узлов, в которых сохранены томы из «Войны и мира»
xml_find_all(rootnode, '//tei:text//tei:div[@type = "volume"]', ns){xml_nodeset (4)}
[1] <div n="1" type="volume" xml:id="Volume1">\n <div n="1" type="part" xml: ...
[2] <div n="2" type="volume" xml:id="Volume2">\n <div n="1" type="part" xml: ...
[3] <div n="3" type="volume" xml:id="Volume3">\n <div n="1" type="part" xml: ...
[4] <div n="4" type="volume" xml:id="Volume4">\n <div n="1" type="part" xml: ...
После этого я обращаюсь ко второму тому.
# сохранить второй том, обращаясь к атрибутам
volume_2 <- xml_find_first(rootnode, '//tei:div[@type = "volume"][@n = "2"]', ns)Создание графа
Подготовка
Теперь подготовим граф, который должен служить репрезентациею взаимодействия персонажей. Начну с того, что нужно получить список персонажей вообще (это понадобится для дальнейшего оформления графа). Для этого я подготовил функцию, которая забирает из people имена персонажей (подробные, кириллицею) и их id (латиницею). Если у персонажа не было имени, записанного кириллицею, я его заменил на id. Примеры из xml-файла:
...
<person xml:id="Pfuel">
</person>
...
<person xml:id="Mademoiselle_Bourienne">
</person>
...
Чуть ниже я это меняю, а пока что исправляем через заполнение:
people <- xml_find_all(rootnode, '//tei:listPerson/tei:person', ns)
# функция
extraction <- function(p) {
# вычленить id из тэгов за помощью регулярного выражения
id <- str_extract(as.character(p), '(?<=xml:id=")[^"]+')
# сохранить имя, удалив \n и лишние пробелы
name <- str_squish(xml_text(p, './persName'))
# если после преобразований имя - пустая строка, приравнять к id
if (name == '') name <- id
# сохранить в tibble
person = tibble(
id = id,
name = name
)
}
# применить функцию ко всем узлам
people_ids <- map_dfr(people, extraction)Получившиеся ячейки в столбце name с латиницею, коих получилось немного, я предлагаю переделать, чтобы всё было красиво. Добавляем имена в кириллице:
# case_when(): заменить содержание name по индексу, иначе оставить как есть
people_ids <- people_ids |>
mutate(name = case_when(
row_number() == 17 ~ 'Алина Курагина',
row_number() == 30 ~ 'Пфуль',
row_number() == 48 ~ 'Павел Васильевич Чичагов',
row_number() == 57 ~ 'Мадемуазель Бурьен',
TRUE ~ name
))Теперь я перехожу к созданию датафрэйма на основе взаимодействий персонажей во втором томе. Взаимодействия отображаются рамках узлов said через corresp (кому говорят) и who (кто говорит). В такой системе мы можем задать направленность будущего графа.
# забрать все узлы said из второго тома
said <- xml_find_all(volume_2, ".//tei:said", ns)
# сохранить в виде таблицы информацию о взаимодействиях
interactions <- map_dfr(said, ~tibble(
who_utter = xml_attr(.x, "who"),
corresp_utter = xml_attr(.x, "corresp"),
))
# вывести в качестве примера начало таблицы
head(interactions, 10)# A tibble: 10 × 2
who_utter corresp_utter
<chr> <chr>
1 Nikolai_Rostov "Vasily__Vasska__Denisov"
2 Nikolai_Rostov "NatashaRostova"
3 Nikolai_Rostov "Vasily__Vasska__Denisov"
4 Nikolai_Rostov "Vasily__Vasska__Denisov"
5 Nikolai_Rostov "Vasily__Vasska__Denisov"
6 Nikolai_Rostov "Vasily__Vasska__Denisov"
7 Nikolai_Rostov ""
8 ямщик "Nikolai_Rostov"
9 Nikolai_Rostov "ямщик"
10 Nikolai_Rostov "Vasily__Vasska__Denisov"
Представляется целесообразным отредактировать получившуюся таблицу следующим способом:
- удалить все наблюдения, которые содержат пустые строки в ячеках (наблюдение = одна интеракция);
- исключить наблюдения, которые не связаны с основными персонажами; таким образом будут исключены «безымянные» персонажи, как, например ямщик. Такие персонажи не являются центральными в романе, поэтому интеракциями с ними при анализе можно пренебречь.
# пропустить строки, в которых хотя бы одна ячейка имеет пустую строку
interactions_cleaned <- interactions |>
filter(if_all(everything(), ~ .x != ''))
# пропустить строки, в которых хотя бы одна ячейка не из people_ids$id
interactions_cleaned <- interactions_cleaned |>
filter(if_all(everything(), ~ .x %in% people_ids$id))
# вывести в качестве примера начало таблицы
head(interactions_cleaned, 10)# A tibble: 10 × 2
who_utter corresp_utter
<chr> <chr>
1 Nikolai_Rostov Vasily__Vasska__Denisov
2 Nikolai_Rostov NatashaRostova
3 Nikolai_Rostov Vasily__Vasska__Denisov
4 Nikolai_Rostov Vasily__Vasska__Denisov
5 Nikolai_Rostov Vasily__Vasska__Denisov
6 Nikolai_Rostov Vasily__Vasska__Denisov
7 Nikolai_Rostov Vasily__Vasska__Denisov
8 Nikolai_Rostov Vasily__Vasska__Denisov
9 Nikolai_Rostov NatashaRostova
10 Nikolai_Rostov Vasily__Vasska__Denisov
Далее можно посчитать, сколько раз была совершена та или иная интеракция. Иными словами, сколько раз, например, Наташа Ростова обратилась к Пьеру (1) или сколько раз Пьер обратился к Наташе Ростовой (2). Это будут две разные интеракции.
# посчитать количество взаимодействий и сделать новый датафрэйм
interactions_final <- interactions_cleaned |>
count(who_utter, corresp_utter, name = 'edge_weight')
# вывести в качестве примера начало таблицы
head(interactions_final, 10)# A tibble: 10 × 3
who_utter corresp_utter edge_weight
<chr> <chr> <int>
1 Anatole_Kuragin Fedor_Ivanovich_Dolokhov 21
2 Anatole_Kuragin NatashaRostova 10
3 Anatole_Kuragin Pierre_Bezukhov 7
4 AndreyBolkonsky AndreyBolkonsky 2
5 AndreyBolkonsky Countess_Natalya_Rostova 5
6 AndreyBolkonsky NatashaRostova 14
7 AndreyBolkonsky Pierre_Bezukhov 68
8 AndreyBolkonsky Princess_Elisabeta__Lisa__Karlovna_Bolkonskaya 1
9 AndreyBolkonsky Princess_Mariya_Bolkonskaya 12
10 AndreyBolkonsky Sonya_Rostova 1
Финальный эстетический штрих: сделаем имена на кириллице!
# функция для замены имён на латинице на их кириллические соответствия
replace_exact <- function(df, dict, cols) {
# именованный вектор для быстрого поиска
lookup <- setNames(dict$name, dict$id)
for (col in cols) {
# заменить значения, которые есть в словаре
df[[col]] <- ifelse(df[[col]] %in% names(lookup),
lookup[df[[col]]],
df[[col]])
}
return(df)
}
interactions_final <- replace_exact(interactions_final,
people_ids, c('who_utter', 'corresp_utter'))
# вывести в качестве примера начало таблицы
head(interactions_final, 10)# A tibble: 10 × 3
who_utter corresp_utter edge_weight
<chr> <chr> <int>
1 Анатоль Курагин Федор Федя Иванови… 21
2 Анатоль Курагин Наталья Наташка На… 10
3 Анатоль Курагин Петр Пьер Кириллов… 7
4 Андрей Андрюша Николаич Николаевич Болконский Андрей Андрюша Ник… 2
5 Андрей Андрюша Николаич Николаевич Болконский Ростова 5
6 Андрей Андрюша Николаич Николаевич Болконский Наталья Наташка На… 14
7 Андрей Андрюша Николаич Николаевич Болконский Петр Пьер Кириллов… 68
8 Андрей Андрюша Николаич Николаевич Болконский Елизавета Лиза Кар… 1
9 Андрей Андрюша Николаич Николаевич Болконский Марья Мари Болконс… 12
10 Андрей Андрюша Николаич Николаевич Болконский Сонюшка Софья Соня… 1
Объект графа и его описание
Фух, теперь всё готово к тому, чтобы создать граф! Ура!
interGraph <- interactions_final |>
graph_from_data_frame(directed = TRUE)
interGraphIGRAPH dadd495 DN-- 35 126 --
+ attr: name (v/c), edge_weight (e/n)
+ edges from dadd495 (vertex names):
[1] Анатоль Курагин ->Федор Федя Иванович Долохов
[2] Анатоль Курагин ->Наталья Наташка Наташа Ильинишна Ростова
[3] Анатоль Курагин ->Петр Пьер Кириллович Кириллыч Кирилыч Безухов
[4] Андрей Андрюша Николаич Николаевич Болконский->Андрей Андрюша Николаич Николаевич Болконский
[5] Андрей Андрюша Николаич Николаевич Болконский->Ростова
[6] Андрей Андрюша Николаич Николаевич Болконский->Наталья Наташка Наташа Ильинишна Ростова
+ ... omitted several edges
Получившийся граф принадлежит к классу IGRAPH, является направленным (для нас всё-таки целесообразно отличать направление реплик от одного персонажа к другому), вершины графа имеют имена. Граф имеет 35 вершин и 126 рёбер.
Атрибутом рёбер (e) является edge_weight, он является числовым (n).
Добавлю атрибуты узлов: степень и центральность посредничества.
V(interGraph)$degree <- degree(interGraph)
V(interGraph)$betweenness <- betweenness(interGraph)
interGraphIGRAPH dadd495 DN-- 35 126 --
+ attr: name (v/c), degree (v/n), betweenness (v/n), edge_weight (e/n)
+ edges from dadd495 (vertex names):
[1] Анатоль Курагин ->Федор Федя Иванович Долохов
[2] Анатоль Курагин ->Наталья Наташка Наташа Ильинишна Ростова
[3] Анатоль Курагин ->Петр Пьер Кириллович Кириллыч Кирилыч Безухов
[4] Андрей Андрюша Николаич Николаевич Болконский->Андрей Андрюша Николаич Николаевич Болконский
[5] Андрей Андрюша Николаич Николаевич Болконский->Ростова
[6] Андрей Андрюша Николаич Николаевич Болконский->Наталья Наташка Наташа Ильинишна Ростова
+ ... omitted several edges
Атрибуты узлов можно обобщить в виде таблицы (выведу по убыванию атрибута degree):
nodes <- tibble(
name = V(interGraph)$name,
degree = V(interGraph)$degree,
betweenness = V(interGraph)$betweenness
)
nodes |>
arrange(-degree)# A tibble: 35 × 3
name degree betweenness
<chr> <dbl> <dbl>
1 Наталья Наташка Наташа Ильинишна Ростова 29 225.
2 Петр Пьер Кириллович Кириллыч Кирилыч Безухов 27 209.
3 Николай Николенька Николушка Коля Ильич Ростов 21 94.8
4 Илья Андреевич Андреич Ростов 18 92.5
5 Андрей Андрюша Николаич Николаевич Болконский 16 66.9
6 Вася Василий Васька Дмитрич Денисов 13 26.0
7 Марья Мари Болконская 12 45.5
8 Федор Федя Иванович Долохов 11 5.31
9 Ростова 10 16.2
10 Сонюшка Софья Соня Ростова 10 6.20
# ℹ 25 more rows
Уже можно заметить, что в топе при ранжировании по атрибуту degree представлены центральные персонажи романа и их родственники, с которыми они взаимодействуют. Это может быть дополнительно укреплено за счёт того, что рассматривается только второй том, в котором преобладают описания жизни представителей высшего общества.
При этом «война» в сети второго тома, как и ожидалось, представлена достаточно ограничено. Например, если мы посмотрим на узлы, не принадлежащие самой большой компоненте, мы увидим, что императоров, военачальников и их подчинённых нельзя ни с кем связать.
# выделить те узлы, которые НЕ связаны с главною компонентою
which(components(interGraph)$membership != 1)Михаил Илларионович Ларионович Кутузов Наполеон Бонапарт
15 16
Козловский Александр Павлович
21 30
Бенигсен
34
Всего имеются три компоненты:
components(interGraph)$csize[1] 30 2 3
Предлагаю на данном этапе визуализировать наибольшую компоненту (суммарно 30 вершин):
lgc <- largest_component(interGraph)
set.seed(2026)
ggraph(lgc, layout = 'lgl', maxiter = 100) +
# стрелки будут отображаться тёмно-зелёным цветом, кол-во реплик влияет на прозрачность
geom_edge_arc(aes(alpha = edge_weight),
colour = 'darkgreen',
arrow = arrow(angle = 20,
length = unit(0.25, 'cm'),
ends = 'last',
type = 'closed'),
end_cap = circle(1.2, 'mm'),
width = 0.6) +
geom_node_point(aes(size = degree),
colour = 'bisque4',) +
# отображение имён только тех персонажей, у кого степень больше 10
geom_node_text(
aes(filter = (degree > 10),
label = name
),
repel = TRUE,
size = 2.5,
show.legend = FALSE
) +
theme_graph(base_family = 'sans') +
theme(legend.position = 'bottom')Подграф (k-core)
Давайте в такой же способ визуализируем подграф k-core. k-core — это максимальный подграф, в котором каждая вершина соединена как минимум с k другими вершинами внутри этого подграфа. Ожидается, что при визуализации данного подграфа будут отображены наиболее значимые, ключевые для романа персонажи.
# записать информацию о ядрах и передать в качестве атрибута в граф lgc
lgcCores <- coreness(lgc)
V(lgc)$core <- lgcCores
head(lgcCores, 10) Анатоль Курагин
6
Андрей Андрюша Николаич Николаевич Болконский
8
Анна Павловна Шерер
2
Борис Боря Друбецкой
5
Илья Андреевич Андреич Ростов
9
Ростова
8
Дуняша
1
Федор Федя Иванович Долохов
9
Элен Безухова Курагина
6
Ипполит Курагин
2
После этого можно визуализировать k-core с параметром более 5:
# задать 5-core подграф
k_core_gr5 <- induced_subgraph(lgc, vids = V(lgc)[core > 5])
ggraph(k_core_gr5, layout = 'lgl', maxiter = 100) +
# стрелки будут отображаться тёмно-зелёным цветом, кол-во реплик влияет на прозрачность
geom_edge_arc(aes(alpha = edge_weight),
colour = 'darkgreen',
arrow = arrow(angle = 20,
length = unit(0.25, 'cm'),
ends = 'last',
type = 'closed'),
end_cap = circle(1.2, 'mm'),
width = 0.6) +
geom_node_point(aes(size = degree),
colour = 'bisque4',) +
# отображение имён всех персонажей из подграфа, тут места больше
geom_node_text(
aes(label = name),
repel = TRUE,
size = 2.5,
show.legend = FALSE
) +
theme_graph(base_family = 'sans') +
theme(legend.position = 'bottom')Как и ожидалось, в подграфе представлены значимые в сети персонажи, у которых при этом degree может быть не очень большой. Это, например, Элен Курагина или Анатоль Курагин. Они говорят с не очень большим числом персонажей (здесь они who), но при этом связь может поддерживаться ответными репликами (здесь они corresp) в томе.
Анализ сообществ и модулярность
Здесь я смотрю на разные алгоритмы для обнаружения сообществ. Обращаемся всё так же к графу lgc.
Алгоритм walktrap:
cw <- cluster_walktrap(lgc)
par(mar = rep(0, 4))
plot(cw, lgc)Алгоритм edge-betweenness:
eb <- cluster_edge_betweenness(lgc)
par(mar = rep(0, 4))
plot(eb, lgc)Алгоритм spinglass:
sg <- cluster_spinglass(lgc)
par(mar = rep(0, 4))
plot(sg, lgc)Алгоритм infoMAP:
im <- cluster_infomap(lgc)
par(mar = rep(0, 4))
plot(im, lgc)Алгоритм optimal:
opt <- cluster_optimal(lgc)
par(mar = rep(0, 4))
plot(opt, lgc)Модулярность
modularity(cw)[1] 0.297828
modularity(eb)[1] 0.1121508
modularity(sg)[1] 0.2274093
modularity(im)[1] 0.03223824
modularity(opt)[1] 0.4005874
Наибольшей модулярности среди опробованных методов удаётся достичь при использовании алгоритма optimal, что довольно символично.
Интерпретация: за помощью алгоритма optimal, как кажется, удаётся разделить граф на группы таким образом, что находятся такие персонажи, которые являются главными связующими в рамках своего сообщества (с ними говорят, к ним обращаются), но при этом эти персонажи взаимодействуют с другими персонажами из других сообществ. Получается ситуация, похожая на, если можно так выразиться, рукопожатия: мой знакомый Α имеет честь знать особу Β, но при это я, будучи Γ, коммуницирую только со своим знакомым.
Анализ ключевых узлов / структур
Точки сочленения
Через вывод точек сочленения убедимся в том, что логика работы алгоритма optimal может быть целесообразна. В частности, представленные ниже точки сочленения в какой-то степени уподобляются отображению сообществ из упомянутого алгоритма (или наоборот). Да, появляется, например, Анна Павловна Шерер, которая не формирует отдельного сообщества (её появление объясняется поддержкою связи с Ипполитом, который иначе бы был отдельною компонентою), но в остальном удаётся рассотреть тех самых, пожалуй, ключевых персонажей, вокруг простроен второй том романа Льва Николаевича Толстого.
articulation_points(lgc)+ 5/30 vertices, named, from daf831c:
[1] Анна Павловна Шерер
[2] Борис Боря Друбецкой
[3] Петр Пьер Кириллович Кириллыч Кирилыч Безухов
[4] Анна Михайловна Друбецкая
[5] Наталья Наташка Наташа Ильинишна Ростова
Клики
Размер наибольшей клики:
clique_num(lgc)[1] 5
Все возможные клики c минимальным размером 5 (всего две):
cliques(lgc, min = 5)[[1]]
+ 5/30 vertices, named, from daf831c:
[1] Андрей Андрюша Николаич Николаевич Болконский
[2] Наталья Наташка Наташа Ильинишна Ростова
[3] Петр Пьер Кириллович Кириллыч Кирилыч Безухов
[4] Николай Андреевич Андреич Болконский
[5] Марья Мари Болконская
[[2]]
+ 5/30 vertices, named, from daf831c:
[1] Илья Андреевич Андреич Ростов
[2] Ростова
[3] Наталья Наташка Наташа Ильинишна Ростова
[4] Николай Николенька Николушка Коля Ильич Ростов
[5] Вася Василий Васька Дмитрич Денисов
О чём это говорит? Среди персонажей, обозначенных в двух кликах мы находим как центральных персонажей романа, так и, что характерно, деление на два семейства: Ростовы и Болконские (своего рода сообщества), - к которым также в рамках второго тома романа так иди иначе подтянуты некоторые из персонажей, как, например, Денисов, который не только является сослуживцем Николая Ростова, но ещё и пытается оказывать знаки внимания Наташе Ростовой, если мы сверяемся с книгою. Таким образом, клики также позволяют продемонстрировать ещё раз, насколько важны главные персонажи в сети на примере второго тома в сфере «мира» романа. «Война», как уже было сказано, представлена ограничено в данной книге.