简介:这份文档面向智能电网与虚拟电厂方向的研究生、科研人员及技术开发者,围绕新能源资源整合中的聚类分析问题展开。内容以Python为工具,对光伏、风电、储能、电动汽车充电桩、楼宇空调及工业可控负荷等数据进行采集与预处理,重点实现DRL-DBSCAN聚类算法,并与K-means、传统DBSCAN进行性能对比,通过轮廓系数等指标评估各算法优势,进而将聚类结果划分为时序互补型、空间互补型与多用途兼容型虚拟电厂,并给出优化调度方案与可视化图表。资源包共1个docx文件,约18KB,内含完整代码框架、分步注释与图形化展示,便于读者理解算法细节并迁移到实际项目。目前已有99人学习,适合希望掌握聚类算法选型与虚拟电厂资源配置思路的读者参考。
1. 当光伏和风电开始"抢地盘":这套 DRL-DBSCAN 聚类方案到底在解决什么
虚拟电厂这个词这两年频繁出现在调度侧的技术方案里,但真正落到代码层面,第一个卡住大多数人的问题不是优化算法,而是资源怎么分组。光伏中午猛发、风电半夜出力、储能随时待命、充电桩集中在傍晚、楼宇空调跟着气温走、工业可控负荷只在特定时段响应——这六类资源的功率曲线、时间戳分布和地理位置差异极大,如果直接丢进一个优化调度模型里,约束条件的维度会爆炸,求解器要么跑不动,要么给出一个物理上根本没法执行的调度方案。
这套 DRL-DBSCAN 聚类方案的核心思路是:先用聚类把异质资源按"互补性"分成若干组,每组内部再独立做优化调度。它适合两类人——一类是正在做虚拟电厂聚合平台、需要把分散资源打包成可调度单元的工程师;另一类是在做智能电网方向研究、需要一套可复现的聚类对比实验框架的研究生。代码是 Python 写的,依赖 pandas、numpy、scikit-learn 和 matplotlib,没有冷门库,环境搭起来不费劲。
2. DRL-DBSCAN 到底改了什么:从固定参数到自适应调参
2.1 传统 DBSCAN 在电力资源聚类上的两个硬伤
DBSCAN 的核心参数只有两个:eps(邻域半径)和min_samples(核心点的最小邻居数)。它的优势是不需要预先指定簇数量,能识别噪声点——这在电力场景里很实用,因为总有一些设备的运行数据是异常的,强行归到某个簇里反而会污染调度模型。
但问题也很明显。第一,eps是全局固定的。光伏和风电的功率数值范围可能差一个数量级,标准化之后虽然量纲统一了,但密度分布仍然不均匀。一个在风电数据上表现良好的eps,放到充电桩数据上可能把所有点都判成噪声。第二,min_samples对簇的形状很敏感。时序互补性虚拟电厂希望把时间上错开的资源聚在一起,空间互补性虚拟电厂希望把地理上分散的资源聚在一起,这两种目标对应的最优min_samples是不一样的,但传统 DBSCAN 只能设一个值。
我见过不少人在这步翻车:直接用默认的eps=0.5, min_samples=5跑一遍,发现所有点都被标成 -1(噪声),然后开始怀疑数据有问题。其实不是数据的问题,是参数没适配。
2.2 DRL-DBSCAN 的改进逻辑
DRL-DBSCAN 的思路是用深度强化学习来动态调整eps和min_samples。具体来说,把聚类过程建模成一个序贯决策问题:智能体观察当前数据的密度分布特征(比如 k-距离曲线的拐点位置、局部密度的方差),然后输出一组参数,执行 DBSCAN 后根据聚类质量(轮廓系数、噪声比例、簇数量合理性)给出奖励,智能体通过反复试错学到一套调参策略。
原始代码里给的drl_dbscan函数是一个简化模拟,只接收固定的eps和min_samples,没有真正的强化学习逻辑。如果你要把它用到实际项目里,需要补上智能体的部分。常见做法是用一个轻量的策略网络(两三层全连接就够了),状态用 k-距离的统计量表示,动作空间离散化成若干组候选参数,奖励函数用轮廓系数减去噪声比例的惩罚项。
import numpy as np from sklearn.cluster import DBSCAN from sklearn.metrics import silhouette_score from sklearn.neighbors import NearestNeighbors def compute_kdist_stats(data, k=5): """计算 k-距离曲线的统计特征,作为 DRL 的状态输入""" nbrs = NearestNeighbors(n_neighbors=k).fit(data) distances, _ = nbrs.kneighbors(data) kdist = np.sort(distances[:, -1]) # 取几个关键统计量:均值、标准差、拐点位置的梯度 stats = { 'mean': np.mean(kdist), 'std': np.std(kdist), 'gradient_max': np.max(np.gradient(kdist)), 'p90': np.percentile(kdist, 90) } return stats def drl_dbscan_adaptive(data, eps_candidates, min_samples_candidates): """带参数搜索的 DRL-DBSCAN 简化实现 实际项目中这里应该替换为训练好的策略网络推理""" best_score = -1 best_labels = None best_params = {} for eps in eps_candidates: for ms in min_samples_candidates: db = DBSCAN(eps=eps, min_samples=ms) labels = db.fit_predict(data) n_clusters = len(set(labels)) - (1 if -1 in labels else 0) if n_clusters < 2: continue noise_ratio = np.sum(labels == -1) / len(labels) sil = silhouette_score(data, labels) if n_clusters > 1 else -1 # 奖励函数:轮廓系数高、噪声比例低 score = sil - 0.5 * noise_ratio if score > best_score: best_score = score best_labels = labels best_params = {'eps': eps, 'min_samples': ms} return best_labels, best_params, best_score这段代码的逻辑是:先计算 k-距离统计量(实际 DRL 版本会把它喂给策略网络),然后在候选参数空间里搜索最优组合。奖励函数的设计是关键——只优化轮廓系数会导致簇数量偏少,只优化噪声比例会导致所有点挤成一个簇,所以用sil - 0.5 * noise_ratio做权衡。eps_candidates一般从 k-距离曲线的 90 分位数附近取,min_samples_candidates从 3 到 10 之间取,再大就容易把正常点判成噪声。
2.3 数据预处理里最容易忽略的一步
原始代码用StandardScaler对功率、时间数值、经纬度做了统一标准化。这一步本身没问题,但要注意:时间数值经过timestamp()转换后是一个十位数(比如 1672531200),和功率(几十到几百)、经纬度(小数点后几位)完全不在一个量级。标准化之后时间维度的方差会被压缩得很小,聚类结果几乎完全由功率和地理位置主导,时序互补性就体现不出来了。
我一般会做分维度加权:给时间维度乘一个权重系数,让它在距离计算中占更大的比重。具体权重取多少要看业务需求——如果目标是时序互补,时间权重可以设到 2.0 甚至 3.0;如果目标是空间互补,经纬度权重调高。
from sklearn.preprocessing import StandardScaler def preprocess_with_weight(data, time_weight=2.0, space_weight=1.0): """分维度加权标准化""" scaler = StandardScaler() scaled = scaler.fit_transform(data) # 假设列顺序为 [功率, 时间数值, 经度, 纬度] scaled[:, 1] *= time_weight # 时间维度加权 scaled[:, 2] *= space_weight # 经度加权 scaled[:, 3] *= space_weight # 纬度加权 return scaledtime_weight和space_weight这两个参数没有标准答案,需要根据你的数据分布和业务目标做几次实验来定。建议先用 1.0 跑一遍看基线,再逐步调整。
3. 三种算法对比实验:轮廓系数之外还要看什么
3.1 对比实验的代码框架
原始代码里compare_algorithms函数只输出了轮廓系数,这个指标在电力资源聚类场景下不够用。轮廓系数衡量的是簇内紧密度和簇间分离度的比值,但它对噪声点不敏感——DBSCAN 把 30% 的点标成噪声,剩下 70% 的点聚得很好,轮廓系数照样能拿高分。但在实际调度中,30% 的资源没有被分配,这个方案是不可接受的。
from sklearn.cluster import KMeans, DBSCAN from sklearn.metrics import silhouette_score, calinski_harabasz_score def compare_algorithms_full(data, n_clusters=3, eps=0.5, min_samples=5): """完整的算法对比,输出多个指标""" results = {} # K-Means km = KMeans(n_clusters=n_clusters, n_init=10, random_state=42) km_labels = km.fit_predict(data) results['K-Means'] = { 'labels': km_labels, 'silhouette': silhouette_score(data, km_labels), 'calinski': calinski_harabasz_score(data, km_labels), 'noise_ratio': 0.0, 'n_clusters': n_clusters } # DBSCAN db = DBSCAN(eps=eps, min_samples=min_samples) db_labels = db.fit_predict(data) n_db_clusters = len(set(db_labels)) - (1 if -1 in db_labels else 0) noise_ratio = np.sum(db_labels == -1) / len(db_labels) results['DBSCAN'] = { 'labels': db_labels, 'silhouette': silhouette_score(data, db_labels) if n_db_clusters > 1 else -1, 'calinski': calinski_harabasz_score(data, db_labels) if n_db_clusters > 1 else -1, 'noise_ratio': noise_ratio, 'n_clusters': n_db_clusters } # DRL-DBSCAN(用自适应版本) eps_cands = [0.3, 0.5, 0.8, 1.0, 1.5] ms_cands = [3, 5, 7, 10] drl_labels, best_params, best_score = drl_dbscan_adaptive(data, eps_cands, ms_cands) n_drl_clusters = len(set(drl_labels)) - (1 if -1 in drl_labels else 0) results['DRL-DBSCAN'] = { 'labels': drl_labels, 'silhouette': silhouette_score(data, drl_labels) if n_drl_clusters > 1 else -1, 'calinski': calinski_harabasz_score(data, drl_labels) if n_drl_clusters > 1 else -1, 'noise_ratio': np.sum(drl_labels == -1) / len(drl_labels), 'n_clusters': n_drl_clusters, 'best_params': best_params } return results这段代码在轮廓系数之外加了三个指标:Calinski-Harabasz 指数(衡量簇间离散度和簇内离散度的比值,越大越好)、噪声比例(越低越好)、簇数量(要和业务预期的虚拟电厂类型数量匹配)。n_init=10是 K-Means 的多次初始化,避免陷入局部最优;random_state=42保证结果可复现。
3.2 三种算法的适用边界
把这三个算法放在一起对比,不是为了证明谁"最好",而是搞清楚各自适合什么场景。
| 维度 | K-Means | DBSCAN | DRL-DBSCAN |
|---|---|---|---|
| 是否需要预设簇数 | 是 | 否 | 否 |
| 能否识别噪声 | 否 | 能 | 能 |
| 对参数敏感度 | 中(n_clusters) | 高(eps, min_samples) | 低(自动调参) |
| 适合的簇形状 | 球形 | 任意 | 任意 |
| 计算开销 | 低 | 中 | 高(需搜索/训练) |
| 电力场景适用性 | 资源分布均匀时 | 资源分布不均、有异常点时 | 多类型资源混合时 |
K-Means 的优势是快、稳定,适合资源类型少、分布比较均匀的场景。比如只有光伏和风电两类,功率曲线差异明显,K-Means 跑一次就能分出两组。但它的硬伤是必须预设簇数,而且对异常值敏感——一个充电桩的功率数据如果因为通信故障跳变到 10000 kW,K-Means 的簇中心会被严重拉偏。
DBSCAN 不需要预设簇数,能识别噪声,适合资源类型多、分布不均匀的场景。但参数调起来很痛苦,eps差 0.1 结果可能完全不同。我一般会先用 k-距离曲线找一个合理的eps范围,再在这个范围内做网格搜索。
DRL-DBSCAN 的价值在于把调参过程自动化了。如果你的资源类型超过四种,或者数据分布随时间变化(比如白天和夜间的资源出力模式不同),手动调参的工作量会很大,这时候自适应调参的优势就体现出来了。代价是需要额外的训练时间,而且策略网络的泛化能力取决于训练数据的覆盖范围。
3.3 聚类结果怎么映射到三类虚拟电厂
原始代码里assign_to_vpp函数的划分逻辑比较粗糙:用时间数值的方差判断是否归入时序互补性虚拟电厂,用经纬度方差之和判断是否归入空间互补性虚拟电厂,剩下的全部丢进多功能互补性虚拟电厂。这个逻辑的问题在于阈值是拍脑袋定的(time_var > 100000、space_var > 0.1),换个数据集就不适用了。
更合理的做法是用聚类结果的统计特征来自动判定。具体来说,对每个簇计算三个指标:时间跨度(簇内时间戳的极差)、空间跨度(簇内经纬度的最大距离)、资源类型多样性(簇内不同能源类型的数量)。然后根据这三个指标的组合来决定归属。
def assign_to_vpp_v2(labels, data, energy_types): """基于簇统计特征的虚拟电厂划分""" time_vpp, space_vpp, multi_vpp = [], [], [] for cluster_id in sorted(set(labels)): if cluster_id == -1: continue # 噪声点单独处理 mask = labels == cluster_id cluster_data = data[mask] cluster_types = energy_types[mask] time_span = cluster_data[:, 1].max() - cluster_data[:, 1].min() space_span = np.sqrt( (cluster_data[:, 2].max() - cluster_data[:, 2].min()) ** 2 + (cluster_data[:, 3].max() - cluster_data[:, 3].min()) ** 2 ) type_diversity = len(set(cluster_types)) # 判定逻辑:时间跨度大且类型少 -> 时序互补 # 空间跨度大且类型少 -> 空间互补 # 类型多 -> 多功能互补 if type_diversity >= 3: multi_vpp.append(cluster_data) elif time_span > np.median([c[:, 1].max() - c[:, 1].min() for c in [data[labels == i] for i in set(labels) if i != -1]]): time_vpp.append(cluster_data) else: space_vpp.append(cluster_data) return time_vpp, space_vpp, multi_vpp这里用中位数而不是固定阈值来判断时间跨度是否"大",这样不同数据集都能自适应。type_diversity >= 3的判断依据是:如果一个簇里包含了三种以上的能源类型,说明这些资源在功能上是互补的,适合归入多功能互补性虚拟电厂。
4. 优化调度与可视化:从聚类标签到可执行方案
4.1 贪心调度只是起点
原始代码里的optimize_scheduling函数做的是最简单的贪心累加:把每个簇内所有资源的功率加起来,输出总功率和资源索引列表。这个结果只能告诉你"这个虚拟电厂有多少可调度功率",但没法回答"什么时候调度哪个资源"。
实际的虚拟电厂优化调度需要处理的是时序问题:给定一个调度周期(比如 24 小时),每个时段的可再生能源出力预测、负荷需求预测、储能荷电状态,求解一个满足功率平衡约束、储能充放电约束、可控负荷响应约束的优化问题。常见做法是用线性规划或混合整数规划,scipy.optimize.linprog或pulp都能做。
from scipy.optimize import linprog def optimize_dispatch(pv_forecast, wind_forecast, load_forecast, storage_capacity, storage_power_max, dt=1.0): """简化的虚拟电厂日内调度:最小化从主网的购电成本 pv_forecast, wind_forecast, load_forecast: 各时段预测值(kW) storage_capacity: 储能容量(kWh) storage_power_max: 储能最大充放电功率(kW) dt: 时间步长(小时) """ T = len(pv_forecast) # 决策变量:每个时段的储能充放电功率(正为放电,负为充电) # 以及从主网的购电功率 # 目标函数:最小化购电成本(假设电价恒定) c = np.ones(2 * T) # 前 T 个是购电,后 T 个是储能充放电 c[:T] = 0.5 # 购电成本系数 c[T:] = 0.01 # 储能充放电的微小惩罚,避免频繁动作 # 约束:功率平衡 pv + wind + grid + storage_discharge = load + storage_charge A_eq = np.zeros((T, 2 * T)) b_eq = np.zeros(T) for t in range(T): A_eq[t, t] = 1 # 购电 A_eq[t, T + t] = 1 # 储能放电为正 b_eq[t] = load_forecast[t] - pv_forecast[t] - wind_forecast[t] # 不等式约束:储能充放电功率限制 bounds = [(0, None)] * T + [(-storage_power_max, storage_power_max)] * T # 储能荷电状态约束(简化处理,实际需要更复杂的时序耦合约束) res = linprog(c, A_eq=A_eq, b_eq=b_eq, bounds=bounds, method='highs') if res.success: grid_power = res.x[:T] storage_power = res.x[T:] return grid_power, storage_power else: return None, None这段代码用linprog求解一个简化的调度问题。目标函数是最小化购电成本和储能动作惩罚,等式约束是每个时段的功率平衡,边界约束是储能充放电功率上限。method='highs'是 scipy 目前推荐的求解器,比默认的单纯形法快很多。实际项目中还需要加储能荷电状态的时序耦合约束(SOC[t+1] = SOC[t] + storage_power[t] * dt / capacity),这会把问题从线性规划变成带状态变量的优化,需要用动态规划或模型预测控制来处理。
4.2 可视化不只是画散点图
原始代码的plot_clusters函数用前两个维度画散点图,但标准化后的数据前两维是功率和时间,经纬度信息完全没展示出来。对于空间互补性虚拟电厂的验证来说,这个图是缺失的。
我一般会画三张图:第一张是功率-时间散点图,用颜色区分簇标签,看时序互补性是否成立;第二张是经纬度散点图,同样用颜色区分,看空间分布是否合理;第三张是每个簇的功率曲线叠加图,看簇内资源的出力是否真的互补。
import matplotlib.pyplot as plt def plot_cluster_analysis(data, labels, energy_types, title_prefix=''): """三合一聚类分析图""" fig, axes = plt.subplots(1, 3, figsize=(18, 5)) # 图1:功率 vs 时间 scatter1 = axes[0].scatter(data[:, 1], data[:, 0], c=labels, cmap='Spectral', s=30) axes[0].set_xlabel('Time (normalized)') axes[0].set_ylabel('Power (normalized)') axes[0].set_title(f'{title_prefix} Power vs Time') plt.colorbar(scatter1, ax=axes[0]) # 图2:经度 vs 纬度 scatter2 = axes[1].scatter(data[:, 2], data[:, 3], c=labels, cmap='Spectral', s=30) axes[1].set_xlabel('Longitude (normalized)') axes[1].set_ylabel('Latitude (normalized)') axes[1].set_title(f'{title_prefix} Spatial Distribution') plt.colorbar(scatter2, ax=axes[1]) # 图3:各簇的功率分布箱线图 unique_labels = sorted(set(labels)) cluster_powers = [data[labels == l, 0] for l in unique_labels if l != -1] axes[2].boxplot(cluster_powers, labels=[f'C{l}' for l in unique_labels if l != -1]) axes[2].set_xlabel('Cluster') axes[2].set_ylabel('Power (normalized)') axes[2].set_title(f'{title_prefix} Power Distribution by Cluster') plt.tight_layout() plt.savefig(f'{title_prefix}_cluster_analysis.png', dpi=150) plt.show()cmap='Spectral'这个配色方案在簇数量不超过 10 的时候区分度很好,超过 10 个簇建议换成tab20。dpi=150保证保存的图片在论文或报告里够清晰。箱线图能直观看出每个簇的功率分布范围,如果某个簇的箱体特别窄,说明这个簇内的资源功率特性太单一,可能不适合单独作为一个虚拟电厂。
5. 避坑与排查:那些跑完代码才发现的坑
5.1 所有点都被标成噪声(label = -1)
现象:DBSCAN 或 DRL-DBSCAN 跑完,set(labels)只有一个值{-1},轮廓系数计算直接报错。
原因:eps设得太小,或者数据标准化之后维度间的尺度差异太大,导致任何两个点之间的距离都超过eps。另一个常见原因是min_samples设得太大,比如设成 20,但实际数据里每个局部区域的点密度根本达不到。
解决:先用 k-距离曲线确定eps的合理范围。具体做法是计算每个点到第 k 个最近邻的距离,排序后画曲线,拐点位置对应的距离就是推荐的eps。min_samples一般从 3 开始试,不要超过 10。如果数据维度超过 10 维,建议先做 PCA 降维再聚类。
5.2 时间维度被功率维度"淹没"
现象:聚类结果在功率-时间散点图上看,簇的划分几乎完全由功率决定,时间上错开的资源没有被分到同一簇。
原因:标准化之后,时间数值的方差被压缩,在欧氏距离计算中贡献很小。比如功率标准化后的范围是 [-2, 2],时间标准化后也是 [-2, 2],但功率的局部变化幅度可能远大于时间。
解决:用分维度加权,给时间维度乘一个大于 1 的系数。具体系数取多少需要实验,一般从 1.5 开始试,逐步增加到 3.0,观察聚类结果是否体现出时序互补性。
5.3 K-Means 的簇数量怎么定
现象:n_clusters设成 3 跑出来结果不理想,设成 4 或 5 又不知道哪个更合理。
原因:K-Means 需要预设簇数,但业务上到底应该分几类虚拟电厂,有时候并不明确。
解决:用肘部法(elbow method)或轮廓系数曲线来辅助判断。计算n_clusters从 2 到 10 的 SSE(簇内平方和),画曲线找拐点。但要注意,肘部法给出的"最优"簇数不一定符合业务需求——如果业务上明确要求分成时序互补、空间互补、多功能互补三类,那就固定n_clusters=3,不要被统计指标带偏。
5.4 可视化时中文标签变成方框
现象:plt.title('时序互补性虚拟电厂')保存的图片里中文显示为方框。
原因:matplotlib 默认字体不支持中文。
解决:在绘图前设置字体。Windows 上用plt.rcParams['font.sans-serif'] = ['SimHei'],Mac 上用['Arial Unicode MS'],Linux 上需要先安装中文字体再指定。如果是在服务器上跑、没有图形界面,用plt.savefig保存图片而不是plt.show,保存的图片里中文能正常显示。
5.5 优化调度求解器返回 infeasible
现象:linprog返回res.success = False,状态是 infeasible。
原因:约束条件互相矛盾。最常见的是功率平衡约束和储能功率约束冲突——比如某个时段光伏和风电出力很低,负荷需求很高,但储能的最大放电功率不够填补缺口,同时购电功率的上界又设得太小。
解决:先检查购电功率的上界是否足够大(可以设成None即无上界),再检查储能的最大充放电功率是否满足最恶劣时段的功率缺口。如果确实存在物理上无法满足的时段,需要在模型中引入切负荷变量(允许部分负荷不被满足),而不是让整个问题无解。
6. 从聚类标签到调度指令:一个验证闭环的搭建技巧
聚类做完、调度算完,怎么验证这套方案真的有效?我一般会搭一个简单的回测框架:用历史数据的前 70% 做聚类和参数训练,后 30% 做调度验证,对比"按聚类结果分组调度"和"不分组直接调度"两种策略的购电成本。
def backtest_clustering_benefit(data, labels, pv_idx, wind_idx, load_idx, split_ratio=0.7): """回测聚类分组的调度收益""" T = data.shape[0] split = int(T * split_ratio) train_data = data[:split] test_data = data[split:] # 策略A:不分组,所有资源统一调度 cost_no_cluster = simulate_dispatch(test_data, pv_idx, wind_idx, load_idx) # 策略B:按聚类标签分组,每组独立调度后汇总 cost_with_cluster = 0 for cluster_id in set(labels): if cluster_id == -1: continue mask = labels == cluster_id cluster_test = test_data[:, mask] # 这里需要根据簇内资源的类型重新映射索引 cost_with_cluster += simulate_dispatch(cluster_test, ...) return cost_no_cluster, cost_with_cluster def simulate_dispatch(data, pv_idx, wind_idx, load_idx): """模拟一个调度周期的购电成本""" pv = data[:, pv_idx].sum(axis=1) if pv_idx else 0 wind = data[:, wind_idx].sum(axis=1) if wind_idx else 0 load = data[:, load_idx].sum(axis=1) if load_idx else 0 net_load = np.maximum(load - pv - wind, 0) return np.sum(net_load * 0.5) # 假设电价 0.5 元/kWh这个回测框架的关键在于:simulate_dispatch函数要能处理不同簇内资源类型不一致的情况。实际写的时候,每个簇需要单独维护一个资源类型到列索引的映射表。回测结果如果显示分组调度的成本比不分组低 5% 以上,说明聚类确实捕捉到了资源之间的互补性;如果差异很小甚至更高,说明聚类分组没有带来实际收益,需要重新审视特征选择和参数设置。
从那以后我每次做完聚类都会强制走一遍这个回测流程——聚类指标好看不代表调度收益高,只有真金白银的成本对比才能说明问题。希望帮到你。
本文还有配套的精品资源,点击获取