.

Сделать репост в соц сети!

суббота, 6 апреля 2019 г.

Анализ графов с использованием «tidyverse»





Анализ графов с использованием «tidyverse»

2019-03-06
by Edgar Ruiz

Хоть я и не эксперт по анализу графов, но я подумал, что очень важно все же написать эту статью. Для тех, кто разбирается в терминах отдельных прямоугольных массивов данных, это что-то вроде «интеллектуального скачка» для понимания того, как правильно применять принципы функции tidy к более сложному объекту, такому, например, как таблица. К счастью, есть два пакета, которые значительно облегчают эту работу:
  •        tidygraph - предоставляет способ взаимодействия dplyr с графами.
  •        ggraph - расширение до ggplot2 для анализа графов.


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

Пример
На примере этой статьи будут показаны концепции работы по анализу графов, а также то, как для такого анализа могут использоваться смежные инструменты tidyverse.

Источник данных
Еженедельный проект «Tidytuesday» призывает как новичков, так и опытных пользователей использовать инструменты tidyverse для анализа наборов данных, которые меняются каждую неделю. Я использовал эту возможность для освоения новых инструментов и методов. Один из самых свежих наборов данных касается французских поездов; он содержит количество ежедневных поездок от станции до станции.


library(readr)

url <- "https://raw.githubusercontent.com/rfordatascience/tidytuesday/master/data/2019/2019-02-26/small_trains.csv"

small_trains <- read_csv(url)

head(small_trains)


# A tibble: 6 x 13
   year month service departure_stati~ arrival_station journey_time_avg total_num_trips avg_delay_all_d~ avg_delay_all_a~ num_late_at_dep~
  <dbl> <dbl> <chr>   <chr>            <chr>                      <dbl>           <dbl>            <dbl>            <dbl>            <dbl>
2017     9 Nation~ PARIS EST        METZ                        85.1             299            0.752            0.420               15
2017     9 Nation~ REIMS            PARIS EST                   47.1             218            1.26             1.14                10
2017     9 Nation~ PARIS EST        STRASBOURG                 116.              333            1.14             1.59                20
2017     9 Nation~ PARIS LYON       AVIGNON TGV                161.              481            1.41             4.79                36
2017     9 Nation~ PARIS LYON       BELLEGARDE (AI~            164.              190            1.73             6.01                16
2017     9 Nation~ PARIS LYON       BESANCON FRANC~            129.              191            1.84             5.03                18
# ... with 3 more variables: num_arriving_late <dbl>, delay_cause <chr>, delayed_number <dbl>


Подготовка данных
Хоть это изначально и предназначалось для анализа задержек поездов, я подумал, что было бы интересно использовать данные, для того чтобы понять, как станции соединены друг с другом. Создается новый сводный набор данных, называемый маршрутами, в котором каждая станция содержится лишь один раз. Он также включает в себя среднее время в пути, необходимое для того чтобы доехать от одной станции до другой.

library(dplyr)

routes <- small_trains %>%
  group_by(departure_station, arrival_station) %>%
  summarise(journey_time = mean(journey_time_avg)) %>%
  ungroup() %>%
  mutate(from = departure_station,
  to = arrival_station) %>%
select(from, to, journey_time)

routes


# A tibble: 130 x 3
   from                       to                 journey_time
   <chr>                      <chr>                     <dbl>
 1 AIX EN PROVENCE TGV        PARIS LYON                186.
 2 ANGERS SAINT LAUD          PARIS MONTPARNASSE         97.5
 3 ANGOULEME                  PARIS MONTPARNASSE        146.
 4 ANNECY                     PARIS LYON                225.
 5 ARRAS                      PARIS NORD                 52.8
 6 AVIGNON TGV                PARIS LYON                161.
 7 BARCELONA                  PARIS LYON                358.
 8 BELLEGARDE (AIN)           PARIS LYON                163.
 9 BESANCON FRANCHE COMTE TGV PARIS LYON                131.
10 BORDEAUX ST JEAN           PARIS MONTPARNASSE        186.
# ... with 120 more rows

Следующим шагом является преобразование набора данных в графическую таблицу. Чтобы подготовить маршруты для преобразования, этот набор данных должен содержать две переменные под названием from (от) и to (к), которые tidygraph ожидает увидеть. Эти переменные должны содержать названия каждой станции (например, станция «AIX EN PROVENCE TGV») и их связь (станция «AIX EN PROVENCE TGV» -> станция «PARIS LYON»).
В графической терминологии член группы называется узлом (или вершиной) на графе, а связь между узлами - ребром.

library(tidygraph)
 
graph_routes <- as_tbl_graph(routes)
 
graph_routes
 
# A tbl_graph: 59 nodes and 130 edges
#
# A directed simple graph with 1 component
#
# Node Data: 59 x 1 (active)
  name               
  <chr>              
1 AIX EN PROVENCE TGV
2 ANGERS SAINT LAUD  
3 ANGOULEME          
4 ANNECY             
5 ARRAS              
6 AVIGNON TGV        
# ... with 53 more rows
#
# Edge Data: 130 x 3
   from    to journey_time
  <int> <int>        <dbl>
1     1    39        186. 
2     2    40         97.5
3     3    40        146. 
# ... with 127 more rows

Функция as_tbl_graph() разбивает маршруты на две части:
·        данные узла - содержит все уникальные значения, найденные в переменных from и to. В данном случае это таблица с одним столбцом, содержащая названия всех станций.
·        данные ребра - это таблица со всеми связями между from и to. Особенность tidygraph состоит в том, что он использует позицию строки узла в качестве идентификатора для for и to вместо его исходного имени.
Еще одна интересная особенность tidygraph состоит в том, что он позволяет нам прикрепить дополнительную информацию об узле или ребре в новый столбец. В этом случае для создания графической таблицы не требуется journey_time, но оно может понадобиться для анализа, который мы планируем сделать. Функция as_tbl_graph() автоматически создала для нас столбец.
Представление graph_routes в качестве двух столбцов внутри большой графической таблицы было одним из двух главных ментальных прорывов, которые произошли во мне во время решения этой задачи. В этот момент стало очевидно, что dplyr нуждается в том, чтобы узнать, в какой из двух таблиц (узлов или ребер) надо выполнить преобразования. В tidygraph это делается с помощью функции activ(). Чтобы продемонстрировать это, таблица узлов будет «активирована» для добавления двух новых строковых переменных, полученных из name.

library(stringr)
 
graph_routes <- graph_routes %>%
  activate(nodes) %>%
  mutate(
  title = str_to_title(name),
  label = str_replace_all(title, " ", "\n")
  )
 
graph_routes
# A tbl_graph: 59 nodes and 130 edges
#
# A directed simple graph with 1 component
#
# Node Data: 59 x 3 (active)
  name                title               label                   
  <chr>               <chr>               <chr>                   
1 AIX EN PROVENCE TGV Aix En Provence Tgv "Aix\nEn\nProvence\nTgv"
2 ANGERS SAINT LAUD   Angers Saint Laud   "Angers\nSaint\nLaud"   
3 ANGOULEME           Angouleme           Angouleme               
4 ANNECY              Annecy              Annecy                  
5 ARRAS               Arras               Arras                   
6 AVIGNON TGV         Avignon Tgv         "Avignon\nTgv"          
# ... with 53 more rows
#
# Edge Data: 130 x 3
   from    to journey_time
  <int> <int>        <dbl>
1     1    39        186. 
2     2    40         97.5
3     3    40        146. 
# ... with 127 more rows

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

stations <- graph_routes %>%
 
activate(nodes) %>%
pull(title)
 
stations
 [1] "Aix En Provence Tgv"            "Angers Saint Laud"              "Angouleme"                      "Annecy"                        
 [5] "Arras"                          "Avignon Tgv"                    "Barcelona"                      "Bellegarde (Ain)"              
 [9] "Besancon Franche Comte Tgv"     "Bordeaux St Jean"               "Brest"                          "Chambery Challes Les Eaux"     
[13] "Dijon Ville"                    "Douai"                          "Dunkerque"                      "Francfort"                     
[17] "Geneve"                         "Grenoble"                       "Italie"                         "La Rochelle Ville"             
[21] "Lausanne"                       "Laval"                          "Le Creusot Montceau Montchanin" "Le Mans"                       
[25] "Lille"                          "Lyon Part Dieu"                 "Macon Loche"                    "Madrid"                        
[29] "Marne La Vallee"                "Marseille St Charles"           "Metz"                           "Montpellier"                   
[33] "Mulhouse Ville"                 "Nancy"                          "Nantes"                         "Nice Ville"                    
[37] "Nimes"                          "Paris Est"                      "Paris Lyon"                     "Paris Montparnasse"            
[41] "Paris Nord"                     "Paris Vaugirard"                "Perpignan"                      "Poitiers"                      
[45] "Quimper"                        "Reims"                          "Rennes"                         "Saint Etienne Chateaucreux"    
[49] "St Malo"                        "St Pierre Des Corps"            "Strasbourg"                     "Stuttgart"                     
[53] "Toulon"                         "Toulouse Matabiau"              "Tourcoing"                      "Tours"                         
[57] "Valence Alixan Tgv"             "Vannes"                         "Zurich"                        

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

library(ggplot2)
 
thm <- theme_minimal() +
  theme(
  legend.position = "none",
  axis.title = element_blank(),
  axis.text = element_blank(),
  panel.grid = element_blank(),
  panel.grid.major = element_blank(),
  ) 
 
theme_set(thm)

Для создания графа, начните с ggraph() вместо ggplot2(). Пакет ggraph содержит geoms, они уникальны для анализа графов. Пакет содержит geoms для точного построения узлов, а другие geoms - для ребер.
Для первой базовой проверки будет использован точечный geom, но вместо вызова geom_point() мы вызываем geom_node_point(). Ребра строятся с использованием geom_edge_diagonal().

library(ggraph)
 
graph_routes %>%
  ggraph(layout = "kk") 
  geom_node_point() 
  geom_edge_diagonal()
Чтобы упростить просмотр местоположения каждой станции на этом графе, используется geom_node_text(). Как и в случае с обычными geoms в ggplot2, другие атрибуты, такие, как: размер, цвет и первый член ряда, могут быть изменены.

graph_routes %>%
  ggraph(layout = "kk") +
  geom_node_text(aes(label = label, color = name), size = 3) +
  geom_edge_diagonal(color = "gray", alpha = 0.4)

Время morph`инга!
Вторым ментальным прорывом было понимание того, как применяется графический алгоритм. Как правило, выходные данные функции модели являются объектом модели, а не объектом данных. С tidygraph процесс начинается и заканчивается графической таблицей. Шаги следующие:
1.                     Начните с графической таблицы.
2.                     Временно преобразуйте граф, чтобы он соответствовал запрашиваемой модели (morph()).
3.                     Сделайте дополнительные преобразования к преобразованным данным, используя dplyr (необязательно).
4.                     Восстановите исходную графическую таблицу, но сохраните изменения, сделанные во время преобразования.
Алгоритм кратчайшего пути определяет «длину» как количество ребер между двумя узлами. Может быть несколько маршрутов из точки A в точку B, но алгоритм выбирает тот, который имеет наименьшее количество «скачков». Способ вызова алгоритма находится внутри функции morph(). Даже если to_shortest_path() сама по себе является функцией, и ее можно запустить без morph(), она не предназначена для использования таким образом. В этом примере в качестве weights используется journey_time, чтобы помочь алгоритму найти оптимальный маршрут между станциями Arras и Nancy. Выведенный на экран граф не будет похож на исходную графическую таблицу.

from <- which(stations == "Arras")
 
to <-  which(stations == "Nancy")
 
shortest <- graph_routes %>%
  morph(to_shortest_path, from, to, weights = journey_time)
 
shortest
# A tbl_graph temporarily morphed to a shortest path representation
#
# Original graph is a directed simple graph with 1 component
# consisting of 59 nodes and 130 edges

Можно сделать больше преобразований с помощью функций activ() и dplyr. Результаты можно предварительно просмотреть или зафиксировать обратно в исходную переменную R с помощью unmorph(). По умолчанию узлы активны на преобразованном графе, поэтому нет необходимости еще раз это упоминать.

shortest %>%
  mutate(selected_node = TRUE) %>%
  unmorph()
# A tbl_graph: 59 nodes and 130 edges
#
# A directed simple graph with 1 component
#
# Node Data: 59 x 4 (active)
  name                title               label                    selected_node
  <chr>               <chr>               <chr>                    <lgl>       
1 AIX EN PROVENCE TGV Aix En Provence Tgv "Aix\nEn\nProvence\nTgv" NA          
2 ANGERS SAINT LAUD   Angers Saint Laud   "Angers\nSaint\nLaud"    NA          
3 ANGOULEME           Angouleme           Angouleme                NA          
4 ANNECY              Annecy              Annecy                   NA          
5 ARRAS               Arras               Arras                    TRUE        
6 AVIGNON TGV         Avignon Tgv         "Avignon\nTgv"           NA          
# ... with 53 more rows
#
# Edge Data: 130 x 3
   from    to journey_time
  <int> <int>        <dbl>
1     1    39        186.
2     2    40         97.5
3     3    40        146.
# ... with 127 more rows

В то время как происходило преобразование, были выбраны только несколько узлов, которые составляют связи между станциями Arras и Nansy. Довольно простой mutate() добавляет новую переменную selected_node, которая помечает эти узлы TRUE. Новая переменная и значение сохраняются после восстановления остальных узлов с помощью команды unmorph().
Чтобы сохранить изменения, самая короткая переменная обновляется изменениями, внесенными как для ребер, так и для узлов.

shortest <- shortest %>%
  mutate(selected_node = TRUE) %>%
  activate(edges) %>%
  mutate(selected_edge = TRUE) %>%
  unmorph()

Следующим шагом является приведение каждого NA в 1, а кратчайший маршрут - в 2. Это позволит нам легко изменить порядок расположения ребер на графе, гарантируя, что маршрут будет нарисован сверху.

shortest <- shortest %>%
  activate(nodes) %>%
  mutate(selected_node = ifelse(is.na(selected_node), 1, 2)) %>%
  activate(edges) %>%
  mutate(selected_edge = ifelse(is.na(selected_edge), 1, 2)) %>%
arrange(selected_edge)

shortest
# A tbl_graph: 59 nodes and 130 edges
#
# A directed simple graph with 1 component
#
# Edge Data: 130 x 4 (active)
   from    to journey_time selected_edge
  <int> <int>        <dbl>         <dbl>
1     1    39        186.              1
2     2    40         97.5             1
3     3    40        146.              1
4     4    39        225.              1
5     6    39        161.              1
6     7    39        358.              1
# ... with 124 more rows
#
# Node Data: 59 x 4
  name                title               label                    selected_node
  <chr>               <chr>               <chr>                            <dbl>
1 AIX EN PROVENCE TGV Aix En Provence Tgv "Aix\nEn\nProvence\nTgv"             1
2 ANGERS SAINT LAUD   Angers Saint Laud   "Angers\nSaint\nLaud"                1
3 ANGOULEME           Angouleme           Angouleme                            1
# ... with 56 more rows

Простой способ составить маршрут - использовать selected_variables для изменения первого члена ряда. Это позволит выделить кратчайший путь без полного удаления других станций. То, как все это строить - личный выбор каждого, поэтому всегда рекомендуется экспериментировать с различными способами выделения результатов.

shortest %>%
  ggraph(layout = "kk") +
  geom_edge_diagonal(aes(alpha = selected_edge), color = "gray") +
  geom_node_text(aes(label = label, color =name, alpha = selected_node ), size = 3)


selected_fields могут также использоваться в других функциях dplyr для анализа результатов. Например, чтобы узнать совокупную информацию о поездке, selected_edge используется для фильтрации ребер, а затем можно уже рассчитать итоговые значения. Для графических таблиц нет функции summarise(); и это имеет смысл, так как графическая таблица станет сводной таблицей с такой функцией. Поскольку конечный результат, который мы ищем, представляет собой итоговую сумму, а не другую графическую таблицу, простая команда as_tibble() приведет к появлению ребер, что позволит нам завершить вычисление.

shortest %>%
  ggraph(layout = "kk") +
  geom_edge_diagonal(aes(alpha = selected_edge), color = "gray") +
  geom_node_text(aes(label = label, color =name, alpha = selected_node ), size = 3)

shortest %>%
  activate(edges) %>%
  filter(selected_edge == 2) %>%
  as_tibble() %>%
  summarise(
    total_stops = n() - 1,
    total_time = round(sum(journey_time) / 60)
)
# A tibble: 1 x 2
  total_stops total_time
        <dbl>      <dbl>
1           8         23

Повторное использование кода
Чтобы скомпилировать большую часть кода в одном фрагменте, вот пример того, как повторно запустить кратчайший путь для другого набора станций: станции Laval и Montpellier.

from <- which(stations == "Montpellier")

to <-  which(stations == "Laval")

shortest <- graph_routes %>%
  morph(to_shortest_path, from, to, weights = journey_time) %>%
  mutate(selected_node = TRUE) %>%
  activate(edges) %>%
  mutate(selected_edge = TRUE) %>%
  unmorph() %>%
  activate(nodes) %>%
  mutate(selected_node = ifelse(is.na(selected_node), 1, 2)) %>%
  activate(edges) %>%
  mutate(selected_edge = ifelse(is.na(selected_edge), 1, 2)) %>%
  arrange(selected_edge)

shortest %>%
  ggraph(layout = "kk") +
  geom_edge_diagonal(aes(alpha = selected_edge), color = "gray") +
  geom_node_text(aes(label = label, color =name, alpha = selected_node ), size = 3)



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

shortest %>%
activate(edges) %>%
filter(selected_edge == 2) %>%
as_tibble() %>%
summarise(
total_stops = n() - 1,
total_time = round(sum(journey_time) / 60)
)
# A tibble: 1 x 2
  total_stops total_time
        <dbl>      <dbl>
1           3         10



Приложение Shiny
Чтобы увидеть, как использовать этот вид анализа в Shiny, пожалуйста, обратитесь к этомуприложению. Оно позволяет пользователю выбрать две станции и возвращает маршрут, а также суммированные данные. Исходный код встроен в приложение.




Комментариев нет:

Отправить комментарий