使用序列到序列模型完成数字加法

作者: jm12138
日期: 2022.5
摘要: 本示例介绍如何使用飞桨完成一个数字加法任务,将会使用飞桨提供的LSTM,组建一个序列到序列模型,并在随机生成的数据集上完成数字加法任务的模型训练与预测。

一、环境配置

本教程基于PaddlePaddle 2.3.0 编写,如果你的环境不是本版本,请先参考官网安装 PaddlePaddle 2.3.0。

# 导入项目运行所需的包

import paddle
import paddle.nn as nn

import random
import numpy as np

from visualdl import LogWriter

# 打印Paddle版本
print('paddle version: %s' % paddle.__version__)
paddle version: 2.3.0

二、构建数据集

  • 随机生成数据,并使用生成的数据构造数据集

  • 通过继承 paddle.io.Dataset 来完成数据集的构造

# 编码函数
def encoder(text, LEN, label_dict):
    # 文本转ID
    ids = [label_dict[word] for word in text]
    # 对长度进行补齐
    ids += [label_dict[' ']]*(LEN-len(ids))
    return ids

# 单个数据生成函数
def make_data(inputs, labels, DIGITS, label_dict):
    MAXLEN = DIGITS + 1 + DIGITS
    # 对输入输出文本进行ID编码
    inputs = encoder(inputs, MAXLEN, label_dict)
    labels = encoder(labels, DIGITS + 1, label_dict)
    return inputs, labels

# 批量数据生成函数
def gen_datas(DATA_NUM, MAX_NUM, DIGITS, label_dict):
    datas = []
    while len(datas)<DATA_NUM:
        # 随机取两个数
        a = random.randint(0,MAX_NUM)
        b = random.randint(0,MAX_NUM)
        # 生成输入文本
        inputs = '%d+%d' % (a, b)
        # 生成输出文本
        labels = str(eval(inputs))
        # 生成单个数据
        inputs, labels = [np.array(_).astype('int64') for _ in make_data(inputs, labels, DIGITS, label_dict)]
        datas.append([inputs, labels])
    return datas

# 继承paddle.io.Dataset来构造数据集
class Addition_Dataset(paddle.io.Dataset):
    # 重写数据集初始化函数
    def __init__(self, datas):
        super(Addition_Dataset, self).__init__()
        self.datas = datas
    
    # 重写生成样本的函数
    def __getitem__(self, index):
        data, label = [paddle.to_tensor(_) for _ in self.datas[index]]
        return data, label

    # 重写返回数据集大小的函数
    def __len__(self):
        return len(self.datas)

print('generating datas..')

# 定义字符表
label_dict = {
    '0': 0, '1': 1, '2': 2, '3': 3,
    '4': 4, '5': 5, '6': 6, '7': 7,
    '8': 8, '9': 9, '+': 10, ' ': 11
}

# 输入数字最大位数
DIGITS = 2

# 数据数量
train_num = 5000
dev_num = 500

# 数据批大小
batch_size = 32

# 读取线程数
num_workers = 8

# 定义一些所需变量
MAXLEN = DIGITS + 1 + DIGITS
MAX_NUM = 10**(DIGITS)-1

# 生成数据
train_datas = gen_datas(
    train_num, 
    MAX_NUM,
    DIGITS, 
    label_dict
) 
dev_datas = gen_datas(
    dev_num, 
    MAX_NUM,
    DIGITS, 
    label_dict
)

# 实例化数据集
train_dataset = Addition_Dataset(train_datas)
dev_dataset = Addition_Dataset(dev_datas)

print('making the dataset...')

# 实例化数据读取器
train_reader = paddle.io.DataLoader(
    train_dataset,
    batch_size=batch_size,
    shuffle=True,
    drop_last=True
)
dev_reader = paddle.io.DataLoader(
    dev_dataset,
    batch_size=batch_size,
    shuffle=False,
    drop_last=True
)

print('finish')
generating datas..
making the dataset...
finish

三、模型组网

  • 通过继承 paddle.nn.Layer 类来搭建模型

  • 本次介绍的模型是一个简单的基于 LSTMSeq2Seq 模型

  • 一共有如下四个主要的网络层:

    1. 嵌入层(Embedding):将输入的文本序列转为嵌入向量

    2. 编码层(LSTM):将嵌入向量进行编码

    3. 解码层(LSTM):将编码向量进行解码

    4. 全连接层(Linear):对解码完成的向量进行线性映射

  • 损失函数为交叉熵损失函数

# 继承paddle.nn.Layer类
class Addition_Model(nn.Layer):
    # 重写初始化函数
    # 参数:字符表长度、嵌入层大小、隐藏层大小、解码器层数、处理数字的最大位数
    def __init__(self, char_len=12, embedding_size=128, hidden_size=128, num_layers=1, DIGITS=2):
        super(Addition_Model, self).__init__()
        # 初始化变量
        self.DIGITS = DIGITS
        self.MAXLEN = DIGITS + 1 + DIGITS
        self.hidden_size = hidden_size
        self.char_len = char_len

        # 嵌入层
        self.emb = nn.Embedding(
            char_len, 
            embedding_size
        )
        
        # 编码器
        self.encoder = nn.LSTM(
            input_size=embedding_size,
            hidden_size=hidden_size,
            num_layers=1
        )
        
        # 解码器
        self.decoder = nn.LSTM(
            input_size=hidden_size,
            hidden_size=hidden_size,
            num_layers=num_layers
        )
        
        # 全连接层
        self.fc = nn.Linear(
            hidden_size, 
            char_len
        )
    
    # 重写模型前向计算函数
    # 参数:输入[None, MAXLEN]、标签[None, DIGITS + 1]
    def forward(self, inputs, labels=None):
        # 嵌入层
        out = self.emb(inputs)

        # 编码器
        out, (_, _) = self.encoder(out)

        # 按时间步切分编码器输出
        out = paddle.split(out, self.MAXLEN, axis=1)

        # 取最后一个时间步的输出并复制 DIGITS + 1 次
        out = paddle.expand(out[-1], [out[-1].shape[0], self.DIGITS + 1, self.hidden_size])

        # 解码器
        out, (_, _) = self.decoder(out)

        # 全连接
        out = self.fc(out)

        # 如果标签存在,则计算其损失和准确率
        if labels is not None:

            # 计算交叉熵损失
            loss = nn.functional.cross_entropy(out, labels)

            # 计算准确率
            acc = paddle.metric.accuracy(paddle.reshape(out, [-1, self.char_len]), paddle.reshape(labels, [-1, 1]))

            # 返回损失和准确率
            return loss, acc

        # 返回输出
        return out

四、模型训练与评估

  • 使用 Adam 作为优化器进行模型训练

  • 以模型准确率作为评价指标

  • 使用 VisualDL 对训练数据进行可视化

  • 训练过程中会同时进行模型评估和最佳模型的保存

# 初始化log写入器
log_writer = LogWriter(logdir="./log")

# 模型参数设置
embedding_size = 128
hidden_size=128
num_layers=1

# 训练参数设置
epoch_num = 50
learning_rate = 0.001
log_iter = 2000
eval_iter = 500

# 定义一些所需变量
global_step = 0
log_step = 0
max_acc = 0

# 实例化模型
model = Addition_Model(
    char_len=len(label_dict), 
    embedding_size=embedding_size, 
    hidden_size=hidden_size, 
    num_layers=num_layers, 
    DIGITS=DIGITS)

# 将模型设置为训练模式
model.train()

# 设置优化器,学习率,并且把模型参数给优化器
opt = paddle.optimizer.Adam(
    learning_rate=learning_rate,
    parameters=model.parameters()
)

# 启动训练,循环epoch_num个轮次
for epoch in range(epoch_num):
    # 遍历数据集读取数据
    for batch_id, data in enumerate(train_reader()):
        # 读取数据
        inputs, labels = data

        # 模型前向计算
        loss, acc = model(inputs, labels=labels)

        # 打印训练数据
        if global_step%log_iter==0:
            print('train epoch:%d step: %d loss:%f acc:%f' % (epoch, global_step, loss.numpy(), acc.numpy()))
            log_writer.add_scalar(tag="train/loss", step=log_step, value=loss.numpy())
            log_writer.add_scalar(tag="train/acc", step=log_step, value=acc.numpy())
            log_step+=1

        # 模型验证
        if global_step%eval_iter==0:
            model.eval()
            losses = []
            accs = []
            for data in dev_reader():
                loss_eval, acc_eval = model(inputs, labels=labels)
                losses.append(loss_eval.numpy())
                accs.append(acc_eval.numpy())
            avg_loss = np.concatenate(losses).mean()
            avg_acc = np.concatenate(accs).mean()
            print('eval epoch:%d step: %d loss:%f acc:%f' % (epoch, global_step, avg_loss, avg_acc))
            log_writer.add_scalar(tag="dev/loss", step=log_step, value=avg_loss)
            log_writer.add_scalar(tag="dev/acc", step=log_step, value=avg_acc)

            # 保存最佳模型
            if avg_acc>max_acc:
                max_acc = avg_acc
                print('saving the best_model...')
                paddle.save(model.state_dict(), 'best_model')
            model.train()

        # 反向传播
        loss.backward()

        # 使用优化器进行参数优化
        opt.step()

        # 清除梯度
        opt.clear_grad()

        # 全局步数加一
        global_step += 1

# 保存最终模型
paddle.save(model.state_dict(),'final_model')
W0509 16:43:23.286460   233 gpu_context.cc:278] Please NOTE: device: 0, GPU Compute Capability: 7.0, Driver API Version: 11.2, Runtime API Version: 10.1
W0509 16:43:23.291019   233 gpu_context.cc:306] device: 0, cuDNN Version: 7.6.


train epoch:0 step: 0 loss:2.486356 acc:0.062500
eval epoch:0 step: 0 loss:2.486356 acc:0.062500
saving the best_model...
eval epoch:3 step: 500 loss:1.116378 acc:0.583333
saving the best_model...
eval epoch:6 step: 1000 loss:0.985826 acc:0.635417
saving the best_model...
eval epoch:9 step: 1500 loss:0.889170 acc:0.708333
saving the best_model...
train epoch:12 step: 2000 loss:0.800535 acc:0.697917
eval epoch:12 step: 2000 loss:0.800535 acc:0.697917
eval epoch:16 step: 2500 loss:0.618130 acc:0.750000
saving the best_model...
eval epoch:19 step: 3000 loss:0.249026 acc:0.947917
saving the best_model...
eval epoch:22 step: 3500 loss:0.101126 acc:0.989583
saving the best_model...
train epoch:25 step: 4000 loss:0.063789 acc:0.989583
eval epoch:25 step: 4000 loss:0.063789 acc:0.989583
eval epoch:28 step: 4500 loss:0.023056 acc:1.000000
saving the best_model...
eval epoch:32 step: 5000 loss:0.059553 acc:0.989583
eval epoch:35 step: 5500 loss:0.013325 acc:1.000000
train epoch:38 step: 6000 loss:0.007098 acc:1.000000
eval epoch:38 step: 6000 loss:0.007098 acc:1.000000
eval epoch:41 step: 6500 loss:0.005362 acc:1.000000
eval epoch:44 step: 7000 loss:0.005228 acc:1.000000
eval epoch:48 step: 7500 loss:0.003874 acc:1.000000

五、模型测试

  • 使用保存的最佳模型进行测试

# 反转字符表
label_dict_adv = {v: k for k, v in label_dict.items()}

# 输入计算题目
input_text = '12+40'

# 编码输入为ID
inputs = encoder(input_text, MAXLEN, label_dict)

# 转换输入为向量形式
inputs = np.array(inputs).reshape(-1, MAXLEN)
inputs = paddle.to_tensor(inputs)

# 加载模型
params_dict= paddle.load('best_model')
model.set_dict(params_dict)

# 设置为评估模式
model.eval()

# 模型推理
out = model(inputs)

# 结果转换
result = ''.join([label_dict_adv[_] for _ in np.argmax(out.numpy(), -1).reshape(-1)])

# 打印结果
print('the model answer: %s=%s' % (input_text, result))
print('the true answer: %s=%s' % (input_text, eval(input_text)))
the model answer: 12+40=52 
the true answer: 12+40=52

六、总结

  • 你还可以通过变换网络结构,调整数据集,尝试不同的参数的方式来进一步提升本示例当中的数字加法的效果

  • 同时,也可以尝试在其他的类似的任务中用飞桨来完成实际的实践