Какие знаешь способы борьбы с высокой дисперсией?
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Способы борьбы с высокой дисперсией в экспериментах
Высокая дисперсия (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
Рекомендуемый подход
Когда я вижу высокую дисперсию:
- First: Check если это из-за power users → Stratification
- Second: Implement CUPED (30-50% variance reduction обычно)
- Third: Если дальше надо → Variance-reducing covariates или longer duration
- 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 методов даёт отличные результаты.