Предсказание вероятности перехода каждого клиента компании в статус бывшего члена клуба
Авторы публикации — Дмитрий Сергеев и Юлия Петропавловская.
Недавно закончился первый в России Виртуальный хакатон от компании Microsoft при поддержке Forbes. Нашей команде, состоящей из двух человек, удалось занять первое место в номинации от WorldClass, в которой требовалось предсказать вероятности перехода каждого клиента компании в статус бывшего члена клуба. В этой статье мы бы хотели поделиться нашим решением и рассказать о его основных этапах.
Подготовка данных
Большую часть времени провели в очистке, восстановлении и объединении данных, так как датасеты были сильно загрязнены и сгруппированы по четырём отдельным категориям:
- Контракты клиентов
- Посещаемость
- Заморозки
- Коммуникации между клиентами и клубом
Тестовые и тренировочные наборы данных были разбиты по месяцам. Train содержал информацию о клиентах за декабрь 2015 года, а Test — за март 2016. Для каждой из категорий мы объединили Train и Test части для дальнейшей обработки.
Контракты клиентов
Контракты стали первым набором данных, за который мы взялись, так как именно там содержалась целевая переменная — «продлил ли клиент свой договор», а также коды контрактов и клиентов в количестве 17631 штук, послуживших ключами для объединения всех остальных датасетов. Небольшое количество пропущенных значений в переменных были восстановлены модами. Затем создали фичи для сезона (зима, весна…), месяца и дня, в который был заключен контракт с клубом, и переменные «длительность контракта», «остаток дней заморозки» и «остаток бонусов на счету». Различные категориальные переменные, такие как возрастная группа, сегмент клуба и т.д. оставили без изменений.
Посещаемость
Начали с создания переменной — длительность разового похода в фитнес-клуб.
Выяснилось, что особо усердные клиенты могут проводить почти по 9 часов на территории клуба, возможно, это связано с прохождением комплексных процедур.
Также в датасете присутствовали категориальные переменные, градации которых мы решили сгруппировать в более общие категории. Например, «КатегорияТренера»:
additional = ['Сотрудник СПА', 'Врач']
coach = ['Тренер мастер', "Тренер фитнес"]
coach_vip = ["Тренер персональный", "Тренер элит"]
other = ['Другое']
Аналогично — «НаправлениеУслуги»:
sport = ["Тренажерный зал", "Водные программы", "Аэробика", "Боевые искусства", "Mind Body",
"Танцевальные программы", "Игровые программы", "Йога", "Групповые программы"]
health_beauty = ["Солярий", "Парикмахерские услуги", "Лечебный массаж", "Маникюр, педикюр", "Массаж_SF",
"Терапевтические процедуры", "Физиотерапевтические процедуры", "Косметические услуги",
"Аппаратная косметология", "Окрашивание", "Аппаратная косметология_SF", "Врачи", "Врачи_SF",
"Продажа косметических товаров", "Инъекции", "Прочие услуги SPA", "Лечебное питание", "Инъекции_SF", "SPA"]
Наконец, добавили переменную с частотой посещения клуба в месяц и суммарные количества посещений в различные сезоны (зима, весна…) и сгруппировали данные по кодам клиентов, содержавшихся в датасете по контрактам. Итого, из 3 700 000 записей осталось ~15 000 наблюдений.
Заморозки
Изначально мы выяснили, что в датасете имеются дубликаты. После небольшого исследования оказалось, что один и тот же номер контракта с одинаковыми операциями по заморозке содержится и в Train, и в Test, так как клиентская история заморозок переносилась в тестовый набор. Чтобы в будущем избежать переобучения моделей, мы выкинули повторяющиеся значения из теста.
В течение года каждый клиент мог замораживать свою карту несколько раз, и нам показалось полезным в каком-то виде сохранить временную структуру его заморозок. Для этого мы создали четыре переменных для каждого времени года, в которые записывали суммарное число дней заморозки, израсходованных том или ином сезоне. В результате получили такую структуру данных:
Коммуникации
В сырых данных было три основных столбца: «Дата», «Вид» и «Состояние» взаимодействия. Под «видом» скрывались такие варианты как «телефонный звонок», «встреча», «смс» и т.д., «состояние» же характеризовалось тремя уровнями: «состоялось», «отменено», «запланировано». Как и в заморозках, сначала мы удалили дубликаты из тестовых данных, чтобы очистить их от клиентской истории, а затем перешли к созданию переменных.
Практически у каждого клиента было по несколько десятков коммуникаций того или иного вида. Чтобы сжать эту информацию в одну строку для последующего объединения по уникальному коду контракта мы создали несколько новых фичей.
Сначала разбили переменную «Вид взаимодействия» на 3 дамми:
- личная встреча
- телефон
- другое
Затем посчитали для каждого клиента общее и успешное («состоялось») число коммуникаций. Разделив одно на другое получили переменную «доля успешных коммуникаций».
Последней находкой стало создание дамми-переменной «были ли коммуникации за последние два месяца». Мы предположили, что если человек собирается продлевать свой контракт, он постарается так или иначе связаться с клубом, когда его текущий контракт будет подходить к концу.
В результате, из 1 500 000 строк получили 15500 и объединили их с финальным датасетом. После преобразования категориальных переменных в дамми количество столбцов раздулось до 72 штук.
Машинное обучение
Итак, бинарная классификация клиентов, классы представлены примерно поровну, всё хорошо и можно обучаться. Кандидатами в модели, помимо очевидного, стали:
- Random Forest
- Neural Network
- SVM
- k-NN
- Naive Bayes
- Logit regression
- Decision Stumps
Каждый из классификаторов, в целом, показывал очень неплохие результаты на валидации. Random Forest на 1000 деревьев с 10-fold cv давал 0.9499 AUC, двухслойная нейронная сеть смогла поднять результат до 0.98, а гроза соревнований на Kaggle, XGB, показал впечатляющие 0.982. Также xgboost помог с визуализацией важности признаков:
Первая тройка достаточно ожидаема — «длина контракта», «остаток бонусных баллов» и «средняя длина визита». Также в первой десятке «количество успешных коммуникаций», «остаток дней заморозки» и, внезапно, «посещал ли фитнес зимой».
Остальные модели, кроме решающих пеньков, в среднем, давали по 0.92–0.94 AUC и были добавлены в ансамбль для уменьшения коррелированности между различными предсказаниями.
Ансамбль задумывался в виде двух уровней — на первом сотня decision stumps, предсказания по которым объединялись при помощи принципа большинства голосов (majority vote), т.е. если 51 пенёк был «за», а 49 «против», то ставилась единица. На втором — подключались предсказания по остальным классификаторам для последующего объединения.
Для создания ансамбля использовался метод взвешенных средних, каждый классификатор тренируется отдельно, а затем из их предсказаний создается линейная комбинация:
aj — веса, с которыми предсказания входят в ансамбль
yj (x) — индивидуальные предсказания классификаторов
p — число используемых моделей
Веса определялись путём минимизации logloss-а ансамбля, при помощи замечательной функции minimize, возвращавшей оптимальные значения вектора весов x0.
from scipy.optimize import minimize
opt = minimize(ensemble_logloss, x0=[1, 1, 1, 1, 1, 1, 1])
Модели, которым давался отрицательный вес, из ансамбля выкидывались, чтобы избежать подгонки под тренировочные данные, хотя есть интересное мнение, что так делать не обязательно в случае отрицательно коррелированных ошибок моделей.
В результате такого отбора отпала логистическая регрессия и, сожалению, все пеньки, зато AUC вырос ещё на пару тысячных процента и составил 0.98486. Totally worth it.
Наконец, на тестовом датасете были сделаны предсказания, и чтобы иметь хотя бы какое-то представление об их качестве, были построены две гистограммы: первая для предсказанных ансамблем вероятностей продления клиентом контракта на валидационной выборке, вторая — на тестовой выборке.
Если предположить, что Train и Test выборки были более-менее однородны, и число продлившихся должно быть примерно равно числу отказавшихся, то налицо более чем двукратное завышение моделями вероятности продления контракта. Однако мы решили довериться решению ансамбля и не стали наказывать его за излишнюю наглостьоптимистичность прогноза. И как оказалось — не зря.
В завершении хотелось бы сказать огромное спасибо организаторам хакатона за очень интересную практическую задачу и незабываемый опыт.
Ссылка на репозиторий.