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

от автора

На хабре есть хороший материал по диаграмме Санки (Сэнки, Санкея, 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 разные. После того, как все данные собрались в цепочки, для каждого лейбла, дела сразу пошли на лад. 

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

Скрытый текст
  lables = []     source = []   indexes_dict_new = {}      index_count = 0   for part in collect_lables:       if part.lable not in lables: # проверяем уникальность           lables.append(part.lable) # добавляем значение лейбла           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() 
Структура трафика по вкладу авторов с разделением на типы материалов

Структура трафика по вкладу авторов с разделением на типы материалов

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


ссылка на оригинал статьи https://habr.com/ru/articles/902566/