第5章〓Transformer与人机畅聊

当读完本章时,应该能够: 

 理解机器是如何学习说话和学会说话的。

 理解机器问答的技术路线。

 理解机器问答语言模型及评价方法。

 理解Transformer模型的原理与方法。

 理解Transformer独特的注意力机制。

 理解Transformer模型的定义与训练。

 实战基于腾讯聊天数据集+Transformer的聊天机器人设计。

 实战基于Web API的聊天服务器设计。

 实战Android版人机畅聊客户机设计。







5.1项目动力

机器视觉与自然语言处理是人工智能的两大热点领域。对于机器视觉学习,读者较容易入门,但对于自然语言的处理,特别是对于机器翻译、机器问答、人机聊天等应用的理解与学习,难度较高。究其原因,读者往往很难独立去完成一个上述自然语言处理项目,一旦缺少了亲力亲为的实战体验,理论不能与实践紧密结合,不能相互印证,那么对理论的掌握往往就是空中楼阁。

本章案例将带领读者从零起步,以Transformer为建模基础,在腾讯发布的中文聊天数据集上,训练出一款会聊天的机器人程序。聊天机器人除了不知疲倦、有求必应、一对多并发服务的优点外,聊天机器人的未来将兼具多种风格,例如滔滔不绝的机器人、激情演讲的机器人、能做灵魂触碰的机器人、能心有灵犀的机器人、能聊出思想火花的机器人、能与你一起头脑风暴的机器人、能自我学习提高的机器人……

总之,具备强大语言能力的机器人、具备思想能力的机器人,正沿着人类追求的航向,奋力前行。







5.2机器问答技术路线

机器问答与机器聊天(本书特指人机聊天)都是自然语言处理领域的经典问题,二者并不完全相同。

机器问答一般常见于阅读理解的场景,给定参考语料,要求机器人根据语料回答问题,问题的答案往往是在语料之中做了显式陈述,或者根据语料可以推断的。

对于机器聊天而言,聊天的随机性决定了应答难度更大一些,不但需要及时切换话题,还需要通过简短的交谈揣摩对方的真实意图,否则会答非所问。

机器问答与机器聊天的界限有时也是模糊的,例如,电商平台部署的客服机器人,确实可以解决客户的若干问题,节省了大规模的人力资源。这些客服机器人基于其收集的大量客服会话数据训练而成,兼具机器问答与机器聊天的特点。

经常听到新入门学生询问如何开发一个机器问答或者机器聊天的项目,图5.1给出了项目实施路径的四部曲。





图5.1机器问答技术路线



第一步,解决数据集的问题。俗话说,巧妇难为无米之炊,强大的语料数据集对语言建模至关重要。基本原则是根据问题目标,选择适配的语料库。例如斯坦福大学发布的CoQA、SQuAD 2.0语料库,华盛顿大学牵头发布的QuAC语料库,百度发布的DuConv语料库,京东的JDDC客服语料库,腾讯的NaturalConv聊天语料库,清华大学的KdConv语料库等。这些经典的语料库都有论文解析,通过阅读论文可以得知语料库的规模、覆盖的领域、适合解决的问题。如果条件允许,可以考虑自建语料库。或者自建小规模语料库,用迁移学习的方法解决数据量不足的问题。

第二步,选择模型(算法)。工欲善其事,必先利其器。近年来,能够代表自然语言处理领域前沿热点的模型非Transformer和BERT莫属。研读前沿文献不难发现,活跃在各大语言建模挑战赛排行榜前列的模型,无不是以Transformer和BERT结构为核心的衍生品。图5.2给出了适合自然语言建模的经典技术,自底向上,反映了语言建模技术的变迁与演进。


图5.2语言建模技术的演进



第三步,理解语言模型的评价方法。之所以强调这一点,是因为学生往往比较熟悉机器视觉领域的分类或回归问题的评价方法,这些方法在自然语言处理领域并不好用。以机器聊天或机器推理为例,可接受的正确答案往往不止一个。如何去评价这些语句长度不一、文字不一的句子,需要新方法。例如BLEU即是一种经典方法。

第四步,解决算力问题。普通的个人计算机,即使对于小规模数据集,也难以胜任模型训练。GPU性能稍好的工作站,往往也难以满足中等建模问题对算力的需求。对于学生而言,需要寻找免费算力资源,才能不让自己的想法卡在最后一公里。幸运的是,百度、Google等都提供了免费算力平台。

以本章的机器人聊天项目为例,在3.7节给出的计算机配置上,完成38个epoch的训练,需要4小时左右。在Kaggle平台提供的免费TPUv38上,只需要12分钟。速度提升20倍。







5.3腾讯聊天数据集

2021年,腾讯人工智能实验室发布了主题驱动的多轮中文聊天数据集,数据集名称为NaturalConv,详情参见论文Naturalconv: A chinese dialogue dataset towards multiturn topicdriven conversation(WANG,X Y,LI C,ZHAO J,et al. 2021)。

NaturalConv包含体育、娱乐、科技、游戏、教育和健康六个领域的19.9K场双人对话,合计40万条对话语句,每场对话平均包含20.1个句子,即对话双方进行了10次左右的表达。为了获取上述数据,腾讯支付了语料库整理人员5万美元的信息采集和加工整理费用。现在这个数据集可以在腾讯人工智能实验室官方网站免费下载。

NaturalConv数据集包含的对话是以6500篇网络新闻为背景展开的。对话具备自然性,即对话双方围绕一个共同主题展开话语交流,属于自然情况下的聊天行为。

口语化是聊天的显著特点,这也是NaturalConv数据集设计时遵循的原则。表5.1给出了NaturalConv中的一场对话文本,A、B两名学生关于当天的一场荷甲球赛展开的话题聊天。


表5.1A、B两人对话样本抽样观察




轮次A、B两人的对话内容


A1嗨,你来得挺早啊。
B1是啊,你怎么来得这么晚?
A2昨晚我看了球赛,所以今早起晚了,也没吃饭。
B2现在这个点食堂应该有饭,你看什么球赛啊?篮球吗?
A3不是,足球。
B3怪不得,足球时间长。
A4你知道吗?每次都是普罗梅斯进球。
B4这个我刚才也看了新闻了,他好有实力啊。
A5是啊,尤其是他那个帽子戏法,让我看得太惊心动魄了。
B5我一个同学在群里说了,每次聊天都离不开他,可见他的实力有多强大。
A6是啊,看来你那个同学和我是一样的想法。
B6我好不容易摆脱他的话题,你又来说出他的名字。
A7哈哈,你不懂我们对足球有多热爱。
B7我知道你热爱,我还记得你参加初中比赛还拿到冠军呢。你功不可没啊。
A8哈哈,还是你能记得我当时的辉煌。
B8没办法,咱俩从小一起长大的,彼此太了解了。
A9嗯,老师来了。
B9快打开课本,老师要检查。
A10嗯嗯,下课再聊。
B10嗯。



话题参照的新闻稿件如下。

新闻稿: 北京时间今天凌晨,荷甲第4轮进行了两场补赛。阿贾克斯和埃因霍温均在主场取得了胜利,两队7轮后同积17分,阿贾克斯以6个净胜球的优势领跑积分榜。0点30分,埃因霍温与格罗宁根的比赛开战,埃因霍温最终3∶1在主场获胜。2点45分,阿贾克斯主场与福图纳锡塔德之战开始。由于埃因霍温已经先获胜了,阿贾克斯必须获胜才能在积分榜上咬住对方。在整个上半场,阿贾克斯得势不得分,双方0∶0互交白卷。在下半场中,阿贾克斯突然迎来了大爆发。在短短33分钟内,阿贾克斯疯狂打进5球,平均每6分钟就能取得1个进球。在第50分钟时,新援普罗梅斯为阿贾克斯打破僵局。塔迪奇左侧送出横传,普罗梅斯后点推射破门。第53分钟时亨特拉尔头球补射,内雷斯在门线前头球接力破门。第68分钟时,普罗梅斯近距离补射梅开二度。这名27岁的前场多面手,跑到场边来了一番尬舞。第77分钟时阿贾克斯收获第4球,客队后卫哈里斯在防传中时伸腿将球一捅,结果皮球恰好越过门将滚入网窝。在第83分钟时,普罗梅斯上演了帽子戏法,比分也最终被定格为5∶0。在接到塔迪奇直传后,普罗梅斯禁区左侧反越位成功,他的单刀低射从门将裆下入网。普罗梅斯这次的庆祝动作是秀出三根手指,不过他手指从上到下抹过面部时的动作,很有点像是在擦鼻涕。


NaturalConv数据集与CMU DoG、Wizard of Wiki、DuConv、KdConv的对比如表5.2所示。NaturalConv涉及的主题更为广泛,双方对话的回合数更多,语料库规模更大。


表5.2与其他数据集对比



DatasetLanguageDocument
TypeAnnotation
LevelTopicAvg.#turns#uttrs


CMU DoGEnglishTextSentenceFilm22.6130k
Wizard of WikiEnglishTextSentenceMultiple9.0202k
DuConvChineseText&KGDialogueFilm5.891k
KdConvChineseText&KGSentenceFilm,Music,Travel19.086k
NaturalConvChineseTextDialogueSports,Ent,
Tech,Games,
Edu,Health20.1400k


NaturalConv语料库中各主题的分布情况如表5.3所示。可以看到,体育类主题的文档数占比接近一半,健康类主题最少,只有52篇背景文档。


表5.3主题的分布情况



SportsEntTechGamesEduHealthTotal


#document312413311476103414526500
#dialogues974044034061308126514219919
#dialogues per document3.13.32.83.03.12.73.0
#utterances19564388457815876180253762852400095
Avg.#utterances per dialogue20.120.120.120.120.120.120.1
Avg.#tokens per utterance12.012.412.312.112.612.512.2
Avg.#characters per utterance17.818.118.617.818.118.318.1
Avg.#tokens per dialogue241.1248.2247.5242.9248.3251.1244.8
Avg.#characters per dialogue357.5363.2372.8356.5356.5368.0363.1


每篇文档相关的对话场次平均为3场。每场对话包含的语句平均为20.1个。平均每场对话包含的字数为360个左右。

可见,NaturalConv语料库的主题分布并不均衡,这也可以解释,为什么后面训练的聊天机器人,偏爱于体育类的话题,或者对体育类的话题更“健谈”一些。语料库相当于语言模型的先天基因,决定了语言模型表现的倾向性。






5.4Transformer模型解析

Transformer模型参见论文Attention is all you need(VASWANI A,SHAZEER N,PARMAR N,et al. 2017)。Transformer这个词的原意是“变形金刚”,作者用Transformer寓意其可伸缩性好,可以胜任的应用领域非常广泛。

Transformer模型结构如图5.3所示。左侧代表编码器,右侧代表解码器。编码器与解码器的结构非常相似。

编码器由N个结构重复的单元连接而成。每个单元包含两个残差块: 第一个残差块以多头注意力模块为核心; 第二个残差块以全连接网络为核心。

解码器的主体是由N个结构重复的单元连接而成,最后跟上一个全连接层和Softmax分类层。每个单元包含三个残差块: 第一个残差块以带掩码的多头注意力模块为核心; 第二个残差块是交叉多头注意力模块; 第三个模块为全连接网络模块。

注意,Transformer中用Linear表示的全连接网络,不包含激活函数,是一个线性结构。





图5.3Transformer模型结构(见彩插)



编码器的输入层需要将序列的嵌入向量与序列的位置编码向量做叠加运算,然后并行输入到三个Linear网络,得到Q、K、V三个输入向量。

编码器输出层的输出将直接给到解码器各个单元的第二个残差块。

解码器除了接收来自编码器的输入,还有一个被称作Output Embedding的输入。在模型训练期间,Output Embedding用样本的标签向量表示。在模型推理时,解码器是一个自回归结构,单步推理生成的输出,将回馈给Output Embedding作为解码器的输入,参入整个推理过程。

编码器与解码器之间的连接方式有很多,图5.4给出了Transformer的经典推荐方式。即编码器最后一个单元的输出,给到解码器各个单元的交叉多头注意力模块。



图5.4Transformer的经典推荐方式


事实上,也可以让编码器各个单元的输出,只给到解码器的同层次单元,或者给到解码器的所有单元。


Transformer的核心运算体现在注意力机制上,图5.5给出了Transformer的单头注意力机制与多头注意力机制的计算逻辑。



图5.5Transformer的单头注意力机制与多头注意力机制的计算逻辑


Transformer将编码的输入序列,通过全连接网络学习为Q、K、V三个嵌入向量。左图为基于点积的单头注意力逻辑。右图为h个多头注意力并行堆叠、合并计算的逻辑。


Q、K之间完成注意力计算,形成注意力权重,用注意力权重乘以V,得到单个注意力模块的输出。

直观上看,Q、K之间的注意力强度反映了序列中上下文之间的关系强弱,将这种强弱关系映射到向量V上,即可实现对输入序列的编码和特征提取。Q、K、V之间的计算逻辑与互动关系如图5.6所示。



图5.6Q、K、V之间的计算逻辑与互动关系


假设输入序列为a1a2a3a4,现在只考虑a2对应的输出b2,通过注意力计算得到b2的步骤解析如下。

(1) a2通过三个独立的全连接网络学习,得到编码q2、k2、v2,q2与k2按照图5.5所示的点积注意力计算逻辑,经Softmax输出q2与k2之间的归一化关系权重向量a′2,2。

(2) q2与k1经注意力计算,Softmax层输出q2与k1的归一化关系权重向量a′2,1。

(3) 重复步骤(2),得到a′2,3和a′2,4。

至此,a2与序列a1a2a3a4中其他单词的关系(包括与自身的关系),已经通过q2与k1、k2、k3、k4之间的注意力计算得到,表示为四个权重向量a′2,1、a′2,2、a′2,3和a′2,4。

(4) 得到b2=a′2,1×v1+a′2,2×v2+a′2,3×v3+a′2,4×v4。现在,可以认为向量b2是对向量a2施加上下文全局注意力后的新表示。




同样的方法可以得到b1、b3、b4。

从a1a2a3a4到b1b2b3b4,依靠注意力机制,完成了一次特征提取与变换。

下面通过一个例子演示注意力的计算过程。如图5.7所示(图片源自Ketan Doshi博客),假设输入的序列为You are welcome,规定序列长度为4,所以后面需要补一个空位,不妨用PAD表示。假定每个单词向量的编码长度为3。



图5.7注意力计算举例


You are welcome PAD构成了一个维度为(4,3)的特征矩阵,分别送入三个Linear网络,得到Q、K、V三个特征编码矩阵,维度均为(4,3)。


注意力的计算可以归纳为式(5.1)。
Attention(Q,K,V)=SoftmaxQKTdkV(5.1)
其中,dk表示Q、K序列中单词向量的长度。考虑到Q与K的点积运算,有可能放大了特征的输出值,所以分母除以dk,这也是Scaled DotProduct Attention名称的由来。

Transformer的相关参数设置如表5.4所示。


表5.4Transformer的相关参数设置




参 数 名 称参数值


编码器单元数N6
解码器单元数N6
输入输出向量的长度dmodel 512
注意力头数h8
Q、K、V的向量长度 dk=dv=dmodel/h=64








5.5机器人项目初始化

用PyCharm在TensorFlow_to_Android项目下新建目录MyRobot。在MyRobot目录下新建models和transformer两个子目录。

models目录用于存放训练好的聊天机器人模型。transformer目录用于存放数据集预处理程序和模型定义程序。

在transformer目录下新建子目录dataset。将下载的腾讯自然语言聊天数据集NaturalConv解压后存放到dataset目录下。

数据集可在腾讯人工智能实验室官方网站免费下载。下载地址为https://ai.tencent.com/ailab/nlp/dialogue/#datasets。

在MyRobot目录下新建主程序main.py,负责模型的训练、保存、评估和测试。

初始化后的项目结构如图5.8所示。





图5.8MyRobot项目初始结构


dataset目录下的vocab.txt是BERT中文模型的词典文件。这个文件不属于腾讯自然语言聊天数据集,需要从BERT官方网站下载中文模型,从中抽取中文词典文件。

BERT中文预训练模型可从官方网站下载。下载地址为https://github.com/googleresearch/bert。

本章案例采用BERT的分词模型对中文语句分词。







5.6数据集预处理与划分

模型训练之前,需要首先准备好数据,让数据能够直接“喂入”模型进行训练,为了提高模型“喂入”的效率,往往还要设计数据加载模式。

在transformer目录下新建dataset.py程序,如程序源码P5.1所示。





程序源码P5.1dataset.py数据集预处理与划分


1import codecs
2import json
3import re
4import tensorflow as tf
5from tensorflow.keras.preprocessing.sequence import pad_sequences
6# 析取数据集(训练集、验证集、测试集),将所有的"问"与"答"分开
7def extract_conversations(hparams, data_list, dialog_list):
8inputs, outputs = [], []# 问答列表
9for dialog in dialog_list:
10if dialog['dialog_id'] in data_list:
11if len(dialog['content']) % 2 == 0:
12i = 0
13for line in dialog['content']:
14if (i % 2 == 0):
15inputs.append(line) # "问"列表
16else:
17outputs.append(line) # "答"列表
18i += 1
19# 限定样本总数
20# if len(inputs) >= hparams.total_samples:
21# return inputs, outputs
22return inputs, outputs
23# 分词,过滤掉超过长度的句子,短句补齐
24def tokenize_and_filter(hparams, inputs, outputs, tokenizer):
25tokenized_inputs, tokenized_outputs = [], []
26for (sentence1, sentence2) in zip(inputs, outputs):
27sentence1 = tokenizer.tokenize(sentence1) # 分词
28sentence1 = tokenizer.convert_tokens_to_ids(sentence1)# ids
29sentence2 = tokenizer.tokenize(sentence2)
30sentence2 = tokenizer.convert_tokens_to_ids(sentence2)
31sentence1 = hparams.start_token + sentence1 + hparams.end_token
32sentence2 = hparams.start_token + sentence2 + hparams.end_token
33if len(sentence1) <= hparams.max_length and len(sentence2) <= hparams.max_
34length:
35tokenized_inputs.append(sentence1)
36tokenized_outputs.append(sentence2)
37# 补齐
38tokenized_inputs = pad_sequences(tokenized_inputs, \
39maxlen=hparams.max_length, padding='post')
40tokenized_outputs = pad_sequences(tokenized_outputs, \
41maxlen=hparams.max_length, padding='post')
42return tokenized_inputs, tokenized_outputs
43# 读文件
44def get_data(datafile):
45with open(f'{datafile}', 'r') as f:
46data_list = f.readlines()
47for i in range(len(data_list)):
48data_list[i] = re.sub(r'\n', '', data_list[i])
49return data_list
50# 返回训练集和验证集
51def get_dataset(hparams, tokenizer, dialog_file, train_file, valid_file):
52dialog_list = json.loads(codecs.open(f"{dialog_file}", "r", "utf-8").read())
53print(dialog_list[0])
54train_list = get_data(f'{train_file}')
55train_questions, train_answers = extract_conversations(hparams, train_list, dialog_list)
56train_questions, train_answers = tokenize_and_filter(hparams, \
57list(train_questions), list(train_answers), tokenizer)
58# 构建训练集
59train_dataset = tf.data.Dataset.from_tensor_slices((
60{
61'inputs': train_questions,
62# 解码器使用正确的标签作为输入
63'dec_inputs': train_answers[:, :-1] # 去掉最后一个元素或 END_TOKEN
64},
65{
66'outputs': train_answers[:, 1:] # 去掉 START_TOKEN
67},
68))
69train_dataset = train_dataset.cache()
70train_dataset = train_dataset.shuffle(len(train_questions))
71train_dataset = train_dataset.batch(hparams.batchSize)
72train_dataset = train_dataset.prefetch(tf.data.AUTOTUNE)
73# 构建验证集
74valid_list = get_data(f'{valid_file}')
75valid_questions, valid_answers = extract_conversations( \
76hparams,valid_list, dialog_list)
77valid_questions, valid_answers = tokenize_and_filter(hparams, \
78list(valid_questions), list(valid_answers), tokenizer)
79valid_dataset = tf.data.Dataset.from_tensor_slices((
80{
81'inputs': valid_questions,
82# 解码器使用正确的标签作为输入
83'dec_inputs': valid_answers[:, :-1] # 去掉最后一个元素或 END_TOKEN
84},
85{
86'outputs': valid_answers[:, 1:] # 去掉START_TOKEN
87},
88))
89valid_dataset = valid_dataset.cache()
90valid_dataset = valid_dataset.shuffle(len(valid_questions))
91valid_dataset = valid_dataset.batch(hparams.batchSize)
92valid_dataset = valid_dataset.prefetch(tf.data.AUTOTUNE)
93return train_dataset, valid_dataset


程序源码解析参见本节视频教程。







5.7定义Transformer输入层编码

模型定义的完整流程如图5.9所示。本节首先完成输入层的编码定义。后续各节分步完成注意力机制、编码器、解码器的模块定义,最后合成为模型的整体定义。





图5.9Transformer模型定义的完整流程


在transformer目录下新建模型定义程序model.py。关于编码器输入层的定义逻辑如程序源码P5.2所示。





程序源码P5.2model.py之输入层定义



1import matplotlib.pyplot as plt
2import tensorflow as tf
3from tensorflow.keras import Input, Model
4from tensorflow.keras.layers import Dense, Lambda, Embedding, Dropout, \
5add, LayerNormalization
6from tensorflow.keras.utils import plot_model
7# 定义掩码矩阵
8def create_padding_mask(x):
9# 找出序列中的 padding,设置掩码值为 1
10mask = tf.cast(tf.math.equal(x, 0), tf.float32)
11# (batch_size, 1, 1, sequence length)
12return mask[:, tf.newaxis, tf.newaxis, :]
13# 测试语句
14print(create_padding_mask(tf.constant([[1, 2, 0, 3, 0], [0, 0, 0, 4, 5]])))
15# 解码器的前向掩码
16def create_look_ahead_mask(x):
17seq_len = tf.shape(x)[1]
18look_ahead_mask = 1 - tf.linalg.band_part(tf.ones((seq_len, seq_len)), -1, 0)
19padding_mask = create_padding_mask(x)
20return tf.maximum(look_ahead_mask, padding_mask)
21# 测试
22print(create_look_ahead_mask(tf.constant([[1, 2, 0, 4, 5]])))
23# 位置编码类
24class PositionalEncoding(tf.keras.layers.Layer):
25def __init__(self, position, d_model):
26super(PositionalEncoding, self).__init__()
27self.pos_encoding = self.positional_encoding(position, d_model)
28def get_config(self):
29config = super(PositionalEncoding, self).get_config()
30config.update({
31'position': self.position,
32'd_model': self.d_model,
33})
34return config
35def get_angles(self, position, i, d_model):
36angles = 1 / tf.pow(10000, (2 * (i //2)) / tf.cast(d_model, tf.float32))
37return position * angles
38def positional_encoding(self, position, d_model):
39angle_rads = self.get_angles( \
40position=tf.range(position, dtype=tf.float32)[:, tf.newaxis], \
41i=tf.range(d_model, dtype=tf.float32)[tf.newaxis, :], \
42d_model=d_model)
43# 奇数位置用正弦函数
44sines = tf.math.sin(angle_rads[:, 0::2])
45# 偶数位置用余弦函数
46cosines = tf.math.cos(angle_rads[:, 1::2])
47pos_encoding = tf.concat([sines, cosines], axis=-1)
48pos_encoding = pos_encoding[tf.newaxis, ...]
49return tf.cast(pos_encoding, tf.float32)
50def call(self, inputs):
51return inputs + self.pos_encoding[:, :tf.shape(inputs)[1], :]
52# 测试
53sample_pos_encoding = PositionalEncoding(50, 512)
54plt.pcolormesh(sample_pos_encoding.pos_encoding.numpy()[0], cmap='RdBu')
55plt.xlabel('Depth')
56plt.xlim((0, 512))
57plt.ylabel('Position')
58plt.colorbar()
59plt.show()


假定输入层向量的维度为(50,512),即序列长度为50(也即有50个单词),每个单词的嵌入向量长度为512,则整个输入层向量的位置编码及其几何分布如图5.10所示。对于中文而言,分词以单个中文字符为单位,所以位置编码是针对单个中文字符而言的。对于英文,分词以英文单词为单位。函数曲线的变化趋势体现了位置编码函数对不同位置编码的差异性。





图5.10位置编码及其几何分布








5.8定义Transformer注意力机制

先定义单头注意力函数,实现单头注意力机制的计算,然后合成多头注意力计算模块。编码逻辑如程序源码P5.3所示。





程序源码P5.3model.py之注意力机制


1# 计算注意力
2def scaled_dot_product_attention(query, key, value, mask):
3matmul_qk = tf.matmul(query, key, transpose_b=True)
4# 计算qk
5depth = tf.cast(tf.shape(key)[-1], tf.float32)
6logits = matmul_qk / tf.math.sqrt(depth)
7# 添加掩码以将填充标记归零
8if mask is not None:
9logits += (mask * -1e9)
10# 在最后一个轴上实施softmax
11attention_weights = tf.nn.softmax(logits, axis=-1)
12output = tf.matmul(attention_weights, value)
13return output
14# 定义多头注意力类,继承了Layer类
15class MultiHeadAttention(tf.keras.layers.Layer):
16def __init__(self, d_model, num_heads, name="multi_head_attention"):
17super(MultiHeadAttention, self).__init__(name=name)
18self.num_heads = num_heads
19self.d_model = d_model
20assert d_model % self.num_heads == 0
21self.depth = d_model //self.num_heads
22self.query_dense = Dense(units=d_model)
23self.key_dense = Dense(units=d_model)
24self.value_dense = Dense(units=d_model)
25self.dense = Dense(units=d_model)
26def get_config(self):
27config = super(MultiHeadAttention, self).get_config()
28config.update({
29'num_heads':self.num_heads,
30'd_model':self.d_model,
31})
32return config
33def split_heads(self, inputs, batch_size):
34inputs = Lambda(lambda inputs:tf.reshape(inputs, \
35shape=(batch_size, -1, self.num_heads, self.depth)))(inputs)
36return Lambda(lambda inputs: tf.transpose(inputs, perm=[0, 2, 1, 3]))(inputs)
37def call(self, inputs):
38query, key, value, mask = inputs['query'], inputs['key'], inputs[
39'value'], inputs['mask']
40batch_size = tf.shape(query)[0]
41# 线性层变换
42query = self.query_dense(query)
43key = self.key_dense(key)
44value = self.value_dense(value)
45# 分头
46query = self.split_heads(query, batch_size)
47key = self.split_heads(key, batch_size)
48value = self.split_heads(value, batch_size)
49# 定义缩放的点积注意力
50scaled_attention = scaled_dot_product_attention(query, key, value, mask)
51scaled_attention = Lambda(lambda scaled_attention: tf.transpose(
52scaled_attention, perm=[0, 2, 1, 3]))(scaled_attention)
53# 堆叠注意力头
54concat_attention = Lambda(lambda scaled_attention: tf.reshape( \
55scaled_attention,(batch_size, -1, self.d_model)))(scaled_attention)
56# 多头注意力最后一层
57outputs = self.dense(concat_attention)
58return outputs








5.9定义Transformer编码器

先完成编码器一个单元的定义,然后由多个单元合成整个编码器。编码逻辑如程序源码P5.4所示。





程序源码P5.4model.py之编码器定义


1# 编码器中的一层,即编码器的一个单元定义
2def encoder_layer(units, d_model, num_heads, dropout, name="encoder_layer"):
3inputs = tf.keras.Input(shape=(None, d_model), name="inputs")
4padding_mask = tf.keras.Input(shape=(1, 1, None), name="padding_mask")
5attention = MultiHeadAttention( \
6d_model, num_heads, name="attention")({ \
7'query': inputs,
8'key': inputs,
9'value': inputs,
10'mask': padding_mask
11})
12attention = Dropout(rate=dropout)(attention)
13add_attention = add([inputs,attention])
14attention = LayerNormalization(epsilon=1e-6)(add_attention)
15outputs = Dense(units=units, activation='relu')(attention)
16outputs = Dense(units=d_model)(outputs)
17outputs = Dropout(rate=dropout)(outputs)
18add_attention = add([attention,outputs])
19outputs = LayerNormalization(epsilon=1e-6)(add_attention)
20return Model(inputs=[inputs, padding_mask], outputs=outputs, name=name)
21# 测试
22sample_encoder_layer = encoder_layer(
23units=512,
24d_model=128,
25num_heads=4,
26dropout=0.3,
27name="sample_encoder_layer")
28plot_model(sample_encoder_layer, to_file='encoder_layer.png', show_shapes=True)
29# 定义编码器,由多个单元合成编码器
30def encoder(vocab_size,
31num_layers,
32units,
33d_model,
34num_heads,
35dropout,
36name="encoder"):
37inputs = Input(shape=(None,), name="inputs")
38padding_mask = Input(shape=(1, 1, None), name="padding_mask")
39embeddings = Embedding(vocab_size, d_model)(inputs)
40embeddings*=Lambda(lambda d_model: tf.math.sqrt(tf.cast(d_model, tf.float32)))(d_model)
41embeddings = PositionalEncoding(vocab_size,d_model)(embeddings)
42outputs = Dropout(rate=dropout)(embeddings)
43for i in range(num_layers):
44outputs = encoder_layer(
45units=units,
46d_model=d_model,
47num_heads=num_heads,
48dropout=dropout,
49name="encoder_layer_{}".format(i),
50)([outputs, padding_mask])
51return Model(inputs=[inputs, padding_mask], outputs=outputs, name=name)
52# 编码器测试
53sample_encoder = encoder(
54vocab_size=21128,
55num_layers=2,
56units=512,
57d_model=128,
58num_heads=4,
59dropout=0.3,
60name="sample_encoder")
61plot_model(sample_encoder, to_file='encoder.png', show_shapes=True)


测试编码器程序,观察生成的编码器逻辑结构,与Transformer论文解析的结构做对照,在实践中灵活配置编码器的相关参数,可得到适配问题需求的编码器。







5.10定义Transformer解码器

解码器的设计思路与编码器类似。先完成解码器一个单元的定义,然后由多个单元合成整个解码器。编码逻辑如程序源码P5.5所示。





程序源码P5.5model.py之解码器定义



1# 定义解码器中的一层,一个解码单元
2def decoder_layer(units, d_model, num_heads, dropout, name="decoder_layer"):
3inputs = Input(shape=(None, d_model), name="inputs")
4enc_outputs = Input(shape=(None, d_model), name="encoder_outputs")
5look_ahead_mask = Input(shape=(1, None, None), name="look_ahead_mask")
6padding_mask = Input(shape=(1, 1, None), name='padding_mask')
7attention1 = MultiHeadAttention(
8d_model, num_heads, name="attention_1")(inputs={
9'query': inputs,
10'key': inputs,
11'value': inputs,
12'mask': look_ahead_mask
13})
14add_attention = tf.keras.layers.add([attention1,inputs])
15attention1 = tf.keras.layers.LayerNormalization(epsilon=1e-6)(add_attention)
16attention2 = MultiHeadAttention(
17d_model, num_heads, name="attention_2")(inputs={
18'query': attention1,
19'key': enc_outputs,
20'value': enc_outputs,
21'mask': padding_mask
22})
23attention2 = Dropout(rate=dropout)(attention2)
24add_attention = add([attention2,attention1])
25attention2 = LayerNormalization(epsilon=1e-6)(add_attention)
26outputs = Dense(units=units, activation='relu')(attention2)
27outputs = Dense(units=d_model)(outputs)
28outputs = Dropout(rate=dropout)(outputs)
29add_attention = add([outputs,attention2])
30outputs = LayerNormalization(epsilon=1e-6)(add_attention)
31return Model(
32inputs=[inputs, enc_outputs, look_ahead_mask, padding_mask],
33outputs=outputs,
34name=name)
35# 测试
36sample_decoder_layer = decoder_layer(
37units=512,
38d_model=128,
39num_heads=4,
40dropout=0.3,
41name="sample_decoder_layer")
42plot_model(sample_decoder_layer, to_file='decoder_layer.png', show_shapes=True)
43# 定义解码器,合成多个解码单元
44def decoder(vocab_size,
45num_layers,
46units,
47d_model,
48num_heads,
49dropout,
50name='decoder'):
51inputs = Input(shape=(None,), name='inputs')
52enc_outputs = Input(shape=(None, d_model), name='encoder_outputs')
53look_ahead_mask = Input(shape=(1, None, None), name='look_ahead_mask')
54padding_mask = Input(shape=(1, 1, None), name='padding_mask')
55embeddings = Embedding(vocab_size, d_model)(inputs)
56embeddings *= Lambda(lambda d_model: tf.math.sqrt( \
57tf.cast(d_model, tf.float32)))(d_model)
58embeddings = PositionalEncoding(vocab_size, d_model)(embeddings)
59outputs = Dropout(rate=dropout)(embeddings)
60for i in range(num_layers):
61outputs = decoder_layer(
62units=units,
63d_model=d_model,
64num_heads=num_heads,
65dropout=dropout,
66name='decoder_layer_{}'.format(i),
67)(inputs=[outputs, enc_outputs, look_ahead_mask, padding_mask])
68return Model(
69inputs=[inputs, enc_outputs, look_ahead_mask, padding_mask],
70outputs=outputs,
71name=name)
72# 解码器测试
73sample_decoder = decoder(
74vocab_size=21128,
75num_layers=2,
76units=512,
77d_model=128,
78num_heads=4,
79dropout=0.3,
80name="sample_decoder")
81plot_model(sample_decoder, to_file='decoder.png', show_shapes=True)


测试解码器程序,观察生成的解码器逻辑结构,与Transformer论文解析的结构做对照,在实践中灵活配置解码器的相关参数,可得到适配问题需求的解码器。







5.11Transformer模型合成

在前面分步完成的各个模块的基础上,定义Transformer的完整模型。编程逻辑如程序源码P5.6所示。





程序源码P5.6model.py之Transformer定义


1# 定义Transformer模型
2def transformer(vocab_size,
3num_layers,
4units,
5d_model,
6num_heads,
7dropout,
8name="transformer"):
9inputs = Input(shape=(None,), name="inputs")
10dec_inputs = Input(shape=(None,), name="dec_inputs")
11enc_padding_mask = Lambda(
12create_padding_mask, output_shape=(1, 1, None),
13name='enc_padding_mask')(inputs)
14# 解码器第一个注意力块的前向掩码
15look_ahead_mask = Lambda(
16create_look_ahead_mask,
17output_shape=(1, None, None),
18name='look_ahead_mask')(dec_inputs)
19# 对编码器输出到解码器第二个注意力块的内容掩码
20dec_padding_mask = Lambda(
21create_padding_mask, output_shape=(1, 1, None),
22name='dec_padding_mask')(inputs)
23enc_outputs = encoder(
24vocab_size=vocab_size,
25num_layers=num_layers,
26units=units,
27d_model=d_model,
28num_heads=num_heads,
29dropout=dropout,
30)(inputs=[inputs, enc_padding_mask])
31dec_outputs = decoder(
32vocab_size=vocab_size,
33num_layers=num_layers,
34units=units,
35d_model=d_model,
36num_heads=num_heads,
37dropout=dropout,
38)(inputs=[dec_inputs, enc_outputs, look_ahead_mask, dec_padding_mask])
39outputs = Dense(units=vocab_size, name="outputs")(dec_outputs)
40return Model(inputs=[inputs, dec_inputs], outputs=outputs, name=name)
41# 测试
42sample_transformer = transformer(
43vocab_size=21128,
44num_layers=4,
45units=512,
46d_model=128,
47num_heads=4,
48dropout=0.3,
49name="sample_transformer")
50plot_model(sample_transformer, to_file='transformer.png', show_shapes=True)


查看生成的Transformer结构图,理解Transformer的逻辑运算过程。实践中可灵活调整相关参数配置,以与目标问题相适配。

Transformer模型定义期间,为了测试各模块程序的逻辑,在每一个模块后面都编写了测试语句。模型程序model.py可以迭代运行,观察测试语句的输出结果,加强对模型的理解。其中的plot_model函数可以绘制模型结构图,但是需要安装图形支持包graphviz,根据程序运行时的相关提示,完成环境配置。

Transformer各模块的逻辑解析及其测试,参见视频教程。







5.12模型结构与参数配置

从本节到5.16节,分步完成聊天机器人模型的训练与评估。打开5.5节创建的主程序main.py,首先完成库的导入和Transformer聊天机器人的参数配置和结构定义。编程逻辑如程序源码P5.7所示。





程序源码P5.7main.py之模型结构与参数配置


1from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping
2from tensorflow.keras.losses import SparseCategoricalCrossentropy
3from tensorflow import multiply, minimum
4from tensorflow.keras.optimizers.schedules import LearningRateSchedule
5from tensorflow.keras.metrics import sparse_categorical_accuracy
6from tensorflow.python.ops.math_ops import rsqrt
7from tensorflow.keras.optimizers import Adam
8from bert.tokenization.bert_tokenization import FullTokenizer
9import numpy as np
10# 用BLEU方法评估模型
11from nltk.translate.bleu_score import sentence_bleu
12from transformer.model import *
13from transformer.dataset import *
14if __name__ == "__main__" :
15dialog_list = json.loads( \
16codecs.open("transformer/dataset/dialog_release.json", \
17"r", "utf-8").read())
18print(dialog_list[0])# 第一条数据
19# 以下参数可根据需要调整,为了演示,可以将相关参数调低一些
20# 最大句子长度
21MAX_LENGTH = 40
22# 最大样本数量
23MAX_SAMPLES = 120000 
# 可根据需要调节
24BATCH_SIZE = 64 # 批处理大小
25# Transformer参数定义
26NUM_LAYERS = 2 # 编码器解码器block重复数,论文中是6
27D_MODEL = 128 # 编码器解码器宽度,论文中是512
28NUM_HEADS = 4 # 注意力头数,论文中是8
29UNITS = 512 # 全连接网络宽度,论文中输入输出为512
30DROPOUT = 0.1 # 与论文一致
31VOCAB_SIZE = 21128 # BERT词典长度
32START_TOKEN = [VOCAB_SIZE] # 序列起始标志
33END_TOKEN = [VOCAB_SIZE + 1] # 序列结束标志
34VOCAB_SIZE = VOCAB_SIZE + 2 # 加上开始与结束标志后的词典长度
35EPOCHS = 50 # 训练代数
36bert_vocab_file = 'transformer/dataset/vocab.txt'
37tokenizer = FullTokenizer(bert_vocab_file)
38# 聊天模型参数配置与结构定义
39model = transformer(
40vocab_size=VOCAB_SIZE,
41num_layers=NUM_LAYERS,
42units=UNITS,
43d_model=D_MODEL,
44num_heads=NUM_HEADS,
45dropout=DROPOUT)
46model.summary()


为了满足教学演示需要,程序源码P5.7中将Transformer编码器与解码器的单元数均缩减为2,其他参数也有相应缩减,模型可训练参数总量为9060746个。

模型采用了BERT分词方法,故需要安装BERT模型库。安装命令为: 

pip install bert-for-tf2

模型采用了两种评价方法: 一是计算模型的回归损失; 二是计算BLEU得分。需要安装BLEU函数库。安装命令为: 

pip install nltk








5.13学习率动态调整

为了优化模型训练过程,加快模型收敛速度,指定了学习率动态调整策略,编码逻辑如程序源码P5.8所示。





程序源码P5.8main.py之学习率动态调整


1# 学习率动态调整
2class CustomSchedule(LearningRateSchedule):
3def __init__(self, d_model, warmup_steps=4000):
4super(CustomSchedule, self).__init__()
5self.d_model = tf.constant(d_model, dtype=tf.float32)
6self.warmup_steps = warmup_steps
7def get_config(self):
8return {"d_model": self.d_model, "warmup_steps": self.warmup_steps}
9def __call__(self, step):
10arg1 = rsqrt(step)
11arg2 = step * (self.warmup_steps ** -1.5)
12return multiply(rsqrt(self.d_model), minimum(arg1, arg2))
13# 测试
14sample_learning_rate = CustomSchedule(d_model=256)
15plt.plot(sample_learning_rate(tf.range(200000, dtype=tf.float32)))
16plt.ylabel("Learning Rate")
17plt.xlabel("Train Step")
18plt.show()
19learning_rate = CustomSchedule(D_MODEL)# 学习率


学习率变化曲线如图5.11所示,训练初期学习率采取上升策略以加快训练速度,训练中期保持学习率为一个稳定值,训练后期对学习率采取衰减策略,以期寻找最优解。



图5.11学习率变化曲线


当然,图5.11显示本案例跳过了学习率恒定的阶段,在达到最高值后直接开始衰减。







5.14模型训练过程

模型训练之前,指定模型采用的优化算法为Adam,定义分类交叉熵损失函数,并定义准确率评价标准,完成模型编译。训练过程中,保存可能取得的最优模型的权重,用提前终止回调函数控制模型训练进程。编码逻辑如程序源码P5.9所示。





程序源码P5.9main.py之模型训练过程



1# 定义损失函数
2def loss_function(y_true, y_pred):
3y_true = tf.reshape(y_true, shape=(-1, MAX_LENGTH - 1))
4loss = SparseCategoricalCrossentropy(
5from_logits=True, reduction='none')(y_true, y_pred)
6mask = tf.cast(tf.not_equal(y_true, 0), tf.float32)
7loss = tf.multiply(loss, mask)
8return tf.reduce_mean(loss)
9# 自定义准确率函数
10def accuracy(y_true, y_pred):
11# 调整标签的维度为:(batch_size, MAX_LENGTH - 1)
12y_true = tf.reshape(y_true, shape=(-1, MAX_LENGTH - 1))
13return sparse_categorical_accuracy(y_true, y_pred)
14# 优化算法
15optimizer = Adam(learning_rate, beta_1=0.9, beta_2=0.98, epsilon=1e-9)
16model.compile(optimizer=optimizer, loss=loss_function, metrics=[accuracy])
17# 定义回调函数:保存最优模型
18checkpoint = ModelCheckpoint("robot_weights.h5",
19monitor="val_loss",
20mode="min",
21save_best_only=True,
22save_weights_only=True,
23verbose=1)
24# 定义回调函数:提前终止训练
25earlystop = EarlyStopping(monitor='val_loss',
26min_delta=0,
27patience=10,
28verbose=1,
29restore_best_weights=True)
30# 将回调函数组织为回调列表
31callbacks = [earlystop, checkpoint]
32dialog_file = 'transformer/dataset/dialog_release.json'
33train_file = 'transformer/dataset/train.txt'
34valid_file = 'transformer/dataset/dev.txt'
35class Hparams() :
36def __init__(self,start_token,end_token,batchSize,total_samples,max_length):
37self.start_token = start_token
38self.end_token = end_token
39self.batchSize = batchSize
40self.total_samples = total_samples
41self.max_length = max_length
42hparams = Hparams
43hparams.start_token = START_TOKEN
44hparams.end_token =END_TOKEN
45hparams.total_samples = MAX_SAMPLES
46hparams.batchSize = BATCH_SIZE
47hparams.max_length = MAX_LENGTH
48# 加载并划分数据集
49train_dataset, valid_dataset = get_dataset(hparams, tokenizer, dialog_file,
50train_file, valid_file)
51# 模型训练
52history = model.fit(train_dataset, epochs=EPOCHS, validation_data=valid_dataset,
53callbacks=callbacks)


执行程序源码P5.9,开始模型训练。训练结束后,在当前目录下会保存最佳模型的权重文件robot_weights.h5。

如果计算机配置不够,可以先不要考虑模型可用性,适当降低模型参数配置,先运行并通过项目逻辑。

本项目在Kaggle服务器上的训练结果可以参见链接https://www.kaggle.com/code/upsunny/naturalconvchatbot/notebook。

如果训练模型的计算机配置过低,无法完成模型训练时,可以先将本书素材包中的robot_weights_l2.h5模型复制到MyRobot的models目录下。按照视频教程演示的方法,调用预训练模型完成后续测试任务。







5.15损失函数与准确率曲线

绘制模型损失函数曲线与准确率曲线,有助于观察模型的过拟合情况,判断模型的泛化能力。编码逻辑如程序源码P5.10所示。





程序源码P5.10main.py之损失函数与准确率曲线



1# 损失函数曲线
2plt.figure(figsize=(12, 6))
3x = range(1, len(history.history['loss']) + 1)
4plt.plot(x, history.history['loss'])
5plt.plot(x, history.history['val_loss'])
6plt.xticks(x)
7plt.ylabel('Loss')
8plt.xlabel('Epoch')
9plt.legend(['train', 'test'])
10plt.title('Loss over training epochs')
11plt.savefig('loss.png')
12plt.show()
13# 准确率曲线
14plt.figure(figsize=(12, 6))
15plt.plot(x, history.history['accuracy'])
16plt.plot(x, history.history['val_accuracy'])
17plt.ylabel('Accuracy')
18plt.xlabel('Epoch')
19plt.xticks(x)
20plt.legend(['train', 'test'])
21plt.title('Accuracy over training epochs')
22plt.savefig('acc.png')
23plt.show()


损失函数曲线如图5.12所示。模型在第14代之前,损失保持了较快的下降速度。从第20代开始,模型优化的幅度不明显,逐渐呈现过拟合趋势。





图5.12损失函数曲线


准确率曲线如图5.13所示,与损失函数曲线基本保持了一致的判断。在第14代之前,模型准确率保持较快的增长,第20代之后,训练集上的准确率仍保持缓慢增长,验证集上的准确率则几乎保持不变,模型逐渐呈现过拟合趋势。




图5.13准确率曲线


模型设置了提前结束的条件,如果连续10代的损失函数不下降,则提前终止模型训练。这就是为什么设置了50代的训练,却在第38代终止训练的原因。







5.16聊天模型评估与测试

用5.15节训练好的模型做随机对话测试,并用BLEU评分观察预测结果。编码逻辑如程序源码P5.11所示。






程序源码P5.11main.py之聊天模型评估与测试



1# 加载训练好的模型
2model.load_weights('models/robot_weights_l2.h5')
3# 用模型做聊天推理,A、B两人聊天,输入 A 的句子,得到 B 的回应
4def evaluate(sentence):
5sentence = tokenizer.tokenize(sentence)
6sentence = START_TOKEN + tokenizer.convert_tokens_to_ids(sentence) + END_TOKEN
7sentence = tf.expand_dims(sentence, axis=0)
8output = tf.expand_dims(START_TOKEN, 0)
9for i in range(MAX_LENGTH):
10predictions = model(inputs=[sentence, output], training=False)
11# 选择最后一个输出
12predictions = predictions[:, -1:, :]
13predicted_id = tf.cast(tf.argmax(predictions, axis=-1), tf.int32)
14# 如果是END_TOKEN则结束预测
15if tf.equal(predicted_id, END_TOKEN[0]):
16break
17# 把已经得到的预测值串联起来,作为解码器的新输入
18output = tf.concat([output, predicted_id], axis=-1)
19return tf.squeeze(output, axis=0)
20# 模拟聊天间的问答,输入问话,输出回答
21def predict(question):
22prediction = evaluate(question)# 调用模型推理
23predicted_answer = tokenizer.convert_ids_to_tokens(
24np.array([i for i in prediction if i < VOCAB_SIZE - 2]))
25print(f'问话者: {question}')
26print(f'答话者: {"".join(predicted_answer)}')
27return predicted_answer
28# 几组随机测试
29output1 = predict('嗨,你好呀。') # 训练集中的样本
30print("")
31output2 = predict('昨晚的比赛你看了吗?') # 随机问话1
32print("")
33output3 = predict('你最喜欢的人是谁?') # 随机问话2
34print("")
35output4 = predict('真热,下点儿雨就好了') # 随机问话3
36print("")
37output5 = predict('这个老师讲课怎么样?') # 随机问话4
38print("")
39output6 = predict('今天收获大吗?') # 随机问话5
40print("")
41# 多轮对话测试,自问自答
42sentence = '你最近听说过《中国女排》这部电影吗?'
43for _ in range(5):
44sentence = "".join(predict(sentence))
45print("")
46reference = '是呀,我觉得这部《中国女排》应该能有很高的收视率。'
47pred_sentence = predict(reference)
48reference = tokenizer.tokenize(reference)
49# 1-gram BLEU计算
50BLEU_1 = sentence_bleu([reference], pred_sentence, weights=(1, 0, 0, 0))
51print(f"\n BLEU-1 评分: {BLEU_1}")
52# 2-gram BLEU计算
53BLEU_2 = sentence_bleu([reference], pred_sentence, weights=(0.5, 0.5, 0, 0))
54print(f"\n BLEU-2 评分: {BLEU_2}")
55# 3-gram BLEU计算
56BLEU_3 = sentence_bleu([reference], pred_sentence, weights=(0.33, 0.33, 0.33, 0))
57print(f"\n BLEU-3 评分:: {BLEU_3}")
58# 4-gram BLEU计算
59BLEU_4 = sentence_bleu([reference], pred_sentence, weights=(0.25, 0.25, 0.25, 0.25))
60print(f"\n BLEU-4 评分:: {BLEU_4}")
61# 5-gram BLEU计算
62BLEU_5 = sentence_bleu([reference], pred_sentence, weights=(0.2, 0.2, 0.2, 0.2, 0.2))
63print(f"\n BLEU-5 评分:: {BLEU_5}")


程序源码P5.11测试结果如表5.5所示。假定问话者由人扮演,答话者由机器扮演。自问自答部分由机器同时扮演两个角色。


表5.5聊天模型推理抽样观察




简单问答: (一问一答模式)

问话者: 嗨,你好呀。

答话者: 你好。

问话者: 昨晚的比赛你看了吗?

答话者: 看了,昨天我看了,昨天晚上的比赛,我看了。

问话者: 你最喜欢的人是谁?

答话者: 我喜欢的就是那个梅西,他的球技也很好。

问话者: 真热,下点儿雨就好了。

答话者: 是啊,我也是刚刚看到这个新闻,你看了吗?

问话者: 这个老师讲课怎么样?

答话者: 我们学校的教师,学校也是很重要的。

问话者: 今天收获大吗?

答话者: 今天休班。

自问自答: (连续推理测试)

问话者: 你最近听说过《中国女排》这部电影吗?

答话者: 没有哎,我最近没怎么关注电影。

问话者: 没有哎,我最近没怎么关注电影。

答话者: 你看了吗?

问话者: 你看了吗?

答话者: 看了,这部电影的预告片很好看。

问话者: 看了,这部电影的预告片很好看。

答话者: 是啊,这部电影的主演是谁啊?

问话者: 是啊,这部电影的主演是谁啊?

答话者: 这部电影是《中国机长》,叫《我的祖国》。


注意: 表中的对话解析参见视频教程。

模型还对下面这组问答给出了BLEU评分。

问话者: 是呀,我觉得这部《中国女排》应该能拿下很高的收视率。

答话者: 是呀,这次的世界杯的表现也是非常不错的。

BLEU1评分: 0.22517932221294598

BLEU2评分: 0.1332178835716084

BLEU3评分: 0.09227103858589292

BLEU4评分: 1.8955151000606497e-78

BLEU5评分: 1.8662507507148366e-124

事实上,受限于答话句子的长度,BLEU4与BLEU5评分没有实际意义。BLEU1的得分值表明模型具备一定的可用性与参考性。

直观看,机器的回答是有些跑题,甚至答非所问。但是似乎前后又有一定联系。因为前者说收视率很高,是一个正面评价。后者给出的是对世界杯的正面评价。或许,机器人并不知道如何理解和接续问话者的表达,只是根据自己建模得到的经验做了一个力所能及的回答。

至于为什么这个回答与世界杯有关,而不是与电影有关,一是问话者的语言中包含“中国女排”,这可以理解为体育相关的话题; 二是在5.3节已经指明,给定的数据集偏重体育语料,会使得训练的模型偏爱体育表达。

事实上,对人类之间的交流而言,这完全不是问题,因为其中的“这部”“收视率”等字眼表明谈论的《中国女排》是一部电影。







5.17聊天模型部署到服务器

将训练好的Transformer聊天模型部署到Web服务器上,可以实现一对多的聊天服务。在第1章已经搭建了一个基于Flask的Web API服务框架。在此基础上,迭代追加支持机器人聊天的Web API设计。

打开Server目录下的app.py程序,追加聊天机器人的服务逻辑,如程序源码P5.12所示。





程序源码P5.12app.py之聊天模型部署到服务器


1# Transformer参数定义
2# 最大句子长度
3MAX_LENGTH = 40
4NUM_LAYERS = 2 # 编码器解码器block重复数,论文中是6
5D_MODEL = 128 # 编码器解码器宽度,论文中是512
6NUM_HEADS = 4 # 注意力头数,论文中是8
7UNITS = 512 # 全连接网络宽度,论文中输入输出为512
8DROPOUT = 0.1 # 与论文一致
9VOCAB_SIZE = 21128 # 词典长度
10START_TOKEN = [VOCAB_SIZE] # 序列起始标志
11END_TOKEN = [VOCAB_SIZE + 1] # 序列结束标志
12VOCAB_SIZE = VOCAB_SIZE + 2 # 加上开始与结束标志后的词典长度
13# 分词器
14bert_vocab_file = '../MyRobot/transformer/dataset/vocab.txt'
15tokenizer = FullTokenizer(bert_vocab_file)
16# 模型
17robot_model = transformer(
18vocab_size=VOCAB_SIZE,
19num_layers=NUM_LAYERS,
20units=UNITS,
21d_model=D_MODEL,
22num_heads=NUM_HEADS,
23dropout=DROPOUT)
24# 加载权重文件。注意,上面的模型参数必须与权重文件对应的结构一致
25robot_model.load_weights('../MyRobot/models/robot_weights_l2.h5')
26# 用模型做聊天推理,A、B两人聊天,输入 A 的句子,得到 B 的回应
27def robot_evaluate(sentence):
28sentence = tokenizer.tokenize(sentence)
29sentence = START_TOKEN + tokenizer.convert_tokens_to_ids(sentence) + END_TOKEN
30sentence = tf.expand_dims(sentence, axis=0)
31output = tf.expand_dims(START_TOKEN, 0)
32for i in range(MAX_LENGTH):
33predictions = robot_model(inputs=[sentence, output], training=False)
34# 选择最后一个输出
35predictions = predictions[:, -1:, :]
36predicted_id = tf.cast(tf.argmax(predictions, axis=-1), tf.int32)
37# 如果是END_TOKEN则结束预测
38if tf.equal(predicted_id, END_TOKEN[0]):
39break
40# 把已经得到的预测值串联起来,作为解码器的新输入
41output = tf.concat([output, predicted_id], axis=-1)
42return tf.squeeze(output, axis=0)
43# 输入问话,输出回答
44def robot_predict(question):
45prediction = robot_evaluate(question)
46predicted_answer = tokenizer.convert_ids_to_tokens(
47np.array([i for i in prediction if i < VOCAB_SIZE - 2]))
48return "".join(predicted_answer)
49# 机器人聊天API
50@app.route('/robot', methods=['post'])
51def robot():
52message = request.get_json(force=True)
53question = message['question']
54answer = robot_predict(question)
55response = {
56'answer': answer
57}
58print(response)
59return jsonify(response), 200


待完成Android客户机后,再与服务器做联合测试。







5.18Android项目初始化

新建Android项目,模板选择Empty Activity,项目参数设定如图5.14所示。项目名称为AndroidChatBot,包


图5.14项目参数设定


名称为cn.edu.ldu.androidchatbot,编程语言为Kotlin,SDK最小版本号设置为API 21: Android 5.0(Lollipop),单击Finish,完成项目初始化。


选择项目根目录,右击,借助快捷菜单命令New→Package分别新建ui、utils、network、data四个子目录。各子目录的功能及其包含的程序如表5.6所示。


表5.6项目各子目录的功能及其包含的程序




子 目 录 名程序名功能
ui
MainActivity主控界面逻辑控制

MessagingAdapter主控界面数据适配器

utils
Constant定义全局性常量对象

Time定义时间戳对象
networkApiService定义网络访问服务接口
data
Answer匹配服务器应答消息结构的实体类

Message消息实体类




图5.15项目初始结构


依照表5.6的提示,依次完成各个程序模块的创建。MainActivity在项目初始化时已自动生成,将其移动到ui目录下即可。项目初始结构如图5.15所示。


其他一些简单的初始化工作包括: 

(1) 定义实体类Answer。

data class Answer(val answer:String)

(2) 定义实体类Message。

data class Message(val message:String, val id:String, val time:String)

(3) 在清单文件中声明Internet访问权限。

<uses-permission android:name="android.permission.INTERNET" />

(4) 支持HTTP通信。

考虑到本案例采用HTTP通信,还需要在appliacation节点中添加支持HTTP的属性。

android:usesCleartextTraffic="true"

(5) 添加模块依赖。

在模块配置文件开头的plugins{}节点后面添加Kotlin扩展语句。

apply plugin: 'kotlin-android-extensions'

在末尾的dependencies{}节点中追加以下依赖库: 



// RecyclerView

implementation("androidx.recyclerview:recyclerview:1.2.1")







// For control over item selection of both touch and mouse driven selection

implementation("androidx.recyclerview:recyclerview-selection:1.1.0")

// Coroutines

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0'

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'

// Retrofit2

implementation "com.squareup.retrofit2:retrofit:2.9.0"

implementation "com.squareup.retrofit2:converter-scalars:2.9.0"

implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

// Glide

implementation 'com.github.bumptech.glide:glide:4.11.0'

annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'





注意,修改build.gradle文件之后,需要同步更新项目配置。系统设计过程中,可根据需要,随时为模块引入相关依赖库。

(6) 定义工具性对象类。

Constant定义两个常量标识符,用于区别消息发送者和接收者。



package cn.edu.ldu.androidchatbot.utils

object Constant {

const val SEND_ID = "SEND_ID"

const val RECEIVE_ID = "RECEIVE_ID"

}





Time定义时间戳,表示消息收发时间。



package cn.edu.ldu.androidchatbot.utils

import java.sql.Date

import java.sql.Timestamp

import java.text.SimpleDateFormat

object Time {

fun timeStamp(): String {

val timeStamp = Timestamp(System.currentTimeMillis())

val sdf = SimpleDateFormat("HH:mm")

val time = sdf.format(Date(timeStamp.time))

return time.toString()

}

}





(7) 定义网络通信模块。



package cn.edu.ldu.androidchatbot.network

import okhttp3.RequestBody

import okhttp3.ResponseBody

import retrofit2.Response

import retrofit2.http.Body







import retrofit2.http.POST

interface ApiService {

@POST("/robot")

suspend fun getAnswer(@Body body: RequestBody): Response<ResponseBody>

}





修改程序名称为“我的聊天机器人”。接下来,转入界面设计和主控逻辑设计。







5.19Android聊天界面设计

聊天界面是程序的主控界面,核心控件是构建聊天列表的RecyclerView。主控界面由布局文件activity_main.xml定义。RecyclerView中的单行布局由message_item.xml定义。

为了美化界面风格,定义两个形状控件,用于渲染聊天消息的背景。send_box.xml定义的形状用于衬托和突出用户发送的消息。round_box.xml定义的形状作为发送按钮的背景轮廓。receive_box.xml定义的形状用于衬托和突出用户收到的消息。

相关资源列表如图5.16所示。

选择drawable目录,右击,在弹出的快捷菜单中执行New→Drawable Recourse File命令,设置文件名称为receive_box,根元素为shape,如图5.17所示,单击OK按钮,创建形状资源文件。



图5.16与界面布局相关的资源列表




图5.17创建receive_box.xml文件



替换receive_box.xml文件内容。



<?xml version="1.0" encoding="utf-8"?>

<shape xmlns:android="http://schemas.android.com/apk/res/android">

<corners android:topLeftRadius="0dp"

android:topRightRadius="20dp"

android:bottomLeftRadius="20dp"

android:bottomRightRadius="20dp"/>

</shape>





这是一个左上角为直角、其他三个角为圆角的矩形框。

用类似的方法定义send_box.xml。



<?xml version="1.0" encoding="utf-8"?>

<shape xmlns:android="http://schemas.android.com/apk/res/android">

<corners android:topLeftRadius="20dp"

android:topRightRadius="20dp"

android:bottomLeftRadius="20dp"

android:bottomRightRadius="0dp"/>

</shape>





这是一个右下角为直角、其他三个角为圆角的矩形框。

再定义round_box.xml,这是一个四个角都是圆角的矩形框,用于修饰主界面下方文本框和按钮所在的区域。



<?xml version="1.0" encoding="utf-8"?>

<shape xmlns:android="http://schemas.android.com/apk/res/android">

<corners android:radius="20dp"></corners>

</shape>





用程序源码P5.13所示的脚本,完成主控界面布局。





程序源码P5.13activity_main.xml主控界面布局脚本



1<?xml version="1.0" encoding="utf-8"?>
2<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
3xmlns:tools="http://schemas.android.com/tools"
4android:layout_width="match_parent"
5android:layout_height="match_parent"
6tools:context=".ui.MainActivity">
7<LinearLayout
8android:id="@+id/ll_layout_bar"
9android:layout_width="match_parent"
10android:layout_height="wrap_content"
11android:layout_alignParentBottom="true"
12android:background="#E4E4E4"
13android:orientation="horizontal">
14<EditText
15android:id="@+id/input_box"
16android:inputType="textShortMessage"
17android:layout_width="match_parent"
18android:layout_height="wrap_content"
19android:layout_margin="10dp"
20android:layout_weight=".5"
21android:background="@drawable/round_box"
22android:backgroundTint="@android:color/white"
23android:hint="输入消息..."
24android:padding="10dp"
25android:singleLine="true" />
26<Button
27android:id="@+id/btn_send"
28android:layout_width="match_parent"
29android:layout_height="match_parent"
30android:layout_margin="10dp"
31android:layout_weight="1"
32android:background="@drawable/round_box"
33android:backgroundTint="#26A69A"
34android:text="发 送"
35android:textColor="@android:color/white" />
36</LinearLayout>
37<androidx.recyclerview.widget.RecyclerView
38android:id="@+id/chat_view"
39android:layout_width="match_parent"
40android:layout_height="match_parent"
41android:layout_above="@id/ll_layout_bar"
42android:layout_below="@+id/dark_divider"
43tools:itemCount="20"
44tools:listitem="@layout/message_item" />
45<View
46android:layout_width="match_parent"
47android:layout_height="10dp"
48android:background="#42A5F5"
49android:id="@+id/dark_divider" />
50</RelativeLayout>


还有最后一项工作,定义RecyclerView中每一行的结构。

选择layout目录,右击,在弹出的快捷菜单中执行New→Layout Resource File命令,如图5.18所示,设定文件名称为message_item,单击OK按钮。





图5.18创建单行布局文件


用程序源码P5.14所示的脚本程序,定义单行布局。





程序源码P5.14message_item.xml单行布局文件



1<?xml version="1.0" encoding="utf-8"?>
2<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
3android:layout_width="match_parent"
4android:layout_height="wrap_content">
5<TextView
6android:id="@+id/tv_message"
7android:layout_width="200dp"
8android:layout_height="wrap_content"
9android:layout_margin="4dp"
10android:background="@drawable/send_box"
11android:backgroundTint="#26A69A"
12android:padding="14dp"
13android:text="发出的消息"
14android:textColor="@android:color/white"
15android:textSize="18sp"
16android:layout_alignParentEnd="true" />
17<TextView
18android:visibility="visible"
19android:id="@+id/tv_bot_message"
20android:layout_width="200dp"
21android:layout_height="wrap_content"
22android:layout_margin="4dp"
23android:background="@drawable/receive_box"
24android:backgroundTint="#FF7043"
25android:padding="14dp"
26android:text="收到的消息"
27android:textColor="@android:color/white"
28android:textSize="18sp"
29android:layout_alignParentStart="true" />
30</RelativeLayout>


运行主程序,观察主控界面效果。







5.20Android聊天逻辑设计

主控逻辑包括两部分: 一部分放在ActivityMain中; 另一部分放在MessagingAdapter中。在ui包目录下新建消息适配器程序MessagingAdapter,编码逻辑如程序源码P5.15所示。





程序源码P5.15MessagingAdapter消息适配器


1package cn.edu.ldu.androidchatbot.ui
2import android.view.LayoutInflater
3import android.view.View
4import android.view.ViewGroup
5import androidx.recyclerview.widget.RecyclerView
6import cn.edu.ldu.androidchatbot.R
7import cn.edu.ldu.androidchatbot.data.Message
8import cn.edu.ldu.androidchatbot.utils.Constant.RECEIVE_ID
9import cn.edu.ldu.androidchatbot.utils.Constant.SEND_ID
10import kotlinx.android.synthetic.main.message_item.view.*
11class MessagingAdapter: RecyclerView.Adapter<MessagingAdapter.MessageViewHolder>() {
12var messageList = mutableListOf<Message>()
13inner class MessageViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
14init {
15itemView.setOnClickListener {
16messageList.removeAt(adapterPosition)
17notifyItemRemoved(adapterPosition)
18}
19}
20}
21override fun onCreateViewHolder(parent: ViewGroup,
22viewType: Int): MessageViewHolder {
23return MessageViewHolder(
24LayoutInflater.from(parent.context).inflate(
25R.layout.message_item, parent, false))
26}
27override fun onBindViewHolder(holder: MessageViewHolder, position: Int) {
28val currentMessage = messageList[position]
29when (currentMessage.id) {
30SEND_ID -> {
31holder.itemView.tv_message.apply {
32text = currentMessage.message
33visibility = View.VISIBLE
34}
35holder.itemView.tv_bot_message.visibility = View.GONE
36}
37RECEIVE_ID -> {
38holder.itemView.tv_bot_message.apply {
39text = currentMessage.message
40visibility = View.VISIBLE
41}
42holder.itemView.tv_message.visibility = View.GONE
43}
44}
45}
46override fun getItemCount(): Int {
47return messageList.size
48}
49fun insertMessage(message:Message) {
50this.messageList.add(message)
51notifyItemInserted(messageList.size)
52notifyDataSetChanged()
53}
54}


ActivityMain编码逻辑如程序源码P5.16所示,负责与用户的交互,包括收发消息及界面滚动。收发消息均通过后台协程实现。





程序源码P5.16ActivityMain主控逻辑



1package cn.edu.ldu.androidchatbot.ui
2import androidx.appcompat.app.AppCompatActivity
3import android.os.Bundle
4import androidx.recyclerview.widget.LinearLayoutManager
5import cn.edu.ldu.androidchatbot.R
6import cn.edu.ldu.androidchatbot.data.Answer
7import cn.edu.ldu.androidchatbot.data.Message
8import cn.edu.ldu.androidchatbot.network.ApiService
9import cn.edu.ldu.androidchatbot.utils.Constant.RECEIVE_ID
10import cn.edu.ldu.androidchatbot.utils.Constant.SEND_ID
11import cn.edu.ldu.androidchatbot.utils.Time
12import com.google.gson.Gson
13import kotlinx.android.synthetic.main.activity_main.*
14import kotlinx.coroutines.Dispatchers
15import kotlinx.coroutines.GlobalScope
16import kotlinx.coroutines.launch
17import kotlinx.coroutines.withContext
18import okhttp3.MediaType
19import okhttp3.RequestBody
20import org.json.JSONObject
21import retrofit2.Retrofit
22import retrofit2.converter.gson.GsonConverterFactory
23class MainActivity : AppCompatActivity() {
24// 腾讯服务器教学演示地址
25private val BASE_URL = "http://120.53.107.28"
26// private val BASE_URL = "http://192.168.0.103:5000" // 本地服务器地址
27private val retrofit = Retrofit.Builder()// 初始化Retrofit框架
28.addConverterFactory(GsonConverterFactory.create())
29.baseUrl(BASE_URL)
30.build()
31.create(ApiService::class.java)
32private lateinit var adapter:MessagingAdapter
33var messageList = mutableListOf<Message>() // 消息列表
34override fun onCreate(savedInstanceState: Bundle?) {
35super.onCreate(savedInstanceState)
36setContentView(R.layout.activity_main)
37recyclerView() // 列表视图
38clickEvents()  // 单击事件
39customMessage("你好,很高兴见到你!") // 欢迎语
40}
41private fun clickEvents() { 
42btn_send.setOnClickListener { // 发送消息单击事件
43sendMessage()
44}
45input_box.setOnClickListener { // 输入消息事件
46GlobalScope.launch {
47withContext(Dispatchers.Main){
48chat_view.scrollToPosition(adapter.itemCount-1)
49}
50}
51}
52}
53override fun onStart() {
54super.onStart()
55GlobalScope.launch {
56withContext(Dispatchers.Main) {
57chat_view.scrollToPosition(adapter.itemCount-1)
58}
59}
60}
61private fun recyclerView() { // 视图绑定到消息适配器
62adapter = MessagingAdapter()
63chat_view.adapter = adapter
64chat_view.layoutManager = LinearLayoutManager(applicationContext)
65}
66private fun sendMessage() { // 发送消息
67val message = input_box.text.toString()
68val timeStamp = Time.timeStamp()
69if (message.isNotEmpty()) {
70messageList.add(Message(message,SEND_ID,timeStamp))
71input_box.setText("")
72adapter.insertMessage(Message(message,SEND_ID,timeStamp))
73chat_view.scrollToPosition(adapter.itemCount-1)
74botResponse(message)
75}
76}
77private fun botResponse(message: String) { // 接收消息
78val timeStamp = Time.timeStamp()
79GlobalScope.launch(Dispatchers.Main) {
80val request = JSONObject()
81request.put("question", message)
82val body: RequestBody =
83RequestBody.create(
84MediaType.parse("application/json"),
85request.toString()
86)
87val response = retrofit.getAnswer(body)
88if (response.isSuccessful) {
89val json: String = response.body()!!.string()
90var gson = Gson()
91var reply = gson.fromJson(
92json,
93Answer::class.java
94)
95messageList.add(Message(reply.answer,RECEIVE_ID,timeStamp))
96adapter.insertMessage(Message(reply.answer,RECEIVE_ID,timeStamp))
97chat_view.scrollToPosition(adapter.itemCount-1)
98}else{ }
99}
100}
101private fun customMessage(message: String) { // 自定义欢迎消息
102GlobalScope.launch {
103val timeStamp = Time.timeStamp()
104withContext(Dispatchers.Main){
105messageList.add(Message(message,RECEIVE_ID,timeStamp))
106adapter.insertMessage(Message(message,RECEIVE_ID,timeStamp))
107chat_view.scrollToPosition(adapter.itemCount-1)
108}
109}
110}
111}



程序解析参见本节视频教程。







5.21客户机与服务器联合测试 

现在可以开始项目联合测试了。先运行Web服务器,再运行客户机。可以用模拟器测试,也可以用真机测试。既可以本地测试,也可以远程测试。

如果做本地测试,首先运行Server目录下的服务器程序app.py,观察控制台上输出的服务器地址,将程序源码P5.16中第26行语句中的地址修改为Web API运行的地址,注释掉第25行中的远程服务器地址,然后做客户机与服务器的本地联合测试。

如果做远程测试,可以采用书中的腾讯服务器地址,直接运行Android客户机程序,在模拟器与真机上与远程服务器做人机畅聊测试。图5.19给出了一组随机对话,显示了用真机分别与本地服务器和远程服务器通信的测试效果。


图5.19真机与本地服务器和远程服务器通信的测试效果





至此,本章实现的聊天机器人已经初露锋芒。无论其聊天水平怎么样,对于一个仅有900万训练参数的小模型,依靠一个超小规模语料库,实现的语言对答还是令人惊讶的。今天的一小步,孕育着未来的一大步。

事实上,图5.19展示的人机聊天,是故意增加了难度的。首先,语料库中关于健康和航天类的语料是偏少的。其次,对于第一组聊天,涉及奥密克戎这个新生词汇,超过了模型的“知识范畴”; 对于第二组聊天,航天的话题无疑也超过了模型的“知识范畴”。

可以试试体育类的话题,机器人的回答则会生动得多,有趣得多。做完这个项目,逗着自己缔造的机器人玩玩,成就感与幸福感如期而至。原来,是如此容易让机器说话的呀。更多测试细节,参见本节视频教程。







5.22小结 

本章以人机畅聊的境界追求为动力,遵循机器问答的技术设计路线,完成了Transformer聊天模型+Web API+Android聊天客户机的项目设计。本章项目对于提升读者在自然语言智能领域的理论水平和实践能力,具备非常好的教学示范效果。

沿着语料库分析,Transformer模型解析,聊天模型建模、训练、评估,Web服务器模型部署以及Android客户机设计这些环环相扣的步骤,读者可以体验到学习过程中的循序渐进,体验到量变到质变,体验到从混沌到顿悟、从顿悟到彻悟的华丽蜕变。

机器是如何学习说话的?机器是如何学会说话的?机器说话的本质是什么?现阶段的局限是什么?所有这些不再是秘密。


5.23习题 

1. 简要描述机器问答与机器聊天的不同。

2. 简要描述实施机器问答项目的方法与步骤。

3. 腾讯自然语言聊天数据集NaturalConv有哪些特点?

4. Transformer的创新点有哪些?

5. 简要解析Transformer单头注意力的计算逻辑。

6. Transformer模型适合哪些场景的应用?

7. 结合项目实战,谈谈为什么本章实现的聊天模型偏爱体育方面的表达。

8. 根据Transformer多头注意力机制的编程设计,绘制其计算逻辑流程图。

9. 绘制Transformer单层编码器的工作流程图。

10. 绘制Transformer单层解码器的工作流程图。

11. 模型训练期间,采用动态学习率调整策略有何优势?

12. Transformer聊天机器人中的损失函数是如何定义的?

13. 描述Transformer聊天机器人在Web服务器上的部署流程。

14. 简述Android聊天机器人的客户机界面布局设计。

15. 绘图说明Android聊天机器人的客户机运行逻辑。

16. 为什么Android客户机的收发消息流程需要定义到协程中?

17. 结合本章项目实战,谈谈机器是如何学习说话的、机器是如何学会说话的、机器说话的本质是什么。