A/Bテスト基盤を自前構築して1年半、失敗から学んだデータエンジニアリングの現実

「フラグ出し分けるだけでしょ」と思ってたら大間違いだった話。1年半の自前構築で踏んだ地雷と、実験基盤設計の現実をエンジニア目線で書き残します。

A/Bテスト基盤、なめてた自分を殴りたい

正直に言うと、最初は「A/Bテストなんて、フラグ出し分けてメトリクス集めるだけでしょ」と思ってた。2024年末にプロダクトが急成長して「実験文化を根付かせよう」という話になったとき、データエンジニアリングチームのメンバーとして基盤構築を担当することになったんだけど、最初の3ヶ月で想定の3倍くらいの複雑さに直面した。

1年半運用してきて、今はやっとまともに動く基盤になってきた。その過程で踏んだ地雷と、2026年時点でどう設計するのが現実的かを書き残しておきたい。ちなみにデータ品質の話はデータ品質管理2026年版の記事でも書いてるので、そっちも参考にしてほしい。


まず基盤全体のアーキテクチャを整理した

最終的に落ち着いた構成はこれ。最初はもっとシンプルにしようとしたんだけど、スケールと信頼性を両立しようとするとどうしてもこのくらいの複雑さになった。

flowchart TB
    subgraph Ingestion["イベント収集層"]
        SDK["Client SDK\n(Web/iOS/Android)"]
        API["Assignment API\n(実験割り当て)"]
        EV["Event Collector\n(Kafka Topics)"]
    end

    subgraph Processing["処理層"]
        SP["Spark Streaming\n(リアルタイム集計)"]
        BQ["Batch Pipeline\n(dbt + Airflow)"]
        AS["Assignment Store\n(DynamoDB)"]
    end

    subgraph Storage["ストレージ層"]
        LH["Data Lakehouse\n(Delta Lake)"]
        DW["Data Warehouse\n(BigQuery)"]
        RS["Result Store\n(PostgreSQL)"]
    end

    subgraph Analysis["分析・配信層"]
        STAT["統計計算エンジン\n(Python + SciPy)"]
        DASH["実験ダッシュボード"]
        ALERT["アノマリー検知"]
    end

    SDK --> API
    API --> AS
    API --> EV
    SDK --> EV
    EV --> SP
    SP --> LH
    LH --> BQ
    BQ --> DW
    AS --> BQ
    DW --> STAT
    STAT --> RS
    RS --> DASH
    RS --> ALERT

Kafkaのトピック設計についてはイベント駆動アーキテクチャ実装ガイドの記事が参考になる。うちのチームも最初はこれを読んでトピック設計した。

特に気をつけたのが「Assignment API」を独立したサービスとして切り出したこと。最初はアプリケーションコードに直接フラグ判定ロジックを書いていたんだけど、これだと実験の管理が地獄になる。割り当てを中央集権的に管理することで、後述するサンプル比率汚染をかなり防げるようになった。


最初に踏んだ地雷3つ

1. サンプル比率汚染(SRM: Sample Ratio Mismatch)

最初の半年で一番ハマったやつ。A/Bテストで「50:50で振り分けているはずなのに、実際のユーザー数がA:B=47:53になっている」という状況で、SRMが発生すると実験結果が根本から信頼できなくなる。

原因を調査したら、割り当てタイミングの問題だった。

# ダメなパターン(最初にやってた)
def assign_variant(user_id: str, experiment_id: str) -> str:
    # リクエストのたびに確率的に割り当てていた
    # これだとセッションをまたいで割り当てが変わることがある
    import random
    return "control" if random.random() < 0.5 else "treatment"

# 正しいパターン
def assign_variant(user_id: str, experiment_id: str) -> str:
    # ユーザーIDと実験IDのハッシュで決定論的に割り当て
    import hashlib
    hash_input = f"{user_id}:{experiment_id}".encode()
    hash_value = int(hashlib.sha256(hash_input).hexdigest(), 16)
    bucket = hash_value % 1000  # 0-999のバケット
    
    # 実験設定から割り当て比率を取得
    experiment = get_experiment_config(experiment_id)
    cumulative = 0
    for variant in experiment.variants:
        cumulative += variant.traffic_percentage * 10  # 0-999にスケール
        if bucket < cumulative:
            return variant.name
    return experiment.variants[-1].name

ハッシュベースの決定論的割り当てに変えてから、SRM問題はほぼ解消した。ただ「ほぼ」というのが正直なところで、ボットトラフィックや再インストールユーザーの扱いはまだ完璧じゃない。

2. 統計的検定の設定ミス

「有意水準5%で検定すれば安心」と思ってたら大間違いだった。複数の指標を同時に見ていると、少なくとも1つで偽陽性が出る確率がめちゃくちゃ上がる。

10個の指標を同時に検定すると、全部帰無仮説が真でも偽陽性が出る確率は約40%。これ、実体験として「CTRが改善した!」と喜んで施策を本番に出したら、実は偽陽性だったということを経験している。あのときの虚脱感はなかなかのものだった。

from scipy import stats
import numpy as np
from statsmodels.stats.multitest import multipletests

def calculate_experiment_results(
    control_data: np.ndarray,
    treatment_data: np.ndarray,
    metrics: list[str],
    alpha: float = 0.05,
    method: str = "benjamini-hochberg"
) -> dict:
    """
    複数指標のA/B検定結果を計算
    多重比較補正を必ず適用する
    """
    p_values = []
    effects = []
    
    for metric in metrics:
        t_stat, p_val = stats.ttest_ind(
            control_data[metric],
            treatment_data[metric],
            equal_var=False  # Welch's t-test
        )
        p_values.append(p_val)
        
        # Cohen's d で効果量を計算
        pooled_std = np.sqrt(
            (control_data[metric].std()**2 + treatment_data[metric].std()**2) / 2
        )
        cohens_d = (
            treatment_data[metric].mean() - control_data[metric].mean()
        ) / pooled_std
        effects.append(cohens_d)
    
    # 多重比較補正 (Benjamini-Hochberg)
    reject, p_corrected, _, _ = multipletests(
        p_values, alpha=alpha, method="fdr_bh"
    )
    
    return {
        metric: {
            "p_value": p_val,
            "p_corrected": p_corr,
            "significant": rej,
            "effect_size": eff,
            "relative_lift": (
                treatment_data[metric].mean() / control_data[metric].mean() - 1
            ) * 100
        }
        for metric, p_val, p_corr, rej, eff in zip(
            metrics, p_values, p_corrected, reject, effects
        )
    }

3. ノベルティ効果を無視してた

新しいUIや機能を出したとき、最初の1〜2週間はユーザーが「珍しいから触る」という行動をする。これがノベルティ効果で、短期間しか実験しないと「効果があった」と誤判断してしまう。

うちでは実験期間に最低2週間(できれば4週間)のルールを設けた。それと「先行指標」と「遅行指標」を分けて管理するようにした。クリック率みたいな先行指標は早く動くけど、リテンションみたいな遅行指標はもっと時間が必要で、この2つを混ぜて判断すると話がこじれる。


データパイプラインの設計で気をつけたこと

イベント収集から分析テーブルまでのフローで、特に悩んだのがイベントスキーマの管理だった。最終的にはこういうレイヤー構成に落ち着いた。

flowchart LR
    subgraph Raw["Raw Events"]
        E1["experiment_exposed\n実験露出イベント"]
        E2["experiment_converted\n目標達成イベント"]
        E3["metric_events\n各種指標イベント"]
    end

    subgraph Bronze["Bronze Layer (Delta Lake)"]
        B1["raw_experiment_events\n未加工・スキーマ検証済"]
    end

    subgraph Silver["Silver Layer"]
        S1["experiment_assignments\nユーザー×実験×バリアント"]
        S2["experiment_metrics\n実験ごと日次集計"]
    end

    subgraph Gold["Gold Layer"]
        G1["experiment_results\n統計検定結果"]
        G2["experiment_dashboard\nダッシュボード用"]
    end

    E1 --> B1
    E2 --> B1
    E3 --> B1
    B1 --> S1
    B1 --> S2
    S1 --> G1
    S2 --> G1
    G1 --> G2

スキーマはProtobuf + Schema Registryで管理している。これが地味に効いていて、クライアントチームがイベントのスキーマを壊すミスがかなり減った。

dbtで書いたSilverレイヤーのモデルはこんな感じ:

-- models/silver/experiment_assignments.sql
-- ユーザーごとに最初の実験露出だけを取得(後入れ無効)

WITH first_exposure AS (
    SELECT
        user_id,
        experiment_id,
        variant_name,
        event_timestamp,
        -- ユーザーの最初の露出のみ取得
        ROW_NUMBER() OVER (
            PARTITION BY user_id, experiment_id
            ORDER BY event_timestamp ASC
        ) AS exposure_rank
    FROM {{ ref('raw_experiment_events') }}
    WHERE event_type = 'experiment_exposed'
    -- データ品質チェック
    AND user_id IS NOT NULL
    AND experiment_id IS NOT NULL
    AND variant_name IS NOT NULL
),

-- 同じユーザーが複数バリアントに割り当てられていないかチェック
variant_consistency AS (
    SELECT
        user_id,
        experiment_id,
        COUNT(DISTINCT variant_name) AS variant_count
    FROM first_exposure
    WHERE exposure_rank = 1
    GROUP BY user_id, experiment_id
    HAVING COUNT(DISTINCT variant_name) > 1  -- これが出たら設計バグ
)

SELECT
    fe.user_id,
    fe.experiment_id,
    fe.variant_name,
    fe.event_timestamp AS first_exposure_at,
    DATE(fe.event_timestamp) AS exposure_date
FROM first_exposure fe
LEFT JOIN variant_consistency vc
    ON fe.user_id = vc.user_id
    AND fe.experiment_id = vc.experiment_id
WHERE fe.exposure_rank = 1
    AND vc.user_id IS NULL  -- バリアント汚染ユーザーを除外

dbtのテストで毎日このデータ品質をチェックしているんだけど、dbt移行の記事で書いたように2.0移行でテストのインターフェースが変わって少し手間がかかった。


実験基盤として必要な機能を比較した

自前構築 vs SaaSの判断をするために主要ツールを比較した時期があった。最終的には自前構築を選んだんだけど、これは正直好み分かれると思う。

機能自前構築Statsig (2026年版)LaunchDarklyOptimizely
割り当てのカスタマイズ性◎ 完全自由○ 柔軟○ 柔軟△ 限定的
統計エンジン◎ 自前実装◎ Sequential Testing対応△ 基本的○ 充実
データ連携◎ 自前倉庫と直結○ BigQuery/Snowflake等△ 要設定○ 主要DWH対応
初期コスト○ 低△ 月額数十万〜△ 月額数十万〜✕ 月額百万〜
運用コスト✕ 高(人件費)○ 低○ 低○ 低
SRM自動検知自前実装が必要◎ 自動△ 限定的◎ 自動
Sequential Testing自前実装が必要◎ 対応✕ 非対応◎ 対応
CUPED対応自前実装が必要◎ 対応✕ 非対応◎ 対応

2026年時点でStatsigは機能がかなり充実してきていて、自前構築より良い選択肢になるケースも多いと思う。うちが自前にしたのは「既存のData Lakehouseとの密な連携」と「統計手法を自分たちでコントロールしたい」という要求があったから。規模が小さいうちはSaaSで十分、というか正直そっちのほうがラクだと思う。


実験の信頼性向上のために実装したCUPED

最近で一番「これよかった」と感じているのがCUPED(Controlled-experiment Using Pre-Experiment Data)の実装。実験前の行動データを使って分散を削減し、必要サンプルサイズを減らせる手法で、最初は「理論はわかるけど本当に効くの?」と懐疑的だった。実際にやってみたら結果が安定したので素直に反省している。

import numpy as np
from scipy import stats

def apply_cuped(
    control_metric: np.ndarray,
    treatment_metric: np.ndarray,
    control_covariate: np.ndarray,  # 実験前の同じ指標
    treatment_covariate: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
    """
    CUPED: Controlled-experiment Using Pre-Experiment Data
    事前データで共変量補正して分散を削減
    """
    all_metric = np.concatenate([control_metric, treatment_metric])
    all_covariate = np.concatenate([control_covariate, treatment_covariate])
    
    # 共変量との回帰係数を推定
    covariate_mean = all_covariate.mean()
    theta = np.cov(all_metric, all_covariate)[0, 1] / np.var(all_covariate)
    
    # 補正後の指標を計算
    control_cuped = control_metric - theta * (control_covariate - covariate_mean)
    treatment_cuped = treatment_metric - theta * (treatment_covariate - covariate_mean)
    
    return control_cuped, treatment_cuped


def compare_variance_reduction(
    control_metric, treatment_metric,
    control_covariate, treatment_covariate
):
    """CUPED適用前後の分散比較"""
    control_cuped, treatment_cuped = apply_cuped(
        control_metric, treatment_metric,
        control_covariate, treatment_covariate
    )
    
    original_variance = (
        np.var(control_metric) + np.var(treatment_metric)
    ) / 2
    cuped_variance = (
        np.var(control_cuped) + np.var(treatment_cuped)
    ) / 2
    
    reduction_rate = (1 - cuped_variance / original_variance) * 100
    print(f"分散削減率: {reduction_rate:.1f}%")
    print(f"等価サンプルサイズ増加: {1 / (1 - reduction_rate/100):.2f}x")
    
    return control_cuped, treatment_cuped

実際にうちのプロダクトで試したら、購買率指標の分散が平均30〜40%削減できた。これだけで必要サンプルサイズが15〜25%減るので、実験期間を短縮できてPdMチームに非常に喜ばれた。CUPED導入前後で実験完了までの日数がどう変わったかを指標別に並べるとこんな感じ:

xychart-beta
    title "CUPED導入前後の実験完了期間比較(日数)"
    x-axis ["購買率", "CTR", "セッション時間", "リテンション", "LTV"]
    y-axis "実験期間(日)" 0 --> 50
    bar [42, 28, 35, 48, 45]
    bar [31, 19, 26, 36, 33]

実験基盤の運用で気づいたこと

技術的な実装以上に重要だったのが、組織的な運用ルールの整備だった。実験基盤を作っても、「とりあえずA/Bテストしてみよう」という雑な文化が根付いてしまうと意味がない。むしろ誤った確信を量産するマシンになりかねないので、ここは手を抜かなかった。

うちのチームで整備したのは主に3つ。

① 実験登録のテンプレート化

実験を登録するときに必ず「仮説」「プライマリメトリクス」「ガードレールメトリクス」「最小検出可能効果(MDE)」「必要サンプルサイズ」を記入するGitHub PRテンプレートを作った。最初はめんどくさいって言われたけど、これをやり始めてから「よく分からないけどテストしてみよう」という実験が激減した。考えてから手を動かす文化、大事。

② 実験の健全性チェックの自動化

毎朝Airflowのジョブが走って、実行中の実験に対してSRMチェック・Sample Sizeチェック・データ欠損チェックを実行し、異常があればSlackに通知する仕組みを作った。

def check_experiment_health(experiment_id: str) -> dict:
    """実験の健全性チェック"""
    assignment_data = get_assignment_counts(experiment_id)
    
    results = {
        "experiment_id": experiment_id,
        "issues": []
    }
    
    # SRMチェック(カイ二乗検定)
    expected_ratio = get_expected_ratio(experiment_id)
    observed = [assignment_data[v] for v in assignment_data.variants]
    expected = [
        sum(observed) * r for r in expected_ratio
    ]
    
    chi2, p_value = stats.chisquare(observed, expected)
    if p_value < 0.01:  # 厳しめの閾値
        results["issues"].append({
            "type": "SRM_DETECTED",
            "severity": "HIGH",
            "detail": f"Sample Ratio Mismatch検出 (p={p_value:.4f})"
        })
    
    # 最小サンプルサイズチェック
    total_samples = sum(observed)
    min_required = get_required_sample_size(experiment_id)
    
    if total_samples < min_required * 0.8:
        results["issues"].append({
            "type": "INSUFFICIENT_SAMPLES",
            "severity": "MEDIUM",
            "detail": f"現在{total_samples}件、必要{min_required}件"
        })
    
    return results

③ 実験レポートの標準化

実験終了時に統計計算エンジンが自動でレポートを生成して、NotionのDBに書き込む仕組みを作った。「あの実験の結果どうだったっけ」という振り返りが非常にしやすくなったし、意思決定の根拠が残るのが地味に大事なんですよね。半年後に「なぜあの機能を入れたのか」って聞かれたとき、ちゃんと答えられるのは思ったより重要だった。

インシデント対応の仕組みとも連携していて、実験によってエラーレートが急上昇した場合は自動で実験を停止するようにしてある。このあたりの自動化についてはインシデント対応のベストプラクティスの記事も参考にした。


実験基盤のコストと効果

1年半運用してきたインフラコストの推移はこんな感じ:

xychart-beta
    title "実験基盤の月次インフラコスト推移(万円)"
    x-axis ["2025Q1", "2025Q2", "2025Q3", "2025Q4", "2026Q1", "2026Q2"]
    y-axis "コスト(万円)" 0 --> 80
    line [15, 28, 42, 55, 62, 58]

最初は小さかったけど、実験数が増えるにつれてコストも上がった。2026年Q1で最大になったのは実験数が月30件を超えた頃で、このタイミングでKafkaの設定見直しとSpark処理の最適化をした。Q2で少し下がったのはその効果。正直まだ最適化の余地はあると思っている。

効果の面では、実験基盤を整備してから意思決定のスピードが実感として上がった。「なんとなくいい気がする」でリリースしていた施策が、データに基づいて判断されるようになってきた。先週も「これは絶対良くなる」と開発チームが確信していた機能改善が、テストでネガティブな結果が出て差し戻しになった。あれがなかったら普通に本番に出ていたと思うと、基盤作って本当によかったと思う。

皆さんのチームではどうやってA/Bテストの意思決定をしていますか?まだ「フィーリング」で施策を出している場合は、この記事が参考になれば嬉しい。


まとめ

1年半A/Bテスト基盤を運用してわかったことを整理すると:

  1. 割り当てはハッシュベースの決定論的手法を使う — ランダム関数を毎回呼ぶとSRMの温床になる。これは最初から正しく設計すべき

  2. 多重比較補正は必須 — 複数指標を見るなら Benjamini-Hochberg などの補正を必ず入れる。しないと偽陽性祭りになる

  3. CUPEDで実験効率を上げる — 実験前データを使った共変量補正で分散を30〜40%削減できた。実験期間の短縮に直結する

  4. SRM自動検知を仕組み化する — 毎日チェックが走らないと、気づいたときには手遅れになる

  5. 自前構築 vs SaaSは規模次第 — 初期はStatsigのようなSaaSで十分。自前は運用コスト(人件費)が見えにくいので、決断は慎重に

次のアクション候補:

  • 現在のフラグ管理がアプリケーションコードに埋め込まれているなら、まずFeature Flagの記事を読んで分離を検討する
  • 統計的検定の設定を一度見直す(多重比較補正の適用有無を確認)
  • SRMチェックを実験ごとに手動でやっているなら、自動化スクリプトを書く
  • データウェアハウスのコスト増が気になる場合はBigQuery vs Athena vs Redshift の比較記事も参考に

正直まだ改善中のところも多いけど、実験文化を根付かせる上でデータエンジニアリングが担う役割は思っていた以上に大きかった。地味だけどやりがいのある仕事だと思っている。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事