Использование анализа выживаемости для прогнозирования и предотвращения оттока пользователей в Python с помощью пакета lifeelines и модели пропорциональных рисков Кокса.

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

Будут ли они, не так ли

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

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

Тем не менее, давайте начнем с модели классификации и посмотрим, чем мы закончим.

Наш набор данных

Набор данных, который мы будем использовать, - это набор данных Kaggle Telco Churn (доступен здесь), он содержит чуть более 7000 записей о клиентах и ​​включает такие функции, как ежемесячные расходы клиента в компании, продолжительность (в месяцах), которую они были клиентами, независимо от того, есть ли у них различные надстройки интернет-услуг.

Вот первые 5 строк (см. Исходную статью для таблиц, которые не просто изображения):

Первое, что вы заметите, это то, что есть много категориальных переменных, отображаемых в виде текстовых значений («Да», «Нет» и т. Д.) В столбцах, давайте воспользуемся pd.get_dummies, чтобы исправить это:

dummies = pd.get_dummies(
    data[[ 'gender', 'SeniorCitizen', 'Partner', 'Dependents', 'tenure', 'PhoneService', 'MultipleLines', 'InternetService', 'OnlineSecurity', 'OnlineBackup', 'DeviceProtection', 'TechSupport', 'StreamingTV', 'StreamingMovies', 'Contract', 'PaperlessBilling', 'PaymentMethod', 'Churn' ]]
) 
data = dummies.join(data[['MonthlyCharges', 'TotalCharges']])

Если вы будете следовать инструкциям, вам также придется исправить некоторые отсутствующие значения в столбце TotalCharges, в результате чего текст столбца стал целиком, а не числовым:

data['TotalCharges'] = data[['TotalCharges']].replace([' '], '0') data['TotalCharges'] = pd.to_numeric(data['TotalCharges'])

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

from matplotlib import pyplot as plt 
plt.scatter( data['tenure'], data['MonthlyCharges'], c=data['Churn_Yes']) 
plt.xlabel('Customer Tenure (Months)') 
plt.ylabel('Monthly Charges')

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

Логистическая регрессия

from sklearn.linear_model import LogisticRegression 
from sklearn.model_selection import train_test_split 
from sklearn.metrics import confusion_matrix, accuracy_score 
X_train, X_test, y_train, y_test = train_test_split(data[x_select], data['Churn_Yes']) 
clf = LogisticRegression(solver='lbfgs', max_iter=1000) clf.fit(X_train, y_train)

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

Вот матрица путаницы для модели логистической регрессии:

Здесь вы можете увидеть, что, хотя общая точность довольно высока, модель определяет только клиентов, которые уходят в 55% случаев, что гораздо ближе к случайному, чем нам хотелось бы.

Какая наша цель?

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

Было бы здорово узнать каждого клиента, который откажется от обслуживания, но насколько полезна эта информация? И как вы узнаете, на чем сосредоточиться, если вы хотите их сохранить, и сколько вы могли бы потратить на их сохранение, прежде чем они, как покупатель, обернутся убытками?

Давайте попробуем взглянуть с другой точки зрения.

Анализ выживаемости

Логистическая регрессия (под капотом) присваивает вероятность каждому наблюдению, которое описывает, насколько вероятно, что оно принадлежит к положительному классу.

В случае оттока по сравнению с отсутствием оттока и любой классификации это может показаться незначительным раздражением, которое мы должны преодолеть, выбрав порог и округляя результат (‹0,5 означает 0,› = 0,5 означает 1). Однако, если задуматься, эта вероятность - именно то, что нам нужно.

В любой достаточно большой группе клиентов будут люди с одинаковыми атрибутами / характеристиками (действует принцип «голубятни»). Некоторые из них будут оттока, а некоторые - нет, и в идеале вам нужно знать вероятность оттока для каждой группы. Это то, что дает вам логистическая регрессия (до того, как вы отсечете и округлите), но использование логистической регрессии в этом сценарии действительно имеет одну проблему - неясно, для какой временной шкалы она прогнозирует. Войдите в Анализ выживаемости.

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

Модель пропорциональных опасностей Кокса

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

Благодаря очень хорошему и хорошо документированному пакету lifelines на Python, легко начать использовать модель Cox PH.

Применение модели CoxPH

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

  1. `` Возраст '' наблюдения (разница во времени между пациентом, начинающим курс лечения, и самым последним наблюдением за его статусом, или, в нашем случае, время между клиентом, присоединившимся к услуге, и самым последним наблюдением того, действительно ли они сбились)
  2. Флаг «событие» (двоичный флаг, указывающий, произошло ли событие, например, смерть или отток)

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

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

Вот первые 5 строк нашего фиктивного и сокращенного набора данных:

Вы можете видеть, что Gender_Male исчез, как и Partner_No, Dependents_No и так далее.

Теперь, когда у нас есть набор данных в правильном формате, давайте подойдем к модели Кокса:

from lifelines import CoxPHFitter 
cph_train, cph_test = train_test_split(data[x_select], test_size=0.2) 
cph.fit(cph_train, 'tenure', 'Churn_Yes')

В пакете lifelines есть несколько уникальных особенностей, которые могут сбить с толку постоянных пользователей Scikit-Learn. Во-первых, столбец, содержащий наблюдения за событиями (отток), должен находиться в единственном наборе данных, который передается в вызов соответствия. Таким образом, мы не можем разделить наш набор данных на четыре части (обучить и проверить на X и y), как в случае с логистической регрессией, нам придется разделить его на две части. Для всех, кому интересно, это очень похоже на синтаксис R, где вы указываете соответствующие столбцы в одном наборе данных, а алгоритм позаботится об их удалении из обучающих данных.

В вызове cph.fit вы должны передать три разных аргумента. Первый - это набор данных, который мы создали с помощью train_test_split, второй - столбец «возраст» (в нашем случае срок полномочий), а третий - столбец «событие» (в нашем случае Churn_Yes).

Следующее уникальное свойство пакета lifelines - это метод .print_summary, который можно использовать в моделях (еще одна вещь, заимствованная из R).

Вот краткое изложение нашей модели:

Следует отметить несколько важных моментов в этом выводе.

  1. Мы можем видеть количество наблюдений, перечисленных как n = 5634, прямо в верхней части вывода, рядом с этим у нас есть наше количество событий (отток клиентов).
  2. Получаем коэффициенты нашей модели. Это очень важно, и они рассказывают нам, как каждая функция увеличивает риск, поэтому, если это положительное число, этот атрибут увеличивает вероятность ухода клиента, а если отрицательный, то клиенты с этой функцией менее вероятны. сбивать.
  3. Мы получаем коды значимости для наших функций. Очень красивое дополнение!
  4. Получаем согласование.

Соответствие

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

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

Наша модель имеет соответствие 0,929 из 1, так что это очень хорошая модель Кокса.

Построение модели Кокса

Вызов базовой функции .plot в модели дает нам следующее:

Удобная визуализация значимости и рисков, связанных с различными функциями.

Что-то еще, что мы можем сделать на этом этапе, - это исследовать, как функции влияют на выживаемость с течением времени, например:

cph.plot_covariate_groups('TotalCharges', groups=[0,4000])

.plot_covariate_groups - это метод из пакета lifelines, который принимает имя функции в качестве первого входа и диапазон групп в качестве второго. Итак, здесь мы рассмотрим различные кривые выживания для клиентов, у которых TotalCharges близок к нулю, по сравнению с теми, у которых TotalCharges ближе к 4000. Это выглядит следующим образом:

Вы можете видеть, что клиенты, у которых TotalSpend ближе к нулю, подвергаются гораздо более высокому риску оттока (их кривая выживаемости падает), чем те, у которых TotalSpend ближе к 4000.

Прогнозирование оттока

У нас есть хорошая работающая модель, что теперь?

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

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

censored_subjects = data.loc[data['Churn_Yes'] == 0]

На жаргоне «Анализ выживаемости» цензурированное наблюдение - это наблюдение, для которого еще не произошло «событие», поэтому мы выбираем всех тех клиентов, которые еще не отказались от услуг.

Теперь, чтобы предсказать их кривые выживаемости, мы используем удобный метод .predict_survival_function примерно так:

unconditioned_sf = cph.predict_survival_function(censored_subjects)

Вы можете видеть, что мы назвали эти функции «безусловного» выживания, потому что мы знаем, что некоторые из этих кривых предсказывают отток клиентов до текущего срока владения клиентом. Мы должны обусловливать прогнозы на том основании, что мы знаем, что клиенты все еще были с нами, когда были собраны данные:

conditioned_sf = unconditioned_sf.apply(lambda c: (c / c.loc[data.loc[c.name, 'tenure']]).clip_upper(1))

Теперь мы можем исследовать отдельных клиентов и увидеть, как кондиционирование повлияло на их выживаемость по сравнению с исходным уровнем:

subject = 12 
unconditioned_sf[subject].plot(ls="--", color="#A60628", label="unconditioned") 
conditioned_sf[subject].plot(color="#A60628", label="conditioned on $T>58$") plt.legend()

Как видите, тот факт, что мы знаем, что клиент 12 все еще остается клиентом после 58 месяцев, означает, что его кривая выживаемости падает медленнее, чем базовая кривая для клиентов, похожих на него, без этого условия.

Этот очень удобный predict_survival_function метод создал матрицу, содержащую вероятность выживания для каждого оставшегося клиента в каждый момент времени. Что нам нужно сделать дальше, так это использовать это, чтобы выбрать одно число в качестве прогноза того, как долго продержится клиент, которое мы можем использовать для определения ценности вмешательства.

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

from lifelines.utils import median_survival_times, qth_survival_times 
predictions_50 = median_survival_times(conditioned_sf) 
# This is the same, but you can change the fraction to get other 
# %tiles.  
# predictions_50 = qth_survival_times(.50, conditioned_sf)

Это дает нам единственную строку (в фрейме данных pandas), в которой указан номер месяца (срок пребывания), в котором у клиента есть 50% -ная вероятность отказа.

Мы можем использовать эту единственную строку и, присоединив ее к нашему DataFrame, может исследовать прогнозируемую оставшуюся ценность, которую клиент имеет для бизнеса:

values = predictions_50.T.join(data[['MonthlyCharges','tenure']]) values['RemainingValue'] = values['MonthlyCharges'] * (values[0.5] - values['tenure'])

Вот первые 5 строк этого нового DataFrame:

Столбец с именем 0.5 - это единственная строка, которую мы получили в результате нашего median_survival_times вызова. Если вы выбрали другой процентиль, этот столбец будет называться иначе.

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

Предотвращение оттока

Итак, теперь мы знаем, какие клиенты подвергаются наибольшему риску оттока клиентов, но что мы можем сделать, чтобы их удержать?

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

  1. Имея 2-летний контракт
  2. Имея контракт на 1 год
  3. Оплата кредитной картой
  4. Оплата банковским переводом

За пределами этих четырех увеличение выживаемости становится минимальным, и результаты в любом случае несущественны. Итак, давайте сосредоточимся на этих четырех.

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

upgrades = ['PaymentMethod_Credit card (automatic)', 'PaymentMethod_Bank transfer (automatic)', 'Contract_One year', 'Contract_Two year'] 
results_dict = {} 
for customer in values.index: 
    actual = data.loc[[customer]] change = data.loc[[customer]]     
    results_dict[customer] = [cph.predict_median(actual)] 
    for upgrade in upgrades: 
        change[upgrade] = 1 if list(change[upgrade]) == [0] else 0    
        results_dict[customer].append(cph.predict_median(change))      
        change[upgrade] = 1 if list(change[upgrade]) == [0] else 0  
results_df = pd.DataFrame(results_dict).T 
results_df.columns = ['baseline'] + upgrades actions = values.join(results_df).drop([0.5], axis=1)

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

Из этого видно, что если бы нам удалось заставить первого клиента использовать кредитную карту для совершения платежей, мы могли бы увеличить время выживания на 4 месяца (25–21 базовый уровень) и так далее.

Это отличный результат, который действительно помогает нам понять, как мы можем продвинуть иглу в удержании клиентов, но давайте сделаем еще один шаг и посмотрим, какое влияние это окажет в финансовом отношении:

actions['CreditCard Diff'] = ( 
    actions['PaymentMethod_Credit card (automatic)'] -     
    actions['baseline']
) * actions['MonthlyCharges'] 
actions['BankTransfer Diff'] = ( 
    actions['PaymentMethod_Bank transfer (automatic)'] - 
    actions['baseline']
) * actions['MonthlyCharges'] 
actions['1yrContract Diff'] = ( 
    actions['Contract_One year'] - actions['baseline']
) * actions['MonthlyCharges'] 
actions['2yrContract Diff'] = ( 
    actions['Contract_Two year'] - actions['baseline']
) * actions['MonthlyCharges']

Теперь мы видим, что перевод клиента из первой строки на оплату кредитной картой может стоить до 119,40 фунтов стерлингов. Это гораздо полезнее, чем простой счет месяцев.

Точность и калибровка

Ладно, мы почти закончили. У нас есть денежные значения, которые мы можем использовать, чтобы судить о целесообразности того или иного вмешательства в отток, а также надежные прогнозы относительно когда отток клиентов. Но насколько все это точно?

Мы знаем, что наша модель Кокса хороша (соответствие 92,9%), но что это означает в реальном выражении? Насколько это точно?

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

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

В Scikit-Learn мы можем использовать метод calibration_curve для получения значений из вероятностных прогнозов и истинных значений (двоичных) нашего набора данных:

from sklearn.calibration import calibration_curve 
plt.figure(figsize=(10, 10))
 
ax1 = plt.subplot2grid((3, 1), (0, 0), rowspan=2) 
ax1.plot([0, 1], [0, 1], "k:", label="Perfectly calibrated") 
probs = 1-np.array(cph.predict_survival_function(cph_test).loc[13])
actual = cph_test['Churn_Yes'] 
fraction_of_positives, mean_predicted_value = \ calibration_curve(actual, probs, n_bins=10, normalize=False) 
ax1.plot(mean_predicted_value, fraction_of_positives, "s-", label="%s" % ("CoxPH",)) 
ax1.set_ylabel("Fraction of positives") 
ax1.set_ylim([-0.05, 1.05]) ax1.legend(loc="lower right") ax1.set_title('Calibration plots (reliability curve)')

Что дает нам это:

Вы читаете эту диаграмму, исследуя различные вероятности относительно дробей, присутствующих в наборе данных (в нашем случае - наборе тестов).

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

Чтобы получить численное представление о том, насколько далеко линия от идеальной калибровки, мы можем использовать brier_score_loss из пакета Scikit-Learn:

brier_score_loss( 
    cph_test['Churn_Yes'], 1 - 
    np.array(cph.predict_survival_function(cph_test).loc[13]), pos_label=1 
)

Те из вас, у кого есть зоркий глаз, возможно, заметили, что я продолжаю индексировать при tenure = 13. Поскольку наша модель работает в диапазоне периодов времени, мы должны проверять калибровку на каждом этапе, чтобы почувствовать точность. Давайте сделаем это за один раз:

loss_dict = {} 
for i in range(1,73): 
    score = brier_score_loss( 
        cph_test['Churn_Yes'], 1 -    
        np.array(cph.predict_survival_function(cph_test).loc[i]),   
        pos_label=1 ) 
    loss_dict[i] = [score] 
loss_df = pd.DataFrame(loss_dict).T 
fig, ax = plt.subplots() 
ax.plot(loss_df.index, loss_df) 
ax.set(xlabel='Prediction Time', ylabel='Calibration Loss', title='Cox PH Model Calibration Loss / Time') 
ax.grid() 
plt.show()

Что дает нам это:

Таким образом, мы видим, что наша модель довольно хорошо откалибрована между 5 и 25 месяцами, а затем становится все меньше и меньше, поэтому мы начинаем прогнозировать дальше.

Единственное, что осталось сделать, чтобы сделать наш анализ более реалистичным, - это учесть эту плохую калибровку.

Давайте создадим верхнюю и нижнюю границы ожидаемой рентабельности инвестиций от привлечения клиентов к внесению изменений:

loss_df.columns = ['loss'] 
temp_df = actions.reset_index().set_index('PaymentMethod_Credit card (automatic)').join(loss_df) 
temp_df = temp_df.set_index('index') 
actions['CreditCard Lower'] = temp_df['CreditCard Diff'] - (temp_df['loss'] * temp_df['CreditCard Diff']) 
actions['CreditCard Upper'] = temp_df['CreditCard Diff'] + (temp_df['loss'] * temp_df['CreditCard Diff']) 
temp_df = actions.reset_index().set_index('PaymentMethod_Bank transfer (automatic)').join(loss_df) 
temp_df = temp_df.set_index('index') 
actions['BankTransfer Lower'] = temp_df['BankTransfer Diff'] - (.5 * temp_df['loss'] * temp_df['BankTransfer Diff']) actions['BankTransfer Upper'] = temp_df['BankTransfer Diff'] + (.5 * temp_df['loss'] * temp_df['BankTransfer Diff']) 
temp_df = actions.reset_index().set_index('Contract_One year').join(loss_df) 
temp_df = temp_df.set_index('index') 
actions['1yrContract Lower'] = temp_df['1yrContract Diff'] - (.5 * temp_df['loss'] * temp_df['1yrContract Diff']) actions['1yrContract Upper'] = temp_df['1yrContract Diff'] + (.5 * temp_df['loss'] * temp_df['1yrContract Diff']) 
temp_df = actions.reset_index().set_index('Contract_Two year').join(loss_df) 
temp_df = temp_df.set_index('index') 
actions['2yrContract Lower'] = temp_df['2yrContract Diff'] - (.5 * temp_df['loss'] * temp_df['2yrContract Diff']) actions['2yrContract Upper'] = temp_df['2yrContract Diff'] + (.5 * temp_df['loss'] * temp_df['2yrContract Diff'])

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

Это дает нам что-то вроде этого:

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

Спасибо за чтение!