В данной работе проводится анализ сети персонажей романа Л.Н. Толстого «Война и мир» на основе размеченного 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)
)
| Метрика | Персонаж | Значение | Ранг |
|---|---|---|---|
| Степень | 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 |
Атрибуты рёбер: каждое ребро имеет вес (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"
Как я предполагала, первые три имени из полученного списка звучат во всех разговорах в связи с событиями, которые обсуждают все персонажи, и, зная это, неудивительно, что они оказываются в топе.