Условие
CTR = clicks / views на user-level. Считаем по 10 000 пользователей. Как построить CI?
Решение
Проблема ratio metric
CTR на user level — это sum(clicks) / sum(views) по всем юзерам. Не среднее CTR per user (это разные числа).
Прямой t-test на ratio неверен, потому что:
- Numerator (clicks) и denominator (views) коррелированы.
- Variance ratio не равна
Var(numerator) / mean(denominator)².
Delta method
Для функции g(X, Y) = X / Y:
Var(X/Y) ≈ (1/μ_Y²) × Var(X) + (μ_X² / μ_Y⁴) × Var(Y) - 2(μ_X/μ_Y³) × Cov(X, Y)
Где μ — средние, X — clicks, Y — views.
import numpy as np
clicks = df['clicks']
views = df['views']
mean_c, mean_v = clicks.mean(), views.mean()
var_c, var_v = clicks.var(ddof=1), views.var(ddof=1)
cov_cv = np.cov(clicks, views, ddof=1)[0, 1]
n = len(df)
ctr = mean_c / mean_v
var_ctr = (var_c / mean_v**2 - 2*mean_c*cov_cv / mean_v**3 + mean_c**2*var_v / mean_v**4) / n
se_ctr = np.sqrt(var_ctr)
ci = (ctr - 1.96 * se_ctr, ctr + 1.96 * se_ctr)Альтернатива — bootstrap
def ratio_ci_bootstrap(df, n_boot=10_000):
ratios = []
n = len(df)
for _ in range(n_boot):
idx = np.random.randint(0, n, n)
sub = df.iloc[idx]
ratios.append(sub['clicks'].sum() / sub['views'].sum())
return np.percentile(ratios, [2.5, 97.5])Bootstrap дороже, но меньше предположений.
A/B-тест для ratio
# Версия A: clicks_a, views_a; Версия B: clicks_b, views_b
def delta_variance(c, v):
mc, mv = c.mean(), v.mean()
var_c, var_v = c.var(ddof=1), v.var(ddof=1)
cov_cv = np.cov(c, v, ddof=1)[0, 1]
n = len(c)
return (var_c / mv**2 - 2*mc*cov_cv / mv**3 + mc**2*var_v / mv**4) / n
var_a = delta_variance(c_a, v_a)
var_b = delta_variance(c_b, v_b)
ctr_a, ctr_b = c_a.sum() / v_a.sum(), c_b.sum() / v_b.sum()
z = (ctr_a - ctr_b) / np.sqrt(var_a + var_b)
p_value = 2 * (1 - stats.norm.cdf(abs(z)))Когда unit of analysis = views
Если каждый view независим (не клустеризован по юзерам), CTR — это просто Binomial → Wilson CI. Но в реальности views у одного юзера зависимы (поведение коррелирует), поэтому unit = user.
Clustered std error
Альтернативный подход — кластеризованные SE с регрессией:
import statsmodels.formula.api as smf
model = smf.ols('clicks ~ 1', data=df).fit(
cov_type='cluster',
cov_kwds={'groups': df['user_id']}
)Подводные камни
- Naive
t-test(ctr_per_user)≠ delta method. На user-level разные числа. - Bootstrap должен resample users (целые блоки), не отдельные events.
- Delta method предполагает CLT — не работает на малых n или очень скошенных распределениях.
- Cov(clicks, views) обычно положительный — игнорирование сильно завышает SE.
- Watch out for users с 0 views — деление на ноль, исключать или агрегировать.
Эталонный ответ
Delta method: Var(X/Y) ≈ (1/μ_Y²)Var(X) − 2(μ_X/μ_Y³)Cov(X,Y) + (μ_X²/μ_Y⁴)Var(Y). Или bootstrap на user-level. Naive t-test на user-CTR — неверно.