阶段二 · 文本分类与情感分析

文本分类任务解析

一句话总结

文本分类 = 给一段文本贴一个标签.
阶段一把文本变成了向量,本篇讲如何定义"标签体系",如何衡量分类效果(Precision / Recall / F1),以及如何把"群聊对骂检测"建模为一个分类问题.

从向量到标签:分类器做什么

上一阶段结尾,我们得出一个关键结论:向量空间中同类文本自然聚集,但模型不知道哪个簇是"骂人"--这需要标注数据来定义.

分类器就是那条"国界线"的数学实现:

输入: 一个句子向量(768维)
输出: 一个类别标签(如 "对骂" 或 "正常")

本质: 学一个函数 f(向量) → 标签
等价于在向量空间中画一个超平面,把不同类别分开

最简单的分类器(逻辑回归)的计算过程:

score = W · sentence_vector + b      ← 向量点积 + 偏置
probability = sigmoid(score) ← 压到 (0, 1) 当概率
预测 = probability > 0.5 ? 对骂 : 正常

其中 W 是训练出来的权重向量(和输入同维度,768 维). 直觉理解:W 定义了空间中的一个"方向标杆"--点积运算的几何含义是计算输入向量在 W 方向上的投影长度. 投影越长(score 越大),越像对骂;投影越短甚至反方向(score 为负),越像正常.

逻辑回归 = Go 中的 feature flag 权重打分. 给每个维度一个权重("脏话词频"权重高,"日常用词"权重低),对输入向量加权求和得到总分. 分数高于阈值就触发干预. 逻辑回归就是自动学出这组权重.

W 指向哪个方向是约定,不是固定的

说"W 指向对骂方向"只是因为用 label=1 表示对骂. 如果反过来定义 label=1 为正常,训练出来的 W 就指向正常方向--score 大的反而是正常文本.

本质上 W 指向的是 "label=1 那一类" 的方向,具体是什么语义完全取决于标签定义. 不存在"默认指向哪边"--是标注规则决定的.

整个阶段二的核心就一件事:让这个函数从数据中学会怎么画线.

NLP 任务类型对比:分类在什么位置

NLP 中有很多不同的任务形态,分类只是其中一种. 先看全局:

任务类型 输入 输出 例子
文本分类 一段文本 一个/多个标签 对骂检测,情感分析,垃圾邮件过滤
序列标注 一段文本 每个 token 一个标签 命名实体识别("北京" → 地名),分词
文本生成 一段文本/指令 一段新文本 机器翻译,摘要,对话回复
文本匹配 两段文本 相似度/关系 语义搜索,问答匹配,重复问题检测

分类 = Go 的 switch-case, 输入一个请求,返回一个枚举值.
序列标注 = 逐字节解析协议, 输入流中每个字节都要标记类型.
生成 = io.Writer, 给定输入后不断产出输出.

对骂检测是典型的文本分类任务. 后面如果要做意图识别("禁言张三 10 分钟" → 提取动作+对象+时长),那会涉及序列标注--但现在先聚焦分类.

分类任务的三种变体

同样是"贴标签",根据标签体系的不同,分成三种建模方式:

二分类(Binary Classification)

只有两个类别:是/否,正面/负面,对骂/正常.

# 二分类输出
model(sentence_vector) → {对骂: 0.92, 正常: 0.08}
# 取概率最大的作为预测结果

这是最简单的形态,也是起步选择--先把对骂检测建模为二分类.

多分类(Multi-class Classification)

多个类别,每条文本只属于一个类:

# 多分类输出(互斥,只能选一个)
model(sentence_vector) → {对骂: 0.75, 阴阳怪气: 0.15, 正常: 0.08, 广告: 0.02}
# 预测结果: 对骂

当需要区分对骂的子类型(人身攻击,地域歧视,性别歧视等),就需要多分类.

多标签(Multi-label Classification)

一条文本可以同时属于多个类别:

# 多标签输出(不互斥,可以同时命中多个)
model(sentence_vector) → {对骂: 0.92, 带节奏: 0.85, 人身攻击: 0.78, 广告: 0.03}
# 预测结果: 对骂 ✓, 带节奏 ✓, 人身攻击 ✓(超过阈值的都算命中)

"你这种垃圾还好意思带节奏"既是对骂也是带节奏--多标签允许一条文本有多个标签.

建模决策:从简单开始

实战建议是先跑通二分类(对骂 vs 正常),验证可行后再扩展为多分类或多标签. 过早做细粒度分类会导致每个类别的标注数据太少,模型学不好.

评估指标:怎么衡量"分得准不准"

模型训练出来了,怎么知道效果好不好? 不能只看"猜对了几个"--需要更精确的指标.

混淆矩阵:一切指标的起点

假设对骂检测模型在 100 条测试数据上的预测结果:

模型预测
对骂 正常
实际 ┌─────────┬─────────┐
标签 │ TP=35 │ FN=5 │ ← 实际是对骂的 40 条
对骂 │ (命中) │ (漏报) │
├─────────┼─────────┤
实际 │ FP=8 │ TN=52 │ ← 实际是正常的 60 条
正常 │ (误报) │ (正确) │
└─────────┴─────────┘

四个格子的含义:

  • TP(True Positive):模型说"对骂",确实是对骂 → 正确命中
  • FN(False Negative):模型说"正常",实际是对骂 → 漏报(该管的没管)
  • FP(False Positive):模型说"对骂",实际是正常 → 误报(误伤正常用户)
  • TN(True Negative):模型说"正常",确实是正常 → 正确放行

TP = 真故障被监控发现了.
FN = 故障发生了但监控没报警(最危险).
FP = 没故障但监控乱报(狼来了).
TN = 没故障也没误报(正常状态).

精确率(Precision):模型说"对骂"时,有多少是真的

Precision = TP / (TP + FP) = 35 / (35 + 8) = 81.4%

含义:模型报告的"对骂"中,81.4% 确实是对骂
剩下 18.6% 是误报(正常发言被误判为对骂)

Precision 高 = 误报少. 如果系统对误报敏感(误禁言正常用户会引起投诉),需要追求高 Precision.

召回率(Recall):实际对骂中,模型抓住了多少

Recall = TP / (TP + FN) = 35 / (35 + 5) = 87.5%

含义:40 条真实对骂中,模型抓住了 35 条
漏掉了 5 条(12.5% 的对骂没被检测到)

Recall 高 = 漏报少. 如果系统对漏报敏感(骂人消息没被拦截,群环境恶化),需要追求高 Recall.

F1 Score:Precision 和 Recall 的平衡

F1 = 2 × (Precision × Recall) / (Precision + Recall)
= 2 × (0.814 × 0.875) / (0.814 + 0.875)
= 84.3%

F1 是 Precision 和 Recall 的调和平均--
任何一方太低都会把 F1 拉下来,它惩罚"偏科".

Precision 和 Recall 的跷跷板效应

两者几乎总是此消彼长:
阈值调高(只有 >95% 置信度才判为对骂) → Precision 上升,Recall 下降(谨慎了,误报少但漏报多).
阈值调低(>50% 就判为对骂) → Recall 上升,Precision 下降(激进了,漏报少但误报多).

没有"最优阈值"--取决于业务容忍度.

为什么不只看准确率(Accuracy)

Accuracy = 预测正确的总数 / 总样本数 = (TP + TN) / 全部 = (35 + 52) / 100 = 87%

87% 看起来不错? 但考虑一个极端情况:

假设 100 条数据中只有 5 条是对骂,95 条是正常.
模型策略:无脑预测所有文本都是"正常".

Accuracy = 95/100 = 95% ← 看起来很高!
Precision = 0/0 = 未定义
Recall = 0/5 = 0% ← 一条对骂都没抓到
F1 = 0% ← 完全无用

这就是类别不平衡的陷阱--当某类数据量远大于另一类时,Accuracy 会骗人. 在对骂检测中,正常发言远多于对骂发言(可能 20:1),所以必须用 F1 作为主指标.

类比 Go 服务的监控:如果 99.9% 的请求都是成功的,一个"永远返回 success"的监控系统准确率是 99.9%--但它对发现问题毫无价值. 需要的是故障召回率,不是总体准确率.

把对骂检测建模为分类任务

现在用上面的框架来设计具体任务.

任务定义

要素 选择 理由
任务类型 二分类(起步) 先跑通再扩展,避免标注数据分散
标签体系 对骂(1) / 正常(0) 明确的二值判断,标注一致性高
输入粒度 单条消息 最简单;后续可扩展到上下文窗口
主指标 F1-score 对骂占比低,Accuracy 不可靠
辅助指标 Precision, Recall 分别看 根据业务侧重调整阈值

业务权衡:Precision vs Recall 该偏向哪边

对骂检测的业务场景决定了指标偏好:

策略 偏向 后果 适用场景
宁可漏报,不可误报 高 Precision 少数对骂溜走,但不会误伤正常用户 用户群体敏感,投诉成本高
宁可误报,不可漏报 高 Recall 偶尔误判正常发言,但对骂一个不漏 强管控群,内容安全合规
平衡 F1 最大化 两边都有少量错误,但整体最优 大多数通用场景

场景建议

群 AI 管理的干预动作(禁言)有较高成本--误禁正常用户会引起不满.
建议初期偏向高 Precision:宁可漏掉几条对骂(让大模型兜底二次判断),也不要误伤正常用户.
上线后再根据实际数据逐步调低阈值提升 Recall.

训练-评估循环:模型如何学会分类

有了标注数据和评估指标,训练过程就是一个迭代循环:

flowchart TD A["标注数据集"] --> B["划分: 训练集 80% + 测试集 20%"] B --> C["训练集送入模型"] C --> D["模型学习画分界线"] D --> E["在测试集上预测"] E --> F["计算 P/R/F1"] F --> G{"F1 达标?"} G -->|"是"| H["上线/导出"] G -->|"否"| I["分析错误案例"] I --> J["调整: 加数据 / 换模型 / 调参数"] J --> C

关键概念:

  • 训练集:模型用来学习的数据,看过答案
  • 测试集:模型没见过的数据,用来评估真实表现
  • 为什么必须分开? 防止模型"背答案"--在训练集上全对,遇到新数据就不行(过拟合)

训练集 = 课后习题(可以看答案反复做).
测试集 = 期末考卷(做的时候不能看答案).
如果考卷题目和习题一模一样,100 分不代表真学会了.

跑通分类需要什么

把阶段二要做的事情拆成具体的输入输出:

需要什么 具体是什么 哪篇讲
标注数据 足够多的"文本+标签"对,标注一致 02 篇
特征提取 文本 → 向量(TF-IDF 或 BERT) 阶段一已完成
分类模型 逻辑回归 / FastText / TextCNN / BERT 03-04 篇
损失函数 告诉模型"预测错了多少"的数学公式 03-04 篇
评估流程 P / R / F1 计算 + 错误分析 本篇 + 05 篇

其中标注数据是最关键的--模型效果的天花板取决于数据质量,不是模型复杂度. 下一篇会讲如何设计标注规范,如何从零构建数据集.

完整流水线预览

把分类任务的完整流水线画出来,后续每篇笔记聚焦其中一段:

flowchart LR A["原始文本"] --> B["预处理"] B --> C["特征提取  
TF-IDF / BERT"] C --> D["分类模型
LR / FastText / CNN"] D --> E["输出概率"] E --> F["阈值判断"] F --> G["最终标签"] style C fill:#dbeafe,stroke:#3b82f6 style D fill:#fef3c7,stroke:#f59e0b

蓝色部分(特征提取)阶段一已经搞定了;黄色部分(分类模型)是阶段二的核心内容.

阈值不是固定的

模型输出的是"属于对骂的概率"(如 0.73),阈值决定了"多高的概率才判定为对骂".
默认 0.5,但可以调:调高 → 更谨慎(Precision 高);调低 → 更激进(Recall 高).
这是一个不需要重新训练模型就能调节行为的旋钮.

从阶段一的种子数据出发

阶段一的练习中已经有了 40 条标注数据(20 对骂 + 20 正常)和对应的 TF-IDF 向量, BERT [CLS] 向量. 现在可以直接在上面跑一个最简单的分类器看看效果:

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
import numpy as np

# 加载阶段一保存的 [CLS] 向量
data = np.load("data/cls_vectors.npz")
X, y = data["vectors"], data["labels"]

# 划分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y
)

# 逻辑回归分类器--最简单的线性模型
clf = LogisticRegression(max_iter=1000)
clf.fit(X_train, y_train)

# 在测试集上评估
y_pred = clf.predict(X_test)
print(classification_report(y_test, y_pred, target_names=["正常", "对骂"]))

这段代码就是阶段二的第一个"能跑的 baseline"--用阶段一产出的向量 + 最简单的线性模型. 下一篇讲数据标注时,会正式写这段代码的完整练习版本.

40 条数据太少

40 条数据只能看趋势,不能得出可靠结论. 真正能训练出可用模型的最低门槛大约是每类 200-500 条. 阶段二的 02 篇会讲如何快速扩充数据集.

快速回顾

  • 文本分类:输入一段文本,输出一个标签;对骂检测是最典型的二分类任务
  • 三种变体:二分类(起步) → 多分类(细分类型) → 多标签(一条文本多个标签)
  • 评估指标:Precision(误报率的反面),Recall(漏报率的反面),F1(平衡两者)
  • 不看 Accuracy:类别不平衡时 Accuracy 骗人,用 F1 作为主指标
  • 业务权衡:对骂检测初期应偏向高 Precision(不误伤正常用户),漏报由大模型兜底
  • 训练循环:标注数据 → 划分集合 → 训练 → 评估 → 分析错误 → 迭代

动手练习

  1. 跑第一个分类器:加载阶段一保存的 cls_vectors.npz,用 sklearn.linear_model.LogisticRegression 跑一个二分类,打印 classification_report
  2. 观察小数据波动:调整测试集比例(0.2 / 0.3 / 0.5),观察 F1 的波动--体会小数据的不稳定性
  3. 对比表示方法:用 TF-IDF 向量替代 [CLS] 向量跑同一个分类器,对比 F1--验证哪种表示更适合分类