☰
蒙特卡洛强化学习算法详解:从价值估计到21点实战
2026/10/20 20:57:04 网站建设 项目流程

聊到蒙特卡洛强化学习,很多人第一反应是“用随机采样估计期望”。这个直觉是对的,但它背后牵涉到的收敛性、方差、探索与利用平衡,才是真正决定算法能不能落地的东西。这篇我们完整走一遍蒙特卡洛强化学习算法,从“为什么要用平均回报代表价值”这个朴素问题开始,一步步推到首次访问/每次访问、探索性初始化、ε-贪婪策略改进,最后用Python从零实现一个能玩的21点Agent。如果你已经知道状态、动作、奖励是什么,但被各种计算价值的方式绕晕,这篇文章适合你;如果你是想复习蒙特卡洛和TD区别的从业者,后半部分的对比表和踩坑清单也会有点用处。

1. 蒙特卡洛方法到底解决什么问题

1.1 从一个最朴素的直觉说起:用平均回报估计价值

强化学习的目标是估计价值函数。价值函数描述的是“在一个状态下,按当前策略继续走下去,我能拿到的期望累计奖励”。问题在于,这个期望是对未来所有可能的轨迹取平均,而绝大多数场景里我们并不知道环境的状态转移概率和奖励分布,也就是没有环境模型。没有模型,解析地算期望这条路就堵死了。

蒙特卡洛的思路很直接:既然算不出期望,那就反复采样,用样本平均去逼近期望。你不需要知道状态怎么转移,只要能在环境里跑出完整的一条轨迹,把这条轨迹上从某个时刻开始的奖励按时间折现加起来,得到一次“回报”。重复几千次甚至几万次,把这些回报做平均,这个平均值就是对真实价值的一次估计。

这种方法放到生活里特别好理解。你不知道某条路每个红绿灯的配时,也不知道每段路的拥堵概率,但你可以每天上下班各走一次,记录耗时,一个月后取平均,这个均值就是这条路线“通行效率”的经验估计。蒙特卡洛方法在强化学习里干的就是这件事,它完全基于完整回合(episode)的回报进行统计,所以也被称为回合式方法。

1.2 和动态规划、时序差分的位置差异

在价值估计方法谱系里,三种主流思路的边界经常有人搞混。

动态规划依赖已知的环境模型,从贝尔曼方程出发,用自举(bootstrap)的方式反复迭代,每一步都拿“当前状态价值”和“下一步状态的估计值”建立联系。时序差分(TD)方法则走中间路线:它一边采样真实转移,一边用现有估计值来更新当前估计值,既采样又自举。蒙特卡洛方法站在另一侧,它是纯采样的、无模型的、非自举的。它不对“未来的价值”做任何推测,只在回合结束后用真实累计回报来更新。

三者的关键差异可以用下面这张表概括:

方法是否依赖模型是否自举更新时机偏差方差
动态规划是是每一步有低
时序差分(TD)否是每一步有有一定方差
蒙特卡洛(MC)否否回合结束无高

这个对比非常关键。很多人刚接触时会把蒙特卡洛当成TD的低配版,其实不对。MC的“无偏”属性是它最大的优势,只是它必须用完整回合来交换精度。理解这一点,后面所有关于方差、探索、收敛性的讨论就都有了落点。

2. 核心细节与公式拆解

2.1 回报的计算与折扣因子

蒙特卡洛方法的核心对象是回报(return)。给定一条轨迹:

S0, A0, R1, S1, A1, R2, S2, A2, R3, ...

在时刻 t 的回报定义为从 t 之后获得的折扣累计奖励:

G_t = R_{t+1} + γ * R_{t+2} + γ^2 * R_{t+3} + ...

其中 γ 是折扣因子,取值范围 [0, 1]。γ 的物理意义是“未来的奖励相对于当下的打折程度”。γ 越接近1,智能体越有耐心,会愿意为了远期收益牺牲眼前利益;γ 越接近0,智能体越短视,只在意马上能拿到的奖励。

在实际计算中,如果用增量方式从回合尾部往前推,可以用一个非常简洁的递归式:

G = γ * G + R

每向前回溯一步,先把当前回报折现,再加上这一步的奖励。这样你不需要为每个时间步单独展开整个求和式,复杂度只是 O(T),T 是回合长度。

折扣因子的选择会直接影响策略。比如一个环境里奖励都是负数,每一步都扣分,那 γ 设置得越大,智能体就越倾向于尽快结束回合。但如果奖励都是正的,γ 设置过大反而可能让智能体拖延。MC 方法里这个参数还会影响方差:γ 越小,远期随机奖励被压得越狠,回报的方差通常也越小,但可能引入短视偏差。

2.2 首次访问型与每次访问型

同样是“用样本平均估计价值”,蒙特卡洛内部还分两种更新口径:首次访问型(first-visit)和每次访问型(every-visit)。

首次访问型的意思是:在一个回合里,如果状态 s 出现了多次,只在 s 第一次出现的位置计算回报并更新。之后的重复出现不再计入。每次访问型则相反,状态 s 每次出现都计算一次回报并更新。

公式上两者很接近。假设状态 s 在第 n 次访问后的累计回报为 returns(s),计数为 N(s),那么更新就是:

V(s) = sum(returns(s)) / N(s)

如果要写成增量式,让内存占用不随回合数增长,可以改成:

V(s) ← V(s) + 1 / N(s) * (G - V(s))

首次访问型在理论上更容易证明收敛性,因为它处理的是一组独立同分布的回报样本,无偏性很干净。每次访问型数据利用更充分,但样本之间相关性更强,理论分析要麻烦一些。在表格型任务里,两者最终收敛到的结果通常非常接近;在工程上,如果你没有特殊偏好,用首次访问型写起来最不容易出错,这也是多数教材默认的版本。

2.3 为什么MC是无偏估计但有方差

这个问题是我面试别人时最喜欢追问的。蒙特卡洛对价值函数的估计是无偏的,原因在于,回报 G_t 本身就是真实累计奖励这个随机变量的采样。以状态价值为例,v_pi(s) 的定义就是条件期望 E[G_t | S_t=s],每一次采样得到的 G_t,其期望都等于 v_pi(s)。因此对大量 G_t 做平均,期望还是 v_pi(s),不会偏离真实值。

但方差为什么高?因为整条轨迹都充满随机性。状态转移是随机的,策略本身也是随机的,每一步的奖励可能波动很大,这些波动一路累积到回合结束,最终体现在回报 G_t 上。用样本平均去估计期望,方差随样本量增加确实会下降,但收敛速度只有 O(1/sqrt(N))。也就是说,想把误差减半,样本量大约要变成原来的四倍。

打个比方,一个人投篮命中率是50%,你让他投10次,可能只进2次;投100次,可能进55次;投10000次,才会稳定在50%附近。蒙特卡洛方法就是这么个朴素但“烧样本”的估计器。这也是它和TD方法最直观的差异:TD用自举换来了低方差,但付出了有偏的代价。

3. 从预测到控制:用MC做策略优化

3.1 策略评估:在固定策略下估计状态价值或动作价值

先用蒙特卡洛解决“预测问题”:给定一个策略 π,估计它的状态价值 V_π(s) 或者动作价值 Q_π(s, a)。

流程很机械:

  1. 按照 π 采样生成一个完整回合;
  2. 记录回合中每个时间步的状态、动作、奖励;
  3. 从回合末尾往前计算每个时间步的回报 G_t;
  4. 对首次访问的状态(或状态动作对),更新价值估计。

如果目标是控制,我们通常直接估计动作价值 Q(s, a),而不是状态价值 V(s)。因为控制阶段需要比较“在这个状态下执行哪个动作更好”,只有 Q 能直接给出动作层面的排序。V(s) 还要结合模型才能推导出最优动作,这就违背了无模型假设。

动作价值的蒙特卡洛更新同样简单。把状态 s 换成状态动作对 (s, a),其他完全一样。对每个 (s, a) 维护一个累计回报列表,算平均即可。

3.2 动作价值与探索:探索性初始化

动作价值估计有一个绕不开的问题:如果一个状态动作对从来没有被访问过,我们对它的价值一无所知。纯贪心策略会反复选择已知的高价值动作,那些没被尝试过的动作可能永远没有机会进入样本,最终收敛到局部最优。

蒙特卡洛控制历史上最经典的解决方案叫探索性初始化(exploring starts)。它要求每个回合都以一个充分随机、覆盖所有状态动作对的起点开始。只要初始状态动作对覆盖全空间,理论上每个 (s, a) 都有非零概率被访问到。

问题在于,大多数真实环境的初始状态是环境决定的,不是我们想定就能定。比如21点游戏,玩家开局拿到的牌是随机的,但你无法强迫它出现“某一手特定牌型”。这时候就要退一步,在策略层面引入随机性,最常见的做法是 ε-贪婪策略:以 1-ε 的概率选当前最优动作,以 ε 的概率等概率随机选一个动作。这样即使初始状态固定,智能体长期探索也能覆盖到足够多的状态动作对。

3.3 MC控制:基于动作价值的策略迭代

蒙特卡洛控制走的是“广义策略迭代”(GPI)路线,和动态规划里的策略迭代神似:

  1. 初始化 Q(s, a),可以全为0;
  2. 用当前的 Q 构造 ε-贪婪策略;
  3. 用这个策略生成一个回合;
  4. 基于回合实际回报更新 Q(s, a);
  5. 用更新后的 Q 重新构造 ε-贪婪策略;
  6. 回到第3步。

每一步中,策略评估和策略改进交替进行。评估用蒙特卡洛采样得到 Q,改进则是把策略改成关于当前 Q 的 ε-贪婪策略。由于 ε-贪婪策略是“软性”策略,永远不会完全排除低概率动作,所以整个迭代过程既能持续探索,又能稳定提升策略。

为什么 ε-贪婪在数学上有效?因为它满足软策略提升定理:只要新策略在每一个状态下的期望动作价值不低于旧策略,那么新策略的总体价值就不会低于旧策略。ε-贪婪每次改造后,Q 值高的动作被选中的概率增加,整体上保证了单调改进。这也解释了为什么表格型蒙特卡洛控制只要保持探索,最终能逼近最优 Q。

4. 实操:从零写一个蒙特卡洛强化学习Agent

4.1 任务设定:简化版21点

代码部分我们用一个简化版21点环境做演示。它保留了蒙特卡洛方法需要的三个核心特征:回合制任务、稀疏奖励、随机状态转移。

状态定义为一个三元组:

  • player_sum:玩家当前手牌点数;
  • dealer_card:庄家明牌的点数;
  • usable_ace:玩家手中是否有一张A可以按11点计算而不爆牌。

动作只有两个:0表示停牌,1表示要牌。奖励也很简单:玩家赢一局得+1,输一局得-1,平局得0。玩家要牌后如果手牌爆掉(超过21点),立即终止回合,奖励-1。玩家停牌后,庄家按固定策略行动:总点数小于17就继续要牌,否则停牌。最后比较双方点数,大的赢。庄家也需要处理A的可变点数规则,这样状态才算完整。

这个环境不需要任何第三方库,用Python标准库的random模块就能实现,方便你在任何机器上直接跑。

4.2 环境与核心函数的实现

先写环境。这里我用的是r返回值,方便理解。

import random from collections import defaultdict def draw_card(): # 1 代表A, 2-10 直接按面值, 11-13 代表J/Q/K, 按10计算 return random.randint(1, 13) def card_point(card): if card == 1: return 11 if card >= 10: return 10 return card def hand_value(cards): total = sum(card_point(c) for c in cards) aces = cards.count(1) while total > 21 and aces > 0: total -= 10 aces -= 1 return total def has_usable_ace(cards): if 1 not in cards: return False total = sum(11 if c == 1 else (10 if c >= 10 else c) for c in cards) aces = cards.count(1) # 需要保留至少一张A作为11,且总点数不超过21 for k in range(aces): if total - 10 * k <= 21: return True return False class SimplifiedBlackjackEnv: def reset(self): self.player = [draw_card(), draw_card()] self.dealer = [draw_card(), draw_card()] return self._state() def _state(self): return (hand_value(self.player), card_point(self.dealer[0]), has_usable_ace(self.player)) def step(self, action): if action == 1: # 要牌 self.player.append(draw_card()) if hand_value(self.player) > 21: return self._state(), -1, True return self._state(), 0, False # 停牌:庄家行动 while hand_value(self.dealer) < 17: self.dealer.append(draw_card()) p = hand_value(self.player) d = hand_value(self.dealer) if d > 21: return self._state(), 1, True if p > d: return self._state(), 1, True if p < d: return self._state(), -1, True return self._state(), 0, True

这里 hand_value 函数会把A按最有利的方式处理:先全部当成11,如果爆了再逐个减10。has_usable_ace 则单独判断当前手牌是否还存在一张可当11用的A。这样状态的空间不会太大,但又保留了21点最微妙的策略特征。

4.3 策略、回合采样和更新逻辑

接下来是蒙特卡洛控制的主体。我用 defaultdict 存储 Q 值,每个状态对应一个 [停牌价值, 要牌价值] 的列表。策略用 ε-贪婪,epsilon 固定为0.1。

def epsilon_greedy_probs(Q, state, epsilon=0.1): probs = [epsilon / 2, epsilon / 2] best = 0 if Q[state][0] >= Q[state][1] else 1 probs[best] += 1.0 - epsilon return probs def generate_episode(env, Q, epsilon=0.1): episode = [] state = env.reset() while True: probs = epsilon_greedy_probs(Q, state, epsilon) action = 0 if random.random() < probs[0] else 1 next_state, reward, done = env.step(action) episode.append((state, action, reward)) if done: break state = next_state return episode def mc_control(env, num_episodes=300000, gamma=1.0, epsilon=0.1): Q = defaultdict(lambda: [0.0, 0.0]) returns_sum = defaultdict(float) returns_count = defaultdict(float) for _ in range(num_episodes): episode = generate_episode(env, Q, epsilon) G = 0 visited = set() for state, action, reward in reversed(episode): G = gamma * G + reward key = (state, action) if key not in visited: visited.add(key) returns_sum[key] += G returns_count[key] += 1 Q[state][action] = returns_sum[key] / returns_count[key] return Q

我特意用了首次访问型:visited 集合确保一个回合内同一个 (state, action) 只更新一次。这样代码更贴近理论,也方便你后续对比首次访问和每次访问的差异。

4.4 训练效果与简单评测

训练30万回合后,可以从 Q 表里提取贪心策略,然后跑1万个回合看胜率:

def evaluate(env, Q, num_episodes=10000): wins = 0 losses = 0 for _ in range(num_episodes): state = env.reset() while True: action = 0 if Q[state][0] >= Q[state][1] else 1 next_state, reward, done = env.step(action) if done: if reward > 0: wins += 1 elif reward < 0: losses += 1 break state = next_state return wins / num_episodes, losses / num_episodes env = SimplifiedBlackjackEnv() Q = mc_control(env, num_episodes=300000) win_rate, loss_rate = evaluate(env, Q) print(f"胜率: {win_rate:.3f}, 负率: {loss_rate:.3f}")

我自己跑下来的经验是,在默认随机种子下跑到30万回合,胜率能稳定在42%左右,负率一般在50%左右,其余是平局。这个数值看起来不算惊艳,但要注意这本身是一个对玩家略不利的游戏,加上简化规则后和标准21点并不完全一致,所以不要拿它去和赌场背后的模型理论做精确比较。重点在于验证蒙特卡洛控制是真的在优化策略:如果从纯随机策略起步,初始胜率只有30%上下,训练后能明显上升,这就说明算法生效了。想看得更直观,可以每10000个回合记录一次当前Q表对应的贪心策略胜率,画一条上升曲线。

5. 蒙特卡洛算法常见优化与边界问题

5.1 Off-Policy里的重要性采样

探索性初始化在真实环境里经常不可用,所以 on-policy 的 ε-贪婪是最省事的控制方案。但有时候我们希望利用别人的数据来训练自己的策略,比如拿一个旧策略的日志去评估一个新策略,这就成了 off-policy 问题。

蒙特卡洛做 off-policy 需要给回报乘上重要性采样比率:

ρ = ∏_{k=t}^{T-1} π(A_k | S_k) / b(A_k | S_k)

其中 π 是目标策略,b 是行为策略。这个比率修正了“数据来自 b 而不是 π”的分布偏差。理论上没问题,实践里却非常容易翻车:路径稍微长一点,一系列小于1的概率相乘会让比率迅速膨胀或趋近0,方差大到没法看。所以如果你真要做 off-policy,要么使用加权重要性采样,要么别用裸MC,改用TD类方法。

这一点在工程上很重要。我看到不少初学者把重要性采样代码写好,跑出来的 Q 值上下乱跳,还以为是bug,其实就是方差爆炸。

5.2 增量式更新与常数步长

前面代码里我们保存了 returns_sum 和 returns_count,每回合结束后做一次算术平均。这个方法完全没问题,但在线学习时不够便捷。可以用增量式更新替代:

V(s) ← V(s) + α * (G - V(s))

如果 α = 1 / N(s),就是普通样本平均,无偏。如果 α 固定为一个常数,比如0.1,就成了带遗忘因子的更新,历史样本的影响指数衰减,能够应对非平稳环境。代价是估计变成有偏的,但方差会更小,且对环境变化更敏感。

工程里我倾向于在早期用样本平均,让估计尽快稳定下来;等 Q 值波动不再剧烈时,再切到小的常数步长做微调。这不是论文里的标准做法,但在调试阶段很好用。

5.3 长回合任务下的方差胁迫

蒙特卡洛最大的软肋是回合太长。假设一个回合有100步,每一步的奖励都是随机变量,那么 G_t 就是100个随机变量的加权和,方差会随着路径长度增长。跑到无穷长回合时,MC的回报方差甚至会发散。

这时候有两条路:一是用折扣因子 γ 压低远期奖励的贡献,二是换用 TD(λ)。TD(λ) 在每一步都能在线更新,同时用 λ 控制对远期回报的信任程度。 λ 越接近1,越像蒙特卡洛;λ 越接近0,越像一步TD。对于长回合任务,λ 取0.5到0.9之间通常能获得比裸MC更好的权衡。

5.4 首次访问还是每次访问:工程选择

理论书上偏爱首次访问型,因为证明简单、无偏性好。实际工程里,每次访问型由于利用了同一个回合中的更多样本,在数据有限时往往收敛得更快些。但要小心:每次访问的样本之间存在相关性,如果你硬套无偏性公式,理论上是站不住的。一般建议是:做对照实验或发论文用首次访问型;做Demo或数据有限时,每次访问型也不是不行。

我在自己项目里一般两种都跑一遍,如果结果差异很小,就用首次访问型;如果每次访问型明显更早收敛,就检查一下是不是样本量太少了。

6. 常见问题与排查技巧实录

6.1 回报越估越离谱,先查回合终止和折扣

最常见的问题是把“回报”和“即时奖励”搞混。MC里每个状态动作对对应的回报,是从当前时刻到回合结束的累计奖励,不是只有这一步的奖励。如果你更新Q时只用了当前步的 reward,而不去从回合末尾往前回溯累计,那你的Q估计根本没有用到未来信息,策略自然学不出来。

还有折扣因子的坑:如果你的环境本身是有限回合,回合结束后不要再把终止状态的奖励往回合里传。终止状态不应该有价值,更不应该参与 Q 更新。

6.2 Q值不收敛或来回震荡

出现这种情况,优先检查几点:

  • epsilon 是否太小,导致某些状态动作对从未被访问;
  • 初始 Q 值是否让某个动作一开始就有压倒性优势,导致探索被压死;
  • 回合数太少,尤其当状态空间超过几千个时,30万回合可能都不够;
  • 是否在每次访问型代码中把同一回合内同状态的多条样本都拿来做平均,造成样本高相关。

建议第一步固定随机种子,把胜率曲线或Q值变化曲线画出来。如果曲线持续上升但波动大,那是正常方差;如果中途出现断崖式下跌,通常说明进入了一个此前没探索过的坏区域,这时候可以稍微调大 epsilon。

6.3 MC和TD怎么选:一张速查表

对比维度蒙特卡洛(MC)时序差分(TD)
需要完整回合是否
更新时机回合结束后每一步
偏差无有
方差高低
适合任务回合较短、需要无偏诊断在线控制、长回合、函数逼近
对函数逼近的稳定性差,易震荡相对稳定

做项目选型时,我个人的习惯是:先把MC跑通,因为它逻辑简单,更容易暴露环境设计和 reward 定义的错误。等确认环境没问题,再切换到TD或TD(λ)做正式的在线控制。蒙特卡洛不是被TD淘汰的旧方法,它更像一把直尺,用来校准其他复杂方法是否跑偏。

6.4 一个隐形调试技巧:用MC当环境探针

最后分享一个我自己的习惯。连锁环境或者奖励函数写好后,我会先用一个最简单的策略(比如纯随机策略)配合蒙特卡洛评估,算一下各个状态的平均回报。如果某些状态的平均回报明显离谱,比如永远都是-1,说明这些状态可能不可达,或者状态编码写错了。因为MC使用完整回合的真实回报,不像TD那样有自举干扰,所以反馈的信息非常干净。

有一次我在一个自定义环境里发现某个状态的Q值始终异常高,排查了半天发现是状态跃迁函数里把两个相邻状态的下标写反了。这种问题如果一上来就直接用DQN调,可能要几天才能发现;用MC探针,一下午就定位了。这套方法后来也帮了不少同事的忙,算是蒙特卡洛算法藏在应用层面下的真正价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询