基于VAEs与GMM的深度聚类:原理、实现与调参实践
三年前我接手一个没有标签的样本分组任务,手里八万条记录,业务方只丢过来一句话——“你看看能分成几类”。K-Means 跑完,轮廓系数惨不忍睹;换谱聚类,内存直接爆掉。后来把变分自编码器(VAEs)和高斯混合模型(GMM)拼到一起做深度聚类,这件事才算真正推进下去。这套组合的核心思路很朴素:让编码器学出一个“自带簇结构”的潜在空间,再让 GMM 在这个空间里当“裁判”。今天就把这套基于VAEs和GMM的深度聚类模型从动机、数学、代码到踩坑,完整拆一遍。
这套东西适合两类人看。一类是手上有一堆没标签的数据,被要求“先分个群看看”的工程同学,你需要一个能跑起来、指标能交代的方案;另一类是想搞明白生成式聚类到底怎么推导、损失函数那三项分别管什么的人,代码抄完至少得知道每一行在干什么。全文会给出可复现的 PyTorch 实现,也会讲清楚每个超参数背后的取舍,尽量做到看完就能改吧改吧用到自己的数据上。
1. 这套模型到底在解决什么问题
1.1 K-Means 在高维数据上的三个死穴
先说清楚为什么非得绕这么大一圈。K-Means 本身没毛病,问题出在它假设的三件事上,而真实数据往往一条都不满足。
第一是硬分配。每个样本只能属于一个簇,归属概率非 0 即 1。这在簇边界清晰的时候没问题,可现实里大量样本卡在两个簇中间,硬切一刀必然错。第二是球形簇假设。K-Means 本质上是在最小化簇内到中心的欧氏距离平方和,等价于假设每个簇都是各向同性的正球体。数据里那种细长条、斜着的椭球簇,它切不准。第三是距离度量在高维失效。图像像素空间里两张图的欧氏距离,和它们在语义上像不像,基本没什么关系。维度一上去,所有点对之间的距离都趋于相等,这就是所谓的距离集中现象。
一个直观判断:如果你的数据维度超过 50,并且特征之间高度相关(比如图像、文本嵌入),先别急着上 K-Means,先把降维或者表示学习做掉。
这三条合起来,结论就是:直接在原始空间聚类,天花板很低。真正该做的是先把数据映射到一个低维、语义化的空间,在那个空间里再做聚类。这就引出了表示学习和聚类要不要联合优化的问题。
1.2 VAE 的潜在空间为什么天然不够“聚类友好”
很多人第一反应是:那我先训个 VAE,把编码器的输出拿出来跑 K-Means 不就行了。这个两阶段方案我试过,能跑,但效果普遍比联合训练差一截,原因藏在 VAE 的目标函数里。
标准 VAE 的 ELBO 是“重构项 + KL(q(z|x) || N(0,I))”。那个 KL 项的作用是把整个聚合后验往标准正态上压。标准正态是单峰的、以原点为中心的、各向同性的。这意味着模型被明确要求:所有类别的编码都往原点附近挤,形成一个连续的整体,中间没有“沟”。类别之间的边界被这个先验主动抹平了。
更麻烦的是聚合后验和先验的失配问题。理论上 KL 项最小化的是每个样本的后验和先验的距离,但实际训练中,聚合起来的总后验 q(z) = ∫q(z|x)p(x)dx 往往和 N(0,I) 对不上——中间区域被填满,边缘区域空着,这就是常说的“先验空洞”。在这种空间里跑 K-Means,等于在一块被压平的橡皮泥上找山峰,找出来的东西没什么意义。
我做过一个对照实验:同一个数据集,普通 VAE 的 z 上跑 K-Means,和联合训练的版本比,NMI 差了将近 0.2。这个差距不是调参能补回来的,是目标函数决定的。
1.3 GMM 补上的那块拼图
既然问题出在“先验是单峰”,那把先验换掉就是最直接的解法。高斯混合模型天生就是多峰的:它假设数据由 K 个高斯分量混合生成,每个分量有自己的均值 μ_k、方差 σ_k² 和混合权重 π_k。一个分量对应一个簇,这在结构上就和聚类的目标对齐了。
更重要的是 GMM 给的是软分配。给定一个潜在向量 z,它属于第 k 个簇的后验概率 γ_k 可以直接用贝叶斯公式算出来,是个 0 到 1 之间的连续值。这个 γ 有两个用处:一是作为最终的聚类结果(取 argmax),二是作为权重反过来指导编码器调整 z 的位置。后者才是关键——聚类目标能反传到表示学习里去。
还有一个容易被忽略的好处:对角协方差。我们通常假设每个分量的协方差矩阵是对角的,也就是各个维度独立。这样每个簇在潜在空间里就是一个轴对齐的椭球,长宽可以不一样。这比 K-Means 的球体灵活多了,而且参数量只增加了一倍(K×D 个方差),计算上完全可接受。
2. 整体设计思路拆解
2.1 生成过程怎么定义:把联合分布拆开
这套模型本质是一个生成模型,所以第一步是把数据的生成过程用概率图语言写清楚。我采用的是引入离散潜变量 c 的版本,生成过程分三步:
- 先抽一个簇标签 c ~ Categorical(π),π 是 K 维的混合权重向量
- 给定 c,从对应的那个高斯里抽潜在向量 z ~ N(μ_c, σ_c²)
- 给定 z,通过解码器生成观测 x ~ p_θ(x|z)
对应的联合分布就是 p(x, z, c) = p(c) · p(z|c) · p(x|z)。这个拆法和标准 VAE 唯一的区别,就是把 p(z) = N(0,I) 换成了 p(z|c)p(c),也就是一个混合高斯。
这里有个设计选择值得说一下:为什么是“先有 c 再有 z”,而不是“先有 z 再推 c”。前者是生成式的写法,c 是真正的隐变量;后者是判别式的写法,编码器直接输出 c 的分布。两种流派都有人做,前者数学上更自洽,能算边际似然 p(x),后者训练更直接但容易退化成普通的分类器。我选前者。
2.2 先验换血:从单峰到多峰意味着什么
把先验从 N(0,I) 换成 GMM,表面上只是换了个分布,实际上改变了整个优化的“地形”。
在标准 VAE 里,KL 项是一个很强的正则,它把每个样本的后验往原点拉。换成 GMM 之后,这个拉力变成了“往最近的簇中心拉”,而且这个“最近的簇中心”是随训练动态变化的。模型因此获得了自由:它可以把不同类别的编码推到不同的区域,中间留出空隙,只要每个区域内部的分布形状接近对应的高斯分量就行。
代价是训练难度上来了。标准 VAE 的先验是固定的,KL 有闭式解,梯度稳定。GMM 先验的参数(μ_k, σ_k², π_k)本身也是要学的,它们和编码器、解码器一起被优化,形成了一个鸡生蛋蛋生鸡的局面:编码器希望 z 靠近簇中心,簇中心又是由 z 的分布决定的。这种耦合容易导致训练早期的不稳定,后面第 5 节会专门讲怎么处理。
2.3 三项损失各管什么:拆开 ELBO
从 -ELBO 推下来,最终的损失函数是三项相加,每一项都有明确的分工,理解这一点比记住公式重要得多。
第一项是重构损失。给定 x,编码出 z,再解码回 x̂,最小化两者差距。这一项负责保证潜在空间没有丢掉重构所需的信息。没有它,模型可以随便把 z 映射到 K 个点上,聚类指标好看但表示没意义。
第二项是潜在空间的 KL,形式是 E_γ[KL(q(z|x) || p(z|c))]。注意这里是对所有簇加权平均的:一个样本的 z 离哪个簇的分布近,那个簇的权重就大。这一项负责把 z 往“合适的高斯分量”里推,同时控制分量的方差不要塌缩。
第三项是簇分配的 KL,形式是 KL(q(c|x) || p(c))。它约束的是软分配 γ 和先验 π 之间的差异。这一项的存在是为了防止模型把绝大部分样本都塞给少数几个簇,起到一种“簇大小别太离谱”的平衡作用。
三项的权重关系直接决定了模型的行为。重构权重太大,潜在空间只关心重构,簇结构出不来;KL 权重太大,z 被压得太紧,重构崩掉。我的经验是重构项保持 1.0,另外两项按 1:1 起步,然后看训练曲线微调。
2.4 端到端训练比两阶段强在哪
两阶段方案(先训 VAE,再在 z 上跑 GMM)最大的问题是目标不一致。VAE 的编码器优化的是重构,它没有动力让不同类别的 z 分开。你在它产出的空间里做 GMM,等于在一个没为聚类设计过的空间里硬找结构。
联合训练把这个链条打通了:GMM 的似然会反传到编码器,编码器会主动把样本推到“更符合自己所属分量”的位置,簇之间的空隙因此被撑开。这个自我强化的过程,是两阶段方案拿不到的。
不过联合训练也有代价:它对初始化极其敏感。如果一上来就随机初始化 GMM 参数,编码器还什么都没学会,z 是一团乱麻,GMM 拟合出来的分量毫无意义,后面就再也救不回来了。所以实操中必须分阶段——先用自编码器把空间学出来,再用 K-Means 给 GMM 一个靠谱的起点,最后才联合微调。第 4 节会给出完整的三阶段流程。
3. 核心数学细节与参数计算
3.1 重参数化技巧与 ELBO 的落地写法
VAE 能训练的前提是重参数化。我们要从 N(μ, σ²) 采样,但采样这个操作本身不可导,梯度传不回去。解决办法是把随机性剥离出来:z = μ + σ ⊙ ε,其中 ε ~ N(0,I)。这样 μ 和 σ 就变成了普通的确定性函数,梯度可以从 z 一路传回编码器。
代码上,编码器输出的是 μ 和 log σ² 而不是 σ²,这是工程上的标准做法。原因很实在:σ² 必须为正,直接回归正数容易越界;而 log σ² 可以是任意实数,网络想输出什么输出什么,用的时候 exp 一下就行。同时 log 域上的数值范围更友好,不容易溢出。
ELBO 的完整形式是:
ELBO = E_q(z,c|x)[log p(x|z)] - E_q(c|x)[KL(q(z|x) || p(z|c))] - KL(q(c|x) || p(c))最大化 ELBO 等价于最小化它的负值,也就得到了 2.3 节说的三项损失。第一项的重构,如果输入是归一化到 [0,1] 的连续值,用 MSE 就行;如果是二值像素,用 BCE 更合适。这里没有绝对的对错,看数据类型。
3.2 软分配 γ 怎么推出来:贝叶斯那一步
这是整套模型里最容易写错的地方,值得单独拎出来讲。
我们想要的是 q(c|x),也就是给定观测 x,它属于第 k 个簇的概率。但直接算这个积分很难。工程上的做法是走两步近似:
先算 q(c|z)。用贝叶斯公式:
q(c=k|z) = p(c=k) · p(z|c=k) / Σ_j p(c=j) · p(z|c=j)其中 p(c=k) 就是 π_k,p(z|c=k) 是第 k 个高斯在 z 处的密度。因为我们在对数域计算,实际是先算每个簇的对数似然:
log p(z|c=k) = -0.5 · Σ_d [ (z_d - μ_kd)² / σ_kd² + log σ_kd² + log(2π) ]然后加上 log π_k,做 softmax 归一化,得到 γ。
再近似 q(c|x) ≈ q(c|z),其中 z 是从 q(z|x) 采样的一个样本。严格来说应该对 q(z|x) 求期望,但实践中单样本估计就够了,多了反而增加方差。
注意那个 log(2π) 常数项可以省掉,因为 softmax 对每个簇加上同一个常数不改变结果。同理,后面算 KL 的时候,那些不依赖于参数的常数也能省。
3.3 GMM 参数怎么更新:梯度还是滑动平均
这是一个在论文和代码之间经常出现分歧的点。理论上 μ_k、σ_k²、π_k 都是可学习参数,直接挂到优化器上让梯度更新就行。实践中这样做的坑不少:
μ_k 用梯度更新容易崩。因为 μ_k 的梯度量级和编码器输出的 μ_z 是耦合的,学习率设成一样,要么 μ_k 动得太慢跟不上,要么动得太快把编码器带崩。我一般给 GMM 参数单独设一个更小的学习率,比如主网络的 1/10。
另一种做法是滑动平均。每个 batch 算完之后,用当前的软分配 γ 加权统计出新的 μ_k 和 σ_k²,然后按 EMA(指数滑动平均)的方式缓慢更新。这样 GMM 参数的变化非常平滑,训练稳定性明显提升。代价是收敛慢一些,而且需要额外维护一份统计量。
两种我都用过,说下取舍:数据量小、簇结构清晰的场景,EMA 更省心;数据量大、追求最终指标的场景,梯度更新配上单独的学习率上限更高,但要盯着 loss 曲线。
3.4 几个关键超参数的量级参考
参数选错了,代码写得再对也跑不出结果。下面这张表是我在几个不同规模的数据集上总结出来的经验区间。
| 参数 | 常用取值 | 调大/调小的影响 |
|---|---|---|
| 潜在维度 D | 10 / 16 / 32 | 太小信息不够重构差,太大会让簇结构变散,建议先按簇数 K 的 2-4 倍试 |
| 隐藏层宽度 | 256 / 512 / 1024 | 数据维度高的往上加,但超过 1024 收益递减明显 |
| 簇数 K | 由业务定,或用轮廓系数扫 | K 选大了会分裂真实簇,选小了会合并 |
| 预训练轮数 | 100-300 | 少了 GMM 初始化不准,多了编码器过拟合重构 |
| 微调轮数 | 50-200 | 太多会过拟合到伪标签上 |
| 微调学习率 | 预训练的 1/10 | 太大直接崩,太小收敛慢 |
| 重构权重 | 1.0 | 调到 10 以上会让聚类项失效 |
| 方差下限 | 1e-6 | 防止 σ² 塌到 0 导致除零 |
潜在维度这个参数值得多说两句。很多人习惯直接设成 2,方便可视化。但 2 维空间的信息承载能力有限,重构会明显变差,聚类指标也会掉。我的建议是:训练时用 10 到 32 维,训完之后如果需要画图,再单独跑一个 t-SNE 或者 UMAP 降到 2 维。不要为了可视化牺牲模型能力。
4. 实操:从零搭一个可跑的版本
4.1 数据准备与预处理的三条硬规矩
代码开跑之前,数据这块有三条规矩,违反了后面全是玄学问题。
第一,输入必须归一化到 [0,1] 或标准化到零均值单位方差。因为解码器最后一层通常接 sigmoid,输出范围是 [0,1],输入不匹配的话重构损失从第一步就很大,梯度乱飞。我一般用 MinMax 归一化,比标准化的重构效果稳一些。
第二,特征维度不要超过几千。全连接编码器参数量是 O(D_in × H),D_in 上万的时候一层就是几千万参数,小数据集直接过拟合。维度太高先做 PCA 降到 500 维以内,或者换成卷积/Transformer 编码器。
第三,训练集要做成 DataLoader,batch size 建议 256 起步。太小的话 GMM 的软分配统计噪声大,γ 抖得厉害;太大又会让每个 epoch 的更新次数变少,联合微调阶段收敛不充分。
数据加载的骨架大概是这样:
import torch from torch.utils.data import TensorDataset, DataLoader from sklearn.preprocessing import MinMaxScaler def build_loader(X, batch_size=256, shuffle=True): scaler = MinMaxScaler() X_scaled = scaler.fit_transform(X).astype("float32") tensor = torch.from_numpy(X_scaled) ds = TensorDataset(tensor) return DataLoader(ds, batch_size=batch_size, shuffle=shuffle), scaler4.2 编码器与解码器的结构选择
结构本身没什么花哨的,全连接堆几层就够。有两点值得注意:编码器输出两个头(μ 和 log σ²),这两个头不要共享最后一层,各接一个独立的线性层效果更好;解码器最后接 sigmoid,和归一化后的输入对齐。
import torch import torch.nn as nn import torch.nn.functional as F class Encoder(nn.Module): def __init__(self, in_dim, hidden, latent): super().__init__() self.backbone = nn.Sequential( nn.Linear(in_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), ) self.head_mu = nn.Linear(hidden, latent) self.head_logvar = nn.Linear(hidden, latent) def forward(self, x): h = self.backbone(x) return self.head_mu(h), self.head_logvar(h).clamp(-10, 10) class Decoder(nn.Module): def __init__(self, latent, hidden, out_dim): super().__init__() self.net = nn.Sequential( nn.Linear(latent, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, out_dim), nn.Sigmoid(), ) def forward(self, z): return self.net(z)logvar上加clamp(-10, 10)是一个很实用的小动作。不加的话,训练中期偶尔会冒出 -30 这种值,exp 之后是 1e-13,除一下就上溢成 inf,一个 batch 的 loss 变 NaN,整个训练白跑。这个 clamp 不会影响正常范围,纯粹是保险。
4.3 GMM 先验模块:把 μ_k、σ_k²、π_k 装进去
GMM 这一块用一个独立的 Module 封装,参数用nn.Parameter注册,这样能统一交给优化器管理,也方便单独设学习率。
class GMMPrior(nn.Module): def __init__(self, n_clusters, latent_dim): super().__init__() self.K = n_clusters self.D = latent_dim self.mu_c = nn.Parameter(torch.randn(n_clusters, latent_dim)) self.log_sigma2_c = nn.Parameter(torch.zeros(n_clusters, latent_dim)) self.pi_logit = nn.Parameter(torch.zeros(n_clusters)) @property def sigma2_c(self): return torch.exp(self.log_sigma2_c).clamp(min=1e-6) @property def pi(self): return F.softmax(self.pi_logit, dim=0) def log_prob(self, z): # z: [B, D] -> [B, K] s2 = self.sigma2_c # [K, D] diff = z.unsqueeze(1) - self.mu_c.unsqueeze(0) # [B, K, D] quad = (diff.pow(2) / s2.unsqueeze(0)).sum(-1) # [B, K] logdet = -0.5 * torch.log(s2).sum(-1).unsqueeze(0) return logdet - 0.5 * quad def soft_assign(self, z): logits = self.log_prob(z) + torch.log(self.pi + 1e-10).unsqueeze(0) return F.softmax(logits, dim=1)这里的sigma2_c用了clamp(min=1e-6),同样是为了防除零。实践中如果某个簇的方差一直贴在 1e-6 上,说明这个簇基本没人用,是塌缩的前兆,看第 5 节。
4.4 三项损失的完整实现
把前面推导的公式逐项翻译成代码。要注意 KL 那一项的常数项都能省,但减 1 那一项(对应维度 D)不能省,它是方差比值的归一化基准。
def vade_loss(x, x_hat, mu_z, logvar_z, gamma, prior, recon_w=1.0): B, D = mu_z.shape # 1. 重构 recon = F.mse_loss(x_hat, x, reduction="sum") / B # 2. KL(q(z|x) || p(z|c)),按 gamma 软加权 s2 = prior.sigma2_c # [K, D] var_z = torch.exp(logvar_z) # [B, D] term_logdet = torch.log(s2).sum(-1).unsqueeze(0) # [1, K] diff = mu_z.unsqueeze(1) - prior.mu_c.unsqueeze(0) # [B, K, D] term_quad = (diff.pow(2) / s2.unsqueeze(0)).sum(-1) # [B, K] term_var = (var_z.unsqueeze(1) / s2.unsqueeze(0)).sum(-1) # [B, K] kl_z = 0.5 * (term_logdet + term_quad + term_var - logvar_z.sum(-1, keepdim=True) - D) loss_z = (gamma * kl_z).sum(1).mean() # 3. KL(q(c|x) || p(c)) log_gamma = torch.log(gamma + 1e-10) log_pi = torch.log(prior.pi + 1e-10) loss_c = (gamma * (log_gamma - log_pi.unsqueeze(0))).sum(1).mean() return recon_w * recon + loss_z + loss_c光看代码可能还是有点晕,这里拆一下kl_z那四段拼起来对应的是什么:term_logdet是 log σ_c² 的求和,term_quad是 (μ_z - μ_c)²/σ_c² 的求和,term_var是 σ_z²/σ_c² 的求和,最后的- logvar_z.sum()和- D补上 log σ_z² 和常数 1。合起来正好是标准的两高斯 KL 公式在 D 维上的展开。
4.5 三阶段训练流程:为什么不能一把梭
前面提过联合训练对初始化极其敏感。我摸索出来的流程是三段:
阶段一,纯自编码器预训练。注意,这个阶段我不加强 KL 项,就是普通的 AE,只最小化重构损失。原因很实在:加了 KL 之后潜在空间会被压向原点,所有样本的 z 挤成一小团,后面 K-Means 根本分不开。纯 AE 至少能保证 z 在空间里铺得比较开。
def pretrain(enc, dec, loader, epochs=150, lr=1e-3, device="cpu"): params = list(enc.parameters()) + list(dec.parameters()) opt = torch.optim.Adam(params, lr=lr) enc.to(device); dec.to(device) for ep in range(epochs): total = 0.0 for (x,) in loader: x = x.to(device) mu, logvar = enc(x) z = mu + torch.exp(0.5 * logvar) * torch.randn_like(mu) x_hat = dec(z) loss = F.mse_loss(x_hat, x) opt.zero_grad(); loss.backward(); opt.step() total += loss.item() * x.size(0) if (ep + 1) % 30 == 0: print(f"[pretrain] ep {ep+1:3d} recon {total/len(loader.dataset):.5f}") return enc, dec阶段二,K-Means 初始化 GMM。把训练集的 μ 全部抽出来,跑一次 K-Means,簇心直接赋给mu_c;log_sigma2_c用全局方差的 log 初始化;pi_logit用各簇的样本占比取 log 后初始化。这一步能把 GMM 拉到“基本合理”的位置,省掉后面几十个 epoch 的瞎撞。
from sklearn.cluster import KMeans import numpy as np @torch.no_grad() def init_gmm_from_kmeans(enc, prior, loader, device="cpu"): enc.eval() Z = [] for (x,) in loader: mu, _ = enc(x.to(device)) Z.append(mu.cpu()) Z = torch.cat(Z).numpy() km = KMeans(n_clusters=prior.K, n_init=10, random_state=0).fit(Z) centers = torch.from_numpy(km.cluster_centers_).float() prior.mu_c.data.copy_(centers) global_var = Z.var(axis=0) global_var = np.clip(global_var, 1e-6, None) prior.log_sigma2_c.data.copy_( torch.from_numpy(np.tile(np.log(global_var), (prior.K, 1))).float() ) counts = np.bincount(km.labels_, minlength=prior.K).astype(np.float64) ratio = torch.from_numpy(counts / counts.sum()).float() prior.pi_logit.data.copy_(torch.log(ratio + 1e-8)) return km.labels_阶段三,联合微调。三个模块一起训,但学习率分开:编码器解码器用 1e-4,GMM 参数用 1e-5。这个比例我试过很多次,1:10 是相对稳的区间。
def finetune(enc, dec, prior, loader, epochs=100, lr=1e-4, device="cpu"): opt = torch.optim.Adam([ {"params": list(enc.parameters()) + list(dec.parameters()), "lr": lr}, {"params": prior.parameters(), "lr": lr * 0.1}, ]) for ep in range(epochs): enc.train(); dec.train(); prior.train() stat = dict(recon=0.0, acc=0.0) for (x,) in loader: x = x.to(device) mu, logvar = enc(x) z = mu + torch.exp(0.5 * logvar) * torch.randn_like(mu) x_hat = dec(z) gamma = prior.soft_assign(z) loss = vade_loss(x, x_hat, mu, logvar, gamma, prior, recon_w=1.0) opt.zero_grad(); loss.backward() torch.nn.utils.clip_grad_norm_( list(enc.parameters()) + list(dec.parameters()), 5.0) opt.step() stat["recon"] += F.mse_loss(x_hat, x).item() * x.size(0) if (ep + 1) % 10 == 0: n = len(loader.dataset) print(f"[finetune] ep {ep+1:3d} recon {stat['recon']/n:.5f}") return enc, dec, prior梯度裁剪那行不要省。联合训练阶段,重构损失和聚类损失的量级偶尔会突然拉大,不加裁剪很容易一步就把编码器推到一个不可恢复的区域。
4.6 评估指标:ACC、NMI、ARI 各看什么
聚类评估这块,三个指标各有侧重,别只盯一个。
ACC(聚类准确率)是最直观的,但它需要做一次标签匹配。因为聚类给出的编号和真实标签的编号是任意的,得用匈牙利算法找最优的一一对应。这个匹配过程用scipy.optimize.linear_sum_assignment一行就能搞定。
NMI(归一化互信息)衡量的是两个划分之间的共享信息量,对簇的数量不敏感,适合对比不同 K 值的结果。
ARI(调整兰德指数)衡量的是样本对级别的一致性,即“在原划分里同簇/不同簇,在新划分里是否也同簇/不同簇”,并且对随机划分做了校正,随机结果的期望是 0。
from scipy.optimize import linear_sum_assignment import numpy as np def cluster_acc(y_true, y_pred): y_true = y_true.astype(np.int64) y_pred = y_pred.astype(np.int64) D = max(y_pred.max(), y_true.max()) + 1 w = np.zeros((D, D), dtype=np.int64) for i in range(y_pred.size): w[y_pred[i], y_true[i]] += 1 row, col = linear_sum_assignment(w.max() - w) return w[row, col].sum() / y_pred.size一个容易被忽略的细节:评估时到底用 μ 还是用采样出来的 z 去做
argmax?我的做法是用 μ。因为采样引入了随机噪声,同一个样本两次前向可能分到不同的簇,指标会抖动。μ 是确定性的,更能反映模型学到了什么。
可视化我一般画两张图:一张是 z 用 UMAP 降到 2 维后按预测簇着色,看簇是不是分开的;另一张是按真实标签着色,两张并排看,能一眼看出是“没分开”还是“分开了但分错了”。前者是表示的问题,后者是 GMM 的问题,排查方向完全不同。
5. 常见问题与排查实录
5.1 所有样本塌到一个簇
这是这套模型最常见、也最让人头大的问题。表现是训练到某个 epoch,gamma的某一行突然变成接近 one-hot,然后所有样本都往那一行跑,其他簇的sigma2_c一路掉到下限,彻底没人用了。
根因在第三项损失。KL(q(c|x) || p(c)) 这个方向是不对称的,它会惩罚“q 有质量而 p 没有”的情况。如果某个簇的先验 π_k 被学得很小,那么把样本分给它的代价就很高,模型于是更倾向于把样本分给 π 大的簇——这是一个正反馈,越分越集中。
三个缓解手段,按有效性排序:
第一,在训练早期把pi_logit冻住。用 K-Means 初始化出来的比例,前 30 个 epoch 不让它更新。这一招最直接,能挡住绝大部分塌缩。
第二,给 π 加一个向均匀分布靠拢的正则。形式上就是在损失里加一个小系数的 KL(π || Uniform(1/K)),系数 0.01 到 0.1 之间试。等价于告诉模型“别把宝全押在一个簇上”。
第三,监控最小簇占比。每个 epoch 统计一下预测标签的直方图,如果某个簇的占比低于 1/K 的十分之一,就说明已经开始塌了,赶紧回滚 checkpoint 调参。这个监控比看 loss 有用得多,因为塌缩的时候 loss 往往是下降的,曲线看起来很健康。
5.2 重构损失降得很好,但聚类指标不动
这个现象我遇到过好几次,本质上说明潜在空间学到了重构需要的信息,但没有学到区分性信息。
先检查预训练是不是训过头了。纯 AE 训太久,编码器会把所有容量都用来记住训练样本的细节,z 里塞满了和聚类无关的高频信息。把预训练轮数砍掉三分之一再试。
再检查重构权重是不是太大。如果重构项量级远大于两个 KL 项,梯度几乎全被重构占据,GMM 的信号根本传不进编码器。打印一下三项 loss 的实际数值,正常情况下重构项应该是最大的,但不应该大出两个数量级。
还有一个隐蔽的原因:输入特征里混进了强噪声维度。比如某些特征全是常数,或者方差极大但和目标完全无关。AE 会花力气去重构这些维度,z 被污染。开训之前跑一遍方差过滤,把方差接近 0 的列删掉,往往有奇效。
5.3 训练中途 NaN 与数值不稳定
NaN 基本都出在除法和 exp 上。按出现频率排一下:
sigma2_c塌到 0,diff.pow(2) / s2直接 inf。这个用clamp(min=1e-6)就挡住了。
logvar_z掉到 -30 以下,exp之后虽然不溢出,但1/s2的反向梯度会爆炸。这个用输出端的clamp(-10, 10)挡住。
gamma某一项恰好是 0,torch.log(gamma)变 -inf。所有log前面都要加+1e-10。
loss_c在pi某项接近 0 的时候爆炸。同样加 eps,并且pi_logit可以加个clamp(-10, 10)。
还有一个不是 NaN 但同样致命的问题:损失值突然跳变。这通常是某个 batch 里出现了离群样本,编码出的 z 离所有簇中心都极远,KL 项瞬间拉满。解决办法是开梯度裁剪(前面代码里已经加了clip_grad_norm_5.0),或者对输入做一次离群点剔除。
5.4 问题速查表
把上面这些整理成一张表,出问题的时候按症状查。
| 症状 | 最可能的原因 | 处理动作 |
|---|---|---|
| 全部样本进一个簇 | π 学偏 + 正反馈 | 冻结 pi_logit 前 30 epoch,加 π 均匀正则 |
| 某项 sigma2 贴死在下限 | 该簇无人使用 | 检查初始化,减少 K 值重跑 |
| 重构好但 ACC 低 | 预训练过久或重构权重过大 | 砍预训练轮数,降重构权重 |
| loss 变 NaN | 除法/exp 上溢 | 加 clamp,log 加 eps |
| 指标剧烈抖动 | 用采样 z 做评估 | 改用 μ 做 argmax |
| 簇大小极不均衡 | 方差被学得太小,退化成硬分配 | 给 log_sigma2_c 加下限正则 |
| 收敛后指标不如两阶段 | 联合微调把空间带偏了 | 缩短微调轮数,或只微调最后几层 |
6. 适用边界与后续怎么扩展
这套模型不是万能的,说清楚它不适合什么比吹它能做什么更有价值。
样本量太小的时候别用。几千条以下的数据,参数化的 GMM 先验很难学准,反而不如直接用谱聚类或者密度聚类。我一般建议至少上万条样本再考虑深度聚类。
簇的形状高度不规则的时候效果打折。对角协方差只能表达轴对齐的椭球,如果真实簇是斜的、弯曲的、或者套嵌的,这套建模假设就不对。可以换成全协方差矩阵(参数量 K×D×D,D 大了扛不住),或者干脆换基于密度的深度聚类方法。
类别极度不均衡的时候要小心。π 本来可以学出不均衡的比例,但早期塌缩的风险会显著上升,前面说的冻结策略必须开。
如果想继续往下做,有几个方向我觉得性价比比较高。
一个是把对角协方差换成低秩分解。σ 矩阵用 U·diag(d)·U^T 表示,U 是 D×r 的正交阵,r 取 2 到 4。这样能表达方向性,参数量只增加 r×D,比全协方差省得多,在处理“斜着”的簇时效果好不少。
另一个是给解码器加一个时间或者结构的先验。比如序列数据,可以让解码器输出一个序列分布而不是各维度独立的高斯;图数据,可以把邻接关系塞进重构损失里。这套基于 VAEs 和 GMM 的深度聚类的框架是很松的,编码器和解码器换成什么都行,只要中间那个 GMM 先验在,整体的推导就成立。
最后分享一个我踩过好几次的小坑:不要在验证集上挑 epoch。无监督场景下你没有可靠的验证信号,重构损失和聚类指标经常不同步,靠着验证 loss 挑出来的那个 checkpoint,聚类指标往往不是最好的。我现在是固定训练轮数,然后把最近 20 个 epoch 的模型权重和对应的聚类指标全部存下来,最后人工挑一个——听起来很土,但比任何自动策略都靠谱。