Заказчик

Интернет-магазин

Цель проекта

  • Необходимость приоритизировать гипотезы из списка гипотез, предоставленных отделом Маркетинга;

  • Анализа результатов проведенного А/В-тестирования и аргументация решения по результатам теста.

Варианты решений:

  1. Остановить тест, зафиксировать победу одной из групп.
  2. Остановить тест, зафиксировать отсутствие различий между группами.
  3. Продолжить тест.

Ход исследования

Исследование пройдёт в три этапа:

  1. Обзор и предобработка данных;
  2. Приоритезация гипотез;
  3. Анализ А/В-теста;
In [1]:
import pandas as pd
import numpy as np
import scipy.stats as stats
import matplotlib.pyplot as plt
import plotly.graph_objs as go
from plotly.offline import download_plotlyjs, init_notebook_mode, plot, iplot
In [2]:
# снимаем ограничение на количество столбцов
pd.set_option('display.max_columns', None)

# снимаем ограничение на ширину столбцов
pd.set_option('display.max_colwidth', None)

# выставляем ограничение на показ знаков после запятой
pd.options.display.float_format = '{:,.3f}'.format
In [3]:
try:
    hypothesis, orders, visitors = (
        pd.read_csv('/datasets/hypothesis.csv'),
        pd.read_csv('/datasets/orders.csv', parse_dates=['date']),
        pd.read_csv('/datasets/visitors.csv', parse_dates=['date']))


except:
    hypothesis, orders, visitors = (
        pd.read_csv('hypothesis.csv'),
        pd.read_csv('orders.csv', parse_dates=['date']),
        pd.read_csv('visitors.csv', parse_dates=['date']))

Обзор датафрейма hypothesis

In [4]:
hypothesis.info()
hypothesis
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 9 entries, 0 to 8
Data columns (total 5 columns):
 #   Column      Non-Null Count  Dtype 
---  ------      --------------  ----- 
 0   Hypothesis  9 non-null      object
 1   Reach       9 non-null      int64 
 2   Impact      9 non-null      int64 
 3   Confidence  9 non-null      int64 
 4   Efforts     9 non-null      int64 
dtypes: int64(4), object(1)
memory usage: 488.0+ bytes
Out[4]:
Hypothesis Reach Impact Confidence Efforts
0 Добавить два новых канала привлечения трафика, что позволит привлекать на 30% больше пользователей 3 10 8 6
1 Запустить собственную службу доставки, что сократит срок доставки заказов 2 5 4 10
2 Добавить блоки рекомендаций товаров на сайт интернет магазина, чтобы повысить конверсию и средний чек заказа 8 3 7 3
3 Изменить структура категорий, что увеличит конверсию, т.к. пользователи быстрее найдут нужный товар 8 3 3 8
4 Изменить цвет фона главной страницы, чтобы увеличить вовлеченность пользователей 3 1 1 1
5 Добавить страницу отзывов клиентов о магазине, что позволит увеличить количество заказов 3 2 2 3
6 Показать на главной странице баннеры с актуальными акциями и распродажами, чтобы увеличить конверсию 5 3 8 3
7 Добавить форму подписки на все основные страницы, чтобы собрать базу клиентов для email-рассылок 10 7 8 5
8 Запустить акцию, дающую скидку на товар в день рождения 1 9 9 5

Обзор датафрейма orders

In [5]:
orders.info()
orders.sample(5)
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1197 entries, 0 to 1196
Data columns (total 5 columns):
 #   Column         Non-Null Count  Dtype         
---  ------         --------------  -----         
 0   transactionId  1197 non-null   int64         
 1   visitorId      1197 non-null   int64         
 2   date           1197 non-null   datetime64[ns]
 3   revenue        1197 non-null   int64         
 4   group          1197 non-null   object        
dtypes: datetime64[ns](1), int64(3), object(1)
memory usage: 46.9+ KB
Out[5]:
transactionId visitorId date revenue group
733 3782700345 2395035985 2019-08-30 4990 B
423 4161654914 990904712 2019-08-19 11249 B
145 735232225 611059232 2019-08-01 8800 A
937 3628490752 284094220 2019-08-08 1090 B
824 3086067579 277405052 2019-08-27 19990 A

Обзор датафрейма visitors

In [6]:
visitors.info()
visitors.sample(5)
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 62 entries, 0 to 61
Data columns (total 3 columns):
 #   Column    Non-Null Count  Dtype         
---  ------    --------------  -----         
 0   date      62 non-null     datetime64[ns]
 1   group     62 non-null     object        
 2   visitors  62 non-null     int64         
dtypes: datetime64[ns](1), int64(1), object(1)
memory usage: 1.6+ KB
Out[6]:
date group visitors
4 2019-08-05 A 756
21 2019-08-22 A 609
15 2019-08-16 A 361
42 2019-08-12 B 543
47 2019-08-17 B 421

Выводы

Мы ознакомились с таблицами. На первый взгляд, пропусков нет, но кое-что требует правки: тип данных в столбцах не всегда корректный (например, столбцы с датами имеют тип object). Так же нужно проверить данные на наличие дубликатов и привести наименования столбцов к единому стилю.

Приступаем к предобработке данных.

Предобработка данных¶

In [7]:
# приведем наименования столбцов к хорошему стилю
hypothesis.columns = hypothesis.columns.str.lower()
orders.columns = [name.replace('Id', '_id') for name in orders.columns]

display(hypothesis.columns, orders.columns)

# проверяем таблицы на наличие дубликатов
print(f'Количество дубликатов в датасете visitors \n{visitors.isna().sum()}')
print('_________' * 10, '\n')
print(f'Количество дубликатов в датасете orders \n{orders.isna().sum()}')
Index(['hypothesis', 'reach', 'impact', 'confidence', 'efforts'], dtype='object')
Index(['transaction_id', 'visitor_id', 'date', 'revenue', 'group'], dtype='object')
Количество дубликатов в датасете visitors 
date        0
group       0
visitors    0
dtype: int64
__________________________________________________________________________________________ 

Количество дубликатов в датасете orders 
transaction_id    0
visitor_id        0
date              0
revenue           0
group             0
dtype: int64

Комментарий:
Итак, ранее мы увидели, что отдел Маркетинга подготовил список из 9-ти гипотез, каждый компонент которой оценен по шкале от 0 до 10.
Применим один из самых популярных методов приоритезации гипотез - ICE (от. англ.: Impact, Confidence, Effort/ Влияние, Уверенность, Усилия).

In [8]:
hypothesis['ice'] = hypothesis['impact'] * hypothesis['confidence'] / hypothesis['efforts']
display(hypothesis[['hypothesis', 'ice']].sort_values(by='ice', ascending=False).head())
hypothesis ice
8 Запустить акцию, дающую скидку на товар в день рождения 16.200
0 Добавить два новых канала привлечения трафика, что позволит привлекать на 30% больше пользователей 13.333
7 Добавить форму подписки на все основные страницы, чтобы собрать базу клиентов для email-рассылок 11.200
6 Показать на главной странице баннеры с актуальными акциями и распродажами, чтобы увеличить конверсию 8.000
2 Добавить блоки рекомендаций товаров на сайт интернет магазина, чтобы повысить конверсию и средний чек заказа 7.000
In [9]:
ax = hypothesis[['hypothesis', 'ice']].sort_values(by=['ice']) \
                                      .set_index('hypothesis') \
                                      .plot(kind='barh', color='k', alpha = 0.8)
ax.set_xlabel('Приоритет')
ax.set_ylabel(' ')
ax.set_title('Приоритезация гипотез по методу ICE')
plt.grid(b=True, color='grey', alpha=0.3)
plt.rcParams['axes.facecolor'] = '#ffffff'
plt.show()

Комментарий
Вывели топ-5 гипотез, которым следует уделить пристальное внимание. Добавим к расчету параметр Reach (англ.: Охват) и приоритезируем гипотезы по методу RICE.

In [10]:
hypothesis['rice'] = hypothesis['reach'] * hypothesis['impact'] * hypothesis['confidence'] / hypothesis['efforts']

hypothesis[['hypothesis', 'rice']].sort_values(by='rice', ascending=False).head(5)
Out[10]:
hypothesis rice
7 Добавить форму подписки на все основные страницы, чтобы собрать базу клиентов для email-рассылок 112.000
2 Добавить блоки рекомендаций товаров на сайт интернет магазина, чтобы повысить конверсию и средний чек заказа 56.000
0 Добавить два новых канала привлечения трафика, что позволит привлекать на 30% больше пользователей 40.000
6 Показать на главной странице баннеры с актуальными акциями и распродажами, чтобы увеличить конверсию 40.000
8 Запустить акцию, дающую скидку на товар в день рождения 16.200
In [11]:
ax = hypothesis[['hypothesis', 'rice']].sort_values(by='rice') \
                                      .set_index('hypothesis') \
                                      .plot(kind='barh', color='y', alpha = 0.8)
ax.set_xlabel('Приоритет')
ax.set_ylabel(' ')
ax.set_title('Приоритезация гипотез по методу RICE')
plt.grid(b=True, color='grey', alpha=0.3)
plt.rcParams['axes.facecolor'] = '#ffffff'
plt.show()
In [12]:
hypothesis[['hypothesis','ice', 'rice']].sort_values(by='rice', ascending=False).head(5)
Out[12]:
hypothesis ice rice
7 Добавить форму подписки на все основные страницы, чтобы собрать базу клиентов для email-рассылок 11.200 112.000
2 Добавить блоки рекомендаций товаров на сайт интернет магазина, чтобы повысить конверсию и средний чек заказа 7.000 56.000
0 Добавить два новых канала привлечения трафика, что позволит привлекать на 30% больше пользователей 13.333 40.000
6 Показать на главной странице баннеры с актуальными акциями и распродажами, чтобы увеличить конверсию 8.000 40.000
8 Запустить акцию, дающую скидку на товар в день рождения 16.200 16.200
In [13]:
ax = hypothesis[['hypothesis', 'ice', 'rice']].sort_values(by=['ice']) \
                                      .set_index('hypothesis') \
                                      .plot(kind='barh', color = ['k', 'y'], alpha = 0.8)
ax.set_xlabel('Приоритет')
ax.set_ylabel(' ')
ax.set_title('Сравнение приоритезаций гипотез ICE и RICE')
plt.grid(b=True, color='grey', alpha=0.3)
plt.rcParams['axes.facecolor'] = '#ffffff'
plt.show()

Комментарий:
По фреймворку ICE наибольшую оценку имели гпиотезы 8,0 и 7. После применения фреймворка RICE приоритеты изменились на гпиотезы 7,2 и 0. Это объясняется тем, что в случае фреймворка ICE в отличие от RICE мы не учитывали охват изменений, тогда как это важная составляющая оценки гипотез.

В нашем случае наиболее приоритетными гипотезами будут 7, 2, 0.

Анализ A/B-теста¶

Проверим корректность распределения на группы A/B

In [14]:
print('Кол-во посетителей в группе А: {}'.format(visitors[visitors['group']=='A']['visitors'].sum()))
print('Кол-во посетителей в группе B: {}'.format(visitors[visitors['group']=='B']['visitors'].sum()))

purchasesA = len(orders[orders['group']=='A'])
purchasesB = len(orders[orders['group']=='B'])

print(f'Кол-во покупок в группе A: {purchasesA}')
print(f'Кол-во покупок в группе B: {purchasesB}')

plt.hist(visitors[visitors['group']=='A']['visitors'], alpha = 0.8, label='A', bins = 30, color='y')
plt.hist(visitors[visitors['group']=='B']['visitors'], alpha = 0.8, label='B', bins = 30, color='k')
plt.legend()
plt.title('Гистограммы кол-ва посетителей в день по группам A и B')
plt.xlabel('Количество посетителей')
plt.ylabel('Частота значений')
plt.grid(b=True, color='grey', alpha=0.3)
plt.rcParams['axes.facecolor'] = '#ffffff'
plt.show()

results = stats.mannwhitneyu(visitors[visitors['group']=='A']['visitors'], \
                             visitors[visitors['group']=='B']['visitors'])
alpha = 0.05

print(f'P-value:{results.pvalue}')
if results.pvalue < alpha:
    print('Разница в количестве посетителей в группах A и B статистически значима\n')
else:
    print('Разница в количестве посетителей в группах A и B статистически НЕ значима\n')

initial_conversion = len(orders[orders['group']=='A']) / visitors[visitors['group']=='A']['visitors'].sum() 
resulting_conversion = len(orders[orders['group']=='B']) / visitors[visitors['group']=='B']['visitors'].sum()

print('Начальная конверсия: {:.3f}'.format(initial_conversion))
print('Полученная после изменений конверсия: {:.3f}'.format(resulting_conversion))
print('Относительное изменение конверсии после изменений: {:.3f}'.format(abs(1-(initial_conversion/resulting_conversion))))
print('\nВводим начальную конверсию и полученное относительное изменение конверсии в калькулятор\n'
     'Сайт калькулятора: https://www.evanmiller.org/ab-testing/sample-size.html\n'
     'Получаем необходимое количество выборки 35855 для корректного проведения тестирования\n'
     'В нашем случае количество {} посещений\n'
     'Кол-во выборки для A/B тестирования корректно подобрано, ' 
     'статистически значимой разницы\nв количестве посещений м/у группами не обнаружено\n\nОк, идём дальше.'.format(visitors['visitors'].sum()))
Кол-во посетителей в группе А: 18736
Кол-во посетителей в группе B: 18916
Кол-во покупок в группе A: 557
Кол-во покупок в группе B: 640
P-value:0.7301376549390499
Разница в количестве посетителей в группах A и B статистически НЕ значима

Начальная конверсия: 0.030
Полученная после изменений конверсия: 0.034
Относительное изменение конверсии после изменений: 0.121

Вводим начальную конверсию и полученное относительное изменение конверсии в калькулятор
Сайт калькулятора: https://www.evanmiller.org/ab-testing/sample-size.html
Получаем необходимое количество выборки 35855 для корректного проведения тестирования
В нашем случае количество 37652 посещений
Кол-во выборки для A/B тестирования корректно подобрано, статистически значимой разницы
в количестве посещений м/у группами не обнаружено

Ок, идём дальше.

Начало теста: 01 августа 2019 года

Окончание теста: 31 августа 2019 года

Посмотрим на количество груп:

In [15]:
visitors['date'].value_counts()
Out[15]:
2019-08-27    2
2019-08-24    2
2019-08-08    2
2019-08-14    2
2019-08-20    2
2019-08-26    2
2019-08-01    2
2019-08-07    2
2019-08-13    2
2019-08-19    2
2019-08-25    2
2019-08-31    2
2019-08-06    2
2019-08-12    2
2019-08-18    2
2019-08-30    2
2019-08-21    2
2019-08-05    2
2019-08-11    2
2019-08-17    2
2019-08-23    2
2019-08-29    2
2019-08-04    2
2019-08-10    2
2019-08-16    2
2019-08-22    2
2019-08-28    2
2019-08-03    2
2019-08-09    2
2019-08-15    2
2019-08-02    2
Name: date, dtype: int64
In [16]:
visitors['group'].value_counts()
Out[16]:
A    31
B    31
Name: group, dtype: int64

Проверим группы на совпадения пользователей

In [17]:
g_a = orders[orders['group'] == 'A']['visitor_id']
g_b = orders[orders['group'] == 'B']['visitor_id']
orders_ab = orders.query('visitor_id in @g_a and visitor_id in @g_b')
display(orders_ab['visitor_id'].unique())
print('Количество пользователей в двух группах:', orders_ab['visitor_id'].nunique())
print('Всего пользователей в тесте:', orders['visitor_id'].nunique())
array([4069496402,  963407295,  351125977, 3234906277,  199603092,
        237748145, 3803269165, 2038680547, 2378935119, 4256040402,
       2712142231,    8300375,  276558944,  457167155, 3062433592,
       1738359350, 2458001652, 2716752286, 3891541246, 1648269707,
       3656415546, 2686716486, 2954449915, 2927087541, 2579882178,
       3957174400, 2780786433, 3984495233,  818047933, 1668030113,
       3717692402, 2044997962, 1959144690, 1294878855, 1404934699,
       2587333274, 3202540741, 1333886533, 2600415354, 3951559397,
        393266494, 3972127743, 4120364173, 4266935830, 1230306981,
       1614305549,  477780734, 1602967004, 1801183820, 4186807279,
       3766097110, 3941795274,  471551937, 1316129916,  232979603,
       2654030115, 3963646447, 2949041841])
Количество пользователей в двух группах: 58
Всего пользователей в тесте: 1031
In [18]:
# Посмотрим, сколько и на какую сумму были заказы у выявленных 58 пользователей
orders_ab['revenue'].describe()
Out[18]:
count      181.000
mean     8,612.901
std     14,161.551
min         50.000
25%      1,530.000
50%      3,460.000
75%      8,439.000
max     93,940.000
Name: revenue, dtype: float64

Комментарий:
Имеем 58 пользователей который одновременно присутствуют в двух группах. Всего они сделали 181 заказ со средней суммой заказа 8612 (причем медианное значение 3460) и максимальной 93940. Всего в таблице 1031 пользоватей. Наши пользователи из 2 групп составляют 5,6% от общего числа. Так как их процент от общего числа не велик, очистим таблицу от этих пользователей, для корретного А/В теста.

In [19]:
orders = orders.query('visitor_id not in @orders_ab["visitor_id"]')
print('Всего пользователей в тесте осталось:', orders['visitor_id'].nunique())
Всего пользователей в тесте осталось: 973

Кумулятивные метрики¶

Постройте график кумулятивной выручки по группам. Сделайте выводы и предположения.¶

Комментарий:
Чтобы построить графики по кумулятивным (накапливаемым) данным, необходимо собрать соответствующий агрегированный датафрейм, содержащий информацию о дате, группе (А или В), кумулятивных количестве заказов и пользователей их оформивших, среднем чеке и, наконец, о кумулятивном количестве посетителей магазина.

In [20]:
# создаем массив уникальных пар значений дат и групп теста
dates_grouped = orders[['date', 'group']].drop_duplicates()

# получаем агрегированные кумулятивные по дням данные о заказах 
orders_grouped = dates_grouped.apply(
    lambda x: orders[
        np.logical_and(
            orders['date'] <= x['date'], orders['group'] == x['group']
        )
    ].agg(
        {
            'date': 'max',
            'group': 'max',
            'transaction_id': 'nunique',
            'visitor_id': 'nunique',
            'revenue': 'sum',
        }
    ),
    axis=1,
).sort_values(by=['date', 'group'])

orders_grouped.head(6)
Out[20]:
date group transaction_id visitor_id revenue
55 2019-08-01 A 23 19 142779
66 2019-08-01 B 17 17 59758
175 2019-08-02 A 42 36 234381
173 2019-08-02 B 40 39 221801
291 2019-08-03 A 66 60 346854
383 2019-08-03 B 54 53 288850
In [21]:
# получаем агрегированные кумулятивные по дням данные о посетителях интернет-магазина 

visitors_grouped = dates_grouped.apply(
    lambda x: visitors[
        np.logical_and(
            visitors['date'] <= x['date'], visitors['group'] == x['group']
        )
    ].agg(
        {
            'date': 'max', 
            'group': 'max', 
            'visitors': 'sum'
        }
    ),
    axis=1,
).sort_values(by=['date', 'group'])

visitors_grouped.head(6)
Out[21]:
date group visitors
55 2019-08-01 A 719
66 2019-08-01 B 713
175 2019-08-02 A 1338
173 2019-08-02 B 1294
291 2019-08-03 A 1845
383 2019-08-03 B 1803
In [22]:
# объединяем кумулятивные данные в одной таблице и присваиваем ее столбцам понятные названия

cumulative_data = orders_grouped.merge(
    visitors_grouped, 
    left_on=['date', 'group'], 
    right_on=['date', 'group']
)

cumulative_data.columns = ['date', 'group', 'orders', 'buyers', 'revenue', 'visitors']
In [23]:
# Добавим расчетную колонку с конверсией.
cumulative_data['conversion'] = cumulative_data['orders'] / cumulative_data['visitors']
cumulative_data.head(6)
Out[23]:
date group orders buyers revenue visitors conversion
0 2019-08-01 A 23 19 142779 719 0.032
1 2019-08-01 B 17 17 59758 713 0.024
2 2019-08-02 A 42 36 234381 1338 0.031
3 2019-08-02 B 40 39 221801 1294 0.031
4 2019-08-03 A 66 60 346854 1845 0.036
5 2019-08-03 B 54 53 288850 1803 0.030

Построим график кумулятивной выручки по группам.

In [24]:
# датафрейм с кумулятивным количеством заказов и кумулятивной выручкой по дням в группе А
cumulative_revenue_a = cumulative_data[cumulative_data['group']=='A'][['date','revenue', 'orders']]

# датафрейм с кумулятивным количеством заказов и кумулятивной выручкой по дням в группе B
cumulative_revenue_b = cumulative_data[cumulative_data['group']=='B'][['date','revenue', 'orders']]
template = 'plotly_white'
trace_A = go.Scatter(
    x = cumulative_revenue_a['date'],
    y = cumulative_revenue_a['revenue'],
    mode = 'lines+markers',
    name = 'A'
)
trace_B = go.Scatter(
    x = cumulative_revenue_b['date'],
    y = cumulative_revenue_b['revenue'],
    mode = 'lines+markers',
    name = 'B'
)

layout = go.Layout(
    title='Кумулятивная выручка по группам',
    xaxis_title='Дата',
    yaxis_title='Средний чек'
)   

fig = go.Figure(data = [trace_A, trace_B], layout = layout)
fig.layout.template = 'plotly_white'
iplot(fig)

Комментарий:
Мы видим, что кумулятивная выручка группы А растет линейно, и она меньше, чем у группы В. При этом в группе В есть резкий скачок в районе 18-го августа, что может сигнализировать о всплесках числа заказов, либо о появлении очень дорогих заказов в выборке.

Постройте график кумулятивного среднего чека по группам. Сделайте выводы и предположения.¶

In [25]:
# Построим график динамики кумулятивного среднего чека по группам.
trace_A = go.Scatter(
    x = cumulative_revenue_a['date'],
    y = cumulative_revenue_a['revenue']/cumulative_revenue_a['orders'],
    mode = 'lines+markers',
    name = 'A'
)
trace_B = go.Scatter(
    x = cumulative_revenue_b['date'],
    y = cumulative_revenue_b['revenue']/cumulative_revenue_b['orders'],
    mode = 'lines+markers',
    name = 'B'
)

layout = go.Layout(
    title='График кумулятивного среднего чека по группам',
    xaxis_title='Дата',
    yaxis_title='Средний чек'
)   

fig = go.Figure(data = [trace_A, trace_B], layout = layout)
fig.layout.template = 'plotly_white'
iplot(fig)

Комментарий:
Мы видим, что средний чек в группе А через какое-то время стабилизировался. Средний чек группы В показал резкий рывок, что говорит в пользу версии о дорогой покупке.

Постройте график относительного изменения кумулятивного среднего чека группы B к группе A. Сделайте выводы и предположения.¶

Построим график относительного изменения кумулятивного среднего чека группы B к группе A.

In [26]:
# собираем данные в одном датафрейме
cumulative_revenue_ab = cumulative_revenue_a.merge(cumulative_revenue_b, left_on='date', \
                                                   right_on='date', how='left', suffixes=['_a', '_b'])

# cтроим отношение средних чеков
mean_check_A = cumulative_revenue_ab['revenue_a'] / cumulative_revenue_ab['orders_a']
mean_check_B = cumulative_revenue_ab['revenue_b'] / cumulative_revenue_ab['orders_b']
mean_check_rel = mean_check_B / mean_check_A - 1

cum_mean_check_rel = pd.DataFrame({'date': cumulative_revenue_ab['date'].unique(),
                                   'mean_check_rel': mean_check_rel})

layout = go.Layout(title='График относительного изменения кумулятивного среднего чека группы B к группе A',
                   xaxis_title='Дата',
                   yaxis_title='Средний чек')
fig = go.Figure(layout=layout)
fig.add_trace(go.Scatter(x=cumulative_revenue_ab['date'],
                                      y=cum_mean_check_rel['mean_check_rel'],
                                      mode='lines+markers', line = dict(color='black')
                                     ))


## --------------------------> линия параллельно оси х
fig.add_trace( go.Scatter(x = [min(cum_mean_check_rel['date']), max(cum_mean_check_rel['date'])], y=[0, 0], mode="lines",
                        line = dict(color='royalblue', width=4, dash='dash'))) ## <------------------------------

fig.layout.template = 'plotly_white'
fig.show()

Комментарий:
График резко скачет в нескольких точках - определённо, имеют место какие-то выбросы и крупные заказы.

Постройте график кумулятивного среднего количества заказов на посетителя по группам. Сделайте выводы и предположения.¶

In [27]:
# отделяем данные по группе A
cumulative_data_a = cumulative_data[cumulative_data['group']=='A']

# отделяем данные по группе B
cumulative_data_b = cumulative_data[cumulative_data['group']=='B']
In [28]:
layout = go.Layout(title='График кумулятивного среднего количества заказов на посетителя по группам',
                  xaxis_title = 'Дата',
                  yaxis_title = 'Частота значений')

fig = go.Figure(layout=layout)
fig.add_trace(go.Scatter(x = cumulative_data_a['date'],
                         y = cumulative_data_a['conversion'], mode = 'lines+markers', name='A'))
fig.add_trace(go.Scatter(x = cumulative_data_b['date'],
                         y = cumulative_data_b['conversion'], mode = 'lines+markers', name='B'))
fig.layout.template = 'plotly_white'
fig.show()

Комментарий:
Сначала количество заказов на посетителя колебались, но довольно быстро выровнялись; результат группы В кажется более привлекательным.

Постройте график относительного изменения кумулятивного среднего количества заказов на посетителя группы B к группе A. Сделайте выводы и предположения.¶

In [29]:
cumulative_conversions_ab = (
    cumulative_data_a[['date','conversion']]
    .merge(
        cumulative_data_b[['date','conversion']], 
        left_on='date', 
        right_on='date', 
        how='left', 
        suffixes=['_a', '_b']
    )
)

cum_mean_conv_rel = (cumulative_conversions_ab['conversion_b'] / cumulative_conversions_ab['conversion_a'] - 1)


layout = go.Layout(title = 'График относительного изменения кумулятивного среднего количества заказов группы B к группе A',
                  xaxis_title = 'Дата',
                  yaxis_title = 'Частота значений')
fig = go.Figure(layout=layout)
fig.add_trace(go.Scatter(x = cumulative_conversions_ab['date'],
                         y = cum_mean_conv_rel,
                         mode='lines+markers', line = dict(color='black')))

## --------------------------> линия параллельно оси х
fig.add_trace( go.Scatter(x = [min(cum_mean_check_rel['date']), max(cum_mean_check_rel['date'])], y=[0, 0], mode="lines",
                        line = dict(color='royalblue', width=4, dash='dash'))) ## <------------------------------

fig.layout.template = 'plotly_white'
fig.show()

Комментарий:
В начале теста группа В проигрывала группе А, но затем вырвалась вперед. Ее среднее количество заказов стремительно росла, далее начался медленный спад. Теперь среднее количество заказов группы В снова растет. Но мы помним, что графики выше сигнализировали нам о наличии крупных заказов. Необходимо проанализировать данные после чистки от выбросов еще раз.

Анализ количества заказов по пользователям и их стоимости¶

КОЛИЧЕСТВО ЗАКАЗОВ ПО ПОЛЬЗОВАТЕЛЯМ

Комментарий:
Пользователи, совершившие много заказов влияют на числитель формулы отношения количества заказов к количеству поситителей интернет-магазина за время теста.

"Обычный" пользователь редко совершает более одного-двух заказов в короткий срок (если только речь не идет о сайтах с регулярным спросом (например, продуктовый интернет-магазин)). Посмотрим, что с количеством заказов происходит с участниками нашего тестирования.

In [30]:
orders_by_users = (
    orders.groupby('visitor_id', as_index=False)
    .agg({'transaction_id': 'nunique'})
)
orders_by_users.columns =  ['visitor_id', 'orders']
display(orders_by_users['orders'].describe())


# строим гистограмму
plt.hist(orders_by_users['orders'], color='k', alpha=0.8)
plt.grid(b=True, color='grey', alpha=0.3)
plt.rcParams['axes.facecolor'] = '#ffffff'
plt.title('Распределение количества заказов по пользователям')
plt.xlabel('Количество заказов')
plt.ylabel('Количество пользователей')
plt.show()
count   973.000
mean      1.044
std       0.238
min       1.000
25%       1.000
50%       1.000
75%       1.000
max       3.000
Name: orders, dtype: float64

Комментарий:
Большинство пользователей оформляли заказ один раз, но есть и те, кто успел сделать за месяц одиннадцать заказов.

Постройте точечный график количества заказов по пользователям. Сделайте выводы и предположения.¶

In [31]:
x_values = pd.Series(range(0,len(orders_by_users)))

layout = go.Layout(title = 'Точечный график количества заказов по пользователям',
                  xaxis_title = 'Пользователи',
                  yaxis_title = 'Кол-во заказов')
fig = go.Figure(layout=layout)
fig.add_trace(go.Scatter(x=x_values, y=orders_by_users['orders'], mode='markers'))
fig.layout.template = 'plotly_white'
fig.show()

Комментарий:
Много пользователей с 2-3 заказами. Их точная доля не ясна, поэтому сложно понять, можно ли считать их выбросами или нет. Посчитаем 95-й и 99-й перцентили количества заказов на пользователя и выберем границу для определения аномальных пользователей.

Посчитайте 95-й и 99-й перцентили количества заказов на пользователя. Выберите границу для определения аномальных пользователей.¶

In [32]:
np.percentile(orders_by_users['orders'], [95, 99])
Out[32]:
array([1., 2.])

Комментарий:
Не более 5% пользователей совершали больше 2-х покупок в течение тестирования. И только 1% - четыре и более. Примем за верхнюю границу 4 заказа на одного пользователя.

Постройте точечный график стоимостей заказов. Сделайте выводы и предположения.¶

Изучим гистограмму стоимости заказов.

In [33]:
display(orders['revenue'].describe())

# строим гистограмму
plt.hist(orders['revenue'], alpha=0.8, color='k')
plt.grid(b=True, color='grey', alpha=0.3)
plt.rcParams['axes.facecolor'] = '#ffffff'
plt.title('Распределение стоимости заказов по пользователям')
plt.xlabel('Стоимость заказов')
plt.ylabel('Количество пользователей')
plt.show()
count       1,016.000
mean        8,300.815
std        42,121.992
min            50.000
25%         1,190.000
50%         2,955.000
75%         8,134.250
max     1,294,500.000
Name: revenue, dtype: float64

Один из заказов - почти 1.3 млн.!

In [34]:
x_values = pd.Series(range(0,len(orders['revenue'])))
layout = go.Layout(title = 'Точечный график количества заказов по пользователям',
                  xaxis_title = 'Пользователи',
                  yaxis_title = 'Кол-во заказов')
fig = go.Figure(layout=layout)
fig.add_trace(go.Scatter(x=x_values, y=orders['revenue'], mode='markers'))
fig.layout.template = 'plotly_white'
fig.show()

Комментарий:
Опять мы видим этот гигантский заказ, который вызвал всплеск на графиках кумулятивных метрик, а так же другие весомые заказы, которые могут повлиять на результат исследований.

Пострим аналогичный график без двух крупных выбросов, которые заметили выше.

In [35]:
layout = go.Layout(title = 'Точечный график количества заказов по пользователям',
                  xaxis_title = 'Пользователи',
                  yaxis_title = 'Кол-во заказов', yaxis=dict(range=[0, 150000]))
fig = go.Figure(layout=layout)
fig.add_trace(go.Scatter(x=x_values, y=orders['revenue'], mode='markers'))
fig.layout.template = 'plotly_white'
fig.show()

Комментарий:
Мы видим, что основная масса заказов не привышает 20000.

Посчитаем 95-й и 99-й перцентили стоимости заказов на пользователя и выберим границу для определения аномалий.

Посчитайте 95-й и 99-й перцентили стоимости заказов. Выберите границу для определения аномальных заказов¶

In [36]:
print(np.percentile(orders['revenue'], [95, 99])) 
[26785. 53904.]

Определяем границу по величине 99% перцентиля (58233)

Анализ статистической значимости¶

Посчитайте статистическую значимость различий в среднем количестве заказов на посетителя между группами по «сырым» данным. Сделайте выводы и предположения¶

Комментарий:
Ранее мы результаты A/B-теста визуально и выяснили, что в данных, скорее всего, есть выбросы. Потом увидели выбросы и нашли границу для их определения.

Посчитаем статистическую значимость различий в среднем количестве заказов на посетителя между группами по «сырым» данным — без удаления аномальных пользователей.

Введем основную и альтернативные гипотезы:

$\begin{equation*} \begin{cases} H_0 :\text{различий в среднем количестве заказов на посетителя между группами нет}\\ H_1 :\text{различия в среднем количестве заказов на посетителя между группами есть} \end{cases} \end{equation*}$

Уровень значимости: $\alpha = 0.05$

Так как данные о продажах и средних чеках редко бывают нормально распределены (это классический пример переменных, подверженных выбросам), для проверки гипотез будем использовать непараметрический тест Уилкоксона-Манна-Уитни. Для уровеня значимости установим стандартный уровень статистической значимости, равный 0.05.

Начнём с подготовки данных.

In [37]:
visitors_daily_a = visitors[visitors['group'] == 'A'][['date', 'visitors']]
visitors_daily_a.columns = ['date', 'visitors_per_date_a']

visitors_cummulative_a = visitors_daily_a.apply(
    lambda x: visitors_daily_a[visitors_daily_a['date'] <= x['date']].agg(
        {'date': 'max', 'visitors_per_date_a': 'sum'}
    ),
    axis=1,
)
visitors_cummulative_a.columns = ['date', 'visitors_cummulative_a']

visitors_daily_b = visitors[visitors['group'] == 'B'][['date', 'visitors']]
visitors_daily_b.columns = ['date', 'visitors_per_date_b']

visitors_cummulative_b = visitors_daily_b.apply(
    lambda x: visitors_daily_b[visitors_daily_b['date'] <= x['date']].agg(
        {'date': 'max', 'visitors_per_date_b': 'sum'}
    ),
    axis=1,
)
visitors_cummulative_b.columns = ['date', 'visitors_cummulative_b']

orders_daily_a = (
    orders[orders['group'] == 'A'][['date', 'transaction_id', 'visitor_id', 'revenue']]
    .groupby('date', as_index=False)
    .agg({'transaction_id': pd.Series.nunique, 'revenue': 'sum'})
)
orders_daily_a.columns = ['date', 'orders_daily_a', 'revenue_per_date_a']

orders_cummulative_a = orders_daily_a.apply(
    lambda x: orders_daily_a[orders_daily_a['date'] <= x['date']].agg(
        {'date': 'max', 'orders_daily_a': 'sum', 'revenue_per_date_a': 'sum'}
    ),
    axis=1,
).sort_values(by=['date'])

orders_cummulative_a.columns = ['date','orders_cummulative_a','revenue_cummulative_a']

orders_daily_b = (
    orders[orders['group'] == 'B'][['date', 'transaction_id', 'visitor_id', 'revenue']]
    .groupby('date', as_index=False)
    .agg({'transaction_id': pd.Series.nunique, 'revenue': 'sum'})
)
orders_daily_b.columns = ['date', 'orders_daily_b', 'revenue_per_date_b']

orders_cummulative_b = orders_daily_b.apply(
    lambda x: orders_daily_b[orders_daily_b['date'] <= x['date']].agg(
        {'date': 'max', 'orders_daily_b': 'sum', 'revenue_per_date_b': 'sum'}
    ),
    axis=1,
).sort_values(by=['date'])

orders_cummulative_b.columns = ['date','orders_cummulative_b','revenue_cummulative_b']


data = (
    orders_daily_a.merge(
        orders_daily_b, left_on='date', right_on='date', how='left'
    )
    .merge(orders_cummulative_a, left_on='date', right_on='date', how='left')
    .merge(orders_cummulative_b, left_on='date', right_on='date', how='left')
    .merge(visitors_daily_a, left_on='date', right_on='date', how='left')
    .merge(visitors_daily_b, left_on='date', right_on='date', how='left')
    .merge(visitors_cummulative_a, left_on='date', right_on='date', how='left')
    .merge(visitors_cummulative_b, left_on='date', right_on='date', how='left')
)

data.head(6)
Out[37]:
date orders_daily_a revenue_per_date_a orders_daily_b revenue_per_date_b orders_cummulative_a revenue_cummulative_a orders_cummulative_b revenue_cummulative_b visitors_per_date_a visitors_per_date_b visitors_cummulative_a visitors_cummulative_b
0 2019-08-01 23 142779 17 59758 23 142779 17 59758 719 713 719 713
1 2019-08-02 19 91602 23 162043 42 234381 40 221801 619 581 1338 1294
2 2019-08-03 24 112473 14 67049 66 346854 54 288850 507 509 1845 1803
3 2019-08-04 11 41176 14 96890 77 388030 68 385740 717 770 2562 2573
4 2019-08-05 22 86383 21 89908 99 474413 89 475648 756 707 3318 3280
5 2019-08-06 15 40919 23 214842 114 515332 112 690490 667 655 3985 3935

Итак, у нас получилась следующая таблица.

date — дата;
orders_daily_a — количество заказов в выбранную дату в группе A;
revenue_per_date_a — суммарная выручка в выбранную дату в группе A;
orders_daily_a — количество заказов в выбранную дату в группе B;
revenue_per_date_a — суммарная выручка в выбранную дату в группе B;
orders_cummulative_a — суммарное число заказов до выбранной даты включительно в группе A;
revenue_cummulative_a — суммарная выручка до выбранной даты включительно в группе A;
orders_cummulative_b — суммарное количество заказов до выбранной даты включительно в группе B;
revenue_cummulative_b — суммарная выручка до выбранной даты включительно в группе B;
visitors_per_date_a — количество пользователей в выбранную дату в группе A;
visitors_per_date_b — количество пользователей в выбранную дату в группе B;
visitors_cummulative_a — количество пользователей до выбранной даты включительно в группе A;
visitors_cummulative_b — количество пользователей до выбранной даты включительно в группе B.

Создадим переменные orders_by_users_a и orders_by_users_b; в них для пользователей, которые заказывали хотя бы 1 раз, укажем число совершённых заказов.

In [38]:
orders_by_users_a = (
    orders[orders['group'] == 'A']
    .groupby('visitor_id', as_index=False)
    .agg({'transaction_id': pd.Series.nunique})
)
orders_by_users_a.columns = ['visitor_id', 'orders']

orders_by_users_b = (
    orders[orders['group'] == 'B']
    .groupby('visitor_id', as_index=False)
    .agg({'transaction_id': pd.Series.nunique})
)
orders_by_users_b.columns = ['visitor_id', 'orders']

Объявим переменные sample_a и sample_b, в которых пользователям из разных групп будет соответствовать количество заказов. Тем, кто ничего не заказал, будут соответствовать нули. Это нужно, чтобы подготовить выборки к проверке критерием Манна-Уитни.

In [39]:
sample_a = pd.concat([orders_by_users_a['orders'],
                      pd.Series(
                          0, 
                          index=np.arange(data['visitors_per_date_a'].sum() - 
                                          len(orders_by_users_a['orders'])), 
                          name='orders')],axis=0
                    )

sample_b = pd.concat([orders_by_users_b['orders'],
                      pd.Series(
                          0, 
                          index=np.arange(data['visitors_per_date_b'].sum() - 
                                          len(orders_by_users_b['orders'])), 
                          name='orders')],axis=0
                    )

Задаем функцию, в которой:

  • задаем уровень значимости alpha=0.05,
  • применим критерий Манна-Уитни,
  • отформатируем p-value, округлив его до трёх знаков после запятой,
  • выведем относительный прирост среднего количества заказов на посетителя группы B: среднее группы B / среднее группы A - 1, округлив до трёх знаков после запятой.
In [40]:
# Функция для проверки гипотезы о равенстве групп data A и data B
def stat_significance(data_a, data_b):
    alpha = 0.05
    p_value = stats.mannwhitneyu(data_a, data_b)[1]
    print("P-value: {0:.3f}".format(p_value))

    if (p_value < alpha):
        print("Отвергаем нулевую гипотезу: между группами есть разница")
    else:
        print("Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными")
    
    print("Относительный прирост В к А: {0:.3%}".format(data_b.mean() / data_a.mean()-1))
In [41]:
stat_significance(sample_a, sample_b)
P-value: 0.011
Отвергаем нулевую гипотезу: между группами есть разница
Относительный прирост В к А: 15.980%

Комментарий:
По неочищенным данным различия в среднем количестве заказов на посетителя между группами есть.
P-value = 0.017 меньше 0.05. Значит, нулевую гипотезу о том, что статистически значимых различий в в среднем количестве заказов на посетителя между группами нет, отвергаем. Относительный выигрыш группы B равен 13.81%.

Посчитайте статистическую значимость различий в среднем чеке заказа между группами по «сырым» данным. Сделайте выводы и предположения.¶

Комментарий:

Посчитаем статистическую значимость различий в среднем чеке между группами по «сырым» данным — без удаления аномальных пользователей.

Введем основную и альтернативные гипотезы:

$\begin{equation*} \begin{cases} H_0 :\text{различий в среднем чеке между группами нет}\\ H_1 :\text{различия в среднем чеке между группами есть} \end{cases} \end{equation*}$

Уровень значимости: $\alpha = 0.05$

Чтобы рассчитать статистическую значимость различий в среднем чеке, передадим критерию mannwhitneyu() данные о выручке с заказов. А ещё найдём относительные различия в среднем чеке между группами.

In [42]:
stat_significance(orders[orders['group']=='A']['revenue'], orders[orders['group']=='B']['revenue'])
P-value: 0.829
Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными
Относительный прирост В к А: 28.660%

Комментарий:
P-value значительно больше 0.05. Значит, причин отвергать нулевую гипотезу и считать, что в среднем чеке есть различия, нет. Впрочем, средний чек группы B значительно выше среднего чека группы A.

Посчитайте статистическую значимость различий в среднем количестве заказов на посетителя между группами по «очищенным» данным. Сделайте выводы и предположения¶

Примем за аномальных пользователей тех, кто совершил от 5 заказов или совершил заказ дороже 58233. Так мы уберём 1% пользователей с наибольшим числом заказов и от 1% пользователей с дорогими заказами. Сделаем срезы пользователей с числом заказов больше 4 — users_with_many_orders и пользователей, совершивших заказы дороже 58233 — users_with_expensive_orders. Объединим их в таблице abnormal_users.
Узнаем, сколько всего аномальных пользователей методом shape().

In [43]:
many_orders = np.percentile(orders_by_users['orders'], 99)
expensive_orders = np.percentile(orders['revenue'], 99)


users_with_many_orders = pd.concat(
    [
        orders_by_users_a[orders_by_users_a['orders'] > many_orders]['visitor_id'],
        orders_by_users_b[orders_by_users_b['orders'] > many_orders]['visitor_id'],
    ],
    axis=0,
)


users_with_expensive_orders = orders[orders['revenue'] > expensive_orders]['visitor_id']


abnormal_users = (
    pd.concat([users_with_many_orders, users_with_expensive_orders], axis=0)
    .drop_duplicates()
    .sort_values()
)
display(abnormal_users.head(5))
abnormal_users.shape[0]
1099    148427295
33      249864742
58      611059232
949     887908475
744     888512513
Name: visitor_id, dtype: int64
Out[43]:
16

Комментарий:

Всего 16 аномальных пользователей. Узнаем, как их действия повлияли на результаты теста. Посчитаем статистическую значимость различий в среднем количестве заказов на посетителя между группами теста по очищенным данным. Сначала подготовим выборки количества заказов по пользователям по группам теста.

In [44]:
# Рассчитаем относительные потери
abnormal_users.shape[0] / orders.visitor_id.nunique()
Out[44]:
0.01644398766700925
In [45]:
sample_a_filtered = pd.concat(
    [
        orders_by_users_a[
            np.logical_not(orders_by_users_a['visitor_id'].isin(abnormal_users))
        ]['orders'],
        pd.Series(
            0,
            index=np.arange(
                data['visitors_per_date_a'].sum() - len(orders_by_users_a['orders'])
            ),
            name='orders',
        ),
    ],
    axis=0,
)

sample_b_filtered = pd.concat(
    [
        orders_by_users_b[
            np.logical_not(orders_by_users_b['visitor_id'].isin(abnormal_users))
        ]['orders'],
        pd.Series(
            0,
            index=np.arange(
                data['visitors_per_date_b'].sum() - len(orders_by_users_b['orders'])
            ),
            name='orders',
        ),
    ],
    axis=0,
)
In [46]:
stat_significance(sample_a_filtered, sample_b_filtered)
P-value: 0.007
Отвергаем нулевую гипотезу: между группами есть разница
Относительный прирост В к А: 18.921%

Комментарий:
На очищенных данных разница в среднем количестве заказов на посетителя между группами есть, а относительный прирост среднего группы В отнистельно группы А увеличился на 15.3%.

Посчитайте статистическую значимость различий в среднем чеке заказа между группами по «очищенным» данным. Сделайте выводы и предположения.¶

Подсчитаем статистическую значимость различий в среднем чеке после удаления аномальных пользователей.

In [47]:
stat_significance(
    orders[(orders['group']=='A') & np.logical_not(orders['visitor_id'].isin(abnormal_users))]['revenue'], 
    orders[(orders['group']=='B') & np.logical_not(orders['visitor_id'].isin(abnormal_users))]['revenue']
                  )
P-value: 0.788
Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными
Относительный прирост В к А: -3.234%

Комментарий:
P-value значительно больше 0.05. Значит, причин отвергать нулевую гипотезу и считать, что в среднем чеке есть различия, нет. По разнице средних чеков групп различий практически нет.

Вывод:¶

Примите решение по результатам теста и объясните его. Варианты решений:¶

На основании входных данных, предоставленных интернет-магазином был проведено исследование и вынесены рекомендации, изложенные ниже.

1. В части приоритизации гипотез из списка, предоставленных отделом Маркетинга следует в первую очередь обратить внимание на гипотезы:

  • "Запустить акцию, дающую скидку на товар в день рождения",
  • "Добавить два новых канала привлечения трафика, что позволит привлекать на 30% больше пользователей",
  • "Добавить форму подписки на все основные страницы, чтобы собрать базу клиентов для email-рассылок";

Если ранжирование гипотез должно включать в себя и охват пользователей интернет-магазина, то места необходимо распределить таким образом:

  • "Добавить форму подписки на все основные страницы, чтобы собрать базу клиентов для email-рассылок",
  • "Добавить блоки рекомендаций товаров на сайт интернет магазина, чтобы повысить средний чек заказа",
  • "Добавить два новых канала привлечения трафика, что позволит привлекать на 30% больше пользователей".

2. В части анализа А/В теста:

  • Есть статистически значимое различие по среднему количеству заказов на посетителя между группами как по «сырым», так и по данным после фильтрации аномалий. Среднее группы В выше, чем в А, на 14-15%;

  • Нет статистически значимого различия по среднему чеку между группами ни по «сырым», ни по данным после фильтрации аномалий. При этом средний чек группы В выше (на "очищенных" данных - на ~2%);

  • График относительного изменения кумулятивной среднего количества заказов группы B к группе A показывает, что результаты группы В стабильно лучше группы А;

На основании вышеизложенного рекомендуем остановить тест, зафиксировав победу группы B (ее среднее значительно выше среднего группы А).