简介Word2Vec是Google提出的词向量学习模型涵盖CBOW与Skip-gram两种训练方式能在低维空间中保留词与词之间的语义关系TextCNN则借助卷积神经网络提取文本的n-gram局部特征二者结合是情感分析、新闻分类等场景中一类成熟常用的技术路径。这份53.6MB的实战资料共13个文件包含word2vec_txt.txt词向量训练文本、vocab.txt词汇表、word2vectextcnn.py建模脚本、nCoV_100k_train.labled.csv十万级带标签训练样本、nCov_10k_test.csv测试数据集及test.model训练完成的模型权重配套xml、iml等工程文件便于还原完整项目结构。面向NLP初学者与中级开发者已有749人学习下载。对照代码与数据处理流程可完整掌握从词向量训练、文本序列化到CNN卷积池化特征提取、全连接分类与模型评估的各个环节并体会过滤器大小、卷积核数量、池化层类型等超参数对分类性能的影响为独立开展其他文本分类任务提供可迁移的实践路径。1. 文本分类为什么绕不开 word2vectextcnn先搞清楚这组组合在解决什么很多团队做文本分类上来就纠结“用 BERT 还是用别的”结果模型没跑通数据先把自己搞崩了。实际上在样本量不大、算力有限、要快速上线的场景里word2vec TextCNN 依然是最值得先做的基线方案。word2vec 把词变成稠密向量让“苹果”和“香蕉”在向量空间里靠近TextCNN 用多个尺寸的卷积核去抓局部 n-gram 特征比如“不”“好”这种组合。两者拼在一起既解决了一词一义的表示问题又解决了短文本里关键词位置不固定的问题。适合谁适合要做情感分类、意图识别、垃圾文本过滤、新闻分类的从业者尤其是第一次从规则匹配或 TF-IDF 往深度学习迁移的团队这条路足够短也足够稳。2. 准备训练语料与预处理文本分类的样本质量决定模型上限2.1 标签体系与数据划分先定边界再写代码文本分类的第一步不是加载模型而是把标签体系定死。常见做法是把标签做成一个独立的映射文件比如label.txt每行一个标签名顺序就是训练时类别的索引。这个文件要人工确认因为后面所有代码都依赖这个顺序。不要直接在代码里写死标签列表否则加一个类别就要改源码、改 checkpoint非常被动。数据划分上我一般按 8:1:1 切训练集、验证集、测试集而且要保证划分是按标签分层抽样不是随机乱切。分层抽样的意义在于如果某个类别只有 200 条样本随机切可能把其中 30 条全切到测试集训练集只剩 170 条模型在这个类别上基本学不到东西。用 sklearn 的train_test_split带stratify参数或者直接自己写一个按标签分组的切分函数都可以。import json from sklearn.model_selection import train_test_split # 假设 data.jsonl 每行是 {text: ..., label: 体育} with open(data.jsonl, r, encodingutf-8) as f: lines [json.loads(line) for line in f] texts [item[text] for item in lines] labels [item[label] for item in lines] # stratifylabels 保证每个类别在训练/验证/测试中的比例一致 train_texts, tmp_texts, train_labels, tmp_labels train_test_split( texts, labels, test_size0.2, random_state42, stratifylabels ) val_texts, test_texts, val_labels, test_labels train_test_split( tmp_texts, tmp_labels, test_size0.5, random_state42, stratifytmp_labels )这里的random_state固定为 42是为了让每次跑脚本得到完全一样的数据划分方便复现和排查问题。stratify是分层抽样的关键参数如果传入的是标签列表sklearn 会按照每个类别的比例来抽样。要注意的是如果某个类别样本数极少分层抽样可能会报错这时候需要检查样本分布考虑对少数类做过采样或数据增强而不是硬切。2.2 中文分词与停用词jieba 的粒度直接影响分类效果中文文本分类里分词是个绕不开的环节。word2vec 学的是“词”的向量不是“字”的向量所以分词结果直接决定了词表质量和语义表达能力。常见做法是使用 jieba 分词但我不会直接用默认全模式而是用精确模式并且关掉 HMM 开关。原因很实际HMM 开启时会把一些未登录词硬切出来比如把“ Tribeca 电影节”切成奇怪的碎片造成训练和预测时词表不一致。import jieba def cut_text(text): # 精确模式 关闭 HMM避免未登录词被硬切 words jieba.lcut(text, cut_allFalse, HMMFalse) # 过滤空白字符和单字噪声可选 words [w.strip() for w in words if w.strip() and len(w.strip()) 0] return .join(words)cut_allFalse表示使用精确模式HMMFalse表示不启用隐马尔可夫模型识别新词。这个开关在分类场景里建议关掉因为分类任务对 OOV词表外词的处理是“宁可不切不要乱切”。如果某些领域词比如“量化交易”“碳中和”被 jieba 切错正确做法是往jieba的自定义词典里加词而不是依赖 HMM 去猜。停用词方面很多初学者上来就套一个几百行的停用词表结果把“不”“很”“最”这些对情感分类至关重要的词全滤掉了模型直接在原地踏步。我的做法是只过滤空字符和标点停用词表要按自己的验证集效果来定不要盲抄网上的列表。2.3 用 gensim 训练 word2vecCBOW 还是 Skip-gram、维度与窗口怎么定词向量的来源有两条路一是用别人预训练好的如腾讯词向量、百度百科词向量二是用自己的语料训练。我的经验是如果领域语料够大百万句以上自训练通常比通用预训练向量更贴合任务如果只有几万条样本直接用通用预训练向量更稳妥否则词向量本身没学够TextCNN 拿到的 embedding 都是噪声。自训练用 gensim 很成熟核心参数如下。from gensim.models import Word2Vec sentences [] with open(train_seg.txt, r, encodingutf-8) as f: for line in f: # 每行是已经用空格分隔好的分词结果 sentences.append(line.strip().split( )) model Word2Vec( sentences, vector_size100, # 词向量维度 window5, # 上下文窗口大小 min_count5, # 词频低于5的词直接丢弃 sg0, # 0CBOW1Skip-gram workers8, # 并行线程数 epochs10 ) model.save(w2v.model)这里sg0选 CBOW 而不是 Skip-gram是因为 CBOW 训练更快在小语料上表现更稳适合文本分类这种对词向量精度要求不极致的任务。Skip-gram 对低频词更友好但训练时间更长。vector_size我一般设置在 100 到 200 之间中文短文本分类不需要 300 维高了反而容易过拟合。window5表示每个词最多看前后各 5 个词短文本里这个值够用。min_count5会把出现次数少于 5 的词丢弃这些词学出来的向量也不可靠。训练完 word2vec 后建议顺手做一个词向量可视化检查用 PCA 或 t-SNE 把“好”“棒”“赞”和“差”“烂”“垃圾”这两组词投影到二维平面上看看它们是否各自聚在一起。这一步一分钟就能跑完能直观确认词向量有没有学出语义结构。如果发现同类词没有聚在一起先别调 TextCNN回头检查语料清洗和分词问题多半出在前面。3. 把词向量变成模型输入Embedding 矩阵对齐的 3 个关键步骤3.1 词表构建与 OOV 处理用UNK兜住没见过的词有了 word2vec 模型接下来要做的是把词表映射成两个东西一个是word2idx字典一个是 embedding 矩阵。这里最容易翻车的是词表不一致word2vec 模型里有 5 万个词你构建的输入词表里只有 1 万个或者反过来训练时出现的词在预测时根本没有对应向量。解决思路是以训练语料的分词结果为主构建词表从 word2vec 里取对应向量取不到的词统一映射到一个随机初始化的UNK向量上。import numpy as np from gensim.models import Word2Vec w2v_model Word2Vec.load(w2v.model) # 构建词表预留 PAD 和 UNK word2idx {PAD: 0, UNK: 1} embedding_dim w2v_model.vector_size for word in w2v_model.wv.index_to_key: word2idx[word] len(word2idx) # 初始化 embedding 矩阵 vocab_size len(word2idx) embedding_matrix np.random.normal(scale0.1, size(vocab_size, embedding_dim)) embedding_matrix[0] 0 # PAD 向量置零 # 用 word2vec 的向量填充没有的词保持随机初始化 oov_count 0 for word, idx in word2idx.items(): if word in w2v_model.wv: embedding_matrix[idx] w2v_model.wv[word] else: oov_count 1 print(fvocab_size{vocab_size}, oov_count{oov_count})PAD的向量置零是为了让 padding 位置在卷积和池化时不贡献有效特征这是 TextCNN 的常见做法。UNK用随机初始化并且后续参与训练是为了让模型在训练过程中学到“遇到没见过的词时应该怎么处理”。注意这里有几个细节一是np.random.normal的 scale 设为 0.1不要用标准正态分布否则向量初始值方差太大第一个 epoch 的 loss 会异常高二是w2v_model.wv.index_to_key是 gensim 4.x 的 API如果你用的是 3.x要改成w2v_model.wv.index2word。OOV 的数量要重点观察。如果 OOV 占比超过 5%说明分词不一致或者训练语料和预测语料差异太大需要回头补充语料或做词典对齐。如果 OOV 是 0也要小心——大概率是词表没有正确构建代码里可能直接把 word2vec 的词表当成了输入词表这样所有词都有向量但模型在预测时遇到新词直接崩掉。3.2 固定词向量还是微调两者差别比想象中大加载完 embedding 矩阵后面临一个选择Embedding 层的trainable是 True 还是 False。固定词向量False的好处是训练快、不容易过拟合尤其适合样本量小于 1 万条的小数据集微调词向量True的好处是词向量能根据当前任务的标签信号做调整比如“苹果”在手机分类任务里会更靠近“华为”在水果分类任务里会更靠近“香蕉”。我的经验是数据量超过 5 万条就开微调低于 1 万条就固定1 万到 5 万之间做实验对比没有绝对正确。这个环节有一个被很多人忽略的坑如果选择微调UNK的随机向量也会被更新这意味着每个 batch 里 OOV 词的特征都在变化可能导致训练不稳定。解决方法是在构建 embedding 矩阵时把UNK的向量用所有词向量的均值来初始化而不是随机初始化。均值初始化让UNK在语义上尽量靠近“普通词”的中心位置模型学起来更稳。3.3 用 PyTorch 构建 TextCNN卷积核尺寸、数量与池化策略TextCNN 的原理一句话就能说清输入是一个[batch_size, seq_len, embedding_dim]的词向量序列把它当作一张单通道的“图像”用多个不同高度的卷积核去扫提取局部 n-gram 特征然后做 1-max pooling 把每个卷积核的输出压成一个标量最后拼起来过全连接层。为什么用 1-max pooling因为文本分类只需要知道“这个 n-gram 模式是否出现”不需要知道它出现在哪个位置。位置信息在短文本分类中往往不是关键甚至可能干扰模型泛化。import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embedding_dim, num_classes, filter_sizes[2, 3, 4], num_filters256, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) # 输入是 [batch, seq_len, embedding_dim]Conv1d 希望 [batch, channels, seq_len] self.convs nn.ModuleList([ nn.Conv1d(in_channelsembedding_dim, out_channelsnum_filters, kernel_sizefs) for fs in filter_sizes ]) self.fc nn.Linear(len(filter_sizes) * num_filters, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): # x: [batch, seq_len] 的 token ids emb self.embedding(x) # [batch, seq_len, emb_dim] emb emb.permute(0, 2, 1) # [batch, emb_dim, seq_len] conv_outputs [] for conv in self.convs: c conv(emb) # [batch, num_filters, seq_len - fs 1] c F.relu(c) c F.max_pool1d(c, c.size(2)) # 1-max pooling conv_outputs.append(c.squeeze(2)) combined torch.cat(conv_outputs, dim1) combined self.dropout(combined) logits self.fc(combined) return logitsfilter_sizes[2, 3, 4]是最常见的配置分别对应二元组、三元组、四元组特征。比如“不好”是二元组“非常满意”是三元的。num_filters256表示每种尺寸的卷积核有 256 个这个值在样本量不大时建议降到 128否则全连接层的参数量会翻倍容易过拟合。padding_idx0和前面 embedding 矩阵里PAD的位置对齐确保 padding 位置的 embedding 梯度不会被更新。permute这一步是把维度顺序从[batch, seq_len, emb_dim]转成[batch, emb_dim, seq_len]因为nn.Conv1d默认对最后一维做卷积而我们需要在序列长度方向滑动。卷积核宽度上还有一个细节kernel_size不能超过seq_len否则F.max_pool1d的输入长度会变成负数直接报错。所以训练时要保证所有样本截断或填充到同一个max_len而且这个长度要大于filter_sizes中的最大值。分类任务通常取max_len为 64 或 128过长的文本截断即可TextCNN 本身抓的就是局部特征文本超过 200 字时尾部信息对分类的贡献已经很低。4. 训练与评估文本分类的指标别只看 accuracy4.1 损失函数与类别不平衡weight 参数怎么设TextCNN 训练时最常用的损失函数是交叉熵。但工业场景里文本分类的标签分布几乎永远是不均衡的比如垃圾文本过滤中“正常”类别占 90%“垃圾”只占 10%。这时候如果直接用nn.CrossEntropyLoss()模型会倾向于把所有样本都预测为多数类因为这样 loss 最小。解决办法是给少数类更大的权重让模型在训练时“多看”少数类的错误。from sklearn.utils.class_weight import compute_class_weight import numpy as np # train_labels 是训练集的原始标签列表 classes np.unique(train_labels) weights compute_class_weight(class_weightbalanced, classesclasses, ytrain_labels) class_weight_dict dict(zip(classes, weights)) # 按 label 的索引顺序构造权重张量 label2idx {label: i for i, label in enumerate(classes)} weight_tensor torch.tensor([class_weight_dict[label] for label in classes], dtypetorch.float32) criterion nn.CrossEntropyLoss(weightweight_tensor).to(device)compute_class_weight的balanced模式会自动计算每个类别的权重公式是总样本数 / (类别数 * 该类别样本数)。这样少数类的权重天然更大多数类的权重小于 1。要注意的是weight_tensor的排列顺序必须和模型输出的类别索引顺序完全一致否则权重会张冠李戴——这个问题非常隐蔽模型训练完 loss 在降但少数类的 F1 反而更差了。4.2 训练参数batch size、epoch、学习率怎么配文本分类的模型不大但参数选择依然会直接影响结果。batch_size我一般取 64 或 128太大容易让模型收敛到尖锐的极小值泛化差太小则训练不稳定。epoch不要定死用 early stopping 在验证集 loss 连续 3 个 epoch 不下降时停止。learning_rate用 Adam 优化器时从 1e-3 起步如果 loss 震荡明显就降到 5e-4 或 1e-4。这里有一条血泪经验不要一开始就用学习率调度器scheduler先把固定学习率跑通再加入衰减策略否则你分不清 loss 不降是模型问题还是调度器问题。from torch.optim import Adam from torch.utils.data import DataLoader, TensorDataset optimizer Adam(model.parameters(), lr1e-3) for epoch in range(30): model.train() total_loss 0.0 for batch_x, batch_y in train_loader: batch_x, batch_y batch_x.to(device), batch_y.to(device) logits model(batch_x) loss criterion(logits, batch_y) optimizer.zero_grad() loss.backward() # 梯度裁剪防止 embedding 微调时梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() avg_loss total_loss / len(train_loader) val_loss, val_acc evaluate(model, val_loader) print(fepoch{epoch}, train_loss{avg_loss:.4f}, val_loss{val_loss:.4f}, val_acc{val_acc:.4f}) if early_stopping(val_loss): breakclip_grad_norm_这行很多人会漏掉。当embedding层开微调时词向量的梯度可能非常大导致训练 loss 突然变成 NaN。裁剪到 5.0 的意思是把所有参数的梯度向量拼接后如果 L2 范数大于 5就按比例缩放回去保证梯度方向不变但步子变小。这个操作几乎不影响收敛速度但能避免很多“训练到一半 loss 爆炸”的诡异情况。4.3 评估指标精确率、召回率、F1 与混淆矩阵文本分类的评估不能只看 accuracy。类别不均衡时95% 的 accuracy 可能是“把所有样本都预测成多数类”得到的。正确做法是看每个类别的精确率precision、召回率recall和 F1再算 macro-F1 和 weighted-F1。macro-F1 对所有类别一视同仁如果你的任务关心少数类的识别能力就盯着 macro-F1 看如果更关心整体效果看 weighted-F1。from sklearn.metrics import classification_report, confusion_matrix model.eval() all_preds [] all_labels [] with torch.no_grad(): for batch_x, batch_y in test_loader: batch_x batch_x.to(device) logits model(batch_x) preds torch.argmax(logits, dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(batch_y.numpy()) print(classification_report(all_labels, all_preds, target_namesclass_names)) print(confusion_matrix(all_labels, all_preds))classification_report会打印每个类别的 precision、recall、F1 和样本数以及 macro/weighted 平均。看这个报告时要盯两件事一是少数类的 recall 是不是明显低于多数类如果是说明类别权重还没起作用二是混淆矩阵中哪些类别互相混淆比如“体育”和“娱乐”新闻经常分不清这时候要考虑是不是这两个类别的训练样本本身就不够或者分词后关键词重叠度过高。不要把报告打印出来就完事要把 bad case 抽样打印出来逐条看这是调模型最有效的动作。5. 文本分类实战中的 5 个高频坑与排查思路5.1 Embedding 矩阵没对齐loss 正常下降但验证集 acc 一直上不去现象训练 loss 降得很顺利但验证集 accuracy 在 50% 左右晃等于随机猜测。原因构建 embedding 矩阵时没有做词表对齐word2idx的顺序和 embedding 矩阵的行号不一致导致输入 token id 对应的向量是别的词的向量模型相当于在随机特征上训练。这个错特别隐蔽因为代码能跑loss 能降就是 acc 不对。解决加载完 embedding 矩阵后做一个对齐断言。取十个在语料中出现次数最多的词分别从w2v_model和embedding_matrix中取向量计算余弦相似度应该是 1.0。如果不是检查word2idx的构建顺序和填充逻辑。我自己的经验是直接在构建 embedding_matrix 的循环里加一个assert np.allclose(embedding_matrix[idx], w2v_model.wv[word])一旦不匹配立刻报错不要相信肉眼检查。5.2 预测时输入长度不一致长文本效果飘忽不定现象训练时把所有文本截断到 128 个字预测时输入一个 300 字的文本模型效果明显变差。原因长度变化本身不是问题问题是长文本里有效信息可能分布在靠后位置截断后直接被切掉了。比如一篇新闻的关键信息在最后一段总结里前面的内容全是铺垫截断后模型根本看不到关键句。解决在预处理阶段统计训练集文本长度的分布用 95 分位数作为max_len而不是拍脑袋定 64 或 128。如果 95 分位数是 150就把max_len定为 160。如果文本确实很长可以考虑用“首尾截断法”——保留前 100 字和后 50 字中间直接丢弃。这个技巧在新闻分类和评论分类里都很有效因为开头和结尾通常是信息密度最高的位置。5.3 个别类别 recall 为 0少数类被模型直接忽略现象分类报告里某几个类别的 recall 是 0.00precision 也是 0.00模型完全没预测出这些类别。原因样本量太少加上类别权重设置不当或没设置模型把少数类当噪声处理了。如果少数类样本只有几十条即使加了权重也很难学到有效特征。解决先看样本量少于 100 条的类别不建议直接进模型训练。一种思路是做简单的数据增强——对少数类样本做同义词替换、随机删除部分词、在分词后打乱词序等生成 3 到 5 倍的合成样本。另一种思路是降级为“先二分类再细分”的层级分类把样本量极少的类别先统一归为“其他”训练一个粗分类器再对“其他”类做进一步判断。不要指望一个模型解决所有不平衡问题。5.4 训练和预测时分词结果不一致同一个词被切成不同颗粒度现象训练时 jieba 把“自然语言处理”切成一个词预测时切成“自然”“语言”“处理”三个词导致预测样本里大量 OOV模型效果断崖式下跌。原因jieba 的词典在训练和预测代码里不一致。常见情况是训练脚本加载了自定义词典预测服务的代码里忘记加载同一份词典或者加载了但路径错误。解决把分词函数单独封装成一个公共模块训练和预测都从同一个模块里 import确保jieba.load_userdict的路径只有一处配置。我一般会把用户词典路径放在一个config.py文件里训练和预测共用同一份配置从根源上杜绝不一致。部署上线前写一个冒烟测试准备 20 条线上真实文本跑一遍分词检查是否有KEYERROR或明显切错的词。5.5 验证集 loss 先降后升过拟合来了现象前 5 个 epoch 验证集 loss 持续下降第 6 个 epoch 开始反弹但训练集 loss 还在下降。原因模型开始记住训练数据里的噪声。TextCNN 虽然结构简单但当num_filters256、embedding 层微调时参数总量依然不小样本量只有几千条时非常容易过拟合。解决先看样本量。如果训练集少于 1 万条固定 embedding 层num_filters降到 128dropout从 0.5 提到 0.6。如果训练集超过 5 万条还过拟合检查是不是标签噪声太大抽样 100 条数据人工核对标签是否准确。另外加正则化weight_decay1e-4是常见选择。dropout 的位置也不要只加在全连接层前可以考虑在 embedding 层后加一个nn.Dropout2d这会对整个 embedding 矩阵的行做随机置零相当于一种词级别的数据增强。6. 验证与上线的最后一步用混淆矩阵做回归测试别信验证集数字模型训练完我从来不会直接信验证集上的 accuracy 和 F1。原因是验证集本身是从训练数据里切出来的分布未必和线上真实请求一致。我的习惯是从线上日志里随机抽 500 条真实文本这些文本是模型从未见过的、包含各种噪声的比如用户随手打的错别字、中英文混排、表情符号等。把这 500 条文本跑一遍预测再和人工标注结果做对比计算真实场景下的准确率。这个数字才是能向业务方汇报的数字。对比的方式不要只算一个准确率要打印出预测错误的前 50 条逐条看模型错在哪。最常见的情况是错在“中性”和“正向”边界上的样本占比很高这通常不是模型问题而是标注标准本身不统一。如果发现这个问题正确的做法是回到标注环节重新定义类别边界而不是继续调模型参数。调参只能微调不能解决标准不一致。进阶技巧如果要在服务端部署 PyTorch 模型不要直接加载整个 checkpoint而是把模型导出成 TorchScript 或者 ONNX 格式。导出前把模型切到 eval 模式并去掉dropout层或确认它只在训练时生效。导出后要用同一批测试数据跑一遍对比导出前后的预测结果差异差异为 0 才算导出成功。这里有一个很容易忽略的点导出的模型不包含word2idx和分词逻辑线上服务必须额外加载词表文件并执行同样的预处理流程。所以我的做法是把word2idx存成 JSON和模型文件放在同一个目录服务启动时一起加载。最后说一个我自己的习惯每次实验从头到尾记录一份实验表包含数据量、词向量训练方式、embedding 是否微调、filter_sizes、num_filters、dropout、学习率、最终 macro-F1、以及线上 500 条样本的实测准确率。这个表不用很复杂一个 Markdown 文件就够了但它的价值在于一个月后你再回来调模型不需要靠回忆就能知道哪些配置已经被试过哪些方向值得继续试。文本分类的调参有时候确实带点玄学但能把实验历史串起来玄学就会变成可复用的方法论。希望这些踩坑经验能帮到你也祝你的文本分类项目少走几步弯路。本文还有配套的精品资源点击获取