Привет, Хабр! В этой статье я расскажу про своё видение работы с цветом при визуализации графиков. Буду показывать все на примерах — уверен, они вам понравятся.

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

Меня зовут Саша, сейчас я работаю в Lamoda Tech старшим бизнес/дата-аналитиком. До этого я несколько лет был специалистом по данным в другой компании и регулярно представлял совету директоров анализ и прогноз физических и бизнес-показателей. Умение донести результаты исследования до заказчика, особенно если он не погружен в работу с данными — это важный аспект моей профессии. Надеюсь, статья поможет прокачать этот скилл. 

Визуализация графиков

Давайте приступим. В качестве библиотеки для визуализации будет использоваться seaborn. В скрытых блоках будет находиться код, а основой статьи станут картинки: не зря же статья именно про визуализацию?

Представим, что у нас есть данные некой метрики за несколько лет (2006-2011), и нам нужно представить результаты менеджменту. Файлы для этой статьи — код, датасет и графики вы можете найти на GitHub.

Прим.: следующий блок кода — единственный обязательный к исполнению. Все остальные нужны только для воспроизведения конкретного примера визуализации.

Блок кода
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns

path_graphs = '../graphs/'
is_save_graph = False

df_data = pd.read_pickle('../data/df_data.pkl')
display(df_data.head(), df_data.tail())

# Пригодится позже №1
# Для графиков синусойд
df_sin = df_data.copy(deep=True)
sin_base = np.sin(np.arange(0, np.pi*2, np.pi*2/12)) * 1.5*10**4
df_sin['metrics1'] = np.concatenate([sin_base + i*10**4 for i in range(6,12)])

# Пригодится позже №2
# Для графиков с предположением, что 2011 год — прогноз
df_predict = df_data.copy(deep=True) 
df_predict['year'] = df_predict['year'].apply(lambda x: f'{x} прогноз' if x == 2011 else f'{x} факт')

# Пригодится позже №3
# Для графиков, предложенных вместо 3Д визуализаций
df_data3d = pd.concat([df_data.iloc[:, 2:], df_data.iloc[:, 2:]*1.01, df_data.iloc[:, 2:]*1.05], axis=0)

База

Блок кода
sns.lineplot(data=df_data, x=df_data.index, y='metrics1') # Отрисуем график

if is_save_graph: plt.savefig(f'{path_graphs}plot_base.jpeg') # Сохраним график

Как видно из графика, это временной ряд некоторой метрики. Такая визуализация подойдет только для внутреннего пользования командой, и то с натяжкой: нечитаемый временной период, плохо воспринимается размерность, качество картинки оставляет желать лучшего. Если нам понадобится использовать подобный график в отчете или презентации, то придется перезапускать тетрадь и отформатировать его, и нам повезет, если делать это будет тот же человек, что выполнял задачу — у него уже будут необходимые данные и понимание, что нужно подсветить.

Применим немного усилий, чтобы улучшить восприятие.

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.lineplot(data=df_data, x=df_data.index, y='metrics1') # Отрисуем график

plt.xticks(rotation=90, fontsize=6) # Повернем подписи делений оси абсцисс
plt.xlim(0, len(df_data)-1) # Ограничим ось абсцисс
plt.xlabel('Дата', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи оси ординат
plt.ylim(4*10**4, 12.5*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.title('Изменение метрики №1 в период 2006-2011 годов', fontsize=16) # Изменим название графика
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_base_advanced.jpeg') # Сохраним график

Затрачено 2 минуты, а график стал намного привлекательнее и читабельнее. Читателю стали понятнее:

  • Суть графика —  название.

  • Временной период: подписи повернули, и их стало видно.

  • Размерность метрики: перенесли приставку тыс. в название оси.

Я согласен, что в первое время такие изменения будут отнимать больше времени. Но с каждым разом у вас будет больше опыта, и вы будете справляться с форматированием быстрее. Даже когда вы на 99% уверены, что график не нужно будет презентовать, отформатируйте его. Так вы оставите после себя приятную и понятную тетрадь и покажете свое отношение к проделанной работе и коллегам, которые будут в неё погружаться.

У вас есть только 10 секунд

Знаете «правило 10 секунд»? Мы по большей части формируем своё мнение о незнакомом человеке в первые несколько мгновений. С графиками происходит точно так же, поэтому с первого взгляда они должны быть максимально интуитивными и сразу давать представление о содержании!

Немного о hue

Параметр hue позволяет выполнить визуализацию с дополнительной разбивкой по фиче. Для этого можно использовать два типа цветовых палитр (подробнее https://seaborn.pydata.org/tutorial/color_palettes.html):

  • Непрерывная (подходит для непрерывных фичей) — это некоторый градиент. Сколько бы ни было значений в фиче, они расположатся внутри градиента строго от минимума к максимуму.

Блок кода
for palette in ('viridis', 'flare', 'rocket'):
    plt.figure(figsize=(5,1), dpi=300)
    sns.heatmap(data=[range(10**4)], cbar=False, cmap=palette)
    plt.axis('off')
    plt.tight_layout()
    if is_save_graph: plt.savefig(f'{path_graphs}plot_palette_{palette}_1.jpeg') # Сохраним график

  • Дискретная (логично для дискретных фичей) — конечное количество цветов в палитре. При достижении последнего цвета цикл назначения цвета категории начинается по новой.

Блок кода
for palette in ('pastel', 'Set2', 'rocket'):
    plt.figure(figsize=(5,1), dpi=300)
    sns.barplot(data=[[1]]*20, palette=palette)
    for w in plt.gca().patches:
        w.set_width(1)
    plt.axis('off')
    plt.tight_layout()
    if is_save_graph: plt.savefig(f'{path_graphs}plot_palette_{palette}_2.jpeg') # Сохраним график

  1. В палитре ‘pastel’ 10 цветов, поэтому начиная с 11 цвета цикл начинается снова.

  2. В палитре 'Set2' 8 цветов, поэтому начиная с 9 цвета цикл начинается снова.

  3. Некоторые палитры можно использовать в качестве и первого, и второго типа, например, 'rocket'. Сколько бы мы ни получили из этих палитр цветов — все они будут уникальными, но не всегда это будет заметно визуально.

Градиентная палитра

Например, если мы хотим презентовать тренды, которые сложились внутри годов (представить год категориальной фичей), укажем параметр hue='year'.

Прим.: дополнительно поправим название легенды и кегль.

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.lineplot(data=df_data, x='month', y='metrics1', hue='year') # Отрисуем график

x_unique = df_data.month.unique()
plt.xticks(ticks=x_unique, fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(x_unique.min(), x_unique.max()) # Ограничим ось абсцисс
plt.xlabel('Месяц', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.legend(title='Год', loc='upper right', fontsize=8) # Укажем название и положение легенды
plt.title('Годовое изменение метрики №1 в период 2006-2011 годов', fontsize=16) # Изменим название графика
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_gradient.jpeg') # Сохраним график

Попробуем поменять порядок категорий через параметр hue_order.

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.lineplot(data=df_data, x='month', y='metrics1', hue='year', hue_order=df_data.year.unique()[::-1]) # Отрисуем график

x_unique = df_data.month.unique()
plt.xticks(ticks=x_unique, fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(x_unique.min(), x_unique.max()) # Ограничим ось абсцисс
plt.xlabel('Месяц', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.legend(title='Год', loc='upper right', fontsize=8) # Укажем название и положение легенды
plt.title('Годовое изменение метрики №1 в период 2006-2011 годов', fontsize=16) # Изменим название графика
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_gradient_test.jpeg') # Сохраним график

Не получилось.

Если для hue переданы данные с типом данных число, то стандартно в seaborn будет использоваться непрерывная (градиентная) палитра, и мы не сможем изменить порядок категорий через hue_order, так как они будут распознаны, как непрерывные данные. Если интересно почему, то https://stackoverflow.com/a/68136043/23121666.

Но если мы хотим поправить порядок годов («категорий»), чтобы они визуально, насколько это возможно, совпадали с расположением трендов, то можно сделать это принудительно в легенде. В версиях matplotlib >=3.7 для этого можно использовать reverse=True в plt.legend, но мы рассмотрим вариант, который позволит работать с легендой по нашему усмотрению в любой версии.

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.lineplot(data=df_data, x='month', y='metrics1', hue='year') # Отрисуем график

x_unique = df_data.month.unique()
plt.xticks(ticks=x_unique, fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(x_unique.min(), x_unique.max()) # Ограничим ось абсцисс
plt.xlabel('Месяц', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.title('Годовое изменение метрики №1 в период 2006-2011 годов', fontsize=16) # Изменим название графика
handles, labels = plt.gca().get_legend_handles_labels() # Получим данные легенды
plt.legend(handles[::-1], labels[::-1], title='Год', loc='upper right', fontsize=8) # Укажем название и положение легенды и порядок категорий
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_gradient_advanced.jpeg') # Сохраним график

График уже получился внешне приятным, но кажется, что эта палитра не подходит для нашей фичи. В нашем случае тренды 2010 и 2011 годов вообще перемешались, и совершенно непонятно, что из них что.

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

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.lineplot(data=df_sin, x='month', y='metrics1', hue='year') # Отрисуем график

x_unique = df_sin.month.unique()
plt.xticks(ticks=x_unique, fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(x_unique.min(), x_unique.max()) # Ограничим ось абсцисс
plt.xlabel('Месяц', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.title('Годовое изменение метрики №1 в период 2006-2011 годов', fontsize=16) # Изменим название графика
handles, labels = plt.gca().get_legend_handles_labels() # Получим данные легенды
handles = handles[::-1]
labels = labels[::-1]
plt.legend(handles, labels, title='Год', loc='upper right', fontsize=8) # Укажем название и положение легенды и порядок категорий
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_vacuum.jpeg') # Сохраним график

Дискретная палитра

Используя параметр palette, выберем другую палитру — дискретную.

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.lineplot(data=df_data, x='month', y='metrics1', hue='year', palette='Set2') # Отрисуем график

x_unique = df_data.month.unique()
plt.xticks(ticks=x_unique, fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(x_unique.min(), x_unique.max()) # Ограничим ось абсцисс
plt.xlabel('Месяц', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.title('Годовое изменение метрики №1 в период 2006-2011 годов', fontsize=16) # Изменим название графика
plt.legend(title='Год', loc='upper right', fontsize=8) # Укажем название и положение легенды и порядок категорий
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_discrete.jpeg') # Сохраним график

Теперь мы можем использовать параметр hue_order и изменить порядок категорий. С этой палитрой уже очевидно, где тренды 2010 и 2011 годов. Но выглядит это всё как набор фломастеров.

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.lineplot(data=df_data, x='month', y='metrics1', hue='year', palette='Set2', hue_order=df_data.year.unique()[::-1]) # Отрисуем график

x_unique = df_data.month.unique()
plt.xticks(ticks=x_unique, fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(x_unique.min(), x_unique.max()) # Ограничим ось абсцисс
plt.xlabel('Месяц', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.legend(title='Год', loc='upper right', fontsize=8) # Укажем название и положение легенды
plt.title('Годовое изменение метрики №1 в период 2006-2011 годов', fontsize=16) # Изменим название графика
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_discrete_advanced.jpeg') # Сохраним график

Комбинированная палитра

Эти палитры воплощают в себе сразу 2 идеи: интенсивная смена цветов и постепенное изменение оттенка. Это будет полезно, когда нам нужно расставить акценты за счет цвета и прозрачности. Например, цвета сгруппированы на две группы и делят категорию на более актуальные, и скорее всего, более значимые года, и менее значимые, которые отображены скорее для исторического факта. Конечно, уже есть и такие готовые палитры.

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.lineplot(data=df_data, x='month', y='metrics1', hue='year', palette='tab20b', alpha=0.7, hue_order=df_data.year.unique()[::-1]) # Отрисуем график

x_unique = df_data.month.unique()
plt.xticks(ticks=x_unique, fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(x_unique.min(), x_unique.max()) # Ограничим ось абсцисс
plt.xlabel('Месяц', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.title('Годовое изменение метрики №1 в период 2006-2011 годов', fontsize=16) # Изменим название графика
plt.legend(title='Год', loc='upper right', fontsize=8) # Укажем название и положение легенды и порядок категорий
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_combine.jpeg') # Сохраним график

В нашем примере эта палитра не идеально отражает задачу. Попробуем добавить новое условие: предположим, что данные за 2011 год — это прогноз, и тогда нам стоит сделать на нем акцент. Для этого создадим собственную палитру. Подобрать RGB можно онлайн на любом сайте, а оттенки генерируются за счет уровня непрозрачности.

Блок кода
def calculate_rgb(
    rgb: tuple((int, int, int)),
    opacity: float,
) -> [float, float, float, float]:
    """
    Функция получает на вход RGB палитру в виде чисел 0-255, а также уровень непрозрачности в диапазоне 0-1.
    Возвращает список из четырех чисел. Первые три числа - RGB, пересчитанные для seaborn. Последнее число - уровень непрозрачности.
    """
    return [i/255 for i in rgb] + [opacity]

def make_palette(
    rgb_tuple: tuple((int, int, int)),
    opacity_tuple: tuple((float, float)),
    elem_numbers: int,
) -> [list]:
    """
    Функция получает на вход числа RGB, диапазон непрозрачности и количество элементов, которые нужно сгенерировать.
    Возвращает список элементов (списков) согласно задааному количеству элементов.
    """
    return [calculate_rgb(rgb_tuple, o) for o in np.linspace(opacity_tuple[0], opacity_tuple[1], elem_numbers)]

# make_palette((R, G, B), (opacity_max, opacity_min), element_numbers)
own_palette = make_palette((200, 50, 50), (1.0, 1.0), 1) + make_palette((50, 50, 200), (0.7, 0.3), 3) + make_palette((200, 200, 200), (0.8, 0.3), 2)

plt.figure(figsize=(5,1), dpi=300)
sns.barplot(data=[[1]]*6, palette=own_palette)
asd = plt.gca().patches
for w,o in zip(plt.gca().patches, own_palette):
    w.set_width(1.0)
    w.set_alpha(o[-1])
plt.axis('off')
plt.tight_layout()

if is_save_graph: plt.savefig(f'{path_graphs}plot_personal_palette.jpeg')

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.lineplot(data=df_predict, x='month', y='metrics1', hue='year', palette=own_palette, hue_order=df_predict.year.unique()[::-1]) # Отрисуем график

x_unique = df_predict.month.unique()
plt.xticks(ticks=x_unique, fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(x_unique.min(), x_unique.max()) # Ограничим ось абсцисс
plt.xlabel('Месяц', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.title('Годовое изменение метрики №1 в период 2006-2011 годов', fontsize=16) # Изменим название графика
plt.legend(title='Год', loc='upper right', fontsize=8) # Укажем название и положение легенды и порядок категорий
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_combine_advanced.jpeg') # Сохраним график

Здесь можно заметить, что явный акцент сделан на 2011 год (условно мы сделали по нему прогноз и презентуем/анализируем результаты). Три следующих периода вносят наибольший вклад в наше понимание прогнозного тренда, а два последних представлены как исторический факт.

Вместо 3D графиков

Иногда возникает потребность показать распределение данных по трем осям, то есть использовать 3D график. Не торопитесь, у них есть несколько больших недостатков:

  • они требуют больше ресурсов,

  • визуально они сложнее,

  • их нельзя перенести в отчет так, чтобы было видно все три оси и распределение всех данных.

Для отображения третьего измерения я предлагаю использовать:

  • цвет,

  • размер точки,

  • форму точки.

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

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.scatterplot(data=df_data3d, x='metrics2', y='metrics1', color='orange') # Отрисуем график

x_ticks, x_label = plt.xticks() # Получим индексы и подписи оси ординат
plt.xticks(ticks=x_ticks, labels=['{:.1f}'.format(i) for i in x_ticks/10**6], fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(0.1*10**6, 1*10**6) # Ограничим ось абсцисс
plt.xlabel('Метрика №2, млн.шт.', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.title('Зависимость метрики №1 от метрики №2', fontsize=16) # Изменим название графика
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_3d.jpeg') # Сохраним график

Мы видим, что метрики коррелируют. Для большей наглядности мы можем сделать точки более прозрачными, тогда нам будет проще оценить скопление таких точек (при наложении друг на друга уровень непрозрачности будет возрастать).

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.scatterplot(data=df_data3d, x='metrics2', y='metrics1', color='orange', alpha=0.6) # Отрисуем график

x_ticks, x_label = plt.xticks() # Получим индексы и подписи оси ординат
plt.xticks(ticks=x_ticks, labels=['{:.1f}'.format(i) for i in x_ticks/10**6], fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(0.1*10**6, 1*10**6) # Ограничим ось абсцисс
plt.xlabel('Метрика №2, млн.шт.', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.title('Зависимость метрики №1 от метрики №2', fontsize=16) # Изменим название графика
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_3d_opacity.jpeg') # Сохраним график

Но что, если нам нужно посмотреть на поведение данных относительно третьей оси, но отрисовывать 3D график мы не хотим? Используем размер точек в качестве дополнительного измерения.

В отличие от графика с оранжевыми точками без изменения размера точек я увеличил уровень непрозрачности.

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

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.scatterplot(data=df_data3d, x='metrics2', y='metrics1', size='metrics3', color='orange', alpha=0.8) # Отрисуем график

x_ticks, x_label = plt.xticks() # Получим индексы и подписи оси ординат
plt.xticks(ticks=x_ticks, labels=['{:.1f}'.format(i) for i in x_ticks/10**6], fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(0.1*10**6, 1*10**6) # Ограничим ось абсцисс
plt.xlabel('Метрика №2, млн.шт.', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.title('Зависимость метрики №1 от метрики №2', fontsize=16) # Изменим название графика
legend_handles, legend_labels = plt.gca().get_legend_handles_labels() # Получим индексы и подписи оси ординат
for l in legend_handles: # Поправим цвет и непрозрачность подписей в легенде
    l.set_color('orange')
    l.set_alpha(0.8)
legend_labels = ['{:.0f}'.format(float(l)*100) for l in legend_labels] # Изменим подписи легенды
plt.legend(legend_handles, legend_labels, title='Метрика №3, млн.шт.', loc='lower right', fontsize=8) # Укажем название и положение легенды и порядок категорий
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_3d_size.jpeg') # Сохраним график

Различие в размерах точек сейчас недостаточно очевидно, и непонятно, что из них что. Для наглядности увеличим диапазон между минимальным и максимальным размером.

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.scatterplot(data=df_data3d, x='metrics2', y='metrics1', size='metrics3', sizes=(10, 150), color='orange', alpha=0.8) # Отрисуем график

x_ticks, x_label = plt.xticks() # Получим индексы и подписи оси ординат
plt.xticks(ticks=x_ticks, labels=['{:.1f}'.format(i) for i in x_ticks/10**6], fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(0.1*10**6, 1*10**6) # Ограничим ось абсцисс
plt.xlabel('Метрика №2, млн.шт.', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.title('Зависимость метрики №1 от метрики №2', fontsize=16) # Изменим название графика
legend_handles, legend_labels = plt.gca().get_legend_handles_labels() # Получим индексы и подписи оси ординат
for l in legend_handles: # Поправим цвет и непрозрачность подписей в легенде
    l.set_color('orange')
    l.set_alpha(0.8)
legend_labels = ['{:.0f}'.format(float(l)*100) for l in legend_labels] # Изменим подписи легенды
plt.legend(legend_handles, legend_labels, title='Метрика №3, млн.шт.', loc='lower right', fontsize=8) # Укажем название и положение легенды и порядок категорий
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_3d_size_range.jpeg') # Сохраним график

Все еще не очень наглядно. Предлагаю использовать вместо размера цвет точек. Мне такой вариант нравится больше и кажется более восприимчивым другими.

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.scatterplot(data=df_data3d, x='metrics2', y='metrics1', hue='metrics3') # Отрисуем график

x_ticks, x_label = plt.xticks() # Получим индексы и подписи оси ординат
plt.xticks(ticks=x_ticks, labels=['{:.1f}'.format(i) for i in x_ticks/10**6], fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(0.1*10**6, 1*10**6) # Ограничим ось абсцисс
plt.xlabel('Метрика №2, млн.шт.', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.title('Зависимость метрики №1 от метрики №2', fontsize=16) # Изменим название графика
legend_handles, legend_labels = plt.gca().get_legend_handles_labels() # Получим индексы и подписи оси ординат
legend_labels = ['{:.0f}'.format(float(l)*100) for l in legend_labels] # Изменим подписи легенды
plt.legend(legend_handles, legend_labels, title='Метрика №3, млн.шт.', loc='lower right', fontsize=8) # Укажем название и положение легенды и порядок категорий
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_3d_hue.jpeg') # Сохраним график

Еще один пример, который мне кажется очень наглядным. Мы вновь добавим условие, как делали это ранее, что 2011 год — это прогноз, и воспользуемся собственной палитрой, которую мы уже создавали.

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.scatterplot(data=df_predict, x='metrics2', y='metrics1', hue='year', hue_order=df_predict.year.unique()[::-1], palette=own_palette) # Отрисуем график

x_ticks, x_label = plt.xticks() # Получим индексы и подписи оси ординат
plt.xticks(ticks=x_ticks, labels=['{:.1f}'.format(i) for i in x_ticks/10**6], fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(0.1*10**6, 1*10**6) # Ограничим ось абсцисс
plt.xlabel('Метрика №2, млн.шт.', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.title('Зависимость метрики №1 от метрики №2', fontsize=16) # Изменим название графика
plt.legend(title='Год', loc='lower right', fontsize=8) # Укажем название и положение легенды и порядок категорий
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_3d_palette.jpeg') # Сохраним график

Ну и давайте добавим размер точек в качестве измерения. Может быть, это даже ту мач…

Блок кода
plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество

sns.scatterplot(
    data=df_predict, x='metrics2', y='metrics1', hue='year', hue_order=df_predict.year.unique()[::-1], palette=own_palette,
    size='metrics3', sizes=(10, 150),
) # Отрисуем график

x_ticks, x_label = plt.xticks() # Получим индексы и подписи оси ординат
plt.xticks(ticks=x_ticks, labels=['{:.1f}'.format(i) for i in x_ticks/10**6], fontsize=6) # Изменим подписи делений оси абсцисс
plt.xlim(0.1*10**6, 1*10**6) # Ограничим ось абсцисс
plt.xlabel('Метрика №2, млн.шт.', fontsize=10) # Изменим подпись оси абсцисс

y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат

plt.title('Зависимость метрики №1 от метрики №2', fontsize=16) # Изменим название графика
legend_handles, legend_labels = plt.gca().get_legend_handles_labels() # Получим индексы и подписи оси ординат
# Изменим подписи легенды
legend_labels[0] = 'Год'
legend_labels[7] = 'Метрика №3, млн.шт.'
legend_labels[8:] = ['{:.0f}'.format(float(l)*100) for l in legend_labels[8:]]
plt.legend(legend_handles, legend_labels, loc='lower right', fontsize=8) # Укажем название и положение легенды и порядок категорий
plt.tight_layout() # Растянем график на всю подложку

if is_save_graph: plt.savefig(f'{path_graphs}plot_3d_palette_size.jpeg') # Сохраним график

Какие выводы можно сделать из нашего графика? Например, что:

  1. Мы планируем интенсивный рост метрики №2 в 2011 году.

  2. По метрике №1 также ожидается рост, но в меньшей степени.

  3. В 2009-2010 есть драйверы роста метрики №3, которых мы не ожидаем в 2011 году.

Заключение

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

В части визуализации нет никаких ограничений, и всё зависит только от вашей фантазии. Я стараюсь придерживаться следующих рекомендаций:

  • Исправлять подписи и легенду, добавлять название. Со временем вы будете делать это быстрее и быстрее, и в итоге это войдет в привычку.

  • Не перегружать график: не использовать более 3 базовых цветов, не учитывая их оттенки, не добавлять одновременно визуализацию размеров точек, цвета и формы. Нужно выбрать один, максимум два типа визуализации, если это уместно.

  • Приоритетные тренды — те, на которые заказчик должен обратить внимание в первую очередь, — лучше отображать более насыщенными и темными оттенками.

  • Если есть несколько трендов одного оттенка, их должно что-то объединять идейно: сегмент рынка/год/месяц. Вы должны это показать, сделать очевидным.

  • Использовать тип палитры в соответствии с типом данных: дискретные, непрерывные. Это избавит от проблем и конфликтов при работе с библиотекой.

  • Чем больше разнообразных визуализаций вы делаете, тем лучше. Сначала количество, потом качество.

Комментарии (6)


  1. Zenitchik
    27.03.2024 11:07

    На КДПВ бессмысленный график.


  1. IgorSuchkov
    27.03.2024 11:07

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


    1. sawabear_a Автор
      27.03.2024 11:07

      Привет) сетка на графиках очень дискуссионная тема - зачастую считается, что ей лучше пренебречь... Графики в данной статье визуализируют тренды, диапазоны метрик, периоды и не призваны дать быструю оценку значений в конкретной точке) Давай посмотрим как они будут выглядеть с сеткой:

      Блок кода
      plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество
      
      sns.lineplot(data=df_predict, x='month', y='metrics1', hue='year', palette=own_palette, hue_order=df_predict.year.unique()[::-1]) # Отрисуем график
      
      x_unique = df_predict.month.unique()
      plt.xticks(ticks=x_unique, fontsize=6) # Изменим подписи делений оси абсцисс
      plt.xlim(x_unique.min(), x_unique.max()) # Ограничим ось абсцисс
      plt.xlabel('Месяц', fontsize=10) # Изменим подпись оси абсцисс
      
      y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
      plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
      plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
      plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат
      
      plt.title('Годовое изменение метрики №1 в период 2006-2011 годов', fontsize=16) # Изменим название графика
      plt.legend(title='Год', loc='upper right', fontsize=8) # Укажем название и положение легенды и порядок категорий
      plt.tight_layout() # Растянем график на всю подложку
      plt.grid(True) # Отобразим сетку

      Блок кода
      plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество
      
      sns.scatterplot(
          data=df_predict, x='metrics2', y='metrics1', hue='year', hue_order=df_predict.year.unique()[::-1], palette=own_palette,
          size='metrics3', sizes=(10, 150),
      ) # Отрисуем график
      
      x_ticks, x_label = plt.xticks() # Получим индексы и подписи оси ординат
      plt.xticks(ticks=x_ticks, labels=['{:.1f}'.format(i) for i in x_ticks/10**6], fontsize=6) # Изменим подписи делений оси абсцисс
      plt.xlim(0.1*10**6, 1*10**6) # Ограничим ось абсцисс
      plt.xlabel('Метрика №2, млн.шт.', fontsize=10) # Изменим подпись оси абсцисс
      
      y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
      plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
      plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
      plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат
      
      plt.title('Зависимость метрики №1 от метрики №2', fontsize=16) # Изменим название графика
      legend_handles, legend_labels = plt.gca().get_legend_handles_labels() # Получим индексы и подписи оси ординат
      # Изменим подписи легенды
      legend_labels[0] = 'Год'
      legend_labels[7] = 'Метрика №3, млн.шт.'
      legend_labels[8:] = ['{:.0f}'.format(float(l)*100) for l in legend_labels[8:]]
      plt.legend(legend_handles, legend_labels, loc='lower right', fontsize=8) # Укажем название и положение легенды и порядок категорий
      plt.tight_layout() # Растянем график на всю подложку
      plt.grid(True) # Отобразим сетку

      Я согласен, что так проще и быстрее оценить значение в точке, но вопрос в том, что нужно ли нам это? В любом случае всё зависит от твоего вкуса и запроса заказчика)

      Дополнительно хочу показать, как можно акцентировать внимание на некоторых значениях, которые могут нести инсайты)

      Блок кода
      plt.figure(figsize=(10,5), dpi=300) # Изменим размер подложки графика и качество
      
      sns.lineplot(data=df_predict, x='month', y='metrics1', hue='year', palette=own_palette, hue_order=df_predict.year.unique()[::-1]) # Отрисуем график
      plt.axvline(8, 0, 1, color='black', alpha=0.5, dashes=(5, 5))
      
      x_unique = df_predict.month.unique()
      plt.xticks(ticks=x_unique, fontsize=6) # Изменим подписи делений оси абсцисс
      plt.xlim(x_unique.min(), x_unique.max()) # Ограничим ось абсцисс
      plt.xlabel('Месяц', fontsize=10) # Изменим подпись оси абсцисс
      
      y_ticks, y_label = plt.yticks() # Получим индексы и подписи оси ординат
      plt.yticks(ticks=y_ticks, labels=['{:.0f}'.format(i) for i in y_ticks/10**3], fontsize=8) # Изменим подписи делений оси ординат
      plt.ylim(4*10**4, 13*10**4) # Ограничим ось ординат
      plt.ylabel('Метрика №1, тыс.шт.', fontsize=12) # Изменим подпись оси ординат
      
      plt.title('Годовое изменение метрики №1 в период 2006-2011 годов', fontsize=16) # Изменим название графика
      plt.legend(title='Год', loc='upper right', fontsize=8) # Укажем название и положение легенды и порядок категорий
      plt.tight_layout() # Растянем график на всю подложку
      
      plt.savefig(f'../plot_combine_advanced_line.jpeg') # Сохраним график

      Например, здесь мы хотим сказать, что практически внутри каждого года пиковое значение метрики №1 пришлось на восьмой месяц, а затем происходило резкое снижение... Поэтому мы на 2011 год спрогнозировали пик в этом же месяце, хотя год назад пик был в июле)


  1. dkhor
    27.03.2024 11:07

    На мой взгляд, самая грубая и самая распространенная ошибка при построении любого графика - мелкий шрифт подписей. Он обесценивает любые находки для визуализации данных. На всех примерах здесь - текст для меня, человека с хорошим зрением не читаем. Помимо чисто утилитарного недостатка, непропорционально мелкий текст ещё и эстетически неприятен. А вообще статья интересная. В общем, по моим наблюдениям люди довольно редко пользуются возможностями упростить восприятие данных через цветовые мотивы. А это очень мощный инструмент воздействия.


    1. sawabear_a Автор
      27.03.2024 11:07

      Привет, твоя мысль про важность шрифта/кегля на графике звучит как отличная тема для статьи)

      Многие аспекты в визуализации это очень субъективные вещи и в конечном итоге важно восприятие конечного заказчика. Если он лучше воспринимает (условно) крупный кегль, цветные подписи осей, столбчатые диаграммы - важно к этому прислушаться)

      Возможно, подписи оси абсцисс можно было бы сделать крупнее.. Учту замечание для будущих статей)


  1. maks_v
    27.03.2024 11:07
    +3

    Полезная статья, правда если ограничивать оси, то для новых данных опять придется менять минмум и максимум оси.. не удобно.

    За картинку с котиком отдельный лайк.