
1. 从零搭建强化学习认知框架蒙特卡洛方法在强化学习里的地位有点像做菜时的“尝味道”——你不需要知道锅里每种调料的精确化学反应只需要反复尝、反复调最终找到那个让人满意的配比。这个系列的前三篇我们聊了马尔可夫决策过程、动态规划、时序差分学习到了第四篇终于要啃蒙特卡洛这块硬骨头了。说实话我刚开始学强化学习的时候最怕的就是蒙特卡洛因为它的数学符号看起来又多又杂但真正动手写代码跑通之后才发现它的核心思想其实特别朴素用大量随机样本的平均值来逼近真实期望。这篇文章面向的是已经了解强化学习基本概念状态、动作、奖励、策略、价值函数的读者最好你已经跟着前三篇写过一些简单的Python代码。如果你还没接触过强化学习建议先补一下基础否则直接看蒙特卡洛部分可能会有点懵。我会从蒙特卡洛的基本原理讲起然后一步步推导到蒙特卡洛控制、重要性采样最后给出完整的Python实现。代码都是可以直接跑的环境用最经典的Blackjack和GridWorld不需要安装复杂的依赖Python 3.8以上加NumPy就够了。为什么蒙特卡洛在强化学习里这么重要因为它是第一个不依赖环境模型的方法。动态规划需要知道状态转移概率和奖励函数但现实中很多场景你根本拿不到这些信息——比如教机器人走路你没法写出精确的物理方程来描述每一个关节的摩擦力。蒙特卡洛就不一样它只需要能采样能跟环境交互拿到一条条轨迹然后从这些轨迹里学习。这个特性让它在实际应用中极其灵活也是后来深度强化学习能爆发的基础之一。提示本文所有代码基于Python 3.8和NumPy不需要GPU普通笔记本就能跑。完整代码我会按模块拆解你可以直接复制到Jupyter Notebook里逐段运行。2. 蒙特卡洛预测的核心逻辑与实现细节2.1 从“大数定律”到价值估计蒙特卡洛方法最底层的数学依据就是大数定律当样本量足够大时样本均值会收敛到真实期望。放到强化学习里假设我们想估计某个状态s的价值V(s)也就是从s出发按照策略π走到底能拿到的累积奖励期望。那最直接的做法就是让智能体从s出发按照π跑很多很多条完整的轨迹把每条轨迹从s开始的累积奖励也就是回报G记下来最后求平均。这里有个关键点蒙特卡洛必须等到回合结束才能更新。因为回报G的定义是整条轨迹的累积折扣奖励你不跑到终点就不知道G是多少。这跟时序差分学习不一样TD可以在每一步都更新MC必须等。这个特性决定了MC只适合有终止状态的回合制任务比如下棋、打牌、走迷宫不适合那种永远不结束的连续控制任务。用数学写出来就是V(s) E_π[G_t | S_t s] ≈ (1/N) Σ G_i(s)其中N是访问到状态s的次数G_i(s)是第i次访问s时算出来的回报。这个公式看起来简单但实现的时候有个坑首次访问和每次访问的区别。首次访问MC只把每个回合中第一次出现s时的回报计入平均每次访问MC则把每次出现s的回报都计入。理论上两者都收敛到V(s)但首次访问的方差更小实际中更常用。2.2 用Python实现首次访问蒙特卡洛预测我们拿一个简单的4x4网格世界来练手。规则很简单左上角是起点右下角是终点每走一步奖励-1走到终点回合结束。动作有上下左右四个方向撞墙就停在原地。import numpy as np from collections import defaultdict class GridWorld: def __init__(self, size4): self.size size self.start (0, 0) self.goal (size-1, size-1) self.actions [(-1,0), (1,0), (0,-1), (0,1)] # 上下左右 def step(self, state, action): if state self.goal: return state, 0, True next_state (state[0] action[0], state[1] action[1]) # 边界检查 if not (0 next_state[0] self.size and 0 next_state[1] self.size): next_state state reward -1 done (next_state self.goal) return next_state, reward, done def reset(self): return self.start环境写好了接下来是首次访问MC预测。核心逻辑是跑很多个回合每个回合记录状态、动作、奖励序列然后反向计算每个状态的回报G如果是首次访问就更新价值。def mc_prediction_first_visit(env, policy, episodes5000, gamma0.9): V defaultdict(float) returns_sum defaultdict(float) returns_count defaultdict(int) for _ in range(episodes): episode [] state env.reset() done False while not done: action policy(state) next_state, reward, done env.step(state, action) episode.append((state, action, reward)) state next_state # 反向计算回报 G 0 visited set() for t in range(len(episode)-1, -1, -1): state, action, reward episode[t] 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这段代码里有几个细节值得说。第一visited集合用来保证首次访问每个状态在一个回合里只更新一次。第二回报是反向计算的因为G_t R_{t1} γG_{t1}从后往前算只需要一个变量累加就行不用存整个回报数组。第三用defaultdict省去了初始化判断代码更干净。跑一下看看效果env GridWorld() # 随机策略 def random_policy(state): return np.random.choice(env.actions) V mc_prediction_first_visit(env, random_policy, episodes10000) for i in range(4): print([round(V[(i,j)], 2) for j in range(4)])你会看到靠近终点的状态价值更高因为负得少这符合直觉。但随机策略下价值分布比较均匀因为智能体经常乱走。2.3 增量更新与常数α的取舍上面的实现存了所有回报再求平均内存占用会随着回合数增长。实际中更常用增量更新V(s) ← V(s) (1/N(s)) * (G - V(s))这个公式的好处是每来一个新样本就更新一次不需要存历史。更进一步把1/N(s)换成常数α就变成了V(s) ← V(s) α * (G - V(s))用常数α意味着最近的样本权重更大适合环境会变化的场景。α取0.1到0.01之间比较常见太小收敛慢太大震荡。我实测下来网格世界这种确定性环境α0.1就够但如果环境有随机性α要调小一点。注意常数α的MC严格来说不是求平均而是指数加权平均。如果你需要精确的期望估计还是用1/N(s)更稳妥。3. 蒙特卡洛控制从策略评估到策略改进3.1 广义策略迭代在MC中的变形预测只是第一步我们真正想要的是找到最优策略。蒙特卡洛控制的核心思路跟动态规划一样都是广义策略迭代先评估当前策略的价值然后根据价值改进策略反复循环。但MC有个特殊问题很多状态-动作对可能永远不被访问到如果某个(s,a)没被采样过它的Q值就是初始值策略改进时可能会错误地选择它。解决办法是探索性初始化每个回合开始时随机选择一个状态-动作对作为起点保证所有(s,a)都有机会被访问。这个假设在理论上能保证收敛但实际中很难做到——你没法让智能体从任意状态开始。所以后来有了ε-软策略这个我们后面讲。先看探索性初始化的MC控制实现def mc_control_exploring_starts(env, episodes50000, gamma0.9): Q defaultdict(float) returns_sum defaultdict(float) returns_count defaultdict(int) policy {} for _ in range(episodes): # 随机选择初始状态和动作 state (np.random.randint(env.size), np.random.randint(env.size)) action np.random.choice(env.actions) episode [] done (state env.goal) while not done: next_state, reward, done env.step(state, action) episode.append((state, action, reward)) state next_state if not done: action policy.get(state, np.random.choice(env.actions)) # 反向更新Q值 G 0 visited set() for t in range(len(episode)-1, -1, -1): state, action, reward episode[t] 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)] # 策略改进 best_action max(env.actions, keylambda a: Q.get((state, a), 0)) policy[state] best_action return Q, policy这段代码里策略改进是嵌在反向循环里的每更新一个Q值就立刻改进对应状态的策略。这样做效率高但要注意策略改进必须基于更新后的Q值否则可能选到过时的最优动作。3.2 ε-软策略让探索更自然探索性初始化要求每个回合从随机状态开始这在很多任务里不现实。ε-软策略换了个思路不强制初始状态而是让策略本身带一点随机性。具体来说以1-ε的概率选当前最优动作以ε的概率随机选一个动作。这样每个(s,a)都有非零概率被访问到理论上能保证收敛到最优策略。ε的取值很关键。太大探索过度收敛慢太小探索不足可能卡在局部最优。常见做法是让ε随时间衰减比如从1.0开始慢慢降到0.1。我一般用线性衰减或者指数衰减实测下来线性衰减在网格世界这种小任务里效果不错。def epsilon_greedy_policy(Q, state, actions, epsilon): if np.random.random() epsilon: return np.random.choice(actions) else: q_values [Q.get((state, a), 0) for a in actions] max_q max(q_values) best_actions [a for a, q in zip(actions, q_values) if q max_q] return np.random.choice(best_actions)注意这里有个细节当多个动作的Q值相同时随机选一个避免总是偏向某个动作。这个在初期Q值都是0的时候特别重要否则智能体会一直选第一个动作。3.3 在线与离线学习的区别MC控制有两种更新时机在线on-policy和离线off-policy。在线学习用当前策略产生的轨迹来改进当前策略比如ε-软策略控制。离线学习可以用一个行为策略产生轨迹然后评估和改进另一个目标策略这就需要重要性采样。在线学习的优点是简单稳定缺点是探索和利用绑在一起ε必须一直保持。离线学习更灵活可以用历史数据但重要性采样的方差问题很头疼。实际项目中如果环境交互成本低我优先用在线学习如果数据珍贵比如医疗、金融场景离线学习更合适。4. 重要性采样离线学习的数学桥梁4.1 为什么需要重要性采样假设你有一个行为策略b(a|s)产生的轨迹但你想估计目标策略π(a|s)的价值。这两个策略不一样轨迹的分布就不一样直接平均会得到b的价值而不是π的。重要性采样通过给每个回报乘一个权重来修正这个偏差ρ Π [π(a_t|s_t) / b(a_t|s_t)]这个权重就是重要性采样比。如果π选某个动作的概率比b大权重就大于1说明这个样本在π下更常见应该放大反之则缩小。普通重要性采样直接用ρ乘以回报无偏但方差可能极大尤其是轨迹长的时候因为多个小于1的数连乘会趋近于0。加权重要性采样用ρ除以权重和来做归一化有偏但方差小得多。实际中几乎都用加权版本。4.2 加权重要性采样的Python实现def mc_off_policy_prediction(env, target_policy, behavior_policy, episodes10000, gamma0.9): Q defaultdict(float) C defaultdict(float) # 累积权重 for _ in range(episodes): episode [] state env.reset() done False while not done: action behavior_policy(state) next_state, reward, done env.step(state, action) episode.append((state, action, reward)) state next_state G 0 W 1 for t in range(len(episode)-1, -1, -1): state, action, reward episode[t] G gamma * G reward C[(state, action)] W Q[(state, action)] (W / C[(state, action)]) * (G - Q[(state, action)]) # 更新权重 pi_prob target_policy_prob(target_policy, state, action) b_prob behavior_policy_prob(behavior_policy, state, action) W * pi_prob / b_prob if W 0: break return Q这段代码有几个关键点。第一权重W是从后往前累积的因为重要性采样比是整条轨迹的连乘。第二如果W变成0目标策略不选这个动作后面的更新直接跳过因为再往前乘还是0。第三C用来做加权平均避免普通重要性采样的方差爆炸。4.3 重要性采样的方差问题与缓解技巧重要性采样最大的问题就是方差。我做过一个实验在Blackjack环境里用均匀随机策略作为行为策略目标策略是最优策略结果普通重要性采样的价值估计波动极大跑一万个回合都不收敛。加权版本好很多但收敛速度还是比在线学习慢。缓解方差有几个实用技巧。第一限制轨迹长度不要用太长的回合或者用折扣因子γ1来衰减远期回报。第二裁剪权重把ρ限制在某个范围内比如[0, 10]虽然引入偏差但方差可控。第三用边际重要性采样只考虑当前动作的比值而不是整条轨迹的连乘方差小但偏差大。实际中要根据任务特点权衡。提示如果行为策略和目标策略差异太大重要性采样的效果会很差。实践中尽量让行为策略覆盖目标策略的高概率动作比如用ε-软策略作为行为策略。5. 完整项目实战Blackjack策略优化5.1 环境搭建与状态编码Blackjack是强化学习教材里的经典环境规则简单但状态空间适中很适合练手。我用简化版规则无限牌堆玩家要牌直到停牌或爆牌庄家按固定规则要牌小于17必须要。状态用三元组表示(玩家当前点数, 庄家明牌, 是否有可用A)。def draw_card(): card np.random.randint(1, 14) return min(card, 10) # JQK都算10 def usable_ace(hand): return 1 in hand and sum(hand) 10 21 def sum_hand(hand): if usable_ace(hand): return sum(hand) 10 return sum(hand) def is_bust(hand): return sum_hand(hand) 21 def score(hand): return 0 if is_bust(hand) else sum_hand(hand)状态编码要注意玩家点数从12到21庄家明牌从1到10是否有可用A是0或1。总共200个状态每个状态两个动作要牌/停牌Q表大小是200x2很小跑起来很快。5.2 蒙特卡洛控制的完整流程def blackjack_mc_control(episodes500000, gamma1.0, epsilon0.1): Q defaultdict(lambda: np.zeros(2)) returns_sum defaultdict(float) returns_count defaultdict(float) policy defaultdict(lambda: 1) # 默认停牌 for ep in range(episodes): # 生成回合 player [draw_card(), draw_card()] dealer [draw_card(), draw_card()] episode [] while True: state (sum_hand(player), dealer[0], usable_ace(player)) action epsilon_greedy_policy(Q, state, [0,1], epsilon) episode.append((state, action)) if action 1: # 要牌 player.append(draw_card()) if is_bust(player): reward -1 break else: # 停牌 # 庄家要牌 while sum_hand(dealer) 17: dealer.append(draw_card()) if is_bust(dealer): reward 1 elif sum_hand(player) sum_hand(dealer): reward 1 elif sum_hand(player) sum_hand(dealer): reward -1 else: reward 0 break # 更新Q值 G 0 visited set() for t in range(len(episode)-1, -1, -1): state, action episode[t] 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)] # 策略改进 policy[state] np.argmax(Q[state]) return Q, policy跑50万回合大概需要一两分钟最终策略会收敛到一个比较合理的打法点数小于12时基本都要牌大于17时基本停牌12到16之间根据庄家明牌决定。这个策略跟教科书上的基本策略很接近说明MC确实学到了东西。5.3 结果可视化与策略解读把学到的策略画出来横轴是玩家点数纵轴是庄家明牌颜色表示要牌还是停牌。你会看到一条明显的分界线玩家点数越低越倾向于要牌庄家明牌越大越倾向于要牌因为庄家爆牌概率低你得冒险冲高点数。有个有趣的现象当玩家有可用A时策略会更激进因为A可以灵活算1或11爆牌风险低。这个细节MC也能学到说明它确实在从数据中挖掘规律。6. 踩坑记录与常见问题排查6.1 收敛慢或不收敛的排查思路MC控制最常见的坑就是跑了很多回合价值还是不收敛。我总结了几条排查路径问题现象可能原因解决方法价值震荡大学习率太大或ε太大减小α或ε用衰减策略价值不更新状态从未被访问增加探索检查状态编码策略反复横跳Q值差异小噪声大增加采样次数用加权平均收敛到次优探索不足增大ε或延长探索期我遇到过一次特别隐蔽的bug状态编码时把庄家明牌1和11搞混了导致两个不同状态映射到同一个key价值估计完全乱套。所以状态编码一定要仔细最好写个单元测试验证。6.2 重要性采样的数值稳定性重要性采样比是连乘很容易溢出或下溢。如果π和b差异大ρ可能变成inf或0。解决办法是用对数空间计算或者每步检查W是否超过阈值就截断。另外如果b_prob是0行为策略不选这个动作直接跳过这个样本否则会除零。# 数值稳定的权重更新 log_ratio np.log(pi_prob 1e-10) - np.log(b_prob 1e-10) W * np.exp(np.clip(log_ratio, -10, 10))6.3 探索与利用的平衡技巧ε-软策略的ε怎么选是个经验活。我的做法是前期ε1.0纯探索然后线性降到0.1后期保持0.1。如果任务状态空间大探索期要更长。另外可以用乐观初始化把Q值初始化为一个较大的正数这样智能体会优先尝试没访问过的动作自然实现探索。注意乐观初始化在奖励总是负的任务里要小心初始值设太高会导致智能体一直回避所有动作。这种场景用ε-软策略更稳妥。7. 从蒙特卡洛到时序差分下一步该学什么MC跑通之后你会发现它有个硬伤必须等回合结束才能更新。如果回合特别长比如下围棋学习效率极低。时序差分学习就是来解决这个问题的它用自举的方式在每一步都更新不需要等终点。下一篇我会详细讲TD(0)、TD(λ)和SARSA、Q-Learning这些是深度强化学习的直接基础。如果你现在就想动手改代码可以试试把MC控制改成每步更新看看效果差异。我试过在网格世界里把MC换成TD收敛速度快了大概5倍。这个对比实验能帮你直观理解两种方法的本质区别。另外蒙特卡洛在深度强化学习里也有应用比如蒙特卡洛树搜索MCTS就是AlphaGo的核心组件之一。MCTS用蒙特卡洛模拟来评估棋局结合神经网络做策略和价值的近似这个思路值得深入。如果你对博弈类任务感兴趣MCTS是绕不开的。代码仓库我整理了一个完整的notebook包含网格世界和Blackjack两个环境的MC实现还有策略可视化的代码。你可以直接跑改改参数看看效果。强化学习这东西看十遍不如跑一遍动手才是最快的入门方式。