实体识别模型与策略2020

模型

FLAT

FLAT部分Blog原文:https://mp.weixin.qq.com/s/6aU6ZDYPWPHc3KssuzArKw

论文:FLAT: Chinese NER Using Flat-Lattice Transformer

将Lattice图结构无损转换为扁平的Flat结构的方法,并将LSTM替换为了更先进的Transformer Encoder,更好地建模了序列的长期依赖关系

提出了一种针对Flat结构的相对位置编码机制,使得字符与词汇信息交互更直接,在基于词典的中文NER模型中取得了SOTA。

由于中文词汇的稀疏性和模糊性,基于字符的序列标注模型往往比基于词汇的序列标注模型表现更好,但在基于字符的模型中引入分词信息往往能够带来性能的提升,尤其是对于NER任务来说,词汇能够提供丰富的实体边界信息。

Lattice LSTM首次提出使用Lattice结构在NER任务中融入词汇信息,如图所示,一个句子的Lattice结构是一个有向无环图,每个节点是一个字或者一个词。

设计适应Lattice结构的模型

Lattice LSTM (ACL 2018): 将词汇信息引入中文NER的开篇之作,作者将词节点编码为向量,并在字节点以注意力的方式融合词向量。

Lexicon Rethink CNN(IJCAI 2019): 作者提出了含有rethink机制的CNN网络解决Lattice LSTM的词汇冲突问题。

RNN和CNN难以建模长距离的依赖关系,且在Lattice LSTM中的字符只能获取前向信息,没有和词汇进行足够充分的全局交互

FLAT

Git Repo

从Transformer的position representation得到启发,作者给每一个token/span(字、词)增加了两个位置编码,分别表示该span在sentence中开始(head)和结束(tail)的位置

扁平的结构允许我们使用Transformer Encoder,其中的self-attention机制允许任何字符和词汇进行直接的交互

Relative Position Encoding of Spans

span是字符和词汇的总称,span之间存在三种关系:交叉、包含、分离,然而作者没有直接编码这些位置关系,而是将其表示为一个稠密向量。作者用 和 表示span的头尾位置坐标,并从四个不同的角度来计算 和 的距离:

使用\(A^{*}_{i,j}\)代替 tranformer 的self attention 中的 \(A_{i,j}\):

通过FLAT模型后,取出token的编码表示,将其送入CRF层进行解码得到预测的标签序列。

论文中给出的结果显示,FLAT相较于一众NER模型,取得了SOTA的效果。同时,使用较大规模数据时,效果更好。在对比实验中发现,字符与包含它的词汇之间的充分交互是很重要的。

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
91
class MultiHeadAttention(nn.Module):
def __init__(self, hidden_size, num_heads, scaled=True, attn_dropout=None):
super(MultiHeadAttentionRel, self).__init__()

self.hidden_size = hidden_size

self.num_heads = num_heads
self.per_head_size = self.hidden_size // self.num_heads
self.scaled = scaled
assert (self.per_head_size * self.num_heads == self.hidden_size)

# 正常 attention 的 q,k,v 变换矩阵
self.w_k = nn.Linear(self.hidden_size, self.hidden_size)
self.w_q = nn.Linear(self.hidden_size, self.hidden_size)
self.w_v = nn.Linear(self.hidden_size, self.hidden_size)

# 计算 Rij 的权重
self.w_r = nn.Linear(self.hidden_size, self.hidden_size)

# 计算 A* 的权重
self.u = nn.Parameter(torch.randn(self.num_heads, self.per_head_size), requires_grad=True)
self.v = nn.Parameter(torch.randn(self.num_heads, self.per_head_size), requires_grad=True)

self.dropout = nn.Dropout(attn_dropout)

def forward(self, key, query, value, pos, flat_mask):
"pos 为 自定义的 postion embedding,对应公式的 Rij"
batch = key.size(0)

# 输入线性变换
key = self.w_k(key)
query = self.w_q(query)
value = self.w_v(value)
rel_pos_embedding = self.w_r(pos)

####### 计算 A* 矩阵的方法 和 论文不是完全一致
# batch, seq_len, n_head, d_head
key = torch.reshape(key, [batch, -1, self.num_heads, self.per_head_size])
query = torch.reshape(query, [batch, -1, self.num_heads, self.per_head_size])
value = torch.reshape(value, [batch, -1, self.num_heads, self.per_head_size])
# batch, seq_len, seq_len, n_head, d_head
rel_pos_embedding = torch.reshape(rel_pos_embedding,
list(rel_pos_embedding.size()[:3]) + [self.num_heads, self.per_head_size])

# batch, n_head, seq_len, d_head
key = key.transpose(1, 2)
query = query.transpose(1, 2)
value = value.transpose(1, 2)

# batch, n_head, d_head, seq_len
key = key.transpose(-1, -2)

# 1, num_heads, 1, d_head
u_for_c = self.u.unsqueeze(0).unsqueeze(-2)

# batch, n_head, seq_len, d_head
query_and_u_for_c = query + u_for_c

# batch, n_head, seq_len, seq_len
A_C = torch.matmul(query_and_u_for_c, key)

# batch, n_head, seq_len, d_head, seq_len
rel_pos_embedding_for_b = rel_pos_embedding.permute(0, 3, 1, 4, 2)
# batch, n_head, seq_len, seq_len, 1, d_head
query_for_b = query.view([batch, self.num_heads, query.size(2), 1, self.per_head_size])
# batch, n_head, seq_len, seq_len, 1, d_head
query_for_b_and_v_for_d = query_for_b + self.v.view(1, self.num_heads, 1, 1, self.per_head_size)

# batch, n_head, seq_len, seq_len
B_D = torch.matmul(query_for_b_and_v_for_d, rel_pos_embedding_for_b).squeeze(-2)

# batch, n_head, seq_len, seq_len
attn_score_raw = A_C + B_D

# 计算 score
if self.scaled:
attn_score_raw = attn_score_raw / math.sqrt(self.per_head_size)

mask = 1 - flat_mask.long().unsqueeze(1).unsqueeze(1)
attn_score_raw_masked = attn_score_raw.masked_fill(mask.bool(), -1e15)

# batch, n_head, seq_len, seq_len
attn_score = F.softmax(attn_score_raw_masked, dim=-1)
attn_score = self.dropout(attn_score)

# batch, n_head, seq_len, d_head
value_weighted_sum = torch.matmul(attn_score, value)
# batch, seq_len, n_head, d_head -> batch, seq_len, hidden_size
result = value_weighted_sum.transpose(1, 2).contiguous().reshape(batch, -1, self.hidden_size)

return result

BERT

教程博客很多,比如 http://jalammar.github.io/illustrated-bert/

CRF

参考 note1 note2

MRC

论文:A Unified MRC Framework for Named Entity Recognition

Git Repo

转换为阅读理解(MRC)任务,来解决NER问题。似乎有很多搞研究的,都在尝试将NLP问题转换到MRC框架下,解决问题。

目的,解决NER中的实体重叠、嵌套关系问题。这是序列建模方式,比较难处理的问题。

数据,处理为三元组形式:(问题,答案,上下文)

其中,问题:一段对 实体类型 的描述文字,多种实体,就有多个问题;答案:为 实体的起始 index;上下文就是待识别的整个文本。

模型,使用BERT:

每个token预测输出有两个,是否为实体开始字,是否为实体结束字。

输出为 2 维,是和不是的预测概率。分别对每个位置判断,是否为开始字或者结束字。

但是这个两个集合,在有监督数据条件下,即训练时,并没有必要,只在预测推断时使用(推断需要通过下式计算所有组合的概率 P)。因为下式:

直接根据标注数据的 i, j 对标注部分计算 P。而不用对所有 i, j 组合算一次 P。

损失,多个预测损失之和:

权重为超参数。

Simple-Lexicon

论文:Simple-Lexicon:Simplify the Usage of Lexicon in Chinese NER

Git Repo

在Embedding信息的输入上进行改进,尝试了多种方式。

  1. Softword:使用分词工具,标记词的 BMESO,结合字向量和标记向量输入。存在误差传播问题,无法引入一整个词汇对应word embedding

  2. ExtendSoftword:组合所有字的所有BME,得到可能的词,但是无法复原原始的词汇信息是怎样

  3. Soft-lexicon:对当前字符,依次获取BMES对应所有词汇集合。

    根据词频加权词向量,与字向量求和。

该模型比Lattice LSTM, WC-LSTM等,在输入embedding上进行改进的模型,效果更好,更容易使用和迁移。

策略

Positive-unlabeled learning -- PU Learning

在只有正类和无标记数据的情况下,训练二分类器

Method 1 Directly

  1. 将正样本和部分筛选出的未标记样本分别看作是positive samples和negative samples
  2. 训练一个分类器,输出样本属于正、负类的概率
  3. 使用训练好的分类器。分类未标注数据,若正类的概率 大于 负类的概率,则该未标注样本的更可能为正类

Method 2 PU bagging

  1. 将所有正样本和未标记样本进行随机组合 bootstrap 来创建训练集;
  2. 将正样本和未标记样本视为positive和negative,训练一个分类器;
  3. 将分类器应用于不在训练集中的未标记样本 OOB(“out of bag”),并记录其分数;
  4. 重复上述三个步骤,最后每个未标记样本的分数为每一轮 OOB分数 的平均值。

Method 3

人工标注一部分确认为负类的数据,训练分类器识别这些 确认为 负类的数据。

示例 示例

论文:Distantly Supervised Named Entity Recognition using Positive-Unlabeled Learning,将PU Learning应用在NER任务上 Git Repo

  1. 首先有 未标记数据 Du,实体字典 Dict;

  2. 使用最大匹配方法,标记一部分 Du,是NE则为正类,不是NE则为负类;

  3. 对每一种NE类型(比如,Loc,Nane)训练一个PU 分类器(自定义的神经网络模型);

  4. 使用多个PU 分类器,对剩余的 Du,进行预测,每一个词,取预测概率最大的那一类标记;

  5. 若某些 词 多次被预测为 实体,且每次出现都被预测为同一类实体,那么,将这个词,加入Dict;

  6. 重复以上步骤,直到Dict不再改变。

FGM

引用Blog原文

对抗可以作为一种防御机制,并且经过简单的修改,便能用在NLP任务上,提高模型的泛化能力。对抗训练可以写成一个插件的形式,用几行代码就可以在训练中自由地调用。

在原始输入样本 上加一个扰动 ,得到对抗样本后,用其进行训练。将输入样本向着损失上升的方向再进一步,得到的对抗样本就能造成更大的损失,提高模型的错误率。问题可以被抽象成这么一个模型:

其中, 为gold label, 为模型参数。Goodfellow认为,神经网络由于其线性的特点,很容易受到线性扰动的攻击。于是,他提出了 Fast Gradient Sign Method (FGSM) :

其中, 为符号函数, 为损失函数。Goodfellow发现,令 ,用这个扰动能给一个单层分类器造成99.9%的错误率。

Goodfellow还总结了对抗训练的两个作用:

  1. 提高模型应对恶意对抗样本时的鲁棒性;
  2. 作为一种regularization,减少overfitting,提高泛化能力。

从优化的视角,问题重新定义成了一个找鞍点的问题,Min-Max:内部损失函数的最大化,外部经验风险的最小化:

  1. 内部max是为了找到worst-case的扰动,也就是攻击,其中, 为损失函数, 为扰动的范围空间。
  2. 外部min是为了基于该攻击方式,找到最鲁棒的模型参数,也就是防御,其中 是输入样本的分布。

CV任务的输入是连续的RGB的值,而NLP问题中,输入是离散的单词序列,一般以one-hot vector的形式呈现,如果直接在raw text上进行扰动,那么扰动的大小和方向可能都没什么意义。Goodfellow在17年的ICLR中提出了可以在连续的embedding上做扰动。在CV任务,根据经验性的结论,对抗训练往往会使得模型在非对抗样本上的表现变差,然而神奇的是,在NLP任务中,模型的泛化能力反而变强了。

因此,在NLP任务中,对抗训练的角色不再是为了防御基于梯度的恶意攻击,反而更多的是作为一种regularization,提高模型的泛化能力

对抗训练,FSGM的修改版本,取消了符号函数,对梯度计算进行scale,而不是只使用 +1 或者 -1 代替。

  1. 原网络进行一次,前向反向传播,得到梯度g

  2. 计算embedding矩阵的修正梯度 r:

    \(r=\frac{\epsilon g}{\|g\|_{2}}\)

  3. 输入 embedding + r ,计算对抗梯度 ga

  4. 将 ga 累加到 g 中,得到 gf

  5. 恢复原网络的embedding数值,使用 gf 对参数进行更新

Projected Gradient Descent(PGD)“小步走,多走几步”,如果走出了扰动半径为 的空间,就映射回“球面”上,以保证扰动不要过大。

其中 为扰动的约束空间, 为小步的步长。

PGD模型能够得到一个非常低且集中的loss分布

另外在半监督条件下,也可以使用对抗训练方法Virtual Adversarial Training进行半监督训练。

示例代码

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
import torch


grad_backup = {}


def save_grad(tensorName):
def backward_hook(grad: torch.Tensor):
grad_backup[tensorName] = grad

return backward_hook


class PGD:
def __init__(self, model):
self.model = model
self.emb_backup = {}

def attack(self,
epsilon=1.,
alpha=0.3,
emb_name='emb.',
is_first_attack=False):
for name, param in self.model.named_parameters():
if param.requires_grad and emb_name in name:
if is_first_attack:
self.emb_backup[name] = param.data.clone()
norm = torch.norm(param.grad)
if norm != 0 and not torch.isnan(norm):
r_at = alpha * param.grad / norm
param.data.add_(r_at)
param.data = self.project(name, param.data, epsilon)

def restore(self, emb_name='emb.'):
# emb_name这个参数要换成你模型中embedding的参数名
for name, param in self.model.named_parameters():
if param.requires_grad and emb_name in name:
assert name in self.emb_backup
param.data = self.emb_backup[name]
self.emb_backup = {}

def project(self, param_name, param_data, epsilon):
r = param_data - self.emb_backup[param_name]
if torch.norm(r) > epsilon:
r = epsilon * r / torch.norm(r)
return self.emb_backup[param_name] + r

def backup_grad(self):
# 此处也可以直接用一个成员变量储存 grad,而不用 register_hook 存储在全局变量中
for name, param in self.model.named_parameters():
if param.requires_grad:
param.register_hook(save_grad(name))

def restore_grad(self):
for name, param in self.model.named_parameters():
if param.requires_grad:
param.grad = grad_backup[name]



if __name__ == '__main__':
# 示例过程
pgd = PGD(model)
K = 3 # 小步走的步数
for batch_input, batch_label in data:
# 正常训练
loss = model(batch_input, batch_label)
loss.backward() # 反向传播,得到正常的grad
pgd.backup_grad()

# 对抗训练
for t in range(K):
pgd.attack(is_first_attack=(t==0)) # 在embedding上添加对抗扰动, first attack时备份param.data
if t != K-1:
model.zero_grad()
else:
pgd.restore_grad()
loss_adv = model(batch_input, batch_label)
loss_adv.backward() # 反向传播,并在正常的grad基础上,累加对抗训练的梯度
pgd.restore() # 恢复embedding参数

# 梯度下降,更新参数
optimizer.step()
model.zero_grad()
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
import torch


class FGM:
def __init__(self, model):
self.model = model
self.backup = {}

def attack(self, epsilon=1, emb_name='emb.'):
for name, param in self.model.named_parameters():
if param.requires_grad and emb_name in name:
self.backup[name] = param.data.clone()
norm = torch.norm(param.grad)
if norm != 0 and not torch.isnan(norm):
r_adv = epsilon * param.grad / norm
param.data.add_(r_adv)

def restore(self, emb_name='emb.'):
for name, param in self.model.named_parameters():
if param.requires_grad and emb_name in name:
assert name in self.backup
param.data = self.backup[name]
self.backup = {}


if __name__ == "__main__":
# 示例过程
fgm = FGM(model)
for batch_input, batch_label in data:
# 正常训练
loss = model(batch_input, batch_label)
loss.backward() # 反向传播,得到正常的grad
# 对抗训练
fgm.attack() # 在embedding上添加对抗扰动
loss_adv = model(batch_input, batch_label)
loss_adv.backward() # 反向传播,并在正常的grad基础上,累加对抗训练的梯度
fgm.restore() # 恢复embedding参数
# 梯度下降,更新参数
optimizer.step()
model.zero_grad()

SWA

Stochastic Weight Averaging,方法的提出者认为,训练期间得到的局部最小值 倾向于 在损失值较低的区域的边界,而不是集中在损失更低的区域中心部分。所以,Stochastic Weight Averaging可以通过对边界的平均,得到更好性能和更好泛化性能的模型。Git Repo

  1. 保存两套权重w, wswa;

  2. 使用循环学习率,训练w;

  3. 达到指定轮次,更新ws,\(n_{models}\)指更新\(w_{swa}\)时,中间间隔的轮次:

    \(w_{swa} = \frac{w_{swa}n_{models}+w}{n_{models}+1}\)

  4. 循环以上步骤,最后使用wswa,作为最终模型

有可以直接使用的工具,比较方便。~from torchcontrib.optim import SWA~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
optimizer = torch.optim.Adam(params_lr)
# Stochastic Weight Averaging
optimizer = SWA(optimizer)


if ...:
optimizer.update_swa()

...
# 训练结束时使用收集到的swa moving average
optimizer.swap_swa_sgd()
# optimizer.bn_update(
# train_dataloader,
# model) # 更新BatchNorm的 running mean

# save

参考链接:

2020CCF-NER

Flat-Lattice-Transformer


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!