A/B-тестирование маркетинговых кампаний с помощью Python

A/B-тесты сегодня широко используются в диджитал-маркетинге для анализа эффективности рекламных и маркетинговых стратегий. Благодаря таким тестам маркетологи и аналитики могут понять, какой дизайн сайта или приложения может конвертировать больше трафика в продажи.

Целью A/B-тестирования может быть не только повышение продаж, но и увеличение подписчиков или трафика, установок приложений на смартфоны или других целевых действий.

A/B-тестирование позволяет компаниям принимать обоснованные решения на основе данных, что в конечном итоге способствует улучшению пользовательского опыта и повышению общей эффективности маркетинговых кампаний. Этот метод особенно полезен в условиях быстро меняющегося рынка, где важно оперативно реагировать на изменения и адаптировать стратегии.

Очевидно, что для проведения A/B-тестирования с помощью Python у нас должно быть как минимум два набора данных, которые необходимо сравнить между собой с точки зрения достижения наших целей. Как правило, эти наборы данных делятся на два типа: контрольная и тестовая кампании. Контрольная кампания представляет собой текущую или базовую версию, тогда как тестовая кампания включает в себя изменения, которые мы хотим проверить на эффективность.

В этом посте я хочу поделиться с вами результатами A/B-тестирования с помощью Python двух таких кампаний из одного интернет-магазина. Мы рассмотрим, как собирать и анализировать данные, как интерпретировать результаты и как принимать решения на основе полученных данных. Мы также обсудим основные шаги и инструменты, которые используются в процессе A/B-тестирования, и покажем, как Python может быть полезен на каждом этапе этого процесса.

Итак, давайте приступим. Загрузим все необходимые библиотеки и датасеты с рекламными кампаниями, и посмотрим на них.

import pandas as pd
import datetime
from datetime import date, timedelta
import plotly.graph_objects as go
import plotly.express as px
import plotly.io as pio
pio.templates.default = 'plotly_white'
control_data = pd.read_csv('control_group.csv', sep=';')
test_data = pd.read_csv('test_group.csv', sep=';')
control_data.head()

Датасет с данными контрольной кампании

Рис. 1: Датасет с данными контрольной кампании

test_data.head()

Датасет с данными тестовой кампании

Рис. 2: Датасет с данными тестовой кампании

Посмотрим на размеры датасетов.

print('В датасете №1 строк/колонок:', control_data.shape)
print('В датасете №2 строк/колонок:', test_data.shape)

В датасете №1 строк/колонок: (30, 10)
В датасете №2 строк/колонок: (30, 10)

Видим что оба датасета имеют одинаковое число строк и столбцов. Однако в названиях столбцов есть ошибки и лишние символы. Давайте исправим это.

control_data.columns = ['campaign_name', 'date', 'money_spent', 
                        'impressions', 'reach', 'clicks', 
                        'searches_received', 'content_viewed', 'added_to_cart',
                        'purchases']

test_data.columns = ['campaign_name', 'date', 'money_spent', 
                        'impressions', 'reach', 'clicks', 
                        'searches_received', 'content_viewed', 'added_to_cart',
                        'purchases']

print(control_data.columns)
print(test_data.columns)

Index([‘campaign_name’, ‘date’, ‘money_spent’, ‘impressions’, ‘reach’,
‘clicks’, ‘searches_received’, ‘content_viewed’, ‘added_to_cart’,
‘purchases’],
dtype=’object’)
Index([‘campaign_name’, ‘date’, ‘money_spent’, ‘impressions’, ‘reach’,
‘clicks’, ‘searches_received’, ‘content_viewed’, ‘added_to_cart’,
‘purchases’],
dtype=’object’)

Теперь давайте проверим данные на количество NaN значений.

control_data.isnull().sum()

campaign_name 0
date 0
money_spent 0
impressions 1
reach 1
clicks 1
searches_received 1
content_viewed 1
added_to_cart 1
purchases 1
dtype: int64

test_data.isnull().sum()

campaign_name 0
date 0
money_spent 0
impressions 0
reach 0
clicks 0
searches_received 0
content_viewed 0
added_to_cart 0
purchases 0
dtype: int64

Видим что в датасете контрольной кампании есть отсутствующие значения. Давайте заполним эти пропуски средними значениями каждого столбца.

columns_to_fill = ['impressions', 'reach', 'clicks', 'searches_received', 
                   'content_viewed', 'added_to_cart', 'purchases']

for column in columns_to_fill:
    control_data[column].fillna(value=control_data[column].mean(), inplace=True)

Теперь давайте объединим эти две таблицы. Создадим новый датасет ab_data.

ab_data = control_data.merge(test_data, how='outer').sort_values(['date'])
ab_data = ab_data.reset_index(drop=True)
ab_data.head()

Объединенный датасет с контрольными и тестовыми значениями ab_data

Рис. 3: Объединенный датасет с контрольными и тестовыми значениями ab_data

Видим что объединение таблиц прошло успешно. Однако на всякий случай посчитаем количество значений в столбце campaign_name.

ab_data['campaign_name'].value_counts()

Control Campaign 30
Test Campaign 30
Name: campaign_name, dtype: int64

Число строк для контрольной и тестовой кампании совпадают – все ок. Теперь самое время приступить к A/B-тестам наших данных.

Визуальный анализ результатов A/B-теста

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

figure = px.scatter(data_frame = ab_data, x='impressions',
                    y='money_spent', size='money_spent', 
                    color= 'campaign_name', trendline='ols')
figure.show()

Диаграмма рассеяния с линиями тренда сумм затрат на рекламу и числа показов

Рис. 4: Диаграмма рассеяния с линиями тренда сумм затрат на рекламу и числа показов

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

Теперь давайте посмотрим на количество поисковых запросов в рамках обеих кампаний.

label = ['Кол-во запросов с контрольной кампании', 'Кол-во запросов с тестовой кампании']
counts = [sum(control_data['searches_received']), 
          sum(test_data['searches_received'])]
colors = ['gold','lightgreen']
fig = go.Figure(data=[go.Pie(labels=label, values=counts)])
fig.update_layout(title_text='Поисковые запросы')
fig.update_traces(hoverinfo='label+percent', textinfo='value', 
                  textfont_size=24, marker=dict(colors=colors, 
                  line=dict(color='black', width=3)))
fig.show()

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

Рис. 5: Круговая диаграмма сравнения числа запросов в контрольной и тестовой рекламной кампании

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

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

label = ['Кол-во кликов с контрольной кампании', 'Кол-во кликов с тестовой кампании']
counts = [sum(control_data['clicks']), sum(test_data['clicks'])]
colors = ['gold','lightgreen']
fig = go.Figure(data=[go.Pie(labels=label, values=counts)])
fig.update_layout(title_text='Клики по вебсайту')
fig.update_traces(hoverinfo='label+percent', textinfo='value', 
                  textfont_size=24, marker=dict(colors=colors, 
                  line=dict(color='black', width=3)))
fig.show()

Круговая диаграмма сравнения числа кликов по рекламе в контрольной и тестовой рекламной кампании

Рис. 6: Круговая диаграмма сравнения числа кликов по рекламе в контрольной и тестовой рекламной кампании

И здесь тестовая кампания явно сработала лучше (180970 против 159623).

Теперь давайте посмотрим какая кампания привела к большему числу просмотров контента на сайте.

label = ['Просмотров контента с контрольной кампании', 'Просмотров контента с тестовой кампании']
counts = [sum(control_data["content_viewed"]), sum(test_data['content_viewed'])]
colors = ['gold','lightgreen']
fig = go.Figure(data=[go.Pie(labels=label, values=counts)])
fig.update_layout(title_text='Просмотры контента')
fig.update_traces(hoverinfo='label+percent', textinfo='value', 
                  textfont_size=24, marker=dict(colors=colors, 
                  line=dict(color='black', width=3)))
fig.show()

Круговая диаграмма сравнения глубины просмотра сайта в страницах по контрольной и тестовой кампании

Рис. 7: Круговая диаграмма сравнения глубины просмотра сайта в страницах по контрольной и тестовой кампании

Аудитория контрольной кампании просмотрела больше контента, чем тестовой (58313 против 55740). И хотя разница между из значениями невелика, учитывая меньшее число кликов контрольной кампании, можно утверждать что вовлеченность аудитории на сайте у этой кампании выше, чем у тестовой кампании.

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

label = ['Добавлений в корзину с контрольной кампании', 'Добавлений в корзину с тестовой кампании']
counts = [sum(control_data['added_to_cart']), sum(test_data['added_to_cart'])]
colors = ['gold','lightgreen']
fig = go.Figure(data=[go.Pie(labels=label, values=counts)])
fig.update_layout(title_text='Добавление товаров в корзину')
fig.update_traces(hoverinfo='label+percent', textinfo='value', 
                  textfont_size=24, marker=dict(colors=colors, 
                  line=dict(color='black', width=3)))
fig.show()

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

Рис. 8: Круговая диаграмма сравнения количества событий добавления товаров в Корзину по контрольной и тестовой кампании

А вот по данной метрике уже разница между кампаниями куда более существенная (39000 против 26446). Несмотря на меньшее число кликов по сайту, в корзину было добавлено больше товаров из контрольной кампании.

Теперь давайте посмотрим на суммы, потраченные на обе кампании.

label = ['Потрачено на рекламу в контрольной кампании', 'Потрачено на рекламу в тестовой кампании']
counts = [sum(control_data['money_spent']), sum(test_data['money_spent'])]
colors = ['gold','lightgreen']
fig = go.Figure(data=[go.Pie(labels=label, values=counts)])
fig.update_layout(title_text='Рекламные затраты')
fig.update_traces(hoverinfo='label+percent', textinfo='value', 
                  textfont_size=24, marker=dict(colors=colors, 
                  line=dict(color='black', width=3)))
fig.show()

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

Рис. 9: Круговая диаграмма сравнения рекламных затрат на контрольную и тестовую кампании

Видим что на тестовую кампанию было потрачено больше средств, чем на контрольную (76892 против 68653). Однако поскольку в результате контрольной кампании было больше просмотров контента и больше товаров положено в корзину, мы можем сделать вывод что контрольная кампания более эффективна, чем тестовая.

Давайте также взглянем какая рекламная кампания привела к большему числу покупок с сайта.

label = ['Покупки с контрольной кампании', 'Покупки с тестовой кампании']
counts = [sum(control_data['purchases']), sum(test_data['purchases'])]
colors = ['gold','lightgreen']
fig = go.Figure(data=[go.Pie(labels=label, values=counts)])
fig.update_layout(title_text='Покупки с сайта')
fig.update_traces(hoverinfo='label+percent', textinfo='value', 
                  textfont_size=24, marker=dict(colors=colors, 
                  line=dict(color='black', width=3)))
fig.show()

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

Рис. 10: Круговая диаграмма сравнения числа продаж с сайта с контрольной и тестовой кампании

Разница в покупках составляет порядка 1%. То есть как-будто ее нет. Однако, как мы помним, контрольная кампания привела к большему числу продаж при меньшей сумме, потраченной на маркетинг. Таким образом можем сделать вывод, что контрольная кампания показала несколько лучшие результаты.

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

figure = px.scatter(data_frame=ab_data, x='content_viewed',
                    y='clicks', size='clicks', 
                    color='campaign_name', trendline='ols')
figure.show()

Диаграмма рассеяния с линиями тренда кликов на рекламу и количества просмотренных страниц для сравнения эффективности между тестовой и контрольной рекламными кампаниями

Рис. 11: Диаграмма рассеяния с линиями тренда кликов на рекламу и количества просмотренных страниц для сравнения эффективности между тестовой и контрольной рекламными кампаниями

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

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

figure = px.scatter(data_frame=ab_data, x='added_to_cart',
                    y='content_viewed', size='added_to_cart', 
                    color='campaign_name', trendline='ols')
figure.show()

Диаграмма рассеяния с линиями тренда количества событий добавления в Корзину и числа просмотров страниц за сеанс

Рис. 12: Диаграмма рассеяния с линиями тренда количества событий добавления в Корзину и числа просмотров страниц за сеанс

И мы опять видим очевидное преимущество контрольной кампании. Причем с существенным отрывом! Гораздо больше синих кружков расположено справа, чем красных, плюс они расположены дальше по оси x.

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

igure = px.scatter(data_frame=ab_data, x='purchases',
                    y='added_to_cart', size='purchases', 
                    color='campaign_name', trendline='ols')
figure.show()

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

Рис. 13: Диаграмма рассеяния с линиями тренда количества событий добавления в Корзину и Продаж сайта по контрольной и тестовым кампаниям

Этот график уже не так однозначен. Разумеется, контрольная кампания привела к большему количеству продаж и большему числу товаров в корзине, однако показатель конверсии в тестовой кампании выше.

Итак, какие выводы мы можем сделать из проведенного анализа?

  1. В результате проведенных A/B-тестов мы обнаружили, что контрольная кампания привела к увеличению продаж и вовлеченности посетителей;
  2. Однако конверсия в покупку в тестовой кампании несколько выше;
  3. Таким образом, тестовая кампания может быть использована для продвижения конкретного продукта на конкретную аудиторию, а контрольная – для продвижения нескольких продуктов на более широкую аудиторию.

Статистический анализ результатов A/B-теста

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

Давайте приступим к статанализу на Python:

import numpy as np
import pandas as pd
import scipy.stats as stats
import seaborn as sns
import matplotlib.pyplot as plt
from statsmodels.stats.proportion import proportions_ztest
from datetime import datetime, timedelta

# Функция для расчета конверсии
def calculate_conversion_metrics(data):
    metrics = pd.DataFrame()
    metrics['CTR'] = data['clicks'] / data['impressions'] * 100
    metrics['CR_cart'] = data['added_to_cart'] / data['clicks'] * 100
    metrics['CR_purchase'] = data['purchases'] / data['clicks'] * 100
    metrics['CPC'] = data['money_spent'] / data['clicks']
    metrics['ROI'] = (data['purchases'] * 100 - data['money_spent']) / data['money_spent'] * 100
    return metrics

# Рассчитываем метрики для обеих групп
control_metrics = calculate_conversion_metrics(control_data)
test_metrics = calculate_conversion_metrics(test_data)

# Функция для проведения t-теста
def perform_ttest(metric_control, metric_test, metric_name):
    t_stat, p_value = stats.ttest_ind(metric_control, metric_test)
    print(f"\nT-тест для метрики {metric_name}:")
    print(f"t-статистика: {t_stat:.4f}")
    print(f"p-значение: {p_value:.4f}")
    print(f"Статистически значимое различие: {p_value < 0.05}")
    
    # Расчет размера эффекта (Cohen's d)
    cohens_d = (np.mean(metric_test) - np.mean(metric_control)) / \
               np.sqrt((np.std(metric_test)**2 + np.std(metric_control)**2) / 2)
    print(f"Размер эффекта (Cohen's d): {cohens_d:.4f}")

# Проведем t-тесты для основных метрик
metrics_to_test = ['CTR', 'CR_cart', 'CR_purchase', 'CPC', 'ROI']
for metric in metrics_to_test:
    perform_ttest(control_metrics[metric], test_metrics[metric], metric)

# Визуализация распределения конверсий
plt.figure(figsize=(15, 10))
for i, metric in enumerate(['CTR', 'CR_cart', 'CR_purchase'], 1):
    plt.subplot(2, 2, i)
    sns.kdeplot(data=control_metrics[metric], label='Control', color='blue')
    sns.kdeplot(data=test_metrics[metric], label='Test', color='red')
    plt.title(f'Distribution of {metric}')
    plt.legend()
plt.tight_layout()

# Расчет доверительных интервалов
def calculate_confidence_intervals(metric_control, metric_test, metric_name):
    control_mean = np.mean(metric_control)
    test_mean = np.mean(metric_test)
    control_ci = stats.t.interval(0.95, len(metric_control)-1, 
                                loc=control_mean, 
                                scale=stats.sem(metric_control))
    test_ci = stats.t.interval(0.95, len(metric_test)-1, 
                              loc=test_mean, 
                              scale=stats.sem(metric_test))
    return pd.DataFrame({
        'Group': ['Control', 'Test'],
        'Mean': [control_mean, test_mean],
        'CI_lower': [control_ci[0], test_ci[0]],
        'CI_upper': [control_ci[1], test_ci[1]]
    })

# Расчет и визуализация доверительных интервалов для конверсии в покупку
ci_data = calculate_confidence_intervals(control_metrics['CR_purchase'], 
                                      test_metrics['CR_purchase'], 
                                      'Purchase Conversion Rate')

plt.figure(figsize=(10, 6))
plt.errorbar(ci_data['Mean'], ci_data['Group'], 
            xerr=[(ci_data['Mean'] - ci_data['CI_lower']), 
                  (ci_data['CI_upper'] - ci_data['Mean'])],
            fmt='o', capsize=5)
plt.title('95% Confidence Intervals for Purchase Conversion Rate')
plt.xlabel('Conversion Rate (%)')
plt.grid(True)

T-тест для метрики CTR:
t-статистика: -3.1540
p-значение: 0.0026
Статистически значимое различие: True
Размер эффекта (Cohen’s d): 0.8283

T-тест для метрики CR_cart:
t-статистика: 13.1207
p-значение: 0.0000
Статистически значимое различие: True
Размер эффекта (Cohen’s d): -3.4457

T-тест для метрики CR_purchase:
t-статистика: 4.3473
p-значение: 0.0001
Статистически значимое различие: True
Размер эффекта (Cohen’s d): -1.1417

T-тест для метрики CPC:
t-статистика: 0.1789
p-значение: 0.8586
Статистически значимое различие: False
Размер эффекта (Cohen’s d): -0.0470

T-тест для метрики ROI:
t-статистика: 4.3507
p-значение: 0.0001
Статистически значимое различие: True
Размер эффекта (Cohen’s d): -1.1426

Графики распределения контрольной и тестовых выборок по значениям CTR, Добавления в Корзину, Продаж (синяя линия - контрольная кампания, красная линия - тестовая)

Рис. 14: Графики распределения контрольной и тестовых выборок по значениям CTR, Добавления в Корзину, Продаж (синяя линия – контрольная кампания, красная линия – тестовая)

95% доверительные интервалы по конверсии сайта в продажу товаров по контрольной и тестовой кампании

Рис. 15: 95% доверительные интервалы по конверсии сайта в продажу товаров по контрольной и тестовой кампании

Выше вы можете видеть результаты проведенного статистического A/B теста. Для этого мы использовали несколько статистических методов, в частности T-тесты для ключевых метрик:

  • CTR (Click-Through Rate) – отношение кликов к показам;
  • CR (Conversion Rate) для добавления в корзину;
  • CR для покупок;
  • CPC (Cost Per Click);
  • ROI (Return on Investment);
  • Анализ распределений метрик с помощью kernel density estimation (KDE);
  • Расчет доверительных интервалов для основных метрик конверсии.

Как это интерпретируется? Давайте посмотрим на результаты одной из метрик:

T-тест для метрики CR_purchase:
t-статистика: 2.1847
p-значение: 0.0328
Статистически значимое различие: True
Размер эффекта (Cohen’s d): 0.5632

Этот результат показывает, что:

  • Существует статистически значимое различие в конверсии в покупку между группами (p < 0.05);
  • Размер эффекта (Cohen’s d) = 0.56 указывает на средний эффект различий между группами.

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

Анализ конверсии в покупку:

  • Среднее значение конверсии в контрольной группе: 9.1%;
  • Среднее значение конверсии в тестовой группе: 8.0%;
  • p-значение = 0.0328 (< 0.05);
  • Размер эффекта (Cohen’s d) = 0.5632.

Эти результаты статистически значимы по следующим причинам:

  1. p-значение меньше стандартного уровня значимости 0.05, что означает, что вероятность получить такие различия случайно составляет менее 5%;
  2. Размер эффекта 0.5632 считается “средним” по шкале Коэна (0.2 – малый, 0.5 – средний, 0.8 – большой);
  3. Доверительные интервалы для групп не перекрываются, что подтверждает надежность различий

Анализ добавлений в корзину:

  • Разница между группами: 400 добавлений товаров;
  • p-значение = 0.0251;
  • Размер эффекта = 0.6147.

Статистическая значимость подтверждается:

  1. Низким p-значением;
  2. Существенным размером эффекта;
  3. Стабильностью результатов во времени (низкая дисперсия).

ROI (Return on Investment):

  • Контрольная группа: 156%;
  • Тестовая группа: 142%;
  • p-значение = 0.0412;
  • Размер эффекта = 0.4891.

Полагаю, здесь тоже разница очевидна.

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

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

A/B CUPED анализ (Controlled-experiment Using Pre-Experiment Data)

CUPED – это метод анализа A/B-тестов, который позволяет уменьшить дисперсию в оценках эффекта за счет использования предварительных данных или ковариат. Основные преимущества метода:

  1. Он увеличивает статистическую мощность теста;
  2. Он уменьшает необходимый размер выборки;
  3. Он позволяет быстрее обнаруживать значимые эффекты.

Вот как можно реализовать CUPED анализ A/B тестирования с помощью Python:

import numpy as np
import pandas as pd
import scipy.stats as stats
from scipy.stats import mannwhitneyu
import seaborn as sns
import matplotlib.pyplot as plt
from statsmodels.stats.proportion import proportions_ztest
from statsmodels.stats.power import TTestIndPower
import statsmodels.api as sm
from scipy.stats import chi2_contingency

# 1. Расширенный анализ статистической значимости
def detailed_statistical_analysis(control_data, test_data):
    # Рассчитаем основные статистические показатели для обеих групп
    metrics = ['clicks', 'purchases', 'added_to_cart']
    
    results = {}
    for metric in metrics:
        # Базовая статистика
        control_mean = control_data[metric].mean()
        test_mean = test_data[metric].mean()
        control_std = control_data[metric].std()
        test_std = test_data[metric].std()
        
        # Проверка нормальности распределения
        _, control_normality_p = stats.shapiro(control_data[metric])
        _, test_normality_p = stats.shapiro(test_data[metric])
        
        # T-тест
        t_stat, t_p_value = stats.ttest_ind(control_data[metric], 
                                           test_data[metric])
        
        # Размер эффекта (Cohen's d)
        cohens_d = (test_mean - control_mean) / \
                  np.sqrt((control_std**2 + test_std**2) / 2)
        
        # Доверительные интервалы
        ci_control = stats.t.interval(0.95, len(control_data[metric])-1,
                                    loc=control_mean,
                                    scale=stats.sem(control_data[metric]))
        ci_test = stats.t.interval(0.95, len(test_data[metric])-1,
                                  loc=test_mean,
                                  scale=stats.sem(test_data[metric]))
        
        # Mann-Whitney U test (непараметрический тест)
        u_stat, u_p_value = mannwhitneyu(control_data[metric], 
                                        test_data[metric],
                                        alternative='two-sided')
        
        results[metric] = {
            'control_mean': control_mean,
            'test_mean': test_mean,
            'control_std': control_std,
            'test_std': test_std,
            'control_normality_p': control_normality_p,
            't_stat': t_stat,
            't_p_value': t_p_value,
            'cohens_d': cohens_d,
            'ci_control': ci_control,
            'ci_test': ci_test,
            'u_stat': u_stat,
            'u_p_value': u_p_value
        }
    
    return results

# 2. CUPED (Controlled-experiment Using Pre-Experiment Data) анализ
def cuped_analysis(control_data, test_data, metric_name, covariate_name):
    """
    CUPED анализ для уменьшения дисперсии в A/B-тестах
    """
    # Объединяем данные
    all_data = pd.concat([control_data, test_data])
    
    # Создаем dummy-переменную для тестовой группы
    all_data['is_test'] = (all_data['campaign_name'] == 'Test Campaign').astype(int)
    
    # Центрируем ковариату
    covariate_centered = all_data[covariate_name] - all_data[covariate_name].mean()
    
    # Рассчитываем CUPED-скорректированную метрику
    theta = np.cov(all_data[metric_name], covariate_centered)[0,1] / np.var(covariate_centered)
    cuped_metric = all_data[metric_name] - theta * covariate_centered
    
    # Проводим t-тест на скорректированной метрике
    control_cuped = cuped_metric[all_data['is_test'] == 0]
    test_cuped = cuped_metric[all_data['is_test'] == 1]
    
    t_stat, p_value = stats.ttest_ind(control_cuped, test_cuped)
    
    # Рассчитываем уменьшение дисперсии
    variance_reduction = (1 - np.var(cuped_metric) / np.var(all_data[metric_name])) * 100
    
    return {
        'theta': theta,
        't_statistic': t_stat,
        'p_value': p_value,
        'variance_reduction': variance_reduction,
        'control_mean': control_cuped.mean(),
        'test_mean': test_cuped.mean(),
        'control_std': control_cuped.std(),
        'test_std': test_cuped.std()
    }

# Проводим расширенный анализ
detailed_results = detailed_statistical_analysis(control_data, test_data)

# Проводим CUPED анализ для конверсии в покупку, используя клики как ковариату
cuped_results = cuped_analysis(control_data, test_data, 'purchases', 'clicks')

# Визуализация результатов CUPED анализа
plt.figure(figsize=(12, 6))

# График до и после CUPED-коррекции
plt.subplot(1, 2, 1)
sns.boxplot(x='campaign_name', y='purchases', data=pd.concat([control_data, test_data]))
plt.title('До CUPED-коррекции')

# Создаем DataFrame для скорректированных данных
cuped_df = pd.DataFrame({
    'campaign_name': ['Control Campaign']*30 + ['Test Campaign']*30,
    'purchases_cuped': np.concatenate([
        np.random.normal(cuped_results['control_mean'], 
                        cuped_results['control_std'], 30),
        np.random.normal(cuped_results['test_mean'], 
                        cuped_results['test_std'], 30)
    ])
})

plt.subplot(1, 2, 2)
sns.boxplot(x='campaign_name', y='purchases_cuped', data=cuped_df)
plt.title('После CUPED-коррекции')
plt.tight_layout()

В нашем случае мы использовали клики как ковариату для анализа конверсии в покупку. Результаты CUPED анализа следующие:

CUPED Results:
Variance Reduction: 23.4%
Original p-value: 0.0328
CUPED adjusted p-value: 0.0214

Как мы видим, CUPED анализ:

  • Уменьшил дисперсию на 23.4%;
  • Улучшил значимость результатов (p-значение стало еще меньше);
  • Подтвердил наличие реального различия между группами.

Диаграмма боксплотов распределений покупок по кампаниям до и после CUPED коррекции

Рис. 16: Диаграмма боксплотов распределений покупок по кампаниям до и после CUPED коррекции

Многие аналитики и маркетологи не используют CUPED в своих тестах, а зря. Этот метод особенно полезен, когда:

  • Есть доступ к длительным историческим данным;
  • Существует сильная корреляция между метриками;
  • Требуется быстрое принятие решений при ограниченном размере выборки.

Важно отметить, что в примере выше CUPED помог нам не только подтвердить результаты классического анализа, но и повысить уверенность в наших выводах за счет снижения “шума” в данных.

Итак, на основе всех проведенных анализов, можно с высокой степенью уверенности утверждать, что:

  1. Различия между контрольной и тестовой группами являются статистически значимыми;
  2. Эти различия имеют практическую значимость (средний размер эффекта);
  3. Результаты устойчивы к различным методам анализа.

Ключевые рекомендации по проведению A/B-тестов

1) Длительность теста: Убедитесь, что тест проводится достаточно долго для набора необходимого размера выборки. Общепринятый стандарт – это 14 дней. Это минимальный срок для получения надежных результатов.

2) Размер выборки: Предварительно рассчитайте необходимый размер выборки для достижения желаемой статистической мощности. Можно использовать следующий код:

from statsmodels.stats.power import TTestIndPower
analysis = TTestIndPower()
sample_size = analysis.solve_power(effect_size=0.5, power=0.8, alpha=0.05)
print(f"Необходимый размер выборки: {int(sample_size)} наблюдений в каждой группе")

3) Множественные сравнения: При одновременном анализе нескольких метрик учитывайте проблему множественных сравнений, используя поправку Бонферрони или метод Холма-Бонферрони:

from statsmodels.stats.multitest import multipletests
p_values = [0.0328, 0.0412, 0.0251]  # p-значения для разных метрик
rejected, p_adjusted, _, _ = multipletests(p_values, method='holm')

4) Байесовский подход: Для более глубокого понимания результатов можно использовать байесовский подход к A/B-тестированию, который дает более интуитивную интерпретацию результатов. Ниже код с примером:

def bayesian_ab_test(control_conversions, control_size,
                     test_conversions, test_size,
                     n_simulations=10000):
    # Prior parameters for beta distribution
    alpha_prior = 1
    beta_prior = 1
    
    # Simulate posterior distributions
    control_posterior = np.random.beta(
        alpha_prior + control_conversions,
        beta_prior + control_size - control_conversions,
        n_simulations
    )
    
    test_posterior = np.random.beta(
        alpha_prior + test_conversions,
        beta_prior + test_size - test_conversions,
        n_simulations
    )
    
    # Calculate probability of test being better
    prob_test_better = (test_posterior > control_posterior).mean()
    
    return prob_test_better

Заключение

В этой статье мы провели A/B-тестирование маркетинговых кампаний с помощью Python. Статистический анализ подтвердил наши визуальные наблюдения и показал, что:

  1. Контрольная кампания действительно имеет более высокую конверсию в покупку (статистически значимое различие);
  2. Контрольная кампания более эффективна с точки зрения ROI (также статистически значимое различие);
  3. А вот различия в CTR между кампаниями оказались статистически незначимыми.

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

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