Опубликовано: 31 авг 2021
Вам знакомы выражения "нулевая гипотеза", "нормальное распределение", "параметрический тест"? Если да, то вы как минимум стали погружаться в теорию a/b тестирования. Продвигаясь в этой области знаний, вы можете встречать такое:
или такое:
И если вы не математик по профессии, то возможно ваша первая реакция будет такая:
Если у вас возникает вопрос по какой-либо концепции или задаче, и вы обращаетесь к мнению экспертов, будь то лекции на youtube или онлайн школы, вы можете обнаружить, что хотя в целом ответы экспертов будут звучать близко по смыслу, все же могут быть различия. Это связано с тем, что как таковой науки "a/b тестирование" нет, есть классическая статистика, которую эксперты в меру своего математического бекграунда применяют для решения задач, связанных с проведением экспериментов на сайтах и приложениях.
Так как понять, какая стратегия вам подходит ближе, какие подходы работают хорошо, а какие могут ошибаться? Как проверить логику за этими подходами?
Проведение A/B эксперимента — это зачастую технически непростая задача, которая может занимать заметный интервал времени, поэтому проверять алгоритмы непосредственно в реальном эксперименте - это не самым эффективный подход. Решение, на которые можно обратить внимание, если вам знаком Python или другой язык программирования - это симуляция (генерирование) данных эксперимента и анализ алгоритмов и концепций на основе сгенерированных данных.
Попробуем прояснить данный подход на примерах, и для начала сгенерируем что-нибудь простое, например, нормальное распределение.
import numpy as np
import pandas as pd
from scipy import stats
import matplotlib.pyplot as plt
import matplotlib as mpl
import seaborn as sns
import datetime
Будем смотреть данные на суточном интервале (24 часа):
time = np.random.normal(12, 4, 1000) #center, std, size
print(f'Максимальное значение {time.max()}')
print(f'Минимальное значение {time.min()}')
print(f'Среднее значение {time.mean()}')
print(f'Стандартное отклонение {time.std()}')
print(f'Длина массива {len(time)}')
Максимальное значение 25.40619242878821 Минимальное значение -0.8048998540482124 Среднее значение 11.772303187706113 Стандартное отклонение 3.943611752217578 Длина массива 1000
Параметры можно подбирать эмпирически. Но нужно трансформировать те немногие значения, которые выходят за рамки определенного диапазона, приведя их к минимальному и максимальному значению соответственно.
time = np.clip(np.random.normal(12, 4, 1000), 0 ,23)
print(f'Максимальное значение {time.max()}')
print(f'Минимальное значение {time.min()}')
print(f'Среднее значение {time.mean()}')
print(f'Стандартное отклонение {time.std()}')
print(f'Длина массива {len(time)}')
Максимальное значение 23.0 Минимальное значение 0.0 Среднее значение 11.784216443753543 Стандартное отклонение 4.040449572486255 Длина массива 1000
Сгенерируем выручку в заказах:
revenue = np.random.randint(low=1000, high=2000, size=1000, dtype=int)
df = pd.DataFrame({'time': time, 'revenue': revenue})
df.head()
time | revenue | |
---|---|---|
0 | 15.944401 | 1459 |
1 | 16.684475 | 1172 |
2 | 9.812285 | 1775 |
3 | 6.621241 | 1357 |
4 | 6.413500 | 1742 |
Для наглядности визуализируем данные нашей модели.
ax = sns.kdeplot(df.time, color='#FF5252')
ax.set_xticks([0, 6, 12, 18, 23]);
Теперь построим модель, когда с возрастанием цены количество покупок становится меньше. Т.е. покупатели склоняются к более дешевым предложениям товаров. Для простоты решения построим эти данные, взяв правую часть от нормального распределения.
revenue2 = np.random.normal(1000, 270, 2000) #center, std, size
revenue2 = revenue2[revenue2 > 1000]
print(f'Максимальное значение {revenue2.max()}')
print(f'Минимальное значение {revenue2.min()}')
print(f'Среднее значение {revenue2.mean()}')
print(f'Стандартное отклонение {revenue2.std()}')
print(f'Длина массива {len(revenue2)}')
Максимальное значение 1897.2575788074569 Минимальное значение 1000.1008497320913 Среднее значение 1218.9053762249764 Стандартное отклонение 164.71761381886498 Длина массива 960
df2 = pd.DataFrame({'time': time[:len(revenue2)], 'revenue': revenue2})
df2.head()
time | revenue | |
---|---|---|
0 | 15.944401 | 1062.941996 |
1 | 16.684475 | 1127.483081 |
2 | 9.812285 | 1245.774893 |
3 | 6.621241 | 1094.025684 |
4 | 6.413500 | 1042.780654 |
fig, ax = plt.subplots()
ax2 = ax.twinx()
ax = sns.scatterplot(data=df2, x="time", y="revenue", ax=ax, linewidth=0)
ax2 = sns.kdeplot(df2.time, ax=ax2, color='#FF5252');
xticks = [0, 6, 12, 18, 23]
ax.set_xticks(xticks);
xlabels = [datetime.time(x).strftime("%H:00") for x in xticks]
ax.set_xticklabels(xlabels)
plt.show()
Теперь, взяв данную модель за основу, давайте перейдем непосредственно к симуляции эксперимента. Предположим у нас имеется магазин, в котором пользователи делают приблизительно 1000 заказов в день. Мы изменили воронку покупки и выдвигаем гипотезу, что средний чек покупки на новой воронке должен увеличиться.
(нулевая гипотеза): Между контрольной и целевой группой нет разницы. В данном контексте это означает, что нет разницы с точки зрения выручки между воронкой покупки A и B.
(альтернативная гипотеза): Между контрольной (A) и целевой (B) группой есть разница с точки зрения полученной выручки.
Тезис: мы запустили A/B тест на протяжении недели.
Сгенерируем данные для варианта A:
dates = np.arange(np.datetime64('2021-07-01'), np.datetime64('2021-07-08'))
frames_a = []
for day in dates:
revenue = np.random.normal(1000, 300, 2000)
revenue = revenue[revenue > 1000]
df = pd.DataFrame({'date': np.full(len(revenue), day), 'revenue': revenue})
frames_a.append(df)
A = pd.concat(frames_a).reset_index(drop=True)
A.head()
date | revenue | |
---|---|---|
0 | 2021-07-01 | 1705.091928 |
1 | 2021-07-01 | 1242.255142 |
2 | 2021-07-01 | 1142.097701 |
3 | 2021-07-01 | 1081.876914 |
4 | 2021-07-01 | 1214.247046 |
A.tail()
date | revenue | |
---|---|---|
6962 | 2021-07-07 | 1165.372611 |
6963 | 2021-07-07 | 1150.987498 |
6964 | 2021-07-07 | 1160.441116 |
6965 | 2021-07-07 | 1138.960605 |
6966 | 2021-07-07 | 1335.013797 |
print(f'Максимальное значение {A.revenue.max()}')
print(f'Минимальное значение {A.revenue.min()}')
print(f'Среднее значение {A.revenue.mean()}')
print(f'Стандартное отклонение {A.revenue.std()}')
print(f'Длина массива {len(A.revenue)}')
Максимальное значение 2216.5368244320225 Минимальное значение 1000.0518454900939 Среднее значение 1238.8446523088994 Стандартное отклонение 180.59483648168455 Длина массива 6967
Сгенерируем данные для варианта B, так чтобы его средний чек был немного больше.
frames_b = []
for day in dates:
revenue = np.random.normal(1050, 290, 2000) #center, std, size
revenue = revenue[revenue > 1000]
df = pd.DataFrame({'date': np.full(len(revenue), day), 'revenue': revenue})
frames_b.append(df)
B = pd.concat(frames_b).reset_index(drop=True)
date | revenue | |
---|---|---|
0 | 2021-07-01 | 1155.477913 |
1 | 2021-07-01 | 1120.772944 |
2 | 2021-07-01 | 1162.751370 |
3 | 2021-07-01 | 1350.819018 |
4 | 2021-07-01 | 1050.296434 |
B.tail()
date | revenue | |
---|---|---|
8014 | 2021-07-07 | 1175.512999 |
8015 | 2021-07-07 | 1520.556930 |
8016 | 2021-07-07 | 1179.524552 |
8017 | 2021-07-07 | 1386.365227 |
8018 | 2021-07-07 | 1386.436178 |
print(f'Максимальное значение {B.revenue.max()}')
print(f'Минимальное значение {B.revenue.min()}')
print(f'Среднее значение {B.revenue.mean()}')
print(f'Стандартное отклонение {B.revenue.std()}')
print(f'Длина массива {len(B.revenue)}')
Максимальное значение 2172.0163444326354 Минимальное значение 1000.0493183947639 Среднее значение 1250.0283759164997 Стандартное отклонение 183.4669879473019 Длина массива 8019
Посмотрим на данные с помощью простого графика. Средний чек в течение дня в контрольной и целевой группе:
ax = A.groupby('date').revenue.mean().plot()
B.groupby('date').revenue.mean().plot(ax=ax)
plt.gca().set_ylim(bottom=1000)
ax.legend(["A arpu (руб)", "B arpu (руб)"]);
ax.yaxis.set_major_formatter(mpl.ticker.StrMethodFormatter('{x:.0f}'))
plt.show()
Мы видим некую небольшую разницу между средним чеком группы A
и B
.
Основной вопрос на который мы постараемся ответить - сможет ли выбранный нами статистический тест определить эту разницу.
Мы сравниваем два числовых распределения (выручка). Это параметрический тип теста. Для определения статистической значимости мы будем использовать t-критерий Стьюдента.
Условия, необходимые для применения t-теста:
Мы проверим, сможет ли t-test из модуля stats определить статистическую разницу в данных нашей модели.
This is a two-sided test for the null hypothesis that 2 independent samples have identical average (expected) values. This test assumes that the populations have identical variances by default.
twosample_results = stats.ttest_ind(A.revenue, B.revenue)
print('stats = {:.3f}, pvalue = {:.7f}'.format(twosample_results.statistic, twosample_results.pvalue))
stats = -3.749, pvalue = 0.0001781
Как мы видим, p-value
заметно ниже 5%, т. е. t-тест успешно справился с задачей. Мы можем отклонить нулевую гипотезу.
Проведем A/A эксперимент и убедимся, что t-тест работает корректно с нашей моделью и в обратную сторону.
frames_a2 = []
for day in dates:
revenue = np.random.normal(1000, 300, 2000)
revenue = revenue[revenue > 1000]
df = pd.DataFrame({'date': np.full(len(revenue), day), 'revenue': revenue})
frames_a2.append(df)
A2 = pd.concat(frames_a2).reset_index(drop=True)
twosample_results = stats.ttest_ind(A.revenue, A2.revenue)
print('stats = {:.3f}, pvalue = {:.7f}'.format(twosample_results.statistic, twosample_results.pvalue))
stats = 0.950, pvalue = 0.3421866
И здесь t-тест успешно справился с задачей, показав, что мы не можем отклонить нулевую гипотезу, поскольку p-value значительно выше 5%.
Данная симуляция эксперимента и проверка алгоритма показана в качестве примера. Такая проверка возможно не даст 100%‑й гарантии относительно выбора статистического теста. Но в целом на мой взгляд этот подход открывает дополнительные возможности по подготовке экспериментов и оценке их результатов.