АБ тесты и подводные камни при их автоматизации
Задача оценки нововведений в онлайн и мобильных приложениях возникает повсеместно. Один из наиболее надёжных и популярных способов решения этой задачи — двойной слепой рандомизированный эксперимент, также известный как АБ-тест.
На тему АБ-тестирования доступны как статьи на Хабре, так и целые книги (неполный список литературы в конце). В основе АБ-теста лежит следующая идея — случайно разделить пользователей на две или более группы, в одной из которых исследуемая функциональность выключена, а в других — включена. Затем можно сравнить метрики и сделать выводы.
Основной математический инструментарий, применяемый в A/B-тестировании, уже реализован в стандартных библиотеках, таких как SciPy, Statsmodels, Scikit-learn, Pandas и других. Кроме того, существуют готовые коробочные решения, включая открытый исходный код, которые объединяют возможности включения и отключения функциональности для различных пользователей, расчёт метрик и вывод результатов статистических критериев.
Это приводит к естественному желанию полностью автоматизировать процесс дизайна, сопровождения и анализа A/B-тестов, чтобы снизить нагрузку на аналитиков компании.
Полная автоматизация
Зачем каждый раз после окончания очередного АБ-теста писать новый Jupyter ноутбук, если часть кода, связанная с вычислением p-value и обработкой выбросов, повторяется из раза в раз? Если описать все часто используемые метрики, то необязательно каждый раз скачивать данные и запускать Т-тест или бутстрап для подсчета p-value. Разумно проводить эти расчёты автоматически сразу для всех метрик и тестов. После тщательной проверки на исторических данных, синтетических данных и в АА тестах можно организовать небольшой сервис для их мониторинга и запуска.
Тогда роль аналитика практически полностью исключается, особенно если выдать доступы на мониторинг и запуск заказчикам, например менеджерам. Таким образом, заказчики после реализации новой идеи могут самостоятельно создать АБ тест, разбить пользователей на желаемые группы и самостоятельно визуально убедиться, что новая фича «прокрасила» часть метрик — обнаружена статистическая значимость.
Из статистической значимости набора метрик можно сделать вывод об успешности идеи и её реализации. Таким образом время аналитика можно потратить на более важные задачи.
Однако, непродуманная автоматизация может привести к неожиданным проблемам.
Проблемы аналитиков
Первая проблема, с которой приходится сталкиваться, — это рост числа параллельно выполняемых тестов. Не все заказчики знают, что происходит в соседних командах, и тесты начинают пересекаться и влиять друг на друга. С этим можно справиться достаточно просто, если ввести визуальный контроль за выполняемыми тестами и дополнительные инструкции. Например, при создании нового теста необходимо убедиться, что он не влияет на уже выполняющиеся, а общее количество параллельно выполняемых тестов не должно превышать утвержденной величины, например, 20.
Каждая новая функциональность имеет свою специфику и добавляет свои метрики. С какого-то момента количество метрик и данных становится настолько велико, что вычисление доверительных интервалов и p-value бутстрапированием становится невыносимо долгим. В таких случаях приходится переходить к аккуратному применению Пуассоновского бутстрапирования, t-тест, дельта метода и других продвинутых подходов. Код становится сложным инженерным решением, требующим комбинацию хороших навыков разработчика, дата инженера и аналитика. Процесс добавления новых метрик усложняется, возрастает нагрузка на команду девопсов.
Проблемы заказчиков
Давайте рассмотрим А/Б-тестирование с точки зрения заказчиков и тех, кто управляет процессом создания новых фичей. Обычно на таких должностях работают люди с хорошими софт скилами и объяснить, почему нужен доступ в систему управления и мониторинга АБ тестов большой проблемы не составляет.
Дальше процесс максимально прост и понятен — создать новый тест, выбрать его конфигурацию, запустить на максимально возможном количестве метрик и пользователей. В процессе, если в какой-то момент метрики ухудшились, то можно подождать ещё. Как только появилась стат. значимость необходимого набора метрик можно остановить тест, сформировать автоматический отчёт и подкрепить тем самым продуктовую идею. Такой путь намного проще и быстрее, чем общение с дотошным аналитиком, вечно сомневающемся в любом результате. То есть происходит манипуляция данными и выбор метрик для получения ожидаемого результата.
Таким образом, даже технически подкованный инициатор АБ теста, осознанно или неосознанно, подвергается высокому риску сделать ложноположительный вывод. Скорее всего, помимо АБ теста имеется ряд предпосылок и аргументов, подтверждающих, что новая функциональность будет положительно влиять на продукт, а положительный результат вознаграждается гораздо лучше, чем никакой.
Примеры
Рассмотрим примеры проблем, с которыми заказчики и аналитики могут столкнуться на практике
Множественное тестирование
Предположим, в системе А/Б-тестов есть возможность отслеживать 100 метрик, значение каждой из которых имеет:
Для 10% выборки — значение метрики от 90 до 110 (нормальное распределение с математическим ожиданием 100 и дисперсией 10)
Для 90% выборки — 0
Подобным образом ведут себя, например, конверсия в покупку сервисов с подпиской. Давайте проведем А/Б-тест с двумя одинаковыми группами (АА-тест) и сравним попарно все метрики с помощью t-теста.
import numpy as np
import pandas as pd
import scipy.stats as st
def generate_data(n_users: int = 10 ** 5, n_metrics: int = 100) -> pd.DataFrame:
user_ids = np.arange(n_users)
data = pd.DataFrame(user_ids, columns=["user_id"])
for metric_number in range(n_metrics):
data = pd.concat(
[
data,
pd.DataFrame(
np.random.normal(100, 10, size=n_users)
* np.random.choice([0, 1], size=n_users, p=[0.9, 0.1]),
columns=[f"metric_{metric_number}"],
index=data.index,
),
],
axis=1,
)
data = data.set_index("user_id")
return data
data_a = generate_data()
data_b = generate_data()
significance = []
for column in data_a.columns[1:]:
p_value = st.ttest_ind(data_a[column], data_b[column])[1]
if p_value < 0.05:
significance.append(1)
else:
significance.append(0)
metric_names = data_a.columns[1:]
result_df = pd.DataFrame({"Metric": metric_names, "Significant": significance})
significant_metrics = result_df[result_df["Significant"] == 1]
print(significant_metrics)
Запустив код можно наблюдать что из 100 метрик имеют стат значимые различия 6, хотя изначально данные были сгенерированы как АА тест и каких либо различий быть не должно
Чтобы убедиться, что это не случайность, а закономерность, можно запустить следующий код для проведения 100 или более подобных экспериментов:
import numpy as np
import pandas as pd
import scipy.stats as st
import seaborn as sns
import matplotlib.pyplot as plt
n_users = 10 ** 5
num_simulations = 100
significant_counts = []
def generate_data(n_users: int = 10 ** 5, n_metrics: int = 100) -> pd.DataFrame:
user_ids = np.arange(n_users)
data = pd.DataFrame(user_ids, columns=["user_id"])
for metric_number in range(n_metrics):
data = pd.concat(
[
data,
pd.DataFrame(
np.random.normal(100, 10, size=n_users)
* np.random.choice([0, 1], size=n_users, p=[0.9, 0.1]),
columns=[f"metric_{metric_number}"],
index=data.index,
),
],
axis=1,
)
data = data.set_index("user_id")
return data
for n in range(num_simulations):
data_a = generate_data(n_users)
data_b = generate_data(n_users)
significance = []
for column in data_a.columns[1:]:
p_value = st.ttest_ind(data_a[column], data_b[column])[1]
if p_value < 0.05:
significance.append(1)
else:
significance.append(0)
significant_counts.append(sum(significance))
plt.figure(figsize=(12, 6))
sns.distplot(significant_counts, hist_kws={"edgecolor": "k"})
plt.xticks(np.arange(0, 11))
plt.xlabel("Number of Significant Metrics")
plt.ylabel("Frequency")
plt.title(f"Distribution of Significant Metrics in {num_simulations} Simulations")
plt.show()
При отсутствии разницы в распределениях t-test детектирует значимые различия, в среднем 4–6 раз из 100.
Очевидно, что для получения результата нельзя просто запустить тест на большое количество метрик. Некоторые из них могут дать ложные положительные результаты.
При автоматизации, чтобы минимизировать человеческий труд, тест по умолчанию запускается на все возможные метрики. Однако такой подход, известный как Множественное тестирование, может привести к ложным выводам и внедрению ненужной функциональности на основе сомнительных результатов.
Тест: пуш со скидкой на первую покупку книги Цель: увеличить конверсию в продажу и средний чек Результат: Клиенты начинают чаще подтверждать свою почту
Одним из вариантов решения проблемы может быть установка альфы 0.05 для ключевых метрик, и 0.01 для второстепенных, что может снизить число ложноположительных прокрасов.
Подглядывание
Похожая проблема возникает, если проводить оценку p-value слишком часто. В следующем примере p-value вычисляется заново с каждым новым пользователем.
import numpy as np
import pandas as pd
import scipy.stats as st
import seaborn as sns
import matplotlib.pyplot as plt
np.random.seed(43)
def generate_data(n_users: int = 10 ** 5, n_metrics: int = 100) -> pd.DataFrame:
user_ids = np.arange(n_users)
data = pd.DataFrame(user_ids, columns=["user_id"])
for metric_number in range(n_metrics):
data = pd.concat(
[
data,
pd.DataFrame(
np.random.normal(100, 10, size=n_users)
* np.random.choice([0, 1], size=n_users, p=[0.9, 0.1]),
columns=[f"metric_{metric_number}"],
index=data.index,
),
],
axis=1,
)
data = data.set_index("user_id")
return data
n_users = 10 ** 3
data_a = generate_data(n_users, 1)
data_b = generate_data(n_users, 1)
p_values = []
for time in range(100, n_users):
time_slice_a = data_a.iloc[:time]["metric_0"]
time_slice_b = data_b.iloc[:time]["metric_0"]
p_values.append(st.ttest_ind(time_slice_a, time_slice_b)[1])
sns.lineplot(x=range(100, n_users), y=p_values)
plt.yticks(np.arange(0, max(p_values), 0.05))
plt.xticks(np.arange(0, n_users, 100))
plt.ylim(-0.1, max(p_values))
plt.gca().invert_yaxis()
plt.axhline(0.05, color="green", linestyle="--", linewidth=2)
plt.xlabel("Users_n")
plt.ylabel("p-value")
plt.title("p-value over time")
plt.show()
Запустив код выше несколько раз, можно получить различные примеры, часть из которых приведена ниже.
В данном случае статистически значимые результаты достигаются лишь несколько раз за период проведения теста, и к концу теста различий уже нет. Однако, если умело подобрать момент, можно представить результаты в желаемом свете.
В этом же примере обратная ситуация — не дождавшись окончания теста, можно упустить значимые различия между группами.
Исключение выбросов
Ещё один способ манипулирования данными — исключение выбросов. Существует несколько популярных способов для работы с ними, например: использование квантилей, логарифмирование и др. Подробнее о том, почему их нельзя использовать, разобрано в статье.
Одним из самых признаваемых способов является подбор фиксированной отсечки, однако и здесь остается пространство для манипуляции.
Ниже приведён синтетический пример, демонстрирующий процесс подбора отсечки в поисках p-value.
import numpy as np
import scipy.stats as st
import seaborn as sns
import matplotlib.pyplot as plt
np.random.seed(43)
def generate_data(n_users: int = 10 ** 3) -> np.ndarray:
revenue = np.random.uniform(10, 300, n_users)
return revenue
def add_outliers_to_data(
data: np.ndarray,
outliers_min: int = 300,
outliers_max: int = 400,
outliers_n: int = 10,
) -> np.ndarray:
outliers_revenue = np.random.uniform(outliers_min, outliers_max, outliers_n)
data = np.insert(data, 1, outliers_revenue)
return data
data_a = generate_data()
data_b = generate_data()
data_b = add_outliers_to_data(
data_b, outliers_min=500, outliers_max=900, outliers_n=100
)
data_a = add_outliers_to_data(data_a, outliers_min=500, outliers_max=600, outliers_n=50)
data_a = add_outliers_to_data(
data_a, outliers_min=1100, outliers_max=1200, outliers_n=10
)
thresholds = []
threshold_candidates = [400, 600, 900]
for threshold in threshold_candidates:
data_a_cut = data_a[data_a < threshold]
data_b_cut = data_b[data_b < threshold]
p_value = st.ttest_ind(data_a_cut, data_b_cut)[1]
print(f"Threshold: {threshold}, p-value: {p_value:.3f}")
print(f"Number of users cut: {len(data_a) - len(data_a_cut)}")
thresholds.append(threshold)
print(
f"diff between groups {round(sum(data_a[data_a < threshold]) - sum(data_b[data_b < threshold]))}"
)
sns.boxplot(data=[data_a, data_b])
for threshold in thresholds:
plt.plot([-0.5, 1.5], [threshold, threshold], color="red", linestyle="--")
plt.xlabel("Group")
plt.ylabel("Revenue")
plt.title("Revenue distribution")
plt.show()
В данном примере есть несколько возможных пороговых значений для исключения выбросов.
Нет стат значимых различий между группами Threshold: 400, p-value: 0.957 — diff between groups -200
Значимо лучше 0 группа Threshold: 600, p-value: 0.028 — diff between groups 15914
Значимо лучше 1 группа
Threshold: 900, p-value: 0.000 — diff between groups -43068
Таким образом убрав несколько клиентов из выборки можно получить любой результат от теста. Для корректного подведения результатов необходимо либо проанализировать второстепенные и косвенные метрики, либо оценить, было ли взаимодействие выбросов с функционалом теста. Только после этого можно взвешенно принимать решение о том, можно ли убирать каких-то клиентов из теста. В некоторых случаях может быть принято решение о повторном проведении теста, если дать однозначный ответ не удаётся или результаты вызывают сомнения.
Сильная связанность метрик
Рассмотрим пример АБ-теста на снижение минимальной цены товара. Если взять количество покупок в качестве основной метрики, то скорее всего она покажет рост
Однако этот рост может быть скомпенсирован уменьшением величины сделок. Кроме того, уменьшение среднего чека может привести к снижению других метрик, вплоть до ухудшения показателей прибыли.
Таким образом, выбрав несколько связанных метрик, можно представить результаты в более выгодном свете, при этом опустив остальные
Некорректно выбранный статистический критерий
Иногда в компании количество тестов становится очень большим, и при таких объёмах данных такие методы, как bootstrap, могут быть недоступны из-за недостатка вычислительных мощностей. В таких случаях необходимо выбрать другие критерии, которые можно использовать для оценки статистической значимости в тестах.
Однако при выборе статистического критерия важно хорошо понимать данные и сами критерии, так как для применения некоторых требуется выполнение ряда условий, например, гомоскедастичность для F-критерия, равенство форм распределений для U-критерия и так далее. Если условия не выполняются, то можно получить некорректную оценку.
Яркий пример не правильного выбора критерия, часто используемого для АБ тестов — критерий Манна-Уитни. Его не корректно применять для сравнения средних и он сложно интерпретируется. Ниже приведен пример двух нормальных распределений с одинаковым средним, но разными дисперсиями.
import numpy as np
import scipy.stats as sps
from tqdm import trange
t_pvals = []
mw_pvals = []
n_interaitons = 1000
for i in trange(n_interaitons):
control = np.random.normal(1000, 1, size=100)
test = np.random.normal(1000, 100, size=100)
t_stat, t_pval = sps.ttest_ind(control, test)
t_pvals.append(t_pval)
mw_u, mw_pval = sps.mannwhitneyu(control, test)
mw_pvals.append(mw_pval)
num_sig_t = sum(p < 0.05 for p in t_pvals)
num_sig_mw = sum(p < 0.05 for p in mw_pvals)
print(f"Number of significant p-values from t-test: {num_sig_t/n_interaitons:.3f}")
print(f"Number of significant p-values from MW U test: {num_sig_mw/n_interaitons:.3f}")
Тест Стьюдента в этом случае выдаёт ожидаемые 5% ложноположительных срабатываний, в то время как U-тест больше 11%. Подробнее об этом эффекте можно прочитать в статье.
Неравномерное деление на группы
Достаточно часто возникает ситуация, когда заказчик теста уверен в его успехе или провале и хочет минимизировать потери от его запуска. Например, можно разделить пользователей на контрольную и тестовую группу не в пропорции 50:50, а 10:90. T-test также можно проводить в этой ситуации, однако тяжёлые хвосты распределений могут сильно влиять на получение ошибки первого рода. Рассмотрим далее серию АА-тестов на синтетическом нормальном распределении с добавлением редких выбросов и измерим количество ложноположительных срабатываний.
import numpy as np
import pandas as pd
import scipy.stats as st
def generate_data(n_users: int) -> pd.DataFrame:
user_ids = np.arange(n_users)
data = pd.DataFrame(user_ids, columns=["user_id"])
data = pd.concat(
[
data,
pd.DataFrame(
np.random.normal(100, 10, size=n_users)
+ (10**6) * np.random.binomial(1, 0.001, size=n_users),
columns=["metric"],
index=data.index,
),
],
axis=1,
)
data = data.set_index("user_id")
return data
significance = 0
n_tests = 1000
for i in range(n_tests):
data_a = generate_data(n_users=10**5)
data_b = generate_data(n_users=100)
p_value = st.ttest_ind(data_a["metric"], data_b["metric"])[1]
if p_value < 0.05:
significance += 1
print(f"AA test false positive rate: {significance/n_tests*100:.1f}%")
AA test false positive rate: 10.4%
Запустив код несколько раз, можно наблюдать значения около 10%, что сильно больше пороговых 5%. Таким образом шанс увидеть эффект там где его на самом деле нет существенно возрастает. Иногда это можно решить увеличением выборки, усечения обоих распределений, увеличением времени проведения теста и другими методами. Однако, все эти методы имеют свои недостатки и если есть возможность стоит избегать таких неравномерных разбиений.
Итоги
Мы обсудили несколько важных проблем, с которыми можно столкнуться при автоматизации А/Б-тестирования.
Множественного тестирования
Подглядывание за результатами
Возможные трудности при исключении выбросов
Сильная связанность метрик
Некорректный выбор статистических критериев
Неравномерное деление на группы.
Часть проблем можно решить технически, например применять поправки Бонферони, sequential testing (mSPRT), тесты на равномерность групп, запретить пользоваться ненадежными критериями, однообразно исключать выбросы и т.д. Однако, опытный менеджер с хорошо развитым социальным капиталом без больших проблем найдет способ их обойти. Тогда на первый план выходит зрелость менеджмента и организации процесса принятия решений.
Будем рады услышать ваш положительный и отрицательный опыт по этому вопросу — будет как нельзя кстати для подготовки следующей заметки.
Авторы
Marat Yuldashev
Roman Rudnitskiy
Mikhail Tretyakov
Ссылки на материалы
https://en.wikipedia.org/wiki/A%2FB_testing
https://www.youtube.com/watch? v=YeHx5AspRA4
https://www.youtube.com/watch? v=b6L6P2Bzaq4
Книги
Trustworthy Online Controlled Experiments, Ron Kohavi , Diane Tang, Ya Xu
Статьи
Underpowered A/B Tests — Confusions, Myths, and Reality https://blog.analytics-toolkit.com/2020/underpowered-a-b-tests-confusions-myths-reality/#authorStart
Zhou J., Lu J., Shallah A. All about sample-size calculations for A/B testing: Novel extensions and practical guide //arXiv preprint arXiv:2305.16459. — 2023.
Howard S.R. et al. Time-uniform, nonparametric, nonasymptotic confidence sequences. — 2021.
Kohavi R., Deng A., Vermeer L. A/b testing intuition busters: Common misunderstandings in online controlled experiments //Proceedings of the 28th ACM SIGKDD Conference on Knowledge Discovery and Data Mining. — 2022. — С. 3168–3177.
Dmitriev P., Wu X. Measuring metrics //Proceedings of the 25th ACM international on conference on information and knowledge management. — 2016. — С. 429–437.
Gupta S. et al. Top challenges from the first practical online controlled experiments summit //ACM SIGKDD Explorations Newsletter. — 2019. — Т. 21. — №. 1. — С. 20–35.
Fabijan A. et al. Diagnosing sample ratio mismatch in online controlled experiments: a taxonomy and rules of thumb for practitioners //Proceedings of the 25th ACM SIGKDD International Conference on Knowledge Discovery & Data Mining. — 2019. — С. 2156–2164.
Howard S. R., Ramdas A. Sequential estimation of quantiles with applications to A/B testing and best-arm identification //Bernoulli. — 2022. — Т. 28. — №. 3. — С. 1704–1728.
Scott S.L. Multi‐armed bandit experiments in the online service economy //Applied Stochastic Models in Business and Industry. — 2015. — Т. 31. — №. 1. — С. 37–45.
Johari R. et al. Peeking at a/b tests: Why it matters, and what to do about it //Proceedings of the 23rd ACM SIGKDD International Conference on Knowledge Discovery and Data Mining. — 2017. — С. 1517–1525.
Johari R. et al. Always valid inference: Continuous monitoring of a/b tests //Operations Research. — 2022. — Т. 70. — №. 3. — С. 1806–1821.
Статьи на Хабре
Как выбрать уровень статистической значимости для AB-теста и как интерпретировать результат
Эксперимент в Яндекс Метрике: как провести A/B-тест и что учесть при подготовке
Без А/B результат XЗ, или Как построить высоконагруженную платформу А/B-тестов
Критерий Манна-Уитни — самый главный враг A/B-тестов
Как улучшить ваши A/B-тесты: лайфхаки аналитиков Авито. Часть 1
Как улучшить ваши A/B-тесты: лайфхаки аналитиков Авито. Часть 2
Дизайн А/В-теста: пошаговая инструкция с теоретическими основами
Подводные камни A/Б-тестирования или почему 99% ваших сплит-тестов проводятся неверно?
ML-критерии для A/B-тестов
Как устроено A/B-тестирование в Авито
Эксперименты в Ситимобил. Эпизод 2: Атака тестов на Switchback
Математика в АБ-тестах. Что такое z-score и p-value?
Как сравнивать распределения. От визуализации до статистических тестов
Стратификация. Как разбиение выборки повышает чувствительность A/B теста
А/Б тесты с метрикой отношения. Дельта-метод
АБ-тесты — это не только ценный мех… Но еще и процессы
Как оценить качество системы A/B-тестирования
Байесовский подход к АБ тестированию
Коварные перцентильные фильтры
A/B-эксперименты и Growth hacking
Что не так с A/B тестированием
Определяем оптимальный размер групп при множественном А/Б тестировании
Бутстреп и А/Б тестирование
Проверка корректности А/Б тестов