☰
约束感知强化学习在能源系统优化调度中的落地实践与代码解析
2026/10/15 4:27:08 网站建设 项目流程

去年做某园区微网调度项目时,被同一个问题反复折磨了好几周:储能系统的SOC明明在约束范围内,可训出来的策略一到真实电价曲线边上就开始“抖”,不是把电池充到上限就是深夜偷偷放到底。后来我才意识到,问题不是算法不先进,而是我把约束的处理方式搞错了——惩罚项加得再多,策略网络该越界还是越界。这个项目最后改成约束感知强化学习的思路才真正跑通。这篇文章就围绕“约束感知强化学习算法在能源系统优化调度里的落地”展开,把MDP建模、约束感知机制的选型、Python代码骨架、训练细节和踩过的坑一次讲清楚。适合已经跑过基础DRL算法、现在想把强化学习用到真实调度场景里的读者。

把调度问题交给强化学习,最关键的一步不是选哪篇paper的算法,而是先想明白:你的“优化”到底在优化什么,约束又以什么形式存在。下面从问题建模开始,一步步拆。

1. 先搞清楚:能源调度用深度强化学习,到底在优化什么

1.1 一个真实的调度场景长什么样

假定一个最简单的园区微网:光伏板发电、一组储能电池、外部电网购电,负荷侧有固定用电需求。调度器每隔15分钟或者1小时做一个决策,决定电池是充电、放电还是待机,同时决定从电网买多少电。目标是让一天下来的总用电成本最低,同时不违反电池的物理限制和不必要的功率约束。

功率平衡是这个场景的硬约束:任意时刻,光伏出力加电网购电功率,再加上电池的放电功率(充电时视为负的放电),必须等于负荷功率。这是等式约束,违反的话要么负荷没供上,要么功率倒送,在真实系统里都不可接受。除此之外还有电池的SOC上下限、充放电功率上限、购电功率上限等不等式约束。

这类问题传统上是用模型预测控制或者混合整数规划做的。MPC的思路是:在每一个控制周期,基于当前状态和预测信息,求解一个有限时域的优化问题,执行第一步,然后滚动更新。这个方法本身很成熟,但有一个绕不开的前提——你得有一个足够准的预测模型,负荷、光伏、电价都要预测。预测误差一大,MPC的控制序列就容易在真实世界里“差之毫厘,谬以千里”。

1.2 从MPC到无模型决策,DRL补上了哪块短板

深度强化学习的价值在于:它不要求你显式地写出生硬的预测模型,而是通过大量交互自动学习“看到什么状态、应该做什么动作”的映射。训练好之后,决策成本很低,只需要一次神经网络前向传播,非常匹配实时调度场景。

能源系统用DRL并不是什么新概念,很早就有用DQN做家庭能量管理的文章。但到了连续动作空间(电池功率是连续值、购电功率也是连续值),DQN这类离散动作算法就力不从心了,需要换成SAC、PPO这类连续控制算法。

我最终采用的方案是:以SAC这类Actor-Critic算法为骨干,在其上叠加约束感知机制。约束感知不是某一种固定的算法,而是一类方法的统称,核心思想是:不要让策略网络在奖励函数里“间接猜测”约束的边界,而是把约束信息显式地参与到决策过程里。这是后面所有代码和设计的出发点。

1.3 把调度问题写成MDP

不管用什么算法,第一步都是把问题翻译成MDP的四个要素:状态、动作、奖励、转移。

  • 状态 (s_t):能反映系统当前情况的所有信息。常见组合是当前SOC、当前电价、当前负荷、当前光伏出力、时段(或星期)特征。必要时还可以把历史序列信息叠进来,但本文先用单步状态。
  • 动作 (a_t):控制器输出。最常见的是电池功率 (P_{bat}),有符号约定:正值充电、负值放电。也可以把购电功率纳入动作,但那样耦合性更强,约束处理更麻烦,我一般只控制电池,电网功率由功率平衡等式隐式决定。
  • 奖励 (r_t):和成本挂钩。典型设计是 (r_t = -\left( price_t \cdot P_{grid,t} + \text{惩罚项} \right)),让智能体在最小化购电成本的过程中学会利用光伏和电池的时序套利。
  • 转移:SOC的差分方程、功率平衡等式,以及外部变量(电价、负荷、光伏)的时序变化。

把这四件事写清楚,环境代码才能落地。真实项目中90%的返工,都是因为MDP定义有歧义——尤其动作的正负号约定、SOC的更新公式、约束的判定口径,这三个地方最容易埋雷。

2. 普通强化学习为什么总在“压线”——约束处理是第一道坎

2.1 用惩罚项处理硬约束的三个典型坑

很多人第一次接触调度问题时,做法很直接:在奖励函数里给约束违反加惩罚项,比如SOC低于下限就扣100分,功率越界就扣50分。初看上去没问题,实际训练起来全是问题。

第一个坑是惩罚系数难调。系数太小,策略网络根本不把约束当回事,宁肯偶尔罚一次也要省电费;系数太大,学习过程不稳定,奖励信号被惩罚项淹没,算法很难收敛到最优。这个平衡点在不同场景、不同数据分布下都不一样,纯靠手调非常痛苦。

第二个坑是“惩罚是滞后的”。策略网络是拿整个轨迹的回报来算梯度的,一次越界动作的惩罚要等时序回溯才会影响其更新。尤其在长序列调度里,某一步越界导致的惩罚可能淹没在几百步的累计回报里,对梯度的贡献微乎其微。

第三个坑更隐蔽:惩罚项改变的是“最优解”的形状。加了惩罚之后,策略网络隐约知道“越界不好”,但它不知道应该在离边界多远的地方停下来。最终学出来的策略往往就是在边界上反复试探,表现为SOC贴着下限波动、动作频繁满功率冲击,也就是我们常说的“压线”行为。这在真实系统里是运维最忌讳的。

2.2 SOC这类累计约束比想象中更麻烦

有些约束是单步可判定的,比如充放电功率不能超过额定值,看一眼动作就知道违没违反。但SOC约束是累计约束:单步动作不违法,连续多步充电之后SOC却可能超过上限。

累计约束对普通强化学习是真正的难点。因为在MDP的框架下,环境每一步只给你一个即时奖励,而SOC越界是在某一步突然发生的。策略网络必须学会预判未来几步的动作组合会不会导致越界,这本质上是时序信用分配问题。

在代码层面,SOC约束常常表现得更加“刁钻”:充放电效率不是100%,SOC更新公式里有充放电效率项,导致同样的动作在不同SOC下的边际影响不同。低SOC区间用大功率充电可能刚刚好,高SOC区间再来一下就直接封顶。这种非线性让惩罚项策略几乎无法可靠工作。

2.3 从“事后惩罚”到“事先感知”的思路转变

我后来想明白一件事:约束不应该只出现在奖励函数里,更应该出现在策略网络的输入和动作的生成过程中。

所谓约束感知,就是让约束信息在决策链路里前移。要么把约束条件写进目标函数,用对偶变量自适应调整惩罚强度;要么在动作输出之后、进入环境之前,加一层“可行性投影”,从算法层面保证动作天然满足约束;要么在动作空间设计阶段就做文章,让不可能违法的动作根本不在候选范围内。

这三种思路各有利弊,下面单独展开。但共同点是:它们都避免了“靠奖励惩罚去猜边界”这个不稳定的过程,这也是约束感知方法在能源调度里被广泛接受的根本原因。

3. 约束感知机制的三种落地思路与选型

3.1 三种做法对比

思路核心机制优点缺点适合场景
拉格朗日乘子法把约束违反均值乘以对偶变量加入目标,对偶变量按约束违反程度自适应调整实现简单,训练稳定,不依赖额外的优化求解器只能保证“平均意义”上的约束,单步硬约束可能仍被违反SOC上下限类、平均功率类累计约束
可微投影层将策略网络输出投影到满足约束的动作集上,构成“安全动作”单步动作硬约束几乎100%满足,直观可靠需要求解二次规划,且反向传播梯度可能退化;对耦合约束适用性有限充放电功率上下限、购电功率上限类不等式约束
动作掩码/动作设计把连续动作离散化或做变量替换,让动作天然在安全区间零额外计算,思想最朴素表达能力有限,变量替换只能处理简单盒子约束电池功率盒子约束的简单场合

第一种方案适合处理类似SOC的累计约束,第二种方案适合处理单步动作的盒子约束。实际项目里我常常把两者混用:动作用投影层保证单步可行,SOC约束用拉格朗日乘子处理。这样分工明确,各管各的。

3.2 拉格朗日乘子法:把约束写进目标

拉格朗日乘子法来自约束优化理论。原始问题是:

[ \min_\theta ; J_{cost}(\theta) \quad \text{s.t.} \quad J_{viol}(\theta) \le d ]

其中 (J_{cost}) 是期望成本,(J_{viol}) 是预期约束违反量,(d) 是允许的违反阈值,在严格场景下取0。把这个约束优化转化为无约束问题,就是加上一个对偶项:

[ J(\theta, \lambda) = J_{cost}(\theta) + \lambda \cdot \left( J_{viol}(\theta) - d \right) ]

策略网络对着 (\theta) 最小化这个目标,而对偶变量 (\lambda) 则对着最大化方向走:违反越严重,(\lambda) 越大,惩罚越重;约束满足得越好,(\lambda) 变小,惩罚减轻。用代码表达就是每隔若干步做一次:

# lambda 初始值 lam = 1.0 lr_lambda = 5e-5 # 训练循环中更新对偶变量 mean_viol = recent_violation_history.mean() lam = max(0.0, lam + lr_lambda * (mean_viol - d))

这个更新公式非常直观:平均违反超过阈值,罚就变重;违反没有了,罚就松。算法的超参数从“惩罚系数“变成了“对偶变量更新步长”,前者需要大量尝试,后者只需保证数量级别合理即可,鲁棒性高很多。我在项目里会把violation_history做成滚动窗口,比如最近100个回合的均值,避免单回合波动太大导致 (\lambda) 抖动。

3.3 可微投影层:让动作在输出前就变得可行

如果说拉格朗日乘子法解决“平均不那么违反”,那投影层则是冲着“单步就不许违反”去的。思路是把Actor网络输出的原始动作 (\hat{a}_t) 当作理想值,求解一个带约束的二次规划,找到离它最近的可行动作 (a_t^*):

[ a_t^* = \arg\min_{a} ; | a - \hat{a}t |^2 \quad \text{s.t.} \quad a{low} \le a \le a_{up} ]

如果只有盒子约束,这个投影就是简单的逐元素clip。但如果约束里包含类似“电池功率和电网购电功率之和不能超过某个值”这种耦合约束,就必须真的去解QP,然后把求解过程包装成一个网络层。梯度可以通过KKT条件反向传播,或者用直通估计器直接忽略投影层的梯度,本文不再深入。

在能源调度里,我把投影层用在电池功率上下限和购电功率上限的约束上。实测效果立竿见影,训练期间几乎见不到单步越界。但要注意一个陷阱:如果动作空间大部分区域都被约束“截断”,策略梯度可能会退化,因为投影层让多个不同的原始动作映射到同一个安全动作,梯度信息丢失一半。解决方法是给投影后的动作加一点探索噪声,并配合SAC的熵项一起用。

3.4 动作掩码与离散动作空间

还有一类做法从根源上让违法动作“不存在”。比如电池功率只取几个离散档位,-1、-0.5、0、0.5、1倍额定功率,这样功率约束天然满足,再用DQN或优先回放去学。优点是想都不用想约束问题,缺点是时间粒度细的时候,离散动作容易导致SOC控制的颗粒度不够,调度效果打折。

变量替换也是常用技巧。比如把动作改定义为“目标SOC”,而不是“电池功率”,让策略决定SOC该到哪儿,功率由外部PI控制器闭环去追。这样SOC约束在定义上就满足了一部分。这个思路在生产系统里很受欢迎,但训练难度会转移到“目标SOC如何反推功率”这个逆模型上。

三种方式我最终选的是第1种加第2种的混搭,原因很实际:混搭的代码量可控,且每类约束都用最擅长它的机制。下面进入代码环节。

4. Python实现:调度环境的MDP设计与约束记录

4.1 环境状态、动作、奖励定义

先用一个类把环境写清楚。环境类需要实现reset和step两个核心方法,并对外暴露:状态维度、动作空间维度、约束违反的统计信息。

我习惯把“约束违反情况”单独作为日志字段输出,而不只是藏在奖励里。这样训练完处理数据时,可以直接画出“违反次数随时间变化”的曲线,判断约束感知机制是否真的在起作用。

状态向量设计如下:

# [soc, price, load, pv, hour_onehot_0 ... hour_onehot_3] state_dim = 1 + 1 + 1 + 1 + 4

hour用4位one-hot编码(把一天切4段),而不是直接传一个小时的数值。直接传数值会让网络误以为“12点是6点的两倍”,one-hot没有这个问题。

动作只控制电池功率,单位是kW,正为充电、负为放电。购电功率由功率平衡方程算出来:

P_grid = load - pv - P_bat # 可能为负,代表向电网倒送电

这个等式约束本身不在动作空间里做投影,而是在step函数里检查 (P_{grid}) 是否超过购电上限。如果超过,就按硬约束违规记录并给出大惩罚。

4.2 环境骨架代码

下面给一个可以直接扩展的环境骨架,重点看SOC更新和约束记录两个地方。

import numpy as np class MicrogridEnv: def __init__(self, price_series, load_series, pv_series, dt=1.0): self.price = price_series self.load = load_series self.pv = pv_series self.dt = dt self.E_cap = 100.0 # 电池容量 kWh self.eta_ch = 0.95 # 充电效率 self.eta_dis = 0.95 # 放电效率 self.soc_min = 0.10 self.soc_max = 0.90 self.p_bat_max = 50.0 # 最大充放电功率 kW self.p_grid_max = 80.0 # 最大购电功率 kW self.state = None self.soc = None self.t = 0 self.violation_log = [] def reset(self): self.soc = 0.5 self.t = 0 self.violation_log = [] return self._get_state() def _get_state(self): hour = self.t % 24 hour_oh = np.eye(4)[hour // 6] return np.concatenate([ [self.soc], [self.price[self.t]], [self.load[self.t]], [self.pv[self.t]], hour_oh, ]) def step(self, action): p_bat = np.clip(action, -self.p_bat_max, self.p_bat_max) # 功率平衡等式 p_grid = self.load[self.t] - self.pv[self.t] - p_bat grid_violation = max(0.0, p_grid - self.p_grid_max) # SOC更新,充电和放电效率分开处理 if p_bat >= 0: soc_delta = self.eta_ch * p_bat * self.dt / self.E_cap else: soc_delta = p_bat * self.dt / (self.eta_dis * self.E_cap) new_soc = self.soc + soc_delta # SOC约束违反量,软性记录 soc_violation = 0.0 if new_soc < self.soc_min: soc_violation += self.soc_min - new_soc new_soc = self.soc_min if new_soc > self.soc_max: soc_violation += new_soc - self.soc_max new_soc = self.soc_max self.soc = new_soc # 成本与奖励 cost = self.price[self.t] * max(0.0, p_grid) * self.dt reward = -cost violation = grid_violation + soc_violation self.violation_log.append(violation) self.t += 1 done = self.t >= len(self.price) - 1 return self._get_state(), reward, done, { "violation": violation, "soc": self.soc, "p_bat": p_bat, "p_grid": p_grid, "cost": cost, }

有几个细节值得提醒:

  • 充放电效率必须分开写,不能共用一个系数。尤其在低SOC区间,同一功率值对应的SOC变化在充放两个方向上差别很大。
  • 我把SOC越界后的值clip到边界,而不是保持越界值。这是为了情景继续跑下去,否则SOC是负数,后续计算全部乱套。拮据做法是同时把越界量记进violation,让智能体知道刚才“犯了事”。
  • 成本这里只算 (max(0, p_{grid})),意思是倒送给电网的电不收钱也不收惩罚。真实场景里如果上网电价或者倒送惩罚存在,要单独加一项。

4.3 LOG约束违反记录的细节

violation_log是后续训练和评估的重要依据。我建议每个step记录的数据不要只存数值,最好把 (p_bat)、(p_grid)、(soc)、(cost) 全部存下来。训练完可以用pandas分析:哪些时段SOC逼近边界、哪些时段购电功率贴着上限——这些细节能快速定位策略的“安全隐患”。

另外一个容易踩的坑:violation的单位不一致。SOC违反量的单位是百分比(0.02代表2%),功率违反量的单位是kW,两者直接相加会变成“合成怪物”。如果要把它们合并成一个指标,必须先归一化:SOC违反量除以 (soc_{max}-soc_{min}),功率违反量除以 (p_{grid_max}),得到一个无量纲的违规指标。这一步乍一看无所谓,但直接影响拉格朗日乘子的尺度更新,建议从一开始就统一。

5. 训练流程里的关键细节:奖励塑形、归一化、超参数与收敛判断

5.1 奖励塑形与归一化

基础奖励是负数(负成本),取值范围可能从-2000到-50,跨度很大。SAC这类算法对奖励尺度其实不算特别敏感,但配合上 (\lambda \times violation) 项后,两者尺度差太多就会出现一方主导梯度。

我的做法是两步:先对成本做归一化,再用小系数加约束项。归一化方式是用历史均值和标准差,而非最大最小值:

[ r_{cost, norm} = -\frac{(cost - \mu_{cost})}{\sigma_{cost}} ]

初始时 (\mu)、(\sigma) 从无约束策略的随机动作试跑统计得到,后续用训练数据的滑动统计量更新。这样成本项的量级恒定,(\lambda) 初值设成1.0就不用频繁调。

状态归一化同样重要,而且我见过太多代码在这里翻车。电价可能是几百,负荷可能是几万,SOC只有0到1,不归一化直接喂给网络,前几轮训练基本等于废的。用StandardScaler类实现,或者直接在环境里存mean和std:

# 训练前先跑200步随机策略,计算状态向量的mean/std state_scaler.mean = state_history.mean(axis=0) state_scaler.std = state_history.std(axis=0) + 1e-6

注意标准差加1e-6的防护,防止某些特征完全不变时除零。

5.2 超参数配置表

我用的骨干算法是SAC,网络结构是两层256。给一份可以当作起点的配置:

参数取值说明
actor / critic 隐藏层256 / 256,ReLU足够处理单步调度的非线性
学习率3e-4三个网络统一用,SAC原作者建议值
折扣因子 (\gamma)0.99小时级决策,一天144步,0.99不会让远期回报衰减太快
target smoothing (\tau)0.005常规取值
replay buffer1e6只有一天的数据也可以存,但1e6是安全容量
batch size256中等
自动熵调节TrueSAC自带,优于手动设温度系数
初始 (\lambda)1.0对偶变量初值
(\lambda) 学习率5e-5明显小于策略学习率,避免乘子振荡
约束阈值 (d)0.0严格模式下不允许平均违规

SAC的熵正则和约束感知机制在理论上有一些潜在冲突:熵鼓励探索,约束机制想要安全,两者可能打架。实际测试下来,只要 (\lambda) 更新不过快,SAC自动熵调节能自己找到一个平衡,不必额外干预。

5.3 训练循环与 λ 更新策略

伪代码形式的训练循环如下:

for episode in range(max_episodes): state = env.reset() ep_violations = [] while not done: action_raw = actor(state) action = safety_projection(action_raw) # 如果启用投影层 next_state, reward, done, info = env.step(action) replay_buffer.add(state, action, reward, next_state, done) if len(replay_buffer) > batch_size: update_actor_critic(batch_size) ep_violations.append(info["violation"]) # 每个回合结束后更新lambda mean_viol = np.mean(ep_violations[-100:]) if len(ep_violations) > 100 else np.mean(ep_violations) lam = max(0.0, lam + lr_lambda * (mean_viol - d))

注意几点:(\lambda) 更新放在回合级而不是step级,减少振荡;如果项目里还套了投影层,step里的safety_projection只处理功率盒子约束,SOC累计约束仍然交给(\lambda)。

训练过程中,要定期保存一套“没有加噪声“的确定性策略用于评估。SAC训练时动作是带探索噪声的,同一个状态下greedy action和探索action差别不小,评估时必须用greedy action,否则指标看起来乱糟糟。

5.4 判断收敛:成本曲线、违规率与乘子轨迹

不要只盯着reward曲线看。reward里混了(\lambda),成本降低可能是真的学好了,也可能是(\lambda)变小的错觉。我习惯把三张图放在一起看:

  • 成本曲线:滑动平均后的单日总成本。
  • 违规率曲线:每100回合中违规步数的占比,或者平均violation值。
  • (\lambda) 轨迹:这个最容易被忽略,但信息量很大:如果(\lambda)持续上升,说明约束一直没被满足,策略没学到安全动作;如果(\lambda)先上升后下降,则是典型的“先学安全、再学经济”的良性过程;如果(\lambda)从头到尾纹丝不动,可能是(d)设得太宽容或者lr_lambda过小。

我自己的经验是,当成本曲线平稳、违规率低于1%且(\lambda)在某个较小值附近稳定时,就可以考虑停止训练了。

6. 实测复盘:约束违反率、收敛曲线和几个容易翻车的地方

6.1 一组对比实验的表现(示意数据)

用一个简化微网环境做了三组对比,每组训练400回合,数据是本地实验的典型表现,不代表所有场景:

方案平均单日成本SOC违规次数购电功率违规次数
纯SAC + 固定惩罚项18203712
SAC + 拉格朗日乘子174086
SAC + 投影层 + 拉格朗日乘子172530

纯SAC成本看着不算高,但它违规次数多,真实系统里根本不敢上线。加投影层之后购电功率违规彻底清零,SOC违规也大幅下降,成本反而还低了,因为策略不再浪费力气在“试探边界”上,可以把更多电量用在电价谷段充电。这就是约束感知机制的价值:不只是让安全指标变好,还能间接提升经济指标的收敛质量。

6.2 翻车点一:λ 初始值

有一次测试,(\lambda) 直接设成0.1,结果前100回合几乎都在违规,乘子爬得很慢,整个前期训练都在“裸奔”。反例是设成10,策略又被严重束缚,成本迟迟下不来。

我的经验是:(\lambda) 初值不需要精确,但量级要对。简单方法是先拿一个随机策略跑100回合,记录平均violation和平均cost,然后反推一个让cost项和violation项同量级的(\lambda)。这个思路比凭感觉设系数靠谱得多,配合5e-5的更新步长基本不会出问题。

6.3 翻车点二:投影层的梯度问题

用可微QP层做动作投影时,梯度确实容易“钝化”。具体表现为:训练前50回合很顺,之后critic的loss降不下去,actor的loss也纹丝不动。原因是动作大量砸在边界上,QP层的雅可比矩阵变成了某个方向的零向量。

我用了两个缓解措施,实测有效:第一,产品网络结构换成“先投影、后加噪声”而不是“先加噪声、后投影”,让网络永远在可行域内产生动作;第二,对没有投影层的残差分支做直通估计,梯度直接复制过去。简单说就是让actor网络自己学会“在安全动作附近做文章”,投影层只是最后的兜底保险。

6.4 翻车点三:分布偏移与离线评估

最后一个坑不在训练里,在评估里。训练用的电价、光伏数据如果都来自同一个季节,模型到了夏天高热高光照的数据上大概率表现骤降。这个问题的本质是DRL的策略在分布内过拟合,约束感知机制并不能完全解决它。

我的建议是:训练数据里强制混入不同典型日类型,测试时按“训练集分布内”和“分布外”分别报告指标。上线前还要做一次仿真回放:把训练好的策略放到真实历史数据上跑一遍,看它在极端日里的约束违反情况。不是所有问题都能靠训练解决,约束感知的意义在于让算法的“安全下限”更高,而不是无限制地容忍分布漂移。

最后说一个我体会很深的经验:写在日志里的violation数据价值远超预期。有一次策略调参后成本曲线变好了,但violation曲线悄悄上升,如果只看reward我根本发现不了,是日志里的违规统计把问题揪出来的。做能源调度的DRL项目,胜败往往不在算法多新,而在这些细枝末节上。约束感知帮我们把约束从“隐性惩罚”变成“显式机制”,但真正让它起效的,还是耐心的曲线分析和对每个环节数据口径的较真。如果你正在把这类代码跑起来,建议先把环境里的violation日志写全,再叠算法,你会少走不少弯路。

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

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

立即咨询