Исследование мобильного приложения по продаже продуктов¶

Описание проекта

Заказчик — менеджеры стартапа, продающего продукты питания. Нужно разобраться, как ведут себя пользователи мобильного приложения:

  1. Изучить воронку продаж:

    • узнать, как пользователи доходят до покупки
    • сколько пользователей доходит до покупки, а сколько — «застревает» на предыдущих шагах?
    • на каких именно?
  2. Исследовать результаты A/A/B-эксперимента:

    • дизайнеры захотели поменять шрифты во всём приложении, но менеджеры засомневались, что пользователям будет непривычно
    • договорились принять решение по результатам A/A/B-теста
    • пользователей разбили на 3 группы: 2 контрольные со старыми шрифтами и одну экспериментальную — с новыми
    • нужно выяснить, какой шрифт лучше?

Содержание:

  1. Исследовательский анализ данных
  2. Анализ воронки событий
  3. Исследование результатов эксперимента
  4. Выбор уровня статистической значимости

👉 Итоги исследования здесь

Описание данных

В таблице logs_exp.csv логи действий пользователя (событий) мобильного приложения.

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

Общая информация о данных¶

In [40]:
# импорт библиотек
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import display, Markdown, IFrame
import os
import seaborn as sns
import numpy as np
from datetime import datetime as dt
import matplotlib.ticker as mticker
import scipy.stats as st
from plotly import graph_objects as go
import math as mth
import sys
from statsmodels.stats.proportion import proportions_ztest
In [41]:
# проверка на возможность выполнения Mardown
if 'ipykernel' in sys.modules:
    flag_md = 1
else:
    flag_md = 0
In [42]:
# чтение файла с данными и сохранение в датафрейм
dir1 = '/datasets/'
dir2 = 'C:/Users/user/Downloads/'
event_pth = 'logs_exp.csv'


if os.path.exists(dir1):
    df_event = pd.read_csv(dir1 + event_pth, sep='\t')
elif os.path.exists(dir2):
    df_event = pd.read_csv(dir2 + event_pth, sep='\t')
else:
    print('Something is wrong')
In [43]:
# датафрейм о событиях `df_event`
# вывод первых 5 строчек 
display(df_event.head())

# вывод основной информации
df_event.info()

# гистограмма для столбца `EventName`
sns.countplot(
    data=df_event,
    x="EventName", 
    order=df_event['EventName'].value_counts().index
)
plt.title('Распределение событий')
plt.ylabel('Количество')
plt.xlabel('Событие')
plt.xticks(rotation=45)
plt.show()
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
<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
No description has been provided for this image

Выводы:

  1. В таблице нет пропущенных значений

  2. Необходимо переименовать столбцы

  3. Необходимо добавить столбцы для даты и времени, даты событий

  4. Таблицу необходимо проверить на наличие дубликатов

  5. Необходимо проверить корректность результатов проведения тестов

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

Переименование столбцов¶

In [44]:
# переименование столбцов
df_event = df_event.rename(
    columns={
        'EventName': 'event_name',
        'DeviceIDHash': 'user_id',
        'EventTimestamp': 'event_ts',
        'ExpId': 'exp_group'
    }
)

Добавление столбцов¶

Добавим столбец event_dt для даты и времени событий и столбец event_date для даты событий. Преобразуем временную метку в формате Unix из столбца event_ts.

In [45]:
# создание столбцов
df_event['event_dt'] = pd.to_datetime(df_event['event_ts'], unit='s')
df_event['event_date'] = df_event['event_dt'].dt.date

Проверка наличия дубликатов¶

Проверим наличие явных дубликатов

In [46]:
# вывод названий таблиц и количество явных дубликатов в них
message = (
    f'Удалим **{df_event.duplicated().sum()}**'
    f' явных дубликатов строк'
) if df_event.duplicated().sum() > 0 else f'Нет явных дубликатов в таблице'

if flag_md == 1:
    display(Markdown(message))
else:
    print(message.replace("*", ""))

Удалим 413 явных дубликатов строк

In [47]:
# удаление явных дубликатов
df_event = df_event.drop_duplicates()

Проверим наличие неявных дубликатов в столбцах event_name и exp_group

In [48]:
# вывод отсортированного уникального списка значений в 'event_name'
display(df_event['event_name'].sort_values().unique())

# вывод отсортированного уникального списка значений в 'exp_group'
display(df_event['exp_group'].sort_values().unique())
array(['CartScreenAppear', 'MainScreenAppear', 'OffersScreenAppear',
       'PaymentScreenSuccessful', 'Tutorial'], dtype=object)
array([246, 247, 248])

Неявных дубликатов не обнаружено.

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

Проверим попадание действий одних и тех же user_id в разные группы тестирования.

In [49]:
# запись количества уникальных групп для каждого user_id в столбец group_count
df_event['group_count'] = df_event.groupby('user_id')['exp_group'].transform('nunique')

# запись в переменную списка user_id, которые находятся в более чем одной группе
multigroup_visitors = df_event.query('group_count > 1')['user_id'].unique()

# вывод результата
message = (
    f'Есть **{len(multigroup_visitors)}** посетителей, чьи действия попали в разные группы тестирования.\n\n'
    f'Это составляет **{len(multigroup_visitors) / df_event["user_id"].nunique():.0%}** от всех посетителей.\n\n'
    f'Исключим из дальнейшего исследования действия пользователей, попавших в разные группы А/А/В теста.'
) if len(multigroup_visitors) > 0 else f'Нет `user_id`, чьи действия попали в разные группы.'

if flag_md == 1:
    display(Markdown(message))
else:
    print(message.replace("*", ""))

Нет user_id, чьи действия попали в разные группы.

Выводы:

  1. Переименовали и создали новые столбцы

  2. Удалены явные дубликаты. Неявные дубликаты в таблицах не обнаружены

  3. Система распределения пользователей по тестовым группам отработала корректно

Исследовательский анализ данных¶

Пользователи и события¶

Оценим количество количество событий и пользователей. Рассчитаем среднее количество событий на пользователя.

In [50]:
# количество событий
total_events = len(df_event)

# уникальные пользователи
unique_users = df_event['user_id'].nunique()

# среднее количество событий на пользователя
average_events = total_events / unique_users

# вывод результата
message = (
    f'Всего событий: **{total_events:,}**\n\n'
    f'Уникальных пользователей: **{unique_users:,}**\n\n'
    f'Среднее количество событий на одного пользователя: **{average_events:.2f}**'.replace(",", " ")
)

if flag_md == 1:
    display(Markdown(message))
else:
    print(message.replace("*", ""))

Всего событий: 243 713

Уникальных пользователей: 7 551

Среднее количество событий на одного пользователя: 32.28

Анализ периода полных данных¶

Найдем максимальную и минимальную даты в логе.

In [51]:
# вывод результата
message = (
    f'Минимальная дата: **{df_event["event_date"].min()}**\n\n'
    f'Максимальная дата: **{df_event["event_date"].max()}**'
)

if flag_md == 1:
    display(Markdown(message))
else:
    print(message.replace("*", ""))

Минимальная дата: 2019-07-25

Максимальная дата: 2019-08-07

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

In [52]:
# задание размера графика
plt.figure(figsize=(12, 6))

# вывод гистограммы для df_event
sns.histplot(
    df_event,
    x="event_date", 
    edgecolor=".3",
    linewidth=.5,
    hue='exp_group',
    multiple='dodge',
    shrink=.8
)
plt.title('Распределение событий')
plt.ylabel('Количество событий')
plt.xlabel('Даты')
plt.xticks(ticks=df_event['event_date'].unique()[::1], rotation=45)
plt.show()
No description has been provided for this image

Для анализа будем использовать период полных данных с 2019-08-01 по 2019-08-07. Исключим из анализа старые данные и оценим количество "потерянных" событий, пользователей и их доли от исходного количества.

In [53]:
# начальная дата анализа
start_date = pd.to_datetime('2019-08-01')

# исключение старых событий
df_event = df_event.query('event_dt >= @start_date')

# потерянные события
lost_events = total_events - len(df_event)

# доля потерянных событий
rate_lost_events = lost_events / total_events

# потерянные пользователи
lost_users = unique_users - df_event["user_id"].nunique()

# доля потерянных пользователей
rate_lost_users = lost_users / unique_users

# вывод переменных
message = (
    f'Исключили: **{lost_events}** событий, **{rate_lost_events:.2%}** от исходного количества\n\n'
    f'Исключили: **{lost_users}** пользователей, **{rate_lost_users:.2%}** от исходного количества'
)

if flag_md == 1:
    display(Markdown(message))
else:
    print(message.replace("*", ""))

Исключили: 2826 событий, 1.16% от исходного количества

Исключили: 17 пользователей, 0.23% от исходного количества

Проверим наличие пользователей из всех трёх тестовых групп.

In [54]:
# распределение пользователей по тестовым группам
df_agg = df_event.groupby('exp_group', as_index=False)['user_id'].nunique()
df_agg.columns = ('exp_group', 'user_count')
df_agg
Out[54]:
exp_group user_count
0 246 2484
1 247 2513
2 248 2537

Выводы

  1. Для анализа будем использовать период полных данных с 2019-08-01 по 2019-08-07.

  2. Старые данные исключили из дальнейшего анализа - потеряли 1.16% событий и 0.23% пользователей

  3. Пользователи присутствуют во всех трех тестовых группах

Анализ воронки событий¶

Обзор событий¶

Оценим числовое и графическое распределение событий в event_name.

In [55]:
# количество событий по убыванию
event_counts = df_event['event_name'].value_counts().reset_index()
event_counts
Out[55]:
event_name count
0 MainScreenAppear 117328
1 OffersScreenAppear 46333
2 CartScreenAppear 42303
3 PaymentScreenSuccessful 33918
4 Tutorial 1005
In [56]:
# вывод гистограммы для столбца `event_name`
plt.figure(figsize=(12, 6))
sns.histplot(
    df_event,
    x="event_name", 
    edgecolor=".3",
    linewidth=.5,
    shrink=.9
)
plt.title('Распределение событий')
plt.ylabel('Количество')
plt.xlabel('Событие')
plt.show()
No description has been provided for this image

Предварительная оценка событий по количеству:

  • событие Tutorial не входит в последовательную цепочку событий
  • для подтверждения этой гипотезы оценим распределение пользователей по событиям в числовом и графическом виде.
In [57]:
# распределение пользователей по событиям
df_agg = \
(
    df_event.groupby('event_name', as_index=False)['user_id'].nunique()
    .sort_values(by='user_id', ascending=False)
)
df_agg.columns = ('event_name', 'user_count')
df_agg
Out[57]:
event_name user_count
1 MainScreenAppear 7419
2 OffersScreenAppear 4593
0 CartScreenAppear 3734
3 PaymentScreenSuccessful 3539
4 Tutorial 840
In [58]:
# вывод графика распределения пользователей
plt.figure(figsize=(12, 6))
sns.barplot(
    data=df_agg,
    x="event_name",
    y="user_count",
    edgecolor=".3",
    linewidth=.5
)
plt.title('Распределение пользователей по событиям')
plt.ylabel('Количество пользователей')
plt.xlabel('Событие')
plt.show()
No description has been provided for this image

Выводы:

  1. События выстраиваются в следующий порядок (воронку):

    MainScreenAppear -> OffersScreenAppear -> CartScreenAppear -> PaymentScreenSuccessful

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

  3. Количество пользователей ожидаемо убывает на каждом шаге воронки

Расчет воронки событий¶

Исключим событие Tutorial из дальнейшего расчета воронки.

In [59]:
# исключение события `Tutorial`
df_agg = df_agg.query('event_name != "Tutorial"')

Посчитаем, какая доля пользователей проходит на следующий шаг воронки (от числа пользователей на предыдущем). Запишем значения в столбец conversion. Последовательность событий воронки сохраним в переменную event_funnel.

In [60]:
# задание последовательности событий воронки
event_funnel = ['MainScreenAppear', 'OffersScreenAppear', 'CartScreenAppear', 'PaymentScreenSuccessful']

df_agg = pd.DataFrame(df_agg).copy()
df_agg['conversion'] = (
    df_agg['user_count'] / 
    df_agg['user_count'].shift(1)
).fillna(1).apply(lambda x: f'{x:.2%}' if x!=1 else f'{1:.0%}')
df_agg
Out[60]:
event_name user_count conversion
1 MainScreenAppear 7419 100%
2 OffersScreenAppear 4593 61.91%
0 CartScreenAppear 3734 81.30%
3 PaymentScreenSuccessful 3539 94.78%

Оценим графически долю пользователей, переходящих на следующий этап воронки. Отобразим на графике значения конверсий из предыдущего события of previous и из первого события of initial

In [62]:
# вывод диаграммы воронки событий
fig = go.Figure(
    go.Funnel(
        y = df_agg['event_name'],
        x = df_agg['user_count'],
        textposition = "inside",
        textinfo = "value+percent previous+percent initial",
    )
)
fig.update_layout(
    width=1000,  
    height=600,
    title='Диаграмма воронки событий',
    title_x=0.55
)
fig.write_html('funnel_chart.html')
IFrame('funnel_chart.html', width=1000, height=600)
Out[62]:

Вывод:

  1. Больше всего пользователей теряется при переходе с первого на второй шаг - 38% пользователей не достигают события OffersScreenAppear

  2. От первого события MainScreenAppear до оплаты PaymentScreenSuccessful доходит 48% пользователей

Исследование результатов эксперимента¶

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

Запишем коды тестовых групп в переменные.

In [23]:
# первая контрольная группа
control_A1 = 246

# вторая контрольная группа
control_A2 = 247

# объединенная контрольная группа
control_AA = 246247

# экспериментальная группа
variant_B = 248

Оценим количественное распределение пользователей по трём тестовым группам.

In [24]:
# распределение пользователей по тестовым группам
df_agg = df_event.groupby('exp_group', as_index=False)['user_id'].nunique()
df_agg.columns = ('exp_group', 'user_group')

# функция для отображения подписей
def label_pct(pct, allusers):
    absolute = round(pct / 100. * allusers.sum())
    return f'Количество: {absolute},\n доля: {pct:.1f}%'
    
# вывод круговой диаграммы для`user_count` 
plt.figure(figsize=(12, 6))
plt.pie(
    df_agg['user_group'],
    shadow=True,
    autopct=lambda pct: label_pct(pct, df_agg['user_group']),
    labels=[
        f'{"Экспериментальная" if group == variant_B else "Контрольная"} группа: {group}' 
        for group in df_agg['exp_group']
    ]
)
plt.title('Распределение пользователей по тестовым группам')
plt.show()
No description has been provided for this image

Вывод

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

Проверка корректности разделения на группы (А/А тест)¶

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

In [25]:
# создание таблицы по группам/событиям
df_group = \
(
    df_event
    .groupby(['exp_group', 'event_name'], as_index=False)['user_id'].nunique()
    .sort_values(by=['exp_group', 'user_id'], ascending=[True, False])
)
df_group.columns = ('exp_group', 'event_name', 'user_event')

# добавление столбца количества пользователей по группам
df_group = df_group.merge(df_agg, on='exp_group', how='left')

Объединим суммированием пользователей контрольных групп А1 и А2 и добавим их в таблицу по группам и событиям.

In [26]:
# создание объединенной контрольной группы
df_union = \
(
    df_group
    .query('exp_group == @control_A1 or exp_group == @control_A2')
    .groupby('event_name', as_index=False)
    .agg(user_event=('user_event', 'sum'),
         user_group=('user_group', 'sum'))
    .sort_values(by='user_event', ascending=False)
)
df_union.insert(0, 'exp_group', control_AA)

# присоединение объединенной контрольной группы
df_union = pd.concat([df_group, df_union], ignore_index=True)

Создадим функцию для расчета статистической значимости различия между конверсиями в событие двух тестовых групп. Используем двусторонний z-test для пропорций.

In [27]:
def z_test(successes, trials, alpha, print_result):
    
    # доля успехов в первой группе:
    p1 = successes[0][0]/trials[0][0]
    
    # доля успехов во второй группе:
    p2 = successes[1][0]/trials[1][0]
    
    # расчет вероятности
    p_value = proportions_ztest([successes[0][0],successes[1][0]],[trials[0][0],trials[1][0]])[1]
    
    if print_result==1:
        # вывод результатов теста
        message = (
            f'Результаты теста события: **{successes[0][1]}** для групп: **{trials[0][1]}** '
            f'и **{trials[1][1]}**, alpha=**{alpha}**\n\n'
            f'В группе **{trials[0][1]}** доля успехов: **{p1:.2%}**\n\n'
            f'В группе **{trials[1][1]}** доля успехов: **{p2:.2%}**\n'
        )
        
        # проверка гипотезы
        message1 = (
            f'p_value=**{p_value:.2e}** < {alpha:.2%}\n\n'
            f' Отвергаем нулевую гипотезу: между долями есть значимая разница.\n\n'
            f'---'
        ) if p_value < alpha else (
            f'p_value=**{p_value:.2%}** >= {alpha:.2%}\n\n'
            f'Не удалось отвергнуть нулевую гипотезу: '
            f'нет статистически значимых оснований считать доли разными.\n\n'
            f'---\n')
        
        if flag_md == 1:
            display(Markdown(message))
            display(Markdown(message1))
        else:
            print(message.replace("*", ""))
            print(message1.replace("*", ""))
    return p_value

Создадим функцию для формирования набора данных для статистического z-test. Не будем включать в наборы данных событие Tutorial, как не влияющее на процесс покупки.

In [28]:
def ztest_data(groupA, groupB, alpha, print_result):
    results_test = []
    # создания списка тестовых групп
    exp_group=[groupA, groupB]

    # количество пользователей в группе 
    trials = [[df_union.query('exp_group == @group')['user_group'].iloc[0], group] for group in exp_group]
    
    # количество пользователей, совершивших событие
    for current_event in event_funnel:
        successes = [[
            df_union.query('exp_group == @group & event_name == @current_event')['user_event'].iloc[0],
            current_event]
            for group in exp_group
        ]

        # вызов функции расчета статистической значимости
        p_value = z_test(successes, trials, alpha, print_result)
        results_test.append([current_event, p_value])
    return results_test

Проверка правильности выбора z-test¶

Для этого сравним его результаты с результатами t-test и Mann–Whitney U test на заведомо одинаковых контрольных группах А1 и А2. Сравним значения p-value трех статистических методов для каждого события.

Оценим возможность применения t-test - проверим для группы А1, что выборочное среднее распределено нормально (bootstrap + график qq).

In [29]:
# формирование данных для `t-test` и `Mann–Whitney-test`
df_filtered = df_event.query(
    '(exp_group ==@control_A1 or exp_group ==@control_A2) and event_name in @event_funnel'
)

# создание таблицы по группам/событиям/датам
df_group = \
(
    df_filtered
    .groupby(['exp_group', 'event_name', 'event_date'], as_index=False)['user_id'].nunique()
    .sort_values(by=['exp_group', 'event_date'], ascending=[True, True])
)
df_group.columns = ('exp_group', 'event_name', 'event_date', 'user_event')

# распределение пользователей по тестовым группам, событиям и датам
df_agg = df_filtered.groupby(['exp_group', 'event_date'], as_index=False)['user_id'].nunique()
df_agg.columns = ('exp_group', 'event_date', 'user_group')

# добавление столбца количества пользователей по группам и датам
df_group = df_group.merge(df_agg, on=['exp_group', 'event_date'], how='left')

# расчет конверсии
df_group['conversion'] = df_group['user_event'] / df_group['user_group']

# инициализация списка
results = []

# цикл по событиям воронки
for current_event in event_funnel:
    # bootstrap среднего значения конверсии
    avg_conv = [
        df_group.query('exp_group == @control_A1 and event_name == @current_event')['conversion']
        .sample(1000, replace=True).mean()
        for _ in range(1000)
    ]
    # добавление результатов в список
    results.append({
        'current_event': current_event,
        'avg_conv': avg_conv
    })
df_avg = pd.DataFrame(results).explode('avg_conv').reset_index(drop=True)
In [30]:
# гистограммы для выборочного среднего по событиям
plt.figure(figsize=(14, 8))
for i, current_event in enumerate(event_funnel, start=1):
    plt.subplot(2, 2, i)
    sns.histplot(df_avg.query('current_event == @current_event')['avg_conv'], kde=True)
    plt.title(f'Выборочное среднее для {current_event}')
    plt.ylabel('Количество') if i % 2 != 0 else plt.ylabel('')
    plt.xlabel('Конверсия') if i > 2 else plt.xlabel('')
plt.tight_layout()
plt.show()
No description has been provided for this image
In [31]:
# гистограммы для qq-графика по событиям
plt.figure(figsize=(14, 8))
for i, current_event in enumerate(event_funnel, start=1):
    plt.subplot(2, 2, i)
    st.probplot(df_avg.query('current_event == @current_event')['avg_conv'].astype(float), dist="norm", plot=plt)
    plt.title(f'QQ-график для {current_event}')
    plt.ylabel('Квантиль наблюдений')
    plt.xlabel('Квантиль нормального распределения')    
plt.tight_layout()
plt.show()
No description has been provided for this image

Вывод:

  1. Выборочное среднее конверсии в событие в группе А1 распределено нормально

Проведем статистические тесты t-test и Mann–Whitney U test и сравним результаты с z-test

In [32]:
# инициализация выходных датафреймов
results_ttest = []
results_mw = []

# цикл по событиям воронки
for current_event in event_funnel:
    # формирование результатов `t-test`
    p_value = st.ttest_ind(
        df_group.query('exp_group ==@control_A1 and event_name ==@current_event')['conversion'], 
        df_group.query('exp_group ==@control_A2 and event_name ==@current_event')['conversion'], equal_var=False
    )[1]
    results_ttest.append([current_event, p_value])
    
    # формирование результатов `Mann–Whitney-test`
    p_value = st.mannwhitneyu(
        df_group.query('exp_group ==@control_A1 and event_name ==@current_event')['conversion'], 
        df_group.query('exp_group ==@control_A2 and event_name ==@current_event')['conversion'])[1]
    results_mw.append([current_event, p_value])

# запись результатов в датафреймы
results_ttest = pd.DataFrame(results_ttest, columns=['event_name', 'ttest_pvalue'])
results_mw = pd.DataFrame(results_mw, columns=['event_name', 'mw_pvalue'])
In [33]:
# результаты `z-test`
results_test = ztest_data(control_A1, control_A2, 0.05, 0)
results_test = pd.DataFrame(results_test, columns=['event_name', 'ztest_pvalue'])
In [34]:
# объединение результатов `z-test` и `t-test` и `Mann–Whitney-test`
results_test = results_test.merge(results_ttest, on='event_name', how='left')
results_test = results_test.merge(results_mw, on='event_name', how='left')
results_test
Out[34]:
event_name ztest_pvalue ttest_pvalue mw_pvalue
0 MainScreenAppear 0.757060 0.503038 0.534965
1 OffersScreenAppear 0.248095 0.205942 0.208625
2 CartScreenAppear 0.228834 0.179367 0.164918
3 PaymentScreenSuccessful 0.114567 0.128758 0.072844

Выводы:

  1. На заведомо одинаковых контрольных группах А1 и А2 лучшие показатели, подтверждающие отсутствие различий, показал z-test

  2. В t-test сравнивали среднюю конверсию по дням в А1 и А2. Результат: - выборки одинаковые, хотя и с меньшей вероятностью чем у z-test

  3. В Mann–Whitney-test сравнивали среднюю конверсию по дням в А1 и А2. Результаты теста схожие с результатами t-test. Но для события

    PaymentScreenSuccessful результат близок к статистически значимому различию между А1 и А2.

  4. Для дальнейшего статистического анализа будем использовать z-test

Посчитаем статистическую значимость отличий между долями посетителей контрольных групп А1/А2 по всем событиям. Сформулируем гипотезы:

H0 - нулевая гипотеза:

  • различий между долями в контрольных группах нет

H1 - альтернативная двусторонняя гипотеза

  • различия между долями в контрольных группах есть

Проверим гипотезу с помощью Z-теста для пропорций. Если вероятность ошибочно отвергнуть нулевую гипотезу p-value окажется меньше уровня статистической значимости alpha = 5%, то отвергнем нулевую гипотезу в пользу альтернативной.

In [35]:
# проверка отличий между А/А
ztest_data(control_A1, control_A2, 0.05, 1);

Результаты теста события: MainScreenAppear для групп: 246 и 247, alpha=0.05

В группе 246 доля успехов: 98.63%

В группе 247 доля успехов: 98.53%

p_value=75.71% >= 5.00%

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


Результаты теста события: OffersScreenAppear для групп: 246 и 247, alpha=0.05

В группе 246 доля успехов: 62.08%

В группе 247 доля успехов: 60.49%

p_value=24.81% >= 5.00%

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


Результаты теста события: CartScreenAppear для групп: 246 и 247, alpha=0.05

В группе 246 доля успехов: 50.97%

В группе 247 доля успехов: 49.26%

p_value=22.88% >= 5.00%

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


Результаты теста события: PaymentScreenSuccessful для групп: 246 и 247, alpha=0.05

В группе 246 доля успехов: 48.31%

В группе 247 доля успехов: 46.08%

p_value=11.46% >= 5.00%

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


Выводы:

  1. При заданном уровне статистической значимости alpha = 5% разбиение пользователей на группы работает корректно

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

  3. С переходом пользователей на каждый следующий этап воронки происходит увеличение различий между контрольными группами

Проверка результатов экспериментальной группы¶

Сравнение с контрольной группой А1¶

Посчитаем статистическую значимость отличий между долями посетителей контрольной и экспериментальной групп А1/В по всем событиям. Сформулируем гипотезы:

H0 - нулевая гипотеза:

  • различий между долями в контрольной и экспериментальной группах нет

H1 - альтернативная двусторонняя гипотеза

  • различия между долями в контрольной и экспериментальной группах есть

Проверим гипотезу с помощью Z-теста для пропорций. Если вероятность ошибочно отвергнуть нулевую гипотезу p-value окажется меньше уровня статистической значимости alpha = 5%, то отвергнем нулевую гипотезу в пользу альтернативной.

In [36]:
# проверка отличий между А1/В
ztest_data(control_A1, variant_B, 0.05, 1);

Результаты теста события: MainScreenAppear для групп: 246 и 248, alpha=0.05

В группе 246 доля успехов: 98.63%

В группе 248 доля успехов: 98.27%

p_value=29.50% >= 5.00%

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


Результаты теста события: OffersScreenAppear для групп: 246 и 248, alpha=0.05

В группе 246 доля успехов: 62.08%

В группе 248 доля успехов: 60.35%

p_value=20.84% >= 5.00%

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


Результаты теста события: CartScreenAppear для групп: 246 и 248, alpha=0.05

В группе 246 доля успехов: 50.97%

В группе 248 доля успехов: 48.48%

p_value=7.84% >= 5.00%

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


Результаты теста события: PaymentScreenSuccessful для групп: 246 и 248, alpha=0.05

В группе 246 доля успехов: 48.31%

В группе 248 доля успехов: 46.55%

p_value=21.23% >= 5.00%

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


Сравнение с контрольной группой А2¶

Посчитаем статистическую значимость отличий между долями посетителей контрольной и экспериментальной групп А2/В по всем событиям. Сформулируем гипотезы:

H0 - нулевая гипотеза:

  • различий между долями в контрольной и экспериментальной группах нет

H1 - альтернативная двусторонняя гипотеза

  • различия между долями в контрольной и экспериментальной группах есть

Проверим гипотезу с помощью Z-теста для пропорций. Если вероятность ошибочно отвергнуть нулевую гипотезу p-value окажется меньше уровня статистической значимости alpha = 5%, то отвергнем нулевую гипотезу в пользу альтернативной.

In [37]:
# проверка отличий между А2/В
ztest_data(control_A2, variant_B, 0.05, 1);

Результаты теста события: MainScreenAppear для групп: 247 и 248, alpha=0.05

В группе 247 доля успехов: 98.53%

В группе 248 доля успехов: 98.27%

p_value=45.87% >= 5.00%

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


Результаты теста события: OffersScreenAppear для групп: 247 и 248, alpha=0.05

В группе 247 доля успехов: 60.49%

В группе 248 доля успехов: 60.35%

p_value=91.98% >= 5.00%

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


Результаты теста события: CartScreenAppear для групп: 247 и 248, alpha=0.05

В группе 247 доля успехов: 49.26%

В группе 248 доля успехов: 48.48%

p_value=57.86% >= 5.00%

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


Результаты теста события: PaymentScreenSuccessful для групп: 247 и 248, alpha=0.05

В группе 247 доля успехов: 46.08%

В группе 248 доля успехов: 46.55%

p_value=73.73% >= 5.00%

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


Сравнение с объединенной контрольной группой АА¶

Посчитаем статистическую значимость отличий между долями посетителей объединенной контрольной и экспериментальной групп АА/В по всем событиям. Сформулируем гипотезы:

H0 - нулевая гипотеза:

  • различий между долями в объединенной контрольной и экспериментальной группах нет

H1 - альтернативная двусторонняя гипотеза

  • различия между долями в объединенной контрольной и экспериментальной группах есть

Проверим гипотезу с помощью Z-теста для пропорций. Если вероятность ошибочно отвергнуть нулевую гипотезу p-value окажется меньше уровня статистической значимости alpha = 5%, то отвергнем нулевую гипотезу в пользу альтернативной.

In [38]:
# проверка отличий между АА/В
ztest_data(control_AA, variant_B, 0.05, 1);

Результаты теста события: MainScreenAppear для групп: 246247 и 248, alpha=0.05

В группе 246247 доля успехов: 98.58%

В группе 248 доля успехов: 98.27%

p_value=29.42% >= 5.00%

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


Результаты теста события: OffersScreenAppear для групп: 246247 и 248, alpha=0.05

В группе 246247 доля успехов: 61.28%

В группе 248 доля успехов: 60.35%

p_value=43.43% >= 5.00%

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


Результаты теста события: CartScreenAppear для групп: 246247 и 248, alpha=0.05

В группе 246247 доля успехов: 50.11%

В группе 248 доля успехов: 48.48%

p_value=18.18% >= 5.00%

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


Результаты теста события: PaymentScreenSuccessful для групп: 246247 и 248, alpha=0.05

В группе 246247 доля успехов: 47.19%

В группе 248 доля успехов: 46.55%

p_value=60.04% >= 5.00%

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


Выводы:

  1. Между экспериментальной и контрольными группами есть различия между конверсиями в события, но при заданном уровне значимости alpha = 5% они не являются статистически значимыми

  2. Вероятно не имеет смысла менять шрифт во всём приложении

Выбор уровня статистической значимости¶

При проверке 16 статистических гипотез использован уровень значимости alpha = 5%.

Если использовать уровень значимости alpha = 10% и провести повторные проверки статистических гипотез, то станет значимой разница только в конверсии в событие CartScreenAppear при сравнении контрольной группы А1 и экспериментальной группы В:

  • в группе А1 доля успехов: 50.97%
  • в группе В доля успехов: 48.48%
  • p_value=7.84% >= 10%

Вывод:

  1. Имеет смысл использовать результаты проверки статистических гипотез с заданным уровнем ошибочно отклонить нулевую гипотезу в 5% случаев.

Итоги исследования¶

Результаты анализа воронки событий и исследования результатов A/A/B-эксперимента позволят менеджерам приложения принять правильные управленческие решения.

Установлено по результатам теста:

  1. События в приложении выстраиваются в следующий порядок (воронку):

    MainScreenAppear -> OffersScreenAppear -> CartScreenAppear -> PaymentScreenSuccessful

  2. Больше всего пользователей теряется при переходе с первого на второй шаг событийной воронки - 38% пользователей не достигают события OffersScreenAppear

  3. От первого события MainScreenAppear до оплаты PaymentScreenSuccessful доходит 48% пользователей

  4. Различия в конверсии экспериментальной группы пользователей, использовавших приложение с новыми шрифтами, не являются статистически значимыми

Рекомендации:

  1. Имеет смысл оставить исходный шрифт

  2. Провести дополнительный А/В тест изменений, направленных на уменьшение потерь пользователей при переходе к OffersScreenAppear

Вернуться в начало