← Назад к вопросам

Какие знаешь способы ускорения A/B-тестов?

1.7 Middle🔥 191 комментариев
#Статистика и A/B тестирование

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI29 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Способы ускорения A/B-тестов: Практические методы

Время — деньги. В стартапах и крупных компаниях я постоянно сталкивался с давлением: сделать выводы быстрее, не теряя статистическую достоверность. Существует несколько проверенных подходов для ускорения A/B-тестов, которыми я активно пользуюсь.

1. Sequential Testing (Последовательное тестирование)

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

import numpy as np
from scipy import stats
import matplotlib.pyplot as plt

def sequential_ab_test(control_data, test_data, alpha=0.05, beta=0.2, expected_effect=0.1):
    """
    Sequential test с использованием метода Вальда (SPRT - Sequential Probability Ratio Test)
    """
    control_conversions = np.sum(control_data)
    test_conversions = np.sum(test_data)
    
    # Базовая конверсия
    p0 = control_conversions / len(control_data)
    
    # Ожидаемая конверсия в тестовой группе
    p1 = p0 * (1 + expected_effect)
    
    # Границы Вальда
    A = (1 - beta) / alpha
    B = beta / (1 - alpha)
    a = np.log(A) / np.log(p1 * (1 - p0) / (p0 * (1 - p1)))
    b = np.log(B) / np.log(p1 * (1 - p0) / (p0 * (1 - p1)))
    
    print(f"\n===== SEQUENTIAL TESTING =====")
    print(f"Контрольная группа: {control_conversions}/{len(control_data)} = {p0:.2%}")
    print(f"Тестовая группа: {test_conversions}/{len(test_data)} = {test_conversions/len(test_data):.2%}")
    print(f"\nГраницы принятия решения:")
    print(f"Граница A (Continue): {a:.2f}")
    print(f"Граница B (Accept H1): {b:.2f}")
    
    # Отношение вероятностей
    lr = (p1 * (1 - p0) / (p0 * (1 - p1))) ** (test_conversions - control_conversions) * \
         ((1 - p1) / (1 - p0)) ** (len(test_data) - test_conversions - len(control_data) + control_conversions)
    
    log_lr = np.log(lr)
    
    print(f"\nТекущее log-отношение вероятностей: {log_lr:.2f}")
    
    if log_lr >= np.log(A):
        return "STOP: Тестовая группа лучше (H1 отклонена)"
    elif log_lr <= np.log(B):
        return "STOP: Нет значимого различия (H0 отклонена)"
    else:
        return "CONTINUE: Собирайте больше данных"

# Пример использования
control = np.random.binomial(1, 0.10, 2000)
test = np.random.binomial(1, 0.115, 2000)  # На 15% лучше

result = sequential_ab_test(control, test, expected_effect=0.15)
print(f"\nРезультат: {result}")

2. Power Analysis и минимальный размер выборки

from statsmodels.stats.power import proportions_ztest

def calculate_sample_size(baseline_conversion, min_effect, alpha=0.05, power=0.80):
    """
    Вычисляет минимальный размер выборки для достижения нужной мощности
    """
    # Минимальная конверсия в тестовой группе
    test_conversion = baseline_conversion * (1 + min_effect)
    
    # Используем встроенную функцию
    effect_size = abs(test_conversion - baseline_conversion) / \
                  np.sqrt(baseline_conversion * (1 - baseline_conversion))
    
    # Примерный расчет (более точный через statsmodels)
    from statsmodels.stats.power import tt_ind_solve_power
    
    # Для пропорций используем z-тест
    z_alpha = stats.norm.ppf(1 - alpha/2)
    z_beta = stats.norm.ppf(power)
    
    n = 2 * ((z_alpha + z_beta) / effect_size) ** 2
    
    return int(np.ceil(n))

# Пример расчета
baseline = 0.05  # 5% базовая конверсия
min_effect = 0.20  # Хотим обнаружить 20% улучшение

n_required = calculate_sample_size(baseline, min_effect, power=0.80)
print(f"\nТребуемый размер выборки (per group): {n_required:,}")
print(f"Общее время при 100 users/day: {n_required * 2 / 100:.1f} дней")

3. Bayesian A/B Testing (Байесовский подход)

from scipy.stats import beta as beta_dist

def bayesian_ab_test(control_conversions, control_samples, 
                     test_conversions, test_samples,
                     prior_alpha=1, prior_beta=1):
    """
    Байесовский A/B тест с Beta-Bernoulli моделью
    Позволяет получить вероятность того, что одна группа лучше другой
    """
    # Апостериорные параметры (Beta распределение)
    alpha_control = control_conversions + prior_alpha
    beta_control = control_samples - control_conversions + prior_beta
    
    alpha_test = test_conversions + prior_alpha
    beta_test = test_samples - test_conversions + prior_beta
    
    # Симуляция из апостериорного распределения
    n_simulations = 100000
    samples_control = np.random.beta(alpha_control, beta_control, n_simulations)
    samples_test = np.random.beta(alpha_test, beta_test, n_simulations)
    
    # Вероятность того, что тест лучше контроля
    prob_test_better = np.mean(samples_test > samples_control)
    
    # Ожидаемое улучшение
    expected_lift = np.mean(samples_test - samples_control)
    
    print(f"\n===== BAYESIAN A/B TEST =====")
    print(f"Контрольная группа: {control_conversions}/{control_samples} = {control_conversions/control_samples:.2%}")
    print(f"Тестовая группа: {test_conversions}/{test_samples} = {test_conversions/test_samples:.2%}")
    print(f"\nВероятность того, что тест лучше контроля: {prob_test_better:.1%}")
    print(f"Ожидаемое улучшение (lift): {expected_lift:.2%}")
    print(f"\nDOMAIN:")
    
    # 95% credible interval для lift
    percentile_025 = np.percentile(samples_test - samples_control, 2.5)
    percentile_975 = np.percentile(samples_test - samples_control, 97.5)
    print(f"95% Credible Interval для lift: [{percentile_025:.2%}, {percentile_975:.2%}]")
    
    # Визуализация
    fig, axes = plt.subplots(1, 2, figsize=(14, 5))
    
    # Распределения конверсий
    x = np.linspace(0, max(samples_control.max(), samples_test.max()), 100)
    axes[0].hist(samples_control, bins=50, alpha=0.5, density=True, label='Контроль')
    axes[0].hist(samples_test, bins=50, alpha=0.5, density=True, label='Тест')
    axes[0].set_xlabel('Конверсия')
    axes[0].set_ylabel('Плотность')
    axes[0].set_title('Апостериорные распределения конверсий')
    axes[0].legend()
    axes[0].grid(True, alpha=0.3)
    
    # Распределение разницы
    lift_dist = samples_test - samples_control
    axes[1].hist(lift_dist, bins=50, alpha=0.7, edgecolor='black')
    axes[1].axvline(0, color='r', linestyle='--', linewidth=2, label='Нет разницы')
    axes[1].axvline(expected_lift, color='g', linestyle='--', linewidth=2, label='Ожидаемый lift')
    axes[1].set_xlabel('Lift')
    axes[1].set_ylabel('Частота')
    axes[1].set_title('Распределение lift (Тест - Контроль)')
    axes[1].legend()
    axes[1].grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()
    
    return prob_test_better, expected_lift

# Пример использования
prob_better, lift = bayesian_ab_test(200, 2000, 250, 2000)
print(f"\nВывод: {'Тест лучше' if prob_better > 0.95 else 'Результаты неубедительны'}")

4. Stratified Sampling (Стратифицированная выборка)

def stratified_ab_test(data, stratify_column, treatment_column, outcome_column):
    """
    Стратифицированный A/B тест уменьшает дисперсию
    и позволяет получить результаты быстрее
    """
    results = {}
    overall_effect = 0
    
    print(f"\n===== STRATIFIED A/B TEST =====")
    
    strata = data[stratify_column].unique()
    
    for stratum in strata:
        stratum_data = data[data[stratify_column] == stratum]
        
        control = stratum_data[stratum_data[treatment_column] == 0][outcome_column].mean()
        test = stratum_data[stratum_data[treatment_column] == 1][outcome_column].mean()
        
        effect = test - control
        weight = len(stratum_data) / len(data)
        
        results[stratum] = {'control': control, 'test': test, 'effect': effect, 'weight': weight}
        overall_effect += effect * weight
        
        print(f"\nСтрата '{stratum}':")
        print(f"  Контроль: {control:.4f}")
        print(f"  Тест: {test:.4f}")
        print(f"  Эффект: {effect:.4f} (вес: {weight:.1%})")
    
    print(f"\nОбщий эффект (weighted): {overall_effect:.4f}")
    return results, overall_effect

# Пример
data = pd.DataFrame({
    'device': np.repeat(['mobile', 'desktop'], 2000),
    'treatment': np.tile(np.repeat([0, 1], 1000), 2),
    'conversion': np.random.binomial(1, np.repeat([0.05, 0.06], 4000))
})

results, overall_effect = stratified_ab_test(data, 'device', 'treatment', 'conversion')

5. Использование variance reduction techniques (CUPED)

def cuped_analysis(pre_experiment, post_experiment, control, test_group):
    """
    CUPED (Controlled experiment Using Pre-Experiment Data)
    Использует исторические данные для уменьшения дисперсии
    """
    # Вычисляем ковариацию между pre и post
    covariate = np.cov(pre_experiment, post_experiment)[0, 1]
    var_pre = np.var(pre_experiment)
    
    # CUPED коэффициент
    theta = covariate / var_pre if var_pre > 0 else 0
    
    # Скорректированные значения
    adjusted_post = post_experiment - theta * (pre_experiment - pre_experiment.mean())
    
    # Вычисляем эффект на скорректированных данных
    control_adjusted = adjusted_post[~test_group].mean()
    test_adjusted = adjusted_post[test_group].mean()
    
    effect_adjusted = test_adjusted - control_adjusted
    variance_adjusted = np.var(adjusted_post[~test_group]) + np.var(adjusted_post[test_group])
    
    # Сравниваем с обычным анализом
    control_unadjusted = post_experiment[~test_group].mean()
    test_unadjusted = post_experiment[test_group].mean()
    effect_unadjusted = test_unadjusted - control_unadjusted
    variance_unadjusted = np.var(post_experiment[~test_group]) + np.var(post_experiment[test_group])
    
    print(f"\n===== CUPED ANALYSIS =====")
    print(f"Без CUPED:")
    print(f"  Эффект: {effect_unadjusted:.4f}")
    print(f"  Стандартная ошибка: {np.sqrt(variance_unadjusted / len(post_experiment)):.4f}")
    print(f"\nС CUPED:")
    print(f"  Эффект: {effect_adjusted:.4f}")
    print(f"  Стандартная ошибка: {np.sqrt(variance_adjusted / len(post_experiment)):.4f}")
    print(f"\nУменьшение дисперсии: {(1 - variance_adjusted/variance_unadjusted)*100:.1f}%")
    print(f"Ускорение теста: {variance_unadjusted/variance_adjusted:.1f}x")
    
    return effect_adjusted, variance_adjusted

# Пример использования
n = 5000
pre_data = np.random.randn(n)
post_data = pre_data * 0.7 + np.random.randn(n) * 0.3 + np.repeat([0, 0.05], n // 2)  # Лечение
test_group = np.repeat([False, True], n // 2)

cuped_analysis(pre_data, post_data, ~test_group, test_group)

6. Практические рекомендации по ускорению

Таблица сравнения подходов:

МетодУскорениеСложностьПрименимость
Sequential Testing30-50%СредняяВысокая (если мало конвесрий)
Bayesian20-40%СредняяЧасто (более гибкий)
Stratification15-30%СредняяПри выраженной гетерогенности
CUPED25-50%ВысокаяПри наличии исторических данных
Power AnalysisПодготовкаНизкаяВсегда

Мой подход:

  1. Всегда начните с Power Analysis — определите реалистичный размер выборки
  2. Используйте Sequential Testing для быстрого отклонения очевидно неработающих гипотез
  3. Для долгосрочных тестов применяйте Bayesian подход и CUPED
  4. Стратифицируйте по ключевым демографическим признакам

Ловушки:

  • Не используйте Peeking (промежуточные проверки) в частотистском подходе — увеличивает риск Type I ошибки
  • Байесовский подход более tolerant, но требует осмысленные prior-ы
  • CUPED требует стабильной связи между pre и post