对联AI被玩坏了,什么?还能陪我闲聊?

345 阅读14分钟

「这是我参与2022首次更文挑战的第17天,活动详情查看:2022首次更文挑战」。

对联AI被玩坏了,什么?还能陪我闲聊?

腊月二十八,打糕蒸馍贴花花,年画、春联贴起来,马上过大年!

AI跟你对对联,虎年大吉福星到,平安富贵好运来!财旺运旺福气满,吃好喝好有人爱!

对联,是我国传统文化之一,是写在纸、布上或刻在竹子、木头、柱子上的对偶语句。对联对仗工整,平仄协调,是一字一音的汉语独特的艺术形式,是中国传统文化瑰宝。

对对联本质上是一个中文文本生成任务,今天我们将使用PaddleNLP中的UNIMO-text预训练模型,训练一个给定上联,自动对下联的模型。并展示如何使用简单易用的生成式API,以及如何使用FasterGeneration快速批量得生成下联。

不仅如此!这个对联AI被我玩坏了,我发现,TA竟然能陪聊。对话闲聊,大家都不陌生,但是像对对联一样,对仗工整得聊天,这效果绝绝子,解救网聊的尴尬时刻~

所有代码均已开源,你可以来训一个自己的对联机器人、闲聊模型啦!

更多NLP项目都开源在 PaddleNLP 中。如果感兴趣,欢迎star收藏一下,不易走丢哦~链接指路: github.com/PaddlePaddl…


先来看看效果吧

安装说明

  • PaddlePaddle 安装

    本项目依赖于 PaddlePaddle 2.2 及以上版本,请参考 安装指南 进行安装

  • PaddleNLP 安装

    pip install --upgrade paddlenlp -i https://pypi.org/simple
    
  • 环境依赖

    Python的版本要求 3.7+

!pip install --upgrade paddlenlp -i https://pypi.org/simple
Requirement already satisfied: paddlenlp in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (2.2.4)
Requirement already satisfied: colorlog in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from paddlenlp) (4.1.0)
Requirement already satisfied: seqeval in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from paddlenlp) (1.2.2)
Requirement already satisfied: jieba in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from paddlenlp) (0.42.1)
Requirement already satisfied: multiprocess in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from paddlenlp) (0.70.11.1)
Requirement already satisfied: h5py in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from paddlenlp) (2.9.0)
Requirement already satisfied: colorama in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from paddlenlp) (0.4.4)
Requirement already satisfied: numpy>=1.7 in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from h5py->paddlenlp) (1.19.5)
Requirement already satisfied: six in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from h5py->paddlenlp) (1.16.0)
Requirement already satisfied: dill>=0.3.3 in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from multiprocess->paddlenlp) (0.3.3)
Requirement already satisfied: scikit-learn>=0.21.3 in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from seqeval->paddlenlp) (0.24.2)
Requirement already satisfied: joblib>=0.11 in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from scikit-learn>=0.21.3->seqeval->paddlenlp) (0.14.1)
Requirement already satisfied: scipy>=0.19.1 in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from scikit-learn>=0.21.3->seqeval->paddlenlp) (1.6.3)
Requirement already satisfied: threadpoolctl>=2.0.0 in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from scikit-learn>=0.21.3->seqeval->paddlenlp) (2.1.0)

加载训练好的模型,直接预测

import paddlenlp
from utils import select_sum
# 由于训练时间较长,这里我们直接读训练好的模型
model = paddlenlp.transformers.UNIMOLMHeadModel.from_pretrained('data/data126898')
tokenizer = paddlenlp.transformers.UNIMOTokenizer.from_pretrained('unimo-text-1.0')
# model.eval()
/opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages/paddlenlp/transformers/funnel/modeling.py:30: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated, and in 3.8 it will stop working
  from collections import Iterable
W0130 10:39:57.035668   317 device_context.cc:447] Please NOTE: device: 0, GPU Compute Capability: 7.0, Driver API Version: 10.1, Runtime API Version: 10.1
W0130 10:39:57.041208   317 device_context.cc:465] device: 0, cuDNN Version: 7.6.
[2022-01-30 10:40:04,015] [    INFO] - Already cached /home/aistudio/.paddlenlp/models/unimo-text-1.0/unimo-text-1.0-vocab.txt
from utils import post_process_sum
num_return_sequences = 8

l = ['祝福盛世全民富', '春满人间欢歌阵阵', '瑞气满神州青山不老', '喜迎新春', '新年快乐', '我们一起滑雪吧', '我能去你家过年吗', '哭笑不得', '祝福盛世全民富', '财旺运旺福气满']
# 对春联是正经的      
# 对成语也朗朗上口
# 闲聊天简直是驴唇不对马嘴文学,哈哈哈
inputs = l[0]

inputs_ids = tokenizer.gen_encode(inputs, return_tensors=True, add_start_token_for_decoding=True, return_position_ids=True)
# 调用生成api并指定解码策略为beam_search
outputs, scores = model.generate(**inputs_ids, decode_strategy='beam_search', num_beams=8,num_return_sequences=num_return_sequences)
# 调用生成api并指定解码策略为Sampling,不同策略的效果不同哦。
# outputs, scores = model.generate(**inputs_ids, decode_strategy='sampling', top_k=8, num_return_sequences=num_return_sequences)
print("Result:\n" + 100 * '-' + '\n  ' + '输入: '+ inputs)
for i in range(num_return_sequences):
    print(i+1, '输出:', ''.join(post_process_sum(outputs[i].numpy(), tokenizer)[1]))
Result:
----------------------------------------------------------------------------------------------------
  输入: 祝福盛世全民富
1 输出: 歌颂党恩举国兴
2 输出: 歌颂党恩举国强
3 输出: 歌颂神州万象新
4 输出: 歌颂神州遍地春
5 输出: 歌颂和谐举国强
6 输出: 祝福新春举国强
7 输出: 欢庆新春举国欢
8 输出: 贺贺新春举国强

自己动手训练一个专属对联、闲聊模型吧

数据准备

首先我们从数据准备开始,数据准备流程如下:

1. 加载PaddleNLP内置数据集

本示例使用的数据集为开源的对联数据集couplet-clean-dataset,该数据集过滤了 couplet-dataset中的低俗、敏感内容。

这个数据集包含70w多条训练样本,1000条验证样本和1000条测试样本。

下面列出一些训练集中对联样例:

上联:晚风摇树树还挺 下联:晨露润花花更红

上联:愿景天成无墨迹 下联:万方乐奏有于阗

上联:丹枫江冷人初去 下联:绿柳堤新燕复来

上联:闲来野钓人稀处 下联:兴起高歌酒醉中

该数据集已集成在Paddlenlp中,使用PaddleNLP提供的load_datasetAPI,即可一键完成数据集加载。

from paddlenlp.datasets import load_dataset

train_ds, test_ds = load_dataset('couplet', splits=('train', 'test'))

for idx in range(2):
    print(train_ds[idx])
    print()
{'first': '晚\x02风\x02摇\x02树\x02树\x02还\x02挺', 'second': '晨\x02露\x02润\x02花\x02花\x02更\x02红'}

{'first': '愿\x02景\x02天\x02成\x02无\x02墨\x02迹', 'second': '万\x02方\x02乐\x02奏\x02有\x02于\x02阗'}

关于更多PaddleNLP数据集,请参考数据集列表

如果你想使用自己的数据集文件构建数据集,请参考以内置数据集格式读取本地数据集自定义数据集

2. 加载 paddlenlp.transformers.UNIMOTokenizer用于数据处理

文本数据在输入unimo-text预训练模型之前,需要经过数据处理转化为Feature。这一过程通常包括分词,token to id,add special token等步骤。

PaddleNLP对于各种预训练模型已经内置了相应的tokenizer,指定想要使用的模型名字即可加载对应的tokenizer。

可以通过调用tokenizer中的方法简单的完成上述数据处理。

import paddlenlp

# 设置模型名称
MODEL_NAME = 'unimo-text-1.0'
tokenizer = paddlenlp.transformers.UNIMOTokenizer.from_pretrained(MODEL_NAME)
[2022-01-30 10:40:05,256] [    INFO] - Already cached /home/aistudio/.paddlenlp/models/unimo-text-1.0/unimo-text-1.0-vocab.txt

3. 调用map()方法批量处理数据

由于我们传入了lazy=False,所以我们使用load_dataset()自定义的数据集是MapDataset对象。MapDatasetpaddle.io.Dataset的功能增强版本。其内置的map()方法适合用来进行批量数据集处理。

map()方法接受的主要参数是一个用于数据处理的function。正好可以与tokenizer相配合。

以下是本示例中的用法:

from functools import partial
from utils import convert_example

train_trans_func = partial(
    convert_example,
    tokenizer=tokenizer,
    mode='train')

test_trans_func = partial(
    convert_example,
    tokenizer=tokenizer,
    mode='test')

train_ds.map(train_trans_func, lazy=False, num_workers=4)
test_ds.map(test_trans_func, lazy=False, num_workers=4)
<paddlenlp.datasets.dataset.MapDataset at 0x7f8ad08f3f50>
for idx in range(2):
    print(train_ds[idx]['input_ids'])
    print(train_ds[idx]['token_type_ids'])
    print(train_ds[idx]['position_ids'])
    print(train_ds[idx]['masked_positions'])
    print(train_ds[idx]['labels'])
    print()
[1, 1010, 260, 1702, 715, 715, 201, 2150, 2, 1, 1947, 1113, 1230, 283, 283, 263, 536, 3]
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 0, 1, 2, 3, 4, 5, 6, 7, 8]
[9, 10, 11, 12, 13, 14, 15, 16]
[1947, 1113, 1230, 283, 283, 263, 536, 3]

[1, 929, 561, 125, 33, 154, 1318, 1193, 2, 1, 211, 58, 354, 1364, 9, 37, 6011, 3]
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 0, 1, 2, 3, 4, 5, 6, 7, 8]
[9, 10, 11, 12, 13, 14, 15, 16]
[211, 58, 354, 1364, 9, 37, 6011, 3]

从以上结果可以看出,数据集中的example已经被转换成了模型可以接收的feature,包括input_ids、token_type_ids、position_ids和labels等。 其中:

  • input_ids: 表示输入文本的token ID。
  • token_type_ids: 用于区分source(上联)和target(下联)。
  • position_ids: 表示输入token的位置。
  • masked_positions: 表示target的位置。
  • labels: target部分的token ID.

用于数据处理的convert_example已经写在了utils.py中。 更多有关数据处理的内容,请参考数据处理

4. Batchify和数据读入

使用paddle.io.BatchSamplerpaddlenlp.data中提供的方法把数据组成batch。

然后使用paddle.io.DataLoader接口多线程异步加载数据。

batchify_fn详解:

本示例中我们将batchify方法写在了utils.py中。

import paddle
from utils import batchify_fn

batch_size = 64

# 定义BatchSampler
train_batch_sampler = paddle.io.DistributedBatchSampler(
        train_ds, batch_size=batch_size, shuffle=True)

test_batch_sampler = paddle.io.BatchSampler(
    test_ds, batch_size=batch_size, shuffle=False)

# 定义batchify_fn
train_collate_fn = partial(batchify_fn, pad_val=0, mode='train')
test_collate_fn = partial(batchify_fn, pad_val=0, mode='test')

# 构造DataLoader
train_data_loader = paddle.io.DataLoader(
    dataset=train_ds,
    batch_sampler=train_batch_sampler,
    collate_fn=train_collate_fn,
    return_list=True)

test_data_loader = paddle.io.DataLoader(
    dataset=test_ds,
    batch_sampler=test_batch_sampler,
    collate_fn=test_collate_fn,
    return_list=True)

更多PaddleNLP内置的batchify相关API,请参考collate

模型加载

在本示例中我们选择的模型是UNIMO-text,是基于ERNIE-UNIMO框架在文本数据上预训练得到模型。

PaddleNLP已经内置了unimo-text-1.0,使用PaddleNLP API即可一键调用。

from paddlenlp.transformers import UNIMOLMHeadModel

model = UNIMOLMHeadModel.from_pretrained(MODEL_NAME)
[2022-01-30 10:42:09,466] [    INFO] - Downloading https://bj.bcebos.com/paddlenlp/models/transformers/unimo/unimo-text-1.0.pdparams and saved to /home/aistudio/.paddlenlp/models/unimo-text-1.0
[2022-01-30 10:42:09,468] [    INFO] - Downloading unimo-text-1.0.pdparams from https://bj.bcebos.com/paddlenlp/models/transformers/unimo/unimo-text-1.0.pdparams
100%|██████████| 434M/434M [00:08<00:00, 51.1MB/s] 

选择loss function

本示例中的对联生成任务是一个典型的seq2seq任务,所以我们使用的loss应该是多分类任务常用的CrossEntropy loss。Paddle中也内置了该loss,可以一键调用。之后在训练代码中可以看到。

设置Fine-Tune优化策略

适用于ERNIE/BERT这类Transformer模型的学习率为warmup的动态学习率。


# 训练过程中的最大学习率
learning_rate = 3e-5 

# 训练轮次
epochs = 10

# 学习率预热比例
warmup_proportion = 0.02

# 权重衰减系数,类似模型正则项策略,避免模型过拟合
weight_decay = 0.01

num_training_steps = len(train_data_loader) * epochs

# 学习率衰减策略
lr_scheduler = paddlenlp.transformers.LinearDecayWithWarmup(learning_rate, num_training_steps, warmup_proportion)

decay_params = [
    p.name for n, p in model.named_parameters()
    if not any(nd in n for nd in ["bias", "norm"])
]
optimizer = paddle.optimizer.AdamW(
    learning_rate=lr_scheduler,
    parameters=model.parameters(),
    weight_decay=weight_decay,
    apply_decay_param_fun=lambda x: x in decay_params)

模型训练与评估

模型训练的过程通常有以下步骤:

  1. 从dataloader中取出一个batch data
  2. 将batch data喂给model,做前向计算
  3. 将前向计算结果传给损失函数,计算loss。
  4. loss反向回传,更新梯度。重复以上步骤。

每训练1000个steps后,程序会自动调用utils.py中的evaluation()方法计算任务的BLEU score。

由于这里使用的训练数据集非常大,所以训练起来很慢,大家可以运行代码,自行训练。训练完成之后,就得到了我们最开始看到的模型啦。

from utils import evaluation
import paddle.nn.functional as F

global_step = 0
for epoch in range(1, epochs + 1):
    for batch in train_data_loader:
        global_step += 1
        labels = batch[-1]
        logits = model(*batch[:-1])
        labels = paddle.nn.functional.one_hot(labels, num_classes=logits.shape[-1])
        labels = paddle.nn.functional.label_smooth(labels)
        loss = F.cross_entropy(logits, labels, soft_label=True)

        loss.backward()
        optimizer.step()
        lr_scheduler.step()
        optimizer.clear_grad()
        if global_step % 1000 == 0:
            ppl = paddle.exp(loss)
            print("global step %d, epoch: %d, ppl: %.4f, loss: %.5f" % (global_step, epoch, ppl, loss))

    evaluation(model, test_data_loader,tokenizer,4)

生成API - 让预测变得简单

PaddleNLP针对生成式任务提供了generate()函数,内嵌于PaddleNLP所有的生成式模型。支持Greedy Search、Beam Search和Sampling解码策略,用户只需指定解码策略以及相应的参数即可完成预测解码,得到生成的sequence的token ids以及概率得分。

在本示例中,由于对联生成任务对生成结果的长度和内容有一定的预期(例如下联长度必须跟上联相等,对仗工整,尽量满足天对地,雨对风,大陆对长空。雷隐隐,雾蒙蒙,山花对海树,赤日对苍穹...),所以在本示例中我们尝试选择beam_search解码策略进行生成。当然也可以尝试选择sampling策略,以得到更具创造力的结果。

Beam Search

Beam Search是一种启发式图搜索算法,具有更大的搜索空间,可以减少遗漏隐藏在低概率单词后面的高概率单词的可能性,他会在每步保持最可能的num_beams个hypotheses,最后选出整体概率最高的hypotheses。下面以num_beams=2为例:

从上图中可以看到,在第一步的时候,我们除了选择概率最高的『机』字以外,还保留了概率第二高的『桨』字。在第二步的时候两个beam分别选择了『起』和『框』。这时我们发现『飞机快』这一序列的概率为0.2,而『飞桨框』序列的概率为0.32。我们找到了整体概率更高的序列。在我们这个示例中继续解下去,得到的最终结果为『飞桨框架』。

相比Greedy Search,Beam Search几乎总能找到整体概率更高的结果。

下面我们来看一下Beam Search解码进行对联生成的效果:

from utils import select_sum
import paddlenlp
# 由于训练时间较长,这里我们直接读训练好的模型
model = paddlenlp.transformers.UNIMOLMHeadModel.from_pretrained('data/data126898')
tokenizer = paddlenlp.transformers.UNIMOTokenizer.from_pretrained('unimo-text-1.0')
# model.eval()

# 对输入文本进行编码,转换为id
print("Result:\n" + 100 * '-')
for i in range(5):
    inputs = test_ds.data[i]['first']

    inputs_ids = tokenizer.gen_encode(
        inputs,
        return_tensors=True,
        add_start_token_for_decoding=True,
        return_position_ids=True)

    # 调用生成api并指定解码策略为beam_search
    outputs, scores = model.generate(**inputs_ids, decode_strategy='beam_search', num_beams=8)
    print(i, '上联:', inputs, '下联:', select_sum(outputs,scores, tokenizer))
[2022-01-30 10:42:23,271] [    INFO] - Already cached /home/aistudio/.paddlenlp/models/unimo-text-1.0/unimo-text-1.0-vocab.txt


Result:
----------------------------------------------------------------------------------------------------
0 上联: 心尘须自扫 下联: ['世事莫强为']
1 上联: 碧涧飞泉山笑语 下联: ['清溪流水鸟欢歌']
2 上联: 即景即心无机不被 下联: ['是虚是实有口皆碑']
3 上联: 袋鼓黎民乐 下联: ['心甜日子甜']
4 上联: 相通心意何须语 下联: ['不解风情枉自吟']
from utils import post_process_sum

num_return_sequences = 8
inputs = '财旺运旺福气满'
# inputs = '我饿了'

inputs_ids = tokenizer.gen_encode(
        inputs,
        return_tensors=True,
        add_start_token_for_decoding=True,
        return_position_ids=True)

# 调用生成api并指定解码策略为beam_search
outputs, scores = model.generate(**inputs_ids, decode_strategy='beam_search', num_beams=8,num_return_sequences=num_return_sequences)
print("Result:\n" + 100 * '-')
for i in range(num_return_sequences):
    print(i, '上联:', inputs, '下联:', ''.join(post_process_sum(outputs[i].numpy(), tokenizer)[1]))

Result:
----------------------------------------------------------------------------------------------------
0 上联: 财旺运旺福气满 下联: 人和家和喜事多
1 上联: 财旺运旺福气满 下联: 家兴人兴幸福长
2 上联: 财旺运旺福气满 下联: 人和家和幸福多
3 上联: 财旺运旺福气满 下联: 家兴业兴喜事多
4 上联: 财旺运旺福气满 下联: 家兴人兴幸福多
5 上联: 财旺运旺福气满 下联: 人和家和幸福长
6 上联: 财旺运旺福气满 下联: 家兴人兴福源长
7 上联: 财旺运旺福气满 下联: 家兴人兴福星高

可以看到,我们生成的下联效果还不错,对仗工整。而且生成API的使用非常简便。

更快更强 ——文本生成任务高性能推理FasterGeneration

在实际的文本生成场景中,例如实时对对联,输入法联想等产品中,除了结果的正确性,解码速度也是成本和用户体验的重大组成部分。在此我们隆重推出FasterGeneration高性能推理功能。

FasterGeneration是PaddleNLP v2.2版本加入的一个高性能推理功能,可实现基于CUDA的序列解码。功能底层依托于FasterTransformer,该库专门针对Transformer系列模型及各种解码策略进行了优化。功能顶层封装于model.generate函数。功能的开启和关闭通过传入use_faster参数进行控制(默认为关闭状态)。通过调用generate函数,用户可以简单实现模型的高性能推理功能。更详细的介绍可以参见README

在上面的对联生成示例中的generate函数中传入use_faster=True,即可启动FasterGeneration高性能解码功能。下面我们以上面的测试数据为例,展示一下FasterGeneration在对联生成任务中的加速效果:

import time
import paddle

place = "gpu"
place = paddle.set_device(place)
num_loop = 20

# 运行普通版本并计算耗时
with paddle.no_grad():
    for i in range(num_loop):
        # For warmup.
        if num_loop / 2 == i:
            paddle.device.cuda.synchronize(place)
            start = time.perf_counter()
        output, _ = model.generate(
            **inputs_ids,
            decode_strategy="beam_search",
            num_beams=8,
            use_faster=False)
    paddle.device.cuda.synchronize(place)
    pd_cost = (time.perf_counter() - start) / (num_loop / 2) * 1000

# 运行加速版本并计算耗时
with paddle.no_grad():
    for i in range(num_loop):
        # For warmup.
        if num_loop / 2 == i:
            paddle.device.cuda.synchronize(place)
            start = time.perf_counter()
        output, _ = model.generate(
            **inputs_ids,
            decode_strategy="beam_search",
            num_beams=8,
            use_faster=True)
    paddle.device.cuda.synchronize(place)
    faster_cost = (time.perf_counter() - start) / (num_loop / 2) * 1000
2022-01-30 10:42:29,993 - INFO - Using Python interpreter: /opt/conda/envs/python35-paddle120-env/bin/python, version: Python 3.7.4
2022-01-30 10:42:29,996 - INFO - execute command: cd /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages/paddlenlp/ops/extenstions && /opt/conda/envs/python35-paddle120-env/bin/python FasterTransformer_setup.py build


copying build/temp.linux-x86_64-3.7/lib/libdecoding_op.so -> /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages/paddlenlp/ops/extenstions/FasterTransformer

可以看到,第一次使用FasterGeneration时,PaddleNLP会启动即时编译(JIT Compile)自动编译高性能解码算子。编译过程通常会花费几分钟的时间,编译只会进行一次,之后再次使用高性能解码就不需要重新编译了。

# print("Result:\n" + 100 * '-')
# print(select_sum(outputs,scores, tokenizer))

print("Cost of origin generate:",pd_cost,"ms")
print("Cost of FasterGeneration:",faster_cost,"ms")
Cost of origin generate: 241.0419411957264 ms
Cost of FasterGeneration: 36.926571279764175 ms

可以看到,FasterGeneration的生成结果正确无误,且加速效果很强,在本例中达到了非加速版generate函数的6.3倍左右。

除此之外,FasterGeneration还支持FP16混合精度计算。在牺牲少量精度的同时获得更高的加速效果,传入use_fp16_decoding=True即可启动该功能。

关于更多有关生成API和FasterGeneration的使用教程和注意事项以及解码策略的讲解请参见文本生成任务实战:如何使用PaddleNLP实现各种解码策略

关于FasterGeneration的详细的模型和解码策略支持情况以及更详细的性能数据可以参见README