首次访问蒙特卡洛预测算法的实现,旨在估计给定策略 $\pi$ 的状态价值函数 $V^\pi$。这种估计仅利用在环境中遵循该策略生成的样本片段。不需要了解环境的转移概率或奖励动态。对于本练习,我们将使用通过 Gymnasium 库提供的经典二十一点环境。回顾上一节,蒙特卡洛预测通过平均多次片段中首次访问每个状态后观察到的回报来工作。二十一点环境首先,请确保您已安装 Gymnasium (pip install gymnasium[classic_control])。二十一点环境 (Blackjack-v1) 模拟了这款流行的纸牌游戏。状态: 一个元组 (player_sum, dealer_showing_card, usable_ace)。player_sum:玩家当前手牌点数总和(整数,介于 4 到 21 之间)。dealer_showing_card:庄家明牌的点数(整数,介于 1 到 10 之间,其中 1 代表 A)。usable_ace:玩家是否拥有一张可以计为 11 点而不会爆牌的 A(布尔值,0 或 1)。动作: 有两种动作:0:停牌(停止要牌)。1:要牌(再拿一张牌)。奖励:+1:玩家赢。-1:玩家输。0:平局(推)。片段结束: 当玩家停牌或爆牌(点数总和 > 21)时,或当庄家根据标准二十一点规则完成其回合(庄家要牌直到其点数总和达到 17 或更多)时,一个片段结束。待评估的策略我们需要一个策略 $\pi$ 来生成片段。我们将使用一个简单的、固定的策略,它常作为基准使用:策略 $\pi$: 如果玩家当前点数总和为 20 或 21,则停牌。否则,要牌。这个策略是合理的但并非最优。我们的目标不是现在找到最佳策略,而是仅仅使用蒙特卡洛预测来评估这个特定的策略。实现首次访问蒙特卡洛预测我们来概述步骤,然后用 Python 实现它们。初始化:创建字典来存储每个状态的回报总和 (state_returns_sum) 以及每个状态的访问次数 (state_visit_count)。将状态价值函数 $V(s)$ 初始化为一个字典,所有值可能从 0 开始。片段循环: 运行指定数量的片段。生成片段: 使用固定策略 $\pi$ 玩一局完整的二十一点游戏。存储状态和奖励的序列:$(S_0, R_1, S_1, R_2, ..., S_{T-1}, R_T)$。计算回报: 从片段末尾开始反向迭代($t = T-1, T-2, ..., 0$)。计算从步骤 $t$ 开始的回报 $G$:$G = R_{t+1} + \gamma R_{t+2} + ... + \gamma^{T-t-1} R_T$。由于二十一点是片段式的,并且我们只关心最终结果,我们将使用 $\gamma = 1$。因此,$G$ 变成了从状态 $S_t$ 开始获得的最终奖励。实际上,我们只需要最终奖励 $R_T$,因为中间奖励为 0。首次访问检查: 跟踪当前片段中访问过的状态。如果这是状态 $S_t$ 在此片段中首次被访问,则进行更新。更新值:增加状态 $S_t$ 的访问计数:state_visit_count[S_t] += 1。将计算出的回报 $G$ 添加到状态 $S_t$ 的总和中:state_returns_sum[S_t] += G。更新价值估计:$V(S_t) = \text{state_returns_sum}[S_t] / \text{state_visit_count}[S_t]$。现在,我们将其转换为 Python 代码。import gymnasium as gym import numpy as np from collections import defaultdict import random # 仅在使策略随机化时使用,对于此固定策略不需要 # 初始化环境 env = gym.make('Blackjack-v1', sab=True) # sab=True 使用略有不同的规则,结果相似 # 定义简单策略:20 或 21 停牌,否则要牌。 def simple_policy(state): """ 参数: state:元组 (player_sum, dealer_showing, usable_ace) 返回: action:0(停牌)或 1(要牌) """ player_sum, _, _ = state if player_sum >= 20: return 0 # 停牌 else: return 1 # 要牌 # 蒙特卡洛预测算法(首次访问) def mc_prediction_first_visit(policy, env, num_episodes, gamma=1.0): """ 执行首次访问蒙特卡洛预测。 参数: policy:一个接受状态并返回动作的函数。 env:OpenAI Gym 环境。 num_episodes:要生成的片段数量。 gamma:折扣因子。 返回: V:一个将状态映射到价值估计的字典。 """ # 初始化字典 # defaultdict 避免检查是否存在 state_returns_sum = defaultdict(float) state_visit_count = defaultdict(int) V = defaultdict(float) # 最终价值估计 for i_episode in range(num_episodes): # 打印进度 if (i_episode + 1) % 10000 == 0: print(f"\rEpisode {i_episode+1}/{num_episodes}", end="") # 按照策略生成一个片段 episode = [] state, _ = env.reset() # 获取初始状态 terminated = False truncated = False # 处理 gymnasium 可能的截断 while not terminated and not truncated: action = policy(state) next_state, reward, terminated, truncated, _ = env.step(action) episode.append((state, reward)) # 存储状态和*下一个*奖励 state = next_state # 为首次访问蒙特卡洛处理片段 states_in_episode = set([s for (s, r) in episode]) # 获取访问过的唯一状态 visited_in_episode = set() # 跟踪此片段中的首次访问 # 向后循环遍历片段 G = 0.0 # 初始化回报 # 片段存储 (S_t, R_{t+1}) 对。 # 我们向后迭代以计算 G_t = R_{t+1} + gamma*R_{t+2} + ... for t in range(len(episode) - 1, -1, -1): state, reward = episode[t] G = gamma * G + reward # 如果这是此片段中首次访问“state” if state not in visited_in_episode: visited_in_episode.add(state) state_returns_sum[state] += G state_visit_count[state] += 1 # 增量更新价值估计 V[state] = state_returns_sum[state] / state_visit_count[state] print(f"\nFinished {num_episodes} episodes.") return V # 运行预测 num_episodes = 500000 V_simple_policy = mc_prediction_first_visit(simple_policy, env, num_episodes) # 打印一些示例值(可选) # 示例:状态 (18, 7, False) 的价值 - 玩家点数 18,庄家显示 7,无可用 A print(f"\nExample state value V(18, 7, False): {V_simple_policy.get((18, 7, False), '未访问')}") # 示例:状态 (21, 10, True) 的价值 - 玩家点数 21,庄家显示 10,有可用 A print(f"Example state value V(21, 10, True): {V_simple_policy.get((21, 10, True), '未访问')}") env.close()代码说明simple_policy(state): 此函数实现我们固定的策略。它接受当前状态元组,如果玩家点数总和为 20 或 21,则返回 0(停牌),否则返回 1(要牌)。mc_prediction_first_visit(...):为方便起见,使用 defaultdict 初始化 state_returns_sum、state_visit_count 和 V。defaultdict(float) 将新键初始化为 0.0,defaultdict(int) 初始化为 0。主循环运行 num_episodes 次。在循环内部,使用 env.step(policy(state)) 逐步生成一个片段。我们存储 (state, reward) 元组,其中 reward 是在 state $S_t$ 中之后收到的 $R_{t+1}$。片段结束后,我们找到所有访问过的唯一状态 (states_in_episode)。注意:此行对于逻辑并非严格必需,但对于调试或分析可能有用。visited_in_episode 集合对算法来说才是重要的。内循环反向迭代遍历片段中的步骤(t = T-1, ..., 0)。$G$ 累积从步骤 $t$ 开始的折扣奖励。由于二十一点中 $\gamma=1$ 且中间奖励为 0,$G$ 在首次计算后实际上变成了片段的最终奖励($+1, -1, 0$)。if state not in visited_in_episode: 检查确保我们只处理该特定片段中状态首次出现时的回报。如果是首次访问,我们将该状态添加到 visited_in_episode,更新该状态的总回报和访问计数,并重新计算平均回报,即我们的估计值 $V(state)$。运行代码: 我们实例化环境,为大量片段运行预测函数(蒙特卡洛方法通常需要大量样本),并打印几个示例状态值。结果可视化估计所有可能二十一点状态的值是有用的,但可视化它们能提供更多认识。由于状态空间是三维的 (player_sum, dealer_showing, usable_ace),我们可以通过固定一个维度来创建二维图。我们将针对有可用 A 和没有可用 A 的情况,分别绘制估计的状态值 $V(s)$ 与 player_sum 的关系图,同时保持 dealer_showing_card 不变(例如,庄家显示 7)。import matplotlib.pyplot as plt def plot_blackjack_values(V, dealer_card=7): """绘制 V(s) 与玩家点数总和关系的辅助函数""" player_sums = np.arange(12, 22) # 相关的玩家点数总和 # 没有可用 A 的状态值 values_no_ace = [V.get((s, dealer_card, False), 0) for s in player_sums] # 有可用 A 的状态值 values_usable_ace = [V.get((s, dealer_card, True), 0) for s in player_sums] fig, ax = plt.subplots(figsize=(10, 6)) ax.plot(player_sums, values_no_ace, label='无可用 A', marker='o') ax.plot(player_sums, values_usable_ace, label='有可用 A', marker='x') ax.set_xlabel("玩家点数总和") ax.set_ylabel("状态价值(估计的 V)") ax.set_title(f"价值函数与玩家点数总和的关系(庄家显示 {dealer_card})") ax.legend() ax.grid(True) plt.show() # 运行预测后生成图 # 使用之前计算的 V_simple_policy plot_blackjack_values(V_simple_policy, dealer_card=7) plot_blackjack_values(V_simple_policy, dealer_card=2) # 另一个庄家牌的示例这些图显示了在庄家显示特定牌(例如 7 或 2)时,不同玩家点数总和的估计价值(预期回报)。您通常会发现,较高的玩家点数总和通常具有较高的价值,并且拥有可用 A 通常会增加价值,特别是对于爆牌风险不高的较低点数总和。简单策略的效果(20/21 点停牌)应该反映在那些点数总和附近的值中。这项实践练习展示了蒙特卡洛预测如何直接从经验中学习状态值,而无需环境模型。通过运行片段并平均每个状态观察到的回报,我们可以有效地估计 $V^\pi$。这为蒙特卡洛控制方法奠定了基础,在这些方法中,我们将使用类似的想法来估计动作值($Q^\pi$)并寻找最优策略。