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

深度学习文本分类

一句话总结

当 TF-IDF + LogReg 的 F1 到了瓶颈,TextCNN 和 LSTM 是第一步升级.
它们能自动学特征,捕获词序和局部语义,代价是需要 GPU 和更多数据.

前置回顾

第 07 篇用 TF-IDF + LogReg 和 FastText 跑出了 baseline,F1 大约 0.80-0.88.
错误分析表明大部分漏报来自隐晦表达和上下文依赖--这些是词袋模型的能力边界.
本篇引入深度学习模型:TextCNN 和 LSTM,用 PyTorch 从零搭建训练流程.

为什么需要深度模型

传统方法的核心问题:特征是人工设计的. TF-IDF 只看词频,N-gram 只看局部窗口,手工特征靠经验堆砌. 当错误主要来自以下情况时,人工特征已经不够用:

  • "你可真行啊" -- 没有脏话关键词,TF-IDF 无法区分这是讽刺还是表扬
  • "这种水平也配写代码" -- 语义完整的对骂,但每个词单独都是中性的
  • "nb" vs "牛逼" vs "NB" -- 同一个意思的多种写法,词表永远追不上

深度模型的本质区别:自动从数据中学习特征,不再需要人工设计. 模型自己决定哪些局部模式(TextCNN)或序列依赖(LSTM)对分类最有用.

传统方法 = 写一堆 if-else 规则做请求路由, 每加一种新 pattern 就加一条规则.
深度模型 = 用一个 ML router 自动学习路由规则, 给够样本就能泛化到没见过的 pattern.

TextCNN:用卷积捕获局部语义

核心思想

TextCNN(Kim, 2014)把文本当作一维"图像":每个词是一个像素,词向量是像素的通道值. 卷积核在文本上滑动,提取局部 N-gram 模式.

输入: "你 脑子 有 病 吧"
词向量: ↓ ↓ ↓ ↓ ↓
[v1] [v2] [v3][v4][v5] ← 每个词是一个 d 维向量

卷积核(窗口=2):
滑窗 1: [v1, v2] → "你 脑子" → 特征值 f1
滑窗 2: [v2, v3] → "脑子 有" → 特征值 f2
滑窗 3: [v3, v4] → "有 病" → 特征值 f3
滑窗 4: [v4, v5] → "病 吧" → 特征值 f4

Max Pooling: 取最大值 max(f1,f2,f3,f4) → 保留最强的局部信号

用多个不同大小的卷积核(窗口 2, 3, 4)同时扫描,就能捕获不同粒度的局部模式:窗口 2 捕获二元组("有病"),窗口 3 捕获三元组("脑子有病"),窗口 4 捕获短语级别的模式.

TextCNN = strings.Contains() 的升级版. strings.Contains("有病") 只能匹配精确字串;TextCNN 的卷积核学到的是"类似于有病的语义模式",能匹配"脑子有问题""智商欠费"等变体.

PyTorch 实现

import torch
import torch.nn as nn

class TextCNN(nn.Module):
def __init__(self, vocab_size, embed_dim, num_classes, num_filters=100):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim)
# 三种窗口大小的卷积核,分别捕获 2/3/4-gram
self.convs = nn.ModuleList([
nn.Conv1d(embed_dim, num_filters, kernel_size=k)
for k in [2, 3, 4]
])
self.dropout = nn.Dropout(0.5)
self.fc = nn.Linear(num_filters * 3, num_classes)

def forward(self, x):
# x: (batch, seq_len) → 词 ID 序列
x = self.embedding(x) # (batch, seq_len, embed_dim)
x = x.permute(0, 2, 1) # Conv1d 要求 (batch, channels, length)

# 每种卷积核扫描 + ReLU 激活 + 最大池化
pooled = []
for conv in self.convs:
h = torch.relu(conv(x)) # (batch, num_filters, new_length)
h = h.max(dim=2).values # (batch, num_filters) ← 取最强信号
pooled.append(h)

out = torch.cat(pooled, dim=1) # 拼接三种卷积的结果
out = self.dropout(out)
return self.fc(out) # (batch, num_classes)

为什么 Max Pooling 而不是 Average

对骂检测中,一句话里可能只有一个关键短语(如 "脑子有病")决定了整句的类别.
Max Pooling 保留最强的局部信号,不会被大量中性词稀释.
Average Pooling 会让一个关键短语被其他中性词平均掉.

LSTM:用序列记忆捕获上下文

核心思想

LSTM(Long Short-Term Memory)按顺序读取每个词,维护一个"记忆状态"来记住之前看过的内容. 它能建模长距离依赖:前面出现的词影响后面的判断.

输入: "你 说 得 对 , 就 你 最 聪明"

读"你" → 记忆: [人称代词,可能在对话]
读"说" → 记忆: [有人在表达观点]
读"得对" → 记忆: [看起来在同意]
读"," → 记忆: [转折可能来了]
读"就你" → 记忆: [反转!这是讽刺] ← LSTM 能捕获这种语义反转
读"最聪明" → 记忆: [确认是阴阳怪气]

最终状态 → 分类为: 对骂(阴阳怪气)

TextCNN 只看局部窗口,无法跨越逗号理解"先同意后反转"的语义. LSTM 逐词阅读,能记住全句的上下文流.

TextCNN = regexp.FindAll(), 只关注匹配到的局部模式,不关心模式之间的关系.
LSTM = 逐行 bufio.Scanner, 每读一行都能参考之前所有行的累积状态.

PyTorch 实现

class LSTMClassifier(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim)
self.lstm = nn.LSTM(
embed_dim, hidden_dim,
batch_first=True,
bidirectional=True, # 双向:正着读一遍 + 反着读一遍
)
self.dropout = nn.Dropout(0.5)
self.fc = nn.Linear(hidden_dim * 2, num_classes) # 双向 → 维度翻倍

def forward(self, x):
x = self.embedding(x) # (batch, seq_len, embed_dim)
output, (hidden, _) = self.lstm(x)

# 取双向 LSTM 最后一步的隐藏状态拼接
hidden = torch.cat([hidden[-2], hidden[-1]], dim=1)
hidden = self.dropout(hidden)
return self.fc(hidden)

双向 LSTM 的代价

bidirectional=True 让模型同时正向和反向读取文本,效果通常好于单向.
但训练时间翻倍,参数量翻倍. 如果数据量很小(< 1000 条),单向 LSTM 反而不容易过拟合.

PyTorch 训练流程

不管用 TextCNN 还是 LSTM,训练流程是通用的. 理解这个流程是后续用 BERT 微调的基础.

数据准备:Dataset 和 DataLoader

from torch.utils.data import Dataset, DataLoader

class TextDataset(Dataset):
def __init__(self, texts, labels, vocab, max_len=128):
self.labels = labels
self.max_len = max_len
self.vocab = vocab
self.encoded = [self._encode(t) for t in texts]

def _encode(self, text):
tokens = list(text) # 字级别分词(中文最简单的方式)
ids = [self.vocab.get(t, 1) for t in tokens] # 1 = UNK
# 截断或填充到固定长度
ids = ids[:self.max_len]
ids += [0] * (self.max_len - len(ids)) # 0 = PAD
return ids

def __len__(self):
return len(self.labels)

def __getitem__(self, idx):
return (
torch.tensor(self.encoded[idx], dtype=torch.long),
torch.tensor(self.labels[idx], dtype=torch.long),
)

# DataLoader 负责分批,打乱,并行加载
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)

为什么要固定长度

GPU 批量计算要求同一个 batch 内所有样本形状一致.
短文本用 PAD 填充到 max_len,长文本截断到 max_len.
群聊消息通常很短(< 50 字),max_len=128 绰绰有余.

训练循环:5 步骨架

model = TextCNN(vocab_size=5000, embed_dim=128, num_classes=2)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()

for epoch in range(10):
model.train()
total_loss = 0

for batch_x, batch_y in train_loader:
# 1. 前向传播:输入 → 模型 → 预测
logits = model(batch_x)

# 2. 计算损失:预测与真实标签的差距
loss = criterion(logits, batch_y)

# 3. 反向传播:计算每个参数对损失的贡献(梯度)
optimizer.zero_grad()
loss.backward()

# 4. 更新参数:沿梯度方向微调参数
optimizer.step()

total_loss += loss.item()

avg_loss = total_loss / len(train_loader)
print(f"Epoch {epoch+1}, Loss: {avg_loss:.4f}")

这 5 步是 PyTorch 训练的固定骨架:forward → loss → zero_grad → backward → step.

训练循环 = Go 中的重试循环 + 误差修正.
每一轮(epoch)就像重新处理所有请求,每个 batch 像一次 RPC:
发请求(forward) → 对比期望响应(loss) → 计算哪里出了问题(backward) → 调整策略(step).

评估函数

from sklearn.metrics import classification_report

def evaluate(model, data_loader):
model.eval()
all_preds, all_labels = [], []

with torch.no_grad(): # 评估时不需要计算梯度,省内存
for batch_x, batch_y in data_loader:
logits = model(batch_x)
preds = logits.argmax(dim=1)
all_preds.extend(preds.cpu().numpy())
all_labels.extend(batch_y.cpu().numpy())

print(classification_report(
all_labels, all_preds, target_names=["正常", "对骂"]
))

TextCNN vs LSTM:怎么选

两种模型各有长短,选择取决于数据特点:

维度 TextCNN LSTM
核心能力 捕获关键局部模式 建模序列依赖和上下文
对骂检测强项 显式脏话,关键短语命中 阴阳怪气,先扬后抑
训练速度 快(卷积可并行) 慢(必须逐步处理)
数据需求 1000+ 条可用 2000+ 条更稳定
可解释性 中(看卷积核激活的 N-gram) 低(隐藏状态难解读)

实战选择建议

数据量 < 2000 条 → 先用 TextCNN,结构简单不容易过拟合.
错误分析显示大量"语义反转"漏报 → 换 LSTM 或 BiLSTM.
两者都试,取测试集 F1 更高的那个.

深度模型 vs 传统方法:什么时候该升级

不是所有场景都需要深度模型. 回顾第 07 篇的 baseline 数据:

场景 推荐方法 理由
数据 < 500 条 TF-IDF + LR 深度模型数据不够会严重过拟合
只需关键词级别检测 FastText 简单,快速,效果够用
需要检测隐晦表达 TextCNN / LSTM 能学到超越关键词的语义模式
需要理解上下文语义 BERT(下篇) 预训练语言模型,最强语义理解
部署资源极度有限 FastText 模型几 KB,毫秒推理

过拟合:小数据的头号杀手

深度模型参数量远大于传统模型(TextCNN 约 50 万参数,LR 只有几千).
如果训练数据太少,模型会"背下"训练集的每条样本,而非学到泛化规律.
表现:训练集 F1 = 0.99,测试集 F1 = 0.60 → 经典过拟合.

对策:

  1. Dropout(随机丢弃部分神经元,防止共适应)
  2. Early stopping(验证集 F1 不再上升就停)
  3. 增加数据(数据增强,第 06 篇的方法)

训练技巧清单

把深度模型训练中最常用的技巧汇总:

技巧 做法 作用
预训练词向量 nn.Embedding.from_pretrained() 加载 利用大语料学到的词义,减少数据需求
学习率衰减 torch.optim.lr_scheduler.StepLR 训练后期减小步幅,避免震荡
Early Stopping 监控验证集 loss,连续 N 轮不降就停 防止过拟合
梯度裁剪 torch.nn.utils.clip_grad_norm_ LSTM 防止梯度爆炸
类别加权 CrossEntropyLoss(weight=...) 对骂样本少时加大权重
# 类别加权示例:对骂样本数是正常的 1/3,给对骂类 3 倍权重
weights = torch.tensor([1.0, 3.0]) # [正常, 对骂]
criterion = nn.CrossEntropyLoss(weight=weights)

# Early stopping 简单实现
best_f1 = 0
patience, wait = 5, 0
for epoch in range(50):
train_one_epoch(model, train_loader, optimizer, criterion)
f1 = evaluate_f1(model, val_loader)
if f1 > best_f1:
best_f1 = f1
wait = 0
torch.save(model.state_dict(), "best_model.pt") # 保存最优
else:
wait += 1
if wait >= patience:
print(f"Early stopping at epoch {epoch+1}")
break

完整实验流程

把上面的知识串起来,跑一个完整的 TextCNN 实验:

# 完整流程(伪代码级别,突出关键步骤)
# ─────────────────────────────────────

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

# 2. 划分数据集(训练 70% / 验证 15% / 测试 15%)
train_texts, temp_texts, train_labels, temp_labels = train_test_split(
texts, labels, test_size=0.3, stratify=labels
)
val_texts, test_texts, val_labels, test_labels = train_test_split(
temp_texts, temp_labels, test_size=0.5, stratify=temp_labels
)

# 3. 构建 DataLoader
train_loader = DataLoader(TextDataset(train_texts, train_labels, vocab), batch_size=32, shuffle=True)
val_loader = DataLoader(TextDataset(val_texts, val_labels, vocab), batch_size=64)
test_loader = DataLoader(TextDataset(test_texts, test_labels, vocab), batch_size=64)

# 4. 初始化模型
model = TextCNN(vocab_size=len(vocab), embed_dim=128, num_classes=2)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss(weight=torch.tensor([1.0, 3.0]))

# 5. 训练(带 early stopping)
# ... 上面的 early stopping 循环

# 6. 加载最优模型,在测试集上最终评估
model.load_state_dict(torch.load("best_model.pt"))
evaluate(model, test_loader)

三集划分的意义

验证集(val)用来调参数和做 early stopping--模型训练过程中可以反复看.
测试集(test)只在最终评估时用一次--模拟真实上线后遇到的新数据.
如果只分两集(训练+测试),用测试集调参会导致"间接过拟合到测试集".

下一步:情感分析与群聊对骂检测

本篇介绍的 TextCNN 和 LSTM 是单条消息级别的分类器. 但群聊对骂有一个特殊性:上下文. "你说得对" 单独看是正常的,但如果前面是一连串争吵,它可能是讽刺.

下一篇(第 09 篇)会把分类器放进群聊检测的完整 pipeline 中:如何用上下文窗口,如何融合对话级特征,如何处理真实数据中的噪声.

快速回顾

  • TextCNN:用多种大小的卷积核扫描文本,捕获关键 N-gram 模式,结构简单训练快
  • LSTM:逐词阅读并维护记忆状态,擅长捕获语义反转和长距离依赖
  • 训练 5 步骨架:forward → loss → zero_grad → backward → step,所有 PyTorch 模型通用
  • 过拟合防御:Dropout + Early Stopping + 数据增强,小数据场景必须用
  • 选择策略:数据少用 TextCNN,需要上下文理解用 LSTM,F1 说了算
  • 升级时机:传统方法 F1 遇瓶颈 + 错误集中在语义类漏报 → 换深度模型

动手练习

  1. 实现 TextCNN:用上面的 TextCNN 类,在 dataset_v1.csv 上训练,对比 TF-IDF + LR 的 F1
  2. 实现 LSTM 分类器:用 LSTMClassifier 替换 TextCNN,对比两者的 F1 和训练速度
  3. 加 Early Stopping:实现验证集监控,记录停止时的 epoch 数和最终 F1
  4. 做错误对比:把 TextCNN 和 TF-IDF + LR 的错误案例放在一起,观察哪些错误被深度模型修复了
  5. 预训练词向量:下载中文预训练词向量(如 Tencent AI Lab),用 from_pretrained() 加载,观察 F1 变化