做图聚类这条线的人,大概都经历过同一个尴尬:手上一张图,节点特征有、边也有,就是没有标签,而下游不管是社区发现、异常检测还是推荐召回,都等着你给出一个还算靠谱的分组结果。传统做法是把邻接矩阵和特征拼一拼,扔给谱聚类或者 DeepWalk 之类的流程,跑得出来,但效果经常卡在一个说不清的天花板上。近两年真正让这条线动起来的一个方向,就是对比学习在深度图聚类方法里的落地——它不是简单地给自编码器多加一个损失项,而是把"什么样的表示算是好表示"这件事,从依赖重构误差,换成了依赖样本之间的一致性约束。
我前后在几个不同规模的图上折腾过这套东西,从几千节点的小引文网络到十几万节点的合作网络都试过,也踩过不少当时觉得莫名其妙、事后回看特别蠢的坑。这篇东西想讲清楚几件事:对比信号在图聚类里到底补上了哪块短板;正负样本是怎么凑出来的;对比目标和聚类目标怎么焊在一起才不至于互相拆台;以及那些只有跑过才知道的调参顺序和隐性退化。不管你是刚接触图自监督,还是已经能背出 InfoNCE 公式但一上手就掉点,下面这些内容应该都能对得上你的实际处境。
1. 深度图聚类的老病灶:无标签条件下表示学习的两难
想把对比学习为什么有效讲明白,得先承认一件事:在它出现之前,深度图聚类的主流范式其实一直在一个不太舒服的位置上打转。以自编码器为代表的一类方法,靠重构邻接矩阵或者特征矩阵来学节点表示,再用 KMeans 或者带 KL 散度的软分配在表示空间里切块。这套流程逻辑上是通的,但它隐含了一个相当强的假设——重构得好的表示,天然就适合聚类。这个假设在实践中经常不成立,而且是那种"训练日志看起来一切正常、结果指标就是上不去"的不成立。
1.1 重构目标和聚类目标为什么经常互相拆台
邻接矩阵重构的本质是逼近局部连接模式。一个节点的表示只要足够保留它和邻居的连边概率,重构损失就会降下来。问题在于,图的局部连接模式里含着大量跟聚类无关的噪声:跨社区的少量长尾边、采集过程引入的伪连接、度数分布极度不均导致的"枢纽节点"。编码器为了压低重构误差,会老老实实把这些噪声也编码进去,于是表示空间里就出现了这样的现象——同一个簇的两个节点,因为中间夹了一条噪声边,表示反而被拉远了。
聚类目标关心的恰恰是全局的块结构,它要的是"同簇紧凑、异簇分离",跟逐条边拟合局部概率是两套优化方向。我做过一个很直观的对照:在同一个引文网络上,只跑重构、不加任何其他约束,然后把学到的表示拿出来算轮廓系数,再和真实标签对照,会发现簇内距离的方差特别大,说明表示空间里同一个类的节点散得很开。加大重构权重只会让这个现象更明显,因为模型在更努力地拟合那些它本来就不该在意的边。
这里有个容易被忽略的细节:重构损失降不下去的时候,很多人的第一反应是加深网络或者加宽隐层。但在图数据上,层数一多,过平滑就来了,节点表示趋同,重构损失确实降了,因为所有节点都被预测成差不多的连接模式,这时候聚类结果会退化成"一大坨加几个碎片"。这个坑我在早期的实验里踩过至少三次。
另一条路是直接对软分配做约束,也就是 DEC 那一套:用 t 分布算节点到簇心的相似度,再构造一个锐化的目标分布,最小化两者的 KL 散度。这条路解决了"重构和聚类脱节"的问题,但它把全部压力都压在了聚类损失上,缺少一个足够强的表示层约束,结果就是对初始化极其敏感——KMeans 初始簇心稍微偏一点,后面的 self-training 就会一路强化这个偏差,最后收敛到一个局部最优解上。我在一个类别极不均衡的数据集上见过最夸张的情况:十次不同随机种子跑下来,ACC 最高 0.72、最低 0.41,方差大到根本没法写进论文表格。
1.2 表示坍塌:那个让训练曲线看起来很美的陷阱
还有一个更隐蔽的问题,是很多人在自监督预训练阶段没意识到的——表示坍塌。简单说,模型学会了一个"偷懒解":把所有节点映射到表示空间里几乎同一个点上,或者映射到一个极低维的子空间里。这时候如果损失函数里只有正样本对的一致性约束(比如只拉近同一节点的两个视图),模型完全可以靠输出常数来让损失降到接近零,训练曲线漂亮得不行,但你把这个表示拿去做任何下游任务都是废的。
这在图对比学习里尤其容易发生,原因是图增强造出来的两个视图天然高度相似。特征掩码只遮掉一小部分维度、边扰动只删掉几条边,两个视图几乎是同一张图的轻微扰动版本,模型甚至不需要学什么有区分度的东西,只要输出一个与输入弱相关的平滑函数就能让一致性损失很低。我在第一次复现节点级对比的时候,就遇到了这个情况:对比损失从 4.2 一路降到 0.03,看起来收敛得非常完美,但把表示拿出来算聚类 ACC,比不加对比学习的基线还低两个点。当时排查了大半天,最后把表示矩阵做 PCA 降维画出来才看明白,所有点挤成一团。
真正把这个坑填上的是负样本,或者更准确地说,是足够难、足够多样的负样本。InfoNCE 那套形式之所以在图上有效,核心不在于它拉近了正样本对,而在于它同时把负样本对推远,这个"推"的动作强制模型在表示空间里维持区分度,坍塌解在负样本项上会付出巨大代价。后面的章节会详细拆负样本工程,但这里先把结论摆出来:对比学习在深度图聚类里的价值,主要来自它提供了一个与聚类语义无关、但能强制维持表示区分度的辅助信号,这个信号和聚类目标结合之后,才把前面说的那个两难解开了一半。
2. 对比信号在图上的三种形态与构造逻辑
搞清楚为什么要用对比,接下来是更实际的问题:图上的对比到底在"比"什么。跟图像领域不一样,图没有天然的裁剪、旋转、色彩抖动这类语义保持的增强手段,你得自己造视图。而造视图的方式,直接决定了对比信号在学什么。我把目前主流做法归成三类形态,每一类的适用场景和隐含假设都不太一样,选错了反而是负收益。
2.1 节点-节点对比:InfoNCE 拆开看,温度系数是个狠角色
节点级对比是最常见的一种,一个节点在两个视图里的表示构成正样本对,其他节点构成负样本。损失函数就是标准的 InfoNCE 形式:
import torch import torch.nn.functional as F def info_nce(z1, z2, tau=0.5): # z1, z2: [N, d],已经过 L2 归一化 z1 = F.normalize(z1, dim=1) z2 = F.normalize(z2, dim=1) sim = z1 @ z2.t() / tau # [N, N] labels = torch.arange(z1.size(0)).to(z1.device) loss = F.cross_entropy(sim, labels) return loss这段代码短得不像话,但里面每个量都有讲究。先说归一化,很多人会省掉这一步,觉得反正后面除温度系数了。实际上不归一化的话,相似度会被表示的模长主导,模型会发现"把所有表示模长放大"就能让正样本相似度变大,这又是一条偷懒路径。L2 归一化之后,相似度就落在余弦区间里,约束才是有意义的。
温度系数 τ 的作用值得单独拎出来说。它控制的是 softmax 分布的锐利程度:τ 越小,分布越尖,模型越关注最难的负样本;τ 越大,分布越平,所有负样本的梯度贡献被摊薄。在节点级对比里,常用的取值是 0.5 到 0.2 之间。我自己的经验是,类别数少、簇结构清晰的图(比如三类的引文网络),τ 可以取大一点,0.5 附近比较稳;类别数多、簇之间边界模糊的图(比如十几类的商品共购网络),τ 要往小调,0.2 甚至 0.1,否则模型分辨不出那些"长得像但不是同类"的难负样本。
有个反直觉的现象我印象很深:在某个类别极多的数据集上,τ 从 0.5 一路调到 0.05,对比损失下降得越来越快,但聚类 NMI 是先升后降的。原因是 τ 太小时,模型被少数几个极难负样本主导,表示空间被过度拉扯,反而破坏了簇的紧凑性。所以调 τ 不能只看对比损失的收敛情况,必须盯着下游指标一起看。
还有一个实现层面的坑:这段代码里sim是一个 N×N 的矩阵,N 是节点数。几千节点的图,这个矩阵占几十 MB,没什么问题;但到了十万节点级别,单是这一个矩阵就要占几十 GB 显存,直接爆掉。这个后面第 4 节会专门讲采样策略,这里先记一笔——节点级全负样本对比的显存开销是 O(N²),这个天花板是硬性的,绕不过去。
2.2 节点-图对比与跨视图互信息:从 DGI 那一脉延续下来的思路
节点级对比有个隐含要求:你得造出两个质量相当的视图。但图增强本身是有损的,尤其是边扰动,删掉边可能直接把某个节点的局部结构破坏掉,这时候正样本对其实已经不同源了,硬拉近反而是噪声。DGI 那一脉的思路是绕开这个问题——不去比两个节点,而是比"节点表示"和"全图摘要表示"之间的互信息。
具体做法是先过一个图卷积编码器得到节点表示,再用一个 readout 函数(通常是均值池化)聚合出全图向量,然后构造正负样本:真实节点表示配真实图向量是正样本,真实节点表示配打乱行顺序后的假图向量是负样本。这个设计的好处是它不需要显式的图增强,避免了增强引入的结构失真;坏处是图级向量信息量太粗,它反映的是整张图的平均状态,对于局部簇结构不敏感的图,这个信号基本学不到东西。我在一个簇内特征差异极大的数据集上试过,DGI 风格的对比几乎没有带来提升,因为图级摘要被大簇主导了。
跨视图的互信息最大化(比如同时对齐局部子图和全局图)算是这条线的加强版,思路是对每个节点采样子图,子图编码和一个全局视图对齐。它在中小规模图上确实比 DGI 稳定一些,代价是要做子图采样,训练速度大概慢两到三倍。是否值得,取决于你对训练时长的容忍度。我一般的判断标准是:如果单次训练已经超过两小时,加这个大概率不划算,不如去调增强策略。
2.3 簇级对比:把"组"当成对比单位
前两类都是在节点粒度上做对比,但聚类的目标本来就是簇。于是很自然的一个想法是:能不能直接在簇粒度上构造正负样本,让对比信号和聚类目标天然对齐。簇级对比通常的做法是,先做一次软分配得到每个节点的簇隶属度,然后从同一簇里采样节点构成正样本、不同簇构成负样本,或者把簇中心当作对比的锚点。
这条路的吸引力很直接——它显著缩短了"辅助任务"和"目标任务"之间的距离。但它有个致命的前置依赖:簇分配本身得是靠谱的。训练初期簇分配基本是随机的,这时候按簇采正样本,等于把随机分组的噪声当成监督信号喂进去,模型会被带着往错误方向跑。常见的缓解手段是加一个 warm-up 阶段,前若干轮只跑节点级对比把表示大致拉开,再引入簇级信号;或者用置信度筛选,只对高置信度的节点做簇级对比。
| 对比形态 | 关键依赖 | 适合的图规模 | 主要风险 |
|---|---|---|---|
| 节点-节点(InfoNCE) | 高质量图增强 | 中小规模为主 | 显存 O(N²)、假负样本 |
| 节点-图(互信息) | 有效的 readout | 大规模友好 | 图级信息过粗 |
| 簇级对比 | 较准的初始簇分配 | 中等规模 | 早期噪声放大 |
这张表不是让你照着选,而是提醒一件事:三种形态的风险点完全不同。节点级要解决的是采样和显存,图级要解决的是信息粒度,簇级要解决的是冷启动。实际项目里我会先用节点级把表示拉开,再视情况引入簇级做精修,两者叠加在多数图上都稳过单用一种。
3. 对比损失与聚类损失怎么焊在一起才不打架
把对比学习和深度图聚类结合,最核心的工程决策不是选哪个损失函数,而是这两个目标在训练流程里怎么排列组合。我见过不少人直接把两个损失加起来扔进优化器,跑出来效果不稳定,然后怀疑是对比学习没用。其实问题往往出在耦合方式上。目前大致有三条路,各有各的代价。
3.1 两阶段:先自监督预训练,再聚类微调
这是最稳的一条路,也是最容易解释的。第一阶段完全不管聚类,只跑对比学习,把编码器训到一个表示区分度足够好的状态;第二阶段冻结或者小学习率微调编码器,接上聚类头,用 KL 散度那套目标做细化。这种做法的好处是两个目标不会互相干扰,你可以独立调对比部分的超参,也可以独立评估预训练表示的质量(比如拿去跑 KMeans 看 ACC),出了问题容易定位。
代价是训练成本几乎翻倍,而且存在一个"预训练目标和聚类目标不完全一致"的间隙。我在一个图上做过对照:预训练阶段表示质量(用 KMeans ACC 衡量)已经到 0.68 了,但第二阶段微调之后只涨到 0.70,提升非常有限。也就是说,预训练阶段的表示已经基本决定了天花板,第二阶段的聚类微调更多是在做局部整理,很难突破。
这条路适合什么样的场景?我的判断是:数据规模中等偏上、训练预算充足、对结果稳定性要求高(比如要出一个能复现的基线)。如果只是快速验证一个想法,两阶段太慢了。
3.2 端到端联合优化:上限高,但调参像走钢丝
把对比损失和聚类损失放在同一个优化步里,损失函数写成L = L_cluster + λ * L_contrast。听起来简单,实际调起来非常考验耐心,因为两个损失的量级和收敛速度往往差一个数量级。对比损失初始值通常在 4 到 5(N 较大时更高),聚类损失初始值可能只有 0.0x 量级。如果不做归一化直接加权,λ 取 1 的话,整个优化过程基本被对比损失主导,聚类头几乎是摆设;λ 取小了,聚类信号又完全推不动。
我一般的处理方式是先分别跑一遍,记录两个损失在前几十轮的典型量级,然后按量级比例设 λ 的初值,再在这个初值上下浮动一个数量级去搜。举个实际例子:某次实验里对比损失稳定在 3.5 左右,KL 聚类损失稳定在 0.08 左右,量级差约 40 倍,那 λ 的合理起点就在 0.02 到 0.05 之间,实测 0.03 效果最好。这个数完全不能跨数据集复用,换个图就得重来。
这里有个特别容易忽略的问题:两个损失的学习动态不同步。对比损失通常在前期下降很快,一两百轮就基本平了;聚类损失在表示还没成形的时候几乎不动,等到表示稳定了才开始明显下降。如果用一个固定的 λ,前期对比占主导,后期聚类想发力的时候又被对比项的梯度压着。我的做法是用一个简单的线性 warm-up:前 100 轮 λ 从 0 线性增到目标值,之后保持。这个改动在三个数据集上都带来了 1 到 3 个点的提升,成本却几乎为零。
联合优化最大的风险是表示坍塌的变种:聚类损失会推着模型把表示往簇心收缩,如果簇心初始化不好,收缩的方向就是错的,而对比损失又不足以把它拉回来,最后双方妥协到一个既没有好簇结构、也没有好区分度的解上。表现出来就是两个损失都在降,但降到一个平台期之后指标怎么调都上不去。遇到这种情况,最有效的排查方式是把每一轮的表示拿出来算一下平均簇内方差,如果这个值单调下降且很快趋近于零,基本可以确认是往错误方向收缩了。
3.3 聚类引导的对比:让伪标签参与正负样本构造
第三条路是把聚类结果反过来喂给对比学习,用它来决定谁是正样本、谁是负样本。这是三条路里最"聪明"的一条,也是风险最高的一条。
逻辑上很诱人:既然聚类是我们的目标,那对比学习的正负样本就应该按聚类结果来定,同一簇的是正样本,不同簇的是负样本。这样对比信号和聚类目标完全一致,理论上不该有冲突。实际跑起来的问题在于冷启动。前面提过一次,这里展开说:训练初期的簇分配基本等于随机划分,按这个划分构造的正样本对里,有相当比例其实是不同类的节点被硬拉到一起,负样本对里也有大量同类节点被推开。模型会认认真真地学这些错误信号,跑个几十轮之后表示空间就被污染了,后面想纠正回来非常难。
可行的缓解手段我试过三种,效果从好到差排列:一是置信度阈值筛选,只对软分配最大概率超过某个阈值(比如 0.9)的节点参与簇级对比,剩下的节点仍然走节点级的通用对比;二是双信号融合,簇级对比的权重从 0 慢慢增上去,同时保留节点级对比作为兜底,避免完全依赖伪标签;三是周期性重算,每若干轮重新跑一次 KMeans 刷新簇分配,避免错误的伪标签被 self-training 一路强化。第三种做法能缓解累积误差,但如果初始簇分配错得离谱,刷新也救不回来。
我在一个类别边界非常模糊的数据集上试过纯粹的簇级对比(不带节点级兜底),结果十次实验的标准差大得离谱,最好的一次比基线高 4 个点,最差的一次低 6 个点。加上置信度筛选和节点级兜底之后,方差收敛了不少,均值也能稳定高于基线。所以结论很明确:簇级对比可以加,但绝不能单独用,它更适合作为一个增益项,而不是主力信号。
4. 负样本工程:假阴性、显存墙与增强过头的隐性退化
这一节大概是整篇里最能体现"跑过才知道"的部分。对比学习的论文里,负样本通常一笔带过,默认就是"batch 内其他样本",但真正决定你在自己数据上能不能跑出效果的,往往就是这些细节。
4.1 假负样本为什么在图上是高频事故
假负样本指的是这样一批节点:它们是负样本对里的"负方",但实际上和锚点属于同一个簇。InfoNCE 的梯度会把它们推远,这跟聚类目标直接冲突。在图像领域这个问题也存在,但图数据上尤其严重,原因是图里的同簇节点经常在特征上高度相似甚至几乎一样——同一篇论文引用链上的节点、同一个商品类目下的条目,特征向量长得非常像。模型被要求把两个几乎完全相同的向量推开,这在表示空间里是做不到的,只能通过扭曲其他维度来"凑"出一个距离,最终损害的是整个表示空间的质量。
我最早的实验里没有意识到这一点,直接把 batch 内所有其他节点当负样本,结果发现表示空间里出现了很多"细长条"状的簇——大类被切成了好几个细碎的块。后来分析才明白,这些被切碎的块之间特征太像了,模型为了让它们在对比损失里能被区分开,强行拉出了人为的距离。
缓解手段有几种,我用下来比较有效的两个:
- 相似度阈值剔除:在计算损失前,把与锚点余弦相似度超过某个阈值(比如 0.9)的负样本从分母里踢掉。实现上就是在 sim 矩阵上加一个 mask。这个操作很便宜,但对结果影响不小。
- 按簇分配加权:利用当前的软分配结果,给"大概率同簇"的负样本一个较小的权重,而不是直接剔除。这种做法比硬剔除更平滑,但依赖簇分配的质量,同样有冷启动问题。
还有个更根本的思路是不加负样本,走非对比的自监督路线,用预测头加停梯度来避免坍塌。这类方法在图上的表现……说实话不太稳定。小图上偶尔能跑出不错的结果,节点数一多就经常直接塌掉,原因是图数据的信息密度比图像低,预测任务太容易学。我个人的结论是:在没有大规模调参预算的情况下,老老实实做负样本对比更可靠。
4.2 显存墙:从 O(N²) 到采样近似
前面提过节点级全负样本的显存是 O(N²)。具体算一下:N = 100000,单精度浮点,相似度矩阵是 10^10 个元素,40 GB。这还不算反向传播要存的中间变量,实际占用是这个数的两三倍。所以十万节点以上的图,全负样本根本跑不了。
常见的绕法有这么几种,我按实际使用频率排:
子图采样。每个 epoch 随机采若干个子图(比如每次采 512 到 2048 个节点),在子图内部做对比。这样显存只跟子图大小有关,可控。代价是负样本只来自子图,全局的簇结构信息可能被漏掉。缓解方式是保证采样子图时不要破坏簇的完整性——按随机游走采样通常比纯随机采样好,因为随机游走更可能采到连通的局部结构。
队列维护动量负样本。维护一个负样本队列,把历史 batch 的表示存起来,每次从队列里取负样本。这样负样本数量可以远超 batch size,而且不增加当前 batch 的显存。这个做法在视觉领域的 MoCo 那一脉很成熟,搬到图上需要注意一点:图的节点表示会随着训练快速变化,队列里的老表示可能已经跟当前编码器脱节,用它做负样本相当于引入了噪声。队列长度不宜过大,几千到一两万比较合适,太大反而掉点。
随机负样本采样。每个锚点只随机采 k 个负样本(k 取 256 到 1024),而不是用全部。这是最省事的做法,效果损失也不大。我在一个八万节点的图上对比过:全负样本 vs 每锚点采 512 个负样本,ACC 差了不到 0.5 个点,但显存从爆掉变成了 8 GB 以内。
| 策略 | 显存占用 | 效果损失 | 实现复杂度 |
|---|---|---|---|
| 全负样本 | O(N²) | 无 | 低 |
| 子图采样 | O(s²),s 为子图规模 | 中 | 中 |
| 动量队列 | O(N·q),q 为队列长度 | 低到中 | 高 |
| 随机采样 k 个 | O(N·k) | 低 | 低 |
需要提醒的是,随机负样本采样虽然省事,但会让每个 epoch 的损失值波动变大,因为每次采到的负样本难度不同。如果你靠监控损失曲线判断收敛,最好把 batch 调大一点,或者多跑几个 step 再取平均,不然曲线抖得没法看。
4.3 增强过头:结构信息被削掉之后的隐性退化
图增强的强度和对比学习效果之间是一条倒 U 形曲线,这一点很多人在理论上知道,实际操作时却容易往强的那头偏。原因很朴素:增强弱了,两个视图太像,对比任务太简单,学不到东西;那就加强呗。但加强的代价在图上是非线性的。
拿边扰动举例。删掉 10% 的边,图的全局结构基本保持,局部邻居可能变了一两个,还算合理。删到 40%,很多低度数节点的邻居被删光了,变成孤立点,这些节点的表示就完全失去了结构信息,只剩特征。它们在对比损失里还照样被当成正样本对拉近,实际上两个视图里的这个节点已经不是同一个东西了。这种情况最隐蔽的地方在于,损失曲线看不出异常——孤立点的表示在两侧都被拉向同一个方向,损失正常下降,但学出来的表示质量很差。
特征掩码也是同理。掩掉 10% 到 20% 的维度,模型必须从剩余维度推断,是有意义的任务;掩掉 60%,剩下的信息量可能已经不足以区分不同类别的节点,模型只能靠猜,学出来的东西跟随机初始化差不多。
我自己的经验取值是:边扰动比例控制在 0.1 到 0.3,特征掩码比例控制在 0.1 到 0.3,两者不要同时取上限。另外一定要监控孤立点比例这个指标——增强之后度数为零的节点占比超过 5%,就说明边扰动过头了,得往回收。这个检查一行代码就能加,但能避免大量无效实验。
还有一个进阶做法是按重要性自适应地决定增强哪些边,而不是均匀随机。核心思路是:度数高的边、连接两个特征相似的节点的边,更可能是重要的结构边,少动或者不动;度数低的边、连接特征差异大的节点的边,更可能是噪声,优先扰动。这套自适应增强在好几个图上都比均匀随机稳定,代价是要多算一个边重要性分数,训练前一次性算好就行,不增加每轮开销。
5. 一份能跑起来的对照实验骨架
理论说再多不如给一份能直接改的代码。这一节给一个尽量精简但完整的实现骨架,包含图增强、双分支编码器、对比损失、聚类损失和训练循环。基于 PyTorch 和 PyG 的接口习惯写,没用到的部分我省掉了,你按自己的数据接口替换即可。
5.1 数据组织与两个视图的构造
import torch import numpy as np def random_edge_perturb(edge_index, num_nodes, drop_ratio=0.2): num_edges = edge_index.size(1) keep_mask = torch.rand(num_edges) > drop_ratio return edge_index[:, keep_mask] def random_feature_mask(x, mask_ratio=0.2): mask = torch.rand_like(x) > mask_ratio return x * mask.float() def build_two_views(x, edge_index, drop_ratio=0.2, mask_ratio=0.2): v1_x = random_feature_mask(x, mask_ratio) v1_e = random_edge_perturb(edge_index, x.size(0), drop_ratio) v2_x = random_feature_mask(x, mask_ratio) v2_e = random_edge_perturb(edge_index, x.size(0), drop_ratio) return (v1_x, v1_e), (v2_x, v2_e)这段代码有两个地方值得改。第一,边扰动删完之后最好做一次对称化处理,把(i,j)和(j,i)补齐,不然有向化之后 GCN 的聚合会偏。第二,特征掩码对稀疏特征(比如 one-hot 的词袋特征)要小心,掩掉的比例按非零元素算更合理,按全部维度算的话,稀疏特征可能被整片掩光。我在引文网络上就吃过这个亏,特征维度 1433 但每篇论文平均只有几十个非零,按 20% 全局掩码实际上把很多节点的特征全清了。
5.2 编码器与投影头
import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import GCNConv class GCNEncoder(nn.Module): def __init__(self, in_dim, hid_dim, out_dim, dropout=0.3): 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 ProjectionHead(nn.Module): def __init__(self, dim, proj_dim=128): super().__init__() self.net = nn.Sequential( nn.Linear(dim, dim), nn.ReLU(), nn.Linear(dim, proj_dim), ) def forward(self, z): return self.net(z)投影头这个东西看着多余,但它是有实际作用的:对比损失作用在投影空间,聚类损失作用在编码器输出空间,两个空间分开,能减少对比目标对聚类表示的过度干扰。这是视觉领域验证过的经验,我在图上试下来也确实有效——去掉投影头直接在同一空间做对比,聚类指标平均低 1 到 2 个点。
层数方面,两层 GCN 基本是所有图上的默认选择。三层及以上就要直面过平滑,除非你同时上残差连接或者跳跃连接。我试过四层带残差的版本,在稍大的图上比两层好一点点,但在小图上反而更差,因为参数变多之后过拟合明显。所以层数这件事,不要一上来就想加深,先把两层的效果做扎实。
5.3 损失拼装与训练循环
def cluster_loss(z, mu): # z: [N, d] 编码器输出,mu: [K, d] 簇心 q = 1.0 / (1.0 + torch.cdist(z, mu).pow(2)) q = q / q.sum(dim=1, keepdim=True) weight = q.pow(2) / q.sum(dim=0, keepdim=True) p = weight / weight.sum(dim=1, keepdim=True) return F.kl_div(q.log(), p, reduction='batchmean') def train(model, proj, x, edge_index, num_clusters, epochs=500, tau=0.5, lam=0.03, warmup=100): opt = torch.optim.Adam( list(model.parameters()) + list(proj.parameters()), lr=1e-3, weight_decay=1e-5) mu = None for epoch in range(epochs): model.train(); opt.zero_grad() (x1, e1), (x2, e2) = build_two_views(x, edge_index) z1, z2 = model(x1, e1), model(x2, e2) p1, p2 = proj(z1), proj(z2) l_con = 0.5 * (info_nce(p1, p2, tau) + info_nce(p2, p1, tau)) if epoch == warmup: with torch.no_grad(): kmeans_input = model(x, edge_index).detach().cpu().numpy() from sklearn.cluster import KMeans km = KMeans(n_clusters=num_clusters, n_init=20).fit(kmeans_input) mu = torch.tensor(km.cluster_centers_, dtype=torch.float32, device=x.device) if mu is not None: l_clu = cluster_loss(z1, mu) cur_lam = lam * min(1.0, (epoch - warmup) / 50) loss = l_clu + cur_lam * l_con else: loss = l_con loss.backward(); opt.step()这段里有几个地方是我踩过坑之后才加上的。warmup轮之前不初始化簇心,纯跑对比,让表示先有个大致形状;到 warmup 那一轮用 KMeans 在完整图上跑一次得到初始簇心;之后聚类损失才开始参与,并且权重用 50 轮线性爬升。这个三段式流程比一上来就两个损失一起加要稳得多,尤其在类别数较多的时候差异明显。
cluster_loss里我用了 cdist 直接算欧氏距离再取倒数,跟原版 t 分布的写法等价,但避免了手写平方和展开时的广播错误。这个错误我犯过一次,写成了(z.unsqueeze(1) - mu).pow(2).sum(-1)但维度搞反了,损失照样能算、照样能降,只是 softmax 的归一化轴错了,跑了两天才发现结果一直不对。这种静默错误最要命,建议写完先用一个小规模的假数据验证一下输出是不是每行和为 1。
5.4 评估:别只盯着 ACC 看
评估这块很多人只看 ACC,但 ACC 在类别不均衡的时候会严重失真。一个十类数据集,如果最大类占了 70%,模型把所有节点判成这一类,ACC 也有 0.7。所以至少要同时看 NMI 和 ARI,NMI 对簇的划分质量更敏感,ARI 对随机划分的惩罚更严格。
另外两个我必看的诊断指标:
- 簇大小分布。如果出现一个簇吞掉 60% 以上的节点,基本可以判定是坍塌的早期信号,即使 ACC 看着还行。
- 不同随机种子下的方差。深度图聚类对初始化非常敏感,我一直要求至少跑五次不同种子,报均值和标准差。如果标准差超过 2 个点,说明这个方法在这个数据上不稳定,不能拿来下结论。
6. 调参顺序和几条反直觉的实测经验
最后这块是我个人觉得最值钱的部分,因为大部分内容在论文里不会写,但直接决定你能否在有限时间内把效果调出来。
6.1 超参敏感度排序:先动什么
如果只能调三个参数,按这个顺序动:增强强度 > 对比损失权重 λ > 温度系数 τ。这个排序是我在五六个不同图上反复验证过的。
增强强度排第一,因为它同时影响对比任务难度和结构信息保真度,是唯一一个"调错了直接从有效变成无效"的参数。我的标准流程是先固定其他参数,把边扰动比例从 0.0 扫到 0.5(步长 0.1),特征掩码同步扫,看聚类指标的变化曲线。找到峰值区间之后,在这个区间内部细扫。多数图上峰值落在 0.1 到 0.3 之间,很少超过 0.4。
λ 排第二,因为它决定了两个目标谁主导。扫的范围我一般取 0.001 到 1 这五个数量级,每个数量级取两三个点。这里有个经验:如果最优 λ 落在你扫描范围的最边上,说明前面量级估计错了,得往外扩。如果 λ 在很宽的范围内效果都差不多(比如 0.01 到 0.1 之间差不超 0.5 个点),那说明这个数据集上对比信号的贡献本来就有限,加强增强或者换对比形态可能更有价值。
τ 排第三,因为它的影响相对平滑,而且跟其他参数有一定耦合。τ 和 λ 的交互比较明显:λ 大、τ 小的时候,对比项梯度很强,容易压制住聚类项的细化作用。所以调完 λ 之后一定要重新扫一遍 τ,不能沿用之前的结果。
隐藏层维度和学习率我一般不做精细搜索,256 或 512 差别不大,lr 在 1e-3 到 5e-4 之间选一个就行。倒是weight_decay值得留意——在图上,权重衰减设得太大会显著削弱对比学习的效果,因为它抑制了表示空间的展开。我通常设 1e-5 或者干脆设 0。
6.2 几条看起来违反直觉的实测结论
结论一:对比学习带来的提升,很大程度上体现在方差降低而不是均值提高。我在三个数据集上做过完整对照,加对比学习之后,ACC 均值提升了 2 到 5 个点,但标准差从 4 到 6 个点降到了 1 到 2 个点。也就是说,对比学习最大的贡献是让方法变得可复现、可调。这个观察改变了我做实验的方式——现在评估一个改动是否有效,我会先看五次运行的标准差,方差降了就认为是个正向改动,哪怕均值暂时没涨。
结论二:投影维度不是越大越好,128 往往比 256 好。这跟直觉有点冲突,按理说对比空间维度高一点信息量更大。实际测下来,128 维的投影头在多数图上比 256 维稳定。我猜的原因是维度太高时表示空间过于稀疏,负样本之间的区分度反而不明显,而且投影头参数变多之后训练更慢、更易过拟合。这个结论在三个图上一致,但样本量不算大,仅供参考。
结论三:先跑小图调参,再迁到大图,成功率比反过来高。大图跑一次动辄几小时,调参根本调不动。我的做法是在小图上把增强强度、λ、τ 这三个参数调到一个合理区间,直接迁到大图上,只微调 λ 一个参数。这个策略的隐含假设是"超参在相似结构的数据之间有可迁移性",在引文网络和共购网络这两类结构差异很大的图上,这个假设会被打破,迁移效果不好,但同类型图上迁移效果还行。
结论四:加对比学习之后,聚类头的最优层数会变。这个是意外发现。纯 KL 聚类的方法里,软分配层往往只接一层就够了;加了对比之后,有时候接两层反而更好,因为对比学习已经把表示空间整理得不错,多一层可以让聚类头做更细的非线性切分。不过这个提升不稳定,在部分图上反而是负收益,所以不作为默认配置。
6.3 两个高频故障的应急处理
故障一:训练后期聚类结果突然崩掉。表现是前 300 轮指标稳步上升,到某个点之后突然跳变,簇数实际变成了一两个。这个现象我在联合优化的设置里遇到过几次,根因基本都是簇心塌缩——KL 散度会不断锐化软分配,如果某个簇心的梯度更新太快,它会把周围的节点全吸走。应急处理有两个方向:一是给簇心加一个更新约束,每个 epoch 只允许移动一个小步长;二是加一个最小的簇大小惩罚项,簇小于某个比例时给一个额外损失。前者实现简单,我一般先用它。
故障二:对比损失正常降,但 KMeans 在学到的表示上效果很差。这种情况通常是表示空间被"拉平"了——区分度是有的,但不是簇级别而是节点级别。判断方法是把表示做一次 PCA 降到二维画散点图,如果能看到清晰的局部结构但整体上没有团块,基本就是这个情况。根因往往是负样本太容易了,模型只学了区分明显不同的节点,同簇节点之间反而没被拉紧。处理办法是降低 τ、提高增强强度、或者引入簇级对比作为补充信号。
顺便提一个工程习惯:每次改动之后,把当前最好结果的完整配置(超参、随机种子、代码版本)记录到一个表里,不要指望记事本或者脑子。我在做消融实验的时候丢过一次配置,导致一个关键结果没法复现,又重新跑了三天。这件事之后我把配置记录变成了硬性流程。
回头看这套东西,对比学习在深度图聚类里真正解决的问题,其实不是"效果更好"这么简单,而是把无标签条件下的表示学习从"依赖重构"这条单薄的路径上解脱出来,给了一个可以独立评估、独立调优的辅助信号。它带来的稳定性提升,在我看来比那几个点的 ACC 更有价值。至于要不要用、怎么用,我的判断标准一直很简单:如果你的基线在不同随机种子下波动超过 5 个点,先上对比学习压方差;如果已经比较稳但卡在瓶颈,那就去啃增强策略和负样本工程,这两块才是决定上限的地方。