Как стать автором
Обновить

Некоторые особенности создания диаграммы Санки (Sankey Diagram) на Python, библиотека plotly

Уровень сложностиСредний
Время на прочтение7 мин
Количество просмотров2.3K

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

Важное наблюдение: визуализация оказалось самым сложным этапом разработки. И ещё более интересное наблюдение: скажем, так, если бы этой визуализации не было, какие-то фатальные последствия для операционной деятельности вряд ли наступили бы.

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

Диаграмма Санки VS круговая диаграмма
Диаграмма Санки VS круговая диаграмма

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

Правда надо понимать, диаграмма Санки хороша для отображения структуры, и совершенно не подходит для отображения динамики.

Итак, исходная задача и сложности: мне нужно было собрать диаграмму Санки, для ежедневного/еженедельного/квартального, одним словом регулярного представления данных о структуре трафика. Сложность в том, что не все параметры одинаковы каждый день. Т.е. количество узлов в диаграмме всё время меняется (бывают дни когда нет эксклюзивов, на выходных другой состав авторов и так далее). 

Basic Sankey Diagram или основное что нужно знать

Документация по диаграмме тут.

На plotly построение состоит из двух основных блоков: узлы и связи. Связей обычно больше чему узлов, в чем собственно, вся прелесть этой визуализации и есть. Данные передаются в виде кортежей (списков), тут в общем-то главная сложность (для меня и была скрыта), списки друг с другом не связаны, и создать путаницу в буквальном смысле не сложно. 

Скрытый текст
import plotly.graph_objects as go

fig = go.Figure(data=[go.Sankey(
    node = dict(
      pad = 15,
      thickness = 20,
      line = dict(color = "black", width = 0.5),
      label = ["A1", "A2", "B1", "B2", "C1", "C2"],
      color = "blue"
    ),
    link = dict(
      source = [0, 1, 0, 2, 3, 3], # indices correspond to labels, eg A1, A2, A1, B1, ...
      target = [2, 3, 3, 4, 4, 5],
      value = [8, 4, 2, 8, 4, 2]
  ))])

fig.update_layout(title_text="Basic Sankey Diagram", font_size=10)
fig.show()

Узлы (строка 4, словарь node) принимает значения:

  • название (label), 

  • цвет (color), 

  • координата по оси икс (x),

  • координата по оси y (y). 

  • Плюс толщина линий, расстояние между потоками.

Связи (строка 12, словарь link) принимает значения: 

  • Источник данных (source) – жестко привязан к индексу label в части узлов (т.е. индекс названия в списке label и есть source);

  • Цель или направление (target): это тоже индексы названий узлов (label), от какого (source) узла к какому (target) направлен поток. Если направления нет, выходящего потока из узла не отрисовывается;

  • Значение (value): размер потока от источника к другому узлу (source-target);

  • Название потока (label);

  • Цвет поток (color).

Длина передаваемых списков желательно должна быть одинаковой.

Принцип связей индекса списка label
Принцип связей индекса списка label

На рисунке каждый элемент списка label выделен цветом и стрелкой показаны индексы в значениях source и target справа приведены связи узлов по названиям и собственно сама диаграмма.

Другими словами - индексы значений в списке label для словаря node - отправная точка, от которой собирается вся диаграмма.

Диаграмма сама суммирует входящие на узлы потоки, если есть какие-то расхождения, это сразу довольно наглядно видно.

Задача довольно простая, нужно выстроить связи между узлами (node - label) и прописать значения для потоков. Небольшие сложности возникают, когда индексы начинают гулять туда-сюда, и вся диаграмма категорически не желает собираться.

Конструктор для динамической сборки диаграммы

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

На первом шаге я взял листок бумаги и просто нарисовал как примерно должен выглядеть результат (а до этого, нужно ещё понять, зачем вообще всё это делается, и какие задачи решает), в нашем случае, это оценка вклада, трудозатрат и эффективности.

Эскиз диаграммы на начальном этапе
Эскиз диаграммы на начальном этапе

На рисунке видно, довольно неочевидную классификацию: это объекты, для которых характерны одни и те же связи. Приведу сразу парами source-target:

  1. автор -> эксклюзив

  2. автор -> новость

  3. автор -> статья

  4. автор -> галерея

  5. эксклюзив -> новость

  6. эксклюзив -> статья

  7. новость -> всего

  8. статья -> всего

  9. галерея -> всего

Решение получилось тоже простое: это адресная книга. 

У каждого узла есть жёсткий список его адресатов, нужно всего лишь отвязать его от текущих индексов и сделать в виде отдельной карты. Далее, при сборке данных программа сразу понимает куда какой поток направлять.

Запишем в виде словаря:

destanation_map_dict = {
  'author': ['exclusives', 'news', 'arts', 'gallery'],
  'exclusives': ['news', 'arts'],
  'news': ['total'],
  'arts': ['total'],
  'gallery': ['total'],
  'total': [],
 }
           

Теперь у каждого объекта есть список назначений, осталось только только всё собрать, рассчитать и распределить каждого по адресу.

Этап расчётов и подготовки данных пропущу (некоторые нюансы под спойлером), если в двух словах, то у меня это был список именованных кортежей, куда вносятся нужные значения для каждой тройки: источник-назначение-значение (source-target-value). 

Скрытый текст

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

  1. Значение для эксклюзива (для новостей) - промежуточный узел

  2. Значение новости (прямая связь) = общее значение на новости минус эксклюзив (для новостей)

И аналогично для для статей. Т.е. мы сперва считаем сколько трафика ушло на первый (промежуточный) узел (эксклюзивы), потом вычитаем это значение из второго узла (новости). 

Т.е. у каждого автора есть значения для эксклюзивов, новостей, статей, галерей; эксклюзивы связаны с новостями и статьями. Новости, статьи и галереи связаны только с итоговым числом, итог ни с кем не связан, это финальный узел. Тут ещё есть хитрость с нулевыми значениями, если value = 0, библиотека просто не отображает этот кусок. 

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

В моём случае, я воспользовался следующей структурой данных:

class SankyLableChain(NamedTuple):
    label: str = ''
    source: str = ''    
    value: int = 0
    link_color: str = ""
    node_color: str = ""
    x_pos: float = 0
    y_pos: float = 0
    target: str = ''  

Тут весь секрет в том, что в значении source присваиваем имя объекта, а в значении target имя узла-назначения. И не торопимся присваивать индексы, пока работаем только с наименованиями. 

На этом этапе главное верно присвоить значения из словаря-карты, авторам - авторов, остальным объектам их значения. Дальше проходимся по таблице, и сверяем какие данные для какого направления есть. Получаются примерно такие цепочки для всех значений:

SankyLableChain(
    label='Эксклюзивы: 795 (1шт.)', 
    source='exclusives',     
    value=795, 
    link_color='rgba(219,41,35, 0.7)', 
    node_color='rgba(219,41,35, 0.9)', 
    x_pos=0.3, 
    y_pos=0.2, 
    target='news',     
)

Т.е. вместо индексов для source и target присваиваем имена из словаря-карты в этом примере, это Эксклюзивы (1шт), имя 'exclusives' и target на 'news'. Значения из node повторяются, значения из link разные. После того, как все данные собрались в цепочки, для каждого лейбла, дела сразу пошли на лад. 

Сводим всё в единую табличку, формируем словарь индексов и прописываем итоговые кортежи.

Скрытый текст
  labels = []  
  source = []
  indexes_dict_new = {}
  
  index_count = 0
  for part in collect_lables: # список всех цепочек данных srs-trg-val
      if part.lable not in lables: # проверяем уникальность
          labels.append(part.label) # добавляем значение лейбла
          source.append(index_count)           
          indexes_dict_new[part.source] = index_count # part.source это имя (ключ) из словаря-карты destanation_map_dict
          index_count += 1

  plot_data = SankyPlotData(
      lable=lables,
      source=[indexes_dict_new.get(x.source) for x in collect_lables],
      target=[indexes_dict_new.get(x.destination) for x in collect_lables],
      values=[x.value for x in collect_lables],
      link_color=[x.link_color for x in collect_lables],
      node_color=node_color,
      x_position=x_poses,
      y_position=y_poses,
    )

В первой части можно вместе со словарём индексов собрать и всю часть node (названия, цвета, координаты). Пробегаем по уникальным значениям labels и присваиваем индексы (индексы присваиваются не по значению подписи, а по названию объекта тут (author, exclusives, news, arts, gallery, total). 

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

Скрытый текст
fig = go.Figure(go.Sankey(
    #arrangement='snap',
    node = dict(        
        thickness = 10,
        line = dict(color = "black", width = 0),
        label = ['Автор-0 (38.25%)',
                 'Автор-1 (12.80%)',
                 'Автор-2 (18.74%)',
                 'Автор-3 (11.00%)',
                 'Автор-4 (19.22%)',
                 'Эксклюзивы (1шт.)',
                 'Новости 45.79%',
                 'Заметки 35.47%',
                 'Галереи 18.74%',
                 'Всего просмотров: 10.16 тыс'],
        color = plot_data.node_color,
        #x = [0, 0, 0, 0, 0, 0.3, 0.6, 0.6, 0.6, 0.9],
        #y = [0, 0, 0, 0, 0, 0.2, 0.1, 0.1, 0.1, 0.55],
        pad = 25,
       
    ),
    link = dict(        
        source = [0, 0, 1, 2, 3, 3, 4, 5, 6, 7, 8, 9],
        target = [6, 7, 6, 8, 5, 6, 6, 6, 9, 9, 9, None],
        value = [0.28, 3.6, 1.3, 1.9, 0.7, 0.3, 1.9, 0.79, 4.6, 3.6, 1.9, 10.157],
        color = plot_data.link_color,        
  )))

fig.show()
Структура трафика по вкладу авторов с разделением на типы материалов
Структура трафика по вкладу авторов с разделением на типы материалов

Отдельного разговора заслуживают цветовые решения и позиционирование потоков.

Теги:
Хабы:
+2
Комментарии3

Публикации

Работа

Data Scientist
46 вакансий

Ближайшие события