Привет, Хабр!

Представьте: вы запускаете A/B тест. Цель проста: проверить, работает ли новая кнопка лучше старой. Но тут же возникает мысль: «А вдруг мобильные юзеры и десктопные реагируют по‑разному? А что с новыми пользователями? Их мнение ведь явно не равноценное опытным юзерам». Без стратификации результат может быть так себе.

Что такое стратификация? Это способ сделать A/B тесты чуточку честнее. Берем выборку, делим ее на однородные группы — страты — по ключевым признакам (например, устройство и статус пользователя), а потом уже распределяем юзеров в группы А и Б.

Применять стратификацию стоит, если:

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

  2. Важно сохранить баланс: вы же не хотите, чтобы в одной группе было 90% новичков, а в другой — одни ветераны интерфейсов.

  3. Выборка достаточно большая: если страт больше, чем участников, тест превращается в цирк с мизерными данными.

Но не надо стратифицировать все подряд. Для мелких тестов или быстрых проверок «а что, если сделать кнопку розовой?» — проще оставить все как есть.

Реализация стратификации в A/B тестах

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

import pandas as pd
from sklearn.model_selection import train_test_split

# Создаём пример данных
data = pd.DataFrame({
    'user_id': range(1, 1001),
    'is_new': [1 if x < 500 else 0 for x in range(1000)],  # Первые 500 новых пользователей
    'device': ['mobile' if x % 2 == 0 else 'desktop' for x in range(1000)],
    'conversion': [0]*1000  # Здесь будут результаты теста
})

print(data.head())
   user_id  is_new  device  conversion
0        1       1  mobile           0
1        2       1 desktop           0
2        3       1  mobile           0
3        4       1 desktop           0
4        5       1  mobile           0

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

Теперь объединим категории is_new и device, чтобы создать страты. Это позволит учитывать два фактора при распределении пользователей.

# Создаём страты, объединяя категории
data['stratum'] = data['is_new'].astype(str) + '_' + data['device']

print(data['stratum'].value_counts())
1_mobile      250
0_mobile      250
1_desktop     250
0_desktop     250
Name: stratum, dtype: int64

Получили ровно четыре страты: новые мобильные, старые десктопные и так далее. Теперь разделяем всё это добро на группы A и B. Используем функцию train_test_split с параметром stratify, чтобы сохранить пропорции страт в обеих группах.

group_a, group_b = train_test_split(
    data,
    test_size=0.5,
    stratify=data['stratum'],
    random_state=42
)

print("Group A size:", group_a.shape[0])
print("Group B size:", group_b.shape[0])
Group A size: 500
Group B size: 500

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

Проверим, что страты распределились равномерно между группами A и B.

print("Group A strata distribution:")
print(group_a['stratum'].value_counts(normalize=True))

print("\nGroup B strata distribution:")
print(group_b['stratum'].value_counts(normalize=True))
Group A strata distribution:
1_mobile      0.25
0_mobile      0.25
1_desktop     0.25
0_desktop     0.25
Name: stratum, dtype: float64

Group B strata distribution:
1_mobile      0.25
0_mobile      0.25
1_desktop     0.25
0_desktop     0.25
Name: stratum, dtype: float64

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

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

import numpy as np
from scipy.stats import chi2_contingency

# Симуляция конверсий
np.random.seed(42)
group_a['conversion'] = np.random.binomial(1, 0.10, size=group_a.shape[0])  # Группа A конверсия 10%
group_b['conversion'] = np.random.binomial(1, 0.12, size=group_b.shape[0])  # Группа B конверсия 12%

# Подсчёт конверсий
conversions_a = group_a['conversion'].sum()
conversions_b = group_b['conversion'].sum()

print(f"Group A conversions: {conversions_a} / {group_a.shape[0]}")
print(f"Group B conversions: {conversions_b} / {group_b.shape[0]}")
Group A conversions: 49 / 500
Group B conversions: 62 / 500

В группе A конверсия 9.8%, а в группе B — 12.4%. Теперь проверим, значима ли эта разница.

Проведём тест хи‑квадрат, чтобы определить, значима ли разница между группами.

# Создание таблицы сопряжённости
contingency_table = pd.DataFrame({
    'A': [conversions_a, group_a.shape[0] - conversions_a],
    'B': [conversions_b, group_b.shape[0] - conversions_b]
}, index=['Converted', 'Not Converted'])

print("Contingency Table:")
print(contingency_table)

# Тест Хи-квадрат
chi2, p, dof, ex = chi2_contingency(contingency_table)

print(f"\nChi2 Statistic: {chi2}")
print(f"P-value: {p}")
Contingency Table:
              A   B
Converted    49  62
Not Converted 451 438

Chi2 Statistic: 2.0408163265306123
P-value: 0.15223670058397293

P‑value = 0.152 больше стандартного уровня значимости 0.05, что означает, что разница в конверсиях между группами A и B статистически не значима.

Иногда нужно учитывать больше факторов. Добавим ещё одну характеристику — географию пользователя.

# Добавим географию
data['country'] = ['USA' if x < 333 else 'Canada' if x < 666 else 'UK' for x in range(1000)]

# Обновляем страты
data['stratum'] = data['is_new'].astype(str) + '_' + data['device'] + '_' + data['country']

print(data['stratum'].value_counts())
1_mobile_USA        167
0_mobile_USA        167
1_mobile_Canada     167
0_mobile_Canada     167
1_mobile_UK         166
0_mobile_UK         166
1_desktop_USA       167
0_desktop_USA       167
1_desktop_Canada    167
0_desktop_Canada    167
1_desktop_UK        166
0_desktop_UK        166
Name: stratum, dtype: int64

Теперь у нас 12 страт, каждая с примерно 167 участниками. Чем больше факторов вы учитываете, тем более точным становится ваш тест.

Разделение с учетом новых страт:

# Разделение на группы A и B с учётом новых страт
group_a, group_b = train_test_split(
    data,
    test_size=0.5,
    stratify=data['stratum'],
    random_state=42
)

print("Group A strata distribution:")
print(group_a['stratum'].value_counts(normalize=True))

print("\nGroup B strata distribution:")
print(group_b['stratum'].value_counts(normalize=True))
Group A strata distribution:
1_mobile_USA         0.083333
0_mobile_USA         0.083333
1_mobile_Canada      0.083333
0_mobile_Canada      0.083333
1_mobile_UK          0.083333
0_mobile_UK          0.083333
1_desktop_USA       0.083333
0_desktop_USA       0.083333
1_desktop_Canada    0.083333
0_desktop_Canada    0.083333
1_desktop_UK        0.083333
0_desktop_UK        0.083333
Name: stratum, dtype: float64

Group B strata distribution:
1_mobile_USA         0.083333
0_mobile_USA         0.083333
1_mobile_Canada      0.083333
0_mobile_Canada      0.083333
1_mobile_UK          0.083333
0_mobile_UK          0.083333
1_desktop_USA       0.083333
0_desktop_USA       0.083333
1_desktop_Canada    0.083333
0_desktop_Canada    0.083333
1_desktop_UK        0.083333
0_desktop_UK        0.083333
Name: stratum, dtype: float64

Каждая из 12 страт равномерно распределена между группами A и B.


Стратификация — штука мощная, но будем честны: иногда она ни к чему. Если у вас выборка меньше, чем число страт, или нужно быстро проверить гипотезу «а что если добавить котиков в интерфейс?», — не усложняйте себе жизнь. Иногда достаточно простого случайного деления, и мир продолжит вращаться.

Какие нестандартные способы стратификации вы использовали? Делитесь своими кейсами в комментариях.

27 декабря в Otus пройдет открытый урок «Визуализация данных. Основные „финансовые“ графики, работа с mplfinance». На нем научимся строить графики в формате, принятом для анализа финансовых данных. Рассмотрим, что такое свечные графики; научимся строить дополнительные линии на графиках и доверительные интервалы. Записаться.

Все темы открытых уроков можно посмотреть в календаре.

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