基于Seq2Seq的中文聊天机器人编程实践(Encoder编码器-Decoder解码器框架 + Attention注意力机制)

日萌社

人工智能AI:Keras PyTorch MXNet TensorFlow PaddlePaddle 深度学习实战(不定时更新)


Encoder编码器-Decoder解码器框架 + Attention注意力机制

Pytorch:Transformer(Encoder编码器-Decoder解码器、多头注意力机制、多头自注意力机制、掩码张量、前馈全连接层、规范化层、子层连接结构、pyitcast) part1

Pytorch:Transformer(Encoder编码器-Decoder解码器、多头注意力机制、多头自注意力机制、掩码张量、前馈全连接层、规范化层、子层连接结构、pyitcast) part2

Pytorch:使用Transformer构建语言模型

Pytorch:解码器端的Attention注意力机制、seq2seq模型架构实现英译法任务

BahdanauAttention注意力机制、LuongAttention注意力机制

BahdanauAttention注意力机制:基于seq2seq的西班牙语到英语的机器翻译任务、解码器端的Attention注意力机制、seq2seq模型架构

图片的描述生成任务、使用迁移学习实现图片的描述生成过程、CNN编码器+RNN解码器(GRU)的模型架构、BahdanauAttention注意力机制、解码器端的Attention注意力机制

注意力机制、bmm运算

注意力机制 SENet、CBAM

机器翻译 MXNet(使用含注意力机制的编码器—解码器,即 Encoder编码器-Decoder解码器框架 + Attention注意力机制)

基于Seq2Seq的中文聊天机器人编程实践(Encoder编码器-Decoder解码器框架 + Attention注意力机制)

基于Transformer的文本情感分析编程实践(Encoder编码器-Decoder解码器框架 + Attention注意力机制 + Positional Encoding位置编码)

注意:这一文章“基于Transformer的文本情感分析编程实践(Encoder编码器-Decoder解码器框架 + Attention注意力机制 + Positional Encoding位置编码)”
	该文章实现的Transformer的Model类型模型,实际是改造过的特别版的Transformer,因为Transformer的Model类型模型中只实现了Encoder编码器,
	而没有对应实现的Decoder解码器,并且因为当前Transformer的Model类型模型处理的是分类任务,
	所以我们此处只用了Encoder编码器来提取特征,最后通过全连接层网络来拟合分类。



RNN基于时间的反向传播算法BPTT(Back Propagation Trough Time)梯度消失与梯度爆炸

将RNN展开之后,前向传播(Forward Propagation)就是依次按照时间的顺序计算一次就好了,
反向传播(Back Propagation)就是从最后一个时间将累积的残差传递回来即可,这与普通的神经网络训练本质上是相似的。



文本预处理:分词器Tokenizer、text_to_word_sequence、one-hot、hashing_trick、pad_sequences

1.句子分割 text_to_word_sequence
	keras.preprocessing.text.text_to_word_sequence(text, filters='!"#$%&()*+,-./:;<=>?@[\]^_`{|}~\t\n', lower=True, split=" ")
	本函数将一个句子拆分成单词构成的列表。使用filters参数中定义的标点符号和split参数中定义的分隔符作为分割句子的标准。
	text_to_word_sequence,将文本转换为一个字符序列,即将文本转换为序列(即单词在字典中的下标构成的列表,从1算起)。
	参数:
		text:字符串,待处理的文本
		filters:需要滤除的字符的列表或连接形成的字符串,例如标点符号。
			默认值为 '!"#$%&()*+,-./:;<=>?@[]^_`{|}~\t\n',包含标点符号,制表符和换行符等。
		lower:布尔值,是否将序列设为小写形式
		split:字符串,单词的分隔符,如空格
	返回值:字符串列表
	>>> import keras
	>>> text="我爱你!!你爱我么??"
	>>> keras.preprocessing.text.text_to_word_sequence(text, filters='!"#$%&()*+,-./:;<=>?@[\]^_`{|}~\t\n', lower=True, split=" ")
	['我爱你', '你爱我么']
	>>> text="好好学习,天天向上!!"
	>>> keras.preprocessing.text.text_to_word_sequence(text, filters='!"#$%&()*+,-./:;<=>?@[\]^_`{|}~\t\n', lower=True, split=" ")
	['好好学习', '天天向上']
	#使用了中文形式的标点符号,因此filters参数中也应加上中文形式的标点符号,才能正常分割句子
	>>> text="好好学习,天天向上!!"
	>>> keras.preprocessing.text.text_to_word_sequence(text, filters='!"#$%&()*+,-./:;<=>?@[\]^_`{|}~\t\n,!', lower=True, split=" ")
	['好好学习', '天天向上']

2.one-hot编码
	keras.preprocessing.text.one_hot(text, n, filters='!"#$%&()*+,-./:;<=>?@[\]^_`{|}~\t\n', lower=True, split=" ")
	本函数将一段文本编码为one-hot形式的码,即仅记录词在词典中的下标。
	从定义上,当字典长为n时,每个单词应形成一个长为n的向量,其中仅有单词本身在字典中下标的位置为1,其余均为0,这称为one-hot。
	为了方便起见,函数在这里仅把“1”的位置,即字典中词的下标记录下来。
	这个函数表示把一个string的文本编码成一个index的list,这里的index指的是在字典中的index。字典的规模可以制定,就是n。
	使用filters参数中定义的标点符号和split参数中定义的分隔符作为分割句子的标准。
	one_hot,对字符串序列进行独热编码。所谓的独热编码就是在整个文本中,根据字符出现的次数进行排序,以序号作为字符的索引构成词频字典,
	在一个字典长度的全零序列中将序号对应的元素置1来表示序号的编码。比如“我”的序号是5,全字典长度为10,
	那么“我”的独热编码为[0,0,0,0,1,0,0,0,0,0]。
	参数:
		text:字符串,待处理的文本
		n:整数,字典长度
		filters:需要滤除的字符的列表或连接形成的字符串,例如标点符号。
			默认值为 '!"#$%&()*+,-./:;<=>?@[]^_`{|}~\t\n',包含标点符号,制表符和换行符等。
		lower:布尔值,是否将序列设为小写形式
		split:字符串,单词的分隔符,如空格
	返回值:整数列表,每个整数是[1,n]之间的值,代表一个单词(不保证唯一性,即如果词典长度不够,不同的单词可能会被编为同一个码)。

	>>> import keras
	#使用filters参数中定义的标点符号和split参数中定义的分隔符把句子进行分割之后,n为字典长度,每个被分割出来的单词先是被one-hot化,
	#相同的单词具有相同的one-hot码,其中单词会被放到字典中,单词在字典中的索引index作为one-hot向量中值为1的索引index,
	#函数返回值便是这个单词在one-hot向量中值为1的索引index,也同样为单词在字典中的索引index,最终封装为列表返回全部单词的索引。
	>>> text='Near is a good name, you should always be near to someone to save'
	>>> keras.preprocessing.text.one_hot(text, 20, filters='!"#$%&()*+,-./:;<=>?@[\]^_`{|}~\t\n', lower=True, split=" ")
	[19, 12, 8, 15, 1, 4, 9, 14, 13, 19, 15, 3, 15, 12]

3.特征哈希hashing_trick
	keras.preprocessing.text.hashing_trick(text, n, hash_function=None, filters='!"#$%&()*+,-./:;<=>?@[\]^_`{|}~\t\n', lower=True, split=' ')
	将文本转换为固定大小的哈希空间中的索引序列。
	hashing_trick,对文本或者字符串进行哈希计算,将计算所得的哈希值作为存储该文本或者字符串的索引。
	参数
		text:字符串,待处理的文本
		n: 哈希空间的维度
		hash_function: 默认为 python hash 函数, 可以是 'md5' 或任何接受输入字符串, 并返回 int 的函数。
			      注意 hash 不是一个稳定的哈希函数, 因此在不同执行环境下会产生不同的结果, 作为对比, 'md5' 是一个稳定的哈希函数。
		filters:需要滤除的字符的列表或连接形成的字符串,例如标点符号。
			默认值为 '!"#$%&()*+,-./:;<=>?@[]^_`{|}~\t\n',包含标点符号,制表符和换行符等。
		lower:布尔值,是否将序列设为小写形式
		split:字符串,单词的分隔符,如空格
	返回值:整数列表

	>>> import keras
	>>> text='Near is a good name, you should always be near to someone to save'
	>>> keras.preprocessing.text.hashing_trick(text, 20, hash_function='md5', filters='!"#$%&()*+,-./:;<=>?@[\]^_`{|}~\t\n', lower=True, split=' ')
	[5, 19, 14, 15, 15, 3, 13, 12, 7, 5, 6, 16, 6, 11]

4.填充序列pad_sequences
	keras.preprocessing.sequence.pad_sequences(sequences, maxlen=None, dtype='int32', padding='pre', truncating='pre', value=0.)
	将长为nb_samples的序列(标量序列)转化为形如(nb_samples,nb_timesteps)2D numpy array。
	如果提供了参数maxlen,nb_timesteps=maxlen,否则其值为最长序列的长度。其他短于该长度的序列都会在后部填充0以达到该长度。
	长于nb_timesteps的序列将会被截断,以使其匹配目标长度。padding和截断发生的位置分别取决于padding和truncating.
	参数
		sequences:浮点数或整数构成的两层嵌套列表
		maxlen:None或整数,为序列的最大长度。大于此长度的序列将被截短,小于此长度的序列将在后部填0.
		dtype:返回的numpy array的数据类型
		padding:‘pre’或‘post’,确定当需要补0时,在序列的起始还是结尾补
		truncating:‘pre’或‘post’,确定当需要截断序列时,从起始还是结尾截断
		value:浮点数,此值将在填充时代替默认的填充值0
	返回值
		返回形如(nb_samples,nb_timesteps)的2D张量

5.tensorflow中的分词器Tokenizer
	enc_vocab_size = 20000
	tokenizer = tf.keras.preprocessing.text.Tokenizer(num_words=enc_vocab_size, oov_token=3)
    	tokenizer.fit_on_texts(texts)
	tensor = tokenizer.texts_to_sequences(texts)

	Tokenizer,一个将文本进行数字符号化的方法类,在进行神经网络训练时需要输入的数据是数值,因此需要将文本字符转换为可进行数学计算的数值。
	在这个方法类中提供了fit_on_sequences、fit_on_texts、get_config、sequences_to_matrix、sequences_to_texts和sequences_to_texts_generator等方法。
	在使用Tokenizer时,可以配置如下参数。
		• num_words:配置符号化的最大数量。
		• filters:配置需要过滤的文本符号,比如逗号、中括号等。
		• lower:配置是否需要将大写全部转换为小写。这个配置是相对于英文来说的,中文不存在大小写的问题。
		• split:配置进行分割的分隔符。
		• char_level:配置字符串的级别。如果配置为True,那么每个字符都会作为一个token。
		• oov_token:配置不在字典中的字符的替换数字,一般使用“3”这个数字来代替在字典中找不到的字符。

6.keras中的分词器Tokenizer
	keras.preprocessing.text.Tokenizer(num_words=None, filters='!"#$%&()*+,-./:;<=>?@[\]^_`{|}~\t\n', lower=True, split=" ", char_level=False)
	Tokenizer是一个用于向量化文本,或将文本转换为序列(即单词在字典中的下标构成的列表,从1算起)的类。
	1.参数:
		num_words: None或整数,处理的最大单词数量。若被设置为整数,则分词器将被限制为待处理数据集中最常见的num_words个单词
		filters:需要滤除的字符的列表或连接形成的字符串,例如标点符号。
			默认值为 '!"#$%&()*+,-./:;<=>?@[]^_`{|}~\t\n',包含标点符号,制表符和换行符等。
		lower:布尔值,是否将序列设为小写形式
		split:字符串,单词的分隔符,如空格
		char_level: 如果为 True, 每个字符将被视为一个标记

	2.类方法
		fit_on_texts(texts)
			texts:要用以训练的文本列表
			
		texts_to_sequences(texts)
			texts:待转为序列的文本列表
			返回值:序列的列表,列表中每个序列对应于一段输入文本

		texts_to_sequences_generator(texts)
			本函数是texts_to_sequences的生成器函数版
			texts:待转为序列的文本列表
			返回值:每次调用返回对应于一段输入文本的序列

		texts_to_matrix(texts, mode):
			texts:待向量化的文本列表
			mode:‘binary’,‘count’,‘tfidf’,‘freq’之一,默认为‘binary’
			返回值:形如(len(texts), nb_words)的numpy array

		fit_on_sequences(sequences):
			sequences:要用以训练的序列列表
			
		sequences_to_matrix(sequences):
			sequences:待向量化的序列列表
			mode:‘binary’,‘count’,‘tfidf’,‘freq’之一,默认为‘binary’
			返回值:形如(len(sequences), nb_words)的numpy array

	3.属性
		Tokenizer对象.word_counts: 获取字典{单词:单词出现次数},将单词(字符串)映射为它们在训练期间出现的次数。仅在调用fit_on_texts之后设置。
		Tokenizer对象.word_docs: 获取字典{单词:单词出现次数},将单词(字符串)映射为它们在训练期间所出现的文档或文本的数量。仅在调用fit_on_texts之后设置。
		Tokenizer对象.word_index: 获取字典{单词:单词在字典中的索引值},将单词(字符串)映射为它们的排名或者索引。仅在调用fit_on_texts之后设置。
		Tokenizer对象.index_word[index]: 获取单词(字符串),根据传入单词所在字典中的索引值来获取该单词。
		Tokenizer对象.word_index[word]: 获取单词在字典中的索引值,根据传入单词,获取单词所在字典中的索引值。
		Tokenizer对象.document_count: 获取整数。分词器被训练的文档(文本或者序列)数量。仅在调用fit_on_texts或fit_on_sequences之后设置。

	>>> import keras
	>>> tokenizer = keras.preprocessing.text.Tokenizer(num_words=10, filters='!"#$%&()*+,-./:;<=>?@[\]^_`{|}~\t\n', lower=True, split=" ", char_level=False)
	>>> text = ["今天 北京 下雨 了", "我 今天 加班"]
	>>> tokenizer.fit_on_texts(text)
	>>> tokenizer.word_counts
	OrderedDict([('今天', 2), ('北京', 1), ('下雨', 1), ('了', 1), ('我', 1), ('加班', 1)])
	>>> tokenizer.word_docs
	defaultdict(<class 'int'>, {'下雨': 1, '了': 1, '今天': 2, '北京': 1, '加班': 1, '我': 1})
	#word_index返回每个被分割的单词在字典中的索引值,从1算起。
	>>> tokenizer.word_index
	{'今天': 1, '北京': 2, '下雨': 3, '了': 4, '我': 5, '加班': 6}
	>>> tokenizer.document_count
	2
	#texts_to_sequences返回每个被分割的单词在字典中的索引值,从1算起。
	#每个句子对应一个列表,每个列表中元素值为该句子中的单词在字典中的索引值。
	>>> tokenizer.texts_to_sequences(text)
	[[1, 2, 3, 4], [5, 1, 6]]
	#每个句子所转换为列表,如果列表中单词所对应的索引值数量不满maxlen,则默认补0,可指定padding='post'在后面做填充
	>>> keras.preprocessing.sequence.pad_sequences(tokenizer.texts_to_sequences(text), maxlen=10, padding='post')
	array([[1, 2, 3, 4, 0, 0, 0, 0, 0, 0],
                [5, 1, 6, 0, 0, 0, 0, 0, 0, 0]])

# coding=utf-8
from configparser import SafeConfigParser

def get_config(config_file='seq2seq.ini'):
    parser = SafeConfigParser()
    parser.read(config_file)
    # get the ints, floats and strings
    _conf_ints = [ (key, int(value)) for key,value in parser.items('ints') ]
    #_conf_floats = [ (key, float(value)) for key,value in parser.items('floats') ]
    _conf_strings = [ (key, str(value)) for key,value in parser.items('strings') ]
    return dict(_conf_ints  + _conf_strings)

[strings]
# Mode : train, test, serve
mode = train
seq_data = train_data/seq.data
train_data=train_data
#训练集原始文件
resource_data = train_data/xiaohuangji50w_nofenci.conv

#读取识别原始文件中段落和行头的标示

e = E
m = M

model_data = model_data
[ints]
# vocabulary size 
# 	20,000 is a reasonable size
enc_vocab_size = 20000
dec_vocab_size = 20000
embedding_dim=128

# typical options : 128, 256, 512, 1024
layer_size = 256
# dataset size limit; typically none : no limit
max_train_data_size = 50000
batch_size = 128

# coding=utf-8

import os
import getConfig
import jieba
#结巴是国内的一个分词python库,分词效果非常不错。pip3 install jieba安装

gConfig = {}

gConfig=getConfig.get_config()

conv_path = gConfig['resource_data']
 
if not os.path.exists(conv_path):
	
	exit()
#下面这段我们需要完成一件事,就是将训练集的数据识别读取并存入一个List中,大概分为以下几个步骤
#a、打开文件 
#b、读取文件中的内容,并对文件的数据进行初步处理
#c、找出我们想要的数据存储下来
#知识点:open函数 for循环结构、数据类型(list的操作)、continue
convs = []  # 用于存储对话的列表
with open(conv_path,encoding='utf-8') as f:
	one_conv = []        # 存储一次完整对话
	for line in f:
		line = line.strip('\n').replace('/', '')#去除换行符,并将原文件中已经分词的标记去掉,重新用结巴分词.
		if line == '':
			continue
		if line[0] == gConfig['e']:
			if one_conv:
				convs.append(one_conv)
			one_conv = []
		elif line[0] == gConfig['m']:
			one_conv.append(line.split(' ')[1])#将一次完整的对话存储下来
#接下来,我们需要对训练集的对话进行分类,分为问和答,或者叫上文、下文,这个主要是作为encoder和decoder的熟练数据
#我们一般分为以下几个步骤

#1、初始化变量,ask response为List
#2、按照语句的顺序来分为问句和答句,根据行数的奇偶性来判断
#3、在存储语句的时候对语句使用结巴分词,jieba.cut

# 把对话分成问与答两个部分
seq = []        

for conv in convs:
	if len(conv) == 1:
		continue
	if len(conv) % 2 != 0:  # 因为默认是一问一答的,所以需要进行数据的粗裁剪,对话行数要是偶数的
		conv = conv[:-1]
	for i in range(len(conv)):
		if i % 2 == 0:
			conv[i]=" ".join(jieba.cut(conv[i]))#使用jieba分词器进行分词
			conv[i+1]=" ".join(jieba.cut(conv[i+1]))
			seq.append(conv[i]+'\t'+conv[i+1])#因为i是从0开始的,因此偶数行为发问的语句,奇数行为回答的语句

seq_train = open(gConfig['seq_data'],'w') 

for i in range(len(seq)):
   seq_train.write(seq[i]+'\n')
 
   if i % 1000 == 0:
      print(len(range(len(seq))), '处理进度:', i)
 
seq_train.close()

# coding=utf-8
import tensorflow as tf
import getConfig

gConfig = {}

gConfig=getConfig.get_config(config_file='seq2seq.ini')

class Encoder(tf.keras.Model):
  def __init__(self, vocab_size, embedding_dim, enc_units, batch_sz):
    super(Encoder, self).__init__()
    self.batch_sz = batch_sz
    self.enc_units = enc_units
    self.embedding = tf.keras.layers.Embedding(vocab_size, embedding_dim)
    self.gru = tf.keras.layers.GRU(self.enc_units,return_sequences=True,return_state=True,
                                   recurrent_initializer='glorot_uniform')

  def call(self, x, hidden):
    x = self.embedding(x)
    output, state = self.gru(x, initial_state = hidden)
    return output, state

  def initialize_hidden_state(self):
    return tf.zeros((self.batch_sz, self.enc_units))
 
  
class BahdanauAttention(tf.keras.Model):
  def __init__(self, units):
    super(BahdanauAttention, self).__init__()
    self.W1 = tf.keras.layers.Dense(units)
    self.W2 = tf.keras.layers.Dense(units)
    self.V = tf.keras.layers.Dense(1)

  def call(self, query, values):
    # hidden shape == (batch_size, hidden size)
    # hidden_with_time_axis shape == (batch_size, 1, hidden size)
    # we are doing this to perform addition to calculate the score
    hidden_with_time_axis = tf.expand_dims(query, 1)

    # score shape == (batch_size, max_length, hidden_size)
    score = self.V(tf.nn.tanh(
        self.W1(values) + self.W2(hidden_with_time_axis)))

    # attention_weights shape == (batch_size, max_length, 1)
    # we get 1 at the last axis because we are applying score to self.V
    attention_weights = tf.nn.softmax(score, axis=1)

    # context_vector shape after sum == (batch_size, hidden_size)
    context_vector = attention_weights * values
    context_vector = tf.reduce_sum(context_vector, axis=1)
    return context_vector, attention_weights

class Decoder(tf.keras.Model):
  def __init__(self, vocab_size, embedding_dim, dec_units, batch_sz):
    super(Decoder, self).__init__()
    self.batch_sz = batch_sz
    self.dec_units = dec_units
    self.embedding = tf.keras.layers.Embedding(vocab_size, embedding_dim)
    self.gru = tf.keras.layers.GRU(self.dec_units,
                                   return_sequences=True,
                                   return_state=True,
                                   recurrent_initializer='glorot_uniform')
    self.fc = tf.keras.layers.Dense(vocab_size)
    self.attention = BahdanauAttention(self.dec_units)

  def call(self, x, hidden, enc_output):
    context_vector, attention_weights = self.attention(hidden, enc_output)
    x = self.embedding(x)
    x = tf.concat([tf.expand_dims(context_vector, 1), x], axis=-1)
    output, state = self.gru(x)
    output = tf.reshape(output, (-1, output.shape[2]))
    x = self.fc(output)
    return x, state, attention_weights


vocab_inp_size = gConfig['enc_vocab_size']
vocab_tar_size = gConfig['dec_vocab_size']
embedding_dim=gConfig['embedding_dim']
units=gConfig['layer_size']
BATCH_SIZE=gConfig['batch_size']

encoder = Encoder(vocab_inp_size, embedding_dim, units, BATCH_SIZE)
decoder = Decoder(vocab_tar_size, embedding_dim, units, BATCH_SIZE)
optimizer = tf.keras.optimizers.Adam()
loss_object = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)

def loss_function(real, pred):
  mask = tf.math.logical_not(tf.math.equal(real, 0))
  loss_ = loss_object(real, pred)
  mask = tf.cast(mask, dtype=loss_.dtype)
  loss_ *= mask

  return tf.reduce_mean(loss_)

checkpoint = tf.train.Checkpoint(optimizer=optimizer,encoder=encoder,decoder=decoder)

#@tf.function
def train_step(inp, targ, targ_lang,enc_hidden):
  loss = 0
  with tf.GradientTape() as tape:
    enc_output, enc_hidden = encoder(inp, enc_hidden)
    dec_hidden = enc_hidden
    dec_input = tf.expand_dims([targ_lang.word_index['start']] * BATCH_SIZE, 1)

    for t in range(1, targ.shape[1]):
      predictions, dec_hidden, _ = decoder(dec_input, dec_hidden, enc_output)
      loss += loss_function(targ[:, t], predictions)
      dec_input = tf.expand_dims(targ[:, t], 1)

  batch_loss = (loss / int(targ.shape[1]))
  variables = encoder.trainable_variables + decoder.trainable_variables
  gradients = tape.gradient(loss, variables)
  optimizer.apply_gradients(zip(gradients, variables))
  return batch_loss

# -*- coding:utf-8 -*-
import os
import sys
import time
import tensorflow as tf
import seq2seqModel
import getConfig
import io

gConfig = {}
gConfig=getConfig.get_config(config_file='seq2seq.ini')
vocab_inp_size = gConfig['enc_vocab_size']
vocab_tar_size = gConfig['dec_vocab_size']
embedding_dim=gConfig['embedding_dim']
units=gConfig['layer_size']
BATCH_SIZE=gConfig['batch_size']
max_length_inp,max_length_tar=20,20

def preprocess_sentence(w):
    w ='start '+ w + ' end'
    #print(w)
    return w

def create_dataset(path, num_examples):
    lines = io.open(path, encoding='UTF-8').read().strip().split('\n')
    word_pairs = [[preprocess_sentence(w)for w in l.split('\t')] for l in lines[:num_examples]]
    return zip(*word_pairs)

def max_length(tensor):
    return max(len(t) for t in tensor)

def read_data(path,num_examples):
    input_lang,target_lang=create_dataset(path,num_examples)
    input_tensor,input_token=tokenize(input_lang)
    target_tensor,target_token=tokenize(target_lang)
    return input_tensor,input_token,target_tensor,target_token

def tokenize(lang):
    lang_tokenizer = tf.keras.preprocessing.text.Tokenizer(num_words=gConfig['enc_vocab_size'], oov_token=3)
    lang_tokenizer.fit_on_texts(lang)
    tensor = lang_tokenizer.texts_to_sequences(lang)
    tensor = tf.keras.preprocessing.sequence.pad_sequences(tensor, maxlen=max_length_inp,padding='post')
    return tensor, lang_tokenizer

input_tensor,input_token,target_tensor,target_token= read_data(gConfig['seq_data'], gConfig['max_train_data_size'])

def train():
    print("Preparing data in %s" % gConfig['train_data'])
    steps_per_epoch = len(input_tensor) // gConfig['batch_size']
    print(steps_per_epoch)
    enc_hidden = seq2seqModel.encoder.initialize_hidden_state()
    checkpoint_dir = gConfig['model_data']
    ckpt=tf.io.gfile.listdir(checkpoint_dir)
    if ckpt:
        print("reload pretrained model")
        seq2seqModel.checkpoint.restore(tf.train.latest_checkpoint(checkpoint_dir))
    BUFFER_SIZE = len(input_tensor)
    dataset = tf.data.Dataset.from_tensor_slices((input_tensor,target_tensor)).shuffle(BUFFER_SIZE)
    dataset = dataset.batch(BATCH_SIZE, drop_remainder=True)
    checkpoint_dir = gConfig['model_data']
    checkpoint_prefix = os.path.join(checkpoint_dir, "ckpt")
    start_time = time.time()

    while True:
        start_time_epoch = time.time()
        total_loss = 0
        for (batch, (inp, targ)) in enumerate(dataset.take(steps_per_epoch)):
            batch_loss = seq2seqModel.train_step(inp, targ,target_token, enc_hidden)
            total_loss += batch_loss
            print(batch_loss.numpy())

        step_time_epoch = (time.time() - start_time_epoch) / steps_per_epoch
        step_loss = total_loss / steps_per_epoch
        current_steps = +steps_per_epoch
        step_time_total = (time.time() - start_time) / current_steps
        print('训练总步数: {} 每步耗时: {}  最新每步耗时: {} 最新每步loss {:.4f}'.format(current_steps, step_time_total, step_time_epoch, step_loss.numpy()))
        seq2seqModel.checkpoint.save(file_prefix=checkpoint_prefix)
        sys.stdout.flush()

def predict(sentence):
    checkpoint_dir = gConfig['model_data']
    seq2seqModel.checkpoint.restore(tf.train.latest_checkpoint(checkpoint_dir))
    sentence = preprocess_sentence(sentence)
    inputs = [input_token.word_index.get(i,3) for i in sentence.split(' ')]
    inputs = tf.keras.preprocessing.sequence.pad_sequences([inputs],maxlen=max_length_inp,padding='post')
    inputs = tf.convert_to_tensor(inputs)

    result = ''
    hidden = [tf.zeros((1, units))]
    enc_out, enc_hidden = seq2seqModel.encoder(inputs, hidden)
    dec_hidden = enc_hidden
    dec_input = tf.expand_dims([target_token.word_index['start']], 0)

    for t in range(max_length_tar):
        predictions, dec_hidden, attention_weights = seq2seqModel.decoder(dec_input, dec_hidden, enc_out)
        predicted_id = tf.argmax(predictions[0]).numpy()
        if target_token.index_word[predicted_id] == 'end':
            break
        result += target_token.index_word[predicted_id] + ' '
        dec_input = tf.expand_dims([predicted_id], 0)
    return result

if __name__ == '__main__':
    if len(sys.argv) - 1:
        gConfig = getConfig.get_config(sys.argv[1])
    else:
        gConfig = getConfig.get_config()
    print('\n>> Mode : %s\n' %(gConfig['mode']))
    if gConfig['mode'] == 'train':
        train()
    elif gConfig['mode'] == 'serve':
        print('Serve Usage : >> python3 app.py')

# coding=utf-8
from flask import Flask, render_template, request,jsonify
import execute
import time
import threading
import jieba
"""
定义心跳检测函数
"""
def heartbeat():
    print (time.strftime('%Y-%m-%d %H:%M:%S - heartbeat', time.localtime(time.time())))
    timer = threading.Timer(60, heartbeat)
    timer.start()
timer = threading.Timer(60, heartbeat)
timer.start()

"""
ElementTree在 Python 标准库中有两种实现。
一种是纯 Python 实现例如 xml.etree.ElementTree ,
另外一种是速度快一点的 xml.etree.cElementTree 。
 尽量使用 C 语言实现的那种,因为它速度更快,而且消耗的内存更少
"""


app = Flask(__name__,static_url_path="/static") 
@app.route('/message', methods=['POST'])

#"""定义应答函数,用于获取输入信息并返回相应的答案"""
def reply():
#从请求中获取参数信息
    req_msg = request.form['msg']
#将语句使用结巴分词进行分词
    req_msg=" ".join(jieba.cut(req_msg))
    #调用decode_line对生成回答信息
    res_msg = execute.predict(req_msg)
    #将unk值的词用微笑符号袋贴
    res_msg = res_msg.replace('_UNK', '^_^')
    res_msg=res_msg.strip()
    
    # 如果接受到的内容为空,则给出相应的回复
    if res_msg == ' ':
      res_msg = '请与我聊聊天吧'
    return jsonify( { 'text': res_msg } )

"""
jsonify:是用于处理序列化json数据的函数,就是将数据组装成json格式返回

http://flask.pocoo.org/docs/0.12/api/#module-flask.json
"""
@app.route("/")
def index(): 
    return render_template("index.html")
'''
'''
# 启动APP
if (__name__ == "__main__"): 
    app.run(host = '0.0.0.0', port = 8808) 

chineseChatbotWeb完整注释版

seq2seq.ini
 
[strings]
# 配置执行器的运行模式 Mode : train, test, serve
mode = train
# 处理后的中文训练集
seq_data = train_data/seq.data
train_data=train_data
# 训练集原始文件
resource_data = train_data/xiaohuangji50w_nofenci.conv

#读取识别原始文件中段落和行头的标示
e = E
m = M

model_data = model_data

[ints]
# 配置字典的大小 vocabulary size,建议字典大小为20000
# 20000是一个合理的大小
enc_vocab_size = 20000
dec_vocab_size = 20000
#配置embedding的维度,就是用多长的向量对单词来进行编码
embedding_dim=128

# 配置循环神经网络层级的神经元数量
# 典型选项 : 128, 256, 512, 1024
layer_size = 256
# 配置读取训练数据的最大值,一般当显存或者内存不足时可以适当减少训练数据大小
# 数据集大小限制;通常为none:无限制
max_train_data_size = 50000
# 配置批量大小
batch_size = 128

getConfig.py

# coding=utf-8
from configparser import SafeConfigParser

# configparser为 用于读取配置文件的包
def get_config(config_file='seq2seq.ini'):
    parser = SafeConfigParser()
    parser.read(config_file,encoding="utf-8")
    #获取int、float、string等类型的参数,按照key-value形式保存
    _conf_ints = [(key, int(value)) for key, value in parser.items('ints')]
    # _conf_floats = [ (key, float(value)) for key,value in parser.items('floats') ]
    _conf_strings = [(key, str(value)) for key, value in parser.items('strings')]
    #封装为一个字典对象,包含所读取的所有参数
    return dict(_conf_ints + _conf_strings)

data_util.py

# coding=utf-8
import os
import getConfig
# 结巴是国内的一个分词python库,分词效果非常不错。pip3 install jieba安装
import jieba

gConfig = {}
gConfig = getConfig.get_config()
# 配置源文件的路劲
conv_path = gConfig['resource_data']
#判断文件是否存在
if not os.path.exists(conv_path):
    exit()

# 下面这段我们需要完成一件事,就是将训练集的数据识别读取并存入一个List中,大概分为以下几个步骤
# a、打开文件
# b、读取文件中的内容,并对文件的数据进行初步处理
# c、找出我们想要的数据存储下来
# 知识点:open函数 for循环结构、数据类型(list的操作)、continue

# 用于存储对话的列表
convs = []
with open(conv_path, encoding='utf-8') as f:
    # 存储一次完整对话
    one_conv = []
    for line in f:
        # 去除换行符,并将原文件中已经分词的标记去掉,重新用结巴分词
        line = line.strip('\n').replace('/', '')
        if line == '':
            continue
        if line[0] == gConfig['e']:
            if one_conv:
                convs.append(one_conv)
            one_conv = []
        elif line[0] == gConfig['m']:
            # 将一次完整的对话存储下来
            one_conv.append(line.split(' ')[1])

# 接下来,我们需要对训练集的对话进行分类,分为问和答,或者叫上文、下文,这个主要是作为encoder和decoder的熟练数据
# 我们一般分为以下几个步骤
# 1、初始化变量,ask response为List
# 2、按照语句的顺序来分为问句和答句,根据行数的奇偶性来判断
# 3、在存储语句的时候对语句使用结巴分词,jieba.cut

# 把对话分成问与答两个部分
seq = []
for conv in convs:
    if len(conv) == 1:
        continue
    # 因为默认是一问一答的,所以需要进行数据的粗裁剪,对话行数要是偶数的
    if len(conv) % 2 != 0:
        conv = conv[:-1]
    for i in range(len(conv)):
        if i % 2 == 0:
            # 使用jieba分词器进行分词
            conv[i] = " ".join(jieba.cut(conv[i]))
            conv[i + 1] = " ".join(jieba.cut(conv[i + 1]))
            # 因为i是从0开始的,因此偶数行为发问的语句,奇数行为回答的语句
            seq.append(conv[i] + '\t' + conv[i + 1])

# 新建一个文件用于存储处理好的数据,作为训练数据
seq_train = open(gConfig['seq_data'], 'w')
# 将处理好的数据存储到文件中
for i in range(len(seq)):
    seq_train.write(seq[i] + '\n')
    if i % 1000 == 0:
        print(len(range(len(seq))), '处理进度:', i)
# 保存修改并关闭文件
seq_train.close()

execute.py

# -*- coding:utf-8 -*-
import os
import sys
import time
import tensorflow as tf
import seq2seqModel
import getConfig
import io
from sklearn.model_selection import train_test_split
#pysnooper是开源的python代码调试包,可以使用pysnooper.snoop()装饰器调试代码
import pysnooper

#下面的方式可以打print打印的语句信息输出到文件
# import sys
# import os
# from Logger import Logger
# path = os.path.abspath(os.path.dirname(__file__))
# type = sys.getfilesystemencoding()
# sys.stdout = Logger('out.txt')

#定义一个字典用于接收配置文件的配置参数
gConfig = {}
gConfig = getConfig.get_config(config_file='seq2seq.ini')
vocab_inp_size = gConfig['enc_vocab_size'] #输入语句字典维度
vocab_tar_size = gConfig['dec_vocab_size'] #输出语句字典维度
embedding_dim = gConfig['embedding_dim'] #embedding维度
units = gConfig['layer_size'] #层级的神经元数量
BATCH_SIZE = gConfig['batch_size'] #批量大小
#输入语句(问句)的最大长度、输出语句(答句)的最大长度
max_length_inp, max_length_tar = 20, 20

#定义一个语句的处理函数,在所有的语句开头和结尾分别加上'start '/' end'
def preprocess_sentence(w):
    w = 'start ' + w + ' end'
    #打印信息:start 好 的 end
    # print("完整句子:",w.encode("GBK","ignore").decode("GBK"))
    return w

#定义一个训练数据集的处理函数,用于读取文件中的数据,并进行初步的语句处理,在所有的语句开头和结尾分别加上'start '/' end'
def create_dataset(path, num_examples):
    #使用分行符'\n'进行分割,分割出完整的每行句子
    lines = io.open(path, encoding='UTF-8').read().strip().split('\n')
    #使用分行符'\t'进行分割,分割出问句和答句两部分的句子,并在所有的语句开头和结尾分别加上'start '/' end'
    word_pairs = [[preprocess_sentence(w) for w in l.split('\t')] for l in lines[:num_examples]]
    #返回初步处理好的数据
    return zip(*word_pairs)

#计算最大的句子长度
def max_length(tensor):
    return max(len(t) for t in tensor)

#定义 word2vec函数,通过统计训练集数据中所有字符出现频率,并且构建一个字典存储所有字符和其对应编码值,
#并使用字典中的编码值对训练集中的语句中的字符进行替换。
def tokenize(lang):
    #enc_vocab_size构建输入语句的字典维度。配置不在字典中的字符的替换数字,一般使用“3”这个数字来代替在字典中找不到的字符。
    #Tokenizer对象.index_word[index]: 获取单词(字符串),根据传入单词所在字典中的索引值来获取该单词。
    #Tokenizer对象.word_index[word]: 获取单词在字典中的索引值,根据传入单词,获取单词所在字典中的索引值。
    lang_tokenizer = tf.keras.preprocessing.text.Tokenizer(num_words=gConfig['enc_vocab_size'], oov_token=3)
    # word_index可以返回每个被分割的单词在字典中的索引值,从1算起。仅在调用fit_on_texts之后设置。
    lang_tokenizer.fit_on_texts(lang)
    # texts_to_sequences返回每个被分割的单词在字典中的索引值,从1算起。
    # 每个句子对应一个列表,每个列表中元素值为该句子中的单词在字典中的索引值。
    tensor = lang_tokenizer.texts_to_sequences(lang)
    # 每个句子所转换为列表,如果列表中单词所对应的索引值数量不满maxlen,则默认补0,可指定padding='post'在后面做填充
    tensor = tf.keras.preprocessing.sequence.pad_sequences(tensor, maxlen=max_length_inp, padding='post')
    return tensor, lang_tokenizer

#num_examples读取训练数据的最大值
def read_data(path, num_examples):
    # 定义一个训练数据集的处理函数,用于读取文件中的数据,并进行初步的语句处理,在所有的语句开头和结尾分别加上'start '/' end'
    input_lang, target_lang = create_dataset(path, num_examples)
    # print(input_lang[:7]) #取出问句的前8个句子,分别对应答句的前8个句子
    # print(target_lang[:7]) #取出答句的前8个句子,分别对应问句的前8个句子
    """
    1.input_lang[:7] 是取出的是问句的前8个句子,分别对应答句的前8个句子
        ('start 怎么 了 end', 'start 开心 点哈 , 一切 都 会 好 起来 end', 'start 我 还 喜欢 她 , 怎么办 end',
         'start 短信 end', 'start 你 知道 谁 么 end', 'start 许兵 是 谁 end', 'start 这么 假 end', 'start 许兵 是 傻 逼 end')
 
    2.target_lang[:7] 是取出的是答句的前8个句子,分别对应问句的前8个句子
        ('start 我 很 难过 , 安慰 我 ~ end', 'start 嗯 end', 'start 我 帮 你 告诉 她 ? 发短信 还是 打电话 ? end',
         'start 嗯 嗯 。 我 也 相信 end', 'start 肯定 不是 我 , 是 阮德培 end', 'start 吴院 四班 小帅哥 end',
         'start 三鹿 奶粉 也 假 , 不 一样 的 卖 啊 end', 'start 被 你 发现 了 。 end')
    """

    #tokenize函数实现了把输入语句(问句)和目标语句(答句)都通过word2vec转换为数值
    #返回 输入语句(问句)的word2vec转换(每个数值均为单词在字典中的索引值)、输入语句(问句)的Tokenizer对象input_token
    #input_tensor/target_tensor中的每个列表对应一个句子,每个列表中元素值为该句子中的单词在字典中的索引值。
    input_tensor, input_token = tokenize(input_lang)
    # 返回 目标语句(答句)的word2vec转换(每个数值均为单词在字典中的索引值)、目标语句(答句)的Tokenizer对象target_token
    target_tensor, target_token = tokenize(target_lang)
    # print("input_tensor:",input_tensor)
    #打印信息 {3: 1, 'start': 2, 'end': 3, '你': 4, '我': 5, '了': 6, '是': 7, '的': 8,
    # print("input_token.word_index:",input_token.word_index)
    # print("target_tensor:",target_tensor)
    #打印信息 {3: 1, 'start': 2, 'end': 3, ',': 4, '我': 5, '你': 6, '的': 7, 。。。。。。
    # print("target_token.word_index:",target_token.word_index)
    return input_tensor, input_token, target_tensor, target_token

#seq_data处理后的中文训练集,max_train_data_size读取训练数据的最大值
input_tensor, input_token, target_tensor, target_token = read_data(gConfig['seq_data'], gConfig['max_train_data_size'])

#训练模型
def train():
    print("Preparing data in %s" % gConfig['train_data'])
    input_tensor_train,input_tensor_val,target_tensor_train,target_tensor_val = train_test_split(input_tensor,target_tensor,test_size=0.2)
    #训练数据除以批量大小:计算一个epoch循环需要训练多少步才能将训练数据训练完整一遍,steps_per_epoch值为312步
    steps_per_epoch = len(input_tensor_train) // gConfig['batch_size']
    print("steps_per_epoch:",steps_per_epoch) #312

    #计算需要随机打乱排序的数据大小BUFFER_SIZE,将数据集随机打乱可以防止模型过多陷入局部最优解中
    BUFFER_SIZE = len(input_tensor_train)
    #将训练数据集随机打乱
    dataset = tf.data.Dataset.from_tensor_slices((input_tensor_train, target_tensor_train)).shuffle(BUFFER_SIZE)
    #drop_remainder=True表示最后一个batch的数量达不到batch_size的话则会被丢弃
    dataset = dataset.batch(BATCH_SIZE, drop_remainder=True)

    # 初始化模型保存路径
    checkpoint_dir = gConfig['model_data']
    # 初始化模型文件的保存前缀
    checkpoint_prefix = os.path.join(checkpoint_dir, "ckpt")
    #如果保存路径下已经存在预训练好的模型文件的话则直接加载
    ckpt = tf.io.gfile.listdir(checkpoint_dir)
    if ckpt:
        print("reload pretrained model")
        """
        当在其他地方需要为模型重新载入之前保存的参数时,需要再次实例化一个 checkpoint,同时保持键名的一致。
        再调用 checkpoint 的 restore 方法。就像下面这样:
            model_to_be_restored = MyModel()                                        # 待恢复参数的同一模型
            checkpoint = tf.train.Checkpoint(myAwesomeModel=model_to_be_restored)   # 键名保持为“myAwesomeModel”
            checkpoint.restore(save_path_with_prefix_and_index)
        即可恢复模型变量。 save_path_with_prefix_and_index 是之前保存的文件的目录 + 前缀 + 编号。
        例如,调用 checkpoint.restore('./save/model.ckpt-1') 就可以载入前缀为 model.ckpt ,序号为 1 的文件来恢复模型。
        当保存了多个文件时,我们往往想载入最近的一个。可以使用 tf.train.latest_checkpoint(save_path) 
        这个辅助函数返回目录下最近一次 checkpoint 的文件名。
        例如如果 save 目录下有 model.ckpt-1.index 到 model.ckpt-10.index 的 10 个保存文件, 
        tf.train.latest_checkpoint('./save') 即返回 ./save/model.ckpt-10 。
        """
        #重新加载预应变模型
        seq2seqModel.checkpoint.restore(tf.train.latest_checkpoint(checkpoint_dir))
    #计算训练开始时间
    start_time = time.time()

    while True:
        # 计算当前训练开始时间
        start_time_epoch = time.time()
        total_loss = 0
        #对encoder编码器的隐藏层的状态进行零初始化,shape为(批量大小batch_size 128, 神经元数量layer_size 256)
        enc_hidden = seq2seqModel.encoder.initialize_hidden_state()

        #批量从训练数据中取出数据进行训练
        # dataset.take(312)会获取312次批量大小的数据进行遍历,遍历的值batch从0到steps_per_epoch-1,即从0到312-1
        for (batch, (inp, targ)) in enumerate(dataset.take(steps_per_epoch)):
            print("batch:",batch) #遍历的值从0到steps_per_epoch-1,即从0到312-1
            print("inp.shape:",inp.shape)#(128, 20) 即(批量大小128, 输入语句(问句)的最大长度20)
            print("targ.shape:",targ.shape) #(128, 20) 即(批量大小128, 输出语句(答句)的最大长度20)
            #获取每步所训练的批量数据的loss值:传入 批量输入语句(问句)、批量输出语句(答句)、目标语句(答句)的Tokenizer对象、编码器隐藏层状态
            batch_loss = seq2seqModel.train_step(inp, targ, target_token, enc_hidden)
            #计算一个epoch的总loss值total_loss
            total_loss += batch_loss
            print("batch_loss:",batch_loss.numpy())
        print("total_loss:",total_loss.numpy())

        #计算一个epoch中每步训练批量数据所要消耗的平均时间
        step_time_epoch = (time.time() - start_time_epoch) / steps_per_epoch
        #计算一个epoch中每步训练批量数据的平均loss值
        step_loss = total_loss / steps_per_epoch
        #计算当前已经执行的总的训练步数,即训练了多少个批量数据
        current_steps =+ steps_per_epoch
        #计算当前已经执行的总的训练步数的其中每步的平均耗时,即训练每个批量数据的平均耗时
        step_time_total = (time.time() - start_time) / current_steps
        #每一个epoch结束后打印一次信息
        print('训练总步数: {} 每步耗时: {}  最新epoch中的每步耗时: {} 最新epoch中的每步loss值 {:.4f}'.
              format(current_steps, step_time_total, step_time_epoch, step_loss.numpy()))
        """
        例如,在源代码目录建立一个名为 save 的文件夹并调用一次 checkpoint.save('./save/model.ckpt') ,
        我们就可以在可以在 save 目录下发现名为 checkpoint 、 model.ckpt-1.index 、 model.ckpt-1.data-00000-of-00001 的三个文件,
        这些文件就记录了变量信息。checkpoint.save() 方法可以运行多次,每运行一次都会得到一个.index 文件和.data 文件,序号依次累加。
        """
        #把每一个epoch结束后训练好的模型文件进行保存
        # seq2seqModel.checkpoint.save(file_prefix=checkpoint_prefix)
        #刷新命令行输出
        sys.stdout.flush()

#预测模型
def predict(sentence):
    # 定义一个语句的处理函数,对输入语句的开头和结尾分别加上'start '/' end'
    sentence = preprocess_sentence(sentence)
    #对输入语句的每个字符进行word2vec转换,返回每个字符在字典中的所对应的索引值
    #get(i, 3:如果句子中的字符是不在字典中的字符的话,则一般使用“3”这个数字来代替在字典中找不到的字符
    inputs = [input_token.word_index.get(i, 3) for i in sentence.split(' ')]
    #对输入语句进行进行word2vec转换后的句子按照maxlen最大长度进行以0补全,默认补0,可指定padding='post'在后面做填充
    inputs = tf.keras.preprocessing.sequence.pad_sequences([inputs], maxlen=max_length_inp, padding='post')
    #将输入语句转换为tensor
    inputs = tf.convert_to_tensor(inputs)

    # 初始化模型保存路径
    checkpoint_dir = gConfig['model_data']
    # 重新加载预应变模型
    seq2seqModel.checkpoint.restore(tf.train.latest_checkpoint(checkpoint_dir))

    #初始化输出变量
    result = ''
    #初始化隐藏层
    #注意的是原本在训练模型中也是同样需要对encoder编码器的隐藏层的状态进行零初始化,但shape为(批量大小batch_size 128, 神经元数量layer_size 256),
    #而此处预测模型中初始化隐藏层的shape为(1, 神经元数量layer_size 256),是因为此处只对一个问句进行预测答句,而不是对批量的问句进行预测批量的答句
    hidden = [tf.zeros((1, units))]
    #使用编码器encoder对输入语句(问句inputs)、隐藏层状态hidden进行提取特征,最后返回编码器输出enc_out和编码器隐藏层状态enc_hidden
    enc_out, enc_hidden = seq2seqModel.encoder(inputs, hidden)
    #初始化解码器的隐藏层:编码器隐藏层状态enc_hidden作为解码器隐藏层状态dec_hidden
    dec_hidden = enc_hidden
    #初始化解码器的输入:定义答句的开头首单词为“'start'在字典中的”索引值作为解码器的输入语句
    #Tokenizer.word_index[word] 获取单词在字典中的索引值,根据传入单词,获取单词所在字典中的索引值
    dec_input = tf.expand_dims([target_token.word_index['start']], 0)

    #开始按照输出语句(答句)的最大长度进行预测,即遍历的最大次数为输出语句(答句)的最大长度,但如果解码器输出的单词为'end'即结束遍历循环
    for t in range(max_length_tar):
        #根据输入信息,逐字对输出语句(答句)进行预测,解码器最终返回预测值、解码器隐藏层状态、注意力权重
        # 第一次预测:传入上一个单词'start'、编码器隐藏层状态、编码器输出
        # 第一次后面的预测:传入上一个时间步预测出的单词、上一个时间步的解码器隐藏层状态、编码器输出
        predictions, dec_hidden, attention_weights = seq2seqModel.decoder(dec_input, dec_hidden, enc_out)
        #获取预测的结果:argmax获取向量(列表)中最大元素值所在的索引值
        predicted_id = tf.argmax(predictions[0]).numpy()
        #Tokenizer.index_word[wordIndex] 获取单词(字符串),根据传入单词所在字典中的索引值来获取该单词
        #根据所预测的索引值通过字典获取对应的单词,如果解码器输出的单词为'end'(结束标识)即结束遍历循环停止预测
        if target_token.index_word[predicted_id] == 'end':
            break
        #输出语句(答句)仍然还没有结束,继续在结尾追加空格
        result += target_token.index_word[predicted_id] + ' '
        #在第一层增加维度,shape变成(1,1),继续把所预测的索引值作为上文输入信息然后加入到解码器中来预测下一个单词的数值
        dec_input = tf.expand_dims([predicted_id], 0)
    #返回所预测的完整输出语句(答句)
    return result

if __name__ == '__main__':
    if len(sys.argv) - 1:
        gConfig = getConfig.get_config(sys.argv[1])
    else:
        gConfig = getConfig.get_config()
    #打印当前执行器的模式
    print('\n>> Mode : %s\n' % (gConfig['mode']))
    #如果配置文件中配置的是训练模式,则开始训练
    if gConfig['mode'] == 'train':
        train()
    #如果配置文件中配置的是服务模式,则直接运行应用程序
    elif gConfig['mode'] == 'serve':
        print('Serve Usage : >> python3 app.py')

seq2seqModel.py

# coding=utf-8
import tensorflow as tf
import getConfig

gConfig = {}
gConfig = getConfig.get_config(config_file='seq2seq.ini')

class Encoder(tf.keras.Model):
    def __init__(self, vocab_size, embedding_dim, enc_units, batch_sz):
        super(Encoder, self).__init__()
        self.batch_sz = batch_sz #批量大小为128
        self.enc_units = enc_units #网络层神经元数量为256
        # 配置Embedding层:编码器字典大小为20000、embedding维度为128
        self.embedding = tf.keras.layers.Embedding(vocab_size, embedding_dim)
        # 配置GRU层:神经元数量为256
        #       return_sequences:配置是否在输出的句子中返回最后的输出数据。
        #       return_state:配置是否将训练的最后状态添加到输出数据中返回。
        #       recurrent_initializer:配置循环网络核的初始化权重矩阵,用于对循环神经元的状态进行线性变换。
        self.gru = tf.keras.layers.GRU(self.enc_units, return_sequences=True, return_state=True, recurrent_initializer='glorot_uniform')

    #传入 批量输入语句(问句)、编码器隐藏层状态 ,返回 编码器输出、编码器隐藏层状态输出
    def call(self, x, hidden):
        #使用Embedding层对批量输入语句(问句)进行编码,返回shape为(128, 20, 128),即(批量大小128, 句子最大长度20, embedding维度128)的数据
        x = self.embedding(x)
        print("Encoder x.shape:",x.shape) #(128, 20, 128)
        #将进行编码过后的数据输入到GRU层,最终返回shape为(128, 20, 256),即(批量大小128, 句子最大长度20, GRU层神经元数量256)的编码器输出、
        # 还有shape为(128, 256),即(批量大小128, GRU层神经元数量256)的编码器隐藏层状态输出
        output, state = self.gru(x, initial_state=hidden)
        print("Encoder output.shape:",output.shape)#(128, 20, 256)
        print("Encoder state.shape:",state.shape) #(128, 256)
        return output, state

    #创建编码器的隐藏层状态
    def initialize_hidden_state(self):
        #对encoder编码器的隐藏层的状态进行零初始化,shape为(批量大小batch_size 128, 神经元数量layer_size 256)
        return tf.zeros((self.batch_sz, self.enc_units))


class BahdanauAttention(tf.keras.Model):
    # 传入 网络层神经元数量为256
    def __init__(self, units):
        super(BahdanauAttention, self).__init__()
        self.W1 = tf.keras.layers.Dense(units) #配置Dense全连接层的神经元数量为256
        self.W2 = tf.keras.layers.Dense(units) #配置Dense全连接层的神经元数量为256
        self.V = tf.keras.layers.Dense(1)      #配置Dense全连接层的神经元数量为1

    """
    1.MXNet中的 编码器-解码器-注意力机制
        1.GRU在前向计算后会分别返回输出output(即最后一层的隐藏层在各个时间步上计算并输出的隐藏状态)和最终时间步的多层隐藏状态state,
          其中前向计算后返回的输出output指的是最后一层的隐藏层在各个时间步上计算并输出的隐藏状态,shape为(时间步数, 批量大小, 隐藏单元个数),
          该输出本身通常作为后续输出层的输入,因此该输出本身就不涉及输出层计算,而注意力机制通常将该输出本身同时作为键项K和值项V。
        2.其中GRU前向计算后返回的最终时间步的多层隐藏状态state指的是隐藏层在最后时间步的“可用于初始化下一时间步的”隐藏状态,
          shape为(隐藏层个数, 批量大小, 隐藏单元个数):当隐藏层有多层时,每一层的隐藏状态都会记录到该变量中。
          注意:最终时间步的多层隐藏状态state为一个列表。
          对于门控循环单元:state[0]即能取出“shape为(隐藏层个数, 批量大小, 隐藏单元个数)的最终时间步的”多层隐藏状态。
          对于像长短期记忆这样的循环神经网络:state列表不仅包含多层隐藏状态,还包含记忆细胞。
    2.当前BahdanauAttention类中下面的call函数的传入值实际代表意义如下:
        1.query(查询项Q):
                1.第一次计算注意力:编码器Encode的GRU在前向计算后返回的最终时间步的多层隐藏状态state
                2.第一次之后的计算注意力:上一个时间步解码器Decode的隐藏层状态
        2.values(键项K/值项V):编码器Encode的GRU在前向计算后返回的“最后一层的隐藏层在各个时间步上计算并输出的”隐藏状态 
    """
    # 调用Attention注意力机制类的call方法:传入 编码器隐藏层状态/上一个时间步解码器隐藏层状态、编码器输出
    # 返回 上下文向量context_vector、注意力权重attention_weights
    def call(self, query, values):
        """
        :param query: 编码器隐藏层状态/上一个时间步解码器隐藏层状态
        :param values: 编码器输出
        :return: 返回 上下文向量context_vector、注意力权重attention_weights
        """
        """
        1.把编码器隐藏层状态/上一个时间步解码器隐藏层状态从(批量大小128, 神经元数量256)拓增为(批量大小128, 1, 神经元数量256)
        2.计算分数score = Dense V( tanh( Dense W1(编码器输出) + Dense W2(编码器隐藏层状态/上一个时间步解码器隐藏层状态) ) )
        3.计算注意力权重attention_weights = softmax(分数score, axis=1):把 第一维度的值 转换为 概率值
        4.上下文向量context_vector = 注意力权重 * 编码器输出
        5.上下文向量context_vector = reduce_sum(上下文向量context_vector, axis=1):对第一维度进行求和
        """
        # 首先是 hidden的shape为(batch_size, hidden size) 即 (批量大小128, 神经元数量256),
        # 然后转换为 hidden_with_time_axis的shape为(batch_size, 1, hidden size)即 (批量大小128, 1, 神经元数量256)。
        hidden_with_time_axis = tf.expand_dims(query, 1)
        print("BahdanauAttention hidden_with_time_axis.shape:",hidden_with_time_axis.shape) #(128, 1, 256)

        # 计算分数score,最终shape为(128, 20, 1),即(batch_size, max_length句子最大长度20, hidden_size),Dense层V的神经元数量hidden_size为1
        # values实际为 shape为(批量大小128, 句子最大长度20, GRU层神经元数量256)的编码器输出enc_output
        score = self.V(tf.nn.tanh(self.W1(values) + self.W2(hidden_with_time_axis)))
        print("BahdanauAttention score.shape:",score.shape) #(128, 20, 1)

        # 注意力权重attention_weights的shape为(128, 20, 1) 即(batch_size, max_length句子最大长度20, 1)
        attention_weights = tf.nn.softmax(score, axis=1)
        print("BahdanauAttention attention_weights.shape:",attention_weights.shape)#(128, 20, 1)

        #上下文向量context_vector = 注意力权重(128, 20, 1) * 编码器输出(128, 20, 256)
        # 最终context_vector的shape为(128, 20, 256) 即(批量大小128, 句子最大长度20, GRU层神经元数量256)
        context_vector = attention_weights * values
        print("BahdanauAttention context_vector.shape:",context_vector.shape) #(128, 20, 256)

        # 上下文向量context_vector经过sum之后的shape为(batch_size, hidden_size) 即 (批量大小128, GRU层神经元数量256)
        context_vector = tf.reduce_sum(context_vector, axis=1)
        print("BahdanauAttention reduce_sum.shape:",context_vector.shape) #(128, 256)
        # 返回 上下文向量context_vector、注意力权重attention_weights
        return context_vector, attention_weights


class Decoder(tf.keras.Model):
    # 传入 解码器字典大小、embedding维度、网络层神经元数量、批量大小 初始化 Decoder解码器
    def __init__(self, vocab_size, embedding_dim, dec_units, batch_sz):
        super(Decoder, self).__init__()
        self.batch_sz = batch_sz  #批量大小为128
        self.dec_units = dec_units #网络层神经元数量为256
        # 配置Embedding层:解码器字典大小为20000、embedding维度为128
        self.embedding = tf.keras.layers.Embedding(vocab_size, embedding_dim)
        # 配置GRU层:神经元数量为256
        #       return_sequences:配置是否在输出的句子中返回最后的输出数据。
        #       return_state:配置是否将训练的最后状态添加到输出数据中返回。
        #       recurrent_initializer:配置循环网络核的初始化权重矩阵,用于对循环神经元的状态进行线性变换。
        self.gru = tf.keras.layers.GRU(self.dec_units, return_sequences=True, return_state=True, recurrent_initializer='glorot_uniform')
        #配置Dense全连接层的神经元数量为解码器字典大小为20000,即作为最后一层的分类器
        self.fc = tf.keras.layers.Dense(vocab_size)
        #创建初始化注意力机制对象:传入 网络层神经元数量为256
        self.attention = BahdanauAttention(self.dec_units)

    # 调用decoder解码器的call方法:传入 单词'start'/下一个时间步的真实单词、编码器隐藏层状态/上一个时间步解码器隐藏层状态、编码器输出
    # 解码器最终返回 当前时间步的解码器预测输出值、解码器隐藏层状态、注意力权重
    def call(self, x, hidden, enc_output):
        #调用Attention注意力机制类的call方法:传入 编码器隐藏层状态/上一个时间步解码器隐藏层状态、编码器输出
        # 返回 上下文向量context_vector、注意力权重attention_weights
        context_vector, attention_weights = self.attention(hidden, enc_output)
        #使用Embedding层对单词'start'/下一个时间步的真实单词进行编码,返回shape为(批量大小128, 1, embedding维度128)的数据
        x = self.embedding(x)
        print("Decoder x.shape:",x.shape) #(128, 1, 128)

        #上下文向量context_vector的shape为(batch_size, hidden_size),即 (批量大小128, GRU层神经元数量256),
        #经过拓增后shape为(batch_size, 1, hidden_size) 即 (批量大小128, 1, GRU层神经元数量256)。
        #然后对(批量大小128, 1, GRU层神经元数量256)的上下文向量context_vector 和 (批量大小128, 1, embedding维度128)的x 两者在最后一个维度的进行concat,
        #最终concat的结果的shape为(128, 1, 384)。
        x = tf.concat([tf.expand_dims(context_vector, 1), x], axis=-1)
        print("Decoder concat.shape:",x.shape) #(128, 1, 384)

        #将进行编码过后的数据输入到GRU层,因为GRU层神经元数量hidden_size为256,
        #因此 GRU层最终返回 shape为(128, 1, 256) 即(批量大小128, 1, GRU层神经元数量256)的当前时间步的解码器预测输出值、
        #还有 shape为(128, 256) 即(批量大小128, GRU层神经元数量256)的解码器隐藏层状态state。
        output, state = self.gru(x)
        print("Decoder output.shape:",output.shape) #(128, 1, 256)
        print("Decoder state.shape:",state.shape) #(128, 256)

        #把当前时间步的解码器预测输出值output的shape的前两个维度进行合并为一个维度,即output的shape从(128, 1, 256)变成(128, 256) 即(批量大小128, GRU层神经元数量256)
        output = tf.reshape(output, (-1, output.shape[2]))
        print("Decoder reshape output.shape:",output.shape) #(128, 256)

        #使用最后一层的分类器Dense全连接层进行分类,Dense层hidden_size为解码器器字典大小20000,
        # 因此最终返回当前时间步的解码器预测输出值,shape为(128, 20000) 即(批量大小128, 解码器器字典大小20000)
        x = self.fc(output)
        print("Decoder fc x.shape:",x.shape) #(128, 20000)

        return x, state, attention_weights

#配置字典的大小 vocabulary size,建议字典大小为20000
vocab_inp_size = gConfig['enc_vocab_size'] #编码器字典大小 20000
vocab_tar_size = gConfig['dec_vocab_size'] #解码器字典大小 20000
#配置embedding的维度为128,就是用多长的向量对单词来进行编码
embedding_dim = gConfig['embedding_dim']
# 配置循环神经网络层级的神经元数量
# 典型选项 : 128, 256, 512, 1024
units = gConfig['layer_size']
BATCH_SIZE = gConfig['batch_size'] # 配置批量大小为128

#传入 编码器字典大小、embedding维度、网络层神经元数量、批量大小 初始化 Encoder编码器
encoder = Encoder(vocab_inp_size, embedding_dim, units, BATCH_SIZE)
#传入 解码器字典大小、embedding维度、网络层神经元数量、批量大小 初始化 Encoder编码器
decoder = Decoder(vocab_tar_size, embedding_dim, units, BATCH_SIZE)
optimizer = tf.keras.optimizers.Adam()
"""
SparseCategoricalCrossentropy是可以接受稀疏编码的多对数交叉熵,所谓的接受稀疏编码就是指期望值可以是整型的分类编码,如1,2,3等。
在使用SparseCategoricalCrossentropy时,可以配置的参数如下。
    y_true:配置期望的真实值。
    y_pred:配置预测的值

from_logits=True 标志位将softmax激活函数实现在损失函数中,便不需要手动添加softmax损失函数,提升数值计算稳定性。
from_logits 指的就是是否有经过Logistic函数,常见的Logistic函数包括Sigmoid、Softmax函数。
函数参数默认为from_logits=False,网络预测值y_pred 表示必须为经过了 Softmax函数的输出值。
当from_logits 显式设置为 True 时,网络预测值y_pred 表示必须为还没经过 Softmax 函数的变量。
"""
loss_object = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)

"""
tf.train.Checkpoint 变量的保存与恢复
    1.Checkpoint 只保存模型的参数,不保存模型的计算过程,因此一般用于在具有模型源代码的时候恢复之前训练好的模型参数。
      如果需要导出模型(无需源代码也能运行模型).
    2.TensorFlow 提供了 tf.train.Checkpoint 这一强大的变量保存与恢复类,可以使用其 save() 和 restore() 方法,
      将 TensorFlow 中所有包含 Checkpointable State 的对象进行保存和恢复。具体而言,tf.keras.optimizer 、 tf.Variable 、 
      tf.keras.Layer 或者 tf.keras.Model 实例都可以被保存。
      其使用方法非常简单,我们首先声明一个 Checkpoint:checkpoint = tf.train.Checkpoint(model=model)
    3.这里 tf.train.Checkpoint() 接受的初始化参数比较特殊,是一个 **kwargs 。
      具体而言,是一系列的键值对,键名可以随意取,值为需要保存的对象。
      例如,如果我们希望保存一个继承 tf.keras.Model 的模型实例 model 和一个继承 tf.train.Optimizer 的优化器 optimizer ,
      我们可以这样写:checkpoint = tf.train.Checkpoint(myAwesomeModel=model, myAwesomeOptimizer=optimizer)
      这里 myAwesomeModel 是我们为待保存的模型 model 所取的任意键名。注意,在恢复变量的时候,我们还将使用这一键名。
      接下来,当模型训练完成需要保存的时候,使用:checkpoint.save(save_path_with_prefix) 就可以。 
      save_path_with_prefix 是保存文件的目录 + 前缀。
"""
checkpoint = tf.train.Checkpoint(optimizer=optimizer, encoder=encoder, decoder=decoder)


# 传入 批量输出语句(答句)中第t个时间步上的单词所在字典的索引值、当前时间步的解码器预测输出值,最终计算返回当前时间步的loss值
def loss_function(real, pred):
    """
    logical_not是一个逻辑非运算,返回的是一个布尔型数值,当两个元素不相同时返回True,反之返回False。
    在使用logical_not时,可以配置的参数如下。
        x:配置需要运算的Tensor。
        name:配置运算操作的名称。
    """
    #先标识出 批量输出语句(答句)中的填充值0的位置,通过equal把这些填充值0所在的位置都标识出来为True,
    #同时通过logical_not判断两者相同为True的话,则最终返回False,那么最终mask掩码中对应填充值0的位置上的值便会被置为False,
    #目的是去除填充值0对最终计算loss的影响,填充值0便不会被计算其中。
    mask = tf.math.logical_not(tf.math.equal(real, 0))
    #使用SparseCategoricalCrossentropy计算loss,传入 批量输出语句(答句)中第t个时间步上的单词所在字典的索引值、当前时间步的解码器预测输出值
    #SparseCategoricalCrossentropy是可以接受稀疏编码的多对数交叉熵,所谓的接受稀疏编码就是指期望值可以是整型的分类编码,如1,2,3等。
    loss_ = loss_object(real, pred)
    mask = tf.cast(mask, dtype=loss_.dtype)
    #mask掩码中对应填充值0的位置上的值便会被置为False,目的是去除填充值0对最终计算loss的影响,填充值0便不会被计算其中。
    loss_ *= mask
    return tf.reduce_mean(loss_)


# 获取每步所训练的批量数据的loss值:传入 批量输入语句(问句)、批量输出语句(答句)、目标语句(答句)的Tokenizer对象、编码器隐藏层状态
# @tf.function
def train_step(inp, targ, targ_lang, enc_hidden):
    #每步所训练的批量数据的loss值
    loss = 0
    with tf.GradientTape() as tape:
        #调用encoder编码器的call方法:传入 批量输入语句(问句)、编码器隐藏层状态 ,返回 编码器输出、编码器隐藏层状态输出
        #shape为(批量大小128, 句子最大长度20, GRU层神经元数量256)的编码器输出enc_output、shape为(批量大小128, GRU层神经元数量256)的编码器隐藏层状态输出enc_hidden
        enc_output, enc_hidden = encoder(inp, enc_hidden)
        #把编码器隐藏层状态输出 作为 解码器隐藏层状态的输入
        dec_hidden = enc_hidden
        #根据目标语句(答句)的Tokenizer对象.word_index['start'] 获取该单词在字典中的索引值,shape为(批量大小128,),
        #然后又进行维度拓增 最终返回shape为(批量大小128, 1)的数据
        dec_input = tf.expand_dims([targ_lang.word_index['start']] * BATCH_SIZE, 1)
        print("dec_input.shape:",dec_input.shape) #(128, 1)

        #批量输出语句(答句).shape[1]为输出语句(答句)的最大长度20,但不从0开始遍历而是从1开始遍历,因为不需要遍历首个单词'start'
        for t in range(1, targ.shape[1]):
            """
            1.根据输入信息,逐字对输出语句(答句)进行预测,解码器最终返回预测值、解码器隐藏层状态、注意力权重
                第一次预测:
                    传入上一个单词'start'、编码器隐藏层状态、编码器输出。
                    使用单词'start'作为解码器的输入来预测下一个单词,
                    然后根据所预测的单词和真实输出语句(答句)中单词'start'的下一个单词两者求出loss值。
                第一次后面的预测:
                    传入下一个时间步的真实单词、上一个时间步解码器隐藏层状态、编码器输出。
                    使用真实输出语句(答句)中第t-1个时间步的单词作为解码器的输入来预测下一个单词(对应真实输出语句(答句)中第t个时间步的单词),
                    然后根据所预测的单词和真实输出语句(答句)中第t个时间步的单词两者求出loss值。
            2.decoder解码器的call方法的传入值
                单词'start'/下一个时间步的真实单词 shape为(批量大小128, 1)
                编码器隐藏层状态/上一个时间步解码器隐藏层状态 shape为(批量大小128, GRU层神经元数量256)
                编码器输出 shape为(批量大小128, 句子最大长度20, GRU层神经元数量256)
            3.decoder解码器的call方法的输出值
                当前时间步的解码器预测输出值 shape为(批量大小128, 解码器器字典大小20000)
                当前时间步解码器隐藏层状态 shape为(批量大小128, GRU层神经元数量256)
                注意力权重 shape为 (batch_size, max_length句子最大长度20, 1)
            """
            # 调用decoder解码器的call方法:传入 单词'start'/下一个时间步的真实单词、编码器隐藏层状态/上一个时间步解码器隐藏层状态、编码器输出
            # 解码器最终返回 当前时间步预测出的单词、当前时间步解码器隐藏层状态、注意力权重。
            predictions, dec_hidden, _ = decoder(dec_input, dec_hidden, enc_output)
            # targ[:, t] 获取的是批量输出语句(答句)中第t个时间步上的单词所在字典的索引值,shape为(128,) 即(批量大小128,)
            # 传入 批量输出语句(答句)中第t个时间步上的单词所在字典的索引值、当前时间步的解码器预测输出值 计算当前时间步的loss值
            # 先计算一个批量数据中每个时间步的loss值,然后汇总每个时间步的loss值为一个批量数据中的总loss值
            loss += loss_function(targ[:, t], predictions)
            print("targ[:, t].shape:", targ[:, t].shape) #(128,)
            #targ[:, t] 获取的是批量输出语句(答句)中第t个时间步上的单词所在字典的索引值,shape为(128,) 即(批量大小128,),
            #然后又进行维度拓增 最终返回shape为(128, 1) 即(批量大小128, 1)的数据,即把下一个时间步的真实单词作为解码器的输入进行下次预测
            dec_input = tf.expand_dims(targ[:, t], 1)
            print("dec_input.shape:", dec_input.shape) #(128, 1)

    #loss为一个批量数据中每个时间步的loss值所汇总的loss值,然后除以总的时间步数(即输出语句(答句)的最大长度20),得出一个批量数据中的平均loss值
    batch_loss = (loss / int(targ.shape[1]))
    #把编码器和解码器网络的参数都作为可自动微调的参数
    variables = encoder.trainable_variables + decoder.trainable_variables
    #通过应用loss对编码器和解码器网络的参数进行求出相应的梯度
    gradients = tape.gradient(loss, variables)
    #根据相应的梯度对编码器和解码器网络的参数进行梯度下降
    optimizer.apply_gradients(zip(gradients, variables))
    #返回一个批量数据中的平均loss值
    return batch_loss

    1.MXNet中的 编码器-解码器-注意力机制
        1.GRU在前向计算后会分别返回输出output(即最后一层的隐藏层在各个时间步上计算并输出的隐藏状态)和最终时间步的多层隐藏状态state,
          其中前向计算后返回的输出output指的是最后一层的隐藏层在各个时间步上计算并输出的隐藏状态,shape为(时间步数, 批量大小, 隐藏单元个数),
          该输出本身通常作为后续输出层的输入,因此该输出本身就不涉及输出层计算,而注意力机制通常将该输出本身同时作为键项K和值项V。
        2.其中GRU前向计算后返回的最终时间步的多层隐藏状态state指的是隐藏层在最后时间步的“可用于初始化下一时间步的”隐藏状态,
          shape为(隐藏层个数, 批量大小, 隐藏单元个数):当隐藏层有多层时,每一层的隐藏状态都会记录到该变量中。
          注意:最终时间步的多层隐藏状态state为一个列表。
          对于门控循环单元:state[0]即能取出“shape为(隐藏层个数, 批量大小, 隐藏单元个数)的最终时间步的”多层隐藏状态。
          对于像长短期记忆这样的循环神经网络:state列表不仅包含多层隐藏状态,还包含记忆细胞。
    2.当前BahdanauAttention类中下面的call函数的传入值实际代表意义如下:
        1.query(查询项Q):
                1.第一次计算注意力:编码器Encode的GRU在前向计算后返回的最终时间步的多层隐藏状态state
                2.第一次之后的计算注意力:上一个时间步解码器Decode的隐藏层状态
        2.values(键项K/值项V):编码器Encode的GRU在前向计算后返回的“最后一层的隐藏层在各个时间步上计算并输出的”隐藏状态 

  • 3
    点赞
  • 34
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

あずにゃん

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值