バンディットアルゴリズム 基本編

データ分析部の中村、中野です。
今回から2回に分けてバンディットアルゴリズムをご紹介いたします。

今回は基本編ということで「バンディットアルゴリズム」の基本的な思想と代表的な方策について簡単にご説明します。

バンディットアルゴリズムはWEB広告配信やレコメンドシステム、はたまたトップ棋士に勝ち越したことで有名なアルファ碁にもその技術が応用されたことで注目を集めています。

そもそも、バンディットアルゴリズムとはどういったものでしょうか。まずは少しややこしいその問題設定を丁寧に見ていきます。

バンディットアルゴリズムで扱うのは、

「選択肢はいくつもあるが、どの選択肢が効果が高いのかは事前にはわからない」

「限られた試行回数でできる限りいい選択肢を選んでいき、トータルの報酬を最大化したい」

といったような問題設定です。WEB画面上に表示する広告や配信するコンテンツをどう選択するか、顧客にダイレクトメールを送る際の件名はどの候補がよいか、といったようにこのような問題はビジネスの現場でもよく見られます。

重要なのはどの選択肢がよいか事前に情報がない、という点です。もし各選択肢について情報があるのであれば、それを学習データとして教師あり学習による予測モデルを作ることで事足ります。バンディットでは学習データがない状況からどの選択肢がよいかを学習しながら、その過程で得られる報酬を最大化することを目的としています。

同じような設定でA/Bテストがよく用いられますが、バンディットと何が異なるのかを整理しましょう。

A/BテストはAかBの2つの選択肢のどちらがより優れているかを統計学的仮説検定を元に判断する枠組みです。全体を2つのグループに分割し、あるグループにはAという選択肢、もう一方のグループにはBを適用し、その結果からAとBのどちらが優れているかを決定します。また、一度決定した後は優れた選択肢を選びつづけるというのが一般的です。つまりA/Bテストは最適な選択肢を見つけるためのテストです。このように最適な選択肢を見つけ出す枠組みを最適腕識別と言い、A/Bテストはこの最適腕識別に分類されます。

一方でバンディットアルゴリズムで目指すのは累積報酬の最大化です。有限回の試行の中で報酬を最大化するには、優れたアームを多く引き、劣ったアームは引く回数を抑えることが必要となります(バンディットの文脈では選択肢のことをアームと呼びます)。しかし、事前に各アームの良し悪しはわからないので、どのアームが良いかを探りつつ(探索)、良さそうなアームほど積極的に引いていく(活用)をバランスさせるのがバンディットアルゴリズムの本質です。

どのアームが良いアームかについては、それまでに得られた報酬の標本平均などをアームごとにスコア化し、それらを比較することで判断します。そのため、ある時点で選択したアームの報酬が高ければ選択したアームのスコアが高くなり、報酬が低ければ選択したアームのスコアが低くなるといったように随時情報を更新しながらアームの良し悪しを判断していきます。

バンディットアルゴリズムは、アームを選択した結果得られる報酬を動的に反映し、次のアームの選択に活かすことからA/Bテストに比べて機会損失の低減が見込めます。また、コンテンツの入れ替わりが多いようなケースではA/Bテストを都度設計するのは手間ですが、バンディットならシステム化できるというメリットもあります。

bandit_image_64_48

ではここからは代表的な方策であるε-greedy方策、UCB方策、Thompson Sampling方策の3つをご紹介していきます。

■ε-greedy方策

最も単純な方策としてε-greedy方策が知られています。この方策では各ステップごとに確率εで探索、1−εで活用を行います。具体的には、

探索時:すべてのアームをランダムに選択

活用時:それまでの試行の結果から、報酬の標本平均\hat{\mu}_{i}の最も高かったアームを選択

という方法でアームを選択していくことで累積報酬の最大化を目指します1

この方策はシンプルで判りやすいですが、最適な探索回数を見つけるのが困難という課題があり、探索と活用のバランスをうまく調整できないと次のような問題が生じます。

探索が少ない → 最適なアームを発見できず、活用時に最適でないアームを引き続ける可能性がある

探索が多い  → 最適でないアームを余分に引いてしまう

■UCB方策

累積報酬を最大化するためには最適なアームを多く引いていくことが重要ですが、選択回数が少ないアームについては報酬が正確に推定できていない可能性を考慮する必要があります。

これらのバランスをとれる方策としてUCB(Upper Confidence Bound)方策を紹介します。

この方策では、アームを選択する際に毎回以下の式で表されるスコアを算出し、最もスコアの高いアームを引きます。

\bar{\mu}_{i}(t) :時刻tのアームiのスコア

\hat{\mu}_{i}(t) :時刻tのアームiの標本平均

N_{i}(t) :時刻tまでのアームiの選択回数

\bar{\mu}_i(t)=\hat{\mu}_i(t)+\sqrt{\frac{\log t}{2N_i(t)}}

標本平均に補正項を加えた値がスコアであり、選択数 N_{i}(t) が少ないアームほど補正項の値は大きくなります。そのため標本平均は小さいが、選択回数が少ないアームも選ばれることがあります。

つまり、単純に標本平均の大きなアームが選択される時は活用が行われ、標本平均は小さいが、選択回数が少ないアームが選択される時は探索が行なわれていると解釈できます。

このようにUCB方策では探索と活用のバランスをうまくとりながらアームの選択を行い、報酬の最大化を目指します。

■Thompson Sampling方策

「そのアームが最適なアームである確率」に注目した確率一致法という方法論があります。Thompson Samplingとはベイズ統計の枠組みをこの確率一致法に適用した方策です。

Thompson Sampling方策では、アームiの期待値のパラメータ\mu_iが何らかの事前分布\pi_i(\mu_i)から生成されるとし、時刻tまでの観測{\cal H} (t)が得られたときの期待値\mu_iの事後分布を考えます。

「アームiが最適」という命題は、「あるx\mu_i=x、かつすべてのj\neq i\mu_j\leq x」と解釈でき、数式を用いると以下のように表現できます。

\displaystyle\pi(\mu_i = \mu^* |{\cal H} (t)) = \int \pi(x_i|{\cal H} (t))\left(\prod_{j\neq i}\int_{x_j\leq x_i}\pi_j(x_j|{\cal H} (t))dx_j\right)dx_i

この式を各アームについて計算し事後確率を比較すれば良いのですが、一般にこの式を計算することは困難なことが知られています。

しかし実際に事後確率を直接比較する必要はなく、以下の手続きを行うことで「期待値最大の事後確率」に基づいたアームの選択を実現することができます。

①各アームの期待値の事後分布\pi(x_i|{\cal H}(t))から乱数\tilde{\mu}_iを生成

\tilde{\mu}_iを最大にするアームiを引く

Thompson Sampling方策はUCB方策に比べて余分な探索が少なくなることが知られていて、有限の試行回数でより良い性能を達成することができます。

方策の紹介は以上にして、これら3つの方策(ε-greedy方策、UCB方策、Thompson Sampling方策)とランダムにアームを選んだ場合との比較実験を行います。

実験では10本のアームを合計で10,000回選ぶ中で累積報酬を最大化することを目指します。得られる報酬は0/1のベルヌーイ分布に従い、パラメータpはそれぞれ以下のように設定します。(プレイヤーはこの情報を知らないという前提です)

アーム 1 2 3 4 5
確率 5.4% 6.9% 8.0% 9.7% 11.2%
アーム 6 7 8 9 10
確率 11.9% 12.1% 14.4% 15.5% 17.4%

例えば全てアーム1を選択した場合、報酬を受け取る回数の期待値は540回(0.054 × 10,000回)になり、

全てアーム10(最適なアーム)を選択した場合、報酬を受け取る回数の期待値は1,740回(0.174 × 10,000回)になります。

結果にランダム性があるのでモンテカルロシミュレーションを行い、方策ごとの累積報酬の平均値で評価を行うこととします。

実験の結果は以下の図のようになりました。(実験のコードはページ下部に記載)

bandit_test_image_64_48

右の図がアルゴリズムごとの累積報酬を可視化した図で、方策ごとの平均累積報酬を表しています。方策に従ってアームを選択した場合では、ランダムに選択する場合と比べ累積報酬が高くなっていることがわかります。今回の実験で最も累積報酬が高かったのはThompson Sampling方策で約1,700回報酬を受け取っています。ランダムに選択した場合の累積報酬が約1,100回なので、50%以上の改善となりました。

左の図はアルゴリズムことに最適アームの選択割合を可視化した図で、横軸が試行回数、縦軸が最適なアームを選択した割合を示しています。一番下の青い線がランダムにアームを選んだ場合で、およそ1/(アームの本数)の確率で最適なアームが選択されている様子が見られます(今回は1/10で10%)。紹介した3つの方策では、試行回数が多くなるにつれて最適なアームを選択する割合が多くなっている様子がわかります。

最適なアームの選択割合についてもThompson Sampling方策が最も高く、最終的に約90%の割合で最適なアームを選択しています。

また、ε-greedy方策とUCB方策について2つの図を見比べると、最適腕の選択割合(左図)ではε-greedy方策が割合が高いにも関わらず、累積報酬(右図)ではUCB方策の方が多く報酬を獲得しています。これは探索時にアームをランダムに選択するε-greedy方策と比べ、UCB方策はアームごとのUCBスコアに基づいてアームを選択することで悪いアームの選択回数を抑え、全体として比較的期待値の高いアームを多く引いたためと考えられます。

実問題では報酬を最大化することだけではなく、計算効率についても考慮する必要があり、紹介した方策以外にもいくつかの方策が提案されています。

今回は基礎編ということでバンディット問題の基本思想と代表的な方策についてご紹介しましたが、様々な応用問題も考えられています。例えば、アームから受け取る報酬の確率分布が時間変化する場合や、選択できるアームが時間変化する場合、アームごとに選択できる回数に上限がある場合などがあります。

次回はそんな応用例の1つである文脈付きバンディット問題について紹介する予定です。

参考文献

Bandit Algorithms for Website Optimization O’Reilly
バンディット問題の理論とアルゴリズム(機械学習プロフェッショナルシリーズ) 講談社

以下シミュレーションに使用したコード
(環境はpython3、https://github.com/johnmyleswhite/BanditsBook を参考に作成)

各アルゴリズム(model.pyとして保存)

# -*- coding:utf-8 -*-
import numpy as np
import random

class BernoulliArm():

    def __init__(self, p):
        self.p = p

    def draw(self):
        if random.random() > self.p:
            return 0.0
        else:
            return 1.0

class random_select():

    def __init__(self, counts, values):
        self.counts = counts
        self.values = values

    def initialize(self, n_arms):
        self.counts = np.zeros(n_arms)
        self.values = np.zeros(n_arms)

    def select_arm(self):
        return random.randint(0, len(self.values) - 1)

    def update(self, chosen_arm, reward):
        self.counts[chosen_arm] = self.counts[chosen_arm] + 1
        n = self.counts[chosen_arm]
        value = self.values[chosen_arm]
        new_value = ((n - 1) / float(n)) * value + (1 / float(n)) * reward
        self.values[chosen_arm] = new_value

class EpsilonGreedy():

    def __init__(self, epsilon, counts, values):
        self.epsilon = epsilon
        self.counts = counts
        self.values = values

    def initialize(self, n_arms):
        self.counts = np.zeros(n_arms)
        self.values = np.zeros(n_arms)

    def select_arm(self):
        if random.random() > self.epsilon:
            return np.argmax(self.values)
        else:
            return random.randint(0, len(self.values) - 1)

    def update(self, chosen_arm, reward):
        self.counts[chosen_arm] = self.counts[chosen_arm] + 1
        n = self.counts[chosen_arm]
        value = self.values[chosen_arm]
        new_value = ((n - 1) / float(n)) * value + (1 / float(n)) * reward
        self.values[chosen_arm] = new_value

class UCB():

    def __init__(self, counts, values):
        self.counts = counts
        self.values = values

    def initialize(self, n_arms):
        self.counts = np.zeros(n_arms)
        self.values = np.zeros(n_arms)

    def select_arm(self):
        n_arms = len(self.counts)
        if min(self.counts) == 0:
            return np.argmin(self.counts)

        total_counts = sum(self.counts)
        bonus = np.sqrt((np.log(np.array(total_counts))) /
                        2 / np.array(self.counts))
        ucb_values = np.array(self.values) + bonus
        return np.argmax(ucb_values)

    def update(self, chosen_arm, reward):
        self.counts[chosen_arm] = self.counts[chosen_arm] + 1
        n = self.counts[chosen_arm]
        value = self.values[chosen_arm]
        new_value = ((n - 1) / float(n)) * value + (1 / float(n)) * reward
        self.values[chosen_arm] = new_value

class ThompsonSampling():

    def __init__(self, counts_alpha, counts_beta, values):
        self.counts_alpha = counts_alpha
        self.counts_beta = counts_beta
        self.alpha = 1
        self.beta = 1
        self.values = values

    def initialize(self, n_arms):
        self.counts_alpha = np.zeros(n_arms)
        self.counts_beta = np.zeros(n_arms)
        self.values = np.zeros(n_arms)

    def select_arm(self):
        theta = [(arm,
                  random.betavariate(self.counts_alpha[arm] + self.alpha,
                                     self.counts_beta[arm] + self.beta))
                 for arm in range(len(self.counts_alpha))]
        theta = sorted(theta, key=lambda x: x[1])
        return theta[-1][0]

    def update(self, chosen_arm, reward):
        if reward == 1:
            self.counts_alpha[chosen_arm] += 1
        else:
            self.counts_beta[chosen_arm] += 1
        n = float(self.counts_alpha[chosen_arm]) + self.counts_beta[chosen_arm]
        self.values[chosen_arm] = (n - 1) / n * \
            self.values[chosen_arm] + 1 / n * reward

def test_algorithm(algo, arms, num_sims, horizon):
    chosen_arms = np.zeros(num_sims * horizon)
    cumulative_rewards = np.zeros(num_sims * horizon)
    times = np.zeros(num_sims * horizon)
    for sim in range(num_sims):
        algo.initialize(len(arms))
        for t in range(horizon):
            index = sim * horizon + t
            times[index] = t + 1
            chosen_arm = algo.select_arm()
            chosen_arms[index] = chosen_arm
            reward = arms[chosen_arm].draw()
            if t == 0:
                cumulative_rewards[index] = reward
            else:
                cumulative_rewards[index] = cumulative_rewards[
                    index - 1] + reward
            algo.update(chosen_arm, reward)
    return [times, chosen_arms, cumulative_rewards]

シミュレーション用

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from model import (BernoulliArm,
                   random_select,
                   EpsilonGreedy,
                   UCB,
                   ThompsonSampling,
                   test_algorithm)
import random

n_arms = 10
means = [0.054,  0.069,  0.080,  0.097,  0.112,
         0.119,  0.121,  0.144,  0.155,  0.174]

epsilon = 0.2  # パラメータ
sim_num = 500  # シミュレーション回数
time = 10000  # 試行回数

arms = pd.Series(map(lambda x: BernoulliArm(x), means))

algo_1 = random_select([], [])           # random
algo_2 = EpsilonGreedy(epsilon, [], [])  # epsilon-greedy
algo_3 = UCB([], [])                    # UCB
algo_4 = ThompsonSampling([], [], [])   # ThompsonSampling
fig = plt.figure()
ax1 = fig.add_subplot(121)
ax2 = fig.add_subplot(122)
heights = []
random.seed(2017)
for algo in [algo_1, algo_2, algo_3, algo_4]:
    algo.initialize(n_arms)
    result = test_algorithm(algo, arms, sim_num, time)

    df_result = pd.DataFrame({"times": result[0], "chosen_arms": result[1]})
    df_result["best_arms"] = (df_result["chosen_arms"]
                              == np.argmax(means)).astype(int)
    grouped = df_result["best_arms"].groupby(df_result["times"])

    ax1.plot(grouped.mean(), label=algo.__class__.__name__)
    heights.append(result[2][-1])

ax1.set_title("Compare 4model - Best Arm Rate")
ax1.set_xlabel("Time")
ax1.set_ylabel("Best Arm Rate")
ax1.legend(loc="upper left")

plt_label = ["Random", "Epsilon\nGreedy", "UCB", "Tompson \nSampling"]
plt_color = ["deep", "muted", "pastel", "bright"]
ax2.bar(range(1, 5), heights, color=sns.color_palette()[:4], align="center")
ax2.set_xticks(range(1, 5))
ax2.set_xticklabels(plt_label)
ax2.set_label("random_select")
ax2.set_ylabel("Cumulative Rewards")
ax2.set_title("Compare 4model - Cumulative Rewards")
plt.show()

  1. アルゴリズムの定義にはいくつかありますが「Bandit Algorithms for Website Optimization」のものを記述しています。