На хабре есть хороший материал по диаграмме Санки (Сэнки, Санкея, Sankey) в дополнение к нему хотелось бы рассказать, как подходить к созданию, и немного углубиться в вопросы некоторых особенностей построения связей. К сожалению, прочитал я её после того как решил свои задачи, поэтому процесс получился отличным, и некоторыми деталями этого процесса хотел поделиться.
Важное наблюдение: визуализация оказалось самым сложным этапом разработки. И ещё более интересное наблюдение: скажем, так, если бы этой визуализации не было, какие-то фатальные последствия для операционной деятельности вряд ли наступили бы.
Меня диаграмма Санки впечатлила силой визуализации, особенно хорошо это видно на сравнении с пирожковой круговой диаграммой. Получается намного более наглядное представление данных.
Но самое главное, для меня, это возможность сочетания разных характеристик данных в одном представлении. У меня это было структурой трафика: можно посмотреть не только вклад авторов в общий трафик, но и структуру вклада, выделяя потоки, например, эксклюзивы.
Правда надо понимать, диаграмма Санки хороша для отображения структуры, и совершенно не подходит для отображения динамики.
Итак, исходная задача и сложности: мне нужно было собрать диаграмму Санки, для ежедневного/еженедельного/квартального, одним словом регулярного представления данных о структуре трафика. Сложность в том, что не все параметры одинаковы каждый день. Т.е. количество узлов в диаграмме всё время меняется (бывают дни когда нет эксклюзивов, на выходных другой состав авторов и так далее).
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 выделен цветом и стрелкой показаны индексы в значениях source и target справа приведены связи узлов по названиям и собственно сама диаграмма.
Другими словами — индексы значений в списке label для словаря node — отправная точка, от которой собирается вся диаграмма.
Диаграмма сама суммирует входящие на узлы потоки, если есть какие-то расхождения, это сразу довольно наглядно видно.
Задача довольно простая, нужно выстроить связи между узлами (node — label) и прописать значения для потоков. Небольшие сложности возникают, когда индексы начинают гулять туда-сюда, и вся диаграмма категорически не желает собираться.
Конструктор для динамической сборки диаграммы
Как появилась эта задача: сперва было интересно посмотреть просто вклад каждого (обычная круговая диаграмма, см картинку выше) но потом задача усложнилась, так как общий вклад сильно структурирован: бывают новости (которых пишут много), бывают статьи, которых пишут мало, ещё есть галереи, которые вообще не пишут), а новости и статьи бывают эксклюзивными.
На первом шаге я взял листок бумаги и просто нарисовал как примерно должен выглядеть результат (а до этого, нужно ещё понять, зачем вообще всё это делается, и какие задачи решает), в нашем случае, это оценка вклада, трудозатрат и эффективности.
На рисунке видно, довольно неочевидную классификацию: это объекты, для которых характерны одни и те же связи. Приведу сразу парами source-target:
-
автор -> эксклюзив
-
автор -> новость
-
автор -> статья
-
автор -> галерея
-
эксклюзив -> новость
-
эксклюзив -> статья
-
новость -> всего
-
статья -> всего
-
галерея -> всего
Решение получилось тоже простое: это адресная книга.
У каждого узла есть жёсткий список его адресатов, нужно всего лишь отвязать его от текущих индексов и сделать в виде отдельной карты. Далее, при сборке данных программа сразу понимает куда какой поток направлять.
Запишем в виде словаря:
destanation_map_dict = { 'author': ['exclusives', 'news', 'arts', 'gallery'], 'exclusives': ['news', 'arts'], 'news': ['total''], 'arts': ['total''], 'gallery': ['total'], 'total': [], }
Теперь у каждого объекта есть список назначений, осталось только только всё собрать, рассчитать и распределить каждого по адресу.
Этап расчётов и подготовки данных пропущу (некоторые нюансы под спойлером), если в двух словах, то у меня это был список именованных кортежей, куда вносятся нужные значения для каждой тройки: источник-назначение-значение (source-target-value).
Скрытый текст
Например, от автора есть прямая связь с новостью и статьёй а есть через эксклюзив. В этом варианте не будет видно, от какого автора дальше на какой тип материала потянется нитка, т.е. узел эксклюзивов впитает в себя всех авторов, и дальше распределит по типам материалов. Тут при расчёте входящих и выходящих потоков это надо учитывать, т.е. общей трафик автора будет делиться на несколько частей исходя из последовательности шагов диаграммы:
-
Значение для эксклюзива (для новостей) — промежуточный узел
-
Значение новости (прямая связь) = общее значение на новости минус эксклюзив (для новостей)
И аналогично для для статей. Т.е. мы сперва считаем сколько трафика ушло на первый (промежуточный) узел (эксклюзивы), потом вычитаем это значение из второго узла (новости).
Т.е. у каждого автора есть значения для эксклюзивов, новостей, статей, галерей; эксклюзивы связаны с новостями и статьями. Новости, статьи и галереи связаны только с итоговым числом, итог ни с кем не связан, это финальный узел. Тут ещё есть хитрость с нулевыми значениями, если 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/
Добавить комментарий