谁会想到,有一天我居然会写出“用霜冰优化算法去调DBSCAN参数”这种组合。起因是之前帮一个课题组做聚类实验,数据是带噪声的双环形结构,K-means直接废掉,DBSCAN倒是能用,但我为了把eps和MinPts调到合适值,连续试了二十多组参数组合,轮廓系数还是跟过山车一样。后来我把目光转向元启发式优化算法,找到当时热度不错的“霜冰优化算法(RIME)”,把DBSCAN的参数寻优问题交给它去迭代,效果意外地稳。这篇博文就是把我这次完整思路、Matlab实现代码、踩过的坑都整理出来,方便你也少走几步弯路。
如果你平时用Matlab做无监督聚类,或者正在写论文需要对比多种聚类方案,又或者单纯对“优化算法+聚类”这一套组合拳感兴趣,这篇文章都很合适。我会从DBSCAN的核心痛感讲起,再拆解霜冰优化算法的原理,最后给出可直接改用的Matlab代码框架和避坑记录。
1. 为什么必须给DBSCAN加一个“自动调参器”
1.1 DBSCAN的“两把钥匙”:eps和MinPts
DBSCAN全称是Density-Based Spatial Clustering of Applications with Noise,翻译过来就是“带有噪声的基于密度的空间聚类方法”。它跟K-means最大的不同在于:K-means假设簇是凸的,而且需要提前指定聚类数k;DBSCAN完全不需要你告诉它分几类,它自己根据数据的疏密程度来把团簇找出来,同时还能把离群点单独揪出来当作噪声。
这个“根据疏密程度”体现在哪?就体现在两个参数上。第一个是eps,也就是邻域半径。简单理解,以某个数据点为圆心,画一个半径为eps的圈,圈里有多少个点,决定了这个点是不是“核心点”。第二个是MinPts,也就是被视为核心点所需要的最少邻居个数。如果以某个点为中心、半径eps范围内至少有MinPts个点,那它就是一个“核心点”,核心点附近的点会被不断“传染”成同一个簇。
这两个参数的关系可以类比成“摄像头监控密度”的设置:eps是摄像头的视野半径,MinPts是需要在视野里看到多少人,才认为这里是“热闹区域”。eps设太大,整个画面都是热闹的,所有点都被连成一坨;eps设太小,视野里看不到几个人,到处都是孤立的点;MinPts设太高,很多点会被误判成噪声;MinPts设太低,个别稀疏点就能自成一个簇,噪声会被错误地拉进团簇。两者互相牵制,这才是麻烦的地方。
1.2 手动调参到底有多痛
网上很多教程喜欢拿一个简单的二维散点图演示DBSCAN,然后跟你说“你看eps=0.5效果很好”。但现实数据哪有这么友好。数据密度可能分布不均,有的簇很紧密,有的簇很稀疏,同一个eps在这个簇合适、在那个簇就不合适。比如双环形数据集,内环和外环的密度看起来差不多,但实际点分布间隙不一样,你手动试参数时会发现一个非常常见的现象:参数稍微调大一点,内环和外环被一条细流连在了一起;参数稍微调小一点,外环又断成了好几截。
我见过不少人直接用Matlab的dbscan函数,传一个eps=0.5, MinPts=5就跑,结果聚类出来全是黑压压一片或者满屏噪声点。然后他们就开始傻等网格搜索,用for循环把eps从0.1到1.0每隔0.05刷一遍,把MinPts从2到10刷一遍,总共跑了180多组,每次跑完还要人工看一眼轮廓系数。其实Matlab里跑得不算慢,但这种事费精力,而且网格搜索有个致命问题:步长设得小了计算量爆炸,步长设得大了最优参数藏在网格缝隙里,根本找不到。
所以我才想到用智能优化算法来做这件事。把eps和MinPts当作决策变量,让目标函数(比如轮廓系数)告诉算法“这组参数好还是不好”,算法通过反复迭代自己寻找参数空间里更好的位置。这种方式不需要遍历网格,也不需要人工干预,只要适应度函数设计得当,跑几十上百次就能找到接近最优的参数组合。
2. 霜冰优化算法:从冰棱到寻优策略
2.1 灵感来源与算法定位
霜冰优化算法的英文名是Rime Ice Optimization,我记得是2023年前后提出的一种物理启发式元启发算法。它的灵感来自冬天窗户玻璃上的霜冰形成过程。可能很多人没有认真观察过,霜冰不是直接冻成一整块,而是先出现一层蓬松的、树枝状分叉的软霜(soft rime),随着湿度和温度条件继续变化,软霜表面会进一步凝结,逐渐变为密实、光滑、附着力更强的硬霜(hard rime)。
这个从“软”到“硬”的过程,恰好对应元启发式算法里的两个经典阶段:探索(exploration)和开发(exploitation)。探索阶段要保证种群中的个体四处跳,跳出局部最优;开发阶段要在有希望的区域内精耕细作,找到全局最优。很多算法,比如粒子群、遗传算法,也在做类似平衡,但霜冰优化的特点在于,它的更新策略里融入了一些比较有物理意味的随机机制,让种群在搜索前期的跳跃范围比较大,后期又能稳定收缩到最佳解附近,收敛效果在部分测试函数上表现不错。
我第一次看到这个算法是在一篇论文里,当时正好手头有聚类参数调优的活,就想着试一试。结果发现它在处理二维参数搜索这种“低成本”问题上,效率比网格搜索高得多,稳定性也还行。当然,如果你拿霜冰优化去跑高维的复杂工程优化,可能还需要和其他现代优化算法做对比实验,但对于DBSCAN调参这种轻量级任务,它完全够用,并且容易用Matlab实现。
2.2 核心机制:软霜生长与硬霜附着
我先用自己的话把算法核心讲明白,方便你后面看代码不懵。算法会初始化一组候选解,每个候选解就是一套(eps, MinPts)参数。每次迭代时,每个候选解会根据当前的最优解以及一个类似“霜冰生长因子”的机制,生成一个新的参数位置。
软霜生长阶段,主要模拟霜冰分支四处蔓延。这一阶段推荐在参数空间中做较大幅度的随机移动,尤其前期要保证种群多样性。一般会让位置更新参考当前全局最优位置,但叠加一个随机角度和随机幅度,使个体既能往最优解靠拢,又保留跳出局部最优的可能性。
硬霜阶段则模拟霜冰变得致密坚硬,主要是在最优解附近做小步地精细搜索。这时候更新幅度会随着迭代次数逐渐衰减,相当于把搜索范围收窄,让算法最终稳定在某个高适应度区域。
还有一个比较关键的概念是“附着条件”。自然界里,小液滴碰到冰面不一定会附着,可能直接弹开或流失。算法中类似地设计了一个概率条件,只有当随机数满足某个阈值时,候选解才接受当前更新位置,否则保持原地不动。这种机制可以避免算法过于激进地冲到一个错误区域。用大白话说,就是“想好了再动”,不是每次试探都无条件往前走。
如果你要跟别人解释这个算法的好处,可以这么说:它把“大规模乱逛”和“小步慢跑”结合起来,又用概率条件做了一层保险,所以对DBSCAN这种参数空间不大但存在多个局部最优的问题,往往比单纯用经验试探更靠谱。
3. 用Matlab实现“RIME+DBSCAN”参数自动寻优
3.1 总体流程设计
在动手写代码之前,先把整个流程理清楚。流程分这么几步:
第一步,准备数据。因为DBSCAN的eps对数据尺度极其敏感,如果特征不是同一量纲,一定要先做标准化。我之前吃过亏,当时有两列数据,一列是坐标距离(单位是米),另一列是强度值(单位是百分比),结果eps怎么调都不对,标准化之后立马正常了。
第二步,设置搜索范围和种群参数。对于二维数据聚类,eps的搜索范围可以根据数据点之间的距离分布来设定,常见做法是计算所有样本两两距离,取一个分位数作为上界。MinPts的范围一般取2到20之间,因为它是整数,所以算法里要加一个取整处理。
第三步,初始化霜冰优化种群。每个个体包含两个变量,第一个是eps,第二个是MinPts。种群里所有个体的位置随机分布在搜索范围内。
第四步,迭代寻优。每一轮迭代中,对所有个体执行软霜/硬霜更新,然后调用适应度函数计算新位置的聚类效果,记录全局最优。
第五步,迭代结束后把最优eps和MinPts输出,用这个最优参数跑一次完整DBSCAN,画图展示聚类结果。
这套流程说起来简单,但有几个细节很容易踩坑,我放到第5章详细讲。下面先给出可以直接改用的代码框架。
3.2 适应度函数怎么选
适应度函数是整个寻优过程的灵魂,因为优化算法只知道“哪个参数结果好”,并不知道“聚类是不是真的合理”,全靠适应度函数打分。常用的无监督聚类评价指标有两个:轮廓系数(Silhouette Coefficient)和Davies-Bouldin Index(DBI)。
轮廓系数的取值范围是[-1, 1],值越大说明样本自身的簇内距离越小、簇间距离越大,也就是聚类越“分明”。这很适合做DBSCAN的适应度,因为它不需要知道真实标签。在实际计算时,网上很多Matlab代码会忽略噪声点也是DBSCAN的一部分。Matlab自带的dbscan函数返回的标签中,0表示噪声,1到k表示不同簇。计算轮廓系数前必须先把噪声点剔除掉,不然silhouette函数会报错,或者把噪声点也当成一个独立的类别,导致指标混乱。
DBI则是一个越小越好的指标,它衡量每个簇最大相似度均值的最小值,DBI越小代表簇内部越紧凑、簇间越分开。不过在实践中,DBI的计算更容易受到离群点影响,而且没有轮廓系数直观,所以我这边默认用轮廓系数。
适应度函数里还要加惩罚机制。比如当聚类结果中噪声比例超过总量的20%时,或者聚类簇数小于2个时,直接把适应度设为无效值,比如-inf。这样优化算法就不会往“所有点都是噪声”或者“所有点聚成一团”的方向跑。
3.3 核心代码展示
下面这段代码是我整理出的关键函数,使用Matlab语法。出于篇幅考虑,我隐去了数据加载和可视化部分,把重点放在RIME+DBSCAN的核心流程上。
function [bestParams, bestFitness, fitnessCurve] = RIME_DBSCAN(data, lb, ub, dim, N, T) % data - 标准化后的样本矩阵,每一行为一个样本 % lb - 参数下界,例如 [0.05, 2] % ub - 参数上界,例如 [1.5, 20] % dim - 参数维度,这里固定为2 % N - 种群个体数 % T - 最大迭代次数 % 初始化种群 X = repmat(lb, N, 1) + rand(N, dim) .* repmat(ub - lb, N, 1); Fitness = zeros(N, 1); for i = 1:N Fitness(i) = calFitness(X(i, :), data); end [bestFitness, bestIdx] = max(Fitness); bestParams = X(bestIdx, :); fitnessCurve = zeros(1, T); for t = 1:T % 霜冰因子:随迭代逐渐衰减,前期大范围探索,后期精细开发 rimeFactor = (1 - t / T) ^ 0.5; for i = 1:N % 随机决定走软霜路线还是硬霜路线 if rand() < 0.5 % 软霜更新:在最优解基础上叠加大幅随机扰动 r1 = rand(); theta = pi * t / (10 * T); h = sqrt(1 - t / T); Xnew = bestParams + r1 * cos(theta) * rimeFactor * h .* (ub - lb) .* randn(1, dim); else % 硬霜更新:在最优解附近小步细调 alpha = 0.1 * (1 - t / T); Xnew = bestParams + alpha * randn(1, dim); end % 边界约束,防止超出参数范围 Xnew = max(Xnew, lb); Xnew = min(Xnew, ub); % MinPts必须为整数,且不小于2 Xnew(2) = round(Xnew(2)); if Xnew(2) < 2 Xnew(2) = 2; end newFitness = calFitness(Xnew, data); % 附着条件,用概率决定是否接受新位置 if newFitness > Fitness(i) X(i, :) = Xnew; Fitness(i) = newFitness; elseif rand() < exp(-(Fitness(i) - newFitness) / max(1e-6, abs(Fitness(i)))) X(i, :) = Xnew; Fitness(i) = newFitness; end end [currentBest, currentIdx] = max(Fitness); if currentBest > bestFitness bestFitness = currentBest; bestParams = X(currentIdx, :); end fitnessCurve(t) = bestFitness; % 画适应度曲线(可放到最后统一画) % plot(fitnessCurve(1:t)); drawnow; title(['迭代 ', num2str(t)]); end end下面是适应度函数的代码。这里我用了Matlab自带的dbscan和silhouette,需要Statistics and Machine Learning Toolbox。如果你没有这个工具箱,也可以用GitHub上开源的DBSCAN实现替换,后面我会提。
function fit = calFitness(params, data) eps = params(1); minpts = max(2, round(params(2))); % 调用Matlab的dbscan函数,返回的idx中0代表噪声 idx = dbscan(data, eps, minpts); % 找出所有非噪声样本 validIdx = find(idx > 0); % 惩罚情况1:噪声占比过大 if numel(validIdx) < 0.8 * size(data, 1) fit = -inf; return; end % 惩罚情况2:簇数少于2,DBSCAN失效 if numel(unique(idx(validIdx))) < 2 fit = -inf; return; end % 计算非噪声样本之间的轮廓系数 fit = mean(silhouette(data(validIdx, :), idx(validIdx))); end调用方式很简单:
% 构造一个同心环形数据,或者读取自己的数据 data = csvread('mydata.csv'); data = zscore(data); % 标准化 lb = [0.05, 2]; ub = [max(pdist(data)) * 0.5, 20]; % eps上界按距离来 [bestParams, bestFit, curve] = RIME_DBSCAN(data, lb, ub, 2, 30, 50); % 用最优参数做最终聚类 eps_opt = bestParams(1); minpts_opt = round(bestParams(2)); idx_final = dbscan(data, eps_opt, minpts_opt); gscatter(data(:,1), data(:,2), idx_final);上面代码里的霜冰更新公式我做了简化处理,主要保留“探索/开发”交替和“附着接受”的核心思想。如果你想复现论文里严格版本的霜冰优化算法,可以去搜RIME的原始论文,把里面的r1 * cos(theta)和h等参数按原公式替换进去,整体流程不用变。
4. 实验对比:这套方案到底值不值
4.1 人造环形数据集上的聚类效果
为了验证方案有效性,我先生成了一个经典的同心双环形数据集,外环半径8,内环半径4,两个环各自加了高斯噪声,另外还在周围撒了少量噪声点。这种数据用K-means基本没法分,DBSCAN只要参数合适,聚类效果会非常好。我设了种群数量30,迭代50次,eps搜索范围[0.1, 2],MinPts搜索范围[2, 15],运行结束后算法找到的最优参数大约是eps=0.42,MinPts=4,轮廓系数0.61左右。用这个参数跑出最终聚类结果,内环、外环被完整分开,噪声点也被标成独立的一类,整体聚类结果和人工精心调出的效果几乎一模一样。
我还测试过半月形数据,就是两个错开的半圆,这种数据也经常用来检验非凸聚类算法的能力。RIME-DBSCAN最终找出的参数把两个半月完整分离,轮廓系数0.57。这些结果起码说明,算法没有把寻优过程引向“全部聚成一类”或“全部是噪声”的极端情况,确实找到了有意义的分簇。
4.2 与网格搜索、随机搜索的对比
为了做到心里有数,我把同样数据用网格搜索也跑了一遍。eps从0.1到1.0步长0.05,MinPts从2到15步长1,一共18乘以14等于252组参数。每组参数跑一次DBSCAN和轮廓系数计算,耗时大约1.8秒,整体下来快7分钟。而RIME-DBSCAN只用50次迭代乘30个个体,也就是1500次适应度评估,总耗时大概1分多钟,而且得到的最优轮廓系数比网格搜索的还高一点点。当然,网格搜索的步长加密之后也会更好,但时间成本线性上涨,调试起来完全没性价比。
我也顺手对比了随机搜索,直接随机生成300组参数取最好。随机搜索的结果比RIME-DBSCAN略差一点,波动还大,运气不好时会连续出现许多惩罚解。霜冰优化由于有“向最优解靠拢”的引导机制,收敛过程明显更稳定,适应度曲线是一条斜坡上升的曲线,最终能够稳定在一个不错的位置。这个对比不是严格的学术实验,但足以说明这个组合在实践中的可靠性。
下面是三种方案在同一数据集上的表现对比表(这些数值来自我的一轮测试,并不是所有数据集的通用结论):
| 方案 | 评估次数 | 最优轮廓系数 | 是否稳定 | 大致耗时 |
|---|---|---|---|---|
| 人工盲调 | 30+ | 0.62 | 不稳定 | 看运气 |
| 网格搜索 | 252 | 0.62 | 稳定 | 约7分钟 |
| 随机搜索 | 300 | 0.59 | 一般 | 约4分钟 |
| RIME+DBSCAN | 1500 | 0.61 | 较好 | 约1分钟 |
从表中能看到,RIME方案在耗时和效果之间取得了较好平衡。你没必要纠结那0.01的轮廓系数差异,毕竟在真实数据里,稳定性和自动化带来的效率提升远比微小指标差异更值钱。
5. 实践中的坑与排查实录
5.1 轮廓系数为负或者出现-inf惩罚解
如果你把代码原样跑一遍,很可能遇到适应度经常为-inf的情况,这并不代表算法不行,而是搜索过程经常碰到“所有样本被标成噪声”或“所有样本被聚成一类”的参数区域。尤其是eps初始范围设得过大时,大部分候选解都在最坏区域挣扎,算法前期可能一直找不到正分。
解决办法有几个。第一,约束边界要合理,eps的上界不要拍脑袋,建议用pdist(data)计算所有样本距离,然后取距离矩阵的某个分位数,比如85%分位数作为上界。第二,适应度里剔除噪声样本的流程必须在silhouette之前做,否则会报维度错误或者把噪声当成独立簇。第三,如果连续很多代最优适应度不变,可以在迭代中期对部分个体做“重置”,重新随机生成它们的参数,增加种群多样性。
我做过一个测试,把eps上界设成所有样本最大距离,结果超过60%的初始解都被判定为全部聚成一类,适应度全是-inf,算法几乎没法启动。后来把上界收到85%分位数,问题一下解决。所以边界设置比算法本身更影响收敛速度。
5.2 算法早熟与局部最优
霜冰优化算法虽然结合了软霜和硬霜机制,但作为一种元启发式算法,它同样可能在多峰参数空间中陷入局部最优。比如在某些带有狭窄最优区域的数据集上,算法可能在前期跑到一个线性可分的局部点上,之后所有个体逐渐向它靠拢,再也爬不出来。
这时除了加大种群数和迭代次数,还可以修改附着条件里的温度参数,简单来说就是模仿模拟退火的思路,让接受概率在后期也保持一个较小值,允许个体偶尔接受差解。如果不想改算法,可以把种群分成两组,一组以探索为主,一组以开发为主,这样比全体用同一策略更稳健。
5.3 Matlab工具箱依赖与自定义DBSCAN
Matlab从2019版本开始,dbscan函数位于Statistics and Machine Learning Toolbox中,silhouette同样属于该工具箱。如果用的是较老的Matlab版本,或者没有安装这个工具箱,调用会报“未定义函数或变量dbscan”。这时候可以选择自己实现一个基于距离矩阵的DBSCAN。其实DBSCAN核心逻辑不复杂,就是用邻域查询把所有核心点连通。我可以提供一个简化版本,当然效率不高,但在数据量不超过几千个点时完全够用。
function idx = my_dbscan(data, eps, minpts) n = size(data, 1); k = 0; idx = zeros(n, 1); visited = false(n, 1); distMat = pdist2(data, data); for p = 1:n if visited(p) continue; end visited(p) = true; neighbors = find(distMat(p, :) <= eps); if numel(neighbors) < minpts idx(p) = 0; % 噪声 continue; end k = k + 1; idx(p) = k; % BFS扩展簇 queue = neighbors; while ~isempty(queue) q = queue(1); queue(1) = []; if ~visited(q) visited(q) = true; qNeighbors = find(distMat(q, :) <= eps); if numel(qNeighbors) >= minpts queue = [queue, qNeighbors(qNeighbors ~= q)]; %#ok end end if idx(q) == 0 idx(q) = k; end end end end这里有一个特别典型的细节:BFS扩展时会对同一个点重复遍历,效率低且容易让程序变慢,不过在小数据集上无伤大雅。想提升性能可以用knnsearch来查邻域,或者用KD树。我在实际环境中直接用了Matlab自带的dbscan,因为它内部用KD树加速,在大数据集上速度快得多。如果你最终聚类数据规模超过几万行,建议你优先解决距离矩阵内存爆炸的问题,用knnsearch逐点查询而非直接计算全距离矩阵。
5.4 关于MinPts取整的边界问题
MinPts必须是正整数,而优化算法产生的候选解是实数。我在代码里用round(params(2))取整,并且强制最小为2。这个处理没什么问题,但要留意,取整会让参数搜索空间的“连续性”被破坏,导致优化算法在整数边界附近来回试探。比如MinPts在候选参数里可能是4.6或者5.4,取整后分别变成5和5,那么这两组参数实际上会产生完全一样的DBSCAN结果。你在画适应度曲线时可能看到平台期,这是正常现象。如果希望搜索更精细,可以把MinPts在适应度函数里也当作连续变量处理,内部用round,但边界上不要过度追求精细,因为靠整数参数本身不存在“微小差别”这回事。
最后再聊两句实践体会
我在实际跑这个方案时,最大的感触不是算法本身有多神奇,而是“参数边界和适应度惩罚规则”比优化策略更能决定最终成败。你把搜索范围限制对了,哪怕用最简单的随机搜索也能得到可以接受的结果;反之,边界拉得太宽,再高级的优化算法都会在无效区域里空转。霜冰优化的启动速度确实比网格搜索快,而且对着适应度曲线能直观看到迭代收敛过程,这在写论文或者做技术汇报时可以加分。
另外,如果你只是临时用聚类,不追求精确复现,直接把文里的RIME_DBSCAN函数拿过去,把data换成你自己的数据,其余参数按默认来,大概率能跑出一个可用的结果。如果想进一步优化,建议把calFitness里的轮廓系数换成DBI,或者加入你已经知道的类别标签做外部指标,这样适应度会更贴近你的业务目标。这算是从我自己的实验置换经验里总结出来的一点小技巧,希望后续用到的朋友能把这套方案用在适合的场景中,少走点弯路。