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

传统方法快速出 baseline

一句话总结

用最简单的方法跑出第一个"能用"的分类器:TF-IDF + LogisticRegression 和 FastText.
不追求最高精度,但能在几分钟内给出可量化的 baseline,为后续深度模型提供对比基准.

为什么要先跑传统方法

直觉上可能想直接上 BERT--效果最好,何必绕路? 但实战中传统方法有不可替代的价值:

传统方法 深度模型
训练几秒到几分钟 训练几十分钟到数小时
CPU 即可运行 通常需要 GPU
100 条数据就能看趋势 通常需要 1000+ 条
模型可解释(能看到哪些特征重要) 黑箱,难以解释
部署极轻量(几 KB 模型文件) 模型文件几百 MB

传统方法的定位是快速验证数据质量和任务可行性:

  • F1 只有 0.5 → 数据有问题(标注不一致或样本太少),换 BERT 也救不回来
  • F1 达到 0.75 → 数据质量 OK,深度模型能进一步提升
  • F1 已经 0.90+ → 任务简单,可能不需要深度模型

传统方法 = 先用 fmt.Println 打日志确认流程通了, 再换 structured logging. 先证明逻辑对,再优化工具链.

方法一:TF-IDF + LogisticRegression

这是最经典的文本分类组合--上一篇已经见过了,本篇完整展开.

TF-IDF 回顾

TF-IDF 把文本变成一个稀疏向量,每一维代表一个词的"区分度":

TF-IDF(词, 文档) = TF(词在文档中的频率) × IDF(词在全部文档中的稀有度)

"废物" 在一条对骂消息中出现 → TF 高
"废物" 只在少量文档中出现 → IDF 高
→ TF-IDF("废物") 很高 → 这个维度对分类有区分力

"的" 在所有文档中都出现 → IDF 极低
→ TF-IDF("的") ≈ 0 → 这个维度对分类没用

完整流水线

flowchart LR A["原始文本"] --> B["TfidfVectorizer  
文本 → 稀疏向量"] B --> C["LogisticRegression
向量 → 类别概率"] C --> D["阈值判断
→ 对骂/正常"]
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report

# 1. 加载数据
texts, labels = load_dataset("data/dataset_v1.csv")

# 2. 划分训练/测试集
X_train, X_test, y_train, y_test = train_test_split(
texts, labels, test_size=0.2, random_state=42, stratify=labels
)

# 3. TF-IDF 向量化
vectorizer = TfidfVectorizer(max_features=5000)
X_train_vec = vectorizer.fit_transform(X_train) # 在训练集上学词表
X_test_vec = vectorizer.transform(X_test) # 用同一个词表转换测试集

# 4. 训练分类器
clf = LogisticRegression(max_iter=1000)
clf.fit(X_train_vec, y_train)

# 5. 评估
y_pred = clf.predict(X_test_vec)
print(classification_report(y_test, y_pred, target_names=["正常", "对骂"]))

fit_transform vs transform 的区别

fit_transform = 学习词表 + 转换(只对训练集用).
transform = 用已学的词表转换(对测试集用).
如果测试集也用 fit_transform,会学到测试集的词--相当于考试前偷看了考卷,评估结果虚高.

关键参数调优

参数 作用 建议值
max_features 最多保留多少个词 3000-10000(数据少时用小值)
ngram_range 考虑几个词的组合 (1,2) 比 (1,1) 效果好
min_df 至少出现在几个文档中才保留 2-5(过滤噪声词)
sublinear_tf 对 TF 取对数(减弱高频词的影响) True
# 推荐配置
vectorizer = TfidfVectorizer(
max_features=5000,
ngram_range=(1, 2), # 单词 + 二元组("脑子有","有病")
min_df=2, # 至少出现在 2 个文档中
sublinear_tf=True, # TF 取对数
)

ngram_range=(1,2) 的威力

单词级别 "脑子""有""病" 分别出现在很多正常语句中.
但二元组 "脑子有""有病" 几乎只出现在对骂场景.
N-gram 能捕获词序信息--对分类极其有用.

方法二:FastText 文本分类

FastText 是 Facebook(Meta)开源的轻量级文本分类工具,特点是极快--几秒训练完,毫秒级推理.

FastText vs TF-IDF + LR

维度 TF-IDF + LR FastText
文本表示 稀疏向量(词袋) 密集向量(词嵌入平均)
考虑词序 N-gram 部分考虑 N-gram + 子词(subword)
未登录词 直接忽略 子词拆解兜底
训练速度 秒级 秒级
可解释性 高(看 feature weight) 中(看近义词)

FastText 的核心优势是子词(subword):它把词拆成字符 N-gram. 比如 "nmsl" 这种不在词表中的缩写,TF-IDF 只能忽略,FastText 能通过字符组合 "nm""ms""sl" 捕获一定的信息.

数据格式

FastText 需要特定格式的训练文件:

__label__1 你脑子有病吧
__label__0 今天的版本什么时候发
__label__1 废物一个
__label__0 收到我待会看

每行格式:__label__标签 文本内容,标签和文本之间用空格分隔.

训练与评估

import fasttext

# 训练(一行代码)
model = fasttext.train_supervised(
input="data/train.txt", # FastText 格式的训练文件
epoch=25, # 训练轮数
lr=0.5, # 学习率
wordNgrams=2, # 考虑二元组
dim=50, # 词向量维度
loss="softmax", # 分类损失函数
)

# 预测
result = model.predict("你脑子有病吧")
# 返回: (('__label__1',), array([0.95])) → 对骂,置信度 95%

# 评估
result = model.test("data/test.txt")
# 返回: (样本数, precision, recall)
print(f"样本数: {result[0]}, P: {result[1]:.3f}, R: {result[2]:.3f}")

FastText 的安装

Python 包名是 fasttext(原版)或 fasttext-wheel(预编译,推荐).
安装命令:uv add fasttext-wheel. 如果遇到编译问题优先用 wheel 版本.

关键参数

参数 作用 建议值
epoch 训练轮数 25-50(数据少时多训几轮)
lr 学习率 0.1-1.0(数据少用较大值)
wordNgrams 词级 N-gram 2(捕获短语)
dim 词向量维度 50-100(数据少不需要高维)
minCount 最小词频 1-2(数据少时设 1)

特征工程:超越词袋

传统方法的效果天花板往往取决于特征工程--能从文本中提取多少有区分力的信号.

文本特征清单

特征类型 具体特征 对对骂检测的意义
词袋特征 TF-IDF 向量 基础特征,捕获关键词
统计特征 文本长度,标点数量,感叹号数量 对骂消息通常更短,感叹号更多
词表特征 脏话词命中数,脏话占比 最直接的信号
字符特征 大写比例,特殊字符比例 缩写脏话(SB,NM)大写居多
情感特征 负面情感词数,情感极性分 补充词袋无法捕获的情感方向
import numpy as np

def extract_features(text: str, abuse_keywords: list) -> np.ndarray:
"""提取手工特征(拼接到 TF-IDF 向量后面)"""
features = []

# 长度特征
features.append(len(text))
features.append(len(text.split()))

# 标点特征
features.append(text.count("!") + text.count("!"))
features.append(text.count("?") + text.count("?"))

# 脏话词表特征
hit_count = sum(1 for kw in abuse_keywords if kw in text.lower())
features.append(hit_count)
features.append(hit_count / max(len(text), 1)) # 脏话密度

# 大写比例(检测 SB,NM 等)
alpha_chars = [c for c in text if c.isalpha()]
upper_ratio = sum(1 for c in alpha_chars if c.isupper()) / max(len(alpha_chars), 1)
features.append(upper_ratio)

return np.array(features)

特征工程的收益递减

前 3-5 个特征通常贡献了 80% 的提升,之后每加一个特征边际收益急剧下降. 发现手工特征已经榨不出更多提升时,就是切换到深度模型的时机.

三种方法对比实验

用同一份数据集跑三种方法,横向对比:

# 实验设计
# ─────────────────────────────────────
# 数据集: dataset_v1.csv(200+ 条,1:1 比例)
# 划分: 80% 训练,20% 测试,stratify 分层
# 指标: F1-score(对骂类)
# 方法:
# A. TF-IDF(unigram) + LR
# B. TF-IDF(bigram) + LR + 手工特征
# C. FastText

# 预期结果(量级参考):
# A: F1 ≈ 0.75-0.85
# B: F1 ≈ 0.80-0.90(特征工程提升 5-10%)
# C: F1 ≈ 0.80-0.88(子词优势)

实际数字取决于数据

上面的 F1 只是量级参考. 如果扩充数据太同质化(模板生成的变体),F1 可能虚高到 0.95+--这不代表真实效果好,只代表测试集和训练集太相似. 引入真实数据后 F1 会下降,这才是真实水平.

错误分析:baseline 告诉什么

跑完 baseline 后最重要的不是看 F1 数字,而是分析错误案例--它告诉数据或模型的短板在哪.

常见错误类型

错误类型 例子 原因 改进方向
漏报:隐晦对骂 "你可真行啊" 没有脏话关键词 需要上下文 → 深度模型
漏报:新词/缩写 "yyds反面" 不在词表中 扩充词表或用子词模型
误报:引用脏话 "他说了sb这个词" 关键词命中 需要语义理解 → BERT
误报:自嘲 "我真是废物" 关键词命中 加"主语是自己"特征
# 错误分析代码框架
errors = []
for text, true_label, pred_label in zip(X_test, y_test, y_pred):
if true_label != pred_label:
error_type = "漏报" if true_label == 1 else "误报"
errors.append({"text": text, "type": error_type, "true": true_label, "pred": pred_label})

print(f"总错误: {len(errors)} 条")
print(f" 漏报: {sum(1 for e in errors if e['type'] == '漏报')} 条")
print(f" 误报: {sum(1 for e in errors if e['type'] == '误报')} 条")

# 打印错误样本
for e in errors[:10]:
print(f" [{e['type']}] {e['text']}")

错误分析 = Go 中的 benchmark + profiling. 不是看总耗时(F1),而是看哪些函数最慢(哪类错误最多). pprof 告诉优化方向,错误分析告诉数据/模型的改进方向.

什么时候该换深度模型

传统方法的能力边界在这几个场景:

  • 隐晦表达:阴阳怪气,反讽--没有关键词,纯靠语义
  • 上下文依赖:同一句话在不同语境含义不同
  • 长尾新词:网络缩写,谐音梗,不断演变的表达
  • F1 瓶颈:加特征,加数据都不再提升

如果错误分析中大部分错误属于前三类,说明问题出在"理解能力"而非"数据量"--这时该换能建模上下文语义的深度模型了(下一篇讲 TextCNN 和 LSTM).

快速回顾

  • 传统方法的价值:快速验证数据质量,提供对比基准,可解释,轻量部署
  • TF-IDF + LR:经典组合,bigram + sublinear_tf 是关键调优点
  • FastText:子词拆解处理新词,一行代码训练,适合快速迭代
  • 特征工程:脏话词表命中,文本长度,标点特征--前几个特征贡献最大
  • 错误分析:比 F1 数字更重要,决定下一步是加数据还是换模型
  • 升级时机:隐晦表达,上下文依赖,F1 瓶颈 → 切深度模型

动手练习

  1. 跑 TF-IDF baseline:用 dataset_v1.csv 跑 TF-IDF(bigram) + LogisticRegression,调优参数使 F1 最大化
  2. 训练 FastText:将数据转换为 FastText 格式,训练 FastText 分类器,对比 F1
  3. 实现手工特征:实现 extract_features() 函数,将手工特征拼接到 TF-IDF 向量后面,观察 F1 变化
  4. 做错误分析:打印所有误分类样本,归类为"隐晦对骂""新词""引用""自嘲"等类型,统计占比