Word2Vec以及tf实现

引言?

大部分内容摘自《TensorFlow实战》(黄文坚),算是读书笔记,也会在网上搜集一些相关的文章进行补充。 我觉得这本书很nice呀,虽然没有对具体的神经网络进行详细的讲解,但是很通俗易懂,偏实战。 也会有看了神经网络的详解然后雨里雾里,再在这边看了总结的豁然开朗之感。

One-Hot Encoder

最早的词向量表示方法为One-Hot Encoder,将字词转成离散的单独的符号:

杭州 [0,0,0,0,0,0,0,1,0,……,0,0,0,0,0,0,0]
上海 [0,0,0,0,1,0,0,0,0,……,0,0,0,0,0,0,0]
宁波 [0,0,0,1,0,0,0,0,0,……,0,0,0,0,0,0,0]
北京 [0,0,0,0,0,0,0,0,0,……,1,0,0,0,0,0,0]

杭州、上海、宁波、北京各对应一个向量,向量中只有一个值为1,其余都为0。 使用One-Hot Encoder有一个问题,即对特征的编码是随机的,没有考虑到字词之间的关系。如北京、上海应该聚集到一起,华盛顿、纽约聚集在一起。并且效率低、计算麻烦。

什么是Word2Vec

循环神经网络是在NLP领域最常使用的神经网络,而Word2Vec则是将语言中的字词转化为计算机可以理解的稠密向量(Dense Vector),通过一个嵌入空间使得语义上相似的单词在该空间内距离很近。

Word2Vec分为:

CBOW(Continuous Bag of Words):从原始语句(例如:中国的首都是____)推测目标字词(例如:北京)

Skip-Gram:从目标字词推测出原始语句,在大型语料库中表现更好

负采样:在Word2Vec的CBOW模型中,只训练一个二分类模型,区分真实的目标词汇和编造的噪声词汇(Negative Sampling)。只需要计算随机选择的k个词汇而并非全部。在实际中,使用Noise-Contrastive Estimation(NCE)Loss,在tf中对应为tf.nn.nce_loss()

模型拆解

Word2Vec模型其实就是简单化的神经网络:

图片来源:理解 Word2Vec 之 Skip-Gram 模型,作者写的超级详细,可以去读读。

输入是One-Hot Vector。 如果想要用300个特征来表示一个单词,则隐藏层的维度为300,且Hidden Layer没有激活函数。 Output Layer维度跟Input Layer的维度一样,用的是Softmax回归,输出概率分布。

将从输入层到隐含层的那些权重,作为每一个词汇表中的词的向量。

因为One-Hot Vector十分稀疏,消耗大量的计算资源。为了进行高效计算,只要选择选择矩阵中对应的向量中维度值为1的索引行:

左边向量中取值为1的对应维度为3(下标从0开始),那么计算结果就是矩阵的第3行(下标从0开始)—— [10, 12, 19],这样模型中的隐层权重矩阵便成了一个”查找表“(lookup table)

详细拆解参考如果看了此文还不懂 Word2Vec,那是我太笨

  1. CBOW

将一个词所在的上下文中的词作为输入,而那个词本身作为输出,也就是说,看到一个上下文,希望大概能猜出这个词和它的意思。通过在一个大的语料库训练,得到一个从输入层到隐含层的权重模型。如下图所示,第l个词的上下文词是i,j,k,那么i,j,k作为输入,它们所在的词汇表中的位置的值置为1。然后,输出是l,把它所在的词汇表中的位置的值置为1。训练完成后,就得到了每个词到隐含层的每个维度的权重,就是每个词的向量。例如第i个词的词向量为(Wi,1 Wi,2...Wi,m),m为向量的维度。

  1. Skip-gram

将一个词所在的上下文中的词作为输出,而那个词本身作为输入,也就是说,给出一个词,希望预测可能出现的上下文的词。通过在一个大的语料库训练,得到一个从输入层到隐含层的权重模型。如下图所示,第l个词的上下文词是i,j,k,那么i,j,k作为输出,它们所在的词汇表中的位置的值置为1。然后,输入是l,把它所在的词汇表中的位置的值置为1。训练完成后,就得到了每个词到隐含层的每个维度的权重,就是每个词的向量。

tf实现Word2Vec

使用Skip-Gram模式的Word2Vec,以“the quick brown fox jumped over the lazy dog”为例,训练样本为(quick,the),(quick,brown),(brown,quick),(brown,fox)等。训练时,希望模型可以从目标词汇quick推测出语境the,同时也需要制造随机的词汇作为负样本(噪声)。使用SGD(随机梯度下降算法)来更新模型中Word Embedding的参数,让概率分布的损失函数(NCE Loss)尽可能小。这样每个单词的Embedded Vector就会随着训练过程不断调整,直到处于一个最合适语料的空间位置。

基本每一句都有注释啦,可以结合原书来看此段代码😳

啊啊啊,我手工巧了一遍代码,才发现代码居然是tf上的demo

# -*- coding: UTF-8 -*-
import collections
import math
import os
import random
import zipfile
import numpy as np 
import urllib
import tensorflow as tf 
import urllib

# ---------------------------  数据预处理---------------------------
url = 'http://mattmahoney.net/dc/'
#   从‘http://mattmahoney.net/dc’下载文本文件,里面约有17005207个用空格分隔好的英文句子。
def maybe_download(filename,expected_bytes):
    if not os.path.exists(filename):
        filename, _ = urllib.request.urlretrieve(url + filename,filename)
    statinfo = os.stat(filename)

    if statinfo.st_size == expected_bytes:
        print('Found and verified',filename)
    else:
        print(statinfo.st_size)
        raise Exception(
            'Failed to verify' + filename +'. Can you get to it with a browser?'
        )
    return filename

filename = maybe_download("text8.zip",31344016)
#   filename = "text8.zip"

#   解压文件,并使用`tf.compat.as_str`将数据转化成单词列表。
def read_data(filename):
    with zipfile.ZipFile(filename) as f:
       data = tf.compat.as_str(f.read(f.namelist()[0])).split()
    return data

words = read_data(filename)
print('Data size',len(words))

#   创建vocabulary词汇表,选取前50000频数的单词,其余单词认定为Unknown,编号为0
vocabulary_size = 50000
def build_dataset(words):
    count = [['UNK',-1]]
    print("---------------------------------")
    #   collections.Counter统计单词列表中单词的频数
    #   most_common 取top 50000频数的单词作为vocabulary
    #   count = [(单词1,词频1),(单词2,词频2),...]
    count.extend(collections.Counter(words).most_common(vocabulary_size -1))
    dictionary = dict()
    #   存入dic中
    for word ,_ in count:
        dictionary[word] = len(dictionary)  

    #   遍历单词列表,如果出现在dictionary中,则转化为编号。不在则为0。
    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)
    count[0][1] = unk_count
    #   字典的反转形式,可用编号查询出对应的单词
    reverse_dictionary = dict(zip(dictionary.values(),dictionary.keys()))
    return data,count,dictionary,reverse_dictionary
data,count,dictionary,reverse_dictionary = build_dataset(words)
#   删除原始单词列表,节约内存
del words
#   打印最高频出现的词汇及数量,[['UNK', 418391], ('the', 1061396), ('of', 593677), ('and', 416629), ('one', 411764)]
print('Most common words (+UNK)',count[:5])
#   打印data前10个单词
print('Sample data',data[:10],[reverse_dictionary[i] for i in data[:10]])

#   ---------------------------生成Word2Vec的训练样本---------------------------
#   使用Skip-Gram模式,将原始数据:
#   “the quick brown fox jumped over the lazy dog”转化为:
#   (quick,the),(quick,brown),(brown,quick),(brown,fox)等。
data_index = 0
def generate_batch(batch_size , num_skips, skip_window): #  生成训练用的batch数据
    #   skip_window 单词最远可以联系的距离
    #   num_skips 对每个单词生成多少样本
    global data_index   #   定义为全局变量,因为会反复调用此函数
    #   batch_size 必须为num_skips的整数倍,确保每个batch包含一个词汇对应的所有样本
    assert batch_size % num_skips == 0  
    assert num_skips <= 2 * skip_window
    #   ndarray对象是用于存放同类型元素的多维数组
    batch = np.ndarray(shape = (batch_size),dtype = np.int32)
    labels = np.ndarray(shape = (batch_size,1),dtype = np.int32)
    #   某个单词创建样本时会使用到的单词数量,包括单词本身和它前后的单词
    span = 2 * skip_window + 1
    #   双向队列,使用append方法添加变量时,只会保留最后插入span个变量
    buffer = collections.deque(maxlen=span) 
    #   从序号data_index开始,把span个单词顺序读入buffer作为初始值
    for _ in range(span):
        buffer.append(data[data_index])
        data_index = (data_index + 1 ) % len(data)

    for i in range(batch_size // num_skips):    # // 为整数除法
        target = skip_window    #   buffer中第skip_window个变量为目标单词
        targets_to_avoid = [ skip_window ]  #   生成样本时需要避免的单词列表
        for j in range(num_skips):
            while target in targets_to_avoid:
                target = random.randint(0,span-1)
            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])
        data_index = (data_index +1) % len(data)
    return batch ,labels
#   简单测试功能
batch, labels = generate_batch(batch_size = 8, num_skips = 2,skip_window = 1)
for i in range(8):
    print(batch[i],reverse_dictionary[batch[i]],'->',labels[i,0],
        reverse_dictionary[labels[i,0]])

#   训练数据
batch_size = 128
embedding_size = 128    # 单词转为稠密向量的维度,一般是50~1000
skip_window = 1     # 单词间最远可以联系的距离 
num_skips = 2   # 每个目标单词提取的样本数

#   验证数据,随机抽取一些频数最高的单词,看向量空间上跟它们最近的单词是否相关性比较高
valid_size = 16     # 抽取的验证单词数
valid_window = 100  # 验证单词只从频数最高的100个单词中抽取
valid_examples = np.random.choice(valid_window,valid_size,replace=False)
num_sampled = 64    # 训练时用来做负样本的噪声单词的数量

#   ---------------------------定义Skip-Gram模型的网络结构---------------------------
graph = tf.Graph()
with graph.as_default():
    train_inputs = tf.placeholder(tf.int32,shape = [batch_size])
    train_labels = tf.placeholder(tf.int32,shape = [batch_size,1])
    #   将随机产生的valid_examples转换为constant
    valid_dataset = tf.constant(valid_examples,dtype = tf.int32)

    with tf.device('/cpu:0'):
        #   tf.random_uniform随机生成所有单词的词向量embeddings,单词表大小为50000,向量维度为128
        embeddings = tf.Variable(
            tf.random_uniform([vocabulary_size,embedding_size],-1.0,1.0))
        #   tf.nn.embedding_lookup 查找输入train_inputs对应的向量
        embed = tf.nn.embedding_lookup(embeddings,train_inputs)
        #   使用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]))
    #   loss的计算方式
    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))

    #   定义优化器为SGD,lr为1.0
    optimizer = tf.train.GradientDescentOptimizer(1.0).minimize(loss)
    #   计算嵌入向量embeddings的L2范数norm
    norm  = tf.sqrt(tf.reduce_sum(tf.square(embeddings),1,keep_dims = True))
    #   规范化
    normalized_embeddings = embeddings /norm
    #   tf.nn.embedding_lookup 查询验证单词的嵌入向量
    valid_embeddings = tf.nn.embedding_lookup(
        normalized_embeddings, valid_dataset)
    #   计算验证单词的嵌入向量与词汇表中所有单词的相似性
    similarity = tf.matmul(valid_embeddings,normalized_embeddings,transpose_b = True)
    #   初始化所有模型参数
    init = tf.global_variables_initializer()

#   最大的迭代次数
num_steps = 100001
with tf.Session(graph = graph) as session:
    init.run()
    print("Initialized")
    average_loss = 0
    for step in range(num_steps):
        #   生成一个batch的inputs和labels数据
        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
        # 2000此循环,计算一下平均loss并显示出来
        if step % 2000 ==0:
            if step >0 :
                average_loss /= 2000
            print("Average loss at step ", step, ": ",average_loss)
            average_loss = 0

        # 每10000次循环,计算一次验证单词与全部单词的相似度,并将最相似的8个单词展示出来
        if step % 10000 ==0:
            sim = similarity.eval()
            for i in range(valid_size):
                valid_word = reverse_dictionary[valid_examples[i]]
                top_k = 8
                nearest = (-sim[i,:]).argsort()[1:top_k+1]
                log_str = "Nearest to %s: " % valid_word
            for k in range(top_k):
                close_word = reverse_dictionary[nearest[k]]
                #close_word = reverse_dictionary.get(nearest[k])
                log_str = "%s %s ," % (log_str,close_word)
            print(log_str)
    final_embeddings = normalized_embeddings.eval()

#   ---------------------------可视化---------------------------
import matplotlib.pyplot as plt
# low_dim_embs 是降维到2维的单词的空间向量,在图表中展示每个单词的位置
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))
    for i,label in enumerate(labels):
        x, y = low_dim_embs[i,:]
        plt.scatter(x,y)    # 显示散点图(单词的位置)
        # plt.annotate为单词本身
        plt.annotate(label,     
                     xy = (x,y),
                     xytext = (5,2),
                     textcoords = 'offset points',
                     ha = 'right',
                     va = 'bottom')
    plt.savefig(filename)   #   保存图片到本地

# 降维,将原始的128维嵌入向量降到2维,展示词频最高的100个单词
from sklearn.manifold import TSNE
tsne = TSNE(perplexity=30, n_components=2,init="pca",n_iter = 5000)
plot_only = 100
low_dim_embs = tsne.fit_transform(final_embeddings[:plot_only,:])
labels = [reverse_dictionary[i] for i in range(plot_only)]
plot_with_labels(low_dim_embs,labels)