Введение проблемы

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

Исследовательский анализ данных

Обзор набора данных

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

 | — userId: ID of user
 | — gender: gender of user
 | — level: level of user (free vs. paid)
 | — location: location of user (e.g. Bakersfield, CA)
 | — registration: registration time of user
| — page: type of page visit event (e.g. add a friend, listen to a song)
 | — ts: timestamp of event
 | — song: name of song
 | — artist: artist of song
 | — length: length of song
 | — userAgent: device used (e.g. Mozilla/5.0 Macintosh…) | — sessionId: ID of current session
 | — itemInSession: order of event in current session
 | — …

Определить отток

Отток был определен как пользователи, у которых есть страница = «Подтверждение отмены».

flag_churn_event = udf(lambda x: 1 if x == “Cancellation Confirmation” else 0, IntegerType())
df.withColumn(“churned”, flag_churn_event(“page”))\
    .groupBy(‘userId’).agg(max(‘churned’))\
    .withColumnRenamed(‘max(churned)’, ‘churn’)

В этом подмножестве 52 (23%) ушедших пользователя и 173 (77%) не ушедших.

Сравните поведение ушедших и не ушедших пользователей.

Час дня.Количество пользователей относительно стабильно в разные часы дня.

День недели. В выходные дни пользователей немного меньше, чем в будние дни.

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

События страницы: среди посещений страницы преобладает прослушивание песен ("NextSong", обратите внимание на логарифмическую шкалу по оси Y). В целом, у оттока пользователей меньше вовлеченности, чем у тех, у кого нет оттока. Пользователи с оттоком, как правило, добавляли меньше друзей, добавляли меньше плейлистов, запрашивали меньше помощи, слушали меньше песен и даже сталкивались с меньшим количеством ошибок. Хотя уходящие пользователи составляют небольшую долю от общего числа пользователей, они вносят почти сопоставимый вклад в количество посещений страниц по сравнению с не уходящими пользователями, что говорит о том, что компании НЕ следует отказываться от этой когорты.

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

Используемое устройство. Неудивительно, что Windows и Mac являются наиболее часто используемыми устройствами. Пользователи Windows и Mac имеют примерно одинаковую долю оттока. Для сравнения, почти половина пользователей X11 Linux уходят. Если предположить, что наблюдаемые закономерности репрезентативны для всего населения, такой высокий уровень оттока может свидетельствовать о возможных проблемах в пользовательском интерфейсе приложения в X11 Linux.

Моделирование

Разработка функций

Создавайте функции для каждого пользователя

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

  • Последний уровень пользователя
func_levels = udf(lambda x: 1 if x=="paid" else 0, IntegerType())
levels = df.select(['userId', 'level', 'ts'])\
    .orderBy(desc('ts'))\
    .dropDuplicates(['userId'])\
    .select(['userId', 'level'])\
    .withColumn('level', func_levels('level').cast(IntegerType()))
  • Время с момента регистрации (вменение отсутствующих значений среднему)
time_regi = df.groupBy('userId')\
    .agg(max('ts'), avg('registration'))\
    .withColumn('time_since_regi', (col('max(ts)')-col('avg(registration)'))/lit(1000))

avg_time = time_regi.select(avg('time_since_regi'))\
    .collect()[0]['avg(time_since_regi)']
time_regi = time_regi.fillna(avg_time, subset=['time_since_regi'])\
    .drop(['max(ts)', 'avg(registration)'])
  • Пол пользователя
func_gender = udf(lambda x: 1 if x=="M" else (0 if x=="F" else -1), IntegerType())
gender = df.select(['userId', 'gender'])\
    .dropDuplicates()\
    .withColumn('gender', func_gender('gender'))
  • Количество времени, количество исполнителей, количество песен и количество сессий, которые провел пользователь.
engagement = df.groupBy('userId')\
    .agg(
         countDistinct('artist').alias('num_artists_dist'), 
         countDistinct('sessionId').alias('num_sessions'),
         countDistinct('song').alias('num_songs_dist'),
         count('song').alias('num_songs'),
         count('page').alias('num_events'),
         Fsum('length').alias('tot_length')
    )
  • Среднее значение и стандартное отклонение количества прослушанных песен на исполнителя
per_artist = df.filter(~df['artist'].isNull())\
    .groupBy(['userId', 'artist'])\
    .agg(count('song').alias('num_songs'))\
    .groupBy('userId')\
    .agg(avg(col('num_songs')).alias('avg_songs_per_artist'),
         stddev(col('num_songs')).alias('std_songs_per_artist')
    )\
    .fillna(0)
  • Среднее значение и стандартное отклонение количества песен, прослушанных за сеанс, и времени, затраченного на сеанс.
per_session = df.groupBy(['userId', 'sessionId'])\
    .agg(
         max('ts'), 
         min('ts'), 
         count('song').alias('num_songs')
    )\
    .withColumn('time', (col('max(ts)')-col('min(ts)'))/lit(1000))\
    .groupBy('userId')\
    .agg(
         stddev(col('time')).alias('std_time_per_session'), 
         avg(col('time')).alias('avg_time_per_session'),
         stddev(col('num_songs')).alias('std_songs_per_session'),
         avg(col('num_songs')).alias('avg_songs_per_session')
    )\
    .fillna(0)
  • Используемое устройство

Необработанные значения устройства пользователя были в формате: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/36.0.1985.143 Safari/537.36. Я преобразовал громоздкие тексты только в имя устройства, например. Macintosh.

window = Window.partitionBy("userId")\
    .rowsBetween(
        Window.unboundedPreceding,
        Window.unboundedFollowing
    )
func_agent_device = udf(
    lambda x: "user_agent_"+x.split('(')[1].replace(";", " ").split(" ")[0] if '(' in str(x) else 'user_agent_none', 
    StringType()
)

agents = df.withColumn(
    "userAgent", func_agent_device(col("userAgent"))
    )\
    .groupBy(["userId", 'userAgent'])\
    .agg(count("userAgent").alias("user_agent_usage_count"))\
    .withColumn(
        'total', Fsum(col('user_agent_usage_count')).over(window)
    )\
    .withColumn(
        'user_agent_usage', 
        col('user_agent_usage_count')/col('total')
    )\
    .groupBy("userId").pivot("userAgent").sum("user_agent_usage")\
    .drop('user_agent_none').fillna(0)
  • Количество событий каждого типа
pages_to_exclude = ['Cancel', 'Downgrade', 'Cancellation Confirmation', 'Upgrade', 'Submit Registration', 'Login', 'Register']
func_pages = udf(lambda x: "page_"+x.replace(" ", "_").lower())pages = df.filter(~df['page'].isin(pages_to_exclude))\
    .withColumn("page", func_pages(df["page"]))\
    .groupBy(['userId']).pivot("page").agg(count('page'))\
    .fillna(0)\
    .withColumn(
        "page_up_down_ratio", 
        pages["page_thumbs_up"]/(pages['page_thumbs_down']+0.1)
)
  • Доля каждого типа событий
pages = pages.withColumn(
    'total', sum(pages[coln] for coln in pages.columns if coln not in ['userId', 'page_up_down_ratio'])
)
for coln in pages.columns:
    if coln not in ['userId', 'total', 'page_up_down_ratio']:
        new_col_name = coln[0:5]+'frac_'+coln[5:]
        pages = pages.withColumn(
            new_col_name, pages[coln] / pages['total']
        )
pages = pages.drop('total')

Для этого у меня есть 64 столбца функций и 1 столбец меток для всех пользователей.

dataset = churn.join(levels, ['userId'])\
    .join(time_regi, ['userId'])\
    .join(gender, ['userId'])\
    .join(engagement, ['userId'])\
    .join(per_artist, ['userId'])\
    .join(per_session, ['userId'])\
    .join(agents, ['userId'])\
    .join(pages, ['userId'])\
    .join(locations, ['userId'])

Проверить корреляции функций

Во-вторых, я оценил корреляцию между каждой парой признаков. Чтобы получить краткий набор признаков, я удалил по одному признаку из каждой пары сильно коррелированных признаков (коэффициент корреляции > 0,8). Для этого у меня есть 43 столбца функций и 1 столбец меток для всех пользователей.

Преобразование функций

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

for col_name in col_names:
    if col_name in columns_to_transform:
        dataset = dataset.withColumn(
            col_name, log(dataset[col_name]+1)
        )

Машинное обучение

Целью модели машинного обучения является прогнозирование оттока (метка = 1) по сравнению с отсутствием оттока (метка = 0) на основе функций, которые я реконструировал на шаге 2.

Метрика оценки

Поскольку уходящие пользователи составляют только 23% всех пользователей, я выбрал показатель F1 в качестве метрики для оценки производительности модели, а не показатель точности.

Вкратце, точность определяется как:

точность = (количество правильных прогнозов) / (общее количество прогнозов)

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

Для сравнения, оценка F1 определяется как:

F1 = 2*точность*отзыв / (точность + отзыв)

Где точность — это количество правильно идентифицированных оттоков из общего числа идентифицированных оттоков, а отзыв — это количество правильно идентифицированных оттоков из общего числа реальных оттоков. При прогнозировании оттока точность гарантирует, что это отток, тогда как отзыв направлен на то, чтобы не пропустить ни одного реального оттока. F1, который усредняет точность и полноту, имеет больше смысла с несбалансированными классами.

Искровой трубопровод

После разделения обучения и тестирования я создал конвейер машинного обучения PySpark, который состоит из:

  • VectorAssembler, который векторизует входные функции
  • MaxAbsScaler, который повторно масштабирует каждую функцию в диапазоне [-1, 1]
  • Классификатор на выбор

Первоначальная оценка модели на подмножестве данных

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

  • Наивный предсказатель, который всегда предсказывает отсутствие оттока
  • Логистическая регрессия
  • Случайный лес
  • Дерево с градиентным усилением
Naive model:
+------+--------+
|    f1|accuracy|
+------+--------+
|0.6684|  0.7689|
+------+--------+
Logistic Regression:
+----------+--------+---------+-------+--------+
|train_time|f1_train|acc_train|f1_test|acc_test|
+----------+--------+---------+-------+--------+
| 1448.4415|  0.842 |   0.8534| 0.6804|  0.7059|
+----------+--------+---------+-------+--------+
Random Forest:
+----------+--------+---------+-------+--------+
|train_time|f1_train|acc_train|f1_test|acc_test|
+----------+--------+---------+-------+--------+
|  689.0333|  0.9339|   0.9372| 0.6479|  0.7353|
+----------+--------+---------+-------+--------+
Gradient-Boosted Tree:
+----------+--------+---------+-------+--------+
|train_time|f1_train|acc_train|f1_test|acc_test|
+----------+--------+---------+-------+--------+
| 2025.4227|     1.0|      1.0| 0.6868|  0.6765|
+----------+--------+---------+-------+--------+

Наивная модель устанавливает базовый уровень производительности модели, F1 = 0,67 и точность = 0,77. Как и ожидалось, три классификатора машинного обучения могут работать лучше, чем наивная модель на обучающем наборе. Среди прочего, Random Forest требует наименьшего времени для обучения, достигает второй лучшей производительности на тренировочном наборе (F1 = 0,93, точность = 0,94) и достигает наилучшей производительности на тестовом наборе (F1 = 0,65, точность = 0,74). Gradient Boosted Tree требует больше всего времени для обучения, достигает наилучшей производительности на тренировочном наборе (F1 = 1,0, точность = 1,0) и достигает второй лучшей производительности на тестовом наборе (F1 = 0,69, точность = 0,68).

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

Поскольку проводимый здесь анализ можно масштабировать и обучать на всем наборе данных, при условии, что код будет развернут на кластере, способном выполнять необходимые вычисления. Учитывая, что полный набор данных предоставит больше данных для обучения, чтобы помочь устранить переоснащение, гиперпараметры модели Gradient-Boosted Tree могут быть дополнительно обучены.

Настройка гиперпараметров

Я настроил гиперпараметры классификатора Gradient-Boosted Tree.

+-------+--------+
|f1_test|acc_test|
+-------+--------+
| 0.8229|  0.8387|
+-------+--------+

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

Заключение/Размышление/Возможные улучшения

  • Sparkify должен уменьшить отток пользователей, потому что оттоки пользователей вносят значительный вклад в общее использование, несмотря на небольшую долю от общей численности населения.
  • Модель машинного обучения может достаточно хорошо прогнозировать отток, что поможет Sparkify расставить приоритеты в удержании пользователей с наибольшей вероятностью оттока. Производительность модели может быть дополнительно улучшена за счет настройки более широкого диапазона гиперпараметров и дополнительных инженерных функций, таких как распределение активности пользователей по дням недели.
  • Оттоки связаны с пользователями, которые получили больше рекламы, чаще не любили песни, чем любили, и зарегистрировались позже.
  • Этот анализ выиграет от использования полного набора данных и развертывания в кластере Spark в облаке. Поиск по сетке — операция, требующая больших вычислительных затрат, но при наличии больших ресурсов и времени можно было бы провести более обширный поиск по большему набору данных и пространству гиперпараметров, чтобы дополнительно настроить модель и, вероятно, повысить общую точность.
  • Эти характеристики оттока также помогут вымышленному сервису потоковой передачи музыки Sparkify определить, какие действия предпринять, например: 1. Уменьшить количество рекламных роликов для идентифицированных пользователей. 2. Улучшение алгоритмов рекомендаций, чтобы рекомендовать песни и друзей, которые больше привлекают пользователей. 3. Внедрить краткое руководство сразу после регистрации пользователя, чтобы упростить взаимодействие пользователей. Sparkify потребуется A/B-тестирование для статистической оценки прибыли и затрат по каждому действию.