Pull to refresh

Расчёт каннибализации на основе классического A/B-теста и метод bootstrap’а

Reading time12 min
Views15K
В данной статье рассмотрен метод расчёта каннибализации для мобильного приложения на основе классического A/B-теста. В данном случае рассматриваются и оцениваются целевые действия в рамках процесса реаттрибуции с рекламного источника (Direct, Criteo, AdWords UAC и прочих) по сравнению с целевыми действиями в группе, на которую реклама была отключена.

В статье дан обзор классических методик сравнения независимых выборок с кратким теоретическим базисом и описанием примененных библиотек, в т.ч. вкратце описывается суть метода bootstrap’а и его реализация в библиотеке FaceBook Bootstrapped, а также проблемы, возникающие на практике при применении этих методик, и способы их решения.

Фактические данные либо обфусцированы, либо не приводятся с целью сохранения none- disclosure agreement.

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

Введение


Каннибализация – процесс перетекания траффика, полного и целевого, из одного канала в другой.

Маркетологи обычно используют данный показатель как дополнительный коэффициент К при рассчёте CPA: предрассчитанный CPA домножается на 1+K. Под CPA в данном случае подразумеваются суммарные траты на привлечение траффика/количество целевых действий, монетизируемых прямо, то есть принесших фактический профит – например, целевой звонок, и/или монетизируемых косвенно – например, увеличение объема базы объявлений, рост аудитории и так далее.

Когда бесплатные каналы (например, заходы с органической выдачи SERP’ов, переходы по ссылкам на сайтах, размещение на которых для нас является бесплатным) каннибализируются платными (Direct, Adwords вместо органики, реклама в feed’ах социальных сетей вместо переходов по объявлениям, бесплатно размещенным в группах, и так далее), это несет с собой риски финансовых потерь, поэтому важно знать коэффициент каннибализации.

В нашем случае стояла задача расчёта каннибализации «органических» переходов в приложение переходами из рекламной сети Criteo. Наблюдением считается устройство или пользователь-uid (GAID/ADVID и IDFA).

Подготовка эксперимента


Можно подготовить аудиторию для эксперимента, разбив в интерфейсе аналитической системы AdJust пользователей на группы для вычленения тех, которые будут видеть рекламу из определённой рекламной сети (контрольная выборка), и тех, кому реклама не будет показываться, используя соответственно в дальнейшем GAID или ADVID и IDFA (AdJust предоставляет API Audience Builder). Далее на контрольную выборку можно включить рекламную кампанию в исследуемой в эксперименте рекламной сети.

Отмечу от себя, что, как интуитивно кажется, более грамотной в данном случае была бы следующая реализация эксперимента: выбрать четыре группы – тех, у кого был отключен retargeting со всех каналов (1), в качестве экспериментальной группы, и тех, у кого был включен только retargeting с Criteo (2); тех, у кого был отключен только retargeting с Criteo (3), тех, у кого был включен весь retargeting (4). Тогда можно было бы рассчитать (1)/(2), получив фактическое значение каннибализации рекламными кампаниями сети Criteo «органических» переходов в приложение, и (3)/(4), получив каннибализацию Criteo в «естественной» среде (ведь Criteo, очевидно, может каннибализировать и другие платные каналы). Этот же эксперимент следовало бы повторить и для других рекламных сетей, чтобы выяснить impact каждой из них; в идеальном мире хорошо бы исследовать ещё и cross-каннибализацию между всеми ключевыми платными источниками, составляющими наибольшую долю в общем траффике, но это потребовало бы столько времени (как для подготовки экспериментов с точки зрения разработки, так и для оценки результатов), что вызвало бы критику за необоснованную дотошность.

Фактически наш эксперимент осуществлялся в условиях (3) и (4), выборки были разбиты в соотношении 10% к 90%, эксперимент проводился 2 недели.

Предподготовка и верификация данных


Перед началом любого исследования важным этапом является грамотная предподготовка и очистка данных.

Следует отметить, что фактически активных устройств за период эксперимента было в 2 раза меньше (42.5% и 50% от контрольной и экспериментальной групп, соответственно), чем устройств в полных исходных выборках, что объясняется природой данных:

  1. во-первых (и это ключевая причина), в выборке для retargeting’а из Adjust’а содержатся идентификаторы всех устройств, когда-либо устанавливавших приложение, то есть и тех устройств, которые уже не используются, и тех, с которых приложение уже было удалено,
  2. во-вторых, необязательно, что со всех устройств за время эксперимента осуществлялся вход в приложение.

Однако расчёт каннибализации мы осуществляли на основе данных из полной выборки. Лично для меня корректность такого рассчёта всё ещё представляется спорным вопросом – в целом, на мой взгляд, корректнее вычищать всех, кто удалил приложение и более его не устанавливал по соответствующим tag’ам, а также тех, кто не заходил в приложение более года – за этот период времени пользователь мог сменить устройство; минус – таким образом для эксперимента могут быть убраны из выборки ещё и те пользователи, которые не переходили в приложение, но могли бы это сделать, покажи мы им рекламу в сети Criteo. Хочу отметить, что в хорошем мире все эти вынужденных пренебрежения и допущения следовало бы исследовать и проверить отдельно, но мы живём в мире do it fast and furry.

В нашем случае важно проверить следующие пункты:

  1. Проверяем наличие пересечения в наших исходных выборках — экспериментальной и контрольной. В правильно реализованном эксперименте таких пересечений быть не должно, однако в нашем случае нашлось несколько дубликатов из экспериментальной выборке в контрольной. В нашем случае доля этих дубликатов в общем объеме устройств, задействованных в эксперименте, была невелика поэтому мы пренебрегли данным условием. Если бы дубликатов было > 1%, следовало бы считать эксперимент некорректным и провести повторный экспермент, предварительно вычистив дубли.
  2. Проверяем, что данные в эксперименте действительно подвергались воздействию — в экспериментальной выборке должен был быть отключен retargeting (как минимум с Criteo, в корректно поставленном эксперименте — со всех каналов), поэтому необходимо осуществить проверку на отсутствие DeviceID из эксперимента в retargeting'е с Criteo. В нашем случае DeviceID из экспериментальной группы всё же попадали в retargeting, но их было менее 1%, что пренебрежимо мало.

Непосредственная оценка эксперимента


Мы будем рассматривать изменение следующих целевых метрик: абсолютной – количество звонков, и относительной — количество звонков на пользователя в контрольной (видели рекламу в сети Criteo) и экспериментальной (реклама была отключена) группах. В приводимом ниже коде под переменной data понимается структура pandas.DataFrame, которая формируется из результатов экспериментальной или контрольной выборки.

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

1. Исследование распределений значений в выборках на нормальность


Первый шаг – исследовать существующие выборки на вид распределения значений и равенство дисперсий выборок при помощи стандартных тестов – критериев Колмогорова-Смирнова и Шапиро-Уилкса и критерия Бартлетта, реализованных в библиотеке sklearn.stats, приняв p-value = 0.05:

# Проверка на нормальность распределения:
def norm_test(df, pvalue = 0.05, test_name = 'kstest'):
	if test_name == 'kstest':
st = stats.kstest(df, 'norm') 
	if test_name == 'shapiro':
		st = stats.shapiro(df)
	sys.stdout.write('According to {} {} is {}normal\n'.format(test_name, df.name, {True:'NOT ', False:''}[st[1] < pvalue])) 

# Проверка на равенство дисперсий:
def barlett_test(df1, df2, pvalue = 0.05):
    st = stats.bartlett(df1, df2)
    sys.stdout.write('Variances of {} and {} is {}equals\n'.format(df1.name, df2.name, {True:'NOT ', False:''}[st[1] < pvalue]))

Дополнительно для визуальной оценки результатов можно воспользоваться функцией построения гистограмм.

data_agg = data.groupby(['bucket']).aggregate({'device_id': 'nunique', 'calls': 'sum'}).fillna(0)
data_conv = data_agg['calls_auto']/data_agg['device_id']
data_conv.hist(bins=20)

image

Читать гистограмму можно так: 10 раз в выборке встретилась конверсионность 0.08, 1 – 0.14. О количестве устройств как наблюдений для какого-либо из конверсионных показателей это ничего не говорит.

В нашем случае распределение значения параметра как в абсолютных значениях, так и в относительных (количество звонков на устройство) в выборках не является нормальным.
В таком случае можно применить либо непараметрический критерий Вилкоксона, реализованный в стандартной библиотеке sklearn.stats, либо попробовать привести распределения значений в выборках к нормальному виду и применить один из параметрических критериев — критерий Стьюдента aka t-test или Шапиро-Уилкса.

2. Методы приведения распределения значений в выборках к нормальному виду


2.1. Sub-buckets

Одним из подходов к приведению распределения к нормальному виду является метод sub-bucket’ов. Суть его проста, а теоретическую основу составляет следующий математический тезис: согласно классической центральной предельной теореме распределение средних стремится к нормальному – сумма n независимых одинаково распределённых случайных величин имеет распределение, близкое к нормальному, и, эквивалентно, распределение выборочных средних первых n независимых одинаково распределённых случайных величин стремится к нормальному. Поэтому можно разбить существующие bucket’ы на sub-bucket’ы и, соответственно, взяв средние значения по sub-bucket’ам для каждого из bucket’ов, мы можем получить близкое к нормальному распределение:

# Разделили по subbucket'ам
data['subbucket'] = data['device_id'].apply(lambda x: randint(0,1000)) # Variant 1
data['subbucket'] = data['device_id'].apply(lambda x: hash(x)%1000) # Variant 2

Вариантов разбиения может быть множество, всё зависит от фантазии и нравственных устоев разработчика – можно взять честный random или использовать hash от исходного bucket’а, тем самым учтя в схеме механизм его выдачи.

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

Кроме того, отношение целевых действий и пользователей к общему числу действий и пользователей в sub-bucket’ах может быть не консистентно исходным backet’ам, поэтому необходимо предварительно проверить, что соотношение сохраняется.

data[data['calls'] > 0].device_id.nunique()/data.device_id.nunique() # Total

buckets = data.groupby(['bucket']).aggregate({'device_id': 'nunique', 'calls': 'sum'})
buckets[buckets['calls'] > 0].device_id.nunique()/buckets.device_id.nunique() # Buckets

subbuckets = data.groupby(['subbucket']).aggregate({'device_id': 'nunique', 'calls': 'sum'}) subbuckets[subbuckets['calls'] > 0].device_id.nunique()/subbuckets.device_id.nunique() # Subbuckets

В процессе такой проверки мы выяснили, что конверсионные соотношения для subbucket’ов относительно исходной выборки не сохраняются. Поскольку нам необходимо дополнительно гарантировать консистентность соотношения доли звонков в выходных и исходных выборках, мы применяем балансировку классов, добавив взвешенность – чтобы данные отдельно выбирались по подгруппам: отдельно из наблюдений с целевыми действиями и отдельно из наблюдений без целевых действий в нужной пропорции. Кроме того, в нашем случае выборки были разбиты неравномерно; интуитивно кажется, что среднее при этом измениться не должно, а вот как неравномерность выборок влияет на дисперсию, неочевидно из формулы дисперсии. Для того, чтобы уточнить, влияет ли разница в размерах выборок на результат, используется критерий Xi-квадрат – если выявлена статистически значимая разница, то будет произведено sample’ирование большего data-frame’а по размеру меньшего:

def class_arrays_balancer(df1, df2, target = 'calls', pvalue=0.05):
    df1_target_size = len(df1[df1[target] > 0])
    print(df1.columns.to_list())
    df2_target_size = len(df2[df2[target] > 0])
    total_target_size = df1_target_size + df2_target_size
    chi2_target, pvalue_target, dof_target, expected_target = chi2_contingency([[df1_target_size, total_target_size], [df2_target_size, total_target_size]])
    
    df1_other_size = len(df1[df1[target] == 0])
    df2_other_size = len(df1[df1[target] == 0])
    total_other_size = df1_other_size + df2_other_size
    chi2_other, pvalue_other, dof_other, expected_other = chi2_contingency([[df1_other_size, total_other_size], [df2_other_size, total_other_size]])
    
    df1_target, df2_target, df1_other, df2_other = None, None, None, None

   if pvalue_target < pvalue:
        sample_size = min([df1_target_size, df2_target_size])
        df1_rnd_indx = np.random.choice(df1_target_size, size=sample_size, replace=False)
        df2_rnd_indx = np.random.choice(df2_target_size, size=sample_size, replace=False)
        df1_target = pd.DataFrame((np.asarray(df1[df1[target] == 1])[df1_rnd_indx]).tolist(), columns = df1.columns.tolist())
        df2_target = pd.DataFrame((np.asarray(df2[df2[target] == 1])[df2_rnd_indx]).tolist(), columns = df2.columns.tolist())
    
    if p_value_other < pvalue:
        sample_size = min([df1_other_size, df2_other_size])
        df1_rnd_indx = np.random.choice(df1_other_size, size=sample_size, replace=False)
        df2_rnd_indx = np.random.choice(df2_other_size, size=sample_size, replace=False)
        df1_other = pd.DataFrame((np.asarray(df1[df1[target] == 0])[df1_rnd_indx]).tolist(), columns = df1.columns.tolist())
        df2_other = pd.DataFrame((np.asarray(df2[df2[target] == 0])[df2_rnd_indx]).tolist(), columns = df2.columns.tolist())
        
    df1 = pd.concat([df1_target, df1_other])
    df2 = pd.concat([df2_target, df2_other])
        
    return df1, df2

exp_classes, control_classes = class_arrays_balancer(data_exp, data_control)

На выходе получаем сбалансированные по размеру и консистентные исходным по конверсионным соотношениям данные, исследуемые метрики (рассчитанные для средних значений по sub-bucket’ам) в которых уже распределены нормально, что видно как визуально, так и по результатам применения уже известных нам критериев-тестов на нормальность (при p-value >= 0.05). Например, для относительных показателей:

data_conv = (data[data['calls'] > 0].groupby(['subbucket']).calls.sum()*1.0/data.groupby(['subbucket']).device_id.nunique())
data_conv.hist(bins = 50)

Теперь к средним по sub-bucket’ам можно применить t-test (таким образом, в качестве наблюдения выступает уже не device_id, не устройство, а sub-bucket).

Убедившись, что изменения являются статистически значимыми, можно с чистой совестью провести то, ради чего мы всё это затеяли – расчёт каннибализации:

(data_exp.groupby(['subbucket']).calls.avg() - data_cntrl.groupby(['subbucket']).calls.avg() )/ data_exp.groupby(['subbucket']).calls.avg()

В знаменателе должен быть траффик без рекламы, то есть экспериментальный.

3. Метод Bootstrap’а


Метод bootstrap’а является расширением метода sub-bucket’ов и представляет собой его более продвинутый и усовершенствованный вариант; программную реализацию этого метода на языке Python можно найти в библиотеке Facebook Bootstrapped.
Кратко идею bootstrap'а можно описать следующим образом: метод представляет собой не что иное, как конструктор выборок, формируемых аналогично методики sub-bucket’ов случайным образом, но с возможными повторениями. Можно сказать, размещения из генеральной совокупности (если таковой можно назвать исходную выборку) с возвращением. На выходе формируются средние (или медианы, суммы, etc.) от средних для каждой из сформированных подвыборок.

Основные методы библиотеки FaceBook Bootstrap:
bootstrap()
– реализует механизм формирования подвыборок; по умолчанию возвращает lower bound (5 перцентиль) и upper bound (95 перцентиль); чтобы вернуть дискретное распределение в этом диапазоне, необходимо установить параметр return_distribution = True (его генерирует вспомогательная функция generate_distributions()).

Можно задать число итераций при помощи параметра num_iterations, в которых будет осуществляться формирование подвыборок, и количество подвыборок iteration_batch_size для каждой итерации. На выходе generate_distributions() будет сформирована выборка размером, равным числу итераций num_iterations, элементы которой будут представлять собой среднее от значений выборок iteration_batch_size, рассчитанных на каждой итерации. При больших объемах выборок данные могут перестать влезать в память, поэтому в таких случаях желательно уменьшать значениие iteration_batch_size.

Пример: пусть исходная выборка имеет размер 2 000 000; num_iterations = 10 000, iteration_batch_size = 300. Тогда на каждой из 10 000 итераций в памяти будет храниться 300 списков по 2 000 000 элементов.

Функция также позволяет производить параллельные вычисления на нескольких ядрах процессора, на нескольких thread’ах, устанавливая необходимое число при помощи параметра num_threads.

bootstrap_ab()

производит все те же действия, что и описанная выше функция bootstrap(), однако дополнительно также производится агрегация средних значений методом, указанным в stat_func – от значений num_iterations. Далее рассчитывается метрика, указанная в параметре compare_func, и производится оценка статистической значимости.

compare_functions

– класс функций, предоставляющий инструментарий для формирования метрик для оценки:
compare_functions.difference()
compare_functions.percent_change()
compare_functions.ratio()
compare_functions.percent_difference()

# difference = (test_stat - ctrl_stat)
# percent_change = (test_stat - ctrl_stat) * 100.0 / ctrl_stat
# ratio = test_stat / ctrl_stat
# percent_difference = (test_stat - ctrl_stat) / ((test_stat + ctrl_stat) / 2.0) * 100.0

stats_functions
– класс функций, из которого выбирается способ агрегации исследуемой метрики:
stats_functions.mean
stats_functions.sum 
stats_functions.median
stats_functions.std

В качестве stat_func можно применять и custom’ную пользовательскую функцию, например:

def test_func(test_stat, ctrl_stat):
    return (test_stat - ctrl_stat)/test_stat

bs.bootstrap_ab(test.values, control.values, 
                                   stats_functions.mean, 
                                   test_func, num_iterations=5000, alpha=0.05, 
                                   iteration_batch_size=100, scale_test_by=1, num_threads=4)

По сути, (test_stat — ctrl_stat)/test_stat и есть формула для расчёта нашей каннибализации.

В качестве альтернативы или с целью практичекого эксперимента можно первоначально получить распределения при помощи bootstrap(), проверить статистическую значимость различий целевых метрик при помощи t-test’а и затем применить к ним необходимые манипуляции.
Пример того, насколько «качественное» нормальное распределение можно получить при помощи этого метода:



Более подробную документацию можно прочитать на странице репозитория.

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

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

Best wishes to everyone! :)

P.S. Уважаемый канал Чемпионатов, задача оценки результатов A/B-тестирования — одна из самых важных в Data Science, ведь не один запуск новой ML-модели в production не обходится без A/B. Может быть, настало время организовать конкурс по разработке системы оценки результатов A/B-тестирования? :)
Tags:
Hubs:
Total votes 7: ↑6 and ↓1+5
Comments4

Articles