Машинное обучение для A/B тестов: практический гайд по CUPAC

A/B-тестирование остается основным инструментом для принятия продуктовых решений в технологических компаниях. Главная проблема — высокая дисперсия метрик, из-за которой требуется несколько недель или даже месяцев, чтобы достичь статистической значимости. Длительные эксперименты замедляют итерации и увеличивают альтернативные издержки.

Классический подход к снижению дисперсии — метод CUPED. Он использует исторические значения целевой метрики для корректировки результатов. Метод CUPAC (Control Using Predictions As Covariates) расширяет эту идею: вместо исторических данных используются предсказания машинного обучения. Это позволяет задействовать более широкий набор признаков и достичь большего снижения дисперсии.

Проблема variance в экспериментах

Статистическая мощность A/B теста определяется размером эффекта, объемом выборки и дисперсией метрики. Первые два параметра часто находятся вне контроля исследователя: изменения в продукте дают тот эффект, который дают, а увеличение трафика ограничено масштабом бизнеса. Остается третий параметр — дисперсия.

Почему высокая дисперсия замедляет тесты

Стандартная ошибка среднего пропорциональна:

σ/√n

где:

  • σ — стандартное отклонение метрики;
  • n — размер выборки.

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

Типичный пример: метрика Выручка на пользователя (RPU) обладает высокой дисперсией, потому что в выборке одновременно присутствуют неактивные пользователи с нулевой выручкой и клиенты, которые тратят очень много. Из-за этого распределение сильно скошено вправо, что приводит к длительным экспериментам даже при заметных изменениях в продукте.

Практические последствия высокой дисперсии:

  1. Эксперименты длятся 4-8 недель вместо 1-2 недель;
  2. Невозможность тестировать небольшие улучшения, которые в сумме дают значительный эффект;
  3. Увеличение риска внешних конфаундеров (сезонность, конкурентные действия, технические инциденты);
  4. Замедление скорости итераций и продуктовых релизов.

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

Традиционные подходы к снижению дисперсии

Стратификация — классический метод из теории выборки. Пользователи разбиваются на однородные группы (страты) по заранее известным признакам, внутри каждой страты проводится рандомизация. Дисперсия итоговой оценки снижается за счет исключения между-стратовой вариации.

Ограничения стратификации в онлайн-экспериментах:

  1. Необходимость отбора признаков для стратификации до начала эксперимента;
  2. Сложность с непрерывными признаками, требующими дискретизации;
  3. Комбинаторный рост числа страт при использовании нескольких признаков;
  4. Малые размеры некоторых страт приводят к проблемам с оценкой.

Постстратификация (post-stratification) частично решает проблему высокой дисперсии, но требует точного знания размеров страт в генеральной совокупности. Метод регрессионной корректировки (regression adjustment) использует линейную регрессию для уточнения оценок, однако его эффективность ограничена допущением о линейности связей.

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

CUPED: фундамент для CUPAC

CUPED использует ковариатную корректировку для снижения дисперсии экспериментальных метрик. Основная идея: если известна переменная, коррелирующая с целевой метрикой но не зависящая от воздействия (treatment), ее можно использовать для уменьшения шума в оценках.

Механика работы CUPED

Пусть Y — метрика в период эксперимента, X — та же метрика в предэкспериментальный период (pre-experiment period). Скорректированная метрика вычисляется как:

Y_cv = Y — θ(X — E[X])

где:

  • Y_cv — скорректированное значение метрики;
  • Y — наблюдаемое значение в эксперименте;
  • X — значение ковариаты (pre-experiment метрика);
  • θ — оптимальный коэффициент корректировки;
  • E[X] — среднее значение ковариаты по всей выборке.

Параметр θ выбирается для минимизации дисперсии скорректированной метрики. Оптимальное значение:

θ* = Cov(Y, X) / Var(X)

Это стандартный коэффициент из линейной регрессии Y на X. Дисперсия скорректированной метрики:

Var(Y_cv) = Var(Y)(1 — ρ²)

где ρ — корреляция между Y и X.

При ρ = 0.7 дисперсия снижается на 51%, что эквивалентно удвоению размера выборки.

Ключевое свойство метода CUPED состоит в том, что он не вносит смещения (bias) в оценку эффекта воздействия (treatment effect). Корректирующий член θ · (X − E[X]) по построению имеет нулевое математическое ожидание, поэтому выполняется равенство E[Y_cv] = E[Y]. Благодаря рандомизации распределение X одинаково в контрольной и тестовой группах, и значит корректировка не влияет на разницу между ними.

👉🏻  A/B-тестирование маркетинговых кампаний с помощью Python

Ограничения метода:

  1. CUPED требует исторических данных той же метрики для каждого пользователя, поэтому новые пользователи не могут быть корректно обработаны — для них либо используют нули, снижая эффективность, либо исключают из корректировки;
  2. Метод чувствителен к нестационарности: если поведение пользователей меняется между pre- и post- периодами, корреляция падает, и эффективность CUPED снижается;
  3. Кроме того, CUPED работает только с одной ковариатой и не позволяет использовать дополнительную информацию о пользователях; расширение на несколько признаков приводит к нестабильности оценок и может увеличить дисперсию.

CUPAC: использование ML-предсказаний

CUPAC решает ограничения CUPED через замену исторических значений метрики на предсказания машинного обучения. Вместо корреляции между pre- и post-experiment значениями одной метрики используется предиктивная модель, обученная на множестве признаков.

Ключевые отличия от CUPED

Архитектурное изменение заключается в разделении на два этапа: обучение предиктивной модели и применение ковариатной корректировки. Модель обучается предсказывать целевую метрику Y по набору признаков X, которые доступны до начала эксперимента. Предсказания ŷ = f(X) затем используются как ковариата в формуле корректировки.

Преимущества подхода:

  1. Возможность использовать любые признаки пользователей, не только историю целевой метрики;
  2. Работа с новыми пользователями через предсказания по демографическим и первым взаимодействиям;
  3. Адаптация к нестационарности через регулярное переобучение модели;
  4. Большее снижение дисперсии за счет более точных предсказаний.

Качество предсказаний напрямую влияет на размер снижения дисперсии. Если модель объясняет 60% дисперсии целевой метрики (R² = 0.6), теоретический предел снижения дисперсии через CUPAC составляет 60%. Для сравнения, CUPED с корреляцией 0.7 между периодами дает снижение на 49%.

Выбор признаков для модели имеет ключевое значение. Все используемые признаки должны быть доступны до начала эксперимента и не зависеть от назначения в контрольную или тестовую группу. Это исключает использование конкурентных фичей (concurrent features) — характеристик, измеряемых одновременно с целевой метрикой и потенциально затрагиваемых воздействием.

Математический аппарат

Корректировка в CUPAC использует ту же формулу, что и CUPED:

Y_cv = Y — θ(ŷ — E[ŷ])

где:

  • Y_cv — скорректированное значение метрики;
  • Y — наблюдаемое значение;
  • ŷ — предсказание ML-модели;
  • θ — коэффициент корректировки;
  • E[ŷ] — среднее предсказание.

Оптимальный коэффициент:

θ* = Cov(Y, ŷ) / Var(ŷ)

Это эквивалентно регрессии остатков на предсказания. Дисперсия скорректированной метрики:

Var(Y_cv) = Var(Y)(1 — ρ²_Yŷ)

где ρ_Yŷ — корреляция между фактическими значениями и предсказаниями.

Эта корреляция связана с R² модели: ρ²_Yŷ = R², следовательно максимальное снижение дисперсии определяется качеством предсказаний модели.

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

Практическая реализация требует особого внимания к оценке коэффициента θ. Наивный подход — вычислять θ на тех же данных, на которых обучалась модель — это приводит к переобучению корректировки. Правильная практика — использовать кросс-валидационные предсказания (cross-fitted predictions), когда для каждого пользователя предсказание получено моделью, обученной без его данных.

Выбор предикторов для ковариат

Эффективный набор признаков для CUPAC модели балансирует между предсказательной силой и практичностью сбора данных. Категории признаков по убыванию важности:

  • Исторические значения целевой метрики остаются наиболее сильными предикторами. Агрегаты за разные временные окна (7, 14, 30 дней) захватывают как недавние тренды, так и долгосрочные паттерны. Для метрик с сезонностью добавляются значения из аналогичных периодов прошлого года;
  • Поведенческие метрики включают частоту использования продукта, глубину взаимодействия, давность последнего визита. Эти признаки особенно ценны для новых пользователей, у которых нет длинной истории целевой метрики;
  • Демографические и контекстные признаки: возраст, география, тип устройства, источник привлечения. Их предсказательная сила обычно ниже, но они доступны для всех пользователей, включая новых;
  • Признаки из смежных метрик расширяют информацию. Если целевая метрика — выручка на пользователя, то признаки из количества сессий, времени в продукте, метрик вовлеченности добавляют ортогональную информацию.
👉🏻  Прогнозирование трафика и конверсий сайта с помощью Catboost

Реализация CUPAC на Python

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

Подготовка данных и обучение модели

Временная структура данных для CUPAC включает три периода:

  • training period для обучения модели;
  • prediction period для генерации предсказаний;
  • experiment period для проведения теста.

Training period должен быть достаточно длинным для накопления статистики (обычно 60-90 дней) и располагаться до начала эксперимента.

import pandas as pd
import numpy as np
from datetime import datetime, timedelta

class CUPACPipeline:
    def __init__(self, training_days=60, prediction_lag=7):
        """
        training_days: длина периода для обучения модели
        prediction_lag: задержка между prediction и experiment периодами
        """
        self.training_days = training_days
        self.prediction_lag = prediction_lag
        self.model = None
        self.scaler = None
        self.theta = None
        
    def prepare_training_data(self, df, experiment_start_date):
        """
        Подготовка данных для обучения модели
        """
        # Определение временных границ
        prediction_date = experiment_start_date - timedelta(days=self.prediction_lag)
        training_end = prediction_date
        training_start = training_end - timedelta(days=self.training_days)
        
        # Фильтрация данных
        training_data = df[
            (df['date'] >= training_start) & 
            (df['date'] < training_end) ].copy() # Создание целевой переменной (будущие значения) target_data = df[ (df['date'] >= prediction_date) &
            (df['date'] < experiment_start_date) ].copy() target_agg = target_data.groupby('user_id').agg({ 'revenue': 'sum', 'sessions': 'count' }).reset_index() target_agg.columns = ['user_id', 'target_revenue', 'target_sessions'] return training_data, target_agg, prediction_date def engineer_features(self, df, reference_date): """ Создание признаков для модели """ features_list = [] # Агрегация по временным окнам for window in [7, 14, 30]: window_start = reference_date - timedelta(days=window) window_data = df[ (df['date'] >= window_start) & 
                (df['date'] < reference_date) ] user_agg = window_data.groupby('user_id').agg({ 'revenue': ['sum', 'mean', 'std', 'count'], 'sessions': ['count', 'mean'], 'session_duration': ['mean', 'max'] }) user_agg.columns = [f'{col[0]}_{col[1]}_{window}d' for col in user_agg.columns] user_agg = user_agg.reset_index() if len(features_list) == 0: features_list.append(user_agg) else: features_list[0] = features_list[0].merge( user_agg, on='user_id', how='outer' ) features_df = features_list[0] # Признаки recency last_activity = df.groupby('user_id')['date'].max().reset_index() last_activity['recency_days'] = ( reference_date - last_activity['date'] ).dt.days features_df = features_df.merge( last_activity[['user_id', 'recency_days']], on='user_id', how='left' ) # Категориальные признаки categorical_features = df.groupby('user_id').agg({ 'device_type': lambda x: x.mode()[0] if len(x) > 0 else 'unknown',
            'country': lambda x: x.mode()[0] if len(x) > 0 else 'unknown'
        }).reset_index()
        
        features_df = features_df.merge(
            categorical_features, 
            on='user_id', 
            how='left'
        )
        
        # One-hot encoding для категориальных признаков
        features_df = pd.get_dummies(
            features_df, 
            columns=['device_type', 'country'],
            drop_first=True
        )
        
        # Заполнение пропусков
        features_df = features_df.fillna(0)
        
        return features_df
    
    def fit(self, training_data, target_data, reference_date):
        """
        Обучение CUPAC модели
        """
        from sklearn.ensemble import GradientBoostingRegressor
        from sklearn.preprocessing import StandardScaler
        from sklearn.model_selection import KFold
        
        # Создание признаков
        features = self.engineer_features(training_data, reference_date)
        
        # Объединение с целевой переменной
        model_data = features.merge(target_data, on='user_id', how='inner')
        
        X = model_data.drop(['user_id', 'target_revenue', 'target_sessions'], axis=1)
        y = model_data['target_revenue']
        
        # Масштабирование
        self.scaler = StandardScaler()
        X_scaled = self.scaler.fit_transform(X)
        
        # Cross-fitted predictions
        kf = KFold(n_splits=5, shuffle=True, random_state=42)
        cv_predictions = np.zeros(len(y))
        
        for train_idx, val_idx in kf.split(X_scaled):
            model_fold = GradientBoostingRegressor(
                n_estimators=150,
                max_depth=6,
                learning_rate=0.05,
                subsample=0.8,
                min_samples_leaf=50,
                random_state=42
            )
            
            model_fold.fit(X_scaled[train_idx], y.iloc[train_idx])
            cv_predictions[val_idx] = model_fold.predict(X_scaled[val_idx])
        
        # Финальная модель на всех данных
        self.model = GradientBoostingRegressor(
            n_estimators=150,
            max_depth=6,
            learning_rate=0.05,
            subsample=0.8,
            min_samples_leaf=50,
            random_state=42
        )
        
        self.model.fit(X_scaled, y)
        
        # Метрики качества
        self.r_squared = 1 - np.var(y - cv_predictions) / np.var(y)
        self.correlation = np.corrcoef(y, cv_predictions)[0, 1]
        
        # Сохранение имен признаков для предсказаний
        self.feature_names = X.columns.tolist()
        
        return cv_predictions
    
    def predict(self, experiment_data, experiment_start_date):
        """
        Генерация предсказаний для периода эксперимента
        """
        # Создание признаков на момент начала эксперимента
        prediction_date = experiment_start_date - timedelta(days=self.prediction_lag)
        features = self.engineer_features(experiment_data, prediction_date)
        
        # Выравнивание признаков с обучающими данными
        X = features.drop('user_id', axis=1)
        
        # Добавление отсутствующих колонок
        for col in self.feature_names:
            if col not in X.columns:
                X[col] = 0
        
        # Удаление лишних колонок и сортировка
        X = X[self.feature_names]
        
        X_scaled = self.scaler.transform(X)
        predictions = self.model.predict(X_scaled)
        
        return features['user_id'], predictions

Давайте рассмотрим, что делает этот код:

  1. Мы создаем класс CUPACPipeline для построения и применения пайплайна CUPAC;
  2. Метод __init__ задает параметры обучения и инициализирует пустые атрибуты модели и масштабирования;
  3. Метод prepare_training_data формирует тренировочные данные и целевую переменную по пользователям. Затем рассчитывает временные границы для тренировочного окна и периода предсказания на основе даты эксперимента, и фильтрует исходный датафрейм по датам, плюс агрегирует метрики пользователей для обучения модели;
  4. Метод engineer_features создает признаки: агрегаты за разные временные окна, давность пользователей (recency) и категориальные признаки с one-hot кодированием;
  5. Метод fit обучает модель на признаках и целевой переменной с масштабированием и cross-fitted предсказаниями. Затем рассчитывает метрики качества модели, такие как R² и корреляцию между предсказаниями и реальными значениями;
  6. Метод predict создает признаки для экспериментальных данных и генерирует предсказания по обученной модели.
👉🏻  Проведение A/B-тестов дизайна сайта с помощью машинного обучения с Python

Модель машинного обучения GradientBoostingRegressor обучается на исторических признаках пользователей, чтобы предсказывать будущую выручку за период до эксперимента. Она используется как корректирующий инструмент CUPAC, уменьшая дисперсию метрики без внесения смещения в оценку эффекта воздействия.

Параметры градиентного бустинга настроены консервативно: умеренная глубина деревьев (6), низкий learning rate (0.05), большой min_samples_leaf (50). Это снижает риск переобучения, что важнее максимизации R² на валидации.

Применение корректировки в эксперименте

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

# Генерация синтетических данных
np.random.seed(42)
# Параметры
n_users = 5000  # 5000 пользователей
date_range = pd.date_range('2025-01-01', '2025-11-30', freq='D')
# Создание исторических данных пользователей
user_data = []
for user_id in range(n_users):
# Базовые характеристики пользователя
base_revenue = np.random.lognormal(mean=2, sigma=1)
device_type = np.random.choice(['mobile', 'desktop', 'tablet'], p=[0.6, 0.3, 0.1])
country = np.random.choice(['US', 'UK', 'DE', 'FR'], p=[0.4, 0.3, 0.2, 0.1])
# Генерация активности по дням (весь исторический период до эксперимента)
for date in date_range[:120]:  # До 1 мая (120 дней с 1 января)
if np.random.random() < 0.3: # 30% дней пользователь активен sessions = np.random.poisson(2) revenue = base_revenue * np.random.gamma(2, 0.5) * sessions session_duration = np.random.exponential(300) * sessions user_data.append({ 'user_id': user_id, 'date': date, 'revenue': revenue, 'sessions': sessions, 'session_duration': session_duration, 'device_type': device_type, 'country': country }) df_historical = pd.DataFrame(user_data) # Данные эксперимента (1 мая - 31 октября, 184 дня) experiment_start = pd.Timestamp('2025-05-01') experiment_end = pd.Timestamp('2025-10-31') experiment_data = [] for user_id in range(n_users): # Рандомизация в тест/контроль treatment = np.random.binomial(1, 0.5) # Treatment effect: 8% увеличение revenue treatment_multiplier = 1.08 if treatment == 1 else 1.0 # Получение базовых характеристик пользователя user_history = df_historical[df_historical['user_id'] == user_id] if len(user_history) > 0:
base_revenue = user_history['revenue'].mean()
device_type = user_history['device_type'].iloc[0]
country = user_history['country'].iloc[0]
else:
# Новый пользователь
base_revenue = np.random.lognormal(mean=2, sigma=1)
device_type = np.random.choice(['mobile', 'desktop', 'tablet'])
country = np.random.choice(['US', 'UK', 'DE', 'FR'])
# Генерация данных эксперимента (184 дня)
experiment_days = pd.date_range(experiment_start, experiment_end, freq='D')
for date in experiment_days:
if np.random.random() < 0.35: sessions = np.random.poisson(2) revenue = base_revenue * treatment_multiplier * np.random.gamma(2, 0.5) * sessions session_duration = np.random.exponential(300) * sessions experiment_data.append({ 'user_id': user_id, 'date': date, 'revenue': revenue, 'sessions': sessions, 'session_duration': session_duration, 'device_type': device_type, 'country': country, 'treatment': treatment }) df_experiment = pd.DataFrame(experiment_data) # Агрегация метрик эксперимента по пользователям experiment_results = df_experiment.groupby('user_id').agg({ 'revenue': 'sum', 'sessions': 'sum', 'treatment': 'first' }).reset_index() print(f"Historical data: {len(df_historical)} записей, {df_historical['user_id'].nunique()} пользователей") print(f"Experiment data: {len(experiment_results)} пользователей") print(f"Control group: {(experiment_results['treatment']==0).sum()} пользователей") print(f"Treatment group: {(experiment_results['treatment']==1).sum()} пользователей") # Функция подготовки данных def prepare_training_data(df, experiment_start_date, training_days=60, prediction_window=7): """ Исправленная подготовка данных для обучения """ # Prediction period находится перед экспериментом prediction_end = experiment_start_date - timedelta(days=1) prediction_start = prediction_end - timedelta(days=prediction_window) # Training period находится перед prediction period training_end = prediction_start - timedelta(days=1) training_start = training_end - timedelta(days=training_days) print(f"Training period: {training_start.date()} to {training_end.date()}") print(f"Prediction period: {prediction_start.date()} to {prediction_end.date()}") print(f"Experiment starts: {experiment_start_date.date()}") # Данные для обучения (признаки) training_data = df[ (df['date'] >= training_start) & 
(df['date'] < training_end) ].copy() # Данные для целевой переменной (что мы хотим предсказать) target_data = df[ (df['date'] >= prediction_start) &
(df['date'] <= prediction_end)
].copy()
# Агрегация целевой переменной по пользователям
target_agg = target_data.groupby('user_id').agg({
'revenue': 'sum',
'sessions': 'count'
}).reset_index()
target_agg.columns = ['user_id', 'target_revenue', 'target_sessions']
# Для признаков используем дату конца training периода
reference_date = training_end
return training_data, target_agg, reference_date
# Обучение CUPAC модели
pipeline = CUPACPipeline(training_days=60, prediction_lag=7)
# Используем функцию подготовки данных
training_data, target_data, reference_date = prepare_training_data(
df_historical,
experiment_start,
training_days=60,
prediction_window=7
)
print(f"\nTraining period records: {len(training_data)}")
print(f"Target period users: {len(target_data)}")
print(f"Users with both training and target data: {len(target_data[target_data['user_id'].isin(training_data['user_id'].unique())])}")
if len(target_data) == 0:
raise ValueError("No target data available. Check date ranges.")
# Создание признаков и обучение
features = pipeline.engineer_features(training_data, reference_date)
model_data = features.merge(target_data, on='user_id', how='inner')
print(f"Model training data: {len(model_data)} пользователей")
if len(model_data) < 100:
raise ValueError(f"Insufficient training data: {len(model_data)} users")
# Обучение модели
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import KFold
X = model_data.drop(['user_id', 'target_revenue', 'target_sessions'], axis=1)
y = model_data['target_revenue']
pipeline.scaler = StandardScaler()
X_scaled = pipeline.scaler.fit_transform(X)
# Cross-fitted predictions
kf = KFold(n_splits=5, shuffle=True, random_state=42)
cv_predictions = np.zeros(len(y))
for train_idx, val_idx in kf.split(X_scaled):
model_fold = GradientBoostingRegressor(
n_estimators=150,
max_depth=6,
learning_rate=0.05,
subsample=0.8,
min_samples_leaf=50,
random_state=42
)
model_fold.fit(X_scaled[train_idx], y.iloc[train_idx])
cv_predictions[val_idx] = model_fold.predict(X_scaled[val_idx])
# Финальная модель
pipeline.model = GradientBoostingRegressor(
n_estimators=150,
max_depth=6,
learning_rate=0.05,
subsample=0.8,
min_samples_leaf=50,
random_state=42
)
pipeline.model.fit(X_scaled, y)
pipeline.r_squared = 1 - np.var(y - cv_predictions) / np.var(y)
pipeline.correlation = np.corrcoef(y, cv_predictions)[0, 1]
pipeline.feature_names = X.columns.tolist()
print(f"\nModel R²: {pipeline.r_squared:.3f}")
print(f"Model correlation: {pipeline.correlation:.3f}")
# Генерация предсказаний для периода эксперимента
# Используем данные до начала эксперимента для признаков
prediction_reference_date = experiment_start - timedelta(days=1)
features_experiment = pipeline.engineer_features(df_historical, prediction_reference_date)
X_experiment = features_experiment.drop('user_id', axis=1)
# Выравнивание признаков
for col in pipeline.feature_names:
if col not in X_experiment.columns:
X_experiment[col] = 0
X_experiment = X_experiment[pipeline.feature_names]
X_experiment_scaled = pipeline.scaler.transform(X_experiment)
predictions = pipeline.model.predict(X_experiment_scaled)
predictions_df = pd.DataFrame({
'user_id': features_experiment['user_id'],
'prediction': predictions
})
print(f"\nGenerated predictions for {len(predictions_df)} пользователей")
# Анализ эксперимента с CUPAC
analysis = CUPACAnalysis(predictions_df, experiment_results)
# Применение корректировки
var_reduction = analysis.compute_correction('revenue')
print(f"\nVariance Reduction: {var_reduction:.2%}")
print(f"Theta: {analysis.theta:.4f}")
# Сравнение результатов
results = analysis.run_ab_test('revenue')
print("\n" + "="*60)
print("РЕЗУЛЬТАТЫ A/B ТЕСТА")
print("="*60)
print("\nOriginal Metric:")
print(f"  Control mean: ${results['original']['control_mean']:.2f}")
print(f"  Treatment mean: ${results['original']['treatment_mean']:.2f}")
print(f"  Lift: {results['original']['lift']:.2%}")
print(f"  P-value: {results['original']['p_value']:.4f}")
print(f"  T-statistic: {results['original']['t_statistic']:.2f}")
print(f"  95% CI: [${results['original']['ci_lower']:.2f}, ${results['original']['ci_upper']:.2f}]")
print("\nCUPAC Metric:")
print(f"  Control mean: ${results['cupac']['control_mean']:.2f}")
print(f"  Treatment mean: ${results['cupac']['treatment_mean']:.2f}")
print(f"  Lift: {results['cupac']['lift']:.2%}")
print(f"  P-value: {results['cupac']['p_value']:.4f}")
print(f"  T-statistic: {results['cupac']['t_statistic']:.2f}")
print(f"  95% CI: [${results['cupac']['ci_lower']:.2f}, ${results['cupac']['ci_upper']:.2f}]")
print("\n" + "="*60)
print("IMPROVEMENT METRICS")
print("="*60)
print(f"T-statistic improved by: {results['improvement']['t_stat_ratio']:.2f}x")
print(f"CI width reduced by: {results['improvement']['ci_width_reduction']:.2%}")
# Интерпретация результатов
if results['original']['p_value'] < 0.05 and results['cupac']['p_value'] < 0.05: print("\n✓ Обе метрики показывают статистически значимый эффект") elif results['original']['p_value'] >= 0.05 and results['cupac']['p_value'] < 0.05:
print("\n✓ CUPAC обнаружил значимый эффект, пропущенный оригинальной метрикой")
elif results['original']['p_value'] < 0.05 and results['cupac']['p_value'] >= 0.05:
print("\n⚠ Несогласованность результатов - требуется дополнительная проверка")
else:
print("\n○ Обе метрики не обнаружили статистически значимого эффекта")
Historical data: 179863 записей, 5000 пользователей
Experiment data: 5000 пользователей
Control group: 2573 пользователей
Treatment group: 2427 пользователей
Training period: 2025-02-21 to 2025-04-22
Prediction period: 2025-04-23 to 2025-04-30
Experiment starts: 2025-05-01
Training period records: 89866
Target period users: 4719
Users with both training and target data: 4719
Model training data: 4719 пользователей
Model R²: 0.315
Model correlation: 0.562
Generated predictions for 5000 пользователей
Variance Reduction: 71.60%
Theta: 41.7089
============================================================
РЕЗУЛЬТАТЫ A/B ТЕСТА
============================================================
Original Metric:
Control mean: $3164.59
Treatment mean: $3430.84
Lift: 8.41%
P-value: 0.0410
T-statistic: 2.04
95% CI: [$10.96, $521.54]
CUPAC Metric:
Control mean: $3192.58
Treatment mean: $3401.17
Lift: 6.59%
P-value: 0.0026
T-statistic: 3.01
95% CI: [$72.88, $344.30]
============================================================
IMPROVEMENT METRICS
============================================================
T-statistic improved by: 1.47x
CI width reduced by: 46.84%
✓ Обе метрики показывают статистически значимый эффект

Класс CUPACAnalysis выполняет статистический анализ с корректировкой: вычисляется оптимальный коэффициент θ, применяется корректировка, а затем проводится t-test для исходной и скорректированной версии метрики. Результаты включают не только p-values, но и относительное улучшение (improvement) в t-статистике и ширине доверительных интервалов.

👉🏻  Прогнозирование трафика и конверсий сайта с помощью SVM, SVR (опорных векторов)

Sensitivity analysis проверяет устойчивость результатов к выбору θ. Если p-value сильно меняется при небольших вариациях θ, это сигнализирует о возможных проблемах с данными или моделью.

Результаты CUPAC показывают, что корректировка существенно повысила точность оценки эффекта эксперимента. Исходный lift составил 8,41% с p-value 0,041, тогда как после корректировки lift стал 6,59%, но p-value снизилось до 0,0026, а t-статистика выросла с 2,04 до 3,01. Это сопровождается уменьшением ширины доверительного интервала почти вдвое (−46,84%), при этом дисперсия метрики снизилась на 71,6%.

Вывод: модель хорошо предсказывает поведение пользователей (R²=0,315, корреляция=0,562). В целом CUPAC делает выводы более стабильными и статистически убедительными.

Оценка эффективности снижения дисперсии (variance reduction)

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

import matplotlib.pyplot as plt
import seaborn as sns
def evaluate_cupac_effectiveness(original_metric, cupac_metric, treatment_indicator):
"""
Комплексная оценка эффективности CUPAC
"""
# Variance reduction
var_original = np.var(original_metric)
var_cupac = np.var(cupac_metric)
var_reduction = (var_original - var_cupac) / var_original
# Эффект на статистическую мощность
control_mask = treatment_indicator == 0
treatment_mask = treatment_indicator == 1
# Стандартные ошибки
se_original = np.sqrt(
var_original / treatment_mask.sum() + 
var_original / control_mask.sum()
)
se_cupac = np.sqrt(
var_cupac / treatment_mask.sum() + 
var_cupac / control_mask.sum()
)
se_reduction = (se_original - se_cupac) / se_original
# Minimum detectable effect (MDE)
alpha = 0.05
beta = 0.20  # 80% power
z_alpha = stats.norm.ppf(1 - alpha/2)
z_beta = stats.norm.ppf(1 - beta)
mde_original = (z_alpha + z_beta) * se_original
mde_cupac = (z_alpha + z_beta) * se_cupac
mde_improvement = (mde_original - mde_cupac) / mde_original
# Sample size reduction
# n_new / n_old = (sigma_new / sigma_old)^2
sample_size_reduction = 1 - (np.sqrt(var_cupac) / np.sqrt(var_original))**2
results = {
'variance_reduction': var_reduction,
'se_reduction': se_reduction,
'mde_improvement': mde_improvement,
'sample_size_reduction': sample_size_reduction
}
return results
def plot_distribution_comparison(original_metric, cupac_metric, treatment_indicator):
"""
Визуализация распределений оригинальной и CUPAC метрики
"""
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
control_mask = treatment_indicator == 0
treatment_mask = treatment_indicator == 1
# Распределения для контрольной группы
axes[0, 0].hist(original_metric[control_mask], bins=50, alpha=0.7, 
color='blue', label='Control', density=True)
axes[0, 0].hist(original_metric[treatment_mask], bins=50, alpha=0.7,
color='red', label='Treatment', density=True)
axes[0, 0].set_title('Original Metric Distribution')
axes[0, 0].legend()
axes[0, 0].set_xlabel('Metric Value')
axes[0, 0].set_ylabel('Density')
axes[0, 1].hist(cupac_metric[control_mask], bins=50, alpha=0.7,
color='blue', label='Control', density=True)
axes[0, 1].hist(cupac_metric[treatment_mask], bins=50, alpha=0.7,
color='red', label='Treatment', density=True)
axes[0, 1].set_title('CUPAC Metric Distribution')
axes[0, 1].legend()
axes[0, 1].set_xlabel('Metric Value')
axes[0, 1].set_ylabel('Density')
# Q-Q plots для проверки нормальности
stats.probplot(original_metric, dist="norm", plot=axes[1, 0])
axes[1, 0].set_title('Q-Q Plot: Original Metric')
stats.probplot(cupac_metric, dist="norm", plot=axes[1, 1])
axes[1, 1].set_title('Q-Q Plot: CUPAC Metric')
plt.tight_layout()
return fig
def bootstrap_variance_reduction(original_metric, cupac_metric, n_bootstrap=1000):
"""
Bootstrap доверительный интервал для variance reduction
"""
var_reductions = []
for _ in range(n_bootstrap):
idx = np.random.choice(len(original_metric), size=len(original_metric), replace=True)
var_orig_boot = np.var(original_metric[idx])
var_cupac_boot = np.var(cupac_metric[idx])
vr = (var_orig_boot - var_cupac_boot) / var_orig_boot
var_reductions.append(vr)
var_reductions = np.array(var_reductions)
ci_lower = np.percentile(var_reductions, 2.5)
ci_upper = np.percentile(var_reductions, 97.5)
return var_reductions.mean(), ci_lower, ci_upper
# Визуализация эффективности CUPAC
print("\n" + "="*60)
print("ДОПОЛНИТЕЛЬНЫЙ АНАЛИЗ")
print("="*60)
# Оценка эффективности
effectiveness = evaluate_cupac_effectiveness(
analysis.data['revenue'].values,
analysis.data['revenue_cupac'].values,
analysis.data['treatment'].values
)
print(f"\nVariance Reduction: {effectiveness['variance_reduction']:.2%}")
print(f"Standard Error Reduction: {effectiveness['se_reduction']:.2%}")
print(f"MDE Improvement: {effectiveness['mde_improvement']:.2%}")
print(f"Sample Size Reduction: {effectiveness['sample_size_reduction']:.2%}")
# Визуализация распределений
fig1 = plot_distribution_comparison(
analysis.data['revenue'].values,
analysis.data['revenue_cupac'].values,
analysis.data['treatment'].values
)
plt.show()
# Bootstrap анализ variance reduction
vr_mean, vr_lower, vr_upper = bootstrap_variance_reduction(
analysis.data['revenue'].values,
analysis.data['revenue_cupac'].values,
n_bootstrap=1000
)
print(f"\nBootstrap Variance Reduction: {vr_mean:.2%}")
print(f"95% CI: [{vr_lower:.2%}, {vr_upper:.2%}]")
# Визуализация bootstrap результатов
fig2, ax = plt.subplots(figsize=(10, 6))
var_reductions_boot = []
for _ in range(1000):
idx = np.random.choice(len(analysis.data), size=len(analysis.data), replace=True)
var_orig = np.var(analysis.data['revenue'].values[idx])
var_cupac = np.var(analysis.data['revenue_cupac'].values[idx])
var_reductions_boot.append((var_orig - var_cupac) / var_orig)
ax.hist(var_reductions_boot, bins=50, color='steelblue', alpha=0.7, edgecolor='black')
ax.axvline(vr_mean, color='red', linestyle='--', linewidth=2, label=f'Mean: {vr_mean:.2%}')
ax.axvline(vr_lower, color='orange', linestyle='--', linewidth=1.5, label=f'2.5%: {vr_lower:.2%}')
ax.axvline(vr_upper, color='orange', linestyle='--', linewidth=1.5, label=f'97.5%: {vr_upper:.2%}')
ax.set_xlabel('Variance Reduction')
ax.set_ylabel('Frequency')
ax.set_title('Bootstrap Distribution of Variance Reduction')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
============================================================
ДОПОЛНИТЕЛЬНЫЙ АНАЛИЗ
============================================================
Variance Reduction: 71.60%
Standard Error Reduction: 46.71%
MDE Improvement: 46.71%
Sample Size Reduction: 71.60%
Bootstrap Variance Reduction: 71.54%
95% CI: [67.52%, 75.70%]

Представленный выше код выполняет дополнительный анализ эффективности CUPAC:

  1. Вычисляет метрики улучшения — уменьшение дисперсии (variance reduction), уменьшение стандартной ошибки (SE reduction), улучшение минимально обнаруживаемого эффекта (MDE improvement) и потенциальное сокращение размера выборки;
  2. Строит графики распределений исходной и скорректированной метрики для контрольной и тестовой групп, а также Q-Q графики для проверки нормальности;
  3. Проводит bootstrap-анализ дисперсии, оценивая доверительный интервал для variance reduction.
👉🏻  Информационные критерии: AIC (Akaike Information Criterion) и BIC (Bayesian Information Criterion)

Сравнение распределений оригинальной и CUPAC метрики. Верхние панели демонстрируют гистограммы распределения выручки для контрольной (синий) и тестовой (красный) групп. Левая панель показывает оригинальную метрику с высокой дисперсией и значительным перекрытием распределений между группами. Правая панель отображает CUPAC-скорректированную метрику со снижением дисперсии на 71.6% при сохранении разделения между группами. Нижние панели содержат Q-Q plots для проверки нормальности распределений. Отклонения от диагональной линии на хвостах указывают на наличие тяжелых хвостов, характерных для метрик выручки, что не влияет на валидность t-теста при больших выборках

Рис. 1: Сравнение распределений оригинальной и CUPAC метрики. Верхние панели демонстрируют гистограммы распределения выручки для контрольной (синий) и тестовой (красный) групп. Левая панель показывает оригинальную метрику с высокой дисперсией и значительным перекрытием распределений между группами. Правая панель отображает CUPAC-скорректированную метрику со снижением дисперсии на 71.6% при сохранении разделения между группами. Нижние панели содержат Q-Q plots для проверки нормальности распределений. Отклонения от диагональной линии на хвостах указывают на наличие тяжелых хвостов, характерных для метрик выручки, что не влияет на валидность t-теста при больших выборках

Бутстрап-распределение снижения дисперсии. Гистограмма демонстрирует распределение оценок снижения дисперсии, полученных методом bootstrap с 1000 итераций. Красная пунктирная линия обозначает среднее значение variance reduction (71.54%), оранжевые линии показывают границы 95% доверительного интервала [67.52%, 75.70%]. Узкий доверительный интервал (ширина 8.18%) свидетельствует о высокой стабильности оценки и робастности CUPAC корректировки. Симметричная форма распределения подтверждает отсутствие систематических смещений в методе

Рис. 2: Бутстрап-распределение снижения дисперсии. Гистограмма демонстрирует распределение оценок снижения дисперсии, полученных методом bootstrap с 1000 итераций. Красная пунктирная линия обозначает среднее значение variance reduction (71.54%), оранжевые линии показывают границы 95% доверительного интервала [67.52%, 75.70%]. Узкий доверительный интервал (ширина 8.18%) свидетельствует о высокой стабильности оценки и робастности CUPAC корректировки. Симметричная форма распределения подтверждает отсутствие систематических смещений в методе

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

Бутстрап-доверительные интервалы для variance reduction оценивают стабильность результатов: широкий интервал сигнализирует о высокой вариативности и необходимости увеличить объем тренировочных данных, либо улучшить модель.

Практические аспекты применения

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

Выбор архитектуры предиктивной модели

Модели градиентного бустинга (XGBoost, LightGBM, CatBoost) на сегодняшний день являются наиболее популярными в работе с методом CUPAC благодаря хорошему сочетанию точности, скорости обучения и интерпретируемости.

👉🏻  Автоматизация процессов анализа данных с помощью Python

Линейные модели (Ridge, Lasso) подходят для высокоразмерных данных с преимущественно линейными зависимостями: они эффективны вычислительно и устойчивы к переподгонке при правильной регуляризации, однако не улавливают нелинейные паттерны и взаимодействия признаков.

Нейронные сети оправданы для очень больших датасетов с сложными нелинейными зависимостями; многослойные архитектуры с dropout и batch normalization дают качество, сопоставимое с градиентным бустингом, но требуют больше времени на обучение и настройку гиперпараметров.

Обработка нестационарности данных

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

Стратегии борьбы с нестационарностью:

  • Регулярное переобучение: частота зависит от скорости изменений паттернов — для стабильных продуктов достаточно раз в месяц, для быстро меняющихся — еженедельно. Сигнал к переобучению — падение корреляции на holdout ниже порога;
  • Скользящее окно обучения: используется только недавний период (например, последние 60 дней), что адаптирует модель к актуальным паттернам, но сокращает объем данных;
  • Temporal features: добавление времени (день недели, месяц, праздники, время с последнего релиза) позволяет учитывать систематические временные паттерны.

Интеграция в пайплайны A/B тестирования

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

Архитектура пайплайна:

  • Offline — периодическое обучение и валидация моделей на исторических данных с сохранением в model registry;
  • Near-realtime — генерация и кеширование предсказаний для пользователей;
  • Post-experiment — применение CUPAC к завершенным экспериментам и сравнение с оригинальными метриками.

Ключевые требования:

  1. Версионирование моделей с метаданными (дата, R², признаки, гиперпараметры) для воспроизводимости и отката;
  2. Кеширование предсказаний для экономии ресурсов при длительных экспериментах;
  3. Мониторинг качества через корреляцию предсказаний и фактических значений: падение ниже порога инициирует переобучение или алерт;
  4. A/B тестирование самого CUPAC на подмножестве экспериментов для проверки консистентности и стабильности результатов.

Таким образом, предсказания лучше генерировать заранее, затем сохранять в кеш, а после завершения запускать CUPAC анализ. Для анализа результатов можно использовать дашборды с преднастроенными алертами. Оптимально реализовать CUPAC как микросервис с API для постепенной интеграции без переписывания существующего кода.

Заключение

Метод CUPAC превращает машинное обучение из вспомогательного инструмента маркетинга и продуктовой аналитики в важный элемент экспериментальной инфраструктуры:

  • Снижение дисперсии на 40-70% — это не просто улучшение метрики, а качественный сдвиг в способности компаний принимать решения;
  • Эксперименты, которые раньше требовали месяцев накопления данных, завершаются за недели;
  • Изменения, которые терялись в шуме, становятся видимыми и измеримыми.

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

Практическое внедрение CUPAC требует инвестиций в инфраструктуру: дополнительный компьют, регулярное обучение моделей, мониторинг деградации, интеграция в платформу для проведения экспериментов. Тем не менее, для компаний, проводящих десятки A/B тестов одновременно, эти затраты окупятся многократно через ускорение итераций и способность обнаруживать тонкие, но крайне важные эффекты в данных пользователей.