Будучи сторонницей минимализма во всем, в том числе и в визуализации данных, я избегаю попыток «впихнуть невпихуемое» в одну визуализацию. Лучше построю группу графиков. Но иногда попадается интересный визуал и хочется его воспроизвести.
Эта визуализация одна из них. Но заинтриговал меня даже не сам дизайн, а текст публикации из ТГК Power BI Design: «Три разные единицы измерения на одном графике. Как? Непросто». И я подумала: а как это реализовать на python с использованием plotly?
Скрытый текст
Финал будет такой, если вдруг нет времени читать с начала и до конца) Полный код в конце

Первое, что нужно было сделать, это создать синтетический набор данных.
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.
Да-да))) Цветовая палитра очень девчачья. Но я девочка, а за окном весна.
Я задам цветовую палитру из шести цветов путем простого присваивания переменных, где значением переменной будет 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'
И приняла как финальный такой вариант:
Какой вариант с Вашей точки зрения лучше: с обилием отображаемых значений или насколько возможно минималистичный вариант?
ссылка на оригинал статьи https://habr.com/ru/articles/898970/
Добавить комментарий