CUPED и стратификация — два метода повышения чувствительности А/Б тестов. При первом знакомстве с ними часто возникают вопросы. В чём их отличие? Кто из них лучше? Чем пользоваться? Разберёмся с этими вопросами на примерах.
Меня зовут Коля, я работаю аналитиком данных в X5 Tech. Мы с Сашей продолжаем писать серию статей по А/Б тестированию. Это наша восьмая статья, предыдущие статьи можно найти в описании профиля.
В предыдущих статьях мы рассмотрели два способа повышения чувствительности оценки экспериментов: CUPED и стратификация. Оба метода используют дополнительную информация об исследуемых объектах. Напомним их основные принципы.
Для CUPED нужна ковариата. Ковариата — это метрика, скоррелированная с метрикой эксперимента, которая не зависит от эксперимента. Чем сильнее корреляция, тем сильнее снижается дисперсия. В качестве ковариаты часто берут значения метрики эксперимента, посчитанные на периоде до эксперимента.
Для стратификации нужно разбиение объектов на страты. Чем сильнее отличаются средние значения метрики между стратами, тем сильнее снижается дисперсия. В экспериментах с пользователями онлайн-магазина признаками для разбиения на страты могут быть: пол, возраст, город проживания, факт регистрации в программе лояльности, операционная система устройства пользователя.
Как выбрать, какой метод повышения чувствительности использовать? Дают ли они одинаковый прирост качества? Можно ли использовать их вместе? Нужно ли использовать их вместе? Мы постараемся ответить на эти вопросы, рассмотрев ряд модельных ситуации.
Генерация данных и реализации методов
В этом разделе собрали реализации функций генерации данных и методов повышения чувствительности, которые будут использоваться далее.
Данные генерируются так, что корреляция метрики до эксперимента с метрикой во время эксперимента равна примерно 0.87. Есть три страты, их доли в популяции равны 1/3. Средние значения метрики в стратах составляют 2000, 2300 и 3200.
Импорты и генерация данных
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from collections import defaultdict
from scipy import stats
def generate_data(
sample_size=1000,
effect=0,
min_base=1000,
max_base=3000,
delta_strat=300,
noise_std=300,
):
"""Генерирует данные контрольной и экспериментальной групп.
Случайным образом присваиваем пользователям одну из трёх страт (S) и базовое
значение метрики из равномерного распределения U[min_base, max_base] (B).
Значение метрики генерируем как B + delta_strat * S**2 + noise, где noise ~ N(0, noise_std).
Для данных из экспериментальной группы во время эксперимента добавляем effect.
:param sample_size (int): размер групп
:param effect (float): размер эффекта, добавляется к экспериментальной группе во время эксперимента
:param min_base (float): минимальное базовое значение метрики пользователей
:param max_base (float): максимальное базовое значение метрики пользователей
:param delta_strat (float): разница средних между стратами
:param noise_std (float): стандартное отклонение шума из нормального распределения
:return df (pd.DataFrame): датафрейм с информацией о пользователях.
Столбцы датафрейма:
- group - группа: 0 - контрольная, 1 - экспериментальная
- strat - страта 0, 1 или 2
- value - значение метрики во время эксперимента
- value_before - значение метрики до эксперимента
"""
strat = np.random.randint(0, 3, sample_size*2)
base = np.random.uniform(min_base, max_base, sample_size*2)
base_with_strat = base + strat ** 2 * delta_strat
noises = np.random.normal(0, noise_std, (2, sample_size*2))
group = np.hstack([np.zeros(sample_size), np.ones(sample_size)])
df = pd.DataFrame({
'group': group,
'strat': strat,
'value': base_with_strat + noises[0] + effect * group,
'value_before': base_with_strat + noises[1],
}).astype(int)
return df
Тест Стьюдента
def check_ttest(a, b, *args):
"""Возвращает pvalue теста Стьюдента.
:param a (pd.DataFrame): данные пользователей контрольной группы
:param b (pd.DataFrame): данные пользователей экспериментальной группы
:return pvalue (float): pvalue
"""
a_values = a['value'].values
b_values = b['value'].values
pvalue = stats.ttest_ind(a_values, b_values).pvalue
return pvalue
CUPED
def calculate_theta(y_control, y_pilot, x_control, x_pilot):
"""Вычисляем Theta по данным двух групп.
:param y_control (np.array): значения метрики во время пилота на контрольной группе
:param y_pilot (np.array): значения метрики во время пилота на пилотной группе
:param x_control (np.array): значения ковариант на контрольной группе
:param x_pilot (np.array): значения ковариант на пилотной группе
:return theta (float): theta
"""
y = np.hstack([y_control, y_pilot])
x = np.hstack([x_control, x_pilot])
covariance = np.cov(x, y, ddof=1)[0, 1]
variance = x.var(ddof=1)
theta = covariance / variance
return theta
def check_cuped(a, b, *args):
"""Возвращает pvalue теста Стьюдента с использованием CUPED.
:param a (pd.DataFrame): данные пользователей контрольной группы
:param b (pd.DataFrame): данные пользователей экспериментальной группы
:return pvalue (float): pvalue
"""
theta = calculate_theta(a['value'], b['value'], a['covariate'], b['covariate'])
a_cuped_values = a['value'] - theta * a['covariate']
b_cuped_values = b['value'] - theta * b['covariate']
pvalue = stats.ttest_ind(a_cuped_values, b_cuped_values).pvalue
return pvalue
Стратификация
def calc_strat_mean(df, weights):
"""Считает стратифицированное среднее.
:param df (pd.DataFrame): датафрейм с целевой метрикой и данными для стратификации
:param weights (pd.Series): маппинг {название страты: вес страты в популяции}
:return strat_mean (float): стратифицированное среднее
"""
strat_mean = df.groupby('strat')['value'].mean()
return (strat_mean * weights).sum()
def calc_strat_var(df, weights):
"""Считает стратифицированную дисперсию.
:param df (pd.DataFrame): датафрейм с целевой метрикой и данными для стратификации
:param weights (pd.Series): маппинг {название страты: вес страты в популяции}
:return strat_var (float): стратифицированное среднее
"""
strat_var = df.groupby('strat')['value'].var(ddof=1)
return (strat_var * weights).sum() + ((1-weights) * strat_var).sum() / len(df)
def check_strat(a, b, weights):
"""Возвращает pvalue теста Стьюдента для стратифицированного среднего.
:param a (pd.DataFrame): данные пользователей контрольной группы
:param b (pd.DataFrame): данные пользователей экспериментальной группы
:param weights (pd.Series): маппинг {название страты: вес страты в популяции}
:return pvalue (float): pvalue
"""
a_strat_mean = calc_strat_mean(a, weights)
b_strat_mean = calc_strat_mean(b, weights)
a_strat_var = calc_strat_var(a, weights)
b_strat_var = calc_strat_var(b, weights)
delta = b_strat_mean - a_strat_mean
std = (a_strat_var / len(a) + b_strat_var / len(b)) ** 0.5
t = delta / std
df = (
(a_strat_var / len(a) + b_strat_var / len(b)) ** 2
/ ((a_strat_var / len(a))**2 / (len(a)-1) + (b_strat_var / len(b))**2 / (len(b)-1))
)
pvalue = 2 * (1 - stats.t.cdf(np.abs(t), df=df))
return pvalue
CUPED и Стратификация
def check_cuped_strat(a, b, weights):
"""Возвращает pvalue теста Стьюдента с использованием CUPED и стратификации.
:param a (pd.DataFrame): данные пользователей контрольной группы
:param b (pd.DataFrame): данные пользователей экспериментальной группы
:param weights (pd.Series): маппинг {название страты: вес страты в популяции}
:return pvalue (float): pvalue
"""
theta = calculate_theta(a['value'], b['value'], a['covariate'], b['covariate'])
a = a.copy()
b = b.copy()
a['value'] = a['value'] - theta * a['covariate']
b['value'] = b['value'] - theta * b['covariate']
pvalue = check_strat(a, b, weights)
return pvalue
Распределение pvalue
def plot_pvalue_distribution(dict_pvalues):
"""Рисует графики распределения pvalue."""
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()
def show_part_positive_ci(dict_pvalues, alpha=0.05):
"""Выводит точечные оценки и доверительные интервалы доли прокрасившихся экспериментов."""
for key, pvalues in dict_pvalues.items():
values = np.array(pvalues) < alpha
pe, std = np.mean(values), np.std(values)
mean_std = std / len(values) ** 0.5
left, right = pe - 1.96 * mean_std, pe + 1.96 * mean_std
print(f'{key:<11}: pe={pe:0.3f}, ci=[{left:0.3f}, {right:0.3f}]')
def describe_pvalues(dict_pvalues):
"""Выводит описание распределения pvalue."""
show_part_positive_ci(dict_pvalues)
plot_pvalue_distribution(dict_pvalues)
Кейс 1: только страты
Допустим, онлайн-магазин меняет дизайн сайта с целью увеличить среднюю выручку с покупателя. Данные пользователей считаем независимыми. Проверяем гипотезу о равенстве средних. Знаем только страты пользователей. Пусть это будут города. Покупатели в разных городах в среднем покупают на разные суммы: где-то больше, где-то меньше.
Понятно, как применить стратификацию, зная страты и их веса в популяции. На самом деле, мы будем использовать постстратификацию, так как выборки генерируются не стратифицированные. Но для краткости будем называть этот подход просто стратификацией.
Для применения CUPED нужна ковариата. Знаем только страты пользователей, участвующих в эксперименте. Положим, что знаем также страты и исторические данные других пользователей, не участвующих в эксперименте. Легко показать, что наибольшей корреляцией с метрикой эксперимента будет обладать точечная оценка среднего значения метрики в страте. Оценим средние страт по историческим данным и используем их в качестве ковариаты.
Определившись со стратами и ковариатой, можно применить стратификацию и CUPED вместе. Преобразуем метрику эксперимента в cuped-метрику и применим к новой метрике стратификацию.
Проведём численные А/Б эксперименты, оценим мощности критериев и построим распределения pvalue. Не будем показывать результаты А/А экспериментов, все критерии на А/А экспериментах работаю корректно. Можно это проверить, запустив тот же код, установив значение переменной effect = 0
. Подробнее про проверку корректности А/Б тестов можно прочитать в статье.
Код
def generate_data_one(effect=0):
"""Генерирует данные, у которых знаем только страты.
В качестве ковариаты используется среднее значение метрики страты.
"""
df = generate_data(effect=effect)
df_agg = (
df.groupby('strat')[['value_before']].mean()
.rename(columns={'value_before': 'covariate'})
.reset_index()
)
df = pd.merge(df, df_agg, on='strat')
a, b = [df[df['group'] == group].copy() for group in [0, 1]]
return a, b
DICT_STATTESTS = {
'ttest': check_ttest,
'cuped': check_cuped,
'strat': check_strat,
'cuped_strat': check_cuped_strat,
}
WEIGHTS = pd.Series({0: 1/3, 1: 1/3, 2: 1/3})
EFFECT = 50
dict_stattests = DICT_STATTESTS
weights = WEIGHTS
effect = EFFECT
dict_pvalues = defaultdict(list)
for _ in range(10000):
a, b = generate_data_one(effect)
for test_name, stattests in dict_stattests.items():
pvalue = stattests(a, b, weights)
dict_pvalues[test_name].append(pvalue)
describe_pvalues(dict_pvalues)
ttest : pe=0.278, ci=[0.269, 0.286]
cuped : pe=0.409, ci=[0.399, 0.418]
strat : pe=0.408, ci=[0.399, 0.418]
cuped_strat: pe=0.408, ci=[0.399, 0.418]
Получили график оценки распределения pvalue и точечные оценки с доверительными интервалами для мощности четырёх критериев: тест Стьюдента (ttest), CUPED (cuped), стратификация (strat), CUPED+стратификация (cuped_strat).
Все способы повышения чувствительности работают лучше теста Стьюдента и показывают примерно одинаковую мощность. Получается, если знаем только страты и можем оценить средние значения метрики в стратах, то можно пользоваться как стратификацией, так и CUPED. Чувствительность будет одинаковая.
Кейс 2: только метрика перед экспериментом
Теперь знаем только значение метрики до эксперимента. Есть готовая ковариата, которую можно использовать для CUPED.
Как определить страты? Самая простая идея — объединить пользователей с одним значением ковариаты. Купивших на 100 рублей в одну страту, на 101 рубль в другую и так далее. Однако, если в какой-либо страте окажется один пользователь, то такой подход не позволит применить стратификацию. Мы не можем оценить дисперсию по одному значению.
Можно разбить пользователей по квантилям ковариаты. Например, чтобы получить пять страт, нужно 20% пользователей с наименьшим значением ковариаты определить в одну страту, следующие 20% – в другую и так далее.
Проведём численные эксперименты со стратификацией для разбиений на 2, 5 и 50 страт.
Код
def generate_data_two(effect, list_n_strat):
"""Генерирует данные, у которых знаем только исторические значения.
Пользователи объединяются в страты по близости значений метрики до эксперимента.
В датафреймы добавляются столбцы для разных разбиений с названиями f'strat_{n_strat}'.
list_n_strat - список n_strat. n_strat - количество страт.
"""
df = generate_data(effect=effect)
df['covariate'] = df['value_before']
df.sort_values('value_before', inplace=True)
for n_strat in list_n_strat:
df[f'strat_{n_strat}'] = np.arange(len(df)) // (len(df) // n_strat)
a, b = [df[df['group'] == group].copy() for group in [0, 1]]
return a, b
dict_stattests = {'ttest': check_ttest, 'cuped': check_cuped, 'strat': check_strat}
effect = EFFECT
list_n_strat = [2, 5, 50]
dict_pvalues = defaultdict(list)
for _ in range(10000):
a, b = generate_data_two(effect, list_n_strat)
for test_name, stattest in dict_stattests.items():
if test_name == 'strat':
for n_strat in list_n_strat:
a['strat'] = a[f'strat_{n_strat}']
b['strat'] = b[f'strat_{n_strat}']
weights = pd.Series({x: 1/n_strat for x in range(n_strat)})
pvalue = stattest(a, b, weights=weights)
dict_pvalues[f'{test_name}_{n_strat}'].append(pvalue)
else:
pvalue = stattest(a, b, weights)
dict_pvalues[test_name].append(pvalue)
describe_pvalues(dict_pvalues)
ttest : pe=0.274, ci=[0.265, 0.283]
cuped : pe=0.777, ci=[0.769, 0.785]
strat_2 : pe=0.488, ci=[0.478, 0.498]
strat_5 : pe=0.712, ci=[0.703, 0.720]
strat_50 : pe=0.766, ci=[0.758, 0.774]
При увеличении количества страт мощность оценки стратификацией увеличивается и приближается к мощности CUPED. В пределе мощности методов будут очень близки или даже равны. Однако, объединение пользователей с разными ковариатами в одну страту теряет часть полезной информации. В таких ситуациях стратификация будет уступать CUPED.
Кейс 3: страты и метрика перед экспериментом
Объединим информацию из первых двух кейсов. Знаем и страты, и метрику перед экспериментом. Проведём численные эксперименты для всех методов.
Код
def generate_data_three(effect=0):
"""Генерирует данные, у которых знаем страты и исторические значения."""
df = generate_data(effect=effect)
df['covariate'] = df['value_before']
a, b = [df[df['group'] == group].copy() for group in [0, 1]]
return a, b
dict_stattests = DICT_STATTESTS
weights = WEIGHTS
effect = EFFECT
dict_pvalues = defaultdict(list)
for _ in range(10000):
a, b = generate_data_three(effect)
for test_name in dict_stattests:
pvalue = dict_stattests[test_name](a, b, weights)
dict_pvalues[test_name].append(pvalue)
describe_pvalues(dict_pvalues)
ttest : pe=0.264, ci=[0.255, 0.272]
cuped : pe=0.775, ci=[0.767, 0.783]
strat : pe=0.393, ci=[0.383, 0.402]
cuped_strat: pe=0.786, ci=[0.778, 0.794]
Стратификация ожидаемо уступает другим методам, она использует малую часть известной информации. CUPED использует ковариату, в которой косвенно учтена информация о принадлежности стратам. Если пользователь принадлежит некоторой страте, то он покупает больше или меньше, чем пользователи других страт, и это отражается на значении ковариаты. Поэтому совместное использование CUPED со стратификацией даёт лишь небольшой прирост мощности.
Можно построить ML модель, которая по метрике до эксперимента и страте предсказывает метрику эксперимента. Прогноз модели будет иметь большую корреляцию с метрикой, чем просто метрика до эксперимента. Взяв прогноз модели в качестве ковариаты, получим мощность как у CUPED со стратификацией.
У нас могут быть другие признаки, описывающие пользователей. Построив с их помощью прогноз, имеющий наибольшую корреляция с метрикой эксперимента, и использовав его в качестве ковариаты, получим наиболее мощный критерий на основе CUPED.
В рассмотренных выше кейсах с помощью одного CUPED всегда можно было получить наибольшую мощность. Нужно ли тогда вообще использовать стратификацию?
Кейс 4: непредсказуемые изменения
Страты у нас обозначают разные города. В городах могут случаться локальные события, которые влияют на поведение покупателей этого города, и не влияют на покупателей других городов. Такие события могут происходить случайно, их нельзя спрогнозировать.
Допустим, во время эксперимента в одном из городов резко увеличились продажи по независящим от эксперимента причинам. Проведём численные эксперименты, увеличивая значения метрики во время эксперимента в обеих группах в одной из страт.
Код
def generate_data_four(effect=0, strat_effect=1500):
"""Генерирует данные со сдвигом в страте во время эксперимента.
strat_effect - величина изменения метрики страты.
"""
df = generate_data(effect=effect)
df['covariate'] = df['value_before']
df['value'] += (df['strat'] == 1) * strat_effect
a, b = [df[df['group'] == group].copy() for group in [0, 1]]
return a, b
dict_stattests = DICT_STATTESTS
weights = WEIGHTS
effect = EFFECT
dict_pvalues = defaultdict(list)
for _ in range(10000):
a, b = generate_data_four(effect)
for test_name in dict_stattests:
pvalue = dict_stattests[test_name](a, b, weights)
dict_pvalues[test_name].append(pvalue)
describe_pvalues(dict_pvalues)
ttest : pe=0.199, ci=[0.191, 0.207]
cuped : pe=0.289, ci=[0.280, 0.297]
strat : pe=0.403, ci=[0.393, 0.412]
cuped_strat: pe=0.792, ci=[0.784, 0.800]
Стратификация и CUPED со стратификацией сохранили свои мощности примерно на том же уровне. Мощность CUPED сильно уменьшилась и стала меньше мощности стратификации.
Использование стратификации позволяет получить бОльшую мощность при непрогнозируемых изменениях в стратах во время эксперимента.
Итоги
При некоторых предположениях CUPED и стратификация могут давать одинаковое увеличение чувствительности. В общем случае результаты отличаются. Оба метода имеют свои преимущества. CUPED позволяет использовать наиболее точные персонализированные ковариаты. Стратификация помогает бороться с непрогнозируемыми изменениями в стратах и дисбалансом выборки. Для получения наилучших результатов имеет смысл использовать совместно оба метода.