news 2026/10/21 16:25:40

从词袋到词向量:TextCNN与BiLSTM在新闻文本分类中的实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从词袋到词向量:TextCNN与BiLSTM在新闻文本分类中的实战

1. 从词袋到词向量:为什么深度学习模型需要更“懂语义”的输入

做新闻文本分类做到第五篇,很多人会卡在同一个地方:前面用TF-IDF加线性模型已经能跑到一个不错的分数,但再往上提就非常吃力了。我当初也是这个状态,线下验证集准确率卡在某个数字上,调参调了一整天,提升不到0.5个百分点。后来把思路从“特征工程”切换到“表示学习”,才真正打开了新的空间。

这个切换的核心,就是文本表示方式的升级。传统方法里,一篇新闻被表示成一个稀疏的高维向量,每个维度对应一个词,权重是TF-IDF值。这种表示有两个硬伤:第一,它完全丢失了词序,新闻里“球队战胜对手”和“对手战胜球队”在词袋模型里几乎一样;第二,它无法表达语义相似性,“手机”和“智能手机”在词袋里是两个完全正交的维度,模型学不到它们之间的关系。

深度学习文本分类要解决的,正是这两个问题。它通过词嵌入(Word Embedding)把每个词映射到一个低维稠密向量空间,语义相近的词在空间中距离更近;再通过序列建模结构(如卷积、循环、注意力)捕捉词与词之间的顺序和依赖关系。对于新闻分类这个任务来说,新闻标题和正文里往往存在大量同义替换、指代和省略,语义级别的表示能显著提升模型对长尾类别的识别能力。

我在实际项目里做过一个对比:同一份新闻数据集,用TF-IDF加线性分类器,宏平均F1大约在0.86左右;换成词嵌入加卷积网络之后,宏平均F1能到0.91以上,提升最明显的恰恰是那些样本量少、用词灵活的类别。这个差距不是靠调参能弥补的,它来自表示能力的本质差异。

所以这一篇的重点,不是再堆一个更复杂的网络结构,而是把“文本怎么变成模型能吃的数字”这件事讲透。我会从词向量的训练方式讲起,然后落到卷积网络和循环网络在新闻分类上的具体用法,最后给出完整的训练流程和我在实践中踩过的坑。如果你已经做过前四篇的练习,这一篇会帮你把分数再往上推一个台阶;如果你直接从这里开始,也能顺着走通整条链路。

2. 词向量的两条路线:预训练与随模型训练

2.1 预训练词向量:拿别人的语料先“补课”

预训练词向量的思路很直接:在大规模通用语料上先训练好每个词的向量,然后拿到自己的任务里直接用或者微调。常见的做法有Word2Vec、GloVe、FastText这几种。它们的共同点是,通过“一个词预测它周围的词”或者“用周围词预测中间词”这样的任务,让语义相近的词在向量空间里靠拢。

在新闻分类任务里,预训练词向量的优势很明显。新闻语料本身覆盖面广,但你的训练集可能只有几万条,某些类别的样本更少。如果从随机初始化开始训练词向量,低频词和长尾类别的词向量会训得很差。用预训练向量初始化,相当于让模型一开始就“认识”大部分词的含义,训练过程只需要微调。

我一般会这样操作:先加载预训练词向量文件,构建一个词到向量的映射字典;然后遍历训练集,把出现过的词对应的向量填进嵌入矩阵;对于预训练词典里没有的词,用均匀分布随机初始化,范围控制在正负0.05之间。嵌入矩阵的形状是[词表大小, 向量维度],这个矩阵作为模型的第一层,设置trainable=True,让它在训练中继续调整。

注意:预训练词向量的维度要和模型后续层的输入维度对齐。常见的选择是100维、200维或300维。维度太低表达力不够,太高则参数量大、容易过拟合。新闻分类任务里200维通常是个稳妥的起点。

有一个细节容易被忽略:预训练词向量是在通用语料上训的,而新闻文本里有很多专有名词、缩写和领域术语。这些词要么不在预训练词典里,要么向量质量很差。我的处理方式是,对这类词单独统计词频,如果词频低于某个阈值(比如3),就直接映射到统一的未知词向量;如果词频够高,就保留随机初始化并让模型重点学习它。

2.2 随模型训练词向量:把表示学习和分类任务绑在一起

另一条路线是不用预训练向量,直接把嵌入层作为模型的一部分,从随机初始化开始,在训练分类任务的同时学习词向量。这种做法在数据量足够大时效果很好,因为词向量会朝着“有利于分类”的方向优化,而不是单纯朝着“语义相似”的方向。

我做过一组对比实验:同一份新闻数据,一份用预训练词向量初始化后微调,一份完全随机初始化。在训练集规模小于5万条时,预训练方案收敛更快、最终F1更高;当训练集超过20万条时,两种方案差距缩小到0.5个百分点以内,随机初始化甚至在某些类别上略好。这说明数据量足够时,模型有能力自己学出合适的表示。

随模型训练还有一个好处是流程简单,不需要额外下载和加载词向量文件,整个训练过程端到端。对于新闻分类这种任务,如果你手头的数据量还可以,我建议至少跑一次随机初始化的版本作为基线,再和预训练版本对比,看哪个更适合你的数据分布。

2.3 两种路线的选择依据与混合策略

到底选哪条路线,我通常看三个指标:训练集规模、类别数量、以及低频词占比。下面这张表是我根据多次实验总结的判断依据,供你参考。

条件推荐路线理由
训练集小于5万条预训练词向量+微调数据不足,需要外部知识补充
训练集大于20万条随机初始化模型能自己学出任务相关的表示
类别数超过20类预训练词向量+微调长尾类别需要更好的语义泛化
低频词占比高预训练词向量+微调低频词随机初始化训不好
追求快速上线预训练词向量+冻结训练快,效果稳定

还有一种混合策略我也常用:嵌入层用预训练向量初始化,但前几个epoch先冻结不训练,让后面的网络层先适应;等损失下降变缓后再解冻嵌入层,用较小的学习率微调。这样做的好处是避免训练初期嵌入层被随机梯度带偏,把预训练学到的语义信息破坏掉。实测下来,这种策略比直接微调能快1到2个epoch达到相同效果。

3. 卷积网络处理新闻文本:把n-gram检测器叠起来

3.1 一维卷积在文本上的直观理解

卷积网络用在图像上大家很熟悉,但用在文本上,很多人第一反应是“文本是一维的,怎么卷积”。其实文本的一维卷积非常直观:把一句话看成[序列长度, 向量维度]的矩阵,卷积核在序列长度方向上滑动,每次覆盖连续若干个词。一个卷积核就是一个n-gram检测器,它专门识别某种局部模式。

举个例子,假设卷积核宽度为3,向量维度为200,那么这个核每次看3个连续的词,把它们对应的600个数值加权求和,输出一个标量。滑动完整个句子后,得到一个一维的激活序列。这个序列里的每个值,代表该位置是否存在这个卷积核所检测的模式。多个卷积核并行,就相当于同时检测多种n-gram模式。

新闻文本特别适合这种结构。新闻标题和正文里有很多固定的表达模式,比如“某地发生某事件”“某公司发布某产品”“某球队击败某对手”。这些模式往往由连续的几个词构成,卷积核能高效地把它们捕捉出来。而且卷积操作可以并行计算,训练速度比循环网络快很多。

3.2 卷积核尺寸、数量与池化方式的选择

卷积核的尺寸决定了模型能捕捉多长的局部模式。尺寸为2的核看二元组,尺寸为3的核看三元组,尺寸为4的核看四元组。新闻分类里,我一般会同时使用2、3、4三种尺寸,让模型同时捕捉不同粒度的模式。尺寸太小会丢失上下文,尺寸太大则参数量增加且容易过拟合。

卷积核的数量决定了每种尺寸能检测多少种不同的模式。数量太少,模型表达能力不够;数量太多,训练慢且容易过拟合。我的经验值是每种尺寸用128到256个核。如果类别数多、数据量大,可以适当增加到512;如果数据量小,就降到64或128。

池化方式我试过两种:最大池化和平均池化。最大池化取每个卷积核激活序列里的最大值,代表“这个模式在句子中最强烈的出现”;平均池化取平均值,代表“这个模式在句子中的平均强度”。新闻分类里,最大池化通常更好,因为一个关键模式出现一次就足以影响分类,不需要它在全句均匀分布。我一般用最大池化,然后把不同尺寸卷积核的输出拼接起来,送进全连接层。

下面是我常用的一个卷积文本分类结构的核心代码,用PyTorch写,你可以直接参考:

import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, filter_sizes=(2, 3, 4), num_filters=128, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(in_channels=embed_dim, out_channels=num_filters, kernel_size=fs) for fs in filter_sizes ]) self.dropout = nn.Dropout(dropout) 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.transpose(1, 2) # [batch, embed_dim, seq_len] conv_outs = [] for conv in self.convs: c = F.relu(conv(emb)) # [batch, num_filters, seq_len-k+1] p = F.max_pool1d(c, c.size(2)).squeeze(2) # [batch, num_filters] conv_outs.append(p) out = torch.cat(conv_outs, dim=1) # [batch, num_filters*len(fs)] out = self.dropout(out) return self.fc(out)

这段代码里,padding_idx=0表示填充符对应的向量不参与梯度更新,这是处理变长序列的标准做法。transpose(1, 2)把维度从[batch, seq_len, embed_dim]变成[batch, embed_dim, seq_len],因为一维卷积要求通道维在中间。池化用max_pool1d,池化窗口大小等于卷积后的序列长度,直接取全局最大值。

3.3 卷积网络的训练节奏与过拟合控制

卷积网络在新闻分类上训练起来很快,但也很容易过拟合。我的做法是:在嵌入层后、卷积层后、全连接层前都加Dropout,比例分别设为0.2、0.5、0.5。嵌入层的Dropout比较特殊,它随机把整个词向量置零,相当于强制模型不依赖某些词,能提升泛化。

学习率方面,我一般用Adam优化器,初始学习率1e-3,每3个epoch如果验证集损失不下降就乘以0.5。批大小设为64或128,序列长度统一截断或填充到256。新闻正文通常比标题长,但256个词已经能覆盖大部分关键信息,再长收益递减且显存占用大。

还有一个技巧是梯度裁剪。卷积网络虽然比循环网络稳定,但在某些批数据上梯度范数会突然变大,导致参数更新过猛。我一般把梯度范数裁剪到5.0,实测能减少训练过程中的震荡。

4. 循环网络与注意力:捕捉新闻中的长距离依赖

4.1 循环网络为什么适合新闻正文

卷积网络擅长捕捉局部n-gram模式,但新闻正文里有很多长距离依赖。比如“某公司昨天发布了一款产品,该产品采用了新技术,它将在下个月上市”,这里的“它”指代的是“产品”,中间隔了十几个词。卷积核宽度有限,很难直接建立这种跨距离的关联。循环网络通过隐藏状态的传递,理论上能把前面所有词的信息带到后面,更适合处理这种依赖。

不过标准循环网络有梯度消失问题,实际能记住的距离也就十几个词。后来出现的LSTM和GRU通过门控机制缓解了这个问题,能记住更长的依赖。在新闻分类任务里,我一般用双向LSTM或双向GRU,把正向和反向的隐藏状态拼接起来,这样每个词都能同时看到左边和右边的上下文。

4.2 双向LSTM的层数、隐藏单元与输出聚合

双向LSTM的隐藏单元数我一般设为128或256。层数方面,1层通常够用,2层在数据量大时能提升一点效果,但训练时间翻倍。新闻分类里,我倾向于用1层双向LSTM,把前向和后向最后一个时间步的隐藏状态拼接起来,送进全连接层分类。

输出聚合方式有两种:一种是取最后一个时间步的隐藏状态,代表整句话的总结;另一种是对所有时间步的隐藏状态做最大池化或平均池化。我试过两种,在新闻分类上平均池化略好于取最后一步,因为新闻里关键信息可能出现在任何位置,不一定在结尾。平均池化能综合全句信息,更稳健。

下面是一个双向LSTM分类器的核心结构:

class TextBiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes, num_layers=1, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers=num_layers, bidirectional=True, batch_first=True, dropout=dropout if num_layers > 1 else 0) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_dim * 2, num_classes) def forward(self, x): emb = self.embedding(x) # [batch, seq_len, embed_dim] out, _ = self.lstm(emb) # [batch, seq_len, hidden*2] out = out.mean(dim=1) # 平均池化 out = self.dropout(out) return self.fc(out)

这里batch_first=True让输入维度是[batch, seq_len, embed_dim],符合直觉。bidirectional=True让LSTM同时跑正向和反向,输出维度是hidden_dim * 2。平均池化用out.mean(dim=1),对序列维度求平均。

4.3 注意力机制在分类中的轻量用法

注意力机制能让模型对不同词赋予不同权重,在新闻分类里特别有用。比如一篇体育新闻里,“比赛”“比分”“球员”这些词权重高,“今天”“表示”这些词权重低。实现上,我一般用一个简单的加性注意力:对LSTM每个时间步的输出,先过一个线性层得到注意力分数,再softmax归一化,最后加权求和得到句子表示。

这种轻量注意力比标准Transformer里的自注意力简单得多,参数量小,训练快,在新闻分类上效果稳定。我试过把它加在双向LSTM后面,宏平均F1能再提升0.5到1个百分点,尤其是对那些关键词分散在全文的类别帮助明显。

注意力还有一个好处是可解释性。训练完之后,你可以把注意力权重可视化,看看模型到底关注了哪些词。我经常用这个来检查模型有没有学到合理的模式,比如有没有被标题里的噪声词带偏。如果发现注意力集中在一些无意义的词上,就说明数据预处理或模型结构有问题,需要调整。

5. 完整训练流程:从原始新闻到可评估模型

5.1 数据预处理中的几个关键决策

新闻文本分类的预处理,我一般按这个顺序走:先做基本清洗,去掉HTML标签、特殊符号、多余空白;然后做分词,中文新闻用jieba或类似工具,英文新闻按空格和标点切分;接着构建词表,按词频从高到低排序,取前N个词,其余映射到未知词;最后把每条新闻转成词索引序列,统一截断或填充到固定长度。

这里有几个决策点值得展开。词表大小取多少?我一般取3万到5万。太小会丢失信息,太大会增加嵌入层参数量。序列长度取多少?新闻标题短,正文长,我一般统一取256。如果正文特别长,可以只取前256个词,因为新闻的导语部分通常包含最关键的信息。低频词怎么处理?词频低于3的词直接映射到未知词,这样能减少词表大小和过拟合风险。

还有一个容易忽略的点是标签分布。新闻分类数据集往往类别不均衡,某些类别样本多,某些少。我一般会在训练时给每个类别加权,权重和样本数成反比,这样模型不会偏向多数类。评估时用宏平均F1而不是准确率,因为宏平均对每个类别一视同仁,更能反映模型在长尾类别上的表现。

5.2 训练循环、验证策略与早停

训练循环我一般这样组织:每个epoch遍历训练集,前向传播算损失,反向传播更新参数;每隔一定步数记录训练损失和准确率;每个epoch结束后在验证集上评估,记录验证损失和宏平均F1;如果验证损失连续3个epoch不下降,就停止训练并保存验证集上最好的模型。

验证集的比例我一般取训练集的10%到20%。如果数据量很大,可以取固定数量的验证样本,比如1万条。验证集要从训练集里随机划分,保证类别分布一致。测试集只在最后评估一次,不要用来调参,否则会过拟合测试集。

早停的耐心值我一般设3到5。太小容易停得太早,模型还没收敛;太大则训练时间长且可能过拟合。我通常设5,然后观察验证损失曲线,如果连续5个epoch都没改善就停。

5.3 学习率调度与正则化的组合拳

学习率调度我用得最多的是ReduceLROnPlateau:监控验证损失,如果连续2个epoch不下降,就把学习率乘以0.5。这样训练初期学习率大、收敛快,后期学习率小、精细调整。初始学习率设1e-3,最小学习率设1e-6。

正则化方面,除了Dropout,我还会加权重衰减,系数设1e-4或1e-5。权重衰减让参数不会变得太大,减少过拟合。另外,批归一化在卷积网络里也很有用,能加速训练并提升稳定性。我一般在卷积层后加批归一化,再接激活函数。

还有一个技巧是标签平滑。新闻分类里,有些样本可能介于两个类别之间,硬标签会让模型过于自信。标签平滑把硬标签变成软标签,比如正确类别概率0.9,其余类别平分0.1。这样能提升泛化,实测宏平均F1能提升0.3到0.5个百分点。

6. 踩坑记录:那些让我调了一整天的细节

6.1 嵌入层冻结与解冻的时机

我最早做预训练词向量微调时,直接让嵌入层和后面的网络一起训练,结果验证集效果比冻结嵌入层还差。排查后发现,训练初期后面的网络层参数是随机的,反向传播的梯度噪声很大,把预训练好的词向量带偏了。后来改成先冻结嵌入层训练2个epoch,等后面网络稳定后再解冻,效果就正常了。

解冻后学习率也要调小。我一般把嵌入层的学习率设为其他层的十分之一,这样词向量只做微调,不会大幅偏离预训练值。PyTorch里可以通过参数组给不同层设置不同学习率,实现起来很方便。

6.2 序列填充位置对池化的影响

用卷积网络时,我把序列填充到固定长度,填充符的索引是0。如果填充在序列末尾,最大池化不受影响,因为填充位置的激活值通常很小。但如果用平均池化,填充位置会拉低平均值,影响句子表示。我一开始没注意这点,用平均池化时效果比最大池化差很多,后来把填充位置的输出mask掉再平均,效果就正常了。

mask的做法是生成一个和序列等长的0/1向量,填充位置为0,真实词位置为1;计算平均时用这个向量加权,再除以真实词数量。这个细节在循环网络里同样重要,因为LSTM处理填充符也会产生隐藏状态,虽然影响比卷积小,但长序列里累积起来也不可忽略。

6.3 类别不均衡下的评估陷阱

新闻分类数据集里,有些类别样本数是其他类别的几十倍。我一开始用准确率评估,模型把所有样本都预测成多数类,准确率也有80%多,但宏平均F1只有0.3。后来改用宏平均F1,才真实反映模型性能。

处理不均衡还有几个手段:一是重采样,对少数类过采样或对多数类欠采样;二是类别加权损失,给少数类更高的损失权重;三是Focal Loss,让模型更关注难分类的样本。我一般先用类别加权损失,如果效果不够再加Focal Loss。重采样要小心,过采样容易导致少数类过拟合,欠采样会丢失多数类信息。

6.4 模型集成与结果复现

单模型调到瓶颈后,我一般会做模型集成。最简单的做法是训练多个不同随机种子的同结构模型,预测时把概率平均。更进一步的,可以训练不同结构的模型,比如一个TextCNN、一个BiLSTM、一个带注意力的BiLSTM,把它们的预测概率加权平均。实测下来,三个模型集成比单模型宏平均F1能提升1到2个百分点。

复现性方面,我一般固定随机种子,包括Python的random、NumPy的random、PyTorch的manual_seed,以及CUDA的种子。但即使这样,不同硬件和软件版本下结果也可能有微小差异。我的做法是跑3次不同种子,取平均和标准差,这样报告的结果更可靠。

7. 从实验到落地:我总结的几条实用经验

做新闻文本分类做到深度学习这一步,技术选型其实没有绝对的对错,关键看数据特点和业务需求。我自己的经验是,如果数据量在几万条级别,预训练词向量加TextCNN通常是最快出效果的组合,训练半小时到一小时就能看到不错的结果。如果数据量上到几十万条,双向LSTM加注意力往往能拿到更好的上限,但训练时间会长很多。

还有一个容易被忽视的点是推理速度。TextCNN的推理速度比BiLSTM快好几倍,因为卷积可以并行,而LSTM必须按时间步串行。如果业务对延迟敏感,比如需要实时分类新闻,TextCNN是更务实的选择。如果离线批量处理,BiLSTM的额外时间成本可以接受。

最后说一个我踩过的坑:不要一上来就堆复杂模型。我见过不少人直接上预训练语言模型微调,效果确实好,但训练成本高、推理慢,而且在小数据集上容易过拟合。我的建议是先把TextCNN和BiLSTM这两个基线跑通,把数据预处理、评估流程、超参数调优这些基础工作做扎实,再考虑要不要上更重的模型。很多时候,把基础模型调好,效果已经能满足业务需求了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 9:57:16

医院挂号就诊系统全栈实战:SpringBoot+Vue+MySQL毕设指南

1. 这个选题为什么值得做:先搞清楚它解决什么问题很多人在挑毕业设计题目的时候,第一反应是往“高大上”的方向冲——图像识别、推荐算法、语音交互。结果呢,算法原理讲不明白,工程实现做不出来,最后只能到处找现成代码…

作者头像 李华
网站建设 2026/10/19 1:19:17

基于SpringAI的在线考试主观题智能阅卷系统设计实践

做在线考试系统,最容易翻车的不是题库也不是组卷,而是阅卷。尤其主观题,一张卷子里有几道论述题和简答题,按传统方式让老师逐份批改,量大、耗时,而且两个老师打出的分可能差出一档。去年我在负责某个高校的…

作者头像 李华
网站建设 2026/10/18 23:31:23

模板代码生成工具:从元数据到可用接口的完整实现

1. 为什么需要模板代码生成工具:一大半重复代码其实不该手写做后端开发的朋友应该都有过这种体验:新项目启动,建表、写实体、写Mapper、写Service接口和实现、写Controller,然后前端再把列表页、表单页、弹窗、状态标签挨个撸一遍…

作者头像 李华
网站建设 2026/10/21 13:48:57

贪心算法实战:从陶陶摘苹果升级版掌握最优选择策略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/18 10:19:52

HDU6759 Leading Robots解题精要:上凸包与物理模型的工程实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/19 7:24:56

Vue 3 集成 wangEditor v5 富文本编辑器:避坑指南与最佳实践

1. 为什么在 Vue 项目里选富文本编辑器会让人头疼做过后台管理系统的朋友大概率都碰过这个场景:产品经理甩过来一个需求,说文章发布页要能加粗、能插图片、能调字号、能传视频,最好还能支持表格和代码块。你打开 npm 一搜,富文本编…

作者头像 李华