从程序设计、tqdm到lambda:python的“奇技淫巧”,让实现效率翻倍【科学计算类】

前言: 最近在学强化学习,从Sutton的《强化学习(第二版)》开始学起。在GitHub上找到了很不错的资源:Sutton的学生,现在在牛津读强化学习PhD的Shangtong Zhang复现的书中的实例的代码https://github.com/ShangtongZhang/reinforcement-learning-an-introduction

在得到这份代码前,我自发地、独立地实现过书上k-armed赌博机的实验,自认为效果不错;见到Zhang学长的代码ten_armed_testbed.py后,觉得其代码与自己写的代码简直天壤之别。

阅读源码受益良多,我主要从以下三个方面进行总结:

  • 面向对象的程序设计(封装用于迭代的重复性算法)
  • 数值实验的常用方法与思想:tqdm与更稳健的大数定律
  • 更短的代码收获更好的可读性:lambda、zip()、_、np.zero_like()等技巧的使用

面向对象的程序设计

在这里插入图片描述

将迭代动作封装在类中

Zhang首先创建了赌博机这个类,这个类不仅仅是字面意义上的“赌博机”(不仅仅拥有返回摇臂结果的功能),还能做到:

  • 输出自己最好的臂(最优动作是什么);
  • 将各种迭代/学习方式封装,根据参数进行选择模式进行执行(如,实例化时UCB_param=1,则该摇臂机自动进行UCB式学习)。
class Bandit:
    # @k_arm: # of arms
    # @epsilon: probability for exploration in epsilon-greedy algorithm
    # @initial: initial estimation for each action
    # @step_size: constant step size for updating estimations
    # @sample_averages: if True, use sample averages to update estimations instead of constant step size
    # @UCB_param: if not None, use UCB algorithm to select action
    # @gradient: if True, use gradient based bandit algorithm
    # @gradient_baseline: if True, use average reward as baseline for gradient based bandit algorithm
    def __init__(self, k_arm=10, epsilon=0., initial=0., step_size=0.1, sample_averages=False, UCB_param=None,
                 gradient=False, gradient_baseline=False, true_reward=0.):
        self.k = k_arm
        self.step_size = step_size
        self.sample_averages = sample_averages
        self.indices = np.arange(self.k)
        self.time = 0
        self.UCB_param = UCB_param
        self.gradient = gradient
        self.gradient_baseline = gradient_baseline
        self.average_reward = 0
        self.true_reward = true_reward
        self.epsilon = epsilon
        self.initial = initial

	 def reset(self):
	 	...

	# get an action for this bandit
    def act(self):
    	...
    return np.random.choice(np.where(...))

	# take an action, update estimation for this action
    def step(self, action):
    	return reward

为什么这么设计?

  • 充分利用共性;
  • 无论是贪心算法、UCB还是梯度下降法,解决摇臂赌博机都遵循“依据某一规则选择动作-动作-获得该动作奖赏,更新奖赏表”这一规则;
  • 让代码更少,并且极大地增强易读性。

尽量多地将重复动作封装在函数里

这个.py文件的调用关系是这样的:

  • __main__调用了figure_2_2()
  • figure_2_2()调用了新建了一个list,保存不同参数的摇臂机(用于对比实验),接着调用simulate(, , list)
  • simulate(, , list)中,list里可能有2个摇臂机对象,而一个simulate就可以输出保存2个摇臂机各自每代结果的解
def simulate(runs, time, bandits):
    rewards = np.zeros((len(bandits), runs, time))
    best_action_counts = np.zeros(rewards.shape)
    for i, bandit in enumerate(bandits):
        for r in trange(runs):
            bandit.reset()
            for t in range(time):
                action = bandit.act()
                reward = bandit.step(action)
                rewards[i, r, t] = reward
                if action == bandit.best_action:
                    best_action_counts[i, r, t] = 1
    mean_best_action_counts = best_action_counts.mean(axis=1)
    mean_rewards = rewards.mean(axis=1)
    return mean_best_action_counts, mean_rewards

对于 rewards[i, r, t] 与后来的 best_action_counts.mean(axis=1) 的处理很值得学习。

对于不同实验,无需声明全局常量,降低了灵活性

我实现赌博机时,首先声明了一个全局10臂赌博机,以后所有实验中,都得去使用这个10臂赌博机,因此函数都需要带着个参数( , , 10臂赌博机);其中,10臂赌博机仅用于输入选择的动作,返回奖励值。

这显然是不经济的(总是输入相同参数,则该参数不应作为参数,而应内化到对象中去)。

无需全局的赌博机参数都一样,作对比实验中的赌博机一样就可以了。Zhang的算法中则注意到了这点。

甚至,在示意赌博机分布时,都没有从赌博机内提取数据,而是使用np.random直接生成的。

def figure_2_1():
    plt.violinplot(dataset=np.random.randn(200, 10) + np.random.randn(10))
    plt.xlabel("Action")
    plt.ylabel("Reward distribution")
    plt.savefig('images/figure_2_1.png')
    plt.close()

在这里插入图片描述

如上图,其实上图中的数据从未被使用过,只是用来画图释义。真正用于实验的数据在每个赌博机实例内。

但是,其同一组实验中,赌博机参数其实也是不一样的,这点我要质疑一下。

数值实验的常用方法与思想

tqdm

from tqdm import trange
def simulate(runs, time, bandits):
    ...
    for i, bandit in enumerate(bandits):
        for r in trange(runs):
            ...
            for t in range(time):
               ...
    ...
    return ...

将 in range(num) 改为 in trange(num) ,可以轻易查看进度条。

大数定律

如何保证实验稳健性?

我在进行我的实验时,每次结果都不一样,但是多次进行是可以看出趋势的。

Zhang如何做到每次输出的图都几乎一样呢?

from tqdm import trange
def simulate(runs, time, bandits):
    ...
    for i, bandit in enumerate(bandits):
        for r in trange(runs):
            ...
            for t in range(time):
               ...
    ...
    return ...

注意到,runs是我写算法时所没有用到的。

其意义为:我的实验每次迭代times步,但是我要做runs次实验,再对每步取平均。一般来讲,times取1000,runs取2000,由大数定律,这么多次实验的取值,每步已经很接近真实的平均值了。

Intel® Core™ i5-8265U CPU @ 1.60GHz 1.80GHz , RAM = 8.00GB

我的电脑大概每秒能做75次实验。

更短的代码收获更好的可读性

lambda

lambda被我理解成很好的委托,不能说自己不会用,但没有Zhang学长用起来这么美观、强健。

def figure_2_6(runs=2000, time=1000):
    labels = ['epsilon-greedy', 'gradient bandit',
              'UCB', 'optimistic initialization']
    generators = [lambda epsilon: Bandit(epsilon=epsilon, sample_averages=True),
                  lambda alpha: Bandit(gradient=True, step_size=alpha, gradient_baseline=True),
                  lambda coef: Bandit(epsilon=0, UCB_param=coef, sample_averages=True),
                  lambda initial: Bandit(epsilon=0, initial=initial, step_size=0.1)]
    parameters = [np.arange(-7, -1, dtype=np.float),
                  np.arange(-5, 2, dtype=np.float),
                  np.arange(-4, 3, dtype=np.float),
                  np.arange(-2, 3, dtype=np.float)]

    bandits = []
    for generator, parameter in zip(generators, parameters):
        for param in parameter:
            bandits.append(generator(pow(2, param)))

	...

zip()

zip()将2个同维度对象合并,与for结合方便高效。

for generator, parameter in zip(generators, parameters):
        for param in parameter:
            bandits.append(generator(pow(2, param)))
            ...

_

_, average_rewards = simulate(runs, time, bandits)

只想要函数返回值中的一/几个量(严格来说是不想要某一/几个量),使用_补位,类似matlab中的~。

使用_补位后,编辑器也不会提示该常量没有使用过。

[A for a in list]

python这个把 for 简化在 list 声明中的语法确实太友善了。

epsilons = [0, 0.1, 0.01]
bandits = [Bandit(epsilon=eps, sample_averages=True) for eps in epsilons]

enumerate()

枚举器,返回索引与元素。

np.zero_like()

输出一个同尺寸的0矩阵。

np.ndenumerate()

'''
Z=
 [[0 1 2]
 [3 4 5]
 [6 7 8]]
'''
for index, value in np.ndenumerate(Z):
    print('\n',index, value)

输出:


 (0, 0) 0

 (0, 1) 1

 (0, 2) 2

numpy的枚举器,没想到numpy竟然自带枚举器(可返回索引),知道了,效率翻倍。

np.random.choice(np.where(np.array == target)[0])

np.where(condition)[0]返回的是符合condition的全部位置,结合random.choice(),保证选择的随机性。

总结:技多不压身,持续学习提效率

如果不知道 np.ndenumerate() 可以实现类似功能么?可以,但恐怕要写个:

for i in range(array.shape[0]):
	for j in range(array.shape[1]):
		...

其实,python早已经把这种常用、重复性强的代码封装好了。了解这些“奇技淫巧”,很有助于我们提升开发效率,且增强代码可读性。

发布了132 篇原创文章 · 获赞 36 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/weixin_42815609/article/details/103941030