n 本腕バンディット問題2020年11月8日 | |
はじめにSutton and Barto 著『強化学習』(Reinforcement Learning An Introduction) を参考に、n 本腕バンディット問題の解き方を見る。 環境
n 本腕バンディット問題「1 本腕バンディット」というスロットマシンがあるらしい。これが n 台あるときに、これらを限られた回数だけ動かして利益を最大にする方法を探るのが n 本腕バンディット問題 (n-armed bandit problem あるいは multi-armed bandit problem) である。この問題を解くアルゴリズムは、治療薬の選択や Web ページデザインの選択などに応用できる。複数の選択肢があり、それぞれの効果を確率変数として定量化でき、それらを利用しながら最適な条件を探る必要がある場合に適用できる。 n 本腕バンディット問題は、強化学習の文脈では、1 つの状態をもつ学習問題とみなせる。n 本腕バンディット問題を解くアルゴリズムは、強化学習アルゴリズムのベースとなる。 ここでは Python を用いて問題と解法を記述する。まず、必要なモジュールを準備する。 %matplotlib inline from matplotlib import pyplot as plt import numpy as np from tqdm import tqdm 計算に結構時間がかかるので、tqdm で進捗を表示したほうがよい。 n 本腕バンディットを以下のように実装する。 class Bandits: def __init__(self, n_arms): self.n_arms = n_arms self.q = np.random.normal(size=self.n_arms) def play(self, i): assert(i < self.n_arms) return np.random.normal(loc=self.q[i]) 各バンディットの報酬の平均値を、平均 0、分散 1 の正規分布で決める。play() を実行すると、選んだバンディットの平均値で分散 1 の正規分布に応じた報酬が得られる。 10 本腕バンディットで 1000 回のプレイを 2000 回行い、それぞれのプレイの報酬を平均して評価する。 def testbed(n_arms, n_runs, n_plays, new_palyer, new_bandits=lambda n_arms: Bandits(n_arms)): r_avgs = np.zeros(n_plays) for run in tqdm(range(n_runs)): bandits = new_bandits(n_arms) player = new_palyer(n_arms) r_avg = 0. for i in range(n_plays): a = player.get_action() r = bandits.play(a) player.update_value(a, r) r_avgs[i] += r r_avgs /= n_runs return r_avgs n_arms = 10 n_runs = 2000 n_plays = 1000 $\varepsilon$ グリーディ法行動価値を報酬の平均値で表す。 $$ Q_t(a) = \frac{r_1 + r_2 + \cdots + r_{k_a}}{k_a} $$ここで、$t$ はプレイの回数、$k_a$ は行動 $a$ が取られた回数である。これは次のように書ける。 $$ Q_{t+1}(a) = Q_t(a) + \frac{1}{k_a + 1} (r_{k_a + 1} - Q_t(a)) $$これは一般化して次のように書ける。 $$ Q_{t+1}(a) = Q_t(a) + \alpha (r_{k_a + 1} - Q_t(a)) $$ここで $\alpha$ はステップサイズパラメータと呼ばれる。上式は $Q_t(a)$ を $r_{k_a}$ に向かって修正していると解釈できる。 行動は $Q_t(a^*) = \max_a Q_t(a)$ となるような $a^*$ を選択する。これをグリーディ (greedy、貪欲) 法という。この方法だと、他のもっとよい行動を取る余地がないので、確率 $\varepsilon$ でランダムな行動を取る。これを $\varepsilon$ グリーディ ($\varepsilon$-greedy) 法という。 $\varepsilon$ グリーディ法を用いるプレイヤーは以下のように実装できる。 class BanditsGreedyPlayer: def __init__(self, n_arms, eps=0., q0=0., alpha=0.): self.n_arms = n_arms self.eps = eps self.q = np.ones(self.n_arms)*q0 self.num_select = np.zeros(self.n_arms) self.alpha = alpha def get_action(self): if np.random.rand() < self.eps: return np.random.choice(n_arms) else: return np.argmax(self.q) def update_value(self, a, r): if self.alpha <= 0.: self.num_select[a] += 1 self.q[a] += (r - self.q[a])/self.num_select[a] else: self.q[a] += self.alpha*(r - self.q[a]) テスト。ここでは再現性確保のため、乱数シードを固定している。 np.random.seed(0) r_avgs0 = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsGreedyPlayer(n_arms)) r_avgs001 = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsGreedyPlayer(n_arms, eps=0.01)) r_avgs01 = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsGreedyPlayer(n_arms, eps=0.1)) 可視化。 x = range(n_plays) plt.plot(x, r_avgs0, label=r"$\varepsilon = 0$") plt.plot(x, r_avgs001, label=r"$\varepsilon = 0.01$") plt.plot(x, r_avgs01, label=r"$\varepsilon = 0.1$") plt.xlim(0, 1000) plt.ylim(0.) plt.xlabel("Number of plays") plt.ylabel("Average of rewards") plt.legend(loc="lower right") plt.show() グリーディな方法より、少し探索を入れたほうが報酬は高くなっている。 ソフトマックス行動選択行動を以下の確率分布から選択する。 $$ \pi_t(a) = \frac{e^{Q_t(a)/\tau}}{\Sigma^n_{b=1}e^{Q_t(b)/\tau}} $$ここで $\tau$ は温度と呼ばれる調整定数である。$\tau \rightarrow 0$ の極限でグリーディ手法と一致する。 class BanditsSoftmaxPlayer: def __init__(self, n_arms, tau=1., q0=0., alpha=0.): self.n_arms = n_arms self.tau = tau self.q = np.ones(self.n_arms)*q0 self.num_action = np.zeros(self.n_arms) self.alpha = alpha def get_action(self): n = np.exp(self.q/self.tau) d = np.sum(n) pi = n/d return np.random.choice(n_arms, p=pi) def update_value(self, a, r, alpha=0.): if alpha <= 0.: self.num_action[a] += 1 self.q[a] += (r - self.q[a])/self.num_action[a] else: self.q[a] += alpha*(r - self.q[a]) np.random.seed(0) r_avgs_sm01 = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsSoftmaxPlayer(n_arms, tau=0.1)) r_avgs_sm001 = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsSoftmaxPlayer(n_arms, tau=0.01)) x = range(n_plays) plt.plot(x, r_avgs0, label=r"$\varepsilon = 0$") plt.plot(x, r_avgs01, label=r"$\varepsilon = 0.1$") plt.plot(x, r_avgs_sm01, label=r"$\tau = 0.1$") plt.plot(x, r_avgs_sm001, label=r"$\tau = 0.01$") plt.xlim(0, 1000) plt.ylim(0.) plt.xlabel("Number of plays") plt.ylabel("Average of rewards") plt.legend(loc="lower right") plt.show() この方法は、$\tau$ の決め方がよくわからないのが難点である。 非定常問題問題自体が時間に応じて変化していく非定常 (nonstationary) 問題の場合は、ステップサイズパラメータを固定したほうがよい。 class BanditsNonstationary: def __init__(self, n_arms): self.n_arms = n_arms self.q = np.random.normal(size=self.n_arms) def play(self, i): assert(i < self.n_arms) self.q += np.random.normal(scale=0.1, size=self.n_arms) # random walk return np.random.normal(loc=self.q[i]) 報酬の平均値がプレイの度にランダムウォークするようにしている。 np.random.seed(0) r_avgs0_nonst = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsGreedyPlayer(n_arms), new_bandits=lambda n_arms: BanditsNonstationary(n_arms)) r_avgs0_nonst01 = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsGreedyPlayer(n_arms, alpha=0.1), new_bandits=lambda n_arms: BanditsNonstationary(n_arms)) x = range(n_plays) plt.plot(x, r_avgs0_nonst, label=r"$\varepsilon = 0$, $\alpha = 1/k$") plt.plot(x, r_avgs0_nonst01, label=r"$\varepsilon = 0$, $\alpha = 0.1$") plt.xlim(0, 1000) plt.ylim(0.) plt.xlabel("Number of plays") plt.ylabel("Average of rewards") plt.legend() plt.show() ステップサイズパラメータを固定したほうが報酬が高くなっている。 楽観的 (optimistic) 初期値行動価値の初期値を大きく取ることで、一通りすべての行動を試すようになる。 np.random.seed(0) r_avgs0_opt = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsGreedyPlayer(n_arms, q0=5.)) r_avgs01_opt = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsGreedyPlayer(n_arms, eps=0.1, q0=5.)) x = range(n_plays) plt.plot(x, r_avgs01, label=r"$\varepsilon = 0.1$, $q0 = 0$") plt.plot(x, r_avgs0_opt, label=r"$\varepsilon = 0$, $q0 = 5$") plt.plot(x, r_avgs01_opt, label=r"$\varepsilon = 0.1$, $q0 = 5$") plt.xlim(0, 1000) plt.ylim(0.) plt.xlabel("Number of plays") plt.ylabel("Average of rewards") plt.legend(loc="lower right") plt.show() この結果は、たまに探索を行うより、初めに一通り調べてから最良のものを選択した方がよい、という結果になっている。 UCB (Upper Confidence Bound) 行動選択行動の選択を次式で行う。 $$ Q_t(a^*) = \max_a \left[ Q_t(a) + c \sqrt{\frac{\log t}{k_a}} \right] $$あまり選択されていない行動が選択されるようになる。 class BanditsUCBPlayer: def __init__(self, n_arms, c=1., q0=0., alpha=0.): self.n_arms = n_arms self.c = c self.q = np.ones(self.n_arms)*q0 self.num_action = np.zeros(self.n_arms) self.alpha = alpha self.t = 0 def get_action(self): self.t += 1 ucb = self.c*np.sqrt(np.log(self.t)/(self.num_action + 1e-5)) return np.argmax(self.q + ucb) def update_value(self, a, r): if self.alpha <= 0.: self.num_action[a] += 1 self.q[a] += (r - self.q[a])/self.num_action[a] else: self.q[a] += self.alpha*(r - self.q[a]) np.random.seed(0) r_avgs_ucb1 = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsUCBPlayer(n_arms)) r_avgs_ucb2 = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsUCBPlayer(n_arms, c=2.)) r_avgs_ucb071 = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsUCBPlayer(n_arms, c=0.71)) # c = 1/sqrt(2) ここで $c = 0.71$ というのは、$c = 1/\sqrt{2}$ のことである。 x = range(n_plays) plt.plot(x, r_avgs01, label=r"$\varepsilon = 0.1$") plt.plot(x, r_avgs_ucb1, label=r"ucb $c = 1$") plt.plot(x, r_avgs_ucb2, label=r"ucb $c = 2$") plt.plot(x, r_avgs_ucb071, label=r"ucb $c = 0.71$") plt.xlim(0, 1000) plt.ylim(0.) plt.xlabel("Number of plays") plt.ylabel("Average of rewards") plt.legend(loc="lower right") plt.show() これまでで一番よい結果である。非定常問題でも試してみる。 np.random.seed(0) r_avgs_ucb071_nonst = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsUCBPlayer(n_arms, c=0.71), new_bandits=lambda n_arms: BanditsNonstationary(n_arms)) x = range(n_plays) plt.plot(x, r_avgs0_nonst, label=r"$\varepsilon = 0$, $\alpha = 1/k$") plt.plot(x, r_avgs0_nonst01, label=r"$\varepsilon = 0$, $\alpha = 0.1$") plt.plot(x, r_avgs_ucb071_nonst, label=r"ucb $c = 0.71$") plt.xlim(0, 1000) plt.ylim(0.) plt.xlabel("Number of plays") plt.ylabel("Average of rewards") plt.legend() plt.show() 非定常問題でもそこそこの結果である。 強化比較 (reinforcement comparison)行動を確率分布 $\pi_t(a)$ で決める。これは優先度 $p_t(a)$ からソフトマックス手法で求める。 $$ \pi_t(a) = \frac{e^{p_t(a)}}{\sum^n_{b=1}e^{p_t(b)}} $$優先度は、選択された行動 $a_t$ について、報酬 $r_t$ と参照報酬 $\bar{r}_t$ との差を用いて更新する。 $$ p_{t+1}(a_t) = p_t(a_t) + \beta (r_t - \bar{r}_t) $$参照報酬は次式で計算する。 $$ \bar{r}_{t+1} = \bar{r}_t + \alpha (r_t - \bar{r}_t) $$$\alpha = 1/t$ であれば、これは報酬の平均値である。参照報酬の初期値は楽観的な値を設定する。 優先度を次式のように修正すると、参照報酬の初期値依存性を改善できる。 $$ p_{t+1}(a_t) = p_t(a_t) + \beta (r_t - \bar{r}_t) \{1 - \pi_t(a_t)\} $$class BanditsRCPlayer: def __init__(self, n_arms, alpha, rr0, beta=1.): self.n_arms = n_arms self.p = np.ones(self.n_arms) self.pi = np.ones(self.n_arms)*(1./self.n_arms) self.alpha = alpha self.rr = rr0 self.beta = beta def get_action(self): return np.random.choice(n_arms, p=self.pi) def update_value(self, a, r): self.p[a] += self.beta*(r - self.rr) n = np.exp(self.p) d = np.sum(n) self.pi = n/d self.rr += self.alpha*(r - self.rr) np.random.seed(0) r_avgs_rc01 = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsRCPlayer(n_arms, 0.1, 0.)) r_avgs_rc01_opt = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsRCPlayer(n_arms, 0.1, 5.)) x = range(n_plays) plt.plot(x, r_avgs01, label=r"$\varepsilon = 0.1$") plt.plot(x, r_avgs_ucb071, label=r"ucb $c = 0.71$") plt.plot(x, r_avgs_rc01, label=r"rc $\alpha = 0.1$, $rr0 = 0$") plt.plot(x, r_avgs_rc01_opt, label=r"rc $\alpha = 0.1$, $rr0 = 5$") plt.xlim(0, 1000) plt.ylim(0.) plt.xlabel("Number of plays") plt.ylabel("Average of rewards") plt.legend(loc="lower right") plt.show() 参照報酬の初期値が 0 だと報酬が下がる。参照報酬の影響を抑えた方法は次のようになる。 class BanditsRC2Player: def __init__(self, n_arms, alpha, rr0=0., beta=1.): self.n_arms = n_arms self.p = np.ones(self.n_arms) self.pi = np.ones(self.n_arms)*(1./self.n_arms) self.alpha = alpha self.rr = rr0 self.beta = beta def get_action(self): return np.random.choice(n_arms, p=self.pi) def update_value(self, a, r): self.p[a] += self.beta*(r - self.rr)*(1. - self.pi[a]) n = np.exp(self.p) d = np.sum(n) self.pi = n/d self.rr += self.alpha*(r - self.rr) np.random.seed(0) r_avgs_rc201 = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsRC2Player(n_arms, 0.1)) x = range(n_plays) plt.plot(x, r_avgs01, label=r"$\varepsilon = 0.1$") plt.plot(x, r_avgs_ucb071, label=r"ucb $c = 0.71$") plt.plot(x, r_avgs_rc01_opt, label=r"rc $\alpha = 0.1$") plt.plot(x, r_avgs_rc201, label=r"rc2 $\alpha = 0.1$") plt.xlim(0, 1000) plt.ylim(0.) plt.xlabel("Number of plays") plt.ylabel("Average of rewards") plt.legend(loc="lower right") plt.show() 参照報酬の初期値が 0 でもそれなりの結果になっている。 追跡手法 (pursuit method)追跡手法は、行動選択の確率分布 $\pi_t(a)$ を、グリーディ手法に近づくように修正する。 $Q_t(a^*) = \max_a Q_t(a)$ となる行動 $a^*$ について、行動選択確率を次のように更新する。 $$ \pi_{t+1}(a^*) = \pi_t(a^*) + \beta\{1 - \pi_t(a^*)\} $$$a^*$ 以外の行動 $a$ については逆方向に更新する。 $$ \pi_{t+1}(a) = \pi_t(a) + \beta\{0 - \pi_t(a)\} $$行動選択確率を直接更新せず、優先度からソフトマックス手法で求めるようにするのであれば、優先度を次のように更新すればよい。 $$ p_{t+1}(a^*) = p_t(a^*) + \beta\{1 - \pi_t(a^*)\} $$$a^*$ 以外の行動 $a$ については逆方向に更新する。 $$ p_{t+1}(a) = p_t(a) + \beta\{0 - \pi_t(a)\} $$class BanditsPursuitPlayer: def __init__(self, n_arms, beta, q0=0., alpha=0.): self.n_arms = n_arms self.beta = beta self.q = np.ones(self.n_arms)*q0 self.num_action = np.zeros(self.n_arms) self.pi = np.ones(self.n_arms)*(1./self.n_arms) self.alpha = alpha def get_action(self): return np.random.choice(n_arms, p=self.pi) def update_value(self, a, r): if self.alpha <= 0.: self.num_action[a] += 1 self.q[a] += (r - self.q[a])/self.num_action[a] else: self.q[a] += self.alpha*(r - self.q[a]) a_max = np.argmax(self.q) one = np.zeros(self.n_arms) one[a_max] = 1. self.pi += self.beta*(one - self.pi) np.random.seed(0) r_avgs_pm001 = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsPursuitPlayer(n_arms, 0.01)) x = range(n_plays) plt.plot(x, r_avgs01, label=r"$\varepsilon = 0.1$") plt.plot(x, r_avgs_ucb071, label=r"ucb $c = 0.71$") plt.plot(x, r_avgs_rc01_opt, label=r"rc $\alpha = 0.1$") plt.plot(x, r_avgs_pm001, label=r"pursuit method $\beta = 0.01$") plt.xlim(0, 1000) plt.ylim(0.) plt.xlabel("Number of plays") plt.ylabel("Average of rewards") plt.legend() plt.show() 最終的には UCB 行動選択と同程度の報酬が得られている。ソフトマックス手法を用いた場合は、次のようになる。 class BanditsPursuit2Player: def __init__(self, n_arms, beta, q0=0., alpha=0.): self.n_arms = n_arms self.beta = beta self.q = np.ones(self.n_arms)*q0 self.num_action = np.zeros(self.n_arms) self.p = np.ones(self.n_arms) self.pi = np.ones(self.n_arms)*(1./self.n_arms) self.alpha = alpha def get_action(self): return np.random.choice(n_arms, p=self.pi) def update_value(self, a, r): if self.alpha <= 0.: self.num_action[a] += 1 self.q[a] += (r - self.q[a])/self.num_action[a] else: self.q[a] += self.alpha*(r - self.q[a]) a_max = np.argmax(self.q) one = np.zeros(self.n_arms) one[a_max] = 1. self.p += self.beta*(one - self.pi) n = np.exp(self.p) d = np.sum(n) self.pi = n/d np.random.seed(0) r_avgs_pm201 = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsPursuit2Player(n_arms, 0.1)) x = range(n_plays) plt.plot(x, r_avgs_pm001, label=r"pursuit method $\beta = 0.01$") plt.plot(x, r_avgs_pm201, label=r"pursuit method 2 $\beta = 0.1$") plt.xlim(0, 1000) plt.ylim(0.) plt.xlabel("Number of plays") plt.ylabel("Average of rewards") plt.legend() plt.show() 直接に行動選択確率を更新するのと同様の報酬が得られている。 勾配法 (gradient bandit algorithm)これは強化比較と追跡手法を組み合わせたような方法である。 選択された行動 $a_t$ について、優先度を以下の式で更新する。 $$ p_{t+1}(a_t) = p_t(a_t) + \beta(r_t - \bar{r}_t)\{1 - \pi_t(a_t)\} $$それ以外の行動については、逆方向に更新する。 $$ p_{t+1}(a) = p_t(a) + \beta(r_t - \bar{r}_t)\{0 - \pi_t(a)\} $$参照報酬 $\bar{r}_t$ は、報酬 $r_t$ の平均値である。 $$ \bar{r}_{t+1} = \bar{r}_t + \frac{1}{t}(r_t - \bar{r}_t) $$class BanditsGradientPlayer: def __init__(self, n_arms, beta, rr0=0., alpha=0.): self.n_arms = n_arms self.p = np.ones(self.n_arms) self.pi = np.ones(self.n_arms)*(1./self.n_arms) self.beta = beta self.rr = rr0 self.alpha = alpha self.t = 0 def get_action(self): return np.random.choice(n_arms, p=self.pi) def update_value(self, a, r): one = np.zeros(self.n_arms) one[a] = 1. self.p += self.beta*(r - self.rr)*(one - self.pi) n = np.exp(self.p) d = np.sum(n) self.pi = n/d self.t += 1 if self.alpha <= 0.: self.rr += (r - self.rr)/self.t else: self.rr += self.alpha*(r - self.rr) np.random.seed(0) r_avgs_gm01 = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsGradientPlayer(n_arms, 0.1)) x = range(n_plays) plt.plot(x, r_avgs01, label=r"$\varepsilon = 0.1$") plt.plot(x, r_avgs_ucb071, label=r"ucb $c = 0.71$") plt.plot(x, r_avgs_rc01_opt, label=r"rc $\alpha = 0.1$") plt.plot(x, r_avgs_pm201, label=r"pursuit method 2 $\beta = 0.1$") plt.plot(x, r_avgs_gm01, label=r"gradient method $\beta = 0.1$") plt.xlim(0, 1000) plt.ylim(0.) plt.xlabel("Number of plays") plt.ylabel("Average of rewards") plt.legend() plt.show() 追跡手法と同程度の報酬が得られている。非定常問題でも試してみる。 np.random.seed(0) r_avgs_gm01_nonst = testbed(n_arms, n_runs, n_plays, lambda n_arms: BanditsGradientPlayer(n_arms, 0.1), new_bandits=lambda n_arms: BanditsNonstationary(n_arms)) x = range(n_plays) plt.plot(x, r_avgs0_nonst, label=r"$\varepsilon = 0$, $\alpha = 1/k$") plt.plot(x, r_avgs0_nonst01, label=r"$\varepsilon = 0$, $\alpha = 0.1$") plt.plot(x, r_avgs_ucb071_nonst, label=r"ucb $c = 0.71$") plt.plot(x, r_avgs_gm01_nonst, label=r"gradient method $\beta = 0.1$") plt.xlim(0, 1000) plt.ylim(0.) plt.xlabel("Number of plays") plt.ylabel("Average of rewards") plt.legend() plt.show() そこそこの結果である。 まとめ今回実験した条件では、UCB 行動選択でもっとも高い報酬が得られた。おそらく、条件を変えると多少順番が入れ替わったりするものと考えられる。 | |
PENGUINITIS |