图像里,卷积神经网络靠一个小窗口在图片上滑动,捕捉边缘、纹理这类局部模式。文本其实也能这么处理,一个滑动窗口盖住连续几个词,就是在看一个短语片段。TextCNN 是 Kim 2014 年提出的经典模型,用不同宽度的一维卷积同时抓取不同长度的短语特征,再做池化分类,结构简单、训练快,至今仍是文本分类的强基线。
这一节我们先把一维卷积在文本上的形状和计算讲透,包括 padding 和 dilation 这两个容易绕晕的参数,再拼出完整的 TextCNN 网络并跑通代码。读之前需要会用 PyTorch,知道张量维度是怎么回事。
1. 卷积形状
文本进模型第一步是词嵌入,每个词变成一个固定维度的向量。假设一句话有 3 个词,每个词向量 4 维,摆在矩阵里就是 4 行 3 列,一行代表一个维度,一列代表一个词。PyTorch 的 nn.Conv1d 要求输入形状是 批量、通道数、序列长度。这里通道数正好等于词向量维度,序列长度等于词数。我们先跑一段最小例子,看看形状怎么变:
import torch
import torch.nn as nn
def demo():
batch_size, token_dim, seq_len = 2, 4, 3
inputs = torch.randn(batch_size, token_dim, seq_len)
# 输入形状 (batch_size, in_channels, seq_len)
# in_channels 表示输入 token 的向量维度
print(inputs.shape)
conv = nn.Conv1d(in_channels=token_dim, out_channels=8, kernel_size=2)
# 卷积核参数形状: (out_channel, in_channels, kernel_size)
# kernel_size: 卷积核在 seq_len 方向上按照 kernel_size 滑动窗口计算
print(conv.weight.shape)
outputs = conv(inputs)
# 输出形状: (batch_size, out_channel, seq_len)
print(outputs.shape)
if __name__ == '__main__':
demo()
torch.Size([2, 4, 3]) torch.Size([8, 4, 2]) torch.Size([2, 8, 2])
我们对一遍数字。输入是 (2, 4, 3),卷积核宽度 2,一次窗口盖两个词,在 3 个词的序列上能滑出 2 个位置,所以输出序列长度是 3 减 2 加 1 等于 2,输出形状就是 (2, 8, 2)。卷积核自身的参数形状是 输出通道数、输入通道数、核宽,也就是 (8, 4, 2),和打印结果完全一致。
2. 长度与填充
上面算完我们发现一个现象,3 个词进去出来只剩 2 个位置,序列长度被卷积核啃短了。如果任务是词级标注,要求输出和输入等长,就要靠 padding 在序列两端补零。宽度 2 的核,左右各补一个零列,序列长度从 3 变 5,卷积后长度变成 4:
[[1, 3, 2], [8, 2, 2], [4, 6, 7], [3, 9, 1]] [[0, 1, 3, 2, 0], [0, 8, 2, 2, 0], [0, 4, 6, 7, 0], [0, 3, 9, 1, 0]]
补完两侧还是差一个,原作者的做法是再切一刀把多余列扔掉。更稳的等长做法是只在序列左侧补 dilation 乘 核宽减 1 个零,右侧不补,这样输出长度自动等于输入长度。这叫因果卷积的补法,不用再手动切片。
还有一个 dilation 参数,控制卷积核的跳跃间隔,默认 1 就是连续看。我们拿上面那个 4 行 3 列的矩阵举例,三个列向量分别是 [1,8,4,3]、[3,2,6,9]、[2,2,7,1]:
[[1, 3, 2], [8, 2, 2], [4, 6, 7], [3, 9, 1]]
dilation 等于 1 时,窗口连续取相邻两列,先算第一列和第二列的组合 [1,8,4,3] 与 [3,2,6,9],再算第二列和第三列的组合 [3,2,6,9] 与 [2,2,7,1]。dilation 等于 2 时,窗口跳过中间一列,直接拿第一列和第三列算,也就是 [1,8,4,3] 与 [2,2,7,1],中间那个列向量被隔了过去。
跳跃看的好处是不加深网络就能覆盖更长的上下文,参数量不涨。代价是输出长度会跟着变,同样要用 padding 补回来。把几个参数合起来,输出长度的公式是:输出长度等于输入长度加两倍 padding,减 dilation 乘 核宽减 1,再减 1,除以步长后加 1。步长固定为 1 时,想保持等长,padding 就按这个公式反推。
(待补图:原文此处有 padding 计算公式截图,上面的文字公式已覆盖该内容。)
3. 网络结构
卷积参数搞清楚了,拼 TextCNN 就是顺手的事。它的设计很直白。词嵌入之后,同时铺开几种不同宽度的卷积核,常见 3、4、5 三种,每种各出 100 个特征图。宽 3 的核专抓三字短语,宽 5 的抓五字片段,多宽度并行等于同时看不同粒度的搭配。每种卷积过一遍 ReLU 激活,然后对各自的序列维度做最大池化,整张特征图只留最显眼的那个值。三种池化结果拼在一起,过 dropout,接全连接层输出类别。
我们为什么用最大池化?因为一句话里最有判别力的短语可能出现在任何位置,池化把每个特征图最突出的响应抽出来,不管它在第几个词,句子长短不一也不影响。这正好绕开了上一节说的长度变化问题,TextCNN 本身其实不需要 padding。
4. 代码实现
结构讲完,我们直接上可跑的代码。下面这段是经典实现,卷积核宽取 3、4、5,每个宽度 100 个过滤器:
import torch
import torch.nn as nn
import torch.nn.functional as F
class TextCNN(nn.Module):
def __init__(self, vocab_size, embed_dim, num_classes, kernel_sizes=[3, 4, 5], num_filters=100, dropout=0.5):
super(TextCNN, self).__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim)
self.convs = nn.ModuleList([
nn.Conv1d(in_channels=embed_dim, out_channels=num_filters, kernel_size=k) for k in kernel_sizes
])
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(num_filters * len(kernel_sizes), num_classes)
def forward(self, inputs):
# 输入形状:(batch_size, seq_len, embed_dim)
inputs = self.embedding(inputs)
# 装换形状:(batch_size, embed_dim, seq_len)
inputs = inputs.permute(0, 2, 1)
# 应用不同尺度文本卷积
inputs = [ conv(inputs) for conv in self.convs ]
# 应用 ReLU 激活函数
inputs = [ F.relu(i) for i in inputs ]
# [torch.Size([1, 100, 6]), torch.Size([1, 100, 5]), torch.Size([1, 100, 4])]
# print([i.shape for i in inputs])
# 应用最大池化
inputs = [ i.max(dim=2)[0] for i in inputs ]
# [torch.Size([1, 100]), torch.Size([1, 100]), torch.Size([1, 100])]
# print([i.shape for i in inputs])
inputs = torch.cat(inputs, dim=1) # 拼接不同卷积核的输出
print(inputs.shape)
inputs = self.dropout(inputs)
return self.fc(inputs)
if __name__ == '__main__':
estimator = TextCNN(vocab_size=100, embed_dim=8, num_classes=2)
inputs = torch.randint(0, 100, size=(1, 8))
logits = estimator(inputs)
print(logits)
torch.Size([1, 300]) tensor([[0.1518, 0.4898]], grad_fn=<AddmmBackward0>)
我们再对一遍形状。输入是一个长度 8 的 token 序列,嵌入后转成 (1, 8, 8),再把维度转置给卷积用。三种核宽分别卷完,序列长度变成 6、5、4,各 100 个通道。最大池化把序列维度压掉,各得 100 维,拼成 300 维,再过全连接层输出两个类别的得分,和打印结果一致。
5. 误区澄清
第一个坑,是把 in_channels 理解成卷积核个数。它在文本任务里等于词向量维度,是输入的通道数,真正决定输出多少种特征的是 out_channels。
第二个坑,是以为 TextCNN 能捕捉长距离依赖。它的窗口最多覆盖五六个词,长句里相隔很远的两个词它看不见,长程关系得靠注意力机制。
第三个坑,是觉得池化只取最大值是浪费信息。这正是论文的有意设计,分类只需要最关键的那个短语信号,经验上最大池化在句子分类上效果通常好于平均池化。
我们回到开头的问题。TextCNN 用多宽度一维卷积并行抓不同长度的短语特征,最大池化把不定长的序列压成定长向量,再接全连接分类。它的卷积参数少、训练快,效果却能打过不少复杂模型,至今是文本分类的入门标配。原论文见 https://arxiv.org/abs/1408.5882




冀公网安备13050302001966号