
这次我们来看强化学习入门系列的第 17 篇蒙特卡洛方法。前面几篇已经把马尔可夫决策过程MDP和动态规划DP讲完了但细心的读者应该会发现一个问题动态规划的两大前提是已知状态转移概率和奖励函数也就是需要环境模型。真实场景里下棋对手的走法、游戏引擎的反馈、机器人关节的动力学方程绝大多数情况下我们是拿不到精确模型的。拿不到模型动态规划就废了。所以从这一篇开始我们正式进入强化学习中非常重要的一大分支无模型Model-Free方法。蒙特卡洛Monte CarloMC就是第一个要掌握的算法。这篇文章会做三件事。第一讲清楚蒙特卡洛方法的核心思想为什么只需要“完整回合的采样数据”就能估计价值。第二给出蒙特卡洛预测MC Prediction的完整 Python 实现用经典的 21 点游戏环境做测试。第三实现蒙特卡洛控制MC Control让智能体在没有环境模型的情况下直接学出最优策略。整个过程会配合可以复制的代码、判断标准、常见坑点和排查思路不只是讲概念而是保证你能在自己的电脑上把算法跑起来、看到价值函数和策略的变化。如果你正在学强化学习入门、准备刷强化学习算法、想理解深度强化学习底层逻辑这一篇值得看完。如果你只想要公式那看到第 2 节就够了如果你想要能跑的代码那直接跳到第 5 节。1. 核心能力速览先给一张总表明确本讲的定位和可交付物能力项说明本讲主题强化学习中的蒙特卡洛方法MC Prediction 与 MC Control前置知识MDP 四元组、策略、状态价值函数 V、动作价值函数 Q、动态规划策略迭代核心解决的问题在无环境模型条件下从完整回合采样中估计价值函数并优化策略依赖库Python 3.7NumPy配套环境简化版 21 点Blackjack回合制环境自实现无需额外安装算法能力支持状态价值估计、动作价值估计、ε-贪心策略探索、最优策略输出是否支持批量任务不涉及但支持一次性跑多个回合episode并做均值统计典型运行时间5000 回合训练普通 CPU 几秒内完成适合读者正在学习强化学习基础、需要理解 model-free 方法源头、准备过渡到 TD / DQN 的读者2. 蒙特卡洛方法要解决什么问题强化学习任务可以按“是否知道环境模型”分成两类。有模型Model-Based环境的状态转移概率 P(s|s,a) 和奖励函数 r(s,a) 已知代表方法就是动态规划包括策略迭代和价值迭代。无模型Model-Free环境模型未知只能通过与真实环境交互、收集数据来学习代表方法包括蒙特卡洛方法、时序差分学习TD、Q-Learning以及后续的深度强化学习 DQN、PPO 等。蒙特卡洛方法解决的是无模型场景下的一个基本问题给定一个策略如何估计这个策略的价值函数。动态规划的做法是依靠贝尔曼方程把当前状态的价值写成所有后继状态价值的加权和权重就是转移概率。蒙特卡洛的做法完全不同我不去猜环境模型而是让智能体带着当前策略真正去和环境玩很多局把每一局的完整轨迹状态-动作-奖励序列记录下来用这一局结束时获得的实际总回报来修正每个状态的价值估计。换句话说动态规划是“期望视角”利用模型计算数学期望蒙特卡洛是“经验视角”利用大量采样做平均。大数定律保证了只要采样回合足够多平均值就会收敛到期望。这就是蒙特卡洛方法能工作的数学基础。这里需要强调一个关键限制蒙特卡洛方法只能处理回合制任务episodic task也就是任务必须有明确的结束状态。棋类一局分出胜负、游戏一关结束、21 点一局牌打完都算回合制。如果是无限持续的控制任务比如某个机器人要一直保持平衡没有结束状态那蒙特卡洛方法就用不了得换成后续的 TD 方法。3. 蒙特卡洛方法的核心思想用完整回合的数据估计价值先明确符号。对于一个完整的回合我们得到一条轨迹S0, A0, R1, S1, A1, R2, ..., ST-1, AT-1, RT, ST在状态 St 之后获得的折扣总回报 Gt 定义为Gt R(t1) γ * R(t2) γ^2 * R(t3) ... γ^(T-1-t) * RT其中 γ 是折扣因子范围是 [0, 1]T 是回合结束的时刻。状态价值函数 Vπ(s) 的数学定义是“从状态 s 出发按照策略 π 行动后续回报 Gt 的期望”。动态规划用模型来算这个期望蒙特卡洛则用采样平均来逼近Vπ(s) ≈ 所有访问过状态 s 的回合中从 s 出发获得的回报 Gt 的平均值这个逻辑非常直观。你可以把它想象成做问卷调查我问很多个“从状态 s 出发打完一局你最后拿回来多少钱”然后把答案取平均这个平均值就是这个状态的价值。实现层面有一个细节要区分一个完整的回合中同一个状态可能被访问多次。比如在棋盘游戏中绕了一圈又回到同一个位置。这带来两种不同的估计方式首次访问蒙特卡洛First-Visit MC在一个回合中只使用状态 s 第一次被访问时的回报 Gt。这个回合中后面再次出现 s 时的回报不计入。每次访问蒙特卡洛Every-Visit MC在一个回合中状态 s 每次被访问时对应的回报 Gt 都计入平均。理论上首次访问 MC 的估计是无偏的方差略大每次访问 MC 也可以收敛且在某些任务中数据利用率更高。Sutton 的教材默认推荐首次访问 MC实现也更直接。本文的代码就采用首次访问 MC。4. 从预测到控制蒙特卡洛如何处理策略优化光会估计策略还不够强化学习的最终目标是找到最优策略。在动态规划里我们交替做两件事策略评估算出 V 或 Q和策略改进贪心地更新策略。蒙特卡洛控制也用同样的套路但有一个关键变化。先看策略改进。如果只有 V 函数想要做贪心改进就必须知道环境模型才能根据状态价值去挑选动作。但在无模型场景下我们没有转移概率所以 V 函数不够用。解决办法是把估计目标从 V 换成 Q 函数。动作价值函数 Qπ(s,a) 表示从状态 s 采取动作 a之后按策略 π 行动能获得的期望回报。它的蒙特卡洛估计方式和 V 完全一样在回合中记录每个 (s,a) 对之后的总回报取平均即可。有了 Q 之后策略改进就非常自然对于每个状态把策略改成选择 Q 值最大的动作。不再需要任何环境模型。# 贪心策略改进的核心逻辑 action np.argmax(Q[state])然后这里会暴露一个蒙特卡洛控制特有的问题如果当前策略是确定性的贪心策略那么智能体每次都只会选择当前 Q 值最高的动作很多其他动作根本没有机会被尝试。那些没尝试过的动作Q 值估计永远是初始值可能是一个不准确的偏置。这样策略改进只会在一个很小的动作子集里打转永远找不到真正的全局最优。这就是经典的探索与利用Exploration vs Exploitation权衡。蒙特卡洛控制有两种标准应对方案。方案一是探索性初始化Exploring Starts要求每一个回合开始时智能体以随机方式覆盖所有状态-动作对。这种方式在理论上是可行的但实际环境很难保证每个状态都能被随机地作为起点尤其是在状态空间很大的时候。方案二更常用把行为策略改为 ε-贪心ε-Greedy。也就是以 1-ε 的概率选择当前最优动作以 ε 的概率随机选择任意动作。这样即使某个动作当前看起来很差也有一定概率被采样到从而持续修正 Q 值估计。需要说明如果在蒙特卡洛控制中使用 ε-贪心策略那么目标策略我们最终要输出的优化策略和行为策略实际采样数据的策略就不再完全一致我们实际上是在做离策略off-policy学习。严格意义上的离策略 MC 需要使用重要性采样。入门阶段通常先使用探索性初始化来规避这个问题或者直接实现带 ε-贪心但不做重要性校正的版本把它理解为一种近似做法。本文采用探索性初始化 贪心策略改进的经典版本代码简单、逻辑清晰适合先建立直觉。5. 蒙特卡洛预测代码实现以 21 点为例编程环境只需要 Python 和 NumPy。21 点环境是强化学习教材里的经典教学环境状态空间小、回合短、有清晰的胜负奖励非常适合用来观察蒙特卡洛方法的收敛过程。5.1 环境准备import numpy as np from collections import defaultdict如果机器上还没有 NumPy先安装pip install numpy5.2 实现一个简化版 21 点环境class BlackjackEnv: def __init__(self): self.deck [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 10, 10] * 4 def reset(self): self.player [self._draw(), self._draw()] self.dealer [self._draw(), self._draw()] return self._state() def _draw(self): return int(np.random.choice(self.deck)) def _value(self, hand): v sum(hand) aces hand.count(1) while v 10 21 and aces 0: v 10 aces - 1 return v def _state(self): player_v self._value(self.player) dealer_v self._value([self.dealer[0]]) usable_ace 1 in self.player and player_v 11 return (player_v, dealer_v, usable_ace) def step(self, action): # action: 0 stick(停牌), 1 hit(要牌) if action 1: self.player.append(self._draw()) if self._value(self.player) 21: return self._state(), -1.0, True return self._state(), 0.0, False # 停牌后庄家补牌到不小于17 while self._value(self.dealer) 17: self.dealer.append(self._draw()) p self._value(self.player) d self._value(self.dealer) if d 21 or p d: reward 1.0 elif p d: reward 0.0 else: reward -1.0 return self._state(), reward, True这个环境把 A 记作 1同时计算一个“可用 A”标志位。状态用三元组表示玩家当前点数、庄家的明牌点数、玩家手牌是否有可作 11 的 A。动作就两个要牌hit和停牌stick。5.3 编写蒙特卡洛预测函数def mc_prediction(env, policy, episodes10000, gamma1.0): returns_sum defaultdict(float) returns_count defaultdict(float) V defaultdict(float) for _ in range(episodes): episode [] state env.reset() done False while not done: action policy(state) next_state, reward, done env.step(action) episode.append((state, action, reward)) state next_state visited set() G 0.0 for state, action, reward in reversed(episode): G gamma * G reward if state not in visited: visited.add(state) returns_sum[state] G returns_count[state] 1 V[state] returns_sum[state] / returns_count[state] return V这里的实现逻辑是用当前策略 π 采样完整回合。记录回合中每一步的状态动作奖励。逆序计算每一步的折扣回报 G。如果状态是首次访问则把 G 加入该状态的回报总和并更新平均价值。5.4 定义一个简单策略并运行预测先测试一个非常简单的策略玩家点数小于 20 就继续要牌否则停牌。def simple_policy(state): player_v, dealer_v, usable_ace state return 1 if player_v 20 else 0 env BlackjackEnv() V mc_prediction(env, simple_policy, episodes5000, gamma1.0) # 输出部分状态的价值作为观察样本 for key in [(18, 5, False), (15, 10, False), (20, 7, True)]: print(f状态 {key} 的估计价值: {V[key]:.3f})运行后你会看到每个状态都对应一个估计价值。这里需要提醒由于 21 点是随机性很强的环境每次运行结果会有小幅度波动但整体规律是稳定的点数接近 20 或 21 的状态价值偏高点数偏低的价值会变成负数。这就是蒙特卡洛预测的直观输出。判断预测是否成功不只看数字还要看规律。价值函数应当平滑地随玩家点数变化点数越高胜率越大同时庄家明牌越大价值越低。5.5 首次访问与每次访问的对比前面说过同一回合中同一状态可能出现多次。初次实现时可以先照着上面的代码用首次访问。想观察差异可以改成每次访问版本把 visited 判断去掉每个状态-回报对都计入平均。从输出上看两者都能收敛到类似的价值但每次访问 MC 在状态空间小的时候收敛轨迹更稳定。6. 蒙特卡洛控制代码实现学出最优策略接下来用蒙特卡洛控制来直接学出最优策略。沿用上面的 21 点环境使用基于 Q 函数的首次访问 MC配合探索性初始化。6.1 实现基于 Q 的蒙特卡洛控制def mc_control_exploring_starts(env, episodes10000, gamma1.0): Q defaultdict(lambda: np.zeros(env.action_space)) returns_sum defaultdict(lambda: np.zeros(env.action_space)) returns_count defaultdict(lambda: np.zeros(env.action_space)) policy {} for _ in range(episodes): episode [] state env.reset() # 探索性初始化随机选一个初始状态/动作这里环境已经随机发牌可以直接采随机动作 done False while not done: if state in policy: action policy[state] else: action np.random.choice(env.action_space) next_state, reward, done env.step(action) episode.append((state, action, reward)) state next_state visited set() G 0.0 for state, action, reward in reversed(episode): G gamma * G reward if (state, action) not in visited: visited.add((state, action)) returns_sum[state][action] G returns_count[state][action] 1 Q[state][action] returns_sum[state][action] / returns_count[state][action] # 用当前 Q 的函数更新贪心策略 for state in set([s for s, a, r in episode]): best_action np.argmax(Q[state]) policy[state] best_action return Q, policy注意这里 environment 需要定义self.action_space所以给 BlackjackEnv 加一个属性self.action_space 2探索性初始化在 21 点环境中有个自然优势每局开始是随机发牌的天然的回合起点覆盖了各种玩家点数、庄家明牌、可用 A 的组合。所以不需要额外做动作强制随机只需要在还没学出策略时随机选择动作即可。6.2 输出学到的策略实践中最有价值的输出是策略表。玩家应该根据自己点数和庄家明牌决定要牌还是停牌。写一段查看策略的函数env BlackjackEnv() Q, policy mc_control_exploring_starts(env, episodes10000, gamma1.0) # 输出去掉可用A状态下的策略1 表示要牌0 表示停牌 for player in range(12, 21): row [] for dealer in range(1, 11): state (player, dealer, False) row.append(policy.get(state, 1)) print(player, :, .join([要 if a 1 else 停 for a in row]))输出是一张 9 行 × 10 列的行动表。你会发现玩家点数低时大部分要牌点数到 20 基本都停牌庄家明牌较大时玩家更倾向要牌去搏一搏。这和 21 点的经验策略基本一致。这张表格就是蒙特卡洛控制输出的“最优策略”的可视化结果。6.3 判断控制是否成功策略是否符合常识点数过高不要牌点数过低继续要牌。训练过程是否稳定可以每隔 1000 回合输出一次策略观察策略是否还在剧烈变化。价值是否收敛Q 表更新幅度逐渐变小。7. 蒙特卡洛方法的局限性入门阶段如果把蒙特卡洛方法理解透了后续学 TD、DQN 都会轻松很多。但也要清楚它的问题不然实战时容易踩坑。第一必须等一个完整回合结束才能学习。如果一局棋要下几百手、游戏一关要打很久那么学习效率会非常低。智能体必须不断收集回合数据而不能在每一步之后立刻更新。第二方差很大。蒙特卡洛用完整回合的总回报作为观测值总回报受整条轨迹长度的随机性影响非常大。同一状态出发不同回合的回报可能天差地别。而后续要讲的 TD 方法因为每一步只用一步奖励加后续价值的估计方差会小很多。第三探索问题难以彻底解决。探索性初始化在真实系统中不总是可行ε-贪心与离策略采样之间又有偏差。这也是为什么实际工程中很少直接用纯蒙特卡洛控制而更多使用 TD 类方法和深度强化学习。8. 资源占用与性能观察虽然蒙特卡洛方法不涉及 GPU、大模型但作为一手代码实验还是可以观察一下资源占用和性能表现。在跑上面代码时内存占用主要取决于状态字典的大小。21 点环境状态空间只有 200 个左右跑 5000 回合内存占用可以忽略不计。如果你后续把 MC 用到更大的环境比如迷宫或棋盘类游戏状态字典会膨胀需要注意 defaultdict 和 Python 对象的开销。性能瓶颈通常出现在回合内部循环每决策一步都要走一次环境 step而 step 内部有列表操作。像 21 点这种回合很短的任务10000 回合也只需要几秒钟。但如果是长回合任务episode 的长度会直接影响计算量因为每个回合末尾还要逆序遍历一次全部轨迹计算回报。这时候应该控制回合数或者考虑引入终止条件提前截断。CPU 和 GPU 的差异在这里不必关注因为纯 Python 代码的单核运行很容易成为瓶颈。想提速可以改成向量化批量环境一次采样多个回合。这个优化思路在后续做 API 批量任务、测并发时同样重要。9. 常见问题与排查方法问题现象可能原因排查方式解决方案价值函数全是 0策略采样没有回合结束episode 为空检查环境 step 是否一直返回 doneFalse检查 stop 条件是否触发代码运行很慢回合数量过大或回合内循环过长统计单回合平均步数减少 episodes或精简环境 step 内部计算策略从头到尾不变或很差探索不足某个动作从未被采样打印每个 (s,a) 的访问次数增大探索概率或使用探索性初始化21 点中“可用A”状态价值偏高样本量不足特殊状态出现少检查 returns_count 中该状态的出现次数增加回合数Q 表不断震荡探索策略过强新数据不断推翻旧估计打印每千回合的平均策略变化率适当衰减 ε或者固定随机种子对比报错 KeyErrorstate 不在 Q 字典中查看访问状态的逻辑用 defaultdict 或 get() 方法兜底收益一直为负且不改善策略死循环或者奖励设置有问题单独随机跑一个回合打印轨迹人工检查奖励符号转置10. 最佳实践与学习建议把蒙特卡洛方法跑通只是第一步。为了让后续学习更顺这里给几条实践建议。先在小状态空间验证正确性。不要一上来就接大环境。21 点这种状态空间很小的任务价值函数可以直接打印出来观察能快速发现逻辑错误。如果直接在复杂环境上调代码很难判断是算法问题还是环境问题。保留一份最小可运行脚本。把环境、MC 预测、MC 控制分别拆成独立函数方便随时回归测试。每次改动只动一个模块。关注访问次数不要只看价值。蒙特卡洛估计在小样本下偏差很大。判断一个状态的价值是否可信之前先看 returns_count 的次数。次数少于几十次的状态价值基本不可靠。验证时固定随机种子。比如np.random.seed(0)。这样跑多轮对比的时候代码每次的结果是一致的方便调试。如果发现每次运行策略变化极大可能就是样本不足。理解贪心策略与探索的权衡。代码里直接用了探索性初始化加贪心更新。在后续 DQN 中会看到同样的思想只是从表格变成了神经网络。现在把这一点想清楚后面看 ε-greedy、经验回放会容易得多。在实际项目中使用时还要注意一个边界如果任务不是回合制或者回合非常长、终止条件稀少蒙特卡洛方法就不合适。这时候应该切换到 TD 方法比如后一篇要讲的时序差分学习。11. 总结与下一步这一篇做了两件事用蒙特卡洛预测从完整回合中估计状态价值用蒙特卡洛控制从交互数据中学出 Q 函数和最优策略。核心思想一句话没有模型就用大量采样取平均。最值得记住的点是把 V 换成 Q 来摆脱对转移概率的依赖以及探索性初始化或 ε-贪心解决探索问题的必要性。对于初学者最关键的操作是跑通mc_prediction和mc_control_exploring_starts这两个函数然后用策略表和常识做对比。最容易踩的坑则有两个一是某个动作从未被采样导致策略偏差二是用首次访问和每次访问混淆导致预测逻辑不一致。蒙特卡洛方法虽然是 model-free 的起点但它的“回合结束才能学习”特性限制了应用范围。下一篇我们会讲时序差分学习TD它只需要一步或几步就能更新价值是 Q-Learning 和 DQN 的直接理论基础。如果能先把蒙特卡洛的“采样-平均-改进”闭环消化掉再去看 TD 的 bootstrapping 会非常顺畅。建议把本文的代码整理成一个mc_blackjack.py脚本保存后续学习 TD 时可以直接复用这个环境对比两种方法的价值收敛曲线。