为了账号安全,请及时绑定邮箱和手机立即绑定

seq2seq里在chatbot的一些用法

标签:
人工智能

转处:https://blog.csdn.net/liuchonge/article/details/79021938

上篇文章我们使用tf.contrib.legacy_seq2seq下的API构建了一个简单的chatbot对话系统,但是我们已经说过,这部分代码是1.0版本之前所提供的API,将来会被弃用,而且API接口并不灵活,在实际使用过程中还会存在版本不同导致的各种个样的错误。所以我们有必要学习一下新版本的API,这里先来说一下二者的不同:

  • 新版本都是用dynamic_rnn来构造RNN模型,这样就避免了数据长度不同所带来的困扰,不需要再使用model_with_buckets这种方法来构建模型,使得我们数据处理和模型代码都简洁很多

  • 新版本将Attention、Decoder等几个主要的功能都分别进行封装,直接调用相应的Wapper函数进行封装即可,调用起来更加灵活方便,而且只需要写几个简单的函数既可以自定义的各个模块以满足我们个性化的需求。

  • 实现了beam_search功能,可直接调用

这次我们先来看如何直接使用新版本API构造对话系统,然后等下一篇文章在分析一些主要文件和函数的源码实现。本文代码可以再我的github中找到:seq2seq_chatbot_new。欢迎fork和star~~

数据处理

仍然沿用之前的代码,不过createBatch函数可以变得简单而又整洁,原因是新版本API我们在定义输入的placeholder是不需要在定义为seq_len*batch_size这样的列表,直接定义一个batch_size*seq_len的tensor即可。所以数据处理部分的代码也可以简化为得到一个嵌套列表的形式即可。这里我们重新定义Batch类,使其包含四个元素分别为encoder_inputs、encoder_inputs_length、decoder_targets、decoder_targets_length,前两项是PAD之后的源序列及每个序列的长度,后两项为PAD之后的目的序列和每个序列的长度。这里encoder_inputs_length和decoder_targets_length是为了动态编解码时表示序列长度的作用,下面给出修改了的Batch类和createBatch函数,其他函数都没有发生变化。

    class Batch:
        def __init__(self):
            self.encoder_inputs = [] #嵌套列表,每个元素都是一个句子中每个单词都id
            self.encoder_inputs_length = [] #一维列表,每个元素对应上面每个句子的长度
            self.decoder_targets = []
            self.decoder_targets_length = []    def createBatch(samples):
        '''
        根据给出的samples(就是一个batch的数据),进行padding并构造成placeholder所需要的数据形式
        :param samples: 一个batch的样本数据,列表,每个元素都是[question, answer]的形式,id
        :return: 处理完之后可以直接传入feed_dict的数据格式
        '''
        batch = Batch()        #获取每个样本的长度,并保存在source_length和target_length中
        batch.encoder_inputs_length = [len(sample[0]) for sample in samples]
        batch.decoder_targets_length = [len(sample[1]) for sample in samples]        #获得一个batch样本中最大的序列长度
        max_source_length = max(batch.encoder_inputs_length)
        max_target_length = max(batch.decoder_targets_length)        #将每个样本进行PAD至最大长度
        for sample in samples:            #将source进行反序并PAD值本batch的最大长度
            source = list(reversed(sample[0]))
            pad = [padToken] * (max_source_length - len(source))
            batch.encoder_inputs.append(pad + source)            #将target进行PAD,并添加END符号
            target = sample[1]
            pad = [padToken] * (max_target_length - len(target))
            batch.decoder_targets.append(target + pad)        return batch

模型构建

这一部分代码主要是从tensorflow官网给出的nmt例子的代码简化而来,实现了最基本的attention和beam_search等功能,同时有将nmt代码中繁杂的代码逻辑进行简化,将不必要的代码都清除,是的代码的易读性提高。这里参考nmt中所提到的构建train、eval、inference三个图进行模型构建,好处在于(下面部分翻译自nmt官方文档):

  • inference图往往与train和eval结构存在较大差异(没有decoder输入和目标,需要使用贪婪或者beam_search进行decode,batch_size也不同等等),所以往往需要单独进行构建

  • eval图也会得到简化,因为其不需要进行反向传播,只需要得到一个loss和acc值

  • 数据可以分别进行feed,简化数据操作

  • 变量重用变得简单,因为train、eval存在一些公用变量和代码块,就不需要我们重复定义,使代码简化

  • 只需要在train时不断保存模型参数,然后在eval和infer的时候restore参数即可

以上,所以我们构建了train、eval、infer三个函数来实现上面的功能。在看代码之前我们先来简单说一下新版API几个主要的模块以及相互之间的调用关系。tf.contrib.seq2seq文件夹下面主要有下面6个文件,除了loss文件和之前的sequence_loss函数没有很大区别,这里不介绍之外,其他几个文件都会简单的说一下,这里主要介绍函数和类的功能,源码会放在下篇文章中介绍。

  • decoder

  • basic_decoder

  • helper

  • attention_wrapper

  • beam_search_decoder

  • loss

BasicDecoder类和dynamic_decode

decoder和basic_decoder文件可以放在一起看,decoder文件中定义了Decoder抽象类和dynamic_decode函数,dynamic_decode可以视为整个解码过程的入口,需要传入的参数就是Decoder的一个实例,他会动态的调用Decoder的step函数按步执行decode,可以理解为Decoder类定义了单步解码(根据输入求出输出,并将该输出当做下一时刻输入),而dynamic_decode则会调用control_flow_ops.while_loop这个函数来循环执行直到输出结束编码过程。而basic_decoder文件定义了一个基本的Decoder类实例BasicDecoder,看一下其初始化函数:

def __init__(self, cell, helper, initial_state, output_layer=None):
  • 1

  • 2

需要传入的参数就是cell类型、helper类型、初始化状态(encoder的最后一个隐层状态)、输出层(输出映射层,将rnn_size转化为vocab_size维),需要注意的就是前面两个,下面分别介绍:

cell类型(Attention类型)

cell类型就是RNNCell,也就是decode阶段的神经元,可以使简单的RNN、GRU、LSTM(也可以加上dropout、并使用MultiRNNCell进行堆叠成多层),也可以是加上了Attention功能之后的RNNcell。这就引入了attention_wrapper文件中定义的几种attention机制(BahdanauAttention、 LuongAttention、 BahdanauMonotonicAttention、 LuongMonotonicAttention)和将attention机制封装到RNNCell上面的方法AttentionWrapper。其实很简单,就跟dropoutwrapper、outputwrapper一样,我们只需要在原本RNNCell的基础上在封装一层attention即可。代码如下所示:

    # 分为三步,第一步是定义attention机制,第二步是定义要是用的基础的RNNCell,第三步是使用AttentionWrapper进行封装
    #定义要使用的attention机制。
    attention_mechanism = tf.contrib.seq2seq.BahdanauAttention(num_units=self.rnn_size, memory=encoder_outputs, memory_sequence_length=encoder_inputs_length)    #attention_mechanism = tf.contrib.seq2seq.LuongAttention(num_units=self.rnn_size, memory=encoder_outputs, memory_sequence_length=encoder_inputs_length)
    # 定义decoder阶段要是用的LSTMCell,然后为其封装attention wrapper
    decoder_cell = self._create_rnn_cell()
    decoder_cell = tf.contrib.seq2seq.AttentionWrapper(cell=decoder_cell, attention_mechanism=attention_mechanism, attention_layer_size=self.rnn_size, name='Attention_Wrapper')

helper类型

helper其实就是decode阶段如何根据预测结果得到下一时刻的输入,比如训练过程中应该直接使用上一时刻的真实值作为下一时刻输入,预测过程中可以使用贪婪的方法选择概率最大的那个值作为下一时刻等等。所以Helper也就可以大致分为训练时helper和预测时helper两种。官网给出了下面几种Helper类:

  • “Helper”:最基本的抽象类

  • “TrainingHelper”:训练过程中最常使用的Helper,下一时刻输入就是上一时刻target的真实值

  • “GreedyEmbeddingHelper”:预测阶段最常使用的Helper,下一时刻输入是上一时刻概率最大的单词通过embedding之后的向量

  • “SampleEmbeddingHelper”:预测时helper,继承自GreedyEmbeddingHelper,下一时刻输入是上一时刻通过某种概率分布采样而来在经过embedding之后的向量

  • “CustomHelper”:最简单的helper,一般用户自定义helper时会基于此,需要用户自己定义如何根据输出得到下一时刻输入

  • “ScheduledEmbeddingTrainingHelper”:训练时Helper,继承自TrainingHelper,添加了广义伯努利分布,对id的embedding向量进行sampling

  • “ScheduledOutputTrainingHelper”:训练时Helper,继承自TrainingHelper,直接对输出进行采样

  • “InferenceHelper”:CustomHelper的特例,只用于预测的helper,也需要用户自定义如何得到下一时刻输入

所以了解cell和helper类之后我们就可以很轻松的构建decode阶段的模型,以train阶段为例:

    #分为四步,第一步是定义cell类型,第二步是定义helper类型,第三步是定义BasicDecoder类实例,第四步是调用dynamic_decode函数进行解码
    cell = ***(上面的代码)
    training_helper = tf.contrib.seq2seq.TrainingHelper(inputs=decoder_inputs_embedded,
                                                        sequence_length=self.decoder_targets_length,
                                                        time_major=False, name='training_helper')
    training_decoder = tf.contrib.seq2seq.BasicDecoder(cell=decoder_cell, helper=training_helper,
                                                       initial_state=decoder_initial_state, output_layer=output_layer)
    decoder_outputs, _, _ = tf.contrib.seq2seq.dynamic_decode(decoder=training_decoder, impute_finished=True,
                                                        maximum_iterations=self.max_target_sequence_length)

beam search decoder类

到这,基本上就可以构建一个完整的seq2seq模型了,但是上面的文件中还有beam_search_decoder.py文件没有介绍,也就是我们常用的beam_search方法,下面也简单说一下。该文件定义了BeamSearchDecoder类,其实是一个Decoder的实例,跟BasicDecoder在一个等级上,但是二者又存在着不同,因为BasicDecoder需要指定helper参数,也就是指定decode阶段如何根据上一时刻输出获得下一时刻输入。但是BeamSearchDecoder不需要,因为其在内部实现了beam_search的功能,也就包含了helper的效果,不需要再额外定义。所以BeamSearchDecoder的调用方法如下所示:

    #分为三步,第一步是定义cell,第二步是定义BeamSearchDecoder,第三步是调用dynamic_decode函数进行解码
    cell = ***(上面代码)
    inference_decoder = tf.contrib.seq2seq.BeamSearchDecoder(cell=decoder_cell, embedding=embedding,
                                                             start_tokens=start_tokens, end_token=end_token,
                                                             initial_state=decoder_initial_state,
                                                             beam_width=self.beam_size,
                                                             output_layer=output_layer)
    decoder_outputs, _, _ = tf.contrib.seq2seq.dynamic_decode(decoder=inference_decoder,
                                                    maximum_iterations=self.max_target_sequence_length)

OK,接下来切入正题,看一下model部分代码:

    class Seq2SeqModel():
        def __init__(self, rnn_size, num_layers, embedding_size, learning_rate, word_to_idx, mode, use_attention,
                     beam_search, beam_size, max_gradient_norm=5.0):
            self.learing_rate = learning_rate            self.embedding_size = embedding_size            self.rnn_size = rnn_size            self.num_layers = num_layers            self.word_to_idx = word_to_idx            self.vocab_size = len(self.word_to_idx)            self.mode = mode            self.use_attention = use_attention            self.beam_search = beam_search            self.beam_size = beam_size            self.max_gradient_norm = max_gradient_norm            #执行模型构建部分的代码
            self.build_model()        def _create_rnn_cell(self, single=False):
            def single_rnn_cell():
                # 创建单个cell,这里需要注意的是一定要使用一个single_rnn_cell的函数,不然直接把cell放在MultiRNNCell
                # 的列表中最终模型会发生错误
                single_cell = tf.contrib.rnn.LSTMCell(self.rnn_size)                #添加dropout
                cell = tf.contrib.rnn.DropoutWrapper(single_cell, output_keep_prob=self.keep_prob_placeholder)                return cell            #列表中每个元素都是调用single_rnn_cell函数
            cell = tf.contrib.rnn.MultiRNNCell([single_rnn_cell() for _ in range(self.num_layers)])            return cell        def build_model(self):
            print('building model... ...')            #=================================1, 定义模型的placeholder
            self.encoder_inputs = tf.placeholder(tf.int32, [None, None], name='encoder_inputs')            self.encoder_inputs_length = tf.placeholder(tf.int32, [None], name='encoder_inputs_length')            self.batch_size = tf.placeholder(tf.int32, [], name='batch_size')            self.keep_prob_placeholder = tf.placeholder(tf.float32, name='keep_prob_placeholder')            self.decoder_targets = tf.placeholder(tf.int32, [None, None], name='decoder_targets')            self.decoder_targets_length = tf.placeholder(tf.int32, [None], name='decoder_targets_length')            # 根据目标序列长度,选出其中最大值,然后使用该值构建序列长度的mask标志。用一个sequence_mask的例子来说明起作用
            #  tf.sequence_mask([1, 3, 2], 5)
            #  [[True, False, False, False, False],
            #  [True, True, True, False, False],
            #  [True, True, False, False, False]]
            self.max_target_sequence_length = tf.reduce_max(self.decoder_targets_length, name='max_target_len')            self.mask = tf.sequence_mask(self.decoder_targets_length, self.max_target_sequence_length, dtype=tf.float32, name='masks')            #=================================2, 定义模型的encoder部分
            with tf.variable_scope('encoder'):
                #创建LSTMCell,两层+dropout
                encoder_cell = self._create_rnn_cell()                #构建embedding矩阵,encoder和decoder公用该词向量矩阵
                embedding = tf.get_variable('embedding', [self.vocab_size, self.embedding_size])
                encoder_inputs_embedded = tf.nn.embedding_lookup(embedding, self.encoder_inputs)                # 使用dynamic_rnn构建LSTM模型,将输入编码成隐层向量。
                # encoder_outputs用于attention,batch_size*encoder_inputs_length*rnn_size,
                # encoder_state用于decoder的初始化状态,batch_size*rnn_szie
                encoder_outputs, encoder_state = tf.nn.dynamic_rnn(encoder_cell, encoder_inputs_embedded,
                                                                   sequence_length=self.encoder_inputs_length,
                                                                   dtype=tf.float32)            # =================================3, 定义模型的decoder部分
            with tf.variable_scope('decoder'):
                encoder_inputs_length = self.encoder_inputs_length                if self.beam_search:
                    # 如果使用beam_search,则需要将encoder的输出进行tile_batch,其实就是复制beam_size份。
                    print("use beamsearch decoding..")
                    encoder_outputs = tf.contrib.seq2seq.tile_batch(encoder_outputs, multiplier=self.beam_size)
                    encoder_state = nest.map_structure(lambda s: tf.contrib.seq2seq.tile_batch(s, self.beam_size), encoder_state)
                    encoder_inputs_length = tf.contrib.seq2seq.tile_batch(self.encoder_inputs_length, multiplier=self.beam_size)                #定义要使用的attention机制。
                attention_mechanism = tf.contrib.seq2seq.BahdanauAttention(num_units=self.rnn_size, memory=encoder_outputs,
                                                                         memory_sequence_length=encoder_inputs_length)                #attention_mechanism = tf.contrib.seq2seq.LuongAttention(num_units=self.rnn_size, memory=encoder_outputs, memory_sequence_length=encoder_inputs_length)
                # 定义decoder阶段要是用的LSTMCell,然后为其封装attention wrapper
                decoder_cell = self._create_rnn_cell()
                decoder_cell = tf.contrib.seq2seq.AttentionWrapper(cell=decoder_cell, attention_mechanism=attention_mechanism,
                                                                   attention_layer_size=self.rnn_size, name='Attention_Wrapper')                #如果使用beam_seach则batch_size = self.batch_size * self.beam_size。因为之前已经复制过一次
                batch_size = self.batch_size if not self.beam_search else self.batch_size * self.beam_size                #定义decoder阶段的初始化状态,直接使用encoder阶段的最后一个隐层状态进行赋值
                decoder_initial_state = decoder_cell.zero_state(batch_size=batch_size, dtype=tf.float32).clone(cell_state=encoder_state)
                output_layer = tf.layers.Dense(self.vocab_size, kernel_initializer=tf.truncated_normal_initializer(mean=0.0, stddev=0.1))                if self.mode == 'train':
                    # 定义decoder阶段的输入,其实就是在decoder的target开始处添加一个<go>,并删除结尾处的<end>,并进行embedding。
                    # decoder_inputs_embedded的shape为[batch_size, decoder_targets_length, embedding_size]
                    ending = tf.strided_slice(self.decoder_targets, [0, 0], [self.batch_size, -1], [1, 1])
                    decoder_input = tf.concat([tf.fill([self.batch_size, 1], self.word_to_idx['<go>']), ending], 1)
                    decoder_inputs_embedded = tf.nn.embedding_lookup(embedding, decoder_input)                    #训练阶段,使用TrainingHelper+BasicDecoder的组合,这一般是固定的,当然也可以自己定义Helper类,实现自己的功能
                    training_helper = tf.contrib.seq2seq.TrainingHelper(inputs=decoder_inputs_embedded,
                                                                        sequence_length=self.decoder_targets_length,
                                                                        time_major=False, name='training_helper')
                    training_decoder = tf.contrib.seq2seq.BasicDecoder(cell=decoder_cell, helper=training_helper,
                                                                       initial_state=decoder_initial_state, output_layer=output_layer)                    #调用dynamic_decode进行解码,decoder_outputs是一个namedtuple,里面包含两项(rnn_outputs, sample_id)
                    # rnn_output: [batch_size, decoder_targets_length, vocab_size],保存decode每个时刻每个单词的概率,可以用来计算loss
                    # sample_id: [batch_size], tf.int32,保存最终的编码结果。可以表示最后的答案
                    decoder_outputs, _, _ = tf.contrib.seq2seq.dynamic_decode(decoder=training_decoder,
                                                                              impute_finished=True,
                                                                        maximum_iterations=self.max_target_sequence_length)                    # 根据输出计算loss和梯度,并定义进行更新的AdamOptimizer和train_op
                    self.decoder_logits_train = tf.identity(decoder_outputs.rnn_output)                    self.decoder_predict_train = tf.argmax(self.decoder_logits_train, axis=-1, name='decoder_pred_train')                    # 使用sequence_loss计算loss,这里需要传入之前定义的mask标志
                    self.loss = tf.contrib.seq2seq.sequence_loss(logits=self.decoder_logits_train,
                                                                 targets=self.decoder_targets, weights=self.mask)                    # Training summary for the current batch_loss
                    tf.summary.scalar('loss', self.loss)                    self.summary_op = tf.summary.merge_all()

                    optimizer = tf.train.AdamOptimizer(self.learing_rate)
                    trainable_params = tf.trainable_variables()
                    gradients = tf.gradients(self.loss, trainable_params)
                    clip_gradients, _ = tf.clip_by_global_norm(gradients, self.max_gradient_norm)                    self.train_op = optimizer.apply_gradients(zip(clip_gradients, trainable_params))
                elif self.mode == 'decode':
                    start_tokens = tf.ones([self.batch_size, ], tf.int32) * self.word_to_idx['<go>']
                    end_token = self.word_to_idx['<eos>']                    # decoder阶段根据是否使用beam_search决定不同的组合,
                    # 如果使用则直接调用BeamSearchDecoder(里面已经实现了helper类)
                    # 如果不使用则调用GreedyEmbeddingHelper+BasicDecoder的组合进行贪婪式解码
                    if self.beam_search:
                        inference_decoder = tf.contrib.seq2seq.BeamSearchDecoder(cell=decoder_cell, embedding=embedding,
                                                                                 start_tokens=start_tokens, end_token=end_token,
                                                                                 initial_state=decoder_initial_state,
                                                                                 beam_width=self.beam_size,
                                                                                 output_layer=output_layer)                    else:
                        decoding_helper = tf.contrib.seq2seq.GreedyEmbeddingHelper(embedding=embedding,
                                                                                   start_tokens=start_tokens, end_token=end_token)
                        inference_decoder = tf.contrib.seq2seq.BasicDecoder(cell=decoder_cell, helper=decoding_helper,
                                                                            initial_state=decoder_initial_state,
                                                                            output_layer=output_layer)
                    decoder_outputs, _, _ = tf.contrib.seq2seq.dynamic_decode(decoder=inference_decoder,
                                                                    maximum_iterations=10)                    # 调用dynamic_decode进行解码,decoder_outputs是一个namedtuple,
                    # 对于不使用beam_search的时候,它里面包含两项(rnn_outputs, sample_id)
                    # rnn_output: [batch_size, decoder_targets_length, vocab_size]
                    # sample_id: [batch_size, decoder_targets_length], tf.int32

                    # 对于使用beam_search的时候,它里面包含两项(predicted_ids, beam_search_decoder_output)
                    # predicted_ids: [batch_size, decoder_targets_length, beam_size],保存输出结果
                    # beam_search_decoder_output: BeamSearchDecoderOutput instance namedtuple(scores, predicted_ids, parent_ids)
                    # 所以对应只需要返回predicted_ids或者sample_id即可翻译成最终的结果
                    if self.beam_search:
                        self.decoder_predict_decode = decoder_outputs.predicted_ids                    else:
                        self.decoder_predict_decode = tf.expand_dims(decoder_outputs.sample_id, -1)            # =================================4, 保存模型
            self.saver = tf.train.Saver(tf.all_variables())        def train(self, sess, batch):
            #对于训练阶段,需要执行self.train_op, self.loss, self.summary_op三个op,并传入相应的数据
            feed_dict = {self.encoder_inputs: batch.encoder_inputs,                          self.encoder_inputs_length: batch.encoder_inputs_length,                          self.decoder_targets: batch.decoder_targets,                          self.decoder_targets_length: batch.decoder_targets_length,                          self.keep_prob_placeholder: 0.5,                          self.batch_size: len(batch.encoder_inputs)}            _, loss, summary = sess.run([self.train_op, self.loss, self.summary_op], feed_dict=feed_dict)            return loss, summary        def eval(self, sess, batch):
            # 对于eval阶段,不需要反向传播,所以只执行self.loss, self.summary_op两个op,并传入相应的数据
            feed_dict = {self.encoder_inputs: batch.encoder_inputs,                          self.encoder_inputs_length: batch.encoder_inputs_length,                          self.decoder_targets: batch.decoder_targets,                          self.decoder_targets_length: batch.decoder_targets_length,                          self.keep_prob_placeholder: 1.0,                          self.batch_size: len(batch.encoder_inputs)}
            loss, summary = sess.run([self.loss, self.summary_op], feed_dict=feed_dict)            return loss, summary        def infer(self, sess, batch):
            #infer阶段只需要运行最后的结果,不需要计算loss,所以feed_dict只需要传入encoder_input相应的数据即可
            feed_dict = {self.encoder_inputs: batch.encoder_inputs,                          self.encoder_inputs_length: batch.encoder_inputs_length,                          self.keep_prob_placeholder: 1.0,                          self.batch_size: len(batch.encoder_inputs)}
            predict = sess.run([self.decoder_predict_decode], feed_dict=feed_dict)            return predict

训练 && 预测

模型构建好之后,剩下的工作就很简单了,训练的话其实就是一个简单的循环,每个epoch都重新shuffle数据并产生batches的数据并传入模型调用train函数进行训练。时不时打印结果并保存模型参数,这里如果大家有eval数据集可以添加上相应的代码,比如每个100步评价一次等~~

预测的话跟训练步骤是一样的,先倒入模型参数,再将输入的句子转化成batch,接下来调用infer函数即可。这里主要说一下如何从infer函数返回结果predicted_ids得到我们想要的字符串结果。首先来讲predicted_ids是一个batch_size*decode_length*beam_size维度的列表,我这里每次只预测一个结果所以batch_size为1。我们最终想要beam_szie个长度为decode_length的字符串(如果字符串中有,则长度会变短),所以需要对predicted_ids进行转化并将id转换为其对应的word。使用下面这个函数即可:

    def predict_ids_to_seq(predict_ids, id2word, beam_szie):
        '''
        将beam_search返回的结果转化为字符串
        :param predict_ids: 列表,长度为batch_size,每个元素都是decode_len*beam_size的数组
        :param id2word: vocab字典
        :return:
        '''
        for single_predict in predict_ids:            for i in range(beam_szie):
                predict_list = np.ndarray.tolist(single_predict[:, :, i])
                predict_seq = []                for idx in predict_list[0]:
                    predict_seq.append(id2word[idx])
                print(predict_seq)

原文出处

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消