В данной работе проводится анализ сети персонажей романа Л.Н. Толстого «Война и мир» на основе размеченного XML-файла “Персонажи «Войны и мира» Л. Н. Толстого: вхождения в тексте, прямая речь и семантические роли”, https://doi.org/10.31860/openlit-2022.1-C005, (Скоринкин, Даниил, 2022). Цель работы - выявление центральных персонажей, сообществ и структурных особенностей при помощи методов анализа графов.

В качестве определителя связи между персонажей было решено использовать совместную встречаемость в главах. Использовать пары кто-кому-что говорит оказалось более запутанным методом, в котором неминуемо образовывались пары в духе “Анна Павловна Шерер - ей”, затрудняющие анализ. Выбранный способ оставляет одно важное допущение: публично значимые фигуры, такие как Наполеон, Кутузов, Александр I - оказались “знакомы” практически со всеми, просто потому, что о них все постоянно говорят в связи с идущей войной. В дальнейшем анализе мы сгладим это допущение, выделив военных и политиков в отдельное сообщество.

Загрузка и подготовка данных

doc <- read_xml("War_and_Peace.xml")
xml_ns_strip(doc)

# Находим все главы
chapters <- xml_find_all(doc, "//div[@type='chapter']")
cat("Найдено глав:", length(chapters), "\n")
## Найдено глав: 358
# Для каждой главы собираем уникальных персонажей
chapter_chars <- map(chapters, function(ch) {
  refs <- xml_find_all(ch, ".//rs[@ref]")
  unique(xml_attr(refs, "ref"))
})

# Создаем все возможные пары для каждой главы
all_pairs <- map_df(seq_along(chapter_chars), function(i) {
  chars <- chapter_chars[[i]]
  if(length(chars) < 2) return(NULL)
  
  pairs <- combn(chars, 2, simplify = FALSE)
  
  map_df(pairs, ~tibble(
    source = .x[1],
    target = .x[2],
    chapter = i
  ))
})

# Считаем вес (сколько раз пара встретилась)
edges <- all_pairs |>
  count(source, target, sort = TRUE) |>
  rename(weight = n)

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

Очистка от дублей и петель (когда персонаж упоминается вместе с самим собой)

edges_clean <- edges |>
  mutate(
    pair = map2_chr(source, target, ~paste(sort(c(.x, .y)), collapse = " || "))
  ) |>
  group_by(pair) |>
  summarise(
    source = first(source),
    target = first(target),
    weight = sum(weight),
    .groups = "drop"
  ) |>
  select(source, target, weight) |>
  filter(source != target)

Уникальных пар после очистки: 1578 Удалено дублей и петель: 556

Создание графа

g <- graph_from_data_frame(edges_clean, directed = FALSE)
E(g)$weight <- edges_clean$weight

cat(
  "Вершин (персонажей):", vcount(g), "\n",
  "Ребер (связей):", ecount(g), "\n",
  "Плотность:", round(edge_density(g), 4), "\n",
  "Компонент связности:", count_components(g), "\n",
  "Транзитивность:", round(transitivity(g), 4), "\n"
)
## Вершин (персонажей): 137 
##  Ребер (связей): 1578 
##  Плотность: 0.1694 
##  Компонент связности: 1 
##  Транзитивность: 0.462

Проверим результат:

# Расчет центральностей
V(g)$degree <- degree(g)
V(g)$strength <- strength(g)
V(g)$betweenness <- betweenness(g, normalized = TRUE)

# Создаем базовый датафрейм
df <- data.frame(
  Персонаж = V(g)$name,
  Степень = degree(g),
  Взвешенная = round(strength(g), 1),
  Посредничество = round(betweenness(g, normalized = TRUE), 3)
)

# Собираем топ-10 по каждой метрике
result <- bind_rows(
  df |> arrange(desc(Степень)) |> head(10) |> mutate(Метрика = "Степень", Значение = Степень),
  df |> arrange(desc(Взвешенная)) |> head(10) |> mutate(Метрика = "Взвешенная", Значение = Взвешенная),
  df |> arrange(desc(Посредничество)) |> head(10) |> mutate(Метрика = "Посредничество", Значение = Посредничество)
) |>
  select(Метрика, Персонаж, Значение) |>
  group_by(Метрика) |>
  mutate(Ранг = row_number()) |>
  ungroup()

# Выводим красивую таблицу
knitr::kable(
  result,
  format = "markdown",
  caption = "**Топ-10 персонажей по различным метрикам центральности**",
  digits = c(0, 0, 0, 3, 0)
)
Топ-10 персонажей по различным метрикам центральности
Метрика Персонаж Значение Ранг
Степень Tsar_Alexander_I_of_Russia 102 1
Степень Napoleon_Bonaparte 101 2
Степень AndreyBolkonsky 97 3
Степень Mikhail_Ilarionovich_Kutuzov 86 4
Степень Pierre_Bezukhov 78 5
Степень NatashaRostova 66 6
Степень Nikolai_Rostov 63 7
Степень HeleneKuragin 62 8
Степень Princess_Mariya_Bolkonskaya 62 9
Степень Prince_Bagration 62 10
Взвешенная AndreyBolkonsky 820 1
Взвешенная Pierre_Bezukhov 712 2
Взвешенная NatashaRostova 672 3
Взвешенная Tsar_Alexander_I_of_Russia 584 4
Взвешенная Nikolai_Rostov 565 5
Взвешенная Napoleon_Bonaparte 530 6
Взвешенная Count_Ilya_Rostov 528 7
Взвешенная Sonya_Rostova 522 8
Взвешенная Princess_Mariya_Bolkonskaya 458 9
Взвешенная Mikhail_Ilarionovich_Kutuzov 389 10
Посредничество Tsar_Alexander_I_of_Russia 0 1
Посредничество Mikhail_Ilarionovich_Kutuzov 0 2
Посредничество Napoleon_Bonaparte 0 3
Посредничество AndreyBolkonsky 0 4
Посредничество Count_Bennigsen 0 5
Посредничество Prince_Bagration 0 6
Посредничество NatashaRostova 0 7
Посредничество Barclay_de_Tolly 0 8
Посредничество Joachim_Murat 0 9
Посредничество Vasily__Vasska__Denisov 0 10

Интерпретация показателей центральности:

  • Степень (количество связей). Наибольшее количество прямых связей у исторических фигур — Александра I, Наполеона, Кутузова (86–102 связи). Это ожидаемо, так как они являются предметом обсуждения для большинства персонажей на протяжении всего романа.
  • Взвешенная степень (с учетом частоты взаимодействий). Здесь лидерами становятся главные герои: Андрей Болконский, Пьер Безухов и Наташа Ростова. Это подтверждает их центральную роль в сюжете и активное участие в жизни других персонажей.
  • Посредничество (роль «моста»). Нулевые значения по этой метрике для всех персонажей — не ошибка, а важный структурный результат. Он свидетельствует о том, что в романе нет изолированных групп или единственных связующих звеньев. Все линии переплетены настолько тесно, что между любыми двумя персонажами существует прямой или очень короткий путь через третьих лиц. Это отражает литературное мастерство автора, создавшего единую, цельную картину мира, где все персонажи так или иначе связаны друг с другом (это мы еще увидим в конце, когда будем анализировать эго-граф Пьера).

Атрибуты рёбер: каждое ребро имеет вес (weight), соответствующий количеству глав, в которых пара персонажей встречается вместе. В визуализации мы используем этот вес для определения цвета и прозрачности линий (чем темнее линия, тем чаще встреча). Атрибуты узлов: для каждого персонажа рассчитаны три показателя важности: - Степень (degree) — количество прямых связей - Взвешенная степень (strength) — сумма весов всех связей - Центральность по посредничеству (betweenness) — роль персонажа как моста между разными группами

Принадлежность к сообществу (community), выделим алгоритмом Louvain (количество и названия сообществ я выясняла опытным путем, названия давала по таблице):

communities <- cluster_louvain(g)
V(g)$community <- membership(communities)

cat("Количество сообществ:", length(unique(membership(communities))), "\n")
## Количество сообществ: 4
cat("Модулярность:", round(modularity(communities), 4), "\n")
## Модулярность: 0.2358
# Таблица сообществ
community_table <- tibble(
  character = V(g)$name,
  community = V(g)$community
) |> arrange(community)

# Названия сообществ
community_names <- tibble(
  community = unique(V(g)$community)
) |>
  mutate(
    name = case_when(
      community == 1 ~ "Придворный круг",
      community == 2 ~ "Ростовы и их окружение",
      community == 3 ~ "Болконские и их окружение",
      community == 4 ~ "Военные и политики",
      TRUE ~ paste("Сообщество", community)
    )
  )

# ФИКСИРУЕМ ЦВЕТА ДЛЯ СООБЩЕСТВ(для визуализаций)
fixed_colors <- c(
  "1" = "tomato",  # красный - Придворный круг
  "2" = "#377EB8",  # синий - Ростовы
  "3" = "purple",  # фиолетовый - Болконские
  "4" = "lightgreen"   #зеленый - Военные
)

# Принудительно назначаем цвет каждому узлу в графе
V(g)$color <- fixed_colors[as.character(V(g)$community)]

# Проверка (выведет цвета для первых 10 персонажей)
head(V(g)$color, 10)
##  [1] "tomato"     "tomato"     "tomato"     "tomato"     "#377EB8"   
##  [6] "purple"     "lightgreen" "tomato"     "tomato"     "purple"

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

Статистическая визуализация

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

# Топ-5 из каждого сообщества для подписей
chars_df <- tibble(
  name = V(g)$name,
  degree = degree(g),
  community = V(g)$community
)

top_chars_by_community <- chars_df |>
  group_by(community) |>
  slice_max(order_by = degree, n = 5) |>
  ungroup() |>
  pull(name)

set.seed(123)
static_plot <- ggraph(g, layout = "fr") +
  geom_edge_link(aes(color = weight, alpha = weight), 
                 width = 0.8) +
  geom_node_point(aes(size = degree), 
                  color = V(g)$color, 
                  alpha = 0.8) +
  geom_node_text(aes(label = ifelse(name %in% top_chars_by_community, name, "")), 
                 repel = TRUE, size = 8, max.overlaps = 50) +
  scale_edge_color_gradient(low = "lightblue", high = "darkblue", 
                            name = "Вес ребра") +
  scale_size_continuous(range = c(5, 20)) +
  theme_void() +
  theme(
    plot.title = element_text(size = 24, face = "bold", hjust = 0.5),
    plot.subtitle = element_text(size = 18, hjust = 0.5, color = "gray30"),
    legend.title = element_text(size = 16, face = "bold"),
    legend.text = element_text(size = 14),
    legend.position = "bottom",
    legend.box = "horizontal",
    legend.margin = margin(t = 20)
  ) +
  labs(title = "Сеть персонажей 'Войны и мира'",
       subtitle = paste("Сообщества (Louvain, модулярность =", 
                        round(modularity(communities), 3), 
                        ") | Подписаны топ-5 из каждого сообщества"))

# Оставляем только график, без лишнего вывода
static_plot
Сеть персонажей 'Войны и мира'

Сеть персонажей ‘Войны и мира’

Статический граф получается достаточно громоздким, несмотря на попытки его облегчить, поэтому продублируем его интерактивным:

nodes <- data.frame(
  id = 1:vcount(g),
  label = V(g)$name,
  group = V(g)$community,
  value = V(g)$degree,
  title = paste0("<b>", V(g)$name, "</b><br>",
                 "Связей: ", V(g)$degree, "<br>",
                 "Сообщество: ", community_names$name[V(g)$community]))

nodes$color <- V(g)$color 

edges_vis <- as_data_frame(g, what = "edges") |>
  mutate(
    from = match(from, V(g)$name),
    to = match(to, V(g)$name),
    value = weight,
    title = paste("Вес:", weight),
    color = "lightgrey"
  )

interactive_plot <- visNetwork(nodes, edges_vis, 
                               width = "100%", height = "600px",
                               main = "Сеть персонажей 'Войны и мира'") |>
  visEdges(color = list(color = "lightgrey", opacity = 0.4), smooth = FALSE) |>
  visNodes(borderWidth = 1, font = list(size = 12)) |>
  visOptions(highlightNearest = list(enabled = TRUE, degree = 1, hover = TRUE),
             nodesIdSelection = TRUE) |>
  visPhysics(stabilization = TRUE,
             solver = "forceAtlas2Based",
             forceAtlas2Based = list(gravitationalConstant = -20,
                                     springLength = 150)) |>
  visLayout(randomSeed = 123)

interactive_plot

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

Эго-граф Пьера Безухова

# Создаем эго-граф Пьера
pierre_ego <- make_ego_graph(g, order = 1, nodes = "Pierre_Bezukhov")[[1]]

# Фильтруем ребра с весом >= 10
pierre_ego_filtered <- delete_edges(pierre_ego, which(E(pierre_ego)$weight < 10))

# Удаляем изолированные вершины
pierre_ego_filtered <- delete_vertices(pierre_ego_filtered, 
                                       which(degree(pierre_ego_filtered) == 0))

Визуализация:

# Переносим цвета сообществ
V(pierre_ego_filtered)$color <- V(g)$color[match(V(pierre_ego_filtered)$name, V(g)$name)]

# Создаем копию цвета для модификации, Пьера сделаем золотой звездой
node_colors <- V(pierre_ego_filtered)$color
node_colors[V(pierre_ego_filtered)$name == "Pierre_Bezukhov"] <- "gold"

set.seed(123)
pierre_ego_plot <- ggraph(pierre_ego_filtered, layout = "fr") +
  geom_edge_link(aes(alpha = weight, width = weight), 
                 color = "grey60") +
  geom_node_point(aes(size = degree), 
                  color = node_colors,
                  alpha = 0.9) +
  geom_node_text(aes(label = name, 
                     fontface = ifelse(name == "Pierre_Bezukhov", "bold", "plain")), 
                 repel = TRUE, 
                 size = ifelse(V(pierre_ego_filtered)$name == "Pierre_Bezukhov", 8, 6),
                 max.overlaps = 30) +
  scale_edge_width_continuous(range = c(0.5, 2)) +
  scale_size_continuous(range = c(8, 15), 
                        name = "Связей в эго-графе") +
 theme_void() +
  theme(
    plot.title = element_text(size = 24, face = "bold", hjust = 0.5),
    plot.subtitle = element_text(size = 18, hjust = 0.5, color = "gray30"),
    legend.title = element_text(size = 16, face = "bold"),
    legend.text = element_text(size = 14),
    legend.position = "bottom",
    legend.box = "horizontal",
    legend.margin = margin(t = 20)
  ) +
  labs(title = "Ближайший круг Пьера Безухова",
       subtitle = paste("Эго-граф (вес связей ≥ 10) |", 
                        vcount(pierre_ego_filtered), "персонажей,", 
                        ecount(pierre_ego_filtered), "связей | Золотая звезда = Пьер"))

print(pierre_ego_plot)
Ближайший круг Пьера Безухова

Ближайший круг Пьера Безухова

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

Построим для него и интерактивный граф.


``` r
V(pierre_ego_filtered)$color <- V(g)$color[match(V(pierre_ego_filtered)$name, V(g)$name)]

# Подготовка данных для интерактивного эго-графа Пьера
nodes_pierre <- data.frame(
  id = 1:vcount(pierre_ego_filtered),
  label = V(pierre_ego_filtered)$name,
  group = V(pierre_ego_filtered)$community,
  value = V(pierre_ego_filtered)$degree,
  # Берем цвет НАПРЯМУЮ из атрибута графа
  color = V(pierre_ego_filtered)$color,
  title = paste0("<b>", V(pierre_ego_filtered)$name, "</b><br>",
                 "Связей в эго-графе: ", V(pierre_ego_filtered)$degree, "<br>",
                 "Круг общения: ", community_names$name[V(pierre_ego_filtered)$community])
)

# Выделяем Пьера золотым
pierre_idx <- which(nodes_pierre$label == "Pierre_Bezukhov")
nodes_pierre$color[pierre_idx] <- "gold"

# Форма узлов: Пьер - звезда, остальные - круги
nodes_pierre$shape <- "dot"
nodes_pierre$shape[pierre_idx] <- "star"

# Размер узлов: Пьер крупнее
nodes_pierre$size <- scales::rescale(nodes_pierre$value, to = c(15, 25))
nodes_pierre$size[pierre_idx] <- 35

# Ребра
edges_pierre <- as_data_frame(pierre_ego_filtered, what = "edges") |>
  mutate(
    from = match(from, V(pierre_ego_filtered)$name),
    to = match(to, V(pierre_ego_filtered)$name),
    value = weight,
    title = paste("Вес:", weight)
  )

# Градиент для ребер (от светлого к темному в зависимости от веса)
if(nrow(edges_pierre) > 0) {
  max_weight <- max(edges_pierre$value)
  color_gradient <- colorRampPalette(c("lightblue", "darkblue"))
  edge_colors <- color_gradient(max_weight)
  edges_pierre$color <- edge_colors[edges_pierre$value]
} else {
  edges_pierre$color <- "lightgrey"
}

# Интерактивный граф
interactive_pierre <- visNetwork(nodes_pierre, edges_pierre,
                                 width = "100%", 
                                 height = "600px",
                                 main = list(
                                   text = "Ближайший круг Пьера Безухова",
                                   style = "font-family: Arial; font-size: 20px; text-align: center;"
                                 ),
                                 submain = list(
                                   text = paste("Эго-граф (вес связей ≥ 10) |", 
                                                vcount(pierre_ego_filtered), "персонажей,", 
                                                ecount(pierre_ego_filtered), "связей | Звезда = Пьер"),
                                   style = "font-family: Arial; font-size: 14px; text-align: center; color: grey;"
                                 )) |>
  
  # Настройка ребер
  visEdges(
    smooth = list(enabled = TRUE, type = "curvedCW", roundness = 0.2),
    arrows = list(enabled = FALSE),
    shadow = TRUE
  ) |>
  
  # Настройка узлов
  visNodes(
    borderWidth = 1,
    shadow = TRUE,
    font = list(size = 14)
  ) |>
  
  # Опции взаимодействия
  visOptions(
    highlightNearest = list(enabled = TRUE, degree = 1, hover = TRUE),
    nodesIdSelection = TRUE
  ) |>
  
  # Физика для красивого расположения
  visPhysics(
    stabilization = TRUE,
    solver = "forceAtlas2Based",
    forceAtlas2Based = list(
      gravitationalConstant = -30,
      springLength = 150
    )
  ) |>
  
  # Layout с фиксированным seed
  visLayout(randomSeed = 123)


interactive_pierre

Точки сочленения

articulation <- articulation_points(g)
print(V(g)$name[articulation])
## [1] "Countess_Natalya_Rostova"

Тут у нас получился интересный результат - в ближайшем кругу Пьера именно Наташа оказалась тем персонажем, который объединяет для Пьера все сообщества. Казалось бы, а как же Андрей Болконский? Друг Пьера, военный и несостоявшийся жених Наташи. Но по алгоритму получается так, что Наташа знакома с Пьером не через Андрея, с военными она связана через Николая, а со светским миром - через балы. А сам Пьер с военными связан тоже не через Андрея.К тому же, у сети достаточно высокая плотность (0.169) — много обходных путей, по сути, все так или иначе связаны со всеми.

Наиболее связная группа персонажей

largest_clique <- largest_cliques(g)[[1]]
print(V(g)$name[largest_clique])
##  [1] "Mikhail_Ilarionovich_Kutuzov"                  
##  [2] "Tsar_Alexander_I_of_Russia"                    
##  [3] "Napoleon_Bonaparte"                            
##  [4] "Countess_Natalya_Rostova"                      
##  [5] "NatashaRostova"                                
##  [6] "Count_Ilya_Rostov"                             
##  [7] "AndreyBolkonsky"                               
##  [8] "Princess_Elisabeta__Lisa__Karlovna_Bolkonskaya"
##  [9] "Vasili_Kuragin"                                
## [10] "Sonya_Rostova"                                 
## [11] "Nikolai_Rostov"                                
## [12] "Princess_Mariya_Bolkonskaya"                   
## [13] "Prince_Nikolay_Bolkonsky"                      
## [14] "Pierre_Bezukhov"                               
## [15] "Vasily__Vasska__Denisov"                       
## [16] "HeleneKuragin"                                 
## [17] "Prince_Bagration"                              
## [18] "Lieutenant_Alphonse_Karlovich_Berg"            
## [19] "Pyotr_Nikolaitch_Shinshin"                     
## [20] "Rastopchin"                                    
## [21] "Boris_Drubetskoy"                              
## [22] "Princess_Anna_Mikhaylovna_Drubetskaya"         
## [23] "Fedor_Ivanovich_Dolokhov"

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