ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于DDPG的电力市场报价策略复现与训练调参实战

基于DDPG的电力市场报价策略复现与训练调参实战

最近把某篇 Trans 系列期刊里的“基于 Agent 的电力市场深度强化学习报价策略”用 Python 完整复现了一遍,从读公式到改环境再到调 DDPG 训练,整个流程下来踩了不少坑。这篇文章就当一次复盘,把我在复现时做的关键决策、代码组织方式、训练时遇到的奇葩问题都写出来,希望能给正在做类似论文复现或电力市场强化学习研究的人一点参考。

这里先说明一下,论文标题里的“深度决策梯度”并不是什么新算法,对应到实际代码就是深度确定性策略梯度(DDPG),也就是 Deep Deterministic Policy Gradient。只不过有些论文作者在翻译时会用“决策梯度”这个词来表示策略梯度类方法。所以下面我会以 DDPG 为主线来展开,同时会交代清楚为什么这类连续动作问题不能用 DQN 这类离散动作算法硬解。如果你正在做多智能体电力市场、电力现货报价策略、或者任何涉及连续动作的博弈决策建模,这篇文章会比较对口。

1. 电力市场决策问题与论文复现思路

1.1 这到底是个什么问题:报价而不是定价

电力市场里的发电商,并不是自己说了算想卖多少钱就卖多少钱。实际交易中心会收集所有报价,按价格从低到高排序,再结合用户侧负荷需求来确定出清价格,所有成交机组通常按统一边际价格结算。这个机制很像拍卖:你报高了可能卖不出去,报低了虽然能成交但利润空间被压缩。

所以 Agent 要学的不是“怎么定电价”,而是“怎么报价”。报价是一个连续动作,可以在价格上限之内取任意数值。报价策略要考虑自己的成本、系统负荷、竞争对手可能的报价、历史出清价格等一堆信息。这个问题本质上是一个多 Agent 序贯博弈:所有发电商都在同时学习,一个 Agent 改策略,整个出清结果就变了,其他 Agent 的状态转移也变了。

论文复现时要做的第一件事,不是急着写网络,而是把这个市场机制落到代码层面。我当时的做法是:写一个模拟市场环境类,接收所有 Agent 的动作,也就是报价系数,然后按发电成本计算实际报价,调用一个简单的出清函数,返回每个 Agent 的中标电量和统一出清价。环境返回的奖励就是每台机组的利润。

这一步看起来简单,但确实最容易出错。比如报价排序时相同价格的机组怎么处理,机组的中标上限怎么限制,需求曲线是刚性还是弹性,这些细节直接决定后续训练能不能收敛。论文里可能一句话带过“按照边际出清规则结算”,但代码里每一个判断分支都要落实。

1.2 为什么选深度确定性策略梯度而不是其他算法

一开始我也想过 DQN,但很快发现不对。DQN 输出的是离散动作的概率分布,需要在有限的报价档位里做选择。可实际电力市场里,报价可以是连续的,即使你只允许报价格上限百分比 50% 到 150% 之间的任意值,离散化之后档位稍微一多,动作空间就爆炸,而且相邻档位的价值差异很小,DQN 很难学到精细的边际调整策略。

DDPG 正好解决这个问题。它是 Actor-Critic 结构,Actor 网络直接输出一个连续动作向量:在一个完全确定性的市场出清环境下,给定观测状态,Actor 输出一个连续报价系数。Critic 网络负责评估“在这个观测和动作组合下,未来期望收益有多大”。训练时用 DQN 的那套经验回放和 target 网络思想,让 Critic 更新稳定,再用确定性策略梯度去更新 Actor。

这里还有个关键点:DDPG 是 off-policy 算法,可以用一个行为策略去探索环境,然后把经验存进回放池,随时从中采样更新。论文里的电力市场仿真需要大量交互数据,如果被 on-policy 方法限制,每次更新都要重新采样,训练会慢到怀疑人生。off-policy 让数据利用率高很多,这也是论文选这类算法的重要原因。

当然,多 Agent 环境下硬套单 Agent DDPG 会有非平稳问题。因为每个 Agent 的观测里如果包含对手的报价信息,对手策略一变,你的状态转移就变了,Critic 的价值估计会乱跳。我在复现时采用的是多个 DDPG 实例并行训练,每个 Agent 有自己独立的行为网络和评估网络,同时环境中加入上一轮所有 Agent 的报价作为公共观测量,这样能让每个智能体对竞争环境有感知。

1.3 论文复现的完整工作流

复现这套代码,我基本按下面四步走。先通读论文里的模型定义和市场出清公式,把状态空间、动作空间、奖励函数三个要素列出来。然后写环境,不依赖强化学习算法,先用随机策略去跑一遍,检查出清逻辑和奖励数值是否合理。接着搭 DDPG 算法框架,分成网络结构、经验回放、探索策略、训练主循环几个模块。最后才是调参训练,盯着奖励曲线和报价行为判断是否收敛。

这四个步骤里,我花时间最多的是环境验证而不是神经网络。原因很简单,如果环境算出的奖励平均值本身就不对,后面网络学出来的策略再漂亮也是无源之水。而且论文里的公式有些符号不完整,得靠购物式推断,比如成本系数、容量约束这些参数具体取值,很可能需要参考同领域其他文献补全。

2. 环境建模核心细节:从物理电网到 Agent 观测空间

2.1 市场模型简化:统一出清与边际定价

电力市场环境我采用了最常用的简化模型。假设系统内有 N 个发电商,每个发电商一台机组。每台机组有私有成本参数:线性成本系数和二次成本系数。发电商向市场提交一个报价系数,实际报价就等于报价系数乘以基础成本。这个报价系数的取值范围我设置成 0.5 到 1.5,意思是可以在成本价的一半到一点五倍之间报价。

市场运营商收到所有报价后,计算每一档实际报价,按价格从小到大排序,然后按负荷需求逐台出清。出清时还要考虑每台机组的最大出力限制。当累计中标容量超过负荷需求后,最后一次被调用的机组部分牺牲出力,最后一台机组的报价就是系统统一出清价。

我在环境里把负荷需求设置成一个时变函数。为了让强化学习学到的策略更贴近真实市场,需求曲线不是固定数值,而是包含周期性波动和随机扰动。这个设计会让出清价有波动,Agent 才有必要去学习“在低需求时报低一些避免停机,在高需求时报高一些吃利润”。

这里有一种容易忽略的边界处理:如果所有机组都报高价,出清价会高于真实供需水平,市场变成“卖方市场”;如果都报低价,利润被压没。Agent 群体要通过学习达到一个纳什均衡似的状态,不会无限抬高价格,也不会恶意低价竞争。

2.2 观测空间与动作空间定义

观测空间的设计决定了 Agent 到底能“看到”什么。我在复现时,给每个 Agent 的观测包含六类信息:自身机组当前成本参数、上一轮自身中标电量、上一轮统一出清价、当前负荷需求、上一轮所有 Agent 的平均报价、以及一个时间特征表示当前处于日内哪个时段。

这些信息混合了私有信息与公共信息。自身成本参数是私有观测,负荷需求和出清价是公共信息。这里需要注意:观测空间里的数据量纲差异很大,负荷可能是几百兆瓦,成本是几十元每兆瓦时,直接喂给神经网络容易让训练波动。我在环境里对所有连续观测做了归一化,统一映射到 0 到 1 之间。

动作空间是每个 Agent 的报价系数,连续值,输出范围需要限制。Actor 网络最后一层用 tanh 激活,输出范围在 -1 到 1,再映射到 0.5 到 1.5。这个映射过程我单独封装成一个函数,因为后续如果改变报价范围,只需要改这一处。

价格上限的约束不是靠网络结构完成的。Agent 即使输出 1.5 的报价系数,也要经过市场出清。但如果你不做额外惩罚,Agent 可能会一直报上限价。所以我在奖励函数里已经隐含了约束,报价过高导致流标,利润为零,这比额外加惩罚项更干净。

2.3 奖励函数设计与多 Agent 博弈关系

奖励函数在强化学习里直接决定策略走向。对于发电商来说,最自然的奖励就是利润:中标电量乘以出清价,减去发电成本。如果机组没有中标,奖励就是零。这里我没有加入额外惩罚项,因为市场机制本身已经对不合理报价做出反馈:报高了难中标,报低了利润低。

多 Agent 环境下,每个 Agent 有自己的奖励,不能简单求和以后再分配给所有 Agent。有的论文会用团队奖励,但电力市场竞争场景里每个发电商都是独立利益主体,应该用独立奖励。这在代码里就体现为:每个 Agent 各自维护自己的 replay buffer、actor、critic,训练时只采样自己的经验。

由于所有 Agent 同时更新,环境是非平稳的。一个 Agent 的奖励变化不仅取决于自己动作,还取决于对手动作变化。让每个 Agent 观测中引入上一轮所有 Agent 的报价,能缓解部分非平稳性,但无法完全消除。从我的实验来看,DDPG 在这种环境下还是能收敛到比较稳定的报价策略,不过收敛速度会比单 Agent 环境慢不少,需要耐心跑几千个 episode。

3. Python 代码实现与训练实操

3.1 环境准备与关键依赖

我用的 Python 版本是 3.10,深度学习框架选了 PyTorch,版本 2.1。虽然 TensorFlow 也能写 DDPG,但 PyTorch 的动态图机制在调试网络结构时舒服得多。环境仿真部分只用了 NumPy,没有引入额外的电力系统仿真库,因为简化市场模型本身不复杂,自行实现更能确保和论文逻辑一致。

Python>=3.10 numpy>=1.24 torch>=2.1 gymnasium>=0.29 # 用来定义环境接口,方便包装 pandas>=2.0 # 记录训练日志 matplotlib>=3.8 # 可视化出清价格和报价曲线

gymnasium 在这里不是必须的,但用它的接口规范环境能省很多事。我给环境类实现了 reset、step 两个方法,内部维护一个全局状态字典。每个 Agent 的外部策略拿到的观测是字典切片,然后各自输出动作。环境内部一次性收集所有动作,统一出清,再返回下一状态和奖励。

3.2 网络结构与 DDPG 核心代码

DDPG 网络实现我拆成了两个类:Actor 和 Critic。

Actor 结构很简单:输入层接观测维度,两层 256 个神经元的全连接层,中间用 ReLU 激活,输出层用 tanh 激活。因为动作范围映射到 0.5 到 1.5,tanh 输出后再做线性映射。这里我尝试过用三层全连接,但效果提升有限,反而增加了训练时间,最终保留两层。

Critic 结构稍微特殊一些:它同时接收状态和动作,所以我把观测输入和动作输入分开处理。观测过一个全连接层后,和动作拼接在一起,再过两层全连接,最后输出一个 Q 值。这样搭的目的是让 Critic 在评估状态价值时能充分考虑到动作信息,而不是把动作简单追加在输入后面。

import torch import torch.nn as nn class Actor(nn.Module): def __init__(self, state_dim, hidden_dim=256, action_dim=1, action_range=(0.5, 1.5)): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim), nn.Tanh() ) low, high = action_range self.low = low self.high = high def forward(self, state): # tanh输出[-1,1],映射到[low, high] out = self.net(state) action = (out + 1) / 2 * (self.high - self.low) + self.low return action class Critic(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim=256): super().__init__() self.state_fc = nn.Linear(state_dim, hidden_dim) self.fc1 = nn.Linear(hidden_dim + action_dim, hidden_dim) self.fc2 = nn.Linear(hidden_dim, 1) def forward(self, state, action): s = torch.relu(self.state_fc(state)) x = torch.cat([s, action], dim=-1) x = torch.relu(self.fc1(x)) return self.fc2(x)

这里有个很关键的细节:target 网络的结构必须和主网络完全一致,但权重延迟更新。我在训练时没有用 PyTorch 自带的模型拷贝,而是手动写了一个软更新函数,每一轮训练后将 target 网络参数按一定比例向主网络靠近。

tau = 0.005 def soft_update(target_net, source_net): for target_param, source_param in zip(target_net.parameters(), source_net.parameters()): target_param.data.copy_( tau * source_param.data + (1 - tau) * target_param.data )

软更新比例 tau 不能太大,太大会导致 target 网络跟踪太紧,价值估计很容易发散。论文里常见取值是 0.001 到 0.01,我最终固定为 0.005。

3.3 训练主循环与经验回放实现

训练主循环是标准 DDPG 流程:每个 episode 开始时重置环境,在各台机组初始化随机成本参数。每个时间步,每个 Agent 根据当前观测,用带有探索噪声的 Actor 策略生成动作。环境执行动作,返回奖励和下一观测,存入每个 Agent 自己的 replay buffer。之后从 buffer 里随机采样 batch,训练 Actor 和 Critic。

探索噪声我用的是高斯噪声而不是 OU 噪声。OU 噪声在物理控制问题里有优势,因为它能产生时间相关性,但在报价策略上高斯噪声足够,而且参数更少。噪声标准差从 0.2 开始,随着 episode 数增加线性衰减到 0.02,让 Agent 前期充分探索,后期集中利用。

class ReplayBuffer: def __init__(self, capacity=200000): self.capacity = capacity self.buffer = [] self.pos = 0 def push(self, state, action, reward, next_state): if len(self.buffer) < self.capacity: self.buffer.append(None) self.buffer[self.pos] = (state, action, reward, next_state) self.pos = (self.pos + 1) % self.capacity def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) state = torch.FloatTensor([b[0] for b in batch]) action = torch.FloatTensor([b[1] for b in batch]) reward = torch.FloatTensor([b[2] for b in batch]).unsqueeze(1) next_state = torch.FloatTensor([b[3] for b in batch]) return state, action, reward, next_state def train_ddpg(agent_id, policy, replay_buffer, batch_size=64, gamma=0.99): state, action, reward, next_state = replay_buffer.sample(batch_size) with torch.no_grad(): next_action = policy.target_actor(next_state) target_q = reward + gamma * policy.target_critic(next_state, next_action) current_q = policy.critic(state, action) critic_loss = nn.MSELoss()(current_q, target_q) policy.critic_optimizer.zero_grad() critic_loss.backward() policy.critic_optimizer.step() actor_loss = -policy.critic(state, policy.actor(state)).mean() policy.actor_optimizer.zero_grad() actor_loss.backward() policy.actor_optimizer.step() soft_update(policy.target_actor, policy.actor) soft_update(policy.target_critic, policy.critic)

要注意,Critic 如果收敛不好,Actor 的梯度方向是错的。所以训练时我会更关注 Critic 的 loss 曲线,如果 Critic loss 一直不降,Actor 大概率在瞎学。我的做法是每 50 个 episode 记录一次平均 Q 值和 Critic loss,观测它们是否有相关性。

3.4 多 Agent 训练组织:同步更新还是交替更新

多 Agent 环境里,每个 Agent 虽然有独立网络,但训练节奏如果错开,会加剧非平稳性。我采用的是同步更新:一个 episode 内收集完所有 Agent 的经验后,在 episode 末尾统一更新所有 Agent。这样每个 Agent 训练时使用的都是同一轮环境交互结果,经验池里的数据时间对齐。

如果每个 Agent 用不同的采样频率,环境交互数据会有滞后,Agent 学习到的策略会落后于实际环境变化。当然同步更新的代价是单次训练耗时更长,因为要依次更新 N 个网络。我的模拟环境里默认 5 个 Agent,单次更新耗时仍在可接受范围内。

还有一个容易忽略的点:replay buffer 里存放的观测如果包含上一轮所有 Agent 报价,一旦对手策略变化,旧经验中记录的历史报价和新策略下的报价分布差异会很大。我在训练时设置了一个 buffer 清洗策略:当评估阶段的平均报价分布发生明显偏移时,只保留最近 50% 的经验。这个方法没有论文依据,但实测能提高稳定性。

4. 训练效果分析与调参实录

4.1 如何判断收敛:不是只看奖励曲线

强化学习训练里最直观的就是奖励曲线,但只盯着奖励曲线很容易被误导。因为电力市场是多 Agent 博弈,每个 Agent 的奖励受到其他 Agent 策略影响,短期波动很大。一个 Agent 奖励上升,很可能是另一个 Agent 策略突然变差导致的,不代表整体策略质量提升。

我的判断标准有三个。第一,各 Agent 的平均报价系数是否稳定在一个区间,不再大范围震荡。第二,统一出清价是否和理想竞争均衡价格接近,不能长期偏离。第三,随机种子改变后,跑多次训练是否仍然收敛到相近策略,如果每次结果千差万别,说明训练过程不稳定。

我记录了一张训练日志表,每 100 个 episode 统计平均奖励、平均报价、出清价、中标率。用 pandas 保存成 CSV,训练结束用 matplotlib 画曲线。其中报价曲线的稳定性是判断收敛最直观的信号。

指标早期(0-500)中期(500-1500)后期(1500-3000)
平均报价系数1.2 左右反复跳动逐步回落稳定在 1.05 附近
统一出清价偏高,波动剧烈波动变小进入合理区间
Agent 中标率波动大各 Agent 交替中标形成稳定模式
Critic Loss初期快速下降缓慢下降基本平稳

这张表是我训练 3000 个 episode 后的记录。可以看到后期报价系数稳定在 1.05 附近,说明 Agent 学会了在高价无法中标和低价利润微薄之间取平衡。

4.2 常见问题排查:奖励崩了、动作退化、不收敛

我在复现中出现过三种典型问题,第一个是训练到一半突然出现 NaN 奖励。排查下来是因为 Critic 的 Q 值估计溢出,导致计算损失时梯度爆炸。解决办法是给奖励做归一化,同时将梯度裁剪设置在 1.0。PyTorch 里用 clip_grad_norm_ 即可。

第二个问题是所有 Agent 都报最高价,市场变成极端卖方市场。这是典型的探索不充分,Agent 没能发现低价中标后的长期收益。我调整了噪声衰减速度,从 3000 个 episode 衰减改为 6000 个 episode 衰减,同时把初始噪声标准差提高到 0.3。换了探索策略后,报价分布明显回归理性。

第三个问题是 Critic 不学习,Q 值估计基本不变化。检查后发现是 reward scale 问题:每度电利润在几十元到几百元之间,而 Q 值初始范围很小,导致 MSE loss 看起来很小但梯度更小。我把奖励除以一个基准数值,缩小到 0 到 1 区间,之后 Critic 才开始正常工作。

还有一个藏在数据层面的问题:当某个 Agent 连续多次不中标,它的 replay buffer 里大量堆积“零奖励”经验。这些经验没有有效梯度,导致策略学不到区分度。我增加了一个最小中标率约束,在环境里给每个 Agent 一定比例的优先调度权,模拟市场中的优先调度机组,避免个别 Agent 彻底退出学习。

4.3 调参经验:网络大小、学习率与噪声策略

DDPG 参数敏感性很高,我在调参过程里积累了几个比较实用的经验。Actor 和 Critic 的隐藏层大小从 128 到 512 都试过,最后 256 最稳。太小拟合不了状态转移中的非线性关系,太大容易过拟合到历史经验,泛化到新场景时反而崩溃。

学习率上,Critic 要比 Actor 学得快一点。我用 Actor 学习率 1e-4,Critic 学习率 3e-4。这样设定有一定的理论依据:Critic 需要准确估计 Q 值,给 Actor 提供稳定的梯度方向。如果 Critic 学得慢,Actor 会顺着错误的梯度方向更新,最后越跑越歪。

批量大小 64、经验回放容量 200000、折扣因子 0.99 是相对通用的配置。折扣因子 0.99 意味着 Agent 主要关注未来几十步内的收益,和电力市场按小时滚动出清的周期基本匹配。如果改成 0.95,Agent 会变得短视,倾向于本期高价中标而牺牲未来市场份额,整体收敛效果下降。

探索噪声衰减必须和总 episode 数配合。我的训练总时长为 8000 个 episode,前 10% 用高噪声探索,中间 50% 逐步衰减,最后 40% 保持低噪声微调。这个比例不是绝对的,建议看报价曲线的过渡是否平滑来调整。如果衰减过快,早期未探索充分,后期容易被局部最优卡住。

4.4 复现过程中容易被忽略的细节

有几个坑,论文里完全不会写,但复现时特别重要。

固定随机种子是第一个。PyTorch、NumPy、Python 自带的 random 三个库都要设置。如果不固定,即使所有超参数相同,每次训练结果都不同,根本没法判断调参效果。

评估策略时一定要关闭探索噪声。我在测试阶段把 Actor 的输出直接当作最终动作,不做噪声添加。否则评估出的平均奖励被探索噪声污染,无法反映真实策略水平。

出清排序里如果用快速排序,相同报价时顺序每次可能不同,这会影响中标结果。我在排序时给相同报价的机组增加一个随机器排序的稳定排序策略,确保市场结算公平。这个细节直接影响多 Agent 环境下某些 Agent 是否系统性地吃亏。

观测归一化不能只在环境里做一次。对于在线训练,新状态到来时应该复用之前计算出的均值和方差来变换,不能用全量数据的统计量。我的做法是维护一个可增量更新的均值和方差估计器,每收集一万条经验更新一次,训练和评估时统一调用。

5. 复现延伸:从简化环境到更真实的市场

这篇论文复现做的是简化单时段市场,但思路可以扩展到更复杂的场景。比如把统一出清改成节点边际电价出清,这时候 Agent 不仅要学报价,还要考虑电网阻塞位置,奖励函数会变成区域价格乘以区域中标电量,状态空间需要加入节点间潮流信息。网络结构上可以引入图神经网络来提取电网拓扑特征。

还有一个容易扩展的方向是多种结算机制对比。现在只用了统一边际价格,但有些市场采用按报价支付或差价合约结算。同一样的 DDPG 代码,只要改环境里的结算函数,就能对比不同机制下 Agent 的报价行为和市场价格水平。这个扩展做起来很快,也能让论文复现工作更有说服力。

我在实际跑完这套代码之后一个很深的体会是,强化学习电力市场博弈研究里,环境可信度比算法复杂度重要得多。网络结构再花哨,如果出清机制、回报函数和市场价格形成过程有问题,训练结果都没有现实意义。这也解释了为什么论文复现第一步一定要先把环境逻辑核对清楚,而不是急着写神经网络。

最后再分享一个小技巧:训练结束后把学到的 Actor 网络导成 ONNX 格式,然后在新的天气、负荷场景下快速推理,观察报价曲线和市场出清价的变化。这个步骤能帮你判断 Agent 是真的学到了电力市场供需规律,还是仅仅死记硬背了训练时的负荷模式。我后续还在往这个方向接着做,后续有结果了再继续更新。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进