用深度學習網路搭建一個聊天機器人(下篇)

Naturali奇點機智發表於2018-12-26

誰/為什麼關注基於檢索模型的機器人?

在本系列的上一篇部落格中說到,基於檢索模型的機器人有一個的回答集(repository),包含了預先定義的若干回答。與該模型相對應的產生式模型則是在不借助任何回答集的情況下產生一個全新的回答。

讓我們更正式地定義基於檢索模型的機器人:模型的輸入為上下文(context)$c$和回答(response)$r$。模型會根據上下文為回答評分,評分最高的回答將被選擇作為模型的輸出。

你一定想問,在能夠搭建一個產生式模型的情況下,為什麼我們更想搭建一個基於檢索的模型?誠然,產生式模型看起來更靈活,並且不需要預先定義的回答集。原因很簡單:當下的產生式模型在實踐中表現不佳。因為他們太靈活了,以至於他們非常容易犯語法錯誤、產生和問題不相關的回答、產生萬金油式的回答或是與前文不一致的回答(我們在(上篇)中對這些問題有過簡略討論)。此外,產生式模型需要大量的訓練資料。現今,工業界主流的系統大多仍是基於檢索模型的,或是兩種模型的結合。產生式模型是一個活躍的研究領域,但是我們才剛剛起步。如果現在的你想搭建一個聊天機器人,基於檢索的模型應該能讓你更有成就感 :)

UBUNTU對話語料庫

在這篇部落格中,我們將使用UBUNTU對話語料庫Ubuntu Dialog Corpus)(papercode)。UBUNTU對話語料庫UDC)是基於Ubuntu頻道的對話日誌,是最大的公開的對話資料集之一。

這篇文章已經深入介紹了這個資料集是如何建立的,因此本文不再加以贅述。不過我們可以大致瞭解一下資料集的結構,方便我們在模型中使用。

訓練資料集包括了100萬個樣本,正負樣本各佔一半。每個樣本由上下文(context)和回答(utterance)構成。上下文指的是從對話開始,截止到當前的內容,回答指的是對這段內容的迴應。換而言之,上下文可以是若干句對話,而回答則是對這若干句對話的迴應。正樣本指的是該樣本的上下文和回答是匹配的,對應地,負樣本指的是二者是不匹配的——回答是從語料庫的某個地方隨機抽取的。下圖是訓練資料集的部分展示:

用深度學習網路搭建一個聊天機器人(下篇)

你會發現,這些樣本看起來有點奇怪。事實上,是因為產生資料集的指令碼使用NLTK為我們做了一系列的資料預處理工作——分詞tokenized)、英文單詞取詞根stemmed)、英文單詞變形的歸類lemmatized)(例如單複數歸類)等。此外,例如人名、地名、組織名、URL連結、系統路徑等專有名詞,NLTK也做了替代。這些預處理工作也不是非做不可,不過它們似乎讓結果變好了:) 經過統計,上下文的平均長度大概是450個字元,回答的平均長度大概是80個字元。

產生資料集的指令碼也能夠生成測試資料集(見下圖)。在測試資料集中,每一條記錄(record)包括:1個上下文;1個真實回答;9個錯誤回答。本模型的目標就是讓真實回答的評分最高,錯誤回答的評分低(這樣模型就能選出正確回答了!)

用深度學習網路搭建一個聊天機器人(下篇)

介紹完資料集,大致說一下評測模型好壞的方法。有許多評測方法可以使用,而最常用的方法稱為$Recall@K$ 。什麼意思呢?模型會按照評分的從高到低,挑選K個回答。如果正確的回答在這K個當中,我們就認為這條測試樣本預測正確。顯然,K越大,事情越簡單。對於剛才介紹的測試集,如果令$K=10$ ,則分類準確率為100%,因為所有的回答都被選進來了,正確的回答一定在其中!對應的,如果令$K=1$ ,則模型只有一次選擇的機會,這對模型的精準程度要求很高。

在這裡,我想提一下該資料集的特殊性以及和真實資料的區別。對於該資料集,機器人模型每次都對不同的回答打分,在訓練階段,有些回答機器人可能只見過一次。這意味著機器人的泛化能力要好,才能在面對測試集中許多從未見過的回答時表現良好。然而,在許多的現實系統中,機器人只需要處理數量有限的回答,即訓練集中,每一種可能的回答,都會有若干條樣本與此對應。因此,機器人不會被要求給從沒見過的回答打分。這樣事情就簡單多了!因此現實中的基於檢索模型的機器人,應該會比本模型的效果要好。

一些簡單的基準線(baseline)

在介紹更復雜的深度學習的模型之前,先讓我們再次明確一下我們的任務,並且搭建幾個簡單的baseline模型。這有助於瞭解我們能夠對我們的模型抱有多大的期待 :)

我們將使用下面的函式來評估我們的$recall@k$指標:

# Evaluation
def evaluate_recall(y, y_test, k=1):
    num_examples = float(len(y))
    num_correct = 0
    for predictions, label in zip(y, y_test):
        if label in predictions[:k]:
            num_correct += 1
    return num_correct/num_examples

在這裡,$y$是排序過後的預測值的list,$y$_$test$是真實的標籤(label)。舉例來說,有一個$y$是這樣的:$[0,3,1,2,5,6,4,7,8,9]$,代表編號為0的回答得到了最高分,編號為9的回答最低分。因為我們的測試集的樣本有10個回答,因此編號為0~9。如果$y$_$test=3$,即正確的回答為編號為3的回答,並且評測標準為$recall@1$,那麼這條測試樣本將被標註為錯誤;反之,如果是$recall@2$,那麼這條樣本則為正確。

直觀來說,一個完全隨機的預測模型,在$recall@1$時,正確率應該為10%,在$recall@2$時,正確率應為20%,以此類推。讓我們寫一個小程式驗證一下:

# Random Predictor
def predict_random(context, utterances):
    return np.random.choice(len(utterances), 10, replace=False)
# Evaluate Random predictor
y_random = [predict_random(test_df.Context[x], test_df.iloc[x,1:].values) for x in range(len(test_df))]
for n in [1, 2, 5, 10]:
    print("Recall @ ({}, 10): {:g}".format(n, evaluate_recall(y_random, y_test, n)))
Recall @ (1, 10): 0.0937632
Recall @ (2, 10): 0.194503
Recall @ (5, 10): 0.49297
Recall @ (10, 10): 1

非常好!結果和我們預想的一樣。當然了,我們並不滿足於一個隨機的預測模型。在那篇文章(見上)中,還討論了另一個baseline,叫做tf-idf預測模型。tf-idf指的是詞頻-逆向檔案頻率(term frequency – inverse document frequency),衡量的是一個單詞在一個語料庫中的重要程度。更多關於tf-idf的細節我們將不再贅述(網上有許多相關資料),一言蔽之,相似內容的文件具有相似的tf-idf向量。直觀來說,如果一個上下文和回答具有相似的詞彙,那麼它們更有可能是一對匹配的組合。至少這種估計會比隨機的要靠譜。

當下,許多庫(例如scikit-learn)都有tf-idf的內建函式,因此,使用起來並不困難。讓我們搭建一個tf-idf的預測模型,看看它表現如何:

class TFIDFPredictor:
    def __init__(self):
        self.vectorizer = TfidfVectorizer()
 
    def train(self, data):
        self.vectorizer.fit(np.append(data.Context.values,data.Utterance.values))
 
    def predict(self, context, utterances):
        # Convert context and utterances into tfidf vector
        vector_context = self.vectorizer.transform([context])
        vector_doc = self.vectorizer.transform(utterances)
        # The dot product measures the similarity of the resulting vectors
        result = np.dot(vector_doc, vector_context.T).todense()
        result = np.asarray(result).flatten()
        # Sort by top results and return the indices in descending order
        return np.argsort(result, axis=0)[::-1]
# Evaluate TFIDF predictor
pred = TFIDFPredictor()
pred.train(train_df)
y = [pred.predict(test_df.Context[x], test_df.iloc[x,1:].values) for x in range(len(test_df))]
for n in [1, 2, 5, 10]:
    print("Recall @ ({}, 10): {:g}".format(n, evaluate_recall(y, y_test, n)))
Recall @ (1, 10): 0.495032
Recall @ (2, 10): 0.596882
Recall @ (5, 10): 0.766121
Recall @ (10, 10): 1

可以看到,tf-idf模型比隨機模型表現好多了,但是這遠遠不夠。事實上,我們剛才的假設是有問題的:第一,合適的回答沒必要和上下文詞彙相似;第二,tf-idf忽略了詞彙的順序,而這點很關鍵。使用基於神經網路的模型,我們應該會得到更好的結果。

對偶編碼LSTM模型(DUAL ENCODER LSTM

在本小節中,我們將搭建一個對偶編碼的LSTM深度學習模型,也叫作連體網路(Siamese network)。這種型別的網路只是解決此類問題的選擇之一,也許不是最好的。大家當然可以發揮想象力,搭建各種各樣的深度學習框架來嘗試——這也是目前的研究熱點。那麼,為什麼我們選擇了對偶編碼的模型呢?因為根據這個實驗的結果,該模型表現良好。並且,由於我們已經有可以參照的基準程式(benchmark)了,我們就可以對重現該模型有一個合理的預估。當然,使用別的模型(例如attention-based的RNN模型)也會是個有趣的研究點。 我們搭建的對偶編碼RNN模型結構如下(paper):

用深度學習網路搭建一個聊天機器人(下篇)

它的工作原理大致如下:

  1. 上下文(context)和回答(response)按照單詞分割,然後每個單詞用詞向量代替。我們使用的詞向量是Stanford的GloVe,它們在網路訓練過程中也會被調優(圖中沒有展示詞向量代替)

  2. 上下文和回答會以單詞為粒度,送入RNN中(圖中$c_i$和$r_i$可以認為是一個單詞的詞向量)。接著RNN會產生一個向量,可以認為其大致代表上下文和回答的“含義”(圖中的$c$和$r$)我們可以指定這個向量的維度,假設現在我們指定為256維。

  3. 我們將$c$和一個矩陣$M$相乘,“預測”一個回答$r’$。如果$c$是一個256維的向量,那麼$M$設定為256*256維的矩陣,這樣乘出來的結果$r’$就是另一個256維的向量。可以認為$r’$是上下文$c$經過網路後,產生的回答。矩陣$M$在訓練過程中會被學習。

  4. 我們衡量產生的回答$r’$和真實回答$r$之間的相似度。使用的方法為對二者進行點積(dot product)操作。點積結果越大,說明二者越相似,那麼當前的回答$r$就會獲得越高分。然後,我們會使用sigmoid函式,將點積結果轉化為概率值。圖中右側的$σ(c^{T}Mr)$結合了步驟3和步驟4。

為了訓練這個網路,我們需要定義一個損失函式loss function)。我們將使用在分類問題中常用的二元交叉熵binary cross-entropy)。我們用$y$代表“上下文-回答”的pair的真實標註(true label),$y$要麼是1(真實回答),要麼是0(錯誤回答);用$y’$代表預測出來的概率值,$y\in[0,1]$。那麼,交叉熵損失值的計算方式如下:$L = −y * ln(y’) − (1−y) * ln(1−y’)$。這個公式的直觀理解非常簡單。如果$y=1$,則$L = -ln(y’)$,那麼,如果$y’$離1很遠,L的值就會很大,作為懲罰。如果$y=0$,則$L = -ln(1-y’)$,此時則是懲罰$y’$離0很遠的情況。

我們的模型實現使用了numpypandasTensorflowTF Learn(也是Tensorflow的一部分,提供很多便於使用的函式)

在模型搭建之前,我們需要定義一些引數hyper-parameters):

# The maximum number of words to consider for the contexts
MAX_CONTEXT_LENGTH = 80
 
# The maximum number of words to consider for the utterances
MAX_UTTERANCE_LENGTH = 30
 
# Word embedding dimensionality
EMBEDDING_SIZE = 300
 
# LSTM Cell dimensionality
LSTM_CELL_SIZE = 256

限制上下文和回答句子的長度是為了使得模型訓練得更快。根據之前介紹的對資料集的統計,80個單詞大概能夠擷取到上下文的大部分內容,相應地,回答使用40個單詞大概足夠。我們讓詞向量的維數為300,因為預先訓練(pre-trained)好的無論是word2vec還是GloVe都是300維的,這樣設定方便我們直接使用它們。

接著,我們利用TF Learn的庫函式,對資料做預處理。包括構建單詞-索引表(vocab_processor)、將資料集從單詞轉換為索引(index);此外,我們還載入GloVe的詞向量,並初始化單詞索引-詞向量表(initial_embeddings):將資料集中,在GloVe存在的單詞替換為GloVe詞向量,不存在的單詞則初始化為$(-0.25,0.25)$之間的均勻分佈。

# Preprocessing
# ==================================================
# Create vocabulary mapping
all_sentences = np.append(train_df.Context, train_df.Utterance)
vocab_processor = skflow.preprocessing.VocabularyProcessor(MAX_CONTEXT_LENGTH, min_frequency=5)
vocab_processor.fit(all_sentences)

# Transform contexts and utterances
X_train_context = np.array(list(vocab_processor.transform(train_df.Context)))
X_train_utterance = np.array(list(vocab_processor.transform(train_df.Utterance)))

# Generate training tensor
X_train = np.stack([X_train_context, X_train_utterance], axis=1)
y_train = train_df.Label

n_words = len(vocab_processor.vocabulary_)
print("Total words: {}".format(n_words))


# Load glove vectors
# ==================================================
vocab_set = set(vocab_processor.vocabulary_._mapping.keys())
glove_vectors, glove_dict = load_glove_vectors(os.path.join(FLAGS.data_dir, "glove.840B.300d.txt"), vocab_set)


# Build initial word embeddings
# ==================================================
initial_embeddings = np.random.uniform(-0.25, 0.25, (n_words, EMBEDDING_DIM)).astype("float32")
for word, glove_word_idx in glove_dict.items():
    word_idx = vocab_processor.vocabulary_.get(word)
    initial_embeddings[word_idx, :] = glove_vectors[glove_word_idx]

在搭建模型之前,我們需要先引入一個擴充套件內容。由於在實際測試時,對資料集進行“多擷取,少補零”的規整化方式,可能會讓我們損失一些精度。設想,如果一句話只有5個單詞,而被補全到了80個單詞,或是一句話有150個單詞,卻被擷取到了80個單詞,無論如何,都是不夠好的。但是,在上文也提過,對資料進行切取,是為了加速訓練過程。因此,我們需要一個trade-off。對於擷取,我們仍然維持原狀;對於補全,我們在送入RNN網路之前,會先計算出資料的原始長度(即最後一個非零index)。值得注意的是,之所以可以這麼做,是因為tensorflow的RNN模組是支援變長輸入資料的訓練的。獲取不大於最大長度的資料的實際長度的函式定義如下:

def get_sequence_length(input_tensor, max_length):
    """
    If a sentence is padded, returns the index of the first 0 (the padding symbol).
    If the sentence has no padding, returns the max length.
    """
    zero_tensor = np.zeros_like(input_tensor)
    comparsion = tf.equal(input_tensor, zero_tensor)
    zero_positions = tf.argmax(tf.to_int32(comparsion), 1)
    position_mask = tf.to_int64(tf.equal(zero_positions, 0))
    sequence_lengths = zero_positions + (position_mask * max_length)
    return sequence_lengths

接下來,我們可以開始搭建模型了!以下的操作都是以batch為單位進行的。基本步驟如下:

  1. 呼叫get_sequence_length函式,分別獲取上下文和回答的實際長度;

  2. 使用先前構建的單詞索引-詞向量表,將上下文和回答替換為詞向量;

  3. 將上下文和回答分別送入同一個RNN網路中訓練,取RNN網路的最後一個state作為上下文和回答的encoding;

  4. 預測、計算概率值和loss。

這些步驟和之前的圖解是一一對應的,程式碼如下:

def rnn_encoder_model(X, y):
    # Split input tensor into separare context and utterance tensor
    context, utterance = tf.split(1, 2, X, name='split')
    context = tf.squeeze(context, [1])
    utterance = tf.squeeze(utterance, [1])
    utterance_truncated = tf.slice(utterance, [0, 0], [-1, MAX_UTTERANCE_LENGTH])

    # Calculate the sequence length for RNN calculation
    context_seq_length = get_sequence_length(context, MAX_CONTEXT_LENGTH)
    utterance_seq_length = get_sequence_length(utterance, MAX_UTTERANCE_LENGTH)

    # Embed context and utterance into the same space
    with tf.variable_scope("shared_embeddings") as vs, tf.device('/cpu:0'):
        embedding_tensor = tf.convert_to_tensor(initial_embeddings)
        embeddings = tf.get_variable("word_embeddings", initializer=embedding_tensor)
        # Embed the context
        word_vectors_context = skflow.ops.embedding_lookup(embeddings, context)
        word_list_context = skflow.ops.split_squeeze(1, MAX_CONTEXT_LENGTH, word_vectors_context)
        # Embed the utterance
        word_vectors_utterance = skflow.ops.embedding_lookup(embeddings, utterance_truncated)
        word_list_utterance = skflow.ops.split_squeeze(1, MAX_UTTERANCE_LENGTH, word_vectors_utterance)

    # Run context and utterance through the same RNN
    with tf.variable_scope("shared_rnn_params") as vs:

        #lsy modified the forget_bias = 2.0
        cell = tf.nn.rnn_cell.LSTMCell(RNN_DIM, forget_bias=2.0)
        cell = tf.nn.rnn_cell.DropoutWrapper(cell,output_keep_prob=0.5)
        context_outputs, context_state = tf.nn.rnn(
            cell, word_list_context, dtype=dtypes.float32, sequence_length=context_seq_length)
        encoding_context = tf.slice(context_state, [0, cell.output_size], [-1, -1])
        vs.reuse_variables()
        utterance_outputs, utterance_state = tf.nn.rnn(
            cell, word_list_utterance, dtype=dtypes.float32, sequence_length=utterance_seq_length)
        encoding_utterance = tf.slice(utterance_state, [0, cell.output_size], [-1, -1])

    with tf.variable_scope("prediction") as vs:
        W = tf.get_variable("W",
                            shape=[encoding_context.get_shape()[1], encoding_utterance.get_shape()[1]],
                            initializer=tf.random_normal_initializer())
        b = tf.get_variable("b", [1])

        # We can interpret this is a "Generated context"
        generated_context = tf.matmul(encoding_utterance, W)
        # Batch multiply contexts and utterances (batch_matmul only works with 3-d tensors)
        generated_context = tf.expand_dims(generated_context, 2)
        encoding_context = tf.expand_dims(encoding_context, 2)
        scores = tf.batch_matmul(generated_context, encoding_context, True) + b
        # Go from [15,1,1] to [15,1]: We want a vector of 15 scores
        scores = tf.squeeze(scores, [2])
        # Convert scores into probabilities
        probs = tf.sigmoid(scores)

        # Calculate loss
        loss = tf.contrib.losses.logistic(scores, tf.expand_dims(y, 1))
        tf.scalar_summary("mean_loss", tf.reduce_mean(loss))

    return [probs, loss]

在定義好模型函式之後,我們呼叫TF Learn的方法,將模型函式包裝起來,還可以設定一些諸如優化方法(optimazer)、學習速率(learning rate)、學習速率衰減函式(learning rate decay function)等引數。然後,令分類器啟動,就可以開始訓練了!

def evaluate_rnn_predictor(df):
    y_test = np.zeros(len(df))
    y = predict_rnn_batch(df.Context, df.iloc[:, 1:].values)
    for n in [1, 2, 5, 10]:
        print("Recall @ ({}, 10): {:g}".format(n, evaluate_recall(y, y_test, n)))


class ValidationMonitor(tf.contrib.learn.monitors.BaseMonitor):
    def __init__(self, print_steps=100, early_stopping_rounds=None, verbose=1, val_steps=1000):
        super(ValidationMonitor, self).__init__(
            print_steps=print_steps,
            early_stopping_rounds=early_stopping_rounds,
            verbose=verbose)
        self.val_steps = val_steps

    def _modify_summary_string(self):
        if self.steps % self.val_steps == 0:
            evaluate_rnn_predictor(validation_df)


def learning_rate_decay_func(global_step):
    return tf.train.exponential_decay(
        FLAGS.learning_rate,
        global_step,
        decay_steps=FLAGS.learning_rate_decay_every,
        decay_rate=FLAGS.learning_rate_decay_rate,
        staircase=True)

classifier = tf.contrib.learn.TensorFlowEstimator(
    model_fn=rnn_encoder_model,
    n_classes=1,
    continue_training=True,
    steps=FLAGS.num_steps,
    learning_rate=learning_rate_decay_func,
    optimizer=FLAGS.optimizer,
    batch_size=FLAGS.batch_size)

monitor = ValidationMonitor(print_steps=100, val_steps=1000)
classifier.fit(X_train, y_train, logdir='./tmp/tf/dual_lstm_chatbot/', monitor=monitor)

關於對測試集的檢驗函式,只需要呼叫我們定義好的classifier的predict_proba函式,就能便捷地做預測了。當然,在此之前,還需要將資料從單詞轉換為索引。事實上,測試和訓練幾乎可以共享之前定義的model,除此之外的一些區別,classifier會幫我們處理。

def predict_rnn_batch(contexts, utterances, n=1):
    num_contexts = len(contexts)
    num_records = np.multiply(*utterances.shape)
    input_vectors = []
    for context, utterance_list in zip(contexts, utterances):
        cvec = np.array(list(vocab_processor.transform([context])))
        for u in utterance_list:
            uvec = np.array(list(vocab_processor.transform([u])))
            stacked = np.stack([cvec, uvec], axis=1)
            input_vectors.append(stacked)
    batch = np.vstack(input_vectors)
    result = classifier.predict_proba(batch)[:, 0]
    result = np.split(result, num_contexts)
    return np.argsort(result, axis=1)[:, ::-1]

程式碼框架大致介紹到這,如果你對這個基於檢索的對話系統模型感興趣,希望自己試驗一下,可以訪問原作者的github得到更多資料。

在下期的部落格中,我們將會推出基於產生式模型的聊天機器人介紹,敬請期待!

相關文章