分类和标注词汇(基于nltk)

2023-11-04

早在小学你就学过名词、动词、形容词和副词之间的差异。这些“词类”不是闲置的文法家的发明,而是对许多语言处理任务都有用的分类。正如我们将看到的,这些分类源于对文本中词的分布的简单的分析。本章的目的是要回答下列问题:

  1. 什么是词汇分类,在自然语言处理中它们是如何使用?
  2. 一个好的存储词汇和它们的分类的 Python 数据结构是什么?
  3. 我们如何自动标注文本中词汇的词类?

一路上,我们将介绍 NLP 的一些基本技术,包括序列标注、N 元模型、回退和评估。这些技术在许多方面都很有用,标注为我们提供了一个表示它们的简单的上下文。我们还将看到,在典型的 NLP 处理流程中,标注为何是位于分词之后的第二个步骤。

将单词按它们的词性分类并进行相应地标注的过程,称为词语性质标注、词性标注或简称标注。词性也称为词类或词汇类别。用于特定任务的标记的集合被称为一个标记集。我们在本章的重点是运用标记和自动标注文本。

1 使用词性标注器

一个词语性质标注器或者词性标注器处理一个单词序列,为每个词附加一个词性标记(不要忘记import nltk):

>>> text = word_tokenize("And now for something completely different")
>>> nltk.pos_tag(text)
[('And', 'CC'), ('now', 'RB'), ('for', 'IN'), ('something', 'NN'),
('completely', 'RB'), ('different', 'JJ')]

在这里我们看到andCC,并列连词;nowcompletelyRB,副词;forIN,介词;somethingNN,名词;differentJJ,形容词。

注意

NLTK 为每个标记提供了文档,可以使用该标记来查询,如nltk.help.upenn_tagset('RB'),或者一个正则表达,如nltk.help.upenn_tagset('NN.*')。一些语料库有标记集文档的 README 文件,见nltk.corpus.???.readme(),用语料库的名称替换。

让我们来看看另一个例子,这次包括一些同形同音异义词:

>>> text = word_tokenize("They refuse to permit us to obtain the refuse permit")
>>> nltk.pos_tag(text)
[('They', 'PRP'), ('refuse', 'VBP'), ('to', 'TO'), ('permit', 'VB'), ('us', 'PRP'),
('to', 'TO'), ('obtain', 'VB'), ('the', 'DT'), ('refuse', 'NN'), ('permit', 'NN')]

请注意refusepermit都以现在时动词(VBP)和名词(NN)形式出现。例如refUSE是一个动词,意为“拒绝”,而REFuse是一个名词,意为“垃圾”(即它们不是同音词)。因此,我们需要知道正在使用哪一个词以便能正确读出文本。(出于这个原因,文本转语音系统通常进行词性标注。)

注意

轮到你来:很多单词,如skirace,可以用作名词或动词而发音没有区别。你能想出其他的吗?提示:想想一个常见的东西,尝试把词to放到它前面,看它是否也是一个动词;或者想想一个动作,尝试把the放在它前面,看它是否也是一个名词。现在用这个词的两种用途造句,在这句话上运行词性标注器。

词汇类别如“名词”和词性标记如NN,看上去似乎有其用途,但在细节上将使许多读者感到晦涩。你可能想知道要引进这种额外的信息的理由是什么。很多这些类别源于对文本中单词分布的粗略分析。考虑下面的分析,涉及woman(名词),bought(动词),over(介词)和the(限定词)。text.similar()方法接收一个单词w,找出所有上下文w[1]w w[2],然后找出所有出现在相同上下文中的词w',即w[1]w'w[2]

>>> text = nltk.Text(word.lower() for word in nltk.corpus.brown.words())
>>> text.similar('woman')
Building word-context index...
man day time year car moment world family house boy child country job
state girl place war way case question
>>> text.similar('bought')
made done put said found had seen given left heard been brought got
set was called felt in that told
>>> text.similar('over')
in on to of and for with from at by that into as up out down through
about all is
>>> text.similar('the')
a his this their its her an that our any all one these my in your no
some other and

可以观察到,搜索woman找到名词;搜索bought找到的大部分是动词;搜索over一般会找到介词;搜索the找到几个限定词。一个标注器能够正确识别一个句子的上下文中的这些词的标记,例如The woman bought over $150,000 worth of clothes

一个标注器还可以为我们对未知词的认识建模,例如我们可以根据词根scrobble猜测scrobbling可能是一个动词,并有可能发生在he was scrobbling这样的上下文中。

2 已经标注的语料库

2.1 表示已经标注的词符

按照 NLTK 的约定,一个已标注的词符使用一个由词符和标记组成的元组来表示。我们可以使用函数str2tuple()从表示一个已标注的词符的标准字符串创建一个这样的特殊元组:

>>> tagged_token = nltk.tag.str2tuple('fly/NN')
>>> tagged_token
('fly', 'NN')
>>> tagged_token[0]
'fly'
>>> tagged_token[1]
'NN'

我们可以直接从一个字符串构造一个已标注的词符的列表。第一步是对字符串分词以便能访问单独的单词/标记字符串,然后将每一个转换成一个元组(使用str2tuple())。

>>> sent = '''
... The/AT grand/JJ jury/NN commented/VBD on/IN a/AT number/NN of/IN
... other/AP topics/NNS ,/, AMONG/IN them/PPO the/AT Atlanta/NP and/CC
... Fulton/NP-tl County/NN-tl purchasing/VBG departments/NNS which/WDT it/PPS
... said/VBD ``/`` ARE/BER well/QL operated/VBN and/CC follow/VB generally/RB
... accepted/VBN practices/NNS which/WDT inure/VB to/IN the/AT best/JJT
... interest/NN of/IN both/ABX governments/NNS ''/'' ./.
... '''
>>> [nltk.tag.str2tuple(t) for t in sent.split()]
[('The', 'AT'), ('grand', 'JJ'), ('jury', 'NN'), ('commented', 'VBD'),
('on', 'IN'), ('a', 'AT'), ('number', 'NN'), ... ('.', '.')]

2.2 读取已标注的语料库

NLTK 中包括的若干语料库已标注了词性。下面是一个你用文本编辑器打开一个布朗语料库的文件就能看到的例子:

The/at Fulton/np-tl County/nn-tl Grand/jj-tl Jury/nn-tl said/vbd Friday/nr an/at investigation/nn of/in Atlanta's/np$ recent/jj primary/nn election/nn produced/vbd / no/at evidence/nn ''/'' that/cs any/dti irregularities/nns took/vbd place/nn ./.

其他语料库使用各种格式存储词性标记。NLTK 的语料库阅读器提供了一个统一的接口,使你不必理会这些不同的文件格式。与刚才提取并显示的上面的文件不同,布朗语料库的语料库阅读器按如下所示的方式表示数据。注意,词性标记已转换为大写的,自从布朗语料库发布以来这已成为标准的做法。

>>> nltk.corpus.brown.tagged_words()
[('The', 'AT'), ('Fulton', 'NP-TL'), ...]
>>> nltk.corpus.brown.tagged_words(tagset='universal')
[('The', 'DET'), ('Fulton', 'NOUN'), ...]

只要语料库包含已标注的文本,NLTK 的语料库接口都将有一个tagged_words()方法。下面是一些例子,再次使用布朗语料库所示的输出格式:

>>> print(nltk.corpus.nps_chat.tagged_words())
[('now', 'RB'), ('im', 'PRP'), ('left', 'VBD'), ...]
>>> nltk.corpus.conll2000.tagged_words()
[('Confidence', 'NN'), ('in', 'IN'), ('the', 'DT'), ...]
>>> nltk.corpus.treebank.tagged_words()
[('Pierre', 'NNP'), ('Vinken', 'NNP'), (',', ','), ...]

并非所有的语料库都采用同一组标记;看前面提到的标记集的帮助函数和readme()方法中的文档。最初,我们想避免这些标记集的复杂化,所以我们使用一个内置的到“通用标记集“的映射:

>>> nltk.corpus.brown.tagged_words(tagset='universal')
[('The', 'DET'), ('Fulton', 'NOUN'), ...]
>>> nltk.corpus.treebank.tagged_words(tagset='universal')
[('Pierre', 'NOUN'), ('Vinken', 'NOUN'), (',', '.'), ...]

NLTK 中还有其他几种语言的已标注语料库,包括中文,印地语,葡萄牙语,西班牙语,荷兰语和加泰罗尼亚语。这些通常含有非 ASCII 文本,当输出较大的结构如列表时,Python 总是以十六进制显示这些。

>>> nltk.corpus.sinica_treebank.tagged_words()
[('ä', 'Neu'), ('åæ', 'Nad'), ('åç', 'Nba'), ...]
>>> nltk.corpus.indian.tagged_words()
[('মহিষের', 'NN'), ('সন্তান', 'NN'), (':', 'SYM'), ...]
>>> nltk.corpus.mac_morpho.tagged_words()
[('Jersei', 'N'), ('atinge', 'V'), ('m\xe9dia', 'N'), ...]
>>> nltk.corpus.conll2002.tagged_words()
[('Sao', 'NC'), ('Paulo', 'VMI'), ('(', 'Fpa'), ...]
>>> nltk.corpus.cess_cat.tagged_words()
[('El', 'da0ms0'), ('Tribunal_Suprem', 'np0000o'), ...]

如果你的环境设置正确,有适合的编辑器和字体,你应该能够以人可读的方式显示单个字符串。例如,2.1 显示的使用nltk.corpus.indian访问的数据。

图 2.1:四种印度语言的词性标注数据:孟加拉语、印地语、马拉地语和泰卢固语

如果语料库也被分割成句子,将有一个tagged_sents()方法将已标注的词划分成句子,而不是将它们表示成一个大列表。在我们开始开发自动标注器时,这将是有益的,因为它们在句子列表上被训练和测试,而不是词。

2.3 通用词性标记集

已标注的语料库使用许多不同的标记集约定来标注词汇。为了帮助我们开始,我们将看一看一个简化的标记集(2.1 中所示)。

表 2.1:

通用词性标记集

>>> from nltk.corpus import brown
>>> brown_news_tagged = brown.tagged_words(categories='news', tagset='universal')
>>> tag_fd = nltk.FreqDist(tag for (word, tag) in brown_news_tagged)
>>> tag_fd.most_common()
[('NOUN', 30640), ('VERB', 14399), ('ADP', 12355), ('.', 11928), ('DET', 11389),
 ('ADJ', 6706), ('ADV', 3349), ('CONJ', 2717), ('PRON', 2535), ('PRT', 2264),
 ('NUM', 2166), ('X', 106)]

注意

轮到你来:使用tag_fd.plot(cumulative=True)为上面显示的频率分布绘图。标注为上述列表中的前五个标记的词的百分比是多少?

我们可以使用这些标记做强大的搜索,结合一个图形化的词性索引工具nltk.app.concordance()。用它来寻找任一单词和词性标记的组合,如N N N Nhit/VDhit/VN或者the ADJ man

2.4 名词

名词一般指的是人、地点、事情或概念,例如: woman, Scotland, book, intelligence。名词可能出现在限定词和形容词之后,可以是动词的主语或宾语,如 2.2 所示。

表 2.2:

一些名词的句法模式

>>> word_tag_pairs = nltk.bigrams(brown_news_tagged)
>>> noun_preceders = [a[1] for (a, b) in word_tag_pairs if b[1] == 'NOUN']
>>> fdist = nltk.FreqDist(noun_preceders)
>>> [tag for (tag, _) in fdist.most_common()]
['NOUN', 'DET', 'ADJ', 'ADP', '.', 'VERB', 'CONJ', 'NUM', 'ADV', 'PRT', 'PRON', 'X']

这证实了我们的断言,名词出现在限定词和形容词之后,包括数字形容词(数词,标注为NUM)。

2.5 动词

动词是用来描述事件和行动的词,例如 2.3 中的fall, eat。在一个句子中,动词通常表示涉及一个或多个名词短语所指示物的关系。

表 2.3:

一些动词的句法模式

>>> wsj = nltk.corpus.treebank.tagged_words(tagset='universal')
>>> word_tag_fd = nltk.FreqDist(wsj)
>>> [wt[0] for (wt, _) in word_tag_fd.most_common() if wt[1] == 'VERB']
['is', 'said', 'are', 'was', 'be', 'has', 'have', 'will', 'says', 'would',
 'were', 'had', 'been', 'could', "'s", 'can', 'do', 'say', 'make', 'may',
 'did', 'rose', 'made', 'does', 'expected', 'buy', 'take', 'get', 'might',
 'sell', 'added', 'sold', 'help', 'including', 'should', 'reported', ...]

请注意,频率分布中计算的项目是词-标记对。由于词汇和标记是成对的,我们可以把词作作为条件,标记作为事件,使用条件-事件对的链表初始化一个条件频率分布。这让我们看到了一个给定的词的标记的频率顺序列表:

>>> cfd1 = nltk.ConditionalFreqDist(wsj)
>>> cfd1['yield'].most_common()
[('VERB', 28), ('NOUN', 20)]
>>> cfd1['cut'].most_common()
[('VERB', 25), ('NOUN', 3)]

我们可以颠倒配对的顺序,这样标记作为条件,词汇作为事件。现在我们可以看到对于一个给定的标记可能的词。我们将用《华尔街日报 》的标记集而不是通用的标记集来这样做:

>>> wsj = nltk.corpus.treebank.tagged_words()
>>> cfd2 = nltk.ConditionalFreqDist((tag, word) for (word, tag) in wsj)
>>> list(cfd2['VBN'])
['been', 'expected', 'made', 'compared', 'based', 'priced', 'used', 'sold',
'named', 'designed', 'held', 'fined', 'taken', 'paid', 'traded', 'said', ...]

要弄清VBD(过去式)和VBN(过去分词)之间的区别,让我们找到可以同是VBDVBN的词汇,看看一些它们周围的文字:

>>> [w for w in cfd1.conditions() if 'VBD' in cfd1[w] and 'VBN' in cfd1[w]]
['Asked', 'accelerated', 'accepted', 'accused', 'acquired', 'added', 'adopted', ...]
>>> idx1 = wsj.index(('kicked', 'VBD'))
>>> wsj[idx1-4:idx1+1]
[('While', 'IN'), ('program', 'NN'), ('trades', 'NNS'), ('swiftly', 'RB'),
 ('kicked', 'VBD')]
>>> idx2 = wsj.index(('kicked', 'VBN'))
>>> wsj[idx2-4:idx2+1]
[('head', 'NN'), ('of', 'IN'), ('state', 'NN'), ('has', 'VBZ'), ('kicked', 'VBN')]

在这种情况下,我们可以看到过去分词kicked前面是助动词have的形式。这是普遍真实的吗?

注意

轮到你来:通过list(cfd2['VN'])指定一个过去分词的列表,尝试收集所有直接在列表中项目前面的词-标记对。

2.6 形容词和副词

另外两个重要的词类是形容词和副词。形容词修饰名词,可以作为修饰语(如the large pizza中的large),或者谓语(如the pizza is large)。英语形容词可以有内部结构(如the falling stocks中的fall+ing)。副词修饰动词,指定动词描述的事件的时间、方式、地点或方向(如the stocks fell quickly中的quickly)。副词也可以修饰的形容词(如Mary's teacher was really nice中的really)。

英语中还有几个封闭的词类,如介词,冠词(也常称为限定词)(如the, a),情态动词(如should, may)和人称代词(如she, they)。每个词典和语法对这些词的分类都不同。

注意

轮到你来:如果你对这些词性中的一些不确定,使用nltk.app.concordance()学习它们,或在 YouTube 看《Schoolhouse Rock!》语法视频,或者查询本章结束的进一步阅读一节。

2.7 未简化的标记

让我们找出每个名词类型中最频繁的名词。2.2 中的程序找出所有以NN开始的标记,并为每个标记提供了几个示例单词。你会看到有许多NN的变种;此外,大多数的标记都有后缀修饰符:-NC表示引用,-HL表示标题中的词,-TL表示标题(布朗标记的特征)。

def findtags(tag_prefix, tagged_text):
    cfd = nltk.ConditionalFreqDist((tag, word) for (word, tag) in tagged_text
                                  if tag.startswith(tag_prefix))
    return dict((tag, cfd[tag].most_common(5)) for tag in cfd.conditions())

>>> tagdict = findtags('NN', nltk.corpus.brown.tagged_words(categories='news'))
>>> for tag in sorted(tagdict):
...     print(tag, tagdict[tag])
...
NN [('year', 137), ('time', 97), ('state', 88), ('week', 85), ('man', 72)]
NN$ [("year's", 13), ("world's", 8), ("state's", 7), ("nation's", 6), ("company's", 6)]
NN$-HL [("Golf's", 1), ("Navy's", 1)]
NN$-TL [("President's", 11), ("Army's", 3), ("Gallery's", 3), ("University's", 3), ("League's", 3)]
NN-HL [('sp.', 2), ('problem', 2), ('Question', 2), ('business', 2), ('Salary', 2)]
NN-NC [('eva', 1), ('aya', 1), ('ova', 1)]
NN-TL [('President', 88), ('House', 68), ('State', 59), ('University', 42), ('City', 41)]
NN-TL-HL [('Fort', 2), ('Dr.', 1), ('Oak', 1), ('Street', 1), ('Basin', 1)]
NNS [('years', 101), ('members', 69), ('people', 52), ('sales', 51), ('men', 46)]
NNS$ [("children's", 7), ("women's", 5), ("janitors'", 3), ("men's", 3), ("taxpayers'", 2)]
NNS$-HL [("Dealers'", 1), ("Idols'", 1)]
NNS$-TL [("Women's", 4), ("States'", 3), ("Giants'", 2), ("Bros.'", 1), ("Writers'", 1)]
NNS-HL [('comments', 1), ('Offenses', 1), ('Sacrifices', 1), ('funds', 1), ('Results', 1)]
NNS-TL [('States', 38), ('Nations', 11), ('Masters', 10), ('Rules', 9), ('Communists', 9)]
NNS-TL-HL [('Nations', 1)]

当我们开始在本章后续部分创建词性标注器时,我们将使用未简化的标记。

2.8 探索已标注的语料库

让我们简要地回过来探索语料库,我们在前面的章节中看到过,这次我们探索词性标记。

假设我们正在研究词often,想看看它是如何在文本中使用的。我们可以试着看看跟在often后面的词汇

>>> brown_learned_text = brown.words(categories='learned')
>>> sorted(set(b for (a, b) in nltk.bigrams(brown_learned_text) if a == 'often'))
[',', '.', 'accomplished', 'analytically', 'appear', 'apt', 'associated', 'assuming',
'became', 'become', 'been', 'began', 'call', 'called', 'carefully', 'chose', ...]

然而,使用tagged_words()方法查看跟随词的词性标记可能更有指导性:

>>> brown_lrnd_tagged = brown.tagged_words(categories='learned', tagset='universal')
>>> tags = [b[1] for (a, b) in nltk.bigrams(brown_lrnd_tagged) if a[0] == 'often']
>>> fd = nltk.FreqDist(tags)
>>> fd.tabulate()
 PRT  ADV  ADP    . VERB  ADJ
 2    8    7    4   37    6

请注意often后面最高频率的词性是动词。名词从来没有在这个位置出现(在这个特别的语料中)。

接下来,让我们看一些较大范围的上下文,找出涉及特定标记和词序列的词(在这种情况下,">Verb> to >Verb>")。在code-three-word-phrase中,我们考虑句子中的每个三词窗口❶,检查它们是否符合我们的标准❷。如果标记匹配,我们输出对应的词❸。

from nltk.corpus import brown
def process(sentence):
    for (w1,t1), (w2,t2), (w3,t3) in nltk.trigrams(sentence): ❶
        if (t1.startswith('V') and t2 == 'TO' and t3.startswith('V')): ❷
            print(w1, w2, w3) ❸

>>> for tagged_sent in brown.tagged_sents():
...     process(tagged_sent)
...
combined to achieve
continue to place
serve to protect
wanted to wait
allowed to place
expected to become
...

最后,让我们看看与它们的标记关系高度模糊不清的词。了解为什么要标注这样的词是因为它们各自的上下文可以帮助我们弄清楚标记之间的区别。

>>> brown_news_tagged = brown.tagged_words(categories='news', tagset='universal')
>>> data = nltk.ConditionalFreqDist((word.lower(), tag)
...                                 for (word, tag) in brown_news_tagged)
>>> for word in sorted(data.conditions()):
...     if len(data[word]) > 3:
...         tags = [tag for (tag, _) in data[word].most_common()]
...         print(word, ' '.join(tags))
...
best ADJ ADV NP V
better ADJ ADV V DET
close ADV ADJ V N
cut V N VN VD
even ADV DET ADJ V
grant NP N V -
hit V VD VN N
lay ADJ V NP VD
left VD ADJ N VN
like CNJ V ADJ P -
near P ADV ADJ DET
open ADJ V N ADV
past N ADJ DET P
present ADJ ADV V N
read V VN VD NP
right ADJ N DET ADV
second NUM ADV DET N
set VN V VD N -
that CNJ V WH DET

注意

轮到你来:打开词性索引工具nltk.app.concordance()并加载完整的布朗语料库(简化标记集)。现在挑选一些上面代码例子末尾处列出的词,看看词的标记如何与词的上下文相关。例如搜索near会看到所有混合在一起的形式,搜索near/ADJ会看到它作为形容词使用,near N会看到只是名词跟在后面的情况,等等。更多的例子,请修改附带的代码,以便它列出的词具有三个不同的标签。

3 使用 Python 字典映射单词到其属性

正如我们已经看到,(word, tag)形式的一个已标注词是词和词性标记的关联。一旦我们开始做词性标注,我们将会创建分配一个标记给一个词的程序,标记是在给定上下文中最可能的标记。我们可以认为这个过程是从词到标记的映射。在 Python 中最自然的方式存储映射是使用所谓的字典数据类型(在其他的编程语言又称为关联数组或哈希数组)。在本节中,我们来看看字典,看它如何能表示包括词性在内的各种不同的语言信息。

3.1 索引列表 VS 字典

我们已经看到,文本在 Python 中被视为一个词列表。链表的一个重要的属性是我们可以通过给出其索引来“看”特定项目,例如text1[100]。请注意我们如何指定一个数字,然后取回一个词。我们可以把链表看作一种简单的表格,如 3.1 所示。

图 3.1:列表查找:一个整数索引帮助我们访问 Python 列表的内容。

对比这种情况与频率分布(3),在那里我们指定一个词然后取回一个数字,如fdist['monstrous'],它告诉我们一个给定的词在文本中出现的次数。用词查询对任何使用过字典的人都很熟悉。3.2 展示一些更多的例子。

图 3.2:字典查询:我们使用一个关键字,如某人的名字、一个域名或一个英文单词,访问一个字典的条目;字典的其他名字有映射、哈希表、哈希和关联数组。

在电话簿中,我们用名字查找一个条目得到一个数字。当我们在浏览器中输入一个域名,计算机查找它得到一个 IP 地址。一个词频表允许我们查一个词找出它在一个文本集合中的频率。在所有这些情况中,我们都是从名称映射到数字,而不是其他如列表那样的方式。总之,我们希望能够在任意类型的信息之间映射。3.1 列出了各种语言学对象以及它们的映射。

表 3.1:

语言学对象从键到值的映射

>>> pos = {}
>>> pos
{}
>>> pos['colorless'] = 'ADJ' ❶
>>> pos
{'colorless': 'ADJ'}
>>> pos['ideas'] = 'N'
>>> pos['sleep'] = 'V'
>>> pos['furiously'] = 'ADV'
>>> pos ❷
{'furiously': 'ADV', 'ideas': 'N', 'colorless': 'ADJ', 'sleep': 'V'}

所以,例如,❶说的是colorless的词性是形容词,或者更具体地说:在字典pos中,键'colorless'被分配了值'ADJ'。当我们检查pos的值时❷,我们看到一个键-值对的集合。一旦我们以这样的方式填充了字典,就可以使用键来检索值:

>>> pos['ideas']
'N'
>>> pos['colorless']
'ADJ'

当然,我们可能会无意中使用一个尚未分配值的键。

>>> pos['green']
Traceback (most recent call last):
 File "<stdin>", line 1, in ?
KeyError: 'green'

这就提出了一个重要的问题。与列表和字符串不同,我们可以用len()算出哪些整数是合法索引,我们如何算出一个字典的合法键?如果字典不是太大,我们可以简单地通过查看变量pos检查它的内容。正如在前面(❷行)所看到,这为我们提供了键-值对。请注意它们的顺序与最初放入它们的顺序不同;这是因为字典不是序列而是映射(参见 3.2),键没有固定地排序。

换种方式,要找到键,我们可以将字典转换成一个列表❶——要么在期望列表的上下文中使用字典,如作为sorted()的参数❷,要么在for循环中❸。

>>> list(pos) ❶
['ideas', 'furiously', 'colorless', 'sleep']
>>> sorted(pos) ❷
['colorless', 'furiously', 'ideas', 'sleep']
>>> [w for w in pos if w.endswith('s')] ❸
['colorless', 'ideas']

注意

当你输入list(pos)时,你看到的可能会与这里显示的顺序不同。如果你想看到有序的键,只需要对它们进行排序。

与使用一个for循环遍历字典中的所有键一样,我们可以使用for循环输出列表:

>>> for word in sorted(pos):
...     print(word + ":", pos[word])
...
colorless: ADJ
furiously: ADV
sleep: V
ideas: N

最后,字典的方法keys()values()items()允许我们以单独的列表访问键、值以及键-值对。我们甚至可以排序元组❶,按它们的第一个元素排序(如果第一个元素相同,就使用它们的第二个元素)。

>>> list(pos.keys())
['colorless', 'furiously', 'sleep', 'ideas']
>>> list(pos.values())
['ADJ', 'ADV', 'V', 'N']
>>> list(pos.items())
[('colorless', 'ADJ'), ('furiously', 'ADV'), ('sleep', 'V'), ('ideas', 'N')]
>>> for key, val in sorted(pos.items()): ❶
...     print(key + ":", val)
...
colorless: ADJ
furiously: ADV
ideas: N
sleep: V

我们要确保当我们在字典中查找某词时,一个键只得到一个值。现在假设我们试图用字典来存储可同时作为动词和名词的词sleep

>>> pos['sleep'] = 'V'
>>> pos['sleep']
'V'
>>> pos['sleep'] = 'N'
>>> pos['sleep']
'N'

最初,pos['sleep']给的值是'V'。但是,它立即被一个新值'N'覆盖。换句话说,字典中只能有'sleep'的一个条目。然而,有一个方法可以在该项目中存储多个值:我们使用一个列表值,例如pos['sleep'] = ['N', 'V']。事实上,这就是我们在 4 中看到的 CMU 发音字典,它为一个词存储多个发音。

3.3 定义字典

我们可以使用键-值对格式创建字典。有两种方式做这个,我们通常会使用第一个:

>>> pos = {'colorless': 'ADJ', 'ideas': 'N', 'sleep': 'V', 'furiously': 'ADV'}
>>> pos = dict(colorless='ADJ', ideas='N', sleep='V', furiously='ADV')

请注意,字典的键必须是不可改变的类型,如字符串和元组。如果我们尝试使用可变键定义字典会得到一个TypeError

>>> pos = {['ideas', 'blogs', 'adventures']: 'N'}
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
TypeError: list objects are unhashable

3.4 默认字典

如果我们试图访问一个不在字典中的键,会得到一个错误。然而,如果一个字典能为这个新键自动创建一个条目并给它一个默认值,如 0 或者一个空链表,将是有用的。由于这个原因,可以使用一种特殊的称为defaultdict的字典。为了使用它,我们必须提供一个参数,用来创建默认值,如intfloatstrlistdicttuple

>>> from collections import defaultdict
>>> frequency = defaultdict(int)
>>> frequency['colorless'] = 4
>>> frequency['ideas']
0
>>> pos = defaultdict(list)
>>> pos['sleep'] = ['NOUN', 'VERB']
>>> pos['ideas']
[]

注意

这些默认值实际上是将其他对象转换为指定类型的函数(例如int("2")list("2"))。当它们不带参数被调用时——int()list()——它们分别返回0[] 。

前面的例子中指定字典项的默认值为一个特定的数据类型的默认值。然而,也可以指定任何我们喜欢的默认值,只要提供可以无参数的被调用产生所需值的函数的名子。让我们回到我们的词性的例子,创建一个任一条目的默认值是'N'的字典❶。当我们访问一个不存在的条目时❷,它会自动添加到字典❸。

>>> pos = defaultdict(lambda: 'NOUN') ❶
>>> pos['colorless'] = 'ADJ'
>>> pos['blog'] ❷
'NOUN'
>>> list(pos.items())
[('blog', 'NOUN'), ('colorless', 'ADJ')] # [_automatically-added]

注意

上面的例子使用一个 lambda 表达式,在 4.4 介绍过。这个 lambda 表达式没有指定参数,所以我们用不带参数的括号调用它。因此,下面的fg的定义是等价的:

>>> f = lambda: 'NOUN'
>>> f()
'NOUN'
>>> def g():
...     return 'NOUN'
>>> g()
'NOUN'

让我们来看看默认字典如何被应用在较大规模的语言处理任务中。许多语言处理任务——包括标注——费很大力气来正确处理文本中只出现过一次的词。如果有一个固定的词汇和没有新词会出现的保证,它们会有更好的表现。在一个默认字典的帮助下,我们可以预处理一个文本,替换低频词汇为一个特殊的“超出词汇表”词符UNK。(你能不看下面的想出如何做吗?)

我们需要创建一个默认字典,映射每个词为它们的替换词。最频繁的n个词将被映射到它们自己。其他的被映射到UNK

>>> alice = nltk.corpus.gutenberg.words('carroll-alice.txt')
>>> vocab = nltk.FreqDist(alice)
>>> v1000 = [word for (word, _) in vocab.most_common(1000)]
>>> mapping = defaultdict(lambda: 'UNK')
>>> for v in v1000:
...     mapping[v] = v
...
>>> alice2 = [mapping[v] for v in alice]
>>> alice2[:100]
['UNK', 'Alice', "'", 's', 'UNK', 'in', 'UNK', 'by', 'UNK', 'UNK', 'UNK',
'UNK', 'CHAPTER', 'I', '.', 'UNK', 'the', 'Rabbit', '-', 'UNK', 'Alice',
'was', 'beginning', 'to', 'get', 'very', 'tired', 'of', 'sitting', 'by',
'her', 'sister', 'on', 'the', 'UNK', ',', 'and', 'of', 'having', 'nothing',
'to', 'do', ':', 'once', 'or', 'twice', 'she', 'had', 'UNK', 'into', 'the',
'book', 'her', 'sister', 'was', 'UNK', ',', 'but', 'it', 'had', 'no',
'pictures', 'or', 'UNK', 'in', 'it', ',', "'", 'and', 'what', 'is', 'the',
'use', 'of', 'a', 'book', ",'", 'thought', 'Alice', "'", 'without',
'pictures', 'or', 'conversation', "?'" ...]
>>> len(set(alice2))
1001

3.5 递增地更新字典

我们可以使用字典计数出现的次数,模拟 fig-tally 所示的计数词汇的方法。首先初始化一个空的defaultdict,然后处理文本中每个词性标记。如果标记以前没有见过,就默认计数为零。每次我们遇到一个标记,就使用+=运算符递增它的计数。

>>> from collections import defaultdict
>>> counts = defaultdict(int)
>>> from nltk.corpus import brown
>>> for (word, tag) in brown.tagged_words(categories='news', tagset='universal'):
...     counts[tag] += 1
...
>>> counts['NOUN']
30640
>>> sorted(counts)
['ADJ', 'PRT', 'ADV', 'X', 'CONJ', 'PRON', 'VERB', '.', 'NUM', 'NOUN', 'ADP', 'DET']

>>> from operator import itemgetter
>>> sorted(counts.items(), key=itemgetter(1), reverse=True)
[('NOUN', 30640), ('VERB', 14399), ('ADP', 12355), ('.', 11928), ...]
>>> [t for t, c in sorted(counts.items(), key=itemgetter(1), reverse=True)]
['NOUN', 'VERB', 'ADP', '.', 'DET', 'ADJ', 'ADV', 'CONJ', 'PRON', 'PRT', 'NUM', 'X']

3.3 中的列表演示了一个重要的按值排序一个字典的习惯用法,来按频率递减顺序显示词汇。sorted()的第一个参数是要排序的项目,它是由一个词性标记和一个频率组成的元组的列表。第二个参数使用函数itemgetter()指定排序的键。在一般情况下,itemgetter(n)返回一个函数,这个函数可以在一些其他序列对象上被调用获得这个序列的第n个元素,例如:

>>> pair = ('NP', 8336)
>>> pair[1]
8336
>>> itemgetter(1)(pair)
8336

sorted()的最后一个参数指定项目是否应被按相反的顺序返回,即频率值递减。

在 3.3 的开头还有第二个有用的习惯用法,那里我们初始化一个defaultdict,然后使用for循环来更新其值。下面是一个示意版本:

>>> my_dictionary = defaultdict(_function to create default value_)
>>> for item in sequence:
... # my_dictionary[item_key] is updated with information about item

下面是这种模式的另一个示例,我们按它们最后两个字母索引词汇:

>>> last_letters = defaultdict(list)
>>> words = nltk.corpus.words.words('en')
>>> for word in words:
...     key = word[-2:]
...     last_letters[key].append(word)
...
>>> last_letters['ly']
['abactinally', 'abandonedly', 'abasedly', 'abashedly', 'abashlessly', 'abbreviately',
'abdominally', 'abhorrently', 'abidingly', 'abiogenetically', 'abiologically', ...]
>>> last_letters['zy']
['blazy', 'bleezy', 'blowzy', 'boozy', 'breezy', 'bronzy', 'buzzy', 'Chazy', ...]

下面的例子使用相同的模式创建一个颠倒顺序的词字典。(你可能会试验第 3 行来弄清楚为什么这个程序能运行。)

>>> anagrams = defaultdict(list)
>>> for word in words:
...     key = ''.join(sorted(word))
...     anagrams[key].append(word)
...
>>> anagrams['aeilnrt']
['entrail', 'latrine', 'ratline', 'reliant', 'retinal', 'trenail']

由于积累这样的词是如此常用的任务,NLTK 提供一个创建defaultdict(list)更方便的方式,形式为nltk.Index()

>>> anagrams = nltk.Index((''.join(sorted(w)), w) for w in words)
>>> anagrams['aeilnrt']
['entrail', 'latrine', 'ratline', 'reliant', 'retinal', 'trenail']

注意

nltk.Index是一个支持额外初始化的defaultdict(list)。类似地,nltk.FreqDist本质上是一个额外支持初始化的defaultdict(int)(附带排序和绘图方法)。

3.6 复杂的键和值

我们可以使用具有复杂的键和值的默认字典。让我们研究一个词可能的标记的范围,给定词本身和它前一个词的标记。我们将看到这些信息如何被一个词性标注器使用。

>>> pos = defaultdict(lambda: defaultdict(int))
>>> brown_news_tagged = brown.tagged_words(categories='news', tagset='universal')
>>> for ((w1, t1), (w2, t2)) in nltk.bigrams(brown_news_tagged): ❶
...     pos[(t1, w2)][t2] += 1 ❷
...
>>> pos[('DET', 'right')] ❸
defaultdict(<class 'int'>, {'ADJ': 11, 'NOUN': 5}

这个例子使用一个字典,它的条目的默认值也是一个字典(其默认值是int(),即 0)。请注意我们如何遍历已标注语料库的双连词,每次遍历处理一个词-标记对❶。每次通过循环时,我们更新字典pos中的条目(t1, w2),一个标记和它后面的词❷。当我们在pos中查找一个项目时,我们必须指定一个复合键❸,然后得到一个字典对象。一个词性标注器可以使用这些信息来决定词right,前面是一个限定词时,应标注为ADJ

3.7 反转字典

字典支持高效查找,只要你想获得任意键的值。如果d是一个字典,k是一个键,输入d[k],就立即获得值。给定一个值查找对应的键要慢一些和麻烦一些:

>>> counts = defaultdict(int)
>>> for word in nltk.corpus.gutenberg.words('milton-paradise.txt'):
...     counts[word] += 1
...
>>> [key for (key, value) in counts.items() if value == 32]
['brought', 'Him', 'virtue', 'Against', 'There', 'thine', 'King', 'mortal',
'every', 'been']

如果我们希望经常做这样的一种“反向查找”,建立一个映射值到键的字典是有用的。在没有两个键具有相同的值情况,这是一个容易的事。只要得到字典中的所有键-值对,并创建一个新的值-键对字典。下一个例子演示了用键-值对初始化字典pos的另一种方式。

>>> pos = {'colorless': 'ADJ', 'ideas': 'N', 'sleep': 'V', 'furiously': 'ADV'}
>>> pos2 = dict((value, key) for (key, value) in pos.items())
>>> pos2['N']
'ideas'

首先让我们将我们的词性字典做的更实用些,使用字典的update()方法加入再一些词到pos中,创建多个键具有相同的值的情况。这样一来,刚才看到的反向查找技术就将不再起作用(为什么不?)作为替代,我们不得不使用append()积累词和每个词性,如下所示:

>>> pos.update({'cats': 'N', 'scratch': 'V', 'peacefully': 'ADV', 'old': 'ADJ'})
>>> pos2 = defaultdict(list)
>>> for key, value in pos.items():
...     pos2[value].append(key)
...
>>> pos2['ADV']
['peacefully', 'furiously']

现在,我们已经反转字典pos,可以查任意词性找到所有具有此词性的词。可以使用 NLTK 中的索引支持更容易的做同样的事,如下所示:

>>> pos2 = nltk.Index((value, key) for (key, value) in pos.items())
>>> pos2['ADV']
['peacefully', 'furiously']

3.2 给出 Python 字典方法的总结。

表 3.2:

Python 字典方法:常用的方法与字典相关习惯用法的总结。

>>> from nltk.corpus import brown
>>> brown_tagged_sents = brown.tagged_sents(categories='news')
>>> brown_sents = brown.sents(categories='news')

4.1 默认标注器

最简单的标注器是为每个词符分配同样的标记。这似乎是一个相当平庸的一步,但它建立了标注器性能的一个重要的底线。为了得到最好的效果,我们用最有可能的标记标注每个词。让我们找出哪个标记是最有可能的(现在使用未简化标记集):

>>> tags = [tag for (word, tag) in brown.tagged_words(categories='news')]
>>> nltk.FreqDist(tags).max()
'NN'

现在我们可以创建一个将所有词都标注成NN的标注器。

>>> raw = 'I do not like green eggs and ham, I do not like them Sam I am!'
>>> tokens = word_tokenize(raw)
>>> default_tagger = nltk.DefaultTagger('NN')
>>> default_tagger.tag(tokens)
[('I', 'NN'), ('do', 'NN'), ('not', 'NN'), ('like', 'NN'), ('green', 'NN'),
('eggs', 'NN'), ('and', 'NN'), ('ham', 'NN'), (',', 'NN'), ('I', 'NN'),
('do', 'NN'), ('not', 'NN'), ('like', 'NN'), ('them', 'NN'), ('Sam', 'NN'),
('I', 'NN'), ('am', 'NN'), ('!', 'NN')]

不出所料,这种方法的表现相当不好。在一个典型的语料库中,它只标注正确了八分之一的标识符,正如我们在这里看到的:

>>> default_tagger.evaluate(brown_tagged_sents)
0.13089484257215028

默认的标注器给每一个单独的词分配标记,即使是之前从未遇到过的词。碰巧的是,一旦我们处理了几千词的英文文本之后,大多数新词都将是名词。正如我们将看到的,这意味着,默认标注器可以帮助我们提高语言处理系统的稳定性。我们将很快回来讲述这个。

4.2 正则表达式标注器

正则表达式标注器基于匹配模式分配标记给词符。例如,我们可能会猜测任一以ed结尾的词都是动词过去分词,任一以's结尾的词都是名词所有格。可以用一个正则表达式的列表表示这些:

>>> patterns = [
...     (r'.*ing$', 'VBG'),               # gerunds
...     (r'.*ed$', 'VBD'),                # simple past
...     (r'.*es$', 'VBZ'),                # 3rd singular present
...     (r'.*ould$', 'MD'),               # modals
...     (r'.*\'s$', 'NN$'),               # possessive nouns
...     (r'.*s$', 'NNS'),                 # plural nouns
...     (r'^-?[0-9]+(.[0-9]+)?$', 'CD'),  # cardinal numbers
...     (r'.*', 'NN')                     # nouns (default)
... ]

请注意,这些是顺序处理的,第一个匹配上的会被使用。现在我们可以建立一个标注器,并用它来标记一个句子。做完这一步会有约五分之一是正确的。

>>> regexp_tagger = nltk.RegexpTagger(patterns)
>>> regexp_tagger.tag(brown_sents[3])
[('``', 'NN'), ('Only', 'NN'), ('a', 'NN'), ('relative', 'NN'), ('handful', 'NN'),
('of', 'NN'), ('such', 'NN'), ('reports', 'NNS'), ('was', 'NNS'), ('received', 'VBD'),
("''", 'NN'), (',', 'NN'), ('the', 'NN'), ('jury', 'NN'), ('said', 'NN'), (',', 'NN'),
('``', 'NN'), ('considering', 'VBG'), ('the', 'NN'), ('widespread', 'NN'), ...]
>>> regexp_tagger.evaluate(brown_tagged_sents)
0.20326391789486245

最终的正则表达式.*是一个全面捕捉的,标注所有词为名词。这与默认标注器是等效的(只是效率低得多)。除了作为正则表达式标注器的一部分重新指定这个,有没有办法结合这个标注器和默认标注器呢?我们将很快看到如何做到这一点。

注意

轮到你来:看看你能不能想出一些模式,提高上面所示的正则表达式标注器的性能。(请注意 1 描述部分自动化这类工作的方法。)

4.3 查询标注器

很多高频词没有NN标记。让我们找出 100 个最频繁的词,存储它们最有可能的标记。然后我们可以使用这个信息作为“查找标注器”(NLTK UnigramTagger)的模型:

>>> fd = nltk.FreqDist(brown.words(categories='news'))
>>> cfd = nltk.ConditionalFreqDist(brown.tagged_words(categories='news'))
>>> most_freq_words = fd.most_common(100)
>>> likely_tags = dict((word, cfd[word].max()) for (word, _) in most_freq_words)
>>> baseline_tagger = nltk.UnigramTagger(model=likely_tags)
>>> baseline_tagger.evaluate(brown_tagged_sents)
0.45578495136941344

现在应该并不奇怪,仅仅知道 100 个最频繁的词的标记就使我们能正确标注很大一部分词符(近一半,事实上)。让我们来看看它在一些未标注的输入文本上做的如何:

>>> sent = brown.sents(categories='news')[3]
>>> baseline_tagger.tag(sent)
[('``', '``'), ('Only', None), ('a', 'AT'), ('relative', None),
('handful', None), ('of', 'IN'), ('such', None), ('reports', None),
('was', 'BEDZ'), ('received', None), ("''", "''"), (',', ','),
('the', 'AT'), ('jury', None), ('said', 'VBD'), (',', ','),
('``', '``'), ('considering', None), ('the', 'AT'), ('widespread', None),
('interest', None), ('in', 'IN'), ('the', 'AT'), ('election', None),
(',', ','), ('the', 'AT'), ('number', None), ('of', 'IN'),
('voters', None), ('and', 'CC'), ('the', 'AT'), ('size', None),
('of', 'IN'), ('this', 'DT'), ('city', None), ("''", "''"), ('.', '.')]

许多词都被分配了一个None标签,因为它们不在 100 个最频繁的词之中。在这些情况下,我们想分配默认标记NN。换句话说,我们要先使用查找表,如果它不能指定一个标记就使用默认标注器,这个过程叫做回退(5)。我们可以做到这个,通过指定一个标注器作为另一个标注器的参数,如下所示。现在查找标注器将只存储名词以外的词的词-标记对,只要它不能给一个词分配标记,它将会调用默认标注器。

>>> baseline_tagger = nltk.UnigramTagger(model=likely_tags,
...                                      backoff=nltk.DefaultTagger('NN'))

让我们把所有这些放在一起,写一个程序来创建和评估具有一定范围的查找标注器 ,4.1。

def performance(cfd, wordlist):
    lt = dict((word, cfd[word].max()) for word in wordlist)
    baseline_tagger = nltk.UnigramTagger(model=lt, backoff=nltk.DefaultTagger('NN'))
    return baseline_tagger.evaluate(brown.tagged_sents(categories='news'))

def display():
    import pylab
    word_freqs = nltk.FreqDist(brown.words(categories='news')).most_common()
    words_by_freq = [w for (w, _) in word_freqs]
    cfd = nltk.ConditionalFreqDist(brown.tagged_words(categories='news'))
    sizes = 2 ** pylab.arange(15)
    perfs = [performance(cfd, words_by_freq[:size]) for size in sizes]
    pylab.plot(sizes, perfs, '-bo')
    pylab.title('Lookup Tagger Performance with Varying Model Size')
    pylab.xlabel('Model Size')
    pylab.ylabel('Performance')
    pylab.show()

图 4.2:查找标注器

可以观察到,随着模型规模的增长,最初的性能增加迅速,最终达到一个稳定水平,这时模型的规模大量增加性能的提高很小。(这个例子使用pylab绘图软件包,在 4.8 讨论过)。

4.4 评估

在前面的例子中,你会注意到对准确性得分的强调。事实上,评估这些工具的表现是 NLP 的一个中心主题。回想 fig-sds 中的处理流程;一个模块输出中的任何错误都在下游模块大大的放大。

我们对比人类专家分配的标记来评估一个标注器的表现。由于我们通常很难获得专业和公正的人的判断,所以使用黄金标准测试数据来代替。这是一个已经手动标注并作为自动系统评估标准而被接受的语料库。当标注器对给定词猜测的标记与黄金标准标记相同,标注器被视为是正确的。

当然,设计和实施原始的黄金标准标注的也是人。更深入的分析可能会显示黄金标准中的错误,或者可能最终会导致一个修正的标记集和更复杂的指导方针。然而,黄金标准就目前有关的自动标注器的评估而言被定义成“正确的”。

注意

开发一个已标注语料库是一个重大的任务。除了数据,它会产生复杂的工具、文档和实践,为确保高品质的标注。标记集和其他编码方案不可避免地依赖于一些理论主张,不是所有的理论主张都被共享,然而,语料库的创作者往往竭尽全力使他们的工作尽可能理论中立,以最大限度地提高其工作的有效性。我们将在 11 讨论创建一个语料库的挑战。

5 N 元标注

5.1 一元标注

一元标注器基于一个简单的统计算法:对每个标识符分配这个独特的标识符最有可能的标记。例如,它将分配标记JJ给词frequent的所有出现,因为frequent用作一个形容词(例如a frequent word)比用作一个动词(例如I frequent this cafe)更常见。一个一元标注器的行为就像一个查找标注器(4),除了有一个更方便的建立它的技术,称为训练。在下面的代码例子中,我们训练一个一元标注器,用它来标注一个句子,然后评估:

>>> from nltk.corpus import brown
>>> brown_tagged_sents = brown.tagged_sents(categories='news')
>>> brown_sents = brown.sents(categories='news')
>>> unigram_tagger = nltk.UnigramTagger(brown_tagged_sents)
>>> unigram_tagger.tag(brown_sents[2007])
[('Various', 'JJ'), ('of', 'IN'), ('the', 'AT'), ('apartments', 'NNS'),
('are', 'BER'), ('of', 'IN'), ('the', 'AT'), ('terrace', 'NN'), ('type', 'NN'),
(',', ','), ('being', 'BEG'), ('on', 'IN'), ('the', 'AT'), ('ground', 'NN'),
('floor', 'NN'), ('so', 'QL'), ('that', 'CS'), ('entrance', 'NN'), ('is', 'BEZ'),
('direct', 'JJ'), ('.', '.')]
>>> unigram_tagger.evaluate(brown_tagged_sents)
0.9349006503968017

我们训练一个UnigramTagger,通过在我们初始化标注器时指定已标注的句子数据作为参数。训练过程中涉及检查每个词的标记,将所有词的最可能的标记存储在一个字典里面,这个字典存储在标注器内部。

5.2 分离训练和测试数据

现在,我们正在一些数据上训练一个标注器,必须小心不要在相同的数据上测试,如我们在前面的例子中的那样。一个只是记忆它的训练数据,而不试图建立一个一般的模型的标注器会得到一个完美的得分,但在标注新的文本时将是无用的。相反,我们应该分割数据,90% 为测试数据,其余 10% 为测试数据:

>>> size = int(len(brown_tagged_sents) * 0.9)
>>> size
4160
>>> train_sents = brown_tagged_sents[:size]
>>> test_sents = brown_tagged_sents[size:]
>>> unigram_tagger = nltk.UnigramTagger(train_sents)
>>> unigram_tagger.evaluate(test_sents)
0.811721...

虽然得分更糟糕了,但是现在我们对这种标注器的用处有了更好的了解,如它在之前没有遇见的文本上的表现。

5.3 一般的 N 元标注

在基于一元处理一个语言处理任务时,我们使用上下文中的一个项目。标注的时候,我们只考虑当前的词符,与更大的上下文隔离。给定一个模型,我们能做的最好的是为每个词标注其先验的最可能的标记。这意味着我们将使用相同的标记标注一个词,如wind,不论它出现的上下文是the wind还是to wind

一个 N 元标注器是一个一元标注器的一般化,它的上下文是当前词和它前面n - 1个标识符的词性标记,如图 5.1 所示。要选择的标记是圆圈里的t[n],灰色阴影的是上下文。在 5.1 所示的 N 元标注器的例子中,我们让n = 3;也就是说,我们考虑当前词的前两个词的标记。一个 N 元标注器挑选在给定的上下文中最有可能的标记。

图 5.1:标注器上下文

注意

1-gram 标注器是一元标注器另一个名称:即用于标注一个词符的上下文的只是词符本身。2-gram 标注器也称为二元标注器,3-gram 标注器也称为三元标注器

NgramTagger类使用一个已标注的训练语料库来确定对每个上下文哪个词性标记最有可能。这里我们看 N 元标注器的一个特殊情况,二元标注器。首先,我们训练它,然后用它来标注未标注的句子:

>>> bigram_tagger = nltk.BigramTagger(train_sents)
>>> bigram_tagger.tag(brown_sents[2007])
[('Various', 'JJ'), ('of', 'IN'), ('the', 'AT'), ('apartments', 'NNS'),
('are', 'BER'), ('of', 'IN'), ('the', 'AT'), ('terrace', 'NN'),
('type', 'NN'), (',', ','), ('being', 'BEG'), ('on', 'IN'), ('the', 'AT'),
('ground', 'NN'), ('floor', 'NN'), ('so', 'CS'), ('that', 'CS'),
('entrance', 'NN'), ('is', 'BEZ'), ('direct', 'JJ'), ('.', '.')]
>>> unseen_sent = brown_sents[4203]
>>> bigram_tagger.tag(unseen_sent)
[('The', 'AT'), ('population', 'NN'), ('of', 'IN'), ('the', 'AT'), ('Congo', 'NP'),
('is', 'BEZ'), ('13.5', None), ('million', None), (',', None), ('divided', None),
('into', None), ('at', None), ('least', None), ('seven', None), ('major', None),
('``', None), ('culture', None), ('clusters', None), ("''", None), ('and', None),
('innumerable', None), ('tribes', None), ('speaking', None), ('400', None),
('separate', None), ('dialects', None), ('.', None)]

请注意,二元标注器能够标注训练中它看到过的句子中的所有词,但对一个没见过的句子表现很差。只要遇到一个新词(如 13.5),就无法给它分配标记。它不能标注下面的词(如million),即使是在训练过程中看到过的,只是因为在训练过程中从来没有见过它前面有一个None标记的词。因此,标注器标注句子的其余部分也失败了。它的整体准确度得分非常低:

>>> bigram_tagger.evaluate(test_sents)
0.102063...

n越大,上下文的特异性就会增加,我们要标注的数据中包含训练数据中不存在的上下文的几率也增大。这被称为数据稀疏问题,在 NLP 中是相当普遍的。因此,我们的研究结果的精度和覆盖范围之间需要有一个权衡(这与信息检索中的精度/召回权衡有关)。

小心!

N 元标注器不应考虑跨越句子边界的上下文。因此,NLTK 的标注器被设计用于句子列表,其中一个句子是一个词列表。在一个句子的开始,t[n - 1]和前面的标记被设置为None

5.4 组合标注器

解决精度和覆盖范围之间的权衡的一个办法是尽可能的使用更精确的算法,但却在很多时候落后于具有更广覆盖范围的算法。例如,我们可以按如下方式组合二元标注器、一元注器和一个默认标注器,如下:

  1. 尝试使用二元标注器标注标识符。
  2. 如果二元标注器无法找到一个标记,尝试一元标注器。
  3. 如果一元标注器也无法找到一个标记,使用默认标注器。

大多数 NLTK 标注器允许指定一个回退标注器。回退标注器自身可能也有一个回退标注器:

>>> t0 = nltk.DefaultTagger('NN')
>>> t1 = nltk.UnigramTagger(train_sents, backoff=t0)
>>> t2 = nltk.BigramTagger(train_sents, backoff=t1)
>>> t2.evaluate(test_sents)
0.844513...

注意

轮到你来:通过定义一个名为t3TrigramTagger,扩展前面的例子,它是t2的回退标注器。

请注意,我们在标注器初始化时指定回退标注器,从而使训练能利用回退标注器。于是,在一个特定的上下文中,如果二元标注器将分配与它的一元回退标注器一样的标记,那么二元标注器丢弃训练的实例。这样保持尽可能小的二元标注器模型。我们可以进一步指定一个标注器需要看到一个上下文的多个实例才能保留它,例如nltk.BigramTagger(sents, cutoff=2, backoff=t1)将会丢弃那些只看到一次或两次的上下文。

5.5 标注生词

我们标注生词的方法仍然是回退到一个正则表达式标注器或一个默认标注器。这些都无法利用上下文。因此,如果我们的标注器遇到词blog,训练过程中没有看到过,它会分配相同的标记,不论这个词出现的上下文是the blog还是to blog。我们怎样才能更好地处理这些生词,或词汇表以外的项目?

一个有用的基于上下文标注生词的方法是限制一个标注器的词汇表为最频繁的n个词,使用 3 中的方法替代每个其他的词为一个特殊的词 UNK。训练时,一个一元标注器可能会学到 UNK 通常是一个名词。然而,N 元标注器会检测它的一些其他标记中的上下文。例如,如果前面的词是to(标注为TO),那么 UNK 可能会被标注为一个动词。

5.6 存储标注器

在大语料库上训练一个标注器可能需要大量的时间。没有必要在每次我们需要的时候训练一个标注器,很容易将一个训练好的标注器保存到一个文件以后重复使用。让我们保存我们的标注器t2到文件t2.pkl

>>> from pickle import dump
>>> output = open('t2.pkl', 'wb')
>>> dump(t2, output, -1)
>>> output.close()

现在,我们可以在一个单独的 Python 进程中,我们可以载入保存的标注器。

>>> from pickle import load
>>> input = open('t2.pkl', 'rb')
>>> tagger = load(input)
>>> input.close()

现在让我们检查它是否可以用来标注。

>>> text = """The board's action shows what free enterprise
...     is up against in our complex maze of regulatory laws ."""
>>> tokens = text.split()
>>> tagger.tag(tokens)
[('The', 'AT'), ("board's", 'NN$'), ('action', 'NN'), ('shows', 'NNS'),
('what', 'WDT'), ('free', 'JJ'), ('enterprise', 'NN'), ('is', 'BEZ'),
('up', 'RP'), ('against', 'IN'), ('in', 'IN'), ('our', 'PP$'), ('complex', 'JJ'),
('maze', 'NN'), ('of', 'IN'), ('regulatory', 'NN'), ('laws', 'NNS'), ('.', '.')]

5.7 准确性的极限

一个 N 元标注器准确性的上限是什么?考虑一个三元标注器的情况。它遇到多少词性歧义的情况?我们可以根据经验决定这个问题的答案:

>>> cfd = nltk.ConditionalFreqDist(
...            ((x[1], y[1], z[0]), z[1])
...            for sent in brown_tagged_sents
...            for x, y, z in nltk.trigrams(sent))
>>> ambiguous_contexts = [c for c in cfd.conditions() if len(cfd[c]) > 1]
>>> sum(cfd[c].N() for c in ambiguous_contexts) / cfd.N()
0.049297702068029296

因此,1/20的三元是有歧义的示例。给定当前单词及其前两个标记,根据训练数据,在 5% 的情况中,有一个以上的标记可能合理地分配给当前词。假设我们总是挑选在这种含糊不清的上下文中最有可能的标记,可以得出三元标注器准确性的一个下界。

调查标注器准确性的另一种方法是研究它的错误。有些标记可能会比别的更难分配,可能需要专门对这些数据进行预处理或后处理。一个方便的方式查看标注错误是混淆矩阵。它用图表表示期望的标记(黄金标准)与实际由标注器产生的标记:

>>> test_tags = [tag for sent in brown.sents(categories='editorial')
...                  for (word, tag) in t2.tag(sent)]
>>> gold_tags = [tag for (word, tag) in brown.tagged_words(categories='editorial')]
>>> print(nltk.ConfusionMatrix(gold_tags, test_tags))           

基于这样的分析,我们可能会决定修改标记集。或许标记之间很难做出的区分可以被丢弃,因为它在一些较大的处理任务的上下文中并不重要。

分析标注器准确性界限的另一种方式来自人类标注者之间并非 100% 的意见一致。

一般情况下,标注过程会损坏区别:例如当所有的人称代词被标注为PRP时,词的特性通常会失去。与此同时,标注过程引入了新的区别从而去除了含糊之处:例如deal标注为VBNN。这种消除某些区别并引入新的区别的特点是标注的一个重要的特征,有利于分类和预测。当我们引入一个标记集的更细的划分时,在 N 元标注器决定什么样的标记分配给一个特定的词时,可以获得关于左侧上下文的更详细的信息。然而,标注器同时也将需要做更多的工作来划分当前的词符,只是因为有更多可供选择的标记。相反,使用较少的区别(如简化的标记集),标注器有关上下文的信息会减少,为当前词符分类的选择范围也较小。

我们已经看到,训练数据中的歧义导致标注器准确性的上限。有时更多的上下文能解决这些歧义。然而,在其他情况下,如(Church, Young, & Bloothooft, 1996)中指出的,只有参考语法或现实世界的知识,才能解决歧义。尽管有这些缺陷,词性标注在用统计方法进行自然语言处理的兴起过程中起到了核心作用。1990 年代初,统计标注器令人惊讶的精度是一个惊人的示范,可以不用更深的语言学知识解决一小部分语言理解问题,即词性消歧。这个想法能再推进吗?第 7 中,我们将看到,它可以。

6 基于转换的标注

N 元标注器的一个潜在的问题是它们的 N 元表(或语言模型)的大小。如果使用各种语言技术的标注器部署在移动计算设备上,在模型大小和标注器准确性之间取得平衡是很重要的。使用回退标注器的 N 元标注器可能存储三元和二元表,这是很大的稀疏阵列,可能有数亿条条目。

第二个问题是关于上下文。N 元标注器从前面的上下文中获得的唯一的信息是标记,虽然词本身可能是一个有用的信息源。N 元模型使用上下文中的词的其他特征为条件是不切实际的。在本节中,我们考察 Brill 标注,一种归纳标注方法,它的性能很好,使用的模型只有 N 元标注器的很小一部分。

Brill 标注是一种基于转换的学习,以它的发明者命名。一般的想法很简单:猜每个词的标记,然后返回和修复错误。在这种方式中,Brill 标注器陆续将一个不良标注的文本转换成一个更好的。与 N 元标注一样,这是有监督的学习方法,因为我们需要已标注的训练数据来评估标注器的猜测是否是一个错误。然而,不像 N 元标注,它不计数观察结果,只编制一个转换修正规则列表。

Brill 标注的的过程通常是与绘画类比来解释的。假设我们要画一棵树,包括大树枝、树枝、小枝、叶子和一个统一的天蓝色背景的所有细节。不是先画树然后尝试在空白处画蓝色,而是简单的将整个画布画成蓝色,然后通过在蓝色背景上上色“修正”树的部分。以同样的方式,我们可能会画一个统一的褐色的树干再回过头来用更精细的刷子画进一步的细节。Brill 标注使用了同样的想法:以大笔画开始,然后修复细节,一点点的细致的改变。让我们看看下面的例子:

>>> nltk.tag.brill.demo()
Training Brill tagger on 80 sentences...
Finding initial useful rules...
    Found 6555 useful rules.

           B      |
   S   F   r   O  |        Score = Fixed - Broken
   c   i   o   t  |  R     Fixed = num tags changed incorrect -> correct
   o   x   k   h  |  u     Broken = num tags changed correct -> incorrect
   r   e   e   e  |  l     Other = num tags changed incorrect -> incorrect
   e   d   n   r  |  e
------------------+-------------------------------------------------------
  12  13   1   4  | NN -> VB if the tag of the preceding word is 'TO'
   8   9   1  23  | NN -> VBD if the tag of the following word is 'DT'
   8   8   0   9  | NN -> VBD if the tag of the preceding word is 'NNS'
   6   9   3  16  | NN -> NNP if the tag of words i-2...i-1 is '-NONE-'
   5   8   3   6  | NN -> NNP if the tag of the following word is 'NNP'
   5   6   1   0  | NN -> NNP if the text of words i-2...i-1 is 'like'
   5   5   0   3  | NN -> VBN if the text of the following word is '*-1'
 ...
>>> print(open("errors.out").read())
             left context |    word/test->gold     | right context
--------------------------+------------------------+--------------------------
                          |      Then/NN->RB       | ,/, in/IN the/DT guests/N
, in/IN the/DT guests/NNS |       '/VBD->POS       | honor/NN ,/, the/DT speed
'/POS honor/NN ,/, the/DT |    speedway/JJ->NN     | hauled/VBD out/RP four/CD
NN ,/, the/DT speedway/NN |     hauled/NN->VBD     | out/RP four/CD drivers/NN
DT speedway/NN hauled/VBD |      out/NNP->RP       | four/CD drivers/NNS ,/, c
dway/NN hauled/VBD out/RP |      four/NNP->CD      | drivers/NNS ,/, crews/NNS
hauled/VBD out/RP four/CD |    drivers/NNP->NNS    | ,/, crews/NNS and/CC even
P four/CD drivers/NNS ,/, |     crews/NN->NNS      | and/CC even/RB the/DT off
NNS and/CC even/RB the/DT |    official/NNP->JJ    | Indianapolis/NNP 500/CD a
                          |     After/VBD->IN      | the/DT race/NN ,/, Fortun
ter/IN the/DT race/NN ,/, |    Fortune/IN->NNP     | 500/CD executives/NNS dro
s/NNS drooled/VBD like/IN |  schoolboys/NNP->NNS   | over/IN the/DT cars/NNS a
olboys/NNS over/IN the/DT |      cars/NN->NNS      | and/CC drivers/NNS ./.

7 如何确定一个词的分类

我们已经详细研究了词类,现在转向一个更基本的问题:我们如何首先决定一个词属于哪一类?在一般情况下,语言学家使用形态学、句法和语义线索确定一个词的类别。

7.1 形态学线索

一个词的内部结构可能为这个词分类提供有用的线索。举例来说:-ness是一个后缀,与形容词结合产生一个名词,如happy → happinessill → illness。如果我们遇到的一个以-ness结尾的词,很可能是一个名词。同样的,-ment是与一些动词结合产生一个名词的后缀,如govern → governmentestablish → establishment

英语动词也可以是形态复杂的。例如,一个动词的现在分词以-ing结尾,表示正在进行的还没有结束的行动(如falling, eating)。-ing后缀也出现在从动词派生的名词中,如the falling of the leaves(这被称为动名词)。

7.2 句法线索

另一个信息来源是一个词可能出现的典型的上下文语境。例如,假设我们已经确定了名词类。那么我们可以说,英语形容词的句法标准是它可以立即出现在一个名词前,或紧跟在词bevery后。根据这些测试,near应该被归类为形容词:

Statement  User117 Dude..., I wanted some of that
ynQuestion User120 m I missing something?
Bye        User117 I'm gonna go fix food, I'll be back later.
System     User122 JOIN
System     User2   slaps User122 around a bit with a large trout.
Statement  User121 18/m pm me if u tryin to chat

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

分类和标注词汇(基于nltk) 的相关文章

随机推荐

  • 解决nodejs版本问题

    下载nvm 1 1 7 管理nodejs版本 下一步进行nodejs 安装 这里注意本人使用nvm1 1 9出现错误发现1 1 7较为好用 nvm use 选择使用版本 npm v 测试nodejs安装是否成功
  • React控制元素显示隐藏的三种方法

    React控制元素显示和隐藏的方法目前我知道的有三种方法 第一种是通过state变量来控制是否渲染元素 类似vue中的v if 第二种是通过style控制display属性 类似vue 中的v show 第三种是通过动态切换classNam
  • S11、反射系数、回波损耗

    S11是反射系数中的一种 以dB为单位的S11就是回波损耗 S11 Pr Pin S11等于反射功率除以入射功率 是功率比 将S11转换成以dB为单位 10 lgS11 系数 S11 dB 就是回波损耗 HFSS里S11就是这样表示的 ma
  • 问题记录整理(持续更新)

    文章目录 Java SSL SpringBoot Springboot项目配置jar外部静态文件 Swagger JPA SpringCloud Gateway 不能使用 web MVC框架 Feign 每个参数需要指定PathVariab
  • Vue之mitt事务总线使用方法

    npm install S mitt 1 组件总线 vue入口文件main js中挂载全局属性 导入vue创建命令 import createApp from vue 导入vue入口组件 import App from App vue 导入
  • CMake can not determine linker language for target: Intro

    cmake minimum required VERSION 3 9 project Intro C set CMAKE CXX STANDARD 11 add executable Intro main cpp 原来是一个C语言的项目 本
  • Css如何优雅的实现抽奖转盘

    如图 抽奖转盘 可以拆分为几部分 1 底部大圆 2 中间小圆 3 扇形区 4 扇形内部奖品区 5 抽奖按钮 6 点击抽奖按钮时旋转动效及逻辑 这其中 扇形区 以及扇形区内奖品的布局最为关键和麻烦 这个问题解决 剩下的问题 那都不是事儿 那如
  • 10种电脑无法启动故障

    开机自检时出现问题后会出现各种各样的英文短句 短句中包含了非常重要的信息 读懂这些信息可以自己解决一些小问题 可是这些英文难倒了一部分朋友 下面是一些常见的BIOS短句的解释 是我在修电脑时 常出现的短句 大家可以参考一下 1 CMOS b
  • Spring基础知识讲解

    文章目录 Spring是什么 IoC容器与DI DI与IoC的区别 Spring项目的创建 配置maven国内源 创建Spring项目 有关Bean的操作 存储Bean 使用Bean ApplicationContext和BeanFacto
  • mmsegmentation中可视化

    目录 1 tools train py tools dist train sh 2 tools test py 指定输出到 pkl 文件 3 分析日志 绘制分类损失 日志分析 4 Flops Params fps的实现 fps的实现 too
  • 关于实现类按多属性排序的问题(compareTo()函数应用)

    class tongji char zi 字符 int num 出现次数 如上这个类 按照统计个数由多到少输出统计结果 如果统计的个数相同 则按照ASII码由小到大排序输出 1 num大的靠前 2 num 相同情况下 判断字符的ASII码
  • kafka总结概述

    kafka概述 消息中间件对比 特性 ActiveMQ RabbitMQ RocketMQ Kafka 开发语言 java erlang java scala 单机吞吐量 万级 万级 10万级 100万级 时效性 ms us ms ms级以
  • 谷歌开源芯片 180 纳米制造工艺

    谷歌一直在开源芯片方面发力 从跟 SkyWater Technology 的合作开始 SkyWater 为谷歌的开源芯片计划提供了 130nm 工艺的芯片设计 随后把工艺推进到 90nm 制造 为该开源芯片计划加了不少含金量 谷歌与Glob
  • leetcode 347:前K个高频词

    使用python的dict存储 value记录数量 再排序 最后输出 class Solution object def topKFrequent self nums k type nums List int type k int rtyp
  • ORA-12505: TNS: 监听程序当前无法识别连接描述符中所给出的SID等错误解决方法

    当遇到ORA 12505 TNS 监听程序当前无法识别连接描述符中所给出的SID等错误需要特别检查如下配置 1 检查数据库客户端 ORACLEHOME client 1 NETWORK ADMIN目录中的tnsnames ora文件看客户端
  • 如何自学Python爬虫? 零基础入门教程

    如何自学Python爬虫 在大家自学爬虫之前要解决两个常见的问题 一是爬虫到底是什么 二是问什么要用Python来做爬虫 爬虫其实就是自动抓取页面信息的网络机器人 至于用Python做爬虫的原因 当然还是为了方便 本文将为大家提供一份详细的
  • Docker笔记1 基础概念和镜像

    基础概念 开发环境deepin linux 15 11 安装docker之后 执行命令 sudo usermod aG docker USER NAME 让docker和当前用户在一个组中 可以每次不用执行sudo命令了 三个核心的概念 镜
  • 【电路补习笔记】10、电感式开关电源(BUCK 降压电路)

    目录 分类 封装 焊线式 覆晶式 开关电源 三种基本的非隔离开关电源 纹波 ripple 开关电源的元件构成 有源开关 肖特基二极管或快恢复二极管 不使用普通硅二极管的原因 电感 电容 分压电阻 输出电压设置 工作原理 工作模式 连续模式
  • laya快捷键

    Ctrl F6 构建html Ctrl F7 实时编译 Ctrl F8 实时编译 F11 UI导出 不导出图集 图集不变的情况用这个会快很多
  • 分类和标注词汇(基于nltk)

    早在小学你就学过名词 动词 形容词和副词之间的差异 这些 词类 不是闲置的文法家的发明 而是对许多语言处理任务都有用的分类 正如我们将看到的 这些分类源于对文本中词的分布的简单的分析 本章的目的是要回答下列问题 什么是词汇分类 在自然语言处