Привет, Хабр! В этой статье разберём, что такое метрики отношения. Узнаем, почему критерий Стьюдента не работает. Попробуем применить бутстреп к зависимым данным. Изучим дельта-метод — способ оценки А/Б тестов с метрикой отношения.

Меня зовут Коля, я работаю аналитиком данных в X5 Tech. Мы с Сашей продолжаем писать серию статей по А/Б тестированию, это наша четвёртая статья. Первые три можно посмотреть тут:

Метрики отношения

Для проведения А/Б эксперимента мы выделяем две группы объектов: контрольную и экспериментальную. К объектам экспериментальной группы применяем тестируемое изменение, а объекты контрольной группы оставляем без изменений. 

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

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

Мы сталкиваемся с метриками отношения, когда хотим анализировать данные эксперимента на более детальном уровне, чем уровень объектов, по которым проводилось разбиение на группы. Примеры метрик отношения:

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

  2. CTR (click-through rate) — это соотношение кликов по рекламе к числу её показов. Если в группе два пользователя, первый кликнул на 1 рекламу из 7 показанных, а второй кликнул на 2 рекламы из 3 показанных, то CTR равен 0.3.

  3. Средний вес клубня. Если в группе 2 клубня, из первого выросло три клубня весом 4, 5 и 6 килограмм, а из второго выросло два клубня весом по 10 килограмм, то средний вес клубня равен 7 килограммам.

Формально метрики отношения можно представить формулой, как отношения двух сумм:

\mathcal{R} = \dfrac{X_1 + \ldots + X_N}{Y_1 + \ldots + Y_N}

где N — размер группы, X_i и Y_i — метрики i-го объекта. Например, для среднего чека X_i — суммарная стоимость покупок i-го покупателя, Y_i — количество покупок i-го покупателя.

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

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

Тест Стьюдента не работает

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

Однако, выше мы писали, что данные от одного объекта могут быть зависимыми, а тест Стьюдента предполагает независимость данных. Давайте проверим, будет ли критерий работать корректно. Подробнее про проверку корректности можно прочитать в статье Проверка корректности А/Б тестов.

Будем генерировать данные для двух групп покупателей размером по 1000 человек. Каждый покупатель совершает от 1 до 4 покупок. Значения среднего чека у покупателей отличаются. Применим тест Стьюдента к сгенерированным данным и запомним значение p-value. Повторим эту процедуру 1000 раз и построим эмпирическую функцию распределения p-value. Для корректного критерия распределение p-value должно быть близко к равномерному распределению от 0 до 1.

import numpy as np
import matplotlib.pyplot as plt
from scipy import stats


def plot_pvalue_distribution(dict_pvalues):
    """Рисует графики распределения p-value."""
    X = np.linspace(0, 1, 1000)
    for name, pvalues in dict_pvalues.items():
        Y = [np.mean(pvalues < x) for x in X]
        plt.plot(X, Y, label=name)

    plt.plot([0, 1], [0, 1], '--k', alpha=0.8)
    plt.title('Оценка распределения p-value', size=16)
    plt.xlabel('p-value', size=12)
    plt.legend(fontsize=12)
    plt.grid()
    plt.show()

def generate_data(sample_size, effect):
    """Генерирует данные со стоимостью покупок.

    Возвращает два списка с данными контрольной и экспериментальной групп.
    Элементы списков - множества со стоимостями покупок пользователей. 
    """
    result = []
    for group_effect in [0, effect]:
        n_purchases = np.random.randint(1, 5, sample_size)
        mean_costs = np.random.uniform(1000, 2000, sample_size)
        data = [
            np.random.normal(mean + group_effect, 200, n)
            for n, mean in zip(n_purchases, mean_costs)
        ]
        result.append(data)
    return result

alpha = 0.05              # допустимая вероятность ошибки I рода
sample_size = 1000        # размер групп

pvalues = []
for _ in range(1000):
    a, b = generate_data(sample_size, 0)
    a_values = np.hstack(a)
    b_values = np.hstack(b)
    pvalue = stats.ttest_ind(a_values, b_values).pvalue
    pvalues.append(pvalue)

error_rate = np.mean(np.array(pvalues) < alpha)
print(f'Доля ошибок первого рода: {error_rate:0.2f}')
plot_pvalue_distribution({'A/A': pvalues})
Доля ошибок первого рода: 0.20

Распределение p-value получилось неравномерным. Доля ошибок первого рода 0.2, это значительно больше ожидаемых 0.05. При проверке гипотезы о равенстве метрик отношения тест Стьюдента работает некорректно.

Среднее средних

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

Проверим, будет ли тест Стьюдента корректно работать на таких данных.

pvalues = []
for _ in range(1000):
    a, b = generate_data(sample_size, 0)
    a_means = [np.mean(x) for x in a]
    b_means = [np.mean(x) for x in b]
    pvalue = stats.ttest_ind(a_means, b_means).pvalue
    pvalues.append(pvalue)

plot_pvalue_distribution({'A/A': pvalues})

Распределение p-value получилось равномерным. Тест Стьюдента работает корректно.

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

Рассмотрим пример. Есть две группы по два покупателя в каждой. В первой группе один покупатель сделал две покупки по 2000 рублей, а другой совершил одну покупку на 1000 рублей. Во второй группе один покупатель сделал две покупки по 1000 рублей, а другой совершил одну покупку на 2700 рублей.

Вычислим разность метрик отношения между группами:

(1000 + 1000 + 2700) / 3 - (2000 + 2000 + 1000) / 3 = -100

Вычислим разность среднего средних между группами:

(1000 + 2700) / 2 - (2000 + 1000) / 2 = 350

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

Бутстреп

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

Основная особенность применение бутстрепа для метрик отношения — данные необходимо семплировать по объектам, а не по наблюдениям. Если объект оказался в определённой группе, то и все его действия будут в этой группе.

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

def get_percentile_ci(bootstrap_stats, alpha):
    """Строит перцентильный доверительный интервал."""
    left, right = np.quantile(bootstrap_stats, [alpha / 2, 1 - alpha / 2])
    return left, right

def check_bootsrtap(a, b, n_iter, alpha):
    """Оценивает значимость отличий с помощью бутстрепа.

    Если отличия значимые, то возвращает 1, иначе 0.
    """
    # вычисляем стоимость и количество покупок клиентов
    xy_a = np.array([[sum(values), len(values)] for values in a])
    xy_b = np.array([[sum(values), len(values)] for values in b])
    # генерируем случайные индексы для выбора подмножеств данных
    len_a = len(a)
    len_b = len(b)
    indexes_a = np.random.choice(
        np.arange(len_a), size=(n_iter, len_a), replace=True
    )
    indexes_b = np.random.choice(
        np.arange(len_b), size=(n_iter, len_b), replace=True
    )

    bootstrap_stats = []
    for idx_a, idx_b in zip(indexes_a, indexes_b):
        bootstrap_xy_a = xy_a[idx_a]
        bootstrap_xy_b = xy_b[idx_b]
        # считаем разницу метрик отношения
        bootstrap_stat = (
            bootstrap_xy_b[:, 0].sum() / bootstrap_xy_b[:, 1].sum()
            - bootstrap_xy_a[:, 0].sum() / bootstrap_xy_a[:, 1].sum()
        )
        bootstrap_stats.append(bootstrap_stat)
    # строим доверительный интервал и оцениваем значимость отличий
    ci = get_percentile_ci(bootstrap_stats, alpha)
    has_effect = 1 - (ci[0] < 0 < ci[1])
    return has_effect

alpha = 0.05               # допустимая вероятность ошибки I рода
sample_size = 1000         # размер групп
n_iter = 1000              # количество итераций бутстрепа

effects = []
for _ in range(1000):
    a, b = generate_data(sample_size, 0)
    has_effect = check_bootsrtap(a, b, n_iter, alpha)
    effects.append(has_effect)

error_rate = np.mean(np.array(effects) == 1)
print(f'Доля ошибок первого рода: {error_rate:0.3f}')
Доля ошибок первого рода: 0.053

Приведённая реализация бутстрепа не вычисляет p-value, поэтому построить распределение мы не сможем. Доля ошибок первого рода находится в районе заданного уровня значимости. Можно изменять значение уровня значимости и проверить, что бутстреп работает корректно при всех \alpha \in [0, 1].

Мы нашли способ, который корректно проверяет гипотезу о равенстве метрик отношения. Однако, этот способ имеет существенный недостаток. Бутстреп является вычислительно трудоёмким подходом. Если вам нужно оценивать эксперименты с большими объёмами данных, то применение бутстрепа может занимать часы или даже дни.

Дельта-метод

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

Существует формула для оценки дисперсии отношения двух случайных величин:

\mathbb{V}\left(\dfrac{X}{Y}\right) \approx \dfrac{1}{\mu_y^2} \mathbb{V}(X) - 2 \dfrac{\mu_x}{\mu_y^3} cov(X, Y) + \dfrac{\mu_x^2}{\mu_y^4} \mathbb{V}(Y)

где X и Y — случайные величины, а \mu_x и \mu_y — их математические ожидания.

Воспользуемся этой формулой для оценки дисперсии метрики отношения:

Теперь мы можем использовать новые оценки дисперсий для вычисления статистики теста Стьюдента:

t = \frac{\mathcal{R}_B - \mathcal{R}_A}{\sqrt{\mathbb{V}(\mathcal{R}_A) + \mathbb{V}(\mathcal{R}_B)}} \xrightarrow{N\to\inf} \mathcal{N}(0, 1)

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

def check_delta_method(a, b):
    """Проверка гипотезы с помощью дельта-метода.

    Возвращает p-value.
    """
    dict_ = {}
    for data, group_name in [(a, 'a',), (b, 'b',)]:
        n_user = len(data)
        array_x = np.array([np.sum(row) for row in data])
        array_y = np.array([len(row) for row in data])
        mean_x, mean_y = np.mean(array_x), np.mean(array_y)
        var_x, var_y = np.var(array_x), np.var(array_y)
        cov_xy = np.cov(array_x, array_y)[0, 1]
        # точечная оценка метрики
        pe_metric = np.sum(array_x) / np.sum(array_y)
        # оценка дисперсии метрики
        var_metric = (
            var_x / mean_y ** 2
            - 2 * (mean_x / mean_y ** 3) * cov_xy
            + (mean_x ** 2 / mean_y ** 4) * var_y
        ) / n_user
        dict_[f'pe_metric_{group_name}'] = pe_metric
        dict_[f'var_metric_{group_name}'] = var_metric
    var = dict_['var_metric_a'] + dict_['var_metric_b']
    delta = dict_['pe_metric_b'] - dict_['pe_metric_a']
    t = delta / np.sqrt(var)
    pvalue = (1 - stats.norm.cdf(np.abs(t))) * 2
    return pvalue

alpha = 0.05           # допустимая вероятность ошибки I рода
sample_size = 1000     # размер групп
effect = 50            # размер эффекта

pvalues_aa = []
pvalues_ab = []
for _ in range(1000):
    a, b = generate_data(sample_size, 0)
    pvalues_aa.append(check_delta_method(a, b))
    a, b = generate_data(sample_size, effect)
    pvalues_ab.append(check_delta_method(a, b))

plot_pvalue_distribution({'A/A': pvalues_aa, 'A/B': pvalues_ab})

В этот раз мы провели и А/А и А/Б эксперименты. Дельта-метод контролирует вероятность ошибки первого рода на уровне значимости, когда эффекта нет, и находит эффект, когда он есть. Критерий работает корректно.

Итоги

Мы разобрались, что такое метрики отношения и как их оценивать в экспериментах.

Вспомним подходы, которые рассмотрели:

  • Критерий Стьюдента, применённый в лоб к исходным данным, работает некорректно, так как данные зависимы.

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

  • Оценить значимость изменения метрики отношения можно с помощью бутстрепа. Основной недостаток — требует много вычислительных ресурсов.

  • Дельта-метод — способ оценки А/Б тестов с метрикой отношения, который основан на вычислении дисперсии зависимых данных.

Комментарии (0)