三种文本分类模型对比与Python复现:TextGCN、TextING与LEAM解析
简介面向自然语言处理课程设计与期末项目这份资源复现了TextGCN、TextING、LEAM三种经典文本分类方法提供完整Python源码与详细注释。TextGCN基于图卷积网络TextING基于图神经网络LEAM则利用标签嵌入注意力机制三个模型均包含数据预处理、构图、训练与可视化等完整流程注释对关键函数和参数作了说明适合计算机、人工智能等专业学生作为课设作业参考也可供初学者对照学习模型细节。压缩包共91个文件包含32个Python脚本、10个PDF说明、多个ipynb笔记及npz/npy数据文件总大小约805MB目录按三种模型分别组织并附带README与环境说明便于快速运行。资源内代码已测试运行成功可直接修改扩展也可用于课程设计、毕业设计或项目初期演示。已有437人学习下载适合需要理解文本分类原理并动手复现的同学。1. 三种文本分类方法在同一份期末数据上凭什么能差出十个点做 NLP 期末大作业最常遇到的不是模型跑不起来而是三个模型都能跑效果却对不上论文。TextGCN、TextING、LEAM 是三种极具代表性的文本分类方法TextGCN 把整份语料建成一张文档-词大图走半监督传导式学习TextING 把每篇文档单独建词图新样本来了直接出结果LEAM 完全不建图用标签词向量引导文本里的注意力。同一份数据上这三套思路常常能拉开 510 个百分点的准确率差差距不在调参而在建模假设本身。这篇文章按数据准备、三个模型逐一复现、对比与集成展开每个模型都给出带注释的 Python 源码、关键参数和踩坑记录适合 NLP 课设与期末大作业复现也适合想快速横向验证这三条技术路线的工程师。所有代码基于 Python 3.10依赖越少越好能跑通的地方全部给出了最小命令。2. 复现前的数据准备与 Python 环境配置先固定词表和词向量2.1 文本分类数据集怎么选先把规模限死在够用的范围TextGCN 的构图开销随词表和文档数线性增长三套模型共用一份数据时最省心的选择是 20 个类别以内、1 万到 3 万条的短文本。中文场景我用的是 THUCNews 的 10 类子集英文可以直接用 sklearn 内置的 20Newsgroups。无论选哪个先把目录固定成下面三行格式后面所有代码都按这个格式读data/train.txt 标签\t文本 data/dev.txt 标签\t文本 data/test.txt 标签\t文本这里有一个容易被略过的细节dev 集不能省。TextGCN 的 epoch 数、TextING 的消息传递步数、LEAM 的 temperature三个模型都要靠 dev 来挑 checkpoint。没有 dev 时只能按训练 loss 存模型结果往往是训练集准确率很漂亮测试集直接掉下去。2.2 分词、固定词表与 OOV 处理的 Python 代码中文分词直接用 jieba停用词表随便找一份常见的 1000 词版本即可。词表固定是整个实验里最先必须完成的一步因为它决定三套模型的特征空间。下面这段代码同时输出词表和每个样本的 token id# text_utils.py import jieba from collections import Counter def load_lines(path): pairs [] with open(path, encodingutf-8) as f: for line in f: label, text line.rstrip(\n).split(\t, 1) pairs.append((int(label), text)) return pairs def tokenize(pairs, stopwords): result [] for label, text in pairs: words [w for w in jieba.lcut(text) if w not in stopwords and w.strip()] result.append((label, words)) return result def build_vocab(tokenized, min_count5, max_size20000): counter Counter() for _, words in tokenized: counter.update(words) vocab {PAD: 0, OOV: 1} for w, c in counter.most_common(max_size - 2): if c min_count: break vocab[w] len(vocab) return vocab逻辑说明load_lines按第一个\t切成 (标签, 原文)tokenize做分词并过滤停用词和空串build_vocab把词表压在前 20000 个词内。参数方面min_count5是经验值太小会让低频噪音词进入后面的 PMI 计算太大又会在短文本数据上制造大量 OOVmax_size20000是为 TextGCN 的单位矩阵特征准备的词表翻倍单位矩阵和邻接矩阵的内存会成倍上涨第 3 章会展开说这个约束。注意这里词表用的是 traindevtest 全部文本。TextGCN 论文本身就把测试文档放进图里因此这步对 TextGCN 是标准操作但如果同时要对比 TextING 和 LEAM建议再单独保存一份只用 train 构建的词表避免答辩被问“测试集有没有泄漏进词表”时答不上来。2.3 conda 创建 Python 环境与依赖锁定复现这三套模型不需要很新的 CUDAtorch 2.x 的 CPU 版本也能跑完全部实验只是慢一些。我用 conda 单独建环境避免把本机的 Python 环境搅乱conda create -n nlp_hw python3.10 -y conda activate nlp_hw pip install torch2.1.2 --index-url https://download.pytorch.org/whl/cu118 pip install torch-geometric gensim scikit-learn jieba如果用的是 VSCode记得在命令面板里执行 “Python: Select Interpreter”选中nlp_hw这个 conda env否则终端里能 import 的包在编辑器里照样报 ModuleNotFoundError这一步是 python 环境配置里卡住人最多的位置。依赖版本和用途如下包名版本建议用途torch2.1.x三套模型的张量运算与自动求导torch-geometric2.4TextGCN 的 GCNConv、TextING 的 GatedGraphConv 和 Batchgensim4.3训练 Word2Vec 词向量供 TextING 和 LEAM 使用scikit-learn1.3TF-IDF 计算与评估指标jieba0.42中文分词torch-geometric 的安装有个传统坑老版本依赖 torch-scatter、torch-sparse 等扩展包直接 conda 装容易编译报错。torch-geometric 2.4 之后多数算子已经内置先装 torch 再装 torch-geometric顺序不要反。2.4 用 gensim 在训练集上练一份 300 维词向量TextGCN 不需要预训练词向量它用单位矩阵当节点特征语义靠图结构传递。TextING 和 LEAM 则必须有词向量做节点初始特征和标签表示。期末项目里最可控的做法不是下载外部词向量而是就在训练集上自己训一份# train_w2v.py from gensim.models import Word2Vec from text_utils import load_lines, tokenize train_pairs load_lines(data/train.txt) stopwords set(open(stopwords.txt, encodingutf-8).read().split()) train_tok tokenize(train_pairs, stopwords) sentences [w for _, w in train_tok] w2v Word2Vec(sentences, vector_size300, window5, min_count2, workers8, epochs10) w2v.save(w2v_300.model)这里的参数含义vector_size300是主流默认值太小装不下语义太大对 LEAM 只会增加过拟合风险min_count2保留足够多的低频词来压低 OOV 比例epochs10对中小语料够用再多容易把向量拟合到训练集的词汇分布上。有了词表和词向量第 3 章到第 5 章的模型代码就有了统一输入。3. TextGCN 复现文档-词异构图、PMI 邻接与两层 GCN3.1 为什么 TextGCN 是传导式的整张图一次建好TextGCN 的核心是构造一张包含“词节点 文档节点”的异构图。文档和词之间有边权重是 TF-IDF词和词之间有边权重是正的点互信息 PMI。关键设计在于文档节点同时包含训练集、验证集和测试集训练时只有训练节点带标签却让所有节点在图里一起参与消息传递。这种“测试数据已经躺在图里”的学习方式就是传导式transductive。传导式带来的工程后果很直接拿到一篇训练时没见过的新文本不能说“直接推理”必须把新文档以及它包含的词节点挂到原图上或者单独跑一次只含新文本的小图前向。期末答辩时把这一点讲清楚比背模型结构更能证明你真的理解了 TextGCN这也是它和 TextING 最本质的分界。3.2 邻接矩阵构建TF-IDF 边权重与正 PMI 词边代码构建目标是产出两个集合边起点、边终点、边权重。用 scipy 的 coo_matrix 组装稀疏矩阵再转成 PyG 的 edge_index 和 edge_weight。TF-IDF 边直接让 sklearn 的 TfidfVectorizer 输出稀疏矩阵最省事# build_graph.py import math import torch from collections import Counter from sklearn.feature_extraction.text import TfidfVectorizer def doc_word_edges(docs, vocab, offset): # docs: 已经分词并用空格拼接的文本列表长度 文档数 vec TfidfVectorizer(vocabularyvocab, token_patternr\S, lowercaseFalse) tfidf vec.fit_transform(docs) # (n_docs, n_vocab) coo tfidf.tocoo() # 文档节点 id 从 0 开始词节点 id 整体偏移 offset避开文档节点 return coo.row, coo.col offset, coo.dataPMI 词边稍微麻烦。PMI 的定义是log(p_ij / (p_i * p_j))其中p_ij是两个词在滑动窗口内共现的概率p_i是单词出现概率。论文只保留 PMI 大于 0 的边因为负 PMI 在归一化后基本是噪音def pmi_edges(sentences, vocab, offset, window20, min_count5): word_freq, pair_freq, total_pairs Counter(), Counter(), 0 for sent in sentences: sent [w for w in sent if w in vocab] for i in range(len(sent)): word_freq[sent[i]] 1 seen set() # 同一窗口内同一对词只计一次防止重复共现虚高 for j in range(i 1, min(i window, len(sent))): pair (sent[i], sent[j]) if sent[i] sent[j] else (sent[j], sent[i]) if pair in seen: continue seen.add(pair) pair_freq[pair] 1 total_pairs 1 rows, cols, weights [], [], [] for (a, b), count in pair_freq.items(): if min(word_freq[a], word_freq[b]) min_count: continue p_ab count / total_pairs p_a word_freq[a] / sum(word_freq.values()) p_b word_freq[b] / sum(word_freq.values()) pmi math.log2(p_ab / (p_a * p_b)) if pmi 0: rows [vocab[a] offset, vocab[b] offset] # 无向图补反向边 cols [vocab[b] offset, vocab[a] offset] weights [pmi, pmi] return rows, cols, weights两个函数里的offset都是文档节点总数因为节点顺序被固定为“文档在前、词在后”。三个参数按数据调window20是 TextGCN 论文原值短文本可以降到 10长文本可以放到 25min_count5过滤掉只出现几次的偶发词这类词的 PMI 经常虚高只保留正 PMI 是论文做法负边参与归一化反而压低有效边的权重。3.3 两层 GCN 前向与单位矩阵特征没有预训练向量也能跑TextGCN 的节点初始特征就是单位矩阵每个节点一个 one-hot 向量。模型没有任何语义先验语义完全靠图的边结构和标签传播两者共同注入这也是它不需要预训练词向量的原因。实现直接用 PyG 的 GCNConv# textgcn.py import torch.nn.functional as F from torch_geometric.nn import GCNConv class TextGCN(torch.nn.Module): def __init__(self, num_nodes, hidden200, num_classes10): super().__init__() self.conv1 GCNConv(num_nodes, hidden) self.conv2 GCNConv(hidden, num_classes) def forward(self, x, edge_index, edge_weight): x F.relu(self.conv1(x, edge_index, edge_weight)) return self.conv2(x, edge_index, edge_weight)GCNConv内部默认加自环并按度做对称归一化所以外部不用再手工处理行列归一化。edge_weight不传时全部为 1这里必须传前一步算好的 TF-IDF 和 PMI 权重否则边权信息全部丢失准确率会显著下降。训练前先构造整图的 Data 对象N len(vocab) len(docs) data Data(xtorch.eye(N), edge_indextorch.tensor([rows, cols], dtypetorch.long), edge_weighttorch.tensor(weights, dtypetorch.float))torch.eye(N)是稠密矩阵N 在两万五左右时会占 2.5GB 内存。GPU 显存吃紧时把第 2 章的max_size降到 10000或者直接先用 CPU 跑通再换 GPU。PAD和OOV这两个特殊节点没有连边在图里是孤立节点不会影响消息传递。3.4 训练循环与 TextGCN 的四个关键超参数训练时每个 epoch 只做一次全图前向损失只算训练文档节点optimizer torch.optim.SGD(model.parameters(), lr0.02, weight_decay1e-4) for epoch in range(200): model.train() out model(data.x, data.edge_index, data.edge_weight) loss F.cross_entropy(out[train_idx], train_y) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 20 0: model.eval() pred out[dev_idx].argmax(1) print(epoch, round(loss.item(), 4), round((pred dev_y).float().mean().item(), 4))out[train_idx]能这样索引的前提是节点顺序固定前若干个是训练文档接着是 dev、test、词节点。顺序在构图时必须记死否则标签对不上节点。lr0.02用 SGD 是论文默认值换成 Adam 后学习率要降到 1e-3 量级否则前期 loss 抖动剧烈。参数推荐值影响GCN 层数2超过 2 层容易过平滑节点表示趋同hidden200小于 100 时图语义承载不够PMI window20越大共现边越多越小图越稀疏dropout0.5加在两层 GCN 输出之间weight_decay1e-4图模型对过拟合比序列模型更敏感两个高频坑一是 TF-IDF 边和 PMI 边拼接时顺序弄混edge_weight 是按边顺序对应的拼之前记下每部分边数二是换了 session 后忘记重载 edge_index导致图结构与词表对不上训练 loss 正常但准确率停在类别先验水平。4. TextING 复现每篇文档独立词图上的归纳式消息传递4.1 TextING 的建模假设和 TextGCN 差在哪TextGCN 是全语料一张图TextING 反过来一篇文档一张图。文档里的每个词是一个节点用滑动窗口的共现关系连边然后在这个小图上做若干轮 Gated 消息传递最后对全部节点特征做池化得到文档表示。因为训练和测试的图彼此独立TextING 是归纳式inductive模型新样本进来直接建自己的小图就能出结果这是它和 TextGCN 最本质的区别。这个差异直接决定工程取舍。TextGCN 每来一批新数据都要面对“挂回旧图还是整图重建”的选择TextING 完全没有这个问题天然适合线上单条预测。代价是 TextING 看不到跨文档的词共现信息在短文本数据集上通常比 TextGCN 低 12 个百分点换来的是可部署性。期末作业里把这段取舍写进报告比“我们用了图神经网络”有说服力得多。4.2 单文档词图构建与 Gated 消息传递代码先用 PyG 的 Data 把一篇文档的 token id 序列转成图# texting_graph.py import torch from torch_geometric.data import Data def doc_to_graph(token_ids, window3): n len(token_ids) src, dst [], [] for i in range(n): for j in range(i 1, min(i window, n)): src [i, j] dst [j, i] edge_index torch.tensor([src, dst], dtypetorch.long) return Data(xtorch.tensor(token_ids, dtypetorch.long), edge_indexedge_index)window3表示每个词只和它后面的两个词连边并补反向边。窗口越小图越稀疏消息传递越局部窗口超过 5 后短文本的图基本变成全连接池化出来的表示和均值向量没区别图结构的信息就浪费了。模型主体直接用 PyG 的 GatedGraphConv它封装了 Gated GNN 的消息传递循环# texting.py import torch import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import GatedGraphConv class TextING(nn.Module): def __init__(self, embed, hidden300, steps2, num_classes10, dropout0.5): super().__init__() self.embed nn.Embedding.from_pretrained(embed, freezeFalse, padding_idx0) self.proj nn.Linear(embed.size(1), hidden) self.ggnn GatedGraphConv(hidden, steps) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden, num_classes) def forward(self, batch, n_graphs, pad_mask): x self.dropout(self.proj(self.embed(batch.x))) # (总节点数, hidden) x self.ggnn(x, batch.edge_index) x x.clone() x[~pad_mask] -1e9 # padding 词不参与池化 out torch.full((n_graphs, x.size(1)), -1e9, devicex.device) out.scatter_reduce_(0, batch.batch.unsqueeze(1).expand_as(x), x, reduceamax, include_selfFalse) return self.fc(self.dropout(out))GatedGraphConv(hidden, steps)的steps是消息传递轮数短文本 2 轮就够3 轮以上信息已经在全图来回多趟边际收益很小。batch.batch是 PyG 的 Batch 在拼接多个图时自动生成的“节点属于哪篇文档”的索引池化就是按它分桶取最大值。pad_mask在数据加载时构造形状是总节点数padding 位置为 False。把 padding 词替换成-1e9而不是乘 0是因为乘 0 会让 max 池化在整行特征都为负时误选 padding 位置的 0。注意漏掉 pad_mask 是 TextING 复现里最常见的错误。padded 词没有连边经过消息传递后仍带着投影层的输出scatter max 会把它们当作正常节点典型表现是训练 loss 正常下降、验证集准确率长期不动。4.3 Batch 拼接、词向量初始化和三个必调参数训练时把所有文档一次性 Batch 成一张大图比循环单篇快一个数量级from torch_geometric.data import Batch def collate_graphs(graph_list): return Batch.from_data_list(graph_list)实际 data loader 会把每批文档先 padding 成等长再逐一转图最后Batch.from_data_list拼起来。文档长度不齐没关系Batch 会自动记录每个子图的边界。词向量用第 2 章训好的 Word2Vec 构建 embedding 矩阵freezeFalse表示训练时微调词向量数据量在一两万条时微调收益明显数据量再大为了防止过拟合改成freezeTrue。参数推荐值说明steps2消息传递轮数短文本 2 轮足够window3共现窗口过大会让图接近全连接hidden300词向量维度和 hidden 不一致时由 proj 对齐dropout0.5加在投影层和输出层优化器和 TextGCN 完全不同这里用 Adamlr 取 1e-3epoch 数 3050 就够收敛不需要 200 轮。TextING 没有单位矩阵那样的大稠密张量同样数据量下内存占用明显低于 TextGCNCPU 也能跑得舒服。5. LEAM 复现标签嵌入引导的文本 attention不建图也能竞速5.1 LEAM 的核心思想让标签语义参与词的加权TextGCN 和 TextING 都是图模型LEAM 走的是完全不同的路线。它认为一个词对分类结果的贡献取决于它和各个类别标签的语义接近程度。做法是把每个类别的标签文本映射成词向量和输入文本的词向量互算相似度相似度高的词拿到更大的 attention 权重最后把加权后的文本表示和标签向量做内积得到分类分数。这套设计的好处是模型轻、训练快、没有构图逻辑。对情感分析、新闻分类这类标签语义明确的场景LEAM 经常能追平甚至超过图模型而且它只需要词向量和 TextING 共用第 2 章训练好的那份即可。期末大作业里把 LEAM 当作“无图基线”和两个图模型对照可以直接回答“图结构到底带来了多少增益”这个问题。5.2 LEAM 前向传播CNN 特征、词-标签相似度与注意力加权代码先用第 2 章的词表和词向量把每个类别的标签文本转成向量均值作为标签嵌入的初始化# leam.py import torch import torch.nn as nn import torch.nn.functional as F def label_embed_init(word_segs_by_class, w2v, dim300): # word_segs_by_class[c] 是第 c 类全部训练文本分词后的词列表已拼接 init [] for segs in word_segs_by_class: vecs [torch.tensor(w2v.wv[w]) for w in set(segs) if w in w2v.wv] init.append(torch.stack(vecs).mean(0)) return torch.stack(init) # (C, dim)逻辑说明每个类的标签嵌入是该类训练文本全部词向量的平均。比随机初始化收敛更快也避免了“标签文本本身太短平均出来信息量不够”的问题。dim300必须和词向量维度一致。模型前向走四步查词向量、过一维卷积提取 n-gram 特征、和标签嵌入算相似度得到 attention、加权求和后与标签做内积class LEAM(nn.Module): def __init__(self, embed, label_embed, hidden300, conv_kernel3, temperature2.0, dropout0.5): super().__init__() self.embed nn.Embedding.from_pretrained(embed, freezeFalse, padding_idx0) self.conv nn.Conv1d(hidden, hidden, conv_kernel, paddingconv_kernel // 2) self.dropout nn.Dropout(dropout) self.label_embed nn.Parameter(label_embed) # (C, d) self.temp temperature def forward(self, x): # x: (B, L) 词 id w self.dropout(self.embed(x)) # (B, L, d) w F.relu(self.conv(w.transpose(1, 2))) # (B, d, L) w w.transpose(1, 2) # (B, L, d) s torch.matmul(w, self.label_embed.t()) / self.temp # (B, L, C) alpha F.softmax(s.max(dim-1).values, dim1).unsqueeze(-1) doc (w * alpha).sum(dim1) # (B, d) return torch.matmul(doc, self.label_embed.t())s.max(dim-1)取每个词对所有标签相似度的最大值代表“这个词和哪个标签最像”softmax 在词维上归一化得到 attention。temperature2.0让 attention 分布更平滑避免少数高频词一枝独秀调小会让注意力更“硬”风险更大。用nn.Parameter包装标签嵌入使标签向量随训练更新这是 LEAM 区别于普通 attention 机制的关键。注意label_embed.t()参与 matmul要求 label_embed 是 float 且和词向量同维度。最常见的报错是把标签嵌入初始化成了整数张量或者类别数写成了 batch size导致维度对不上。5.3 LEAM 的三个调参点和最容易过的拟合关卡参数推荐值说明conv_kernel3卷积窗口可以并列多个 kernel 模拟 n-gramtemperature2.0attention 平滑度越大越分散dropout0.5对过拟合最敏感的参数没有之一lr (Adam)1e-3相比两个图模型更容易欠拟合lr 可以略大LEAM 的模型体量最小最容易出现的是过拟合而不是欠拟合词表 2 万、训练数据才 1 万条时embedding 层占掉绝大部分参数。应对手段是freezeTrue固定词向量只更新卷积和标签向量或者把 hidden 从 300 压到 128。判断是不是过拟合看训练准确率和 dev 准确率的差超过 8 个百分点就该收紧模型容量。到这里三套模型的输入输出已经统一成(batch, num_classes)的 logits第 6 章可以直接在一个脚本里对齐评估。6. 三种文本分类方法的统一评估与 logits 平均值集成6.1 用同一份测试脚本对齐三套模型的输出三套模型训练完成后都保存验证集最优的 checkpoint在测试集上输出 logits。只要类别顺序来自同一份 train.txt三份 logits 就可以直接对比# evaluate.py import numpy as np import torch from sklearn.metrics import accuracy_score, f1_score, classification_report logits { textgcn: np.load(out_textgcn.npy), texting: np.load(out_texting.npy), leam: np.load(out_leam.npy), } for name, z in logits.items(): pred z.argmax(1) print(name, acc%.4f % accuracy_score(y_test, pred), macro-F1%.4f % f1_score(y_test, pred, averagemacro)) print(classification_report(y_test, logits[textgcn].argmax(1)))argmax(1)在第二维取最大值的下标。多分类评估里 macro-F1 比 accuracy 更能反映少数类表现TextGCN 在类别不平衡的数据上经常 accuracy 高、macro-F1 低因为它会把少数类节点的表示往多数类方向拉。6.2 复现过程中最常见的六类问题定位现象原因处理方式TextGCN CUDA 显存不足torch.eye 稠密矩阵太大词表上限降到 10000或改用 CPU 训练TextGCN 准确率像随机猜edge_weight 没传或索引错位检查边数是否和 weight 长度一致TextING 验证集不涨padded 词参与最大值池化池化前把 padding 位置替换成 -1e9TextING 收敛慢window 太大、图太密window 降到 3steps 降到 2LEAM 训练集好测试集差embedding 层参数过多freeze 词向量hidden 压到 128三模型结果无法对齐词表或类序不一致统一用 train.txt 生成的同一份 vocab6.3 一个立竿见影的集成技巧三个模型 logits 平均图模型和注意力模型的错误分布通常不重叠把它们的结果做概率平均大概率能拿到比最好单模型更高的准确率。注意先做 softmax 再平均不要直接平均原始 logits因为 TextGCN 的 logits 方差比其他两个模型大不少直接平均会被它的量级带偏probs [torch.softmax(torch.tensor(z), dim-1) for z in logits.values()] avg torch.stack(probs).mean(0) print(ensemble acc%.4f % accuracy_score(y_test, avg.argmax(1).numpy()))如果想让集成再进一步可以给三个模型配权重用验证集做小规模搜索。一个常见的经验起点是 TextGCN 0.4、TextING 0.3、LEAM 0.3数据偏短文本时 LEAM 的权重可以再拉高。保存 logits 时务必用同一个测试集顺序和同一个类标签顺序这里最隐蔽的错误是两份 npy 文件的行顺序不一致会让集成结果反而低于最好的单模型。本文还有配套的精品资源点击获取