上一期討論了Tensorflow以及Gensim的Word2Vec模型的建設以及對比。這一期,我們來看一看Mikolov的另一個模型,即Paragraph Vector模型。目前,Mikolov以及Bengio的最新論文Ensemble of Generative and Discriminative Techniques for Sentiment Analysis of Movie Reviews里就引入了該模型作為用戶對影視作品的評論分析方法。與此同時,網絡上很多地方也指出該模型效果並沒有其前期模型Word2Vec的效果好。這里,我們先不討論其效果是好是壞,單就如何搭建該模型來展開討論。首先,我將介紹該模型的Gensim編寫方法,之后,在Gensim模型的思維下,我們將嘗試運用Tensorflow來編寫這個模型。在我們開始碼代碼前,對於這個模型,還是有必要做一點稍許介紹的。
模型背景:
該模型起始於Word2Vec中的CBOW以及Skip-Gram模型。從模型的框架來看,其結構基本等同於CBOW或者Skip-Gram模型,但最大區別在於加入了一個新的於單詞維度相等的維度作為句子維度,段落維度或者文章維度。 維度的意義為需要運用該模型的人他們所需要代表的意義,即句子分類,段落分類還是文章分類。這個新的維度存在於不同於單詞維度的空間,所以大家注意不要混淆單詞維度和這個新維度的概念。模型的訓練方式等同於Word2Vec。模型的目的在於為單詞加入一些更長的序列意義外同時為句子,段落或者文章的非監督分類得到類似於Word2Vec的效果。詳細的說明大家可以閱讀如下鏈接。
模型代碼:
1. Gensim代碼:
首先,讓我們來看看Gensim代碼是如何表達的:
from gensim.models import doc2vec from collections import namedtuple import csv import re import string # 選擇wikipedia作為輸入,錄入一部分wikipedia的csv文檔 reader = csv.reader(open("wikipedia.csv")) count = 0 data = '' for row in reader: count = count + 1 if count > 301: break else: data += row[1] # 分句。我們以句號,問號以及感嘆號作為分句依據。 # 值得注意的是,該依據並非十分嚴謹,例如英文中的 # Mr.Wang就會被划分為兩句,但是由於該代碼是作為 # 示范,我們對嚴謹的分句並不感興趣,大家有空可以 # 做更好的處理 sentenceEnders = re.compile('[.?!]') data_list = sentenceEnders.split(data) # 建設一個namedtuple框架來裝載輸入 LabelDoc = namedtuple('LabelDoc', 'words tags') exclude = set(string.punctuation) all_docs = [] count = 0 for sen in data_list: word_list = sen.split() # 當一句話小於三個詞兒時,我們認為其意義並不 # 完整,所以去除該類話以凈化我們的輸入。 if len(word_list) < 3: continue tag = ['SEN_' + str(count)] count += 1 sen = ''.join(ch for ch in sen if ch not in exclude) all_docs.append(LabelDoc(sen.split(), tag)) # 打印例子來看看all_docs的形狀 print all_docs[0:10] # 在Gensim的官方文件中,作者指出最好的效果要么來自於隨意排列輸入句子,要么 # 來自於在訓練迭代的過程中減少learning rate alpha,故這里我們運用了后者。 model = doc2vec.Doc2Vec(alpha=0.025, min_alpha=0.025) # use fixed learning rate model.build_vocab(all_docs) for epoch in range(10): model.train(all_docs) model.alpha -= 0.002 # decrease the learning rate model.min_alpha = model.alpha # fix the learning rate, no decay # 保存該模型 model.save('my_model.doc2vec')
不難看出,在整理好輸入后,除了需要設計減少learning rate alpha外,其余的訓練方法非常淺顯易懂。在測試該模型的效果時,運行以下代碼即可:
import random import numpy as np import string # 選取一個任意的句子id doc_id = np.random.randint(model.docvecs.count) print doc_id # 通過docvecs.most_similar函數計算相近的句子id,並依次打印出前8個 sims = model.docvecs.most_similar(doc_id, topn=model.docvecs.count) print('TARGET' , all_docs[doc_id].words) count = 0 for i in sims: if count > 8: break pid = int(string.replace(i[0], "SEN_", "")) print(i[0],": ", all_docs[pid].words) count += 1
運行結果如下:
顯而易見,當我們的目標句子是關於Maldonado時,我們的最接近句子也是關於他的。同時,我們的句子是關羽notable victories(明顯的勝利)時,第二接近的句子也是關於這個主題。由此可見,系統的確學習到了一些關聯性。但是我們終究只是運用了一個黑盒子,這個黑盒子到底是怎么工作的呢?下面我們將試圖用Tensorflow還原這個邏輯。
2. Tensorflow代碼:
在我5月19日的博客上已經介紹了關於Word2Vec里CBOW模型在Tensorflow上的編寫,詳細信息請點擊鏈接查詢。基於這個模型,我們將來推演如何更改以獲得PV-DM模型,即Paragraph Vector版本的CBOW模型。
首先,我們需要整理輸入。方法相同於之前Gensim的代碼,這里將不予重復。但是值得注意的是,原來的wikipedia.csv文檔被我們預處理為一個裝有單詞list以及其對應句子id的一個namedtuple struct。那么,在接受這個struct的同時,我們需要更改build_data函數來正確的組建dictionary以及我們需要的data輸入。這里,我們的目標是保持原來的count, dictionary以及reverse dictionary, 但是對於輸入data,我們希望直接更改我們的輸入,把namedtuple中,單詞list里的單詞換成他們在dictionary中的index。如下代碼將做到這個功能:
def build_dataset(input_data, min_cut_freq): # 這里將input_data重新收集為CBOW模型中的words list以方便 # counter函數的使用。 words = [] for i in input_data: for j in i.words: words.append(j) count_org = [['UNK', -1]] count_org.extend(collections.Counter(words).most_common()) count = [['UNK', -1]] for word, c in count_org: word_tuple = [word, c] if word == 'UNK': count[0][1] = c continue if c > min_cut_freq: count.append(word_tuple) dictionary = dict() for word, _ in count: dictionary[word] = len(dictionary) data = [] unk_count = 0 for tup in input_data: word_data = [] for word in tup.words: if word in dictionary: index = dictionary[word] else: index = 0 unk_count += 1 word_data.append(index) data.append(LabelDoc(word_data, tup.tags)) count[0][1] = unk_count reverse_dictionary = dict(zip(dictionary.values(), dictionary.keys())) return data, count, dictionary, reverse_dictionary
由以上代碼,我們將會得到我們需要的輸入。那么,如何建立我們的模型呢?在建立模型前,我們需要跟改generate_batch函數以求保持原來的batch和label輸出外,增加一個對應每個label的一個paragraph label。
def generate_DM_batch(batch_size, num_skips, skip_window): global word_index global sentence_index assert batch_size % num_skips == 0 assert num_skips <= 2 * skip_window batch = np.ndarray(shape=(batch_size, num_skips), dtype=np.int32) labels = np.ndarray(shape=(batch_size, 1), dtype=np.int32) para_labels = np.ndarray(shape=(batch_size, 1), dtype=np.int32) # Paragraph Labels span = 2 * skip_window + 1 # [ skip_window target skip_window ] buffer = collections.deque(maxlen=span) for _ in range(span): buffer.append(data[sentence_index].words[word_index]) sen_len = len(data[sentence_index].words) if sen_len - 1 == word_index: # reaching the end of a sentence word_index = 0 sentence_index = (sentence_index + 1) % len(data) else: # increase the word_index by 1 word_index += 1 for i in range(batch_size): target = skip_window # target label at the center of the buffer targets_to_avoid = [ skip_window ] batch_temp = np.ndarray(shape=(num_skips), dtype=np.int32) for j in range(num_skips): while target in targets_to_avoid: target = random.randint(0, span - 1) targets_to_avoid.append(target) batch_temp[j] = buffer[target] batch[i] = batch_temp labels[i,0] = buffer[skip_window] para_labels[i, 0] = sentence_index buffer.append(data[sentence_index].words[word_index]) sen_len = len(data[sentence_index].words) if sen_len - 1 == word_index: # reaching the end of a sentence word_index = 0 sentence_index = (sentence_index + 1) % len(data) else: # increase the word_index by 1 word_index += 1 return batch, labels, para_labels
這里,我們保持了兩個global的變量,即word_index和sentence_index。前者標記了在一句中前一個batch讀到了哪個單詞,后者標記了前一個batch讀到了哪個句子。他們的初始值都是0。如果我們發現目前所讀入的單詞在句子中是最后一個詞,即sen_len - 1 == word_index, 我們將會重置word_index,並移動sentence_index去向下一句。這樣,我們保持了原有的batch和labels外針對每一個input window定義了它自身所應對的一個para_label。 好了,材料齊備了,那么我們如何運用這些材料構建paragraph vector呢?
with graph.as_default(): # Input data. train_inputs = tf.placeholder(tf.int32,shape=[batch_size, skip_window * 2]) train_labels = tf.placeholder(tf.int32, shape=[batch_size, 1]) #paragraph vector place holder train_para_labels = tf.placeholder(tf.int32,shape=[batch_size, 1]) # Ops and variables pinned to the CPU because of missing GPU implementation with tf.device('/cpu:0'): # Look up embeddings for inputs. embeddings = tf.Variable(tf.random_uniform([vocabulary_size, embedding_size], -1.0, 1.0)) embed_word = tf.nn.embedding_lookup(embeddings, train_inputs) # Look up embeddings for paragraph inputs para_embeddings = tf.Variable(tf.random_uniform([paragraph_size, embedding_size], -1.0, 1.0)) embed_para = tf.nn.embedding_lookup(para_embeddings, train_para_labels) # Concat them and average them embed = tf.concat(1, [embed_word, embed_para]) reduced_embed = tf.div(tf.reduce_sum(embed, 1), skip_window*2 + 1) # Construct the variables for the NCE loss nce_weights = tf.Variable(tf.truncated_normal([vocabulary_size, embedding_size], stddev=1.0 / math.sqrt(embedding_size))) nce_biases = tf.Variable(tf.zeros([vocabulary_size])) # Compute the average NCE loss for the batch. # tf.nce_loss automatically draws a new sample of the negative labels each # time we evaluate the loss. loss = tf.reduce_mean( tf.nn.nce_loss(nce_weights, nce_biases, reduced_embed, train_labels, num_sampled, vocabulary_size))
這里,我們首先保留了原來的word embedding的graph,在此基礎上,我們加入了paragraph_labels的placeholder,並且定義了paragraph vector的embedding。在把他們合並並且加權平均了后,我們通過nce loss的方式訓練該模型。最后
with tf.Session(graph=graph) as session: # We must initialize all variables before we use them. init.run() print("Initialized") average_loss = 0 for step in xrange(num_steps): batch_inputs, batch_labels, batch_para_labels = generate_DM_batch( batch_size, num_skips, skip_window) feed_dict = {train_inputs : batch_inputs, train_labels : batch_labels, train_para_labels: batch_para_labels}
在session里,我們呼叫我們的generatge_DM_batch函數並將batch, label和paragraph_label喂給我們的模型。該模型在運行的過程中效果並不很好,由於時間緊張,我沒有對模型進行優化。之后我將會gensim里關於shuffle輸入語句或者減少learning rate alpha的提議進行嘗試。如果你發現我的代碼有誤,請務必指出,感謝你的熱情參與!謝謝!代碼可以在這里找到.