情感分析 本节摘要:这是 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(朴素贝叶斯)。
阅读完本节,你应当能够:
"The food was not great." 正面还是负面?
情感听起来简单。评论者喜欢或不喜欢某样东西,标个句子。它之所以成为典型 NLP 任务,是因为每个看似简单的样例都藏着一个难的:
tight 在乐评里褒义,在时装评论里中性)。情感是古典 NLP 的工作实验室。理解了每个朴素基线为何有特定失败模式,你就理解了为何要发明每一种更丰富的模型。
古典情感是两步配方:
朴素贝叶斯是最笨但管用的模型。假设给定标签后每个特征独立,从计数估出 P(word | positive) 和 P(word | negative),推理时把概率相乘。「天真」的独立性假设错得离谱,结果却惊人地强。原因:在稀疏文本特征和中等数据下,分类器关心的是每个词倾向哪一边,多过关心「倾向多少」。
逻辑回归修掉了独立性假设。它为每个特征学一个权重,包括负权重。not good 作为二元特征会得到负权重——这是朴素贝叶斯对没标注过的二元组做不到的。
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 极性),数学完全一样。
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 是教学默认。
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 起调。
看 "not good" 和 "not bad"。词袋分类器看到 {not, good} 和 {not, bad},从训练里哪个多就学哪个。二元分类器看到 not_good 和 not_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']
现在 good 和 NOT_good 是不同特征,分类器能给它们相反权重。三行预处理,情感基准上可测的准确率跳升。
类别不平衡时,光看准确率会误导。真实情感语料常是 7080% 正面或 7080% 负面;一个恒预测多数类的分类器就能拿 80% 准确率,毫无价值。下面每一项都要报:
严重不平衡(>95:5)时,报 AUROC 和 AUPRC 替代准确率。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% 基线的差距。
需要以上任何一个,就跳到第 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.
apply_negation 作为预处理加进 scikit-learn 流水线,在一个小情感数据集上测 F1 差值。class_weight="balanced",或自己推梯度)。在合成的 90:10 类别不平衡上测效果。not good 这种二元组负权重。alpha)防零概率爆对数;L2 正则防稀疏特征下死记。NOT_good)修复,三行预处理换可测跳升。stop_words=None、ngram_range=(1,2)、sublinear_tf=True。下一节,我们从「标正负」升级到「抠实体」——进入「命名实体识别」,看模型如何从自由文本里抽出人名、地名、机构名。