Как я повышал конверсию машинным обучением

    В этой статье я попробую ответить на такие вопросы:
    • может ли один доклад умного человека сделать другого человека одержимым?
    • как окунуться в машинное обучение (почти) с нуля?
    • почему не стоит недооценивать многоруких бандитов?
    • существует ли серебряная пуля для 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, разделить выборкy на обучающую и тестовую, обучить классификатор, сделать предсказания и оценить их точность.

    Так выглядят исходные данные:
    [{'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': 'Raleigh',
      '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': 'ru-RU',
      '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', 'sqrt', '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(),  {'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 тыс.).

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

    Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

    Метки:
    Поделиться публикацией
    Похожие публикации
    Комментарии 24
    • +1
      Хех, делали тоже самое, но на ruby :)
      • +2
        И как результаты? Удалось добиться значительного роста конверсии?
        • +1
          А с этого момента поподробнее :)
          Какие библиотеки использовали? Что есть почитать на тему? Или может отдельную статью напишите? :)
          • 0
            Присоединяюсь к ожидающим подробности.
          • 0
            А корпус какого размера использовался для обучения?
            • 0
              Для каждого эксперимента — свой корпус, от 10 до 150 тыс. При этом learning curve достаточная плавная, уже на 10-20 тысячах точность обычно была близка к максимуму.
            • 0
              Всё же интересно какой скор по f1 получался. Возможно она будет лучше коррелировать с конверсией?
              Бустинг не пробовали? Например, GradientBoostingClassifier — как правило работает лучше обычных деревьев.
              Было бы интересно посмотреть на результат с большим количеством факторов.
              • 0
                Я смотрел на связь matthews_corrcoef (он по сути похож на f1) и конверсию и тоже ничего не нашел.
                Бустинг пробовал, в двух из шести экспериментов сейчас именно он и применяется. Ансамблевые методы вообще работают в среднем лучше :)
              • 0
                Статья очень интересная, спасибо!
                Мне кажется ваша модель имеет 1 очень большой плюс — она более гибкая. Мне кажется, похожую модель можно использовать для таргетированной/контекстной рекламы
                • +2
                  Очень хороший базовый курс по машинному обучение — здесь:
                  Caltech's Machine Learning Course — CS 156 by Professor Yaser Abu-Mostafa
                  • +3
                    важность хорошего лендинга несколько преувеличена

                    На другом проекте, другие методы, но вывод по жизни тот же… Такое ощущение, что вокруг темы лендингов специально раздувается ажиотаж, чтобы активнее продавать всевозможные решения по их «оптимизации».
                    • 0
                      Нельзя ли привести результаты рандома, что бы понять насколько «позади» он оказался?
                      Все ли лендинги были одинаково хорошо оптимизированы под мобильный трафик?
                      • 0
                        Рандом в среднем на 30-50% хуже a/b теста.
                        Оптимизация под мобильный трафик была одинаковой (у одного клиента все хорошо на всех лендингах, у другого — все равномерно плохо).

                        • 0
                          Как по мне — результат неожиданно высок. Там точно нет ошибки в сравнениях с рандомом?
                          Как я понял, вы browscap использовали? По факту там только одна существенная степень свободы — тип устройства. Может быть еще браузер, если лендинги — онлайн игры. Если б соцдем-информация была, тогда понятно. А так не ясно за счет чего такой прирост.
                          • 0
                            Не browscap (проект же на Python), но решение похожее. Есть еще география, utm-метки, время − факторов хватает.
                      • +1
                        Немного не понял, вы сначала тренируете модель прямо предсказывать клик

                        Y = [1 if click.conversion_time else 0 for click in clicks]
                        

                        А потом считаете «насколько близка предсказанная вероятность»? Я не очень понимаю что есть «предсказанная вероятность» потому что ясно, что линейная регрессия не вероятность выдает.
                        Есть мнение что лучше использовать стандартные инструменты, но грамотно оценивать результаты моделирования, для примера порог качества модели установить 99,5%, а не 99%, при 1% положительных значений
                        • +2
                          Классификатор может оценивать вероятность того, что клик относится к выбранному классу (сконвертившийся или нет), а не только булев факт. Линейная регрессия тут вообще не при чем, т.к. это задача классификации, а не регрессии.

                          Допустим, есть клик, который на самом деле сконвертился.
                          Первый классификатор оценил вероятность clf1.predict_proba(click) = (0.51, 0.49) — т.е. 49%.
                          Второй классификатор оценил вероятность clf1.predict_proba(click) = (0.95, 0.05) — т.е. 5%.

                          Если threshold для классификатора будет на уровне 0.5 (т.е. если вероятность выше 0.5, расценивать как успешный клик), оба классификатора ошибутся, но второй ошибется значительно сильнее.
                          • 0
                            Так, а вы ранжируете классификаторы по сумме ошибок? Лучшие те, у которых сумма ошибок наименьшая (первый вариант)?
                            • 0
                              Ранжирую не по сумме, а по log_loss, но логика именно такая.
                        • 0
                          А есть ли подобный же сервис, но для текстов рекламных кампаний? Ведь тут важна полная связка между источником траффика и сконвертировавшимся пользователем.
                          • 0
                            Смотря что подразумевается под «текстами рекламных кампаний» :- ) Все-таки это достаточно широкое понятие.
                            • 0
                              Разные объявления для одного продукта дают не только разный CTR, но и разную конверсию. Интересно, можно ли ее увязывать с подсовыванием пользователям разных лендингов.
                              • 0
                                В принципе, у меня в одном из тестов была похожая ситуация. Разные объявления содержали разные utm-метки, которые использовались как признак. Но там не было полной связки, т.к. и объявления, и лендинги достаточно общие.
                          • 0
                            Без предварительной обработки предикторов не обойтись, посмотрите код победителей того же кагла. Навскидку помню, что они отсеивают редкие значения категорийных полей, добавляют искусственные поля с историей кликов, и т.п.

                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.