Поправки на множественную проверку гипотез в Tester (Ambrosia)

Когда эксперимент оценивается сразу по нескольким метрикам (или нескольким группам), p-value нужно корректировать на множественные сравнения. Начиная с версии 0.5.2, Tester поддерживает восемь методов поправки. В этом ноутбуке показана сама проблема и как пользоваться каждым методом.

1. Проблема множественных сравнений

Один тест на уровне 5% даёт 5% шанс ложного срабатывания. Если метрик много, шанс, что хотя бы одна окажется «значимой» по чистой случайности, быстро растёт. Поправка на множественность удерживает этот шанс под контролем.

Сымитируем A/B-тест с шестью метриками: у пяти нет реального эффекта (metric_1..metric_5), а у одной есть настоящий положительный эффект (metric_6).

[1]:
import numpy as np
import pandas as pd

from ambrosia.tester import Tester

N = 2000  # пользователей в группе
rng = np.random.default_rng(8)

data = {"group": ["A"] * N + ["B"] * N}
for i in range(1, 6):  # metric_1..metric_5: эффекта нет (A и B из одного распределения)
    data[f"metric_{i}"] = np.r_[rng.normal(0.0, 1.0, N), rng.normal(0.0, 1.0, N)]
data["metric_6"] = np.r_[rng.normal(0.0, 1.0, N), rng.normal(0.15, 1.0, N)]  # реальный эффект в B

df = pd.DataFrame(data)
metrics = [f"metric_{i}" for i in range(1, 7)]
df.head()
[1]:
group metric_1 metric_2 metric_3 metric_4 metric_5 metric_6
0 A -1.738266 -0.006715 -0.886940 1.199254 -1.841961 -0.164049
1 A -1.336643 -0.244478 0.374934 1.521730 -0.789614 -0.582462
2 A -1.361107 -0.471546 -0.497588 0.121381 0.035406 -1.076962
3 A -0.351617 0.692823 0.434765 -0.011001 -2.566139 0.218516
4 A -2.312582 -0.573991 1.012139 0.385061 0.266645 -0.536790

2. Без поправки

Запустим Tester с correction_method=None. Обратите внимание на «нулевые» метрики: по случайности одна из них опускается ниже порога 0.05 — это ложное срабатывание.

[2]:
tester = Tester(dataframe=df, column_groups="group", metrics=metrics)

raw = tester.run("absolute", method="theory", correction_method=None, as_table=True)
raw_view = raw[["metric name", "pvalue"]].rename(columns={"metric name": "метрика", "pvalue": "p-value"})
raw_view["значим при 0.05"] = raw_view["p-value"] < 0.05
raw_view.round(4)
[2]:
метрика p-value значим при 0.05
0 metric_1 0.1387 False
1 metric_2 0.4573 False
2 metric_3 0.8861 False
3 metric_4 0.0320 True
4 metric_5 0.6910 False
5 metric_6 0.0000 True

Здесь metric_4 помечается как значимая, хотя реального эффекта у неё нет, а metric_6 (настоящий эффект) — тоже значима. При пяти «нулевых» метриках шанс хотя бы одного такого ложного срабатывания был около 23%.

3. Как включить поправку

Передайте имя метода в correction_method. По умолчанию это "bonferroni", поэтому старый код не меняет поведения. Полный список поддерживаемых методов:

[3]:
from ambrosia.tools import multitest

multitest.available_methods()
[3]:
['bonferroni', 'sidak', 'holm', 'holm-sidak', 'fdr_bh', 'fdr_by', 'hommel', 'simes-hochberg']

4. Сравнение методов

В таблице рядом с сырыми p-value показаны все скорректированные варианты. Все методы увеличивают p-value (становятся консервативнее); различаются тем, насколько.

[4]:
methods = ["bonferroni", "holm", "holm-sidak", "sidak", "fdr_bh", "fdr_by", "hommel", "simes-hochberg"]

comparison = pd.DataFrame({"метрика": raw["metric name"].values, "без поправки": raw["pvalue"].values})
for m in methods:
    res = tester.run("absolute", method="theory", correction_method=m, as_table=True)
    comparison[m] = res["pvalue"].values
comparison.round(4)
[4]:
метрика без поправки bonferroni holm holm-sidak sidak fdr_bh fdr_by hommel simes-hochberg
0 metric_1 0.1387 0.8319 0.5546 0.4495 0.5916 0.2773 0.6794 0.5546 0.5546
1 metric_2 0.4573 1.0000 1.0000 0.8401 0.9744 0.6859 1.0000 0.8861 0.8861
2 metric_3 0.8861 1.0000 1.0000 0.9045 1.0000 0.8861 1.0000 0.8861 0.8861
3 metric_4 0.0320 0.1922 0.1602 0.1502 0.1774 0.0961 0.2354 0.1602 0.1602
4 metric_5 0.6910 1.0000 1.0000 0.9045 0.9991 0.8293 1.0000 0.8861 0.8861
5 metric_6 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000

5. Какой метод выбрать?

  • Контроль вероятности хотя бы одной ошибки (FWER)bonferroni, holm, holm-sidak, sidak, hommel, simes-hochberg. Ограничивают вероятность сделать хотя бы одно ложное срабатывание. bonferroni самый простой, но самый консервативный; holm и пошаговые методы отвергают как минимум столько же, сохраняя больше мощности.

  • Контроль доли ложных открытий (FDR)fdr_bh (Бенджамини–Хохберг) и fdr_by (Бенджамини–Иекутиели). Контролируют ожидаемую долю ложных срабатываний среди метрик, признанных значимыми, — обычно правильный компромисс, когда метрик много.

Сколько метрик остаются значимыми при 5% для каждого подхода?

[5]:
alpha = 0.05
for label, cm in [("none", None), ("bonferroni", "bonferroni"), ("holm", "holm"), ("fdr_bh", "fdr_bh")]:
    res = tester.run("absolute", method="theory", correction_method=cm, as_table=True)
    n_sig = int((res["pvalue"] < alpha).sum())
    print(f"{label:11s}: значимых метрик при {alpha} = {n_sig}")
none       : значимых метрик при 0.05 = 2
bonferroni : значимых метрик при 0.05 = 1
holm       : значимых метрик при 0.05 = 1
fdr_bh     : значимых метрик при 0.05 = 1

none показывает 2 (одна из них — ложное срабатывание). Любая поправка убирает ложное срабатывание, а holm и Бенджамини–Хохберг при этом сохраняют настоящую metric_6.

6. Что происходит с доверительными интервалами?

Для методов с постоянным масштабированием (bonferroni, sidak) доверительные интервалы расширяются, чтобы согласоваться со скорректированным решением. Пошаговые методы (Holm, FDR, …) корректируют только p-value и оставляют интервалы на номинальном уровне.

[6]:
none_ci = tester.run("absolute", method="theory", correction_method=None, as_table=True)
bonf_ci = tester.run("absolute", method="theory", correction_method="bonferroni", as_table=True)
holm_ci = tester.run("absolute", method="theory", correction_method="holm", as_table=True)

pd.DataFrame({
    "метрика": none_ci["metric name"].values,
    "ДИ (без поправки)": list(none_ci["confidence_interval"]),
    "ДИ (bonferroni — шире)": list(bonf_ci["confidence_interval"]),
    "ДИ (holm — как без поправки)": list(holm_ci["confidence_interval"]),
})
[6]:
метрика ДИ (без поправки) ДИ (bonferroni — шире) ДИ (holm — как без поправки)
0 metric_1 (-0.0155, 0.1111) (-0.0373, 0.133) (-0.0155, 0.1111)
1 metric_2 (-0.0838, 0.0377) (-0.1048, 0.0587) (-0.0838, 0.0377)
2 metric_3 (-0.0664, 0.0573) (-0.0878, 0.0787) (-0.0664, 0.0573)
3 metric_4 (-0.1285, -0.0058) (-0.1497, 0.0154) (-0.1285, -0.0058)
4 metric_5 (-0.0486, 0.0733) (-0.0697, 0.0944) (-0.0486, 0.0733)
5 metric_6 (0.1358, 0.2583) (0.1146, 0.2795) (0.1358, 0.2583)

7. Алиасы и итог

Поддерживаются удобные алиасы, например "benjamini-hochberg" == "fdr_bh" и "benjamini-yekutieli" == "fdr_by".

[7]:
alias = tester.run("absolute", method="theory", correction_method="benjamini-hochberg", as_table=True)
canonical = tester.run("absolute", method="theory", correction_method="fdr_bh", as_table=True)
bool((alias["pvalue"].values == canonical["pvalue"].values).all())
[7]:
True

Памятка

Ситуация

Рекомендуемый correction_method

Одна метрика, две группы

None (корректировать нечего)

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

"holm" (или "bonferroni")

Много метрик на дашборде

"fdr_bh"

Нужны ещё и скорректированные доверительные интервалы

"bonferroni" или "sidak"

Переключение поправки — это изменение одного аргумента в Tester.run.