Анализ сетевых данных персонажей «Войны и мира» Л. Н. Толстого

Author

Кулятина Екатерина

Подготовка к работе

Загрузим все необходимые для рабоыт библиотеки

library(ggraph)
library(paletteer)
library(igraph)
library(rdracor)
library(tidyverse)
library(xml2)
library(tidygraph)
library(RColorBrewer)
library(stringr)
library(purrr)

1. Импорт и сбор данных

doc <- read_xml("War_and_Peace.xml")
ns <- xml_ns(doc)
ns <- xml_ns_rename(xml_ns(doc), d1 = "tei")
person_nodes <- xml_find_all(doc, ".//tei:person", ns = ns)

Сбор и обработка имён

Хочу сохранить имена в словарь и хранить person ID (pid) и full name (forename+surname).

char_names <- list()
char_ids <- character()

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

for (person in person_nodes) {
  
  pid <- xml_attr(person, "id")
  persName <- xml_find_first(person, ".//tei:persName", ns = ns)
  
  #Методом пристального вглядывания выяснилсь,
  #что в данных есть люди только с pid и без полного имени, 
  #есть с pid, но только с фамилией, есть только с именем, 
  #поэтому тут будет много проверок
  
  if (!is.null(persName)) {
    forenames <- xml_text(xml_find_all(persName, ".//tei:forename", ns = ns))
    surname <- xml_text(xml_find_first(persName, ".//tei:surname", ns = ns))
    patronymic <- xml_text(xml_find_first(persName, ".//tei:patronymic", ns = ns))
    
    if (is.null(forenames) || length(forenames) == 0) {
      forenames <- character() #здесь пустой символьный вектор, потому что имен может быть несколько, например, Сонюшка Софья Соня (Ростова)
    }
    if (is.null(surname) || is.na(surname)) {
      surname <- "" #а здесь просто пустая строка, потому что фамилия обычно одна 
    }
    if (is.null(patronymic) || is.na(patronymic)) {
      patronymic <- "" #здесь то же самое
    }
    
    # собираем полные имена
    if (length(forenames) > 0) { 
      name_parts <- c(forenames, patronymic, surname)
      name_parts <- name_parts[name_parts != ""]
      full_name <- paste(name_parts, collapse = " ")
    } else {
      # если нет имени, собираем из отчества и фамилии, это, например, Ростова, Раевский
      name_parts <- c(patronymic, surname)
      name_parts <- name_parts[name_parts != ""]
      full_name <- paste(name_parts, collapse = " ")
    }
    
    #привожу в порядок имена, убираю лишние пробелы и переносы строк
    full_name <- str_trim(full_name)
    full_name <- gsub("\n", " ", full_name) 
    full_name <- gsub(" +", " ", full_name)
    
    if (!is.na(full_name) && full_name != "") {   #если имя "сформировалось", то записываем его
      char_names[[pid]] <- full_name
    } else {
      char_names[[pid]] <- pid #если нет, то записываем person id
    }
  } else {
    char_names[[pid]] <- pid  #этот else обрабатывает is.null(persName), тогда тоже записывается pid
  }
  
  char_ids <- c(char_ids, pid) #делаем вектор с айдишками для быстрого доступа
}

Сохраняем диалоги и участвующие в них лица

said_nodes <- xml_find_all(doc, ".//tei:said", ns = ns)
interactions <- list() 

for (said in said_nodes) {
  speaker <- xml_attr(said, "who") #получаем говорящего

  corresp <- xml_attr(said, "corresp")  #получаем адресата
  
  
  #проверку наличия говорящего и адрессата в "словаре имен" пришлось добавить, 
  #чтобы не учитывать монологи и убрать связи без действующих лиц, которых мы вытаскивали ранее
  #количество said_nodes - 6306, а interactions почти в два раза меньше - 3732
  if (speaker %in% names(char_names) && corresp %in% names(char_names)) {
    interactions <- append(interactions, list(c(speaker, corresp)))
  } else {next}
}

Собираем датафрейм, по которому будем считать кол-во диалогов между людьми (и считать веса для графа)

interactions_df <- do.call(rbind, interactions) %>%
  as.data.frame(stringsAsFactors = FALSE) %>%
  setNames(c("speaker", "corresp"))

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

interactions_df <- interactions_df %>%
  mutate(
    from = pmin(speaker, corresp),
    to = pmax(speaker, corresp)
  )

Считаем веса

edge_weights <- interactions_df %>%
  group_by(from, to) %>%
  summarise(weight = n()) 
`summarise()` has grouped output by 'from'. You can override using the
`.groups` argument.

2. Создание объекта графа

G <- graph_from_data_frame(
  edge_weights[, c("from", "to", "weight")],
  directed = FALSE,
  vertices = data.frame(name = char_ids, 
                        name_full = unlist(char_names[char_ids]))
)

3. Осмысляем атрибуты рёбер

characteristics <- paste0(
  "Количество вершин в графе - количество персонажей: ", vcount(G), "\n",
  "Количество рёбер в графе: ", ecount(G), "\n",
  "Плотность графа: ", edge_density(G), "\n",
  "Количество компонент связности в графе: ", components(G)$no, "\n",
  "Размеры компонент связности: ", paste(components(G)$csize, collapse = " "), "\n"
)
cat(characteristics)
Количество вершин в графе - количество персонажей: 84
Количество рёбер в графе: 228
Плотность графа: 0.0654044750430293
Количество компонент связности в графе: 14
Размеры компонент связности: 71 1 1 1 1 1 1 1 1 1 1 1 1 1

4. Собираем атрибуты узлов

Так как мы выяснили, что у нас есть 13 компонент связности, отдельных от основного “скопления”, почистим данные.

G <- delete_vertices(G, which(degree(G) == 0))

Для узлов посчитаем центральность по степени и взвешенную центральность

degrees <- degree(G)
degrees %>% 
  sort(decreasing = T) %>% 
  head()
             Pierre_Bezukhov              AndreyBolkonsky 
                          32                           31 
              Nikolai_Rostov               NatashaRostova 
                          28                           22 
Mikhail_Ilarionovich_Kutuzov            Count_Ilya_Rostov 
                          19                           17 
V(G)$degrees <- degrees

wDegree <- strength(G)
wDegree %>% 
  sort(decreasing = TRUE)   %>% 
  head()
             NatashaRostova              Nikolai_Rostov 
                       1019                         913 
            Pierre_Bezukhov             AndreyBolkonsky 
                        896                         734 
Princess_Mariya_Bolkonskaya    Countess_Natalya_Rostova 
                        475                         306 
V(G)$wDegree <- wDegree

Центральность по степени показывает самых важных участников Войны и Мира - Пьер Безухов, Андрей Болконский, Николай Ростов. Взвешенная центральность показывает уже чуть другие результаты - топ-3 становятся Наташа Ростова, Николай Ростов и Пьер Безухов.

#центральность по близости

closeness_centrality <- closeness(G, 
                                  mode = "all",
                                  normalized = TRUE)
closeness_centrality %>% 
  sort() %>% 
  tail()
               Sonya_Rostova              AndreyBolkonsky 
                   0.1241135                    0.1241135 
                   Weyrother                 Miloradovich 
                   0.1245552                    0.1247772 
             Pierre_Bezukhov Mikhail_Ilarionovich_Kutuzov 
                   0.1293900                    0.1351351 
V(G)$closeness <- closeness_centrality

#центральность по посредничеству
betweenness_centrality <- betweenness(G, 
                                      directed = FALSE,
                                      normalized = TRUE)
betweenness_centrality %>%  
  sort() %>% 
  tail()
  Tsar_Alexander_I_of_Russia               Nikolai_Rostov 
                   0.1279145                    0.1369467 
            Boris_Drubetskoy              AndreyBolkonsky 
                   0.1614258                    0.2089694 
             Pierre_Bezukhov Mikhail_Ilarionovich_Kutuzov 
                   0.2616111                    0.4450353 
V(G)$betweenness <- betweenness_centrality

Проанализируем, например, персонажа Андрея Болконского с точки зрения характеристик соответствующего узла. Его центральность по степени 31 говорит о том, что у него много связей, он активно общается с другими персонажами. Центральность по близости 0.1241135 говорит о том, что он не центральная фигура (так как значение не близко к 1), но он на небольшом расстоянии от других персонажей, значит, находится в центре сюжетных событий. Центральность по посредничеству 0.14829889 показывает, что Андрей Болконский - важный второстепенный персонаж, чья значимость обуславливается количеством и качеством связей с другими героями.

5. Cтроим подграф k-ядро

cores_g <- coreness(G)
head(cores_g)
Mavra_Kuzminishna             Ilyin    Dron_Zakharych Count_Ilya_Rostov 
                1                 2                 3                 7 
        Lavrushka           Bilibin 
                3                 4 
table(cores_g)
cores_g
 1  2  3  4  5  6  7 
12 17  6 12  3  5 16 
V(G)$core <- cores_g
cols <- paletteer_d("nbapalettes::hawks_statement")

set.seed(22092024)
ggraph(G, layout = "stress") + 
  geom_edge_link(color = cols[3],
                 alpha = 0.3,
                 width = 0.6) +
  geom_node_point(aes(color = as.factor(core)),
                  size = 3, 
                  show.legend = TRUE) + 
  geom_node_text(aes(filter = wDegree > 400,
                     label = name_full),
                 color = cols[3],
                 repel = TRUE,
                 check_overlap = TRUE) +
  scale_color_brewer("k-ядра", type = "qual") +
  theme_void()

6. Визуализация графа и подграфа

library(visNetwork)

vis_nodes <- tibble(
  id = V(G)$name,
  label = V(G)$name_full,
  value = V(G)$wDegree,
  color.background = scales::col_numeric(
    palette = c("pink", "purple"),
    domain = range(V(G)$betweenness)
  )(V(G)$betweenness),
  title = paste0("<b>", V(G)$name_full, "</b><br>
                 Центральность по степени: ", V(G)$degrees, "<br>
                 Центральность по посредничеству: ", round(V(G)$betweenness, 3))
)

vis_edges <- tibble(
  from = get.edgelist(G)[,1],
  to = get.edgelist(G)[,2],
  value = E(G)$weight
)
Warning: `get.edgelist()` was deprecated in igraph 2.0.0.
ℹ Please use `as_edgelist()` instead.
visNetwork(vis_nodes, vis_edges, 
           main = "Персонажи «Война и мир»") |>
  visNodes(scaling = list(min = 10, max = 50)) |>
  visEdges(scaling = list(min = 1, max = 8)) |>
  visOptions(
    highlightNearest = list(enabled = TRUE),
    nodesIdSelection = TRUE  
  ) |>
  visPhysics(
    solver = "forceAtlas2Based",
    stabilization = list(enabled = TRUE)
  ) |>
  visIgraphLayout() 

Посмотрим на самое большое 7-ядро

g7 <- induced_subgraph(G, vids=V(G)[core > 6])
ggraph(g7, layout = "stress") + 
  geom_edge_link(color = cols[3],
                 alpha = 0.3,
                 width = 0.6) +
  geom_node_point(aes(color = as.factor(core)),
                  size = 3, 
                  show.legend = TRUE) + 
  geom_node_text(aes(filter = wDegree > 10,
                     label = name_full),
                 color = cols[3],
                 repel = TRUE,
                 check_overlap = TRUE) +
  scale_color_brewer("k-ядра", type = "qual") +
  theme_void()

Продолжим дальше анализировать Болконского. Как мы видим, Андрей Болконский попал в сообщество 7-ядра (16 персонажей) - в наиболее плотно связанный кластер сети. Это опять же подтверждает то, что персонаж задействован в ключевых событиях.

Эго-граф для Болконского

p_bolkonsky <- make_ego_graph(
  G,         
  order = 1,  
  nodes = "AndreyBolkonsky",
  mode = "all"
)[[1]]

layout_bolkonsky <- layout_in_circle(p_bolkonsky)

plot(p_bolkonsky, 
     vertex.size = 10, 
     edge.arrow.size = 0.5, 
     vertex.label.dist = 1.5,
     edge.curved = 0.2,
     edge.color = "grey80",
     vertex.color = "plum",
     layout = layout_bolkonsky,
     vertex.label.cex = 0.8, 
     vertex.label.font = 2)

Естественно, что в эго-графе с Болконским связан 31 персонаж - столько же, сколько его центральность по степени.

7 и 8. Поиск сообществ и вычисление их модулярности

Посмотрим на сообщества по алгоритму случайного блуждания

cw <- cluster_walktrap(G)
membership(cw) %>%  head()
Mavra_Kuzminishna             Ilyin    Dron_Zakharych Count_Ilya_Rostov 
                1                 1                 5                 1 
        Lavrushka           Bilibin 
                1                 5 
par(mar = rep(0, 4))
plot(cw, G)

modularity(cw)
[1] 0.3048683

Смотрим разбиение «спиновые стекла»

csg <- cluster_spinglass(G)
membership(csg) %>%  head()
Mavra_Kuzminishna             Ilyin    Dron_Zakharych Count_Ilya_Rostov 
                5                 4                 3                 5 
        Lavrushka           Bilibin 
                4                 6 
par(mar = rep(0, 4))
plot(csg, G)

modularity(csg)
[1] 0.0266197

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

Используем алгоритм под названием “главный собственный вектор”.

cev <- cluster_leading_eigen(G)
par(mar = rep(0, 4))
plot(cev, G)

modularity(cev) 
[1] 0.3400967

У этого алгоритма самая большая модулярность.

9. Анализ ключевых узлов / структур

#точки сочленения
articulation_points(G)
+ 10/71 vertices, named, from 1ff0663:
 [1] AndreyBolkonsky              Bolhovitinov                
 [3] Mikhail_Ilarionovich_Kutuzov Pierre_Bezukhov             
 [5] Captain_Ramballe             Vasily__Vasska__Denisov     
 [7] Pfuel                        Napoleon_Bonaparte          
 [9] Nikolai_Rostov               NatashaRostova              

Наличие всего 10 точек сочленения на граф из 71 узла свидетельствует о высокой связности сети

#посмотрим на самую большую клику
clique_num(G)
[1] 7
cliques(G, min=7)
[[1]]
+ 7/71 vertices, named, from 1ff0663:
[1] Princess_Mariya_Bolkonskaya Sonya_Rostova              
[3] Pierre_Bezukhov             AndreyBolkonsky            
[5] Nikolai_Rostov              Countess_Natalya_Rostova   
[7] NatashaRostova