Як я підвищував конверсію машинним навчанням

У цій статті я спробую відповісти на такі питання:
  • може один доповідь розумної людини зробити іншої людини одержимим?
  • як зануритися в машинне навчання (майже) з нуля?
  • чому не варто недооцінювати многоруких бандитів?
  • існує срібна куля для a/b тестів?
Відповідь на перше питання буде найбільш лаконічним — «так». Почувши це виступ bobuk на YaC/M, я захопився елегантністю підходу і задумався про те, як би запровадити схоже рішення. Я тоді працював продуктовим менеджером в компанії Wargaming і якраз займався т. н. user acquisition services – технологічними рішеннями для залучення користувачів, у число яких входила і система для A/B тестування лендингов. Так що зерна лягли на благодатний грунт.

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


Про що взагалі мова?
Концепція сервісу була досить простою: на стадії прототипу я вирішив обмежитися роутером, який би прогнозував конверсію для кожного новоприбулого користувача на різних лендингах і відправляв його на оптимальний. Всі свистілки відклав на потім, оскільки потрібно було спочатку відточити передбачувану технологію.
image

Рівень моїх пізнань в машинному навчанні був десь між «відсутні» і «мізерні». Тому довелося починати з мінімального лікнепу:
Для реалізації непогано підійшов Python, т. к. з одного боку, це мова загального призначення, з іншого — його екосистема породила чимало бібліотек для роботи з даними (зокрема, мені знадобилися scikit-learn, pandas, Lasagne). Для веб-обгортки використовував Django — це явно неоптимальний вибір, але зате це не потребувало додаткового часу на освоєння нового фреймворка. Забігаючи вперед, відзначу, що проблем зі швидкодією Django не було, і на моїй порівняно невеликому навантаженні до 3000 RPM серверні запити відпрацьовували протягом 20-30 мс.


Що буває, якщо раптово підключити до тесту клієнта з великою кількістю трафіку.

Workflow був таким:
  • користувач приходить на роутер;
  • сервіс збирає максимальну кількість інформації про нього (так як на етапі прототипу було б нерозумно інтегруватися з постачальниками інформації на зразок DMP, я починав з технічних даних — HTTP-заголовки, геолокація, дозвіл екрана тощо);
  • класифікатор передбачає конверсію на можливих варіантах лендингов;
  • роутер віддає 302 Redirect на потенційно кращий лендінгем.


Класифікуючи класифікатори
Щоб підбирати кращі лендінгем, потрібно для початку підібрати відповідний класифікатор. Для кожного проекту (набору лендингов одного клієнта) класифікатори б напевно різнилися, бо мені потрібно було:
  • навчати деяку кількість класифікаторів на історичних даних, підбираючи гиперпараметры;
  • вибирати оптимальний класифікатор на поточний момент;
  • періодично повторювати цю операцію.
Безсумнівна перевага була в тому, що навчати класифікатори можна в бекграунді, незалежно від тієї частини системи, яка безпосередньо розподіляла користувачів по лендингам. Наприклад, на початку експериментів користувачі розподілялися в режимі a/b тесту, щоб одночасно зібрати дані для навчання моделі, і не зіпсувати існуючу конверсію.

Навчання класифікатора за допомогою scikit-learn — досить проста річ. Досить спершу векторизовать дані за допомогою DictVectorizer з scikit-learn, розділити на вибірку навчальну та тестову, навчити класифікатор, зробити передбачення і оцінити їх точність.

Так виглядають вихідні дані:
[{'1_lang': 'pl-PL',
'browser_full': 'IE8',
'country': 'Poland',
'day': '5',
'hour': '9',
'is_bot': False,
'is_mobile': False,
'is_pc': True,
'is_tablet': False,
'is_touch_capable': False,
'month': '6',
'os': 'Windows 7',
'timezone': '+0200',
'utm_campaign': '11766_',
'utm_medium': '543', 
'used_landing' : '1'
},
{'1_lang': 'en-US',
'REFERER_HOST': 'somedomain.com',
'browser': 'Firefox',
'browser_full': 'Firefox38',
'city': 'Рейлі',
'country': 'United States',
'day': '5',
'hour': '3',
'is_bot': False,
'is_mobile': False,
'is_pc': True,
'is_tablet': False,
'is_touch_capable': False,
'month': '6',
'os': 'Windows 8.1',
'timezone': '-0400',
'utm_campaign': 'pff_r.search.yahoo.com',
'utm_medium': '1822',
'used_landing' : '2'
},
..., 
{'1_lang': 'uk-ua',
'HTTP_REFERER': 'somedomain.ru',
'browser': 'IE',
'browser_full': 'IE11',
'screen': '1280x960x24',
'country': 'Ukraine',
'day': '5',
'hour': '7',
'is_bot': False,
'is_mobile': False,
'is_pc': True,
'is_tablet': False,
'is_touch_capable': False,
'month': '6',
'os': 'Windows 7',
'timezone': 'N/A',
'utm_campaign': '62099',
'utm_medium': '1077',
'used_landing' : '1'
}]

(частина пар ключ-значення видалена)

А приблизно так — трансформовані в numpy array:
[[ 0. 0. 0. ..., 0. 0. 1.]
[ 0. 0. 0. ..., 0. 0. 1.]
[ 0. 0. 0. ..., 0. 1. 0.]
..., 
[ 0. 0. 0. ..., 0. 1. 0.]
[ 0. 0. 0. ..., 0. 0. 1.]
[ 0. 0. 0. ..., 0. 0. 1.]] 


До речі, для багатьох методів класифікації раціональніше залишати дані у вигляді sparse-матриці, а не numpy array, оскільки це знижує витрату пам'яті.

Результат, який потрібно передбачати за цими даними, є списком з нулів (конверсії не сталося) і одиниць (ура, користувач зареєструвався!).

from sklearn.feature_extraction import DictVectorizer
from sklearn.cross_validation import train_test_split
import json

clicks = Click.objects.filter(project=42)

# deserializing data stored as json
X = DictVectorizer().fit_transform([json.loads(x.data) for x in clicks]) 
Y = [1 if click.conversion_time else 0 for click in clicks]

# getting train and test subsets for model fitting and scoring
X1, X2, Y1, Y2 = train_test_split(X, Y, test_size=0.3) 

Зробимо найпростішу логістичну регресію:

from sklearn.linear_model import LogisticRegression

clf = LogisticRegression(class_weight='auto')
clf.fit(X1, Y1)

predicted = clf.predict(X2)

Оцінимо якість прогнозів. Вибір критерію оцінки — чи не найскладніша частина проекту. Інтуїтивно здається, що не потрібно нічого вигадувати і досить оцінити частку правильних передбачень, однак це некоректний підхід. Найпростіший контраргумент: якщо у наших лендингов буде середня конверсія 1%, то самий тупий класифікатор, пророкує відсутність конверсії для будь-якого користувача, покаже точність 99%.

Для завдань бінарної класифікації часто використовують такі показники як f1-score або коефіцієнт Меттьюса. Але в моєму випадку важлива не стільки коректність бінарного передбачення (трапиться конверсія або ні), а наскільки близька передбачена ймовірність. У таких випадках можна використовувати ROC AUC score або log_loss; якщо вивчити схожі завдання на Kaggle (наприклад, конкурс Avazu або Avito), можна побачити, що саме ці метрики найчастіше і використовуються.

In [21]: roc_auc_score(Y2, clf.predict(X2))
Out[21]: 0.76443388650963591

Хм, якість так собі. А чому б не спробувати перебирати гиперпараметры моделі? Для цього в scikit-learn теж є готовий інструмент — модуль grid_search і класи GridSearchCV для повного перебору і RandomizedSearchCV для безлічі випадкових виборів (стане у нагоді, якщо кількість можливих варіантів аж надто велике).

from sklearn.metrics import roc_auc_score, make_scorer
from sklearn.grid_search import RandomizedSearchCV

clfs = ((DecisionTreeClassifier(), {'max_features': ['auto', 'корінь', 'log2', None],
'max_depth': range(3, 15),
'criterion': ['gini', 'entropy'],
'splitter': ['best', 'random'],
'min_samples_leaf': range(1, 10),
'class_weight': ['auto'],
'min_samples_split': range(1, 10),
}),
(LogisticRegression(), {'penalty': ['l1', 'l2'],
'C': [x / 10.0 for x in range(1, 50)],
'fit_intercept': [True, False],
'class_weight': ['auto'],
}),
(SGDClassifier(), X, 12, {'loss': ['modified_huber', 'log'],
'alpha': [1.0 / 10 ** x for x in range(1, 6)],
'penalty': ['l2', 'l1', 'elasticnet'],
'n_iter': range(4, 12),
'learning_rate': ['constant', 'optimal', 'invscaling'],
'class_weight': ['auto'],
'eta0': [0.01],
}))

for clf, param in clfs:
logger.debug('Parameters search started for {0}'.format(clf.__class__.__name__))
grid = RandomizedSearchCV(estimator=clf,
param_distributions=param,
scoring=make_scorer(roc_auc_score),
n_iter=200,
n_jobs=2,
iid=True,
refit=True,
cv=2,
verbose=0,
pre_dispatch='2*n_jobs',
error_score=0)
grid.fit(X, Y)
logger.info('Best estimator is {} with score {} using params {}'.format(clf.__class__.__name__, grid.best_score_, grid.best_params_))

Цей фрагмент коду, як і інші, максимально спрощено для наочності: наприклад, в якості числових параметрів для перебору в RandomizedSearchCV рекомендується передавати розподілу scipy.stats, а не списки.

В принципі, можна було б не зупинятися на досягнутому і прикрутити до цього щастя ще і генетичні алгоритми. Але, здається, якість прогнозів вже стає більш або менш адекватним:

In [27]: roc_auc_score(Y2, clf.predict(X2))
Out[27]: 0.95225886338947252

На жаль, немає чіткого критерію, коли модель вже гарна чи ще потребує тюнінгу. Можливості для тюнінга безмежні: можна додати поліноміальні комбінації ознак, можна конструювати ознаки на базі існуючих (наприклад, визначати тематики сайту-реферера). Тим не менш, коли різного роду маніпуляції вже не дають відчутного ефекту, пора приступати до наступної стадії, інакше можна провести чимало часу, покращуючи класифікатор ще на 0.00001%.

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

З пісочниці в продакшен
Отже, у нас є скільки-небудь працюють класифікатори. Пора в продакшен! Тільки перед цим потрібно визначитися, що таке добре, а що таке погано — вже не з точки зору математики і машинного навчання, а для бізнесу.

Т. к. весь проект про підвищення конверсії, треба знову влаштувати A/B тест. Точніше, метатест:
  • частина трафіку буде розподілятися між лендингами випадковим чином;
  • для частини буде працювати A/B тест з найпростішої моделі багаторукого бандита;
  • і, нарешті, залишився трафік буде розподілятися за допомогою класифікаторів.
Деплой, обговорення запуску з тестовими клієнтами, налагодження дрібних багів — і можна з нетерпінням чекати результату.

Особисто для мене саме неприємне в процесі A/B тестів — це проміжні результати. Регулярно трапляється ситуація, що спочатку один варіант вибивається вперед, здається, що вдалося добитися хорошого результату, хоча статистичної значущості ще немає. Через якийсь час даних стає більше, і приходить розуміння, що все не так райдужно.

Приблизно так само було і в цей раз. «Розумний» роутинг за допомогою класифікаторів спершу вибився вперед, а потім результати майже зрівнялися з багаторуким бандитом. Обидві моделі вибору лендинга показали себе приблизно однаково ефективними, залишивши рандом позаду (не дивно). Тільки один з шести експериментів показав статистично значущу перевагу такого роутінга перед A/B тестом.



Окремо хочу відзначити, що жодної кореляції між якістю класифікатора (ROC AUC / log_loss) і конверсією я не помітив, що сильно ускладнювало спроби якось поліпшити ситуацію.

чи Є життя на Марсі?
Більш актуальне питання, чи може ця технологія принести відчутну користь, а не коливатися на межі статистичної похибки? Я не можу підтвердити або спростувати цю гіпотезу, але виглядає так, ніби такий підхід з машинним навчанням може виявитися корисним при роботі з усіма залучення користувачів.

Ймовірно, якщо робити різноманітні лендінгем (я тестував систему на дуже схожих сторінках), інтегрувати більшу кількість джерел даних (макроси рекламних мереж, CRM клієнта і навіть DMP) — технологія може виявитися корисною і підвищити конверсію в ситуації, коли звичайні a/b тести не дають ефекту. Зробити ж срібну кулю, щоб будь-який бажаючий міг на рівному місці отримати +N% конверсії з існуючих сторінок без вдумливої роботи — швидше нереально.

Деякі класифікатори scikit-learn (зокрема, засновані на деревах рішень) мають цікавий атрибут feature_importances_, показує вагу тієї або іншої ознаки для підсумкового передбачення. Мої тестові класифікатори рідко привласнювали обраному лендингу вага більше 2% і жодного разу не пробили поріг у 5%. При цьому такі параметри як країна, реферер і браузер могли відібрати собі 20-25%. Я схильний трактувати це так: важливість гарного лендинга дещо перебільшена, а робота, націлених в рекламній кампанії могла бути і краще.

Тим не менш, якщо серед читачів є бажаючі спробувати мою розробку на своєму проекті, пишіть, буду радий провести ще кілька спільних експериментів. Для тіста знадобиться кілька варіантів транзакційних сторінок і достатня кількість користувачів (від 10 тис.).

Я планую на цьому закінчити свій творчий відпустку і повернутися до звичайної роботи, тому хотів би дізнатися думку громади про необхідність такого проекту, щоб дійти до логічного завершення.
Вам би знадобився такий сервіс?

/>
/>


<input type=«radio» id=«vv67305»
class=«radio js-field-data»
name=«variant[]»
value=«67305» />
Це цікаво, готовий користуватися за невеликі гроші
<input type=«radio» id=«vv67307»
class=«radio js-field-data»
name=«variant[]»
value=«67307» />
Це цікаво, хочеться, щоб проект пішов в open source
<input type=«radio» id=«vv67309»
class=«radio js-field-data»
name=«variant[]»
value=«67309» />
Для мене це марно / нецікаво
<input type=«radio» id=«vv67311»
class=«radio js-field-data»
name=«variant[]»
value=«67311» />
Якщо знадобиться, я сам легко зроблю такий велосипед

Проголосувало 26 осіб. Утрималося 14 осіб.


Тільки зареєстровані користувачі можуть брати участь в опитуванні. Увійдіть, будь ласка.


Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.