65.9K
CodeProject 正在变化。 阅读更多。
Home

[教程 2] Lina 聊天机器人 - 使用 CKY 解析和网络爬虫进行问答

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (5投票s)

2017 年 9 月 12 日

CPOL

11分钟阅读

viewsIcon

15962

downloadIcon

518

使用自然语言解析和网络爬虫进行问答的聊天机器人

系列介绍

tut1 基于检索的聊天机器人

这是关于自然语言和机器学习系列教程的第二部分,我们将手动创建一个能够

  • 与用户聊天
  • 回答他们的问题
  • 提取用户数据
  • 执行某些操作(推荐电影、告知时间……)的聊天机器人。

本系列的 목표 不仅仅是创建一个聊天机器人,而是通过一个实际项目来展示 NLP 和 ML 的应用。我的主要目标是以有趣的方式展示 ML 和 NLP,即创建一个聊天机器人。

在本系列中,我们将介绍一些技术,包括简单和高级的技术,以达到我们的最终目标。它们是

  • 使用 TF-IDF 进行文档检索
  • 使用 Word2Vect 进行文档检索
  • CKY 解析
  • 网页抓取

我希望通过这个系列,您能通过这个有趣的项目爱上 NLP 和 ML。

我们想把我们的聊天机器人命名为 Lina :D

引言

这是本系列关于如何构建聊天机器人的第二部分。今天,我们将讨论如何构建一个系统来

  1. 识别普通句子中的问题
  2. 在线查找答案

要查看描述如何创建基于检索的聊天机器人的最后一个教程,请访问此 链接

我们将主要使用两种技术来实现这一点

  1. 使用名为 CKY 的算法进行自然语言解析
  2. 使用 BeautifulSoup 库从多个网站进行网页抓取

在本教程中,我们将简要介绍解析(Parsing)作为通用概念,并简要介绍 CKY 自然语言解析算法的工作原理,**然后**我们将开始有趣的部分,着手构建我们的聊天机器人,以利用解析和网页抓取。

附带两个文件

  1. 仅 tut2
  2. tut1 + tut2

技术背景议程 (a)

如果您需要了解更多关于该主题的信息,此部分是可选的。如果您需要开始使用代码,请查看 实现

  1. 为什么选择解析而不是分类或简单的正则表达式?
  2. 为什么选择解析
  3. 什么是 PCFG?
  4. 什么是 CKY 解析算法?

实现议程 (b)

如果您想跳过技术背景并直接开始实现,请查看以下链接

  1. stat_parser 的 CKY 算法
  2. 解析以获取树状输出,然后遍历以获取字符串
  3. 通过已知解析进行正则表达式匹配
  4. 调用 Duck Duck Go API
  5. 从 Yahoo Answers 进行网页抓取
  6. 在 while 1 循环中调用函数
  7. 连接到上一个教程

参考文献

  1. emilmont 的 CKY 实现
  2. Dan Jurafsky & Chris 的 NLP 播放列表

  1. NLTK
  2. emilmont 的 CKY 实现
  3. Beautiful Soup

技术背景

1a - 为什么选择解析而不是分类或简单的正则表达式?

我们需要识别问题,因此,如果使用**分类技术**(例如任何分类算法,如朴素贝叶斯),我们将需要对问题结构(例如“有多少”、“什么颜色”、“谁是……”之类的问题)进行训练。

但这并非全部,因为您的分类器必须理解

  • "how much" 和 "how many" 属于同一类别,而
  • "how old" 和 "how big" 属于同一类别,
  • "what is" 和 "who is" 属于同一类别。

这将导致需要大量的训练句子,或者如果您尝试使用正则表达式,您将需要大量的实际上是相同的规则。

因此,分类和简单的正则表达式这两种方法都无法达到很高的效率。

2a - 为什么选择解析?

如我们在前面的例子中看到的

  • "how much" 和 "how many" 实际上是相同的,因为 "much" 和 "many" 都是形容词。
  • "how old" 和 "how big" 实际上是相同的,因为 "old" 和 "big" 都是形容词,实际上这与前一点相同。
  • "what is" 和 "who is" 实际上是相同的,因为 "much" 和 "many" 都是形容词。

所以我们可以将(big, old...)等实际词语替换,并将它们替换为另一个词语(称为终结符),该词语描述该词语是形容词。

这正是解析所做的,它试图知道这个词属于什么,是名词、动词、形容词还是副词。

这项任务称为自然语言解析,解析有很多用途,就像任何高级语言都有自己的解析系统将其转换为汇编代码一样,但对于我们这里的任务,我们将使用解析,但应用于自然语言。

有一个著名的算法叫做 CKY,但它与 PCFG 一起工作,那么 PCFG 是什么?

3a - 什么是 PCFG?

PCFG 代表概率上下文无关文法。

  1. "文法"(grammar)指的是语言中的词语排列方式,即某种语言的规则。
  2. "上下文无关"(context free)指的是文法是以非交叉的方式构建的。
  3. "概率"(probability)指的是某个文法规则相对于其他规则的概率。

此处的所有规则构成我们所谓的文法。

它简单地描述了有两种方法可以构成一个主语。

  1. 当 NP(名词短语)后面跟 VP(动词短语)时。这将构成一个主语,并且此规则的概率为 0.9。
  2. 仅通过动词短语,就可以构成一个主语,概率为 0.1。

对于所有其他非终结符也同样如此。

4a - 什么是 CKY 解析算法?

CKY 解析算法代表解析自然语言句子的方法,它是使用动态编程开发的,它使用我们之前讨论过的 PCFG

我们将简要介绍其构建的基本概念,因为构建 CKY 算法本身并不是我们的目标,我们的目标是将其用于聊天机器人,但了解其构建方式非常重要,所以让我们开始吧!

CKY 正常工作有两个主要方面

  1. **CKY** 只使用二元 PCFG(右侧只有 2 个术语),因此如果您有一个右侧有 2 个以上术语的规则,您必须将其分解为多个二元规则。
  2. 假设我们有一个字典,其中包含每个单词以及它是名词、动词、形容词等的概率。

假设我们需要解析句子 "people fish in the sea"。为了简化,我们将只解析句子 "people fish" 的情况。

假设给定的文法(PCFG 文法)是

CKY 使用一种称为解析三角形(chart)的东西。它只是可视化 CKY 如何解析句子的一种方式。

在这里,我们假设

  1. People(来自预定义单词及其概率的字典)是
    • 名词短语,概率为 0.35。
    • 动词,概率为 0.1。
    • 名词,概率为 0.5 ==> 这很有道理,因为 "people" 毕竟是名词。
  2. Fish(来自预定义单词及其概率的字典)是
    • 动词短语,概率为 0.06。
    • 名词短语,概率为 0.14。
    • 动词,概率为 0.6 ==> 这很有道理,因为 "fish" 毕竟是动词。
    • 名词,概率为 0.2。

但是,在选择终结符对(VP, NP, V, N)时,我们不只是选择概率最大的,**而是**我们测试**所有可能的**文法规则对以获得最大值。

所以在上面的例子中,我们选择开始测试 **NP → NP NP**,因为 "people" 和 "fish" 都可以是 NP(当然,概率不同)。

所以我们将进入下一个更高的级别,以获得该规则可能生成的概率,为此,我们相乘

  1. people 是 NP 的概率
  2. fish 是 NP 的概率
  3. 规则 **NP → NP NP** 本身的概率。

这将得到 0.35 x 0.14 x 0.1 = 0.0049。

但我们不会就此止步,我们必须继续,直到我们遍历 "people fish" 可以构成的所有可能规则。

所以,让我们选择 **VP → V NP**。这将产生 0.007(我们简单地将 NP 写成我们之前的猜测)。

然后继续选择 NP 和 VP 来构成 S。

**S → NP VP**,这将产生 0.0189 的概率(我们也写下之前的猜测)。

**但是**在

**CKY** 中,我们也考虑了单元 PCFG,因此在每个步骤(在第一个非终结符
步骤之后,您会检查此规则的输出是否可以通过单元规则生成)。

**S → VP**,概率为 0.0007。

所以我们有两种构成 S 的方法

  1. 要么使用 NP 和 VP,概率为 0.0189;
  2. 要么仅使用已生成的 VP,概率为 0.0007。

所以我们只使用概率最高的那个,而丢弃另一个。

因此,现在,在遍历了所有可能的组合后,我们得到 3 个终端作为输出,3 个可能的猜测如何解析 "people fish"。

  1. **名词短语**,概率为 **0.0049**。
  2. **动词短语**,概率为 **0.007**。
  3. **完整句子**,概率为 **0.0189**。

在这里,CKY 将选择将 "people fish" 解析为**完整句子**,该句子由名词短语 "people" 和动词短语 "fish" 构成。

然后,我们通过整个解析三角形使用这个概念,直到到达其顶部。在每一步,我们都会进入上一个级别,直到到达三角形的顶部。通过这种方式,我们将实现一种以**平方时间**而不是**指数时间**解析句子的方法。


实现

1b - stat_parser 的 CKY 算法

emilmont 在 上开发了一个非常智能的 CKY 解析实现,地址为 github。它非常独特,因为它不包含预定义的 pcfg,而是从 QuestionBank 和 Penn treebanks 中学习。

这使得学习到的 PCFG 非常真实,而不是为每个规则设置简单的任意概率,而是真实学习到的值。这完美地反映了此代码输出的结果。

2b - 解析以获取树状输出,然后遍历以获取字符串

首先,我们将导入 cky 和 nltk 库的代码,因为 NLTK 将有助于存储树。

# _____for parsing_____
from stat_parser.parser import Parser, display_tree
import nltk 

让我们定义一个函数,该函数接受一个句子并以树状图显示解析输出。

def parse(sentence):

    # first we create a parser obj
    parser = Parser()
    
    # put parsing in a try and catch 
    try:
        # actually parse the sentence
        tree = parser.parse(sentence)
    except:
        return False, ""

    # display the tree as string
    print tree

    # display the tree in a gui form
    display_tree(tree) 

当解析 "can you tell me who is Obama" 时,这就是树状 GUI。

但是

为了实际处理输出,如果我们能获得仅

- MD - PRP - VB - PRP - WP - VBZ - NNP -

这样的输出,将会更容易。因此,我们需要遍历树,所以我们使用递归函数进行遍历。

def parse(sentence):

   parser = Parser()

   try:
     tree = parser.parse(sentence) 
   except: 
     return False, ""
 
   print tree
   display_tree(tree)

   #just call the function through passing tree[i]

    for i in range(len(tree)):
        traverse(tree[i], 0)

现在让我们看看函数本身。

# this function uses a global variable array to store the leaves
tree_output = []

# and a dictionary to save nouns
imp_list_array = {'Noun': []}


def traverse(parent, x):
    # all our code is written in a try catch clauses
    try:
        # we loop through all children within the passed node
        for node in parent:

            # if this node is a tree , then loop till you reach the leaves
            if type(node) is nltk.Tree:

                # if the node is the root  ==> do nothing, 
                # as we will have reached our final goal
                if node.label() == 'ROOT':
                    # "======== Sentence ========="
                    # print "Sentence:", " ".join(node.leaves()) , " +  
                    # type " , node.label()
                    a = 6


                # else , call the function again on this node ==> till reaching leaves
                else:
                    element_type = node.label()
                    element_value = node.leaves()[0]
                    element_sentence = node.leaves()

                    # just a condition i have written t extract nouns
                    if str(element_type) == 'NN' or str(element_type) == \
                           'NNS' or str(element_type) == 'NNP' or str(
                            element_type) == 'NNPS':
                        imp_list_array['Noun'].append(str(element_value))

                   
                    # call the function again , recursively
                    traverse(node, x)
            else:
                # actually add the leaves to the array 
                tree_output.append(parent.label())
 


    # if the above code failed , simply do nothing
    except:

        tree_output.append('NN')

由于我们使用了全局变量,因此最好在每次解析时清除这些变量。

def parse(sentence):
    while len(tree_output) > 0:
        tree_output.pop()

    parser = Parser()
    try:
        tree = parser.parse(sentence)
        #print tree
    except:
        return False, ""

    # display_tree(tree)
    #print("parse succeeded")

    for i in range(len(tree)):
        traverse(tree[i], 0)

3b - 通过已知解析进行正则表达式匹配

def parse(sentence):
    while len(tree_output) > 0:
        tree_output.pop()

    parser = Parser()
    try:
        tree = parser.parse(sentence)
         
    except:
        return False, ""  

    for i in range(len(tree)):
        traverse(tree[i], 0)
 
    tree_output_str = ""

    # first we would convert the array to a string 
    for a in tree_output:
        tree_output_str += " - " + a

    print  tree_output_str

    # here we would save the parses that we are interested in
    special_parses = [
        "WRB - JJ - NNS",  # how many Leopards
        "WRB - JJ - JJ",  # how many leopards
        "WRB - JJ - VBP - DT - NN",  # how big are the pyramids
        "WRB - JJ - VBZ - JJ",  # how old is obama
        "WRB - JJ - VBZ - NNP",  # how old is Obama
        "WRB - JJ - NN - VBP - NNP - VBP",  # how much money do Bill have
        "WRB - VBP - DT - NN",  # where are the pyramids
        
        "WP - VBD - DT - NN",  # who won the champions last week  #when was the 
                                                                  #tv first invented
     
        "WP - VBP - DT - NN",  # what are the pyramids
        "WP - VBZ - DT - NN - IN - NN",  # what is the capital of egypt

        "WDT - NNS",  # which companies are the biggest ,
        "WRB - VBZ - NN",  # where is egypt
        "WRB - VBZ - DT - NN" ,    # where is the usa

        "WP - VBZ - NNP",  # what is Egypt
        "WP - VBZ - JJ",  # what is egypt
        "WRB - VBD - NNP",  # when did Bayern
        "WP - VBZ - NN" ,  # what is indonesian      

         "WP - VBZ - DT - JJ - NN - IN - NN"  #who is the current president of usa
    ]

    try:
        # add all elements in special_parses as a regex expression
        # putting "|" between them
        regex = reduce(lambda x, y: x + "|" + y, special_parses)
         
        # do the actual regex
        # and get the position where the question is within the sentence
        # as a user can ask "could you tell me who is Obama"
        # we would only need to get "who is Obama"
        pos_tree_output = tree_output_str.index\
                          (re.search(regex, tree_output_str).group(0))
        pos_var = len(tree_output_str.replace('-', '').split()) - len(
            tree_output_str[pos_tree_output:].replace('-', '').split())
         
        # do the extraction itself from the index known from previous lines
        fact_question = ' '.join(sentence.split()[pos_var:])

        print("it is a fact question")
        # ----scrap solution from Internet-------#
        # ----we would scrap from 2 sources------#
        
        # duck duck go
        answr_text = look_in_duck_go(fact_question)
                                                                       
        #if not found in duck duck go look inside yahoo answers
        if answr_text=="":
            # yahoo answers
            answr_text = look_in_yahoo_answers(fact_question)
            
        return True, answr_text

    except:
        print("not fact question")
        return False, "not fact question"

4b - 调用 Duck Duck Go API

Duck Duck Go 提供了一个简单的 API(甚至不需要 API 密钥)来获取问题的答案,但我们首先必须导入一些库。

# _____for scrapping_____
import re
from bs4 import BeautifulSoup
import requests

Duck Duck Go 以 XML 或 JSON 的形式返回答案,我们将选择 XML,并使用 BeautifulSoup 从中提取数据。

XML 响应将如下所示:

我们将提取 abstract 标签和 image 标签。

但是如果 abstract 标签为空,我们将尝试查找 text 标签。

现在让我们编写函数本身。

def look_in_duck_go(fact_question):
      
      try:
          # we would call the api , attach the question , ask for xml format
          base_address = "https://api.duckduckgo.com/?q=" + fact_question + "&format=xml"
 
          # get the webpage
          super_page = requests.get(base_address)

          # pass it to Beautiful soap
          soup_super_page = BeautifulSoup(super_page.content, "xml")


          # extract all Abstract tag , and use the first one
          answer = soup_super_page.findAll('Abstract')[0].text

          # extract the image
          Image = soup_super_page.findAll('Image')[0].text

          # if the abstract tag is empty , look for the text tag
          if (answer == ""):
              answer = soup_super_page.findAll('Text')[0].text
          print("found in duck duck go")
      
     except:
          answer=""
      return  answer

5b - 从 Yahoo Answers 进行网页抓取

Duck Duck Go 只回答 "what" 和 "who" 类型的问题,因此还有许多其他问题未得到解答,因此我们将使用 Yahoo Answers 来解决这个问题,鉴于其庞大的社区,大多数问题都可以找到。

这是您可以使用 Yahoo Answers 的方式:

https://answers.search.yahoo.com/search?fr=uh3_answers_vert_gs&type=2button&p=***your_question***

这将返回:

我们将使用第一个链接中的答案。

这是 HTML 中的链接:

 <a class="<code> lh-17</code> fz-m"
 href="https://answers.yahoo.com/question/index;_ylt=AwrC1C7U5rdZzxMA1QFPmolQ;
_ylu=X3oDMTEybTFvb2wxBGNvbG8DYmYxBHBvcwMxBHZ0aWQDQjI1NTlfMQRzZWMDc3I-?
     qid=20091221094648AAFlRbE" 
referrerpolicy="unsafe-url" target="_blank" id="yui_3_10_0_1_1505224373621_408">

<b>how</b> 
<b id="yui_3_10_0_1_1505224373621_431">many</b> 
<b>pyramids</b>
<b>in</b> 
<b id="yui_3_10_0_1_1505224373621_407">Egypt</b>
?

</a>

真正重要的是类名,即 `lh-17`。

我们提取链接(href)。

所以我们的函数将是:

def look_in_yahoo_answers(fact_question):

      # to replace empty spaces with %20
      fact_question = fact_question.replace(' ' , '%20')

      # write the website path
      base_address ="https://answers.search.yahoo.com/search?
                     fr=uh3_answers_vert_gs&type=2button&p="+fact_question
     
      #access it
      super_page = requests.get(base_address) 

      # load it into BeautifulSoup
      soup_super_page = BeautifulSoup(super_page.content, "lxml")

      # get the first link ==> <code>lh-17
     </code># and extract the href attribute
      answr_link = soup_super_page.findAll('a', {'class' : 'lh-17'})[0].attrs["href"]

      #--------------------------------------------------------#
      #--------------- we still need to get real answer--------#
      #--------------------------------------------------------# 

      return answr_text
  1. 然后,在获得答案链接后,它看起来将如下所示:

我们需要获得最佳答案,在 HTML 中看起来如下:

<span itemprop="text" class="<code>ya-q-full-text</code>" 
id="yui_3_17_2_5_1505225015420_1884">
 
There are more than one hundred pyramids. <br>
The reason the exact number is not fixed is that
 there are unfinished or not in good shape pyramids. 
So it is debatable for some of them if they would be counted as pyramids or not. <br>
The perfect ones are the three pyramids of Giza. 

</span>

我们感兴趣的是类名 `ya-q-full-text`。

所以我们提取它的文本。

def look_in_yahoo_answers(fact_question):
      fact_question = fact_question.replace(' ' , '%20')

      base_address ="https://answers.search.yahoo.com/search?
                     fr=uh3_answers_vert_gs&type=2button&p="+fact_question
     
      super_page = requests.get(base_address)#, headers=headers)

      soup_super_page = BeautifulSoup(super_page.content, "lxml")

      answr_link = soup_super_page.findAll('a', {'class' : 'lh-17'})[0].attrs["href"]

      #-------------------------------------------------#

      # get the page corresponding to the link we have just extracted
      answer_page = requests.get(answr_link)

      # load page into BeautifulSoup
      soup_answer_page = BeautifulSoup(answer_page.content, "lxml")

      # extract the answer
      answr_text = soup_answer_page.findAll('span', 
                   {'class' : '<code>ya-q-full-text</code>'})[0].text

      print("found in yahoo answers")

      # return the answer
      return answr_text

6b - 在 while 1 循环中调用函数

让我们将以上所有代码放入一个用户友好的界面中。

while 1:

    # empty the global variables
    tree_output=[]
    tree_output_str =""

    fact_question = raw_input("talk to Lina : ")

    # just call the parse function
    answr_text = parse(fact_question)


    print answr_text
    print

在这里,我们将创建一个简单的 Python 脚本,它将使用 tut1 和 tut2 来创建一个更完整的聊天机器人。这个聊天机器人现在将能够:

  1. 使用检索模型和 TF-IDF(有关更多信息,请参阅上一个教程)与用户聊天。
  2. 使用解析和网页抓取回答用户的问题。

所以,让我们从一个名为 `tut_1_2` 的 Python 脚本开始。

import tf_idf # tut1
import parsing_scrapping  # tut2

**我们将注释掉 tf_idf 和 parsing_scrapping 中的所有 while 1(禁用内部代码中的 while 1),并在 `tut_1_2` 中创建一个新的 while 1。**

在 TF-IDF 之前,我们将先从解析模块开始,因为如果问题是事实性问题,该模块会轻松将其识别为事实性问题,而 TF-IDF 会尽力将其作为普通聊天来回应,我们不需要这样,所以我们将从解析模块开始。

while 1:
    sentence = raw_input("talk to Lina : ")
    
    # start with parsing & scrapping
    # empty the global variables
    parsing_scrapping.tree_output=[]
    parsing_scrapping.tree_output_str =""
   
    # call the parsing module
    # it would return 2objs
    # answr_text[0] bool either fact question or not
    # answr_text[1] the answer 
    answr_text = parsing_scrapping.parse(sentence) 

    # if it is a fact question
    if answr_text[0] :
         print answr_text[1]

    # if normal chat
    else :
        response_primary, line_id_primary = tf_idf.talk_to_lina_primary(sentence)
        print response_primary

    print 
注意

请告诉我您对这个教程的看法,告诉我您的评论并为您投票。我还建议您查看最后一个 教程,并等待即将到来的教程。

© . All rights reserved.