接到这个需求的时候,对方给了我一整份微博评论的Excel,说要做一个情感分析二分类模型。我当时没有急着去选网络结构,而是先把样本从头到尾翻了十来分钟。翻完之后我的判断是:这活儿不能照着教科书里的标准文本分类流程硬套。微博评论这东西,短、乱、情绪表达高度口语化,“哈哈哈这电影绝了”和“这电影绝了”可能就差一个词,语义方向却完全不同。也正是这次经历,让我最终确定了LSTM+CNN+GloVe词向量预训练模型这套方案:CNN负责抓局部关键词,LSTM负责串联上下文,GloVe负责在标注数据有限的情况下给模型提供先验语义。这篇文章就把整个项目的来龙去脉完整写下来,包括数据怎么洗、模型怎么搭、参数怎么调、最后踩了哪些坑,给准备做类似评论情感分析任务的朋友一个能直接参考的完整路线。
1. 先看数据再看模型:微博评论的“脾气”与二分类边界
1.1 微博评论和标准文本分类数据集差在哪
如果你以前主要拿新闻语料或者商品评论做分类任务,第一次面对微博评论的时候一定会有一个明显的不适应感。新闻文本结构完整、用词规范,商品评论虽然口语化但目标明确——夸就是正面、骂就是负面。微博评论是完全不同的物种。
首先它短。大多数评论不会超过30个字,很多甚至就是三五个字的梗:“绝了”“yyds”“笑不活了”“人麻了”。这种信息密度极低的文本,传统基于词频统计的特征很容易失效,因为同一个表达在不同语境下可能完全是两种情绪。
其次是网络热词密集。我做数据预处理的阶段,光自定义词典就加了上百个词:yy ds、绝绝子、u1s1、栓Q、破防、emo……这些词在通用语料里很少出现,或者即使出现,分词器也容易把它们切成碎片。而且热词的迭代速度非常快,这个月流行的梗下个月可能就没人用了,所以模型必须依赖预训练词向量来兜底,不然完全靠标注数据学这些词,成本高得离谱。
再次是噪声多。@用户、URL链接、表情符号、重复叠词、繁体字、全角半角混用,这些在评论里几乎是标配。一条“哈哈哈哈哈哈哈哈哈哈哈哈哈哈太好笑了吧”里面,真正有效的情感词其实只有一个“好笑”,剩下的全是冗余字符。如果不做压缩清洗,词表会被这种无意义的字符组合撑爆,模型也会被带偏。
最后是反讽和隐晦表达多。豆瓣社区式的“阴阳怪气”、微博常见的“呵呵”“挺好”“你开心就好”,这些在字面上看完全没有负面词,但在上下文里往往是负面情绪。这让二分类任务的难度直接上了一个台阶,也是我后来在错误样本分析里重点关注的类型。
1.2 二分类边界:中性样本怎么归置是个大坑
需求方说要二分类,但原始数据标注通常是三分——正面、负面、中性。这里就碰到了一个所有做情感分析的人都会纠结的问题:中性样本到底算哪边?
我见过两种主流做法。第一种是把中性直接归入负面,理由是电商场景里“不表扬就等于差评”,用户没有明确表达满意,说明体验离预期有距离。第二种是只保留明显正面和明显负面的样本,把中性丢掉,尤其当你的模型容量不大时,混入大量中性样本会让决策边界变得很模糊,“模型不知道该往哪边靠”。
我当时选了第二种思路的变体:先看三条标注的一致性,把三个人都标成中性的样本直接剔除;被标成中性的但带有明显情绪词的,重新讨论归入对应类别;最终保留正负两类,各自一万条出头。做完这一步之后,模型的收敛速度明显快了,验证集上的指标也涨了一截。
这里要提醒一句:如果你的业务场景确实需要识别“中性”,那就别硬做二分类,扩成三分类任务更合理。二分类的前提一定是业务上只关心正负两个方向。否则你把中性样本生硬地塞进某一类,模型学到的边界就是乱的,上线之后预测结果也会让人摸不着头脑。
1.3 为什么是LSTM+CNN+GloVe,而不是直接上BERT
模型选型这件事,我的立场从来都是“先看自己手里有什么”。我当时手头只有一万多条标注数据,对计算资源也有要求,线上推理还得兼顾速度。直接上BERT类预训练模型不是不行,但对这个体量的任务来说性价比不高。
GloVe词向量的价值在于,它在大规模语料上通过共现统计学习到了词的语义表示。比如“精彩”和“好看”虽然在标注数据里可能没有同时出现在同一条评论里,但它们在词向量空间中是相近的,模型可以通过Embedding层把这种相似性带到下游任务里来。这相当于把外部知识注入了模型,是数据量不足时最划算的杠杆。
CNN的强项是捕捉局部n-gram特征。情感表达往往由特定的关键词组合触发,比如“太……了”“一点儿都……不……”“……到爆”。这类固定搭配通常只出现在一个很小的窗口内,正好是CNN卷积核擅长扫描的范围,多个不同尺寸的卷积核可以同时捕捉不同长度的短语模式。
LSTM则负责把词的顺序和上下文关系串起来。短文本虽然信息少,但语序往往决定语义,“我不喜欢”和“喜欢我不”完全不是一回事。LSTM对顺序敏感,双向LSTM还能同时看到某个词之前和之后的内容,对“这部电影,我劝你别看”这种句子很有帮助。
把一个任务拆给两个模型分支去做,最后把特征拼接起来,是我在实际项目里验证过很稳的方案。单用CNN能抓到关键词但容易忽略语境,单用LSTM能理解上下文却又对强特征词的响应不够直接。两个分支互补,效果比各自单跑都有提升。
2. 数据预处理:清洗、分词、词表与序列化,每一步都在影响最终效果
2.1 清洗规则:哪些该删,哪些该留
很多教程会把预处理写得轻描淡写,好像正则替换几条规则就完事了。实际做下来,清洗规则的设计直接决定词表质量,也直接影响后续词向量的覆盖率。我在这部分花的时间比搭模型还多。
我的清洗流程是这样的:
import re import opencc converter = opencc.OpenCC('t2s') # 繁体转简体 def clean_text(text: str) -> str: # 1. 去掉URL和@用户 text = re.sub(r'https?://\S+|www\.\S+', ' ', text) text = re.sub(r'@\S+', ' ', text) # 2. 去掉话题标签符号,保留话题文字 text = re.sub(r'#([^#]+)#', r'\1 ', text) # 3. 压缩重复字符:哈哈哈哈哈哈 -> 哈哈哈哈 text = re.sub(r'(.)\1{2,}', r'\1\1', text) # 4. 繁体转简体、全角转半角 text = converter.convert(text) text = re.sub(r'[“”]', '"', text) # 5. 表情符号替换成特殊token text = re.sub(r'\[(.*?)\]', r' [EMOJI_\1] ', text) return text.strip()这里有几条容易踩坑的规则。第一条是压缩重复字符,一定要放在分词前面。微博里“哈哈哈哈哈”非常常见,如果不压缩,“哈哈哈哈”和“哈哈哈哈哈”在词表里会变成两个完全不同的词,而GloVe预训练词向量里通常只收录了“哈哈”,覆盖率一下子就掉下去了。我见过有人为了省事不压缩,结果词表里光“哈”的各种长度组合就占了上百个词位。
第二条是表情符号的处理。微博自带表情的原文形式是[赞]、[笑cry]这种中括号结构,如果直接删掉,会丢失大量情绪信号;如果保留中括号原样分词,又会变成奇怪的碎片。我的做法是把整个表情转成一个带标识的token,比如[EMOJI_赞]、[EMOJI_笑cry],这样模型可以学到这个token和情绪之间的关联。当然这个做法要求你的词向量覆盖这些token,覆盖不了也没关系,Embedding层对没覆盖的词会随机初始化,模型自己学。
第三条更重要:停用词表要慎用。做情感分析时,否定词“不、没、别、无”和程度副词“太、很、非常、极其”绝对不能进停用词表。“太差”和“好”是正面词,“不好”就是负面词,你把“不”删了,语义直接翻转。我刚开始就犯过这个错,去掉停用词后F1掉了好几个点。
2.2 分词与自定义词典:让“yyds”变成一个词
中文分词我用的是jieba的精确模式,准确率够用,关键是自定义词典必须跟上。做微博评论情感分析,分词的质量直接决定后续所有环节的上限。
你在通用语料里分词,“yyds”可能被切成字母碎片,甚至被当成英文过滤掉;而“绝绝子”这种叠词结构也可能被拆成“绝绝”和“子”。我当时的做法是定期把训练集里出现频率较高的未登录词捞出来,人工确认后加入自定义词典。
举几个我自己加进去的词:yyds、绝绝子、u1s1、栓Q、破防、下头、上头、安利、拔草、种草、真香、打call。这些词在微博语境里都是高频情绪词,分词正确之后,CNN分支的卷积核才有机会把它们识别为有效特征。
另外,分词之后我保留了词性过滤这一步,但只过滤掉明显的虚词和标点,词只保留长度大于1的。单字词里“牛”“绝”“妙”都是强情感词,不能一刀切。过滤用的停用词表是经过人工挑选的,和百度NLP那些通用停用词表不一样,原因就是上一条说的:情感分析场景下,否定词和程度副词必须保留。
2.3 词表构建、OOV与序列填充
预处理完成之后进入序列化阶段。我用的是Keras的Tokenizer,关键参数是num_words和oov_token。num_words我设的是五万,也就是词表保留频次最高的前五万个词。这个值不是拍脑袋定的,而是根据训练集的词汇量分布来的——五万以内基本覆盖了90%以上的评论词,再往上大多是低频噪声。
from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences tokenizer = Tokenizer(num_words=50000, oov_token='<OOV>') tokenizer.fit_on_texts(train_texts) sequences = tokenizer.texts_to_sequences(train_texts) # maxlen 我选了50,微博评论大部分在30字以内,留出余量 padded = pad_sequences(sequences, maxlen=50, padding='post', truncating='post')这里有一个很容易被忽略的细节:oov_token='<OOV>'会在词表里占一个索引,所有没进前五万的词都会被映射到这个统一token上。这样做的好处是模型不会因为见到生词就崩溃,坏处是OOV词丧失了个体语义。为了缓解这个问题,我把num_words适当调大了一些,并且在清洗阶段尽量把可能造成OOV的噪声处理掉。
关于maxlen的选择,短文本没必要拉太长。微博平均长度二三十字,设成50已经覆盖了绝大多数样本;如果设成100或200,大部分序列会被大量padding填充,模型在padding区域白白消耗计算量,还容易引入噪声。padding='post'和truncating='post'的含义是从尾部填充和截断,这样能尽量保留句子前面的完整语义。
序列化之后还有一个必须检查的环节:抽样看几条texts_to_sequences之后的索引序列是否和原文对应。我试过一次分词时没做繁简转换,导致同一句话的简体版本和繁体版本被当成两个不同的词,词表莫名其妙膨胀了很多。
2.4 训练集和验证集划分的隐藏风险
划分训练集和验证集时,我遇到过最典型的坑,就是同一条原始微博的不同评论被抓进了不同集合里。微博有评论串帖的现象,一条热门微博下面可能有几百条回复,这些回复共享同一个上下文主题,语言风格也趋同。如果按评论级别随机划分,主题相关的高相似样本会同时出现在训练集和验证集里,验证集的指标会虚高,给人“模型效果很好”的错觉,上线之后立刻现原形。
正确做法是按微博ID分组再划分,保证同一条微博的所有评论只出现在一边。这个细节我在第一次跑实验时没注意,验证集F1高达0.9,换成按ID分组划分之后直接掉到0.8附近,那才是最真实的水平。
3. 模型搭建:GloVe权重加载、TextCNN与BiLSTM融合的关键实现
3.1 Embedding层的加载姿势:对齐词表索引是核心
模型搭建的第一步不是定义网络结构,而是把GloVe预训练词向量加载进来,并且把词表索引和词向量矩阵对齐。这一步错一个下标,整个模型的学习过程就全乱了。
GloVe词向量文件的标准格式是每行一个词,行首是词,后面跟着固定维度的浮点向量,用空格分隔。加载逻辑并不复杂:
import numpy as np def load_glove_embedding(glove_path, word_index, embed_dim=300): embeddings_index = {} with open(glove_path, 'r', encoding='utf-8') as f: for line in f: values = line.strip().split() word = values[0] coefs = np.asarray(values[1:], dtype='float32') embeddings_index[word] = coefs # 词表index从1开始,0留给padding embedding_matrix = np.zeros((len(word_index) + 1, embed_dim)) hit = 0 for word, i in word_index.items(): vec = embeddings_index.get(word) if vec is not None: embedding_matrix[i] = vec hit += 1 print(f'词向量覆盖率: {hit}/{len(word_index)} = {hit / len(word_index):.2%}') return embedding_matrix这里有两个关键点。第一,embedding_matrix的行数必须是len(word_index) + 1,因为Keras的Embedding层在padding时用索引0,这个位置要留出来。如果你写成了len(word_index),训练时会在最后一步报索引越界。第二,词向量覆盖率一定要打出来看一眼。我当时洗过的数据,常见词的覆盖率在80%以上,剩下那些没覆盖到的主要是网络热词和特殊token。覆盖率如果低于70%,就要回头检查清洗和分词环节是不是出了问题。
关于词向量本身,这里要特别说明一个中文场景的坑。GloVe官方提供的是英文预训练向量,中文没法直接用。我当时用的是一个在中文语料上训练的GloVe版本,格式和加载方式完全一样。如果你手头只有腾讯或搜狗那类通用中文词向量文件,加载逻辑也是相同的,只是文件格式里每个词的元信息不同,解析时多处理一步就行。核心判断标准就一条:你用的词向量必须覆盖你任务所在的语言和领域,英文向量跑中文项目肯定不对路。
Embedding层里还有一个参数值得反复拿捏:trainable。如果标注数据很少,建议把trainable=False,让Embedding层保持冻结状态,模型只能利用预训练的语义表示;如果像我这个项目一样有一万条左右的数据,就可以把它设成True,让模型在训练过程中微调词向量,把领域知识学进去。我在实验里测过,冻结时F1大约低三个点,解冻后指标明显提升,代价是训练时间变长,而且训练初期容易过拟合,需要配合早停策略使用。
3.2 CNN分支:多尺寸卷积核抓短语特征
CNN分支的设计思路借鉴了TextCNN的结构。核心思想是用多个不同宽度的卷积核在Embedding序列上滑动,相当于同时看2-gram、3-gram、4-gram的短短语模式。
from tensorflow.keras.layers import ( Input, Embedding, Conv1D, GlobalMaxPooling1D, LSTM, Bidirectional, Dropout, Dense, concatenate ) from tensorflow.keras.models import Model def build_model(vocab_size, embed_dim, maxlen, embedding_matrix): inputs = Input(shape=(maxlen,)) emb = Embedding( vocab_size + 1, embed_dim, weights=[embedding_matrix], trainable=True, mask_zero=False )(inputs) # CNN分支:3种卷积核,各64个filter convs = [] for kernel_size in [2, 3, 4]: conv = Conv1D(filters=64, kernel_size=kernel_size, activation='relu')(emb) pool = GlobalMaxPooling1D()(conv) convs.append(pool) cnn_out = concatenate(convs) # LSTM分支:双向LSTM lstm_out = Bidirectional(LSTM(units=128, dropout=0.3, return_sequences=False))(emb) concat = concatenate([cnn_out, lstm_out]) dense = Dense(128, activation='relu')(concat) dense = Dropout(0.3)(dense) output = Dense(1, activation='sigmoid')(dense) model = Model(inputs, output) return model我用了三种卷积核尺寸,分别是2、3、4,各64个filter。2-gram能抓到“不喜”“绝了”这种两词搭配,3-gram能抓到“太好笑”“真无语”,4-gram覆盖面相对更大。卷积之后的池化层选择很重要,我用的是GlobalMaxPooling1D而不是平均池化。情感分析里往往只靠一两个关键短语就能定基调,max pooling会把整个序列里“响应最强”的特征挑出来,平均池化则会把关键信号稀释掉。
3.3 LSTM分支:双向结构捕捉上下文
LSTM分支我用的是双向LSTM,隐藏单元数128。双向的意思是同时有一个正向的LSTM从头到尾读句子,一个反向的LSTM从尾到头读句子,最后把两个方向的输出拼接起来。这对短文本来说特别有意义:“这部电影不怎么样”里,“不”对后面的“怎么样”有修饰作用,正向来读能捕捉“不怎么样”这个组合;而“一点都不可爱”里,“可爱”位于句尾,反向LSTM能从尾部往回看,更好地捕捉“不可爱”的语义。
这里return_sequences=False表示只保留最后一个时间步的隐藏状态,把整条句子的压缩表示作为输出。很多人会误以为对短文本来说单层LSTM就够了,我实测下来,双向比单向稳定高两个点左右。缺点也很明显:参数量翻倍,训练时间变长。如果你的数据量小到只有两三千条,建议把LSTM(units=128)换成LSTM(units=64),双向保留,效果会更稳。
LSTM内部我加了dropout=0.3,这是LSTM层自身的dropout,主要作用于输入和循环连接,能有效缓解过拟合。如果你发现训练集loss很低但验证集loss很高,优先检查这一项,其次再考虑全连接层的Dropout。
3.4 特征融合与输出层
两个分支的输出最后通过concatenate拼接成一个向量。CNN分支输出维度是3乘64等于192,LSTM分支输出维度是双向128乘2等于256,拼接后是448维。这个向量经过一个128维的全连接层,再做一次Dropout,最后接一个单节点sigmoid输出。
sigmoid输出的是样本属于“正面”的概率,通常以0.5为阈值划分正负,但这个阈值不是死的,后面我会专门讲怎么根据业务需求调整。
这个模型结构不算复杂,参数量大概在几百万量级,一张普通的单卡就能跑起来。我当时用批量大小64训练十几个epoch,每个epoch大概几十秒,整个项目从零到一不到半天时间。这种轻量级结构最大的好处是容易调试、容易部署,出问题时也容易定位到底出在哪个分支。
3.5 一个容易忽略的细节:padding区域的mask问题
Embedding层的mask_zero参数我设成了False,这里值得解释一下。mask_zero=True的意思是让Embedding层自动忽略索引为0的位置,也就是padding的部分,这对LSTM特别友好,可以避免模型把无意义的padding当真。但问题在于,如果设成mask_zero=True,Embedding层返回的是一个带mask的序列,而CNN分支的一些操作对这种mask支持得不好,容易报错或者导致卷积计算也被屏蔽。
我当时图省事直接设了False,好在maxlen不长,padding对模型的影响很有限,整体效果并没什么影响。如果你追求更精细的效果,可以试一下mask_zero=True,然后在对CNN分支做GlobalMaxPooling1D时留意行为是否有异常。这个细节属于锦上添花,不是必选项。
4. 训练调参实战:从Loss曲线到类别不平衡的三板斧
4.1 损失函数、优化器与学习率
二分类任务的标准配置是sigmoid输出加binary_crossentropy损失,优化器我用的是Adam。初始学习率设成1e-3,这个值在大多数情况下都不会太离谱。
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau model.compile( optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'] ) callbacks = [ EarlyStopping(patience=3, restore_best_weights=True), ReduceLROnPlateau(patience=2, factor=0.5, min_lr=1e-5) ]ReduceLROnPlateau这个回调很容易被新手忽略,但它在训练中后期非常有用。模型跑几个epoch之后,如果验证集loss不降了,回调会自动把学习率减半,让模型在更小的步长下继续微调。我实测这个策略比死守初始学习率跑到底效果稳定得多。
4.2 训练过程中的诊断信号
训练的时候不能光盯着验证集准确率,一定要同时看训练集loss和验证集loss的走势。这里有几个典型的信号,我逐个说一下对应的处理方式。
第一种情况:训练集loss已经很低,验证集loss却高企不下,验证集准确率也上不去。这是过拟合的典型信号,优先检查Dropout是否到位,其次把EarlyStopping的patience调小。
第二种情况:训练集loss和验证集loss都降得很慢,甚至在震荡。这时优先怀疑学习率是不是太大了,把初始学习率从1e-3降到1e-4重新试。
第三种情况,也是我这次遇到的情况:验证集loss降了几个epoch后突然反弹。排查了一圈发现是Embedding层解冻太早导致词向量被大量不相关样本带偏了。我的解决办法是分两阶段训练:第一阶段冻结Embedding层,只训练上游的CNN和LSTM分支,等上层网络基本收敛后,再解冻Embedding层,用很小的学习率对整个模型微调。这个两阶段策略在数据量不充裕的任务里非常管用,值得记下来。
4.3 关键参数表与推荐值参考
我把自己在本次项目里实际使用的参数整理成了一个表格,供参考。数据规模、文本长度不同,参数需要跟着调,但初始值可以从这里起步。
| 参数 | 推荐值 | 我的选择及依据 |
|---|---|---|
| maxlen | 30~60 | 我选50,因为微博评论多数在30字以内 |
| embed_dim | 100~300 | 我选300,与预训练向量维度一致 |
| 卷积核尺寸 | 2, 3, 4 | 覆盖两词到四词短语 |
| filter数量 | 64~128 | 我选64,兼顾效果与训练速度 |
| LSTM隐藏单元 | 64~128 | 我选128,双向之后输出256维 |
| 全连接层维度 | 64~128 | 我选128,加Dropout防过拟合 |
| batch_size | 32~64 | 我选64,一万多条样本训练较快 |
| 初始学习率 | 1e-3~1e-4 | 我选1e-3,配学习率衰减 |
| early_stop patience | 2~4 | 我选3,避免模型在验证集上反复横跳 |
4.4 类别不平衡:三种处理手段
微博评论的正负样本比例天然是不平衡的,负面情绪更倾向于说得更冲、更明确,而正面表达相对克制。我清洗后拿到的数据里,负面样本比正面样本多了大概三成。类别不平衡如果不处理,模型会往多数类严重倾斜:全预测成负面,准确率也可能不低,但正面评论基本全废。
我的处理顺序是:先用class_weight给少数类加权,训练时少数类样本的loss被放大。这个手段最简单,不动数据,只是告诉模型“少数类的错误更贵”。
# 0是负面,1是正面,多数类是负面 class_weight = {0: 1.0, 1: 1.4}如果加权后效果还不够,再考虑过采样或者欠采样。但微博评论的情感标签噪声本身就不小,无脑复制少数类样本容易让模型死记硬背某几条具体评论,泛化能力反而下降。我更推荐的做法是去埋标签噪声,而不是造更多噪声数据。
另外还有一个容易被忽略的手段:调整预测阈值。模型最终输出的是sigmoid概率,默认以0.5为分界线。如果你的业务更看重“把正面评论准确捞出来”,可以把阈值往上抬到0.6甚至0.7,用召回率换准确率;反过来就看你要的是啥。这个调阈值的过程不需要重新训练,直接拿验证集试,几分钟就能定下来。
5. 评测复盘与下一步:别只看准确率,把错分样本拉出来看
5.1 指标选择:二分类不能只看accuracy
很多初学者做完分类任务习惯只看准确率,但这个指标在不平衡数据上很会骗人。假设你的验证集里60%是负面样本,模型全部预测成负面,准确率就是60%,看起来“及格了”,实际上正面评论一条都没分对,模型毫无使用价值。
我一般会先输出分类报告,用sklearn一行搞定:
from sklearn.metrics import classification_report, confusion_matrix # y_pred是模型输出的0/1标签 print(classification_report(y_true, y_pred, target_names=['negative', 'positive']))重点看两个数字:正面类的F1和负面类的F1。如果两者相差超过五个点,说明模型对某一类有明显的偏向,需要回头检查类别不平衡处理是否到位。我当时正面类的F1到了0.83,负面类是0.79,整体算均衡,说明加权和阈值调整起到了作用。
混淆矩阵也是必看的。它能把“错在哪”具象化:正面被误判成负面,还是负面被误判成正面。这两类错误在业务上代价完全不同,比如做舆情监控,把负面舆情漏掉远比把正常评论误伤严重,那就要把负面类的召回率拉满,哪怕牺牲一点正面类的准确率。
5.2 错分样本到底长什么样
指标看完了,必须把预测错的样本拉出来一条条看。这一步是提升模型的关键,也是最容易偷懒不做的部分。我整理了一批典型的Bad Case,列几个有代表性的:
| 原始评论 | 标注 | 模型预测 | 问题分析 |
|---|---|---|---|
| 这电影绝了 | 正面 | 负面 | “绝了”一词在语境中高度歧义,模型偏向去学习字面负面特征 |
| 呵呵 | 负面 | 正面 | 字面中性词,微博语境下是典型嘲讽,模型缺少反讽识别能力 |
| 666 | 正面 | 负面 | 网络热词,词向量覆盖不全,模型只能靠随机初始化去猜 |
| 无语,这操作我直接人麻了 | 负面 | 负面 | 正确预测,但“人麻了”这类热词全靠自定义词典兜底 |
“绝了”这种词是我踩过的最大坑之一。在GloVe词向量里,“绝”的近邻词可能带有负面色彩,模型学到的是“绝=极端=不好”,但在微博语境里“绝了”经常是“太好看了”的口语化表达。这种词义漂移靠词向量很难纠正,只能靠大量真实标注让模型自己去调整词义权重。
“呵呵”则反映了一个更本质的问题:情感分析的模型再强,也只能学到表面词义和模式,无法真正理解人类社会里的“阴阳怪气”。如果想进一步缓解,可以尝试把表情符号和标点符号的上下文特征加入模型,但说实话,效果有限,反讽永远是情感分析里最难的边界问题。
5.3 还值得尝试的提效方向
这套LSTM+CNN+GloVe的组合做完之后,整体效果基本可用,但还是有几个明显的可优化方向。以我自己的经验看,优先级从高到低排列如下。
第一优先级是继续提升数据质量。这听起来很土,但实际收益最大。我把标注里三个人意见不一致的样本全部拉出来重新核对,再补充清洗了一些高频噪声词之后,F1从0.78出头提升到了0.83左右。这个提升幅度比我换任何网络结构都大。数据质量永远是深度学习的上限。
第二优先级是给模型加入注意力机制。具体做法是在LSTM的输出上接一个注意力层,让模型学习把注意力集中在句子里最关键的那几个词上。CNN分支用多个卷积核已经在做类似的事情,但注意力机制能给模型更强的解释性——你可以直接看到模型在判断一条评论时更依赖哪些词。这对后续调试和改进非常有价值。
第三优先级才是换更大的预训练模型。如果数据量能积累到五万条以上,可以考虑让BERT类模型做教师模型,把它的预测结果蒸馏出来,训练一个更小的学生模型部署到线上。这个方案兼顾了效果和性能,但实现成本高很多,数据量不够时反而容易出现学生模型学不到位的尴尬。
我个人对这类轻量级文本分类任务的经验是:先把数据处理到极致,再把一个中等复杂度的模型调明白,它的效果已经能覆盖很多业务场景了。不要一上来就追求最先进的模型,调参和调试的成本可能比收益还要大。
回头复盘这个项目,让我印象最深的不是网络结构本身,而是“每一层细节都会传导到最终结果”这件事。分词不一致会让词向量覆盖率下降,覆盖率下降会让模型被迫随机初始化,随机初始化会让热词情感学习变得困难,热词学不明白会让线上预测频频出错——每一个环节看似不起眼,积累起来就是模型质量的差距。如果你正准备做类似的评论情感分析项目,我的建议很简单:数据清洗和标签质量上多花的时间,最后都会在模型指标上还给你。