☰
对比学习驱动的深度图聚类:方法、调参、代码与排错
2026/10/28 3:01:38 网站建设 项目流程

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~50.1~0.20.1~0.3
中等密度社交图10~300.2~0.40.2~0.4
稠密共现图50+0.3~0.50.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_index

4.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, centers

4.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。这个改动成本几乎为零,但很多人懒得等。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询