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

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

Определение проблемы

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

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

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

Исследование и очистка данных

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

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

root
 |-- artist: string (nullable = true)
 |-- auth: string (nullable = true)
 |-- firstName: string (nullable = true)
 |-- gender: string (nullable = true)
 |-- itemInSession: long (nullable = true)
 |-- lastName: string (nullable = true)
 |-- length: double (nullable = true)
 |-- level: string (nullable = true)
 |-- location: string (nullable = true)
 |-- method: string (nullable = true)
 |-- page: string (nullable = true)
 |-- registration: long (nullable = true)
 |-- sessionId: long (nullable = true)
 |-- song: string (nullable = true)
 |-- status: long (nullable = true)
 |-- ts: long (nullable = true)
 |-- userAgent: string (nullable = true)
 |-- userId: string (nullable = true)

Сначала набор данных был проверен на наличие пропущенных значений и других несоответствий. Выяснилось, что 8346 записей не содержат userId (пустая строка). Эти данные невозможно проанализировать, поэтому их пришлось удалить.

После очистки данных наш набор данных состоял из 225 пользователей, 104 женщин и 121 мужчин, при этом 177 из них имели бесплатное членство и только 48 оплатили услугу.

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

Также была проанализирована сводная статистика для числовых значений; больше несоответствий обнаружено не было.

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

df.dropDuplicates([“userId”]) \
 .groupBy(‘location’) \
 .count() \
 .orderBy(‘count’, ascending = False) \
 .show(5)
+--------------------+-----+
|            location|count|
+--------------------+-----+
|Los Angeles-Long ...|   16|
|New York-Newark-J...|   15|
|Phoenix-Mesa-Scot...|    7|
|Dallas-Fort Worth...|    7|
|Chicago-Napervill...|    6|
+--------------------+-----+
only showing top 5 rows

На следующем графике представлен обзор типов запросов, содержащихся в наборе данных, и их частоты. Как и ожидалось, запрос на воспроизведение следующей песни доминирует в наборе данных с 82% от общего числа запросов, в то время как другими важными/частыми запросами были запросы «большой палец вверх», «добавить в плейлист» и «добавить в друзья».

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

Если в данных для пользователя есть один запрос «Подтверждение отмены», пользователь определяется как пользователь, который уволился.

Всего в нашем наборе данных ушли 52 пользователя из 225, то есть ушли 23% пользователей. Ниже показано распределение ушедших пользователей по полу и уровню подписки (бесплатная/платная).

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

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

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

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

# Create dummy variable for the gender
gender_dummy = df_ch.groupBy(‘userId’).pivot(‘gender’).agg(lit(1)).na.fill(0)
gender_dummy.show(5)
+------+---+---+
|userId|  F|  M|
+------+---+---+
|100010|  1|  0|
|200002|  0|  1|
|   125|  0|  1|
|    51|  0|  1|
|     7|  0|  1|
+------+---+---+
only showing top 5 rows

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

+------+-----+----------+---------------+------+-------------------------+---------+-----+----+----+------+--------+-----------+-------------+--------+----------------+--------------+-----------+---------+-------+
|userId|About|Add Friend|Add to Playlist|Cancel|Cancellation Confirmation|Downgrade|Error|Help|Home|Logout|NextSong|Roll Advert|Save Settings|Settings|Submit Downgrade|Submit Upgrade|Thumbs Down|Thumbs Up|Upgrade|
+------+-----+----------+---------------+------+-------------------------+---------+-----+----+----+------+--------+-----------+-------------+--------+----------------+--------------+-----------+---------+-------+
|200002|    3|         4|              8|     0|                        0|        5|    0|   2|  20|     5|     387|          7|            0|       3|               0|             1|          6|       21|      2|
|100010|    1|         4|              7|     0|                        0|        0|    0|   2|  11|     5|     275|         52|            0|       0|               0|             0|          5|       17|      2|
+------+-----+----------+---------------+------+-------------------------+---------+-----+----+----+------+--------+-----------+-------------+--------+----------------+--------------+-----------+---------+-------+
only showing top 2 rows

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

# Calculate average number of songs per day for each user
df_day_songs = df_ch.filter(df_ch.song.isNotNull()).groupBy(‘userId’, ‘date’).agg(countDistinct(df.song)) \
 .groupBy(‘userId’).agg({‘count(DISTINCT song)’ : ‘avg’}) \
 .withColumnRenamed(‘avg(count(DISTINCT song))’, ‘No_Songs’)
df_day_songs.show(5)
+------+------------------+
|userId|          No_Songs|
+------+------------------+
|200002|              55.0|
|100010|39.142857142857146|
|   125|               8.0|
|   124|         124.90625|
|    51|158.46153846153845|
+------+------------------+
only showing top 5 rows

Затем нам пришлось объединить все функции в один фрейм данных.

df_final = df_churned.join(level_dummy, [‘userId’], ‘left’) \
 .join(gender_dummy, [‘userId’], ‘left’) \
 .join(df_actions, [‘userId’], ‘left’) \
 .join(df_day_songs, [‘userId’], ‘left’) \
 .join(df_day_dur, [‘userId’], ‘left’) \
 .withColumnRenamed(‘Churned’, ‘label’)

В Spark, чтобы применить модель классификации, мы также должны преобразовать функции столбца в вектор. Более того, поскольку набор данных содержит много информации с различными числовыми величинами (например, поле «Женщина» [0,1] и средняя продолжительность дня, которая принимает значения до нескольких тысяч секунд), было выбрано масштабирование всех полей в векторе, чтобы принимать значения от 0 до 1.Таким образом, различные функции не влияют на модель неодинаково.

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

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

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

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

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

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

а. Показатели оценки

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

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

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

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

б. Обучение, тестирование и проверка

Чтобы исследовать модели, нам также необходимо разделить набор данных, чтобы иметь возможность протестировать его и проверить.

Для обучения модели использовалось 80% исходного набора данных. Это было сделано с использованием техники перекрестной проверки с 3-кратным повторением. Это означает, что 80% набора данных были случайным образом разделены на 3 части. В каждой итерации модель подгонялась к двум частям, а затем проверялась относительно третьей части с использованием метрики F1.

Чтобы подтвердить результаты, «лучшая» модель затем предсказывала 20% «неизвестного» набора данных.

в. Создание конвейера

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

assembler = VectorAssembler(inputCols=feature_cols, outputCol=’vec_features’)
scaler = MinMaxScaler(inputCol=’vec_features’, outputCol=’scaled_features’)
f1_evaluator = MulticlassClassificationEvaluator(metricName=’f1')
lr = LogisticRegression(featuresCol=’scaled_features’, maxIter=10, elasticNetParam=0)
pipeline = Pipeline(stages=[assembler, scaler, lr])
paramGrid = ParamGridBuilder() \
 .addGrid(lr.regParam,[0.01, 0.1]) \
 .addGrid(lr.fitIntercept, [False, True]) \
 .build()
crossval = CrossValidator(estimator=pipeline,
 estimatorParamMaps=paramGrid,
 evaluator=f1_evaluator,
 numFolds=3)
cvModel = crossval.fit(rest)

Результаты и заключение

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

Логистическая регрессия дала 73% F1-Score для тестового набора данных во время перекрестной проверки, а также 73% для набора проверки, в то время как достигнутая точность составила 79%. Кроме того, это был наиболее эффективный алгоритм, требующий всего несколько минут для запуска всего процесса, описанного выше. Параметры для лучшей модели в целом были следующими:

aggregationDepth: 2
elasticNetParam: 0.0
family: auto
featuresCol: scaled_features
fitIntercept: False
labelCol: label
maxIter: 10
predictionCol: prediction
probabilityCol: probability
rawPredictionCol: rawPrediction
regParam: 0.01
standardization: True
threshold: 0.5
tol: 1e-06

Классификатор случайного леса также получил 73% F1-оценки для тестового набора данных, однако произошло значительное снижение оценки проверочного набора по сравнению с этим и с логистической регрессией до 65%. Точность этой модели составила 74%. Кроме того, запуск этой модели занял примерно в два раза больше времени по сравнению с моделью логистической регрессии.

Древесный классификатор с градиентным усилением показал наилучшие результаты в тестовом наборе, достигнув 72% F1-Score. Тем не менее, в качестве классификатора случайного леса он показал худшие результаты для проверочного набора, до 65%, а точность также составила 65%. Что касается эффективности, оказалось, что это худшая модель, так как для ее выполнения потребовалось примерно в 5 раз больше времени, чем для логистической регрессии.

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

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

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