Как улучшить ваши A/B-тесты: лайфхаки аналитиков Авито. Часть 1
Всем привет! Я Дмитрий Лунин, работаю аналитиком в команде ценообразования Авито. Наш юнит отвечает за все платные услуги площадки. К примеру, услуги продвижения или платные размещения для профессиональных продавцов. Наша основная задача — сделать цены на них оптимальными.
Мы не только пытаемся максимизировать выручку Авито, но и думаем про счастье пользователей. Если установить слишком большие цены, то пользователи возмутятся и начнут уходить с площадки, а если сделать цены слишком маленькими, то мы недополучим часть оптимальной выручки. Низкие цены также увеличивают количество «спамовых» объявлений, которые портят поисковую выдачу пользователям. Поэтому нам очень важно уметь принимать математически обоснованные решения — любая наша ошибка напрямую отразится на выручке и имидже компании.
Одним из инструментов для решения наших задач является A/B-тестирование.
Это статья для вас, если хоть что-то из перечисленного далее вас заинтересовало:
Вы очень часто получаете не статистически значимые результаты в A/B-тестах, и вас это не устраивает. Вы хотите как-то изменить процедуру проведения и анализа A/B-тестов, чтобы начать детектировать больше статистически значимых результатов.
Для вас я расскажу про CUPED, про бутстрап-критерии для тестирования более чувствительных гипотез, про стратификацию, и про очень простой метод деления выборок на тест и контроль, который позволит вам значительно улучшить результаты будущих A/B-тестов. А ещё продемонстрирую результаты сравнения всех этих методов на наших данных.
Вы не получили статистически значимый результат и не знаете, что делать, ведь он ни о чём не говорит.
Я покажу, что это не так, и на самом деле вы можете получить некоторые инсайты даже из таких данных.
Чтобы сделать тест более устойчивым к выбросам, вы используете критерий Манна-Уитни, логарифмирование метрики или просто выкидываете выбросы.
Остановитесь! Я расскажу, к чему это может привести. А ещё поделюсь корректным методом борьбы с выбросами.
Вы задумывались, корректно ли работают статистические критерии, которые вы используете для анализа A/B-тестов. Или, к примеру, собираетесь начать использовать новый метод для анализа экспериментов, но не уверены в его корректности.
Я поделюсь, как проверить ваш метод, попутно доказав корректность всех методов, описанных в статье. А также развею миф, что T-test можно использовать только для выборок из нормального распределения.
Вы хотели бы отдавать стейкходерам более интерпретируемые результаты A/B-тестов. Не просто фразу: «выручка статистически значимо стала лучше, чем была», а ещё и численные значения плана +100±10 ₽.
Но даже такой результат не самый понятный и интерпретируемый: вы не можете сказать, 100 рублей — это много или мало для компании. Я расскажу, как решить эту проблему и сделать результаты эксперимента наиболее наглядными.
И в качестве последней плюшки: практически все описанные в статье методы будут иметь реализацию на Python. Если вы захотите использовать их у себя в компании, то у вас будет готовый стартовый пример.
Материала очень много, поэтому я разделил статью на две части. Большинство пунктов из списка выше будут затронуты в первом материале. Во второй части сосредоточимся на увеличении мощности ваших A/B–тестов.
В первой части статьи рассмотрим:
Терминология
Иногда я буду использовать нашу терминологию, связанную с A/B-тестами. Чтобы она была вам понятна, приведу основные понятия. Не все они будут в статье, но их полезно знать:
Статистически значимый результат — результат, который статистически значимо лучше 0.
Прокрас теста — результат эксперимента статистически значимо отличается от 0, и у вас есть какой-то эффект.
Зелёный тест — метрика в A/B-тесте статистически значимо стала лучше.
Красный тест — метрика в A/B-тесте статистически значимо стала хуже.
Серый тест — результат A/B-теста не статистически значим.
Тритмент — фича или предложение, чьё воздействие на пользователей вы проверяете в A/B-тесте.
MDE — минимальный детектируемый эффект. Размер, который должен иметь истинный эффект от тритмента, чтобы эксперимент его обнаружил с заданной долей уверенности (мощностью). Чем меньше MDE, тем лучше.
Мощность критерия — вероятность критерия задетектировать эффект, если он действительно есть. Чем больше мощность критерия, тем он круче.
Предпериод — период до начала эксперимента.
Какие гипотезы мы проверяем в A/B-тестах
Давайте поговорим о том, что мы вообще хотим получить от A/B-теста: какие гипотезы проверяем и как лучше преподнести результаты стейкхолдерам, чтобы они были более понятными и интерпретируемыми. Это будет та основа, на которой будет строиться материал всех последующих разделов статьи.
Абсолютная постановка в A/B-тестах
Начнём с азов A/B-тестов: что мы вообще тестируем с математической точки зрения.
H_0 — нулевая гипотеза в A/B-тестировании, которую, в основном, мы хотим отвергнуть. Чаще всего эта гипотеза отвечает за то, что эффекта в A/B-тесте нет.
H_1 — альтернативная гипотеза в A/B-тестировании, которую, наоборот, мы хотим подтвердить. Эта гипотеза отвечает за то, что эффект в A/B-тесте есть.
T — тестовая выборка. Одно значение в выборке — значение целевой метрики у пользователя в тесте. Например, количество просмотров нашего сайта или суммарная выручка от пользователя.
C — контрольная выборка. —//— в контроле.
EC — математическое ожидание в контроле.
ET — математическое ожидание в тесте.
Черта над T и С означает среднее этой метрики на пользователях в тесте и в контроле.
Допустим, мы тестируем рост выручки от внедрения новой фичи. Факт существования эффекта доказывается от противного.
Пусть тестируемое изменение никак не влияет на пользователей. Но по результатам мы получили +10М рублей, насколько такое возможно? Чтобы это понять, посчитаем вероятность такого или большего прироста в предположении, что эффекта нет. Эта вероятность называется p-value. Если вероятность (или p-value) мала, то наше изначальное предположение было неверным. А значит, эффект от тестируемой фичи есть.
Для отвержения нулевой гипотезы нам достаточно, чтобы p-value критерия было меньше некоторого α. Но оно плохо интерпретируемо, поэтому вместо него можно построить доверительный интервал для эффекта. Тогда гипотеза об отсутствии эффекта отвергается ⟺ 0 не лежит в доверительном интервале. Например, доверительный интервал для эффекта 10±5 эквивалентен тому, что эффект всё же есть, а если бы результат был 10±15, то эффект не обнаружен.
В наших A/B-тестах мы всегда строим доверительные интервалы: так результаты нагляднее для стейкхолдеров, нежели какие-то p-value, которые можно неправильно понять. Да и самим в таком виде проще анализировать результаты.
К примеру, наш A/B-тест привёл к росту выручки. Какой результат вы захотели бы отдать заказчику, да и сами проанализировать?
Мы получили 10М рублей, p-value=0.01, прирост выручки статистически значим.
Или:
Мы получили 10±5М рублей.
Второй результат будет понятней и привлекательней для заказчика и для вас.
А теперь давайте посмотрим: 10М рублей — это большой прирост или нет?
Ранее выручка была 1000М рублей, а сейчас 1010М рублей. В таком случае мы не очень-то и приросли в деньгах.
В другом случае выручка была 20М рублей, а стала 30М. В таком случае это офигенный результат!
Абсолютный результат в обоих случаях один и тот же, но в реальности они имеют совершенно разный вес. Поэтому я предлагаю вместе с абсолютными числами смотреть и относительный прирост. Вместо «мы получили 10±5М рублей» говорить: по результатам теста «мы получили +20±10% (10М рублей)». В таком виде результаты становятся понятны и интерпретируемы для любого человека.
Ещё один плюс относительных метрик: результаты можно сравнивать в различных разрезах. Допустим, вы провели A/B-тест с выдачей скидок пользователям в Москве и в Петербурге. Получилось следующее:
В Москве прирост выручки +10±1М рублей.
В Петербурге прирост выручки +1±0.3М рублей.
Значит ли это, что акция в Москве успешнее, чем в Петербурге? Вообще-то не факт, посмотрим на относительный прирост денег:
В Москве прирост выручки +20±2% (10М рублей).
В Петербурге прирост выручки +50±15% (1М рублей).
Результаты-то получились полностью противоположными. Если мы смотрим на относительные метрики, то лучше понимаем структуру данных, а значит, менее вероятно совершим ошибку при анализе результатов.
Итог:
При анализе A/B-тестов считайте не только p-value, но и доверительные интервалы с численными оценками эффекта.
Считайте не только абсолютные метрики, но и относительные.
Выполнив эти два шага, вы сильно повысите наглядность и интерпретируемость результатов.
Относительная постановка в A/B-тестах
Посмотрим, как можно поменять проверяемую гипотезу в A/B-тестировании, чтобы сразу считать относительный эффект и строить для него доверительный интервал. Предлагаются такие гипотезы:
С точки зрения здравого смысла здесь всё корректно. Мы берём математическое ожидание разницы средних в двух группах и смотрим, какую часть это изменение составляет от среднего в контроле. Этот результат и будет той метрикой, которую я предлагаю считать в относительных A/B-тестах. Далее я покажу, как исправить формулы в критериях, чтобы научиться правильно строить в таком случае доверительные интервалы и считать p-value.
Теперь, когда мы разобрались с тестируемыми гипотезами, я предлагаю перейти к критерию, которым мы будем их проверять.
T-test
T-test — самый первый метод, который приходит в голову при анализе A/B-тестов. Посмотрим на основные формулы, из которых выводится критерий:
Смысл таков: среднее в тесте и в контроле, а также разница средних должны быть из нормального распределения. Это свойство следует из центральной предельной теоремы практически для любых выборок. Правда, выборки в таком случае должны быть достаточно большого размера. Про это я расскажу чуть дальше.
Чтобы получить доверительный интервал для истинного эффекта в A/B-тесте на уровне значимости 5%, нужно воспользоваться следующей формулой:
Есть очень распространенное заблуждение, что T-test работает только в том случае, если изначальные выборки X и Y из нормального распределения. На самом деле это не так, нам нужна только нормальность средних.
Небольшая оговорка
На самом деле, коэффициент 1.96 (или 0.975 квантиль нормального распределения) в доверительном интервале неверен и там должно стоять другое число, рассчитанное из квантилей распределения Стьюдента. Но на большом объёме данных истинный коэффициент практически не отличается от квантили нормального распределения, поэтому в качестве очень точной и простой аппроксимации можно использовать его.
У многих людей, не знакомых близко с T-test, в этот момент может возникнуть вопрос: а как это проверить? Вдруг на самом деле T-test работает только для нормальных выборок, а я пытаюсь вас обмануть? Более того, я упоминал, что на самом деле этот критерий работает при условии выполнения центральной предельной теоремы (ЦПТ), а она работает не всегда, да и требует большого количества данных. Отсюда возникает главный вопрос: а ваши данные подпадают под действие ЦПТ или нет? Можно ли на ваших данных применять T-test или нет? А любой другой критерий, который вы придумали?
Предлагаю обсудить, как можно проверить корректность любого метода на практике и провалидировать T-test. Осознав и реализовав самостоятельно идеи из следующего параграфа, вы сможете точно быть уверенными в тех критериях, которые используете в работе.
Следующий раздел очень важен. Все мы допускаем ошибки, но один неверный критерий, который вы реализовали, может катастрофически отразиться на результатах всех последующих A/B-тестов.
Алгоритм проверки статистических критериев
Идея простая:
Создаём как можно больше датасетов, поделённых на контроль и тест, без какого-либо различия между ними (обычный А/А-тест).
Прогоняем на них придуманный критерий.
Если мы хотим, чтобы ошибка первого рода была 5%, то критерий должен ошибиться на этих примерах лишь в 5% случаев. То есть 0 не попал в доверительный интервал.
Если критерий ошибся в 5% случаев, значит он корректный. Если ошибок статистически значимо больше или меньше 5%, то для нас плохие новости: критерий некорректен.
Если он ошибся меньше, чем в 5% случаев, это не так страшно. Это только означает, что критерий вероятней всего не очень точный, и в большем проценте случаев мы не задетектируем эффект. Использовать такой критерий на практике можно, но, вероятно, он будет проигрывать по мощности своим конкурентам.
Но если критерий ошибся больше, чем в 5% случаев, это ALERT, плохо, страшно, ужасно. Таким критерием нельзя пользоваться! Это значит, что вы будете ошибаться больше, чем вы рассчитываете, и в большем проценте случаев раскатите тритменты, которые на самом деле не ведут к росту целевой метрики.
Резюмируя: мы генерируем большое количество А/А-тестов и на них прогоняем наш критерий. На всякий случай скажу, что A/A-тесты — это тесты без различий в двух группах, когда мы сравниваем контроль с контролем.
Как создать подходящие датасеты? Есть два способа решения проблемы:
Создать датасеты полностью на искусственных данных.
Создать датасеты, основываясь на исторических данных компании.
Датасеты на искусственных данных. Сначала я предлагаю обсудить первый способ, а также подробнее описать план проверки критериев:
Первым делом надо выбрать распределение, которое будет описывать наши данные. К примеру, если у нас метрика конверсии, то это бернуллевское распределение, а если метрика — выручка, то лучше использовать экспоненциальное распределение в качестве самого простого приближения.
Следующим шагом надо написать код для нашего критерия. Для проверки нужно, чтобы он возвращал доверительный интервал для эффекта.
Завести счётчик bad_cnt = 0.
Далее в цикле размера N, где N — натуральное число от 1000 до бесконечности, чем оно больше, тем лучше:
Симулировать создание теста и контроля из распределения, выбранного на первом шаге.
Запустить на сгенерированных данных наш критерий со второго шага.
Далее проверить, лежит 0 в доверительном интервале или нет. Если нет, то увеличить счётчик bad_cnt на 1. Здесь мы проверяем, ошибся ли критерий на текущей симуляции, или нет.
Построить доверительный интервал для полученной конверсии bad_cnt / N. Вот статья на Википедии о том, как это сделать. Если 5% не принадлежит ему, значит, критерий некорректен, и он заужает или заширяет доверительный интервал. Здесь как раз и играет выбор значения N на четвёртом шаге. Чем оно больше, тем меньше доверительный интервал для конверсии ошибок, а значит, мы более уверены в своём критерии.
> Разбор плана на примере проверки корректности T-test
from collections import namedtuple
import scipy.stats as sps
import statsmodels.stats.api as sms
from tqdm.notebook import tqdm as tqdm_notebook # tqdm – библиотека для визуализации прогресса в цикле
from collections import defaultdict
from statsmodels.stats.proportion import proportion_confint
import numpy as np
import itertools
import seaborn as sns
import matplotlib.pyplot as plt
import seaborn as sns
sns.set(font_scale=1.5, palette='Set2')
ExperimentComparisonResults = namedtuple('ExperimentComparisonResults',
['pvalue', 'effect', 'ci_length', 'left_bound', 'right_bound'])
# 2. Создание тестируемого критерия.
def absolute_ttest(control, test):
mean_control = np.mean(control)
mean_test = np.mean(test)
var_mean_control = np.var(control) / len(control)
var_mean_test = np.var(test) / len(test)
difference_mean = mean_test - mean_control
difference_mean_var = var_mean_control + var_mean_test
difference_distribution = sps.norm(loc=difference_mean, scale=np.sqrt(difference_mean_var))
left_bound, right_bound = difference_distribution.ppf([0.025, 0.975])
ci_length = (right_bound - left_bound)
pvalue = 2 * min(difference_distribution.cdf(0), difference_distribution.sf(0))
effect = difference_mean
return ExperimentComparisonResults(pvalue, effect, ci_length, left_bound, right_bound)
A/A-тест:
# 3. Заводим счётчик.
bad_cnt = 0
# 4. Цикл проверки.
N = 100000
for i in tqdm_notebook(range(N)):
# 4.a. Тестирую A/A-тест.
control = sps.expon(scale=1000).rvs(500)
test = sps.expon(scale=1000).rvs(600)
# 4.b. Запускаю критерий.
_, _, _, left_bound, right_bound = absolute_ttest(control, test)
# 4.c. Проверяю, лежит ли истинная разница средних в доверительном интервале.
if left_bound > 0 or right_bound < 0:
bad_cnt += 1
# 5. Строю доверительный интервал для конверсии ошибок у критерия.
left_real_level, right_real_level = proportion_confint(count = bad_cnt, nobs = N, alpha=0.05, method='wilson')
# Результат.
print(f"Реальный уровень значимости: {round(bad_cnt / N, 4)};"
f" доверительный интервал: [{round(left_real_level, 4)}, {round(right_real_level, 4)}]")
Результат вывода: реальный уровень значимости: 0.0501; доверительный интервал: [0.0487, 0.0514].
Возможно, у кого-то будет вопрос, за что отвечает параметр scale в функции sps.expon: это истинное значение математических ожидания этой случайной величины, а также значение стандартного отклонения случайной величины.
Пример показывает, что для использования T-test выборка не обязательно должна быть из нормального распределения. Это миф!
Датасеты на исторических данных компании. У многих компаний есть логирование событий. К примеру, данные о транзакциях пользователей за несколько лет. Это уже один готовый датасет: вы делите всех пользователей на тест и контроль и получаете один «эксперимент» для проверки вашего критерия.
Осталось понять, как из одного большого датасета сделать N маленьких датасетов. Я расскажу, как мы это делаем в Авито, но описанная механика применима практически к любой компании.
Наши пользователи размещают объявления. Каждое объявление относится только к одной категории товаров и размещено только в одном регионе. Отсюда возникает незамысловатый алгоритм:
Разобьём все размещения пользователей на четыре (или N в общем случае) категории: автомобили, спецтехника, услуги и недвижимость. Теперь нашу метрику, к примеру, выручку, от каждого юзера можно тоже разбить на эти категории.
Поделим выбранную метрику по месяцам: выручка за ноябрь, выручка за декабрь и так далее.
Ещё все метрики можно поделить по субъектам РФ или по группе субъектов: выручка из Москвы, выручка из Хабаровска и так далее.
Теперь у нас есть пользователи в каждой из этих групп. Поделим их случайно на тест и контроль и получим финальные датасеты для валидации придуманных статистических критериев.
Давайте посмотрим на картинках, как такая схема увеличивает количество датасетов:
Здесь мы смогли разбить 1 метрику (опять-таки, выручку) на 16 метрик (выручка в ноябре в автомобилях, выручка в марте в недвижимости и так далее), и получить 16 датасетов. А если добавить ещё и разделение по субъектам РФ, которых больше 80, то мы получим уже 16×80 = 1280 датасетов для проверки. И это всего за 5 месяцев! При этом, как показывает наша практика, 1000 датасетов достаточно, чтобы отделить некорректный критерий от хорошего.
Мы рассмотрели два метода проверки алгоритма: на искусственных и на реальных данных. Когда и где стоит использовать тот или иной способ проверки?
Главные плюсы искусственных данных в том, что их сколько угодно, они генерируются быстро, и вы полностью контролируете распределение. Можно создать бесконечно много датасетов, и очень точно оценить ошибку первого рода вашего критерия. Плюс, мой опыт говорит, что на начальных этапах дебага нового критерия искусственные данные сильно лучше реальных. Главный минус — вы получили корректность вашего критерия только на искусственных данных! На реальных же данных критерий может работать некорректно.
У датасетов, полученных на настоящих данных, всё наоборот: собрать большое количество датасетов сложно, да и не всегда нормально построен процесс сбора логов. Но адекватная оценка корректности критерия для проверки гипотез в вашей компании возможна только таким способом. Всегда можно реализовать такой критерий, который будет правильно работать на искусственных данных. Но, столкнувшись в реальности с более шумными данными, он может начать ошибаться чаще, чем в 5% случаев. Поэтому важно убедиться, что именно на настоящих данных метод будет работать верно.
По такой же процедуре, что описана выше, можно подобрать минимальный размер выборок для A/B-теста в вашей компании. Например, вы хотите протестировать, можно ли на 100 юзерах запустить ваш A/B-тест. Вы создаёте 1000 датасетов с размером выборок, равным 100, и на них проверяете критерий.
Итого, мой совет по проверке критерия такой: сначала валидируйте критерий на искусственных датасетах, чтобы точнее оценить, не ошибается ли он на простых распределениях. И лишь после этого переходите к датасетам на реальных данных.
Как я писал выше, таким образом мы прогоняем наши критерии только на А/А-тестах. Но на самом деле можно эмулировать и A/B-тесты. Казалось бы, зачем, но про это я расскажу позже.
Как искусственно реализовать A/B-тест
Допустим, наш тритмент так повлиял на выборку, что среднее увеличилось в 2 раза, то есть прирост составил +100%. Чтобы это просимулировать, выполним следующие шаги:
Умножим выборку в тесте на 2.
Поменяем понятие того, что критерий ошибся. Раньше мы проверяли, лежит 0 в доверительном интервале, или нет. Но сейчас это не то, что нам нужно. Мы строим доверительный интервал для истинного повышения, поэтому именно этот прирост и должен принадлежать интервалу в 95% случаев.
Дополнительные замечания про искусственную реализацию A/B-тестов:
В общем случае можно умножать не только на 2, но и на любое Z значение. Тогда в доверительном интервале должно лежать значение Z - 1 в 95% случаев. Например, если вы домножаете выборку на 1.5, то прирост составляет +0.5 или +50%.
Можно генерировать A/B-тест не только умножением на константу. Есть много разных вариантов, например, не умножать, а добавлять константу. Или реализовать более сложные механики, имитирующие влияние тритмента на пользователей.
Далее я буду проверять примеры на искусственных данных. Но в конце второй статьи покажу корректность всех методов и на наших настоящих данных. А ещё сравню все описанные сейчас и в дальнейшем критерии между собой.
А теперь поговорим про относительный T-test критерий.
Относительный T-test критерий
Тут, всё просто: давайте возьмём доверительный интервал в абсолютном случае и поделим его на среднее в контроле.
По смыслу всё корректно. Точно так же, как мы эффект делим на среднее в контроле, чтобы получить относительный прирост, мы отнормируем значения границ доверительного интервала, поделив их на среднее в контроле. Проверим, что всё хорошо, на А/А-тесте:
A/A-проверка
# 2. Создание тестируемого критерия.
def relative_ttest(control, test):
mean_control = np.mean(control)
mean_test = np.mean(test)
var_mean_control = np.var(control) / len(control)
var_mean_test = np.var(test) / len(test)
difference_mean = mean_test - mean_control
difference_mean_var = var_mean_control + var_mean_test
difference_distribution = sps.norm(loc=difference_mean, scale=np.sqrt(difference_mean_var))
left_bound, right_bound = difference_distribution.ppf([0.025, 0.975])
left_bound = left_bound / np.mean(control) # Деление на среднее
right_bound = right_bound / np.mean(control) # Деление на среднее
ci_length = (right_bound - left_bound)
pvalue = 2 * min(difference_distribution.cdf(0), difference_distribution.sf(0))
effect = difference_mean
return ExperimentComparisonResults(pvalue, effect, ci_length, left_bound, right_bound)
# 3. Заводим счётчик.
bad_cnt = 0
# 4. Цикл проверки.
N = 100000
for i in tqdm_notebook(range(N)):
# 4.a. Тестирую A/A-тест.
control = sps.expon(scale=1000).rvs(1000)
test = sps.expon(scale=1000).rvs(1100)
# 4.b. Запускаю критерий.
_, _, _, left_bound, right_bound = relative_ttest(control, test)
# 4.c. Проверяю, лежит ли истинная разница средних в доверительном интервале.
if left_bound > 0 or right_bound < 0:
bad_cnt += 1
# 5. Строю доверительный интервал для конверсии ошибок у критерия.
left_real_level, right_real_level = proportion_confint(count = bad_cnt, nobs = N, alpha=0.05, method='wilson')
# Результат.
print(f"Реальный уровень значимости: {round(bad_cnt / N, 4)};"
f" доверительный интервал: [{round(left_real_level, 4)}, {round(right_real_level, 4)}]")
Результат вывода: реальный уровень значимости: 0.0507; доверительный интервал: [0.0494, 0.0521].
И вроде бы на этом стоит закончить, уровень значимости 5%, всё, как мы и хотели. Но давайте проверим, что происходит на искусственно сгенерированных A/B-тестах.
# 3. Заводим счётчик.
bad_cnt = 0
# 4. Цикл проверки.
N = 100000
for i in tqdm_notebook(range(N)):
# 4.a. Тестирую A/B-тест
control = sps.expon(scale=1000).rvs(1000)
test = sps.expon(scale=1000).rvs(1100)
test *= 2
# 4.b. Запускаю критерий.
_, _, _, left_bound, right_bound = relative_ttest(control, test)
# 4.c. Проверяю, лежит ли истинная разница средних в доверительном интервале.
if left_bound > 1 or right_bound < 1:
bad_cnt += 1
# 5. Строю доверительный интервал для конверсии ошибок у критерия.
left_real_level, right_real_level = proportion_confint(count = bad_cnt, nobs = N, alpha=0.05, method='wilson')
# Результат.
print(f"Реальный уровень значимости: {round(bad_cnt / N, 4)};"
f" доверительный интервал: [{round(left_real_level, 4)}, {round(right_real_level, 4)}]")
Результат вывода: реальный уровень значимости: 0.1278; доверительный интервал: [0.1258, 0.1299].
Что-то пошло сильно не так. Критерий ошибается не в 5% случаях, а в 12%. А это значит, что мы будем совершать в два раза больше ошибок, чем рассчитывали. Хочу ещё раз отметить: очень важно валидировать критерии! Чтобы убедиться в этом, можете сами запустить код отсюда.
Теоретическое обоснование полученного результата простое: мы не учли, что «С с чертой» — это оценка среднего, а не истинное математическое ожидание. Поэтому, когда мы делим на него, мы не учитываем шум, который возникает в знаменателе. А после проверки мы получили важный результат для относительной постановки T-test:
Но теперь надо придумать, как это исправить. Предлагаю перейти к такой случайной величине:
Утверждается, что её математическое ожидание — это именно то, что нам нужно в относительной постановке A/B-тестов.
Доказательство корректности
Вообще, математическое ожидание у такой статистики не равно величине, которую мы хотим оценить в гипотезе относительного A/B-тестирования. Но здесь на помощь приходит разложение в многочлен Тейлора:
Подробнее про этот переход можно почитать здесь. Единственное, надо помнить, что статистика среднего сходится к своему математическому ожиданию из усиленного закона больших чисел, поэтому разложение в многочлен Тейлора здесь работает.
Но надо понять, как посчитать дисперсию этой статистики. Для этого предлагается применить дельта-метод. В итоге, формула дисперсии будет такой:
А в случае выборок разного размера такой:
Выглядит сложно и страшно, поэтому вот код критерия и его проверка, которые вы можете использовать.
Реализация и проверка критерия
# 2. Создание тестируемого критерия.
def relative_ttest(control, test):
mean_control = np.mean(control)
var_mean_control = np.var(control) / len(control)
difference_mean = np.mean(test) - mean_control
difference_mean_var = np.var(test) / len(test) + var_mean_control
covariance = -var_mean_control
relative_mu = difference_mean / mean_control
relative_var = difference_mean_var / (mean_control ** 2) \
+ var_mean_control * ((difference_mean ** 2) / (mean_control ** 4))\
- 2 * (difference_mean / (mean_control ** 3)) * covariance
relative_distribution = sps.norm(loc=relative_mu, scale=np.sqrt(relative_var))
left_bound, right_bound = relative_distribution.ppf([0.025, 0.975])
ci_length = (right_bound - left_bound)
pvalue = 2 * min(relative_distribution.cdf(0), relative_distribution.sf(0))
effect = relative_mu
return ExperimentComparisonResults(pvalue, effect, ci_length, left_bound, right_bound)
А/B-проверка:
# 3. Заводим счётчик.
bad_cnt = 0
# 4. Цикл проверки.
N = 100000
for i in tqdm_notebook(range(N)):
# 4.a. Тестирую A/B-тест.
control = sps.expon(scale=1000).rvs(2000)
test = sps.expon(scale=1000).rvs(2100)
test *= 2
# 4.b. Запускаю критерий.
_, _, _, left_bound, right_bound = relative_ttest(control, test)
# 4.c. Проверяю, лежит ли истинная разница средних в доверительном интервале.
if left_bound > 1 or right_bound < 1:
bad_cnt += 1
# 5. Строю доверительный интервал для конверсии ошибок у критерия.
left_real_level, right_real_level = proportion_confint(count = bad_cnt, nobs = N, alpha=0.05, method='wilson')
# Результат.
print(f"Реальный уровень значимости: {round(bad_cnt / N, 4)};"
f" доверительный интервал: [{round(left_real_level, 4)}, {round(right_real_level, 4)}]")
Реальный уровень значимости: 0.0501; доверительный интервал: [0.0487, 0.0514].
Для A/A-теста: реальный уровень значимости: 0.05; доверительный интервал: [0.049, 0.052].
Итак, относительный T-test критерий работает на искусственных данных. Но, возможно, у вас возник вопрос: а не ухудшим ли мы таким образом мощность критериев? Вдруг дополнительный шум в знаменателе так расширит доверительный интервал, что критерий станет бесполезным? И если раньше, с обычным критерием, мы детектировали эффект в 80% случаев, а сейчас только в 50%, то, очевидно, мы не будем пользоваться относительным критерием: мощность всегда превыше всего.
Ответ: нет, этого не произойдёт. Вот практический пример:
absolute_power_cnt = 0
relative_power_cnt = 0
# 4. Цикл проверки.
N = 10000
for i in tqdm_notebook(range(N)):
X = sps.expon(scale=1000).rvs(10000)
Y = sps.expon(scale=1000).rvs(10000) * 1.01
_, _, _, rel_left_bound, rel_right_bound = relative_ttest(X, Y)
_, _, _, abs_left_bound, abs_right_bound = absolute_ttest(X, Y)
if rel_left_bound > 0:
relative_power_cnt += 1
if abs_left_bound > 0:
absolute_power_cnt += 1
print(f"Мощность относительного критрерия VS мощность абсолютного критерия: {relative_power_cnt / N} VS. {absolute_power_cnt /N}")
Мощность относительного критрерия VS мощность абсолютного критерия: 0.0933 VS. 0.0983
Как видно, результаты по мощности относительного и абсолютного критериев практически идентичны. Если хотите, можете прочесть теоретическое обоснование.
Теоретическое обоснование
Давайте построим доверительный интервал для статистики X, объявленной чуть выше, не через дельта-метод, а через бутстрап. Причём, так как эта статистика из нормального распределения (также из дельта-метода), то для него можно построить перцентильный доверительный интервал.
Что нас тогда будет интересовать? В каком проценте случаев насемплированный X < 0. Если процент будет меньше α, то эффект задетектирован.
То есть, если бы мы строили перцентильный доверительный интервал для абсолютной метрики, то процент случаев, когда T−C будет меньше 0, такой же, как и X < 0. А значит, отвержение гипотезы в относительном и абсолютном случаях будет происходить одновременно. И не может быть такого, что абсолютный критерий задетектировал эффект, а относительный — нет. По крайней мере, на большом объёме данных.
Итог: я показал, как правильно построить относительный T-test критерий. Теперь у вас есть бейзлайн-критерий для относительных A/B-тестов. На этом с T-test покончено. Давайте обсудим серые метрики или не статистически значимые результаты в A/B-тестах.
Серые метрики в A/B-тестах
Допустим, вы добавили новую фичу на сайте и решили проверить, приросла ли выручка.
Как в основном смотрят на результаты теста:
P-value = 0.4, результат не статистически значим.
Прирост: +10000 рублей на всю тестовую группу.
+1% выручки.
В этот момент аналитики часто думают: «Чёрт, результаты серые, ничего сказать нельзя. Может на самом деле и есть эффект, но мы его не видим. Выручка же положительна, давайте катить».
Но это на самом деле и из таких результатов можно вытащить инсайты. Для этого добавим в результаты доверительные интервалы:
P-value = 0.4, результат не статистически значим.
Прирост: +10000±40000 рублей на всю тестовую группу.
+1±4% выручки.
А теперь спросим себя: может ли в таком случае прирост на самом деле составлять не 10 000 рублей, как мы получили, а 100 000 рублей? Если бы у нас действительно был эффект в 100 000 рублей, то вероятность в таком случае получить прирост +10 000 рублей (или меньше), равнялась бы 0! Поэтому мы можем говорить, что гипотеза о таком большом приросте несостоятельна. А то, что вероятность равна 0 следует из ширины доверительного интервала.
Более подробное объяснение
Для начала перейдём к метрике средней выручки на человека. Пусть у нас всего 10 000 человек, тогда средний прирост на пользователя +1±4 рубля, а истинный средний прирост выручки на человека +10 рублей, если истинная выручка +100 000. Как я писал выше, если выборка большого размера, то на ней работает ЦПТ, и среднее будет из нормального распределения.
Мы знаем, что оценка половины ширины доверительного интервала у нас примерно 4 рубля, а ещё знаем, что наши данные — из нормального распределения, у которого есть два параметра:
μ — математическое ожидание случайной величины из этого распределения;
σ — среднеквадратическое отклонение.
Тогда, если бы истинный эффект был 10 рублей, то мы знаем μ этого распределения. Осталось понять σ. Если вспомнить формулу доверительного интервала в T-test, то становится понятно, что σ=4/1.96=2.04. А значит, мы знаем все параметры этого распределения и можем его визуализировать:
Жёлто-зелёным обозначено предполагаемое распределение выручки. Какова вероятность для такого распределения получить значение меньшее, или равное единице (левее красной точки)? Она равна 0. Поэтому, гипотеза о приросте в 10 рублей несостоятельна. Здесь, кстати, видна роль ширины доверительного интервала: чем она меньше, тем меньше σ и тем менее «широким» будет распределение. А значит, менее состоятельно, что истинный прирост равняется 10 рублям.
Ещё раз повторим основные моменты того, как мы оценили гипотезу о несостоятельности большого прироста:
Нормальность этого распределения следует из ЦПТ.
Параметр μ — это то, что мы хотели бы увидеть в качестве прироста.
Параметр σ высчитывается при построении доверительного интервала.
Красная точка — полученный на тесте эффект.
Далее мы смотрим, в каком проценте случаев в построенном распределении мы получим наблюдаемый эффект (в текущем примере это 1 рубль). Если эта вероятность меньше α, мы отвергнем гипотезу об истинном эффекте в 10 рублей.
Процедура практически полностью эквивалентна обычному A/B-тесту. В обычном A/B-тесте мы отвергаем гипотезу о равенстве разницы средних нулю. В этой процедуре мы отвергаем гипотезу о равенстве разницы средних 10 рублям.
Так что, если вы получили серые метрики, то всё ещё можете понять, какой эффект вы не сможете получить на ваших данных. Также отсюда следует простое правило: чем меньше доверительный интервал, тем вы уверенней в том, что у вас никакого эффекта нет.
Итог: когда ваши метрики — серые, надо говорить не то, что не получилось обнаружить эффект, а то, что если он и есть, то он находится в определённых границах. И уже из этого делать выводы.
В заключительной части статьи я предлагаю обсудить одну из самых интересных тем: методы борьбы с выбросами.
Методы борьбы с выбросами в данных
Не секрет, что чем больше у вас выбросов, тем больше будет дисперсия в данных. А отсюда уже следует, что у вас будет менее мощный критерий. Поэтому иногда аналитикам приходит в голову что-то сделать с данными, чтобы учитывать выбросы с меньшим весом. И в погоне за очисткой данных они начинают использовать «некорректные» критерии для проверки гипотез о равенстве средних. Это:
Логарифмирование метрики.
Убрать топ n% пользователей с максимальной метрикой в тесте и контроле.
Но все же используют, никаких проблем не было...
Критерии Манна-Уитни и логарифмирование метрики
Давайте рассмотрим пример. Пусть мы провели A/B-тест со скидками и теперь хотим проверить, правда ли среднее выручки в тесте стало больше среднего в контроле. Результаты T-test получились такими:
sps.ttest_ind(sample_control, sample_test, alternative='less')
P-value = 0.68.
Грусть, печаль, тоска: результаты не статистически значимы. Но давайте посмотрим на гистограмму распределения:
Видно, что здесь есть выбросы, и хочется уменьшить их влияние на дисперсию выборок. В таких случаях чаще всего предлагают прологарифмировать метрику или перейти к критерию Манна-Уитни, который устойчив к выбросам. Давайте посмотрим на результаты этих критериев:
sps.ttest_ind(np.log(sample_control + 1), np.log(sample_test + 1), alternative='less')
P-value = 0.01.
sps.mannwhitneyu(sample_control, sample_test, alternative='less')
P-value = 0.00.
Отлично, результат в обоих случаях статистически значим, тест лучше контроля. Ура, давайте катить! Но напоследок убедимся, что среднее в тесте реально больше среднего в контроле:
print(f"среднее в тесте: {np.mean(sample_test)}\n"
f"среднее в контроле: {np.mean(sample_control)}")
Среднее в тесте: 39.95, cреднее в контроле: 50.71.
Хм, странно, мы получили противоположный результат. Но аналитик может подумать, что это шум, и всё равно раскатить тест. И вот теперь я предлагаю посмотреть на саму выборку:
sample_test = [8] * 30 + [20] * 30 + [100] * 10 + [1000]
sample_control = [3] * 30 + [10] * 30 + [200] * 10 + [1200]
sample_control = np.array(sample_control) + sps.norm().rvs(len(sample_control))
sample_test = np.array(sample_test) + sps.norm().rvs(len(sample_test))
В этом примере есть четыре сегмента пользователей по их выручке и наш тест повлиял на них так:
3 → 8
10 → 20
200 → 100
1200 → 1000
Видно, что искусственный тест хорошо повлиял на пользователей с мелкой выручкой и вроде как плохо на пользователей с большой выручкой, но это не статистически значимо. А теперь представим, что надо было подождать, чтобы выборка стала больше, а пропорции сегментов при этом сохранились. В данном случае я просто размножил выборку в 20 раз:
sample_test = [8] * 600 + [20] * 600 + [100] * 200 + [1000] * 20
sample_control = [3] * 600 + [10] * 600 + [200] * 200 + [1200] * 20
sample_control = np.array(sample_control) + sps.norm().rvs(len(sample_control))
sample_test = np.array(sample_test) + sps.norm().rvs(len(sample_test))
Теперь снова запустим T-test, но в этот раз с альтернативой, что в контроле значение больше, чем в тесте. То есть проверим то, что катить тест не надо:
sps.ttest_ind(sample_control, sample_test, alternative='greater')
P-value = 0.02.
Мы получили совершенно противоположный результат. В итоге получается, что критерии Манна-Уитни и логарифмирование метрики привели нас к тому, что мы раскатили тритмент, уменьшающий выручку! Поэтому в случае, когда ваш тритмент слабо увеличил метрику у мелких пользователей, но сильно уменьшил у крупных пользователей, эти критерии легко могут привести вас к ложному результату.
В чем истинная проблема этих методов? В том, что вы смотрите неинтерпретируемые метрики. Вы не сможете объяснить, зачем нужно увеличивать среднее логарифмов ваших метрик или зачем нужно, чтобы одно распределение было больше другого. Бизнесу всегда нужна конкретика: мы хотим повысить средний чек или хотим повысить медианный или квантильный чек, а не какие-то непонятные метрики. Если есть чёткая задача, то надо тщательно подбирать эквивалентные гипотезы, которые не приведут к некорректному результату.
Поэтому рекомендация: никогда не используйте эти критерии! Есть другие способы борьбы с выбросами.
Убрать топ 1% пользователей с максимальной метрикой в тесте и контроле
Теперь посмотрим на более нетривиальный пример: выкидывать топ 1% (или n%) в контроле и в тесте, чтобы избавиться от выбросов. Чтобы продемонстрировать, почему он некорректен, предлагаю проверить метод на искусственных данных.
A/A-проверка:
# 3. Заводим счётчик.
bad_cnt = 0
# 4. Цикл проверки.
N = 30000
for i in tqdm_notebook(range(N)):
# 4.a. Тестирую A/A-тест.
control = sps.expon(scale=1000).rvs(1000)
test = sps.expon(scale=1000).rvs(1000)
outlier_control_filter = np.quantile(control, 0.99)
outlier_test_filter = np.quantile(test, 0.99)
control = control[control < outlier_control_filter]
test = test[test < outlier_test_filter]
# 4.b. Запускаю критерий.
_, _, _, left_bound, right_bound = relative_ttest(control, test)
# 4.c. Проверяю, лежит ли истинная разница средних в доверительном интервале.
if left_bound > 0 or right_bound < 0:
bad_cnt += 1
# 5. Строю доверительный интервал для конверсии ошибок у критерия.
left_real_level, right_real_level = proportion_confint(count = bad_cnt, nobs = N, alpha=0.05, method='wilson')
# Результат.
print(f"Реальный уровень значимости: {round(bad_cnt / N, 4)};"
f" доверительный интервал: [{round(left_real_level, 4)}, {round(right_real_level, 4)}]")
Реальный уровень значимости: 0.0675; доверительный интервал: [0.0647, 0.0704].
Мы получили, что в таком случае процент ошибок первого рода не 5%, как мы ожидали, а 6.8%. Это значит, что метод некорректен и его нельзя использовать.
Почему так произошло? Здесь есть две проблемы.
Первая проблема в том, что мы не знаем точного значения 0.99 квантили, а лишь её оценку. Без точного значения у нас получаются разные пороги в тесте и в контроле, а значит, и разные итоговые выборки. К примеру, в одной выборке все значения будут меньше 2000, а в другой — меньше 3000, потому что из-за шума получились разные оценки квантили.
Чтобы исправить этот недостаток, можно брать одну квантиль для теста и контроля, посчитанную на всём тесте или на всём контроле или на объединенной выборке теста и контроля. На А/А-тестах такая вещь работает.
A/A-проверка
# 3. Заводим счётчик.
bad_cnt = 0
# 4. Цикл проверки.
N = 30000
for i in tqdm_notebook(range(N)):
# 4.a. Тестирую A/A-тест.
control = sps.expon(scale=1000).rvs(1000)
test = sps.expon(scale=1000).rvs(1000)
outlier_filter = np.quantile(np.concatenate([control, test]), 0.99)
control = control[control < outlier_filter]
test = test[test < outlier_filter]
# 4.b. Запускаю критерий.
_, _, _, left_bound, right_bound = relative_ttest(control, test)
# 4.c. Проверяю, лежит ли истинная разница средних в доверительном интервале.
if left_bound > 0 or right_bound < 0:
bad_cnt += 1
# 5. Строю доверительный интервал для конверсии ошибок у критерия.
left_real_level, right_real_level = proportion_confint(count = bad_cnt, nobs = N, alpha=0.05, method='wilson')
# Результат.
print(f"Реальный уровень значимости: {round(bad_cnt / N, 4)};"
f" доверительный интервал: [{round(left_real_level, 4)}, {round(right_real_level, 4)}]")
Реальный уровень значимости: 0.0494; доверительный интервал: [0.047, 0.0519].
Вторая проблема состоит в том, что тест и контроль отличаются друг от друга влиянием тритмента. Поэтому, если средние в тесте и в контроле отличаются, то использование одной квантили для отсечения в обоих выборках приведёт к тому, что мы выкинем из теста больше значений. В худшем случае мы будем сравнивать среднее без 1% в тесте со средним на всей выборке в контроле, что совсем не то, что мы хотим.
A/B-проверка
# 3. Заводим счётчик.
bad_cnt = 0
# 4. Цикл проверки.
N = 30000
for i in tqdm_notebook(range(N)):
# 4.a. Тестирую A/B-тест.
control = sps.expon(scale=1000).rvs(1000)
test = sps.expon(scale=1000).rvs(1000)
test *= 1.5
outlier_filter = np.quantile(np.concatenate([control, test]), 0.99)
control = control[control < outlier_filter]
test = test[test < outlier_filter]
# 4.b. Запускаю критерий.
_, _, _, left_bound, right_bound = relative_ttest(control, test)
# 4.c. Проверяю, лежит ли истинная разница средних в доверительном интервале.
if left_bound > 0.5 or right_bound < 0.5:
bad_cnt += 1
# 5. Строю доверительный интервал для конверсии ошибок у критерия.
left_real_level, right_real_level = proportion_confint(count = bad_cnt, nobs = N, alpha=0.05, method='wilson')
# Результат.
print(f"Реальный уровень значимости: {round(bad_cnt / N, 4)};"
f" доверительный интервал: [{round(left_real_level, 4)}, {round(right_real_level, 4)}]")
Реальный уровень значимости: 0.3381; доверительный интервал: [0.3328, 0.3435].
Итог: надеюсь, вы убедились в важности проверки методов, которые вы используете.
Напоследок, ещё раз зафиксируем мысль. Не используйте:
Критерий Манна-Уитни.
Логарифмирование метрики.
Удаление топ n% пользователей с максимальной метрикой в тесте и контроле.
А теперь я покажу, как исправить последний критерий и как правильно работать с выбросами.
Выкинуть топ n% пользователей на предэкспериментальном периоде
Как было замечено ранее, нужно, чтобы порог для отсечения пользователей был одним и тем же в тесте и в контроле, но при этом тритмент никак не должен привести к тому, что из одной выборки будет убрано больше значений, чем из другой. Поэтому я предлагаю подобрать порог отсечения, используя значение целевой метрики на предпериоде. К примеру, отсечь топ 1% юзеров по выручке за 2 месяца до эксперимента, когда никакого тритмента не было в тесте.
A/B-проверка:
# 3. Заводим счётчик/
bad_cnt = 0
# 4. Цикл проверки.
N = 30000
for i in tqdm_notebook(range(N)):
# 4.a. Тестирую A/B-тест.
control_before = sps.expon(scale=1000).rvs(10000)
test_before = sps.expon(scale=1000).rvs(10000)
control = control_before + sps.norm(loc=0, scale=100).rvs(10000)
test = test_before + sps.norm(loc=0, scale=100).rvs(10000)
test *= 1.5
outlier_filter = np.quantile(np.concatenate([control_before, test_before]), 0.99)
control = control[control_before < outlier_filter]
test = test[test_before < outlier_filter]
# 4.b. Запускаю критерий.
_, _, _, left_bound, right_bound = relative_ttest(control, test)
# 4.c. Проверяю, лежит ли истинная разница средних в доверительном интервале.
if left_bound > 0.5 or right_bound < 0.5:
bad_cnt += 1
# 5. Строю доверительный интервал для конверсии ошибок у критерия.
left_real_level, right_real_level = proportion_confint(count = bad_cnt, nobs = N, alpha=0.05, method='wilson')
# Результат.
print(f"Реальный уровень значимости: {round(bad_cnt / N, 4)};"
f" доверительный интервал: [{round(left_real_level, 4)}, {round(right_real_level, 4)}]")
Реальный уровень значимости: 0.05; доверительный интервал: [0.0476, 0.0525].
Для A/A-теста реальный уровень значимости: 0.0495; доверительный интервал: [0.0471, 0.052].
Итог: именно в таком варианте метод корректен и и только так стоит бороться с выбросами в данных, если они есть. Финальные результаты улучшения мощности критериев после использования такого хака, будут показаны в конце второй статьи.
Но при этом надо помнить и о минусе выкидывания n% пользователей по предпериоду. Выкидывая топ юзеров из рассмотрения и принимая решение о раскатке теста на основе оставшихся пользователей, вы автоматически считаете, что топ юзеров поведёт себя также, как и остальные пользователи, или лучше. Что на самом деле может быть не так. Это стоит всегда стоит дополнительно проверять. С другой стороны, возможно, топ-пользователи вас не интересуют, потому что они не целевая аудитория вашего тритмента. Тогда вы можете спокойно их убрать.
Ещё один нюанс. Примерно такой же пример я показывал, когда объяснял, что не стоит использовать критерий Манна-Уитни: там топ вел себя не так, как остальные пользователи. Так почему я сразу забраковал тот метод, а этот, наоборот, предлагаю для работы с выбросами? Текущий метод же точно так же привёл бы к неверным результатам.
Всё дело в том, что здесь я понимаю, какую бизнес-гипотезу проверяю и в каком предположении она будет верна в общем случае. Топ n% пользователей на предпериоде ведёт себя также, как и остальные пользователи. Более того, это предположение можно провалидировать на старых A/B-тестах. А в случае с Манном-Уитни чёрт его знает, какую часть топа он не учитывает и когда он даёт верный результат для бизнеса, а когда нет. Его тестируемая гипотеза не интерпретируема, и поэтому легко ведет к ошибкам. Да и адекватных численных оценок эффекта этот критерий не даёт.
Общие рекомендации
Вот и подошла к концу первая часть статьи про A/B-тестирование. Давайте ещё раз пробежимся по основным описанным лайфхакам:
Используйте не только абсолютную постановку A/B-тестирования, но и относительную, она более интерпретируема.
T-test работает не только для выборок из нормального распределения.
Валидируйте критерии. Иначе вы рискуете использовать неверный метод.
Серые результаты тоже несут в себе информацию. Кроме фразы «эффект может есть, а может и нет» у вас есть ещё и доверительный интервал.
Если вы хотите избавиться от выбросов, то не надо переходить к критериям типа Манна-Уитни. Достаточно удалить топ пользователей на предэкспериментальном периоде. Но при этом надо помнить, что в таком случае топ пользователей может ввести себя не так, как остальные, и из-за этого вы можете принять неверное решение. Поэтому стоит дополнительно валидировать это предположение на старых A/B-тестах.
Во второй части я расскажу про основные методы увеличения мощности ваших A/B-тестов без удаления выбросов.
Если у вас остались вопросы, можете писать их мне в соц сетях:
тг: @dimon2016