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

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

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

Диаграмма Санки 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 выделен цветом и стрелкой показаны индексы в значениях 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()
Структура трафика по вкладу авторов с разделением на типы материалов

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