Пересекающиеся эксперименты: как мы навели порядок в A/B-тестах

Привет! Меня зовут Кирилл Кочнев, я руковожу продуктовой аналитикой в hh.ru и веду «Статзначимый подкаст». Каждый месяц мы в компании запускаем больше сотни A/B-экспериментов, и со временем тесты начали пересекаться: разные команды одновременно меняли связанные части пользовательских сценариев, из-за чего результаты искажались.
В этой статье расскажу, как мы подошли к управлению пересекающимися экспериментами: выстроили процесс согласования между командами и бизнес-владельцами, доработали платформу A/B-тестов и превратили это не только в техническое изменение, но и в способ развивать культуру планирования экспериментов.
Пересекающиеся эксперименты и связанные проблемы
Пересекающимися называются эксперименты, которые влияют на результаты друг друга.
Иногда такие пересечения очевидны. Скажем, две команды одновременно проводят эксперименты на одной странице: первая меняет фон на красный, вторая — цвет текста на красный. В пересечении текст становится нечитаемым — совсем не то, что требовалось. Такой конфликт обычно быстро обнаруживается — и по коду, и визуально.

В контексте A/B-экспериментов влияние пересекающихся экспериментов можно проверить прямо в данных через таблицу сопряженности. Для этого мы делим пользователей на четыре группы:
не попали ни в один таргет
попали только в таргет первого эксперимента
попали только в таргет второго эксперимента
попали сразу в оба таргета
Если эксперименты не влияют друг на друга, их эффекты должны просто складываться: например, первый тест дает прирост α1, второй — прирост α2, то на пересечении мы ожидаем увидеть α1 + α2. Если же фактический результат заметно отличается от этой суммы, значит, эксперименты влияют друг на друга: вместе они работают иначе, чем по отдельности.
Однако иногда пересечения могут быть неявными.
Представим, что две команды независимо друг от друга запускают эксперименты с одним и тем же баннером. В первом тесте меняются его дизайн и текст, а во втором баннер вообще скрывается. В результате часть пользователей вместо обновлённого баннера не увидит его вовсе.
Если оба изменения происходят на одном экране, проблему ещё можно заметить в коде. Но если это один сценарий на разных экранах, результаты могут исказиться, а код при этом будет выглядеть нормально. В итоге мы получаем искажённые данные и ноль подозрений, что с ними что-то не так (а подозрения могут возникнуть только после завершения теста).
Поиск решения
Риск неявных пересечений экспериментов был с нами уже давно, но долгое время не влиял на результаты — пока один из экспериментов не продемонстрировал совсем уж неадекватные цифры показов.
Как аналитики и разработчики собственной платформы для A/B-тестов мы могли только подсветить командам, что их тесты относятся к одной части продукта. Нам нужно было быстро дать командам видимость планируемых запусков. Поэтому мы начали с простого процесса бронирования.
Мы быстренько собрали временное решение — вики-страничку, на которой нужно заранее бронировать эксперименты. Для согласования завели чат в корпоративном мессенджере и параллельно стали искать более надёжные способы решения.

Мы выяснили, что в разных компаниях эксперименты проводятся на разных уровнях контроля.
Некоторые большие компании просто запускают все эксперименты ортогонально. Это метод одновременного проведения нескольких независимых экспериментов на одной аудитории, при котором случайное разделение пользователей в одном тесте никак не связано с их распределением в других.
Другие жёстко фиксируют слой страницы, на котором производится эксперимент. Тесты в одном слое не должны пересекаться по трафику. Но в этом случае количество возможных тестов сильно сокращается. Бесконечно делить слой нельзя: трафика будет недостаточно.
Третий вариант — согласовывать эксперименты вручную: внутри команд запускать их как угодно, а между командами договариваться с ответственными, когда мы хотим изменить что-то в другой части продукта.
Мы решили найти промежуточный вариант и зафиксировали не жёсткий набор слоёв, а жёсткий набор блоков продукта. Так появилась новая сущность — Флоу: абстракция уровнем выше отдельного слоя, которая может объединять несколько слоёв, но не охватывает продукт целиком. Наше решение мы построили вокруг него.
Поле «Флоу»
Мы не сразу определились, чем заполнять это поле. Сначала рассматривали список всех экранов продукта. Кроме того, у нас был «список фич» — большая табличка со всей функциональностью, которую вели разработчики. По ней мы могли заранее видеть, что эксперименты проходят на одном и том же экране.
Но это порождало несколько проблем:
Экранов у нас около тысячи. Выборочное поле из тысячи значений — это не слишком удобно.
Эксперименты часто затрагивают несколько экранов. Это также может порождать неявное пересечение, например, одна команда проводит эксперимент на одном экране, а вторая — на другом.
Так что мы решили сделать какое-то более агрегирующее значение. Мы обратились к нашей команде UX, которая обрабатывает обратную связь от пользователей. У них для собственных нужд была создана Miro-доска, где весь пользовательский путь был разбит на блоки CJM.
Вот их-то мы и захардкодили в платформе, немного поправив: где-то добавили новые, а где-то интегрировали бэкендовые элементы, с которыми пользователь напрямую не взаимодействует (например, всевозможные коммуникации или алгоритмы ранжирования).
Ещё раз подчеркну: список Флоу нигде не формируется автоматически. Мы вносим его вручную: хардкодим не слои и не экраны, а конкретные части продукта, соответствующие агрегированным блокам CJM.
Для каждого блока мы ищем ответственного, который может согласовывать эксперименты. Обычно это люди из продукта, которые отвечают за эту часть функционала и могут согласовывать, что здесь будет катиться, а что не будет.
Наше решение
Мы заменили временное решение на постоянное, и на нашей веб-платформе оно теперь выглядит так:

В интерфейсе появилось обязательное поле «Флоу» с захардкоженным списком продуктовых блоков: главная страница, авторизация, оплата и другие. По этому полю можно отфильтровать все эксперименты в календаре, заранее увидеть уже запланированные тесты и согласовать новый запуск с владельцем Флоу.
Здесь есть важный момент: тест нужно заводить заранее. Завести эксперимент день в день будет труднее.
Список тестов отображается на двух страницах: на главной и на дашборде. На главной все эксперименты показаны в виде таблички. По сути, это страница, на которой можно искать эксперименты: добавлять разные фильтры, искать по названию и так далее. Мы добавили туда фильтр по полю Флоу: теперь можно быстро посмотреть, какие эксперименты уже запланированы в нужном продуктовом блоке, и заранее обсудить возможные пересечения с другими командами.
На дашборде те же данные отображаются в виде диаграммы Ганта. Достаточно выбрать нужный Флоу, чтобы увидеть все эксперименты, запланированные в данном блоке. Разные слои выделены цветом, так что внутри одного флоу команды могут завести несколько слоев и в каждом запустить несколько тестов.

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



Как изменился процесс запуска эксперимента
Если продакт, разработчик или аналитик хочет завести эксперимент, то он:
Открывает админку, выбирает нужный Флоу и проверяет, какие ещё эксперименты планируются на эту дату.
По таблице находит владельца Флоу и согласовывает свой эксперимент.
После согласования создаёт эксперимент и заполняет описание: Флоу, даты и другие параметры.
Владелец Флоу ловит уведомление о новом эксперименте. Этот шаг особенно важен: если согласование не состоялось или осталось незавершённым, уведомление помогает вовремя обратить на это внимание.
При этом на платформе нет технического согласования в формате «поставить окей и считать эксперимент одобренным». Вместо этого мы сделали адресные уведомления: у каждого Флоу есть ответственный — конкретный продакт-лид, который отвечает за эту часть продукта. Когда в его Флоу появляется новый эксперимент, он узнаёт об этом и, если видит риск или спорное решение, связывается с командой.
Кстати, у этого решения был неожиданный культурный эффект: оно мотивировало бизнес-заказчиков заводить эксперименты заранее. Ведь если вы хотите точно знать , что ваш эксперимент запустится и не сдвинется по срокам, стоит завести его не накануне, а за месяц. Тогда ваши возможные «соседи» увидят, что у вас тоже планируется эксперимент.
Как я уже говорил, мы предоставляем только инструменты и методологию, а бизнес-заказчики сами договариваются между собой. Но мы можем развести эксперименты — у нас используется система слоев — «сплитов». То есть мы можем поделить пользователей, например, на пять групп и в каждой группе запустить свой эксперимент.
Я бы еще добавил несколько предложений про то, что мы не фиксируем слои, и бизнес-заказчики со своей командой аналитики сами решают, сколько должно быть слоёв и как разводить эксперименты.
Сценарий заказчика
Допустим, я бизнес-заказчик и хочу запустить эксперимент. В нужном Флоу я вижу, что уже запланированы другие эксперименты. Что делать дальше?
Сначала определяю, как буду запускаться: ортогонально с пересекающимися экспериментами или с разделением по времени или по трафику.
Какой из этих вариантов лучше? Вопрос с подвохом.
Когда мы запускаем несколько экспериментов с разделением по трафику, каждый из них получает только часть аудитории. Например, если одновременно идут два эксперимента, вместо одной контрольной и одной целевой группы мы получаем одну контрольную и две целевые. Поэтому каждому эксперименту достаётся меньше пользователей, чем при последовательном запуске. Компенсировать это можно большей длительностью теста, однако количество уникальных пользователей со временем растёт нелинейно: одни и те же пользователи возвращаются. Кроме того, дисперсия некоторых метрик может не уменьшаться, а расти. Например, один пользователь за это время может не совершить ни одной покупки, а другой — совершить значительно больше.
При разделении по времени эксперименты запускаются последовательно, поэтому каждый из них получает бо́льшую долю трафика — например, по 50% в контрольной и целевой группах. Однако следующий эксперимент можно запустить только после завершения предыдущего.
Получается, что в одном случае у нас больше доля трафика, а в другом — больше времени. Поэтому однозначного ответа, какой вариант лучше, нет. И мы оставляем его на усмотрение командам. Кроме того, оба сценария можно сравнить с помощью расчёта MDE, который доступен прямо на нашей платформе.

Отдельный важный момент — как запускать эксперименты, если вы хотите увидеть их возможное пересечение. Часто продуктовая команда просит выделить ей отдельные 5–10% трафика под эксперимент, чтобы он точно не пересекался с другими тестами. Такой подход снижает риск взаимного влияния, но у него есть ограничение: вы не сможете проверить, есть ли это влияние на самом деле. Если вы хотите посмотреть пересечение экспериментов и проверить неаддитивность эффектов, то их нужно запускать ортогонально. При запуске с разделением по времени вы увидите результаты каждого эксперимента по отдельности, но не поймёте, как они работают вместе и влияют ли друг на друга.
Почему важно следить за неаддитивностью и почему мы планируем запускать тесты не ортогонально? Тут есть три важных эффекта, которые можно посчитать.

Главная сложность заключается в оценке эффекта. Main effect отличается от uplift тем, что при наличии неортогональных экспериментов в модели появляется interaction term, из-за которого итоговый эффект меняется. Отдельное смещение создают сопутствующие воздействия — например, онбординг, пуши, рассылки и другие активности, запускаемые вместе с фичей. Кроме того, эксперименты могут проходить параллельно или со временным сдвигом. Возникающие эффекты не всегда аддитивны, поэтому их необходимо учитывать при оценке результатов и последующей раскатке.
Поэтому если вы хотите учитывать этот последний эффект — то есть понимать, какой результат фича принесёт после раскатки, — эксперименты нужно выпускать ортогонально.
Каждый тест — это регрессия. У каждого эксперимента есть основной эффект, а если одновременно запущены другие эксперименты, в модели появляются interaction terms — эффекты их взаимодействия. На графике показано, как это работает: есть синий прирост, есть красный прирост, и при аддитивности они складываются. Если совместный результат отличается от их суммы, значит, тесты неаддитивны.

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

Итого, как же заказчику правильно работать с тестами?
Смотреть пересечения на нашей платформе: выбирать нужный Флоу и смотреть другие эксперименты в то же время.
Если есть пересечения (возможный симбиоз или, наоборот, негативный сценарий), то синхронизировать запуски.
Если вам важно понять, повлияли ли эксперименты друг на друга, попросите своего аналитика посчитать interaction effect. Методы для этого уже есть в нашем внутреннем Python-пакете.
При этом два пересекающихся эксперимента нужно запускать примерно в один и тот же период. Тогда аналитик сможет посчитать interaction effect — то есть запустить эту регрессию с тремя элементами и посмотреть, значим ли он.
Мы не считаем это автоматически, хотя технически это возможно. Автоматизация тут породила бы слишком много false positive результатов, а по нашему опыту, эксперименты не так часто влияют друг на друга.
Ещё один вопрос — если мы смотрим пересечения двух экспериментов, почему не посмотреть сразу три, четыре и так далее?
Всё просто: допустим, у нас есть 100 пересекающихся экспериментов, и мы добавляем 101-й. Коэффициенты перед ними будут одинаковые, сами фичи — сильно похожими, и в результате возникнет мультиколлинеарность. То есть наша регрессия будет бесполезна, потому что там будет слишком большая дисперсия (математику таких расчетов я подробно разобрал в отдельной статье).
Поэтому, если вам кажется, что эксперименты могут пересекаться, можно вручную запустить регрессию. Если вам так не кажется, то ничего делать не нужно. Мы тоже не выполняем такой анализ автоматически и никак его не подсвечиваем.
Что показали первые месяцы работы
Наше решение работает уже несколько месяцев, за это время мы столкнулись с несколькими трудностями.
Первая — информирование сотрудников. Мы сделали кучу демо, чтобы рассказать о новом процессе и объяснить, как им пользоваться. Но в каждой компании появляются новички, а кто-то просто пропускает демо. Это нормально, поэтому остаётся только ввести каждого пришедшего в курс дела.
Вторая — разграничение ответственности. Изначально у каждого Флоу был один владелец, но со временем к нам стали обращаться сотрудники с пересекающейся зоной ответственности и просить уведомлять их об экспериментах тоже. Мы пофиксили это: сейчас у каждого Флоу можно указывать сразу нескольких владельцев.
Третья — эксперименты, которые затрагивают несколько Флоу одновременно. Например, эксперимент с топом поисковой выдачи может относиться и к Флоу поиска, и к Флоу AdTech. Сейчас такие случаи приходится учитывать отдельно, поэтому мы добавили возможность выбирать сразу несколько Флоу при создании эксперимента.
Но эти доработки не меняют главного результата: нам удалось навести порядок в A/B-тестах и сделать процесс запуска экспериментов прозрачнее. Теперь команды заранее видят, какие тесты затрагивают один и тот же продуктовый блок, могут синхронизироваться и осознанно выбирать способ запуска: ортогонально, последовательно или с разделением трафика. А владельцы продуктовых блоков лучше понимают, что происходит в их зоне ответственности, и могут подключиться к обсуждению ещё до старта теста, а не после получения странных результатов.
А как вы решаете проблему пересекающихся A/B-тестов? Есть ли у вас похожие процессы или, наоборот, совсем другой подход? Давайте обсуждать в комментариях.
Если вам понравилась моя статья, приглашаю послушать наш новый «Статзначимый подкаст» — в нём мы с коллегами обсуждаем актуальные вопросы из продуктовой аналитики, делимся нашим опытом, прогнозами и переживаниями. Подключайтесь, будет интересно!
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.