【文本分类】 基于深度学习的方法总结

1,363 阅读30分钟

简介

文本分类在文本处理中是很重要的一个模块,它的应用也非常广泛,比如:新闻分类、简历分类、邮件分类、办公文档分类、区域分类等诸多方面,还能够实现文本过滤,从大量文本中快速识别和过滤出符合特殊要求的信息。它和其他的分类没有本质的区别,核心方法为首先提取分类数据的特征,然后选择最优的匹配,从而分类。

通常来讲,文本分类任务是指在给定的分类体系中,将文本指定分到某个或某几个类别中。被分类的对象有短文本,例如句子、标题、商品评论等等,长文本如文章等。分类体系一般人工划分,例如:1)政治、体育、军事 2)正能量、负能量 3)好评、中性、差评。此外,还有文本多标签分类,比如一篇博客的标签可以同时是:自然语言处理,文本分类等。因此,对应的分类模式可以分为:二分类多分类以及多标签分类问题。

文本分类任务主要分为:

  • 情感分析:旨在分析人们在文本数据(如产品评论、电影评论和推特)中的观点,并提取他们的极性和观点。可以是二分类问题也可以是多分类问题,二元情感分析是将文本分为正类和负类,而多类情感分析则侧重于将数据分为细粒度的标签或多层次的强度。

  • 新闻分类:新闻分类系统可以帮助用户实时获取感兴趣的信息。基于用户兴趣的新闻主题识别和相关新闻推荐是新闻分类的两个主要应用。

  • 主题分析:主题分析试图通过识别文本的主题从文本中自动获得意义。主题分类的目标是为每个文档分配一个或多个主题,以便于分析。

  • 问答系统:有两种类型的问答系统:提取式和生成式。抽取式QA可以看作是文本分类的一个特例。给定一个问题和一组候选答案,根据问题需要将每个候选答案正确分类。

  • 自然语言推理:NLI,也称为文本蕴含识别(RTE),预测一个文本的含义是否可以从另一个文本中推断出来。特别是,一个系统需要给每对文本单元分配一个标签,比如蕴涵、矛盾和中立。

通常,进行文本分类的主要方法有三种:

  • 基于规则特征匹配的方法(如根据“喜欢”,“讨厌”等特殊词来评判情感,但准确率低,通常作为一种辅助判断的方法)

  • 基于传统机器学习的方法(特征工程 + 分类算法)

image.png

  • 基于深度学习的方法(词向量 + 神经网络)

基于深度学习方法的文本分类

FastText

论文:arxiv.org/abs/1607.01…
代码:github.com/facebookres…

FastText是 Facebook 于2016年开源的一个词向量计算和文本分类工具,其特点就是训练速度快。在文本分类任务中,FastText(浅层网络)往往能取得和深度网络相媲美的精度,却在训练时间上比深度网络快许多数量级。在标准的多核CPU上, 在10分钟之内能够训练10亿词级别语料库的词向量,在1分钟之内能够分类有着30万多类别的50多万句子。

FastText是一个快速文本分类算法,与基于神经网络的分类算法相比有两大优点:

  1. FastText在保持高精度的情况下加快了训练速度和测试速度
  2. FastText不需要预训练好的词向量,FastText会自己训练词向量
  3. FastText两个重要的优化:Hierarchical SoftmaxN-gram

FastText模型架构和 word2vec 中的 CBOW 很相似, 不同之处是FastText预测标签而CBOW预测的是中间词,即模型架构类似但是模型的任务不同:

word2vec将上下文关系转化为多分类任务,通常的文本数据中,词库少则数万,多则百万,在训练中直接训练多分类逻辑回归并不现实。word2vec 中提供了两种针对大规模多分类问题的优化手段, Negative samplingHierarchical softmax。在优化中,Negative sampling 只更新少量负类词的词向量,从而减轻了计算量。Hierarchical softmax 将词库表示成前缀树,从树根到叶子的路径可以表示为一系列二分类器,一次多分类计算的复杂度从 V|V| 降低到了树的高度 log2Vlog_2 |V|

FastText模型架构:其中 w1,w2,,wn1,wnw_1,w_2,\ldots,w_{n-1},w_n 表示一个文本中的 n-gram向量,每个特征是词向量的平均值。

image.png

缺点是:

我不喜欢这类电影,但是喜欢这一个。

我喜欢这类电影,但是不喜欢这一个。

这样的两个句子经过词向量平均以后已经送入单层神经网络的时候已经完全一模一样了,分类器不可能分辨出这两句话的区别,只有添加 n-gram 特征以后才可能有区别。因此,在实际应用的时候需要对数据有足够的了解,然后在选择模型。

对于文本长且对速度要求高的场景,FasttextBaseline 首选。同时用它在无监督语料上训练词向量,进行文本表示也不错。不过想继续提升效果还需要更复杂的模型。

模型结构

image.png

  1. 模型输入: [batch_size, seq_len]

  2. embedding层:随机初始化, 词向量维度为embed_size: [batch_size, seq_len, embed_size]

  3. 求所有 seq_len 个词的均值: [batch_size, embed_size]

  4. 全连接 + softmax归一化: [batch_size,num_class]

主要代码

class FastText(nn.Module):
    def __init__(self, config, word_embedding, freeze):
        """
        config.class_num: 类别数
        word_embedding: 训练好的词向量(一个类,包含idx2str、str2idx、vectors)
        freeze: 是否冻结词向量
        """
        super(FastText, self).__init__()
        word_embedding = word_embedding
        self.embedding_size = len(word_embedding.vectors[0])
        # 加载词向量
        self.embedding = nn.Embedding.from_pretrained(torch.from_numpy(np.asarray(word_embedding.vectors)),
                                                      freeze=freeze)
        self.fc = nn.Linear(self.embedding_size, config.class_num)

    def forward(self, input_ids):
        # text = [batch size, sent len]
        embedded = self.embedding(input_ids).float()
        # embedded = [batch size, sent len, emb dim]
        pooled = F.avg_pool2d(embedded, (embedded.shape[1], 1)).squeeze(1)
        # pooled = [batch size, embedding_dim]
        return self.fc(pooled), pooled

TextCNN

论文:arxiv.org/abs/1408.58…
代码:github.com/yoonkim/CNN…

TextCNN 是 Yoon Kim 在2014年提出的模型,开创了用 CNN 编码 n-gram特征的先河。

image.png

详细过程:

  • Embedding:第一层是图中最左边的 5×35 \times 3 的句子矩阵,每行是词向量,维度为 33,这个可以类比为图像中的原始像素点。

  • Convolution:然后经过 kernel_sizes=(2,3,4) 的一维卷积层,每个kernel_size22 个输出 channel

  • MaxPolling:第三层是一个 1-max pooling 层(因为是时间维度的,也称 max-over-time pooling),这样不同长度句子经过 pooling 层之后都能变成定长的表示。

  • Full Connection and Softmax:最后接一层全连接的 softmax 层,输出每个类别的概率。

TextCNN 的实践中,有很多地方可以优化:

  • kernel_sizes尺寸:这个参数决定了抽取 n-gram 特征的长度,这个参数主要跟数据有关,平均长度在 5050 以内的话,用 1010 以下就可以了,否则可以长一些。在调参时可以先用一个尺寸 grid search,找到一个最优尺寸,然后尝试最优尺寸和附近尺寸的组合

  • Filter 个数:这个参数会影响最终特征的维度,维度太大的话训练速度就会变慢。这里在 100600100-600 之间调参即可

  • CNN的激活函数:可以尝试 ReLUtanh

  • 正则化:指对 CNN 参数的正则化,可以使用 dropoutL2,但能起的作用很小,可以试下小的 dropout

  • Pooling方法:根据情况选择 meanmaxk-max pooling,大部分时候 max 表现就很好,因为分类任务对细粒度语义的要求不高,只抓住最大特征就好了

  • Embedding表:中文可以选择 charword 级别的输入,也可以两种都用,会提升些效果。如果训练数据充足(10w+10w+),也可以从头训练

  • 蒸馏 BERTlogits,利用领域内无监督数据

TextCNN 是很适合中短文本场景的强 baseline,但不太适合长文本,因为卷积核尺寸通常不会设很大,无法捕获长距离特征。同时max-pooling也存在局限,丢失了文本的结构信息,因此很难去发现文本中的转折关系等复杂模式。。另外再仔细想的话,TextCNN 和传统的 n-gram 词袋模型本质是一样的,它的好效果很大部分来自于词向量的引入,解决了词袋模型的稀疏性问题。

模型结构

image.png

主要代码

class TextCNN(nn.Module):
    def __init__(self, config, word_embedding, freeze):
        """
        config.n_filters: 卷积核个数(对应2维卷积的通道数)
        config.filter_sizes: 卷积核的多个尺寸
        config.class_num: 类别数
        config.dropout: dropout率
        word_embedding: 训练好的词向量(一个类,包含idx2str、str2idx、vectors)
        freeze: 是否冻结词向量
        """
        super(TextCNN, self).__init__()
        word_embedding = word_embedding
        self.embedding_size = len(word_embedding.vectors[0])
        self.embedding = nn.Embedding.from_pretrained(torch.from_numpy(np.asarray(word_embedding.vectors)),
                                                      freeze=freeze)
        self.convs = nn.ModuleList(
            [nn.Conv2d(in_channels=1, out_channels=config.n_filters, 
                       kernel_size=(fs, self.embedding_size)) for fs in config.filter_sizes])
        self.fc = nn.Linear(len(config.filter_sizes) * config.n_filters, config.class_num)
        self.dropout = nn.Dropout(config.dropout)

    def forward(self, input_ids):
        # text = [batch size, sent len]
        embedded = self.embedding(input_ids)
        # embedded = [batch size, sent len, emb dim]
        embedded = embedded.unsqueeze(1).float()
        # embedded = [batch size, 1, sent len, emb dim]
        conved = [F.relu(conv(embedded)).squeeze(3) for conv in self.convs]
        # conved_n = [batch size, n_filters, sent len - filter_sizes[n] + 1]
        pooled = [F.max_pool1d(conv, int(conv.shape[2])).squeeze(2) for conv in conved]
        # pooled_n = [batch size, n_filters]
        cat = self.dropout(torch.cat(pooled, dim=1))
        # cat = [batch size, n_filters * len(filter_sizes)]
        return self.fc(cat)

DPCNN

论文:ai.tencent.com/ailab/media…
代码:github.com/649453932/C…

ACL 2017 年中,腾讯 AI-lab 提出了 Deep Pyramid Convolutional Neural Networks for Text Categorization(DPCNN)。论文中提出了一种基于 word-level 级别的网络,由于 TextCNN 不能通过卷积获得文本的长距离依赖关系,而论文中 DPCNN 通过不断加深网络,可以抽取长距离的文本依赖关系。实验证明在不增加太多计算成本的情况下,增加网络深度就可以获得最佳的准确率。‍

image.png

Region embedding

作者将 TextCNN 的包含多尺寸卷积滤波器的卷积层的卷积结果称之为 Region embedding,意思就是对一个文本区域/片段(比如3-gram)进行一组卷积操作后生成的 embedding

卷积和全连接的权衡

产生 region embedding 后,按照经典的 TextCNN 的做法的话,就是从每个特征图中挑选出最有代表性的特征,也就是直接应用全局最大池化层,这样就生成了这段文本的特征向量,假如卷积滤波器的 size(3,4,5) 这三种,每种 size 包含 100100个卷积核,那么当然就会产生 31003*100 幅特征图,然后将max-over-time-pooling 操作应用到每个特征图上,于是文本的特征向量即 3100=3003*100=300 维。

TextCNN 这样做的意义本质上与词袋模型的经典文本分类模型没本质区别,只不过 one-hotword embedding 表示的转变避免了词袋模型遭遇的数据稀疏问题。TextCNN 本质上收益于词向量的引入带来的近义词有相近向量表示的 bonus,同时 TextCNN 可以较好的利用词向量中近义关系。

经典模型里难以学习的远距离信息在 TextCNN 中依然难以学习

等长卷积

假设输入的序列长度为 nn,卷积核大小为 mm,步长为 ss,输入序列两端各填补 pp 个零,那么该卷积层的输出序列为 (nm+2p)s+1\frac{(n-m+2p)}{s}+1

  1. 窄卷积:步长 s=1s=1 ,两端不补零,即 p=0p=0,卷积后输出长度为 nm+1n-m+1
  2. 宽卷积:步长 s=1s=1,两端补零 p=m1p=m-1,卷积后输出长度 n+m1n+m-1
  3. 等长卷积: 步长 s=1s=1,两端补零 p=(m1)/2p=(m-1)/2,卷积后输出长度为 nn

将输入输出序列的第 nnembedding 称为第 nn 个词位,那么这时 size=n 的卷积核产生的等长卷积的意义就是将输入序列的每个词位及其左右 n12\frac{n-1}{2} 个词的上下文信息压缩为该词位的 embedding,产生了每个词位的被上下文信息修饰过的更高level、更加准确的语义。想要克服 TextCNN 的缺点,捕获长距离模式,显然就要用到深层 CNN

直接等长卷积堆等长卷积会让每个词位包含进去越来越多,越来越长的上下文信息,这种方式会让网络层数变得非常非常非常深,但是这种方式太笨重。不过,既然等长卷积堆等长卷积会让每个词位的embedding 描述语义描述的更加丰富准确,可以适当的堆两层来提高词位 embedding的表示的丰富性。

image.png

固定 feature map 的数量

在表示好每个词位的语义后,很多邻接词或者邻接 ngram 的词义是可以合并,例如 “小明 人 不要 太好” 中的 “不要” 和 “太好” 虽然语义本来离得很远,但是作为邻接词“不要太好”出现时其语义基本等价为“很好”,完全可以把“不要”和“太好”的语义进行合并。同时,合并的过程完全可以在原始的 embedding space 中进行的,原文中直接把“不要太好”合并为“很好”是很可以的,完全没有必要动整个语义空间。

实际上,相比图像中这种从“点、线、弧”这种 low-level 特征到“眼睛、鼻子、嘴”这种 high-level 特征的明显层次性的特征区分,文本中的特征进阶明显要扁平的多,即从单词(1gram)到短语再到 3gram4gram 的升级,其实很大程度上均满足“语义取代”的特性。而图像中就很难发生这种“语义取代”现象。因此,DPCNNResNet 很大一个不同就是,DPCNN 中固定死了 feature map 的数量,也就是固定住了 embedding space 的维度(为了方便理解,以下简称语义空间),使得网络有可能让整个邻接词(邻接 ngram)的合并操作在原始空间或者与原始空间相似的空间中进行(当然,网络在实际中会不会这样做是不一定的,只是提供了这么一种条件)。也就是说,整个网络虽然形状上来看是深层的,但是从语义空间上来看完全可以是扁平的。而ResNet 则是不断的改变语义空间,使得图像的语义随着网络层的加深也不断的跳向更高level的语义空间。

池化

每经过一个 size=3,stride=2size=3,stride=2 的池化层(简称 1/21/2 池化层),序列的长度就被压缩成了原来的一半。这样同样是 size=3size=3 的卷积核,每经过一个 1/21/2 池化层后,其能感知到的文本片段就比之前长了一倍。例如之前是只能感知 33 个词位长度的信息,经过 1/21/2 池化层后就能感知 66 个词位长度的信息,这时把 1/21/2 池化层和 size=3 的卷积层组合起来如图:

image.png

残差连接

在初始化深度 CNN 时,往往各层权重都是初始化为一个很小的值,这就导致最开始的网络中,后续几乎每层的输入都是接近 00,这时网络的输出自然是没意义的,而这些小权重同时也阻碍了梯度的传播,使得网络的初始训练阶段往往要迭代好久才能启动。同时,就算网络启动完成,由于深度网络中仿射矩阵近似连乘,训练过程中网络也非常容易发生梯度爆炸或弥散问题(虽然由于非共享权重,深度CNN 网络比 RNN 网络要好点)。 针对深度 CNN 网络的梯度弥散问题 ResNet 中提出的shortcut-connection\skip-connection\residual-connection(残差连接)就是一种非常简单、合理、有效的解决方案。

image.png

主要代码

class DPCNN(nn.Module):
    def __init__(self, config, word_embedding, freeze):
        """
        config.n_filters: 卷积核个数(对应2维卷积的通道数)
        config.class_num: 类别数
        word_embedding: 训练好的词向量(一个类,包含idx2str、str2idx、vectors)
        freeze: 是否冻结词向量
        """
        super(DPCNN, self).__init__()
        word_embedding = word_embedding
        self.embedding_size = len(word_embedding.vectors[0])
        self.embedding = nn.Embedding.from_pretrained(torch.from_numpy(np.asarray(word_embedding.vectors)),
                                                      freeze=freeze)

        self.conv_region = nn.Conv2d(1, config.n_filters, (3, self.embedding_size), stride=1)
        self.conv = nn.Conv2d(config.n_filters, config.n_filters, (3, 1), stride=1)

        self.max_pool = nn.MaxPool2d(kernel_size=(3, 1), stride=2)
        self.padding1 = nn.ZeroPad2d((0, 0, 1, 1))  # top bottom
        self.padding2 = nn.ZeroPad2d((0, 0, 0, 1))  # bottom

        self.relu = nn.ReLU()
        self.fc = nn.Linear(config.n_filters, config.class_num)

    def forward(self, input_ids):
        # [batch size, seq len, emb dim]
        x = self.embedding(input_ids) 
        # [batch size, 1, seq len, emb dim]
        x = x.unsqueeze(1).to(torch.float32) 
        # [batch size, n_filters, seq len-3+1, 1]
        x = self.conv_region(x)  
        # [batch size, n_filters, seq len, 1]
        x = self.padding1(x)  
        x = self.relu(x)
        # [batch size, n_filters, seq len-3+1, 1]
        x = self.conv(x) 
        # [batch size, n_filters, seq len, 1]
        x = self.padding1(x)  
        x = self.relu(x)
        # [batch size, n_filters, seq len-3+1, 1]
        x = self.conv(x)
        # [batch size, n_filters, 1, 1]
        while x.size()[2] >= 2:
            x = self._block(x)  
        # [batch size, n_filters]
        x_embedding = x.squeeze()  
        # [batch_size, 1]
        x = self.fc(x_embedding)  
        return x

    def _block(self, x):
        x = self.padding2(x)
        px = self.max_pool(x)

        x = self.padding1(px)
        x = F.relu(x)
        x = self.conv(x)

        x = self.padding1(x)
        x = F.relu(x)
        x = self.conv(x)

        # Short Cut
        x = x + px
        return x

TextRNN

RNN

循环神经网络(Recurrent Neural Network,RNN)是一类具有短期记忆能 力的神经网络。在循环神经网络中,神经元不但可以接受其它神经元的信息,也 可以接受自身的信息,形成具有环路的网络结构。

image.png

Ht=Tanh([Ht1,Xt]W+b)H_t = Tanh([H_{t-1}, X_t]W+b)

长程依赖问题

虽然简单循环网络理论上可以建立长时间间隔的状态之间的依赖关系,但 是由于梯度爆炸或消失问题,实际上只能学习到短期的依赖关系。这样,如果时刻 tt 的输出 yty_t 依赖于时刻 kk 的输入 xkx_k,当间隔 tkt−k 比较大时,简单神经网络很难建模这种长距离的依赖关系,称为长程依赖问题(Long-Term Dependencies Problem)。

RNN

LSTM

为了改善循环神经网络的长程依赖问题,一种非常好的解决方案是引入门控机制来控制信息的累积速度,包括有选择地加入新的信息,并有选择地遗忘之前累积的信息。

长短期记忆(Long Short-Term Memory,LSTM)网络[Gers 等人; Hochreiter 等人,2000; 1997] 是循环神经网络的一个变体,可以有效地解决简单循环神经网络的梯度爆炸或消失问题。

LSTM 的关键在于细胞的状态 CtC_t 和穿过细胞的线,细胞状态类似于传送带,直接在整个链上运行,只有一些少量的线性交互,信息在上面流动保持不变会变得容易。

image.png

LSTM 网络引入门控机制(Gating Mechanism)来控制信息传递的路径。LSTM网络中的“门”是一种“软”门,取值在 (0,1)(0, 1) 之间,表示以一定的比例运行信息通过。

遗忘门

遗忘门 ftf_t 控制上一个时刻的内部状态 ct1c_{t−1} 需要遗忘多少信息。

image.png

输入门

输入门决定让多少新的信息加入到当前的 CtC_t 中来。实现这个需要两个步骤:

  1. 首先计算出输入门 iti-t 和候选记忆细胞状态。

image.png

  1. 结合遗忘门 ftf_t 和输入门 iti_t 来更新记忆单元 CtC_t

image.png

输出门

最终,我们需要确定输出什么值,这个输出将会基于我们的细胞的状态,但是也是一个过滤后的版本。首先,我们通过一个 sigmoid 层来确定细胞状态的哪些部分将输出出去。接着,我们把细胞状态通过 tanh 进行处理(得到一个 [1,1][-1,1] 之间的值)并将它和sigmoid 门的输出相乘。

image.png

GRU

LSTM 中引入了三个门函数:输入门、遗忘门和输出门来控制信息的传递,由于输入门和遗忘门是互补关系,具有一定的冗余性,GRU 网络 直接使用一个门来控制输入和遗忘之间的平衡,在 GRU 模型中只有两个门:分别是更新门和重置门。

图中的 ztz_trtr_t 分别表示更新门和重置门。更新门用于控制前一时刻的状态信息被带入到当前状态中的程度,更新门的值越大说明前一时刻的状态信息带入越多。重置门控制前一状态有多少信息被写入到当前的候选状态 h~t\tilde{h}_{t} 上,重置门越小,前一状态的信息被写入的越少。

image.png

LSTMGRU 都是通过各种门函数来将重要特征保留下来,这样就保证了在 long-term 传播的时候也不会丢失。此外 GRU 相对于 LSTM 少了一个门函数,因此在参数的数量上也是要少于LSTM 的,所以整体上 GRU 的训练速度要快于 LSTM 的。

Attention

image.png

Attention 计算:

M=tanh(H)M=tanh(H)

α=softmax(uTM)\alpha = softmax(u^TM)

rep=HαTrep = H \alpha_{T}

其中 HH 为每个时刻得到的隐藏状态, uucontext vector,随机初始化并随着训练更新,最后得到句子表示 reprep,再进行分类。

主要代码

class RNNAttention(nn.Module):
    def __init__(self, config, word_embedding, freeze, batch_first=True):
        """
        config.class_num: 类别数
        config.hidden_dim: rnn隐藏层的维度
        config.n_layers: rnn层数
        config.rnn_type: rnn类型,包括['lstm', 'gru', 'rnn']
        config.bidirectional: 是否双向
        config.dropout: dropout率
        word_embedding: 训练好的词向量(一个类,包含idx2str、str2idx、vectors)
        freeze: 是否冻结词向量
        batch_first: 第一个维度是否是批量大小
        """
        super(RNNAttention, self).__init__()
        word_embedding = word_embedding
        self.embedding_size = len(word_embedding.vectors[0])
        self.embedding = nn.Embedding.from_pretrained(torch.from_numpy(np.asarray(word_embedding.vectors)),
                                                      freeze=freeze)

        if config.rnn_type == 'lstm':
            self.rnn = nn.LSTM(self.embedding_size,
                               config.hidden_dim,
                               num_layers=config.n_layers,
                               bidirectional=config.bidirectional,
                               batch_first=batch_first,
                               dropout=config.dropout)
        elif config.rnn_type == 'gru':
            self.rnn = nn.GRU(self.embedding_size,
                              hidden_size=config.hidden_dim,
                              num_layers=config.n_layers,
                              bidirectional=config.bidirectional,
                              batch_first=batch_first,
                              dropout=config.dropout)
        else:
            self.rnn = nn.RNN(self.embedding_size,
                              hidden_size=config.hidden_dim,
                              num_layers=config.n_layers,
                              bidirectional=config.bidirectional,
                              batch_first=batch_first,
                              dropout=config.dropout)
        # query向量   
        self.u = nn.Parameter(torch.randn(config.hidden_dim * 2), requires_grad=True)
        self.tanh = nn.Tanh()
        self.fc = nn.Linear(config.hidden_dim * 2, config.class_num)

        self.dropout = nn.Dropout(config.dropout)
        self.batch_first = batch_first

    def forward(self, text, text_lengths):
        # 按照句子长度从大到小排序
        text, sorted_seq_lengths, desorted_indices = self.prepare_pack_padded_sequence(text, text_lengths)
        # text = [batch size, sent len]
        embedded = self.dropout(self.embedding(text)).float()
        # embedded = [batch size, sent len, emb dim]

        # pack sequence
        packed_embedded = nn.utils.rnn.pack_padded_sequence(embedded, sorted_seq_lengths, batch_first=self.batch_first)
        self.rnn.flatten_parameters()
        if config.rnn_type in ['rnn', 'gru']:
            packed_output, hidden = self.rnn(packed_embedded)
        else:
            # output (batch, seq_len, num_directions * hidden_dim)
            # hidden (batch, num_layers * num_directions, hidden_dim)
            packed_output, (hidden, cell) = self.rnn(packed_embedded)

        # unpack sequence
        output, output_lengths = nn.utils.rnn.pad_packed_sequence(packed_output, batch_first=self.batch_first)
        # 把句子序列再调整成输入时的顺序
        output = output[desorted_indices]
        # output = [batch_size, seq_len, hidden_dim * num_directionns ]

        alpha = F.softmax(torch.matmul(self.tanh(output), self.u), dim=1).unsqueeze(-1) 
        # alpha = [batch_size, seq_len, 1]
        output_attention = output * alpha # [batch_size, seq_len, hidden_dim * num_directionns ]


        output_attention = torch.sum(output_attention, dim=1)  # [batch_size, hidden_dim]

        fc_input = self.dropout(output_attention)
        out = self.fc(fc_input)
        return out, fc_input

    def prepare_pack_padded_sequence(self, inputs_words, seq_lengths, descending=True):
        """
        for rnn model :按照句子长度从大到小排序
        
        """
        sorted_seq_lengths, indices = torch.sort(seq_lengths, descending=descending)
        _, desorted_indices = torch.sort(indices, descending=False)
        sorted_inputs_words = inputs_words[indices]
        return sorted_inputs_words, sorted_seq_lengths, desorted_indices

TextRCNN

论文:dl.acm.org/doi/10.5555…
代码:github.com/649453932/C…

RNNCNN 作为文本分类问题的主要模型架构,都存在各自的优点及局限性。RNN 擅长处理序列结构,能够考虑到句子的上下文信息,但 RNN 属于 biased model,一个句子中越往后的词重要性越高,这有可能影响最后的分类结果,因为对句子分类影响最大的词可能处在句子任何位置。CNN 属于无偏模型,能够通过最大池化获得最重要的特征,但是 CNN 的滑动窗口大小不容易确定,选的过小容易造成重要信息丢失,选的过大会造成巨大参数空间。为了解决二者的局限性,这篇文章提出了一种新的网络架构,用双向循环结构获取上下文信息,这比传统的基于窗口的神经网络更能减少噪声,而且在学习文本表达时可以大范围的保留词序。其次使用最大池化层获取文本的重要部分,自动判断哪个特征在文本分类过程中起更重要的作用。

image.png

单词表示学习

作者提出将单词的左上下文、右上下文、单词本身结合起来作为单词表示。作者使用了双向 RNN 来分别提取句子的上下文信息。公式如下:

cl(wi)=f(W(l)cl(wi1)+W(sl)e(wi1))cr(wi)=f(W(r)cr(wi+1)+W(sr)e(wi+1))\begin{array}{l} c_{l}\left(w_{i}\right)=f\left(W^{(l)} c_{l}\left(w_{i-1}\right)+W^{(s l)} e\left(w_{i-1}\right)\right) \\ c_{r}\left(w_{i}\right)=f\left(W^{(r)} c_{r}\left(w_{i+1}\right)+W^{(s r)} e\left(w_{i+1}\right)\right) \end{array}

其中,cl(wi)c_l(w_i) 代表单词 wiw_i 的左上下文,cl(wi)c_l(w_i) 由上一个单词的左上下文 cl(wi1)c_l(w_{i-1}) 和 上一个单词的词嵌入向量 e(wi1)e(w_{i-1}) 计算得到,如公式(1)所示,所有句子第一个单词的左侧上下文使用相同的共享参数 cl(w1)c_l(w_1)W(l),W(sl)W^{(l)},W^{(sl)} 用于将上一个单词的左上下文语义和上一个单词的语义结合到单词 wiw_i 的左上下文表示中。右上下文的处理与左上下文完全相同,同样所有句子最后一个单词的右侧上下文使用相同的共享参数 cr(wn)c_r(w_n)。 得到句子中每个单词的左上下文表示和右上下文表示后,就可以定义单词 wiw_i 的表示如下

xi=[cl(wi);e(wi);cr(wi)]\boldsymbol{x}_{i}=\left[\boldsymbol{c}_{l}\left(w_{i}\right) ; \boldsymbol{e}\left(w_{i}\right) ; \boldsymbol{c}_{r}\left(w_{i}\right)\right]

实际就是单词wiw_i,单词的词嵌入表示向量 e(wi)e(w_i) 以及单词的右上下文向量 ce(wi)c_e(w_i) 的拼接后的结果。得到 wiw_i 的表示xix_i后,就可以输入激活函数得到wiw_i的潜在语义向量 yi(2)y_i^{(2)}

yi(2)=tanh(W(2)xi+b(2))\boldsymbol{y}_{i}^{(2)}=\tanh \left(W^{(2)} \boldsymbol{x}_{i}+\boldsymbol{b}^{(2)}\right)

文本表示学习

经过卷积层后,获得了所有词的表示,首先对其进行最大池化操作,最大池化可以帮助找到句子中最重要的潜在语义信息。

y(3)=maxi=1nyi(2)\boldsymbol{y}^{(3)}=\max _{i=1}^{n} \boldsymbol{y}_{i}^{(2)}

然后经过全连接层得到文本的表示,最后通过 softmax 层进行分类。

y(4)=W(4)y(3)+b(4)pi=exp(yi(4))k=1nexp(yk(4))\begin{aligned} &\boldsymbol{y}^{(4)}=W^{(4)} \boldsymbol{y}^{(3)}+\boldsymbol{b}^{(4)}\\ &p_{i}=\frac{\exp \left(\boldsymbol{y}_{i}^{(4)}\right)}{\sum_{k=1}^{n} \exp \left(\boldsymbol{y}_{k}^{(4)}\right)} \end{aligned}

主要代码

class RCNN(nn.Module):
    def __init__(self, config, word_embedding, freeze, batch_first=True):
        """
        config.class_num: 类别数
        config.hidden_dim: rnn隐藏层的维度
        config.n_layers: rnn层数
        config.rnn_type: rnn类型,包括['lstm', 'gru', 'rnn']
        config.bidirectional: 是否双向
        config.dropout: dropout率
        word_embedding: 训练好的词向量(一个类,包含idx2str、str2idx、vectors)
        freeze: 是否冻结词向量
        batch_first: 第一个维度是否是批量大小
        """
        super(RCNN, self).__init__()
        word_embedding = word_embedding
        self.embedding_size = len(word_embedding.vectors[0])
        self.embedding = nn.Embedding.from_pretrained(torch.from_numpy(np.asarray(word_embedding.vectors)),
                                                      freeze=freeze)

        if config.rnn_type == 'lstm':
            self.rnn = nn.LSTM(self.embedding_size,
                               config.hidden_dim,
                               num_layers=config.n_layers,
                               bidirectional=config.bidirectional,
                               batch_first=batch_first,
                               dropout=config.dropout)
        elif config.rnn_type == 'gru':
            self.rnn = nn.GRU(self.embedding_size,
                              hidden_size=config.hidden_dim,
                              num_layers=config.n_layers,
                              bidirectional=config.bidirectional,
                              batch_first=batch_first,
                              dropout=config.dropout)
        else:
            self.rnn = nn.RNN(self.embedding_size,
                              hidden_size=config.hidden_dim,
                              num_layers=config.n_layers,
                              bidirectional=config.bidirectional,
                              batch_first=batch_first,
                              dropout=config.dropout)
        
        # 1 x 1 卷积等价于全连接层,故此处使用全连接层代替
        self.fc_cat = nn.Linear(config.hidden_dim * 2 + self.embedding_size, self.embedding_size)
        self.fc = nn.Linear(self.embedding_size, config.class_num)

        self.dropout = nn.Dropout(config.dropout)
        self.batch_first = batch_first

    def forward(self, text, text_lengths):
        # 按照句子长度从大到小排序
        text, sorted_seq_lengths, desorted_indices = self.prepare_pack_padded_sequence(text, text_lengths)
        # text = [batch size, sent len]
        embedded = self.dropout(self.embedding(text)).float()
        # embedded = [batch size, sent len, emb dim]

        # pack sequence
        packed_embedded = nn.utils.rnn.pack_padded_sequence(embedded, sorted_seq_lengths, batch_first=self.batch_first)
        self.rnn.flatten_parameters()
        if config.rnn_type in ['rnn', 'gru']:
            packed_output, hidden = self.rnn(packed_embedded)
        else:
            # output (batch, seq_len, num_directions * hidden_dim)
            # hidden (batch, num_layers * num_directions, hidden_dim)
            packed_output, (hidden, cell) = self.rnn(packed_embedded)

        # unpack sequence
        output, output_lengths = nn.utils.rnn.pad_packed_sequence(packed_output, batch_first=self.batch_first)
        # 把句子序列再调整成输入时的顺序
        output = output[desorted_indices]
        # output = [batch_size, seq_len, hidden_dim * num_directionns ]

        batch_size, max_seq_len, hidden_dim = output.shape

        # 拼接左右上下文信息
        output = torch.tanh(self.fc_cat(torch.cat((output, embedded), dim=2)))
        # output = [batch_size, seq_len, embedding_size]

        output = torch.transpose(output, 1, 2)
        output = F.max_pool1d(output, int(max_seq_len)).squeeze().contiguous()

        return self.fc(output)

    def prepare_pack_padded_sequence(self, inputs_words, seq_lengths, descending=True):
        """
        for rnn model :按照句子长度从大到小排序
        
        """
        sorted_seq_lengths, indices = torch.sort(seq_lengths, descending=descending)
        _, desorted_indices = torch.sort(indices, descending=False)
        sorted_inputs_words = inputs_words[indices]
        return sorted_inputs_words, sorted_seq_lengths, desorted_indices

HAN

论文:www.aclweb.org/anthology/N…
代码:github.com/richliao/te…

上文都是句子级别的分类,虽然用到长文本、篇章级也是可以的,但速度精度都会下降,于是有研究者提出了层次注意力分类框架,即Hierarchical Attention

image.png

整个网络结构包括五个部分:

  1. 词序列编码器

  2. 基于词级的注意力层

  3. 句子编码器

  4. 基于句子级的注意力层

  5. 分类

整个网络结构由双向 GRU 网络和注意力机制组合而成。

词序编码器

给定一个句子中的单词 witw_{it},其中 ii 表示第 ii 个句子,tt 表示第 tt 个词。通过一个词嵌入矩阵 WeW_e 将单词转换成向量表示,具体如下所示:

xit=Wewitx_{it}=W_e w_{it}

将获取的词向量输入词编码器,即一个双向 GRU,将两个方向的 GRU 输出拼接在一起得到词级别的隐向量 hh

词级别的注意力

但是对于一句话中的单词,并不是每一个单词对分类任务都是有用的,比如在做文本的情绪分类时,可能我们就会比较关注 “很好”、“伤感” 这些词。为了能使循环神经网络也能自动将“注意力”放在这些词汇上,作者设计了基于单词的注意力层的具体流程如下:

uit=tanh(Wwhit+bw) u_{i t} =\tanh \left(W_{w} h_{i t}+b_{w}\right)

αit=exp(uituw)texp(uituw)\alpha_{i t} =\frac{\exp \left(u_{i t}^{\top} u_{w}\right)}{\sum_{t} \exp \left(u_{i t}^{\top} u_{w}\right)}

si=tαithits_{i} =\sum_{t} \alpha_{i t} h_{i t}

上面式子中,uitu_{it}hith_{it} 的隐层表示,aita_{it} 是经 softmax 函数处理后的归一化权重系数,uwu_w是一个随机初始化的向量,之后会作为模型的参数一起被训练,sis_i 就是我们得到的第 ii 个句子的向量表示。

句子编码器和句子级注意力

对于句子级别的向量,我们用相类似的方法,将其通过双向 GRU 和注意力层,最后将文档中所有句子的隐向量表示加权求和,得到整个文档的文档向量 vv,将该向量通过一个全连接分类器进行分类。

主要代码

class HierAttNet(nn.Module):
    def __init__(self, rnn_type, word_hidden_size, sent_hidden_size, num_classes, word_embedding, 
                       n_layers, bidirectional, batch_first, freeze, dropout):
        super(HierAttNet, self).__init__()
        self.word_embedding = word_embedding
        self.word_hidden_size = word_hidden_size
        self.sent_hidden_size = sent_hidden_size
        self.word_att_net = WordAttNet(rnn_type,word_embedding, word_hidden_size,n_layers,bidirectional,batch_first,dropout,freeze)
        self.sent_att_net = SentAttNet(rnn_type,sent_hidden_size, word_hidden_size,n_layers,bidirectional,batch_first,dropout, num_classes)


    def forward(self, batch_doc, text_lengths):
        output_list = []
        # ############################ 词级 #########################################
        for idx,doc in enumerate(batch_doc):
            # 把一篇文档拆成多个句子
            doc = doc[:text_lengths[idx]]
            doc_list = doc.cpu().numpy().tolist()
            sep_index = [i for i, num in enumerate(doc_list) if num == self.word_embedding.stoi['[SEP]']]
            sentence_list = []
            if sep_index:
                pre = 0
                for cur in sep_index:
                    sentence_list.append(doc_list[pre:cur])
                    pre = cur

                sentence_list.append(doc_list[cur:])

            else:
                sentence_list.append(doc_list)
            max_sentence_len = len(max(sentence_list,key=lambda x:len(x)))
            seq_lens = []
            input_token_ids = []
            for sent in sentence_list:
                cur_sent_len = len(sent)
                seq_lens.append(cur_sent_len)
                input_token_ids.append(sent+[self.word_embedding.stoi['PAD']]*(max_sentence_len-cur_sent_len))
            input_token_ids = torch.LongTensor(np.array(input_token_ids)).to(batch_doc.device)
            seq_lens = torch.LongTensor(np.array(seq_lens)).to(batch_doc.device)
            word_output, hidden = self.word_att_net(input_token_ids,seq_lens)
            # word_output = [bs,hidden_size]
            output_list.append(word_output)

        max_doc_sent_num = len(max(output_list,key=lambda x: len(x)))
        batch_sent_lens = []
        batch_sent_inputs = []
        
        # ############################ 句子级 #########################################
        for doc in output_list:
            cur_doc_sent_len = len(doc)
            batch_sent_lens.append(cur_doc_sent_len)
            expand_doc = torch.cat([doc,torch.zeros(size=((max_doc_sent_num-cur_doc_sent_len),len(doc[0]))).to(doc.device)],dim=0)
            batch_sent_inputs.append(expand_doc.unsqueeze(dim=0))

        batch_sent_inputs = torch.cat(batch_sent_inputs, 0)
        batch_sent_lens = torch.LongTensor(np.array(batch_sent_lens)).to(doc.device)
        output = self.sent_att_net(batch_sent_inputs,batch_sent_lens)
        return output

BERT

BERT(Bidirectional Encoder Representations from Transformers) 的发布是 NLP 领域发展的最新的里程碑之一,这个事件 NLP 新时代的开始。BERT 模型打破了基于语言处理的任务的几个记录。在 BERT 的论文发布后不久,这个团队还公开了模型的代码,并提供了模型的下载版本,这些模型已经在大规模数据集上进行了预训练。这是一个重大的发展,因为它使得任何一个构建构建机器学习模型来处理语言的人,都可以将这个强大的功能作为一个现成的组件来使用,从而节省了从零开始训练语言处理模型所需要的时间、精力、知识和资源。

更多详细内容见 【图解BERT】【图解BERT模型】

Task 1: Masked Language Model

由于 BERT 需要通过上下文信息,来预测中心词的信息,同时又不希望模型提前看见中心词的信息,因此提出了一种 Masked Language Model 的预训练方式,即随机从输入预料上 mask 掉一些单词,然后通过的上下文预测该单词,类似于一个完形填空任务。

在预训练任务中,15% 的 Word Piece 会被 mask,这 15% 的 Word Piece 中,80%的时候会直接替换为 [Mask] ,10% 的时候将其替换为其它任意单词,10% 的时候会保留原始 Token

  • 没有100% mask 的原因
    • 如果句子中的某个Token 100% 都会被 mask 掉,那么在fine-tuning 的时候模型就会有一些没有见过的单词
  • 加入 10% 随机 token 的原因
    • Transformer 要保持对每个输入 token 的分布式表征,否则模型就会记住这个 [mask] 是 某个特定的 token
    • 另外编码器不知道哪些词需要预测的,哪些词是错误的,因此被迫需要学习每一个 token 的表示向量
  • 另外,每个 batchsize 只有 15% 的单词被 mask 的原因,是因为性能开销的问题,双向编码器比单项编码器训练要更慢

Task 2: Next Sequence Prediction

仅仅一个 MLM 任务是不足以让 BERT 解决阅读理解等句子关系判断任务的,因此添加了额外的一个预训练任务,即 Next Sequence Prediction

具体任务即为一个句子关系判断任务,即判断句子B是否是句子A的下文,如果是的话输出 IsNext,否则输出 NotNext

训练数据的生成方式是从平行语料中随机抽取的连续两句话,其中 50% 保留抽取的两句话,它们符合 IsNext 关系,另外 50% 的第二句话是随机从预料中提取的,它们的关系是 NotNext 的。这个关系保存在 [CLS] 符号中

输入

  • Token Embeddings:即传统的词向量层,每个输入样本的首字符需要设置为 [CLS],可以用于之后的分类任务,若有两个不同的句子,需要用 [SEP] 分隔,且最后一个字符需要用 [SEP] 表示终止
  • Segment Embeddings:为 [0,1] 序列,用来在 NSP 任务中区别两个句子,便于做句子关系判断任务
  • Position Embeddings :与 Transformer 中的位置向量不同,BERT 中的位置向量是直接训练出来的

Fine-tunninng

对于不同的下游任务,我们仅需要对 BERT 不同位置的输出进行处理即可,或者直接将BERT不同位置的输出直接输入到下游模型当中。具体的如下所示:

  • 对于情感分析等单句分类任务,可以直接输入单个句子(不需要 [SEP] 分隔双句),将 [CLS] 的输出直接输入到分类器进行分类
  • 对于句子对任务(句子关系判断任务),需要用 [SEP] 分隔两个句子输入到模型中,然后同样仅须将 [CLS] 的输出送到分类器进行分类
  • 对于问答任务,将问题与答案拼接输入到 BERT 模型中,然后将答案位置的输出向量进行二分类并在句子方向上进行 softmax(只需预测开始和结束位置即可)
  • 对于命名实体识别任务,对每个位置的输出进行分类即可,如果将每个位置的输出作为特征输入到 CRF 将取得更好的效果。

缺点

  • BERT 的预训练任务 MLM 使得能够借助上下文对序列进行编码,但同时也使得其预训练过程与中的数据与微调的数据不匹配,难以适应生成式任务
  • 另外,BERT没有考虑预测 [MASK] 之间的相关性,是对语言模型联合概率的有偏估计
  • 由于最大输入长度的限制,适合句子和段落级别的任务,不适用于文档级别的任务(如长文本分类)
  • 适合处理自然语义理解类任务(NLU),而不适合自然语言生成类任务(NLG)

BERT 分类的优化可以尝试:

  • 多试试不同的预训练模型,比如 RoBERTWWMALBERT

  • 除了 [CLS] 外还可以用 avgmax 池化做句表示,甚至可以把不同层组合起来 在领域数据上增量预训练

  • 集成蒸馏,训多个大模型集成起来后蒸馏到一个上

  • 先用多任务训,再迁移到自己的任务

主要代码

class BertForSeqCLS(nn.Module):
    def __init__(self, config, train=True):
        super(BertForSeqCLS, self).__init__()
        self.bert = BertModel.from_pretrained(config.bert_path)
        # 对bert进行训练
        for param in self.bert.parameters():
            param.requires_grad = train
        
        self.dropout = nn.Dropout(config.dropout)
        self.fc = nn.Linear(768 * 3, config.class_num)

    def forward(self, input_ids, attention_mask, labels=None):
        # input_ids  输入的句子序列
        # seq_len  句子长度
        # attention_masks  对padding部分进行mask,和句子一个size,padding部分用0表示,如:[1, 1, 1, 1, 0, 0]
        # pooled_out [batch_size, 768]
        # sentence [batch size, sen len,  768]
        
        outputs = self.bert(input_ids, attention_mask=attention_mask, 
                                                output_hidden_states=True)
        cat_out = torch.cat((outputs.pooler_output, outputs.hidden_states[-1][:,0], 
                             outputs.hidden_states[-2][:, 0]), 1)
        logits = self.fc(self.dropout(cat_out))

        loss = None
        if labels is not None:
            loss = F.cross_entropy(logits.view(-1, config.class_num), labels.view(-1))

        return {"loss": loss, "logits": logits}

文本分类技巧

image.png

数据集构建

首先是标签体系的构建,拿到任务时自己先试标一两百条,看有多少是难确定(思考1s以上)的,如果占比太多,那这个任务的定义就有问题。可能是标签体系不清晰,或者是要分的类目太难了,这时候就要找项目owner去反馈而不是继续往下做。

其次是训练评估集的构建,可以构建两个评估集,一个是贴合真实数据分布的线上评估集,反映线上效果,另一个是用规则去重后均匀采样的随机评估集,反映模型的真实能力。训练集则尽可能和评估集分布一致,有时候我们会去相近的领域拿现成的有标注训练数据,这时就要注意调整分布,比如句子长度、标点、干净程度等,尽可能做到自己分不出这个句子是本任务的还是从别人那里借来的。

最后是数据清洗:

  • 去掉文本强pattern:比如做新闻主题分类,一些爬下来的数据中带有的XX报道、XX编辑高频字段就没有用,可以对语料的片段或词进行统计,把很高频的无用元素去掉。还有一些会明显影响模型的判断,比如之前判断句子是否为无意义的闲聊时,发现加个句号就会让样本由正转负,因为训练预料中的闲聊很少带句号(跟大家的打字习惯有关),于是去掉这个pattern就好了不少

  • 纠正标注错误:简单的说就是把训练集和评估集拼起来,用该数据集训练模型两三个epoch(防止过拟合),再去预测这个数据集,把模型判错的拿出来按 abs(label-prob) 排序,少的话就自己看,多的话就反馈给标注人员,把数据质量搞上去了提升好几个点都是可能的。

长文本

任务简单的话(比如新闻分类),直接用 fasttext 就可以达到不错的效果。

想要用 BERT 的话,最简单的方法是粗暴截断,比如只取句首+句尾、句首+tfidf筛几个词出来;或者每句都预测,最后对结果综合。

另外还有一些魔改的模型可以尝试,比如BERT+HANXLNetReformerLongformer

鲁棒性

在实际的应用中,鲁棒性是个很重要的问题,否则在面对 badcase 时会很尴尬,怎么明明那样就分对了,加一个字就错了呢?

这里可以直接使用一些粗暴的数据增强,加停用词加标点、删词、同义词替换等,如果效果下降就把增强后的训练数据洗一下。

当然也可以用对抗学习、对比学习这样的高阶技巧来提升,一般可以提1个点左右,但不一定能避免上面那种尴尬的情况。

参考链接

  1. arxiv.org/abs/1607.01…
  2. arxiv.org/abs/1408.58…
  3. ai.tencent.com/ailab/media…
  4. dl.acm.org/doi/10.5555…
  5. www.aclweb.org/anthology/N…
  6. zhuanlan.zhihu.com/p/266364526
  7. cloud.tencent.com/developer/a…
  8. www.cnblogs.com/sandwichnlp…
  9. github.com/jeffery0628…
  10. zhuanlan.zhihu.com/p/349086747
  11. zhuanlan.zhihu.com/p/35457093
  12. www.pianshen.com/article/431…
  13. www.zhihu.com/question/32…