1. 文本情感分析的项目定位与整体设计思路
1.1 这个项目到底在解决什么问题
文本情感分析,说白了就是让机器读懂一段文字背后的情绪倾向——是正面、负面还是中性。这个需求在实际场景中极其普遍:电商平台需要自动识别用户评论是好评还是差评,社交媒体运营团队需要监控品牌口碑走向,客服系统需要根据用户留言的情绪紧急程度做优先级排序。如果全靠人工阅读,一天处理几千条就已经是极限,而实际业务中动辄几十万上百万条数据,人工根本扛不住。
我这次做的项目,核心目标就是搭建一个能自动完成情感分类的模型,输入一段中文文本,输出它属于正面还是负面的概率。选择LSTM和TextCNN这两个模型来做对比和融合,原因很直接:它们分别代表了文本建模的两条经典路线,LSTM 擅长捕捉长距离的时序依赖,TextCNN 擅长提取局部 n-gram 特征。单独用某一个,总会在某类样本上吃亏,把两者结合起来,能覆盖更广的语言现象。
这个项目适合谁参考?如果你已经了解 Python 基础、用过 PyTorch 或 TensorFlow 写过简单的神经网络,但还没完整走过一遍文本分类的全流程,那这篇内容基本可以当施工图来用。如果你是完全零基础,也没关系,我会把每个环节的“为什么”讲清楚,你照着步骤走也能跑通。
1.2 为什么选 LSTM 和 TextCNN 而不是别的
做文本分类,可选的模型架构其实不少。传统方法有 TF-IDF 加朴素贝叶斯或者 SVM,深度学习路线有 FastText、TextRNN、TextCNN、BERT 等等。我选 LSTM 和 TextCNN 组合,是基于几个很实际的考量。
第一,数据规模。BERT 这类预训练模型效果好,但需要的数据量和算力都不小。如果你的标注数据只有几万条,BERT 微调很容易过拟合,反而不如轻量模型稳。LSTM 和 TextCNN 在几万到几十万条数据量级上表现非常扎实。
第二,推理速度。TextCNN 的卷积结构可以高度并行化,推理速度极快,LSTM 虽然串行计算慢一些,但参数量小,整体延迟可控。如果业务场景要求实时返回结果,这个组合比 BERT 更友好。
第三,可解释性。TextCNN 的卷积核可以直观理解为在捕捉不同长度的 n-gram 特征,LSTM 的隐状态也能反映序列信息的流动。出了问题,排查起来比黑盒模型容易得多。
第四,融合空间大。LSTM 和 TextCNN 的输出可以拼接后接全连接层,让模型同时具备全局时序建模和局部特征提取的能力。这种双通道架构在很多文本分类竞赛中被验证过,效果稳定。
注意:模型选型没有绝对的好坏,只有适不适合。如果你的数据量超过百万级且有充足算力,BERT 系列确实更强;但如果数据量中等、要求快速上线,LSTM+TextCNN 是性价比极高的选择。
1.3 整体架构一览
整个项目的流程可以拆成六大块:数据采集与清洗、分词与词表构建、词向量初始化、模型搭建、训练与调参、推理与部署。每一块都有不少细节坑,我后面会逐一展开。
架构上,我采用的是双通道并行结构:一条通道走 Embedding 层后接两层 LSTM,取最后时刻的隐状态作为句子表示;另一条通道走 Embedding 层后接多尺度卷积核的 TextCNN,取最大池化后的拼接向量。两个向量拼接后经过 Dropout 和全连接层,最后用 Softmax 输出二分类概率。
这个结构的好处是,LSTM 通道能捕捉“虽然……但是……”这类转折结构中的长距离依赖,TextCNN 通道能快速识别“太差了”“非常满意”这类强情感短语。两者互补,模型鲁棒性明显提升。
2. 数据准备与预处理的核心细节
2.1 数据来源与标注策略
做情感分析,数据质量决定天花板。我这次用的数据主要来自两个渠道:一是公开的中文情感分析数据集,二是自己从业务场景中积累的用户评论。公开数据集的好处是标注规范、量大,缺点是领域不匹配,直接拿来训练可能在你的业务场景上表现不佳。所以我的策略是:用公开数据做预训练或者冷启动,再用业务数据做微调。
标注环节有几个经验值得分享。首先,标注规范要提前定死。什么叫正面?什么叫负面?带讽刺的怎么算?“质量还行但物流太慢”这种混合情感归哪类?这些边界情况如果不提前统一,不同标注员标出来的结果会打架,模型学到的就是噪声。
其次,标注一致性检验不能省。我一般会抽 10% 的样本让两个人独立标注,算一下 Kappa 系数。如果低于 0.8,说明标注规范还不够清晰,需要重新培训标注员。这个步骤很多人跳过,结果模型训练完发现准确率上不去,回头查半天才发现是标注本身就有问题。
第三,类别平衡要关注。实际业务中正面评论往往远多于负面,如果直接训练,模型会倾向于预测多数类。我的做法是对少数类做上采样,或者用 Focal Loss 来调整类别权重。具体选哪种,看数据量和分布偏斜程度。
2.2 中文分词的工具选择与坑
中文和英文不一样,英文单词天然用空格隔开,中文需要先分词。分词工具我用过不少,结巴分词、HanLP、LTP 都试过。结巴分词胜在轻量、速度快、上手简单,适合快速原型;HanLP 功能更全,但依赖较重;LTP 在句法分析上更强,但分词速度偏慢。
这个项目我最终用的是结巴分词,原因很简单:情感分析对分词的精度要求没有句法分析那么高,结巴的准确率已经够用,而且它的自定义词典功能很方便,可以把业务领域的专有名词加进去,避免被切碎。
分词时有几个坑要注意。第一,停用词表要针对领域调整。通用停用词表里可能包含“不”“没”这类否定词,但在情感分析中,否定词恰恰是关键信号,绝对不能去掉。我一般会基于通用停用词表做裁剪,保留所有情感相关词汇。
第二,表情符号和网络用语的处理。用户评论里经常出现“yyds”“绝绝子”“无语子”这类网络热词,通用分词工具往往切不对。我的做法是维护一个动态更新的自定义词典,把新出现的网络热词加进去。这个词典需要定期更新,因为网络用语变化太快。
第三,长度截断策略。LSTM 和 TextCNN 都要求输入序列长度固定。太短的信息不够,太长的计算浪费。我统计了训练数据的长度分布,发现 95% 的样本在 128 个 token 以内,所以最终把最大长度设为 128,超出的截断,不足的补零。
2.3 词表构建与词向量初始化
词表构建看似简单,其实有几个关键决策。第一个是词表大小。太大,Embedding 层参数爆炸,训练慢且容易过拟合;太小,很多词变成 UNK,信息丢失。我的经验是,根据数据量来定:10 万条以下的数据,词表控制在 2 万到 3 万;10 万到 50 万条,可以放到 5 万;再大就可以考虑 10 万。
第二个是低频词处理。出现次数少于 3 次的词,我一般直接归为 UNK。这些词在训练集中样本太少,模型学不到有效表示,保留反而增加噪声。
第三个是词向量初始化。随机初始化不是不行,但收敛慢。我习惯用预训练词向量做初始化,中文可以用腾讯词向量或者百度词向量。加载预训练词向量时要注意:词表要对齐,预训练里有但你的词表里没有的词直接忽略,你的词表里有但预训练里没有的词用随机值填充。
实操心得:预训练词向量加载后,我一般会先冻结 Embedding 层训练几轮,让上层网络先适应,然后再解冻做微调。这样比一上来就联合训练更稳,尤其在小数据集上效果明显。
3. 模型搭建与核心代码实现
3.1 TextCNN 通道的设计细节
TextCNN 的核心思想是用不同尺寸的卷积核去捕捉不同长度的 n-gram 特征。比如卷积核大小为 2 时,它看的是相邻两个词的组合;大小为 3 时看三个词;大小为 4 时看四个词。这样模型能同时感知“很好”“非常好”“好得不得了”这些不同粒度的情感表达。
具体实现上,Embedding 层输出维度我设为 300,和预训练词向量对齐。卷积核我用了三种尺寸:2、3、4,每种尺寸 128 个卷积核。卷积之后接 ReLU 激活,然后做最大池化。最大池化的作用是,对每个卷积核,只保留它在整个序列上最强的那个响应。这样不管情感词出现在句子的哪个位置,都能被捕捉到。
这里有个细节:池化方式的选择。最大池化适合捕捉强信号,平均池化适合捕捉整体趋势。我试过两者拼接,效果比单用一种略好,但提升有限,计算量却增加了。最终我选了最大池化,因为情感分析中强信号往往比平均趋势更重要。
另一个细节是padding 的处理。因为序列有补零,卷积时如果不做 mask,补零位置也会参与计算,影响池化结果。我的做法是在池化前把补零位置的响应设为负无穷,这样最大池化就不会选中它们。
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_filters, filter_sizes, dropout): super(TextCNN, self).__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (fs, embed_dim)) for fs in filter_sizes ]) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(num_filters * len(filter_sizes), 2) 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] conv_outs = [] for conv in self.convs: c = F.relu(conv(emb)).squeeze(3) # [batch, num_filters, seq_len - fs + 1] c = F.max_pool1d(c, c.size(2)).squeeze(2) # [batch, num_filters] conv_outs.append(c) out = torch.cat(conv_outs, dim=1) out = self.dropout(out) return self.fc(out)3.2 LSTM 通道的搭建与调优
LSTM 通道我用了两层双向 LSTM。双向的意思是,每个时刻的输出同时包含从左到右和从右到左的上下文信息。对于情感分析来说,后面的词往往能修正前面的理解,比如“我以为很好,结果……”如果只看前面,会误判为正面,双向结构就能避免这个问题。
隐层维度我设为 128,两层堆叠。层数不是越多越好,两层已经能捕捉大部分依赖关系,再深容易过拟合,而且训练速度明显下降。Dropout 我设在 LSTM 层之间,概率 0.3,防止过拟合。
取句子表示时,我用的是最后一层双向 LSTM 的最后一个时刻的前向隐状态和第一个时刻的后向隐状态拼接。这样得到的向量同时包含了整个序列的正向和反向信息。
class LSTMEncoder(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers, dropout): super(LSTMEncoder, self).__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers, bidirectional=True, batch_first=True, dropout=dropout if num_layers > 1 else 0) self.dropout = nn.Dropout(dropout) def forward(self, x): emb = self.dropout(self.embedding(x)) output, (hidden, cell) = self.lstm(emb) # hidden: [num_layers * 2, batch, hidden_dim] hidden = hidden.view(self.lstm.num_layers, 2, -1, self.lstm.hidden_size) last_hidden = torch.cat([hidden[-1, 0], hidden[-1, 1]], dim=1) return self.dropout(last_hidden)3.3 双通道融合与分类头设计
两个通道的输出维度分别是:TextCNN 是num_filters * len(filter_sizes),我设的是 128 * 3 = 384;LSTM 是hidden_dim * 2,即 128 * 2 = 256。拼接后是 640 维。
分类头我用了两层全连接:第一层 640 到 256,ReLU 激活,Dropout 0.5;第二层 256 到 2,输出 logits。为什么用两层而不是一层?一层也能用,但两层能让模型在融合特征后再做一次非线性变换,表达能力更强。Dropout 设 0.5 是因为全连接层参数多,容易过拟合。
损失函数用 CrossEntropyLoss,优化器用 AdamW,学习率 1e-3,权重衰减 1e-4。学习率调度用 ReduceLROnPlateau,验证集损失不下降时自动减半。
class DualChannelModel(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_filters, filter_sizes, num_layers, dropout): super(DualChannelModel, self).__init__() self.textcnn = TextCNN(vocab_size, embed_dim, num_filters, filter_sizes, dropout) self.lstm = LSTMEncoder(vocab_size, embed_dim, hidden_dim, num_layers, dropout) fusion_dim = num_filters * len(filter_sizes) + hidden_dim * 2 self.classifier = nn.Sequential( nn.Linear(fusion_dim, 256), nn.ReLU(), nn.Dropout(dropout), nn.Linear(256, 2) ) def forward(self, x): cnn_out = self.textcnn(x) lstm_out = self.lstm(x) fused = torch.cat([cnn_out, lstm_out], dim=1) return self.classifier(fused)注意:两个通道的 Embedding 层是独立的还是共享的?我试过两种方案。共享 Embedding 参数量少,训练快,但两个通道会互相牵制;独立 Embedding 更灵活,但参数量翻倍。实测下来,数据量小于 10 万条时共享更好,大于 10 万条时独立略优。我最终选了共享,因为我的数据量在 8 万条左右。
4. 训练流程与调参实战
4.1 训练集验证集划分与交叉验证
数据划分看似简单,但有个坑很多人踩:随机划分可能导致数据泄漏。如果你的数据里有同一条评论的多个变体,随机划分可能让变体同时出现在训练集和验证集,验证结果虚高。我的做法是先按用户 ID 或评论 ID 去重,再划分。
划分比例我一般用 8:1:1,即训练集 80%,验证集 10%,测试集 10%。如果数据量小于 1 万条,我会用 5 折交叉验证,把每一折都当一次验证集,最后取平均。这样能更充分地利用数据,评估结果也更稳定。
验证集的作用是调参和早停,测试集只在最后评估一次,绝对不能用来调参,否则测试结果就没有参考意义了。
4.2 批次大小与学习率的配合
批次大小和学习率是一对需要联合调节的超参数。批次太大,梯度估计准但更新次数少,收敛慢;批次太小,梯度噪声大,训练不稳定。我的经验是:数据量 10 万条以下,批次设 32 或 64;10 万到 50 万,设 128;再大可以设 256。
学习率和批次大小有个经验关系:批次翻倍,学习率也可以适当放大。但这不是线性的,我一般用 1e-3 作为起点,如果训练损失震荡就降到 5e-4,如果收敛太慢就升到 2e-3。
还有一个技巧是学习率预热。前几个 epoch 用很小的学习率,让模型先稳定下来,再逐步升到目标学习率。这对 Transformer 类模型很重要,对 LSTM+TextCNN 也有帮助,能减少初期震荡。
4.3 早停与模型保存策略
早停是防止过拟合最实用的手段。我监控验证集的 F1 分数,如果连续 5 个 epoch 没有提升,就停止训练。为什么用 F1 而不是准确率?因为如果类别不平衡,准确率会偏向多数类,F1 能更真实地反映模型对两个类别的综合表现。
模型保存我一般保留两个:一个是验证集 F1 最高的模型,用于最终推理;另一个是最后一个 epoch 的模型,用于分析训练过程。有时候最后 epoch 的模型在测试集上反而更好,因为验证集和测试集的分布可能有细微差异。
实操心得:训练过程中我习惯把每个 epoch 的训练损失、验证损失、验证 F1 都记录下来,画成曲线。如果训练损失持续下降但验证损失开始上升,就是过拟合的典型信号,这时候早停或者加正则化。如果两者都下不去,就是欠拟合,需要加大模型容量或者降低正则化强度。
4.4 超参数搜索的实用方法
超参数搜索不用一上来就上贝叶斯优化或者遗传算法,那样太耗时。我的做法是分两阶段:先粗调,再精调。
粗调阶段,我固定其他参数,只调一个。比如先调学习率,试 1e-3、5e-4、2e-3 三档,看哪个验证集 F1 最高。然后固定学习率,调批次大小,试 32、64、128。再调 Dropout,试 0.3、0.4、0.5。这样一轮下来,基本能锁定一个不错的参数组合。
精调阶段,我在粗调结果附近做小范围网格搜索。比如学习率锁定在 5e-4 附近,就试 3e-4、5e-4、8e-4。这个阶段不需要太多轮,因为参数已经接近最优,微调空间不大。
整个调参过程我一般控制在 20 到 30 次实验以内,再多就边际收益递减了。每次实验记录好参数和结果,方便回溯。
5. 常见问题与排查技巧实录
5.1 模型不收敛或损失震荡怎么办
这是训练中最常见的问题。可能的原因和排查顺序如下:
第一,检查数据标签。我遇到过标签写反的情况,正面标成 0,负面标成 1,模型怎么学都学不对。排查方法是人工抽查几十条样本,确认标签和文本内容一致。
第二,检查学习率。学习率太大,损失会剧烈震荡甚至发散;太小,损失下降极慢。可以先试 1e-4 到 1e-2 之间的几个值,看哪个最稳。
第三,检查梯度。用torch.nn.utils.clip_grad_norm_做梯度裁剪,防止梯度爆炸。LSTM 对梯度爆炸比较敏感,裁剪阈值一般设 1.0 或 5.0。
第四,检查初始化。Embedding 层如果用随机初始化,方差设太大或太小都会影响收敛。我一般用 Xavier 或 Kaiming 初始化,或者直接加载预训练词向量。
5.2 验证集表现好但测试集差很多
这是典型的过拟合或者数据分布不一致。如果是过拟合,加 Dropout、加权重衰减、减少模型参数、增加训练数据都能缓解。如果是数据分布不一致,需要检查验证集和测试集的划分是否随机,以及测试集是否来自不同时间段或不同渠道。
我遇到过一次,验证集 F1 有 0.92,测试集只有 0.78。排查后发现测试集里有很多新出现的网络热词,训练集里没有。解决办法是把测试集里的新词加入自定义词典,重新分词后效果就上来了。
5.3 推理速度慢的优化思路
TextCNN 本身很快,LSTM 是瓶颈。优化方向有几个:一是减少 LSTM 层数,从两层降到一层,速度能提升 30% 左右,精度损失通常不到 1 个百分点;二是用 ONNX 或 TensorRT 做推理加速,能提升 2 到 3 倍;三是把模型量化成 FP16 或 INT8,速度提升明显,精度损失可控。
如果业务允许,还可以做模型蒸馏,用大模型教小模型,把小模型部署上线。不过这个工程量较大,适合对延迟要求极高的场景。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 损失不下降 | 学习率太小、标签错误、初始化不当 | 检查标签、试大学习率、换初始化 | 调整学习率、修正标签、加载预训练词向量 |
| 损失震荡 | 学习率太大、批次太小 | 降低学习率、增大批次 | 加梯度裁剪、学习率预热 |
| 验证集好测试集差 | 过拟合、分布不一致 | 对比验证集和测试集分布 | 加正则化、补充训练数据、更新词典 |
| 推理慢 | LSTM 串行计算、模型太大 | 测各层耗时 | 减层、量化、ONNX 加速 |
| 某类样本表现差 | 类别不平衡、样本太少 | 看混淆矩阵 | 上采样、Focal Loss、补充样本 |
避坑技巧:训练前一定要做一次小规模过拟合测试。拿 100 条数据,让模型训练到训练准确率 100%,如果能做到,说明模型结构和代码没问题,问题出在数据或超参上;如果做不到,说明代码有 bug,先修代码再调参。
6. 实际部署与效果评估的几点体会
6.1 离线评估指标的选择
准确率、精确率、召回率、F1 分数,这几个指标要结合业务看。如果业务对误报容忍度低,比如把正常评论误判为负面会导致用户投诉,那就优先看精确率;如果业务对漏报容忍度低,比如负面舆情漏掉会导致公关危机,那就优先看召回率。F1 是两者的平衡,适合作为综合指标。
AUC 也是常用指标,它衡量的是模型对正负样本的排序能力,不受阈值影响。如果业务需要灵活调整阈值,AUC 比 F1 更有参考价值。
6.2 在线 A/B 测试的设计
离线指标好不代表线上效果好。上线前一定要做 A/B 测试,把模型预测结果和人工判断做对比,看一致率有多高。我一般会抽 500 到 1000 条线上真实数据,让标注员重新标一遍,然后算模型和人工的一致率。如果一致率低于 85%,说明模型还不够可靠,需要继续优化。
A/B 测试还要关注延迟和吞吐量。情感分析通常是实时调用,如果单条推理超过 100 毫秒,用户体验就会受影响。我一般要求 P99 延迟在 50 毫秒以内。
6.3 模型迭代与数据闭环
模型上线不是终点,而是起点。线上会不断产生新的数据,这些数据是宝贵的反馈。我的做法是建立一个数据闭环:线上预测结果中置信度低的样本,自动进入待标注队列,人工标注后加入训练集,定期重新训练模型。这样模型能持续适应新的语言现象和业务变化。
迭代频率我一般控制在两周到一个月一次。太频繁,工程成本高;太慢,模型会逐渐落后于数据分布的变化。
最后分享一个小技巧:如果你的业务场景对情感强度有要求,比如需要区分“一般满意”和“非常满意”,可以把二分类扩展成多分类,用 1 到 5 星作为标签。这样模型输出的信息更丰富,后续做业务决策时也更灵活。