自然语言处理应用篇 · 第6章

文本分类与情感分析

把文章自动归入预设类别、判断评论的正负面——这是 NLP 最经典的监督学习任务。本章用 scikit-learn 跑通完整中文分类项目。

🎯 学习目标

  • 理解文本分类任务与情感分析的关系、典型应用
  • 掌握机器学习文本分类完整流水线
  • 掌握词袋、n-gram、TF-IDF 特征与 sklearn 向量化器
  • 了解朴素贝叶斯、逻辑回归、SVM 的原理直觉与选型
  • 会用准确率/精确率/召回率/F1/混淆矩阵评估模型
  • 掌握词典法(SnowNLP)与机器学习法两种情感分析路线

1任务全景

文本分类(Text Classification):给定预定义类别集合,让模型为文本打上一个(单标签)或多个(多标签)类别标签。

应用文本类别
垃圾邮件/短信过滤"恭喜中奖点击领取"垃圾 / 正常
新闻分类"上证指数今日收涨…"财经/体育/科技…
客服工单路由"我的订单还没发货"物流/退款/售后
情感分析"手机拍照真烂,后悔买了"正面 / 负面
意图识别"帮我订明天去上海的票"订票/查询/闲聊

情感分析(Sentiment Analysis)本质就是类别为"正/负/中性"的文本分类;更细粒度还有基于方面的情感分析(ABSA):"屏幕很好但电池拉胯"→ 屏幕正面、电池负面。

2机器学习文本分类五步流水线

① 数据准备    文本 X + 标签 y(人工标注/公开数据集)
     │  划分训练集 / 测试集
     ▼
② 文本预处理   清洗 → jieba 分词 → 空格拼接
     ▼
③ 特征工程    词袋 / n-gram / TF-IDF 把文本变成数值向量
     ▼
④ 训练模型    朴素贝叶斯 / 逻辑回归 / SVM(fit)
     ▼
⑤ 评估与预测   测试集 P/R/F1 → 调参 → 对新文本 predict

关键认知:传统机器学习模型(贝叶斯/SVM)只能处理数字,不能直接读文字。所以第③步"文本→向量"是整个流程的核心,这一步叫特征提取。

3文本如何变成向量

One-Hot 与词袋模型(Bag of Words)

把语料中所有词收集成词表(vocabulary),每个词占一维;文档用一个向量表示,每一维记录该词是否出现/出现次数。忽略词序,像把词倒进袋子,故名词袋。

词表(按语料构建):[喜欢, 好吃, 难吃, 服务, 速度, 慢, 快]
文档1 "喜欢 好吃 喜欢"  → [2,1,0,0,0,0,0]
文档2 "难吃 服务 慢"    → [0,0,1,1,0,1,0]
文档3 "速度 快 好吃"    → [0,1,0,0,1,0,1]

缺点:① 词表可达几万~几十万维,极度稀疏;② 不含语序("狗咬人"="人咬狗");③ 常见词权重过大。TF-IDF 加权缓解第③点,n-gram 缓解第②点。

n-gram:保留局部词序

unigram(1元):不好 / 吃   → 两个独立词,情感被拆散!
bigram(2元):不好吃 / 好吃 → 能捕捉短语
trigram:更大粒度
实践:ngram_range=(1,2) 一元+二元一起用,通常效果最好

sklearn 向量化器

from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer

docs = ["喜欢 好吃 喜欢", "难吃 服务 慢", "速度 快 好吃"]

# CountVectorizer:词频计数
cv = CountVectorizer()
X = cv.fit_transform(docs)       # 稀疏矩阵!3 文档 × 7 词
print(X.toarray())
print(cv.get_feature_names_out()) # 词表

# TfidfVectorizer:分词后直接做 TF-IDF(文本分类首选,一步到位)
tv = TfidfVectorizer()
X2 = tv.fit_transform(docs)

# 中文:把 jieba 接进向量化器(它默认按空白分词,所以先 jieba+空格)
tv_zh = TfidfVectorizer(
    token_pattern=r"(?u)\b\w+\b",  # 让单字也能进词表
    ngram_range=(1, 2),            # 一元+二元
    min_df=2,                      # 至少在2篇出现(去极罕见噪声词)
    max_df=0.9,                    # 在90%以上文档出现的词忽略(近似停用词)
    max_features=10000,            # 词表上限
    sublinear_tf=True)             # 用 1+log(tf) 削弱高频影响

4三种常用分类算法(直觉理解)

算法核心直觉特点
多项式朴素贝叶斯
MultinomialNB
用贝叶斯公式,在"词独立"假设下比较各类别生成该文本的概率极快、小数据表现好、适合词频特征;文本分类经典基线
逻辑回归
LogisticRegression
学一条线性决策边界,输出属于各类的概率稳、可解释(权重显示哪些词导向正/负类),文本分类强基线
支持向量机
LinearSVC
在高维空间找间隔最大的分类超平面高维稀疏文本上非常强,长文本/大词表常夺冠
# 朴素贝叶斯的直觉(了解即可,公式不必死记)
# P(类别|文本) ∝ P(类别) × Π P(每个词|类别)
# "垃圾"类文章里"中奖"出现概率很高 → 看到"中奖"就给垃圾类加分
# 拉普拉斯平滑避免某词在某类从未出现导致概率为 0

# sklearn 中三者调用接口完全一致:fit / predict / predict_proba / score

5完整实战:中文评论情感分类

下面使用内置的小语料演示可直接运行的完整流程;真实项目把 texts/labels 换成从 CSV 读取的上万条数据即可。

# ===== 1. 准备数据(texts 原始文本,labels 1正面 0负面)=====
texts = [
    "这个手机拍照非常清晰,续航也很好,很满意",
    "物流很快,包装完整,客服态度好,五星好评",
    "性价比超高,用了一个月依然流畅,推荐购买",
    "餐厅环境不错,菜品新鲜,下次还会再来",
    "书的质量很好,印刷清晰,内容深入浅出",
    "屏幕显示效果惊艳,运行速度飞快,喜欢",
    "物流太慢了,等了一周才到,非常不满意",
    "手机经常卡顿,电池掉电飞快,后悔购买",
    "客服态度恶劣,退货流程繁琐,差评",
    "菜品又贵又难吃,服务也很差,再也不来",
    "包装破损严重,商品有划痕,申请售后",
    "噪音大得离谱,发热严重,完全不值这个价",
]
labels = [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0]

# ===== 2. jieba 分词并空格拼接(sklearn 按空白取词)=====
import jieba
def tokenize(text):
    return " ".join(jieba.lcut(text))

texts_cut = [tokenize(t) for t in texts]

# ===== 3. 划分训练集/测试集 =====
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
    texts_cut, labels, test_size=0.25, random_state=42)

# ===== 4. TF-IDF 特征 + 逻辑回归,用 Pipeline 串成一条流水线 =====
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),
                              token_pattern=r"(?u)\b\w+\b")),
    ("clf", LogisticRegression(max_iter=1000)),
])
pipe.fit(X_train, y_train)              # 训练:向量化 + 分类一步完成

# ===== 5. 评估与预测 =====
print("测试集准确率:", pipe.score(X_test, y_test))

def predict_sentiment(text):
    vec = tokenize(text)
    prob = pipe.predict_proba([vec])[0]
    label = pipe.predict([vec])[0]
    return ("正面" if label == 1 else "负面", round(max(prob), 3))

print(predict_sentiment("电池很耐用,拍照效果也很棒"))   # ('正面', ...)
print(predict_sentiment("卡顿严重,体验极差"))           # ('负面', ...)

换成朴素贝叶斯 / SVM(只改一行)

from sklearn.naive_bayes import MultinomialNB
from sklearn.svm import LinearSVC

pipe_nb = Pipeline([("tfidf", TfidfVectorizer()),
                    ("clf", MultinomialNB())]).fit(X_train, y_train)

pipe_svm = Pipeline([("tfidf", TfidfVectorizer(ngram_range=(1,2))),
                     ("clf", LinearSVC())]).fit(X_train, y_train)

6模型评估四件套

                    预测为正   预测为负
实际为正          TP 真正例    FN 假负例(漏报)
实际为负          FP 假正例    TN 真负例
(以"负面"为正类时同样道理)
指标公式关注场景
准确率 Accuracy(TP+TN)/全部类别均衡时的总体正确率
精确率 PrecisionTP/(TP+FP)报警了的有多少是真的(垃圾邮件:别误伤正常邮件)
召回率 RecallTP/(TP+FN)真正的坏样本抓到多少(风控/疾病:宁抓错勿放过)
F12PR/(P+R)P 与 R 的调和平均,综合指标
from sklearn.metrics import (classification_report, confusion_matrix,
                             accuracy_score)
y_pred = pipe.predict(X_test)

print("准确率:", accuracy_score(y_test, y_pred))
print(classification_report(y_test, y_pred,
                            target_names=["负面", "正面"], digits=3))
print("混淆矩阵:\n", confusion_matrix(y_test, y_pred))
# 混淆矩阵读法(二分类):
# [[TN, FP]
#  [FN, TP]]

类别不平衡陷阱:1000 条里 990 条正常、10 条垃圾,模型全猜"正常"也有 99% 准确率,但毫无用处。此时应看少数类的 F1 / ROC-AUC,并用 class_weight="balanced"、过采样(SMOTE)等手段。

7情感分析路线一:情感词典法

不训练模型,直接查"情感词表"打分:正面词(好、棒、满意)加分,负面词(差、烂、失望)减分,再考虑否定词翻转、程度副词加权。

# pip install snownlp (SnowNLP:自带中文语料训练的情感模型,开箱即用)
from snownlp import SnowNLP

comments = [
    "这家店味道真好,服务也很周到,强烈推荐",
    "太难吃了,等了一个小时,态度还差,再也不来",
    "价格一般,味道中规中矩吧",
]
for c in comments:
    score = SnowNLP(c).sentiments   # 0~1,越接近1越"正面"
    print(f"{score:.3f}  {c}")
# 0.9+   这家店味道真好…
# 0.0-   太难吃了…
# 0.4~0.6 一般…(中性往往落在中间带)

# SnowNLP 还提供分词、拼音、关键词、摘要等功能
s = SnowNLP("这个手机真的非常好用")
print(s.keywords(3), s.pinyin[:3])
# 手写词典法(原理演示,工业级需搭配否定/程度副词表)
POS = {"好": 2, "棒": 2, "喜欢": 2, "满意": 2, "快": 1, "推荐": 2}
NEG = {"差": -2, "烂": -2, "慢": -1, "难吃": -2, "后悔": -2, "卡顿": -2}
NEGATION = {"不", "没", "别", "无", "难以"}

def sentiment_score(text):
    words = jieba.lcut(text)
    score = 0
    for i, w in enumerate(words):
        val = POS.get(w, 0) + NEG.get(w, 0)
        if i > 0 and words[i-1] in NEGATION:
            val = -val            # 否定翻转极性
        score += val
    return score

print(sentiment_score("不好"))         # -2("不"翻转了"好")
print(sentiment_score("喜欢不慢"))      # 2 + 1 = 3

常用中文情感词典资源:知网 HowNet 情感词表、大连理工情感词汇本体库、BosonNLP 情感词典,GitHub 均可找到。

8两条路线对比与选型

词典法机器学习法(本章)深度学习/大模型
需要标注数据不需要需要(千~万条)少量/零样本
反讽/隐含情感很差一般好
可解释性强(可列出命中词)较强(看权重)较弱
领域迁移换词典即可需重新标注训练提示工程/微调
适用冷启动、规则明确有标注数据的业务主力方案高精度、复杂语义

实战建议:上线初期用词典法快速跑通基线;积累几千条人工标注后上 TF-IDF + 逻辑回归/SVM(简单、快、稳);数据过十万或精度不足时再上 BERT 微调;大模型 API 适合零样本冷启动或作为标注助手(让 LLM 预标注再人工校正)。

9从真实数据读取:CSV 模板

# 数据文件 waimai.csv:
# label,text
# 1,味道不错送餐也快
# 0,等了一小时还没到
import pandas as pd
from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.svm import LinearSVC
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
import jieba

df = pd.read_csv("waimai.csv")
df["text_cut"] = df["text"].astype(str).map(
    lambda x: " ".join(jieba.lcut(x)))
Xtr, Xte, ytr, yte = train_test_split(
    df["text_cut"], df["label"], test_size=0.2,
    random_state=42, stratify=df["label"])   # stratify 保持类别比例

clf = Pipeline([
    ("tfidf", TfidfVectorizer(ngram_range=(1, 2),
                              token_pattern=r"(?u)\b\w+\b",
                              min_df=2, max_df=0.9)),
    ("svm", LinearSVC()),
]).fit(Xtr, ytr)

print(classification_report(yte, clf.predict(Xte), digits=3))

10本章小结与练习

📌 关键结论

  • 流水线:标注数据 → 清洗分词 → TF-IDF(1,2-gram)→ LR/SVM → 评估。
  • 小数据 NB 起步,稀疏高维文本 LinearSVC/逻辑回归常是最强传统基线。
  • 不平衡数据别看准确率,要看少数类 F1;用 Pipeline 串联避免数据泄漏。
  • 情感分析:词典法冷启动,机器学习做主力,BERT/LLM 冲精度。
练习1:词袋模型为什么要配合 n-gram?举出两个因忽略词序导致错误的例子。
词袋只统计词频,完全丢弃词序。反例:"不好吃"和"好吃不"词频相同但情感相反;"狗咬人"和"人咬狗"意思完全不同。加入 bigram(不好吃、好吃)能保留局部语序与否定短语,通常 ngram_range=(1,2) 同时使用一元二元效果最好。
练习2:垃圾邮件只占 1%,模型全部预测"正常"准确率 99%,为什么这是失败的模型?应看什么指标?
因为类别不平衡,全猜多数类即可获得高准确率,但对真正目标(抓垃圾邮件)召回为 0,没有任何价值。应看垃圾类(少数类)的精确率、召回率、F1 和 ROC-AUC、PR 曲线;并采用 class_weight='balanced'、重采样或调整阈值。
练习3:用本章代码训练一个三分类器:把句子分为"表扬/投诉/咨询"三类,每类手工写 8~10 条训练样本。
构造 texts 与 labels(0/1/2),jieba 分词空格拼接,train_test_split 划分,Pipeline(TfidfVectorizer(ngram_range=(1,2)), LogisticRegression()) 后 fit;classification_report 指定 target_names=['表扬','投诉','咨询'] 评估;新句子 predict。样本虽少但同类关键词明显时已能演示完整流程。