强化学习入门知识与经典项目分析2.2

本篇文章为编程实践——蒙特卡罗学习评估21点游戏的玩家策略、求解21点游戏的最优策略

话说前头,我只是个大三的搬运工,代码文字很多都是基于叶强老师的github ,我只能读懂代码,我写不出这种面向对象的程序,我的水平还远远不够格。写博客的目的也只是为了自己的学习,增进对知识的理解,在疫情期间的假期可以消磨时光。而且因为很多参考资料是图书馆借的书,到期要还,再加上图书馆的效率。。所以趁知识还热乎就赶紧记录了。

一、蒙特卡罗学习评估21点游戏的玩家策略

1.二十一点游戏规则

  二十一点游戏是一个比较经典的对弈游戏,其规则也有各种不同的版本,为了简化,本文仅介绍由一个庄家 (dealer) 和一个普通玩家 (player,下文简称玩家) 共 2 位游戏者参与的一个比较基本的规则版本。游戏使用一副除大小王以外的 52 张扑克牌,游戏者的目标是使手中的牌的点数之和不超过 21 点且尽量大。其中 2-10 的数字牌点数就是牌面的数字,J,Q,K 三类牌均记为10 点,A 既可以记为 1 也可以记为 11,由游戏者根据目标自己决定。牌的花色对于计算点数没有影响。
  开局时,庄家将依次连续发 2 张牌给玩家和庄家,其中庄家的第一张牌是明牌,其牌面信息对玩家是开放的,庄家从第二张牌开始的其它牌的信息不对玩家开放。玩家可以根据自己手中牌的点数决定是否继续叫牌 (twist) 或停止叫牌 (stick), 玩家可以持续叫牌,但一旦手中牌点数超过 21 点则停止叫牌。当玩家停止叫牌后,庄家可以决定是否继续叫牌。如果庄家停止叫牌,对局结束,双方亮牌计算输赢。
  计算输赢的规则如下:如果双方点数均超过 21 点或双方点数相同,则和局;一方 21 点另一方不是 21 点,则点数为 21 点的游戏者赢;如果双方点数均不到 21 点,则点数离 21 点近的玩家赢。

2.将二十一点游戏建模为强化学习问题

  为了讲解基于完整状态序列的蒙特卡罗学习算法,我们把二十一点游戏建模成强化学习问题,设定由下面三个参数来集体描述一个状态:庄家的明牌(第一张牌)点数;玩家手中所有牌点数之和;玩家手中是否还有“可用(useable)”的A(ace)。前两个比较好理解,第三个参数是与玩家策略相关的,玩家是否有A这个比较好理解,可用的A指的是玩家手中的A按照目标最大化原则是否没有被计作1点,如果这个A没有被记为1点而是计为了11点,则成这个A为可用的A,否则认为没有可用的A,当然如果玩家手中没有A,那么也被认为是没有可用的A。
  例如玩家手中的牌为“A,3,6”,那么此时根据目标最大化原则,A将被计为11点,总点数为20点,此时玩家手中的A称为可用的A。加入玩家手中的牌为“A,5,7”,那么此时的A不能被计为11点只能按1计,相应总点数被计为13点,否则总点数将为 23 点,这时的A就不能称为可用的A。

根据我们对状态的设定,我们使用由三个元素组成的元组来描述一个状态

  • 使用(10,15,0)表示的状态是庄家的明牌是10,玩家手中的牌加起来点数是15,并且玩家手中没有可用的A
  • (A,17,1) 表述的状态是庄家第一张牌为 A,玩家手中牌总点数为 17, 玩家手中有可用的 A

这样的状态设定不考虑玩家手中的具体牌面信息,也不记录庄家除第一张牌外的其它牌信息。所有可能的状态构成了状态空间
该问题的行为空间比较简单,玩家只有两种选择:“继续叫牌”或“停止叫牌”
该问题中的状态如何转换取决于游戏者的行为以及后续发给游戏者的牌,状态间的转移概
率很难计算

可以设定奖励如下:当棋局未结束时,任何状态对应的奖励为0;当棋局结束时,如果玩家赢得对局,奖励值为1,玩家输掉对局,奖励值为-1,和局是奖励为0。
本问题中衰减因子γ = 1。

游戏者在选择行为时都会遵循一个策略。在本例中,庄家遵循的策略是只要其手中的牌点数达到或超过17点就停止叫牌。我们设定玩家遵循的策略是只要手中的牌点数不到20点就会继续叫牌,点数达到或超过20点就停止叫牌。

3.游戏场景的搭建

  首先来搭建这个游戏场景,实现生成对局数据的功能,我们要实现的功能包括:统计游戏者手中牌的总点数、判断当前牌局信息对应的奖励、实现庄家与玩家的策略、模拟对局的过程生成对局数据等。为了能尽可能生成较符合实际的对局数据,我们将循环使用一副牌,对局过程中发牌、洗牌、收集已使用牌等过程都将得到较为真实的模拟。我们使用面向对象的编程思想,通过构建游戏者类和游戏场景类来实现上述功能。

首先我们导入一些必要的库:

from random import shuffle
from queue import Queue
from tqdm import tqdm
import math
import matplotlib.pyplot as plt
import numpy as np
from mpl_toolkits.mplot3d import Axes3D
from utils import str_key, set_dict, get_dict

经过初步的分析和整理,我们认为一个单纯的二十一点游戏者应该至少能记住对局过程中手中牌的信息,知道自己的行为空间,还应该能辨认单张牌的点数以及手中牌的总点数,此外游戏者能够接受发给他的牌以及一局结束后将手中的牌扔掉等。为此我们编写了一个名称为 Gamer的游戏者类。代码如下:

class Gamer():
    '''游戏者
    '''
    def __init__(self, name = "", A = None, display = False):
        self.name = name
        self.cards = [] # 手中的牌
        self.display = display # 是否显示对局文字信息
        self.policy = None # 策略
        self.learning_method = None # 学习方法
        self.A = A # 行为空间
        
    def __str__(self):
        return self.name
    
    def _value_of(self, card):
        '''根据牌的字符判断牌的数值大小,A被输出为1, JQK均为10,其余按牌字符对应的数字取值
        Args:
            card: 牌面信息 str
        Return:
            牌的大小数值 int, A 返回 1
        '''
        try:
            v = int(card)
        except:
            if card == 'A':
                v = 1
            elif card in ['J','Q','K']:
                v = 10
            else:
                v = 0
        finally:
            return v
    
    def get_points(self):
        '''统计一手牌分值,如果使用了A的1点,同时返回True
        Args:
            cards 庄家或玩家手中的牌 list ['A','10','3']
        Return
            tuple (返回牌总点数,是否使用了可复用Ace) 
            例如['A','10','3'] 返回 (14, False)
               ['A','10'] 返回 (21, True)
        '''
        num_of_useable_ace = 0 # 默认没有拿到Ace
        total_point = 0 # 总值
        cards = self.cards
        if cards is None:
            return 0, False
        for card in cards:
            v = self._value_of(card)
            if v == 1:
                num_of_useable_ace += 1
                v = 11
            total_point += v
        while total_point > 21 and num_of_useable_ace > 0:
            total_point -= 10
            num_of_useable_ace -= 1
        return total_point, bool(num_of_useable_ace)
    
    def receive(self, cards = []): # 玩家获得一张或多张牌
        cards = list(cards)
        for card in cards:
            self.cards.append(card)
    
    def discharge_cards(self): # 玩家把手中的牌清空,扔牌
        '''扔牌
        '''
        self.cards.clear()
    
    
    def cards_info(self): # 玩家手中牌的信息
        '''
        显示牌面具体信息
        '''
        self._info("{}{}现在的牌:{}\n".format(self.role, self,self.cards))
    
    def _info(self, msg):
        if self.display:
            print(msg, end="")

try-expect-finally语法规则
  在上面的代码中,构造一个游戏者可以提供三个参数,分别是该游戏者的姓名(name),行为空间(A)和是否在终端显示具体信息 (display)。其中设置第三个参数主要是由于调试和展示的需要,我们希望一方面游戏在生成大量对局信息时不要输出每一局的细节,另一方面在观察细节时希望能在终端给出某时刻庄家和玩家手中具体牌的信息以及他们的行为等。我们还给游戏者增加了一些辅助属性,比如游戏者姓名、策略、学习方法等,还设置了一个 display 以及一些显示信息的方法用来在对局中在终端输出对局信息。在计算单张牌面点数的时候,借用了异常处理。在统计一手牌的点数时,要考虑到可能出现多张A的情况。读者可以输入一些测试牌的信息观察这两个方法的输出。
  在二十一点游戏中,庄家和玩家都是一个游戏者,我们可以从Gamer类继承出 Dealer类和Player类分别表示庄家和普通玩家。庄家和普通玩家的区别在于两者的角色不同、使用的策略不同。其中庄家使用固定的策略,他还能显示第一张明牌给其他玩家。在本章编程实践中,玩家则使用最基本的策略,由于我们的玩家还要进行基于蒙特卡罗算法的策略评估,他还需要具备构建一个状态的能力。我们扩展的庄家类如下:

class Dealer(Gamer):
    def __init__(self, name = "", A = None, display = False):
        super(Dealer,self).__init__(name, A, display)
        self.role = "庄家"
        self.policy = self.dealer_policy
    
    def first_card_value(self):
        if self.cards is None or len(self.cards) == 0:
            return 0
        return self._value_of(self.cards[0])
    
    def dealer_policy(self, Dealer = None):
        action = ""
        dealer_points, _ = self.get_points()
        if dealer_points >= 17:
            action = self.A[1] # "停止要牌"
        else:
            action = self.A[0]
        return action

  在庄家类的构造方法中声明其基类是游戏者 (Gamer),这样他就具备了游戏者的所有属性和方法了。我们给庄家贴了个“庄家”的角色标签,同时指定了其策略,在具体的策略方法中,规定庄家的牌只要达到或超过 17 点就不再继续叫牌。

玩家类的代码如下:

class Player(Gamer):
    def __init__(self, name = "", A = None, display = False):
        super(Player, self).__init__(name, A, display)
        self.policy = self.naive_policy
        self.role = "玩家" # “庄家”还是“玩家”,庄家是特殊的玩家
    
    def get_state(self, dealer):
        dealer_first_card_value = dealer.first_card_value()
        player_points, useable_ace = self.get_points()
        return dealer_first_card_value, player_points, useable_ace
        
    def get_state_name(self, dealer):
        return str_key(self.get_state(dealer))

    def naive_policy(self, dealer=None):
        player_points, _ = self.get_points()
        if player_points < 20:
            action = self.A[0]
        else:
            action = self.A[1]        
        return action  
        

  类似的,我们的玩家类也继承子游戏者 (Gamer),指定其策略为最原始的策略 (naive_policy),规定玩家只要点数小于 20 点就会继续叫牌。玩家同时还会根据当前局面信息得到当前局面的状态,为策略评估做准备。至此游戏者这部分的建模工作就完成了,接下来将准备游戏桌、游戏牌、组织游戏对局、判定输赢等功能。我们把所有的这些功能包装在一个名称为 Arena 的类中。Arena 类的构造方法如下:

class Arena():
    '''负责游戏管理
    '''
    def __init__(self, display = None, A = None):
        self.cards = ['A','2','3','4','5','6','7','8','9','10','J','Q',"K"]*4
        self.card_q = Queue(maxsize = 52) # 洗好的牌
        self.cards_in_pool = [] # 已经用过的公开的牌  
        self.display = display
        self.episodes = [] # 产生的对局信息列表
        self.load_cards(self.cards)# 把初始状态的52张牌装入发牌器
        self.A = A # 获得行为空间

  Arena 类接受两个参数,这两个参数与构建游戏者的参数一样。Arena 包含的属性有:一副不包括大小王、花色信息的牌(cards)、一个装载洗好了的牌的发牌器 (cards_q),一个负责收集已经使用过的废牌的池子(cards_in_pool),一个记录了对局信息的列表 (episodes),还包括是否显示具体信息以及游戏的行为空间等。在构造一个Arena对象时,我们同时把一副新牌洗好并装进了发牌器,这个工作在load_cards方法里完成。我们来看看这个方法的细节。

    def load_cards(self, cards):
        '''把收集的牌洗一洗,重新装到发牌器中
        Args:
            cards 要装入发牌器的多张牌 list
        Return:
            None
        '''
        shuffle(cards) # 洗牌
        for card in cards:# deque数据结构只能一个一个添加
            self.card_q.put(card)
        cards.clear() # 原来的牌清空
        return

  这个方法接受一个参数 (cards),多数时候我们将 cards_in_pool 传给这个方法,也就是把桌面上已使用的废牌收集起来传给这个方法,该方法将首先把这些牌的次序打乱,模拟洗牌操作。随后将洗好的牌放入发牌器。完成洗牌装牌功能。Arena 应具备根据庄家和玩家手中的牌的信息判断当前谁赢谁输的能力,该能力通过如下的方法 (reward_of) 来实现:

    def reward_of(self, dealer, player):
        '''判断玩家奖励值,附带玩家、庄家的牌点信息
        '''
        dealer_points, _ = dealer.get_points()
        player_points, useable_ace = player.get_points()
        if player_points > 21:
            reward = -1
        else:
            if player_points > dealer_points or dealer_points > 21:
                reward = 1
            elif player_points == dealer_points:
                reward = 0
            else:
                reward = -1
        return reward, player_points, dealer_points, useable_ace

  该方法接受庄家和玩家为参数,计算对局过程中以及对局结束时牌局的输赢信息 (reward)后,同时还返回当前玩家、庄家具体的总点数以及玩家是否有可用的A等信息。

下面的方法实现了 Arena 对象如何向庄家或玩家发牌的功能:

    def serve_card_to(self, player, n = 1):
        '''给庄家或玩家发牌,如果牌不够则将公开牌池的牌洗一洗重新发牌
        Args:
            player 一个庄家或玩家 
            n 一次连续发牌的数量
        Return:
            None
        '''
        cards = []  #将要发出的牌
        for _ in range(n):
            # 要考虑发牌器没有牌的情况
            if self.card_q.empty():
                self._info("\n发牌器没牌了,整理废牌,重新洗牌;")
                shuffle(self.cards_in_pool)
                self._info("一共整理了{}张已用牌,重新放入发牌器\n".format(len(self.cards_in_pool)))
                assert(len(self.cards_in_pool) > 20) # 确保有足够的牌,将该数值设置成40左右时,如果玩家
                # 即使爆点了也持续的叫牌,会导致玩家手中牌变多而发牌器和已使用的牌都很少,需避免这种情况。
                self.load_cards(self.cards_in_pool) # 将收集来的用过的牌洗好送入发牌器重新使用
            cards.append(self.card_q.get()) # 从发牌器发出一章牌
        self._info("发了{}张牌({})给{}{};".format(n, cards, player.role, player))
        #self._info(msg)
        player.receive(cards) # 牌已发给某一玩家
        player.cards_info()
    def _info(self, message):
        if self.display:
            print(message, end="")

  这个方法(serve_card_to)接受一个玩家 (player) 和一个整数 (n) 作为参数,表示向该玩家一次发出一定数量的牌,在发牌时如果遇到发牌器里没有牌的情况时会将已使用的牌收集起来洗好后送入发牌器,随后在把需要数量的牌发给某一玩家。代码中的方法 (_info) 负责根据条件在终端输出对局信息。
  当一局结束时,Arena 对象还负责把玩家手中的牌回收至已使用的废牌区,这个功能由下面这个方法来完成:

    def recycle_cards(self, *players):
        '''回收玩家手中的牌到公开使用过的牌池中
        '''
        if len(players) == 0:
            return
        for player in players:
            for card in player.cards:
                self.cards_in_pool.append(card)
            player.discharge_cards() # 玩家手中不再留有这些牌

  剩下一个最关键的功能就是,如何让庄家和玩家进行一次对局,编写下面的方法来实现这个功能:

    def play_game(self, dealer, player):
        '''玩一局21点,生成一个状态序列以及最终奖励(中间奖励为0)
        Args:
            dealer/player 庄家和玩家手中的牌 list
        Returns:
            tuple:episode, reward
        '''
        #self.collect_player_cards()
        self._info("========= 开始新一局 =========\n")
        self.serve_card_to(player, n=2) # 发两张牌给玩家
        self.serve_card_to(dealer, n=2) # 发两张牌给庄家
        episode = [] # 记录一个对局信息
        if player.policy is None:
            self._info("玩家需要一个策略")
            return
        if dealer.policy is None:
            self._info("庄家需要一个策略")
            return
        while True:
            action = player.policy(dealer)
            # 玩家的策略产生一个行为
            self._info("{}{}选择:{};".format(player.role, player, action))
            episode.append((player.get_state_name(dealer), action)) # 记录一个(s,a)
            if action == self.A[0]: # 继续叫牌
                self.serve_card_to(player) # 发一张牌给玩家
            else: # 停止叫牌
                break
        # 玩家停止叫牌后要计算下玩家手中的点数,玩家如果爆了,庄家就不用继续了        
        reward, player_points, dealer_points, useable_ace = self.reward_of(dealer, player)
        
        if player_points > 21:
            self._info("玩家爆点{}输了,得分:{}\n".format(player_points, reward))
            self.recycle_cards(player, dealer)
            self.episodes.append((episode, reward)) # 预测的时候需要形成episode list后同一学习V
            # 在蒙特卡洛控制的时候,可以不需要episodes list,生成一个episode学习一个,下同
            self._info("========= 本局结束 ==========\n")
            return episode, reward
        # 玩家并没有超过21点
        self._info("\n")
        while True:
            action = dealer.policy() # 庄家从其策略中获取一个行为
            self._info("{}{}选择:{};".format(dealer.role, dealer, action))
            if action == self.A[0]: # 庄家"继续要牌":
                self.serve_card_to(dealer)
                # 停止要牌是针对玩家来说的,episode不记录庄家动作
                # 在状态只记录庄家第一章牌信息时,可不重复记录(s,a),因为此时玩家不再叫牌,(s,a)均相同
                # episode.append((get_state_name(dealer, player), self.A[1]))
            else:
                break
        # 双方均停止叫牌了    
        self._info("\n双方均了停止叫牌;\n")
        reward, player_points, dealer_points, useable_ace = self.reward_of(dealer, player)
        player.cards_info() 
        dealer.cards_info()
        if reward == +1:
            self._info("玩家赢了!")
        elif reward == -1:
            self._info("玩家输了!")
        else:
            self._info("双方和局!")
        self._info("玩家{}点,庄家{}点\n".format(player_points, dealer_points))
        
        self._info("========= 本局结束 ==========\n")
        self.recycle_cards(player, dealer) # 回收玩家和庄家手中的牌至公开牌池
        self.episodes.append((episode, reward)) # 将刚才产生的完整对局添加值状态序列列表,蒙特卡洛控制不需要
        return episode, reward

  这段代码虽然比较长,但里面包含许多反映对局过程的信息,使得代码也比较容易理解。该方法接受一个庄家一个玩家为参数,产生一次对局,并返回该对局的详细信息。需要指出的是玩家的策略要做到在玩家手中的牌超过21点时强制停止叫牌。其次在玩家停止叫牌后,Arena对局面进行一次判断,如果玩家超过21点则本局结束,否则提示庄家选择行为。当庄家停止叫牌后后,Arena对局面再次进行以此判断,结束对局并将该对局产生的详细信息记录一个episode对象,并附加地把包含了该局信息的episode对象联合该局的最终输赢(奖励)登记至Arena的成员属性episodes中。
  有了生成一次对局的方法,我们编写下面的代码来一次性生成多个对局:

    def play_games(self, dealer, player, num=2, show_statistic = True):
        '''一次性玩多局游戏
        '''
        results = [0, 0, 0]# 玩家负、和、胜局数
        self.episodes.clear()
        for i in tqdm(range(num)):
            episode, reward = self.play_game(dealer, player)
            results[1+reward] += 1
            if player.learning_method is not None:
                player.learning_method(episode ,reward)
        if show_statistic:
            print("共玩了{}局,玩家赢{}局,和{}局,输{}局,胜率:{:.2f},不输率:{:.2f}"\
              .format(num, results[2],results[1],results[0],results[2]/num,(results[2]+results[1])/num))
        pass

该方法接受一个庄家、一个玩家、需要产生的对局数量、以及是否显示多个对局的统计信息这四个参数,生成指定数量的对局信息,这些信息都保存在 Arena 的 episodes 对象中。为了兼容具备学习能力的玩家,我们设置了在每一个对局结束后,如果玩家能够从中学习,则提供玩家一次学习的机会,在本章中的玩家不具备从对局中学习改善策略的能力,这部分内容将在下面内容中详细讲解。如果参数设置为显示统计信息,则会在指定数量的对局结束后显示一共对局多少,玩家的胜率等。

4.生成对局数据

至此,我们所有的准备工作就完成了。下面的代码将生成一个庄家、一个玩家,一个Arena对象,并进行20万次的对局:

A=["继续叫牌","停止叫牌"]
display = False
# 创建一个玩家一个庄家,玩家使用原始策略,庄家使用其固定的策略
player = Player(A = A, display = display)
dealer = Dealer(A = A, display = display)
# 创建一个场景
arena = Arena(A = A, display=display)
# 生成num个完整的对局
arena.play_games(dealer, player, num=200000)
# 100%|██████████| 200000/200000 [00:19<00:00, 10142.90it/s]
# 共玩了200000局,玩家赢58785局,和11180局,输130035局,胜率:0.29,不输率:0.35

5.策略评估

对局生成的数据均保存在对象arena.episodes中,接下来的工作就是使用这些数据来对player 的策略进行评估,下面的代码完成这部分功能:

# 统计个状态的价值,衰减因子为1,中间状态的即时奖励为0,递增式蒙特卡洛评估
def policy_evaluate(episodes, V, Ns):
    for episode, r in episodes:
        for s, a in episode:
            ns = get_dict(Ns, s)
            v = get_dict(V, s)
            set_dict(Ns, ns+1, s)
            set_dict(V, v+(r-v)/(ns+1), s)
V = {} # 状态价值字典
Ns = {} # 状态被访问的次数节点
policy_evaluate(arena.episodes, V, Ns) # 学习V值

其中,V 和 Ns 保存着蒙特卡罗策略评估进程中的价值和统计次数数据,我们使用的是每次访问计数的方法。我们还可以编写如下的方法将价值函数绘制出来:

def draw_value(value_dict, useable_ace = True, is_q_dict = False, A = None):
    # 定义figure
    fig = plt.figure()
    # 将figure变为3d
    ax = Axes3D(fig)
    # 定义x, y
    x = np.arange(1, 11, 1) # 庄家第一张牌
    y = np.arange(12, 22, 1) # 玩家总分数
    # 生成网格数据
    X, Y = np.meshgrid(x, y)
    # 从V字典检索Z轴的高度
    row, col = X.shape
    Z = np.zeros((row,col))
    if is_q_dict:
        n = len(A)
    for i in range(row):
        for j in range(col):
            state_name = str(X[i,j])+"_"+str(Y[i,j])+"_"+str(useable_ace)
            if not is_q_dict:
                Z[i,j] = get_dict(value_dict, state_name)
            else:
                assert(A is not None)
                for a in A:
                    new_state_name = state_name + "_" + str(a)
                    q = get_dict(value_dict, new_state_name)
                    if q >= Z[i,j]:
                        Z[i,j] = q
    # 绘制3D曲面
    ax.plot_surface(X, Y, Z, rstride = 1, cstride = 1, color="lightgray")
    plt.show()
draw_value(V, useable_ace = True, A = A) # 绘制状态价值图
draw_value(V, useable_ace = False, A = A) # 绘制状态价值图

二十一点游戏玩家原始策略的价值函数 (20 万次迭代):
有可用的Ace:

没有可用的 Ace

# 观察几局对局信息
display = True
player.display, dealer.display, arena.display = display, display, display
arena.play_games(dealer, player, num =2)
# ========= 开始新一局 =========
# 发了2张牌(['Q', 'Q'])给玩家;玩家现在的牌:['Q', 'Q']
# 发了2张牌(['K', '2'])给庄家;庄家现在的牌:['K', '2']
# 玩家选择:停止叫牌;
# 庄家选择:继续叫牌;发了1张牌(['4'])给庄家;庄家现在的牌:['K', '2', '4']
# 庄家选择:继续叫牌;发了1张牌(['10'])给庄家;庄家现在的牌:['K', '2', '4', '10']
# 庄家选择:停止叫牌;
# 双方均了停止叫牌;
# 玩家现在的牌:['Q', 'Q']
# 庄家现在的牌:['K', '2', '4', '10']
# 玩家赢了!玩家20点,庄家26点
# ========= 本局结束 ==========
# ========= 开始新一局 =========
# 发了2张牌(['9', '4'])给玩家;玩家现在的牌:['9', '4']
# 发了2张牌(['J', 'A'])给庄家;庄家现在的牌:['J', 'A']
# 玩家选择:继续叫牌;发了1张牌(['5'])给玩家;玩家现在的牌:['9', '4', '5']
# 玩家选择:继续叫牌;发了1张牌(['4'])给玩家;玩家现在的牌:['9', '4', '5', '4']
# 玩家选择:停止叫牌;玩家爆点22输了,得分:-1
# ========= 本局结束 ==========
# 共玩了2局,玩家赢1局,和0局,输1局,胜率:0.50,不输率:0.50

  本节编程实践中,我们构建了游戏者基类并扩展形成了庄家类和玩家类来模拟玩家的行为,同时构建了游戏场景类来负责进行对局管理。在此基础上使用蒙特卡罗算法对游戏中玩家的原始策略进行了评估。在策略评估环节,我们并没有把价值函数 (字典)、计数函数 (字典) 以及策略评估方法设计为玩家类的成员对象和成员方法,这只是为了讲解的方便。下一节的编程实践中,我们将继续通过二十一点游戏介绍如何使用蒙特卡罗控制寻找最优策略,本节建立的Dealer,Player和Arena类将得到复用和扩展。

二、蒙特卡罗学习求21点游戏的最优策略

在本节的编程实践中,我们将继续使用前一节二十一点游戏的例子,这次我们要使用基于现时策略蒙特卡罗控制的方法来求解二十一点游戏玩家的最优策略。我们把上一节编写的Dealer,Player和Arena类保存至文件blackjack.py中,并加载这些类和其它一些需要的库和方法:

from blackjack import Player, Dealer, Arena
from utils import str_key, set_dict, get_dict
from utils import draw_value, draw_policy
from utils import epsilon_greedy_policy
import math

目前的Player类不具备策略评估和更新策略的能力,我们基于Player类编写一个MC_Player类,使其具备使用蒙特卡罗控制算法进行策略更新的能力,代码如下:

class MC_Player(Player):
    '''具备蒙特卡罗控制能力的玩家
    '''
    def __init__(self, name = "", A = None, display = False):
        super(MC_Player, self).__init__(name, A, display)
        self.Q = {}   # 某一状态行为对的价值,策略迭代时使用
        self.Nsa = {} # Nsa的计数:某一状态行为对出现的次数
        self.total_learning_times = 0
        self.policy = self.epsilon_greedy_policy # 
        self.learning_method = self.learn_Q # 有了自己的学习方法
    
    def learn_Q(self, episode, r): # 从状态序列来学习Q值
        '''从一个Episode学习
        '''
        #for episode, r in episodes:
        for s, a in episode:
            nsa = get_dict(self.Nsa, s, a)
            set_dict(self.Nsa, nsa+1, s, a)
            q = get_dict(self.Q, s,a)
            set_dict(self.Q, q+(r-q)/(nsa+1), s, a)
        self.total_learning_times += 1
    
    def reset_memory(self):
        '''忘记既往学习经历
        '''
        self.Q.clear()
        self.Nsa.clear()
        self.total_learning_times = 0

    
    def epsilon_greedy_policy(self, dealer, epsilon = None):
        '''这里的贪婪策略是带有epsilon参数的
        '''
        player_points, _ = self.get_points()
        if player_points >= 21:
            return self.A[1]
        if player_points < 12:
            return self.A[0]
        else:
            A, Q = self.A, self.Q
            s = self.get_state_name(dealer)
            if epsilon is None:
                #epsilon = 1.0/(self.total_learning_times+1)
                #epsilon = 1.0/(1 + math.sqrt(1 + player.total_learning_times))
                epsilon = 1.0/(1 + 4 * math.log10(1+player.total_learning_times))
            return epsilon_greedy_policy(A, s, Q, epsilon)

这样,MC_Player类就具备了学习Q值的方法和一个ϵ-贪婪策略。接下来我们使用MC_Player类来生成对局,庄家的策略仍然不变。

A=["继续叫牌","停止叫牌"]
display = False
player = MC_Player(A = A, display = display)
dealer = Dealer(A = A, display = display)
# 创建一个场景
arena = Arena(A = A, display=display)
arena.play_games(dealer = dealer, player = player,num = 800000, show_statistic = True)
# 共玩了200000局,玩家赢85019局,和15790局,输99191局,胜率:0.43,不输率:0.50

MC_Player 学习到的行为价值函数和最优策略可以使用下面的代码绘制:

draw_value(player.Q, useable_ace = True, is_q_dict=True, A = player.A)
draw_policy(epsilon_greedy_policy, player.A, player.Q, epsilon = 1e-10, useable_ace = True)
draw_value(player.Q, useable_ace = False, is_q_dict=True, A = player.A)
draw_policy(epsilon_greedy_policy, player.A, player.Q, epsilon = 1e-10, useable_ace = False)

绘制结果下图所示。策略图中深色部分(上半部) 为“停止叫牌”,浅色部分 (下半部) 为“继续交牌”。基于前一章介绍的二十一点游戏规则,迭代 20 万次后得到的贪婪策略为:当玩家手中有可用的 Ace 时,在牌点数达到 17 点,仍可选择叫牌;而当玩家手中没有可用的 Ace 时,当庄家的明牌在 2-7 点间,最好停止叫牌,当庄家的明牌为 A 或者超过 7 点时,可以选择继续交牌至玩家手中的牌点数到达 16 为止。由于训练次数并不多,策略图中还有一些零星的散点。

有可用的Ace的行为价值:

有可用的Ace的最优策略:

无可用的Ace的行为价值:

无可用的Ace的最优策略:

可以编写代码生成一些对局的详细数据观察具备 MC 控制能力的玩家的行为策略:

display = True
arena.display = display
player.display = display
dealer.display = display
arena.play_games(dealer,player,num=2, show_statistic = True)
# ========= 开始新一局 =========
# 发了2张牌(['2', '5'])给玩家;玩家现在的牌:['2', '5']
# 发了2张牌(['Q', '6'])给庄家;庄家现在的牌:['Q', '6']
# 玩家选择:继续叫牌;发了1张牌(['7'])给玩家;玩家现在的牌:['2', '5', '7']
# 玩家选择:继续叫牌;发了1张牌(['5'])给玩家;玩家现在的牌:['2', '5', '7', '5']
# 玩家选择:停止叫牌;
# 庄家选择:继续叫牌;发了1张牌(['9'])给庄家;庄家现在的牌:['Q', '6', '9']
# 庄家选择:停止叫牌;
# 双方均了停止叫牌;
# 玩家现在的牌:['2', '5', '7', '5']
# 庄家现在的牌:['Q', '6', '9']
# 玩家赢了!玩家19点,庄家25点
# ========= 本局结束 ==========
# ========= 开始新一局 =========
# 发了2张牌(['J', '3'])给玩家;玩家现在的牌:['J', '3']
# 发了2张牌(['8', 'A'])给庄家;庄家现在的牌:['8', 'A']
# 玩家选择:继续叫牌;发了1张牌(['2'])给玩家;玩家现在的牌:['J', '3', '2']
# 玩家选择:停止叫牌;
# 庄家选择:停止叫牌;
# 双方均了停止叫牌;
# 玩家现在的牌:['J', '3', '2']
# 庄家现在的牌:['8', 'A']
# 玩家输了!玩家15点,庄家19点
# ========= 本局结束 ==========
# 共玩了2局,玩家赢1局,和0局,输1局,胜率:0.50,不输率:0.50
posted @ 2022-03-13 21:31  静候佳茵  阅读(374)  评论(1编辑  收藏  举报