入门 | 玩转词向量:用fastText预训练向量做个智能

原标题:入门 | 玩转词向量:用fastText预训练向量做个智能小程序

选自Medium

作者:Martin Konicek

参与:Panda

越来越多的软件工程师开始学习和涉足机器学习开发。近日,伦敦的软件工程师 Martin Konicek 在 Medium 上介绍了他使用 fastText 预训练过的词向量创建一个智能回答小程序的过程,相信能给仍不理解词向量的初学者提供一些帮助。此外,这个程序的代码也已经在 GitHub 上公开,感兴趣的读者不妨自己动手实现一下。更多有关 fastText 的介绍,可参阅机器之心专栏文章《专栏 | fastText 原理及实践》。

过去几年,人工智能领域发展非常快,相关的文章也层出不穷,你很有可能已经听说过自然语言处理领域内的一些出色成果了。

比如,一个程序可以通过阅读维基百科完成对这个问题的分析:男人对于国王就相当于女人对于___?(女王/王后)

我自己也写过一个简单程序来完成这一任务。我很好奇:它能够回答多难的问题?

这篇博文是对这一主题的非常轻量的介绍。我没有训练任何机器学习模型,而是下载了使用 fastText 库创造的预训练英语词向量:https://fasttext.cc/docs/en/english-vectors.html

首先先看数据

fastText 通过阅读维基百科学习到了什么?让我们打开这个 2GB 的文件一探究竟:

  1. good -0.1242 -0.0674 -0.1430 -0.0005 -0.0345 ...
  2. day 0.0320 0.0381 -0.0299 -0.0745 -0.0624 ...
  3. three 0.0304 0.0070 -0.0708 0.0689 -0.0005 ...
  4. know -0.0370 -0.0138 0.0392 -0.0395 -0.1591 ...
  5. ...

很好,这个格式应该很容易操作。每一行都包含一个词,表示成了 300 维空间中的一个向量。如果这是二维的,我们可以想象会是这样:

唯一的区别是每个词所具有的坐标的数量不是 2,而是 300.

这个输入文件中的词是按照频率排序的,这很方便。对于我的实验来说,使用最常见的 10 万个英语词就足够了,所以我将前 10 万行复制成了一个单独的文件。

使用 Python 3

为了让我的第一个项目足够简单,我决定使用普通的 Python 3,不带任何附加依赖包。我也在使用 Mypy,这是一个用于 Python 的静态类型检查器(http://mypy-lang.org)。

首先,我们定义一个类来表示每个词:

  1. from typing import List
  2. Vector = List[float]
  3. class Word:
  4. def __init__(self, text: str, vector: Vector) -> None:
  5. self.text = text
  6. self.vector = vector

接下来,我们将数据加载到内存中:

  1. words = load_words('data/words.vec')

我们已经将该文件解析成了一个 List[Word]。这部分非常简单,我把细节放在本文后面,现在直接来看有意思的东西。

余弦相似度

将这些向量放入内存之后,我们就可以回答各种关于它们的问题了。我的第一个问题是:

在这个向量空间中,哪个词与给定的词最近?

我们如何计算两个词向量 a 和 b 之间的距离?你可能会说「欧几里得距离」,但对我们的用例而言,余弦相似度的效果要好得多。其背后的思想是:向量的绝对长度并不重要,重要的是两个向量之间的角度。

根据高中所学习到的内容(或根据维基百科),余弦相似度为:

用 Python 表示:

  1. def vector_len(v: Vector) -> float:
  2. return math.sqrt(sum([x*x for x in v]))
  3. def dot_product(v1: Vector, v2: Vector) -> float:
  4. assert len(v1) == len(v2)
  5. return sum([x*y for (x,y) in zip(v1, v2)])
  6. def cosine_similarity(v1: Vector, v2: Vector) -> float:
  7. """
  8. Returns the cosine of the angle between the two vectors.
  9. Results range from -1 (very different) to 1 (very similar).
  10. """
  11. return dot_product(v1, v2) / (vector_len(v1) * vector_len(v2))

现在我们可以得到一个词与给定词的相似度了:

  1. def sorted_by_similarity(words: List[Word], base_vector: Vector) -> List[Tuple[float, Word]]:
  2. """Returns words sorted by cosine distance to a given vector, most similar first"""
  3. words_with_distance = [(cosine_similarity(base_vector, w.vector), w) for w in words]
  4. # We want cosine similarity to be as large as possible (close to 1)
  5. return sorted(words_with_distance, key=lambda t: t[0], reverse=True)

我们只需要一些简单的实用函数来显示相关词:

  1. def print_related(words: List[Word], text: str) -> None:
  2. base_word = find_word(text, words)
  3. sorted_words = [
  4. word.text for (dist, word) in
  5. sorted_by_similarity(words, base_word.vector)
  6. if word.text.lower() != base_word.text.lower()
  7. ]
  8. print(', '.join(sorted_words[:7]))
  9. def find_word(words: List[Word], text: str) -> Word:
  10. return next(w for w in words if text == w.text)

现在试试看:

  1. >>> print_related(words, 'spain')
  2. britain, england, france, europe, germany, spanish, italy
  3. >>> print_related(words, 'called')
  4. termed, dubbed, named, referred, nicknamed, titled, described
  5. >>> print_related(words, 'although')
  6. though, however, but, whereas, while, since, Nevertheless
  7. >>> print_related(words, 'arms')
  8. legs, arm, weapons, coat, coats, armaments, hands
  9. >>> print_related(words, 'roots')
  10. root, origins, stems, beginnings, rooted, grass, traditions

结果非常好!看起来余弦相似度高的词确实是彼此相关的——要么是句法上相关(roots 和 rooted),要么是语义上相关(roots 和 grass、arms 和 legs)。

完成句子:巴黎对于法国就相当于罗马对于___

来试试更难的任务。给定的两个词「巴黎」和「法国」之间存在语义关系(巴黎是法国的首都);对于第三个词「罗马」,我们能推理得到「意大利」吗?

事实证明我们可以直接通过加减向量来做到这一点!这是因为这些词的向量在空间中具有特定的关系:

事实证明这两个红色的向量非常相似!我们可以想象它们表示「首都」这个关系。

惊人的来了:

  1. vector(“France”) - vector("Paris") = answer_vector - vector("Rome")

因此:

  1. vector(“France”) - vector("Paris") + vector("Rome") = answer_vector

我们会寻找接近于答案向量的词;这个答案就算不是「意大利」,也应该与之相近。

让我们实现它:

  1. def closest_analogies(
  2. left2: str, left1: str, right2: str, words: List[Word]
  3. ) -> List[Tuple[float, Word]]:
  4. word_left1 = find_word(left1, words)
  5. word_left2 = find_word(left2, words)
  6. word_right2 = find_word(right2, words)
  7. vector = add_vectors(
  8. sub_vectors(word_left1.vector, word_left2.vector),
  9. word_right2.vector)
  10. closest = sorted_by_similarity(words, vector)[:10]
  11. def is_redundant(word: str) -> bool:
  12. """
  13. Sometimes the two left vectors are so close the answer is e.g.
  14. "shirt-clothing is like phone-phones". Skip 'phones' and get the next
  15. suggestion, which might be more interesting.
  16. """
  17. return (
  18. left1.lower() in word.lower() or
  19. left2.lower() in word.lower() or
  20. right2.lower() in word.lower())
  21. return [(dist, w) for (dist, w) in closest if not is_redundant(w.text)]
  22. def print_analogy(left2: str, left1: str, right2: str, words: List[Word]) -> None:
  23. analogies = closest_analogies(left2, left1, right2, words)
  24. if (len(analogies) == 0):
  25. print(f"{left2}-{left1} is like {right2}-?")
  26. else:
  27. (dist, w) = analogies[0]
  28. print(f"{left2}-{left1} is like {right2}-{w.text}")

接下来问一些问题:

  1. >>> print_analogy('Paris', 'France', 'Rome', words)
  2. Paris-France is like Rome-Italy
  3. >>> print_analogy('man', 'king', 'woman', words)
  4. man-king is like woman-queen
  5. >>> print_analogy('walk', 'walked' , 'go', words)
  6. walk-walked is like go-went
  7. >>> print_analogy('quick', 'quickest' , 'far', words)
  8. quick-quickest is like far-furthest

成功了!通过阅读维基百科,fastText 学会了关于首都、性别、不规则变化的动词和形容词的关系。再试试其它的:

  1. English-Jaguar is like German-BMW // Expensive cars
  2. English-Vauxhall is like German-Opel // Cheaper cars
  3. German-BMW is like American-Lexus // Expensive cars
  4. German-Opel is like American-Chrysler // Cheaper cars

还有:

  1. >>> print_analogy('dog', 'mammal', 'eagle', words)
  2. dog-mammal is like eagle-bird

这个模型并不能正确分析一切

你会怎样完成下列分析?

1. 寿司-米饭就像是披萨-___

2. 寿司-米饭就像是牛排-___

3. 衬衫-衣服就像是电话-___

4. 衬衫-衣服就像是碗-___

5. 书-阅读就像是电视-___

寿司是用米饭和其它配料做成的,披萨是用面团、肉肠、芝士等材料做成的。牛排是肉制品。衬衫是一种衣服,电话是一种电子设备,碗是一种餐具。书是用来阅读的,电视是用来观看的。

让我们看看答案:

  1. sushi-rice is like pizza-wheat // Makes sense
  2. sushi-rice is like steak-chicken
  3. shirt-clothing is like bowl-food
  4. shirt-clothing is like phone-mobile
  5. book-reading is like TV-television

还有:

  1. >>> print_analogy('do', 'done' , 'go', words)
  2. do-done is like go-undertaken

可以看到,fastText 的分析并不都是正确的。它的结果很出色,但错起来也很离谱。

如果我们看看建议列表,而不只是第一个,会有更好的答案吗?

  1. sushi-rice is like steak-
  2. [chicken (0.58), beef (0.56), potatoes (0.56), corn (0.55)]
  3. book-reading is like TV-
  4. [television (0.68), watching (0.64), listening (0.57), viewing (0.57)]
  5. shirt-clothing is like bowl-[food, cereal, rice, porridge]
  6. shirt-clothing is like phone-[mobile, cellular]

其中第二个建议是非常好的。但是,看起来「衬衫」和「衣服」之间的关系仍然是一个谜。

结论

当我第一次在我的笔记本电脑上看到这些结果时,我完全震惊了。这个仅由少量 Python 代码组成的程序能让你感到它是智能的并且能真正理解你询问的东西。

在我尝试过一些困难的问题之后,我意识到这个程序也可能「离题千里」——任何人类都不会犯这样的错。这可能会让你认为这个模型根本就不聪明。

但是,想想这一点:这些向量是在英语文本上训练的,但和人类不一样,这个学习算法没有任何预先的英语知识。在阅读维基百科几个小时之后,它很好地学习到了英语语法以及很多真实世界概念之间的语义关系。只要文本足够,它也能使用德语、泰语、汉语或其它任何语言做到这一点。

对于一个人类孩童来说,需要多少时间才能发展出足以回答上述问题所需的逻辑思考能力?成年人学习一门外语的效果呢?或者说两门乃至三门外语呢?人类需要数年时间才能做到的事情 Word2vec 或 fastText 仅需数小时就能办到。人类的学习方式与这些算法有很大的差异。人类可以使用远远更少的数据而更好地学习到概念,但所需的时间更长。

本文中的所有代码都已公布在 GitHub 上。你只需要 Python 3 和预训练的向量来运行该代码,然后就能自己寻找词之间的有趣关系了。

代码:https://github.com/mkonicek/nlp

预训练的向量:https://fasttext.cc/docs/en/english-vectors.html

附录

我想尽可能地缩短正文的篇幅,同时涵盖最重要的基础和相关结果。下面给出了更多细节。

一点简单的开发工作

我第一次实现该算法时,得到的结果是错误的,比如:

  1. man-king is like woman-king

这是因为通过「向量(国王)- 向量(男人)+ 向量(女人)」所得到的答案向量与「国王」这个词非常接近。也就是说,减去「男人」向量再加上「女人」向量对「国王」向量的影响很小,这可能是因为「男人」与「女人」本身是相关的。

实际上,我得到的几乎所有答案都只是对某个输入词的简单重复。我做了一些开发来跳过建议答案中的这些多余的词,然后才开始得到上面给出的相关答案。这部分开发在代码中被称为 is_redundant。

更新:fastText 的作者 Tomas Mikolov 在 Facebook 上回复说我所做的实际上是一个众所周知的操作,而且是正确的。

向量是如何产生的?

我只写了少量代码就得到了这些惊人的结果。这是因为所有的神奇之处都在向量之中——使用 fastText 在数千兆字节的维基百科英语文本和其它来源上进行了训练。另外还有一些与 fastText 类似的库,比如 Word2vec 和 GloVe。这些库是如何工作的?这个主题值得再写一篇文章,但背后的思想是:出现在相似语境中的词应该具有相似的向量。比如如果词「披萨」通常出现在「吃」、「餐厅」和「意大利」等词的附近,那么「披萨」的向量就会与那些词的向量具有较高的余弦相似度。很少同时出现的词会有较低的余弦相似度,可能能够达到 -1。

历史

n-gram 和将词表示为向量等思想已经存在了很长时间,但直到 2013 年那篇 Word2vec 的论文和实现发表之后(https://arxiv.org/abs/1301.3781),才表明这些方法「能以远远更低的计算成本实现准确度的极大提升」。Tomas Mikolov 为 Word2Vec 和 fastText 这两个项目都立下过汗马功劳。

我现在才开始学习,但好事不嫌晚。

为什么在 Python 中使用类型(type)?

我同意 Michael Bolin 的说法:「在为大型软件项目选择语言时,静态类型是一项关键特性」。类型有助于我们让代码更具可读性和减少漏洞。即使是对于这样的小项目,如果在运行代码之前就能在 Atom 中看到错误,也能带来极大的帮助,节省很多时间。

Python 3 runtime 接受类型注释,这一点非常棒。任何人都无需任何额外设置就能运行我的代码。在运行这些代码之前无法对其进行转换,就像使用 Java 的 Flow 一样。

优化

这个代码完全没有进行优化,而且也没有进行太多错误处理(error handling)。至少我们可以归一化所有的向量以使余弦相似度的计算更快(在 10 万词的情况下,调用一次 sorted_by_similarity 来回答一个问题,在我的 MacBook Pro 上耗时 7 秒)。我们也许应该使用 numpy。这个 Python 进程要使用近 1GB 的内存。我们可以使用一个已有的实现,可以直接加载文件然后问答问题。所有这些都超出了本文的范畴。我只是想知道我能不能从头开始写一些非常简单的代码来回答一些有趣的问题。

加载和清理数据

这是 load_words 函数所做的工作:

  1. def load_words(file_path: str) -> List[Word]:
  2. """Load and cleanup the data."""
  3. words = load_words_raw(file_path)
  4. print(f"Loaded {len(words)} words.")
  5. words = remove_stop_words(words)
  6. print(f"Removed stop words, {len(words)} remain.")
  7. words = remove_duplicates(words)
  8. print(f"Removed duplicates, {len(words)} remain.")
  9. return words

load_words_raw 只是逐行读取文件,然后将每一行解析成一个词。我们加载了 10 万词(每个词 300 维),而这个 Python 进程就用了近 1GB 的内存!这很糟糕,但可以忍受。

remove_stop_words 会将起始或结尾字符不是字母的词移除,比如「inter-」、「thanks.」、「--redrose64」。现在剩下 98,648 个词。

remove_duplicates 会忽略标点符号,所以「U.K」、「U.K.」和「UK」是一样的词,只存储一次。剩下 97,190 个词。但仍然还有一些重复,比如「years」和「Years」。我们也可以忽略大小写,但这样我们就无法区分「us」(我们)和「US」(美国)这样的词了。

原文链接:https://medium.com/swlh/playing-with-word-vectors-308ab2faa519

本文为机器之心编译,转载请联系本公众号获得授权。返回搜狐,查看更多

责任编辑:

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:http://hongxinsheng.com/bagua/1890.html