文本的 CNN 与 RNN 本节摘要:卷积学 n-gram,循环记住过去,两者都被注意力取代,但两者在受约束的硬件上仍然要紧。TF-IDF 和 Word2Vec 产出的是无视词序的扁平向量,建在其上的分类器分不清 和 ,而词序有时恰恰就是信号。Transformer 到来前,有两个架构家族补上了这个缺口:文本卷积网(TextCNN)在词嵌入序列上做一维卷积,宽 3 的滤波器就是一个可学的三元检测器,堆叠不同宽度再最大池化,扁平、并行、快;循环网(RNN、LSTM、GRU)逐个处理 token,靠隐状态把信息带向未来,顺序、有记忆、输入长度灵活,2014 到 2017 统治序列建模,然后注意力来了。本节从零搭两者,再点出那个催生注意力的失败。
本节摘要:卷积学 n-gram,循环记住过去,两者都被注意力取代,但两者在受约束的硬件上仍然要紧。TF-IDF 和 Word2Vec 产出的是无视词序的扁平向量,建在其上的分类器分不清
dog bites man和man bites dog,而词序有时恰恰就是信号。Transformer 到来前,有两个架构家族补上了这个缺口:文本卷积网(TextCNN)在词嵌入序列上做一维卷积,宽 3 的滤波器就是一个可学的三元检测器,堆叠不同宽度再最大池化,扁平、并行、快;循环网(RNN、LSTM、GRU)逐个处理 token,靠隐状态把信息带向未来,顺序、有记忆、输入长度灵活,2014 到 2017 统治序列建模,然后注意力来了。本节从零搭两者,再点出那个催生注意力的失败。
对应原课程:Phase 5 · Lesson 08 ·
cnns-rnns-for-text(原英文phases/05-nlp-foundations-to-advanced/08-cnns-rnns-for-text/docs/en.md)。前置依赖:Phase 3 · 11(PyTorch 入门)、第 03 节(词嵌入)、Phase 4 · 02(从零实现卷积)。
阅读完本节,你应当能够:
TF-IDF 和 Word2Vec 产出的是无视词序的扁平向量。建在其上的分类器分不清 dog bites man 和 man bites dog。词序有时恰恰就是信号。
Transformer 到来前,两个架构家族补上了这个缺口。
文本卷积网(TextCNN):在词嵌入序列上做一维卷积。宽 k 的滤波器滑过连续的 k 元嵌入,产出特征图。全局最大池化挑出最强激活,拼接几个宽度的池化输出,喂给分类头。宽 3 的滤波器就是一个可学的三元检测器,堆叠宽度 2/3/4/5 检测多尺度模式,最大池化到定长表示。扁平、并行、快。
循环网(RNN、LSTM、GRU):逐个处理 token,维护一个把信息带向未来的隐状态。顺序、有记忆、输入长度灵活。2014 到 2017 统治序列建模,然后注意力来了。
TextCNN(Kim, 2014)为何奏效:滤波器就是可学的 n-gram,最大池化位置不变,所以 "not good" 无论在评论开头还是中间都激活同一特征。三个宽度各 100 个滤波器,就是 300 个学到的 n-gram 检测器。训练并行,无顺序依赖。
RNN:每个时间步 t,隐状态 h_t = f(W * x_t + U * h_{t-1} + b),跨时间共享 W、U、b。时刻 T 的隐状态是整个前缀的摘要。分类时,对 h_1 ... h_T 池化(最大、均值或取末态)。
普通 RNN 受梯度消失之苦。LSTM 加门决定遗忘什么、存什么、输出什么,稳定长序列梯度。GRU 把 LSTM 简化成两门,参数更少,表现相近。
双向 RNN 一个前向、一个后向,拼接隐状态。每个 token 的表示同时看到左右上下文,对标注任务必不可少。
import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, n_classes, filter_widths=(2, 3, 4), n_filters=64, dropout=0.3): super().__init__() self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, n_filters, kernel_size=k) for k in filter_widths ]) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(n_filters * len(filter_widths), n_classes) def forward(self, token_ids): x = self.embed(token_ids).transpose(1, 2) pooled = [] for conv in self.convs: c = F.relu(conv(x)) p = F.max_pool1d(c, c.size(2)).squeeze(2) pooled.append(p) h = torch.cat(pooled, dim=1) return self.fc(self.dropout(h))
transpose(1, 2) 把 [batch, seq_len, embed_dim] 换成 [batch, embed_dim, seq_len],因为 nn.Conv1d 把中间轴当通道。池化输出定长,与输入长度无关。
class LSTMClassifier(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, n_classes, bidirectional=True, dropout=0.3): super().__init__() self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True, bidirectional=bidirectional) factor = 2 if bidirectional else 1 self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_dim * factor, n_classes) def forward(self, token_ids): x = self.embed(token_ids) out, _ = self.lstm(x) pooled = out.max(dim=1).values return self.fc(self.dropout(pooled))
对序列做最大池化,而非取末态。分类任务上,最大池化通常胜过取末隐状态——因为长序列末尾的信息容易在末态里一家独大。
无门控的普通 RNN 学不到长程依赖。想一个玩具任务:预测序列里是否出现过 token A。若 A 在位置 1、序列长 100,损失梯度要回流过 99 次循环权重的乘法。权重小于 1 则梯度消失,大于 1 则爆炸。
def vanishing_gradient_sim(seq_len, recurrent_weight=0.9): import math return math.pow(recurrent_weight, seq_len) # weight=0.9、100 步时: # 0.9 ^ 100 ≈ 2.7e-5 # 从第 100 步回传到第 1 步的梯度,实际上就是零。
LSTM 用一个只有加性交互的细胞状态修掉了这个(遗忘门是乘性缩放,但梯度仍沿「高速路」流动)。GRU 用更少参数做了类似的事。两者都能让你在 100+ 步序列上稳定训练。
即便有 LSTM,三个问题依旧:
注意力把三个全解决了。Transformer 干脆丢掉循环。第 10 节就是那个转折点。
PyTorch 的 nn.LSTM、nn.GRU、nn.Conv1d 都是生产就绪的,训练代码是标准的。
Hugging Face 提供预训练嵌入,你把它当输入层插进去:
from transformers import AutoModel encoder = AutoModel.from_pretrained("bert-base-uncased") for param in encoder.parameters(): param.requires_grad = False class BertCNN(nn.Module): def __init__(self, n_classes, filter_widths=(2, 3, 4), n_filters=64): super().__init__() self.encoder = encoder self.convs = nn.ModuleList([nn.Conv1d(768, n_filters, kernel_size=k) for k in filter_widths]) self.fc = nn.Linear(n_filters * len(filter_widths), n_classes) def forward(self, input_ids, attention_mask): with torch.no_grad(): out = self.encoder(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state x = out.transpose(1, 2) pooled = [F.max_pool1d(F.relu(conv(x)), kernel_size=conv(x).size(2)).squeeze(2) for conv in self.convs] return self.fc(torch.cat(pooled, dim=1))
「合约束时就用」清单:
其余一切,交给 Transformer。
保存为 outputs/prompt-text-encoder-picker.md:
--- name: text-encoder-picker description: Pick a text encoder architecture for a given constraint set. phase: 5 lesson: 08 --- Given constraints (task, data volume, latency budget, deploy target, compute budget), output: 1. Encoder architecture: TextCNN, BiLSTM, BiLSTM-CRF, transformer fine-tune, or "use a pretrained transformer as a frozen encoder + small head". 2. Embedding input: random init, GloVe / fastText frozen, or contextualized transformer embeddings. 3. Training recipe in 5 lines: optimizer, learning rate, batch size, epochs, regularization. 4. One monitoring signal. For RNN/CNN models: attention mechanism absence means they miss long-range deps; check per-length accuracy. For transformers: fine-tuning collapse if LR too high; check train loss. Refuse to recommend fine-tuning a transformer when data is under ~500 labeled examples without showing that a TextCNN / BiLSTM baseline has plateaued. Flag edge deployment as needing architecture-before-everything.
dog bites man 与 man bites dog 分不清,词序有时就是信号。h_t = f(W x_t + U h_{t-1}),跨时间共享权重,h_T 是前缀摘要。transpose:把嵌入维度换到通道轴,池化输出定长。0.9^100 ≈ 2.7e-5,普通 RNN 学不到长程依赖。下一节,我们走进编码器-解码器的核心——进入「序列到序列」,看定长上下文向量如何成为瓶颈,为注意力铺路。