☰
从Q表格到深度强化学习:DQN、策略梯度与Actor-Critic解析
2026/10/10 7:21:25 网站建设 项目流程

做完了第一个用Q表格实现的格子世界之后,我一度觉得强化学习也就那么回事:查表、迭代、收敛、结束。直到我把任务换成了一个小型机器人仿真,行为状态从几个离散格子变成了十几路连续的传感器读数,Q表格瞬间就变成了不可能维护的庞然大物。为了给每个状态-动作对留一行记录,我试过先把连续读数离散化,结果维度仍然高得离谱,训练速度慢到我开始怀疑人生。那段时间我反复调整奖励和探索率,效果都有限,最后才真正想明白一件事:强化学习要走向真实场景,第一步不是把奖励函数改得更好看,而是把“表”换成“神经网络”。

这也是强化学习(二)这篇笔记的主题:从值函数近似到策略梯度,再到Actor-Critic这类工程上能真正跑起来的框架,以及我在实验里踩到过的稳定性和调参相关的坑。这篇文章默认你已经了解MDP、Q-learning或者SARSA的基本概念,如果你还在和简单的格子世界Q表格打交道,那这篇正好是往深度强化学习方向迈的那一步。

1. 表格型方法的天花板与神经网络的入场理由

1.1 状态爆炸:一张表装不下真实世界

想理解深度强化学习为什么能起作用,必须先承认一件很残酷的事:表格型方法在真实问题上基本撑不住。以我那个小车仿真为例,状态是一组连续传感器读数,取值范围本身就没法枚举,哪怕砍到最粗的二进制编码,十几个传感器的联合状态都足以让任何一张表变成天文数字。更极端的是视觉输入,假设一帧画面只有80×80的灰度图,穷举256的6400次方种状态,把所有硬盘加在一起都装不下。所以第一步是放弃“为每个状态单独记一个数”的执念,改用函数近似:用一个带参数的神经网络,输入状态s,输出动作价值Q(s,a)的估计值。神经网络通过隐层在相似状态之间共享信息,只要探索过的轨迹足够覆盖状态空间的代表性区域,它就能泛化到从未见过的相近状态上。

这个思路在生活里也成立。你学开车时不需要把每一条路的路面情况都记下来,而是学会了“看到前方障碍物就减速”这类规律。Q网络就是试图把“什么状态下什么动作大概值多少分”这个规律学出来。一旦想通这一点,你再看DQN之类的算法,就不会被满篇公式吓到,它本质上只是在做一件很简单的事:用神经网络拟合一个原本靠查表才能得到的函数。

1.2 动态标签:强化学习与监督学习的关键区别

这里有个刚入门特别容易绕进去的点,是深度强化学习和普通监督学习的训练逻辑差异。监督学习的标签是事先确定的,例如图像分类里每张图属于哪一类,在你开始训练之前就标好了。但强化学习的TD目标y = r + gamma * max_{a'} Q_target(s', a')是网络自己生成的,标签和预测相互纠缠。说得更直白一点:你在用网络当前的输出定义标签,再让网络去逼近这个标签,整个训练过程就是一个不断自我修正的闭环。

第一次写代码时很容易觉得这不就是普通回归吗?仔细想想就会发现,你今天拟合的标签,明天网络参数一变,标签也跟着变了,目标一直在移动。这也是后面目标网络设计出来的直接原因。理解了这个动态标签机制以后,你再看强化学习的各种训练技巧,会更容易抓住它们到底在解决什么问题。

1.3 输出设计与损失函数选择

输入输出设计上,最简单也最常见的做法是:网络输入当前状态,输出一个向量,维度等于动作数量。比如四动作的离散环境,输出四维向量,第i个值代表执行动作i的期望回报。这样在环境中选动作时只需一次前向传播,然后argmax选最大Q值即可,不需要对每个动作单独做一次前向计算。如果你接的是连续动作问题,这种输出设计就不太合适了,策略网络或者Actor-Critic会更自然,这部分后面细说。

损失函数上我强烈建议把标准MSE换成Huber loss,在常用深度学习框架里对应的是SmoothL1Loss。强化学习训练目标本身噪声很大,偶尔一个离群TD目标,在MSE下会给出特别大的梯度,一次更新就把模型参数带偏;而Huber在误差较大时改用线性增长,梯度幅度受到限制,训练曲线会明显稳一截。我自己的项目里从MSE切到Huber之后,震荡频率肉眼可见地下降。这个改动成本几乎为零,回报却很直接。

2. DQN三件套:经验回放、目标网络与TD目标的正确写法

深度Q网络不是把Q-learning里那张表机械地换成神经网络就行。如果直接让网络一边和环境交互一边做梯度更新,你会看到什么?训练loss发散,奖励曲线原地抽搐。DQN能被大规模训练稳定下来,靠的是三件套:经验回放、目标网络,以及正确的TD目标写法。这三样缺一个,训练效果都会大打折扣。

2.1 经验回放为什么能稳定训练

经验回放的核心原因是打破样本相关性。强化学习在线获取的数据天然按时间序列排列,相邻时间步的状态、动作、奖励高度相关,如果按经验产生的顺序直接喂给网络做SGD,相当于回归问题中训练集内部全是近亲样本,梯度方向会被最近一小段轨迹支配。想象一下,一个奔跑的人如果只盯着当前脚下这一块地面决定下一步重心,根本不回顾过去一段路的地形,摔跟头几乎是肯定的。

经验回放的做法是维护一个环形缓冲区,把每次交互产生的转移样本(s, a, r, s', done)存进去,训练时随机均匀采样一个batch。由于样本来自不同历史时段,相关性被打破,数据利用效率也更高,一条样本可以被反复使用。缓冲区的大小会直接影响训练效果:经验池太小,样本多样性不够;太大,太老的样本和当前策略已经不太匹配,可能干扰新方向。常见的启动范围是100k到1M条样本,具体数值要结合任务复杂度来定。

2.2 目标网络:给动态目标装上慢半拍的锚

目标网络解决的是“移动靶”问题。在TD目标里,max_{a'} Q(s', a') 这部分价值来自当前正在训练的网络本身,参数每一步都在变,所以网络输出的目标值也在变。表面上看这没什么,但实际训练中会导致网络不断去追一个自己刚创造出来的新目标,更新方向和损失景观都容易震荡。

比较经典的解决方案是维护一份延迟更新的参数拷贝:在线Q网络实时更新,目标网络每隔C步同步一次在线网络的参数。于是每个更新步,TD目标相对固定,梯度方向稳定,整个学习过程变成追一个慢慢移动的靶子。也可以用软更新的方式,把目标网络按tau比例向在线网络参数缓慢逼近,例如tau=0.005,每步都做一点参数插值,这种写法在后续进阶方法里也很常见。实际操作中,任务越复杂、奖励越稀疏,目标网络往往需要更新得越慢,否则两个网络相互追逐,梯度方向来回翻转,损失曲线就会变成密集锯齿。

2.3 训练循环骨架与done掩码

TD目标公式里另一个必须注意的细节是done标志。当s'是终止状态时,后面没有未来回报了,计算目标时要把gamma * max_{a'} Q_target(s', a')这一整项乘成0。如果忘了写(1 - done)这个掩码,终止状态的价值会被严重高估,相当于把“游戏结束”误当成“游戏还能继续”,整个Q值都会被污染。新手最容易犯的错就在这,我第一次写DQN时奖励曲线完全不收敛,检查了半天才发现目标值里少了done掩码。

下面给一个简化的训练循环框架,我的经验是先把循环骨架跑对,再往里塞复杂模块:

for episode in range(total_episodes): obs = env.reset() episode_reward = 0 while True: if random.random() < epsilon: action = env.action_space.sample() else: q_vals = q_net(obs_tensor) action = q_vals.argmax().item() next_obs, reward, terminated, truncated = env.step(action) done = terminated or truncated buffer.push(obs, action, reward, next_obs, done) obs = next_obs episode_reward += reward if len(buffer) > warmup_steps: q_loss = update_dqn(q_net, target_net, optimizer, buffer, batch_size) if total_steps % target_update_freq == 0: target_net.load_state_dict(q_net.state_dict()) epsilon = max(epsilon_min, epsilon * epsilon_decay) if done: break

对应的update函数:

def update_dqn(q_net, target_net, optimizer, buffer, batch_size): obs, actions, rewards, next_obs, dones = buffer.sample(batch_size) predicted = q_net(obs).gather(1, actions) with torch.no_grad(): max_next = target_net(next_obs).max(dim=1, keepdim=True).values target = rewards + gamma * max_next * (1 - dones) loss = smooth_l1_loss(predicted, target) optimizer.zero_grad() loss.backward() optimizer.step()

这里值得强调两点:sample之后把dones传到target的计算里做掩码,以及max_next用的是target_net而不是在线q_net。这两处一旦写错,你会得到一份几乎不可能收敛的DQN。另外注意warmup_steps的用法:缓冲区里样本还太少时,先只探索不更新,避免模型在数据很稀疏的阶段就被带偏。常见设置是前1k到10k步只填充经验池。

3. 策略梯度:直接对策略求导是另一种解法

3.1 为什么值函数方法在连续动作上吃力

值函数方法的基本路径是先把价值估准,再从价值间接得出策略:Q值大的动作就选它。但问题在于,连续动作空间里要在每一步求解argmax over action,这个优化会非常难受。动作空间是连续实数的时候,你没法枚举所有动作,而像爬山一样去搜索最优动作又慢又不稳定。策略梯度方法更改了提问方式:我不再关心每个动作值多少分,而是直接参数化策略π_theta(a|s),用梯度上升让“好轨迹”出现的概率更大。

具体实现上,策略网络会输出一个概率分布,比如在高斯策略里输出均值和方差,动作通过采样得到。拿这个采样结果去和环境交互,整个过程顺理成章。这也是为什么做机器人控制这类连续动作问题时,策略梯度家族比DQN系方法要自然得多。

3.2 log概率公式的直觉与baseline

数学上的切入点是定义目标函数J(theta)等于期望轨迹累计回报,然后计算它的梯度。这个梯度里有一项核心公式,可以写成:

E[ sum_t ∇theta log π_theta(a_t | s_t) * R(τ) ]

第一次看到这个公式的人多半会懵,为什么是log概率再乘总回报?其实背后的直觉很朴素:把一条完整轨迹看成一个整体,如果这条轨迹最后累计回报高,我们希望沿着能让这条轨迹更可能出现的方向调整参数,也就是提高轨迹上每个动作的log概率;如果累计回报低,就降低这些动作的概率。乘子是同一个R(τ),相当于整条轨迹“有功同赏、有过同罚”,不是针对某个单独动作评价。

这种做法的直接问题是方差太大。环境本身有随机性,同一条策略采样出来的轨迹回报可能天差地别,如果直接乘R(τ),梯度方向会被个别运气很好的轨迹主导。为了降低方差,常见的做法是引入baseline:不是用整条轨迹的R(τ),而是用这个动作相比平均水平好多少,也就是优势函数。最简单的baseline可以是状态价值V(s),这样每个时间步的权重变成:

A(s_t, a_t) = r_t + γV(s_{t+1}) - V(s_t)

这个值可以理解成“这个动作的实际结果比我预期好还是差”。为正就是好,就提升该动作概率;为负就降低。这样一来,梯度的尺度统一到“相对优势”上,不再受绝对回报大小支配,训练过程和奖励尺度之间的关系也被弱化。这一小步,正是通向Actor-Critic框架的桥梁。

3.3 REINFORCE入门与方差焦虑

入门时你可以实现一个带baseline的REINFORCE:采样一整条episode,计算累计折扣回报,减去baseline,再乘log概率梯度。逻辑很直白,代码量也很少,很适合确认自己真的理解了这个公式:

rewards_to_go = [] running_return = 0 for r in reversed(rewards): running_return = r + gamma * running_return rewards_to_go.insert(0, running_return) advantage = rewards_to_go - values loss = -(log_probs * advantage.detach()).mean()

但入门归入门,真正跑起来你会发现纯REINFORCE的方差还是太大。一个episode里步数稍微长一点,累计回报的数字就会很大,梯度更新容易一步跨过头。还会遇到一种尴尬现象:明明策略已经不错了,某一次采样运气不好,整条轨迹回报很低,反而把策略往差的方向推。这也是为什么工程上更常用下一章的Actor-Critic框架,它每一步都能用Critic的估计提供低方差优势信号,而不是等整条轨迹结束才给反馈。

4. Actor-Critic:策略网络和价值网络的互保机制

4.1 两个网络的具体分工

如果把策略梯度比作一个只靠事后总结做复盘的慢思考者,那Actor-Critic就是给这位思考者配了一位全程盯盘的助理。Actor指策略网络,负责根据当前状态输出动作概率分布,并从分布中采样动作;Critic指价值网络,负责估计状态价值V(s),或者更精细地估计动作价值Q(s,a)。Critic的存在就是为了给Actor提供一个温和、低方差的优势信号,让Actor知道当前这个动作比平均水平好还是差,而不是用整条轨迹的总回报这样的粗糙火力。

两者之间是一个持续的互保循环:Critic用越来越准的价值估计帮助Actor降低方差,Actor的新策略产生的新数据又反过来帮助Critic校正价值估计。直到价值估计和策略都稳定下来,这个循环才慢慢收敛。你如果看过入门资料里那些动态图,会发现Actor和Critic的箭头转个不停,其实就是这个互相监督的过程。

4.2 优势函数如何让训练温顺下来

训练过程的关键在于TD误差。Critic每步读到(s, a, r, s')之后,能立刻算出一个优势估计:

A = r + γV(s') - V(s)

这个A如果为正,表示当前动作带来的结果好于Critic原本对状态的预期,Actor就增大该动作概率;为负则减小。Critic本身也会被这个TD误差反向监督,让V(s)的预测越来越接近真实的期望回报。为什么用优势而不是直接用回报?因为优势函数相当于“这个动作相比平均水平好了多少”,baseline已经内建在估计值里,方差自然大大降低。这也是A2C这类框架能稳定工作的原因。

A2C是这一类框架里我推荐初学者先跑通的版本。A3C是它的异步版本,允许多个环境各自并行交互、异步把梯度传到全局网络;A2C把它们改成同步等待,所有环境完成一个rollout后再汇总梯度更新一次。同步版本逻辑更干净,调试起来不用处理参数同步的竞态问题,效果也往往不输异步版本。如果你看论文遇到A3C,记住它和A2C的核心网络结构其实一样,差别主要在并行方式上。

4.3 A2C训练骨架与两个关键工程细节

伪代码骨架可以写成:

for rollout in range(total_rollouts): obs_list, action_list, reward_list, next_obs_list, done_list, log_prob_list = [], [], [], [], [], [] for _ in range(rollout_length): dist = actor(obs) action = dist.sample() log_prob = dist.log_prob(action) next_obs, reward, terminated, truncated = env.step(action.item()) done = terminated or truncated obs_list.append(obs) action_list.append(action) reward_list.append(reward) next_obs_list.append(next_obs) done_list.append(done) log_prob_list.append(log_prob) obs = next_obs if done: obs = env.reset() values = critic(obs_list) next_values = critic(next_obs_list) advantages = (rewards + gamma * next_values * (1 - dones)) - values actor_loss = -(log_prob_list * advantages.detach()).mean() critic_target = rewards + gamma * next_values * (1 - dones) critic_loss = smooth_l1_loss(values, critic_target) total_loss = actor_loss + critic_loss optimizer.zero_grad() total_loss.backward() torch.nn.utils.clip_grad_norm_(actor.parameters(), max_norm=0.5) torch.nn.utils.clip_grad_norm_(critic.parameters(), max_norm=0.5) optimizer.step()

这里有两个值得展开的工程细节。第一,advantages计算里的detach。Actor的损失函数只希望对策略网络求梯度,不希望沿Critic的路径回传梯度,所以要用detach把优势当作一个固定的确定性标量,否则优化目标会变得含糊,训练方向会被价值网络产生的梯度污染。第二个细节是梯度裁剪。策略网络输出一个概率分布,一旦某些样本的log_prob遇到极小概率值,梯度会异常巨大,一次更新就可能把网络参数甩出可工作区域。max_norm取0.5或1.0是我常用的值,能有效防住这类梯度爆炸。

如果你发现训练曲线后期还是一路震荡,可以考虑引入熵正则项。思想是给策略的熵加一个小的正系数,鼓励策略保持一点随机性,避免过早坍缩到某个确定性动作上。一般做法是在actor_loss里加 -beta * entropy。beta不宜太大,0.01量级比较合适,否则策略永远像一只没头苍蝇,探索过度,收敛也会变慢。

5. 训练不稳定的典型信号与我的调参对策

到了这一章,我默认你已经把前面几种算法的骨架都能跑通了。这时你会遇到一个真实的瓶颈:代码逻辑没错,环境也能正常交互,但训练就是不收敛、奖励就是上不去。强化学习调参的坑和普通深度学习不太一样,问题往往不是模型容量不足,而是训练信号本身不稳定。下面列几个我自己实验里反复遇到的现象和解决思路。

5.1 损失锯齿:先查缓冲区,再查目标网络

第一种现象:损失曲线像密集锯齿一样狂跳,完全没有下降趋势。绝大多数情况下先检查目标网络更新频率是否过低,以及batch内样本是否来自同一个短暂时期。如果你把buffer从10k调到1M后锯齿明显变平,说明此前缓冲区太小了;如果buffer没问题,再把target更新频率调大一点看看。还有一种可能性是学习率偏大,把学习率从常见的1e-3降到3e-4或1e-4,很多时候立刻见效。

我接手的某个避障Demo就遇到过这类问题,经验池只有20k,智能体在一条街区来回打转,缓冲区里塞满的都是高度相似的样本。每轮梯度更新都在强化这同一个循环,奖励自然推不动。把缓冲区提到500k之后,模型才开始看到足够多样的状态,训练曲线才终于往上走。这一条是调参时最先要排除的。

5.2 奖励不动:loss下降不等于策略变好

第二种现象:奖励曲线长时间不动,但loss本身在下降。这个更有迷惑性。loss下降可能只是网络把Q值预测得更加“自洽”了,不代表策略在变好,因为损失函数衡量的是预测值和自生成目标之间的差异,目标本身可能也在跟着摆烂。此时建议去检查探索率是否过早降到0,或者reward scale是否太小,把所有信号都淹没在噪声里。

我在某个避障项目上经历过奖励曲线几十个episode毫无进展,后来发现奖励一直是0/1这种稀疏信号,加上epsilon衰减太快,智能体几乎没机会做有效探索,整条训练就是死水一潭。调整方式是把epsilon衰减曲线放平一点,并把每步的势能奖励加进去,让智能体哪怕没完成目标,也能因为“离目标更近了一点点”得到微弱反馈。很多问题的本质不是模型学不会,而是信号本身太稀薄。

5.3 失忆与复现性问题

第三种现象:智能体学了一阵子之后突然“失忆”,表现断崖式下降。这种情况常见于经验回放缓冲区太小。由于新样本不断覆盖旧样本,模型一直在用最近产生的数据更新,旧经验的多样性迅速流失,网络很快忘记早期有用行为。对策是加大buffer容量、降低每步更新频率,以及确保epsilon不会完全退到0,保留一个最低探索率。

第四种现象:同一个随机种子下跑两次结果居然不一样,很难定位是算法问题还是环境问题。先检查环境是否有非确定性:如果在GPU上训练,CUDA的随机性几乎无法彻底消除;更常见的是环境本身有随机初始化,比如起始状态随机。想稳定复现,就要在固定所有随机种子的同时,把初始状态也固定住,然后放到CPU上跑一遍确认同一随机种子是否完全一致。如果CPU上还不一致,说明环境里藏着隐藏的随机源,需要逐个排查。

5.4 一套可直接抄的初始参数表与验收标准

最后给一份我自己常用的初始参数表,省得每次都从头调:

参数建议范围
gamma0.99
经验回放缓冲区100k - 1M
batch size32 - 64
warmup steps1k - 10k
目标网络更新频率2k - 10k步
学习率3e-4 - 1e-3
优化器Adam
梯度裁剪 max_norm0.5 - 1.0
熵系数(A2C)0.01
epsilon 下限0.01 - 0.05

需要补充的是,这些参数只是起点,不是万能答案。我见过因为奖励函数设计不同,同一套算法表现完全两个样子。强化学习里最难Debug的其实是“价值网络是否真的学到了一个好信号”,很多问题表面看像超参数问题,本质上都是奖励信号设计得太糙。如果发现怎么调都推不动,先回头检查每一项reward设计,确认它真的能引导智能体一步步完成目标,而不是只顾着调网络结构。

最后分享两个我比较受益的观察技巧:一是每N步输出一次当前策略在若干固定起始状态下的采样轨迹,用可视化或数值方式对比前后行为,这比只看loss曲线直观得多;二是在训练早期就保存一份小型的验证集轨迹,动态和早期版本对比,模型是否有真正的行为改善一目了然。强化学习项目最容易让人一头扎进loss曲线里出不来,但判断模型是否在学的最终标准永远是行为表现,而不是曲线本身。

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

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

立即咨询