ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Word2Vec+TextCNN文本分类实战:从词向量训练到模型部署全流程

2026/9/28 16:29:06 拓冰建站 浏览量
Word2Vec+TextCNN文本分类实战:从词向量训练到模型部署全流程 简介面向NLP初学者与文本分类实践者的Word2VecTextCNN实战项目覆盖情感分析、新闻分类等常见场景。压缩包共13个文件包含Python源码、词向量训练文本、词汇表、训练与测试CSV数据集以及训练好的模型权重总大小53.6MB。已有749人学习下载。项目基于10万条带标签样本训练并预留1万条测试数据评估效果详细演示CBOW与Skip-gram两种词向量方法以及TextCNN中卷积层、池化层、全连接层的构建过程可帮助理解n-gram特征提取与文本分类原理。通过这份资料还能学习文本分词、停用词处理、词索引映射、超参数调整与模型保存复用等完整流程适合作为NLP分类任务的快速参考和实验模板。1. 用 word2vectextcnn 做文本分类这份资源值得下载跑一遍吗做文本分类时很多人第一反应是把 BERT 直接怼上去但在数据量只有几十万条、算力又有限的场景里word2vectextcnn 依然是我会优先考虑的一套组合。word2vec 把语义相近的词映射成稠密向量textcnn 再用几组不同尺寸的卷积核抓文本局部 n-gram 特征两者配合在普通 CPU 上十几分钟就能跑完一轮训练效果也不输很多复杂模型。这份资源是一个能直接落地的项目包包含 10 万条带标签的训练集、1 万条测试集、已经训练好的 word2vec 词向量文本、词表文件以及核心代码 word2vectextcnn.py。它不是让你看 PPT 学概念而是能直接复现的分类流程。适合刚接触 NLP、想把文本分类从理论变成实际结果的人也适合拿来做后续复杂模型的 baseline。2. Word2Vec 词向量训练语料清洗、分词与词表构建全流程2.1 原始 CSV 长什么样先看清字段再决定清洗策略拿到nCoV_100k_train.labled.csv之后我一般不会直接写模型而是先把数据读出来看字段。这个习惯能省掉后面很多对不上号的麻烦。import pandas as pd train_df pd.read_csv(nCoV_100k_train.labled.csv, encodingutf-8) print(train_df.shape) print(train_df.columns.tolist()) print(train_df.head(3))第一行打印会告诉你数据规模是不是 10 万条第二行是列名第三行是看前三条数据长什么样。常见的坑在编码上如果文件是 UTF-8 带 BOMencodingutf-8会读到\ufeff把第一列列名污染掉这时改成encodingutf-8-sig就好。如果分隔符不是逗号而是制表符需要在read_csv里加sep\t否则整行会被当成一个字段。数据本身的字段一般就两个一个标签列一个文本列。标签可能是 0/1也可能是字符串类别读进来之后要先确认它是不是连续整数后面会直接影响 CrossEntropyLoss 的使用。文本列如果是社交媒体短文本清洗策略通常是这几步转小写、去掉 URL、去掉 用户名、去掉非字母字符。import re def clean_text(text): text text.lower() text re.sub(rhttp\S, , text) text re.sub(r\w, , text) text re.sub(r[^a-z\s], , text) return text.strip() train_df[clean_text] train_df[text].apply(clean_text)这里按英文语料的常见做法写用[^a-z\s]把数字和标点都去掉。如果你的项目是中文文本清洗规则要换成去掉非中文字符分词也会从split()换成 jieba逻辑是一样的。需要注意清洗和执行分词必须用同一份代码否则后面 word2vec 和 vocab 会出现词对不上的问题。2.2 分词与词表构建vocab.txt 是怎么来的文本分类里词表文件vocab.txt承担的是「词到索引」的映射。TextCNN 输入是整数序列但这个整数不是随便编的它必须和 word2vec 词向量矩阵的行号对应上。from collections import Counter def tokenize(text): return text.split() train_df[tokens] train_df[clean_text].apply(tokenize) counter Counter() for tokens in train_df[tokens]: counter.update(tokens) vocab {word: idx 1 for idx, (word, _) in enumerate(counter.items())} vocab[PAD] 0 vocab[UNK] 1这段代码先把清洗后的文本按空格切分成 token再用 Counter 统计每个词出现的次数。vocab字典里词频最高的词排在前面索引从 1 开始为什么不是从 0 开始因为 0 这个位置要留给填充符PAD卷积的时候填充位置不应该携带语义信息。同时我把UNK放到索引 1训练集没出现过的词在推理时统一映射到它。把词表写进文件时常见格式是每行一个词加一个索引中间用制表符隔开。这份资源里的vocab.txt大概率就是这种格式。写文件时我会刻意加上编码参数encodingutf-8Windows 下默认 GBK 编码容易在后续读取时报UnicodeDecodeError。2.3 用 gensim 训练 Word2Vec窗口、向量维度与 min_count 的取舍训练词向量的工具我一般直接用 gensim 的 Word2Vec 实现它把 CBOW 和 Skip-gram 都封装好了不需要自己反向传播。这份资源里的word2vec_txt.txt就是训练产物——每一行是一个词加上它的向量数值。from gensim.models import Word2Vec sentences train_df[tokens].tolist() w2v_model Word2Vec( sentences, vector_size100, window5, min_count2, workers4, epochs5, sg0, ) w2v_model.wv.save_word2vec_format(word2vec_txt.txt, binaryFalse)参数含义这里解释一下因为它们直接影响后面 TextCNN 的效果vector_size100词向量维度。TextCNN 的 Embedding 层维度必须和它严格一致后面模型里embed_dim也设 100改一个就要同时改另一处。window5上下文窗口大小。窗口越大词向量越偏向主题聚类窗口越小越偏向语法和近义词。文本分类任务用 5 比较稳。min_count2出现次数少于 2 的词不训练向量。这样做的目的是把低频噪声词过滤掉否则最后UNK对应的向量几乎学不到信息。sg0选用 CBOW 还是 Skip-gram。CBOW 训练速度快对小数据量更平滑Skip-gram 对低频词更友好但慢。十万条语料用 CBOW 足够了。epochs5迭代次数。不是越多越好迭代到后期词向量会过拟合训练语料泛化反而下降。save_word2vec_format保存的是纯文本格式每个词一行先输出该词的向量维度统计然后逐行输出词和向量。之所以不用二进制格式是因为后面 PyTorch 加载时按文本行解析更方便出问题也容易定位。我在实际项目里通常会先拿w2v_model.wv.most_similar(vaccine, topn5)看一眼训练出来的词向量有没有语义如果返回的相似词明显不合理说明语料清洗或参数设置有问题这时候再往下走 TextCNN 只会放大错误。3. TextCNN 模型搭建卷积核尺寸、Embedding 初始化与核心参数配置3.1 为什么 TextCNN 能抓文本特征TextCNN 的核心思想是把句子当成一张「一维图像」。输入是[batch, max_len]的整数索引经过 Embedding 层变成[batch, max_len, embed_dim]的稠密矩阵然后用多个不同宽度的卷积核在词序列方向上滑动。宽度为 2 的卷积核相当于抓 bigram 特征宽度为 3 抓 trigram宽度为 4 抓 4-gram。每个卷积核输出一个特征图再经过全局最大池化取出最强特征。这里有一个容易被忽略的点如果 Embedding 层用随机初始化模型必须从头学习词的分布训练时间会成倍增加。而用 word2vec 训练好的词向量做初始化相当于给模型灌入了预训练知识卷积层只需要在已有语义上做特征组合收敛速度快很多。这份资源里加载word2vec_txt.txt就是为了做这一步。3.2 搭建一个可运行的 TextCNNPyTorch 实现以下是文本分类任务里最常见的 PyTorch 实现模型结构包含 Embedding、三层卷积、全局最大池化和一个全连接层。import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_filters, filter_sizes, num_classes): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList() for size in filter_sizes: self.convs.append( nn.Conv1d(in_channelsembed_dim, out_channelsnum_filters, kernel_sizesize) ) self.fc nn.Linear(len(filter_sizes) * num_filters, num_classes) self.dropout nn.Dropout(0.5) def forward(self, x): x self.embedding(x) # [batch, max_len, embed_dim] x x.permute(0, 2, 1) # [batch, embed_dim, max_len] conv_outs [] for conv in self.convs: c torch.relu(conv(x)) # [batch, num_filters, conv_seq_len] c c.max(dim2)[0] # 全局最大池化 conv_outs.append(c) out torch.cat(conv_outs, dim1) # [batch, len(filter_sizes) * num_filters] out self.dropout(out) return self.fc(out)注意padding_idx0这个参数。因为之前词表里把PAD放到了索引 0这里必须告诉 Embedding 层索引 0 是一个填充位不参与梯度更新。如果漏掉这一项填充位置会被当成普通词训练出无意义向量还会影响池化结果。Conv1d的输入要求是[batch, channels, length]所以x.permute(0, 2, 1)是必须的。卷积之后每个样本得到[num_filters, conv_seq_len]的特征图max(dim2)[0]对每个特征图取全局最大得到num_filters维向量。每个卷积核尺寸产生一组特征全部拼起来之后经过全连接层输出分类 logits。3.3 把 Word2Vec 向量灌进 Embedding 层模型定义好之后加载word2vec_txt.txt把词向量逐一写入 embedding 矩阵。这步是资源落地的关键也是最容易报错的地方。import numpy as np vocab_size len(vocab) embed_dim 100 embedding_matrix np.random.uniform(-0.05, 0.05, size(vocab_size, embed_dim)) with open(word2vec_txt.txt, r, encodingutf-8) as f: for line in f: parts line.strip().split() if len(parts) embed_dim 1: continue word parts[0] vector np.asarray(parts[1:embed_dim 1], dtypenp.float32) idx vocab.get(word) if idx is not None: embedding_matrix[idx] vector model TextCNN(vocab_sizevocab_size, embed_dimembed_dim, num_filters128, filter_sizes[2, 3, 4], num_classes2) model.embedding.weight.data.copy_(torch.from_numpy(embedding_matrix)) model.embedding.weight.requires_grad True读文件时按空格切分第一部分是词后面是向量数值。vocab.get(word)找到这个词在词表中的索引然后把对应行替换成 word2vec 向量。查不到的词保留随机初始化值这样UNK也有一组可训练的向量不会在反向传播时崩溃。requires_grad设为 True 还是 False 是个取舍。设 False 表示冻结词向量只训练卷积层和全连接层适合数据量小、防止过拟合的场景。设 True 表示让词向量继续微调适应任务数据效果通常会更好但训练时间也变长。我一般先冻结跑几个 epoch如果过拟合再放开微调。3.4 超参数配置表每个参数影响什么资源里的模型效果好不好很大程度上取决于超参数。给出我常用的基线配置参数推荐值影响embed_dim100必须与 Word2Vec 向量维度一致max_len100截断长度过长则训练慢过短会丢信息filter_sizes[2, 3, 4]抓 n-gram 特征范围num_filters128每个尺寸卷积核的数量越多特征越丰富dropout0.5防止全连接层过拟合batch_size128越大训练越稳但吃显存learning_rate1e-3Adam 优化器的默认入门值num_classes2必须与标签类别数一致max_len 的选择最好看训练集文本长度分布取 90% 分位数而不是拍脑袋设个 200。设得太大大部分样本都是填充卷积特征被稀释设得太小长文本信息被截断。我一般会先跑一下train_df[tokens].str.len().describe()根据 75 分位和 90 分位的值来定。4. 训练与评估数据加载、loss 曲线监控与 test.model 的保存4.1 数据集与 DataLoader 的构建TextCNN 的输入必须是固定长度的整数序列这一步在 Dataset 里完成最合适。import torch from torch.utils.data import Dataset, DataLoader class TextDataset(Dataset): def __init__(self, texts, labels, vocab, max_len): self.samples [] for text in texts: tokens clean_text(text).split() ids [vocab.get(w, vocab[UNK]) for w in tokens[:max_len]] ids ids [vocab[PAD]] * (max_len - len(ids)) self.samples.append(torch.tensor(ids, dtypetorch.long)) self.labels torch.tensor(labels, dtypetorch.long) def __len__(self): return len(self.labels) def __getitem__(self, idx): return self.samples[idx], self.labels[idx]vocab.get(w, vocab[UNK])处理的是训练时没见过的词测试集里这种情况很常见。截断发生在取前max_len个 token 时如果文本长度不够则用PAD补齐。这里填充的索引和前面padding_idx0一致不能混。DataLoader 里需要关注shuffleTrue开训练集、batch_size按显存调整。测试集 loader 一般不用 shuffle因为评估指标不依赖数据顺序。4.2 训练循环loss 下降怎么看训练循环本身不复杂但有几个操作值得固定下来。model.train() optimizer torch.optim.Adam(model.parameters(), lr1e-3) loss_fn nn.CrossEntropyLoss() best_acc 0.0 for epoch in range(10): total_loss 0.0 for batch_texts, batch_labels in train_loader: optimizer.zero_grad() logits model(batch_texts) loss loss_fn(logits, batch_labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() avg_loss total_loss / len(train_loader) print(fepoch {epoch}, loss {avg_loss:.4f})clip_grad_norm_是防梯度爆炸的保险。word2vec 初始化后如果某些词向量在训练初期被大步长更新梯度范数可能突然飚到几十loss 直接变 NaN。加了梯度裁剪之后这类问题基本消失。loss 初始值可以根据类别数和标签分布大概估算一下。二分类用 CrossEntropyLoss如果随机初始化loss 应该接近-ln(0.5)0.693。如果你发现第一个 epoch 的 loss 正好卡在 0.69 不往下走先别急着调学习率大概率是标签没有转换成 0 开始的连续整数CrossEntropy 在悄悄报错却没有崩溃。4.3 在测试集上评估与保存 test.model训练完成后用nCov_10k_test.csv算准确率。这份资源里test.model就是训练完毕保存的模型权重文件。model.eval() correct 0 total 0 with torch.no_grad(): for batch_texts, batch_labels in test_loader: logits model(batch_texts) preds logits.argmax(dim1) correct (preds batch_labels).sum().item() total batch_labels.size(0) print(ftest accuracy: {correct / total:.4f}) torch.save(model.state_dict(), test.model)评估时一定要写torch.no_grad()否则会缓存计算图显存溢出几乎必然发生。准确率达到多少算合格取决于数据集难易程度但一个合理的信号是如果测试集准确率比训练集低超过 5 个百分点要考虑过拟合或数据预处理不一致。保存时我习惯只存state_dict()而不是整个 model 对象。只存权重的好处是文件体积小、跨版本兼容性好。但代价是加载时需要手动重建模型结构而且必须保证重建时传入的vocab_size、num_filters、filter_sizes与训练时完全一致。这份资源里直接命名成test.model没有明显的 PyTorch 后缀加载时你只需要知道它是 state_dict 格式就行。5. 避坑与常见问题排查文本分类训练里最常翻车的五个点5.1 Embedding 加载时词对不上准确率低得像随机猜现象代码不报错训练 loss 正常下降但测试集准确率只有 50% 左右接近随机。原因vocab.txt 和 word2vec_txt.txt 不是用同一份语料生成的。比如词表来自全量数据而 word2vec 训练时设了min_count5大量低频词没有向量Embedding 矩阵大部分行是随机初始化语义信息根本没传进去。解决在加载词向量之后加一行自检代码统计命中率。hit 0 for word in vocab: if word in w2v_model.wv: hit 1 print(fhit rate: {hit / len(vocab):.2f})命中率低于 90% 就要回头检查是不是min_count设太高或者清洗规则和分词方式不一致。这是我吃过亏的地方一次用了清洗后的语料训练 word2vec却拿未清洗的原始文本构建词表结果一半词都匹配不上。5.2 模型报错index out of rangevocab_size 比实际索引小现象训练第一个 batch 就抛IndexError: index out of range in self。原因构建nn.Embedding时传入的vocab_size小于实际数据里存在的最大索引。常见于动态构建词表时去重不彻底或者测试集里出现了词表之外的新词但 Dataset 里vocab.get(w, 1)返回了UNK的索引 1理论上不该越界。真正的问题往往出在vocab_size计数错误比如手写len(set(vocab.keys()))时把PADUNK重复计算。解决直接取len(vocab)作为 Embedding 的vocab_size不要自己减一或加一。训练之前打印一下max(ids)和vocab_size - 1做对照。5.3 显存 OOMbatch_size 怎么调都不行现象CUDA out of memory减小 batch_size 后仍然偶发崩溃。原因输入max_len设得过大比如 300加上 128 个卷积核同时计算每个样本产生的中间特征图会占据大量显存。另一个隐患是加载 word2vec 时如果用model.embedding.weight.data.copy_()后没有释放原始的embedding_matrix这块大矩阵会一直驻留在内存里给你添堵。解决优先缩短max_len到 100再配合num_filters64试跑一个 batch用torch.cuda.max_memory_allocated()看实际峰值。如果数据本身都很短把max_len从 200 降到 80 对显存节省是非常可观的。5.4 loss 不降或震荡问题不一定在模型结构现象做了多个 epochloss 一直停在初始值附近或者剧烈上下跳动。原因这个现象的原因很多最常见的是标签没有映射成从 0 开始的连续整数导致 CrossEntropyLoss 计算时类别内部错位。还有可能是batch_size太小每个 batch 统计噪声太大梯度方向随机。解决先打印labels.value_counts()确认类别数。如果标签是字符串用label_to_id {label: idx for idx, label in enumerate(labels.unique())}做映射。然后调大batch_size到 128 或 256。仍然无效再考虑降低learning_rate到 1e-4。5.5 加载 test.model 时missing keys或unexpected keys报错现象load_state_dict抛出 Missing key(s) 或 Unexpected key(s)。原因重建模型时传入的num_filters、filter_sizes与训练时不一致。比如训练时filter_sizes[2, 3, 4]加载时写成了[2, 3, 4, 5]多出来的卷积层权重在 state_dict 里找不到对应 key。解决把模型配置和权重一起存进文件或者加载后先打印model.state_dict().keys()和pt_file里的 key 做对比。最稳妥的做法是保存时用字典结构torch.save({ model_state: model.state_dict(), config: { vocab_size: vocab_size, embed_dim: embed_dim, num_filters: num_filters, filter_sizes: filter_sizes, num_classes: num_classes, } }, test.model)加载时先读 config 再重建模型这基本可以杜绝配置对不齐的问题。6. 进阶用法加载 test.model 做单条样本推理与效果验证6.1 从词表对齐到推理输出一个完整的单条预测函数模型训练完只是第一步实际使用时需要嵌入到业务里比如给一条新文本打标签。推理函数看起来不难但有几个细节卡过不少人。def predict(text): tokens clean_text(text).split() ids [vocab.get(w, vocab[UNK]) for w in tokens[:max_len]] ids ids [vocab[PAD]] * (max_len - len(ids)) input_tensor torch.tensor([ids], dtypetorch.long) model.eval() with torch.no_grad(): logits model(input_tensor) prob torch.softmax(logits, dim1).squeeze() pred prob.argmax().item() return pred, prob[pred].item()推理时最容易犯的错是忘记调用model.eval()。模型里包含 Dropout不切到 eval 模式每次推理会随机丢弃部分神经元同样的输入会得出不同的结果。我这里把这一行放在with torch.no_grad()外面明确它的作用。这个函数的价值还在于它可以作为回归测试的入口。数据清洗、词表、模型权重任何一个环节改过之后都可以拿同一条样本跑一遍看输出是否稳定。我之前的做法是准备 5 条覆盖不同类别的样本训练完跑一轮记录预测结果和置信度之后任何代码改动都先过一遍这个回归用例。6.2 验证词向量语义与模型置信度的配合我常用来验证模型有没有学到真实特征的一个简单技巧是把预测置信度低的样本筛出来人工看。如果低置信度样本大多是文本长度短、包含生僻词的输入说明模型行为合理如果某些类别整体置信度高但准确率低说明分类边界学偏了。还有一个容易被忽略的验证点检查UNK这个词对应的向量最终变化。训练完成后再看model.embedding.weight[1]如果它和其他词向量的距离异常远说明未登录词的梯度更新方式有问题可以考虑在train模式下多跑几个 epoch 让它充分调整。我在最初自己跑这个项目时偷懒跳过 word2vec 预加载直接让 Embedding 从随机初始化开始训结果测试准确率明显掉了一个档次。从那以后我每次加载词向量都会写一段命中率自检代码跑任何文本分类任务都强制确认词表与向量对齐再进训练循环。希望这套流程能帮你也少踩一遍这些坑。本文还有配套的精品资源点击获取