搞随机优化的人,多数都在场景生成这一步踩过同一个坑。我有一回给某区域风电出力做不考虑时序相关性的蒙特卡洛(MC)采样,结果生成出一堆锯齿状曲线,画出来均值方差都对得上,仔细一看全是假的——白天十五点还是两成出力,十六点直接跳到八成,真实风电场根本不会这么变。从那以后,我逼着自己把"考虑时序相关性MC的场景生成与削减研究"整个链路重新走了一遍,从生成到削减再到验证,才算把这条流程跑通。这篇文章就是那次复盘的完整记录,适合正在做电力系统随机优化、储能规划、微网调度,以及任何需要把随机过程离散成场景集的人参考。
1. 为什么随机优化离不开"场景生成":从单一预测值到概率路径集合
1.1 确定值模型的尽头,就是场景模型的起点
你去翻早期的调度模型,风电出力、光伏出力、负荷基本都按一个确定值处理,目标函数里只有一套数字,边界条件也是死的。这种模型算起来快,但实质是在"用平均数赌明天"——万一实际出力偏离了预测值,最优解立刻失去意义。
后来大家开始引入随机规划,核心思想是不再赌一个值,而是给出一组"可能发生的情况",每个情况带一个概率。这一组情况就是场景集。场景里的每个场景,本质是一条时序路径,比如未来二十四小时、按每小时一个点,共二十四个值的一条曲线。
这里有个关键认知:场景生成不是把分布函数离散化就完了,而是要生成一堆"路径",这条路径要能反映真实过程的时变性。这就是为什么我们经常看到有人画场景图,画出来一堆曲线乱糟糟的,单看每个时刻的分布没问题,但每一条曲线单独拿出来都不像真实数据。这种场景送进优化模型,算出来的决策自然不可信。
1.2 场景的三个底层属性:分布、相关、时变
一个合格场景集至少要满足三个层次的约束。
第一层是边缘分布匹配,任何一个时刻的采样值,它的分布要和历史数据的分布对得上,均值、方差、分位数都不能差太多。
第二层是时序相关性匹配。真实的风电出力、电价、负荷都不是白噪声序列,相邻时段之间有很强的依赖关系:这一小时风速高,下一小时风速大概率也不会低到哪去。如果生成出的场景忽略了这种自相关性,曲线就会变成高频锯齿,看似覆盖了边缘分布,实际却破坏了过程的连续性。
第三层是跨变量相关匹配。比如两个相邻风电场的出力、风电场和负荷、电价和负荷之间都存在相关关系。如果各变量独立采样,场景之间的联动关系就会被切断,这在多能源系统中问题特别明显。
三层缺一不可。我在实践中的体会是:很多人只盯第一层,导致后续优化得到的所谓"最优质解"只是一个纸面解,真拿到现场走一遍,完全对不上。
1.3 蒙特卡洛为什么仍是默认的地基
现在的生成方法很多,生成对抗网络、变分自编码器、扩散模型都有人试过。但我到现在依然把蒙特卡洛当成默认方法,原因有三:
一是渐进收敛性有理论保障,样本量足够大时,经验分布能收敛到目标分布,这是严格性质,不是经验调参出来的。
二是实现简单、可并行,绝大多数语言都有现成的随机数库,GPU批处理也很容易。
三是蒙特卡洛框架天然容易嵌套时间序列模型。你可以在采样环节换成ARMA、马尔可夫链或者带相关性的多维正态采样,而不是推翻整个方法论重来。
所以我的主张是:不要一上来就上深度生成模型,先把MC这条路走到极致。这一节标题里说的"MC的场景生成与削减研究",本质上就是围绕蒙特卡洛这个地基,去解决时序相关性和削减效率这两个关键问题。
2. 朴素蒙特卡洛为什么生成不出"像样的风电曲线":时序相关性被忽略的代价
2.1 锯齿状场景的数学来源:独立采样没有自相关
蒙特卡洛最朴素的做法,是把每个时间步当成一个独立随机变量,按某个分布去抽。
举例,风电出力某时刻近似服从Beta分布(归一化到0到1之间),你会对每个时刻单独采样,循环96次,得到一条96个点的曲线。问题是:相邻两个时刻的采样值之间没有任何约束,今天是0.1,明天是0.9,完全可能发生。真实出力过程的自相关系数可能是0.9以上,而独立采样的自相关系数理论上接近0。
所以从ACF(自相关函数)角度看,独立MC生成的场景和真实过程根本不在一个频域。真实曲线的功率谱集中在低频段,锯齿曲线的能量则均匀撒在全部频段。如果把这个场景丢进储能调度模型,你会看到储能系统被逼着高频充放——因为它要不停去平滑那些现实中不会出现的剧烈波动。
2.2 时序相关性到底指什么:三个容易被混为一谈的概念
很多人一提时序相关性就只想到相邻时刻的相关系数,实际至少要分三个维度:
- 自相关(Autocorrelation):同一变量在时序上不同滞后的相关性。风电出力通常具有较高的一阶自相关,同时受天气系统影响,还可能有24小时左右的周期相关性。
- 互相关(Cross-correlation):不同变量在相同时刻或跨时刻的相关性,比如两个风电场的出力会同时受同一风带影响。
- 总体相关结构(Correlation Structure):包括完整的相关矩阵,而不仅仅是一阶滞后。真实时间序列的ACF往往不是简单的指数衰减,可能有季节性、有长记忆,这些都要在生成阶段保留。
我在项目里遇到的典型错误是:有人用"相邻时间点相关系数0.85"这一个标量去"修"MC的结果,结果就是全局ACF依然一塌糊涂。相关性是一个结构,不是一个数,这一点必须从一开始就建立起来。
2.3 破坏时序相关性对下游优化到底有什么影响
如果你认为"反正优化模型最终只关心期望值,时序相关性不重要",那就大错特错了。时序相关性对优化模型的影响是结构性的,不是轻微扰动。
第一,对储能类决策的影响最致命。储能的价值来自时间套利——低谷充电、高峰放电。如果场景曲线是高频锯齿状,储能就会在虚拟的尖峰和谷底之间反复充放,算出来的容量配置和充放电策略会严重虚高。我见过一个真实的例子:同一个风电场数据,用独立MC生成的场景,算出来的储能容量需求比用时序ARMA场景高出40%以上。这40%就是决策失真。
第二,对风险类指标的影响。随机优化的目标函数往往包含条件风险价值(CVaR)这类尾部风险指标。如果场景不真实,尾部场景的组成就错了——真实过程里"连续三天无风"这种持续低出力状态,在独立MC里会被拆成随机零星的低出力点,尾部风险被严重低估。
第三,对模型收敛性的影响。很多随机优化算法(如样本均值近似SAA)的性能依赖于场景质量,场景质量差时,同一个问题多次求解的方差会很大,导致你根本无法判断两个方案谁更优。
3. 三条可行路线:让蒙特卡洛生成的场景带上时序相关性
3.1 路线一:ARMA 模型驱动 MC,兼顾递推简单与长程依赖
ARMA(自回归滑动平均)模型是时间序列里的老将。做法是先对历史出力序列做平稳化处理(风电出力先做归一化、差分或者Box-Cox变换),然后基于历史数据辨识AR和MA部分的系数,得到模型之后,接下来用MC去采样随机残差序列,再通过递推式生成一条完整序列。
我用Python写过最简单的AR(1)递推生成,示意如下:
import numpy as np # 一阶自回归系数,由历史数据辨识得到 phi = 0.85 sigma = 0.06 T = 96 # 一天96个15分钟时段 x = np.zeros(T) eps = np.random.randn(T) * sigma for t in range(1, T): x[t] = phi * x[t-1] + eps[t]就这么几行,生成的序列就已经带有明显的时间持续性,画出来跟真实曲线的视觉形态接近很多。实际工程中会用ARMA(p,q)而不是AR(1),因为风电出力的一阶自相关通常不足以描述完整的ACF形状。
ARMA的优点:递推简单、计算量极小、理论上ACF可以直接由模型参数推算出来,方便你反推生成结果是否匹配目标ACF。
ARMA的缺点:模型是线性的、假设噪声是同方差的高斯噪声,难以刻画风电出力的非线性和异方差性。对于要求不高的场景生成,ARMA已经完全够用;但如果你的模型里包含极端天气事件,ARMA就力不从心。
3.2 路线二:Cholesky 分解 + Nataf 变换,精确控制相关矩阵
这一条路线是我在项目里用得最多的,因为它的控制精度最高。
核心思路分四步:
第一步,从历史数据估计目标相关矩阵R。你的变量是每个时间步的出力值,假设一天96个时段,R就是一个96乘96的矩阵,每个元素是两个时段之间的相关系数。
第二步,对R做Cholesky分解:R = L * L^T。这里的L是下三角矩阵。
第三步,在标准正态空间生成独立样本Z,然后做线性变换Y = L * Z。根据线性代数的性质,Y的协方差结构正是R。这里要特别注意:Cholesky分解要求R为正定矩阵,实际估计出来的相关矩阵往往因为样本不足或数值误差而不正定,需要做特征值修正。
第四步,Nataf变换:通过Y_t = F_t^{-1}(Phi(Y_t)),把相关正态变量映射回目标边缘分布。F_t是第t个时刻的边缘累积分布函数(可以从历史数据用核密度估计或经验分布得到),Phi是标准正态CDF。
Python示例:
import numpy as np from scipy.stats import norm # 假设4个时段,目标相关矩阵 R = np.array([ [1.0, 0.9, 0.7, 0.5], [0.9, 1.0, 0.9, 0.7], [0.7, 0.9, 1.0, 0.9], [0.5, 0.7, 0.9, 1.0] ]) # Cholesky分解 L = np.linalg.cholesky(R) N = 500 # 场景数 Z = np.random.randn(N, 4) Y = Z @ L.T # 现在Y的各列之间相关矩阵近似为R # 假设目标边缘分布接近正态,可以用norm.ppf逆变换 # 实际中替换为经验分布的逆函数 mean = np.array([0.4, 0.38, 0.36, 0.34]) std = np.array([0.10, 0.12, 0.11, 0.10]) X = mean + Y * std为什么Nataf变换里要先经过正态空间?因为Cholesky分解只能生成相关正态变量,而我们希望最终的边缘分布是任意分布,所以需要先在正态空间完成相关性植入,再通过逆变换把边缘分布变成我们想要的形状。这样做有个小代价:相关性在非线性映射下会发生轻微畸变,正规做法是先用Nataf公式修正目标相关矩阵,但我实践下来,在场景生成这种场景中,对相关矩阵做简单迭代修正就够了,不必追求完美。
3.3 路线三:马尔可夫链,用转移概率直接编码时序行为
马尔可夫链的思路更"离散"。把出力值划分成若干个状态区间(比如0到10%、10%到20%……),然后从历史数据统计状态转移概率矩阵P,P[i][j]表示当前时刻处于状态i、下一时刻转移到状态j的概率。生成场景时,从初始状态出发,每一步按转移矩阵抽样,走出一条状态序列,再映射回应力值。
这条路线最适合风电出力:真实风电出力的变化率是有物理约束的,不可能从满发瞬间跳到零,马尔可夫链通过转移概率矩阵天然编码了这种约束。我在某项目里用三状态马尔可夫链(低出力、中出力、高出力)做过初步场景生成,生成的出力持续性明显优于独立MC。
但它的局限也明显:状态数不能太大,否则转移矩阵稀疏,估计不准;离散化必然损失出力值连续性;且很难扩展为多维变量的联合场景。
3.4 怎么选:数据量、分布形式、精度三个维度
三类方法没有绝对优劣,我的选择矩阵如下:
| 方法 | 数据量要求 | 边缘分布适配 | 时序结构还原 | 实现难度 | 适用场景 |
|---|---|---|---|---|---|
| ARMA+MC | 中等 | 较弱(假设正态) | 强(线性ACF) | 低 | 数据量大、分布接近正态、长周期模拟 |
| Cholesky+Nataf | 较高 | 强(任意分布) | 强(指定相关矩阵) | 中 | 需要精确控制相关结构、含多变量 |
| 马尔可夫链 | 较低 | 弱(离散状态) | 强(转移约束) | 低 | 状态自然离散、侧重变化率约束 |
为什么我不推荐一上来就上深度生成模型?因为MC方法有概率理论保证,稳定性强;深度模型需要大量调参、训练数据也得多几个量级,结果还不一定好解释。先试ARMA和Cholesky+Nataf,通常已经能解决80%的问题。
4. 场景削减实战:从数千场景压到几十个,同时保住时序特征
4.1 削减的本质是"最优子集筛选"问题
MC生成几千个场景很容易,但优化模型扛不住——一个包含8760个时段、几千个场景的随机规划问题,求解规模直接爆炸。削减的目的,就是在这几千个场景中选出一个子集,使子集场景对应的概率测度和原场景集的概率测度之间的"距离"尽可能小。
这个距离在理论上通常用Kantorovich距离(也叫Wasserstein距离)来衡量。直观理解:要把原场景集的概率"搬移"到子集场景上,搬移的代价最小,就说明两个集合越接近。Kantorovich距离恰好把概率搬移的运输问题变成可计算的数学形式,因此场景削减问题可以严格建模为一个运输问题。
不过,精确求解运输问题的计算复杂度很高,实际工程里都用启发式算法。最常见的就是快速前向选择(FSS)。
4.2 快速前向选择(FSS):大家都用的默认算法
FSS的思路非常直白:从初始场景集合出发,每轮找出一个"删掉代价最小"的场景删掉,把被删场景的概率累加到离它最近的剩余场景上,一步一步删到目标数量为止。
伪代码思路如下:
def fast_forward_selection(scenarios, probs, target_num): remaining = set(range(len(scenarios))) while len(remaining) > target_num: # 计算删除每个场景带来的概率距离增量 # 优先删除对整体概率分布影响最小的那个 to_remove = min( remaining, key=lambda i: removal_cost(i, scenarios, probs, remaining) ) # 找到距离被删场景最近的剩余场景,把概率转移给它 nearest = find_nearest(to_remove, scenarios, probs, remaining) probs[nearest] += probs[to_remove] remaining.remove(to_remove) return list(remaining)这里的removal_cost通常定义为:被删场景与最近场景之间的距离乘以被删场景的概率。所以删除一个孤立、又距离谁都远的场景,代价就大;删除一个被其他场景围绕的场景,代价就小,符合直觉。
FSS一个重要细节:每一步删除后,剩余场景的概率都会变化,所以"最近邻"也需要跟着更新。我用Python实现时没有做全局重算,而是只在删除场景的邻域里做局部更新,计算量从O(N^3)降到O(N^2),在5000个初始场景的情况下,运行时间从几分钟降到几秒钟,削减效果几乎无差别。
4.3 后向消除与聚类法:什么时候用它们
后向消除(Backward Selection)和FSS相反,从原始集合出发,每次删除一个"删除代价最大"的场景,直到达到目标数量。它的理论解释性强,但计算负担比FSS重,当初始场景数达到几千个时,后向消除会跑得很痛苦,我一般只在小规模数据集上用。
聚类法又是另一条路:把几千个场景当成高维向量做K-means聚类,把每个簇的中心(或距中心最近的场景)作为代表场景。这种方法实现快,但有两个隐患:
一是K-means用欧氏距离做聚类,会忽略时序形状,两条形状完全不同但平均出力相同的曲线会被分到同一个簇;
二是聚类中心本身可能不是真实场景,需要做"投影回真实场景"的处理,否则削减后的场景失去了物理可行性。
所以我的建议是:默认用FSS,尝试聚类法时一定要先检查代表场景的物理可行性,必要时改用DTW距离做聚类,代价是计算量大幅上升。
4.4 削减后的概率更新与时序重建,细节决定成败
很多人削完场景就完事了,其实还有两个容易被忽视的细节:
第一,概率更新。每次删除场景后,被删场景的概率必须转移给最近邻场景,这是保证削减后概率测度接近原测度的关键。如果不更新概率,只做简单均匀概率,削减后的场景集会有系统性偏差。
第二,极端场景的保留。削减算法天然偏向保留"周围场景多"的密集区域,而那些概率低但距离远的极端场景(比如连续多天低出力)很容易被第一个删掉。然而尾部风险分析恰恰需要这类场景。我通常的做法是:在削减前单独挑出若干极端场景作为强制保留集合,禁止算法删除它们,剩下的场景再进入FSS流程。这样虽然会让整体概率距离略大一点,但尾部风险指标的可靠性大幅提升。
5. 联合验证清单:生成和削减之后该怎么检查成果
5.1 一阶统计量:均值、方差、分位数曲线
先检查最基本的:每个时刻的均值、方差、以及5%、50%、95%分位数,和原始历史数据的对应统计量做对比。
最直观的做法是画一张分位数走廊图——把历史数据的15%到85%区间画成一条带,再把生成场景对应分位数画上去,如果带外漏出太多,说明边缘分布没配好。这个方法肉眼就能看出问题,是我每次做完生成和削减后的第一道检查。
这里没有高深理论,但很重要:如果削减前后的均值和方差发生明显偏移,说明削减参数没调对,或者目标场景数定得太少。
5.2 二阶时序特征:自相关函数的保持程度
边缘分布对了只是第一步,接下来必须检查自相关函数。做法是:对每个生成场景计算ACF(比如滞后1到24小时),求平均后和历史数据的ACF对比。
我曾经遇到一个案例:Cholesky+Nataf生成出来之后,ACF在前三个滞后点匹配得很好,但从滞后12小时开始明显偏离,原因是真实数据里有日周期分量,而我的相关矩阵只估计了一阶、二阶滞后协方差,没有包含24小时间隔的相关。后来我在相关矩阵里补上了滞后24小时的自相关项,ACF曲线马上贴合。
这就是为什么二阶验证不能只看相邻一段,一定要看ACF的整个衰减形状。
5.3 概率距离:用数值衡量生成/削减的"信息损失"
视觉验证有主观成分,数值验证可以用Kantorovich/Wasserstein距离。我建议同时监控两条曲线:
一是原始MC场景集和历史经验分布之间的Wasserstein距离,衡量生成环节的信息损失;
二是削减后场景集和削减前场景集之间的Wasserstein距离,衡量削减环节的信息损失。
两条曲线都要画成"场景数/概率距离"的关系图。这样做的好处是,你可以确定一个合理的削减目标数:当场景数从50增加到200时,如果概率距离急剧下降,说明50个场景太少;如果下降趋缓,说明继续增加场景数收益不大,过拟合风险反而增加。
5.4 端到端验证:把场景丢进优化模型里看决策差异
统计指标全过一遍后,最后一道验证最贴近工程实践:把生成削减后的场景集送进同一个优化模型,对比以下两组决策:
- 场景集A(历史数据场景)求解结果
- 场景集B(生成削减场景)求解结果
如果两组决策下的目标函数值差异很小,说明场景集B能替代历史数据场景。我一般对比三个指标:目标函数值、决策变量(比如储能容量、机组开机方式)的欧氏距离、以及约束满足率。
这一步看起来繁琐,却能发现前四步检查发现不了的问题。有一回我的场景通过了所有统计指标,但端到端验证时目标函数值差了8%,最后发现是场景削减时把某些极端低出力场景强制保留后,概率分配和原分布不一致,导致目标函数偏保守。由此可见,端到端验证绝对不能省。
一套完整的验证表格,我通常这样记录:
| 验证项 | 对比内容 | 通过标准 |
|---|---|---|
| 一阶统计量 | 均值、方差、分位数 | 偏差小于5% |
| 二阶时序特征 | ACF(滞后1-24h) | 平均绝对偏差小于0.05 |
| 概率距离 | Wasserstein距离下降趋势 | 场景数增加后趋于平缓 |
| 端到端 | 目标函数值、决策变量 | 差异小于2% |
6. 几个容易踩的坑和我的经验判断
6.1 先清洗数据,再谈相关性
场景生成的第一步不是写采样函数,而是清洗数据。风电数据里有大量异常的零出力时段、通信中断造成的空洞、限电造成的出力平台期,这些天然不反映真实过程。如果你把限电数据喂给相关矩阵估计,估计出来的自相关结构会被人为压低,因为限电时段出力被人为卡在低位。
我的做法是:先把明显异常时段剔除,用插值补齐空洞,并标注限电时段是否保留。通常我会生成两个版本:一个用于统计特征估计,一个用于场景验证,确保不把数据瑕疵当成"真实特征"学进去。
6.2 相关矩阵不正定?先查样本量
Cholesky分解的前提是相关矩阵正定,但实际估计出的相关矩阵经常不正定,尤其当时间维度大(比如8760个时段)而历史样本少(只有一年数据)时。
此时不要立刻去做特征值截断,先查样本量:如果样本量不到时间维度,相关矩阵必然奇异;即便样本量大于维度,估计噪声也可能导致最小特征值为负。稳妥的修复方法是特征值截断加对角加载:
将特征值中小于1e-6的部分置为1e-6,然后重构矩阵我建议在做Cholesky之前,先对相关矩阵做一次正则化。这样既能保证分解可计算,又不会明显扭曲目标相关结构。
6.3 削减比例不是越多越好
我有一个经验判断:初始生成3000到5000个场景,削减到30到50个通常已经够用。低于20个,时序特征会显著退化;高于100个,计算收益往往边际递减。把削减后的场景数量当成可调参数,配合Wasserstein距离图来选,不要拍脑袋定50。
6.4 距离函数选错了,时序特征照样丢
FSS中的距离函数决定"谁和谁靠近",默认用欧氏距离,但它对时序形态不敏感。两条曲线一条是"前高后低",另一条是"前低后高",平均出力一样,欧氏距离可能很小,但它们代表的运行形态完全不同。如果削减后要进行的是时序调度,就会出问题。
我在某个涉及储能调度的项目里试用过DTW(动态时间规整)距离替代欧氏距离,削减后的场景在端到端验证中目标函数偏差小了一个数量级。计算代价是DTW距离比欧氏距离贵很多,但考虑到初始场景也就几千个,完全可接受。
6.5 别忘了跨变量互相关
如果你的问题是多变量随机优化(风电加光伏加负荷),那么生成时要同时植入变量间的相关结构,不能各自独立生成再硬凑到一块。做法是:用历史数据构造一个块相关矩阵,把"风电-风电""风电-光伏""风电-负荷"的相关系数都放进去,再用Cholesky+Nataf一次生成全部变量场景,而不是每个变量单独跑一遍再拼接。
我在一个微网规划项目里对比过:独立生成再拼接,与联合生成相比,储能配置方案差异达到15%左右。跨变量互相关直接影响多能互补的决策质量,这一点经常被忽略,但它往往比单一变量的时序相关性更值钱。
最后再分享一个实际操作中的技巧:无论你选择哪条生成路线,都要把"随机种子"固定下来。场景生成、削减、验证三个阶段共用同一个随机种子,才能保证每次复现得到相同结果。我在合作项目里吃过亏——两周前调好的削減参数,换了个机器跑,随机种子变了,结果最优解差了一大截。从那以后,固定种子变成了我的默认习惯,也建议你从一开始就养成。