DPCNN 😊

论文:https://ai.tencent.com/ailab/media/publications/ACL3-Brady.pdf
代码:https://github.com/649453932/Chinese-Text-Classification-Pytorch

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

image.png

Region embedding

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

卷积和全连接的权衡

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

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

但是文本中的远距离信息在 TextCNN 中依然难以学习。

等长卷积

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

  • 窄卷积🍊:步长 $s=1$ ,两端不补零,即 $p=0$,卷积后输出长度为 $n-m+1$。

  • 宽卷积🍊:步长 $s=1$,两端补零 $p=m-1$,卷积后输出长度 $n+m-1$。

  • 等长卷积🍊: 步长 $s=1$,两端补零 $p=(m-1)/2$,卷积后输出长度为 $n$。

我们将输入输出序列的第 $n$ 个 embedding 称为第 $n$ 个词位,那么这时 size=n 的卷积核产生的等长卷积的意义就是将输入序列的每个词位及其左右 $\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)的合并操作在原始空间或者与原始空间相似的空间中进行(当然,网络在实际中会不会这样做是不一定的,只是提供了这么一种条件)。也就是说,整个网络虽然形状上来看是深层的,但是从语义空间上来看完全可以是扁平的。

池化

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

image.png

残差连接

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

image.png

模型结构和代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class DPCNN(nn.Module):
def __init__(self, config):
"""
config.vocab_size:词表大小
config.embedding_size:词向量维度
config.num_labels: 类别数
config.n_filters: 卷积核个数(对应2维卷积的通道数)
config.filter_sizes: 卷积核的尺寸(3,4,5)
"""
super(DPCNN, self).__init__()
self.embedding_size = config.embedding_size
self.embedding = nn.Embedding(config.vocab_size, config.embedding_size)

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.num_labels)

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

TextRCNN 😊

论文:https://dl.acm.org/doi/10.5555/2886521.2886636
代码:https://github.com/649453932/Chinese-Text-Classification-Pytorch

RNNCNN 作为文本分类问题的主要模型架构,都存在各自的优点及局限性。

  • RNN 擅长处理序列结构,能够考虑到句子的上下文信息,但 RNN 属于 biased model,一个句子中越往后的词重要性越高,这有可能影响最后的分类结果,因为对句子分类影响最大的词可能处在句子任何位置。

  • CNN 属于无偏模型,能够通过最大池化获得最重要的特征,但是 CNN 的滑动窗口大小不容易确定,选的过小容易造成重要信息丢失,选的过大会造成巨大参数空间。

为了解决二者的局限性,这篇文章提出了一种新的网络架构,用双向循环结构获取上下文信息,这比传统的基于窗口的神经网络更能减少噪声,而且在学习文本表达时可以大范围的保留词序。其次使用最大池化层获取文本的重要部分,自动判断哪个特征在文本分类过程中起更重要的作用。

image.png

单词表示学习

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

其中,$c_l(w_i)$ 代表单词 $w_i$ 的左上下文,$c_l(w_i)$ 由上一个单词的左上下文 $c_l(w_{i-1})$ 和 上一个单词的词嵌入向量 $e(w_{i-1})$ 计算得到,所有句子第一个单词的左侧上下文使用相同的共享参数 $c_l(w_1)$。

$W^{(l)},W^{(sl)}$ 用于将上一个单词的左上下文语义和上一个单词的语义结合到单词 $w_i$ 的左上下文表示中。右上下文的处理与左上下文完全相同,同样所有句子最后一个单词的右侧上下文使用相同的共享参数 $c_r(w_n)$。 得到句子中每个单词的左上下文表示和右上下文表示后,就可以定义单词 $w_i$ 的表示如下

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

文本表示学习

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

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

模型结构和代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
class RCNN(nn.Module):
def __init__(self, config):
"""
config.vocab_size:词表大小
config.embedding_size:词向量维度
config.num_labels: 类别数
config.hidden_dim: rnn隐藏层的维度
config.n_layers: rnn层数
config.rnn_type: rnn类型,包括['lstm', 'gru', 'rnn']
config.bidirectional: 是否双向
config.dropout: dropout率
config.batch_first: 第一个维度是否是批量大小
"""
super(RCNN, self).__init__()
word_embedding = word_embedding
self.embedding_size = config.embedding_size
self.embedding = nn.Embedding(config.vocab_size, config.embedding_size)

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=config.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=config.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=config.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.num_labels)

self.dropout = nn.Dropout(config.dropout)
self.batch_first = config.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 😊

论文:https://www.aclweb.org/anthology/N16-1174.pdf
代码:https://github.com/richliao/textClassifier

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

image.png

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

  1. 词序列编码器

  2. 基于词级的注意力层

  3. 句子编码器

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

  5. 分类层

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

词序编码器

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

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

词级别的注意力

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

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

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

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

模型结构和代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
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-tuning

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

  • 对于情感分析等单句分类任务,可以直接输入单个句子(不需要 [SEP] 分隔双句),将 [CLS] 的输出直接输入到分类器进行分类。

  • 对于句子对任务(句子关系判断任务),需要用 [SEP] 分隔两个句子输入到模型中,然后同样仅须将 [CLS] 的输出送到分类器进行分类。

  • 对于问答任务,将问题与答案拼接输入到 BERT 模型中,然后将答案位置的输出向量进行二分类并在句子方向上进行 softmax(只需预测开始和结束位置即可)。

  • 对于命名实体识别任务,对每个位置的输出进行分类即可,如果将每个位置的输出作为特征输入到 CRF 将取得更好的效果。

缺点

  • BERT 的预训练任务 MLM 使得能够借助上下文对序列进行编码,但同时也使得其预训练过程与中的数据与微调的数据不匹配,难以适应生成式任务。

  • 另外,BERT 没有考虑预测 [MASK] 之间的相关性,是对语言模型联合概率的有偏估计。

  • 由于最大输入长度的限制,适合句子和段落级别的任务,不适用于文档级别的任务(如长文本分类)。

  • 适合处理自然语义理解类任务(NLU),而不适合自然语言生成类任务(NLG)。

BERT 分类的优化可以尝试 👼:

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

  • 除了 [CLS] 外还可以用 avgmax 池化做句表示,甚至可以把不同层组合起来。

  • 在领域数据上增量预训练。

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

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

模型结构和代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class BertForSeqCLS(nn.Module):
def __init__(self, config):
super(BertForSeqCLS, self).__init__()
self.bert = BertModel.from_pretrained(config)

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. https://arxiv.org/abs/1607.01759
  2. https://arxiv.org/abs/1408.5882
  3. https://ai.tencent.com/ailab/media/publications/ACL3-Brady.pdf
  4. https://dl.acm.org/doi/10.5555/2886521.2886636
  5. https://www.aclweb.org/anthology/N16-1174.pdf
  6. https://zhuanlan.zhihu.com/p/266364526
  7. https://cloud.tencent.com/developer/article/1389555
  8. https://www.cnblogs.com/sandwichnlp/p/11698996.html
  9. https://github.com/jeffery0628/text_classification
  10. https://zhuanlan.zhihu.com/p/349086747
  11. https://zhuanlan.zhihu.com/p/35457093
  12. https://www.pianshen.com/article/4319299677/
  13. https://www.zhihu.com/question/326770917/answer/698646465