websitelytics

Menu

Потайная дверь в A/B тестирование

Опубликовано: 31 авг 2021

... для тех кто кодит на Python

Вам знакомы выражения "нулевая гипотеза", "нормальное распределение", "параметрический тест"? Если да, то вы как минимум стали погружаться в теорию 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

Для наглядности визуализируем данные нашей модели.

2021-08-31T03:43:25.834198 image/svg+xml Matplotlib v3.3.4, https://matplotlib.org/
ax = sns.kdeplot(df.time, color='#FF5252')
ax.set_xticks([0, 6, 12, 18, 23]);
2021-08-31T03:43:26.123393 image/svg+xml Matplotlib v3.3.4, https://matplotlib.org/

Теперь построим модель, когда с возрастанием цены количество покупок становится меньше. Т.е. покупатели склоняются к более дешевым предложениям товаров. Для простоты решения построим эти данные, взяв правую часть от нормального распределения.

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()
2021-08-31T03:43:26.595652 image/svg+xml Matplotlib v3.3.4, https://matplotlib.org/

Симуляция эксперимента

Теперь, взяв данную модель за основу, давайте перейдем непосредственно к симуляции эксперимента. Предположим у нас имеется магазин, в котором пользователи делают приблизительно 1000 заказов в день. Мы изменили воронку покупки и выдвигаем гипотезу, что средний чек покупки на новой воронке должен увеличиться.

Тезис: мы запустили 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()
2021-08-31T05:23:52.503661 image/svg+xml Matplotlib v3.3.4, https://matplotlib.org/

Мы видим некую небольшую разницу между средним чеком группы 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%‑й гарантии относительно выбора статистического теста. Но в целом на мой взгляд этот подход открывает дополнительные возможности по подготовке экспериментов и оценке их результатов.