Фрея · риск-менеджер необанка «Монетка»
Следующий тест кешбэка хотят запустить на месяц, а не на квартал. Мне сказали, что точности не хватит. У нас же есть история каждого клиента за три месяца до теста. Неужели она ничем не поможет?
Поможет. Траты в кафе на клиента у теста выше на 697,59 ₽ за квартал, стандартная ошибка 197,60 ₽, интервал от 310 до 1085 ₽. Интервал широкий, потому что клиенты разные: один обедает в ресторанах, другой раз в месяц берёт кофе. В уроке про мощность мы видели, что дисперсия метрики прямо задаёт, сколько клиентов и дней нужно тесту. Сегодня уменьшим её, не трогая сам тест.
Взвешивание с поправкой на тару
Ты взвешиваешь варенье в банках. Банки разные, от 300 до 700 граммов стекла, и разброс веса огромный. Но вес каждой пустой банки известен заранее. Вычитаешь его, и остаётся почти один разброс варенья.
С клиентами так же. Большая часть разброса трат в кафе за время теста это привычки, которые были и до теста. Траты клиента за январь-март и есть «вес пустой банки». Вычтем ту часть трат, которую предсказывает история, и останется разброс, связанный с самим тестом и случаем.
Ковариата до теста
Сначала убедимся, что история лежит до теста. Каждого клиента назначали в группу в свой момент, assigned_at:
SELECT min(assigned_at), max(assigned_at) FROM cashback_groups;
min | max
------------------------+------------------------
2026-04-01 00:00:00+00 | 2026-04-02 05:00:00+00
Операции в базе начинаются 1 января. Значит, у каждого клиента есть три месяца до назначения. Ни одной оплаты в кафе между 1 апреля 00:00 и назначением клиента нет, поэтому граница «до и после» по assigned_at даёт те же траты за время теста, что и в уроке 54. Правило всё равно строгое: ковариата считается до того, как клиент увидел свой вариант. Собрать её в SQL ты сможешь в квесте ★★.
В cafe_cuped.csv по строке на клиента: pre_cafe_rub это траты в кафе до назначения (X), cafe_rub траты после (Y).
import numpy as np
import pandas as pd
from scipy import stats
clients = pd.read_csv("cafe_cuped.csv")
clients.groupby("test_group")[["pre_cafe_rub", "cafe_rub"]].mean()
pre_cafe_rub cafe_rub
test_group
control 3651.120724 3746.078471
test 3435.562500 4443.664959
До теста группа test тратила в кафе на 215,56 ₽ меньше. Это случайность жребия, p = 0,25, но она уже сидит внутри сырого эффекта.
CUPED
CUPED делает поправку в одну строку:
theta = cov(X, Y) / var(X)
Y_cuped = Y - theta * (X - mean(X))
theta это наклон прямой, по которой траты до теста предсказывают траты во время теста, как в уроке про регрессию. X - mean(X) показывает, насколько клиент тратил больше или меньше среднего до теста. Эту разницу, умноженную на наклон, мы и вычитаем.
x = clients["pre_cafe_rub"]
y = clients["cafe_rub"]
theta = np.cov(x, y)[0, 1] / x.var() # 0.533
rho = np.corrcoef(x, y)[0, 1] # 0.502
y_cuped = y - theta * (x - x.mean())
y_cuped.var() / y.var() # 0.748
Дисперсия упала до 1 - rho**2 = 0,748 от исходной: стандартное отклонение трат было 4393 ₽, стало 3799 ₽. Это не совпадение, а свойство формулы: при таком theta поправка убирает ровно ту долю дисперсии, которую объясняет X, а это rho**2.
Теперь тот же тест на Y_cuped:
| Эффект | Стандартная ошибка | z | 95% интервал | |
|---|---|---|---|---|
| Сырые траты | 697,59 ₽ | 197,60 ₽ | 3,53 | 310-1085 ₽ |
| CUPED | 812,57 ₽ | 170,46 ₽ | 4,77 | 478-1147 ₽ |
Интервал сузился на 14%, p-value упал с 0,0004 до 0,000002. Стандартная ошибка падает как корень из числа клиентов, поэтому без CUPED для такой точности понадобилось бы в 1 / 0,748 = 1,34 раза больше клиентов. Или, наоборот, с CUPED той же точности хватает три четверти выборки. Для Фреи это и есть ответ: история клиентов работает как дополнительные клиенты в тесте.
урок из курса
Урок из курса «Аналитика данных с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.
Оплата пока не подключена: премиум и курсы открываются промокодом.