Ratio-метрики в A/B-тесте: дельта-метод и кластеры
Как считать отношение сумм, дельта-метод, линеаризацию и бутстреп по пользователям. На сессиях учебного A/B-теста среднее пользовательских долей меняет знак.
Содержание статьи
Ratio-метрика делит одну сумму на другую: клики на показы, заказы на сеансы, выручку на заказ. Если вариант A/B-теста назначен пользователю, его сеансы зависимы. Для оценки отношения сумм считайте числитель и знаменатель по каждому пользователю, а дисперсию — дельта-методом, линеаризацией или бутстрепом целых пользователей. Наивный интервал по отдельным сеансам может быть слишком узким. При этом средняя доля на пользователя — другая метрика: в учебном DragonKeep она меняет даже направление сравнения.
Что именно делим на что
Представьте вопрос: какая доля игровых сеансов достигает пятого уровня за первые 14 дней после назначения? Для каждого игрока обозначим X числом сеансов, Y числом сеансов с уровнем 5 или выше. Целевая метрика для варианта — сумма Y по всем назначенным, делённая на сумму X. Игрок с десятью сеансами в этой метрике весит больше игрока с одним. Это осознанный выбор: нас интересует доля всех сеансов, а не опыт среднего активного игрока.
CTR, конверсия сеанса и средний чек тоже отношения, но их знаменатели описывают разные популяции. У среднего чека X — число заказов, Y — деньги; у CTR X — показы, Y — клики. Некоторые пользователи имеют X=0. В отношении сумм они остаются в наборе назначенных с нулевым вкладом, а деление Y/X для каждого из них невозможно. До расчёта письменно определите метрику, окно, способ отбора и единицу назначения.
Простой пример показывает разницу без статистики. У первого игрока один сеанс и один успех, у второго девять сеансов и ноль успехов. Доля среди всех сеансов равна 1/10=10%, а средняя доля двух активных игроков — (100%+0%)/2=50%. Ни одно число не «правильнее» само по себе: первое описывает случайный сеанс, второе — случайного активного игрока. Если вариант меняет число сеансов сильнее, чем успешность каждого, два показателя могут идти в разные стороны. Поэтому название метрики без формулы и знаменателя недостаточно для карточки эксперимента.
| Формула | Вопрос | Вес пользователя |
|---|---|---|
| ΣYᵢ / ΣXᵢ | доля среди всех сеансов | пропорционален числу сеансов |
| mean(Yᵢ / Xᵢ), Xᵢ>0 | средняя доля активного игрока | каждый активный игрок одинаково |
Почему назначение важнее числа строк
В onboarding_v2 варианты назначены игрокам: exposures.variant хранит единственное назначение для каждого user_id. Игрок может открыть игру несколько раз за день. Если считать 87 794 сеанса независимыми наблюдениями, стандартная ошибка будет привязана к числу строк, хотя реальную независимую рандомизацию прошли 17 003 игрока. У усердных игроков поведение соседних сеансов обычно похоже, а изменение онбординга может изменить и частоту входов.
В расчёт входят sessions.csv и 186 дополнительных D7-сеансов из extra_sessions_onboarding_v2.csv: это часть учебного эффекта, а не новые игроки. Окно — дни 0–13 от экспозиции. В нём 103 назначенных без сеансов; не подменяйте назначенную популяцию только активными при описании размера групп. Если число сеансов само меняется под действием теста, отношение сумм измеряет долю в изменившемся потоке сеансов. Его причинная интерпретация отличается от «средний игрок стал успешнее».
Проверка зерна данных должна идти до расчёта интервала. Для onboarding_v2 можно потребовать ровно одну строку назначения на игрока, уникальный session_id после объединения файлов и отсутствие сеансов за пределами соответствующего окна. Затем посчитайте число игроков с нулевым X и распределение X среди остальных: небольшой процент очень активных способен сильно изменить взвешенное отношение. Если в основной и дополнительной выгрузке один и тот же session_id встречается дважды, COUNT(*) даст неверный знаменатель. В коде используется nunique, а в рабочем ETL лучше явно проверять и устранять такие дубли.
Число в учебном эксперименте
У контроля 28 852 успешных сеанса из 43 596: 66,180%. У теста 29 188 из 44 198: 66,039%. Разница test минус control — −0,141 процентного пункта. В абсолютных числах успешных сеансов больше в test, но и общее число сеансов больше; доля чуть ниже. Сравнение одного числителя без знаменателя выдало бы противоположный смысл.
Среднее индивидуальных долей только среди активных игроков составляет 57,249% в control и 57,289% в test: уже +0,040 п.п. Эти две оценки не противоречат друг другу. У игроков разное число сеансов, и веса формул различаются. Поюзерный t-тест индивидуальных долей не обязательно неверен: он отвечает на вопрос о среднем активном игроке. Он неверен как тест отношения сумм, если команда решила по доле всех сеансов.
| Вариант | Игроки | Сеансы | Достигли уровня 5+ | Доля сеансов |
|---|---|---|---|---|
| control | 8 534 | 43 596 | 28 852 | 66,180% |
| test | 8 469 | 44 198 | 29 188 | 66,039% |
Дельта-метод: от отношения к остаткам
Для группы с n независимо назначенными пользователями положим R=Ȳ/X̄, где Xᵢ и Yᵢ — пользовательские суммы. Малое случайное изменение Ȳ и X̄ приближённо меняет R на (ΔȲ−RΔX̄)/X̄. Отсюда оценка дисперсии: Var(R) ≈ Var(Yᵢ−RXᵢ)/(n·X̄²). Развёрнутая числительная часть — Var(Yᵢ)−2R·Cov(Xᵢ,Yᵢ)+R²Var(Xᵢ). Ковариация здесь существенна: больше сеансов обычно означает больше успешных сеансов.
Для разности вариантов складываем оценки дисперсии двух независимо назначенных групп и берём квадратный корень. Затем при достаточно большом числе пользователей применяем нормальное приближение для интервала разности. Это асимптотический метод: он не обещает хорошего покрытия при нескольких кластерах, крайне тяжёлом хвосте или знаменателе около нуля. Именно поэтому рядом полезна проверка бутстрепом целых пользователей.
SE(R) ≈ √[ Var(Yᵢ − R·Xᵢ) / (n · mean(Xᵢ)²) ]Дисперсия берётся по пользователям; R — отношение сумм в этой группе.
Как получить интервал на данных
В контрольной группе пользовательская стандартная ошибка отношения около 0,237 п.п., в тестовой — 0,235 п.п. Для их разности дельта-метод даёт 0,333 п.п.; 95%-й интервал — от −0,795 до +0,512 п.п. Ноль внутри. Этой метрикой нельзя доказать ни улучшение, ни ухудшение доли успешных сеансов.
Наивный расчёт по бинарным сеансам дал стандартную ошибку 0,320 п.п. и интервал [−0,767; +0,485] п.п. Здесь он примерно на 4,4% уже по SE. Это измеренная разница в данном наборе, а не закон «любой событийный интервал сильно уже». В других данных знак и масштаб расхождения зависят от корреляций, числа событий на пользователя и распределения размеров кластеров. Если метрика и назначение живут на разных уровнях, используйте кластерный расчёт без обещания конкретной поправки.
Узость интервала не единственный тест корректности. Если у каждого человека ровно один сеанс, пользовательский и событийный уровни совпадут. Если успешные сеансы одного человека коррелируют отрицательно или размеры кластеров связаны с исходом особым образом, наивный вариант может дать неожиданное направление ошибки. Главное — не пытаться вывести нужную корректировку из общего множителя «среднее число сессий на игрока»: такая поправка не знает ни Cov(X,Y), ни зависимости успехов внутри человека. Считайте её на пользовательских X/Y и показывайте неопределённость рядом с оценкой.
Категории методов; высота — полная ширина интервала, а не величина эффекта. Границы указаны в тексте.
Код: агрегируйте игрока до статистики
Ниже вычисление на выгрузках. groupby создаёт по одной паре X/Y для каждого игрока, а левое соединение сохраняет назначенных без сеансов. Параметр ddof=1 даёт выборочную дисперсию пользовательских остатков. Разница оценивается как test минус control. Код показывает принцип; для продуктового пайплайна отдельно контролируйте дубли session_id, пропуски level_reached и дату экспозиции.
import numpy as np
import pandas as pd
from scipy.stats import norm
base='dataset/gamedev/'
e=pd.read_csv(base+'exposures.csv',parse_dates=['exposure_date'])
s=pd.concat([pd.read_csv(base+'sessions.csv',parse_dates=['session_date']),pd.read_csv(base+'extra_sessions_onboarding_v2.csv',parse_dates=['session_date'])])
e=e.loc[e.exp_id.eq('onboarding_v2'),['user_id','variant','exposure_date']]
j=e.merge(s,on='user_id',how='left')
j=j.loc[j.session_date.ge(j.exposure_date)&j.session_date.lt(j.exposure_date+pd.Timedelta(days=14))].copy()
j['hit']=j.level_reached.ge(5).astype(int)
u=e[['user_id','variant']].merge(j.groupby('user_id').agg(Y=('hit','sum'),X=('session_id','nunique')),on='user_id',how='left').fillna({'Y':0,'X':0})
res={}
for group in ['control','test']:
a=u.loc[u.variant.eq(group)]
r=a.Y.sum()/a.X.sum()
se=np.sqrt(np.var(a.Y-r*a.X,ddof=1)/(len(a)*a.X.mean()**2))
res[group]=(r,se)
d=(res['test'][0]-res['control'][0])*100
se=np.hypot(res['test'][1],res['control'][1])*100
print(round(d,4),round(se,4),np.round([d-1.96*se,d+1.96*se],4))
assert abs(d+0.141197)<.00001
assert abs(se-0.333423)<.00001Кластерный бутстреп проверяет приближение
Для каждой группы пересэмплируйте строки пользовательской таблицы X/Y с возвращением. В каждом повторе заново сложите Y и X и возьмите отношение, затем разность test минус control. 2 000 повторов с seed 20260928 дали процентильный интервал [−0,810; +0,493] п.п. Он близок к дельта-интервалу на этих крупных группах. Близость двух методов повышает доверие к вычислению, но не подтверждает валидность самого эксперимента или бизнес-полезность выбранной метрики.
Если вместо пользователя пересэмплировать сеанс, вновь разорвёте связь событий одного человека. Если игроки рандомизированы домохозяйствами, даже пользовательский бутстреп окажется слишком мелким. В разборе бутстрепа показано, почему статистическая единица должна следовать единице назначения. Число повторов контролирует вычислительный шум, а число кластеров — доступную информацию.
Дельта-интервал симметричен вокруг точечной разницы из-за нормального приближения. Процентильный бутстреп не обязан быть симметричным: у отношений с редким знаменателем распределение может иметь длинный хвост. На этих данных границы очень похожи, и это позволяет сосредоточиться на решении, а не споре о методе. Но при малом трафике или метрике вроде рублей на редкий заказ сравните форму бутстреп-распределения и проверьте, нет ли повторов с нулевым суммарным X. Если такие повторы встречаются, простое деление перестаёт определяться; сначала нужен пересмотр метрики или дизайна.
В продуктовой записке достаточно указать один основной интервал и рядом чувствительность к альтернативному методу. Не усредняйте границы дельта-метода и бутстрепа: это не две независимые оценки эффекта, а два способа оценить неопределённость одних и тех же пользовательских данных. Если они расходятся настолько, что меняется решение, вернитесь к распределению X, редким крупным кластерам и предпосылке нормального приближения.
Линеаризация: почти тот же ответ в привычной таблице
Возьмите общий опорный коэффициент R₀=ΣY/ΣX по двум группам. Для каждого игрока вычислите Lᵢ=Yᵢ−R₀Xᵢ. Это аддитивная величина на игрока, поэтому её можно сравнивать обычным поюзерным анализом. Чтобы вернуться к шкале исходного отношения, разделите разность средних L на среднее X по объединённым группам. В нашем примере R₀=0,661093, разница на исходной шкале −0,141192 п.п., SE 0,333424 п.п.
Линеаризация удобна, когда инфраструктура умеет сравнивать пользовательские суммы и сложно добавлять отдельный движок для каждого ratio. Но выбор R₀, весов, способа нормировки и стандартной ошибки нужно зафиксировать. Если показать L как готовую метрику без возвращения к исходной шкале, продуктовая команда прочитает абстрактные «остатки» вместо понятной доли. Для точной формулы и проверки чисел храните один воспроизводимый скрипт.
Что делать, если тест меняет сам знаменатель
Новый онбординг может увеличить число игровых входов. Тогда даже при неизменном качестве каждого игрока отношение сеансов пересчитывается с новыми весами. Анализ отношения сумм не отвечает на вопрос «стал ли каждый игрок чаще достигать уровня?». Для него заранее определите пользовательскую метрику: например, долю игроков, хотя бы раз достигших уровня 5 к дню 14, или среднюю индивидуальную долю среди активных, отдельно решив судьбу нулевых X.
Полезно показывать рядом числитель и знаменатель по группе, а не только итоговый ratio. Если разница знаменателей велика, разбирайте механизм: больше новых входов, смена состава активных, повторные сеансы успешных игроков. Изменение способа весов после просмотра данных создаёт ещё одну непредусмотренную проверку. Основная метрика и guardrail помогают закрепить вопрос до запуска.
Пять ошибок, из-за которых интервал обманывает
Первая — объявить сеанс единицей независимого назначения. Интервал может сузиться, и команда «увидит» эффект раньше времени. Вторая — заменить отношение сумм средним Y/X без объявления нового estimand: особенно опасно, когда активность неравномерна; на DragonKeep знак меняется. Третья — исключить игроков с X=0 молча. Доли среди активных могут сместиться из-за теста, который сам определяет активность.
Четвёртая — считать Var(Y) и Var(X) отдельно без члена ковариации. Это нарушает формулу дельта-метода и меняет точность. Пятая — собрать только основной файл сеансов и забыть добавочные D7-события теста: числители уже не совпадут с учебной базой. Шестая — использовать дельта-приближение при нулевом знаменателе или нескольких кластерах. Формула может выдать число, но оно не будет хорошим основанием для релиза.
Частые вопросы о ratio-метриках
Средний чек — ratio? Да, сумма выручки делится на число заказов. Но если тест меняет вероятность заказа, условная метрика среди заказов может скрыть эффект на выручку на назначенного пользователя. Показывайте обе.
Почему нельзя t-тест по пользователям? Можно, если пользовательская величина соответствует вопросу: например, среднее число заказов на назначенного. t-тест средних индивидуальных Y/X проверяет иной estimand, чем отношение сумм.
Дельта-метод лучше бутстрепа? В больших группах он быстро даёт приближение; бутстреп полезен как проверка. При маленьком числе независимых пользователей оба требуют осторожности и анализа дизайна.
Что делать с пользователями без сеансов? Для отношения сумм оставьте их в таблице с X=Y=0, но не пытайтесь делить ноль на ноль. Для средней индивидуальной доли заранее решите, входят ли они и как трактуются.
Решение по эксперименту и следующий шаг
Доля сеансов уровня 5+ в тесте ниже всего на 0,141 п.п., а пользовательский интервал от −0,795 до +0,512 п.п. не отделяет снижение от нуля. По этой вторичной метрике нельзя объявить новый онбординг вредным или полезным. Основной D7 retention onboarding_v2 вырос; решение по запуску принимайте по заранее выбранной основной метрике и guardrail, учитывая их пороги. Не подбирайте ratio задним числом ради нужного вердикта.
Сегментный разбор можно потренировать в SQL-симуляторе. Для полного протокола эксперимента вернитесь к хабу A/B-тестирования. Перед несколькими разрезами зафиксируйте семейство гипотез: поправки для множественных сравнений помогут не принять случайный канал за открытие.
Материалы по теме

Ошибки первого и второго рода в A/B-тесте: цена решения
Что такое α, β и мощность, как размер выборки меняет риск и почему p-value не доказывает отсутствие эффекта. Симуляция 1 000 экспериментов на данных DragonKeep.

Бутстреп в статистике: интервалы для метрик A/B-теста
Как построить процентильный бутстреп по пользователям: разница средней выручки, медиана и ARPPU на учебном A/B-тесте, код Python и ограничения метода.

CUPED в A/B-тесте: когда снижение дисперсии работает
Формула CUPED, предэкспериментальная ковариата и выигрыш в точности на данных DragonKeep. Почему у новых игроков метода нет, а у старых выигрыш всего 0,11%.