1. 先把问题说透:深度图聚类到底卡在哪一步
图聚类这件事,说到底就是把一张图里的节点分成若干组,使得组内连接密集、组间连接稀疏。听起来像个老问题,谱聚类、模块度优化、标签传播这些方法已经跑了几十年。但只要真拿它们去处理现实中的图,问题立刻冒出来:节点数上百万、特征维度几千、图结构还带噪声,传统方法要么算不动拉普拉斯矩阵的特征分解,要么得手工设计相似度核,稍微换个数据集就得重新调参。深度图聚类(Deep Graph Clustering,DGC)就是在这种背景下被推上前台的——用图神经网络做编码器,把结构和特征一起压进低维表示,再在表示空间里做划分。
我最早接触这个方向的时候,思路还停留在"GAE 重构邻接矩阵 + KMeans"的组合上,跑完发现指标比谱聚类好不了多少。后来才明白,问题不在于编码器不够深,而在于优化目标和聚类目标根本不是一回事。重构损失关心的是"能不能把边还原出来",聚类关心的是"哪些点该在一起",两者之间存在天然的错位。对比学习进入这个领域,恰恰是为了补上这个错位——它不依赖标签,通过构造正负样本对,直接约束表示空间的几何结构。这篇文章我会把对比学习在图聚类里的几种典型落法、关键参数怎么定、代码怎么落地、以及实际跑下来会遇到哪些坑,一次性讲清楚。适合已经了解 GNN 基础、想往无监督方向深入的同学,也适合手上有一批无标注图数据、急需一个可落地方案的工程同学。
1.1 从谱聚类到图自编码器,深度模型为什么非上不可
先说清楚深度模型进来的理由,不然很容易陷入"为了深而深"的误区。传统谱聚类本质上是对图的拉普拉斯矩阵做特征分解,取前 k 个特征向量再做 KMeans。它的理论很漂亮,但工程上有两个硬伤:一是特征分解的复杂度在节点数上是立方级的,几万个节点就开始吃力;二是它只用了图的拓扑结构,节点自带的特征(文本、画像、属性)完全用不上。现实场景里,节点的属性信息往往比边更有区分度,比如社交网络里用户的兴趣标签、电商图里商品的类目文本。
图自编码器(GAE/VGAE)把这个问题解决了一半:编码器用 GCN 把特征和结构一起编码,解码器重构邻接矩阵,训练完成后取中间层表示做聚类。这条路子在当时确实打开了局面,但它把"学表示"和"做聚类"拆成了两个阶段,中间没有任何反馈。编码器学到的表示是"最有利于还原边"的,而还原边这件事,对稠密社区内部的区分度并不敏感——一个节点和它邻居的边很容易还原,但它在社区边界上的归属,重构损失根本不管。更麻烦的是,重构目标会让表示偏向于保留局部结构,长程的社区结构反而被稀释掉。
后来出现的一批方法开始尝试联合优化,比如在编码器后面接一个聚类头,用 KL 散度把软分配往目标分布上推,形成自训练闭环。这类思路确实把两个阶段捏到了一起,但也引入了新的麻烦:自训练依赖伪标签,伪标签来自当前聚类结果,一旦早期划分偏了,后面就会一路错下去,越训越自信。这就是我下面要展开的核心痛点。
1.2 三个真痛点:表示坍塌、伪标签噪声、目标错位
表示坍塌是最致命也最隐蔽的一个。所谓坍塌,是指编码器输出的表示在空间里挤成一团,所有节点的向量几乎平行,余弦相似度接近 1。这时候你做 KMeans,它会把所有点分到一两个簇里,剩下的簇全是空的或者只有零星几个点。指标看上去 ACC 可能还有 30% 多(因为大簇恰好覆盖了某个真实类别),但模型实际上什么都没学到。坍塌在纯重构目标下不容易发生,但在对比学习或者聚类自训练目标下非常容易出现,因为这两类目标都存在"把所有东西映射到同一个点就能让损失最小"的退化解。
伪标签噪声是自训练路线的通病。软分配矩阵里,高置信度的节点通常是度数高、特征典型的节点,低置信度的往往是边界节点。自训练会不断强化高置信度那部分,边界节点的归属被永久固化。我在一个引文网络上做过统计,训练到 200 轮之后,置信度低于 0.4 的节点大概占 15%,这些节点的划分结果和初始化时的 KMeans 结果重合度超过 90%——也就是说,后半程的训练对它们几乎没有任何修正作用。
目标错位则是一个结构性矛盾。重构损失、聚类损失、对比损失三者的梯度方向未必一致。重构想让邻居表示接近,聚类想让同簇节点接近,对比想让正对接近负对远离。当图的社区结构和局部邻接不一致时(比如存在大量跨社区的桥边),这几个目标就会互相拉扯,表现出来就是损失在降但指标在震荡。
提示:判断是否发生坍塌,有个特别快的土办法——随机抽 200 个节点,算两两余弦相似度的均值。健康情况下这个值在 0.1~0.5 之间,如果超过 0.9,基本可以断定塌了。
1.3 对比学习补的到底是哪块板
对比学习的价值,用一句话概括:它提供了一个不依赖标签、又能直接约束表示几何结构的自监督信号。InfoNCE 这类损失可以拆成两项看,一项是对齐性(alignment),拉近正样本对;另一项是均匀性(uniformity),把表示推向超球面的均匀分布。均匀性这一项天生就是反坍塌的——所有点挤在一起,均匀性损失会爆炸。
更关键的是,它不需要重构邻接矩阵。传统的重构目标隐含了一个假设:"邻居的表示应该相似",这个假设在异配图(heterophily)上直接失效。对比学习可以通过视图构造绕开这个假设,比如对同一张图做两次不同的扰动,要求两次编码结果一致,这个约束只关心"扰动不该改变语义",而不关心"谁和谁相连"。这个视角的转换,是深度图聚类这几年最大的思路变化。
我在实际项目里的体会是,对比项最好当"骨架"用,聚类项当"导向"用。骨架负责把表示空间撑开、防止塌缩,导向负责把表示往簇结构上拉。两者缺一不可,但配比很讲究,后面第 3 节会详细说。
2. 四种落法:对比学习和图聚类怎么拼
对比学习和图聚类的结合方式,目前主流能归成四类。这四类不是互斥的,很多方法会取两三种混着用。我把它们的构造逻辑、优缺点和适用场景挨个拆开讲,你在选型时可以直接对号入座。
2.1 视图级对比:给同一张图照两张影子
视图级对比是最直观的一类,思路来自图像领域的 SimCLR:同一张图做两次随机增强,得到两个视图,同一个节点在两个视图里的表示构成正对,不同节点的表示构成负对。图上的增强手段主要有三种——边扰动(随机删掉一部分边)、特征掩码(把部分特征维度置零)、子图采样(随机采样一个子图)。
这套东西的好处是实现简单、通用性强,几乎不用改编码器结构。缺点也很明显:增强的力度极难拿捏。增强太弱,两个视图几乎一样,正对太容易区分,学不到东西;增强太强,图的语义被破坏,正对之间的一致性假设不成立了,模型反而在学噪声。我在引文网络上试过边扰动率 0.6 的配置,训练损失降得很漂亮,但聚类 NMI 比不加对比还低——因为图已经被打散成随机图了。
另一个隐藏成本是负样本。标准 InfoNCE 要求每个正对配一批负样本,节点数是 N 的话,相似度矩阵是 N×N,几万个节点直接爆显存。工程上一般用两种绕法:一是每个 batch 内做对比(batch 内的其他样本当负样本),二是对每个节点随机采样固定数量的负样本(比如 256 个)。前者实现简单但受 batch size 限制,后者需要额外写采样逻辑。
2.2 节点与原型对比:把簇心直接放进表示空间
原型对比(prototype contrast)是我个人最偏好的做法,因为它把聚类结构直接编进了对比目标里。具体做法是维护 k 个可学习的原型向量(可以理解成簇心),每个节点和"自己所属的原型"构成正对,和其他原型构成负对。原型本身也可以通过聚类中心的滑动平均来更新,不一定非要当可学习参数。
这么设计的好处是,对比目标和聚类目标在语义上完全对齐了——节点往自己簇心靠、远离别的簇心,这本身就是聚类想要的效果。它不需要构造两个视图,也就不存在增强力度难调的问题。同时负样本数量天然等于 k-1,计算量只和簇数有关,跟节点数无关,扩展性很好。
坑在于原型的初始化。如果 k 个原型初始化得太近,早期所有节点都会被拉到同一个原型上,形成事实上的坍塌。我的做法是用一次 KMeans 的结果初始化原型,或者干脆从表示空间里随机挑 k 个相距较远的节点当初始原型。另外原型数量 k 如果设错(比如真实类别是 7 类但你设了 10),多出来的原型会抢走一部分节点,指标会掉得很难看。这一点目前没有特别优雅的无监督解法,实践中一般靠肘部法、轮廓系数或者谱分析的特征值间隙来估。
2.3 簇级对比与难负样本:让同类靠拢、异类推开
簇级对比是把对比的粒度从节点提升到簇。做法是先对当前表示做一次软聚类,得到每个簇的"质心表示",然后让簇和簇之间互相排斥——同一簇内的节点表示要紧凑,不同簇的质心要拉开距离。这类方法和原型对比有点像,区别在于簇质心是动态算出来的,不是可学习参数。
难负样本挖掘是另一个常被忽视的点。随机采的负样本里,绝大多数和锚点节点的相似度都很低,梯度贡献接近零,等于白算。真正有用的是那些"看起来像但实际不同类"的难负样本。常见的挖法是:先算所有候选的相似度,取相似度最高的一批(但排除正样本)作为负样本。这里的风险是假负样本——两个节点视觉上很像,实际上是同一类但被误判成负对,强行推开会破坏表示。缓解办法是用聚类置信度做过滤,只在高置信度的不同簇之间构造负对。
2.4 停梯度与无负样本:绕开大 batch 的取舍
BYOL 那条路线证明了不用负样本也能避免坍塌,关键在于停梯度(stop-gradient)和非对称结构:一个分支带预测头,另一个分支不带,且不回传梯度给另一分支。图领域有对应的工作把这套搬了过来,用动量编码器更新目标分支,主分支去预测目标分支的输出。
这种做法的最大好处是不依赖大 batch,也不依赖负样本采样,在十万级节点的图上跑起来很舒服。代价是训练更敏感,动量系数、预测头结构、学习率三者耦合很紧,稍微不对就容易塌。而且它缺少均匀性约束,表示的分布容易偏向某些方向。我一般会在它上面再加一个轻量的均匀性正则项(把表示协方差矩阵往单位阵推),实测能明显改善簇的均衡度。
| 方法类型 | 需要负样本 | 计算开销 | 调参难度 | 适合的图规模 |
|---|---|---|---|---|
| 视图级对比 | 是 | 中到高 | 高(增强力度敏感) | 中小规模 |
| 节点-原型对比 | 是(k-1 个) | 低 | 中 | 任意规模 |
| 簇级对比 + 难负样本 | 是 | 中 | 中高 | 中小规模 |
| 停梯度无负样本 | 否 | 低 | 高 | 大规模 |
3. 参数怎么定:增强力度、温度与损失配比
这一节是我认为整篇文章最该反复看的部分。对比学习类的方法,结构和公式都是公开的,真正决定结果的是那几个标量超参。下面每个参数我都给出取值逻辑和实测区间。
3.1 图增强:边扰动和特征掩码的取值逻辑
边扰动率 p_e 的本质是控制视图之间的"信息差"。太低,正对太简单;太高,语义被破坏。取值和一个图的平均度强相关:平均度高的稠密图可以承受更高的扰动率,因为删掉一部分边之后局部结构还在;稀疏图(平均度 2~3)就很脆弱,p_e 超过 0.3 就容易把节点变成孤岛。
我的经验区间:
| 图类型 | 平均度 | 边扰动率 p_e | 特征掩码率 p_f |
|---|---|---|---|
| 稀疏引文网络 | 2~5 | 0.1~0.2 | 0.1~0.3 |
| 中等密度社交图 | 10~30 | 0.2~0.4 | 0.2~0.4 |
| 稠密共现图 | 50+ | 0.3~0.5 | 0.3~0.5 |
特征掩码率 p_f 的取值还要看特征维度。特征维度高、且信息冗余(比如 TF-IDF 向量)时,掩掉 30% 也不太伤;特征维度低(比如只有 10 维画像特征),掩 20% 就可能把关键信号抹掉了。有个技巧是按特征的重要度加权掩码,用特征方差或者互信息做权重,重要特征少掩、冗余特征多掩,实测比均匀掩码好 1~2 个点。
注意:边扰动一定要做无向化处理。删边的时候如果只删一个方向,剩下的单向边会让 GCN 的聚合结果出现方向性偏差,这个坑我踩过一次,找了两天才定位到。
3.2 温度系数 τ:分母里的那把尺子
温度系数 τ 出现在 InfoNCE 的分母里,控制 softmax 的尖锐程度。公式写出来是这样的:
$$L_{i} = -\log \frac{\exp(\text{sim}(z_i, z_i^{+})/\tau)}{\sum_{j}\exp(\text{sim}(z_i, z_j)/\tau)}$$
τ 越小,分布越尖锐,模型越关注那些相似度高的难负样本,梯度也越大;τ 越大,分布越平滑,所有负样本的权重被拉平,对比信号变弱。常见取值 0.2~0.5,图像领域 SimCLR 用的是 0.5,图领域因为负样本质量参差,很多人用 0.2~0.3。
有个容易被忽视的耦合关系:τ 的合理取值和 batch size 有关。batch 小的时候,负样本少,需要更小的 τ 来放大有限负样本之间的差异;batch 大了,负样本多,τ 可以适当放大。我做过一组对照,节点数 2708 的引文网络,batch 取全图时 τ=0.5 表现最好;如果改成 batch=256 的小批量,τ=0.2 反而更优。
如果你不确定,就从这个网格里搜:τ ∈ {0.1, 0.2, 0.3, 0.5, 0.8}。别嫌麻烦,这个参数对结果的影响经常比网络层数还大。
3.3 对比项与聚类项的配比 λ
联合损失一般是这个形式:
$$L = L_{cluster} + \lambda L_{contrast}$$
λ 控制两者的相对权重。λ 太大,模型只顾着撑开表示空间,簇结构反而被冲淡,表现为簇的划分和真实类别对不上;λ 太小,对比项起不到防坍塌的作用,聚类项会把表示往退化方向拽。
我的建议是分段式而不是固定权重。前 100~200 轮只训对比项(相当于预训练),让表示空间先被撑开;之后把 λ 设成 0.1~1.0 的固定值联合训练。这个策略比一上来就联合训练效果好得多,因为早期聚类结果本身很不可靠,用不可靠的伪标签去引导表示,等于给模型喂噪声。
如果你非要用固定权重,那就从 λ=1.0 起步,观察聚类项的 KL 损失和对比损失的量级,让两者大致在同一个数量级上。数量级差太多的话,小的那一项基本等于不存在。
3.4 三道防坍塌保险
坍塌是对比学习图聚类里最容易翻车的地方,我一般会同时上三道保险:
第一道是投影头加停梯度。编码器输出 z,再加一个两层 MLP 投影头得到 h,对比损失算在 h 上,聚类损失算在 z 上。这个设计的道理是,投影头吸收掉了对比学习为了"区分样本"而引入的过度信息,让 z 保留更多和聚类相关的语义。梯度只回传到编码器的一部分,也能抑制塌缩。
第二道是均匀性正则。直接对表示矩阵算一个均匀性损失,把归一化后的表示往均匀分布上推:
$$L_{uni} = \log \mathbb{E}_{i,j}\left[\exp(-2| \tilde{z}_i - \tilde{z}_j |^2)\right]$$
这一项权重给很小(0.01~0.1)就够了,主要是兜底。
第三道是聚类均衡约束。每次更新后检查各簇的样本数,如果最大的簇占比超过 40%,就把它的原型往外推一点,或者对分配矩阵做一次 Sinkhorn 归一化,强制让各簇的边缘分布接近均匀。这一道对指标提升最直接,尤其是在类别不平衡没那么极端的图上。
4. 从零跑通一版:代码与训练流程
光说原理容易飘,我把一套能直接跑的最小实现贴出来,基于 PyTorch 和 PyG。这套代码我在好几个中等规模的图上验证过,节点数几千到几万都能跑。
4.1 数据与预处理
先做两件最基础的事:特征行归一化、边去重和无向化。特征归一化不是可选项,GCN 第一层的聚合对特征尺度非常敏感,不做归一化的话,方差大的那几维会主导整个表示。边去重是因为很多公开数据集的边表里有重复边,重复边在 GCN 里等价于给这条边加权,会扭曲聚合结果。
import torch import torch.nn.functional as F from torch_geometric.utils import to_undirected, remove_self_loops def preprocess(data): x = data.x.float() x = F.normalize(x, p=2, dim=1) edge_index, _ = remove_self_loops(data.edge_index) edge_index = to_undirected(edge_index, num_nodes=data.num_nodes) return x, edge_index4.2 编码器、投影头与聚类头
编码器用两层 GCN 就够了。这里我要强调一下层数:图聚类任务上,两层几乎总是最优的,三层以上的表示会出现过平滑——节点表示趋同,簇的边界被抹平。我做过对照,同一套对比损失,两层 GCN 的 NMI 比三层高 5~8 个点,比四层高十几个点。如果你的图确实需要更大的感受野,用 APPNP 或者带残差的跳连,别硬堆层数。
from torch_geometric.nn import GCNConv class Encoder(torch.nn.Module): def __init__(self, in_dim, hid_dim, out_dim, dropout=0.5): super().__init__() self.conv1 = GCNConv(in_dim, hid_dim) self.conv2 = GCNConv(hid_dim, out_dim) self.dropout = dropout def forward(self, x, edge_index): x = self.conv1(x, edge_index) x = F.relu(x) x = F.dropout(x, p=self.dropout, training=self.training) x = self.conv2(x, edge_index) return x class Projector(torch.nn.Module): def __init__(self, dim, hid_dim=256): super().__init__() self.net = torch.nn.Sequential( torch.nn.Linear(dim, hid_dim), torch.nn.ReLU(), torch.nn.Linear(hid_dim, dim) ) def forward(self, z): return F.normalize(self.net(z), dim=1)聚类头直接用软分配,不需要额外的参数矩阵,簇心单独维护:
def soft_assign(z, centers, alpha=1.0): # Student-t 核,alpha=1 时即 DEC 的标准形式 dist = torch.cdist(z, centers, p=2) ** 2 q = (1.0 + dist / alpha).pow(-(alpha + 1) / 2) q = q / q.sum(dim=1, keepdim=True) return q def target_dist(q): weight = q ** 2 / q.sum(dim=0, keepdim=True) return (weight.t() / weight.sum(dim=1)).t()4.3 增强、对比损失与联合目标
增强函数和对比损失:
from torch_geometric.utils import dropout_edge, mask_feature def augment(x, edge_index, p_e=0.2, p_f=0.2): ei, _ = dropout_edge(edge_index, p=p_e, force_undirected=True) x_aug, _ = mask_feature(x, p=p_f, mode='multinomial') return x_aug, ei def info_nce(h1, h2, tau=0.3): logits = h1 @ h2.t() / tau labels = torch.arange(h1.size(0), device=h1.device) loss_a = F.cross_entropy(logits, labels) loss_b = F.cross_entropy(logits.t(), labels) return (loss_a + loss_b) / 2注意这里做了双向计算再取平均。原因是相似度矩阵本身不对称(虽然 h1@h2.T 在归一化后理论上对称,但因为负样本集合的视角不同,两个方向的梯度贡献不一样),双向计算能让每个节点既当一次锚点也当一次负样本,实测比单向高 1~3 个点。
节点数超过两万的时候,全量相似度矩阵会吃掉大量显存。这时候改成随机负采样版本:
def info_nce_sampled(h1, h2, tau=0.3, n_neg=256): h1 = F.normalize(h1, dim=1) h2 = F.normalize(h2, dim=1) n = h1.size(0) idx = torch.randint(0, n, (n, n_neg), device=h1.device) neg = h2[idx] # [n, n_neg, d] pos = (h1 * h2).sum(dim=1, keepdim=True) / tau neg_sim = torch.bmm(neg, h1.unsqueeze(2)).squeeze(2) / tau logits = torch.cat([pos, neg_sim], dim=1) labels = torch.zeros(n, dtype=torch.long, device=h1.device) return F.cross_entropy(logits, labels)4.4 训练循环与监控指标
整体训练分两段,前段只跑对比,后段联合。每轮都要记录三个数:对比损失、聚类 KL 损失、以及簇大小的最大占比。第三个数是坍塌的早期预警,一旦超过 0.5 就该停下来检查。
def train(model, projector, x, edge_index, k, epochs=400, warmup=150, lam=0.5, tau=0.3): opt = torch.optim.Adam( list(model.parameters()) + list(projector.parameters()), lr=1e-3, weight_decay=5e-4 ) # 用一次前向 + KMeans 初始化簇心 with torch.no_grad(): z = model(x, edge_index) from sklearn.cluster import KMeans km = KMeans(n_clusters=k, n_init=20).fit(z.cpu().numpy()) centers = torch.tensor(km.cluster_centers_, dtype=torch.float, device=z.device) for ep in range(epochs): model.train(); projector.train() opt.zero_grad() x1, ei1 = augment(x, edge_index) x2, ei2 = augment(x, edge_index) z1 = model(x1, ei1) z2 = model(x2, ei2) loss_c = info_nce(projector(z1), projector(z2), tau) if ep >= warmup: q = soft_assign(z1, centers) p = target_dist(q) loss_k = F.kl_div(q.log(), p, reduction='batchmean') loss = loss_k + lam * loss_c else: loss_k = torch.tensor(0.0) loss = loss_c loss.backward() opt.step() if ep >= warmup: with torch.no_grad(): q = soft_assign(z1, centers) centers = (q.t() @ z1) / q.sum(dim=0, keepdim=True).t().clamp(min=1e-6) if ep % 20 == 0: with torch.no_grad(): q = soft_assign(model(x, edge_index), centers) share = q.max(dim=1).values.mean().item() size_max = (q.argmax(1).bincount(minlength=k).max().item() / x.size(0)) print(f"ep {ep} | c {loss_c:.4f} | k {loss_k:.4f} | conf {share:.3f} | maxshare {size_max:.3f}") return model, centers4.5 评测:ACC、NMI、ARI 的三个对齐坑
第一个坑是聚类标签对齐。KMeans 输出的簇编号和真实类别的编号没有对应关系,算 ACC 之前必须做一次最优匹配。用匈牙利算法(scipy.optimize.linear_sum_assignment)在混淆矩阵上做最小代价匹配,别自己写贪心匹配,贪心在类别数多的时候会明显低估。
import numpy as np from scipy.optimize import linear_sum_assignment from sklearn.metrics import confusion_matrix def cluster_acc(y_true, y_pred): cm = confusion_matrix(y_true, y_pred) row, col = linear_sum_assignment(-cm) return cm[row, col].sum() / cm.sum()第二个坑是 KMeans 的随机性。同一套表示,跑五次 KMeans 的 ACC 波动可能有 2~3 个点。所以评测时必须跑多次取平均(我一般跑 10 次),并且报告标准差。只报最好那一次的结果,说服力很弱。
第三个坑是用标签调超参。这是这个领域里公开的秘密——不少工作嘴上说无监督,实际上拿真实标签反复调 τ、λ、学习率。你自己做实验的时候一定要留出一部分标签完全不看,只用它做最终评测。否则线上换一批数据,指标会掉得你怀疑人生。
5. 排查手册:跑不出结果的七种典型情况
这一节全是我自己踩过的坑,按症状归类,你可以直接当速查表用。
5.1 损失在降,聚类指标不动
这是最常见的情况,十有八九是对比项和聚类项各干各的。具体表现是对比损失从 5 降到 1,但 NMI 一直在 0.3 附近晃。原因通常是两者作用在不同的表示上,或者 λ 设得太小让聚类项的梯度被淹没。
排查顺序:先看聚类 KL 损失有没有在降。如果 KL 也不降,说明簇心的更新有问题,检查中心更新的分母是否用了 clamp 防止除零。如果 KL 在降但 NMI 不涨,说明模型在优化一个错误的划分——通常是初始化太差,KMeans 一开始就分错了,后面只是把这个错误固化了。解法是重跑几次不同的 KMeans 初始化,取 KL 最终值最低的那次;或者在 warmup 阶段把对比损失权重拉满,让表示充分撑开再聚类。
5.2 所有点挤进一两个簇
典型坍塌。按概率从高到低排查:
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 表示余弦相似度均值 > 0.9 | 缺少反坍塌机制 | 加投影头 + 停梯度,或加均匀性正则 |
| 对比损失极低(<0.5) | 温度 τ 太大 / 增强太弱 | τ 降到 0.2,增强力度调高 |
| 簇心之间距离接近 0 | 中心更新发散 | 检查中心更新的分母,加 L2 归一化 |
| 最大簇占比 > 0.5 | 类别不均衡 + 无均衡约束 | 加 Sinkhorn 归一化或簇大小正则 |
我遇到过一次特别隐蔽的情况:所有簇心在初始化时被设成了同一个值(代码里torch.zeros忘了改),模型训了 300 轮全部塌到一个簇,损失还特别好看。这个问题靠肉眼看损失根本发现不了,必须监控簇心之间的距离。
5.3 显存爆掉与训练慢
节点数超过三万,全量相似度矩阵基本就撑不住了。三个解法按推荐顺序:一是改随机负采样,把负样本数固定到 128~256;二是改小批量训练,但要注意小批量会破坏图的结构完整性,需要用邻居采样(比如 GraphSAINT 那类思路)来保证每个 batch 里的子图结构合理;三是用半精度训练,torch.cuda.amp两行代码就能开,显存能省 40% 左右,注意在计算 KL 散度时把 q 转回 fp32,否则会出现 log(0) 的数值问题。
5.4 相邻层表示几乎一样
过平滑。两层以上的 GCN 在图聚类任务上收益递减,三层开始掉点。判断方法很简单,算每一层输出表示的两两平均距离,如果第三层的距离不到第一层的一半,说明已经在趋同了。缓解手段有残差连接、PairNorm、或者干脆停在两层。我个人更推荐后者,简单有效,别为了架构好看硬堆深度。
5.5 换数据集就崩
超参过拟合。在引文网络上调到 NMI 0.6,换到共现网络上直接掉到 0.35。根本原因是不同图的平均度、特征稀疏度、类别均衡度差异太大,一套超参吃不遍。我的处理办法是让超参跟着图统计量走,比如把边扰动率设成0.5 / log(平均度 + 1)这种形式,让它在稀疏图上自动变小、在稠密图上自动变大。这个经验公式没什么理论基础,但在十几个数据集上试下来,比固定值靠谱得多。
6. 影响范围:这套思路还能搬去哪
对比学习加图聚类,表面上是学术问题,但它解决的问题——无标签图数据的结构化分组——在实际场景里到处都是。我把见过的落地场景和对应的改造要点列一下。
6.1 场景清单与改造要点
| 场景 | 数据形态 | 关键改造点 |
|---|---|---|
| 用户分群 | 行为二部图投影成用户图 | 边权重要映射成采样概率,别用二值化 |
| 文本主题聚类 | 文档-词共现图 + TF-IDF | 特征掩码率调到 0.4 以上,文本特征冗余度高 |
| 单细胞类型划分 | 细胞相似度图,特征高维稀疏 | 必须做特征降维,否则前 50 维主导表示 |
| 异常群体识别 | 设备-行为图 | 对比项用局部子图采样,异常点天然稀疏 |
| 商品类目发现 | 共现/共购图 | 类别数不确定,需要用特征值间隙估 k |
用户分群这块我做得最多。有一个特别实际的体会:真实业务图里边的权重几乎都是有意义的(购买次数、互动频率),直接二值化会丢掉大量信息。把边权归一化成采样概率做边扰动,比统一丢 20% 的边效果好很多。
6.2 后续还能往哪扩
三个方向我觉得值得投入。异质图是目前最大的空白,真实场景里节点类型往往不止一种,现有的对比方法大多假设同质图,处理异质图时要么先做元路径投影(会丢信息),要么得重新设计视图构造方式。动态图是另一个方向,社区结构随时间演化,对比学习的正对可以从"同一时刻的两个视图"扩展到"相邻时刻的同一节点",这个思路已经有工作在做了,但工程落地的经验还很少。大规模分布式训练则是纯工程问题,十万级节点以上,单机怎么调都吃力,得考虑图分区加参数服务器那套。
6.3 一点个人体会
最后说个我觉得最值钱的经验。这套方法里,80% 的效果来自三件事:表示别塌、簇心初始化别太差、评测别作弊。网络结构、损失函数的具体形式、增强策略的精细设计,加起来大概只占 20%。我见过太多人一上来就魔改损失函数,加各种花哨的对比项,结果基础的三件事没做好,指标还不如一个调好参数的 DEC。
还有一个反直觉的点:别过早引入聚类损失。warmup 阶段给足,让对比学习把表示空间充分撑开,簇结构会自然浮现出来。急着聚类,等于在噪声上做决策。我在一个四万节点的图上把 warmup 从 100 轮加到 300 轮,其他一概不动,NMI 从 0.41 涨到 0.53。这个改动成本几乎为零,但很多人懒得等。