1. 程式人生 > >使用TensorFlow實現RNN模型入門篇2--char-rnn語言建模模型

使用TensorFlow實現RNN模型入門篇2--char-rnn語言建模模型

這是使用tf實現RNN模型的第二篇,上次用很簡單的例子實現了一個簡單的RNN用於解釋其原理,這次我們開始結合NLP嘗試構建一個char-rnn的語言建模模型。和CNN的入門篇一樣,我們這裡也直接來分析一個github上star很多的專案,這樣我們不僅可以學習到一些程式設計的標準規範,還能夠開始我們的RNN-NLP之旅。閒話少說,先來介紹一下此次要實現的char-rnn模型。
這個模型是Andrej Karpathy提出來的,可以去看他的一篇博文The Unreasonable Effectiveness of Recurrent Neural Networks,程式碼存放在其github

上面,不過是用lua程式設計實現的,在這之後sherjilozair使用tf進行了重寫,這也就是我們今天要學習的程式碼。這裡使用的是多層RNN/LSTM模型,我們同樣按照資料集、預處理、模型構建、模型訓練、結果分析的思路對其程式碼進行分析。

資料集和預處理

首先看一下這裡要用到的資料集是小莎士比亞文集。很簡單就是一個txt文字檔案,我們的預處理工作主要是獲得該資料集中所有出現的字元,存到vocab裡面,並按照其出現次數多少構建索引列表,最後將我們的資料轉化為int型索引。此外也包括譬如劃分batch等功能。其實要實現的功能很簡單,如果要我自己寫的話應該是寫很多個迴圈把想要的資料一次次遍歷出來即可,但是這裡作者使用collections.Counter函式以及dict、map、zip幾個功能函式很簡單的實現了,這也是我們需要學習的地方。接下來看一下程式碼:

class TextLoader():
    def __init__(self, data_dir, batch_size, seq_length, encoding='utf-8'):
        self.data_dir = data_dir
        self.batch_size = batch_size
        self.seq_length = seq_length
        self.encoding = encoding
        #第一次執行程式時只有input.txt一個檔案,剩下兩個檔案是執行之後產生的
        input_file = os.path.join(data_dir, "input.txt"
) vocab_file = os.path.join(data_dir, "vocab.pkl") tensor_file = os.path.join(data_dir, "data.npy") #如果是第一次執行則呼叫preprocess函式,否則呼叫load_preprocessed函式。 if not (os.path.exists(vocab_file) and os.path.exists(tensor_file)): print("reading text file") self.preprocess(input_file, vocab_file, tensor_file) else: print("loading preprocessed files") self.load_preprocessed(vocab_file, tensor_file) self.create_batches() self.reset_batch_pointer() def preprocess(self, input_file, vocab_file, tensor_file): with codecs.open(input_file, "r", encoding=self.encoding) as f: data = f.read() #使用Counter函式對輸入資料進行統計。counter儲存data中每個字元出現的次數 counter = collections.Counter(data) #對counter進行排序,出現次數最多的排在前面 count_pairs = sorted(counter.items(), key=lambda x: -x[1]) #將data中出現的所有字元儲存,這裡有65個,所以voacb_size=65 self.chars, _ = zip(*count_pairs) self.vocab_size = len(self.chars) #按照字元出現次數多少順序將chars儲存,vocab中儲存的是char和順序,這樣方便將data轉化為索引 self.vocab = dict(zip(self.chars, range(len(self.chars)))) with open(vocab_file, 'wb') as f: #儲存chars cPickle.dump(self.chars, f) #將data中每個字元轉化為索引下標。 self.tensor = np.array(list(map(self.vocab.get, data))) np.save(tensor_file, self.tensor) def load_preprocessed(self, vocab_file, tensor_file): #如果是第二次執行,則可以直接讀取之前儲存的chars和tensor with open(vocab_file, 'rb') as f: self.chars = cPickle.load(f) self.vocab_size = len(self.chars) self.vocab = dict(zip(self.chars, range(len(self.chars)))) self.tensor = np.load(tensor_file) self.num_batches = int(self.tensor.size / (self.batch_size * self.seq_length)) def create_batches(self): #首先將資料按batch_size切割,然後每個batch_size在按照seq_length進行切割 self.num_batches = int(self.tensor.size / (self.batch_size * self.seq_length)) if self.num_batches == 0: assert False, "Not enough data. Make seq_length and batch_size small." self.tensor = self.tensor[:self.num_batches * self.batch_size * self.seq_length] xdata = self.tensor #構造target,這裡使用上一個詞預測下一個詞,所以直接將x向後一個字元即可 ydata = np.copy(self.tensor) ydata[:-1] = xdata[1:] ydata[-1] = xdata[0] #將資料進行切分,這裡我們假設資料總長度為10000,batch_size為100, seq_length為10. # 所以num_batches=10,所以,xdata在reshape之後變成[100, 100],然後在第二個維度上切成10份, # 所以最終得到[100, 10, 10]的資料 self.x_batches = np.split(xdata.reshape(self.batch_size, -1), self.num_batches, 1) self.y_batches = np.split(ydata.reshape(self.batch_size, -1), self.num_batches, 1) def next_batch(self): x, y = self.x_batches[self.pointer], self.y_batches[self.pointer] self.pointer += 1 return x, y def reset_batch_pointer(self): self.pointer = 0

模型構建

這一部分我們將使用tf構建RNN模型,看程式碼之前先來關注tf中幾個比較重要的函式和其引數:
1,tf.contrib.rnn.BasicRNNCell/GRUCell/BasicLSTMCell/NASCell
這裡以BasicLSTMCell為例,

__init__(
    num_units,
    forget_bias=1.0,
    input_size=None,
    state_is_tuple=True,
    activation=tf.tanh,
    reuse=None
)

num_units:cell中的神經元個數,注意再很多教程中將RNNcell表示為一個小圓圈,但其實其中有很多個神經元。
forget_bias:忘記門的偏置大小
state_is_tuple:若為真則接受和返回的狀態儲存在長度為2的tuple中。c_state和m_state。
reuse:該單元的引數是否重複使用。如果不是真,而且不是第一次使用,則會報錯。

zero_state(
    batch_size,
    dtype
)

將初始狀態設為0,需要傳入的引數是batch_size。並且根據cell的state_size返回不同結果。如果state_size為正數,則返回[batch_size x state_size]的全零初始狀態;如果state_size為列表,則返回一個列表[batch_size x s] for each s in state_size.

2,tf.contrib.rnn.DropoutWrapper函式
為我們上面選擇的RNNCell新增dropout屬性,抑制過擬合。

__init__(
    cell,
    input_keep_prob=1.0,
    output_keep_prob=1.0,
    state_keep_prob=1.0,
    variational_recurrent=False,
    input_size=None,
    dtype=None,
    seed=None
)

從建構函式可以看出,對每個cell而言,我們都有input、output、state三個層面的dropout。
cell: an RNNCell, 可以使我們上面選擇的某一種RNNCell.
input_keep_prob: 0-1,輸入的dropout機率
output_keep_prob: 0-1,輸出的dropout機率
state_keep_prob: 0-1,state的dropout機率,在output的基礎上進行dropout
variational_recurrent: 若為真,則說明所有時間步上應用相同的dropout,並且需要設定input_size引數。

3,tf.contrib.rnn.MultiRNNCell函式

__init__(
    cells,
    state_is_tuple=True
)

 1. cells: cell list,包含n層rnn的cell.
 2. state_is_tuple: 若為真則接受和返回的狀態是n(層數)元tuple. 

zero_state(
    batch_size,
    dtype
)

4,tf.contrib.legacy_seq2seq.rnn_decoder函式

rnn_decoder(
    decoder_inputs,
    initial_state,
    cell,
    loop_function=None,
    scope=None
)

該函式實現了一個簡單的多層rnn模型。上面的MultiRNNCell函式構造了一個時間步的多層rnn,本函式則實現將其迴圈num_steps個時間步。

  1. decoder_inputs:輸入列表,是一個長度為num_steps的列表,每個元素是[batch_size, input_size]的2-D維的tensor
  2. initial_state:初始化狀態,2-D的tensor,shape為 [batch_size x cell.state_size].
  3. cell:RNNCell
  4. loop_function:如果不為空,則將該函式應用於第i個輸出以得到第i+1個輸入,此時decoder_inputs變數除了第一個元素之外其他元素會被忽略。其形式定義為:loop(prev, i)=next。prev是[batch_size x output_size],i是表明第i步,next是[batch_size x input_size]。

這裡我們可以看一下該函式的原始碼加深理解:

  with variable_scope.variable_scope(scope or "rnn_decoder"):
    state = initial_state
    outputs = []
    prev = None
    #遍歷n個時間步
    for i, inp in enumerate(decoder_inputs):
      #下面這兩個if語句只有在第2個時間步之後才會被執行
      if loop_function is not None and prev is not None:
        with variable_scope.variable_scope("loop_function", reuse=True):
          inp = loop_function(prev, i)
      if i > 0:
        variable_scope.get_variable_scope().reuse_variables()
      #重點是迴圈執行這個
      output, state = cell(inp, state)
      outputs.append(output)
      if loop_function is not None:
        prev = output
  return outputs, state

看了上面幾個重要函式的介紹,那麼對下面程式碼就不難理解了。在tf中,構造RNN模型的一般思路就是RNNcell–>dropout–>MultiRNNCell–>重複time_steps步(這裡使用legacy_seq2seq.rnn_decoder函式實現,我們也可以自己定義自己的模型)。接下來我們看一下model.py檔案中關於模型構建部分的程式碼。

class Model():
    def __init__(self, args, training=True):
        self.args = args
        if not training:
            args.batch_size = 1
            args.seq_length = 1
        #幾種可選的rnn cell
        if args.model == 'rnn':
            cell_fn = rnn.BasicRNNCell
        elif args.model == 'gru':
            cell_fn = rnn.GRUCell
        elif args.model == 'lstm':
            cell_fn = rnn.BasicLSTMCell
        elif args.model == 'nas':
            cell_fn = rnn.NASCell
        else:
            raise Exception("model type not supported: {}".format(args.model))

        cells = []
        #因為是多層RNN,所以在recoll時我們要輸入的是一個多層的cell,
        # 根據是否處於訓練過程和需要dropout新增dropout層
        for _ in range(args.num_layers):
            cell = cell_fn(args.rnn_size)
            if training and (args.output_keep_prob < 1.0 or args.input_keep_prob < 1.0):
                cell = rnn.DropoutWrapper(cell,
                                          input_keep_prob=args.input_keep_prob,
                                          output_keep_prob=args.output_keep_prob)
            cells.append(cell)
        #MultiRNNCell接受我們之前定義的多層RNNcell列表。
        # state_is_tuple預設為True,表示輸入和輸出都用tuple儲存,將來會丟棄False的選項。
        self.cell = cell = rnn.MultiRNNCell(cells, state_is_tuple=True)

        self.input_data = tf.placeholder(
            tf.int32, [args.batch_size, args.seq_length])
        self.targets = tf.placeholder(
            tf.int32, [args.batch_size, args.seq_length])
        #定義初始化狀態,可以直接呼叫cell.zero_state函式,引數為batch_size
        self.initial_state = cell.zero_state(args.batch_size, tf.float32)

        with tf.variable_scope('rnnlm'):
            softmax_w = tf.get_variable("softmax_w",
                                        [args.rnn_size, args.vocab_size])
            softmax_b = tf.get_variable("softmax_b", [args.vocab_size])
        #將輸入索引轉化為索引
        embedding = tf.get_variable("embedding", [args.vocab_size, args.rnn_size])
        inputs = tf.nn.embedding_lookup(embedding, self.input_data)

        # dropout beta testing: double check which one should affect next line
        if training and args.output_keep_prob:
            inputs = tf.nn.dropout(inputs, args.output_keep_prob)
        #將輸入切分
        inputs = tf.split(inputs, args.seq_length, 1)
        inputs = [tf.squeeze(input_, [1]) for input_ in inputs]

        def loop(prev, _):
            prev = tf.matmul(prev, softmax_w) + softmax_b
            prev_symbol = tf.stop_gradient(tf.argmax(prev, 1))
            return tf.nn.embedding_lookup(embedding, prev_symbol)
        #直接呼叫rnn_decoder函式構建RNN模型
        outputs, last_state = legacy_seq2seq.rnn_decoder(inputs, self.initial_state, cell, loop_function=loop if not training else None, scope='rnnlm')
        output = tf.reshape(tf.concat(outputs, 1), [-1, args.rnn_size])

        #下面就是loss和梯度計算,優化器定義部分
        self.logits = tf.matmul(output, softmax_w) + softmax_b
        self.probs = tf.nn.softmax(self.logits)
        loss = legacy_seq2seq.sequence_loss_by_example(
                [self.logits],
                [tf.reshape(self.targets, [-1])],
                [tf.ones([args.batch_size * args.seq_length])])
        self.cost = tf.reduce_sum(loss) / args.batch_size / args.seq_length
        with tf.name_scope('cost'):
            self.cost = tf.reduce_sum(loss) / args.batch_size / args.seq_length
        self.final_state = last_state
        self.lr = tf.Variable(0.0, trainable=False)
        tvars = tf.trainable_variables()
        #RNN中常用的梯度截斷,防止出現梯度過大難以求導的現象
        grads, _ = tf.clip_by_global_norm(tf.gradients(self.cost, tvars),
                args.grad_clip)
        with tf.name_scope('optimizer'):
            optimizer = tf.train.AdamOptimizer(self.lr)
        self.train_op = optimizer.apply_gradients(zip(grads, tvars))

        # instrument tensorboard
        tf.summary.histogram('logits', self.logits)
        tf.summary.histogram('loss', loss)
        tf.summary.scalar('train_loss', self.cost)

模型訓練

模型定義完成之後,我們要進行的就是讀入資料,構建模型並開始訓練等一系列操作。這裡並沒有設麼新鮮的程式碼,我們直接看程式就可以了:

def train(args):
    #讀入資料
    data_loader = TextLoader(args.data_dir, args.batch_size, args.seq_length)
    args.vocab_size = data_loader.vocab_size

    # check compatibility if training is continued from previously saved model
    if args.init_from is not None:
        # 繼續從之前的模型接著訓練(可以先不看)
        assert os.path.isdir(args.init_from)," %s must be a a path" % args.init_from
        assert os.path.isfile(os.path.join(args.init_from,"config.pkl")),"config.pkl file does not exist in path %s"%args.init_from
        assert os.path.isfile(os.path.join(args.init_from,"chars_vocab.pkl")),"chars_vocab.pkl.pkl file does not exist in path %s" % args.init_from
        ckpt = tf.train.get_checkpoint_state(args.init_from)
        assert ckpt, "No checkpoint found"
        assert ckpt.model_checkpoint_path, "No model path found in checkpoint"

        # open old config and check if models are compatible
        with open(os.path.join(args.init_from, 'config.pkl'), 'rb') as f:
            saved_model_args = cPickle.load(f)
        need_be_same = ["model", "rnn_size", "num_layers", "seq_length"]
        for checkme in need_be_same:
            assert vars(saved_model_args)[checkme]==vars(args)[checkme],"Command line argument and saved model disagree on '%s' "%checkme

        # open saved vocab/dict and check if vocabs/dicts are compatible
        with open(os.path.join(args.init_from, 'chars_vocab.pkl'), 'rb') as f:
            saved_chars, saved_vocab = cPickle.load(f)
        assert saved_chars==data_loader.chars, "Data and loaded model disagree on character set!"
        assert saved_vocab==data_loader.vocab, "Data and loaded model disagree on dictionary mappings!"

    if not os.path.isdir(args.save_dir):
        os.makedirs(args.save_dir)
    with open(os.path.join(args.save_dir, 'config.pkl'), 'wb') as f:
        cPickle.dump(args, f)
    with open(os.path.join(args.save_dir, 'chars_vocab.pkl'), 'wb') as f:
        cPickle.dump((data_loader.chars, data_loader.vocab), f)

    #構建模型
    model = Model(args)

    with tf.Session() as sess:
        # 寫入Summary
        summaries = tf.summary.merge_all()
        writer = tf.summary.FileWriter(
                os.path.join(args.log_dir, time.strftime("%Y-%m-%d-%H-%M-%S")))
        writer.add_graph(sess.graph)
        #引數初始化
        sess.run(tf.global_variables_initializer())
        saver = tf.train.Saver(tf.global_variables())
        # restore model
        if args.init_from is not None:
            saver.restore(sess, ckpt.model_checkpoint_path)

        #開始迴圈送入資料並訓練
        for e in range(args.num_epochs):
            sess.run(tf.assign(model.lr,
                               args.learning_rate * (args.decay_rate ** e)))
            data_loader.reset_batch_pointer()
            state = sess.run(model.initial_state)
            for b in range(data_loader.num_batches):
                start = time.time()
                x, y = data_loader.next_batch()
                feed = {model.input_data: x, model.targets: y}
                for i, (c, h) in enumerate(model.initial_state):
                    feed[c] = state[i].c
                    feed[h] = state[i].h
                train_loss, state, _ = sess.run([model.cost, model.final_state, model.train_op], feed)

                # instrument for tensorboard
                summ, train_loss, state, _ = sess.run([summaries, model.cost, model.final_state, model.train_op], feed)
                writer.add_summary(summ, e * data_loader.num_batches + b)

                end = time.time()
                print("{}/{} (epoch {}), train_loss = {:.3f}, time/batch = {:.3f}"
                      .format(e * data_loader.num_batches + b,
                              args.num_epochs * data_loader.num_batches,
                              e, train_loss, end - start))
                if (e * data_loader.num_batches + b) % args.save_every == 0\
                        or (e == args.num_epochs-1 and
                            b == data_loader.num_batches-1):
                    # save for the last result
                    checkpoint_path = os.path.join(args.save_dir, 'model.ckpt')
                    saver.save(sess, checkpoint_path,
                               global_step=e * data_loader.num_batches + b)
                    print("model saved to {}".format(checkpoint_path))

結果分析

程式碼是可以直接執行的,在cpu上平均1s可以執行3次。最終我們獲得的結果也十分平滑。這裡只記錄了loss,所以我們也暫時只展示最終的架構圖和loss曲線。如下:
這裡寫圖片描述
這裡寫圖片描述
這裡寫圖片描述
這裡寫圖片描述