サイトのルートレイアウトにある共有関数が、A/Bテストのエクスポージャー・カウンターをページビュー・カウンターに変えてしまい、サンプルサイズが膨れ上がり、コンバージョン率が無意味なものになってしまいました。ホームページを50/50で分割するこのテストでは、一方のバリアントが178ヒット、もう一方が57ヒットと報告され、期待されていた均等な分割からは程遠い結果となりました。

なぜエラーがランダマイザーをすり抜けたのか

開発者はまず、訪問者をバリアントに割り当てるランダマイザーを検証しました。ミドルウェアを読み、クッキーのロジックを調査し、割り当て関数を1万回呼び出すスクリプトを実行したところ、完璧な50/50の分割結果が得られました。ランダマイザー自体は正常に動作していました。問題は、エクスポージャーがどのように記録されるかにありました。

単一の関数が2つの役割を担っていました:

  1. バリアントの設定 – 訪問者の体験の一貫性を保つため、ページロードのたびに実行される。
  2. エクスポージャー・イベントの記録 – バリアントが最初に表示された瞬間に、訪問者ごとに一度だけ実行されるべきもの。

その関数は、ナビゲーションのたびにレンダリングされるコンポーネントであるルートレイアウト内に存在していました。エクスポージャーを記録するコードがレイアウトのレンダリングのたびに実行されたため、すべてのページビューが新しいエクスポージャーとしてカウントされてしまったのです。ホームページの2つのバリアントは、わずかに異なるレイアウトツリーを使用していたため、ページビュー率に差が生じ、ランダマイザーが故障しているかのような錯覚を生み出しました。

なぜカウントミスが重要だったのか

コンバージョン数(クリック、サインアップ、購入)は正しく記録されていました。しかし、分母(エクスポージャー数)が間違っていたのです。算出されたコンバージョン率は実態よりもはるかに低く見え、それらの率に基づいたあらゆる意思決定が信頼できないものになってしまいました。

この不一致が表面化するまでテストは2週間間実施されており、チームはデータセット全体を破棄せざるを得なくなりました。

修正方法

解決策は単純明快でした。役割を別々の関数に分割することです。エクスポージャーを記録するコードは、訪問者がすでにカウントされているかどうかを確認し、ユーザーごとに一度だけ実行されるようになりました。バリアントを設定するコードはそのままの場所に留まり、ナビゲーションのたびに実行され続けます。

実験を行うすべての人への3つの教訓

  • 状態の設定と単発のイベントを分離する。 バリアントの割り当てとエクスポージャーの記録の両方を行う関数は、前者が繰り返し実行されるのに対し、後者は繰り返してはならないため、衝突してしまいます。
  • ルートレイアウトに単発のロジックを配置しない。 ページロードのたびにレンダリングされるコンポーネントに配置されたものはすべて繰り返し実行され、「訪問者ごとに1回」が「ページビューごとに1回」になってしまいます。
  • 観測された分割がランダマイザーと矛盾する場合、まずはカウンターを監査する。 開発者はランダマイザーの公平性をテストすることは多いですが、カウントの仕組みが正確であることを検証することは滅多にありません。

次に注意すべき点

エクスポージャーを単一のカウンターに依存している実験は、そのカウンターがコンポーネントツリーのどこに位置しているかを監査すべきです。カウンターがグローバルレイアウトにある場合は、クッキーやローカルストレージのフラグなどの永続的な識別子にイベントを紐付けるチェックを追加してください。また、チームはダッシュボードにサニティチェックを組み込むべきです。観測されたバリアントの分布がわずかな統計的誤差範囲を超えて逸脱している場合は、ランダマイザーが故障していると決めつける前に、カウンターの監査対象としてテストにフラグを立てるようにします。

要するに、信頼できるエクスポージャーのカウントがなければ、適切に機能しているランダマイザーも役に立ちません。役割を分割し、単発のイベントを常にレンダリングされるコンポーネントの外側に配置することで、A/Bテストの正確性を保ち、数週間にわたる無駄な分析を防ぐことができます。