文本分类是NLP里最经典的任务之一。从词袋、TF-IDF到CNN、LSTM再到BERT和各类预训练模型我见证过很多方案迭代。但有一篇论文我每隔一段时间就会翻出来重读就是题目为 Graph Convolutional Networks for Text Classification 的那篇经典工作。它发表于图神经网络快速发展的时期核心思路很朴素不要把一个文档当作孤立的词序列而是把整个语料库构造成一张图文档和词都是节点然后用图卷积网络做半监督节点分类。这个思路听起来不复杂但在当时的多个基准上它不需要预训练词向量、不做复杂微调就能超过主流的CNN和RNN。这篇论文对后来文本图学习方向影响很大不少后续模型都是它的变体。无论你刚接触图神经网络还是已经习惯用BERT处理文本我觉得这篇论文的构图和传播思路都值得完整拆一遍。下面我从问题动机、技术原理、实验设计到复现细节把我读论文和动手复现的体会完整过一遍。1. 这篇论文到底解决了文本分类的什么问题1.1 序列模型始终看不到的全局信息先聊一个我自己的观察。文本分类发展到现在最主流的两条路线一条是把文本当序列用CNN或RNN建模另一条是把文本当词袋用线性模型建模。CNN善于提取局部n-gram特征RNN善于建模序列依赖但它们的共同问题是视野有限。一条LSTM虽然能“记住”长期信息但训练中很容易在长文本上衰减CNN的卷积核再大也就是某几个连续词组成的模式。更关键的是这两种模型都只能看到当前文档内部的信息看不到整个语料库里词与词、文档与文档之间的全局关系。举个例子。语料里有一篇讲“苹果发布折叠屏手机”的文章还有一篇讲“折叠屏技术供应链”的报道。这两篇文档在词袋模型里可能是两坨完全独立的词分布但如果看全局统计“折叠屏”“供应链”“发布”这些词在大量文档里反复共现它们之间存在很强的语义关联。序列模型默认忽略这种跨文档的共现信号只有把全语料统计起来才能发现。Text GCN这篇论文的核心动机就是把这个以前被浪费掉的全局统计信息变成一个图结构喂给模型。1.2 把文档和词塞进同一张图这篇论文的建模方式非常直接把语料库里出现过的词和语料里的每一篇文档都当作节点。词与文档之间有一条边权重用TF-IDF表示词与词之间也有一条边权重用PMI表示。文档和文档之间没有直接边但文档A和文档B可以通过共同的词节点间接相连。这样一来语义相近的文档在图上的路径就很短标签信息就可以沿着图路径从一篇文章传到另一篇。这种设计解决了一个很重要的工程问题很少标记数据怎么办传统序列模型如果没有大量标注很容易过拟合而图模型天然支持半监督学习。哪怕只有几百个带标签的文档它们也会通过词节点把影响扩散到所有未标注节点模型在图上做一次前向传播就等于让每篇文档吸收来自整个语料库的消息。这个思路有点像社交网络里的兴趣偏好传播你不认识的人也可能因为共同好友和你兴趣一致。在我看来这篇论文能站住脚不只是因为它把文本变成图而是它把“词频”“逆文档频率”“点互信息”这些老统计量重新组织成图的拓扑让一套新模型吃到了老特征里的结构信息。这是它和后来很多“图模型花活”最大的区别。2. 构图与图卷积的数学细节2.1 节点和边的定义细节构图的第一步是确定词表。原文的做法是扫描整个语料库对每个词统计文档频率只保留出现次数达到阈值的词常见实践里这个阈值可以取5到10。频率太低的词对共现统计没有意义还会让节点数量爆炸、图矩阵变大。文档节点好理解每篇文档一个。边分为两类。第一类是词-文档边权重是TF-IDF值表示这个词对这篇文档的重要程度。第二类是词-词边权重是PMI值。PMI本身是信息论里衡量两个变量相关性的指标这里用它判断两个词在局部上下文里是否倾向于一起出现。论文里只保留PMI为正数的词对也就是说两个词如果几乎不共现或者负相关就不连边。这样做既能让图更稀疏也能避免把语义无关甚至对立的词强行拉到一起。有人会问文档和文档之间为什么不直接连边从数据上看文档之间直接连边需要定义文档相似度计算成本高而且容易让图变得过密通过词节点间接相连已经能让相似文档共享大量邻居信息足够流动。2.2 TF-IDF与PMI的计算方式TF-IDF的计算方法在论文里没有特别神秘的地方。词频TF通常取词在文档里出现的次数也可以用子线性形式做平滑比如 1log(tf)这是很多NLP实现里的常见变体。逆文档频率IDF取 log(N / df)N是文档总数df是包含该词的文档数。TF-IDF值越高说明这个词在当前文档里既频繁又具备区分度。它天然给构图中的“共享词”赋予不同权重让那些只在特定文档出现的领域词起更大作用。PMI的计算要更讲究。具体做法是设置一个滑动窗口比如窗口大小为20个词在整个语料的所有文档上滑动对窗口内任意两个不同的词记录一次共现。然后统计所有滑动窗口的总数以及每个词单独出现在窗口里的次数按下面的公式估算概率p(i,j) count(i,j) / win_nump(i) count(i) / win_numPMI(i,j) log( p(i,j) / (p(i)*p(j)) )当共现次数远大于随机独立出现的期望时PMI为正说明两个词存在强关联如果接近于随机共现PMI为零或接近零如果负相关PMI为负。论文选择PMI0作为连边条件在实操里我建议加一条PMI大于0就连边但不必再把PMI值做进一步归一化直接作为边的权重放到邻接矩阵里即可。2.3 两层GCN的传播公式有了邻接矩阵A之后需要做归一化。常用的是对称归一化A~ D^(-1/2) * A * D^(-1/2)D是度矩阵对角元素是每个节点的邻居权重之和。这个归一化的意义在于度大的节点不要过度影响别人同时避免某些低度节点的表示被稀释。从空间变换的角度看它等价于把邻接矩阵的行和列同时按度缩放让传播过程对节点度数不那么敏感。这也是GCN论文里最经典的拉普拉斯重归一化技巧。初始特征X怎么办原文直接使用单位矩阵作为节点特征意味着每个节点先用一个独立的one-hot向量表示自己不引入任何外部词向量。这是一个很聪明的选择既然模型的任务是端到端学习节点表示外部预训练词向量反而可能把语义先验和任务无关的信息带进来。GCN的第一层权重矩阵会把one-hot映射成低维表示之后通过图传播混入邻居信息。整个模型只有两层H1 ReLU(A~ * X * W0)Z softmax(A~ * H1 * W1)拿Z里文档节点对应的行做分类损失用交叉熵只对有标签的节点计算。为什么只堆两层而不是更多图卷积每一层都在扩大接收野层数太深时节点表示会不断向邻居的平均值靠拢最终所有节点变得难以区分这就是图神经网络里常说的过度平滑问题。两层结构刚好让每个文档节点聚合到一次词节点信息、再聚合一次邻域信息足够完成任务。3. 实验设计与结果究竟强在哪里3.1 半监督评估设定不搞明白会误判Text GCN这篇论文的实验设置延续了半监督分类的标准玩法。每个类别只保留少量训练样本常见设置是每类20个标注文档验证集每类再给几百个文档其余文档全部作为测试集。训练时只有这些少量文档的标签进入损失函数但图构建时所有文档节点都参与包括测试集。也就是说模型在训练阶段其实“见过”全图的拓扑结构只是不知道测试节点的标签。这是transductive半监督设定和线上推理inductive不同。这种设定在当时很典型它的好处是能让模型利用整个语料库的统计信息。如果你第一次接触这个领域不要把它理解为传统意义的监督学习否则会对结果产生误判。从实际复现角度看这种设定的主要意义在于小样本场景下文本分类模型很容易过拟合而GCN通过图传播等于隐式做了“标签扩充”。同一个词节点连接到多条文档边训练文档的标签会通过词节点扩散起到类似数据增强的作用。3.2 关键基准上的表现与我的复现体感原文在公开基准上做了大量对比包括规模较大的新闻语料、医学文献摘要、电影评论情感分类等任务。对比对象包括线性模型、TextCNN、LSTM、BiLSTM等。整体结论很清楚Text GCN在绝大多数任务上都能超过这些序列模型尤其在训练数据少、文本长度较短的任务里优势更明显它甚至不需要外部词向量而很多对比的序列模型用了预训练词向量仍打不过它。我自己复现时也发现在中小规模语料上Text GCN的收敛速度很快通常几十个epoch就能看到不错的验证集精度。原因不难理解图的传播让信息按拓扑结构扩散而不是按时间步一个个字看过去。在长文档场景它的提升幅度没那么夸张但依然不落后。需要说明的是这些结果我是基于论文公开图表和常见复现经验概括的具体数值会因数据划分、预处理方式不同有一些浮动。3.3 消融实验把每个组件的价值摆得很清楚论文里最有说服力的一部分是消融实验。作者把构图里的不同组件逐个关掉去掉PMI词-词边只保留词-文档边效果明显下降去掉TF-IDF权重把词-文档边变成二值边或改为词频效果也会变差把图卷积层换成普通的全连接层基本退化成词袋模型加一个MLP。这些实验直观地说明了文本图里的每一类边都在贡献不同信息。词-词边承载局部共现语义相当于把n-gram信息图论化词-文档边承载文档的主题分布是每个词节点向文档节点传递“这篇文章大概在讲什么”的依据而TF-IDF权重让不同词有了重要性差异避免高频停用词主导传播。对做工程的人来说这种可解释性很有价值如果任务效果不好可以回头检查构图而不是盲目换模型。4. 从零复现Text GCN的完整流程4.1 数据处理与构图代码骨架复现Text GCN并不需要特别复杂的框架技术核心是构图。我习惯按下面几步走对每个文档分词去停用词可选得到token列表统计词频过滤出现次数少于阈值的低频词构建词典得到词和索引的映射计算每个文档的TF-IDF向量设置滑动窗口统计词对共现次数计算PMI构建邻接矩阵并归一化构造节点特征矩阵X单位矩阵或等价的稀疏索引训练两层GCN下面是一个简化版的构图代码骨架基于Python和SciPy方便你理解每一步的意图from collections import Counter, defaultdict import numpy as np def compute_pmi(corpus, window20, min_freq5): # corpus: list of list[str] vocab Counter() for doc in corpus: vocab.update(doc) # 过滤低频词 vocab {w: c for w, c in vocab.items() if c min_freq} word2id {w: i for i, (w, _) in enumerate(vocab.items())} n len(word2id) window_count 0 single_count np.zeros(n) pair_count defaultdict(int) for doc in corpus: ids [word2id[w] for w in doc if w in word2id] # 以每个token为中心统计其窗口内的词对 for i, u in enumerate(ids): lo max(0, i - window // 2) hi min(len(ids), i window // 2 1) win_tokens set(ids[lo:hi]) window_count 1 for w in win_tokens: single_count[w] 1 for v in win_tokens: if v ! u: key (min(u, v), max(u, v)) pair_count[key] 1 # 构造PMI矩阵只保留正值 pmi np.zeros((n, n)) for (u, v), c in pair_count.items(): p_ij c / window_count p_i single_count[u] / window_count p_j single_count[v] / window_count val np.log(p_ij / (p_i * p_j)) if val 0: pmi[u, v] val pmi[v, u] val return word2id, pmi这段代码并没有做高维稀疏优化但它能让你看清PMI计算的本质。真正落到大语料上建议用矩阵运算或并行加速。4.2 稀疏矩阵与内存控制是复现第一道门槛很多第一次复现的人会栽在内存上。假设语料有2万篇文档、2万个词图节点总数是4万如果按稠密矩阵存就是4万乘4万的浮点矩阵16亿个元素直接内存爆炸。所以邻接矩阵必须用稀疏格式保存比如COO或CSR。代码里的pmi矩阵是稠密的只是演示用。实际做法是用dict或者coo_matrix记录非零位置。我建议构图时直接构建三元组row, col, value最后一次性转成scipy.sparse.coo_matrix再转csr_matrix。归一化可以在稀疏矩阵上做先算度向量再逐行做缩放。import scipy.sparse as sp def normalize_adj(A): d np.array(A.sum(axis1)).flatten() d_inv_sqrt np.power(d, -0.5) d_inv_sqrt[np.isinf(d_inv_sqrt)] 0 D_inv_sqrt sp.diags(d_inv_sqrt) return D_inv_sqrt A D_inv_sqrt在PyTorch里可以用torch.sparse.FloatTensor承接稀疏归一化邻接矩阵。训练时前向传播就是稀疏矩阵乘速度很快显存占用也远小于把邻接矩阵转成稠密张量。如果语料特别大还可以考虑把邻接矩阵存到磁盘训练前加载省掉每次实验都重新构图的时间。4.3 训练细节与超参选择训练部分相对直接。模型就是两层带权值的图卷积第一层输出维度通常取200左右激活函数用ReLU之后接dropout再进入第二层最后softmax分类。损失函数用交叉熵优化器用Adam。我复现时常用的一组超参是学习率0.02、dropout 0.5、隐藏层维度200、窗口大小20、epoch数200。需要说明的是这些参数属于论文和常见复现实践里的通用设置针对不同语料需要适当调整。比如情感分类这类短文本任务窗口可以减到10左右长文档任务窗口保持在20到30之间效果更稳。超参常见取值说明学习率0.02左右Adam优化器下较快收敛过大容易震荡dropout0.5两层图网络容易过拟合需要强dropout隐藏层维度200文本图节点数多维度太低表示能力不足窗口大小20词共现统计窗口10~30按任务调整epoch200按验证集early stopping不必死守数字训练时一定要按验证集做early stopping。我踩过的坑是直接训练到固定epoch数不保存最优模型结果后段过拟合导致最终结果差不少。正确做法是每个epoch算验证集准确率保存最高点对应的模型参数之后用测试集评估。5. 实操中踩过的坑与边界思考5.1 窗口大小与词表过滤的敏感性窗口大小是Text GCN里最敏感的超参之一。我建议把它当成一个需要调参的特征工程维度而不是随手定一个数。窗口太小比如5词共现统计只覆盖极短范围很多有语义关联但距离略远的词对连不起来窗口太大比如50又会让共现关系变得太泛像“的”“了”这类停用词如果没过滤干净会把大量语义无关的词拉进图。词频过滤阈值也一样。阈值太低低频稀有词会引入大量噪音边阈值太高一些领域内的关键专有词被丢掉尤其在小数据集上影响明显。我常用的做法是先按出现次数≥5过滤然后逐步上调到10或20观察验证集精度的变化趋势。这个调参过程比调模型结构高效得多因为它直接影响图的连通质量。5.2 标签泄漏与信息泄漏之争要分清读这篇论文时有个问题绕不开测试集文档节点在训练时也出现在图里。从严格意义上说测试文档的邻居信息是参与消息传递的这会让测试结果略“乐观”。这也是为什么很多后续从业者会把Text GCN归为transductive模型把它和inductive模型的对比看作两种评估协议的差异而不是简单的谁强谁弱。我自己的看法是不要把这个特征当成缺陷去回避而是要清楚自己的应用场景。如果你的业务是批量处理一个静态语料库比如给一批存量文章打标签Text GCN非常合适如果要处理不断涌入的新文章更好的是用GraphSAGE这类归纳式模型或者预处理阶段把新文档加入图再推理。我见过一些团队在线上直接调用训练好的Text GCN预测新文档效果明显变差原因就在于新文档在训练时根本没有对应节点。5.3 新样本冷启动与扩展方向针对新文档冷启动一个朴素的解决办法是把新文档作为临时节点更新它和词节点之间的TF-IDF边重新做一次归一化和前向传播。这个逻辑可行但每次新增文档都要重新计算归一化成本随图规模增长。实际工程里我更倾向训练一个归纳式编码器或者把图卷积模型和预训练语言模型叠加。方案类型适用场景Text GCNtransductive静态语料、低资源、可复现性强GraphSAGEinductive新文档持续增加、需要在线推理GCN预训练向量混合有大规模无标注文本或预训练资源GAT等注意力图模型变体希望邻居权重可学习、标签噪音较大后来出现的很多改进方向也值得关注用注意力机制替代固定传播权重可以让邻居的影响变成可学习的把词节点细分为实体节点、主题节点能纳入更丰富的语义单元将BERT生成的上下文向量作为初始特征再配合GCN聚合在不少任务上能取得比单独使用任一模型更好的效果。这些方向都建立在Text GCN这个基准之上先把它完全吃透再往前走会轻松很多。我自己在实际操作中的体会是Text GCN不是要跟BERT这类大模型对抗它更适合低资源场景、静态语料、以及对可解释性有要求的任务。你在小语料上跑一遍就会真切感受到“全局统计图传播”有多能打。最后分享一个我常用来节省时间的小技巧归一化后的邻接矩阵可以以稀疏格式存成npz文件之后每次训练直接加载配合固定的词表和随机种子实验结果的可复现性也会高很多。复现卡住时优先检查构图逻辑里是否用了稠密矩阵、窗口统计是否有重复计数、训练是否保存了最优验证模型这三个地方解决掉基本就能跑通。