Why this matters
Python's slogan is "batteries included": the standard library ships with hundreds of modules, so many problems are already solved before you start. Beginners often hand-write 15 lines of dictionary bookkeeping that Counter does in one. This lesson covers three modules that you will use constantly for working with data: collections (specialized containers), itertools (building blocks for iteration) and functools (tools that work on functions). Knowing they exist is half the value; the other half is knowing what they do underneath so you choose the right one.
collections
Counter: counting things
Counting occurrences is so common that it has its own type. A Counter is a dict subclass mapping items to counts:
from collections import Counter
votes = ["red", "blue", "red", "green", "red", "blue"]
c = Counter(votes)
print(c)
print(c["red"], c["purple"])
print(c.most_common(2))
Counter({'red': 3, 'blue': 2, 'green': 1})
3 0
[('red', 3), ('blue', 2)]
Missing items count as 0 instead of raising KeyError. most_common(n) returns the n highest counts; ties keep the order in which items were first seen (because dicts remember insertion order). Counters can be updated and combined:
c.update(["green", "green"]) # add more observations
print(c["green"]) # 3
print(Counter("hello") + Counter("world"))
3
Counter({'l': 3, 'o': 2, 'h': 1, 'e': 1, 'w': 1, 'r': 1, 'd': 1})
Hand-written, the counting loop would be counts[x] = counts.get(x, 0) + 1 for every item. Counter does the same work, in C, with a clearer name.
defaultdict: dictionaries that fill themselves in
Grouping items means "append to the list for this key, creating the list if needed". With a plain dict that needs a check every time. defaultdict takes a factory, a callable it uses to create a value for any missing key:
from collections import defaultdict
by_initial = defaultdict(list)
for name in ["ada", "alan", "grace", "guido", "linus"]:
by_initial[name[0]].append(name)
print(dict(by_initial))
{'a': ['ada', 'alan'], 'g': ['grace', 'guido'], 'l': ['linus']}
On by_initial["a"], if "a" is missing, the defaultdict calls list() to make [], stores it under "a", and returns it; .append then modifies that stored list. defaultdict(int) gives 0 for missing keys (a basic counter), defaultdict(set) an empty set.
Warning: merely reading a missing key inserts it.
if by_initial["z"]:adds"z": []. Use"z" in by_initialto test membership, and convert to a plaindictbefore handing it to other code.
deque: a queue with fast ends
A list is a dynamic array: appending or popping at the end is fast, but list.pop(0) or list.insert(0, x) must shift every other element, so it costs time proportional to the length. A deque ("deck", double-ended queue) is a linked sequence of blocks with fast operations at both ends:
from collections import deque
q = deque(["a", "b"])
q.append("c") # right end
q.appendleft("z") # left end
print(q.popleft(), q.pop(), list(q))
recent = deque(maxlen=3) # keeps only the last 3 items
for line in ["l1", "l2", "l3", "l4", "l5"]:
recent.append(line)
print(list(recent))
z c ['a', 'b']
['l3', 'l4', 'l5']
Use a deque for queues (first in, first out) and for "the last N items", like tail. Indexing into the middle of a deque is slow, so it is not a general list replacement.
namedtuple: tuples with field names
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(p.x, p[1], p)
x, y = p # still unpacks like a tuple
print(p._replace(x=10))
3 4 Point(x=3, y=4)
Point(x=10, y=4)
A namedtuple is an immutable tuple whose positions also have names, which makes code that passes records around far more readable than p[0]. (Dataclasses, in the object-oriented module, are the more flexible modern alternative.)
itertools
itertools provides fast building blocks that produce and transform iterators. Most of them are lazy: they produce items one at a time on demand, never building a full list, so they work on huge or even infinite streams.
from itertools import chain, islice, count, product, groupby, accumulate
print(list(chain([1, 2], (3, 4), "ab"))) # one iterable after another
print(list(islice(count(10), 5))) # first 5 items of an infinite counter
print(list(islice("abcdefg", 2, 6, 2))) # like slicing [2:6:2], for any iterable
print(list(product("ab", [1, 2]))) # every combination: nested loops
print(list(accumulate([1, 2, 3, 4]))) # running totals
[1, 2, 3, 4, 'a', 'b']
[10, 11, 12, 13, 14]
['c', 'e']
[('a', 1), ('a', 2), ('b', 1), ('b', 2)]
[1, 3, 6, 10]
count(10) would go on forever; islice takes just what you need. That only works because nothing is computed until list() asks for items. product(A, B) replaces two nested for loops; itertools.permutations and itertools.combinations are its cousins.
groupby: grouping consecutive items
groupby(iterable, key=None) walks the input and starts a new group whenever the key changes:
from itertools import groupby
for key, group in groupby("aaabccaa"):
print(key, list(group))
a ['a', 'a', 'a']
b ['b']
c ['c', 'c']
a ['a', 'a']
Notice a appears twice: groupby only merges adjacent equal items. To group all equal items, sort by the same key first. Each group is an iterator that shares the underlying input with groupby, so consume it before moving to the next group (convert to a list if you need to keep it).
words = ["apple", "bob", "avocado", "banana", "cherry"]
for letter, grp in groupby(sorted(words), key=lambda w: w[0]):
print(letter, list(grp))
a ['apple', 'avocado']
b ['banana', 'bob']
c ['cherry']
functools
lru_cache: remembering results
Some functions are called again and again with the same arguments. Memoization stores each result the first time and returns the stored value afterwards. functools.lru_cache adds this to any function:
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(100))
print(fib.cache_info())
354224848179261915075
CacheInfo(hits=98, misses=101, maxsize=None, currsize=101)
Without the cache, fib(100) would make more calls than there are atoms in a mountain; with it, each n is computed once. The @lru_cache(...) line is a decorator: it replaces fib with a wrapper that checks a dictionary keyed by the arguments before calling the original. (You will write decorators yourself in module 13; using them only requires the @ line.) Because the arguments are dictionary keys, they must be hashable: numbers, strings and tuples are; lists and dicts are not. maxsize=128 (the default) keeps only the 128 most recently used results ("LRU" = least recently used is evicted); maxsize=None (or functools.cache) keeps everything.
partial: pre-filling arguments
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
print(square(7), list(map(square, [1, 2, 3])))
49 [1, 4, 9]
partial returns a new callable with some arguments fixed, handy for key= and map arguments.
reduce: folding a sequence
reduce(f, items, start) combines items pairwise from the left: f(f(f(start, a), b), c).
from functools import reduce
import operator
print(reduce(operator.mul, [1, 2, 3, 4, 5], 1)) # 5 factorial
120
For sums, sum() is clearer, and math.prod() exists for products. Reach for reduce only when no named built-in fits.
Common mistakes
- Using groupby on unsorted data and getting the same key in several groups. Sort first, by the same key.
- Reusing a groupby group after advancing.
groups = list(groupby(data))leaves every group iterator empty, because advancinggroupbyconsumes the shared input. Use[(k, list(g)) for k, g in groupby(data)]. - Accidental keys in defaultdicts from lookups, as described above.
list.pop(0)in a loop on a large list: quadratic time. Usedeque.popleft().- Caching a function with unhashable arguments (
TypeError: unhashable type: 'list') or with side effects: a cached function's body does not run again, so its prints or file writes happen only once.
Recap
Countercounts,defaultdictcreates missing values with a factory,dequehas fast operations at both ends,namedtuplegives tuple fields names.itertoolsfunctions are lazy iterator building blocks:chain,islice,count,product,accumulate, andgroupbyfor consecutive runs.lru_cachememoizes by hashable arguments;partialpre-fills arguments;reducefolds a sequence.
Going further: read the "Itertools Recipes" section at the end of the itertools documentation, a collection of short, clever functions built from these pieces. Try help(Counter.subtract) and help(deque.rotate).