以下の内容はhttps://hoxo-m.hatenablog.com/entry/2025/03/05/080000より取得しました。


デルタ法を使った統計的仮説検定を行うパッケージ deltatest をリリースしました

Web における A/B テストでは、ランダム化単位と分析単位が異なるということがよくあります。 例えば、A か B かのランダム割付がユーザーごとに行われるのに対して、評価指標として分析したいのはページビューごとのクリック率だったりします。 この場合、ランダム化単位はユーザー、分析単位はページビューということになります。

一般に、Web の A/B テストはサンプルサイズが非常に大きいため、中心極限定理により平均値は正規分布に従うと仮定できるので、Z検定がよく使われます。 しかし、ランダム化単位と分析単位が異なると、この Z検定にまずいことが起きます。 具体的には、一人のユーザーが複数のページビューを発生させることができ、それぞれのユーザーは異なるクリック率を持つため、Z検定が仮定する独立同分布 (i.i.d.) に違反してしまいます。

データがこのような性質を持つと、A と B で評価指標に差が無い場合でも、Z検定の p値は小さめに出やすくなります。 (シミュレーションのコードはこの記事の最後にあります)

したがって、A と B に差が無い場合に、誤って有意差があると判定するリスクが高くなってしまいます。

デルタ法を使った統計的仮説検定

この問題に対して、Deng et al. (2018) では、デルタ法を使った解決策を提示しています。 相関のあるデータにおいて、Z検定の p値が小さくなりやすいのは、分散(ひいては標準誤差)が過小推定されることが原因です。 そこで、デルタ法を使って、データ内相関を考慮した形で分散推定式を導出します。 Z検定の分散推定式をこれに置き換えることで、分散の過小推定を防ぐことができます。

今回、この検定手法を簡単に実行できるパッケージ deltatest を CRAN に登録しました。 インストールするには次を実行します。

install.packages("deltatest")

利用するには、ユーザー(ランダム化単位)ごとに評価指標の分子と分母を集計したデータを準備します。

# A tibble: 200 × 4
    user group click pageview
   <int> <chr> <int>    <int>
 1     1 B         7        8
 2     2 A         4        8
 3     3 B         5       11
 4     4 A         3        9
 5     5 A         3       12
 6     6 B         3        9
 7     7 A         6       14
 8     8 A         3        8
 9     9 A         3        9
10    10 A         5       11
# ℹ 190 more rows

このデータに対して、デルタ法を使った Z検定を実行するには、次のようにします。

library(deltatest)

deltatest(data, click / pageview, by = group)
 Two Sample Z-test Using the Delta Method

data:  click/pageview by group
Z = 1.0601, p-value = 0.2891
alternative hypothesis: true difference in means between control and treatment is not equal to 0
95 percent confidence interval:
 -0.02525010  0.08474725
sample estimates:
  mean in control mean in treatment        difference 
       0.47876448        0.50851305        0.02974857 

この手法を使うと、A と B に差が無い場合、検定の p値は一様分布します。

したがって、A と B に差が無い場合に、有意差を誤って検出するリスク(タイプ I エラー率)を正しい値(有意水準)に抑えることができます。

詳しい使い方については次をご参照ください。

参考

コード

generate_data <- function(n_user) {
  data <- data.frame()
  for (i in 1:n_user) {
    group <- sample(c("A", "B"), size = 1)
    N <- rpois(1, 9) + 1
    prob <- rnorm(1, mean = 0.5, sd = 0.1)
    if (prob < 0 || prob > 1) next
    click <- rbinom(N, size = 1, prob)
    d <- data.frame(user = i, group, click)
    data <- rbind(data, d)
  }
  data
}

ztest <- function(data) {
  data_A <- subset(data, group == "A")
  data_B <- subset(data, group == "B")
  
  mean_A <- mean(data_A$click)
  mean_B <- mean(data_B$click)
  diff <- mean_B - mean_A
  se2_A <- var(data_A$click)
  se2_B <- var(data_B$click)
  se <- sqrt(se2_A / nrow(data_A) + se2_B / nrow(data_B))
  z <- diff / se
  p <- 2 * pnorm(-abs(z))
  list(p.value = p)
}

library(mirai)
daemons(0)
daemons(10, seed = 314)
m <- mirai_map(1:10, function(x) {
  p_values <- double(500)
  for (i in 1:500) {
    data <- generate_data(n_user = 200)
    p_value <- ztest(data)$p.value
    p_values[i] <- p_value
  }
  p_values
}, generate_data = generate_data, ztest = ztest)

p_value <- m[.flat]

library(dplyr)
df <- data.frame(p_value) |>
  mutate(range = cut(p_value, breaks = seq(0, 1, by = 0.05))) |>
  group_by(range) |>
  summarise(p = factor(ceiling(max(p_value) * 20) / 20), n = n()) |>
  mutate(prop = n / sum(n))

library(ggplot2)
ggplot(df, aes(p, prop)) +
  geom_col() +
  geom_hline(yintercept = 0.05, color = "red") +
  scale_y_continuous(breaks = seq(0, 1, by = 0.01), minor_breaks = NULL) +
  xlab("p-value") + ylab("proportion")


library(mirai)
daemons(0)
daemons(10, seed = 314)
everywhere(library(dplyr))
everywhere(library(deltatest))
m <- mirai_map(1:10, function(x) {
  p_values <- double(500)
  for (i in 1:500) {
    data <- generate_data(n_user = 200) |>
      group_by(user, group) |>
      summarise(click = sum(click), pageview = n(), .groups = "drop")
    p_value <- deltatest(data, click / pageview, by = group, quiet = TRUE)$p.value
    p_values[i] <- p_value
  }
  p_values
}, generate_data = generate_data, ztest = ztest)

p_value <- m[.flat]

library(dplyr)
df <- data.frame(p_value) |>
  mutate(range = cut(p_value, breaks = seq(0, 1, by = 0.05))) |>
  group_by(range) |>
  summarise(p = factor(ceiling(max(p_value) * 20) / 20), n = n()) |>
  mutate(prop = n / sum(n))

library(ggplot2)
ggplot(df, aes(p, prop)) +
  geom_col() +
  geom_hline(yintercept = 0.05, color = "red") +
  scale_y_continuous(breaks = seq(0, 1, by = 0.01), minor_breaks = NULL, limits = c(0, 0.1)) +
  xlab("p-value") + ylab("proportion")



以上の内容はhttps://hoxo-m.hatenablog.com/entry/2025/03/05/080000より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14