Анализ сетевых данных

Компьютерный анализ текстов, модуль 3

Автор

Константин Сатдаров

Дата публикации

2026.03.21

Импорт необходимых для работы библиотек

library(ggraph)
library(igraph)
library(tidyverse)
library(xml2)

Импорт данных

В данном домашнем задании я решил работать со вторым вариантом — персонажами «Войны и мира» Льва Николаевича Толстого. Для начала работы нужно импортировать данные из 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)

interGraph
IGRAPH 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)

interGraph
IGRAPH 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] Вася Василий Васька Дмитрич Денисов           

О чём это говорит? Среди персонажей, обозначенных в двух кликах мы находим как центральных персонажей романа, так и, что характерно, деление на два семейства: Ростовы и Болконские (своего рода сообщества), - к которым также в рамках второго тома романа так иди иначе подтянуты некоторые из персонажей, как, например, Денисов, который не только является сослуживцем Николая Ростова, но ещё и пытается оказывать знаки внимания Наташе Ростовой, если мы сверяемся с книгою. Таким образом, клики также позволяют продемонстрировать ещё раз, насколько важны главные персонажи в сети на примере второго тома в сфере «мира» романа. «Война», как уже было сказано, представлена ограничено в данной книге.