ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

VAE+GMM深度聚类:原理、三阶段训练与避坑实战

VAE+GMM深度聚类:原理、三阶段训练与避坑实战

拿 VAE 配 GMM 做深度聚类,这几年我反复用过好几轮,从早期的表格数据项目一直到现在的图像与序列特征分群,这套组合的性价比一直很高。它的核心逻辑其实很朴素:用变分自编码器(VAE)把原始样本压到一个低维、连续、概率化的隐空间,再在这个隐空间里用高斯混合模型(GMM)去拟合多峰分布,把“生成建模”和“分群判别”塞进同一个目标函数里联合优化。跟直接在原始维度上跑 K-Means 相比,它抗维度灾难、能给出软划分、还能顺带生成新样本;跟纯判别式的深度聚类相比,它有明确的概率解释和完整的似然函数,训练稳得多。这篇文章适合三类人:手上有无标签数据想做结构探索的算法同学、被 K-Means 在高维上折磨过的工程同学、以及想搞清楚 VaDE、DGG 这一脉模型到底在优化什么公式的读者。我会把公式讲明白,也会把代码和踩坑过程摊开讲。

1. 从K-Means的硬伤说起:为什么非要把生成模型和聚类绑在一起

1.1 高维稀疏空间里,欧氏距离本身就不太可靠

很多人第一次做聚类,默认动作就是标准化之后直接上 K-Means,样本量几万、特征几百维,跑出来的结果往往很“均匀”——每个簇的样本数差不多,簇内却看不出任何业务含义。这不是 K-Means 实现有问题,而是它的核心假设在这里失效了。K-Means 隐式假定各簇是各向同性、方差相同、且边界是超平面的球状分布,一旦真实数据的簇是长条形、嵌套形,或者各簇的密度差异很大,它给出的划分就会严重跑偏。更麻烦的是高维情形下的距离集中现象:当维度升高,样本两两之间的距离会趋于相等,最近邻和最远邻的差距被压缩,距离这个度量本身就失去了分辨力。

我做过一个很直观的小实验,在同一个数据集上对比两种做法的轮廓系数:原始维度直接 K-Means,和先用自编码器压到 10 维再 K-Means,后者在大多数场景下能高出 0.1 到 0.2。这背后的道理并不神秘——编码器是一个非线性降维器,它把“重构信息量大”的方向保留下来,把噪声方向压掉,距离度量在低维稠密空间里重新变得有意义。但两步走也有明显问题:降维阶段完全不知道聚类的存在,它优化的是重构误差,保留下来的低维结构未必是对分群最有区分度的结构。于是引出下一个问题——能不能让降维和聚类互相看着对方调整。

1.2 VAE给聚类提供的不只是一个降维器

自编码器(AE)压出来的隐向量是确定性的一个点,VAE 压出来的是一个分布 q(z|x),通常建模成对角高斯,输出均值 μ 和对数方差 log σ²。这个差别看起来只是多了一个头,实际影响很大。第一,概率化的隐空间让“簇”可以用概率密度来描述,簇的形状可以不是球,而是任意的高斯椭球,甚至可以各自有不同的体积和朝向。第二,有了显式的先验分布,模型天然具备生成能力,你可以从某个簇的高斯里采样再解码,看看这个簇到底对应什么样的样本,这对做业务解释非常有用。第三,也是我认为最关键的一点,q(z|x) 的存在让“软分配”变得自然——一个样本属于各个簇的概率,可以严格地从后验密度算出来,而不是像 K-Means 那样硬性划到最近中心。

代价当然也有。VAE 训练里那个 KL 项是个磨人的东西,权重没调好就容易出现后验坍塌,编码器干脆忽略输入,把 q(z|x) 退化成先验,隐空间完全不带信息,聚类自然一塌糊涂。后面第 4 节会专门讲怎么处理。

1.3 GMM为什么刚好是隐空间里那个“合适的裁判”

在隐空间里做聚类,最直接的做法是 K-Means,但它只给你硬标签和球状簇。换成 GMM,你得到的是混合权重 π、每个成分的均值 μ_k 和协方差 Σ_k,一个样本属于第 k 个簇的后验概率可以按贝叶斯公式算出来。这不只是“更精细”的问题,而是参数估计的路径变了:K-Means 靠距离分配再更新中心,是一种交替优化的启发式;GMM 靠期望最大化,每一步都在抬高似然下界,理论上有单调收敛保证。

更妙的是,VAE 的 ELBO 里本来就有一项 KL(q(z|x) || p(z)),如果把先验 p(z) 从标准正态 N(0, I) 换成一个 GMM,那么“让隐分布贴近先验”这个动作,本质上就等价于“让隐向量聚到混合成分的高斯核附近”。原来那个纯正则化的 KL 项,摇身一变成了聚类驱动力。这就是 VaDE 这一脉模型最漂亮的地方:它没有外加一个聚类损失去和重构损失打架,而是把聚类直接写进了生成模型的结构里。

2. 模型骨架拆解:从ELBO推到联合训练目标

2.1 用GMM替换标准正态先验

先把记号立清楚。输入 x,隐变量 z,簇指示变量 c 取 1 到 K。生成过程是:先按 π 抽一个簇 c,再从 N(μ_c, Σ_c) 里抽 z,最后由解码器生成 x。在标准 VAE 里,先验固定为 p(z)=N(0,I),而在 GMM 先验的版本里,p(z) = Σ_k π_k N(z | μ_k, Σ_k),簇参数是可学习的。

对应的变分下界,对 q(z,c|x) 做因子化分解 q(z|x)q(c|x) 之后,可以写成三项:

  • 重构项 E[log p(x|z)],让解码器能还原样本;
  • 隐变量项 -E_{q(c|x)}[KL(q(z|x) || p(z|c))],让每个样本的隐分布贴近它所属簇的高斯核;
  • 簇先验项 -KL(q(c|x) || p(c)),让簇的分配比例不要偏离混合权重太远。

我第一次推这个式子的时候有点疑惑:为什么 KL 项是对 c 取期望的形式?理解方式是这样的——样本的簇归属本身是不确定的,q(c|x) 给出一个软分配,那么“这个样本的隐分布该贴近哪个高斯核”,就应该按这个软分配做加权平均。这样一来,梯度可以顺畅地从聚类项回传到编码器,整个模型端到端可训。

2.2 三项损失各自在管什么,权重怎么摆

落到代码里,三项损失的量纲差别很大,这是实操中最容易翻车的地方。重构项如果按像素求 MSE 再求和,量级可能是几百甚至上千;KL 项通常在个位数到几十之间;簇先验项更小。直接相加,重构项会彻底主导梯度,聚类项几乎不起作用,训练到后期隐空间还是散的。

我的处理方式分两层。第一层是对重构损失做归一化,比如按输入维度取平均而不是求和,图像数据用 BCE 按像素平均,连续特征用 MSE 按维度平均,把量级压到 1 附近。第二层是给 KL 相关项一个系数 β,这个 β 不是固定的,用 warm-up 从 0 线性升到目标值,通常在前 20% 到 30% 的迭代里完成爬坡。理由很实在:训练早期编码器还没学到有意义的表示,此时强行让隐分布去贴高斯核,只会把所有样本压到一个核里,直接触发簇坍缩;等重构稳定了再逐步加聚类约束,隐空间才有足够的结构去支撑多峰分布。

提示:如果你的数据重构难度很高(比如高分辨率图像、长序列),β 的终值不要设得太高,0.1 到 0.5 之间往往比 1.0 更稳。重构和聚类之间是存在真实张力的,强行两者都拉满,结果通常是隐空间被压扁。

2.3 从KL项到责任度的闭式更新

VaDE 系列有个很实用的性质:在给定隐向量之后,GMM 的参数(π、μ、Σ)有闭式解,不需要用梯度去学。具体做法是先算出每个样本对每个簇的责任度 γ_ik,它正比于 π_k 乘以第 k 个高斯在 z_i 处的密度,然后对所有样本做加权统计:

  • π_k = (1/N) Σ_i γ_ik
  • μ_k = Σ_i γ_ik z_i / Σ_i γ_ik
  • Σ_k 用同样的加权二阶矩减去均值平方得到

这套更新和 EM 算法的 M 步完全一致,只是把样本换成了编码器输出的隐向量。实践里我更倾向于在两套方案里选:小数据集(几万条以内)用闭式更新,稳定、快、不需要给 GMM 调学习率;大数据集必须分 batch,闭式更新会有 batch 间抖动,此时改用梯度下降学 GMM 参数,或者对闭式结果做指数滑动平均(EMA,衰减系数 0.9 到 0.99)。我一般默认用 EMA,它相当于把闭式解的方差压下来,训练曲线会平滑很多。

另外,协方差的选择值得单独说。全协方差矩阵参数是 K·D²,隐维度 D 稍大就容易过拟合,而且求逆的数值稳定性堪忧。绝大多数场景用对角协方差就够,甚至用所有簇共享一个标量方差都行。我在一个客户分群项目里试过全协方差,最终 NMI 反而比对角版本低了将近 3 个点,原因就是参数太多,簇边界被噪声带跑。

3. 实操落地:一套能直接复现的训练流程

3.1 数据预处理:别小看这一步

聚类对尺度极其敏感,这句话听起来像废话,但真正踩过坑的人才知道它有多致命。如果一个特征取值范围是 0 到 10000,另一个是 0 到 1,编码器在前几层几乎只会关注第一个特征,隐空间被它单方面决定。我的标准动作是:连续特征先做分位数截断(1% 和 99% 分位),把长尾极值压掉,再做 z-score 标准化;偏态严重的特征先取对数再标准化。类别特征要么做 one-hot(类别数少),要么做 embedding(类别数多),不要直接 LabelEncode 后当连续值喂进去,那等于人为制造了一个虚假的序关系。

缺失值也需要提前想清楚。用均值填充会把大量样本堆到分布中心,形成一个大而假的簇,这在聚类任务里比在监督任务里危害更大,因为模型会把这个假簇当成真实结构去拟合。我通常的做法是:缺失率低于 5% 的特征做填充并额外加一个缺失指示列;缺失率超过 30% 的特征直接丢弃;中间地带的特征视业务重要性决定。这个经验是从一次失败的分群里换来的——那次没加缺失指示列,结果最大的簇几乎全是“某几个字段缺失”的样本,业务方一看就摇头。

3.2 网络结构与隐空间维度

结构上不必追求复杂。表格数据两到三层全连接,每层 256 到 512 单元,激活用 ReLU 或 GELU 足够;图像数据用几层卷积加反卷积;序列数据用一维卷积或轻量 Transformer 编码器。真正需要花心思的是隐维度 D 的选择,它直接决定了 GMM 能不能把簇分开。

我的经验取值是 D 在 8 到 32 之间,常用 10。太小的隐空间会强行把不同簇重叠在一起,重构损失降不下去;太大则隐空间过于宽松,所有样本都变得“各自独立”,GMM 找不到明显的多峰结构。有个粗略的判断方法:训练完第一阶段之后,画一下隐空间前两个主成分的散点图,如果肉眼能看到分离的趋势,说明 D 选得合适;如果所有点糊成一团,先试着减小 D 或者加大 β,别急着调聚类数。

关于簇数 K,如果业务上没有先验,可以用 BIC 或者轮廓系数在预训练隐空间上做一轮扫描,K 从 2 扫到 20,看指标曲线的拐点。注意深度聚类里的 K 一旦定下就不好中途改,因为 GMM 参数是跟着 K 走的,改 K 等于重训。

3.3 三阶段训练:预训练、初始化、联合微调

这是整套流程里最影响成败的部分。一次性端到端训练几乎必然失败,标准做法是拆成三段。

第一阶段,纯 VAE 预训练。先不管聚类,只用重构损失加一个带 warm-up 的 KL 项,训练到重构损失收敛。这一步的目的是让编码器学会一个有意义、连续、光滑的隐空间。有个细节值得提:有些实现会先用纯 AE(不加 KL)预训练,再切到 VAE,理由是纯 AE 的隐空间更“舒展”,KMeans 初始化更准。我两种都试过,纯 AE 初始化在图像上确实略好一点,但切换时要注意把编码器的方差头重新初始化得小一些,否则一上来 KL 项会爆。

第二阶段,GMM 初始化。把训练集全部过一遍编码器,取 μ 作为隐向量(注意用均值而不是采样,减少随机性),在隐向量上跑一次标准 GMM 或者 K-Means,把得到的中心、协方差、权重填进 GMM 先验。这一步相当于给联合训练一个靠谱的起点,能省掉大量迭代,也能明显降低簇坍缩的概率。

第三阶段,联合微调。打开完整的三项损失,用很小的学习率(预训练学习率的十分之一到五分之一)继续训练。GMM 参数用闭式更新加 EMA,编码器和解码器用梯度下降。这一段通常只需要预训练迭代数的 10% 到 20%,训太久反而会过拟合到当前的软分配上。

3.4 核心代码实现

下面这套代码我用来做表格数据的原型验证,结构上做了简化,但关键部分都在。先定义编码器和解码器:

import math import torch import torch.nn as nn import torch.nn.functional as F class Encoder(nn.Module): def __init__(self, x_dim, h_dim, z_dim): super().__init__() self.backbone = nn.Sequential( nn.Linear(x_dim, h_dim), nn.GELU(), nn.Linear(h_dim, h_dim), nn.GELU(), ) self.head_mu = nn.Linear(h_dim, z_dim) self.head_logvar = nn.Linear(h_dim, z_dim) def forward(self, x): h = self.backbone(x) # 把 logvar 夹在合理区间,避免 exp 溢出或方差趋零 return self.head_mu(h), self.head_logvar(h).clamp(-8.0, 6.0) class Decoder(nn.Module): def __init__(self, z_dim, h_dim, x_dim): super().__init__() self.net = nn.Sequential( nn.Linear(z_dim, h_dim), nn.GELU(), nn.Linear(h_dim, h_dim), nn.GELU(), nn.Linear(h_dim, x_dim), ) def forward(self, z): return self.net(z)

GMM 先验这一块,把 π、μ、log σ² 都做成可学习参数,但实际由闭式更新去写:

class GMMPrior(nn.Module): def __init__(self, n_clusters, z_dim): super().__init__() self.log_pi = nn.Parameter(torch.zeros(n_clusters)) self.mu = nn.Parameter(torch.randn(n_clusters, z_dim) * 0.1) self.log_var = nn.Parameter(torch.zeros(n_clusters, z_dim)) @torch.no_grad() def m_step(self, z, gamma, momentum=0.9, eps=1e-4): # z: [B, D] gamma: [B, K] nk = gamma.sum(0).clamp_min(1e-3) # 每个簇的有效样本数 pi = nk / nk.sum() mu = (gamma.t() @ z) / nk[:, None] # 加权二阶矩减均值平方,得到对角方差 var = (gamma.t() @ (z ** 2)) / nk[:, None] - mu ** 2 var = var.clamp_min(eps) self.log_pi.mul_(momentum).add_((1 - momentum) * torch.log(pi), alpha=1.0) self.mu.mul_(momentum).add_((1 - momentum) * mu, alpha=1.0) self.log_var.mul_(momentum).add_((1 - momentum) * torch.log(var), alpha=1.0)

这里用了 EMA 而不是直接覆盖,momentum越大越平滑。注意log_pi用 EMA 之前不需要再归一化,因为 softmax 会自动处理。

然后是计算责任度和损失:

def log_gaussian(z, mu, log_var): # z: [B, D] mu/log_var: [K, D] -> [B, K] inv_var = torch.exp(-log_var) diff = z[:, None, :] - mu[None, :, :] return -0.5 * (diff ** 2 * inv_var[None] + log_var[None] + math.log(2 * math.pi)).sum(-1) def vade_loss(x, x_rec, mu, logvar, gmm, beta=1.0): # 1) 重构损失:按维度取平均,把量级压到 1 附近 rec = F.mse_loss(x_rec, x, reduction='none').mean(-1) # 2) 重参数化采样 std = torch.exp(0.5 * logvar) z = mu + std * torch.randn_like(std) # 3) 责任度 q(c|z),用 log-sum-exp 保持数值稳定 log_joint = log_gaussian(z, gmm.mu, gmm.log_var) + F.log_softmax(gmm.log_pi, 0)[None] log_gamma = F.log_softmax(log_joint, dim=-1) gamma = log_gamma.exp() # 4) KL(q(z|x) || p(z|c)),对 c 取期望 var = torch.exp(logvar) diff = z[:, None, :] - gmm.mu[None] kl_z = -0.5 * (1 + logvar[None] - gmm.log_var[None] - (diff ** 2 + var[:, None, :]) / torch.exp(gmm.log_var[None])) kl_z = (gamma * kl_z.sum(-1)).sum(-1) # 5) KL(q(c|x) || p(c)) kl_c = (gamma * (log_gamma - F.log_softmax(gmm.log_pi, 0)[None])).sum(-1) return (rec + beta * (kl_z + kl_c)).mean(), gamma, z

训练循环按三阶段走,KL warm-up 用一个简单的线性爬坡:

def train(model_enc, model_dec, gmm, loader, epochs_pre=50, epochs_ft=20, device='cuda'): params = list(model_enc.parameters()) + list(model_dec.parameters()) opt = torch.optim.Adam(params, lr=1e-3) for ep in range(epochs_pre): for x, in loader: x = x.to(device) mu, logvar = model_enc(x) std = torch.exp(0.5 * logvar) z = mu + std * torch.randn_like(std) x_rec = model_dec(z) rec = F.mse_loss(x_rec, x, reduction='none').mean(-1).mean() kl = (-0.5 * (1 + logvar - mu ** 2 - logvar.exp()).sum(-1)).mean() beta = min(1.0, ep / (0.3 * epochs_pre)) loss = rec + beta * kl opt.zero_grad(); loss.backward() torch.nn.utils.clip_grad_norm_(params, 5.0) opt.step() # ---- 第二阶段:在全部隐均值上初始化 GMM ---- zs = [] with torch.no_grad(): for x, in loader: mu, _ = model_enc(x.to(device)) zs.append(mu) zs = torch.cat(zs, 0) from sklearn.mixture import GaussianMixture gm = GaussianMixture(n_components=gmm.mu.shape[0], covariance_type='diag').fit(zs.cpu().numpy()) with torch.no_grad(): gmm.log_pi.copy_(torch.log(torch.tensor(gm.weights_, dtype=torch.float32, device=device) + 1e-8)) gmm.mu.copy_(torch.tensor(gm.means_, dtype=torch.float32, device=device)) gmm.log_var.copy_(torch.log(torch.tensor(gm.covariances_, dtype=torch.float32, device=device) + 1e-4)) # ---- 第三阶段:联合微调 ---- opt = torch.optim.Adam(params, lr=2e-4) for ep in range(epochs_ft): for x, in loader: x = x.to(device) mu, logvar = model_enc(x) std = torch.exp(0.5 * logvar) z = mu + std * torch.randn_like(std) x_rec = model_dec(z) loss, gamma, _ = vade_loss(x, x_rec, mu, logvar, gmm, beta=0.5) opt.zero_grad(); loss.backward() torch.nn.utils.clip_grad_norm_(params, 5.0) opt.step() gmm.m_step(z.detach(), gamma.detach(), momentum=0.9)

预测阶段直接取责任度最大的簇,同时保留完整的概率向量供后续分析:

@torch.no_grad() def predict(enc, gmm, loader): labels, probs = [], [] for x, in loader: mu, _ = enc(x.cuda()) log_joint = log_gaussian(mu, gmm.mu, gmm.log_var) + F.log_softmax(gmm.log_pi, 0)[None] p = F.softmax(log_joint, dim=-1) probs.append(p) labels.append(p.argmax(-1)) return torch.cat(labels), torch.cat(probs)

3.5 关键超参数与取值参考

调参的时候,我一般先固定网络结构,只动下面这几个。这张表是我在不同数据集上反复试出来的经验区间,可以直接当起点:

超参数经验取值影响与调整方向
隐维度 D8 ~ 32,常取 10太小簇重叠,太大簇松散;看 PCA 散点图判断
簇数 K业务先验或 BIC 扫描定下后不要中途改,改 K 等于重训
隐藏层宽度256 ~ 512数据量大可以加宽,样本少于 5000 时收窄防过拟合
预训练轮数损失的拐点后再多跑 20%预训练不足,GMM 初始化会很糟
微调轮数预训练的 10% ~ 20%训太久会过拟合当前软分配
重建损失系数1.0(本身已归一化)归一化是关键,别用求和
KL 系数 β0.1 ~ 1.0,warm-up 到终值重构难的任务取小值
batch size256 ~ 1024用闭式更新时尽量大,方差估计才稳
GMM EMA 衰减0.9 ~ 0.99抖动大就调大
方差下限1e-4防止某簇方差趋零导致数值爆炸

还有一个隐性的重要参数经常被忽略:随机种子。深度聚类的损失曲面非凸,不同种子跑出来的 ACC 能差 5 到 10 个点。我的做法是固定跑 5 个种子,用完整数据的平均 ELBO 挑最优的那个,而不是用聚类指标挑,因为用聚类指标挑等于在无标签数据上做监督选择,容易过拟合。

4. 避坑实录:从簇坍缩到后验坍塌的排查路径

4.1 簇坍缩:所有样本挤进同一个簇

这是最高频的故障,表现是训练几十轮之后,某个簇吞掉了 90% 以上的样本,其余簇只剩零星几个点。原因通常有三类。第一类是初始化太差,GMM 的初始中心全挤在隐空间的同一片区域,责任度算出来天然偏向某一个簇。第二类是 β 升得太快,编码器还没学会区分样本就被强行压向高斯核。第三类是簇先验项没起作用,q(c|x) 完全跑偏也没人管。

排查顺序我一般是这样的:先打印每个簇的有效样本数 nk,看是不是极度不均;再把 β 的 warm-up 拉长一倍重新跑;还不行的话,检查 GMM 初始化时用的是不是 μ 而不是采样值——用采样值初始化,方差大的样本会把初始中心拉偏。实在稳不住,可以临时加一个均衡正则,形如 KL(平均责任度 || 均匀分布),系数取 0.01 到 0.05,等训练稳定了再撤掉。这招有点“作弊”的意味,但在簇数明显小于真实簇数时确实管用。

注意:有些簇坍缩是数据本身的问题。如果真实数据里确实存在一个占比 80% 的主群体,模型把它单独拎出来是对的,不要一看不均衡就去改模型。判断方法是看这个大连的簇内部方差是否合理、解码出来的样本是否同质,而不是只看比例。

4.2 后验坍塌:KL 掉到接近零

后验坍塌的表现很典型:KL 项在训练早期迅速掉到 0.01 以下,重构损失看着还不错,但隐空间里所有样本挤成一团,聚类指标惨不忍睹。本质是解码器太强了,它发现直接忽略 z、靠自身的学习能力也能把重构做下来,于是编码器输出的 q(z|x) 就被优化成了先验,隐变量彻底失效。

处理手段有几个,我一般组合使用。KL warm-up 是第一道防线,把 β 从 0 缓慢升上去,给编码器争取学习时间。第二道是 free bits,给每个维度的 KL 设一个下限(比如每维 0.05 nat),低于这个值就不再产生梯度,逼着模型至少用上这么多信息量。第三道是削弱解码器,减小它的层数或宽度,或者在解码器输入上加一点 dropout。第四道是针对性的:如果你的重构目标是连续值且已经标准化,试试把重构损失从 MSE 换成高斯负对数似然,让解码器同时输出方差,这会给模型一个更合理的概率目标。

顺带提一句,后验坍塌在隐维度很小(比如 D=2 或 3)的时候反而不容易出现,因为信息瓶颈本身就很紧,编码器不得不把关键信息塞进去。所以如果你的 D 只有个位数,而 KL 还是掉到零,那问题多半不在模型结构,而在数据预处理或者学习率。

4.3 训练震荡与数值溢出

联合微调阶段如果出现损失剧烈震荡,甚至突然变成 NaN,八成是数值问题。最常见的来源是方差太小:某个簇的 σ² 在 EMA 更新中一路衰减到 1e-8 以下,算高斯密度时 exp(-log_var) 直接溢出。所以log_var一定要 clamp,下限设 1e-4 到 1e-3,别嫌它影响精度,稳定的训练比那点精度重要得多。

第二个来源是责任度计算里的 log-sum-exp。如果你直接用概率相乘再归一化,K 稍微大一点就会出现全零或者全 inf,务必在 log 空间里算,用F.log_softmax处理。第三个来源是梯度爆炸,加梯度裁剪(范数 5.0)基本能解决,如果是图像数据还要注意解码器最后一层的激活函数选择,用 sigmoid 配 BCE、不加激活配 MSE,别搞混。

还有一个容易被忽略的点:batch 内有簇空的时候。如果某个簇在当前 batch 里一个样本都没分到,nk 会趋近于 0,闭式更新算出来的均值是个 0/0。代码里clamp_min(1e-3)就是干这个用的,同时可以给空簇加一个很小的伪计数,或者干脆跳过这个簇的参数更新,让它保持上一轮的值。

4.4 评价指标怎么选,以及一个容易自欺欺人的陷阱

有标签的时候用 ACC(需要匈牙利算法做标签匹配)、NMI、ARI,这三个我一般一起报。ACC 直观但只反映最好的匹配,NMI 对簇数不敏感,ARI 对随机划分更严格。无标签的时候只能看 ELBO、重构误差和簇的均衡度,这时候指标的解释力很弱,我的建议是同时观察隐空间的二维可视化,作为定性判断的补充。

这里有个陷阱必须提醒:不要在没有验证集的情况下反复用聚类指标调超参数。聚类指标本质上是外部信息,你用它调参就等于偷偷引入了标签,报出来的数字会虚高。更靠谱的做法是留出一部分样本只看 ELBO,或者用 bootstrap 重采样看划分的稳定性——把数据重采样多次,计算不同次划分之间的 ARI,稳定性高的模型通常泛化性也更好。我做过对比,稳定性指标和真实 ACC 的相关性能到 0.7 左右,比单纯看 ELBO 可靠得多。

5. 选型对照:这套方案适合什么,不适合什么

5.1 和其他深度聚类方案的横向对比

把常见的几种放一起看,选择逻辑会清楚很多。下表是我按实际使用体验整理的对照:

方案核心机制是否需要预训练能否生成样本主要短板
原始数据 + KMeans欧氏距离交替优化否否高维失效,只能球形簇
AE + KMeans非线性降维后聚类是否降维与聚类目标脱节
DEC / IDEC自训练 + KL 锐化目标分布是否目标分布构造靠经验,无似然解释
VaDEGMM 作为隐空间先验是是依赖好的 GMM 初始化
DGG隐空间 GMM + 分 batch 闭式更新是是batch 小的时候参数估计抖
本文这套VAE + GMM 先验 + EMA 更新是是超参数较多,需要三阶段

DEC 系列的优势是收敛快、实现简单,缺点是它那个目标分布 p 是按当前软分配构造出来的,本质上是一种自洽的锐化,没有概率模型支撑,遇到簇数估计错误时会一路错下去而且毫无提示。VaDE 这一脉的优势在于有完整的似然,你能从 ELBO 的变化看出模型是不是“学到了东西”,出问题时有明确的排查方向。

5.2 什么数据量级、什么场景下值得上这套模型

我的判断标准很直接:样本量在几千到几百万、特征维度超过 50 维、或者数据结构明显是多峰的,这套方案的优势才明显。如果数据只有几百条、十几维特征,老老实实跑 GMM 或者层次聚类,效果未必差,还省掉一堆调参的功夫。如果数据是纯类别特征且维度很低,先做频繁项集或者简单的共现分析,收益可能比深度聚类更大。

在场景上,我踩过效果最好的几类:用户行为分群(特征维度高、群体结构明显)、异常检测里的正常模式归纳(GMM 的密度可以直接用来算异常分)、生物或者化学的高维测量数据分型、以及图像数据的无监督预分组。效果一般的场景是文本嵌入的聚类——不是模型不行,而是预训练句向量本身的几何结构已经被对比学习重塑过,直接在上面对角高斯拟合,簇形假设不太匹配,这时候用球形 K-Means 或者谱聚类反而更稳。

5.3 几个可以继续往下做的扩展方向

如果你已经把这套跑通了,有几个方向值得再往前推一步,我自己也在陆续试。一个是把对角协方差换成低秩分解的形式,Σ_k = W_k W_k^T + diag(d),参数量从 K·D² 降到 K·D·r,既保留了一定的形状灵活性,又不至于过拟合,在隐维度较大时收益明显。另一个是把重构目标从逐点 MSE 换成对比式的重构,让解码器不仅要还原自身,还要和同 batch 内的其他样本拉开距离,这在簇间差异细微的数据上能提升不少区分度。

还有一个我最近在琢磨的方向:把 GMM 的簇数和结构做成非参数化的,用狄利克雷过程先验替代固定的 K,让模型自己决定需要几个簇,训完之后把权重接近零的成分剪掉。理论上很优雅,实践里的坑是截断的簇数上限要设得足够大,而且剪枝之后需要重新微调一轮,否则隐空间的结构和剪枝后的簇数对不上。这条路我还没在真实项目里跑通全流程,暂时只建议作为探索方向,生产环境还是用固定 K 稳当。

顺带记一个很实用的小技巧:训完之后别急着交付簇标签,先对每个簇做一次“解码重构探针”——从该簇的高斯里采若干个 z,解码成样本,看看它们长什么样。这一步经常能发现模型把两个不相干的群体合并了,或者某个簇其实是缺失值堆出来的。可视化加上这个探针,比看任何指标都更能帮你判断结果能不能用。

返回列表