NNLM语言模型(原理、反向传播的推导以及python实现)

NNLM语言模型(原理、反向传播的推导以及python实现)

-1、写这篇博客的目的

  因为研究生选择了自然语言处理方向(NLP),之前对此没有过接触,所以在大四阶段准备对NLP方向的一些算法做一些了解。在阅读《文本数据挖掘》(宗成庆、夏睿、张家俊)这本书的时,发现上面介绍了文本数据挖掘方向的很多算法,但是并未具体的展开,进行详细的推导。

  由此,我萌生了将自己学习这本书的过程中对算法的理解、看法、问题以及源代码进行分享的想法。当然,如果我写的有什么问题或者有需要和我讨论的都可以私信联系我!!!

0、引言

  如何将文本用数学的方法表达出来,是文本挖掘的基础。我们很容易会想到,在文本的向量空间中可以很容易的实现本文的聚类、文档的聚类、近义词的查找、等等。对于一段话来说,将其进行数学表达的最有效的方法,应该是将其中的每一个词都映射到一个词向量空间当中————即将本文视作一个词的集合。但是,如何构建这个向量空间是很困难的一件事情。比如说,我爱你你爱我这两句话,他们当中的每个词都是一样的,但是词序的不同改变了整句话的意义。不难看出,构建词向量空间时不仅要考虑到每个词的词义,还要考虑到每个词在句子中的结构。

  这种掌握词的上下文信息的词语表达方法就是次分布表示。由这个思想,我们需要建立一个模型去得到词向量空间。这篇博客就主要介绍一个比较简单的词分布表示方法————神经网络语言模型(NNLM)。

1、NNLM(神经网络语言模型)

  2003年Bengio等人提出了神经网络语言模型。他的基本思想是已知前面n个词的情况下预测词典中所有次成为第n+1个词的概率,并以此为目标进行训练。

  这里提到一个词典(词袋)的概念,他就是包含这篇文章中可能出现的所有词。读者可能就会想,我怎么才能得到一个词袋呢,这里的解决方法就是将你自己要训练和测试的数据中出现的所有词做成一个词典(当然也可以将一个庞大的预料库中的的所有词预先保存下来作为词典)。

  如下图所示,NNLM模型每次将n个词组合成一个向量输入到模型当中(这个非常重要),这个模型的主体部分和BP神经网络大致相当,不同的是它使用的激活函数是tanh,输出采用了softmax函数进行归一化,并且目标函数是让第n+1个词对应的概率最大。

NNLM模型的具体流程如下:

1、 x = [ V [ w i ] V [ v i + 1 ] ⋯ V [ v i + n − 1 ] ] x = \left[ \begin{array}{c|c|c|c} V\left[ w_{i}\right] & V\left[ v_{i+1}\right] & \cdots & V\left[ v_{i+n-1}\right] \end{array} \right] x=[V[wi]V[vi+1]V[vi+n1]]

2、 h = t a n h ( U ∗ x + b 1 ) h={tanh(U*x+b^1)} h=tanh(Ux+b1)

3、 y = W ∗ h + b 2 y=W*h+b^2 y=Wh+b2

4、 p = exp ⁡ y ∑ i = 1 n exp ⁡ y i p=\frac{\exp y}{\sum_{i=1}^n{\exp y_i}} p=i=1nexpyiexpy

其中,公式1中的V表示向量空间,V[w_i]表示词w_i在向量空间中的表示,windows表示窗口数,整个公式1表示的是将windows个词向量拼接成一个列向量;公式4中的n表示词典中词的个数;U、 b 1 b^1 b1、W和 b 2 b^2 b2都是参数,需要随机初始化, b 1 b^1 b1 b 2 b^2 b2都是向量;读者可能会疑惑,V是从哪来的,这里说明一下,V一开始是我们按照词典的规模随机生成的,后续的反向传播过程中会对V空间进行更新。

  上述流程是对于一个窗口所截取的n+1个词最为输入得到的结果。但是整个NNML模型是对所有的文本进行训练,也就是说,窗口会从第一个词汇开始,往后滚动,每一次都会有一个输出p。而且之前也说过,该模型是以预测的第Window+1个词的概率最大为目的进行训练的。为了表示方便,这里结合窗口的第一个词的序号i表示该窗口下预测下一个词为 w i + w i n d o w w_{i+window} wi+window的概率:
P ( w i ) = p w i [ w i + w i n d o w ] P(w_i)=p_{wi}\lbrack wi+window\rbrack P(wi)=pwi[wi+window]

p w i [ w i + w i n d o w ] = exp ⁡ y w i + w i n d o w ∑ i = 1 n exp ⁡ y i p_{w_i}\lbrack wi+window\rbrack=\frac{\exp y_{w_{i+window}}}{\sum_{i=1}^n{\exp y_i}} pwi[wi+window]=i=1nexpyiexpywi+window

以预测的下一个字母的概率为目标,建立误差函数如下:

e = ∑ i M log ⁡ P ( w i ) e=\sum_{i}^{M}{\log{P(w_i)}} e=iMlogP(wi)

其中,M表示所可能的窗口数。该神经网络模型优化的目标是使e的值达到最大。

2、反向传播的推导

  前向传播过程中涉及一些需要更新的参数,例如W和b等;此外,向量空间V作为变量也是需要迭代更新的。通过最大似然的方法对参数进行更新,实质上就是求各个变量的偏导,在偏导方向上进行更新,我个人感觉和GD(最小二乘)差不多。


这里介绍一下对向量和矩阵求偏导
1、一个常数对一个向量求偏导

  令 x x x为常数, Y = ( y 1 , y 2 , ⋯   , y n ) Y=\left( y_1 , y_2 , \cdots , y_n \right) Y=(y1,y2,,yn)为一个n维列向量

  常数 x x x对向量 Y Y Y求导其实就是对 Y Y Y中各元素求导,组合得到一个列向量向量,即:

∂ x ∂ Y = ( ∂ x ∂ y 1 ⋯ ∂ x ∂ y n ) ′ \frac{\partial x}{\partial Y}=\left( \frac{\partial x }{ \partial y_1 } \cdots \frac{\partial x }{ \partial y_n } \right )' Yx=(y1xynx)

2、一个常数关于矩阵求导

 设 Y = ( y 11 , y 12 , ⋯   , y 1 n ⋯ y n 1 , y n 2 , ⋯   , y n n ) ​ Y=\left(\begin{aligned} y_{11} , y_{12} , \cdots , y_{1n} \\ \cdots \\ y_{n1} , y_{n2} , \cdots , y_{nn} \end{aligned} \right) ​ Y=y11,y12,,y1nyn1,yn2,,ynn那么, e ​ e​ e关于 Y ​ Y​ Y求导为一个矩阵

∂ x ∂ Y = ( ∂ x ∂ y 11 , ∂ x ∂ y 12 , ⋯   , ∂ x ∂ y 1 n ⋯ ∂ x ∂ y n 1 , ∂ x ∂ y n 2 , ⋯   , ∂ x ∂ y n n ) \frac{\partial x}{\partial Y}=\left(\begin{aligned} \frac{\partial x }{ \partial y_{11} } , \frac{\partial x }{ \partial y_{12} } , \cdots , \frac{\partial x }{ \partial y_{1n} } \\ \cdots \\ \frac{\partial x }{ \partial y_{n1} } , \frac{\partial x }{ \partial y_{n2} } , \cdots , \frac{\partial x }{ \partial y_{nn} } \end{aligned} \right) Yx=y11x,y12x,,y1nxyn1x,yn2x,,ynnx

  。所以,归根结底,所谓对矩阵或者向量求偏导,可以看是对函数求导,矩阵或者向量中的元素就是函数的变量;如果函数的输出是多维的,那么对应求导的结果也是多维且求导结果对应的就是各个维度函数输出的求导结果。


3、tanh函数

   t a n h x tanh{x} tanhx是双曲正切函数,定义如下:
t a n h ( x ) = sinh ⁡ x cosh ⁡ x = exp ⁡ x − exp ⁡ − x exp ⁡ x + exp ⁡ − x tanh(x)=\frac{\sinh{x}}{\cosh{x}}=\frac{\exp{x}-\exp{-x}}{\exp{x}+\exp{-x}} tanh(x)=coshxsinhx=expx+expxexpxexpx
对双曲正切函数进行求导可得
tanh ⁡ ( x ) = ( exp ⁡ x + exp ⁡ − x ) 2 − ( exp ⁡ x − exp ⁡ − x ) 2 ( exp ⁡ x + exp ⁡ − x ) 2 \tanh(x)=\frac{\left( \exp{x}+\exp{-x} \right)^2 - \left( \exp{x}-\exp{-x} \right)^2 }{\left( \exp{x}+\exp{-x} \right)^2} tanh(x)=(expx+expx)2(expx+expx)2(expxexpx)2
即:
tanh ⁡ ( x ) = cosh ⁡ 2 ( x ) − sinh ⁡ 2 ( x ) cosh ⁡ 2 ( x ) = 1 − tanh ⁡ 2 ( x ) \tanh(x)=\frac{\cosh^2(x) - \sinh^2(x)}{\cosh^2(x)}=1-\tanh^2(x) tanh(x)=cosh2(x)cosh2(x)sinh2(x)=1tanh2(x)


  
首先对 e e e关于 y y y求导,这个过程比较简单,就是把 e e e y y y中元素表示,并对 y y y中各个元素求导:

∂ e ∂ y i = { 1 − p i , w i + w i n d o w = = i − p i , e l s e \frac{\partial e}{ \partial y_i } = \left\{ \begin{aligned} 1 - p_i , w_{i+window}==i \\ -p_i , else \end{aligned} \right. yie={1pi,wi+window==ipi,else
再关于 y y y b b b进行求导,这个过程其实就是将 y y y中各个元素用 b b b中元素表示出来再求导。因为 y y y中各个位置元素只和 b b b中对应元素有关,所以不存在链式关系,结果如下:

∂ y ∂ b 2 = 1 \frac{\partial{ y }}{\partial{ b^2}} = 1 b2y=1

  整理得到:

∂ e ∂ b 2 = ∂ e ∂ y \frac{\partial{ e }}{\partial{ b^2}} = \frac{\partial e}{ \partial y} b2e=ye

  再对 e e e关于 W W W进行求导,考虑到 y y y中每一行元素只和 W W W中对应的行元素有关,就对 W W W的第 j j j行元素 W j W_j Wj求导:
∂ e ∂ W j = ∂ e ∂ y j ⊙ h ′ \frac{\partial e}{ \partial W_j } = \frac{\partial e}{ \partial y_j } \odot h' Wje=yjeh

  接下来考虑 y y y h h h的关系,因为 y y y中每一个元素都和 h h h有关,那么 e e e关于 h h h的导数需要考虑链式法则,即 y y y关于 x x x的导数需要将 y y y中各个元素对 h h h求导再累加起来。有人可能会有疑惑, y y y明明是一个向量,向量关于向量的导数难道不是矩阵吗?为什么要累加?事实上,这里是考虑的 e e e关于h的导数, e e e的计算是将 y y y中所有元素进行计算得到的, y y y中每一个元素都和 h h h有关。所以,由链式法则, e e e h h h的导数等于 e e e关于 y y y所有元素的导数分别乘上对应位置 y y y中元素对 h h h的导数再进行累加的结果。

∂ e ∂ h = W ′ ⋅ ∂ e ∂ y ′ \frac{\partial e}{ \partial h } = W' \cdot \frac{\partial e}{ \partial y } ' he=Wye

  这里, h h h b b b进行求导,考虑最终是 e e e b b b求导,所以得到的是向量:

∂ h ∂ b 1 = ( 1 − h 2 ) \frac{\partial h }{\partial b^1 }=\left(1 - h ^ 2 \right) b1h=(1h2)

  隐藏层在tanh函数之后的模式和前面输出层大体相同,就不过多解释,读者记住牢记链式法则进行思考应该不难推导。直接上公式:

∂ e ∂ b 1 = ∂ e ∂ h ⊙ ∂ h ∂ b 1 = ( ∂ e ∂ y ⋅ W ) ⊙ ( 1 − h 2 ) \frac{\partial e }{\partial b^1 }= \frac{\partial e }{\partial h } \odot \frac{\partial h }{\partial b^1 } = \left( \frac{\partial e}{ \partial y } \cdot W \right) \odot \left(1 - h ^ 2 \right) b1e=heb1h=(yeW)(1h2)

∂ e ∂ H j = ∂ e ∂ b j 1 ⋅ x ′ \frac{ \partial e }{ \partial H_j } = \frac{ \partial e }{ \partial b^1_j } \cdot x' Hje=bj1ex

∂ e ∂ x = H ′ ⋅ ∂ e ∂ b 1 ′ \frac{\partial e}{\partial x} = H' \cdot \frac{\partial e}{\partial b^1}' xe=Hb1e

  最后,各个参数的更新,就是将变量加上各个 e e e关于各个变量的偏导(原论文上加入了一个调节参数学习率,但直接加问题也不大),要注意的是, x x x加完之后要在向量空间 V V V中替换对应位置的元素。PS:因为是最大化目标函数,所以应该是在梯度方向搜索,而不是负梯度

3、code

  代码部分是用python整个的思路是面向过程的,比较简单,但是没有用TensorFlow或者pytorch之类的工具,反向传播的过程是手写的。前面字符的停词化处理用了python里的jieba包、去停用词用的停词表我后面也会附上,但是这些预处理做的都比较简单,主要是想复现一下这个算法。

import jieba
import numpy
import math

def stop_set( path ) :
    f = open(path, 'r', encoding='utf-8')
    L = []
    for word in f.readlines():
        L.append(word.strip())#默认的方法去除换行符

    f.close()
    return L

#对文本进行去停词化和分词
def data_set(path , L ):
    #L是提取的停词表
    #这里输入的只是数据所在的文件夹名,
    # 统一规定文本文件的名字为data.txt
    file_name = path+"/data.txt"

    #注意,读取中文时记得规定解码方式
    f = open(file_name,'r',encoding='utf-8')

    #按行进读取输出
    line = f.read()

    R = ''
    for i in line :
        R += i

    #进行分词化
    seg_ment = jieba.cut( R )

    W = []
    tempt = []
    # 去停词
    for word in seg_ment:
        if word == '。' :
            #以。作为句子结尾的标记
            #进行以句子为单位的划分
            W.append( tempt )
            tempt = []

        if word not in L :
            #对每一行的每一个字,如果不在停词表中
            tempt.append(word)

    f.close()
    return  W

#建立词典
def Dic_set( W ) :
    #W是去停词化和分词化后的文本
    #首先将所有的句子合并到一起
    tempt = []
    for i in W :
        for j in i :
            tempt.append( j )

    W = tempt

    #建立词典
    Dic = []
    while W :
        word = W[ 0 ]
        Dic.append( word )
        num = W.count( word )
        for i in range( num ) :
            #删除所有第一个元素
            W.remove( word )

    return  Dic

#文本转化为词典中对应的序号
def change( W , Dic ) :
    n = len( W )
    D = []
    for i in W :
        tempt = []
        for j in i :
            tempt.append( Dic.index( j ) )
        D.append( tempt )
    return D

def NNLM( V , W , n ) :
    alpha = 0.5 #学习率
    #V是向量空间,行是不同的单词,列是不同的维度
    #W是文本,不同行是不同的句子
    #n是窗口大小
    m = len( V.T )
    hiden_num = 100

    #初始化参数
    H = numpy.random.rand( hiden_num , n * m ) - 0.5 #输入层到输出层的权重
    U = numpy.random.rand( len(V) , hiden_num ) - 0.5
    d = numpy.random.rand( hiden_num , 1 ) - 0.5
    b = numpy.random.rand( len(V) , 1 ) - 0.5

    for i in W :
        for j in range( 0 , len( i ) -n ) :
            #生成x
            x = []
            for p in range( j , j + n ) :
                for q in range( 0 , m ) :
                    x.append( V[ i[ p ] , q ] )
            x = numpy.mat( x ).T

            # 前向传播
            o = d + numpy.dot(H, x)
            a = tanh( o )
            y = b + numpy.dot(U , a)
            p = exp( y )

            #反向传播
            delta_b = -1 *  p / p.sum()
            delta_b[ i[ j + n ] ] += 1

            delta_U = 1 * U
            for p in range( len( U ) ) :
                delta_U[ p , : ] = delta_b[ p ] * a.T

            delta_d = ( 1 - numpy.power( a , 2 ) )
            delta_d = numpy.multiply( delta_d , ( numpy.dot( delta_b.T , U ).T ) )
            # delta_d = delta_o
            delta_H = 1 * H
            for p in range( 0 , len(H) ):
                delta_H[ p , : ] = delta_d[ p ] * x.T
            delta_x = numpy.dot( H.T , delta_d )

            #更新变量
            b += delta_b * alpha
            d += delta_d * alpha
            U += delta_U * alpha
            H += delta_H * alpha
            x += delta_x * alpha
            #将更新后的变量x中的数值放回V中更新
            for p in range( 0 , n ) :
                V[ i[p] , : ] = 1 * x[ p * n : ( p + 1 ) *n ].T
    return V

def tanh( x ) :
    #这里输入的是列向量
    n = len( x )
    y1 = exp(x) - exp(-x)
    y2 = exp(x) + exp(-x)
    y = []
    for i in range(n):
        y.append(y1[i, 0] / y2[i, 0])

    return numpy.mat( y ).T

def exp( x ) :
    y = 1 * x#赋值给y,这样后面的操作不会因为地址相同影响到x
    #注意,这边传入的形参是地址或者说是指针
    for i in range( len( x ) ) :
        if x[i] > 100 :
            y[i] = math.exp(100)
        else :
            y[i] = math.exp(x[ i ])
    return  y

if __name__ == "__main__" :
    print("*==================================开始==========================================*")
    L = stop_set( "E:\作业\data\stopwords-master\cn_stopwords.txt" )#提取停词表
    path = "E:/作业/data"
    file_name = path + "/data.txt"
    W = data_set( path , L )#去停词化和分词化
    print( W )
    Dic = Dic_set( W )#建立词典

    W = change( W , Dic )#将W转换为Dic中对应的序号表示
    # print( W )

    #生成词向量矩阵
    n = len( Dic )
    print( n )
    m = 5 #给定向量维度为5
    V = numpy.random.rand( n , m ) - 0.5

    window = 5 #设置窗口数为5
    print("==============rawV==================")
    print(V)
    V = NNLM( V , W , window) #通过神经网络算法对向量V进行训练
    print("==============V==================")
    print(V)

    print( Dic )
    print( len( Dic) )



代码中需要的停词表我已经上传,审核过后我会附上链接停词表和训练样本;当然停词表和训练的文本可以用自己的。如果下载有问题可以私信找我要!!!

4、问题

ps:之前的代码除了一点问题,现在已经更新了。原因有两点“1、python的数据结构里面,像矩阵、列表这样的数据类型,直接使用A=B进行赋值是进行的地址复制,即A和B的地址是相同的,指向同一个内存,解决方法在这里:python矩阵类型的变量给另一个变量赋值;2、python里面把矩阵、列表这类的数据结构看作是数组一样的东西,作为函数形参时,输入的就是一个指针,这也是说,如果在子函数里面对形参数组进行改变,在主函数中的数组也是会变化的。
最后考虑了这两点,把代码修改好了。

你可能感兴趣的:(笔记,自然语言处理)