分散削減(CUPED)と層化 ― 少ないサンプルで効かせる
サンプルが足りない現場で検出力を稼ぐために、実験前の共変量で結果の分散を減らすCUPEDと、層化の考え方を扱います。同じサンプル数でより小さな差を検出できる仕組みを直感と最小限の式で示し、架空のD2C企業SOLNAのサンプルコードで具体化します。
by Shin
サンプルを増やせないとき、検出力はどうやって稼ぐのか。答えは、実験前の共変量で結果の分散を減らすことです。代表的な手法がCUPED(Controlled-experiment Using Pre-Experiment Data)で、データを1件も増やさずに、同じ件数でより小さな差を見えるようにします。この記事では、なぜ分散を減らすと検出力が上がるのかを最小限の式で示し、架空のD2C企業SOLNAのサンプルコードでCUPEDと層化を具体化します。
棲み分けを先に宣言します。観測データから因果に迫るのは因果連載の仕事で、本連載は介入を自分で設計して因果を測る側です。本記事はその設計を「少ないサンプルで効かせる」ための統計的な工夫の話で、第1回(検出力・必要サンプル数)の続きに当たります。実験そのものは正しく割り付けられている前提で、分析の効率だけを上げにいきます。
なぜ分散を減らすと検出力が上がるのか
群間差を検定する統計量は、ざっくり「効果の大きさ ÷ ばらつき」です。2群の平均差を 、推定の標準誤差を とすると、検定統計量は で、 のように分散とサンプル数で決まります。つまり有意に到達する道は二つしかありません。n を増やして分母を小さくするか、 そのものを小さくするかです。
サンプルを増やす道は、トラフィック・予算・実験期間の上限にすぐ当たります。一方、最小検出効果(MDE)は に比例するので、分散を 倍にできれば、検出できる最小の差は 倍まで縮みます。これは n を 倍に増やしたのと等価です。たとえば実験前後の相関が なら分散は約36%減り、サンプルを約1.5倍に増やしたのと同じ検出力が、データを増やさずに手に入ります。
CUPED(実験前共変量で調整)
直感
売上のような指標は、群間でばらつくのではなく「もともとよく買う人/ほとんど買わない人」という個人差で大きくばらついています。この個人差は処置とは無関係で、純粋なノイズとして検定の分母を膨らませているだけです。だとすれば、実験前の購買履歴で「この人はもともとどのくらい買う人か」を説明し、その分を結果から差し引けば、残るのは処置が動かしうる部分に近づきます。分子(効果)はそのままに、分母(無関係なばらつき)だけを削るイメージです。
最小限の式
結果指標を 、実験前に観測した共変量を とします。CUPEDは調整後の指標を次で定義します。
性質は二つだけ押さえれば十分です。第一に、 なので 。つまり調整しても処置効果の推定値は(不偏に)変わりません。第二に、分散は
となり、 は と の相関係数です。削減量は だけで決まり、共変量が結果をよく予測するほど分散が減ります。 は処置の有無に依存しない量なので、両群をプールして1つだけ推定します。
SOLNAでの要点
SOLNAの一実験コホート(数万人規模)で、実験期間中の顧客あたり売上 y を指標、実験開始前の同じ顧客の累計売上 pre を共変量にします。リピート購買のあるD2Cでは pre が y の強い予測子になり、CUPEDがよく効く典型です。
import numpy as np
import pandas as pd
from scipy import stats
import statsmodels.formula.api as smf
# df: customer_id, T(0=control,1=treatment), pre(実験前売上), y(実験中売上)
# theta は両群をプールして推定する(処置に依存させない)
theta = np.cov(df["y"], df["pre"], ddof=1)[0, 1] / df["pre"].var(ddof=1)
df["y_cv"] = df["y"] - theta * (df["pre"] - df["pre"].mean())
def welch(frame, col):
t = frame.loc[frame["T"] == 1, col]
c = frame.loc[frame["T"] == 0, col]
diff = t.mean() - c.mean()
se = np.sqrt(t.var(ddof=1) / len(t) + c.var(ddof=1) / len(c))
return diff, se, *stats.ttest_ind(t, c, equal_var=False)
print("raw :", welch(df, "y")) # 素の指標
print("cuped:", welch(df, "y_cv")) # 分散削減後
rho = np.corrcoef(df["y"], df["pre"])[0, 1]
print("1 - rho^2 =", 1 - rho**2) # 分散がこの比率まで減る
# CUPED は回帰調整(ANCOVA)と一致する: y ~ T + pre の T 係数
print(smf.ols("y ~ T + pre", data=df).fit().params["T"])このコホートでの結果はおおむね次の範囲でした(匿名化のため数値はレンジで丸めています)。相関は 、分散比 は約 0.68(理論値 とほぼ一致)、標準誤差は15〜20%減りました。これはサンプルを4〜5割増やしたのと等価です。肝心なのは、効果の推定値そのものは素のt検定とほぼ同じだったことで、CUPEDは「効果を盛る」手法ではなく「ノイズを削る」手法だと数値でも確認できます。
層化との関係
層化(stratification)も分散削減の一種です。実験前の変数で母集団を層(例:購買額の5分位)に分け、層の中で割り付け、最後に層内の群間差を層サイズで加重平均します。層間の差(=個人差の大きな部分)が推定から外れるので、やはり分散が下がります。
SOLNAで pre の5分位層化を試すと、標準誤差は素の状態から1割強下がりましたが、CUPEDの方がさらに小さくなりました。理由は単純で、層化は連続的な共変量を粗いカテゴリに丸めてしまうのに対し、CUPEDは連続値をそのまま使うからです。一般に、連続共変量があるなら層化よりCUPED(=回帰調整)の方が効率的です。
使い分けの軸は「いつ効かせるか」です。割付の段階で層化(block randomization)すれば、群間の偏りそのものを未然に防げる利点があります。一方CUPEDは分析の段階だけで効かせられるので、すでに走ってしまった実験にも後から適用できます。両立も可能で、割付で層化しつつ分析でCUPEDをかけても構いません。
使いどころと注意
最大の決定要因は です。実験前データが結果をよく予測する場面でしか効きません。リピート購買のある既存顧客の売上は pre が強い予測子になり大きく効きますが、新規ユーザー中心の指標は実験前の履歴が存在せず()、恩恵はほぼ消えます。二値のコンバージョン率も相関が低くなりがちで、削減量は連続・歪んだ指標(売上・滞在)より小さくなります。
設計と運用でもう二点。 は両群プールで推定します(片群だけだと小標本で偏りえます)。そして、 を見込んで第1回のサンプル設計を先に削るのは危険です。 は実験前には不確実なので、安全側はサンプル設計を で組み、分析の段階でCUPEDの恩恵を「上振れ」として取りにいくことです。なお、 の推定や調整は途中で覗くかどうか(第4回のpeeking)とは独立で、停止ルールを別に汚すことはありません。
章末チェックリスト
- 分散削減が「サンプル増と等価」である理屈(MDEは に比例)を説明できる
- CUPEDの前提が「実験前に観測した共変量」であることを理解した
- 共変量に期間中・実験後の変数を使っていないか確認した
- を両群プールで推定している
- が低い場面(新規中心・二値指標)では恩恵が小さいと判断できる
- サンプル設計は で組み、CUPEDは分析時の上振れとして扱っている
次回は、ユーザー単位で割り付けられない施策(ブランド広告・配信)をどう測るか――地域実験とswitchbackです。観測手法に踏み込むので、因果連載のDiD・合成コントロールと地続きになります。
参考文献
- Kohavi ほか『A/Bテスト実践ガイド』(Trustworthy Online Controlled Experiments 邦訳)― CUPEDと分散削減の実務的決定版。
- 効果検証入門(ホクソエム)― 回帰調整と共変量の扱いの土台。
- 統計学入門(赤本)― 検出力・分散・検定の基礎。