データ・分析

A/Bテストを実装する — 判定を間違えないための設計

A/Bテストは「2つ見せて比べる」だけに見えますが、
実装と判定の両方に落とし穴があります。実際に踏んだものを整理します。

振り分けは決定的に行う

リクエストごとに乱数で決めると、同じ人が毎回違うものを見ます

import hashlib

def variant(user_id: int, experiment: str) -> str:
    h = hashlib.md5(f"{experiment}:{user_id}".encode()).hexdigest()
    return "B" if int(h[:8], 16) % 2 else "A"

ユーザーIDと実験名からハッシュを取ると、
同じ人には常に同じ変種が出ます。実験名を含めるのがポイントで、
これが無いと複数の実験で同じ偏りが発生します。

未ログインの扱い

ログイン前後で振り分けが変わると、データが汚れます。
Cookie に識別子を持たせ、ログイン後も引き継ぎます。

aid = request.COOKIES.get("aid") or uuid.uuid4().hex
response.set_cookie("aid", aid, max_age=60*60*24*90)

記録するのは「割り当て」と「成果」

class Assignment(models.Model):
    experiment = models.CharField(max_length=40)
    subject = models.CharField(max_length=64)     # user_id or aid
    variant = models.CharField(max_length=8)
    assigned_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        unique_together = [("experiment", "subject")]

割り当てを記録した人だけを分母にするのが重要です。
「全訪問者」を分母にすると、実験対象外の人が混ざります。

途中で覗いて止めない

最もやりがちな失敗です。

3日目: Bが勝っている → 終了しよう

これは有意水準を大きく歪めます。何度も覗けば、
差が無くても「差がある」ように見える瞬間が必ず訪れます。

対策は単純で、開始前に期間とサンプル数を決め、それまで見ないことです。

EXPERIMENT = {
    "name": "cta_wording",
    "min_subjects_per_variant": 1000,
    "min_days": 14,
}

曜日と季節の影響を排除する

平日と休日で行動が変わるサービスは多くあります。
最低1週間、できれば2週間回して曜日を一巡させます。

複数の実験を同時に走らせるとき

同時に走らせること自体は可能ですが、
同じ画面の同じ要素を対象にする実験は同時に走らせません
効果が混ざって解釈できなくなります。

差が無いという結果も価値がある

「有意差なし」は失敗ではありません。
その変更に工数をかける必要が無いという重要な情報です。

実務では「差が無かったので、より効果のありそうな別の施策に移る」
という判断ができるようになるのが最大の価値でした。

まとめ

  • 振り分けはハッシュで決定的に(実験名を含める)
  • 未ログインは Cookie で識別し、ログイン後も引き継ぐ
  • 分母は「割り当てを記録した人」
  • 開始前に期間とサンプル数を決め、途中で覗いて止めない
  • 最低1〜2週間回して曜日の影響を消す
  • 「差が無い」も有益な結果として扱う