Честные эффекты в A/B-тестах
Ситуация: ваша команда реализовала новую фичу в разделе с настройками приложения. Когда пользователь заходит в этот раздел, он попадает в эксперимент и сплитуется в одну из групп. Фича оказалась настолько хороша, что вы заметили прирост средней выручки на пользователя в 2 раза. Эксперимент максимально честный, все чекеры пройдены. Репортим, что увеличили выручку на 100% ?
Если ответ "да", то давайте добавим вводных. Эксперимент длился месяц. За это время в раздел с настройками зашло 200к человек: 100к попали в контрольную группу, 100к – в тестовую. MAU – 5 млн.
Проблема в том, что мы получили локальный эффект – вырастили метрику только среди зашедших в настройки пользователей. А среди незашедших – не вырастили. А значит, нам нужно учесть это при репортинге результатов.
Триггеринг
Включение в эксперимент только дошедших до экрана с изменением пользователей называется триггерингом. Отсекая нерелевантных пользователей – тех, для которых опыт не изменился бы будь они в другой группе (ITE = 0, выражаясь тюремным жаргоном), – мы устраняем шум, возникающий из-за неидеально сбалансированного разбиения на группы. Это увеличивает мощность теста. Подробнее о том, как и почему это работает, я писал тут.
Часто наличие триггеринга в компании – не заслуга аналитиков. Это следствие действий разработчиков – им удобнее разместить фиче флаг в том месте в коде, в котором как раз и появилась новая фича. В результате пользователи попадают в эксперимент, только когда доходят до нужного экрана.
Еще один пример триггеринга – эксперименты с коммуникациями. Обычно под условие рассылки попадают не все пользователи, а, например, лишь наиболее склонные к оттоку. Если при подведении итогов A/B-теста метрика будет считаться только по ним, то это сделает тест чувствительнее, но полученная оценка эффекта опять же будет локальной.
Проблема локальных оценок в том, что каждая существует в своем измерении. Представьте, что одна команда стабильно растит метрику на 2% среди тех, кто зашел в настройки, а вторая – так же стабильно растит ту же метрику на 2%, но среди тех, кто зашел на главную страницу. Очевидно, что ценность второй команды для бизнеса сильно выше.
Разбавление эффекта для аддитивных метрик
Для того чтобы оценка эффекта перестала быть локальной, её нужно пересчитать на всю пользовательскую базу. Подробное объяснение того, как это можно сделать, есть в документации вендорской A/B-платформы Eppo. Сейчас я разберу их подход, немного поменяв обозначения.

Мы провели эксперимент, фиче флаг которого триггерится, когда пользователь заходит на карточку товара. Эксперимент был настроен таким образом, что в него попадали 40% случайно отобранных пользователей из тех, кто стриггерил фиче флаг (решили не рисковать и раскатить только на часть пользователей). Всех пользователей можно разбить на 4 группы:
- Not eligible – те, кто не стриггерил фиче флаг (= не зашел на карточку товара)
- Not enrolled – те, кто стриггерил фиче флаг, но не попал в эксперимент (в нашем примере таких 60% от всех стриггеривших фиче флаг)
- Test group – те, кто стриггерил фиче флаг, попал в эксперимент и посплитовался в тестовую группу
- Control group – те, кто стриггерил фиче флаг, попал в эксперимент и посплитовался в контрольную группу
Введем условные обозначения:
- X_t – суммарная выручка с пользователей из тестовой группы после попадания в эксперимент
- X_c – суммарная выручка с пользователей из контрольной группы после попадания в эксперимент
- X_ne – суммарная выручка с пользователей, которые не попали в эксперимент (Not eligible + Not enrolled), + выручка с пользователей, которые попали в эксперимент, до их попадания в эксперимент. То есть это вся внеэкспериментальная выручка за период эксперимента
- n_t – размер тестовой группы
- n_c – размер контрольной группы
- t_exp – доля пользователей, стриггеривших фиче флаг и попавших в эксперимент. В нашем примере – 40%. Если проводите эксперимент на 100% пользователей, стриггеривших фиче флаг, то значение будет равно 1. В этом случае Not enrolled группы не будет в принципе.
Когда мы смотрим на локальный процентный прирост, мы видим это

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

Начнем с числителя. Если бы фича была раскатана на всех, то ей бы воспользовались пользователи из групп Not enrolled, Test group и Control group. Пользователи из группы Not eligible не триггерили фиче флаг, поэтому выручка с них будет одинаковой и при наличии фичи, и при её отсутствии. Также одинаковой будет выручка с групп Not enrolled, Test group и Control group до их захода на экран с изменениями.
Зануление эффекта на выручку, которая была получена до захода на экран с изменениями, как раз и является устранением шума, о котором я говорил выше. Если бы мы изначально сплитовали вообще всех пользователей на тест и контроль, то такая выручка в двух группах немного отличалась бы в силу случайности разбиения. Это создавало бы шум. Но так как мы знаем, что эта выручка не подвержена влиянию фичи, мы можем смело утверждать, что истинный эффект на нее в точности равен 0.
Таким образом в числителе нам достаточно посчитать
- выручку с групп Not enrolled, Test group и Control group после захода на экран с изменениями при раскатанной на них фиче
и вычесть
- выручку с групп Not enrolled, Test group и Control group после захода на экран с изменениями при отсутствии фичи.
(1) Выручка с групп Not enrolled, Test group и Control group после захода на экран с изменениями при раскатанной на них фиче
Возьмем выручку на пользователя после захода на экран с изменениями в тестовой группе – X_t / n_t. Столько в среднем приносит пользователь из тестовой группы. Умножим на (n_t + n_c) и получим выручку после захода на экран с изменениями, если бы фича была включена на Test + Control. Наконец поделим это на t_exp, чтобы получить выручку после захода на экран с изменениями, если бы фича была включена на Not enrolled + Test + Control.

(2) Выручка с групп Not enrolled, Test group и Control group после захода на экран с изменениями при отсутствии фичи
Возьмем выручку на пользователя после захода на экран с изменениями в контрольной группе – X_с / n_с. Столько в среднем приносит пользователь из контрольной группы. Умножим на (n_t + n_c) и получим выручку после захода на экран с изменениями по Test + Control при отсутствии фичи. Наконец поделим это на t_exp, чтобы получить выручку после захода на экран с изменениями по Not enrolled + Test + Control при отсутствии фичи.

Если мы из (1) вычтем (2), то получим числитель интересующей нас метрики.
Теперь найдем знаменатель.
(3) Выручка за время эксперимента по всем пользователям, если бы фича не была раскатана (то есть без влияния фичи на тестовую группу)
У нас есть X_ne – выручка от групп Not eligible и Not enrolled + выручка с пользователей, которые попали в эксперимент, до их попадания в эксперимент. Остается найти выручку от тестовой и контрольной групп после захода на экран с изменениями, если бы фича не была раскатана на тестовую группу. Мы уже получали этот результат в качестве промежуточного на предыдущем шаге.

Мы не умножаем дополнительно на 1 / t_exp, так как выручка с Not enrolled группы уже включена в X_ne.
Финальный результат выглядит так:

Особые кейсы
- В мой эксперимент попадают все пользователи, зашедшие в приложение, сразу, как только они сделали это
- Мой фиче флаг находится на чекауте или в любом другом месте, через которое пользователь обязан пройти, чтобы его выручка стала ненулевой
В обоих случаях эффект можно не разбавлять. Более того, даже если t_exp != 1, то всё равно репортим эффект as is. Так можно делать, потому что выручка с Not eligible группы будет равна 0. В первом случае, потому что такой группы в принципе не будет, во втором – потому что пользователи, нестриггерившие фиче флаг не смогут принести выручку. Также вся выручка с пользователей из Test и Control до попадания в эксперимент тоже будет равна 0.
Следовательно, X_ne в данном случае включает в себя только выручку с Not enrolled группы. Выручка на пользователя в Not enrolled группе примерно равна выручке на пользователя в контрольной группе. А значит мы можем посчитать знаменатель как

В итоге интересующая нас метрика примет вид исходного локального процентного прироста:

- У меня в эксперименте больше одной тестовой группы
С точки зрения расчетов меняется только то, что вместо n_t + n_c, мы берем n_t_1 + ... + n_t_k + n_c.
А что если конверсия
Идея того, что мы делаем в числителе нашей формулы, достаточно проста: занулим весь эффект на выручку, полученную до взаимодействия с фичей, так как он равен 0 by design, и оставим только эффект на выручку, полученную после взаимодействия с фичей. Если бы наши выборки состояли только из одного пользователя в двух параллельных мирах – с фичей и без, – то числители локальной и разбавленной глобальной оценки выглядели бы так:

Теперь давайте заменим выручку на конверсию в заказ. Помимо того, что теперь вместо денег мы будем считать количество заказавших пользователей, возникает еще одно важное отличие. Среди пользователей из Test и Control будут те, кто сделал заказ до попадания в эксперимент, и те, кто не сделал. Ключевой момент здесь следующий:
Пользователи, которые попали в эксперимент, но сделали заказ до попадания в эксперимент, никак не будут влиять на числитель глобального прироста
Почему так? В случае с конверсией мы не можем просто разложить её на "до захода на экран с изменениями" и "после захода". Все потому, что конверсия – неаддитивная метрика. Когда мы считаем локальный эффект на конверсию, мы смотрим на флаг Has_order после попадания в эксперимент. Если мы хотим учесть еще и факт совершения доэкспериментальных заказов (что нам и нужно при разбавлении), то нам придется смотреть на флаг Has_order за весь период эксперимента. Мы можем записать это как max( Has_order outside exp, Has_order in exp ).
Для пользователей, которые сделали заказ до попадания в эксперимент, флаг Has_order outside_exp будет равен 1 вне зависимости от того, в какую группу они попали, – опять же, потому что факт совершения заказа до взаимодействия с фичей не подвержен её влиянию. А значит и значение разбавленной конверсии будет всегда max(1, x) = 1 ∀ x ∈ {0, 1}. В примере с одним и тем же пользователем в параллельных мирах числители процентных приростов в таком случае будут выглядеть так:

Из этого следует, что в числителе при разбавлении нам важны только те пользователи, которые попали в эксперимент, не сделав заказ до этого. Для всех остальных эффект на такую "ever ordered" конверсию будет равен 0. В знаменателе нужно просто аккуратно посчитать количество сделавших заказ, если бы фича не была раскатана на тестовую группу. Опять же учитываем, что для тех, кто сделал заказ до попадания в эксперимент значение конверсии будет 1.
Давайте адаптируем нашу формулу под конверсионные реалии:

- X*_t – количество пользователей из тестовой группы, которые не делали заказ до попадания в эксперимент, но сделали заказ после попадания в эксперимент
- X*_c – количество пользователей из контрольной группы, которые не делали заказ до попадания в эксперимент, но сделали заказ после попадания в эксперимент
- n*_t – количество пользователей в тестовой группе, которые не делали заказ до попадания в эксперимент
- n*_с – количество пользователей в контрольной группе, которые не делали заказ до попадания в эксперимент
- X¬*_t,c – количество пользователей из тестовой и контрольной групп, которые сделали заказ до попадания в эксперимент
- X_ne – количество пользователей, которые сделали заказ, и, которые не попали в эксперимент (Not eligible + Not enrolled)
Кое-какая проблема с разбавлением конверсии
Поразмышляйте на досуге над такой ситуацией:
- Все пользователи в тестовой группе, которые сделали заказ до попадания в эксперимент, не сделали ни одного заказа после попадания в эксперимент
- Все пользователи в контрольной группе, которые сделали заказ до попадания в эксперимент, сделали 1 и более заказов после попадания в эксперимент
Очевидно, что фича оказала негативный эффект на заказавших до попадания в эксперимент пользователей. Зафиксирует ли это локальная метрика? А разбавленная?
Всех с наступающим!
Мой тгк @abtesticles
P.S. Раздел для моих групис и фэнов
Если вы читали мою статью "Триггеринг, разбавление эффекта и CUPED на стероидах", то возможно помните, что там приводилась другая формула для разбавления процентного прироста. Несложно показать, что она полностью эквивалентна подходу от Eppo, но без использования 1 / t_exp. То есть работает только при раскатке эксперимента на 100% пользователей, стриггеривших фиче флаг
