Topic 4.10
The collections Toolbox
In one line
The standard library module collections adds specialised containers: Counter for counting, defaultdict for grouping, deque for fast work at both ends, namedtuple for records, OrderedDict for order-aware dictionaries and ChainMap for layered lookups.
Think of it like this
A kitchen drawer of special tools. A knife does everything badly; a peeler, a whisk and a grater each do one job very well. The built-in list, dict and set are the knives. The collections module is the drawer of specialised tools you reach for when the job is counting, grouping, queueing, or layering settings. To take a tool out of the drawer you write a line like from collections import Counter, which means "fetch the tool named Counter from the toolbox named collections". Topic 6.1 teaches import in full; for now copy that first line as it is.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Counter
- A special kind of dictionary for counting items. Its keys must be hashable (Topic 4.5).
- defaultdict
- A dictionary that makes a default value for a missing key by calling a factory such as
listorint. - deque
- A double-ended queue: fast to add or remove at either end.
- Queue (FIFO)
- First in, first out, like people waiting in a line.
- namedtuple
- A tuple whose positions also have names.
- OrderedDict
- A dictionary with
move_to_endand order-sensitive equality. - ChainMap
- A view over several dictionaries that searches them in order.
- Factory
- A callable (like
list) that creates a new default value when needed.
Step by step
01Counter: count in one line
Counter takes any iterable (anything you can loop over, such as a string or a list). most_common(2) gives the two highest as (item, count) pairs, with ties in the order the items were first seen. A missing item counts as zero.
from collections import Counter
counts = Counter("banana")
print(counts) # Counter({'a': 3, 'n': 2, 'b': 1})
print(counts.most_common(2)) # [('a', 3), ('n', 2)]
print(counts["z"]) # 002defaultdict: group without the ceremony
Group the words by their length. For a new length, the factory list is called and returns []; then we append. Compare with setdefault (Topic 4.4): the same result, less noise.
from collections import defaultdict
by_length = defaultdict(list)
for word in ["pen", "book", "bag", "desk"]:
by_length[len(word)].append(word)
print(dict(by_length)) # {3: ['pen', 'bag'], 4: ['book', 'desk']}03deque: both ends, and a fixed window
With maxlen, appending to a full deque pushes the oldest item out the other end. popleft is the constant-time way to take from the front, which a list does slowly with pop(0).
from collections import deque
recent = deque(maxlen=3)
for n in [1, 2, 3, 4]:
recent.append(n)
print(recent) # deque([2, 3, 4], maxlen=3)
line = deque(["Asha", "Ravi"])
line.appendleft("VIP")
print(line.popleft()) # VIP04namedtuple: a record with names
Named tuples cost the same as tuples but read better. _asdict() converts one to a dictionary; _replace makes a changed copy, because the original cannot change.
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(p.x, p[1], p)
print(p._replace(x=10)) # a NEW Point(x=10, y=4)05OrderedDict: reorder and strict equality
move_to_end is the building block of an "oldest first" cache. A plain dict says two dicts with the same pairs are equal in any order; an OrderedDict does not.
from collections import OrderedDict
d = OrderedDict([("a", 1), ("b", 2), ("c", 3)])
d.move_to_end("a")
print(list(d)) # ['b', 'c', 'a']
print(OrderedDict(a=1, b=2) == OrderedDict(b=2, a=1)) # False06ChainMap: layered lookups
The three dictionaries are not merged; the ChainMap just asks them in order. Changing a layer later is visible through the ChainMap.
from collections import ChainMap
cli = {"color": "red"}
env = {"color": "blue", "size": 12}
defaults = {"color": "black", "size": 10, "lang": "en"}
settings = ChainMap(cli, env, defaults)
print(settings["color"], settings["size"], settings["lang"]) # red 12 enTry it yourself
- 1
Count your own text
In the first example, replace the sentence with a line you choose. Which word wins? What does
most_common()with no number return? - 2
Slide the window
In the second example, change
maxlen=3tomaxlen=2and predict the output of "last three" before you run it.
Code & diagrams
Replace the sentence with your own and see which word is most common.
Expected output
counts: Counter({'to': 3, 'be': 3, 'or': 1, 'not': 1, 'that': 1, 'is': 1, 'the': 1, 'question': 1})
top 2: [('to', 3), ('be', 3)]
'to' appears: 3 | 'zebra' appears: 0
after update: 2
letters: Counter({'a': 3, 'n': 2, 'b': 1})
by length: {3: ['pen', 'bag', 'ink'], 4: ['book', 'desk']}
tally: {'m': 1, 'i': 4, 's': 4, 'p': 2}Expected output
last three: deque([3, 4, 5], maxlen=3)
first in line: VIP | left: ['Asha', 'Ravi']
rotated: ['Ravi', 'Asha']
point: Point(x=3, y=4) | x: 3 | as dict: {'x': 3, 'y': 4}
moved: Point(x=10, y=4)
reordered: ['b', 'c', 'a']
settings: red 12 en
cli changed later: 99A tiny version of what real services do with logs and analytics: count what happened, group it by user, and keep only the latest few events.
Expected output
actions: [('login', 3), ('search', 3), ('logout', 1)]
per user: {'asha': ['login', 'search', 'logout'], 'ravi': ['login', 'search', 'search'], 'meena': ['login']}
last three events: [('ravi', 'search'), ('meena', 'login'), ('ravi', 'search')]
busiest user: [('asha', 3)]Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Change a namedtuple
Assign to a field of a named tuple.
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
p.x = 10Break #2
Slice a deque
Take a slice of a deque as you would from a list.
from collections import deque
d = deque([1, 2, 3, 4])
print(d[1:3])Myth vs fact
Myth
OrderedDict is needed to keep order.
Fact
All dictionaries keep insertion order since Python 3.7. OrderedDict adds move_to_end and order-sensitive equality.
Myth
deque is just a list with a different name.
Fact
It is fast at both ends but slow to index in the middle. A list is the opposite.
Myth
Reading from a defaultdict never changes it.
Fact
Reading a missing key creates it with the default value. Use key in d or d.get(key) to look without creating.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
dequeis a doubly linked list of fixed-size blocks, which gives O(1) at both ends and O(n) in the middle. It is also thread-safe forappendandpopleft, which is why it is the usual in-memory queue between threads.queue.Queueadds blocking and waiting on top. - ▸
Counteris adictsubclass, so its keys must be hashable. Counter arithmetic (+,-,&,|) keeps only positive counts.counts.total()(Python 3.10) sums all counts, andcounts.elements()repeats each item by its count. - ▸
OrderedDictstill differs fromdictin three ways:move_to_end(key, last=True)andpopitem(last=False)(pop from the front), which make an LRU cache easy; equality between two OrderedDicts is order-sensitive, butOrderedDict == dictignores order; and it is a little slower and bigger.ChainMapreads search the layers in order, but writes and deletes touch only the first map, andnew_child()pushes a fresh empty layer, which is how nested scopes are modelled.Counter.subtract()keeps zero and negative counts, while+and-between Counters drop them. - ▸
A
defaultdict's factory is called with no arguments only when a missing key is read with[];getdoes not call it. Settingdefault_factorytoNonemakes it behave like a normal dict. Printing it showsdefaultdict(<class 'list'>, {...}), so convert withdict(d)before you print or send it as JSON. - ▸
In AI and data code you will use
Counterfor token and word frequencies,defaultdict(list)to group documents by label, anddeque(maxlen=n)for a sliding window of recent messages. Be aware that a long chat history must also be trimmed by tokens, not only by message count (Phase 16).
Remember this
- 1
Counter(items)counts how often each item appears and behaves like a dictionary from item to count.Counter("banana")gives{'a': 3, 'n': 2, 'b': 1}. A missing item counts as 0 instead of raisingKeyError,most_common(n)returns thenbiggest counts,updateadds more counts, and you can add and subtract Counters. It replaces thecounts[x] = counts.get(x, 0) + 1loop (Topic 4.4). - 2
defaultdict(factory)is a dictionary that creates a missing value for you by callingfactory().defaultdict(list)gives an empty list for a new key, so grouping becomesgroups[key].append(item)with nosetdefaultand noif key not in.defaultdict(int)starts counts at 0. One warning: just reading a missing key creates it. - 3
deque(say "deck", short for double-ended queue) is a list-like container that is fast at both ends:append,appendleft,pop,popleftall take constant time, which means the same short time however long the deque is. A list is slow at the front, because every item has to shift. Adeque(maxlen=3)automatically drops the oldest item when full, perfect for "the last 3 messages". Reading from the middle of a deque by index is slow, and slices are not supported. - 4
namedtuple("Point", ["x", "y"])creates a new kind of tuple (a class, which Phase 8 teaches) where positions also have names:p.x. It is immutable, unpackable, light on memory, and prints asPoint(x=3, y=4). Use it for small, fixed records. For richer records (defaults, methods, type hints) use adataclass(Topic 8.8) ortyping.NamedTuple. - 5
OrderedDictis a dictionary that remembers insertion order, which all dictionaries have done since Python 3.7. It remains useful for two things regular dictionaries lack:move_to_end(key)to reorder, and equality that cares about order (two OrderedDicts with the same pairs in different order are not equal). Together they make it handy for small caches that evict the oldest entry. - 6
ChainMap(first, second, third)looks up a key in several dictionaries in order, without copying them: the first dictionary that has the key wins. It models layered settings: options typed when the program starts, then settings kept by your computer (environment variables), then built-in defaults. Writes go to the first dictionary only. Seeing the layers separately is its advantage over merging with|.
Explain it without notes
What problem do Counter and defaultdict solve compared with a plain dict?
Why choose deque instead of a list for a queue?
When would you use ChainMap?
How does a namedtuple differ from a regular tuple and from a dictionary?
How would you build a small LRU (least recently used) cache with the standard library?
Practice
Use Counter to find the most common letter in "mississippi" and print it with its count.
Group ["apple", "avocado", "banana", "blueberry", "cherry"] by first letter using defaultdict(list) and print it as a normal dict.
Keep only the last 3 numbers of [1, 2, 3, 4, 5, 6] using a deque with maxlen, and print them as a list.
Trade-offs
- ↔
Right tool, one more import. Each container is faster or clearer for one job but adds a name to learn. For one-off scripts a plain dict is fine; for counting and grouping the
collectionsversion is shorter and safer. - ↔
Specialised means restricted.
dequegives up cheap indexing and slicing;namedtuplegives up mutation;Counteris for hashable items only. Choose by the operation you do most.
Done when you can
Done when you can count items with
Counterand readmost_common.Done when you can group items with
defaultdict(list)and explain why reading a missing key creates it.Done when you can use a
dequeas a queue and as a sliding window withmaxlen.Done when you can create and use a
namedtuple, including_replace.Done when you can describe what
OrderedDictandChainMapadd.