Методы анализа A/B тестов: как выбрать правильный метод для каждого типа метрик и размера выборки

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

Всем привет! Я Ваня Касторнов, продуктовый аналитик Лиги Ставок. На сегодняшний день все вокруг говорят о необходимости проведения а/б тестирования, но зачастую оказывается, что их проводят, не проверяя статистическую значимость, или даже не знают, как их проводить. Здесь не будет абстрактного описания, зачем и как делать а/б тесты, вместо этого я постараюсь развеять туман над тем, какие статистические методы использовать в разных ситуациях, а также приведу примеры скриптов на python, чтобы вы могли сразу ими воспользоваться. Для кого эта статья: для аналитиков, продактов или любого, кто сам планирует провести аб тест. Как читать? Я старался учесть последовательность при написании, но на самом деле, это скорее шпаргалка – в любой момент, когда вам нужно провести аб тест, вы можете найти в ней кейс, который подходит для вас, и воспользоваться им, в конце есть ссылка на сводную схему. Пара слов, и начнем. Пока я писал эту статью, я старался не использовать много научной терминологии, чтобы не распугать вас, но где-то она встречается, извините за это…)) Что ж, начнем…

У нас есть две выборки, которые мы получили рандомным путем. Первое, что нам нужно сделать, чтобы начать эксперимент, это определить метрику (или метрики), на основании которой мы будем принимать решение: то, на основании чего, построена сама гипотеза. Выбор статистического метода в том числе зависит от типа метрики, на которую мы будем смотреть. Мы поделим метрики на три типа:

  1. Количественные (количество ставок или заказов)

  2. Пропорционные или качественные (конверсия в покупку, CTR)

  3. Соотношения (рентабельность или отношение открытых продуктовых карточек к количеству покупок)

С каждым типом метрик – свой определенный подход, так как наибольшее количество А/Б тестов проводят с метриками пропорций, то с него и начнем, он же и самый простой.

Пропорции (качественные)

Тут все просто, наилучший метод чтобы проверить статистическую значимость изменения для пропорций это метод Хи-Квадрат. Но перед тем, как его проводить, стоит проверить размер тестовой группы для желаемого минимального статистически значимого изменения. Это можно сделать, используя один из инструментов в интернете, например, этот или с помощью библиотеки statsmodels.stats через python, вот пример кода:

from statsmodels.stats.proportion import proportion_effectsize
from statsmodels.stats.power import TTestIndPower


baseline_cr = 0.2 # базовый уровень конверсии
min_effect = 0.05 # минимальный значимый результат

effect_size = proportion_effectsize(baseline_cr, baseline_cr + min_effect)


alpha = 0.05 # уровень значимости
power = 0.8  #уровень мощности
power_analysis = TTestIndPower()
sample_size = power_analysis.solve_power(effect_size, power=power, alpha=alpha, alternative='two-sided')

print(f"Необходимый размер выборки: {sample_size:.0f}")

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

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

Опять же, в интернете полно сайтов с онлайн-калькуляторами, которые позволят это сделать, например, вот этот. Если же вы решите провести тест используя python, то вот пример скрипта, который позволит это сделать.

import numpy as np
import scipy.stats as stats

# Загрузите данные в переменные
group_A = [50, 100]
group_B = [60, 90]

# Запустите тест
chi2, p, dof, ex = stats.chi2_contingency([group_A, group_B], correction=False)

# Рассчитайте доверительный интервал для изменения
lift = (group_B[0]/group_B[1])/(group_A[0]/group_A[1])
std_error = np.sqrt(1/group_B[0] + 1/group_B[1] + 1/group_A[0] + 1/group_A[1])
ci = stats.norm.interval(0.95, loc=lift, scale=std_error)

# Выводим результаты
print("Хи-квадрат p-value: ", p)
print("Доверительный интервал изменения: ", ci)

# Проверяем есть ли изменение
if p < 0.05 and ci[0] > 1:
    print("Вариант лучше.")
else:
    print("Разницы нет.")

Количественные

Вторые по популярности аб тесты проходят с количественными метриками, примерами таких метрик могут быть: количество ставок, длина сессии и тд. При анализе количественных метрик важно выбрать правильный метод проверки статистической значимости. Так как мы выбрали рандомизированный метод сплитования, то скорее всего мы столкнемся с высоким уровнем дисперсии (разности) данных, что будет негативно влиять на определение значимости результатов, поэтому я рекомендую использовать CUPED (Controlled pre-post experimental design) – это статистический метод, который все чаще используется в А/Б тестировании для повышения точности полученных результатов.

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

Для того, чтобы использовать cuped, нам потребуются набор данных со значением метрик в предтестовый период и само значение в момент теста. Для того, чтобы преобразовать данные, есть удобная библиотека ambrosia, вот пример скрипта:

import ambrosia as amb
import pandas as pd

# Загружаем данные
data = pd.read_csv('experiment_data.csv')

# Опеределяем переменные
covariates = ['pre_experiment_data']
outcome = 'experiment_data'

cuped = amb.cuped(data, covariates, outcome)

# Выводим разницу в средних после лечения
print(f"The adjusted difference in means is: {cuped['diff']:.3f}")

# сохраняем в csv
cuped['data'].to_csv('adjusted_experiment_data.csv', index=False)

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

Если выборка большая…

То нужно посмотреть на нормальность распределения данных, в целом все просто, проверить нормальность распределения поможет вот этот скрипт, который использует тест Шапиро-Уилка, он подойдет в большинстве случаев:

import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import shapiro, norm

data = pd.read_csv('adjusted_experiment_data.csv')

# Применяем тест Шапиро-Уилка
stat, p = shapiro(data)

alpha = 0.05
if p > alpha:
    print("Нормальное распределение.")
else:
    print("Не нормальное распределение.")

# График с расрпеделением
fig, ax = plt.subplots()
ax.hist(data, bins=5, density=True, alpha=0.5, label='Data')

mu, std = norm.fit(data)
xmin, xmax = ax.get_xlim()
x = np.linspace(xmin, xmax, 100)
p = norm.pdf(x, mu, std)
ax.plot(x, p, 'k', linewidth=2, label='Normal distribution')

Итак, распределение получилось нормальным, что же дальше..

Дальше мы можем провести самый используемый метод проверки отсутствия нулевой гипотезы, а именно t-test, наиболее известным является т-критерий стъюдента, он также подойдет, если же между группами осталась неравная дисперсия, то лучше подойдет т-критерий Уэлча, в целом может использовать его всегда не будет ошибкой, но вы можете столкнуться с меньшим уровнем значимости. Оба теста удобно проводить, используя библиотеку scipy.stats, вот примеры запросов:

import pandas as pd
import scipy.stats as stats

data = pd.read_csv('adjusted_experiment_data.csv')

control = data[data['group_type'] == 'control']['experiment_data']
test = data[data['group_type'] == 'test']['experiment_data']

# т-критерий Уэлча
welch_t, welch_p = stats.ttest_ind(control, test, equal_var=False)

# т-критерий Стъюдента
student_t, student_p = stats.ttest_ind(control, test, equal_var=True)


print("Welch's t-test:")
print("t-statistic: ", welch_t)
print("p-value: ", welch_p)

print("\nStudent's t-test:")
print("t-statistic: ", student_t)
print("p-value: ", student_p)

Но бывает и так, что распределение данных не соответствует нормальному, что же тогда?

В этом случае лучше всего подходит U-test Манна-Уитни, В отличие от параметрических тестов, таких как t-тест Стьюдента или t-test Уэлча, U-test Манна-Уитни не делает никаких предположений о форме базового распределения. Для того, чтобы его провести, мы можем обратится к той же библиотеке scipy.stats:

u, p = stats.mannwhitneyu(control, test, alternative='two-sided')

print("Mann-Whitney U-test:")
print("U-statistic: ", u)
print("p-value: ", p)

Что делать, если размер выборки нормальный, мы разобрались, но бывает, когда данных очень мало, тут на помощь приходит bootstrap. Основная идея bootstrap заключается в многократной выборке с заменой из исходных данных для создания большого количества "выборок". Каждая выборка похожа на исходную выборку, но имеет немного другие значения из-за случайного подставления, подробнее можно узнать в этой статье, а пример скрипта выглядит так:

import pandas as pd
import numpy as np
import scipy.stats as stats


# Назначаем количество сэмплов
n_bootstrap = 10000

# генерирует сэмплы и расчитываем средние
bootstrap_diff = []
for i in range(n_bootstrap):
    control_sample = np.random.choice(control, size=len(control), replace=True)
    test_sample = np.random.choice(test, size=len(test), replace=True)
    bootstrap_diff.append(np.mean(test_sample) - np.mean(control_sample))
bootstrap_diff = np.array(bootstrap_diff)

# считаем доверительный интервал
ci = np.percentile(bootstrap_diff, [2.5, 97.5])

# проводим т-тест
t, p = stats.ttest_1samp(bootstrap_diff, 0)

print("Bootstrap mean difference 95% CI: ({:.2f}, {:.2f})".format(ci[0], ci[1]))
print("t-statistic: ", t)
print("p-value: ", p)

Фух, кажется с количественными метриками все…

Соотношения

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

Перво-наперво я рекомендую так же использовать один из вариантов сокращения дисперсии между данными, CUPED в данном случае не подойдет, так как это метод, который помогает уменьшить ошибки в данных, контролируя одну переменную, которая сильно связана с исследуемой переменной. Однако, если причина изменения исследуемой переменной является тем, имеет ли место какое-то воздействие (например, показ обучающего баннера или получение фрибета (бесплатной ставки)), то CUPED не может использоваться, так как не существует других факторов, которые можно контролировать. В этом случае лучше использовать метод Diff-in-diff, который позволяет учитывать другие факторы, влияющие на результаты, и исследовать эффект воздействия на исследуемую переменную.

Чтобы использовать diff in diff, можно воспользоваться скриптом, который указан ниже, структура данных должна быть следующей: каждая строка соответствует одному участнику теста, а столбцы представляют различные переменные, например: ID участника, группа A/B-теста, временной период, зависимая переменная:

import pandas as pd

# загрузка данных из csv файла в DataFrame
data = pd.read_csv('data.csv')

# фильтрация данных по группе и временному периоду
control = data[(data['group'] == 'control') & (data['time'] == 'before')]
treatment = data[(data['group'] == 'treatment') & (data['time'] == 'before')]

# вычисление среднего значения зависимой переменной для контрольной и экспериментальной групп до воздействия
control_before = control['dependent_variable'].mean()
treatment_before = treatment['dependent_variable'].mean()

# фильтрация данных по временному периоду после воздействия
control = data[(data['group'] == 'control') & (data['time'] == 'after')]
treatment = data[(data['group'] == 'treatment') & (data['time'] == 'after')]

# вычисление среднего значения зависимой переменной для контрольной и экспериментальной групп после воздействия
control_after = control['dependent_variable'].mean()
treatment_after = treatment['dependent_variable'].mean()

# вычисление разницы между средними значениями для контрольной и экспериментальной групп до и после воздействия
control_diff = control_after - control_before
treatment_diff = treatment_after - treatment_before

# вычисление оценки эффекта воздействия с помощью метода Diff-in-Diff
diff_in_diff = treatment_diff - control_diff

# сохранение преобраз
output_data = pd.DataFrame({'group': ['control', 'treatment'], 'before': [control_before, treatment_before], 'after': [control_after, treatment_after], 'diff': [control_diff, treatment_diff]})
output_data.to_csv('output.csv', index=False)

Так, данные в порядке, переходим к проверке.

Большая выборка

Предположим, что выборка имеет внушительный размер, тогда нам нужно всего-то провести t-test, но сперва стоит взглянуть еще раз на дисперсию. Например, наша метрика это CTR, клики и просмотры являются случайными величинами, и когда мы объединяем их в одну метрику CTR, они будут иметь совместное распределение. Кроме того, если наша рандомизация основана на user_id, один пользователь может генерировать несколько просмотров, так что просмотры не являются независимыми друг от друга. Лучше использовать дельта-метод для аппроксимации дисперсии соотношений и после этого проводить t-test, в помощь приходит библиотека scipy.stats . Скрипт может выглядеть следующим образом.

import pandas as pd
import scipy.stats as stats

# загрузка преобразованных данных из CSV-файла
data = pd.read_csv('output.csv')

# вычисление стандартной ошибки разницы между средними значениями для контрольной и экспериментальной групп после воздействия с использованием дельта-метода
se_diff = ((data['after'][0] - data['after'][1]) ** 2 * control_var / 2 + (data['before'][0] - data['before'][1]) ** 2 * treatment_var / 2) ** 0.5 / pooled_var ** 0.5

# вычисление t-статистики и p-значения с использованием оценки стандартной ошибки, полученной из дельта-метода
t_stat = (data['after'][0] - data['after'][1]) / se_diff
df = len(data) - 2
p_val = stats.t.sf(abs(t_stat), df) * 2

# вывод результатов
print('Diff-in-Diff with Delta Method:')
print(f"Standard Error: {se_diff}")
print(f"t-statistic: {t_stat}")
print(f"p-value: {p_val}")

Маленькая выборка

Ну и последний метод, который мы рассмотрим, который стоит применять если размер выборки маленький. В таком случае bootstrap будет также актуален. Но так как мы пытаемся анализировать метрику, соотношения и наблюдения в данных могут быть независимыми, то больше подойдет блочный bootstrap. В обычном bootstrap данные берутся из исходной выборки с заменой, в блочном данные берутся группами (или блоками). Это нужно потому, что мы хотим учесть структуру зависимости в данных. В конце проведем уже известным нам t-test.

Скрипт похож на обычный bootstrap:

import pandas as pd
import numpy as np
import scipy.stats as stats

data = pd.read_csv('output.csv')

control = data[data['group'] == 'control']
treatment = data[data['group'] == 'test']

observed_effect = treatment['after'].mean() - control['after'].mean()

# определяем количество блоков
num_blocks = 100
block_size = len(data) // num_blocks

bootstrap_samples = []
for i in range(num_blocks):
    # вставляем данные в блоки
    block_indices = np.random.choice(range(len(data)), size=block_size, replace=True)
    bootstrap_sample = data.iloc[block_indices]

    # сплитуем по группам
    bootstrap_control = bootstrap_sample[bootstrap_sample['group'] == 'control']
    bootstrap_test = bootstrap_sample[bootstrap_sample['group'] == 'test']

    # считаем эффект через bootstrap
    bootstrap_effect = bootstrap_test['after'].mean() - bootstrap_control['after'].mean()

    bootstrap_samples.append(bootstrap_effect)

# стандартная ошибка bootstrap
bootstrap_std_err = np.std(bootstrap_samples)

# проводим t-test
t_statistic = observed_effect / bootstrap_std_err
p_value = stats.t.sf(np.abs(t_statistic), len(data) - 1) * 2

print("Observed treatment effect: ", observed_effect)
print("Bootstrap estimate of standard error: ", bootstrap_std_err)
print("t-statistic: ", t_statistic)
print("p-value: ", p_value)

Проверка Мощности

Важный критерий после проведения тестов статистической значимости – это проверка мощности. Эксперимент с низкой мощностью будет страдать от высокой частоты ложных отрицательных результатов (ошибка второго типа). Размер эффекта обычно представляет собой разницу в средних между контрольной и тестовой группами, деленную на стандартное отклонение. Чтобы проверить мощность после получения результатов, можно воспользоваться TTestIndPower из библиотеки statsmodels, а скрипт может выглядеть следующим образом:

import pandas as pd
import numpy as np
from statsmodels.stats.power import TTestIndPower

# загрузка данных из CSV-файла
data = pd.read_csv('output.csv')

# вычисление размера выборок на основе количества уникальных значений ID
n1 = len(data[data['group'] == 'control']['id'].unique())
n2 = len(data[data['group'] == 'test']['id'].unique())

# вычисление ожидаемого эффекта на основе наблюдаемого эффекта
observed_effect = data[data['group'] == 'test']['after'].mean() - data[data['group'] == 'control']['after'].mean()
effect_size = observed_effect / data['after'].std()

# задание параметров теста
alpha = 0.05  # уровень значимости
power = 0.8  # мощность теста

# вычисление мощности теста
power_analysis = TTestIndPower()
sample_size = power_analysis.solve_power(effect_size=effect_size, alpha=alpha, power=power, ratio=n2/n1)
power = power_analysis.power(effect_size=effect_size, nobs1=sample_size, alpha=alpha, ratio=n2/n1)

# вывод результатов
print("Sample size required: ", sample_size)
print("Power of the test: ", power)

Вывод

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

Для более простого восприятия я добавил в Miro общую схему, которая будет работать как навигатор выбора стат метода для вашего теста.

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

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

Источник: https://habr.com/ru/company/ligastavok/blog/718336/


Интересные статьи

Интересные статьи

Привет Хабр. Этой статьей мы начинаем цикл «Бытовой инфобез», в котором постараемся дать относительно простые ответы на вопросы личной информационной безопасности. По возможности постараемся не углубл...
"Хочу рассказать историю, как я недавно лоханулся и потерял свой аккаунт Steam со 100+ играми и донатом на общую сумму больше 15 тыщ рублей". Или вот: "Несколько косарей в Стим вложено, и штук 20 игр...
Если вы озадачены выбором инструмента для статического анализа кода Terraform, то мы поможем вам с этим. Мы изучили несколько решений по анализу безопасности и конфигурац...
Сравнивать CRM системы – дело неблагодарное. Очень уж сильно они отличаются в целях создания, реализации, в деталях.
Получить трафик для интернет-магазина сегодня не проблема. Есть много каналов его привлечения: органическая выдача, контекстная реклама, контент-маркетинг, RTB-сети и т. д. Вопрос в том, как вы распор...