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

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

Понимание данных

Всего записей в данных 286500. Различные столбцы и количество уникальных значений в каждом столбце приведены ниже.

artist 17656
auth 4
firstName 190
gender 3
itemInSession 1322
lastName 174
length 14866
level 2
location 115
method 2
page 22
registration 226
sessionId 2354
song 58481
status 3
ts 277447
userAgent 57
userId 226

Столбец userId представляет 226 уникальных пользователей в наборе данных. Поэтому стремитесь создать множество функций для этих пользователей. Несколько других описаний данных

+----------+------+
|      auth| count| 
+----------+------+
|Logged Out|  8249|
| Cancelled|    52|
|     Guest|    97|
| Logged In|278102|
+----------+------+
+-----+------+
|level| count|
+-----+------+
| free| 58338|
| paid|228162|
+-----+------+

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

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

+--------------------+------+
|                page| count|
+--------------------+------+
|            NextSong|228108|
|                Home| 14457|
|           Thumbs Up| 12551|
|     Add to Playlist|  6526|
|          Add Friend|  4277|
|         Roll Advert|  3933|
|               Login|  3241|
|              Logout|  3226|
|         Thumbs Down|  2546|
|           Downgrade|  2055|
|                Help|  1726|
|            Settings|  1514|
|               About|   924|
|             Upgrade|   499|
|       Save Settings|   310|
|               Error|   258|
|      Submit Upgrade|   159|
|    Submit Downgrade|    63|
|              Cancel|    52|
|Cancellation Conf...|    52|
+--------------------+------+

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

В этом проекте я широко использовал запросы Spark SQL для извлечения данных. Ниже приведен пример получения измененного идентификатора пользователя.

query=""" 
select userId,page
from spotify
where page='Cancellation Confirmation'
"""
spark.sql(query).show(5)
.....
+------+--------------------+
|userId|                page|
+------+--------------------+
|    18|Cancellation Conf...|
|    32|Cancellation Conf...|
|   125|Cancellation Conf...|
|   105|Cancellation Conf...|
|    17|Cancellation Conf...|
+------+--------------------+
only showing top 5 rows

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

+-----+---------+--------+
|level|     auth|count(1)|
+-----+---------+--------+
| paid|Cancelled|      31|
| free|Cancelled|      21|
+-----+---------+--------+

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

query=""" 
select userId,page,level,lead_level
from
(select userId,page,level,lead(level) over (partition by userId order by ts) as lead_level
from spotify
where userId !=''
order by userId) sub
where level='paid' and lead_level='free'
"""
spark.sql(query).show(5)
.....
+------+----------------+-----+----------+
|userId|            page|level|lead_level|
+------+----------------+-----+----------+
|   100|Submit Downgrade| paid|      free|
|100004|Submit Downgrade| paid|      free|
|100004|Submit Downgrade| paid|      free|
|100008|Submit Downgrade| paid|      free|
|100009|Submit Downgrade| paid|      free|
+------+----------------+-----+----------+
only showing top 5 rows

Здесь level и lead_level — это уровни, соответствующие двум соседним транзакциям пользователя. Можно видеть, что как только пользователь нажимает «Отправить на понижение», его уровень меняется с платного на бесплатный. Я бы также считал этих пользователей уволенными пользователями. К этой категории относятся 49 пользователей. Объединение этих двух категорий ушедших пользователей привело к 76 уникальным пользователям.

Очистка данных

В наборе данных несколько userId не имеют значения. Они связаны с записями «Вышел из системы». Я сохранил только записи «Вошел в систему». Поскольку цель здесь — предсказать, кто из платных пользователей уйдет, я удалил все записи, соответствующие «бесплатным» в столбце уровня. Это также имеет смысл, поскольку у бесплатных пользователей могут быть некоторые ограничения на использование контента. У некоторых уволенных пользователей есть «бесплатные» записи, связанные с ними после того, как они отменили свою подписку. Эти записи не следует использовать для прогнозирования оттока

После очистки осталось 222402 записи. Количество ушедших пользователей – 76, количество неушедших пользователей – 88.

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

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

Коэффициент пропуска

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

query=""" 
With t3 as
(With t1 as (
select userId userId1,count(*) cn1 from spotify_cleaned group by userId order by userId
),
 t2 as (
select userId userId2,count(*) cn2 from spotify_cleaned where length<120 group by userId order by userId
)
Select *
from t1
left Join t2
on t2.userId2=t1.userId1)
Select userId1 as userId,cn2/cn1 skip_ratio
from t3
order by skip_ratio desc
"""
user_skip_ratio=spark.sql(query)
user_skip_ratio=user_skip_ratio.fillna(0)
user_skip_ratio.show(5)
output:
+------+--------------------+
|userId|          skip_ratio|
+------+--------------------+
|    55| 0.06896551724137931|
|300005| 0.03939393939393939|
|    79|0.036585365853658534|
|    93| 0.03614457831325301|
|300004| 0.03289473684210526|
+------+--------------------+
only showing top 5 rows

Пол

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

Non-churned Users
+------+--------------------+
|gender|gender_count_entries|
+------+--------------------+
|     F|               59857|
|     M|               54581|
+------+--------------------+
Churned Users
+------+--------------------+
|gender|gender_count_entries|
+------+--------------------+
|     F|               65036|
|     M|               42927|
+------+--------------------+

Соотношение положительных и отрицательных результатов

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

query=""" 
With t3 as
(With t1 as (
select userId userId1,count(*) t_up from spotify_cleaned Where page='Thumbs Up' group by userId order by userId
),
 t2 as (
select userId userId2,count(*) t_down from spotify_cleaned where page='Thumbs Down' group by userId order by userId
)
Select *
from t1
left Join t2
on t2.userId2=t1.userId1)
Select userId1 as userId,t_up/t_down updown_ratio
from t3
order by updown_ratio desc
"""
user_updown_ratio=spark.sql(query)
user_updown_ratio=user_updown_ratio.fillna(0)
user_updown_ratio.show(5)
Output:
------+------------+
|userId|updown_ratio|
+------+------------+
|   155|        24.5|
|    18|        20.0|
|300013|        19.0|
|300020|        18.0|
|100015|        14.0|
+------+------------+
only showing top 5 rows

Учитывается категория страницы

Как было показано ранее, существуют разные типы просмотров страниц, где преобладает «Следующая песня». Для каждого пользователя я рассчитываю частоту категории страницы как долю от общего числа посещений пользователем. Затем я сравнил эту долю для всех пользователей в категории ушедших и не ушедших. Я выбрал три категории, в которых ушедшие и не ушедшие пользователи выглядели по-разному. Это NextSong, Roll Advert и Error

10 лучших фракций исполнителей

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

Доля отдельных исполнителей и песен

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

Доля активных дней и количество сеансов в активных днях

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

Товар за сеанс

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

Построение модели

Окончательный фрейм данных имеет 163 строки и 14 столбцов. Нам нужно преобразовать столбец «пол» в числовой. Для этой цели я использовал StringIndexer, а затем OneHotEncoderEstimator. Для построения модели все функции необходимо преобразовать в один из столбцов функций. Для этого я использовал VectorAssembler. Все эти процедуры были реализованы с помощью пайплайна после разделения данных на обучающую и тестовую выборки.

# Split the Data
user_churn_df_train,user_churn_df_test=user_churn_df.randomSplit([.8,.2],seed=125)
# Create a pipeline
vec_assembler1 = VectorAssembler(inputCols=['skip_ratio','updown_ratio','Error','NextSong',
 'Roll Advert','top10_frac','dist_artist_frac','dist_song_frac','session_act_days','act_days_frac',
                                            'avg_items_sessions'],outputCol='features1')
scaler = StandardScaler(inputCol="features1", outputCol="scaledFeatures",
                        withStd=True, withMean=False)
stringIndexer = StringIndexer(inputCol="gender",outputCol="genderIndex")
encoder = OneHotEncoderEstimator(inputCols=["genderIndex"],outputCols=["genderVec1"])
vec_assembler2 = VectorAssembler(inputCols=["scaledFeatures",'genderVec1'],outputCol='features')
pipeline=Pipeline(stages=[vec_assembler1,scaler,stringIndexer, encoder,vec_assembler2])
# fit and transform using the pipeline to produce train and test sets
model=pipeline.fit(user_churn_df_train)
train=model.transform(user_churn_df_train)
test=model.transform(user_churn_df_test)

Три разные модели были оценены с использованием f1-показателя. Они используют логистическую регрессию, линейный SVC и случайные леса.

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

lr = LogisticRegression(labelCol="Churn",featuresCol="features")
lrModel = lr.fit(train)
train_predictions=lrModel.transform(train)
evaluator =evals.MulticlassClassificationEvaluator(labelCol="Churn", predictionCol="prediction",metricName='f1')
print(evaluator.evaluate(train_predictions))
output:.6798808588254699
test_predictions=lrModel.transform(test)
print(evaluator.evaluate(test_predictions))
output:0.6664576802507837

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

Линейный SVM

lsvc = LinearSVC(labelCol="Churn",featuresCol="features")
lsvcModel=lsvc.fit(train)
train_predictions=lsvcModel.transform(train)
evaluator =evals.MulticlassClassificationEvaluator(labelCol="Churn", predictionCol="prediction",metricName='f1')
print(evaluator.evaluate(train_predictions))
output:0.6531008945141741
test_predictions=lsvcModel.transform(test)
print(evaluator.evaluate(test_predictions))
output:0.7064935064935065

Производительность теста лучше, чем у логистической регрессии. Однако модель долго тренировалась

Случайные леса

rf = RandomForestClassifier(labelCol="Churn", featuresCol="features", numTrees=25,maxBins=5,maxDepth=4,featureSubsetStrategy='onethird')
rf_model=rf.fit(train)
train_predictions=rf_model.transform(train)
evaluator =evals.MulticlassClassificationEvaluator(labelCol="Churn", predictionCol="prediction",metricName='f1')
print("Train set f1 score is ",evaluator.evaluate(train_predictions))
output:0.8416119429121793
test_predictions=rf_model.transform(test)
print("Test set f1 score is ",evaluator.evaluate(test_predictions))
output:0.5454545454545454

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

Настройка модели

Наконец, я решил выбрать модель логистической регрессии и оптимизировать ее. Я использовал функцию CrossValidator, которая будет использовать 5-кратную перекрестную проверку и поиск по сетке параметров, чтобы найти лучшую модель. Используемая метрика - f1-score.

lr = LogisticRegression(labelCol="Churn",featuresCol="features")
evaluator =evals.MulticlassClassificationEvaluator(labelCol="Churn", predictionCol="prediction",metricName='f1')
grid = tune.ParamGridBuilder()
grid = grid.addGrid(lr.elasticNetParam,[0.0, 0.5, 1.0])
grid = grid.addGrid(lr.maxIter,[10, 100, 200])
grid = grid.addGrid(lr.threshold,[.4,.5,.6])
grid = grid.build()
cv = tune.CrossValidator(estimator=lr,
               estimatorParamMaps=grid,
               evaluator=evaluator,numFolds=5)
models = cv.fit(train)
best_lr= models.bestModel

Лучшая производительность модели на тренировочном и тестовом наборах приведена ниже.

train_predictions=best_lr.transform(train)
print(“Train set f1 score is“,evaluator.evaluate(train_predictions))
Train set f1 score is  0.6818801279376998
test_predictions=best_lr.transform(test)
print("Test set f1 score is ",evaluator.evaluate(test_predictions))
Test set f1 score is  0.6462891946762914

Сводка

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