Python collections 模組提供多個方便的類別(class)供開發者利用,其中 Counter 類別(dict 的子類別)可以應用在計數(counting)相關的場景:

A Counter is a dict subclass for counting hashable objects.

本文將介紹 Counter 類別的使用方式,並比較其與 dictdefaultdict 之間的效能差異。

本文環境

  • Python 3.7

Counter 類別介紹

一般做統計項目的計數(counting), 我們都會很直覺使用 dict (或稱字典)進行,例如以下利用 dict 統計單字(words)個數、 stopwords 個數、多少句子:

counters = {
  "words": 123,
  "stopwords": 4,
  "sentences": 6,
}

使用 dict 進行統計時,不免都要判斷鍵值(key)是否已經存在於該字典中,如果不存在就指定個預設值,如果已經存在就按照需求增減計數,例如以下形式的程式碼:

if key in counters:
   counters[key] += 1
else:
   counters[key] = 1

除了上述檢查 key 是否存在的方法外,也可用 setdefault() 為不存在的 key 設定預設值,例如以下範例程式碼使用 setdefault() 之後,就不再需要用 if 檢查 key 是否存在:

counters.setdefault(key, 0)
counters[key] += 1

Counter 是在 Python 3.1 之後加入的新類別(class), 可以說集前述做法於一身,並進一步簡化的成果。使用 Counter 後,開發者們不需再檢查 key 是否存在字典中,也不需為 key 值設定預設值(直接預設為 0),因此使用上可以直接簡化為:

from collections import Counter

counters = Counter(
    {
        'words': 123,
        'stopwords': 4,
        'sentences': 6,
    }
)

counters['words'] += 1
counters['new item'] += 1

也由於 Counter 繼承自 dict , 所以也能用與 dict 相同的方式進行走訪,例如:

from collections import Counter

counters = Counter(
    {
        'words': 123,
        'stopwords': 4,
        'sentences': 6,
    }
)

for k, v in counters.items():
    print(k, v)

Counter 類別也有內建一些實用的方法,像 most_common{} 可以幫助我們取的前 n 大的 key 與 value, 並且遞減排序,或者 [例如以下範例取得 counters 中的前 3 大 key 與 value:

>>> counters.most_common(3)  # TOP 3
[('words', 123), ('sentences', 6), ('stopwords', 4)]

Counter 類別接受帶入參數作為初始值,參數可以是 iterable, dict 甚至是 keyword arguments, 例如以下範例,其執行結果都是相同的:

counters = Counters({'a': 1, 'b': 2})

counters = Counter(['a', 'b', 'b'])

counters = Counters('abb')

counters = Counters(a=1, b=2)

Counter 也支援 + - 運算,加法會將 2 個 Counter 中所有的 key 合併,並對相同 key 的值進行加總:

>>> Counter(a=1, b=1) + Counter(a=1, b=1, c=1)
Counter({'a': 2, 'b': 2, 'c': 1})

而減法運算則僅對相同 key 的值進行相減,如果相減後值為 0, 則刪去該 key 值:

>>> Counter(a=3, b=1) - Counter(a=1, b=1, c=1)
Counter({'a': 2})

此外,Counter 也像 set() 一樣支援 | (Bitwise Or)與 & (Bitwise And) 2 種運算。

使用 | (Bitwise Or) 會留下 Counter 中每個 key 的最大值:

>>> Counter('abbb') | Counter('bcc')
Counter({'b': 3, 'c': 2, 'a': 1})

使用 & (Bitwise And) 則是只會留下 Counters 交集(intersection)部分的每個 key 的最小值:

>>> Counter('abbbc') & Counter('bcc')
Counter({'b': 1, 'c': 1})

執行效率比較(Benchmark)

最後比較一下使用 dict , defaultdict , Counter 的執行效率。

以下範例程式分別以 dict , defaultdict , Counter 計算長字串 A2Z1000 (a 到 z 重複 1000 次)中每個字元出現的次數,並用 Jupyter notebook 的 %timeit 衡量其執行時間:

from collections import Counter, defaultdict


A2Z1000 = ''.join(chr(i) for i in range(97, 123)) * 1000


def use_dict_to_count():
    d = dict()
    for c in A2Z1000:
        d.setdefault(c, 0)
        d[c] += 1


def use_default_dict_to_count():
    d = defaultdict(int)
    for c in A2Z1000:
        d[c] += 1


def use_counter_to_count():
    d = Counter()
    for c in A2Z1000:
        d[c] += 1

上述結果可以看到 use_counter_to_count() 比起單用 dict 以及 defaultdict 來的較慢。

但有趣的是當我們單純改用 Counter(A2Z1000) 時,效率反而變得極好!這是為什麼呢?

原來,初始化 Counter 實例(instance)時,如果傳進去的是 iterable, 那麼 Counter 會呼叫一個由 C 實作的 helper function - _count_elements, 該函式除了得益於 C 語言的效率之外,也減少很多 Python 天生物件化操作的耗費(overheads), 所以才會相對較快。

要改善 Counter 的效率的話,建議使用 Counter(iterable) 的方式進行計算之外,也可以呼叫 Counter 所提供的 update() 方法(該方法並不像 dict 與 defaultdict 是對 key 值進行覆蓋,而是加總),只要傳進去 iterable, 該方法也會呼叫 _count_elements 進行計算,因此 use_counter_to_count() 可以進一步改成以下形式,就能夠比 dict 與 defaultdict 更快:

def use_counter_to_count():
    d = Counter()
    d.update(A2Z1000)

以上,就是關於 Counter 類別的介紹啦!

Happy Coding!

References

https://github.com/python/cpython/blob/7acedd71dec4e60400c865911e8961dbb49d5597/Lib/collections/__init__.py#L534

https://docs.python.org/3/library/collections.html#collections.Counter