Визуализация одномерных данных в Python



Построение графика одной переменной кажется простой задачей. Но насколько это просто в действительности — эффективно отобразить данные со всего одним измерением? Долгое время я обходился стандартной гистограммой, которая показывает расположение значений, разброс и форму распределения данных (нормальное, скошенное, двухпиковое и др). Но недавно я столкнулся со случаем, когда гистограмма не помогла. И тогда понял, что настало время узнать больше о построении графиков. Я нашёл в сети отличную бесплатную книгу о визуализации данных и попробовал некоторые методы. Я решил, что (и мне, и другим людям) будет полезно, если я поделюсь этими знаниями и составлю руководство по построению на Python гистограмм и их крайне полезной альтернативы — графиков распределения плотности (density plots). Подробности — к старту нашего курса по анализу данных.


Я подробно рассмотрю применение гистограмм и графиков распределения в Python при помощи библиотек matplotlib и seaborn. На протяжении всего руководства исследуем набор реальных данных, потому что богатство доступных в сети материалов не даёт права отказываться от них! Покажем данные NYCflights13 с более чем 300000 наблюдений за авиарейсами из Нью-Йорка в 2013 году. Сосредоточимся на отображении одной переменной — задержки прибытия рейсов в минутах. Весь код этой статьи — в Jupyter Notebook на GitHub.


Перед построением графика всегда полезно изучить данные. Считаем данные во фрейм данных pandas и отобразим первые 10 строк:


import pandas as pd
# Read in data and examine first 10 rows
flights = pd.read_csv('data/formatted_flights.csv')
flights.head(10)


Задержки рейсов указаны в минутах. Отрицательные значения означают, что самолёт совершил посадку с опережением графика (они часто его опережают в те самые дни, когда мы никуда не летим!) Всего у нас более 300000 рейсов. Наименьшая задержка составляет минус шестьдесят минут, наибольшая — сто двадцать минут. В другом столбце — названия авиалиний для сравнения.


Гистограммы


Разумно начать изучение данных с построения гистограммы. При построении гистограммы переменная делится на бины, точки данных подсчитываются в каждом бине, эти бины откладываются по оси x. По оси y откладывается число объектов. Здесь бины отражают диапазон времени задержки рейса, а по y откладывается число рейсов, попавшее в этот интервал. Важнейший параметр гистограммы — ширина бина (binwidth). Всегда стоит попробовать разную ширину и выбрать самую подходящую.


В Python базовую гистограмму может построить или matplotlib, или seaborn. В приведённом ниже коде показаны вызовы функций в обеих библиотеках, которые создают эквивалентные графики. При вызове функции plot мы указываем ширину бина, выраженную в числе бинов. Для этого графика я использую бины длиной 5 минут, что означает, что количество бинов будет равно диапазону данных (от -60 до 120 минут), делённому на ширину бина, 5 минут (bins = int(180/5)).


# Import the libraries
import matplotlib.pyplot as plt
import seaborn as sns

# matplotlib histogram
plt.hist(flights['arr_delay'], color = 'blue', edgecolor = 'black',
         bins = int(180/5))

# seaborn histogram
sns.distplot(flights['arr_delay'], hist=True, kde=False, 
             bins=int(180/5), color = 'blue',
             hist_kws={'edgecolor':'black'})
# Add labels
plt.title('Histogram of Arrival Delays')
plt.xlabel('Delay (min)')
plt.ylabel('Flights')


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


Почему я взял за ширину бина 5 минут? Чтобы найти оптимальное значение, нужно попробовать разные варианты! Ниже я привожу код для создания такого же графика в matplotlib с различными значениями ширины бина. В конечном счёте нет верного или неверного ответа на вопрос о его ширине. Я выбрал 5 минут, потому что считаю, что это значение лучше всего отражает распределение.


# Show 4 different binwidths
for i, binwidth in enumerate([1, 5, 10, 15]):

    # Set up the plot
    ax = plt.subplot(2, 2, i + 1)

    # Draw the plot
    ax.hist(flights['arr_delay'], bins = int(180/binwidth),
             color = 'blue', edgecolor = 'black')

    # Title and labels
    ax.set_title('Histogram with Binwidth = %d' % binwidth, size = 30)
    ax.set_xlabel('Delay (min)', size = 22)
    ax.set_ylabel('Flights', size= 22)

plt.tight_layout()
plt.show()


Ширина бина существенно влияет на вид графика. Слишком узкие бины загромождают его, а слишком широкие скрывают нюансы данных. Matplotlib выбирает оптимальную ширину бина автоматически, однако я предпочитаю выбирать значение вручную, перебирая варианты. Поскольку здесь нет ни единственно верного, ни заведомо неправильного выбора, попробуйте разные варианты и посмотрите, какой из них лучше всего подходит к вашему набору данных.


Когда гистограммы бесполезны


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



(Заметим, что ось y нормализована с учётом различий в количестве рейсов разных авиалиний. Для этого мы используем аргумент norm_hist = True при вызове функции sns.distplot).


Пользы от такого графика мало! Перекрытие линий делает сравнение авиалиний практически невыполнимой задачей. Рассмотрим варианты решения этой задачи.


Вариант 1. Сравнительные гистограммы (Side-by-Side Histograms)


Вместо наложения гистограмм друг на друга мы можем расположить их рядом. Для этого создадим списки (list) задержек рейсов по авиалиниям, а затем передадим вызываемой функции plt.hist список таких списков. Разным авиалиниям мы присвоим разные цвета (color) и наименования (name), чтобы их проще было отличить друг от друга. Всё это, начиная с создания списков, делает вот такой код:


# Make a separate list for each airline
x1 = list(flights[flights['name'] == 'United Air Lines Inc.']['arr_delay'])
x2 = list(flights[flights['name'] == 'JetBlue Airways']['arr_delay'])
x3 = list(flights[flights['name'] == 'ExpressJet Airlines Inc.']['arr_delay'])
x4 = list(flights[flights['name'] == 'Delta Air Lines Inc.']['arr_delay'])
x5 = list(flights[flights['name'] == 'American Airlines Inc.']['arr_delay'])

# Assign colors for each airline and the names
colors = ['#E69F00', '#56B4E9', '#F0E442', '#009E73', '#D55E00']
names = ['United Air Lines Inc.', 'JetBlue Airways', 'ExpressJet Airlines Inc.'',
         'Delta Air Lines Inc.', 'American Airlines Inc.']

# Make the histogram using a list of lists
# Normalize the flights and assign colors and names
plt.hist([x1, x2, x3, x4, x5], bins = int(180/15), normed=True,
         color = colors, label=names)

# Plot formatting
plt.legend()
plt.xlabel('Delay (min)')
plt.ylabel('Normalized Flights')
plt.title('Side-by-Side Histogram with Multiple Airlines')


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


Вариант 2. Столбчатые графики (Stacked Bars)


Вместо построения столбцов данных рядом мы можем расположить их друг над другом при помощи параметра stacked = True при вызове гистограммы:


# Stacked histogram with multiple airlines
plt.hist([x1, x2, x3, x4, x5], bins = int(180/15), stacked=True,
         normed=True, color = colors, label=names)


Этот вариант ничуть не лучше! В каждом бине представлены доли всех авиалиний, однако сравнить их всё ещё невозможно. Вот например, у кого больше доля в бине от -15 до 0 минут: у United Air Lines или же у JetBlue Airlines? Я этого пока не знаю и аудитория тоже. И вообще, я не фанат столбчатых диаграмм. Они обычно трудны для понимания. (Полезными они могут быть лишь в отдельных случаях, например при визуализации соотношений). Ни один из этих гистограммных графиков не приблизил нас к решению. Настало время попробовать графики распределения.


Графики распределения (Density Plots)


Для начала — что это за графики? График распределения можно назвать непрерывным сглаженным аналогом гистограммы. Самый распространённый вариант построения такого графика — ядерная оценка плотности. В этом методе для каждой точки данных строится непрерывная кривая — ядро. Все эти кривые складываются вместе, чтобы получить единую гладкую оценку плотности. Чаще всего используется гауссово ядро (которое даёт колоколообразную кривую Гаусса в каждой точке данных). Если вы, как и я, находите это описание немного запутанным, взгляните на следующий график:



Ядерная оценка плотности (Источник)


Каждый чёрный вертикальный штрих у оси x представляет точку данных. Отдельные ядра (в данном случае — гауссовы) построены над каждой точкой красными пунктирными линиями. Их суммирование даёт общий график распределения, показанный сплошной синей линией.


По оси x здесь, как и на гистограмме, откладывается значение переменной. Но что показывает ось y? Ось y на графике плотности — это функция плотности вероятности для ядерной оценки плотности. Нужно помнить, что это именно плотность вероятности, а не сама вероятность. Разница между ними заключается в том, что плотность вероятности — это вероятность на единицу по оси x. Для преобразования этих данных в обычную вероятность нам нужно найти площадь под кривой для определённого интервала на оси x. Несколько смущает то, что поскольку это плотность вероятности, а не вероятность, ось y может принимать значения больше единицы. Единственное требование к графику плотности — чтобы общая площадь под кривой интегрировалась в единицу. Я привык рассматривать ось y на графике распределения как величину, применимую только для относительных сравнений между категориями.


Графики распределения в Seaborn


При построении графиков распределения в seaborn можно использовать функцию distplot или kdeplot. Я снова использую distplot, ведь он строит несколько распределений одним вызовом функции. Например, можно построить график распределения задержек всех рейсов поверх соответствующей гистограммы:


# Density Plot and Histogram of all arrival delays
sns.distplot(flights['arr_delay'], hist=True, kde=True, 
             bins=int(180/5), color = 'darkblue', 
             hist_kws={'edgecolor':'black'},
             kde_kws={'linewidth': 4})


График распределения и гистограмма, построенные при помощи seaborn


Кривая — график распределения, который, по сути, является сглаженным аналогом гистограммы. По оси y откладывается плотность. Гистограмма по умолчанию нормализована. Поэтому её масштаб по оси y соответствует масштабу графика распределения.


У графика распределения есть величина, аналогичная ширине бина в гистограмме. Её называют шириной полосы пропускания (bandwidth). Эта величина позволяет изменить отдельные ядра и значительно влияет на общий вид графика. Библиотека построения графиков (plotting library) позволяет выбрать ширину полосы пропускания (по умолчанию используется «оценка по Скотту» (scott)). В отличие от гистограмм здесь я обычно полагаюсь на значение по умолчанию. Тем не менее ничто не мешает нам попробовать разную ширину полосы и выбрать оптимальную. Значение по умолчанию на этом графике, ‘scott’, действительно выглядит как наилучший вариант.



График распределения плотностей, показывающий различные полосы пропускания


Заметим, что с увеличением полосы пропускания распределение становится более сглаженным. Мы также видим, что, несмотря на ограничение данных от -60 до 120 минут, график плотности выходит за эти пределы. Это одна из возможных проблем графика плотности. Поскольку мы строим распределение в каждой точке данных, генерируемые данные могут выходить за рамки исходного диапазона. Таким образом, мы получаем на оси x нереалистичные значения, которых не было в исходном наборе данных! Мы также можем изменить ядро, что, в свою очередь, изменит распределение в каждой точке. Тем не менее в большинстве случаев ядро Гаусса по умолчанию и стандартная оценка полосы пропускания работают хорошо.


Вариант 3. График распределения


Теперь мы знаем, что представляет собой график распределения плотностей. Посмотрим, поможет ли он визуализировать задержки рейсов разных авиалиний. Чтобы показать эти распределения на одном графике, мы можем перебрать все авиалинии, каждый раз вызывая distplot. При этом мы присвоим параметру kde (kernel density estimate, ядерная оценка плотности) значение True, а параметру hist (гистограмма) — значение False. Код для построения графика распределения для множества авиалиний приведён ниже:


# List of five airlines to plot
airlines = ['United Air Lines Inc.', 'JetBlue Airways', 'ExpressJet Airlines Inc.'',
         'Delta Air Lines Inc.', 'American Airlines Inc.']

# Iterate through the five airlines
for airline in airlines:
    # Subset to the airline
    subset = flights[flights['name'] == airline]

    # Draw the density plot
    sns.distplot(subset['arr_delay'], hist = False, kde = True,
                 kde_kws = {'linewidth': 3},
                 label = airline)

# Plot formatting
plt.legend(prop={'size': 16}, title = 'Airline')
plt.title('Density Plot with Multiple Airlines')
plt.xlabel('Delay (min)')
plt.ylabel('Density')


Наконец-то мы нашли эффективное решение! Этот график в меньшей степени загромождён, что позволяет проводить сравнения. Получив желанный график, мы можем сделать вывод. Рейсы всех этих авиалиний задерживаются почти одинаково: нет в жизни счастья! Но в нашем наборе данных есть и другие авиалинии, и мы можем построить немного другой график, который покажет ещё один дополнительный параметр графиков распределения плотности — затенение графика (shading).


Графики распределения с затенением (Shaded Density Plots)


Заполнение графика распределения плотностей позволяет нам различать перекрывающиеся распределения. Этот подход не всегда оправдан, но он может подчеркнуть разницу распределений. Чтобы затенять графики плотности, мы передаём shade = True в аргументе kde_kws при вызове функции distplot.


sns.distplot(subset['arr_delay'], hist = False, kde = True,
                 kde_kws = {'shade': True, 'linewidth': 3}, 
                  label = airline)


Затенять или не затенять — вот в чём вопрос… И ответ на него зависит от решаемой задачи! В нашем случае затенение не лишено смысла, поскольку помогает нам рассмотреть оба графика в области их перекрытия. Наконец, мы получили полезную информацию: рейсы Alaska Airlines показывают тенденцию к опережению графика чаще, чем United Airlines. Теперь вы знаете, чьи рейсы выбирать!


Штрих-диаграммы (Rug Plots)


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


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

Она показывает каждую точку на оси x и визуализирует все исходные значения. Преимущество использования distplot в seaborn — возможность добавления штрих-диаграммы при вызове rug = True с одним параметром (и небольшим форматированием).


# Subset to Alaska Airlines
subset = flights[flights['name'] == 'Alaska Airlines Inc.']

# Density Plot with Rug Plot
sns.distplot(subset['arr_delay'], hist = False, kde = True, rug = True,
             color = 'darkblue', 
             kde_kws={'linewidth': 3},
             rug_kws={'color': 'black'})

# Plot formatting
plt.title('Density Plot with Rug Plot for Alaska Airlines')
plt.xlabel('Delay (min)')
plt.ylabel('Density')


Если точек данных много, штрих-диаграмма становится слишком сложной, однако она полезна в некоторых проектах, так как позволяет увидеть каждую точку данных. Штрих-диаграмма также наглядно показывает, как график распределения «создаёт» данные там, где их нет. Это связано с распределением ядерной оценки плотности в каждой точке данных. Это распределение может выходить за рамки начального диапазона данных, создавая впечатление, что некоторые рейсы Alaska Airlines прибывают и раньше и позже, чем в действительности. Нужно помнить об этой иллюзии и информировать о ней аудиторию!


Заключение


Смею надеяться, что в этом посте я перечислил набор полезных для вас вариантов визуализации значений одной переменной для одной и более категорий. Мы могли бы построить и другие одномерные («однопеременные») графики: эмпирические графики кумулятивной плотности (empirical cumulative density plots) и графики квантиль-квантиль (quantile-quantile plots). Однако в этой статье остановимся на гистограммах и графиках распределения (со штрих-диаграммами!). Если даже эти варианты кажутся вам слишком сложными, не отчаивайтесь. После некоторой практики вам станет проще сделать правильный выбор. Если потребуется, вы всегда можете обратиться за помощью. Более того, часто оптимального выбора не существует, а «верное» решение зависит и от личных предпочтений, и от цели визуализации данных. Но, какой бы график вы ни выбрали, вы всегда сможете построить его же Python! Визуальная подача доходчива, а зная все возможные варианты, мы всегда сможем выбрать наилучший график для нашего набора данных.


Любая обратная связь и конструктивная критика приветствуются. Меня можно найти в Twitter @koehrsen_will.


Научим вас аккуратно работать с данными, чтобы вы прокачали карьеру и стали востребованным IT-специалистом:




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


  1. vassabi
    18.12.2022 01:41

    трих-диаграмма также наглядно показывает, как график распределения «создаёт» данные там, где их нет. Это связано с распределением ядерной оценки плотности в каждой точке данных. Это распределение может выходить за рамки начального диапазона данных, создавая впечатление, что некоторые рейсы Alaska Airlines прибывают и раньше и позже, чем в действительности. Нужно помнить об этой иллюзии и информировать о ней аудиторию!

    а если не показывать график там, где нет данных (т.е. соединять линией только те точки, которые показаны на штрих-диаграмме)?


  1. nikc-mk
    18.12.2022 10:28

    Насколько помню в histplot есть параметр hue_order, можно поставить признак, по которому надо сгруппировать, и не писать кучу лишнего кода.