情感分析


文档摘要

情感分析 本节摘要:这是 NLP 的典型任务。古典文本分类你要懂的东西,大半都在这里。「The food was not great.」是正面还是负面?情感听起来简单——评论者喜欢或不喜欢某个东西,给句子打标签。它之所以成为典型任务,是因为每个看似简单的样例背后都藏着一个难的:否定翻转含义,讽刺反转它,「Not bad at all」明明有两个负面词却是正面,表情包比周围文字信号更强,领域词汇要紧( 在乐评和时装评论里含义不同)。情感是古典 NLP 的工作实验室。理解了每个朴素基线为何有特定失败模式,你就理解了为何要发明每一种更丰富的模型。本节从零搭朴素贝叶斯基线、加逻辑回归,再点出让生产级情感成为「合规级难题」的那些陷阱。

情感分析

本节摘要:这是 NLP 的典型任务。古典文本分类你要懂的东西,大半都在这里。「The food was not great.」是正面还是负面?情感听起来简单——评论者喜欢或不喜欢某个东西,给句子打标签。它之所以成为典型任务,是因为每个看似简单的样例背后都藏着一个难的:否定翻转含义,讽刺反转它,「Not bad at all」明明有两个负面词却是正面,表情包比周围文字信号更强,领域词汇要紧(tight 在乐评和时装评论里含义不同)。情感是古典 NLP 的工作实验室。理解了每个朴素基线为何有特定失败模式,你就理解了为何要发明每一种更丰富的模型。本节从零搭朴素贝叶斯基线、加逻辑回归,再点出让生产级情感成为「合规级难题」的那些陷阱。

对应原课程:Phase 5 · Lesson 05 · sentiment-analysis(原英文 phases/05-nlp-foundations-to-advanced/05-sentiment-analysis/docs/en.md)。前置依赖:第 02 节(词袋与 TF-IDF)、Phase 2 · 14(朴素贝叶斯)。

学习目标

阅读完本节,你应当能够:

  1. 说清古典情感分析的两步配方(表示 + 分类),以及朴素贝叶斯为何「天真却强」。
  2. 从零实现多项式朴素贝叶斯与逻辑回归,并说清拉普拉斯平滑、L2 正则各自的作用。
  3. 处理情感的头号失败——否定,用二元语法或否定作用域前缀修复。
  4. 选对评估指标(宏 F1 而非准确率),并识别讽刺、方面级情感、领域漂移这些要上 Transformer 的场景。

一、问题与直觉

"The food was not great." 正面还是负面?

情感听起来简单。评论者喜欢或不喜欢某样东西,标个句子。它之所以成为典型 NLP 任务,是因为每个看似简单的样例都藏着一个难的:

  • 否定翻转含义。
  • 讽刺反转它。
  • "Not bad at all" 明明有两个负面编码词,却是正面。
  • 表情包比周围文字信号更强。
  • 领域词汇要紧(tight 在乐评里褒义,在时装评论里中性)。

情感是古典 NLP 的工作实验室。理解了每个朴素基线为何有特定失败模式,你就理解了为何要发明每一种更丰富的模型。

古典情感是两步配方:

  1. 表示:把文本变成特征向量。词袋、TF-IDF 或 n-gram。
  2. 分类:在标注样本上拟合线性模型(朴素贝叶斯、逻辑回归、SVM)。

朴素贝叶斯是最笨但管用的模型。假设给定标签后每个特征独立,从计数估出 P(word | positive)P(word | negative),推理时把概率相乘。「天真」的独立性假设错得离谱,结果却惊人地强。原因:在稀疏文本特征和中等数据下,分类器关心的是每个词倾向哪一边,多过关心「倾向多少」。

逻辑回归修掉了独立性假设。它为每个特征学一个权重,包括负权重。not good 作为二元特征会得到负权重——这是朴素贝叶斯对没标注过的二元组做不到的。

二、从零实现

第 1 步:一个真实的小数据集

POSITIVE = [ "absolutely loved this movie", "beautiful cinematography and a great story", "one of the best films of the year", "brilliant acting from the lead", "heartwarming and funny", ] NEGATIVE = [ "boring and far too long", "not worth your time", "the plot made no sense", "terrible acting, awful script", "i want my two hours back", ]

故意做小。真干活用数万样本(IMDb、SST-2、Yelp 极性),数学完全一样。

第 2 步:从零实现多项式朴素贝叶斯

import math from collections import Counter def train_nb(docs_by_class, vocab, alpha=1.0): class_priors = {} class_word_probs = {} total_docs = sum(len(d) for d in docs_by_class.values()) for cls, docs in docs_by_class.items(): class_priors[cls] = len(docs) / total_docs counts = Counter() for doc in docs: for token in doc: counts[token] += 1 total = sum(counts.values()) + alpha * len(vocab) class_word_probs[cls] = { w: (counts[w] + alpha) / total for w in vocab } return class_priors, class_word_probs def predict_nb(doc, class_priors, class_word_probs): scores = {} for cls in class_priors: s = math.log(class_priors[cls]) for token in doc: if token in class_word_probs[cls]: s += math.log(class_word_probs[cls][token]) scores[cls] = s return max(scores, key=scores.get)

加性平滑(alpha=1.0)就是拉普拉斯平滑。没有它,某类里没见过的词概率为零,对数就爆了。实践中 alpha=0.01 常见,alpha=1.0 是教学默认。

第 3 步:从零实现逻辑回归

import numpy as np def sigmoid(x): return 1.0 / (1.0 + np.exp(-np.clip(x, -20, 20))) def train_lr(X, y, epochs=500, lr=0.05, l2=0.01): n_features = X.shape[1] w = np.zeros(n_features) b = 0.0 for _ in range(epochs): logits = X @ w + b preds = sigmoid(logits) err = preds - y grad_w = X.T @ err / len(y) + l2 * w grad_b = err.mean() w -= lr * grad_w b -= lr * grad_b return w, b def predict_lr(X, w, b): return (sigmoid(X @ w + b) >= 0.5).astype(int)

L2 正则在这里很要紧。文本特征稀疏,没有 L2 模型会死记训练样本。从 0.01 起调。

第 4 步:处理否定(那个失败模式)

看 "not good" 和 "not bad"。词袋分类器看到 {not, good}{not, bad},从训练里哪个多就学哪个。二元分类器看到 not_goodnot_bad,把它们当不同特征学。这通常就够了。

没有二元组时,一个更糙但管用的修法是否定作用域:把否定词之后、到下一个标点之前的 token 加 NOT_ 前缀。

NEGATION_WORDS = {"not", "no", "never", "nor", "none", "nothing", "neither"} NEGATION_TERMINATORS = {".", "!", "?", ",", ";"} def apply_negation(tokens): out = [] negate = False for token in tokens: if token in NEGATION_TERMINATORS: negate = False out.append(token) continue if token in NEGATION_WORDS: negate = True out.append(token) continue out.append(f"NOT_{token}" if negate else token) return out
>>> apply_negation(["not", "good", "at", "all", ".", "but", "funny"]) ['not', 'NOT_good', 'NOT_at', 'NOT_all', '.', 'but', 'funny']

现在 goodNOT_good 是不同特征,分类器能给它们相反权重。三行预处理,情感基准上可测的准确率跳升。

第 5 步:要紧的评估指标

类别不平衡时,光看准确率会误导。真实情感语料常是 7080% 正面或 7080% 负面;一个恒预测多数类的分类器就能拿 80% 准确率,毫无价值。下面每一项都要报:

  • 每类精确率与召回率:每类一对,宏平均得到一个尊重类别平衡的单一数。
  • 宏 F1(不平衡数据的首选指标):每类 F1 的等权均值。类别不平衡时用它替代准确率。
  • 加权 F1(备选):同宏 F1,但按类频加权。不平衡本身有业务含义时,与宏 F1 并报。
  • 混淆矩阵:原始计数。相信任何标量指标前先看它,它会暴露模型混淆的是哪两类。
  • 每类错误样本:每类抽 5 个错预测读一读。读真实错误无可替代。

严重不平衡(>95:5)时,报 AUROCAUPRC 替代准确率。AUPRC 对少数类更敏感,而这通常正是你关心的(垃圾、欺诈、罕见情感)。

⚠️ 常见坑:不平衡数据上报微 F1 而非宏 F1,会得到一个看起来高、实则是被多数类主导的数。宏 F1 逼你看少数类表现。

def evaluate(y_true, y_pred): tp = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 1) fp = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 1) fn = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 0) tn = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 0) precision = tp / (tp + fp) if tp + fp else 0 recall = tp / (tp + fn) if tp + fn else 0 f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0 return {"tp": tp, "fp": fp, "tn": tn, "fn": fn, "precision": precision, "recall": recall, "f1": f1}

三、框架对比

scikit-learn 六行搞定,且正确。

from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline pipe = Pipeline([ ("tfidf", TfidfVectorizer(ngram_range=(1, 2), min_df=2, sublinear_tf=True, stop_words=None)), ("clf", LogisticRegression(C=1.0, max_iter=1000)), ]) pipe.fit(X_train, y_train) print(pipe.score(X_test, y_test))

三处要留意。stop_words=None 保住否定词;ngram_range=(1, 2) 加二元组,让 not_good 成为特征;sublinear_tf=True 削弱反复出现的词。这三个开关,就是 SST-2 上 75% 基线和 85% 基线的差距。

何时该上 Transformer

  • 讽刺检测:古典模型在此失灵,毫无商量。
  • 长评论,情感中途反转
  • 方面级情感:"Camera was great but battery was terrible." 要把情感归到方面,只有 Transformer 或结构化输出模型能做。
  • 非英语、低资源语言:多语言 BERT 免费给你一个零样本基线。

需要以上任何一个,就跳到第 7 章(Transformer 深入)。否则,TF-IDF 加二元组加否定处理的朴素贝叶斯或逻辑回归,就是你 2026 年的生产基线。

可复现性陷阱(再说一遍)

重训情感模型是常态,重新评估却不是。论文里报的准确率用的是特定划分、特定预处理、特定分词器。如果你不沿用同一套流水线就拿新模型比基线,会得到误导性的差值。永远在你的流水线上重算基线,别信论文的数。

四、可复用产物

保存为 outputs/prompt-sentiment-baseline.md:

--- name: sentiment-baseline description: Design a sentiment analysis baseline for a new dataset. phase: 5 lesson: 05 --- Given a dataset description (domain, language, size, label granularity, latency budget), you output: 1. Feature extraction recipe. Specify tokenizer, n-gram range, stopword policy (usually keep), negation handling (scoped prefix or bigrams). 2. Classifier. Naive Bayes for baseline, logistic regression for production, transformer only if the domain needs sarcasm / aspects / cross-lingual. 3. Evaluation plan. Report precision, recall, F1, confusion matrix, and per-class error samples (not just scalars). 4. One failure mode to monitor post-deployment. Domain drift and sarcasm are the top two. Refuse to recommend dropping stopwords for sentiment tasks. Refuse to report accuracy as the sole metric when classes are imbalanced (e.g., 90% positive). Flag subword-rich languages as needing FastText or transformer embeddings over word-level TF-IDF.

五、练习

  1. 基础:把 apply_negation 作为预处理加进 scikit-learn 流水线,在一个小情感数据集上测 F1 差值。
  2. 进阶:实现类别加权逻辑回归(scikit-learn 传 class_weight="balanced",或自己推梯度)。在合成的 90:10 类别不平衡上测效果。
  3. 挑战:在情感模型的残差上训第二个分类器,搭一个讽刺检测器。记录实验设置。准确率低于随机时(二类讽刺随机约 50%,多数首次尝试就落在这)警告读者。

本节要点回顾

  1. 情感是典型 NLP 任务:每个简单样例背后都藏否定、讽刺、表情包、领域词汇等难点。
  2. 两步配方:表示(BoW/TF-IDF/n-gram)+ 分类(朴素贝叶斯/逻辑回归/SVM)。
  3. 朴素贝叶斯天真却强:独立性假设错得离谱,但稀疏特征下关心「倾向哪边」多过「倾向多少」。
  4. 逻辑回归修独立性假设:能给 not good 这种二元组负权重。
  5. 拉普拉斯平滑(alpha)防零概率爆对数;L2 正则防稀疏特征下死记。
  6. 否定是头号失败:二元组或否定作用域前缀(NOT_good)修复,三行预处理换可测跳升。
  7. 不平衡时报宏 F1 而非准确率:恒多数类分类器白拿 80% 准确率。
  8. 三开关定基线:stop_words=Nonengram_range=(1,2)sublinear_tf=True
  9. 上 Transformer 的信号:讽刺、方面级、长文中途反转、低资源非英语。
  10. 可复现陷阱:永远在自己的流水线上重算基线,别信论文的数。

下一节,我们从「标正负」升级到「抠实体」——进入「命名实体识别」,看模型如何从自由文本里抽出人名、地名、机构名。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U