【发布时间】:2012-11-19 06:16:10
【问题描述】:
问题
我一直在尝试不同的方法(在 Python 2.7 中)从语料库或字符串列表中提取(单词、频率)元组列表,并比较它们的效率。据我所知,在未排序列表的正常情况下,collections 模块中的Counter 方法优于我在其他地方提出或发现的任何方法,但它似乎并不需要太多利用预排序列表的优势,我想出了在这种特殊情况下轻松击败它的方法。那么,简而言之,是否有任何内置方法可以通知Counter 列表已经排序以进一步加快速度?
(下一部分是关于 Counter 发挥神奇作用的未排序列表;您可能想跳到最后,在处理排序列表时它会失去魅力。)
未排序的输入列表
一种行不通的方法
天真的方法是使用sorted([(word, corpus.count(word)) for word in set(corpus)]),但是一旦你的语料库有几千个项目长,这种方法就会可靠地让你陷入运行时问题——这并不奇怪,因为你正在遍历 n 个单词的整个列表 m很多次,其中 m 是唯一词的数量。
列表排序+本地搜索
所以我在找到Counter 之前尝试做的是通过首先对输入列表进行排序来确保所有搜索都是严格本地的(我还必须删除数字和标点符号并将所有条目转换为小写以避免'foo'、'Foo' 和 'foo:' 等重复项)。
#Natural Language Toolkit, for access to corpus; any other source for a long text will do, though.
import nltk
# nltk corpora come as a class of their own, as I udnerstand it presenting to the
# outside as a unique list but underlyingly represented as several lists, with no more
# than one ever loaded into memory at any one time, which is good for memory issues
# but rather not so for speed so let's disable this special feature by converting it
# back into a conventional list:
corpus = list(nltk.corpus.gutenberg.words())
import string
drop = string.punctuation+string.digits
def wordcount5(corpus, Case=False, lower=False, StrippedSorted=False):
'''function for extracting word frequencies out of a corpus. Returns an alphabetic list
of tuples consisting of words contained in the corpus with their frequencies.
Default is case-insensitive, but if you need separate entries for upper and lower case
spellings of the same words, set option Case=True. If your input list is already sorted
and stripped of punctuation marks/digits and/or all lower case, you can accelerate the
operation by a factor of 5 or so by declaring so through the options "Sorted" and "lower".'''
# you can ignore the following 6 lines for now, they're only relevant with a pre-processed input list
if lower or Case:
if StrippedSorted:
sortedc = corpus
else:
sortedc = sorted([word.replace('--',' ').strip(drop)
for word in sorted(corpus)])
# here we sort and purge the input list in the default case:
else:
sortedc = sorted([word.lower().replace('--',' ').strip(drop)
for word in sorted(corpus)])
# start iterating over the (sorted) input list:
scindex = 0
# create a list:
freqs = []
# identify the first token:
currentword = sortedc[0]
length = len(sortedc)
while scindex < length:
wordcount = 0
# increment a local counter while the tokens == currentword
while scindex < length and sortedc[scindex] == currentword:
scindex += 1
wordcount += 1
# store the current word and final score when a) a new word appears or
# b) the end of the list is reached
freqs.append((currentword, wordcount))
# if a): update currentword with the current token
if scindex < length:
currentword = sortedc[scindex]
return freqs
找到collections.Counter
这要好得多,但仍然不如使用 collections 模块中的 Counter 类快,后者会创建一个 {word: frequency of word} 条目的字典(我们仍然必须进行相同的剥离和降低,但是没有排序):
from collections import Counter
cnt = Counter()
for word in [token.lower().strip(drop) for token in corpus]:
cnt[word] += 1
# optionally, if we want to have the same output format as before,
# we can do the following which negligibly adds in runtime:
wordfreqs = sorted([(word, cnt[word]) for word in cnt])
在古腾堡语料库与 appr。 200 万个条目,Counter 方法在我的机器上大约快 30%(5 秒而不是 7.2 秒),这主要是通过大约 2.1 秒的排序例程来解释的(如果你没有也不想安装提供对该语料库的访问的 nltk 包(自然语言工具包),任何其他足够长的文本适当地拆分为单词级别的字符串列表都会向您显示相同的内容。)
比较性能
使用我的特殊计时方法,使用重言式作为延迟执行的条件,这为我们提供了计数器方法:
import time
>>> if 1:
... start = time.time()
... cnt = Counter()
... for word in [token.lower().strip(drop) for token in corpus if token not in [" ", ""]]:
... cnt[word] += 1
... time.time()-start
... cntgbfreqs = sorted([(word, cnt[word]) for word in cnt])
... time.time()-start
...
4.999882936477661
5.191655874252319
(我们看到最后一步,将结果格式化为元组列表,占用的时间不到总时间的 5%。)
与我的功能相比:
>>> if 1:
... start = time.time()
... gbfreqs = wordcount5(corpus)
... time.time()-start
...
7.261770963668823
排序的输入列表 - 当Counter'失败'
但是,您可能已经注意到,我的函数允许指定输入已经排序,去除了标点垃圾,并转换为小写。如果我们已经为其他一些操作创建了这样一个列表的转换版本,使用它(并声明)可以大大加快我的wordcount5的操作:
>>> sorted_corpus = sorted([token.lower().strip(drop) for token in corpus if token not in [" ", ""]])
>>> if 1:
... start = time.time()
... strippedgbfreqs2 = wordcount5(sorted_corpus, lower=True, StrippedSorted=True)
... time.time()-start
...
0.9050078392028809
在这里,我们将运行时间减少了大约 1 倍。 8 不必对语料库进行排序和转换项目。当然,后者在为 Counter 提供这个新列表时也是如此,所以可以预期它也会快一点,但它似乎没有利用它已排序的事实,现在它需要两倍于我的函数之前的速度提高了 30%:
>>> if 1:
... start = time.time()
... cnt = Counter()
... for word in sorted_corpus:
... cnt[word] += 1
... time.time()-start
... strippedgbfreqs = [(word, cnt[word])for word in cnt]
... time.time()-start
...
1.9455058574676514
2.0096349716186523
当然,我们可以使用我在wordcount5 中使用的相同逻辑 - 递增本地计数器直到遇到新单词,然后才将最后一个单词与计数器的当前状态一起存储,然后将计数器重置为0 表示下一个单词 - 仅使用 Counter 作为存储,但 Counter 方法的固有效率似乎丢失了,性能在我创建字典的函数范围内,转换为列表的额外负担元组现在看起来比我们处理原始语料库时更麻烦了:
>>> def countertest():
... start = time.time()
... sortedcnt = Counter()
... c = 0
... length = len(sorted_corpus)
... while c < length:
... wcount = 0
... word = sorted_corpus[c]
... while c < length and sorted_corpus[c] == word:
... wcount+=1
... c+=1
... sortedcnt[word] = wcount
... if c < length:
... word = sorted_corpus[c]
... print time.time()-start
... return sorted([(word, sortedcnt[word]) for word in sortedcnt])
... print time.time()-start
...
>>> strippedbgcnt = countertest()
0.920727014542
1.08029007912
(结果的相似性并不令人惊讶,因为我们实际上禁用了Counter 自己的方法,并滥用它作为使用与以前相同的方法获得的值的存储。)
因此,我的问题是:是否有一种更惯用的方式来通知Counter 它的输入列表已经排序,并因此将当前键保留在内存中,而不是每次都重新查找它 - 可以预见 - 遇到同一个词的下一个标记?换句话说,是否有可能通过将Counter/dictionary 类的固有效率与排序列表的明显好处相结合来进一步提高预排序列表的性能,或者我我已经在 0.9 秒的硬限制上计算了 2M 条目的列表?
可能没有很大的改进空间 - 当我做我能想到的最简单的事情时,我得到大约 0.55 秒的时间,这仍然需要遍历同一个列表并检查每个单独的值,并且 0.25对于 set(corpus) 没有计数,但也许有一些 itertools 魔法可以帮助接近这些数字?
(注意:我是 Python 和一般编程的新手,如果我遗漏了一些明显的东西,请原谅。)
12 月 1 日编辑:
除了排序本身之外,另一件事就是将 2M 字符串中的每一个都转换为小写,并去掉它们可能包含的任何标点符号或数字。我之前曾尝试通过计算未处理的字符串来缩短它,然后才转换结果并在添加它们的计数时删除重复项,但我一定做错了什么,因为它使事情变得如此缓慢。因此,我恢复到以前的版本,转换了原始语料库中的所有内容,现在无法完全重建我在那里所做的事情。
如果我现在尝试,我确实从最后转换字符串中得到了改进。我仍然通过遍历(结果)列表来做到这一点。我所做的是编写几个函数,在它们之间将 JF Sebastian 获胜的 default_dict 方法(格式为[("word", int), ("Word", int)], ("word2", int),...])的输出中的键转换为小写并去除标点符号,并折叠所有剩余键的计数该操作后相同(下面的代码)。优点是我们现在正在处理一个大约 50k 条目的列表,而不是语料库中 > 2M 的条目。这样,我现在从语料库(作为列表)到不区分大小写的字数计数(忽略我的机器上的标点符号)的时间为 1.25 秒,低于使用 Counter 方法和字符串转换作为第一步的大约 4.5 秒。但是对于我在sum_sorted() 中所做的事情,也许还有一种基于字典的方法?
代码:
def striplast(resultlist, lower_or_Case=False):
"""function for string conversion of the output of any of the `count_words*` methods"""
if lower_or_Case:
strippedresult = sorted([(entry[0].strip(drop), entry[1]) for entry in resultlist])
else:
strippedresult = sorted([(entry[0].lower().strip(drop), entry[1]) for entry in resultlist])
strippedresult = sum_sorted(strippedresult)
return strippedresult
def sum_sorted(inputlist):
"""function for collapsing the counts of entries left identical by striplast()"""
ilindex = 0
freqs = []
currentword = inputlist[0][0]
length = len(inputlist)
while ilindex < length:
wordcount = 0
while ilindex < length and inputlist[ilindex][0] == currentword:
wordcount += inputlist[ilindex][1]
ilindex += 1
if currentword not in ["", " "]:
freqs.append((currentword, wordcount))
if ilindex < length and inputlist[ilindex][0] > currentword:
currentword = inputlist[ilindex][0]
return freqs
def count_words_defaultdict2(words, loc=False):
"""modified version of J.F. Sebastian's winning method, added a final step collapsing
the counts for words identical except for punctuation and digits and case (for the
latter, unless you specify that you're interested in a case-sensitive count by setting
l(ower_)o(r_)c(ase) to True) by means of striplast()."""
d = defaultdict(int)
for w in words:
d[w] += 1
if col=True:
return striplast(sorted(d.items()), lower_or_case=True)
else:
return striplast(sorted(d.items()))
我第一次尝试使用 groupy 来完成当前由 sum_sorted() 和/或 striplast() 完成的工作,但我不知道如何欺骗它对 [entry[1]] 求和以获得列表count_words' 结果中的条目按 entry[0] 排序。我得到的最接近的是:
# "i(n)p(ut)list", toylist for testing purposes:
list(groupby(sorted([(entry[0].lower().strip(drop), entry[1]) for entry in iplist])))
# returns:
[(('a', 1), <itertools._grouper object at 0x1031bb290>), (('a', 2), <itertools._grouper object at 0x1031bb250>), (('a', 3), <itertools._grouper object at 0x1031bb210>), (('a', 5), <itertools._grouper object at 0x1031bb2d0>), (('a', 8), <itertools._grouper object at 0x1031bb310>), (('b', 3), <itertools._grouper object at 0x1031bb350>), (('b', 7), <itertools._grouper object at 0x1031bb390>)]
# So what I used instead for striplast() is based on list comprehension:
list(sorted([(entry[0].lower().strip(drop), entry[1]) for entry in iplist]))
# returns:
[('a', 1), ('a', 2), ('a', 3), ('a', 5), ('a', 8), ('b', 3), ('b', 7)]
【问题讨论】:
-
FWIW,您的 Edit Dec. 1 部分中有无效和不正确的代码。具体来说,在接近末尾的
count_words_defaultdict2()函数定义中有if col=True:,它既是SyntaxError,我相信你的意思是指参数loc,而不是col。 -
跟进我之前的评论:您可以完全消除最后的
if/else并使用return striplast(sorted(d.items()), lower_or_case=loc)。
标签: python performance python-2.7 counter