代码实现与注释import random # 导入random库用于生成随机数 import gym # 导入gym库强化学习的标准环境构建库 import numpy as np # 导入numpy库用于进行高效的矩阵/数组科学计算 from tqdm import tqdm # 导入tqdm库用于在终端显示训练进度的进度条 import torch # 导入PyTorch深度学习框架的核心库 import torch.nn.functional as F # 导入PyTorch中包含激活函数、损失函数等无参数运算的模块 from torch.distributions import Normal # 导入正态分布模块用于策略网络输出随机动作分布 import matplotlib.pyplot as plt # 导入matplotlib绘图库用于训练后绘制奖励曲线 import rl_utils # 导入自定义的强化学习工具包通常包含经验回放池 ReplayBuffer 等组件 class PolicyNetContinuous(torch.nn.Module): 定义连续动作空间的策略网络 (Actor) def __init__(self, state_dim, hidden_dim, action_dim, action_bound): super(PolicyNetContinuous, self).__init__() # 调用父类 torch.nn.Module 的初始化方法 self.fc1 torch.nn.Linear(state_dim, hidden_dim) # 第一层全连接层输入状态维度输出隐藏层维度 #DDPG 的 Actor 网络只有一个输出层给定一个状态 $s$它永远只吐出一个固定死板的动作 $a$。如果要探索只能在外部强行加上人工噪声。SAC 认为在很多情况下**“最优解不止一个”**。 #因此SAC 的 Actor 不输出死板的动作而是输出一个**动作的概率分布**通常假设为高斯分布 / 正态分布。 self.fc_mu torch.nn.Linear(hidden_dim, action_dim) # 均值输出层由隐藏层计算动作的高斯分布均值(mu) self.fc_std torch.nn.Linear(hidden_dim, action_dim) # 方差输出层由隐藏层计算动作的高斯分布标准差(std) self.action_bound action_bound # 记录环境允许的动作最大绝对边界值如方向盘最大打 30 度 # 输入状态 # 网络输出一个高斯分布对高斯分布进行重参数化随机采样采样出来一个动作以及该动作的对数概率 def forward(self, x): # 前向传播函数x 代表输入的状态数据 x F.relu(self.fc1(x)) # 状态 x 经过第一层全连接层并使用 ReLU 激活函数进行非线性映射 mu self.fc_mu(x) # 经过均值层得到当前状态下正态分布的均值 std F.softplus(self.fc_std(x)) # 经过方差层使用 softplus 激活函数因为标准差必须大于0且该函数平滑可导 dist Normal(mu, std) # 以算出的 mu 和 std 为参数构建一个 PyTorch 的正态分布对象 # rsample()是重参数化采样(Reparameterization Trick)。 # 它不同于普通的 sample()它能让随机采样出来的数值保留梯度计算图连通使得误差可以反向传播回 mu 和 std 网络。 normal_sample dist.rsample() # 计算采样出来的动作在这个正态分布下的对数概率密度log probability log_prob dist.log_prob(normal_sample) # 使用 tanh 函数将采样出的无限范围的高斯噪声强行压缩映射到 [-1, 1] 的闭区间内 action torch.tanh(normal_sample) # 【核心数学修正】由于对动作施加了 tanh 非线性变换根据概率论变量代换公式 # 需要减去变换导数的对数值以修正其对数概率密度。加 1e-7 是为了防止 tanh(action)^2 极度接近 1 时导致 log(0) 溢出报错 log_prob log_prob - torch.log(1 - torch.tanh(action).pow(2) 1e-7) # 将 [-1, 1] 范围的动作乘以环境真实的动作边界如 30度还原为物理世界能用的真实动作 action action * self.action_bound return action, log_prob # 返回计算出的确定动作以及修正后的对应动作对数概率 class QValueNetContinuous(torch.nn.Module): 定义连续动作空间的价值网络 (Critic) def __init__(self, state_dim, hidden_dim, action_dim): super(QValueNetContinuous, self).__init__() # 调用父类初始化 # Critic的特点是必须同时输入【状态】和【动作】来给这个组合打分。所以输入维度是 state_dim action_dim self.fc1 torch.nn.Linear(state_dim action_dim, hidden_dim) self.fc2 torch.nn.Linear(hidden_dim, hidden_dim) # 第二层全连接层 self.fc_out torch.nn.Linear(hidden_dim, 1) # 输出层输出一个标量即 Q(s, a) 的具体打分 #输入【状态】和【动作】 #输出当前状态-动作对的 Q 值分数 def forward(self, x, a): # 前向传播函数x 是状态a 是动作 cat torch.cat([x, a], dim1) # 沿着特征维度dim1将状态向量和动作向量硬拼接到一起 x F.relu(self.fc1(cat)) # 拼接后的张量经过第一层使用 ReLU 激活 x F.relu(self.fc2(x)) # 经过第二层再次 ReLU 激活 return self.fc_out(x) # 经过输出层吐出当前状态-动作对的 Q 值分数 class SACContinuous: 处理连续动作的SAC算法主体逻辑 def __init__(self, state_dim, hidden_dim, action_dim, action_bound, actor_lr, critic_lr, alpha_lr, target_entropy, tau, gamma, device): # 1. 初始化 1 个策略网络 (Actor) self.actor PolicyNetContinuous(state_dim, hidden_dim, action_dim, action_bound).to(device) # 2. 初始化 2 个主 Q 网络 (Critic)。【SAC 的双Q技巧用于缓解强化学习常见的 Q 值高估问题】 self.critic_1 QValueNetContinuous(state_dim, hidden_dim, action_dim).to(device) self.critic_2 QValueNetContinuous(state_dim, hidden_dim, action_dim).to(device) # 3. 初始化 2 个目标 Q 网络 (Target Critic)用于提供稳定的 TD 目标标签靶子 self.target_critic_1 QValueNetContinuous(state_dim, hidden_dim, action_dim).to(device) self.target_critic_2 QValueNetContinuous(state_dim, hidden_dim, action_dim).to(device) # 4. 令目标 Q 网络的初始参数和主 Q 网络完全一致硬拷贝初始化 self.target_critic_1.load_state_dict(self.critic_1.state_dict()) self.target_critic_2.load_state_dict(self.critic_2.state_dict()) # 5. 定义各自对应的 Adam 优化器 self.actor_optimizer torch.optim.Adam(self.actor.parameters(), lractor_lr) self.critic_1_optimizer torch.optim.Adam(self.critic_1.parameters(), lrcritic_lr) self.critic_2_optimizer torch.optim.Adam(self.critic_2.parameters(), lrcritic_lr) # 6. 【SAC 的灵魂自动调整温度系数 Alpha】 # 使用 log_alpha 作为优化的变量保证实际的 alpha exp(log_alpha) 永远大于 0 self.log_alpha torch.tensor(np.log(0.01), dtypetorch.float) self.log_alpha.requires_grad True # 明确告诉 PyTorch我们要对 log_alpha 求导并动态优化它 self.log_alpha_optimizer torch.optim.Adam([self.log_alpha], lralpha_lr) # 为其单独设置优化器 # 7. 存储其他算法超参数 self.target_entropy target_entropy # 算法追求的目标熵大小通常设为负的动作维度作为随机性的底线 self.gamma gamma # 奖励折扣因子0~1之间越接近1越看重长远未来 self.tau tau # 软更新系数通常是个很小的值如 0.005 self.device device # 指定运行设备 CPU / GPU def take_action(self, state): 与环境交互时的动作采样函数 state torch.tensor([state], dtypetorch.float).to(self.device) # 将传入的 NumPy 状态转换为 PyTorch 张量并加一个 Batch 维度 action self.actor(state)[0] # 调用策略网络前向传播返回 (action, log_prob)取索引 [0] 只要动作 return [action.item()] # 取出张量中的标量数值并包裹在列表里返回给环境 def calc_target(self, rewards, next_states, dones): 内部辅助函数计算 Critic 训练所需的目标 Q 值 (TD Target) # 让 Actor 看一眼下一步状态预测出下一步的动作和对数概率注意这里使用的是当前最新策略 next_actions, log_prob self.actor(next_states) entropy -log_prob # 熵Entropy在数学上等于负的对数概率。对数概率越小动作越随机熵越大 # 让两个目标 Q 网络分别给【下一步状态 下一步动作】打分 q1_value self.target_critic_1(next_states, next_actions) q2_value self.target_critic_2(next_states, next_actions) # 【双Q网络截断防高估】 取两个打分中较小的一个。同时加上 温度系数 * 熵 的额外奖励鼓励探索 next_value torch.min(q1_value, q2_value) self.log_alpha.exp() * entropy #合起来给策略加 “探索奖励”让算法既要奖励高也要动作随机、探索充分 # 根据贝尔曼方程组装 TD 目标值 即时奖励 衰减系数 * (下一步的混合价值) * (如果没死) td_target rewards self.gamma * next_value * (1 - dones) return td_target def soft_update(self, net, target_net): 内部辅助函数目标网络软更新机制 for param_target, param in zip(target_net.parameters(), net.parameters()): # 目标网络的参数 旧目标参数 * (1 - tau) 主网络新参数 * tau # 使用 .data.copy_() 在原内存地址上就地覆盖数值防止计算图中断或爆显存 param_target.data.copy_(param_target.data * (1.0 - self.tau) param.data * self.tau) def update(self, transition_dict): SAC算法的核心从经验回放池抽出一批数据进行网络参数更新 # ---- 1. 数据预处理阶段 ---- # 将传入的字典数据List全部转换为 PyTorch 张量并送到对应设备。view(-1, 1) 是为了保证它们是列向量形式。 states torch.tensor(transition_dict[states], dtypetorch.float).to(self.device) actions torch.tensor(transition_dict[actions], dtypetorch.float).view(-1, 1).to(self.device) rewards torch.tensor(transition_dict[rewards], dtypetorch.float).view(-1, 1).to(self.device) next_states torch.tensor(transition_dict[next_states], dtypetorch.float).to(self.device) dones torch.tensor(transition_dict[dones], dtypetorch.float).view(-1, 1).to(self.device) # 针对倒立摆 (Pendulum) 环境特定的奖励重塑Reward Shaping把奖励从 [-16, 0] 映射到 [0, 1] 左右加速神经网络收敛 rewards (rewards 8.0) / 8.0 # ---- 2. 更新两个 Critic 价值网络 ---- td_target self.calc_target(rewards, next_states, dones) # 算出真理靶子 TD Target # 计算 主Q网络1 预测的分数与真理靶子的均方误差。 # 【极其关键】td_target 必须加 .detach() 截断梯度回传因为靶子是固定的标签绝不能让梯度顺着靶子流回 Actor 或 Target Critic critic_1_loss torch.mean(F.mse_loss(self.critic_1(states, actions), td_target.detach())) critic_2_loss torch.mean(F.mse_loss(self.critic_2(states, actions), td_target.detach())) # 标准的 PyTorch 梯度下降三步曲 (清空梯度 - 反向传播求导 - 更新参数) self.critic_1_optimizer.zero_grad() critic_1_loss.backward() self.critic_1_optimizer.step() self.critic_2_optimizer.zero_grad() critic_2_loss.backward() self.critic_2_optimizer.step() # ---- 3. 更新 Actor 策略网络 ---- # 用最新策略网络对【当前状态】重新算出如果现在做决策会选什么动作以及其对数概率 new_actions, log_prob self.actor(states) entropy -log_prob # 算出新动作的熵 # 把新动作塞进刚才更新过的两个主 Q 网络中看看现在的神仙教练能给几分 q1_value self.critic_1(states, new_actions) q2_value self.critic_2(states, new_actions) # Actor 的终极使命既希望动作够随机温度系数*熵 越大越好又希望动作能拿高分min(q1,q2) 越大越好。 # 因为 PyTorch 的优化器默认是在求“最小值(梯度下降)”所以我们要最大化上面的目标就必须在整体前面加一个负号(-)变成损失函数。 actor_loss torch.mean(-self.log_alpha.exp() * entropy - torch.min(q1_value, q2_value)) # 梯度下降更新 Actor 参数 self.actor_optimizer.zero_grad() actor_loss.backward() self.actor_optimizer.step() # ---- 4. 更新 Alpha 温度系数 (SAC 特有的自动调温机制) ---- # Alpha 掌控着“探索(随机性)”与“利用(追求高分)”的平衡。 # 公式含义如果当前的平均熵(entropy)小于目标底线熵(target_entropy)目标底线熵是一个超参数说明动作太死板了(entropy - target)为负数 # 为了让 alpha_loss 变小梯度下降网络会自动调大 alpha放大系数从而鼓励策略变随机。反之同理。 # .detach() 是因为我们只用当前熵的数值作为判断基准不需要也不应该对 Actor 求梯度。 alpha_loss torch.mean((entropy - self.target_entropy).detach() * self.log_alpha.exp()) self.log_alpha_optimizer.zero_grad() alpha_loss.backward() self.log_alpha_optimizer.step() # ---- 5. 目标网络软更新 ---- # 最后让幕后冷冻的目标 Critic 1 和 2向着今天刚挨过训的主 Critic 1 和 2 进行微小幅度的参数渗透 self.soft_update(self.critic_1, self.target_critic_1) self.soft_update(self.critic_2, self.target_critic_2)个人总结Actor进化的唯一指标就是让 Critic价值网络给它打的分数越高越好。同时Critic 计算未来靶子TD Target时也会挑选那个能让未来 Q 值最大的动作。但是神经网络是有误差的存在噪声由于泛化和逼近误差Critic 对某个动作的打分总会在真实价值上下波动。有时估高了有时估低了。但是算法总是贪婪地去“最大化”收益所以尽管有虚高但是神经网络还是会选择因为噪声而偶然给出的虚假高分。Actor 发现某个垃圾动作因为 Critic 的误差拿了 100 分。Actor 立刻修改参数疯狂输出这个垃圾动作。下一步计算 TD 目标值时把这个虚假的 100 分当成了“真理靶子”。主 Critic 追赶这个虚假靶子把自己的分数也推高到了 105 分。误差越搞越大。误差在最大化操作下永远会不可逆地向上累积。为了解决这个问题SAC提出了一个极其简单但极为有效的工程手段养两个各自独立的 Critic双Q网络self.critic_1 QValueNetContinuous(...) self.critic_2 QValueNetContinuous(...)这两个网络结构一模一样但是初始化参数完全不同。它们就像两个独立的评审委员。在 SAC 计算下一步的真理靶子TD Target时代码是这样写的q1_value self.target_critic_1(next_states, next_actions) q2_value self.target_critic_2(next_states, next_actions) ​ # 【核心魔法】取两者的最小值 next_value torch.min(q1_value, q2_value) self.log_alpha.exp() * entropy很多人会问为什么不取均值。如果取平均值那个虚高的 150 分依然把整体平均分拉高了。随着自举Bootstrapping的反复循环高估的毒素依然会慢慢渗透进网络最终依然会导致 Q 值爆炸。最大熵强化学习DDPG / TD3没有 α没有熵完全没有探索机制策略会迅速变成确定性动作。因此必须手动加噪声效果极差。所以SAC v1 提出了 总目标奖励 α*熵α 是固定值人工调参训练前期需要大 α 探索训练后期需要小 α 收敛固定 α 无法兼顾 → 效果差、难调参。所以SAC v2提出自动 α并设置它为可梯度更新参数不断的调整让策略熵 ≈ 目标熵target_entropy不论在何时何地都能保持一定的探索率。α 更新完整 6 步流程步骤 1初始化 log_alpha开启自动学习self.log_alpha torch.tensor(np.log(0.01), dtypetorch.float) self.log_alpha.requires_grad True # 允许求梯度、允许更新 self.log_alpha_optimizer torch.optim.Adam([self.log_alpha], lralpha_lr)作用把 log_alpha 变成可训练参数给它配独立优化器步骤 2训练时得到当前策略的熵 entropynew_actions, log_prob self.actor(states) entropy -log_prob # 策略的熵 -对数概率作用得到当前策略有多随机步骤 3计算 α 的损失 alpha_lossalpha_loss torch.mean( (entropy - self.target_entropy).detach() * self.log_alpha.exp() )公式作用计算当前熵与目标熵的差距告诉 α 该变大还是变小步骤 4清空梯度self.log_alpha_optimizer.zero_grad()步骤 5反向传播计算 log_alpha 的梯度alpha_loss.backward()步骤 6优化器更新 log_alpha → α 自动改变self.log_alpha_optimizer.step()新 log_alpha → 新 α exp(新 log_alpha)三、α 自动调节的核心规则看这就够1. 策略探索太少熵太小entropy target_entropy→ 差值为负→ alpha_loss 变小→ 梯度下降会增大 log_alpha→ α 变大→ 探索奖励增强→ 策略变随机2. 策略探索太多熵太大entropy target_entropy→ 差值为正→ alpha_loss 变大→ 梯度下降会减小 log_alphalog是单调增函数减小 log_alphaα 变小→ 探索奖励减弱→ 策略变稳定四、最精简一句话总结α 更新流程 计算当前策略熵 → 算与目标熵的差距 → 算 alpha_loss → 反向传播 → 更新 log_alpha → 得到新 α永远只有一个目标让策略的随机性熵稳定在目标熵附近不多也不少。Actor代码分析class PolicyNetContinuous(torch.nn.Module): 定义连续动作空间的策略网络 (Actor) def __init__(self, state_dim, hidden_dim, action_dim, action_bound): super(PolicyNetContinuous, self).__init__() # 调用父类 torch.nn.Module 的初始化方法 self.fc1 torch.nn.Linear(state_dim, hidden_dim) # 第一层全连接层输入状态维度输出隐藏层维度 #DDPG 的 Actor 网络只有一个输出层给定一个状态 $s$它永远只吐出一个固定死板的动作 $a$。如果要探索只能在外部强行加上人工噪声。SAC 认为在很多情况下**“最优解不止一个”**。 #因此SAC 的 Actor 不输出死板的动作而是输出一个**动作的概率分布**通常假设为高斯分布 / 正态分布。 self.fc_mu torch.nn.Linear(hidden_dim, action_dim) # 均值输出层由隐藏层计算动作的高斯分布均值(mu) self.fc_std torch.nn.Linear(hidden_dim, action_dim) # 方差输出层由隐藏层计算动作的高斯分布标准差(std) self.action_bound action_bound # 记录环境允许的动作最大绝对边界值如方向盘最大打 30 度 ​ # 输入状态 # 网络输出一个高斯分布对高斯分布进行重参数化随机采样采样出来一个动作以及该动作的对数概率 def forward(self, x): # 前向传播函数x 代表输入的状态数据 x F.relu(self.fc1(x)) # 状态 x 经过第一层全连接层并使用 ReLU 激活函数进行非线性映射 mu self.fc_mu(x) # 经过均值层得到当前状态下正态分布的均值 std F.softplus(self.fc_std(x)) # 经过方差层使用 softplus 激活函数因为标准差必须大于0且该函数平滑可导 dist Normal(mu, std) # 以算出的 mu 和 std 为参数构建一个 PyTorch 的正态分布对象 # rsample()是重参数化采样(Reparameterization Trick)。 # 它不同于普通的 sample()它能让随机采样出来的数值保留梯度计算图连通使得误差可以反向传播回 mu 和 std 网络。 normal_sample dist.rsample() # 计算采样出来的动作在这个正态分布下的对数概率密度log probability log_prob dist.log_prob(normal_sample) # 使用 tanh 函数将采样出的无限范围的高斯噪声强行压缩映射到 [-1, 1] 的闭区间内 action torch.tanh(normal_sample) # 【核心数学修正】由于对动作施加了 tanh 非线性变换根据概率论变量代换公式 # 需要减去变换导数的对数值以修正其对数概率密度。加 1e-7 是为了防止 tanh(action)^2 极度接近 1 时导致 log(0) 溢出报错 log_prob log_prob - torch.log(1 - torch.tanh(action).pow(2) 1e-7) # 将 [-1, 1] 范围的动作乘以环境真实的动作边界如 30度还原为物理世界能用的真实动作 action action * self.action_bound return action, log_prob # 返回计算出的确定动作以及修正后的对应动作对数概率action torch.tanh(normal_sample) 为何要强行压缩映射到 [-1, 1] 的闭区间内把动作强行压缩到[-1, 1]的闭区间学术上称为Squashed Gaussian / 挤压高斯分布是纯粹为了向物理现实妥协并兼顾深度学习求导机制的必然选择。