記憶網路之End-To-End Memory Networks

記憶網路之End-To-End Memory Networks

這是Facebook AI在Memory networks之後提出的一個更加完善的模型,前文中我們已經說到,其I和G模塊並未進行複雜操作,只是將原始文本進行向量化並保存,沒有對輸入文本進行適當的修改就直接保存為memory。而O和R模塊承擔了主要的任務,但是從最終的目標函數可以看出,在O和R部分都需要監督,也就是我們需要知道O選擇的相關記憶是否正確,R生成的答案是否正確。這就限制了模型的推廣,太多的地方需要監督,不太容易使用反向傳播進行訓練。因此,本文提出了一種end-to-end的模型,可以視為一種continuous form的Memory Network,而且需要更少的監督數據便可以進行訓練。論文中提出了單層和多層兩種架構,多層其實就是將單層網路進行stack。我們先來看一下單層模型的架構:

單層 Memory Networks

單層網路的結構如下圖所示,主要包括下面幾個模塊:

模型主要的參數包括A,B,C,W四個矩陣,其中A,B,C三個矩陣就是embedding矩陣,主要是將輸入文本和Question編碼成詞向量,W是最終的輸出矩陣。從上圖可以看出,對於輸入的句子s分別會使用A和C進行編碼得到Input和Output的記憶模塊,Input用來跟Question編碼得到的向量相乘得到每句話跟q的相關性,Output則與該相關性進行加權求和得到輸出向量。然後再加上q並傳入最終的輸出層。接下來詳細介紹一下各個模塊的原理和實現(這裡跟論文中的敘述方式不同,按照自己的理解進行介紹)。

輸入模塊

首先是輸入模塊(對應於Memory Networks那篇論文的I和G兩個組件),這部分的主要作用是將輸入的文本轉化成向量並保存在memory中,本文中的方法是將每句話壓縮成一個向量對應到memory中的一個slot(上圖中的藍色或者黃色豎條)。其實就是根據一句話中各單詞的詞向量得到句向量。論文中提出了兩種編碼方式,BoW和位置編碼。BoW就是直接將一個句子中所有單詞的詞向量求和表示成一個向量的形式,這種方法的缺點就是將丟失一句話中的詞序關係,進而丟失語義信息;而位置編碼的方法,不同位置的單詞的權重是不一樣的,然後對各個單詞的詞向量按照不同位置權重進行加權求和得到句子表示。位置編碼公式如下:lj就是位置信息向量(這部分可以參考我們後面的代碼理解)。

此外,為了編碼時序信息,比如Sam is in the bedroom after he is in the kitchen。我們需要在上面得到mi的基礎上再加上個矩陣對應每句話出現的順序,不過這裡是按反序進行索引。將該時序信息編碼在Ta和Tc兩個矩陣裡面,所以最終每句話對應的記憶mi的表達式如下所示:

輸出模塊

上面的輸入模塊可以將輸入文本編碼為向量的形式並保存在memory中,這裡分為Input和Output兩個模塊,一個用於跟Question相互作用得到各個memory slot與問題的相關程度,另一個則使用該信息產生輸出。

首先看第一部分,將Question經過輸入模塊編碼成一個向量u,與mi維度相同,然後將其與每個mi點積得到兩個向量的相似度,在通過一個softmax函數進行歸一化:

pi就是q與mi的相關性指標。然後對Output中各個記憶ci按照pi進行加權求和即可得到模型的輸出向量o。

Response模塊

輸出模塊根據Question產生了各個memory slot的加權求和,也就是記憶中有關Question的相關知識,Response模塊主要是根據這些信息產生最終的答案。其結合o和q兩個向量的和與W相乘在經過一個softmax函數產生各個單詞是答案的概率,值最高的單詞就是答案。並且使用交叉熵損失函數最為目標函數進行訓練。

多層模型

其實就是將多個單層模型進行stack在一塊。這裡成為hop。其結構圖如下所示:

首先來講,上面幾層的輸入就是下層o和u的和。至於各層的參數選擇,論文中提出了兩種方法(主要是為了減少參數量,如果每層參數都不同的話會導致參數很多難以訓練)。

1. Adjacent:這種方法讓相鄰層之間的A=C。也就是說Ak+1=Ck,此外W等於頂層的C,B等於底層的A,這樣就減少了一半的參數量。

2. Layer-wise(RNN-like):與RNN相似,採用完全共享參數的方法,即各層之間參數均相等。Ak=...=A2=A1,Ck=...=C2=C1。由於這樣會大大的減少參數量導致模型效果變差,所以提出一種改進方法,即令uk+1=Huk+ok,也就是在每一層之間加一個線性映射矩陣H。

TensorFlow實現

為了更好的理解模型原理,其實最好的方法就是將其實現一遍。由於本模型15年發表,所以github上面已經有了很多實現方案,所以我們就參考其中兩個分別來介紹QA任務的bAbI和語言建模的ptb。

bAbI QA建模

這部分代碼參考github.com/domluna/memn,先簡單介紹一下數據集,bAbI是facebook提出的,裡面包含了20種問題類型,分別代表了不同的推理形式。如下所示:

在本試驗中,我們會將這些句子和Question作為模型輸入進行建模,希望模型可以學習出這種推理模式。下面我們主要看一下數據處理和模型構建部分的代碼。我們以task1為例,先來看一下數據格式:

可以看出基本上是兩句話後面跟一個問句,並且給出相應答案。答案後面的數字意味著該問題與哪一行相關(這個數據在Memory Networks中需要使用,但在本模型中弱化了監督的問題,所以不需要)。然後15行組成一個小故事,也就是說這15行內的數據都是相關的,後面的15個組成另外一組數據。所以memory_size也是10(15行中有10行是數據,5行是問題)。另外每個句子的組大長度是7。所以處理完之後的數據應該時15*7的矩陣。而且每15行數據會被處理成5組訓練樣本。第一組是前兩行數據加問題和答案,第二個是前四行數據家問題和答案,這樣繼續下去。也就是說後面的問題是依據前面所有的數據回答的。數據處理的代碼如下所示,主要關注parse_stories這個函數即可,實現了數據轉化的功能,參考代碼注釋理解。

def load_task(data_dir, task_id, only_supporting=False):n Load the nth task. There are 20 tasks in total.n Returns a tuple containing the training and testing data for the task.n n #讀取文件並返回處理之後的數據n assert task_id > 0 and task_id < 21nn files = os.listdir(data_dir)n files = [os.path.join(data_dir, f) for f in files]n s = qa{}_.format(task_id)n train_file = [f for f in files if s in f and train in f][0]n test_file = [f for f in files if s in f and test in f][0]n train_data = get_stories(train_file, only_supporting)n test_data = get_stories(test_file, only_supporting)n return train_data, test_datann def tokenize(sent):n Return the tokens of a sentence including punctuation.n >>> tokenize(Bob dropped the apple. Where is the apple?)n [Bob, dropped, the, apple, ., Where, is, the, apple, ?]n n return [x.strip() for x in re.split((W+)?, sent) if x.strip()]nnn def parse_stories(lines, only_supporting=False):n Parse stories provided in the bAbI tasks formatn If only_supporting is true, only the sentences that support the answer are kept.n n data = []n story = []n for line in lines:n line = str.lower(line)n nid, line = line.split( , 1)n nid = int(nid)n #nid是每一行的序號(1~15之間),如果等於1則說明是一個新故事的開始,需要將story數組重置。n if nid == 1:n story = []n if t in line: # 如果有t,則說明是問題行n q, a, supporting = line.split(t)n q = tokenize(q)n #a = tokenize(a)n # answer is one vocab word even if its actually multiple wordsn a = [a]n substory = Nonenn # remove question marksn if q[-1] == "?":n q = q[:-1]nn #判斷是否需要記錄相應信息行,即上面說到的數字信息。本文不需要n if only_supporting:n # Only select the related substoryn supporting = map(int, supporting.split())n substory = [story[i - 1] for i in supporting]n else:n # Provide all the substoriesn substory = [x for x in story if x]nn data.append((substory, q, a))n story.append()n else: # regular sentencen # remove periodsn sent = tokenize(line)n if sent[-1] == ".":n sent = sent[:-1]n story.append(sent)n return datannn def get_stories(f, only_supporting=False):n Given a file name, read the file, retrieve the stories, and then convert the sentences into a single story.n If max_length is supplied, any stories longer than max_length tokens will be discarded.n n with open(f) as f:n return parse_stories(f.readlines(), only_supporting=only_supporting)nn def vectorize_data(data, word_idx, sentence_size, memory_size):n """n Vectorize stories and queries.n If a sentence length < sentence_size, the sentence will be padded with 0s.n If a story length < memory_size, the story will be padded with empty memories.n Empty memories are 1-D arrays of length sentence_size filled with 0s.n The answer array is returned as a one-hot encoding.n """n #將單詞轉化為vocab中的索引方便進行embedding lookup。n S = []n Q = []n A = []n for story, query, answer in data:n ss = []n for i, sentence in enumerate(story, 1):n ls = max(0, sentence_size - len(sentence))n ss.append([word_idx[w] for w in sentence] + [0] * ls)nn # take only the most recent sentences that fit in memoryn ss = ss[::-1][:memory_size][::-1]nn # Make the last word of each sentence the time word which n # corresponds to vector of lookup tablen for i in range(len(ss)):n ss[i][-1] = len(word_idx) - memory_size - i + len(ss)nn # pad to memory_sizen lm = max(0, memory_size - len(ss))n for _ in range(lm):n ss.append([0] * sentence_size)nn lq = max(0, sentence_size - len(query))n q = [word_idx[w] for w in query] + [0] * lqnn y = np.zeros(len(word_idx) + 1) # 0 is reserved for nil wordn for a in answer:n y[word_idx[a]] = 1nn S.append(ss)n Q.append(q)n A.append(y)n return np.array(S), np.array(Q), np.array(A)n

處理完數據之後我們就得到了一組組訓練數據,接下來就是構建模型和訓練工作。先看一看模型構建部分的代碼,這部分主要關注一下position_encoding和模型推理部分,其他部分由於文章長度限制不在貼上來,感興趣的同學可以自行查看。下面看一下位置編碼PE的實現:

def position_encoding(sentence_size, embedding_size):n """n Position Encoding described in section 4.1 [1]n """n encoding = np.ones((embedding_size, sentence_size), dtype=np.float32)n ls = sentence_size+1n le = embedding_size+1n for i in range(1, le):n for j in range(1, ls):n #L矩陣每個元素的值。i,j分別減去行列值的一半。這裡的實現方式和論文中好像不太一樣,但最終都是編碼位置信息。n encoding[i-1, j-1] = (i - (embedding_size+1)/2) * (j - (sentence_size+1)/2)n encoding = 1 + 4 * encoding / embedding_size / sentence_sizen # Make position encoding of time words identity to avoid modifying them n encoding[:, -1] = 1.0n return np.transpose(encoding)n

模型的推理部分代碼如下所示:

def _build_inputs(self):n self._stories = tf.placeholder(tf.int32, [None, self._memory_size, self._sentence_size], name=stories)n self._queries = tf.placeholder(tf.int32, [None, self._sentence_size], name=queries)n self._answers = tf.placeholder(tf.int32, [None, self._vocab_size], name=answers)n self._lr = tf.placeholder(tf.float32, [], name=learning_rate)nn def _build_vars(self):nn with tf.variable_scope(self._name):n nil_word_slot = tf.zeros([1, self._embedding_szie])n #concat 是為了添加一個全0的詞向量n A = tf.concat(axis=0, values=[nil_word_slot, self._init([self._vocab_size-1, self._embedding_szie])])n C = tf.concat(axis=0, values=[nil_word_slot, self._init([self._vocab_size-1, self._embedding_szie])])nn #這裡使用adjacent是參數初始化方法。即B=A, Ak+1 = Ck, 所以不需要定義hop個A,直接使用相對應的C即可。n self.A_1 = tf.Variable(A, name=A)n self.C = []nn for hopn in range(self._hops):n with tf.variable_scope(hop_{}.format(hopn)):n self.C.append(tf.Variable(C, name=C))nn self._nil_vars = set([self.A_1.name] + [x.name for x in self.C])nnn def _inference(self, stories, queries):n with tf.variable_scope(self._name):n #得到queries的向量表示,使用A_1來代替Bn q_emb = tf.nn.embedding_lookup(self.A_1, self._queries)n #位置編碼,將一句話中所有單詞的embedding進行sum,然後得到一句話的表示n u_0 = tf.reduce_sum(q_emb*self._encoding, axis=1)n u = [u_0]nn for hopn in range(self._hops):n if hopn == 0: #如果是第一層,要使用A-1進行embedding,否則使用C-in m_emb_A = tf.nn.embedding_lookup(self.A_1, self._stories)n m_A = tf.reduce_sum(m_emb_A*self._encoding, axis=2)n else:n with tf.variable_scope(hop_{}.format(hopn - 1)):n m_emb_A = tf.nn.embedding_lookup(self.C[hopn-1], stories)n m_A = tf.reduce_sum(m_emb_A*self._encoding, axis=2)nn #取出上一層的輸出,作為u。並且為其擴展一個維度[batch_size, 1, embedding_size]n u_temp = tf.transpose(tf.expand_dims(u[-1], -1), [0, 2, 1])n #m_A的維度是[batch_size, memory_size, embedding_size]n dotted = tf.reduce_sum(m_A * u_temp, 2) #[batch_size, memory_size]n #queries對stories(記憶中每個句子)的權重,經過softmax,可以視為概率n probs = tf.nn.softmax(dotted)n #擴展一個維度並轉置,[batch_size, 1, memory_size]n probs_temp = tf.transpose(tf.expand_dims(probs, -1), [0, 2, 1])nn with tf.variable_scope(hop_{}.format(hopn)):n m_emb_C = tf.nn.embedding_lookup(self.C[hopn], stories)n m_C = tf.reduce_sum(m_emb_C*self._encoding, axis=2)nn #[batch_size, embedding_size, memory_size]n c_temp = tf.transpose(m_C, [0, 2, 1])n o_k = tf.reduce_sum(c_temp * probs_temp, axis=2) #[batch_size, embedding_size]nn u_k = u[-1] + o_knn u.append(u_k)nn with tf.variable_scope(hop_{}.format(self._hops)):n return tf.matmul(u_k, tf.transpose(self.C[-1], [1, 0]))n

接下來模型訓練部分就比較簡單了,我們可以看一下task1的訓練結果如下圖所示,可以看到對於task1這個簡單任務來講,本模型可以完全正確的回答相應問題。但是當遇到比較複雜的問題時,比如task2等其準確率還有待提高:

PTB 語言模型建模

這部分的代碼可以參考[https://github.com/carpedm20/MemN2N-tensorflow],這就是一個很傳統的語言建模任務,其實就是給定一個詞序列預測下一個詞出現的概率,數據集使用的是PTB(訓練集、驗證集、測試集分別包含929K,73K,82K個單詞,vocab包含10000個單詞)。為了適應該任務,需要對模型做出下面幾點的修改:

1. 由於輸入是單詞級別,不再是QA任務中的句子,所以這裡不需要進行句子向量的編碼,直接將每個單詞的詞向量存入memory即可,也就是說現在mi就是每個單詞的詞向量,也就不需要位置編碼這一步。

2. 輸出時給定此序列的下一個單詞,也就是vocab中某個詞的概率,這部分不需要修改

3. 因為這裡不存在Question,或者說每個訓練數據的Question都是一樣的,所以我們可以直接將Q向量設置為0.1的常量,不需要再進行embedding操作。

4. 因為之前都是使用LSTM來進行語言建模,所以為了讓模型更像RNN,我們採用第二種參數綁定方式,也就是讓每層的A和C保持相同,使用H矩陣來對輸入進行線性映射。

5. 文中提出要對每個hop中一般的神經元進行ReLU非線性處理

5. 採用更深的模型,hop=6或者7,而且memory size也變得更大,100。

解決完上面幾個問題,我們就可以把Memory Network移植到語言建模的任務上面來了。由於這裡數據比較簡單,我們就不再對數據處理的代碼進行介紹,直接看模型構建部分即可:

#定義模型輸入的placeholder,input對應Question,後面會初始化為0.1的常向量,time是時序信息,後面會按照其順序進行初始化,注意其shape是batch_size*mem_size,因為它對應的memory中每句話的時序信息,target是下一個詞,及我們要預測的結果,context是上下文信息,丫就是要保存到memory中的信息。n self.input = tf.placeholder(tf.float32, [None, self.edim], name="input")n self.time = tf.placeholder(tf.int32, [None, self.mem_size], name="time")n self.target = tf.placeholder(tf.float32, [self.batch_size, self.nwords], name="target")n self.context = tf.placeholder(tf.int32, [self.batch_size, self.mem_size], name="context")nn #因為有多個hop,所以使用下面兩個列表來保存每層的結果信息n self.hid = []n #對於第一層而言,輸入就是input,所以直接將其添加到hid裡面,方便後面循環中使用n self.hid.append(self.input)n self.share_list = []n self.share_list.append([])nn self.lr = Nonen self.current_lr = config.init_lrn self.loss = Nonen self.step = Nonen self.optim = Nonenn self.sess = sessn self.log_loss = []n self.log_perp = []nn def build_memory(self):n self.global_step = tf.Variable(0, name="global_step")nn #定義變數,A對應論文中的A,B對應論文中的C,C對應論文中的H矩陣,這裡作者並未按照論文中變數的命名規則定義n self.A = tf.Variable(tf.random_normal([self.nwords, self.edim], stddev=self.init_std))n self.B = tf.Variable(tf.random_normal([self.nwords, self.edim], stddev=self.init_std))n self.C = tf.Variable(tf.random_normal([self.edim, self.edim], stddev=self.init_std))nn # Temporal Encoding,時序編碼矩陣,T_A對應T_A,T_B對應T_Cn self.T_A = tf.Variable(tf.random_normal([self.mem_size, self.edim], stddev=self.init_std))n self.T_B = tf.Variable(tf.random_normal([self.mem_size, self.edim], stddev=self.init_std))nn #下面兩段是將context信息編碼進入memory的過程,這裡結合時序信息進行編碼n # m_i = sum A_ij * x_ij + T_A_in Ain_c = tf.nn.embedding_lookup(self.A, self.context)n Ain_t = tf.nn.embedding_lookup(self.T_A, self.time)n Ain = tf.add(Ain_c, Ain_t)nn # c_i = sum B_ij * u + T_B_in Bin_c = tf.nn.embedding_lookup(self.B, self.context)n Bin_t = tf.nn.embedding_lookup(self.T_B, self.time)n Bin = tf.add(Bin_c, Bin_t)nn #對每一層,執行下面的操作n for h in xrange(self.nhop):n #取出hid中上一層的輸出信息n self.hid3dim = tf.reshape(self.hid[-1], [-1, 1, self.edim])nn #下面三行就是根據Question信息得到memory的相關性評分P,注意變數的緯度變化n Aout = tf.matmul(self.hid3dim, Ain, adjoint_b=True)n Aout2dim = tf.reshape(Aout, [-1, self.mem_size])n P = tf.nn.softmax(Aout2dim)nn #根據P和輸出記憶加權求和得到輸出的信息n probs3dim = tf.reshape(P, [-1, 1, self.mem_size])n Bout = tf.matmul(probs3dim, Bin)n Bout2dim = tf.reshape(Bout, [-1, self.edim])nn #根據論文,使用H矩陣對q進行線性映射,然後與o相加得到該層輸出n Cout = tf.matmul(self.hid[-1], self.C)n Dout = tf.add(Cout, Bout2dim)nn #將各層的中間變數保存到列表當中,方便查看每層的功能n self.share_list[0].append(Cout)n #如果需要對某些元素執行ReLU函數,根據相應設置進行操作n if self.lindim == self.edim:n self.hid.append(Dout)n elif self.lindim == 0:n self.hid.append(tf.nn.relu(Dout))n else:n F = tf.slice(Dout, [0, 0], [self.batch_size, self.lindim])n G = tf.slice(Dout, [0, self.lindim], [self.batch_size, self.edim-self.lindim])n K = tf.nn.relu(G)n self.hid.append(tf.concat(axis=1, values=[F, K]))nn def build_model(self):n self.build_memory()nn #輸出層,使用hid最後一個變數得到輸出的答案n self.W = tf.Variable(tf.random_normal([self.edim, self.nwords], stddev=self.init_std))n z = tf.matmul(self.hid[-1], self.W)n #交叉熵損失函數n self.loss = tf.nn.softmax_cross_entropy_with_logits(logits=z, labels=self.target)nn self.lr = tf.Variable(self.current_lr)n self.opt = tf.train.GradientDescentOptimizer(self.lr)nn #梯度截斷,如果梯度大於設定值,則進行截斷n params = [self.A, self.B, self.C, self.T_A, self.T_B, self.W]n grads_and_vars = self.opt.compute_gradients(self.loss,params)n clipped_grads_and_vars = [(tf.clip_by_norm(gv[0], self.max_grad_norm), gv[1]) n for gv in grads_and_vars]nn inc = self.global_step.assign_add(1)n with tf.control_dependencies([inc]):n self.optim = self.opt.apply_gradients(clipped_grads_and_vars)nn tf.global_variables_initializer().run()n self.saver = tf.train.Saver()n

最終的訓練結果如下所示,由於訓練比較耗時,所以只截了前30個epoch的訓練效果,可以看到驗證集的perplexity已經降到了130多,效果還算可以。

總結

通過上面兩個實驗,想必對Memory Networks的了解更加深了一步,而且如何將其用到不同NLP任務當中也有了一定了解。相比上篇論文中介紹的Memory Networks,本片提出的End-To-End Memory Networks減少了監督程度,從訓練數據中不再需要答案跟某一句話相關這一重要信息可以看出來,模型可以自己學習到與問題最相關的輸入在哪裡,這裡我們可以結合論文中的一個圖片進行理解,從下圖可以知道模型的每個hop層都會學習到與問題相關的輸入,對於簡單問題,三層都可以學到最相關的那個句子,而對於比較複雜的問題(問題可能會與多個句子相關),每個hop都會學習到相應的輸入,而且還呈現一種推理的關係。說明這種外部Memory效果是很好的。

推薦閱讀:

邏輯回歸和SVM的區別是什麼?各適用於解決什麼問題?
隱馬爾可夫模型--更加接近真相。
《NLP》第二章The Language Modeling Problem

TAG:深度学习DeepLearning | 自然语言处理 |