Автор материала: программист-исследователь Mail.ru Group, старший преподаватель Факультета Компьютерных Наук ВШЭ Юрий Кашницкий. Материал распространяется на условиях лицензии Creative Commons CC BY-NC-SA 4.0. Можно использовать в любых целях (редактировать, поправлять и брать за основу), кроме коммерческих, но с обязательным упоминанием автора материала.
Считываем в DataFrame знакомые нам по первой статье данные по оттоку клиентов телеком-оператора.
In [1]:
from __future__ import division, print_function
# отключим всякие предупреждения Anaconda
import warnings
warnings.filterwarnings('ignore')
import numpy as np
import pandas as pd
pd.set_option('display.max.columns', 100)
import pylab as plt
%matplotlib inline
import seaborn as sns
from matplotlib import pyplot as plt
plt.rcParams['figure.figsize'] = (10, 8)
In [2]:
df = pd.read_csv('../data/telecom_churn.csv')
Проверим, все ли нормально считалось – посмотрим на первые 5 строк (метод head
).
In [3]:
df.head()
Out[3]:
Число строк (клиентов) и столбцов (признаков):
In [4]:
df.shape
Out[4]:
Посмотрим на признаки и убедимся, что пропусков ни в одном из них нет – везде по 3333 записи.
In [5]:
df.info()
Описание признаков
Название | Описание | Тип |
---|---|---|
State | Буквенный код штата | номинальный |
Account length | Как долго клиент обслуживается компанией | количественный |
Area code | Префикс номера телефона | количественный |
International plan | Международный роуминг (подключен/не подключен) | бинарный |
Voice mail plan | Голосовая почта (подключена/не подключена) | бинарный |
Number vmail messages | Количество голосовых сообщений | количественный |
Total day minutes | Общая длительность разговоров днем | количественный |
Total day calls | Общее количество звонков днем | количественный |
Total day charge | Общая сумма оплаты за услуги днем | количественный |
Total eve minutes | Общая длительность разговоров вечером | количественный |
Total eve calls | Общее количество звонков вечером | количественный |
Total eve charge | Общая сумма оплаты за услуги вечером | количественный |
Total night minutes | Общая длительность разговоров ночью | количественный |
Total night calls | Общее количество звонков ночью | количественный |
Total night charge | Общая сумма оплаты за услуги ночью | количественный |
Total intl minutes | Общая длительность международных разговоров | количественный |
Total intl calls | Общее количество международных разговоров | количественный |
Total intl charge | Общая сумма оплаты за международные разговоры | количественный |
Customer service calls | Число обращений в сервисный центр | количественный |
Целевая переменная: Churn – Признак оттока, бинарный (1 – потеря клиента, то есть отток). Потом мы будем строить модели, прогнозирующие этот признак по остальным, поэтому мы и назвали его целевым.
Посмотрим на распределение целевого класса – оттока клиентов.
In [6]:
df['Churn'].value_counts()
Out[6]:
In [7]:
df['Churn'].value_counts().plot(kind='bar', label='Churn')
plt.legend()
plt.title('Распределение оттока клиентов');
Выделим следующие группы признаков (среди всех кроме Churn ):
Посмотрим на корреляции количественных признаков. По раскрашенной матрице корреляций видно, что такие признаки как Total day charge считаются по проговоренным минутам (Total day minutes). То есть 4 признака можно выкинуть, они не несут полезной информации.
In [8]:
corr_matrix = df.drop(['State', 'International plan', 'Voice mail plan',
'Area code'], axis=1).corr()
In [9]:
sns.heatmap(corr_matrix);
Теперь посмотрим на распределения всех интересующих нас количественных признаков. На бинарные/категориальные/порядковые признакие будем смотреть отдельно.
In [10]:
features = list(set(df.columns) - set(['State', 'International plan', 'Voice mail plan', 'Area code',
'Total day charge', 'Total eve charge', 'Total night charge',
'Total intl charge', 'Churn']))
df[features].hist(figsize=(20,12));
Видим, что большинство признаков распределены нормально. Исключения – число звонков в сервисный центр (Customer service calls) (тут больше подходит пуассоновское распределение) и число голосовых сообщений (Number vmail messages, пик в нуле, т.е. это те, у кого голосовая почта не подключена). Также смещено распределение числа международных звонков (Total intl calls).
Еще полезно строить вот такие картинки, где на главной диагонали рисуются распределения признаков, а вне главной диагонали – диаграммы рассеяния для пар признаков. Бывает, что это приводит к каким-то выводам, но в данном случае все примерно понятно, без сюрпризов.
In [11]:
sns.pairplot(df[features + ['Churn']], hue='Churn');
Дальше посмотрим, как признаки связаны с целевым – с оттоком.
Построим boxplot-ы, описывающее статистики распределения количественных признаков в двух группах: среди лояльных и ушедших клиентов.
In [12]:
fig, axes = plt.subplots(nrows=3, ncols=4, figsize=(16, 10))
for idx, feat in enumerate(features):
sns.boxplot(x='Churn', y=feat, data=df, ax=axes[int(idx / 4), idx % 4])
axes[int(idx / 4), idx % 4].legend()
axes[int(idx / 4), idx % 4].set_xlabel('Churn')
axes[int(idx / 4), idx % 4].set_ylabel(feat);
На глаз наибольшее отличие мы видим для признаков Total day minutes, Customer service calls и Number vmail messages. Впоследствии мы научимся определять важность признаков в задаче классификации с помощью случайного леса (или градиентного бустинга), и окажется, что первые два – действительно очень важные признаки для прогнозирования оттока.
Посмотрим отдельно на картинки с распределением кол-ва проговоренных днем минут среди лояльных/ушедших. Слева - знакомые нам боксплоты, справа – сглаженные гистограммы распределения числового признака в двух группах (скорее просто красивая картинка, все и так понятно по боксплоту).
Интересное наблюдение: в среднем ушедшие клиенты больше пользуются связью. Возможно, они недовольны тарифами, и одной из мер борьбы с оттоком будет понижение тарифных ставок (стоимости мобильной связи). Но это уже компании надо будет проводить дополнительный экономический анализ, действительно ли такие меры будут оправданы.
In [13]:
_, axes = plt.subplots(1, 2, sharey=True, figsize=(16, 6))
sns.boxplot(x='Churn', y='Total day minutes', data=df, ax=axes[0]);
sns.violinplot(x='Churn', y='Total day minutes', data=df, ax=axes[1]);
Теперь изобразим распределение числа обращений в сервисный центр (такую картинку мы строили в первой статье). Тут уникальных значений признака не много (признак можно считать как количественным целочисленным, так и порядковым), и наглядней изобразить распределение с помощью countplot
. Наблюдение: доля оттока сильно возрастает начиная с 4 звонков в сервисный центр.
In [14]:
sns.countplot(x='Customer service calls', hue='Churn', data=df);
Теперь посмотрим на связь бинарных признаков International plan и Voice mail plan с оттоком. Наблюдение: когда роуминг подключен, доля оттока намного выше, т.е. наличие междунароного роуминга – сильный признак. Про голосовую почту такого нельзя сказать.
In [15]:
_, axes = plt.subplots(1, 2, sharey=True, figsize=(16,6))
sns.countplot(x='International plan', hue='Churn', data=df, ax=axes[0]);
sns.countplot(x='Voice mail plan', hue='Churn', data=df, ax=axes[1]);
Наконец, посмотрим, как с оттоком связан категориальный признак State. С ним уже не так приятно работать, поскольку число уникальных штатов довольно велико – 51. Можно в начале построить сводную табличку или посчитать процент оттока для каждого штата. Но мы видим, что данных по каждом штату по отдельности маловато (ушедших – всего от 3 до 17), поэтому, возможно, признак State впоследствии не стоит добавлять в модели классификации из-за риска переобучения (но мы это будем проверять на кросс-валидации, stay tuned!).
In [16]:
pd.crosstab(df['State'], df['Churn']).T
Out[16]:
Доли оттока для каждого штата:
In [17]:
df.groupby(['State'])['Churn'].agg([np.mean]).sort_values(by='mean', ascending=False).T
Out[17]:
Видно, что в Нью-Джерси и Калифорнии доля оттока выше 25%, а на Гавайях и в Аляске меньше 5%. Но эти выводы построены на слишком скромной статистике и возможно, это просто особенности имеющихся данных (тут можно и гипотезы попроверять про корреляции Мэтьюса и Крамера, но это уже за рамками данной статьи).
Наконец построим t-SNE представление данных. Название метода сложное – t-distributed Stohastic Neighbor Embedding, математика тоже крутая (и вникать в нее не будем), но основная идея проста, как дверь: найдем такое отображение из многомерного признакового пространства на плоскость (или в 3D, но почти всегда выбирают 2D), чтоб точки, которые были далеко друг от друга, на плоскости тоже оказались удаленными, а близкие точки – также отобразились на близкие. То есть neighbor embedding – это своего рода поиск нового представления данных, при котором сохраняется соседство.
Немного деталей: выкинем штаты и признак оттока, бинарные Yes/No-признаки переведем в числа (при помощи pandas.Series.map
). Также нужно масштабировать выборку – из каждого признака вычесть его среднее и поделить на стандартное отклонение, это делае StandardScaler
.
In [18]:
from sklearn.manifold import TSNE
from sklearn.preprocessing import StandardScaler
In [19]:
# преобразуем все признаки в числовые, выкинув штаты
X = df.drop(['Churn', 'State'], axis=1)
X['International plan'] = X['International plan'].map({'Yes': 1, 'No': 0})
X['Voice mail plan'] = X['Voice mail plan'].map({'Yes': 1, 'No': 0})
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
In [20]:
%%time
tsne = TSNE(random_state=17)
tsne_representation = tsne.fit_transform(X_scaled)
In [21]:
plt.scatter(tsne_representation[:, 0], tsne_representation[:, 1]);
Раскрасим полученное t-SNE представление данных по оттоку (синие – лояльные, оранжевые – ушедшие клиенты).
In [22]:
plt.scatter(tsne_representation[:, 0], tsne_representation[:, 1],
c=df['Churn'].map({0: 'blue', 1: 'orange'}));
Видим, что ушедшие клиенты преимущественно "кучкуются" в некоторых областях признакового пространства.
Чтоб лучше понять картинку, можно также раскрасить ее по остальным бинарным признакам – по роумингу и голосовой почте. Синие участки соответствуют объектам, обладающим этим бинарным признаком.
In [23]:
_, axes = plt.subplots(1, 2, sharey=True, figsize=(16,6))
axes[0].scatter(tsne_representation[:, 0], tsne_representation[:, 1],
c=df['International plan'].map({'Yes': 'blue', 'No': 'orange'}));
axes[1].scatter(tsne_representation[:, 0], tsne_representation[:, 1],
c=df['Voice mail plan'].map({'Yes': 'blue', 'No': 'orange'}));
axes[0].set_title('International plan');
axes[1].set_title('Voice mail plan');
Теперь понятно, что, например, много ушедших клиентов кучкуется в левом кластере людей с поключенным роумингом, но без голосовой почты.
Напоследок отметим минусы t-SNE (да, по нему тоже лучше писать отдельную статью):
random seed
, это усложняет интерпретацию. Вот хороший тьюториал по t-SNE. Но в целом по таким картинкам не стоит делать далеко идущих выводов – не стоит гадать по кофейной гуще. Иногда что-то бросается в глаза и подтверждается при изучении, но это не часто происходит.Вот еще пара картинок. С помощью t-SNE можно действительно получить хорошее представление о данных (как в случае с рукописными цифрами, вот хорошая статья), так и просто нарисовать елочную игрушку.