Привет, Хабр!
Представьте: вы запускаете A/B тест. Цель проста: проверить, работает ли новая кнопка лучше старой. Но тут же возникает мысль: «А вдруг мобильные юзеры и десктопные реагируют по‑разному? А что с новыми пользователями? Их мнение ведь явно не равноценное опытным юзерам». Без стратификации результат может быть так себе.
Что такое стратификация? Это способ сделать A/B тесты чуточку честнее. Берем выборку, делим ее на однородные группы — страты — по ключевым признакам (например, устройство и статус пользователя), а потом уже распределяем юзеров в группы А и Б.
Применять стратификацию стоит, если:
Много сегментов: например, мобильные и десктопные пользователи, которые ведут себя, как север и юг: вроде люди, но с совершенно разными привычками.
Важно сохранить баланс: вы же не хотите, чтобы в одной группе было 90% новичков, а в другой — одни ветераны интерфейсов.
Выборка достаточно большая: если страт больше, чем участников, тест превращается в цирк с мизерными данными.
Но не надо стратифицировать все подряд. Для мелких тестов или быстрых проверок «а что, если сделать кнопку розовой?» — проще оставить все как есть.
Реализация стратификации в 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». На нем научимся строить графики в формате, принятом для анализа финансовых данных. Рассмотрим, что такое свечные графики; научимся строить дополнительные линии на графиках и доверительные интервалы. Записаться.
Все темы открытых уроков можно посмотреть в календаре.