简介:面向自然语言处理课程期末大作业的Python项目包,完整复现TextGCN、TextING和LEAM三种经典文本分类模型,适合计算机相关专业在校生、教师及入门者参考学习。压缩包共91个文件,约806MB,核心为32个Python源码文件,涵盖模型构建、训练测试与可视化等模块;另含10个PDF文档、5个Jupyter Notebook及配套数据文件,便于对照实验与修改调试。项目代码已通过运行验证,作者可提供远程讲解,已有437人学习/下载。代码注释详细,并附README说明,覆盖数据预处理、文本图构建、模型训练到评估的全流程,既能支撑期末作业或项目立项演示,也可进一步扩展用于课程设计、毕业设计等场景。
1. NLP 期末大作业复现三件套:TextGCN、TextING、LEAM 从跑通到改明白
做 NLP 课程项目最怕的不是没思路,而是调了一周环境、跑通一个 baseline 就发现时间不够了。这份期末大作业源码一次性给了三种文本分类方法的完整复现:TextGCN、TextING 和 LEAM,而且不是那种只贴核心代码的阉割版,是带数据预处理、建图、训练、可视化、评估的完整工程。对正在做课设、准备毕设或者想入门图神经网络做文本分类的人来说,这份代码最大的价值在于:你能看到三种完全不同的建模思路在同一个数据集上是怎么组织代码、怎么调参、怎么出结果的。我拆完这份源码的目录结构和全部核心脚本,把三种模型的原理、运行流程、参数含义和容易翻车的地方都过了一遍,下面直接按模型逐个说透。
2. TextGCN:先把词-文档异构图建明白,后面才不会白跑
2.1 TextGCN 在做什么:从词共现到图传播
TextGCN 的核心思想是把整个语料库建成一张异构图,节点有两种类型——词节点和文档节点。词与词之间用 PMI(点互信息)衡量共现关系,词与文档之间用 TF-IDF 衡量词对文档的重要程度。分类任务在图上变成了节点分类问题,用两层 GCN 做消息传递,把邻居节点的信息聚合到当前节点上,最后一层输出每个文档节点的类别概率。
这份源码里 TextGCN 目录下的build_graph.py就是整个模型的基石。它做的事情可以拆成三步:第一步统计词频和文档词频,第二步计算 PMI 和 TF-IDF,第三步构建邻接矩阵。代码里有一个非常关键的参数window_size,控制词共现的滑动窗口大小,直接影响 PMI 的计算结果。
# build_graph.py 中 PMI 计算的核心逻辑(节选) def compute_pmi(window_size=20): word_pair_count = defaultdict(int) # 滑动窗口统计词对共现次数 for doc_words in docs_words: for i, word in enumerate(doc_words): for j in range(i + 1, min(i + window_size, len(doc_words))): word_pair_count[(word, doc_words[j])] += 1 return word_pair_count这里的window_size默认是 20,意思是说在一个文档内,两个词只要在 20 个词的距离内出现过一次,就算一次共现。这个值不是越大越好——窗口太大,语义上不相干的词会被强行拉上关系,图会变得非常稠密;窗口太小,词与词之间的长距离依赖又抓不到。我一般会在小数据集上从 5 开始试,逐步加到 20,观察验证集准确率的变化趋势再定。
2.2 build_graph.py:构图参数与邻接矩阵的坑
构建邻接矩阵的时候,代码里有一个容易忽略的细节:自环(self-loop)是手动加的,对角线元素初始化为 1。这在 GCN 里是标准操作,因为如果不加自环,节点自身的特征在消息传递过程中会被邻居信息稀释掉,甚至完全丢失。
# 邻接矩阵构建,注意对角线加自环 adj = sp.csr_matrix((data, (row, col)), shape=(node_size, node_size)) adj = adj + sp.eye(adj.shape[0]) # 加自环另外一个实际运行中一定会遇到的坑是文档长度过滤。源码里remove_words.py会做停用词去除和低频词过滤,但不会自动处理空文档。如果一个文档的所有词都被过滤掉了,docs_words里会出现空的 list,后续统计 TF-IDF 时会出现除零或者维度错位的问题。我跑的时候在构建词表前加了一个判断:文档长度小于 1 的直接丢弃,或者保留一个<UNK>占位符,两种做法都可以,看你的数据集噪声程度。
构图阶段还有个内存问题。如果数据集比较大,邻接矩阵的稀疏表示会膨胀得很快,sp.csr_matrix虽然省内存,但构建过程中如果用的是 dense 的二维列表去填充,内存会直接爆掉。源码里数据是用三个平行列表data、row、col收集的,这个写法是对的,千万不要自己改成二维数组再转稀疏矩阵。
2.3 train.py 里调什么:超参、早停与随机种子
TextGCN 的train.py里核心超参不多,但每个都直接影响结果。第一是隐层维度,默认是 200,这个值在中小规模数据集上够用,如果分类类别非常多,可以适当加到 300。第二是 dropout,默认 0.5,这个值在图网络上算是比较激进的,如果训练集准确率上升但验证集抖动厉害,可以降到 0.3。
# train.py 中模型初始化和训练的关键参数 model = GCN( nfeat=adj.shape[1], # 输入特征维度,等于节点数 nhid=200, # 隐层维度 nclass=num_classes, # 分类类别数 dropout=0.5 # dropout 比例 )有一点要特别说明:TextGCN 的输入特征矩阵是单位矩阵I,也就是说每个节点用 one-hot 向量表示自己的身份,不加载预训练词向量。这是 TextGCN 的一个设计特点——它完全靠图结构来传播语义信息,而不是靠词嵌入。所以如果你在思考"能不能换成 GloVe 初始化",答案是能,但那已经不是 TextGCN 了,效果也不一定更好。
训练时源码没有做早停(early stopping),只固定了epochs=200。这在复现时是个隐患——不同数据集收敛速度差异很大,有的 80 个 epoch 就到顶了,之后开始过拟合;有的 180 个 epoch 还在涨。我一般会在训练循环里加一个简单的早停:验证集准确率连续 20 轮不提升就保存当前最优模型并终止。
2.4 跑通一次的完整流程
TextGCN 整个流程从数据准备到训练结束,顺序比较固定,建议按下面的步骤走一遍,先确认环境没问题再改参数:
# 第一步:预处理原始文本,生成干净的词序列 python prepare_data.py --dataset 20ng --output_dir ./data # 第二步:构建词-文档异构图 python build_graph.py --dataset 20ng --window_size 20 --threshold 5 # 第三步:训练 GCN 模型 python train.py --dataset 20ng --epochs 200 --dropout 0.5--threshold控制低频词过滤的阈值,默认是 5,意思是词频低于 5 的词直接丢掉。这个值直接影响图的规模:阈值越大,词表越小,图越稀疏,训练越快,但语义信息丢失也越多。我在小数据集上一般用 2 或者 3,数据集上万篇文档才用 5。
3. TextING:单词级图网络的另一套思路
3.1 TextING 和 TextGCN 的本质差别
TextGCN 建的是整个语料库级别的全局图,文档和文档通过共享的词节点间接相连,一次训练就要把整张图加载进显存。TextING 的思路完全反过来——每个文档单独建一张图,图的节点是文档里出现的单词,边是根据词共现关系构建的,每次只处理一个文档的图,属于 inductive 学习,新文档来了不需要重新构图。
这个差异在实际使用中的影响非常直接:TextGCN 训练时动辄几十 GB 内存,TextING 单文档建图,内存占用小得多。源码里 TextING 目录下的build_graph.py和train.py是配套的,数据格式和 TextGCN 也不太一样,需要先跑一遍数据预处理。
3.2 remove_words.py 与数据预处理管道
TextING 的预处理核心是remove_words.py,它做两件事:加载停用词表过滤无意义词,以及生成词表文件。源码里默认用的是nltk的英文停用词表,如果你处理的是中文文本,这一步要替换成中文停用词表,否则分词后的虚词、语气词全都会留下,噪声非常大。
# remove_words.py 中停用词过滤逻辑 from nltk.corpus import stopwords stop_words = set(stopwords.words("english")) def clean_words(doc): words = doc.lower().split() return [w for w in words if w not in stop_words and len(w) > 2]注意len(w) > 2这个过滤条件,它会把所有单字母和双字母词都丢掉。英文里 "AI"、"IT" 这种有意义的双字母词会被误杀,中文场景下更是完全不适用。我自己的做法是把长度过滤拆出来单独控制,词表构建时再统一处理,不要在清洗阶段一刀切。
3.3 train.py 的迭代逻辑与 K 跳邻居参数
TextING 的train.py里最核心的参数是图卷积层的层数和 K 跳邻居的范围。模型用的是 Gated Graph Neural Network(GGNN),每一轮迭代相当于信息在图上多传播一跳。
# TextING train.py 中的模型初始化 model = TextING( vocab_size=vocab_size, embed_dim=300, num_layers=3, # GGNN 迭代轮数,相当于 3 跳消息传递 num_classes=num_classes )num_layers=3是论文里的标准配置,意味着一个单词节点能接收到 3 跳范围内的单词信息。这个值不能盲目加大——图神经网络普遍存在过度平滑问题,层数太多会让所有节点的表示趋于一致,分类效果反而直线下降。如果你的文本长度普遍在 50 词以内,2 到 3 层就够了;文本特别长、语义跨度大,才考虑加到 4 层。
另一个容易出错的地方是批次处理。TextGCN 是一整张图喂进去,不存在批次概念;TextING 每个文档一张图,batch 大小控制的是同时处理多少个文档图。源码里默认 batch size 是 64,如果你的显卡显存不够,调到 32 或者 16 都可以,收敛速度会慢一些但结果不会差太多。
4. LEAM:标签语义嵌入的轻量方案
4.1 标签嵌入为什么有效:GloVe 与标签向量的对齐
第三种方法 LEAM 和前两种完全不同,它不走图结构,而是把分类标签本身当成文本去做嵌入,然后计算词序列和标签嵌入之间的交互注意力。思路很直觉:如果文档里出现了和标签语义接近的词,这些词应该获得更高的注意力权重。比如体育类新闻里频繁出现"得分"、"比赛"、"球员",这些词在和"体育"标签计算相似度时天然会偏高。
实现上,LEAM 需要先加载预训练词向量,源码里用的是 GloVe。核心逻辑是:把每个标签的文本描述(比如"体育")通过 GloVe 变成向量,再和文档里每个词的向量做内积归一化,得到注意力权重。
# LEAM 注意力计算的核心逻辑(节选) def label_attention(word_embeddings, label_embeddings): # word_embeddings: [seq_len, embed_dim] # label_embeddings: [num_labels, embed_dim] attention = torch.matmul(word_embeddings, label_embeddings.t()) attention = attention / math.sqrt(embed_dim) weights = torch.softmax(attention, dim=1) return weightsmath.sqrt(embed_dim)是缩放因子,目的是防止内积值过大导致 softmax 进入饱和区。这个细节在很多复现里容易被忽略——不加缩放模型也能训练,但收敛速度和最终准确率都会有明显下降。GloVe 版本建议选 6B 的 300 维版本,兼容性最好,如果磁盘空间紧张,100 维版本也能用,但需要同步修改代码里的embed_dim,否则加载时会直接报维度不匹配。
4.2 glove_generate.py 与 embed 文件怎么生成
LEAM 目录下的glove_generate.py负责生成训练集中所有词的词向量矩阵。它做的事情是从下载好的 GloVe 原始文件里,把词表里每个词对应的向量抽出来,存成 numpy 格式。这一步跑完会得到一个.npy文件和一个词表文件,后续训练时按索引取向量即可。
# 先下载 GloVe 6B 预训练词向量到自己目录 # 然后执行转换脚本,生成模型可用的嵌入矩阵 python glove_generate.py --glove_path ./glove.6B.300d.txt --output_dir ./embed这个脚本本身逻辑不复杂,但有个很现实的坑:GloVe 原始文件的词表和你的数据集词表交集之外的词怎么办?源码里的处理方式是随机初始化一个向量补齐,这意味着 OOV(Out of Vocabulary)词的表示是随机的。如果数据集领域比较专业,OOV 比例可能超过 10%,会拖累效果。常见做法是冻结已加载的 GloVe 向量,只对 OOV 词开放训练更新,或者所有向量都参与微调。两种做法代码里没有显式区分,需要在模型的forward里手动控制requires_grad。
4.3 main.py 多分类入口的参数说明
LEAM 的训练入口是main.py,支持二分类和多分类两种模式。源码里main_multiclass.py和main.py的区别就在于数据集格式和损失函数配置,多分类用交叉熵,二分类用带 sigmoid 的 BCE。实际使用时先看你的标签文件是一维整数还是 one-hot。
# 多分类任务的标准启动命令 python main_multiclass.py --dataset yahoo --embed_path ./embed --batch_size 128 --lr 0.001batch_size=128和lr=0.001是一组比较稳的组合。LEAM 的收敛速度比图模型快很多,一般在 20 到 30 个 epoch 内就能到最佳效果,如果训练集准确率快速冲高但验证集不动,优先检查是不是数据划分出了问题,而不是盲目调学习率。源码里的数据划分用的是随机切分,没有做分层采样,遇到类别不均衡的数据集,小类别的文档可能全部被切到训练集或测试集,导致验证结果忽高忽低。
5. 复现中的高频报错与避坑记录
5.1 现象一:构图阶段内存直接爆掉
跑 TextGCN 的build_graph.py时,数据集稍微大一点,进程直接被系统 kill 掉,看日志没有任何报错。
原因:window_size设置过大,加上低频词过滤阈值太低,词对共现数量呈组合级增长,稀疏矩阵构建过程中中间表示占满了内存。
解决:先把--threshold从 5 提到 10,减少词表规模;再把--window_size从 20 降到 10,观察内存占用变化。如果还不行,用分块构建策略,把文档列表切成几段分别统计词对,最后合并字典。我处理 20ng 数据时就是按 4 块分片统计,内存峰值降了约 60%。
5.2 现象二:词向量维度不匹配,加载 GloVe 直接报错
LEAM 训练时加载预训练词向量,提示embedding weight size mismatch,模型里定义的embed_dim=300,但实际加载的向量是 100 维。
原因:glove_generate.py转换时用的源文件是glove.6B.100d.txt,但模型配置还是 300 维,两边没对齐。
解决:统一维度,要么把模型里embed_dim改成 100,要么重新生成 300 维的嵌入文件。这个错误本质上是配置文件和生成脚本的参数不同步,我习惯在glove_generate.py的输出文件名里直接写进维度信息,比如embed_100d.npy,这样加载的时候一眼就能看出问题。
5.3 现象三:训练 loss 乱跳,验证集准确率几乎不变
TextGCN 和 TextING 都遇到过类似情况,loss 曲线像锯齿一样上下起伏,验证集准确率在某个值附近反复横跳。
原因:学习率偏大导致参数更新步长过大,在最优解附近来回震荡;或者 dropout 设置过高,训练阶段信息丢失太多,模型一直都在"重新学"。
解决:把学习率从默认的 0.01 降到 0.005 或者 0.001,同时把 dropout 从 0.5 降到 0.3。改完这两个参数,大部分情况下 loss 曲线会平滑很多。如果还很剧烈,检查批次大小是不是过小,TextING 的 batch size 小于 16 时,梯度噪声会非常大。
5.4 现象四:复现结果和论文报告的数字差太多
复现三种模型后,准确率比论文里低了 3 到 5 个百分点,反复调参也没用。
原因:最常见的是数据划分不一致——论文里用的是官方划分好的 train/test 集,而自己跑的时候是随机切分,类别分布完全不同;其次是数据清洗力度不同,有些复现会额外做词干提取或者词形还原,这些预处理都会影响最终指标。
解决:先确认数据集是否有官方划分,有就严格按官方划分跑;没有官方划分就固定随机种子并记录划分方式,保证三个模型在同一份划分上对比。模型间的相对差异比绝对数值更有参考价值,TextGCN 比 TextING 高 2 个百分点,这个结论才是复现中最该关注的。
5.5 现象五:TextGCN 训练时显存溢出
3060 显存 12G 跑 TextGCN 报 CUDA out of memory,但数据规模看起来并不大。
原因:TextGCN 的输入是整张邻接矩阵和单位矩阵,adj.shape[1]等于节点总数,节点数上万时,特征矩阵本身就是上亿的规模,GPU 显存很快就吃满了。
解决:不要用 GPU 训练 TextGCN,直接用 CPU。GCN 的前向传播以稀疏矩阵乘法为主,CPU 跑反而没有显存瓶颈,速度慢一些但能跑完。如果数据特别大,考虑降采样——只保留词频最高的前 8000 个词节点,文档节点全保留。
6. 验证与对比的三个硬习惯:固定划分、可视化、结果留痕
三种模型都跑通之后,最容易犯的错误是直接拿各自的最佳结果放在一起比,这样对比出来的结论根本没有说服力,因为三个模型的实验条件可能完全不同。我的做法是固定一套数据划分,用同一个随机种子生成一组 train/test 索引,三个模型的训练和测试全部在这组索引上进行。TextGCN 和 TextING 的代码里都支持导入外部索引文件,LEAM 则是控制random_seed参数,把seed设为同一个值即可。
第二个习惯是看注意力或梯度可视化,而不是只看准确率数字。LEAM 的visualize.py脚本可以输出每个文档里不同词的注意力权重,我跑完之后会随机抽几个预测正确的文档,观察是不是真的把注意力放在和主题相关的词上。如果模型预测对了但注意力权重完全随机分布,说明它可能学到了数据里的偶然模式,这样的模型换数据表现会很不稳定。TextGCN 的visualize_words.py则是构建词权重热力图,用来确认哪些词在类别判定中贡献最大。
第三个习惯是结果留痕。每次实验结束,把三个模型的准确率、F1、训练时间、显存占用、调参记录一起写进一个 CSV 文件。不要只在终端看一眼就关掉,后面写课程设计报告或者答辩的时候,这些数据就是你论证方法有效性的全部依据。下面是一个我常用的记录模板:
| 模型 | 准确率 | Macro-F1 | 训练时间 | 显存占用 | 关键参数 |
|---|---|---|---|---|---|
| TextGCN | 0.813 | 0.802 | 45min | CPU | window=20, threshold=5 |
| TextING | 0.796 | 0.785 | 12min | 2.1G | layers=3, batch=64 |
| LEAM | 0.807 | 0.791 | 8min | 1.8G | GloVe300, lr=0.001 |
对比表格一出来,三种方法的差异一目了然:TextGCN 效果最好但代价是训练时间最长、无法快速适配新样本;TextING 训练快但需要逐文档构图;LEAM 效率和效果平衡最好,适合快速迭代实验。这样的对比分析放在课设报告里,比单张准确率截图要有说服力得多。
我最初跑这份源码的时候,也是先把三种模型的默认参数全部跑了一遍,然后在验证集上记录结果,再逐个调参。印象最深的是 TextGCN 内存爆掉那次,折腾了整整一个晚上才意识到是threshold设得太低、词表膨胀导致的。从那以后,每换一个数据集,我都会先打印词表大小和图的边数,确认规模在合理范围内再开始训练。这个习惯帮我省掉了大量反复试错的时间。希望这次拆解的这些细节也能帮到你,让你少走几个弯路。
本文还有配套的精品资源,点击获取