2024-8-13

ブログにA/Bテストを導入した

TypeScript

機械学習

最近Misreading ChatのA/Bテストの回を聞いて興味を持ったので、このブログにA/Bテストを実装してみた。 Misreading Chatで取り上げられたGoogleの論文では、 大規模な組織でA/Bテストを実施する方法について論じていたが、個人のブログ程度であればシンプルな方法で十分だろう。

ということで、TypeScript + React + Google AnalyticsでA/Bテストを実施した。 実装の概要は以下の通り。

  1. 0以上1000未満の整数をクッキーにセットする (以下、ID)。
  2. IDの数字に基づいて、A/Bテストの状況を管理するオブジェクト(以下、実験オブジェクト)を作成する。
  3. Google Analyticsの初期化時に実験オブジェクトの中身をそのままuser propertiesに入れる。
  4. 実験オブジェクトを簡単に取得するためのフック(useExperiment)を作成し、各コンポーネントでは実験オブジェクトの中身に応じて表示を切り替える。

要素要素で上で紹介したGoogleの論文のアイディアを利用している。 実験オブジェクトはクッキーに基づいて一意に決まるので、ページを変える毎に設定が変わったりする心配は特に無い。 実装に難しいところはないし、結果の集計はGoogle Analytics上で適当なレポートを作成すればいいので管理も簡単。

実験オブジェクトの具体的な中身は以下のようなイメージ。 各種フラグを格納するオブジェクト。

typescript
Copied!
interface Experiment {
  useBigButton: boolean;
}

実装

型定義など込みで70ページ程度。 環境変数からGoogle Analyticsのタグを取得しているが、自分で利用する際には適宜書き換えてほしい。

typescript
Copied!
"use client";

import Cookies from "js-cookie";
import { useSyncExternalStore } from "react";

interface Experiment {
  useBigButton: boolean;
}

const defaultExperiment: Experiment = {
  useBigButton: false,
};

export function initGoogleAnalytics() {
  const tag = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID;

  const script1 = document.createElement("script");

  // biome-ignore lint: google analytics script
  script1.src = "https://www.googletagmanager.com/gtag/js?id=" + tag;
  script1.async = true;
  document.body.appendChild(script1);
  window.dataLayer = window.dataLayer || [];

  function gtag() {
    // @ts-ignore
    dataLayer.push(arguments);
  }

  const id = getABTestID();
  const experiments =
    id !== undefined ? generateExperiment(id) : defaultExperiment;

  // @ts-ignore
  gtag("js", new Date());
  // @ts-ignore
  gtag("config", tag, {
    "user_properties": experiments,
  });
}

export function useExperiment(): Experiment {
  const id = useSyncExternalStore(
    () => () => {},
    () => getABTestID(),
    () => undefined
  );

  if (id === undefined) {
    return defaultExperiment;
  }
  return generateExperiment(id);
}

function getABTestID(): number | undefined {
  const key = "blog-ab-test-id";
  const cookie = Cookies.get(key);
  if (cookie !== undefined) {
    return parseInt(cookie, 10);
  }

  const id = Math.floor(Math.random() * 1000) % 1000;
  Cookies.set(key, id.toString(), { expires: 30 });
  return id;
}

function generateExperiment(id: number): Experiment {
  return {
    useBigButton: id < 500,
  };
}

今回はuseBigButtonという仮想のフラグを使っている。 IDが500以下かどうかでtrueかfalseが決まるので、後はuseExperimentで自由に表示を切り替えればいい。

今回初めてuseSyncExternalStoreを使ってみた。 クッキーから情報を取得する際にuseEffectなどで頑張ることもできるが、ちらつきなどを考えるとuseSyncExternalStoreを使ったほうが好ましい。 以下のリンクで詳しく紹介してくれている。

https://qiita.com/ssssota/items/51278dc5d51801dfb3fc

この実装のデメリット

Google Analyticsが使いづらいという問題がある。 user_properties周りの記事があまり見つからないし、Real Time Viewの挙動がガバガバでuser_propertiesが見えないことがあった。 別にリアルタイムで見えなくても分析時はそこまで困らないが、動作確認中とかは困る。 カスタムディメンジョンを作って、実際に分析で使えるようになるまでもラグがあって体験が微妙に悪い。

あとは集計がやりにくいという問題もある。 基本的なメトリクスは取れるが検定などの数値的な処理をしたい場合には使いにくい。 使ったことが無いがLooker Studioなどと連携すれば、この問題は解決されるかもしれない。

まとめ

TypeScriptで簡単なA/Bテストを実装した。 小規模のWebサイトであれば十分実用に耐えるものだと思う。

更新: 2024/08/17

掲載しているコードに誤りがあったので修正