NLP概述

NLP的定义

NLP(Nature Language Processing,自然语言处理)是计算机学科及人工智能领域一个重要的子学科,它主要研究计算机如何处理、理解及应用人类语言。所谓自然语言,指人说的话、人写的文章,是人类在长期进化过程中形成的一套复杂的符号系统(类似于C/Java等计算机语言则称为人造语言)。以下是关于自然语言处理常见的定义:

  • 自然语言处理是计算机科学与语言中关于计算机与人类语言转换的领域。——中文维基百科
  • 自然语言处理是人工智能领域中一个重要的方向。它研究实现人与计算机之间用自然语言进行有效沟通的各种理论和方法。——百度百科
  • 自然语言处理研究在人与人交际中及人与计算机交际中的语言问题的一门学科。NLP要研制表示语言能力和语言应用的模型,建立计算机框架来实现这些语言模型,提出相应的方法来不断完善这种模型,并根据语言模型设计各种实用系统,以及对这些系统的评测技术。——Bill Manaris,《从人机交互的角度看自然语言处理》
  • 自然语言处理还有其它一些名称,例如:自然语言理解(Natural Language Understanding),计算机语言学(Computational Linguistics),人类语言技术(Human Language Technology)等等。

NLP的主要任务

NLP的主要任务可以分为两大类,一类是基于现有文本或语料的分析,另一类是生成新的文本或语料。

NLP_task.png

分词

该任务将文本或语料分隔成更小的语言单元(例如,单词)。对于拉丁语系,词语之间有空格分隔,对于中文、日文等语言,分词就是一项重要的基本任务,分词直接影响对文本语义的理解。例如:

文本:吉林市长春药店
分词1:吉林市/长春/药店
分词2:吉林/市长/春药/店

词义消歧

词义消歧是识别单词正确含义的任务。例如,在句子“The dog barked at the mailman”(狗对邮递员吠叫)和“Tree bark is sometimes used as a medicine”(树皮有时用作药物)中,单词bark有两种不同的含义。词义消歧对于诸如问答之类的任务至关重要。

命名实体识别(NER)

NER尝试从给定的文本主体或文本语料库中提取实体(例如,人物、位置和组织)。例如,句子:

John gave Mary two apples at school on Monday

将转换为:

NER.png

词性标记(PoS)

PoS标记是将单词分配到各自对应词性的任务。它既可以是名词、动词、形容词、副词、介词等基本词、也可以是专有名词、普通名词、短语动词、动词等。

文本分类

文本分类有许多应用场景,例如垃圾邮件检测、新闻文章分类(例如,政治、科技和运动)和产品评论评级(即正向或负向)。我们可以用标记数据(即人工对评论标上正面或负面的标签)训练一个分类模型来实现这项任务。

语言生成

可以利用NLP模型来生成新的文本或语料,例如机器写作(天气预报、新闻报道、模仿唐诗),生成文本摘要等。以下是一段机器合成的"诗":

向塞唯何近,空令极是辞。向睹一我扇,猛绶临来惊。
向面炎交好,荷莎正若隳。即住长非乱,休分去此垂。
却定何人改,松仙绕绮霞。偶笑寒栖咽,长闻暖顶时。
失个亦垂谏,守身丈韦鸿。忆及他年事,应愁一故名。
坐忆山高道,为随夏郭间。到乱唯无己,千方得命赊。

问答(QA)系统

QA技术具有很高的商业价值,这些技术是聊天机器人和VA(例如,Google Assistant和Apple Siri)的基础。许多公司已经采用聊天机器人来提供客户支持。以下是一段与聊天机器人的对话:

chat_robot.png

机器翻译(MT)

机器翻译(Machine Translation,MT)指将文本由一种语言翻译成另一种语言,本质是根据一个序列,生成语义最相近的另一种语言序列。

MT.png

NLP的发展历程

NLP的发展轨迹为:基于规则 → 基于统计 → 基于深度学习,其发展大致经历了4个阶段:1956年以前的萌芽期;1957~1970年的快速发展期;1971~1993年的低速发展期;1994年至今的复苏融合期。

萌芽期(1956年以前)

  • 1946年:第一台电子计算机诞生
  • 1948年:Shannon把离散马尔可夫过程的概率模型应用于描述语言的自动机。接着,他又把热力学中“熵”(entropy)的概念引用于语言处理的概率算法中
  • 1956年:Chomsky又提出了上下文无关语法,并把它运用到自然语言处理中

快速发展期(1957~1970)

自然语言处理在这一时期很快融入了人工智能的研究领域中。由于有基于规则和基于概率这两种不同方法的存在,自然语言处理的研究在这一时期分为了两大阵营。一个是基于规则方法的符号派(symbolic),另一个是采用概率方法的随机派(stochastic)。这一时期,两种方法的研究都取得了长足的发展。从50年代中期开始到60年代中期,以Chomsky为代表的符号派学者开始了形式语言理论和生成句法的研究,60年代末又进行了形式逻辑系统的研究。而随机派学者采用基于贝叶斯方法的统计学研究方法,在这一时期也取得了很大的进步。 这一时期的重要研究成果包括1959年宾夕法尼亚大学研制成功的TDAP系统,布朗美国英语语料库的建立等。1967年美国心理学家Neisser提出认知心理学的概念,直接把自然语言处理与人类的认知联系起来了。

低速发展期(1971~1993)

随着研究的深入,由于人们看到基于自然语言处理的应用并不能在短时间内得到解决,而一连串的新问题又不断地涌现,于是,许多人对自然语言处理的研究丧失了信心。从70年代开始,自然语言处理的研究进入了低谷时期。 但尽管如此,一些研究人员依旧坚持继续着他们的研究。由于他们的出色工作,自然语言处理在这一低谷时期同样取得了一些成果。70年代,基于隐马尔可夫模型(Hidden Markov Model, HMM)的统计方法在语音识别领域获得成功。80年代初,话语分析(Discourse Analysis)也取得了重大进展。之后,由于自然语言处理研究者对于过去的研究进行了反思,有限状态模型和经验主义研究方法也开始复苏。

复苏融合期(1994年至今)

90年代中期以后,有两件事从根本上促进了自然语言处理研究的复苏与发展。一件事是90年代中期以来,计算机的速度和存储量大幅增加,为自然语言处理改善了物质基础,使得语音和语言处理的商品化开发成为可能;另一件事是1994年Internet商业化和同期网络技术的发展使得基于自然语言的信息检索和信息抽取的需求变得更加突出。以下列举除了2000年之后NLP领域的几个里程碑事件:

  • 2001年:神经语言模型
  • 2008年:多任务学习
  • 2013年: Word嵌入
  • 2013年:NLP的神经网络
  • 2014年:序列到序列模型
  • 2015年:注意力机制
  • 2015年:基于记忆的神经网络
  • 2018年:预训练语言模型

NLP的困难与挑战

语言歧义

  • 不同分词导致的歧义
例如:自动化研究所取得的成就
理解一:自动化 / 研究 / 所 / 取得 / 的 / 成就
理解二:自动化 / 研究所 / 取得 / 的 / 成就
  • 词性歧义
动物保护警察

“保护”理解成动词、名词,语义不一样
  • 结构歧义
喜欢乡下的孩子
关于鲁迅的文章
  • 语音歧义
节假日期间,所有博物馆全部(不)对外开放

不同语言结构差异

translate_err.png

未知语言不可预测性

语言在不断演化,每年都有为数不少的新词语、新语料出现,给一些NLP处理任务造成困难。以下列举了几个2021年网络上出现的新词语:

双减
元宇宙
绝绝子
躺平

语言表达的复杂性

甲:你这是什么意思?
乙:没什么意思,意思意思。
甲:你这就不够意思了。
乙:小意思,小意思。
甲:你这人真有意思。
乙:其实也没有别的意思。
甲:那我就不好意思了。

机器处理语言缺乏背景与常识

中国国家队比赛最没悬念的是乒乓球和足球,他们一个谁也打不过,另一个谁也打不过
如果希拉里当选,她就是全世界唯一一个干过美国总统和干过美国总统的女人,克林顿也将成为全世界唯一一个干过美国总统和干过美国总统的男人

NLP相关知识构成

NLP_structure.png

语料库

什么是语料库

语料库(corpus)是指存放语言材料的仓库。现代的语料库是指存放在计算机里的原始语料文本或经过加工后带有语言学信息标注的语料文本。以语言的真实材料为基础来呈现语言知识,反映语言单位的用法和意义,基本以知识的原型形态表现——语言的原貌。

语料库的特征

  • 语料库中存放的是实际中真实出现过的语言材料
  • 语料库是以计算机为载体承载语言知识的基础资源,但不等于语言知识
  • 真实语料需要经过分析、处理和加工,才能成为有用的资源

语料库的作用

  • 支持语言学研究和语言教学研究
  • 支持NLP系统的开发

常用语料库介绍

传统NLP处理技术

中文分词

中文分词是一项重要的基本任务,分词直接影响对文本语义的理解。分词主要有基于规则的分词、基于统计的分词和混合分词。基于规则的分词主要是通过维护词典,在切分语句时,将语句的每个子字符串与词表中的词语进行匹配,找到则切分,找不到则不切分;基于统计的分词,主要是基于统计规则和语言模型,输出一个概率最大的分词序列(由于所需的知识尚未讲解,此处暂不讨论);混合分词就是各种分词方式混合使用,从而提高分词准确率。下面介绍基于规则的分词。

正向最大匹配法

正向最大匹配法(Forward Maximum Matching,FMM)是按照从前到后的顺序对语句进行切分,其步骤为:

  • 从左向右取待分汉语句的m个字作为匹配字段,m为词典中最长词的长度;
  • 查找词典进行匹配;
  • 若匹配成功,则将该字段作为一个词切分出去;
  • 若匹配不成功,则将该字段最后一个字去掉,剩下的字作为新匹配字段,进行再次匹配;
  • 重复上述过程,直到切分所有词为止。

逆向最大匹配法

逆向最大匹配法(Reverse Maximum Matching, RMM)基本原理与FMM基本相同,不同的是分词的方向与FMM相反。RMM是从待分词句子的末端开始,也就是从右向左开始匹配扫描,每次取末端m个字作为匹配字段,匹配失败,则去掉匹配字段前面的一个字,继续匹配。

双向最大匹配法

双向最大匹配法(Bi-directional Maximum Matching,Bi-MM)是将正向最大匹配法得到的分词结果和逆向最大匹配法得到的结果进行比较,然后按照最大匹配原则,选取词数切分最少的作为结果。双向最大匹配的规则是:

  • 如果正反向分词结果词数不同,则取分词数量少的那个;
  • 分词结果相同,没有歧义,返回任意一个;分词结果不同,返回其中单字数量较少的那个。

双向最大匹配法

双向最大匹配法(Bi-directional Maximum Matching,Bi-MM)是将正向最大匹配法得到的分词结果和逆向最大匹配法得到的结果进行比较,然后按照最大匹配原则,选取词数切分最少的作为结果。双向最大匹配的规则是:

  • 如果正反向分词结果词数不同,则取分词数量少的那个;
  • 分词结果相同,没有歧义,返回任意一个;分词结果不同,返回其中单字数量较少的那个。

正向最大匹配分词法

# 正向最大匹配分词示例
class MM(object):
    def __init__(self):
        self.window_size = 3

    def cut(self, text):
        result = [] # 分词结果
        start = 0 # 起始位置
        text_len = len(text) # 文本长度

        dic = ["吉林", "吉林市", "市长", "长春", "春药", "药店"]

        while text_len > start:
            for size in range(self.window_size + start, start, -1): # 取最大长度,逐步比较减小
                piece = text[start:size] # 切片
                if piece in dic: # 在字典中
                    result.append(piece) # 添加到列表
                    start += len(piece)
                    break
                else: # 没在字典中,什么都不做
                    if len(piece) == 1:
                        result.append(piece) # 单个字成词
                        start += len(piece)

        return result

if __name__ == "__main__":
    text = "吉林市长春药店"
    tk = MM() # 实例化对象
    result = tk.cut(text)
    print(result)

执行结果:

['吉林市', '长春', '药店']

逆向最大匹配分词法

# 逆向最大匹配分词示例
class RMM(object):
    def __init__(self):
        self.window_size = 3

    def cut(self, text):
        result = [] # 分词结果
        start = len(text) # 起始位置
        text_len = len(text) # 文本长度

        dic = ["吉林", "吉林市", "市长", "长春", "春药", "药店"]

        while start > 0:
            for size in range(self.window_size, 0, -1):
                piece = text[start-size:start] # 切片
                if piece in dic: # 在字典中
                    result.append(piece) # 添加到列表
                    start -= len(piece)
                    break
                else: # 没在字典中
                    if len(piece) == 1:
                        result.append(piece) # 单个字成词
                        start -= len(piece)
                        break
        result.reverse()
        return result

if __name__ == "__main__":
    text = "吉林市长春药店"
    tk = RMM() # 实例化对象
    result = tk.cut(text)
    print(result)

执行结果:

['吉林市', '长春', '药店']

Jieba库分词 Jieba是一款开源的、功能丰富、使用简单的中文分词工具库,它提供了三种分词模式:

  • 精确模式:试图将句子最精确地分词,适合文本分析
  • 全模式:把句子中所有可以成词的词语分割出来,速度快,但有重复词和歧义
  • 搜索引擎模式:在精确模式基础上,对长词再次切分,提高召回率,适合用于搜索引擎分词
  • 使用Jieba库之前,需要进行安装:
pip install jieba==0.42.1

分词示例代码如下:

# jieba分词示例
import jieba

text = "吉林市长春药店"

# 全模式
seg_list = jieba.cut(text, cut_all=True)
for word in seg_list:
    print(word, end="/")
print()

# 精确模式
seg_list = jieba.cut(text, cut_all=False)
for word in seg_list:
    print(word, end="/")
print()

# 搜索引擎模式
seg_list = jieba.cut_for_search(text)
for word in seg_list:
    print(word, end="/")
print()

执行结果:

吉林/吉林市/市长/长春/春药/药店/
吉林市/长春/药店/
吉林/吉林市/长春/药店/

文本高频词汇提取

# 通过tf-idf提取高频词汇
import glob
import random
import jieba


# 读取文件内容
def get_content(path):
    with open(path, "r", encoding="gbk", errors="ignore") as f:
        content = ""
        for line in f.readlines():
            line = line.strip()
            content += line
        return content


# 统计词频,返回最高前10位词频列表
def get_tf(words, topk=10):
    tf_dict = {}

    for w in words:
        if w not in tf_dict.items():
            tf_dict[w] = tf_dict.get(w, 0) + 1  # 获取词频并加1

    # 倒序排列
    new_list = sorted(tf_dict.items(), key=lambda x: x[1], reverse=True)

    return new_list[:topk]


# 去除停用词
def get_stop_words(path):
    with open(path, encoding="utf8") as f:
        return [line.strip() for line in f.readlines()]


if __name__ == "__main__":
    # 样本文件
    fname = "11.txt"
    # 读取文件内容
    corpus = get_content(fname)
    # 分词
    tmp_list = list(jieba.cut(corpus))
    # 去除停用词
    stop_words = get_stop_words("stop_words.utf8")
    split_words = []
    for tmp in tmp_list:
        if tmp not in stop_words:
            split_words.append(tmp)

    # print("样本:\n", corpus)
    print("\n 分词结果: \n" + "/".join(split_words))

    # 统计高频词
    tf_list = get_tf(split_words)
    print("\n top10词 \n:", str(tf_list))

执行结果:

分词结果:
焦点/个股/苏宁/电器/002024/该股/早市/涨停/开盘/其后/获利盘/抛/压下/略有/回落/强大/买盘/推动/下该/股/已经/再次/封于/涨停/主力/资金/积极/拉升/意愿/相当/强烈/盘面/解析/技术/层面/早市/指数/小幅/探低/迅速/回升/中石化/强势/上扬/带动/指数/已经/成功/翻红/多头/实力/之强/令人/瞠目结舌/市场/高度/繁荣/情形/投资者/需谨慎/操作/必竟/持续/上攻/已经/消耗/大量/多头/动能/盘中/热点/来看/相比/周二/略有/退温/依然/看到/目前/热点/效应/外扩散/迹象/相当/明显/高度/活跌/板块/已经/前期/有色金属/金融/地产股/向外/扩大/军工/概念/航天航空/操作/思路/短线/依然/需/规避/一下/技术性/回调/风险/盘中/切记/不可/追高

top10词:
 [('已经', 4), ('早市', 2), ('涨停', 2), ('略有', 2), ('相当', 2), ('指数', 2), ('多头', 2), ('高度', 2), ('操作', 2), ('盘中', 2)]

词性标注

什么是词性标注

词性是词语的基本语法属性,通常也称为词类。词性标注是判定给定文本或语料中每个词语的词性。有很多词语在不同语境中表现为不同的词性,这就为词性标注带来很大的困难。另一方面,从整体上看,大多数词语,尤其是实词,一般只有一到两个词性,其中一个词性的使用频率远远大于另一个。

词性标注的原理

词性标注最主要方法同分词一样,将其作为一个序列生成问题来处理。使用序列模型,根据输入的文本,生成一个对应的词性序列。

词性标注规范

词性标注要有一定的标注规范,如将名词、形容词、动词表示为"n", "adj", "v"等。中文领域尚无统一的标注标准,较为主流的有北大词性标注集和宾州词性标注集。以下是北大词性标注集部分词性表示:

pku_pos_1.jpg

pku_pos_2.jpg

Jieba库词性标注

Jieba库提供了词性标注功能,采用结合规则和统计的方式,具体为在词性标注的过程中,词典匹配和HMM共同作用。词性标注流程如下:

  • 第一步:根据正则表达式判断文本是否为汉字;
  • 第二步:如果判断为汉字,构建HMM模型计算最大概率,在词典中查找分出的词性,若在词典中未找到,则标记为"未知";
  • 第三步:若不如何上面的正则表达式,则继续通过正则表达式进行判断,分别赋予"未知"、”数词“或"英文"。

Jieba库实现词性标注

import jieba.posseg as psg


def pos(text):
    results = psg.cut(text)
    for w, t in results:
        print("%s/%s" % (w, t), end=" ")
    print("")


text = "呼伦贝尔大草原"
pos(text)

text = "梅兰芳大剧院里星期六晚上有演出"
pos(text)

执行结果:

呼伦贝尔/nr 大/a 草原/n 
梅兰芳/nr 大/a 剧院/n 里/f 星期六/t 晚上/t 有/v 演出/v 

命名实体识别(NER)

命名实体识别(Named Entities Recognition,NER)也是自然语言处理的一个基础任务,是信息抽取、信息检索、机器翻译、问答系统等多种自然语言处理技术必不可少的组成部分。其目的是识别语料中人名、地名、组织机构名等命名实体,实体类型包括3大类(实体类、时间类和数字类)和7小类(人名、地名、组织机构名、时间、日期、货币和百分比)。中文命名实体识别主要有以下难点:

  1. 各类命名实体的数量众多。
  2. 命名实体的构成规律复杂。
  3. 嵌套情况复杂。
  4. 长度不确定。

命名实体识别方法有:

  1. 基于规则的命名实体识别。规则加词典是早期命名实体识别中最行之有效的方式。其依赖手工规则的系统,结合命名实体库,对每条规则进行权重赋值,然后通过实体与规则的相符情况来进行类型判断。这种方式可移植性差、更新维护困难等问题。
  2. 基于统计的命名实体识别。基于统计的命名实体识别方法有:隐马尔可夫模型、最大熵模型、条件随机场等。其主要思想是基于人工标注的语料,将命名实体识别任务作为序列标注问题来解决。基于统计的方法对语料库的依赖比较大,而可以用来建设和评估命名实体识别系统的大规模通用语料库又比较少,这是该方法的一大制约。
  3. 基于深度学习的方法。利用深度学习模型,预测词(或字)是否为命名实体,并预测出起始、结束位置。
  4. 混合方法。将前面介绍的方法混合使用。

关键词提取

  • 关键词提取是提取出代表文章重要内容的一组词,对文本聚类、分类、自动摘要起到重要作用。此外,关键词提取还能使人们便捷地浏览和获取信息。现实中大量文本不包含关键词,自动提取关检测技术具有重要意义和价值。关键词提取包括有监督学习、无监督学习方法两类。
  • 有监督关键词提取。该方法主要通过分类方式进行,通过构建一个较为丰富完整的词表,然后通过判断每个文档与词表中每个词的匹配程度,以类似打标签的方式,达到关键词提取的效果。该方法能获取较高的精度,但需要对大量样本进行标注,人工成本过高。另外,现在每天都有大量新的信息出现,固定词表很难将新信息内容表达出来,但人工实时维护词表成本过高。所以,有监督学习关键词提取方法有较明显的缺陷。
  • 无监督关键词提取。相对于有监督关键词提取,无监督方法对数据要求低得多,既不需要人工维护词表,也不需要人工标注语料辅助训练。因此,在实际应用中更受青睐。这里主要介绍无监督关键词提取算法,包括TF-IDF算法,TextRank算法和主题模型算法。

TF-IDF算法

  • TF-IDF(Term Frequency-Inverse Document Frequency,词频-逆文档频率)是一种基于传统的统计计算方法,常用于评估一个文档集中一个词对某份文档的重要程度。其基本思想是:一个词语在文档中出现的次数越多、出现的文档越少,语义贡献度越大(对文档区分能力越强)。TF-IDF表达式由两部分构成,词频、逆文档频率。词频定义为:

$$TF_{ij} = \frac{n_{ji}}{\sum_k n_{kj}}$$

  • 其中,\(n_{ij}\)表示词语i在文档j中出现的次数,分母\(\sum_k n_{kj}\)表示所有文档总次数。逆文档频率定义为:

$$IDF_i = log(\frac{|D|}{|D_i| + 1})$$

  • 其中,\(|D|\)为文档总数,\(D_i\)为文档中出现词i的文档数量,分母加1是避免分母为0的情况(称为拉普拉斯平滑),TF-IDF算法是将TF和IDF综合使用,表达式为:

$$TF-IDF = TF_{ij} \times IDF_i =\frac{n_{ji}}{\sum_k n_{kj}} \times log(\frac{|D|}{|D_i| + 1})$$

  • 由公式可知,词频越大,该值越大;出现的文档数越多(说明该词越通用),逆文档频率越接近0,语义贡献度越低。例如有以下文本:
世界献血日,学校团体、献血服务志愿者等可到血液中心参观检验加工过程,我们会对检验结果进行公示,同时血液的价格也将进行公示。
  • 以上文本词语总数为30,计算几个词的词频:

$$TF_{献血} = 2 / 30 \approx 0.067$$ $$TF_{血液} = 2 / 30 \approx 0.067$$ $$TF_{进行} = 2 / 30 \approx 0.067$$ $$TF_{公示} = 2 / 30 \approx 0.067$$

  • 假设出现献血、血液、进行、公示文档数量分别为10、15、100、50,根据TF-IDF计算公式,得:

$$TF-IDF_{献血} = 0.067 * log(1000/10) = 0.067 * 2 = 0.134$$ $$TF-IDF_{血液} = 0.067 * log(1000/15) = 0.067 * 1.824 = 0.1222$$ $$TF-IDF_{进行} = 0.067 * log(1000/100) = 0.067 * 1 = 0.067$$ $$TF-IDF_{公示} = 0.067 * log(1000/50) = 0.067 * 1.30 = 0.08717$$

  • “献血”、“血液”的TF-IDF值最高,所以为最适合这篇文档的关键词。

TextRank算法

  • 与TF-IDF不一样,TextRank算法可以脱离于语料库,仅对单篇文档进行分析就可以提取该文档的关键词,这也是TextRank算法的一个重要特点。TextRank算法最早用于文档的自动摘要,基于句子维度的分析,利用算法对每个句子进行打分,挑选出分数最高的n个句子作为文档的关键句,以达到自动摘要的效果。
  • TextRank算法的基本思想来源于Google的PageRank算法,该算法是Google创始人拉里·佩奇和希尔盖·布林于1997年构建早期的搜索系统原型时提出的链接分析法,用于评价搜索系统各覆盖网页重要性的一种方法。随着Google的成功,该算法也称为其它搜索引擎和学术界十分关注的计算模型。

PageRank2.png

PageRank基本思想有两条:

  • 链接数量。一个网页被越多的其它网页链接,说明这个网页越重要
  • 链接质量。一个网页被一个越高权值的网页链接,也能表名这个网页越重要

基于上述思想,一个网页的PageRank计算公式可以表示为:

$$S(V_i) = \sum_{j \in In(V_i)} \Bigg( \frac{1}{Out(V_j)} \times S(V_j) \Bigg)$$

  • 其中,\(In(V_i)\)为\(V_i\)的入链集合,\(Out(V_j)\)为\(V_j\)的出链集合,\(|Out(V_j)|\)为出链的数量。因为每个网页要将它自身的分数平均贡献给每个出链,则\(\Bigg( \frac{1}{Out(V_j)} \times S(V_j) \Bigg)\)即为\(V_i\)贡献给\(V_j\)的分数。将所有入链贡献给它的分数全部加起来,就是\(V_i\)自身的得分。算法开始时,将所有页面的得分均初始化为1。
  • 对于一些孤立页面,可能链入、链出的页面数量为0,为了避免这种情况,对公式进行了改造,加入了一个阻尼系数\(d\),这样,即使孤立页面也有一个得分。改造后的公式如下:

$$S(V_i) = (1 - d) + d \times \sum_{j \in In(V_i)} \Bigg( \frac{1}{Out(V_j)} \times S(V_j) \Bigg)$$

  • 以上就是PageRank的理论,也是TextRank的理论基础,不同于的是TextRank不需要与文档中的所有词进行链接,而是采用一个窗口大小,在窗口中的词互相都有链接关系。例如对下面的文本进行窗口划分:
世界献血日,学校团体、献血服务志愿者等可到血液中心参观检验加工过程,我们会对检验结果进行公示,同时血液的价格也将进行公示。
  • 如果将窗口大小设置为5,则可得到如下计算窗口:
[世界,献血,日,学校,团体]
[献血,日,学校,团体,献血]
[日,学校,团体,献血,服务]
[学校,团体,献血,服务,志愿者]
……
  • 每个窗口内所有词之间都有链接关系,如[世界]和[献血,日,学校,团体]之间有链接关系。得到了链接关系,就可以套用TextRank公式,计算每个词的得分,最后选择得分最高的N个词作为文档的关键词。

关键词提取示例

本案例演示了通过自定义TF-IDF、调用TextRank API实现关键字提取

# -*- coding: utf-8 -*-

import math
import jieba
import jieba.posseg as psg
from gensim import corpora, models
from jieba import analyse
import functools
import numpy as np


# 停用词表加载方法
def get_stopword_list():
    # 停用词表存储路径,每一行为一个词,按行读取进行加载
    # 进行编码转换确保匹配准确率
    stop_word_path = '../data/stopword.txt'
    with open(stop_word_path, "r", encoding="utf-8") as f:
        lines = f.readlines()

    stopword_list = [sw.replace('\n', '') for sw in lines]
    return stopword_list


# 去除停用词
def word_filter(seg_list):
    filter_list = []
    for word in seg_list:
        # 过滤停用词表中的词,以及长度为<2的词
        if not word in stopword_list and len(word) > 1:
            filter_list.append(word)

    return filter_list


# 数据加载,pos为是否词性标注的参数,corpus_path为数据集路径
def load_data(corpus_path):
    # 调用上面方式对数据集进行处理,处理后的每条数据仅保留非干扰词
    doc_list = []
    for line in open(corpus_path, 'r', encoding='utf-8'):  # 循环读取一行(一行即一个文档)
        content = line.strip()  # 去空格
        seg_list = jieba.cut(content)  # 分词
        filter_list = word_filter(seg_list)  # 去除停用词
        doc_list.append(filter_list)  # 将分词后的内容添加到列表

    return doc_list


# idf值统计方法
def train_idf(doc_list):
    idf_dic = {}
    tt_count = len(doc_list)  # 总文档数

    # 每个词出现的文档数
    for doc in doc_list:
        doc_set = set(doc)  # 将词推入集合去重
        for word in doc_set:  # 词语在文档中
            idf_dic[word] = idf_dic.get(word, 0.0) + 1.0  # 文档数加1

    # 按公式转换为idf值,分母加1进行平滑处理
    for word, doc_cnt in idf_dic.items():
        idf_dic[word] = math.log(tt_count / (1.0 + doc_cnt))

    # 对于没有在字典中的词,默认其仅在一个文档出现,得到默认idf值
    default_idf = math.log(tt_count / (1.0))

    return idf_dic, default_idf


# TF-IDF类
class TfIdf(object):
    def __init__(self, idf_dic, default_idf, word_list, keyword_num):
        """
        TfIdf类构造方法
        :param idf_dic: 训练好的idf字典
        :param default_idf: 默认idf值
        :param word_list: 待提取文本
        :param keyword_num: 关键词数量
        """
        self.word_list = word_list
        self.idf_dic, self.default_idf = idf_dic, default_idf # 逆文档频率
        self.tf_dic = self.get_tf_dic()  # 词频
        self.keyword_num = keyword_num

    # 统计tf值
    def get_tf_dic(self):
        tf_dic = {}  # 词频字典
        for word in self.word_list:
            tf_dic[word] = tf_dic.get(word, 0.0) + 1.0

        total = len(self.word_list)  # 词语总数
        for word, word_cnt in tf_dic.items():
            tf_dic[word] = float(word_cnt) / total

        return tf_dic

    # 按公式计算tf-idf
    def get_tfidf(self):
        tfidf_dic = {}
        for word in self.word_list:
            idf = self.idf_dic.get(word, self.default_idf)
            tf = self.tf_dic.get(word, 0)

            tfidf = tf * idf  # 计算TF-IDF
            tfidf_dic[word] = tfidf

        # 根据tf-idf排序,去排名前keyword_num的词作为关键词
        s_list = sorted(tfidf_dic.items(), key=lambda x: x[1], reverse=True)
        # print(s_list)
        top_list = s_list[:self.keyword_num]  # 切出前N个
        for k, v in top_list:
            print(k + ", ", end='')
        print()


def tfidf_extract(word_list, keyword_num=20):
    doc_list = load_data('../data/corpus.txt')  # 读取文件内容
    # print(doc_list)
    idf_dic, default_idf = train_idf(doc_list) # 计算逆文档频率

    tfidf_model = TfIdf(idf_dic, default_idf, word_list, keyword_num)
    tfidf_model.get_tfidf()


def textrank_extract(text, keyword_num=20):
    keywords = analyse.textrank(text, keyword_num)
    # 输出抽取出的关键词
    for keyword in keywords:
        print(keyword + ", ", end='')
    print()


if __name__ == '__main__':
    global stopword_list

    text = """在中国共产党百年华诞的重要时刻,在“两个一百年”奋斗目标历史交汇关键节点,
    党的十九届六中全会的召开具有重大历史意义。全会审议通过的《决议》全面系统总结了党的百年奋斗
    重大成就和历史经验,特别是着重阐释了党的十八大以来党和国家事业取得的历史性成就、发生的历史性变革,
    充分彰显了中国共产党的历史自觉与历史自信。"""

    stopword_list = get_stopword_list()

    seg_list = jieba.cut(text)  # 分词
    filter_list = word_filter(seg_list)

    # TF-IDF提取关键词
    print('TF-IDF模型结果:')
    tfidf_extract(filter_list)

    # TextRank提取关键词
    print('TextRank模型结果:')
    textrank_extract(text)

执行结果:

TF-IDF模型结果:
历史, 中国共产党, 百年, 历史性, 华诞, 一百年, 奋斗目标, 交汇, 节点, 十九, 六中全会, 全会, 奋斗, 重大成就, 着重, 阐释, 十八, 党和国家, 成就, 变革, 

TextRank模型结果:
历史, 历史性, 意义, 成就, 决议, 审议, 发生, 系统, 总结, 全面, 节点, 关键, 交汇, 召开, 具有, 全会, 取得, 事业, 自信, 变革, 

综合案例

垃圾邮件分类

  • 数据集介绍:包含5000份正常邮件、5001份垃圾邮件的样本
  • 文本特征处理方式:采用TF-IDF作为文本特征值
  • 模型选择:朴素贝叶斯、支持向量机模型
  • 基本流程:读取数据 → 去除停用词和特殊符号 → 计算TF-IDF特征值 → 模型训练 → 预测 → 打印结果
  • ham_data.txt
  • spam_data.txt
  • stop_words.utf8.txt
# -*- coding: utf-8 -*-
# 利用TF-IDF特征、朴素贝叶斯/支持向量机实现垃圾邮件分类
import numpy as np
import re
import string
import sklearn.model_selection as ms
from sklearn.naive_bayes import MultinomialNB
from sklearn.linear_model import SGDClassifier
from sklearn import metrics

import jieba
from sklearn.feature_extraction.text import TfidfVectorizer

label_name_map = ["垃圾邮件", "正常邮件"]


# 分词
def tokenize_text(text):
    tokens = jieba.cut(text)  # 分词
    tokens = [token.strip() for token in tokens]  # 去空格
    return tokens


def remove_special_characters(text):
    tokens = tokenize_text(text)
    # escape函数对字符进行转义处理
    # compile函数用于编译正则表达式,生成一个 Pattern 对象
    pattern = re.compile('[{}]'.format(re.escape(string.punctuation)))
    # filter() 函数用于过滤序列,过滤掉不符合条件的元素,返回由符合条件元素组成的新列表
    # sub函数进行正则匹配字符串替换
    filtered_tokens = filter(None, [pattern.sub('', token) for token in tokens])
    filtered_text = ' '.join(filtered_tokens)
    return filtered_text


# 去除停用词
def remove_stopwords(text):
    tokens = tokenize_text(text)  # 分词、去空格
    filtered_tokens = [token for token in tokens if token not in stopword_list]  # 去除停用词
    filtered_text = ''.join(filtered_tokens)
    return filtered_text


# 规范化处理
def normalize_corpus(corpus):
    result = []  # 处理结果

    for text in corpus:  # 遍历每个词汇
        text = remove_special_characters(text)  # 去除标点符号
        text = remove_stopwords(text)  # 去除停用词
        result.append(text)

    return result


def tfidf_extractor(corpus):
    vectorizer = TfidfVectorizer(min_df=1,
                                 norm='l2',
                                 smooth_idf=True,
                                 use_idf=True)
    features = vectorizer.fit_transform(corpus)
    return vectorizer, features


def get_data():
    '''
    获取数据
    :return: 文本数据,对应的labels
    '''
    corpus = []  # 邮件内容
    labels = []  # 标签(0-垃圾邮件 1-正常邮件)

    # 正常邮件
    with open("data/ham_data.txt", encoding="utf8") as f:
        for line in f.readlines():
            corpus.append(line)
            labels.append(1)

    # 垃圾邮件
    with open("data/spam_data.txt", encoding="utf8") as f:
        for line in f.readlines():
            corpus.append(line)
            labels.append(0)

    return corpus, labels


# 过滤空文档
def remove_empty_docs(corpus, labels):
    filtered_corpus = []
    filtered_labels = []

    for doc, label in zip(corpus, labels):
        if doc.strip():
            filtered_corpus.append(doc)
            filtered_labels.append(label)

    return filtered_corpus, filtered_labels


# 计算并打印分类指标
def print_metrics(true_labels, predicted_labels):
    # Accuracy
    accuracy = metrics.accuracy_score(true_labels, predicted_labels)

    # Precision
    precision = metrics.precision_score(true_labels,
                                        predicted_labels,
                                        average='weighted')

    # Recall
    recall = metrics.recall_score(true_labels,
                                  predicted_labels,
                                  average='weighted')

    # F1
    f1 = metrics.f1_score(true_labels,
                          predicted_labels,
                          average='weighted')

    print("正确率: %.2f, 查准率: %.2f, 召回率: %.2f, F1: %.2f" % (accuracy, precision, recall, f1))


if __name__ == "__main__":
    global stopword_list

    # 读取停用词
    with open("dict/stop_words.utf8", encoding="utf8") as f:
        stopword_list = f.readlines()

    corpus, labels = get_data()  # 加载数据
    corpus, labels = remove_empty_docs(corpus, labels)
    print("总的数据量:", len(labels))

    # 打印前N个样本
    for i in range(10):
        print("label:", labels[i], " 邮件内容:", corpus[i])

    # 对数据进行划分
    train_corpus, test_corpus, train_labels, test_labels = \
        ms.train_test_split(corpus,
                            labels,
                            test_size=0.10,
                            random_state=36)

    # 规范化处理
    norm_train_corpus = normalize_corpus(train_corpus)
    norm_test_corpus = normalize_corpus(test_corpus)

    # tfidf 特征
    ## 先计算tf-idf
    tfidf_vectorizer, tfidf_train_features = tfidf_extractor(norm_train_corpus)
    ## 再用刚刚训练的tf-idf模型计算测试集tf-idf
    tfidf_test_features = tfidf_vectorizer.transform(norm_test_corpus)
    # print(tfidf_test_features)
    # print(tfidf_test_features)

    # 基于tfidf的多项式朴素贝叶斯模型
    print("基于tfidf的贝叶斯模型")
    nb_model = MultinomialNB()  # 多分类朴素贝叶斯模型
    nb_model.fit(tfidf_train_features, train_labels)  # 训练
    mnb_pred = nb_model.predict(tfidf_test_features)  # 预测
    print_metrics(true_labels=test_labels, predicted_labels=mnb_pred)  # 打印测试集下的分类指标

    print("")

    # 基于tfidf的支持向量机模型
    print("基于tfidf的支持向量机模型")
    svm_model = SGDClassifier()
    svm_model.fit(tfidf_train_features, train_labels)  # 训练
    svm_pred = svm_model.predict(tfidf_test_features)  # 预测
    print_metrics(true_labels=test_labels, predicted_labels=svm_pred)  # 打印测试集下的分类指标

    print("")

    # 打印测试结果
    num = 0
    for text, label, pred_lbl in zip(test_corpus, test_labels, svm_pred):
        print('真实类别:', label_name_map[int(label)], ' 预测结果:', label_name_map[int(pred_lbl)])
        print('邮件内容【', text.replace("\n", ""), '】')
        print("")

        num += 1
        if num == 10:
            break

执行结果:

基于tfidf的贝叶斯模型
正确率: 0.97, 查准率: 0.97, 召回率: 0.97, F1: 0.97

基于tfidf的支持向量机模型
正确率: 0.98, 查准率: 0.98, 召回率: 0.98, F1: 0.98

真实类别: 正常邮件  预测结果: 正常邮件
邮件内容【 分专业吧,也分导师吧 标  题: Re: 问一个:有人觉得自己博士能混毕业吗 当然很好混毕业了 : 博士读到快中期了,始终感觉什么都不会,文章也没发几篇好的,论文的架构也没有, : 一切跟刚上的时候没有区别。但是事实上我也很辛苦的找资料,做实验,还进公司实习过, : 现在感觉好失败,内心已经放弃了,打算混毕业,不知道过来人有什么高招,请指点一二。 -- 】

真实类别: 垃圾邮件  预测结果: 垃圾邮件
邮件内容【 您好! 我公司有多余的发票可以向外代开!(国税、地税、运输、广告、海关缴款书)。 如果贵公司(厂)有需要请来电洽谈、咨询! 联系电话: 01351025****  陈先生 谢谢 顺祝商祺! 】

……

文本表示

One-hot

One-hot(独热)编码是一种最简单的文本表示方式。如果有一个大小为V的词表,对于第i个词\(w_i\),可以用一个长度为V的向量来表示,其中第i个元素为1,其它为0.例如:

减肥:[1, 0, 0, 0, 0]
瘦身:[0, 1, 0, 0, 0]
增重:[0, 0, 1, 0, 0]

One-hot词向量构建简单,但也存在明显的弱点:

  • 维度过高。如果词数量较多,每个词需要使用更长的向量表示,造成维度灾难;
  • 稀疏矩阵。每个词向量,其中只有一位为1,其它位均为零;
  • 语义鸿沟。词语之间的相似度、相关程度无法度量。

词袋模型

词袋模型(Bag-of-words model,BOW),BOW模型假定对于一个文档,忽略它的单词顺序和语法、句法等要素,将其仅仅看作是若干个词汇的集合,文档中每个单词的出现都是独立的,不依赖于其它单词是否出现。例如:

我把他揍了一顿,揍得鼻青眼肿
他把我走了一顿,揍得鼻青眼肿

构建一个词典:

{"我":0, "把":1, "他":2, "揍":3, "了":4 "一顿":5, "鼻青眼肿":6, "得":7}

再将句子向量化,维数和字典大小一致,第i维上的数值代表ID为i的词在句子里出现的频次,两个句子可以表示为:

[1, 1, 1, 2, 1, 1, 1, 1]
[1, 1, 1, 2, 1, 1, 1, 1]

词袋模型表示简单,但也存在较为明显的缺点:

  • 丢失了顺序和语义。顺序是极其重要的语义信息,词袋模型只统计词语出现的频率,忽略了词语的顺序。例如上述两个句子意思相反,但词袋模型表示却完全一致;
  • 高维度和稀疏性。当语料增加时,词袋模型维度也会增加,需要更长的向量来表示。但大多数词语不会出现在一个文本中,所以导致矩阵稀疏。

TF-IDF

TF-IDF(Term Frequency-Inverse Document Frequency,词频-逆文档频率)是一种基于传统的统计计算方法,常用于评估一个文档集中一个词对某份文档的重要程度。其基本思想是:一个词语在文档中出现的次数越多、出现的文档越少,语义贡献度越大(对文档区分能力越强)。其表达式为:

$$TF-IDF = TF_{ij} \times IDF_i =\frac{n_{ji}}{\sum_k n_{kj}} \times log(\frac{|D|}{|D_i| + 1})$$

  • 该指标依然无法保留词语在文本中的位置关系。该指标前面有过详细讨论,此处不再赘述。

共现矩阵

共现(co-occurrence)矩阵指通过统计一个事先指定大小的窗口内的词语共现次数,以词语周边的共现词的次数做为当前词语的向量。具体来说,我们通过从大量的语料文本中构建一个共现矩阵来表示词语。例如,有语料如下:

I like deep learning.
I like NLP.
I enjoy flying.

则共现矩阵表示为:

co_occurrence.png

  • 矩阵定义的词向量在一定程度上缓解了one-hot向量相似度为0的问题,但没有解决数据稀疏性和维度灾难的问题。

N-Gram表示

N-Gram模型是一种基于统计语言模型,语言模型是一个基于概率的判别模型,它的输入是个句子(由词构成的顺序序列),输出是这句话的概率,即这些单词的联合概率。

  • N-Gram本身也指一个由N个单词组成的集合,各单词具有先后顺序,且不要求单词之间互不相同。常用的有Bi-gram(N=2)和Tri-gram(N=3)。例如:
  • 句子:L love deep learning
  • Bi-gram: {I, love}, {love, deep}, {deep, learning}
  • Tri-gram: {I, love, deep}, {love deep learning}

N-Gram基本思想是将文本里面的内容按照字节进行大小为n的滑动窗口操作,形成了长度是n的字节片段序列。每一个字节片段称为一个gram,对所有gram的出现频度进行统计,并按照事先设置好的频度阈值进行过滤,形成关键gram列表,也就是这个文本向量的特征空间,列表中的每一种gram就是一个特征向量维度。

词嵌入

什么是词嵌入

词嵌入(word embedding)是一种词的向量化表示方式,该方法将词语映射为一个实数向量,同时保留词语之间语义的相似性和相关性。例如:

Man Women King Queen Apple Orange
Gender -1 1 -0.95 0.97 0.00 0.01
Royal 0.01 0.02 0.93 0.95 -0.01 0.00
Age 0.03 0.02 0.70 0.69 0.03 -0.02
Food 0.09 0.01 0.02 0.01 0.95 0.97

我们用一个四维向量来表示man,Women,King,Queen,Apple,Orange等词语(在实际中使用更高维度的表示,例如100~300维),这些向量能进行语义的表示和计算。例如,用Man的向量减去Woman的向量值:

$$ e_{man} - e_{woman} = \left[ \begin{matrix} -1 \\ 0.01 \\ 0.03 \\ 0.09 \\ \end{matrix} \right] -\left[ \begin{matrix} 1 \\ 0.02 \\ 0.02 \\ 0.01 \\ \end{matrix} \right] = \left[ \begin{matrix} -2 \\ -0.01 \\ 0.01 \\ 0.08 \\ \end{matrix} \right] \approx \left[ \begin{matrix} -2 \\ 0 \\ 0 \\ 0 \\ \end{matrix} \right] $$

  • 类似地,如果用King的向量减去Queen的向量,得到相似的结果:

$$ e_{man} - e_{woman} = \left[ \begin{matrix} -0.95 \\ 0.93 \\ 0.70 \\ 0.02 \\ \end{matrix} \right] -\left[ \begin{matrix} 0.97 \\ 0.85 \\ 0.69 \\ 0.01 \\ \end{matrix} \right] = \left[ \begin{matrix} -1.92 \\ -0.02 \\ 0.01 \\ 0.01 \\ \end{matrix} \right] \approx \left[ \begin{matrix} -2 \\ 0 \\ 0 \\ 0 \\ \end{matrix} \right] $$

我们可以通过某种降维算法,将向量映射到低纬度空间中,相似的词语位置较近,不相似的词语位置较远,这样能帮助我们更直观理解词嵌入对语义的表示。如下图所示:

word_embedding.png

  • 实际任务中,词汇量较大,表示维度较高,因此,我们不能手动为大型文本语料库开发词向量,而需要设计一种方法来使用一些机器学习算法(例如,神经网络)自动找到好的词嵌入,以便有效地执行这项繁重的任务。

词嵌入的优点

  • 特征稠密;
  • 能够表征词与词之间的相似度;
  • 泛化能力更好,支持语义计算。

文本表示方式总结

  1. 独热表示
  • 将词语表示成长度固定的向量,其中一位为1,其它为0
  • 优点:简单
  • 缺点:损失语义信息;维度灾难;特征稀疏
  1. 词袋模型(BOW)
  • 统计每个词出现的次数
  • 优点:表示简单
  • 缺点:损失了语言的顺序信息,损失较多语义信息
  1. TF-IDF
  • 词频-逆文档频率
  • 优点:能够部分程度表示词语语贡献度
  • 缺点:损失了语言的顺序信息,损失较多语义信息
  1. 共现矩阵
  • 统计词语共同出现的次数/频率
  • 优点:部分程度保留语义信息、词语的相似度
  • 缺点:维度灾难、特征稀疏
  1. N-Gram
  • N个词作为一组,保留出现的联合概率较高的,去掉联合概率较低的
  • 优点:部分程度保留语义信息、顺序信息
  • 缺点:计算困难,N取太小,语音保留较少;N取过大,计算量太大
  1. 词嵌入
  • 将词表示成一个实数、粘稠向量,又称分布式表示,词语语义就分布于这一组向量值中
  • 优点:最大程度保留语义信息支持语义级计算;特征密(存储和计算效率高)
  • 缺点:词向量需要表示成一个长度合适的向量;需要单独进行训练,一般来说训练周期较长