Авторы материала: программист-исследователь Mail.ru Group, старший преподаватель Факультета Компьютерных Наук ВШЭ Юрий Кашницкий и Data Scientist в Segmento Екатерина Демидова. Материал распространяется на условиях лицензии Creative Commons CC BY-NC-SA 4.0. Можно использовать в любых целях (редактировать, поправлять и брать за основу), кроме коммерческих, но с обязательным упоминанием автора материала.
Pandas — это библиотека Python, предоставляющая широкие возможности для анализа данных. С ее помощью очень удобно загружать, обрабатывать и анализировать табличные данные с помощью SQL-подобных запросов. В связке с библиотеками Matplotlib
и Seaborn
появляется возможность удобного визуального анализа табличных данных.
In [1]:
import numpy as np
import pandas as pd
Данные, с которыми работают дата саентисты и аналитики, обычно хранятся в виде табличек — например, в форматах .csv
, .tsv
или .xlsx
. Для того, чтобы считать нужные данные из такого файла, отлично подходит библиотека Pandas.
Основными структурами данных в Pandas являются классы Series
и DataFrame
. Первый из них представляет собой одномерный индексированный массив данных некоторого фиксированного типа. Второй - это двухмерная структура данных, представляющая собой таблицу, каждый столбец которой содержит данные одного типа. Можно представлять её как словарь объектов типа Series
. Структура DataFrame
отлично подходит для представления реальных данных: строки соответствуют признаковым описаниям отдельных объектов, а столбцы соответствуют признакам.
Прочитаем данные и посмотрим на первые 5 строк с помощью метода head
:
In [2]:
df = pd.read_csv('../data/telecom_churn.csv')
In [3]:
df.head()
Out[3]:
В Jupyter-ноутбуках датафреймы Pandas
выводятся в виде вот таких красивых табличек, и print(df.head())
выглядит хуже.
Кстати, по умолчанию Pandas
выводит всего 20 столбцов и 60 строк, поэтому если ваш датафрейм больше, воспользуйтесь функцией set_option
:
In [4]:
pd.set_option('display.max_columns', 100)
pd.set_option('display.max_rows', 100)
А также укажем значение параметра presicion
равным 2, чтобы отображать два знака после запятой (а не 6, как установлено по умолчанию.
In [5]:
pd.set_option('precision', 2)
Посмотрим на размер данных, названия признаков и их типы
In [6]:
print(df.shape)
Видим, что в таблице 3333 строки и 20 столбцов. Выведем названия столбцов:
In [7]:
print(df.columns)
Чтобы посмотреть общую информацию по датафрейму и всем признакам, воспользуемся методом info
:
In [8]:
print(df.info())
bool
, int64
, float64
и object
— это типы признаков. Видим, что 1 признак — логический (bool
), 3 признака имеют тип object
и 16 признаков — числовые.
Изменить тип колонки можно с помощью метода astype
. Применим этот метод к признаку Churn
и переведём его в int64
:
In [9]:
df['Churn'] = df['Churn'].astype('int64')
Метод describe
показывает основные статистические характеристики данных по каждому числовому признаку (типы int64
и float64
): число непропущенных значений, среднее, стандартное отклонение, диапазон, медиану, 0.25 и 0.75 квартили.
In [10]:
df.describe()
Out[10]:
Чтобы посмотреть статистику по нечисловым признакам, нужно явно указать интересующие нас типы в параметре include
. Можно также задать include
='all', чтоб вывести статистику по всем имеющимся признакам.
In [11]:
df.describe(include=['object', 'bool'])
Out[11]:
Для категориальных (тип object
) и булевых (тип bool
) признаков можно воспользоваться методом value_counts
. Посмотрим на распределение нашей целевой переменной — Churn
:
In [12]:
df['Churn'].value_counts()
Out[12]:
2850 пользователей из 3333 — лояльные, значение переменной Churn
у них — 0
.
Посмотрим на распределение пользователей по переменной Area code
. Укажем значение параметра normalize=True
, чтобы посмотреть не абсолютные частоты, а относительные.
In [13]:
df['Area code'].value_counts(normalize=True)
Out[13]:
In [14]:
df.sort_values(by='Total day charge',
ascending=False).head()
Out[14]:
Сортировать можно и по группе столбцов:
In [15]:
df.sort_values(by=['Churn', 'Total day charge'],
ascending=[True, False]).head()
Out[15]:
DataFrame
можно индексировать по-разному. В связи с этим рассмотрим различные способы индексации и извлечения нужных нам данных из датафрейма на примере простых вопросов.
Для извлечения отдельного столбца можно использовать конструкцию вида DataFrame['Name']
. Воспользуемся этим для ответа на вопрос: какова доля нелояльных пользователей в нашем датафрейме?
In [16]:
df['Churn'].mean()
Out[16]:
14,5% — довольно плохой показатель для компании, с таким процентом оттока можно и разориться.
Очень удобной является логическая индексация DataFrame
по одному столбцу. Выглядит она следующим образом: df[P(df['Name'])]
, где P
- это некоторое логическое условие, проверяемое для каждого элемента столбца Name
. Итогом такой индексации является DataFrame
, состоящий только из строк, удовлетворяющих условию P
по столбцу Name
.
Воспользуемся этим для ответа на вопрос: каковы средние значения числовых признаков среди нелояльных пользователей?
In [17]:
df[df['Churn'] == 1].mean()
Out[17]:
Скомбинировав предыдущие два вида индексации, ответим на вопрос: сколько в среднем в течение дня разговаривают по телефону нелояльные пользователи?
In [18]:
df[df['Churn'] == 1]['Total day minutes'].mean()
Out[18]:
Какова максимальная длина международных звонков среди лояльных пользователей (Churn == 0
), не пользующихся услугой международного роуминга ('International plan' == 'No'
)?
In [19]:
df[(df['Churn'] == 0) & (df['International plan'] == 'No')]['Total intl minutes'].max()
Out[19]:
Датафреймы можно индексировать как по названию столбца или строки, так и по порядковому номеру. Для индексации по названию используется метод loc
, по номеру — iloc
.
В первом случае мы говорим «передай нам значения для id строк от 0 до 5 и для столбцов от State до Area code», а во втором — «передай нам значения первых пяти строк в первых трёх столбцах».
В случае iloc
срез работает как обычно, однако в случае loc
учитываются и начало, и конец среза. Да, неудобно, да, вызывает путаницу.
In [20]:
df.loc[0:5, 'State':'Area code']
Out[20]:
In [21]:
df.iloc[0:5, 0:3]
Out[21]:
Метод ix
индексирует и по названию, и по номеру, но он вызывает путаницу, и поэтому был объявлен устаревшим (deprecated).
Если нам нужна первая или последняя строчка датафрейма, пользуемся конструкцией df[:1]
или df[-1:]
:
In [22]:
df[-1:]
Out[22]:
Применение функции к каждому столбцу:
In [23]:
df.apply(np.max)
Out[23]:
Метод apply
можно использовать и для того, чтобы применить функцию к каждой строке. Для этого нужно указать axis=1
.
Применение функции к каждой ячейке столбца
Допустим, по какой-то причине нас интересуют все люди из штатов, названия которых начинаются на 'W'. В данному случае это можно сделать по-разному, но наибольшую свободу дает связка apply
-lambda
– применение функции ко всем знаениям в столбце.
In [24]:
df[df['State'].apply(lambda state: state[0] == 'W')].head()
Out[24]:
Метод map
можно использовать и для замены значений в колонке, передав ему в качестве аргумента словарь вида {old_value: new_value}
:
In [25]:
d = {'No' : False, 'Yes' : True}
df['International plan'] = df['International plan'].map(d)
df.head()
Out[25]:
Аналогичную операцию можно провернуть с помощью метода replace
:
In [26]:
df = df.replace({'Voice mail plan': d})
df.head()
Out[26]:
В общем случае группировка данных в Pandas выглядит следующим образом:
df.groupby(by=grouping_columns)[columns_to_show].function()
groupby
, который разделяет данные по grouping_columns
– признаку или набору признаков.columns_to_show
). Группирование данных в зависимости от значения признака Churn
и вывод статистик по трём столбцам в каждой группе.
In [27]:
columns_to_show = ['Total day minutes', 'Total eve minutes', 'Total night minutes']
df.groupby(['Churn'])[columns_to_show].describe(percentiles=[])
Out[27]:
Сделаем то же самое, но немного по-другому, передав в agg
список функций:
In [28]:
columns_to_show = ['Total day minutes', 'Total eve minutes', 'Total night minutes']
df.groupby(['Churn'])[columns_to_show].agg([np.mean, np.std, np.min, np.max])
Out[28]:
Допустим, мы хотим посмотреть, как наблюдения в нашей выборке распределены в контексте двух признаков — Churn
и Customer service calls
. Для этого мы можем построить таблицу сопряженности, воспользовавшись методом crosstab
:
In [29]:
pd.crosstab(df['Churn'], df['International plan'])
Out[29]:
In [30]:
pd.crosstab(df['Churn'], df['Voice mail plan'], normalize=True)
Out[30]:
Мы видим, что большинство пользователей — лояльные и пользуются дополнительными услугами (международного роуминга / голосовой почты).
Продвинутые пользователи Excel
наверняка вспомнят о такой фиче, как сводные таблицы (pivot tables
). В Pandas
за сводные таблицы отвечает метод pivot_table
, который принимает в качестве параметров:
values
– список переменных, по которым требуется рассчитать нужные статистики,index
– список переменных, по которым нужно сгруппировать данные,aggfunc
— то, что нам, собственно, нужно посчитать по группам — сумму, среднее, максимум, минимум или что-то ещё.Давайте посмотрим среднее число дневных, вечерних и ночных звонков для разных Area code
:
In [31]:
df.pivot_table(['Total day calls',
'Total eve calls',
'Total night calls'], ['Area code'],
aggfunc='mean').head(10)
Out[31]:
Например, мы хотим посчитать общее количество звонков для всех пользователей. Создадим объект total_calls
типа Series
и вставим его в датафрейм:
In [32]:
total_calls = df['Total day calls'] + df['Total eve calls'] + \
df['Total night calls'] + df['Total intl calls']
df.insert(loc=len(df.columns), column='Total calls', value=total_calls)
# loc - номер столбца, после которого нужно вставить данный Series
# мы указали len(df.columns), чтобы вставить его в самом конце
df.head()
Out[32]:
Добавить столбец из имеющихся можно и проще, не создавая промежуточных Series
:
In [33]:
df['Total charge'] = df['Total day charge'] + df['Total eve charge'] + \
df['Total night charge'] + df['Total intl charge']
df.head()
Out[33]:
Чтобы удалить столбцы или строки, воспользуйтесь методом drop
, передавая в качестве аргумента нужные индексы и требуемое значение параметра axis
(1
, если удаляете столбцы, и ничего или 0
, если удаляете строки):
In [34]:
# избавляемся от созданных только что столбцов
df = df.drop(['Total charge', 'Total calls'], axis=1)
df.drop([1, 2]).head() # а вот так можно удалить строчки
Out[34]:
Посмотрим, как отток связан с признаком "Подключение международного роуминга" (International plan
). Сделаем это с помощью сводной таблички crosstab
, а также путем иллюстрации с Seaborn
(как именно строить такие картинки и анализировать с их помощью графики – материал следующей статьи.)
In [35]:
# надо дополнительно установить (команда в терминале)
# чтоб картинки рисовались в тетрадке
# !conda install seaborn
%matplotlib inline
import matplotlib.pyplot as plt
import seaborn as sns
plt.rcParams['figure.figsize'] = (8, 6)
In [36]:
pd.crosstab(df['Churn'], df['International plan'], margins=True)
Out[36]:
In [37]:
sns.countplot(x='International plan', hue='Churn', data=df);
plt.savefig('int_plan_and_churn.png', dpi=300);
Видим, что когда роуминг подключен, доля оттока намного выше – интересное наблюдение! Возможно, большие и плохо контролируемые траты в роуминге очень конфликтогенны и приводят к недовольству клиентов телеком-оператора и, соответственно, к их оттоку.
Далее посмотрим на еще один важный признак – "Число обращений в сервисный центр" (Customer service calls
). Также построим сводную таблицу и картинку.
In [38]:
pd.crosstab(df['Churn'], df['Customer service calls'], margins=True)
Out[38]:
In [39]:
sns.countplot(x='Customer service calls', hue='Churn', data=df);
plt.savefig('serv_calls__and_churn.png', dpi=300);
Может быть, по сводной табличке это не так хорошо видно (или скучно ползать взглядом по строчкам с цифрами), а вот картинка красноречиво свидетельствует о том, что доля оттока сильно возрастает начиная с 4 звонков в сервисный центр.
Добавим теперь в наш DataFrame бинарный признак — результат сравнения Customer service calls > 3
. И еще раз посмотрим, как он связан с оттоком.
In [40]:
df['Many_service_calls'] = (df['Customer service calls'] > 3).astype('int')
pd.crosstab(df['Many_service_calls'], df['Churn'], margins=True)
Out[40]:
In [41]:
sns.countplot(x='Many_service_calls', hue='Churn', data=df);
plt.savefig('many_serv_calls__and_churn.png', dpi=300);
Объединим рассмотренные выше условия и построим сводную табличку для этого объединения и оттока.
In [42]:
pd.crosstab(df['Many_service_calls'] & df['International plan'] ,
df['Churn'])
Out[42]:
Значит, прогнозируя лояльность клиента в случае, когда число звонков в сервисный центр меньше 4 и не подключен роуминг (и прогнозируя отток – в противном случае), можно ожидать процент "угадывания лояльности клиента" около 85.8% (ошибаемся всего 464 + 9 раз). Эти 85.8%, которые мы получили с помощью очень простых рассуждений – это неплохая отправная точка (baseline) для дальнейших моделей машинного обучения, которые мы будем строить.
В целом до появления машинного обучения процесс анализа данных выглядел примерно так. Прорезюмируем: