Здесь набор данных состоит из транзакций группы пользователей на музыкальной платформе. Некоторые из этих пользователей в конечном итоге отменят свою подписку. Моя цель — предсказать, кто уйдет. Этот проект также дает возможность изучить и использовать 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
Сводка
Спроектированные функции и модели хорошо справляются с прогнозированием оттока клиентов, учитывая, что набор данных довольно мал. При большом наборе данных модель может хорошо обобщаться и улучшать свою производительность на тестовых данных. Кроме того, я мог бы добавить полиномиальные функции, чтобы усложнить модель.