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

Разница между парным тестом и многомерным экспериментом на живом трафике

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

Сильная сторона метода именно в деталях интерфейса: формулировки кнопок, тексты подсказок, число шагов формы, порядок блоков на витрине. Глобальный редизайн честнее запускать парным тестом, потому что целый макет это один фактор с двумя уровнями, а вот тонкая настройка десятка мелочей создаёт то самое пространство, где многомерный план раскрывается полностью.

Рынок инструментов пережил встряску: бесплатный Google Optimize и его платная версия Optimize 360 перестали работать 30 сентября 2023 года, все эксперименты, оставшиеся активными к этой дате, были завершены. Команды перебрались на коммерческие платформы и на открытые проекты вроде GrowthBook, чей браузерный пакет дошёл до версии 1.8.0, а SDK для Python до версии 3.2.2.

pip install growthbook==3.2.2

Комбинации уровней перебираются элементарно, и в этом переборе нет ничего ручного:

import itertools

factors = {
    "заголовок": ["короткий", "с выгодой"],
    "кнопка": ["серая", "зелёная"],
    "форма": ["один шаг", "два шага"],
    "подсказки": ["есть", "нет"],
}

variants = list(itertools.product(*factors.values()))
print(len(variants))   # 16

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

Метрики выбирают заранее и держат в строгой иерархии: одна первичная, остальные охраняемые. Первичной обычно становится конверсия в целевое действие, охраняемыми скорость загрузки, выручка на посетителя и доля ошибок формы. Каждой комбинации присваивают короткий код от v01 до v16, по которому события легко разложить по ячейкам отчёта.

Полный факторный план превращает четыре изменения в шестнадцать вариантов

Математика полного факторного плана проста как таблица умножения: четыре фактора по два уровня дают два в четвёртой степени, то есть шестнадцать комбинаций, пять факторов уже тридцать две, шесть упирается в шестьдесят четыре. Сколько трафика нужно, чтобы прокормить шестнадцать аппетитов? Сайту с 400 тысячами визитов в месяц достаётся по 25 тысяч визитов на вариант, и это без потерь на ботов и внутренние адреса. Для скромных площадок придуманы дробные планы: ортогональная матрица L9 проверяет четыре трёхуровневых фактора на девяти комбинациях вместо восьмидесяти одной, платой за экономию становится часть взаимодействий, которую план сознательно приносит в жертву. Полный план честнее, дробный дешевле, выбор зависит от трафика и терпения команды.

Расчёт размера выборки до запуска защищает тест от самообмана

Выборку считают до запуска, а не после него. Формула для доли конверсии опирается на две константы: перцентиль 97.5 для уровня значимости 5 процентов, равный 1.959964, и перцентиль 80 для мощности, равный 0.841621. Минимально обнаружимый эффект это наименьший сдвиг, который эксперимент ловит с вероятностью не ниже 80 процентов при текущей дисперсии метрики.

import math

def sample_size(p1, p2):
    za = 1.959964
    zb = 0.841621
    pbar = (p1 + p2) / 2
    num = (za * math.sqrt(2 * pbar * (1 - pbar)) +
           zb * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2
    return math.ceil(num / (p2 - p1) ** 2)

print(sample_size(0.10, 0.11))     # 14751 на вариант
print(sample_size(0.10, 0.105))    # 57763 на вариант

Базовая конверсия 10 процентов, цель 11 процентов: на каждый вариант нужно 14 751 визит, а на шестнадцать комбинаций полного плана набегает 236 016 визитов. Сайту со 100 тысячами визитов в месяц такой эксперимент займёт почти два с половиной месяца, и это ещё без времени на прогрев и вычищение ботов. Аппетит к мелким сдвигам карается квадратично: желание поймать прирост на 0.5 процентного пункта поднимает планку до 57 763 визитов на вариант, то есть почти в четыре раза против исходного сценария. Для непрерывных метрик вроде среднего чека формула меняется: вместо долей в расчёт входит дисперсия, и длинный хвост делает тест дороже. Редкие крупные покупки тянут разброс вверх, поэтому выручку на пользователя логарифмируют или урезают выбросы по 99-му перцентилю, и требуемая выборка падает.

Взаимодействие факторов видно только при одновременном показе изменений

Главный эффект фактора это разность средних по его уровням, усреднённая по всем остальным факторам. Взаимодействие же показывает, насколько вклад фактора меняется в зависимости от соседа, и именно ради него затевают матрицу. Простой числовой пример объясняет это быстрее любых определений:

a = 9.1    # старый заголовок, серая кнопка
b = 12.4   # старый заголовок, зелёная кнопка
c = 10.3   # новый заголовок, серая кнопка
d = 18.0   # новый заголовок, зелёная кнопка

color_old = b - a                      # 3.3
color_new = d - c                      # 7.7
interaction = color_new - color_old     # 4.4
print(color_old, color_new, interaction)

Зелёная кнопка добавляет 3.3 процентного пункта к старому заголовку и 7.7 к новому. Сама по себе кнопка вроде бы работает всегда, но по-настоящему её раскрывает только пара с новым текстом. Взаимодействие в 4.4 пункта это разница между "чуть лучше" и "лучше почти вдвое". Последовательные парные тесты усреднили бы обе картины и выдали аккуратную середину 5.5, потеряв самое интересное. Комплексные изменения нельзя разобрать по одному именно потому, что суть комплекта живёт в сочетаниях.

Многократные сравнения и подглядывания портят чистоту статистики

Шестнадцать вариантов порождают сто двадцать пар, если сравнивать всех со всеми, или пятнадцать пар, если каждого проверять с контролем. При пороге значимости 5 процентов хотя бы одна ложная тревога среди пятнадцати сравнений вспыхивает с вероятностью 53.7 процента, то есть чаще, чем в половине запусков. Классическая поправка Бонферрони делит порог на число сравнений: 0.05 на 15 даёт 0.0033, и победителю приходится быть убедительнее обычного. Проверка значимости для одной пары выглядит так:

import math

def chi_square(conv_a, n_a, conv_b, n_b):
    rate = (conv_a + conv_b) / (n_a + n_b)
    e_a = n_a * rate
    e_b = n_b * rate
    chi2 = 0.0
    for observed, expected in (
        (conv_a, e_a),
        (n_a - conv_a, n_a - e_a),
        (conv_b, e_b),
        (n_b - conv_b, n_b - e_b),
    ):
        chi2 += (observed - expected) ** 2 / expected
    return round(chi2, 2), round(math.erfc(math.sqrt(chi2 / 2)), 4)

print(chi_square(12480, 120000, 12951, 120000))   # (9.76, 0.0018)

Контроль показал 10.4 процента, вариант 10.79, относительный прирост 3.8 процента, хи-квадрат 9.76 и p-значение 0.0018. Даже суровый порог Бонферрони со своими 0.0033 такой результат не съедает. Отдельная беда это подглядывания: открывать отчёт каждый день и останавливать тест при первой звёздочке всё равно что бросать монету до первого орла. Классическое лекарство называется фиксированным горизонтом, современные платформы предлагают последовательный анализ с заранее рассчитанными границами. Полезен и пустой прогон A/A: два одинаковых контрольных варианта в среднем дают ложную значимость в каждом двадцатом запуске при пороге 5 процентов, а если тревог заметно больше, искать проблему нужно в раздаче трафика, а не в победителях. Сегментный разбор после теста требует той же осторожности: разбивка на группы снова умножает число сравнений, а эффект из одного сегмента легко принять за общую закономерность. Сегменты заявляют до запуска, а не после того, как отчёт подсветил удачный срез.

Снижение дисперсии метрики укорачивает тест и уточняет выводы

Дисперсия метрики это шум, в котором тонет эффект. Метод CUPED строит скорректированную метрику, вычитая из исходной ковариату, умноженную на коэффициент тета, при этом тета равна отношению ковариации метрики с ковариатой к дисперсии ковариаты. Идеальная ковариата это значение той же метрики у того же пользователя до эксперимента.

def cuped_theta(y, y_cv):
    n = len(y)
    mean_y = sum(y) / n
    mean_c = sum(y_cv) / n
    cov = sum((y[i] - mean_y) * (y_cv[i] - mean_c) for i in range(n)) / n
    var_cv = sum((v - mean_c) ** 2 for v in y_cv) / n
    return cov / var_cv

theta = cuped_theta(revenue_after, revenue_before)
adjusted = [r - theta * c for r, c in zip(revenue_after, revenue_before)]

Дисперсия скорректированной метрики сжимается пропорционально единице минус квадрат корреляции: связь до и после с корреляцией 0.7 срезает 51 процент дисперсии, и тест на непрерывных метриках вроде выручки идёт почти вдвое быстрее. Бонусом выравниваются постоянные пользователи, чей личный уровень потребления не зависит от варианта, а случайные всплески усредняются. Стратификация решает ту же задачу с другой стороны: трафик делят по известным сегментам заранее, чтобы группы не расходились ещё до старта. Практический вывод короткий: применять CUPED, мощность будет выше.

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

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

function hash32(str) {
  let h = 0x811c9dc5;
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0;
}

function assign(userId, experiment, variants) {
  const value = hash32(experiment + ":" + userId) / 4294967296;
  return variants[Math.floor(value * variants.length)];
}

Алгоритм FNV-1a раскидывает строки равномерно по 4 294 967 296 значениям, а название эксперимента внутри строки гарантирует независимое попадание в разные тесты. Дальше работает дисциплина. Длительность считают не меньше двух полных недель, чтобы в выборку попали и будни, и выходные, а эффект новизны, при котором любые изменения бодрят аудиторию самим фактом обновления, успел затухнуть. Охраняемые метрики вроде скорости загрузки и доли отписок держат рядом с основной, чтобы победа в конверсии не пришла вместе с потерянными клиентами. Проваленный тест тоже результат: каталог проверенных гипотез экономит командам месяцы повторения чужих ошибок.

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

Проверка готовности перед запуском умещается в пять пунктов:
1. зафиксировать письменно метрику успеха и охраняемые метрики;
2. посчитать выборку и длительность для каждой комбинации плана;
3. прогнать пустой тест A/A и убедиться в исправности раздачи;
4. выбрать порог значимости с поправкой на число сравнений;
5. назначить единый горизонт и не останавливать тест раньше срока.

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