Mímisbrunnr知恵の泉

← マーケティングサイエンス 一覧

🎓 レベル:標準 | 重要度:A(必須)

📎 関連:第8章 レコメンデーション 目次 | 前提:A/Bテストの設計と分析

要点(BLUF)

1. 探索と活用:A/Bの固定割付からバンディットへ

A/Bテストの設計と分析 では、トラフィックを 50:5050:50 で固定して一定期間流し、各腕の反応率を偏りなく推定して、勝った腕に切り替えました。これは「因果効果をきれいに測る」ためには理想的です。けれど、その間ずっと半分のトラフィックを負け腕に流し続けている——これが「学習のコスト」です。本当に最大化したいのが期間中の総コンバージョン(論文に載せる効果量ではなく)なら、証拠がたまるにつれて良い腕へ配分を寄せたい。これがバンディットの発想です。

舞台設定はこうです。KK 本の腕(クリエイティブ案・推薦候補・価格水準)があり、1本引くと報酬(クリック/購入=0/10/1)が返る。各腕の真の報酬率 μk\mu_k未知TT 回の試行で累積報酬を最大化したい。ここに緊張があります——ある腕が良いと知るには引いてみる必要がある(探索)が、不確実な腕を引くたび、すでに良いと分かっている腕で稼げたはずの報酬を取りこぼす(活用の機会損失)。名前の由来は、当たりの分からないスロットマシン(=片腕の盗賊 one-armed bandit)が何台も並んだ状況です。

flowchart LR
  subgraph AB["A/B テスト(固定割付・07-01)"]
    Aa["腕A:ずっと 50%"]
    Ab["腕B:ずっと 50%"]
  end
  subgraph MAB["多腕バンディット(適応配分)"]
    M1["腕を選ぶ"] --> M2["報酬を観測"] --> M3["推定を更新"]
    M3 --> M4{"探索と活用を両立"}
    M4 --> M1
  end
  AB -->|"負け腕にも流し続ける=機会損失"| MAB

A/B は左の固定割付、バンディットは右の選ぶ→観測→更新を回し続けるループです。「どれだけ良い腕に寄せられたか」と「未知の腕をどれだけ取りこぼさず試せたか」を、毎ラウンド両立させるのが課題になります。

2. リグレットと3つの戦略(数式)

最大化したい累積報酬の代わりに、最小化したい機会損失を測るほうが見通しが良い。最良腕 μ=maxkμk\mu^*=\max_k\mu_k だけを引き続けた理想と、実際に選んだ腕の期待報酬の差を累積リグレットと呼びます。

Regret(T)=Tμ最良腕だけの理想t=1Tμat実際に選んだ腕の期待報酬=t=1T(μμat)\text{Regret}(T)=\underbrace{T\mu^*}_{\text{最良腕だけの理想}}-\underbrace{\sum_{t=1}^{T}\mu_{a_t}}_{\text{実際に選んだ腕の期待報酬}}=\sum_{t=1}^{T}\bigl(\mu^*-\mu_{a_t}\bigr)

これは腕ごとに集計し直せて、ギャップ Δk=μμk\Delta_k=\mu^*-\mu_k引いた回数 nk(T)n_k(T) の積の和になります。

Regret(T)=k=1KΔkE ⁣[nk(T)]\text{Regret}(T)=\sum_{k=1}^{K}\Delta_k\,\mathbb{E}\!\left[n_k(T)\right]

この式が全ての要です。リグレットは「ギャップの大きい(悪い)腕を、何回引いてしまったか」で決まる。だから二つの極端はどちらも損です。活用だけ(毎回いまの推定最良を引く)だと、序盤の偶然で悪い腕が良く見えるとそこに固定化し、悪い腕を nkTn_k\propto T 回引いてリグレットが線形に伸びる。探索だけ(A/B のように常に均等割付)だと nkT/Kn_k\approx T/K で、RegretTKkΔk\text{Regret}\approx\frac{T}{K}\sum_k\Delta_kやはり線形。良い戦略は、データがたまるほど悪い腕を引く頻度を落とし、劣線形(対数的)リグレット O ⁣(klnTΔk)O\!\left(\sum_k\frac{\ln T}{\Delta_k}\right) を目指します。

ε-greedy は最も素朴な折衷です。経験的平均 μ^k\hat\mu_k(その腕でこれまで得た報酬の平均)を持ち、

at={一様ランダムな腕確率 εargmaxkμ^k確率 1εa_t= \begin{cases} \text{一様ランダムな腕} & \text{確率 }\varepsilon\\[4pt] \arg\max_k\hat\mu_k & \text{確率 }1-\varepsilon \end{cases}

で選びます。ε\varepsilon固定すると、毎ラウンド確率 ε\varepsilon で無作為に探索し続けるため、εTKkΔk\frac{\varepsilon T}{K}\sum_k\Delta_k ぶんの線形の尾が残ります(εt1/t\varepsilon_t\propto 1/t と減衰させれば対数リグレットに改善できます)。

UCB1 は「不確実なものは楽観的に評価する(optimism in the face of uncertainty)」原則です。

at=argmaxk(μ^k+2lntnk)a_t=\arg\max_k\left(\hat\mu_k+\sqrt{\frac{2\ln t}{n_k}}\right)

加える項 2lnt/nk\sqrt{2\ln t/n_k} は、μk\mu_k信頼区間の上側の幅(Hoeffding 不等式に由来)。引いた回数 nkn_k が少ない腕ほど大きく(1/nk1/\sqrt{n_k})、自動的に探索が促され、よく引いた腕は幅が締まって実力勝負になる。lnt\ln t はゆっくり増えるので、長く無視された腕もいつか再訪されます。理論上 O(klnT/Δk)O(\sum_k\ln T/\Delta_k) の対数リグレットを達成します。

Thompson Sampling はベイズ的です。報酬がベルヌーイなら、腕 kk の反応率 μk\mu_k の事後は ベイズA/BテストBeta–二項共役そのもの——Beta(1,1)\text{Beta}(1,1) から始め、報酬 r{0,1}r\in\{0,1\} を見るたび αk+=r, βk+=1r\alpha_k\mathrel{+}=r,\ \beta_k\mathrel{+}=1-r と更新します。毎ラウンド、各腕の事後から1標本ずつ引いて、最大の腕を選ぶ。

θkBeta(αk,βk)(k=1,,K),at=argmaxkθk\theta_k\sim\text{Beta}(\alpha_k,\beta_k)\quad(k=1,\dots,K),\qquad a_t=\arg\max_k\theta_k

これは確率マッチング——腕 kk は「事後のもとで kk が最良である確率」と等しい確率で選ばれます。不確実な腕は事後が広く、たまに高い標本を出して探索され、自信を持って良い腕はたいてい最大で活用される。探索と活用がサンプリング一発で自然に両立するのが美点で、これも対数リグレットを達成し、実務では最良のことが多い手法です。

3. 4戦略を走らせて比べる(コード)

真の報酬率を μ=[0.10,0.13,0.16,0.20]\mu=[0.10,0.13,0.16,0.20](最良=腕3、μ=0.20\mu^*=0.20)と決め、T=2000T=2000 ラウンド回します。公平に比べるため共通乱数法を使います——rewards[t,k]=「腕 kk をラウンド tt で引いたら出る 0/10/1 報酬」を1枚の表に先に作り、全戦略が同じ報酬実現を見るようにします。各戦略は毎ラウンド腕を選び、その腕の報酬だけを観測し、推定を更新します。sklearn は使わず、Thompson の事後標本は numpyrng.beta で引きます。

import numpy as np
import pandas as pd

# 4本腕のベルヌーイ・バンディット。真の報酬率は腕3(=0.20)が最良。
rng = np.random.default_rng(0)
K = 4
mu = np.array([0.10, 0.13, 0.16, 0.20])   # 各腕の真の報酬率(学習者には未知)
T = 2000                                   # ラウンド数
best_arm = int(np.argmax(mu))              # 最良腕=3
mu_star = mu[best_arm]                      # μ* = 0.20

# 共通の報酬表:全戦略が同じ報酬実現を見る(公平な比較=共通乱数法)
# rewards[t, k] = 腕kをラウンドtで引いたときに出る0/1報酬
rewards = (rng.random((T, K)) < mu).astype(int)


def run_random(rewards, seed):
    g = np.random.default_rng(seed)
    T, K = rewards.shape
    arms = np.empty(T, dtype=int)
    for t in range(T):
        arms[t] = g.integers(K)            # 毎回ランダムに腕を選ぶ
    return arms, rewards[np.arange(T), arms]


def run_epsilon_greedy(rewards, eps, seed):
    g = np.random.default_rng(seed)
    T, K = rewards.shape
    counts = np.zeros(K)
    values = np.zeros(K)                   # 各腕の経験的な平均報酬
    arms = np.empty(T, dtype=int)
    rew = np.empty(T, dtype=int)
    for t in range(T):
        if g.random() < eps:
            a = g.integers(K)              # 確率εで無作為に探索
        else:
            a = int(np.argmax(values))     # 確率1−εで最良腕を活用
        r = rewards[t, a]
        counts[a] += 1
        values[a] += (r - values[a]) / counts[a]   # 平均を逐次更新
        arms[t], rew[t] = a, r
    return arms, rew


def run_ucb1(rewards):
    T, K = rewards.shape
    counts = np.zeros(K)
    values = np.zeros(K)
    arms = np.empty(T, dtype=int)
    rew = np.empty(T, dtype=int)
    for t in range(T):
        if t < K:
            a = t                          # 最初のKラウンドは各腕を1回ずつ
        else:
            ucb = values + np.sqrt(2 * np.log(t) / counts)      # 上側信頼界
            a = int(np.argmax(ucb))        # 楽観的に最大の腕を選ぶ
        r = rewards[t, a]
        counts[a] += 1
        values[a] += (r - values[a]) / counts[a]
        arms[t], rew[t] = a, r
    return arms, rew


def run_thompson(rewards, seed):
    g = np.random.default_rng(seed)
    T, K = rewards.shape
    alpha = np.ones(K)                     # Beta(1,1) 事前
    beta = np.ones(K)
    arms = np.empty(T, dtype=int)
    rew = np.empty(T, dtype=int)
    for t in range(T):
        theta = g.beta(alpha, beta)        # 各腕のBeta事後から1標本ずつ
        a = int(np.argmax(theta))          # 最大の標本を持つ腕を選ぶ
        r = rewards[t, a]
        if r == 1:
            alpha[a] += 1                  # 成功→α、失敗→βを更新
        else:
            beta[a] += 1
        arms[t], rew[t] = a, r
    return arms, rew


strategies = {
    "無作為": run_random(rewards, seed=1),
    "ε-greedy(ε=0.1)": run_epsilon_greedy(rewards, 0.1, seed=2),
    "UCB1": run_ucb1(rewards),
    "Thompson": run_thompson(rewards, seed=3),
}

rows = []
for name, (arms, rew) in strategies.items():
    final_regret = mu_star * T - mu[arms].sum()    # Tμ* − Σμ_{a_t}
    best_frac = np.mean(arms == best_arm)          # 最良腕(腕3)を選んだ割合
    total_reward = int(rew.sum())                  # 実現した累積報酬
    rows.append((name, final_regret, best_frac, total_reward))

df = pd.DataFrame(rows, columns=["戦略", "最終累積リグレット", "最良腕(腕3)選択割合", "累積報酬"])
print(f"4本腕ベルヌーイ μ={[0.10, 0.13, 0.16, 0.20]}  最良腕=腕{best_arm}(μ*={mu_star})  T={T}")
print(df.to_string(index=False, formatters={
    "最終累積リグレット": "{:.1f}".format,
    "最良腕(腕3)選択割合": "{:.1%}".format,
}))

出力:

4本腕ベルヌーイ μ=[0.1, 0.13, 0.16, 0.2]  最良腕=腕3(μ*=0.2)  T=2000
             戦略 最終累積リグレット 最良腕(腕3)選択割合  累積報酬
            無作為     102.0       27.4%   304
ε-greedy(ε=0.1)      26.2       81.8%   382
           UCB1      71.0       39.5%   341
       Thompson      19.2       80.5%   384

出力の意味:基準の無作為は4本を均等(各 25%\approx25\%)に引くだけで、リグレット 102.0102.0・最良腕 27.4%27.4\% と最悪——学習しない戦略の損です。Thompson が最小リグレット 19.219.2・累積報酬 384384 で最良。ε-greedy(0.1)26.226.2・最良腕 81.8%81.8\% と健闘します。一方 UCB171.071.0・最良腕 39.5%39.5\% で、無作為には明確に勝つものの、Thompson・ε-greedy にはこの地平では及びません

ここで §2\S2 の分解 Regret=kΔknk\text{Regret}=\sum_k\Delta_k n_k(ギャップ Δ=[0.10,0.07,0.04,0]\Delta=[0.10,0.07,0.04,0])が効きます。非最良の手をどの腕に費やしたかで損が決まる、という話です。Thompson は外した手を安い近接腕(腕2、ギャップ 0.040.04)に集中させ(腕2を 15%15\%、腕0/1 はほぼ引かない)、0.019×.10+0.021×.07+0.153×.040.0095/0.019{\times}.10+0.021{\times}.07+0.153{\times}.04\approx0.0095/\text{回}×200019\times2000\approx19 に収まる。ところが ε-greedy は最良腕割合こそ 81.8%81.8\% と高いのに、序盤にギャップの大きい腕1(0.070.07)へ一時的に活用が固定し、そこを 13%13\% 引いてしまった——これが高くつき 2626 になります。「最良腕を当てた割合」が高くても、外した手が高ギャップ腕なら損は大きい。UCB1 は腕2(0.040.04)を 35%35\%・腕3 を 39%39\%分け合ったまま終わっており、0.349×.040.349{\times}.04 だけで 2828 ぶんの損が積まれています。なぜ UCB1 がこうなるかは、図のあとで詰めます。

累積リグレットの時系列(4戦略)と、各戦略がどの腕を何割引いたかを1枚にします(このブロックはデータ生成から各戦略までを内部で再実行し、上と同じ値を描きます)。

import numpy as np
import matplotlib.pyplot as plt
import japanize_matplotlib

# データ生成と4戦略の実行をこのブロック内で再現(ブロック単体で動く)。
rng = np.random.default_rng(0)
K, T = 4, 2000
mu = np.array([0.10, 0.13, 0.16, 0.20])
best_arm, mu_star = int(np.argmax(mu)), mu.max()
rewards = (rng.random((T, K)) < mu).astype(int)


def run(policy, seed=0):
    g = np.random.default_rng(seed)
    counts = np.zeros(K); values = np.zeros(K)
    alpha = np.ones(K); beta = np.ones(K)
    arms = np.empty(T, dtype=int)
    for t in range(T):
        if policy == "random":
            a = g.integers(K)
        elif policy == "egreedy":
            a = g.integers(K) if g.random() < 0.1 else int(np.argmax(values))
        elif policy == "ucb1":
            a = t if t < K else int(np.argmax(values + np.sqrt(2 * np.log(t) / counts)))
        else:  # thompson
            a = int(np.argmax(g.beta(alpha, beta)))
        r = rewards[t, a]
        counts[a] += 1; values[a] += (r - values[a]) / counts[a]
        if r == 1: alpha[a] += 1
        else: beta[a] += 1
        arms[t] = a
    return arms


runs = {
    "無作為": run("random", seed=1),
    "ε-greedy(ε=0.1)": run("egreedy", seed=2),
    "UCB1": run("ucb1"),
    "Thompson": run("thompson", seed=3),
}
colors = {"無作為": "C7", "ε-greedy(ε=0.1)": "C0", "UCB1": "C1", "Thompson": "C3"}

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11.5, 4.4))

# 左:累積リグレットの時系列(寝ているほど良い)
ts = np.arange(1, T + 1)
for name, arms in runs.items():
    cum_regret = np.cumsum(mu_star - mu[arms])
    ax1.plot(ts, cum_regret, color=colors[name], lw=2, label=name)
ax1.set_xlabel("ラウンド t"); ax1.set_ylabel("累積リグレット")
ax1.set_title("累積リグレットの推移(寝ているほど良い)")
ax1.legend(loc="upper left", fontsize=9); ax1.grid(alpha=0.3)

# 右:各戦略の腕選択割合(積み上げ。最良腕=腕3が上に伸びるほど良い)
names = list(runs.keys())
arm_colors = ["#cccccc", "#9ecae1", "#fdae6b", "#e6550d"]  # 腕3を強調
fracs = np.array([[np.mean(runs[nm] == k) for nm in names] for k in range(K)])
bottom = np.zeros(len(names))
for k in range(K):
    lbl = f"腕{k}" + ("(最良)" if k == best_arm else "")
    ax2.bar(names, fracs[k], bottom=bottom, color=arm_colors[k], label=lbl,
            edgecolor="white", width=0.6)
    bottom += fracs[k]
for j, nm in enumerate(names):                 # 最良腕の割合を上に注記
    ax2.text(j, 1.02, f"{np.mean(runs[nm] == best_arm):.0%}", ha="center", fontsize=9)
ax2.set_ylim(0, 1.12); ax2.set_ylabel("腕の選択割合")
ax2.set_title("各戦略の腕選択割合(上の数字=最良腕の割合)")
ax2.legend(loc="lower center", ncol=4, fontsize=8, bbox_to_anchor=(0.5, -0.28))
plt.setp(ax2.get_xticklabels(), fontsize=9)

fig.suptitle("多腕バンディット:探索と活用のトレードオフ(μ=[.10,.13,.16,.20]・T=2000)")
fig.tight_layout()
plt.show()

左のリグレット曲線が物語です。無作為(灰)は直線——毎ラウンド一定の損を積む線形リグレット。Thompson(赤)は最も早く寝て最下層を走ります。ε-greedy(青)は序盤に立ち上がったあとほぼ平らになり(固定 ε\varepsilon の線形の尾でわずかに上り続ける)、UCB1(橙)はその中間を、まだ寝きらずに上昇し続けます。右の積み上げ棒は各戦略の腕配分で、無作為はほぼ四等分、ε-greedy と Thompson は最良腕(濃橙)が 88 割超、UCB1 だけは腕2(薄橙)と腕3がほぼ半々——「まだ最良を選びきれていない」ことが目で見えます。

なぜ UCB1 がこの設定で振るわないかは、バグではなく原理です。最良腕(0.200.20)と次点の腕2(0.160.16)のギャップは 0.040.04 しかありません。UCB1 がこの2本を見分けるには信頼界の幅 2lnt/n\sqrt{2\ln t/n} をギャップの半分 0.020.02 未満に縮める必要があり、n>2ln(2000)/0.0223.8×104n>2\ln(2000)/0.02^2\approx3.8\times10^4 回——T=2000T=2000 では到底足りないのです。だから UCB1 は腕2と腕3を引き分けたまま終わります。実際、地平を T=20000,100000T=20000,\,100000 と伸ばすと最良腕割合は 68%,88%68\%,\,88\% へ上がり、リグレットは 7136068571\to360\to685対数的にしか増えません(=最良腕に収束していく)。固定 ε\varepsilon の ε-greedy が線形の尾を持つのに対し UCB1 は劣線形なので、十分大きな地平では UCB1 が優位になります(ただしギャップが小さい本設定では交差はさらに先で、Thompson はどの地平でも最小)。戦略の優劣は地平とギャップに依存する——短期キャンペーンと恒常運用で答えが変わる、という実務的に重要な教訓がここにあります。

⚠️ よくある誤解

関連ノート