利用Word2vec生成句向量(二)

在之前的文章《利用Word2vec生成句向量(一)》中,介绍了两种句向量的生成方法,本文将介绍一种号称"简单却具有一定竞争力"的句向量表示方法:SIF加权平均
论文见A simple but tough-to-beat baseline for sentence embeddings
本文依旧不会对论文及其原理做过多解读,我更着眼于源码的修改,使源码能运行起来跑得通,人人都能拿来就用
和之前提到的TFIDF加权相似,SIF也是对于每个词向量给出一定的权重,并且权重的大小也是基于词频的,论文把该方法命名为平滑倒词频。
官方给出了SIF的源码,但是源码只支持英文,并且只支持python2,不支持python3,本文将会对源码进行改造使其适用于中文和python3版本,并进行简单解读。


首先,将SIF源码拷贝到本地

git clone https://github.com/PrincetonML/SIF.git

注意,SIF源码本身存在很多BUG,Glove词向量中字段一塌糊涂,向量中的两个值都会连在一块,根本读不出来,如果想运行原有的SIF需要进行很多调试工作。


主程序入口sif_embedding.py
修改部分:

  • 将原有代码中的Glove词向量变为自己的Word2vec词向量Word2Vec.load(‘词向量文件’)
  • 原有源码中有一个enwiki_vocab_min200.txt的文件,源码中没有提及这个文件的来源,通过观察可以看出文件存储的是每个单词的词频。
    但是我们用gensim训练出的Word2vec模型中本身就有类似的统计参数,无需额外获取,只需要把model_100.wv.vocab传进去再修改getWordWeight的访问方式即可
from src import data_io, params, SIF_embedding
import os
from gensim.models import Word2Vec

model_100 = Word2Vec.load(os.path.join('/media/brx/TOSHIBA EXT/wiki_zh_word2vec/', 'ngram_100_5_90w.bin'))
words = {}
for index, word in enumerate(model_100.wv.index2entity):
    words[word] = index
We = model_100.wv.vectors

# input
weightpara = 1e-3 # the parameter in the SIF weighting scheme, usually in the range [3e-5, 3e-3]
rmpc = 1 # number of principal components to remove in SIF weighting scheme
sentences = ['这是一个测试句子', '这是另一个测试句子']

# load word vectors
# (words, We) = data_io.getWordmap(wordfile)
# load word weights
word2weight = data_io.getWordWeight(model_100.wv.vocab, weightpara) # word2weight['str'] is the weight for the word 'str'
weight4ind = data_io.getWeight(words, word2weight) # weight4ind[i] is the weight for the i-th word
# load sentences
x, m = data_io.sentences2idx(sentences, words) # x is the array of word indices, m is the binary mask indicating whether there is a word in that location
w = data_io.seq2weight(x, m, weight4ind) # get word weights

# set parameters
params = params.params()
params.rmpc = rmpc
# get SIF embedding
embedding = SIF_embedding.SIF_embedding(We, x, w, params) # embedding[i,:] is the embedding for sentence i
print()

数据处理data_io.py
修改部分:

  • getSeq函数中将原有的split()改为jieba分词
  • sentences2idx函数中定义seq1=[](源代码BUG)
  • getWordWeight中实现了平滑倒词频,函数中将当前词的词频和总词数从原来文件中读取,改为从Word2vec模型中读取
  • iteritems->items, xrange->range (python2->python3)
from __future__ import print_function

import numpy as np
import pickle
from src.tree import tree
import jieba
#from theano import config

def getWordmap(textfile):
    words={}
    We = []
    f = open(textfile,'r', errors='ignore')
    lines = f.readlines()
    for (n,i) in enumerate(lines):
        i=i.split()
        j = 1
        v = []
        while j < len(i):
            if i[j] == '.':
                v.append(0)
            else:
                v.append(float(i[j]))
            j += 1
        words[i[0]] = n
        We.append(v)
    return (words, np.array(We))

def prepare_data(list_of_seqs):
    lengths = [len(s) for s in list_of_seqs]
    n_samples = len(list_of_seqs)
    maxlen = np.max(lengths)
    x = np.zeros((n_samples, maxlen)).astype('int32')
    x_mask = np.zeros((n_samples, maxlen)).astype('float32')
    for idx, s in enumerate(list_of_seqs):
        x[idx, :lengths[idx]] = s
        x_mask[idx, :lengths[idx]] = 1.
    x_mask = np.asarray(x_mask, dtype='float32')
    return x, x_mask

def lookupIDX(words,w):
    w = w.lower()
    if len(w) > 1 and w[0] == '#':
        w = w.replace("#","")
    if w in words:
        return words[w]
    elif 'UUUNKKK' in words:
        return words['UUUNKKK']
    else:
        return len(words) - 1

def getSeq(p1,words):
    p1 = jieba.cut(p1)
    X1 = []
    for i in p1:
        X1.append(lookupIDX(words,i))
    return X1

def getSeqs(p1,p2,words):
    p1 = p1.split()
    p2 = p2.split()
    X1 = []
    X2 = []
    for i in p1:
        X1.append(lookupIDX(words,i))
    for i in p2:
        X2.append(lookupIDX(words,i))
    return X1, X2

def get_minibatches_idx(n, minibatch_size, shuffle=False):
    idx_list = np.arange(n, dtype="int32")

    if shuffle:
        np.random.shuffle(idx_list)

    minibatches = []
    minibatch_start = 0
    for i in range(n // minibatch_size):
        minibatches.append(idx_list[minibatch_start:
        minibatch_start + minibatch_size])
        minibatch_start += minibatch_size

    if (minibatch_start != n):
        minibatches.append(idx_list[minibatch_start:])

    return zip(range(len(minibatches)), minibatches)

def getSimEntDataset(f,words,task):
    data = open(f,'r')
    lines = data.readlines()
    examples = []
    for i in lines:
        i=i.strip()
        if(len(i) > 0):
            i=i.split('\t')
            if len(i) == 3:
                if task == "sim":
                    e = (tree(i[0], words), tree(i[1], words), float(i[2]))
                    examples.append(e)
                elif task == "ent":
                    e = (tree(i[0], words), tree(i[1], words), i[2])
                    examples.append(e)
                else:
                    raise ValueError('Params.traintype not set correctly.')

            else:
                print(i)
    return examples

def getSentimentDataset(f,words):
    data = open(f,'r')
    lines = data.readlines()
    examples = []
    for i in lines:
        i=i.strip()
        if(len(i) > 0):
            i=i.split('\t')
            if len(i) == 2:
                e = (tree(i[0], words), i[1])
                examples.append(e)
            else:
                print(i)
    return examples

def getDataSim(batch, nout):
    g1 = []
    g2 = []
    for i in batch:
        g1.append(i[0].embeddings)
        g2.append(i[1].embeddings)

    g1x, g1mask = prepare_data(g1)
    g2x, g2mask = prepare_data(g2)

    scores = []
    if nout <=0:
        return (scores, g1x, g1mask, g2x, g2mask)

    for i in batch:
        temp = np.zeros(nout)
        score = float(i[2])
        ceil, fl = int(np.ceil(score)), int(np.floor(score))
        if ceil == fl:
            temp[fl - 1] = 1
        else:
            temp[fl - 1] = ceil - score
            temp[ceil - 1] = score - fl
        scores.append(temp)
    scores = np.matrix(scores) + 0.000001
    scores = np.asarray(scores, dtype='float32')
    return (scores, g1x, g1mask, g2x, g2mask)

def getDataEntailment(batch):
    g1 = []; g2 = []
    for i in batch:
        g1.append(i[0].embeddings)
        g2.append(i[1].embeddings)

    g1x, g1mask = prepare_data(g1)
    g2x, g2mask = prepare_data(g2)

    scores = []
    for i in batch:
        temp = np.zeros(3)
        label = i[2].strip()
        if label == "CONTRADICTION":
            temp[0]=1
        if label == "NEUTRAL":
            temp[1]=1
        if label == "ENTAILMENT":
            temp[2]=1
        scores.append(temp)
    scores = np.matrix(scores)+0.000001
    scores = np.asarray(scores,dtype='float32')
    return (scores,g1x,g1mask,g2x,g2mask)

def getDataSentiment(batch):
    g1 = []
    for i in batch:
        g1.append(i[0].embeddings)

    g1x, g1mask = prepare_data(g1)

    scores = []
    for i in batch:
        temp = np.zeros(2)
        label = i[1].strip()
        if label == "0":
            temp[0]=1
        if label == "1":
            temp[1]=1
        scores.append(temp)
    scores = np.matrix(scores)+0.000001
    scores = np.asarray(scores,dtype='float32')
    return (scores,g1x,g1mask)

def sentences2idx(sentences, words):
    """
    Given a list of sentences, output array of word indices that can be fed into the algorithms.
    :param sentences: a list of sentences
    :param words: a dictionary, words['str'] is the indices of the word 'str'
    :return: x1, m1. x1[i, :] is the word indices in sentence i, m1[i,:] is the mask for sentence i (0 means no word at the location)
    """
    seq1 = []
    for i in sentences:
        seq1.append(getSeq(i,words))
    x1,m1 = prepare_data(seq1)
    return x1, m1


def sentiment2idx(sentiment_file, words):
    """
    Read sentiment data file, output array of word indices that can be fed into the algorithms.
    :param sentiment_file: file name
    :param words: a dictionary, words['str'] is the indices of the word 'str'
    :return: x1, m1, golds. x1[i, :] is the word indices in sentence i, m1[i,:] is the mask for sentence i (0 means no word at the location), golds[i] is the label (0 or 1) for sentence i.
    """
    f = open(sentiment_file,'r')
    lines = f.readlines()
    golds = []
    seq1 = []
    for i in lines:
        i = i.split("\t")
        p1 = i[0]; score = int(i[1]) # score are labels 0 and 1
        X1 = getSeq(p1,words)
        seq1.append(X1)
        golds.append(score)
    x1,m1 = prepare_data(seq1)
    return x1, m1, golds

def sim2idx(sim_file, words):
    """
    Read similarity data file, output array of word indices that can be fed into the algorithms.
    :param sim_file: file name
    :param words: a dictionary, words['str'] is the indices of the word 'str'
    :return: x1, m1, x2, m2, golds. x1[i, :] is the word indices in the first sentence in pair i, m1[i,:] is the mask for the first sentence in pair i (0 means no word at the location), golds[i] is the score for pair i (float). x2 and m2 are similar to x1 and m2 but for the second sentence in the pair.
    """
    f = open(sim_file,'r')
    lines = f.readlines()
    golds = []
    seq1 = []
    seq2 = []
    for i in lines:
        i = i.split("\t")
        p1 = i[0]; p2 = i[1]; score = float(i[2])
        X1, X2 = getSeqs(p1,p2,words)
        seq1.append(X1)
        seq2.append(X2)
        golds.append(score)
    x1,m1 = prepare_data(seq1)
    x2,m2 = prepare_data(seq2)
    return x1, m1, x2, m2, golds

def entailment2idx(sim_file, words):
    """
    Read similarity data file, output array of word indices that can be fed into the algorithms.
    :param sim_file: file name
    :param words: a dictionary, words['str'] is the indices of the word 'str'
    :return: x1, m1, x2, m2, golds. x1[i, :] is the word indices in the first sentence in pair i, m1[i,:] is the mask for the first sentence in pair i (0 means no word at the location), golds[i] is the label for pair i (CONTRADICTION NEUTRAL ENTAILMENT). x2 and m2 are similar to x1 and m2 but for the second sentence in the pair.
    """
    f = open(sim_file,'r')
    lines = f.readlines()
    golds = []
    seq1 = []
    seq2 = []
    for i in lines:
        i = i.split("\t")
        p1 = i[0]; p2 = i[1]; score = i[2]
        X1, X2 = getSeqs(p1,p2,words)
        seq1.append(X1)
        seq2.append(X2)
        golds.append(score)
    x1,m1 = prepare_data(seq1)
    x2,m2 = prepare_data(seq2)
    return x1, m1, x2, m2, golds

def getWordWeight(word2weight, a=1e-3):
    if a <=0: # when the parameter makes no sense, use unweighted
        a = 1.0

    # word2weight = {}
    # with open(weightfile) as f:
    #     lines = f.readlines()
    # N = 0
    # for i in lines:
    #     i=i.strip()
    #     if(len(i) > 0):
    #         i=i.split()
    #         if(len(i) == 2):
    #             word2weight[i[0]] = float(i[1])
    #             N += float(i[1])
    #         else:
    #             print(i)
    for key, value in word2weight.items():
        word2weight[key] = a / (a + value.count/value.sample_int)
    return word2weight

def getWeight(words, word2weight):
    weight4ind = {}
    for word, ind in words.items():
        if word in word2weight:
            weight4ind[ind] = word2weight[word]
        else:
            weight4ind[ind] = 1.0
    return weight4ind

def seq2weight(seq, mask, weight4ind):
    weight = np.zeros(seq.shape).astype('float32')
    for i in range(seq.shape[0]):
        for j in range(seq.shape[1]):
            if mask[i,j] > 0 and seq[i,j] >= 0:
                weight[i,j] = weight4ind[seq[i,j]]
    weight = np.asarray(weight, dtype='float32')
    return weight

def getIDFWeight(wordfile, save_file=''):
    def getDataFromFile(f, words):
        f = open(f,'r')
        lines = f.readlines()
        golds = []
        seq1 = []
        seq2 = []
        for i in lines:
            i = i.split("\t")
            p1 = i[0]; p2 = i[1]; score = float(i[2])
            X1, X2 = getSeqs(p1,p2,words)
            seq1.append(X1)
            seq2.append(X2)
            golds.append(score)
        x1,m1 = prepare_data(seq1)
        x2,m2 = prepare_data(seq2)
        return x1,m1,x2,m2

    prefix = "../data/"
    farr = ["MSRpar2012"]
    #farr = ["MSRpar2012",
    #        "MSRvid2012",
    #        "OnWN2012",
    #        "SMTeuro2012",
    #        "SMTnews2012", # 4
    #        "FNWN2013",
    #        "OnWN2013",
    #        "SMT2013",
    #        "headline2013", # 8
    #        "OnWN2014",
    #        "deft-forum2014",
    #        "deft-news2014",
    #        "headline2014",
    #        "images2014",
    #        "tweet-news2014", # 14
    #        "answer-forum2015",
    #        "answer-student2015",
    #        "belief2015",
    #        "headline2015",
    #        "images2015",    # 19
    #        "sicktest",
    #        "twitter",
    #        "JHUppdb",
    #        "anno-dev",
    #        "anno-test"]
    (words, We) = getWordmap(wordfile)
    df = np.zeros((len(words),))
    dlen = 0
    for f in farr:
        g1x,g1mask,g2x,g2mask = getDataFromFile(prefix+f, words)
        dlen += g1x.shape[0]
        dlen += g2x.shape[0]
        for i in range(g1x.shape[0]):
            for j in range(g1x.shape[1]):
                if g1mask[i,j] > 0:
                    df[g1x[i,j]] += 1
        for i in range(g2x.shape[0]):
            for j in range(g2x.shape[1]):
                if g2mask[i,j] > 0:
                    df[g2x[i,j]] += 1

    weight4ind = {}
    for i in range(len(df)):
        weight4ind[i] = np.log2((dlen+2.0)/(1.0+df[i]))
    if save_file:
        pickle.dump(weight4ind, open(save_file, 'w'))
    return weight4ind

移除纠正项SIF_embedding.py
可以看到这里的移除项是通过SVD奇异值分解训练出来的,类似于PCA主成分分析,可用于降维。
svd.components_是一个矩阵,每一行为主题在每个单词上的分布。我们可以通过这个矩阵得到哪些词对主题贡献最大。
接着,在remove_pc函数中将svd.components_这一项进行了移除,原文说的是:移出(减去)所有句子向量组成的矩阵的第一个主成分(principal component / singular vector)上的投影

修改部分:

  • get_weighted_average函数中修改加权向量的生成方式,改为python3的语法,测试显示,相同句子在原有代码python2环境下,和更改后代码在python3环境下,结果相同。
import numpy as np
from sklearn.decomposition import TruncatedSVD


def get_weighted_average(We, x, w):
    """
    Compute the weighted average vectors
    :param We: We[i,:] is the vector for word i
    :param x: x[i, :] are the indices of the words in sentence i
    :param w: w[i, :] are the weights for the words in sentence i
    :return: emb[i, :] are the weighted average vector for sentence i
    """
    n_samples = x.shape[0]
    emb = np.zeros((n_samples, We.shape[1]))
    for i in range(n_samples):
        # emb[i] = w[i].dot(np.array(We[x[i]])) / np.count_nonzero(w[i])
        for j in range(len(w[i])):
            emb[i] += w[i][j] * np.array(We[x[i]][j])
        emb[i] = emb[i] / np.count_nonzero(w[i])
    return emb

def compute_pc(X,npc=1):
    """
    Compute the principal components. DO NOT MAKE THE DATA ZERO MEAN!
    :param X: X[i,:] is a data point
    :param npc: number of principal components to remove
    :return: component_[i,:] is the i-th pc
    """
    svd = TruncatedSVD(n_components=npc, n_iter=7, random_state=0)
    svd.fit(X)
    return svd.components_

def remove_pc(X, npc=1):
    """
    Remove the projection on the principal components
    :param X: X[i,:] is a data point
    :param npc: number of principal components to remove
    :return: XX[i, :] is the data point after removing its projection
    """
    pc = compute_pc(X, npc)
    if npc==1:
        XX = X - X.dot(pc.transpose()) * pc
    else:
        XX = X - X.dot(pc.transpose()).dot(pc)
    return XX


def SIF_embedding(We, x, w, params):
    """
    Compute the scores between pairs of sentences using weighted average + removing the projection on the first principal component
    :param We: We[i,:] is the vector for word i
    :param x: x[i, :] are the indices of the words in the i-th sentence
    :param w: w[i, :] are the weights for the words in the i-th sentence
    :param params.rmpc: if >0, remove the projections of the sentence embeddings to their first principal component
    :return: emb, emb[i, :] is the embedding for sentence i
    """
    emb = get_weighted_average(We, x, w)
    if  params.rmpc > 0:
        emb = remove_pc(emb, params.rmpc)
    return emb

论文实验表明该方法具有不错的竞争力,在大部分数据集上都比平均词向量或者使用TFIDF加权平均的效果好,在使用PSL作为词向量时甚至能达到最优结果。
根据论文中的实验结果来看,在句子相似度任务上超过平均水平,甚至超过部分复杂的模型。在句子分类上效果也很明显,甚至是最好成绩。


经验之谈

  • 我用了词向量平均和TFIDF加权与SIF方法进行了对比,都采用100维的Word2vec词向量,做相似度匹配的任务,通过欧氏距离和余弦相似度度量向量之间的距离,但是SIF的效果却不如另外两种更简单的方法。
  • 另外,对于两个很相似的句子,SIF生成的向量的正负项截然相反。比如对于‘这是一个测试句子’和‘这是另一个测试句子’这两个非常相似的中文句子,生成的句向量每一项的正负值都刚好相反,这让我没有想明白。这应该不利于欧氏距离的计算,因为这两个句子的欧氏距离并不大。所以用了SIF方法最好用余弦距离进行相似度匹配。
这是一个测试句子
[ 0.07638578 -0.15427788  0.04004123 -0.11843429 -0.06013182  0.03942103
 -0.01382917  0.01305546  0.06177262 -0.02547832 -0.04165836  0.02171577
  0.03483471  0.05667425 -0.117093   -0.02521048 -0.00686271 -0.02931183
  0.05059035 -0.02502487 -0.00903647  0.00778577  0.01954736 -0.03124137
  0.10074088  0.02835767 -0.08591071 -0.05027893  0.09560275 -0.08829507
 -0.07332305 -0.06830808  0.09723447  0.01102427 -0.10592448 -0.01029612
  0.07102155 -0.03058108 -0.01676355 -0.06929373 -0.05900271 -0.05584531
 -0.00446632  0.07027014  0.14057033 -0.05284498 -0.02534611 -0.01722914
 -0.07428796 -0.05775267 -0.00475082  0.00043147 -0.0978087   0.08172205
 -0.10074747 -0.03555521 -0.08807748  0.07520326  0.01554954 -0.00893718
  0.07821482  0.00935646  0.0465772   0.00160614 -0.05490717 -0.01119706
 -0.04844879 -0.06298091  0.01656367  0.00719948  0.12924895 -0.00991099
  0.08364741 -0.00887778 -0.05152184  0.10083027  0.0076994   0.03921235
  0.00199744  0.0446614  -0.06055355  0.12712339  0...]
这是另一个测试句子
[-0.08652813  0.1747626  -0.04535782  0.13415977  0.06811601 -0.04465528
  0.01566538 -0.01478894 -0.06997467  0.02886128  0.04718968 -0.02459915
 -0.03946    -0.06419934  0.13264038  0.02855788  0.00777393  0.0332038
 -0.05730764  0.02834763  0.01023632 -0.00881955 -0.02214282  0.03538954
 -0.11411705 -0.03212295  0.09731777  0.05695486 -0.10829669  0.10001872
  0.08305874  0.07737789 -0.11014507 -0.01248805  0.11998893  0.01166321
 -0.08045165  0.03464158  0.01898938  0.07849441  0.06683698  0.06326034
  0.00505935 -0.07960047 -0.15923499  0.05986164  0.02871152  0.01951679
  0.08415177  0.06542096  0.00538162 -0.00048876  0.11079555 -0.09257294
  0.11412452  0.04027616  0.09977224 -0.08518861 -0.01761418  0.01012385
 -0.08860003 -0.0105988  -0.05276163 -0.0018194   0.06219764  0.01268379
  0.05488173  0.0713434  -0.01876297 -0.00815541 -0.14641037  0.01122695
 -0.09475394  0.01005656  0.05836281 -0.11421831 -0.00872171 -0.04441888
 -0.00226266 -0.05059145  0.06859373 -0.14400259 -0....]

之后可能会考虑将代码上传至github

你可能感兴趣的:(利用Word2vec生成句向量(二))