NLP模型(四)——seq2seq模型与Attention机制实现

在这篇文章里,我们准备实现一个带有注意力机制的seq2seq模型,seq2seq模型的应用场景广为人知的当属语言翻译了,但是,由于语言翻译训练时间太过漫长,模型结构太过庞大,这里我们使用B站中的一个视频中讲解的案例来对模型进行实现。

视频链接:https://www.bilibili.com/video/BV1LD4y1P7nK?p=90

该视频讲解的案例是对时间的表示进行格式化输出,如2023年1月1日有的地方表示为 2023/1/1,有的地方表示为2023.1.1,格式并不统一,十分不利于时间结构的统一,下面这个例子将对不同的时间表示格式进行训练,统一为 2023-1-1的形式,且因为这个案例的数据短小,训练的耗时也不会太长。

以下代码需要的环境需求为

keras==2.2.5

1. 制造数据集

由于在公开数据集上很难找到我们需要的时间格式的数据集,所以我们可以考虑使用 faker 库伪造数据集。

faker 库能够很轻松的帮助你伪造一些看起来很真实的数据集,这就免去了我们自己制造的数据集单调的困难。

from faker import Faker
import random
from tqdm import tqdm
from babel.dates import format_date

fake = Faker()
# 设置随机种子
Faker.seed(12345)
random.seed(12345)

# 定义想要生成的日期的格式
FORMATS = ['short',
           'medium',
           'long',
           'full',
           'full',
           'full',
           'full',
           'full',
           'full',
           'full',
           'full',
           'full',
           'full',
           'd MMM YYY',
           'd MMMM YYY',
           'dd MMM YYY',
           'd MMM, YYY',
           'd MMMM, YYY',
           'dd, MMM YYY',
           'd MM YY',
           'd MMMM YYY',
           'MMMM d YYY',
           'MMMM d, YYY',
           'dd.MM.YY']

# 定义生成的语言
LOCALES = ['en_US']


def load_date():
    """
        生成数据
        :returns: 返回生成的数据、对应的标准格式的数据、data object
    """
    dt = fake.date_object()

    try:
        human_readable = format_date(dt, format=random.choice(FORMATS),
                                     locale='en_US')
        human_readable = human_readable.lower()
        human_readable = human_readable.replace(',', '')
        # 将生成的数据化为标准格式
        machine_readable = dt.isoformat()

    except AttributeError as e:
        return None, None, None

    return human_readable, machine_readable, dt


def load_dataset(m):
    """
        生成m个日期数据
        :m: 生成的数据的数量
    """
    human_vocab = set()
    machine_vocab = set()
    dataset = []
    Tx = 30

    for i in tqdm(range(m)):
        h, m, _ = load_date()
        if h is not None:
            dataset.append((h, m))
            human_vocab.update(tuple(h))
            machine_vocab.update(tuple(m))

    # 将'<unk>', '<pad>'两个标签加入,'<unk>'表示未出现在字典中的词,'<pad>'表示占位
    human = dict(zip(sorted(human_vocab) + ['<unk>', '<pad>'],
                     list(range(len(human_vocab) + 2))))
    # 将dict化为item:num的形式
    inv_machine = dict(enumerate(sorted(machine_vocab)))
    machine = {v: k for k, v in inv_machine.items()}
    return dataset, human, machine

dataset, x_vocab, y_vocab = load_dataset(10000)

dataset 中是生成的数据和标准数据, x_vocab, y_vocab 存储的是生成数据和标准数据每个字符的字典,方便生成Onehot编码。

处理后的部分数据如下:

dataset[0]:
(‘9 may 1998’, ‘1998-05-09’)
x_vocab:
{’ ‘: 0, ‘.’: 1, ‘/’: 2, ‘0’: 3, ‘1’: 4, ‘2’: 5, ‘3’: 6, ‘4’: 7, ‘5’: 8, ‘6’: 9, ‘7’: 10, ‘8’: 11, ‘9’: 12, ‘a’: 13, ‘b’: 14, ‘c’: 15, ‘d’: 16, ‘e’: 17, ‘f’: 18, ‘g’: 19, ‘h’: 20, ‘i’: 21, ‘j’: 22, ‘l’: 23, ‘m’: 24, ‘n’: 25, ‘o’: 26, ‘p’: 27, ‘r’: 28, ‘s’: 29, ‘t’: 30, ‘u’: 31, ‘v’: 32, ‘w’: 33, ‘y’: 34, ‘<unk>’: 35, ‘<pad>’: 36}
y_vocab:
{’-': 0, ‘0’: 1, ‘1’: 2, ‘2’: 3, ‘3’: 4, ‘4’: 5, ‘5’: 6, ‘6’: 7, ‘7’: 8, ‘8’: 9, ‘9’: 10}

每一个元组的前面是生成的数据,后面是标准的数据,形式上还是难辨真假的。

2. 数据处理

在seq2seq模型的输入上面,我们可以输入Onehot编码,当然这是最简单的处理形式,复杂一点可以输入词嵌入的向量,这里我们采用Onehot编码的形式对数据进行处理,将每一条数据都处理成Onehot编码的格式。

在处理数据前,我们需要先定义Decoder和Encoder的最大长度,因为输入的数据的Onehot编码肯定必须与Encoder的长度一样长,输出的Onehot编码肯定必须与Decoder的Onehot编码一样长,这里,由于日期格式序列都不是很长,我们定义Encoder长度为30,Decoder长度为10即可。

def preprocess_data(dataset, human_vocab, machine_vocab, Tx, Ty):
    '''
    处理数据为Onehot编码格式
    :param dataset: 传入的生成数据与标准数据
    :param human_vocab: 生成数据的字典
    :param machine_vocab: 标准数据的字典
    :param Tx: Encoder的最大长度
    :param Ty: Decoder的最大长度
    :return: 编码后的XY以及Onehot后的XY
    '''
    X, Y = zip(*dataset)

    X = np.array([string_to_int(i, Tx, human_vocab) for i in X])
    Y = [string_to_int(t, Ty, machine_vocab) for t in Y]

    Xoh = np.array(list(map(lambda x: to_categorical(x, num_classes=len(human_vocab)), X)))
    Yoh = np.array(list(map(lambda x: to_categorical(x, num_classes=len(machine_vocab)), Y)))

    return X, np.array(Y), Xoh, Yoh


def string_to_int(string, length, vocab):
    """
    将每条数据按照字典对应的编码转为编码格式
    string -- 输入的字符串
    length -- 想要转为的编码的长度
    vocab -- 字符与数字对应的字典
    """

    # make lower to standardize
    string = string.lower()
    string = string.replace(',', '')
    # 如果大于这个长度就截断
    if len(string) > length:
        string = string[:length]
    # 填充字典里没有的字符为'<unk>'的编码
    rep = list(map(lambda x: vocab.get(x, '<unk>'), string))
    # 如果小于规定长度则用'<pad>'的编码进行填充
    if len(string) < length:
        rep += [vocab['<pad>']] * (length - len(string))

    return rep

X, Y, X_onehot, Y_onehot = preprocess_data(dataset, x_vocab, y_vocab, 30, 10)

输出部分数据后,可以看到处理后的数据的形式如下:

X[0]:
[12 0 24 13 34 0 4 12 12 11 36 36 36 36 36 36 36 36 36 36 36 36 36 36
36 36 36 36 36 36]
Y[0]:
[ 2 10 10 9 0 1 6 0 1 10]
X_onehot[0]:
[[0. 0. 0. … 0. 0. 0.]
[1. 0. 0. … 0. 0. 0.]
[0. 0. 0. … 0. 0. 0.]

[0. 0. 0. … 0. 0. 1.]
[0. 0. 0. … 0. 0. 1.]
[0. 0. 0. … 0. 0. 1.]]
Y_onehot[0]:
[[0. 0. 1. 0. 0. 0. 0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 1.]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 1.]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 1. 0.]
[1. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
[0. 1. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0. 0. 1. 0. 0. 0. 0.]
[1. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
[0. 1. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 1.]]

3. 构建模型

3.1 构建seq2seq模型

首先我们来搭建seq2seq模型,seq2seq模型由编码器和解码器构成,编码器和解码器我们都采用LSTM的结构。

LSTM的API为根据参数的不同返回值也有不同,主要影响返回值的参数有 return_sequences, return_state 两个布尔值的参数,两个参数根据布尔值的组合不同返回值也不同。

  1. return_sequences=False,return_state=False

    h = LSTM(X)
    

    默认情况就是两个都是False,这种情况下会返回最后一个时间步的隐藏状态。

  2. return_sequences = True, return_state = False

    lstm = LSTM(X,return_sequences = True, return_state = False)
    

    lstm 返回的是每个时间步的隐藏状态。

  3. return_sequences = False, return_state = True

    lstm, state_h, state_c = LSTM(X,return_sequences = False, return_state = True)
    

    接收三个返回值,lstm 是最后一个时间步的隐藏状态,state_hlstm 完全一样,state_c 是最后一个时间步的cell状态。

  4. return_sequences = True, return_state = True

    lstm, state_h, state_c = LSTM(X,return_sequences = True, return_state = True)
    

    lstm 是每个时间步的隐藏状态, state_h 是最后一个时间步的隐藏状态, state_c 是最后一个时间步的cell状态。

此外,由于解码器是对编码器产生的中间向量进行计算,所以我们在编码器上采用提取特征更好的双向LSTM结构,构建的seq2seq模型的层次如下:

from keras.layers import Bidirectional, Concatenate, Dot, Input, LSTM
from keras.layers import RepeatVector, Dense, Softmax

class Seq2seq(object):
    """Seq2seq进行日期格式翻译
    """
    def __init__(self, Tx=30, Ty=10, n_x=32, n_y=64):
        # 定义网络的相关参数
        self.model_param = {
            "Tx": Tx,  # 定义encoder序列最大长度
            "Ty": Ty,  # decoder序列最大长度
            "n_x": n_x,  # encoder的隐层输出值大小
            "n_y": n_y  # decoder的隐层输出值大小和cell输出值大小
        }

    def load_data(self, m):
        """
        获取m条数据
        """
        # 获取3个值:数据集,特征词的字典映射,目标词字典映射
        dataset, x_vocab, y_vocab = load_dataset(m)

        # 获取处理好的数据:特征x以及目标y的one_hot编码
        X, Y, X_onehot, Y_onehot = preprocess_data(dataset, x_vocab, y_vocab, self.model_param["Tx"], self.model_param["Ty"])

        # 添加特征词个不重复个数以及目标词的不重复个数
        self.model_param["x_vocab"] = x_vocab
        self.model_param["y_vocab"] = y_vocab

        self.model_param["x_vocab_size"] = len(x_vocab)
        self.model_param["y_vocab_size"] = len(y_vocab)

        return X_onehot, Y_onehot

    def get_encoder(self):
        """
        定义编码器结构
        :return:
        """
        # 指定隐层值输出的大小
        self.encoder = Bidirectional(LSTM(self.model_param["n_x"], return_sequences=True, name='bidirectional_1'),
                                     merge_mode='concat')

    def get_decoder(self):
        """
        定义解码器结构
        :return:
        """
        # 定义decoder结构,指定隐层值的形状大小,return_state=True
        self.decoder = LSTM(self.model_param["n_y"], return_state=True)

至此,seq2seq模型就搭建好了,之后,便是实现在seq2seq模型中的注意力机制了。

3.2 实现Attention

这里我们实现上一篇文章中介绍的软性注意力机制,其计算的过程如下所示。

设输入序列为 x = ( x 1 , x 2 , ⋯   , x n ) x=(x_1,x_2,\cdots,x_n) x=(x1,x2,,xn),输出序列为 y = ( y 1 , y 2 , ⋯   , y m ) y=(y_1,y_2,\cdots,y_m) y=(y1,y2,,ym),首先,将输入序列通过LSTM,得到最后一个状态的隐藏状态和输出状态,即
h n , c n = L S T M ( x , h n − 1 , c n − 1 ) h_n,c_n=LSTM(x,h_{n-1},c_{n-1}) hn,cn=LSTM(x,hn1,cn1)之后就是计算注意力的问题了。
s t s_t st 为解码器 t t t 时刻的隐层状态输出, h t h_t ht 为编码器 t t t 时刻的隐层状态输出,故计算解码器 i i i 时刻与编码器 j j j 时刻的权重计算函数为:
e i j = v T t a n h ( W 1 s i + W 2 h j ) e_{ij}=v^T tanh (W_1s_i+W_2h_j) eij=vTtanh(W1si+W2hj)权重系数表现如下:
α i k = e x p ( e i k ) ∑ l = 1 n e x p ( e i l ) , k = 1 , ⋯   , n \alpha_{ik}=\frac{exp({e_{ik}})}{\sum_{l=1}^{n}exp({e_{il}})},k=1,\cdots,n αik=l=1nexp(eil)exp(eik),k=1,,n接着计算加上了注意力权重后的隐层状态的加权向量 c i c_i ci
c i = ∑ j = 1 n α i j h j c_i = \sum_{j=1}^{n}\alpha_{ij}h_{j} ci=j=1nαijhj然后将 c i c_i ci 与上一时刻解码器的输出 s i − 1 s_{i-1} si1 结合并输入到LSTM中,最后将其输出到一个softmax网络结构中,输出概率最大的标签即可。

我们依然在Seq2seq模型的类中定义注意力机制的计算方式,

    def get_attention(self):
        """
        定义Attention的结构
        :return: attention结构
        """

        repeator = RepeatVector(self.model_param["Tx"])

        concatenator = Concatenate(axis=-1)

        densor1 = Dense(10, activation="tanh", name='Dense1')

        densor2 = Dense(1, activation="relu", name='Dense2')

        activator = Softmax(axis=1, name='attention_weights')

        dotor = Dot(axes=1)

        # 将结构存储在attention当中
        self.attention = {
            "repeator": repeator,
            "concatenator": concatenator,
            "densor1": densor1,
            "densor2": densor2,
            "activator": activator,
            "dotor": dotor
        }

    def computer_one_attention(self, a, s_prev):
        """
        利用定义好的attention结构计算中的alpha系数与a对应输出
        :param a:隐层状态值 (m, 30, 64)
        :param s_prev: LSTM的初始隐层状态值, 形状(batch, 64)
        :return: context
        """
        # 使用repeator扩大数据s_prev的维度为(sample, Tx, n_y),这样可以与a进行合并
        s_prev = self.attention["repeator"](s_prev) #[batchsize, 30, 64]

        # 将a和s_prev 按照最后一个维度进行合并计算
        concat = self.attention["concatenator"]([a, s_prev]) #[batchsize, 30, 128]

        # 使用densor1全连接层网络计算出e
        e = self.attention["densor1"](concat) #[batchsize, 30, 10]

        # 使用densor2增加relu激活函数计算
        energies = self.attention["densor2"](e) #[batchsize, 30, 1]

        # 使用"activator"的softmax函数计算权重"alphas"
        # 这样一个attention的系数计算完成
        alphas = self.attention["activator"](energies) #[batchsize, 30, 1]

        # 使用dotor,矩阵乘法,将 "alphas" and "a" 去计算context/c
        context = self.attention["dotor"]([alphas, a]) #[batchsize, 1, 64]

        return context

3.3 构造输出

seq2seq模型以及注意力机制的计算都已经完成后,就可以对模型的输出进行定义,模型的输出实际上就是让Decoder对输出字典中每个字符编码输出概率,然后取概率最大的那个字符编码为最终输出的字符编码,这涉及到概率,我们在最后一层加上一个softmax输出,对概率进行规整化,所以,输出的构造如下

    def get_output_layer(self):
        """
        定义输出层
        :return: output_layer
        """

        # 对decoder输出进行softmax,输出向量大小为y_vocab大小
        self.output_layer = Dense(self.model_param["y_vocab_size"], activation=Softmax(axis=1))

3.4 定义模型

到此,一个模型所需要的输主体、Attention机制以及输出都已经齐全了,接下来就需要将这些串联起来构成我们需要的模型了,在上节所讲的seq2seq模型的介绍里面,我们知道了解码器的结构有很多种,这里,我们采取第二种解码器的结构进行构建,即初始化Decoder的初始状态,并将中间向量输入到Decoder的每一个时间步中去,我们的模型定义如下:

    def model(self):
        """
        定义模型获取模型实例
        :param model_param: 网络的相关参数
        :param seq2seq:网络结构
        :return: model,Keras model instance
        """
        # 定义模型的输入 (30,)
        # 定义decoder中隐层初始状态值s0以及cell输出c0
        X = Input(shape=(self.model_param["Tx"], self.model_param["x_vocab_size"]), name='X')

		# 输入Decoder的初始状态
        s0 = Input(shape=(self.model_param["n_y"],), name='s0')
        c0 = Input(shape=(self.model_param["n_y"],), name='c0')
        s = s0
        c = c0

        # 定义装有输出值的列表
        outputs = []

        # 步骤1:定义encoder的双向LSTM结构得输出a
        a = self.encoder(X)

        # 步骤3:循环decoder的Ty次序列输入,获取decoder最后输出
        # 包括计算Attention输出
        for t in range(self.model_param["Ty"]):
            # 1: 定义decoder第t'时刻的注意力结构并输出context
            context = self.computer_one_attention(a, s)

            # 2: 对"context" vector输入到deocder当中
            # 获取cell的两个输出隐层状态和,initial_state= [previous hidden state, previous cell state]
            s, _, c = self.decoder(context, initial_state=[s, c])

            # 3: 应用 Dense layere获取deocder的t'时刻的输出
            out = self.output_layer(s)

            # 4: 将decoder中t'时刻的输出装入列表
            outputs.append(out)

        # 步骤 4: 创建model实例,定义输入输出
        model = Model(inputs=(X, s0, c0), outputs=outputs)

在这里,我们为什么要循环进行输出呢?因为我们需要预测的是一句话,在Decoder中,一句话最多的长度是10,那么,对于Encoder以及Attention送入的向量,我们都需要将其放入Decoder中进行预测,由于Decoder中每一句话都有10个长度,所以对其中每个位置的字符都要进行预测,也就是输出10个字符的预测,因此会循环10次。

而对于构建的模型,我们需要按照一定的顺序将各函数连接在一起,即定义一个初始化函数如下:

    def init_seq2seq(self):
        """
        初始化网络结构
        :return:
        """
        self.get_encoder()
        self.get_decoder()
        self.get_attention()
        self.get_output_layer()

至此,模型的初始化就完成了。

4. 模型训练

模型初始化后,就需要对模型进行训练了,训练时,我们需要将模型的输入数据进行输入,从上面的模型可知,我们的输入包含训练数据的Onehot编码以及Decoder的初始状态,这里我们就定义初始状态为0即可。

需要注意的是模型的目标值,由于在上面模型定义时,由于Decoder的最长序列是10,所以我们输出是连续输出10个位置中每个位置的预测,故这里的输出相当于将每个位置的输出都进行分开预测,因此,我们将目标值输入时也需要按照这样的格式进行输入,才能够计算损失。由于输出是十个位置,所以我们放入模型的目标值也必须是十个位置的,原来的目标值 Y_onehot 是一个 [10000,10,11] 维度的向量,最后的 11 是指映射到字典里的11个字符中,直接这样输出显然是不行的,因为这样相当于输出的位置有10000个,我们需要将第一维与第二维进行交换,将这个向量变为 [10,10000,11] 维度的向量,才能够对应上模型中定义的10个输出位置。

    def train(self, X_onehot, Y_onehot):
        """
        训练
        :param X_onehot: 特征值的one_hot编码
        :param Y_onehot: 目标值的one_hot编码
        :return:
        """
        # 利用网络结构定义好模型输入输出
        model = self.model()

        opt = Adam(lr=0.005, beta_1=0.9, beta_2=0.999, epsilon=None, decay=0.001)
        model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])

        s0 = np.zeros((10000, self.model_param["n_y"]))
        c0 = np.zeros((10000, self.model_param["n_y"]))
        outputs = list(Y_onehot.swapaxes(0, 1))

        # 输入x,以及decoder中LSTM的两个初始化值
        model.fit([X_onehot, s0, c0], outputs, epochs=10, batch_size=100)

5.模型预测

最后则是模型预测,在最后进行预测的时候,模型的输出是一个 [10,11] 维度的向量,这显然是无法进行读取的,因此,我们需要借助最开始保存的输出部分的字典来讲这些Onehot向量反向映射为字符串,模型预测如下:

    def test(self):
        """
        模型预测
        :return:
        """
        model = self.model()

        model.load_weights("./models/model.h5")

        example = '1 March 2001'
        source = string_to_int(example, self.model_param["Tx"], self.model_param["x_vocab"])
        source = np.expand_dims(np.array(list(map(lambda x:
                                                  to_categorical(x, num_classes=self.model_param["x_vocab_size"]),
                                                  source))), axis=0)
        s0 = np.zeros((10000, self.model_param["n_y"]))
        c0 = np.zeros((10000, self.model_param["n_y"]))
        prediction = model.predict([source, s0, c0])
        prediction = np.argmax(prediction, axis=-1)

        output = [dict(zip(self.model_param["y_vocab"].values(), self.model_param["y_vocab"].keys()))[int(i)] for i in prediction]

        print("source:", example)
        print("output:", ''.join(output))

s2s = Seq2seq()
X_onehot, Y_onehot = s2s.load_data(10000)
s2s.init_seq2seq()
s2s.train(X_onehot, Y_onehot)
s2s.test()

以上就是对加入了Attention机制的seq2seq模型的手动实现过程,相信经过了如上的过程后,你应该明白了seq2seq模型的实现与作用机理,并且经过手动实现注意力机制,对注意力机制更加的熟悉,也由此能够去了解更加复杂多样的注意力方式。

参考文章

[1] : https://blog.csdn.net/weixin_38314865/article/details/107582093
[2] : https://blog.csdn.net/rocking_struggling/article/details/104318023
[3] : https://www.bilibili.com/video/BV1LD4y1P7nK?p=90

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值