Бакеты вместо миллионов строк: как мы ускоряем рассчёты A/B-тестов без потери точности

Представьте, что у вас одновременно запущено несколько десятков экспериментов. В каждом миллионы пользователей и несколько метрик с ежедневным пересчётом. У нас как раз такой кейс, поэтому ещё при проектировании платформы мы подумали, что считать метрики на уровне пользователей будет слишком дорого и медленно…
Решили попробовать через бакетирование. Все пользователи в рамках одного эксперимента разбиваются на 256 бакетов, поэтому статистика считается уже по бакетам. В результате вычислительная сложность и объём хранимых данных сокращаются на порядки. Вместо миллионов строк мы работаем всего с 512, поскольку для двух групп нужно обработать по 256 бакетов.
Но сразу возникает вопрос: а не теряется ли при этом статистическая корректность? Ведь нам важно оценивать эффект именно на пользователя, а не на бакет, работать со всеми типами метрик, включая ratio, и поддерживать всевозможные сплиты. Кроме того, мы хотим применять CUPED, который снижает дисперсию через исторические данные, но ещё сильнее усложняет расчёты.
Всем привет! На связи команда ABSalute, а именно аналитик Юля и руководитель аналитики Денис. Мы развиваем А/Б-платформу для онлайна X5. В этой статье мы разберём, как устроена статистическая машина внутри ABSalute. Мы пройдём основные шаги нашего вычислительного пайплайна, включая линеаризацию, взвешенную регрессию и CUPED, и покажем, что бакетирование не ломает оценку эффекта на пользователя.
Как мы сжимаем данные
Механика бакетирования
Прежде чем переходить к статистике, нужно разобраться, с какими именно данными мы работаем и как они устроены.
В нашей системе каждый пользователь, попавший в эксперимент, получает два признака. Первый показывает, к какой группе он относится, контрольной или пилотной. Обозначаем его как . Второй определяет номер одного из 256 бакетов. Обозначаем его как
. Оба признака вычисляются по
и
, но через разные процедуры. Распределение в группу происходит в два этапа.
Этап 1. Попадание в эксперимент.
Каждый эксперимент проводится на слое, или плоскости, где могут одновременно работать несколько тестов. Слой делится на слоты, и эксперименту выделяется столько слотов, сколько процентов трафика ему требуется. Слот пользователя вычисляется так:
Пользователь участвует в эксперименте, только если его слот заранее для этого зарезервирован. Это исключает пересечение конфликтующих экспериментов на одном слое.
Этап 2. Распределение по группам.
Слоты эксперимента делятся на 100 бинов, а их количество для каждой группы определяется сплитом. Например, при сплите 50/50 бины поровну делятся между контрольной и пилотной группами.
Бин пользователя вычисляется по формуле:
где — уникальная строка для каждого теста, а группа пользователя
зависит от того, в какой бин он попал.
Распределение в бакет
Номер бакета вычисляется отдельной формулой:
Для этого используется другая хэш-функция и другая . Поскольку входит в хэш, номер бакета для одного и того же пользователя рассчитывается заново в каждом эксперименте и распределения по бакетам не коррелируют между собой.
Почему бакет не зависит от группы
Группа и бакет вычисляются разными хэшами с разными входными данными. Хорошая хэш-функция не создаёт систематической связи между полученными значениями. На практике это означает, что в каждом бакете пользователи распределены по группам ровно в той же пропорции, что и во всём эксперименте. Если сплит 90/10, то в каждом бакете тоже будет примерно 90% пользователей из пилотной группы и 10% из контрольной. Бакет ничего не знает о группе.
Структура датафрейма
Пересечение бакета и группы образует ячейку . Всего получается 512 ячеек, по 256 в каждой группе. Для каждой ячейки мы храним три числа.
поле | обозначение | смысл |
|---|---|---|
numerator | Сумма вкладов пользователей | |
denominator | Сумма вкладов пользователей | |
users_cnt | Число уникальных пользователей в ячейке |
Все метрики в ABSalute записываются в едином формате «числитель/знаменатель», но содержательно делятся на два типа.
У ratio-метрик числитель и знаменатель суммируют разные события пользователя. Классический пример — средний чек. В числителе выручка, в знаменателе число заказов. Формально метрика определяется как отношение математических ожиданий:
где — вклад пользователя в числитель,
— вклад в знаменатель.
Обратите внимание: это отношение средних, а не среднее отношений. Разница принципиальна.
Simple-метрики считаются как среднее на пользователя. Например, средняя выручка, число сессий и конверсия. Их тоже можно записать как дробь: полагаем , и тогда знаменателем становится число пользователей:
Формально simple — частный случай ratio, и весь дальнейший пайплайн одинаков для обоих типов. Это принципиальное архитектурное решение. Разделение на simple и ratio нужно только на этапе агрегации по периоду рассчёта, но не в статистических вычислениях. Третье поле users_count отличает наш датафрейм от наивной агрегации и делает возможным все дальнейшие вычисления, включая взвешенную регрессию, корректную оценку эффекта и CUPED.
Две ловушки, в которые легко попасть
После бакетирования у нас остаётся всего 256 наблюдений на группу, поэтому может возникнуть соблазн посчитать метрику для каждого бакета и усреднить. Но это может привести к ошибкам.
Первая ловушка: ratio-метрики нельзя усреднять.
Вспомним, что целевая метрика — это отношение математических ожиданий
. Если для каждого пользователя посчитать индивидуальную дробь
и усреднить результаты, мы получим оценку совсем другой величины
. В общем случае, эти два функционала не совпадают.
Пользователи с малым знаменателем дают экстремальные значения дроби и перетягивают среднее. По той же причине нельзя рассчитать по бакету и усреднить результаты. Корректная оценки метрики — это отношение полных сумм:
Такая оценка состоятельна, но у неё есть практическое ограничение. Отношение сумм представляет собой нелинейную функцию от данных, поэтому стандартные инструменты вроде t-тест, регрессия, CUPED нельзя применять к нему напрямую. Все они требуют аддитивной величины на уровне пользователя, которую можно суммировать, усреднять и подставлять в регрессию. Именно такую величину даёт линеаризация.
Вторая ловушка: неравные размеры бакетов.
Мы будем оценивать эффект одной регрессией по всем 512 бакетам, объединяя контрольную и пилотную группы. Если сплит равный (50/50), благодаря хэшированию размеры бакетов почти одинаковые, и проблема невелика. Но далеко не все эксперименты используют равный сплит.
При сплите 90/10 в пилотной ячейке одного бакета может быть 3 600 пользователей, а в контрольной всего 400. Для регрессии это два равноправных наблюдения, хотя в первом в девять раз больше пользователей. Если не учитывать это в модели, то 400 пользователей контрольной группы будут влиять на результат так же сильно, как 3 600 пользователей пилотной группы. В итоге мы будем оценивать эффект на ячейку, хотя нам нужен эффект на пользователя.
Эту проблему решает взвешенная регрессия, в которой вес каждой ячейки зависит от числа пользователей.
Таким образом, для первой ловушки нужна линеаризация, которая превращает ratio-метрику в аддитивную величину. А для второй учитывать размеры ячеек с помощью весов.
Линеаризация: от поюзерной величины к бакетным агрегатам
Сначала разберём линеаризацию и оценку эффекта с помощью регрессии на поюзерных данных. Затем покажем, почему эти вычисления можно корректно перенести на бакеты.
Дельта-метод: линеаризация на уровне пользователя
Для корректной работы с ratio-метрикой, нам нужно преобразовать отношение двух случайных сумм в линейную комбинацию. С этим нам поможет дельта-метод.
Разложим функцию в ряд Тейлора первого порядка вокруг точки
c
:
Раскрываем скобки:
Слагаемое — это одинаковая для контрольной и пилотной групп константа. При вычислении ATE мы вычитаем среднее значение линеаризованной величины в контрольной группе из среднего значения в пилотной, поэтому одинаковая для обеих групп константа сокращается.
Оставшаяся часть является линейной функцией от . Это и есть та аддитивная величина на уровне пользователя, которую мы искали в предыдущем разделе. Её можно суммировать по пользователям, усреднять и подставлять в регрессию. Назовём её линеаризованной величиной для пользователя
и обозначим
:
В качестве точки разложения возьмём средние значения на пользователя в контрольной группе:
Логика здесь простая. При нулевом эффекте у обоих групп одинаковое распределение, поэтому точка контроля становится естественной базовой линией для разложения.
Рассмотрим пример со средним чеком, который рассчитывается как отношение выручки к числу заказов.
В контрольной группе рублей выручки на пользователя, а
заказа. Базовый средний чек составляет 1500 рублей.
Линеаризованный средний чек рассчитывается так:
Для пользователя со средним поведением получаем
. А если средняя выручка в пилотной группе вырастет до 3200 рублей, при тех же 2 заказа на пользователя, разность средних
рублей будет совпадать с истинным
рублей.
Будем разбавлять формулы иллюстрациями. Все графики в этой статье построили на одном симулированном эксперименте с параметрами из примера выше. Мы сгенерировали около миллиона пользователей и разбили их на 256 бакетов и на группы контроль/пилот = 90/10. У каждого пользователя два устойчивых признака: интенсивность заказов (в среднем два за период, у трети пользователей заказов не было) и выручка в чеке (в среднем 1500 ₽). Посмотреть код симуляций и графиков можно в репозитории.

Поюзерная модель оценки ATE
Применению регрессии в A/B-экспериментах посвящена отдельная статья наших коллег, которую мы рекомендуем для более глубокого погружения. Здесь ограничимся основными моментами.
Запишем линейную модель для отдельного пользователя, используя линеаризованную метрику :
где — базовый уровень линеаризованной метрики на пользователя в контроле,
— эффект на пользователя,
— индикатор группы,
— случайная ошибка модели на уровне пользователя.
Матрица регрессоров для пользователя u имеет вид:
Чтобы оценить и по данным, применим обычный метод наименьших квадратов, или МНК (в англоязычной литературе он обозначается как OLS — ordinary least squares). С его помощью подбираются такие
и
, при которых сумма квадратов остатков
минимальна. В нашей модели это даёт интуитивно ожидаемый результат — разность средних значений линеаризованной метрики между группами:

Однако есть нюанс — для вычисления нужны миллионы строк. Но сейчас мы покажем, что можно перейти к вычислениям на бакетах без потери корректности.
Переход к бакетам: суммирование поюзерной модели
Поскольку уравнение линейно, мы можем просуммировать обе стороны по всем пользователям внутри ячейки :
Все пользователи в ячейке принадлежат одной группе, поэтому
для всех
. После преобразования сумм получаем:
где — сумма линеаризованных значений по ячейке,
— сумма ошибок.
Таким образом, получаем матрицу регрессоров для ячейки:
Ничего не теряется потому что равенство — точное алгебраическое тождество, следствие линейности преобразования. Поэтому три числа на ячейку — , , достаточно, чтобы оценить ATE без поюзерных данных. Точку линеаризации также оцениваем по контрольной группе:
После этого линеаризованное значение для бакетной ячейки вычисляется по формуле:

Зафиксируем обозначения до конца статьи:
— линеаризованное значение для пользователя,
— сумма по ячейке (линеаризованный бакет),
— среднее на пользователя.
Для simple-метрик (, ) линеаризация сводится к центрированию
. На бакетах
. Константа сокращается при разности средних, и весь пайплайн работает без изменений.
Что мы получили и чего нам не хватает
Разность средних линеаризованных значений приближает ATE с точностью до остатка второго порядка:
где . Остаток
— систематическая ошибка (bias), не стохастический шум.
В итоге линеаризация даёт асимптотически несмещённую оценку, поскольку bias стремится к нулю как квадрат размера эффекта. В типичных A/B-тестах, где эффекты измеряются в долях процента, такой остаток обычно пренебрежимо мал.
Мы получили аддитивную величину для оценки ATE, которая точно агрегируется по бакетам. Используемая бакетная модель согласована с поюзерной моделью. Домножение на возникло из суммирования по пользователям в ячейках, поэтому необходимая для оценки эффекта информация не потерялась.
Однако нам всё ещё нужен корректный способ оценить эту модель. Потому что суммирование поюзерных ошибок по ячейке создаёт проблему: если имеют одинаковую дисперсию и независимы внутри ячейки, то
Получается, что дисперсия ошибки растет с размером ячейки, и возникает гетероскедастичность. Например, для ячейки с 4 000 пользователями дисперсия ошибки будет в четыре раза больше, чем для ячейки с 1 000 пользователей. Обычный МНК не учитывает эту зависимость и рассматривает все наблюдения так, будто дисперсия ошибок у них одинакова, и в результате подстраивается под большие ячейки, у которых большие и большие остатки, в ущерб малым. Поэтому оценка по бакетным суммам не воспроизводит поюзерную модель, а стандартные ошибки, рассчитанные обычным способом, оказываются некорректными. И чтобы учесть разный размер ячеек и вернуть каждому пользователю одинаковый вклад в результат, добавим в регрессию веса.
Взвешенный МНК (WLS)
Почему взвешенный МНК даёт эффективную оценку при гетероскедастичности
Каждому наблюдению i присваивается вес , и регрессия минимизирует взвешенную сумму квадратов остатков:
Большие ячейки имеют более высокую дисперсию ошибки, поэтому получают меньший вес и перестают доминировать при оценке коэффициентов. Остаётся понять, почему подходят именно такие веса. Есть стандартный факт из теории регрессии: МНК с весами эквивалентен обычному МНК на преобразованных данных, где каждое наблюдение домножено на
. Для наших весов это означает деление на
. Это преобразование не нужно делать руками, потому что оно зашито внутрь формулы взвешенного МНК. Оно помогает увидеть, почему веса
работают.
Разделим обе стороны формулы для линеаризованного бакета на
Теперь посмотрим на дисперсию преобразованной ошибки:
Дисперсия больше не зависит от и становится одинаковой для всех наблюдений. Проблема гетероскедастичности решена. Только делить нужно именно на , потому что чтобы разделить дисперсию на , нужно разделить случайную величину на . Таким образом, мы подаём в регрессию исходные данные
и веса
.

Матричная запись
В матричной форме WLS-оценка записывается как:
где — вектор зависимой переменной,
Это стандартная формула обобщённого МНК: вместо минимизации мы минимизируем
, что эквивалентно OLS на преобразованных данных.
Что оценивает τˆ_L
Матричная запись компактная, но непрозрачная. Она не показывает явно к какой именно оценке эффекта мы приходим. Поэтому разберём вычисления по шагам. Любую МНК или взвешенную МНК-оценку можно найти из системы нормальных уравнений. Это условие минимума обычной или взвешенной суммы квадратов остатков, записанное как система линейных уравнений на коэффициенты.
Для обычного OLS условие даёт систему
. Для МНК с весами
аналогично:
.
Вычислим и
для нашей модели.
Введём обозначения: — суммарное число пользователей в группе
, и
— суммарная линеаризованная метрика по группе .
Начнём с . Каждое слагаемое — это , где
,
:
Поскольку , имеем
. После суммирования по всем ячейкам получаем:
Аналогично :
Первая компонента: .
Вторая: .
Система нормальных уравнений :
Мы получили систему двух линейных уравнений с двумя неизвестными и . Решим её.
Это взвешенное среднее линеаризованной метрики на пользователя в контрольной группе, то есть именно та величина, которую должен оценивать коэффициент . Теперь подставим полученное выражение в уравнение:
Нигде в выводе не использовалось равенство размеров групп. Полученная формула для верна для любого сплита. При неравном сплите , и это автоматически учитывается — каждая группа нормируется на своё суммарное число пользователей.
Эквивалентность поюзерной и побакетной оценок
Полученная оценка — это разность взвешенных средних линеаризованной метрики между пилотной и контрольной группами, где веса пропорциональны числу пользователей в ячейке. Но мы знаем, что — сумма по ячейке. Подставим это выражение в формулу оценки. После раскрытия числителей получим суммы по отдельным пользователям:
Это в точности та оценка, которую мы получили бы, запустив МНК на поюзерной модели с миллионами строк. Бакетирование со взвешенным МНК не вносит дополнительного смещения сверх смещения линеаризации .

Вот зачем нужна вся цепочка: суммирование сжимает данные с миллионов строк до 512, взвешенный МНК корректно обрабатывает сжатое представление, а на выходе мы получаем ту же оценку, которую бы дали поюзерные вычисления.
Стандартные ошибки: что мы теряем и насколько это критично
Точечная оценка тождественна поюзерной, но со стандартными ошибками ситуация другая, и здесь нужно пояснить.
Поюзерная оценка дисперсии основана на индивидуальных остатках :
где N — общее число пользователей в эксперименте. В наших экспериментах N обычно имеет порядок , поэтому для оценок ниже мы будем брать
как ориентир.
Побакетная оценка основана на бакетных остатках :
где — число ячеек (бакеты контроля плюс бакеты пилота).
Обе оценки несмещены: . Побакетная оценка не занижает и не завышает дисперсию в среднем. Различие заключается в точности самой оценки: она вычисляется по 510 степеням свободы вместо
.
Относительная погрешность оценки дисперсии при степенях свободы составляет
. Для поюзерной оценки:
, для побакетной:
.
Но стандартная ошибка зависит от , а не от
. Из разложения в ряд Тейлора следует что если величина отклоняется на
, то её корень отклоняется примерно на
. Поскольку у нас
, относительная погрешность оценки вдвое меньше, чем у . Отсюда погрешность SE при бакетном подходе получается около 6%/2 = 3%.
Иными словами, доверительный интервал, построенный на побакетной SE, будет шире поюзерного примерно на 3%. Для принятия решений по эксперименту это пренебрежимо малая цена за сокращение данных и скорость.

CUPED: снижаем дисперсию через прошлое
Идея
У нас есть корректная оценка ATE с корректными стандартными ошибками. Но чем больше дисперсия оценки, тем шире доверительный интервал и тем дольше нужно держать эксперимент, чтобы обнаружить реальный эффект. CUPED, или Controlled-experiment Using Pre-Experiment Data решает эту проблему. Он основан на том, что поведение пользователя до эксперимента обычно коррелирует с его поведением во время эксперимента. Кто много покупал на прошлой неделе, скорее всего, будет много покупать и на этой. Такая корреляция становится источником предсказуемого шума, поскольку часть вариации пост-метрики объясняется устойчивыми привычками пользователей. CUPED «вычитает» эту предсказуемую часть. Эффект остаётся несмещённым, потому что пре-период не зависит от группы, а дисперсия падает. На практике это позволяет обнаружить более маленькие эффекты, либо быстрее находить тот же эффект. В ABSalute в качестве ковариаты мы используем ту же метрику, предварительно линеаризованную, за три недели до старта эксперимента.
Поюзерная модель CUPED.
Начнём с уровня пользователя, а затем агрегируем. Классический CUPED определяет скорректированную метрику так:
где ,
— линеаризованные метрики за период эксперимента и за период до эксперимента соответсвенно,
— коэффициент, минимизирующий дисперсию
.
Варьируя , находим минимум:
Это в точности коэффициент линейной регрессии на
. А значит, вычитание ковариаты вручную эквивалентно добавлению как регрессора в линейную модель:
Регрессия сама подберёт оптимальное (минимизирующее дисперсию) , и коэффициент при
будет CUPED-оценкой эффекта. Никакие дополнительные шаги не нужны, поскольку всё решается одной регрессией.
Почему оценка остаётся несмещённой
Здесь возникает ключевой вопрос. Не искажает ли добавление ковариаты оценку ? Нет, поскольку пользователи рандомизируются в группы до начала эксперимента и независимо от их исторического поведения. Поэтому препериодная метрика не зависит от индикатора группы :
Когда регрессор ортогонален другому регрессору, их коэффициенты оцениваются независимо друг от друга. Формально это следствие теоремы Фриша - Во - Ловелла: если некоррелирована с , то коэффициент при одинаков вне зависимости от того, включена ковариата в модель или нет.
CUPED уменьшает дисперсию остатков . Регрессия «объясняет» часть вариации через и то, что остаётся (), менее шумно. В результате точечная оценка не сдвигается, но её стандартная ошибка уменьшается.
Важная оговорка: мы говорим о несмещённости как оценки линеаризованного ATE. Смещение линеаризации , которое мы обсудили в разделе Линеаризация, по-прежнему присутствует, но CUPED его не увеличивает. Совокупное смещение остаётся на уровне, унаследованном от дельта-метода.
Насколько снижается дисперсия
Дисперсия CUPED-оценки связана с дисперсией базовой оценки:
где — корреляция между и .
Чем сильнее , тем больше выигрыш. Для поведенческих метрик, таких как выручка, сессии, клики, корреляция между тремя неделями до эксперимента и первой неделей эксперимента обычно составляет = 0,6–0,8. Это означает снижение дисперсии на 36%–64%, что позволяет пропорционально сократить необходимый размер выборки. По данным экспериментов в ABSalute, использование CUPED снижает дисперсию на 45–55% в зависимости от метрики.
Переход к бакетам: суммирование поюзерной модели с CUPED
Повторим тот же приём, что в разделе Переход к бакетам. Суммируем обе стороны поюзерного уравнения по всем пользователям внутри ячейки :
Все пользователи в ячейке принадлежат одной группе ( для всех ). Раскрываем суммы:
где ,
.
Матрица регрессоров для ячейки:
Применяем МНК с весами , потому что суммирование создаёт гетероскедастичность , и веса позволяют корректно с ней работать.
Несмещённость в бакетной модели — это прямое следствие поюзерной конструкции. Рандомизация гарантирует ортогональность и
, и включение ортогонального регрессора не смещает коэффициент при
. Коэффициент
оценивает
— то самое оптимальное значение, минимизирующее дисперсию.

Препериод тоже линеаризован, и это важно
В поюзерной модели в качестве ковариаты используется линеаризованная препериодная метрика .
Поскольку преобразование линейно, на бакетах она агрегируется как линейная комбинация препериодных сумм:
где точки линеаризации оцениваются по контрольной группе на данных препериода. Эта конструкция требует пояснения по двум пунктам.
Почему линеаризованная ковариата
Формально в модель можно подставить сырую ratio-метрику . Рандомизация гарантирует независимость от группы для любой преэкспериментальной величины, будь то линеаризованная метрика , сырая дробь или любая другая функция от данных до эксперимента. Оценка останется несмещённой при любом выборе ковариаты.
Но «можно» не значит «оптимально». CUPED снижает дисперсию пропорционально корреляции между ковариатой и зависимой переменной. Если ковариата линеаризована тем же преобразованием, она является линейной функцией от тех же величин с теми же коэффициентами, и корреляция между ними максимальна. Дробь — другая функция тех же данных, и поэтому её корреляция с слабее. Снижение дисперсии будет менее эффективным.
Почему точка линеаризации оценивается на данных препериода
С точки точки зрения несмещенности выбор или формально безразличен.
Однако оценка точки линеаризации для ковариат по препериоду обеспечивает методологическую чистоту: ковариата строго принадлежит препериоду и не зависит от постэкспериментальных данных. На практике при стационарном поведении пользователей , и оба выбора дают близкую корреляцию
с
. Тем не менее пре-периодная точка предпочтительнее с методологической точки зрения. Для каждого периода точка линеаризации оценивается по данным этого же периода, а ковариата не использует никакой информации, появившейся после начала эксперимента.
Заключение
Теперь давайте соберём всё вместе в единую цепочку решений. Мы начали с инфраструктурной проблемы: считать статистику на миллионах пользователей в десятках параллельных экспериментов дорого. Решили её с помощью бакетирования и сжали данные до 512 строк на тест, работая только с агрегатами, но не сломали статистику.
Мы показали, что бакетирование с правильной методологией позволяет получить ту же оценку без компромисса между скоростью и корректностью но с гораздо меньшим объёмом данных.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.