Всем привет! Я Дмитрий Лунин, работаю аналитиком в команде ценообразования Авито. Наш юнит отвечает за все платные услуги площадки. К примеру, услуги продвижения или платные размещения для профессиональных продавцов. Наша основная задача — сделать цены на них оптимальными. 

Мы не только пытаемся максимизировать выручку Авито, но и думаем про счастье пользователей. Если установить слишком большие цены, то пользователи возмутятся и начнут уходить с площадки, а если сделать цены слишком маленькими, то мы недополучим часть оптимальной выручки. Низкие цены также увеличивают количество «спамовых» объявлений, которые портят поисковую выдачу пользователям. Поэтому нам очень важно уметь принимать математически обоснованные решения — любая наша ошибка напрямую отразится на выручке и имидже компании. 

Одним из инструментов для решения наших задач является A/B-тестирование.

Это статья для вас, если хоть что-то из перечисленного далее вас заинтересовало:

  • Вы очень часто получаете не статистически значимые результаты в A/B-тестах, и вас это не устраивает. Вы хотите как-то изменить процедуру проведения и анализа A/B-тестов, чтобы начать детектировать больше статистически значимых результатов.

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

  • Вы не получили статистически значимый результат и не знаете, что делать, ведь он ни о чём не говорит.

Я покажу, что это не так, и на самом деле вы можете получить некоторые инсайты даже из таких данных.

  • Чтобы сделать тест более устойчивым к выбросам, вы используете критерий Манна-Уитни, логарифмирование метрики или просто выкидываете выбросы.

Остановитесь! Я расскажу, к чему это может привести. А ещё поделюсь корректным методом борьбы с выбросами.

  • Вы задумывались, корректно ли работают статистические критерии, которые вы используете для анализа A/B-тестов. Или, к примеру, собираетесь начать использовать новый метод для анализа экспериментов, но не уверены в его корректности. 

Я поделюсь, как проверить ваш метод, попутно доказав корректность всех методов, описанных в статье. А также развею миф, что T-test можно использовать только для выборок из нормального распределения.

  • Вы хотели бы отдавать стейкходерам более интерпретируемые результаты A/B-тестов. Не просто фразу: «выручка статистически значимо стала лучше, чем была», а ещё и численные значения плана +100±10 ₽.

Но даже такой результат не самый понятный и интерпретируемый: вы не можете сказать, 100 рублей — это много или мало для компании. Я расскажу, как решить эту проблему и сделать результаты эксперимента наиболее наглядными.

И в качестве последней плюшки: практически все описанные в статье методы будут иметь реализацию на Python. Если вы захотите использовать их у себя в компании, то у вас будет готовый стартовый пример.

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

В первой части статьи рассмотрим:

  1. Гипотезы, которые мы проверяем в A/B-тестах.

  2. Критерий T-test и как провалидировать, что вы можете использовать его или любой критерий на ваших данных.

  3. О чём говорят серые метрики.

  4. Как работать с выбросами в 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-тестов.

Алгоритм проверки статистических критериев

Идея простая:

  1. Создаём как можно больше датасетов, поделённых на контроль и тест, без какого-либо различия между ними (обычный А/А-тест). 

  2. Прогоняем на них придуманный критерий.

  3. Если мы хотим, чтобы ошибка первого рода была 5%, то критерий должен ошибиться на этих примерах лишь в 5% случаев. То есть 0 не попал в доверительный интервал. 

Если критерий ошибся в 5% случаев, значит он корректный. Если ошибок статистически значимо больше или меньше 5%, то для нас плохие новости: критерий некорректен.

Если он ошибся меньше, чем в 5% случаев, это не так страшно. Это только означает, что критерий вероятней всего не очень точный, и в большем проценте случаев мы не задетектируем эффект. Использовать такой критерий на практике можно, но, вероятно, он будет проигрывать по мощности своим конкурентам.

Но если критерий ошибся больше, чем в 5% случаев, это ALERT, плохо, страшно, ужасно. Таким критерием нельзя пользоваться! Это значит, что вы будете ошибаться больше, чем вы рассчитываете, и в большем проценте случаев раскатите тритменты, которые на самом деле не ведут к росту целевой метрики.

Резюмируя: мы генерируем большое количество А/А-тестов и на них прогоняем наш критерий. На всякий случай скажу, что A/A-тесты — это тесты без различий в двух группах, когда мы сравниваем контроль с контролем. 

Как создать подходящие датасеты? Есть два способа решения проблемы:

  1. Создать датасеты полностью на искусственных данных.

  2. Создать датасеты, основываясь на исторических данных компании.

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

  1. Первым делом надо выбрать распределение, которое будет описывать наши данные. К примеру, если у нас метрика конверсии, то это бернуллевское распределение, а если метрика — выручка, то лучше использовать экспоненциальное распределение в качестве самого простого приближения.

  2. Следующим шагом надо написать код для нашего критерия. Для проверки нужно, чтобы он возвращал доверительный интервал для эффекта.

  3. Завести счётчик bad_cnt = 0.

  4. Далее в цикле размера N, где N — натуральное число от 1000 до бесконечности, чем оно больше, тем лучше:

    • Симулировать создание теста и контроля из распределения, выбранного на первом шаге.

    • Запустить на сгенерированных данных наш критерий со второго шага.

    • Далее проверить, лежит 0 в доверительном интервале или нет. Если нет, то увеличить счётчик bad_cnt на 1. Здесь мы проверяем, ошибся ли критерий на текущей симуляции, или нет.

  5. Построить доверительный интервал для полученной конверсии 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 маленьких датасетов. Я расскажу, как мы это делаем в Авито, но описанная механика применима практически к любой компании.

Наши пользователи размещают объявления. Каждое объявление относится только к одной категории товаров и размещено только в одном регионе. Отсюда возникает незамысловатый алгоритм:

  1. Разобьём все размещения пользователей на четыре (или N в общем случае) категории: автомобили, спецтехника, услуги и недвижимость. Теперь нашу метрику, к примеру, выручку, от каждого юзера можно тоже разбить на эти категории.

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

  3. Ещё все метрики можно поделить по субъектам РФ или по группе субъектов: выручка из Москвы, выручка из Хабаровска и так далее.

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

Давайте посмотрим на картинках, как такая схема увеличивает количество датасетов:

Здесь мы смогли разбить 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-тестирование. Давайте ещё раз пробежимся по основным описанным лайфхакам:

  1. Используйте не только абсолютную постановку A/B-тестирования, но и относительную, она более интерпретируема.

  2. T-test работает не только для выборок из нормального распределения.

  3. Валидируйте критерии. Иначе вы рискуете использовать неверный метод.

  4. Серые результаты тоже несут в себе информацию. Кроме фразы «эффект может есть, а может и нет» у вас есть ещё и доверительный интервал.

  5. Если вы хотите избавиться от выбросов, то не надо переходить к критериям типа Манна-Уитни. Достаточно удалить топ пользователей на предэкспериментальном периоде. Но при этом надо помнить, что в таком случае топ пользователей может ввести себя не так, как остальные, и из-за этого вы можете принять неверное решение. Поэтому стоит дополнительно валидировать это предположение на старых A/B-тестах.

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

Комментарии (15)


  1. ngekht
    11.08.2021 16:15

    Просто интереса ради

    Почему питон, а не R, тоже самое на порядок проще там делается? Плюс очень приличные, но при этом простые в использовании пакеты для визуализации сразу есть. В областях где плотно зависят от статистики - R ща чуть ли не выбор по умолчанию.

    Но вообще это вы в одной статье попытались рассказать сразу три главы учебника по какой-нибудь 6 сигма. :-) Есть подозрение что для человека, который не в теме, не очень понятно будет. А изобилие транслитерированных терминов типа “раскатать” (лично я не сразу понял что это не от русского глагола “катать”, а от английского cut). А если учесть достаточно смелое утверждение о применимости т-теста для данных не попадающих под нормальное распределение и пока более чем спорно выглядящий метод фильтрации outliers… Может быть стоит чуть подробнее и тут уж точно надо показывать и на каких выборках, и с какой целью принималось такое решение. Я вполне допускаю что существуют ситуации когда это решение было обоснованным, но как общее правило - очень, очень смелое утверждение.


    1. gofat
      12.08.2021 08:55

      "Раскатить" от слова "катить". Например: "мы выкатили новые улучшения нашего сайта для всех пользователей". Получается, что мы выпустили некие обновления, которые теперь видны всем пользователям.

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


      1. ngekht
        12.08.2021 15:57

        Хм… два нюанса. Во-первых статья только выиграет если читателю не придется подменять одно слово другим. Все-таки выкатили как у вас в примере там нигде не используется, только раскатили. А это все-таки два сильно разных слова по значению. Но и во-вторых как минимум один раз там в статье раскатывают тест. Даже приняв ваше объяснение - я не вижу как именно следует трактовать эту фразу в контексте статанализа.


        1. gofat
          12.08.2021 16:06

          Из двух альтернатив (тест и контроль) выбрали тестовый вариант для того, чтобы его применить ко всем пользователям сервиса. То есть "раскатили тест".


          1. ngekht
            12.08.2021 17:19

            Спасибо, вы то что я написал в первом комментарии продемонстрировали на практике. Ну или если автор действительно такое писал - было бы здорово чтобы он прямо подтвердил, тогда собственно вопросов и не останется.


    1. dvlunin Автор
      16.08.2021 00:10

      • Почему питон, а не R:

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

      2) Я не вижу никакого преимущества R перед питоном. По крайней мере в тех задачах, которые мне попадались. Можете привести пример стат.критерия или визуализации, которых сейчас не были бы реализованы на питоне, но реализованы на R?

      3) Питон в несколько раз быстрее R. https://towardsdatascience.com/is-python-faster-than-r-db06c5be5ce8 

      • Про «раскатили тест» вам все верно сказали до меня. Хочу заметить, что используем этот термин не только мы, но и многие другие компании, достаточно загуглить эту фразу в поисковике.

      • Про  смелое утверждение о применимости т-теста для данных не попадающих под нормальное распределение: я описываю в статье, какие условия на самом деле требует t-test для корректной работы. Кроме того, я демонстрирую на примере выборок из экспоненциального распределения, что t-test работает. Поэтому, если у вас есть нарекания к примеру или объяснение, почему он некорректен, то мне будет интересно подискутировать на эту тему)

      • Про «спорно выглядящий метод фильтрации outliers»: я привожу примеры, в каких случаях этот метод может привести к ошибкам, а когда его можно применять в статье. Причем в общих рекомендациях (предпоследнее предложение в статье) я снова предупреждаю, что этот метод опасен и его стоит валидировать. Так что моя совесть чиста.

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

      Надеюсь я смог ответить на ваши вопросы и замечания, готов отвечать и далее)


      1. ngekht
        16.08.2021 03:14

        Я ж не в порядке оспаривания.

        Статья спорная, но взгляд очень интересный и почерпнуть из неё можно много полезного. Но, просто по опыту, 8 из 10 читателей "под кат" там где вы оговариваетесь про ЦПТ скорее всего даже не заглянут, а вот идею категорично выраженную в конце уже понесут гордо на флаге "я на хабре прочитал, там же все мега-профи сидят". :-) Совесть у вас в любом случае чиста, я тоже считаю что читать не умеет - сам себе злая буратина, но они ж потом так же будут страдать на каждом углу как их бедных обманули.

        Про питон логика понятна. Я собственно чисто из интереса спросил. С моей колокольни читать код про статанализ - надо 99% знания статистики и только 1% знания языка. Да и скорости выполнения не то что бы были очень важны, чай не в реальном времени считаем. Зато на R все это пишется в 3 раза быстрее и в 3 раза короче. Ну и время развернуть - начать - 5 минут. Хотя операции типа нарезки сетов в одну строку могут напугать, что есть - то есть :-)

        Насчет раскатывания - ну я ж тоже сразу предположил сленг, и поискал. При всем моем уважении - одно использование на первые три страницы выдачи. Опять-таки, дело хозяйское, но если избежать узкоспециального сленга - то статья станет только лучше. :-)

        А по теме...

        я описываю в статье, какие условия на самом деле требует t-test для корректной работы

        Вы про кат с оговоркой про ЦПТ? Тогда, если честно, не совсем понятно в чем тут новость. Всю жизнь же проверяли данные например Шапиро-Вилксом и если данные достаточно близки к НР - то и t-тест или anova вполне себе применимы.

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

        И, как мне показалось, пример, особенно где про крупных клиентов - он скорее сигнал к тому что может быть критерии эксперимента или таргет-группу надо было по другому проектировать. По крайней мере прислали б мне студенты такой эксперимент в качестве работы по 6 сигма - именно перепланировать эксперимент я бы и отправил. :-) Ну если только у вас совсем нет возможности бить по конкретным группам в рамках тестирования гипотезы.

        Про то, что статья получилась огромной

        С точностью до наоборот. Маленькая она сильно :-) Ее можно сделать в 3 раза больше и она только выиграет.

        А делилась, как мне кажется, легко. Рассуждения о презентации результатов тестирования - вполне себе статья сама по себе и отличная статья, кстати. Близкая к сердцу куда более широкой аудитории :-) Нюансы применения t-теста для не "сферических НР в вакууме", а в реальности (да хотя бы просто когда какая-то с...волочь взяла и округлила исходные данные до целых, при общем диапазоне в пару десятков единиц :-)) - еще статья. Борьба с выбросами - еще статья.

        А вторую пишите обязательно, в потоке УГ из серии "как мы классно внедрили модную но никому не нужную фигню" - прям глоток свежего воздуха. :-)


        1. dvlunin Автор
          16.08.2021 11:33

          Про раскатку: я в гугле искал) раскатить тест

          По поводу проверки на нормальность: нам нужна нормальность среднего по выборке, а среднее у выборки всего одно) А как по одному значению определить, является ли это значение из нормального распределения, я не знаю) Точнее представляю, как это можно сделать, но не совсем через Шапиро-Уилка.

          Далее, даже если бы Шапиро-Уилка не отверг гипотезу о ненормальности выборки средних (если бы была выборка средних), то это не значит, что t-test здесь отработает корректно (можно привести пример). Поэтому имеет смысл проверять работоспособность самого критерия, а не нормальность, потому что интересует нас именно корректность критерия.

          Теперь, про убирание крупных клиентов – да, можно изначально спроектировать эксперимент так, чтобы топ юзеры в нем не участвовали. Но зачем, если провести можно на всех юзерах? И в случае, когда на всех юзерах мы получили серый результат (а лучше – увидели, что MDE сильно больше ожидаемого, посчитанного на исторических данных), тогда можно перейти к критерию, который не учитывает топ юзеров на пред периоде.

          Про применение t-test на реальных данных: я как раз указываю, как проверить корректность использования этого (да и вообще любого) критерия на реальных данных, чтобы убедиться, что вы можете использовать этот критерий для анализа AB-тестов в своей компании. Правда, конечно, от человеческих ошибок типа округления это не спасет, тут вообще мало что спасет на первый взгляд)


  1. tonyvolcano
    12.08.2021 17:35

    А я ведь просто хотел подать объявление о продаже на Авито...


  1. Famazon
    16.08.2021 00:10

    Спасибо за интересную статью!

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


    1. dvlunin Автор
      16.08.2021 00:16

      Я не очень понимаю, зачем здесь мониторить MDE? Когда я строю доверительный интервал в определенный момент времени, я понимаю, что в alpha проценте случаев эффект будет не лежать в его границах, но как здесь мониторинг MDE позволит что-то сказать точнее? Мониторинг MDE имеет смысл для изначального определения срока теста, но об этом я расскажу во второй части статьи (выйдет на следующей неделе)


      1. Famazon
        16.08.2021 19:05

        MDE состоит из дисперсии, конкретного alpha и количества наблюдений в выборке. Из всего этого с течением времени меняется только количество наблюдений. Теоретически имея огромное множество наблюдений мы могли бы детектировать очень маленькое MDE, но из-за ограничения по времени мы ограничиваем MDE. Условно вычисляя размер выборки для 10% MDE , мы понимаем что нам нужно минимум N наблюдений. Но если бы решили собирать 100*N то возможно нашлибы прокрас но для меньшего MDE. Таким образом менеджер должен понимать, что серый эксп это не вообще отсутствие эффекта , а то что он может быть меньше MDE


        1. dvlunin Автор
          16.08.2021 19:24

          Проблема состоит в том, что со временем людей становится больше, но и дисперсия становится больше, в следующей статье будет пример. Поэтому нельзя утверждать, что со временем MDE станет лучше. У нас на практике MDE в какой-то момент просто перестает меняться или меняется очень слабо.

          Все остальное правда, эффект будет меньше MDE, но этот вывод никак не зависит от мониторинга MDE


  1. pr001
    20.08.2021 19:26

    а почему альтернативная гипотеза односторонняя? мы заранее не знаем в какую сторону отклонится эффект в эксперименте


    1. dvlunin Автор
      20.08.2021 19:31

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