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

Три разные единицы измерения на одном графике с библиотекой Plotly

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

Будучи сторонницей минимализма во всем, в том числе и в визуализации данных, я избегаю попыток "впихнуть невпихуемое" в одну визуализацию. Лучше построю группу графиков. Но иногда попадается интересный визуал и хочется его воспроизвести.

Эта визуализация одна из них. Но заинтриговал меня даже не сам дизайн, а текст публикации из ТГК Power BI Design: "Три разные единицы измерения на одном графике. Как? Непросто". И я подумала: а как это реализовать на python с использованием plotly?

Референс, взято из ТГК Power BI Design
Референс, взято из ТГК Power BI Design
Скрытый текст

Финал будет такой, если вдруг нет времени читать с начала и до конца) Полный код в конце

Первое, что нужно было сделать, это создать синтетический набор данных.

import numpy as np
revenue_fact = np.random.randint(50000000, 100000000, size=12).tolist() #выручка
SKU_count = np.random.randint(80, 150, size=12).tolist() #кол-во SKU
quarters_1 = pd.date_range('2022Q1', periods=12, freq='Q')
quarters = quarters_1.to_period('Q').astype(str) #периоды - кварталы
gross_margin = np.random.randint(15, 22, size=12).tolist() #валовая рентабельность
#создаю фрейм и добавляю вспомогательные столбцы
df = pd.DataFrame({
  
    'quarter': quarters,
    'revenue': revenue_fact,
    'SKU_count': SKU_count, 
    'percent': persent, 
    'gross_margin': gross_margin})
df['revenue_mln'] = round(df['revenue']/1000000,2)
df['gross_profit_margin'] = round(df['revenue'] * (df['gross_margin']/100),2)
df['gross_profit_margin_mln'] = round(df['gross_profit_margin']/1000000,2)

Далее я объясню, зачем я создаю столбцы с данными о выручке и валовой прибыли в миллионах рублей.

Общая логика графика

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

# Инициализирую пространство с подзаголовком, который содержит две оси Y
fig = make_subplots(specs=[[{'secondary_y': True}]])
# В ось X кладем значения периодов
# Добавляю первый график: динамику изменения количества проданных SCU
# secondary_y = True привязывает график ко второй оси Y
fig.add_trace(go.Scatter(x=df['quarter'], 
                         y=df['SKU_count'], 
                         name='Кол-во SKU', 
                         mode='lines+markers+text'),
              secondary_y = True)

# Добавляю второй график: Значение выручки
fig.add_trace(go.Bar(x=df['quarter'], y=df['revenue'], 
                     name='Выручка'), 
              secondary_y = False)

# Добавляю третий график: Значение валовой прибыли
fig.add_trace(go.Bar(x=df['quarter'], y=df['gross_profit_margin'], 
                     name='Валовая прибыль'), 
              secondary_y = False)

# Добавляю четвертый график: Значение валовой рентабельности
fig.add_trace(go.Scatter(x=df['quarter'], y=df['gross_margin'], 
                         name='Рентабельность', 
                         mode='lines', 
                         fill='tozeroy'), 
              secondary_y = True)

fig.update_layout(title=(f'Сколько SKU мы продаем и сколько зарабатываем'), #title_x=0.1, title_y=0.95,
                  #title_font=dict(size=16, color=dark_3, weight='bold'), 
                  height = 350, width = 650),
fig.show()
Результат выполнения кода
Результат выполнения кода

Вот такой неказистенький график меня получился: между графиками, визуализирующими количество SKU и значения валовой рентабельности слишком уж большое расстояние, сетки обеих осей Y создают неразбериху и делают график абсолютно нечитаемым. И ох уж эти цвета по умолчанию... Можно и не перечислять все недостатки: визуализация плоха и точка и это очевидно

Изменение расположения элементов на графике

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

fig = make_subplots(specs=[[{'secondary_y': True}]])

fig.add_trace(go.Scatter(x=df['quarter'], 
                         y=df['SKU_count'], 
                         name='Кол-во SKU', 
                         mode='lines+markers+text'),
              secondary_y = True)

fig.add_trace(go.Bar(x=df['quarter'], y=df['revenue'], 
                     name='Выручка'), 
              secondary_y = False)

fig.add_trace(go.Bar(x=df['quarter'], y=df['gross_profit_margin'], 
                     name='Валовая прибыль'), 
              secondary_y = False)

fig.add_trace(go.Scatter(x=df['quarter'], y=df['gross_margin'], 
                         name='Рентабельность', 
                         mode='lines', 
                         fill='tozeroy'), 
              secondary_y = True)

fig.update_layout(title=(f'Сколько SKU мы продаем и сколько зарабатываем'), 
                  title_x=0.1, title_y=0.95, #определяю положение заголовка
                  height = 350, width = 650, 
                  yaxis2=dict(showticklabels=False, showgrid=False), #отключаю сетку и тики для второй (правой) оси Y 
                  legend_font=dict(size=11), # Устанавливаю размер шрифта для легенды, чтобы замостить все обозначенрия в один ряд 
                  #определяю конфигурацию легенды и добавляю прозрачный фон для легенда
                  legend=dict(orientation="h", yanchor="bottom", y=0.9, xanchor="left", x=0.02, bgcolor='rgba(255, 255, 255, 0)'),
                  #определяю режим отображения столбцов: столбцы будут накладываться друг на друга                  
                  barmode='overlay',
                  # устанавливаю параметры отступов для фигyры
                  margin = dict(t=50, l=50, r=10, b=10))
fig.show()

А это результат его выполнения.

Результат выполнения кода
Результат выполнения кода

По моему выглядит уже приличнее. Но все равно еще очень много недостатков: элементы накладывают друг на друга или наоборот между ними очень много пространства, очевидна проблема несоответствия масштабности элементов друг другу.
Я буду решать эту проблему путем изменения масштабов осей Y.
Чтобы сместить графики вниз относительно верхней границы и устранить наложение легенды на график, я увеличу границы отображения значений на оси Y. Дополнительно изменю размер шрифта тиков по осям Y и X.
Чтобы это изменить я добавлю дополнительные параметры в метод update_layout.

#меняю размер шрифта тиков, параметр "range" устанавливает пределы осей
xaxis=dict(tickfont_size=10),
axis=dict(tickfont_size=10, 
          side='left', range=[0, df['revenue'].max() + 20000000]),
yaxis2=dict(showticklabels=False, side='right', showgrid=False, 
            range=[df['SKU_count'].min()-20, df['SKU_count'].max() + 20])

После добавления этого блока кода, график будет выглядеть вот так:

Результат выполнения кода
Результат выполнения кода

Уууппппс... А где Area Chart?

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

Чтобы решить эту проблему я изменю масштабность данных для нашего Area Chart (График площадей), который показывает изменения значений рентабельности. Изначально в ось Y я положила значения из столбца df['gross_margin']. Среднее значение этого столбца в ~6 раз меньше, чем значения столбца df['SKU_count']. Я создам список синтетических значений для графика рентабельностей, путем увеличения значений рентабельности на n.

Обновленный код выглядит так:

fig = make_subplots(specs=[[{'secondary_y': True}]])

fig.add_trace(go.Scatter(x=df['quarter'], 
                         y=df['SKU_count'], 
                         name='Кол-во SKU', 
                         mode='lines+markers+text'),
              secondary_y = True)

fig.add_trace(go.Bar(x=df['quarter'], y=df['revenue'], 
                     name='Выручка'), 
              secondary_y = False)

fig.add_trace(go.Bar(x=df['quarter'], y=df['gross_profit_margin'], 
                     name='Валовая прибыль'), 
              secondary_y = False)

#создание синтетического значения для графика рентабельностей
#значение n = 4.8 выбрано простым подбором; 
#мне кажется, что именно такое значение наилучшим образом влияет на композицию графика
gross_margin_line = [int(num * 4.8) for num in df['gross_margin'].tolist()]
fig.add_trace(go.Scatter(x=df['quarter'], y=gross_margin_line, #Кладем список gross_margin_line в Y
                         name='Рентабельность', 
                         mode='lines', 
                         fill='tozeroy'), 
              secondary_y = True)

fig.update_layout(title=(f'Сколько SKU мы продаем и сколько зарабатываем'), 
                  title_x=0.1, title_y=0.95,
                  height = 350, width = 650, 
                  xaxis=dict(tickfont_size=10),
                  yaxis=dict(tickfont_size=10, 
                             side='left', range=[0, df['revenue'].max() + 20000000]),
                  yaxis2=dict(showticklabels=False, side='right',
                              showgrid=False, range=[df['SKU_count'].min()-20, df['SKU_count'].max() + 20]),
                  legend_font=dict(size=11), 
                  legend=dict(orientation="h", yanchor="bottom", y=0.9, xanchor="left", x=0.02, bgcolor='rgba(255, 255, 255, 0)'),
                  barmode='overlay',
                  margin = dict(t=50, l=50, r=10, b=10))
fig.show()

Результат выполнения кода; обратите внимание на содержание всплывающей подсказки
Результат выполнения кода; обратите внимание на содержание всплывающей подсказки

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

Поскольку мне необходимо настраивать всплывающие подсказки для графика Area Chart, я сделаю это для всех графиков, чтобы обеспечить единообразие отображения. И именно тут мне понадобятся созданные столбцы с данными о выручке и валовой прибыли в миллионах рублей. Для этого буду настраивать значения параметров customdata и hovertemplate.

fig = make_subplots(specs=[[{'secondary_y': True}]])

fig.add_trace(go.Scatter(x=df['quarter'], y=df['SKU_count'], 
                         name='Кол-во SKU', 
                         mode='lines+markers+text', 
                         customdata = df['SKU_count'], 
                         hovertemplate='<b>%{x}</b><br>' + 'Кол-во SKU: %{customdata:.0f}<extra></extra>',),
              secondary_y = True)

fig.add_trace(go.Bar(x=df['quarter'], y=df['revenue'], 
                     name='Выручка', 
                     customdata = df['revenue_mln'], #столбец с выручкой в миллионах руб.
                     hovertemplate='<b>%{x}</b><br>' + 'Выручка: %{customdata:.2f} млн.руб.<extra></extra>'), 
              secondary_y = False)

fig.add_trace(go.Bar(x=df['quarter'], y=df['gross_profit_margin'], 
                     name='Валовая прибыль', 
                     customdata = df['gross_profit_margin_mln'], #столбец с вал. прибылью в миллионах руб.
                     hovertemplate='<b>%{x}</b><br>' + 'Вал.прибыль: %{customdata:.2f} млн.руб.<extra></extra>'), 
              secondary_y = False)

gross_margin_line = [int(num * 4.8) for num in df['gross_margin'].tolist()]
fig.add_trace(go.Scatter(x=df['quarter'], y=gross_margin_line, 
                         name='Рентабельность', 
                         #именно этот параметр отвечает за то, какте значения будут отображаться во всплывающих подсказках
                         customdata=df['gross_margin'], 
                         hovertemplate='<b>%{x}</b><br>' + 'Маржинальность: %{customdata:.2f}%<extra></extra>',
                         mode='lines', 
                         fill='tozeroy'), 
              secondary_y = True)

fig.update_layout(title=(f'Сколько SKU мы продаем и сколько зарабатываем'), 
                  title_x=0.1, title_y=0.95,
                  height = 350, width = 650, 
                  xaxis=dict(tickfont_size=10),
                  yaxis=dict(tickfont_size=10, 
                             side='left', range=[0, df['revenue'].max() + 20000000]),
                  yaxis2=dict(showticklabels=False, side='right',
                              showgrid=False, range=[df['SKU_count'].min()-20, df['SKU_count'].max() + 20]),
                  legend_font=dict(size=11), 
                  legend=dict(orientation="h", yanchor="bottom", y=0.9, xanchor="left", x=0.02, bgcolor='rgba(255, 255, 255, 0)'),
                  barmode='overlay',
                  margin = dict(t=50, l=50, r=10, b=10))
fig.show()

Теперь всплывающие подсказки отображаются корректно.

Кастомизация визуализации

Теперь самое время приступить к улучшению внешнего вида моей визуализации и приблилизить ее к референсу. Разумеется, я не ставила себе целью полностью повторить референс (хотя Вы можете это сделать, используя приведенный код), поэтому полного совпадения не будет: выглядеть они будут все-же по-разному.
Мне необходимо сделать более плавными линии Line Chart и Area Chart, добавить для них значения, а также настроить цветовую схему.
Поехали!

Референс цветовой схемы. Найдено в Pinterest
Референс цветовой схемы. Найдено в Pinterest

Начнем с цветовой схемы. В качестве референса я использую идею из Pinterest.
Да-да))) Цветовая палитра очень девчачья. Но я девочка, а за окном весна.
Я задам цветовую палитру из шести цветов путем простого присваивания переменных, где значением переменной будет Hex Color Code нужного мне цвета.

Что я сделаю:
- настрою сглаживание линий Line Chart и Area Chart, добавлю визуализацию их значений и настрою красивый визуал области под графиком Area Chart;
- задам цвета для каждого из графиков, а также для заголовка, шрифтов легенды и тиков, фона;
- задам параметры сетки осей Y;
- настрою расстояние между столбцами Bar Chart.

fig = make_subplots(specs=[[{'secondary_y': True}]])

#Задаю переменные, которые хранят информацию о Hex Color Code используемых цветов.
dark_1 = '#c46d86'
light_1 = '#eab0bb'
light_2 = '#f5f5f5'
dark_2 = '#6c6c6c'
dark_3 = '#26601c'
light_3 = '#31a422'
fig.add_trace(go.Scatter(x=df['quarter'], y=df['SKU_count'], 
                         name='Кол-во SKU', 
                         mode='lines+markers+text', 
                         customdata = df['SKU_count'], 
                         hovertemplate='<b>%{x}</b><br>' + 'Кол-во SKU: %{customdata:.0f}<extra></extra>',
                         marker_color=light_3, #задаю цвет линии
                         line=dict(shape='spline', #сглаживание линии
                                   smoothing=0.9, # настраиваю степень сглаживания)
                                   color = light_3), #настраиваю цвет маркера
                         text = df['SKU_count'].tolist(), #отображающиеся на графике значения
                         textposition='top center', #положение этих значений
                         textfont=dict(size=9, color=dark_3, weight='bold')),#размер шрифта, его жирность и цвет 
              secondary_y = True)

fig.add_trace(go.Bar(x=df['quarter'], y=df['revenue'], 
                     name='Выручка', 
                     customdata = df['revenue_mln'], 
                     hovertemplate='<b>%{x}</b><br>' + 'Выручка: %{customdata:.2f} млн.руб.<extra></extra>', 
                     marker_color=light_1), #цвет столбца
              secondary_y = False)

fig.add_trace(go.Bar(x=df['quarter'], y=df['gross_profit_margin'], 
                     name='Валовая прибыль', 
                     customdata = df['gross_profit_margin_mln'], 
                     hovertemplate='<b>%{x}</b><br>' + 'Вал.прибыль: %{customdata:.2f} млн.руб.<extra></extra>', 
                     marker_color=dark_1),  #цвет столбца
              secondary_y = False)

gross_margin_line = [int(num * 4.8) for num in df['gross_margin'].tolist()]
fig.add_trace(go.Scatter(x=df['quarter'], y=gross_margin_line, 
                         name='Рентабельность', 
                         marker_color=dark_3, #цвет линии 
                         customdata=df['gross_margin'], 
                         hovertemplate='<b>%{x}</b><br>' + 'Маржинальность: %{customdata:.2f}%<extra></extra>',
                         mode='lines+text+markers', 
                         fill='tozeroy',
                         text=[f"{val:.0f}%" for val in df['gross_margin']], #отображающиеся на графике значения со знаком '%'
                         textposition='top center', #положение этих значений
                         textfont=dict(size=9, color=dark_3, weight='bold'),
                         #эта часть кода отвечает за настроку отображения области под графиком
                         fillpattern=dict(shape='/',  # тип штриховки
                                          fgcolor=dark_3,  # цвет линий штриховки
                                          bgcolor="rgba(255, 255, 255, 0)"),  # цвет фона, я задаю его полностью прозрачным)
                         line=dict(shape='spline', smoothing=0.6)), #эти параметры отвечают за сграживание линий 
              secondary_y = True)
#настройка параметров сетки: настраиваю цвет и толщину линий, визуально убираю саму ось X: она есть, но ее не видно за счет того, что ее цвет совпадает с фоном
fig.update_yaxes(showgrid=True, gridwidth=1, gridcolor=dark_2, zerolinecolor=light_2)

fig.update_layout(title=(f'Сколько SKU мы продаем и сколько зарабатываем'), 
                  title_font=dict(size=16, color=dark_3, weight='bold'), #параметры шрифта заголовка
                  title_x=0.1, title_y=0.95, height = 350, width = 650, 
                  xaxis=dict(tickfont_size=10, tickfont_color=dark_2), #настраиваю цвета тиков
                  yaxis=dict(tickfont_size=10, tickfont_color=dark_2, #настраиваю цвета тиков
                             side='left', range=[0, df['revenue'].max() + 20000000]),
                  yaxis2=dict(showticklabels=False, side='right',
                              showgrid=False, range=[df['SKU_count'].min()-20, df['SKU_count'].max() + 20]),
                  legend_font=dict(size=11, color = dark_2), #настраиваю цвет текста легенды
                  legend=dict(orientation="h", yanchor="bottom", y=0.9, xanchor="left", x=0.02, bgcolor='rgba(255, 255, 255, 0)'),
                  barmode='overlay',
                  margin = dict(t=50, l=50, r=10, b=10),
                  bargroupgap=0.1, #добавлю "воздух" между столбцами
                  paper_bgcolor = light_2, plot_bgcolor=light_2) #задам цвет фона
fig.show()
Результат выполнения кода
Результат выполнения кода

Готово! Честно говоря, мне категорически не нравится одновременное отображение значений для чартов "Количество SKU" и "Рентабельность": создается ощущение перегруженности. С учетом того, что на Plotly создаются интерактивные визуализации, необходимость визуализации всех значений непосредственно на графике уже не кажется такой очевидной. Поэтому я бы внесла такие изменения в код:

#удалить
text=[f"{val:.0f}%" for val in df['gross_margin']], 
textposition='top center', #положение этих значений
textfont=dict(size=9, color=dark_3, weight='bold')
#заменить mode='lines+text+markers', на
mode='lines'

И приняла как финальный такой вариант:

Итоговый вариант визуализации
Итоговый вариант визуализации

Какой вариант с Вашей точки зрения лучше: с обилием отображаемых значений или насколько возможно минималистичный вариант?

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

Публикации

Работа

Data Scientist
48 вакансий

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