💊

母比率の差の検定に必要なサンプルサイズを計算する(実装編)

2025/01/14に公開

はじめに

ABテストにおいて統計的仮説検定を用いて意思決定を行う場合、サンプルサイズ設計は必須である。これを怠ると、得られた結果(KPIの差分)が真に意義のある差分か単なるブレか区別がつかないなどの問題が起きてしまう。

今回は、CVRやCTRのような比率・割合値をKPI(目的変数)とした場合における、サンプルサイズの算出方法をまとめる。統計学の言葉で書くなら「母比率の差の検定におけるサンプルサイズの計算」ということになる。

本稿では、PythonとRによる実装をまとめた。
次の記事では、この記事で紹介したPythonやRの各種ライブラリ・パッケージで算出されたサンプルサイズがどのように導出されるのかをまとめる。

前提

サンプルサイズの計算方法は、利用する検定によって異なる。
本稿で紹介する実装は、2つのZ検定(統計量が標準正規分布に従うことを利用した検定)に基づいて実装されたものである。

なお、母比率の差の検定方法は本稿で紹介するもの以外にも存在する。例えば、割合値を単なる連続値と捉えてt検定を行う、正規近似を利用せず二項検定を行う、カイ二乗検定を実施する、などである。これらはあまり需要はない(≒実務で利用されることは少ない)と考え、本記事では紹介していない。

なおここでの実務とは、筆者が経験してきたような、サンプルサイズの確保が容易なWebサービスの改善における話であることに留意頂きたい。

まとめ

忙しい人はここだけ読んでください。

実装

母比率の差の検定において、Z検定を利用するものは2つ存在する。1つは得られた比率を角変換して検定するもの、もう1つは角変換しないものである。

Python・Rについて、検定の種類(角変換の有無) x 2群のサンプルサイズ(等しい/等しくない)で利用できるライブラリ・パッケージを表形式でまとめた。

Python

2群のサンプルサイズが等しい 2群のサンプルサイズが異なる
角変換あり statsmodels.stats.power.NormalIndPower.solve_power statsmodels.stats.power.NormalIndPower.solve_power
角変換なし statsmodels.stats.power.normal_sample_size_one_tail statsmodels.stats.proportion.samplesize_proportions_2indep_onetail

R

2群のサンプルサイズが等しい 2群のサンプルサイズが異なる
角変換あり pwr.2p.test pwr.2p2n.test
角変換なし power.prop.test -

角変換なし・サンプルサイズが異なる場合を実装しているパッケージが見つからず。。。

角変換すべきか

しなくてよい。

母比率の差の検定で利用するZ検定

上で実装されているものは、2つのZ検定に基づいている。
これらの検定について簡単に触れておく。詳細は次の記事を参照のこと。

問題設定

ECサイトでメルマガ配布をしているケースを考える。メルマガに記載する文言を変更することで、メルマガ経由のCVR(メルマガ開封かつ購買ありUU数 / メルマガ配信UU数)の向上を狙いたい。これを検証すべくABテストを実施する。
なお、直近数ヶ月のメルマガ経由のCVRの実績は10%前後であり、今回のテストでは1%pt以上の改善(10% → 11%)であればビジネス的に意義があるとしたい。

まず初めに共通の仮定を置く。
メルマガ配信UU数を n_i、メルマガ開封かつ購買ありUU数を X_i として、 X_i が母比率 p_i の二項分布に従うと仮定する。

X_t ~ \sim Bin(n_t, p_t) \\ X_c ~ \sim Bin(n_c, p_c)

なお、添字はtest群(検証したい新パターンのメルマガを受け取るUU)、control群(従来通りのメルマガを受け取るUU)を表す。

角変換あり

ここで母比率の推定量として、\hat{p_i} = \frac{X_i}{n_i} を考える。n_i \to \infty を満たす時、中心極限定理により X_i は正規分布に従うことから、\hat{p_i} は以下のような正規分布に従う。

\hat{p_t} ~ \sim N(p_t, \frac{p_t(1-p_t)}{n_t}) \\ \hat{p_c} ~ \sim N(p_c, \frac{p_c(1-p_c)}{n_c})

ここで、\hat{p_i} を逆正弦関数(arcsin)で変換したものを \hat{\varphi_i} = 2sin^{-1}{\sqrt{\hat{p_i}}} と定義すると、これはn_i \to \inftyのを満たす時、デルタ法により以下の正規分布に従う。

\hat{\varphi_t} ~ \sim N(2sin^{-1}{\sqrt{\hat{p_t}}}, \frac{1}{n_t}) \\ \hat{\varphi_c} ~ \sim N(2sin^{-1}{\sqrt{\hat{p_c}}}, \frac{1}{n_c})

正規分布の再生性により、これらの差分も正規分布に従う。よって、これらの差分を正規化した値が標準正規分布に従うため、これを統計量として用いて検定を行う。

z = \frac{\hat{\varphi_t} - \hat{\varphi_c}}{\sqrt{\frac{1}{n_t} + \frac{1}{n_c}}}

未知パラメータが存在しないため、問題なく統計量を計算することが出来る。これは角変換をするメリットの一つである。

なお、分子の角変換した差分はcohen's hと呼ばれる。

\varphi_i = 2sin^{-1}{\sqrt{p_i}} \\ h = \varphi_i - \varphi_j

角変換なし

角変換する場合と途中までは同じのため省略する。
こちらでは角変換せずに、母比率の推定量 \hat{p_i} の差分が再生性により正規分布に従うことを利用して、統計量を作成する。

z = \frac{\hat{p_t} - \hat{p_c}}{\sqrt{\frac{p_t(1-p_t)}{n_t} + \frac{p_c(1-p_c)}{n_c}}}

分母に含まれる p_i は未知パラメータのため、\hat{p_i} を利用した値で代用し、統計量を計算する。

実装例

以上の統計量を利用し、サンプルサイズを計算することになる。
ここでは、PythonとRにおける実装例をまとめた。

Python

角変換あり

statsmodels.stats.power.NormalIndPower.solve_power を用いる。
ドキュメントに記載があるが、nobs2 = nobs1 * ratio という関係が成り立つため、2群でサンプルサイズを等しくしたい場合は ratio=1.0 を設定すれば良い。総サンプルサイズは nobs1 + nobs2 = nobs1 * (1 + ratio) となる。

from statsmodels.stats.power import NormalIndPower
import statsmodels.api as sm

# cohen's hを算出
h = sm.stats.proportion_effectsize(0.11, 0.10)

# サンプルサイズの計算
nobs1 = NormalIndPower().solve_power(
    effect_size=h
    , nobs1=None
    , alpha=0.05
    , power=0.8
    , ratio=1.0 # 2群で等しいサンプルサイズにする
    , alternative='two-sided'
)

print(nobs1)
# 14744.104836925611

https://www.statsmodels.org/dev/generated/statsmodels.stats.power.NormalIndPower.solve_power.html

角変換なし

statsmodels.stats.proportion.samplesize_proportions_2indep_onetail を用いる。
diff には検出したい割合の差分(効果量ではなく単なる差分であることに注意)、 prop2 には、ベースラインとなる割合(=帰無仮説のもとでの母比率)を指定する。 prop1 = prop2 + diff という関係が成り立つ。

なお、2群でサンプルサイズが等しい場合は statsmodels.stats.power.normal_sample_size_one_tail が利用できるが、samplesize_proportions_2indep_onetail内部からこれを呼び出していることが分かる。one_tail の方は帰無仮説時と対立仮説時の分散を自ら指定してあげる必要があるが、 2indep_onetail はこちらが指定せずとも分散の計算も行ってくれる。

こだわりがなければ、2群のサンプルサイズが等しい場合でも 2indep_onetail を利用するのを推奨する。

from statsmodels.stats.proportion import samplesize_proportions_2indep_onetail

nobs1 = samplesize_proportions_2indep_onetail(
    diff=(0.11 - 0.10)
    , prop2=0.10
    , power=0.8
    , ratio=1.0 # 2群で等しいサンプルサイズにしたい
    , alpha=0.05
    , alternative='two-sided'
)
print(nobs1)
# 14750.790469044958

僅かではあるが、角変換した場合とサンプルサイズに差異が生じていることが分かる。

https://www.statsmodels.org/dev/generated/statsmodels.stats.proportion.samplesize_proportions_2indep_onetail.html

R

角変換あり

Rでは pwr というパッケージでサンプルサイズの計算や検出力の計算を行うことが出来る。
2群のサンプルサイズが等しい場合は pwr.2p.test 、等しくない場合は pwr.2p2n.test を利用する。また、h には効果量であるcohen's hを指定する。
サンプルサイズが異なる場合、いずれかの群のサンプルサイズを設定する必要がある。

library(pwr)

# 2群のサンプルサイズが等しい場合
res1 = pwr.2p.test(h = ES.h(0.11, 0.10), n = NULL, sig.level = 0.05, power = 0.8,
    alternative = "two.sided")
print(res1$n)
# 14744.1


# 2群のサンプルサイズが異なる場合
res2 = pwr.2p2n.test(h = ES.h(0.11, 0.10), n1 = 10000, n2 = NULL, sig.level = 0.05, power = 0.8,
    alternative = "two.sided")
print(res2$n2)
# 28052.51

PythonとRで同一の値が算出されていることが分かる。

https://www.rdocumentation.org/packages/pwr/versions/1.3-0

角変換なし

2群のサンプルサイズが等しい場合、 power.prop.test を利用することが出来る。

res = power.prop.test(n = NULL, p1 = 0.11, p2 = 0.10, sig.level = 0.05,
                power = 0.8,
                alternative = "two.sided")
print(res$n)
# 14750.79

こちらもPythonと同じ値が算出されていることが分かる。

筆者の検索力不足か、サンプルサイズが異なる場合についての実装は見つからなかった。ご存知の方がいれば教えて頂きたい。。。

2つの検定の比較

ここまで読んだ人は、「結局どちらを使えば良いの?」という感想をお持ちになるだろう。
ここでは簡単なシミュレーションを行い、2つの検定の差分を調べてみた。

必要なサンプルサイズ

上の実装例から分かる通り、2つの検定でサンプルサイズに僅かに差が生じた(角変換あり: 14744, 角変換なし: 14750)。
control群の母比率と相対差分を設定し、2つの手法で必要サンプルサイズにどれだけ差がつくのかを調べた。

以下がその結果である。1つ目の図はサンプルサイズの差分の割合((角変換なし ー 角変換あり) / 角変換あり)を可視化している。
これを見ると、せいぜい1%ほどしか差がついていないことが分かる。あまり大きな差がつかないと考えて差し支えないだろう

シミュレーションのコード
from statsmodels.stats.proportion import samplesize_proportions_2indep_onetail
from statsmodels.stats.power import NormalIndPower
import statsmodels.api as sm
from tqdm.notebook import tqdm


delta_ratios = [1, 3, 5, 10, 20, 50]
proportions_control = [0.01, 0.05, 0.10, 0.3, 0.5, 0.7]
res = []
for p_c in tqdm(proportions_control):

    for delta_ratio in delta_ratios:
        # 差分を設定
        diff = p_c * (delta_ratio / 100)
        p_t = p_c + diff
        if p_t > 1.0:
            break

        # 角変換なし
        n_pooled = samplesize_proportions_2indep_onetail(
            diff=(p_t - p_c)
            , prop2=p_c
            , power=0.8
            , ratio=1.0 # 2群で等しいサンプルサイズにしたい
            , alpha=0.05
            , alternative='two-sided'
        )

        # 角変換あり
        h = sm.stats.proportion_effectsize(p_t, p_c)
        n_asin = NormalIndPower().solve_power(
            effect_size=h
            , nobs1=None
            , alpha=0.05
            , power=0.8
            , ratio=1.0 # 2群で等しいサンプルサイズにしたい
            , alternative='two-sided'
        )

        res.append([p_c, delta_ratio, n_pooled, n_asin])
res = pd.DataFrame(res, columns=['p_c', 'delta_ratio', 'n_pooled', 'n_asin'])

AAテスト

母比率とサンプルサイズを設定し、二項分布に従う数列を2つ生成して母比率の差の検定を行うシミュレーションを10,000回実行、有意水準を5%として帰無仮説を棄却した(有意差がついた)割合を可視化した。正しく検定が出来て入れば、有意差がつく割合は5%になるはずである。

以下がその結果である。
まず、母比率がどのような値の場合でも、1群あたりのサンプルサイズが500を超えると問題なく帰無仮説を棄却出来ていることが分かる。つまり、一定のサンプルサイズがあればいずれの手法でも問題ないと言える。

母比率が小さく、サンプルサイズが100以下と小さい場合、角変換ありの方は帰無仮説を棄却しすぎている様子が伺える。また、角変換なしの方でも、p=0.01のように母比率が小さい場合はサンプルサイズが小さいと逆に帰無仮説を十分に棄却出来ていない様子が伺える。

色々と深堀りすれば議論できることはあると思うが、適切な検定を行うにはまず第一に十分なサンプルサイズを確保するのが優先事項であることが分かる。その上でどちらの検定を選ぶべきか?ということであれば、実装の手間もかからない角変換なしの方で良いだろう。

シミュレーションのコード
import numpy as np
import matplotlib.pyplot as plt
from tqdm.notebook import tqdm
import pandas as pd

# 母比率
proportions = [0.01, 0.03, 0.05, 0.10, 0.30, 0.50]

res = []
for p in tqdm(proportions):

    # 各群のサンプルサイズ
    sample_size_list = [30, 50, 100, 500, 1000, 5000, 10000]
    simulation_cnt = 10000

    for n in sample_size_list:
        # 今回のシミュレーションでは2群でサンプルサイズを等しくする
        n1 = n
        n2 = n

        reject_cnt_pooled = 0
        reject_cnt_transformed = 0
        for _ in range(simulation_cnt):
            x1 = np.random.binomial(n1, p)
            p1_hat = x1 / n1

            x2 = np.random.binomial(n2, p)
            p2_hat = x2 / n2

            # 角変換なし
            p_hat = (x1 + x2) / (n1 + n2)
            z_pooled = np.abs(p1_hat - p2_hat) / np.sqrt(p_hat * (1 - p_hat) * (1 / n1 + 1 / n2))
            if z_pooled > 1.96:
                reject_cnt_pooled += 1

            # 角変換あり
            z_transformed = 2 * np.abs(np.arcsin(np.sqrt(p1_hat)) - np.arcsin(np.sqrt(p2_hat))) / np.sqrt(1 / n1 + 1 / n2)
            if z_transformed > 1.96:
                reject_cnt_transformed += 1

        reject_ratio_pooled = reject_cnt_pooled / simulation_cnt
        reject_ratio_transformed = reject_cnt_transformed / simulation_cnt

        res.append([p, n, reject_ratio_pooled, reject_ratio_transformed])

df = pd.DataFrame(res, columns=['p', 'n', 'pooled_z_test', 'transformed_z_test'])

さいごに

以上のように、母比率の差の検定では2つのZ検定が利用可能であり、それぞれに対応した関数が実装されていることが分かる。

実務における母比率の差の有意差検定を行う際に、角変換をしてから検定を行う人はほとんどいないだろう。一方で、サンプルサイズの計算の際には、角変換ありの方法(Rの pwr パッケージや、Pythonの NormalIndPower)を利用したことがある人はまあまあいるのではないだろうか。サンプルサイズの計算方法を検索すると、これらのパッケージやライブラリを使った記事がいくつも出てくる。

有意差検定とサンプルサイズの計算方法は表裏一体なので、一貫して同じ検定方法を利用するのが筋であろう。想定されている検定が異なるものとは露知らず、適当にライブラリやパッケージを使っていた自分への戒めとしてこの記事を書いた。

参考文献

https://note.com/dd_techblog/n/n73eca35106c7

https://zenn.dev/parcb/articles/sample-size-for-comparing-proportions

https://biolab.sakura.ne.jp/proportion-test.html

Discussion