博客園的markdown用起來太心塞了,現在重新用其他編輯器把這篇博客整理了一下。
目前用word2vec算法訓練詞向量的工具主要有兩種:gensim 和 tensorflow。gensim中已經封裝好了word2vec這個包,用起來很方便,只要把文本處理成規范的輸入格式,寥寥幾行代碼就能訓練詞向量。這樣比較適合在做項目時提高效率,但是對理解算法的原理幫助不大。相比之下,用tensorflow來訓練word2vec比較麻煩,生成batch、定義神經網絡的各種參數,都要自己做,但是對於理解算法原理,還是幫助很大。
所以這次就用開源的tensorflow實現word2vec的代碼,來訓練詞向量,並進行可視化。這次的語料來自於一個新聞文本分類的項目。新聞文本文檔非常大,有 120多M,這里提供百度網盤下載:https://pan.baidu.com/s/1yeFORUVr3uDdTLUYqDraKA 提取碼:c98y 。
詞向量訓練出來有715M,真是醉了!好,開始吧。
一、用tensorflow和word2vec訓練中文詞向量
這次用到的是skip-gram模型。新聞文本的訓練語料是一個txt文檔,每行是一篇新聞,開頭兩個字是標簽:體育、財經、娛樂等,后面是新聞的內容,開頭和內容之間用制表符 '\t' 隔開。
(一)讀取文本數據,分詞,清洗,生成符合輸入格式的內容
這里是用jieba進行分詞的,加載了停用詞表,不加載的話會發現 “的、一 ”之類的詞是排在前列的,而負采樣是從詞頻高的詞開始,因此會對結果產生不好的影響。
處理得到的規范格式的輸入是這樣的,把所有新聞文本分詞后做成一個列表:['體育', '馬', '曉', '旭', '意外', '受傷', '國奧', '警惕', '無奈', '大雨', ...]。
#encoding=utf8 from __future__ import absolute_import from __future__ import division from __future__ import print_function import collections import math import os import random import zipfile import numpy as np from six.moves import xrange import tensorflow as tf import jieba from itertools import chain """第一步:讀取數據,用jieba進行分詞,去除停用詞,生成詞語列表。""" def read_data(filename): f = open(filename, 'r', encoding='utf-8') stop_list = [i.strip() for i in open('ChineseStopWords.txt','r',encoding='utf-8')] news_list = [] for line in f: if line.strip(): news_cut = list(jieba.cut(''.join(line.strip().split('\t')),cut_all=False,HMM=False)) news_list.append([word.strip() for word in news_cut if word not in stop_list and len( word.strip())>0]) # line是:'體育\t馬曉旭意外受傷讓國奧警惕 無奈大雨格外...'這樣的新聞文本,標簽是‘體育’,后面是正文,中間用'\t'分開。 # news_cut : ['體育', '馬', '曉', '旭', '意外', '受傷', '讓', '國奧', '警惕', ' ', '無奈',...], 按'\t'來拆開 # news_list為[['體育', '馬', '曉', '旭', '意外', '受傷', '國奧', '警惕', '無奈', ...],去掉了停用詞和空格。 news_list = list(chain.from_iterable(news_list)) # 原列表中的元素也是列表,把它拉成一個列表。['體育', '馬', '曉', '旭', '意外', '受傷', '國奧', '警惕', '無奈', '大雨', ...] f.close() return news_list filename = 'data/cnews/cnews.train.txt' words = read_data(filename) # 把所有新聞分詞后做成了一個列表:['體育', '馬', '曉', '旭', '意外', '受傷', '國奧', '警惕', '無奈', '大雨', ...]
(二)建立詞匯表
這一步是把得到的詞列表中的詞去重,統計詞頻,然后按詞頻從高到低排序,構建一個詞匯表:{'UNK': 0, '中': 1, '月': 2, '年': 3, '說': 4, '中國': 5,...},key是詞,‘中’的詞頻最高,放在前面,value是每個詞的索引。
為了便於根據索引來取詞,因此把詞匯表這個字典進行反轉得到:reverse_dictionary: {0: 'UNK', 1: '中', 2: '月', 3: '年', 4: '說', 5: '中國',...}。
同時還得到了上面words這個列表中每個詞的索引: [259, 512, 1023, 3977, 1710, 1413, 12286, 6118, 2417, 18951, ...]。
詞語(含重復)共有1545萬多個,去重復后得到19萬多個。
"""第二步:建立詞匯表""" words_size = len(words) vocabulary_size = len(set(words)) print('Data size', vocabulary_size) # 共有15457860個,重復的詞非常多。 # 詞匯表中有196871個不同的詞。 def build_dataset(words): count = [['UNK', -1]] count.extend(collections.Counter(words).most_common(vocabulary_size - 1)) dictionary = dict() # 統計詞頻較高的詞,並得到詞的詞頻。 # count[:10]: [['UNK', -1], ('中', 96904), ('月', 75567), ('年', 73174), ('說', 56859), ('中國', 55539), ('日', 54018), ('%', 52982), ('基金', 47979), ('更', 40396)] # 盡管取了詞匯表前(196871-1)個詞,但是前面加上了一個用來統計未知詞的元素,所以還是196871個詞。之所以第一個元素是列表,是為了便於后面進行統計未知詞的個數。 for word, _ in count: dictionary[word] = len(dictionary) # dictionary: {'UNK': 0, '中': 1, '月': 2, '年': 3, '說': 4, '中國': 5,...},是詞匯表中每個字是按照詞頻進行排序后的,字和它的索引構成的字典。 data = list() unk_count = 0 for word in words: if word in dictionary: index = dictionary[word] else: index = 0 unk_count += 1 data.append(index) # data是words這個文本列表中每個詞對應的索引。元素和words一樣多,是15457860個 # data[:10] : [259, 512, 1023, 3977, 1710, 1413, 12286, 6118, 2417, 18951] count[0][1] = unk_count reverse_dictionary = dict(zip(dictionary.values(), dictionary.keys())) return data, count, dictionary, reverse_dictionary # 位置詞就是'UNK'本身,所以unk_count是1。[['UNK', 1], ('中', 96904), ('月', 75567), ('年', 73174), ('說', 56859), ('中國', 55539),...] # 把字典反轉:{0: 'UNK', 1: '中', 2: '月', 3: '年', 4: '說', 5: '中國',...},用於根據索引取詞。 data, count, dictionary, reverse_dictionary = build_dataset(words) # data[:5] : [259, 512, 1023, 3977, 1710] # count[:5]: [['UNK', 1], ('中', 96904), ('月', 75567), ('年', 73174), ('說', 56859)] # reverse_dictionary: {0: 'UNK', 1: '中', 2: '月', 3: '年', 4: '說', 5: '中國',...} del words print('Most common words (+UNK)', count[:5]) print('Sample data', data[:10], [reverse_dictionary[i] for i in data[:10]]) # 刪掉不同的數據,釋放內存。 # Most common words (+UNK) [['UNK', 1], ('中', 96904), ('月', 75567), ('年', 73174), ('說', 56859)] # Sample data [259, 512, 1023, 3977, 1710, 1413, 12286, 6118, 2417, 18951] ['體育', '馬', '曉', '旭', '意外', '受傷', '國奧', '警惕', '無奈', '大雨'] data_index = 0
(三)為skip-gram模型生成訓練的batch
skip-gram模型是根據中心詞來預測上下文詞的,拿['體育', '馬', '曉', '旭', '意外', '受傷', '國奧', '警惕', '無奈', '大雨']來舉例,滑動窗口為5,那么中心詞前后各2個詞,第一個中心詞為 ‘曉’時,上下文詞為(體育,馬,旭,意外)這樣一個沒有順序的詞袋。
那么生成的樣本可能為:[(曉,馬),(曉,意外),(曉,體育),(曉,旭)],上下文詞不是按順序排列的。
""" 第三步:為skip-gram模型生成訓練的batch """ def generate_batch(batch_size, num_skips, skip_window): global data_index assert batch_size % num_skips == 0 assert num_skips <= 2 * skip_window batch = np.ndarray(shape=(batch_size), dtype=np.int32) labels = np.ndarray(shape=(batch_size, 1), dtype=np.int32) span = 2 * skip_window + 1 buffer = collections.deque(maxlen=span) # 這里先取一個數量為8的batch看看,真正訓練時是以128為一個batch的。 # 構造一個一列有8個元素的ndarray對象 # deque 是一個雙向列表,限制最大長度為5, 可以從兩端append和pop數據。 for _ in range(span): buffer.append(data[data_index]) data_index = (data_index + 1) % len(data) # 循環結束后得到buffer為 deque([259, 512, 1023, 3977, 1710], maxlen=5),也就是取到了data的前五個值, 對應詞語列表的前5個詞。 for i in range(batch_size // num_skips): target = skip_window targets_to_avoid = [skip_window] # i取值0,1,是表示一個batch能取兩個中心詞 # target值為2,意思是中心詞在buffer這個列表中的位置是2。 # 列表是用來存已經取過的詞的索引,下次就不能再取了,從而把buffer中5個元素不重復的取完。 for j in range(num_skips): # j取0,1,2,3,意思是在中心詞周圍取4個詞。 while target in targets_to_avoid: target = random.randint(0, span - 1) # 2是中心詞的位置,所以j的第一次循環要取到不是2的數字,也就是取到0,1,3,4其中的一個,才能跳出循環。 targets_to_avoid.append(target) # 把取過的上下文詞的索引加進去。 batch[i * num_skips + j] = buffer[skip_window] # 取到中心詞的索引。前四個元素都是同一個中心詞的索引。 labels[i * num_skips + j, 0] = buffer[target] # 取到中心詞的上下文詞的索引。一共會取到上下各兩個。 buffer.append(data[data_index]) # 第一次循環結果為buffer:deque([512, 1023, 3977, 1710, 1413], maxlen=5), # 所以明白了為什么限制為5,因為可以把第一個元素去掉。這也是為什么不用list。 data_index = (data_index + 1) % len(data) return batch, labels batch, labels = generate_batch(batch_size=8, num_skips=4, skip_window=2) # batch是 array([1023, 1023, 1023, 1023, 3977, 3977, 3977, 3977], dtype=int32),
# 8個batch取到了2個中心詞,一會看樣本的輸出結果就明白了。 for i in range(8): print(batch[i], reverse_dictionary[batch[i]], '->', labels[i, 0], reverse_dictionary[labels[i, 0]]) ''' 打印的結果如下,突然明白說為什么說取樣本的時候是用bag of words 1023 曉 -> 3977 旭 1023 曉 -> 1710 意外 1023 曉 -> 512 馬 1023 曉 -> 259 體育 3977 旭 -> 512 馬 3977 旭 -> 1023 曉 3977 旭 -> 1710 意外 3977 旭 -> 1413 受傷 '''
(四)定義skip-gram模型
這里面涉及的一些tensorflow的知識點在第二部分有寫,這里也說明一下。
首先 tf.Graph().as_default() 表示將新生成的圖作為整個 tensorflow 運行環境的默認圖,如果只有一個主線程不寫也沒有關系,tensorflow 里面已經存好了一張默認圖,可以使用tf.get_default_graph() 來調用(顯示這張默認紙),當你有多個線程就可以創造多個tf.Graph(),就是你可以有一個畫圖本,有很多張圖紙,而默認的只有一張,可以自己指定。
tf.random_uniform這個方法是用來產生-1到1之間的均勻分布, 看作是初始化隱含層和輸出層之間的詞向量矩陣。
nce_loss函數是tensorflow中常用的損失函數,可以將其理解為其將多元分類分類問題強制轉化為了二元分類問題,num_sampled參數代表將選取負例的個數。
這個損失函數通過 sigmoid cross entropy來計算output和label的loss,從而進行反向傳播。這個函數把最后的問題轉化為了(num_sampled ,num_True)這個兩分類問題,然后每個分類問題用了交叉熵損失函數。
""" 第四步:定義和訓練skip-gram模型""" batch_size = 128 embedding_size = 300 skip_window = 2 num_skips = 4 num_sampled = 64 # 上面那個數量為8的batch只是為了展示以下取樣的結果,實際上是batch-size 是128。 # 詞向量的維度是300維。 # 左右兩邊各取兩個詞。 # 要取4個上下文詞,同一個中心詞也要重復取4次。 # 負采樣的負樣本數量為64 graph = tf.Graph() with graph.as_default(): # 把新生成的圖作為整個 tensorflow 運行環境的默認圖,詳見第二部分的知識點。 train_inputs = tf.placeholder(tf.int32, shape=[batch_size]) train_labels = tf.placeholder(tf.int32, shape=[batch_size, 1]) embeddings = tf.Variable(tf.random_uniform([vocabulary_size, embedding_size], -1.0, 1.0)) embed = tf.nn.embedding_lookup(embeddings, train_inputs) #產生-1到1之間的均勻分布, 看作是初始化隱含層和輸出層之間的詞向量矩陣。 #用詞的索引在詞向量矩陣中得到對應的詞向量。shape=(128, 300) nce_weights = tf.Variable(tf.truncated_normal([vocabulary_size, embedding_size], stddev=1.0 / math.sqrt(embedding_size))) # 初始化損失(loss)函數的權重矩陣和偏置矩陣 # 生成的值服從具有指定平均值和合理標准偏差的正態分布,如果生成的值大於平均值2個標准偏差則丟棄重新生成。這里是初始化權重矩陣。 # 對標准方差進行了限制的原因是為了防止神經網絡的參數過大。 nce_biases = tf.Variable(tf.zeros([vocabulary_size])) loss = tf.reduce_mean(tf.nn.nce_loss(weights=nce_weights, biases=nce_biases, labels=train_labels, inputs=embed, num_sampled=num_sampled, num_classes=vocabulary_size)) # 初始化偏置矩陣,生成了一個vocabulary_size * 1大小的零矩陣。 # 這個tf.nn.nce_loss函數把多分類問題變成了正樣本和負樣本的二分類問題。用的是邏輯回歸的交叉熵損失函數來求,而不是softmax 。 optimizer = tf.train.GradientDescentOptimizer(1.0).minimize(loss) norm = tf.sqrt(tf.reduce_sum(tf.square(embeddings), 1, keepdims=True)) normalized_embeddings = embeddings / norm # shape=(196871, 1), 對詞向量矩陣進行歸一化 init = tf.global_variables_initializer()
(五)訓練skip-gram模型
接下來就開始訓練了,這里沒什么好說的,就是訓練神經網絡,不斷更新詞向量矩陣,然后訓練完后,得到最終的詞向量矩陣。源碼中還有一個展示鄰近詞語的代碼,我覺得沒啥用,刪掉了。
num_steps = 10 with tf.Session(graph=graph) as session: init.run() print('initialized.') average_loss = 0 for step in xrange(num_steps): batch_inputs, batch_labels = generate_batch(batch_size, num_skips, skip_window) feed_dict = {train_inputs: batch_inputs, train_labels: batch_labels} _, loss_val = session.run([optimizer, loss], feed_dict=feed_dict) average_loss += loss_val final_embeddings = normalized_embeddings.eval() print(final_embeddings) print("*"*20) if step % 2000 == 0: if step > 0: average_loss /= 2000 print("Average loss at step ", step, ": ", average_loss) average_loss = 0 final_embeddings = normalized_embeddings.eval() # 訓練得到最后的詞向量矩陣。 print(final_embeddings) fp=open('vector.txt','w',encoding='utf8') for k,v in reverse_dictionary.items(): t=tuple(final_embeddings[k]) s='' for i in t: i=str(i) s+=i+" " fp.write(v+" "+s+"\n") # s為'0.031514477 0.059997283 ...' , 對於每一個詞的詞向量中的300個數字,用空格把他們連接成字符串。 #把詞向量寫入文本文檔中。不過這樣就成了字符串,我之前試過用np保存為ndarray格式,這里是按源碼的保存方式。 fp.close()
(六)詞向量可視化
用sklearn.manifold.TSNE這個方法來進行可視化,實際上作用不是畫圖,而是降維,因為詞向量是300維的,降到2維或3維才能可視化。
這里用到了t-SNE這一種集降維與可視化於一體的技術,t-SNE 的主要目的是高維數據的可視化,當數據嵌入二維或三維時,效果最好。
值得注意的一點是,matplotlib默認的字體是不含中文的,所以沒法顯示中文注釋,要自己導入中文字體。在默認狀態下,matplotlb無法在圖表中使用中文。
matplotlib中有一個字體管理器——matplotlib.Font_manager,通過該管理器的方法——matplotlib.Font_manager.FontProperties(fname)可以指定一個ttf字體文件作為圖表使用的字體。
"""第六步:詞向量可視化 """ def plot_with_labels(low_dim_embs, labels, filename='tsne.png'): assert low_dim_embs.shape[0] >= len(labels), "More labels than embeddings" plt.figure(figsize=(18, 18)) # in inches myfont = font_manager.FontProperties(fname='/home/dyy/Downloads/font163/simhei.ttf') #加載中文字體 for i, label in enumerate(labels): x, y = low_dim_embs[i, :] plt.scatter(x, y) plt.annotate(label, xy=(x, y), xytext=(5, 2), #添加注釋, xytest是注釋的位置。然后添加顯示的字體。 textcoords='offset points', ha='right', va='bottom', fontproperties=myfont) plt.savefig(filename) plt.show() try: from sklearn.manifold import TSNE import matplotlib.pyplot as plt from matplotlib import font_manager #這個庫很重要,因為需要加載字體,原開源代碼里是沒有的。 tsne = TSNE(perplexity=30, n_components=2, init='pca', n_iter=5000) plot_only = 500 low_dim_embs = tsne.fit_transform(final_embeddings[:plot_only, :]) labels = [reverse_dictionary[i] for i in xrange(plot_only)] # tsne: 一個降維的方法,降維后維度是2維,使用'pca'來初始化。 # 取出了前500個詞的詞向量,把300維減低到2維。 plot_with_labels(low_dim_embs, labels) except ImportError: print("Please install sklearn, matplotlib, and scipy to visualize embeddings.")
可視化的結果:
詞向量(太大了,打開也要花不少時間)