精华内容
下载资源
问答
  • Unified Language Model 文本生成从未如此轻松前言UniLMHow to build UniLMGet 2D MASKSend 2D MASK to Bert使用UniLM实现新闻标题生成数据处理部分(略)模型...一篇19年的微软论文,老规矩先放论文链接:https://.


    前言

    一篇19年的微软论文,老规矩先放论文链接:https://arxiv.org/abs/1905.03197

    最近开始尝试做长文本的摘要生成任务,因此要拿出了几篇 Transformer 时代的文本生成相关的经典论文参考。当我看到UniLM这个Bert的变体模型(甚至连变体都算不上),口中只能叹出,“相见恨晚” 四个字。接着回顾了之前辛苦辛苦手敲的LSTM+Attention的seq2seq模型,脸上浮现出了嫌弃的表情。文本生成在Self-Attention的加持下从未如此简单。


    UniLM

    这里只阐述论文的关键思路,具体细节还是看论文来的实在啦~

    1. MLM (Mask language model),之所以在文本生成任务上表现较弱,归根到底是其一个非常重要的先验假设:Token之间是相互独立的。
      在这里插入图片描述
      Independence Assumption: As emphasized by the ≈ sign in Eq, BERT factorizes the joint conditional probability p(x¯ | xˆ) based on an independence assumption that all masked tokens x¯ are separately reconstructed. (摘自论文XLNet)
    2. 其 Self-Attention 模块使得每一个Position上的Token都能获取到全文的上下文信息,这与文本生成任务相违背:即文本的生成是具有依赖关系的,Xt+1的生成应该依赖于X<=t,并且Xt+1不能获取X>t的信息,因为那是未来的信息,如果仍然使用MLM中的满Self-attention(姑且这么叫)来做训练模型的文本生成能力,那么在一开始模型就已经知道了所有的答案了,这显然是不合理的。
    3. 那该如何是好?使用Seq2Seq的模型:如老家族的RNN,LSTM,GRU等序列模型,或新家族的Transform类,由于在该类模型中Decoder的信息传递是单向的,每一个输出只决定于Encoder部分的输入和之前的输出,因此也能较好的完成文本生成的任务。
    4. 那有没有什么办法,让MLM / Bert的框架既能优秀的完成文本理解,又能轻松实现文本生成任务呢?UNILM给出了答案:给Self-Attention加上MASK!
    1. 既然满Self-Attention会泄密,那么为什么不能通过MASK:将待预测部分的Token的Attention做选择性的屏蔽,让他只能看到获取到上文的信息,而对输入部分保持理解输入部分的上下文信息,但屏蔽待生成部分的信息。
    2. 图中 Bidirectional LM 与Bert一致,使用满 Self-attention 是模型充分学习全文的上下文信息,提高文本理解能力。
    3. 图中 Seq-to-Seq LM 部分:矩阵 Sij i为行 j 为列 Sij 为空白表示以 i 为 Q,j 为 K 的Attention信息没有被MASK,即 i 能获取 j 的信息。Sij 为黑 则表示该Attention信息被Mask,i 无法获取 即 j 的信息。如图中的MASK设计,S1部分仍为 Bidirectional LM,S2部分的每一个Token只能获取前面的Token的信息,而后面的信息是被MASK的。这符合文本生成的逻辑。
    4. 将以上 两种 LM 方式作为模型的 Pretrain 任务,Bidirectional LM 与 Bert一致,随机MASK Token,并进行预测,Seq-to-Seq LM 则 MASK S2的Token 利用 S1的信息去预测。这种Pretrain机制的设计使得 UNILM 完成文本理解和文本生成任务的能力都得到了提升。
      在这里插入图片描述在这里插入图片描述

    How to build UniLM

    Tensorflow-GPU 2.0.0
    Transformers 3.1.0

    Get 2D MASK

    1. 当我们要使用 UniLM 完成文本生成任务时,Self-Attention 的Mask 会变成一个2D动态遮招(每一个sample都不同)这与往常我们通过 Transformers 的 BertTokenizer 模块直接得到的1D的 attention_mask不同。
    2. 2D Mask 取决于我们的输入和输出,而这部分信息可以通过Segment_id进行表示,因此我们只需要将输入文本和目标输出文本同时传递给BertTokenizer,通过返回的Segment_id 构建 2D MASK即可。
    def unilm_mask_single(s):
    	'''
    	s = np.array([0,0,0,0,1,1,1,0,0,0])
    	unilm_mask_single(s) = 
    	<tf.Tensor: shape=(10, 10), dtype=float32, numpy=
    	array([[1., 1., 1., 1., 0., 0., 0., 0., 0., 0.],
    	       [1., 1., 1., 1., 0., 0., 0., 0., 0., 0.],
    	       [1., 1., 1., 1., 0., 0., 0., 0., 0., 0.],
    	       [1., 1., 1., 1., 0., 0., 0., 0., 0., 0.],
    	       [1., 1., 1., 1., 1., 0., 0., 0., 0., 0.],
    	       [1., 1., 1., 1., 1., 1., 0., 0., 0., 0.],
    	       [1., 1., 1., 1., 1., 1., 1., 1., 1., 1.],
    	       [1., 1., 1., 1., 1., 1., 1., 1., 1., 1.],
    	       [1., 1., 1., 1., 1., 1., 1., 1., 1., 1.],
    	       [1., 1., 1., 1., 1., 1., 1., 1., 1., 1.]], dtype=float32)>
    	'''
        idxs = K.cumsum(s, axis=0)
        mask = idxs[None, :] <= idxs[:, None]
        mask = K.cast(mask, K.floatx())
        return mask
        
    ids = np.zeros((self.batch_size,self.Max_len),dtype='int32')
    seg_id = np.zeros((self.batch_size,self.Max_len),dtype='int32')
    mask_att = np.zeros((self.batch_size,self.Max_len,self.Max_len),dtype='int32')
    
    input_dict = self.tokenizer(content,title,max_length=self.Max_len,truncation=True,padding=True)
    len_ = len(input_dict['input_ids'])
    token_ids = input_dict['input_ids']
    segment_ids = input_dict['token_type_ids']
    ids[index][:len_] = token_ids
    seg_id[index][:len_] = segment_ids
    mask_id = unilm_mask_single(seg_id[index])
    mask_att[index] = mask_id
    

    Send 2D MASK to Bert

    1. 通过 Trainsformers.TFBertModel 类创建的Bert实例,其默认接受的attention_mask类型为2维 即[batch_size, MAX_LEN] 之后通过广播的形式传播到 Self-attention矩阵的每一行,因此我们需要修改 Trainsformers.TFBertModel 的逻辑,使其允许接受我们提前计算好的Self-attention矩阵,即[batch_size, MAX_LEN, MAX_LEN]
    2. 具体的:
    class TFBertMainLayer(tf.keras.layers.Layer):
    	def call(……)
            if len(attention_mask.shape) == 2:
                extended_attention_mask = attention_mask[:, tf.newaxis, tf.newaxis, :]
            elif len(attention_mask.shape) == 3:
                extended_attention_mask = attention_mask[:, tf.newaxis, :, :]
            else:
                raise NotImplementedError
            extended_attention_mask = tf.cast(extended_attention_mask, embedding_output.dtype)
            extended_attention_mask = (1.0 - extended_attention_mask) * -10000.0
    
    1. 当你需要使用UniLM完成文本生成任务时,传入你提前计算好的attention_mask矩阵即可,
    2. 当你需要使用UniLM完成文本理解任务时,传入原始的attention_mask序列即可。
    3. 至此你已经完成了 UniLM 模型!

    使用UniLM实现新闻标题生成

    数据处理部分(略)

    数据处理部分只需要将新闻文本和标题同时传给BertTokenizer实例,并通过返回的Segment_id构建attention_mask矩阵即可。

    模型训练

    技巧1:用自定义损失层来代替损失函数

    1. 通过自定义层,并利用tf.keras.layers.Layer.add_loss 方法,可以允许我们在层的计算中传递Loss,这允许我们利用 input 和 output 计算损失。具体如下:
    class Loss(tf.keras.layers.Layer):
        """特殊的层,用来定义复杂loss
        """
        def __init__(self, output_axis=None, **kwargs):
            super(Loss, self).__init__(**kwargs)
            self.output_axis = output_axis
    
        def call(self, inputs, mask=None):
            loss = self.compute_loss(inputs, mask)
            self.add_loss(loss)
            if self.output_axis is None:
                return inputs
            elif isinstance(self.output_axis, list):
                return [inputs[i] for i in self.output_axis]
            else:
                return inputs[self.output_axis]
    
        def compute_loss(self, inputs, mask=None):
            raise NotImplementedError
    
    class CrossEntropy(Loss):
        """交叉熵作为loss,并mask掉输入部分
        """
        def compute_loss(self, inputs, mask=None):
            y_true, y_mask, y_pred = inputs
            y_true = tf.cast(y_true,tf.float32)
            y_mask = tf.cast(y_mask,tf.float32)
            y_true = y_true[:, 1:]  # 目标token_ids
            y_mask = y_mask[:, 1:]  # segment_ids,刚好指示了要预测的部分
            y_pred = y_pred[:, :-1] # 预测序列,错开一位
            loss = K.sparse_categorical_crossentropy(y_true, y_pred)
            loss = K.sum(loss * y_mask) / K.sum(y_mask)
            return loss
    

    技巧2: 结合Embedding信息输出预测文本

    1. 传统上,我们会使用 Dense(vocab_size,activaion=‘softmax’)(weight = [hidden_size, voacb_size) 来输出预测的概率分布,这样虽然可行,但模型的收敛速度非常慢甚至无法收敛,且需要更多的数据支持。因为模型在最开始是不知道vocab_size维度上每一维对应的文字信息。如果我们将这个Dense层的weight替换成我们的word_embedding,将每一维的所代表的token信息提早告诉模型,不仅加快了模型的收敛速度(实测有效!),还提高了模型的性能。模型具体如下:
    def build_model(pretrained_path,config,MAX_LEN,vocab_size,keep_tokens):
        ids = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        token_id = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        att = tf.keras.layers.Input((MAX_LEN,MAX_LEN), dtype=tf.int32)
        config.output_hidden_states = True
        bert_model = TFBertModel.from_pretrained(pretrained_path,config=config,from_pt=True)
        x, _ , hidden_states = bert_model(ids,token_type_ids=token_id,attention_mask=att)
        layer_1 = hidden_states[-1]
        
        '''
        [batch_size,max_len,hidden_size] * [hidden_size,vocab_size]
        = [batch_size, max_len , vocab_size]
        '''
        word_embeeding = bert_model.bert.embeddings.word_embeddings
        embeddding_trans = tf.transpose(word_embeeding)
        sof_output = tf.matmul(layer_1,embeddding_trans)
        sof_output = tf.keras.layers.Activation('softmax')(sof_output)
        
        #加入损失层,计算损失
        output = CrossEntropy(2)([ids,token_id,sof_output])
        
        model = tf.keras.models.Model(inputs=[ids,token_id,att],outputs=output)
        optimizer = tf.keras.optimizers.Adam(learning_rate=1e-5)
        model.compile(optimizer=optimizer)
        model.summary()
        return model
    

    模型推理

    技巧3: BeamSearch解码

    1. 模型推理时为多步迭代解码过程,并用BeamSearch方法寻找较优序列
    2. 具体来说每次只预测下一个词,并将已经生成的token加入到输入中,但segment_id编码为1,传入相应的mask矩阵。(蓝色为输入,红色为输出)
    3. BeamSearch解码代码如下,已补充更多备注:
    class AutoRegressiveDecoder(object):
        """通用自回归生成模型解码基类
        包含beam search和random sample两种策略
        """
        def __init__(self, start_id, end_id, maxlen, model,minlen=1):
            self.start_id = start_id
            self.end_id = end_id
            self.maxlen = maxlen
            self.minlen = minlen
            self.models = {}
            self.model = model
            if start_id is None:
                self.first_output_ids = np.empty((1, 0), dtype=int)
                # array([], shape=(1, 0), dtype=int64)
            else:
                self.first_output_ids = np.array([[self.start_id]])
    
        @staticmethod
        def wraps(default_rtype='probas', use_states=False):
            """用来进一步完善predict函数
            目前包含:1. 设置rtype参数,并做相应处理;
                      2. 确定states的使用,并做相应处理;
                      3. 设置温度参数,并做相应处理。
            """
            def actual_decorator(predict):
                def new_predict(
                    self,
                    inputs,
                    output_ids,
                    states,
                    temperature=1,
                    rtype=default_rtype
                ):
                    assert rtype in ['probas', 'logits']
                    prediction = predict(self, inputs, output_ids, states)
    
                    if not use_states:
                        prediction = (prediction, None)
    
                    if default_rtype == 'logits':
                        prediction = (
                            softmax(prediction[0] / temperature), prediction[1]
                        )
                    elif temperature != 1:
                        probas = np.power(prediction[0], 1.0 / temperature)
                        probas = probas / probas.sum(axis=-1, keepdims=True)
                        prediction = (probas, prediction[1])
    
                    if rtype == 'probas':
                        return prediction
                    else:
                        return np.log(prediction[0] + 1e-12), prediction[1]
    
                return new_predict
    
            return actual_decorator
    
        def last_token(self,end):
            """创建一个只返回输入的最后一个token输出的新Model
            """
    #         if model not in self.models:
            outputs = [
                    tf.keras.layers.Lambda(lambda x: x[:,end])(output)
                    for output in self.model.outputs]
            model_temp = tf.keras.models.Model(self.model.inputs, outputs)
    
            return model_temp
    
        def predict(self, inputs, output_ids, states=None):
            """用户需自定义递归预测函数
            说明:定义的时候,需要用wraps方法进行装饰,传入default_rtype和use_states,
                 其中default_rtype为字符串logits或probas,probas时返回归一化的概率,
                 rtype=logits时则返回softmax前的结果或者概率对数。
            返回:二元组 (得分或概率, states)
            """
            raise NotImplementedError
    
        def beam_search(self, inputs, topk, states=None, temperature=1, min_ends=1):
            """beam search解码
            说明:这里的topk即beam size;
            返回:最优解码序列。
            """
            #inputs = [token_ids,segment_ids]
            output_ids, output_scores = self.first_output_ids, np.zeros(1)
            # output_ids = [] , output_scores = 0
            for step in range(self.maxlen):
                scores, states = self.predict(
                    inputs, output_ids, states, temperature, 'logits'
                )  # 计算当前得分
                #每一次人输入的拼接在predict里完成
    
                if step == 0:  # 第1步预测后将输入重复topk次
                    inputs = [np.repeat(i, topk, axis=0) for i in inputs]
                scores = output_scores.reshape((-1, 1)) + scores  # 综合累积得分-相加等于相乘,输出的是logist
                # output_scores = [1.16165232,1.75142511]#上一次最优的两个的值
                # 分别由上面两个最优值作为x产生,故在各自产生的概率上加上之前的值
                # [[0.99853728 0.67273463 1.50580529 1.16165232 1.4321206 ]
                # [1.44454842 1.68150066 1.24661511 1.42612343 1.75142511]]
                indices = scores.argpartition(-topk, axis=None)[-topk:]  # 仅保留topk
                #[3 ,9]
                indices_1 = indices // scores.shape[1] # 候选字数 # 行索引
                # [0 ,1]
                indices_2 = (indices % scores.shape[1]).reshape((-1, 1))  # 列索引
                # [[3],[4]]
                output_ids = np.concatenate([output_ids[indices_1],indices_2],1)  # 更新输出
                #[[1,2,2,3,3], + [[3]
                # [2,3,1,4,4]]    [4]]
                output_scores = np.take_along_axis(
                    scores, indices, axis=None
                )  # 更新得分
                #按indices的一维切片去获得索引 [1.16165232,1.75142511]
                end_counts = (output_ids == self.end_id).sum(1)  # 统计出现的end标记
                #[分别统计两条路 end出现次数 0,1]
                if output_ids.shape[1] >= self.minlen:  # 最短长度判断
                    best_one = output_scores.argmax()  # 得分最大的那个
                    if end_counts[best_one] == min_ends: # =1   # 如果已经终止
                        return output_ids[best_one]  # 直接输出
                    else:  # 否则,只保留未完成部分
                        flag = (end_counts < min_ends)  # 标记未完成序列
                        if not flag.all():  # 如果有已完成的
                            inputs = [i[flag] for i in inputs]  # 扔掉已完成序列
                            output_ids = output_ids[flag]  # 扔掉已完成序列
                            output_scores = output_scores[flag]  # 扔掉已完成序列
                            end_counts = end_counts[flag]  # 扔掉已完成end计数
                            topk = flag.sum()  # topk相应变化
            # 达到长度直接输出
            return output_ids[output_scores.argmax()]
    
    class AutoTitle(AutoRegressiveDecoder):
        """seq2seq解码器
        """
        @AutoRegressiveDecoder.wraps(default_rtype='probas')
        def predict(self, inputs, output_ids, states):
            ids,seg_id,mask_att = inputs
            ides_temp = ids.copy()
            seg_id_temp = seg_id.copy()
            mask_att_temp = mask_att.copy()
            len_out_put = len(output_ids[0])
            for i in range(len(ids)):
                get_len = len(np.where(ids[i] != 0)[0])
                end_ = get_len + len_out_put
                ides_temp[i][get_len:end_] = output_ids[i]
                seg_id_temp[i][get_len:end_] = np.ones_like(output_ids[i])
                mask_att_temp[i] = unilm_mask_single(seg_id_temp[i])
            return self.model.predict([ides_temp,seg_id_temp,mask_att_temp])[:,end_-1]
        
        def generate(self,text,tokenizer,maxlen,topk=1):
            max_c_len = maxlen - self.maxlen
            input_dict = tokenizer(text,max_length=max_c_len,truncation=True,padding=True)
            token_ids = input_dict['input_ids']
            segment_ids = input_dict['token_type_ids']
            ids = np.zeros((1,maxlen),dtype='int32')
            seg_id = np.zeros((1,maxlen),dtype='int32')
            mask_att = np.zeros((1,maxlen,maxlen),dtype='int32')
            len_ = len(token_ids)
            ids[0][:len_] = token_ids
            seg_id[0][:len_] = segment_ids
            mask_id = unilm_mask_single(seg_id[0])
            mask_att[0] = mask_id
            output_ids = self.beam_search([ids,seg_id,mask_att],topk=topk)  # 基于beam search
            return tokenizer.decode(output_ids)
    

    精简你的词汇表

    技巧4: 精简你的词汇表,让你的模型收敛更快

    1. 加载预训练模型的词汇表,其中所有词汇量达到21127个,所对应的预训练的word_embedding参数为 [21127,hidden_size],而这21127中包含了英文后缀、特殊符号、表情、其他文字等等,考虑到中文文本生成任务,我们可以通过精简词汇表,并调整word_embedding来减少模型需要预测的输出类别,一方面减少了模型参数,加速了模型收敛,另一方面也避免了生成的文本有奇怪的字符掺入。
    def load_vocab(dict_path, encoding='utf-8', simplified=False, startswith=None):
        """
        从bert的词典文件中读取词典,如果simplified = True,则对该字典进行精简。
        return:返回精简留下的字符及其新token_id组成的字典,已经其对应的老token_id。
        """
        def _is_punctuation(ch):
            """标点符号类字符判断(全/半角均在此内)
            提醒:unicodedata.category这个函数在py2和py3下的
            表现可能不一样,比如u'§'字符,在py2下的结果为'So',
            在py3下的结果是'Po'。
            """
            code = ord(ch)
            return 33 <= code <= 47 or \
                58 <= code <= 64 or \
                91 <= code <= 96 or \
                123 <= code <= 126 or \
                unicodedata.category(ch).startswith('P')
        
        def stem(token):
            """获取token的“词干”(如果是##开头,则自动去掉##)
            """
            if token[:2] == '##':
                return token[2:]
            else:
                return token
            
        def _cjk_punctuation():
            return u'\uff02\uff03\uff04\uff05\uff06\uff07\uff08\uff09\uff0a\uff0b\uff0c\uff0d\uff0f\uff1a\uff1b\uff1c\uff1d\uff1e\uff20\uff3b\uff3c\uff3d\uff3e\uff3f\uff40\uff5b\uff5c\uff5d\uff5e\uff5f\uff60\uff62\uff63\uff64\u3000\u3001\u3003\u3008\u3009\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\u3014\u3015\u3016\u3017\u3018\u3019\u301a\u301b\u301c\u301d\u301e\u301f\u3030\u303e\u303f\u2013\u2014\u2018\u2019\u201b\u201c\u201d\u201e\u201f\u2026\u2027\ufe4f\ufe51\ufe54\u00b7\uff01\uff1f\uff61\u3002'
    
        def _is_cjk_character(ch):
            """CJK类字符判断(包括中文字符也在此列)
            参考:https://en.wikipedia.org/wiki/CJK_Unified_Ideographs_(Unicode_block)
            """
            code = ord(ch)
            return 0x4E00 <= code <= 0x9FFF or \
                0x3400 <= code <= 0x4DBF or \
                0x20000 <= code <= 0x2A6DF or \
                0x2A700 <= code <= 0x2B73F or \
                0x2B740 <= code <= 0x2B81F or \
                0x2B820 <= code <= 0x2CEAF or \
                0xF900 <= code <= 0xFAFF or \
                0x2F800 <= code <= 0x2FA1F
        
        token_dict = {}
        with open(dict_path, encoding=encoding) as reader:
            for line in reader:
                token = line.split()
                token = token[0] if token else line.strip()
                token_dict[token] = len(token_dict)
    
        if simplified:  # 过滤冗余部分token
            new_token_dict, keep_tokens = {}, []
            startswith = startswith or []
            for t in startswith:
                new_token_dict[t] = len(new_token_dict)
                keep_tokens.append(token_dict[t])
    
            for t, _ in sorted(token_dict.items(), key=lambda s: s[1]):
                if t not in new_token_dict:
                    keep = True
                    if len(t) > 1:
                        for c in stem(t):
                            if (
                                _is_cjk_character(c) or
                                _is_punctuation(c)
                            ):
                                keep = False
                                break
                    if keep:
                        new_token_dict[t] = len(new_token_dict)
                        keep_tokens.append(token_dict[t])
    
            return new_token_dict, keep_tokens
        else:
            return token_dict
    
    1. 通过load_vocab函数,我们可以将传入的词表.txt文件精简后,以dict的形式返回,并附带对应的老token_id,这对我们来说很重要,可以帮助我们重构word_embedding。
    2. 那么我们该如何将这个new_dict载入到BertTokenizer中去呢,简单的替换 tokenizer.vocab 是会出现错误的,而BertTokenizer的call函数仅接受文件路径,因此同样我们需要修改BertTokenizer类的函数,使之可以接受dict形式的词典,具体的:修改BertTokenizer脚本中的load_vocab 函数,使之可以直接返回dict。
    def load_vocab(vocab_file):
        """Loads a vocabulary file into a dictionary."""
        
        if isinstance(vocab_file,dict):
            return vocab_file
        
        vocab = collections.OrderedDict()
        with open(vocab_file, "r", encoding="utf-8") as reader:
            tokens = reader.readlines()
        for index, token in enumerate(tokens):
            token = token.rstrip("\n")
            vocab[token] = index
        return vocab
    
    1. 加载new_dict,精简后的词汇表从原来的 21127 压缩至13584。
    new_token_dict, keep_tokens = load_vocab(vocab_path,simplified=True,startswith=['[PAD]', '[UNK]', '[CLS]', '[SEP]'])
    tokenizer = BertTokenizer(new_token_dict)
    vocab_size = tokenizer.vocab_size
    print(vocab_size) # 13584
    
    1. 那么精简后的词表改变了各个字符对应的token_id,它和原来的预训练网络的embedding映射关系已经无法匹配,因此我们通过得到的keep_tokens,来对模型的word_embedding进行修改,使之与我们新的词表相对应:
    def build_model(pretrained_path,config,MAX_LEN,vocab_size,keep_tokens):
        ids = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        token_id = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        att = tf.keras.layers.Input((MAX_LEN,MAX_LEN), dtype=tf.int32)
        config.output_hidden_states = True
        bert_model = TFBertModel.from_pretrained(pretrained_path,config=config,from_pt=True)
        '''
        通过set_input_embeddings函数,修改word_embedding矩阵
        '''
        bert_model.bert.set_input_embeddings(tf.gather(bert_model.bert.embeddings.word_embeddings,keep_tokens))
        
        x, _ , hidden_states = bert_model(ids,token_type_ids=token_id,attention_mask=att)
        layer_1 = hidden_states[-1]
        word_embeeding = bert_model.bert.embeddings.word_embeddings
        embeddding_trans = tf.transpose(word_embeeding)
        sof_output = tf.matmul(layer_1,embeddding_trans)
        sof_output = tf.keras.layers.Activation('softmax')(sof_output)
        output = CrossEntropy(2)([ids,token_id,sof_output])
        model = tf.keras.models.Model(inputs=[ids,token_id,att],outputs=output)
        optimizer = tf.keras.optimizers.Adam(learning_rate=1e-5)
        model.compile(optimizer=optimizer)
        model.summary()
        return model
    

    测试结果

    1. 仅仅通过几个小(1000steps)epoch,模型就能生成基本可读的新闻标题
    2. 充分训练后,找了几篇最新的杭州报道进行测试,效果如下:
    '''
    杭州日报讯 留下来吧,在杭州过个暖心年!
    昨日,市人力社保局、市经信局、市总工会等多部门相继发布倡议书,
    倡议大家“就地过年”“留杭过年”,非必要不离杭,减少出行聚集,助力疫情防控。
    为确保企业生产稳定有序,多部门鼓励企业通过“留岗红包”“过年大礼包”
    “特殊津贴”“错峰调休”“领导带头”等积极措施留工稳岗。
    同时,倡议大家尽量选择留杭过节、远程拜年,减少跨区域流动,把疫情传播风险降到最低。
    针对确需返乡过年的员工,要做好防疫措施,有条件的企业可开展“点对点”送返,
    确保员工往返途中和假期安全。
    '''
    Generate_title: '杭 州 多 部 门 倡 议 留 杭 过 年 [SEP]'
    
    '''
    都市快报讯 目前,北方强冷空气已严重影响杭州。
    杭州市气象台1月7日15时31分发布低温橙色预警信号:
    “受北方强冷空气影响,预计明天早晨主城区和钱塘新区最低气温-5℃到-7℃,
    有严重冰冻,请注意做好防冻保暖工作。
    ”根据《杭州市抗雪防冻应急预案》,市防指决定从1月7日19时启动抗雪防冻Ⅳ级应急响应。
    要求各地各部门按照预案要求,密切关注天气变化,加强监测预警,及时启动响应,
    做好防御低温雨雪冰冻灾害的各项工作,确保安全。
    '''
    Generate_title: '北 方 强 冷 空 气 严 重 影 响 杭 州 [SEP]'
    
    '''
    杭州日报讯 昨日起,大家熟悉的公共自行车小红车使用有了新的变化:
    杭州公共自行车推出“扫码租车免押金”服务,
    市民游客只要通过信用免押进行实名认证后便可实现免押金租车。
    记者从杭州公共自行车公司了解到,通过App Store、应用市场搜索“叮嗒出行”APP,
    下载后点击首页“实名认证”,输入姓名、身份证号后,经校验通过,即可进入“免押通道”,
    选择“0元免费开通”,同意“信用免押协议”,完成信用免押。
    之后,无论是通过“叮嗒出行”APP,还是通过相应微信、支付宝小程序均可直接租用小红车,
    不再需要缴纳信用保证金。
    '''
    Generate_title: '杭 州 公 共 自 行 车 推 出 免 押 金 服 务 [SEP]'
    

    参考资料

    [1] Unified Language Model Pre-training for
    Natural Language Understanding and Generationh https://arxiv.org/abs/1905.03197
    [2] 苏剑林. (Sep. 18, 2019). 《从语言模型到Seq2Seq:Transformer如戏,全靠Mask 》[Blog post]. Retrieved from https://kexue.fm/archives/6933


    代码地址

    https://github.com/zhengyanzhao1997/TF-NLP-model/blob/main/model/train/Unified%20Language%20Model/tran.py

    展开全文
  • Sentence-BERT 句子语义匹配模型的tensorflow实现以及训练trick论文模型回顾建模与训练模型代码部分数据处理训练模型训练Tricktrick1 warm up代码实现:trick2 focal loss代码实现:总结与思考 论文模型回顾 论文...


    论文模型回顾

    论文链接:https://arxiv.org/abs/1908.10084
    文章在已有的语义匹配模型的基础上提出了基于Bert的句义匹配孪生网络
    论文模型图
    模型介绍:将两个句子通过Bert(注意:在对句子相似度建模时,两个句子经过的Bert层应该是共享权重的,及同一个Bert)进行特征提取后,取最后一层的hidde_layers进行pooling,文章试验了直接取CLS向量、max_pooling、mean_pooling,结果显示mean_pooling效果最好。将pooling后得到的两个句子向量进行特征交叉,文章尝试了多种交叉方式,|u-v|的效果最好,当然使用者可以根据具体任务和场景自行尝试多种交叉方法;最后通过softmax层。
    训练好模型之后,我们可以将语料库中的句子通过单塔转化为对应的句子向量,当待匹配句子进入时,通过向量相似度检索来直接搜索相似句子,节省了大量的模型推理时间。

    在这里插入图片描述
    在这里插入图片描述


    建模与训练

    tensorflow 2.0.0
    transformers 3.1.0

    模型代码部分

    class BertNerModel(tf.keras.Model):
        dense_layer = 512
        class_num = 2
        drop_out_rate = 0.5
        def __init__(self,pretrained_path,config,*inputs,**kwargs):
            super(BertNerModel,self).__init__()
            config.output_hidden_states = True
            self.bert = TFBertModel.from_pretrained(pretrained_path,config=config,from_pt=True)
            self.liner_layer = tf.keras.layers.Dense(self.dense_layer,activation='relu')
            self.softmax = tf.keras.layers.Dense(self.class_num,activation='softmax')
            self.drop_out = tf.keras.layers.Dropout(self.drop_out_rate)      
        def call(self,input_1):
            hidden_states_1,_,_ = self.bert((input_1['input_ids'],input_1['token_type_ids'],input_1['attention_mask']))
            hidden_states_2,_,_ = self.bert((input_1['input_ids_2'],input_1['token_type_ids_2'],input_1['attention_mask_2']))
            hidden_states_1 = tf.math.reduce_mean(hidden_states_1,1)
            hidden_states_2 = tf.math.reduce_mean(hidden_states_2,1)
            concat_layer = tf.concat((hidden_states_1,hidden_states_2,tf.abs(tf.math.subtract(hidden_states_1, hidden_states_2))),1,)
            drop_out_l = self.drop_out(concat_layer)
            Dense_l = self.liner_layer(drop_out_l)
            outputs = self.softmax(Dense_l)
            print(outputs.shape)
            return outputs
    

    这里比较难受的是,在自定义模型的时候本来想直接继承transformers的TFBertPreTrainedModel类,但是发现这传入训练数据的时候需要以元组的形式传入,但是在tf model.fit的时候会报错无法识别元组+datasets的数据,因此这里改为继承tf.keras.Model,在类中直接加入TFBertModel.from_pretrained加载之后的TFBertModel,再在后面接自定义的层。

    数据处理

    def data_proceed(path,batch_size,tokenizer):
        data = pd.read_csv(path)
        data = data.sample(frac=1)
        inputs_1 = tokenizer(list(data['sentence1']), padding=True, truncation=True, return_tensors="tf",max_length=30)
        inputs_2 = tokenizer(list(data['sentence2']), padding=True, truncation=True, return_tensors="tf",max_length=30)
        inputs_1  = dict(inputs_1)
        inputs_1['input_ids_2'] = inputs_2['input_ids']
        inputs_1['token_type_ids_2'] = inputs_2['token_type_ids']
        inputs_1['attention_mask_2'] = inputs_2['attention_mask']
        label = list(data['label'])
        steps = len(label)//batch_size
        x = tf.data.Dataset.from_tensor_slices((dict(inputs_1),label))
        return x,steps
    

    训练

        optimizer = tf.keras.optimizers.Adam(learning_rate=5e-5)
        bert_ner_model.compile(optimizer=optimizer,loss='sparse_categorical_crossentropy',metrics=['acc'])
        bert_ner_model.fit(train_data,epochs=5,verbose=1,steps_per_epoch=steps_per_epoch,
                               validation_data=test_data,validation_steps=validation_steps)
    

    模型训练Trick

    trick1 warm up

    原文中提到了在训练时warm up learning rate的训练技巧
    warm up
    由于刚开始训练时,模型的权重(weights)是随机初始化的,此时若选择一个较大的学习率,可能带来模型的不稳定(振荡),选择Warmup预热学习率的方式,可以使得开始训练的几个epoches或者一些steps内学习率较小,在预热的小学习率下,模型可以慢慢趋于稳定,等模型相对稳定后再选择预先设置的学习率进行训练,使得模型收敛速度变得更快,模型效果更佳。
    该段摘自深度学习训练策略-学习率预热Warmup

    代码实现:

    class WarmupExponentialDecay(Callback):
        def __init__(self,lr_base=0.0002,decay=0,warmup_epochs=0,steps_per_epoch=0):
            self.num_passed_batchs = 0   #一个计数器
            self.warmup_epochs=warmup_epochs  
            self.lr=lr_base #learning_rate_base
            self.decay=decay  #指数衰减率
            self.steps_per_epoch=steps_per_epoch #也是一个计数器
        def on_batch_begin(self, batch, logs=None):
            # params是模型自动传递给Callback的一些参数
            if self.steps_per_epoch==0:
                #防止跑验证集的时候呗更改了
                if self.params['steps'] == None:
                    self.steps_per_epoch = np.ceil(1. * self.params['samples'] / self.params['batch_size'])
                else:
                    self.steps_per_epoch = self.params['steps']
            if self.num_passed_batchs < self.steps_per_epoch * self.warmup_epochs:
                K.set_value(self.model.optimizer.lr,
                            self.lr*(self.num_passed_batchs + 1) / self.steps_per_epoch / self.warmup_epochs)
            else:
                K.set_value(self.model.optimizer.lr,
                            self.lr*((1-self.decay)**(self.num_passed_batchs-self.steps_per_epoch*self.warmup_epochs)))
            self.num_passed_batchs += 1
        def on_epoch_begin(self,epoch,logs=None):
        #用来输出学习率的,可以删除
            print("learning_rate:",K.get_value(self.model.optimizer.lr)) 
    

    trick2 focal loss

    在实际应用中,负样本往往来自于负采样,大量的负采样会时训练时负样本数量远多余正样本数量导致训练样本不平衡,且软负采样的负样本往往非常弱,在模型推理时置信度一般较高,加入focal loss可以让模型专注于那些置信度低的比较难区分的样本,提高模型的训练效果。
    详细可以查看我的之前的博客Tensorlfow2.0 二分类和多分类focal loss实现和在文本分类任务效果评估

    代码实现:

    def sparse_categorical_crossentropy(y_true, y_pred):
        y_true = tf.reshape(y_true, tf.shape(y_pred)[:-1])
        y_true = tf.cast(y_true, tf.int32)
        y_true = tf.one_hot(y_true, K.shape(y_pred)[-1])
        return tf.keras.losses.categorical_crossentropy(y_true, y_pred)
    
    def loss_with_gradient_penalty(model,epsilon=1):
        def loss_with_gradient_penalty_2(y_true, y_pred):
            loss = tf.math.reduce_mean(sparse_categorical_crossentropy(y_true, y_pred))
            embeddings = model.variables[0]
            gp = tf.math.reduce_sum(tf.gradients(loss, [embeddings])[0].values**2)
            return loss + 0.5 * epsilon * gp
        return loss_with_gradient_penalty_2
    
    #使用方法:
    model.compile(optimizer=optimizer, loss=softmax_focal_loss,metrics=[tf.keras.metrics.SparseCategoricalAccuracy()])
    

    效果:
    在公开数据集上:该focal_loss可以很好的抑制模型过拟合且模型效果也有1个多点的提升。


    总结与思考

    本次训练使用的数据集:LCQMC 是哈尔滨工业大学在自然语言处理国际顶会 COLING2018 构建的问题语义匹配数据集,其目标是判断两个问题的语义是否相同。准确率能达到90%+

    但在实际测试时发现,模型推理相似的问句条件比较严格,无法做到真的根据语义进行匹配,(对于同义词、别名等无法识别区分)需要应用到实际生产工作则对训练样本的要求比较严格。

    拓展思考:由于该孪生模型的两个句子共享一个bert参数,因此要求两个句子的分布或者说两个句子必须来自统一场景,需要在格式、长度、风格、句式上比较相近。因此在问句匹配、句子相似度判断等工作上能有不错的表现。但可能不适用于类似评论于商品相关度等任务的分析(因为评论文本于商品介绍文本不统一,经过同一个Bert会产生偏差)因此思考对于此类问题,借鉴双塔模型,使用两个不同的Bert来提取两种分布的句子特征,或许仍能有不错的标签,之后有机会会试验一下~

    展开全文
  • NEZHA 相对位置编码突破Bert文本512长度的限制前言NEZHAHow to build UniLMGet 2D MASKSend 2D MASK to Bert使用UniLM实现新闻标题生成数据处理部分(略)模型训练技巧1:用...论文原文:NEZHA: NEURAL CONTEXTUALIZED .


    前言

    论文原文:
    Self-Attention with Relative Position Representations
    NEZHA: NEURAL CONTEXTUALIZED REPRESENTATION FOR CHINESE LANGUAGE UNDERSTANDING

    最近在研究苏神的《SPACES:“抽取-生成”式长文本摘要(法研杯总结)
    面向法律领域裁判文书的长文本摘要生成,涉及到长文的输入与输出,其输入加输出长度远超过bert限定的512,(Bert的postion_embedding是在预训练过程中训练好的,最长为512)。因此需要寻找解决突破输入长度限制的方法,目前了解到的解决方案:

    1. Bert层次编码
    2. T5模型相对位置编码
    3. NEZHA相对位置编码

    本文选择了华为的NEZHA模型的相对位置编码作为复现目标,先比T5来说,NEZHA沿用了 Self-attention with relative position representations 文中的相对位置编码方法,实现起来较为简单,并不需要给模型增加额外的可训练参数,问题在于增加了模型的计算量。


    Self-Attention with Relative Position Representations

    1. position_embedding的意义:position_embedding表征了token在输入中的位置信息,该位置信息主要在self-attention阶段被利用,具体可以理解为,在self-attention阶段,我们希望attention不仅要考虑word-embedding的信息,同时也要考虑到Q与K的位置关系。
    2. 不同于Transformer的绝对位置编码,论文作者希望将原来从first input传入的position_embedding 转移到self-attention中,并希望模型能在训练的过程中学习到这相对位置编码参数,最后作出假设:Residual connections help propagate position information to higher layers.
    3. 论文将token之间相对位置输入建模为一个有向的、全联接的图模型, 希望通过直接创建两组边关系aVij and aKij 分别适用于attention中的QK点积计算,与V与softmax结果的点积计算,由此可以避免一些多余的线性变换。
    4. V与softmax结果的点积计算,将相对位置信息传递给下游任务:

      This extension is presumably important for tasks where information about the edge types selected by a given attention head is useful to downstream encoder or decoder layers.
    5. attention中的QK点积计算,通过相对位置信息影响注意力分布:

      model will consider edges when determining compatibility
    6. 对相对位置编码距离进行截断,将其最大相对位置设置为固定值K:

      We hypothesized that precise relative position information is not useful beyond a
      certain distance.
    7. 更有效的计算:
      1. 多头attention共享一组相对位置编码,we reduce the space complexity of storing relative position representations from O(hn2da) to O(n2da) by sharing them across each heads. Additionally, relative position representations can be shared across sequences.
      2. 当不考虑相对位置编码时,原有的QKattend可以通过矩阵点积的方式实现并行计算,但当我们在eij的计算公式,对于不同的i 我们需要给不同的Wj 加上对应aij,这不利于用矩阵惩罚的广播机制,论文通过如下变换解决了并行计算的问题:

        式子的左半部分与原attention相同,可以通过矩阵乘法并行计算,观察式子的右半部分我们可以发现,对于eij部分的计算已经与K无关,我们可以分开计算两部分后再相加,右半部分我们可以通过 i 次并行的 j * d · d * 1 = j * 1 矩阵乘法得到可以与左半部分对位相加的 e_ij 矩阵,以此加快了模型的计算速度。
        勉强一看的示意图:

    NEZHA

    这里只阐述NEZHA的相对位置编码方法,模型的其他细节还是看论文来的实在啦~

    1. 前言中也说道:Bert模型之所以限制了输入token的长度要小于512,原因在于bert的postition_embedding是与word_embedding相加后输入到encode层中,虽然与transformer一样,都是绝对位置编码,但bert的postition_embedding是初始化后可以训练的参数,在预训练过程中得到,因此固定的参数大小使得当给入一个大于512的postition_id后无法在embedding矩阵中找到对应的向量。
    2. 因此可以思考,既然绝对位置编码的意义在于捕获token的相对位置关系,那么我们可以直接对token的相对位置进行编码,NEZHA模型就是在相对位置编码的基础上诞生的MLM预训练模型。
    3. 与上一篇论文不同的是,NEZHA相对位置编码是sinusoidal functions计算出的固定值,这使得模型可以延展到处理更长长度的句子,具体如下:
    1. That is, each dimension of the positional encoding corresponds to a sinusoid, and the sinusoidal functions for different dimensions have different wavelengths. In the above equations, dz is equal to the hidden size per head of the NEZHA model (i.e., the hidden size divided by the number of heads). The wavelengths form a geometric progression from 2π to 10000 · 2π. We choose the fixed sinusoidal functions mainly because it may allow the model to extrapolate to sequence lengths longer than the ones encountered during training.
    2. 用 aij 表示 i 到 j 的相对位置编码,其本质是一个n维的向量。位置编码上每一维的值沿用了sinusoidal functions来计算,j 代表 Self-attention中Q的位置,i 代表K的位置,k表示该位置编码向量上的第k维,dz则与一个attention-head的hidden_size对齐,由此我们就构建好了相对位置编码矩阵,且该矩阵在训练过程中固定不变。
    3. 论文沿用了Self-Attention with Relative Position Representations的相对位置编码在Attention中的计算方法。

    How to build Relative Position

    Tensorflow-GPU 2.0.0
    Transformers 3.1.0

    Get Relative Position Embedding

    class Sinusoidal(tf.keras.initializers.Initializer):
        def __call__(self, shape, dtype=None):
            """
            Sin-Cos形式的位置向量
            用于创建relative position embedding
            后续通过计算位置差来对embedding进行查询 得到相对位置向量
            embedding的shape 为[max_k(最大距离),deep(相对位置向量长度)]
            """
            vocab_size, depth = shape
            embeddings = np.zeros(shape)
            for pos in range(vocab_size):
                for i in range(depth // 2):
                    theta = pos / np.power(10000, 2. * i / depth)
                    embeddings[pos, 2 * i] = np.sin(theta)
                    embeddings[pos, 2 * i + 1] = np.cos(theta)
            return embeddings
        
    class RelativePositionEmbedding(tf.keras.layers.Layer):
    	'''
    	input_dim: max_k 对最大相对距离进行截断
    	output_dim:与最后的eij相加,由于各个head之间共享相对位置变量,
    	因此该参数为 hidden_size / head_num = head_size
    	embeddings_initializer:初始化的权重,此处使用Sinusoidal()
    	'''
        def __init__(
            self, input_dim, output_dim, embeddings_initializer=None, **kwargs
        ):
            super(RelativePositionEmbedding, self).__init__(**kwargs)
            self.input_dim = input_dim
            self.output_dim = output_dim
            self.embeddings_initializer = embeddings_initializer
    
        def build(self, input_shape):
            super(RelativePositionEmbedding, self).build(input_shape)
            self.embeddings = self.add_weight(
                name='embeddings',
                shape=(self.input_dim, self.output_dim),
                initializer = self.embeddings_initializer,
                trainable=False
                # 此处注意设置trainable = False 固定相对位置编码
            )
    
        def call(self, inputs):
        	'''
        	(l,l) 根据embedding查表得到相对位置编码矩阵 (l,l,d)
        	'''
            pos_ids = self.compute_position_ids(inputs)
            return K.gather(self.embeddings, pos_ids)
    
        def compute_position_ids(self, inputs):
        	'''
        	通过传入的hidden_size (b,l,h)
        	根据长度计算相对位置矩阵(l,l)(k个相对位置值)
        	'''
            q, v = inputs
            # 计算位置差
            q_idxs = K.arange(0, K.shape(q)[1], dtype='int32')
            q_idxs = K.expand_dims(q_idxs, 1)
            v_idxs = K.arange(0, K.shape(v)[1], dtype='int32')
            v_idxs = K.expand_dims(v_idxs, 0)
            pos_ids = v_idxs - q_idxs
            # 后处理操作
            max_position = (self.input_dim - 1) // 2
            pos_ids = K.clip(pos_ids, -max_position, max_position)
            pos_ids = pos_ids + max_position
            return pos_ids
    

    Send Relative Position to Self-attention

    1. 使用相对位置编码后,我们不再需要在input阶段,在word_embedding上加上预训练好的position,因此我们需要改变 TFBertEmbeddings 的计算逻辑,具体需要添加的语句如下:
    class TFBertEmbeddings(tf.keras.layers.Layer):
        """Construct the embeddings from word, position and token_type embeddings."""
    
        def __init__(self, config, **kwargs):
            super().__init__(**kwargs)
    
            if config.model_type:
                self.model_type = config.model_type
      
        def _embedding(self, input_ids, position_ids, token_type_ids, inputs_embeds, training=False):
    
            if self.model_type == 'NEZHA':
                embeddings = inputs_embeds + token_type_embeddings
                '''
                当我们的模型类型是NEZHA时,是需要将word_embedding和token_embeddings相加即可
                '''
            else:
                position_embeddings = tf.cast(self.position_embeddings(position_ids), inputs_embeds.dtype)
                embeddings = inputs_embeds + position_embeddings + token_type_embeddings
            embeddings = self.LayerNorm(embeddings)
            embeddings = self.dropout(embeddings, training=training)
            return embeddings
    
    1. 同时我们需要修改TFBertSelfAttention类的attention计算逻辑,把相对位置编码的计算加入:
    class TFBertSelfAttention(tf.keras.layers.Layer):
        def __init__(self, config, **kwargs):
            super().__init__(**kwargs)
            self.attention_head_size = int(config.hidden_size / config.num_attention_heads)
            
    		'''
    		通过RelativePositionEmbedding 创建一个最大距离为129,输出为
    		attention_head_size,以Sinusoidal function 编码的相对位置编码矩阵
    		'''
            if config.model_type:
                self.model_type = config.model_type
                if self.model_type == 'NEZHA':
                    self.position_bias = RelativePositionEmbedding(129,self.attention_head_size,Sinusoidal())
    
        def call(self, hidden_states, attention_mask, head_mask, output_attentions, training=False):
            attention_scores = tf.matmul(
                query_layer, key_layer, transpose_b=True
            )  # (batch size, num_heads, seq_len_q, seq_len_k)
            
           	'''
           	通过 tf.einsum('bhjd,jkd->bhjk', query_layer, position_bias)
           	即可一步完成上文所述的相对位置编码矩阵与Q矩阵的计算。
           	b: batch_size 
           	h: head_num
           	j: seq_len_q
           	d: attention_head_size
           	k: seq_len_k
           	'''
            if self.model_type == 'NEZHA':
                position_bias = self.position_bias([hidden_states,hidden_states])
                attention_scores = attention_scores + tf.einsum('bhjd,jkd->bhjk', query_layer, position_bias)
            
            dk = tf.cast(shape_list(key_layer)[-1], attention_scores.dtype)  # scale attention_scores
            attention_scores = attention_scores / tf.math.sqrt(dk)
    
            if attention_mask is not None:
                attention_scores = attention_scores + attention_mask
            attention_probs = tf.nn.softmax(attention_scores, axis=-1)
            attention_probs = self.dropout(attention_probs, training=training)
            # Mask heads if we want to
            if head_mask is not None:
                attention_probs = attention_probs * head_mask
            context_layer = tf.matmul(attention_probs, value_layer)
            
            '''
            与v * softmax结果 进行计算,逻辑相同
            '''
            if self.model_type == 'NEZHA':
                context_layer = context_layer + tf.einsum('bhjk,jkd->bhjd', attention_probs, position_bias)
                
            context_layer = tf.transpose(context_layer, perm=[0, 2, 1, 3])    
            context_layer = tf.reshape(
                context_layer, (batch_size, -1, self.all_head_size)
            )  # (batch_size, seq_len_q, all_head_size)
            outputs = (context_layer, attention_probs) if output_attentions else (context_layer,)
            return outputs
    
    1. 恭喜,到此已经可以轻轻松松实现相对位置编码了
    2. 当你需要使用相对位置编码时,在创建config后,添加该语句即可:
        config = BertConfig.from_json_file(config_path)
        config.model_type = 'NEZHA'
    
    1. 此时你创建的Bert类模型,不再受限制与512的长度,只要你的GPU顶的住,长度任您选择。

    使用NEZHA实现法律长文摘要生成

    该部分主要参考苏神的建模思路,只调几个比较有意思的点进行讲述,完整修改后的代码已经公开在个人github上了,同样对bertkeras的移植到transformer框架进行了调试。

    关键句抽取模型

    1. 对原文句子进行分割后,通过bert提取句子特征,按原文顺序输入DGCNN后对每个句子是否为摘要关键句进行标注。

    DGCNN

    1. DGCNN是苏神仍较为频繁使用的基础神经网络框架,其示意图与优点如下:
      1. 用GCNN的一个好处是梯度消失的风险更低,因为有一个卷积是不加任意激活函数的,没加激活函数的这部分卷积不容易梯度消失。
      2. 残差结构,并不只是为了解决梯度消失,而是使得信息能够在多通道传输。
      3. 为了使得CNN模型能够捕捉更远的的距离,并且又不至于增加模型参数,使用了膨胀卷积。

    代码实现

    class ResidualGatedConv1D(tf.keras.layers.Layer):
        """
        门控卷积
        filters:卷积核个数
        kernel_size:1D卷积大小
        dilation_rate:卷积膨胀率(长度)
        """
        def __init__(self, filters, kernel_size, dilation_rate=1, **kwargs):
            super(ResidualGatedConv1D, self).__init__(**kwargs)
            self.filters = filters
            self.kernel_size = kernel_size
            self.dilation_rate = dilation_rate
            self.supports_masking = True
    
        def build(self, input_shape):
            super(ResidualGatedConv1D, self).build(input_shape)
            self.conv1d = tf.keras.layers.Conv1D(
                filters=self.filters * 2,
                kernel_size=self.kernel_size,
                dilation_rate=self.dilation_rate,
                padding='same',
            )
            self.layernorm = tf.keras.layers.LayerNormalization()
            if self.filters != input_shape[-1]:
                self.dense = tf.keras.layers.Dense(self.filters, use_bias=False)
            self.alpha = self.add_weight(
                name='alpha', shape=[1], initializer='zeros'
            )
    
        def call(self, inputs, mask=None):
            if mask is not None:
                mask = K.cast(mask, K.floatx())
                inputs = inputs * mask[:, :, None]
            outputs = self.conv1d(inputs)
            # 2*filters 相当于两组filters来 一组*sigmoid(另一组)
            gate = K.sigmoid(outputs[..., self.filters:])
            outputs = outputs[..., :self.filters] * gate
            outputs = self.layernorm(outputs)
            if hasattr(self, 'dense'):
                #用于对象是否包含对应的属性值
                inputs = self.dense(inputs)
            return inputs + self.alpha * outputs
    

    纯手绘示意图,配合代码食用:

    生成模型

    BIO Copy

    Copy 机制可以保证摘要与原始文本的忠实程度,避免出现专业性错误,这在实际使用中是相当必要的。

    1. 训练阶段:我们只需要给数据作为标注,并作为输入传入,通过loss_layer进行loss计算即可。
    2. 至于预测阶段,对于每一步,我们先预测标签zt,如果zt是O,那么不用改变,如果zt是B,那么在token的分布中mask掉所有不在原文中的token,如果zt是I,那么在token的分布中mask掉所有不能组成原文中对应的n-gram的token。也就是说,解码的时候还是一步步解码,并不是一次性生成一个片段,但可以通过mask的方式,保证BI部分位置对应的token是原文中的一个片段。
    3. AutoRegressiveDecoder子类具体实现代码如下,已加入更多注释:
    class AutoTitle(AutoRegressiveDecoder):
        """seq2seq解码器
        """
        def get_ngram_set(self, x, n):
            """生成ngram合集,返回结果格式是:
            {(n-1)-gram: set([n-gram的第n个字集合])}
            """
            result = {}
            for i in range(len(x) - n + 1):
                k = tuple(x[i:i + n])
                if k[:-1] not in result:
                    result[k[:-1]] = set()
                result[k[:-1]].add(k[-1])
            return result
        
        @AutoRegressiveDecoder.wraps(default_rtype='logits', use_states=True)
        def predict(self, inputs, output_ids, states):
            ids,seg_id,mask_att = inputs
            ides_temp = ids.copy()
            seg_id_temp = seg_id.copy()
            mask_att_temp = mask_att.copy()
            len_out_put = len(output_ids[0])
            for i in range(len(ids)):
                get_len = len(np.where(ids[i] != 0)[0])
                end_ = get_len + len_out_put
                ides_temp[i][get_len:end_] = output_ids[i]
                seg_id_temp[i][get_len:end_] = np.ones_like(output_ids[i])
                mask_att_temp[i] = unilm_mask_single(seg_id_temp[i])
            prediction = self.last_token(end_-1).predict([ides_temp,seg_id_temp,mask_att_temp])
            '''
            假设现在的topK = 2 所以每次只predict 二组的可能输出 len(ides_temp) = 2
            那我们初始化[0,0] 代表每一组输出组目前的ngram情况
            1. 当目前组输出的label为0时:没有输出限制,则从所有字典中选择输出,states = label = 0
            2. 当目前组输出的label为1时:输出限制为B,则从所有输入中选择输出,states = label = 1
            3. 当目前组输出的label为2时:输出限制为I,若目前 states=0,则说明之前未输出B,则I无效,将lable=2 mask掉
            若目前 states + 1 = n >= 2,则有效,且目前处于n-gram状态,要输出的值与输入中n个连续的字组成ngram + 1,
            则考虑目前已经输出的 n-1 个字符是否属于输入中的连续片断,若是则将该片断对应的后续子集作为候选集
            若否,则退回至 1 - gram
            注意:states在每次predict后都会被保存
            '''
            if states is None:
                states = [0]
            elif len(states) == 1 and len(ides_temp) > 1:
                states = states * len(ides_temp)
            
            # 根据copy标签来调整概率分布
            probas = np.zeros_like(prediction[0]) - 1000  # 最终要返回的概率分布 初始化负数
            for i, token_ids in enumerate(inputs[0]):
                if states[i] == 0:
                    prediction[1][i, 2] *= -1  # 0不能接2 mask掉 2这个值
                label = prediction[1][i].argmax()  # 当前label
                if label < 2:
                    states[i] = label #[1,0]
                else:
                    states[i] += 1 #如果当前
                    
                if states[i] > 0:
                    ngrams = self.get_ngram_set(token_ids, states[i])
                    '''
                    if satates = 1 :开头
                    因此 ngrams = 1 所有的token
                    prefix = 全场 跳到 1garm 
                    if satates > 1 说明这个地方的label = 2 前需要和前面几个2与1组成n garm
                    则 ngrams = n 所有的token组合
                    prefix = output_ids 的最后 n-1 个 token
                    若存在 在 就是指定集合下的候选集
                    '''
                    prefix = tuple(output_ids[i, 1 - states[i]:])
                    if prefix in ngrams:  # 如果确实是适合的ngram
                        candidates = ngrams[prefix]
                    else:  # 没有的话就退回1gram
                        ngrams = self.get_ngram_set(token_ids, 1)
                        candidates = ngrams[tuple()]
                        states[i] = 1
                    candidates = list(candidates)
                    probas[i, candidates] = prediction[0][i, candidates]
                else:
                    probas[i] = prediction[0][i]
                idxs = probas[i].argpartition(-10)
                probas[i, idxs[:-10]] = -1000
                #把probas最小的k_sparse的值mask掉???
            return probas, states
        
        def generate(self,text,tokenizer,maxlen,topk=1):
            max_c_len = maxlen - self.maxlen
            input_dict = tokenizer(text,max_length=max_c_len,truncation=True,padding=True)
            token_ids = input_dict['input_ids']
            segment_ids = input_dict['token_type_ids']
            ids = np.zeros((1,maxlen),dtype='int32')
            seg_id = np.zeros((1,maxlen),dtype='int32')
            mask_att = np.zeros((1,maxlen,maxlen),dtype='int32')
            len_ = len(token_ids)
            ids[0][:len_] = token_ids
            seg_id[0][:len_] = segment_ids
            mask_id = unilm_mask_single(seg_id[0])
            mask_att[0] = mask_id
            output_ids = self.beam_search([ids,seg_id,mask_att],topk=topk)  # 基于beam search
            return tokenizer.decode(output_ids)
    

    稀疏Softmax

    1. 其中Ωk是将s1,s2,…,sn从大到小排列后前k个元素的下标集合。说白了,我们提出的Sparse Softmax就是在计算概率的时候,只保留前k个,后面的直接置零,k是人为选择的超参数,这次比赛中我们选择了k=10。在算交叉熵的时候,则将原来的对全体类别logsumexp操作,改为只对最大的k个类别进行,其中t代表目标类别。
    2. 为什么稀疏化之后会有效呢?这可能是稀疏化避免了Softmax的过度学习问题。
    3. 公示推理与代码:
        def compute_seq2seq_loss(self,inputs,k_sparse,mask=None):
            y_true, y_mask, y_pred ,_,_ = inputs
            y_mask = tf.cast(y_mask,y_pred.dtype)
            y_true = y_true[:, 1:]  # 目标token_ids
            y_mask = y_mask[:, 1:]  # segment_ids,刚好指示了要预测的部分
            y_pred = y_pred[:, :-1]  # 预测序列,错开一位
            pos_loss = tf.gather(y_pred,y_true[..., None],batch_dims=len(tf.shape(y_true[..., None]))-1)[...,0]
            y_pred = tf.nn.top_k(y_pred, k=k_sparse)[0]
            neg_loss = tf.math.reduce_logsumexp(y_pred, axis=-1)
            loss = neg_loss - pos_loss
            loss = K.sum(loss * y_mask) / K.sum(y_mask)
            return loss
    

    模型创建与训练

    同时创建两个模型,一个用来预测,一个用来训练。

    def build_model(pretrained_path,config,MAX_LEN,vocab_size,keep_tokens):
        ids = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        token_id = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        att = tf.keras.layers.Input((MAX_LEN,MAX_LEN), dtype=tf.int32)
        label = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        
        config.output_hidden_states = True
        bert_model = TFBertModel.from_pretrained(pretrained_path,config=config,from_pt=True)
        bert_model.bert.set_input_embeddings(tf.gather(bert_model.bert.embeddings.word_embeddings,keep_tokens))
        x, _ , hidden_states = bert_model(ids,token_type_ids=token_id,attention_mask=att)
        layer_1 = hidden_states[-1]
        label_out = tf.keras.layers.Dense(3,activation='softmax')(layer_1)
        word_embeeding = bert_model.bert.embeddings.word_embeddings
        embeddding_trans = tf.transpose(word_embeeding)
        sof_output = tf.matmul(layer_1,embeddding_trans)
        output = CrossEntropy([2,4])([ids,token_id,sof_output,label,label_out])
        model_pred = tf.keras.models.Model(inputs=[ids,token_id,att],outputs=[sof_output,label_out])
        model = tf.keras.models.Model(inputs=[ids,token_id,att,label],outputs=output)
        optimizer = tf.keras.optimizers.Adam(learning_rate=1e-5)
        model.compile(optimizer=optimizer)
        model.summary()
        return model , model_pred
    
    def main():
        pretrained_path = '*******'
        vocab_path = os.path.join(pretrained_path,'vocab.txt')
        new_token_dict, keep_tokens = load_vocab(vocab_path,simplified=True,startswith=['[PAD]', '[UNK]', '[CLS]', '[SEP]'])
        tokenizer = BertTokenizer(new_token_dict)
        vocab_size = tokenizer.vocab_size
        config_path = os.path.join(pretrained_path,'config.json')
        config = BertConfig.from_json_file(config_path)
        config.model_type = 'NEZHA'
        MAX_LEN = 1024
        batch_size = 8
        data = load_data('sfzy_seq2seq.json')
        fold = 0
        num_folds = 100
        train_data = data_split(data, fold, num_folds, 'train')
        valid_data = data_split(data, fold, num_folds, 'valid')
        train_generator = data_generator(train_data,batch_size,MAX_LEN,tokenizer)
        model,model_pred = build_model(pretrained_path,config,MAX_LEN,vocab_size,keep_tokens)
        autotitle = AutoTitle(start_id=None, end_id=new_token_dict['[SEP]'],maxlen=512,model=model_pred)
        evaluator = Evaluator(valid_data,autotitle,tokenizer,MAX_LEN)
        epochs = 50
        model.fit_generator(train_generator.forfit(),steps_per_epoch=len(train_generator),epochs=epochs,callbacks=[evaluator])
    

    总结

    通过相对位置编码,我可以创建输入长度为1024的模型了!但是由于attention的范围从512增加到了1024,整个内存的计算量从n 变成了n2。也导致我的小显卡根本跑不动,有条件的朋友可以尝试一下~


    参考资料

    [1] Self-Attention with Relative Position Representations
    [2] NEZHA: NEURAL CONTEXTUALIZED REPRESENTATION FOR CHINESE LANGUAGE UNDERSTANDING
    [3] 苏剑林. (Jan. 01, 2021). 《SPACES:“抽取-生成”式长文本摘要(法研杯总结) 》[Blog post]. Retrieved from https://www.kexue.fm/archives/8046


    代码地址

    https://github.com/zhengyanzhao1997/TF-NLP-model/tree/main/model/train/NEZHA

    展开全文
  • 并对其进了优化(详情见改良后的层叠式指针网络,让我的模型F1提升近4%),因此这次把文中提到的多头选择和Biaffine关系矩阵构建的原论文拿出来研究了一下,并根据实际的关系抽取任务做了复现和改良。 本文主要涉及...


    前言

    最近在磕一人之力,刷爆三路榜单!信息抽取竞赛夺冠经验分享这篇文章,文章对关系抽取任务的解码方式做了简单的概述,在之前的文章中本人已经实现了指针标注网络,并对其进了优化(详情见改良后的层叠式指针网络,让我的模型F1提升近4%),因此这次把文中提到的多头选择和Biaffine关系矩阵构建的原论文拿出来研究了一下,并根据实际的关系抽取任务做了复现和改良。

    本文主要涉及以下论文:

    多头选择与关系抽取:
    Joint entity recognition and relation extraction as a multi-head selection problem

    Bert版多头选择:
    BERT-Based Multi-Head Selection for Joint Entity-Relation Extraction

    双仿射注意力机制:
    Deep Biaffine Attention for Neural Dependency Parsing(基于深层双仿射注意力的神经网络依存解析)

    新论文,采取Biaffine机制构造Span矩阵:
    Named Entity Recognition as Dependency Parsing

    本文只是记录自己的思考和实现,只对每篇论文核心部分做简单理解,可能有错误,感兴趣的同学建议直接看原文。


    Multi-head Selection

    一、Joint entity recognition and relation extraction as a multi-head selection problem

    该网络结构将实体识别和关系抽取 joint 在一起,先通过在隐藏层上连接CRF,来抽取实体标签,并将实体标签信息embedding后与隐藏层一起传递给sigmoid_layer,来与其他实体特征进行交互抽取关系。
    实体抽取部分比较好理解,对于多头选择部分,以下原文的几条核心公式:

    理解如下:
    当Token(j)为Subject的End 且 Token(i)为Object的End的概率分数为

    非常简明的意思是:将Token(j)和Token(i)的Z(隐藏层➕label embedding)分别经过U,W线性变换后相加再加上偏置b,最后再进行一次整体线性变换V,得到的值经过sigmoid函数后即转换为对应的概率分数。
    别急!后文会讨论多类别关系时,各个矩阵的维度关系。

    二、BERT-Based Multi-Head Selection for Joint Entity-Relation Extraction

    Bert + Multi-head Selection,除了引入了Bert作为编码层外,其他创新点如下:

    1. 考虑在训练时我们可以通过传入真实的实体标签的embedding给Multi-head Selection层,但在模型推理时,为了利用CRF的softmax在各个标签上产生的分值信息和考虑到推理时可能产生的错误标签结果,作者将softmax结果与各个标签的embedding进行加权后传给Multi-head Selection层。
    1. 引入句子级关系分类任务来指导特征学习,如图中的用CLS来获得稳定的维度特征。(关于这一改进我并没有进行尝试,还不是因为没有数据!因此持有怀疑态度,原本就将两个任务的解码压力放在一组encoder上,现在又增加了句子分类任务。这不会加大模型压力吗?文中给出的实验结果表明,单独增加Global predicate Prediction并没有带来明显的提升,而组合各种策略能带来的较高提升不一定是该方法的贡献。)
    1. Soft label embedding of the ith token hi is feed into two separate fully connected layers to get the subject representation hsi and object representation hoi. where f(·) means neural network, ri,j is the relation when the ith token is subject and the jth token is object, rj,i is the relation when the jth token is subject and the ith token is object.(论文原文,多头选择方法与上文相同,将构建好的token特征通过两个不同的全连接层后经过一个F网络输出两者的关系分值)

    三、实现方法和模型代码展示

    以三元关系抽取任务为例,我们多头选择该如何更好的理解和应用?功夫不负有心人我找大了夕大姐文章里一张图虽然和多头选择没多少关系,但能比较形象的展示Multi-head Selection的流程:

    对于一个句子中的所有的token形成的SO组合,一共有N2种。假设我们的Token都已经经过了线性变化或者全连接层的洗礼,如图中对于每一个City,我们将其作为S,我们应该考虑其他所有token是否能和它形成SO关系,所以我们要计算每一个token和city经过V变换后的分数。

    具体实现上也非常简单,我们可以构建一个NNHidden_size的token组合矩阵,经过一个P_num(关系类别总数)的Dense层后即可得到各个token之间在各个关系上的分值。

    Subject = tf.keras.layers.Dense(hidden_size)(Encode)
    Object = tf.keras.layers.Dense(hidden_size)(Encode)
    '''
    将原token的encode经过两个不同的全连接层后,得到Subject,Object两个token序列
    对应公式中的 U*zj 和 W*zi
    '''
    Subject = tf.expand_dims(Subject,1)
    #TensorShape([batch_size, 1, MAX_LEN, hidden_size])
    Object = tf.expand_dims(Object,2)
    #TensorShape([batch_size, MAX_LEN, 1, hidden_size])
    Subject = tf.tile(Subject,multiples=(1,MAX_LEN,1,1))
    #TensorShape([batch_size, MAX_LEN, MAX_LEN, hidden_size])
    Object = tf.tile(Object,multiples=(1,1,MAX_LEN,1))
    #TensorShape([batch_size, MAX_LEN, MAX_LEN, hidden_size])
    concat_SO = tf.keras.layers.Concatenate(axis=-1)([Subject,Object])
    #TensorShape([batch_size, MAX_LEN, MAX_LEN, 2*hidden_size])
    output_logist = tf.keras.layers.Dense(P_num,activation='sigmoid')(concat_SO)
    #TensorShape([batch_size, MAX_LEN, MAX_LEN, P_num])
    '''
    将组合后的 U*zj 与 W*zi 经过一个V全连接层,V.shape = (2*hidden_size,P_num)
    对应公式中的 V*U*zj + V*W*zi = V(U*zj + W*zj + b)
    '''
    

    样本构建部分: 我们需要将一个标注好的[ MAX_LEN, MAX_LEN, P_num ]的矩阵作为Multi-Head Selection 结果的 Y值。

    关于样本标注问题:

    1. 一个实体包含多个字符且可能存在实体嵌套的问题,如“我是歌手的主演是阿瓜”,需要抽取的关系为“我是歌手的主演是阿瓜”、“阿瓜是歌手”。

    2. 我们并不需要将“我是歌手”的四个token和“阿瓜”的两个token在P=主演的得分上全都标注为1,因为在实体抽取部分我们对实体的头和尾进行了识别,我们仅需要将 S“手”和O“瓜”所对应的P=主演的分值标注为1即可。本例中即 [3,9,indenx of 主演] = 1即可。如果阿瓜还是个歌手,则 [9,3,indenx of 职业] = 1即可,因为对于不同类型的嵌套的实体,可能存在尾字符相同的情况极少,因此我们标注尾字符而不是首字符。

    以上就是Multi-head Selection Model部分的核心思路和代码。

    完整模型代码如下:

    1. 用bert代替了原文的LSTM编码层。
    2. 这里将CRF替换为指针标注,并引入了实体的类别信息。
    3. 将实体的硬标签与实体的end_token拼接后传入Multi-head Selection层,这也是本人灵光一闪的部分,既然在Multi-head Selection层我们希望model能识别S的end_token和O的end_token, 我们就只给这两个token传入有效的实体标签信息,其余token类别都编码为0即可,实验正面这确实比你对所有的SOtoken都传入对应的实体类别embedding效果更好。
    4. 没有使用上文的软标签,软标签的具体实现可以通过自定义 layer 实现。
    def build_model(pretrained_path,config,MAX_LEN,Cs_num,cs_em_size,R_num):
        ids = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        att = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        cs = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        
        config.output_hidden_states = True
        bert_model = TFBertModel.from_pretrained(pretrained_path,config=config,from_pt=True)
        x, _, hidden_states = bert_model(ids,attention_mask=att)
        layer_1 = hidden_states[-1]
        
        start_logits = tf.keras.layers.Dense(Cs_num,activation = 'sigmoid')(layer_1)
        start_logits = tf.keras.layers.Lambda(lambda x: x**2,name='s_start')(start_logits)
        
        end_logits = tf.keras.layers.Dense(Cs_num,activation = 'sigmoid')(layer_1)
        end_logits = tf.keras.layers.Lambda(lambda x: x**2,name='s_end')(end_logits)
        
        cs_emb = tf.keras.layers.Embedding(Cs_num,cs_em_size)(cs)
        concat_cs = tf.keras.layers.Concatenate(axis=-1)([layer_1,cs_emb])
        
        f1 = tf.keras.layers.Dense(128)(concat_cs)
        f2 = tf.keras.layers.Dense(128)(concat_cs)
        
        f1 = tf.expand_dims(f1,1)
        f2 = tf.expand_dims(f2,2)
    
        f1 = tf.tile(f1,multiples=(1,MAX_LEN,1,1))
        f2 = tf.tile(f2,multiples=(1,1,MAX_LEN,1))
        
        concat_f = tf.keras.layers.Concatenate(axis=-1)([f1,f2])
        output_logist = tf.keras.layers.Dense(128,activation='relu')(concat_f)
        output_logist = tf.keras.layers.Dense(R_num,activation='sigmoid')(output_logist)
        output_logist = tf.keras.layers.Lambda(lambda x: x**4,name='relation')(output_logist)
    
        model = tf.keras.models.Model(inputs=[ids,att,cs], outputs=[start_logits,end_logits,output_logist])
        model_2 = tf.keras.models.Model(inputs=[ids,att], outputs=[start_logits,end_logits])
        model_3 = tf.keras.models.Model(inputs=[ids,att,cs], outputs=[output_logist])
        return model,model_2,model_3
    

    模型示意图之丑陋手稿:看个乐哈~:

    模型效果:
    没有做其他任何的处理,在2019百度三元抽取数据集上F1就达到了 79.157,而且模型结构相比于层叠序列标注简单了许多,理解起来也更加到胃~


    Deep Biaffine Attention

    一、Deep Biaffine Attention for Neural Dependency Parsing

    这篇文章主要通过Biaffine来应用于依存关系分析上,但这也正好和关系抽取共通,只是依存关系中的关系类别只有一种,而在关系抽取中存在多种关系分类。

    文章使用了双仿射注意力机制,而不是使用传统基于MLP注意力机制的单仿射分类器,或双线性分类器;上文提到的Multi-Head Selection正是由多个线性分类器构成的关系分类器。而现在我们希望能通过构建一个Biaffine Attention矩阵直接计算各个token之间在某个关系分类上的attention。

    (这里直接将关系依存中的head 和 dep 称为 关系抽取中的S和O

    1. 将BiLSTM编码的token hidden 经过两个MLP 得到 S 和 O的特征表示;
    2. 对于这一步文章中特地提到:“Applying smaller MLPs to the recurrent output states before the biaffine classifier has the advantage of stripping away information not relevant to the current decision. That is, every top recurrent state ri will need to carry enough information to identify word i’s head, find all its dependents, exclude all its non-dependents, assign itself the correct label, and assign all its dependents their correct labels, as well as transfer any relevant information to the recurrent states of words before and after it. Thus ri necessarily contains significantly more information than is needed to compute any individual score, and training on this superfluous information needlessly reduces parsing speed and increases the risk of overfitting. Reducing dimensionality and applying a nonlinearity addresses both of these problems.”
    3. LSTM层的输出状态需要携带足够的信息,如识别其头结点,找到其依赖项,排除非依赖项,分配自身及其所有依赖的依存标签,而且还需要把其它任何相关信息传递至前或后单元。对这些不必要的信息进行训练会降低训练速度,而且还有过拟合的风险。使用MLP对LSTM输出降维,并使用双仿射变换,可解决这一问题!
    4. 简单来说我们希望能通过将原本高维度富含丰富信息的 hidden state 通过MLP降为至只能容下关系依赖信息的低纬度的特征,一方面加速训练,另一方面可以抑制过拟合。
    1. 最终我通过构建一个U(Biaffine)矩阵来计算各个token之间依存的分值,并引入u矩阵来计算head的先验概率并产生偏置b。我们设token的长度为d,经过MLP压缩后的hidden_size为k,以下是我丑陋手稿来解释矩阵的乘法维度变化。

    Biaffine实现代码:

    class Biaffine(tf.keras.layers.Layer):
        def __init__(self, in_size, out_size, bias_x=False, bias_y=False):
            super(Biaffine, self).__init__()
            self.bias_x = bias_x
            self.bias_y = bias_y
            self.U = self.add_weight(
                name='weight1', shape=(in_size + int(bias_x), 
                out_size, in_size + int(bias_y)),trainable=True)
            #U.shape = [in_size,out_size,in_size]
        def call(self, input1, input2):
            if self.bias_x:
                input1 = tf.concat((input1, tf.ones_like(input1[..., :1])), axis=-1)
            if self.bias_y:
                input2 = tf.concat((input2, tf.ones_like(input2[..., :1])), axis=-1)
            # bxi,oij,byj->boxy
            logits_1 = tf.einsum('bxi,ioj,byj->bxyo', input1, self.U, input2)
            return logits_1
    

    完整模型代码:

    def build_model(pretrained_path,config,MAX_LEN,Cs_num,cs_em_size,R_num):
        ids = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        att = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        cs = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        
        config.output_hidden_states = True
        bert_model = TFBertModel.from_pretrained(pretrained_path,config=config,from_pt=True)
        x, _, hidden_states = bert_model(ids,attention_mask=att)
        layer_1 = hidden_states[-1]
        
        start_logits = tf.keras.layers.Dense(Cs_num,activation = 'sigmoid')(layer_1)
        start_logits = tf.keras.layers.Lambda(lambda x: x**2,name='s_start')(start_logits)
        
        end_logits = tf.keras.layers.Dense(Cs_num,activation = 'sigmoid')(layer_1)
        end_logits = tf.keras.layers.Lambda(lambda x: x**2,name='s_end')(end_logits)
        
        cs_emb = tf.keras.layers.Embedding(Cs_num,cs_em_size)(cs)
        concat_cs = tf.keras.layers.Concatenate(axis=-1)([layer_1,cs_emb])
        
        f1 = tf.keras.layers.Dense(128,activation='relu')(concat_cs)
        f2 = tf.keras.layers.Dense(128,activation='relu')(concat_cs)
        
        Biaffine_layer = Biaffine(128,R_num,bias_y=True)
        output_logist = Biaffine_layer(f1,f2)
        output_logist = tf.keras.layers.Activation('sigmoid')(output_logist)
        output_logist = tf.keras.layers.Lambda(lambda x: x**4,name='relation')(output_logist)
        
        model = tf.keras.models.Model(inputs=[ids,att,cs], outputs=[start_logits,end_logits,output_logist])
        model_2 = tf.keras.models.Model(inputs=[ids,att], outputs=[start_logits,end_logits])
        model_3 = tf.keras.models.Model(inputs=[ids,att,cs], outputs=[output_logist])
        return model,model_2,model_3
    

    模型效果:
    F1 = 0.7964 相比Multi-head Selection F1提高了0.05左右

    二、Named Entity Recognition as Dependency Parsing

    1. After obtaining the word representations from the BiLSTM, we apply two separate FFNNs to create different representations (hs/he) for the start/end of the spans. Using different representations for the start/end of the spans allow the system to learn to identify the start/end of the spans separately. This improves accuracy compared to the model which directly uses the outputs of the LSTM since the context of the start and end of the entity are different. Finally, we employ a biaffine model over the sentence to create a l×l×c scoring tensor(rm), where l is the length of the sentence and c is the number of NER categories + 1(for non-entity). We compute the score for a span i by:

      where si and ei are the start and end indices of the span i, Um is a d × c × d tensor, Wm is a 2d × c matrix and bm is the bias.
    2. 原文在BERT、fastText & Char Embeddings提取特征的基础上,通过BiLSTM捕获word representations后,同样使用两组全连接层来表示实体的头和尾,这比直接使用encode结果后直接输出实体的头和尾来说更加准确,毕竟两者所表示的信息是不同的。
    3. 之后将这两组特征丢入我们的主角:Biaffine矩阵。这个任务中我们不仅要识别实体的头和尾,还要识别出实体的类别C,因此我们目标是得到一个LLC的结果矩阵(其中L为序列长度,C为实体类别数目)
    4. 重点关注公式,其中Um、Wm、bm的shape及其表示意义如下:
      1. Um:tensor shape of dcd: 对hs(i)为头he(i)为尾的实体类别后验概率建模
      2. Wm:tensor shape of 2d*c: 对hs(i) 或 he(i)为尾的实体类别的后验概率分别建模
      3. bm:tensor shape of c: 对实体类别c的先验概率建模
      4. 对于头为hs(i),尾为he(i),类别为C的实体其概率分值为
      5. P(头为hs(i)&尾为he(i),C) + P(头为hs(i),C) + P(尾为he(i),C) + P( C )
    5. 根据公式我们可以构建新的Biaffine矩阵代码:
    class Biaffine_2(tf.keras.layers.Layer):
        def __init__(self, in_size, out_size,MAX_LEN):
            super(Biaffine_2, self).__init__()
            self.w1 = self.add_weight(
                name='weight1', 
                shape=(in_size, out_size, in_size),trainable=True)
            self.w2 = self.add_weight(
                name='weight2', 
                shape=(2*in_size + 1, out_size),trainable=True)
            self.MAX_LEN = MAX_LEN
        def call(self, input1, input2):
            f1 = tf.expand_dims(input1,2)
            f2 = tf.expand_dims(input2,1)
            f1 = tf.tile(f1,multiples=(1,1,self.MAX_LEN,1))
            f2 = tf.tile(f2,multiples=(1,self.MAX_LEN,1,1))
            concat_f1f2 = tf.concat((f1,f2),axis=-1)
            concat_f1f2 = tf.concat((concat_f1f2,tf.ones_like(concat_f1f2[..., :1])), axis=-1)
            logits_1 = tf.einsum('bxi,ioj,byj->bxyo', input1, self.w1, input2)
            logits_2 = tf.einsum('bijy,yo->bijo',concat_f1f2,self.w2)
            return logits_1+logits_2 
    

    尝试1

    目前尝试中能拿到最好效果的模型方案(持续更新中):

    1. 实体标签作为比较强的特征,取得S和O的实体标签基本可以判断两者的关系,因此将标签直接引入Biaffine矩阵。
    2. 将BERT最后两层编码进⾏Biaffine计算,得到关系矩阵。
    3. 给实体抽取层增加了一层全连接,对实体抽取和关系抽取两个任务做适当的分离。
    def build_model_3(pretrained_path,config,MAX_LEN,Cs_num,cs_em_size,R_num):
        ids = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        att = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        cs = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        
        config.output_hidden_states = True
        bert_model = TFBertModel.from_pretrained(pretrained_path,config=config,from_pt=True)
        x, _, hidden_states = bert_model(ids,attention_mask=att)
        layer_1 = hidden_states[-1]
        layer_2 = hidden_states[-2]
        
        
        start_logits = tf.keras.layers.Dense(256,activation = 'relu')(layer_1)
        start_logits = tf.keras.layers.Dense(Cs_num,activation = 'sigmoid')(start_logits)
        start_logits = tf.keras.layers.Lambda(lambda x: x**2,name='s_start')(start_logits)
        
        end_logits = tf.keras.layers.Dense(256,activation = 'relu')(layer_1)
        end_logits = tf.keras.layers.Dense(Cs_num,activation = 'sigmoid')(end_logits)
        end_logits = tf.keras.layers.Lambda(lambda x: x**2,name='s_end')(end_logits)
        
        cs_emb = tf.keras.layers.Embedding(Cs_num,cs_em_size)(cs)
        
        concat_cs = tf.keras.layers.Concatenate(axis=-1)([layer_1,layer_2])
        
        f1 = tf.keras.layers.Dropout(0.2)(concat_cs)
        f1 = tf.keras.layers.Dense(256,activation='relu')(f1)
        f1 = tf.keras.layers.Dense(128,activation='relu')(f1)
        f1 = tf.keras.layers.Concatenate(axis=-1)([f1,cs_emb])
        
        f2 = tf.keras.layers.Dropout(0.2)(concat_cs)
        f2 = tf.keras.layers.Dense(256,activation='relu')(f2)
        f2 = tf.keras.layers.Dense(128,activation='relu')(f2)
        f2 = tf.keras.layers.Concatenate(axis=-1)([f2,cs_emb])
        
        Biaffine_layer = Biaffine_2(128+cs_em_size,R_num,MAX_LEN)
        output_logist = Biaffine_layer(f1,f2)
        output_logist = tf.keras.layers.Activation('sigmoid')(output_logist)
        output_logist = tf.keras.layers.Lambda(lambda x: x**4,name='relation')(output_logist)
        
        model = tf.keras.models.Model(inputs=[ids,att,cs], outputs=[start_logits,end_logits,output_logist])
        model_2 = tf.keras.models.Model(inputs=[ids,att], outputs=[start_logits,end_logits])
        model_3 = tf.keras.models.Model(inputs=[ids,att,cs], outputs=[output_logist])
        return model,model_2,model_3
    

    F1 = 0.7986 比baseline提高了 0.022

    尝试2

    1. 实体抽取部分用Biaffine矩阵代替序列标注,softmax激活后输出
    2. 并用bert后两层隐藏层同时编码实体和关系矩阵
    def build_model(pretrained_path,config,MAX_LEN,Cs_num,cs_em_size,R_num):
        ids = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        att = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        cs = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)
        
        config.output_hidden_states = True
        bert_model = TFBertModel.from_pretrained(pretrained_path,config=config,from_pt=True)
        x, pooling, hidden_states = bert_model(ids,attention_mask=att)
        layer_1 = hidden_states[-1]
        layer_2 = hidden_states[-2]
        
        concat_cs = tf.keras.layers.Concatenate(axis=-1)([layer_1,layer_2])
        
        start_logits = tf.keras.layers.Dense(128,activation = 'relu')(concat_cs) 
        end_logits = tf.keras.layers.Dense(128,activation = 'relu')(concat_cs)
        
        S_Biaffine_layer = Biaffine_2(128,Cs_num,MAX_LEN)
        S_logits = S_Biaffine_layer(start_logits,end_logits)
        
        S_output_logist = tf.keras.layers.Activation('softmax',name='s_token')(S_logits)
        
        S_logits = tf.keras.layers.Dense(256,activation = 'relu')(concat_cs)
        S_logits = tf.keras.layers.Dropout(0.2)(S_logits)
        S_logits = tf.keras.layers.Dense(128,activation = 'relu')(S_logits)
        
        O_logits = tf.keras.layers.Dense(256,activation = 'relu')(concat_cs)
        O_logits = tf.keras.layers.Dropout(0.2)(O_logits)
        O_logits = tf.keras.layers.Dense(128,activation = 'relu')(O_logits)
    
        cs_emb = tf.keras.layers.Embedding(Cs_num,cs_em_size)(cs)
    
        f1 = tf.keras.layers.Concatenate(axis=-1)([S_logits,cs_emb])
        f2 = tf.keras.layers.Concatenate(axis=-1)([O_logits,cs_emb])
        
        Biaffine_layer = Biaffine_2(128+cs_em_size,R_num,MAX_LEN)
        output_logist = Biaffine_layer(f1,f2)
    
        output_logist = tf.keras.layers.Activation('sigmoid')(output_logist)
        output_logist = tf.keras.layers.Lambda(lambda x: x**4,name='relation')(output_logist)
        
        model = tf.keras.models.Model(inputs=[ids,att,cs], outputs=[S_output_logist,output_logist])
        model_2 = tf.keras.models.Model(inputs=[ids,att], outputs=[S_output_logist])
        model_3 = tf.keras.models.Model(inputs=[ids,att,cs], outputs=[output_logist])
        return model,model_2,model_3
    

    F1值:0.8016


    Biaffine 和 Multi-head对比

    1. 同样的计算开销:N^2
    2. 更多的参数:Baffine attention 矩阵拥有更多的参数,且相比于Muti-head Selection 能捕捉到S和O特征之间的交叉关系,而Muti-head Selection则是通过简单的MLP线性变化进行组合。丑陋手稿如下:
    3. 加入了start和end单独的先验概率
    4. 各模型结果对比

      biaffine + biaffine 8 0.8016

    总结

    1. 在关系抽取的任务中,baseline选择 Biaffine 会优于 基本的Multi-head,在这个基础上模型还有很多可以优化的地方。比如 如何更好的构造Biaffine矩阵,如何产生更有效的信息,但不至于过拟合。在我的实验过程中,Biaffine方法一直无法超越之前改良后的层叠式指针网络,这让我非常郁闷(主要是和别人实验结果不同),排除batch_size太小,模型收敛性较差的原因之外,根据模型的训练情况判断可能原因是在抽取实体时要求模型辨别实体的类别,导致该任务收敛性较差,试了多种网络较难拟合,也可能和数据集相关。使用上还存在一定的问题,会持续关注。

    2. 其实学术界已经对这种共享编码的效果提出了质疑,也有不少实验证明,实体抽取和关系抽取这两个任务在独立编码的情况下效果要好于共享编码。Two are Better than One: Joint Entity and Relation Extraction with Table-Sequence Encoders

    3. 本文并没有尝试用CRF来抽取实体,而是直接使用了指针标注的方法,只是收到了大部分实体抽取文章的影响(默认指针标注优于CRF)之后会对这两种方法进行对比。

    4. 关系抽取的系列文章可能要到此告一段落,之后看到这方面重要的论文也会第一时间和大家分享~

    展开全文
  • Google DeepMind 团队在 NIPS 2015 发表了一篇题为“Teaching Machines to Read and Comprehend” 的论文 [1]。这篇论文想解决的问题是:如何让计算机回答提问。具体来说,输入一篇文章(d)和一句提问(q),输出...
  • 个人介绍:大佬们好,我叫杨夕,该项目主要是本人在研读顶会论文复现经典论文过程中,所见、所思、所想、所闻,可能存在一些理解错误,希望大佬们多多指正。 NLP 面经地址: 目录 【关于 信息抽取】 那些的你不...
  • 论文复现】使用CNN进行文本分类

    千次阅读 热门讨论 2018-11-11 10:55:00
    跟着大牛们的论文复现代码,然后一点一点学习吧。嗯 今天要写的是关于NLP领域的一个关键问题:文本分类。 相对应的论文是:Convolutional Neural Networks for Sentence Classification 全部的代码github:text ...
  • NLP的同学应该有注意到,当下google、facebook、阿里、百度、腾讯等都有自己的机器翻译团队。在2020年,阿里也提出要把大规模预训练模型在机器翻译中应用。不难看出,机器翻译是...
  • 论文复现】使用RCNN进行文本分类

    千次阅读 2018-11-26 12:56:31
    写在前面 昨天下午去面试了微信模式识别中心的NLP,被面试官问到自闭,菜是原罪... ...这也是今天这篇论文提出的原因之一吧。(虽然我觉得文中关于CNN的部分只是用了一个max-pooling,本质上并不...

空空如也

空空如也

1 2 3 4 5
收藏数 93
精华内容 37
关键字:

nlp论文复现