如果你用过DBSCAN聚类,大概率对下面这个场景不会陌生:调邻域半径的时候,参数调大一点,整份数据糊成一团;调小一点,点全变成了孤零零的噪声。以前网上都说"看k距离图的拐点",可真到自己上手,那个拐点根本没有一个明确的判断标准。我当时在一个密度差异特别大的数据集上折腾了一整个下午,才彻底意识到——DBSCAN的聚类能力确实强,但它的两个参数(邻域半径eps和最小样本数MinPts)就是它的命门。后来我尝试用霜冰优化算法(RIME)来自动搜索这两个参数,效果出乎意料地稳定。这篇文章就把整个过程整理出来,包括算法思路、完整的Matlab代码框架、实验结果,以及我在实际运行中踩过的几个坑。
1. DBSCAN参数调优的痛点:eps和MinPts到底怎么选
1.1 那两个参数到底在控制什么
DBSCAN聚类的基本逻辑是"以点带面":只要某个点周围eps半径内有足够多的点(不少于MinPts),它就是一个核心点;核心点周围的所有点都归入同一个簇,簇边界沿着密度可达的链条不断向外生长。整个过程不需要预先指定簇的数量,也能识别出任意形状的簇,这是它相对于K-Means最大的优势。
但代价就是两个参数特别敏感。eps决定了"看多远",MinPts决定了"多少个点才算密集"。在密度均匀的数据集上,它们其实还好选;一旦数据出现多密度区域,事情就变得很折磨人。
举个例子。同样是eps取0.3,在密集区域里可能一个簇能容纳上千个点,在稀疏区域可能连MinPts都凑不满,整片区域全变成噪声。MinPts取小了,簇被噪声点串在一起;取大了,小簇直接被灭掉。两个参数互相耦合,手动调参时往往陷入"调好一个另一个又崩了"的循环。
1.2 三种常见人工调参方法为什么都不太够用
我最早用的方法是k距离图。思路是画出每个点到其第k个最近邻的距离曲线,找到曲线"拐点"作为eps。问题在于"拐点"在很多数据集上并不明显,是一条缓慢变化的S形曲线,不同人选出来的eps可能差很远。某A同学觉得0.2合适,某B同学认为是0.25,结果两个聚类结果完全不同。
第二种做法是套经验公式。比如MinPts取特征维度的2倍,eps靠运气试。这种办法在简单数据集上能跑通,但没什么鲁棒性,特征维度估计不准时,整套参数一起偏。
第三种是网格搜索,这也是我一开始最依赖的方法。把eps按0.01步进从0.01扫到1,MinPts从2扫到20,听起来很"科学",实际算一下就是100档乘以19档,总共1900次完整聚类。当时我拿一个五千多条样本的数据集跑,跑了一整晚还没结束。更尴尬的是,由于eps与MinPts存在耦合,这种独立枚举的方式经常漏掉真正的最佳参数区间。
1.3 换个思路:把调参当成一个黑箱优化问题
后来我换了一个角度想问题:给定数据集,选定一种聚类质量评价指标(比如后面会说的DBI指数),那么DBSCAN的聚类效果其实是eps和MinPts这两个变量的函数。函数具体长什么样、能不能求导,我们完全不关心,只要能有办法对数次评估,理论上就能用一个全局优化算法去搜索它的最优值。
这个问题的特点是:维度低(只有两维)、评价函数计算代价较大、曲面是非凸的(可能有很多局部极值)。所以我需要的不是一个依赖梯度的数学优化器,而是一个带随机性的启发式优化算法。霜冰优化算法就是在这个背景下被我用上的。
2. 霜冰优化算法的机制拆解与选型理由
2.1 霜冰是怎么从自然界变成算法的
霜冰优化算法是这几年的新算法,灵感来自霜冰在冷表面上的形成和生长过程。它把每一个候选解看成一片"霜冰",算法整体分成两个阶段。
第一个阶段叫软霜搜索,对应的是霜冰刚开始形成时,冰晶还比较软、外形不规则,生长方向受环境扰动很大。算法用余弦波动加随机差分的方式生成新个体,重点保持种群的多样性,希望大家在解空间里充分铺开,寻找那些"看起来有希望"的区域。
第二个阶段叫硬霜穿刺,对应的是霜冰逐渐硬化、晶体尖端互相刺穿和堆叠的过程。此时个体开始向当前最优解靠拢,用两个随机个体的差分信息做精细搜索。我自己的理解是:前半程负责"找得到",后半程负责"找得准"。
2.2 为什么我不选粒子群和遗传算法
我最早其实想直接用粒子群,但一查就发现粒子群自己要调惯性权重、个体学习因子、社会学习因子,等于给一个调参问题引入了更多参数。遗传算法也有类似问题,交叉概率、变异概率对收敛行为影响很大,还得考虑编码方式,改起来不够直觉。
霜冰优化算法在这方面的优势是超参数少,核心只有一个迭代衰减系数。软霜和硬霜两个阶段根据迭代进度自然切换,不需要我手动决定什么时候从探索转开发。这个特性在写代码的时候特别舒服,整个寻优循环非常干净。
当然,并不是说霜冰优化算法在所有问题上都吊打粒子群。对这个两维参数搜索问题,它算是一个"够用且省心"的选择,关键是实现成本低、不容易被额外超参带偏。
2.3 改进的整体思路:把参数组合编码成霜冰粒子
整个改进方案可以概括成一句话:用霜冰优化算法去搜索e```plaintext ps和MinPts的最优组合。
具体来说,我把每个霜冰个体定义成一个二维向量[eps_ratio, MinPts]。eps_ratio是归一化后的邻域半径,范围限定在0.01到1之间,实际使用时乘上数据集中最大的样本间距离,换算成真正的eps值。这样做的原因是不同数据集的距离尺度差异巨大,有的数据集距离普遍在0.01量级,有的则在几百的量级,直接搜索原始eps值会让搜索范围很难定。
MinPts则采用整数编码,在2到30的范围内搜索。每一轮迭代,每个个体先做一次DBSCAN聚类,然后根据聚类结果的好坏得出适应度值,适应度最优的个体逐步引导整个种群向更好的参数区域移动。
3. 改进框架设计:参数编码、适应度与寻优流程
3.1 参数编码与解码的细节
编码环节最容易踩坑的是MinPts的整数化。优化器本身是在连续空间里生成新个体的,但MinPts必须是正整数。我一开始直接把浮点数四舍五入,结果发现适应度函数在边界点附近完全不平滑,优化器很难稳定收敛。
后来我调整成两步走:先让优化器在连续空间搜索,评估前把MinPts四舍五入;同时把种群规模设置得稍大一些,让个体的多样性弥补离散化带来的"台阶效应"。实际运行下来,这个问题缓解了很多。
eps_ratio的反归一化也要注意。有人会顺手用数据集的平均距离做上界,我觉得不太稳妥。比如数据里存在两个离群点,平均距离会被拉大,整个搜索空间都偏向过大的eps。我更推荐用所有样本两两距离的一个高分位数(比如90%分位数)作为实际eps的换算基准,这样抗离群点干扰的能力强很多。
3.2 适应度函数为什么选DBI而不是轮廓系数
常见的聚类评价指标有轮廓系数、DBI指数、CH指数等。我首选DBI作为适应度函数,主要出于三个原因。第一,DBI的计算只需要簇内平均距离和簇中心距离,复杂度低;轮廓系数需要反复计算样本间距离,在数据量大时整个寻优过程会被拖慢很多。第二,轮廓系数对凸形簇有明显偏好,DBSCAN擅长发现的细长形、月牙形簇在轮廓系数下经常得分偏低,这会导致优化器把参数推向错误的区域。第三,DBI的本质是"簇内紧凑度与簇间分离度的比值",正好贴合聚类任务的核心目标。
当然,单纯用DBI也有隐患。比如某个极端参数把所有样本都判成噪声,DBI反而会得到一个虚低的分数。所以我的实现里加了两个惩罚项:一是有效簇样本数低于总样本数60%时,直接给一个很大的惩罚值;二是簇数量小于2或大于样本数的平方根时,同样直接判死刑。
3.3 整体寻优流程
整个流程是这样的:
- 数据预处理:先做标准化,计算所有样本两两距离,确定eps的实际搜索上界。
- 初始化霜冰种群:随机生成Npop个二维个体,MinPts预先取整。
- 迭代循环:每一轮里,对每个个体执行DBSCAN聚类,计算DBI并施加惩罚规则,更新全局最优个体。
- 种群更新:根据迭代次数切换软霜搜索和硬霜穿刺公式,生成新个体并做边界约束。
- 循环结束后,用最优个体解码出的eps和MinPts做最终聚类。
从工程角度看,这个流程最大的优点是把DBSCAN当成了一个黑箱模块,后续如果想换别的基础聚类算法,只需要替换第3步的聚类函数就行,框架本身完全不用动。
4. Matlab代码实现:RIME-DBSCAN的核心模块
4.1 主程序与参数配置
下面是我整理后的Matlab实现,基于R2021b版本,核心依赖是自带的dbscan函数(R2019a及以上版本都有)。代码我按模块拆开讲,方便直接抄。
%% 基于霜冰优化算法改进DBSCAN聚类 —— 主程序 % 优化变量:[eps_ratio, MinPts] % 实际eps = eps_ratio * dist_high clear; clc; rng(42); %% 1. 加载数据并预处理(强烈建议zscore标准化) load('sampleData.mat', 'X'); % 假设X是N×d矩阵 X = zscore(X); [Ndata, dims] = size(X); % 用90%分位数作为eps搜索上界 D = pdist(X); dist_high = quantile(D, 0.9); % 抗离群点干扰 %% 2. 霜冰优化算法参数配置 Npop = 20; % 种群规模 MaxIter = 60; % 最大迭代次数 dim = 2; lb = [0.01, 2]; % eps_ratio下限, MinPts下限 ub = [1.00, 30]; % eps_ratio上限, MinPts上限 % 初始化种群 Pop = repmat(lb, Npop, 1) + rand(Npop, dim) .* repmat(ub - lb, Npop, 1); Pop(:,2) = round(Pop(:,2)); BestFit = inf; BestPos = Pop(1,:); %% 3. 迭代寻优 for t = 1:MaxIter % 评估每个个体 for i = 1:Npop fit_i = cal_fitness(Pop(i,:), X, dist_high); if fit_i < BestFit BestFit = fit_i; BestPos = Pop(i,:); end end % 更新种群 Pop = rime_update(Pop, BestPos, t, MaxIter, lb, ub); Pop(:,2) = round(Pop(:,2)); fprintf('Iter %3d | DBI = %.4f | eps = %.3f | MinPts = %d\n', ... t, BestFit, BestPos(1) * dist_high, round(BestPos(2))); end %% 4. 用最优参数做最终聚类 eps_best = BestPos(1) * dist_high; minPts_best = max(2, round(BestPos(2))); idx_final = dbscan(X, eps_best, minPts_best); % 可视化(仅二维数据可用) figure; gscatter(X(:,1), X(:,2), idx_final); title(sprintf('RIME-DBSCAN: eps=%.3f, MinPts=%d', eps_best, minPts_best));这里有几个细节想提醒。第一,quantile(D, 0.9)计算的是所有样本对距离的90%分位数,比max(pdist(X))更稳健,可以防止个别离群点把搜索上界拉得过大。第二,rng(42)是为了实验可复现,实际使用的时候可以去掉,让优化器随机探索。
4.2 霜冰更新模块
霜冰优化算法的核心更新逻辑我封装在rime_update函数里。软霜搜索阶段使用余弦扰动和随机差分,硬霜穿刺阶段围绕最优解做精细搜索。
function Pop = rime_update(Pop, BestPos, t, MaxIter, lb, ub) % 霜冰优化算法种群更新 [Npop, dim] = size(Pop); beta = 1 - t / MaxIter; % 搜索强度随迭代衰减 for i = 1:Npop j = randi(Npop); k = randi(Npop); if t <= MaxIter / 2 % 软霜搜索阶段:余弦波动 + 随机差分,保持探索能力 theta = pi * rand; h = randn; diff = Pop(k,:) - Pop(j,:); Pop(i,:) = BestPos + randn * cos(theta) * beta * ... (h * (BestPos - Pop(i,:)) + rand * diff); else % 硬霜穿刺阶段:围绕最优解精细搜索 Pop(i,:) = BestPos + rand * (Pop(j,:) - Pop(k,:)); end % 边界回拉 Pop(i,:) = max(Pop(i,:), lb); Pop(i,:) = min(Pop(i,:), ub); end end关于这个函数的两个阶段切换,我最初是直接按迭代次数的前一半和后一半划分,后来也试过按适应度停滞次数来切换,比如连续10轮最优值没有改善就提前进入硬霜穿刺。实测中两种方式的差异不大,简单的前后半划分反而更稳定,代码也更清晰。
4.3 适应度评估模块
适应度评估是整套改进中最关键的部分,因为它直接决定了优化器的搜索方向。我选择自己手写DBI计算而非调用现成接口,一方面是为了减少对工具箱的依赖,另一方面是为了能灵活加入各种惩罚规则。
function dbi = cal_fitness(pos, X, dist_high) % 解码参数 eps = pos(1) * dist_high; minPts = max(2, round(pos(2))); % 运行DBSCAN idx = dbscan(X, eps, minPts); valid = idx > 0; % 防退化:噪声比例过高、簇数量异常时直接判罚 if sum(valid) < size(X,1) * 0.6 dbi = 1e6; return; end labels = unique(idx(valid)); k = length(labels); if k < 2 || k > max(2, round(sqrt(size(X,1)))) dbi = 1e6; return; end % 计算DBI: 簇内平均离散度 / 簇心距离 Xv = X(valid, :); idxv = idx(valid); S = zeros(k, 1); M = zeros(k, size(Xv,2)); for i = 1:k Ci = Xv(idxv == labels(i), :); M(i,:) = mean(Ci, 1); S(i) = mean(sqrt(sum((Ci - M(i,:)).^2, 2))); end dbi = 0; for i = 1:k worst = 0; for ii = 1:k if ii ~= i tmp = (S(i) + S(ii)) / sqrt(sum((M(i,:) - M(ii,:)).^2)); worst = max(worst, tmp); end end dbi = dbi + worst; end dbi = dbi / k; end这套DBI实现简单说就是:对每个簇,找出它和其他簇的最大相似度,然后取平均值。数值越小,说明簇内越紧凑、簇间越分散。
我这里加了两个惩罚条件,其中一个特别值得注意:噪声占比超过40%直接判罚。这是我踩坑之后补上的。最早版本只优化DBI,结果优化器为了压缩簇内离散度,把很多样本都标成了噪声,聚类结果从评价指标上看"很好",实际却完全没有可用性。
4.4 运行版本与工具箱说明
代码依赖两个能力:一个是dbscan函数,R2019a之后主版本自带;另一个是pdist和quantile,这在基础工具箱里就有。如果你用的Matlab版本较老,没有dbscan函数,需要手写一个基于区域查询的DBSCAN实现,核心就是利用KD树或距离矩阵找出每个点的eps邻域,然后沿密度可达链扩展簇。这部分逻辑不难,但需要额外花一点时间。
数据格式上只需要确认一件事:X必须是样本行、特征列的矩阵,每行是一个样本点,每列是一个维度。二维数据可以直接用gscatter可视化,高维数据建议先用主成分分析降到二维再看聚类边界。
5. 实验验证:人造数据集与公开测试集上的表现
5.1 人造数据集:验证任意形状簇的识别能力
为了验证改进效果,我先造了三组经典的人造数据:一组是两个月牙形交错的形状,一组是同心圆环,还有一组是在三个球形簇上额外撒了大量均匀噪声。这几组数据都是DBSCAN的"主场",也是K-Means这类算法最容易翻车的地方。
从结果看,人工经验参数虽然也能把月牙形数据分得不错,但对同心圆环和大噪声数据明显吃力,尤其容易把外圈圆环拆成好几段。RIME-DBSCAN在迭代到大约30轮时,搜索到的参数组合基本能一次性把完整圆环识别出来,噪声点也控制得比较好。
这里我特意关注了MinPts的作用。在同心圆环数据上,人工经验把MinPts设为4,结果内圈和外圈的边界处频繁出现断链;RIME-DBSCAN最终选出的MinPts是6,虽然只大了2,但边界断裂的情况少了很多。这个差异如果不是用优化器系统性搜索,单靠人工试参很难意识到。
5.2 在公开标准测试集上的对比结果
我还用了两个公开的标准聚类测试集做验证,一个是150个样本、4类别的典型案例,另一个是178个样本、3类别的经典数据。这两个数据集特征维度都不高,样本量不大,适合反复跑对比实验。
我对比了三种方式:人工看k距离图取参、网格搜索取参、RIME-DBSCAN取参。人工取参在多次实验中的DBI浮动最大,网格搜索能找到较好的参数但耗时巨大,RIME-DBSCAN在DBI上普遍优于人工取参,和网格搜索最优值基本持平或略好。更重要的是,RIME-DBSCAN只跑了60次迭代、每次20个个体,也就是总共1200次DBSCAN评估,而网格搜索在两个维度上各扫20个点、需要400次评估;考虑到RIME不需要设置密密麻麻的网格,这个效率是比较理想的。
5.3 收敛特性:一般多少轮能稳定
从收敛曲线来看,这类两维参数寻优问题收敛非常快。我跑了多次实验,大部分情况下在15到25轮迭代时就找到了可接受的最优区间,后面几十轮更多是在做精细调整。如果你自己复现时发现前20轮收敛很慢,大概率不是算法的问题,而是检查一下eps的搜索上界是不是被离群点拉得太大了。
6. 实操避坑:运行中这些细节决定了结果好坏
6.1 eps搜索上界的坑
我最早用的上界是max(pdist(X)),也就是所有样本对距离的最大值。这个选择看起来没问题,但数据里一旦有一两个离群点,最大距离会变得很大,导致搜索空间里绝大部分区域对应的都是"eps过大、全部归为一簇"的退化情况。优化器要在这种近乎失效的空间里找到有效区间,效率自然很低。
后来我改成90%分位数,效果立竿见影。同样的数据,前期迭代的适应度下降速度明显变快。如果你用的是高维数据,分位数建议再低一点,比如80%,因为维度增高后距离分布会更集中,高分位数的参考价值会下降。
6.2 MinPts离散化带来的台阶效应
MinPts取整后,适应度曲面会变成一个个"台阶",优化器容易在这些台阶的边缘反复震荡。我试过直接改成连续优化然后取最近整数,也试过用更大的种群规模来平滑这种离散影响,最终觉得两个措施配合使用效果最好:种群规模不低于20,同时每轮更新完强制重新取整。
如果你发现算法陷入局部最优不跳出来,可以考虑在软霜搜索阶段增大diff = Pop(k,:) - Pop(j,:)中两个随机个体的差距,也就是让随机差分在更大范围内产生新个体。我在实现里用的是randi(Npop)随机选两个个体,如果想要更强的探索性,可以改成从中位个体附近选一个、从边缘个体选一个,让差分幅度更大。
6.3 数据量大了以后运行时间完全不可控
DBSCAN本身不是为超大数据集设计的,每次评估都需要对每个点做邻域查询。Matlab自带dbscan在内部已经用了一些加速手段,但当你面对两三万条样本、上百维特征时,1200次评估依然可能跑上几个小时。
我的建议有两个。第一,寻优阶段完全可以先对数据做一次随机抽样,用抽样子集来搜索参数,找到之后再在全量数据上用同样的参数做最终聚类。抽样比例一半通常就够用,特征维度高时甚至可以取三分之一。第二,如果数据确实大,可以尝试把dbscan换成基于网格索引的近似密度聚类实现,但那是另一个话题,这里不展开。
6.4 多次运行取最优,而不是跑一次就信
霜冰优化算法本质是随机算法,每次运行的路径和最终解会略有不同。我在测试时连续跑五次,发现最优解有时会落在不同的参数组合上,但对应的DBI差异很小。为了保证结果可复现,我建议每次实验多跑几次,取适应度最好的那一组参数作为最终输出。
我个人的习惯是:同一个数据集连续跑五次,把五组最终参数分别做一次全量聚类,然后用轮廓系数和人工判读两张图综合决定用哪一组。优化器给出的是"数值最优"解,但你真正投入业务使用时,是否采纳还要结合数据背景。聚类这种事,数学模型说好的,不一定是你业务上想要的。
最后说点个人的使用体会。用霜冰优化算法改DBSCAN这件事,技术门槛其实不高,核心收益是把一个"靠感觉"的过程变成了一个"可复现、可解释"的过程。你不再需要对着k距离图像个算命先生一样找拐点,也不用深更半夜跑网格搜索。代码框架搭起来之后,换数据集只需要改数据读取和几个范围参数,整套流程在几分钟内就能给出一个相当靠谱的参数起点。这大概就是动手实践这类组合算法最大的价值所在。