欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

指针发生器网络(指针发生器网络)原理与实践

最编程 2024-04-29 10:39:41
...
$$a^t = softmax(e^t)$$

得到的注意力权重和 $h_i$加权求和得到重要的上下文向量 $h_t^*(context vector)$:

$$h_{t}^{*} = \sum_{i}{a_i^t h_i}$$

$h_t^*$可以看成是该时间步通读了原文的固定尺寸的表征。然后将 $s_t$和 $h_t^*$ 经过两层线性层得到单词表分布 $P_{vocab}$:

$$P_{vocab} = softmax(V'(V[s_t, h_t^*] + b) + b')$$

其中 $[s_t, h_t^*]$是拼接。这样再通过$sofmax$得到了一个概率分布,就可以预测需要生成的词:

$$P(w) = P_{vocab}(w)$$

在训练阶段,时间步 $t$ 时的损失为: 

$$loss_{t} = -logP(w_t^*)$$

那么原输入序列的整体损失为: 

$$loss = \frac{1}{T} \sum_{t=0}^{T}loss_t$$

2 Pointer-Generator-Network

原文中的Pointer-Generator Networks是一个混合了 Baseline seq2seq和PointerNetwork的网络,它具有Baseline seq2seq的生成能力和PointerNetwork的Copy能力。该网络的结构如下:

如何权衡一个词应该是生成的还是复制的?

原文中引入了一个权重 $p_{gen}$ 。

从Baseline seq2seq的模型结构中得到了$s_t$ 和$h_t^*$,和解码器输入 $x_t$ 一起来计算 $p_{gen}$ :

$$p_{gen} = \sigma(w_{h^*}^T h_t^* + w_s^Ts_t + w_x^Tx_t + b_{ptr})$$

这时,会扩充单词表形成一个更大的单词表--扩充单词表(将原文当中的单词也加入到其中),该时间步的预测词概率为:

$$P(w) = p_{gen}P_{vocab}(w) + (1 - p_{gen}) \sum_{i:w_i=w} a_i^t$$

其中 $a_i^t$ 表示的是原文档中的词。我们可以看到解码器一个词的输出概率有其是否拷贝是否生成的概率和决定。当一个词不出现在常规的单词表上时 $P_{vocab}(w)$ 为0,当该词不出现在文档中$ \sum_{i:w_i=w} a_i^t$为0。

3  Coverage mechanism

原文的特色是运用了Coverage Mechanism来解决重复生成文本的问题,下图反映了前两个模型与添加了Coverage Mechanism生成摘要的结果:

蓝色的字体表示的是参考摘要,三个模型的生成摘要的结果差别挺大;

红色字体表明了不准确的摘要细节生成(UNK未登录词,无法解决OOV问题);

绿色的字体表明了模型生成了重复文本。

为了解决此问题--Repitition,原文使用了在机器翻译中解决“过翻译”和“漏翻译”的机制--Coverage Mechanism

具体实现上,就是将先前时间步的注意力权重加到一起得到所谓的覆盖向量 $c^t (coverage vector)$,用先前的注意力权重决策来影响当前注意力权重的决策,这样就避免在同一位置重复,从而避免重复生成文本。计算上,先计算coverage vector $c^t$:

$$c^t = \sum_{t'=0}^{t-1}a^{t'}$$

然后添加到注意力权重的计算过程中,$c^t$用来计算 $e_i^t$:

$$e_i^t = v^T tanh(W_{h}h_i + W_{s}s_t + w_{c}c_i^t + b_{attn})$$

同时,为coverage vector添加损失是必要的,coverage loss计算方式为:

$$covloss_{t} = \sum_{i}min(a_i^t, c_i^t)$$

这样coverage loss是一个有界的量  $covloss_t \leq \sum_{i}a_i^t = 1$ 。因此最终的LOSS为:

$$loss_t = -logP(w_t^*)  + \lambda \sum_{i}min(a_i^t, c_i^t)$$ 

4 实战部分

4.1 DataSet

英文数据集: cnn dailymail数据集,地址:https://github.com/becxer/cnn-dailymail/

中文数据集:新浪微博摘要数据集,这是中文数据集,有679898条文本及摘要。

中英文数据集均可从这里下载,链接:https://pan.baidu.com/s/18ykewFUrTLzW8R84bF42pg  密码:9yqt。

4.2 Experiments

试验环境:centos7.4/python3.6/tensorflow1.12.0  GPU:Tesla-K40m-12G*4   代码参考:python3 tensorflow版本。调试时候各种报错,所以需要debug。

改动后的代码已上传至GitHub:https://github.com/zingp/NLP/tree/master/P007PytorchPointerGeneratorNetwork

中文数据集预处理代码:

第一部分是对原始数据进行分词,划分训练集测试集,并保存文件。

import os
import sys
import time
import jieba

ARTICLE_FILE = "./data/weibo_news/train_text.txt"
SUMMARRY_FILE = "./data/weibo_news/train_label.txt"

TRAIN_FILE = "./data/weibo_news/train_art_summ_prep.txt"
VAL_FILE = "./data/weibo_news/val_art_summ_prep.txt"

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        r = func(*args, **kwargs)
        end = time.time()
        cost = end - start
        print(f"Cost time: {cost} s")
        return r
    return wrapper

@timer
def load_data(filename):
    """加载数据文件,对文本进行分词"""
    data_list = []
    with open(filename, 'r', encoding= 'utf-8') as f:
        for line in f:
            # jieba.enable_parallel()
            words = jieba.cut(line.strip())
            word_list = list(words)
            # jieba.disable_parallel()
            data_list.append(' '.join(word_list).strip())
    return data_list

def build_train_val(article_data, summary_data, train_num=600_000):
    """划分训练和验证数据"""
    train_list = []
    val_list = []
    n = 0
    for text, summ in zip(article_data, summary_data):
        n += 1
        if n <= train_num:
            train_list.append(text)
            train_list.append(summ)
        else:
            val_list.append(text)
            val_list.append(summ)
    return train_list, val_list

def save_file(filename, li):
    """预处理后的数据保存到文件"""
    with open(filename, 'w+', encoding='utf-8') as f:
        for item in li:
            f.write(item + '\n')
    print(f"Save {filename} ok.")

if __name__ == '__main__':
    article_data = load_data(ARTICLE_FILE)     # 大概耗时10分钟
    summary_data = load_data(SUMMARRY_FILE)
    TRAIN_SPLIT = 600_000
    train_list, val_list = build_train_val(article_data, summary_data, train_num=TRAIN_SPLIT)
    save_file(TRAIN_FILE, train_list)
    save_file(VAL_FILE, val_list) 

第二部分是将文件打包,生成模型能够加载的二进制文件。

import os
import struct
import collections
from tensorflow.core.example import example_pb2

# 经过分词处理后的训练数据与测试数据文件
TRAIN_FILE = "./data/weibo_news/train_art_summ_prep.txt"
VAL_FILE = "./data/weibo_news/val_art_summ_prep.txt"

# 文本起始与结束标志
SENTENCE_START = '<s>'
SENTENCE_END = '</s>'

VOCAB_SIZE = 50_000  # 词汇表大小
CHUNK_SIZE = 1000    # 每个分块example的数量,用于分块的数据

# tf模型数据文件存放目录
FINISHED_FILE_DIR = './data/weibo_news/finished_files'
CHUNKS_DIR = os.path.join(FINISHED_FILE_DIR, 'chunked')

def chunk_file(finished_files_dir, chunks_dir, name, chunk_size):
    """构建二进制文件"""
    in_file = os.path.join(finished_files_dir, '%s.bin' % name)
    print(in_file)
    reader = open(in_file, "rb")
    chunk = 0
    finished = False
    while not finished:
        chunk_fname = os.path.join(chunks_dir, '%s_%03d.bin' % (name, chunk))  # 新的分块
        with open(chunk_fname, 'wb') as writer:
            for _ in range(chunk_size):
                len_bytes = reader.read(8)
                if not len_bytes:
                    finished = True
                    break
                str_len = struct.unpack('q', len_bytes)[0]
                example_str = struct.unpack('%ds' % str_len, reader.read(str_len))[0]
                writer.write(struct.pack('q', str_len))
                writer.write(struct.pack('%ds' % str_len, example_str))
            chunk += 1


def chunk_all():
    # 创建一个文件夹来保存分块
    if not os.path.isdir(CHUNKS_DIR):
        os.mkdir(CHUNKS_DIR)
    # 将数据分块
    for name in ['train', 'val']:
        print("Splitting %s data into chunks..." % name)
        chunk_file(FINISHED_FILE_DIR, CHUNKS_DIR, name, CHUNK_SIZE)
    print("Saved chunked data in %s" % CHUNKS_DIR)


def read_text_file(text_file):
    """从预处理好的文件中加载数据"""
    lines = []
    with open(text_file, "r", encoding='utf-8') as f:
        for line in f:
            lines.append(line.strip())
    return lines


def write_to_bin(input_file, out_file, makevocab=False):
    """生成模型需要的文件"""
    if makevocab:
        vocab_counter = collections.Counter()

    with open(out_file, 'wb') as writer:
        # 读取输入的文本文件,使偶数行成为article,奇数行成为abstract(行号从0开始)
        lines = read_text_file(input_file)
        for i, new_line in enumerate(lines):
            if i % 2 == 0:
                article = lines[i]
            if i % 2 != 0:
                abstract = "%s %s %s" % (SENTENCE_START, lines[i], SENTENCE_END)

                # 写入tf.Example
                tf_example = example_pb2.Example()
                tf_example.features.feature['article'].bytes_list.value.extend([bytes(article, encoding='utf-8')])
                tf_example.features.feature['abstract'].bytes_list.value.extend([bytes(abstract, encoding='utf-8')])
                tf_example_str = tf_example.SerializeToString()
                str_len = len(tf_example_str)
                writer.write(struct.pack('q', str_len))
                writer.write(struct.pack('%ds' % str_len, tf_example_str))

                # 如果可以,将词典写入文件
                if makevocab:
                    art_tokens = article.split(' ')
                    abs_tokens = abstract.split(' ')
                    abs_tokens = [t for t in abs_tokens if
                                  t not in [SENTENCE_START, SENTENCE_END]]  # 从词典中删除这些符号
                    tokens = art_tokens + abs_tokens
                    tokens = [t.strip() for t in tokens]     # 去掉句子开头结尾的空字符
                    tokens = [t for t in tokens if t != ""]  # 删除空行
                    vocab_counter.update(tokens)

    print("Finished writing file %s\n" % out_file)

    # 将词典写入文件
    if makevocab:
        print("Writing vocab file...")
        with open(os.path.join(FINISHED_FILE_DIR, "vocab"), 'w', encoding='utf-8') as writer:
            for word, count in vocab_counter.most_common(VOCAB_SIZE):
                writer.write(word + ' ' + str(count) + '\n')
        print("Finished writing vocab file")

if __name__ == '__main__':
    if not os.path.exists(FINISHED_FILE_DIR):
    os.makedirs(FINISHED_FILE_DIR)
    write_to_bin(VAL_FILE, os.path.join(FINISHED_FILE_DIR, "val.bin"))
    write_to_bin(TRAIN_FILE, os.path.join(FINISHED_FILE_DIR, "train.bin"), makevocab=True)
    chunk_all() 

在训练中文数据集的时候,设置的hidden_dim为 256 ,词向量维度emb_dim为126,词汇表数目vocab_size为50K,batch_size设为16。这里由于我们的模型有处理OOV能力,因此词汇表不用设置过大;在batch_size的选择上,显存小的同学建议设为8,否则会出现内存不够,难以训练。

在batch_size=16时,训练了27k step, 出现loss震荡很难收敛的情况,train阶段loss如下:

val阶段loss如下:

可以看到当step在10k之后,loss在3.0-5.0之间来回剧烈震荡,并没有下降趋势。前面我们为了省显存,将batch_size设置成16,可能有点小了,梯度下降方向不太明确,显得有点盲目,因此将batch_size设成了32后重新开始训练。注意:在一定范围内,batchsize越大,计算得到的梯度下降方向就越准,引起训练震荡越小。增大batch_size后训练的loss曲线如下:

val loss曲线如下:

看起来loss还是比较震荡的,但是相比bathc_size=16时有所改善。一开始的前10K steps里loss下降还是很明显的基本上能从6降到4左右的区间,10k steps之后开始震荡,但还是能看到在缓慢下降:从4左右,开始在2-4之间震荡下降。这可能是目前的steps还比较少,只要val loss没有一直升高,可以继续观擦,如果500K steps都还是如此,可以考虑在一个合适的实机early stop。

4.3 Evaluation

摘要质量评价需要考虑一下三点:

(1) 决定原始文本最重要的、需要保留的部分;

(2) 在自动文本摘要中识别出1中的部分;

(3) 基于语法和连贯性(coherence)评价摘要的可读性(readability)。

从这三点出发有人工评价和自动评价,本文只讨论一下更值得关注的自动评价。自动文档摘要评价方法分为两类:

内部评价方法(Intrinsic Methods):提供参考摘要,以参考摘要为基准评价系统摘要的质量。系统摘要与参考摘要越吻合, 质量越高。

外部评价方法(Extrinsic Methods):不提供参考摘要,利用文档摘要代替原文档执行某个文档相关的应用。

内部评价方法是最常使用的文摘评价方法,将系统生成的自动摘要与参考摘要采用一定的方法进行比较是目前最为常见的文摘评价模式。下面介绍内部评价方法是ROUGE(Recall-Oriented Understudy for Gisting Evaluation)。

ROUGE是2004年由ISI的Chin-Yew Lin提出的一种自动摘要评价方法,现被广泛应用于DUC(Document Understanding Conference)的摘要评测任务中。ROUGE基于摘要中n元词(n-gram)的共现信息来评价摘要,是一种面向n元词召回率的评价方法。基本思想为由多个专家分别生成人工摘要,构成标准摘要集,将系统生成的自动摘要与人工生成的标准摘要相对比,通过统计二者之间重叠的基本单元(n元语法、词序列和词对)的数目,来评价摘要的质量。通过与专家人工摘要的对比,提高评价系统的稳定性和健壮性。该方法现已成为摘要评价技术的通用标注之一。 ROUGE准则由一系列的评价方法组成,包括ROUGE-N(N=1、2、3、4,分别代表基于1元词到4元词的模型),ROUGE-L,ROUGE-S, ROUGE-W,ROUGE-SU等。在自动文摘相关研究中,一般根据自己的具体研究内容选择合适的ROUGE方法。公式如下: 

$$ROUGE-N = \frac{\sum_{S\in \left\{ ReferenceSummaries\right\} } \sum_{gram_n \in S}Count_{match}(gram_n)} {\sum_{S\in \left\{ ReferenceSummaries\right\} } \sum_{gram_n \in S}Count(gram_n)}$$

其中,$n-gram$表示n元词${Ref Summaries}$表示参考摘要(标准摘要)$Count_{match}(n-gram)$表示生成摘要和参考摘要中同时出现$n-gram$的个数$Count(n-gram)$则表示参考摘要中出现的$n- gram$个数ROUGE公式是由召回率的计算公式演变而来的,分子可以看作“检出的相关文档数目”,即系统生成摘要与标准摘要相匹配的$n-gram$个数,分母可以看作“相关文档数目”,即参考摘要中所有的$n-gram$个数。

来看原文试验结果:

 在上表中,上半部分是模型生成的的摘要评估,而下半部分的是提取摘要评估。可以看出抽象生成的效果接近了抽取效果。再来看重复情况:

可以看出我们的no coverage的模型生成的摘要在n-gram上是要比reference摘要要多的,而使用了coverage之后,重复数目和reference相当。