代码学习Qlearning-21点游戏为例

21点游戏介绍

21点(Blackjack)是一款经典的赌场卡牌游戏,核心目标是让自己手中卡牌的“点数和”接近21点(但不超过21点),同时超过庄家的点数和。游戏规则围绕“发牌、要牌、停牌、判定胜负”展开,整体逻辑清晰,以下是通俗易懂的完整规则介绍:

一、核心概念:卡牌点数计算

首先要明确每张牌的点数,这是判断胜负的基础:

  • 数字牌(2-10):点数=牌面数字(如“5”就是5点,“10”就是10点)。
  • 花牌(J、Q、K):无论花色,每张都算10点。
  • A(Ace):点数灵活,可算1点或11点(由玩家/庄家根据“不超过21点”的原则自主选择,比如手牌是A+5,可算16点;手牌是A+10,可算21点,称为“黑杰克”)。

二、参与角色与初始发牌

游戏通常由 1名庄家1-7名玩家 组成,使用1-8副标准扑克牌(52张/副,无大小王),初始流程固定:

  1. 庄家发牌:先给每个玩家发2张明牌(所有人都能看到),再给自己发1张明牌、1张暗牌(只有庄家自己能看)。
  2. 初始判定(黑杰克):发牌后先检查是否出现“黑杰克”——
  • 玩家:若2张初始手牌是“A+10/J/Q/K”,直接拿到“黑杰克”(点数和=21点),若庄家没有黑杰克,玩家直接赢(赢取“1.5倍赌注”,比如押100赢150);
  • 庄家:若暗牌+明牌是黑杰克,且玩家没有黑杰克,玩家直接输;
  • 双方都有黑杰克:算“平局”(玩家拿回赌注,不赢不亏)。

三、玩家的核心操作:要牌(Hit)或停牌(Stand)

若初始没有黑杰克,游戏进入“玩家回合”,每个玩家按顺序决定自己的操作,核心只有2种选择:

  1. 要牌(Hit):觉得当前点数不够(比如手牌和=16点),向庄家再要1张明牌。
  • 要牌后若点数和超过21点(称为“爆牌”,Bust),玩家直接输,失去当前赌注,无需继续;
  • 若点数和≤21点,可继续选择“要牌”或“停牌”。
  1. 停牌(Stand):觉得当前点数足够(比如手牌和=19点),不再要牌,等待庄家行动。

(注:部分规则中还有“加倍”“分牌”等进阶操作,适合熟练后使用,新手可先掌握“要牌/停牌”核心逻辑)

四、庄家的行动规则(固定,无自主选择)

所有玩家完成操作后,进入“庄家回合”,庄家需遵守固定规则行动,不能像玩家一样自由决定:

  1. 庄家先翻开自己的暗牌,计算总点数。
  2. 若总点数≤16点:必须要牌(强制,不能停牌),直到点数和≥17点。
  3. 若总点数≥17点:必须停牌(强制,不能再要牌)。
  4. 若庄家要牌后爆牌:所有未爆牌的玩家都赢,赢取“1倍赌注”(比如押100赢100)。

五、最终胜负判定(非爆牌情况下)

若玩家和庄家都未爆牌,对比双方的点数和,规则如下:

  • 玩家点数 > 庄家点数:玩家赢(1倍赌注);
  • 玩家点数 < 庄家点数:玩家输(失去赌注);
  • 玩家点数 = 庄家点数:平局(玩家拿回赌注,无输赢)。

六、关键总结(新手快速记)

  1. 目标:自己点数≤21,且比庄家大;
  2. 玩家:能选“要牌/停牌”,爆牌就输;
  3. 庄家:≤16必须要牌,≥17必须停牌,爆牌玩家赢;
  4. 黑杰克:初始A+10/J/Q/K,优先判定,赢1.5倍。

掌握这些规则,就能快速上手21点游戏,后续可逐步学习“加倍”“分牌”等进阶策略,进一步提升赢面。

Qlearning-21点游戏

代码

from collections import defaultdict
import gymnasium as gym
import numpy as np


class BlackjackAgent:
    def __init__(
        self,
        env: gym.Env,
        learning_rate: float,
        initial_epsilon: float,
        epsilon_decay: float,
        final_epsilon: float,
        discount_factor: float = 0.95,
    ):
        """Initialize a Q-Learning agent.

        Args:
            env: The training environment
            learning_rate: How quickly to update Q-values (0-1)
            initial_epsilon: Starting exploration rate (usually 1.0)
            epsilon_decay: How much to reduce epsilon each episode
            final_epsilon: Minimum exploration rate (usually 0.1)
            discount_factor: How much to value future rewards (0-1)
        """
        self.env = env

        # Q-table: maps (state, action) to expected reward
        # defaultdict automatically creates entries with zeros for new states
        self.q_values = defaultdict(lambda: np.zeros(env.action_space.n))

        self.lr = learning_rate
        self.discount_factor = discount_factor  # How much we care about future rewards

        # Exploration parameters
        self.epsilon = initial_epsilon
        self.epsilon_decay = epsilon_decay
        self.final_epsilon = final_epsilon

        # Track learning progress
        self.training_error = []

    def get_action(self, obs: tuple[int, int, bool]) -> int:
        """Choose an action using epsilon-greedy strategy.

        Returns:
            action: 0 (stand) or 1 (hit)
        """
        # With probability epsilon: explore (random action)
        if np.random.random() < self.epsilon:
            return self.env.action_space.sample()

        # With probability (1-epsilon): exploit (best known action)
        else:
            return int(np.argmax(self.q_values[obs]))

    def update(
        self,
        obs: tuple[int, int, bool],
        action: int,
        reward: float,
        terminated: bool,
        next_obs: tuple[int, int, bool],
    ):
        """Update Q-value based on experience.

        This is the heart of Q-learning: learn from (state, action, reward, next_state)
        """
        # What's the best we could do from the next state?
        # (Zero if episode terminated - no future rewards possible)
        future_q_value = (not terminated) * np.max(self.q_values[next_obs])

        # What should the Q-value be? (Bellman equation)
        target = reward + self.discount_factor * future_q_value

        # How wrong was our current estimate?
        temporal_difference = target - self.q_values[obs][action]

        # Update our estimate in the direction of the error
        # Learning rate controls how big steps we take
        self.q_values[obs][action] = (
            self.q_values[obs][action] + self.lr * temporal_difference
        )

        # Track learning progress (useful for debugging)
        self.training_error.append(temporal_difference)

    def decay_epsilon(self):
        """Reduce exploration rate after each episode."""
        self.epsilon = max(self.final_epsilon, self.epsilon - self.epsilon_decay)

来自:https://gymnasium.org.cn/introduction/train_agent/

一、代码整体框架

首先看代码的“骨架”:通过BlackjackAgent类封装智能体的所有功能,再通过外部参数配置和循环实现训练。整体结构分为3部分:

  1. 导入依赖库(处理数据、调用环境、数值计算)
  2. 定义BlackjackAgent类(智能体的核心逻辑)
  3. 配置训练超参数(控制训练过程的参数)

先看最开头的依赖库导入,这是代码运行的基础:

from collections import defaultdict  # 用于创建默认值为0的Q表(新状态自动初始化)
import gymnasium as gym  # 导入Gymnasium库,用于加载二十一点环境
import numpy as np  # 用于数值计算(如随机选动作、求最大值)

二、BlackjackAgent类:智能体的“大脑”

类是代码的核心,封装了智能体的初始化选动作学经验降探索率4个核心能力,对应Qlearning的完整逻辑。

1. __init__方法:初始化智能体的“初始状态”

作用:给智能体设定“初始属性”,比如Q表、学习率、探索率等,相当于“刚出厂的配置”。

def __init__(
    self,
    env: gym.Env,  # 传入二十一点环境(智能体要交互的世界)
    learning_rate: float,  # 学习率(控制Q值更新的幅度,0-1之间)
    initial_epsilon: float,  # 初始探索率(刚开始多探索,比如1.0=100%随机)
    epsilon_decay: float,  # 探索率衰减量(每回合减少一点,比如0.001)
    final_epsilon: float,  # 最小探索率(不能完全不探索,比如0.1=10%随机)
    discount_factor: float = 0.95,  # 折扣因子(重视未来奖励的程度,0-1之间)
):
    # 1. 保存环境(方便后续交互,比如选动作、看动作空间)
    self.env = env
    
    # 2. 初始化Q表:key是(状态,动作),value是Q值;新状态默认Q值为0
    # defaultdict的作用:如果访问一个没见过的(状态,动作),自动赋值为0,不用手动初始化
    self.q_values = defaultdict(lambda: np.zeros(env.action_space.n))
    
    # 3. 保存学习相关的参数
    self.lr = learning_rate  # 学习率:越大,Q值更新越快(但可能不稳定)
    self.discount_factor = discount_factor  # 折扣因子:越大,越重视未来奖励(比如0.95=95%重视未来)
    
    # 4. 保存探索相关的参数
    self.epsilon = initial_epsilon  # 当前探索率
    self.epsilon_decay = epsilon_decay  # 每回合衰减多少
    self.final_epsilon = final_epsilon  # 探索率的下限(不能低于这个值)
    
    # 5. 记录训练误差(用于后续调试,看Q值是否在稳定优化)
    self.training_error = []

关键理解

  • Q表用defaultdict而不是普通字典,是因为二十一点的状态很多(手牌4-21、庄家1-10、有无可用A,共280种状态),手动初始化所有状态的Q值太麻烦,defaultdict会自动给新状态的Q值设为0。
  • 折扣因子discount_factor:比如现在拿1分,和下一轮拿1分,哪个更值钱?设为0.95,意味着“下一轮的1分相当于现在的0.95分”,既重视未来,又不忽视当下。

2. get_action方法:选动作(平衡探索与利用)

作用:根据当前状态,用“epsilon-贪婪策略”选动作——既敢尝试新动作,又会利用已知的好动作。

def get_action(self, obs: tuple[int, int, bool]) -> int:
    # obs是当前状态:(玩家总点数, 庄家亮牌, 有无可用A),比如(16, 10, False)
    # 返回动作:0=停牌,1=要牌
    
    # 第一步:以概率epsilon“探索”(随机选动作,不管Q值)
    if np.random.random() < self.epsilon:
        # env.action_space.sample():从环境的动作空间里随机选一个动作(0或1)
        return self.env.action_space.sample()
    
    # 第二步:以概率(1-epsilon)“利用”(选当前状态下Q值最高的动作)
    else:
        # self.q_values[obs]:当前状态下所有动作的Q值(比如[0.8, -0.3],对应0=停牌,1=要牌)
        # np.argmax():找Q值最大的动作的索引(比如0,对应停牌)
        # 转成int是为了确保返回值类型正确
        return int(np.argmax(self.q_values[obs]))

关键理解

  • 比如初始epsilon=1.0时,100%随机选动作(纯探索),智能体像“新手”一样瞎试;随着epsilon衰减到0.1,90%的时间选Q值最高的动作(纯利用),10%的时间随机试(防止“钻牛角尖”,错过更好的动作)。

3. update方法:学经验(更新Q值,Qlearning的核心)

作用:根据“当前状态→动作→奖励→新状态”的经验,修正Q表中的Q值,让Q值更接近真实的“长期奖励”。这一步是Qlearning的灵魂,对应“试错后修正”的过程。

def update(
    self,
    obs: tuple[int, int, bool],  # 当前状态(动作前的状态)
    action: int,  # 选的动作(0或1)
    reward: float,  # 动作带来的即时奖励(+1赢,-1输,0平)
    terminated: bool,  # 回合是否结束(比如爆牌或停牌,结束则没有下一个状态)
    next_obs: tuple[int, int, bool],  # 动作后的新状态
):
    # 第一步:计算“未来的最大Q值”(从新状态能拿到的最好收益)
    # (not terminated):如果回合结束(terminated=True),未来没有收益,所以乘0;否则乘1
    # np.max(self.q_values[next_obs]):新状态下所有动作的最大Q值(未来能拿到的最好奖励)
    future_q_value = (not terminated) * np.max(self.q_values[next_obs])
    
    # 第二步:计算“目标Q值”(这个动作应该有的真实Q值,用贝尔曼方程)
    # 贝尔曼方程:当前动作的价值 = 即时奖励 + 折扣后的未来最大价值
    target = reward + self.discount_factor * future_q_value
    
    # 第三步:计算“时间差分误差”(当前Q值和目标Q值的差距,即“错了多少”)
    temporal_difference = target - self.q_values[obs][action]
    
    # 第四步:更新Q值(往目标Q值的方向修正,修正幅度由学习率控制)
    # 公式:新Q值 = 旧Q值 + 学习率 × 误差
    self.q_values[obs][action] += self.lr * temporal_difference
    
    # 第五步:记录误差(用于调试,误差变小说明Q值在优化)
    self.training_error.append(temporal_difference)

用例子看懂更新逻辑
假设场景:

  • 当前状态obs:(16, 10, False)(手牌16点,庄家10点,无可用A)
  • 选的动作action:1(要牌)
  • 奖励reward:-1(要牌后爆牌,输了)
  • 回合结束terminated:True(爆牌后回合结束,没有新状态)
  • 新状态next_obs:无意义(但代码仍会传入)

计算过程:

  1. future_q_value = (not True) × 任何值 = 0 × … = 0(回合结束,未来没收益)
  2. target = -1 + 0.95 × 0 = -1(这个动作的真实价值就是-1,因为既没即时收益,也没未来收益)
  3. 假设旧Q值self.q_values[obs][action]是0.2(之前以为要牌还行)
  4. temporal_difference = -1 – 0.2 = -1.2(误差很大,说明之前的Q值估高了)
  5. 新Q值 = 0.2 + 0.01×(-1.2) = 0.188(学习率0.01,慢慢往-1修正,避免一次改太猛)

关键理解

  • 贝尔曼方程是Qlearning的数学基础,核心思想是“当下的选择要考虑未来的最好结果”——比如二十一点“停牌”可能当下没奖励,但如果后续赢了,未来的奖励会反哺当前动作的Q值。
  • 学习率lr控制“修正幅度”:太大容易“学偏”(比如一次从0.2改成-1),太小则学太慢(几万局都没修正到位),通常设0.01~0.1之间。

4. decay_epsilon方法:衰减探索率

作用:每结束一个回合(玩完一手二十一点),就降低一点探索率,让智能体从“新手”慢慢变成“专家”。

def decay_epsilon(self):
    # 探索率 =  max(最小探索率, 当前探索率 - 衰减量)
    # 保证探索率不会低于final_epsilon(比如0.1),避免完全不探索
    self.epsilon = max(self.final_epsilon, self.epsilon - self.epsilon_decay)

例子
如果initial_epsilon=1.0epsilon_decay=0.001final_epsilon=0.1

  • 第1回合后:epsilon=1.0-0.001=0.999
  • 第900回合后:epsilon=1.0-0.001×900=0.1
  • 第901回合后:epsilon=max(0.1, 0.1-0.001)=0.1(不再衰减)

三、训练超参数:控制训练的“火候”

代码最后给出了训练的关键参数,这些参数决定了智能体“学得多快、学得好不好”:

# Training hyperparameters
learning_rate = 0.01  # 学习率:0.01意味着每次修正Q值时,只改1%的误差(稳但慢)
n_episodes = 100_000  # 训练回合数:玩10万手二十一点(足够智能体摸清规律)
start_epsilon = 1.0  # 初始探索率:刚开始100%随机试(彻底的新手)
# (文档中代码未写完,但完整代码还需补充epsilon_decay和final_epsilon,比如:)
epsilon_decay = 100_000 / 1.0  # 按10万回合衰减到0.1,计算得decay=0.000009
final_epsilon = 0.1  # 最小探索率:10%随机试(留一点探索空间)

关键理解

  • 训练回合数n_episodes不能太少:二十一点有280种状态,每种状态需要多次试错才能摸清Q值,10万回合是经验值(太少则Q表没填准,太多则浪费时间)。
  • 探索率衰减要“循序渐进”:如果衰减太快(比如100回合就降到0.1),智能体没来得及探索所有状态,可能错过最优策略;衰减太慢则学太慢。

四、代码逻辑总结:Qlearning的完整流程

把上述代码串起来,智能体的训练过程就像“学玩二十一点”的完整步骤:

  1. 初始化:创建智能体,加载二十一点环境,Q表初始化为0,探索率设1.0。
  2. 开始训练(循环10万回合)
    a. 重置环境:每回合开始,用env.reset()拿到初始状态(比如手牌4点,庄家2点,无可用A)。
    b. 玩一手牌
  • 根据当前状态,用get_action()选动作(0或1)。
  • env.step(action)执行动作,拿到“新状态、奖励、是否结束、截断标志、调试信息”。
  • update()根据“旧状态→动作→奖励→新状态”更新Q表。
  • 如果回合结束(爆牌或停牌),进入下一个回合。
    c. 衰减探索率:每玩完一手,用decay_epsilon()降低一点探索率。
  1. 训练结束:Q表中每个(状态,动作)的Q值都接近真实长期奖励,智能体学会“什么情况选什么动作最优”(比如手牌12点对庄家2-6点要牌,对7-A点停牌)。

通过这段代码,Qlearning从“抽象的数学概念”变成了“可落地的智能体”——核心就是“用Q表记录经验,用试错修正经验,用探索平衡经验”,像人一样从实践中慢慢学会做决策。

https://gymnasium.org.cn/introduction/train_agent

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注