Заказчик
Интернет-магазин
Цель проекта
Необходимость приоритизировать гипотезы из списка гипотез, предоставленных отделом Маркетинга;
Анализа результатов проведенного А/В-тестирования и аргументация решения по результатам теста.
Варианты решений:
Ход исследования
Исследование пройдёт в три этапа:
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
# снимаем ограничение на количество столбцов
pd.set_option('display.max_columns', None)
# снимаем ограничение на ширину столбцов
pd.set_option('display.max_colwidth', None)
# выставляем ограничение на показ знаков после запятой
pd.options.display.float_format = '{:,.3f}'.format
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
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
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
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
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
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
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). Так же нужно проверить данные на наличие дубликатов и привести наименования столбцов к единому стилю.
Приступаем к предобработке данных.
# приведем наименования столбцов к хорошему стилю
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/ Влияние, Уверенность, Усилия).
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 |
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.
hypothesis['rice'] = hypothesis['reach'] * hypothesis['impact'] * hypothesis['confidence'] / hypothesis['efforts']
hypothesis[['hypothesis', 'rice']].sort_values(by='rice', ascending=False).head(5)
hypothesis | rice | |
---|---|---|
7 | Добавить форму подписки на все основные страницы, чтобы собрать базу клиентов для email-рассылок | 112.000 |
2 | Добавить блоки рекомендаций товаров на сайт интернет магазина, чтобы повысить конверсию и средний чек заказа | 56.000 |
0 | Добавить два новых канала привлечения трафика, что позволит привлекать на 30% больше пользователей | 40.000 |
6 | Показать на главной странице баннеры с актуальными акциями и распродажами, чтобы увеличить конверсию | 40.000 |
8 | Запустить акцию, дающую скидку на товар в день рождения | 16.200 |
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()
hypothesis[['hypothesis','ice', 'rice']].sort_values(by='rice', ascending=False).head(5)
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 |
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
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 года
Посмотрим на количество груп:
visitors['date'].value_counts()
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
visitors['group'].value_counts()
A 31 B 31 Name: group, dtype: int64
Проверим группы на совпадения пользователей
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
# Посмотрим, сколько и на какую сумму были заказы у выявленных 58 пользователей
orders_ab['revenue'].describe()
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% от общего числа.
Так как их процент от общего числа не велик, очистим таблицу от этих пользователей, для корретного А/В теста.
orders = orders.query('visitor_id not in @orders_ab["visitor_id"]')
print('Всего пользователей в тесте осталось:', orders['visitor_id'].nunique())
Всего пользователей в тесте осталось: 973
Комментарий:
Чтобы построить графики по кумулятивным (накапливаемым) данным, необходимо собрать соответствующий агрегированный датафрейм, содержащий информацию о дате, группе (А или В), кумулятивных количестве заказов и пользователей их оформивших, среднем чеке и, наконец, о кумулятивном количестве посетителей магазина.
# создаем массив уникальных пар значений дат и групп теста
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)
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 |
# получаем агрегированные кумулятивные по дням данные о посетителях интернет-магазина
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)
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 |
# объединяем кумулятивные данные в одной таблице и присваиваем ее столбцам понятные названия
cumulative_data = orders_grouped.merge(
visitors_grouped,
left_on=['date', 'group'],
right_on=['date', 'group']
)
cumulative_data.columns = ['date', 'group', 'orders', 'buyers', 'revenue', 'visitors']
# Добавим расчетную колонку с конверсией.
cumulative_data['conversion'] = cumulative_data['orders'] / cumulative_data['visitors']
cumulative_data.head(6)
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 |
Построим график кумулятивной выручки по группам.
# датафрейм с кумулятивным количеством заказов и кумулятивной выручкой по дням в группе А
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-го августа, что может сигнализировать о всплесках числа заказов, либо о появлении очень дорогих заказов в выборке.
# Построим график динамики кумулятивного среднего чека по группам.
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.
# собираем данные в одном датафрейме
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()
Комментарий:
График резко скачет в нескольких точках - определённо, имеют место какие-то выбросы и крупные заказы.
# отделяем данные по группе A
cumulative_data_a = cumulative_data[cumulative_data['group']=='A']
# отделяем данные по группе B
cumulative_data_b = cumulative_data[cumulative_data['group']=='B']
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()
Комментарий:
Сначала количество заказов на посетителя колебались, но довольно быстро выровнялись; результат группы В кажется более привлекательным.
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()
Комментарий:
В начале теста группа В проигрывала группе А, но затем вырвалась вперед. Ее среднее количество заказов стремительно росла, далее начался медленный спад. Теперь среднее количество заказов группы В снова растет.
Но мы помним, что графики выше сигнализировали нам о наличии крупных заказов.
Необходимо проанализировать данные после чистки от выбросов еще раз.
КОЛИЧЕСТВО ЗАКАЗОВ ПО ПОЛЬЗОВАТЕЛЯМ
Комментарий:
Пользователи, совершившие много заказов влияют на числитель формулы отношения количества заказов к количеству поситителей интернет-магазина за время теста.
"Обычный" пользователь редко совершает более одного-двух заказов в короткий срок (если только речь не идет о сайтах с регулярным спросом (например, продуктовый интернет-магазин)). Посмотрим, что с количеством заказов происходит с участниками нашего тестирования.
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
Комментарий:
Большинство пользователей оформляли заказ один раз, но есть и те, кто успел сделать за месяц одиннадцать заказов.
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-й перцентили количества заказов на пользователя и выберем границу для определения аномальных пользователей.
np.percentile(orders_by_users['orders'], [95, 99])
array([1., 2.])
Комментарий:
Не более 5% пользователей совершали больше 2-х покупок в течение тестирования. И только 1% - четыре и более.
Примем за верхнюю границу 4 заказа на одного пользователя.
Изучим гистограмму стоимости заказов.
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 млн.!
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()
Комментарий:
Опять мы видим этот гигантский заказ, который вызвал всплеск на графиках кумулятивных метрик, а так же другие весомые заказы, которые могут повлиять на результат исследований.
Пострим аналогичный график без двух крупных выбросов, которые заметили выше.
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-й перцентили стоимости заказов на пользователя и выберим границу для определения аномалий.
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.
Начнём с подготовки данных.
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)
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 раз, укажем число совершённых заказов.
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, в которых пользователям из разных групп будет соответствовать количество заказов. Тем, кто ничего не заказал, будут соответствовать нули. Это нужно, чтобы подготовить выборки к проверке критерием Манна-Уитни.
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
)
Задаем функцию, в которой:
# Функция для проверки гипотезы о равенстве групп 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))
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() данные о выручке с заказов. А ещё найдём относительные различия в среднем чеке между группами.
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().
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
16
Комментарий:
Всего 16 аномальных пользователей. Узнаем, как их действия повлияли на результаты теста. Посчитаем статистическую значимость различий в среднем количестве заказов на посетителя между группами теста по очищенным данным. Сначала подготовим выборки количества заказов по пользователям по группам теста.
# Рассчитаем относительные потери
abnormal_users.shape[0] / orders.visitor_id.nunique()
0.01644398766700925
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,
)
stat_significance(sample_a_filtered, sample_b_filtered)
P-value: 0.007 Отвергаем нулевую гипотезу: между группами есть разница Относительный прирост В к А: 18.921%
Комментарий:
На очищенных данных разница в среднем количестве заказов на посетителя между группами есть, а относительный прирост среднего группы В отнистельно группы А увеличился на 15.3%.
Подсчитаем статистическую значимость различий в среднем чеке после удаления аномальных пользователей.
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. В части приоритизации гипотез из списка, предоставленных отделом Маркетинга следует в первую очередь обратить внимание на гипотезы:
Если ранжирование гипотез должно включать в себя и охват пользователей интернет-магазина, то места необходимо распределить таким образом:
2. В части анализа А/В теста:
Есть статистически значимое различие по среднему количеству заказов на посетителя между группами как по «сырым», так и по данным после фильтрации аномалий. Среднее группы В выше, чем в А, на 14-15%;
Нет статистически значимого различия по среднему чеку между группами ни по «сырым», ни по данным после фильтрации аномалий. При этом средний чек группы В выше (на "очищенных" данных - на ~2%);
График относительного изменения кумулятивной среднего количества заказов группы B к группе A показывает, что результаты группы В стабильно лучше группы А;
На основании вышеизложенного рекомендуем остановить тест, зафиксировав победу группы B (ее среднее значительно выше среднего группы А).