阅读视图

发现新文章,点击刷新页面。

开始入坑黑胶唱片

第一次知道黑胶唱片的概念,是在孙燕姿《任性》MV开头的歌手旁白:“50萬加侖的水,42公斤的體重,88個琴鍵,24分鐘的車程,33轉的LP,一個任性的我,一個離開的你。”

后来才知道,音乐播放器上以每分钟33⅓缓慢旋转的圆盘状物体,叫 Long Playing Record,简称LP,是一种的 PVC 类材料制成的唱片,常见尺寸为12英寸,在20世纪80年代CD唱片流行之前一直是音乐唱片首选的发行载体。最近,在机缘巧合的情况下,开始接触到黑胶唱片,并准备入坑。


黑胶唱片

我的初中时代,那时候流媒体 APP还未诞生,mp3音频播放器很贵,并未大范围流行。我总习惯周末放假时去步行街的实体店铺购买新出炉的华语CD专辑,如今本地的音像制品店,早于关门歇业,或转售其它商品。

不同于CD或流媒体的高保真,黑胶唱片的模拟录音技术保留了声音的连续波形,许多人认为其音质更“自然”“温暖”,更适合播放爵士乐和古典乐。同时,聆听音乐的仪式感,从收纳架上取出黑胶唱片、用静电刷清洁盘面到放下唱针的过程,让人更专注于音乐本身,而非快速切换曲目。此文,在流媒体主导的“快餐式”听歌时代,黑胶唱片代表了一种放慢节奏、沉浸式享受音乐的态度。黑胶唱片让听歌者专注于整张专辑主题概念,而非被流媒体平台的推荐算法支配。

而黑胶唱片的大尺寸封面、插画、歌词本等,往往设计精美,极具收藏价值,许多限量版或复古唱片本身就是艺术品。部分国内外歌手推出的黑胶是限量版或再版经典,具有升值潜力,成为收藏市场的热门标的,如周杰伦的黑胶唱片专辑套装,价格早已翻了几番。

上周,我人生的第一张黑胶唱片已经到货——小野丽莎的爵士经典专辑。另外,我在日本 CDJapan 网站海淘的几张黑胶唱片正在寄往中国的路途中。

黑胶唱片


黑胶唱机

有了黑胶唱片,怎么能没有黑胶唱机?我不是金耳朵,也没有追求 HIFI 的需求,买千余元左右、入门款的黑胶唱机即可。已下单铁三角 AT-LP70X 黑胶唱片机,预计4月初到货,到货后再放测评。

单铁三角 AT-LP70X 黑胶唱片机

LLM Prompt Engineering 实践:记忆(1)

本文是《LLM Prompt Engineering 实践》系列的第三篇文章,系列文章如下:

  1. LLM Prompt Engineering 实践:序 · ZMonster's Blog
  2. LLM Prompt Engineering 实践:原型 · ZMonster's Blog
  3. LLM Prompt Engineering 实践:记忆(1) · ZMonster's Blog

之前我提出了一些问题:

  1. ChatMessageHistory 是基于内存的历史消息记录方法,一旦退出历史对话消息将全部丢失
  2. 当讨论的话题更早之前聊过时,选择最近历史消息的做法无法利用之前的讨论
  3. 固定设置输出最大 token 数量的做法为了照顾长输出的情况,会导致短输出情况下无法利用更多的历史消息
  4. 一个全局设置的温度参数会影响机器人处理不同任务的表现,比如说像翻译、分类、事实性问答这些任务并不需要随机性,而写作、创意生成则有一定的随机性会更好一些
  5. 在当前这种模式下,将机器人属性设置放在最开始的做法无法保证设定的一致性和连续性,用户总是能通过有意构造的输入来突破这种设置

其中 1/2/3 涉及到记忆机制的问题,对这几个问题都可以独立地去思考和尝试解决,但在此之前,还是有必要再对这几个问题做更多的探讨。

我在之前 xorius 的开发日志里第一次提出使用历史对话记录时是这么说的:这段代码不是对话的模式,没有考虑聊天历史,如果要做聊天的话,需要维护一个聊天历史。

那么问题来了,为什么在响应当前用户输入的时候,要去考虑聊天历史呢?这种做法其实就是假设聊天历史对响应当前的用户输入有帮助,那么:

  1. 这个假设会一直成立吗?
  2. 什么情况下这个假设会成立?此时历史记忆和当前用户输入有哪些可能的相关关系?
  3. 假设成立的情况下,使用最近 K 轮历史记忆的做法,K 会有一个模型计算力能承受的上限吗?
  4. 假设成立的情况下,使用最近 K 轮历史记忆的做法,相关的内容在这 K 轮历史记忆中的占比会有多大?
  5. 假设成立的情况下,使用最近 K 轮历史记忆的做法,其中的无关内容会带来负面影响吗?如果会的话可能带来的负面影响有哪些?

对第 1 个问题,既然是假设,那么它当然不会一直成立,否则它就是真理而不是假设了,而之前 xorius 的实现里是在这一假设下实现了回答时对历史记忆的利用 —— 即无论何时都会尝试把历史记忆用上,因而也会自然而然地带来一定的副作用: 在响应不需要历史记忆的用户输入时,可能因为强制使用历史记忆而造成错误的回答。

对第 2 个问题,个人认为可能有以下几种情况:

  1. 当前输入提及了历史对话中的某些内容,比如
    • 使用代词提及历史对话中聊过的某个或某几个对象,这种情况称之为指代

      「它」指代「语言模型」

      You: 语言模型是什么
      
      Xorius:
      语言模型是一种计算机程序,用于预测自然语言文本中下一个单词或字符的概率分布。它是自然语言处理中的一个重要组成部分,可以用于许多任务,如语音识别、机器翻译、文本生成和自动摘要等。语言模型通常基于统计学方法或神经网络方法,通过学习大量的文本数据来建立模型,并使用该模型来预测下一个单词或字符的概率分布。在实际应用中,语言模型可以帮助计算机更好地理解和生成自然语言,从而提高自然语言处理的效果和质量。
      
      ------------------- 上方为历史对话 -------------------
      
      You: 它有智能吗
      
      Xorius:
      语言模型本身并没有智能,它只是一个程序,能够根据已有的文本数据预测下一个单词或字符的概率分布。然而,通过不断地训练和优化,语言模型可以逐渐提高其预测的准确性和效率,从而更好地模拟人类的语言习惯。此外,语言模型也可以与其他人工智能技术结合使用,如机器学习、深度学习、自然语言处理等,从而实现更加智能化的应用。因此,虽然语言模型本身没有智能,但它可以为人工智能的发展提供重要的支持和基础。
      
    • 提及历史对话中聊过的某个或某几个对象,但以省略的形式,这种情况称之为零指代

      「语言模型在语音识别中怎么用」省略了「语言模型」

      You: 语言模型是什么
      
      Xorius:
      语言模型是一种用于自然语言处理的技术,它可以对一段文本的语言结构进行建模,从而计算出该文本的概率。语可以用于很多应用,例如语音识别、机器翻译、自然语言生成等。在语音识别中,语言模型可以帮助识别出最有可能的语音转录结果;在机器翻译中,语言模型可以帮助翻译系统选择最合适的翻译结果;在自然语言生成中,语言模型可以帮助生成自然流畅的语言表达。常见的语言模型包括n-gram模型、神经网络语言模型等。
      
      ------------------- 上方为历史对话 -------------------
      
      You: 在语音识别中怎么用
      
      Xorius:
      在语音识别中,语言模型主要用于解决识别歧义的问题。当语音识别系统对一段语音进行转录时,可能会遇到多个候选结果,这些结果在音频信号上看起来很相似,但在语言表达上却有很大差异。这时,语言模型可以帮助系统选择最有可能的结果。
      
      具体来说,语言模型会对每个候选结果进行打分,计算出其在语言表达上的概率。然后,系统会选择得分最高的结果作为最终的转录结果。这样,语言模型可以帮助系统更准确地识别语音,提高识别准确率。
      
      在语音识别中,常用的语言模型包括n-gram模型和神经网络语言模型。n-gram模型是一种基于统计的模型,它通过计算文本中相邻的n个词出现的概率来建模。神经网络语言模型则是一种基于神经网络的模型,它通训练神经网络来预测下一个词的概率。这些模型都可以用于语音识别中的语言模型建模。
      
    • 使用代词提及历史对话中的比词语粒度更大的对象,比如说事件、观点、句子甚至更大段的话

      「最后一段」指代对历史对话回答中结尾部分的「他们成功地激活了这个装置……也激励了更多人类探索宇宙的冒险精神。」这段话

      You: 写一个木卫二上的冒险故事
      
      Xorius:
      在木卫二的表面,有一个被称为“红海”的巨大火山口。这个火山口的深度超过了地球上最深的海洋,被认为是太阳系中最深的地方之一。有一天,一组勇敢的宇航员决定探索这个神秘的地方。
      
      他们穿上了特制的宇航服,搭乘着一艘小型的探测器,开始了他们的冒险之旅。他们在探测器上看到了一些奇怪的能量信号,这些信号似乎来自深处的地下洞穴。
      
      他们决定跟随这些信号,进入了一个巨大的洞穴系统。在洞穴里,他们发现了一些奇怪的生物,这些生物似乎是在这个深处的环境中生存了很长时间。
      
      随着他们深入洞穴,他们发现了一些神秘的符号和图案,这些符号似乎是来自一个古老的文明。他们开始研究这些符号,试图理解这个文明的历史和目的。
      
      在他们的探险中,他们遇到了一些危险,包括地震和洪水。但他们始终坚持不懈,终于到达了洞穴的深处。在那里,他们发现了一个神秘的装置,这个装置似乎是一个能量生成器,可以为整个木卫二提供能源。
      
      他们成功地激活了这个装置,并将它带回了地球。这个发现改变了人类对木卫二的认识,也为人类提供了一种新的能源来源。这个冒险故事成为了人类历史上的一个重要事件,也激励了更多人类探索宇宙的冒险精神。
      
      ------------------- 上方为历史对话 -------------------
      
      You: 把最后一段翻译成英文
      
      Xorius: "They successfully activated the device and brought it back to Earth. This discovery changed humanity's understanding of Europa and provided a new source of energy for humanity. This adventure story became an important event in human history and inspired more people to explore the adventure spirit of the universe."
      
    • 提及历史对话中的比词语粒度更大的对象,比如说事件、观点、句子甚至更大段的话,但以省略的形式

      「压缩一下」省略了宾语,指的是历史对话里的整段回答「在遥远的未来……寻找未知的世界和可能性」

      You: 写一个火星流浪的故事
      
      Xorius:
      在遥远的未来,人类已经开始探索太空,其中包括火星。一名名叫艾米的宇航员在一次任务中不幸与同事失联,最终被遗弃在了火星上。
      
      艾米意识到自己已经被遗弃在了这个荒凉的星球上,没有任何人类的生命迹象。她开始寻找食物和水源,但很快发现火星上的环境非常恶劣,几乎没有任何生命存在的可能。
      
      在接下来的几个月里,艾米不断地寻找食物和水源,同时也努力修复她的通讯设备,希望能够联系到地球上的人类。但是,她的努力都没有任何回应。
      
      在孤独和绝望的日子里,艾米开始思考自己的人生和存在的意义。她开始重新审视自己的价值观和信仰,并逐渐接受了自己可能会永远孤独地生活在这个星球上的现实。
      
      然而,就在艾米准备放弃的时候,她收到了一条来自地球的信号。原来,地球上的科学家们一直在寻找她,并且已经派出了一支救援队伍来到火星上救援她。
      
      在救援队伍的帮助下,艾米最终成功返回地球,并成为了一名备受尊敬的宇航员。她的经历也激励了更多的人类探索太空,寻找未知的世界和可能性。
      
      ------------------- 上方为历史对话 -------------------
      
      You: 压缩一下,用更短的文字
      
      Xorius:
      一名宇航员在火星上被遗弃,孤独地寻找食物和水源,修复通讯设备,思考人生意义。最终收到地球救援信号,成功返回地球,成为备受尊敬的宇航员,激励更多人类探索太空。
      
  2. 历史记忆中的内容可以作为回答当前输入的知识,比如说
    • 历史记忆中提供了当前输入的某些知识的定义或详细说明,回答当前输入时正确使用历史记忆中提供的知识可以避免 AI 胡编乱造
    • 历史记忆中描述了用户(或 AI)的喜好、三观或其他个性化(或者说人格化?)的属性描述,并且当前输入是以这些个性化信息为背景的,那么正确地使用相关的历史记忆可以使得回答时表现出来的个性与历史记忆中的保持一致性、连续性
    • ……
  3. 上述两种情况的混合

在多轮对话的研究里,对历史记忆和当前输入的相关关系应该还会有更全面、更细致的分类,暂时不想去翻相关的研究资料了,等需要做更精细的处理的时候可能会去看看,现在主要是做一个大致的梳理,知道模型在利用聊天历史时存在不同情况即可。

在第 2 个问题的回答的基础上,可以对第 3 个问题做一定的回答

  • 如果当前输入和历史记忆的关系是第 1 种情况(当前输入提及了历史对话中的某些内容),可以认为这种情况下要用到的历史记忆都会是比较近的,也就是 K 不会太大,我个人认为是可以让模型直接处理的
  • 如果当前输入和历史记忆的关系是第 2 种情况(历史记忆中的内容可以作为回答当前输入的知识),那么相关的历史记忆未必是在最近,它完全可以是长期记忆里的内容,比如说我让它推荐书籍的时候希望它可以记起来一年前我说过我喜欢看科幻小说进而推荐一些最新的科幻小说,这种情况下这个 K 可能会非常非常大,个人认为这种情况下使用最近 K 轮历史记忆的做法将会超出模型的计算能力上限
  • 如果当前输入和历史记忆的关系是第 3 种情况(第 1 种情况和第 2 种情况的混合),那么同第 2 种情况

对于第 4 个问题,如果精力允许,或许可以去分析一下一些公开的多轮对话数据集来给出一个相对量化的结果,不过目前我并不想去做这个事情,提出这个问题,其实只是想说明一点 —— 即使在聊天历史对响应当前用户输入有帮助这个假设成立,并且有帮助的信息就在最近 K 轮历史记忆中的时候,也可能不是所有 K 轮历史记忆都是有帮助的。

第 5 个问题则是在第 4 个问题上更进一步思考,如果最近 K 轮历史记忆中有无关内容,那么它们一定是会带来负面影响的,它们可能会摊薄当前输入中 token 和历史记忆中有用内容的 attention 权重,进而导致回可能偏离预期。这种负面影响的大小一定程度上取决于模型本身的能力,很强的模型(比如 GPT-4)可能不会表现得很明显。

综上,使用最近 K 轮历史记忆的做法其实是会有一些问题的,但是这种方法是最容易实现的、最直观可理解的,并且在模型较强的时候效果也不错,加上现在也没有公认的更好的方案,所以大家普遍都这么做。

方法有缺陷是正常的,事实上也不存在哪个方法是完美的、没有任何问题的,但这个方法最大的问题不在于其假设,而在于它没有设计对应的错误应对或者说优化迭代的措施,一旦这个机制工作不正常,就只能期待模型自身的改进或者用户自己能够掌握所谓的 Prompt 编写技巧来再次尝试了。如果能对这些问题心知肚明并抱有适当的预期,那么使用这个方法倒也没什么问题,但如果使用了这个方法出了问题后就只会调侃 AI 弱智,那就没什么意思了。

LLM Prompt Engineering 实践:原型

本文是《LLM Prompt Engineering 实践》系列的第二篇文章,系列文章如下:

  1. LLM Prompt Engineering 实践:序 · ZMonster's Blog
  2. LLM Prompt Engineering 实践:原型 · ZMonster's Blog
  3. LLM Prompt Engineering 实践:记忆(1) · ZMonster's Blog

在 ChatGPT 的帮助下,给项目起了 xorius 这么一个名字,这个单词实际上并不存在,所以含义完全是由我来定义的,不过作为一个随时可能会鸽掉的项目,就先不说我给它赋予的意义了,以后做大做强再来说会比较合适一点。

起好名字后,我准备先实现一个最最简单的原型出来,并在过程中对一些问题进行讨论。后文所有内容都是我的开发日志的整理汇总,之后的文章基本上也会采用这个模式。

为了实现原型,有两个选择,一是直接去调用 OpenAI 的官方接口或 SDK,二是选择 langchain 之类的第三方封装,出于方便考虑,我选择了 langchain。

首先,无论是直接调用官方接口、SDK,还是使用 langchain,国内都是需要通过代理才能访问的:

import openai
openai.proxy = 'http://localhost:8080'

设置好代理后,用 langchain 就可以简单地调用 OpenAI 的接口来获得回答,比如说这样:

from langchain.chat_models import ChatOpenAI
from langchain.schema import HumanMessage

user_message = HumanMessage(content='将这句话翻译成中文: “to be or not to be”')

# 安全考虑这里把真实 API KEY 隐藏了,后文不再强调
llm = ChatOpenAI(temperature=0, openai_api_key='sk-xxxxxxxxxxxxxxxx')
answer = llm([user_message])
print(answer.content)

上面的代码没有指定要使用的模型,默认会使用 gpt-3.5-turbo,运行后得到了这样的结果

生存还是毁灭

当然,这段代码不是对话的模式,没有考虑聊天历史,如果要做聊天的话,需要维护一个聊天历史

from langchain.memory import ChatMessageHistory

memory = ChatMessageHistory()

之后每次的用户输入和得到的回答都可以按顺序添加到这个 memory 里面,比如说前面的 user_message 和 answer,都可以加到里面去

memory.add_user_message(user_message.content)
memory.add_ai_message(answer.content)

通过 memory.messages 可以得到历史记录

print(memory.messages)

结果

[HumanMessage(content='将这句话翻译成中文: “to be or not to be”', additional_kwargs={}, example=False),
 AIMessage(content='"生存还是毁灭"', additional_kwargs={}, example=False)]

然后在下一轮对话时使用它

user_message = HumanMessage(content='再翻译成日语')
answer = llm(memory.messages + [user_message])
print('Answer:', answer.content)

memory.add_user_message(user_message.content)
memory.add_ai_message(answer.content)
print('History')
print(memory.messages)

结果

Answer: "生きるか死ぬか"
History:
[HumanMessage(content='将这句话翻译成中文: “to be or not to be”', additional_kwargs={}, example=False),
 AIMessage(content='"生存还是毁灭"', additional_kwargs={}, example=False),
 HumanMessage(content='再翻译成日语', additional_kwargs={}, example=False),
 AIMessage(content='"生きるか死ぬか"', additional_kwargs={}, example=False)]

综合上面的代码,只要写一个循环,就能做持续的对话了

from langchain.chat_models import ChatOpenAI
from langchain.memory import ChatMessageHistory

memory = ChatMessageHistory()
llm = ChatOpenAI(temperature=0, openai_api_key='sk-xxxxxxxxxxxxxxxx')
while True:
    user_message = input('You: ').strip()
    if not user_message:
        continue

    if user_message.lower() in ('quit', 'exit'):
        break

    memory.add_user_message(user_message)
    answer = llm(memory.messages)
    print('BOT:', answer.content)
    memory.add_ai_message(answer.content)

不过上面这段代码是有问题的,因为实质上我是每次都把历史消息和当前输入拼接起来输入给 OpenAI 的接口,而 OpenAI 的接口对处理的文本总长度是有限制的,gpt-3.5-turbo 最大长度是 4096 个 token,gpt-4 最大支持 32768 个 token(这个 token 是语言模型里对文本切分后的最小单元,先不用细究),像上面这样一直把 user_message 和 answer 往 memory 里面加,那么只要对话轮次够多一定会超过这个限制的,超过了就会报错,所以必须要限制一下。需要注意的是,这个最大文本长度,是输入和输出加在一起算的,比如说如果用 gpt-3.5-turbo 然后输入就已经 4000 token 了,那么只能输出最长 96 个 token 的结果。

综上,为了保证不超出接口长度限制,需要只取 memory.messages 中的一部分,假设取的 memory.messages 中的这部分的总 token 数 为 L1,我们还要保证输出结果的总 token 数能达到 L2,那么必须保证 L1 + L2 < 4096,L2 可以在初始化 ChatOpenAI 的时候通过参数 max_tokens 来设置,默认没设置就是无限,那为了更好地计算 L1,需要显式设置一下它,比如说设置为 1024:

max_tokens = 1024
llm = ChatOpenAI(
    temperature=0,
    openai_api_key='sk-xxxxxxxxxxxxxxxx',
    max_tokens=max_tokens
)

在 L2=1024 的情况下,可以得到 L1 < 4096 - 1024 = 3072。接下来的问题就是按照这个最大 token 数来对 memory.messages 进行选择了,要做这个的话,首先要能计算出给定文本的 token 数量,语言模型里的 token 不完全等于字词,OpenAI 提供了一个在线工具展示把文本变成 token 的效果:

  • 英文基本上是一个词一个 token,有时会把一个词拆成几个 token,比如下面这个例子里 Tannhäuser 这个词就被拆成了 T/ann/h/ä/user 五个 token,但不管怎么说按字数算,英文最后的 token 数是小于字数的,下面这个例子的文本总字数是 230,token 数才 57

    gpt3_tokens_example_english.png

  • 中文的话一个字就可能拆成多个 token,这些 token 大都是不可理解的,所以总体来说 token 数会大于字数,下面这个例子里的文本字数是 94,token 数是 191

    gpt3_tokens_example_chinese.png

    注意,上面中文的例子是 GPT3 的编码器的效果,换成 gpt-3.5-turbo 的编码器后,中文的 token 数下降到了 124。

OpenAI 提供了 tiktoken 这个 python 库来做文本到 token 的编码转换

from tiktoken.model import encoding_for_model

encoder = encoding_for_model('gpt-3.5-turbo')

由于 gpt-3.5-turbo 模型使用的 Chat Completion 接口的输入不是普通的文本格式,计算输入的 token 数不能简单把用户输入和接口响应结果文本的 token 数加起来,OpenAI 自己给出了计算方法:

import tiktoken

def num_tokens_from_messages(messages, model="gpt-3.5-turbo-0301"):
    """Returns the number of tokens used by a list of messages."""
    try:
        encoding = tiktoken.encoding_for_model(model)
    except KeyError:
        encoding = tiktoken.get_encoding("cl100k_base")

    if model == "gpt-3.5-turbo-0301":  # note: future models may deviate from this
        num_tokens = 0
        for message in messages:
            num_tokens += 4  # every message follows <im_start>{role/name}\n{content}<im_end>\n
            for key, value in message.items():
                num_tokens += len(encoding.encode(value))
                if key == "name":  # if there's a name, the role is omitted
                    num_tokens += -1  # role is always required and always 1 token

        return num_tokens
    else:
        raise NotImplementedError(
            f"num_tokens_from_messages() is not presently implemented for model {model}.\n"
            "See https://github.com/openai/openai-python/blob/main/chatml.md "
            "for information on how messages are converted to tokens."
        )

注:为了方便计算单条消息的长度,我对 num_tokens_from_messages 做了一定的修改,删掉了 num_tokens += 2 这一行。

注2:经测试,OpenAI 提供的这个函数,计算出来的 token 数比实际的少 1 个。

基于这个函数可以来实现聊天历史的选择了,这里只简单实现一个取最近聊天历史的

from langchain.schema import AIMessage, HumanMessage

def select_messages(messages, max_total_tokens=4096, max_output_tokens=1024):
    tokens_num = 0
    selected = []
    for message in messages[::-1]:
        role = 'system'
        if isinstance(message, AIMessage):
            role = 'assistant'
        elif isinstance(message, HumanMessage):
            role = 'user'

        cur_token_num = num_tokens_from_messages([{'role': role, 'content': message.content}])
        if tokens_num + cur_token_num + 2 + max_output_tokens > max_total_tokens:
            break

        selected.append(message)
        tokens_num += cur_token_num

    selected = selected[::-1]
    if isinstance(selected[0], AIMessage): # 确保第一条是用户消息
        selected = selected[1:]

    if not selected:            # 假设 messages 里最后一条是当前用户输入
        selected = message[-1]

    return selected

那么前面的持续对话代码可以改造成这个样子了

from langchain.chat_models import ChatOpenAI
from langchain.memory import ChatMessageHistory

memory = ChatMessageHistory()

max_output_tokens, max_total_tokens = 1024, 4096
llm = ChatOpenAI(
    temperature=0,
    openai_api_key='sk-xxxxxxxxxxxxxxxx',
    max_tokens=max_output_tokens,
)
while True:
    user_message = input('You: ').strip()
    if not user_message:
        continue

    if user_message.lower() in ('quit', 'exit'):
        break

    memory.add_user_message(user_message)
    messages = select_messages(
        memory.messages,
        max_total_tokens=max_total_tokens,
        max_output_tokens=max_output_tokens
    )
    print(f"selected messages: {messages}")
    answer = llm(messages)
    print('BOT:', answer.content)
    memory.add_ai_message(answer.content)

把 max_output_tokens 设置得更大一些会选择更少的历史,比如说

  • max_output_tokens=200, max_total_tokens=4096 时,输入的最大 token 数可以到 3896,所以可以选到尽可能多的历史消息

    You: 将这句话翻译成中文: “to be or not to be”
    selected messages: [HumanMessage(content='将这句话翻译成中文: “to be or not to be”', additional_kwargs={}, example=False)]
    BOT: "生存还是毁灭"
    You: 再翻译成日语
    selected messages: [HumanMessage(content='将这句话翻译成中文: “to be or not to be”', additional_kwargs={}, example=False), AIMessage(content='"生存还是毁灭"', additional_kwargs={}, example=False), HumanMessage(content='再翻译成日语', additional_kwargs={}, example=False)]
    BOT: "生きるか死ぬか"
    
  • max_output_tokens=4050, max_total_tokens=4096,输入的最大 token 数只有 46,第二轮的时候没法使用前一轮的历史消息

    You: 将这句话翻译成中文: “to be or not to be”
    selected messages: [HumanMessage(content='将这句话翻译成中文: “to be or not to be”', additional_kwargs={}, example=False)]
    BOT: "生存还是毁灭"
    You: 再翻译成日语
    selected messages: [HumanMessage(content='再翻译成日语', additional_kwargs={}, example=False)]
    BOT: もう一度日本語に翻訳してください。
    

实现了聊天历史的选择后,一个最基本的原型其实就好了,进一步地,还可以为这个对话机器人做一些属性设定,比如说叫什么名字、什么性格之类的,具体的做法就是在每次调用语言模型时,除了聊天历史和当前输入,再额外输入一个描述这个机器人属性的 prompt,在 OpenAI 的接口里要求这条 prompt 的 role 设置为 system,用 langchain 的话直接用 SystemMessage 就好,我这里简单把这个 SystemMessage 放在输入的最前面,把代码改造成了这个样子:

from langchain.chat_models import ChatOpenAI
from langchain.memory import ChatMessageHistory
from langchain.schema import SystemMessage

memory = ChatMessageHistory()
system_message = SystemMessage(
    content=(
        "You are an AI assistant. Your name is xorius. "
        "You can discuss any ideas and topics with your users, "
        "and you will help your users solve their problems as much as you can."
    ),
)
max_total_tokens = 4096 - num_tokens_from_messages([{'role': 'system', 'content': system_message.content}])
max_output_tokens = 1024
llm = ChatOpenAI(
    temperature=0,
    openai_api_key='sk-xxxxxxxxxxxxxxxx',
    max_tokens=max_output_tokens,
)
while True:
    user_message = input('You: ').strip()
    if not user_message:
        continue

    if user_message.lower() in ('quit', 'exit'):
        break

    memory.add_user_message(user_message)
    messages = select_messages(
        memory.messages,
        max_total_tokens=max_total_tokens,
        max_output_tokens=max_output_tokens
    )
    print(f"selected messages: {messages}")
    answer = llm([system_message] + messages)
    print('BOT:', answer.content)
    memory.add_ai_message(answer.content)

这样让它介绍自己就会按照设定来回答了

You: 介绍一下你自己
selected messages: [HumanMessage(content='介绍一下你自己', additional_kwargs={}, example=False)]
BOT: 你好,我是Xorius,一名人工智能助手。我可以与您讨论各种话题,帮助您解决问题。我可以提供有关各种主题的信息,例如科技、文化、历史、健康、旅游等等。我还可以执行各种任务,例如设置提醒、发送电子邮件、搜索信息等等。请告诉我您需要什么帮助,我会尽力为您提供支持。

如果不加这个 system_message,同样的输入得到的回答里它会强调自己由 OpenAI 开发,如下所示:

You: 介绍一下你自己
selected messages: [HumanMessage(content='介绍一下你自己', additional_kwargs={}, example=False)]
BOT: 我是一名AI语言模型,由OpenAI开发。我可以进行自然语言处理和生成,帮助用户回答问题、翻译、写作等。我没有实体,只存在于计算机中,但我可以通过文字和语音与人类进行交互。我不会感到疲倦或犯错,但我也没有情感和意识。我只是一个程序,但我希望能够为人类提供帮助和便利。

最后,我将上述代码做一些调整后实现成了项目里的一个命令行工具,代码见 https://github.com/monsternlp/xorius ,也发布到了 pypi,执行 pip install xorius 即可安装,安装后即可在命令行执行 xorius-cli 来进入对话。

xorius_cli.png

命令行参数说明如下:

  • --api-key: 设置 OpenAI 的 API 密钥
  • --temperature: 设置温度参数,用于控制生成结果时的随性,0 的话不随机每次都生成概率最大的结果,默认 0.7
  • --max-tokens: 设置生成结果允许的最大 token 数,默认 512
  • --proxy: 设置 http 代理,主要针对国内网络环境

原型是实现了,但是在这个原型上,能看到一些直接的问题

  1. ChatMessageHistory 是基于内存的历史消息记录方法,一旦退出历史对话消息将全部丢失
  2. 当讨论的话题更早之前聊过时,选择最近历史消息的做法无法利用之前的讨论
  3. 固定设置输出最大 token 数量的做法为了照顾长输出的情况,会导致短输出情况下无法利用更多的历史消息
  4. 一个全局设置的温度参数会影响机器人处理不同任务的表现,比如说像翻译、分类、事实性问答这些任务并不需要随机性,而写作、创意生成则有一定的随机性会更好一些
  5. 在当前这种模式下,将机器人属性设置放在最开始的做法无法保证设定的一致性和连续性,用户总是能通过有意构造的输入来突破这种设置

    xorius_cli_2.png

这些问题将会在之后的文章中做进一步的讨论。

LLM Prompt Engineering 实践:序

本文是《LLM Prompt Engineering 实践》系列的第一篇文章,系列文章如下:

  1. LLM Prompt Engineering 实践:序 · ZMonster's Blog
  2. LLM Prompt Engineering 实践:原型 · ZMonster's Blog
  3. LLM Prompt Engineering 实践:记忆(1) · ZMonster's Blog

ChatGPT 刚发布不久,我就把它接进了飞书供公司同事使用,回过头去看当时的聊天记录,那个时候它还是会经常拒绝回答,虽然相比过去的对话机器人有了非常大的提高,但还是显得有点呆笨的样子,对于它到底能做什么我并不是特别了解,再加上公司裁员、北京疫情(12月上半个月是北京疫情的高峰期)的因素,我就没花太多精力在这方面。春节回来后,可能是因为社会放开了以及经过两个月的发酵,非常明显地感觉到讨论热度在上升,我也开始去做更详细的了解 —— 一方面是持续地去使用 ChatGPT 以及了解其他人怎么使用,另外一方面是开始看相关的论文了解背后的技术变迁。ChatGPT 在模型上除了 RLHF 外我是大致熟悉的,但是近几年困在工作和情绪里没怎么去关注学术进展了,所以二月、三月主要是在补课,顺着几篇比较高质量的综述论文把过去几年的一些工作和思路看了下,慢慢地就对现在大语言模型面临的问题以及可能的发展方向有了一些感觉。

毫无疑问,以 ChatGPT 为代表的大语言模型是很有价值的。从专业角度来看,如果不考虑成本的话,原来的大部分简单 NLP 任务都已经没有必要再做,有人因此担心 NLP 工程师要失业,但我觉得对 NLP 领域整体上来说是好事情,领域和行业的能力下限因此得到了提高,原来我们认为很复杂的、只能花昂贵代价做小规模应用的事情现在也可以得到普及(比如说基于文档的问答),我们也终于可以有更多的精力去讨论自然语言里更深层的原理和问题了。从用户角度看,ChatGPT 无疑也有非常大的潜能,回答知识性问题、辅助写作、翻译、总结、处理数据、写代码、绘图……似乎我们能想到的通过语言来做的事情,它都能做并且做得还不错,作为工具来说它已经大大提高了使用者处理事情的效率,并且还在被用户不断地挖掘出新的用法和能力,不管它的长期影响是好是坏,我想没有人会否认它很能干这个事实。

在受益于 ChatGPT 的同时,很多人也因此非常焦虑,我多少也有一点,这种焦虑的根源主要在于 ChatGPT 后续影响的不确定性。目前观察到的应对这种焦虑的方式主要有几种:

  1. 通过追踪新的资讯、动态来证明自己能跟上变化,但往往因为人的精力有限而使自己非常疲惫并造成进一步的焦虑;
  2. 通过预测(或看别人预测)大语言模型普及背景下的行业和社会未来以减轻焦虑,但是真的了解专业知识又具有宏观视野的人还是比较少,大部分非专业人士之间的相关讨论往往落入玄谈范畴,或者索性变成个人世界观的输出;
  3. 通过重度使用、开发相关工具或应用、推动相关社会治理规范来参与新生态的建设,但是由于目前尚处于新生态早期,工具和方法论都非常不完善,想象力也还是不够。

作为一个工程师,我不认为自己具备预测未来的宏观视野,但我想我还是可以参与到新生态建设里去的。以 ChatGPT 为代表的大语言模型效果确实非常强,但它也还是有非常多问题的,和所有机器学习模型一样,这些问题在之后有可能会慢慢地得到缓解,但绝对不会消失,如果要将大语言模型作为一个基础设施来构建应用,那么这些问题必然要有相应的应对机制,我相信新的生态会在这个基础上生长出来。这些问题已经在大量的论文中被提出讨论了,我自己也有维护相应的笔记来追踪这些问题的研究进展,本来是想以各个问题作为出发点来写一些文章做一些讨论的,不过不少问题即使在研究上也还处在探索讨论的阶段,只是单纯整理出来作为科普觉得会有点枯燥,于是决定写一个项目,通过代码的方式来展示我对这些问题的应对方法的思考和尝试。

对大语言模型,我们目前已经知道的问题有:

  1. 模型偏差问题: 模型会表现出对特定领域的倾向性而不是在各种问题上完全中立客观,在有上下文时模型会表现出近期偏差和标签偏差;
  2. 评估问题: 对输入和输出结果,我们缺乏足够有效的评估手段;
  3. 知识幻觉问题: 有时候语言模型会输出一些与给定上下文或客观事实不符的内容,该问题是由训练数据噪声、表示学习的不充分、解码方法、记忆机制缺失等众多复杂因素导致的;
  4. 记忆机制的缺失: 语言模型本身未设计任何记忆机制,以 ChatGPT 为代表的产品仅能将需要记忆的内容同其他内容一起输入给语言模型,需要模型自己对区分输入中哪部分是记忆、哪部分是指令及其他内容,也因此无法像人一样维护长期记忆;
  5. 多轮对话状态管理: 在对话模式下,对话场景的开始、跳出、返回、结束,目前全依靠语言模型自身进行辨别但做得并不是特别好;
  6. 复杂结构数据的处理: 对体量较大但内部结构简单的数据目前可以切片处理,但对内部结构复杂的数据(如完整的项目源代码)目前要处理还是比较麻烦;
  7. 复杂操作的执行: 人类往往对自己给出的指令的复杂性不太敏感,有时候认为理所当然的简单要求实际上可能需要分解成复杂的步骤才能完成,分解后的步骤之间也可能存在复杂的关系。

我准备写的这个项目,计划由易而难地推进,最开始应该就是实现一个简单的机器人,设定一个初始 Prompt,每次对话时带上之前的聊天历史,然后逐步实现记忆机制、加上对知识幻觉的处理、整合阅读网页和文档的能力、整合图像和音频理解能力、加上任务分解和执行能力、实现人设的一致性和连续性、模拟出自我意识、统治人类……这些都不难,多多少少也在很多其他项目中实现过了,大概率不会有特别突破性的东西,甚至有可能鸽掉……总之,我现在要去解决最困难的一个问题了,那就是,这个项目该叫什么名字?

NLP哪里跑: 文本分类工具一览

本文是《NLP 哪里跑》系列的第四篇文章,系列文章如下:

  1. NLP哪里跑: 开篇及一些碎碎念 · ZMonster's Blog
  2. NLP哪里跑: 什么是自然语言处理 · ZMonster's Blog
  3. NLP哪里跑: Unicode相关的一些小知识和工具 · ZMonster's Blog
  4. NLP哪里跑: 文本分类工具一览 · ZMonster's Blog

关于文本分类

所谓的文本分类,其实就是机器学习中分类问题在 NLP 领域中的应用,它的理论相对简单、工具成熟、上手简单,大家基本上是把它当作一个「理论上已经解决」的问题来看待,但其实在实际场景中处理文本分类任务时还是会遇到不少问题的,加上文本分类又是 NLP 领域中最常见的任务之一,我想把自己在这方面的一些经验和学习成果慢慢地整理出来。

2017 年的时候,为了提高做文本分类任务的效率,将 sklearn 中的文本分类功能做了一些封装,后来断断续续地优化,产出了一个我自己用起来很顺手的文本分类工具。在我开始写《NLP 哪里跑》这个系列的博客后,我计划是把自己在 NLP 方面的经验进行系统地梳理,第一块就是文本分类 —— 这一块当然有很多很多想讲的东西和想做的事情,一篇文章是写不完的,所以最初的想法是先看一下工具方面的情况。我对我在公司维护的文本分类工具还是挺满意的,但也会想自己会不会对一些先进的工具认识不够,所以就去了解了很多其他的同类工具,本篇文章就是对这些工具的一个简单的罗列,不涉及分类模型的理论,也不涉及某个分类模型的具体实现的优劣评价,仅仅是一个非常工具向、实用向的整理记录。

我在挑选文本分类工具时是有一些标准的,不是非常严格,但大概能分成以下几点:

  • 工程化程度良好的,能提供易用的编程接口或命令行接口
  • 以 Python 生态内的工具为主 —— 很多其他语言实现的同类工具,限于精力就没有了解了

后面的内容会分成两块:第一部分讲我的踩坑经历,主要是一些本来以为会好用的工具结果发现不符合我标准的情况;第二部分是我实验之后确认可用的工具和它们的使用方法。

踩坑列表

本节中列举的工具,建议读者不要浪费时间在上面。

腾讯的 NeuralClassifier

注:下文仅代表个人观点和感受,不服不管。

三个月前腾讯开源的,见:【开源公告】NeuralNLP-NeuralClassifier - 深度学习文本分类工具 - 云+社区 - 腾讯云

项目地址:https://github.com/Tencent/NeuralNLP-NeuralClassifier

在我的列表里最坑的一个:

  • 作为一个 Python 项目,没有发布到 pypi 就算了,连 setup.py 也没有,无法安装,项目组织也没眼看,只能像个原始人一样拷贝代码到自己的目录跑跑脚本,很难想象是一个大厂的项目
  • 训练需要使用一个格式复杂的 json 格式的配置文件,然后这个配置的文档太过简略,不少细节藏在项目提供的脚本里……

最终我魔改了一通后跑起来了,但是已经恶心到我了,弃。

无人维护的 keras-text

项目地址:https://github.com/raghakot/keras-text

这个项目我觉得蛮可惜的,从文档和实际使用来看,作者在代码结构和使用流程上是做了一些用心的设计的,但是有些关键模块没有完成,在很多细节上存在令人难以忍受的小问题。

项目已经两年没有更新了,可以参考它的代码,但不建议作为一个正经的工具在实际任务中使用。

差强人意的 text-classification-keras

项目地址:https://github.com/jfilter/text-classification-keras

该项目是对 keras-text 项目的改进,总体上来说是一个可用的项目,但存在以下问题:

  • 使用文档不齐全
  • 代码上仍然有一些致命伤,比如
    • 将文本转成特征向量后,先存了一个文件,然后从文件中加载后再喂给模型……意义不明……
    • 每次调用的时候都要把 spaCy 的模型重新加载一遍,慢得要死

作者应该是从 keras-text 项目 fork 过来然后改成能用的状态的,也挺不容易的,但不管怎么说在我这里是一个不合格的工具。

可用的文本分类工具及其使用方法

使用 NLTK 进行文本分类

安装: pip install nltk

文档: https://www.nltk.org/api/nltk.classify.html

NLTK 提供了大量的文本处理方法,同时提供了通用的分类器接口,组合起来就能进行文本分类了。

以其中的朴素贝叶斯 NaiveBayesClassifier 为例,可以这样来进行文本分类

  • 实现一个方法,将文本转成 dict 形式的特征

    以英文为例,可以直接用 NLTK 中的分词方法,需要的话还可以加上 stemming 或者 lemmatization

    from nltk.corpus import stopwords
    from nltk.tokenize import word_tokenize
    
    
    def extract_feature(text):
        feature = {}
        for word in word_tokenize(text):
            if word not in stopwords:
                feature[word] = 1
    
        return feature
    

    中文的话可以换成 jieba 或者其他中文分词工具。

  • 训练

    from nltk.classify import NaiveBayesClassifier
    
    
    train_texts = [
        # ...
    ]
    train_labels = [
        # ...
    ]
    
    train_features = [extract_feature(text) for text in train_texts]
    train_samples = list(zip(train_features, train_labels))
    classifier = NaiveBayesClassifier.train(train_samples)
    
  • 评估

    from nltk.classify import accuracy
    
    test_texts = [
        # ...
    ]
    test_labels = [
        # ...
    ]
    
    test_features = [extract_feature(text) for text in test_texts]
    test_samples = list(zip(test_features, test_labels))
    acc = accuracy(classifier, test_samples)
    
  • 预测

    用 classify 方法直接预测最可能的类别

    text = "blablabla"              # 待预测的文本
    feature = extract_feature(text)
    pred_label = classifier.classify(feature)
    

    用 prob_classify 方法获得所有可能类别的预测分数

    text = "blablabla"              # 待预测的文本
    feature = extract_feature(text)
    prob = classifier.prob_classify(feature)
    
  • 模型保存和读取

    可以直接用 pickle 保存、读取训练好的模型

    保存:

    import pickle
    
    with open('model.pkl', 'wb') as f:
        pickle.dump(classifier, f)
    

    读取:

    import pickle
    
    classifier = None
    with open('model.pkl', 'rb') as f:
        classifier = pickle.load(f)
    

NLTK 中还有其他分类器,使用方法和 NaiveBayesClassifier 大同小异。

使用 TextBlob 进行文本分类

注意:TextBlob 仅支持英文

安装: pip install textblob

文档: https://textblob.readthedocs.io/en/dev/classifiers.html

TextBlob 是一个基于 NLTK 的文本处理工具,其中的文本分类功能也是建立在 NLTK 中分类器的基础上的。

  • 训练

    from textblob.classifiers import NaiveBayesClassifier
    
    train_texts = [
        # ...
    ]
    train_labels = [
        # ...
    ]
    train_samples = list(zip(train_texts, train_labels))
    classifier = NaiveBayesClassifier(train_samples)
    
  • 评估

    test_texts = [
        # ...
    ]
    test_labels = [
        # ...
    ]
    test_samples = list(zip(test_texts, test_labels))
    acc = classifier.accuracy(test_samples)
    
  • 预测

    只有一个 classify 接口预测得到最有可能的类别

    label = classifier.classify("this is a sentence to be classified")
    
  • 模型保存和读取

    同 NLTK。

相比 NLTK 中原来的文本分类,TextBlob 的封装隐藏了一些细节,简化了接口,用起来还是挺方便的。不好的一点是,TextBlob 里强制依赖了 NLTK 里的 word_tokenize,虽然说 word_tokenize 可以通过 language 参数设置语言,但在 TextBlob 里没有提供传递这个参数的机会,这就导致 TextBlob 只能对英文进行分类。

使用 TextGrocery 进行文本分类

注意:TextGrocery 仅支持 Python2

安装: pip install tgrocery

文档: https://github.com/2shou/TextGrocery/blob/master/README_CN.md

  • 初始化

    需要给分类器指定一个名字

    from tgrocery import Grocery
    
    classifier = Grocery('test')
    

    默认使用 jieba 作为分词器,但也支持在初始化分类器的时候通过 custom_tokenize 参数来自定义分词器

    classifier = Grocery('test', custom_tokenize=list)
    

    要求 custom_tokenize 的参数值是一个 python 的函数。

  • 训练

    支持传入 python 数据进行训练:

    train_src = [
        ('education', '名师指导托福语法技巧:名词的复数形式'),
        ('education', '中国高考成绩海外认可 是“狼来了”吗?'),
        ('sports', '图文:法网孟菲尔斯苦战进16强 孟菲尔斯怒吼'),
        ('sports', '四川丹棱举行全国长距登山挑战赛 近万人参与')
    ]
    classifier.train(train_src)
    

    也支持从文件中读取训练数据然后训练,要求文件中一行是一个数据,且行中有一个分隔符把文本和文本的类别标签分隔开,如用竖线分隔:

    education|名师指导托福语法技巧:名词的复数形式
    education|中国高考成绩海外认可 是“狼来了”吗?
    sports|图文:法网孟菲尔斯苦战进16强 孟菲尔斯怒吼
    sports|四川丹棱举行全国长距登山挑战赛 近万人参与
    

    假设上面的内容存储在 train.txt 中,则将 train.txt 作为 train 的参数,同时要用 delimiter 参数指明分隔符

    classifier.train('train.txt', delimiter='|')
    
  • 评估

    test_src = [
        ('education', '福建春季公务员考试报名18日截止 2月6日考试'),
        ('sports', '意甲首轮补赛交战记录:米兰客场8战不败国米10年连胜'),
    ]
    report = classifier.test(test_src)
    report.show_result()
    

    上述代码会输出如下内容:

                   accuracy       recall
    education      50.00%         100.00%
    sports         0.00%          0.00%
    

    也可以从文件中读取数据进行评估,文件的要求同训练

    report = classifier.test('test.txt', delimiter='|')
    report.show_result()
    
  • 预测

    使用 predict 接口来进行预测

    preds = classifier.predict('意甲首轮补赛交战记录:米兰客场8战不败国米10年连胜')
    print preds.dec_values         # => {'education': 0.00604235155848336, 'sports': -0.006042351558483356}
    print preds.predicted_y        # => education
    print str(preds)               # => education
    
  • 模型保存和读取

    用 save 方法来保存模型

    classifier.save()
    

    保存模型时会用分类器初始化时给的名字来创建一个目录,比如最开始给的名字是 test,所保存的模型会在 test 目录下,如下所示:

    test
    ├── converter
    │   ├── class_map.config.pickle
    │   ├── feat_gen.config.pickle
    │   └── text_prep.config.pickle
    ├── id
    └── learner
        ├── idf.pickle
        ├── liblinear_model
        └── options.pickle
    

    用相同的名字创建一个分类器,然后执行 load 方法来读取模型

    classifier = Grocery('test', custom_tokenize=list)
    classifier.load()
    

    这里需要注意的是,保存模型的时候,自定义的分词器是没有被保存下来的,所以在读取的时候,还需要重新设置一下分词器。

TextGrocery 是一个基于 liblinear 的小巧的文本分类实现,可惜作者已经放弃维护了,目前只能在 Python2 环境里面使用。

使用 sklearn 进行文本分类

安装: pip install scikit-learn

文档: https://scikit-learn.org/stable/tutorial/text_analytics/working_with_text_data.html#training-a-classifier

sklearn 中实现了很多的分类器,并且提供了统一的接口,我个人是比较喜欢的。

  • 训练

    首先创建一个 vectorizer 用来将文本编码成向量,最常用的可能是 TfidfVectorizer 了

    from sklearn.feature_extraction.text import TfidfVectorizer
    
    vectorizer = TfidfVectorizer()
    

    默认会按空格来分词,如果需要自定义分词器,可以通过 tokenizer 参数来传入一个函数,比如

    import jieba
    
    def jieba_tokenize(text):
        return jieba.lcut(text)
    
    vectorizer = TfidfVectorizer(tokenizer=jieba_tokenize)
    

    注意:由于 jieba 中加了线程锁,将 jieba.lcut 直接传入,会导致模型无法保存

    这个 vectorizer 是需要训练的

    texts = [
        '名师指导托福语法技巧',
        '中国高考成绩海外认可',
        '法网孟菲尔斯苦战进16强',
        '四川丹棱举行登山挑战赛',
    ]
    vectorizer.fit(texts)
    

    一旦训练后,对任意一个文本,会产生一个固定长度的向量,比如:

    print(vectorizer.transform(['名师指导托福语法技巧']).toarray())
    

    上面的代码会输出

    [[0.        0.        0.        0.        0.4472136 0.        0.
      0.        0.        0.4472136 0.4472136 0.4472136 0.        0.
      0.        0.        0.        0.        0.        0.4472136 0.
      0.       ]]
    

    向量化还有其他方法,如下:

    from sklearn.feature_extraction.text import (
        TfidfVectorizer,
        CountVectorizer,
        HashingVectorizer,
    )
    from sklearn.feature_extraction import DictVectorizer
    

    创建一个分类器,比如 SVM

    from sklearn.svm import LinearSVC
    
    classifier = LinearSVC()
    

    如果想使用 GBDT 分类器的话,可以执行 pip install xgboost 安装 XGBoost 这个包,它提供了符合 sklearn 规范的接口,可以直接使用并像 sklearn 的分类器一样用在后面的训练、预测过程中:

    from xgboost import XGBClassifier
    
    classifier = XGBClassifier()
    

    首先用 vectorizer 将训练数据中的文本转成矩阵,然后喂给分类器进行训练

    train_texts = [
        # blablabla
    ]
    train_labels = [
        # blablabla
    ]
    train_feats = vectorizer.transform(train_texts)
    classifier.fit(train_feats, train_labels)
    
  • 评估

    用分类器的 score 方法可以计算测试集的 accuracy

    test_texts = [
        # ...
    ]
    test_labels = [
        # ...
    ]
    test_feats = vectorizer.transform(test_texts)
    acc = classifier.score(test_feats, test_labels)
    

    这个方法其实是调用了 accuracy_score 这个函数,所以也可以自己来计算

    from sklearn.metrics import accuracy_score
    
    pred_labels = classifier.predict(test_feats)
    acc = accuracy_score(test_labels, pred_labels)
    

    还可以用 classification_report 这个函数来得到更详细的评估报告

    from sklearn.metrics import classification_report
    
    pred_labels = classifier.predict(test_feats)
    print(classification_report(test_labels, pred_labels))
    

    输出结果是下面这个样子的:

                  precision    recall  f1-score   support
    
         class 0       0.50      1.00      0.67         1
         class 1       0.00      0.00      0.00         1
         class 2       1.00      0.67      0.80         3
    
        accuracy                           0.60         5
       macro avg       0.50      0.56      0.49         5
    weighted avg       0.70      0.60      0.61         5
    

    有时候我们还需要输出分类的混淆矩阵,虽然 sklearn 提供了 sklearn.metrics.confusion_matrix 这个方法来计算混淆矩阵,但它的输出不够直观,我个人比较喜欢用 pandas.crosstab

    import pandas
    
    pred_labels = classifier.predict(test_feats)
    cnf_matrix = pandas.crosstab(
        pandas.Series(test_labels), pandas.Series(pred_labels),
        rownames=['targets'], colnames=['preds']
    )
    

    输入结果是下面这个样子:

    preds     negative  positive
    
    targets
    negative       590       126
    positive       383       901
    
  • 预测

    用 predict 方法来预测最可能的类别,或用 predict_proba 方法来获得所预测类别的分数

    texts = ['text1', 'text2', 'text3']
    feats = vectorizer.transform(texts)
    labels = classifier.predict(feats) # labels: ['label1', 'label2', 'label3']
    # or
    prob_list = classifier.predict_proba(feats)
    # prob_list:
    # [
    #     {'label1': 0.1, 'label2': 0.3, 'label3': 0.6},
    #     {'label1': 0.1, 'label2': 0.3, 'label3': 0.6},
    #     {'label1': 0.1, 'label2': 0.3, 'label3': 0.6},
    # ]
    

    注意 sklearn 中的 predict/predict_proba 都被设计为批量预测,没有单个数据预测的接口。

  • 模型保存和读取

    保存模型用 pickle 或者 joblib 都可以,注意要把 vectorizer 和 classifier 一起保存。

    import pickle
    from sklearn.externals import joblib
    
    with open('model.pkl', 'wb') as f:
        data = [vectorizer, classifier]
        pickle.dump(data, f)
    
    # or
    data = [vectorizer, classifier]
    joblib.dump(data, 'model.pkl')
    

    如果使用 pickle.dump 保存的模型,则用 pickle.load 来读取;如果是用 joblib.dump 保存的则用 joblib.load 读取

    vectorizer, classifier = None, None
    with open('model.pkl', 'rb') as f:
        vectorizer, classifier = pickle.load(f)
    
    # or
    vectorizer, classifier = joblib.load('model.pkl')
    

除了上面这样先创建 vectorizer 再创建 classifier 的方法,sklearn 还提供了 Pipeline 这个类来简化这个过程,非常推荐使用。

创建 vectorizer 和 classifier 后,用 Pipeline 把它们组合起来:

from sklearn.pipeline import Pipeline

vectorizer = TfidfVectorizer()
classifier = LinearSVC()
pipeline = Pipeline([('vec', vectorizer), ('model', classifier)])

然后可以直接将文本喂给 pipeline,不用自己再去调用 vectorizer.fit 和 vectorizer.transform 来将文本编码成向量了!

train_texts = [
    # blablabla
]
train_labels = [
    # blablabla
]
pipeline.fit(train_texts, train_labels)

评估、预测和非 pipeline 方式的差不多,都是可以省略掉将文本转成向量的这个步骤;模型保存时只需要将 pipeline 保存成文件即可。

使用 FastText 进行文本分类

安装: pip install fasttext

文档: https://fasttext.cc/docs/en/python-module.html#text-classification-model

  • 数据格式

    fasttext 的训练和评估都只能从文件中读取数据,而不能直接传入 Python 的值,而且对文件的格式是有要求的

    1. 文件中一行一个样本
    2. 每行用制表符分隔,第一列是标签,第二列是文本
    3. 第一列的标签要有 __label__ 前缀
    4. 第二列的文本必须是用空格分隔的词序列,对中文来说,意味着需要先分好词

    文件内容示例如下:

    __label__education	名师 指导 托福 语法 技巧 : 名词 的 复数 形式
    __label__education	中国 高考 成绩 海外 认可 是 “ 狼 来了 ” 吗 ?
    __label__sports	图文 : 法网 孟菲尔斯 苦战 进 16强 孟菲尔斯 怒吼
    __label__sports	四川 丹棱 举行 全国 长距 登山 挑战赛 近 万人 参与
    
  • 训练

    假设训练数据按照前面的要求写在了 train_data.txt 里,则用下面的代码来训练:

    import fasttext
    
    model = fasttext.train_supervised('train_data.txt')
    
  • 评估

    假设测试数据在 test_data.txt 中,使用 test 方法来评估模型效果,它会返回数据集中的样本数量,以及 precesion 和 recall 值:

    num, precesion, recall = model.test('test_data.txt')
    

    也可以用 test_label 方法获得每个类别的 precesion、recall 和 f1 值:

    print(model.test_label('test_data.txt'))
    

    输出结果是下面这个样子的:

    {
        '__label__education': {
            'precision': 0.8830022075055187,
            'recall': 0.8784773060029283,
            'f1score': 0.8807339449541285
        },
        '__label__sports': {
            'precision': 0.883881230116649,
            'recall': 0.853121801432958,
            'f1score': 0.8682291666666667
        }
    }
    
  • 预测

    用 predict 接口来对单条文本进行预测,同样要求文本是用空格分隔的、分好词的

    top3_labels, top3_scores = model.predict('土豆网 拟 明年 登陆 纳市 募资 1.5 亿美元', k=3)
    
  • 模型保存和读取

    保存

    model.save_model('model.bin')
    

    读取

    import fasttext
    model = fasttext.load_model('model.bin')
    

使用 Kashgari 进行文本分类

安装: pip install kashgari-tf tensorflow==1.14.0

文档: https://kashgari.bmio.net/

Kashgari 是一个基于神经网络模型的 NLP 工具,内部实现大多数常用的神经网络模型,也支持了最新的 BERT,使用体验挺不错的。

进行常规的文本分类

  • 训练

    Kashgari 要求输入的文本是分好词的,把分词的事情留给用户自己处理。不过分好词就能直接输入到模型中了,不需要像 sklearn 一样通过 vectorizer 转成向量:

    from kashgari.tasks.classification.models import CNN_Model
    
    train_x = [['Hello', 'world'], ['Hello', 'Kashgari']]
    train_y = ['a', 'b']
    
    model = CNN_Model()
    model.fit(train_x, train_y)
    

    训练时还可以设置校验集

    val_x = [['Hello', 'world'], ['Hello', 'Kashgari']]
    val_y = ['a', 'b']
    
    model.fit(val_x, val_y)
    
  • 评估

    test_x = [['Hello', 'world'], ['Hello', 'Kashgari']]
    test_y = ['a', 'b']
    model.evaluate(test_x, test_y)
    

    会打印测试结果到标准输出,其内容是下面这个格式的:

                  precision    recall  f1-score   support
    
          sports     1.0000    1.0000    1.0000      1000
       education     1.0000    0.9980    0.9990      1000
      technology     0.9930    1.0000    0.9965      1000
    
        accuracy                         0.9985     10000
       macro avg     0.9985    0.9985    0.9985     10000
    weighted avg     0.9985    0.9985    0.9985     10000
    
  • 预测

    使用 predict 方法来预测最可能的类别

    tokens = ['姚明', ':', '对', '奥尼尔', '不得', '不服']
    model.predict([tokens])          # => ['sports']
    

    或者用 predict_top_k_class 来获取 topk 的预测结果及分数

    print(model.predict_top_k_class([tokens], top_k=3))
    

    结果

    [
        {
            'label': 'sports',
            'confidence': 0.50483656,
            'candidates': [
                {'label': 'education', 'confidence': 0.057417843},
                {'label': 'technology', 'confidence': 0.048766118},
            ]
        }
    ]
    
  • 模型保存和读取
    • 保存

      使用 save 方法将模型保存到 test 目录中,目录不存在会创建

      model.save('test')
      

      目录中会有一个描述模型结构的 model_info.json 和记录模型参数的 model_weights.h5

      test
      ├── model_info.json
      └── model_weights.h5
      
      0 directories, 2 files
      
    • 读取

      使用 kashgari.utils.d_model 来读取模型

      from kashgari.utils import load_model
      
      model = load_model('test')
      

基于 BERT 进行文本分类

先下载 BERT 模型。

中文的话可以用 Google 开放的: https://storage.googleapis.com/bert_models/2018_11_03/chinese_L-12_H-768_A-12.zip

中文模型下载后解压得到 chinese_L-12_H-768_A-12 这个目录

然后创建基于 BERT 的分类器

from kashgari.embeddings import BERTEmbedding
from kashgari.tasks.classification.models import CNN_Model

embedding = BERTEmbedding('chinese_L-12_H-768_A-12/', task=kashgari.CLASSIFICATION)
model = CNN_Model(embedding)

之后的训练、评估、预测,都和非 BERT 的模型一样。

默认情况下 BERTEmbedding 被设置为不可训练,如果需要对 BERT 进行 finetuning 的话,那么按如下设置:

embedding = BERTEmbedding('chinese_L-12_H-768_A-12/', task=kashgari.CLASSIFICATION, trainable=True)

使用 AllenNLP 进行文本分类

安装: pip install allennlp

文档: https://allennlp.org/tutorials

进行常规的文本分类

AllenNLP 完全通过配置文件来对数据处理、模型结果和训练过程进行设置,最简单的情况下可以一行代码不写就把一个文本分类模型训练出来。下面是一个配置文件示例:

{
    "dataset_reader": {
        "type": "text_classification_json",
        "tokenizer": {
            "type": "word",
            "word_splitter": {
                "type": "jieba",
            }
        }
    },
    "train_data_path": "allen.data.train",
    "test_data_path": "allen.data.test",
    "evaluate_on_test": true,
    "model": {
        "type": "basic_classifier",
        "text_field_embedder": {
            "tokens": {
                "type": "embedding",
                "embedding_dim": 100,
                "trainable": true
            }
        },
        "seq2vec_encoder": {
            "type": "cnn",
            "embedding_dim": 100,
            "num_filters": 1,
            "ngram_filter_sizes": [2, 3, 4]
        }
    },
    "iterator": {
        "type": "bucket",
        "sorting_keys": [["tokens", "num_tokens"]],
        "batch_size": 64
    },
    "trainer": {
        "num_epochs": 40,
        "patience": 3,
        "cuda_device": -1,
        "grad_clipping": 5.0,
        "validation_metric": "+accuracy",
        "optimizer": {
            "type": "adam"
        }
    }
}

配置文件中的内容可以分成

  • 数据部分: 包括 dataset_reader/train_data_path/test_data_path 这几个 key 及其 value
  • 模型部分: 就是 model 这个 key 的内容
  • 训练部分: 包括 evaluate_on_test/iterator/trainer 这几个 key 及其 value

由于本文不是专门介绍 AllenNLP 的文章,所以只对这些配置做简要说明,详细内容可查看文档。

  • 数据部分

    train_data_path 和 test_data_path 比较好理解,它们指定了训练数据和测试数据的文件路径;而 data_reader 则限定了数据文件的格式。

    data_reader 中的配置,会被用来构建一个 DatasetReader 的子类的对象,用来读取数据并转换成一个个 Instance 对象。

    • 内置的可用来读取分类数据的 DataReader 是 TextClassificationJsonReader ,所以配置中有

      "type": "text_classification_json"
      

      这个 type 的值是 TextClassificationJsonReader 这个类实现的时候注册上的,去看代码会看到有这样的片段

      @DatasetReader.register("text_classification_json")
      class TextClassificationJsonReader(DatasetReader):
      

      这个 TextClassificationJsonReader 要求的数据文件是一行一个 json 数据,如下:

      {"label": "education", "text": "名师指导托福语法技巧:名词的复数形式"}
      {"label": "education", "text": "中国高考成绩海外认可是“狼来了”吗?"}
      {"label": "sports, "text": "图文:法网孟菲尔斯苦战进16强孟菲尔斯怒吼"}
      {"label": "sports, "text": "四川丹棱举行全国长距登山挑战赛近万人参与"}
      
    • DataReader 通过配置中 tokenizer 部分会创建一个分词器,用来将文本转换为词序列

      "tokenizer": {
          "type": "word",
          "word_splitter": {
              "type": "jieba",
          }
      }
      

      type 的值设置为 word,这没什么好说的。

      tokenizer 中的 word_splitter 指定的才是真正的分词器(比较绕)。

      如果是英文的数据,那么 word_splitter 的配置可以不写,默认就是支持英文分词的。

      但如果是用于中文处理的话,有一个 SpacyWordSplitter 可以用于中文分类,但是现有的中文 spaCy 模型仅支持 spaCy 2.0.x,和 AllenNLP 中 spaCy 要求的版本不兼容,这个是比较坑的。

      好在 AllenNLP 提供了加载自定义模块的方法,按照如下方法来处理这个问题

      mkdir allen_ext/
      touch allen_ext/__init__.py
      touch allen_ext/word_splitter.py
      

      然后在 allen_ext/word_splitter.py 中写入如下内容

      from typing import List
      
      import jieba
      from overrides import overrides
      from allennlp.data.tokenizers.token import Token
      from allennlp.data.tokenizers.word_splitter import WordSplitter
      
      
      @WordSplitter.register('jieba')
      class JiebaWordSplitter(WordSplitter):
      
          def __init__(self):
              pass
      
          @overrides
          def split_words(self, sentence: str) -> List[Token]:
              offset = 0
              tokens = []
              for word in jieba.lcut(sentence):
                  word = word.strip()
                  if not word:
                      continue
      
                  start = sentence.find(word, offset)
                  tokens.append(Token(word, start))
      
                  offset = start + len(word)
      
              return tokens
      

      使用 WordSplitter.register('jieba') 后就可以在配置中 word_splitter 部分写上 "type": "jieba" 来启用。

      在 allen_ext/__init__.py 中写入如下内容

      from .word_splitter import JiebaWordSplitter
      
      __all__ = ['JiebaWordSplitter']
      

      自定义了 JiebaWordSplitter 后在训练的时候还要加载 allen_ext 这个目录才能生效,这个之后再说。

  • 模型部分

    因为是做文本分类,所以 type 设置为 basic_classifier

    这个分类器需要 text_field_embedder 和 seq2vec_encoder 两个参数:

    • text_field_embedder 用来定义 word embedding,这个配置应该还好理解

      "text_field_embedder": {
          "tokens": {
              "type": "embedding",
              "embedding_dim": 100,
              "trainable": true
          }
      }
      
    • seq2vec_encoder 则用来产生句子的编码向量用于分类,这里选择了 CNN

      "seq2vec_encoder": {
          "type": "cnn",
          "embedding_dim": 100,
          "num_filters": 1,
          "ngram_filter_sizes": [2, 3, 4]
      }
      
  • 训练部分:略

配置文件写好后,假设配置文件为 config.json,直接执行下面的命令来训练即可

allennlp train config.json -s model_save_dir --include-package allen_ext

选项 --include-package allen_ext 用来来加载自定义的模块。

最终会在 save_dir 目录下产生一个 model.tar.gz 文件,就是模型参数,然后目录下还会产生 tensorboard 能读取的 log,这个挺方便的。

评估的话,用 evaluate 命令

allennlp evaluate model_save_dir/model.tar.gz test.jsonl --include-package allen_ext

比较麻烦的是,预测需要一个 Predictor,而 AllenNLP 中内置的 TextClassifierPredictor 要求的输入是 {"sentence": "xxx"} ,这个和 TextClassificationJsonReader 的要求不一样……

如果是在代码里进行预测,那么是没有问题的,可以这样

from allen_ext import *         # noqa
from allennlp.models.archival import load_archive
from allennlp.predictors.predictor import Predictor

archive = load_archive('model_save_dir/model.tar.gz')
predictor = Predictor.from_archive(archive)

inputs = {"sentence": "名师指导托福语法技巧:名词的复数形式"}
result = predictor.predict_json(inputs)

得到的 result 是这样的结构

{
    'label': 'education',
    'logits': [
        15.88630199432373,
        0.7209644317626953,
        7.292031764984131,
        5.195938587188721,
        5.073373317718506,
        -35.6490478515625,
        -7.7982988357543945,
        -35.44648742675781,
        -18.14293098449707,
        -14.513381004333496
    ],
    'probs': [
        0.999771773815155,
        2.592259420453047e-07,
        0.0001851213601185009,
        2.2758060367777944e-05,
        2.013285666180309e-05,
        4.153195524896307e-23,
        5.1737975015342386e-11,
        5.085729773519049e-23,
        1.6641527142180782e-15,
        6.273159211056881e-14
    ],
}

这个输出结构完全是由 TextClassifierPredictor 决定的。

如果要自定义 Predictor,可以参考文档

基于 BERT 进行文本分类

AllenNLP 是基于 pytorch 实现的,所以 Google 提供的 BERT 模型在它这里没法用,需要下载它自己提供的模型,以中文模型为例:

mkdir chinese_bert_torch && cd chinese_bert_torch
wget https://s3.amazonaws.com/models.huggingface.co/bert/bert-base-chinese-pytorch_model.bin -O pytorch_model.bin
wget https://s3.amazonaws.com/models.huggingface.co/bert/bert-base-chinese-config.json -O config.json
wget https://s3.amazonaws.com/models.huggingface.co/bert/bert-base-chinese-vocab.txt -O vocab.txt

然后 config.json 中 data_reader 部分这样写

{
    "dataset_reader": {
        "type": "text_classification_json",
        "tokenizer": {
            "type": "word",
            "word_splitter": {
                "type": "bert-basic",
            }
        },
        "token_indexers": {
            "bert": {
                "type": "bert-pretrained",
                "pretrained_model": "./chinese_bert_torch/vocab.txt"
            }
        }
    }
}

model 部分这么写

{
    "model": {
        "type": "bert_for_classification",
        "bert_model": "./chinese_bert_torch",
        "trainable": false
    }
}

这里 trainable 设置成 false 的话 BERT 就只是充当一个 encoder,不参与训练;如果要进行 finetuning 的话将其改为 true。

完整的配置是这个样子的

{
    "dataset_reader": {
        "type": "text_classification_json",
        "tokenizer": {
            "type": "word",
            "word_splitter": {
                "type": "bert-basic",
            }
        },
        "token_indexers": {
            "bert": {
                "type": "bert-pretrained",
                "pretrained_model": "./chinese_bert_torch/vocab.txt"
            }
        }
    },
    "train_data_path": "allen.data.train",
    "test_data_path": "allen.data.test",
    "evaluate_on_test": true,
    "model": {
        "type": "bert_for_classification",
        "bert_model": "./chinese_bert_torch",
        "trainable": false
    },
    "iterator": {
        "type": "bucket",
        "sorting_keys": [["tokens", "num_tokens"]],
        "batch_size": 64
    },
    "trainer": {
        "num_epochs": 5,
        "patience": 3,
        "cuda_device": -1,
        "grad_clipping": 5.0,
        "validation_metric": "+accuracy",
        "optimizer": {
            "type": "adam"
        }
    }
}

训练、评估、预测等操作同未使用 BERT 的时候一样。

NLP哪里跑: Unicode相关的一些小知识和工具

本文是《NLP 哪里跑》系列的第三篇文章,系列文章如下:

  1. NLP哪里跑: 开篇及一些碎碎念 · ZMonster's Blog
  2. NLP哪里跑: 什么是自然语言处理 · ZMonster's Blog
  3. NLP哪里跑: Unicode相关的一些小知识和工具 · ZMonster's Blog
  4. NLP哪里跑: 文本分类工具一览 · ZMonster's Blog

一、Unicode 简介

我们都知道,所有的数据,在计算机上都是以数字(严格来说是二进制)的形式存在的,文字也是如此,只不过咱们的编辑器、浏览器对这些数字做了特殊处理,将其对应的形状展示出来了而已。在这个基础上,不同的操作系统、平台、应用为了能够正常地交流,就必须约定一个统一的「计算机中的数字」到「实际文字」的对应关系(即编码标准),比方说数字 97 对应小写英文字母「a」、33528 对应「言」字之类的 —— 没错,所谓的编码标准,就相当于一个大的索引表,每个文字在这个索引表里都有一个对应的索引号(也就是我们刚才说到的数字)。

在计算机系统发展早期,其实是并没有这样一个统一的编码系统的,美国一开始就用了 0-127 的值来编码,包括了大小写字母、数字、标点符号以及一些特殊符号,这就是“美国信息交换标准代码(American Standard Code for Information Interchange, ASCII)”。显然 ASCII 是不适用于中文的,所以后来我国推出过 GB2312 标准,收录了 6763 个汉字,并在之后经过扩展有了 GBK 和 GB18030 多个编码标准;另外一方面,港澳台地区又独立发展出了繁体的 BIG5 编码……这些编码都是互相不兼容的,这就会导致使用编码 A 的网站,被使用编码 B 的计算机访问后显示为乱码的状况,而这里只提到了中英文的编码体系,实际上很多国家都有过自己的标准,而且很多是还在使用的。

基于这种状况,后来计算机领域产生了一个叫做 Unicode 的统一编码,又称「万国码」,收录了世界上各个国家大部分的文字,并且仍然在不断增修,今年六月份发布了第十一个正式版本。目前使用最广泛的是 Unicode 实现是 UTF-8 编码。

本文无意就编码标准和编码实现的关系、不同编码之间的同异这类问题做太多展开,只是说一下在做自然语言处理的工作时会涉及到的一些小知识以及相关的工具。

二、Unicode 在 NLP 中的应用

也许读者会疑惑,编码标准不就是字符和索引值的对应嘛,和 NLP 有什么关系呢?

是这样的,Unicode 这个标准,并不是单纯做好所有文字的索引,它还对文字分门别类做了很多的整理,比如说同一个语系的文字会放在索引表的邻近区域,而一个文字是否是数字或标点、数学符号这些信息也都在 Unicode 标准中有记录,并且所有这些数据都是公开的。如果能善加利用这些信息的话,能帮助到咱们在 NLP 工作中对文字进行处理的部分。

这里不准备对 Unicode 数据做系统、全面的说明,如有兴趣,可以前往 http://unicode.org/charts/ 查看完整的 Unicode 数据,这里就以几个例子来讲讲我个人的一些认识和经验吧。

根据 Unihan 数据来从文本中筛选中文字符

Unicode 中中文数据的部分被称为「Unihan 数据库」,在这个页面可以看到 Unihan 中数据的范围。根据 Unihan 数据,我们可以得知在 Unicode 编码里,中文的索引值的范围包括以下几部分:

  • U+3400 - U+4DB5: 「U+」表示这是 Unicode 编码,3400 是十六进制表示,换算成十进制是 13312,下同
  • U+4E00 - U+9FCC
  • U+F900 - U+FAD9
  • U+20000 - U+2A6D6
  • U+2A700 - U+2B734
  • U+2B740 - U+2B81D
  • U+2B820 - U+2CEA1
  • U+2F800 - U+2FA1D

根据这个我们能很容易地写出一个检查某个字符是不是中文字符的方法来,如下

def is_chinese_char(char):
    char_idx = ord(char)
    if 0x3400 <= char_idx <= 0x4DB5 or \
       0xF900 <= char_idx <= 0xFAD9 or \
       0x4E00 <= char_idx <= 0x9FCC or \
       0x20000 <= char_idx <= 0x2A6D6 or \
       0x2A700 <= char_idx <= 0x2B734 or \
       0x2B740 <= char_idx <= 0x2B81D or \
       0x2B820 <= char_idx <= 0x2CEA1 or \
       0x2F800 <= char_idx <= 0x2FA1D:
        return True

    return False

或者写个正则

import re

CHINESE_CHAR_PAT = re.compile(
    r'[\u3400-\u4DB5\u4E00-\u9FCC\uF900-\uFAD9'
    r'\u20000-\u2A6D6\u2A700-\u2B734\u2B740-\u2B81D'
    r'\u2B820-\u2CEA1\u2F800-\u2FA1D]'
)

def is_chinese_char(char):
    return bool(CHINESE_CHAR_PAT.match(char))

以上都是笨办法,因为实际上我们并不需要去记中文的编码范围,而且由于 Unicode 是在扩展的,如果将来扩充了,那么扩充进来的新的字可能就没有办法用上面的方法检查了。前面提到,Unicode 标准做了多方面的整理,而这些整理结果都作为属性附加到每个 Unicode 字符上了。

首先,每个 Unicode 字符都会被赋予一个名字,下面是一部分对照表

Unicode Name
a LATIN SMALL LETTER A
9 DIGIT NINE
CJK UNIFIED IDEOGRAPH-6211
α GREEK SMALL LETTER ALPHA

对于中文,我们只要取其 name,然后判断是否包含 CJK 这个关键词就行了。要怎么获取 Unicode 字符的 name 呢?用 Python 标准库里的 unicodedata 模块即可

import unicodedata

def is_chinese(char):
    return unicodedata.name(char).startswith('CJK')

而除了 name,Unicode 字符还有 block 和 script 两个属性:block 其实就是我们前面提到的连续编码区域,不过会有一个名字;script 是指每个文字的书写体系,可能会包含多个 block,详情见 文档

前面提到的汉字的编码区域和 block 名称的对应关系如下表所示

Block Name Block Range
CJK Unified Ideographs Extension A U+3400 - U+4DB5
CJK Unified Ideographs U+4E00 - U+9FCC
CJK Compatibility Ideographs U+F900 - U+FAD9
CJK Unified Ideographs Extension B U+20000 - U+2A6D6
CJK Unified Ideographs Extension C U+2A700 - U+2B734
CJK Unified Ideographs Extension D U+2B740 - U+2B81D
CJK Unified Ideographs Extension E U+2B820 - U+2CEA1
CJK Compatibility Ideographs Supplement U+2F800 - U+2FA1D

汉字对应的 script 名字是 Han,直接包括了上述所有 block。使用 Python 的 regex 这个工具,可以直接在正则表达式中使用 block 和 script。仍以汉字判断为例,可以这么写

import regex

def is_chinese_char(char):
    return bool(regex.match(r'\p{script=han}', char))

可惜在标准库 unicodedata 中并没有访问 Unicode 字符的 block、script 等属性的方法。

对于其他语言的文字,将上述方法中的参数(编码区域、script 等)稍作修改也是可行的,不再赘述。

用 category 属性判断标点、数字、货币单位等

Unicode 数据中,每个 Unicode 字符还有一个叫做 category 的属性,这个属性和字从属的语言无关。category 一共有 Letter、Mark、Number、Punctuation、Symbol、Seperator、Other 七大类,然后每个大类下还有一些小类,总体上是一个二级分类结构。因此在 Unicode 中有两个字母来组合表示一个 Unicode 字符的类型信息,我们可以用 unicodedata.category 来得到这个信息

import unicodedata

for char in '1天。':
    print(char, unicodedata.category(char))

结果为

1 Nd
天 Lo
。 Po

类型码和分类信息的对照表如下

类型码 类型信息
Lu Letter, uppercase
Ll Letter, lowercase
Lt Letter, titlecase
Lm Letter, modifier
Lo Letter, other
Mn Mark, nonspacing
Mc Mark, spacing combining
Me Mark, enclosing
Nd Number, decimal digit
Nl Number, letter
No Number, other
Pc Punctuation, connector
Pd Punctuation, dash
Ps Punctuation, open
Pe Punctuation, close
Pi Punctuation, initial quote (may behave like Ps or Pe depending on usage)
Pf Punctuation, final quote (may behave like Ps or Pe depending on usage)
Po Punctuation, other
Sm Symbol, math
Sc Symbol, currency
Sk Symbol, modifier
So Symbol, other
Zs Separator, space
Zl Separator, line
Zp Separator, paragraph
Cc Other, control
Cf Other, format
Cs Other, surrogate
Co Other, private use
Cn Other, not assigned (including noncharacters)

如上,标点符号的类型码都是 P 开头的,根据这个就能把标点筛出来了

import unicodedata

def is_punctuation_char(char):
    return unicodedata.category(char).startswith('P')

类似的,货币单位符号的类型码为 Sc,可以直接判断

import unicodedata

def is_currency_char(char):
    return unicodedata.category(char) == 'Sc'

类型码 N 开头的是数字字符,除了我们常见的十个阿拉伯数字外,像罗马数字、带圆圈的数字序号等都被涵盖在内。

此外类型信息也可以在 regex 这个工具里使用,例如

  • 找到文本中所有数字

    regex.findall(r'\p{N}', '第⑩项')
    

    结果

    ['⑩']
    
    
  • 找到各种括号表示开始的那一个

    regex.findall(r'\p{Ps}', '「Unicode」(又名万国码)见《标准》')
    

    结果

    ['「', '(', '《']
    
    

    或找到表示结束那一个

    regex.findall(r'\p{Pe}', '「Unicode」(又名万国码)见《标准》')
    

    结果

    ['」', ')', '》']
    
    

根据这张类型表,我们也可以写出一个用于文本预处理的简单清洗函数来,用来把一些奇奇怪怪的字符都从文本里去掉

import regex

def clean_text(text):
    # 去除明确无意义的字符
    # 1. Zl: Separator, line
    # 2. Zp: Separator, paragraph
    # 3. Cc, Cf, Cs, Co, Cn
    text = regex.sub(r'[\p{Zl}\p{Zp}\p{C}]', '', text)

    # 将空白符归一化
    text = regex.sub(r'\p{Zs}', ' ', text)
    return text

我们还可以根据数字类型 Unicode 字符的 name 来将其归一化到阿拉伯数字上,先看看数字类型的 Unicode 字符的 name 吧

import unicodedata

chars = ['⑩', '1', 'Ⅲ', '〹', '⒗']
for char in chars:
    print('{}: {}'.format(char, unicodedata.name(char)))

结果

⑩: CIRCLED NUMBER TEN
1: DIGIT ONE
Ⅲ: ROMAN NUMERAL THREE
〹: HANGZHOU NUMERAL TWENTY
⒗: NUMBER SIXTEEN FULL STOP

可以看到,在 NUMBER/DIGIT/NUMERAL 后面的那个单词,就是对应数值的英文单词,只要把这个英文单词提取出来就得到了一个统一的表示,然后再将其转换成阿拉伯数字即可。

NLP哪里跑: 什么是自然语言处理

本文是《NLP 哪里跑》系列的第二篇文章,系列文章如下:

  1. NLP哪里跑: 开篇及一些碎碎念 · ZMonster's Blog
  2. NLP哪里跑: 什么是自然语言处理 · ZMonster's Blog
  3. NLP哪里跑: Unicode相关的一些小知识和工具 · ZMonster's Blog
  4. NLP哪里跑: 文本分类工具一览 · ZMonster's Blog

什么是自然语言处理

  • 教授:“你笑什么?”
  • Z:“老师,学自然语言处理是我从小的梦想,能在这里我很开心!”
  • 教授:“用不着开心,给自然语言处理下个定义。”
  • Z:“能帮助我们理解自然语言的技术就是自然语言处理。”
  • 教授:“能说详细点吗?”
  • Z:“自然语言处理能帮我们理解人类的语言。你写了一篇文章,编辑器告诉你有些字写错了,这是自然语言处理;来了一封邮件,分个类告诉你这是垃圾邮件,这是自然语言处理;在输入框里输入‘香农’两个字,搜索引擎把香农的百科、学术成果展示在你面前,这是自然语言处理;你发了一条微博,说‘我想连任你的男朋友’,被微博自动删掉了,这是自然语言处理;你写论文从英文文献里抄了一段,用谷歌翻译转成中文放到自己论文里,这是自然语言处理;你对着 iPhone 说「hey siri 定 7 点钟的闹钟」,7 点钟 siri 提醒你该起床了,这是自然语言处理……”
  • 教授(生气):“净说废话!定义是什么!?”
  • Z:“我刚说了呀,老师。”
  • 教授:“考试你也这样?自然语言处理是,连任你的男朋友?白痴!换个人回答!”
  • M:“老师,自然语言处理可以定义为研究在人与人交际中以及在人与计算机交际中的语言问题的一门学科。自然语言处理要研制表示语言能力和语言应用的模型,建立计算框架来实现这样的语言模型,提出相应的方法来不断完善这样的语言模型,根据这样的语言模型设计各种实用系统,并探讨这些实用系统的评测技术。”
  • 教授:“太棒了!好极了!”

以上是一则瞎编的小品,套用了《三傻》中 Rancho 被教授问及“什么是机械装置”的场景。电影里通过这个场景里教授和“优等生”的死板与 Rancho 的灵活风趣之间的对比,来抨击僵硬死板的教育体制。不过电影总归是电影,它批评的问题我们要承认是客观存在的,但 Rancho 的行为是作为与一个极端对立的另一个极端来呈现并强化冲突和矛盾的,在我们认识事物、学习知识时,机械式地死板记忆固然不可取,毫无章法纯凭个人感性认识也不是什么值得鼓励的行为。

那么什么是自然语言处理呢?

自然语言处理,即 Natural Language Processing,简称 NLP ,是一门旨在利用计算机技术来理解并运用自然语言的学科。在不同的场景下,有时候也称之为计算语言学(Computational Linguistics, CL)或者自然语言理解(Natural Language Understanding, NLU)。

要理解 NLP 这个领域,只要紧紧抓住这几个要点就好

  1. NLP 主要通过计算机技术来进行

    在底层理论层面,NLP 会涉及数学、语言学、认知科学等多个学科,但在最后一般是通过计算机技术来承载这些理论知识并发挥效果的。

    看起来好像是废话,但仍然要在此进行强调,计算机和人脑不同,它有其优点也有其缺点,而 NLP 技术也会受到现在计算机技术优缺点的影响。因此不要用我们人脑处理自然语言的过程和效果来要求 NLP 技术 —— 对我们中的绝大部分人来说,使用语言是很自然、很简单的,但不要因此就觉得用计算机来处理也会很简单。

  2. NLP 要理解和运用的,是自然语言

    所谓「自然语言」是指在我们的世界中自然地演变出来的语言,比如说英语、汉语、法语……之所以称之为「自然语言」,是为了和程序设计语言(如 C 语言、Java 语言、Python 语言等)等人造的语言进行区分。

    程序设计语言是有非常明确的、固定的语法的,用程序设计语言写出来的每一个句子,都会有唯一确定的含义,因此计算机只需要按照语法规则对其进行解析并执行就好了。

    自然语言则不同,它有相对稳定的语法规则,但这些语法规则都会存在例外而且一直在演变,加上其他一些特性,经常会出现歧义。处理歧义是 NLP 中非常核心的一部分内容。

  3. NLP 试图理解自然语言,但何谓「理解」其实并没有一个确定的标准

    理想意义上的「理解」自然语言,是要求 NLP 可以像人脑一样理解自然语言,然而现在脑科学研究上对于我们在使用语言时大脑是如何运作的,并没有一个系统的、全面的认识。因此这也就不能称之为一个标准,实际上在现有的技术框架下,用计算机做到完全理解自然语言,是不可能的。

    退而求其次的,我们一般认为只要在特定的场景中,机器能对我们用自然语言表达的要求进行正确的响应,就是理解了自然语言。

    注意这里有几个前提

    • 「在特定的场景中」:一般我们认为,在限制了场景后,人们的目的以及语言的表达也会受到限制,因此能把语言表达的多样性降低,这样理解才具备可能性
    • 「进行了正确的响应」:我们认为机器的行为符合预期就是理解了,并不关心这中间的过程是否和人脑的运作机制是否一致、是否真正意义上的理解了语言的内涵

    当然,这只是现在实际的 NLP 系统所遵循的标准,事实上还是有人从语言学、脑科学等不同角度尝试确定「理解」的过程和标准,让我们保持关注、期待未来吧。

  4. NLP 在理解自然语言之后还有加以运用,因此凡是有用计算机来处理、分析自然语言的应用,我们都可以说它是一个 NLP 过程 —— 当然有可能不止是 NLP。

接下来我将根据这几点来讨论一下

  1. NLP 有哪些困难和限制
  2. NLP 有哪些主要的应用
  3. NLP 有哪些主要的技术

自然语言处理的困难和限制

我们每天都在使用自然语言,并不会对「理解自然语言」这件事情的困难之处有太多认识,但实际上,自然语言自身的一些特性,给计算机理解自然语言这件事情带来了非常多的困难。

第一个特性是自然语言中普遍地存在歧义现象,这种歧义体现在多个层面:

  1. 在语言的最小单元也就是词语的级别,存在多义词、同义词等现象

    比较经典的是 20 世纪 60 年代一位早期的机器翻译研究者给出的经典例子

    The box is in the pen.
    

    我们虽然可能不太清楚 pen 的另外一个含义是「围栏」,但肯定能意识到这句话中 pen 的含义不是「笔」。这个 badcase 从提出到现在已经有 50 多年了,但我们可以看到,仍然没有翻译系统能够在不加特殊处理的情况下解决这个问题(换言之这个 badcase 是可以通过特殊而丑陋的手段来解决的)。

    Google 翻译 google-translate.png

    百度翻译 baidu-translate.png

    搜狗翻译 sogou-translate.png

    与之相对的,中文里有一个例子是「意思」这个词的多义性

       他说:“她这个人真有意思(funny)。”她说:“他这个人怪有意思(funny)的。”于是人们认为他们有了意思(wish),并让他向她意思意思(express)。他火了:“我根本没有那个意思(thought)!”她也生气了:“你们这么说是什么意思(intention)?”事后有人说:“真有意思(funny)。”也有人说:“真没意思(nosense)”。(原文见《生活报》1994.11.13 第六版)
    

    当然,上述两个例子都是经过人为设计的精巧例子,大部分人们日常活动中使用的语言,并不会这么复杂。

    多义词之外,同义词也是非常普遍的语言现象,如:词语的缩写(如「人影办」之于「人工影响天气办公室」)、专有名词的别名(如「感冒」之于「上呼吸道感染」)、网络用语(如「蓝瘦」之于「难受」)、方言(如「包谷」之于「玉米」)、口语和书面语(如「脑袋」之于「头部」)……在 NLP 应用中,对同义词的辨别和处理工作也是非常多的,如:搜索引擎会通过查询改写(Query Rewrite)来将用户的搜索改写为同样含义但表述更为精准的语句;知识图谱里的实体链接技术本质上就是将实体名称(如人名、地名)的不同形式的表达和标准的表达对应起来;智能对话里可以通过同义词来更好地理解用户的问题……

    而在多义词、同义词理解上,目前 NLP 领域并没有一劳永逸的解决办法,有靠人工构建的小规模高质量知识库如 WordNet、HowNet、同义词词林,也有靠机器学习方法从大量语料中学习到词语的向量表示来隐式地反映词义 —— 量大但并不足够精确。前者往往质量很好但只能覆盖人们实际语言中很小的一部分,后者依赖于语料的数量、质量和领域,对于语料中较少出现的一些词往往就会得到莫名其妙的结果。

  2. 在句子乃至更高层的语言单元如段落、篇章里,又存在结构性歧义的现象

    以句子为例,它是由词组成的,词和词之间有些是有关联的、有些是没有关联的,因此整体会形成一个结构,也就是我们在学习语言时了解到的「语法」。

    理想的情况下,任意给定一个句子,如果它的语法结构是唯一、确定的,那么我们只需要把它这个语法结构计算出来,然后再把前面提到的同义词、多义词的问题也解决了,那么这个句子的含义就能确定了。但实际情况是,自然语言的语法规则并不是一个非常严格的规则,在句子比较复杂的时候,往往会造成句子可以有多种不同但都合理的解释。

    举一些例子

    • “喜欢乡下的孩子” 可以有下面两种解释
      • [喜欢/乡下]的/孩子: “喜欢乡下的”作为定语修饰“孩子”
      • 喜欢[乡下/的/孩子]: “乡下的孩子”作为“喜欢”的宾语
    • “他背着总经理和副总经理偷偷地把钱存进了银行” 可以有下面 2 种解释
      • 他[背着/总经理/和/副总经理]偷偷地/把/钱/存进/了/银行: “他”独自存钱
      • 他[背着/总经理]和/副总经理/偷偷地/把/钱/存进/了/银行: “他”和“副总经理”一起存钱
    • “放弃美丽的女人让人心碎” 可以有下面 2 种解释
      • [放弃/美丽/的]女人/让/人/心碎: “女人”让人心碎
      • [放弃/美丽/的/女人]让/人/心碎: “放弃”让人心碎

    再看英文的一个经典例句: "Put the block in the box on the table"。它可以有两种解释:

    • Put the block [in the box on the table]: "on the table" 修饰 "box"
    • Put [the block in the box] on the table: "on the table" 限定 "block"

    如果再加上一个介词短语 "in the kitchen",那么它可以有 5 种不同的解释;再增加一个的话,将会得到 14 种不同的解释。就这个介词短语导致歧义的情况来说,可能的歧义结构数量基本上是随着介词短语数量的增加而呈指数级上升。「指数级上升」是什么概念?那意味着想要靠枚举所有的可能性来解决歧义是非常低效甚至不可能的。

上述两种情况互相组合,又会造成更多、更复杂的歧义情况。

正如前文所说,如何处理歧义并排除歧义,是 NLP 中非常主要的一部分内容,而且是非常困难的一部分,这是因为造成语言歧义的原因多种多样。

造成词语歧义的原因,前面已经说过了一部分,也就是缩写、别名、网络用语、方言、口头语和书面语这几个;除此以外,比喻、拟人、谐音等手法也会使已有的词产生新的含义或和原来不相关的词产生同义;还有省略、指代等依赖上下文的情况也容易造成歧义……总之造成词语歧义的情况是非常多的。

在词义消歧这个问题上,除了造成歧义的情况多种多样外,还有一个困难是消歧经常依赖于文本之外的、海量的「常识」,比如说前面那个机器翻译的例子「The box is in the pen」,我们为什么能判断「pen」的含义不是「笔」,是因为我们具备这些知识

  1. 盒子是有体积的
  2. 笔不是一个容器
  3. 相对盒子,笔一般都更小

并由此推理出:盒子在笔中是不合理的。但是抱歉,这种我们认为是常识的知识,计算机并不具备,它是无法作出这种推理的。我们说的机器学习、深度学习,本质上还是依赖于数据,也就是说,要见过相同的或者相近的数据,它才能理解,对于知识本身则基本没有学习到。

在 NLP 发展历程中,也有人耗费巨大精力和金钱来将我们说的知识和规则整理成计算机可操作的数据,如 CycDBpediaFreebase,并演变成我们现在所说的知识图谱(其实以前就叫「知识库(Knowledge Base)」的,「Knowledge Graph」最早是 Google 的一个知识库的名字),但在 NLP 任务比如说词义消歧中,如何使用这些知识,仍然是一个很大的问题。

至于结构歧义或者说语法歧义,其原因也可能有几方面

  1. 当其中的词语有歧义时,这个词的不同词义可能具有不同的语法功能,而造成最终的语法结构不同,如前文的例句「放弃美丽的女人让人心碎」的两个不同解释中,「美丽」一词分别作为名词和形容词,而造成了不同的语法解析结果
  2. 自然语言语法本身并不是确定性的,即存在一些情况,哪怕其中每个词的词性、词义都是确定无疑的,可以得到的合理的语法解析结果也会有多个,前面那个「他背着总经理和副总经理偷偷地把钱存进了银行」的例句和那个英文例句就是这种情况

再加上语言一直在演变,新的语法规则不断出现,比如原来认为是错误的语法因为被普遍使用而被接受为新的语法规则,比如原有的语法规则被简化称为更简单的语法规则。这就导致很多语法规则都会有例外,而很多例外逐渐又成为了新的语法规则,就造成了「所有规则都有例外,所有例外都是规则」的现象。这就导致如果我们如果想靠语法规则来完成任务的话,最终就会陷入不停补充新的语法规则(或说例外)的境地里。

前面说了歧义这个自然语言的特性,而自然语言还有一个很重要的特性,就是前面提到的语言的动态演变。除了它给带来的歧义现象,更重要的是,由于语言的动态演变,新的知识、语言现象一直在出现,如何捕获、学习到这些新的知识和语言规则,对 NLP 来说,同样是非常大的一个挑战,因为如果不能学习到新的语言知识,那么建立在旧数据上的 NLP 模型,往往连基本的分析都做不到,更别谈去解决歧义并理解了。

此外,对中文、日文等一些语言来说,还有特殊的一点,就是在文字中,词和词不是天然就分隔开的。出于效果和效率等多方面的考虑,现有的 NLP 方法基本都建立在词的基础上,所以对中文这些语言来说,还需要对文字进行额外的、确定词和词之间边界的处理,也就是我们常说的「分词(Word Segmentaion)」。而分词这个过程本身,又存在不确定性,即同一个句子,可能的分词结果不一定是唯一的。

先看看这些例子

  1. “梁启超生前住在这里” 可能有两种分词结果
    • 梁启超/生前/住/在/这里
    • 梁启/超生/前/住/在/这里
  2. “武汉市长江大桥” 可能有两种分词结果
    • 武汉/市长/江大桥
    • 武汉市/长江/大桥
  3. “阿拉斯加遭强暴风雪袭击致多人死亡” 可能有两种分词结果
    • 阿拉斯加/遭/强/暴风雪/袭击/致/多人/死亡
    • 阿拉斯加/遭/强暴/风雪/袭击/致/多人/死亡
  4. “已取得文凭的和尚未取得文凭的干部” 可能有两种分词结果
    • 已/取得/文凭/的/和/尚未/取得/文凭/的/干部
    • 已/取得/文凭/的/和尚/未/取得/文凭/的/干部
  5. “今后三年中将翻两番” 可能有两种分词结果
    • 今后/三年/中将/翻/两番
    • 今后/三年/中/将/翻/两番

可以看到,仅仅是分错一两个词,整个句子的含义就完全不一样了。中文分词的困难在于,如果要正确地进行分词,就要对句子的语义有正确的理解;另一方面,要正确理解句子的语义,又需要正确的分词结果。于是就出现了一个先有鸡还是现有蛋的问题。

虽然有上述诸多困难,但 NLP 领域也形成了相应的应对方法,不过也只能说是「应对」而非「解决」,因为这些方法的目的都是追求解决上述问题在实际应用中常见的一部分,使对应的 NLP 系统能在受限场景中正确处理绝大部分比如 80% 或者 90% 的自然语言;而剩下未能处理的部分,则可以通过系统、产品上的一些设计,根据用户行为把它们找出来,加以研究并更新技术,如此逐渐迭代,来让 NLP 系统逐渐达到令人满意的效果。

自然语言处理的主要应用和关键技术

我们比较熟知的一些比较成体系的 NLP 应用,有

  • 机器翻译: 将一种自然语言的文字转换成另外一种自然语言的文字
  • 信息检索: 从海量的文档里检索出我们需要的信息,搜索引擎如 Google/Bing/Baidu 就是非常典型的信息检索系统
  • 文本摘要: 从较长的文档或文章中生成篇幅更小但内容完整的摘要
  • 智能对话: 通过对话的形式直接自动回答用户的问题或者执行特定的行为
  • 垃圾邮件过滤: 将垃圾邮件筛选出来并进行标记
  • 與情分析: 检测民众对公共事件的意见、态度、情感等倾向

当然实际上 NLP 的应用是非常多的,限于篇幅这里只讨论一下上述比较主要的应用。

上面六个 NLP 应用,其实可以划分成两类,一类是需要理解文本的完整语义并作出响应,如机器翻译、文本摘要和智能对话;另一类只需要理解文本中特定的信息就可以,对于文本的完整语义并不太关心,比如信息检索、垃圾邮件过滤和與情分析。当然我这样划分并不太严格,比如说搜索引擎里也会有人用自然语言去描述自己的问题并期望得到正确的结果,而垃圾邮件过滤和與情分析有时候也需要在理解完整语义的基础上再去提炼「特定信息」,但大部分情况下我认为这样划分是没有问题的。

在第一类即机器翻译、文本摘要和智能对话中,前两者都有明确的目标和评估标准,比如机器翻译有 BLEU 指标,文本摘要有 ROUGE 指标,而智能对话至今仍没有一个通用的评估标准,要么是借用一下机器翻译的 BLEU,要么是在特定的对话形式和对话任务里制定特殊的标准 —— 比如任务型对话里的槽填充率、检索型问答里的命中率和召回率。一个真正可用的智能对话系统,必然是一个融合系统,既有任务型问答也有检索型问答,说不定还会有开放域闲聊,那么在这样一个融合的系统里,各扫自家门前雪肯定是不行的,所以就现在来说,智能对话会比机器翻译和文本摘要要难一点 —— 倒不是说技术上难,而是难在确定目标和评估标准上。

在第二类即信息检索、垃圾邮件过滤、與情分析这三者中,與情分析除了要分析出情感、态度倾向,还要确定这种倾向针对的是什么对象,所以相比信息检索和垃圾邮件过滤,又会更难一点。

总的来说,在大部分情况下,对于 NLP 的应用,可以按照下面的原则来判断其难易程度

  • 需要理解完整语义的任务,要比只需理解部分信息的任务更难
  • 有确定目标和评估标准的任务,要比没有的更容易
  • 要做复杂分析的任务,要比只做简单分析的任务更难

在技术方面,各个应用会涉及的更底层的关键 NLP 技术如下:

  • 早期基于规则的翻译系统: 句法分析
  • 统计机器翻译: 语言模型,隐马尔可夫模型(HMM,早期被广泛用于序列标注)
  • 神经网络机器翻译: seq2seq,注意力模型
  • 信息检索: 大量的文本预处理,实体抽取,主题模型,文本相似度量,知识图谱
  • 检索型的问答系统: 同信息检索,但还需要进行指代消解、省略消解
  • 任务型的问答系统: 意图识别,实体抽取,指代消解,省略消解,对话状态管理,自然语言生成
  • 垃圾邮件过滤: 文本分类
  • 與情分析: 观点词抽取,情感分析,关系抽取,意见挖掘
  • 文本摘要: (这个不太清楚,略过)

上面的东西就是罗列一下以便形成整体印象,实际上像机器翻译和文本摘要我都没有做过,所以里面的关键技术我说不了太多,而信息检索、智能对话方面我相对有经验一些因此就写得比较多。

虽然各个应用的目标都不一样,但最后我们会发现它们用到的很多方法都是共通或者干脆是一样的。

最基础的,各个应用在开始前都需要对文本做预处理,不过预处理是一个很宽泛的概念,并没有一个或一套特定的方法,就我个人经验来说,因为处理的都是汉语,所以基本上都会做简繁统一转换,会把全角字符转成半角字符,会做标点的归一化处理(我才不会告诉你希腊字母里的问号在外形上和英文的分号一个样子呢),会去除一些无效的字符(比如说不可见的零宽度的空白符);英文里则会做词干提取、词形还原。很多讲 NLP 的文章说到预处理都会说停用词(stopwords)去除 —— 所谓停用词是指在特定领域里使用频率很高且往往不表达语义的词,如「了」、「的」,但实际上不是所有的应用上都需要去停用词、都可以去停用词的,比如说在作者身份鉴别这个应用上,是重度依赖虚词、介词等所谓功能词的,而在 NLP 里,虚词、介词这种词一般都会被视作停用词。

预处理的目的是让文本变得更「干净」,少一些「脏东西」,同时尽量更加地规范,所以有时候也会把预处理形象地称为「清洗」。好的预处理能减少干扰信息,并且保留重要信息,同时一些规范化处理(如繁简统一转换、标点归一化处理、英语的词干提取和词形还原)还能够减少后续步骤需要处理的信息量。

在做完预处理后,一般就是变着花样从文本里提取信息出来,以机器学习的角度来讲就是特征提取。根据不同的应用,这一步可能会非常简单,也可能会非常复杂。

最初始的一个处理,就是从文本里,把词都提取出来,这一步对英文等语言来说很自然,按空格和标点分一下就好了,而对中文来说则需要专门做中文分词,这个是前面提到过的。从计算语言学的角度来讲,分词是「词法分析」的一部分,所谓「词法分析」除了确定词和词之间的边界,还需要确定词性(一个词是动词、名词还是别的什么)、词义(「苹果」是一种水果还是一种电子产品),通俗一点来说,就是要解决: 哪些是词,有哪些词,各个词都是什么意思。

分词的方法有多种,简单的如准备好一个超大的词表,然后在这个词表里找可能拼成当前这句话的词,然后在多种可能的组合里确定一个最有可能的,看着很粗暴但其实也还可以。不过现在主流上都是将分词当作序列标注问题来处理。所谓序列标注是说,给一个序列,模型能给这个序列上每一个元素都标注成为一个特殊的记号,对分词而言,要标注的记号可以有三种: 是不是词的第一个字,是不是词的最后一个字,是不是词中间的字。

序列标注问题并不是 NLP 中特有的,但有不少 NLP 任务或技术都和序列标注有关,刚才说到的分词和词性标注(确定词性),基本上都是当作序列标注问题来处理。除此以外,实体抽取或者更复杂的信息抽取,也被视为序列标注问题。所以如果是作为一个 NLP 从业人员,序列标注问题的相关方法,是必须要掌握的,建议 HMM、CRF、RNN 的原理摸熟吃透,掌握一两个好用的序列标注工具,囤点序列标注问题的数据集打磨一下自己的感觉。

虽然说主流的方法都是将分词当作序列标注问题来做,但在实践中,囤点词典还是有益无害的,比如说做做新词挖掘、同义词挖掘、领域实体词的积累等等。至于怎么做这些挖掘、积累,各个应用有各个应用的做法,比如说搜索引擎靠 query 点击日志能把同义词或近义词找出来,原理就是类似两个人用了不同的词去搜索,但最后都点击了同一个链接这种思路;没有搜索引擎这样比较方便的用户反馈的,可以通过一些无监督或半监督的方法来做,会更难一些但总之是有方法的。

做完词法分析后,一般会有一些不同的后续处理

  1. 区分哪些词是重要的,哪些词是不重要的,可能涉及到关键词提取、实体抽取、停用词去除等
  2. 分析哪些词之间是有关联的,哪些词之间是没有关联的,可能涉及到短语抽取、关系抽取、句法分析等
  3. 基于词对整个文本进行分类得到一个高度抽象的结果,可能涉及情感分析、意图识别、主题分类等

实体抽取前面说了,可以当作序列标注问题来做。停用词去除没什么好说的,一般就是在通用的一个停用词表的基础上,统计一下数据中高频的词加进去而已。关键词提取我觉得是很重要的一件事情,不过大部分情况下用 TFIDF 就能提取出不错的关键词来了,在此基础上辅以之前词法分析的一些结果如词性基本上就能把关键词捋得差不多,基本上名词、动词、形容词、实体词拿出来做关键词就差不多了。

这里说到了 TFIDF,这个也是 NLP 里非常经典非常重要的一个技术,思路是很简单的,就是说对一个词,用两个数值来反映其在某篇文档或者某个句子里的重要程度,第一个是所谓词频(Term Frequency, TF),即这个词在当前文档/句子中出现的次数;第二个是所谓逆文档频率(Inverse Document Frequency, IDF),指出现过这个词的文档/句子的数量的倒数。通俗来说,就是,一个词在其他文档/句子里越少出现(IDF 值比较大),在当前文档/句子中越多出现,那么它在当前文档/句子中就越重要。很朴素的思想,实际上这个思路最终是可以用信息熵来解释的,总之很实用。

短语抽取、关系抽取和句法分析都是为了得到文本中的层次结构以更好地理解语义。其中短语抽取较简单,因为只需要看前后两个词是否有关系就好了,而关系抽取和句法分析都需要分析不相邻的词之间是否存在关联。这块不太熟,只是用过相关工具。不过我的观点是,句法分析是理解语义非常核心非常重要的一件事情,一个好的句法分析系统或工具,能够大大提高整个系统的效果。

然后是情感分析、意图识别、主题分类这些技术,其实大部分情况下都是文本分类在不同应用场景里的别名而已。文本分类是一个相对简单但应用非常广泛的技术,也是一块值得花精力去好好掌握的内容,逻辑回归、支持向量机、GBDT,然后是深度学习常见的分类模型结构,把这些都好好掌握一下,并且在实际数据中熟练掌握好预处理、特征提取、特征选择的流程和技巧,受用无穷。至于说深度学习不用特征的都是傻蛋,我分类的时候用深度学习一样能把特征加上去,就是比你不加好,爱调不调。

上述技术,基本上就能把任务相关的「理解」做得差不多了。在理解之后,一般还会有涉及应用的更具体的响应处理,如机器翻译、文本摘要、智能对话中会有自然语言生成,信息检索及检索型问答系统会有文本相似度量。

其中自然语言生成(Natural Language Generation, NLG)至今仍然是一个很困难的问题,在技术上,有基于统计语言模型的生成方法,也有基于神经网络语言模型的生成方法,近几年的变分自编码器(Variational Auto-Encoder, VAE)和基于强化学习的生成方法使用得也越来越多了。但这里说的 NLG 多是在特定任务中,基于前面已经有的分析结果来生成较短的文本,至于生成小说什么的,看看都没有媒体和公司去吹就知道是多么难的一件事情了。

这里提到了语言模型,也是一块可以好好掌握一下的内容。

然后就是文本相似度量,也是非常重要的一项 NLP 技能。某种意义上来说,它和 NLP 的终极目标是等价的。如果任意给定两个句子,一个 NLP 系统能够准确地判断这两个句子是相似还是不相似,那么它就已经是一个完全理解自然语言的系统了。以机器翻译为例,其 BLEU 指标,就是判断翻译出来的句子,和给定的标准翻译是否相似,但实际上其计算方法很简单只是计算了表面相似,所以 BLEU 指标也并不是非常准确,在实际应用中还是要靠人来评估实际效果,但是这么多年了学术界和业界也并没有找到比 BLEU 更好多少的指标,所以将就用着,倒是也有 AMBER 和 METEOR 这种不光看表面相似的评估标准,但是计算太复杂了,所以并没有成为公认的标准。假如有一天有更好的评估标准能和人为评估效果更加接近,那才是机器翻译的大突破。

文本相似的困难对很多非技术人员或者非 NLP 领域人员来说也经常难以理解,“这两个句子就是一个意思为什么就不理解呢”这样的话也是经常能听到的。理解一下,它真的很难。

简单的、传统的相似度量有 LCS 相似度、Jaccard 系数、余弦相似度等,都是久经考验非常实用的方法,应对一些简单的任务措措有余,当然这些方法基本上都是看表面相似,所以泛化性会差一些,需要不断补充特征才能提高效果。如果有足够数据的话,一些基于深度学习的匹配模型会有非常不错的效果,这个不错不光是指其在固定的测试集上的效果,在集外数据的泛化性上都会很不错,当然前提是要有米。

文本相似度量的应用也是很多的,在机器翻译和文本摘要里可以用来评估系统效果,在信息检索和智能对话里可以用来匹配正确的文档或问题,甚至有些时候一些分类任务也可以用文本相似度量来解决(有 A 类数据 1000 条 B 类数据 1000 条,判断下和 A 类数据更像还是和 B 类数据更像)。

另外一方面,除了给定两个句子判断其相似程度外,相似句子的挖掘和生成,也是很有意思的一件事情。生成相似问题这件事情,专业的说法叫做「复述(Paraphrase)」,它和相似度量是互相促进的,相似度量做的好,那么就可以挖掘出更多的相似句子来做训练复述生成模型;复述生成做的话,反过来又可以给相似度量模型提供训练数据。

对于深度学习在 NLP 中的应用,比较核心的技术有

  1. word embedding,也就是我们俗称的词向量,通过大量数据无监督(不需要标注)训练,可以用一个向量来表示一个词的词义,理想情况下如词性、词义、情感、主题等各种信息都可以在这个向量中得到表达 —— 当然这只是一个感性认识,目前对于这个向量实际的含义和解释,我们并不太关心,好用就对了。此外 word embedding 因为能比较好地反映词义,还可以用来做关键词、同义词挖掘,在各项深度学习相关的 NLP 任务中,基本也是标配了
  2. 序列标注方面,BiLSTM+CRF 已经是很主流的做法
  3. seq2seq 和 attention,这两个技术都是在神经网络机器翻译的研究中被提出来的,然后也被广泛用到如智能问答、文本摘要等重要 NLP 应用上。这两者一般是一起出现的,现在已经演变成了一种计算框架,具体的方法和细节则已经有了很多的变化
  4. 自然语言生成方面前面提到过 VAE 和强化学习,近一年内生成对抗网络(Generative Adversarial Nets, GAN)也开始有在这方面的应用

深度学习相关的技术一般都会需要比较多的数据,但我们会发现在实际的任务中,可能并没有那么多数据来让我们上深度学习,或者数据太多但却包含了太多噪音数据。土豪的办法是用金钱来为所欲为顺便创造点特殊岗位造福社会,对这件事情我是举双脚支持的。比较经济的做法是先使用前面提到的传统的、经典的方法,同时在产品、系统的上设计好良好的反馈渠道,在冷启动后不断进行迭代优化,同时有意识地从日志、用户反馈中积累数据,到达一定的量后开始上深度学习来解决传统模型泛化性不够好的问题。

NLP哪里跑: 开篇及一些碎碎念

本文是《NLP 哪里跑》系列的第一篇文章,系列文章如下:

  1. NLP哪里跑: 开篇及一些碎碎念 · ZMonster's Blog
  2. NLP哪里跑: 什么是自然语言处理 · ZMonster's Blog
  3. NLP哪里跑: Unicode相关的一些小知识和工具 · ZMonster's Blog
  4. NLP哪里跑: 文本分类工具一览 · ZMonster's Blog

从事算法工作已经有四年多了,回过头来看过去几年自己做的事情,说实话,谈不上优秀和努力,中规中矩平凡普通,仅此而已。去 Crow 家玩,给他在中科院读研即将毕业的妻子梳理 NLP 的脉络,看到的仿佛是刚工作时候的自己 —— 什么概念都有一点,但又仿佛什么概念都没有,不知道该往哪个方向走,但焦虑和欲望又逼迫你不得不往前走。后来懂得越来越多,做得事情也越来越多,心思却越来越浮泛,在重复繁琐机械的工作里寻得一股虚伪的充实感,只有在偶尔写日记的时候、拖着疲惫的身体行走在路上的时候,自省的想法才会一闪而过。

工作呢,说实话有一个靠谱的团队已经是谢天谢地了,倒也没有办法说我一定要做我感兴趣的事情,毕竟团队需要的是分工协作而不是自大狂的我行我素。话是这么说有时候还是会觉得好无趣啊,想做点有趣点的事情呢。

跑题了,总之,我的一些思考、见闻,我想把他们都写出来。正好最近对 NLP 也就是自然语言处理开始有了一些大致上比较体系的想法了,开始在整理,所以决定开一个系列,专门讲讲 NLP 的东西 —— NLP 是什么呀,有什么应用啦什么的,有什么好玩的东西啦……系列名「NLP 哪里跑」是顺手瞎起的哈哈,"Why so serious",对不?

正经地讲,可能(但不保证)会有以下内容

  1. 个人对 NLP 的整体性思考,会涉及 NLP 的一些基础概念、技术
  2. 常见 NLP 的一些应用场景和解决思路
  3. 好用的轮子介绍,或者顺手造点轮子
  4. 前沿方向的介绍和踩坑
  5. NLP 与其他领域结合的好玩的例子 —— 比如去年在公司年会里分享的《NLP 在文学分析及创作中的应用》

以上。

自然语言处理与深度学习: 集智俱乐部活动笔记


简介

本文为对 2016 年 7 月 3 日集智俱乐部活动「自然语言处理与深度学习」进行的总结。

集智俱乐部是张江在 2008 年建立的一个从事学术研究、享受科学乐趣的开放团体,它倡导以平等开放的态度、科学实证的精神,进行跨学科的研究与交流,力图搭建一个中国的 ”没有围墙的研究所”。我于 2014 年结缘集智俱乐部,从中结识到不少优秀又有趣的人。

集智会不定期地以读书会、讲座等形式进行线下活动,并且这两年的活动主要都聚焦于互联网和人工智能前沿技术上,7 月 3 日这期活动就是如此。

活动主讲人是是香港理工大学的在读博士李嫣然,其主要研究方向为自然语言的语义表达和语言生成。同时她还是微信公众号「程序媛的日常」的维护者之一小 S,这个公众号提供了高质量的关于机器学习和自然语言处理的文章,从事这方面工作或研究的朋友不妨关注一下。另,后文为了方便,一律用「小 S」来指代「李嫣然」。

下面是两个公众号的二维码,有兴趣的可以关注一下:

集智俱乐部 程序媛的日常
qrcode_swarmagents.jpg qrcode_girlswhocode.jpg

自然语言处理的基本任务

自然语言是我们日常用于互相沟通的最常见方式,结合个人的观点以及小 S 讲述的内容,「用自然语言进行沟通」可以分解为以下几个步骤:

  1. 对于接收到的信息,判断是否是自然语言
  2. 在确定接收到自然语言数据后,对其进行理解
  3. 在理解接收的自然语言数据后,组织好要回复的内容并将其传达给另一方

slap.jpg

好好好,我说人话,别打脸!

场景一: 接收到非自然语言

—— 小明: 汪汪汪汪喵喵喵
—— 小红: (说人话,不然滚犊子)

场景二: 接收到自然语言,但理解不能

—— 小明: 苟利国家生死已,岂因祸福避趋之,小红你有没有一种钦定的感觉?
—— 小红: (这个人在说什么……)

场景三: 接收到自然语言,理解并回复

—— 小红: 小明,下午放学后你把教室的窗户玻璃擦一擦吧!
—— 小明: 我擦,我不擦!

小 S 将这个过程划分为 Perceive、Understand 和 Communicate 三部分,并为 NLP 主要的研究或应用方向和这三部分进行对应,如下图所示:

nlp_task.png

Perceive、Understand 和 Communicate 和我之前总结的那三个步骤一一对应,或者说,之前我所说的那三个步骤,是我对 Perceive、Understand 和 Communicate 的理解吧。

  • Language Modeling: 对语言的建模是对整个语言或者特定场景下的语言的特性的一个刻画,所得到的结果通常被称之为「语言模型(Language Model, LM)」
  • Representation: 所谓表示一般是对具体的文本的语义、语法上的刻画,比如一个具体的词、一个具体的句子等等
  • Machine Translation: 机器翻译,无须多说
  • Language Generation: 即根据语义、语法生成有符合自然语言语法并能表达正确语义的文本

语言建模用来刻画整个语言的特性,所得到的语言模型有很多的应用,其中之一就是用来计算某个「给定句子」在这个语言的出现的概率以判别这个句子是不是一个「合理」的句子。而具体的词和句子等元素的表示,一方面是能用来分析这些元素的语义,另一方面也还反映了其所属语言的一些特性,因而是与 Perceive 和 Understand 都相关。

Language Generation 则是在理解之后进行的结果的组织,组织成自然语言当然是要给人看的,这是它和 Communicate 之间的关系所在。Machine Translation 作为一个重要的 NLP 应用方向,需要确保源语言句子和目标语言句子具备同样的语义,从目的上来说本身就是为了提高人与人之间的沟通效率的,因此和 Understand、Communicate 的关系也是很明白的。

对语言进行建模的若干方法

语言模型简介

语言模型是对语言整体特性的一个刻画,理想情况下,我们当然希望这个模型能够完全刻画「语言整体」,但有几个问题:

  1. 从时间角度来看,语言整体一直是在变化的,我们无法建立一个能刻画过去、现在和未来所有时刻语言特性的模型,因此一般的做法是对某个时段的语言数据进行采样
  2. 从语言本身来看,在不同的场景、人群中,同一种语言,其特性也会稍有不同,我们也无法建立一个能刻画所有可能的子语言特性的模型,尽管有人为建立「通用语言模型」而努力,但这种通用性本身也是相对的,绝对通用的语言模型不存在。换言之,在进行语言数据的采样时,按自己的需要,可能还需要将采样范围限制在某些特定的场景或人群中

按上述两个条件得到的语言数据,被称之为「语料(Corpus)」,理想的情况下,我们希望:

  1. 语料中不同时间的语言数据的特性基本稳定
  2. 在限制在特定场景或人群中时,希望数据能尽量涵盖受限条件下语言的各种情况

第一点要求语料的质量,第二点要求语料的规模,但实际情况中这两者很难一起得到保证。

有了语料后,我们怎么去得到语言模型呢?换言之,所谓的「特性」该如何量化?

首先,我们要知道,这个语言模型都涵盖了哪些基本元素,所谓「基本元素」,以英文为例,一般是单词,当然实际上也可以是词组或者字符,但单词是我们认识语言(至少是英文)的比较基本的单元。

然后呢,对任意的这些基本元素组成的序列,一般来说,就是句子,这个模型能给出其置信度(Confidence),用来判断这个句子在这个语言下的合理性,通常表现为一个概率。在语音识别以及其他一些应用中,通常会在最后有若干个候选的句子结果,此时就要用置信度来从中选择最合理的结果。比如说下面两个句子,他们的发音基本上是一样的,但我们是可以知道第一个句子其实是更合理的。

  1. It's hard to recognize speech
  2. It's hard to wreck a nice beach

总结一下,从给定的语料中可以得到一个语言模型,语言模型包含两部分:

  1. 包含语料中出现的基本元素的一个有限集合: \(V = \{ e_{1},e_{2}, ..., e_{n} \}\)
  2. 一个概率分布,对给定的序列 \(W = w_{1}w_{2}...w_{m}\),可以得到概率 \(P(W)\),其中 \(w_{i} \in V\)

也许我们会想,是不是需要考虑语法结构?毕竟一个合理的句子,应该是大致符合自然语言的语法结构的,但首先我们的自然语言并没有一个精确的、无歧义的语法规则,其次语法规则本身已经是一个更高抽象级别的概念了 —— 简单的就是好的,我们更愿意去寻找一些简单的方法,N-gram 语言模型和神经网络语言模型(Neural Network Language Model, NNLM)就是这样。

N-gram 语言模型

基于马尔可夫假设,我们可以认为,在句子中每个「基本元素」出现的概率只和其前面的元素有关,这样,对给定句子 \(W=w_{1}w_{2}...w_{m}\),可以得到其概率为:

\[P(W) = P(w_{1})\cdot P(w_{2}|w_{1})\cdot P(w_{3}|w_{1}w_{2})... P(w_{m}|w_{1}w_{2}...w_{m-1})\]

但是这样的条件概率仍然难以计算,因此我们再进一步地假设: 每个「基本元素」只和其前面 \(N-1\) 个元素有关。在这样的假设即 N-1 阶马尔可夫假设下,前面的式子可以进行近似简化(以 \(N=3\) 为例):

\[\begin{eqnarray} P(W) &=& P(w_{1})\cdot P(w_{2}|w_{1})\cdot P(w_{3}|w_{1}w_{2})\cdot P(w_{4}|w_{1}w_{2}w_{3})... P(w_{m}|w_{1}w_{2}...w_{m-1}) \\ &\approx& P(w_{1})\cdot P(w_{2}|w_{1})\cdot P(w_{3}|w_{1}w_{2})\cdot P(w_{4}|w_{2}w_{3})... P(w_{m}|w_{m-2}w_{m-1})) \end{eqnarray}\]

这样我们就得到了一个 3 阶的「N-gram 语言模型」,又称「Trigram 语言模型」,相应的,1 阶 N-gram 语言模型被称为「Unigram 语言模型」,2 阶 N-gram 语言模型被称为「Bigram 语言模型」,4 阶语言模型则可以简称为「4-gram 语言模型」,依次类推。除了能得到我们期望的概率分布外,大于 1 阶的 N-gram 语言模型还隐式地通过上下文信息一定程度上反映了语言的语法结构。

这里的「gram」是一个抽象的概念,在不同的粒度下它可以对应不同的单元,比如说我们如果基于字符来建模,这个 gram 就是字符;如果基于词来建模,这个 gram 就是词。一般情况下我们使用词作为 gram ,后文一些示例或讨论,如无特殊说明,默认使用词作为 gram。

根据上面的式子,我们要做的,就是从语料中得到每个「N-gram」的概率,最简单的办法是用「极大似然估计(Maximum Likelihood Estimation, MLE)」—— 即直接统计每个 N-gram 的在语料中的频次然后除以频次之和作为概率值。

from __future__ import division
from collections import Counter

def build_ngram(corpus, gram_level):
    """
    :param corpus: list of sentence
    :param gram_level: as it says
    """
    freqs = Counter()
    for sentence in corpus:
        seq = sentence.strip().split()
        for index in xrange(len(seq) - gram_level + 1):
            gram = tuple(seq[index:index+gram_level])
            freqs[gram] += 1

    total_count = sum(freqs.itervalues())
    return {gram: freq / total_count for gram, freq in freqs.iteritems()}

但这样的简单的计算方式是有问题的,在实际应用中,往往因为语料的规模不够,导致一些低频的 N-gram 在语料中未出现,这样在计算句子概率的时候,一个概率为 0 的 N-gram 将导致整个句子的概率为 0,此即「零概率问题」。随着 N 的增大,这个问题会越发的明显。

零概率问题的解决办法是将语料中一些有的 N-gram 的频次「分」一点给这些未出现的 N-gram,这个叫 「折扣(discount)」;或者用这个未出现的 N-gram 的低阶形式的概率来估计,这个叫做「backoff」。这些方法统称为「平滑(smoothing)」方法。

基于神经网络的语言模型

Bengio 在 2003 年提出的神经概率语言模型(Neural Probabilistic Language Model, NPLM)是影响较大的基于神经网络的语言模型1。其模型思想其实和 N-gram 语言模型还是同出一源,即基于 N-1 阶马尔可夫假设,认为句子中某个词是由其前面的 N-1 个词决定的。模型使用的网络结构如下图所示。

nplm_structure.png

这个模型将 N-1 个词的表示拼接起来作为输入,然后去预测下一个词最有可能是哪个。输出层会是一个很大的向量,每一维的值就是每一个可能的词的条件概率,这样要计算一个句子的概率也就没问题了。

这样基于神经网络的语言模型有什么好处呢?

  1. N-gram 需要显式存储每个 N-gram 的内容和概率,于是模型体积会随着语料的扩充而膨胀,但 NPLM 没有这个问题
  2. N-gram 需要应用各种平滑方法来解决零概率问题,但 NPLM 不存在这个问题,即使是语料中没出现的 N-gram ,依然能给出非 0 的概率值
  3. 模型中会学习一个固定长度的向量来表示一个词,且向量中各维度的值都是连续实值而非离散的 0/1 ,此即「Word Embedding」的较早形式

Bengio 还在论文中提到可以用「循环神经网络(Recurrent Neural Network, RNN)」来减少模型参数数量和捕获更远距离的上下文信息(longer term context),这些观点无疑是非常具有前瞻性的。

当然,值得一提的是,现为「IDL 杰出科学家」的徐伟曾在 2000 年独立提出用神经网络构建 Bigram 语言模型的思路2

在这之后,Tomas Mikolov 于 2010 年提出基于 RNN 的语言模型(Recurrent Neural Network Language Model, RNNLM)3,并且顺着这个方向一直发论文发到博士毕业,Bengio 还是厉害啊 —— 话说 Google Scholar 上 Tomas Mikolov 的照片一头长发害近视眼的我以为他是个姑娘呢 :)

RNNLM 的根本思想和 NPLM 是共通的,不同的地方在于词是逐个输入到模型中的,然后隐藏层有一个具有「记忆功能」的单元将之前的词的信息都记录了下来,然后在输出层去预测下一个词。如果把其中的记忆单元拿掉就变成了一个学习 Bigram 语言模型的前馈神经网络了。

语言的表示方法

在 NLP 的应用场景里,无论我们是要做分类、聚类、摘要、机器翻译,首先我们要将要处理的具体文本转换成模型能够处理的数学形式,并且要求这种表示能够反映文本的独特信息。这就是所谓「Representation」,其实也就是特征提取了。

说 representation 是 NLP 的核心应该不为过吧,事实上在其他机器学习相关的领域,特征表达都是非常重要的,特征设计、特征筛选在以前甚至现在依然是非常重要的一个学术和应用问题。

词的表示: one-hot 与 embedding

单个字符因为不具有语义,所以一般不对其表示方法作过多探讨(暂不考虑汉语)。词作为我们认识语言的基本单元,表示它们的最简单方法,就是为词进行编号,比如说我有一个包含 1 万个词的词典,第一个词就记为「1」,第二个记为「2」,依次类推。

这样当然是可以的,在一些简单的任务,比如文本分类上,问题不大,比如说我们可以统计不同 id 的词在两个文本中出现的频次来判断两个文本的相似程度。但问题在于 id 是一个类别变量值,它的值是不能用于比较、不能用于计算的,假想一下我们要构造一个简单的 NNLM ,某次输入的 N-gram 中包含了 id 为 10000 的词,如果直接输入到模型中,这个巨大的值会导致网络产生严重的偏差。

为了解决这个问题,我们一般用所谓「one-hot representation」来表示一个词,具体做法是,每个词都表示为一个向量,这个向量只有一个维度的值为 1,其他维度的值全为 0,且不同词值为 1 的维度都不同。比如说我们要处理的问题中只包含 5 个词,将这 5 个词编号后,可以分别表示为:

第一个词: [1 0 0 0 0]
第二个词: [0 1 0 0 0]
第三个词: [0 0 1 0 0]
第四个词: [0 0 0 1 0]
第四个词: [0 0 0 0 1]

这就是 one-hot 的含义所在,每个词的向量表示中,只有一个位置是「激活的」。

one-hot 表示应用广泛,但它本身有几个问题:

  1. 数据稀疏,一个包含 10000 个词的问题,每个词都要表示为长度 10000 的向量,但只有一个位置是有值的,这将会造成很大的存储开销
  2. 缺乏语义信息,两个词之间无法进行相似性比较

2003 年 Bengio 提出 NPLM 的时候,在模型中去学习每个词的一个连续向量表示,并经过 Tomas Mikolov 等人的发展4,发展出「Word Embedding」这一表示方法。比如说前面那五个词的表示,用 word embedding 表示后,可能是这样子的:

第一个词: [0.2 0.3 0.5]
第二个词: [0.7 0.1 0.2]
第三个词: [0.1 0.2 0.3]
第四个词: [0.2 0.3 0.4]
第四个词: [0.3 0.4 0.6]

这样两个词之间就可以比较了——当然,这种表示下相似值高的两个词,并不一定是具有相同语义的,它只能反映两者经常在相近的上下文环境中出现。但无论如何,one-hot 表示的两大问题,word embedding 都给出了一个解决方案。

word embedding 反映的词与词之间的相似性,可能在语义层面并不能说是真正的相似(取决于如何定义「相似」了),但「两个 word embedding 接近的词,它们是相关的」这一点是毋庸置疑的,如下图所示。

word_embedding_similarity.png

除了能反映同一语言的词之间的关系之外,不同语言之间语义近似或相关的词的 word embedding 也将这种关系反映了出来。

word_embedding_multilingual.png

此外,word embedding 还能一定程度上反映语言的内在规律(linguistic regularity),比如说 'king - man + woman = queen' 之类的,当然这个特定在实际应用场景里是很少用到的,而且我没记错的话主题模型也可以做到类似的事情,所以也并不是什么新东西了。

以上都是从 word embedding 本身来看的一些特点,但根据后来的一些讨论,word embedding 所反映的「相似性」其实要在具体的应用场景下才有确切的意义,像上面提到的 multi-lingual 的相似甚至 multi-modal 的相似,以及 linguistic regularity ,在实际应用中使用得还是较少的。引用小 S 的话,word embedding 更大的意义在于「它已经成为了各种后续或者更高级别粒度的语言表达的基础,这是完全超出相似度的一件事,也是词向量带来的最大贡献」。

当然除了 one-hot 和 word embedding 外,也还有其他的一些特征表示方法,比如说词性、语法结构等信息,都可以附加到词的特征上去。

更深入的关于 word embedding 的讨论,推荐这篇文章: “后 Word Embedding” 的热点会在哪里?

句子及更高层级数据的表示: VSM 和 embedding

句子、段落和文章,我们都可以把它们视为是词的序列,因此在很多场景下可以用统一的方式来进行表示。当然,在涉及句法结构分析时,基本是以句子为单位的,这里暂时不考虑这种情况。作为词的序列,我们该如何去表示它们呢?理想情况下当然是希望词的顺序啊、语义啊、语法结构啊都能够表达出来,但如果要将这些都反映出来,所使用的特征会比较复杂,光是语义和语法结构就够我们喝一壶了。

在文本分类、文本相似度量等一些主要的应用场景中,我们使用向量空间模型(Vector Space Model, VSM)来表示一段文本。VSM 经常和词袋模型(Bag Of Words, BOW)联系在一起。而 BOW 假设词和词之间是互相独立的,认为词和词之间的位置信息是不重要的,可以算作是 VSM 的一个前提、假设。

BOW 的解释也很形象,它假想我们有一个袋子,每遇到一个词就将其丢进袋子中,直到处理完所有文本。在此基础上, VSM 要求用一个向量来表示各个文档,这个向量的长度要与词袋中不同的词的数量一致。比如说我们有下面两段文本:

(1) John likes to watch movies. Mary likes movies too.
(2) John also likes to watch football games.

那么我们得到的词袋中包含的词就是 (John, likes, to, watch, movies, also, football, games, Mary, too) 共 10 个词,这样两段文本可以分别表示为:

(1) [1, 2, 1, 1, 2, 0, 0, 0, 1, 1]
(2) [1, 1, 1, 1, 0, 1, 1, 1, 0, 0]

如上所示,不同位置的值为对应的词在文本中的权重,这里使用的是词频。这样两段文本之间的相似程度可以简单地通过算余弦距离来得到。当然在实际中,我们希望不同维度的值是能反映对应的词在文本中的「重要程度」的,而直接使用词频并不能达到这个目的,事实上有一些常用的词会在很多的文本里都具有较高的频次,但它们本身并不包含重要的信息。所以一般用 TF-IDF(词频-逆文档频率)来作为词的权重。

VSM 的一个问题是,其特征表示往往会很稀疏(想象一个包含了 100,000 个不同词汇的文本集合),解决这个问题的一个方法是将一些文档频率很高的词去除,因为这样的词不能为文本与文本之间的区分性作出贡献,这样的方法能有效地降低向量的维度并保留有效的信息。

VSM 的另外一个问题是缺乏语义信息。比如说下面两个句子,它们表达的意义是不一样的,但在 VSM 中,两者的表示会一模一样

(1) Mary loves John
(2) John loves Mary

这个问题的一个解决办法是使用 N-gram 而非 word 来作为基本单元,比如用 Bigram,上述两句话得到的词袋会是: (Mary loves, loves John, John loves, loves Mary),对应的,两个句子可以表示为:

(1) [1, 1, 0, 0]
(2) [0, 0, 1, 1]

另外一个办法就是,使用句子级别的 embedding 表示。常用的方法有 RNN 和 CNN 两种。

RNN 的话比较简单,将句子中的词逐个输入到模型中,结束时取隐藏层的输出即可。这是因为 RNN 隐藏层的输出是由之前的「记忆」和当前的输入计算得到的,可以认为是「整个句子的记忆」,也就是一个句子的特征表示了。如下图所示:

sentence_embedding_rnn.png

CNN 的话是将句子中的词的向量表示拼接成一个矩阵,然后在上面进行卷积,最后得到一个向量表示,如下图所示:

sentence_embedding_cnn.png

CNN 和 RNN 得到的 sentence embedding,同 word embedding 一样,可以进行相似性的对比,语义相近的句子,其向量表示在空间中也会比较接近,如下图所示:

sentence_embedding_similarity.png

所以,既然可以得到 sentence embedding,能不能得到 paragraph embedding 乃至 document embedding 呢?对于前者,Tomas Mikolov 在 2014 年提出了「Paragraph Vector」5,后者的话似乎还没见到过。在 Mikolov 的实验中,paragraph vector 在情感分析上和信息检索两个任务上都取得了比其他模型更好的结果。不过和 word embedding 一样,sentence embedding 和 paragraph embedding 的可解释性仍然存在问题。

机器翻译: 从 SMT 到 end-to-end

沟通是人类的本能,所以将一种语言翻译成另外一种语言的需求,早在 17 世纪就有人提出来了,但我相信在更早之前,在不同语言的人群交界的地方,就已经有人在尝试做这个事情了。不过直到 1903 年,「机器翻译」这个词才被正式提出来。

1953 年的时候,Weaver 等人发表了一篇名为《Translation》的文章6,并在其中提出两个基本的思想:

  1. 翻译类似于密码解读的过程
  2. 原文与译文表达的是相同的意思,翻译可以通过将源语言转换成一个「通用语言」,然后再转换为目标语言

这里提到的「通用语言」是一种虚构的语言,假定为全人类共同的。这个思想其实和后面要讲到的 Encoder-Decoder 的思想有一些共通之处呢!

从方法上来说,最早使用的是基于规则的方法,后面出现了基于实例的方法,然后在大概 1999 年,在 IBM 推出了 5 种「基于词的统计翻译模型」并取得非常好的成绩后,统计机器翻译(Statistical Machine Translation, SMT)成为了机器翻译的主流方法,直到现在依然如此,Google、Bing、百度等几家的在线翻译系统,都是基于统计方法的。

统计机器翻译(Statistical Machine Translation, SMT)

目前主流的机器翻译系统,其基本思想来源于「噪声信道模型」,即认为源语言的句子 \(f\) 是目标语言 \(e\) 经过含噪声的信道编码后得到的,翻译的过程就是要求解出 \(\hat{e}\)

\[\hat{e} = \mathop{arg\,max}_{e}p(e|f)\]

然后利用贝叶斯公式,转换为

\[\hat{e} = \mathop{arg\,max}_{e}p(f|e)p(e)\]

其中 \(p(f|e)\) 被称为「翻译模型」,\(p(e)\) 则是语言模型。后者可以通过收集目标语言的语料训练得到,但前者怎么办呢?一个简单的思路是,将源语言句子中的每个词都翻译成目标语言对应的词,然后通过重新排列这些词得到符合目标语言语法的句子 —— 后面这个过程称为「词对齐(word alignment)」。

word_alignment.png

所以翻译模型会由两部分组成,一部分是词到词的映射,另一部分是对结果进行对齐。整个系统要在所有可能的对齐结果中找到最好的那个。

phrase_based_smt.png

这就是传统的统计机器翻译模型的基本原理。

端到端(end-to-end)的新方法

2014 年的时候,Kyunghyun Cho7 和 Sutskever8 先后提出了一种 End-to-End 即所谓「端到端」的模型,在英语到法语的翻译实验上取得了很好的成绩,前者将其模型命名为 Encoder-Decoder 模型,后者则将其命名为 Sequence-to-Sequence 模型,不过两者其实在结构上基本是一样的。其核心思想都是:

  1. 将输入的句子经过一个 RNN 生成一个能反映句子语义的向量,类似计算 sentence embedding 的过程,
  2. 用另一个 RNN ,从输入句子的向量表达中解码出另一个句子

第一个过程称之为 encoding,第二个过程称之为 decoding,整个模型非常的简单优雅,不需要词对齐,不需要额外的语言模型,输入英语,直接得到法语,这就是所谓「端到端」。

seq2seq.png

Encoder-Decoder 模型虽然在结构上很简单,但它有很重要的两个特点:

  1. 大部分序列到序列的预测问题,都可以使用该模型,这个序列甚至没有必要是文本,语音、图像同样可以
  2. 先 encoding 再 decoding 的 encoder-decoder 的结构,可以推广到非 RNN 的模型上去

当然,虽然 encoder-decoder 的模型结构简单优美,在一些问题上也还不错,它仍然是有缺点的,回到机器翻译问题上来,将一个句子 encode 成一个向量,真的是合适的么?一方面这个过程相当于进行数据压缩,肯定是会有信息损耗的,而且这种损耗随着句子长度的提高会越来越大;另一方面,翻译的时候并不一定需要完全理解整个句子的含义后才能进行,比如可能在看完句子的前半部分以后就可以进行部分的翻译,每个翻译结果中的词的生成都使用整个句子的语义的话,无疑准确度也会受到限制。

在这之后,Dzmitry Bahdanau 在 encoder-decoder 模型的基础上增加 attention 机制来解决这个问题9,通过双向 RNN 来在 decoder 时得到不同时刻的上下文信息。

其中 decoder 在每一个 time step 用下面的式子计算条件概率以判断该输出什么词。

\[p(y_{i}|y_{1}, ..., y_{i-1}, x) = g(y_{i-1}, s_{i}, c_{i})\]

其中 \(s_{i}\) 是 decoder 的隐藏状态

\[s_{i}=f(s_{i-1}, y_{i-1}, c_{i})\]

而 \(c_{i}\) 则是根据从 encoder 中不同时刻的隐藏状态得到的上下文信息,用来决定输出时该聚焦于输入序列的哪些部分。

\[c_{i}=\sum_{j=1}^{T_{x}}\alpha_{ij}h_{j}\]

上式中的 \(\alpha_{ij}\) 构成的权值矩阵就是最核心的内容,将其可视化的话,能发现高亮的部分其实相当于是源语言句子和目标语言句子之间的对齐路径

attention_alignment.png

对照传统的统计机器翻译系统,在 encoder-decoder 模型中,模型本身相当于隐含了语言模型和词到词翻译的两个部分,但却缺乏词对齐部分,而 attention 机制的加入弥补了这个缺陷。

另外要说的是,attention 机制和 encoder-decoder 一样,更大意义上在于它提供了一种解决问题的思路,而不是说某个具体的模型结构,事实上 attention 机制的实现是多种多样的,不一定要使用 RNN。在应用上,attention 机制在图像、视频上也有很大的应用空间,比如在目标识别时用来聚焦于不同的图像区域。

attention_image.png

2016 年, Kyunghyun Cho 又提出结合了 encoder-decoder 和 attention 的多语言翻译模型10,能够接收多用语言的输入,并翻译成多种不同的语言。

language_transfer.png

自然语言生成(Natural Language Generation, NLG)

从模板方法到 encoder-decoder

说到自然语言生成,我的第一反应是大刘的《诗云》,以及他制作的一款名为「电子诗人」的自动作诗软件。来看一看用电子诗人生成的一首「现代诗」:

作品第75509号——

我面对着黑色的艺术家和荆棘丛生的波浪

我看到,剌眼的心灵在午睡,程序代码在猛击着操场

在这橄榄绿的操场中,没有货车,只有蝴蝶

我想吸毒,我想软弱地变黄

我面对着光灿灿的冬雪和双曲线形的霞光

我看到,青色的乳房在漂荡,肥皂在聆听着海象

在这弱小的春雨中,没有贝多芬,只有母亲

我想上升,我想呼吸着歌唱

我面对着宽大的小船和透明的微波束

我看到,枯死的渔船在叫,蒸馏水在铲起羊

在这多孔的青苔中,没有夏娃,只有老师

我想冬眠,我想可恶地发光

我面对着多血的史诗和悠远的大火

我看到,生机勃勃的战舰在沉默,透明裙在爱抚着操场

在这曲线形的奋斗者中,没有月光舞会,只有风沙 我想摆动,我想粗糙地惊慌

自然语言生成除了上述这种娱乐性质的作用外,目前在自动天气预报、机器翻译、Image Caption 和对话系统中都有应用。

说到自然语言生成,如果要我们自己实现的话,能很快想到的一个基础的办法,就是模板生成方法。在一些受限环境下,需要返回的文本形式可能比较简单,用模板方法还是可以取得能用的结果的。

nlg_with_template.png

不过模板方法的缺点也是显而易见的:

  1. 泛化性极低,换一个场景就要重新设计一个不同的模板,如果要进行通用领域的自然语言生成,模板数量将会多到爆炸
  2. 输出非常死板僵硬,如果是在对话这样的场景下,用户很快就会发现得到的文本是由机器生成的
  3. 并没有真正「理解文本」

怎么样才算是理想的「自然语言生成」呢?

Once aware of the communicative goals, machines can understand the meaning of the content (what is important, what is what), and automatically generate natural, meaningful and plausible language. —— By 小 S

几个重点是:

  1. 理解得到的输入(根据不同的场景,可能是文本会图像)
  2. 要生成自然的语言表达,就算是胡说八道,也要一本正经

对于一本正经地胡说八道这件事情,其实是可以用语言模型来,用 N-gram 模型的话,随机选一个词,然后找到关联的概率最高的几个 N-gram ,依次类推。另外一种办法是在已有特定语言的「基本句法规则」的情况下,先生成句法结构,然后往里面填充词语。当然,这两种方法都是很简单的,如果直接使用的话生成的文本其实本身没有什么意义,要放到具体的应用场景里的话,这样的方法还必须和其他一些方法结合才行。

而使用之前提到的 encoder-decoder 框架,在一些场景下,能够做到 end-to-end 的生成,比如 Image Caption。

image_caption_seq2seq.png

所以说 encoder-decoder 这个想法真的是非常好用的呀!

对话系统中的自然语言生成

对话系统中使用到的技术,大致上可以分成三部分:

  1. 基于知识库的检索方法
  2. 基于知识图谱的简单推理
  3. 基于生成模型的自然语言生成方法

第一部分是在知识库中直接找到「合适的」现成的文本返回给用户,和自然语言生成基本没什么关系;第二部分涉及到对用户意图的分析,可能会有一些基于模板、规则的生成过程;第三部分,据我所知,好几家做问答的公司,都在尝试用 encoder-decoder 的模型。

没做过问答,以上都是我瞎编的 :)

前两部分就不谈了,第三部分使用 encoder-decoder 模型,基本都是基于 RNN 来去实现。这种生成模型虽然能够「理解」用户的问题并且一本正经的胡说八道,但仍然存在一些需要解决的问题。

  1. 语义漂移(semantic drift),直观点来说就是答非所问
  2. 生成的文本在语法上有时并不符合自然语言特性(这真是在胡说八道了……)
  3. 训练语料中的高频句式会频繁出现,典型的如「万能回复」问题,其实也算是语义漂移的一个表现
  4. 生成的文本语言风格可能与实际场景不符合

本质上 encoder-decoder 也可以认为是一种检索办法,只不过相比基于知识库的检索方法,它是一种模糊的、更细粒度的检索,语义漂移的问题其实就是模型在其巨大的解空间中迷路了,要解决这个问题,一方面是提高方向感,一方面也可以将解空间进行限制。Google 的 Allo 就被设计成使用混合响应策略来解决这个问题。

所谓的「混合响应策略」,其实就是通过其他的方法来辅助咯,比如获取用户的个人信息作为辅助特征,比如通过知识图谱并将推理结果输入到模型中,再比如确定用户的地理位置和环境因素作为额外的特征。

allo_mixed_response_strategy.png

至于语言风格的问题,可以通过情感分析等手段来获取用户的情绪状态,然后输入到模型中以得到不同风格的响应。

此外,在对话系统中,一个很典型的问题是「多轮对话问题」—— 大部分对话系统都是「一问一答」的,很少考虑前几轮对话的内容,也就是我们常说的「上下文」。对于这个问题,小 S 的回答是,目前在一块的研究中提到的方法大概有这么几种:

  1. 将前几轮的历史对话信息作为额外的输入
  2. 设计多层次的对话系统
  3. 结合用户画像(user profile)来生成文本,取 top-5 的答案再进一步筛选

综上,encoder-decoder 模型虽然简单强大,但仍然有自身固有的问题,如果要设计一个良好的对话系统,以目前的情况,还是需要和传统方法进行结合。

后记

这应该是我参加集智的活动之中质量最高的一次了,75 页的 slides,覆盖 NLP 几大基本问题的内容和展开,加上小 S 本身在 NLP 这块的造诣,给我带来了很大的收获。又因为自己对 NLP 兴趣浓厚,参加完活动后就兴起了写一个完整笔记的念头,历时五天,查阅了很多的论文,把个中尚未明白的一些细节搞清楚,总算是完成了这篇 1 万多字的笔记。

希望以后能看到更多这样高质量的活动!

脚注:

1

Bengio, Yoshua, et al. "A neural probabilistic language model." journal of machine learning research 3.Feb (2003): 1137-1155.

2

Xu, Wei, and Alexander I. Rudnicky. "Can artificial neural networks learn language models?." (2000).

3

Mikolov, Tomas, et al. "Recurrent neural network based language model." Interspeech. Vol. 2. 2010.

4

Mikolov, Tomas, et al. "Distributed representations of words and phrases and their compositionality." Advances in neural information processing systems. 2013.

5

Le, Quoc V., and Tomas Mikolov. "Distributed Representations of Sentences and Documents." ICML. Vol. 14. 2014.

6

Weaver, Warren. "Translation." Machine translation of languages 14 (1955): 15-23.

7

Sutskever, Ilya, Oriol Vinyals, and Quoc V. Le. "Sequence to sequence learning with neural networks." Advances in neural information processing systems. 2014.

8

Cho, Kyunghyun, et al. "Learning phrase representations using RNN encoder-decoder for statistical machine translation." arXiv preprint arXiv:1406.1078 (2014).

9

Bahdanau, Dzmitry, Kyunghyun Cho, and Yoshua Bengio. "Neural machine translation by jointly learning to align and translate." arXiv preprint arXiv:1409.0473 (2014).

10

Firat, Orhan, Kyunghyun Cho, and Yoshua Bengio. "Multi-way, multilingual neural machine translation with a shared attention mechanism." arXiv preprint arXiv:1601.01073 (2016).

在 NLTK 中使用 Stanford NLP 工具包


注意:本文仅适用于 nltk<3.2.5 及 2016-10-31 之前的 Stanford 工具包,在 nltk 3.2.5 及之后的版本中,StanfordSegmenter 等接口相当于已经被废弃,按照官方建议,应当转为使用 nltk.parse.CoreNLPParser 这个接口,详情见 wiki,感谢网友 Vicky Ding 指出问题所在。

NLTK 与 Stanford NLP

NLTK 是一款著名的 Python 自然语言处理(Natural Language Processing, NLP)工具包,在其收集的大量公开数据集、模型上提供了全面、易用的接口,涵盖了分词、词性标注(Part-Of-Speech tag, POS-tag)、命名实体识别(Named Entity Recognition, NER)、句法分析(Syntactic Parse)等各项 NLP 领域的功能。

Stanford NLP 是由斯坦福大学的 NLP 小组开源的 Java 实现的 NLP 工具包,同样对 NLP 领域的各个问题提供了解决办法。

斯坦福大学的 NLP 小组是世界知名的研究小组,如果能将 NLTK 和 Stanford NLP 这两个工具包结合起来使用,那自然是极好的!在 2004 年 Steve Bird 在 NLTK 中加上了对 Stanford NLP 工具包的支持,通过调用外部的 jar 文件来使用 Stanford NLP 工具包的功能。

从 NLTK 的 commit 历史中可以找到相应的提交记录:

commit e1372fef56bfb88d02fdb6c0ea88474d5f414a38
Author: Steven Bird <stevenbird1@gmail.com>
Date:   Tue Aug 3 12:20:20 2004 +0000

    added Stanford

    svn/trunk@2088

现在的 NLTK 中,通过封装提供了 Stanford NLP 中的以下几个功能:

  1. 分词
  2. 词性标注
  3. 命名实体识别
  4. 句法分析,依存句法分析

安装和配置

NLTK 3.2 之后加入了用于中文分词的 StanfordSegmenter 这个类,作者是知名 NLP 博主 52nlp,见 相关文章。而 NLTK 3.1 及之前则只有以下几个类:

  1. 分词: StanfordTokenizer
  2. 词性标注: StanfordPOSTagger
  3. 命名实体识别: StanfordNERTagger
  4. 句法分析: StanfordParser
  5. 依存句法分析: StanfordDependencyParser, StanfordNeuralDependencyParser

方便起见,本文以 NLTK 3.2 这个版本来说明如何进行相关的安装和配置,3.1 及之前的版本基本相同。

注意事项

需要注意这么几点:

  1. Stanford NLP 工具包自 2014 年 10 月后(大概是 version 3.5.0)需要 Java 8 及之后的版本,如果出错请检查 Java 版本
  2. 下面的配置过程都以 Stanford NLP 3.6.0 为例,如果使用的是其他版本,请注意替换相应的文件名
  3. 下面的配置过程以 NLTK 3.2 为例,如果使用 NLTK 3.1,需要注意该旧版本中 StanfordSegmenter 未实现,其余大致相同
  4. 下面的配置过程是针对不同的接口分别讲述各自如何配置,根据来自 NLTK 的源代码,分别是

    • nltk/tokenize/stanford.py
    • nltk/tag/stanford.py
    • nltk/parse/stanford.py

    如果不想了解这些细节,可以参考 NLTK 官方 wiki 页面上的内容,但需要注意的是,StanfordSegmenter 和 StanfordNeuralDependencyParser 这两者的配置和其他的都不一样,而 wiki 页面上并未覆盖到这部分内容。

  5. 事实上,也可以完全不进行环境变量设置,但这就需要在每次调用的时候手动指定参数

StanfordSegmenter

  1. http://nlp.stanford.edu/software/segmenter.html 处下载 stanford-segmenter-2015-12-09.zip (version 3.6.0)
  2. 将 stanford-segmenter-2015-12-09.zip 解压, 并将解压目录中的 stanford-segmenter-3.6.0.jar 拷贝为 stanford-segmenter.jar
  3. stanford-segmenter.jarslf4j-api.jar 加入到 CLASSPATH 中去

    例:

    export STANFORD_SEGMENTER_PATH="$HOME/stanford/segmenter"
    export CLASSPATH="$CLASSPATH:$STANFORD_SEGMENTER_PATH/stanford-segmenter.jar:$STANFORD_SEGMENTER_PATH/slf4j-api.jar"
    

之所以要将 stanford-segmenter.jarslf4j-api.jar 加入到 CLASSPATH 中,是因为在 StanfordSegmenter 的实现中显式依赖了这两个文件,并且优先在 CLASSPATH 中寻找这两个文件。如果在 CLASSPATH 中找不到 stanford-segmenter.jar ,则会在环境变量 STANFORD_SEGMENTER 指定的路径中寻找;同样的,如果找不到 slf4j-api.jar ,则会在环境变量 SLF4J 指定的路径中寻找。其他几个类也有同样的依赖设置,为了统一管理,可以将所有依赖的 jar 文件都加入到 CLASSPATH 中去,当然分别为不同的 jar 文件设置不同的环境变量也是可以的。

除了设置环境变量,也可以通过函数参数来传入依赖的 jar 文件的准确路径,此时将会忽略环境变量设置。

StanfordTokenizer

  1. http://nlp.stanford.edu/software/tagger.html 中下载 stanford-postagger-full-2015-12-09.zip (version 3.6.0)
  2. stanford-postagger-full-2015-12-09.zip 解压
  3. 将解压目录中的 stanford-postagger.jar 加入到 CLASSPATH 中,或者设置到环境变量 STANFORD_POSTAGGER 中

    export STANFORD_POSTAGGER_PATH="$HOME/stanford/postagger"
    export CLASSPATH="$CLASSPATH:$STANFORD_POSTAGGER_PATH/stanford-postagger.jar"
    

    export STANFORD_POSTAGGER="$HOME/stanford/postagger/stanford-postagger.jar"
    

StanfordNERTagger 和 StanfordPOSTagger

在 NLTK 里,StanfordNERTagger 和 StanfordPOSTagger 都继承自 StanfordTagger ,在设置上有共同之处,因此放到一起来讲一下。

  1. http://nlp.stanford.edu/software/CRF-NER.html 处下载 stanford-ner-2015-12-09.zip (version 3.6.0)
  2. http://nlp.stanford.edu/software/tagger.html 中下载 stanford-postagger-full-2015-12-09.zip (version 3.6.0)
  3. stanford-ner-2015-12-09.zipstanford-postagger-full-2015-12-09.zip 都解压
  4. 将解压后目录中的 stanford-ner.jarstanford-postagger.jar 加入到 CLASSPATH 中去,和 StanfordTokenizer 不一样,这两个类都只从 CLASSPATH 中寻找对应的 jar 文件(所以为了统一我建议都添加到 CLASSPATH 中去)

    export STANFORD_NER_PATH="$HOME/stanford/ner"
    export STANFORD_POSTAGGER_PATH="$HOME/stanford/postagger"
    export CLASSPATH="$CLASSPATH:$STANFORD_NER_PATH/stanford-ner.jar:$STANFORD_POSTAGGER_PATH/stanford-postagger.jar"
    

    同时将 stanford-ner-2015-12-09.zip 解压后目录中的 classifiers 目录和 stanford-postagger-full-2015-12-09.zip 解压后目录中的 models 目录加入到环境变量 STANFORD_MODELS 中去

    export STANFORD_MODELS="$STANFORD_NER_PATH/classifiers:$STANFORD_POSTAGGER_PATH/models"
    

StanfordParser, StanfordDependencyParser

StanfordParser 和 StanfordDependencyParser 都继承自 GenericStanfordParser ,使用 stanford-parser.jar 来提供句法分析功能。

  1. http://nlp.stanford.edu/software/lex-parser.html 处下载 stanford-parser-full-2015-12-09.zip (version 3.6.0)
  2. 将下载的压缩包解压,并将其中的 stanford-parser.jarstanford-parser-3.6.0-models.jar (这个在不同版本中名称会不一样) 都加入到 CLASSPATH 中

    export STANFORD_PARSER_PATH="$HOME/stanford/parser"
    export CLASSPATH="$CLASSPATH:$STANFORD_PARSER_PATH/stanford-parser.jar:$STANFORD_PARSER_PATH/stanford-parser-3.6.0-models.jar"
    

    或者将 stanford-parser.jar 加入到环境变量 STANFORD_PARSER 中,将 stanford-parser-3.6.0-models.jar 加入到环境变量 STANFORD_MODELS 中

    export STANFORD_PARSER="$STANFORD_PARSER_PATH/stanford-parser.jar"
    export STANFORD_MODELS="$STANFORD_MODELS:$STANFORD_PARSER_PATH/stanford-parser-3.6.0.models.jar"
    

StanfordNeuralDependencyParser

StanfordNeuralDependencyParser 虽然也继承自 GenericStanfordParser,并且用来进行句法分析,但它使用的 Stanford CoreNLP 中的功能和模型,不依赖 Stanford Parser 这个(子)工具包。

  1. http://stanfordnlp.github.io/CoreNLP/ 处下载 stanford-corenlp-full-2015-12-09.zip
  2. 将下载的压缩包解压,并将其中的 stanford-corenlp-3.6.0.jarstanford-corenlp-3.6.0-models.jar 加入到 CLASSPATH 中去

    export STANFORD_CORENLP_PATH="$HOME/stanford-corenlp-full-2015-12-09"
    export CLASSPATH="$CLASSPATH:$STANFORD_CORENLP_PATH/stanford-corenlp-3.6.0.jar:$STANFORD_CORENLP_PATH/stanford-corenlp-3.6.0-models.jar"
    

    或者可以更简单地将解压目录设置为环境变量 STANFORD_CORENLP 的值

    export STANFORD_CORENLP=$STANFORD_CORENLP_PATH
    

基本使用

使用 StanfordSegmenter 和 StanfordTokenizer 进行分词

StanfordSegmenter 是 52nlp 实现的对 Stanford Segmenter 的封装,用来进行中文分词。

# coding: utf-8
from nltk.tokenize import StanfordSegmenter

segmenter = StanfordSegmenter(
    path_to_sihan_corpora_dict="/home/linusp/stanford/segmenter/data/",
    path_to_model="/home/linusp/stanford/segmenter/data/pku.gz",
    path_to_dict="/home/linusp/stanford/segmenter/data/dict-chris6.ser.gz"
)
res = segmenter.segment(u"北海已成为中国对外开放中升起的一颗明星")
print type(res)
print res.encode('utf-8')

StanfordSegmenter 的初始化参数说明:

  • path_to_jar: 用来定位 stanford-segmenter.jar ,在设置了 CLASSPATH 的情况下,该参数可留空

    注: 其他所有 Stanford NLP 接口都有 path_to_jar 这个参数,同样在设置了环境变量的情况下可以留空,后面不再另加说明。

  • path_to_slf4j: 用来定位 slf4j-api.jar ,在设置了 CLASSPATH 或者 SLF4J 这个环境变量的情况下,该参数可留空
  • path_to_sihan_corpora_dict: 设定为 stanford-segmenter-2015-12-09.zip 解压后目录中的 data 目录,话说这个参数名真是让人摸不着头脑
  • path_to_model: 用来指定用于中文分词的模型,在 stanford-segmenter-2015-12-09 的 data 目录下,有两个可用模型 pkg.gzctb.gz

需要注意的是,使用 StanfordSegmenter 进行中文分词后,其返回结果并不是 list ,而是一个字符串,各个汉语词汇在其中被空格分隔开。

StanfordTokenizer 可以用来进行英文的分词,使用起来比较简单

# coding: utf-8
from nltk.tokenize import StanfordTokenizer

tokenizer = StanfordTokenizer()
sent = "Good muffins cost $3.88\nin New York.  Please buy me\ntwo of them.\nThanks."
print tokenizer.tokenize(sent)

使用 StanfordNERTagger 进行命名实体识别

所谓命名实体识别,是用来识别并标注文本中的人名、地名、组织机构名等单元,这些单元既是 "命名实体"。

# coding: utf-8
from nltk.tag import StanfordNERTagger

eng_tagger = StanfordNERTagger('english.all.3class.distsim.crf.ser.gz')
print eng_tagger.tag('Rami Eid is studying at Stony Brook University in NY'.split())

StanfordNERTagger 在初始化时需要指定所使用的模型,在 stanford-ner-2015-12-09.zip 解压后的 classifiers 目录中,有几个可用的英语 NER 模型:

/home/linusp/stanford/ner/classifiers/
├── english.all.3class.distsim.crf.ser.gz
├── english.all.3class.distsim.prop
├── english.conll.4class.distsim.crf.ser.gz
├── english.conll.4class.distsim.prop
├── english.muc.7class.distsim.crf.ser.gz
├── english.muc.7class.distsim.prop
├── example.serialized.ncc.ncc.ser.gz
└── example.serialized.ncc.prop

如果需要进行中文的命名实体识别,则可以在 Stanford Named Entity Recognizer 页面的 Models 一节找到中文模型的下载链接,下载得到 stanford-chinese-corenlp-2015-12-08-models.jar ,解压后将 edu/stanford/nlp/models/ner/ 目录下的 chinese.misc.distsim.crf.ser.gz 和 chinese.misc.distsim.prop 复制到模型目录下(stanford-ner-2015-12-09/classifiers)即可。

# coding: utf-8
from nltk.tag import StanfordNERTagger

chi_tagger = StanfordNERTagger('chinese.misc.distsim.crf.ser.gz')
sent = u'北海 已 成为 中国 对外开放 中 升起 的 一 颗 明星'
for word, tag in  chi_tagger.tag(sent.split()):
    print word.encode('utf-8'), tag

使用 StanfordPOSTagger 进行词性标注

所谓词性标注,是根据句子中的上下文信息,给句中每个词确定一个最为合适的词性标记,比如动词、名词、人称代词等。

和 StanfordNERTagger 一样,StanfordPOSTagger 需要的输入也是一个已经分好词的句子,下面是一个英文的词性标注实例:

from nltk.tag import StanfordPOSTagger

eng_tagger = StanfordPOSTagger('english-bidirectional-distsim.tagger')
print eng_tagger.tag('What is the airspeed of an unladen swallow ?'.split())

如果之前配置时下载的是 stanford-postagger-full-xxxx-xx-xx.zip ,在解压后,其中的 models 目录是包含有两个中文模型的,分别是 chinese-distsim.taggerchinese-nodistsim.tagger ,可以直接使用。

# coding: utf-8
from nltk.tag import StanfordPOSTagger

chi_tagger = StanfordPOSTagger('chinese-distsim.tagger')
sent = u'北海 已 成为 中国 对外开放 中 升起 的 一 颗 明星'
for _, word_and_tag in  chi_tagger.tag(sent.split()):
    word, tag = word_and_tag.split('#')
    print word.encode('utf-8'), tag

这个中文的词性标注输出的结果有点奇怪……

使用 StanfordParser 进行句法分析

句法分析在分析单个词的词性的基础上,尝试分析词与词之间的关系,并用这种关系来表示句子的结构。实际上,句法结构可以分为两种,一种是短语结构,另一种是依存结构。前者按句子顺序来提取句法结构,后者则按词与词之间的句法关系来提取句子结构。这里说的句法分析得到的是短语结构。

from nltk.parse.stanford import StanfordParser

eng_parser = StanfordParser(model_path=u'edu/stanford/nlp/models/lexparser/englishPCFG.ser.gz')
print list(eng_parser.parse("the quick brown fox jumps over the lazy dog".split()))

得到的结果是一个 list, 其中的元素是 Tree 类型的,在上面这个例子中,这个 list 的长度是 1 ,调用 Tree 的 draw 方法可以将句法树绘制出来。

eng_parse_tree.png

要进行中文的句法分析,只要指定好中文的模型就好,可用的中文模型有两个,分别是 'edu/stanford/nlp/models/lexparser/chinesePCFG.ser.gz' 和 'edu/stanford/nlp/models/lexparser/chineseFactored.ser.gz',依然拿 "_北海 已 成为 中国 对外开放 中 升起 的 一 颗 明星_" 这句话作为例子,得到的句法树如下所示。

chi_parse_tree.png

使用 StanfordDependencyParser 进行依存句法分析

见上一节,依存句法分析得到的是句子的依存结构。

from nltk.parse.stanford import StanfordDependencyParser

eng_parser = StanfordDependencyParser(model_path=u'edu/stanford/nlp/models/lexparser/englishPCFG.ser.gz')
res = list(eng_parser.parse("the quick brown fox jumps over the lazy dog".split()))
for row in res[0].triples():
    print row

绘制出来的依存句法结构如下图所示。

dep_parse_tree.png

中文的依存句法分析同理,在初始化时使用中文模型即可,不再赘述。

StanfordNeuralDependencyParser 的使用与 StanfordDependencyParser 一样,但是在本人的机器上执行非常耗时,即使是对一些简单句子,所以这里就不略过不讲了。

文本相似度量方法(2): LCS 和编辑距离

本文是《文本相似度量方法(1): 概览》 一文的后续,着重讲述最长公共子序列(Longest Common Subsequence, LCS)和编辑距离的原理和实现。

最长公共子序列

问题定义

对给定序列 A 和 B, 满足以下条件的一个序列 C 被称为 A 和 B 的公共子序列:

  1. C 中每一个元素都对应 A 和 B 中一个元素
  2. 从 C 中挑选两个元素 \(C_{i}\) 和 \(C_{j}\) ,其中 \(i\) 和 \(j\) 表示这两个元素在 C 中的序号(从左至右),假设这两个元素分别对应 \(A_{m}\) 和 \(A_{n}\) ,那么有 \((j - i)\cdot(n - m)\gt 0\),在 B 中对应的两个元素同理

比如说给定 \(A =\) "打南边来了个喇嘛,手里提拉着五斤鳎目", \(B =\) "打北边来了个哑巴,腰里别着个喇叭" ,以下都是 A 和 B 的子序列:

  • "打边"
  • "打边来了个"
  • "边个着"

在所有可能的 C 中,长度最大的即所谓 "最长公共子序列"。上述例子中 A 和 B 的最长公共子序列是: "打边来了个,里着"。如下图所示:

lcs_example.png

直观感受上,我们可以认为,如果 A 和 B 的最长公共子序列越长,A 和 B 就越相似。这也是用最长公共子序列来度量文本相似程度的思想。

求解方法

LCS 是一个比较经典的算法问题,了解动态规划的读者应该对它不会陌生。

要求解 LCS,朴素的想法是将 A 的所有子序列都枚举一遍,看是否是 B 的子序列,然后从中挑选出最长的。对给定字符串 A ,假定其长度为 \(L\),其所有可能的子序列数量为 \(\sum_{i=1}^{L}\binom{L}{i}\) 也就是 \(2^{L} - 1\) ,所以暴力求解方法的复杂度至少是指数级的,这显然是不可取的。

通常我们用动态规划方法来求解 LCS 问题。

不难发现 LCS 的求解可以按照以下方法来得到:

  1. 对比 A 和 B 的第一个字符,如果相等,则转入步骤 2,否则转入步骤 3
  2. 将 A 和 B 的第一个字符记录为 LCS 的第一个字符,求 A 和 B 剩下部分的 LCS (转回步骤 1,下同)
  3. 将 A 去掉第一个字符,用剩下的部分和 B 一起计算 LCS,转入步骤 4
  4. 将 B 去掉第一个字符,用剩下的部分和 A 一起计算 LCS,转入步骤 5
  5. 比较第 3 步和第 4 步得到的 LCS,其中较长的就是 A 和 B 的 LCS.

上述过程很明显是一个递归过程,所以可以简单地实现出来。

# coding: utf-8

def lcs(a, b):
    if not a or not b:
        return []

    res = []
    if a[0] == b[0]:
        res.append(a[0])
        res.extend(lcs(a[1:], b[1:]))
    else:
        lcs_one = lcs(a[1:], b)
        lcs_two = lcs(a, b[1:])
        res = lcs_one if len(lcs_one) > len(lcs_two) else lcs_two

    return res

res = lcs('hello world', 'hero word')
print ''.join(res)

上述代码可以解决问题,看起来行数也不多,但其实还是有问题的,那就是递归树的存在导致了大量的重复计算、以及可能存在的栈溢出风险。举个栗子,计算 "hello" 和 "hero" 的过程如下图所示:

lcs_recursive.png

可以看到 "lcs('lo', 'o')" 被计算了两次。

解决上面提到的问题的方法是将递归方法改为循环方法。用 \(m\) 表示 A 的长度,用 \(n\) 表示 B 的长度,我们可以构建一个 \(m\times n\) 的矩阵 \(D\) ,用来保存上面递归过程中计算到的 A 和 B 的公共子序列。

具体方法是, \(D_{i,j}\) 表示去除 A 的前 i 个元素后的子串 \(A^{\prime}\) 和 B 去掉前 j 个元素后的子串 \(B^{\prime}\) 的 LCS。这样我们首先计算 \(D_{m,n}\) ,然后计算 \(D_{m-1,n}\) 和 \(D_{m,n-1}\) ,再计算 \(D_{m-1,n-1}\),依次类推。为什么可以这么做呢?仔细看看之前提到的递归过程中的 2、3、4 步,以及上面那张计算 "hello" 和 "hero" 的 LCS 的递归树图,可以发现计算最后都可以归结为计算 A 和 B 尾部片段的 LCS 。

我们发现上面的形式话表述不太直观,如果能先计算 \(D_{0,0}\) 是不是更好一些呢?是的,只要把之前提到的递归规则中的 "比较 A 和 B 的第一个字符" 改为 "比较 A 和 B 的最后一个字符" 并对步骤 2、3、4 做出相应的改变即可。

这样,LCS 的算法可以改写为:

# coding: utf-8

import numpy as np


def lcs(a, b):
    m, n = len(a), len(b)

    # 为便于计算,为 D 多增加一行一列
    # 其中第一行和第一列中的元素保持为空字符串
    D = np.zeros((m+1, n+1), dtype=object)
    D[:] = ''                   # 初始化为空字符串

    for idx_a, ch_a in enumerate(a, 1):
        for idx_b, ch_b in enumerate(b, 1):
            # 若 D 不增加一行一列,下标 idx_a-1/idx_b-1 要判断是否非负
            if ch_a == ch_b:
                D[idx_a, idx_b] = D[idx_a-1, idx_b-1] + ch_a
            else:
                lcs_one = D[idx_a, idx_b-1]
                lcs_two = D[idx_a-1, idx_b]
                if len(lcs_one) > len(lcs_two):
                    D[idx_a, idx_b] = lcs_one
                else:
                    D[idx_a, idx_b] = lcs_two

    return D[m, n]


print lcs('hello', 'hero')

当然实际上我们不会去用一个二维数组来保存计算过程中用到的(非最长)公共子序列,这样虽然很直观,但是在内存使用上有点丑陋。标准的做法是只记录这些公共子序列的长度,计算完整个长度矩阵后,再从最后的位置回溯取得 LCS 。

先观察一下计算 "lcs('hello', 'hero')" 时得到的公共子序列矩阵:

    h e r o
  - - - - -
h - h h h h
e - h he he he
l - h he he he
l - h he he he
o - h he he heo

矩阵中出现过的公共子序列有: 'h', 'he' 和 'heo'。从中我们 似乎可以发现这么一个规律: 从上往下逐行查看,这三个公共子序列 第一次 出现的时候,恰好就是 'hello' 和 'hero' 中有字符相等的时候,换成记录长度后,也就对应某个特定的长度值第一次出现的时候。

    h e r o
  - - - - -
h - 1 1 1 1
e - 1 2 2 2
l - 1 2 2 2
l - 1 2 2 2
o - 1 2 2 2

就这个例子而言,我们 似乎 可以这样来根据长度矩阵得到 LCS (行/列序号从 0 开始,后同):

  • 在第 1 行第 1 列找到 1 ,对应的字符是 'h'
  • 在第 2 行第 2 列找到 2 ,对应的字符是 'e'
  • 在第 5 行第 4 列找到 3 ,对应的字符是 'o'

这种方法 直观上感觉是对的,但实际上是有问题的 ,下面是计算 'GCGGACTG' 和 'GCCCTAGCG' 时得到的长度矩阵。

    G C C C T A G C G
  0 0 0 0 0 0 0 0 0 0
G 0 1 1 1 1 1 1 1 1 1
C 0 1 2 2 2 2 2 2 2 2
G 0 1 2 2 2 2 2 3 3 3
G 0 1 2 2 2 2 2 3 3 4
A 0 1 2 2 2 2 3 3 3 4
C 0 1 2 2 3 3 3 3 4 4
T 0 1 2 2 3 4 4 4 4 4
G 0 1 2 2 3 4 4 5 5 5

如果按照刚才的做法来反推 LCS ,会得到下面的结果:

  1. 在第 1 行第 1 列找到 1 ,对应的字符是 'G'
  2. 在第 2 行第 2 列找到 2 ,对应的字符是 'C'
  3. 在第 3 行第 7 列找到 3 ,对应的字符是 'G'
  4. 在第 4 行第 9 列找到 4 ,对应的字符是 'G'
  5. 第 4 步找到的 'G' 是 'GCCCTAGCG' 的最后一个字符(表中最后一列),因此停止

实际上真正求得的 LCS 长度应该是 5 ,为 'GCGCG'、'GCACG' 或 'GCCTG',而不是 'GCGG'。问题出在哪呢? LCS 可以看成是一个最优解,但到达最优解的路径可能有不止一条,而且局部的最优解并不一定是最优解的组成部分,所以前面提到的贪心方法在有些情况下可以得到正确的结果,但有的情况下就会出错。

正确的做法是从长度矩阵右下角,根据长度矩阵的计算规则往前反推,这样就能保证得到的结果是最长的公共子序列。

先把子序列长度矩阵的计算方法实现:

import numpy as np


def lcs_matrix(a, b):
    m, n = len(a), len(b)
    matrix = np.zeros((m+1, n+1), dtype=int)

    for idx_a, ch_a in enumerate(a, 1):
        for idx_b, ch_b in enumerate(b, 1):
            if ch_a == ch_b:
                matrix[idx_a, idx_b] = matrix[idx_a-1, idx_b-1] + 1
            else:
                matrix[idx_a, idx_b] = max(
                    matrix[idx_a, idx_b-1],
                    matrix[idx_a-1, idx_b]
                )

    return matrix


print lcs_matrix('GCGGACTG', 'GCCCTAGCG')

根据长度矩阵的计算规则,可以按照以下步骤来反推出 LCS:

  1. 首先定位到长度矩阵右下角位置
  2. 如果当前位置的值为 0 ,则停止;否则转到步骤 3
  3. 如果当前位置对应的 A 和 B 的元素相等,则向当前位置的左上角后退一步(行号和列号各减 1),并回到步骤 2,否则转到步骤 4
  4. 检查矩阵当前位置左边的值和上边的值,跳转到其中值更大的那个位置(如果相等,则在往上和往左中选择一个方向),回到步骤 2

用代码实现出来大概是这样:

import numpy as np


def lcs_backtrace(a, b, matrix):
    idx_a, idx_b = len(a) - 1, len(b) - 1

    lcs_list = []
    while matrix[idx_a+1, idx_b+1] > 0:
        if a[idx_a] == b[idx_b]:
            lcs_list.append(a[idx_a])
            idx_a -= 1
            idx_b -= 1
        else:
            upper_value = matrix[idx_a, idx_b+1]
            left_value = matrix[idx_a+1, idx_b]
            if upper_value > left_value:
                idx_a -= 1
            else:
                idx_b -= 1

    lcs_list.reverse()
    return lcs_list


a = 'GCGGACTG'
b = 'GCCCTAGCG'
matrix = np.array([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                   [0, 1, 1, 1, 1, 1, 1, 1, 1, 1],
                   [0, 1, 2, 2, 2, 2, 2, 2, 2, 2],
                   [0, 1, 2, 2, 2, 2, 2, 3, 3, 3],
                   [0, 1, 2, 2, 2, 2, 2, 3, 3, 4],
                   [0, 1, 2, 2, 2, 2, 3, 3, 3, 4],
                   [0, 1, 2, 3, 3, 3, 3, 3, 4, 4],
                   [0, 1, 2, 3, 3, 4, 4, 4, 4, 4],
                   [0, 1, 2, 3, 3, 4, 4, 5, 5, 5]], dtype=int)
print lcs_backtrace(a, b, matrix)

需要注意的是,LCS 结果可能并不唯一。

数学表示与相似度量

上述求解方法可以用数学语言表示如下:

\[\begin{eqnarray}LCS(A_{i}, B_{j}) = \begin{cases} 0 & if\quad i=0\quad or\quad j=0\\ LCS(A_{i-1}, B_{j-1})+a_{i} & if\quad a_{i}=b_{j}\\ longest(LCS(A_{i}, B_{j-1}), LCS(A_{i-1}, B_{j})) & if\quad a_{i}\neq b_{j} \end{cases}\end{eqnarray}\]

其中 \(A_{i}\) 表示 A 的前 \(i\) 个字符组成的字符串,\(B_{j}\) 同理。

直观上,我们可以认为 A 和 B 的 LCS 越长,那么 A 和 B 就越相似。为了使所有用于比较的 (A, B) 对得到的相似度量能进行横向比较,定义 LCS 相似度为:

\[S(A, B) = \frac{2\cdot |LCS(A, B)|}{|A| + |B|}\]

这样得到的相似度的值就被变换到 [0, 1] 区间中了。

编辑距离

所谓编辑距离

编辑距离最早由俄罗斯科学家 Levenshtein 提出,故又称 "Levenshtein 距离"。其定义为: 给定两个字符串 A 和 B,将 A 通过删除、插入、替换操作转换为 B 所需要的最少操作次数。

比如将 "kitten" 转换为 "sitting" 需要进行如下操作:

  1. 替换操作: kitten -> sitten (k -> s)
  2. 替换操作: sitten -> sittin (e -> i)
  3. 插入操作: sittin -> sitting (SPC -> g)

我们就说 "kitten" 相对 "sitting" 的编辑距离是 3。

编辑距离衡量的是两个字符串之间的差异程度,所以差异程度越小,相似程度就越大了。

求解方法

编辑距离的核心过程 LCS 在我看来是一样的,可以看一下它的数学描述:

\[\begin{eqnarray}LEV(A_{i}, B_{j}) = \begin{cases} max(i, j) & if\quad min(i, j)=0\\ min(LEV(A_{i-1}, B_{j})+1, LEV(A_{i}, B_{j-1})+1, LEV(A_{i-1}, B_{j-1})) & if\quad a_{i}=b_{j}\\ min(LEV(A_{i-1}, B_{j})+1, LEV(A_{i}, B_{j-1})+1, LEV(A_{i-1}, B_{j-1})+1) & if\quad a_{i}\neq b_{j}\\ \end{cases}\end{eqnarray}\]

按照其数学描述,可以实现如下:

# coding: utf-8
import numpy as np


def edit_distance(a, b):
    m, n = len(a), len(b)
    dis_matrix = np.zeros((m+1, n+1), dtype=int)

    # 初始化距离矩阵的第 0 行和第 0 列
    dis_matrix[0, :] = np.arange(n+1)
    dis_matrix[:, 0] = np.arange(m+1)

    # 开始计算
    for idx_a, ch_a in enumerate(a, 1):
        for idx_b, ch_b in enumerate(b, 1):
            cur_dis = None

            dis_left = dis_matrix[idx_a, idx_b-1]
            dis_upper = dis_matrix[idx_a-1, idx_b]
            dis_upper_left = dis_matrix[idx_a-1, idx_b-1]
            if ch_a == ch_b:
                cur_dis = min(dis_left+1, dis_upper+1, dis_upper_left)
            else:
                cur_dis = min(dis_left+1, dis_upper+1, dis_upper_left + 1)

            dis_matrix[idx_a, idx_b] = cur_dis

    return dis_matrix[m, n]


print edit_distance('kitten', 'sitting')

从编辑距离到相似度量

与 LCS 不同的是,编辑距离衡量的是差异性,所以用编辑距离来表示相似程度,按照如下式子进行转换:

\[S(A, B)=1-\frac{LEV(A, B)}{max(|A|, |B|)}\]

一点看法

LCS 和编辑距离都是基于相似的动态规划方法来进行求解,它们之间是有很强的共性的。不过 LCS 强调的是 "公共子序列" 这个概念,而编辑距离强调删除、插入、替换这三种编辑操作。实际上还有扩充的编辑距离,比较经典的是 Damerau-Levenshtein 距离,它在 Levenshtein 距离定义的三种编辑操作之外还加入了 "交换相邻字符" 这个操作,因为在输入时将邻近字符的顺序搞错的事情实在是蛮常见的(代码里的 typo 多是这种类型 XD)。

编辑距离和 LCS 不同的另一点,那就是在 LCS 中,用于比较的两个串的 地位 是等价的,而在编辑距离及其衍生方法中,一般会有一者被认为是 标准的

另外,在生物信息学中,序列比对是非常常见的计算,比如 DNA序列和氨基酸序列的比对。在这个领域中,经常使用 Needleman-Wunsch 算法和 Smith-Waterman 算法来进行相关的处理。大致上,这两种算法和编辑距离也很相似,都是基于动态规划的算法,但在生物信息学中, 序列的缺失 往往是更不能容忍的现象,因此对应于编辑距离中的删除错误,在这两种算法中,会给予比插入错误和替换错误更高的惩罚值。这些都是后话了,计划在下一篇博客中再详细讨论。

在应用上,LCS 主要被应用于版本控制和 diff 工具中,下面是摘自 Git 源代码中的一个片段

for (i = 1, baseend = base; i < origbaselen + 1; i++) {
  for (j = 1, newend = new; j < lennew + 1; j++) {
    if (match_string_spaces(baseend->line, baseend->len,
                            newend->line, newend->len, flags)) {
      lcs[i][j] = lcs[i - 1][j - 1] + 1;
      directions[i][j] = MATCH;
    } else if (lcs[i][j - 1] >= lcs[i - 1][j]) {
      lcs[i][j] = lcs[i][j - 1];
      directions[i][j] = NEW;
    } else {
      lcs[i][j] = lcs[i - 1][j];
      directions[i][j] = BASE;
    }
    if (newend->next)
      newend = newend->next;
  }
  if (baseend->next)
    baseend = baseend->next;
}

而编辑距离则常见于模糊匹配、拼写检查等场景中,比如 GNU Aspell 使用的就是 Levenshtein 距离,而 GNU Ispell 则使用 Damerau-Levenshtein 距离。从我曾经的工作经历来看,在语音识别中和 OCR (光学字符识别)中,编辑距离也用于衡量识别结果的质量。此外在图像处理领域,也有使用编辑距离的场景。

虽然说基于 LCS 或者编辑距离的文本相似方法都是朴素的、从表面上进行的判断,没有办法深入到语义层面,但用来做一些简单场景的分析已经是足够了,有必要的话还可以对其进行扩充,比如以词为单位来进行处理,比如扩充 item 和 item 的比较方法而不是简单地对比文本组成是否一致。

(待续)

文本相似度量方法(1): 概览

距离度量与相似度量

距离度量或相似度量是被广泛应用的概念,简单来说,可以这么认为:

  1. 距离度量是度量两个“物体”之间的差异程度的
  2. 相似度量是度量两个“物体”之间的近似程度的

在数学上,这两者都有严格的定义,比如说一个严格意义上的距离度量,是应该满足以下几个条件的:

  1. 对称性,即 \(d(A, B) = d(B, A)\)
  2. 非负性,即 \(\forall A\forall B, d(A, B) \ge 0\)
  3. 一致性,即 \(A = B\Leftrightarrow d(A, B) = 0\)
  4. 次可加性(subadditivity)/三角不等式,即 \(d(A, B) \le d(A, C) + d(C, D)\)

严格意义上的相似度量也有类似的限制,比较基本的两条有:

  1. 如果 \(A\) 和 \(B\) 完全不同,那么 \(s(A, B) = 0\)
  2. 如果 \(A\) 和 \(B\) 完全相同,那么 \(s(A, B) = 1\)

距离度量和相似度量是互有关联的,在一些具体的方法中,甚至是可以互相转换的,后续谈到的一些具体的度量方法,其原始目的就是度量距离的。但这两者仍然是有区别的,为了防止混淆,我会尽量统一表示成相似度量。

另外一点需要注意的是,由于具体应用场景的限制或者特殊的需求,往往不一定会应用严格意义的相似度量方法 —— 对这些方法,称之为“度量”其实是不严谨的,但为了表述上的一致,我将保持这种用法。

文本相似度量方法一览

此处的“文本”一词涵盖以下两个对象:

  1. 字符串/序列
  2. 包含较多文本内容的文档

相关的度量方法可以分为两大类,各类下面再有一些具体的分类,比较常用的方法如见下图

similarity_survey.png

总的来说,文本相似度量方法可以分为两大类:

  1. String Based,即基于待比较的文本本身中的信息,该类方法评估的是”词法“上的相似性,或说朴素的相似性
  2. Corpus Based,即基于一个较大的文本集合中的信息,该类方法评估的是“语义”上的相似性

这些方法被普遍应用于以下领域:

  1. 信息检索
  2. 文档分类
  3. 文档聚类
  4. 主题检测
  5. 自动问答
  6. 自动摘要

String Based Methods

要比较两个文本的相似性,比较直观的方法是逐个字符比对,看看有多少个字符是一致的 —— String Based 的方法就是从这种思路中发展出来的。其中以字符为单位去比较的方法被统称为 Character Based,而以词为单位的则被称为 Term Based。

Character Based 的方法普遍用来进行较短文本、小规模的比较,会注意文本中各字符的顺序和位置;Term Based 的方法则更适合用来进行较长文本或大规模的比较,而且通常会抛弃文本中各个单元(通常是词)之间的顺序、位置信息 —— 一般的做法是用文本中的词组成的向量来表示文本,也就是所谓的“向量空间模型(Vecter Space Model, VSM)”。

Character Based Methods

  • 最长公共子序列(Longest Common Subsequence, LCS)

    LCS 方法通过计算出两个字符串/序列之间的最长公共子序列,并使用这个子序列的长度来反映两个字符串/序列之间的相似程度。

  • 编辑距离(Leveinshtein Distance)

    在两个字符串的比较中应用编辑距离时,通常会有一者作为”标准字符串“,另一者则作为”可能错误“的字符串,通过将后者变换成前者所进行的字符的 插入删除替换 操作次数作为衡量两者差异程度的指标。

  • 扩展的编辑距离(Damerau-Levenshtein Distance)

    扩展的编辑距离在思想上与编辑距离一样,只是除插入、删除和替换操作外,还支持 相邻字符的交换 这样一个操作,增加这个操作的考虑是人们在计算机上输入文档时的错误情况中,因为快速敲击而前后两个字符的顺序被输错的情况很常见。

  • Needleman-Wunsch Similarity

    该方法被广泛运用于生物信息学中的序列比对,如氨基酸序列比对、核苷酸序列比对等。其基本思路与编辑距离相近,但在编辑距离中,三种不同的错误情况是平等的,而在生物信息学中,序列中的单元缺失情况比错误(位置匹配但内容不同)情况更不能容忍,因此在 Needleman-Wunsch 方法中,插入错误和删除错误会被赋予较高的惩罚分数。

  • Smith-Waterman Similarity

    Smith-Waterman 方法用于生物信息学中的序列比对,但与 Needleman-Wunsch 方法不一样,它是一个 局部最优比对 方法,简单来说,它的目的是找出两个序列之间 连续且相同 的子序列。

  • Jaro Similarity 和 Jaro-Winkler Similarity

    Jaro 方法和 Jaro-Winkler 方法考虑两个字符串之间相同字符的顺序位置和个数,只适用于像人名这样的较短字符串之间的比较。其中 Jaro-Winkler 方法是对 Jaro 方法的改进,而 Jaro 方法现在已经不常用。

  • Hamming Distance

    Hamming 距离用于 长度相同 的序列之间的比较,思想非常简单,就是逐位比较得到的不同次数。Hamming 距离被广泛应用于信息学。

Term Based Methods

Term Based 方法中的 term 不一定是词,也可以是关键词、短语,当选定”词“作为 term 时,可以用 Bag-of-Words Model(BOW) 来更精确地描述此时的模型。为了表述上的方便,本系列文章将此处的 term 视为词。

  • Cosine Similarity

    余弦相似度建立在用向量表示文档的前提上。两个文档的向量,同一个维度应该是表示的同一个词,而每一个维度的值,一般是用”词频-逆文档频率(Term Frequency-Inverse Document Frequency, TF-IDF)“ 来表示。

    建立向量表示后,通过计算两个向量之间的夹角大小来衡量两个文档之间的近似程度,由于夹角的余弦值的计算方便,而且天然地处在 0 和 1 之间,故一般是用夹角的余弦值而不是夹角的大小来作为度量。

  • Dice's Coefficient 和 Jaccard Similarity

    Dice 系数和 Jaccard 相似性起初被用于生态学上,作为一种判断物种间相似性的方法。在生态学上,要比较两个物种间相似程度时,通常会对该物种的特性进行采样,最后得到各自的特性集合,而 Dice 系数和 Jaccard 相似性都是通过比较两者之间的 共有特性 占比来度量相似性的,因此这两种方法都不是很关心每个 "Term" 的具体量,只是关心有没有某个 "Term"。

Corpus Based Methods

Corpus Based 方法通过对大量文档的统计分析得到语义上的相似,这里 “大量文档” 就是 Corpus 即语料了。

Language Model

常见的语言模型(Language Model, LM)都基于马尔可夫假设(Markov Assumption),即认为语言中每个词只与其前面长度为 N-1 个词有关 —— 这 N-1 个词其实就构成了该词的上文,同时由于每个词都会成为其他词的上文,下文信息也会得到表现。也就是说,语言模型统计的其实是语言中每个词的一定程度的上下文情况。基于语言模型的这个特点,以及下面这样一个假设:

   具有相同(或相近)上下文的词,其语义是相近的。

就可以用语言模型来进行文本的相似度量了。具体一点,有两种方式:

  1. 通过语言模型来发现同义词、近义词,来弥补 Term Based Methods 的缺陷;
  2. 扩大 "term" 的表示范围,比如说按照词组来进行统计,甚至按照句子来进行统计,那么就可以反映词组之间的相似性、句子之间的相似性了。

Topic Model

主题模型与向量空间模型有共同的一点是也是基于 BOW 模型的,也就是说,并不像语言模型一样考虑词与词之间的顺序、位置,而只是通过词与词的共现(Co-occurrence)来反映词与词之间的相似性。

主题模型是一种无监督的方法,与聚类方法在外在表现上会有一定的相似性,但它们各自的内在原理是相差较大的。

❌