Привет, Хабр!

Меня зовут Евгений Пантелеев. Я занимаюсь аналитикой в Авито Авто в сегменте Resellers.

В этой статье я расскажу о том, как мне удалось усилить CUPED довольно простой механикой и довести результат до статзначимости с помощью пост-стратификации в продолжительном эксперименте.

Также попробуем разобраться почему многие аналитики на практике пренебрегают пост-стратификацией, считая, что мощный CUPED дает повышение чувствительности эксперимента близкое к максимальному.

Контекст

Одна из моих регулярных задач - оценка инкремента в денежном выражении от селлерских CRM-коммуникаций нашей команды (количество коммуникаций исчисляется несколькими десятками): push, e-mail, чат-бот и т.п.

Как правило, я провожу такую оценку с помощью обратного эксперимента: тестовая группа получает все CRM-коммуникации компании, контрольная - все, за исключением коммуникаций нашей команды. В результате можно получить аплифт, и рассчитать из него инкремент по формуле: increment = revenue_test - revenue_test / (1 + effect / 100).

При этой оценке важно получать статзначимый результат, и сделать это на контрольной группе минимального размера.

Это достаточно непростая задача по нескольким причинам:

1.Эффект от CRM-коммуникаций отдельной команды незначителен (в районе 1%), так как эти коммуникации отправляются поверх остальных коммуникаций компании.

2.Сегмент Resellers имеет относительно небольшой размер (несколько сотен тысяч пользователей) и достаточно изменчивый (каждый день его пополняют и покидают наши клиенты).

3. Требуется проводить длительные эксперименты (от 3 до 6 месяцев). Как следствие, нет возможности учитывать ARPU пользователей на протяжении одного и того же периода. В эксперименте есть пользователи с выручкой за 1 день, а есть - за 180 дней.

4. Отсутствует возможность точно фиксировать экспоужер (exposure) для контрольной группы, так как для каждого пользователя стоит ограничение на количество отправляемых коммуникаций в течение дня с определенным сложным алгоритмом ранжирования.

5. Как известно, увеличение диспропорции групп в эксперименте (в нашем случае, сокращение размера контрольной группы) ведет к экспоненциальному росту MDE.

Таким образом, требуется оценить незначительный эффект в изменчивой выборке на протяжении длительного времени без окна атрибуции и четкого экспоужера с минимально возможным размером контрольной группы.

Дизайн

Самый важный шаг к повышению чувствительности, на котором закладывается фундамент итогового результата, — это дизайн эксперимента.

В качестве базового соотношения выбрали 70/30 (тестовая группа/контрольная группа). Это оптимальное для начала соотношение между размером тестовой группы и MDE.

Длительность эксперимента: 6 месяцев.

Механика: каждый день новые реселлеры, попадающие в сегмент триггеров CRM-кампаний, добавляются в эксперимент. В качестве экспоужера учитываем попадание пользователя в сегмент одного из триггеров.

Учет метрик: учитываем метрики пользователя с первого дня попадания пользователя в эксперимент на всем его протяжении (не принимаем во внимание выход пользователя из сегмента Resellers).

Усиление эффекта: ортогонально проверяем новые гипотезы, и в случае получения успешного результата, добавляем их триггеры в наш основной эксперимент для усиления общего аплифта.

Целевая метрика эксперимента: ARPU по общей выручке.

Дополнительные метрики: ARPU по отдельным продуктам, доля платящих пользователей, доля платящих пользователей по отдельным продуктам.

Контр-метрика: доля отписавшихся от CRM-коммуникаций пользователей.

При пропорции контрольной и тестовой групп 30/70 и альфе на уровне 0,05, с учетом применения CUPED, получен MDE по целевой метрике: 1,92% (без учета экспоужера и пост-стратификации). Это будет наш ориентир по верхней границе.

Расчет MDE с учетом экспоужера для несколькоих десятков кампаний на длительном промежутке времени крайне ресурсоемкий, и выглядит нецелесообразным. Поэтому такие расчеты не проводились.

Выбор оптимального предпериода для CUPED

Традиционно считается, что оптимальная длительность предпериода для CUPED - это длительность эксперимента. Но в случае нашего продолжительного эксперимента, 6 месяцев для метрики CUPED - это чрезмерно длинный период. За это время данные могут существенно потерять актуальность.

После сбора данных по итогам эксперимента мы проверили это на практике, сравнив CUPED разной длительности, и подобрали оптимальную продолжительность предпериода, ориентируясь на коэффициент корреляции по целевой метрике:

Фиксируем, что коэффициент корреляции растет вплоть до длительности в 60 дней, а затем начинает снижаться.

Пример функции для расчета
def compare_cuped_periods(df, control_group_only=True):
    
    # Выбираем данные для анализа
    if control_group_only:
        data = df[df['main_grp'] == 'control'].copy()
        group_label = "Контрольная группа"
    else:
        data = df.copy()
        group_label = "Вся выборка"
    
    # Словарь для хранения результатов
    results = []
    
    # Определяем метрики и периоды для анализа
    metrics = {
        'revenue': 'revenue'
    }
    
    periods = [90, 80, 70, 60, 50, 40, 30]

    
    # Для каждой метрики и периода считаем корреляцию
    for metric_name, metric_col in metrics.items():
        for period in periods:
            # Название CUPED-столбца
            cuped_col = f"{metric_name}_cuped_{period}"
            
            # Проверяем, что столбец существует
            if cuped_col not in data.columns:
                print(f"Предупреждение: Столбец {cuped_col} не найден")
                continue
            
            # Рассчитываем корреляцию Пирсона
            correlation = data[metric_col].corr(data[cuped_col])
            
            # Рассчитываем снижение дисперсии (%)
            var_original = data[metric_col].var()
            var_cuped = data[cuped_col].var()
            variance_reduction = ((var_original - var_cuped) / var_original * 100 
                                 if var_original > 0 else 0)
            
            
            # Добавляем результат
            results.append({
                'Метрика': metric_name,
                'Период_CUPED': period,
                'Корреляция_Пирсона': round(correlation, 4),
                'Снижение_дисперсии_%': round(variance_reduction, 2)
            })
    
    # Создаем DataFrame с результатами
    results_df = pd.DataFrame(results)
    
    # Сортируем по метрике и корреляции (по убыванию)
    results_df = results_df.sort_values(['Метрика', 'Корреляция_Пирсона'], 
                                        ascending=[True, False])
    
    return results_df

Таким образом, такая длительность предпериода для CUPED является оптимальной для нашего эксперимента.

Выбор оптимальных страт для пост-стратификации

Выбор мощного критерия для разделения страт — ключевой момент в проведении стратификации. Для качественного решения этого вопроса желательно хорошо погрузиться в бизнес вашей компании и на основе прошлого опыта экспертно предложить несколько подходящих вариантов.

Дополнительно можно обратиться к AI-инструментам, чтобы «выслушать» их варианты по вашему контексту задачи и дополнить свой список. Далее — пробовать и смотреть, какой из критериев (или их комбинация) даст наилучший результат.

Оптимальное количество критериев очень индивидуально. Оно зависит от размера выборки и распределения значений в ней. Неизменными остаются лишь два правила:

1. В каждой страте каждой группы должно быть достаточно наблюдений для проведения вычислений (по крайней мере, 30);

2. Значения метрик пользователей в разных стратах должны существенно различаться.

Наиболее эффективным критерием для пост-стратификации в нашем кейсе оказался следующий: "Количество объявлений в логической категории б/у автомобилей за последние полгода до участия в эксперименте".

Распределение его значений неоднородно. Большинство значений смещено к нулю, но при этом есть длинный "правый" хвост, достигающий нескольких тысяч.

В результате анализа (достаточный размер страт и разница в характеристиках их пользователей) были выделены страты, показавшие наибольшую эффективность:

Далее рассмотрим влияние каждого метода повышения чувствительности на основные метрики эксперимента.

Сравнение метрик

Посмотрим на динамику доверительного интервала и p-value по целевой метрике в трех разрезах в нашем эксперименте:

  1. Без применения CUPED и пост-стратификации;

  2. С применением CUPED;

  3. С применением CUPED и пост-стратификации.

Доверительный интервал
Доверительный интервал
P-value
P-value

Совместное применение двух этих методов в нашем конкретном кейсе позволило, во-первых, получить статзначимый результат за прошедший период, а, во-вторых, осознанно запланировать снижение контрольной группы до 20% в следующем полугодии.

Так как у меня нет возможности делиться с широкой публикой остальными реальными показателями, протестируем данные методы на нескольких синтетических датасетах.

Скрипт для симуляции
import numpy as np
import pandas as pd
from typing import Optional
import scipy.stats as stats

np.random.seed(42)


# Генерация данных

def generate_data(
    n_control=5000,
    n_test=5000,
    effect=0.02,        # 2% uplift
    rho=0.7             # корреляция ковариаты с метрикой
):
    strata = np.array(["A", "B", "C"])
    strata_probs = np.array([0.5, 0.3, 0.2])

    def sample_group(n, is_test=False):
        stratum = np.random.choice(strata, size=n, p=strata_probs)

        # базовые средние по стратам
        base_mean = np.select(
            [stratum == "A", stratum == "B", stratum == "C"],
            [100, 200, 400]
        )

        # предпериод
        pre_metric = base_mean + np.random.normal(0, 50, size=n)

        # основная метрика, коррелированная с предпериодом
        noise = np.random.normal(0, 50 * np.sqrt(1 - rho**2), size=n)
        metric = (
            base_mean
            + rho * (pre_metric - base_mean)
            + noise
        )

        if is_test:
            metric *= (1 + effect)

        return pd.DataFrame({
            "metric": metric,
            "pre_metric": pre_metric,
            "stratum": stratum
        })

    control = sample_group(n_control, is_test=False)
    test = sample_group(n_test, is_test=True)

    return control, test




# Применение CUPED

def cuped_adjustment(
    control_metric,
    test_metric,
    control_covariate,
    test_covariate,
    eps: float = 1e-10
):
    control_metric = np.asarray(control_metric)
    test_metric = np.asarray(test_metric)
    control_covariate = np.asarray(control_covariate)
    test_covariate = np.asarray(test_covariate)

    all_cov = np.concatenate([control_covariate, test_covariate])
    cov_mean = np.mean(all_cov)

    control_cov_c = control_covariate - cov_mean
    test_cov_c = test_covariate - cov_mean

    var_c = np.var(control_cov_c, ddof=1)
    var_t = np.var(test_cov_c, ddof=1)
    total_var = var_c + var_t

    if total_var < eps:
        return control_metric.copy(), test_metric.copy(), 0.0

    cov_control = np.cov(control_metric, control_cov_c, ddof=1)[0, 1]
    cov_test = np.cov(test_metric, test_cov_c, ddof=1)[0, 1]

    theta = (cov_control + cov_test) / total_var

    return (
        control_metric - theta * control_cov_c,
        test_metric - theta * test_cov_c,
        theta
    )


# t-тест для оценки относительного эффекта с использованием дельта-метода

def relative_ttest(
    control,
    test,
    alpha: float = 0.05
):
    control = np.asarray(control)
    test = np.asarray(test)

    mean_c = control.mean()
    mean_t = test.mean()

    if abs(mean_c) < 1e-10:
        return None

    delta = (mean_t - mean_c) / mean_c

    var_c = np.var(control, ddof=1) / len(control)
    var_t = np.var(test, ddof=1) / len(test)

    var_delta = (
        var_t / mean_c**2 +
        (mean_t**2 / mean_c**4) * var_c
    )

    se = np.sqrt(var_delta)
    z = delta / se if se > 0 else 0.0

    p_value_one_sided = 1 - stats.norm.cdf(z)

    z_crit = stats.norm.ppf(1 - alpha / 2)
    ci_lower = delta - z_crit * se
    ci_upper = delta + z_crit * se

    return {
        'effect': delta * 100,
        'p_value_one_sided': p_value_one_sided,
        'ci_length': (ci_upper - ci_lower) * 100,
        'left_bound': ci_lower * 100,
        'right_bound': ci_upper * 100
    }


# Применение пост-стратификации

def relative_ttest_post_strat(
    control_values,
    test_values,
    control_strata,
    test_strata,
    alpha=0.05,
    min_n=30,
    min_mean=1e-6
):
    control_values = np.asarray(control_values)
    test_values = np.asarray(test_values)
    control_strata = np.asarray(control_strata)
    test_strata = np.asarray(test_strata)

    all_strata = np.concatenate([control_strata, test_strata])
    unique_strata = np.unique(all_strata)

    pop_weights = {s: np.mean(all_strata == s) for s in unique_strata}

    effect_sum = 0.0
    var_sum = 0.0
    weight_sum = 0.0
    strata_log = []

    for s in unique_strata:
        c = control_values[control_strata == s]
        t = test_values[test_strata == s]

        n_c, n_t = len(c), len(t)
        log_row = {"stratum": s, "n_control": n_c, "n_test": n_t, "included": False}

        if n_c < min_n or n_t < min_n:
            strata_log.append(log_row)
            continue

        mean_c = c.mean()
        mean_t = t.mean()

        if mean_c < min_mean:
            strata_log.append(log_row)
            continue

        var_c = np.var(c, ddof=1) / n_c
        var_t = np.var(t, ddof=1) / n_t

        delta_s = (mean_t - mean_c) / mean_c

        var_delta_s = (
            var_t / mean_c**2 +
            (mean_t**2 / mean_c**4) * var_c
        )

        w = pop_weights[s]
        effect_sum += w * delta_s
        var_sum += w**2 * var_delta_s
        weight_sum += w

        log_row.update({"relative_effect": delta_s * 100, "included": True})
        strata_log.append(log_row)

    if weight_sum == 0:
        raise ValueError("No valid strata")

    delta = effect_sum / weight_sum
    se = np.sqrt(var_sum) / weight_sum

    z = delta / se if se > 0 else 0.0
    p_value_one_sided = 1 - stats.norm.cdf(z)

    z_crit = stats.norm.ppf(1 - alpha / 2)
    ci_lower = delta - z_crit * se
    ci_upper = delta + z_crit * se

    return {
        'effect': delta * 100,
        'p_value_one_sided': p_value_one_sided,
        'ci_length': (ci_upper - ci_lower) * 100,
        'left_bound': ci_lower * 100,
        'right_bound': ci_upper * 100,
        'strata_log': strata_log
    }



# Собираем результат

def relative_ttest_comparison(
    control_df,
    test_df,
    metric_column,
    covariate_column: Optional[str] = None,
    stratification_column: Optional[str] = None,
    metric_type: str = "numeric",  # "numeric" | "binary"
    alpha: float = 0.05
):
    control_metric = control_df[metric_column].values
    test_metric = test_df[metric_column].values

    # ---------- BASELINE ----------
    baseline = relative_ttest(control_metric, test_metric, alpha)

    # ---------- POST-STRAT ----------
    post_strat = None
    if stratification_column is not None:
        if metric_type == "binary":
            post_strat = relative_ttest_post_strat_binary(
                control_metric,
                test_metric,
                control_df[stratification_column].values,
                test_df[stratification_column].values,
                alpha
            )
        else:
            post_strat = relative_ttest_post_strat(
                control_metric,
                test_metric,
                control_df[stratification_column].values,
                test_df[stratification_column].values,
                alpha
            )

    # ---------- CUPED ----------
    cuped = None
    post_strat_cuped = None
    theta = None

    if metric_type == "numeric" and covariate_column is not None:
        control_cup, test_cup, theta = cuped_adjustment(
            control_metric,
            test_metric,
            control_df[covariate_column].values,
            test_df[covariate_column].values
        )

        cuped = relative_ttest(control_cup, test_cup, alpha)

        if stratification_column is not None:
            post_strat_cuped = relative_ttest_post_strat(
                control_cup,
                test_cup,
                control_df[stratification_column].values,
                test_df[stratification_column].values,
                alpha
            )

    return {
        # baseline
        'effect': baseline['effect'] if baseline else None,
        'p_value_one_sided': baseline['p_value_one_sided'] if baseline else None,
        'ci_length': baseline['ci_length'] if baseline else None,
        'left_bound': baseline['left_bound'] if baseline else None,
        'right_bound': baseline['right_bound'] if baseline else None,

        # post-strat
        'effect_post_strat': post_strat['effect'] if post_strat else None,
        'p_value_one_sided_post_strat': post_strat['p_value_one_sided'] if post_strat else None,
        'ci_length_post_strat': post_strat['ci_length'] if post_strat else None,
        'left_bound_post_strat': post_strat['left_bound'] if post_strat else None,
        'right_bound_post_strat': post_strat['right_bound'] if post_strat else None,

        # cuped
        'effect_cuped': cuped['effect'] if cuped else None,
        'p_value_one_sided_cuped': cuped['p_value_one_sided'] if cuped else None,
        'ci_length_cuped': cuped['ci_length'] if cuped else None,
        'left_bound_cuped': cuped['left_bound'] if cuped else None,
        'right_bound_cuped': cuped['right_bound'] if cuped else None,

        # cuped + post-strat
        'effect_cuped_post_strat': post_strat_cuped['effect'] if post_strat_cuped else None,
        'p_value_one_sided_cuped_post_strat': post_strat_cuped['p_value_one_sided'] if post_strat_cuped else None,
        'ci_length_cuped_post_strat': post_strat_cuped['ci_length'] if post_strat_cuped else None,
        'left_bound_cuped_post_strat': post_strat_cuped['left_bound'] if post_strat_cuped else None,
        'right_bound_cuped_post_strat': post_strat_cuped['right_bound'] if post_strat_cuped else None,
        'cuped_post_strat_strata_log': post_strat_cuped['strata_log'] if post_strat_cuped else None,

        'theta': theta
    }

df_result = pd.DataFrame(columns=[
        'ci_length',
        'ci_length_cuped',
        'ci_length_cuped_post_strat',
        'effect',
        'effect_cuped',
        'effect_cuped_post_strat',
        'p_value_one_sided',
        'p_value_one_sided_cuped',
        'p_value_one_sided_cuped_post_strat'])


for i in range(20):

    
    control_df, test_df = generate_data(effect=0.001)


    results = relative_ttest_comparison(
    control_df,
    test_df,
    metric_column="metric",
    covariate_column="pre_metric",
    stratification_column="stratum",
    metric_type="numeric",
    alpha=0.05
)

       
    new_row = {
        'ci_length': results['ci_length'],
        'ci_length_cuped': results['ci_length_cuped'],
        'ci_length_cuped_post_strat': results['ci_length_cuped_post_strat'],
        'effect': results['effect'],
        'effect_cuped': results['effect_cuped'],
        'effect_cuped_post_strat': results['effect_cuped_post_strat'],
        'p_value_one_sided': results['p_value_one_sided'],
        'p_value_one_sided_cuped': results['p_value_one_sided_cuped'],
        'p_value_one_sided_cuped_post_strat': results['p_value_one_sided_cuped_post_strat']
    }
    
       
    df_result = pd.concat([df_result, pd.DataFrame([new_row])], ignore_index=True)


df_result

На синтетических данных, как и в реальных кейсах, далеко не всегда есть существенная польза от пост-стратификации в дополнение к CUPED. Выбор критерия стратификации и корректное выделение страт имеют решающее значение в методологии. И этот процесс фактически является отдельным исследованием, которое потребует значимое количество story points без гарантии достижения существенного результата.

Это, пожалуй, и есть причина того, что стратификация не получила такого широкого распространения на практике, как можно было бы ожидать.

Важно также заметить, что CUPED с высоким коэффициентом корреляции не только существенно сужает доверительный интервал, но и корректирует оценку эффекта, учитывая дисбаланс метрики в тестовой и контрольной группах на предпериоде. В нашем кейсе это крайне важно, потому что именно на основе скорректированного эффекта мы далее рассчитываем инкремент в денежном выражении.

Заключение

Сочетание CUPED и пост-стратификации может быть крайне полезно при проведении экспериментов.

Важно подобрать длительность предпериода для CUPED таким образом, чтобы он показывал наибольший коэффициент корреляции с экспериментальными данными. Кроме повышения чувствительности, CUPED также полезен тем, что корректирует эффект, учитывая дисбаланс метрик в группах на предпериоде.

Пост-стратификация полезна далеко не всегда. Выбор критерия стратификации и корректно выделение страт имеют решающее значение в методологии. И этот процесс фактически является отдельным исследованием, которое потребует значимое количество story points без гарантии достижения существенного результата.

Возможно в этом и заключается причина того, что стратификация не получила такого широкого распространения на практике, как можно было бы ожидать.