Привет, Хабр!
Сегодня рассмотрим типичные грабли, на которые наступает каждый второй новичок, когда берется за A/B‑тесты.
Ошибка №1: «Мы не проверили корректность рандомизации»
Типичная ситуация: запускаем тест: есть группа А и группа B. В группе А — 10% пользователей, в группе B — тоже 10%. Вроде все ровно. А потом выясняется, что в А у нас почему‑то парни 18–25 лет, а в B — дамы 40+. Не то чтобы это плохо, но сравнивать их уже как‑то странно. Причина? Некорректная рандомизация или неправильная сегментация. Например, вы просто берете Math.random()
на фронте и решаете: «Если > 0.5 — в группу А, иначе в B». Но оказывается, что из‑за особенностей потока или кеширования группы распределились не так, как хотелось.
Как исправить:
Делайте рандомизацию на бэкенде.
Используйте стойкие идентификаторы (например, хэш от user_id) для распределения по группам. Это дает некую повторяемость и предсказуемость.
Проверяйте корректность распределения ещё до запуска основного теста.
Пример:
import hashlib
def assign_group(user_id: str):
# Генерим хэш от user_id, превращаем в число и берём модуль
# Допустим, хотим 50% в A, 50% в B
user_hash = hashlib.md5(user_id.encode()).hexdigest()
user_val = int(user_hash, 16) % 100
# Если число < 50 — идёт в группу A, иначе в B
return 'A' if user_val < 50 else 'B'
Так один и тот же юзер всегда в одной группе, а распределение близко к равномерному. Проверяйте статистику перед началом теста — np.bincount()
по массиву из 100 000 хэшей даст вам примерно ровное деление.
Ошибка №2: «Мы меняем функционал на ходу»
Типичный сценарий: решили протестить новый дизайн карточки товара. Запускаем тест, часть пользователей видят старый дизайн, часть — новый. Две недели тестим. На третьей неделе менеджер говорит: «Слушай, давай добавим туда еще новую акцию». И вот вы меняете B‑вариант по ходу теста! Проблема? Конечно. Ведь мы уже начитали данные, а тут внезапно B меняется, и сравнение становится некорректным.
Как исправить:
Не менять вариант B во время теста.
Если уж нужно, останавливайте тест и запускайте новый эксперимент.
Пишите код так, чтобы вариант B был изолирован в отдельный компонент. Тогда изменения в основной код не затронут группу B.
Пример:
// Предположим, мы условно проверяем фичу:
function ProductCard({ userGroup }) {
// Вариант A
const renderA = () => (
<div className="product-card">
<h2>Старый дизайн</h2>
<p>Обычная цена: 1000 руб.</p>
</div>
);
// Вариант B — выделен отдельно
// Важно: Не меняем логику по ходу теста, если хотим модифицировать — перезапускаем тест
const renderB = () => (
<div className="product-card-b">
<h2>Новый дизайн</h2>
<p>Обычная цена: 1000 руб. (Со скидкой 900 руб.)</p>
</div>
);
return userGroup === 'A' ? renderA() : renderB();
}
Ошибка №3: «Мы останавливаем тест, как только видим разницу»
Часто слышал: «О, через три дня видим +5% к конверсии в B. Выключаем тест, всё понятно!». Ну уж нет. Есть такая штука, как статистическая значимость. Возможно, через неделю разницы уже не будет или она сменит знак. Важно дождаться окончания теста с заранее определенными критериями. Без четкого плана остановки эксперимента вы рискуете получить ложноположительные результаты.
Как исправить:
Определить длительность теста и критерии остановки ещё до запуска.
Использовать статистические методы, например, t‑тест или Z‑тест, и убедиться, что p‑value достаточно низкое.
Применять поправки на множественные сравнения, если мы запускаем много тестов.
Пример:
import scipy.stats as stats
import numpy as np
# Пример: CTR для группы A и B
ctr_A = 0.1
ctr_B = 0.12
n_A = 10000
n_B = 10000
conversions_A = int(ctr_A * n_A)
conversions_B = int(ctr_B * n_B)
# Проверим, что у нас есть 2 набора данных: успехи/неуспехи
data_A = [1]*conversions_A + [0]*(n_A - conversions_A)
data_B = [1]*conversions_B + [0]*(n_B - conversions_B)
# Выполним двусторонний t-тест для пропорций
# В реальности для пропорций лучше использовать z-тест, но для примера сгодится и так.
t_stat, p_val = stats.ttest_ind(data_A, data_B)
print("t-статистика:", t_stat)
print("p-значение:", p_val)
# Дальше решаем: если p < 0.05 (или строже, 0.01), считаем, что разница значима.
# Но если мы остановили тест слишком рано, можем получить некорректные выводы.
Ошибка №4: «Мы игнорируем доверительные интервалы и размер эффекта»
Некоторые смотрят только на p‑value. Это ошибка. Предположим, разница статистически значима, но эффект микроскопический. Вы потратили усилия, внедрили новый дизайн, а в итоге получили +0.5% к конверсии. Оно того стоило? Может быть, а может и нет. Нужно смотреть на доверительные интервалы и оценивать величину эффекта.
Поэтому нужно:
Считать не только p‑value, но и доверительные интервалы для метрик.
Оценивать размер эффекта. Иногда стоит задать порог, типа «Мы внедрим новое решение только если конверсия вырастет минимум на 2%.»
Пример доверительных интервалов:
from math import sqrt
def proportion_confidence_interval(conversions, n, z=1.96):
p = conversions / n
se = sqrt(p*(1-p)/n)
ci_lower = p - z*se
ci_upper = p + z*se
return p, ci_lower, ci_upper
p_A, lower_A, upper_A = proportion_confidence_interval(conversions_A, n_A)
p_B, lower_B, upper_B = proportion_confidence_interval(conversions_B, n_B)
print("A:", p_A, "CI:", (lower_A, upper_A))
print("B:", p_B, "CI:", (lower_B, upper_B))
# Сравним интервалы. Если интервалы сильно пересекаются — эффект сомнительный.
Даже если p‑value говорит о «значимости», но интервалы перекрывают большинство возможных значений, выгода может быть чисто теоретической.
Ошибка №5: «Мы не учитываем сезонность и другие внешние факторы»
A/B‑тестирование — это про сравнение двух вариантов при прочих равных условиях. Но если вы проводите тест на неделе больших распродаж или в сезон, когда трафик нестабилен, результаты могут быть искажены. Сезонность, акции конкурентов, новости в СМИ — все это может повлиять на поведение пользователей.
Как исправить:
Планируйте тесты на стабильные периоды.
Используйте блокировку — разбивайте пользователей по сегментам с учетом сезонов, гео или канала трафика.
Делайте несколько итераций теста в разные периоды, чтобы исключить влияние временных факторов.
Пример стратификации по сегментам:
# Представим, что есть список пользователей с их гео и у нас разное поведение по странам.
# Нужно распределять попарно из каждого сегмента, чтобы не исказить распределение.
import random
users = [
{"user_id": "u1", "country": "RU"},
{"user_id": "u2", "country": "US"},
{"user_id": "u3", "country": "RU"},
{"user_id": "u4", "country": "RU"},
{"user_id": "u5", "country": "US"},
]
# Разобьём пользователей по стране
by_country = {}
for u in users:
c = u["country"]
by_country.setdefault(c, []).append(u["user_id"])
# Теперь внутри каждого сегмента рандомим группы A/B
groups = {}
for c, user_list in by_country.items():
for uid in user_list:
# Хэшируем, чтобы было детерминированно
group = assign_group(uid)
groups[uid] = group
print(groups)
# Видим, что для каждого сегмента распределение примерно ровное.
Подводя итоги
Резюмируем:
Некачественная рандомизация: нужно использовать стойкие идентификаторы, хэширование и проверять распределение до старта эксперимента.
Изменение функционала B‑варианта по ходу теста: нельзя делать, если хотим чистых результатов. Прекращаем тест и запускаем новый.
Преждевременное прекращение теста: без статистической значимости и выжидания достаточного объема выборки можно получить ошибочные выводы.
Игнорирование доверительных интервалов: p‑value — еще не всё. Смотрим на размер эффекта и доверительные интервалы, чтобы понять практическую значимость.
Неучет сезонности и внешних факторов: анализируем данные в стабильные периоды, используем стратификацию и блочное рандомизированное разделение на группы.
Реальность такова, что хороший эксперимент требует терпения и методологической точности.
Освоить мощные навыки анализа данных (анализ требований, статистика, BI) можно на онлайн-курсе «Аналитик данных».
19 декабря в рамках курса пройдет открытый урок, на котором разберем основные ошибки при создании визуализаций данных. Если интересно, записывайтесь по ссылке.