Хабр, привет! Сегодня обсудим, как применять CUPED для повышения чувствительности А/Б тестов. Рассмотрим на простом примере принцип работы CUPED, покажем теоретически за счёт чего снижается дисперсия и приведём пример оценки эксперимента. Обсудим, как выбирать ковариату, как работать с бинарными метриками и что делать при противоречивых результатах.
Меня зовут Коля, я работаю аналитиком данных в X5 Tech. Мы с Сашей продолжаем писать серию статей по А/Б тестированию. Предыдущие статьи можно посмотреть тут:
Стратификация. Как разбиение выборки повышает чувствительность А/Б теста;
Определяем оптимальный размер групп при множественном А/Б тестировании.
CUPED
Повышение чувствительности А/Б тестов делает эксперименты более эффективными. При фиксированных вероятностях ошибок более чувствительный тест находит эффекты меньшего размера или требует меньший объём данных.
CUPED (Controlled-experiment Using Pre-Experiment Data) — техника увеличения чувствительности А/Б тестов за счёт использования данных, полученных ранее.
Продемонстрируем идею, на которой основан CUPED. Допустим, мы проводим эксперимент в онлайн магазине. Хотим что-то изменить на сайте магазина и ожидаем, что средняя выручка с пользователя увеличится. Эксперимент проводим одну неделю. Выбираем случайным образом две группы пользователей. Для пользователей контрольной группы ничего не меняем, для пользователей экспериментальной группы внедряем изменение. После окончания эксперимента считаем суммарную стоимость покупок для каждого пользователя и проверяем гипотезу о равенстве средних с помощью теста Стьюдента. Гипотезу проверяем на уровне значимости 0.05.
Для простоты рассмотрим группы из пяти пользователей. Результаты эксперимента приведены на картинке ниже. Среднее значение суммарной выручки с пользователя в экспериментальной группе больше на 40 рублей. Отличия статистически незначимые, p-value равно 0.87.
Просто применив тест Стьюдента, статистически значимых отличий найти не удалось. Посмотрим на данные о покупках пользователей до эксперимента:
Из графиков видно, что пользователи до эксперимента каждую неделю совершали покупки на одну и ту же стоимость. Стоимость покупок пользователя во время эксперимента равна стоимости его покупок до эксперимента плюс эффект эксперимента. Средние стоимости покупок в группах А и Б до эксперимента в среднем совпадают, так как группы формировались случайно из одного распределения. В таком случае изначальная гипотеза о равенстве средних эквивалентна гипотезе о равенстве приращений во время эксперимента. Приращения для каждого пользователя подписаны на картинке справа от графиков.
Среднее значение приращений во время эксперимента в экспериментальной группе больше на 30 рублей. P-value равно 0.007, отличия статистически значимые. Благодаря переходу от стоимости покупок к приращениям, мы смогли обнаружить эффект. Это произошло из-за того, что точечная оценка эффекта осталась примерно той же, а разброс данных сильно уменьшился. Среднеквадратичное отклонение исходных данных равно 276, а приращений — 19.
Трюк, описанный выше, является частным случаем CUPED. На практике данные не такие хорошие, пользователи не покупают товары на одну и ту же стоимость каждую неделю. Тем не менее, какие-то зависимости в поведении пользователей есть. Эти зависимости позволяют повысить чувствительность тестов с помощью CUPED.
Теория
Ковариата — это метрика, скоррелированная с метрикой эксперимента, которая не зависит от эксперимента. В качестве ковариаты часто берут значения метрики эксперимента, посчитанные на периоде до эксперимента. Обозначим значения метрики эксперимента как Y, а значения ковариаты как X.
Суть метода CUPED состоит в переходе от метрики к метрике , которая вычисляется по формуле:
, где — некоторое действительное число.
Для каждого объекта в контрольной и экспериментальной группе нужно получить значения целевой метрики и ковариаты, и по ним для каждого объекта вычислить значение новой CUPED-метрики. Значения CUPED-метрики контрольной и экспериментальной групп будут подаваться в статистический критерий для проверки гипотезы.
Точечная оценка эффекта вычисляется как разница средних значений метрики в контрольной и экспериментальной группах. Если пользователи по группам распределяются случайно, и ковариата не зависит от эксперимента, то при замене исходной метрики на CUPED-метрику точечная оценка эффекта будет несмещённой.
Чем меньше дисперсия, тем больше чувствительность теста. Посмотрим, как меняется дисперсия оценки среднего при переходе к CUPED-метрике:
Дисперсия зависит от квадратично. Это простая парабола с точкой минимума:
Минимальная дисперсия равна:
Чем сильнее ковариата коррелирует с целевой метрикой, тем сильнее с помощью CUPED можно снизить дисперсию.
Алгоритм применения CUPED:
вычислить значения исходной метрики для каждого пользователя в контрольной и экспериментальной группах;
вычислить значения ковариаты для каждого пользователя в контрольной и экспериментальной группах;
вычислить параметр по формуле ;
для каждого пользователя вычислить CUPED-метрику по формуле ;
применить статистический тест на значениях CUPED-метрики контрольной и экспериментальной групп.
Пример вычислений:
группа |
ковариата |
метрика |
theta |
CUPED-метрика |
0 |
4 |
5 |
0.5 |
3 |
1 |
5 |
7 |
0.5 |
4.5 |
1 |
6 |
8 |
0.5 |
5 |
0 |
7 |
7 |
0.5 |
3.5 |
0 |
8 |
7 |
0.5 |
3 |
stattest([3, 3.5, 3], [4.5, 5])
Практика
Покажем, как применять CUPED в коде. Напишем функции для генерации коррелированных данных, оценки параметра и вычисления p-value. Сгенерируем данные контрольной и экспериментальной групп. Искусственно добавим эффект к данным экспериментальной группы. Применив CUPED, получим значение p-value.
Код
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
def generate_data(sample_size, corr, mean=2000, sigma=300):
"""Генерируем коррелированные данные исходной метрики и ковариаты.
sample_size - размер выборки
corr - корреляция исходной метрики с ковариатой
mean - среднее значение исходной метрики
sigma - стандартное отклонение исходной метрики
return - pd.DataFrame со столбцами ['metric', 'covariate'],
'metric' - значения исходной метрики,
'covariate' - значения ковариаты.
"""
means = np.array([mean, mean])
cov = sigma ** 2 * np.array([[1, corr], [corr, 1]])
data = np.random.multivariate_normal(means, cov, sample_size).astype(int)
df = pd.DataFrame({'metric': data[:, 0], 'covariate': data[:, 1]})
return df
def calculate_theta(metrics, covariates):
"""Вычисляем Theta.
metrics - значения исходной метрики
covariates - значения ковариаты
return - theta.
"""
covariance = np.cov(covariates, metrics)[0, 1]
variance = covariates.var()
theta = covariance / variance
return theta
def check_ttest(df_control, df_pilot):
"""Проверяет гипотезу о равенстве средних с помощью t-test.
return - pvalue.
"""
values_control = df_control['metric'].values
values_pilot = df_pilot['metric'].values
_, pvalue = stats.ttest_ind(values_control, values_pilot)
return pvalue
def check_cuped(df_control, df_pilot, df_theta):
"""Проверяет гипотезу о равенстве средних с использованием CUPED.
df_control и df_pilot - данные контрольной и экспериментальной групп
df_theta - данные для оценки theta
return - pvalue.
"""
theta = calculate_theta(df_theta['metric'], df_theta['covariate'])
metric_cuped_control = df_control['metric'] - theta * df_control['covariate']
metric_cuped_pilot = df_pilot['metric'] - theta * df_pilot['covariate']
_, pvalue = stats.ttest_ind(metric_cuped_control, metric_cuped_pilot)
return pvalue
sample_size = 1000 # размер групп
corr = 0.7 # корреляция ковариаты с целевой метрикой
effect = 20 # размер эффекта
df_control = generate_data(sample_size, corr)
df_pilot = generate_data(sample_size, corr)
df_pilot['metric'] += effect
df_theta = pd.concat([df_control, df_pilot])
pvalue_cuped = check_cuped(df_control, df_pilot, df_theta)
Проверим корректность полученного критерия и сравним его с тестом Стьюдента. Для этого проведём большое количество симуляций оценки экспериментов и построим распределения p-value.
Код
from collections import defaultdict
def plot_pvalue_distribution(dict_pvalues):
"""Рисует графики распределения p-value."""
X = np.linspace(0, 1, 1000)
for key, pvalues in dict_pvalues.items():
Y = [np.mean(pvalues < x) for x in X]
plt.plot(X, Y, label=key)
plt.plot([0, 1], [0, 1], '--k', alpha=0.8)
plt.title('Оценка распределения p-value', size=16)
plt.xlabel('p-value', size=12)
plt.legend(fontsize=12)
plt.grid()
plt.show()
sample_size = 1000
corr = 0.7
effect = 20
dict_pvalues = defaultdict(list)
for _ in range(10000):
df_control = generate_data(sample_size, corr)
df_pilot = generate_data(sample_size, corr)
df_theta = pd.concat([df_control, df_pilot])
dict_pvalues['cuped A/A'].append(check_cuped(df_control, df_pilot, df_theta))
df_pilot['metric'] += effect
df_theta = pd.concat([df_control, df_pilot])
dict_pvalues['cuped A/B'].append(check_cuped(df_control, df_pilot, df_theta))
dict_pvalues['ttest A/B'].append(check_ttest(df_control, df_pilot))
plot_pvalue_distribution(dict_pvalues)
CUPED работает корректно, p-value на А/А тестах распределены равномерно. CUPED имеет большую мощность, чем тест Стьюдента, p-value на А/Б тестах у CUPED выпукло сильнее. Про корректность работы критериев можно прочитать в статье Проверка корректности А/Б тестов.
Легко проверить, что при корреляции ковариаты с целевой метрикой, равной нулю, мощность CUPED совпадает с мощностью теста Стьюдента, а чем сильнее корреляция отличается от нуля, тем мощнее CUPED.
Ковариаты
Самое сложно в использовании CUPED — это выбор ковариаты. От ковариаты зависит, насколько чувствительнее станет критерий. Можно придумать огромное количество вариантов ковариат. Обсудим некоторые из них. Напомним определение.
Ковариата — это метрика, скоррелированная с метрикой эксперимента, которая не зависит от эксперимента.
Скоррелированность влияет на уменьшение дисперсии. Чем сильнее ковариата коррелирует с метрикой, тем сильнее уменьшается дисперсия. Независимость важна, так как иначе критерий может стать некорректным. Например, если взять в качестве ковариаты саму метрику во время эксперимента, то все значения CUPED-метрики в обеих группах будут равны одному и тому же значению, критерий всегда будет говорить, что значимых отличий нет.
В качестве ковариаты часто берут значения метрики эксперимента, посчитанные на периоде до эксперимента. Корреляция ковариаты с метрикой обусловлена сохраняющимся поведением исследуемых объектов в течение времени. Проводимый эксперимент не влияет на события случившиеся до его начала, поэтому такая ковариата не зависит от эксперимента. Главное преимущество этой ковариаты – в её простоте. Легко посчитать, сложно ошибиться.
Чтобы сильнее снизить дисперсию, можно поэкспериментировать с продолжительностью периода для вычисления ковариаты. Например, проверяем гипотезу об изменении средней выручки с покупателя. Если эксперимент идёт неделю, то можно в качестве ковариаты использовать как выручку с пользователя за неделю до эксперимента, так и выручку с пользователя за 4 или 8 недель до эксперимента. Такой подход работает лучше для редких событий, когда пользователи покупают в среднем реже одного раза в неделю.
Если в поведении исследуемых объектов есть сезонность, попробуйте использовать значение метрики в аналогичный период прошлого сезона. Примеры сезонных явлений:
разное количество посетителей в будни и на выходных (недельная сезонность);
увеличение спроса на мандарины и новогоднюю атрибутику в декабре (годовая сезонность);
многие сорта яблонь обильно плодоносят раз в два года.
Можно использовать данные, полученные во время эксперимента, если наше воздействие на них не влияет. Например, это могут быть данные о погоде, если эксперимент не изменяет климат и не приводит к погодным аномалиям.
На картинке ниже проиллюстрировано, в какие периоды времени относительно эксперимента какие метрики считаются:
В качестве ковариаты могут использоваться любые характеристики исследуемых объектов, не зависящие от эксперимента. Для пользователей онлайн-магазина это могут быть количество дней с первой покупки, город проживания, рост, средний балл в аттестате, месяц рождения и так далее. Все эти признаки могут как-то коррелировать с целевой метрикой. Чтобы получить максимальный результат, нужно обучить на этих данных модель машинного обучения, которая будет предсказывать значение метрики эксперимента, и использовать это предсказание как ковариату. Такой подход позволяет сильнее снизить дисперсию, но он сложнее в реализации.
На чём оценивать Theta
В примерах выше мы оценивали параметр на объединённых данных контрольной и экспериментальной групп. Может, лучше оценивать на данных только одной группы или на исторических данных?
Если размеры групп велики и эксперимент не приводит к сильному изменению корреляции ковариаты с целевой метрикой, то все предложенные способы оценки дадут одинаковые результаты.
Рассмотрим случай малых групп. Положим размеры групп равными 5. Увеличим размер эффекта, чтобы он был заметен на таких объёмах данных. Дополнительно будем генерировать данные популяции до эксперимента для оценки на исторических данных. Проведём синтетические А/А и А/Б тесты для четырёх способов оценки и построим распределения p-value.
Код
sample_size = 5
history_size = 10000
corr = 0.7
effect = 300
dict_pvalues = defaultdict(list)
for _ in range(10000):
df_history = generate_data(history_size, corr)
df_control = generate_data(sample_size, corr)
df_pilot = generate_data(sample_size, corr)
df_theta_ab = pd.concat([df_control, df_pilot])
dict_pvalues['theta history, cuped A/A'].append(check_cuped(df_control, df_pilot, df_history))
dict_pvalues['theta A group, cuped A/A'].append(check_cuped(df_control, df_pilot, df_control))
dict_pvalues['theta B group, cuped A/A'].append(check_cuped(df_control, df_pilot, df_pilot))
dict_pvalues['theta A+B groups, cuped A/A'].append(check_cuped(df_control, df_pilot, df_theta_ab))
df_pilot['metric'] += effect
df_theta_ab = pd.concat([df_control, df_pilot])
dict_pvalues['theta history, cuped A/B'].append(check_cuped(df_control, df_pilot, df_history))
dict_pvalues['theta A group, cuped A/B'].append(check_cuped(df_control, df_pilot, df_control))
dict_pvalues['theta B group, cuped A/B'].append(check_cuped(df_control, df_pilot, df_pilot))
dict_pvalues['theta A+B groups, cuped A/B'].append(check_cuped(df_control, df_pilot, df_theta_ab))
plot_pvalue_distribution(dict_pvalues)
При оценке на данных одной группы p-value для синтетических А/А-тестов распределено не равномерно, эти тесты некорректны. Так происходит из-за того, что на малых данных параметр переобучается под данные одной из групп и сильно снижает дисперсию CUPED-метрики в этой группе, случайные отклонения в другой группе с большей вероятностью приводят к ошибке первого рода.
Подход с оценкой параметра на исторических данных имеет большую мощность, чем при оценке на объединении групп. Точность оценки на большом объёме исторических данных точнее, чем оценка по десяти точкам из эксперимента. Это позволяет получить преимущество.
Рассмотрим ситуацию, когда эксперимент приводит к изменению связи ковариаты с целевой метрикой. Вернём размер групп, равный 1000. Проведём численные эксперименты для крайних случаев: корреляция в экспериментальной группе увеличилась до 1 и уменьшилась до -1. Построим распределения p-value и сравним результаты с тестом Стьюдента.
Код
sample_size = 1000
history_size = 10000
corr = 0.7
effect = 20
dict_pvalues = defaultdict(list)
for _ in range(10000):
df_history = generate_data(history_size, corr)
df_control = generate_data(sample_size, corr)
for new_corr in [1, -1]:
df_pilot = generate_data(sample_size, new_corr)
df_pilot['metric'] += effect
df_theta_ab = pd.concat([df_control, df_pilot])
dict_pvalues[f'corr {new_corr}, theta history, cuped A/B'].append(
check_cuped(df_control, df_pilot, df_history)
)
dict_pvalues[f'corr {new_corr}, theta A+B groups, cuped A/B'].append(
check_cuped(df_control, df_pilot, df_theta_ab)
)
dict_pvalues['ttest A/B'].append(check_ttest(df_control, df_pilot))
plot_pvalue_distribution(dict_pvalues)
При увеличении корреляции чувствительность критериев для обоих способов оценки увеличивается, подход с оценкой по историческим данным немного проигрывает в мощности. При уменьшении корреляции до -1 чувствительность критериев уменьшается. Причём, при оценке по историческим данным мощность хуже, чем для теста Стьюдента, а при оценке по данным эксперимента примерно совпадает с мощностью теста Стьюдента.
Итого, если размеры групп достаточно велики, лучше оценивать на объединённых данных контрольной и экспериментальной групп. Если размеры групп малы, то оценка на исторических данных может дать лучшее качество.
Обратим внимание, что для оценки по историческим значениям нужно использовать актуальные данные. С течением времени связь между ковариатой и целевой метрикой может меняться. Например, когда онлайн-магазин только запустился, постоянных клиентов ещё нет, и корреляция метрики во время эксперимента с метрикой до эксперимента, используемой в качестве ковариаты, будет около нуля. Через год появятся постоянные клиенты и корреляция увеличится. Оценив на неактуальных данных, мы получим неверную оценку для текущего момента и не достигнем максимального эффекта от применения CUPED.
Бинарные метрики
Периодически сталкиваемся с вопросом, можно ли использовать CUPED для бинарных метрик? Если нужно повысить чувствительность статистического критерия для проверки гипотезы о равенстве средних значений бинарных метрик, мы не видим причин не применять CUPED. Покажем на примере, что для бинарных метрик CUPED работает.
Изменим функцию генерации данных, чтобы она возвращала данные со значениями 0 и 1. Проведём симуляции экспериментов и построим распределения p-value.
Код
def generate_bin_data(sample_size, corr, mean):
"""Генерируем коррелированные бинарные данные исходной метрики и ковариаты.
return - pd.DataFrame со столбцами ['metric', 'covariate'],
'metric' - значения исходной метрики,
'covariate' - значения ковариаты.
"""
p1 = corr * mean * (1 - mean) + (1 - mean) ** 2
p2 = p3 = 1 - mean - p1
p4 = p1 + 2 * mean - 1
variants = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
samples = np.random.choice([0, 1, 2, 3], size=sample_size, replace=True, p=[p1, p2, p3, p4])
res = variants[samples]
df = pd.DataFrame(variants[samples], columns=['metric', 'covariate'])
return df
sample_size = 1000
corr = 0.7
mean = 0.4
effect = 0.03
dict_pvalues = defaultdict(list)
for _ in range(10000):
df_control = generate_bin_data(sample_size, corr, mean)
df_pilot = generate_bin_data(sample_size, corr, mean)
df_theta = pd.concat([df_control, df_pilot])
dict_pvalues['cuped A/A'].append(check_cuped(df_control, df_pilot, df_theta))
zero_mask = df_pilot['metric'] == 0
p = effect / (1 - mean)
effect_mask = np.random.binomial(1, p, len(df_pilot))
df_pilot.loc[zero_mask & effect_mask, 'metric'] = 1
df_theta = pd.concat([df_control, df_pilot])
dict_pvalues['cuped A/B'].append(check_cuped(df_control, df_pilot, df_theta))
dict_pvalues['ttest A/B'].append(check_ttest(df_control, df_pilot))
plot_pvalue_distribution(dict_pvalues)
CUPED корректно работает для бинарных метрик и имеет большую мощность по сравнению с тестом Стьюдента.
Противоречивые результаты
Представим: оценка с CUPED говорит, что значимых отличий нет, а тест Стьюдента — значимые отличия есть. Возможна ли такая ситуация, или мы допустили ошибку в вычислениях? Что делать, кому верить?
Если критерий F более мощный, чем критерий G, это не значит, что при наличии эффекта критерий F обнаруживает значимые отличия всегда, когда критерий G обнаруживает значимые отличия. При наличии эффекта более мощный критерий в среднем обнаруживает значимые отличия чаще.
Покажем, что применение двух разных критериев, один из которых более мощный, может привести к любому исходу. Проведём 1000 синтетических А/Б тестов и оценим их с помощью CUPED и теста Стьюдента. Построим полученные значения p-value на графике.
Код
sample_size = 1000
corr = 0.7
effect = 20
dict_pvalues = defaultdict(list)
for _ in range(1000):
df_control = generate_data(sample_size, corr)
df_pilot = generate_data(sample_size, corr)
df_pilot['metric'] += effect
df_theta_ab = pd.concat([df_control, df_pilot])
dict_pvalues['cuped'].append(check_cuped(df_control, df_pilot, df_theta_ab))
dict_pvalues['ttest'].append(check_ttest(df_control, df_pilot))
df = pd.DataFrame(dict_pvalues)
df['res_cuped'] = df['cuped'] < 0.05
df['res_ttest'] = df['ttest'] < 0.05
df_eq = df[df['res_cuped'] == df['res_ttest']]
df_neq = df[df['res_cuped'] != df['res_ttest']]
plt.plot(df_eq['ttest'], df_eq['cuped'], 'o', markersize=1)
plt.plot(df_neq['ttest'], df_neq['cuped'], 'ro', markersize=1)
plt.hlines(0.05, 0, 0.3, colors='k')
plt.vlines(0.05, 0, 0.3, colors='k')
plt.xlim(0, 0.3)
plt.ylim(0, 0.3)
plt.xlabel('ttest pvalue', size=12)
plt.ylabel('cuped pvalue', size=12)
plt.title('Распределение pvalue двух критериев', size=16)
plt.show()
На графике выше синими точками отмечены ситуации, когда критерии дают одинаковые ответы о значимости отличий, а красными — разные. Видно, что бывают ситуации, где CUPED ошибается, а тест Стьюдента – нет, и наоборот.
Для оценки эксперимента нужно использовать критерий с лучшими вероятностями ошибок. Оценить вероятности ошибок можно с помощью синтетических тестов на исторических данных. Про синтетические тесты можно почитать в статье Проверка корректности А/Б тестов.
Если вы очень хотели увидеть эффект в эксперименте, но выбранный критерий показывает отсутствие значимых отличий, то, перебирая различные критерии и способы обработки данных, рано или поздно вы найдёте критерий, который покажет значимые отличия. Но такой подход увеличивает вероятности ошибок.
Итоги
Вспомним основные идеи статьи:
CUPED позволяет повышать чувствительность А/Б тестов с помощью дополнительных данных, которые объясняют часть дисперсии целевой метрики.
Снижение дисперсии доказано теоретически.
Если размеры групп достаточно велики, лучше оценивать на объединённых данных контрольной и экспериментальной групп. Если размеры групп малы, то оценка на исторических данных может дать лучшее качество.
CUPED работает для бинарных метрик.
Результаты разных критериев могут быть противоречивы. Нужно использовать критерий с лучшими вероятностями ошибок.
Использованные материалы: Improving the Sensitivity of Online Controlled Experiments by Utilizing Pre-Experiment Data.