4.2 序列
到目前为止,我们已经看到了两种序列对象:字符串和列表。另一种序列被称为元组。元组由逗号操作符构造,而且通常使用括号括起来。实际上,我们已经在前面的章节中看到过它们,它们有时也被称为“配对”,因为总是有两名成员。然而,元组可以有任何数目的成员。与列表和字符串一样,元组可以被索引和切片,并有长度。
>>> t = 'walk', 'fem', 3 ![[1]](/projects/nlp-py-2e-zh/Images/ffa808c97c7034af1bc2806ed7224203.jpg)
>>> t
('walk', 'fem', 3)
>>> t[0] ![[2]](/projects/nlp-py-2e-zh/Images/aa68e0e8f4d58caa31e5542dabe4ddc2.jpg)
'walk'
>>> t[1:] ![[3]](/projects/nlp-py-2e-zh/Images/496754d8cdb6262f8f72e1f066bab359.jpg)
('fem', 3)
>>> len(t) ![[4]](/projects/nlp-py-2e-zh/Images/ca21bcde8ab16a341929b7fb9ccb0a0e.jpg)
3
小心!
元组使用逗号操作符来构造。括号是一个 Python 语法的一般功能,设计用于分组。定义一个包含单个元素'snark'
的元组是通过添加一个尾随的逗号,像这样:”'snark',
“。空元组是一个特殊的情况下,使用空括号()
定义。
让我们直接比较字符串、列表和元组,在各个类型上做索引、切片和长度操作:
>>> raw = 'I turned off the spectroroute'
>>> text = ['I', 'turned', 'off', 'the', 'spectroroute']
>>> pair = (6, 'turned')
>>> raw[2], text[3], pair[1]
('t', 'the', 'turned')
>>> raw[-3:], text[-3:], pair[-3:]
('ute', ['off', 'the', 'spectroroute'], (6, 'turned'))
>>> len(raw), len(text), len(pair)
(29, 5, 2)
请注意在此代码示例中,我们在一行代码中计算多个值,中间用逗号分隔。这些用逗号分隔的表达式其实就是元组——如果没有歧义,Python 允许我们忽略元组周围的括号。当我们输出一个元组时,括号始终显示。通过以这种方式使用元组,我们隐式的将这些项目聚集在一起。
序列类型上的操作
我们可以用多种有用的方式遍历一个序列s
中的项目,如4.1所示。
表 4.1:
遍历序列的各种方式
>>> raw = 'Red lorry, yellow lorry, red lorry, yellow lorry.'
>>> text = word_tokenize(raw)
>>> fdist = nltk.FreqDist(text)
>>> sorted(fdist)
[',', '.', 'Red', 'lorry', 'red', 'yellow']
>>> for key in fdist:
... print(key + ':', fdist[key], end='; ')
...
lorry: 4; red: 1; .: 1; ,: 3; Red: 1; yellow: 2
在接下来的例子中,我们使用元组重新安排我们的列表中的内容。(可以省略括号,因为逗号比赋值的优先级更高。)
>>> words = ['I', 'turned', 'off', 'the', 'spectroroute']
>>> words[2], words[3], words[4] = words[3], words[4], words[2]
>>> words
['I', 'turned', 'the', 'spectroroute', 'off']
这是一种地道和可读的移动列表内的项目的方式。它相当于下面的传统方式不使用元组做上述任务(注意这种方法需要一个临时变量tmp
)。
>>> tmp = words[2]
>>> words[2] = words[3]
>>> words[3] = words[4]
>>> words[4] = tmp
正如我们已经看到的,Python 有序列处理函数,如sorted()
和reversed()
,它们重新排列序列中的项目。也有修改序列结构的函数,可以很方便的处理语言。因此,zip()
接收两个或两个以上的序列中的项目,将它们“压缩”打包成单个的配对列表。给定一个序列s
,enumerate(s)
返回一个包含索引和索引处项目的配对。
>>> words = ['I', 'turned', 'off', 'the', 'spectroroute']
>>> tags = ['noun', 'verb', 'prep', 'det', 'noun']
>>> zip(words, tags)
<zip object at ...>
>>> list(zip(words, tags))
[('I', 'noun'), ('turned', 'verb'), ('off', 'prep'),
('the', 'det'), ('spectroroute', 'noun')]
>>> list(enumerate(words))
[(0, 'I'), (1, 'turned'), (2, 'off'), (3, 'the'), (4, 'spectroroute')]
注意
只在需要的时候进行计算(或者叫做“惰性计算”特性),这是 Python 3 和 NLTK 3 的一个普遍特点。当你期望看到一个序列时,如果你看到的却是类似<zip object at 0x10d005448>
这样的结果, 你可以强制求值这个对象,只要把它放在一个期望序列的上下文中,比如list(
x)
或for item in
x。
对于一些 NLP 任务,有必要将一个序列分割成两个或两个以上的部分。例如,我们可能需要用 90%的数据来“训练”一个系统,剩余 10%进行测试。要做到这一点,我们指定想要分割数据的位置,然后在这个位置分割序列。
>>> text = nltk.corpus.nps_chat.words()
>>> cut = int(0.9 * len(text)) ![[1]](/projects/nlp-py-2e-zh/Images/ffa808c97c7034af1bc2806ed7224203.jpg)
>>> training_data, test_data = text[:cut], text[cut:] ![[2]](/projects/nlp-py-2e-zh/Images/aa68e0e8f4d58caa31e5542dabe4ddc2.jpg)
>>> text == training_data + test_data ![[3]](/projects/nlp-py-2e-zh/Images/496754d8cdb6262f8f72e1f066bab359.jpg)
True
>>> len(training_data) / len(test_data) ![[4]](/projects/nlp-py-2e-zh/Images/ca21bcde8ab16a341929b7fb9ccb0a0e.jpg)
9.0
我们可以验证在此过程中的原始数据没有丢失,也没有重复。我们也可以验证两块大小的比例是我们预期的。
合并不同类型的序列
让我们综合关于这三种类型的序列的知识,一起使用列表推导处理一个字符串中的词,按它们的长度排序。
>>> words = 'I turned off the spectroroute'.split() ![[1]](/projects/nlp-py-2e-zh/Images/ffa808c97c7034af1bc2806ed7224203.jpg)
>>> wordlens = [(len(word), word) for word in words] ![[2]](/projects/nlp-py-2e-zh/Images/aa68e0e8f4d58caa31e5542dabe4ddc2.jpg)
>>> wordlens.sort() ![[3]](/projects/nlp-py-2e-zh/Images/496754d8cdb6262f8f72e1f066bab359.jpg)
>>> ' '.join(w for (_, w) in wordlens) ![[4]](/projects/nlp-py-2e-zh/Images/ca21bcde8ab16a341929b7fb9ccb0a0e.jpg)
'I off the turned spectroroute'
上述代码段中每一行都包含一个显著的特征。一个简单的字符串实际上是一个其上定义了方法如split()
的对象。我们使用列表推导建立一个元组的列表,其中每个元组由一个数字(词长)和这个词组成,例如(3, 'the')
。我们使用sort()
方法就地排序列表。最后,丢弃长度信息,并将这些词连接回一个字符串。(下划线只是一个普通的 Python 变量,我们约定可以用下划线表示我们不会使用其值的变量。)
我们开始谈论这些序列类型的共性,但上面的代码说明了这些序列类型的重要的区别。首先,字符串出现在开头和结尾:这是很典型的,我们的程序先读一些文本,最后产生输出给我们看。列表和元组在中间,但使用的目的不同。一个链表是一个典型的具有相同类型的对象的序列,它的长度是任意的。我们经常使用列表保存词序列。相反,一个元组通常是不同类型的对象的集合,长度固定。我们经常使用一个元组来保存一个纪录,与一些实体相关的不同字段的集合。使用列表与使用元组之间的区别需要一些时间来习惯,所以这里是另一个例子:
>>> lexicon = [
... ('the', 'det', ['Di:', 'D@']),
... ('off', 'prep', ['Qf', 'O:f'])
... ]
在这里,用一个列表表示词典,因为它是一个单一类型的对象的集合——词汇条目——没有预定的长度。个别条目被表示为一个元组,因为它是一个有不同的解释的对象的集合,例如正确的拼写形式、词性、发音(以 SAMPA 计算机可读的拼音字母表示,http://www.phon.ucl.ac.uk/home/sampa/
)。请注意,这些发音都是用列表存储的。(为什么呢?)
注意
决定何时使用元组还是列表的一个好办法是看一个项目的内容是否取决与它的位置。例如,一个已标注的词标识符由两个具有不同解释的字符串组成,我们选择解释第一项为词标识符,第二项为标注。因此,我们使用这样的元组:('grail', 'noun')
;一个形式为('noun', 'grail')
的元组将是无意义的,因为这将是一个词noun
被标注为grail
。相反,一个文本中的元素都是词符, 位置并不重要。因此, 我们使用这样的列表:['venetian', 'blind']
;一个形式为['blind', 'venetian']
的列表也同样有效。词的语言学意义可能会有所不同,但作为词符的列表项的解释是不变的。
列表和元组之间的使用上的区别已经讲过了。然而,还有一个更加基本的区别:在 Python 中,列表是可变的,而元组是不可变的。换句话说,列表可以被修改,而元组不能。这里是一些在列表上的操作,就地修改一个列表。
>>> lexicon.sort()
>>> lexicon[1] = ('turned', 'VBD', ['t3:nd', 't3`nd'])
>>> del lexicon[0]
注意
轮到你来:使用lexicon = tuple(lexicon)
将lexicon
转换为一个元组,然后尝试上述操作,确认它们都不能运用在元组上。
生成器表达式
我们一直在大量使用列表推导,因为用它处理文本结构紧凑和可读性好。下面是一个例子,分词和规范化一个文本:
>>> text = '''"When I use a word," Humpty Dumpty said in rather a scornful tone,
... "it means just what I choose it to mean - neither more nor less."'''
>>> [w.lower() for w in word_tokenize(text)]
['``', 'when', 'i', 'use', 'a', 'word', ',', "''", 'humpty', 'dumpty', 'said', ...]
假设我们现在想要进一步处理这些词。我们可以将上面的表达式插入到一些其他函数的调用中,Python 允许我们省略方括号。
>>> max([w.lower() for w in word_tokenize(text)]) ![[1]](/projects/nlp-py-2e-zh/Images/ffa808c97c7034af1bc2806ed7224203.jpg)
'word'
>>> max(w.lower() for w in word_tokenize(text)) ![[2]](/projects/nlp-py-2e-zh/Images/aa68e0e8f4d58caa31e5542dabe4ddc2.jpg)
'word'
第二行使用了生成器表达式。这不仅仅是标记方便:在许多语言处理的案例中,生成器表达式会更高效。在中,列表对象的存储空间必须在 max()的值被计算之前分配。如果文本非常大的,这将会很慢。在中,数据流向调用它的函数。由于调用的函数只是简单的要找最大值——按字典顺序排在最后的词——它可以处理数据流,而无需存储迄今为止的最大值以外的任何值。