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

Туториал: Дизайн и оптимизация эксперимента

Этот туториал посвящён возможностям библиотеки aboba для дизайна экспериментов. Вы узнаете, как оптимизировать параметры эксперимента, рассчитывать оптимальный размер выборки и проектировать временные эксперименты для достижения максимальной статистической мощности.

Понимание дизайна эксперимента

Дизайн эксперимента в aboba помогает ответить на ключевые вопросы ещё до запуска AB-теста:

  • Сколько наблюдений мне нужно? — определить минимальный размер выборки для нужной статистической мощности
  • Какие параметры выбрать? — найти оптимальную конфигурацию эксперимента
  • Как долго должен идти эксперимент? — для экспериментов во времени подобрать подходящую длительность
  • Какая ожидаемая мощность? — оценить ошибки I и II рода

Библиотека предоставляет три класса для дизайна экспериментов:

  • BaseExperimentDesigner — максимум гибкости, с кастомными фабриками экспериментов
  • BasicExperimentDesigner — упрощённый интерфейс для типовых сценариев
  • TimeBasedDesigner — специализированный дизайнер для экспериментов с временными рядами

Пример 1: Кастомный дизайн эксперимента с BaseExperimentDesigner

Класс BaseExperimentDesigner даёт полный контроль над процессом дизайна эксперимента. Вы определяете фабричный класс, который создаёт эксперименты с разными параметрами.

Как устроен workflow

Дизайнер работает следующим образом:

  1. Создаёт эксперименты для различных комбинаций параметров.
  2. Запускает AA-тесты для измерения ошибки I рода (false positive rate, вероятность обнаружить эффект, когда его нет ).
  3. Запускает AB-тесты с синтетическим эффектом для измерения ошибки II рода (false negative rate, вероятность пропустить реальный эффект, т.е. 1− мощность).
  4. Находит оптимальные параметры, обеспечивающие баланс между этими ошибками.

Шаг 1: Создание фабрики экспериментов

import numpy as np
import pandas as pd
import scipy.stats as sps
import typing as tp
from aboba.design.base_designer import (
    ExperimentDesignMetrics,
    IntervalEstimate,
    BaseExperimentDesigner
)
import aboba


class ExperimentSetup:
    """Фабрика, создающая эксперименты с разным размером выборки."""

    def generate_data(self, n_samples: int) -> pd.DataFrame:
        """Сгенерировать синтетические данные из нормального распределения."""
        data_a = sps.norm.rvs(size=n_samples, loc=0, scale=1)
        data_b = sps.norm.rvs(size=n_samples, loc=0, scale=1)

        data = pd.DataFrame({
            'value': np.concatenate([data_a, data_b]),
            'b_group': np.concatenate([
                np.repeat(0, n_samples),
                np.repeat(1, n_samples),
            ]),
        })
        return data

    def generate_test(self) -> aboba.base.base_test.BaseTest:
        """Создать статистический тест."""
        return aboba.tests.AbsoluteIndependentTTest(
            value_column='value',
        )

    def generate_pipeline(self, n_samples: int) -> aboba.pipeline.Pipeline:
        """Создать пайплайн сэмплирования на основе размера выборки."""
        # Выборка 10% данных, минимум 2 наблюдения на группу
        group_size = max(n_samples // 10, 2)
        return aboba.pipeline.Pipeline([
            ("GroupSplitter", aboba.splitters.GroupSplitter(
                size=group_size,
                column='b_group'
            )),
        ])

    def __call__(self, parameters: tp.Dict[str, tp.Any]) -> ExperimentDesignMetrics:
        """
        Запустить эксперимент с заданными параметрами и вернуть метрики.

        Метод вызывается дизайнером для каждой комбинации параметров.
        """
        assert sorted(parameters.keys()) == ["n_samples"]
        n_samples = parameters["n_samples"]

        # Генерация компонентов эксперимента
        data = self.generate_data(n_samples)
        pipeline = self.generate_pipeline(n_samples)
        test = self.generate_test()

        # Создание эксперимента
        experiment = aboba.experiment.AbobaExperiment()

        # Запуск AA-теста для оценки ошибки I рода 
        aa_group = experiment.group(
            "AA",
            test=test,
            data=data,
            data_pipeline=pipeline,
            n_iter=1000,
            joblib_kwargs={"n_jobs": -1, "backend": "threading"}
        ).run()

        alpha_level = 0.05

        # Расчёт ошибки I рода: доля p-value ниже alpha
        type_I_error = (aa_group.get_data()["pvalue"] < alpha_level).mean()

        # Запуск AB-теста с синтетическим эффектом для оценки мощности
        ab_group = experiment.group(
            "AB",
            test=test,
            data=data,
            data_pipeline=pipeline,
            synthetic_effect=aboba.effect_modifiers.GroupModifier(
                effects={1: 0.3},
                value_column='value',
                group_column='b_group',
            ),
            n_iter=1000,
        ).run()

        # Расчёт мощности: доля обнаруженных эффектов
        type_II_error = (ab_group.get_data()["pvalue"] < alpha_level).mean()

        return ExperimentDesignMetrics(
            type_I_error=IntervalEstimate(
                parameter_estimate=type_I_error
            ),
            type_II_error=IntervalEstimate(
                parameter_estimate=type_II_error
            ),
        )

Шаг 2: Создание и запуск дизайнера

Теперь используем дизайнер для подбора оптимального размера выборки:

# Создаём дизайнер с ограничениями на размер выборки
designer = BaseExperimentDesigner(
    experiment_design_factory=ExperimentSetup(),
    constraints={
        # Проверяем 20 разных размеров выборки от 10 до 100 000
        "n_samples": np.array(np.logspace(1, 5, 20), dtype=np.int32)
    }
)

# Подбираем оптимальные параметры перебором (brute force)
designer.optimize(method=BaseExperimentDesigner.OptimizerMethod.BRUTE_FORCE)

# Визуализируем результаты
designer.visualize()
Визуализация покажет:

  • ошибку I рода в зависимости от размера выборки (должна быть около 0.05)

  • статистическую мощность (1− ошибка II рода)

  • оптимальный размер выборки, который обеспечивает требуемую мощность при одновременном контроле ошибки I рода

Пример 2: Упрощённый дизайн с BasicExperimentDesigner

Для большинства сценариев достаточно BasicExperimentDesigner который не требует определения фабричного класса.

Шаг 1: Подготовка данных и теста

import numpy as np
import pandas as pd
import scipy.stats as sps
from aboba.design.basic_designer import BasicExperimentDesigner
from aboba.tests import AbsoluteIndependentTTest
from aboba.splitters import GroupSplitter
from aboba.pipeline import Pipeline
from aboba.effect_modifiers import GroupModifier


# Генерация примера данных
n_samples = 1000
data_a = sps.norm.rvs(size=n_samples, loc=0, scale=1)
data_b = sps.norm.rvs(size=n_samples, loc=0, scale=1)

data = pd.DataFrame({
    'value': np.concatenate([data_a, data_b]),
    'b_group': np.concatenate([
        np.repeat(0, n_samples),
        np.repeat(1, n_samples),
    ]),
})

Шаг 2: Определение теста и эффекта

# Задаём статистический тест
test = AbsoluteIndependentTTest(value_column='value')

# Задаём синтетический эффект, который будем проверять
synthetic_effect = GroupModifier(
    effects={1: 0.3},  # Прибавить 0.3 к группе 1
    value_column='value',
    group_column='b_group',
)

Шаг 3: Создание дизайнера с ограничениями на параметры

Ключевая особенность — функция get_pipeline, которая создаёт пайплайны на основе переданных параметров:

# Создаём дизайнер с ограничениями по параметрам
designer = BasicExperimentDesigner(
    data=data,
    test=test,
    get_pipeline=lambda params: Pipeline([
        ('GroupSplitter', GroupSplitter(
            size=params['group_size'],  # Используем значение параметра
            column='b_group'
        )),
    ]),
    synthetic_effect=synthetic_effect,
    n_iter=1000,  # Число итераций для каждой комбинации параметров
    constraints={
        "group_size": [50, 100, 200, 500],  # Проверяем эти размеры групп
    }
)

Шаг 4: Оптимизация и анализ

# Подбираем оптимальные параметры
designer.optimize()

# Получаем лучшие параметры
best_params = designer.get_best_params()
print(f"Лучший размер группы: {best_params.parameters['group_size']}")

# Визуализируем результаты
designer.visualize()
Дизайнер будет:

  • Тестировать каждый размер группы (50, 100, 200, 500)

  • Выполнять 1000 итераций для каждого размера, чтобы оценить уровни ошибок

  • Находить размер группы, который обеспечивает лучший баланс между статистической мощностью и контролем ошибки I рода

  • Отображать визуализации, показывающие качество работы по всем протестированным параметрам

Пример 3: Проектирование временного эксперимента

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

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

Временные эксперименты имеют свои особенности:

  • Дата включения: момент, когда пользователи попадают в эксперимент
  • Дата начала эффекта: момент, когда начинает проявляться эффект
  • Длительность эксперимента: сколько времени проводится эксперимент
  • Эффекты, меняющиеся во времени: эффекты, величина которых изменяется со временем

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

import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from aboba.design.time_based_designer import TimeBasedDesigner
from aboba.tests import AbsoluteIndependentTTest
from aboba.splitters import UserSplitter
from aboba.pipeline import Pipeline
from aboba.effect_modifiers import TimeBasedEffectModifier
from aboba.utils.time_based_data_generator import generate_time_based_data

# Генерируем синтетический временной ряд
# Данные на уровне пользователей: даты и платежи
data = generate_time_based_data(
    n_users=200,
    start_date='2024-01-01',
    end_date='2024-12-31'
)

Сгенерированные данные включают:

  • user_id: уникальный идентификатор пользователя

  • date: дата каждого наблюдения

  • payment: сумма платежа (целевая метрика)

  • is_in_b_group: принадлежность к группе

  • inclusion_date: момент, когда пользователь вошёл в эксперимент

Шаг 2: Определение теста и фабрики пайплайна

# Задаём статистический тест
test = AbsoluteIndependentTTest(value_column='payment')

# Собираем пайплайн по параметрам
get_pipeline = lambda params: Pipeline([
    ('UserSplitter', UserSplitter(
        group_column='is_in_b_group',
        user_column='user_id',
        size=params.get('user_sample_size', 50)  #Используем параметр или значение по умолчанию
    ))
])

Шаг 3: Определение фабрики временного эффекта

Фабрика эффектов создаёт эффекты на основе временных параметров:

get_effect = lambda params: TimeBasedEffectModifier(
    effect=5.0 * params.get('effect_multiplier', 1.0),  # Масштабируем эффект
    effect_start_date=pd.to_datetime(params['effect_start_date']),
    value_column='payment',
    date_column='date',
    group_column='is_in_b_group',
    inclusion_date_column='inclusion_date'
)
Это создаёт эффекты, которые:

  • применяются только после effect_start_date

  • затрагивают только пользователей, которые были включены до начала эффекта

  • масштабируются параметром effect_multiplier

Шаг 4: Создание дизайнера с временными ограничениями

# Создаём дизайнер с ограничениями по параметрам
designer = TimeBasedDesigner(
    data=data,
    test=test,
    get_pipeline=get_pipeline,
    get_effect=get_effect,
    date_column='date',
    experiment_duration='experiment_duration',  # имя параметра
    effect_start_date='effect_start_date',  # имя параметра
    n_iter=100,
    constraints={
        'effect_start_date': [
            '2024-03-01',
            '2024-04-01',
            '2024-05-01'
        ],
        'effect_multiplier': [0.8, 1.0, 1.2, 1.5],
        'experiment_duration': [
            pd.Timedelta(weeks=4),
            pd.Timedelta(weeks=6),
            pd.Timedelta(weeks=8)
        ]
    }
)

Шаг 5: Оптимизация и визуализация

# Подбираем оптимальные параметры
designer.optimize()

# Получаем лучшие параметры
best_params = designer.get_best_params()
print(f"Лучшая дата начала эффекта: {best_params.parameters['effect_start_date']}")
print(f"Лучший множитель эффекта: {best_params.parameters['effect_multiplier']}")
print(f"Лучшая длительность эксперимента: {best_params.parameters['experiment_duration']}")

# Визуализируем все результаты
designer.visualize()

Расширенная визуализация: фиксированные параметры

Можно визуализировать результаты, зафиксировав некоторые параметры:

# Визуализация при фиксированных длительности эксперимента и дате начала эффекта
designer.visualize(
    fixed_parameters={
        'experiment_duration': pd.Timedelta(weeks=4),
        'effect_start_date': '2024-03-01',
    }
)

Это показывает, как оставшиеся параметры (например, effect_multiplier) влияют на качество результатов, когда остальные параметры зафиксированы.

Ключевые понятия в дизайне экспериментов

Ошибка I рода (False Positive Rate)

  • Определение: вероятность обнаружить эффект, когда его на самом деле нет
  • Цель: должна быть близка к уровню значимости (обычно 0.05)
  • Оценивается с помощью: проведения AA-тестов (обе группы получены из одного распределения)

Ошибка II рода и статистическая мощность

  • Ошибка II рода: вероятность не обнаружить реальный эффект
  • Статистическая мощность: 1 − ошибка II рода (вероятность обнаружить реальный эффект)
  • Цель: обычно стремятся к мощности 80% (ошибка II рода = 0.20)
  • Оценивается с помощью: проведения AB-тестов с заранее заданными синтетическими эффектами

Стратегия оптимизации

Дизайнеры подбирают параметры, которые:

  1. поддерживают ошибку I рода на уровне, близком к уровню значимости (без завышения)

  2. максимизируют статистическую мощность (минимизируют ошибку II рода)

  3. учитывают практические ограничения (размер выборки, длительность, стоимость)