简介:这是一套基于TensorFlow的中文文本分类项目源码与配套数据集,采用字符级卷积神经网络(CNN)和循环神经网络(RNN)实现分类,适合自然语言处理初学者、文本挖掘爱好者及需要快速搭建基线模型的开发者。项目提供训练集五万条、验证集五千条和测试集一万条,整体分类效果较好。资源压缩包共十八个文件,以Python脚本为主,覆盖数据预处理、词汇表构建、CNN与RNN模型定义、训练及预测等核心模块,另含网络结构示意图、运行依赖清单和说明文档,便于对照代码理解原理。包体约四百一十KB,轻量易用,目前已有170人浏览学习。读者可拿到从原始数据生成子集、合并分类文件,到构建词汇表与类别映射、训练字符级模型并预测的完整流程,并配有准确率与损失变化图,适合在此基础上复现实验或做进一步改进。
1. 用 CNN 和 RNN 做中文文本分类,这条技术路线现在还能打吗?
如果只看榜单,中文文本分类的首选早该是预训练模型;可打开一个真实项目,Python 基于 CNN 和 RNN 的中文文本分类仍是被翻出来最多的一套骨架。卷积网络抓短距离关键词,循环网络抓上下文顺序,两者拼起来,几万条新闻、评论、留言都能在分钟级训练里拿到够用的准确率。它不需要大显存,每一层都能单独解释,适合手里有几千到几万条带标签文本、想从数据处理写到模型评估的初学者,也适合先做强基线再决定要不要上 Transformers 的从业者。标题里的源码和数据集,价值不在跑通,而在把数据格式、模型参数和踩坑位置一次暴露给你。下面按数据、模型、训练、排错的顺序拆开讲。
2. 数据预处理:从原始语料到 Embedding 输入,三个必写模块
无论标题里的数据集用什么格式存,第一步都是把它读进来、摸清结构、转成索引。先别急着建模型,这类项目里 80% 的报错发生在数据形状对不上模型输入,而不是模型本身写错。预处理链路固定为三件事:读文件成两列、分词建词表、padding 成定长张量。
2.1 读原始文件:先统一成“标签 + 文本”两列
常见的数据集格式有三种:TXT 每行一条“标签 + 分隔符 + 文本”、CSV 两列、Excel 单列表格。后两种用 pandas 直接读,第一种最需要小心,因为正文里可能本身就带制表符。第一步统一转成两列 DataFrame,并打印类别分布:
import pandas as pd rows = [] with open('data/train.txt', encoding='utf-8', errors='ignore') as f: for line in f: line = line.rstrip('\n') if not line: continue # 用 partition 只切第一个分隔符,避免正文里出现 \t 导致列错位 label, _, text = line.partition('\t') rows.append([label.strip(), text.strip()]) df = pd.DataFrame(rows, columns=['label', 'text']) print(df.shape) print(df['label'].value_counts())逻辑说明:partition 按第一个 \t 切分,切出来永远是三部分,正文里再多 \t 也不会拆出多余列;而直接用 split('\t') 遇到正文含制表符就会多出列,读进来后 DataFrame 列数对不上。label 和 text 都做 strip,去掉行尾换行和首尾空格。value_counts 打印每个类别的样本数,这一步直接决定后面要不要做类别平衡。
参数说明:encoding='utf-8' 出现乱码时优先换 'gbk',这是中文数据集最常见的两种编码;errors='ignore' 只能在摸底的时候用,它会静默丢字,正式建模前应该修好源文件。另外一个隐蔽问题是 CSV 带 BOM 头,如果第一列标签名显示成“\ufefflabel”,用 pandas.read_csv(sep='\t', encoding='utf-8-sig') 读一遍就行。
2.2 分词与词表:字级优先,词级作为对比实验
中文没有天然空格,必须先决定 token 粒度。常见做法是字级或词级,字级把每个汉字当成一个 token,词级用 jieba 先分词。很多 rnn 讲解文章默认词级,但我的经验是短文本分类先走字级:词表小、没有 OOV、训练稳定,几千条数据也能学出不错的 embedding;词级更适合长文本和小词表场景。两种粒度都可以用一个函数支持:
import jieba from collections import Counter def tokenize(text, char_level=True, use_jieba=False): if char_level: return list(text) if use_jieba: return jieba.lcut(text) return text.split() def build_vocab(texts, min_count=2, max_vocab=50000, char_level=True): counter = Counter() for text in texts: counter.update(tokenize(text, char_level=char_level)) # 过滤低频词,再按频次排序 vocab = [w for w, c in counter.items() if c >= min_count] vocab = sorted(vocab, key=lambda w: counter[w], reverse=True)[:max_vocab] word2idx = {w: i + 1 for i, w in enumerate(vocab)} word2idx['<UNK>'] = len(word2idx) + 1 return word2idx, counter逻辑说明:0 留作 padding 的 id,真实 token 从 1 开始编号,这是 PyTorch Embedding 层里 padding_idx=0 能生效的前提。词表最后补一个<UNK>,测试阶段遇到没见过的字直接映射到它,不会 KeyError。低频过滤放在排序之前,min_count=2 能去掉只出现一次的字,这些大多是噪声;max_vocab=50000 对字级来说用不上,词级长尾严重时靠它截断。
参数说明:停用词表在这个方向收益不大,我一般不去。手动删“的、了”可能把“不是不好”这类否定结构拆坏,高频字对分类的干扰可以靠 dropout 和权重自己压下去。想跑对比实验时,把 tokenize 的 char_level 设为 False 且 use_jieba=True,词表规模会从几千涨到几万,后面要检查<UNK>占比,如果超过 5%,说明词级方案在你的语料上并不划算。
2.3 序列化与 Padding:先截断再补零,顺序不能反
模型需要定长输入,而文本长短不一。max_len 别拍脑袋,先统计 token 数分布取 95 分位。短文本(评论、标题)通常 64 到 128 就够,长文本再放宽到 256。编码函数如下:
import numpy as np def encode_texts(texts, word2idx, max_len=128, char_level=True): unk_id = word2idx.get('<UNK>', len(word2idx)) data = [] for text in texts: ids = [word2idx.get(w, unk_id) for w in tokenize(text, char_level=char_level)] ids = ids[:max_len] # 先截断 ids = ids + [0] * (max_len - len(ids)) # 再补零 data.append(ids) return np.asarray(data, dtype=np.int64)逻辑说明:先截断再补零,顺序反了要么截断后长度对不上,要么把补零位误当成真实文本。ids[:max_len] 是从头部截断,保留开头信息——新闻和评论的结论大多在前半段,保头比保尾实用。标签用 sklearn 的 LabelEncoder 转成 0 到类别数减一的整数,和文本 id 一起保存。
参数说明:max_len 太小会让长文本丢关键信息,太大让 padding 占比高、训练变慢、显存浪费。先对 text 列跑一遍 len(tokenize(text)) 拿到长度分布,取 95 分位作为 max_len,这批数据里只有 5% 的样本会被截断,是性价比最高的选择。最后把 ids 和 labels 存成 npz 文件,预处理阶段就结束了。
提示:这里没用 pack_padded_sequence,直接定长 padding 在小项目里误差可以忽略。如果数据里文本长度差异极大,再考虑按长度分组 batch,能省不少显存。
3. 模型构建:TextCNN、BiLSTM 与融合写法
很多 cnn 和 rnn 的对比帖把它们讲成两条对立路线,落到 torch 里其实只是两个模块,真正难的是维度对齐。下面把两个子模块和最常见的并行融合结构写出来,代码基于 PyTorch,Keras 思路同理,只是换 API。
3.1 TextCNN 的 PyTorch 写法:非对称卷积核与全局池化
TextCNN 的核心假设是关键词是局部信号,不需要看全文。例如“垃圾”出现基本就定调,卷积核沿序列方向滑动,抽取 n-gram 特征。实现如下:
import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim=128, num_filters=128, kernel_sizes=(2, 3, 4), num_classes=10, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, k, padding=k // 2) for k in kernel_sizes ]) self.fc = nn.Linear(num_filters * len(kernel_sizes), num_classes) self.dropout = nn.Dropout(dropout) def forward(self, x): emb = self.embedding(x) # [B, L, E] emb = emb.permute(0, 2, 1) # Conv1d 要求 [B, C, L] pooled = [] for conv in self.convs: c = torch.relu(conv(emb)) # [B, F, L] c, _ = c.max(dim=2) # 全局最大池化 pooled.append(c) out = torch.cat(pooled, dim=1) # [B, F * len(ks)] return self.fc(self.dropout(out))逻辑说明:embedding 输出是 [batch, seq_len, embed_dim],而 Conv1d 期望 [batch, channels, length],所以必须 permute 把维度换过来,这是新手最常卡住的地方。三个卷积核分别看 2、3、4 个 token 的窗口,对应中文里的双字词、三字词和四字词短语。padding=k//2 让输出长度和输入近似一致,再用 max(dim=2) 把每个滤波器压成一个数,表示“这个短语在句子任意位置出现过”。
参数说明:num_filters=128 是每个卷积核的滤波器数量,数据量小可降到 64;kernel_sizes 一般不用超过 5,窗口太大反而抓不到短语特征。全局最大池化的含义是只关心最强信号,位置信息本来就不是 CNN 要保留的东西,这也是 CNN 对短文本分类效果好的原因——关键词很直接。
3.2 BiLSTM 的 PyTorch 写法:双向最后时刻与 Mask
rnn 循环神经网络解决的是顺序和长依赖:“不是不好”和“不好”差了三个字,位置一变情感反转。单向 LSTM 只能看到上文,双向把“从头到尾”和“从尾到头”两个隐状态拼起来,对否定、转折更敏感。实现如下:
class BiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_size=128, num_layers=2, num_classes=10, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden_size, num_layers=num_layers, bidirectional=True, batch_first=True, dropout=dropout) self.fc = nn.Linear(hidden_size * 2, num_classes) self.dropout = nn.Dropout(dropout) def forward(self, x): emb = self.embedding(x) # [B, L, E] out, _ = self.lstm(emb) # [B, L, 2*H] mask = (x != 0).unsqueeze(-1) # [B, L, 1] out = (out * mask).sum(dim=1) / mask.sum(dim=1) # 平均池化 return self.fc(self.dropout(out))逻辑说明:直接取 out[:, -1, :] 在无 padding 时没问题,但第 2 章补了一堆 0,最后一个位置很可能是 pad,所以这里用 mask 把 pad 位置的输出置零再做平均池化。这是 rnn pytorch 接口里最容易翻车的地方:LSTM 的输出长度永远等于输入长度,padding 位照样吐向量。
参数说明:batch_first=True 让输入输出统一为 [B, L, H],初学者少晕一次;num_layers=2 时 dropout 才在层间生效,单层写 dropout 会被 PyTorch 静默忽略。想换成 GRU,只把 nn.LSTM 改成 nn.GRU 就行,其余参数不用动,GRU 参数更少、训练更快,效果通常持平。
3.3 融合写法:并行 concat 与串行喂入
CNN 抓局部强信号,RNN 抓时序上下文,两个特征互补。最稳的融合方式是并行:共享 embedding,两路各自出向量,concat 后接一个全连接分类头。完整类如下:
class TextCNNBiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim=128, num_filters=128, kernel_sizes=(2, 3, 4), hidden_size=128, num_layers=2, num_classes=10, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, k, padding=k // 2) for k in kernel_sizes ]) self.lstm = nn.LSTM(embed_dim, hidden_size, num_layers=num_layers, bidirectional=True, batch_first=True, dropout=dropout) self.fc = nn.Linear(num_filters * len(kernel_sizes) + hidden_size * 2, num_classes) self.dropout = nn.Dropout(dropout) def forward(self, x): emb = self.embedding(x) # [B, L, E] pooled = [] for conv in self.convs: c = torch.relu(conv(emb.permute(0, 2, 1))) c, _ = c.max(dim=2) pooled.append(c) cnn_vec = torch.cat(pooled, dim=1) # 局部特征 out, _ = self.lstm(emb) # [B, L, 2*H] mask = (x != 0).unsqueeze(-1) rnn_vec = (out * mask).sum(dim=1) / mask.sum(dim=1) # 上下文特征 vec = torch.cat([cnn_vec, rnn_vec], dim=1) # [B, F*len(ks) + 2*H] return self.fc(self.dropout(vec))逻辑说明:cnn_vec 是静态局部特征,rnn_vec 是动态上下文特征,两者拼起来后分类头能同时利用“出现了关键词”和“这句话顺着读下来是什么语气”。共享 embedding 层让两路从同一份词向量出发,训练更稳;分开建两个 embedding 也可以,但小数据集上收益不明显。
参数说明:num_layers 从 2 降到 1,可以省一大截显存,短文本场景损失很小。还有一种串行变体:先 CNN 卷积但不去池化,把输出 permute 回 [B, new_L, num_filters] 喂给 LSTM,让循环网络在局部特征序列上再读一遍。串行结构表达能力更强,但梯度路径深、更容易过拟合,我的建议是先并行跑通,瓶颈明显再试串行。
4. 训练与调参:优化器、学习率与评估指标怎么配合
模型结构定了,后面就是让 loss 听话的过程。下面把训练循环、评估函数和默认参数表一次给全,照着改即可。
4.1 训练主循环:DataLoader、梯度裁剪与最佳模型保存
训练循环本身不复杂,复杂的是什么时候保存模型、什么时候降学习率。常见做法是每个 epoch 在验证集上算 macro-F1,只保存最高分对应的 state_dict。代码如下:
import torch from torch.utils.data import DataLoader, TensorDataset # ids 和 labels 来自第 2 章预处理结果 dataset = TensorDataset(torch.LongTensor(ids), torch.LongTensor(labels)) loader = DataLoader(dataset, batch_size=64, shuffle=True) model = TextCNNBiLSTM(vocab_size=len(word2idx), num_classes=n_classes) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='max', factor=0.5, patience=1) criterion = torch.nn.CrossEntropyLoss() def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, total_num = 0.0, 0 for xs, ys in loader: xs, ys = xs.to(device), ys.to(device) optimizer.zero_grad() logits = model(xs) loss = criterion(logits, ys) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() * xs.size(0) total_num += xs.size(0) return total_loss / total_num逻辑说明:TensorDataset 要求输入已经 padding 成定长,所以第 2 章的 encode 结果直接能喂进来。optimizer.zero_grad() 必须在 backward 之前,否则梯度会跨 batch 累加。clip_grad_norm_ 把整个模型的梯度范数截断到 5,防止 LSTM 梯度爆炸导致 loss 突然变 nan,这是新手最容易漏的一行。
参数说明:AdamW 比 Adam 在 weight_decay 上更规范,weight_decay=1e-4 相当于给大权重做惩罚,压过拟合。ReduceLROnPlateau 按验证指标自动降学习率,mode='max' 表示指标涨才不动,连续 patience=1 个 epoch 没涨就减半。保存模型只存 state_dict,不存整个 model,否则换 Python 版本或改结构后加载必炸。
4.2 评估指标:准确率会骗人,宏平均 F1 才可信
分类任务最常被忽略的问题是类别不平衡。假设 90% 样本是负类,模型全猜负类准确率也有 90%,所以评估必须看每个类别的精确率和召回率。评估函数如下:
from sklearn.metrics import classification_report, f1_score def evaluate(model, loader, device): model.eval() preds, labels = [], [] with torch.no_grad(): for xs, ys in loader: logits = model(xs.to(device)) preds.extend(logits.argmax(dim=1).cpu().numpy()) labels.extend(ys.numpy()) macro_f1 = f1_score(labels, preds, average='macro', zero_division=0) print(classification_report(labels, preds, digits=4)) return macro_f1逻辑说明:classification_report 会输出每个类别的 P、R、F1 和 support。先看 support 少的类别,再看它们的 F1;如果多数类 F1 0.98、少数类 0.1,优化方向就不是调模型结构,而是处理数据。macro-F1 是各类别 F1 的算术平均,对少数类敏感,比 accuracy 和二元 F1 都更适合中文文本分类这种类别天然不均衡的场景。
参数说明:zero_division=0 让某个类别被完全预测错时返回 0 而不是报警告,日志更干净。digits=4 控制小数位,写报告时直接复制输出,不用重跑。
4.3 参数默认值:先按这张表跑,再按现象调
| 参数 | 起步值 | 出现什么情况时修改 |
|---|---|---|
| embed_dim | 128 | 数据量大到 10 万级可升 200,否则别动 |
| max_len | 128 | 长文本丢关键信息时按长度分布 p95 加大 |
| num_filters | 128 | 过拟合明显降到 64,欠拟合升到 256 |
| kernel_sizes | (2,3,4) | 文本以四字词为主时加 5 |
| hidden_size | 128 | 训练慢或过拟合时降到 64 |
| num_layers | 2 | 小数据集用 1 层更稳,2 层吃显存 |
| dropout | 0.3 | train F1 远高于 val 时升到 0.5 |
| batch_size | 64 | OOM 时降到 32,配合梯度累积 |
| lr | 1e-3 | loss 震荡换 5e-4,F1 卡住用 scheduler |
| epoch/patience | 10 / 2 | 验证集还在涨就继续,早停优于硬跑 |
这张表是这类项目最常见的起点。loss 在前几个 epoch 不降不代表模型坏了,先看是不是 lr 太大导致反复横跳;验证 F1 涨到 0.8 以后不动了,通常不是模型上限,而是学习率需要调小。epoch 不是越多越好,保存最佳模型的逻辑天然带早停:训练 20 个 epoch 但 best model 在第 8 个 epoch,最后取的是第 8 个而不是第 20 个。
5. 避坑指南:5 个常见的翻车现场与排查路径
模型结构、训练循环都写对了,项目依然可能在细节上翻车。下面 5 条是按“现象 → 原因 → 解决”写成的排查路径,来自这个方向最常见的血泪经验。
5.1 数据处理阶段的两个坑:OOV 和截断位置
现象一:加载预训练词向量时 KeyError,或者很多词“假装”有向量但实际是随机初始化,模型训练时看着正常,测真实数据时效果很差。
原因一:预训练词表的词和语料分词后的词对不上。词表构建顺序不同、分词器版本不同,都会导致 30% 以上的词落回随机初始化,相当于预训练知识没用上。
解决一:要么以预训练词表为准重建 vocab,未登录词统一用均值为 0、方差 0.1 的正态分布初始化,并保持 trainable=True 让模型自己微调;要么直接走字级输入,彻底绕开 OOV。字级别在短文本分类里损失很小,是性价比最高的后悔药。
现象二:验证集 F1 不错,但真实长文本上线后明显掉点。
原因二:max_len 设得太短,或者截断时从尾部硬切,把结论所在的后半段切没了。
解决二:先统计文本长度分布,max_len 取 95 分位而不是平均值;截断保头部,因为中文新闻、评论的结论大多在开头。如果是“前面铺垫、结尾反转”的文本,改用双向模型,并在预测阶段把文本按句切分再聚合。
5.2 训练阶段的常见坑:类别不平衡和随机种子
现象三:loss 正常下降,准确率 0.93,但 classification_report 里少数类 F1 是 0。
原因三:CrossEntropyLoss 默认各类权重相等,样本占比悬殊时,模型发现全猜多数类也能把 loss 压得很低,于是少数类被放弃。
解决三:给 CrossEntropyLoss 传 weight,按“样本数倒数归一化”设置权重;或者用 WeightedRandomSampler 重采样,让每个 batch 里少数类占比合理。评估必须看 macro-F1,别再用 accuracy 判断好坏。
现象四:同一份代码、同一份数据,两次训练结果差 3 个百分点以上,怀疑模型有 bug 或者说结构不稳定。
原因四:没有固定随机种子。torch、numpy、random 三个库各自有全局随机状态,不固定的话 dropout 初始化、数据打乱顺序每次都不同。
解决四:在 main 入口加固定种子代码,这是最容易被忽略的复现基础:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False逻辑说明:cudnn.benchmark=False 是让 cuDNN 不再自动选最快的卷积算法,代价是少量速度损失,换来确定性。只有三行 seed 不够,PyTorch 的 CUDA 卷积在 benchmark 模式下仍可能引入随机性,所以这两行要和 seed 一起写。参数说明:seed 取 42 只是惯例,固定成任何整数都行,关键是同一份代码固定同一个值。
5.3 硬件相关的一个坑:显存溢出和梯度爆炸
现象五:训练到一半 CUDA out of memory,或者 loss 突然变成 nan。
原因五:显存溢出通常是 batch_size、max_len、num_layers 三者乘积超了显存。loss 变 nan 常见于学习率过大或 LSTM 梯度爆炸,而不是模型写错了。
解决五:显存溢出先降 batch_size 到 32 或 16,再考虑把 num_layers 从 2 降到 1,最后才是换更小的 hidden_size。gradient clipping 从 max_norm=5.0 开始,若还炸就降到 1.0。nan 出现时优先把 lr 降到 5e-4,并检查标签里有没有脏数据把 loss 推到 inf。如果训练速度莫名变慢,先检查是不是文本 pad 得太长,batch 内长短差异大时,同样的样本数计算量可能翻倍。
6. 最后一招:用交叉验证给模型一个可信的 F1,再决定动不动结构
把训练流程跑通、避坑都过了一遍,最该做的不是继续调参,而是用交叉验证确认当前方案的真实水平。单次划分的验证集 F1 有运气成分,尤其是自带小数据集的项目,一次 0.86 可能换一次只有 0.82。我用 StratifiedKFold 固定 5 折,每一折只训练少量 epoch,拿每折的 macro-F1 看均值和标准差。均值代表方案的真实水平,标准差告诉你要不要担心稳定性。代码很短:
from sklearn.model_selection import StratifiedKFold skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) for fold, (tr_idx, va_idx) in enumerate(skf.split(ids, labels)): train_ids, val_ids = ids[tr_idx], ids[va_idx] train_labels, val_labels = labels[tr_idx], labels[va_idx] # 建立 DataLoader,跑与第 4 章相同的 train_one_epoch / evaluate # 记录本折 macro_f1,最后看均值和标准差逻辑说明:StratifiedKFold 保证每折里各类别比例和全集一致,少数类不会在某折里消失;随机种子固定,别人也能复现你的结果。5 折之后如果均值 0.84、标准差 0.01,说明方案稳定,可以拿去上线或写报告;如果标准差 0.05,说明数据划分的影响大于模型改进,这时候调参是白费力气。应该先查数据:标注有没有错、类别有没有重叠、有没有重复样本串到训练和验证里。重复样本是中文文本分类项目里隐藏最深的坑,去重之后再跑交叉验证,标准差通常会明显变小。
交叉验证稳定后,还有一个值得做的检查:错误样本抽样。把验证集里预测错的样本打印出来,人工看 50 条,你会发现大部分错误属于“标注本身有争议”或者“类别定义模糊”,而不是模型能力不足。这时候与其继续堆模型复杂度,不如回去修数据。我自己的习惯是每次实验跑完,把超参数、classification_report、交叉验证均值存成文本文件放在实验目录里,半年后翻出来还能对上当初的改动,比翻聊天记录靠谱。希望帮到你。
本文还有配套的精品资源,点击获取