ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

有序聚类与K-means结合:能源时序数据聚类分场景实战解析

有序聚类与K-means结合:能源时序数据聚类分场景实战解析

1. 为什么能源数据聚类不能一套流程打到底

做能源数据分析的人应该都有过这种体会:拿到一批光伏电站或风机的出力数据,第一反应就是把所有曲线丢进k-means里跑一遍,按欧氏距离分成几簇,再贴个“晴天、多云、阴天”的标签就收工。这么做在小规模演示里没什么问题,可一旦数据量上来、时间跨度拉长,你就会发现聚类结果越来越离谱——同一个天气类型被劈成两半,不同天气形态反而挤在一起。

我在做这个项目时最先遇到的也是这个问题。项目本身的目标很明确:针对光伏、风电、负荷、储能四条业务线,分别建立一套时序数据的聚类型板,用于后续的功率预测和调度策略下发。但四个场景的数据特性完全不是一个量级,光电出力曲线受云层遮挡影响,分钟级波动剧烈;风电出力有明显的季节性和阵风周期;负荷数据跟着人们的生活节律走,节假日和工作日是两套模式;储能数据则是典型的充放电状态切换。用同一套特征工程和同一个聚类参数跑到底,大概率只会在其中一个场景上表现尚可,另外三个场景全军覆没。

所以这个项目的有趣之处,其实不在聚类算法本身有多高深,而在于把有序聚类和k-means做了组合,并且按能源类型拆分策略。标题里说的“玩出新花样”,不是指发明了新算法,是指明白了一个朴素但容易忽略的道理:数据打架是因为它们的“生成机制”不同,算法要迁就物理语义,而不是让物理语义迁就算法。

1.1 四种能源曲线的形态差异远超预期

先看光电。光伏出力本质上是太阳辐照度经过组件效率、温度折损之后的结果,单条曲线从早到晚呈现“单峰、晨升暮降”的形态,理论上有很强的规律性。但实际数据里,云层遮挡会在分钟级尺度上制造大量凹槽,多云天气下出力曲线可能一个小时内反复升降五六次。这种形态和晴天那种平滑单峰放在一起,如果用原始逐点序列直接做距离度量,聚类结果会被短时波动主导,真正代表“天气类型”的整体形态反而被淹没。

再看风电。风机出力受风速影响,而风速又是典型的非平稳过程,有湍流脉动,有阵风包络,还有日变化和季节变化。风电曲线的“形态”不像光电那么单调,一个风场的多台机组之间都会因为尾流效应和地形差异产生相位偏移。对风电做聚类,如果只看波形形状,会发现同类场景下的曲线形状差异也很大;必须加进频谱特征,区分“高频脉动为主”和“长周期波动为主”两类不同风况。本质上,风电子场景下你关心的是功率的统计分布特征,而不是逐时曲线形状。

负荷数据是另一套逻辑。居民和工商业负荷都有强烈的周期性,但周期不是以“天”为单位,而是以“周”为单位——周一到周五的晚高峰和周六周日的午间高峰,形态完全不同。而且负荷曲线平滑,噪声小,直接聚类效果其实不差,问题在于节假日会打破周期性,导致同一个簇里混入行为模式不同的日期。负荷场景下的有序聚类,作用不在对曲线分段,而在识别“周期行为中断的变点”,把节假日从常规工作日序列里切出来。

储能数据则更特殊。储能系统运行是状态驱动的,充电、放电、待机三种状态来回切换。它的数据形态不是“曲线”,而是“状态序列”。聚类关心的不是数值大小,而是状态转移的频率和节奏,比如“一天两充两放”和“一周深度充放三次”是两类完全不同调度策略,但它们在数值曲线上可能长得并不像。这时候直接用k-means聚类逐时功率值没有任何意义,必须先做状态识别,再对状态转移序列做聚类。

1.2 普通k-means在时序数据上的三个盲区

把上面四种场景都过了一遍之后,你会意识到普通k-means在时序分析里有三个天然盲区。

第一个盲区是时序先后关系被抹平。k-means基于欧氏距离计算样本间相似度,它把一条时间序列当做一个向量,向量里的位置信息虽然在数学上保留,但距离计算时每个维度的顺序并不具有“语义排序”的概念。更直接地说,同一天里“上午高峰下午平谷”和“上午平谷下午高峰”这两条序列,在逐点欧氏距离下可能相距很远,但如果你把时间轴做一个整体平移,距离反而可能变小。可光伏出力曲线的物理含义恰恰是“峰值出现在几点”比“曲线长什么样”更关键。这是k-means处理时序时的结构性缺陷。

第二个盲区是噪声敏感性被平方放大。k-means的目标函数是最小化簇内平方和,而平方和的计算受离群点影响极大。风电数据里一个阵风峰值、光电数据里一片云的遮挡,都会在原始序列上产生一个局部大跳变。这个跳变对欧氏距离的贡献值远大于全天的整体形态差异,于是聚类时一个坏点就能把一个样本拖进错误的簇里。不是说做数据清洗就能解决,而是只要特征停留在“原始逐点值”层面,清洗只能去掉坏点,去不掉正常数据里那些真实存在但会对距离度量产生干扰的高频波动。

第三个盲区是分段数K和簇形态的绑定太僵化。k-means假设每个簇近似球形分布,所以在时序聚类里它默认所有样本共享同一个时间轴切分方式。但能源数据的物理过程不是这样:晴天和多云天的出力曲线,不仅整体水平不同,连“变化节点出现的位置”都不同——晴天只有早晨和傍晚两个变点,多云天气的变点可能分布在中午前后多个位置。两类样本在时间轴上天然不在同一分段对齐方式下,强行聚类只会互相污染。

这也是为什么项目里要把有序聚类塞进来。有序聚类的本质不是分类,而是在一维顺序维度上找变点。它先把每条序列里物理状态发生切换的位置切出来,再把切出来的各个片段各自提取特征,最后才交给k-means。有了这一层“时间轴对齐”的预处理,k-means才能把力气用在真正的聚类目标上。

2. 有序聚类和k-means的分工:先切段,再归类

这个项目的核心方法论可以用一句话概括:有序聚类负责回答“一段序列从哪里断开”,k-means负责回答“断开之后的片段属于哪一类”。两个算法各管一段,串联成一条完整的处理流水线。

2.1 有序聚类的本质是找变点,不是找分类

很多人第一次听“有序聚类”这个词,容易把它和“对有序样本做聚类”混淆。其实有序聚类在文献里的经典定义是“对有序样本进行分割,使得段内离差平方和最小”,它和k-means在数学形式上很接近,但目标完全不同。k-means是把样本分到K个互不交叠的类别里,样本之间的顺序不重要;有序聚类是在一条样本序列上寻找若干分割点,把序列切成连续的段,段的顺序必须遵循原序列的时间顺序,不能重排。

用通俗的话讲,k-means是“把人分成几群”,有序聚类是“在一根绳子上找几个切口”。切口选在哪里,取决于段内的紧凑程度——你要让每一段内部的样本离平均值尽可能近,同时让相邻两段的平均值差异尽可能大。这个标准和“找个拐点把曲线劈开”的直觉一致。

落到能源数据场景里,有序聚类的物理意义很清晰:光电出力曲线上的变点往往对应云层遮挡的开始和结束,风电出力曲线上的变点对应阵风过程的起止,负荷曲线上的变点对应作息规律的变化,储能曲线的变点对应充放电状态的切换。找到这些变点,等于把“物理过程在不同阶段的分界”自动标注出来了。

实际选型时,我用的是ruptures库里的PELT算法,配合RBF核函数来做变点检测。PELT(Pruned Exact Linear Time)是动态规划思想的优化版本,能在近似线性的时间复杂度下找到全局最优的分割方案,适合处理分钟级出力曲线这种几千个点的序列。RBF核的选择是考虑到光伏和风电数据波动形状并不规则,用基于均值的成本函数容易把幅值变化和形态变化混为一谈,RBF核能同时捕捉位置和形状的双重变化。

2.2 和k-means串起来的完整流水线

完整处理流程我分成六步,每一步都有明确产出:

  1. 数据清洗与对齐:剔除异常值、补全缺失时间戳,把不同设备、不同日期的出力曲线统一采样到同一时间分辨率。
  2. 有序聚类分段:对每条日曲线单独跑PELT,得到若干个变点位置,把整条曲线切分成多段。
  3. 段特征提取:对切出来的每个段,计算均值、斜率、峰峰值、持续时间、波动率等统计量,拼成一个特征向量。
  4. 段特征聚类:把全天所有样本的所有段特征向量汇总,用k-means聚类成若干个“段类型”。
  5. 序列重组:把每天的各段按时间顺序组合成“段类型序列”,例如一个晴天可以表示为“上升段-平台段-下降段”,一个多云天可以表示为“上升段-波动段-平台段-波动段-下降段”。
  6. 整日聚类:对段类型序列做Levenshtein编辑距离,再用层次聚类把相似序列归为一组,最终得到日级场景类型。

这六步里最关键的策略差异在第2步——不同能源类型在“切几段、用什么成本函数切”上是完全不同的。光电我倾向于用固定时间窗口内的斜率变化作为成本函数;风电我会把分段和频谱分解配合使用;负荷则把星期标签和节假日标记作为强约束,不是完全让数据自己找变点;储能直接绕开曲线,把状态识别结果变成状态转移序列后再做有序聚类。

2.3 为什么要用“段特征”而不是“点特征”喂给k-means

这是我一开始踩过坑、后来想明白的地方。最开始我也试过直接对原始序列做有序聚类之后,把每个原始数据点拼接起来作为k-means的输入,结果发现聚类效果提升非常有限,有些场景甚至变差了。后来仔细复盘才意识到,有序聚类输出的“段”是时间区间,k-means需要的特征是“描述这个区间的向量”,两者之间必须有一个特征提取的转换层。

段特征提取的原因有三个层面:

第一,降维是必要手段。一条光电日曲线如果按5分钟分辨率采集,有288个点,PELT把它切成4到6段后,如果直接把每段的原始点拼起来,维度仍然是288维,高维下的欧氏距离区分度会大打折扣。但把每段压缩成6到8个统计量,整条曲线就变成一个20到40维的向量,k-means在这个维度下能真正依据形态差异来分类。

第二,段统计量天然包含时序语义。原始点值只表达某时刻的出力大小,但“斜率”表达的是这段区间出力变化的快慢,“持续时间”表达的是这个状态的维持长度,“峰谷差”表达的是波动幅度。这些统计量从不同角度刻画了物理过程的特征,正是风电、光伏场景下区分天气类型或风况类型时真正有判别力的信息。

第三,段特征让不同长度的段可以对齐。光伏的多云天和晴天,切出来的段数量不同、每段长度不同,逐点计算距离是不可能的。但把每段压缩成固定长度的向量后,就可以进行后续的编辑距离计算和聚类。这是一个所有时间序列聚类方法都面临的对齐问题,段特征提供了最直观、最可解释的解决方案。

3. 光电场景核心代码段拆解

接下来直接进入正题。这是项目里光电场景处理的核心代码段,我按数据预处理、有序聚类分段、特征提取、k-means聚类四块拆开讲。先看完整版本,然后逐段解释关键细节。

import numpy as np import pandas as pd from ruptures import Pelt, KernelRBF from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score from kneed import KneeLocator # ---------- 1. 数据预处理 ---------- def load_daily_curves(df, date_col="date", time_col="time", value_col="power"): """把长表转换成以天为行的宽表,时间列按5分钟对齐""" df["dt"] = pd.to_datetime(df[date_col].astype(str) + " " + df[time_col].astype(str)) df = df.set_index("dt").sort_index() daily = df.pivot_table(index=df.index.date, columns=df.index.time, values=value_col) daily = daily.resample("5min", axis=1).mean() # 统一到5分钟分辨率 return daily.dropna() def preprocess_for_cluster(daily): """幅值归一化:保留形态,去掉装机容量差异""" daily_norm = daily.div(daily.max(axis=1), axis=0) daily_norm = daily_norm.fillna(0) return daily_norm # ---------- 2. 有序聚类分段 ---------- def segment_curve(power_arr, n_bkps_max=6, min_segment_len=5): """ 使用PELT+RBF找变点。 power_arr为单条日曲线的归一化数组(长度288) """ algo = Pelt(kernel=KernelRBF(), model="rbf", min_size=min_segment_len).fit(power_arr) # 用跳跃点数量限制分段数,避免过分割 bkps = algo.predict(pen=10) # predict返回包含最后一个点的下标列表,实际变点需去掉末尾 bkps = [b for b in bkps if b < len(power_arr) - 1] if len(bkps) > n_bkps_max: bkps = bkps[:n_bkps_max] # 把变点转换成段的起始位置 bounds = [0] + bkps return bounds def extract_segment_features(power_arr, bounds): """ 对每个段提取6个统计特征: mean, slope, peak_to_peak, duration_ratio, volatility, max_position """ features = [] n_points = len(power_arr) for i in range(len(bounds) - 1): start, end = bounds[i], bounds[i + 1] seg = power_arr[start:end] if len(seg) < 2: continue t = np.arange(len(seg)) slope = np.polyfit(t, seg, 1)[0] feats = [ seg.mean(), slope, seg.max() - seg.min(), (end - start) / n_points, seg.std(), (start + np.argmax(seg)) / n_points, ] features.append(feats) return np.array(features) # ---------- 3. 段特征聚类 ---------- def cluster_segments(daily_norm): """ 对全量日曲线的所有段特征聚成K类段类型。 返回:段类型向量列表,以及KMeans模型 """ all_segment_feats = [] curve_info = [] # 记录每个段来自哪一天、是第几段 for idx, row in daily_norm.iterrows(): arr = row.values.astype(float) bounds = segment_curve(arr) feats = extract_segment_features(arr, bounds) all_segment_feats.append(feats) for j in range(len(feats)): curve_info.append((idx, j)) X = np.vstack(all_segment_feats) X_scaled = StandardScaler().fit_transform(X) # 用肘部法则自动确定K inertias = [] K_range = range(2, 10) for k in K_range: km = KMeans(n_clusters=k, random_state=42, n_init=10) km.fit(X_scaled) inertias.append(km.inertia_) kl = KneeLocator(list(K_range), inertias, curve="convex", direction="decreasing") best_k = kl.knee if kl.knee else 4 km_final = KMeans(n_clusters=best_k, random_state=42, n_init=10) labels = km_final.fit_predict(X_scaled) return labels, curve_info, km_final, X_scaled # ---------- 4. 整日场景聚类 ---------- def sequence_to_cluster(labels, curve_info, n_days): """把每条日曲线的段标签序列转成变长字符串,用于后续编辑距离计算""" day_sequences = {} for day_label, pos, seg_label in zip([info[0] for info in curve_info], [info[1] for info in curve_info], labels): day_sequences.setdefault(day_label, []).append(seg_label) ordered = {day: [s for _, s in sorted(zip([info[1] for info in curve_info if info[0] == day], [seg for day_, seg in zip([info[0] for info in curve_info], labels) if day_ == day]))] for day in day_sequences} return ordered

这段代码初看可能有点绕,我逐块解释。

3.1 数据预处理:如何让不同天气曲线可比

load_daily_curves这一步容易被人忽略,但它是整个方法的地基。原始数据通常是一个电场几十台逆变器的上报记录,每台设备的时间戳可能不完全一致,缺数、重采样都是常态。我在这里把它转成以天为行、以时间为列的宽表,再做一次5分钟重采样,这么做的好处是后续所有处理都围绕“每天一条曲线”展开,便于做物理语义对应。

preprocess_for_cluster里的幅值归一化是光电场景特有的关键步骤。光伏电站之间装机容量不同,即使同一天气类型,30MW和100MW电站的出力数值相差数倍。如果直接把原始数值喂给聚类,容量差异会成为第一聚类主成分,天气类型反而变成次要因素。所以这里用每行最大值做归一化,把曲线压到0到1区间,保留的是“形状”,去掉的是“量级”。

要注意的一个细节是归一化在什么时候做。我的经验是必须在分段之前做,不能在分段之后做。如果先分段再对每段分别归一化,等于人为抹掉了段与段之间的幅值差异,平台段和高峰段都会被压成同一个量级,后续特征提取时的均值特征就失效了。

还有一个隐藏问题:夜间时段光伏出力恒为0,归一化之后这些点全部是0。这些点不会对特征提取造成太大干扰,但会对PELT的变点检测产生误导——因为夜间到凌晨的“拐点”实际上是由数据缺失或零值造成的,并不是物理过程切换。我的处理办法是直接截取每天6点到18点的区间做分段,把夜间零值排除在分析范围之外。代码里为了简洁没有体现这个裁剪,实际项目中这一步很重要。

3.2 有序聚类分段:用PELT找出力曲线的“天气切点”

segment_curve函数是整个流程里最核心的一段。它接收一条归一化后的日曲线,返回一组分段边界。PELT算法在这条曲线上寻找“成本函数变化最剧烈”的位置,这些位置就是天气过程的切换点。

这里有两个参数需要结合场景调:min_size和pen。min_size是段的最短长度,我设成5个采样点,对应25分钟。太短会切出大量噪声段,太长会漏掉多云天气里的短时波动段。pen是惩罚系数,值越大切出的段越少,值越小越倾向于过分割。10这个值是我用验证集反复试出来的,大致等于“允许因为切分而带来的成本下降阈值”,实际使用时可以根据曲线的波动程度做调整。

RBF核的选择值得一提。光伏出力曲线的变点,一部分是幅值突变(云层遮挡导致出力骤降),一部分是趋势突变(早晨出力快速上升转为平缓上升)。如果用L2成本函数,它只能捕捉幅值变化,对趋势变化的敏感度低;RBF核函数同时度量两个位置附近的局部形态差异,对两种变点都有不错的响应。代价是计算量变大,但光伏日曲线只有288个点,性能完全不是瓶颈。

跑完PELT之后返回的边界里,predict返回的最后一个元素是序列末尾的下标,所以代码里用列表推导把末尾去掉。同时用n_bkps_max做了分段数上限控制,这是为了防止在强波动天气下PELT切出十几段,导致后续编辑距离计算复杂度过高、聚类结果碎片化。

3.3 特征提取与k-means:把段变成向量

extract_segment_features做的是把一段数组压缩成6个统计量。我解释一下为什么是这6个,以及它们各自的物理含义:

  • 均值:段内平均出力水平,区分平台段和低谷段的最直接指标。
  • 斜率:用一阶多项式拟合得到的趋势方向,区分上升段、下降段和水平段。
  • 峰峰值:段内最大减最小,衡量波动幅度,是识别多云波动段的关键特征。
  • 持续时间占比:段长度除以全天采样点数,反映该状态的维持时间。
  • 标准差:段内离散程度,衡量高频抖动,阵风或云层快速移动时这个值会显著增大。
  • 峰值位置:段内最大值出现的位置在全天的占比,用于标记早高峰还是午高峰。

这6个特征之间有一些相关性,比如峰峰值和标准差在波动较大的段上会同时变大,但k-means对特征共线性不算敏感,而且保留这些冗余也有好处——它们从不同侧面描述“波动”这件事,可以让聚类结果对特征选择不那么敏感。我在实际实验里试过去掉峰峰值只保留标准差,轮廓系数略降,但没有质的差别。

cluster_segments把全量日曲线的所有段特征拼成一个大矩阵,标准化后做k-means。这里有一个容易犯的错误:标准化必须在拼接所有段之后,而不是在提取之前对每条曲线单独做。单独标准化会让每条曲线的段特征均值变为0,抹掉了不同曲线间的水平差异,聚类时会丢失“今天整体出力偏高还是偏低”这个重要信息。我这里是用StandardScaler在全矩阵上做,让各特征在同一尺度下参与距离计算。

K值的确定我用KneeLocator看肘部。对光伏来说,这个肘部通常出现在4到6之间——对应晴天、多云、阴天、雨天四个主类,加上过渡形态的两个子类。kneed库的返回值偶尔会不稳定,所以我设了一个fallback的best_k = 4,保证至少有可解释的类别数。

3.4 聚类结果如何反推天气类型

跑完k-means之后,直接看每个簇的中心向量就能反推出物理含义。我在项目里做了一个小表,把每个簇的均值、斜率、峰峰值打印出来,人工对照日历上的天气记录打标。比如一个簇的特征向量是[均值0.8,斜率近0,峰峰值0.15,持续时间占比0.45,标准差0.05,峰值位置0.55],这基本就是“上午到中午持续高平台、短时小幅波动、下午开始下降”的晴天上午段;而[均值0.4,斜率波动大,峰峰值0.6,标准差0.25]这个特征向量对应的就是多云波动段。

这一步看似“人工”,其实是聚类项目里不可或缺的闭环验证。算法给出的类别号没有任何语义,只有把聚类中心对应回物理过程,整个模型才变得可解释。我的做法是等全部天气类型打标完成后,把每类曲线的平均形态画出来跟原始数据做对比,目视检查是最后一道关。这个习惯在后面定义了整套方法的可靠边界。

4. 风电、负荷、储能场景的策略差异

光电场景的信手拈来,不代表其他三个场景可以照搬。项目后来把同一套流水线推广到风电、负荷、储能时,每一条线都做了针对性改造。我把这部分也写出来,因为“差异化的聚类策略”才是这个项目真正的核心思想。

4.1 风电:频域特征与季节标签先行

风电曲线跟光电最大的不同是它没有“固定的日内单峰形态”。风机出力完全随来流风速变化,可能是全天平稳,也可能是后半夜突然增强,整体规律性弱很多。直接套用光电的“幅值归一化 + 段特征 + k-means”流程,聚出来的簇要么全都像噪声,要么只分开“高功率天”和“低功率天”,对调度几乎没有帮助。

项目里对风电场景做了一个关键调整:在分段之前先做频谱分解。我把每条日曲线做FFT变换,提取低频段(周期大于6小时)、中频段(1到6小时)、高频段(小于1小时)三个频带的能量占比,作为三个特征,和段特征拼接在一起才送进k-means。高频占比高的曲线代表强湍流天气,低频占比高的曲线代表稳定的天气系统过境,这两种场景在调度上要完全不同的应对策略。

另外风电的场景识别不能只看单日,需要加季节先验。夏季和冬季的风资源特征差异巨大,即使聚类算法把不同季节的样本分到了同一簇,簇中心也必然是一个“两头不靠”的平均形态。我的处理是在特征矩阵里增加月份作为soft特征,允许簇中心在季节维度上有一定偏移,这样聚出的类既反映了日内波动形态,也在季节上有区分度,后续做预测时可以直接按簇取历史相似日。

4.2 负荷:有序聚类作用在星期标记之后

负荷曲线是我认为最有意思的一个场景,因为它的“变点”不是由天气决定的,而是由社会活动节奏决定的。直接在原始负荷曲线上跑PELT,找出的变点大多是早晚高峰的起止时刻,这类变点每天都有,区分度不高。

项目在负荷场景上先把星期标签和节假日标记作为硬特征并行输入,把一年365天先按“工作日 / 周末 / 节假日”粗分为三个大桶,再在每个桶内做有序聚类。有个细节值得注意:节假日不能简单归入周末,因为节假日前后可能有调休,而且节假日当天的大型活动对负荷有脉冲式影响。粗分之后,PELT在曲线的拐点检测就只负责识别粗分桶内“行为模式的变点”,比如某天早上用电高峰提前一小时出现,这类异常会被有效捕捉。

负荷场景的聚类目标不是分“晴天雨天”,而是分“典型日类型”和“异动日类型”。异动日的识别对负荷预测误差修正非常重要,项目里把聚类得到的异动日样本筛选出来,作为独立的训练集,给预测模型做补偿校准,效果比全部数据混训好不少。

4.3 储能:充放循环按状态机做有序聚类

储能场景下,功率曲线本身的信息量其实很少,因为储能系统要么在充电要么在放电,数值上无非是正负变化,直接聚类会得到一堆毫无意义的数值类别。正确的做法是先做状态识别,让每条曲线变成一串状态码:充电C、放电D、待机S。然后再对这个状态序列做有序聚类。

这里的有序聚类目的和前面三个场景完全不同。前三个场景的切分目标是识别“过程切换点”,储能场景的切分目标是找到“行为周期的边界”。一个储能电站的运行日志里,可能存在“一天两充两放”的运行周期,也可能存在“连续三天深充深放再休整一天”的周期。直接看状态码序列根本分不清周期在哪里,但用固定长度的滑动窗口统计充放切换次数和单次充放持续时间,再用PELT对统计指标序列找变点,就能比较可靠地切出行为周期。

切出周期后,对每个周期提取统计特征:充电次数、放电次数、平均单次充电时长、最大放电深度、充放切换频率、待机比例等。k-means对这些特征聚类后,得到的簇基本对应清晰的调度策略类型。比如一个簇的特征是“充电5次、放电5次、平均单次时长1小时、待机占比40%”,这就是典型的峰谷套利策略;另一个簇的特征是“充电1次、放电1次、单次时长4小时、放电深度90%”,这就是典型的容量备用策略。这些聚类结果后续直接用来做储能运行方式画像,为调度端提供策略分类参考。

5. 工程落地时踩过的三个坑

最后写几个项目推进过程中真实踩过的坑,每一个都在线上环境暴露过问题,如果只是Demo阶段很不容易发现。

5.1 归一化层级错误导致的假聚类

第一个坑我在前文提到过,但值得单独拎出来说。项目初版落地时,数据预处理代码里不小心把标准化写在了“按天分组”的循环内部,导致每条日曲线都被独立标准化成零均值单位方差。结果聚类出来的天气类型表面上看轮廓系数还不错,但实际分析时发现同一个簇里既有晴天也有阴天,业务方根本没法用。

原因不复杂:独立标准化把每天的“整体出力水平”这个信息完全抹掉了,剩下来的只有日内形态。而光伏场景下,“今天是晴天还是阴天”恰恰首先体现在整体出力水平上,形态反而是次要的。这个问题花了两天才定位到,定位方法也很笨——对每个簇随机抽几条原始曲线画出来对比,发现簇间均值差异远小于簇内均值差异,才想到是标准化放在了错误的位置。改回全量拼接后统一标准化,聚类结果立刻有了物理可解释性。

这个坑的教训是:聚类的预处理细节必须跟业务语义验证绑定。只盯轮廓系数和簇内平方和这些数值指标,很可能得到一个数学上合理、业务上没用的聚类。

5.2 分段数K怎么定才不会过拟合

PELT的参数pen决定了分段数量,分段数量直接影响后续特征质量。pen设太小,多云天气下的出力曲线会被切成七八段,每段只有二三个采样点,提取出来的段特征噪声极大;pen设太大,晴天和多云天都会被切成两段,特征粒度太粗,聚出来的类只分得开“高发”和“低发”,分不开“平滑”和“波动”。

我试过的办法里,最有效的是用“分段结果稳定性”来做交叉验证。具体做法是:把同一批数据随机打乱成三份,分别跑分段,统计每个样本的变点位置在不同份之间的方差。方差大说明分段对输入噪声敏感,pen需要调大;方差小说明分段稳定。这个方法的计算量略大,但一次调好之后基本不用再动。

另一个技巧是给min_size设一个和特征提取窗口匹配的下限。如果一段只有3个点,提取出来的斜率特征完全受采样噪声控制,不如直接把它并进相邻段。我最后把min_size设成和重采样分辨率对应的25分钟,既保留了多云场景短波动的识别能力,又避免出现无意义的小碎片段。

5.3 轮廓系数虚高时的验证办法

最后一个坑是模型验证环节。k-means聚类后,轮廓系数是最常用的质量指标,但我在能源数据上遇到过轮廓系数接近0.7但聚类结果明显错误的情况。原因是轮廓系数衡量的是几何紧密度,而能源曲线的“正确分类”更多依据物理语义。比如不同日期、不同天气下的两段平台段,在特征空间里可能很近,但它们的“序列上下文”完全不同——一个出现在清晨上升段之后,一个出现在午后下降段之前——单独看段特征无法区分。

解决办法是我在项目里加了一条验证规则:每个簇必须回看原始序列,人工抽检至少20条日曲线,确认该簇内的曲线在形态和天气语义上确实一致。这个检查虽然费时间,但它是保证模型可用的底线。回看时我一般同时打印簇内样本的日期分布,如果发现某个簇里的样本集中在某一个月,就要警惕是不是季节因素在主导聚类而不是天气类型。

实际部署后还有一个更隐蔽的问题:模型上线时用的是历史两年数据训练的聚类中心,但新数据的天气分布可能完全不同,出现历史中几乎没见过的极端天气时,样本会被强制分到距离最近的簇里,聚类结果就失真了。项目里的应对方案是设置一个簇中心最大距离阈值,超过阈值的样本标记为“新场景”而不是强行归类,等积累到一定数量再触发重训练。这个方法不算复杂,但在生产环境里救了很多次。

6. 其他一些心得体会

回顾整个项目,最深的感受是:聚类算法本身并不难,难的是让算法结果和生产业务对得上。k-means和有序聚类都是几十年前就成熟的方法,组合使用也不是什么新鲜事,但真正让它产生价值的,是每一步都在问一个问题——“这个参数选择在物理上意味着什么,业务上能不能解释”。能做到这一点,完全是因为在项目过程中把“算法工程师的视角”和“运行人员的视角”做了充分对齐,面向实际对象去反推模型设计的边界条件。

如果你也要做类似的时间序列聚类项目,我的建议是不要把精力全部花在调参和对比算法上。先花时间把数据物理过程搞清楚:变点对应什么事件,形态差异来自什么原因,簇标签该怎么向业务方解释。数据理解到位之后,算法选择的思路会自然浮现出来,参数调试也事半功倍。

顺便分享一个小技巧:项目里所有聚类结果我都要求输出一个“簇画像表格”,每一行是一个聚类类别,包含簇中心特征、样本量、日期分布、典型日曲线缩略图。这个表格一方面给项目汇报用,另一方面也是检验模型有没有跑偏的利器——每次数据更新后跑一遍,对比新旧版本的簇画像差异,任何因为数据分布漂移导致的问题都能第一时间暴露。

如果后续需要扩展,可以考虑把聚类结果直接接入预测模型的特征工程:用簇标签替代人工选择的“相似日”,或者把簇中心距离作为外部特征输入到功率预测模型里。这条路我还在验证中,目前看比单纯的人工挑相似日要稳定,但还没有到可以完全替代人工判断的程度。等跑完这个季度真实数据,如果效果稳定,我再写一篇专门讲聚类结果怎么和预测模型结合的文章。

RELATED READING

延伸阅读

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