Прикладна аналітика при розробці IT
КНУ імені Тараса Шевченка, ФІТ
Аналітику Артуру дісталися в руки нові результати A/B-тесту. Менеджер Святослав тестував вплив промо-пушів додатка доставки піци. Він виділив три когорти користувачів: перша - контроль, другій відправив пуш на знижку 30%, третій відправив пуш на безкоштовну піцу до 35 см до замовлення від двох піц.
Питання, хто на 3 місяць принесе більше грошей?
Рівень False Positive зростає пропорційно \(1-(1-\alpha)^m\)
де \(\alpha\) - рівень значущості, \(m\) - кількість гіпотез
Якщо коротко, проблема полягає в тому, що під час одночасної перевірки великої кількості гіпотез на тому самому наборі даних ймовірність зробити неправильний висновок щодо хоча б однієї з цих гіпотез значно перевищує початково прийнятий рівень значущості.
Якщо зробити \(N\) тестів, то ймовірність припуститися хоча б однієї помилки I роду в групі тестів (family-wise error rate, FWER) значно зростає згідно з формулою
\[1 - (1 - \alpha)^m\]
де \(\alpha\) - рівень значущості, \(m\) - кількість гіпотез.
У випадку з 3 когортами у нас 3 попарних рівняння, тобто ми перевіряємо 3 гіпотези: A/B, A/C, B/C. Це означає, що за рівня значущості 95%, \(\alpha\) буде
\[1 - (1 - 0.05)^3 = 0.142\]
Ймовірність того, що тест не помилиться дорівнює
\[0.95^{41} = 0.12\]
Або, якщо він помилиться хоча б 1 раз
\[1 - 0.95^{41} = 0.88\]
Можна задерти рівень значущості, і за 10 гіпотез можна отримати такі результати (за різних альфа):
\[ 95\% = 1 - (1 - 0.05)^{10} = 0.401 \\ 99\% = 1 - (1 - 0.01)^{10} = 0.095 \\ 99.5\% = 1 - (1 - 0.005)^{10} = 0.095 \\ 99.9\% = 1 - (1 - 0.001)^{10} = 0.01 \]
Тобто для \(\alpha = 0.05\) ми отримаємо 40% помилку, а зовсім не 5%, як від початку задається параметром
Що більше гіпотез перевіряється в один момент, то складніше проінтерпретувати дані
«У вас є мобільна версія і десктопна, 50 країн, приблизно 20 значущих джерел реферрального трафіку (пошук Google, партнерські посилання тощо). Усього виходить \(2 \times 50 \times 20 = 2000\) сегментів. Припустимо, що кожен сегмент ідентичний кожному іншому сегменту. Якщо сегментувати дані, вийде \(0,05 \times 2000 = 100\) статистично значущих результатів чисто випадково. Так збіглося, що користувачі Android з Кентуккі, перенаправлені Google, користувачі iPhone, перенаправлені jzyQvfh8z, і користувачі, які зайшли через ПК у Нью-Джерсі, вибрали редизайн. Дивовижно!»
Найпростіший, але найжорсткіший спосіб корекції множинних рівнянь — Поправка Бонферонні. Знаючи кількість тестів, можна обчислити скоригований рівень значущості і використовувати його
\[\alpha_{\text{скор.}} = \frac{\alpha}{m}\]
Наприклад, щоб зберегти в групі з 10 тестів ймовірність помилки I роду 0.05, потрібно проводити кожен тест за \(\alpha = 0.005\).
При цьому різко зростає ймовірність не знайти відмінностей там, де вони є.
Для випадків, коли нам важливіше зберегти істинно-позитивні результати, ніж не допустити хибнопозитивних — використовується контроль False Discovery Rate:
FP / (FP + TP)
За допомогою FDR ми задаємо не кількість помилок першого роду в принципі, а кількість хибнопозитивних (fp) результатів відносно до істиннопозитивних (tp) і хибнопозитивних (fp) (далі це число позначається як \(\gamma\)).
Для контролю FDR використовується поправка Бенджаміні-Хохберга
Навіщо розраховувати всі ці ймовірності?
35 з 140 (35 + 105) значущих тестів насправді помилково значущі, а 105 зі 140 “профарбувались” вірно.
False Descovery Rate (FDR) = 35 / 140 = 0.25
Щоб зафіксувати \(FDR < \gamma\) (\(\gamma\) — рівень FDR, зазвичай беруть 0.1 = 10%):
Для кожного тесту можна обчислити \(q\) — мінімальне значення FDR, за якого результат конкретного тесту можна вважати значущим.
Усього \(N = 5\) тестів. Частота хибнопозитивних результатів FDR = 0.1. Порівнюємо \(q\) і \(α\).
Ранг \(j\) | \(p\) | \(p^{*} = \gamma (j / N)\) | Рішення |
---|---|---|---|
1 | 0.005 | 0.1 * 1/5 = 0.02 | Відкидаємо \(H_0\) |
2 | 0.011 | 0.1 * 2/5 = 0.04 | Відкидаємо \(H_0\) |
3 | 0.02 | 0.1 * 3/5 = 0.06 | Відкидаємо \(H_0\) |
4 | 0.04 | 0.1 * 4/5 = 0.08 | Відкидаємо \(H_0\) |
5 | 0.13 | 0.1 * 5/5 = 0.1 | Не відкидаємо \(H_0\) |
FWER чи FDR?
Основний висновок FDR для продуктових реалій є більш корисною метрикою для контролю, тому що нам би не хотілося викочувати неробочі зміни (коли ми говоримо, що вони робочі), а навпаки, бути впевненими в реальних ефектах.
У деяких продуктових компаніях прийнято мораторій на множинні експерименти через уже озвучені проблеми:
Якщо можна не проводити множинний тест, то краще скористатися такою можливістю
Як має бути
Як насправді
Кейс із фуд-ритейлу: Додали нову вітрину «Ваші минулі покупки» на чекаут.
За класикою, бізнесу цікаво дізнатися як змінився середній чек
На око здається, що в тестовій групі ми стали заробляти більше.
Але бізнесу захочеться зрозуміти, чим пояснюється ця мінливість (різниця, яку ми спостерігаємо), за рахунок якої аудиторії досягли ефекту?
Маючи тільки дані за наявною вибіркою, існує можливість оцінити будь-який її параметр, побудувавши емпіричний розподіл параметра.
У контексті нашого завдання з медіаною - отримати розподіл медіан і далі за ним обчислити довірчий інтервал:
Алгоритм:
# bootstrap
def bootstrap(data, n=9999):
"""Bootstrap function"""
return np.random.choice(data, size=(n, len(data)), replace=True)
# bootstrap median
def bootstrap_median(data, n=9999):
"""Bootstrap median function"""
return np.median(bootstrap(data, n), axis=1)
# bootstrap median for control and test
median_c = bootstrap_median(control)
median_t = bootstrap_median(test)
# plot data
fig, ax = plt.subplots(figsize=(10, 5))
sns.histplot(median_t, ax=ax, color=blue, label='Середнє вибіркової медіани для тесту: {:.2f}'.format(np.mean(median_t)))
sns.histplot(median_c, ax=ax, color=red, label='Середнє вибіркової медіани для контролю: {:.2f}'.format(np.mean(median_c)))
ax.legend()
ax.set_title('Розподіл медіани доходу')
plt.show()
Медіана за допомогою scipy
:
from scipy.stats import bootstrap
# bootstrap median for control and test
median_c = bootstrap(data=(control,), statistic=np.median, confidence_level=0.95)
median_t = bootstrap(data=(test,), statistic=np.median, confidence_level=0.95)
print(f'Довірчий інтервал для контрольної групи = {median_c.confidence_interval}')
print(f'Довірчий інтервал для тестової групи = {median_t.confidence_interval}')
Довірчий інтервал для контрольної групи = ConfidenceInterval(low=2294.8899935602362, high=2914.284628954442)
Довірчий інтервал для тестової групи = ConfidenceInterval(low=2728.8595827184886, high=3259.0417274427105)
Середнє за допомогою scipy
:
from scipy.stats import bootstrap
# bootstrap median for control and test
mean_c = bootstrap(data=(control,), statistic=np.mean, confidence_level=0.95)
mean_t = bootstrap(data=(test,), statistic=np.mean, confidence_level=0.95)
print(f'Довірчий інтервал для контрольної групи = {mean_c.confidence_interval}')
print(f'Довірчий інтервал для тестової групи = {mean_t.confidence_interval}')
Довірчий інтервал для контрольної групи = ConfidenceInterval(low=4714.456586527343, high=6261.43817231836)
Довірчий інтервал для тестової групи = ConfidenceInterval(low=5057.483029164094, high=5998.541280222842)
Графік розподілу середнього за допомогою scipy
:
# plot data
sns.histplot(mean_t.bootstrap_distribution)
plt.axvline(mean_t.confidence_interval[0], color='red', linestyle='--')
plt.axvline(mean_t.confidence_interval[1], color='red', linestyle='--')
plt.axvline(np.mean(test), color='blue', linestyle='--')
ax.set_title('Розподіл середнього доходу')
plt.show()
У цьому випадку критерій Манна-Вітні найкраще підійде для завдання. Він дає відповідь на запитання, чи значуще різняться розподіли, чи ні.
До того ж, у кожного критерію своє аналітичне рішення, яке вимагає дотримуватися низки припущень (наприклад, однакова дисперсія/однаковий розмір вибірки/однакова форма розподілів тощо). Така можливість не завжди є.
Алгоритм:
Проблема
Для бутстрап-вибірок потрібно задавати розмір такий самий, як і в початкової вибірки: зміна статистики залежатиме від розміру вибірки.
Якщо ми хочемо апроксимувати цю мінливість, нам потрібно використовувати повторні вибірки однакового розміру.
Починаючи з ~1 млн спостережень починаються проблеми, пов’язані зі швидкістю обчислення (довго чекати, поки порахується).
Рішення Використовувати бакети на вхід бутстрапу
Розподіл, що відрізняються від нормального, можна привести до нормального за допомогою техніки бакетів:
\[\frac{s^2}{N} \approx \frac{s_b^2}{B}\]
Переважно, завдання A/A тестів полягає в тому, щоб зрозуміти, чи працює система сплітування коректно, чи ні.
В А/А-тестах ми хочемо приймати нульову гіпотезу перевіряючи OEC (Overall Evaluation Criterion) - чи є різниця між групами статистично значущою:
\[ OEC_{control_{1}} = OEC_{control_{2}} \]
Переконатися в коректності системи сплітування можна шляхом двоетапної перевірки:
Для перевірки якості сплітовалки рахуємо частку хибно позитивних оцінок (FPR):
\[ FPR = \frac{FP}{FP + TN} = \frac{FP}{N_{sim}}\]
Необхідно перевіряти FPR на кожному рівні значущості: частота хибних профарбовувань не повинна бути вищою за заданий рівень значущості. FPR не повинен перевищувати 0.05 для α = 0.05. Відповідно і для 0.01, 0.005 тощо.
Червона зафарбована область — рівномірний теоретичний розподіл α. Якщо біни вищі або нижчі за червону лінію, то щось не так і потрібно шукати причини.
Основні причини криються в зламаному спліт-алгоритмі.
Причини необхідно шукати на стороні, де реалізовано скрипт і його запуск. Часті кейси:
Дисбаланс у групах за описовими ознаками.
Для пошуку дисбалансу необхідно порівняти поділ груп із врахуванням ознак. Цілком підійдуть:
Для пошуку причини необхідно порівняти конверсію всередині градацій між контролем і тестом:
Для перевірки фактичних часток з їхнім теоретичним рівномірним розподілом використовують спеціалізовані критерії згоди.
У ситуації з А/А підійде критерій CMH (Cochran-Mantel-Haenszel) для перевірки таблиць спряженості 2 х 2 x K, де K - кількість градацій за аналізованою ознакою (наприклад, браузер 1, браузер 2 тощо).
В Python реалізований в statsmodels.stats.contingency_tables.StratifiedTable
.