1. 简介
强化学习关注智能体和环境交互过程中的学习,这是一种试错型学习(trial-and-error learning)范式。在正式学习强化学习之前,我们需要先了解多臂老虎机问题,它可以被看作简化版的强化学习问题。与强化学习不同,多臂老虎机不存在状态信息,只有动作和奖励,算是最简单的“和环境交互中的学习”的一种形式。多臂老虎机中的探索与利用(exploration vs. exploitation)问题一直以来都是一个特别经典的问题,理解它能够帮助我们学习强化学习。
2. 问题介绍
2.1 问题定义
在多臂老虎机(multi-armed bandit,MAB)问题(见图 2-1)中,有一个拥有 根拉杆的老虎机,拉动每一根拉杆都对应一个关于奖励的概率分布 。我们每次拉动其中一根拉杆,就可以从该拉杆对应的奖励概率分布中获得一个奖励 。我们在各根拉杆的奖励概率分布未知的情况下,从头开始尝试,目标是在操作 次拉杆后获得尽可能高的累积奖励。由于奖励的概率分布是未知的,因此我们需要在“探索拉杆的获奖概率”和“根据经验选择获奖最多的拉杆”中进行权衡。“采用怎样的操作策略才能使获得的累积奖励最高”便是多臂老虎机问题。如果是你,会怎么做呢?
2.2 形式化表述
多臂老虎机问题是概率论中一个经典问题,也属于强化学习的范畴。设想,一个赌徒面前有N个老虎机,事先他不知道每台老虎机的真实盈利情况,他如何根据每次玩老虎机的结果来选择下次拉哪台或者是否停止赌博,来最大化自己的从头到尾的收益。
关于多臂老虎机问题名字的来源,是因为老虎机[2]在以前是有一个操控杆,就像一只手臂(arm),而玩老虎机的结果往往是口袋被掏空,就像遇到了土匪(bandit)一样,而在多臂老虎机问题中,我们面对的是多个老虎机。
多臂老虎机问题可以表示为一个元组 ⟨ A , R ⟩ ,其中:
- A 为动作集合,其中一个动作表示拉动一个拉杆。若多臂老虎机一共有 K根拉杆,那动作空间就是集合 { a 1 , … , a K },我们用 a t ∈ A 表示任意一个动作;
- R 为奖励概率分布,拉动每一根拉杆的动作 a 都对应一个奖励概率分布 R ( r ∣ a ) ,不同拉杆的奖励分布通常是不同的。
假设每个时间步只能拉动一个拉杆,多臂老虎机的目标为最大化一段时间步 T TT 内累积的奖励: 。其中 a t 表示在第 t时间步拉动某一拉杆的动 作, r t 表示动作 a t 获得的奖励。
在商业中,多臂老虎机问题有着广泛的应用,包括广告展示,医学试验和金融等领域[3].比如在推荐系统中,我们有N个物品,事先不知道用户U对N个物品的反应,我们需要每次推荐给用户某个物品,来最大化用户的价值(或者说尽量使得用户U转化),比如用户的购买。
2.3 累积懊悔
对于每一个动作 a ,我们定义其期望奖励为 。于是,至少存在一根拉杆,它的期望奖励不小于拉动其他任意一根拉杆,我们将该最优期望奖励表 示为 Q ∗ = max a ∈ A Q ( a ) 。为了更加直观、方便地观察拉动一根拉杆的期望奖励离最优拉杆期玍奖励的差距,我们引入懊悔 (regret) 概念。懊悔定义为拉动当前拉 杆的动作 a aa 与最优拉杆的期望奖励差,即 R ( a ) = Q∗ − Q ( a )。累积懊悔 (cumulative regret) 即操作 T TT 次拉杆后累积的㥪悔总量,对于一次完整的 T 步决策 { a 1 , a 2 , … , a T } ,累积懊悔为 。MAB 问题的目标为最大化累积奖励,等价于最小化累积懊悔。
2.4 估计期望奖励
为了知道拉动哪一根拉杆能获得更高的奖励,我们需要估计拉动这根拉杆的期望奖励。由于只拉动一次拉杆获得的奖励存在随机性,所以需要多次拉动一根拉杆,然 后计算得到的多次奖励的期望,其算法流程如下所示。
- 对于 ∀ a ∈ A , 初始化计数器 N ( a ) = 0 和期望奖励估值
- for t = 1 → T do
- 选取某根拉杆,该动作记为 a t
- 得到奖励 r t
- 更新计数器: N ( a t ) = N ( a t ) + 1
- 更新期望奖励估值:
- end for
以上 for 循环中的第四步如此更新估值,是因为这样可以进行增量式的期望更新,公式如下。
如果将所有数求和再除以次数,其缺点是每次更新的时间复杂度和空间复杂度均为 O ( n ) 。而采用增量式更新,时间复杂度和空间复杂度均为 O ( 1 ) 。
下面我们编写代码来实现一个拉杆数为 10 的多臂老虎机。其中拉动每根拉杆的奖励服从伯努利分布 (Bernoulli distribution),即每次拉下拉杆有 p 的概率获得的奖励 为 1 . 有 1 − p的概率获得的奖励为 0 奖励为 1 代表获奖, 奖励为 0 代表没有获奖。
本文所有代码均在jupyter notebook运行
# 导入需要使用的库,其中numpy是支持数组和矩阵运算的科学计算库,而matplotlib是绘图库 import numpy as np import matplotlib.pyplot as plt class BernoulliBandit: """ 伯努利多臂老虎机,输入K表示拉杆个数 """ def __init__(self, K): self.probs = np.random.uniform(size=K) # 随机生成K个0~1的数,作为拉动每根拉杆的获奖 # 概率 self.best_idx = np.argmax(self.probs) # 获奖概率最大的拉杆 self.best_prob = self.probs[self.best_idx] # 最大的获奖概率 self.K = K def step(self, k): # 当玩家选择了k号拉杆后,根据拉动该老虎机的k号拉杆获得奖励的概率返回1(获奖)或0(未 # 获奖) if np.random.rand() < self.probs[k]: return 1 else: return 0 np.random.seed(1) # 设定随机种子,使实验具有可重复性 K = 10 bandit_10_arm = BernoulliBandit(K) print("随机生成了一个%d臂伯努利老虎机" % K) print("获奖概率最大的拉杆为%d号,其获奖概率为%.4f" % (bandit_10_arm.best_idx, bandit_10_arm.best_prob)) # 随机生成了一个10臂伯努利老虎机 # 获奖概率最大的拉杆为1号,其获奖概率为0.7203
随机生成了一个10臂伯努利老虎机 获奖概率最大的拉杆为1号,其获奖概率为0.7203
接下来我们用一个 Solver 基础类来实现上述的多臂老虎机的求解方案。根据前文的算法流程,我们需要实现下列函数功能:根据策略选择动作、根据动作获取奖励、更新期望奖励估值、更新累积懊悔和计数。在下面的 MAB 算法基本框架中,我们将根据策略选择动作、根据动作获取奖励和更新期望奖励估值放在 run_one_step() 函数中,由每个继承 Solver 类的策略具体实现。而更新累积懊悔和计数则直接放在主循环 run() 中。
class Solver: """ 多臂老虎机算法基本框架 """ def __init__(self, bandit): self.bandit = bandit self.counts = np.zeros(self.bandit.K) # 每根拉杆的尝试次数 self.regret = 0. # 当前步的累积懊悔 self.actions = [] # 维护一个列表,记录每一步的动作 self.regrets = [] # 维护一个列表,记录每一步的累积懊悔 def update_regret(self, k): # 计算累积懊悔并保存,k为本次动作选择的拉杆的编号 self.regret += self.bandit.best_prob - self.bandit.probs[k] self.regrets.append(self.regret) def run_one_step(self): # 返回当前动作选择哪一根拉杆,由每个具体的策略实现 raise NotImplementedError def run(self, num_steps): # 运行一定次数,num_steps为总运行次数 for _ in range(num_steps): k = self.run_one_step() self.counts[k] += 1 self.actions.append(k) self.update_regret(k)
3 探索与利用的平衡
在上面的算法框架中,还没有一个策略告诉我们应该采取哪个动作,即拉动哪根拉杆,所以接下来我们将学习如何设计一个策略。例如,一个最简单的策略就是一直采取第一个动作,但这就非常依赖运气的好坏。如果运气绝佳,可能拉动的刚好是能获得最大期望奖励的拉杆,即最优拉杆;但如果运气很糟糕,获得的就有可能是最小的期望奖励。在多臂老虎机问题中,一个经典的问题就是探索与利用的平衡问题。探索(exploration)是指尝试拉动更多可能的拉杆,这根拉杆不一定会获得最大的奖励,但这种方案能够摸清楚所有拉杆的获奖情况。例如,对于一个 10 臂老虎机,我们要把所有的拉杆都拉动一下才知道哪根拉杆可能获得最大的奖励。利用(exploitation)是指拉动已知期望奖励最大的那根拉杆,由于已知的信息仅仅来自有限次的交互观测,所以当前的最优拉杆不一定是全局最优的。例如,对于一个 10 臂老虎机,我们只拉动过其中 3 根拉杆,接下来就一直拉动这 3 根拉杆中期望奖励最大的那根拉杆,但很有可能期望奖励最大的拉杆在剩下的 7 根当中,即使我们对 10 根拉杆各自都尝试了 20 次,发现 5 号拉杆的经验期望奖励是最高的,但仍然存在着微小的概率—另一根 6 号拉杆的真实期望奖励是比 5 号拉杆更高的。
于是在多臂老虎机问题中,设计策略时就需要平衡探索和利用的次数,使得累积奖励最大化。一个比较常用的思路是在开始时做比较多的探索,在对每根拉杆都有比较准确的估计后,再进行利用。目前已有一些比较经典的算法来解决这个问题,例如ϵ-贪婪算法、上置信界算法和汤普森采样算法等,我们接下来将分别介绍这几种算法。
3.1 ϵ-贪婪算法
完全贪婪算法即在每一时刻采取期望奖励估值最大的动作(拉动拉杆),这就是纯粹的利用,而没有探索,所以我们通常需要对完全贪婪算法进行一些修改,其中比较经典的一种方法为ϵ -贪婪( ϵ-Greedy)算法。 ϵ-贪婪算法在完全贪婪算法的基础上添加了噪声,每次以概率 选择以往经验中期望奖励估值最大的那根拉杆(利用),以概率 随机选择一根拉杆(探索),公式如下:
\begin{align*}at = \begin{cases}\arg\max{a\in\mathcal{A}} \hat{Q}(a), &\text{采样概率: 1-ϵ}\ \text{从 A \mathcal{A}A中随机选择}, &\text{采样概率: }\end{cases}\end{align*}
随着探索次数的不断增加,我们对各个动作的奖励估计得越来越准,此时我们就没必要继续花大力气进行探索。所以在 -贪婪算法的具体实现中,我们可以令 随时间衰减,即探索的概率将会不断降低。但是请注意, ϵ不会在有限的步数内衰减至 0,因为基于有限步数观测的完全贪婪算法仍然是一个局部信息的贪婪算法,永远距离最优解有一个固定的差距。
我们接下来编写代码来实现一个 ϵ-贪婪算法,并用它去解决 2.4节生成的 10 臂老虎机的问题。设置ϵ=0.01,以及T=5000。
class EpsilonGreedy(Solver): """ epsilon贪婪算法,继承Solver类 """ def __init__(self, bandit, epsilon=0.01, init_prob=1.0): super(EpsilonGreedy, self).__init__(bandit) self.epsilon = epsilon #初始化拉动所有拉杆的期望奖励估值 self.estimates = np.array([init_prob] * self.bandit.K) def run_one_step(self): if np.random.random() < self.epsilon: k = np.random.randint(0, self.bandit.K) # 随机选择一根拉杆 else: k = np.argmax(self.estimates) # 选择期望奖励估值最大的拉杆 r = self.bandit.step(k) # 得到本次动作的奖励 self.estimates[k] += 1. / (self.counts[k] + 1) * (r - self.estimates[k]) return k
为了更加直观地展示,可以把每一时间步的累积函数绘制出来。于是我们定义了以下绘图函数,方便之后调用。
def plot_results(solvers, solver_names): """生成累积懊悔随时间变化的图像。输入solvers是一个列表,列表中的每个元素是一种特定的策略。 而solver_names也是一个列表,存储每个策略的名称""" for idx, solver in enumerate(solvers): time_list = range(len(solver.regrets)) plt.plot(time_list, solver.regrets, label=solver_names[idx]) plt.xlabel('Time steps') plt.ylabel('Cumulative regrets') plt.title('%d-armed bandit' % solvers[0].bandit.K) plt.legend() plt.show() np.random.seed(1) epsilon_greedy_solver = EpsilonGreedy(bandit_10_arm, epsilon=0.01) epsilon_greedy_solver.run(5000) print('epsilon-贪婪算法的累积懊悔为:', epsilon_greedy_solver.regret) plot_results([epsilon_greedy_solver], ["EpsilonGreedy"]) # epsilon-贪婪算法的累积懊悔为:25.526630933945313
通过上面的实验可以发现,在经历了开始的一小段时间后,ϵ-贪婪算法的累积懊悔几乎是线性增长的。这是 ϵ=0.01时的结果,因为一旦做出了随机拉杆的探索,那么产生的懊悔值是固定的。其他不同的ϵ取值又会带来怎样的变化呢?我们继续使用该 10 臂老虎机,我们尝试不同的参数 { 0.0001 , 0.01 , 0.1 , 0.25 , 0.5 } ,查看相应的实验结果(另见彩插图 1)。
np.random.seed(0) epsilons = [1e-4, 0.01, 0.1, 0.25, 0.5] epsilon_greedy_solver_list = [ EpsilonGreedy(bandit_10_arm, epsilon=e) for e in epsilons ] epsilon_greedy_solver_names = ["epsilon={}".format(e) for e in epsilons] for solver in epsilon_greedy_solver_list: solver.run(5000) plot_results(epsilon_greedy_solver_list, epsilon_greedy_solver_names)
通过实验结果可以发现,基本上无论 ϵ \epsilonϵ 取值多少,累积懊悔都是线性增长的。在这个例子中,随着 ϵ \epsilonϵ 的增大,累积懊悔增长的速率也会增大。接下来我们尝试 ϵ \epsilonϵ 值随 时间衰减的 ϵ \epsilonϵ-贪婪算法,采取的具体衰减形式为反比例衰减,公式为 。
class DecayingEpsilonGreedy(Solver): """ epsilon值随时间衰减的epsilon-贪婪算法,继承Solver类 """ def __init__(self, bandit, init_prob=1.0): super(DecayingEpsilonGreedy, self).__init__(bandit) self.estimates = np.array([init_prob] * self.bandit.K) self.total_count = 0 def run_one_step(self): self.total_count += 1 if np.random.random() < 1 / self.total_count: # epsilon值随时间衰减 k = np.random.randint(0, self.bandit.K) else: k = np.argmax(self.estimates) r = self.bandit.step(k) self.estimates[k] += 1. / (self.counts[k] + 1) * (r - self.estimates[k]) return k np.random.seed(1) decaying_epsilon_greedy_solver = DecayingEpsilonGreedy(bandit_10_arm) decaying_epsilon_greedy_solver.run(5000) print('epsilon值衰减的贪婪算法的累积懊悔为:', decaying_epsilon_greedy_solver.regret) plot_results([decaying_epsilon_greedy_solver], ["DecayingEpsilonGreedy"]) # epsilon值衰减的贪婪算法的累积懊悔为:10.114334931260183
从实验结果图中可以发现,随时间做反比例衰减的 ϵ \epsilonϵ -贪婪算法能够使累积懊悔与时间步的关系变成次线性(sublinear)的,这明显优于固定 值的 ϵ \epsilonϵ -贪婪算法。
3.2 上置信界算法
设想这样一种情况: 对于一台双臂老虎机,其中第一根拉杆只被拉动过一次,得到的奖励为 0 ;第二根拉杆被拉动过很多次,我们对它的奖励分布已经有了大致的把 握。这时你会怎么做? 或许你会进一步尝试拉动第一根拉杆,从而更加确定其奖励分布。这种思路主要是基于不确定性,因为此时第一根拉杆只被拉动过一次,它的 不确定性很高。一根拉杆的不确定性越大,它就越具有探索的价值,因为探索之后我们可能发现它的期望奖励很大。我们在此引入不确定性度量 U ( a ) U(a)U(a) ,它会随着一个 动作被尝试次数的增加而减小。我们可以使用一种基于不确定性的策略来综合考虑现有的期望奖励估值和不确定性,其核心问题是如何估计不确定性。
上置信界 (upper confidence bound,UCB) 算法是一种经典的基于不确定性的策略算法,它的思想用到了一个非常著名的数学原理: 霍夫丁不等式 (Hoeffding’s inequality)。在霍夫丁不等式中,令 X 1 , … , X n 为 n 个独立同分布的随机变量,取值范围为 [ 0 , 1 ] ,其经验期望为 ,则有
现在我们将霍夫丁不等式运用于多臂老虎机问题中。将 代入 ,不等式中的参数 u =代表不确定性度量。给定一个概率 ,根据上述 不等式, Q t ( a ) <至少以概率 1 − p 成立。当 p 很小时, Q t ( a ) < 就以很大概率成立, 便是期望奖励上界。此时,上 置信界算法便选取期望奖励上界最大的动作,即 。那其中 具体是什么呢? 根据等式 ,解之即得。
因此,设定一个概率 p 后,就可以计算相应的不确定性度量 了。更直观地说,UCB 算法在每次选择拉杆前,先估计每根拉杆的期望奖励的上界,使得拉动每根拉 杆的期望奖励只有一个较小的概率 p超过这个上界,接着选出期望奖励上界最大的拉杆,从而选择最有可能获得最大期望奖励的拉杆。
我们编写代码来实现 UCB 算法,并且仍然使用 2.2.4 节定义的 10 臂老虎机来观察实验结果。在具体的实现过程中,设置 p = ,并且在分母中为拉动每根拉杆的次 数加上常数 1 ,以免出现分母为 0 的情形,即此时 。同时,我们设定一个系数 c cc 来控制不确定性的比重,此时 。
class UCB(Solver): """ UCB算法,继承Solver类 """ def __init__(self, bandit, coef, init_prob=1.0): super(UCB, self).__init__(bandit) self.total_count = 0 self.estimates = np.array([init_prob] * self.bandit.K) self.coef = coef def run_one_step(self): self.total_count += 1 ucb = self.estimates + self.coef * np.sqrt( np.log(self.total_count) / (2 * (self.counts + 1))) # 计算上置信界 k = np.argmax(ucb) # 选出上置信界最大的拉杆 r = self.bandit.step(k) self.estimates[k] += 1. / (self.counts[k] + 1) * (r - self.estimates[k]) return k np.random.seed(1) coef = 1 # 控制不确定性比重的系数 UCB_solver = UCB(bandit_10_arm, coef) UCB_solver.run(5000) print('上置信界算法的累积懊悔为:', UCB_solver.regret) plot_results([UCB_solver], ["UCB"]) # 上置信界算法的累积懊悔为: 70.45281214197854
3.3 汤普森采样算法
MAB 中还有一种经典算法——汤普森采样(Thompson sampling),先假设拉动每根拉杆的奖励服从一个特定的概率分布,然后根据拉动每根拉杆的期望奖励来进行选择。但是由于计算所有拉杆的期望奖励的代价比较高,汤普森采样算法使用采样的方式,即根据当前每个动作a的奖励概率分布进行一轮采样,得到一组各根拉杆的奖励样本,再选择样本中奖励最大的动作。可以看出,汤普森采样是一种计算所有拉杆的最高奖励概率的蒙特卡洛采样方法。
了解了汤普森采样算法的基本思路后,我们需要解决另一个问题:怎样得到当前每个动作a的奖励概率分布并且在过程中进行更新?在实际情况中,我们通常用 Beta 分布对当前每个动作的奖励概率分布进行建模。具体来说,若某拉杆被选择了k次,其中次奖励m1为 1,m2次奖励为 0,则该拉杆的奖励服从参数为的 (m1+1,m2+1)Beta 分布。
编写代码来实现汤普森采样算法,并且仍然使用 2.4 节定义的 10 臂老虎机来观察实验结果。
class ThompsonSampling(Solver): """ 汤普森采样算法,继承Solver类 """ def __init__(self, bandit): super(ThompsonSampling, self).__init__(bandit) self._a = np.ones(self.bandit.K) # 列表,表示每根拉杆奖励为1的次数 self._b = np.ones(self.bandit.K) # 列表,表示每根拉杆奖励为0的次数 def run_one_step(self): samples = np.random.beta(self._a, self._b) # 按照Beta分布采样一组奖励样本 k = np.argmax(samples) # 选出采样奖励最大的拉杆 r = self.bandit.step(k) self._a[k] += r # 更新Beta分布的第一个参数 self._b[k] += (1 - r) # 更新Beta分布的第二个参数 return k np.random.seed(1) thompson_sampling_solver = ThompsonSampling(bandit_10_arm) thompson_sampling_solver.run(5000) print('汤普森采样算法的累积懊悔为:', thompson_sampling_solver.regret) plot_results([thompson_sampling_solver], ["ThompsonSampling"]) # 汤普森采样算法的累积懊悔为:57.19161964443925
通过实验我们可以得到以下结论:ϵ \epsilonϵ -贪婪算法的累积懊悔是随时间线性增长的,而另外 3 种算法( -衰减贪婪算法、上置信界算法、汤普森采样算法)的累积懊悔都是随时间次线性增长的(具体为对数形式增长)。
4. 总结
探索与利用是与环境做交互学习的重要问题,是强化学习试错法中的必备技术,而多臂老虎机问题是研究探索与利用技术理论的最佳环境。了解多臂老虎机的探索与利用问题,对接下来我们学习强化学习环境探索有很重要的帮助。对于多臂老虎机各种算法的累积懊悔理论分析,有兴趣的同学可以自行查阅相关资料。 -贪婪算法、上置信界算法和汤普森采样算法在多臂老虎机问题中十分常用,其中上置信界算法和汤普森采样方法均能保证对数的渐进最优累积懊悔。
多臂老虎机问题与强化学习的一大区别在于其与环境的交互并不会改变环境,即多臂老虎机的每次交互的结果和以往的动作无关,所以可看作无状态的强化学习(stateless reinforcement learning)。
相关资源来自:伯禹学习平台-动手学强化学习