Один многорукий бандит против всех А/Б-тестов

img (Image source: UC Berkeley AI course slide, lecture 11.)

Когда хочется добавить в продукт новую фичу, поменять имеющуюся или изменить формулу построения рекомендации, обычно вспоминают про классические А/Б-тесты.

Как они проводятся? Пользователей делят на две группы, придумывают метрику, показывают каждой группе свою версию фичи. Через некоторое время по метрике оценивают, какая версия лучше. Более подробно останавливаться на А/Б-тестах я не буду.

Подход стандартный, более-менее понятный, подробно описан, но не лишен проблем:

Существует другой, менее известный подход, согласно которому можно просто зарелизить фичу, а выбор варианта фичи для показа предоставить алгоритму. Более того, можно даже учитывать изменения предпочтений пользователей со временем.

О чём же речь? Знакомьтесь:

Многорукие бандиты

Идея довольно простая. Представим игральный автомат типа "однорукий бандит": кидаешь монетку, дёргаешь ручку и иногда (с заданной вероятностью, распределением, вероятностным правилом или законом) получаешь выигрыш, большой или не очень, — всё зависит от того, насколько жаден владелец автомата.

Если переформулировать в терминах веб-разработки: когда выпускаешь новый вариант фичи или меняешь формулу ранжирования результатов, ты так же не знаешь, насколько новая фича будет полезна пользователю, и какой вариант фичи, формулы ему показать.

Для чего многорукий бандит в продакшене

Тут всё просто: релизишь новый вариант фичи — у многорукого бандита появляется новая ручка. Каждый раз, когда пользователь обращается за фичей, алгоритм многорукого бандита принимает решение, какой вариант фичи показывать пользователю (или формулу, или какой алгоритм рекомендации применять).

Когда алгоритм многорукого бандита понимает, что тот или иной вариант фичи чаще даёт лучший результат, он начинает показывать его всё чаще. Плохие варианты фичи показываются пользователю всё реже.

В зависимости от постановки задачи может так случиться, что вариант фичи вдруг или со временем начинает нравиться пользователям больше, чем раньше. В таком случае алгоритм должен будет плавно переключиться на использование этого варианта фичи.

Итак, в отличие от А/Б-тестов, применение многоруких бандитов позволяет:

Алгоритм многорукого бандита балансирует между двумя задачами:

Такая дилемма типична для задач из раздела машинного обучения — Обучение с подкреплением (Reinforcement learning).

Среда обитания многорукого бандита

Для того чтобы посмотреть, как работает многорукий бандит, нам нужна модель его "рук". Например, если мы используем многорукое чудище для того, чтобы показывать разные версии сайта. А за "награду" возьмем конверсию — процент посетителей, который остался на нашем сайте после показа конкретной версии сайта.

Мы заранее не знаем, какие факторы могут повлиять на то, что пользователь останется или уйдёт от нас. Но мы можем с некоторой долей уверенности предположить, что таких факторов много.

В такой постановке мы можем воспользоваться Центральной предельной теоремой, а именно — тем фактом, что сумма множества случайных факторов (величин) приводит к появлению нормального распределения.

Тогда мы можем смоделировать выигрыш от использования "руки" многорукого бандита измерением (выборкой) нормально распределённой случайной величины с заданными параметрами дисперсии и матожидания. При этом разными для каждой из рук.

Но на самом деле это всё допущение, никаких гарантий, что распределение в конкретной задаче будет нормальным, нет. Но это не запрещает использовать многоруких бандитов в задаче.

import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
%matplotlib inline
%config InlineBackend.figure_format='retina'
sns.set(style="white", rc={
                           'axes.spines.left': False,
                           'axes.spines.right': False,  
                           'axes.spines.top': False })
rs = np.random.RandomState(2322)
def arm_A():
    return rs.normal(10, 6)
def arm_B():
    return rs.normal(20, 10)
def arm_C():
    return rs.normal(40, 6)
def arm_D():
    return rs.normal(90, 10)
df = pd.DataFrame({
    "Ручка 1" : [arm_A() for x in range(200)],
    "Ручка 2" : [arm_B() for x in range(200)],
    "Ручка 3" : [arm_C() for x in range(200)],
    "Ручка 4" : [arm_D() for x in range(200)]})

ax = df.plot.hist(bins=40, alpha=0.7)
ax.set_title("Распределение наград, полученных от ручек")
plt.ylabel('Частота')
plt.xlabel('Награда');

img

ax = df.plot.line(style='.')
ax.set_title("Результат использования ручек в времени");
plt.xlabel('Время')
plt.ylabel('Награда');

img

Какие модели бандитов бывают?

В зависимости от решаемой задачи существует выбор модели бандита. Среди самых популярных:

Простые алгоритмы

Всё просто и не работает, и не решает задачу. Но это просто крайности. Идём дальше.

ε-greedy (эпсилон "жадный")

Бандит как бы подбрасывает необычную монетку каждый раз, когда нужно принять решение. Монетка отличается от обычной тем, что падает на одну сторону с вероятностью ε, а на другую — с вероятностью 1 - ε.

В зависимости от того, в какую сторону упала монетка, мы принимаем решение, дергаем ту ручку, про которую мы знаем, что она приносит в среднем больше выигрыша, чем все остальные, либо дергаем любую другую ручку и записываем, сколько выигрыша она принесла.

Лучшая ручка выбирается довольно просто, через обыкновенное среднее:

img

где N — число экспериментов, X1,X2 ... Xn — награды за 1-е, 2-е...энное использование ручки.

Заметим, что Жадный и 100% Исследователь получаются, когда ε выставляется в 0.0 и в 1.0.

Откуда взялось название ε-greedy?

"Жадными" в информатике называются алгоритмы, принимающие решение без оглядки на будущее, исходя из текущей ситуации. ε-greedy в данном контексте означает, что с вероятностью ε будет принято "жадное" решение дёрнуть самую лучшую на данный момент ручку, даже если на самом деле лучшая ручка другая, просто другие ручки мы недостаточно исследовали.

Реализация

def do_nth_arm(n):
    arms = {
        0 : arm_A, 
        1 : arm_B,
        2 : arm_C,
        3 : arm_D}
    return arms[n] ();

def egreed_get_best_arm(stats):
    top = 0;
    top_arm = 0
    for arm,arm_rewards in stats.items():
        avg_reward =  (1.0/len(arm_rewards)) * sum(arm_rewards)
        if top < avg_reward:
            top_arm = arm
            top = avg_reward
    return top_arm

def mab_egreed(p,armstats,runs):
    rn = rs.rand();
    arm = 0
    reward = 0
    if rn > p:
        arm = egreed_get_best_arm(armstats)
        reward = do_nth_arm(arm)
    else:
        arm = rs.choice(4)
        reward = do_nth_arm(arm);
    
    armstats[arm].append(reward)
    runs[arm] =  runs.get(arm, 0) + 1
    
    return armstats,runs    
def mab_egreedrun(eps):
    armstats = {x: [y] for x, y in zip(range(4), [0]*4)}  
    runs = {}
    mA,mB,mC,mD = [],[],[],[]
    r = [mA,mB,mC,mD]
    for g in range(80):
        n = 10
        for _ in range(0,n):
            _, runs = mab_egreed(eps,armstats,runs)
    
        [r[x].append(runs.get(x,0)) for x in range(4)]
    return r
def plot_runs(title,mA,mB,mC,mD):
    data = pd.DataFrame({'model_A':mA,
                     'model_B':mB,
                     'model_C':mC,
                     'model_D':mD }, index=range(len(mA)))
 
    data_perc = data.divide(data.sum(axis=1), axis=0)
     
    labels = ["Ручка 1","Ручка 2","Ручка 1","Ручка 4"] 
    
    plt.figure(figsize=(16, 5))
    plt.stackplot(range(len(mA)),  
              data_perc["model_A"],  
              data_perc["model_B"],  
              data_perc["model_C"], 
              data_perc["model_D"],
                  labels=labels)
    plt.legend(loc='upper left')
    plt.box(False)
    plt.yticks([])
    plt.title(title)
    plt.xlabel('Время')
    plt.ylabel('Доли использования ручки');
Попробуем выставить ε = 1.0 (бандит в 100% случаев будет заниматься исследованием).
runs = mab_egreedrun(1.0);
plot_runs("Прогон с ε = 1.0",*runs)

img

Все ручки исследуются в равной пропорции.

Примерно так же может выглядеть проведение А/Б-теста в четырёх вариантах с равным разделением аудитории. Что показывает неэффективность А/Б-тестирования. Многорукий бандит может найти лучшии вариант фичи гораздо быстрее, чем человек примет решение, что будет показано далее.

Попробуем выставить ε = 0.0 — теперь сделаем бандита на 100% жадным.
runs = mab_egreedrun(0.0);
plot_runs("Прогон с ε = 0.0",*runs)

img

Как и ожидалось, выбралась самая первая ручка, дальше исследования не производятся.

Попробуем выставить ε = 0.5. Самая лучшая ручка найдена, но исследования занимают 50% запусков.
runs = mab_egreedrun(0.5);
plot_runs("Прогон с ε = 0.5",*runs)

img

runs = mab_egreedrun(0.3);
plot_runs("Прогон с ε = 0.3",*runs)

img

runs = mab_egreedrun(0.1);
plot_runs("Прогон с ε = 0.1",*runs)

img

Попробуем выставить ε = 0.05.
runs = mab_egreedrun(0.05)
plot_runs("Прогон с ε = 0.05",*runs)

img

Теперь бандит сходится к лучшей ручке медленно, но при этом в конце концов доля исследований фиксированная. Хорошо или это плохо, зависит от задачи: если ручки меняют своё поведение со временем, то данное свойство желаемо.

Выводы по ε-greedy

Несмотря на простоту и вроде бы адекватность алгоритма, у него есть проблемы:

  1. Мы храним значения всех наград, что затратно.
  2. Если поведение ручек меняется со временем, алгоритм не сможет адекватно среагировать — среднее довольно нечувствительно.
  3. После большого числа использования ручек, когда поведение каждой ручки уже понятно, алгоритм продолжает исследование и получает заведомо плохие награды, чего мог бы не делать.

Что можно сделать:

Это уже будут другие варианты алгоритмов.

Идея в том, чтобы уменьшать долю исследований, отразилась в Aлгоритме UCB1.

Оптимизм перед неизвестностью — Алгоритм UCB1

Вообще можно не кидать монетку для того, чтобы выбрать, что же мы хотим делать, а оценить, насколько больше может быть выигрыш. Мы уже несколько раз дёргали ручку и уже имеем оценку T среднего выигрыша с конкретной ручки. Если ручки со временем не меняют своего поведения, то с каждым использованием ручки надежды недооценить должно становиться всё меньше и меньше.

Формула оценки выводится из неравенства Хёфдинга: img

где, g ≥ 0.

В контексте задачи неравенство ограничивает разницу между посчитанным по первым N использованиям конкретной ручки многорукого бандита оценки среднего выигрыша (T) и реального значения среднего E[T] после бесконечного числа попыток.

Вероятность:

img

можно трактовать как вероятность превысить выбранный порог g.

Как выбрать такой порог и как задать вероятность p?

img

img

img

Авторы статьи [2] придумали эвристику: img

Подставим в выражение р и получим формулу из статьи:

img

Так мы можем оценить надежду по улучшению t-й ручки бандита.

Если мы хотим получить большую скорость сходимости и больше исследований на первых шагах работы алгоритма, то мы можем использовать другую эвристику, взяв не -4, а большее число. Но можно поступить проще, введя константу с, — результат тот же с точностью до внесения под корень. img

где c > 0 — константа оптимистичности: чем больше она, тем больше мы оптимистичны, Na — сколько раз обращались к бандиту за решением, Nt — сколько раз обращались к конкретной t-ой ручке бандита.

Если же Nt = 0, т.е. ручку бандита ещё никогда не дёргали, то ручка просто считается приоритетнее всех, уже однажды опробованных.

Теперь можно выбрать ручку, рассчитав формулы для каждой из них, и выбрать ту, что даёт максимум:

img

def ucb1_get_best_arm(stats,total_runs):
    top = 0;
    top_arm = 0
    for arm,arm_rewards in stats.items():
        avg_reward =  (1.0/len(arm_rewards)) * sum(arm_rewards)
        c = 1
        arm_runs = len(arm_rewards)-1
        if (arm_runs == 0):
            return arm
        optimism = c*np.sqrt(2*np.log(total_runs)/arm_runs)
        if top < avg_reward + optimism:
            top_arm = arm
            top = avg_reward
    return top_arm

def mab_ucb1(armstats, runs,total_runs):
    arm = ucb1_get_best_arm(armstats,total_runs)
    reward = do_nth_arm(arm)
    
    armstats[arm].append(reward)
    runs[arm] =  runs.get(arm, 0) + 1
    
    return armstats,runs    


def mab_ucb1_run():
    armstats = {x: [y] for x, y in zip(range(4), [0]*4)}  
    runs = {}
    cnt = 0;
    mA = [];mB = [];mC = [];mD = []
    r = [mA,mB,mC,mD]
    for g in range(80):
        n = 10
        for _ in range(0,n):
            _, runs = mab_ucb1(armstats,runs,cnt)
            cnt += 1
    
        [r[x].append(runs.get(x,0)) for x in range(4)]
    return r,armstats

runs,armstats = mab_ucb1_run()
plot_runs("Алгоритм UCB1",*runs)

img

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

Попробуем воспроизвести такую ситуацию:

# Зафиксируем seed 
rs = np.random.RandomState(2322)
def arm_A():
    return rs.normal(10, 6)
def arm_B():
    return rs.normal(80, 2)
def arm_C():
    return rs.normal(40, 6)
def arm_D():
    return rs.normal(86, 40)
df = pd.DataFrame({
    "Ручка 1" : [arm_A() for x in range(200)],
    "Ручка 2" : [arm_B() for x in range(200)],
    "Ручка 3" : [arm_C() for x in range(200)],
    "Ручка 4" : [arm_D() for x in range(200)]})

df.plot.hist(bins=40, alpha=0.7).set_title("Кейс ручки с большой дисперсией");

img

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

runs,armstats = mab_ucb1_run()
plot_runs("Алгоритм UCB1 - Сходится уже не всегда",*runs)

img

Что можно с этим сделать?

Больше исследовать, настроить константу c — нам нужно больше оптимизма.

Выводы по UCB1

Что дальше?

Можно ли сделать ещё лучше?

Да, если мы в какой-то степени знаем свойства ручек заранее.

Другим словами, у нас должны быть априорные знания, а в случае распределений — априорные распределения. Подход, в котором вероятность рассматривается как степень незнания, называется Байесовским.

Используя формулу Байеса, мы можем учитывать полученные выигрыши, тем самым уточняя знания о руках многорукого бандита. Мы также можем ограничиться конкретным видом распределения, это тоже априорное знание.

Но это повод для другой статьи.

Выводы

В статье я старался показать, что автоматический подход:

Я надеюсь, статья послужит мотиватором в использовании многоруких бандитов в продуктовой практике как замена А/Б-тестам.

Литература

[1] The Multi-Armed Bandit Problem and Its Solutions.

[2] P. Auer, N. Cesa-Bianchi, and P. Fischer.Finite-time analysis of the multiarmed bandit problem.Machine learning, 47(2):235–256, 2002