很多人刚开始接触强化学习时,容易被“智能体跟环境不断试错”这个叙事带偏,觉得强化学习就是从零开始瞎探索。但实际上,强化学习里有一整套完全不一样的思路:当环境模型——也就是状态转移概率和奖励函数——你已经知道得明明白白的时候,根本不需要采样试错,只需要解方程。而值迭代算法和策略迭代算法,就是解决这类已知模型问题的两大经典武器。先把前提说清楚:我默认你已经知道MDP(马尔可夫决策过程)的五元组,明白状态、动作、奖励、转移概率和折扣因子gamma分别是什么意思。如果你还不熟悉,建议先把贝尔曼方程的基础补上,因为今天要讲的这两个算法,本质上就是贝尔曼方程在两种不同求解路径上的具体展开。这篇博客写给两类读者:一是刚学强化学习、理论知识停留在概念层的初学者,希望帮你把公式和代码对应上;二是已经有了点基础、正在写Demo验证理解的实践者,希望我踩过的坑能帮你省下几个晚上。1. 为什么说动态规划方法才是强化学习的“地基”先泼一盆冷水:很多入门的课程和教程,为了展示强化学习的酷炫,一上来就甩出Q-learning或DQN,好像强化学习非得连个神经网络才能跑起来。但有一句在强化学习圈子里流传很广的话——无模型算法的每一个关键设计,几乎都能在经典动态规划方法里找到对应的原型。这背后的道理很简单。动态规划类方法处理的核心问题是“规划”,不是“学习”。它假设你有一张完整的MDP地图,知道从状态 s 执行动作 a 会以多大的概率到 s'、拿到多少奖励。在这种假设下,寻找最优策略就是一个纯粹的数学迭代问题。我们可以把问题拆成两步看:第一步,给定一个策略,算出这个策略下每个状态的价值是多少,这叫“策略评估”。第二步,根据评估出来的价值,改进策略,让每个状态都选择当前看起来最好的动作,这叫“策略改进”。策略迭代,就是这两个步骤不断交替,直到策略不再变化。值迭代,则是把这两步揉成一整步,直接基于贝尔曼最优方程原地迭代。你可能会疑惑:现在谁还有完整的MDP模型啊?大多数真实场景确实没有,但有完整模型的场景反而更多——比如棋盘游戏、导航地图、下棋引擎,以及很多运筹优化问题。就算没有完整模型,搞清楚这两个算法也能帮你彻底想明白Q-learning为什么用max,Actor-Critic为什么要有两套网络。它们是理解整个强化学习演进的坐标原点,没有之一。1.1 一个容易混淆的概念:规划与学习很多人把“规划”和“学习”混着用,但严格来说这是一条清晰的分界线:规划(planning):给定MDP模型,不和环境交互,直接通过动态规划、搜索或线性规划求解最优策略。学习(learning):不知道模型,只能通过实际采样获取经验,再从中估计价值函数或直接优化策略。值迭代和策略迭代都属于第一类。你不需要派一个智能体去跑地图,只需要在纸上、代码里反复迭代价值表,最优策略就浮出来了。我当年学到这里非常震撼,因为这种“不开一枪就把战争赢了”的感觉,和前面学的蒙特卡洛完全是两种画风。你掌握了这类方法,再回头去看DQN的target network、策略梯度的baseline,都会觉得似曾相识,因为源头上全都是这两套迭代机制。1.2 两个算法放在一起讲,天生一对这两个算法经常被并列拿出来讲,因为它们解决同一个问题:求解MDP的最优策略。但求解路径完全不同,后面我会详细展开。先记住一句话:值迭代解的是贝尔曼最优方程,一步步把价值函数逼近到最优。策略迭代解的是贝尔曼期望方程,反复评估-改进,让策略往回跳跃式前进。理解了这一点,你会自然地看到二者的优势和劣势,也理解了为什么实际科研中有人偏好值迭代,有人偏好策略迭代。2. 收敛性从哪来:贝尔曼算子的“压缩”魔术在写代码之前,我想先带你花五分钟搞清楚一件极其重要的底层问题:为什么这两个算法反反复复迭代就一定会收敛?为什么要确保这点?因为如果不理解收敛性,你写代码时根本不知道怎么判断“到底该停在哪一轮”。这两个算法都依赖于一个关键的工具——贝尔曼算子。你可以把它理解成一个“更新规则发生器”,它接收一个价值函数,输出一个新的价值函数。如果这个算子是压缩的,那么反复应用它,价值函数一定会逼近某个不动点,这个不动点正是我们要求的最优价值函数。用大白话说,压缩映射的性质是这样的:经过一次算子操作之后,新函数和旧函数之间的距离不会变大,反而会缩小到一个固定的比例。这就好比你手里有一把刻度不怎么准的尺子,每次量完都更靠近一个常数,最终稳定下来。这个比例恰好是折扣因子 gamma。所以为什么 gamma 必须严格小于1?因为在收敛性证明里它是至关重要的一环。2.1 贝尔曼最优算子的压缩性我尽量不堆数学,用一个简单例子说明。你在4x4网格世界的每个格子都有一个价值估计值,值迭代的更新公式长这样:V_new(s) = max_a [ r(s,a) + gamma * V(s') ]这个公式里藏着一个非常强的性质:从中取出两个不同的价值函数 V 和 U,那么对所有状态 s,它们的最大差值的上界会被压缩成一个 gamma 倍的旧差值上界。自后一步的 diff,一定比前一步的 diff 小。这就是为什么即使从一个全0的价值表开始,迭代下去也一定会逼近最优价值函数,而不是原地打转。我第一次看完这个证明的时候,有种恍然大悟的感觉。原来强化学习里那么多看起来很“玄”的收敛定理,落到这两个经典算法上,就是贝尔曼算子的压缩性加上不动点定理。这个洞察后来在我实现各种改进算法时帮了大忙——只要你能证明你的新算子还是压缩的,那你就知道引入它不会破坏收敛性。2.2 为什么策略迭代的收敛是“跳跃式”的策略迭代没有像值迭代那样直接用压缩映射一次到底,而是分成了两段。它先对当前策略做价值评估,这一步其实也是压缩迭代——反复用期望方程更新价值,速度慢一些但稳定;然后它做一个贪婪改进,把所有状态的动作换成当前价值下最有利的那个。这里有个非常关键的结论我必须强调:如果新策略里某一个状态的动作已经被换成更优动作,那么新策略的每个状态价值都不会比旧策略更差。这就是所谓的“策略改进定理”。它保证每一次策略改进都不会退步,要么保持原策略,要么想办法变得更好。在一个有限状态、有限动作的MDP里,策略总数是有限的,所以这个“单调提升”的过程最终会在某个策略上停下来。换句话说,策略迭代不是一步步爬向山峰,而是一次次做局部最优决策,从一片小高地跳到另一片更高的高地,最后站上山顶。如果你在代码里观察过策略的变化,会看到策略表经常整体变化很快,这正是因为它每一轮都在做全局贪心,带着强烈的“逼近”意味。3. 值迭代核心实现:用4x4网格世界快速跑通理论说了不少,现在动手。我选的实验场景是一个非常经典的网格世界:4x4,每个格子是一个状态,坐标编号从0到15,右下角格子是终点。所有正常移动的奖励都是-1,终点转移的奖励是0。这样智能体为了拿到最大的累积奖励,必须用最短的步数走到终点。为什么用这个环境?因为它小到你可以在几毫秒内跑完所有状态,又大到你肉眼能看出最优策略是什么样,方便验证你的代码对不对。为了让事情更直观,我给这部分代码用了Go语言,因为Go的数组遍历逻辑清晰,而且后面如果要接入其他工程场景也更方便。3.1 环境定义与转移逻辑先定义环境。这是一个确定性转移的环境,也就是说你朝某个方向走,就一定会走到相邻格子,除非出界,出界时位置不变。package main import ( "fmt" "math" ) const ( rows = 4 cols = 4 gamma = 0.9 theta = 1e-6 actUp = 0 actDown = 1 actLeft = 2 actRight = 3 ) var actions = []int{actUp, actDown, actLeft, actRight} func terminal(s int) bool { return s == rows*cols-1 } func step(s, a int) (int, float64) { r, c := s/cols, s%cols switch a { case actUp: if r > 0 { s -= cols } case actDown: if r < rows-1 { s += cols } case actLeft: if c > 0 { s-- } case actRight: if c < cols-1 { s++ } } if terminal(s) { return s, 0 } return s, -1 }这段代码里我做了两个关键决定:终点的奖励返回0,其他全返回-1。这样设计的好处是,最优策略会很自然地趋向“尽快走到终点”,不会为了让奖励为正而在原地打转。这点比你随便设计一个正奖励要重要得多。3.2 值迭代主循环值迭代的实现非常短,核心就是一个循环里面对所有非终止状态做贝尔曼最优更新:func valueIteration() ([]float64, []int) { statesCnt := rows * cols V := make([]float64, statesCnt) policy := make([]int, statesCnt) for { delta := 0.0 for s := 0; s < statesCnt; s++ { if terminal(s) { continue } bestVal := math.Inf(-1) for _, a := range actions { s2, r := step(s, a) val := r + gamma*V[s2] if val > bestVal { bestVal = val } } delta = math.Max(delta, math.Abs(bestVal-V[s])) V[s] = bestVal } if delta < theta { break } } // 从收敛的价值函数提取最优策略 for s := 0; s < statesCnt; s++ { if terminal(s) { policy[s] = -1 continue } bestA, bestVal := -1, math.Inf(-1) for _, a := range actions { s2, r := step(s, a) val := r + gamma*V[s2] if val > bestVal { bestVal = val bestA = a } } policy[s] = bestA } return V, policy }你可能注意到这里有个小细节:在同一轮循环里,我更新V[s]的时候,后面格子可能用到的V[s2]已经是这一轮更新过的值了。这就是所谓的“原地更新”,也叫Gauss-Seidel式更新,它比“先算完整份新表再替换”的Jacobi式更新通常会更快收敛,而且在这个场景下完全正确。不少教材教的是后者,但我推荐实战时直接用原地更新,速度更快,代码还更简洁。跑完这个函数之后,打印策略表,你会看到一个非常漂亮的结果:起点右上方的格子会一直向右走,起点左边的格子会一路向右或向下,所有格子都会挑离终点最近的方向。这时候你就知道,值迭代真的从一团零值里“长”出了最优策略。3.3 值迭代的终止条件怎么选我用了delta < theta来判断终止,这是最标准的方式,取所有状态本轮更新的最大差值。如果你把 theta 设置成1e-6,那最终价值函数会非常接近数学上的最优价值。但如果你只关心“策略对不对”,其实不需要等到这么精细。我在实际操作中发现,值迭代有一件非常有趣的事:策略的稳定通常远早于价值的完全收敛。比如在这个4x4世界里,迭代到二十多轮时,每个格子的最优动作其实已经定下来了,但价值数字还在慢慢往下掉,因为远处的奖励还在通过 gamma 一层层传过来。所以如果你只是想拿到最优策略,可以把 theta 调大一些,比如1e-3,这会快得多。这个“提前终止”的思路不是偷懒,而是工程优化。等你在更大规模的地图上跑实验时,会感同身受什么叫“价值细节不重要,动作排序才重要”。4. 策略迭代实现:评估与改进的两阶段交替策略迭代的代码量会比值迭代多一截,因为你要把策略评估和策略改进拆成两个独立的阶段。结构倒是非常对称:先给一个乱来的初始策略,然后循环——评估它、改进它、再评估、再改进——直到某轮改进后策略完全没变。这种写法的好处是,你能清楚观察到策略的演化轨迹。第一次改进往往特别野蛮,因为它会把所有状态的动作直接推向当前近似价值下的最优选择;但经过一两轮评估修正后,策略会稳定在一个更合理的状态。4.1 策略评估:解开贝尔曼期望方程策略评估的核心逻辑,是假设策略已经固定,然后反复套用期望方程更新价值表。这看起来和值迭代的代码很像,但有一个本质区别:它不用max,而是严格按当前策略选动作。func policyEval(policy []int) []float64 { statesCnt := rows * cols V := make([]float64, statesCnt) for { delta := 0.0 for s := 0; s < statesCnt; s++ { if terminal(s) { continue } a := policy[s] s2, r := step(s, a) newV := r + gamma*V[s2] delta = math.Max(delta, math.Abs(newV-V[s])) V[s] = newV } if delta < theta { break } } return V }如果你的策略是随机的、包含多种动作,这里就要改成对动作概率求期望。我这为了演示简单,用了确定性策略,所以代码看起来特别短。但逻辑大家要记住:评估的目标是算出“这个策略对每个状态给出的长期累积回报预期”,它只问当前策略会怎么做,绝不问“还有什么更好的做法”。一个经验之谈:策略评估内部这个循环,收敛速度取决于 gamma 的大小。gamma 越接近1,价值传播越慢,循环次数就越多。我经常在测试时先从 gamma=0.9 起步,确认逻辑通畅后再调整到更高值,不要一上来就0.99,调试时收敛慢很痛苦的。4.2 策略改进:贪心不是坏事下一步就是拿价值函数反过来更新策略。每个状态都选能让r + gamma * V(s')最大的动作。这个操作有个专有名词——贪婪化,因为它在每个状态都只盯着当前看起来最优的动作。func policyImprove(policy []int, V []float64) ([]int, bool) { statesCnt := rows * cols newPolicy := make([]int, statesCnt) changed := false for s := 0; s < statesCnt; s++ { if terminal(s) { newPolicy[s] = -1 continue } bestA, bestVal := -1, math.Inf(-1) for _, a := range actions { s2, r := step(s, a) val := r + gamma*V[s2] if val > bestVal { bestVal = val bestA = a } } if bestA != policy[s] { changed = true } newPolicy[s] = bestA } return newPolicy, changed }看到没,这里同样没有重新计算价值,只是从现有价值函数里挑动作。如果你评估做得够准,贪心出来的新策略就必然不会比旧策略差。这正是我们要的核心性质。4.3 主循环:交替走到策略不动把上面两个函数串起来就是完整策略迭代:func policyIteration() ([]int, []float64) { statesCnt := rows * cols policy := make([]int, statesCnt) for s := 0; s < statesCnt; s++ { policy[s] = actUp } for { V := policyEval(policy) newPolicy, changed := policyImprove(policy, V) policy = newPolicy if !changed { return policy, V } } }这里有几个值得指出的工程细节:初始策略我让所有状态都选“向上”,这其实是个很差的策略——左上角的格子永远在撞墙。但它依然能跑通,因为策略评估会如实地把这种低效率体现在惩罚上,之后改进阶段就会把它纠正过来。终止条件是策略没变就停。注意,我判断的是动作是否变了,而不是价值还是不是变了。因为策略迭代的目标就是找策略,价值只是辅助工具。我在主循环里每轮都从头做一次完整的策略评估。这个决定效率不高,但逻辑极其干净,也方便调试。后面我会讲一个实际优化技巧。在这个4x4世界里,只要两三轮策略迭代,策略表就会稳下来。这和值迭代那种慢慢磨的感觉很不一样,策略迭代像带着地图找路,三步五步就到目的地了。5. 同一张网格图上,两个算法的真实差异我在本地把两个算法都跑了一遍,记录了一些直观的数据。这些数字不是算法的理论复杂度,但非常能说明问题。指标值迭代策略迭代外层或迭代次数约33轮价值迭代约5轮评估-改进循环每轮成本一次完整状态+动作扫描评估阶段多次完整状态扫描收敛标准的实现难度低,直接看最大差值需要额外判断策略是否变化中间产物对调试的友好度价值表一直在变,看不出策略逻辑每轮都有明确的策略快照在大规模状态下单步便宜,轮数多,总开销可接受策略评估可能很贵,轮数少但不一定快从迭代次数看,策略迭代似乎是碾压值迭代的,事实也确实如此——前提是状态空间不大。但当我按总计算量去估计时,事情就没那么简单了。策略迭代的每一轮评估阶段,内部可能要跑十几轮甚至几十轮的价值迭代;如果你把评估阶段的内部迭代也累加起来,它的总扫描次数可能跟值迭代差不多。所以这里有个很反直觉的结论:策略迭代收敛轮数少,但不绝对代表总计算量更小。我写过一次状态很多的迷宫测试,策略迭代里简单的一次策略评估就要花几百轮网格扫描,而值迭代反而因为单步简单、轮数可接受而跑得更快。5.1 从两种视角理解值迭代里的max我特别想再深挖一个点,这可能是初学者最容易混淆的地方。看值迭代的更新公式,它用的是max,也就是对每个状态直接挑最好的动作来更新价值。这看起来好像它也在隐含做一个贪心策略选择,但实际上它做得比策略迭代更“激进的激进”:策略迭代是:先算当前策略的价值,再用这个价值改策略,改完再重新算价值。值迭代是:每次更新价值时,都假设下一步一定会用最优动作,直接把这个最优动作对应的价值写进当前状态。所以值迭代可以看成是策略评估和策略改进的极端混合体——它只做一次改进(选max),又只做一次评估(更新V),然后立刻接着下一个状态。理论上有一种说法是,值迭代的本质是策略评估只迭代一次就马上做策略改进,最后再取极限。这种视角有助于你理解为什么值迭代在价值上常常“超前”,因为它的价值表里每一步都混入了对未来最优行为的预期。这个细微差别在调试时特别明显。你打印值迭代过程中间的价值表,会发现数字很快就“像样了”,但策略还没完全定型。而策略迭代的价值表则是基于一个明确策略算出来的,中间每一轮都有一个自洽的解释——所有状态的价值都是针对当前策略而言的。5.2 值不动,策略却在变:一个让我困扰很久的现象我需要专门提一个我在调试中遇到的灵异现象:值迭代已经跑到了收敛标准,价值表几乎不动了,但我每次从价值表提取策略时,策略却不稳定,会在少数几个格子之间来回跳。后来我想明白了,这是因为价值表在几个备选动作上的差异非常非常小,小到比收敛阈值还小。你把迭代停在这个位置,正常来说策略已经是“接近最优”的了;但如果你的目标是要一个严格的确定性最优策略,就要么把theta调小一点,要么在最后一步多跑几个额外的迭代。这个现象在现实场景中其实挺常见的,尤其当你把gamma调到接近1时,不同路径的长期奖励差距被压得越来越小。解决办法也不难:在收敛标准之外,加一个“策略在最近几轮是否稳定”的辅助判断。我后面再详细说。6. 调参和Debug:我踩过的坑和总结的经验光看代码能跑通是不够的,真正让你有收获的是调试过程中发现的问题。我把我踩过的坑按重要性列出来,你也可以直接当作一份避坑清单。6.1 终点状态必须特殊处理,否则会翻车这是我觉得最容易犯的错。如果你在值迭代或策略评估里不对终止状态做特殊处理,那么终止状态也会参与更新,会出现两种恶果:终止状态的价值会被算成一个负值,因为从终点继续走会撞墙、会扣分。策略会倾向于让智能体“不要到终点”,因为停在终点前的状态反而能拿到更多负奖励的延迟补偿。正确做法就是我在前面代码里做的:遇到终止状态直接跳过更新,终点的价值保持为0。这是一个约定俗成的设计,但也有人用“自循环终止奖励”方案,终止后仍然返回0且转移回自己。两种都可以,只是你写代码时要清楚自己在用哪种约定,别让终点状态变成“被诅咒的格子”。6.2 gamma的选择直接影响收敛速度和合理性gamma在强化学习里是折扣因子,它的意义不仅是数学技巧,更是你对“长远”和“眼前”之间的权衡。当gamma=0时,智能体只看一步奖励,永远不会规划未来——这在网格世界里会表现为每个格子都冲向最近的那一步,不想多走任何弯路。当gamma=0.99时,智能体会为了更晚的奖励忍受大量前期惩罚,学习也会慢得让人焦虑。我的实用建议:先设0.9跑通,确认自己的算法有收敛趋势;然后再调到0.95或0.99,观察策略的变化。如果策略没有明显变得更长远的规划,说明你的环境设计或奖励信号可能有问题,排查起来也方便。6.3 记录迭代轮数和策略快照,比只看价值更直观我把两条调试经验一并分享给你:第一,不要在调试时只print价值表。价值表是数字,数字在缓慢变化时你很难一眼看出来策略的趋势。我建议每轮迭代都print一次策略网格图,用符号表示动作方向,一眼就能看出哪些格子的策略在抖动。第二,记录每一轮的delta值。你看到delta的下降曲线不是单调的,不要慌,这是正常现象。重要的是它最终是否突破theta。如果delta在大幅波动,说明gamma太大或状态间有循环路径,需要警惕。6.4 “一次Deep评估”和“截断评估”到底选哪个关于策略迭代,有一个经典优化:不用等策略评估完全收敛再做改进。你可以只允许评估阶段跑有限几轮,比如5轮或10轮,就立刻做策略改进。这种做法叫截断策略迭代,它的收敛性在理论上也是被保障的,而且工程上可以大幅减少计算量。我个人的经验是,在状态空间很大时,完全收敛的评估非常奢侈,像是为了切一棵树把整片森林都锯了。你可以这样设置:评估阶段设一个稍微宽松的内部阈值,比如1e-2,或者直接限制上限迭代次数为10次。这样一轮策略迭代的代价降低了,整体策略到达最优的速度反而可能更快。6.5 用“策略稳定性”作为额外终止信号前面提到的价值没完全收敛而策略已经在跳的问题,解决起来其实很简单。我后来给自己写了一条经验:不管用值迭代还是策略迭代,在收敛到价值阈值后,额外执行几次迭代来确认策略是否保持稳定。如果连续几轮提取出来的策略完全没有变化,那就可以非常有底气地终止计算。在值迭代的代码里,这就是多花几轮扫描的代价,换来策略的可靠性。我现在写强化学习玩具代码时已经默认这种行为,权当保险丝。6.6 从经典DP到现代算法:一条清晰的观察路径最后我想说,这两个算法的价值不仅在于它们本身能解网格世界,而在于你把它们吃透之后,再看现代算法确实顺眼很多。Q-learning的更新式子里面那个max,其实就是值迭代算子的采样版;Actor-Critic结构里的actor做策略改进、critic做策略评估,简直就是策略迭代在函数逼近时代的徒子徒孙;甚至DDPG、PPO这些看起来时髦的算法,剥开壳依然是评估-改进的骨架。所以花费一个晚上把这20年前的经典算法跑明白,是真的不亏。如果还想继续深入,我的建议是:给这个网格世界加入随机转移,看两个算法在带噪声时谁更稳健;改成更大的迷宫环境,测量一下两者的总计算时间,亲身体会“迭代轮数少不等于总计算量小”;打印出每一步的价值表,和贝尔曼方程的数值解核对一遍,保证你的理解没有偏差。这些实验做下来,你对强化学习的信心会完全不一样。不要小看这几个代码短小的玩具问题,它们才是帮你建立直觉的核心材料。
很多人刚开始接触强化学习时,容易被“智能体跟环境不断试错”这个叙事带偏,觉得强化学习就是从零开始瞎探索。但实际上,强化学习里有一整套完全不一样的思路:当环境模型——也就是状态转移概率和奖励函数——你已经知道得明明白白的时候,根本不需要采样试错,只需要解方程。而值迭代算法和策略迭代算法,就是解决这类已知模型问题的两大经典武器。先把前提说清楚:我默认你已经知道MDP(马尔可夫决策过程)的五元组,明白状态、动作、奖励、转移概率和折扣因子gamma分别是什么意思。如果你还不熟悉,建议先把贝尔曼方程的基础补上,因为今天要讲的这两个算法,本质上就是贝尔曼方程在两种不同求解路径上的具体展开。这篇博客写给两类读者:一是刚学强化学习、理论知识停留在概念层的初学者,希望帮你把公式和代码对应上;二是已经有了点基础、正在写Demo验证理解的实践者,希望我踩过的坑能帮你省下几个晚上。1. 为什么说动态规划方法才是强化学习的“地基”先泼一盆冷水:很多入门的课程和教程,为了展示强化学习的酷炫,一上来就甩出Q-learning或DQN,好像强化学习非得连个神经网络才能跑起来。但有一句在强化学习圈子里流传很广的话——无模型算法的每一个关键设计,几乎都能在经典动态规划方法里找到对应的原型。这背后的道理很简单。动态规划类方法处理的核心问题是“规划”,不是“学习”。它假设你有一张完整的MDP地图,知道从状态 s 执行动作 a 会以多大的概率到 s'、拿到多少奖励。在这种假设下,寻找最优策略就是一个纯粹的数学迭代问题。我们可以把问题拆成两步看:第一步,给定一个策略,算出这个策略下每个状态的价值是多少,这叫“策略评估”。第二步,根据评估出来的价值,改进策略,让每个状态都选择当前看起来最好的动作,这叫“策略改进”。策略迭代,就是这两个步骤不断交替,直到策略不再变化。值迭代,则是把这两步揉成一整步,直接基于贝尔曼最优方程原地迭代。你可能会疑惑:现在谁还有完整的MDP模型啊?大多数真实场景确实没有,但有完整模型的场景反而更多——比如棋盘游戏、导航地图、下棋引擎,以及很多运筹优化问题。就算没有完整模型,搞清楚这两个算法也能帮你彻底想明白Q-learning为什么用max,Actor-Critic为什么要有两套网络。它们是理解整个强化学习演进的坐标原点,没有之一。1.1 一个容易混淆的概念:规划与学习很多人把“规划”和“学习”混着用,但严格来说这是一条清晰的分界线:规划(planning):给定MDP模型,不和环境交互,直接通过动态规划、搜索或线性规划求解最优策略。学习(learning):不知道模型,只能通过实际采样获取经验,再从中估计价值函数或直接优化策略。值迭代和策略迭代都属于第一类。你不需要派一个智能体去跑地图,只需要在纸上、代码里反复迭代价值表,最优策略就浮出来了。我当年学到这里非常震撼,因为这种“不开一枪就把战争赢了”的感觉,和前面学的蒙特卡洛完全是两种画风。你掌握了这类方法,再回头去看DQN的target network、策略梯度的baseline,都会觉得似曾相识,因为源头上全都是这两套迭代机制。1.2 两个算法放在一起讲,天生一对这两个算法经常被并列拿出来讲,因为它们解决同一个问题:求解MDP的最优策略。但求解路径完全不同,后面我会详细展开。先记住一句话:值迭代解的是贝尔曼最优方程,一步步把价值函数逼近到最优。策略迭代解的是贝尔曼期望方程,反复评估-改进,让策略往回跳跃式前进。理解了这一点,你会自然地看到二者的优势和劣势,也理解了为什么实际科研中有人偏好值迭代,有人偏好策略迭代。2. 收敛性从哪来:贝尔曼算子的“压缩”魔术在写代码之前,我想先带你花五分钟搞清楚一件极其重要的底层问题:为什么这两个算法反反复复迭代就一定会收敛?为什么要确保这点?因为如果不理解收敛性,你写代码时根本不知道怎么判断“到底该停在哪一轮”。这两个算法都依赖于一个关键的工具——贝尔曼算子。你可以把它理解成一个“更新规则发生器”,它接收一个价值函数,输出一个新的价值函数。如果这个算子是压缩的,那么反复应用它,价值函数一定会逼近某个不动点,这个不动点正是我们要求的最优价值函数。用大白话说,压缩映射的性质是这样的:经过一次算子操作之后,新函数和旧函数之间的距离不会变大,反而会缩小到一个固定的比例。这就好比你手里有一把刻度不怎么准的尺子,每次量完都更靠近一个常数,最终稳定下来。这个比例恰好是折扣因子 gamma。所以为什么 gamma 必须严格小于1?因为在收敛性证明里它是至关重要的一环。2.1 贝尔曼最优算子的压缩性我尽量不堆数学,用一个简单例子说明。你在4x4网格世界的每个格子都有一个价值估计值,值迭代的更新公式长这样:V_new(s) = max_a [ r(s,a) + gamma * V(s') ]这个公式里藏着一个非常强的性质:从中取出两个不同的价值函数 V 和 U,那么对所有状态 s,它们的最大差值的上界会被压缩成一个 gamma 倍的旧差值上界。自后一步的 diff,一定比前一步的 diff 小。这就是为什么即使从一个全0的价值表开始,迭代下去也一定会逼近最优价值函数,而不是原地打转。我第一次看完这个证明的时候,有种恍然大悟的感觉。原来强化学习里那么多看起来很“玄”的收敛定理,落到这两个经典算法上,就是贝尔曼算子的压缩性加上不动点定理。这个洞察后来在我实现各种改进算法时帮了大忙——只要你能证明你的新算子还是压缩的,那你就知道引入它不会破坏收敛性。2.2 为什么策略迭代的收敛是“跳跃式”的策略迭代没有像值迭代那样直接用压缩映射一次到底,而是分成了两段。它先对当前策略做价值评估,这一步其实也是压缩迭代——反复用期望方程更新价值,速度慢一些但稳定;然后它做一个贪婪改进,把所有状态的动作换成当前价值下最有利的那个。这里有个非常关键的结论我必须强调:如果新策略里某一个状态的动作已经被换成更优动作,那么新策略的每个状态价值都不会比旧策略更差。这就是所谓的“策略改进定理”。它保证每一次策略改进都不会退步,要么保持原策略,要么想办法变得更好。在一个有限状态、有限动作的MDP里,策略总数是有限的,所以这个“单调提升”的过程最终会在某个策略上停下来。换句话说,策略迭代不是一步步爬向山峰,而是一次次做局部最优决策,从一片小高地跳到另一片更高的高地,最后站上山顶。如果你在代码里观察过策略的变化,会看到策略表经常整体变化很快,这正是因为它每一轮都在做全局贪心,带着强烈的“逼近”意味。3. 值迭代核心实现:用4x4网格世界快速跑通理论说了不少,现在动手。我选的实验场景是一个非常经典的网格世界:4x4,每个格子是一个状态,坐标编号从0到15,右下角格子是终点。所有正常移动的奖励都是-1,终点转移的奖励是0。这样智能体为了拿到最大的累积奖励,必须用最短的步数走到终点。为什么用这个环境?因为它小到你可以在几毫秒内跑完所有状态,又大到你肉眼能看出最优策略是什么样,方便验证你的代码对不对。为了让事情更直观,我给这部分代码用了Go语言,因为Go的数组遍历逻辑清晰,而且后面如果要接入其他工程场景也更方便。3.1 环境定义与转移逻辑先定义环境。这是一个确定性转移的环境,也就是说你朝某个方向走,就一定会走到相邻格子,除非出界,出界时位置不变。package main import ( "fmt" "math" ) const ( rows = 4 cols = 4 gamma = 0.9 theta = 1e-6 actUp = 0 actDown = 1 actLeft = 2 actRight = 3 ) var actions = []int{actUp, actDown, actLeft, actRight} func terminal(s int) bool { return s == rows*cols-1 } func step(s, a int) (int, float64) { r, c := s/cols, s%cols switch a { case actUp: if r > 0 { s -= cols } case actDown: if r < rows-1 { s += cols } case actLeft: if c > 0 { s-- } case actRight: if c < cols-1 { s++ } } if terminal(s) { return s, 0 } return s, -1 }这段代码里我做了两个关键决定:终点的奖励返回0,其他全返回-1。这样设计的好处是,最优策略会很自然地趋向“尽快走到终点”,不会为了让奖励为正而在原地打转。这点比你随便设计一个正奖励要重要得多。3.2 值迭代主循环值迭代的实现非常短,核心就是一个循环里面对所有非终止状态做贝尔曼最优更新:func valueIteration() ([]float64, []int) { statesCnt := rows * cols V := make([]float64, statesCnt) policy := make([]int, statesCnt) for { delta := 0.0 for s := 0; s < statesCnt; s++ { if terminal(s) { continue } bestVal := math.Inf(-1) for _, a := range actions { s2, r := step(s, a) val := r + gamma*V[s2] if val > bestVal { bestVal = val } } delta = math.Max(delta, math.Abs(bestVal-V[s])) V[s] = bestVal } if delta < theta { break } } // 从收敛的价值函数提取最优策略 for s := 0; s < statesCnt; s++ { if terminal(s) { policy[s] = -1 continue } bestA, bestVal := -1, math.Inf(-1) for _, a := range actions { s2, r := step(s, a) val := r + gamma*V[s2] if val > bestVal { bestVal = val bestA = a } } policy[s] = bestA } return V, policy }你可能注意到这里有个小细节:在同一轮循环里,我更新V[s]的时候,后面格子可能用到的V[s2]已经是这一轮更新过的值了。这就是所谓的“原地更新”,也叫Gauss-Seidel式更新,它比“先算完整份新表再替换”的Jacobi式更新通常会更快收敛,而且在这个场景下完全正确。不少教材教的是后者,但我推荐实战时直接用原地更新,速度更快,代码还更简洁。跑完这个函数之后,打印策略表,你会看到一个非常漂亮的结果:起点右上方的格子会一直向右走,起点左边的格子会一路向右或向下,所有格子都会挑离终点最近的方向。这时候你就知道,值迭代真的从一团零值里“长”出了最优策略。3.3 值迭代的终止条件怎么选我用了delta < theta来判断终止,这是最标准的方式,取所有状态本轮更新的最大差值。如果你把 theta 设置成1e-6,那最终价值函数会非常接近数学上的最优价值。但如果你只关心“策略对不对”,其实不需要等到这么精细。我在实际操作中发现,值迭代有一件非常有趣的事:策略的稳定通常远早于价值的完全收敛。比如在这个4x4世界里,迭代到二十多轮时,每个格子的最优动作其实已经定下来了,但价值数字还在慢慢往下掉,因为远处的奖励还在通过 gamma 一层层传过来。所以如果你只是想拿到最优策略,可以把 theta 调大一些,比如1e-3,这会快得多。这个“提前终止”的思路不是偷懒,而是工程优化。等你在更大规模的地图上跑实验时,会感同身受什么叫“价值细节不重要,动作排序才重要”。4. 策略迭代实现:评估与改进的两阶段交替策略迭代的代码量会比值迭代多一截,因为你要把策略评估和策略改进拆成两个独立的阶段。结构倒是非常对称:先给一个乱来的初始策略,然后循环——评估它、改进它、再评估、再改进——直到某轮改进后策略完全没变。这种写法的好处是,你能清楚观察到策略的演化轨迹。第一次改进往往特别野蛮,因为它会把所有状态的动作直接推向当前近似价值下的最优选择;但经过一两轮评估修正后,策略会稳定在一个更合理的状态。4.1 策略评估:解开贝尔曼期望方程策略评估的核心逻辑,是假设策略已经固定,然后反复套用期望方程更新价值表。这看起来和值迭代的代码很像,但有一个本质区别:它不用max,而是严格按当前策略选动作。func policyEval(policy []int) []float64 { statesCnt := rows * cols V := make([]float64, statesCnt) for { delta := 0.0 for s := 0; s < statesCnt; s++ { if terminal(s) { continue } a := policy[s] s2, r := step(s, a) newV := r + gamma*V[s2] delta = math.Max(delta, math.Abs(newV-V[s])) V[s] = newV } if delta < theta { break } } return V }如果你的策略是随机的、包含多种动作,这里就要改成对动作概率求期望。我这为了演示简单,用了确定性策略,所以代码看起来特别短。但逻辑大家要记住:评估的目标是算出“这个策略对每个状态给出的长期累积回报预期”,它只问当前策略会怎么做,绝不问“还有什么更好的做法”。一个经验之谈:策略评估内部这个循环,收敛速度取决于 gamma 的大小。gamma 越接近1,价值传播越慢,循环次数就越多。我经常在测试时先从 gamma=0.9 起步,确认逻辑通畅后再调整到更高值,不要一上来就0.99,调试时收敛慢很痛苦的。4.2 策略改进:贪心不是坏事下一步就是拿价值函数反过来更新策略。每个状态都选能让r + gamma * V(s')最大的动作。这个操作有个专有名词——贪婪化,因为它在每个状态都只盯着当前看起来最优的动作。func policyImprove(policy []int, V []float64) ([]int, bool) { statesCnt := rows * cols newPolicy := make([]int, statesCnt) changed := false for s := 0; s < statesCnt; s++ { if terminal(s) { newPolicy[s] = -1 continue } bestA, bestVal := -1, math.Inf(-1) for _, a := range actions { s2, r := step(s, a) val := r + gamma*V[s2] if val > bestVal { bestVal = val bestA = a } } if bestA != policy[s] { changed = true } newPolicy[s] = bestA } return newPolicy, changed }看到没,这里同样没有重新计算价值,只是从现有价值函数里挑动作。如果你评估做得够准,贪心出来的新策略就必然不会比旧策略差。这正是我们要的核心性质。4.3 主循环:交替走到策略不动把上面两个函数串起来就是完整策略迭代:func policyIteration() ([]int, []float64) { statesCnt := rows * cols policy := make([]int, statesCnt) for s := 0; s < statesCnt; s++ { policy[s] = actUp } for { V := policyEval(policy) newPolicy, changed := policyImprove(policy, V) policy = newPolicy if !changed { return policy, V } } }这里有几个值得指出的工程细节:初始策略我让所有状态都选“向上”,这其实是个很差的策略——左上角的格子永远在撞墙。但它依然能跑通,因为策略评估会如实地把这种低效率体现在惩罚上,之后改进阶段就会把它纠正过来。终止条件是策略没变就停。注意,我判断的是动作是否变了,而不是价值还是不是变了。因为策略迭代的目标就是找策略,价值只是辅助工具。我在主循环里每轮都从头做一次完整的策略评估。这个决定效率不高,但逻辑极其干净,也方便调试。后面我会讲一个实际优化技巧。在这个4x4世界里,只要两三轮策略迭代,策略表就会稳下来。这和值迭代那种慢慢磨的感觉很不一样,策略迭代像带着地图找路,三步五步就到目的地了。5. 同一张网格图上,两个算法的真实差异我在本地把两个算法都跑了一遍,记录了一些直观的数据。这些数字不是算法的理论复杂度,但非常能说明问题。指标值迭代策略迭代外层或迭代次数约33轮价值迭代约5轮评估-改进循环每轮成本一次完整状态+动作扫描评估阶段多次完整状态扫描收敛标准的实现难度低,直接看最大差值需要额外判断策略是否变化中间产物对调试的友好度价值表一直在变,看不出策略逻辑每轮都有明确的策略快照在大规模状态下单步便宜,轮数多,总开销可接受策略评估可能很贵,轮数少但不一定快从迭代次数看,策略迭代似乎是碾压值迭代的,事实也确实如此——前提是状态空间不大。但当我按总计算量去估计时,事情就没那么简单了。策略迭代的每一轮评估阶段,内部可能要跑十几轮甚至几十轮的价值迭代;如果你把评估阶段的内部迭代也累加起来,它的总扫描次数可能跟值迭代差不多。所以这里有个很反直觉的结论:策略迭代收敛轮数少,但不绝对代表总计算量更小。我写过一次状态很多的迷宫测试,策略迭代里简单的一次策略评估就要花几百轮网格扫描,而值迭代反而因为单步简单、轮数可接受而跑得更快。5.1 从两种视角理解值迭代里的max我特别想再深挖一个点,这可能是初学者最容易混淆的地方。看值迭代的更新公式,它用的是max,也就是对每个状态直接挑最好的动作来更新价值。这看起来好像它也在隐含做一个贪心策略选择,但实际上它做得比策略迭代更“激进的激进”:策略迭代是:先算当前策略的价值,再用这个价值改策略,改完再重新算价值。值迭代是:每次更新价值时,都假设下一步一定会用最优动作,直接把这个最优动作对应的价值写进当前状态。所以值迭代可以看成是策略评估和策略改进的极端混合体——它只做一次改进(选max),又只做一次评估(更新V),然后立刻接着下一个状态。理论上有一种说法是,值迭代的本质是策略评估只迭代一次就马上做策略改进,最后再取极限。这种视角有助于你理解为什么值迭代在价值上常常“超前”,因为它的价值表里每一步都混入了对未来最优行为的预期。这个细微差别在调试时特别明显。你打印值迭代过程中间的价值表,会发现数字很快就“像样了”,但策略还没完全定型。而策略迭代的价值表则是基于一个明确策略算出来的,中间每一轮都有一个自洽的解释——所有状态的价值都是针对当前策略而言的。5.2 值不动,策略却在变:一个让我困扰很久的现象我需要专门提一个我在调试中遇到的灵异现象:值迭代已经跑到了收敛标准,价值表几乎不动了,但我每次从价值表提取策略时,策略却不稳定,会在少数几个格子之间来回跳。后来我想明白了,这是因为价值表在几个备选动作上的差异非常非常小,小到比收敛阈值还小。你把迭代停在这个位置,正常来说策略已经是“接近最优”的了;但如果你的目标是要一个严格的确定性最优策略,就要么把theta调小一点,要么在最后一步多跑几个额外的迭代。这个现象在现实场景中其实挺常见的,尤其当你把gamma调到接近1时,不同路径的长期奖励差距被压得越来越小。解决办法也不难:在收敛标准之外,加一个“策略在最近几轮是否稳定”的辅助判断。我后面再详细说。6. 调参和Debug:我踩过的坑和总结的经验光看代码能跑通是不够的,真正让你有收获的是调试过程中发现的问题。我把我踩过的坑按重要性列出来,你也可以直接当作一份避坑清单。6.1 终点状态必须特殊处理,否则会翻车这是我觉得最容易犯的错。如果你在值迭代或策略评估里不对终止状态做特殊处理,那么终止状态也会参与更新,会出现两种恶果:终止状态的价值会被算成一个负值,因为从终点继续走会撞墙、会扣分。策略会倾向于让智能体“不要到终点”,因为停在终点前的状态反而能拿到更多负奖励的延迟补偿。正确做法就是我在前面代码里做的:遇到终止状态直接跳过更新,终点的价值保持为0。这是一个约定俗成的设计,但也有人用“自循环终止奖励”方案,终止后仍然返回0且转移回自己。两种都可以,只是你写代码时要清楚自己在用哪种约定,别让终点状态变成“被诅咒的格子”。6.2 gamma的选择直接影响收敛速度和合理性gamma在强化学习里是折扣因子,它的意义不仅是数学技巧,更是你对“长远”和“眼前”之间的权衡。当gamma=0时,智能体只看一步奖励,永远不会规划未来——这在网格世界里会表现为每个格子都冲向最近的那一步,不想多走任何弯路。当gamma=0.99时,智能体会为了更晚的奖励忍受大量前期惩罚,学习也会慢得让人焦虑。我的实用建议:先设0.9跑通,确认自己的算法有收敛趋势;然后再调到0.95或0.99,观察策略的变化。如果策略没有明显变得更长远的规划,说明你的环境设计或奖励信号可能有问题,排查起来也方便。6.3 记录迭代轮数和策略快照,比只看价值更直观我把两条调试经验一并分享给你:第一,不要在调试时只print价值表。价值表是数字,数字在缓慢变化时你很难一眼看出来策略的趋势。我建议每轮迭代都print一次策略网格图,用符号表示动作方向,一眼就能看出哪些格子的策略在抖动。第二,记录每一轮的delta值。你看到delta的下降曲线不是单调的,不要慌,这是正常现象。重要的是它最终是否突破theta。如果delta在大幅波动,说明gamma太大或状态间有循环路径,需要警惕。6.4 “一次Deep评估”和“截断评估”到底选哪个关于策略迭代,有一个经典优化:不用等策略评估完全收敛再做改进。你可以只允许评估阶段跑有限几轮,比如5轮或10轮,就立刻做策略改进。这种做法叫截断策略迭代,它的收敛性在理论上也是被保障的,而且工程上可以大幅减少计算量。我个人的经验是,在状态空间很大时,完全收敛的评估非常奢侈,像是为了切一棵树把整片森林都锯了。你可以这样设置:评估阶段设一个稍微宽松的内部阈值,比如1e-2,或者直接限制上限迭代次数为10次。这样一轮策略迭代的代价降低了,整体策略到达最优的速度反而可能更快。6.5 用“策略稳定性”作为额外终止信号前面提到的价值没完全收敛而策略已经在跳的问题,解决起来其实很简单。我后来给自己写了一条经验:不管用值迭代还是策略迭代,在收敛到价值阈值后,额外执行几次迭代来确认策略是否保持稳定。如果连续几轮提取出来的策略完全没有变化,那就可以非常有底气地终止计算。在值迭代的代码里,这就是多花几轮扫描的代价,换来策略的可靠性。我现在写强化学习玩具代码时已经默认这种行为,权当保险丝。6.6 从经典DP到现代算法:一条清晰的观察路径最后我想说,这两个算法的价值不仅在于它们本身能解网格世界,而在于你把它们吃透之后,再看现代算法确实顺眼很多。Q-learning的更新式子里面那个max,其实就是值迭代算子的采样版;Actor-Critic结构里的actor做策略改进、critic做策略评估,简直就是策略迭代在函数逼近时代的徒子徒孙;甚至DDPG、PPO这些看起来时髦的算法,剥开壳依然是评估-改进的骨架。所以花费一个晚上把这20年前的经典算法跑明白,是真的不亏。如果还想继续深入,我的建议是:给这个网格世界加入随机转移,看两个算法在带噪声时谁更稳健;改成更大的迷宫环境,测量一下两者的总计算时间,亲身体会“迭代轮数少不等于总计算量小”;打印出每一步的价值表,和贝尔曼方程的数值解核对一遍,保证你的理解没有偏差。这些实验做下来,你对强化学习的信心会完全不一样。不要小看这几个代码短小的玩具问题,它们才是帮你建立直觉的核心材料。