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

Какие знаешь способы борьбы с высокой дисперсией?

3.0 Senior🔥 141 комментариев
#A/B тестирование#Статистика и математика

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

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

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

Способы борьбы с высокой дисперсией в экспериментах

Высокая дисперсия (variance) метрики — это когда значения сильно варьируются и шумят, что делает тесты долгими и нерезультативными. Например, если daily revenue варьируется от $1M до $5M, сложно обнаружить treatment effect. Расскажу о способах снижения дисперсии.

1. Стратификация (Stratification)

Идея: Разделить users на слои (strata) по признакам, которые сильно влияют на метрику. Внутри каждого слоя провести отдельный тест, потом объединить результаты.

Пример:

def stratified_ab_test():
    """
    Problem: Revenue сильно варьируется, т.к. есть power users (спонсоры) 
    которые платят в 100x больше обычного пользователя.
    
    Solution: стратифицировать по user segment
    """
    
    # Segmentation
    df['segment'] = pd.cut(df['lifetime_value'], 
                           bins=[0, 100, 500, float('inf')],
                           labels=['Low', 'Medium', 'High'])
    
    results = []
    
    # Провести A/B тест отдельно для каждого segment
    for segment in ['Low', 'Medium', 'High']:
        segment_data = df[df['segment'] == segment]
        control = segment_data[segment_data['variant'] == 'control']['revenue']
        variant = segment_data[segment_data['variant'] == 'variant']['revenue']
        
        # Тест
        t_stat, p_value = stats.ttest_ind(control, variant)
        effect = variant.mean() - control.mean()
        
        results.append({
            'segment': segment,
            'effect': effect,
            'p_value': p_value,
            'sample_size': len(segment_data)
        })
    
    # Объединить результаты (weighted average)
    results_df = pd.DataFrame(results)
    overall_effect = (results_df['effect'] * results_df['sample_size']).sum() / results_df['sample_size'].sum()
    
    print("Results by segment:")
    print(results_df)
    print(f"\nOverall Effect: ${overall_effect:.2f}")
    
    return results_df

# Преимущество: 
# Дисперсия внутри segment намного меньше
# -> нужно меньше users для обнаружения effect

Когда использовать:

  • Когда metric зависит от характеристики user (LTV, segment, region)
  • Когда есть power users или outliers

Преимущества:

  • Снижает variance
  • Даёт insights по segments

Недостатки:

  • Нужно больше данных (test каждый segment отдельно)

2. CUPED (Controlled Experiment Using Pre-Experiment Data)

Идея: Использовать исторические данные пользователя (до эксперимента) как контроль. Рассчитать difference for each user: (post - pre), это имеет намного меньше variance.

Как работает:

def cuped_analysis():
    """
    CUPED: использовать pre-experiment метрики для variance reduction
    
    Математика: Var(Y - alpha*X) минимизируется когда alpha = Cov(Y,X)/Var(X)
    где Y = post-experiment метрика, X = pre-experiment метрика
    """
    
    # Данные: user_id, pre_metric (неделю до), post_metric (during experiment)
    data = {
        'user_id': range(1000),
        'pre_revenue': np.random.exponential(scale=10, size=1000),  # Power-law distribution
        'variant': np.random.choice(['control', 'treatment'], size=1000)
    }
    df = pd.DataFrame(data)
    
    # Assign treatment effect: +$2 для treatment group
    df['post_revenue'] = df['pre_revenue'] + np.random.normal(0, 2, 1000) 
    df.loc[df['variant'] == 'treatment', 'post_revenue'] += 2
    
    # Шаг 1: Стандартный анализ (без CUPED)
    control_post = df[df['variant'] == 'control']['post_revenue']
    treatment_post = df[df['variant'] == 'treatment']['post_revenue']
    
    naive_effect = treatment_post.mean() - control_post.mean()
    naive_std_error = np.sqrt(control_post.var() + treatment_post.var()) / np.sqrt(len(df) / 2)
    
    print(f"Naive effect: ${naive_effect:.3f} +/- ${naive_std_error:.3f}")
    print(f"Confidence interval: ${naive_effect - 1.96*naive_std_error:.3f} to ${naive_effect + 1.96*naive_std_error:.3f}")
    
    # Шаг 2: CUPED анализ
    # Рассчитать optimal alpha
    alpha = np.cov(df['post_revenue'], df['pre_revenue'])[0, 1] / df['pre_revenue'].var()
    
    # Рассчитать adjusted metric
    df['adjusted_metric'] = df['post_revenue'] - alpha * df['pre_revenue']
    
    control_adjusted = df[df['variant'] == 'control']['adjusted_metric']
    treatment_adjusted = df[df['variant'] == 'treatment']['adjusted_metric']
    
    cuped_effect = treatment_adjusted.mean() - control_adjusted.mean()
    cuped_std_error = np.sqrt(control_adjusted.var() + treatment_adjusted.var()) / np.sqrt(len(df) / 2)
    
    print(f"\nCUPED effect: ${cuped_effect:.3f} +/- ${cuped_std_error:.3f}")
    print(f"Confidence interval: ${cuped_effect - 1.96*cuped_std_error:.3f} to ${cuped_effect + 1.96*cuped_std_error:.3f}")
    
    # Variance reduction
    variance_reduction = 1 - (cuped_std_error / naive_std_error)**2
    print(f"\nVariance reduction: {variance_reduction*100:.1f}%")
    print(f"This means: нам нужно в {1/(1-variance_reduction):.1f}x меньше users для same power")

Преимущества:

  • Очень эффективно (часто 30-50% reduction in variance)
  • Просто реализовать
  • Не требует изменений в тестировании

Недостатки:

  • Нужны исторические данные
  • Может быть bias если pre-period не репрезентативен

3. Variance-Reducing Covariates (MAPP)

Идея: Использовать несколько covariates (не только pre-metric, но и другие характеристики user) для дополнительного снижения variance.

from sklearn.linear_model import LinearRegression

def variance_reducing_covariates():
    """
    MAPP: Multiple Analytics Personalised Prediction
    Использовать machine learning model для prediction baseline,
    затем рассчитать difference от baseline
    """
    
    # Данные: user characteristics, pre-metrics, treatment, post-metric
    features = ['account_age', 'num_purchases', 'avg_order_value', 'region', 'platform']
    
    X = df[features]
    y = df['post_revenue']
    
    # Шаг 1: Обучить model на control group (no treatment)
    control_data = df[df['variant'] == 'control']
    X_control = control_data[features]
    y_control = control_data['post_revenue']
    
    model = LinearRegression()
    model.fit(X_control, y_control)
    
    # Шаг 2: Для всех users предсказать baseline (что было бы без treatment)
    df['predicted_baseline'] = model.predict(X)
    
    # Шаг 3: Рассчитать adjusted metric
    df['adjusted_revenue'] = df['post_revenue'] - df['predicted_baseline']
    
    # Шаг 4: Провести тест на adjusted metric (намного меньше дисперсия!)
    control_adjusted = df[df['variant'] == 'control']['adjusted_revenue']
    treatment_adjusted = df[df['variant'] == 'treatment']['adjusted_revenue']
    
    effect = treatment_adjusted.mean() - control_adjusted.mean()
    print(f"Effect (variance-reduced): ${effect:.3f}")
    
    return effect

Преимущества:

  • Может дать ещё большее снижение variance чем CUPED
  • Включает все доступные информацию

Недостатки:

  • Более сложно
  • Риск overfitting

4. Ratio Metrics вместо Absolute Metrics

Идея: Иногда более стабильная метрика — это ratio (например, Revenue per User вместо Total Revenue).

def ratio_metric_reduction():
    """
    Пример: Total Revenue очень варьируется (power-law distribution)
    Но Revenue per Session намного более стабильна
    """
    
    # Данные
    df['sessions'] = np.random.poisson(5, size=10000)  # Most users have 3-7 sessions
    df['revenue_per_session'] = np.random.exponential(scale=1, size=10000)
    df['total_revenue'] = df['sessions'] * df['revenue_per_session']
    
    # Сравнить variance
    print(f"CV (Coefficient of Variation) = Std Dev / Mean")
    print(f"\nTotal Revenue:")
    print(f"  Mean: ${df['total_revenue'].mean():.2f}")
    print(f"  Std Dev: ${df['total_revenue'].std():.2f}")
    print(f"  CV: {df['total_revenue'].std() / df['total_revenue'].mean():.2f}")
    
    print(f"\nRevenue per Session:")
    print(f"  Mean: ${df['revenue_per_session'].mean():.2f}")
    print(f"  Std Dev: ${df['revenue_per_session'].std():.2f}")
    print(f"  CV: {df['revenue_per_session'].std() / df['revenue_per_session'].mean():.2f}")
    
    # Revenue per Session имеет намного меньше variance!

Когда использовать:

  • Когда есть "base" metric (sessions, days, items), от которой зависит основная метрика

Преимущества:

  • Естественное нормализация
  • Часто биологически более meaningful

5. Bucketization (Binning Continuous Metrics)

Идея: Вместо использования continuous метрики (revenue $0-$10000), использовать bucketed версию (Low, Medium, High). Это снижает variance за счёт меньшего количества unique values.

def bucketization():
    """
    Binned metric имеет меньше variance, чем continuous metric
    Trade-off: теряем некоторое количество информации
    """
    
    # Continuous metric
    continuous = df['revenue']
    
    # Bucketed metric
    df['revenue_bucket'] = pd.cut(df['revenue'], 
                                 bins=[0, 10, 50, 100, 500, 10000],
                                 labels=['Very Low', 'Low', 'Medium', 'High', 'Very High'])
    
    # Variance в bucketed версии ниже
    print(f"Variance (continuous): {continuous.var():.1f}")
    print(f"Variance (buckets): {df['revenue_bucket'].cat.codes.var():.1f}")
    
    # But: теряем информацию

Когда использовать:

  • Когда metric очень skewed
  • Когда есть outliers

6. Longer Experiment Duration

Идея: Дольше running experiment → более стабильный estimate среднего значения (Law of Large Numbers).

import math

def calculate_required_duration():
    """
    Duration needed = (Z_alpha + Z_beta)^2 * (Var_control + Var_variant) / (effect_size)^2
    
    Чем выше variance, тем дольше нужен тест
    """
    
    variance = 100  # Пример: высокая дисперсия
    effect_size = 5  # Ищем effect в 5%
    
    # Для 80% power, 95% confidence
    z_alpha = 1.96
    z_beta = 0.84
    
    # Минимальное количество observations
    n_per_group = 2 * (z_alpha + z_beta)**2 * variance / (effect_size**2)
    
    # Если в день 1000 users
    users_per_day = 1000
    duration_days = n_per_group / users_per_day * 2  # For both control and variant
    
    print(f"Required sample size per group: {n_per_group:.0f}")
    print(f"Duration: {duration_days:.0f} days")
    
    # Если variance выше в 2 раза
    duration_days_2x_variance = (duration_days * 2)
    print(f"\nIf variance 2x higher: {duration_days_2x_variance:.0f} days")

Преимущества:

  • Всегда работает
  • Не требует изменений метрики

Недостатки:

  • Медленнее
  • Может появиться seasonal effects

7. Segmented Analysis

Идея: Вместо анализа all users вместе, анализировать по segments. Внутри segment дисперсия ниже.

-- Пример: Revenue очень варьируется из-за seasonal effects
-- Но внутри каждого дня недели дисперсия намного ниже

SELECT
  DAYNAME(date) as day_of_week,
  AVG(revenue) as avg_revenue,
  STDDEV(revenue) as stddev_revenue,
  STDDEV(revenue) / AVG(revenue) as cv
FROM daily_revenue
GROUP BY DAYNAME(date);

-- Вместо анализа всех дней вместе,
-- анализируем each день отдельно

8. Bayesian Methods (Sequential Testing)

Идея: Использовать Bayesian approach с prior distribution. Это может дать более стабильные estimates когда variance высокая.

from scipy import stats as sp_stats

def bayesian_sequential_test():
    """
    Bayesian approach: постепенно update beliefs
    """
    
    # Prior: мы think что effect = 0, но неуверены
    prior_mean = 0
    prior_std = 1
    
    # Data comes in
    observations = [0.5, 1.2, 0.8, 1.1, 0.9, 1.3]  # Daily effects
    
    posterior_mean = prior_mean
    posterior_std = prior_std
    
    for obs in observations:
        # Update (simplified Bayes)
        posterior_mean = (posterior_mean / prior_std**2 + obs / 0.5**2) / (1/prior_std**2 + 1/0.5**2)
        posterior_std = 1 / np.sqrt(1/prior_std**2 + 1/0.5**2)
        
        print(f"After observation {obs}: posterior = {posterior_mean:.2f} +/- {posterior_std:.2f}")
        
        # Can stop early if confident
        if posterior_mean - 2*posterior_std > 0:
            print(f"  -> Confident effect is positive, can stop")
            break

Преимущества:

  • Можно stop early when confident
  • Более flexible

Рекомендуемый подход

Когда я вижу высокую дисперсию:

  1. First: Check если это из-за power users → Stratification
  2. Second: Implement CUPED (30-50% variance reduction обычно)
  3. Third: Если дальше надо → Variance-reducing covariates или longer duration
  4. Always: Проверить если есть более стабильная metric (ratio, normalized)

Пример реального случая:

Проблема: Daily Revenue варьируется от $1M до $5M, нужно обнаружить 2% effect
Решение:
1. Stratify by user segment (power users отдельно) -> -20% variance
2. Implement CUPED using pre-period revenue -> -40% variance
3. Use Revenue per User instead of Total Revenue -> -30% variance
4. Result: общее снижение variance на 65%, нужно вдвое меньше времени

Высокая дисперсия — это главный враг быстрых experiments. Но есть много способов с ней бороться, и обычно комбинация из 2-3 методов даёт отличные результаты.

Какие знаешь способы борьбы с высокой дисперсией? | PrepBro