Sutton and Barto 教材中多臂老虎机(k-armed bandit testbed)模拟

简介

在 Sutton 和 Barto 的经典教材 《Reinforcement learning - an introduction》的第二章中,有一个模拟10臂老虎机 (10-armed bandit testbed) 的例子。这个例子显示了 ϵ \epsilon ϵ-greedy 算法优于 greedy(贪心)算法。由于课本中并没有介绍这两个算法的模拟细节,本文将介绍如何通过模拟得到课本中的结论。

问题描述

k k k-armed bandit 问题

k k k-armed bandit 问题是这样一种问题,假如我们面临 k k k 种选择,或者我们可以做 k k k 种动作 (action)。在某个时间 t t t 或者某一步 (step),我们可以从这 k k k 种选择之间做出一个选择,之后我们将会根据我们的选择得到一个回报(reward),这里回报是服从某个概率分布的随机变量,具体的概率分布取决于我们所做的选择。注意,这里的回报所服从的概率分布取决于不同的选择,即不同的选择对应不同的概率分布。我们的目的是经过很多步数之后,最大化这个回报。

我们记在时间 t t t 做的选择为 A t A_t At,得到的回报记为 R t R_t Rt

注意,这里在时间 t t t 我们做出一个选择 A t = a A_t = a At=a,我们只能得到对应选择 a a a 的一个回报,并不能得到关于其他选择的任何信息。我们把这种情况称为 bandit feedback [1]。

ϵ \epsilon ϵ-greedy 和 greedy 算法

q ∗ ( a ) q_{*}(a) q(a) Q t ( a ) Q_{t}(a) Qt(a)

在介绍 ϵ \epsilon ϵ-greedy 和 greedy 算法之前,我们先来看一下 q ∗ ( a ) q_{*}(a) q(a) Q t ( a ) Q_{t}(a) Qt(a) 的定义。我们把 q ∗ ( a ) q_{*}(a) q(a) 称为动作 a a a 的价值 (action value)。它是这么定义的:对于 k k k 种选择中的任一个,这个选择都对应一个回报的期望值,我们把对应于选择 a a a 的回报的期望值记为 q ∗ ( a ) q_{*}(a) q(a)。也就是说,

q ∗ ( a ) = E [ R t ∣ A t = a ] \displaystyle q_{*}(a) = \mathbb{E}[ R_t \vert A_t = a] q(a)=E[RtAt=a] …(1)。

如果我们知道 q ∗ ( a ) q_{*}(a) q(a) 的准确值,那么这个问题就解决了。我们只须要每次都选择 q ∗ ( a ) q_{*}(a) q(a) 最大的那个动作即可。

在实际情况中,我们往往不知道 q ∗ ( a ) q_{*}(a) q(a) 的准确值,从而我们需要去估计这个期望值。我们记在时间 t t t q ∗ ( a ) q_{*}(a) q(a) 的估计记为 Q t ( a ) Q_{t}(a) Qt(a)。我们希望 Q t ( a ) Q_{t}(a) Qt(a) 能尽量与 q ∗ ( a ) q_{*}(a) q(a) 接近。

Exploration 和 exploitation

接下来我们介绍 exploration 和 exploitation。因为我们不知道 q ∗ ( a ) q_{*}(a) q(a) 的准确值,我们只能通过 Q t ( a ) Q_{t}(a) Qt(a) 去估计动作 a a a 的价值。对于某一个时刻 t t t,我们在所有动作中可以贪心地选择当前 Q t ( a ) Q_{t}(a) Qt(a) 最大的那个动作。这样做的代价就是我们失去了尝试其他动作的机会 (在一个时刻 t t t,我们只能选择一个动作),也就是失去了更加准确估计其他动作价值的机会。我们把这种选择叫做 exploitation。也就是说,exploitation 只会选择目前来看价值估计最大的动作,不会去尝试其余的动作。

相反的,如果在某一个时刻 t t t,我们不去选择 Q t ( a ) Q_{t}(a) Qt(a) 最大的那个动作,而是选择其余的动作,于是我们就有机会对其余的动作的价值做出更好的估计。我们把这种选择叫做 exploration。exploration,顾名思义,就是对所有动作的一个探索。因为如果当前 Q t ( a ) Q_{t}(a) Qt(a) 最大的那个动作其真实的价值不是最大的,而我们选择 exploitation 的话,我们就会始终无法知道价值真正最大的动作。

为了通俗起见,我们再举一个例子。

假设我们在玩一个足球电竞游戏,系统随机分给我们一个队。我们可以控制一个队的 11 名球员。除了守门员,其余场上的 10 个球员都有射门的能力。但是我们不知道这 10 个球员每个人射门能力的准确值。这里每个球员射门能力的准确值就是选择这个球员射门的价值,即我们上面定义的 q ∗ ( a ) q_{*}(a) q(a),在这里 a a a 是选择某一个球员射门。为了估计每个球员真实的射门水平,我们就要不断去尝试用不同的球员去射门。我们用不同球员射门,从而估计不同球员射门水平的过程,就是 exploration。如果我们认为我们已经对这 10 个球员的射门水平有了较好的估计,那么每次射门我们都可以用目前射门能力估计值最高的那个球员,这就是 exploitation。

所以我们看到,为了准确估计所有球员的射门价值(即射门能力),我们需要做 exploration。但是一场比赛的时间是固定的,我们不能一直在做 exploration,我们需要在尽量少 exploration 的情况下,找出射门能力最强 (或者大概率最强) 的球员,进行射门得分。也就是说,exploitation 和 exploration 存在一种 trade-off 的关系。

Sample-average 方法

如何去估计一个动作的价值呢,我们根据 (1)知道,一个动作的价值是选取这个动作获得的回报的期望,从而我们用常见的取样本平均的方法去估计 q ∗ ( a ) q_{*}(a) q(a)。这就是课本中的公式 2.1,即

Q t ( a ) = sum of rewards when  a  taken prior to  t number of times  a  taken prior to  t = ∑ i = 1 t − 1 R i ⋅ I A i = a ∑ i = 1 t − 1 I A i = a \displaystyle Q_t(a) = \frac{\text{sum of rewards when \textit{a} taken prior to }t}{\text{number of times \textit{a} taken prior to }t} = \frac{\sum_{i = 1}^{t - 1}R_i \cdot \mathbb{I}_{A_i = a}}{\sum_{i = 1}^{t - 1} \mathbb{I}_{A_i = a}} Qt(a)=number of times a taken prior to tsum of rewards when a taken prior to t=i=1t1IAi=ai=1t1RiIAi=a

根据大数定理,如果我们选取 a a a 的次数足够多,我们计算得到的 Q t ( a ) Q_t(a) Qt(a) 就足够接近 q ∗ ( a ) q_{*}(a) q(a)。我们称这种求样本平均的方法为 sample-average 方法。

了解了 exploration,exploitation 和 sample-average 方法之后,我们来看 greedy 算法和 ϵ \epsilon ϵ-greedy 算法。

k k k-armed testbed greedy 算法

我们先来看 greedy 算法。顾名思义,greedy 算法是指在时间 t t t,我们总是选取目前为止价值估计最大的那个动作,即 A t = ˙ arg max ⁡ a Q t ( a ) \displaystyle A_t \dot{=} \argmax_{a} Q_{t}(a) At=˙aargmaxQt(a) (这里等于号上面加一点表示定义)。也就是在时间 t t t,我们选择 exploitation。值得注意的是,当前价值估计最大的这个动作,并不一定真的是 q ∗ ( a ) q_{*}(a) q(a) 最大的值。如果当前估值最大的这个动作不是使得 q ∗ ( a ) q_{*}(a) q(a) 最大的动作,那么我们就失去了更进一步寻找 A t = ˙ arg max ⁡ a Q t ( a ) \displaystyle A_t \dot{=} \argmax_{a} Q_{t}(a) At=˙aargmaxQt(a) 的机会。

ϵ \epsilon ϵ-greedy 算法

不同于 greedy 算法, ϵ \epsilon ϵ-greedy 算法是在大部分情况下进行 exploitation,也就是选择当前价值估计最大的那个动作,而在极少数情况下随机得选择 k k k 个动作中的一个(即 exploration)。这里 ϵ \epsilon ϵ 是一个很小的数,比如 0.1。在做选择之前,我们随机生成一个 [ 0 , 1 ] [0, 1] [0,1] 之间的随机数,如果这个数小于 ϵ \epsilon ϵ,我们就在 k k k 个动作中随机得选择一个;如果这个数大于 ϵ \epsilon ϵ,我们就进行 exploitation,选取目前价值估计最大的动作。

greedy 算法和 ϵ \epsilon ϵ-greedy 算法的模拟结果

在 Sutton 和 Barto 的教材中 [2] 的 2.3 这一节中,作者给出了分别用 greedy 算法和 ϵ \epsilon ϵ-greedy 算法 ( ϵ = 0.1 ,   ϵ = 0.01 \epsilon = 0.1, \, \epsilon = 0.01 ϵ=0.1,ϵ=0.01)的模拟结果。但是课本没有对模拟的细节做详细的解释,这里我们来介绍下具体的模拟是如何完成的。

这是一个 10-armed testbed 问题,我们面临 10 种不同的选择。每一个选择的价值 q ∗ ( a ) ,   a = 1 ,   2 ,   ⋯   , 10 q_{*}(a), \, a = 1, \, 2, \, \cdots, 10 q(a),a=1,2,,10 是从标准正态分布 N ( 0 ,   1 ) N(0, \, 1) N(0,1)生成的一个随机数。

在每一个时间 t t t,我们从 10 个选择中选出一个动作 A t A_t At,这个选择会给我们回报 R t R_t Rt R t R_t Rt 是从一个期望为 q ∗ ( a ) q_{*}(a) q(a),方差为 1 的正态分布中生成。在得到了 R t R_t Rt 之后,我们用 sample-average 方法更新对于动作 A t A_t At 的价值的估计。

这里课本中模拟的步数是 1000,即 t t t 从1 到 1000。而为了减少随机噪音,课本对问题做了 2000 次模拟,然后取 2000 次的平均值作为每一个时刻的值。对于课本中图 2.2 的两张图,上面一张的 y y y 轴表示的是在时间 t t t 所获的的平均回报 (即取 2000 次实验的平均值),下面一张的 y y y 轴表示的是在时间 t t t 所做出的选择,是最佳选择(即 q ∗ ( a ) q_{*}(a) q(a) 最大的那个动作)的比例。

于是我们可以模拟这个问题。

class bandit_simulation:
    
    def __init__(self, n_testbed: int, epsilon: float, n_iterations: int, n_plays: int, mean: float,
                std: float, initial_Q1: float, sample_average: bool, step_size: float):
        """
        n_testbed: value of k for the k-armed problem
        epsilon: the parameter for epsilon-greedy algorithm
        n_iterations: number of repeated experiments (or plays), for the example in the book,
                      its value is 2000
        n_plays: number of steps for per iteration, for the example in the book, its value is 1000
        mean, std: mean and standard deviation for the normal distribution that is used to generated the values
                   for q*(a), a = 1, 2, ..., n_testbed
        initial_Q1: initial estimate for the action value
        sample_average: if True, we use sample average method for estimating q*(a), otherwise we use the 
                        constant step-size parameter method, i.e., the equation of 2.5 in the book. 
        step_size: step_size only makes sense when sample_average is False, it is the alpha parameter
                   in equation 2.5. 
        """
        self.n_testbed = n_testbed
        self.epsilon = epsilon
        self.n_iterations = n_iterations
        self.n_plays = n_plays
        self.mean = mean
        self.std = std
        
        self.optimal_action = -1
        self.action_val = None
        self.sum_rewards = np.zeros((n_testbed, ))
        self.cnt_actions = np.zeros((n_testbed, ))
        self.action_val_esti = np.ones((n_testbed, )) * initial_Q1
        
        self.score_arr = np.zeros((n_plays, ))
        self.optimal_arr = np.zeros((n_plays, ))
        
        self.sample_average = sample_average
        self.step_size = step_size
        
        
    def generate_testbed(self):
        """
        Generate n_testbed number of normal random variables with mean self.mean and standard deviation of 
        self.std
        """
        self.action_val = np.random.normal(self.mean, self.std, self.n_testbed)
        self.optimal_action = np.argmax(self.action_val)
        return
    
    def simulate_one_run(self):
        """
        For one run, we run self.n_plays steps to simulate.
        Note that for the initial stage, that is when i == 0, we just pick one action randomly as the initial
        self.action_val_esti is all zero. 
        """
        self.generate_testbed()
        for i in range(self.n_plays):
            rnd = np.random.random()
            # get the greedy action
            max_action_val_idx = np.argmax(self.action_val_esti)
            # if there are multiple of max value for self.action_val_esti, possible_idxes has length 
            # larger than 1. 
            possible_idxes = np.where(self.action_val_esti == self.action_val_esti[max_action_val_idx])[0]
                
            if rnd < self.epsilon:
                cur_action = np.random.choice(self.n_testbed)
            else:
                if len(possible_idxes) > 1:
                    cur_action = np.random.choice(possible_idxes)
                else:
                    cur_action = max_action_val_idx
            cur_reward = np.random.normal(self.action_val[cur_action], 1)
            self.sum_rewards[cur_action] += cur_reward
            self.cnt_actions[cur_action] += 1
            if self.sample_average:
                self.action_val_esti[cur_action] = self.sum_rewards[cur_action] / self.cnt_actions[cur_action]
            else:
                self.action_val_esti[cur_action] += self.step_size * (cur_reward - 
                                                                      self.action_val_esti[cur_action])
            
            self.score_arr[i] += cur_reward
            if cur_action == self.optimal_action:
                self.optimal_arr[i] += 1
            
        return
    
    def reset(self):
        self.optimal_action = -1
        self.action_val = None
        self.sum_rewards = np.zeros((n_testbed, ))
        self.cnt_actions = np.zeros((n_testbed, ))
        self.action_val_esti = np.zeros((n_testbed, ))
    
    
    def simulate_total_iterations(self):
        """
        Run self.n_iterations. For each iteration, we call simulate_one_run once(), and then we call reset().
        """
        for i in range(self.n_iterations):
            if i % 200 == 0:
                print("=============" + 'iteration' + str(i) + "=============")
            self.generate_testbed()
            self.simulate_one_run()
            self.reset()
        
        return self.score_arr, self.optimal_arr
        
    
    
n_testbed = 10
epsilon = 0
n_iterations = 2000
n_plays = 1000
mean = 0
std = 1
initial_Q1 = 0 
sample_average = True
step_size = 0
a = bandit_simulation(n_testbed=n_testbed, epsilon=epsilon, n_iterations=n_iterations, n_plays=n_plays,
                     mean=mean, std=std, initial_Q1=initial_Q1, sample_average=sample_average, 
                      step_size=step_size)
res_greedy = a.simulate_total_iterations()
n_testbed = 10
epsilon = 0.1
n_iterations = 2000
n_plays = 1000
mean = 0
std = 1
initial_Q1 = 0 
sample_average = True
step_size = 0
a = bandit_simulation(n_testbed=n_testbed, epsilon=epsilon, n_iterations=n_iterations, n_plays=n_plays,
                     mean=mean, std=std, initial_Q1=initial_Q1, sample_average=sample_average, 
                      step_size=step_size)
res_0p1 = a.simulate_total_iterations()
n_testbed = 10
epsilon = 0.01
n_iterations = 2000
n_plays = 1000
mean = 0
std = 1
initial_Q1 = 0 
sample_average = True
step_size = 0
a = bandit_simulation(n_testbed=n_testbed, epsilon=epsilon, n_iterations=n_iterations, n_plays=n_plays,
                     mean=mean, std=std, initial_Q1=initial_Q1, sample_average=sample_average, 
                      step_size=step_size)
res_0p01 = a.simulate_total_iterations()
# plot average reward vs. steps
plt.figure(figsize=(10, 8))
plt.plot(range(a.n_plays), res_greedy[0] / a.n_iterations, 
         range(a.n_plays), res_0p1[0] / a.n_iterations,
        range(a.n_plays), res_0p01[0] / a.n_iterations, linewidth=2)
plt.xticks(fontsize=24)
plt.yticks(fontsize=24)
plt.xlabel('steps', fontsize=48)
plt.ylabel('average reward', fontsize=48)
plt.legend(['greedy', 'epsilon0.1', 'epsilon0,01'], fontsize=24, bbox_to_anchor=(0.95, 0.4))

# plot percentage of optimal actions vs. steps
plt.figure(figsize=(10, 8))
plt.plot(range(a.n_plays), res_greedy[1] / a.n_iterations, 
         range(a.n_plays), res_0p1[1] / a.n_iterations,
        range(a.n_plays), res_0p01[1] / a.n_iterations, linewidth=2)
plt.xticks(fontsize=24)
plt.yticks(fontsize=24)
plt.xlabel('steps', fontsize=48)
plt.ylabel('% optimal action', fontsize=48)
plt.legend(['greedy', 'epsilon0.1', 'epsilon0.01'], fontsize=24, bbox_to_anchor=(0.85, 0.35))

average reward vs. step

percentage of optimal action vs. step

可以看出, ϵ \epsilon ϵ-greedy 算法比 greedy 算法在平均回报和选择最佳动作的比例两方面都要好。

理想的初值情况(optimistic initial values)

我们在上面的例子中,greedy 算法和 ϵ \epsilon ϵ-greedy 算法所选择的初始对每个动作价值的估计都是 0,并且所用到的估计动作价值的方法是 sample-average 方法。如果我们用课本中提到的 constant step-size 方法,即

Q n + 1 = ˙   Q n + α [ R n − Q n ] \displaystyle Q_{n + 1} \dot{=} \, Q_n + \alpha [R_n - Q_n] Qn+1=˙Qn+α[RnQn] … (2)

我们可以选择一个较大的初始值 Q 1 Q_1 Q1,从而鼓励我们的 action-value 方法做 exploration。

如果我们选 Q 1 = 5 Q_1 = 5 Q1=5 α = 0.1 \alpha = 0.1 α=0.1,我们就得到了课本中的 Figure 2.3。

注意,代码中的 step_size 就是公式 (2) 中的 α \alpha α

n_testbed = 10
epsilon = 0
n_iterations = 2000
n_plays = 1000
mean = 0
std = 1
initial_Q1 = 5
sample_average = False
step_size = 0.1
a = bandit_simulation(n_testbed=n_testbed, epsilon=epsilon, n_iterations=n_iterations, n_plays=n_plays,
                     mean=mean, std=std, initial_Q1=initial_Q1, sample_average=sample_average, 
                      step_size=step_size)
res_greedy_init5 = a.simulate_total_iterations()
# plot percentage of optimal actions vs. steps
plt.figure(figsize=(10, 8))
plt.plot(range(a.n_plays), res_greedy_init5[1] / a.n_iterations, 
         range(a.n_plays), res_0p1[1] / a.n_iterations, linewidth=2)
plt.xticks(fontsize=24)
plt.yticks(fontsize=24)
plt.xlabel('steps', fontsize=48)
plt.ylabel('% optimal action', fontsize=48)
plt.legend(['greedy initial 5', 'epsilon 0.1'], fontsize=24, bbox_to_anchor=(0.85, 0.35))

optimistic greedy vs realistic

其他算法

除了上面介绍的 greedy 和 ϵ \epsilon ϵ-greedy 算法,解决 multi-bandit 问题还有 explore-first,successive elimination,UCB-based arm selection (upper confidence bound) 等。具体的算法细节可以参考 [1]。

参考文献

[1] Introduction to multi-armed bandits, Aleksandrs Slivkins, Foundations and Trends in Machine Learning: Vol. 12, No. 1-2, pp 1–286 (2019)
[2] Reinforcement learning, an introduction 2nd edition, Richard S Sutton, Andrew G Barto (2018)
[3] Github 相关资料

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值