Доверительный интервал для любой метрики можно получить без единой формулы: много раз пересобрать выборку из самой себя с возвращением, каждый раз посчитать нужную статистику и взять её 2.5-й и 97.5-й перцентили. Это и есть бутстрап — универсальный способ оценить неопределённость там, где аналитической формулы либо нет (медиана, 90-й перцентиль, ratio-метрика), либо она врёт из-за ненормальных данных. Ниже разберу, как это работает, сколько итераций брать, где подстелить соломку и как связать всё это с A/B-тестами. С кодом на numpy, который можно скопировать и запустить.
Что вообще делает бутстрап?
Идея почти наглая в своей простоте. У меня есть одна выборка из 500 заказов. Настоящую генеральную совокупность я не вижу — только эти 500 точек. Бутстрап предлагает считать саму выборку лучшим доступным приближением реальности и «переигрывать» сбор данных прямо на ней: беру случайные 500 наблюдений из этих же 500, но с возвращением, так что одни попадают дважды-трижды, а другие не попадают вовсе. Получается новая псевдовыборка того же размера. Считаю на ней метрику. Повторяю тысячи раз.
В итоге у меня не одно число, а распределение метрики — так называемое бутстрап-распределение. Его разброс имитирует то, как гуляла бы метрика, если бы я много раз собирал свежие данные из генеральной совокупности. А раз есть распределение, доверительный интервал берётся тривиально: отрезаем хвосты по 2.5% с каждой стороны.
Ключевое слово — «с возвращением» (replace=True). Без возвращения я бы каждый раз получал ту же самую выборку в перемешанном порядке, и никакой вариативности не было бы. Именно повторы и пропуски создают ту самую случайность, которая моделирует ошибку выборки.
Почему нельзя просто взять готовую формулу?
Для среднего формула есть и работает: стандартная ошибка это std / sqrt(n), интервал — плюс-минус примерно два таких стандартных отклонения. Проблема в том, что она опирается на центральную предельную теорему и на то, что метрика — это среднее. Как только метрика перестаёт быть средним, аккуратной формулы часто просто не существует.
Медиана времени доставки? Формула стандартной ошибки медианы есть, но она хрупкая и зависит от плотности распределения в точке медианы, которую вы не знаете. 90-й перцентиль времени ответа саппорта? Аналитической формулы фактически нет. Отношение двух сумм, вроде выручки на пользователя или CTR по когорте? Числитель и знаменатель случайны одновременно и коррелируют — классическая формула тут даёт кривой интервал.
Второй случай — метрика вроде бы среднее, но данные дикие. Выручка на пользователя почти всегда с тяжёлым правым хвостом: 95% платят по чуть-чуть, а пара китов приносит половину денег. При маленькой выборке ЦПТ ещё не вступила в силу, нормальное приближение симметрично, а реальное распределение среднего — скошено. Формульный интервал получится симметричным вокруг оценки и будет врать. Бутстрап-распределение унаследует ту же скошенность и даст честный несимметричный интервал.
Бутстрапу всё равно, какая у вас метрика. Умеете посчитать её на выборке одной строчкой кода — умеете и построить для неё доверительный интервал.
Как построить перцентильный доверительный интервал?
Возьмём время доставки заказов в минутах — распределение заведомо скошенное, с редкими долгими доставками. Хотим 95% доверительный интервал для медианы.
import numpy as np
rng = np.random.default_rng(42)
delivery = np.array([12, 15, 9, 40, 22, 18, 11, 65, 14, 19,
21, 33, 10, 28, 120, 16, 13, 17, 24, 55])
n_boot = 10_000
medians = np.empty(n_boot)
for i in range(n_boot):
sample = rng.choice(delivery, size=len(delivery), replace=True)
medians[i] = np.median(sample)
lo, hi = np.percentile(medians, [2.5, 97.5])
print(f"Медиана: {np.median(delivery):.1f} мин")
print(f"95% ДИ: [{lo:.1f}, {hi:.1f}]")
Это и есть перцентильный метод — самый простой и самый ходовой. Границы интервала это просто квантили бутстрап-распределения: для 95% берём 2.5-й и 97.5-й перцентили, для 90% — 5-й и 95-й.
Цикл на десять тысяч итераций читается легко, но numpy умеет то же самое без Python-цикла — сгенерировать сразу всю матрицу ресэмплов и посчитать медиану по строкам:
boot = rng.choice(delivery, size=(n_boot, len(delivery)), replace=True)
medians = np.median(boot, axis=1)
lo, hi = np.percentile(medians, [2.5, 97.5])
Векторная версия на порядок быстрее и удобнее, когда итераций много. Единственное, за чем нужно следить, — память: матрица n_boot × n при больших выборках может распухнуть, тогда лучше бить на батчи. Поиграть с этим кодом вживую можно в Python-тренажёре, а синтаксис numpy под рукой держать в справочнике по Python.
Сколько итераций бутстрапа брать?
Число итераций (сколько раз пересэмплируем) и размер выборки (сколько точек в каждом ресэмпле) — это разные вещи, их путают. Размер ресэмпла всегда равен исходному n, тут выбора нет. А вот число итераций вы задаёте сами, и оно влияет только на точность самих границ интервала, не на их положение по сути.
Мои рабочие ориентиры такие. Для черновой прикидки на глаз хватает 1000–2000 итераций. Для числа, которое пойдёт в отчёт или в решение по A/B-тесту, беру 10 000 — стандарт де-факто, считается быстро, границы стабильны. Если интересуют крайние перцентили (99-й, 99.9-й) или очень узкий интервал, где важен каждый знак, имеет смысл 50 000 и больше, потому что в далёких хвостах точек мало и оценка шумит.
Проверить, что итераций достаточно, просто: запустите бутстрап дважды с разным random seed. Если границы интервала пляшут в третьем знаке — всё хорошо. Если гуляют в первом-втором — добавляйте итераций. Важно не обманываться: десять миллионов итераций не сделают интервал «правильнее», если сама исходная выборка маленькая. Итерации борются только с шумом самого бутстрапа, а не с нехваткой данных. При n = 15 интервал будет широким при любом числе итераций — и это честно отражает, что данных мало.
Как бутстрапить конверсию и ratio-метрики?
Конверсия — частный и приятный случай. Данные это нули и единицы (купил / не купил), а конверсия это их среднее. Бутстрап работает ровно как выше:
conversions = rng.binomial(1, 0.037, size=1800) # 0/1 по каждому визиту
boot = rng.choice(conversions, size=(10_000, len(conversions)), replace=True)
rates = boot.mean(axis=1)
lo, hi = np.percentile(rates, [2.5, 97.5])
print(f"CR: {conversions.mean():.3%} 95% ДИ: [{lo:.3%}, {hi:.3%}]")
А вот ratio-метрики требуют аккуратности, и тут ошибаются чаще всего. Возьмём CTR = суммарные клики / суммарные показы по пользователям. Соблазн — пересэмплировать клики и показы по отдельности. Так делать нельзя: у одного пользователя клики и показы связаны, и разрывать эту связь означает выдумывать данные.
Правильно — ресэмплировать единицу наблюдения целиком. Единица здесь пользователь, а у него есть и клики, и показы. Берём случайных пользователей с возвращением и на каждом ресэмпле пересчитываем отношение сумм:
clicks = rng.poisson(3, size=2000) # клики по каждому юзеру
sessions = rng.poisson(20, size=2000) + 1 # показы по каждому юзеру
n = len(clicks)
idx = rng.integers(0, n, size=(10_000, n)) # индексы ЮЗЕРОВ
ctr = clicks[idx].sum(axis=1) / sessions[idx].sum(axis=1)
lo, hi = np.percentile(ctr, [2.5, 97.5])
print(f"CTR: {clicks.sum()/sessions.sum():.4f} 95% ДИ: [{lo:.4f}, {hi:.4f}]")
Обратите внимание: idx — это индексы пользователей, и по этим индексам мы одновременно тянем и клики, и показы. Числитель и знаменатель остаются спаренными. Тот же приём годится для выручки на пользователя, среднего чека, ARPPU — для любой метрики, где нужно ресэмплировать субъект, а не отдельные события. Ratio-метрики вообще стоит понимать как метрики над правильной единицей — про это же речь, когда обсуждают знаменатель метрик вроде DAU и производных от него.
Как бутстрап связан с A/B-тестами?
Прямо. В A/B нас интересует не сама метрика в группе, а разница между контролем и тестом — и попадает ли ноль в её доверительный интервал. Если интервал разницы не накрывает ноль, эффект статистически значим; если накрывает — данных пока не хватает, чтобы отличить эффект от шума.
Бутстрапим обе группы независимо и на каждой итерации считаем разницу метрик:
group_a = rng.gamma(2, 30, size=1200) # контроль, ARPU
group_b = rng.gamma(2, 33, size=1200) # тест
n_boot = 10_000
diff = np.empty(n_boot)
for i in range(n_boot):
a = rng.choice(group_a, size=len(group_a), replace=True)
b = rng.choice(group_b, size=len(group_b), replace=True)
diff[i] = b.mean() - a.mean()
lo, hi = np.percentile(diff, [2.5, 97.5])
print(f"Разница ARPU: {group_b.mean() - group_a.mean():.2f}")
print(f"95% ДИ разницы: [{lo:.2f}, {hi:.2f}]")
if lo > 0 or hi < 0:
print("Ноль вне интервала — эффект значим")
Чем бутстрап особенно хорош в A/B — он умеет работать с метриками, для которых t-тест не предназначен. Тест на разницу медиан? Пожалуйста. На разницу 90-х перцентилей времени загрузки? Тоже. На разницу ratio-метрик с правильным ресэмплом пользователей? Легко. Там, где для аналитической проверки пришлось бы выводить дельта-метод и продираться через ковариации, бутстрап даёт ответ в десять строк. На собеседованиях в продуктовые команды это регулярно спрашивают — потренироваться на реальных формулировках можно в разделе вопросов по A/B и статистике, а на end-to-end сценариях с данными — в кейсах.
Где бутстрап ломается?
Бутстрап не магия, и делать вид, что у него нет слепых зон, нечестно. Первое и главное — он бессилен против маленьких выборок. При n = 10 в исходных данных просто нет информации о хвостах генеральной совокупности, и никакие пересэмплирования её не создадут. Экстремальные значения, которых в выборке не оказалось, не появятся ни в одном ресэмпле. Интервал получится, но узким и самоуверенным — верить ему нельзя.
Второе — метрики, которые сильно зависят от крайних значений: максимум, минимум, очень высокие перцентили. Бутстрап-распределение максимума получается дёрганым и дискретным, перцентильный интервал для него плохо откалиброван. Тут нужны специальные подходы, а не наивный перцентиль.
Третье — независимость наблюдений. Если в данных есть структура (несколько заказов от одного пользователя, временные ряды с автокорреляцией, кластеры по городам), а вы ресэмплируете строки как независимые, вы занизите неопределённость и получите слишком узкий интервал. Лечится ресэмплом на уровне кластера — берём с возвращением пользователей целиком, а не отдельные их события, как мы уже делали для CTR.
Наконец, перцентильный метод — самый простой, но не самый точный из бутстрап-методов. При заметной скошенности существуют улучшения (BCa с поправкой на смещение и асимметрию), которые двигают границы аккуратнее. Для повседневной аналитики перцентильного хватает с запасом, но знать, что есть варианты поточнее, полезно — особенно если по интервалу принимается дорогое решение.
Что стоит запомнить?
Бутстрап — это ваш универсальный ключ к неопределённости. Есть выборка и функция, считающая метрику, — значит, есть и доверительный интервал: пересэмплируйте с возвращением тысячи раз, соберите распределение метрики, отрежьте перцентили. Медиана, конверсия, ratio, разница между A/B-группами — механика одна и та же. Держите в голове три правила: размер ресэмпла всегда равен n, 10 000 итераций хватает почти всегда, а ресэмплировать нужно единицу наблюдения целиком, не разрывая связанные величины. И помните про границы применимости — маленькие выборки и зависимые данные бутстрап не спасёт.
Дальше — только практика. Разберите бутстрап на своих данных в Python-тренажёре, проверьте, как ratio-метрики считаются на SQL-стороне в SQL-тренажёре, и прогоните пару A/B-задач из подборки заданий. Полный доступ ко всем задачам, кейсам и разборам, а также AI-собеседования без лимитов открываются в Pro — если готовитесь к офферу в продуктовую аналитику, это самый короткий путь набить руку.