三年前我接手一个没有标签的样本分组任务手里八万条记录业务方只丢过来一句话——“你看看能分成几类”。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(ck|z) p(ck) · p(z|ck) / Σ_j p(cj) · p(z|cj)其中 p(ck) 就是 π_kp(z|ck) 是第 k 个高斯在 z 处的密度。因为我们在对数域计算实际是先算每个簇的对数似然log p(z|ck) -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 几个关键超参数的量级参考参数选错了代码写得再对也跑不出结果。下面这张表是我在几个不同规模的数据集上总结出来的经验区间。参数常用取值调大/调小的影响潜在维度 D10 / 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 编码器。第三训练集要做成 DataLoaderbatch size 建议 256 起步。太小的话 GMM 的软分配统计噪声大γ 抖得厉害太大又会让每个 epoch 的更新次数变少联合微调阶段收敛不充分。数据加载的骨架大概是这样import torch from torch.utils.data import TensorDataset, DataLoader from sklearn.preprocessing import MinMaxScaler def build_loader(X, batch_size256, shuffleTrue): scaler MinMaxScaler() X_scaled scaler.fit_transform(X).astype(float32) tensor torch.from_numpy(X_scaled) ds TensorDataset(tensor) return DataLoader(ds, batch_sizebatch_size, shuffleshuffle), 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(min1e-6) property def pi(self): return F.softmax(self.pi_logit, dim0) 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, dim1)这里的sigma2_c用了clamp(min1e-6)同样是为了防除零。实践中如果某个簇的方差一直贴在 1e-6 上说明这个簇基本没人用是塌缩的前兆看第 5 节。4.4 三项损失的完整实现把前面推导的公式逐项翻译成代码。要注意 KL 那一项的常数项都能省但减 1 那一项对应维度 D不能省它是方差比值的归一化基准。def vade_loss(x, x_hat, mu_z, logvar_z, gamma, prior, recon_w1.0): B, D mu_z.shape # 1. 重构 recon F.mse_loss(x_hat, x, reductionsum) / 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, keepdimTrue) - 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, epochs150, lr1e-3, devicecpu): params list(enc.parameters()) list(dec.parameters()) opt torch.optim.Adam(params, lrlr) 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 {ep1:3d} recon {total/len(loader.dataset):.5f}) return enc, dec阶段二K-Means 初始化 GMM。把训练集的 μ 全部抽出来跑一次 K-Means簇心直接赋给mu_clog_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, devicecpu): enc.eval() Z [] for (x,) in loader: mu, _ enc(x.to(device)) Z.append(mu.cpu()) Z torch.cat(Z).numpy() km KMeans(n_clustersprior.K, n_init10, random_state0).fit(Z) centers torch.from_numpy(km.cluster_centers_).float() prior.mu_c.data.copy_(centers) global_var Z.var(axis0) 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_, minlengthprior.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-4GMM 参数用 1e-5。这个比例我试过很多次1:10 是相对稳的区间。def finetune(enc, dec, prior, loader, epochs100, lr1e-4, devicecpu): 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(recon0.0, acc0.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_w1.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 {ep1: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), dtypenp.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 所有样本塌到一个簇这是这套模型最常见、也最让人头大的问题。表现是训练到某个 epochgamma的某一行突然变成接近 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塌到 0diff.pow(2) / s2直接 inf。这个用clamp(min1e-6)就挡住了。logvar_z掉到 -30 以下exp之后虽然不溢出但1/s2的反向梯度会爆炸。这个用输出端的clamp(-10, 10)挡住。gamma某一项恰好是 0torch.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 上溢加 clamplog 加 eps指标剧烈抖动用采样 z 做评估改用 μ 做 argmax簇大小极不均衡方差被学得太小退化成硬分配给 log_sigma2_c 加下限正则收敛后指标不如两阶段联合微调把空间带偏了缩短微调轮数或只微调最后几层6. 适用边界与后续怎么扩展这套模型不是万能的说清楚它不适合什么比吹它能做什么更有价值。样本量太小的时候别用。几千条以下的数据参数化的 GMM 先验很难学准反而不如直接用谱聚类或者密度聚类。我一般建议至少上万条样本再考虑深度聚类。簇的形状高度不规则的时候效果打折。对角协方差只能表达轴对齐的椭球如果真实簇是斜的、弯曲的、或者套嵌的这套建模假设就不对。可以换成全协方差矩阵参数量 K×D×DD 大了扛不住或者干脆换基于密度的深度聚类方法。类别极度不均衡的时候要小心。π 本来可以学出不均衡的比例但早期塌缩的风险会显著上升前面说的冻结策略必须开。如果想继续往下做有几个方向我觉得性价比比较高。一个是把对角协方差换成低秩分解。σ 矩阵用 U·diag(d)·U^T 表示U 是 D×r 的正交阵r 取 2 到 4。这样能表达方向性参数量只增加 r×D比全协方差省得多在处理“斜着”的簇时效果好不少。另一个是给解码器加一个时间或者结构的先验。比如序列数据可以让解码器输出一个序列分布而不是各维度独立的高斯图数据可以把邻接关系塞进重构损失里。这套基于 VAEs 和 GMM 的深度聚类的框架是很松的编码器和解码器换成什么都行只要中间那个 GMM 先验在整体的推导就成立。最后分享一个我踩过好几次的小坑不要在验证集上挑 epoch。无监督场景下你没有可靠的验证信号重构损失和聚类指标经常不同步靠着验证 loss 挑出来的那个 checkpoint聚类指标往往不是最好的。我现在是固定训练轮数然后把最近 20 个 epoch 的模型权重和对应的聚类指标全部存下来最后人工挑一个——听起来很土但比任何自动策略都靠谱。