Собесов

Movavi — A/B-тест ценовой опции подписки 5/10/15$

A/B-тестыТесты с несколькими ценамиСложнаяMiddle

Условие

Мы придумали MLфичу — автоматическая сборка крутого видео на YouTube. Хотим продавать как отдельную подписку (другие подобные фичи мы успешно продавали).

Хотим протестировать 3 ценовые опции: 5,5, 10, $15 в месяц. Время теста — месяц. Дизайн эксперимента и анализ — на вас.

Вопросы:

  1. Опишите setup эксперимента: гипотезу (с привязкой к выбору лучшей цены в терминах A/B), ключевую метрику, статкритерий.
  2. Рассчитайте размер групп при базовой конверсии в подписку 10%, MDE = 5%, alpha = 5%, beta = 20%.

Решение

Подход

Это 3-арм A/B/C тест с разными ценами. Главная сложность: decision-метрика — не конверсия, а revenue per user (RPU), потому что более низкая цена даёт высокую конверсию, но низкий чек.

Шаг 1. Формулировка гипотезы

H0: средний доход на пользователя одинаков для всех трёх цен. H1: хотя бы одна цена даёт больший RPU.

В терминах A/B: ищем revenue-максимизирующую цену, не конверсию-максимизирующую.

Ключевая метрика

RPU (revenue per user) = price * conversion_rate. Это бинарная метрика, умноженная на константу — статистически проще, чем «выручка за месяц с учётом отмен», но требует определиться, что считать конверсией.

Дополнительные метрики

Метрика Зачем
Conversion rate для каждой цены — отдельно (для проверки модели)
Cancellation rate дешёвая цена → больше «попробовали и ушли»
LTV-90 если можно ждать дольше — лучше metric, чем RPU
Cancel within 7 days гард — нельзя «купить» конверсию ценой, которую люди потом массово отменяют
Time to subscribe UX-сигнал

Статкритерий

При двух группах — z-test на разность средних RPU (через delta-method или bootstrap, потому что RPU — функция бинарной величины и константы; либо просто z-test для пропорций с поправкой на цену).

При трёх группах — два варианта:

  1. ANOVA / Kruskal-Wallis — глобальный тест «есть ли разница хоть где-то». Требует попарных сравнений потом, с поправкой Бонферрони (α/3\alpha / 3).
  2. Pairwise z-tests на 3 пары (A-B, B-C, A-C) с поправкой Бонферрони / Холма-Бонферрони (α/3 для строгого FWER) или Benjamini-Hochberg (для FDR).

Для multi-arm pricing — лучше попарный z-test на RPU с BH-коррекцией, потому что нас интересуют попарные сравнения, а не общий тест.

Шаг 2. Размер выборки

Если базовая конверсия p0 = 0.10, MDE = 5% относительный (то есть 0.10 → 0.105 — крошечный эффект):

from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize
 
p0 = 0.10
mde_relative = 0.05
p1 = p0 * (1 + mde_relative)  # 0.105
es = proportion_effectsize(p1, p0)
n = NormalIndPower().solve_power(
    effect_size=es, alpha=0.05, power=0.8, alternative="two-sided"
)
print(f"n per group ≈ {n:.0f}")  # ~94 800

~94 800 на группу × 3 группы ≈ 285 000 total. Для месячного теста при 10% базовой конверсии и активной аудитории — может быть подъёмно для крупного приложения, но не для маленького.

Если MDE — 5% абсолютный (0.10 → 0.15 — огромный эффект):

p0, p1 = 0.10, 0.15
es = proportion_effectsize(p1, p0)
n = NormalIndPower().solve_power(effect_size=es, alpha=0.05, power=0.8)
# ~720 на группу

«MDE = 5%» — формулировка двусмысленная. Уточните у заказчика, что именно: 5% относительный или абсолютный.

Поправка на multiple comparisons

При 3 попарных сравнениях с Бонферрони: α' = 0.05 / 3 = 0.0167. Размер выборки растёт примерно в (z_{0.0083} / z_{0.025})^2 ≈ 1.4 раз. То есть для абсолютного MDE = 5% нужно ~1000 на группу. Для относительного 5% — ~133 000 на группу.

Шаг 3. Дизайн

from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize, proportions_ztest
from statsmodels.stats.multitest import multipletests
import numpy as np
 
# 1. Размер группы (с поправкой Бонферрони на 3 сравнения)
p0, p1 = 0.10, 0.15  # абсолютный MDE 5pp
es = proportion_effectsize(p1, p0)
n_unadj = NormalIndPower().solve_power(es, alpha=0.05, power=0.8)
# Поправка на 3 сравнения: alpha = 0.05/3
n_adj = NormalIndPower().solve_power(es, alpha=0.05/3, power=0.8)
print(f"n per group (без поправки): {n_unadj:.0f}")
print(f"n per group (Bonferroni):    {n_adj:.0f}")
 
# 2. После эксперимента: попарные сравнения
def run_pairwise(groups, prefix=""):
    pvals, pairs = [], []
    for i, j in [(0, 1), (1, 2), (0, 2)]:
        z, p = proportions_ztest(
            count=[groups[i].sum(), groups[j].sum()],
            nobs=[len(groups[i]), len(groups[j])])
        pairs.append((i, j)); pvals.append(p)
    rejected, p_adj, _, _ = multipletests(pvals, alpha=0.05, method="bonferroni")
    return list(zip(pairs, p_adj, rejected))

Анализ результатов

# Решение принимаем по RPU
def rpu(group, price):
    return group.mean() * price
 
# Скажем, RPU($5) = 0.20 * 5 = $1.00
# RPU($10) = 0.10 * 10 = $1.00
# RPU($15) = 0.05 * 15 = $0.75
 
# Считаем CI на RPU через bootstrap
def bootstrap_rpu(conv, price, n_iter=10000, seed=42):
    rng = np.random.default_rng(seed)
    samples = []
    for _ in range(n_iter):
        idx = rng.integers(0, len(conv), len(conv))
        samples.append(conv[idx].mean() * price)
    return np.percentile(samples, [2.5, 97.5])

Решение:

  • Сравниваем RPU(price) — более стабильная картина.
  • Если разница в RPU между двумя ценами не значима → выбираем по доп. соображениям (cancel rate, LTV-90, бренд-позиционирование).

Подводные камни

  1. «Конверсия» — главная метрика? Нет. Низкая цена даёт высокую конверсию, но мизерный RPU. Decision = RPU, конверсия — sanity-check.
  2. Multiple comparisons. Без поправки FWER при 3 сравнениях — раздутый FPR.
  3. Cannibalization. Если у нас уже есть платные функции, дешёвая ($5) подписка на новую может «съесть» платящих с других продуктов. Проверяйте.
  4. Cancel rate. Низкая цена → больше «попробовать и забыть». RPU может быть высоким за месяц, но плохая ретенция → LTV рухнет.
  5. Anchoring effect. Если показывать пользователю все 3 цены сразу на пейволле — это другой эксперимент (про anchoring), не про лучшую цену. Здесь — каждый видит ОДНУ цену.
  6. Network effects. Если юзеры обсуждают цену — A/B нечистый.
  7. Sample Ratio Mismatch. Сплит на 3 группы — особое внимание SRM (chi2 со степенями = 2).
  8. Длительность. Месяц — мало для retention/cancel метрик. RPU считаем за месяц, но cancel rate смотрим за 3 месяца.
  9. Sticky assignment. Юзер должен видеть одну цену всегда (иначе путается).

Альтернативы

  • Multi-armed bandit (Thompson sampling) с RPU как reward — оптимизирует выручку в процессе теста, но даёт менее точные оценки.
  • Sequential testing (mSPRT) — экономия выборки, но требует careful FWER.
  • Conjoint analysis на опросе — дешевле, не A/B, но менее точно.

Эталонный ответ

Setup: 3 группы (по одной цене каждая), sticky-assignment, decision-метрика RPU = price × conversion, попарные z-tests с Бонферрони/BH.

Размер выборки при p0=0.10, MDE=5%:

  • 5% относительный: ~94k на группу (без поправки), ~133k с Бонферрони на 3 сравнения. Месяц нереалистичен без массивной аудитории.
  • 5% абсолютный: ~720 на группу (без поправки), ~1000 с поправкой. Подъёмно за месяц.

Главное: уточнить «MDE = 5%» (относительный или абсолютный) и принять решение по RPU, а не конверсии.

Хочешь увидеть разбор?

Зарегистрируйся бесплатно — откроется развёрнутое решение этой задачи и ещё 4 на выбор.

Зарегистрироваться и увидеть разбор
Уже есть аккаунт? Войти