Заказчик

Стартап: мобильное приложение по продаже продуктов питания.

Цель проекта

Необходимо проанализировать поведение покупателей на основании логов пользователей и результатов А/А/В - эксперимента (изменение шрифта во всем приложении).

Входные данные

  • логи пользователей мабильного приложения (файл logs_exp.csv)

EventName — название события;
DeviceIDHash — уникальный идентификатор пользователя;
EventTimestamp — время события;
ExpId — номер эксперимента: 246 и 247 — контрольные группы, а 248 — экспериментальная.

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

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

  • Обзор и предобработка данных;
  • Анализ данных, воронка событий;
  • Анализ результатов экспериента;
  • Выводы.
In [1]:
import pandas as pd
import numpy as np

import matplotlib as mpl
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.express as px
import warnings; warnings.filterwarnings(action = 'ignore') 

from scipy import stats as st
from plotly import graph_objects as go
from IPython.display import set_matplotlib_formats
from pandas.plotting import register_matplotlib_converters
register_matplotlib_converters()
In [2]:
# снимаем ограничение на количество столбцов
pd.set_option('display.max_columns', None)

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

# игнорируем предупреждения
pd.set_option('chained_assignment', None)  

# выставляем ограничение на показ знаков после запятой
pd.options.display.float_format = '{:,.2f}'.format

# устанавливаем стиль графиков
sns.set_style('darkgrid')
sns.set(rc={'figure.dpi':200, 'savefig.dpi':300})   
sns.set_context('notebook')    
sns.set_style('ticks')    
In [3]:
# чтение файлов с данными и сохранение в df

try:
    data = pd.read_csv('/datasets/logs_exp.csv', sep='\t')
except:
    data = pd.read_csv('logs_exp.csv', sep='\t')
In [4]:
data.info()
display(data.head(), data.sample(5), data.tail())
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 244126 entries, 0 to 244125
Data columns (total 4 columns):
 #   Column          Non-Null Count   Dtype 
---  ------          --------------   ----- 
 0   EventName       244126 non-null  object
 1   DeviceIDHash    244126 non-null  int64 
 2   EventTimestamp  244126 non-null  int64 
 3   ExpId           244126 non-null  int64 
dtypes: int64(3), object(1)
memory usage: 7.5+ MB
EventName DeviceIDHash EventTimestamp ExpId
0 MainScreenAppear 4575588528974610257 1564029816 246
1 MainScreenAppear 7416695313311560658 1564053102 246
2 PaymentScreenSuccessful 3518123091307005509 1564054127 248
3 CartScreenAppear 3518123091307005509 1564054127 248
4 PaymentScreenSuccessful 6217807653094995999 1564055322 248
EventName DeviceIDHash EventTimestamp ExpId
10766 MainScreenAppear 1836871388568984876 1564649009 247
46451 MainScreenAppear 5807560156115311891 1564731912 246
14163 CartScreenAppear 2013880302311166256 1564655108 247
25748 OffersScreenAppear 8573758417293729771 1564672050 247
7049 MainScreenAppear 8822682017610729706 1564641298 247
EventName DeviceIDHash EventTimestamp ExpId
244121 MainScreenAppear 4599628364049201812 1565212345 247
244122 MainScreenAppear 5849806612437486590 1565212439 246
244123 MainScreenAppear 5746969938801999050 1565212483 246
244124 MainScreenAppear 5746969938801999050 1565212498 246
244125 OffersScreenAppear 5746969938801999050 1565212517 246
In [5]:
# проверка на количество пропущенных значений

data.isna().sum()
Out[5]:
EventName         0
DeviceIDHash      0
EventTimestamp    0
ExpId             0
dtype: int64
In [6]:
# обзор данных

display(data['EventName'].unique())
display(data['ExpId'].unique())
array(['MainScreenAppear', 'PaymentScreenSuccessful', 'CartScreenAppear',
       'OffersScreenAppear', 'Tutorial'], dtype=object)
array([246, 248, 247])
In [7]:
# проверка на явные дубликаты

display(data.duplicated().sum())
data.duplicated().sum() / len(data)
413
Out[7]:
0.0016917493425526163

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

Таблица содержит информацию о 224126-ти действиях пользователей или событий. Самих событий всего пять.

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

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

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

In [8]:
# удалим дубликаты

data = data.drop_duplicates().reset_index(drop=True)

# переименуем столбцы:

data = data.rename(columns={'EventName': 'event', 'DeviceIDHash': 'user_id', 
                            'EventTimestamp': 'date_time', 'ExpId': 'group_id'})

# заменим тип данных `datetime`: 

data['date_time'] = pd.to_datetime(data['date_time'], unit='s')

# добавим поле с датой:

data['date'] = pd.to_datetime(data['date_time'].dt.date)
data.sample(5)
Out[8]:
event user_id date_time group_id date
103397 MainScreenAppear 3034063235370171931 2019-08-03 18:14:51 247 2019-08-03
168135 CartScreenAppear 9066529870075932190 2019-08-05 16:45:53 246 2019-08-05
116826 MainScreenAppear 3572316166490242010 2019-08-04 09:44:22 247 2019-08-04
97341 MainScreenAppear 2438520924615884865 2019-08-03 15:19:55 246 2019-08-03
13312 MainScreenAppear 2823790631517317353 2019-08-01 10:00:49 247 2019-08-01

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

Итак, мы:

  • удалили явные дубликаты.
  • переименовали столбцы,
  • добавили новое поле с датой, а старое привели к удобному формату

Но перед анализом необходимо проверить, что пользователи в группах не пересекаются. Это важно для чистоты эксперимента.

In [9]:
data.groupby('user_id', as_index=False).agg({'group_id':'nunique'}).query('group_id > 1')
Out[9]:
user_id group_id

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

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

Анализ данных. Воронка событий¶

Количество логов и пользователей¶

Давайте посмотрим, сколько всего событий и сколько пользователей в логе. А так же сколько в среднем событий приходится на пользователя.

In [10]:
print('Всего событий: %d \nВидов событий %d \nВсего пользователей : %d' % (data.shape[0], 
                                                                   data['event'].nunique(), 
                                                                   data['user_id'].nunique()))
print('В среднем, событий на пользователя: %d' % (len(data) / data['user_id'].nunique()))
Всего событий: 243713 
Видов событий 5 
Всего пользователей : 7551
В среднем, событий на пользователя: 32
In [11]:
# посмотрим сколько событий в среднем приходится на пользователя

events_by_user = data.groupby('user_id', as_index=False).agg(event_count = ('event', 'count'))
display(events_by_user.event_count.describe())

plt.figure(figsize=(15, 5))
plt.title('Распрпеделение количества событий на пользователя', loc='center')
sns.histplot(events_by_user.event_count, bins=200)
plt.xlabel('Количество событий')
plt.ylabel('Количество пользователей')
plt.show()
count   7,551.00
mean       32.28
std        65.15
min         1.00
25%         9.00
50%        20.00
75%        37.00
max     2,307.00
Name: event_count, dtype: float64

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

В среднем, на пользователя приходится порядка 32-х событий. При этои, минимальное количество - всего одно, а максимальное - 2307 шт. Если говорить о медиане - это 20 событий на пользователя.

Период¶

In [12]:
data['date_time'].describe()
Out[12]:
count                  243713
unique                 176654
top       2019-08-01 14:40:35
freq                        9
first     2019-07-25 04:43:36
last      2019-08-07 21:15:17
Name: date_time, dtype: object

Данные содержат информацию с 25/07/2019 по 07/08/2019.

In [13]:
# гистограммы по дате и времени:

plt.title('Распределение логов по дате и времени', loc='center')
data['date_time'].hist(bins=100, xrot=15,  figsize=(15, 5), alpha=0.8)
plt.show()

plt.title('Распределение логов по времени суток', loc='center')
data['date_time'].dt.hour.hist(bins=24, figsize=(15, 5), alpha=0.8)
plt.xticks(range(0, 23))
plt.show()

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

На первой гистограмме видно, что данные за июль - неполные. Технически в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого — это может «перекашивать данные». Поэтому, чтобы избежать данной проблемы, берем данные с 01-08-2019 по 07/08/2019. Так же, мы видим, что основная активность пользователей происходит в дневное время.

In [14]:
# Отбросим старые данные и посмотрим, сколько событий и пользователей мы потеряли.

before_logs = data.shape[0]
before_users = data.user_id.nunique()

print('ДО корректировки периода всего событий: %d, всего пользователей: %d.' % (before_logs, 
                                                                                before_users))
data = data[data['date_time'] >= '2019-08-01']
print()

print('ПОСЛЕ корректировки периода всего событий: %d, всего пользователей: %d.' % (data.shape[0], 
                                                                                   data['user_id'].nunique()))
print()

# diff: 

print('Изменение количества логов:', 
      data.shape[0]- before_logs, 
      '({:.1%})'.format((data.shape[0]-before_logs)/before_logs))
print('Изменение количества пользователей:', 
      data['user_id'].nunique()- before_users, 
      '({:.1%})'.format((data['user_id'].nunique()-before_users)/before_users))
ДО корректировки периода всего событий: 243713, всего пользователей: 7551.

ПОСЛЕ корректировки периода всего событий: 240887, всего пользователей: 7534.

Изменение количества логов: -2826 (-1.2%)
Изменение количества пользователей: -17 (-0.2%)
In [15]:
# Теперь посмотрим на распределения в тестовых группах
# (по количеству уникальных пользователей и по количеству событий):

display(data.groupby('group_id', as_index=False).agg({'user_id': 'nunique'}),

data.groupby('group_id', as_index=False).agg(event_count = ('user_id', 'count')))
group_id user_id
0 246 2484
1 247 2513
2 248 2537
group_id event_count
0 246 79302
1 247 77022
2 248 84563

Комментарий:
Количество пользователей в группах в общем и целом сопоставимо, количество событий немного разнится. Группа 248 имеет больше всего пользователей и событий.

Изучим воронку событий¶

Посмотрим, какие события есть в логах, как часто они встречаются.

In [16]:
events = (data.
          groupby('event', as_index=False).
          agg({'user_id': 'count'}).
          rename(columns={'user_id' : 'total_events'}).
          sort_values(by='total_events', ascending=False))
display(events)
plt.figure(figsize=(15, 5))
ax = sns.color_palette("flare", as_cmap=True)
ax = sns.barplot(x='total_events', y='event', data=events)
ax.set_title('Частота событий в логах')
ax.set_xlabel('Количество') 
ax.set_ylabel('') 
plt.show()
event total_events
1 MainScreenAppear 117328
2 OffersScreenAppear 46333
0 CartScreenAppear 42303
3 PaymentScreenSuccessful 33918
4 Tutorial 1005

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

  • MainScreenAppear (Главный экран) увидели 117328 раз;
  • OffersScreenAppear (Каталог предложений) увидели 46333 раза;
  • CartScreenAppear (Карточка товара) увидели 42303 раза;
  • PaymentScreenSuccessful (Экран с подтверждением успешной оплаты) увидели 33918 раз;
  • Tutorial (Урок) просмотрели 1005 раз.
In [17]:
# Отсортируем события по числу пользователей.
# Посчитаем долю пользователей, которые хоть раз совершали событие.

funnel = (data.
          groupby('event', as_index=False).
          agg({'user_id': 'nunique'}).
          rename(columns={'user_id' : 'total_users'}).
          sort_values(by='total_users', ascending=False))
funnel['percent'] = round(funnel['total_users'] / data['user_id'].nunique() * 100, 2)
funnel
Out[17]:
event total_users percent
1 MainScreenAppear 7419 98.47
2 OffersScreenAppear 4593 60.96
0 CartScreenAppear 3734 49.56
3 PaymentScreenSuccessful 3539 46.97
4 Tutorial 840 11.15

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

  • Главную страницу увидели 7419 пользователей (98.5% от общего числа пользователей) - почти все
  • Каталог предложений просмотрели 4593 пользователей (61% от общего числа)
  • Карточку товара 3734 пользователя (49.6% от общего числа)
  • Завершили оплату 3539 пользователей (47% от общего числа)
  • Урок просмотрели 840 пользователей (11% от общего числа)

Мы видим, что все события выстраиваются в цепочку действий, кроме просмотра урока (Tutorial). Для того, чтобы посмотреть урок (как пользоваться приложением?) не обязательно нужно что-то купить. Так же, чаще всего, интерфейс является интуитивно понятным. Поэтому шаг Tutorial из дальнейшей цепочки уберем.

Тогда последовательность действий видится такой:

  • Главный экран
  • Каталог предложений
  • Карточка товара
  • Экран с подтверждением успешной оплаты

Мы определились с последовательносью действий для воронки событий. Посчитаем, какая доля пользователей проходит на следующий шаг воронки (от числа пользователей на предыдущем). То есть для последовательности событий A → B → C → D посчитаем отношение числа пользователей с событием B к количеству пользователей с событием A, отношение числа пользователей с событием C к количеству пользователей с событием B и т.д.

In [18]:
# считае % по шагам (от предыдущего шага)

users = data.pivot_table(
    index='user_id', 
    columns='event', 
    values='date_time',
    aggfunc='min')

print('Посетителей всего:', 
      '({:.1%})'.format(users['MainScreenAppear'].count() / users['MainScreenAppear'].count() ))
print('Просмотрели Каталог в % от предыдущего шага:', 
      '({:.1%})'.format(users['OffersScreenAppear'].count() / users['MainScreenAppear'].count() ))
print('Просмотрели Карточку товара в % от предыдущего шага:', 
      '({:.1%})'.format(users['CartScreenAppear'].count() / users['OffersScreenAppear'].count()))
print('Оплатили в % от предыдущего шага:',
      '({:.1%})'.format(users['PaymentScreenSuccessful'].count() / users['CartScreenAppear'].count()))
Посетителей всего: (100.0%)
Просмотрели Каталог в % от предыдущего шага: (61.9%)
Просмотрели Карточку товара в % от предыдущего шага: (81.3%)
Оплатили в % от предыдущего шага: (94.8%)
In [19]:
funnel = funnel[funnel['event'] != 'Tutorial']

fig = go.Figure(go.Funnel(y = funnel['event'],
                          x = funnel['total_users'],
                          opacity = 0.8,
                          textposition = 'inside',
                          textinfo = 'value + percent previous'))
fig.update_layout(title_text='Воронка событий')
fig.layout.template = 'plotly_white'
fig.show()

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

Мы видим, что наибольшее количество пользователей теряется после первого шага (более 38%). И только 48% от первоначального количества пользователей успешно оплачивают товары из корзины.

Изучите результаты А/А/В-эксперимента¶

Мы знаем, что у нас есть 2 контрольные группы для А/А-эксперимента (246 и 247), чтобы проверить корректность всех механизмов и расчётов, и одна тестовая группа В (248).

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

In [20]:
data.groupby('group_id', as_index=False).agg(count=('user_id', 'nunique'))
Out[20]:
group_id count
0 246 2484
1 247 2513
2 248 2537
In [21]:
funnel_group = (data.
          groupby(['event', 'group_id'], as_index=False).
          agg({'user_id': 'nunique'}).
          rename(columns={'user_id' : 'total_users'}).
          sort_values(by=['group_id','total_users'], ascending=False))

funnel_group = funnel_group[funnel_group['event'] != 'Tutorial']
funnel_group.style.background_gradient(sns.light_palette("brown", as_cmap=True))
Out[21]:
event group_id total_users
5 MainScreenAppear 248 2493
8 OffersScreenAppear 248 1531
2 CartScreenAppear 248 1230
11 PaymentScreenSuccessful 248 1181
4 MainScreenAppear 247 2476
7 OffersScreenAppear 247 1520
1 CartScreenAppear 247 1238
10 PaymentScreenSuccessful 247 1158
3 MainScreenAppear 246 2450
6 OffersScreenAppear 246 1542
0 CartScreenAppear 246 1266
9 PaymentScreenSuccessful 246 1200
In [22]:
fig = go.Figure()

fig.add_trace(go.Funnel(name = '246',
                        y = funnel_group.query('group_id == 246')['event'],
                        x = funnel_group.query('group_id == 246')['total_users'],
                        opacity = 0.8,
                        textposition = 'inside',
                        textinfo = 'value + percent previous'))

fig.add_trace(go.Funnel(name = '247',
                        y = funnel_group.query('group_id == 247')['event'],
                        x = funnel_group.query('group_id == 247')['total_users'],
                        opacity = 0.8,
                        textposition = 'inside',
                        textinfo = 'value + percent previous'))


fig.add_trace(go.Funnel(name = '248',
                        y = funnel_group.query('group_id == 248')['event'],
                        x = funnel_group.query('group_id == 248')['total_users'],
                        opacity = 0.8,
                        textposition = 'inside',
                        textinfo = 'value + percent previous'))
                        
fig.update_layout(title_text='Воронка событий в разрезе тестовых групп')
fig.layout.template = 'plotly_white'
fig.show()

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

Внешне, группы очень похожи. В А/В-тестировании проверяем гипотезу о равенстве выборок, но сначала проверим находят ли статистические критерии разницу между выборками 246 и 247 (А/А-тест).Используем Z-критерий (статистический тест, позволяющий определить, различаются ли два средних значения генеральной совокупности, когда дисперсии известны и размер выборки велик). Для удобства, напишем функцию.

In [23]:
def z_test(df1, df2, event, alpha, n):
    '''    
Функция принимает на вход два датафрейма с логами и по заданному событию попарно проверяет 
есть ли статистически значимая разница между долями пользователей, совершивших его в группе 1 и группе 2.

Входные параметры:
    - df1, df2 - датафреймы с логами
    - event - событие
    - alpfa - критический уровень статистической значимости
    - n - поправка Боннферони для критического уровня статистической значимости
    '''    
    
    # критический уровень статистической значимости c поправкой Бонферрони
    bonferroni_alpha = alpha / n
 
    # число пользователей в группе 1 и группе 2:
    n_users = np.array([df1['user_id'].nunique(), 
                        df2['user_id'].nunique()])

    # число пользователей, совершивших событие в группе 1 и группе 2
    success = np.array([df1[df1['event'] == event]['user_id'].nunique(), 
                        df2[df2['event'] == event]['user_id'].nunique()])

    # пропорции успехов в группах:
    p1 = success[0] / n_users[0]
    p2 = success[1] / n_users[1]
    
    # пропорция успехов в комбинированном датасете:
    p_combined = (success[0] + success[1]) / (n_users[0] + n_users[1])

    # разница пропорций в датасетах
    difference = p1 - p2 

    # считаем статистику в ст.отклонениях стандартного нормального распределения
    z_value = difference /  np.sqrt(p_combined * (1 - p_combined) * (1/n_users[0] + 1/n_users[1]))

    # задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
    distr = st.norm(0, 1)  

    p_value = (1 - distr.cdf(abs(z_value))) * 2   #тест двусторонний, удваиваем результат
    
    print('Событие:', event)
    print('p-значение: ', p_value)

    if p_value < bonferroni_alpha:
        print('Отвергаем нулевую гипотезу: между долями есть разница')
    else:
        print(
        'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')

Согласно предложенному процессу, нам нужно будет сопоставить доли по каждому событию между:

  • контрольными группами 246 и 247;
  • каждой из контрольной группы по отдельности и экспериментальной (246-248 и 247-248);
  • объединенной контрольной группой и экспериментальной (246+247 и 248).

Всего у нас 4 вида событий, 4 A/A теста и 12 А/В, следовательно для всех тестов мы вводим поправку Бонферрони bonferroni_alpha = alpha / 4 или bonferroni_alpha = alpha / 12, чтобы застраховать себя от ложного результата.

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

$\begin{equation*} \begin{cases} H_0 :\text{доли уникальных посетителей, побывавших на этапе воронки, одинаковы}\\ H_1 :\text{доли уникальных посетителей, побывавших на этапе воронки, отличаются} \end{cases} \end{equation*}$

In [24]:
# проверим, есть ли статистически значимая разница между контрольными группами 246 и 247:

for event in funnel_group['event'].unique():
    z_test(data[data['group_id'] == 246], data[data['group_id'] == 247], event,.05, 4)
    print()
Событие: MainScreenAppear
p-значение:  0.7570597232046099
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: OffersScreenAppear
p-значение:  0.2480954578522181
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: CartScreenAppear
p-значение:  0.22883372237997213
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: PaymentScreenSuccessful
p-значение:  0.11456679313141849
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Комментарий:
Главная цель А/А-теста — показать, можно ли доверять результатам эксперимента, который будет запущен в тех же условиях, но уже с разными вариантами шрифта. Если в ходе А/А-теста победителя выявить не удалось, можно запускать А/B-тест.

Между группами 246 и 247 ни по одному событию нет статистически достоверного отличия при заданном уровне значимости, а значит, приступаем к A/B-тестированию.

In [25]:
# проверим, есть ли статистически значимая разница между контрольными группами 246 и 248:

for event in funnel_group['event'].unique():
    z_test(data[data['group_id'] == 246], data[data['group_id'] == 248], event, .05, 12)
    print()
Событие: MainScreenAppear
p-значение:  0.2949721933554552
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: OffersScreenAppear
p-значение:  0.20836205402738917
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: CartScreenAppear
p-значение:  0.07842923237520116
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: PaymentScreenSuccessful
p-значение:  0.2122553275697796
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Комментарий:
При заданном уровне значимости у нас нет оснований считать группы 246 и 248 разными.

In [26]:
# проверим, есть ли статистически значимая разница между контрольными группами 247 и 248:

for event in funnel_group['event'].unique():
    z_test(data[data['group_id'] == 247], data[data['group_id'] == 248], event,.05, 12)
    print()
Событие: MainScreenAppear
p-значение:  0.4587053616621515
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: OffersScreenAppear
p-значение:  0.9197817830592261
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: CartScreenAppear
p-значение:  0.5786197879539783
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: PaymentScreenSuccessful
p-значение:  0.7373415053803964
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Комментарий:
При заданном уровне значимости различия между группами 247 и 248 не обнаружились.

In [27]:
# проверим есть ли статистически значимая разница между объединённой контрольной и экпериментальной 248 группами:

for event in funnel_group['event'].unique():
    z_test(data[data['group_id'] != 248], data[data['group_id'] == 248], event,.05,12)
    print()
Событие: MainScreenAppear
p-значение:  0.29424526837179577
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: OffersScreenAppear
p-значение:  0.43425549655188256
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: CartScreenAppear
p-значение:  0.18175875284404386
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: PaymentScreenSuccessful
p-значение:  0.6004294282308704
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Комментарий:
Аналогичный финал проверки гипотез по последним парам. ___ Фиксируем, что изменение шрифтов во всём приложении на поведение пользователей не повлияло.

Вывод¶

В результате исследования были проанализированы поведение покупателей на основании логов пользователей, а так же, результаты А/А/В-теста. После предобработки данных было рассмотрено поведение 7419-ти пользователей мобильного приложения.

Было выявлено, что:

  • Главную страницу увидели 7419 пользователей (100% от общего числа пользователей);
  • Страницу товара просмотрели 4593 пользователей (61,9% от общего числа);
  • Карточку просмотрели 3734 пользователя (50.3% от общего числа);
  • Завершили оплату 3539 пользователей (47,7% от общего числа).

Еще одно событие (Tutorial) было исключено из анализа ввиду необязательного прохождения и отсутствия влияния на остальные шаги.

БОльшее количество пользователей приложение теряло после первого шага (более 38%), чуть менее 9% на следующем и около 2% при переходе на последний шаг. Т.е. примерно 48% пользователей воспользовались приложением и оплатили заказ. ______ Далее, был проанализирован результат А/А/В-эксперимента(изменение шрифта во всем приложении), для этого были ипользованы логи событий за неделю (с 01/08/2019 по 07/08/2019).

В эксперименте были учтено поведение пользователей, разделенных на три группы:

  • 246-ая - 2484 пользователя;
  • 247-ая - 2513 пользователя;
  • 248-ая - 2537 пользователя.

Согласно предложенному процессу, нам нужно было сопоставить доли пользователей по каждому событию между:

  • контрольными группами 246 и 247;
  • каждой из контрольной группы по отдельности и экспериментальной (246-248 и 247-248);
  • объединенной контрольной группой и экспериментальной (246+247 и 248).

Множесто А/В-тестов, проведённых по каждому из событий, не обнаружили статистически значимой разницы между группами. Т.е. изменение шрифтов во всём приложении на поведение пользователей не повлияло.

Дополнительное задание¶

1. Автоматизировать расчет доли пользователей, переходящих на следующий шаг воронки, а не рассчитывать вручную для каждого события. Можно воспользоваться циклом, можно использовать метод shift. К тому же это достаточно распространенная аналитическая задача,
In [28]:
# Циклом

funnel['cycle'] = 0
for i in range(0, len(funnel['percent'])):
    if i == 0:
        funnel['cycle'].iloc[i] = 100
    else:
        funnel['cycle'].iloc[i] = round(int(funnel['total_users'].iloc[i]) / int(funnel['total_users'].iloc[i-1]), 2) * 100
funnel
Out[28]:
event total_users percent cycle
1 MainScreenAppear 7419 98.47 100
2 OffersScreenAppear 4593 60.96 62
0 CartScreenAppear 3734 49.56 81
3 PaymentScreenSuccessful 3539 46.97 95
In [29]:
# Методом shift

funnel['shift'] = (round(funnel['total_users'] / funnel.shift(1)['total_users'], 2) * 100).fillna(100)
funnel
Out[29]:
event total_users percent cycle shift
1 MainScreenAppear 7419 98.47 100 100.00
2 OffersScreenAppear 4593 60.96 62 62.00
0 CartScreenAppear 3734 49.56 81 81.00
3 PaymentScreenSuccessful 3539 46.97 95 95.00

Дополнительные ссылки¶

По ссылке отличное видео про применение оконок в пандасе. Про шифт тоже есть

https://www.youtube.com/watch?v=yQ7qHZBY5xI&t=1s&ab_channel=karpov.courses


2.1Переделать шаг проверки гипотез:

Сначала подготовить данные, по которым будет идти проверка (сводная таблица с количеством пользователей совершивших каждое из событий)

In [30]:
funnel_hypothesis = data.pivot_table(index='event', columns='group_id', \
                                     values='user_id',aggfunc='nunique',
                                     margins=True) \
                        .reset_index().sort_values('All', ascending=False) \
                        .reset_index(drop=True)
funnel_hypothesis = funnel_hypothesis.reindex([1, 2, 3, 4, 0]).reset_index(drop=True)
funnel_hypothesis['246/247'] = funnel_hypothesis[246] + funnel_hypothesis[247]
funnel_hypothesis
Out[30]:
group_id event 246 247 248 All 246/247
0 MainScreenAppear 2450 2476 2493 7419 4926
1 OffersScreenAppear 1542 1520 1531 4593 3062
2 CartScreenAppear 1266 1238 1230 3734 2504
3 PaymentScreenSuccessful 1200 1158 1181 3539 2358
4 All 2484 2513 2537 7534 4997
2.2 Написать функцию, которая принимает в качестве аргументов 4 числа для z-теста пропорций

(в теле функции не должно быть ни цикла, ни работы с датафреймом, только мат. вычисления для стат. теста)

In [31]:
def z_test_return(x1, x2, y1, y2):
    
    p1 = x1/y1
    p2 = x2/y2
    
    p_combined = (x1 + x2) / (y1 + y2)
    difference = p1 - p2 
    
    z_value = difference / (p_combined * (1 - p_combined) * (1/y1 + 1/y2)) ** 0.5
    distr = st.norm(0, 1)
    
    p_value = (1 - distr.cdf(abs(z_value))) * 2
    return p_value
2.3 Для каждой пары групп вызывать функцию в цикле, итерируясь по датафрейму с данными для проверки гипотез (не 1 цикл, а для каждой пары групп, т.е. всего 4 цикла)
In [32]:
# Группы 246-247

alpha = 0.05

for i in range(0, 4):
    for j in range(1, 2):
        if j < 3:
            k = j + 1
        else:
            k = 1
    
        p_value = z_test_return(funnel_hypothesis.iloc[i, j], funnel_hypothesis.iloc[i, k], 
                                funnel_hypothesis.iloc[4, j], funnel_hypothesis.iloc[4, k])
        print('Событие:', funnel_hypothesis.event.unique()[i])
        print('p-значение:', p_value)

        if p_value < alpha:
            print('Отвергаем нулевую гипотезу: между долями есть разница')
        else:
            print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
        print('--------------------------------------')
Событие: MainScreenAppear
p-значение: 0.7570597232046099
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
Событие: OffersScreenAppear
p-значение: 0.2480954578522181
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
Событие: CartScreenAppear
p-значение: 0.22883372237997213
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
Событие: PaymentScreenSuccessful
p-значение: 0.11456679313141849
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
In [33]:
# Группы 247-248

alpha = 0.05

for i in range(0, 4):
    for j in range(2, 3):
        if j < 3:
            k = j + 1
        else:
            k = 1
    
        p_value = z_test_return(funnel_hypothesis.iloc[i, j], funnel_hypothesis.iloc[i, k], 
                                funnel_hypothesis.iloc[4, j], funnel_hypothesis.iloc[4, k])
        print('Событие:', funnel_hypothesis.event.unique()[i])
        print('p-значение:', p_value)

        if p_value < alpha:
            print('Отвергаем нулевую гипотезу: между долями есть разница')
        else:
            print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
        print('--------------------------------------')
Событие: MainScreenAppear
p-значение: 0.4587053616621515
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
Событие: OffersScreenAppear
p-значение: 0.9197817830592261
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
Событие: CartScreenAppear
p-значение: 0.5786197879539783
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
Событие: PaymentScreenSuccessful
p-значение: 0.7373415053803964
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
In [34]:
# Группы 248-246

alpha = 0.05

for i in range(0, 4):
    for j in range(3, 4):
        if j < 3:
            k = j + 1
        else:
            k = 1
    
        p_value = z_test_return(funnel_hypothesis.iloc[i, j], funnel_hypothesis.iloc[i, k], 
                                funnel_hypothesis.iloc[4, j], funnel_hypothesis.iloc[4, k])
        print('Событие:', funnel_hypothesis.event.unique()[i])
        print('p-значение:', p_value)

        if p_value < alpha:
            print('Отвергаем нулевую гипотезу: между долями есть разница')
        else:
            print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
        print('--------------------------------------')
Событие: MainScreenAppear
p-значение: 0.2949721933554552
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
Событие: OffersScreenAppear
p-значение: 0.20836205402738917
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
Событие: CartScreenAppear
p-значение: 0.07842923237520116
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
Событие: PaymentScreenSuccessful
p-значение: 0.2122553275697796
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
In [35]:
# Группы 246/247-248

alpha = 0.05

for i in range(0, 4):
    for j in range(4, 5):
        if j < 3:
            k = j + 1
        else:
            k = 1
    
        p_value = z_test_return(funnel_hypothesis.iloc[i, 3], funnel_hypothesis.iloc[i, 5], 
                                funnel_hypothesis.iloc[4, 3], funnel_hypothesis.iloc[4, 5])
        print('Событие:', funnel_hypothesis.event.unique()[i])
        print('p-значение:', p_value)

        if p_value < alpha:
            print('Отвергаем нулевую гипотезу: между долями есть разница')
        else:
            print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
        print('--------------------------------------')
Событие: MainScreenAppear
p-значение: 0.29424526837179577
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
Событие: OffersScreenAppear
p-значение: 0.43425549655188256
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
Событие: CartScreenAppear
p-значение: 0.18175875284404386
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
Событие: PaymentScreenSuccessful
p-значение: 0.6004294282308704
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
--------------------------------------
3. Ты выбираешь уровень значимости 0.05, это означает, что допустимая вероятность ложположительного результата для одного теста - 5%. У нас 16 тестов, какой в таком случае становится допустимая вероятность ложноположительного результата хотя бы в одном из тестов?

Задание №3¶

Ну я думаю, чтобы определить допустимую вероятность ложноположительного результата хотя бы в одном из 16 тестов, мы можем использовать формулу вероятности обратного события:

$$P( \overline A) = 1 - P(A)$$

P(хотя бы один ложноположительный результат) = 1 - P(ни одного ложноположительного результата)

In [1]:
print(f'Вероятность того, что не будет ни одного ложноположительного результата при 16 тестах, равна: {round(((1 - 0.05)**16) * 100, 1)}%')
print(f'Вероятность того, что хотя бы один тест даст ложноположительный результат, равна: {round((1 - (1 - 0.05)**16) * 100, 1)}%')
Вероятность того, что не будет ни одного ложноположительного результата при 16 тестах, равна: 44.0%
Вероятность того, что хотя бы один тест даст ложноположительный результат, равна: 56.0%
In [ ]:
 

Дополнительные ссылки¶

Если есть желание поглубже познакомиться со статистикой, тервером и аб тестами (при этом глубоко не погружаясь в математические дебри) рекомендую посмотреть цикл лекций Глеба Михайлова (возможно он наставник у тебя)

https://www.youtube.com/playlist?list=PLQJ7ptkRY-xbHLLI66KdscKp_FJt0FsIi

И отличная статья про структуры данных в пандас

http://datalytics.ru/all/uglublennoe-izuchenie-pandas-struktury-dannyh/