Під час активного розвитку технологій дедалі відомішими стають такі терміни, як Artificial Intelligence, Machine Learning, Data Mining та Data Science. Основне завдання цих технологій у бізнесі – навчитися розуміти поведінку клієнта в умовах, що постійно змінюються. Якщо ми знаємо, як поводитиметься клієнт у майбутньому, то зможемо якнайкраще спланувати та запустити маркетингові активності.
Математичні алгоритми машинного навчання працюють із великими обсягами даних і знаходять навіть приховані закономірності поведінки клієнтів. Ці закономірності не бачать менеджери і, ймовірно, іноді навіть самі клієнти про них не підозрюють.
У цій статті ми розглянемо один із підходів, який використовується в системі штучного інтелекту eSputnik Intelligence.
Цей підхід ґрунтується на декількох ідеях:
- розуміння шаблонів поведінки користувачів
- зменшення рівня занепокоєння клієнтів
- збільшення тривалості життя користувачів
- запобігання вигоранню бази клієнтів
- ефективне використання акційних пропозицій
Ви дізнаєтесь, як збільшити результативність листів і одночасно скоротити їхню загальну кількість більш ніж на 30%.
1. Формальна постановка задачі
Алгоритми розпізнавання шаблонів із навчанням передбачають наявність історичної інформації (т. зв. "вчителя"), що дозволяє побудувати моделі статистичного звʼязку x → y, де
- y ∈ Y, Y - дії користувача (responses), що спостерігаються, або випадкової величини, що моделюється,
- x ∈ X, X - множина змінних (predictors), за допомогою яких планується пояснити мінливість змінної y.
Більшість моделей із вчителем налаштовані так, що їх можна записати у вигляді
y = f(x, β) + ε, де:
- f - математична функція, обрана з певного довільного сімейства,
- β - вектор параметрів цієї функції,
- ε - помилки моделі, які зазвичай згенеровані незміщеним, некорельованим випадковим процесом.
Під час побудови моделі за фіксованими вибірковими значеннями y мінімізується певна функція залишків Q (y, β). У результаті знаходять β̂ – вектор з оптимальними оцінками параметрів моделі.
Варіюючи вид функцій f та Q, можна отримувати різні моделі, з яких перевага надається найефективнішим, тобто моделям, які дають незміщені, точні та надійні прогнози відгуку y.
2. Підготовка даних
Слід памʼятати, що будь-який статистичний метод буде хорошим настільки, наскільки якісними є вхідні дані для навчання моделі (англ. "garbage in – garbage out!" або "мотлох на вході – мотлох на виході"). Без витрачання зусиль на підготовку навчальної вибірки (фільтрація, трансформація, видалення пропущених значень, створення похідних змінних тощо) і розуміння процесу, що моделюється, диво не трапляється.
2.1. Моделювання вихідних даних
На практиці, перед побудовою моделі, необхідно зібрати та проаналізувати будь-яку доступну інформацію. Наприклад, тип розсилання, активність на сайті, транзакції, стать, вік, сімейний стан, індивідуальні вподобання тощо.
Для простоти викладу розглянемо загальний випадок і змоделюємо простий набір даних, який містить:
- ContactID – id користувача
- Date – час отримання листа
- Response – мітка конверсії (0 – ні, 1 – так)
До того ж дані мають бути максимально наближені до поведінки реальних користувачів. У наших даних будуть користувачі, які: зовсім не читають листи, читають рідко, читають майже все. Врахуємо також, що деякі з користувачів читають листи з певною періодичністю, а деякі – час від часу.
Спершу підключимо необхідні пакети і задамо функції:
Підключення пакетів
library(dplyr)
library(lubridate)
repeat_last <- function(x, forward = TRUE, maxgap = Inf, na.rm = FALSE) {
if (!forward) x = rev(x)
ind = which(!is.na(x))
if (is.na(x[1]) && !na.rm)
ind = c(1,ind)
rep_times = diff(
c(ind, length(x) + 1))
if (maxgap < Inf) {
exceed = rep_times - 1 > maxgap
if (any(exceed)) {
ind = sort(c(ind[exceed] + 1, ind))
rep_times = diff(c(ind, length(x) + 1))
}
}
x = rep(x[ind], times = rep_times)
if (!forward) x = rev(x)
x
}
shiftUp <- function(x, n){
as.integer(c(rep(0, n), x[-((length(x)-n+1) : length(x))]))
}
Змоделюємо данні:
Моделювання
initialDate <- ymd_hms("2015-01-01 00:00:00")
set.seed(9)
Activ <- data.table(ContactID = base::sample(1:5000, replace = T),
Date = initialDate +
as.difftime(runif(n, min = 0, max = 5*365/2*24*60),
units = "mins"))
set.seed(9)
Activ[, Response := rnorm(.N, mean = sample(1:200, 1),
sd = sample(1:50, 1)), by = ContactID]
q <- quantile(Activ$Response, .85)
Activ[Response < q, Response := 0][Response > q, Response := 1]
Ось так виглядають дані, які ми змоделювали:
ContactID | Date | Response | |
---|---|---|---|
1: | 45 | 2017-05-30 22:22:49 | 0 |
2: | 5 | 2017-02-14 18:35:40 | 0 |
3: | 42 | 2017-03-10 17:31:10 | 0 |
4: | 44 | 2017-06-04 14:49:50 | 0 |
5: | 89 | 2017-05-17 03:59:05 | 0 |
6: | 27 | 2017-04-18 08:30:45 | 0 |
7: | 79 | 2017-01-24 07:39:52 | 0 |
8: | 74 | 2017-03-14 01:20:03 | 0 |
9: | 134 | 2017-05-15 00:25:07 | 1 |
10: | 199 | 2017-05-01 16:14:43 | 0 |
... | ... | ... | ... |
100000: | 151 | 2017-01-07 04:08:03 | 0 |
Видалимо інформацію про користувачів, які абсолютно не активні. На практиці з такими користувачами потрібно працювати персоналізовано. Наприклад, розробляти методи їх реактивації.
Виключення неактивних користувачів
ActivUsers <- Activ %>% group_by(ContactID) %>%
summarise(IsActive = ifelse(sum(Response) == 0,
yes = 0, no = 1)) %>%
filter(IsActive == 1)
setDT(ActivUsers)
Activ <- merge(Activ, ActivUsers, by = "ContactID", all.x = F, all.y = F)
Activ[, IsActive := NULL]
Ефективний email-маркетинг з eSputnik
Реєстрація2.2. Агрегування інформації щодо кожного користувача
Важливо розуміти, що до того, як ми дізнаємося реакцію користувача на черговий лист, ми знаємо лише попередню історію його поведінки. Твердження очевидне, але на це можна не звернути увагу під час формування змінних для навчання.
Сформуємо множину змінних X – порахуємо для кожного користувача, на момент отримання чергового листа в історії, такі показники:
- кількість отриманих/прочитаних листів
- кількість отриманих/прочитаних листів у будні
- кількість отриманих/прочитаних листів у вихідні
Агрегування інформації
Activ[, DayType := ifelse(Day %in% c("Sat", "Sun"),
yes = "Weekend", no = "Weekday")]
Activ[, Day := NULL]
Activ[, Delivered := .N, by = list(ContactID, Date)]
Activ[, Delivered := cumsum(Delivered), by = list(ContactID)]
Activ[Response == 1, Opened := .N, by = list(ContactID, Response, Date)]
Activ[is.na(Opened), Opened := 0]
Activ[, Opened := cumsum(Opened), by = list(ContactID)]
Activ[, Opened := shiftUp(Opened, 1), by = list(ContactID)]
Activ[, Delivered := shiftUp(Delivered, 1), by = list(ContactID)]
Activ[, DeliveredByDayType := .N,
by = list(ContactID, DayType, Date)]
Activ[, DeliveredByDayType := cumsum(DeliveredByDayType),
by = list(ContactID, DayType)]
Activ[Response == 1, OpenedByDayType := .N,
by = list(ContactID, Response, Date)]
Activ[is.na(OpenedByDayType), OpenedByDayType := 0]
Activ[, OpenedByDayType := cumsum(OpenedByDayType),
by = list(ContactID, DayType)]
Activ[, OpenedByDayType := shiftUp(OpenedByDayType, 1),
by = list(ContactID, DayType)]
Activ[, DeliveredByDayType := shiftUp(DeliveredByDayType, 1),
by = list(ContactID)]
Activ[, OpenRate := Opened / Delivered]
Activ[, OpenRateByDayType := OpenedByDayType / DeliveredByDayType]
- відсоток відкриття листів
відсоток відкриття листів у будні/вихідні
Code
Activ[, OpenRateByDayType := OpenedByDayType / DeliveredByDayType]
- час існування користувача в системі
Code
Activ[, CreatedDate := min(Date), by = ContactID]
Activ[, LifeTime := difftime(Date, CreatedDate, units = "weeks"),
by = ContactID]
Activ[, LifeTime := as.numeric(LifeTime)]
- дата останнього отримання листа
дата останнього прочитання листа
Code
by = ContactID]
Activ[Response == 1, LastOpened := data.table::shift(Date, type = "lag"),
by = list(ContactID, Response)]
Activ[, temp := LastOpened]
Activ[, temp := repeat_last(temp), by = list(ContactID)]
Activ <- Activ[order(desc(ContactID), desc(Date)), ]
Activ[!is.na(temp), LastOpened := repeat_last(LastOpened), by = list(ContactID)]
Activ <- Activ[order(ContactID, Date), ]
Activ[, temp := NULL]
Activ <- Activ[!is.na(LastOpened), ]
- час від останнього отриманого листа
час від останнього прочитання листа
Code
units = "days"),
by = ContactID]
Activ[, RecentlyOpened := difftime(Date, LastOpened,
units = "days"),
by = ContactID]
Activ[, RecentlyDelivery := as.numeric(RecentlyDelivery)]
Activ[, RecentlyOpened := as.numeric(RecentlyOpened)]
- нормовані показники:
- середня кількість листів, яку отримує користувач протягом тижня
- середня кількість листів, яку відкриває користувач протягом тижня
- час до останнього отриманого листа відносно часу існування користувача в системі
- час до останнього прочитання листа відносно часу існування користувача в системі
Code
Activ[, OpenedNormed := Opened / LifeTime]
Activ[, RecentlyDeliveryNormed := RecentlyDelivery / LifeTime]
Activ[, RecentlyOpenedNormed := RecentlyOpened / LifeTime]
- бінарні показники активності за обрані періоди (0 – ні,1 – так):
активний протягом останніх 5 днів
активний протягом останніх 10 днів
активний протягом останніх 15 днів
Code
Activ[is.na(ActiveLast5), ActiveLast5 := 0]
Activ[RecentlyOpened <= 10, ActiveLast10 := 1]
Activ[is.na(ActiveLast10), ActiveLast10 := 0]
Activ[RecentlyOpened <= 15, ActiveLast15 := 1]
Activ[is.na(ActiveLast15), ActiveLast15 := 0]
Activ[, ActiveLast5 := as.factor(ActiveLast5)]
Activ[, ActiveLast10 := as.factor(ActiveLast10)]
Activ[, ActiveLast15 := as.factor(ActiveLast15)]
Змінна відгуку Y отримує два значення (вже задана у вихідних даних):
- 0, якщо користувач не прочитав лист;
- 1, якщо користувач прочитав лист.
Видалимо допоміжні дані:
Code
Activ[, LastDelivered := NULL]
Activ[, LastOpened := NULL]
Activ[, Delivered := NULL]
Activ[, Opened := NULL]
Activ[, Date := NULL]
Activ[, DeliveredByDayType := NULL]
Activ[, OpenedByDayType := NULL]
Activ[, ContactID := NULL]
Activ[, RecentlyDelivery := NULL]
Activ[, RecentlyOpened := NULL]
Дані для навчання моделі готові. Побудуємо модель передбачення значення змінної відгуку y за заданого значення параметрів xi ∈ X, i = {1,..,n}.
Іншими словами, модель відповідатиме на таке запитання: прочитає користувач черговий лист чи ні? Якщо прочитає – надсилаємо листа. Якщо ні – очікуємо від моделі сигналу до відправки.
Відповідно наше завдання зводиться до завдання бінарної класифікації – класифікуємо змінну y всього двома значенням 0 або 1 (де 0 – ні, не прочитає; 1 – так, прочитає).
До речі, ми сформували змінні лише для прикладу. Можливо, деякі з них є неінформативними. В одній із наступних статей ми розглянемо тему аналізу значущості змінних.
3. Вибір та навчання моделі
Один з ключових моментів, з яким дослідник стикається під час розробки статистичної моделі явища, полягає у виборі оптимального для конкретного випадку алгоритму отримання закономірностей.
Не вдаючися до опису існуючих алгоритмів машинного навчання, можна сказати, що багато з них є досить складними. Звісно, складні алгоритми контрінтуїтивні і важко інтерпретовані. Проте дослідження свідчать, що прості методи (на прикладі класифікаторів) під час вирішення практичних завдань часто перевершують складніші алгоритми.
Візьмемо функцію логістичної регресії як математичну функцію f, а як функцію залишків Q — логістичну функцію максимальної правдоподібності (суто інтуїтивно, функція Q допомагає знайти такі значення параметрів β̂, за яких передбачені ймовірності відгуку Ŷ найбільш близькі до дійсних значень відгуку Y).
Ітеративна взаємодія функцій f і Q підбере оптимальні параметри функції f для опису поведінки відгуків Y через змінні X.
3.1. Поділ даних на навчальну та тестову вибірки
Під час побудови моделі необхідно перевірити її точність, тому розподілимо наші дані на дві частини: навчальну (80%) та перевірчу (20%).
set.seed(9)
trainIndexes <- sample(1:nrow(Activ), round(nrow(Activ) * .8))
Activ_train <- Activ[trainIndexes, ]
Activ_test <- Activ[-trainIndexes, ]
3.2. Навчання моделі на вибірці для навчання
Скористаймося засобами пакету stats для навчання моделей.
formula <- Response ~ .
fit <- glm(formula, family = binomial(link='logit'), data = Activ_train)
3.3. Передбачення результатів для тестової вибірки
Під час оцінки параметра y модель вираховує ймовірність прочитання листа, а не конкретне значення 0 чи 1. Тобто ми отримуємо значення ŷ з інтервалу [0, 1].
Маючи ймовірності прочитання листа, необхідно визначитися, у разі якого порогу ймовірності (так званий параметр Threshold) ми відноситимемо користувача до групи 0 або 1. Зараз як порогове значення візьмемо параметр threshold рівним 0,09.
Зробимо так:
- якщо ŷ ≤ threshold, то Response = 0,
- якщо ŷ > threshold, то Response = 1;
glmpred <- predict(fit, newdata = Activ_test, type = 'response')
threshold <- 0.09
glmpredRound <- ifelse(glmpred > trashHold, yes = 1, no = 0)
4. Аналіз точності моделі
Порівняємо результати прогнозу моделі з реальними даними.
(testResult <- t(table(Факт = Activ_test$Response, Прогноз = glmpredRound)))
Отримуємо таблицю зв’язку дійсних і передбачених значень відгуку на тестовій вибірці.
Actual
0 1
Predicted 0 13642 630
1 34967 29054
Ми надсилаємо лист тим користувачам, для яких маємо Прогноз = 1. Користувачам, у яких Прогноз = 0, лист не надсилаємо.
5. Аналіз результатів
- Ми скоротили кількість відправлених листів на 14272 (18,24%)
- 13642 + 630 = 14272
- (13642+630) / (13642 + 630 +34967 + 29004) * 100 = 18,24%
- Точність прогнозу відгуку становить 97,88%, тобто ми втратили всього 2,12% відкриттів
- (1 - 630 / (630 + 29004)) * 100 = 97,88%
Чи це суттєво?
Зрозуміло, що розумно пожертвувати цією кількістю відкриттів та не надсилати зайві 18,24% листів, які не будуть прочитані.
Це дозволить не турбувати користувачів без необхідності та запобігти вигоранню контактної бази.
До того ж, якщо користувач рідше отримує листи, йому цікавіше їх читати. Також він більш імовірно вчинить цільову дію, коли отримає чергового листа.
Зауважимо, що цей результат отримали з використанням мінімуму інформації.
Під час додавання до моделі додаткової інформації, про яку ми писали вище, ефективність моделі збільшується в декілька разів.