2.1简介
多臂老虎机问题可以被看作简化版的强化学习问题。但是其只有动作和奖励没有状态信息,算是简化版的强化学习问题。
2.2问题介绍
2.2.1问题定义
在多臂老虎机(MAB)问题中,有一个有K根拉杆的老虎机,拉动每一根拉杆都对应一个关于奖励的概率分布
R
R
R。我们每次拉动其中一根拉杆,就可以从该拉杆对应的奖励概率分布中获得一个奖励
r
r
r。
我们在各个拉杆的奖励概率分布未知的情况下,从头尝试,目标是操作T次拉杆后获得尽可能高的累积奖励。
由于奖励的概率分布是未知的,所以我们需要在探索拉杆的获奖概率和根据经验选择获奖最多的拉杆中进行权衡。采用怎样的操作策略才能使获得的累积奖励最高便是多臂老虎机问题。
2.2.2形式化描述
多臂老虎机问题可以表示为一个元组 < A , R > <A,R> <A,R>,其中:
- A为动作集合,其中一个动作表示拉动一个拉杆。若多臂老虎机一共有K根拉杆,那动作空间就是集合,我们用 a t ∈ A a_t\in A at∈A表示任意一个动作
- R为奖励概率分布,拉动每一根拉杆的动作a都对应一个奖励概率分布R(r|a),不同拉杆的奖励分布通常是不同的。
假设每个时间步只能拉动一个拉杆,多臂老虎机的目标为最大化一段时间步T内累积的奖励: m a x ∑ t = 1 T r t , r t ∼ R ( ⋅ ∣ a t ) max \sum\limits_{t=1}^Tr_t,r_{t} \sim R(\cdot|a_t) maxt=1∑Trt,rt∼R(⋅∣at),其中 a t a_t at表示在第t时间步拉动某一拉杆的动作, r t r_t rt表示动作 a t a_t at获得的奖励。
在 r t ∼ R ( ⋅ ∣ a t ) r_t ∼ R(·|a_t) rt∼R(⋅∣at) 中,符号 ⋅ · ⋅ 表示一个占位符,通常用来表示条件概率的输入或条件。在这个上下文中,它表示奖励 r t r_t rt 是从奖励分布 R 中根据条件 a t a_t at 抽取的。也就是说,它指代了在给定动作 a t a_t at 的条件下,奖励 r t r_t rt 的分布。
这种表示方法用于表达随机性和条件性概率分布,它告诉我们奖励 r t r_t rt 是依赖于代理选择的动作 a t a_t at 而发生的,不同的动作可能导致不同的奖励分布。这对于解释多臂老虎机问题中的随机性和条件性关系非常有用。
2.2.3累积懊悔
对于每一个动作a,我们定义其期望奖励为
Q
(
a
)
=
E
r
∼
R
(
⋅
∣
a
)
[
r
]
Q(a)=\mathbb{E}_{r \sim R(\cdot|a)}[r]
Q(a)=Er∼R(⋅∣a)[r],于是,至少存在一根拉杆,它的期望奖励不小于拉动其他任意一根拉杆,我们将该最优期望奖励表示为
Q
∗
=
m
a
x
a
∈
A
Q
(
a
)
Q^*=max_{a\in A}Q(a)
Q∗=maxa∈AQ(a)。为了更加直观、方便地观察拉动一根拉杆的期望奖励离最优拉杆期望奖励的差距,我们引入懊悔(regret)概念。
懊悔定义为拉动当前拉杆的动作a与最优拉杆的期望奖励差,即
R
(
a
)
=
Q
∗
−
Q
(
a
)
R(a)=Q^*-Q(a)
R(a)=Q∗−Q(a)。
累积懊悔(cumulative regret)即操作T次拉杆后累积的懊悔总量,对于一次完整的T步决策
{
a
1
,
a
2
,
.
.
.
,
a
T
}
\{a_1,a_2,...,a_T\}
{a1,a2,...,aT},累积懊悔为
σ
R
=
∑
t
=
1
T
R
(
a
t
)
\sigma_R=\sum\limits_{t=1}^TR(a_t)
σR=t=1∑TR(at),MAB问题的目标为最大化累积奖励,等价于最小化累积懊悔。
符号 E \mathbb{E} E 表示数学期望(Expectation),而不带修饰的 “E” 通常用于表示一般的期望值。它们之间的区别在于:
- E \mathbb{E} E:这是一种数学符号,通常用于表示数学期望操作。在LaTeX等数学标记系统中, E \mathbb{E} E通常用于表示数学期望,表示对随机变量的期望值。数学期望是一个用于描述随机变量平均值的概念。通常,数学期望表示为:
E [ X ] \mathbb{E}[X] E[X]
其中,X 是随机变量, E [ X ] \mathbb{E}[X] E[X] 表示随机变量 X 的期望值。- E:这是字母 “E” 的一般表示,可能用于表示其他数学或物理概念中的变量或符号,不一定表示数学期望。如果没有明确的上下文或标记,它可能表示其他概念,而不是期望操作。
所以, E \mathbb{E} E 是专门用于表示数学期望的符号,而 “E” 可能用于其他用途。当你看到 E [ X ] \mathbb{E}[X] E[X],它明确表示对随机变量 X 的数学期望,而 “E” 会根据上下文的不同而有不同的含义。
Q ( a ) = E r ∼ R ( ⋅ ∣ a ) [ r ] Q(a)=\mathbb{E}_{r \sim R(\cdot|a)}[r] Q(a)=Er∼R(⋅∣a)[r]这个方程表示了动作值函数 Q(a) 的定义,其中 Q(a) 表示对动作 a 的期望奖励值。让我来解释它:
- Q ( a ) Q(a) Q(a):这是动作值函数,表示选择动作 a 后的期望奖励值。动作值函数告诉代理在选择特定动作 a 时,可以预期获得多少奖励。
- E r ∼ R ( ⋅ ∣ a ) [ r ] \mathbb{E}_{r \sim R(\cdot|a)}[r] Er∼R(⋅∣a)[r]:这是期望操作,表示对随机变量 r 的期望,其中 r 来自奖励分布 R(·|a)。这个期望操作告诉我们,在给定动作 a 的情况下,随机抽取的奖励 r 的期望值。
具体来说, Q ( a ) Q(a) Q(a) 是在选择动作 a 后,从奖励分布 R(·|a) 中随机抽取奖励 r 并计算其期望值的结果。这是一种在强化学习中用于估计动作的价值的常见方法。代理使用动作值函数来指导其决策,选择具有最高动作值的动作,以最大化累积奖励。
2.2.4估计期望奖励
为了知道拉动哪一根拉杆能获得更高的奖励,我们需要估计拉动这跟拉杆的期望奖励。由于只拉动一次拉杆获得的奖励存在随机性,所以需要多次拉动一根拉杆,然后计算得到的多次奖励的期望,其算法流程如下所示。
- 对与 ∀ a ∈ A \forall a \in A ∀a∈A,初始化计数器 N ( a ) = 0 N(a)=0 N(a)=0和期望奖励估值 Q ^ ( a ) = 0 \hat Q(a)=0 Q^(a)=0
- for
t
=
1
→
T
t=1 →T
t=1→Tdo
- 选取某根拉杆,该动作记为 a t a_t at
- 得到奖励 r t r_t rt
- 更新计数器: N ( a t ) = N ( a t ) + 1 N(a_t)=N(a_t)+1 N(at)=N(at)+1
- 更新期望奖励估值: Q ^ ( a t ) = Q ^ ( a t ) + 1 N ( a t ) [ r t − Q ^ ( a t ) ] \hat Q(a_t)=\hat Q(a_t)+\frac{1}{N(a_t)}[r_t-\hat Q(a_t)] Q^(at)=Q^(at)+N(at)1[rt−Q^(at)]
- end for
以上for循环中的第四步如此更新估值,是因为这样可以进行增量式的期望更新,公式如下。
Q k = 1 k ∑ i = 1 k r i = Q_k=\frac{1}{k}\sum\limits_{i=1}^k r_i= Qk=k1i=1∑kri=
如果将所有数求和再除以次数,其缺点是每次更新的时间复杂度和空间复杂度均为 O ( n ) O(n) O(n)。而采用增量式更新,时间复杂度和空间复杂度均为 O ( 1 ) O(1) O(1)
下面我们编写代码来实现一个拉杆数为 10 的多臂老虎机。其中拉动每根拉杆的奖励服从伯努利分布(Bernoulli distribution),即每次拉下拉杆有p的概率获得的奖励为 1,有1-p的概率获得的奖励为 0。奖励为 1 代表获奖,奖励为 0 代表没有获奖。
# 导入需要使用的库,其中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
接下来我们用一个 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)
这段代码是一个多臂老虎机问题(Multi-Armed Bandit Problem)的算法框架,用于解决类似赌博机的问题,其中每个拉杆(arm)代表一个可选择的动作,每个动作都有一个不同的奖励概率。解释如下:
Solver
类是一个抽象基类,代表多臂老虎机算法的通用框架。它包含以下成员变量和方法:
__init__(self, bandit)
: 初始化函数,接受一个bandit
参数,表示多臂老虎机的实例。counts
是一个数组,用于记录每根拉杆的尝试次数,regret
用于记录当前步的累积懊悔,actions
和regrets
是用于记录每一步的动作和累积懊悔的列表。update_regret(self, k)
: 该方法用于计算和更新累积懊悔。k
是本次动作选择的拉杆的编号。累积懊悔是当前选择策略和最佳策略之间的奖励差距的累积总和。run_one_step(self)
: 这是一个抽象方法,表示在每一步中如何选择动作,具体的策略需要在派生类中实现。它应该返回一个整数,代表选择的拉杆编号。run(self, num_steps)
: 该方法用于运行多臂老虎机算法一定次数,num_steps
表示总的运行次数。在每一步中,它会调用run_one_step
方法选择动作,然后更新counts
、actions
和regrets
,以及调用update_regret
方法更新累积懊悔。update_regret
方法用于计算累积懊悔,懊悔是用于度量策略选择是否明智的指标。累积懊悔是当前选择的策略的奖励期望与最佳策略的奖励期望之间的差值的累积总和。通过追踪累积懊悔,算法可以评估其性能。run_one_step
方法是一个抽象方法,代表每一步如何选择动作,这个方法需要在具体的多臂老虎机算法中实现。不同的算法可能采用不同的策略来选择动作,例如 ε-greedy、UCB(Upper Confidence Bound)等。run
方法是用于运行算法的主要循环,它运行指定的次数num_steps
,在每一步中选择动作并记录结果。这个算法框架的主要目的是帮助实现不同的多臂老虎机算法,只需要继承
Solver
类并实现run_one_step
方法,然后可以利用这个框架来运行和评估不同的策略。
在Python中,通常用下划线
_
作为一个临时占位符,表示一个不需要使用的变量。在循环中,for _ in range(num_steps)
表示在循环执行的过程中,我们不关心迭代的当前值,也就是说,我们不会在循环体内使用这个值。
这种情况通常出现在需要循环一定次数,但不需要使用循环变量的情况下。使用下划线_
作为循环变量的名字是一种通用的惯例,用于表示这个变量是一个占位符,不会在后续的代码中使用。这有助于提高代码的可读性,因为它明确表明了变量不会在后续的代码中被使用。
raise NotImplementedError
是Python中的一种异常抛出语句,通常用于表示某个方法或功能尚未在当前上下文中实现。它用于抽象基类(Abstract Base Classes)或接口的设计,以确保子类必须实现特定的方法或功能。
具体来说,NotImplementedError
异常通常在如下情况使用:
- 定义抽象基类或接口:你可以创建一个抽象基类或接口,其中包含一些抽象方法,这些方法在基类中没有具体实现,但在子类中必须实现。
- 强制子类实现:通过在抽象方法内部使用
raise NotImplementedError
,你告诉Python解释器,这个方法应该由子类具体实现,而不是在基类中。这样可以强制子类提供必要的实现,以确保代码的一致性和可靠性。
举例来说,如果你有一个基类定义如下:
class MyABC:
def my_method(self):
raise NotImplementedError("子类必须实现my_method方法")
然后,如果你创建一个子类,但没有实现
my_method
,并尝试调用它:
class MySubclass(MyABC):
pass
obj = MySubclass()
obj.my_method()
这将引发
NotImplementedError
异常,因为你在子类中没有提供必要的实现。这种机制有助于确保子类满足基类定义的接口,并提高代码的可维护性。
2.3 探索与利用的平衡
在 2.2 节的算法框架中,还没有一个策略告诉我们应该采取哪个动作,即拉动哪根拉杆,所以接下来我们将学习如何设计一个策略。
例如,一个最简单的策略就是一直采取第一个动作,但这就非常依赖运气的好坏。如果运气绝佳,可能拉动的刚好是能获得最大期望奖励的拉杆,即最优拉杆;但如果运气很糟糕,获得的就有可能是最小的期望奖励。
在多臂老虎机问题中,一个经典的问题就是探索与利用的平衡问题。探索(exploration)是指尝试拉动更多可能的拉杆,这根拉杆不一定会获得最大的奖励,但这种方案能够摸清楚所有拉杆的获奖情况。
例如,对于一个 10 臂老虎机,我们要把所有的拉杆都拉动一下才知道哪根拉杆可能获得最大的奖励。利用(exploitation)是指拉动已知期望奖励最大的那根拉杆,由于已知的信息仅仅来自有限次的交互观测,所以当前的最优拉杆不一定是全局最优的。
例如,对于一个 10 臂老虎机,我们只拉动过其中 3 根拉杆,接下来就一直拉动这 3 根拉杆中期望奖励最大的那根拉杆,但很有可能期望奖励最大的拉杆在剩下的 7 根当中,即使我们对 10 根拉杆各自都尝试了 20 次,发现 5 号拉杆的经验期望奖励是最高的,但仍然存在着微小的概率—另一根 6 号拉杆的真实期望奖励是比 5 号拉杆更高的。
于是在多臂老虎机问题中,设计策略时就需要平衡探索和利用的次数,使得累积奖励最大化。一个比较常用的思路是在开始时做比较多的探索,在对每根拉杆都有比较准确的估计后,再进行利用。目前已有一些比较经典的算法来解决这个问题,例如ϵ-贪婪算法、上置信界算法和汤普森采样算法等,我们接下来将分别介绍这几种算法。
2.4ϵ-贪心算法
完全贪婪算法即在每一时刻采取期望奖励估值最大的动作(拉动拉杆),这就是纯粹的利用,而没有探索,所以我们通常需要对完全贪婪算法进行一些修改,其中比较经典的一种方法为ϵ -贪婪( ϵ-Greedy)算法。 ϵ-贪婪算法在完全贪婪算法的基础上添加了噪声,每次以概率 1-ϵ选择以往经验中期望奖励估值最大的那根拉杆(利用),以概率 ϵ 随机选择一根拉杆(探索),公式如下:
随着探索次数的不断增加,我们对各个动作的奖励估计得越来越准,此时我们就没必要继续花大力气进行探索。所以在 ϵ-贪婪算法的具体实现中,我们可以令ϵ 随时间衰减,即探索的概率将会不断降低。但是请注意, ϵ不会在有限的步数内衰减至 0,因为基于有限步数观测的完全贪婪算法仍然是一个局部信息的贪婪算法,永远距离最优解有一个固定的差距。
我们接下来编写代码来实现一个 ϵ-贪婪算法,并用它去解决 2.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
这段代码定义了一个类
EpsilonGreedy
,它继承了基类Solver
,并实现了 epsilon-greedy 多臂老虎机算法。以下是代码的解释:
EpsilonGreedy
类继承自Solver
类,这意味着它使用了Solver
类的框架来解决多臂老虎机问题,但实现了 epsilon-greedy 策略以在每一步中选择动作。__init__(self, bandit, epsilon=0.01, init_prob=1.0)
方法是EpsilonGreedy
类的初始化方法。它接受以下参数:
bandit
: 表示多臂老虎机的实例,用于获取每个拉杆的奖励概率等信息。epsilon
: epsilon 参数,是 epsilon-greedy 算法中控制探索和利用之间权衡的参数,默认为 0.01。init_prob
: 初始化拉杆的期望奖励估值,默认为 1.0。run_one_step(self)
方法是EpsilonGreedy
类的核心方法,用于在每一步中选择一个动作(拉杆)。
- 首先,它生成一个随机数
np.random.random()
,如果这个随机数小于epsilon
,则以概率epsilon
随机选择一根拉杆k
。这代表了算法的探索部分,以一定的概率尝试未知的拉杆,以便更好地了解各个拉杆的奖励情况。- 如果随机数大于等于
epsilon
,则选择当前期望奖励估值最大的拉杆k
,即k = np.argmax(self.estimates)
。这代表了算法的利用部分,选择已知奖励估值最高的拉杆。- 然后,算法执行选择的动作
k
,获取奖励r
,并更新该动作的期望奖励估值。期望奖励估值的更新使用了增量式的均值更新方法,以更好地估计拉杆的实际奖励。- 最后,方法返回选择的拉杆
k
,表示本次动作选择的结果。这个算法通过在探索和利用之间进行平衡,可以有效地解决多臂老虎机问题,帮助选择最优的拉杆以最大化累积奖励。这是一个经典的强化学习算法用于解决探索与利用的权衡问题。
2.4代码部分就看不太懂了,感觉怎么有点困难,先看下一章吧,有空会回来补的。