强化学习笔记写到第七篇,我终于开始啃基于模型的RL(Model-Based RL)这条线了。在这条线上有两个绕不开的名字:Dyna算法和MCTS(蒙特卡洛树搜索)。要说清楚它们,得先从无模型方法的一个别扭之处说起。前面几篇笔记聊的Q-Learning、DQN、策略梯度,全都是无模型的路子:不学环境,只靠真实采样拿奖励信号硬刚。这个路线最大的问题是智能体不长记性——在一个环境里明明已经撞过一百次墙,下个回合照样撞。基于模型的RL解决的就是这个痛点:把环境的转移规律和奖励规律学出来,让智能体在"脑子"里提前推演,然后再决定怎么行动。
这篇笔记适合正在学强化学习、已经跑通DQN但想了解模型这条线的同学,也适合做机器人、游戏AI、路径规划项目时想引入"规划"能力的工程师。读完你至少能搞清楚三件事:第一,所谓"模型"在强化学习里到底指什么;第二,Dyna是怎么把真实经验和模拟经验掺在一起训练、从而大幅提升样本效率的;第三,MCTS那棵搜索树是怎么样一步步长出来的,以及这两个算法各自最容易翻车的地方在哪。
1. 为什么非要多学一个"环境模型":Dyna和MCTS的共同出发点
1.1 模型在强化学习里到底指什么
先统一一下口径。强化学习语境里说的"模型",不是指神经网络的模型,而是指对环境的动态特性的一个近似描述。一个MDP可以写成五元组,其中最关键的是状态转移函数 P(s'|s,a) 和奖励函数 R(s,a)。所谓基于模型的RL,就是把这两个东西显式地学出来或者直接拿到手,然后用它来指导智能体的决策。
模型有三个层次的来源。第一种是环境自带规则,比如棋类游戏的规则就是一个完美的转移模型,你走一步之后棋盘会变成什么样,完全确定,一行代码就能模拟。第二种是学出来的查表模型,这个在离散状态空间最常用,一个字典或者二维表,记录"在状态s做了动作a之后,会得到奖励r,转移到s'"。第三种是学出来的参数化模型,最常见的是用神经网络拟合 f(s,a) -> (r, s'),在连续控制任务里几乎全是这种写法。
为什么要刻意区分"模型已知"和"模型学习"?因为两者的难度完全不同。如果模型是现成的,那问题属于"规划"(planning),算法重点是怎么在已知动态的树或图上搜索好的动作序列,MCTS就是这类问题的典型解法。如果模型要自己去学,那就是真正的"基于模型强化学习",Dyna算法属于这一类:它把学模型和用模型做规划串成了一个闭环。理解这个区分,后面看各种算法就不会混淆。
1.2 学模型的收益和代价
先说说为什么值得花力气去学一个环境模型。第一个直接好处是样本效率。真实环境里走一步就是一步,数据是稀缺的;但模型一旦学出来,你可以在它的世界里无限采样,一条真实经验能"换算"成很多条模拟经验。第二个好处是安全。很多危险情况(比如机器人翻倒、系统参数越界)不适合让智能体在真实环境里去试错,在模型里提前试一遍,至少能筛选掉最离谱的动作。第三个好处是长视界。模型给了智能体一种"向前看"的能力,想几步之后的状态,很多决策就能提前避开陷阱、抓住后面才出现的奖励。
但代价也同样明显,而且这篇文章后半部分会反复提到:模型是错的。任何学出来的模型都有偏差,基于模型的RL是一把双刃剑——模型对了,规划事半功倍;模型错了,规划等于在错误的图纸上施工,干得越快错得越远。所以后面所有算法,本质上都是在"多利用模型"和"别被模型带偏"之间找平衡。Dyna选择的是"浅尝辄止":只把模型用在很短的价值更新上,并且持续用真实经验修正模型。MCTS则选择"深入虎穴":把模型当成一个模拟器,在当前决策点往未来推演很多步。两种哲学后面都会展开。
2. Dyna算法:把真实经验和"做梦"经验混在一起训练
2.1 Dyna框架的一句话概括
Dyna的思想其实朴素到让人怀疑半天:既然我已经从真实交互里学到了一个模型,那我除了用这条真实经验去更新Q值之外,为什么不用这个模型多"脑补"几条经验,再顺便更新几次Q呢?这句话展开就是Dyna-Q的核心循环:
- 在真实环境里执行动作,得到(s, a, r, s'),用标准的时序差分公式更新Q。
- 把这条经验存进模型表 Model(s, a) = (r, s')。
- 从模型表里随机抽n条历史经验,用一模一样的Q更新公式在模拟数据上再更新n次。
这就是所谓的"规划"。它不需要真的去环境里再次执行,完全发生在智能体的"脑子"里。你可以把Q-learning想象成一个只靠真实经历学习的选手,而Dyna是一个白天练习、晚上还会在梦里加练的选手。梦里练的起点都是白天的真实经验,画面可能略有偏差,但胜在量大、便宜,这就是样本效率的来源。
这里有一个容易被忽略的关键限制:模型只是"重放"见过的状态动作对,而不是去生成全新的状态。智能体只会抽那些至少真实见过一次的(s, a)。这个限制挡住了模型错误向没去过的地方外溢,是Dyna能在表格环境下表现稳定的重要原因。如果你把模型换成神经网络去生成全新状态,那就要小心了,那属于"想象力更丰富但也更危险"的路线。
2.2 一个能跑的查表型Dyna-Q实现
拿一个10x10的格子世界举例:智能体从左上角出发,走到右下角得奖励,中途有几堵墙。动作是上下左右,碰到墙就原地不动。下面给核心代码,模型用最简单的字典实现。
import numpy as np class TabularDynaQ: def __init__(self, env, gamma=0.95, alpha=0.1, epsilon=0.1, n_planning=50): self.env = env self.gamma = gamma self.alpha = alpha self.epsilon = epsilon self.n_planning = n_planning # Q表和模型表都初始化为空字典 self.Q = {} self.model = {} self.model_keys = [] def get_q(self, s, a): return self.Q.get((s, a), 0.0) def update_q(self, s, a, r, s_next): best_next = max((self.get_q(s_next, a2) for a2 in range(self.env.n_actions)), default=0.0) target = r + self.gamma * best_next self.Q[(s, a)] = self.get_q(s, a) + self.alpha * (target - self.get_q(s, a)) def epsilon_greedy(self, s): if np.random.rand() < self.epsilon: return np.random.randint(self.env.n_actions) qs = [self.get_q(s, a) for a in range(self.env.n_actions)] return int(np.argmax(qs)) def plan(self): # 从见过的状态动作对里随机抽n条,让Q值沿模型表扩散 if not self.model_keys: return for _ in range(self.n_planning): s, a = self.model_keys[np.random.randint(len(self.model_keys))] r, s_next = self.model[(s, a)] self.update_q(s, a, r, s_next) def train_one_episode(self): s = self.env.reset() done = False while not done: a = self.epsilon_greedy(s) r, s_next, done = self.env.step(s, a) # 第一步:真实经验直接更新Q self.update_q(s, a, r, s_next) # 第二步:把经验存进模型 if (s, a) not in self.model: self.model[(s, a)] = (r, s_next) self.model_keys.append((s, a)) # 第三步:利用模型做规划 self.plan() s = s_next代码里最核心的细节有两个。一个是self.model_keys数组:它记录所有真实见过一次的(s, a),planning就从这个数组里均匀取样,避免浪费时间去抽样没见过的状态。另一个是max_a' Q(s', a')的取法,模拟更新和真实更新用的是完全同一个公式,唯一的区别是数据来源不同——一个来自环境,一个来自模型表。
如果你把n_planning设成0,这个算法就退化成最普通的Q-learning。所以Dyna是Q-learning的一个超集,这也是为什么很多人说Dyna是理解"学习和规划的统合"最好的入门算法。跑这个代码你只要准备一个最简单的环境类,提供n_actions、reset()、step(s, a)三个接口就行,几乎所有强化学习库的toy环境都长这样。
2.3 planning步数n与收敛速度的直观理解
我在这个格子世界上对比过Q-learning和Dyna-Q。Q-learning找到最优策略大概需要几百个episode,而Dyna-Q(n=50)几十个episode就有明显的最优轨迹倾向。原因用一句话说:Q-learning的价值信息只能靠真实交互"踩"着传播,每走一步只能传给相邻的一个状态;Dyna靠着模型,一次planning循环里可以同时把价值信号广播给几十个历史状态,相当于给价值传播开了加速通道,从终点往回传的速度自然快得多。
代价是什么?n太大会让算法过早锁定模型里已经被强化过的路径,导致探索不足。我试过把n从50调到500,收敛速度反而没有继续提升,因为每一步都花大量时间做规划,同一批模拟经验被重复更新了太多次,而真实探索的步数并没有增加。对10x10这种规模的格子,n取20到50是一个合理区间;环境变大、奖励更稀疏时,可以适当往上加,但如果你发现收益曲线开始变得"头重脚轻",那就是n过量的信号。
2.4 一个Dyna的毛刺:Dyna-Q+的奖励修正
Dyna有个隐蔽的问题:模型一旦成型,planning只会反复抽取旧的(s, a),时间一长Q值会被过度打磨,而智能体对"没怎么访问过"的区域越来越不敏感。你可以理解成:一个人总在同一个梦里加练,对现实世界的新变化反而麻木了。在一些动态环境里,这会导致策略僵化。
解决思路很优雅。Dyna-Q+给每个状态动作对记一个"上次访问时间"τ,然后在planning的奖励上做一点手脚:把抽到的模拟奖励改成 r + κ√τ。τ越大说明这块地方被冷落越久,就给它的模拟奖励加一个探索奖金,诱使规划重新关注冷门区域。κ是个超参数,通常取很小的值如0.001到0.1。
实测提醒:Dyna-Q+的探索奖金κ一定要从小往大调。我在实验里把κ设成0.5,结果智能体开始在迷宫里"逛街",专门跑去刷冷门区域的探索奖金,最优路径反而被忘了。把κ降回0.01,行为立刻恢复正常。奖励塑形类参数对策略的影响往往是非线性的,不要凭感觉拍脑袋。
3. MCTS:在搜索树上做蒙特卡洛模拟
3.1 MCTS和Dyna的差别在哪
如果说Dyna是"全局价值更新的加速器",MCTS就是"局部决策的望远镜"。它俩虽然都算基于模型,但解决的问题完全不同。
Dyna适合状态空间不大、需要学一个长期价值函数的场景,它把Q函数存成全局表格,planning更新的是整张表,训好之后策略可以直接查表。MCTS则完全不管长期Q表,它只在决策那一刻,从当前状态出发,动态地长出一棵搜索树,把所有计算资源集中在"接下来怎么走"这一个问题上。每次决策结束后,这棵树通常直接扔掉,下一次决策重新长。这意味着MCTS不需要预先训练,它的"思考"发生在决策当场。
所以MCTS天然适合动作空间离散、又有确定性或随机模型的场景,最典型的就是棋类游戏:状态可以枚举,动作是落子位置,模型就是游戏规则本身。我最早接触MCTS就是实现一个井字棋AI,效果比纯规则AI好出一大截,而且代码量很小,非常适合用来理解搜索的本质。它的另一个应用场景是路径规划和策略类游戏,凡是"决策前需要往未来多看几步"的问题,都可以考虑MCTS。
3.2 四阶段和UCT公式
MCTS每次迭代分成四步,记住这四步就掌握了全部框架。
Selection(选择):从根节点开始,顺着树往下走,每一步都按UCT公式在子节点里挑一个,挑到叶子节点为止。这里的"叶"指的是树上还没被完整探索过的节点。Expansion(扩展):如果这个叶子节点还不是终止局面,就挑一个没访问过的动作,创建出一个新的子节点加入树中。Simulation(模拟):从新子节点开始,用一个默认策略(通常是随机动作)一路模拟到终止状态,得到一个结果值,赢、输或者分数。Backpropagation(回传):把模拟结果沿着刚才路径上的每个节点一路回传上去,更新每个节点的访问次数和累计价值。
重复这个迭代几百次、几千次,根节点的子节点访问次数就会告诉你:哪个动作被证明是好的、被模拟得最多。最后选访问次数最多的动作作为决策结果,而不是选平均价值最高的那个——这一点很多人会搞错,原因后面说。
UCT公式长这样:
UCT(s, a) = Q(s, a) + c * sqrt(ln(N_parent(s)) / N_child(s, a))第一项Q是平均回报,代表"经验上这个动作有多好";第二项是探索项,访问次数越少的子节点,这个值越大,会让算法优先去试试冷门动作。c控制探索强度,c越大越爱尝试冷门,c越小越守成。为什么最终要选访问次数最多的动作而不是Q最高的?因为访问次数隐含了"经过很多次模拟检验"这一层置信度,一个只被访问过一次但Q=1的动作很可能只是运气好,而访问次数最多的动作往往是在大量探索之后稳定胜出的选择。
这个公式给了一个很直观的类比:假设有两家餐馆,A你去过100次,平均评分7分,B你去过1次,评分9分。B虽然看起来更好,但样本太少不值得全信。UCT会继续给B分配一些"试吃"机会,探清楚它的真实水平,同时也不会冷落A。c就是决定你愿意花多少权重去试新店的参数。
3.3 一个极简MCTS实现
下面这个实现去掉了环境细节,只保留搜索骨架,环境需要提供get_next_state(s, a)、is_terminal(s)、get_reward(s)、legal_actions(s)四个接口。
import math import random class MCTSNode: def __init__(self, state, parent=None, action=None): self.state = state self.parent = parent self.action = action self.children = {} self.visits = 0 self.value = 0.0 def ucb(self, c=1.4): parent_visits = self.parent.visits if self.parent else self.visits if self.visits == 0: return float('inf') return self.value / self.visits + c * math.sqrt(math.log(max(parent_visits, 1)) / self.visits) def mcts(root_state, env, iterations=1000, c=1.4): root = MCTSNode(root_state) for _ in range(iterations): node = root # Selection: 一直往UCT最大的子节点走 while node.children: node = max(node.children.values(), key=lambda n: n.ucb(c)) # Expansion: 如果还有未扩展的动作,扩展一个 if not env.is_terminal(node.state): for a in env.legal_actions(node.state): if a not in node.children: child_state = env.get_next_state(node.state, a) node.children[a] = MCTSNode(child_state, parent=node, action=a) node = node.children[a] break # Simulation: 随机rollout到终止状态 sim_state = node.state while not env.is_terminal(sim_state): sim_state = env.get_next_state(sim_state, random.choice(env.legal_actions(sim_state))) reward = env.get_reward(sim_state) # Backpropagation: 沿着父链回传 while node is not None: node.visits += 1 node.value += reward node = node.parent # 选访问次数最多的动作 best_action = max(root.children, key=lambda a: root.children[a].visits) return best_action有两点值得说明。第一是selection阶段只在"已经扩展过"的子节点里走,新动作在expansion时一次性创建儿子,这是MCTS的常见写法,能保证树不会出现"有空壳节点却不知道往哪走"的情况。第二是uct函数在visits==0时返回无穷大,这保证每个子节点至少被完整探索一次,不然新节点永远因为样本少而被冷落,树就长不起来了。
实际跑这个代码你会发现,迭代次数对决策质量影响非常大。井字棋这种深度不大的游戏,几百次迭代就能打败纯随机策略;换到复杂度更高的棋盘游戏,几千次迭代可能都不够。所以MCTS落地时第一优化目标永远是"让每一次模拟更快",而不是盲目加迭代次数。
3.4 从随机模拟到神经网络引导
纯MCTS有个明显短板:Simulation用的是随机策略,一次rollout的结果方差很大,需要几千次模拟才能稳定。高端一点的玩法是让神经网络来"带路",这也是近年棋类AI统治级表现背后的底层逻辑。
具体改两处。选择阶段,UCT改成引入先验概率 P(s,a) 的形式:
UCT = Q(s, a) + c * P(s, a) * sqrt(ln N_parent) / (1 + N_child)P来自一个策略网络,告诉搜索"哪些动作大概率是好的",让树的生长从一开始就有方向,而不是平均撒网。第二处,Simulation阶段不再暴力随机模拟到终点,而是直接用价值网络对叶子节点打分,回传这个分数。这样一来,算法从"靠海量随机模拟堆效率"进化为"搜索引导加价值估计",同样的迭代次数下决策质量高很多。
我自己做小型棋盘游戏时对比过:纯随机rollout的MCTS要打到约3000次迭代才能稳定赢一个普通规则机器人;换成网络引导后,500次迭代的效果就超过了原先的3000次。代价是需要额外训练两个网络,并且网络和质量不好的时候,搜索反而会被带偏,这跟模型误差是同一个道理。
4. 模型误差这笔账:基于模型方法最容易翻车的三个现场
4.1 复合误差:一步错步步错的数学直觉
不管Dyna还是MCTS,只要模型是学出来的,就绕不开一个问题:模型误差会随着推演步数放大。假设模型每一步预测的转移误差平均是ε,你想推演h步之后的状态,累计误差不是hε,而是更快的非线性增长——因为第2步的输入是第1步的预测输出,误差在传递过程中还会相互作用,在混沌一点的动态系统里甚至是指数级发散。这跟传话游戏一样:一句话传十个人,最后基本不是原话。放在强化学习里,就是"想象中生龙活虎,落到真实环境里完全不是那么回事"。
这就是为什么所有基于模型的方法都在小心翼翼地控制推演步数,不是不想想得远,是不敢。MCTS敢推演几十步,是因为它的模型往往是规则本身,没有学习误差;一旦换成学出来的模型,绝大多数算法都把推演步数压到个位数,这是有原因的。
4.2 我在模拟实验里翻车的三个现场
现场一:连续环境学线性模型,推演5步直接发散。当时我用一个很简单的线性回归拟合连续控制环境的动态,单步预测误差看着不大,但用它生成5步轨迹去更新策略,策略越训越差。查了一下才发现,模型预测的第5步状态已经飘到正常状态空间之外,奖励函数在那个区域根本没有定义。解决方案是把推演步数砍到1到2步,模型只用来提供短期模拟,效果立刻正常,策略曲线重新开始上升。
现场二:Dyna-Q在稀疏奖励迷宫里,模型错记了一个关键转移。现象是智能体在某个墙角反复横跳,单局步数迟迟降不下去。我一开始以为是探索不够,把epsilon调大,没用。后来加了一行调试代码,在planning时把重放的(s, a, r, s')打出来,发现墙角状态相关的转移被疯狂重放,而真实交互却很少走到那里。再回头查模型表,发现那个(s, a)在很久以前只被真实访问过一次,记录的是"向左撞墙原地不动",但真实规则是"向左会滑到下一格"。模拟经验里这条错误被重复强化了几十次,Q值彻底被带偏。修复方式是对每个(s, a)记录真实访问次数,访问次数小于阈值时在planning抽样中降权,同时一旦真实经验再次访问到相同(s, a),立即覆盖模型表。跑了几百个episode之后,墙角横跳现象消失。
现场三:Dyna-Q+的探索奖金κ调太大,智能体变成"逛街模式"。前面2.4节说过,κ取0.5时探索奖金盖过了真实奖励,智能体宁可绕远路刷冷门状态也不走捷径。模型本身没错,是奖励塑形坏了。把κ降回0.01,路径选择恢复正常。这三个现场给我的共同教训是:调试基于模型的RL,最先要看的是模型自身的误差曲线和重放记录,而不是总盯着最终策略表现。如果模型在关键区域是错的,策略再优化也是在错误的图纸上优化。
4.3 缓解模型误差的常见措施
业界和学术界的常用手段,按优先级排是这样的。
第一,控制推演长度,这是最简单也最有效的。宁可只用模型做1到3步短推演,也不要追求"看得远"。第二,用模型集成量化不确定性。训练多个模型,如果它们在相同输入下给出的预测差异很大,说明这个区域不可信,就不让planning在这上面花时间。第三,选择性使用模型,只在模型误差小的区域生成模拟经验,误差大的区域坚决用真实经验补。第四,真实经验的优先级永远高于模拟经验——一条真实经验的价值大于几十条模拟经验,这是无数实验换来的经验法则。
提示:如果你发现自己的基于模型算法表现不稳定,先做一个最简单的检查:把planning关掉,看看model-free的基线效果如何。很多情况下,问题不是出在算法的价值更新部分,而是出在模型本身。保留一个无模型基线做对照,是所有基于模型项目的保命手段。
5. Dyna还是MCTS:选型思路和我的实战建议
5.1 表格型离散环境:Dyna简单直接
如果你的状态可以枚举、动作空间不大、状态转移相对干净——迷宫、网格、简单决策流程——我基本首选Dyna。它实现成本低、调参直觉强、样本效率提升明显,n_planning和κ两个核心超参也容易理解。唯一要提醒的是,Dyna适合"学长期价值"的问题,不适合需要"在这一个决策点往深处想"的问题。如果任务是走一步就要重新评估局势的那种实时决策,Dyna不够灵活。
5.2 需要深度前瞻的决策:MCTS
棋类、即时策略、路径规划这类场景,决策质量取决于你往未来看了多深,MCTS是更合适的选择。它的核心优势是动态建树、把算力聚焦在当下最有希望的动作上,不需要全局价值表,维度爆炸的问题天然被规避。缺点也很明显:模型或模拟器必须够快,因为每一个动作决策前都要做几百上千次模拟;如果模型是学出来的,误差问题比Dyna更敏感——毕竟MCTS的推演深度普遍更深,复合误差的杀伤力更大。
5.3 连续控制场景:别硬套Dyna,短时规划更现实
连续状态连续动作的空间里,查表模型根本不成立,值函数也很难用表格存。这类场景的通用做法是学一个神经网络动态模型,然后做短时规划或者短回放训练,也就是前面现场一里提到的思路:模型能信多少,就推演多远。不要指望模型能精确预测几十步之后的连续状态,这在大多数物理系统里都不现实。实际项目中,我见过不少团队试图用深度模型把整条轨迹都生成出来,最后无一例外都被复合误差折磨。短推演加频繁用真实数据校准模型,才是连续控制里的稳定打法。
5.4 一张选型表
| 判断维度 | Dyna | MCTS |
|---|---|---|
| 状态空间 | 离散、可枚举 | 离散或树状展开,动作可枚举 |
| 模型来源 | 学出来的查表模型即可 | 最好有现成或快速的模拟器 |
| 规划方式 | 全局Q表加速更新 | 从当前状态动态建搜索树 |
| 推演深度 | 浅,单步价值传播 | 深,可达几十步 |
| 对模型误差的容忍度 | 中等,持续交互可修正 | 偏低,推演越深误差越放大 |
| 典型场景 | 迷宫、网格、简单决策流 | 棋类、战术决策、规划 |
这张表是我自己选型时常用的判断口径,不一定覆盖所有边界情况,但足够帮你避开"在一个不合适的场景里硬套算法"最常见的坑。
6. 学习顺序与调试心得
6.1 如果你想上手这套东西,建议按这个顺序
先实现一个查表型Dyna-Q,在一个小格子里跑通,把n_planning从0调到100,亲眼看一看收敛曲线怎么变好、又怎么变饱和。这个过程会让你对"规划到底给学习加了什么"产生非常直观的体感。然后找一个棋盘环境实现MCTS,先跑随机模拟版本,再逐步加网络引导,对比同一迭代次数下的胜率变化。最后再去碰连续控制里的模型学习,那部分需要对模型误差和不确定性有足够的心理准备,建议放到前面两个都玩过之后再动手。
6.2 三个调试习惯强烈建议养成
第一,记录模型误差的实时曲线。对学出来的模型,每隔若干个episode用一批真实转移样本测一下预测误差,比如预测(s',r)和真实(s',r)的均方误差,误差暴涨的位置就是模型崩塌的信号。第二,在planning阶段打印"模型重放的(s,a)到底是什么时候真实见过的"。如果发现大量重放都来自很久以前的早期经验,说明模型修正机制可能失效了,需要检查模型表的覆盖逻辑。第三,改动探索奖金这类奖励塑形超参时,先固定环境跑一个小实验对比,别一上来就在正式任务上调整,塑形参数对策略的影响经常是非线性的,小步快跑永远比大步冒险稳妥。
回头再看基于模型的RL这条线,Dyna和MCTS一个代表了"温和地利用模型加速价值学习",另一个代表了"激进地利用模型做深度前瞻",各有各的数学美和工程坑。我自己的态度是:模型这个东西用对了是加速器,用错了是滤镜,关键是随时盯着"模型和现实之间的差距",保持持续校准。真遇到复杂场景,我手头永远会保留一个无模型的基线做对照,这是很多实验翻车之后才养成的习惯,也顺便分享给看到这篇笔记的你。