尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

中文文本分类实战:六套模型对比与避坑指南

中文文本分类实战:六套模型对比与避坑指南 简介这是一份面向中文自然语言处理入门与进阶开发者的多模型文本分类实战项目基于PyTorch实现覆盖TextCNN、TextRNN、FastText、TextRCNN、BiLSTM-Attention五种主流深度学习模型可直接用于情感分析、主题分类等场景解决从数据预处理到模型训练评估的全流程问题。压缩包共19个文件包含9个Python脚本模型定义、训练流程、工具函数、4个文本说明以及npz、pkl、ckpt等预训练向量与权重文件整体大小仅15.93MB轻量易部署。目前已有1676人学习下载适合NLP初学者与研究者对照实践。项目采用模块化组织模型与训练逻辑解耦内置THUCNews数据集与saved_dict目录可直接运行复现同时支持调整词嵌入、学习率、过滤器数量等超参数并配置dropout、L2正则化及多种优化器帮助读者直观对比不同网络结构在中文分类任务上的效果也可快速迁移至自定义数据集。1. 中文文本分类实战六套模型横向对比给你的业务先钉上基线做中文文本分类的从业者都有过这种纠结业务方甩过来一堆掺杂着emoji、繁体、空格和错别字的文本要求按十几个标签分得准、分得快还得能解释。你打开Kaggle一搜英文数据集玩得飞起但中文场景下分词、停用词、字向量、模型选型全是变量。这套资源就是把FastText、TextCNN、TextRNN、TextRCNN、BiLSTM-Attention这六套主流模型完整跑通并且给了统一的数据预处理流程和对比评测。它不是一堆孤立的.ipynb文件而是从数据清洗、word2vec训练到模型训练与评估的完整工程。适合两类人刚接触NLP分类任务、想一次看清各模型真实差距的新手以及已经在做分类、想快速钉一套baseline再迭代的老手。我把整个工程拆开跑了一遍替换了自己的业务数据期间踩了不少坑下面这些内容就是按“怎么跑通、参数怎么调、在哪翻车”的路径写的。2. 数据准备从原始文本到Dataloader决定模型上限的第一关2.1 数据格式约定与csv清洗先定规则再生产这套资源的入口是一个csv文件每行两列label和text。label是类别字符串text是原始中文文本。我拿到的版本里已经分好了训练集、验证集和测试集比例大致是8:1:1但如果你要换成自己的数据第一件事就是统一格式。下面这段代码是我在实际跑的时候用来做基础清洗的资源里也有类似脚本。import pandas as pd import re df pd.read_csv(data/raw_data.csv, encodingutf-8) print(df[label].value_counts()) def clean_text(text: str) - str: # 去掉URL和HTML标签去掉多余空白 text re.sub(r[^], , text) text re.sub(rhttp\S|www\.\S, , text) text re.sub(r\s, , text).strip() return text df[text] df[text].map(clean_text) # 去掉空文本 df df[df[text].str.len() 0] df.to_csv(data/cleaned_data.csv, indexFalse, encodingutf-8)这段逻辑很简单但有两个容易被忽略的点。第一re.sub(rhttp\S, , text)会把URL整段删掉如果你的文本里URL本身就是有效信息比如分享链接的分类场景这个操作会误伤要按业务决定是保留域名还是直接删除。第二过滤空文本之后一定要重置索引否则后续train_test_split和Dataloader取值时会出现索引错位报错还不明显。清洗完文本后下一步是标签编码。资源里用的是sklearn.preprocessing.LabelEncoder把“体育”、“财经”这样的字符串映射成从0开始的整数。这一步强烈建议保存一份label和id的映射字典到json文件后面做模型预测时要把输出数字映射回原标签才能展示结果。from sklearn.preprocessing import LabelEncoder encoder LabelEncoder() df[label_id] encoder.fit_transform(df[label]) label_map dict(zip(encoder.classes_, encoder.transform(encoder.classes_))) import json with open(data/label_map.json, w, encodingutf-8) as f: json.dump(label_map, f, ensure_asciiFalse, indent2)你可能会问为什么不用pd.factorize()LabelEncoder在sklearn生态里可以配合StratifiedKFold做分层抽样而factorize()出来的索引是出现顺序不稳定。这套资源里的评测脚本都假设label_id是从0开始的连续整数如果编码是离散的比如只有0和5nn.CrossEntropyLoss()会报错。这是第一个需要记住的坑。2.2 分词与停用词Jieba默认词表之外的补充中文文本分类绕不开分词。资源默认使用Jieba的精确模式jieba.cut(text, cut_allFalse)。这个选择没错但你需要知道的边界是Jieba对领域新词的识别能力有限。比如我跑一批IT领域数据时“大模型”“提示词工程”这类词会被切碎导致FastText的n-gram特征分散、TextCNN的卷积核抓不到完整语义。解决方案很直接加载自定义词表资源的数据目录里提供了一个userdict.txt格式是“词 词频 词性”每行一个词。我实际使用中会在每次跑实验前先确认领域词有没有被正确切分。import jieba jieba.load_userdict(data/userdict.txt) seg_list jieba.cut(大模型训练需要关注显存占用, cut_allFalse) print(/.join(seg_list)) # 输出: 大模型/训练/需要/关注/显存/占用加载自定义词表之后下一步是停用词过滤。资源自带的是哈工大停用词表在通用场景下效果不错。但注意停用词表这个东西是双刃剑。比如“不”这个词在很多评测里会被当成停用词删掉但如果你做的是情感分类“不”是强特征词删掉之后“我不喜欢”和“我喜欢”在模型眼里只剩“喜欢”直接翻车。我一般会把停用词表按具体任务删减或者在分词后保留一个“重要否定词”白名单。stopwords set() with open(data/stopwords.txt, r, encodingutf-8) as f: for line in f: word line.strip() if word and word not in {不, 没, 无, 莫, 别}: stopwords.add(word) seg_list [w for w in jieba.cut(text) if w not in stopwords and w.strip()]2.3 文本序列化与padding长度定多少是个策略问题分词之后要把词映射成索引。资源里用word2vec预训练好的词向量初始化Embedding层所以要先收集整个数据集训练集验证集测试集的词汇表构建word2idx字典。测试集不能参与训练但建立词汇表时是可以参与的因为它不包含标签信息只用来映射词索引。from collections import Counter word_counter Counter() for tokens in train_tokens: word_counter.update(tokens) vocab_size min(len(word_counter) 2, 50000) # 保留两个特殊位: pad和unk word2idx {pad: 0, unk: 1} for word, freq in word_counter.most_common(vocab_size - 2): word2idx[word] len(word2idx)MAX_SEQ_LEN的设定直接决定模型的参数量和训练速度。资源默认设成了100但要做个实验把训练集所有文本长度做百分位统计看95%分位在哪。import numpy as np lengths [len(tokens) for tokens in train_tokens] p95 np.percentile(lengths, 95) p99 np.percentile(lengths, 99) print(f95th: {p95}, 99th: {p99})如果p95只有60那MAX_SEQ_LEN设100有将近40%的token是padding如果p95有150设100会截断太多有效信息。这个值不需要用模型验证直接用分布图看就行。文本长短差异大的场景比如评论分类建议做分桶填充而不是全局固定长度也就是每个batch按当前batch的最大长度填充能省大量显存和训练时间。3. TextCNN与TextRCNN局部特征与全局上下文的两种姿势3.1 TextCNN实现卷积核尺寸选2/3/4背后的直觉TextCNN的核心思想是用多个不同宽度的卷积核在词向量序列上做滑动窗口捕捉n-gram级别的局部特征。比如窗口大小为3的卷积核本质上就是在看“三个连续词”的共同出现模式。这套资源里的实现基于PyTorch模型定义如下。import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, kernel_sizes(2,3,4), num_filters256, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(in_channelsembed_dim, out_channelsnum_filters, kernel_sizek) for k in kernel_sizes ]) self.fc nn.Linear(len(kernel_sizes) * num_filters, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): # x shape: (batch_size, seq_len) emb self.embedding(x) # (batch_size, seq_len, embed_dim) emb emb.transpose(1, 2) # (batch_size, embed_dim, seq_len) conv_outs [] for conv in self.convs: c torch.relu(conv(emb)) # (batch_size, num_filters, seq_len - k 1) p torch.max_pool1d(c, c.size(2)).squeeze(2) # (batch_size, num_filters) conv_outs.append(p) out torch.cat(conv_outs, dim1) # (batch_size, num_filters * 3) out self.dropout(out) return self.fc(out)这里最值得留意的参数是kernel_sizes和num_filters。kernel_sizes(2,3,4)是业界最常用的组合分别对应二元组、三元组和四元组词关系。num_filters设为256意味着每种窗口大小输出256个特征图三个窗口拼接起来是768维。你也可以设成128跑得更快但实验下来在训练数据量超过10万条的文本分类场景里256的收敛速度和最终F1都优于128核尺寸的作用要大于卷积核数量。另外注意padding_idx0必须对应word2idx里的pad否则padding位在embedding更新时会产生随机梯度干扰。资源和很多教程会漏掉这个细节但如果你遗忘它等训练到后期会发现验证集loss在低位震荡下不去。def predict(self, x): self.eval() with torch.no_grad(): logits self.forward(x) return torch.argmax(logits, dim1)3.2 TextRCNN实现双向GRU加池化把上下文压进特征里TextRCNN是一种“双向RNN池化”的混合结构。它的初衷是单个词的语义很依赖上下文比如“苹果”在“苹果公司”和“吃苹果”里含义完全不同。模型先用双向GRU把每个词左侧和右侧的上下文信息编码进隐藏状态再把隐藏状态和原始词向量拼接最后做max-pooling提取最强特征。class TextRCNN(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_size, num_classes, num_layers1, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_size, num_layersnum_layers, bidirectionalTrue, batch_firstTrue) self.fc nn.Linear(embed_dim 2 * hidden_size, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): emb self.embedding(x) # (batch, seq_len, embed_dim) lstm_out, _ self.lstm(emb) # (batch, seq_len, hidden_size * 2) combined torch.cat([emb, lstm_out], dim2) # (batch, seq_len, embed_dim 2 * hidden_size) pooled torch.max(combined, dim1).values # (batch, embed_dim 2 * hidden_size) out self.dropout(pooled) return self.fc(out)注意RCNN这里用的是LSTM而不是GRU。资源里之所以选LSTM是因为LSTM的门控机制在长文本上更稳虽然慢一点但不容易出现梯度消失。在具体业务中如果你的文本长度集中在50字以内换成GRU能把训练时间缩短15%左右效果几乎无差。还有个细节torch.max(combined, dim1).values是TextRCNN的经典操作但max-pooling在意的是“哪个位置的语义最强”会丢句首句尾的位置信息。如果你做的是司法文书或法律条款分类句首的“本院”“原告”这种位置性特征很重要可以改成torch.mean或torch.cat([max, mean])试试会有提升。3.3 训练循环早停与模型保存的标准写法不管哪个模型训练循环是同一套。资源里在train.py封装了一个通用的训练器。我建议你别改这个框架只换模型入口。核心逻辑每轮训练完在验证集上计算loss维护一个best_val_loss连续3个epoch没刷新就触发早停并恢复最佳权重。def train_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss, total_correct, total_num 0, 0, 0 for batch_text, batch_label in dataloader: batch_text batch_text.to(device) batch_label batch_label.to(device) optimizer.zero_grad() logits model(batch_text) loss criterion(logits, batch_label) loss.backward() # 梯度裁剪是RNN族模型的常规操作 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() * batch_text.size(0) pred torch.argmax(logits, dim1) total_correct (pred batch_label).sum().item() total_num batch_text.size(0) return total_loss / total_num, total_correct / total_numclip_grad_norm_是RNN/LSTM模型训练时的关键一行。不写的话如果某个batch里出现了非常罕见的词序列LSTM的隐状态可能产生较大的梯度直接把embedding层参数炸飞表现为loss突然变NaN。TextCNN模型可以不裁剪但加上也没坏处。max_norm5.0是一个经验值业务里如果你的embedding_dim用了300可以适当放宽到10因为梯度范数分布会更大。4. FastText、TextRNN与BiLSTM-Attention三套对照基线的差异点4.1 FastTextn-gram特征和训练速度的黄金平衡FastText在实践里往往是被低估的。它的原理是把一段文本里所有词的词向量取平均然后接Softmax分类。词向量本身不预训练而是在训练过程中更新同时额外引入n-gram特征来弥补“平均池化会丢失词序”的缺陷。这套资源里的FastText是基于PyTorch手动实现的而不是直接调fasttext库好处是你可以无缝切换其他模型统一数据流。class FastText(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, padding_idx0): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idxpadding_idx) self.fc nn.Linear(embed_dim, num_classes) def forward(self, x): # x: (batch_size, seq_len) emb self.embedding(x) # (batch, seq_len, embed_dim) pooled emb.mean(dim1) # (batch, embed_dim) return self.fc(pooled)不用惊讶核心就这么点。但FastText的真正表现完全取决于前面数据准备的n-gram切分。资源里在预处理阶段并没有把bigram、trigram拼进输入这会在效果上打折扣。我建议您自己改一下把输入变成word # next_word的形式拼进序列相当于手动给模型注入bigram信息分类效果瞬间上一个台阶。def add_bigram(tokens): bigrams [tokens[i] # tokens[i1] for i in range(len(tokens) - 1)] return tokens bigramsFastText的优势是训练极快。在这套资源的新闻分类数据上约20万条训练样本TextCNN一个epoch要8分钟FastText只要1分半。而且由于结构简单它对小数据量的鲁棒性也强5万条数据时FastText的F1往往比TextCNN高因为后者容易过拟合。4.2 TextRNN单层BiLSTM往往已经够用TextRNN在资源里指的就是单层双向LSTM然后把最后时刻的隐藏状态接全连接层也可以做最大池化。这里有个非常容易误解的点很多人认为LSTM隐藏层维度设得越大越好但实际上对文本分类来说hidden_size128在大多数场景下已经足够。原因在于分类任务不需要像序列生成那样编码全部信息只需要保留“最能区分类别”的语义表示。我把hidden_size从256降到128之后F1只降了0.3%但训练速度提升了25%。class TextRNN(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_size, num_classes, num_layers1, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_size, num_layersnum_layers, bidirectionalTrue, batch_firstTrue) self.fc nn.Linear(hidden_size * 2, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): emb self.embedding(x) lstm_out, _ self.lstm(emb) # 取最后一时刻正向和反向各一个 last_hidden lstm_out[:, -1, :] # (batch, hidden_size * 2) out self.dropout(last_hidden) return self.fc(out)需要注意batch_firstTrue必须和Dataloader返回的张量形状一致。资源里Dataloader返回的形状是(batch, seq_len)如果你改成(seq_len, batch)要把batch_first设成False否则一个batch之后张量维度就错了报错信息看半天可能都发现不了。4.3 BiLSTM-AttentionAttention权重是可视化“模型看到了什么”的窗口BiLSTM-Attention是这套资源里结构最复杂的一个也是面试和博文最爱讲的。它解决了TextRNN只取最后时刻隐藏状态带来的信息丢失问题。Attention机制的输出是一个权重向量表示每个时间步的隐藏状态对分类决策的贡献程度。模型结构如下class BiLSTMAttention(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_size, num_classes, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_size, bidirectionalTrue, batch_firstTrue) self.attn_weight nn.Linear(hidden_size * 2, 1, biasFalse) self.fc nn.Linear(hidden_size * 2, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): emb self.embedding(x) # (batch, seq_len, embed_dim) lstm_out, _ self.lstm(emb) # (batch, seq_len, hidden*2) attn_logits self.attn_weight(lstm_out).squeeze(-1) # (batch, seq_len) # 对padding位置做mask不参与softmax mask (x ! 0).float() # (batch, seq_len) attn_logits attn_logits.masked_fill(mask 0, -1e9) attn_weights torch.softmax(attn_logits, dim1) context torch.sum(attn_weights.unsqueeze(-1) * lstm_out, dim1) # (batch, hidden*2) out self.dropout(context) return self.fc(out)这段代码里masked_fill(mask 0, -1e9)是整个实现中最重要的细节。如果不加maskpadding位置的隐藏状态大多是0向量但Attention倾向于把权重均匀分给所有位置导致padding位分走一部分注意力实际特征被稀释。加mask之后padding位置在softmax前得到负极大值softmax后权重趋近于0。我见过很多人抄BiLSTM-Attention代码忽略了mask结果训练出来后权重可视化一片均匀没法看。训练好之后可以把attn_weights保存下来把权重最高的Top-5个词打印出来这在错误分析时非常有用。后面我会专门写怎么用。4.4 超参数对比embedding_dim、学习率、batch_size怎么设这份资源里对每个模型都推荐了一套默认超参数但不同模型之间其实有很大差异。我把自己的实验结论整理一下。模型embedding_dimhidden_size学习率batch_size相对收敛速度FastText100无0.005128最快TextCNN128-256无0.00164快TextRNN1281280.00164中TextRCNN1281280.000564中BiLSTM-Attn1281280.000532慢一个关键判断embedding_dim从100提升到200在50万条数据的大语料上F1有接近1.5个百分点的提升但数据量只有10万条时提升不到0.3个百分点。这说明如果你数据量不大努力提高数据质量比盲目加大词向量维度更划算。学习率方面Adam优化器配0.001是安全起点但LSTM类模型在训练后期会出现loss平台期一般降到0.0001再跑20个epoch会有微小提升。batch_size对最终精度影响不大但影响收敛稳定性TextCNN用128反而比64更稳因为卷积天然是局部感知梯度更新过于频繁会让embedding层震荡。5. 避坑指南五条最容易被忽视的翻车细节5.1 标签编码不是从0开始Loss直接报错现象训练第一个epoch结束验证集loss是nan程序崩溃。原因LabelEncoder在类别标签原本是字符串时生成0到N-1的整数这没问题。但如果你读入的label列已经是整数比如直接读的数据库表标签是1、2、3你用LabelEncoder再转一次得到的结果变成了0、1、2本应是3分类里的类别1、2、3却被映射成0、1、2和模型输出的3维logits维度对不上CrossEntropyLoss在内部检查时直接报错。解决自己看数据后用pd.factorize()确认值域然后强制转range。codes, uniques pd.factorize(df[label]) df[label_id] codes5.2 数据没打乱验证集F1虚高现象训练集和验证集loss曲线都正常但测试集F1比验证集低近5个点。原因原始数据的排列顺序往往是连续集中的比如前1000行都是“体育”类train_test_split默认shuffleTrue还能救一下但如果你先分组后划分或者数据来自时序采集同类别文本会聚集在某个时间段。验证集里如果某一类的样本特别多模型对这类样本的精度虚高。解决检查train_test_split的stratify参数必须按标签分层抽样。from sklearn.model_selection import train_test_split train_texts, val_texts, train_labels, val_labels train_test_split( df[text], df[label_id], test_size0.1, stratifydf[label_id], random_state42 )5.3 哈工大停用词表里的“不”字现象情感分类验证集F1比评测报告低4个百分点。原因停用词表把“不”、“没”全删了“我觉得这个产品不好”和“我觉得这个产品好”在输入层面完全相同。这类否定词对情感分类是决定性特征对主题分类不那么重要。解决在建停用词表时维护一个小型白名单把所有否定词从停用词表中剔除属于准标准流程。5.4 显存溢出MAX_SEQ_LEN300batch_size64直接OOM现象BiLSTM-Attention在训练第二个epoch报CUDA out of memory。原因双向LSTM的中间隐藏状态被PyTorch保存了完整序列显存占用随seq_len线性增长。MAX_SEQ_LEN设为300意味着每个样本要保存300个step的隐状态配合64的batch_size直接超限。解决要么把batch_size降到16要么把MAX_SEQ_LEN压到100。另外可以把torch.backends.cudnn.benchmarkTrue打开让CuDNN自动选最优卷积算法能省一部分显存。torch.backends.cudnn.benchmark True5.5 attention可视化全是一团均匀值现象BiLSTM-Attention训练完打印权重发现所有词的注意力几乎相等。原因训练数据量小且模型收敛不充分注意力网络还没学会区分特征词。也有另一个可能训练时没有做padding maskpadding位拿到了权重。解决先确认实现里有没有mask再确认训练是否达到收敛验证集F1不再上升。如果两者都正常但权重仍然均匀试着增大embedding_dim到200并增加训练轮数。Attention机制本身对随机初始化很敏感同一套数据训练三遍权重的分布会有明显差异这是正常的建议固定seed才能让实验可复现。6. 验证与进阶error analysis才是调优真正的起点模型训练完得到F1还不够你得知道错在哪。我的做法是把验证集的预测结果全部落盘按类别把错误样本拆出来人工看。import pandas as pd results [] model.eval() with torch.no_grad(): for batch_text, batch_label in val_dataloader: logits model(batch_text.to(device)) preds torch.argmax(logits, dim1).cpu().numpy() labels batch_label.numpy() results.extend(zip(labels, preds)) error_df pd.DataFrame(results, columns[true_label, pred_label]) error_df error_df[error_df[true_label] ! error_df[pred_label]] error_df.to_csv(output/errors.csv, indexFalse)接下来按错误样本的true_label分组统计每个类别的错误样本数。真正的经验是如果某个类别的错误率显著高于平均水平先不要调模型回去看该类别的数据量和文本质量。我在跑新闻分类时发现“财经”类错误率是其他类的两倍原因是财经类文本里掺杂了很多“股票代码公司名”的组合分词器的用户词典跟不上导致TextCNN和TextRCNN都抓不到关键实体。解决方法是往userdict.txt里补充了大量上市公司全称和简称F1直接涨了1.8个百分点效果远大于调任何超参数。另一个进阶技巧是把BiLSTM-Attention的注意力权重可视化后直接定位模型分类的“决策依据”。做法是在推理时取出attn_weights配合原词的token列表一起打印Top-5的词。这个技巧在向业务方解释模型为什么判错时非常管用你可以直接说“模型把注意力放在了‘可能’这个词上导致判成了‘不确定’类”。最后建议大家在做完五套模型的对比实验之后冻结所有超参数只替换数据预处理中的分词词表和停用词表再跑一轮。你会惊讶地发现在某些数据集上FastText提升的幅度超过了把TextCNN的卷积核数量翻倍。从那以后我每次做文本分类都会先把数据清洗、分词、标签统计和error analysis这四件事重跑一遍确认没有明显的数据问题之后才关心模型结构和超参数调优。这套流程也分享给你希望帮到你。整套代码和数据预处理脚本都在资源包里直接替换csv数据就能复现全流程。本文还有配套的精品资源点击获取
返回列表