Поправки на множественную проверку гипотез в 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
Памятка
Ситуация |
Рекомендуемый |
|---|---|
Одна метрика, две группы |
|
Несколько ключевых метрик, нельзя допустить ни одного ложного срабатывания |
|
Много метрик на дашборде |
|
Нужны ещё и скорректированные доверительные интервалы |
|
Переключение поправки — это изменение одного аргумента в Tester.run.