ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

情感分类系统三路线对比:词典法、SVM与TextCNN实践指南

情感分类系统三路线对比:词典法、SVM与TextCNN实践指南 简介一套面向自然语言处理零基础初学者的情感分类实战项目基于情感词典法、传统机器学习和深度学习三条技术路线实现情感分类系统并对比性能适合作为数据挖掘、机器学习及深度学习课程大作业或毕业设计参考。压缩包共16个文件、大小11.84MB包含3个Python脚本核心模型与流程、2个CSV微博情感数据集、6个txt词典/停用词文件、4个png模型结构图及1个md说明文档数据、代码、报告层次分明。目前已有58人学习下载。通过完整源码和文档可掌握从数据预处理、特征提取到模型训练与评估的流程实践情感词典法、传统机器学习算法以及CNN/BiLSTM等深度学习方法并借助课设报告和数据集说明快速复现实验、理解不同方法的适用场景与性能差异。1. 情感分类系统课程大作业三条技术路线一起做究竟在比什么岁末课程群里最常出现的求助不是“情感分类系统怎么跑通”而是“三种方法都做出来了结果怎么向老师解释”。典型的课程大作业往往这样要求用 Python 实现情感分类系统分别走情感词典法、传统机器学习、深度学习三条路线最后对比性能。只盯着深度模型以为跑通 TextCNN 或 BiLSTM 就万事大吉的在答辩时往往卡壳。这篇笔记要解决的就是如何让三条技术路线回答同一个问题一句评论文本的情感极性是积极还是消极。情感词典法不训练、可解释覆盖率有限传统机器学习靠特征工程和分类器在中等数据规模上表现扎实深度学习自动学特征上限高训练抖动也更明显。把三者放到同一份测试集上对比你反而会看到部分样本上深度学习不如 SVM这些边界正是报告里最有价值的内容。下面按我的落地顺序拆开讲步骤和参数可以直接换到你自己的数据上跑。2. 情感词典法零样本基线如何先跑起来2.1 情感词典法的原理极性词典与否定/程度结构情感词典法的出发点是把语言学先验写成表格一张正向情感词表、一张负向情感词表再配程度副词表和否定词表。分类时先把文本切词再逐词扫描命中情感词就累计情感分命中否定词就把当前极性翻转命中程度副词就把后续情感分数缩放。整个过程不需要标注数据也不依赖模型训练所以它是整份作业里最容易向老师和同学解释的第一条路线。为什么第一条路线选它而不是直接上模型因为我需要一份“先验证任务本身是否正确”的基线。假如词典法在开发集上只能拿到 0.65 的准确率并不一定说明词典法太弱而可能是数据集的正负标签本身不纯粹这种不纯粹即使换到深度模型上也会被网络原样学进去。先用词典法把任务边界看清楚是我做文本项目后养成的习惯。提示启动词典法时不要一开始就去收集几千个词。先用几十个强情感词把流程跑通再根据开发集效果回捞漏掉的常见词重点在流程完整而不是词表庞大。词典法另一个容易被忽视的价值是它会把“情感强度”这个概念主动摆在你面前。“很棒”的强度显然比“还行”大但普通分类标签里只有正负两种强度差异会被丢掉。这个先验信息在词典法里被完整保留下来后面做传统模型和深度学习时完全可以把它作为辅助特征加回去这本身就是对“特征工程”有真实理解的一种表达。2.2 实现步骤分词、匹配、否定翻转与分数聚合我建议把词典内容从代码中拆出来用外部文件存放格式统一成“词 TAB 强度”后续往文件里补词时不需要反复改代码。以下代码展示了词典加载和情感分数计算两个核心函数。import jieba def load_dict(pos_path, neg_path): pwords, nwords {}, {} for line in open(pos_path, encodingutf-8): word, score line.strip().split(\t) pwords[word] float(score) for line in open(neg_path, encodingutf-8): word, score line.strip().split(\t) nwords[word] float(score) return pwords, nwordsdef predict_by_lexicon(text, pwords, nwords, not_wordsNone, degree_wordsNone): not_words not_words or {不, 没, 没有, 别, 不太} degree_words degree_words or {很: 1.5, 非常: 1.8, 有点: 0.6} score 0.0 neg False degree 1.0 for w in jieba.lcut(text): if w in degree_words: degree degree_words[w] continue if w in not_words: neg not neg continue base None if w in pwords: base pwords[w] elif w in nwords: base nwords[w] if base is None: continue s base * degree if neg: s -s score s neg False degree 1.0 return score代码里的neg False和degree 1.0在命中一个情感词之后重置因为我默认否定词和程度副词只修饰最近的一个情感词。针对“不是很满意”这种连续结构逻辑刚好覆盖住“不”把 neg 置为 True“很”把 degree 设为 1.5命中“满意”时算出的分数是 1.0 乘负 1.5方向正确。如果你想改成否定影响后面所有词把neg False删掉即可但那样会把“不满意但整体还行”这类复杂句全部判偏我不推荐。词典文件切分用\t而不是空格是为后续匹配带空格的短语留余地。强度数值设在 0.5 到 2.0 之间比较合适强烈负面词给 -0.8 以下口语里“绝”“神”这类词给 1.0 以上但不要让单条词分超过 3否则阈值扫描时会被少数词带偏整段分数。2.3 阈值扫描与评估给词典法一个能写进报告的指标每条样本的词典分数是连续值要变成二分类必须先定阈值。最省事的做法是拿 0 当界限但我建议在验证集上扫0, 0.2, 0.4, 0.6, 0.8, 1.0几档选 F1 最高的一档。掩码交给实验不用拍脑袋。from sklearn.metrics import f1_score def lexicon_score(dataset_text): return [predict_by_lexicon(t, pwords, nwords) for t in dataset_text] def find_best_threshold(scores, y_true): best_th, best_f1 0.0, 0.0 for th in [0, 0.2, 0.4, 0.6, 0.8, 1.0]: pred [1 if s th else 0 for s in scores] f1 f1_score(y_true, pred) if f1 best_f1: best_f1, best_th f1, th return best_th, best_f1实际操作中有一个必须统计的数据很多样本的词典分数是 0因为词表没有覆盖到任何情感词。这些样本无论阈值怎么设都会被归为负类。我会单独统计“零分样本占比”写进报告的评估段。词表覆盖率每提升 5%词典法 F1 往往能涨 2 到 3 个百分点这个数字比主观描述有说服力得多。3. 传统机器学习用 TF-IDF 与 SVM 撑住分类精度3.1 为什么传统机器学习适合做对比可解释特征与可靠边界传统机器学习方法的情感分类思路和词典法有根本差异不再依赖手写词表而是从标注样本中自动学特征和决策边界。但“自动学”不等于完全不需要特征工程文本变向量的过程仍然需要你亲手控制分词、去停用词、TF-IDF 权重、n-gram 范围这些都属于特征工程的一部分也是课程作业里老师最愿意给分的点。实现层面直接用 scikit-learn 即可不需要自己重写分类器。传统机器学习模型里我默认先试 SVM 而不是朴素贝叶斯原因是文本特征之间往往存在强关联例如“服务”和“态度”高度相关朴素贝叶斯的独立性假设在这里几乎不成立。SVM 寻找的是最大间隔决策面在稀疏高维的 TF-IDF 特征上通常更抗噪声。如果你想在作业报告里引用教材解释这一点《机器学习》周志华的支持向量机章节比在网上贴一段博客更能体现你理解推导逻辑。传统机器学习模型的价值不只在于准确率更在于它留下的解释空间。你可以把学出来的特征权重列出来直接看到哪些词在推动正负判断。这种透明性也是后面和深度学习做对比时的重要参照物。3.2 最小可复现流程分词、TF-IDF 与 SVM 标准代码整个流程拆成四步读取样本、jieba 分词、TF-IDF 向量化、训练并评估。我把停用词过滤放在 TF-IDF 的stop_words参数里而不是在分词阶段删词避免破坏 n-gram 的连续结构。import jieba import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import train_test_split from sklearn.svm import SVC from sklearn.metrics import classification_report # 数据格式一列文本一列标签1正例 0负例 df pd.read_csv(data.csv, sep\t) texts df[text].apply(lambda x: .join(jieba.cut(x))) X_train, X_test, y_train, y_test train_test_split( texts, df[label], test_size0.2, random_state42, stratifydf[label] ) tfidf TfidfVectorizer( max_features5000, ngram_range(1, 2), stop_wordsSTOP_WORDS, sublinear_tfTrue, ) X_train_vec tfidf.fit_transform(X_train) X_test_vec tfidf.transform(X_test) svm SVC(C1.0, kernelrbf, gammascale, class_weightbalanced) svm.fit(X_train_vec, y_train) pred svm.predict(X_test_vec) print(classification_report(y_test, pred))sublinear_tfTrue会让原始词频做对数变换避免某个词反复出现时产生过大的 TF 值。ngram_range(1, 2)是情感分类的常见配置可以保留“不 划算”“值得 买”这种词对又不至于像 trigram 那样把特征维度急剧扩大。class_weightbalanced在正负样本比例失衡时会自动按反比例给少数类加权。random_state42和stratify必须同时写前者保证可复现后者保证切分后的正负比例一致。你也可以把 TF-IDF 换成CountVectorizer再跑一次观察 TF-IDF 的权重归一化到底贡献了多少提升这样一个对照实验本身就能写进报告。3.3 三个必调参数与特征权重可视化传统机器学习模型在课程作业里不需要做太重度的调参重点放在三个位置C、gamma、ngram_range。以下代码用一个Pipeline把向量化与 SVM 绑定起来再用GridSearchCV做交叉验证避免拿测试集反复试答案。from sklearn.pipeline import Pipeline from sklearn.model_selection import GridSearchCV import joblib pipeline Pipeline([ (tfidf, TfidfVectorizer(max_features5000, stop_wordsSTOP_WORDS)), (svm, SVC(class_weightbalanced)), ]) param_grid { tfidf__ngram_range: [(1, 1), (1, 2)], svm__C: [0.1, 1, 10], svm__gamma: [scale, 0.1], } grid GridSearchCV(pipeline, param_grid, cv5, scoringf1_macro, n_jobs-1) grid.fit(X_train, y_train) # X_train 是还没做过向量化的分词语句 print(grid.best_params_) print(grid.best_score_) joblib.dump(grid.best_estimator_, svm_best.model)注意GridSearchCV接收的是没有transform过的原始分词语句因为Pipeline会在每一折的训练部分执行fit_transform测试部分只执行transform这样唯一能避免数据泄漏的写法就是把向量化步骤放进交叉验证循环内。joblib.dump把最优模型固化下来答辩时直接加载到测试集上做预测比现场运行一整套训练流程稳妥。调参之后我还会做一个可解释性检查用逻辑回归或线性 SVM把特征的权重按数值排序输出正权最大和负权最大的词各 20 个。如果这些词和情感词典里的词高度重叠说明两条路线的结论互相印证如果差异很大说明标注数据里有词典覆盖不到的情感表达这个发现可以直接引导词典扩充方向。做对比实验时这一张词表比只说“模型表现好”更有说服力。4. 深度学习方法用 TextCNN 把端到端模型搭起来4.1 深度学习做文本分类从词向量到卷积深度学习引入情感分类最大的变化是不再手动定义情感词表或 TF-IDF 特征而是让模型自己从训练样本中学习文本表示。RNN 很符合文本的序列直觉但在课程作业对比中我更推荐 TextCNN也就是把卷积神经网络用到文本分类上。它在短文本情感分类场景里训练快、参数少、调参时也不容易翻车。TextCNN 的结构可以这样理解先通过 Embedding 层把每个词的索引映射成向量然后用几组不同宽度的卷积核在词序列上滑动等效于在局部窗口里组合 n-gram 特征最后对每组卷积结果做全局最大池化把整句话里信号最强的特征挑出来。卷积核宽度就相当于 n-gram 的 n只是这里的权重由训练自动确定不再靠手工枚举。把“深度学习cnn”和图像分类对比一下你会发现逻辑完全一致局部连接、权重共享、最大池化制造平移不变性。TextCNN 如果你想扩展到更复杂的评价对象级情感分析结构调整起来也比 RNN 直观。答辩时能把这个类比讲清楚老师基本不会再在这里追问太多。4.2 用 PyTorch 实现 TextCNN数据准备、模型定义与前传下面是模块化的 PyTorch 示例包含数据读取、词典构建、batch 生成和模型定义。为让 CPU 也能在几分钟内跑完一个 epoch把 embedding 维度和卷积通道数都控制在合理范围。import torch import torch.nn as nn import jieba from torch.utils.data import Dataset, DataLoader class ReviewDataset(Dataset): def __init__(self, texts, labels, word2id, max_len64): self.texts texts self.labels labels self.word2id word2id self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): ids [] for w in jieba.lcut(self.texts[idx]): word_id self.word2id.get(w, 1) ids.append(word_id) if len(ids) self.max_len: break ids ids [0] * (self.max_len - len(ids)) return torch.tensor(ids), torch.tensor(self.labels[idx])class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim64, num_filters64, filter_sizes(2, 3, 4), num_classes2, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, kernel_sizek) for k in filter_sizes ]) self.dropout nn.Dropout(dropout) self.fc nn.Linear(len(filter_sizes) * num_filters, num_classes) def forward(self, x): emb self.embedding(x) # (B, L) - (B, L, D) emb emb.transpose(1, 2) # (B, L, D) - (B, D, L) pooled [] for conv in self.convs: c torch.relu(conv(emb)) # - (B, num_filters, L-k1) p torch.max(c, dim2).values pooled.append(p) out torch.cat(pooled, dim1) out self.dropout(out) return self.fc(out)Conv1d这里没有加padding不同尺寸卷积核的输出长度差异直接交给全局最大池化吸收。torch.max(c, dim2).values取每个 feature map 的最大值它让模型只关心句子里最强烈的信号而不关心情感词出现在开头还是结尾。padding_idx0保证 pad 位置的 embedding 不参与更新也不会把大量零向量前传进卷积层。参数上embed_dim64在几千条数据上完全够用词表超过 5 万再考虑加大到 100。num_filters64是 CPU 训练的舒适档位如果答辩环境有 GPU可以升到 128 或 256。filter_sizes(2,3,4)分别对应双词搭配、三词短语和四词表达的感受野。dropout0.3是比较常见的防过拟合配置数据量更小时可以提到 0.5。4.3 训练循环随机种子、早停与收敛控制TextCNN 能不能稳定复现取决于训练过程的随机因素是否被约束。下面这段训练循环把随机种子固定放在最前面用patience计数器实现早停并在验证 F1 提升时保存最优模型。import random import numpy as np def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) def evaluate(model, val_loader, device): model.eval() preds, labels [], [] with torch.no_grad(): for bx, by in val_loader: logits model(bx.to(device)) pred torch.argmax(logits, dim1).cpu().tolist() preds.extend(pred) labels.extend(by.tolist()) return f1_score(labels, preds)def train(model, train_loader, val_loader, epochs20, lr1e-3): set_seed(42) optimizer torch.optim.Adam(model.parameters(), lrlr) criterion nn.CrossEntropyLoss() device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) best_f1, patience, best_state 0.0, 0, None for epoch in range(epochs): model.train() total_loss 0.0 for bx, by in train_loader: bx, by bx.to(device), by.to(device) optimizer.zero_grad() logits model(bx) loss criterion(logits, by) loss.backward() optimizer.step() total_loss loss.item() f1 evaluate(model, val_loader, device) print(fepoch: {epoch 1} loss: {total_loss:.4f} f1: {f1:.4f}) if f1 best_f1: best_f1, patience f1, 0 best_state {k: v.cpu().clone() for k, v in model.state_dict().items()} else: patience 1 if patience 3: break model.load_state_dict(best_state) return modelbest_state保存的是验证 F1 最高时的模型参数注意在保存前把 GPU 上的 tensor 移到 CPU否则在无 GPU 的答辩环境加载时会出现设备不匹配。patience3表示验证 F1 连续三轮不提升就提前结束。对几千条数据来说实际训练轮数通常落在 8 到 15 轮之间。提示训练 loss 在下降、验证 F1 却震荡时先别改网络结构把学习率降到 3e-4 或 1e-4 再跑一遍通常比折腾卷积核尺寸见效更快。5. 性能对比与避坑三套结果放在一起怎么解释5.1 设置一个有说服力的对比流程三条路线全部跑通后真正决定作业分数的其实是对比实验是否公平。我给自己定了几个约定哪怕不是课程作业做真实项目也会沿用所有方法共用同一份原始数据训练测试切分使用同一个random_state指标统一取准确率、精确率、召回率、F1 和混淆矩阵深度学习额外记录训练耗时词典法和机器学习记录单条推理耗时。这样得到的表格才能回答“谁更快、谁更准、谁更省力”三个问题。方法训练集大小测试集大小准确率F1单条推理耗时情感词典法0无训练10000.7530.7410.2 msTF-IDF SVM400010000.8460.8380.8 msTextCNN400010000.8710.8631.5 ms这个表里的数值需要替换成你实验的真实数据但通常趋势是词典法不训练、可解释准确率在 0.7 上下传统机器学习能到 0.82 到 0.87深度学习在此基础上再高 1 到 2 个百分点但训练成本和调参成本显著增加。报告中把这组数字写清楚说明你已经理解三类方法的性价比差异。5.2 什么情况下深度学习不占优为什么这反而是好素材很多同学看到深度学习 F1 低于 SVM第一反应是改模型结构。其实这时候应该先检查训练集规模。如果标注数据只有一千多条TextCNN 很容易记住训练集噪声SVM 反而因为特征稀疏而不容易被单个错误样本带偏。“深度模型在小数据下优势不明显”这个结论本身完全能写进作业而且能证明你理解了模型容量和数据量的关系。画一下训练过程中的 loss 曲线也能说明问题训练 loss 和验证 loss 距离逐渐拉大是过拟合两者都降不下去是容量或特征表达不足。前者提高 dropout、减小num_filters后者增大embed_dim或增加训练轮数。词向量预训练能在这里带来一定提升但如果还没加载过 word2vec就不要为了作业临时增加这个变量它会干扰性能对比的解释。5.3 避坑清单三套模型一起跑时最容易翻车的五件事下面按“现象—原因—解决”整理五条踩坑记录基本覆盖课程作业里最常见的意外。坑一测试集指标虚高一换数据就露馅现象TF-IDF 模型在测试集上准确率有 0.95但人工一看明显很多误判。原因在划分数据之前对全量文本直接做了fit_transform向量化的词表已经看过测试集内容测试时等于提前拿到了全局统计信息。解决先train_test_split再在训练子集上fit测试子集只transform。深度学习同样要检查词索引不能提前用全量数据构建分词语料统计也只能基于训练集。坑二正负样本比例悬殊准确率很好看但少数类全错现象训练集里 90% 是正向评论模型准确率显示 0.9F1 却一塌糊涂。原因SVC 的默认决策面会偏向多数类深度学习里的交叉熵同样对多数类更友好。解决SVM 设置class_weightbalanced深度学习在DataLoader中用WeightedRandomSampler调整采样权重训练时无法靠改变网络结构解决类别失衡。坑三情感词典把“不太满意”判成了正向现象词典法遇到了“不”和“满意”最终分数仍然为正极性没有翻转。原因匹配顺序有问题。系统先命中了“太”把它当成普通词跳过没机会再识别“不”否定状态没有生效。解决词典匹配按最长词优先。“不太”作为一个整体进入程度副词表并触发否定状态这样“不太满意”才会按负向累计。坑四深度学习实验跑两次结果差很多现象同样的random_state上下午各跑一次F1 差了一截。原因PyTorch 的随机种子不止一个。DataLoader 的 shuffle 顺序、GPU 上的异步操作都会引入额外随机性只设random.seed不够。解决同时固定random、numpy.random、torch.manual_seed并设置torch.backends.cudnn.deterministic True。如果仍然有少量波动就在报告里多次运行取平均再把标准差写上。坑五超参力度不对等深度学习的领先其实是假象现象深度学习 F1 领先 SVM 5 个点仔细一看SVM 没有做任何调参深度学习却做了一整套早停和 dropout 优化。原因两边配置的不公平会让对比实验失去意义。解决对比之前先把每个方法调到各自较优的状态。SVM 跑完GridSearchCVTextCNN 至少测两档 dropout 和embed_dim。各自的最优版本放在一起比才谈得上“我们的实验证明”。6. 最后一步证明你的分类系统不是黑匣子答辩时最容易被追问的问题不是“你的准确率是多少”而是“你打开系统看过错误吗”。如果时间只够做一件事我会把最后一个晚上留给错误分析从测试集中挑出每种方法各十条预测错误的样本人工确认它们真实的情感倾向弄清楚是数据标注错、模型判断错还是文本本身模棱两可。这些样本能帮你在老师面前把“系统为什么错”讲清楚而不是绕着说“深度学习是个黑匣子”。一个具体的做法是把三种方法的预测结果按test_id 原文 真实标签 各模型预测输出成表格再按“三模型全错”和“两模型错”分组排序。你会发现高频错误非常集中讽刺语气、否定范围跨越多个分句、领域特殊词未被词典或训练数据覆盖。找到这些共同错误后在报告里写“下一步扩充词典或改用预训练模型预计能覆盖这 20% 的错误”会比空泛地说“将来可以尝试更复杂模型”有说服力得多。我自己的习惯是固定把三类错误截图放在报告附录词典法错误集中在否定结构机器学习错误集中在低频长尾词深度学习错误集中在讽刺与反问。这张附录本身就是对情感分类系统的一种能力边界的证明。还有另一个习惯是每次做这类对比实验都不直接上深度模型一定先把词典法和 SVM 跑完让后面的每一个模型调整都能回答“这 1 个点的提升到底是模型调出来的还是数据本身的干扰”。希望帮到你。本文还有配套的精品资源点击获取
返回列表