简介:这是一份基于深度学习的自动文本分类系统完整源码包,面向希望掌握Python自然语言处理与深度学习分类流程的开发者。系统整合NLTK文本预处理、特征向量化,以及CNN/RNN/LSTM等模型训练与预测模块,可应用于垃圾邮件识别、情感分析、新闻分类等场景。压缩包共37个文件,主要由16个Python源码文件、8个Shell脚本、5个C文件及辅助配置文档组成,py文件覆盖数据转换、词向量训练、模型导出到预测等核心流程,sh脚本用于自动化部署与数据处理,整体仅121KB。已有350人学习下载。通过研读项目文件,可以理解从原始文本到TFRecords格式转换、fastText与HAM等模型实现、以及模型评估的完整工程结构;附带的requirements与readme还能帮助快速搭建运行环境。适合作为NLP课程设计、文本分类任务入门或毕设改造的参考资料。
1. 深度学习自动文本分类:别急着选模型,先把这条数据管线跑通
很多刚接触自动文本分类的人,第一反应是去搜哪个模型效果最好,结果在TextCNN、TextRNN、LSTM、FastText之间来回摇摆,最后卡在数据预处理上两三天动不了。这套基于Python和NLTK的源码给我的感觉恰恰相反:它把“文本分类”这件事当成一条完整的流水线来设计,从原始文本到TFRecord,再到词向量、模型训练、导出和预测,每一步都有独立脚本。里面确实包含CNN、RNN、LSTM、FastText和一个HAM模型,但真正值钱的是那套能把数据喂给模型的标准流程。适合做垃圾邮件识别、情感分析、新闻分类这类需要快速落地、又希望对比多个深度模型的场景。我拆完这套源码后最大的感受是:模型超参可以慢慢调,数据管线的坑如果没填平,后面全白搭。
2. 先把37个文件理成一条主线:从原始文本到TFRecord再到生产预测
拿到这种源码包,第一件事不是打开模型文件,而是先把文件清单过一遍,分清哪些是入口、哪些是数据准备、哪些是模型定义、哪些是辅助配置。这套包从文件名看结构很典型,我把它分成五组,对应五类职责。
2.1 文件分组:数据、词向量、模型、入口、配置
数据相关的是upload.zip、train目录、data_utils.py、text_to_tfrecords.py、create_map_file.py。upload.zip大概率是原始语料压缩包,train目录可能是整理好的训练数据或者某个子模块。data_utils.py负责文本清洗、分词、构建词表这些脏活,text_to_tfrecords.py把清洗后的文本序列化成 TFRecord,create_map_file.py生成标签映射表,也就是把“类别名”和“整数id”对应起来。
词向量相关的是word2vec.py和third_party下的word2vec目录,以及prepare_vec.py。word2vec.py用于训练词向量或者加载预训练好的向量,prepare_vec.py则把词向量与TFRecord里的token id对齐,这一层很容易出问题,后面避坑章节会细说。
模型部分都在models目录下:text_rnn.py、text_cnn.py、c_lstm.py、fasttext.py、HAM.py。每个文件对应一个独立的模型类或构建函数,__init__.py把它们包装成统一的导入接口。入口和流程脚本是train.py、export_model.py、predict.py、main.sh。train.py控制训练循环,export_model.py导出固化模型,predict.py对新文本做推断,main.sh把整个流程串起来。
剩余的是requirements.txt、LICENSE、.gitignore、readme.txt这类标准工程文件。requirements.txt建议先看,它决定了你能不能一键把环境装起来。别小看辅助文件,很多时候环境装不上就是因为你跳过了它。
2.2 数据流主线:五个脚本按顺序执行
把文件分组后,主线就清晰了。我按常见执行顺序整理了一下,这套流程和现在TensorFlow的标准做法基本一致:
create_map_file.py扫描训练数据,生成从原始类别名到整数id的映射文件,比如{"sports": 0, "tech": 1}。data_utils.py被其他脚本调用,负责读取upload.zip解压后的文本,做分词、去停用词、去特殊符号。text_to_tfrecords.py把清洗后的token序列加上标签,写成TFRecord二进制文件。这一步等于把文本变成了模型能直接读的固定维度样本。word2vec.py在全部语料上训练词向量,或者读取预训练向量;prepare_vec.py把词向量表按词表顺序重排,确保token id和向量行号一一对应。train.py加载TFRecord、初始化模型、开始训练;训练完export_model.py把checkpoint固化成可直接部署的模型文件;predict.py加载固化模型,对输入文本输出分类概率。
main.sh的作用就是把上面这些环节串成一个shell脚本。如果你在Linux上跑,一般是一行一个脚本调用,中间用&&或set -e保证失败即停。我习惯先手动跑前两步,确认数据没问题再让main.sh一次跑完,否则脚本报错时经常分不清是数据问题还是模型问题。
这个流程里最容易被忽略的是create_map_file.py和prepare_vec.py之间的依赖关系。标签映射词表顺序和词向量表顺序必须是同一次构建的结果,不能今天建一个、明天重建一个,否则训练和预测的id全乱了。我之前接手过类似项目,就是因为有人反复重建词表,导致模型精度忽高忽低,最后查了两天才发现是id错位。
提示:拿到源码后先跑通默认参数的小数据集,确认五个脚本能一气呵成,再去换自己的数据和超参。
3. 四个模型加一个HAM:选型逻辑与超参设计
models目录下有五个模型文件,覆盖了文本分类最常用的几个套路。它们之间不是简单的“谁更好”,而是“谁更适合你的数据量、文本长度和算力约束”。下面按我从工程角度给它们的定位逐一分析,同时给出可抄作业的参数配置思路。
3.1 TextCNN:第一选择,快且稳
text_cnn.py实现的是标准的卷积文本分类网络。核心思想是用多个不同宽度的卷积核去捕捉局部n-gram特征,再用全局池化把每个卷积核的输出压成一个特征值。比如filter_sizes=(2,3,4),就分别提取2-gram、3-gram、4-gram的组合特征。对短文本和中等长度文本,TextCNN的训练速度非常快,效果也稳定,尤其适合新闻标题、商品评论这类长度在几十到两百个词的文本。
构建时我通常会这样设置:
def text_cnn(vocab_size, num_classes, max_len=100, embed_size=128, num_filters=128, filter_sizes=(2, 3, 4)): # 输入是固定长度的token id序列,shape: (batch, max_len) inputs = tf.keras.Input(shape=(max_len,), dtype='int32') # 词嵌入层:vocab_size是表大小,embed_size是向量维度 emb = tf.keras.layers.Embedding(vocab_size, embed_size)(inputs) # 对每个卷积核宽度做Conv1D + 全局最大池化 conv_pools = [] for fsz in filter_sizes: conv = tf.keras.layers.Conv1D(num_filters, fsz, activation='relu')(emb) pool = tf.keras.layers.GlobalMaxPooling1D()(conv) conv_pools.append(pool) # 把不同宽度特征拼接起来 concat = tf.keras.layers.Concatenate(axis=-1)(conv_pools) # 全连接输出层 output = tf.keras.layers.Dense(num_classes, activation='softmax')(concat) model = tf.keras.Model(inputs, output) return model这里max_len控制了输入文本的最大长度,超过就截断,不足就补0。它直接决定了TFRecord里序列化数据的大小,所以text_to_tfrecords.py里的max_len必须和这里的完全一致,否则训练时直接报shape不匹配。embed_size一般是128或300,和word2vec.py里训练的词向量维度保持一致。如果用了预训练向量,初始化Embedding层的weights参数要传入词向量矩阵,同时设trainable=True做微调,效果通常比完全随机初始化好。
3.2 TextRNN与C-LSTM:捕捉序列信息的代价
text_rnn.py和c_lstm.py都是走序列建模的路子。TextRNN常见的是单层或双层LSTM,把最后一个时间步的隐状态作为整个文本的表示。C-LSTM则是先把文本过一层CNN提取局部特征,再把这个特征序列送进LSTM,相当于用CNN做特征抽取、用LSTM做全局序列建模。这两个模型适合段落级文本,比如需要捕捉“虽然…但是…”这种跨长距离转折关系的场景。
TextRNN的经典参数我一般这么配:
def text_rnn(vocab_size, num_classes, max_len=200, embed_size=128, lstm_units=128, num_layers=2, dropout=0.5): inputs = tf.keras.Input(shape=(max_len,), dtype='int32') emb = tf.keras.layers.Embedding(vocab_size, embed_size)(inputs) # 返回所有时间步的输出,方便堆多层LSTM x = emb for _ in range(num_layers): x = tf.keras.layers.Bidirectional( tf.keras.layers.LSTM(lstm_units, return_sequences=True, dropout=dropout) )(x) # 只取最后一个时间步的隐状态作为句子表示 x = tf.keras.layers.LSTM(lstm_units, dropout=dropout)(x) output = tf.keras.layers.Dense(num_classes, activation='softmax')(x) model = tf.keras.Model(inputs, output) return model注意return_sequences=True只在中间层使用,最后一层不要设,否则输出是序列而不是最终表示。max_len要比CNN设置得更大,因为LSTM吃长文本的能力更强,但代价是显存占用高、训练慢。我踩过一个坑:把CNN用的max_len=100直接搬给LSTM,结果长文本被截得太多,模型完全学不到后置的转折关系,准确率反而比CNN低。后来把max_len提到200以上,效果才回来。
C-LSTM的配置可以理解为上面两个模型的拼接:CNN部分的卷积核宽度小一些,比如(3, 4, 5),输出通道数不要太大,64到128即可;LSTM部分单层就够,因为CNN已经做了局部特征压缩。这个模型参数量比较大,如果你的数据量只有几千条,很容易过拟合,所以正则化要跟上,dropout设在0.3到0.5之间。
3.3 FastText:朴素但适合基线
fasttext.py这个名字容易让人以为它调用了Facebook的fasttext库,其实文本分类场景里我们自己也能实现一个极简版本:把词向量平均,再接softmax。这种模型没有卷积和循环,只有一层Embedding加一层平均池化。但它有一个变体,对词加入bigram和trigram特征后再平均,能部分捕捉词序信息,效果在很多评测上接近TextCNN。
极简实现思路:
def fasttext(vocab_size, num_classes, max_len=100, embed_size=128, ngram_features=0): # 输入可以是token id序列,也可以拼接n-gram id inputs = tf.keras.Input(shape=(max_len,), dtype='int32') emb = tf.keras.layers.Embedding(vocab_size, embed_size)(inputs) # 平均池化:将整个句子的词向量求平均 avg = tf.keras.layers.GlobalAveragePooling1D()(emb) output = tf.keras.layers.Dense(num_classes, activation='softmax')(avg) model = tf.keras.Model(inputs, output) return modelFastText最大的优势是训练极快,参数量小,在小数据集上不容易过拟合。我一般会用它先跑一遍,作为后续深度模型的性能下限。如果你的任务对推理延迟极其敏感,比如在线垃圾评论过滤,FastText的效果和速度平衡可能是五个模型里最好的。数据量大、文本长的时候再考虑升级到CNN或LSTM。
HAM模型我理解是层次注意力网络(Hierarchical Attention Model),在句子级和文档级分别做注意力加权,适合超长文档分类。它的编码器结构更复杂,训练时间也最长,一般放在最后作为效果上限参考。如果数据只是短文本,没必要上HAM。
提示:选型没有银弹。我的习惯是数据量小于5万条时先跑FastText,短文本用TextCNN,长文本用TextRNN或C-LSTM,最后用HAM验证是否还有提升空间。五个模型共享同一套TFRecord和词表,切换成本很低,这才是这套源码最大的价值。
4. NLTK预处理和词向量:把文本变成模型能吃的数字
模型再花哨,输入还是数字。这套源码里data_utils.py和text_to_tfrecords.py承担了从原始文本到数字序列的重任。NLTK在这条链路里负责分词、去停用词和词干提取。很多人在这一步偷懒,直接空格切分,结果英文的标点、单复数、大小写全混在一起,词表膨胀不说,模型还学不到干净的语义特征。
4.1 分词与停用词:NLTK的标准动作
在data_utils.py里,我一般会按这个思路做基础清洗:
import re import nltk from nltk.corpus import stopwords from nltk.stem import SnowballStemmer # 确保首次使用前下载NLTK数据包:nltk.download('punkt') 和 stopwords stemmer = SnowballStemmer('english') stop_words = set(stopwords.words('english')) def clean_text(raw_text): # 去掉HTML标签和多余空白 text = re.sub(r'<[^>]+>', ' ', raw_text) text = re.sub(r'\s+', ' ', text).strip().lower() # NLTK句子分词后按词再分词 words = nltk.word_tokenize(text) # 滤掉停用词和长度只有1的噪声词 words = [w for w in words if w not in stop_words and len(w) > 1] # 可选:词干还原,把“running”变成“run” words = [stemmer.stem(w) for w in words] return wordsnltk.word_tokenize对英文效果很好,能正确拆出标点前的单词,比单纯split()干净得多。stopwords.words('english')返回的停用词表覆盖了常见虚词,比如“the”“is”“and”,这些词对分类几乎没有判别力,去掉能显著减小词表规模。词干还原这一步要看任务,如果你的文本是新闻英语,可以保留原文形态;如果是用户评论,词干还原能合并 “loved”和“love”,缓解稀疏问题。中文用户注意,NLTK的默认分词器对中文无效,需要换成 jieba 或其他中文分词库, 然后在clean_text里加一个语言判断分支。
4.2 词表构建与TFRecord序列化
分词之后就要建立词表。create_map_file.py会同时完成两类映射:一是标签名到整数id,二是词到整数id。词表可以用collections.Counter统计词频,保留出现次数超过min_count的词,其余统一映射为<UNK>。这一步的顺序至关重要,我通常是先扫描全部训练语料,统计词频,再按词频从高到低排序分配id。
from collections import Counter def build_vocab(tokenized_texts, min_count=2): freq = Counter() for tokens in tokenized_texts: freq.update(tokens) # 出现次数低于min_count的词直接丢弃,减少长尾噪声 vocab = {'<PAD>': 0, '<UNK>': 1} for word, count in freq.most_common(): if count >= min_count: vocab[word] = len(vocab) return vocab这里<PAD>固定为0,用于补齐短句子;<UNK>固定为1,用于替换词表外词。固定这两个id,后面训练和预测都必须遵守。然后text_to_tfrecords.py把每个样本的token id序列连同标签写入TFRecord:
import tensorflow as tf def write_tfrecords(token_ids_list, labels, vocab, out_path, max_len=128): # token_ids_list是已经映射成id的样本列表,每个样本是list of int writer = tf.io.TFRecordWriter(out_path) for ids, label in zip(token_ids_list, labels): # 裁剪到max_len,不足的补PAD id(0) if len(ids) > max_len: ids = ids[:max_len] else: ids = ids + [vocab['<PAD>']] * (max_len - len(ids)) feature = { 'input_ids': tf.train.Feature( int64_list=tf.train.Int64List(value=ids)), 'label': tf.train.Feature( int64_list=tf.train.Int64List(value=[label])) } example = tf.train.Example( features=tf.train.Features(feature=feature)) writer.write(example.SerializeToString()) writer.close()max_len的选择直接影响模型效果和显存占用。太短会截掉关键信息,太长会造成大量PAD占位、浪费显存。我一般会统计训练集中文本长度的分布,取95%分位点作为max_len。比如常见的新闻标题集,100左右就够了;长文档任务,可能需要200到300。训练数据加载TFRecord的时候,tf.io.parse_single_example里声明的input_ids长度也必须和max_len一致,多一个少一个都会在建模时暴露。
word2vec.py和prepare_vec.py解决的问题是这个数字序列最初的表示质量。word2vec.py通常用CBOW或Skip-gram在全部语料上训练低维稠密向量,我习惯直接用Gensim的Word2Vec来做,设置vector_size=128, window=5, min_count=2, epochs=5。prepare_vec.py的作用是把Gensim训练出的词向量矩阵按build_vocab生成的id顺序重新排列,得到一个vocab_size * embed_size的二维矩阵给Embedding层。这里最容易犯的错是不排序直接取向量,导致id为5的词用了id为8的向量,模型整个学歪。
注意:TFRecord是TensorFlow的原生二进制格式,读取速度比逐行读文本快很多。但如果你在PyTorch下使用这套源码,不兼容TFRecord,需要改成
torch.utils.data.Dataset配合torch.save序列化。这套包里的模型构建代码是Keras风格,明显是TF生态,所以沿用TFRecord最合适。
5. 避坑指南:预处理、TFRecord和训练中的五个常见翻车点
这套源码我跑了不下五遍,每遍都踩出不同的问题。下面五条是出现频率最高、也最容易让人卡住的情况,全部按“现象→原因→解决”写清楚。
5.1 NLTK数据包下载慢,程序启动就卡住
- 现象:第一次运行
data_utils.py时,执行nltk.download('punkt')或nltk.download('stopwords')长时间没反应,或者下载到一半报网络错误,程序就停在那里。 - 原因:NLTK的默认数据源服务器在国外,国内的网络环境访问很不稳定,尤其点开多个数据包时经常断流。
- 解决:我现在的做法是从依赖环境变量入手。先手动下载所需数据包,然后解压到本机的
~/nltk_data或项目内的nltk_data目录,再在代码开头指定nltk.data.path.append('./nltk_data')。这样代码就不会去访问远程服务器,直接读本地数据。同时把requirements.txt里的NLTK版本固定好,避免旧方法在新版本被移除。
5.2 TFRecord的shape在训练时突然不对
- 现象:
train.py跑到第二个batch就报ValueError: Shapes (None, 100) and (None, 120) are incompatible,有时是输入维度对不上,有时是标签维度多出[1]。 - 原因:
text_to_tfrecords.py里写的max_len是120,但模型输入的Input(shape=(max_len,))里写的还是100;或者标签在序列化时写成了value=[label]整型列表,读取时误用了FixedLenFeature([], tf.int64)。本质是序列化与解析两端的约定不一致。 - 解决:把TFRecord的写入和解析放在同一个模块里,定义一个常量维护
MAX_LEN和特征格式,不要把数字硬编码散落各处。我在data_utils.py顶部写死了MAX_LEN = 128,所有脚本统一from data_utils import MAX_LEN。另外,用tf.io.FixedLenFeature([MAX_LEN], tf.int64)读id序列,用tf.io.FixedLenFeature([], tf.int64)读标签,写的时候注意Int64List(value=ids)传入的是list,不能传多维数组。
5.3 词表顺序变了,训练和预测完全错位
- 现象:模型训练时准确率正常,但换到
predict.py跑新文本,输出的标签概率混乱,甚至出现 “sports” 文本被高概率分成 “tech”。 - 原因:
create_map_file.py生成的词表和标签映射是固定的,但有人在训练后又跑了一次create_map_file.py,或者word2vec.py重新训练了向量,导致词表id顺序和词向量矩阵行号没有对齐。预测时虽然走得通,但输入id对应的是新词表的顺序,Embedding矩阵还是旧顺序的向量,等于对不上号。 - 解决:把词表、标签映射、词向量矩阵统一导出到固定目录,命名包含数据集的
version号。每次训练前检查映射文件的生成时间,训练完导出模型时,附带把词表和标签映射一起打包。predict.py加载模型时,强制要求传入同一个版本的映射文件,不存在就报错。从那以后我每次换数据集,都会在main.sh里先验证映射文件是否存在,不存在才允许执行创建脚本。
5.4 LSTM训练loss不降,甚至越跑越高
- 现象:TextRNN或C-LSTM训练几十步后,训练loss在1附近震荡,验证准确率始终在随机水平,偶尔loss突然变成NaN。
- 原因:LSTM对学习率很敏感,默认的0.01或0.001对LSTM来说常常偏大,梯度范数会爆炸;另外,词向量初始化范围过大也会放大梯度。很多新手直接照搬CNN的优化器参数给LSTM,必出问题。
- 解决:给LSTM加梯度裁剪,比如
optimizer = tf.keras.optimizers.Adam(learning_rate=0.001, clipnorm=5.0),同时把学习率降到0.0005甚至0.0001。更常用的做法是在模型前向传播里加入tf.keras.layers.LayerNormalization()稳定隐状态。词嵌入初始化用tf.keras.initializers.RandomUniform(minval=-0.05, maxval=0.05),不要用默认的随机正态分布。我现在的习惯是:任何RNN类模型,第一版配置直接以clipnorm=5.0和lr=0.0005起步,跑通了再逐步调大学习率。
5.5 训练正常但预测结果全是同一个类
- 现象:
predict.py对任何输入都输出 “tech” 或概率几乎均匀分布,但训练时的验证准确率明明正常。 - 原因:分类不均衡导致模型偏向多数类。假如 tech 占80%,模型只要全输出 tech 就有80%准确率,训练时的batch采样随机,模型很快学会偷懒。验证指标没有用
f1或recall,只看accuracy,所以没暴露问题。 - 解决:训练前统计每个类的样本数,在
train.py里给损失函数加权:loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False),并通过class_weight参数传入{0: 1.0, 1: 10.0}这类比例。更彻底的办法是在data_utils.py里做分层抽样,确保每个batch都包含各类别样本。验证时不要只看准确率,至少加一个Precision、Recall和F1的打印,这点也可以用sklearn.metrics在验证集上计算。
提示:五个坑里,词表错位和TFRecord shape不一致是最隐蔽的,因为程序不报错,只是效果差。我建议每次改动后都跑一遍从
predict.py到原始文本的往返测试:随机抽一条训练数据,预测它的类别,必须和原标签一致,否则数据管线就有问题。
6. 进阶:把模型性能往上拉的三个验证习惯
模型能跑通只是起点,想拿到可以上线用的精度,我总结了三个自己常用的验证习惯。
第一个是混淆矩阵。准确率只能告诉你整体对不对,不能告诉你哪几类容易混。我每轮训练完都会把验证集预测结果存下来,用sklearn画混淆矩阵:
from sklearn.metrics import confusion_matrix, classification_report import numpy as np # labels_val是真实标签,preds_val是模型预测的argmax结果 # 这里假设你已经跑完了验证集的预测 classes = ['sports', 'tech', 'business'] print(classification_report(labels_val, preds_val, target_names=classes)) cm = confusion_matrix(labels_val, preds_val) # 按行归一化,方便看出“某类被错分到了哪里” cm_norm = cm.astype('float') / cm.sum(axis=1, keepdims=True)看到 “sports” 有30%被分到 “tech”,我第一反应不是换模型,而是去检查这两个类别的训练语料是否存在大量重复词,或者标签本身标注就不干净。很多时候,清洗数据带来的提升比换模型大得多。
第二个是分层采样与类别权重。我踩过只按文件顺序切分训练集和验证集的坑:如果原始数据按类别排序存放,验证集里可能完全没有某个类别。正确做法是把样本先按标签分组,每组内按比例随机抽到训练集和验证集,保证每个类在两边都出现。配合class_weight一起用,能让模型在类别不均衡时不至于被多数类带偏。这一步在train.py里写成np.random.RandomState(42).permutation做索引打乱,固定随机种子,方便复现。
第三个是模型导出后的pipeline一致性验证。训练时验证集通过TFRecord喂入,但线上预测往往走的是另一条路:清洗→分词→词表映射→补pad→模型推理。这两条路只要有一处不一致,线上效果就会比测试时低一截。我现在固定用同一条函数处理训练和预测文本,也就是predict.py里直接调用data_utils.clean_text和build_vocab,不单独写第二套预处理。每次导出模型后,我会随机抽20条训练文本,用导出模型重新预测,并和训练时的真实标签对比,全部一致才认为pipeline是通的。
从那以后,我每次换数据集都会强制走一遍这个流程:先跑fasttext.py建立基线,再对比text_cnn.py和text_rnn.py,最后用混淆矩阵定位难点,选择性地尝试HAM.py。宁可多花半小时做验证,也不想在错误的数据管线上浪费三天调参。这套源码最值得复用的不是某一个模型,而是这条从文本到TFRecord再到预测的完整链路——把这条链路看明白,你自己的分类项目就成功了一半。希望这些拆解和踩坑记录帮你在复现时少走弯路。
本文还有配套的精品资源,点击获取