Перейти к содержанию

Туториал: Запуск A/B-тестов с помощью aboba

Этот туториал знакомит с ключевыми понятиями библиотеки aboba на практических примерах. Вы научитесь настраивать эксперименты, запускать тесты и анализировать результаты.

Ключевые понятия

Перед тем как перейти к примерам, разберём основные компоненты:

  • Тест (Test): статистический тест, который используется для проверки гипотез (t-тест, HSD и т.д.)
  • Пайплайн (Pipeline): последовательность обработчиков и сэмплеров, подготавливающих данные
  • Сэмплер (Splitter): определяет, как данные разбиваются на группы
  • Обработчик (Processor): преобразует данные (например, CUPED, бакетизация)
  • Модификатор эффектов (Effect Modifier): добавляет синтетические эффекты для анализа мощности
  • Эксперимент (Experiment): управляет сериями запусков тестов и визуализацией результатов

Пример 1: Базовый эксперимент на синтетических данных

Начнём с простого примера на синтетических данных, чтобы понять общий рабочий процесс.

Шаг 1: Генерация данных

Сначала создадим две группы из одного и того же распределения N(0, 1):

import numpy as np
import pandas as pd
import scipy.stats as sps
from aboba import tests, splitters, effect_modifiers, experiment, pipeline

# Генерация синтетических данных
n = 1000
data_a = sps.norm.rvs(size=n, loc=0, scale=1)
data_b = sps.norm.rvs(size=n, loc=0, scale=1)

# Собираем DataFrame с двумя группами
data = pd.DataFrame({
    'value': np.concatenate([data_a, data_b]),
    'b_group': np.concatenate([
        np.repeat(0, 1000),
        np.repeat(1, 1000)
    ])
})

Шаг 2: Создание пайплайна

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

group_size = 100
data_pipeline = pipeline.Pipeline([
    (splitters.GroupSplitter(size=group_size, column='b_group'), 'GroupSplitter'),
])

Шаг 3: Задание теста

Используем абсолютный независимый t-тест:

test = tests.AbsoluteIndependentTTest(
    value_column='value',
)

Шаг 4: Создание эксперимента

Эксперимент управляет несколькими группами тестов и визуализацией результатов:

exp = experiment.AbobaExperiment()

Шаг 5: Запуск AA-теста (проверка корректности)

Сначала запустим AA-тест, чтобы убедиться, что тест работает корректно (обе группы из одного распределения):

aa_group = exp.group(
    "AA Test",
    test=test,
    data=data,
    data_pipeline=data_pipeline,
    n_iter=100,
    joblib_kwargs={"n_jobs": -1, "backend": "threading"}
).run()

AA-тест должен показать равномерное распределение значений p-value от 0 до 1, что подтверждает отсутствие ложных срабатываний.

Шаг 6: Запуск AB-теста с синтетическим эффектом

Теперь добавим синтетический эффект в группу 1 и запустим тест:

ab_group = exp.group(
    "AB Test (effect=0.3)",
    test=test,
    data=data,
    data_pipeline=data_pipeline,
    synthetic_effect=effect_modifiers.GroupModifier(
        effects={1: 0.3},  # Прибавить 0.3 к группе 1
        value_column='value',
        group_column='b_group',
    ),
    n_iter=100,
).run()

Шаг 7: Визуализация результатов

exp.draw()

Будут показаны распределения значений p-value для AA- и AB-тестов.
Для AB-теста большинство значений p-value должно быть близко к 0, что указывает на успешное обнаружение эффекта.

Получение результатов

Детальные результаты каждой группы доступны напрямую:

results_df = ab_group.get_data()
print(results_df.head())

Пример 2: Реальные данные и CUPED

Теперь рассмотрим работу с реальными данными и применение CUPED (Controlled-experiment Using Pre-Experiment Data) для снижения дисперсии.

Что такое CUPED

CUPED — это метод снижения дисперсии, который использует данные до эксперимента (ковариаты) для повышения чувствительности теста. Целевая метрика корректируется с учётом коррелированной ковариаты.

Шаг 1: Загрузка реальных данных

# Загружаем датасет с квартирами Москвы
data = pd.read_csv('data/flats_moscow.txt', sep='\t')

Шаг 2: Создание собственного обработчика данных

Так как CUPED требует доступ ко всему датасету до сэмплирования, создадим обработчик для формирования групп:

import aboba

class RandomGroupAssigner(aboba.base.BaseDataProcessor):
    def __init__(self, groups_n=2, column_name='group'):
        self.groups_n = groups_n
        self.column_name = column_name

    def transform(self, data: pd.DataFrame):
        n = data.shape[0]
        groups = np.random.randint(0, self.groups_n, size=n)
        data[self.column_name] = groups
        return data, None

Шаг 3: Построение CUPED-пайплайна

sample_size = 100
covariate = 'totsp'  # Общая площадь как ковариата  

cuped_pipeline = pipeline.Pipeline([
    RandomGroupAssigner(groups_n=2),
    aboba.processing.EnsureColsProcessor(['price', 'group', covariate]),
    aboba.processing.CupedProcessor(
        value_column='price',
        covariate_columns=covariate,
        result_column='price_cuped',
        group_column='group',
        group_test=1,
        group_control=0,
    ),
    splitters.GroupSplitter(column='group', size=sample_size),
    aboba.processing.EnsureColsProcessor(['price_cuped']),
])

Шаг 4: Запуск тестов с CUPED

exp = experiment.AbobaExperiment()

cuped_test = tests.AbsoluteIndependentTTest(
    value_column='price_cuped',
)

# AA-тест с применением CUPED
exp.group(
    "AA, CUPED",
    test=cuped_test,
    data=data,
    data_pipeline=cuped_pipeline,
    n_iter=100,
).run()

# AB-тест с CUPED
exp.group(
    "AB, CUPED (effect=10)",
    test=cuped_test,
    data=data,
    data_pipeline=cuped_pipeline,
    synthetic_effect=effect_modifiers.GroupModifier(
        effects={1: 10},
        value_column='price_cuped',
        group_column='group',
    ),
    n_iter=100,
).run()

exp.draw()

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

Пример 3: Создание собственных тестов

Вы можете реализовать собственный тест, унаследовавшись от BaseTest. Ниже приведён пример относительного t-теста:

class RelativeIndependentTTest(aboba.base.BaseTest):
    def __init__(self, value_column="target", alternative="two-sided"):
        super().__init__()
        self.value_column = value_column
        self.alternative = alternative
        assert alternative in {"two-sided", "less", "greater"}

    def test(self, groups, artefacts):
        control_group, test_group = groups

        Y, X = control_group[self.value_column], test_group[self.value_column]
        var_1, var_2 = np.var(X, ddof=1), np.var(Y, ddof=1)
        a_1, a_2 = np.mean(X), np.mean(Y)

        # Рассчитываем относительную разницу
        R = (a_1 - a_2) / a_2
        var_R = var_1 / (a_2**2) + (a_1**2) / (a_2**4) * var_2

        n = len(test_group)
        stat = np.sqrt(n) * R / np.sqrt(var_R)

        if self.alternative == "two-sided":
            pvalue = 2 * min(sps.norm.cdf(stat), sps.norm.sf(stat))
            pvalue = min(pvalue, 1)
        elif self.alternative == "less":
            pvalue = sps.norm.cdf(stat)
        elif self.alternative == "greater":
            pvalue = sps.norm.sf(stat)

        return aboba.base.TestResult(
            pvalue=pvalue, 
            effect=R, 
            effect_type="relative_control"
        )

Использование собственного теста

relative_test = RelativeIndependentTTest(value_column='price')

exp.group(
    "AB, Relative Test",
    test=relative_test,
    data=data,
    data_pipeline=random_pipeline,
    synthetic_effect=effect_modifiers.GroupModifier(
        effects={1: 10},
        value_column='price',
        group_column='group',
    ),
    n_iter=100,
).run()

exp.draw()

Продвинутый уровень: Гибкие модификаторы эффектов

Модификаторы эффектов поддерживают несколько способов задания эффектов:

1. Эффект, заданный константой

effect_modifiers.GroupModifier(
    effects={1: 0.3},  # Добавить константу 0.3 к группе 1
    value_column='value',
    group_column='b_group',
)

2. Эффект, заданный функцией

def my_effect(obj):
    obj['value'] += 0.3
    return obj

effect_modifiers.GroupModifier(
    effects={0: my_effect},
    value_column='value',
    group_column='b_group',
)

3. Эффект, заданный распределением

effect_modifiers.GroupModifier(
    effects={
        0: 0.9,
        1: sps.norm(0.3, 0.001)  # Случайный эффект из нормального распределения
    },
    value_column='value',
    group_column='b_group',
)

Следующие шаги