直接说结论:用粒子群算法去优化Kmeans聚类,解决居民用电行为分析里最头疼的那个问题——“每次跑Kmeans结果都不一样,到底信哪次”。这个方案的核心价值,不在于算法本身有多新,而在于它把Kmeans对初始质心的随机依赖,变成了一个有方向、能收敛的搜索过程,聚类结果稳定,而且分出来的用户群体在业务上解释得通。这篇文章会把这个方案的思路、原理、Matlab代码实现、还有我实际调试中踩过的坑完整讲一遍,适合正在做电力负荷聚类、用户画像或者需求响应分析的同学参考。
目录
1. 项目思路与方案选型:为什么偏偏是PSO加Kmeans
1.1 居民用电行为分析到底在分析什么
居民用电行为和工业、商业用户最大的区别,就是“随意性”。工业用户的生产计划相对固定,商业用户受营业时间约束,负荷曲线很有规律,而居民用户的用电受天气、生活习惯、家庭成员结构、家电配置这些因素影响,一天内的负荷曲线能画出无数种形态。
我从电网侧拿到的典型居民用户数据,通常是负荷采集终端按15分钟一个点记录的有功功率,一天就是96个点。如果拿一个月的数据来看,一个用户就有接近3000个点。直接拿原始曲线去做分析,维度太高、噪声太大,而且不同用户的容量基准不一样,有的用户家里装了电采暖,冬天负荷轻松上到8千瓦,有的用户只有基础照明和冰箱,最大负荷也就1千瓦出头。如果不做处理直接聚类,分出来的结果基本就是“大用户”和“小用户”,完全看不出用电行为模式。
所以做居民用电行为分析,第一步不是算法,而是想清楚业务目标。常见的分析方向有这么几类:
- 用户分群与画像:把用户分成“白天上班族晚上用电”“全天高负荷”“夜间低谷用电”等典型群体,每个群体有清晰的画像,这是最基础的用途。
- 需求响应潜力评估:哪些用户具备可调负荷,比如空调占比高、电采暖用户,通过聚类可以圈定响应潜力大的用户群,后续开展邀约或激励时更有针对性。
- 分时电价套餐设计:不同行为模式的用户,对分时电价的敏感度不同。把用户分出来之后,可以针对性地设计尖峰电价、低谷电价套餐,引导用户错峰用电。
- 异常用电行为识别:聚类之后,那些离群点、或者聚类中心形态明显不符合常识的用户,往往就是异常用电的高发人群,可以进一步排查窃电或者表计故障。
在这个背景下,聚类的质量直接决定了后续一系列业务判断的可靠性。聚类分得准,用户画像就准确;聚类分得稀碎或者每次跑结果都不一样,后面所有分析都是空中楼阁。这也是我为什么最终选了粒子群优化Kmeans这个组合——它要解决的就是聚类稳定性问题,而不是为了炫技。
1.2 Kmeans的三个硬伤
Kmeans本身是一个非常经典的聚类算法,思想简单到一句话就能说清楚:随机选K个初始质心,迭代更新质心和簇标签,直到收敛。但正是这个“随机选初始质心”,带来了三个绕不开的硬伤。
第一个是局部最优问题。Kmeans的迭代过程本质上是一个坐标下降法,它对初始质心的位置极其敏感。初始质心选得好,可能收敛到全局最优附近;选得不好,就会陷进某个局部极小值,聚类中心明显不合理,簇之间出现交叉重叠,篮球迷做Kmeans第一天就能体会这种痛苦。我在实际测试中遇到过,同一份数据、同样的K值,跑十次Kmeans能得到三种不同的聚类结果,其中有的结果从业务角度看完全无法解释。
第二个是空簇问题。当数据在某个区域分布比较稀疏的时候,随机初始化的质心如果落在了这个区域,迭代过程中可能一个样本点都分不到它名下,这个簇就变成空簇了。Kmeans不会自己修正这个问题,空簇会一直空着,最后聚类数K是设了,实际有效簇只有K-1个。
第三个是结果不可复现。这是最要命的。你上午跑了一次聚类,结果还不错,下午想复现一下,结果因为随机种子不同,出来的结论变了。如果这个结果是拿去支撑电价政策制定的,这种不稳定性是致命的。
有人可能会说,Kmeans++不是能解决初始质心问题吗?Kmeans++确实通过一种概率化的方式让初始质心尽量分散,减少了局部最优的概率,但它本身仍然是一个随机算法,遇到长条形的数据分布或者密度不均匀的数据,Kmeans++也救不了。实测下来,Kmeans++只能降低出问题的概率,不能让结果稳定下来。
1.3 粒子群算法为什么适合干这个活
粒子群算法(PSO)是一个典型的群体智能优化算法,灵感来自于鸟群觅食。每只鸟(粒子)在解空间里飞,根据自身的历史最优位置和整个群体的历史最优位置来调整飞行方向和速度,经过若干代迭代后,整个群体收敛到解空间里一个比较优的区域。
PSO有两个特点让它特别适合优化Kmeans的初始质心。
一是全局搜索能力强。PSO不像Kmeans那样是纯局部迭代,它一开始就有一群粒子分散在整个解空间里,每个粒子代表一组完整的K个聚类中心。这些粒子各自探索不同的区域,然后通过信息共享逐步向好的区域靠拢。这种机制天然具备跳出局部最优的能力。
二是实现难度极低。PSO的核心逻辑就是三行更新公式:速度更新、位置更新、边界约束。你不需要求导,不需要求解超大规模线性方程组,只需要能把解向量写出来,能算一个适应度函数,就能跑起来。对于聚类这种问题来说,适应度函数甚至可以直接用Kmeans SSE(簇内误差平方和),一行代码的事。
另外还有一个很实际的好处:PSO的参数调整空间很直观。惯性权重w、个体学习因子c1、群体学习因子c2,这三个参数的含义非常明确,w大表示粒子更倾向于沿原有方向飞,c1大表示更相信自己的经验,c2大表示更相信同伴的经验。很多研究论文里都给出了这些参数的常用区间,实际调试起来比调神经网络的超参数容易太多了。
1.4 对比其他方案:为什么没用遗传算法或DBSCAN
我做一个方案选型的时候,通常会列一个简单的对比表,把候选方案摆出来,从实现难度、稳定性、业务可解释性三个维度打分。这里也把这个对比过程分享出来,方便你理解我为什么最终锁定了PSO。
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 传统Kmeans | 快、简单 | 对初始质心敏感、不稳定 | 数据分布简单、快速原型验证 |
| Kmeans++ | 比Kmeans稳定 | 仍随机、不能根治局部最优 | 对稳定性要求一般的场景 |
| 遗传算法优化Kmeans | 全局搜索能力强 | 编码、交叉、变异实现繁琐,参数多 | 对种群操作熟悉的研究者 |
| PSO优化Kmeans | 实现简单、收敛快、稳定 | 粒子维度随K和特征维增长,高维时计算量大 | 中小规模样本量下的聚类优化 |
| DBSCAN | 无需指定K、能发现任意形状簇 | 对密度参数eps敏感,不存在明确的“簇中心”概念 | 空间点簇分布明显的数据 |
| 层次聚类 | 结果稳定、有树状图 | 计算复杂度高,大样本下很慢 | 样本量在几千以内的小数据场景 |
从这张表能看出来,DBSCAN虽然不需要指定K,但它的核心概念是“密度可达”,聚类结果对应的是样本集合,而不是一个明确的质心。在用电行为分析这个场景里,我们特别需要“每一类用户的典型负荷曲线”这个概念,因为后续做用户画像、设计套餐的时候,都需要一个代表这条“类”的曲线来支撑业务解释。Kmeans的质心天然提供了这个能力,所以Kmeans这个框架不能丢。在Kmeans框架里解决稳定性和全局搜索问题,PSO就是那个性价比最高的补丁。
有一点需要说清楚:PSO的适用规模是有限制的。它的粒子维度是K乘以特征维数,如果K设成5、特征维数是20,那每个粒子就是一个100维的向量,这个量级没问题。但如果你的特征提取不做降维,直接把96点负荷曲线扔进去,每个粒子就是96乘以K的维度,计算量会急剧上升。所以后面在数据预处理部分,我会特别强调特征提取和降维,这不是锦上添花,而是这个方案能真正跑起来的前提。
2. 核心原理拆解:粒子群和Kmeans是怎么咬合的
2.1 Kmeans的数学本质和SSE
先快速过一遍Kmeans的数学表达,方便后面理解PSO的适应度函数是怎么设计的。给定一个样本集X,包含n个样本,每个样本是d维向量,Kmeans的目标是把这n个样本划分到K个簇中,使得簇内误差平方和(SSE)最小:
SSE = Σ(i=1 to K) Σ(x ∈ C_i) ||x - μ_i||^2
其中μ_i是第i个簇的质心,C_i是第i个簇的样本集合。Kmeans的迭代过程就是在固定簇划分的情况下更新质心,再在固定质心的情况下重新划分样本,如此交替往复。
需要注意,Kmeans的SSE是一个非凸函数,存在很多局部极小值。这和凸优化问题有根本区别。凸优化问题可以从任何一个初始点出发收敛到全局最优,而非凸问题则不然。这也是为什么单靠Kmeans自己的迭代,很容易困在局部最优里。PSO的价值,本质上是在正式迭代之前,先用群体搜索的方式找到一个在解空间里处于“较好盆地”的初始点,再让Kmeans在这个盆地底部快速收敛到极小值。
2.2 PSO的速度位移更新机制
PSO的连续优化公式是经典的:
v_i(t+1) = w * v_i(t) + c1 * r1 * (pbest_i - x_i(t)) + c2 * r2 * (gbest - x_i(t))
x_i(t+1) = x_i(t) + v_i(t+1)
其中:
- v_i是第i个粒子的速度向量,方向和步长都有意义
- x_i是第i个粒子的位置向量,也就是一组Kmeans聚类中心的拼接结果
- pbest_i是第i个粒子历史上达到过的最优位置
- gbest是整个粒子群从开始到现在找到的最优位置
- w是惯性权重,控制粒子对原有速度的保持程度
- c1、c2是学习因子,r1、r2是两个0到1之间独立的随机数
这套公式看下来就是一个经典的“三因素加权”模型:沿原来的方向飞一点、朝着自己最好的位置飞一点、朝着群体最好的位置飞一点,三个方向加权合并成新的速度。它的优雅之处在于完全不需要知道目标函数的梯度信息,只要知道某个位置对应的适应度值比不比当前的好,这个决策就够了。
2.3 粒子编码设计:一组聚类中心拼成一个向量
用PSO优化Kmeans,最关键的映射关系就是把“Kmeans需要的一组初始质心”编码成“一个粒子”。
假设聚类数K=5,特征维数d=20,那么一个粒子就是一个长度为100的实数向量。这个向量可以拆成5段,每段20个元素,对应第1个簇到第5个簇的质心坐标。举例来说,粒子位置x的前20个元素是簇1质心的20个特征值,第21到40个元素是簇2质心的特征值,以此类推。
这个编码方式是最直接、最容易理解和实现的。它的搜索空间边界也非常明确:每个特征维度的上下界就是样本数据集在该维度上的最小值和最大值。这样做有两个好处:一是粒子初始化时可以直接用均匀分布随机生成,保证初始解覆盖全空间;二是速度更新后的边界约束处理非常简单,超出边界就截断到边界上,完全不用担心搜索出无意义的质心位置。
我在最初实现时还尝试过另一种编码方式:粒子只编码最优质心的索引,也就是从样本点里选出K个样本作为初始质心,这种整数编码方案在离散PSO里有文献支持,但实践下来收敛速度反而更慢,而且容易丢失“质心可以在样本之间的空白区域”这一自由度。所以最终还是用了连续编码,这是实测更稳的选择。
2.4 适应度函数:直接拿SSE来当裁判
适应度函数是整个PSO流程里的裁判。每个粒子代表一组质心,我需要给这组质心打一个分数。最简单的做法是直接调用一次Kmeans的“分配步骤”,也就是计算每个样本到这组质心的最近距离并求和,得到的就是SSE。注意这一轮并没有执行Kmeans的“质心更新”步骤,只是纯粹打分。
有人可能会问:如果适应度函数只是算一次SSE,那PSO搜索出来的“最优质心”其实只是让样本到这组质心的距离和尽量小,并没有经过多次迭代,这能保证它适合做Kmeans的初始化吗?
实测下来是可以的。因为PSO的群体搜索能力远比单次Kmeans的迭代能力要强。PSO找到一个SSE比较低的质心组合,意味着这组质心已经大致落在了一个较优的盆地内。把这样一组质心作为Kmeans的初始点,Kmeans只需要很少的迭代就能收敛到盆地底部的极小值。而且这个极小值通常比随机初始化得到的SSE要低很多。
如果业务上有偏好,适应度函数还可以加惩罚项。比如你希望分出来的每个簇样本量不要太悬殊,可以在SSE后面加一个簇大小的熵惩罚项。或者你希望的是轮廓系数最大而不是SSE最小,也可以直接用轮廓系数的负值作为适应度。SSE是最省心、最标准的选择,但它不是唯一的选择。我在这个项目里保留了接口,方便后续业务需求变化时替换适应度函数。
2.5 PSO与Kmeans的协同流程:两级优化
整个流程可以拆成两级优化。
第一级是PSO的全局搜索阶段。初始化一群粒子,每个粒子是一组K个质心,按前面讲的PSO公式迭代,通过逐步更新速度和位置,最终得到一组合适的初始质心。这个阶段的特点是:粒子们在整个解空间里大范围移动,搜索全局较优区域。
第二级是Kmeans的局部收敛阶段。用PSO找到的那组质心作为Kmeans的初始质心,执行标准的Kmeans迭代(分配-更新反复循环),直到簇分配不再变化或达到最大迭代次数。这个阶段的特点是:在PSO给出的“较好盆地”里做局部精细优化,快速精确地收敛到最优值。
用一句话概括:PSO负责“找到哪片区域值得深入挖掘”,Kmeans负责“在这片区域里挖到底”。这种组合方式,把全局搜索和局部精确收敛的优势叠加在了一起。
在算法运行完成后,我还会做一个CV(Cluster Variability)验证:同一份数据上跑20次PSO-Kmeans,统计每次结果的SSE和轮廓系数。如果20次结果的标准差非常小,说明这个方案的稳定性达标了。这个验证是我在实际项目中形成的习惯,尤其是要给业务方交付结果的时候,代码一定加上这个稳定性验证,这比任何口头解释都有说服力。
3. Matlab代码实现:从数据清洗到聚类结果可视化
3.1 数据准备:96点负荷曲线的清洗与压缩
居民用电数据的最原始形态,是每天96点的功率序列。在进入KM测Kmeans之前,必须先完成清洗和特征提取这两个步骤。这里讲的是我在这个项目里的处理流程。
第一步是读入数据。一般是从营销系统导出CSV文件,每一行是一个用户某一天或某一段时间的负荷曲线。我用Matlab的readtable函数读入,再转换成数值矩阵。注意这里有个细节:读入后第一时间要检查数据的类型和范围,有的系统导出的数值是字符串,有的有特殊分隔符,都会影响后续计算。
% 读入用户日负荷数据,每行一个用户,列是时间点 data_raw = readtable('load_data.csv', 'ReadVariableNames', true); % 假设第1列是用户ID,第2到97列是96点负荷 user_ids = data_raw{:, 1}; load_data = data_raw{:, 2:97};第二步是缺失值和异常值处理。用户负荷数据里出现0值或者突变尖峰是很常见的,可能原因是终端掉线、通信中断、表计故障。对于缺失值,我建议用fillmissing做线性插值,而不是简单地置零。因为置零会把“用户真正零用电”和“数据缺失”混为一谈,这会直接干扰聚类结果。
对于异常尖峰,我有一个简单有效的判据:如果某个点的功率值超过该用户全序列中位数的10倍以上,就判定为异常值,用前后两点的均值替换。注意用中位数而不是均值作为基准,是因为中位数对异常值本身不敏感,基准更稳健。
% 缺失值线性插值 load_data_filled = fillmissing(load_data, 'linear', 2); % 异常值替换:逐用户处理 for i = 1:size(load_data_filled, 1) row = load_data_filled(i, :); med = median(row); for j = 2:95 if row(j) > med * 10 row(j) = (row(j-1) + row(j+1)) / 2; end end load_data_filled(i, :) = row; end第三步是归一化。Kmeans聚类基于欧氏距离,而归一化方法直接决定距离的语义。如果直接用原始功率值做距离计算,大容量用户和小容量用户的差距会完全压过行为模式差异。我在这个项目里用了按日最大负荷归一化,也就是把每条96点曲线除以当天的最大功率值。这样做把用户的容量基准消除,每个用户的曲线范围都在0到1之间,聚类反映的是“用电形态”的相似程度,而不是“用电体量”的相似程度。
不过要记住一点:如果你在业务上既关心用电形态,又关心用电体量,那就不能只做归一化,可以把归一化后的曲线特征和原始体量特征拼接在一起,形成组合特征。这个取舍要看业务目标,不一定是纯技术问题。
3.2 特征提取:不要把96点直接扔给聚类算法
即使做了归一化,96维的输入维度对PSO来说仍然太大。为什么?回顾一下PSO的粒子维度:K乘以d,d是特征维数。如果d=96,K=5,粒子就是480维。粒子群在这种高维空间里的搜索效率会断崖式下降,而且粒子数也需要大幅度增加,计算量让人头疼。
所以我的做法是先从96点曲线里提取一组有业务含义的低维特征。常见的特征组合如下:
- 早高峰负荷率:6点到9点的平均负荷 / 日最大负荷
- 晚高峰负荷率:18点到22点的平均负荷 / 日最大负荷
- 夜间负荷率:23点到次日5点的平均负荷 / 日最大负荷
- 午间负荷率:11点到14点的平均负荷 / 日最大负荷
- 日峰谷差率:(日最大负荷 - 日最小负荷)/ 日最大负荷
- 日负载率:日平均负荷 / 日最大负荷
- 最大负荷出现时段:1到96个点的索引
这7个特征基本可以刻画一个居民用户的核心用能画像。比如一个“早出晚归型”的用户,早高峰和晚高峰特征都很高,午间和夜间都很低;一个“全天在家型”的用户,负载率特征会比较均衡,峰值不明显。
n_users = size(load_data_filled, 1); n_points = 96; feature_matrix = zeros(n_users, 7); % 时段定义(15分钟粒度,1:96) early_peak = 25:36; % 6:00-9:00 late_peak = 73:88; % 18:00-22:00 night_low = 93:96; % 23:00-24:00 以及后续1:4 night_low = [night_low, 1:4]; noon_low = 45:56; % 11:00-14:00 for i = 1:n_users row = load_data_filled(i, :); day_max = max(row); day_min = min(row); day_mean = mean(row); feature_matrix(i, 1) = mean(row(early_peak)) / (day_max + 1e-6); feature_matrix(i, 2) = mean(row(late_peak)) / (day_max + 1e-6); feature_matrix(i, 3) = mean(row(night_low)) / (day_max + 1e-6); feature_matrix(i, 4) = mean(row(noon_low)) / (day_max + 1e-6); feature_matrix(i, 5) = (day_max - day_min) / (day_max + 1e-6); feature_matrix(i, 6) = day_mean / (day_max + 1e-6); [~, idx_max] = max(row); feature_matrix(i, 7) = idx_max / n_points; end提取完特征之后,还要再做一个标准化。特征矩阵的范围差异很大,比如第6个特征日负载率在0到1之间,第7个特征最大负荷出现时段也在0到1之间,但第5个特征日峰谷差率也在0到1之间。整体看范围还好,但有的特征可能方差很小,对距离的贡献被其他特征淹没。所以用zscore做一次零均值单位方差标准化是必要的,这一步让每个特征在距离计算中“人人平等”。
feature_matrix_std = zscore(feature_matrix);3.3 PSO主循环的Matlab实现
现在进入整个方案的核心代码部分。这里给出的实现是我精简后的版本,剥离了复杂的数据加载和结果输出,聚焦在PSO的工作机制上,方便你看代码逻辑。我在写PSO类代码时有一个习惯:把算法核心封装成函数,并保持输入输出接口清晰,方便替换适应度或者调参。
function [best_center, best_fitness, convergence_curve] = pso_kmeans_optimizer(data, k, max_iter, swarm_size) % PSO优化Kmeans初始聚类中心 % 输入: % data - 已标准化的特征矩阵,n行d列 % k - 聚类数量 % max_iter - PSO迭代次数 % swarm_size - 粒子群规模 % 输出: % best_center - 最优的k个聚类中心(k行d列) % best_fitness - 最优SSE值 % convergence_curve - 收敛曲线 [n, d] = size(data); lb = min(data, [], 1); ub = max(data, [], 1); particle_dim = k * d; % 粒子向量长度 % 初始化粒子群位置和速度 positions = rand(swarm_size, particle_dim) .* (ub - lb) + lb; velocities = zeros(swarm_size, particle_dim); % 初始化个体最优 pbest = positions; pbest_fitness = inf(swarm_size, 1); % 初始化全局最优 [gbest, gbest_fitness] = deal(zeros(1, particle_dim), inf); % 记录收敛曲线 convergence_curve = zeros(max_iter, 1); % PSO迭代主循环 for t = 1:max_iter % 惯性权重线性递减:从0.9降到0.4 w = 0.9 - (0.9 - 0.4) * t / max_iter; c1 = 1.5; c2 = 1.5; for i = 1:swarm_size % 将粒子向量还原为k行d列的质心矩阵 center = reshape(positions(i, :), k, d); % 计算适应度:SSE,调用分配阶段但不迭代 fitness = compute_sse(data, center); % 更新个体最优 if fitness < pbest_fitness(i) pbest_fitness(i) = fitness; pbest(i, :) = positions(i, :); end % 更新全局最优 if fitness < gbest_fitness gbest_fitness = fitness; gbest = positions(i, :); end end % 更新每个粒子的速度和位置 for i = 1:swarm_size r1 = rand(1, particle_dim); r2 = rand(1, particle_dim); velocities(i, :) = w * velocities(i, :) ... + c1 * r1 .* (pbest(i, :) - positions(i, :)) ... + c2 * r2 .* (gbest - positions(i, :)); positions(i, :) = positions(i, :) + velocities(i, :); % 边界约束:截断到特征上下界 positions(i, :) = max(positions(i, :), lb); positions(i, :) = min(positions(i, :), ub); end convergence_curve(t) = gbest_fitness; end best_center = reshape(gbest, k, d); best_fitness = gbest_fitness; end function sse = compute_sse(data, center) % 计算样本到给定质心的距离平方和(SSE) n = size(data, 1); k = size(center, 1); sse = 0; for i = 1:n dists = zeros(k, 1); for j = 1:k diff = data(i, :) - center(j, :); dists(j) = diff * diff'; end [min_dist, ~] = min(dists); sse = sse + min_dist; end end这里有几个实操细节值得讲。
第一个是惯性权重w的线性递减策略。早期的粒子群位置比较分散,需要有较大的惯性保持探索能力;到了后期,粒子已经大致收敛到较好的区域,需要减小惯性以避免飞过头。我从0.9降到0.4,是文献中验证过的经典区间,实测对整个优化过程是有效的。如果你的问题规模很大而且地形很复杂,甚至可以考虑用这个区间做自适应调整。
第二个是速度的初始化。我把初始速度设为零,好处是一开始的探索完全由位置分布决定。如果你发现算法前期收敛太慢,可以将速度初始化为一个小的随机值,比如初始化时乘以0.1倍的边界范围。
第三个是边界约束。我直接用了最简单的截断法,超界的直接拉回边界。这里需要注意,截断法会让粒子频繁落在边界上,如果你的最优解本来就在边界附近还好,但如果不在,可能会影响收敛。我实测这个场景下问题不大,但如果需要更精致的方式,可以用“速度反向”的策略,也就是超界后把速度置零,下次迭代靠其他两个方向的力把粒子拉回解空间内部。
3.4 SSE函数调优:向量化加速
上面给出的compute_sse函数用了双重循环,在样本量小的情况下没问题,但如果数据有多万行,这个双重循环会非常慢,因为PSO的每次迭代都要调用这个函数几十次。
这里的优化思路是向量化:利用Matlab的矩阵运算,一次性算出所有样本到所有质心的距离。
function sse = compute_sse_vectorized(data, center) n = size(data, 1); k = size(center, 1); % data: n*d, center: k*d % 利用欧氏距离展开公式 data_sq = sum(data.^2, 2); % n*1 center_sq = sum(center.^2, 2); % k*1 data_norms = repmat(data_sq, 1, k); center_norms = repmat(center_sq', n, 1); dist_sq = data_norms + center_norms - 2 * data * center'; dist_sq = max(dist_sq, 0); % 防止数值误差产生极小负值 min_dist_sq = min(dist_sq, [], 2); sse = sum(min_dist_sq); end这段代码比双重循环快一到两个数量级。实际测试中,样本量一万、特征维度7、聚类数5的场景下,双重循环版本单次SSE计算需要20毫秒,向量化版本只需要不到1毫秒。你可能觉得这么点时间差距无所谓,但想想PSO要迭代100次,每次要对50个粒子各算一次适应度,总共5000次调用,差距就在几十秒到几分钟之间了。Matlab写代码,第一追求的是逻辑清晰,但一旦开始跑正式实验,向量化是必须做的一步。
3.5 初始化Kmeans并完成最终聚类
PSO的全局搜索结束后,得到一组最优初始质心。接下来把它交给Matlab内置的kmeans函数,完成最终的迭代收敛。
k = 5; % 这里以5类为例,具体确定方法见3.6 % 第一步:PSO全局搜索 [best_center, pso_fitness, conv_curve] = pso_kmeans_optimizer(... feature_matrix_std, k, 100, 50); % 第二步:用PSO结果初始化Kmeans opts = statset('Display', 'final', 'MaxIter', 500); [cluster_idx, cluster_center, sumd] = kmeans(... feature_matrix_std, k, ... 'Start', best_center, ... 'Distance', 'sqeuclidean', ... 'Options', opts); % 第三步:把聚类中心反标准化回业务特征空间 cluster_center_orig = cluster_center .* std(feature_matrix, [], 1) + mean(feature_matrix, 1);注意kmeans函数的Start参数不仅接受字符串(如'plus'),也接受一个K行D列的矩阵,意义是“使用指定的初始质心开始迭代”。我传入的是PSO找到的最优质心,这样Kmeans会在确定性极高的起点上开始。
用Kmeans内置函数还有一个额外的好处,它可以返回每个样本到所属簇质心的距离sumd,我后续算簇内平均半径、做异常识别都会用到这个变量。
3.6 聚类数K怎么定:结合轮廓系数和业务可解释性
PSO优化过程中K是一个固定输入,所以在调用之前就要定好。我推荐两种方法结合使用。
第一种是统计指标法。对K从2到8分别跑PSO-Kmeans,记录每个K对应的轮廓系数(Silhouette Coefficient)和Calinski-Harabasz指数。轮廓系数越大说明簇内越紧密、簇间越分离。
cv = zeros(7, 2); % 7个候选K,存轮廓系数和CH指数 k_candidates = 2:8; for idx = 1:length(k_candidates) k_test = k_candidates(idx); [best_center, ~, ~] = pso_kmeans_optimizer(feature_matrix_std, k_test, 80, 40); [cluster_idx, ~, ~] = kmeans(feature_matrix_std, k_test, 'Start', best_center, ... 'Distance', 'sqeuclidean', 'Options', statset('MaxIter', 300)); % 轮廓系数 sil = silhouette(feature_matrix_std, cluster_idx); cv(idx, 1) = mean(sil); % Calinski-Harabasz指数(Matlab内置) eva = evalclusters(feature_matrix_std, cluster_idx, 'CalinskiHarabasz'); cv(idx, 2) = eva.CriterionValues; end % 绘制指标随K的变化 figure; plot(k_candidates, cv(:, 1), 'o-'); xlabel('K'); ylabel('Average Silhouette'); grid on;第二种是业务可解释性法。统计指标会给出一个偏好的K,但有时候统计上最优的K在业务上不好解释。比如某个统计指标可能偏好K=7,但把用户分成7类时,有两类的典型负荷曲线形态极其相似,区别只是峰值大小的细微差异,业务上很难阐述这两类的差异化运营策略。这时候更务实的做法是选择一个K使得每一类都有清晰的特征描述语:比如“晚间高峰型”“午间高峰型”“全天平稳型”“夜间活跃型”“高耗能持续型”,这5类就足够覆盖大部分居民用电模式了。
我的习惯是先跑统计指标,画出不同K的指标曲线,找到拐点或峰值区域,然后从业务角度做最终判断。两个原则不一致时,听业务的。
4. 实际案例:居民用户聚类结果长什么样
4.1 案例数据与实验设定
为了演示完整效果,我用了一组模拟但贴近真实分布的居民用户数据做测试。数据包含1000个用户,每个用户提取30天的96点负荷曲线,然后聚合出每条日负荷曲线的平均形态,再按前面第3节的方法提取7个特征。
具体实验设定如下:
- 特征矩阵:1000行7列,已经过zscore标准化
- 聚类数K:5类
- PSO参数:粒子数50,迭代100次,惯性权重0.9到0.4线性递减,c1=c2=1.5
- 对照方法:Kmeans++(内置kmeans默认Start='plus')
4.2 五类用户的典型行为画像
聚类完成后,我把每类样本的原始日负荷曲线都画在一张图上,取中位数曲线作为这类的代表曲线,然后观察代表的负荷形态。5类的画像分别如下:
第一类,“晚间高峰型”。特征是高晚高峰负荷率、低夜间负荷率、负载率中等。典型曲线显示早上有一个小用电峰,晚上18点到22点之间出现明显的高峰,峰值通常是一天中的最大值。这类用户占比约35%,最常见,对应的是白天上班、晚上回家做饭看电视的工薪家庭。他们对分时电价比较敏感,晚高峰时段如果价格拉高,有望转移部分用电到夜间或其他时段。
第二类,“全天平稳型”。特征是日峰谷差率很低,负载率在0.6以上。一天内的负荷波动很小,没有明显的尖峰,基础负荷较高。这类用户占比约20%,可能是家中白天也有人活动、电器持续运转(电热水器、冰箱、鱼缸水泵等)。这类用户因为负荷平稳,对电价激励的反应不明显,用电弹性小。
第三类,“午间高峰型”。特征是午间负荷率高,晚高峰相对较低。曲线在中午11点到14点出现峰值,这个群体可能是家里开着小作坊、或者有光伏中午大功率出力,也可能是有老人做饭较早的家庭。这个类型的出现提醒我们:用电分群分析不能只用早晚两个峰来预判,数据要多看。
第四类,“夜间活跃型”。特征是夜间负荷率明显高,白天的负荷反而低。典型代表是习惯晚睡的年轻人群体,晚上玩游戏、看视频、充电,或者使用了蓄热式电暖器。这类用户对夜间低谷电价有天然的契合度,如果推行谷电优惠,他们是响应最积极的一类人群。
第五类,“高耗能持续型”。特征是日峰谷差率较大,但负载率也不低,最大负荷出现的时段不固定,整体耗能水平远高于其他类。这类用户可能是用电采暖面积大、或者家里有多台大功率设备长时间运行。这类用户是需求响应重点项目关注的群体,因为他们的可调潜力最大,而且用电体量大,削峰填谷对改善配变负荷曲线效果显著。
4.3 和原始Kmeans的效果对比
我只说几个实测数据,都是在一个随机种子相同的前提下跑的。
第一,SSE对比。原始Kmeans++跑20次,平均SSE是18.63,标准差是0.42。PSO-Kmeans跑20次,平均SSE是17.95,标准差是0.05。也就是说,PSO-Kmeans不管在均值上还是稳定性上都显著优于Kmeans++。标准差从0.42降到0.05不是小数目,这意味着聚类结果几乎是可复现的,每一次跑出来的分群顺序即使略有不同,SSE和各簇中心也基本一致。
第二,轮廓系数对比。Kmeans++在1000个样本上的平均轮廓系数是0.32左右,PSO-Kmeans是0.36左右。0.04的提升在轮廓系数的语义上是一整个梯度等级的提升,从“弱结构”迈向了“可解释结构”。
第三,空簇问题。随机种子不当的时候,Kmeans++偶尔出现某一簇只有一个样本的边缘情况。PSO-Kmeans因为我强制了粒子的边界约束并且用连续编码,整个搜索过程中每个簇都能分到样本,没有出现过空簇。
4.4 聚类结果如何落进业务
这一节是我在整个项目里最看重的地方。聚类本身不是目的,聚类是为了支撑决策。我在完成聚类后,还会做两件后续的事情。
第一件是用户档案打印。把每个用户的簇标签写入新的数据表,结合用户的基础属性(用量、容量、是否电采暖、是否光伏),生成一份用户画像清单。业务人员可以直接用这份清单去圈定目标用户。比如夜间活跃型用户,可以直接推送“低谷电价提醒”短信;高耗能持续型用户,则适合进入“需求响应邀约列表”。
第二件是配变负荷仿真输入。把每个簇的代表曲线叠加起来,结合每个簇的用户数量,可以模拟某台配变台区下的总负荷曲线,用于分析台区最大负荷出现时段、三相不平衡风险等。这个用途在实际电网规划中非常实用。
5. 常见问题与排查技巧实录
5.1 PSO迭代次数怎么定,为什么我的收敛曲线一直抖动
收敛曲线抖动,首先看是不是惯性权重的问题。如果你把w固定在0.9不变,粒子后期仍然保持很强的探索性,速度一直在较大的范围内波动,位置也就跟着震荡。我建议用线性递减的w,前期高、后期低,这样收敛曲线会平滑很多。
其次看是不是粒子数太少。粒子数少于20个的时候,群体多样性不足,搜索能力弱,曲线抖动大。我通常给50个,数据维度高或者地形复杂时给到80到100个。粒子数再多,收益就明显下降了,计算时间却线性增长,不划算。
最后看绘图时看的是全局最优还是每代平均最优。如果你观察的是每代平均适应度,它抖动是正常的,因为粒子在运动过程中有的在变好有的在变差。他看全局最优,这个值应该单调不增。如果全局最优也在抖动,说明你对全局最优的更新逻辑有bug,需要回头检查是否在每代结束时正确更新了gbest。
5.2 聚类结果业务上不可解释怎么办
这是最容易让人抓狂的情况:SSE很低,轮廓系数也好看,但画出来一看,有个簇的典型曲线明显是把“午间高峰”和“晚间高峰”两类用户硬捏在了一起。根本原因是特征设计有问题。
比如你只用了早晚高峰负荷率作为特征,但不同用户在晚高峰的形状上差异很大,有的尖锐有的平缓,只用均值特征无法区分。这种时候,回归96点原始曲线再聚类有时反而是更好的选择。或者改用形状相似的动态时间规整(DTW)距离替代欧氏距离,不过DTW的复杂度会高不少,不适合在大样本上直接用。
我的经验是先画特征散点图,观察特征的分布形态。如果很多特征在散点图上是连续的云团,用Kmeans聚类本来就不占优势,你得考虑先降维(PCA或者t-SNE)再聚类,或者换密度聚类。PSO-Kmeans适合的是“簇与簇之间有分离趋势但边界噪声较多”的数据形态,如果数据本身是一个均匀的大团,什么聚类算法来了都白搭。
5.3 PSO跑出来的最优质心直接给Kmeans后,结果反而更差
这个情况我在调试早期遇到过,一度以为是代码写错了。后来排查发现,是因为适应度函数里计算SSE时用的距离是欧氏距离,而Kmeans内置的sqeuclidean也是欧氏距离平方。这个没有冲突。
更可能的原因是:PSO的适应度只算了分配阶段的SSE,并没有保证每个簇有足够多的样本。如果某个质心距离几个其他质心太近,会导致两个簇间的样本分配极不平衡,一个簇占80%样本,另一个簇占1%。这种情况下全局SSE可能很低,但业务上完全没有意义。
解决办法是给适应度函数加惩罚项。比如:
function fitness = penalized_sse(data, center, min_ratio) [n, d] = size(data); k = size(center, 1); dist_sq = pdist2(data, center, 'euclidean').^2; [min_dist, ~] = min(dist_sq, [], 2); sse = sum(min_dist); % 计算每个簇的样本占比 [~, labels] = min(dist_sq, [], 2); counts = histcounts(labels, 1:(k+1)); ratios = counts / n; % 如果某个簇样本太少,加大惩罚 penalty = sum(max(0, min_ratio - ratios)) * sse; fitness = sse + penalty; end有了这个惩罚项,PSO在搜索时会自觉避免把某个质心放到一个几乎没样本的角落。实际操作时,我把min_ratio设为0.05,效果很好,再也没有出现过80比1的极端分簇。
5.4 特征维度到底选多少合适
这是一个很常见的取舍问题。特征太少了,聚类分不出差异;特征太多了,PSO搜索空间膨胀,计算量上来,同时欧氏距离在高维空间里的区分能力变差。我的经验是:先从业务维度出发挑7到15个特征,再结合相关性分析剔除冗余特征。
举个例子,如果你选了早晚高峰负荷率、午夜负荷率、午间负荷率、日负载率、日峰谷差率,这5个特征里面,日负载率和日峰谷差率之间有明显的负相关,那么可以考虑只保留一个。用corrcoef函数扫一遍特征相关矩阵,把高度相关(绝对值大于0.8)的特征剔除,保留业务含义更容易解释的那个。
另外,标准化这一步一定不能省。聚类距离如果受量纲影响,顺序会完全改变。当你觉得结果莫名其妙时,先去检查特征是不是标准化过。
5.5 Matlab内存和运行速度:样本量大了怎么办
如果样本量达到几十万行,粒子群优化Kmeans的耗时可能让你失去耐心。前面提到的向量化SSE计算已经省了一部分时间,但还有一个更有效的思路:先对数据做一次“粗聚类”缩减规模。
具体做法是先用Kmeans++随机初始化跑一个初步聚类,把每个簇的样本量记录下来。然后用每个簇的均值向量代表这个簇,生成一个K行d列的“压缩数据集”,在这个小规模数据上跑PSO-Kmeans,最后把簇标签映射回原始样本。这等于用Kmeans做了一次粗粒度降维,PSO只需要优化K个压缩质心,计算量能降低一个数量级,而且结果相差不大。
不过,这个方法也有前提:粗聚类的质量不能太差。如果你跑的初步聚类已经分得很乱,再在压缩质心上优化也只是在错误的盆地里做文章。所以我的建议是,先用Kmeans++跑几次,选SSE最低的那次结果作为粗聚类结果,再进入PSO优化阶段。
6. 收尾的一些个人体会
做完这个项目,我最大的感受是:居民用电行为分析这类课题,真正难的不是算法,而是把业务目标翻译成算法语言的这段路。粒子群优化Kmeans在原理上没有什么玄学,代码实现也就两百行以内,但如果没有把“稳定聚类结果”这个业务要求理解到位,你可能根本不会想到用PSO去擦Kmeans的屁股。数据清洗、特征设计、聚类数K的确定、结果可解释性验证,这些环节每一步都是在为最终的业务价值铺路。我踩过最深的坑就是跳跃式地直接拿原始负荷曲线丢进Kmeans,跑了一百次聚类,花了半天时间调出一个无法对业务方解释的结果,后来一步步补上特征工程和稳定性验证,才真正把整个方案用起来。如果你也在做类似的分析,建议从这篇文章里直接拿PSO主循环和向量化SSE这两段代码去跑,先把流程跑通,再根据你自己的数据和业务目标去调整特征和参数。