一句话总结
当 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) 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 = self.embedding(x) x = x.permute(0, 2, 1)
pooled = [] for conv in self.convs: h = torch.relu(conv(x)) h = h.max(dim=2).values pooled.append(h)
out = torch.cat(pooled, dim=1) out = self.dropout(out) return self.fc(out)
|
为什么 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) output, (hidden, _) = self.lstm(x)
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] ids = ids[:self.max_len] ids += [0] * (self.max_len - len(ids)) 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), )
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: logits = model(batch_x)
loss = criterion(logits, batch_y)
optimizer.zero_grad() loss.backward()
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 → 经典过拟合.
对策:
- Dropout(随机丢弃部分神经元,防止共适应)
- Early stopping(验证集 F1 不再上升就停)
- 增加数据(数据增强,第 06 篇的方法)
训练技巧清单
把深度模型训练中最常用的技巧汇总:
| 技巧 |
做法 |
作用 |
| 预训练词向量 |
用 nn.Embedding.from_pretrained() 加载 |
利用大语料学到的词义,减少数据需求 |
| 学习率衰减 |
torch.optim.lr_scheduler.StepLR |
训练后期减小步幅,避免震荡 |
| Early Stopping |
监控验证集 loss,连续 N 轮不降就停 |
防止过拟合 |
| 梯度裁剪 |
torch.nn.utils.clip_grad_norm_ |
LSTM 防止梯度爆炸 |
| 类别加权 |
CrossEntropyLoss(weight=...) |
对骂样本少时加大权重 |
weights = torch.tensor([1.0, 3.0]) criterion = nn.CrossEntropyLoss(weight=weights)
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 实验:
texts, labels = load_dataset("data/dataset_v1.csv") vocab = build_vocab(texts, max_size=5000)
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 )
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)
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]))
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 遇瓶颈 + 错误集中在语义类漏报 → 换深度模型
动手练习
- 实现 TextCNN:用上面的
TextCNN 类,在 dataset_v1.csv 上训练,对比 TF-IDF + LR 的 F1
- 实现 LSTM 分类器:用
LSTMClassifier 替换 TextCNN,对比两者的 F1 和训练速度
- 加 Early Stopping:实现验证集监控,记录停止时的 epoch 数和最终 F1
- 做错误对比:把 TextCNN 和 TF-IDF + LR 的错误案例放在一起,观察哪些错误被深度模型修复了
- 预训练词向量:下载中文预训练词向量(如 Tencent AI Lab),用
from_pretrained() 加载,观察 F1 变化