简介:这份资源围绕CNN与word2vec结合的序列分类方案展开,面向具备一定深度学习基础、希望动手实践文本分类的NLP学习者与毕设开发者,可用于情感分析、主题识别及基因序列分类等场景。压缩包共5个文件,约174KB,包含2个png模型结构与损失曲线图、1个py训练脚本、1个txt自定义词库以及1个fasta格式序列数据,覆盖从词向量构建、卷积池化特征提取到全连接分类的完整流程。资源以基因序列分类为具体案例,展示了如何将生物序列转为词向量并输入CNN完成分类,同时提供模型架构图与训练损失变化图,便于理解网络设计与收敛过程。目前已有168人学习下载,适合作为文本序列分类入门与毕设复现的参考素材。
1. 词向量喂进卷积核:为什么文本分类能像图像一样“看”
把一句话拆成词、每个词映射成一个稠密向量、再拼成一张二维矩阵,最后用卷积神经网络在这张矩阵上滑窗——这套流程听起来像是把图像分类的模型硬套到文本上。但真正反直觉的地方在于:它不但能 work,而且在很多序列分类任务上,比循环神经网络更快、更稳、更容易并行。很多人第一次接触 CNN 做文本分类时,脑子里会冒出一个疑问:卷积核在图像上捕捉的是局部边缘和纹理,那它在词向量矩阵上到底在捕捉什么?答案其实很朴素——它捕捉的是 n-gram 级别的局部语义组合。一个宽度为 3 的卷积核,扫过三个相邻词的向量,学到的就是“这三个词放在一起意味着什么”。而 word2vec 提供的稠密向量,让语义相近的词在向量空间里距离更近,卷积核因此能在连续空间里做平滑的语义匹配,而不是像 one-hot 那样每个词都是孤立的。理解这条链路,是搞懂所有“CNN + word2vec”序列分类方案的地基。
2. 从离散词到稠密向量:word2vec 到底在优化什么
2.1 为什么 one-hot 喂不进卷积核
假设词表大小是 50000,one-hot 向量就是 50000 维,每个词只有一个位置是 1,其余全是 0。把这样一句话拼成矩阵,形状是序列长度 × 50000。卷积核在这个矩阵上滑窗,每次只覆盖几个词,但每个词向量里 49999 个维度都是零。卷积核的权重和这些零相乘,梯度几乎全部消失,模型根本学不到词与词之间的关联。更致命的是,任意两个不同词的 one-hot 向量点积都是零,余弦相似度也是零,模型没有任何先验知识去判断“开心”和“高兴”应该被同等对待。
word2vec 要解决的就是这个问题:把每个词压缩到一个低维稠密空间(常见 100 到 300 维),让语义相似的词在空间中彼此靠近。它的训练目标不是直接服务于分类任务,而是通过上下文预测来学习词与词之间的共现关系。常见的两种实现思路是 Skip-gram 和 CBOW,前者用中心词预测周围词,后者用周围词预测中心词。Skip-gram 对低频词更友好,CBOW 训练速度更快,选择哪种取决于语料规模和任务对低频词的敏感度。
2.2 负采样与层次 softmax:训练效率的关键设计
原始 word2vec 的输出层是一个 softmax,词表有多大,分母就要算多大。50000 个词意味着每次更新都要遍历整个词表,计算量不可接受。常见的实现思路是用负采样(Negative Sampling)或层次 softmax(Hierarchical Softmax)来近似。
负采样的逻辑是:每次只更新一个正样本和少量负样本。正样本是真实上下文词,负样本是从噪声分布中随机采样的词。这样每次更新的计算量从 O(V) 降到 O(K),K 通常取 5 到 20。噪声分布一般取词频的 0.75 次方,这个指数是为了压低高频词被采样的概率,让低频词也有机会被选为负样本。
# 负采样核心逻辑示意(简化版) import numpy as np def negative_sampling_loss(center_vec, context_vec, neg_vecs): """ center_vec: 中心词向量,形状 (d,) context_vec: 真实上下文词向量,形状 (d,) neg_vecs: 负样本词向量矩阵,形状 (K, d) """ # 正样本得分,越高越好 pos_score = sigmoid(np.dot(center_vec, context_vec)) # 负样本得分,越低越好 neg_scores = sigmoid(np.dot(neg_vecs, center_vec)) # 正样本损失:-log(正样本得分) pos_loss = -np.log(pos_score + 1e-8) # 负样本损失:-sum(log(1 - 负样本得分)) neg_loss = -np.sum(np.log(1 - neg_scores + 1e-8)) return pos_loss + neg_loss这段代码里,sigmoid把点积映射到 0 到 1 之间,正样本希望接近 1,负样本希望接近 0。1e-8是防止 log 零的平滑项。实际训练时,负样本不是每次重新采样,而是按批次采样,并且高频词会被降权。这个设计的精妙之处在于:它把多分类问题拆成了一组二分类问题,每个二分类只关心“这个上下文词是真的还是假的”,而不是“它具体是词表里的哪一个”。模型因此不需要维护完整的 softmax 归一化,训练速度提升一个数量级。
2.3 词向量矩阵怎么变成卷积网络的输入
训练好 word2vec 之后,每个词对应一个 d 维向量。一句话有 n 个词,就得到一个 n × d 的矩阵。这个矩阵就是卷积网络的输入,和图像的 H × W × C 在形式上一致,只是通道数 C 变成了 1(或者把 d 看作通道数,n 看作高度)。
常见的做法是:把预训练好的词向量矩阵作为 Embedding 层的初始化权重,然后在训练分类任务时选择是否冻结。如果任务数据量小,冻结词向量可以防止过拟合;如果数据量足够,微调词向量能让它更适配当前任务。这个选择没有绝对答案,但一个实用的经验是:先冻结训练几轮,观察验证集表现,再决定是否解冻。
提示:词向量维度不是越大越好。100 到 300 维覆盖了大多数序列分类场景,超过 300 维后收益递减,但显存和计算量线性增长。
3. 卷积核在词向量矩阵上滑窗:n-gram 特征是怎么被提取的
3.1 卷积核宽度与 n-gram 的对应关系
在图像里,卷积核通常是 3×3 或 5×5 的正方形。但在文本上,卷积核的宽度(覆盖几个词)和高度(词向量维度)是解耦的。常见的配置是:宽度取 2、3、4、5,高度等于词向量维度 d。宽度为 3 的卷积核,每次覆盖 3 个连续词的完整向量,输出一个标量。这个标量就是这 3 个词组合在一起的一个特征响应。
为什么宽度要取多个值?因为语言里的语义组合长度不固定。“不 好”是两个词,“非常 不 好”是三个词,“一点 都 不 好”是四个词。单一宽度的卷积核只能捕捉固定长度的 n-gram,多宽度并行才能覆盖不同粒度的语义单元。这就是 TextCNN 的核心设计:多个不同宽度的卷积核同时工作,各自提取不同长度的 n-gram 特征,最后拼接在一起。
import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, filter_sizes, num_filters): super().__init__() # 词嵌入层,padding_idx=0 表示填充符不参与梯度更新 self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # 多个不同宽度的卷积核,每个宽度有 num_filters 个 self.convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (fs, embed_dim)) for fs in filter_sizes ]) self.dropout = nn.Dropout(0.5) self.fc = nn.Linear(num_filters * len(filter_sizes), num_classes) def forward(self, x): # x: (batch, seq_len) emb = self.embedding(x) # (batch, seq_len, embed_dim) emb = emb.unsqueeze(1) # (batch, 1, seq_len, embed_dim) # 每个卷积核输出 (batch, num_filters, seq_len - fs + 1, 1) conv_outs = [torch.relu(conv(emb)).squeeze(3) for conv in self.convs] # 全局最大池化,每个卷积核只保留最强响应 pooled = [torch.max(out, dim=2)[0] for out in conv_outs] cat = torch.cat(pooled, dim=1) # (batch, num_filters * len(filter_sizes)) return self.fc(self.dropout(cat))这段代码里,nn.Conv2d(1, num_filters, (fs, embed_dim))的卷积核高度等于embed_dim,意味着它一次覆盖完整的词向量维度,只在序列方向上滑动。squeeze(3)去掉最后一个维度,因为高度方向已经被完全覆盖,输出高度为 1。torch.max(out, dim=2)[0]是全局最大池化,对每个卷积核的输出序列取最大值。这个池化的含义是:不管这个 n-gram 出现在句子的哪个位置,只要它出现过,就保留最强的那次响应。这让模型对位置不敏感,但也丢失了位置信息——这是 CNN 做文本分类的一个固有取舍。
3.2 最大池化为什么比平均池化更适合文本分类
图像分类里,平均池化和最大池化都有使用。但在文本分类任务上,最大池化几乎是默认选择。原因在于:一句话里真正决定类别的往往只有少数几个关键 n-gram。比如情感分类里,“太 差 了”这个三元组一旦出现,整句话大概率是负面的。最大池化保留每个卷积核的最强响应,相当于在问:“这句话里有没有出现这个特征?”而不是“这个特征平均出现了多少次?”
平均池化会把关键特征稀释掉。如果一句话有 20 个词,其中只有 3 个词构成强负面信号,平均池化后这个信号被 17 个中性词拉低,模型很难学到正确的决策边界。最大池化则不受影响,只要那个强信号存在,它就会被保留。
但最大池化也有代价:它完全丢弃了“这个特征出现了几次”的信息。对于需要计数的任务(比如“这句话里出现了几次否定词”),最大池化会失效。这时候可以考虑混合池化,或者用注意力机制替代池化。
3.3 多宽度卷积核的拼接与分类头设计
多个宽度的卷积核各自输出一个向量,拼接后送入全连接层。拼接的顺序不影响模型能力,但会影响可解释性。常见的做法是按宽度从小到大排列,方便后续分析哪个宽度的卷积核贡献最大。
分类头通常是一层或两层全连接,中间加 Dropout。Dropout 的比例在 0.3 到 0.5 之间比较常见,具体取决于训练集大小。如果训练集很小,Dropout 可以调高到 0.5 甚至 0.6;如果训练集很大,0.2 到 0.3 就够。全连接层的输出维度等于类别数,损失函数用交叉熵。
有一个容易被忽略的细节:卷积核的数量。每个宽度取 100 到 256 个卷积核是常见范围。太少会导致特征提取不充分,太多会增加参数量和过拟合风险。一个实用的调参策略是:先固定宽度为 3、4、5,卷积核数量从 100 开始,每次翻倍,观察验证集准确率的变化,直到收益不再明显。
4. 避坑与排查:训练不收敛、显存爆炸、词向量失效的常见问题
4.1 损失不下降,准确率卡在随机水平
现象:训练几轮后,损失在某个值附近震荡,验证集准确率接近类别比例的倒数。原因通常是学习率过大,导致梯度更新步长太大,模型在最优解附近反复跳跃。另一个可能原因是词向量初始化用了全零或全随机,没有加载预训练权重,模型从零开始学词向量,在数据量不足时很难收敛。解决:先把学习率降到 1e-4 或 1e-3,观察损失是否稳定下降。如果仍然不降,检查词向量是否加载成功,可以打印 Embedding 层的权重均值,确认不是全零。
4.2 显存溢出,batch size 调小后训练变慢
现象:训练时显存溢出,把 batch size 从 64 降到 16 后能跑,但每个 epoch 时间变长,且梯度噪声变大。原因在于词向量矩阵本身占显存,如果词表是 50000、维度 300,Embedding 层就是 50000 × 300 × 4 字节,约 60MB。卷积核数量多、序列长度长时,中间激活值占的显存更大。解决:不要盲目调小 batch size,先检查序列最大长度是否设得过大。很多任务里,超过 200 个词的序列很少,把最大长度从 500 截断到 200,显存占用能降一半以上。另外,可以用梯度累积来模拟大 batch,每累积 4 个小 batch 再更新一次参数。
4.3 词向量微调后反而变差
现象:加载预训练词向量后,冻结时验证集准确率 85%,解冻微调后掉到 80%。原因是分类任务的训练数据量远小于 word2vec 的预训练语料,微调时词向量被小数据集带偏,丢失了预训练阶段学到的通用语义知识。解决:先冻结训练几轮,让分类头收敛,再解冻词向量,并且把词向量的学习率设为分类头的十分之一。这样词向量只做小幅调整,不会剧烈偏移。
4.4 最大池化导致位置信息完全丢失
现象:模型在短文本上表现好,在长文本上表现差,尤其是需要判断“否定词在关键词前面还是后面”的任务。原因是最大池化对位置不敏感,不管 n-gram 出现在哪里,都只保留最强响应。解决:如果任务对位置敏感,可以把最大池化换成注意力池化,让模型自己学习每个位置的权重。或者保留一部分位置编码,把位置向量拼接到词向量上再送入卷积。
4.5 卷积核宽度选择不当导致 n-gram 覆盖不全
现象:模型在验证集上表现尚可,但在测试集上遇到长短语时频繁出错。原因是卷积核宽度只取了 3,无法覆盖 4 到 5 个词的组合。解决:宽度至少取 2、3、4 三个值,如果任务里长短语多,再加一个 5。宽度为 1 的卷积核相当于对每个词单独做特征变换,通常不单独使用,但可以作为补充。
5. 设计取舍:为什么 CNN 在序列分类上仍然值得用
CNN 做序列分类有一个被低估的优势:它的计算是高度并行的。循环神经网络必须按时间步展开,前一个时间步算完才能算下一个,训练时 GPU 利用率上不去。卷积核在序列上滑窗,每个窗口的计算互不依赖,可以一次性全部算完。在同等硬件条件下,CNN 的训练速度通常比 LSTM 快 3 到 5 倍。
但 CNN 的短板也很明显:它的感受野受卷积核宽度限制。宽度为 3 的卷积核只能看到 3 个词,要看到更远的依赖关系,必须堆叠多层卷积。堆叠两层后,感受野扩大到 5 个词;堆叠三层,扩大到 7 个词。这和 Transformer 的自注意力机制相比,效率低很多。自注意力一次就能看到全序列,但计算量是序列长度的平方。所以这里有一个清晰的取舍:短文本、对训练速度敏感、需要快速迭代的场景,CNN 是更务实的选择;长文本、需要捕捉长距离依赖的场景,Transformer 或混合架构更合适。
另一个取舍是词向量的来源。用预训练 word2vec 初始化,相当于把通用语义知识注入模型,在小数据集上优势明显。但预训练词向量是静态的,同一个词在不同语境下向量相同。比如“苹果”在“吃苹果”和“苹果手机”里向量一样,模型只能靠后续的卷积层去区分。如果任务对一词多义敏感,可以考虑用 ELMo 或 BERT 这类上下文相关的词向量替代静态 word2vec,但计算成本和工程复杂度会显著上升。
我自己的习惯是:拿到一个序列分类任务,先用 CNN + word2vec 跑一个基线。如果基线准确率已经满足需求,就不上更复杂的模型。如果基线不够,再分析是数据问题、特征问题还是模型容量问题。很多时候,把词向量维度从 100 调到 300、把卷积核宽度从 3 扩到 2/3/4/5、把最大长度截断到合理范围,比换模型带来的提升更大。从那以后我每次调序列分类任务,都强制先跑一遍 CNN + word2vec 基线,再决定要不要上更重的架构。希望帮到你。
本文还有配套的精品资源,点击获取