简介:一套面向自然语言处理零基础初学者的情感分类实战项目,基于情感词典法、传统机器学习和深度学习三条技术路线,实现情感分类系统并对比性能,适合作为数据挖掘、机器学习及深度学习课程大作业或毕业设计参考。压缩包共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, encoding="utf-8"): word, score = line.strip().split("\t") pwords[word] = float(score) for line in open(neg_path, encoding="utf-8"): word, score = line.strip().split("\t") nwords[word] = float(score) return pwords, nwordsdef predict_by_lexicon(text, pwords, nwords, not_words=None, degree_words=None): 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_size=0.2, random_state=42, stratify=df["label"] ) tfidf = TfidfVectorizer( max_features=5000, ngram_range=(1, 2), stop_words=STOP_WORDS, sublinear_tf=True, ) X_train_vec = tfidf.fit_transform(X_train) X_test_vec = tfidf.transform(X_test) svm = SVC(C=1.0, kernel="rbf", gamma="scale", class_weight="balanced") svm.fit(X_train_vec, y_train) pred = svm.predict(X_test_vec) print(classification_report(y_test, pred))sublinear_tf=True会让原始词频做对数变换,避免某个词反复出现时产生过大的 TF 值。ngram_range=(1, 2)是情感分类的常见配置,可以保留“不 划算”“值得 买”这种词对,又不至于像 trigram 那样把特征维度急剧扩大。class_weight="balanced"在正负样本比例失衡时,会自动按反比例给少数类加权。random_state=42和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_features=5000, stop_words=STOP_WORDS)), ("svm", SVC(class_weight="balanced")), ]) 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, cv=5, scoring="f1_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_len=64): 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_dim=64, num_filters=64, filter_sizes=(2, 3, 4), num_classes=2, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, kernel_size=k) 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-k+1) p = torch.max(c, dim=2).values pooled.append(p) out = torch.cat(pooled, dim=1) out = self.dropout(out) return self.fc(out)Conv1d这里没有加padding,不同尺寸卷积核的输出长度差异直接交给全局最大池化吸收。torch.max(c, dim=2).values取每个 feature map 的最大值,它让模型只关心句子里最强烈的信号,而不关心情感词出现在开头还是结尾。padding_idx=0保证 pad 位置的 embedding 不参与更新,也不会把大量零向量前传进卷积层。
参数上,embed_dim=64在几千条数据上完全够用,词表超过 5 万再考虑加大到 100。num_filters=64是 CPU 训练的舒适档位,如果答辩环境有 GPU,可以升到 128 或 256。filter_sizes=(2,3,4)分别对应双词搭配、三词短语和四词表达的感受野。dropout=0.3是比较常见的防过拟合配置,数据量更小时可以提到 0.5。
4.3 训练循环:随机种子、早停与收敛控制
TextCNN 能不能稳定复现,取决于训练过程的随机因素是否被约束。下面这段训练循环把随机种子固定放在最前面,用patience计数器实现早停,并在验证 F1 提升时保存最优模型。
import random import numpy as np def set_seed(seed=42): 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, dim=1).cpu().tolist() preds.extend(pred) labels.extend(by.tolist()) return f1_score(labels, preds)def train(model, train_loader, val_loader, epochs=20, lr=1e-3): set_seed(42) optimizer = torch.optim.Adam(model.parameters(), lr=lr) 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(f"epoch: {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 的答辩环境加载时会出现设备不匹配。patience=3表示验证 F1 连续三轮不提升就提前结束。对几千条数据来说,实际训练轮数通常落在 8 到 15 轮之间。
提示:训练 loss 在下降、验证 F1 却震荡时,先别改网络结构,把学习率降到 3e-4 或 1e-4 再跑一遍,通常比折腾卷积核尺寸见效更快。
5. 性能对比与避坑:三套结果放在一起怎么解释
5.1 设置一个有说服力的对比流程
三条路线全部跑通后,真正决定作业分数的其实是对比实验是否公平。我给自己定了几个约定,哪怕不是课程作业,做真实项目也会沿用:所有方法共用同一份原始数据;训练测试切分使用同一个random_state;指标统一取准确率、精确率、召回率、F1 和混淆矩阵;深度学习额外记录训练耗时,词典法和机器学习记录单条推理耗时。这样得到的表格才能回答“谁更快、谁更准、谁更省力”三个问题。
| 方法 | 训练集大小 | 测试集大小 | 准确率 | F1 | 单条推理耗时 |
|---|---|---|---|---|---|
| 情感词典法 | 0(无训练) | 1000 | 0.753 | 0.741 | 0.2 ms |
| TF-IDF + SVM | 4000 | 1000 | 0.846 | 0.838 | 0.8 ms |
| TextCNN | 4000 | 1000 | 0.871 | 0.863 | 1.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.9,F1 却一塌糊涂。
原因:SVC 的默认决策面会偏向多数类,深度学习里的交叉熵同样对多数类更友好。
解决:SVM 设置class_weight="balanced";深度学习在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 跑完GridSearchCV,TextCNN 至少测两档 dropout 和embed_dim。各自的最优版本放在一起比,才谈得上“我们的实验证明”。
6. 最后一步:证明你的分类系统不是黑匣子
答辩时最容易被追问的问题,不是“你的准确率是多少”,而是“你打开系统看过错误吗”。如果时间只够做一件事,我会把最后一个晚上留给错误分析:从测试集中挑出每种方法各十条预测错误的样本,人工确认它们真实的情感倾向,弄清楚是数据标注错、模型判断错,还是文本本身模棱两可。这些样本能帮你在老师面前把“系统为什么错”讲清楚,而不是绕着说“深度学习是个黑匣子”。
一个具体的做法是把三种方法的预测结果按test_id + 原文 + 真实标签 + 各模型预测输出成表格,再按“三模型全错”和“两模型错”分组排序。你会发现高频错误非常集中:讽刺语气、否定范围跨越多个分句、领域特殊词未被词典或训练数据覆盖。找到这些共同错误后,在报告里写“下一步扩充词典或改用预训练模型,预计能覆盖这 20% 的错误”,会比空泛地说“将来可以尝试更复杂模型”有说服力得多。
我自己的习惯是固定把三类错误截图放在报告附录:词典法错误集中在否定结构,机器学习错误集中在低频长尾词,深度学习错误集中在讽刺与反问。这张附录本身就是对情感分类系统的一种能力边界的证明。还有另一个习惯是,每次做这类对比实验都不直接上深度模型,一定先把词典法和 SVM 跑完,让后面的每一个模型调整都能回答“这 1 个点的提升到底是模型调出来的,还是数据本身的干扰”。希望帮到你。
本文还有配套的精品资源,点击获取