library(tidyverse)
library(estimatr) # lm_robust(), lm_lin() с робастными ошибками и tidy()Это практическое дополнение к посту в телеграм-канале про то, почему CUPED – это старая добрая ANCOVA. Там теория, а здесь пошаговый разбор с кодом на R и Python: как применить CUPED к A/B-тесту и не наступить на типичные грабли.
Данные синтетические, но устроены как настоящая выручка: большинство пользователей ничего не покупают, у платящих длинный хвост, а у части пользователей нет истории до теста. Код на R и Python читает одни и те же CSV-файлы, поэтому цифры во вкладках совпадают.
- Данные (и как собрать такую таблицу у себя)
- Готовим ковариату: новые пользователи и выбросы
- До запуска теста: проверяем на истории, есть ли смысл в CUPED, и считаем MDE
- Тест закончился: CUPED руками
- То же самое через регрессию (и Multi-CUPED)
- ARPPU: то же самое на платящих
- Как сломать CUPED: ковариата из периода теста
- Проверяем на симуляции: A/A и мощность
В конце приложение с кодом симуляции из поста в канале.
1. Данные
Онлайн-библиотека, тест нового экрана оплаты, по 2 недели на тест. Данные лежат в двух файлах:
- cuped_experiment.csv – сам эксперимент, 40 000 пользователей;
- cuped_history.csv – те же метрики за прошлый период, до запуска теста. Понадобится в шаге 3.
Колонки:
group– контроль или тест;is_new– пользователь пришёл недавно, истории до теста у него нет (таких 20%);rev_pre– выручка за 2 недели до теста, у новых пользователей пропуск;revenue– выручка за 2 недели теста, это наша метрика;rev_week1– выручка за первую неделю теста. Понадобится в шаге 7, чтобы показать, как делать не надо.
Истинный эффект в тестовой группе: +3% к выручке.
df <- read_csv("data/cuped_experiment.csv")
head(df)# A tibble: 6 × 6
user_id group is_new rev_pre revenue rev_week1
<dbl> <chr> <lgl> <dbl> <dbl> <dbl>
1 1 control FALSE 149. 181. 75.0
2 2 control FALSE 0 0 0
3 3 control FALSE 96.6 0 0
4 4 control TRUE NA 199. 107.
5 5 test FALSE 146. 0 0
6 6 test FALSE 0 168. 69.7
import numpy as np
import pandas as pd
from scipy import stats
import statsmodels.formula.api as smf
df = pd.read_csv("data/cuped_experiment.csv")
df.head() user_id group is_new rev_pre revenue rev_week1
0 1 control False 149.011673 180.641194 75.046791
1 2 control False 0.000000 0.000000 0.000000
2 3 control False 96.624863 0.000000 0.000000
3 4 control True NaN 199.284841 106.593654
4 5 test False 146.227997 0.000000 0.000000
Связь между «до» и «во время» задаёт скрытая склонность пользователя платить (activity): кто платил раньше, скорее заплатит и сейчас. В реальных данных её не видно, у нас есть только выручка. Генератор понадобится ещё раз в шаге 8, для симуляции.
generate_data <- function(n = 40000, effect = 0.03, seed = 42) {
set.seed(seed)
tibble(
user_id = 1:n,
group = sample(c("control", "test"), n, replace = TRUE),
is_new = rbinom(n, 1, 0.2) == 1, # нет истории до теста
activity = rnorm(n) # скрытая склонность платить
) |>
mutate(
# предпериод: платил ли и сколько
pay_pre = rbinom(n, 1, plogis(-0.5 + 3 * activity)),
rev_pre = if_else(is_new, NA_real_,
pay_pre * exp(5 + 0.8 * activity + rnorm(n, 0, 0.5))),
# период теста: та же склонность + эффект в тестовой группе
pay = rbinom(n, 1, plogis(-0.5 + 3 * activity)),
revenue = pay * exp(5 + 0.8 * activity + rnorm(n, 0, 0.5)) *
if_else(group == "test", 1 + effect, 1),
# часть выручки теста, пришедшаяся на первую неделю
rev_week1 = revenue * runif(n, 0.3, 0.7)
) |>
select(user_id, group, is_new, rev_pre, revenue, rev_week1)
}
# так получены файлы:
# write_csv(generate_data(), "data/cuped_experiment.csv")
# write_csv(generate_data(effect = 0, seed = 2025) |> select(-group, -rev_week1),
# "data/cuped_history.csv")def generate_data(n=40_000, effect=0.03, seed=42):
rng = np.random.default_rng(seed)
activity = rng.normal(size=n) # скрытая склонность платить
p_pay = 1 / (1 + np.exp(-(-0.5 + 3 * activity)))
group = rng.choice(["control", "test"], size=n)
is_new = rng.random(n) < 0.2 # нет истории до теста
# предпериод: платил ли и сколько
pay_pre = rng.random(n) < p_pay
rev_pre = pay_pre * np.exp(5 + 0.8 * activity + rng.normal(0, 0.5, n))
rev_pre = np.where(is_new, np.nan, rev_pre)
# период теста: та же склонность + эффект в тестовой группе
pay = rng.random(n) < p_pay
revenue = pay * np.exp(5 + 0.8 * activity + rng.normal(0, 0.5, n))
revenue = revenue * np.where(group == "test", 1 + effect, 1)
rev_week1 = revenue * rng.uniform(0.3, 0.7, n) # часть выручки за первую неделю
return pd.DataFrame({"user_id": np.arange(1, n + 1), "group": group, "is_new": is_new,
"rev_pre": rev_pre, "revenue": revenue, "rev_week1": rev_week1})Нужна одна строка на пользователя эксперимента. Главное правило: предпериод отсчитывается от момента, когда этот пользователь попал в тест, а не от даты старта теста. Набросок на SQL:
with exposure as ( -- первое попадание пользователя в тест
select user_id, group_name, min(event_time) as exposed_at
from ab_exposures
where test_id = 'paywall_v2'
group by user_id, group_name
)
select
e.user_id,
e.group_name as "group",
-- метрика за период теста: от попадания в тест до конца теста
coalesce(sum(case when p.paid_at >= e.exposed_at
and p.paid_at < '2026-10-01' then p.amount end), 0) as revenue,
-- ковариата: такое же окно (14 дней) ДО попадания в тест
coalesce(sum(case when p.paid_at >= e.exposed_at - interval '14 days'
and p.paid_at < e.exposed_at then p.amount end), 0) as rev_pre,
-- нет истории: зарегистрировался позже начала окна предпериода
u.registered_at > e.exposed_at - interval '14 days' as is_new
from exposure e
join users u using (user_id)
left join payments p using (user_id)
group by e.user_id, e.group_name, u.registered_at, e.exposed_atДля новых пользователей rev_pre здесь получится 0, а не пропуск. Это нормально, если есть флаг is_new, дальше мы делаем ровно так же.
2. Готовим ковариату
Сначала договоримся о подготовке данных: ровно её же мы применим и к истории в шаге 3, и к самому тесту.
Новые пользователи. У них rev_pre отсутствует. Выкидывать их нельзя: это часть эксперимента, и без них оценка эффекта будет про другую аудиторию. Стандартное решение, оно же описано в оригинальной статье про CUPED: заполнить пропуск константой (например, нулём) и добавить индикатор «нет истории» как отдельную ковариату. Константа может быть любой, индикатор всё равно отделит этих пользователей.
Выбросы. Пара пользователей с огромной выручкой раскачивает и среднее, и саму оценку θ. Поэтому до расчёта CUPED метрику и ковариату винсоризируют: обрезают по высокому перцентилю. Порог считаем по объединённым данным контроля и теста, а не отдельно по группам, иначе группы будут обработаны по-разному.
Винсоризация меняет метрику: теперь это не «выручка», а «выручка с обрезанными китами». Порог нужно зафиксировать до анализа, а не подбирать по результату.
winsorize <- function(x, p = 0.995) pmin(x, quantile(x, p, na.rm = TRUE))
prepare <- function(df) {
df |>
mutate(
revenue_w = winsorize(revenue),
x_pre = coalesce(winsorize(rev_pre), 0) # пропуск у новых → 0, флаг is_new уже есть
)
}
df <- prepare(df)def winsorize(x, p=0.995):
return np.minimum(x, np.nanquantile(x, p))
def prepare(df):
df = df.copy()
df["revenue_w"] = winsorize(df["revenue"])
df["x_pre"] = winsorize(df["rev_pre"]).fillna(0) # пропуск у новых → 0, флаг is_new уже есть
return df
df = prepare(df)3. До запуска теста: есть ли смысл в CUPED
CUPED умножает дисперсию на \(1 - \rho^2\), то есть снижает её на \(\rho^2\), где \(\rho\) – корреляция метрики с ковариатой. Решать, стоит ли игра свеч, нужно до запуска теста, а не по его результатам. Иначе это то же подглядывание: попробовали с CUPED и без, выбрали, где p-value меньше.
Поэтому берём историю: те же метрики за прошлый период. Например, выручку пользователей за 2 недели месяц назад (как будто это «период теста») и за 2 недели до них (как будто это «предпериод»). Групп и эффекта в истории нет. Прогоняем историю через ту же функцию prepare() и смотрим:
- какую долю дисперсии метрики объясняет ковариата. Это и есть ожидаемое снижение дисперсии. Для одной ковариаты оно равно \(\rho^2\), для нескольких (Multi-CUPED) это \(R^2\) регрессии;
- какой MDE получится с CUPED и без него при запланированном размере выборки.
history <- read_csv("data/cuped_history.csv") |> prepare()
rho_old <- with(filter(history, !is_new), cor(x_pre, revenue_w))
r2_cuped <- cor(history$x_pre, history$revenue_w)^2
r2_multi <- summary(lm(revenue_w ~ x_pre + is_new, data = history))$r.squared
# MDE для двустороннего теста: alpha = 5%, мощность 80%
mde <- function(sd, n_per_group, alpha = 0.05, power = 0.8) {
(qnorm(1 - alpha / 2) + qnorm(power)) * sd * sqrt(2 / n_per_group)
}
n_per_group <- 20000
sd_y <- sd(history$revenue_w)
mu_y <- mean(history$revenue_w)
tibble(
method = c("t-test", "CUPED: x_pre", "Multi-CUPED: x_pre + is_new"),
variance_reduction = c(0, r2_cuped, r2_multi),
mde_relative = mde(sd_y * sqrt(1 - variance_reduction), n_per_group) / mu_y
) |>
mutate(variance_reduction = scales::percent(variance_reduction, accuracy = 1),
mde_relative = scales::percent(mde_relative, accuracy = 0.1)) |>
knitr::kable(col.names = c("Метод", "Снижение дисперсии", "MDE"))| Метод | Снижение дисперсии | MDE |
|---|---|---|
| t-test | 0% | 4.7% |
| CUPED: x_pre | 33% | 3.9% |
| Multi-CUPED: x_pre + is_new | 36% | 3.8% |
from scipy.stats import norm
history = prepare(pd.read_csv("data/cuped_history.csv"))
old = history[~history["is_new"]]
rho_old = old["x_pre"].corr(old["revenue_w"])
r2_cuped = history["x_pre"].corr(history["revenue_w"]) ** 2
r2_multi = smf.ols("revenue_w ~ x_pre + is_new", data=history.astype({"is_new": int})).fit().rsquared
# MDE для двустороннего теста: alpha = 5%, мощность 80%
def mde(sd, n_per_group, alpha=0.05, power=0.8):
return (norm.ppf(1 - alpha / 2) + norm.ppf(power)) * sd * np.sqrt(2 / n_per_group)
n_per_group = 20_000
sd_y, mu_y = history["revenue_w"].std(), history["revenue_w"].mean()
pd.DataFrame({
"Метод": ["t-test", "CUPED: x_pre", "Multi-CUPED: x_pre + is_new"],
"Снижение дисперсии": [0, r2_cuped, r2_multi],
}).assign(MDE=lambda d: mde(sd_y * np.sqrt(1 - d["Снижение дисперсии"]), n_per_group) / mu_y).round(3) Метод Снижение дисперсии MDE
0 t-test 0.000 0.047
1 CUPED: x_pre 0.332 0.039
2 Multi-CUPED: x_pre + is_new 0.356 0.038
Что видно:
- У пользователей с историей корреляция \(\rho \approx\) 0.67, это обещало бы снижение дисперсии на 45%. Но по всей аудитории выходит меньше, 33%: у новых пользователей предпериода нет, и для них CUPED ничего не даёт. Поэтому ожидаемый выигрыш нужно считать на всех пользователях, которые попадут в тест, а не только на «удобных».
- Индикатор
is_newдобавляет немного: новые пользователи платят иначе, чем старые с нулевой выручкой в предпериоде. - MDE с CUPED заметно меньше. На практике это значит, что тот же тест можно провести быстрее или на меньшей выборке.
Если бы на истории получилось \(\rho \approx 0.2\)–\(0.3\), снижение дисперсии было бы 4–9%, и возиться с CUPED, скорее всего, не стоило бы.
4. Тест закончился: CUPED руками
Формула одна: \(Y_{cuped} = Y - \theta (X - \bar{X})\), где \(\theta = \text{Cov}(X, Y) / \text{Var}(X)\). Два важных момента:
- θ считаем один на обе группы, по объединённым данным. Если считать отдельно в контроле и тесте, оценка эффекта становится смещённой.
- Центрирование \(X - \bar{X}\) нужно только для того, чтобы среднее метрики не поменялось. На оценку разницы между группами оно не влияет.
Для сравнения посчитаем и разность «после минус до» (\(\theta = 1\)), про которую была ловушка в посте.
theta <- cov(df$x_pre, df$revenue_w) / var(df$x_pre)
df <- df |>
mutate(revenue_cuped = revenue_w - theta * (x_pre - mean(x_pre)),
revenue_diff = revenue_w - x_pre)
# t.test считает «контроль − тест», переворачиваем в «тест − контроль»
summarise_t <- function(tt, method) {
tibble(method = method,
effect = unname(diff(tt$estimate)),
ci_low = -tt$conf.int[2],
ci_high = -tt$conf.int[1],
p_value = tt$p.value)
}
t_plain <- t.test(revenue_w ~ group, data = df)
t_diff <- t.test(revenue_diff ~ group, data = df)
t_cuped <- t.test(revenue_cuped ~ group, data = df)
bind_rows(summarise_t(t_plain, "t-test"),
summarise_t(t_diff, "Разность «после минус до»"),
summarise_t(t_cuped, "CUPED")) |>
mutate(variance_vs_ttest = ((ci_high - ci_low) / (ci_high[1] - ci_low[1]))^2) |>
knitr::kable(digits = 3)| method | effect | ci_low | ci_high | p_value | variance_vs_ttest |
|---|---|---|---|---|---|
| t-test | 3.338 | -2.039 | 8.715 | 0.224 | 1.000 |
| Разность «после минус до» | 3.971 | -0.690 | 8.631 | 0.095 | 0.751 |
| CUPED | 3.749 | -0.581 | 8.078 | 0.090 | 0.648 |
is_test = df["group"] == "test"
theta = np.cov(df["x_pre"], df["revenue_w"])[0, 1] / df["x_pre"].var()
df["revenue_cuped"] = df["revenue_w"] - theta * (df["x_pre"] - df["x_pre"].mean())
df["revenue_diff"] = df["revenue_w"] - df["x_pre"]
def welch(col):
t, c = df.loc[is_test, col], df.loc[~is_test, col]
res = stats.ttest_ind(t, c, equal_var=False)
ci = res.confidence_interval()
return {"effect": t.mean() - c.mean(), "ci_low": ci.low, "ci_high": ci.high, "p_value": res.pvalue}
out = pd.DataFrame({"t-test": welch("revenue_w"),
"Разность «после минус до»": welch("revenue_diff"),
"CUPED": welch("revenue_cuped")}).T
out["variance_vs_ttest"] = ((out.ci_high - out.ci_low) / (out.ci_high.iloc[0] - out.ci_low.iloc[0])) ** 2
out.round(3) effect ci_low ci_high p_value variance_vs_ttest
t-test 3.338 -2.039 8.715 0.224 1.000
Разность «после минус до» 3.971 -0.690 8.631 0.095 0.751
CUPED 3.749 -0.581 8.078 0.090 0.648
Эффект +3% в данных заложен, но обычный t-тест его не видит (p = 0.22): выручка слишком шумная. Оценка CUPED почти та же, а доверительный интервал заметно уже: дисперсия уменьшилась на 35%, p-value снизился с 0.22 до 0.09. Это близко к тому, что мы ожидали по истории в шаге 3 (33%), так что прогноз на истории работает.
Разность «после минус до» тоже немного помогла (дисперсия 75% от t-теста), потому что корреляция здесь больше 0.5. Но CUPED всё равно точнее. При слабой корреляции разность оказалась бы хуже обычного t-теста.
Значимым эффект не стал и с CUPED. Окажется ли он значимым на одном конкретном датасете – дело случая: CUPED не гарантирует «прокрас», а повышает его вероятность. На симуляции в шаге 8 мощность растёт примерно с 39% до 55%, то есть такой эффект CUPED будет находить заметно чаще.
Продакты обычно спрашивают «на сколько процентов». Делить разницу нужно на среднее исходной (не скорректированной) метрики в контроле: здесь это 2.3%. Для строгого доверительного интервала относительного эффекта нужен дельта-метод или бутстреп.
5. То же самое через регрессию
CUPED – это регрессия revenue ~ group + x_pre, посчитанная в два шага. В одну строку это удобнее, а заодно сразу получаются робастные стандартные ошибки и можно добавить сколько угодно ковариат.
# CUPED = ANCOVA
m_cuped <- lm_robust(revenue_w ~ group + x_pre, data = df)
# Multi-CUPED: + индикатор новых пользователей
m_multi <- lm_robust(revenue_w ~ group + x_pre + is_new, data = df)
# поправка Лина: взаимодействие с центрированными ковариатами
m_lin <- lm_lin(revenue_w ~ group, covariates = ~ x_pre + is_new, data = df)
bind_rows(
tidy(m_cuped) |> mutate(model = "CUPED: ~ group + x_pre"),
tidy(m_multi) |> mutate(model = "Multi-CUPED: + is_new"),
tidy(m_lin) |> mutate(model = "Lin: group * (x_pre + is_new)")
) |>
filter(term == "grouptest") |>
select(model, estimate, std.error, conf.low, conf.high, p.value) |>
knitr::kable(digits = 3)| model | estimate | std.error | conf.low | conf.high | p.value |
|---|---|---|---|---|---|
| CUPED: ~ group + x_pre | 3.749 | 2.209 | -0.581 | 8.078 | 0.090 |
| Multi-CUPED: + is_new | 3.992 | 2.168 | -0.258 | 8.241 | 0.066 |
| Lin: group * (x_pre + is_new) | 3.991 | 2.168 | -0.259 | 8.242 | 0.066 |
d = df.astype({"is_new": int})
# центрируем ковариаты для модели Лина
d["x_c"] = d["x_pre"] - d["x_pre"].mean()
d["new_c"] = d["is_new"] - d["is_new"].mean()
models = {
"CUPED: ~ group + x_pre": "revenue_w ~ group + x_pre",
"Multi-CUPED: + is_new": "revenue_w ~ group + x_pre + is_new",
"Lin: group * (x_pre + is_new)": "revenue_w ~ group * (x_c + new_c)",
}
rows = []
for name, formula in models.items():
fit = smf.ols(formula, data=d).fit(cov_type="HC2")
ci = fit.conf_int().loc["group[T.test]"]
rows.append({"model": name, "estimate": fit.params["group[T.test]"],
"std.error": fit.bse["group[T.test]"], "conf.low": ci[0],
"conf.high": ci[1], "p.value": fit.pvalues["group[T.test]"]})
pd.DataFrame(rows).round(3) model estimate ... conf.high p.value
0 CUPED: ~ group + x_pre 3.749 ... 8.078 0.090
1 Multi-CUPED: + is_new 3.992 ... 8.241 0.066
2 Lin: group * (x_pre + is_new) 3.991 ... 8.241 0.066
[3 rows x 6 columns]
Оценка из первой модели совпадает с CUPED руками с точностью до третьего знака. Небольшая разница есть потому, что в CUPED θ считается по объединённым данным, а в регрессии наклон оценивается с учётом группы. Индикатор is_new добавляет ещё немного точности, как и обещала история.
Какую модель брать:
- Сплит 50/50 – подойдёт любая из трёх, разница будет минимальной.
- Неравный сплит (например, 10/90) и эффект может быть разным у разных пользователей – берите модель Лина. Модель без взаимодействия в таком случае может оказаться даже хуже обычного t-теста.
- Стандартные ошибки всегда робастные (HC):
lm_robust()в R,cov_type="HC2"в Python.
6. ARPPU: то же самое на платящих
До сих пор метрикой была выручка на пользователя (ARPU). С ARPPU (выручка на платящего) работает тот же пайплайн, только на подвыборке платящих: рандомизация по пользователям, у каждого платящего одно значение, так что обычный t-тест и CUPED применимы как есть. Отличий три.
1. Выигрыш от CUPED нужно пересчитать на истории, по платящим. Без нулей корреляция с предпериодом обычно ниже, чем у ARPU: часть «объяснения» у ARPU давал сам факт платил/не платил. Порог винсоризации тоже считаем по платящим, поэтому заново прогоняем prepare() на подвыборке.
2. Новые пользователи среди платящих. Их доля среди платящих может заметно отличаться от доли во всей аудитории (у нас она такая же, около 20%, но в реальных данных бывает по-разному). Индикатор is_new оставляем.
3. Подвыборка определяется после воздействия. «Платящий» считается по периоду теста. Если фича меняет конверсию в оплату, платящие в контроле и тесте – уже разные по составу люди, и сравнение ARPPU становится сравнением разных аудиторий. Это проблема и для t-теста, и для CUPED. Поэтому сначала проверяем конверсию.
# 1. На истории: сколько дисперсии объясняет предпериод у платящих
payers_hist <- history |> filter(revenue > 0) |> prepare()
tibble(
metric = c("ARPU: все пользователи", "ARPPU: платящие"),
share_new = c(mean(history$is_new), mean(payers_hist$is_new)),
variance_reduction = c(
summary(lm(revenue_w ~ x_pre + is_new, data = history))$r.squared,
summary(lm(revenue_w ~ x_pre + is_new, data = payers_hist))$r.squared
)
) |>
knitr::kable(digits = 2)| metric | share_new | variance_reduction |
|---|---|---|
| ARPU: все пользователи | 0.2 | 0.36 |
| ARPPU: платящие | 0.2 | 0.24 |
# 3. Тест: сначала конверсия в оплату
m_conv <- lm_robust(I(revenue > 0) ~ group, data = df)
tidy(m_conv) |> filter(term == "grouptest") |> select(term, estimate, p.value) term estimate p.value
1 grouptest -0.001604407 0.7466574
# ARPPU: t-test и Multi-CUPED на платящих
payers <- df |> filter(revenue > 0) |> prepare()
m_arppu <- lm_robust(revenue_w ~ group, data = payers)
m_arppu_cuped <- lm_robust(revenue_w ~ group + x_pre + is_new, data = payers)
bind_rows(tidy(m_arppu) |> mutate(model = "ARPPU: t-test"),
tidy(m_arppu_cuped) |> mutate(model = "ARPPU: Multi-CUPED")) |>
filter(term == "grouptest") |>
select(model, estimate, std.error, conf.low, conf.high, p.value) |>
knitr::kable(digits = 3)| model | estimate | std.error | conf.low | conf.high | p.value |
|---|---|---|---|---|---|
| ARPPU: t-test | 9.848 | 4.880 | 0.283 | 19.414 | 0.044 |
| ARPPU: Multi-CUPED | 10.768 | 4.184 | 2.567 | 18.968 | 0.010 |
def r2(d):
return smf.ols("revenue_w ~ x_pre + is_new", data=d.astype({"is_new": int})).fit().rsquared
# 1. На истории: сколько дисперсии объясняет предпериод у платящих
payers_hist = prepare(history[history["revenue"] > 0])
print(pd.DataFrame({"metric": ["ARPU: все пользователи", "ARPPU: платящие"],
"share_new": [history["is_new"].mean(), payers_hist["is_new"].mean()],
"variance_reduction": [r2(history), r2(payers_hist)]}).round(2)) metric share_new variance_reduction
0 ARPU: все пользователи 0.2 0.36
1 ARPPU: платящие 0.2 0.24
# 3. Тест: сначала конверсия в оплату
conv = smf.ols("paid ~ group", data=df.assign(paid=(df["revenue"] > 0).astype(int))).fit(cov_type="HC2")
print(f"\nКонверсия: разница {conv.params['group[T.test]']:.4f}, p = {conv.pvalues['group[T.test]']:.3f}\n")
Конверсия: разница -0.0016, p = 0.747
# ARPPU: t-test и Multi-CUPED на платящих
payers = prepare(df[df["revenue"] > 0]).astype({"is_new": int})
rows = []
for name, formula in {"ARPPU: t-test": "revenue_w ~ group",
"ARPPU: Multi-CUPED": "revenue_w ~ group + x_pre + is_new"}.items():
fit = smf.ols(formula, data=payers).fit(cov_type="HC2")
ci = fit.conf_int().loc["group[T.test]"]
rows.append({"model": name, "estimate": fit.params["group[T.test]"], "std.error": fit.bse["group[T.test]"],
"conf.low": ci[0], "conf.high": ci[1], "p.value": fit.pvalues["group[T.test]"]})
pd.DataFrame(rows).round(3) model estimate std.error conf.low conf.high p.value
0 ARPPU: t-test 9.848 4.880 0.283 19.413 0.044
1 ARPPU: Multi-CUPED 10.768 4.184 2.568 18.967 0.010
Конверсия от теста не изменилась (p = 0.75), так что ARPPU сравнивать можно. У платящих CUPED снизил дисперсию на 27%: меньше, чем у ARPU, как и предсказывала история. p-value снизился с 0.044 до 0.01.
lm_robust(revenue_w ~ group) без ковариат – это тот же t-тест Уэлча, только записанный как регрессия: так удобнее сравнивать с CUPED в одной таблице.
7. Как сломать CUPED: ковариата из периода теста
Чем сильнее корреляция, тем больше выигрыш. Отсюда соблазн взять ковариату посильнее, например выручку за первую неделю теста: она почти идеально предсказывает выручку за весь тест. Посмотрим, что получится.
df_leak <- df |> mutate(x_leak = winsorize(rev_week1))
cat("Корреляция с метрикой:", round(cor(df_leak$x_leak, df_leak$revenue_w), 2), "\n")Корреляция с метрикой: 0.97
theta_leak <- cov(df_leak$x_leak, df_leak$revenue_w) / var(df_leak$x_leak)
df_leak <- df_leak |>
mutate(revenue_leak = revenue_w - theta_leak * (x_leak - mean(x_leak)))
t_leak <- t.test(revenue_leak ~ group, data = df_leak)
bind_rows(summarise_t(t_cuped, "CUPED: выручка до теста"),
summarise_t(t_leak, "CUPED: выручка за 1-ю неделю теста")) |>
knitr::kable(digits = 3)| method | effect | ci_low | ci_high | p_value |
|---|---|---|---|---|
| CUPED: выручка до теста | 3.749 | -0.581 | 8.078 | 0.09 |
| CUPED: выручка за 1-ю неделю теста | -1.086 | -2.381 | 0.210 | 0.10 |
df["x_leak"] = winsorize(df["rev_week1"])
print("Корреляция с метрикой:", round(df["x_leak"].corr(df["revenue_w"]), 2))Корреляция с метрикой: 0.97
theta_leak = np.cov(df["x_leak"], df["revenue_w"])[0, 1] / df["x_leak"].var()
df["revenue_leak"] = df["revenue_w"] - theta_leak * (df["x_leak"] - df["x_leak"].mean())
pd.DataFrame({"CUPED: выручка до теста": welch("revenue_cuped"),
"CUPED: выручка за 1-ю неделю теста": welch("revenue_leak")}).T.round(3) effect ci_low ci_high p_value
CUPED: выручка до теста 3.749 -0.581 8.078 0.09
CUPED: выручка за 1-ю неделю теста -1.086 -2.381 0.210 0.10
Корреляция почти идеальная, интервал стал втрое уже… а оценка эффекта стала -1.09 вместо 3.75: эффект не просто пропал, а сменил знак. Фича повлияла и на выручку первой недели, поэтому CUPED вычел эффект вместе с шумом (и даже с запасом). Самое неприятное, что выглядит это как очень точный тест. Авторы CUPED в оригинальной статье описывают ровно такой случай: ковариата из периода эксперимента дала оценку с неверным знаком.
Ковариата должна быть посчитана строго до того, как пользователь попал в эксперимент. Если пользователи входят в тест постепенно, предпериод отсчитывается от момента входа каждого пользователя, а не от даты старта теста.
8. Проверяем на симуляции
Перед тем как внедрять CUPED в пайплайн, стоит прогнать его на A/A и A/B-симуляциях: ошибка первого рода должна остаться около 5%, а мощность должна вырасти. Генерируем 1000 датасетов без эффекта и 1000 с эффектом +3% и на каждом прогоняем тот же пайплайн.
analyze <- function(df) {
df <- prepare(df)
theta <- cov(df$x_pre, df$revenue_w) / var(df$x_pre)
df <- mutate(df, revenue_cuped = revenue_w - theta * (x_pre - mean(x_pre)))
tibble(
method = c("t-test", "CUPED"),
p_value = c(t.test(revenue_w ~ group, data = df)$p.value,
t.test(revenue_cuped ~ group, data = df)$p.value)
)
}n_sims <- 1000
sim <- expand_grid(effect = c(0, 0.03), sim = seq_len(n_sims)) |>
mutate(res = map2(effect, sim, \(e, s) analyze(generate_data(effect = e, seed = s)))) |>
unnest(res)
sim |>
group_by(effect, method) |>
summarise(share_significant = mean(p_value < 0.05), .groups = "drop") |>
mutate(
test = if_else(effect == 0, "A/A: ошибка I рода", "A/B +3%: мощность"),
share_significant = scales::percent(share_significant, accuracy = 0.1)
) |>
select(test, method, share_significant) |>
pivot_wider(names_from = method, values_from = share_significant) |>
knitr::kable()| test | CUPED | t-test |
|---|---|---|
| A/A: ошибка I рода | 4.4% | 5.2% |
| A/B +3%: мощность | 55.6% | 38.5% |
def analyze(df):
df = prepare(df)
y, x = df["revenue_w"], df["x_pre"]
theta = np.cov(x, y)[0, 1] / x.var()
y_cuped = y - theta * (x - x.mean())
t = df["group"] == "test"
return {
"t-test": stats.ttest_ind(y[t], y[~t], equal_var=False).pvalue,
"CUPED": stats.ttest_ind(y_cuped[t], y_cuped[~t], equal_var=False).pvalue,
}
rows = []
for effect in [0, 0.03]:
for seed in range(1, 1001):
p = analyze(generate_data(effect=effect, seed=seed))
rows += [{"effect": effect, "method": m, "significant": p[m] < 0.05} for m in p]
(pd.DataFrame(rows)
.groupby(["effect", "method"])["significant"].mean()
.unstack())method CUPED t-test
effect
0.00 0.043 0.048
0.03 0.527 0.393
Здесь каждый прогон генерирует новые данные, а генераторы случайных чисел в R и Python разные, поэтому цифры во вкладках немного отличаются. Выводы одни и те же. В A/A оба метода держат ошибку первого рода около 5% (при 1000 прогонах это ±1.4 п.п.). А мощность у CUPED заметно выше: тот же эффект находится чаще на той же выборке. Если на ваших данных CUPED в A/A даёт заметно больше 5%, где-то ошибка: чаще всего ковариата посчитана не до теста.
Чеклист
Приложение: симуляция из поста
Код симуляции, цифры из которой приведены в посте в канале: логнормальная выручка без нулей, 5000 пользователей в группе, по 2000 экспериментов на сценарий, сравнение t-теста, разности «после минус до» и CUPED при корреляции около 0.3 и 0.7.
Показать код
gen_revenue <- function(n, rho, effect = 0) {
z_pre <- rnorm(n)
z_post <- rho * z_pre + sqrt(1 - rho^2) * rnorm(n)
tibble(x_pre = exp(1 + 0.8 * z_pre),
y = exp(1 + 0.8 * z_post) * (1 + effect))
}
post_experiment <- function(n, rho, effect) {
d <- bind_rows(gen_revenue(n, rho) |> mutate(group = "control"),
gen_revenue(n, rho, effect) |> mutate(group = "test"))
theta <- cov(d$x_pre, d$y) / var(d$x_pre)
d <- mutate(d, y_change = y - x_pre, y_cuped = y - theta * (x_pre - mean(x_pre)))
welch <- function(f) { r <- t.test(f, data = d); tibble(p_value = r$p.value, se = r$stderr) }
bind_rows(welch(y ~ group) |> mutate(method = "t-test"),
welch(y_change ~ group) |> mutate(method = "Разность"),
welch(y_cuped ~ group) |> mutate(method = "CUPED")) |>
mutate(corr = cor(d$x_pre, d$y))
}Показать код
set.seed(2026)
# rho задаётся на лог-шкале; наблюдаемая корреляция выручки получается ≈ 0.3 и ≈ 0.7
post_sim <- expand_grid(rho = c(0.38, 0.76), effect = c(0, 0.03), sim = 1:2000) |>
mutate(res = map2(rho, effect, \(r, e) post_experiment(5000, r, e))) |>
unnest(res)
post_sim |>
group_by(rho) |>
mutate(scenario = paste0("ρ ≈ ", sprintf("%.1f", mean(corr)))) |>
group_by(scenario, effect, method) |>
summarise(rate = mean(p_value < 0.05), se2 = mean(se^2), .groups = "drop") |>
group_by(scenario, effect) |>
mutate(var_ratio = se2 / se2[method == "t-test"]) |>
ungroup() |>
mutate(method = factor(method, levels = c("t-test", "Разность", "CUPED")),
test = if_else(effect == 0, "A/A", "A/B +3%")) |>
arrange(scenario, test, method) |>
transmute(scenario, test, method,
rate = scales::percent(rate, accuracy = 0.1),
var_ratio = sprintf("×%.2f", var_ratio)) |>
knitr::kable(col.names = c("Сценарий", "Тест", "Метод", "Доля p < 0.05", "Дисперсия vs t-test"))| Сценарий | Тест | Метод | Доля p < 0.05 | Дисперсия vs t-test |
|---|---|---|---|---|
| ρ ≈ 0.3 | A/A | t-test | 4.8% | ×1.00 |
| ρ ≈ 0.3 | A/A | Разность | 5.4% | ×1.39 |
| ρ ≈ 0.3 | A/A | CUPED | 4.6% | ×0.91 |
| ρ ≈ 0.3 | A/B +3% | t-test | 32.2% | ×1.00 |
| ρ ≈ 0.3 | A/B +3% | Разность | 26.6% | ×1.37 |
| ρ ≈ 0.3 | A/B +3% | CUPED | 35.4% | ×0.91 |
| ρ ≈ 0.7 | A/A | t-test | 4.3% | ×1.00 |
| ρ ≈ 0.7 | A/A | Разность | 5.2% | ×0.60 |
| ρ ≈ 0.7 | A/A | CUPED | 5.3% | ×0.51 |
| ρ ≈ 0.7 | A/B +3% | t-test | 34.5% | ×1.00 |
| ρ ≈ 0.7 | A/B +3% | Разность | 52.8% | ×0.59 |
| ρ ≈ 0.7 | A/B +3% | CUPED | 58.9% | ×0.51 |
Показать код
rng = np.random.default_rng(2026)
def gen_revenue(n, rho, effect=0.0):
z_pre = rng.normal(size=n)
z_post = rho * z_pre + np.sqrt(1 - rho**2) * rng.normal(size=n)
return np.exp(1 + 0.8 * z_pre), np.exp(1 + 0.8 * z_post) * (1 + effect)
def post_experiment(n, rho, effect):
xc, yc = gen_revenue(n, rho)
xt, yt = gen_revenue(n, rho, effect)
x, y = np.r_[xc, xt], np.r_[yc, yt]
theta = np.cov(x, y)[0, 1] / x.var(ddof=1)
out = {}
for method, (a, b) in {"t-test": (yc, yt),
"Разность": (yc - xc, yt - xt),
"CUPED": (yc - theta * (xc - x.mean()), yt - theta * (xt - x.mean()))}.items():
res = stats.ttest_ind(b, a, equal_var=False)
out[method] = (res.pvalue, a.var(ddof=1) / n + b.var(ddof=1) / n)
return out, np.corrcoef(x, y)[0, 1]
rows = []
for rho in [0.38, 0.76]:
for effect in [0, 0.03]:
for _ in range(2000):
res, corr = post_experiment(5000, rho, effect)
rows += [{"rho": rho, "effect": effect, "method": m, "sig": p < 0.05, "se2": v, "corr": corr}
for m, (p, v) in res.items()]
ps = pd.DataFrame(rows)
ps["scenario"] = "ρ ≈ " + ps.groupby("rho")["corr"].transform("mean").round(1).astype(str)
summary = ps.groupby(["scenario", "effect", "method"], sort=False).agg(rate=("sig", "mean"), se2=("se2", "mean"))
summary["var_ratio"] = summary["se2"] / summary.groupby(level=[0, 1])["se2"].transform("first")
summary[["rate", "var_ratio"]].round(2) rate var_ratio
scenario effect method
ρ ≈ 0.3 0.00 t-test 0.05 1.00
Разность 0.05 1.39
CUPED 0.05 0.91
0.03 t-test 0.36 1.00
Разность 0.26 1.37
CUPED 0.39 0.91
ρ ≈ 0.7 0.00 t-test 0.05 1.00
Разность 0.05 0.60
CUPED 0.05 0.51
0.03 t-test 0.36 1.00
Разность 0.53 0.59
CUPED 0.60 0.51
- Deng, Xu, Kohavi, Walker, 2013 – оригинальная статья про CUPED
- Vickers & Altman, 2001, BMJ – почему в клинических исследованиях с измерением «до» и «после» ANCOVA лучше разностей
- Lin, 2013 – регрессионная поправка в рандомизированных экспериментах
- estimatr – R-пакет с
lm_robust()иlm_lin()
Подписывайтесь на телеграм-канал!